@@ -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