From 417c6027dea3c8740d25ae6b68100bf7552d1868 Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Thu, 13 Aug 2026 09:56:56 +0200 Subject: [PATCH 1/4] fix: keep the held performance entitlement nominal across flow rebases (3F-481) The Cantina #32 zero-share hold preserved a positive pending entitlement, but the flow rebase scaled it by the supply ratio: a deposit into a dust-NAV vault could turn a one-atom held gain into a material fee charged against the fresh principal. The gain now stays nominal across flows (like heldManagementFeeAssets), falling back to the supply-scaled read only once it outgrows half the post-flow NAV so the mark cannot degenerate to zero on a draining flow. Claude-Session: https://claude.ai/code/session_01AZowKnphCAYPA1MoEGXLjd --- src/interfaces/manager/IPositionManager.sol | 5 +- .../manager/base/IPositionManagerAdmin.sol | 3 +- src/libs/manager/LibStorage.sol | 57 ++++-- src/manager/base/PositionManagerAdmin.sol | 3 +- src/manager/base/PositionManagerBase.sol | 4 +- src/manager/base/PositionManagerLP.sol | 4 +- test/libs/manager/LibStorage.t.sol | 40 ++++ .../manager/PositionManagerFeeReference.t.sol | 181 ++++++++++++++++-- 8 files changed, 257 insertions(+), 40 deletions(-) diff --git a/src/interfaces/manager/IPositionManager.sol b/src/interfaces/manager/IPositionManager.sol index 84183b20..63af17f4 100644 --- a/src/interfaces/manager/IPositionManager.sol +++ b/src/interfaces/manager/IPositionManager.sol @@ -79,7 +79,7 @@ interface IPositionManager is IPositionManagerAdmin, IPositionManagerRebalancing /// `lastCollat = lastTotalAssets + lastDebt`, read `lastDebt()` alongside this value. The /// reference advances to the current state only when a positive basis crystallizes; while /// it is held (non-positive basis) or after flow rebases it deviates from the live NAV by - /// the carried pending basis. + /// the carried pending basis (or sits below it by a preserved pending gain). /// @return feeRecipient The address that receives fee payments /// @return managementFee The management fee rate in basis points per 365 days, charged on the /// aggregate collateral of non-bad-debt positions (not NAV) and capped at `totalAssets`. @@ -112,7 +112,8 @@ interface IPositionManager is IPositionManagerAdmin, IPositionManagerRebalancing /// @notice Returns the debt component of the performance reference. /// @dev Combined with `feeData().lastTotalAssets`, callers can reconstruct /// `lastCollat = lastTotalAssets + lastDebt`. While the reference is held (non-positive - /// pending basis) this is lower than the live debt by the carried debt cost. A value of + /// pending basis) this is lower than the live debt by the carried debt cost, or above + /// it by a preserved pending gain (see `LibStorage.rebaseSnapshot`). A value of /// zero is the bootstrap sentinel and means the next accrual will skip the performance fee /// and seed this slot. /// @return The reference debt for the performance-fee basis diff --git a/src/interfaces/manager/base/IPositionManagerAdmin.sol b/src/interfaces/manager/base/IPositionManagerAdmin.sol index 21afbefd..796683af 100644 --- a/src/interfaces/manager/base/IPositionManagerAdmin.sol +++ b/src/interfaces/manager/base/IPositionManagerAdmin.sol @@ -118,7 +118,8 @@ interface IPositionManagerAdmin { /// @dev Only callable by the owner. Escape hatch for a permanent drawdown or a realized /// liquidation loss: the held reference would otherwise suppress performance fees until the /// pool recovers past the old mark, which may never happen. Fees accrue first, so a positive - /// pending basis crystallizes to the current recipient at the configured rate; the reset + /// pending basis crystallizes to the current recipient at the configured rate (a held + /// entitlement that rounds to zero fee shares is forgiven without minting); the reset /// itself never charges past gains, it forgives the carried negative basis and future gains /// are charged from the current state onward. /// diff --git a/src/libs/manager/LibStorage.sol b/src/libs/manager/LibStorage.sol index 8b91e0d0..9a09f6c7 100644 --- a/src/libs/manager/LibStorage.sol +++ b/src/libs/manager/LibStorage.sol @@ -67,8 +67,8 @@ struct RebalanceConfig { /// Together with `lastDebt` it encodes the reference loan-to-value /// `LTV_ref = lastDebt / (lastTotalAssets + lastDebt)` that anchors the performance-fee /// basis. The reference advances to the current state only when a positive basis -/// crystallizes (or on bootstrap); on capital flows it is rebased so the pending per-share -/// basis is preserved (see `rebaseSnapshot`). It therefore only matches the live NAV right +/// crystallizes (or on bootstrap); on capital flows it is rebased so the pending basis is +/// preserved (see `rebaseSnapshot`). It therefore only matches the live NAV right /// after a crystallizing accrual; while the reference is held it deviates from the live /// NAV by the carried (negative) pending basis, or sits below it by a preserved positive /// pending gain (see `rebaseSnapshot`); after a seizure loss, flows convert the @@ -92,7 +92,8 @@ struct RebalanceConfig { /// `lastTotalAssets` to reconstruct `lastCollat = lastTotalAssets + lastDebt` for the /// levered-slice performance fee basis. Advanced on crystallization and rebased on flows /// alongside `lastTotalAssets` (see `rebaseSnapshot`), so while the reference is held it is -/// lower than the live debt by the carried debt cost. A value of zero acts as a bootstrap +/// lower than the live debt by the carried debt cost (or higher by a preserved pending +/// gain, see `rebaseSnapshot`). A value of zero acts as a bootstrap /// sentinel: the first accrual after upgrade (or any other time `lastDebt` is zero) skips /// the performance fee and seeds this slot with the current debt. Subsequent accruals /// charge the new basis normally. @@ -180,8 +181,9 @@ library LibStorage { } /// @dev Rebases the performance reference (`lastTotalAssets`, `lastDebt`) across a capital - /// flow (deposit, withdraw, burn, rebalance, module add/remove) so the pending per-share - /// performance basis is preserved instead of being reset to zero. + /// flow (deposit, withdraw, burn, rebalance, module add/remove) so the pending + /// performance basis is preserved instead of being reset to zero (the debt carry per + /// share, a held positive gain nominally). /// /// The reference encodes `LTV_ref = lastDebt / (lastTotalAssets + lastDebt)`; the pending /// basis at any state is `LTV_ref * collat - debt`. Flows change collateral, debt, and @@ -204,15 +206,23 @@ library LibStorage { /// states: a NAV-capped basis (seizure loss, see the cap in `_pendingFees`) and a /// performance entitlement that rounds to zero fee assets or shares. Both survive the /// flow: the seizure as the carried deficit above, and the held entitlement as a - /// preserved pending gain (capped at the NAV gain above the mark and scaled with the - /// supply), encoded as reference debt above the live debt so the next accrual reads the - /// same capped basis back. Without that preservation, repeated economically empty flows - /// (zero-op rebalances at cooldown cadence) would forgive each interval's entitlement - /// and erase the fee. The held management fee accumulator nets against the next - /// crystallization as usual. Rounding matches `_pendingFees` (`mulDivUp` on the scaled - /// reference debt), so the carry is the exact complement of the fee basis and each flow - /// can only shrink it (or the preserved gain) by rounding dust, never create a spurious - /// positive basis. + /// preserved pending gain (capped at the NAV gain above the mark), encoded as reference + /// debt above the live debt so the next accrual reads the same capped basis back. + /// Without that preservation, repeated economically empty flows (zero-op rebalances at + /// cooldown cadence) would forgive each interval's entitlement and erase the fee. The + /// gain is kept nominal across the flow, like the held management fee accumulator and + /// for the same reason (the supply ratio is a value-detached lever; see the gain + /// comment in the body), falling back to the supply-scaled read once it outgrows half + /// the post-flow NAV, so a supply-changing flow cannot leave a degenerate near-zero + /// mark (a supply-neutral flow that drops the NAV below the gain still truncates, as + /// before this fix: that path is rebalancer/owner-gated and owner-remediable). The + /// residual is + /// the mirror of the held-deduction one: an exit leaves its sub-share slice of the + /// pending entitlement with the stayers, remediable via `resetPerformanceReference`. + /// The held management fee accumulator nets against the next crystallization as + /// usual. Rounding matches `_pendingFees` (`mulDivUp` on the scaled reference debt), so + /// the carry is the exact complement of the fee basis and each flow can only shrink it + /// (or the preserved gain) by rounding dust, never create a spurious positive basis. /// /// Partial bad-debt episode: while some (not all) modules are excluded as bad debt, the /// accrual freezes the reference instead of crystallizing (see `_pendingFees`), so a @@ -339,15 +349,23 @@ library LibStorage { // entitlement and erase the performance fee. Capped at the NAV gain above the mark, // mirroring the cap in `_pendingFees`, so a seizure state (NAV at or below the mark) // never reads a preservable gain. Mutually exclusive with the carry by construction. + // Kept nominal like the held management fee accumulator, and for the same reason: the + // supply ratio is a value-detached lever, so scaling up would turn a dust entitlement + // into a fee on fresh deposit principal (Cantina #32 follow-up) and scaling down would + // let a deposit/exit round trip grind the entitlement away. if (prevCarry == 0) { gain = FixedPointMathLib.zeroFloorSub(scaledRefDebt, prevDebt) .min(FixedPointMathLib.zeroFloorSub(prevCollat - prevDebt, self.lastTotalAssets)); - gain = gain.mulDiv(newSupply, prevSupply); + // A gain at or above the post-flow NAV would leave a degenerate mark (the clamp + // below zeroes it and the next accrual would read the entire NAV as basis): once the + // gain outgrows half the post-flow NAV, shed it proportionally like a pre-hold exit + // instead. The `min` never scales up, so deposits keep the nominal gain. + if (gain > (newCollat - newDebt) / 2) gain = gain.min(gain.mulDiv(newSupply, prevSupply)); } // Preserve the per-share carry across the supply change. carry = prevCarry.mulDiv(newSupply, prevSupply); - // Unlike the carry and the gain, the held management fee accumulator is deliberately not - // rescaled: it counts fees actually charged, and the supply ratio is a permissionless + // Unlike the carry, the held management fee accumulator is deliberately not rescaled + // either: it counts fees actually charged, and the supply ratio is a permissionless // value-detached lever in both directions (see the held-accumulator paragraph in the // header). } @@ -356,8 +374,9 @@ library LibStorage { if (gain > 0) { // Held positive basis: encode it as reference debt above the live debt, so the mark // (`lastTotalAssets`) lands at NAV minus the gain and the NAV-gain cap in `_pendingFees` - // reads back exactly the preserved entitlement. Clamped at `newCollat` so the reference - // NAV stays non-negative (the clamp truncates the gain to the post-flow NAV). + // reads back exactly the preserved entitlement. Clamped at `newCollat` as a final guard + // so the reference NAV stays non-negative (the supply-scaled fallback above normally + // keeps the clamp slack). newRefDebt = (newDebt + gain).min(newCollat); newRefTotalAssets = newCollat - newRefDebt; } else if (carry >= newDebt && carry > 0 && self.lastTotalAssets > 0) { diff --git a/src/manager/base/PositionManagerAdmin.sol b/src/manager/base/PositionManagerAdmin.sol index d466bcb9..3fa51f63 100644 --- a/src/manager/base/PositionManagerAdmin.sol +++ b/src/manager/base/PositionManagerAdmin.sol @@ -174,7 +174,8 @@ abstract contract PositionManagerAdmin is IPositionManagerAdmin, PositionManager /// @inheritdoc IPositionManagerAdmin /// @dev Accrues fees first: a positive pending basis crystallizes normally before the reference - /// moves, so the reset never mints on past gains; it only forgives the carried negative + /// moves (a held entitlement that rounds to zero fee shares is forgiven without minting), + /// so the reset never mints on past gains; it only forgives the carried negative /// basis going forward. The forgiven carry includes the debt interest accrued since the /// last crystallization, which the next positive accrual will no longer net (see the /// interface timing note: reset as soon as possible after a positive charge). While every diff --git a/src/manager/base/PositionManagerBase.sol b/src/manager/base/PositionManagerBase.sol index 5a01f6ab..71df4fb0 100644 --- a/src/manager/base/PositionManagerBase.sol +++ b/src/manager/base/PositionManagerBase.sol @@ -113,8 +113,8 @@ abstract contract PositionManagerBase is OwnableRoles, ERC20, ReentrancyGuardTra /// `LibStorage.rebaseSnapshot`). /// /// Capital flows (deposit/withdraw/burn/rebalance/module changes) do not advance the - /// reference either; they rebase it so the pending per-share basis is preserved — see - /// `LibStorage.rebaseSnapshot`. + /// reference either; they rebase it so the pending basis is preserved (see + /// `LibStorage.rebaseSnapshot`). /// /// Debt rounding: `lastDebt` and `currentDebt` both originate from /// `IBorrowPosition.totalBorrowed()`, which uses Morpho's `toAssetsDown` (see diff --git a/src/manager/base/PositionManagerLP.sol b/src/manager/base/PositionManagerLP.sol index 5dbce2b3..94c21e03 100644 --- a/src/manager/base/PositionManagerLP.sol +++ b/src/manager/base/PositionManagerLP.sol @@ -232,8 +232,8 @@ abstract contract PositionManagerLP is IPositionManagerLP, PositionManagerBase { } // If sharesToMint rounds to 0 or assets are equal, sharesDelta remains 0 - // Rebase the performance reference across the flow (preserves the pending per-share basis - // instead of resetting it, so accrued debt carry survives deposits and withdrawals). + // Rebase the performance reference across the flow (preserves the pending basis, so + // accrued debt carry and any held entitlement survive the flow). _storage.rebaseSnapshot( totalAssetsBefore + debtBefore, debtBefore, _totalSupply, collatAfter, debtAfter, ERC20.totalSupply() ); diff --git a/test/libs/manager/LibStorage.t.sol b/test/libs/manager/LibStorage.t.sol index 23c33f29..70d1b3c5 100644 --- a/test/libs/manager/LibStorage.t.sol +++ b/test/libs/manager/LibStorage.t.sol @@ -139,6 +139,46 @@ contract LibManagerStorageTest is Test { assertEq(harness.getLastDebt(), 5_100e18, "reference debt stays out of the sentinel"); } + /// @notice A held positive entitlement (the zero-rounding holds in `_pendingFees`) is kept + /// nominal across flows: it is an asset-denominated entitlement already earned, so a + /// deposit must not scale it up into a fee on fresh principal (Cantina #32 + /// follow-up) and a deposit/exit round trip must hand it back whole. + function test_rebaseSnapshot_keepsHeldGainNominalAcrossFlows() public { + // Reference at collat 10_000 / debt 5_000; pre-flow collat 10_100 at flat debt: the + // levered read is 50 and the NAV gain above the mark is 100, so the preserved gain is 50. + // A deposit doubles the supply; the gain must stay 50, not become 100. + harness.setReference(5_000e18, 5_000e18); + harness.rebaseSnapshot(10_100e18, 5_000e18, 100e18, 15_200e18, 5_000e18, 200e18); + assertEq(harness.getLastDebt(), 5_050e18, "deposit keeps the entitlement nominal above the live debt"); + assertEq(harness.getLastTotalAssets(), 10_150e18, "the mark sits below the post-flow NAV by the nominal gain"); + + // The deposit exits again, restoring the pre-flow state: the entitlement comes back whole + // (a down-only rule would let reversible capital grind it toward zero). + harness.rebaseSnapshot(15_200e18, 5_000e18, 200e18, 10_100e18, 5_000e18, 100e18); + assertEq(harness.getLastDebt(), 5_050e18, "round trip hands the entitlement back whole"); + assertEq(harness.getLastTotalAssets(), 5_050e18, "the mark tracks the restored NAV minus the gain"); + } + + /// @notice A nominal entitlement above half the post-flow NAV would leave a degenerate + /// (zero or atoms-thin) mark whose next accrual reads essentially the entire NAV as + /// basis: the rebase falls back to the supply-scaled gain so the mark stays + /// anchored to the surviving NAV, including at the exact gain == NAV knife-edge. + function test_rebaseSnapshot_gainAboveHalfNavFallsBackToSupplyScaling() public { + harness.setReference(5_000e18, 5_000e18); + // 99% exit: the post-flow NAV (30) sits below the nominal 50 gain, so the gain is scaled + // by the supply ratio instead: 50 * 1/100 = 0.5. + harness.rebaseSnapshot(10_100e18, 5_000e18, 100e18, 40e18, 10e18, 1e18); + assertEq(harness.getLastDebt(), 10.5e18, "the scaled entitlement re-encodes above the live debt"); + assertEq(harness.getLastTotalAssets(), 29.5e18, "the mark stays strictly positive"); + + // Knife-edge: the post-flow NAV exactly equals the nominal gain. A nominal encode would + // write a zero mark (reference debt clamped at newCollat); the fallback scales instead. + harness.setReference(5_000e18, 5_000e18); + harness.rebaseSnapshot(10_100e18, 5_000e18, 100e18, 60e18, 10e18, 1e18); + assertEq(harness.getLastDebt(), 10.5e18, "the scaled entitlement re-encodes at the knife-edge"); + assertEq(harness.getLastTotalAssets(), 49.5e18, "the mark does not truncate to zero at gain == NAV"); + } + /// @notice The held management fee accumulator is never rescaled by a flow: a deposit/exit /// round trip that restores the vault state must hand the deduction back whole (a /// down-only rule would let reversible capital grind it toward zero), and diff --git a/test/manager/PositionManagerFeeReference.t.sol b/test/manager/PositionManagerFeeReference.t.sol index 433df88f..713aa2a4 100644 --- a/test/manager/PositionManagerFeeReference.t.sol +++ b/test/manager/PositionManagerFeeReference.t.sol @@ -1907,9 +1907,9 @@ contract PositionManagerFeeReferenceTest is PositionManagerBaseTest { } /// @notice A flow during a zero-share hold preserves the sub-share pending entitlement: the - /// rebase re-encodes it as reference debt above the live debt (scaled with the - /// supply), so the capped basis reads back unchanged after the flow and checkpoint - /// splitting cannot erase the fee through the flow path either. + /// rebase re-encodes it nominally as reference debt above the live debt (never + /// rescaled by the supply ratio), so the capped basis reads back unchanged after the + /// flow and checkpoint splitting cannot erase the fee through the flow path either. function test_checkpointSplitting_flowPreservesSubShareEntitlement() public { _setFees(0, PERF_FEE); _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); @@ -1929,29 +1929,27 @@ contract PositionManagerFeeReferenceTest is PositionManagerBaseTest { assertEq(_lastTotalAssets(), refNav, "mark held on the zero-share hold"); // The capped pending entitlement right before the flow. - uint256 supplyBefore = positionManager.totalSupply(); uint256 gainBefore = uint256(_pendingBasis()).min(positionManager.totalAssets() - refNav); assertGt(gainBefore, 0, "held entitlement pending at the flow"); - // The flow preserves the entitlement: the mark lands below the live NAV by exactly the - // supply-scaled gain and the reference debt re-encodes it above the live debt. - _mintCollateral(minter, 1_000e18); - vm.prank(minter); - positionManager.deposit(1_000e18, 0); - uint256 gainScaled = gainBefore.mulDiv(positionManager.totalSupply(), supplyBefore); + // The flow preserves the entitlement nominally: the mark lands below the live NAV by + // exactly the pre-flow gain and the reference debt re-encodes it above the live debt. + // The deposit roughly doubles the supply, so a supply-rescaled encoding would visibly + // double the 7-atom entitlement instead. + _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); assertEq( _lastTotalAssets(), - positionManager.totalAssets() - gainScaled, + positionManager.totalAssets() - gainBefore, "the mark sits below the live NAV by the preserved gain" ); assertEq( positionManager.lastDebt(), - positionManager.debtAmount() + gainScaled, + positionManager.debtAmount() + gainBefore, "the entitlement is re-encoded as reference debt above the live debt" ); assertEq( uint256(_pendingBasis()).min(positionManager.totalAssets() - _lastTotalAssets()), - gainScaled, + gainBefore, "the capped entitlement reads back unchanged after the flow" ); } @@ -2041,6 +2039,163 @@ contract PositionManagerFeeReferenceTest is PositionManagerBaseTest { assertGt(perfShares, 0, "the fee is not erased by the sub-BPS checkpoint"); } + /// @notice Reviewer follow-up on 3F-481: the flow rebase must not scale a held positive + /// entitlement by the supply ratio. The gain is an asset-denominated basis and a mint + /// is not profit; near zero NAV the ratio is an unmoored permissionless lever (shares + /// mint against the virtual asset base), so scaling would let a deposit turn a + /// one-atom held gain into a material fee charged against the incoming principal. + function test_checkpointSplitting_depositKeepsHeldEntitlementNominal() public { + // Route to the interest-free market so the atom-scale state is exact. + SupplyQueueEntry[] memory queue = new SupplyQueueEntry[](1); + queue[0] = SupplyQueueEntry({position: address(borrowPosition2), maxBorrow: uint96(type(uint96).max)}); + vm.prank(curator); + positionManager.setSupplyQueue(queue); + + _setFees(0, PERF_FEE); + + // Atom-scale bootstrap cohort (NAV 2, debt 1, supply 2): the flow rebase seeds the + // reference at this state. + _leveredDeposit(3, 1); + assertEq(positionManager.lastDebt(), 1, "reference debt seeded at the atom-scale state"); + assertEq(_lastTotalAssets(), 2, "mark seeded at the atom-scale state"); + + // A third-party one-atom repay through the market lifts NAV one atom: a positive basis + // whose fee rounds to zero at the BPS stage, so the reference is held with a one-atom + // entitlement. + debtToken.setBalance(user, 1); + vm.startPrank(user); + debtToken.approve(address(morpho), type(uint256).max); + morpho.repay(marketParams2, 1, 0, address(borrowPosition2), ""); + vm.stopPrank(); + _accrue(); + assertEq(positionManager.lastDebt(), 1, "reference held on the sub-BPS entitlement"); + assertEq(uint256(_pendingBasis()), 1, "one-atom held entitlement"); + + // Fresh principal arrives: the mint runs against the atom-scale asset base, so the supply + // ratio explodes; the held entitlement must stay nominal instead of scaling with it. + _leveredDeposit(1e18, 0); + assertEq( + positionManager.lastDebt(), + positionManager.debtAmount() + 1, + "the entitlement re-encodes nominally above the live debt" + ); + assertEq(_lastTotalAssets(), positionManager.totalAssets() - 1, "the mark sits one atom below the live NAV"); + + // The next accrual still holds the one-atom entitlement: nothing is minted against the + // fresh principal. + (,,, uint256 perfShares_) = positionManager.pendingFees(); + assertEq(perfShares_, 0, "no fee minted against the fresh principal"); + _accrue(); + assertEq(positionManager.balanceOf(feeRecipient), 0, "no shares minted to the recipient"); + } + + /// @notice A deposit/exit round trip that restores the vault state must hand the held + /// entitlement back whole: scaling on either leg would let reversible capital grind + /// the sub-share entitlement toward zero (the flow-path twin of the held + /// management-fee grind), re-opening the checkpoint-splitting erasure. + function test_checkpointSplitting_roundTripKeepsHeldEntitlementNominal() public { + _setFees(0, PERF_FEE); + _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); + + oracle.setPrice(DEFAULT_ORACLE_PRICE * 11); + _accrue(); + uint256 refNav = _lastTotalAssets(); + + // Enter the zero-share hold with a tiny gain. + oracle.setPrice(DEFAULT_ORACLE_PRICE * 11 + 1.5e16); + (,,, uint256 pending) = positionManager.pendingFees(); + assertEq(pending, 0, "entitlement converts to zero shares"); + _accrue(); + uint256 gainHeld = uint256(_pendingBasis()).min(positionManager.totalAssets() - refNav); + assertGt(gainHeld, 0, "nonzero held entitlement"); + + // Deposit doubling the pool, then burn exactly the minted shares in the same block: the + // vault returns to its pre-flow state and the entitlement must come back whole. + uint256 supplyBefore = positionManager.totalSupply(); + uint256 balanceBefore = positionManager.balanceOf(minter); + _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); + uint256 minted = positionManager.balanceOf(minter) - balanceBefore; + assertEq( + uint256(_pendingBasis()).min(positionManager.totalAssets() - _lastTotalAssets()), + gainHeld, + "the deposit leaves the entitlement nominal" + ); + + _mintDebt(minter, 2 * DEBT_AMOUNT); + vm.prank(minter); + positionManager.burn(minted, WithdrawalStrategy.PROPORTIONAL); + assertEq(positionManager.totalSupply(), supplyBefore, "round trip restored the share supply"); + assertEq( + uint256(_pendingBasis()).min(positionManager.totalAssets() - _lastTotalAssets()), + gainHeld, + "round trip hands the entitlement back whole" + ); + } + + /// @notice A window flow that drains the visible NAV below a frozen held gain must not + /// truncate the mark to zero: the encode falls back to the supply-scaled gain, so + /// the reference survives the flow anchored to the residual NAV and the post-window + /// re-entry does not read the entire NAV as basis (a fee minted on a catastrophic + /// loss). + function test_badDebt_windowFlowGainAboveResidualNavKeepsMarkPositive() public { + _setFees(0, PERF_FEE); + + // Module B (interest market) debt-heavy, module A (interest-free) lightly levered. + SupplyQueueEntry[] memory queue1 = new SupplyQueueEntry[](1); + queue1[0] = SupplyQueueEntry({position: address(borrowPosition1), maxBorrow: uint96(type(uint96).max)}); + vm.prank(curator); + positionManager.setSupplyQueue(queue1); + _leveredDeposit(10_000e18, 6_900e18); + + SupplyQueueEntry[] memory queue2 = new SupplyQueueEntry[](1); + queue2[0] = SupplyQueueEntry({position: address(borrowPosition2), maxBorrow: uint96(type(uint96).max)}); + vm.prank(curator); + positionManager.setSupplyQueue(queue2); + _leveredDeposit(30_000e18, 1_000e18); + + assertEq(positionManager.lastDebt(), 7_900e18, "reference debt seeded on the full universe"); + assertEq(_lastTotalAssets(), 32_100e18, "mark seeded on the full universe"); + + // B's debt compounds past its quoted collateral: the window opens. At 1.2x the survivor's + // NAV sits above the frozen mark, so the flow below reads a large frozen gain. + vm.warp(block.timestamp + 4_015 days); + oracle.setPrice(DEFAULT_ORACLE_PRICE * 12 / 10); + assertEq(positionManager.totalAssets(), 35_000e18, "visible NAV is the healthy module only"); + uint256 gainBefore = positionManager.totalAssets() - _lastTotalAssets(); + assertEq(gainBefore, 2_900e18, "frozen gain above the mark pending at the flow"); + + // The window withdrawal drains A to a residual NAV far below the frozen gain. + address[] memory wq = new address[](1); + wq[0] = address(borrowPosition2); + vm.prank(curator); + positionManager.setWithdrawalQueue(wq); + uint256 supplyBefore = positionManager.totalSupply(); + _mintDebt(minter, 1_000e18); + vm.prank(minter); + positionManager.withdraw(29_500e18, 1_000e18, WithdrawalStrategy.PROPORTIONAL); + + // The nominal gain no longer fits under the residual NAV: the encode falls back to the + // supply-scaled gain and the mark stays anchored to the surviving NAV. + assertEq(positionManager.totalAssets(), 600e18, "residual visible NAV"); + uint256 scaledGain = gainBefore.mulDiv(positionManager.totalSupply(), supplyBefore); + assertLt(scaledGain, 600e18, "the scaled gain fits under the residual NAV"); + assertGt(_lastTotalAssets(), 0, "the mark is not truncated to zero"); + assertEq(_lastTotalAssets(), 600e18 - scaledGain, "the mark sits below the residual NAV by the scaled gain"); + // The visible (good-debt) universe carries no debt after the flow, so the reference debt + // is exactly the scaled entitlement (`debtAmount()` would also count the excluded module). + assertEq(positionManager.lastDebt(), scaledGain, "the scaled entitlement re-encodes above the visible debt"); + + // The window closes (B re-enters at 2x): the basis against the surviving reference is + // non-positive, so nothing mints. A zero mark would have read the entire NAV as basis + // and minted the fee rate on the whole residual pool. + oracle.setPrice(DEFAULT_ORACLE_PRICE * 2); + assertGt(positionManager.totalAssets(), 1_000e18, "the excluded module re-entered the aggregates"); + (,,, uint256 perfShares) = positionManager.pendingFees(); + assertEq(perfShares, 0, "re-entry does not read the residual NAV as basis"); + _accrue(); + assertEq(positionManager.balanceOf(feeRecipient), 0, "no fee minted on the catastrophic loss"); + } + /// @notice Reviewer follow-up on the NAV cap (3F-470): a near-full liquidation can leave a /// NAV deficit larger than the remaining debt. The flow rebase must keep the mark out /// of the bootstrap sentinel (which would reseed at the trough and charge the From 33789695e653edfa3e7c3d7b7058203bca44e5fe Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Thu, 13 Aug 2026 10:12:23 +0200 Subject: [PATCH 2/4] test: deposit fairness properties (fresh-quote and matched-ratio invariants) The minted share fraction is fair under a true price P iff (P - p) * (d*C - c_q*D) == 0: either the quote is fresh or the deposited debt-to-collateral ratio matches the pool's. Pins case 1 (carry mints its own value, same-state accruals mint nothing), case 2 (matched-ratio deposits are repricing-neutral and charged only their own slice's gain, verified against a state-snapshot counterfactual), and demonstrates the inherent transfer of the mismatched-ratio stale-quote case as an operational constraint. Claude-Session: https://claude.ai/code/session_01AZowKnphCAYPA1MoEGXLjd --- .../PositionManagerDepositFairness.t.sol | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 test/manager/PositionManagerDepositFairness.t.sol diff --git a/test/manager/PositionManagerDepositFairness.t.sol b/test/manager/PositionManagerDepositFairness.t.sol new file mode 100644 index 00000000..1c77dd85 --- /dev/null +++ b/test/manager/PositionManagerDepositFairness.t.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.20; + +import {PositionManagerBaseTest} from "./PositionManagerBase.t.sol"; +import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; + +/// @title PositionManagerDepositFairnessTest +/// @notice Deposit-fairness properties. For a deposit of collateral `c` (quoted `c_q` at the +/// oracle quote `p`) borrowing debt `d` into a pool with quoted collateral `C` and +/// debt `D`, the minted share fraction is fair under a true price `P` iff +/// +/// (P - p) * (d * C - c_q * D) == 0 +/// +/// i.e. iff the quote is fresh (`P == p`, case 1) or the deposited debt-to-collateral +/// ratio matches the pool's (case 2). Case 1 additionally requires that no later +/// accrual consumes the fresh principal (the Cantina #32 follow-up regression class). +/// The third case (stale quote, mismatched ratio) is an inherent transfer that no +/// vault-side accounting can prevent; deposits made while the quote may be stale must +/// match the pool ratio (operational policy), see the demonstration test. +contract PositionManagerDepositFairnessTest is PositionManagerBaseTest { + using FixedPointMathLib for uint256; + + uint24 constant PERF_FEE = 1500; // 15%, mirrors the production vault + + uint24 internal currentMgmtFee; + uint24 internal currentPerfFee; + + function _setFees(uint24 managementFee, uint24 performanceFee) internal { + currentMgmtFee = managementFee; + currentPerfFee = performanceFee; + vm.prank(owner); + positionManager.setFeeData(feeRecipient, managementFee, performanceFee); + } + + /// @dev Triggers a fee accrual without any capital flow by re-applying the same fee config. + function _accrue() internal { + vm.prank(owner); + positionManager.setFeeData(feeRecipient, currentMgmtFee, currentPerfFee); + } + + function _leveredDeposit(uint256 collateral, uint256 debt) internal { + _mintCollateral(minter, collateral); + vm.prank(minter); + positionManager.deposit(collateral, debt); + } + + /// @dev Redeemable value of `shares` at the current quote (virtual offset included). + function _shareValue(uint256 shares) internal view returns (uint256) { + return + shares.mulDiv(positionManager.totalAssets(), positionManager.totalSupply() + positionManager.virtualShareOffset()); + } + + /// @dev Value of one share rounded up, the natural dust unit for mint-rounding tolerances. + function _sharePriceCeil() internal view returns (uint256) { + return positionManager.totalAssets().divUp(positionManager.totalSupply() + positionManager.virtualShareOffset()); + } + + /// @notice Case 1 (fresh quote): the deposited carry mints shares worth the carry, minus at + /// most one share of mint rounding, and a same-state accrual afterwards mints + /// nothing, so no slice of the fresh principal can be consumed as a fee. + function testFuzz_deposit_freshQuote_mintsCarryValue(uint256 c, uint256 d, uint256 p) public { + _setFees(0, PERF_FEE); + _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); + + // The mock oracle is the sole truth, so any settled quote is "fresh". Move it and accrue + // so the deposit lands on an arbitrary reference state (crystallized or held). + p = bound(p, 0.8e36, 3e36); + oracle.setPrice(p); + _accrue(); + + c = bound(c, 1e18, 20_000e18); + uint256 cQuoted = c.mulDiv(p, ORACLE_PRICE_SCALE); + d = bound(d, 0, cQuoted.mulDiv(60, 100)); + + uint256 assetsBefore = positionManager.totalAssets(); + uint256 sharesBefore = positionManager.balanceOf(minter); + _leveredDeposit(c, d); + + uint256 carry = positionManager.totalAssets() - assetsBefore; + uint256 minted = positionManager.balanceOf(minter) - sharesBefore; + uint256 value = _shareValue(minted); + assertLe(value, carry, "the depositor never mints more value than the carry"); + assertApproxEqAbs(value, carry, _sharePriceCeil() + 2, "the carry mints its own value back (case 1)"); + + // No gain happened since the deposit settled, so an accrual must mint nothing: any mint + // here would be paid by the fresh principal (the Cantina #32 follow-up class). + uint256 recipientBefore = positionManager.balanceOf(feeRecipient); + _accrue(); + assertEq(positionManager.balanceOf(feeRecipient), recipientBefore, "no fee minted without a gain"); + assertEq(_shareValue(minted), value, "the depositor's claim survives the accrual"); + } + + /// @notice Case 2 (stale quote): a deposit whose debt-to-collateral ratio matches the + /// pool's is fair under ANY later true price. Once the quote refreshes, the + /// depositor's claim is worth the true value of the slice they contributed. + function testFuzz_deposit_matchedRatio_fairUnderRepricing(uint256 c, uint256 p1, uint256 p2) public { + // No fees configured: pure cohort accounting. + _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); + + p1 = bound(p1, 0.8e36, 1.5e36); // the (possibly stale) quote at deposit time + oracle.setPrice(p1); + p2 = bound(p2, p1.mulDiv(85, 100), p1 * 2); // the refreshed true quote + + c = bound(c, 1e18, 20_000e18); + uint256 cQuoted = c.mulDiv(p1, ORACLE_PRICE_SCALE); + // Match the pool's debt-to-quoted-collateral ratio (up to one atom of rounding). + uint256 d = cQuoted.mulDiv(positionManager.debtAmount(), positionManager.collateralAmountQuoted()); + + uint256 sharesBefore = positionManager.balanceOf(minter); + _leveredDeposit(c, d); + uint256 minted = positionManager.balanceOf(minter) - sharesBefore; + + oracle.setPrice(p2); + uint256 trueContribution = c.mulDiv(p2, ORACLE_PRICE_SCALE) - d; + assertApproxEqAbs( + _shareValue(minted), + trueContribution, + _sharePriceCeil() + 100, + "matched-ratio deposit is repricing-neutral (case 2)" + ); + } + + /// @notice Case 2 with fees on: a matched-ratio deposit grows the performance fee by + /// exactly the fee on its OWN levered slice's gain, `perfFee * d * (p2/p1 - 1)`, + /// and nothing more. The deposited principal is never charged; only its genuine + /// performance is (the reference is crystallized at `p1` so the closed form is + /// exact; a pending pre-deposit entitlement is covered by the checkpoint-splitting + /// regression tests). + function testFuzz_deposit_matchedRatio_chargedOnlyOwnGain(uint256 c, uint256 p1, uint256 p2) public { + _setFees(0, PERF_FEE); + _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); + + p1 = bound(p1, 1.05e36, 1.5e36); // above the seed quote so the accrual crystallizes at p1 + oracle.setPrice(p1); + _accrue(); + p2 = bound(p2, p1.mulDiv(85, 100), p1 * 2); + + c = bound(c, 1e18, 20_000e18); + uint256 cQuoted = c.mulDiv(p1, ORACLE_PRICE_SCALE); + uint256 d = cQuoted.mulDiv(positionManager.debtAmount(), positionManager.collateralAmountQuoted()); + + // Counterfactual: no deposit, reprice, accrue; record the fee take in asset value. + uint256 snapshot = vm.snapshotState(); + uint256 recipientBefore = positionManager.balanceOf(feeRecipient); + oracle.setPrice(p2); + _accrue(); + uint256 feeValueWithout = _shareValue(positionManager.balanceOf(feeRecipient) - recipientBefore); + vm.revertToState(snapshot); + + // Real: matched-ratio deposit, then the same repricing and accrual. + _leveredDeposit(c, d); + recipientBefore = positionManager.balanceOf(feeRecipient); + oracle.setPrice(p2); + _accrue(); + uint256 feeValueWith = _shareValue(positionManager.balanceOf(feeRecipient) - recipientBefore); + + // The depositor's own levered slice gain from p1 to p2 (zero when the price fell: the + // basis is capped below at zero for both branches). + uint256 ownGain = FixedPointMathLib.zeroFloorSub(d.mulDiv(p2, p1), d); + assertApproxEqAbs( + feeValueWith, + feeValueWithout + ownGain.mulDiv(PERF_FEE, 10_000), + 2 * _sharePriceCeil() + 1_000, + "the deposit is charged exactly its own slice's gain" + ); + } + + /// @notice The complement, kept as a documented limitation: under a stale quote a + /// ratio-MISMATCHED deposit inherently moves value, with sign + /// `(P - p) * (d * C - c_q * D)`. Here an unlevered deposit ahead of an upward + /// repricing buys the levered pool at the stale price: the depositor exits with + /// more than they contributed and the incumbents pay for it. No vault-side + /// accounting can prevent this; deposits made while the quote may be stale must + /// match the pool's ratio (or wait for the refresh). + function test_deposit_mismatchedRatio_staleQuoteTransfersValue() public { + // Pool: 10_000 collateral / 5_000 debt at a stale 1:1 quote; supply 5_000. + _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); + + // Unlevered 10_000 deposit at the stale quote mints 10_000 shares (carry at the quote). + uint256 sharesBefore = positionManager.balanceOf(minter); + _leveredDeposit(10_000e18, 0); + uint256 minted = positionManager.balanceOf(minter) - sharesBefore; + + // The true price arrives: 2x. True contribution is 20_000, but the depositor's claim is + // 10_000/15_000 of the 35_000 pool = 23_333: a 3_333 transfer from the incumbents. + oracle.setPrice(2e36); + uint256 trueContribution = 20_000e18; + uint256 value = _shareValue(minted); + assertGt(value, trueContribution + 3_000e18, "mismatched-ratio deposit captures incumbent value"); + assertApproxEqAbs(value, 23_333e18, 1e18, "the transfer matches the closed form"); + } +} From bbe2885497d531cd4237b3a6a6094c64bc07c0ca Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Thu, 13 Aug 2026 10:23:10 +0200 Subject: [PATCH 3/4] test: widen deposit-fairness fuzz and add the PM-12 fee-conservation invariant The fairness properties now fuzz the fee configuration (0-200 bps management, 0-5000 bps performance) and elapsed time (time alone must never create a performance fee on fresh principal). The stateful handler gains ghost accounting: NAV gains observed outside capital flows, the management-fee allowance per settled accrual interval, and the value of fee shares at mint. PM-12 asserts fees can never materially exceed those allowances under the handler's full state space (fuzzed fees, 0.1x-10x prices, warps, liquidations, third-party repays), so capital flows alone can never fund a fee. Claude-Session: https://claude.ai/code/session_01AZowKnphCAYPA1MoEGXLjd --- test/manager/PositionManager.invariant.t.sol | 13 +++ .../PositionManagerDepositFairness.t.sol | 35 +++--- test/mock/manager/PositionManagerHandler.sol | 104 ++++++++++++++---- 3 files changed, 116 insertions(+), 36 deletions(-) diff --git a/test/manager/PositionManager.invariant.t.sol b/test/manager/PositionManager.invariant.t.sol index b38fe4fa..49c76305 100644 --- a/test/manager/PositionManager.invariant.t.sol +++ b/test/manager/PositionManager.invariant.t.sol @@ -461,6 +461,19 @@ contract PositionManagerInvariantTest is StdInvariant, Test { assertEq(positionManager.totalAssets(), expected, "PM-10: totalAssets broken after liquidation"); } + /// @notice PM-12: Fee conservation. The cumulative value of fee shares at mint never + /// exceeds the maximum rates applied to what could legitimately be charged: the + /// performance rate on NAV gains observed outside capital flows, plus the + /// management rate on quoted collateral over the settled accrual intervals. + /// Capital flows alone can therefore never fund a fee; a violation means principal + /// was charged (the Cantina #32 follow-up class). The 2x factor covers the share + /// conversion against the fee-adjusted base, which can value a mint at up to twice + /// the underlying fee assets; the flat term absorbs per-action rounding dust. + function invariant_feeConservation() public view { + uint256 allowance = handler.ghostGainObserved() * MAX_PERFORMANCE_FEE / 10_000 + handler.ghostMgmtAllowance(); + assertLe(handler.ghostFeeMintedValue(), 2 * allowance + 1e18, "PM-12: fee minted beyond observable gains"); + } + /// @notice PM-11: Unauthorized WrappedAsset operations never succeed. /// @dev Verifies that: /// a) External actors without SENDER_ROLE cannot transfer WrappedAsset. diff --git a/test/manager/PositionManagerDepositFairness.t.sol b/test/manager/PositionManagerDepositFairness.t.sol index 1c77dd85..a871701c 100644 --- a/test/manager/PositionManagerDepositFairness.t.sol +++ b/test/manager/PositionManagerDepositFairness.t.sol @@ -55,11 +55,14 @@ contract PositionManagerDepositFairnessTest is PositionManagerBaseTest { return positionManager.totalAssets().divUp(positionManager.totalSupply() + positionManager.virtualShareOffset()); } - /// @notice Case 1 (fresh quote): the deposited carry mints shares worth the carry, minus at - /// most one share of mint rounding, and a same-state accrual afterwards mints - /// nothing, so no slice of the fresh principal can be consumed as a fee. - function testFuzz_deposit_freshQuote_mintsCarryValue(uint256 c, uint256 d, uint256 p) public { - _setFees(0, PERF_FEE); + /// @notice Case 1 (fresh quote): under any fee configuration, the deposited carry mints + /// shares worth the carry, minus at most one share of mint rounding, and no later + /// accrual can charge a performance fee against the fresh principal: with the quote + /// unchanged, the pending performance component stays zero no matter how much time + /// passes (only the time-based management fee may accrue). + function testFuzz_deposit_freshQuote_mintsCarryValue(uint256 c, uint256 d, uint256 p, uint256 fees) public { + // Fuzz the fee configuration: management rate in [0, 200] bps, performance in [0, 5000]. + _setFees(uint24(bound(fees, 0, 200)), uint24(bound(fees >> 128, 0, 5000))); _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); // The mock oracle is the sole truth, so any settled quote is "fresh". Move it and accrue @@ -82,12 +85,15 @@ contract PositionManagerDepositFairnessTest is PositionManagerBaseTest { assertLe(value, carry, "the depositor never mints more value than the carry"); assertApproxEqAbs(value, carry, _sharePriceCeil() + 2, "the carry mints its own value back (case 1)"); - // No gain happened since the deposit settled, so an accrual must mint nothing: any mint - // here would be paid by the fresh principal (the Cantina #32 follow-up class). - uint256 recipientBefore = positionManager.balanceOf(feeRecipient); - _accrue(); - assertEq(positionManager.balanceOf(feeRecipient), recipientBefore, "no fee minted without a gain"); - assertEq(_shareValue(minted), value, "the depositor's claim survives the accrual"); + // No gain happened since the deposit settled, so an accrual must mint no performance fee: + // any perf mint here would be paid by the fresh principal (the Cantina #32 follow-up + // class). The debt side routes to the interest-bearing market, whose accrued interest + // only pushes the basis further negative. + (,,, uint256 perfShares) = positionManager.pendingFees(); + assertEq(perfShares, 0, "no performance fee pending without a gain"); + vm.warp(block.timestamp + bound(fees >> 64, 0, 90 days)); + (,,, perfShares) = positionManager.pendingFees(); + assertEq(perfShares, 0, "time alone never creates a performance fee on the principal"); } /// @notice Case 2 (stale quote): a deposit whose debt-to-collateral ratio matches the @@ -125,9 +131,10 @@ contract PositionManagerDepositFairnessTest is PositionManagerBaseTest { /// and nothing more. The deposited principal is never charged; only its genuine /// performance is (the reference is crystallized at `p1` so the closed form is /// exact; a pending pre-deposit entitlement is covered by the checkpoint-splitting - /// regression tests). + /// regression tests). The performance rate itself is fuzzed. function testFuzz_deposit_matchedRatio_chargedOnlyOwnGain(uint256 c, uint256 p1, uint256 p2) public { - _setFees(0, PERF_FEE); + uint24 perfFee = uint24(bound(c >> 128, 1, 5000)); + _setFees(0, perfFee); _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); p1 = bound(p1, 1.05e36, 1.5e36); // above the seed quote so the accrual crystallizes at p1 @@ -159,7 +166,7 @@ contract PositionManagerDepositFairnessTest is PositionManagerBaseTest { uint256 ownGain = FixedPointMathLib.zeroFloorSub(d.mulDiv(p2, p1), d); assertApproxEqAbs( feeValueWith, - feeValueWithout + ownGain.mulDiv(PERF_FEE, 10_000), + feeValueWithout + ownGain.mulDiv(perfFee, 10_000), 2 * _sharePriceCeil() + 1_000, "the deposit is charged exactly its own slice's gain" ); diff --git a/test/mock/manager/PositionManagerHandler.sol b/test/mock/manager/PositionManagerHandler.sol index 08274d0a..bd7ae37e 100644 --- a/test/mock/manager/PositionManagerHandler.sol +++ b/test/mock/manager/PositionManagerHandler.sol @@ -111,12 +111,72 @@ contract PositionManagerHandler is Test { /* LIQUIDATION TRACKING */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - modifier refreshFullLiquidation() { + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* FEE-CONSERVATION GHOSTS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @notice PM-12: cumulative positive NAV deltas observed across non-flow actions (price + /// moves, interest, third-party repays, liquidation recoveries): the only + /// legitimate source of performance-fee basis. Capital-flow actions are excluded, + /// so a fee funded by flow principal alone shows up as a mint without a matching + /// observed gain. + uint256 public ghostGainObserved; + + /// @notice PM-12: cumulative management-fee allowance: MAX_MANAGEMENT_FEE (200 bps) on the + /// quoted collateral held at each accrual, over the interval the accrual settles. + uint256 public ghostMgmtAllowance; + + /// @notice PM-12: cumulative asset value of fee shares, measured at their mint. + uint256 public ghostFeeMintedValue; + + /// @dev Wraps a capital-flow action: tracks fee mints and the management-fee interval, but + /// attributes no NAV delta as an observable gain. + modifier ghostFlow() { _refreshFullLiquidationFlag(); + (uint256 tsBefore, uint256 collatBefore, uint256 recipientSharesBefore) = _ghostBefore(); _; + _ghostAfter(tsBefore, collatBefore, recipientSharesBefore); _refreshFullLiquidationFlag(); } + /// @dev Wraps a non-flow action: additionally counts any positive NAV delta as an observed + /// gain (negative deltas are floored, keeping the allowance an upper bound). + modifier ghostObserve() { + _refreshFullLiquidationFlag(); + uint256 assetsBefore = positionManager.totalAssets(); + (uint256 tsBefore, uint256 collatBefore, uint256 recipientSharesBefore) = _ghostBefore(); + _; + _ghostAfter(tsBefore, collatBefore, recipientSharesBefore); + uint256 assetsAfter = positionManager.totalAssets(); + if (assetsAfter > assetsBefore) ghostGainObserved += assetsAfter - assetsBefore; + _refreshFullLiquidationFlag(); + } + + function _ghostBefore() internal view returns (uint256 tsBefore, uint256 collatBefore, uint256 recipientShares) { + (,,,, tsBefore,) = positionManager.feeData(); + collatBefore = positionManager.collateralAmountQuoted(); + recipientShares = _feeRecipientShares(); + } + + /// @dev Every accrual runs before the action moves assets, so the collateral it charges the + /// management fee on is the pre-action quoted collateral captured here. + function _ghostAfter(uint256 tsBefore, uint256 collatBefore, uint256 recipientSharesBefore) internal { + (,,,, uint256 tsAfter,) = positionManager.feeData(); + if (tsAfter > tsBefore) { + ghostMgmtAllowance += collatBefore * 200 * (tsAfter - tsBefore) / (10_000 * 365 days); + } + uint256 current = _feeRecipientShares(); + if (current > recipientSharesBefore) { + uint256 supply = positionManager.totalSupply() + positionManager.virtualShareOffset(); + ghostFeeMintedValue += (current - recipientSharesBefore) * positionManager.totalAssets() / supply; + } + } + + function _feeRecipientShares() internal view returns (uint256) { + (address recipient,,,,,) = positionManager.feeData(); + return recipient == address(0) ? 0 : positionManager.balanceOf(recipient); + } + function _refreshFullLiquidationFlag() internal { if (!initialized) return; if (fullLiquidationOccurred && positionManager.collateralAmount() > 0) { @@ -141,7 +201,7 @@ contract PositionManagerHandler is Test { /// Early-returns if the supply queue is empty. /// @param collateral Raw fuzz input for collateral amount. /// @param debt Raw fuzz input for debt amount. - function act_deposit(uint256 collateral, uint256 debt) external refreshFullLiquidation { + function act_deposit(uint256 collateral, uint256 debt) external ghostFlow { // Early-return when supply queue is empty (deposit would revert). SupplyQueueEntry[] memory sq = positionManager.supplyQueue(); if (sq.length == 0) return; @@ -174,7 +234,7 @@ contract PositionManagerHandler is Test { /// Early-returns if there is nothing to withdraw. /// @param collateral Raw fuzz input for collateral amount. /// @param debt Raw fuzz input for debt amount. - function act_withdraw(uint256 collateral, uint256 debt) external refreshFullLiquidation { + function act_withdraw(uint256 collateral, uint256 debt) external ghostFlow { uint256 totalCollateral = positionManager.collateralAmount(); uint256 totalDebt = positionManager.debtAmount(); @@ -218,7 +278,7 @@ contract PositionManagerHandler is Test { /// @dev Bounds shares to [1, handler's balance]. Calculates proportional debt needed, /// mints debt tokens, and calls burn. Early-returns if handler has no shares. /// @param shares Raw fuzz input for share amount. - function act_burn(uint256 shares) external refreshFullLiquidation { + function act_burn(uint256 shares) external ghostFlow { uint256 balance = positionManager.balanceOf(address(this)); if (balance == 0) return; @@ -275,7 +335,7 @@ contract PositionManagerHandler is Test { /// @param fromIdx Raw fuzz input for source position index. /// @param toIdx Raw fuzz input for destination position index. /// @param amount Raw fuzz input for the amount to move. - function act_rebalance(uint256 fromIdx, uint256 toIdx, uint256 amount) external refreshFullLiquidation { + function act_rebalance(uint256 fromIdx, uint256 toIdx, uint256 amount) external ghostFlow { address[] memory modules = positionManager.borrowModules(); if (modules.length < 2) return; @@ -332,7 +392,7 @@ contract PositionManagerHandler is Test { /// @notice Warps block.timestamp forward to allow fee accrual over time. /// @dev Bounds seconds to [1, 365 days]. This is crucial for management fee testing. /// @param seconds_ Raw fuzz input for seconds to warp. - function act_warpTime(uint256 seconds_) external refreshFullLiquidation { + function act_warpTime(uint256 seconds_) external ghostObserve { seconds_ = _bound(seconds_, 1, 365 days); vm.warp(block.timestamp + seconds_); } @@ -342,7 +402,7 @@ contract PositionManagerHandler is Test { /// Sets a fee recipient if one is not already configured. /// @param mgmt Raw fuzz input for management fee (basis points). /// @param perf Raw fuzz input for performance fee (basis points). - function act_setFees(uint256 mgmt, uint256 perf) external refreshFullLiquidation { + function act_setFees(uint256 mgmt, uint256 perf) external ghostObserve { mgmt = _bound(mgmt, 0, 200); perf = _bound(perf, 0, 5000); @@ -369,7 +429,7 @@ contract PositionManagerHandler is Test { /// @dev Bounds price to [0.1e36, 10e36] (10x drop to 10x increase from 1e36 default). /// OracleMock.setPrice is permissionless. /// @param priceSeed Raw fuzz input for the new price. - function act_setOraclePrice(uint256 priceSeed) external refreshFullLiquidation { + function act_setOraclePrice(uint256 priceSeed) external ghostObserve { uint256 newPrice = _bound(priceSeed, 0.1e36, 10e36); oracle.setPrice(newPrice); } @@ -379,7 +439,7 @@ contract PositionManagerHandler is Test { /// for repayment, and calls preLiquidate. Sets preLiquidationOccurred on success. /// @param posIdx Seed for selecting among borrow modules. /// @param amountSeed Raw fuzz input for the amount of collateral to seize. - function act_preLiquidate(uint256 posIdx, uint256 amountSeed) external refreshFullLiquidation { + function act_preLiquidate(uint256 posIdx, uint256 amountSeed) external ghostObserve { address[] memory modules = positionManager.borrowModules(); if (modules.length == 0) return; @@ -411,14 +471,14 @@ contract PositionManagerHandler is Test { /// MarketParams, and calls morpho.liquidate. Sets morphoLiquidationOccurred on success. /// @param posIdx Seed for selecting among borrow modules. /// @param amountSeed Raw fuzz input for the amount of collateral to seize. - function act_morphoLiquidate(uint256 posIdx, uint256 amountSeed) external refreshFullLiquidation { + function act_morphoLiquidate(uint256 posIdx, uint256 amountSeed) external ghostObserve { _doMorphoLiquidate(posIdx, amountSeed); } /// @notice Explicitly accrues interest on all Morpho markets. /// @dev This is an independent action separate from the interest accrual in act_burn. /// Combined with act_warpTime, this makes debt grow and pushes LTV higher. - function act_accrueInterest() external refreshFullLiquidation { + function act_accrueInterest() external ghostObserve { for (uint256 i = 0; i < marketParamsArray.length; i++) { morpho.accrueInterest(marketParamsArray[i]); } @@ -427,7 +487,7 @@ contract PositionManagerHandler is Test { /// @notice Changes the PositionManager's LTV parameter. /// @dev Bounds to [0.1e18, 0.95e18]. Affects available collateral calculations. /// @param ltvSeed Raw fuzz input for the new LTV. - function act_setLtv(uint256 ltvSeed) external refreshFullLiquidation { + function act_setLtv(uint256 ltvSeed) external ghostObserve { uint256 newLtv = _bound(ltvSeed, 0.1e18, 0.95e18); vm.prank(owner); try positionManager.setLtv(newLtv) {} catch {} @@ -436,7 +496,7 @@ contract PositionManagerHandler is Test { /// @notice Changes the rebalance config (maxRebalanceLoss only, cooldown stays at 0). /// @dev Bounds to [0, 500] (0% to 5% in basis points). /// @param lossSeed Raw fuzz input for the new maxRebalanceLoss. - function act_setRebalanceConfig(uint256 lossSeed) external refreshFullLiquidation { + function act_setRebalanceConfig(uint256 lossSeed) external ghostObserve { uint16 newLoss = uint16(_bound(lossSeed, 0, 500)); vm.prank(owner); try positionManager.setRebalanceConfig(newLoss, 0) {} catch {} @@ -446,7 +506,7 @@ contract PositionManagerHandler is Test { /// @dev Changes available borrow liquidity which can affect deposit/borrow outcomes. /// @param amountSeed Raw fuzz input for the supply amount. /// @param marketIdx Seed for selecting which market to supply to. - function act_supplyMorphoLiquidity(uint256 amountSeed, uint256 marketIdx) external refreshFullLiquidation { + function act_supplyMorphoLiquidity(uint256 amountSeed, uint256 marketIdx) external ghostObserve { if (marketParamsArray.length == 0) return; uint256 idx = marketIdx % marketParamsArray.length; uint256 amount = _bound(amountSeed, 1e18, 100_000e18); @@ -462,7 +522,7 @@ contract PositionManagerHandler is Test { /// @notice Mints WrappedAsset to the handler (underlying → wrap). /// @param amountSeed Raw fuzz input for the amount to wrap. - function act_wrapped_asset_mint(uint256 amountSeed) external refreshFullLiquidation { + function act_wrapped_asset_mint(uint256 amountSeed) external ghostObserve { uint256 amount = _bound(amountSeed, 1e18, 50_000e18); underlyingToken.mint(address(this), amount); underlyingToken.approve(address(collateralToken), amount); @@ -471,7 +531,7 @@ contract PositionManagerHandler is Test { /// @notice Burns (unwraps) WrappedAsset held by the handler back to underlying. /// @param amountSeed Raw fuzz input for the amount to unwrap. - function act_wrapped_asset_burn(uint256 amountSeed) external refreshFullLiquidation { + function act_wrapped_asset_burn(uint256 amountSeed) external ghostObserve { uint256 balance = collateralToken.balanceOf(address(this)); if (balance == 0) return; uint256 amount = _bound(amountSeed, 1, balance); @@ -482,7 +542,7 @@ contract PositionManagerHandler is Test { /// @dev Should succeed because the handler has SENDER_ROLE. /// @param toSeed Seed for selecting a destination (owner or positionManager). /// @param amountSeed Raw fuzz input for the amount to transfer. - function act_wrapped_asset_transfer(uint256 toSeed, uint256 amountSeed) external refreshFullLiquidation { + function act_wrapped_asset_transfer(uint256 toSeed, uint256 amountSeed) external ghostObserve { uint256 balance = collateralToken.balanceOf(address(this)); if (balance == 0) return; uint256 amount = _bound(amountSeed, 1, balance); @@ -493,7 +553,7 @@ contract PositionManagerHandler is Test { /// @notice Approves an address for WrappedAsset spending. /// @param toSeed Seed for selecting a spender. /// @param amountSeed Raw fuzz input for approval amount. - function act_wrapped_asset_approve(uint256 toSeed, uint256 amountSeed) external refreshFullLiquidation { + function act_wrapped_asset_approve(uint256 toSeed, uint256 amountSeed) external ghostObserve { address spender = toSeed % 2 == 0 ? address(positionManager) : address(morpho); uint256 amount = _bound(amountSeed, 0, type(uint128).max); collateralToken.approve(spender, amount); @@ -503,7 +563,7 @@ contract PositionManagerHandler is Test { /// @dev Pranks as an address without SENDER_ROLE. The transfer should fail. /// If it succeeds, sets unauthorizedTransferSucceeded. /// @param amountSeed Raw fuzz input for the amount to transfer. - function act_wrapped_asset_unauthorized_transfer(uint256 amountSeed) external refreshFullLiquidation { + function act_wrapped_asset_unauthorized_transfer(uint256 amountSeed) external ghostObserve { address externalActor = makeAddr("externalActor"); uint256 balance = collateralToken.balanceOf(externalActor); if (balance == 0) { @@ -535,7 +595,7 @@ contract PositionManagerHandler is Test { /// @dev Reduces available borrow liquidity, which can affect future deposit/borrow outcomes. /// @param amountSeed Raw fuzz input for the withdrawal amount. /// @param marketIdx Seed for selecting which market to withdraw from. - function act_morpho_withdraw(uint256 amountSeed, uint256 marketIdx) external refreshFullLiquidation { + function act_morpho_withdraw(uint256 amountSeed, uint256 marketIdx) external ghostObserve { if (marketParamsArray.length == 0) return; uint256 idx = marketIdx % marketParamsArray.length; Id id = marketParamsArray[idx].id(); @@ -552,7 +612,7 @@ contract PositionManagerHandler is Test { /// @dev Reduces the BP's debt, improving its health. /// @param posIdx Seed for selecting a borrow module. /// @param amountSeed Raw fuzz input for the repayment amount. - function act_morpho_repay(uint256 posIdx, uint256 amountSeed) external refreshFullLiquidation { + function act_morpho_repay(uint256 posIdx, uint256 amountSeed) external ghostObserve { address[] memory modules = positionManager.borrowModules(); if (modules.length == 0) return; @@ -581,7 +641,7 @@ contract PositionManagerHandler is Test { /// because the actor lacks SENDER_ROLE and Morpho lacks RECEIVER_ROLE. /// @param amountSeed Raw fuzz input for the amount. /// @param marketIdx Seed for selecting which market. - function act_morpho_supplyCollateral(uint256 amountSeed, uint256 marketIdx) external refreshFullLiquidation { + function act_morpho_supplyCollateral(uint256 amountSeed, uint256 marketIdx) external ghostObserve { if (marketParamsArray.length == 0) return; uint256 idx = marketIdx % marketParamsArray.length; uint256 amount = _bound(amountSeed, 1e18, 10_000e18); From 16665e208981f1b715161cfa41e2551d24bdc198 Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Thu, 13 Aug 2026 10:31:32 +0200 Subject: [PATCH 4/4] test: mirror the flow-fairness invariants on the exit side Exits obey the same theorem as deposits with the signs flipped: fair under a true price P iff the quote is fresh or the removed debt-to-collateral ratio matches the pool's. Renames the fairness file to PositionManagerFlowFairness and adds: a fresh-quote withdraw burns exactly the carry's value under any ratio and fee configuration (and neither the exit nor time creates a performance fee), a matched-ratio withdraw is repricing-neutral for the stayers (snapshot counterfactual), burn() is ratio-matched by construction and therefore neutral at any stale quote, and the mismatched-ratio stale-quote exit demonstrably hands the exiter's levered upside to the stayers. Claude-Session: https://claude.ai/code/session_01AZowKnphCAYPA1MoEGXLjd --- ....sol => PositionManagerFlowFairness.t.sol} | 151 ++++++++++++++++-- 1 file changed, 140 insertions(+), 11 deletions(-) rename test/manager/{PositionManagerDepositFairness.t.sol => PositionManagerFlowFairness.t.sol} (54%) diff --git a/test/manager/PositionManagerDepositFairness.t.sol b/test/manager/PositionManagerFlowFairness.t.sol similarity index 54% rename from test/manager/PositionManagerDepositFairness.t.sol rename to test/manager/PositionManagerFlowFairness.t.sol index a871701c..fd9887c2 100644 --- a/test/manager/PositionManagerDepositFairness.t.sol +++ b/test/manager/PositionManagerFlowFairness.t.sol @@ -2,22 +2,28 @@ pragma solidity ^0.8.20; import {PositionManagerBaseTest} from "./PositionManagerBase.t.sol"; +import {WithdrawalStrategy} from "src/interfaces/manager/base/IPositionManagerAdmin.sol"; import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; -/// @title PositionManagerDepositFairnessTest -/// @notice Deposit-fairness properties. For a deposit of collateral `c` (quoted `c_q` at the -/// oracle quote `p`) borrowing debt `d` into a pool with quoted collateral `C` and -/// debt `D`, the minted share fraction is fair under a true price `P` iff +/// @title PositionManagerFlowFairnessTest +/// @notice Flow-fairness properties. For a capital flow moving collateral `c` (quoted `c_q` +/// at the oracle quote `p`) and debt `d` (into the pool on a deposit, out of it on a +/// withdraw or burn) against a pool with quoted collateral `C` and debt `D`, the +/// share amount minted or burned is fair under a true price `P` iff /// /// (P - p) * (d * C - c_q * D) == 0 /// -/// i.e. iff the quote is fresh (`P == p`, case 1) or the deposited debt-to-collateral -/// ratio matches the pool's (case 2). Case 1 additionally requires that no later -/// accrual consumes the fresh principal (the Cantina #32 follow-up regression class). -/// The third case (stale quote, mismatched ratio) is an inherent transfer that no -/// vault-side accounting can prevent; deposits made while the quote may be stale must -/// match the pool ratio (operational policy), see the demonstration test. -contract PositionManagerDepositFairnessTest is PositionManagerBaseTest { +/// i.e. iff the quote is fresh (`P == p`, case 1) or the flow's debt-to-collateral +/// ratio matches the pool's (case 2). Exits are deposits with the signs flipped, so +/// both directions are pinned here; `burn()` computes its amounts proportionally by +/// construction, so it is ratio-matched at any quote and only the free-form +/// `withdraw(c, d, strategy)` can pick a mismatched ratio. Case 1 additionally +/// requires that no later accrual consumes principal (the Cantina #32 follow-up +/// regression class). The third case (stale quote, mismatched ratio) is an inherent +/// transfer that no vault-side accounting can prevent; flows executed while the quote +/// may be stale must match the pool ratio (operational policy), see the two +/// demonstration tests. +contract PositionManagerFlowFairnessTest is PositionManagerBaseTest { using FixedPointMathLib for uint256; uint24 constant PERF_FEE = 1500; // 15%, mirrors the production vault @@ -196,4 +202,127 @@ contract PositionManagerDepositFairnessTest is PositionManagerBaseTest { assertGt(value, trueContribution + 3_000e18, "mismatched-ratio deposit captures incumbent value"); assertApproxEqAbs(value, 23_333e18, 1e18, "the transfer matches the closed form"); } + + /// @notice Case 1, exit direction: at a fresh quote a withdraw of ANY ratio burns shares + /// worth exactly the removed carry (rounded against the exiter by at most one + /// share), under any fee configuration, and neither the exit nor elapsed time can + /// create a performance fee afterwards. + function testFuzz_withdraw_freshQuote_burnsCarryValue(uint256 c, uint256 d, uint256 p, uint256 fees) public { + _setFees(uint24(bound(fees, 0, 200)), uint24(bound(fees >> 128, 0, 5000))); + _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); + + p = bound(p, 0.8e36, 3e36); + oracle.setPrice(p); + _accrue(); + + // Keep the post-exit LTV under the 70% withdrawal bound even for a collateral-only exit, + // then let the debt leg roam anywhere below the flow's own 64%. Also cap the removed + // carry at 90% of the minter's redeemable value: a high-rate crystallization above can + // hand a large share slice to the fee recipient, and the burn must fit what the minter + // still holds. + uint256 cMaxQuoted = FixedPointMathLib.zeroFloorSub( + positionManager.collateralAmountQuoted(), positionManager.debtAmount().mulDiv(100, 65) + ).min(_shareValue(positionManager.balanceOf(minter)).mulDiv(90, 100)); + c = bound(c, 1e15, cMaxQuoted.mulDiv(ORACLE_PRICE_SCALE, p)); + uint256 cQuoted = c.mulDiv(p, ORACLE_PRICE_SCALE); + d = bound(d, 0, cQuoted.mulDiv(64, 100).min(positionManager.debtAmount())); + _mintDebt(minter, d); + + uint256 assetsBefore = positionManager.totalAssets(); + uint256 sharesBefore = positionManager.balanceOf(minter); + vm.prank(minter); + positionManager.withdraw(c, d, WithdrawalStrategy.SEQUENTIAL); + + uint256 carry = assetsBefore - positionManager.totalAssets(); + uint256 burned = sharesBefore - positionManager.balanceOf(minter); + uint256 value = _shareValue(burned); + // Withdrawals compound more rounding than deposits (Morpho repay share round-trips, and + // each collateral atom quantizes to ~p quoted atoms), so the dust bound scales with the + // price factor on top of the one-share mint rounding. + uint256 dust = _sharePriceCeil() + 8 * (p / ORACLE_PRICE_SCALE + 2); + assertGe(value + dust, carry, "the stayers never subsidize the exiter beyond dust"); + assertApproxEqAbs(value, carry, dust, "the exit burns exactly the carry's value (case 1)"); + + (,,, uint256 perfShares) = positionManager.pendingFees(); + assertEq(perfShares, 0, "no performance fee pending without a gain"); + vm.warp(block.timestamp + bound(fees >> 64, 0, 90 days)); + (,,, perfShares) = positionManager.pendingFees(); + assertEq(perfShares, 0, "time alone never creates a performance fee after the exit"); + } + + /// @notice Case 2, exit direction: a withdraw whose debt-to-collateral ratio matches the + /// pool's leaves the stayers whole under ANY later true price: the per-share value + /// once the truth arrives equals the no-exit counterfactual. + function testFuzz_withdraw_matchedRatio_leavesStayersWhole(uint256 c, uint256 p1, uint256 p2) public { + // No fees configured: pure cohort accounting. + _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); + + p1 = bound(p1, 0.8e36, 1.5e36); + oracle.setPrice(p1); + p2 = bound(p2, p1.mulDiv(85, 100), p1 * 2); + + c = bound(c, 1e18, 2_000e18); + uint256 cQuoted = c.mulDiv(p1, ORACLE_PRICE_SCALE); + uint256 d = cQuoted.mulDiv(positionManager.debtAmount(), positionManager.collateralAmountQuoted()); + _mintDebt(minter, d); + + uint256 snapshot = vm.snapshotState(); + oracle.setPrice(p2); + uint256 ppsWithout = _shareValue(1e18); + vm.revertToState(snapshot); + + vm.prank(minter); + positionManager.withdraw(c, d, WithdrawalStrategy.SEQUENTIAL); + oracle.setPrice(p2); + assertApproxEqAbs(_shareValue(1e18), ppsWithout, 1e6, "matched-ratio exit is repricing-neutral for the stayers"); + } + + /// @notice `burn()` computes its collateral and debt proportionally by construction, so it + /// is ratio-matched and repricing-neutral at any (possibly stale) quote. + function testFuzz_burn_isRatioMatchedByConstruction(uint256 shares, uint256 p1, uint256 p2) public { + // No fees configured: pure cohort accounting. + _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); + + p1 = bound(p1, 0.8e36, 1.5e36); + oracle.setPrice(p1); + p2 = bound(p2, p1.mulDiv(85, 100), p1 * 2); + + shares = bound(shares, 1e15, positionManager.balanceOf(minter).mulDiv(40, 100)); + _mintDebt(minter, positionManager.debtAmount()); // over-provision the proportional repay + + uint256 snapshot = vm.snapshotState(); + oracle.setPrice(p2); + uint256 ppsWithout = _shareValue(1e18); + vm.revertToState(snapshot); + + vm.prank(minter); + positionManager.burn(shares, WithdrawalStrategy.PROPORTIONAL); + oracle.setPrice(p2); + assertApproxEqAbs(_shareValue(1e18), ppsWithout, 1e6, "burn is repricing-neutral at any quote"); + } + + /// @notice The exit complement of the stale-quote transfer: withdrawing collateral without + /// its debt share at a stale-low quote hands the exiter's levered upside to the + /// stayers. Same closed form as the deposit demonstration, signs flipped. + function test_withdraw_mismatchedRatio_staleQuoteTransfersValue() public { + _leveredDeposit(COLLATERAL_AMOUNT, DEBT_AMOUNT); + + // Collateral-only exit at the stale 1:1 quote: 2_500 of carry burns 2_500 shares (half + // the supply), leaving the pool at 7_500 collateral / 5_000 debt. + uint256 sharesBefore = positionManager.balanceOf(minter); + vm.prank(minter); + positionManager.withdraw(2_500e18, 0, WithdrawalStrategy.SEQUENTIAL); + assertEq(sharesBefore - positionManager.balanceOf(minter), 2_500e18, "the carry burns its quote value in shares"); + + // The truth arrives at 2x: the burned half of the pool was truly worth 7_500, but the + // exiter left with collateral worth 5_000. The stayers keep the difference: per-share + // value 4 against 3 in the no-exit counterfactual. + oracle.setPrice(2e36); + assertEq(positionManager.totalAssets(), 10_000e18, "stayers keep the levered slice"); + assertGt( + _shareValue(1e18), + uint256(15_000e18).mulDiv(1e18, 5_000e18 + 1), + "stayers gain from the exiter's mismatched-ratio exit" + ); + } }