Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/interfaces/manager/IPositionManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/interfaces/manager/base/IPositionManagerAdmin.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
57 changes: 38 additions & 19 deletions src/libs/manager/LibStorage.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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).
}
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion src/manager/base/PositionManagerAdmin.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/manager/base/PositionManagerBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/manager/base/PositionManagerLP.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
Expand Down
40 changes: 40 additions & 0 deletions test/libs/manager/LibStorage.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions test/manager/PositionManager.invariant.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading