Skip to content

feat: add MidasFund for Midas mToken integration#201

Open
maximebrugel wants to merge 6 commits into
v1.3.0from
midas-fund
Open

feat: add MidasFund for Midas mToken integration#201
maximebrugel wants to merge 6 commits into
v1.3.0from
midas-fund

Conversation

@maximebrugel

@maximebrugel maximebrugel commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds MidasFund, an IFund adapter for the Midas protocol (targeting mGLOBAL/mGLO, generic over any mToken), plus its beacon-proxy factory, integration interfaces, and a full test suite.

Design

  • Asymmetric settlement — deposits and redeems settle through different Midas surfaces:
    • Deposits: async via depositRequest (no holdback)commit() transfers the payment token to Midas and records the mint request id (exposed via activeRequestId() and the requestId param on OrderCommitted). The order stays PROCESSING until 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. via withdrawToken).
    • Redeems: instant via redeemInstant with 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 to PROCESSING (repeatable as funds arrive), so the instant proceeds are never locked behind the holdback. An account with HOLDBACK_ROLE (or the owner) confirms receipt — or deliberately waives it as the write-off procedure — via confirmHoldback(orderId), which only flips the holdback flag and never changes the lifecycle state. Once confirmed, state() reports UNLOCKING even at zero balance and the next unlock() is terminal: it sweeps any remainder (possibly zero — a zero-amount OrderUnlocked with no transfer) and ends the order. unlock() therefore remains the only successful-completion transition to ENDED, so the Facility always auto-detaches the order on the ENDED return. confirmHoldback reverts (HoldbackNotPending) for deposit orders.
  • RolesOPERATOR_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).
  • Multiple concurrent settlements — one live order per fund (house invariant); deploy additional instances via MidasFundFactory, all sharing the same WrappedAsset (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.
  • Generic over mTokens — the mToken is derived from the deposit vault at initialize and 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.
  • Vault switching with safeguardssetDepositVault(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.
  • Fn-pause preflightcreate(), 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.
  • Recovery model — for off-band refunds (rejected deposit requests, or Midas returning the committed input after an incident), state() auto-reports RECOVERING on a CANCELED mint request once the refund covers the effective input; the operator can also flag recovering() manually, with resolve() / cancelRecovering() escape hatches (USCCFund model). Recovery is deposit-only: a committed redeem settled irreversibly and completes forward via partial/terminal unlocks + confirmHoldback.
  • Create-time slippage guard (5% max deviation) priced against the direction-specific Midas data feeds (mint and redemption feeds are intentionally asymmetric, ±7%). For deposits this is a sanity bound only: the actual mint amount is set by the NAV rate at which the Midas admin approves the request. Resolved amounts (resolve()) only affect deposit thresholds — redeem claimability tracks the actual balance (the minimum output is already enforced on-chain by redeemInstant at commit).

Tests

  • 140 unit tests + 11 factory tests + 10 fuzz tests + 7 invariants (256 runs × depth 64) with full Midas mocks, including invariants that a PROCESSING redeem reports UNLOCKING exactly 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 still PENDING (regardless of token balances)
  • 8 mainnet fork tests against the live mGLOBAL vaults (block 25,472,600): request-deposit approve lifecycle (real depositRequest + admin approveRequest, 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, totalAssets
  • A Facility regression test pins the zero-amount terminal-unlock contract: FacilityFunds.unlock() accepts (ENDED, 0) — zero tokens received, no intent-accounting change, and the order + fund binding cleared
  • Full repo suite: 1,885 passed, 0 failed

Operational notes for launch

  • Both the fund instance and the wmGLOBAL WrappedAsset must hold M_GLOBAL_GREENLISTED_ROLE (mGLOBAL is mTokenPermissioned — every transfer checks both parties).
  • Deposit liveness depends on the Midas admin approving mint requests — there is no on-chain claim or deadline; until approval the instance stays occupied. Rejections are refunded off-band only (Midas withdrawToken to the fund contract address).
  • HOLDBACK_ROLE is a liveness dependency for every redeem's completion, not its proceeds: the instant settlement is claimable immediately via partial unlock(), but the order — and thus the fund instance — only ends after a confirmHoldback transaction 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.
  • As of the fork block, Midas has depositRequest fn-paused on mainnet; only instant redemption is live. Midas must unpause + greenlist before the fund can operate (the fund now pre-checks fnPaused for its own selectors at create/commit time).
  • First deposit per fund instance must mint ≥ 114,000 mGLOBAL (minMTokenAmountForFirstDeposit — applies to depositRequest too).
  • Redemption instant fee is currently 0.5% (per-vault config; Midas can zero it for our address via their waivedFeeRestriction fee waiver — worth requesting).

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).
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c3b4ed44-3d77-40fd-9d78-b47aad714d8b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch midas-fund

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

❤️ Share

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

Comment thread src/funds/midas/MidasFund.sol
Comment thread src/funds/midas/MidasFund.sol
Comment thread src/funds/midas/MidasFund.sol
Comment thread src/funds/midas/MidasFund.sol
- 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
@maxencerb

maxencerb commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Solidity lines of code (production src/ only)

Correction to my earlier comment: those figures included tests. Restricted to production Solidity under src/, measured against the merge-base with the base branch funds-batch-2 (96a18e3):

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
@maximebrugel maximebrugel changed the title feat: add MidasFund for Midas mToken integration (mGLOBAL) feat: add MidasFund for Midas mToken integration Jul 9, 2026
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
@maximebrugel maximebrugel changed the base branch from funds-batch-2 to v1.3.0 July 13, 2026 07:11

@maxencerb maxencerb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 IFund spec, the sibling USCCFund, 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, or onlyOwner.
  • 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 (RECOVERING state() branch reads only the asset balance, :659-661) until cancelRecovering(). 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 Midas instantFee/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 exceeds MAX_OUTPUT_DEVIATION, every redeem would DoS at commit — worth documenting order.output should be net of fees, or capping a newly-set vault fee < 5%.
  • renounceOwnership() foot-gun — a redeem finalizes only via confirmHoldback (HOLDBACK_ROLE or owner). Renouncing before granting HOLDBACK_ROLE could leave open redeems unable to reach ENDED. Document role-provisioning order, or guard renounceOwnership.

📋 Test-coverage gaps

  • Mocks stub minAmount / instantDailyLimit / minMTokenAmountForFirstDeposit as no-ops → no test exercises Midas limit gating. (Real Midas depositRequest validates 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. MidasFundHandler gates act_payHoldback/act_refund on states impossible post-ENDED, so invariant_EndedRedeemLeavesNoAssetBalance trivially holds and gives false assurance for exactly the strand/misattribution path in the Low finding. Add an unconditional/post-ENDED donate action + an assertion that consciously pins the 3.3.1 acceptance.
  • requestId==0 never exercised (mock counter starts at 1). No reachable strand; a require(requestId != 0) after depositRequest is cheap defense.

3. Checked and confirmed correct (examined, not a bug)

  • IFund state-machine conformance — all transitions legal; ENDED permanence via endedOrders + OrderAlreadyExists; the zero-amount terminal unlock (ENDED, 0) is gated behind confirmHoldback and pinned by the FacilityFunds regression test; redeem partial-unlock → PROCESSING and deposit PROCESSING→RECOVERING on CANCELED are spec-legal.
  • Midas integration correctnessmintRequests / tokensConfig tuple layouts and the RequestStatus enum are authoritatively validated by the fork tests against the real mainnet mGLOBAL vaults (they decode field 3 = status, field 6 = tokenOutRate fed into approveRequest, and assert deposit/redeem math within 0.1% — a wrong offset would blow those assertions). Base-18↔native conversion, fee-inclusive redeemInstant approval, and both Aave/Swapper redemption variants are fork-tested.
  • Reentrancy — fund has no guard but is DEPOSITOR-only (the nonReentrant Facility); 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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants