Skip to content

Cantina audit fixes — v1.3.0 - #206

Open
maxencerb wants to merge 13 commits into
v1.3.0from
v1.3.0-fix
Open

Cantina audit fixes — v1.3.0#206
maxencerb wants to merge 13 commits into
v1.3.0from
v1.3.0-fix

Conversation

@maxencerb

@maxencerb maxencerb commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Integration branch for the v1.3.0 audit fixes.

All Cantina finding links point to the 3F: Grunt Cantina repo.

Merged fixes

PR Commit Cantina findings
#208 — fix: measure remediationDelta price drift against expected collateral growth deb7130 #22
#209 — fix: Retargetter rebalance path and flash-loan callback hardening fba8d42 #12, #28, #29
#210 — fix: harden the borrow-offers fill accounting and offer lifecycle acde2ad #18, #20, #35, #39
#211 — fix: performance-fee reference and basis (NAV-gain cap, checkpoint conservation, pre-transfer rebase) c0c047b #14, #15, #32, #36
#212 — fix: gate Midas bond config on the operator role 42c3bce #45
#213 — fix: Retargetter operation lifecycle, cap gates & fund-adapter assumptions 6993292 #2, #5, #8, #21, #23, #24, #27, #31, #33, #37, #38, #41, #42
#214 — fix: held management-fee deduction accounting (basis-capped consume, no flow rescaling, last-share clear) 4176575 #6, #7, #30
#215 — docs: fees, Facility, and offer/pre-liquidation known issues 8098de3 #9, #10, #11, #19, #34, #43
#216 — fix: expose the bad-debt exclusion flag and document the management-fee eligibility cliff 8d37997 #4, #40
#217 — fix: Cantina #1 small hardening & documentation nits 72df043 #1

Other commits on the branch (no Cantina finding): f6bcfce (ci: pin GitHub Actions to commit SHAs), a255e50 (branch open).

Unaddressed findings (not in this PR): #3, #44, #46, #47

maximebrugel and others added 11 commits August 10, 2026 10:32
setBondConfig/removeBondConfig move from VAULT_MANAGER_ROLE to
OPERATOR_ROLE. The bond is paid with borrowed assets before the full
input is committed, so the fund cannot bound the bond leg against held
balances: the recipient address is the only guardrail on bond payments,
and it is now defined by the most-trusted role. An overstated redeem
input can still consume shares as bond, but only towards an
operator-vouched Midas recipient, recoverable off-band.

Refs 3F-509.
… growth (#208)

The freed collateral of an LTV-down operation is sized net of its own
expected yield (w = x * (1 + Yr) / (1 + Yc)), so measuring the settlement
price drift against unity double counted the expected collateral yield as
a phantom surplus. remediationDelta now takes the collateralYieldRate the
operation was sized with and pivots the surplus/shortfall split at the
expected growth 1 + Yc (selector change; no consumer in this repo).

Cantina finding #22 (3F-495).
* fix: harden Retargetter rebalance path and flash-loan callback

- rebalance: totalAssets must not grow while the operation's Request is
  unrepaid and short of its deadline (value-conservation gate, all callers)
- rebalance: a module above target only reverts when it worsened, so an
  untouched module no longer blocks the other legs
- onFlashLoan: bound to the payload committed at the window open (transient
  digest) and to full delivery of the principal (pre-loan balance snapshot)

* fix: measure bridge value on the whole book and scope window authority to the payload

The value-conservation gate read the position manager's totalAssets, which
drops any module whose debt exceeds its collateral, so it read flat while
bridge capital was poured into an underwater module. It now measures quoted
collateral minus debt summed over every module (PositionValueIncreased).

Window step authority moved from the whole flash-loan call into the callback
around the committed payload, so a module holds no authority in its own frame
and cannot source the delivery check's balance growth from the position
manager itself.

* refactor: read the Request through syncRepaidStatus and store the payload digest as a uint

The value gate derived the effective repaid state from the mirrored deadline
plus the stale repaid flag; syncRepaidStatus returns exactly that disjunction
and folds a past-deadline Request into the flag, the same idiom resolve uses.

The payload digest now goes through the existing tStoreUint/tLoadUint, so
LibTransientSlot is untouched by this PR.

* perf: read the position aggregates once per rebalance snapshot

_positionSnapshot returns the aggregate LTV and the net value from a single
read of collateralAmountQuoted/debtAmount, replacing the two helpers that each
re-read the pair. The post-call check hands the LTV back to the direction
checks so they do not read it again, which also keeps valueAfter off the
rebalance stack (it would otherwise re-trigger stack-too-deep).

Halves the module iterations on the rebalancer path (8 to 4) and drops the
runtime size by 169 bytes.

* docs: point the rebalance NatSpec at the renamed value-gate helpers
* fix: harden the borrow-offers fill accounting and offer lifecycle

- consume walk: evaluate the profitability/bonus floor once per offer on
  its whole remaining amounts (scale-invariant), not on the rounded chunk,
  so partitioning a target cannot skip an exactly-at-floor offer and the
  isConsumable view agrees with the walk
- consume write-back: release a partially-filled slot whose leftover is no
  longer strictly profitable (unusable dust must not hold slab slots until
  expiry); below-floor-but-profitable leftovers stay, like any below-floor
  offer, so a floor change can re-admit them
- band pre-liquidation: after the Morpho repay settles, re-quote the
  position at the entry price and revert (LtvNotReduced) unless the LTV
  strictly decreased; kills the virtual-share-boundary dust fills whose
  settled totals leave the LTV flat or higher
- revokeOffers: the recorded proposer may revoke its own offer at any time
  (before and after activeAt); guardian and registry-owner revocation
  unchanged, authorization now checked per offer

* refactor: per-offer revoke auth via registry boolean read, hoist marketId, silence cast lints

Review follow-ups on the offer lifecycle hardening:
- revokeOffers resolves the registry guardian/owner power once as a boolean
  (canRevokeOffer replaces the reverting checkCanRevokeOffer) and the per-offer
  proposer check moves into LibBorrowOffers.removeOffer; the Unauthorized error
  keeps Solady's selector so external behavior is unchanged
- _positionLtvFrame and _consumeOffers read marketId from storage once
- _checkLtvReduced NatSpec records why the cross-multiplication needs
  fullMulDiv's 512-bit intermediate (price-scaled values can overflow 256 bits)
- forge 1.5.1 unsafe-typecast lints in LibBorrowOffers silenced with justified
  disable comments
- new tests: empty revoke batch no-op, guardian revoke of an active offer,
  per-offer OfferRevoked events in a batch

* fix: derive band settlement LTV frame before repay to close reentrancy drain

The offer-band pre-liquidation guard re-read live position state after the
Morpho repay, whose onPreLiquidate callback (plus permissionless repay /
supplyCollateral) could drain the position through the reentrant proportional
path and re-supply a dust atom so the post-repay check passed. Compute the
settled frame deterministically from the fill amounts before the repay
(reproducing Morpho shares-mode rounding), so no callback can move it.
…nservation, 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.
…tions (Cantina) (#213)

* fix: Retargetter operation snapshot, cap re-check, PM whitelist and clock rewind

Cantina audit fixes across the Retargetter operation lifecycle, the
Request cap gates, and the fund-adapter assumptions.

Code fixes:
- 3F-502: snapshot horizon/tickDuration/tickThreshold into the operation
  at start so setConfig cannot reprice a funded operation (zero extra
  storage slots; _owed reads the snapshot).
- 3F-506: cancelOrder requires IFund.cancel to return State.EMPTY before
  clearing the local order.
- 3F-483: bind operations only to an owner-whitelisted PositionManager
  (setPositionManager / isPositionManager, pair checked at whitelist time).
- 3F-477: move the principal-cap gate in consume to after the Request call
  so a maker callback cannot invalidate it.
- 3F-478: bound maxPrincipal by the quoter's one-trip repayment bound
  (new ltvUpOneTripPrincipal) so a cap-sized operation keeps settlement
  headroom.
- 3F-485: rewind the loan clock to unstarted when a revocation leaves the
  operation with no PT, no YT and no pending authorization.

Documented in NatSpec (no behaviour change): 3F-494 minimum-tick payout,
3F-496 per-call rebalance cooldown, 3F-499 per-instance operation guard,
3F-503 CONSUMER_ROLE self-authorization, 3F-472 firm-commitment mint
policy, 3F-487 exact-transfer fund-asset assumption.

Fixes 3F-521
Fixes 3F-515
Fixes 3F-514

* refactor: bind Retargetter to a single owner-set PositionManager (3F-483)

Replace the position-manager whitelist with a single instance binding: the
Retargetter runs every operation against one owner-bound PositionManager
instead of taking it as a caller-supplied argument.

- Bind via initialize (optional positionManager_ param, threaded through
  createRetargetter) or setPositionManager(address), rebindable by the owner
  only while no operation is active. Both validate the contract and its
  asset-pair match.
- Drop the positionManager argument from startRetargetting and
  startSyncRetargetting; they read the bound manager. maxPrincipal() drops
  its argument too. isPositionManager -> boundPositionManager view.
- Error PositionManagerNotWhitelisted -> PositionManagerNotBound; event
  PositionManagerSet -> PositionManagerBound.

Smaller than the whitelist (no mapping, no per-call arg): Retargetter runtime
23,949 B on the ci profile, 627 B under EIP-170 (was 24,136 / 440 B). Full
suite 2349 passing, 74 invariants passing; the not-bound guard is
mutation-checked.

* docs: state the IFund exact-transfer assumption per-transfer, not per single pull (3F-487)

The assumption is a token property (every transfer/burn/wrap moves exactly
the requested amount), independent of how many legs an adapter uses. Drop the
Centrifuge-specific 'pull order.input and forward that same amount' wording,
which misdescribes multi-leg adapters like the Midas redeem (bond + redemption
legs summing to the input).

* refactor: derive the Retargetter pair from its position manager (3F-483)

The asset pair is the position manager's own pair, so read it from the
manager at initialize instead of taking collateralAsset/debtAsset as params.
The pair is stored (still read on every hot path) and immutable, so setFund's
one-time validation and the start-time whitelist trust stay intact; rebinds
must report the same pair.

- initialize(owner, positionManager, config): position manager is required,
  the pair is derived from its assets(). createRetargetter drops the two asset
  params and reports the derived pair in its event.
- setPositionManager(address) also accepts the zero address to unbind (abandon)
  the instance while idle; operations then revert PositionManagerNotBound until
  a same-pair manager is bound again. The pair is retained across an unbind.

Full suite 2346 passing, 74 invariants passing; the not-bound guard is
mutation-checked. Retargetter runtime 24,004 B on the ci profile, 572 B under
EIP-170.

* refactor: share one bind function for init/setPositionManager, write-once pair (3F-483)

Collapse the position-manager binding into a single internal
_bindPositionManager used by both initialize and setPositionManager: for a
nonzero manager it reads its assets and, while the stored pair is still zero,
derives the immutable pair (else it must match); the zero address unbinds and
leaves the pair untouched. The pair is therefore write-once regardless of
whether the first bind happens at init or later, and initialize now accepts
the zero address (create unbound, bind later). Removes the separate
_checkPair and the duplicated init/setter logic.

Smaller and simpler: Retargetter runtime 23,829 B on the ci profile, 747 B
under EIP-170. Full suite 2347 passing, 74 invariants passing; the pair-match
guard is mutation-checked.

* refactor: drop operation.positionManager, key active on fund + bound PM

The operation always runs against the instance-bound position manager, which
cannot change while active, so storing it on the operation record was pure
duplication. Remove the field:
- fund becomes the operation-active flag (both start paths set it, only
  clearOperation clears it, and no fund can be the zero address since setFund
  contract-checks it). isActive, _checkStart and setPositionManager's
  active guard key on fund; checkActive keys on fund and returns the bound PM.
- consume/authorizeMinting/resolve read the bound PM from assets storage; the
  operation() view derives its positionManager (bound PM when active, else
  zero) instead of returning a stored field, keeping the same ABI and
  semantics.

RetargetterOperation repacks to 7 slots (request now shares slot 0, fund + the
order mode share slot 1); the storage-layout and stray-order tests are updated.
Full suite 2347 passing, 74 invariants passing; the fund active flag is
mutation-checked. Retargetter runtime 23,953 B on the ci profile, 623 B under
EIP-170.

* fix: settle the one-trip bound's rounding order and state the cap's minter trust

Review follow-ups on the Retargetter batch:

- RetargetterQuoter: the one-trip repayment bound now grows the collateral
  before applying the target (the order settlement itself uses) and rounds
  the drifted debt up, so the worst permitted repayment of a bound-sized
  principal is borrowable at target in integers, not just in the reals.
  The reversed order could oversize the bound by a few units and trip the
  settlement direction check by one unit of LTV.
- Retargetter/IRetargetter: the post-call principal cap NatSpec no longer
  claims only the position manager's owner or rebalancer can move
  collateral after consume returns; it names the minter-role trust
  boundary the read relies on and the required role separation.
- Tests: regression fixture with the reviewer's six-decimal counterexample
  plus a fuzz invariant asserting the worst repayment stays borrowable at
  target under worst-case realized integer state.
…no flow rescaling, last-share clear) (#214)

* fix: consume the held management-fee deduction only up to the basis (Cantina #6)

A positive basis smaller than the pending deduction — producible
permissionlessly, e.g. a dust repay through the market — advanced the
performance reference and cleared the whole heldManagementFeeAssets
accumulator, so the next crystallization overcharged LPs by the forgiven
deduction.

A crystallizing advance now consumes the deduction only up to the basis
and carries the excess past the new mark; a reseed advance (bootstrap
sentinel, empty vault) still clears it, which the rebaseSnapshot
fallback branches rely on. The fee-cap early return backs the unminted
interval fee out of the accumulator instead of clearing it on advance.

Closes 3F-473.

* fix: never rescale the held management-fee deduction on capital flows (Cantina #30)

rebaseSnapshot scaled heldManagementFeeAssets by the share-supply ratio,
but that ratio is a permissionless value-detached lever in both
directions: near zero NAV the mint denominator is the virtual asset
base, so a dust repay plus a dust deposit nearly doubles the supply and
would manufacture credit far beyond the fees ever charged, while any
down-only variant lets a deposit/exit round trip that restores the
vault state grind the deduction toward zero with reversible capital and
overcharge the next crystallization.

The accumulator now stays nominal across all flows, matching its
definition (fees actually charged since the last netting); its only
transitions are fee charges, consumption up to a crystallized basis,
and the reseed/reset clears. The residual recipient-side overhang after
a large exit is bounded by fees already collected and remediable via
resetPerformanceReference.

Regression tests at both levels (harness round trip, vault-level
deposit/burn round trip, zero-NAV and dust-positive-NAV deposits), each
verified to fail against the scaled variants.

Closes 3F-480.

* fix: clear the held management-fee deduction when the last share exits (Cantina #7)

The pending deduction is owed to current holders, but nothing wrote it
down when the supply emptied: the empty-vault reseed clear only runs on
a later accrual — and not at all while bad debt is present — so a
departed cohort's credit could sit in storage and shield a future
cohort that never paid the management fees.

rebaseSnapshot now zeroes the accumulator on a flow that burns the last
share, before the reference-hold early return. Unlike a supply-ratio
rescale (removed for Cantina #30) the terminal clear is not a
permissionless lever: only the sole remaining holder can trigger it,
and the only shield it destroys is their own. The finding's other half
— an exit that empties the good-debt universe while shares remain — is
uniform under the never-rescale rule (the credit stays nominal like any
other exit, the documented reset-remediable residual) and is pinned by
the new unit test alongside the reference hold.

The recorded recommendation (hoist the supply-ratio scaling above the
early return) predates the #30 resolution and would reintroduce both
the zero-NAV up-lever and the deposit/exit round-trip grind; it is
deliberately not implemented.

Both new tests fail without the clear (the credit survives the last
burn). Full suite: 2426 passed, fmt clean.

Closes 3F-474.

* test: pin the held-deduction consume on both accrual branches; align accumulator docs

Strengthen the two Cantina #6 branch regressions so each detects its
pre-fix clear-on-advance mutation independently:

- holiday test: routed to the interest-free market; the no-recipient
  variant now advances on a permissionless dust-positive basis below the
  pending deduction and asserts the advance mints nothing, moves the
  reference, and persists exactly `held - basis`.
- fee-cap test: a flat held stretch seeds the deduction and a dust repay
  flips the basis positive before the cap-binding dormancy; the accrual
  must still advance, mint nothing, and back out to `held - basis`.

Both verified by mutation: reverting either consume site in
`_pendingFees` to clear-on-advance fails its test with held == 0.

Extract the repeated permissionless dust-repay block into
`_dustRepayMarket2()` and reuse it in the existing dust tests.

Docs: define the accumulator as fees "charged and not yet netted against
a crystallized basis" everywhere (the old "since the reference last
advanced" predates the carry semantics), list the last-share flow clear
on `feeData`, and document all three fee-cap back-out outcomes (hold,
crystallizing advance, reseed advance).

Part of 3F-518 (3F-473)
* docs(3F-488): forbid Facility as fee recipient

* docs(3F-489): explain zero-share fee accrual

* docs(3F-490): document held fee asset accounting

* docs(3F-493): document offer walk rounding

* docs(3F-500): record superseded exit rounding

* docs(3F-507): document offer timelock grandfathering

* docs(3F-510): document Midas oracle freshness

* docs: remove known-issues registry

* docs: clarify offer halts and oracle validation
…ee eligibility cliff (#216)

* fix: expose the bad-debt exclusion flag via hasBadDebt() (Cantina #40)

LibView.totalAssets() skips any borrow module whose debt exceeds its
quoted collateral, but PositionManager dropped the resulting flag and no
public view returned it, so monitoring had to query every module and
re-implement the collateral >= debt comparison to tell whether the
reported NAV covers all modules or only the solvent ones.

Add a separate additive hasBadDebt() getter sourced from the same
single-pass LibView.totalAssets() iteration, and document on
IPositionManager.totalAssets() that the value covers only the solvent
modules while the flag is set. totalAssets() keeps its bare uint256
return: widening it would change return decoding for generated bindings
(alloy, viem, cast) and break the ERC-4626 shape.

Closes 3F-505. Part of 3F-520.

* docs: management fee uses checkpoint-end eligibility over the full interval (Cantina #4)

The collateral >= debt filter in LibView.totalAssets() is a one-wei
cliff, and fee accrual applies it at checkpoint end over the whole
elapsed interval: a module that re-enters the fee basis at any point --
anyone can flip the filter by repaying one base unit on the underlying
market -- is charged management fee for the full interval, because
storage keeps no per-module inclusion time.

Documented and accepted as-is: charging from first observed eligibility
would require a per-module inclusion timestamp, stored state we would
rather not carry for an edge case liquidation is expected to prevent
long before 100% LTV. The cliff was already described in the LibView
internal NatSpec; this surfaces the fee consequence there and on the
fee-facing documentation (_pendingFees and the feeData() managementFee
return).

Closes 3F-486. Part of 3F-520.

* fix: PR #216 review — scope re-inclusion cost to the shortfall, harden hasBadDebt coverage

Docs: re-including an excluded module costs its full debt - collateral
shortfall, not one base unit (that only holds at the exact boundary) —
corrected in the feeData()/hasBadDebt() NatSpec, the LibView cliff note,
and _pendingFees. Added the converse (an excluded module pays nothing
for the interval, foregone permanently since lastFeeAccrualTimestamp
refreshes unconditionally), a note that hasBadDebt() reverts on a module
quote revert so monitors treat that as unknown rather than no-bad-debt,
and dropped the leftover reviewer-voice tail in LibView.

Tests: collateral == debt boundary (verified to fail against a >= to >
regression), partial exclusion with the flag true while totalAssets()
is still positive, and hasBadDebt() in ReentrantMinter's READ_VIEWS
probe (reverts mid-operation, works outside).
* docs: clarify that borrow-offer views report booked slots, not consumable ones (3F-484 #1, #2)

offerCount(), offer() and offers() all key off `liveBits`, which marks an
allocated slab slot and never checks expiry. Expired offers are pruned lazily
(on the next _alloc that needs a slot, or when a consume walk passes them), so
until then they still count and still appear in the listings.

Document the meaning of "live" once on the views section rather than per
function, and point integrators at `expiresAt` / isConsumable() for offers that
would actually fill.

* docs: disambiguate the performance-fee LTV from the withdrawal-buffer LTV (3F-484 #6)

setFeeData named the performance-fee reference LTV `LTV_prev`, a name used
nowhere else in the codebase, in the same interface that exposes setLtv(ltv_)
for the unrelated withdrawal buffer.

Rename it to `LTV_ref` to match `FeeData` in LibStorage (the derivation this
comment already points at) and state that it is derived from the fee reference,
not configured.

* docs: explain why raw debt is cleared before Morpho pulls the repayment (3F-484 #7)

onMorphoFlashLoan() zeroes the raw debt at the end of the callback, before
Morpho actually transfers the repayment in. Note that the pull happens
immediately after the callback returns and reverts the whole transaction on
failure, so the cleared debt is only ever observable in a transaction where the
repayment succeeded.

* docs: document that a consumed offer is single-use regardless of fill size (3F-484 #9)

Request.consume() prorates the YT mint on a partial fill, but _validateOffer
writes the offer's nonce into the maker-wide nonce, invalidating this offer and
every lower-nonce one. The unfilled remainder is not fillable; the maker must
sign a fresh, higher-nonce offer.

Out of the triaged scope for 3F-484 (item 9 was excluded), kept as it is
doc-only and the behaviour was undocumented at the public entry point.

* refactor: replace the minOfferBonus +1 sentinel with an explicit set flag (3F-484 #10)

The bonus floor was stored biased by one so that a never-configured collateral
(reads DEFAULT_MIN_OFFER_BONUS_BPS) stayed distinguishable from an explicit 0
(disables the floor). Store the value unbiased next to a `minOfferBonusSet`
boolean instead, which states that distinction directly and drops the +1/-1
round trip and its overflow note.

Still a single packed slot (136 -> 144 bits). Storage layout change: the
namespaced OfferConfig struct is not deployed yet, so no migration is involved.

* refactor: pack MidasFundStorage from 18 slots to 15 (3F-484 #12)

Group fields that are read together so they share a slot:

- `asset` + `assetScale`: initialize() rejects assets with more than 18
  decimals, so the scale is 10**(18-d) in [1, 1e18] and fits uint64 (max
  ~1.8e19). The pair is read together on the deposit, redeem and quoting paths.
- `oracle` + `internalState` + `hasResolvedAmounts` + `instantRedeemUnlocked`
  (20+1+1+1 bytes): commit() writes the flags and reads the oracle in the same
  call.

`bondPaid` moves ahead of `bondConfig` and `endedOrders`, which each force a
slot boundary and were stranding it alone. Measured -49k gas on the redeem fuzz
path. No ABI change: assetScale has no external getter, and BondConfig is left
alone since it crosses the interface.

Storage layout change: MidasFund is not deployed yet, so no migration is
involved. Field order is now layout-significant and noted as such.

* feat: emit DepositVaultUpdated from MidasFund.initialize (3F-484 #11)

initialize() already emits OracleUpdated through _setOracle but wrote
$.depositVault raw, so an indexer folding OracleUpdated + DepositVaultUpdated
picked up the oracle at init and nothing for the vault, which only appeared in
the factory's FundCreated. Emit the event at init so the vault binding is
readable from the same stream as every later setDepositVault().
…s (3F-481) (#218)

* fix: keep the held performance entitlement nominal across flow rebases (3F-481)

The Cantina #32 zero-share hold preserved a positive pending entitlement,
but the flow rebase scaled it by the supply ratio: a deposit into a
dust-NAV vault could turn a one-atom held gain into a material fee
charged against the fresh principal. The gain now stays nominal across
flows (like heldManagementFeeAssets), falling back to the supply-scaled
read only once it outgrows half the post-flow NAV so the mark cannot
degenerate to zero on a draining flow.

Claude-Session: https://claude.ai/code/session_01AZowKnphCAYPA1MoEGXLjd

* test: deposit fairness properties (fresh-quote and matched-ratio invariants)

The minted share fraction is fair under a true price P iff
(P - p) * (d*C - c_q*D) == 0: either the quote is fresh or the deposited
debt-to-collateral ratio matches the pool's. Pins case 1 (carry mints its
own value, same-state accruals mint nothing), case 2 (matched-ratio
deposits are repricing-neutral and charged only their own slice's gain,
verified against a state-snapshot counterfactual), and demonstrates the
inherent transfer of the mismatched-ratio stale-quote case as an
operational constraint.

Claude-Session: https://claude.ai/code/session_01AZowKnphCAYPA1MoEGXLjd

* test: widen deposit-fairness fuzz and add the PM-12 fee-conservation invariant

The fairness properties now fuzz the fee configuration (0-200 bps
management, 0-5000 bps performance) and elapsed time (time alone must
never create a performance fee on fresh principal). The stateful handler
gains ghost accounting: NAV gains observed outside capital flows, the
management-fee allowance per settled accrual interval, and the value of
fee shares at mint. PM-12 asserts fees can never materially exceed those
allowances under the handler's full state space (fuzzed fees, 0.1x-10x
prices, warps, liquidations, third-party repays), so capital flows alone
can never fund a fee.

Claude-Session: https://claude.ai/code/session_01AZowKnphCAYPA1MoEGXLjd

* test: mirror the flow-fairness invariants on the exit side

Exits obey the same theorem as deposits with the signs flipped: fair
under a true price P iff the quote is fresh or the removed
debt-to-collateral ratio matches the pool's. Renames the fairness file
to PositionManagerFlowFairness and adds: a fresh-quote withdraw burns
exactly the carry's value under any ratio and fee configuration (and
neither the exit nor time creates a performance fee), a matched-ratio
withdraw is repricing-neutral for the stayers (snapshot counterfactual),
burn() is ratio-matched by construction and therefore neutral at any
stale quote, and the mismatched-ratio stale-quote exit demonstrably
hands the exiter's levered upside to the stayers.

Claude-Session: https://claude.ai/code/session_01AZowKnphCAYPA1MoEGXLjd
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.

2 participants