Skip to content

fix: performance fee debt-carry write-off (hold reference unless crystallized)#202

Open
maxencerb wants to merge 4 commits into
mainfrom
fix/performance-fee-debt-carry
Open

fix: performance fee debt-carry write-off (hold reference unless crystallized)#202
maxencerb wants to merge 4 commits into
mainfrom
fix/performance-fee-debt-carry

Conversation

@maxencerb

@maxencerb maxencerb commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Performance fee: hold the reference unless crystallized, rebase on flows

Problem 1 (fixed): debt-carry write-off / high-water-mark violation

The performance reference (lastTotalAssets, lastDebt, i.e. LTV_ref) was advanced to the current state on every accrual (_accrueFees) and every flow tail (updateSnapshot in _settleShares, burn, rebalance, module add/remove).

With a collateral quote that only reprices periodically (the fund token's virtualPrice steps ~monthly) while Morpho debt accrues continuously:

  • During a flat stretch each accrual sees a negative basis (no fee) but still ratchets LTV_ref up, absorbing the accrued interest into the reference.
  • At the next repricing step the basis nets only the last accrual interval's debt carry instead of the full inter-repricing carry, so the fee is systematically too high for every holder.
  • The same reset re-taxed drawdown recoveries: after a fee crystallized at a peak, a dip reset the reference down and the recovery back to the peak was charged again as fresh alpha.

On-chain evidence (tx 0xbea2e5f8ec03680324a89198290e2f69d2337d7888f17ea1b465c254b0339e4d, block 25448276, manager 0x15ad372AA8b288FFAb36f3E260f8f41F03b23a62): 286.07 fee shares minted (12.26 mgmt + 273.81 perf). currentDebt (380,291 USDC) contained a full month of interest, but the reference had been reset ~7 days earlier, so the basis (1,918 USDC) netted only ~400 USDC of carry instead of ~2,000. With the full month's carry netted the perf fee would have been roughly a third of what was charged. State verified against the RPC at block 25448275.

The fix

Two coupled rules (handoff Solution B):

  1. Advance on crystallization only (_accrueFees / _pendingFees): the reference advances to the current state only when the basis is positive (a fee crystallizes, at the configured rate which may be zero), on bootstrap (lastDebt == 0 sentinel), or on an empty vault. A non-positive basis holds the reference, so accrued debt carry and drawdowns stay inside the basis (true high-water mark). lastFeeAccrualTimestamp still advances every accrual (management fee is time-based and unaffected).

  2. Flows rebase instead of reset (LibStorage.rebaseSnapshot, replacing updateSnapshot): deposits, withdrawals, burns, rebalances and module add/remove preserve the pending per-share basis across the flow:

    carry     = prevDebt - mulDivUp(refDebt, prevCollat, refCollat)   // negative basis magnitude
    carry'    = carry * newSupply / prevSupply                        // preserve per-share
    refDebt'  = newDebt - carry'   (floored at 0 = bootstrap sentinel)
    lastTotalAssets' = newCollat - refDebt'
    

    Exits shed their proportional slice of the carried debt cost (they don't dump it on stayers and don't escape it), re-entries re-attach it (no exit/re-enter gaming), and supply-neutral flows (rebalance, module changes) keep it unchanged. Rounding matches _pendingFees (mulDivUp on the scaled reference debt), so a flow can never manufacture a positive basis.

  3. Owner escape hatch for permanent losses (resetPerformanceReference, owner-only): a true high-water mark cuts both ways — after a permanent drawdown or a realized liquidation loss the held reference would suppress performance fees until the pool recovers past the old mark, which may never happen. The owner can force-advance the reference to the current state: fees accrue first (a positive pending basis crystallizes normally to the current recipient), then the reference re-anchors, so the reset can never charge past gains — it only forgives the carried negative basis, and future gains are charged from the current state onward. Called while every position is underwater it writes the bootstrap sentinel (the next accrual reseeds), which is the same documented semantics as a rescue flow.

    Operational timing rule (documented in the NatSpec): the reset writes off all carried basis — not just the loss being accepted but also the debt interest accrued since the last crystallization — plus the pending management fee deduction (below), and the next positive accrual then overcharges by exactly those forgiven amounts (this is the per-accrual reset behavior of the old code, restored at one chosen moment). So reset as soon as possible after a positive performance fee charge, when the pending carry and deduction are smallest; a reset deep into a flat-quote period re-introduces the debt-carry write-off for that period.

  4. Management fees charged while held are netted from the next crystallization (heldManagementFeeAssets): the performance fee must be net of management fees, but management fees are minted on every accrual (they are time-based and must keep being charged, including to mid-period exiters, who pay them through each flow's accrual). Previously only the current interval's management fee was deducted from the basis, so management fees minted during held (non-positive basis) intervals were forgotten and the next crystallization overcharged by perfRate × those amounts. Now each held accrual adds its management fee assets to an accumulator; the crystallizing basis is reduced by accumulator + current interval, and the accumulator clears whenever the reference advances (any excess above the basis is forgiven, not carried past the new mark) and on resetPerformanceReference (a reset forgives the pending deduction along with the carry). Flows scale the accumulator with the share-supply change exactly like the debt carry — an exit takes its slice of the pending deduction along, a deposit re-attaches it — so it cannot be dumped on stayers or shed by round trips. The accumulator is a new storage slot appended to the end of the ERC-7201 namespaced struct (no existing field moves; on the live-vault upgrade it reads zero, so netting starts fresh with no retroactive adjustment). Note for integrators: the feeData() view gained a sixth return value (heldManagementFees) — an ABI change to that view.

Bad-debt episodes: while every position is excluded as bad debt, both accruals and flows hold the reference, so the high-water mark survives an underwater dip and the recovery is not re-charged (an adversarial review round found and fixed a case where a flow during the dip collapsed the reference to the trough). A flow that brings the pool back above water re-anchors at the post-flow state; this is documented as a limitation of the binary bad-debt exclusion in LibView.totalAssets (unreachable in practice before liquidation).

Worked examples (old vs fixed)

All examples use the same starting position: 10,000 collateral units quoted at 1.00, 5,000 debt, so LTV_ref = 50%; 15% performance fee, management fee set to 0 for clarity; Morpho debt accrues ≈ 0.41%/month. Fee figures are in asset units; basis = LTV_ref × collatQuoted − debt. Numbers below are actual outputs of the test-suite mechanics (Morpho fork mocks), not hand math.

1. Flat month, then the oracle steps +2% (the core bug)

event state fixed behavior old behavior
t0 deposit C 10,000, D 5,000 ref = (10,000 / 5,000) same
30 flat days, several accruals D grows to 5,020.6 basis −20.6, reference held, no fee each accrual resets ref; last one leaves LTV_ref = 50.21%
+2% step, accrual C 10,200 basis = 50% × 10,200 − 5,020.6 = +79.4 → fee 11.91 basis = 50.21% × 10,200 − 5,020.6 = +100.4 → fee 15.06

The old fee ignores the month of debt interest except the slice since the last accrual (27% over-charge here; on the mainnet trigger tx the same mechanism produced a ~3x over-charge because the last reset was only 7 days before the jump). The fixed fee is also invariant to how many accruals ran during the flat month (tested for 1 vs 10).

2. Collateral drawdown and recovery (high-water mark)

event state fixed behavior old behavior
crystallize at 1.20 C 12,000, D 5,000 basis +1,000 → fee 150; ref → (12,000 / 5,000), LTV_ref 41.7% same
drop to 0.95, 10 days C 9,500, D 5,006.9 basis −1,048.5, held, no fee ref resets to trough, LTV_ref = 52.7%
recover to 1.20 C 12,000 basis −6.9 (10 days of interest keep it just under the mark) → no fee basis +1,317.6 → fee 197.6 charged on pure recovery (more than the original peak fee)
new high 1.25 C 12,500 basis +201.5 → fee 30.2 (only the increment above the mark) another +208.6 → fee 31.3 on top

Round-trip total: fixed 30.2 vs old 228.9.

If the drawdown were permanent (the quote never recovers past the 1.20 mark), the owner can call resetPerformanceReference() at the trough: the reference re-anchors at 0.95 and fees resume on gains from there, without waiting for a recovery that never comes.

3. Liquidation during a crash

Continues example 1's flat month (carry −20.6), then the quote crashes to 0.60 (position at 83.7% LTV > 80% LLTV, liquidatable). A liquidator seizes 2,000 collateral units (quoted 1,200) and repays 1,128 debt (Morpho incentive ≈ 6.4%), leaving C 4,800 / D 3,892.6.

event fixed behavior old behavior
crash to 0.60, accrual basis −2,020.6, held, no fee ref resets to trough, LTV_ref = 83.7%
liquidation basis rises to −1,492.6: the repay leg deleverages the pool, consuming repaid − LTV_ref × seizedQuoted = 1,128 − 600 = 528 of the carry shield; still deeply negative
post-liquidation accrual held, no fee basis +123.9 → fee 18.6 minted at the trough, ref resets again (LTV_ref = 81.1%)
full recovery to 1.00 basis +107.4 → fee 16.1 basis +2,595.1 → fee 389.3 on the recovery

Two things to note. First, the fixed residual (16.1) is not zero: a liquidation repays debt by selling collateral, which lands the pool at a lower LTV than the reference, and the levered-slice basis reads that as debt-slice outperformance once the quote recovers. It is bounded by repaid × (1 − LTV_ref × LIF) and only materializes on a genuine price recovery. This semantic is unchanged from the old code (the basis taxes the levered slice, not a NAV high-water mark; NAV here is still 4,107 vs 5,000 at entry). Second, what the fix removes is the amplification: the old trough resets ratcheted LTV_ref from 50% to 81%, taxing the entire recovery (407.9 total vs 16.1, ~25x).

If the post-liquidation loss is judged permanent (no recovery to the old mark expected), the owner can resetPerformanceReference() to re-anchor at the post-liquidation state and resume charging on gains from there.

4. Bad-debt episode (all positions underwater)

Quote crashes to 0.40: C quoted 4,000 < D 5,020.6, so the module drops out of the good-debt aggregates entirely (NAV 0, aggregates (0,0)).

event fixed behavior old behavior
accruals while underwater basis reads 0 (not positive) → reference held at (10,000 / 5,000) accrual snapshots (0, 0): sentinel
flows while still underwater (deposit, rebalance repay that stays under) reference held (review fix: rebasing against empty aggregates would collapse the mark to the trough) flow tail snapshots current state
recovery to 1.00, accrual basis ≤ 0 vs the pre-dip reference → no fee sentinel reseeds at whatever level the first post-recovery accrual sees; any accrual mid-recovery (e.g. at 0.70) re-anchors there and the rest of the recovery is charged as fresh alpha
gain to 1.05 basis ≈ +250 → fee 37.5, only above the pre-dip mark depends on accrual timing; can tax most of the recovery

Caveat (documented in rebaseSnapshot NatSpec and pinned by test): a rescue flow that lifts the pool back above water in the same transaction (e.g. a rebalancer repaying 2,000 while underwater, leaving 4,000 quoted vs 3,000 debt) re-anchors the reference at the post-rescue state, because the pre-flow basis is not measurable against empty aggregates; the recovery beyond that point is charged. In production this whole family of states requires the price to gap past the 78% liquidation LTV to >100% without liquidation, which is not reachable under normal operation.

Storage layout is append-only (ERC-7201): the two existing slots are reinterpreted as the reference, and one new slot (heldManagementFeeAssets) is appended at the end of the namespaced struct — no existing field moves. On upgrade, a live vault continues from its last-accrual snapshot; carry accrued after the upgrade is netted at the next repricing, and the management fee accumulator starts at zero (fees charged before the upgrade are not retro-netted). No retroactive charge or forgiveness.

Fee direction: absent an owner reset, the fix only ever reduces the performance fee relative to the old behavior (under continuously repriced, monotonically rising collateral the two are identical); it favors LPs and removes the over-charge. A positive basis still advances the reference even when it mints nothing (no recipient, zero rate, gain below the management-fee deduction, fee-cap guard), preserving the existing "gains before fees are enabled are never retroactively charged" semantics. An owner reset is bounded by the same rule: because it accrues first and then re-anchors at the observed current state, the worst it can do is restore the old per-accrual reset behavior at one chosen moment (forgiving held carry going forward); it cannot mint on past gains or set an arbitrary reference.

Optimized (profile.ci) PositionManager runtime size: 21,816 → 22,501 bytes (margin 2,075 bytes under the EIP-170 limit).

Problem 2 (not fixed here, documented): stale-quote entry

A mid-flat-period entrant is booked at the stale (low) collateral quote: under-credited at entry, then charged performance fee on the phantom repricing "gain" that predates their entry. This is an entry-pricing problem, not a fee-reference problem — it exists under any performance-reference mechanism and can only be fixed at the pricing layer:

  • fresher virtualPrice cadence on the collateral oracle (fixes both problems at the source, but the oracle is external), or
  • gating deposits/withdrawals to just after an epoch update, or
  • accepting the cohort cross-subsidy as a documented limitation of monthly-priced collateral.

Per the handoff, this PR documents the limitation and leaves the pricing-layer decision open.

Tests

New test/manager/PositionManagerFeeReference.t.sol (handoff §5 scenarios) on real Morpho + IrmMock (debt accrues with vm.warp, quote stepped via the oracle mock):

  • flat month + step: fee nets the full period's carry; explicit comparison showing the old ratcheted reference over-charges by at least the written-off interest
  • fee invariant to the number of flat-period accruals (1 vs 10)
  • high-water mark: drawdown + recovery not re-charged; only the increment above the peak is
  • bad-debt dip and recovery respects the pre-dip reference; bootstrap sentinel reseeds cleanly after full repayment
  • partial exit: stayers' per-share pending basis unchanged; crystallized fee scales with the remaining pool
  • exit-and-reenter round trip cannot shed carry (matches a continuous holder within 0.1%)
  • pure flows at any LTV preserve per-share basis, never mint a fee, never create a spurious pending gain
  • rebalance preserves the dollar carry (supply-neutral) and never crystallizes
  • pendingFees() in lockstep with _accrueFees mints both while held and at crystallization
  • management fee accrues at the full rate and timestamp advances while the reference is held
  • bad-debt episode: flow during the dip holds the reference; rescue flow re-anchor pinned; partial bad-debt module exclusion/re-entry produces no spurious basis
  • the four advance-without-mint branches (no recipient, zero perf rate, gain below mgmt deduction, fee-cap early return) all advance the reference
  • owner reset: forgives the carry after a drawdown (fees resume below the old mark), owner-only, crystallizes a positive pending basis before moving (no forgiveness and no double charge of pending gains), settles the management fee and advances the accrual timestamp, clears the pending management fee deduction, writes the sentinel during a full bad-debt episode, anchors on the good-debt universe during partial bad debt (a later module re-entry is charged as new gain)
  • management fee netting: the accumulator equals the exact sum of management fees charged while held; the crystallizing fee nets held + current interval (the netting removes exactly perfRate × held versus the single-interval deduction); the netted perf fee is invariant to the accrual cadence (1 vs 5 vs 10 accruals through the same flat month, exact share-level equality); a small gain below the pending deduction advances without a perf mint and the consumed deduction does not carry past the new mark; the deduction scales with supply across flows (unit + fuzz + integration)
  • management fee netting edge paths (each pinned after mutation testing showed it unguarded): a rescue deposit out of a full bad-debt episode keeps the deduction nominal (scaling by the unmoored mint ratio would have suppressed the whole first post-recovery crystallization); a fee holiday (no recipient) neither wipes nor grows the deduction, and an advance during the holiday clears it; accruals during a bad-debt dip charge nothing and preserve it; the fee-cap no-mint guard does not add the unminted interval fee to it

LibStorage.t.sol unit tests for rebaseSnapshot (sentinel, supply-neutral, exit scaling, carry clamp, underwater hold, per-share-preservation fuzz).

Full suite: 1,765 tests + invariant suites pass. Each commit was run through an adversarial multi-agent review (independent finder lenses; every finding verified by refutation-oriented agents, majority-confirm):

  • core diff (5 lenses, 20 raw findings): 3 confirmed (underwater-flow HWM wipe, stale bad-debt NatSpec, missing advance-without-mint tests), fixed in the second commit;
  • escape hatch (4 lenses, 7 raw findings): none confirmed; the raised points are the documented owner-trust semantics (the owner already holds stronger levers such as setFeeData up to the capped rates, and every reset is observable via PerformanceReferenceReset), plus two test gaps covered in that commit;
  • management fee netting (4 lenses, 6 raw findings): 1 code defect confirmed with an end-to-end PoC (the rescue-flow deduction inflation, fixed by the nominal-on-rescue rule and pinned by tests) plus 3 mutation-tested coverage gaps (fee holiday, bad-debt dip accrual, fee-cap guard), all now pinned.

Change size (production src/ only, vs main)

Measured against the merge-base with main (96a18e3), Solidity under src/ only (tests and scripts excluded).

Metric Value
src/ files changed 8
Raw diff (git) +424 / −147 lines
Solidity code lines (cloc, comments & blanks excluded) +97 added / 53 modified / −13 removed (≈163 code lines touched)

Summary by CodeRabbit

  • New Features

    • Added a manual performance-reference reset option for edge cases like prolonged drawdowns or realized losses.
    • Fee reporting now includes an additional held-management-fees value for improved visibility.
  • Bug Fixes

    • Improved fee and reference handling across deposits, withdrawals, rebalancing, and module changes so accrued basis is preserved more consistently.
    • Management fees are now carried and settled more accurately across changing supply and recovery scenarios.

maxencerb added 2 commits July 7, 2026 18:49
The performance reference (lastTotalAssets/lastDebt) was advanced to the
current state on every accrual and every flow tail. Under a periodically
repriced collateral quote this wrote the debt interest accrued during flat
stretches off the performance basis, so the fee at each repricing step was
charged as if only the last accrual interval's carry had been incurred
(systematic over-charge), and a drawdown reset the reference down so the
recovery was re-taxed (high-water-mark violation).

The reference now advances only when a positive basis crystallizes (or on
bootstrap/empty vault) and is held otherwise. Capital flows (deposit,
withdraw, burn, rebalance, module add/remove) rebase it so the pending
per-share basis is preserved: exits shed their proportional slice of the
carried debt cost, re-entries re-attach it, and supply-neutral flows keep
it unchanged. Storage layout is unchanged; the two existing slots are
reinterpreted as the reference.
…t paths

Review findings: a flow executed while every position was excluded as bad
debt reached rebaseSnapshot with empty good-debt aggregates, computed zero
carry, and collapsed the held high-water mark to the trough (or the
bootstrap sentinel), re-charging the subsequent recovery. Hold the
reference when the good-debt universe is empty on both sides of the flow,
matching what accruals do during the same episode. A flow that brings the
pool back above water still re-anchors at the post-flow state; documented
as a limitation of the binary bad-debt exclusion.

Also rewrites the stale bad-debt NatSpec paragraph that described the
removed always-advance behavior, and adds tests for the four
advance-without-mint branches (no recipient, zero perf rate, gain below
the mgmt deduction, fee-cap early return) plus underwater-flow, rescue
re-anchor, and partial-bad-debt scenarios.
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR replaces the position manager's snapshot-based performance-fee tracking with a held high-water-mark reference model. It adds a heldManagementFeeAssets accumulator, replaces updateSnapshot with rebaseSnapshot, extends feeData() return values, adds an owner-only resetPerformanceReference() escape hatch, and updates fee accrual logic across LP, rebalancing, and admin flows.

Changes

Held performance reference and management-fee accumulator

Layer / File(s) Summary
Interface and storage contract updates
src/interfaces/manager/IPositionManager.sol, src/interfaces/manager/base/IPositionManagerAdmin.sol, src/libs/manager/LibStorage.sol
NatSpec updated for feeData(), lastDebt(); new PerformanceReferenceReset event and resetPerformanceReference() function declared; heldManagementFeeAssets field added to storage struct.
rebaseSnapshot core logic and tests
src/libs/manager/LibStorage.sol, test/libs/manager/LibStorage.t.sol, test/mock/libs/LibManagerStorageHarness.sol
updateSnapshot removed and replaced with rebaseSnapshot, which re-anchors the reference and scales carried basis and held fees across flows; new deterministic and fuzz tests validate the behavior.
Fee accrual logic reworked for held reference
src/manager/base/PositionManagerBase.sol, src/manager/PositionManager.sol
_pendingFees/_accrueFees compute advanceReference and heldManagementFees_; reference only advances on positive basis/bootstrap/empty vault; feeData() and pendingFees() updated for the extended tuple.
LP, rebalancing, and admin flow wiring
src/manager/base/PositionManagerLP.sol, src/manager/base/PositionManagerRebalancing.sol, src/manager/base/PositionManagerAdmin.sol
Deposit/withdraw/burn, rebalance, and add/remove-module flows capture pre-flow debt/assets and call _rebaseReference; resetPerformanceReference accrues fees, overwrites the reference, clears held fees, and emits the reset event.
Fee-reference behavior test suite and downstream test updates
test/manager/PositionManagerFeeReference.t.sol, test/manager/PositionManager.invariant.t.sol, test/manager/PositionManagerBase.t.sol, test/manager/PositionManagerFee.t.sol, test/mock/manager/PositionManagerHandler.sol
New comprehensive test contract covers debt-carry, high-water-mark, bad-debt, flow-preservation, and reset scenarios; existing tests updated for the extended feeData() tuple.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant PositionManagerLP
  participant PositionManagerBase
  participant LibStorage
  Caller->>PositionManagerLP: deposit/withdraw/burn
  PositionManagerLP->>PositionManagerBase: _accrueFees()
  PositionManagerBase->>PositionManagerBase: _pendingFees() computes basis, advanceReference, heldManagementFees_
  PositionManagerBase-->>PositionManagerLP: totalAssetsBefore, debtBefore
  PositionManagerLP->>PositionManagerBase: _rebaseReference(totalAssetsBefore, debtBefore, totalSupply)
  PositionManagerBase->>LibStorage: rebaseSnapshot(...)
  LibStorage-->>PositionManagerBase: updated lastTotalAssets, lastDebt, heldManagementFeeAssets
Loading

Possibly related PRs

  • 3FLabs/grunt#190: Extends the same performance-reference accounting model (lastDebt, feeData/lastTotalAssets semantics, snapshotting in LibStorage) that this PR builds upon.

Suggested reviewers: maximebrugel

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: holding the performance-fee reference unless crystallized to prevent debt-carry write-off.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/performance-fee-debt-carry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

maxencerb added 2 commits July 7, 2026 19:57
A permanent drawdown or realized liquidation loss would hold the
reference (high-water mark) forever, suppressing performance fees until
a recovery that may never come. resetPerformanceReference (owner-only)
accrues fees first, then force-advances the reference to the current
state: it can never mint on past gains, it only forgives the carried
negative basis so future gains are charged from the current state.

Documented timing rule: the reset also writes off the debt carry
accrued since the last crystallization, which the next positive accrual
then overcharges by; call it as soon as possible after a positive
performance fee charge.
The performance fee must be net of management fees, but management fees
mint on every accrual while the performance reference can stay held for
many intervals. Only the current interval's management fee was deducted
from the crystallizing basis, so fees minted during held intervals were
forgotten and the next crystallization overcharged by perfRate times
those amounts.

heldManagementFeeAssets (appended to the ERC-7201 struct, zero on
upgrade) accumulates the management fees charged while the reference is
held; the crystallizing basis deducts accumulator plus current
interval, and the accumulator clears on any reference advance and on
resetPerformanceReference. Flows scale it with the share supply like
the debt carry; a rescue flow out of a full bad-debt episode keeps it
nominal (shares mint against a zero asset base there, so scaling would
inflate the deduction beyond the fees ever charged).

feeData() exposes the accumulator as a sixth return value.
@maxencerb maxencerb marked this pull request as ready for review July 8, 2026 09:55

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/libs/manager/LibStorage.t.sol (1)

155-191: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fuzz test should exercise prevCollat != refCollat to cover the mulDivUp scaling path.

The fuzz test always sets prevCollat = refCollat (line 170), which makes scaledRefDebt = refDebt.mulDivUp(refCollat, refCollat) = refDebt trivially. In production, pre-flow collateral typically drifts from the reference collateral between crystallizations, so prevCollat != refCollat is the common case. The mulDivUp scaling — the core of the carry computation — is never fuzzed with different numerator and denominator values. All deterministic tests share the same limitation.

♻️ Proposed fuzz test with independent `prevCollat`
 function testFuzz_rebaseSnapshot_preservesPerShareBasis(
   uint96 refTotalAssets,
   uint96 refDebt,
+  uint96 prevCollatSeed,
   uint96 carrySeed,
   uint96 heldMgmtSeed,
   uint96 newCollat,
   uint96 newDebt,
   uint64 prevSupply,
   uint64 newSupply
 ) public {
   vm.assume(refDebt > 0 && prevSupply > 0 && newSupply > 0);
   vm.assume(newCollat >= newDebt);

   uint256 refCollat = uint256(refTotalAssets) + refDebt;
-  // Build a pre-flow state whose basis is exactly -carry at reference LTV.
-  uint256 prevCollat = refCollat;
+  // Pre-flow collateral drifts independently from the reference collateral.
+  uint256 prevCollat = bound(uint256(prevCollatSeed), 1, type(uint96).max);
   uint256 carry = uint256(carrySeed);
-  uint256 prevDebt = uint256(refDebt) + carry;
+  // Build a pre-flow state whose basis is exactly -carry at reference LTV.
+  uint256 scaledRefDebt = FixedPointMathLib.mulDivUp(uint256(refDebt), prevCollat, refCollat);
+  uint256 prevDebt = scaledRefDebt + carry;

   harness.setReference(refTotalAssets, refDebt);
   harness.setHeldManagementFeeAssets(heldMgmtSeed);
   harness.rebaseSnapshot(prevCollat, prevDebt, prevSupply, newCollat, newDebt, newSupply);

-  uint256 expectedCarry = FixedPointMathLib.mulDiv(carry, newSupply, prevSupply);
+  uint256 prevCarry = FixedPointMathLib.zeroFloorSub(prevDebt, scaledRefDebt);
+  uint256 expectedCarry = FixedPointMathLib.mulDiv(prevCarry, newSupply, prevSupply);
   if (expectedCarry > newDebt) expectedCarry = newDebt;

   assertEq(harness.getLastDebt(), uint256(newDebt) - expectedCarry, "reference debt carries the scaled basis");
   assertEq(
     harness.getLastTotalAssets(),
     uint256(newCollat) - (uint256(newDebt) - expectedCarry),
     "reference NAV re-anchored on post-flow collateral"
   );
   assertEq(
     harness.getHeldManagementFeeAssets(),
     FixedPointMathLib.mulDiv(heldMgmtSeed, newSupply, prevSupply),
     "held management fee deduction scales with supply"
   );
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/libs/manager/LibStorage.t.sol` around lines 155 - 191, The fuzz test in
testFuzz_rebaseSnapshot_preservesPerShareBasis currently forces prevCollat to
equal refCollat, so it never exercises the mulDivUp carry-scaling path under
differing collateral bases. Update the setup to let prevCollat vary
independently from the reference collateral while still preserving the intended
pre-flow basis, and keep the assertions validating harness.rebaseSnapshot,
getLastDebt, getLastTotalAssets, and getHeldManagementFeeAssets against the
scaled carry behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/libs/manager/LibStorage.t.sol`:
- Around line 155-191: The fuzz test in
testFuzz_rebaseSnapshot_preservesPerShareBasis currently forces prevCollat to
equal refCollat, so it never exercises the mulDivUp carry-scaling path under
differing collateral bases. Update the setup to let prevCollat vary
independently from the reference collateral while still preserving the intended
pre-flow basis, and keep the assertions validating harness.rebaseSnapshot,
getLastDebt, getLastTotalAssets, and getHeldManagementFeeAssets against the
scaled carry behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cc524b3f-e2b1-4e5d-934b-99bd53f48bf9

📥 Commits

Reviewing files that changed from the base of the PR and between 96a18e3 and 3f519c4.

📒 Files selected for processing (15)
  • src/interfaces/manager/IPositionManager.sol
  • src/interfaces/manager/base/IPositionManagerAdmin.sol
  • src/libs/manager/LibStorage.sol
  • src/manager/PositionManager.sol
  • src/manager/base/PositionManagerAdmin.sol
  • src/manager/base/PositionManagerBase.sol
  • src/manager/base/PositionManagerLP.sol
  • src/manager/base/PositionManagerRebalancing.sol
  • test/libs/manager/LibStorage.t.sol
  • test/manager/PositionManager.invariant.t.sol
  • test/manager/PositionManagerBase.t.sol
  • test/manager/PositionManagerFee.t.sol
  • test/manager/PositionManagerFeeReference.t.sol
  • test/mock/libs/LibManagerStorageHarness.sol
  • test/mock/manager/PositionManagerHandler.sol

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant