Skip to content

Commit c0c047b

Browse files
authored
fix: performance-fee reference and basis (NAV-gain cap, checkpoint conservation, pre-transfer rebase) (#211)
* 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). * fix: harden fee-reference fixes from PR review Three residuals from the PR #211 review round: - rebaseSnapshot now takes the larger of the levered carry and the NAV deficit, so a seizure mark survives repeated flows instead of decaying through the levered read once a carry exists. - A carry at or above the post-flow debt no longer floors the reference debt at the bootstrap sentinel: the mark re-anchors at NAV + carry with the reference debt at the pre-flow reference LTV, so fees stay suspended until NAV clears the preserved mark and resume on the same terms as before the flow. - A held positive pending entitlement (zero-share or BPS-stage hold) is preserved across flows, capped at the NAV gain above the mark and scaled with the supply, so economically empty rebalances cannot erase the fee; _pendingFees additionally holds the reference when the BPS multiplication rounds a nonzero net entitlement to zero fee assets. Seven new or updated tests, each verified to fail against the previous code at the finding-specific assertion. Full suite: 2328 tests plus 12 invariant tests pass; fmt checked with the CI forge binary.
1 parent acde2ad commit c0c047b

7 files changed

Lines changed: 1054 additions & 144 deletions

File tree

‎src/libs/manager/LibStorage.sol‎

Lines changed: 108 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,11 @@ 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, or sits below it by a preserved positive
74+
/// pending gain (see `rebaseSnapshot`); after a seizure loss, flows convert the
75+
/// NAV drawdown against it into that carry (see `rebaseSnapshot`). `lastCollat` is
76+
/// reconstructed on the fly as `lastTotalAssets + lastDebt` rather than stored directly,
77+
/// which keeps the storage layout append-only and existing integrations unchanged.
7678
/// @param ltv Loan-to-value ratio in 18-decimal fixed point (e.g., 0.86e18 = 86%).
7779
/// A small buffer above the target LTV that determines how much collateral can be withdrawn.
7880
/// @param virtualShareOffset Virtual shares offset for inflation attack protection, derived from debt asset decimals.
@@ -182,36 +184,49 @@ library LibStorage {
182184
/// share supply without realising a gain, so the reference must move with them: the carry
183185
/// (the negative pending basis accumulated while the reference is held, typically accrued
184186
/// 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-
/// deposits re-attach it to the new shares, and supply-neutral flows (rebalance, module
187-
/// changes) keep it unchanged. A holder therefore cannot shed accrued debt carry by
188-
/// exiting and re-entering, and an exit does not dump its carry slice on the stayers.
187+
/// re-anchored on the post-flow state. The carry taken is the larger of the levered read
188+
/// and the NAV deficit (mark minus pre-flow NAV): a seizure leaves NAV below the mark
189+
/// while the levered read stays non-negative, and taking the deficit there keeps the
190+
/// high-water mark that the NAV-gain cap in `_pendingFees` measures against intact
191+
/// across any number of flows, even when the deficit exceeds the remaining debt (see
192+
/// the oversized-carry encoding below); an ordinary drawdown (levered carry at or above
193+
/// the deficit) keeps the pre-existing levered-carry semantics. Exits shed their
194+
/// proportional slice of the carry, deposits re-attach it to the new shares, and
195+
/// supply-neutral flows (rebalance, module changes) keep it unchanged. A holder
196+
/// therefore cannot shed accrued debt carry by exiting and re-entering, and an exit
197+
/// does not dump its carry slice on the stayers.
189198
///
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.
199+
/// Called after `_accrueFees`, which crystallizes a positive basis except in two held
200+
/// states: a NAV-capped basis (seizure loss, see the cap in `_pendingFees`) and a
201+
/// performance entitlement that rounds to zero fee assets or shares. Both survive the
202+
/// flow: the seizure as the carried deficit above, and the held entitlement as a
203+
/// preserved pending gain (capped at the NAV gain above the mark and scaled with the
204+
/// supply), encoded as reference debt above the live debt so the next accrual reads the
205+
/// same capped basis back. Without that preservation, repeated economically empty flows
206+
/// (zero-op rebalances at cooldown cadence) would forgive each interval's entitlement
207+
/// and erase the fee. The held management fee accumulator nets against the next
208+
/// crystallization as usual. Rounding matches `_pendingFees` (`mulDivUp` on the scaled
209+
/// reference debt), so the carry is the exact complement of the fee basis and each flow
210+
/// can only shrink it (or the preserved gain) by rounding dust, never create a spurious
211+
/// positive basis.
195212
///
196213
/// 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).
214+
/// accrual freezes the reference instead of crystallizing (see `_pendingFees`), so a
215+
/// flow here compares the frozen full-universe mark with reduced aggregates and the
216+
/// re-anchor is approximate. When the exclusion hides a loss, the seizure conversion
217+
/// measures the deficit against the reduced universe and holds a mark near the frozen
218+
/// full-universe one (the oversized-carry encoding keeps it even above the visible
219+
/// debt), so the post-window recovery is charged only past that mark; when the visible
220+
/// NAV sits above the frozen mark, the levered read can carry a phantom gain, preserved
221+
/// only up to the visible NAV gain above the mark. Either way the re-anchor can under-
222+
/// or over-read the later recovery. Remedies for a mis-anchored window flow: an owner
223+
/// reset once the window has closed re-anchors at the live state, but it crystallizes
224+
/// any positive pending basis first, so to avoid charging a mis-read recovery the
225+
/// owner should instead set the performance fee rate to zero right after the flow,
226+
/// let the first accrual with a positive basis advance the reference mintlessly (a
227+
/// positive basis advances even at a zero rate), and then restore the rate. An
228+
/// under-read needs no action (fees resume at a genuine new high, or an owner reset
229+
/// re-anchors sooner).
215230
///
216231
/// The held management fee accumulator (`heldManagementFeeAssets`, the management fees
217232
/// charged since the reference last advanced, deducted from the next positive basis) is
@@ -227,10 +242,13 @@ library LibStorage {
227242
/// which matches its definition (fees charged since the last advance).
228243
///
229244
/// Edge cases collapse to a plain snapshot of the current state (zero carry): bootstrap
230-
/// (`lastDebt == 0` sentinel), an empty vault before the flow, and a carry exceeding the
231-
/// 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).
245+
/// (`lastDebt == 0` sentinel), an empty vault before the flow, and a degenerate
246+
/// zero-NAV reference (its leftover carry is forgiven through the zero floor as dust).
247+
/// A carry at or above the post-flow debt, including a flow that unwinds the debt to
248+
/// zero, no longer collapses to the sentinel: the oversized-carry encoding re-anchors
249+
/// the mark at `NAV + carry` at the pre-flow reference LTV instead (see the write-out
250+
/// branches). An empty good-debt universe after the flow instead holds the
251+
/// reference (see the bad-debt episode below).
234252
///
235253
/// Bad-debt episode: whenever the flow leaves the good-debt universe empty
236254
/// (`newCollat == 0` with a live reference), there is no good-debt state to re-anchor on,
@@ -269,13 +287,37 @@ library LibStorage {
269287
// accrual advances the reference anyway (empty vault), so no stale mark survives it.
270288
if (refDebt > 0 && newCollat == 0) return;
271289
uint256 carry;
290+
uint256 gain;
272291
if (refDebt > 0 && prevSupply > 0 && newSupply > 0) {
273292
uint256 refCollat = self.lastTotalAssets + refDebt;
274293
uint256 scaledRefDebt = refDebt.mulDivUp(prevCollat, refCollat);
275294
uint256 prevCarry = FixedPointMathLib.zeroFloorSub(prevDebt, scaledRefDebt);
295+
// Seizure state: a liquidation cuts collateral and debt in a ratio that leaves NAV below
296+
// the mark while the levered read stays non-negative, so the levered carry alone would
297+
// drop the mark toward the post-loss trough. Take the larger of the levered carry and
298+
// the NAV deficit (mark minus pre-flow NAV, zero when NAV is at or above the mark): the
299+
// deficit exceeds the levered carry exactly when quoted collateral sits below the
300+
// reference collateral (the seizure signature), so ordinary drawdowns keep the levered
301+
// carry semantics while a seizure holds the full NAV mark, including across repeated
302+
// flows (a converted deficit reads back through this same branch, not the levered one).
303+
// The empty pre-flow universe is excluded (full-episode re-anchor, see the header).
304+
if (prevCollat > 0) {
305+
uint256 deficit = FixedPointMathLib.zeroFloorSub(self.lastTotalAssets + prevDebt, prevCollat);
306+
if (deficit > prevCarry) prevCarry = deficit;
307+
}
308+
// Held positive pending basis (the zero-fee-share hold in `_pendingFees`): preserve it
309+
// across the flow instead of re-anchoring on top of it, or repeated economically empty
310+
// flows (e.g. zero-op rebalances at cooldown cadence) would forgive each interval's
311+
// entitlement and erase the performance fee. Capped at the NAV gain above the mark,
312+
// mirroring the cap in `_pendingFees`, so a seizure state (NAV at or below the mark)
313+
// never reads a preservable gain. Mutually exclusive with the carry by construction.
314+
if (prevCarry == 0) {
315+
gain = FixedPointMathLib.zeroFloorSub(scaledRefDebt, prevDebt)
316+
.min(FixedPointMathLib.zeroFloorSub(prevCollat - prevDebt, self.lastTotalAssets));
317+
gain = gain.mulDiv(newSupply, prevSupply);
318+
}
276319
// Preserve the per-share carry across the supply change.
277320
carry = prevCarry.mulDiv(newSupply, prevSupply);
278-
if (carry > newDebt) carry = newDebt;
279321
// Preserve the per-share pending management fee deduction the same way. Skipped when the
280322
// pre-flow good-debt universe is empty (a rescue flow out of a full bad-debt episode):
281323
// shares are then minted against a zero asset base, so the supply ratio is unmoored from
@@ -289,9 +331,37 @@ library LibStorage {
289331
}
290332
}
291333
}
292-
uint256 newRefDebt = newDebt - carry;
334+
uint256 newRefDebt;
335+
uint256 newRefTotalAssets;
336+
if (gain > 0) {
337+
// Held positive basis: encode it as reference debt above the live debt, so the mark
338+
// (`lastTotalAssets`) lands at NAV minus the gain and the NAV-gain cap in `_pendingFees`
339+
// reads back exactly the preserved entitlement. Clamped at `newCollat` so the reference
340+
// NAV stays non-negative (the clamp truncates the gain to the post-flow NAV).
341+
newRefDebt = (newDebt + gain).min(newCollat);
342+
newRefTotalAssets = newCollat - newRefDebt;
343+
} else if (carry >= newDebt && carry > 0 && self.lastTotalAssets > 0) {
344+
// Oversized carry (a seizure NAV deficit at or above the remaining debt, e.g. after a
345+
// near-full liquidation): the standard encoding would floor the reference debt at zero,
346+
// which is the bootstrap sentinel, and the next accrual would reseed the high-water mark
347+
// at the post-loss trough. Re-anchor the mark at `NAV + carry` and keep the pre-flow
348+
// reference LTV (reference debt proportional to the mark at the old debt-to-NAV ratio):
349+
// the mark survives, later flows read the full deficit back through the max branch
350+
// above, and the levered read turns positive again on the same terms as before the
351+
// flow, so fees resume once NAV clears the preserved mark instead of staying dormant
352+
// behind a diluted reference LTV. `refDebt` still holds the pre-flow stored value here.
353+
// Rounding up keeps the reference debt nonzero (never the sentinel) and biases the
354+
// levered read larger, consistent with `_pendingFees`.
355+
newRefTotalAssets = newCollat - newDebt + carry;
356+
newRefDebt = newRefTotalAssets.mulDivUp(refDebt, self.lastTotalAssets);
357+
} else {
358+
// `carry < newDebt` here (or a degenerate zero-NAV reference, whose leftover carry is
359+
// forgiven through the zero floor as dust): the subtraction cannot reach the sentinel.
360+
newRefDebt = FixedPointMathLib.zeroFloorSub(newDebt, carry);
361+
// Good-debt aggregation guarantees newCollat >= newDebt >= newRefDebt.
362+
newRefTotalAssets = newCollat - newRefDebt;
363+
}
293364
self.lastDebt = newRefDebt;
294-
// Good-debt aggregation guarantees newCollat >= newDebt >= newRefDebt.
295-
self.lastTotalAssets = newCollat - newRefDebt;
365+
self.lastTotalAssets = newRefTotalAssets;
296366
}
297367
}

0 commit comments

Comments
 (0)