Skip to content

Commit 90e02c5

Browse files
v1.3.0 (#204)
* feat: offer-based pre-liquidation band for MorphoBorrowPosition (#197) * feat: offer-based pre-liquidation band for MorphoBorrowPosition Adds a timelocked offer-based pre-liquidation band in (safeLtv, liquidationLtv] beneath the existing proportional path, consumed through the same preLiquidate entrypoint. Beacon-proxy upgrade (reinitializer(2) + permissionless initializeV2), same factory and initialize signature. Includes a fixed-slab sorted doubly-linked list with a recycled free-list, OwnableRoles-based proposer/guardian/admin roles, and an extensive data-structure test + invariant suite. * fix: CI fmt + make _governanceOwner total - Build ConsumeInput as a named local + single-line consume/previewConsume call, avoiding the chained-call-with-struct-literal that forge 1.5.1 (CI) and 1.7.1 fmt format differently (the failing 'forge fmt --check'). - Replace the _governanceOwner lookup with a low-level staticcall + exact-shape length check + mask-decode (address(uint160(abi.decode(ret,(uint256))))). A typed call / try-catch cannot be made total here: a codeless target fails the extcodesize check and a malformed/empty return fails ABI decoding, neither caught by catch. The low-level path falls back on every anomaly (EOA, revert, empty/short/long, dirty bits) without ever reverting initialization. Adds regression tests for the owner-of-owner resolution and the malformed-return fallback. * feat: minimum offer bonus floor for pre-liquidation offers Adds an admin-set minimum offer bonus (basis points) as an anti-griefing floor on the offer book. Without it, a semi-trusted proposer can post barely-profitable offers that sort to the head of the book (lowest price first) and drag the profitability of every band liquidation toward zero, making the band unattractive to liquidators. The floor requires a fill's collateral value to exceed the debt value it repays by at least minOfferBonusBps of that debt value, enforced BOTH at proposal time (reverts OfferBonusTooLow) and per fill at consume time (LibBorrowOffers._priceAction / consumableAtPrice skip a below-floor fill, like an unprofitable one). So a live offer whose bonus later drifts below the floor via price or interest movement stops being consumed rather than degrading the band. - Storage: uint16 minOfferBonusBps packed into the free space of BorrowOffersStorage slot 0 (no new slot). Seeded to 1% (100 bps) in _initializeV2, so both fresh initialize and the initializeV2 migration land with it set. - Admin setter setMinOfferBonus, gated ROLE_ADMIN | owner, bounded to [0, MAX_MIN_OFFER_BONUS_BPS = 1000]; 0 disables the floor. Effective immediately (not timelocked): the floor only ever makes consumption stricter, so it carries no direct fund exposure, and delaying it would only slow the admin's response to an active griefing attack. - Threads minOfferBonusBps through ConsumeInput; _priceAction now takes the ConsumeInput by reference to stay within the non-via-IR stack. - Tests: proposal-time floor (bounds, exact boundary, zero disables), consume-time skip of a drifted below-floor offer, skip-then-consume of a higher-bonus offer, raise-gates / lower-readmits a live offer, and the event/auth surface. * refactor: bitmap offer slab + single-source consume walk Replace the sorted doubly-linked list + recycled free-list + high-water-mark slab in LibBorrowOffers with a uint32 liveness bitmap (liveBits is the single source of truth; freed slots are fully zeroed; allocation hands out the lowest free id), and unify consume/previewConsume into one shared load -> sort -> walk pipeline (previewConsume = load + walk; consume adds only the write-back, so preview and consume cannot disagree by construction). The fill math (_computeFill/_priceAction) is unchanged; the I1/bonus-floor and I2 checks are factored into passesI1AndFloor/strictlyDeRisks, shared with proposal-time admission (_checkOfferProfitable) and the isConsumable view (consumableAtPrice). Deployed bytecode (CI profile): 24,243 -> 23,697 bytes (-546; EIP-170 headroom 333 -> 879). Gas: propose on a fuller book drops sharply (no sorted-splice walk; filling all 32 slots: -667k), revoke -24k, consume paths -1k..-28k; only empty-book revert paths pay ~+0.6k for the bitmap load. Deliberate behavior changes (all documented in natspec): - The walk consumes in EXACT ascending effective-price order, sorted in memory at consume time on current remaining amounts (the stored list's order could drift with partial fills/accrual). Ordering remains a consumption optimization, not a safety invariant; I1/floor/I2 stay the binding checks. - OfferConsumed events are emitted grouped post-walk in sorted order (same set/values) instead of interleaved with the traversal. - consume prunes ALL expired offers, including ones past the walk's halt point (the old walk only pruned expired offers its cursor reached). Expired offers are never consumable, so this is housekeeping only. - offers() lists by ascending slab id, not price order; offer ids are assigned lowest-free-first instead of LIFO recycling. - The Offer struct loses its prev/next pointers (still exactly 2 slots), so the offer()/offers() return tuple shape changes for off-chain consumers. Storage safety: the offer feature has never shipped (this branch is unmerged, no broadcast artifacts), so no deployed proxy holds offer state under the old layout; reinterpreting slot 0 of the "borrow.offers.main" namespace is safe. The all-zero dormant window (post-upgrade, pre-initializeV2) now reads as an empty book structurally (liveBits == 0), replacing the three explicit count == 0 guards; the regression tests are kept. Effects-before-interaction is preserved: all offer mutations persist before the Morpho repay. Tests: data-structure suite rewritten around the bitmap (lowest-free-id alloc, freed-id reuse, 33rd-insert revert, bit-set <=> live-slot structural assertions); new ascending-price-drain, below-floor-Skip and over-price-Stop tests; equivalence fuzz now reconciles OfferConsumed events against both the pre-consume snapshot and the post-consume storage (both amount fields) and asserts event ordering; the reentrancy test now attacks the whole offer and asserts the reentrant call reverts NoConsumableOffer against the emptied book; event value/order assertions added (they detect fill-swap and exhausted-flag mutations, verified). Invariant suite reworked to bitmap invariants (INV-1..4). * refactor: name offer invariant checks explicitly (drop I1/I2/I3 labels) Rename passesI1AndFloor -> isProfitableAboveBonusFloor and strictlyDeRisks -> strictlyLowersLtv, and replace every I1/I2/I3 shorthand in natspec, comments and test strings with the explicit invariant it stood for (profitability with the minimum bonus on top; each fill strictly lowers the position LTV; Morpho's own health check). Docs-and-names only, no behavior change. * refactor: shared BorrowOffersRegistry for offer roles and per-collateral config Move the offer roles (proposer, guardian) and the offer configuration (timelock, minimum bonus) off MorphoBorrowPosition into a single BorrowOffersRegistry deployed once behind an ERC1967 proxy and baked into the position implementation as a constructor immutable, shared by every beacon proxy and future upgrade. - The registry owner is the administrator (no admin role): it manages roles and config directly, is always authorized by the reverting checkCanCreateOffer / checkCanRevokeOffer gates (by derivation, so a Solady two-step ownership handover never strands powers), and role constants alias Solady's _ROLE_n where OwnableRoles is inherited. - Roles are global; configuration is keyed by collateral token. Effective timelocks are floored to MIN_OFFER_TIMELOCK and a never-set bonus floor reads DEFAULT_MIN_OFFER_BONUS_BPS (bias-by-one storage keeps an explicit 0 = disabled expressible), so an unconfigured collateral fails safe: minimum veto window, default anti-griefing floor, no zero-timelock state anywhere. - The position drops OwnableRoles back to plain Ownable and holds no role or configuration storage; initializeV2, _governanceOwner and the versioned-initializer machinery are deleted. initialize is back to a plain Solady initializer and the offer namespace shrinks to {liveBits, slab}, so deployed version-1 proxies need no migration: the all-zero namespace structurally reads as an empty book. - The factory takes the registry as a constructor pass-through (deploy order on a fresh chain: registry proxy, then factory; a live-chain beacon upgrade deploys the new implementation standalone with the same registry). Deployed bytecode (CI profile): 23,697 -> 21,878 bytes. * fix: offer fill and slab edge cases from review Three fixes to LibBorrowOffers, all raised in PR review: - Fixed-ratio clamp rescale: when a fill's debt shares are clamped to the position's remaining borrow shares, the seized collateral is now rescaled down to the offer's fixed collateral/shares ratio (floor division; Skip when it rounds to zero). Previously the liquidator kept the full collateral chunk while paying fewer shares, i.e. a better price than the proposer authorized, reachable whenever the live offers' aggregate remaining debt shares exceed the position's current debt (owner partial repay, interest drift, or overlapping offers). The floor rescale keeps every earlier cap intact and the liquidator still cannot underpay in shares-per-collateral terms. - Skip instead of Stop when a repaidShares-mode fill rounds the remaining share target to zero collateral: the walk sorts by descending debt-shares-per-collateral, so later offers carry more collateral per share and can still produce a non-zero fill for the same target; stopping starved them and could revert an otherwise fillable liquidation with NoConsumableOffer. - Allocation self-heals from expiry: _alloc prunes expired offers (bit cleared, slab slot zeroed, mirroring the consume-time pruning) before looking for a free slot, so a book whose 32 slots have all expired accepts the next proposal instead of reverting TooManyOffers until a guardian intervenes. * test: migrate suites to the offers registry and strengthen regressions - All suites deploy the BorrowOffersRegistry behind an ERC1967 proxy and wire roles/config there; the factory construction sites gain the registry pass-through. New BorrowOffersRegistry unit suite covers initialization, owner-gated role and config setters (role holders rejected), the reverting checkCanCreateOffer / checkCanRevokeOffer gates, the floored timelock and default bonus semantics, pending timelock scheduling/re-basing/due-masking, per-collateral isolation and the two-step ownership handover. - Position suites: auth flips to the registry (the position owner has no bypass and now reverts Unauthorized), dynamic transitions covered (revoked proposer/guardian, ownership handover followed through the position entrypoints), per-collateral config isolation across two positions, and the never-configured collateral floor behavior. initializeV2 / governance-owner-derivation tests are deleted with the code; the dormant-window tests are reframed as zeroed-liveBits empty book regressions against the repacked slot 0. - Fill/slab fix regressions: deterministic boundary-crossing clamp rescale tests in both modes, the clamp rescale-to-zero Skip, the Skip-not-Stop straddling-ratio walk, expired-book self-heal plus the active-vs-expired prune boundary and mixed-book slot recycling; the consume equivalence fuzz and the invariant handler now assert the per-fill fixed-ratio price invariant on every OfferConsumed event, and a small-position handler action makes the clamp path reachable under fuzzing. * docs: factory versioning note (single beacon across upgrades) Document that the registry-aware factory revision is only needed for fresh deployments: already-deployed factories stay in use across beacon upgrades since createBorrowPosition and the 4-argument initialize keep their exact signatures (the registry flows through the implementation constructor, i.e. the beacon upgrade, not the factory). The goal is to keep every position of a deployment behind the single beacon the original factory created, instead of fragmenting across factory versions. * feat: Retargetter (LTV retargetting orchestrator + Morpho Blue flash-loan integration) (#199) * feat: add Retargetter with Morpho Blue flash-loan integration LTV retargetting orchestrator composing Requests (bridge loans), funds (subscription/redemption) and PositionManager rebalancing, with an ASYNC (Request-funded) and a SYNC (flash-loan window) entry point, a stateless quoter for the sizing and tick-repayment math, a Morpho Blue flash-loan adapter behind a provider-agnostic module interface, and a beacon proxy factory. * refactor: extract Retargetter storage libraries and unify window state - Move the ERC-7201 structs, accessors and storage-only operation helpers (checkActive, checkRequest, setOrder, order, clearOrder, checkNoPendingOrder, clearOperation) into LibStorage, following the existing LibStorage convention - Add LibTransientSlot (bytes32-slot transient load/store helpers) and use it in the Retargetter and the MorphoFlashLoanAdapter - Run the flash-loan payload through Solady's inherited _multicall instead of a hand-rolled delegatecall loop - Keep the SYNC window's position manager and fund in the operation storage (zeroed at window close) instead of transient slots, removing the dual resolution logic; ASYNC-only steps now gate on the Request being present * feat: configurable residual tolerance and repayment deadline acknowledgment - Replace the exact-zero settlement gate with a per-asset tolerance stored as a power-of-two exponent (uint8 each in the config): balances strictly below 2^exponent pass, checked with a single shift; the zero default keeps the gate exact. Stops dust-donation griefing of resolve and of the flash-loan window close - Document the 90-day Request repayment deadline as an acknowledged limitation at REPAYMENT_DEADLINE_OFFSET: treated as effectively infinite, bypasses the local repayment flow if ever hit, remediated offchain through governed beacon upgrades (extensive multisig behind its own timelock), extended by upgrade if it becomes too short * perf: copy the packed yield estimates to memory in maxPrincipal The estimates struct packs into a single storage slot and most of its fields feed the quoter call, so one slot read into memory replaces a storage pointer re-reading the slot for every field. The other storage pointers stay: no other struct is both fully packed and read whole (assets reads are one-per-slot, operation and config copies would load more slots than they save). * docs: derive the quoter math in the contract header Every sizing and pricing formula is now derived step by step above the RetargetterQuoter contract (model, notation, target conditions and their solutions, tick quantization, remediation delta, rounding directions). The interface keeps the behavioral contract only (units, rounding, revert and zero-return conditions) and points at the header, and the test fixtures no longer cite external documents. * docs: drop local-spec section references from the rebalancer tests The section numbers pointed at an untracked local document; the comments now carry the underlying rationale directly. * feat: consumption window, mint authorizations, pull-closed funding rounds Capital entry into an operation is now bounded three ways: the first consume or nonzero mint authorization starts the loan clock and capital only enters within tickThreshold of that origin, the first pull of funds closes the round early (revoking every pending authorization), and mint authorizations count toward the live principal cap until minted or revoked (Solady enumerable set capped at 16 accounts, pruned lazily). Repayment prices every yield token from the origin, so late capital would be overpaid and would dilute recovery on a default; the same gates make the Request's zero mint-to-repaid delay safe, now documented at startRetargetting. Along the way: pullRequestFunds accepts the full-balance sentinel, the whitelist add/remove pairs merged into boolean setters, rescue() is gone (dust lives within the residual tolerance, larger donations fold into the position through the rebalance sentinels), maxPrincipal dispatches through the quoter's new retargetPrincipal, and the shared gates live in LibStorage (checkOffer, checkConsumptionWindow, pruneNullAuthorizations, closeConsumption). * perf: wrap modifier bodies and factor shared gates to cut contract size * fix: cap REPAY-sentinel resolution at the module's live debt A REPAY leg at the full-balance sentinel used to resolve to the Retargetter's whole debt balance, which the borrow module forwards to Morpho verbatim; Morpho reverts on any repayment above the outstanding debt, so a residual larger than the module's debt could never be folded back into the position and the owner's only relief was widening the residual exponent for every future settlement. The sentinel now resolves to min(balance, totalBorrowed()) via FixedPointMathLib.min, computed live in the transaction, which also gives folds an exact full-module repayment whenever the balance covers the debt. Two caveats documented at IRetargetter.rebalance: the module reports its debt rounded down, so a capped repayment can leave one or more wei of debt on the module rather than clearing it exactly; and sentinels resolve against one pre-call balance snapshot and do not compose, so REPAY sentinels across several modules can together commit more than the shared balance and revert. The constant's doc now points at the interface instead of restating the semantics. New tests pin the cap (surplus fold clears the debt and sweeps the excess where it previously panicked), the zero resolution on a debt-free module, and the snapshot non-composition through a cross-module payload whose failure selector distinguishes the capped resolution from the old full-balance one. * fix: address PR review (window authority, deadline buffer, doc/test cleanups) Bind flash-loan window authority to the module address instead of a boolean flag, so a third party gaining control mid-window stays unauthorized for every privileged function (WINDOW_TSLOT now holds the module; _checkOwnerOrRebalancer requires msg.sender == that module). Floor the loan-clock start with MIN_DEADLINE_BUFFER (80 days): the Request's 90-day deadline is mirrored into operation storage and the first capital commitment reverts DeadlineTooClose once less than the buffer remains, closing the consumer-timed skew between the deadline and the loan clock. Split IFlashLoanReceiver into its own file; qualify the two REBALANCER_ROLE mentions in the factory checklist; document that several instances per pair are supported; scope the RT-1 invariant NatSpec to cross-transaction state and add SYNC in-window unit tests; tighten the FULL_BALANCE_SENTINEL NatSpec. * fix: performance fee debt-carry write-off (hold reference unless crystallized) (#202) * fix: hold performance reference unless crystallized, rebase on flows 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. * fix: hold reference across underwater flows; test advance-without-mint 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. * feat: owner escape hatch to reset the performance reference 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. * feat: net held-interval management fees from the performance basis 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. * fix: freeze the performance reference during partial bad-debt windows A partial bad-debt exclusion that removes a debt-heavy module (PR #202 review comment) deleverages the visible aggregate: with modules A (10k collat / 1k debt) and B (10k / 5k) and the reference at (20k, 6k), a price step to 0.45 excludes B and the basis against the reduced aggregates (4.5k, 1k) reads +350 although NAV fell 14k to 3.5k. The accrual then minted a phantom performance fee at the trough and re-anchored the reference there, re-charging the healthy module's in-window recovery. LibView.totalAssets now also reports whether any module was excluded (hasBadDebt, derived in the same iteration; no new storage), and _pendingFees freezes the reference for the whole exclusion window: no crystallization, no advance, and no bootstrap seed while the flag is set. The management fee is unaffected; it keeps accruing on the good-debt collateral and joins the held accumulator for the eventual post-window netting. Behavior with no excluded module is unchanged. Flows during a window still rebase against the reduced universe. The carry clamp then re-anchors at the post-flow visible state, which is not direction-safe: a flow that leaves the visible LTV at or below the frozen mark under-reads the later recovery (LP-favorable), but a rescue repayment that pulls the excluded module back above water mid-flow (or a flow that borrows during the window) over-reads it and the recovery is charged from the trough. This is documented in rebaseSnapshot along with the operational remedy, and pinned by test: the remedy is a temporary zero performance rate through the first positive accrual (which advances the reference mintlessly), not resetPerformanceReference (a reset crystallizes the phantom basis before moving). New pinned tests: the trough accrual mints nothing and holds the reference on the review comment's exact numbers; in-window recovery is not charged; the first post-window gain is charged only above the pre-dip mark; the management fee accrues and nets through a window; a window flow re-anchors LP-favorably (unlevered case); the bootstrap seed is gated mid-window; the rescue-flow over-read and its fee-holiday remedy. The FeeData load in _pendingFees moved below the basis block to stay within the non-via-ir stack limit. * fix: hold the reference across flows that empty the good-debt universe The rebase hold guard required the good-debt universe to be empty on BOTH sides of a flow, so the transition flow itself fell through: with module B underwater and only healthy module A visible (prevCollat > 0), removing or draining A (removeBorrowModule has no underwater precondition, and a withdrawal can empty it) left newCollat == 0, the carry clamp forced carry to zero, and the rebase wrote lastDebt = 0 (the bootstrap sentinel) and lastTotalAssets = 0, destroying the held high-water mark. The sentinel then survived the window (the bad-debt gate blocks reseeding), and the first post-recovery accrual reseeded the reference near the bottom of the recovery, charging the rest of the climb back to the old peak as fresh gain; the exact re-charge the hold branch exists to prevent. The guard now holds the reference whenever the flow leaves the good-debt universe empty (newCollat == 0 with a live reference), regardless of the pre-flow state: there is no good-debt state to re-anchor on, and holding can only under-read the later recovery (LP-favorable), never over-read it. Full-repayment flows keep their collateral (newCollat > 0), so the carry-clamp sentinel path is unchanged. A flow that empties the vault outright is also held; the next accrual always advances on an empty vault, so no stale mark can feed a fee. Pinned by a unit test (supply-neutral removal and supply-changing drain, held management fee deduction stays nominal) and an integration test on the debt-heavy exclusion fixture (owner removes the healthy module mid-window; re-entry and sub-mark recovery charge nothing; fees resume only above the held mark). Both fail against the previous guard with the sentinel write. The per-share-basis fuzz test now excludes the hold path, which the dedicated tests pin. * feat: add MidasFund for Midas mToken integration (#201) * feat: add MidasFund for Midas mToken integration (mGLOBAL) Wraps Midas issuance and redemption vaults behind the IFund state machine. Settlement is configurable per direction (INSTANT via depositInstant/redeemInstant, REQUEST via depositRequest/redeemRequest) and changeable while no order is live; vault setters atomically update the paired settlement mode with full safeguards (no live order, same mToken, payment token registered, vault not paused, fund + wrapped share greenlisted). Generic over any mToken: the token is derived from the deposit vault at initialize and all amounts are converted between native and Midas base-18 decimals at the boundary. Rejected Midas requests are refunded off-band, handled via the RECOVERING balance-threshold flow with operator resolve()/recovering() escape hatches. Shares are WrappedAsset tokens; multiple fund instances can share one wrapper to run concurrent settlements. Includes beacon-proxy factory, Midas integration interfaces, unit + factory + fuzz/invariant tests with full Midas mocks, and mainnet fork tests against the live mGLOBAL vaults (instant/request deposit and redemption on both the Aave and Swapper redemption vaults). * feat: MidasFund instant-only settlement with mandatory holdback - remove REQUEST settlement mode entirely: commit() always settles synchronously via depositInstant/redeemInstant; SettlementMode, request-id tracking and setSettlementMode are gone - add mandatory per-order holdback gate: every order (both directions) stays PROCESSING after the instant settlement until HOLDBACK_ROLE confirms the off-band payment via confirmHoldback() (deposits: the remaining mTokens airdropped after month-end NAV publication; redeems: the holdback returned in the payment token); unlock() then sweeps the fund's full output-token balance - add VAULT_MANAGER_ROLE gating setDepositVault/setRedemptionVault, intended for an EOA distinct from the operator, with the existing safeguards (no live order, mToken match, payment token, pause and greenlist checks) - trim the Midas integration interfaces to the instant-only surface - rework the Midas test suite: 131 unit + 11 factory + 8 fuzz tests, 5 invariants, 7 mainnet fork tests, all green * feat: MidasFund async deposits via depositRequest, holdback redeem-only - deposits no longer settle instantly: commit() calls depositRequest and records the mint request id (activeRequestId view, requestId in OrderCommitted); the order stays PROCESSING until the Midas admin approves the request, which mints the full amount at the approval NAV directly to the fund — no deposit holdback - rejected requests are refunded off-band: state() auto-reports RECOVERING on a CANCELED mint request once the refund covers the effective input - holdback gate is now redeem-only: create() presets holdbackPaid for deposit orders, so holdbackPending() is false and confirmHoldback() reverts HoldbackNotPending for deposits - create()/commit()/setDepositVault/setRedemptionVault preflight fnPaused for the commit-time selectors (depositRequest/redeemInstant) in addition to the global pause - restore MidasRequestStatus + depositRequest/mintRequests in the integration interfaces; mock deposit vault gets request storage with approve/reject helpers - rework the Midas test suite: 131 unit + 11 factory + 9 fuzz tests, 6 invariants, 8 mainnet fork tests (real depositRequest approve and reject lifecycles), all green * docs: polish Midas fund integration * feat: restrict MidasFund recovery to deposit orders A committed redeem settles irreversibly on-chain via redeemInstant, so recovering it would strand the instant payment-token settlement in the fund (recover only returned the re-wrapped mToken). Remove the redeem recovery path entirely: the only completion path for a committed redeem is confirmHoldback() — confirming the payment or deliberately waiving it — followed by unlock(). - recovering() now takes the Order struct (instead of the order id) so the mode can be checked, and reverts RecoverNotSupported for redeems - recover() and the RECOVERING branch of _state() are asset-only now - confirmHoldback() NatSpec documents the waive-as-write-off procedure * feat(midas): support partial redeem unlocks * feat(midas): add Repay-and-Redeem bond flow for redeems Midas requires a negotiable bond (e.g. 2%) paid in mTokens to a designated wallet before honoring full redemptions of levered positions; the bond is forfeited if the redemption is abandoned. When a BondConfig is set (VAULT_MANAGER_ROLE, between orders only, removable via removeBondConfig), a redeem commits in two legs: - bond leg: commit() burns input * bps / BPS wrapped shares, forwards the unwrapped mTokens to the bond recipient and parks the order in PROCESSING without touching the redemption vault; - unlockInstantRedeem() (PAYMENT_ROLE, renamed from HOLDBACK_ROLE) confirms the bond receipt and returns the order to ACCEPTED; called before any commit it waives the bond for that order; - redeem leg: commit() settles the remaining input - bondPaid via redeemInstant with a proportionally scaled min-out; the holdback flow applies unchanged from there. Both legs report order.input as the committed amount (the facility requires the full input on every commit and accounts with balance deltas). Once the bond is paid, cancel() reverts (BondAlreadyPaid): the order can only complete forward. During the bond phase state() ignores balances and confirmHoldback() reverts, so donations cannot fake UNLOCKING or end an unredeemed order. A zero config keeps the single-commit instant flow. Ops notes: the bond recipient must be Midas-greenlisted for permissioned mTokens (fork-tested); upgrade the beacon only while all fund instances are EMPTY/ENDED. * feat(midas): add abort path for unsettled bonded redeems A bonded redeem was forward-only after the bond leg (cancel reverts BondAlreadyPaid), so a permanently-reverting redeem leg bricked the fund instance and froze the whole Facility intent (resolve/setFund revert on checkNoPendingOrder). Allow recovering() for UNSETTLED bonded redeems — bond-phase PROCESSING, or re-ACCEPTED with the bond paid — as the operator-gated abort: the bond stays forfeited (matching Midas abandonment semantics), the remainder shares never left the depositor, and recover() finalizes with a possibly zero amount, sweeping any off-band refund. Settled redeems (redeemInstant executed, i.e. PROCESSING with the instant redemption unlocked) stay recovery-blocked and complete forward via unlock() + confirmHoldback(). - recovering() re-locks instantRedeemUnlocked so cancelRecovering() always lands back in the consistent bond phase - redeem RECOVERING state is unconditional (input is share-denominated; nothing is owed back) and recover() skips the transfer at zero - no _checkNoLiveOrder relaxation: abort-then-reissue composes rescue completion through the existing between-orders setters - tests: 8 unit (abort entry states, settled/uncommitted rejection, refund sweep, cancelRecovering landing, resolve-inert, abort-then-reissue), 1 fuzz, 1 invariant, 1 mainnet-fork (abort + reissue through the real Aave vault) * feat(midas): allow setRedemptionVault at any time Midas deploys the dedicated Repay-and-Redeem redemption vault only once the bond is received, so the vault must be configurable while the bonded redeem is live. Drop the _checkNoLiveOrder gate from setRedemptionVault entirely: no per-order state lives on the redemption vault — it is read only at create-time preflight and at the redeem-leg commit, which re-validates access and enforces the order's min-out on-chain. All vault-validity checks are kept (same mToken, payment token registered, not paused/fn-paused, fund + wrapper greenlisted). setDepositVault stays gated: the pending mint request is tracked on it. If async redemptions (redeemRequest) are ever added, the redemption vault will carry pending-request state too and the gate must come back (flagged in natspec). - tests: swap in every lifecycle state, redeem leg proven to settle through the swapped-in vault, full swapped two-leg lifecycle (unit + fuzz), handler swap action under the invariant campaign, mainnet-fork bond-then-swap lifecycle through the Swapper vault * docs: update MidasFund operator runbook for the bond flow The README (the only operator runbook) predated the Repay-and-Redeem work: the role table still listed the Holdback role (renamed PAYMENT_ROLE, now also confirming bond receipts), and the recovery wording was wrong (recovery is payment-token-only and covers unsettled bonded-redeem aborts, not just refund-gated deposits; settled redeems are never recoverable). - role table: Payment role duties (confirmHoldback + unlockInstantRedeem), owner as fallback for the operator/payment/vault-manager roles, vault-manager gating asymmetry (deposit vault between orders, redemption vault at any time) + bond config - key design decisions: bonded-redeem and vault-swappability bullets, corrected recovery bullet, bond-recipient greenlist note - flows: new Bonded Redeem Flow (including the mid-order setRedemptionVault step for the Midas-deployed dedicated vault), rewritten Recovery Flow (entry states, deposit vs abort, cancelRecovering bond-phase landing) - state diagram: PROCESSING -> ACCEPTED transition via unlockInstantRedeem() (Midas bonded redeem) * feat(midas): cap bond amount at 5% (MAX_BOND_AMOUNT) * feat(midas): remove holdback — every redeem is a bonded Repay-and-Redeem The dedicated per-redemption Repay-and-Redeem vault pays the full proceeds instantly, so the redeem holdback machinery is obsolete: drop the holdbackPaid flag (packed-slot removal, layout-safe), the confirmHoldback/holdbackPending entry points, the HoldbackConfirmed event and the HoldbackNotPending error. Every redeem now follows the two-leg bond flow. - create() locks every redeem into the bond phase (instantRedeemUnlocked = mode == DEPOSIT; the true-for-deposits value is what makes unlockInstantRedeem — orderId-only, mode-blind — reject deposit orders). An empty bond config only zeroes the payment: the bond leg still parks PROCESSING awaiting unlockInstantRedeem, which doubles as the pre-commit skip when no bond is required. - create() resets redemptionVault to address(0) (emitting RedemptionVaultUpdated when it was set): Midas deploys a dedicated one-shot vault per redemption, so each order must get setRedemptionVault(freshVault) before its redeem leg or the commit reverts InvalidContract(0). initialize()/createFund() drop the redemptionVault parameter accordingly. - redeem unlock() is single-shot terminal: a settled redeem is always UNLOCKING with the full payment-token balance — even zero, so a dust redeem whose proceeds floor to zero finalizes via a zero-amount unlock instead of bricking the instance. - _validateOutput is deposit-only: redemption-vault feeds sit ~13% below the deposit feeds on mainnet, far past the 5% bound; the redeem min-out is enforced on-chain by redeemInstant at commit instead. totalAssets() prices with the deposit-vault feeds. - README: roles table, key design decisions, redeem flow and state diagram rewritten for the always-bond model. - tests: unit/fuzz/invariant/factory/fork suites reworked for the create -> commit(bond) -> setRedemptionVault -> unlockInstantRedeem -> commit(redeem) -> unlock lifecycle; handler down to 16 selectors. * fix(midas): return the redeem-abort bond refund as re-wrapped mTokens The abort path for an unsettled bonded redeem assumed any off-band refund arrives in the payment token: the RECOVERING state read the USDC balance and recover() swept it for both modes. But the bond was paid in mTokens, and that is what Midas returns on abandonment — the old code would report 0 recoverable and strand the returned mGLOBAL. This is also a spec-conformance fix: IFund.recover already specifies "REDEEM: shares returned to the receiver", and FacilityFunds.recover snapshots share() for a redeem's balance delta. Returning the refund as freshly wrapped shares therefore lands it inside the Facility's intent accounting (closing the documented outside-the-books caveat), and raw mGLOBAL could not go to the non-greenlisted Facility anyway. - _state() RECOVERING reports the fund's mToken balance for a redeem (unconditional, possibly zero); the deposit branch (USDC balance >= effective input) is unchanged. - recover() re-wraps the balance 1:1 into WrappedAsset shares minted to the receiver, via a _wrapTo helper shared with the deposit unlock() wrap. Zero-amount finalization stays for a forfeited bond. - Documented edges: USDC sitting in the fund during a redeem recovery is ignored (later balance sweeps pick it up); cancelRecovering() after the bond came back strands the mTokens until a later deposit's unlock wraps them — operator rule in README + natspec: once the refund is in, finalize via recover(). - tests: refund-sweep unit/fuzz tests reworked to mint mGLOBAL and assert the 1:1 share mint; new unit tests pin the USDC-ignored and cancelRecovering-stranding edges; handler act_refund mints mTokens (bounded by bondPaid) for RECOVERING redeems with a ghostOutstandingMTokenRefund ghost backing the extended ended-redeem-strands-nothing invariant; new fork test returns the real mGLOBAL bond from the greenlisted recipient and re-wraps it. * feat(midas): verify the redeem payout fund-side (InsufficientRedeemOutput) The redeem leg settles through a per-redemption vault deployed by Midas and pointed at mid-order, so the vault cannot be 100% trusted to honor the minReceiveAmount passed to redeemInstant. Snapshot the payment-token balance around the call and revert InsufficientRedeemOutput(received, minimum) when the received delta is below the order's minimum scaled to the non-bond remainder (order.output * redeemAmount / order.input, in native decimals — the assetScale factor only converts the bound to base-18 for the vault API). - tests: MockMidasRedemptionVault gains setPayoutOverride(amountNative), which bypasses its rate math and its own slippage require to simulate a misbehaving vault; new unit tests assert the revert one wei below the bond-scaled minimum and acceptance at exactly the minimum. The mainnet-fork redeems run with the check active against the real vaults (no false positive under real fee/rate mechanics). * Use oracle feed for Midas pricing * Harden Midas commit state transition * fix(midas): reject recovering() on a settled deposit A deposit whose mint request was approved keeps internalState == PROCESSING in storage (only the dynamic state moves to UNLOCKING), so recovering() accepted the flag on an already-claimable order. Once flagged, the RECOVERING branch reads the asset balance (zero — it went to Midas at commit) and reports PROCESSING: unlock() and recover() both revert until cancelRecovering(). Worse, a stray asset balance covering the effective input would let recover() end the order and strand the minted mTokens in the fund. Guard the deposit branch on the dynamic state: recovering() now reverts InvalidState(UNLOCKING) when the mint request is approved and the payout claimable. Legitimate flags are preserved (PENDING requests awaiting an off-band unwind; partial/wrong mints still reporting PROCESSING). An approval landing only AFTER flagging still parks the order the same way — that race is inherent and its remedy is cancelRecovering(), now documented in the natspec. - tests: recovering() rejected on a settled deposit (payout still unlockable), and the approve-while-recovering race pinned end-to-end (parked as PROCESSING, restored by cancelRecovering, then unlocked); handler act_recovering comment updated (the invariant model already only advances on success). * Cantina audit fixes — v1.3.0 (#206) * chore: open v1.3.0-fix integration branch for Cantina audit fixes * ci: pin GitHub Actions to commit SHAs * fix: gate Midas bond config on the operator role (Cantina #45) (#212) 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. * fix: measure remediationDelta price drift against expected collateral 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: Retargetter rebalance path and flash-loan callback hardening (#209) * 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 (#210) * 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. * 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. * fix: Retargetter operation lifecycle, cap gates & fund-adapter assumptions (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. * fix: held management-fee deduction accounting (basis-capped consume, 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: fees, Facility, and offer/pre-liquidation known issues (#215) * 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 * fix: expose the bad-debt exclusion flag and document the management-fee 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 …
1 parent 96a18e3 commit 90e02c5

95 files changed

Lines changed: 26155 additions & 274 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/test.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ jobs:
1616
name: Foundry project
1717
runs-on: ubuntu-latest
1818
steps:
19-
- uses: actions/checkout@v5
19+
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
2020
with:
2121
persist-credentials: false
2222
submodules: recursive
2323

2424
- name: Install Foundry
25-
uses: foundry-rs/foundry-toolchain@v1.6.0
25+
uses: foundry-rs/foundry-toolchain@8b0419c685ef46cb79ec93fbdc131174afceb730 # v1.6.0
2626

2727
- name: Show Forge version
2828
run: forge --version

‎README.md‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ src/
3838
│ │ ├── USCCFund.sol # Superstate USCC integration
3939
│ │ ├── USCCFundFactory.sol # Beacon proxy factory
4040
│ │ └── SuperstateRestrictedWrappedAsset.sol # WrappedAsset with Superstate allowlist
41-
│ └── WrappedAsset.sol # Wrapper token (wUSCC, etc.) with virtual isAllowed hook
41+
│ ├── midas/
42+
│ │ ├── MidasFund.sol # Midas mToken (mGLOBAL, etc.) integration
43+
│ │ └── MidasFundFactory.sol # Beacon proxy factory
44+
│ └── WrappedAsset.sol # Wrapper token (wUSCC, wmGLOBAL, etc.) with virtual isAllowed hook
4245
├── borrow/ # Lending protocol integrations
4346
│ ├── MorphoBorrowPosition.sol # Morpho Blue position
4447
│ └── MorphoBorrowPositionFactory.sol # Beacon proxy factory
@@ -112,6 +115,11 @@ This section provides a consolidated view of all roles across contracts and how
112115
| **ParetoFund** | Owner | Protocol Admin | Resolve stuck orders |
113116
| | Operator | Operations Bot | Resolve stuck orders |
114117
| | Depositor | Facility | Create/cancel/commit/unlock orders |
118+
| **MidasFund** | Owner | Protocol Admin | Fallback for the operator/payment/vault-manager roles: resolve stuck orders, flag/cancel recovery, confirm bond receipts, set vaults/bond config/referrer |
119+
| | Operator | Operations Bot | Resolve stuck orders, flag/cancel recovery, set referrer and mToken/USD oracle |
120+
| | Payment | Operations EOA | Confirm bond receipts via `unlockInstantRedeem()` (required before every redeem can settle; also skips the bond leg when nothing is required for a redemption) |
121+
| | Vault Manager | Operations EOA | Set the deposit vault (between orders) and the per-redemption redemption vault (at any time; reset on every create), manage the bond config |
122+
| | Depositor | Facility | Create/cancel/commit/unlock/recover orders |
115123
| **PositionManager** | Owner | Protocol Admin | Add modules, set LLTV, set fees |
116124
| | Minter | Facility | Deposit, withdraw, burn shares |
117125
| | Curator | Operations Bot | Set supply/withdrawal queues |
@@ -504,6 +512,7 @@ stateDiagram-v2
504512
ACCEPTED --> EMPTY: cancel()
505513
PENDING --> EMPTY: cancel()
506514
ACCEPTED --> PROCESSING: commit()
515+
PROCESSING --> ACCEPTED: unlockInstantRedeem() (Midas redeem bond leg)
507516
PROCESSING --> UNLOCKING: success
508517
PROCESSING --> RECOVERING: failure
509518
UNLOCKING --> PROCESSING: partial unlock
@@ -517,8 +526,8 @@ stateDiagram-v2
517526

518527
| Mode | Input | Output |
519528
|------|-------|--------|
520-
| DEPOSIT | Asset (e.g., USDC) | Shares (e.g., wUSCC) |
521-
| REDEEM | Shares (e.g., wUSCC) | Asset (e.g., USDC) |
529+
| DEPOSIT | Asset (e.g., USDC) | Shares (e.g., wUSCC, wmGLOBAL) |
530+
| REDEEM | Shares (e.g., wUSCC, wmGLOBAL) | Asset (e.g., USDC) |
522531

523532
### USCC Integration (Superstate)
524533

@@ -582,6 +591,41 @@ stateDiagram-v2
582591
3. *Wait for CDO epoch to end*
583592
4. `unlock()` - Call `claimWithdrawRequest()`, send underlying assets to receiver
584593

594+
### Midas mToken Integration
595+
596+
`MidasFund` wraps Midas mTokens (for example, mGLOBAL) through a WrappedAsset token such as `wmGLOBAL`. It uses a Midas issuance vault plus a dedicated, per-redemption redemption vault.
597+
598+
**Key Design Decisions:**
599+
- Uses an **internal state pattern**: the stored `internalState` may differ from what `state()` returns, because `state()` checks the Midas mint request status and fund token balances.
600+
- Deposit output validation and `totalAssets()` use a configurable **8-decimal AggregatorV3 mToken/USD oracle**. The payment asset is assumed to be worth exactly $1; Midas vault feeds are not used for fund-side pricing. The owner or operator can rotate the oracle via `setOracle()`.
601+
- Deposits are **asynchronous** via `depositRequest()`: `commit()` transfers the payment token to Midas, then the order stays PROCESSING until a Midas vault admin approves the mint request and mints mToken to the fund.
602+
- **Every redeem follows the Repay-and-Redeem bond flow** and commits in **two legs**. The first `commit()` burns the bond fraction of the shares (`bondConfig`, set via `setBondConfig()` / `removeBondConfig()` between orders only; an empty config zeroes the payment but not the flow) and forwards the unwrapped mTokens to the bond recipient; `unlockInstantRedeem()` (owner or payment role) confirms the bond receipt and re-arms the order — also callable before any commit to skip the bond leg when nothing is required for a specific redemption; the second `commit()` settles the remainder **instantly** via `redeemInstant()`, and a single terminal `unlock()` sweeps the full proceeds (possibly zero) and ends the order. Once the bond is paid, `cancel()` reverts (the bond is forfeited on abandonment): the order completes forward or is aborted via recovery.
603+
- The **redemption vault is per-redemption**: `create()` resets it to `address(0)` and Midas deploys the dedicated Repay-and-Redeem vault only once the bond is received, so `setRedemptionVault()` (allowed at any time — the vault carries no per-order state) must point the fund at the fresh vault before the redeem leg commits. Consequently redeem outputs are not oracle-validated at create: `redeemInstant()` enforces the order's minimum output on-chain at commit, and the fund re-checks the received amount against that minimum itself (the per-redemption vault is not blindly trusted). The **deposit vault can only change between orders** (the pending mint request is tracked on it).
604+
- Recovery covers **deposits** and **unsettled bonded redeems**. For deposits it returns the payment token and depends on an off-band Midas admin return of the committed input (rejected requests are not refunded on-chain): `state()` falls back to PROCESSING until the refund covers it. An unsettled bonded redeem (bond paid, redemption not executed) is **aborted** unconditionally: the remainder shares never left the depositor, and the only amount possibly owed back is the bond — returned by Midas off-band **in mTokens**, which `recover()` re-wraps 1:1 into shares minted to the receiver (so the refund lands inside the Facility's intent accounting, which snapshots the share token on a redeem recover). `recover()` may finalize with a zero amount when the bond stays forfeited. A **settled** redeem is never recoverable: it completes via the terminal `unlock()`.
605+
- When the Midas vault greenlist is enabled or the mToken is permissioned, both the fund and the WrappedAsset must be greenlisted before orders can be created or committed — and the bond recipient must be greenlisted for the bond leg to succeed.
606+
607+
**Deposit Flow (Asset → WrappedShare):**
608+
1. `create(DEPOSIT)` - Initialize order and validate output against the external mToken/USD oracle
609+
2. `commit()` - Pull assets, approve the deposit vault, call `depositRequest()`, and store the mint request id
610+
3. *Wait for Midas admin approval*
611+
4. `unlock()` - Wrap the minted mToken into WrappedAsset and send it to the receiver
612+
613+
**Redeem Flow (WrappedShare → Asset — Repay-and-Redeem):**
614+
1. `create(REDEEM)` - The order starts bond-locked; the stored redemption vault is reset to `address(0)`
615+
2. `commit()` - Bond leg: burns the bond fraction of the shares and forwards the unwrapped mTokens to the bond recipient (nothing moves with an empty bond config; no redemption vault interaction)
616+
3. *Midas deploys the dedicated redemption vault* — `setRedemptionVault()` points the fund at it (allowed mid-order)
617+
4. `unlockInstantRedeem()` - Owner or payment role confirms the bond receipt (callable before any commit to skip the bond leg when nothing is required for this redemption)
618+
5. `commit()` - Redeem leg: settles `input − bondPaid` instantly via `redeemInstant()` with the order's minimum output enforced on-chain and re-verified by the fund against the received balance
619+
6. `unlock()` - Terminal sweep: transfers the full payment-token balance (possibly zero, then no transfer) to the receiver and ends the order
620+
- `cancel()` reverts once the bond is paid; a stuck order is aborted via the recovery flow below (the bond stays forfeited)
621+
622+
**Recovery Flow (off-band unwind / bonded-redeem abort):**
623+
1. `recovering()` - Owner/operator flags the order: a PROCESSING deposit, or an unsettled bonded redeem (bond phase, or re-ACCEPTED after `unlockInstantRedeem()`); settled redeems are rejected
624+
2. *Deposits: wait for Midas to return the committed input off-band in the payment token (the state falls back to PROCESSING until the refund covers it). An aborted bonded redeem is recoverable immediately; if Midas returns the bond, it arrives off-band in mTokens*
625+
3. `recover()` - Deposits: sweep the fund's payment-token balance to the receiver. Aborted redeems: re-wrap the fund's mToken balance 1:1 into shares minted to the receiver (possibly zero when the bond stays forfeited) — payment tokens are ignored. Ends the order either way
626+
627+
`cancelRecovering()` restores PROCESSING — for a bonded redeem it lands back in the bond phase, so `unlockInstantRedeem()` must be re-called to resume completion. Only cancel a redeem recovery while no bond has been returned: mTokens already received are not swept by the resumed order's completion (they would surface later as a deposit balance sweep) — once the refund is in, finalize via `recover()`.
628+
585629
## Position Manager
586630

587631
The `PositionManager` aggregates multiple `IBorrowPosition` contracts into a single vault with ERC20 share-based accounting.
@@ -1067,6 +1111,7 @@ flowchart TB
10671111
- `USCCFundFactory` - Deploys USCC fund wrappers
10681112
- `CentrifugeFundFactory` - Deploys Centrifuge ERC-7540 fund wrappers
10691113
- `ParetoFundFactory` - Deploys Pareto CDO fund wrappers
1114+
- `MidasFundFactory` - Deploys Midas mToken fund wrappers
10701115
- `TransferGuardFactory` - Deploys transfer guards
10711116

10721117
**Upgrading:** The beacon owner can upgrade all proxies by updating the beacon's implementation.
425 KB
Binary file not shown.

0 commit comments

Comments
 (0)