From 68bc5bb84c94600ed6899ab3ac7d2aa704c59470 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Wed, 24 Jun 2026 16:16:10 -0700 Subject: [PATCH 1/5] Add performance and management fees --- solidity/src/FCMVault.sol | 144 ++++++++++++++++++++++++- solidity/test/FCMVault.t.sol | 204 +++++++++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+), 5 deletions(-) diff --git a/solidity/src/FCMVault.sol b/solidity/src/FCMVault.sol index 8d58931..e8b122c 100644 --- a/solidity/src/FCMVault.sol +++ b/solidity/src/FCMVault.sol @@ -43,9 +43,16 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { // @dev See https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/ERC4626.sol#L32-L39 uint8 internal constant DECIMALS_OFFSET = 6; - /// @dev Basis-points denominator for `maxSlippageBps`. + /// @dev Basis-points denominator (10_000 = 100%). uint256 internal constant BPS = 10_000; + /// @dev Seconds in a year, for the time-based management fee accrual. + uint256 internal constant SECONDS_PER_YEAR = 365 days; + /// @dev Hard cap on the management fee (10%/yr) — admin cannot exceed. + uint256 internal constant MAX_MANAGEMENT_FEE_BPS = 1_000; + /// @dev Hard cap on the performance fee (50%) — admin cannot exceed. + uint256 internal constant MAX_PERFORMANCE_FEE_BPS = 5_000; + // @dev Address of the loan token (inner vault asset) IERC20 public immutable loanToken; // @dev Address of the yield token (inner vault share) @@ -131,6 +138,44 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { error EmergencyRecoveryActive(); error EmergencyRecoveryNotReady(); + // ── Management & performance fees ────────────────────────────────────── + /// @notice Flat yearly management fee on NAV, in basis points. 0 = off. + /// @dev Accrued as `NAV * bps * Δt` (snapshot, not time-weighted); kept + /// tight by frequent accrual. + uint256 public managementFeeBps; + /// @notice Performance fee on per-share gains above the high-water mark, in + /// basis points. 0 = off. + /// @dev Crystallizes on UNREALIZED, oracle-marked NAV and is triggerable by + /// anyone via `accrueFees`; bounded by the all-time HWM and the 50% cap. + uint256 public performanceFeeBps; + /// @notice Recipient of minted fee shares. Must hold `EARLY_ACCESS_ROLE` to + /// receive them; if unset or not allowlisted, fee accrual is skipped + /// (never reverts) so core flows can't be bricked. + address public feeRecipient; + /// @notice Timestamp of the last fee accrual, for the time-based management fee. + uint256 public lastFeeAccrual; + /// @notice High-water mark for the performance fee, as WETH-per-share scaled + /// by WAD (`NAV * WAD / claims`). Flow-neutral, strict all-time peak. + uint256 public perfHighWaterMark; + + /// @notice Emitted when the admin updates the management fee (old + new). + event ManagementFeeSet(uint256 oldBps, uint256 newBps); + /// @notice Emitted when the admin updates the performance fee (old + new). + event PerformanceFeeSet(uint256 oldBps, uint256 newBps); + /// @notice Emitted when the admin updates the fee recipient (old + new). + event FeeRecipientSet(address indexed oldRecipient, address indexed newRecipient); + /// @notice Emitted when fees are accrued and shares minted to the recipient. + /// @param recipient Account that received the minted fee shares. + /// @param managementFee Management fee accrued this call, in asset (WETH) terms. + /// @param performanceFee Performance fee accrued this call, in asset (WETH) terms. + /// @param feeShares Shares minted to `recipient` (dilution). + event FeesAccrued( + address indexed recipient, uint256 managementFee, uint256 performanceFee, uint256 feeShares + ); + + /// @dev Thrown when a fee rate above its hard cap is set. + error InvalidFee(); + struct InitParams { IERC20 collateral; IERC20 loanToken; @@ -241,6 +286,10 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { recoveryDelay = p.recoveryDelay; maxSlippageBps = 100; // 1% default; admin retunes per pool depth. + lastFeeAccrual = block.timestamp; + // Seed the HWM at the starting price-per-share so the first deposit isn't counted as performance. + perfHighWaterMark = MarketLib.WAD / (10 ** DECIMALS_OFFSET); + _grantRole(DEFAULT_ADMIN_ROLE, p.admin); } @@ -259,6 +308,88 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { return expectedOut.mulDiv(BPS - maxSlippageBps, BPS); } + /// @notice Set the management fee rate (basis points), capped at `MAX_MANAGEMENT_FEE_BPS`. + /// @dev Accrues at the OLD rate first so the change isn't retroactive. + function setManagementFeeBps(uint256 newBps) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newBps > MAX_MANAGEMENT_FEE_BPS) revert InvalidFee(); + _accrueFees(); + emit ManagementFeeSet(managementFeeBps, newBps); + managementFeeBps = newBps; + } + + /// @notice Set the performance fee rate (basis points), capped at `MAX_PERFORMANCE_FEE_BPS`. + /// @dev Accrues at the OLD rate first so the change isn't retroactive. + function setPerformanceFeeBps(uint256 newBps) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newBps > MAX_PERFORMANCE_FEE_BPS) revert InvalidFee(); + _accrueFees(); + emit PerformanceFeeSet(performanceFeeBps, newBps); + performanceFeeBps = newBps; + } + + /// @notice Set the fee recipient. Accrues to the old recipient first. + /// @dev The recipient must hold `EARLY_ACCESS_ROLE` to receive minted fee + /// shares; if it doesn't, accrual silently skips (see `_accrueFees`). + function setFeeRecipient(address newRecipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + _accrueFees(); + emit FeeRecipientSet(feeRecipient, newRecipient); + feeRecipient = newRecipient; + } + + /// @notice Permissionlessly accrue fees up to the current block (mints fee + /// shares to the recipient). Lets a keeper tick the management fee + /// during idle stretches so it tracks NAV-over-time more closely. + function accrueFees() external { + _accrueFees(); + } + + /// @dev Accrue management + performance fees and mint the corresponding + /// shares to `feeRecipient` (dilution — no assets leave the vault). + /// Always accrues market interest first so NAV is fresh. No-ops once + /// `recovered`. Skips minting (never reverts) when the recipient is + /// unset or not allowlisted, so core flows can't be bricked. + function _accrueFees() internal { + if (recovered) return; + + market.accrueInterest(); + uint256 nav = totalAssets(); + uint256 claims = _totalClaims(); + uint256 pps = nav.mulDiv(MarketLib.WAD, claims); + + address recipient = feeRecipient; + if (recipient != address(0) && hasRole(EARLY_ACCESS_ROLE, recipient) && nav > 0) { + uint256 elapsed = block.timestamp - lastFeeAccrual; + + uint256 managementFee; + if (managementFeeBps > 0 && elapsed > 0) { + managementFee = nav.mulDiv(managementFeeBps * elapsed, BPS * SECONDS_PER_YEAR); + } + + uint256 performanceFee; + if (performanceFeeBps > 0 && pps > perfHighWaterMark) { + // NAV gain above the high-water mark, then the fee share of it. + uint256 gain = (pps - perfHighWaterMark).mulDiv(claims, MarketLib.WAD); + performanceFee = gain.mulDiv(performanceFeeBps, BPS); + } + + uint256 feeAssets = managementFee + performanceFee; + if (feeAssets > 0 && feeAssets < nav) { + // Mint shares worth `feeAssets` at the post-mint price (dilution). + uint256 feeShares = feeAssets.mulDiv(claims, nav + 1 - feeAssets); + if (feeShares > 0) { + _mint(recipient, feeShares); + emit FeesAccrued(recipient, managementFee, performanceFee, feeShares); + } + } + } + + // Advance clock + HWM unconditionally (even when the mint was skipped) so + // fees meter from when they're enabled, not retroactively — the fee setters + // accrue first, pinning these to now. Gating them on the mint would + // back-charge holders from deploy. + lastFeeAccrual = block.timestamp; + if (pps > perfHighWaterMark) perfHighWaterMark = pps; + } + // @dev Defines the decimal offset between vault assets and shares. Larger offsets make inflation attacks more expensive. // @dev See https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/ERC4626.sol#L32-L39 function _decimalsOffset() internal pure override returns (uint8) { @@ -325,7 +456,8 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { // Freeze deposits while a recovery is pending (recoveryValidAt != 0) or done // (recovered) — don't let new funds in ahead of a sweep. Redeems stay open. if (recoveryValidAt != 0 || recovered) revert EmergencyRecoveryActive(); - market.accrueInterest(); + // Accrue fees first so the deposit prices in at the post-fee share price. + _accrueFees(); uint256 navBefore = totalAssets(); if (navBefore + assets > maxTvl) { @@ -396,7 +528,8 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { // 2. Decremement the redeemer's allowance by the amount redeemed. if (msg.sender != owner) _spendAllowance(owner, msg.sender, shares); - market.accrueInterest(); + // Accrue fees first so the redeemer bears their share of accrued fees. + _accrueFees(); IERC20 assetToken = IERC20(asset()); uint256 assetBefore = assetToken.balanceOf(address(this)); @@ -503,7 +636,8 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { if (shares == 0) return (0, 0); if (msg.sender != owner) _spendAllowance(owner, msg.sender, shares); - market.accrueInterest(); + // Accrue fees first so the redeemer bears their share of accrued fees. + _accrueFees(); uint256 claims = _totalClaims(); // Caller repays the pro-rata debt slice (rounded up — never under-repays); @@ -547,7 +681,7 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { // error so the off-chain rebalancer surfaces it and stops, rather than // silently no-op'ing and running indefinitely. if (recovered) revert EmergencyRecoveryActive(); - market.accrueInterest(); + _accrueFees(); uint256 currentDebt = market.debt(); uint256 maxBorrow = market.maxBorrow(); // independent of current debt balance // we compute inline here rather than use MarketLib.healthFactor to save a SLOAD diff --git a/solidity/test/FCMVault.t.sol b/solidity/test/FCMVault.t.sol index 33df041..fae79ab 100644 --- a/solidity/test/FCMVault.t.sol +++ b/solidity/test/FCMVault.t.sol @@ -1350,4 +1350,208 @@ contract FCMVaultTest is Test { vm.expectRevert(); vault.cancelEmergencyRecovery(); } + + // ---- management & performance fees ------------------------------------ + + function _enableFees(address recipient, uint256 mgmtBps, uint256 perfBps) internal { + _allow(recipient); + vm.startPrank(admin); + vault.setFeeRecipient(recipient); + vault.setManagementFeeBps(mgmtBps); + vault.setPerformanceFeeBps(perfBps); + vm.stopPrank(); + } + + /// @notice With no recipient/rates configured, no fee shares are ever minted. + function test_Fees_OffByDefault() public { + _depositFor(user, 1 ether); + vm.warp(block.timestamp + 365 days); + vault.accrueFees(); + assertEq(vault.totalSupply(), vault.balanceOf(user), "no fee shares minted"); + } + + /// @notice Only the admin can set fees, and rates are capped. + function test_Fees_SetAccessAndCaps() public { + vm.prank(user); + vm.expectRevert(); + vault.setManagementFeeBps(100); + + vm.startPrank(admin); + vm.expectRevert(FCMVault.InvalidFee.selector); + vault.setManagementFeeBps(1_001); // > MAX_MANAGEMENT_FEE_BPS (10%) + vm.expectRevert(FCMVault.InvalidFee.selector); + vault.setPerformanceFeeBps(5_001); // > MAX_PERFORMANCE_FEE_BPS (50%) + vault.setManagementFeeBps(200); + vault.setPerformanceFeeBps(2_000); + vm.stopPrank(); + assertEq(vault.managementFeeBps(), 200, "mgmt set"); + assertEq(vault.performanceFeeBps(), 2_000, "perf set"); + } + + /// @notice Management fee accrues ~ rate * NAV * elapsed, minted as shares. + function test_Fees_ManagementAccrual() public { + address feeRcpt = address(0xFEE5); + _enableFees(feeRcpt, 200, 0); // 2%/yr, no perf + _depositFor(user, 1 ether); + + uint256 nav = vault.totalAssets(); + vm.warp(block.timestamp + 365 days); + vault.accrueFees(); + + uint256 feeShares = vault.balanceOf(feeRcpt); + assertGt(feeShares, 0, "fee shares minted"); + assertApproxEqRel( + vault.convertToAssets(feeShares), nav * 200 / 10_000, 2e16, "mgmt ~2% NAV" + ); + } + + /// @notice Performance fee ~ rate * gain above HWM; not double-charged. + function test_Fees_PerformanceAccrualAndHWM() public { + address feeRcpt = address(0xFEE5); + _enableFees(feeRcpt, 0, 2_000); // 20% perf, no mgmt + _depositFor(user, 1 ether); + + uint256 navBefore = vault.totalAssets(); + // Simulate yield: +10% FUSDEV in the vault -> NAV rises. + MockERC20(address(FUSDEV)).mint(address(vault), FUSDEV.balanceOf(address(vault)) / 10); + uint256 gain = vault.totalAssets() - navBefore; + assertGt(gain, 0, "nav rose"); + + vault.accrueFees(); + uint256 feeShares = vault.balanceOf(feeRcpt); + assertGt(feeShares, 0, "perf fee minted"); + assertApproxEqRel( + vault.convertToAssets(feeShares), gain * 2_000 / 10_000, 3e16, "perf ~20% gain" + ); + + // Second accrual with no new gain -> no additional fee (HWM holds). + uint256 prev = vault.balanceOf(feeRcpt); + vault.accrueFees(); + assertEq(vault.balanceOf(feeRcpt), prev, "no double-charge above HWM"); + } + + /// @notice No performance fee while below the high-water mark (drawdown). + function test_Fees_NoPerfInDrawdown() public { + address feeRcpt = address(0xFEE5); + _enableFees(feeRcpt, 0, 2_000); + _depositFor(user, 1 ether); + + // First gain sets the HWM and charges. + MockERC20(address(FUSDEV)).mint(address(vault), FUSDEV.balanceOf(address(vault)) / 10); + vault.accrueFees(); + uint256 afterFirst = vault.balanceOf(feeRcpt); + assertGt(afterFirst, 0, "charged on first gain"); + + // Drawdown: drop the yield-token price so NAV falls below the HWM. + yieldOracle.setPrice(YIELD_PRICE / 2); + vault.accrueFees(); + assertEq(vault.balanceOf(feeRcpt), afterFirst, "no fee in drawdown"); + } + + /// @notice If the recipient isn't allowlisted, accrual SKIPS (no mint) and + /// core flows are NOT bricked. + function test_Fees_RecipientNotAllowlistedSkips() public { + address feeRcpt = address(0xBAD); + vm.startPrank(admin); + vault.setFeeRecipient(feeRcpt); // deliberately NOT allowlisted + vault.setManagementFeeBps(200); + vault.setPerformanceFeeBps(2_000); + vm.stopPrank(); + + _depositFor(user, 1 ether); // must not revert + vm.warp(block.timestamp + 365 days); + vault.accrueFees(); // must not revert + assertEq(vault.balanceOf(feeRcpt), 0, "no shares minted to non-allowlisted recipient"); + } + + /// @notice Once recovered, fees stop accruing. + function test_Fees_NoAccrualAfterRecovered() public { + address feeRcpt = address(0xFEE5); + _enableFees(feeRcpt, 200, 0); + _depositFor(user, 1 ether); + + MockERC20(address(PYUSD0)).mint(admin, 1_000_000 ether); + vm.prank(admin); + vault.scheduleEmergencyRecovery(); + vm.warp(block.timestamp + vault.recoveryDelay()); + vm.startPrank(admin); + PYUSD0.approve(address(vault), type(uint256).max); + vault.executeEmergencyRecovery(); + vm.stopPrank(); + + uint256 prev = vault.balanceOf(feeRcpt); + vm.warp(block.timestamp + 365 days); + vault.accrueFees(); + assertEq(vault.balanceOf(feeRcpt), prev, "no accrual after recovered"); + } + + /// @notice Both fee legs in one accrual sum into a single dilution mint, and + /// the recipient's claim ~= management + performance fee. + function test_Fees_CombinedAccrual() public { + address feeRcpt = address(0xFEE5); + _enableFees(feeRcpt, 200, 2_000); // 2%/yr mgmt + 20% perf + _depositFor(user, 1 ether); + + uint256 nav0 = vault.totalAssets(); + MockERC20(address(FUSDEV)).mint(address(vault), FUSDEV.balanceOf(address(vault)) / 10); + uint256 navAccrue = vault.totalAssets(); + uint256 gain = navAccrue - nav0; + vm.warp(block.timestamp + 365 days); + + vault.accrueFees(); + uint256 feeShares = vault.balanceOf(feeRcpt); + assertGt(feeShares, 0, "combined fee minted"); + + uint256 expected = (navAccrue * 200 / 10_000) + (gain * 2_000 / 10_000); + assertApproxEqRel(vault.convertToAssets(feeShares), expected, 3e16, "claim ~= mgmt + perf"); + } + + /// @notice Enabling fees after a gain/elapsed window must NOT retroactively + /// charge the pre-enable period (guards the unconditional clock/HWM + /// advance; a gating change would make this fail). + function test_Fees_NoRetroactiveChargeOnEnable() public { + address feeRcpt = address(0xFEE5); + _depositFor(user, 1 ether); // fees OFF + + MockERC20(address(FUSDEV)).mint(address(vault), FUSDEV.balanceOf(address(vault)) / 10); + vm.warp(block.timestamp + 365 days); + + _enableFees(feeRcpt, 200, 2_000); + vault.accrueFees(); + assertEq(vault.balanceOf(feeRcpt), 0, "no retroactive charge for the pre-enable window"); + } + + /// @notice Fees accrue on the redeem path, and a de-allowlisted recipient + /// skips minting without bricking the redeem. + function test_Fees_AccruesOnRedeemAndDeAllowlistDoesNotBrick() public { + address feeRcpt = address(0xFEE5); + _enableFees(feeRcpt, 200, 0); // mgmt only + uint256 shares = _depositFor(user, 1 ether); + + vm.warp(block.timestamp + 30 days); + vm.prank(user); + vault.redeem(shares / 2, user, user); + assertGt(vault.balanceOf(feeRcpt), 0, "fee accrued via the redeem path"); + + bytes32 role = vault.EARLY_ACCESS_ROLE(); + vm.prank(admin); + vault.revokeRole(role, feeRcpt); + vm.warp(block.timestamp + 30 days); + vm.prank(user); + vault.redeem(shares / 4, user, user); // must not revert + } + + /// @notice `accrueFees` is permissionless; the fee setters are admin-gated. + function test_Fees_AccruePermissionlessSettersGated() public { + _depositFor(user, 1 ether); + vm.prank(stranger); + vault.accrueFees(); // permissionless + + vm.prank(stranger); + vm.expectRevert(); + vault.setFeeRecipient(stranger); + vm.prank(stranger); + vm.expectRevert(); + vault.setManagementFeeBps(100); + } } From f31bf6f9b133f26142f86535fed9170e3c65b155 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 25 Jun 2026 21:43:02 -0700 Subject: [PATCH 2/5] Use precise language in code comments --- solidity/src/FCMVault.sol | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/solidity/src/FCMVault.sol b/solidity/src/FCMVault.sol index e8b122c..6e5bd8e 100644 --- a/solidity/src/FCMVault.sol +++ b/solidity/src/FCMVault.sol @@ -140,8 +140,7 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { // ── Management & performance fees ────────────────────────────────────── /// @notice Flat yearly management fee on NAV, in basis points. 0 = off. - /// @dev Accrued as `NAV * bps * Δt` (snapshot, not time-weighted); kept - /// tight by frequent accrual. + /// @dev Linear accrual of the annual rate; bounded by the 10% cap. uint256 public managementFeeBps; /// @notice Performance fee on per-share gains above the high-water mark, in /// basis points. 0 = off. @@ -357,7 +356,17 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { address recipient = feeRecipient; if (recipient != address(0) && hasRole(EARLY_ACCESS_ROLE, recipient) && nav > 0) { + // Bill exactly `rate * Δt` since the last accrual, then advance the clock + // (accrual is irregular: every interaction + permissionless accrueFees). + // The billable gap is capped at one year, so the fee is + // provably <= the annual rate `r` (= bps/1e4) however long the vault + // sits unaccrued - idle time past a year is forgiven, bounding a single + // catch-up dilution after long dormancy. Within a year the realized drag + // lies in `[1 - e^(-r), r]`: `r` at one accrual/year, `1 - e^(-r)` in the + // continuous limit (negligible span <= ~r^2/2: ~0.02% at bps=200, + // ~0.48% at the 1000 cap). uint256 elapsed = block.timestamp - lastFeeAccrual; + if (elapsed > SECONDS_PER_YEAR) elapsed = SECONDS_PER_YEAR; uint256 managementFee; if (managementFeeBps > 0 && elapsed > 0) { From 0b01742feacbc9b35697c6660c879e67ed1f2a4e Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 26 Jun 2026 01:53:28 -0700 Subject: [PATCH 3/5] Update tests --- solidity/src/FCMVault.sol | 6 ++- solidity/test/FCMVault.t.sol | 76 ++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/solidity/src/FCMVault.sol b/solidity/src/FCMVault.sol index 6e5bd8e..53a482a 100644 --- a/solidity/src/FCMVault.sol +++ b/solidity/src/FCMVault.sol @@ -375,7 +375,11 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { uint256 performanceFee; if (performanceFeeBps > 0 && pps > perfHighWaterMark) { - // NAV gain above the high-water mark, then the fee share of it. + // Fee on the gain in pps above the all-time HWM. pps is UNREALIZED and + // oracle-marked, so a transient mark move can crystallize a fee on paper + // profit that later reverses - kept, not refunded. The mint goes to the + // recipient, not the triggerer, so a permissionless accrueFees call can't + // pay its caller; the strict HWM charges net all-time highs only. uint256 gain = (pps - perfHighWaterMark).mulDiv(claims, MarketLib.WAD); performanceFee = gain.mulDiv(performanceFeeBps, BPS); } diff --git a/solidity/test/FCMVault.t.sol b/solidity/test/FCMVault.t.sol index fae79ab..405b7d2 100644 --- a/solidity/test/FCMVault.t.sol +++ b/solidity/test/FCMVault.t.sol @@ -1554,4 +1554,80 @@ contract FCMVaultTest is Test { vm.expectRevert(); vault.setManagementFeeBps(100); } + + /// @notice The management accrual clamps the billable gap at one year: a + /// multi-year dormancy bills a single year's fee, not the full + /// elapsed span (unclamped, 3 years at 2%/yr would dilute ~6%). + function test_Fees_ManagementClampedAtOneYear() public { + address feeRcpt = address(0xFEE5); + _enableFees(feeRcpt, 200, 0); // 2%/yr, no perf + _depositFor(user, 1 ether); + + uint256 nav = vault.totalAssets(); + vm.warp(block.timestamp + 3 * 365 days); + vault.accrueFees(); + + assertApproxEqRel( + vault.convertToAssets(vault.balanceOf(feeRcpt)), + nav * 200 / 10_000, + 2e16, + "one year billed, not three" + ); + } + + /// @notice The HWM is seeded at the initial price-per-share, so the first + /// deposit is not counted as performance — an immediate accrual + /// after the first deposit mints nothing. + function test_Fees_NoPerfOnFirstDeposit() public { + address feeRcpt = address(0xFEE5); + _enableFees(feeRcpt, 0, 2_000); // perf only + _depositFor(user, 1 ether); + + vault.accrueFees(); + assertEq(vault.balanceOf(feeRcpt), 0, "first deposit is not performance"); + } + + /// @notice The dilution gross-up delivers the true rate: a 10%/yr management + /// fee over one year leaves the recipient holding exactly 10% of NAV + /// (not 10/1.1 ≈ 9.09%, which a non-grossed-up mint would give). + function test_Fees_GrossUpDeliversTrueRate() public { + address feeRcpt = address(0xFEE5); + _enableFees(feeRcpt, 1_000, 0); // 10%/yr, no perf + _depositFor(user, 1 ether); + + vm.warp(block.timestamp + 365 days); + vault.accrueFees(); + + // Tight tolerance: a naive (non-grossed) mint would land ~9.09%, far outside. + uint256 recipientValue = vault.convertToAssets(vault.balanceOf(feeRcpt)); + assertApproxEqRel(recipientValue, vault.totalAssets() / 10, 1e15, "recipient holds true 10%"); + } + + /// @notice Fees accrue on the `rebalance` path — the hook runs before the + /// dead-band no-op check, so even a no-op rebalance meters the fee. + function test_Fees_AccruesOnRebalance() public { + address feeRcpt = address(0xFEE5); + _enableFees(feeRcpt, 200, 0); // mgmt only + _depositFor(user, 1 ether); + + vm.warp(block.timestamp + 30 days); + vault.rebalance(false); // no-op inside band, but accrues fees + assertGt(vault.balanceOf(feeRcpt), 0, "fee accrued via the rebalance path"); + } + + /// @notice Fees accrue on the `redeemInKind` escape-hatch path. + function test_Fees_AccruesOnRedeemInKind() public { + address feeRcpt = address(0xFEE5); + _enableFees(feeRcpt, 200, 0); // mgmt only + uint256 shares = _depositFor(user, 1 ether); + + vm.warp(block.timestamp + 30 days); + MockERC20(address(PYUSD0)).mint(user, 1_000_000 ether); + vm.startPrank(user); + PYUSD0.approve(address(vault), type(uint256).max); + vault.redeemInKind(shares / 2, user, user); + vm.stopPrank(); + + assertGt(vault.balanceOf(feeRcpt), 0, "fee accrued via the redeemInKind path"); + } } From 50e87fc7f12fd35e4c970c42dcaa494ae411d407 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 26 Jun 2026 01:55:41 -0700 Subject: [PATCH 4/5] Fix formatting --- solidity/test/FCMVault.t.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/solidity/test/FCMVault.t.sol b/solidity/test/FCMVault.t.sol index 405b7d2..a1904f5 100644 --- a/solidity/test/FCMVault.t.sol +++ b/solidity/test/FCMVault.t.sol @@ -1600,7 +1600,9 @@ contract FCMVaultTest is Test { // Tight tolerance: a naive (non-grossed) mint would land ~9.09%, far outside. uint256 recipientValue = vault.convertToAssets(vault.balanceOf(feeRcpt)); - assertApproxEqRel(recipientValue, vault.totalAssets() / 10, 1e15, "recipient holds true 10%"); + assertApproxEqRel( + recipientValue, vault.totalAssets() / 10, 1e15, "recipient holds true 10%" + ); } /// @notice Fees accrue on the `rebalance` path — the hook runs before the From 86dee9690af21559815a61afa5a431af6a12169c Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 3 Jul 2026 08:42:37 -0700 Subject: [PATCH 5/5] Use 'asset' not 'WETH' in fee docs; extend drawdown fee test --- solidity/src/FCMVault.sol | 9 ++++++--- solidity/test/FCMVault.t.sol | 6 ++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/solidity/src/FCMVault.sol b/solidity/src/FCMVault.sol index 53a482a..5d01218 100644 --- a/solidity/src/FCMVault.sol +++ b/solidity/src/FCMVault.sol @@ -153,8 +153,11 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { address public feeRecipient; /// @notice Timestamp of the last fee accrual, for the time-based management fee. uint256 public lastFeeAccrual; - /// @notice High-water mark for the performance fee, as WETH-per-share scaled + /// @notice High-water mark for the performance fee, as asset-per-share scaled /// by WAD (`NAV * WAD / claims`). Flow-neutral, strict all-time peak. + /// Vault-wide (one mark for all holders): a depositor entering below it + /// rides the recovery back up fee-free — accepted by design in lieu of + /// per-user-HWM accounting. uint256 public perfHighWaterMark; /// @notice Emitted when the admin updates the management fee (old + new). @@ -165,8 +168,8 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { event FeeRecipientSet(address indexed oldRecipient, address indexed newRecipient); /// @notice Emitted when fees are accrued and shares minted to the recipient. /// @param recipient Account that received the minted fee shares. - /// @param managementFee Management fee accrued this call, in asset (WETH) terms. - /// @param performanceFee Performance fee accrued this call, in asset (WETH) terms. + /// @param managementFee Management fee accrued this call, in asset terms. + /// @param performanceFee Performance fee accrued this call, in asset terms. /// @param feeShares Shares minted to `recipient` (dilution). event FeesAccrued( address indexed recipient, uint256 managementFee, uint256 performanceFee, uint256 feeShares diff --git a/solidity/test/FCMVault.t.sol b/solidity/test/FCMVault.t.sol index a1904f5..27d37b5 100644 --- a/solidity/test/FCMVault.t.sol +++ b/solidity/test/FCMVault.t.sol @@ -1446,6 +1446,12 @@ contract FCMVaultTest is Test { yieldOracle.setPrice(YIELD_PRICE / 2); vault.accrueFees(); assertEq(vault.balanceOf(feeRcpt), afterFirst, "no fee in drawdown"); + + // Partial recovery: price rises off the low but pps stays below the HWM — + // rebuilding toward the prior peak still incurs no fee. + yieldOracle.setPrice((YIELD_PRICE * 3) / 4); + vault.accrueFees(); + assertEq(vault.balanceOf(feeRcpt), afterFirst, "no fee on sub-HWM recovery"); } /// @notice If the recipient isn't allowlisted, accrual SKIPS (no mint) and