feat: add MidasFund for Midas mToken integration#201
Conversation
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).
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
- 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
Solidity lines of code (production
|
| Metric | Value |
|---|---|
src/ files changed |
9 (8 new) |
| Raw diff (git) | +1,106 / −0 lines |
| Solidity code lines (cloc, comments & blanks excluded) | +510 added |
Counted with cloc --diff <base>/src <head>/src --include-lang=Solidity.
- 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
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
maxencerb
left a comment
There was a problem hiding this comment.
Review & audit — MidasFund (PR #201)
🤖 This review was produced with Claude Code (Opus 4.8), via a multi-agent adversarial audit: 8 dimension finders → refute-by-default verification → a completeness critic (26 agents total), cross-checked by hand against the code, the
IFundspec, the siblingUSCCFund, and the prior Cantina / ChainSecurity audit reports in./audits. Findings that merely restate patterns 3F already reviewed-and-accepted for the sibling funds are called out as such rather than re-reported.
Verdict
Solid. No Critical/High/Medium issues. The design faithfully reuses the audited adapter model, correctly extends the IFund state machine for the two genuinely new mechanisms (async deposit-requests and the redeem holdback), and the four comments from the earlier revision are all genuinely resolved with dedicated tests. The residual items below are one Low plus informational / test-coverage notes — sharing for awareness, not requesting changes.
forge build + the 161 non-fork Midas tests pass locally. Fork/invariant suites need an RPC (not run here); their code is assessed below.
1. The four earlier comments — all genuinely resolved ✅
| Comment | Resolution | Test |
|---|---|---|
Recovery RECOVERING→ENDED could accept a partial/over-repayment as full |
Deposits settle via depositRequest; state() auto-reports RECOVERING only when the request is CANCELED and assetBalance ≥ effectiveInput (MidasFund.sol:640). Operator can resolve() the threshold up before recovering (:659-661). |
test_Resolve_PartialRefundRecovery, test_Resolve_RaisedThresholdKeepsProcessing |
create() should revert when the fn commit() will call is paused |
create() runs _checkVaultAccess with the direction-correct selector (:227-229, :740-754) |
test_Create_RevertsDepositRequestPaused, test_Create_RevertsRedeemInstantPaused |
Reset requestId on create() |
Resets requestId=0 + hasResolvedAmounts/holdbackPaid/resolvedInput/resolvedOutput (:241-247) |
test_Holdback_FlagsResetOnNextOrder |
setReferrerId is the only setter without _checkNoLiveOrder |
Intentional — metadata read once by Midas at depositRequest (:492-493); confirmed it never touches state()/accounting |
test_SetReferrerId_AllowedDuringLiveOrder |
Nuance on the recovery comment: the fix is correct but relies on operator diligence — if an off-band refund arrives in installments where the first alone ≥ input and the operator recovers before resolve()-ing the threshold up, later installments strand (then fall under the accepted raw-balance-carry-forward pattern, Cantina 3.3.1). Same residual as the accepted USCC design.
2. Findings (all adversarially verified — none exceeded Low)
🟡 Low — confirmHoldback() arms the terminal unlock without verifying the holdback landed
MidasFund.sol:448 (terminal gate :359, redeem state() :654-655)
confirmHoldback only checks internalState==PROCESSING && !holdbackPaid; it does not verify the off-band ~7% true-up actually arrived. If HOLDBACK_ROLE confirms early, the next unlock() is terminal (ENDED) at only the ~93% instant settlement. A true-up arriving afterward hits a fund with no live order — and since there is no rescue/sweep function, it is either swept into an unrelated later redeem (accepted 3.3.1 misattribution) or, if the instance is later decommissioned / deposit-only, permanently stranded.
Under correct operation there is no loss (confirm after receipt → unlock sweeps it and ends cleanly, as test_Fork_InstantRedeem_HoldbackLifecycle does). It's a "trusted role must follow the documented order of operations" gap, new to this holdback flow.
Suggestion (any one): (a) require the terminal unlock's realized balance to cover the resolved/owed total, keeping the order PROCESSING until swept; or (b) add an onlyOwner rescue for a late true-up landing post-ENDED; or at minimum (c) assert the expected holdback balance is on-chain inside confirmHoldback, and document that it must only be called after receipt (or as a deliberate write-off accepted as loss).
⚪ Informational / hardening
setDepositVault/setRedemptionVault(VAULT_MANAGER_ROLE) validate only structural shape of the candidate vault —mToken(), feed tuple,paused/fnPaused, greenlist — all forgeable by an attacker contract (:459/:475). Owner is a fallback so it's a trust assumption, but it's a new mutable fund-flow target that USCC lacks. Consider a Midas-registry/owner allowlist, oronlyOwner.recovering()doesn't require the mint request be CANCELED (:398). Called on a still-PENDING request that Midas then approves, the minted mToken is trapped (RECOVERINGstate()branch reads only the asset balance,:659-661) untilcancelRecovering(). Self-healing, no loss, operator-error-gated. Cheap fix:require(_mintRequestStatus($)==CANCELED), and/or fall back to UNLOCKING when the mToken is present.- Create-time slippage is fee-unaware (
_validateOutput,:698/:704) — gross feed value, not net of MidasinstantFee/tokensConfig.fee. Benign and matches accepted 3.3.10 / 3.4.9-10; live mGLOBAL (0.5% instant, 0 token fee) is safely inside the 5% band. If a future redemption vault's fee ever exceedsMAX_OUTPUT_DEVIATION, every redeem would DoS at commit — worth documentingorder.outputshould be net of fees, or capping a newly-set vault fee< 5%. renounceOwnership()foot-gun — a redeem finalizes only viaconfirmHoldback(HOLDBACK_ROLE or owner). Renouncing before granting HOLDBACK_ROLE could leave open redeems unable to reachENDED. Document role-provisioning order, or guardrenounceOwnership.
📋 Test-coverage gaps
- Mocks stub
minAmount/instantDailyLimit/minMTokenAmountForFirstDepositas no-ops → no test exercises Midas limit gating. (Real MidasdepositRequestvalidates the per-op min + first-deposit floor at request time, so a doomed first deposit reverts atomically at commit — funds safe, but untested.) - No test models an off-band payment arriving after an order reached
ENDED.MidasFundHandlergatesact_payHoldback/act_refundon states impossible post-ENDED, soinvariant_EndedRedeemLeavesNoAssetBalancetrivially holds and gives false assurance for exactly the strand/misattribution path in the Low finding. Add an unconditional/post-ENDEDdonate action + an assertion that consciously pins the 3.3.1 acceptance. requestId==0never exercised (mock counter starts at 1). No reachable strand; arequire(requestId != 0)afterdepositRequestis cheap defense.
3. Checked and confirmed correct (examined, not a bug)
- IFund state-machine conformance — all transitions legal;
ENDEDpermanence viaendedOrders+OrderAlreadyExists; the zero-amount terminal unlock(ENDED, 0)is gated behindconfirmHoldbackand pinned by theFacilityFundsregression test; redeem partial-unlock →PROCESSINGand depositPROCESSING→RECOVERINGon CANCELED are spec-legal. - Midas integration correctness —
mintRequests/tokensConfigtuple layouts and theRequestStatusenum are authoritatively validated by the fork tests against the real mainnet mGLOBAL vaults (they decode field 3 =status, field 6 =tokenOutRatefed intoapproveRequest, and assert deposit/redeem math within 0.1% — a wrong offset would blow those assertions). Base-18↔native conversion, fee-inclusiveredeemInstantapproval, and both Aave/Swapper redemption variants are fork-tested. - Reentrancy — fund has no guard but is DEPOSITOR-only (the
nonReentrantFacility); no re-entry path into the fund exists; approvals reset to 0 after every use; permissioned-mToken transfers at unlock are pre-validated at commit (addresses ChainSecurity 8.9). - Accepted-pattern reuse (NOT new bugs): raw-balance sweeping (3.3.1), one-sided create-time slippage (3.3.10), dust orders (3.3.18), wrapper-wide
totalAssets(3.4.2), no Grunt-pause on fund methods (3.3.19), shared-WrappedAsset ISSUER_ROLE prerequisite — identical to already-accepted sibling behavior. - Invariants are strong and on-point:
PendingDepositRequestBlocksUnlocking,EndedRedeemLeavesNoAssetBalance,RedeemStateTracksBalance,FundDoesNotHoldWrappedShares,WrappedShareSupplyMatchesUnderlying.
The only item I'd actively weigh is the Low (confirmHoldback receipt-check / owner rescue); the VAULT_MANAGER allowlist and the recovering() CANCELED gate are worthwhile defense-in-depth. Everything else is documentation / test coverage. Nice, careful adapter. 👍
Generated by Claude Code · adversarial multi-agent review, hand-verified.
Summary
Adds
MidasFund, anIFundadapter for the Midas protocol (targeting mGLOBAL/mGLO, generic over any mToken), plus its beacon-proxy factory, integration interfaces, and a full test suite.Design
depositRequest(no holdback) —commit()transfers the payment token to Midas and records the mint request id (exposed viaactiveRequestId()and therequestIdparam onOrderCommitted). The order staysPROCESSINGuntil a Midas vault admin approves the request, which mints the full mToken amount at the approval NAV directly to the fund →UNLOCKING. A rejected request is not refunded on-chain:state()reports the order recoverable (RECOVERING) once Midas returns the payment token off-band (e.g. viawithdrawToken).redeemInstantwith partial unlocks + holdback flag — Midas settles ~93% on-chain (±7%-adjusted feeds) and returns the holdback off-band in the payment token. Any positive payment-token balance is immediately claimable:unlock()sweeps it and the order returns toPROCESSING(repeatable as funds arrive), so the instant proceeds are never locked behind the holdback. An account withHOLDBACK_ROLE(or the owner) confirms receipt — or deliberately waives it as the write-off procedure — viaconfirmHoldback(orderId), which only flips the holdback flag and never changes the lifecycle state. Once confirmed,state()reportsUNLOCKINGeven at zero balance and the nextunlock()is terminal: it sweeps any remainder (possibly zero — a zero-amountOrderUnlockedwith no transfer) and ends the order.unlock()therefore remains the only successful-completion transition toENDED, so the Facility always auto-detaches the order on theENDEDreturn.confirmHoldbackreverts (HoldbackNotPending) for deposit orders.OPERATOR_ROLE(resolve/recovering/referrer),DEPOSITOR_ROLE(Facility order lifecycle),HOLDBACK_ROLE(confirm redeem holdback payments; intended for an EOA distinct from the operator),VAULT_MANAGER_ROLE(set deposit/redemption vaults; also intended for a dedicated EOA).MidasFundFactory, all sharing the sameWrappedAsset(wmGLOBAL) as the share token. Note each order occupies its instance until the Midas admin approves the mint request (deposits) or the holdback is confirmed and the terminal unlock lands (redeems) — though redeem proceeds are claimable throughout — so size the instance count accordingly.initializeand validated against the redemption vault and wrapper underlying; all amounts are converted between the payment token's native decimals and the Midas base-18 API at the boundary.setDepositVault(address)/setRedemptionVault(address)(VAULT_MANAGER_ROLE or owner) enforce: no live order, same mToken, payment token registered, vault not paused (globally or for the commit-time function the fund calls), and (when the vault greenlist is enabled) both the fund and the wrapped share greenlisted. Supports routing between the Aave/Swapper redemption vaults or a dedicated instant-redemption vault.create(),commit()and the vault setters check Midas' per-function pause (fnPaused) for the two selectors the fund calls (depositRequest(address,uint256,bytes32),redeemInstant(address,uint256,uint256)) in addition to the global pause, so orders fail early instead of at settlement.state()auto-reportsRECOVERINGon aCANCELEDmint request once the refund covers the effective input; the operator can also flagrecovering()manually, withresolve()/cancelRecovering()escape hatches (USCCFund model). Recovery is deposit-only: a committed redeem settled irreversibly and completes forward via partial/terminal unlocks +confirmHoldback.resolve()) only affect deposit thresholds — redeem claimability tracks the actual balance (the minimum output is already enforced on-chain byredeemInstantat commit).Tests
PROCESSINGredeem reportsUNLOCKINGexactly when it holds a positive claimable balance or a confirmed holdback, that an ended redeem strands no assets in the fund, and that no deposit unlocks while its mint request is stillPENDING(regardless of token balances)depositRequest+ adminapproveRequest, mint within 0.1% of the deposit-feed expectation), request rejection + off-band-refund recovery, partial-unlock + holdback redemption lifecycles, off-band holdback payment swept at unlock, Swapper-vault redemption, greenlist gating, totalAssetsFacilityFunds.unlock()accepts(ENDED, 0)— zero tokens received, no intent-accounting change, and the order + fund binding clearedOperational notes for launch
WrappedAssetmust holdM_GLOBAL_GREENLISTED_ROLE(mGLOBAL ismTokenPermissioned— every transfer checks both parties).withdrawTokento the fund contract address).HOLDBACK_ROLEis a liveness dependency for every redeem's completion, not its proceeds: the instant settlement is claimable immediately via partialunlock(), but the order — and thus the fund instance — only ends after aconfirmHoldbacktransaction followed by the terminal unlock (owner is the fallback confirmer). Midas must deliver holdback payments to the fund contract address — that is what the sweeps capture.depositRequestfn-paused on mainnet; only instant redemption is live. Midas must unpause + greenlist before the fund can operate (the fund now pre-checksfnPausedfor its own selectors at create/commit time).minMTokenAmountForFirstDeposit— applies todepositRequesttoo).waivedFeeRestrictionfee waiver — worth requesting).