Skip to content

Commit 6490377

Browse files
committed
fix: performance-fee reference and basis hardening
Cantina #14 (3F-470): cap the levered performance basis at the zero-floored NAV gain since the reference so an external deleveraging (liquidation, direct repay) cannot mint a fee on a NAV loss; hold the reference when the cap zeroes the basis, and convert a seizure NAV deficit into carry form in rebaseSnapshot so the held mark survives capital flows instead of re-anchoring at the trough. Cantina #32 (3F-481): hold the reference when a positive basis carries a performance entitlement that converts to zero fee shares, so splitting one gain across many checkpoints cannot erase the fee; the interval's management fee joins the held accumulator. Cantina #36 (3F-482): rebase the performance reference (and check maxRebalanceLoss) before the outgoing token transfers in burn() and rebalance(), so a token callback cannot move module state into the new reference or donate to mask a loss. Cantina #15 (3F-475): document the accepted policy that recovery of an in-cap rebalance loss is charged as performance (rebalance() NatSpec).
1 parent f6bcfce commit 6490377

6 files changed

Lines changed: 762 additions & 114 deletions

File tree

‎src/libs/manager/LibStorage.sol‎

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,10 @@ struct RebalanceConfig {
7070
/// crystallizes (or on bootstrap); on capital flows it is rebased so the pending per-share
7171
/// basis is preserved (see `rebaseSnapshot`). It therefore only matches the live NAV right
7272
/// after a crystallizing accrual; while the reference is held it deviates from the live
73-
/// NAV by the carried (negative) pending basis. `lastCollat` is reconstructed on the fly
74-
/// as `lastTotalAssets + lastDebt` rather than stored directly, which keeps the storage
75-
/// layout append-only and existing integrations unchanged.
73+
/// NAV by the carried (negative) pending basis; after a seizure loss, flows convert the
74+
/// NAV drawdown against it into that carry (see `rebaseSnapshot`). `lastCollat` is
75+
/// reconstructed on the fly as `lastTotalAssets + lastDebt` rather than stored directly,
76+
/// which keeps the storage layout append-only and existing integrations unchanged.
7677
/// @param ltv Loan-to-value ratio in 18-decimal fixed point (e.g., 0.86e18 = 86%).
7778
/// A small buffer above the target LTV that determines how much collateral can be withdrawn.
7879
/// @param virtualShareOffset Virtual shares offset for inflation attack protection, derived from debt asset decimals.
@@ -182,36 +183,44 @@ library LibStorage {
182183
/// share supply without realising a gain, so the reference must move with them: the carry
183184
/// (the negative pending basis accumulated while the reference is held, typically accrued
184185
/// debt interest under a flat collateral quote) is scaled by the share-supply change and
185-
/// re-anchored on the post-flow state. Exits shed their proportional slice of the carry,
186+
/// re-anchored on the post-flow state. When the carry reads zero while NAV sits below
187+
/// the mark (a seizure leaves a NAV deficit with a non-negative levered read), the
188+
/// deficit is converted into carry form, so the high-water mark that the NAV-gain cap
189+
/// in `_pendingFees` measures against survives flows; an ordinary drawdown (nonzero
190+
/// carry) keeps the pre-existing semantics, under which a flow re-anchors the mark at
191+
/// NAV plus the levered carry only. Exits shed their proportional slice of the carry,
186192
/// deposits re-attach it to the new shares, and supply-neutral flows (rebalance, module
187193
/// changes) keep it unchanged. A holder therefore cannot shed accrued debt carry by
188194
/// exiting and re-entering, and an exit does not dump its carry slice on the stayers.
189195
///
190-
/// Called after `_accrueFees` has already crystallized any positive basis, so outside a
191-
/// partial bad-debt episode the pending basis here is non-positive; `zeroFloorSub` guards
192-
/// the rounding edge at exactly zero. Rounding matches `_pendingFees` (`mulDivUp` on the
193-
/// scaled reference debt), so the carry is the exact complement of the fee basis and each
194-
/// flow can only shrink it by rounding dust, never create a spurious positive basis.
196+
/// Called after `_accrueFees`, which crystallizes a positive basis except in two held
197+
/// states: a NAV-capped basis (seizure loss, see the cap in `_pendingFees`) and a
198+
/// performance entitlement that converts to zero fee shares. In both, the flow forgives
199+
/// the pending levered gain (for the zero-share hold the forgiven entitlement is
200+
/// bounded by one share's assets); for the seizure the converted deficit keeps the cap
201+
/// suspending fees after the flow. The held management fee accumulator is not cleared
202+
/// by the forgiveness and nets against the next crystallization. Rounding matches
203+
/// `_pendingFees` (`mulDivUp` on the scaled reference debt), so the carry is the exact
204+
/// complement of the fee basis and each flow can only shrink it by rounding dust, never
205+
/// create a spurious positive basis.
195206
///
196207
/// Partial bad-debt episode: while some (not all) modules are excluded as bad debt, the
197-
/// accrual freezes the reference instead of crystallizing (see `_pendingFees`), so a flow
198-
/// here can see a positive pending basis against the reduced aggregates. `zeroFloorSub`
199-
/// then clamps the carry to zero and the reference re-anchors at the post-flow visible
200-
/// state; the phantom gain itself is never preserved. The re-anchor is not
201-
/// direction-safe, because the pre-flow reduced aggregates are not comparable with the
202-
/// frozen full-universe mark: a flow that leaves the post-flow visible LTV at or below
203-
/// the frozen reference LTV under-reads the later recovery (LP-favorable; fees resume at
204-
/// a genuine new high), but a flow that raises it above the mark over-reads the recovery
205-
/// and can charge it as gain. The over-reading flows are a deposit with fresh borrowing
206-
/// during the window, a rebalance that borrows, and, most importantly, a rescue repayment
207-
/// (withdraw or rebalance) that pulls the excluded module back above water mid-flow: the
208-
/// re-anchor then adopts the re-entered module's near-100% LTV. Operational rule for the
209-
/// over-read: `resetPerformanceReference` does NOT correct it (the reset crystallizes a
210-
/// positive pending basis before moving); instead the owner should set the performance
211-
/// fee rate to zero right after such a flow, let the first accrual with a positive basis
212-
/// advance the reference mintlessly (a positive basis advances even at a zero rate), and
213-
/// then restore the rate. The under-read needs no action (fees resume at a genuine new
214-
/// high, or an owner reset re-anchors sooner).
208+
/// accrual freezes the reference instead of crystallizing (see `_pendingFees`), so a
209+
/// flow here compares the frozen full-universe mark with reduced aggregates and the
210+
/// re-anchor is approximate; the phantom gain itself is never preserved. When the
211+
/// visible carry reads zero (the visible LTV sits below the frozen reference LTV), the
212+
/// seizure conversion measures the deficit against the reduced universe; it typically
213+
/// exceeds the post-flow debt, so the sentinel clamp writes the bootstrap sentinel and
214+
/// the first post-window accrual reseeds the reference at the state it observes without
215+
/// charging (the window recovery is forgiven, LP-favorable). With a positive visible
216+
/// carry the ordinary carry semantics re-anchor instead, which can under- or over-read
217+
/// the later recovery. Remedies for a mis-anchored window flow: an owner reset once the
218+
/// window has closed re-anchors at the live state, but it crystallizes any positive
219+
/// pending basis first, so to avoid charging a mis-read recovery the owner should
220+
/// instead set the performance fee rate to zero right after the flow, let the first
221+
/// accrual with a positive basis advance the reference mintlessly (a positive basis
222+
/// advances even at a zero rate), and then restore the rate. An under-read needs no
223+
/// action (fees resume at a genuine new high, or an owner reset re-anchors sooner).
215224
///
216225
/// The held management fee accumulator (`heldManagementFeeAssets`, the management fees
217226
/// charged since the reference last advanced, deducted from the next positive basis) is
@@ -229,8 +238,12 @@ library LibStorage {
229238
/// Edge cases collapse to a plain snapshot of the current state (zero carry): bootstrap
230239
/// (`lastDebt == 0` sentinel), an empty vault before the flow, and a carry exceeding the
231240
/// post-flow debt (the reference debt floors at zero, which is the bootstrap sentinel, so
232-
/// the excess is forgiven and the next accrual reseeds). An empty good-debt universe
233-
/// after the flow instead holds the reference (see the bad-debt episode below).
241+
/// the excess is forgiven and the next accrual reseeds). The last case includes an
242+
/// oversized converted seizure deficit, e.g. a flow that unwinds debt below the seizure
243+
/// loss: the reseed then lands at the state the next accrual observes, and the recovery
244+
/// from there is charged unless the owner applies the temporary zero-rate remedy above.
245+
/// An empty good-debt universe after the flow instead holds the reference (see the
246+
/// bad-debt episode below).
234247
///
235248
/// Bad-debt episode: whenever the flow leaves the good-debt universe empty
236249
/// (`newCollat == 0` with a live reference), there is no good-debt state to re-anchor on,
@@ -273,6 +286,16 @@ library LibStorage {
273286
uint256 refCollat = self.lastTotalAssets + refDebt;
274287
uint256 scaledRefDebt = refDebt.mulDivUp(prevCollat, refCollat);
275288
uint256 prevCarry = FixedPointMathLib.zeroFloorSub(prevDebt, scaledRefDebt);
289+
// Seizure state: a liquidation leaves NAV below the mark while the levered read is
290+
// non-negative, so `prevCarry` reads zero and the re-anchor below would drop the mark to
291+
// the post-loss trough. Carry the NAV deficit (mark minus pre-flow NAV, zero when NAV is
292+
// at or above the mark) instead: the mark then lands at `NAV + deficit`, and later flows
293+
// read the same deficit back through the standard carry path. The empty pre-flow
294+
// universe is excluded (full-episode re-anchor, see the header); the oversized-deficit
295+
// edge is covered by the sentinel clamp below (see the header's edge cases).
296+
if (prevCarry == 0 && prevCollat > 0) {
297+
prevCarry = FixedPointMathLib.zeroFloorSub(self.lastTotalAssets + prevDebt, prevCollat);
298+
}
276299
// Preserve the per-share carry across the supply change.
277300
carry = prevCarry.mulDiv(newSupply, prevSupply);
278301
if (carry > newDebt) carry = newDebt;

‎src/manager/base/PositionManagerBase.sol‎

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,25 @@ abstract contract PositionManagerBase is OwnableRoles, ERC20, ReentrancyGuardTra
5959
/// The management fee assets are then deducted from this basis before applying the
6060
/// performance fee rate.
6161
///
62+
/// The levered basis is capped at the NAV gain since the reference, zero-floored
63+
/// (`totalAssets_ - lastTotalAssets`): an external deleveraging event (a liquidation, a
64+
/// direct repay on the market) can leave the levered read positive while NAV fell, and
65+
/// the cap keeps such events from minting a fee on a loss. A flow rebase converts such a
66+
/// seizure drawdown into the reference carry (see `LibStorage.rebaseSnapshot`), so the
67+
/// suspension survives flows until NAV recovers past the carried mark; an oversized
68+
/// drawdown is forgiven through the bootstrap sentinel instead.
69+
///
6270
/// Reference-advance rule (high-water mark): the performance reference (`lastTotalAssets`,
63-
/// `lastDebt`) advances to the current state only when the basis is positive
71+
/// `lastDebt`) advances to the current state only when the capped basis is positive
6472
/// (crystallization, at the configured rate which may be zero), when `lastDebt` is zero
6573
/// (bootstrap sentinel), or when the vault has no shares. When the basis is non-positive
66-
/// the reference is held. Holding it keeps the debt interest accrued while the collateral
67-
/// quote is flat (the collateral oracle may only reprice periodically) inside the basis, so
68-
/// the next collateral repricing is charged net of the full inter-repricing debt carry
69-
/// rather than only the last accrual interval's. It also prevents re-charging a recovery
74+
/// the reference is held; it is also held when a positive basis carries a performance
75+
/// entitlement that converts to zero fee shares, so the gain accumulates instead of being
76+
/// consumed by the advance (see the hold at the end of this function). Holding it keeps
77+
/// the debt interest accrued while the collateral quote is flat (the collateral oracle
78+
/// may only reprice periodically) inside the basis, so the next collateral repricing is
79+
/// charged net of the full inter-repricing debt carry rather than only the last accrual
80+
/// interval's. It also prevents re-charging a recovery
7081
/// after a drawdown: the reference stays at the old peak instead of resetting down.
7182
/// Crystallizing on a positive basis even when the configured rate is zero (or no recipient
7283
/// is set) mirrors the pre-existing behavior that gains realised before fees are enabled
@@ -130,9 +141,10 @@ abstract contract PositionManagerBase is OwnableRoles, ERC20, ReentrancyGuardTra
130141
/// @return managementFeeShares The shares that would be minted for management fees
131142
/// @return performanceFeeShares The shares that would be minted for performance fees
132143
/// @return advanceReference True when `_accrueFees` must advance the performance reference to
133-
/// the current state (positive basis, bootstrap, or empty vault); false to hold it.
134-
/// Always false while any module is excluded as bad debt (see the partial bad-debt
135-
/// episode rule above)
144+
/// the current state (positive capped basis, bootstrap, or empty vault); false to hold
145+
/// it. Always false while any module is excluded as bad debt (see the partial bad-debt
146+
/// episode rule above) and while a performance entitlement would mint zero shares (see
147+
/// the reference-advance rule above)
136148
/// @return heldManagementFees_ The value `_accrueFees` must persist as the held management fee
137149
/// accumulator: zero when the reference advances (deduction consumed or forgiven),
138150
/// otherwise the stored accumulator plus the management fee charged this interval
@@ -198,6 +210,16 @@ abstract contract PositionManagerBase is OwnableRoles, ERC20, ReentrancyGuardTra
198210
}
199211
}
200212

213+
// Cap the levered basis at the NAV gain since the reference (zero-floored): a seizure
214+
// (liquidation) leaves the levered read positive while NAV fell, and an uncapped basis
215+
// would mint a fee on the loss. Capped to zero the reference is held, so a recovery is
216+
// only charged past the pre-loss mark (see the cap notes in the header). On internal
217+
// paths the cap is slack. Outside the block above for stack depth.
218+
if (basis > 0) {
219+
basis = basis.min(FixedPointMathLib.zeroFloorSub(totalAssets_, _storage.lastTotalAssets));
220+
advanceReference = basis > 0;
221+
}
222+
201223
// Loaded after the basis block to keep the peak stack depth flat (the block above is at
202224
// the non-via-ir stack limit).
203225
FeeData memory fd = _storage.feeData;
@@ -235,10 +257,10 @@ abstract contract PositionManagerBase is OwnableRoles, ERC20, ReentrancyGuardTra
235257
// zero, and `convertToShares` against a zero base would mint an inflated share count to the
236258
// fee recipient, confiscating the pool.
237259
// - `totalFeeAssets > totalAssets_`: should not occur under current invariants
238-
// (`managementFeeAssets` is capped at `totalAssets_`, and the perf basis is bounded by
239-
// `totalAssets_` because `scaledLastDebt <= currentCollat` whenever `lastDebt <= lastCollat`
240-
// and `performanceFee <= BPS`). Folding it in here makes the subsequent subtraction safe
241-
// without depending on that invariant chain.
260+
// (`managementFeeAssets` is capped at `totalAssets_`, and the perf basis is capped at
261+
// `totalAssets_ - lastTotalAssets` by the NAV-gain clamp, with `performanceFee <= BPS`).
262+
// Folding it in here makes the subsequent subtraction safe without depending on that
263+
// invariant chain.
242264
// `_accrueFees` still refreshes `lastFeeAccrualTimestamp` (and the reference, per
243265
// `advanceReference`) so normal accrual resumes next call. Nothing is minted, so this
244266
// interval's management fee does not join the held accumulator either.
@@ -265,6 +287,18 @@ abstract contract PositionManagerBase is OwnableRoles, ERC20, ReentrancyGuardTra
265287
);
266288
performanceFeeShares -= managementFeeShares;
267289
}
290+
291+
// Hold the reference when a nonzero performance entitlement (`totalFeeAssets` above the
292+
// management component) converts to zero fee shares: advancing would consume the gain
293+
// without paying it, so checkpoint splitting could erase the fee. Held, the basis keeps
294+
// accumulating until it mints at least one share, and this interval's management fee joins
295+
// the held accumulator like any held accrual (see the reference-advance rule in the
296+
// header; an entitlement that already rounds to zero assets still advances, forgiven as
297+
// rounding dust).
298+
if (advanceReference && performanceFeeShares == 0 && totalFeeAssets > managementFeeAssets) {
299+
advanceReference = false;
300+
heldManagementFees_ = _storage.heldManagementFeeAssets + managementFeeAssets;
301+
}
268302
}
269303

270304
/// @dev Accrues fees (management + performance) and mints shares to the fee recipient.

‎src/manager/base/PositionManagerLP.sol‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,17 @@ abstract contract PositionManagerLP is IPositionManagerLP, PositionManagerBase {
166166
bool skipLtvCheck = _storage.withdrawalQueue.length == _storage.borrowModules.length();
167167
_storage.processWithdrawal(collateral, debt, strategy, !skipLtvCheck);
168168

169+
// Rebase the performance reference across the flow so the exiting shares take their
170+
// proportional slice of any carried pending basis with them. Rebased before the outgoing
171+
// transfer so a token callback cannot move module state into the new reference; the
172+
// transfer itself changes nothing the rebase reads (module aggregates only).
173+
_rebaseReference(totalAssetsBefore, debtBefore, _totalSupply);
174+
169175
// Send collateral to caller
170176
if (collateral > 0) {
171177
_storage.metadata.collateralAsset.safeTransfer(msg.sender, collateral);
172178
}
173179

174-
// Rebase the performance reference across the flow so the exiting shares take their
175-
// proportional slice of any carried pending basis with them.
176-
_rebaseReference(totalAssetsBefore, debtBefore, _totalSupply);
177-
178180
emit Burn(msg.sender, shares, collateral, debt);
179181
}
180182

0 commit comments

Comments
 (0)