feat: add margin trading periphery - #563
Open
ccashwell wants to merge 168 commits into
Open
Conversation
Scaffolding for the margin-trading periphery suite (M0 of the implementation plan). - morpho-blue @ v1.0.0 (lib/morpho-blue): the lending integration. The adapter will reuse Morpho's own libraries (MarketParamsLib, MorphoBalancesLib, SharesMathLib, MathLib) rather than reimplementing share/accrual math. Only the interfaces (>=0.5.0) and libraries (^0.8.0) are imported — never Morpho.sol (0.8.19) — so everything compiles under this repo's 0.8.26. - solady @ v0.1.26 (lib/solady): LibClone for clones-with-immutable-args. The bundled OpenZeppelin is 5.0.2 whose Clones lacks immutable-args support (added in 5.1) and is shared with v4-core/permit2, so bumping it is avoided. - remappings.txt: add morpho-blue/ and solady/ entries. Verified: imported morpho-blue pragmas admit 0.8.26; morpho-blue's only nested dependency is forge-std (no transitive OZ clash); a throwaway smoke test importing MarketParamsLib/IMorpho/LibClone compiled and passed under 0.8.26.
…Registry)
M1.5 of the margin-trading plan — the custom value types that make illegal
states unrepresentable, built bottom-up and tested in isolation with no mocks
(type-driven-design). Consumed by the adapter, account, and router milestones.
src/types/:
- Direction.sol enum {Long, Short} — replaces a bool long/short flag so
flows branch exhaustively.
- LeverageX18.sol WAD leverage (1e18 = 1x); toLeverageX18 reverts below 1x,
so sub-1x leverage is unconstructable.
- Ltv.sol WAD LTV as a type distinct from amounts and leverage, so
health math cannot unit-confuse them.
- Market.sol the (collateral, debt) pair as a first-class type, plus
toSwapParams — the single choke point that reconciles a v4
pool's currencies with the market and derives zeroForOne
(folds invariant I-11 into one unavoidable runtime check).
- Owner.sol minimal ownership concern (read/write/onlyOwner) composed
by the adapter for governance gating.
- MarketRegistry.sol governed (collateral, debt) -> Morpho MarketParams routing
table; resolve() reverts MarketNotSupported when unset (no
silent default market).
toSwapParams keys on the explicit swap-input currency (open sells debt, close
sells collateral) rather than Direction: zeroForOne is fully determined by the
input currency and canonical pool ordering, and the input is what the flows
actually differ on. Direction lives in the entry-point params. Design doc note
to follow.
M1 of the margin-trading plan. The lending-protocol-agnostic boundary the router and account depend on, consuming the Market type. - src/interfaces/ILendingAdapter.sol: one singleton adapter per lending protocol, parameterized by the Market (collateral, debt) pair. Each encode function returns the call the MarginAccount performs as itself, so no delegated authorization is needed; onBehalf and receiver are owned and re-validated by the account, not trusted from adapter bytes. LTV reads are typed as Ltv. - test/mocks/MockLendingAdapter.sol: minimal configurable implementation so later milestones can compile and test against the interface. Interface and mock compile under 0.8.26.
M2 of the margin-trading plan. The per-user position container, deployed as a Solady clone-with-immutable-args whose owner and manager are baked into the clone bytecode (read via LibClone.argsOnClone), so there is no initializer and ownership is soulbound. The account is the borrower and supplier in the lending protocol, so it acts as itself and needs no delegated authorization. It owns the authority-bearing fields rather than trusting adapter-encoded bytes: - every privileged primitive is gated to the manager or owner (NotAuthorized); - onBehalf is always address(this); - every fund recipient is constrained to the manager or owner (ReceiverNotAllowed); - each adapter-encoded call is asserted to target the adapter's lending protocol and carry no value, then run as a regular call, never a delegatecall; - execute is an owner-only escape hatch so the owner can always close or recover without the manager. Repay returns the assets actually repaid as the account's debt-token balance decrease, which makes repay-all correct without reimplementing share math. Tests cover the access gate (including a fuzzed unauthorized caller), the recipient constraint, the onBehalf invariant, the target check, owner-only execute, repay-all, sweep, and a no-delegatecall proof (the call target's storage changes while the account's does not). 15 tests pass. Adds a MockLendingProtocol target and a forced-target option on MockLendingAdapter.
M3 of the margin-trading plan. Deploys per-user MarginAccount clones via Solady clone-with-immutable-args, baking (owner, manager) into the clone bytecode. - accountOf(owner, subId) predicts the CREATE2 address for any owner without deploying. - createAccount(owner, subId) is idempotent: it returns the existing account if already deployed, tolerating a lost lazy-deploy race. - The salt binds owner, manager, and subId. Binding the manager keeps addresses distinct across router versions and keeps accountOf collision-free; binding the owner neutralizes address squatting, since deploying at someone's predicted address bakes them in as the owner. 8 tests pass, including squatting neutralization, idempotent deploy, and determinism across owners, subIds, and managers.
M4 of the margin-trading plan. The first ILendingAdapter: a singleton over all curated Morpho Blue markets, modeled as a thin shell composing a governed (collateral, debt) routing table and an owner guard. All encode and read logic reuses morpho-blue's own libraries, so no Morpho math is reimplemented. - setMarket is owner-gated and requires the market to already exist on Morpho (idToMarketParams check), then registers the full MarketParams keyed by its token pair. Encode and read calls resolve the pair and revert MarketNotSupported for unrouted pairs. - Encodes map to Morpho calls with the account as onBehalf and empty data (so no Morpho callback fires); full repay burns the account's borrow shares via Morpho's shares path. lendingProtocol returns the Morpho singleton. - positionOf returns raw collateral plus interest-accrued debt via MorphoBalancesLib.expectedBorrowAssets; currentLtvWad combines accrued debt with the market oracle price (1e36 scale); maxLtvWad returns the market lltv. Unit tests (9) cover gating, the not-created and not-supported reverts, encode target/onBehalf/empty-data, and shares-based full repay, using a minimal MockMorpho. The accrual-dependent reads (positionOf debt, currentLtvWad) read live market state and will be validated by a mainnet fork test, which is pending a fork RPC and verified Morpho market addresses.
M5 of the margin-trading plan. Defines the margin action opcode space and the calldata decoders the router uses to dispatch margin actions. - MarginActions: opcodes 0x1c through 0x21 (supply, withdraw, borrow, repay, sweep, assert-health), extending the Actions space that ends at 0x1b. Opcodes below 0x1c fall through to the inherited V4Router handlers. There is no market/swap reconciliation opcode: that check lives in the single Market.toSwapParams choke point at plan-build time, so it cannot be skipped. - MarginCalldataDecoder: abi-decode based decoders for the action param blobs. abi decoding rather than hand-rolled calldata slicing is deliberate, since these params are not the hottest path and decode safety matters here. 6 tests: decoder round-trips (including a fuzz round-trip) and opcode contiguity and disjointness from the inherited Actions space.
M6 of the margin-trading plan. The router composes the v4 action machinery (V4Router, ReentrancyLock, Permit2Forwarder, Multicall_v4, NativeWrapper), implements _pay (Permit2 two-payer), msgSender (returns the locker, which is load-bearing for deriving the caller's account), and overrides _handleAction to dispatch the margin opcodes while falling through to the inherited swap, take, and settle handlers. Each leveraged position is built as a single flash-style swap inside one PoolManager unlock: borrow the debt, swap it into collateral, supply the collateral, then draw the debt to settle. The active account is held in transient storage, always derived from the authenticated caller via the factory, never from calldata. The router deploys and is the manager of its factory, so it can drive each account's lending primitives. - openPosition: pulls equity via Permit2, then assembles swap-out / take / supply / borrow / settle. - closePosition: buys the current debt, repays it, withdraws collateral, settles the swap, and returns residual collateral (realized PnL) to the caller. - addCollateral: pulls collateral and supplies, no swap or unlock needed. - The single Market.toSwapParams choke point validates the pool against the market and derives swap direction for every flow. V4Router._handleAction is made virtual (backward compatible, no behavior change) so the action set can be extended; all existing V4Router and PositionManager tests still pass. Unit tests (5) cover the factory/manager wiring, accountOf passthrough, and the pre-unlock guards (slippage-bound-required, deadline). The swap-coupled leverage flows run through a real PoolManager and are validated by the integration and fork suite (next), which also needs a fork RPC and verified Morpho markets.
M7 (part 1). End-to-end tests of the router's leverage flows against a real local PoolManager and a deep 1:1 pool, with a mock lending protocol standing in for Morpho. These validate that the flash-style plan assembly nets to zero. - open: equity plus a borrowed exact-output buy produces a collateral position of equity + bought, draws debt within the slippage bound, and leaves nothing loose in the account or router. - close: buys and repays the current debt, withdraws all collateral, settles the swap, and returns residual collateral (realized PnL) to the caller; debt and collateral both end at zero. MockLendingAdapter.positionOf now reflects the live mock-protocol state so the close and withdraw paths read real debt and collateral.
M7 (part 2). Forks mainnet and exercises MorphoLendingAdapter against the real Morpho Blue WETH/USDC market, validating the accrual-dependent reads the unit tests could not (positionOf accrued debt, currentLtvWad from the live oracle). The market and token addresses are verified on-chain in setUp (idToMarketParams must return the expected tokens) rather than trusted blindly. The test supplies 1 WETH, borrows 1000 USDC under the 0.86 LTV cap, checks the accrued position and LTV, and repays in full via the shares path. Skips when MAINNET_RPC_URL is unset.
Completes the user-facing position API. - increasePosition adds leverage to an existing position, sharing the lever-up plan with openPosition (open lazily creates the account; increase operates on the existing one). - decreasePosition partially delevers: it sells collateral to buy and repay a chosen amount of debt, leaving the position open and smaller, and asserts the resulting LTV against maxLtvAfter (zero skips the check). - The withdraw handler's OPEN_DELTA now resolves to the collateral the swap owes the pool (used by partial delever), symmetric with the borrow handler; close withdraws the explicit full collateral it reads before the unlock. - ASSERT_HEALTH is wired into decrease and treats a zero bound as skip. Integration tests cover increase (collateral and debt grow), decrease (both shrink, position stays open), and the resulting-LTV-too-high revert.
Validates that the router pulls the caller's equity into their account through a real Permit2 deployment (rather than the equity being pre-funded), then builds the leveraged position. Confirms the permit2.transferFrom path used by open, increase, and add-collateral.
Security review (High): closePosition repaid a stale, rounded-up asset amount of debt and then withdrew ALL collateral. Against real Morpho an asset-denominated repay converts to shares rounding down, leaving dust borrow shares; withdrawing all collateral with non-zero shares then fails Morpho's health check (INSUFFICIENT_COLLATERAL), so the close path reverted for any interest-bearing position. The local mock hid this (no accrual, asset-based), and close was never fork-tested. Fix: repay all by shares (type(uint256).max) so the borrow shares reach zero before the full-collateral withdrawal. The swap still buys exactly the read debt (expectedBorrowAssets), which equals what a full-share repay pulls in the same block, so the account holds exactly enough and nothing is left over. The fork test now warps a day to accrue interest, repays by shares, and withdraws all collateral, asserting both the debt and collateral reach zero and the collateral is returned. This reproduces and guards the close sequence against real Morpho.
…ral guard Addresses the security review's medium findings. - Adapter allowlist (Medium): the router now only accepts governance-allowlisted lending adapters on every flow (AdapterNotAllowed otherwise), enforcing the documented trust model. A hostile caller-supplied adapter could otherwise siphon the caller's own equity. Governance defaults to the deployer and can be handed off via transferGovernance; setAdapterAllowed is governance-gated. - Mandatory delever health (Medium): decreasePosition now requires a non-zero maxLtvAfter, so a delever cannot silently skip the resulting-LTV check and leave the position less healthy than intended. - addCollateral now reverts on a zero amount instead of deploying an account and reverting later. Tests: a non-allowlisted adapter reverts AdapterNotAllowed, only governance can set the allowlist, and governance defaults to the deployer; the integration suites allowlist their adapter in setUp.
… markets Addresses the security review's low findings. - MarginAccountFactory reverts ZeroAddress if constructed with a zero implementation or manager. - MorphoLendingAdapter.MarketSet now emits the oracle, irm, and lltv so offchain monitoring can vet the routed market's parameters, not just its token pair. - Documents that supported markets are standard ERC20s only (the adapter allowlist curates Morpho markets, which exclude fee-on-transfer and rebasing tokens), under which every router flow nets to zero with no residual. This is the resolution for the residual-sweep finding.
Adds PositionOpened, PositionIncreased, PositionClosed, PositionDecreased, and CollateralAdded events, each carrying the owner, account, market currencies, and the relevant amount, so offchain indexers can reconstruct the position lifecycle from the router rather than only from the factory, PoolManager, and Morpho. Validated with an expectEmit on the open flow.
openPosition, increasePosition, and addCollateral are now payable. When native ETH is sent, the router wraps it to WETH and credits the account as equity (the market collateral must be WETH, else NativeCollateralMismatch); otherwise the ERC20 Permit2 pull is used. This lets users lever the ETH they hold without wrapping it themselves first. Test covers addCollateral with native ETH (wrapped and supplied as WETH, no ETH left in the router) and the non-WETH-collateral revert. The wrap path is shared by the open and increase flows.
Instruments the margin suite with vm.snapshotGasLastCall, matching the repo's existing gas-snapshot convention, and commits the isolate-mode baseline: - MarginRouter open / close / increase / decrease / add-collateral (native) - MarginAccount borrow / repay - MarginAccountFactory createAccount (clone deploy) forge snapshot --check (and forge test --isolate) now track gas regressions for these contracts. No existing snapshots changed.
Reorders members to a single convention: state (using, constants, immutables, storage) -> errors -> events -> modifiers -> constructor -> open entrypoints -> privileged/admin entrypoints -> internal/private, with intuitive ordering inside each group. No behavior change (full suite and gas snapshots unchanged). - MarginRouter: modifier moved before the constructor; the user flows (open/increase/close/decrease/addCollateral) and public views grouped as open entrypoints; setAdapterAllowed/transferGovernance grouped as admin; the framework overrides and helpers moved to the internal/private section. - MorphoLendingAdapter: the ILendingAdapter view surface and owner() grouped as open entrypoints; setMarket/transferOwnership moved after them as admin. - MarginAccountFactory: error declared before the event. MarginAccount already followed the convention.
Documents every margin contract, interface, type, and library to the repo's NatSpec standard. - Contract/interface/library/type-file level: @title, @author, @notice, and @Custom:security-contact on the four deployable contracts. - Every external/public function has @notice plus @PARAM and @return (with units: WAD, token decimals, X36, bps); implementations use @inheritdoc with @dev for implementation-specific notes, keeping the canonical docs on the interfaces. - Internal and free functions documented; every struct field, error, and event parameter documented; the modifier and the MarginActions opcodes documented. Documentation only, no logic, signature, or ordering changes. forge build passes, the full suite (754 tests) is green, and gas snapshots are unchanged.
Convert MarginAccountFactory from a separately-deployed contract into an abstract mixin inherited by MarginRouter. The factory's clone-deployment and deterministic-addressing logic stays in its own file for cleanliness, but the router is now the manager baked into every account directly, removing the standalone factory deployment and an external-call hop on every open, increase, and add-collateral flow. - MarginAccountFactory: abstract, single accountImplementation_ ctor arg, manager fixed to address(this), accountOf/createAccount public virtual - MarginRouter: inherits the mixin, drops the factory immutable and the new MarginAccountFactory deployment, calls accountOf/createAccount directly, overrides accountOf for IMarginRouter + mixin - Tests updated to a concrete FactoryHarness; router asserts manager() and determinism in place of the removed factory() getter Gas: open/increase/decrease/close and native add-collateral all drop ~2.5-3.2k gas from removing the external factory call.
Prove the entire margin stack composes, not just each component in
isolation. On a mainnet fork the test drives one continuous lifecycle
through the real contracts:
MarginRouter unlock + flash accounting + delta resolution
-> V4Router swap through a real PoolManager
-> MorphoLendingAdapter encodes real Morpho Blue calls
-> MarginAccount executes them as itself on the live WETH/USDC market
-> real AdaptiveCurveIRM interest accrual
The lending leg, equity tokens (WETH/USDC), Permit2, and WETH9 are the
live mainnet contracts. The only locally deployed venue is the v4 pool,
seeded with deep liquidity at the live Morpho oracle price so the swap
leg and the lending leg agree on valuation; the PoolManager code itself
is the real v4-core contract.
Lifecycle exercised in sequence: open (equity via real Permit2) ->
addCollateral (native ETH, wrapped) -> increase (pure leverage) -> warp
one day (interest accrues from the real IRM) -> decrease (partial
delever, health-bounded) -> close (full unwind, residual PnL returned).
Each stage asserts the real position state through the adapter and that
no dust is left in the account or router.
Gated on MAINNET_RPC_URL; skips cleanly when unset, matching the
existing adapter fork test.
The single-borrower lifecycle is an unrealistically clean path: nobody else touches the market or the pool. Add a scenario with three independent positions contending for the same Morpho market and the same v4 pool at once, to prove isolation under concurrency. Two distinct owners, one of whom runs two sub-accounts, open interleaved (Permit2 and native-ETH equity, different sizes/leverage up to ~0.71 LTV). They accrue interest together for a week from the one shared IRM, one borrower levers up while the others are open, and they are closed in a different order than opened. Asserts the invariants a single borrower cannot exercise: (owner, subId) yields distinct accounts; another borrower's open, increase, or close never moves my collateral and only moves my debt within share-rounding; every owner receives their own residual PnL; the router never retains dust. An external lender supplies USDC up front so the real market can fund several borrowers at once, keeping the market and its accounting real.
The margin action opcodes started at 0x1c, packed immediately against the inherited v4-periphery Actions space (which ends at 0x1b). That left no room for new core actions (swap, settle, take, and similar) to be added without colliding with the margin range. Move the margin opcodes to start at 0x30 (0x30-0x35), reserving the 0x1c-0x2f block for future core actions. The dispatch boundary in _handleAction keys off the lowest margin opcode symbolically, so it tracks the new start automatically; only comments and the explicit opcode-value assertions in the decoder test needed updating. Gas is unchanged: the opcode is a single-byte dispatch key, not packed state.
ActionConstants.OPEN_DELTA is zero, which doubles as both the use-full-delta sentinel and a literal zero amount. Several flows fed a literal zero into the exact-output swap, which the PoolManager rejects with SwapAmountCannotBeZero, producing an opaque revert. - closePosition now resolves the position first and, when the debt is zero (funded only via addCollateral, repaid out of band, or fully liquidated), withdraws the collateral straight to the caller with no swap. The maxCollateralIn slippage bound only gates the swap path. - decreasePosition rejects a zero debtToRepay with SlippageBoundRequired. - _open (open/increase) rejects a zero collateralToBuy with SlippageBoundRequired.
closePosition returned the router's entire collateral balance to the caller, relying on the router holding zero between calls. Because the residual was balance-based rather than delta-based, any stray or donated balance in the collateral token would be swept to whoever closed a position next. Snapshot the router's collateral balance immediately before the unlock and return only the increase across it, so a pre-existing balance is left untouched. The zero-debt swap-free path is unaffected: it withdraws straight to the caller.
closePosition and decreasePosition required the adapter to be on the governance allowlist. If governance removed an adapter while users held positions backed by it, those users could no longer close or delever through the router. Drop the allowlist requirement from the exit flows. The allowlist now gates only exposure-increasing operations (open, increase, add collateral). Unwinding is safe regardless: these flows operate only on the caller's own account, and the MarginAccount itself constrains the call target, receiver, and value.
The margin swaps are all single-hop exact-output, and maxDebtIn (open/increase) and maxCollateralIn (close/decrease) are mandatory non-zero, so they fully bound the worst-case swap input. Strengthen the NatSpec to state that these absolute caps are the binding slippage protection and should be derived from a quote, and that minHopPriceX36 is an optional additional per-hop bound whose zero value disables only that secondary check, not the absolute cap.
The four cosmetic cleanups deferred from the OZ margin-trading audit, with no behavioral change: - N-04: rename the router constructor param to accountImplementation_ so it no longer shadows the inherited MarginAccountFactory immutable (and matches its sibling params). - N-06: drop the unused named returns from all eight MarginCalldataDecoder decoders, which returned an explicit abi.decode over them. - N-07: remove the dead Position/Currency imports from MorphoLendingAdapter and the Ltv.raw/Ltv.lte helpers never called in src/; the remaining raw() callers in scripts and tests migrate to the canonical Ltv.unwrap, and the Ltv type tests now exercise gt (the one comparison the router uses) directly. - N-08: storage-type mutators consistently return nothing; Owner.write, Owner.propose, and MarketRegistry.register returned a storage reference for chaining that every call site discarded.
The active-account guard preceded the UnsupportedAction fallback, so an undefined opcode in a plan with no bound account misreported as NoActiveAccount. The margin opcode space is contiguous [ACCOUNT_SUPPLY_COLLATERAL, ASSERT_ACCOUNT_BALANCE], so anything above it is rejected as UnsupportedAction before the guard; defined opcodes still require a preceding SET_ACCOUNT. Regressions cover the undefined opcode with and without a bound account, and a constants test pins the contiguous range the bound check assumes. Costs one comparison per account-scoped action (snapshots +22 to +88 gas per flow, regenerated isolated).
The Morpho boundary-repay fix turned this path from a loud underflow into a silent success: with no debt, the swap bought debtToRepay of debt tokens that no repay consumed (only a full close sweeps the surplus back), stranding them in the account while PositionDecreased reported a repay that never happened, costing the caller swap fees and slippage. Flagged by OZ in the L-01 review thread with a fork reproduction on both revisions. decreasePosition now reverts NoDebtToRepay when a partial decrease reads zero debt, restoring the pre-fix loud failure with a precise error. This also keeps a stale partial decrease against a re-pointed pair (whose reads see zero debt) a revert rather than a silent value leak. A debt-free position exits via the full close, which is unaffected.
The interface documents that a debt-free repay encodes empty callData the account skips, but only Morpho honored it: Aave v3 reverts NoDebtOfSelectedType and the Aave v4 Spoke reverts InvalidAmount on a debt-free repay, and Compound encoded a zero supply the Comet merely tolerates. Flagged by OZ in the L-01 review thread after probing all four venues. Both Aave encoders now return the empty skip signal when the account holds no debt in the market's reserve, and Compound returns it instead of a zero supply, so the generic repay-then-withdraw exit plan documented on encodeRepay runs against a debt-free position on every venue. Regressions assert the no-op shape per venue; the existing repay-shape tests seed live debt.
… delta The M-03 fix corrected _assertAccountBalance and IncompleteFill, but the three places a plan author actually reads still described the action as a delta check, promising an all-or-nothing fill guard where the action is a balance floor. Flagged by OZ in the M-03 review thread. MarginActions, decodeFillCheck, and the guide's action table now state the check is absolute (any pre-existing balance counts toward the minimum) and that the delta guarantee comes from how the caller computes minAmount. The guide additionally spells out that the curated technique is not replicable inside a plan (static calldata, no mid-plan snapshot, no delta opcode) and what to do instead: prefer ASSERT_FILL where output lands on the router (a true per-unlock delta), and for account-delivered ROUTE_SWAP output either run against an account holding none of the output currency or bake an offchain-observed balance into minAmount and accept the race.
The audit-fix commits left seven '(audit X-NN)' suffixes in src comments. The identifiers are engagement-specific: they mean nothing to a future reader and drift as issues are renumbered or the report is superseded. The explanatory text stays; only the tags go. Regression tests keep their finding references as provenance for why each test exists.
The best-effort event wrapping guards the describePosition call, but the success block computed two deltas from reads taken at different times: the increase event's position.debtAmount - debtBefore and the partial decrease event's collateralBefore - position.collateralAmount. Venues accept permissionless onBehalf repays and supplies, so one landing inside the transaction (from code in the route path, e.g. a hook on a route pool) can move the position against the operation's direction; the subtraction then panics inside the success block, which the catch does not cover, rolling back the completed mutation. Flagged by OZ in the L-05 review thread. Both deltas now saturate to zero, so the success block cannot revert; the resulting-state fields in the same event and the in-unlock PositionUpdated snapshots still carry the true position. Regressions stage the wrong-direction read on both flows and reproduced the panic before the fix.
…ovider is stored The M-01 fix corrected the header, dataProvider(), and the address registry, but the constructor's @PARAM provider NatSpec still said the Pool AND the data provider proxy addresses are resolved and stored immutably, contradicting the in-body comment four lines below and repeating the proxy misclassification that caused M-01 in the first place; the integration guide's adapter section carried the same sentence. Both now state the split correctly: the Pool (a stable proxy) is stored immutably, the data provider is a plain repointable address re-resolved on each use. A sweep for other survivals of the caching claim found none.
… pre-unlock read The NoDebtToRepay guard read the position before the unlock, but the repay leg consults the encoder inside it: a permissionless onBehalf repay landing in between (route-path code can execute one on every venue) cleared the debt, the now-uniform zero-debt no-op skipped the repay, and the silent partial decrease returned: bought debt tokens stranded in the account while the event reported a repay that never happened, with ASSERT_HEALTH passing trivially at the cleared LTV. Flagged by OZ in the L-01 review thread as a residual of the previous two fixes composing. The ACCOUNT_REPAY handler now records the account's measured repay in a transient slot (initialized to zero before the unlock, fail-closed) and the curated partial branch reverts NoDebtToRepay when it reads zero after the unlock, restoring full parity with the original at-repay-time failure. The full close is untouched (it tolerates the no-op and sweeps the over-bought debt back) and execute plans never read the slot, so their zero-repay no-op contract is unchanged. Regression stages the mid-unlock no-op with the health read mocked to the cleared-debt state.
…deEnableCollateral Closes the residuals OZ flagged on the N-15 and M-02 threads: - PositionAmountResolver no longer claims collateral is interest-accrued (deferring to ILendingAdapter.positionOf's per-venue semantics), and the accrual attribution now distinguishes v3 aToken rebasing from the v4 Spoke's accrued supplied assets (interface and guide). - The share-based full-repay framing is venue-neutral everywhere it survived: IMarginAccount.repay, the full-close plan comment, and the guide's full-close mechanics line; the remaining share-based mentions are the venue-correct Morpho and Comet statements. - The execute events note includes CollateralWithdrawn among the account-level events a plan emits. - IncompleteFill's requested/received params describe both throw sites (ASSERT_ACCOUNT_BALANCE and ASSERT_FILL). - The minHopPriceX36 fields are documented as struct-level NatSpec @PARAM tags on all four IV4Router param structs, so the X36 scale and output-per-input orientation reach generated docs. - The guide's Aave v4 bullet no longer describes the removed Spoke.multicall batching: it documents the encodeEnableCollateral hook both adapters use, and the adapter-surface sections (3.3 and the reference) list all five encoders with the note that an out-of-tree adapter must implement the hook (empty callData is the skip signal).
…olution Add _mapSwapAmount (default identity) to V4Router, applied to the amount read from swap params before the OPEN_DELTA sentinel is interpreted, so an inheriting router can resolve a route-level sentinel (a callback register populated by a prior command) into a concrete uint128 at execution time without reimplementing any private swap helper. Also make DeltaResolver._mapTakeAmount virtual for symmetry with _mapSettleAmount, so TAKE amounts are equally overridable. Default behavior is unchanged (identity mapping). Cost is +15 bytes of bytecode and +15 gas per swap (a single JUMP into the hook). Router and margin swap tests pass; snapshots regenerated in isolate mode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…merge The merged-in DeltaResolver change (virtual _take) shifts the execute flows by a few gas; regenerated in isolated mode, matching the committed convention.
ccashwell
marked this pull request as ready for review
August 24, 2026 23:59
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 662d3bace9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The stored debtPrincipal lags accrued interest, so a partial liquidation repaying more than the stored value clamped the remainder to zero, marked the position LIQUIDATED, and deleted its active pointer while accrued debt was still outstanding. Flagged by Codex review on the PR. recordLiquidation now reads the venue's remaining debt at the liquidation block (Morpho borrow shares converted with the virtual-offset share math; Aave v3 variable-debt token balance via the protocol data provider) and derives both the refreshed principal and the terminal decision from it, falling back to the stored-principal arithmetic only when the venue is not readable.
The Swap stream filtered PoolManager events on sender == MarginRouter, but since position swaps route through a caller-named Universal Router (any address, per call), the UR is the swap caller and the filter dropped every curated-route swap, leaving openPoolId and action poolId null. Flagged by Codex review on the PR. The sender-filtered stream is gone; the router handlers (curated events and the PositionUpdated snapshot, so execute-plan swaps are kept too) fetch the margin transaction's receipt via the cached client action and record the PoolManager Swap logs it contains, which attributes exactly the swaps sharing a transaction with a margin event on every execution path. Verified against live mainnet data: previously-null openPoolId now resolves.
PositionDecreased.debtRepaid is the caller's requested amount, which the venue clamps when it exceeds the live debt (per the events reference), so recording it as the action's debt delta could overstate the reduction beyond the position's entire prior debt and distort the computed execution price. Flagged by Codex review on the PR. drainFlows now aggregates the measured repay assets from the same-tx venue flows, and the decrease handler prefers that for the action's debtDelta and priceX18. Aave v4 and Compound stage no flow layer yet and fall back to the requested amount; the position row itself already stored the event's measured resulting totals.
The 2026-08-26 broadcast (blocks 25842465-25842483, commit 0df9a61) redeployed the full suite with the OpenZeppelin fix set. New addresses, verified onchain (code, governance, adapter allowlist, canonical markets): - MarginRouter 0x0000000000F57fCd0d5a78a19907240F1169EDEC - MarginAccount impl 0xdDD0967e90bCBc2D1F026b3977bb4dE39133b109 - Morpho adapter 0x766C34DcFBA565a1b72ce83ECD96712376Ca1f3D - Aave v3 adapter 0x7E1A543Bd8ed2F16D61DA4b6bC2eC5d240D098aC - Aave v4 adapter 0xAb3C2661c810295Db32125942f04b92c61fAE2Eb - Compound adapter 0x77598B845d0200fc707bD32A8Ad6DCF85C995e0d Updates the guide's registry (with a superseded-deployments note), the indexer registry and startBlock (25842465; re-synced from scratch and verified: all six canonical markets and four adapters indexed), the indexer README table (which also still carried pre-08-12 adapter addresses and the removed sender-filter claim), and the ops script defaults.
Replays felipe/margin-trading-poc-v2's indexer work onto margin-trading, which since the fork gained the post-audit redeploy registry and three indexer fixes of its own. The original port was a merge commit; this is its resolved diff, re-resolved against the newer base. Resolutions that were not mechanical: - Swap sourcing: margin-trading replaced the sender-filtered PoolManagerSwaps source with recordTxSwaps, which parses the margin tx's own receipt (the swap caller is whatever Universal Router the route named, so a sender filter cannot be written). That supersedes our allowlist approach, so PoolManagerSwaps stays deleted and pools.ts keeps only Initialize. recordTxSwaps moved to src/swaps.ts and is now ALSO called by the flow layer: the router only records from its own handlers, which fire after each venue event within an execute plan and never at all for an owner escape-hatch operation, so a flow-derived price would otherwise read an empty swap table. - Liquidation terminal state: both branches independently replaced the stored-principal arithmetic with a venue read. Merged to the stronger form — margin-trading's semantics (the read refreshes debtPrincipal, with the arithmetic as fallback) over our read layer (canonical Pool getters, cached, block-pinned, plus the Aave v4 leg), and Morpho's borrowShares now convert via toAssetsUp because the value feeds debtPrincipal rather than a zero test. The read soft-fails to null instead of throwing, so an unreadable venue degrades to the arithmetic rather than halting a liquidation-storm backfill. Consequently aaveV3DataProvider / aaveDataProviderAbi are dropped: the Pool's own getReserveVariableDebtToken serves that lookup and needs no second registry address (both getters verified against live mainnet). - addresses.ts takes the post-audit suite wholesale (router, four adapters, startBlock 25842465) plus our aaveV4Spoke and aaveOracle entries. - drainFlows keeps both sides: margin-trading's measured repaidAssets (used for the decrease's priceX18 and debtDelta) and our DEFICIT skip. Tests: swaps are staged into a stubbed receipt (harness.onReceiptLogs + scenario.stageSwap encoding a real log) rather than dispatched, since there is no Swap handler to dispatch to. stubChainDebt now also stubs Morpho's market read — without it the Morpho cases silently fell through to the arithmetic fallback and passed for the wrong reason. Divergence cases (c)/(d) updated to the merged semantics, and three cases added: shares->assets conversion, its round-up floor, and the unreadable-venue fallback. 50 pass, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…'s own events Restore the MarginAccount clone observation layer carved out of the previous commit: a tx-scoped map keyed on the router's AccountCreated factory, with CollateralSupplied=collateral and Borrowed=debt registered-pairs-only, whose staged rows replay through applyStagedFlows. Its remaining producer is the owner escape hatch (MarginAccount.execute emits only Executed, no snapshot). Restores the 5 aavePairResolution cases and the harness's side-effect registration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…replay in numeric log-index order
drainFlows claimed every staged (null-pair) lendingEvent row unconditionally, so
one tx touching two markets that share a reserve would drain both markets' flows
into whichever pair resolved first. Claim a null-pair row only when the single
reserve it named belongs to THIS pair in the role its kind implies (the belongs
predicate applyStagedFlows already uses); refuse reserve-less rows (fail-closed).
consumeSwaps and txLendingEvents ordered by the row id, which is
`${txHash}-${logIndex}` — a lexical sort puts logIndex 12 before 8. Order by the
parsed numeric log index (new logIndexOf helper) so same-tx flows and swaps
replay in emission order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PositionIncreased and CollateralAdded read the activePosition pointer raw, so a curated open (or add) on a pair whose pointed epoch was already terminated by an execute-composed close snapshot would fold into the dead epoch. Route both through findActivePosition, which treats a non-OPEN pointed epoch as absent: the open starts a fresh epoch and overwrites the pointer, the add fails closed. PositionDecreased keeps the raw read (a same-tx curated close may still enrich a snapshot-closed epoch). The terminal PositionUpdated snapshot now also records closeTxHash/closedAt (economics stay null) and leaves the pointer in place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dresses The README adapter table listed Aave v3 and v4 on one row and omitted Compound v3 entirely; split them and note that Compound's Comet truth layer is deferred. Record the full-close dust sweep as a v1 limitation: the sweep returns residual dust in the debt token via a `Swept` event that is not indexed, so realized PnL can drift by the dust-sized sizeFullClose buffer. QUERIES.md used real clone addresses derived under the retired router; replaced with a neutral placeholder so nobody copies a dead address into a query. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every position action stamps a venue-oracle mark, and the read fell through to a blocking readMarkAtBlock that throws when the oracle is unavailable or reverts — halting the whole indexer. That is an ops risk on the curated path and a serious one in the flow layer: synthesizeAdjust and recordLiquidation read the oracle too, and oracle staleness clusters during a liquidation storm, exactly when the indexer must keep up. readMarkAtBlockSoft wraps the read as warn + null, and every per-action path goes through it. The mark columns are nullable and the other economics are computed independently, so an unavailable oracle yields honest null PnL for that action rather than a halt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…flow-layer swaps Two defects the rebase surfaced, neither of which either branch handled. The audited router saturates PositionIncreased.debtDrawn to zero (0bc2197) so its best-effort event block cannot revert: a permissionless onBehalf repay landing inside the transaction can leave the debt below its pre-increase level. It is never legitimately zero — _increase rejects collateralToBuy == 0 and borrows OPEN_DELTA, exactly the swap cost — so zero means the borrow cost is UNKNOWN, not free. An unknown fill must contribute nothing to the cost basis, not merely carry a null price on its action row. On INCREASE, totalCollateralBought grew by a real fill while totalDebtDrawn stood still, halving avgEntryPriceX18 and keeping it there, since the inflated denominator is permanent. On OPEN, the null price looked like the fix working, but the epoch was seeded with a real totalCollateralBought against a zero cost, so the next ordinary increase averaged against a denominator nothing paid for. Both branches now skip the basis entirely; the authoritative fields are untouched, since collateralAmount and debtPrincipal come from the event's own totals rather than the running sums. The decrease side already degraded safely: a saturated collateralWithdrawn zeroes collateralSold, which the existing guard maps to null. Separately, txSwaps ordered by swapEvent.id, which is `${txHash}-${logIndex}` and sorts lexically, so log index 10 preceded 9. swapEconomics attributes the pool from the first row, so the flow layer could name the wrong pool. This is the same bug 259655a7 fixed in consumeSwaps and txLendingEvents; the flow layer's copy was missed. The curated path was never affected — it reads consumeSwaps. Each case is pinned by a test that fails without its fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A minimality pass over the nine-commit indexer series before it lands as a
production stack. Net −664 lines; the delta against margin-trading goes from
+6757/−181 across 34 files to +6160/−248 across 30.
Dead code, all verified unreachable rather than merely unused:
- FlowEvent.txTo: six writers, no reader. It was the input to a tx.to close gate
that was deliberately dropped; its own comment said "diagnostics only".
- swapEconomics().attributable: hard-coded true, so `attributable && bought > 0n`
was `bought > 0n`, guarded by fifteen lines documenting a tripwire that cannot
trip.
- reverseAndSupersedeAdjust's collateral/debt params: destructured, never read,
passed by all four callers.
- The two Aave v3 token caches and their _clear*ForTests exports — test-only
exports in shipped code, saving one eth_call on two rare paths.
- aaveV4SpokeAbi.SetUsingAsCollateral, deployments.chainId, deployments.compoundComet.
- The raw-candidates arm of CollateralAdded's pair resolution. addresses.ts already
called it pre-startBlock-only, and MarginRouter emits PositionUpdated inside the
same try as CollateralAdded, so justUpdated can never be empty. It was also a
hazard: if reached it attributes the top-up to whatever single OPEN position
shares the collateral token, which may be the wrong pair.
The deficit layer is removed entirely — recordDeficit, resolveDeficitPosition,
both handlers, the DEFICIT enum value, position.badDebt and the v4 hub ABI. It
existed to populate badDebt, which the backend selects and no business logic
reads, and the v4 leg cost two chain reads per event. Morpho keeps its own
bad-debt figure from Liquidate.badDebtAssets, which is free.
Duplication: reserveAssetForId re-ran resolvePairByReserveId's exact query, so
the latter now returns { pair, reserveAsset } from the candidates it already
fetched. The `belongs` predicate was copy-pasted into router.ts from
lendingFlows.ts — two copies of the rule deciding which position a flow lands
on — and is now one reserveBelongsTo in helpers. `event: any` is typed.
Comments: twenty-five corrections. The largest were factually wrong rather than
merely stale — a block in detectRouterlessClose that claimed to derive close
economics directly contradicted the next paragraph and the code; four comments
said Aave v4 has no flow-truth layer when aave.ts implements one; two blamed a
"pre-upgrade deployment" that startBlock excludes; marginAccounts blamed
execute() for a gap only owner-driven clone calls create; and a docstring for
synthesizeAdjust sat above hasLiquidationInTx. Also dropped plan-and-ticket
archaeology from the tests (IDX-3/4/8, "D0 gate", "plan cases a-f", a commit SHA)
and the letter prefixes on test titles.
Tests: 68 -> 61. mergePort.test.ts grouped by merge seam rather than behavior;
both its cases moved to adjust.test.ts and drainFlows.test.ts, the second
re-keyed off COMPOUND_V3, which no handler can stage. harness.test.ts tested the
harness; its real-receipt decode moved to liquidations.test.ts and its synthetic
liquidation was a strict subset of case (a). One of the two 18KB receipt fixtures
was redundant with the other.
Kept deliberately, each having survived a removal proposal: marginAccounts.ts and
the MarginAccounts factory (MarginAccount._authCaller admits owner as well as
manager, so owner-driven supplyCollateral/borrow emits clone events with no
router event at all, and USDC is the debt token of nine of the twelve launch
markets); applyStagedFlows' skip block, which is the type narrowing from the
schema enums to FlowEvent's unions; the equityBase null guard, required because
the column must stay nullable; readVenueBalances and readVenueDebt, which differ
in units and error semantics; and detectRouterlessClose.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`drainFlows` can only name a venue that staged a lendingEvent row. Compound has no flow-truth layer — Comet is not an indexed contract — so a Compound transaction stages nothing and both epoch-opening sites wrote UNKNOWN, which the backend cannot join to a market. Venue was written once at open and nothing could correct it. The MarginAccount clone already reports the adapter of every action it performs, as an indexed topic on CollateralSupplied and Borrowed, and those events are already indexed on a factory source covering every clone — the handler was reading the currency and dropping the adapter. markets.ts already maps adapter to venue. So this needs no new contract, no new ABI concept and no chain read. Ordering makes it safe: MarginRouter calls the account and only then emits its snapshot (_emitPosition), so the sighting is always in hand before the handler that needs it runs. Ponder matches a factory child's logs in the same block as its AccountCreated, so a first-ever open is covered. Sightings are keyed by (txHash, account, currency, role) rather than by account. One transaction can drive one clone through two adapters for two pairs, and an account-wide key would hand one pair's venue to the other. Within a pair the latest sighting wins, which is exact because each snapshot immediately follows the account call that caused it. Two distinct adapter ADDRESSES for one pair means the transaction touched two venues and neither is attributable — compared by address rather than by mapped venue, so an adapter the registry does not know still forces the ambiguous answer instead of leaving one venue standing. CollateralWithdrawn and Repaid are indexed too, for their adapter only. Their amounts are unusable — CollateralWithdrawn logs zero on every venue but Aave v4, per its own NatSpec — and the venue events remain the single writer of balances. Without them a transaction that only exits has no sighting for either currency. The hint is always last in precedence: real flow evidence first, then the epoch's own venue, then the adapter. `upgradeVenue` repairs a live epoch on a later transaction but only ever turns UNKNOWN into a known venue, never rewrites one venue as another, so a row cannot flap. It is skipped on terminal updates: MarginRouter's debt-free full close can emit its snapshot with no adapter-bearing clone event at all, so the only hint in scope there may belong to another leg. Adds a test-only reset for the module-scoped sighting maps, since they are transaction-scoped rather than test-scoped and outlive the harness's table wipe. Five of the seven new cases fail without this change. The other two are guards that assert UNKNOWN — the same answer HEAD gives — and exist to discriminate against a venue-keyed latch and against adopting a foreign leg's hint on a terminal snapshot. Compound liquidation detection and per-flow history are still missing; they need the Comet truth layer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Margin Trading Periphery
Adds a periphery that opens leveraged spot positions in a single transaction by composing a swap with a borrow/supply against an external lending protocol. Four venues are integrated behind one router: Morpho Blue, Aave v3, Aave v4, and Compound v3; the caller selects the venue per call by passing the matching adapter. The whole leverage loop runs inside one
PoolManagerunlock using v4 flash accounting, so it nets to zero with no intermediate capital and no router-held residual.Full protocol and integration guide:
docs/margin-trading.md.What a position is
Borrow the debt token, swap it into the collateral token (exact-output), supply the collateral (equity plus the bought amount), and draw the debt back to settle the swap. The result is a position long the collateral and short the debt, at a caller-chosen leverage bounded by the market's max LTV. Direction is set entirely by the
(collateral, debt)pairing; there is no separate flag. Live markets today: long ETH (WETH/USDC on Morpho or Aave), short ETH (USDC/WETH on Morpho, Aave v3, or Aave v4), and long UNI (UNI/USDC on Compound v3).Routed swaps
Position swaps route through the Universal Router (the
ROUTE_SWAPaction), so a route can source liquidity across v2, v3, and v4 rather than a single v4 pool. The UR self-settles its own swap, so the router wraps the call in a flash-take envelope: it flash-takes the input from the PoolManager, funds the UR through a Permit2 allowance scoped to exactly the input cap (and zeroed after the swap, so no spendable allowance outlives the call), runs the caller-built route, and settles only this call's unspent take. The Universal Router is a per-call parameter (inIncreaseParams/DecreaseParams, or the firstROUTE_SWAPfield in a plan), so callers can adopt newer UR deployments without a router redeploy.The router does not trust the route's internals. Fills are all-or-nothing: the curated flows set the
ASSERT_ACCOUNT_BALANCEthreshold to the account's pre-unlock balance plus the amount the route must deliver, so the absolute check enforces the swap delta and a pre-existing or donated balance cannot mask a short fill (IncompleteFill).Architecture
MarginRouterV4Router,ReentrancyLock,Permit2Forwarder,Multicall_v4,NativeWrapper, and the account factory. Is the trusted manager of every account.MarginAccountonBehalf == account), so it acts as itself with no delegated authorization. Owner and manager are baked into bytecode (soulbound: no initializer, no transfer). Borrowed and withdrawn funds are delivered to the account and forwarded to the validated receiver (measure-and-forward), so venues that lack a receiver argument are handled uniformly.MarginAccountFactoryMorphoLendingAdapter(collateral, debt) -> MarketParamsrouting table (Morpho Blue).AaveLendingAdapter(collateral, debt)allowlist; the Pool (a stable proxy) is stored immutably, while the protocol data provider is re-resolved from the addresses provider on each use because Aave can repoint it.AaveV4LendingAdapter(collateral, debt) -> (collateralReserveId, debtReserveId)routes, validated onchain at registration; reads premium-inclusive debt.CompoundV3LendingAdapterwithdraw/supply. The base price feed is read live (it is governance-mutable behind the Comet proxy).ILendingAdapter/IMarginAccount/IMarginRouterMarket(the pair),Ltv(WAD ratio),MarketRegistry,MarketAllowlist,Owner,PositionData.Adapters are encoders: each returns the
(target, value, callData)the account executes and holds no funds. The encode surface isencodeSupplyCollateral,encodeEnableCollateral(run by the account after every supply, for venues needing an explicit collateral enable; empty calldata is the skip signal),encodeWithdrawCollateral,encodeBorrow, andencodeRepay.Entry points:
increasePosition(opens or adds leverage; payable, equity via Permit2 or native ETH),decreasePosition(partial delever or full close via thetype(uint256).maxsentinel),addCollateral, andexecute, the advanced entrypoint that runs an arbitrary caller-composed plan of the same actions (see the guide's section 4.1 for the composition contract). PluscreateAccount/accountOfand the governance surface.Key design decisions
executeescape hatch on the account lets the owner always act directly on the lending protocol, so funds can never be trapped by router or adapter configuration.ILendingAdapter. Four venues ship today; the same audited router/account served each new venue unchanged.maxDebtIn/maxCollateralIn) are the binding protection;minHopPriceX36is an optional per-hop bound priced on the swap's realized output; fills are all-or-nothing via the delta-threshold fill check. A partial decrease requires live debt and gates on the measured repay carried out of the unlock, so a mid-transaction external repay cannot turn it into a silent no-op (NoDebtToRepay).PositionUpdatedresulting-state snapshot; the curated flows add richer delta events. All event-only reads are best-effort: an oracle revert or a mid-transaction state change can skip or zero an event field but can never roll back a completed mutation.0x30range (0x1c-0x2fstays reserved for future core v4 actions), and undefined opcodes revertUnsupportedActionregardless of plan state.Indexer
indexer/ships a Ponder app that reconstructs positions, actions, and PnL from the router's event surface, with a venue truth layer (Morpho and Aave v3 flows and liquidations) for activity the router cannot see (escape-hatch operations, liquidations). Pool attribution parses each margin transaction's receipt for v4Swaplogs, so it is correct for any Universal Router the route names; liquidation terminality is decided from the venue's live debt, not stored state. GraphQL and SQL-over-HTTP included.Security review and audit
An internal 8-agent adversarial review of the router/account core preceded the external engagement; its findings (opaque zero-amount reverts, balance-based residuals, exit gating, ownership hardening, realized-output price guards, all-or-nothing fills) were fixed in this branch.
OpenZeppelin audited the full margin suite (Jul 28 - Aug 12, 2026): 33 findings, no High or Critical. 3 Medium (two reclassified to Low by the auditors after review), 14 Low, 16 Notes. Every finding is either fixed in this branch with regression tests or acknowledged with documented reasoning; the report is committed at
audits/OpenZeppelin_audit_margin_trading.pdf. Highlights of the fix set:encodeEnableCollateralpost-supply hook on all venues; immune to dust-aToken griefingPositionUpdatedon every path, documented per-venue event field semanticsA follow-up Codex review of the indexer surfaced three findings (swap-sender filtering, liquidation terminality, requested-vs-measured repay deltas), all fixed and verified against live mainnet data.
Testing
Unit and fuzz suites for all four adapters, the account, the factory, the value types, and the router; margin integration tests against a real local PoolManager (Permit2 and native-ETH equity, short fills, event-read failure modes, mid-transaction interference); a stateful invariant suite for router/account accounting; and mainnet-fork lifecycle tests per venue (Morpho e2e and multi-borrower, Aave v3, Aave v4, Compound v3) plus hedging (cross-venue, same-venue, shared-account via
subId),executeplans, and routed swaps. Gas snapshots are committed in isolate mode.Deployment
The suite is live on mainnet (2026-08-12 redeploy):
MarginRouterat0x000000000075e82F7B7DdC5DD1B4984b560eF5D4(mined vanity, CREATE2), with the four adapters allowlisted and the canonical markets registered. The full address registry, with provenance, is in the guide's section 10 andindexer/addresses.ts. Governance is currently the deploy key and should be handed to the timelock or a multisig via the two-step transfer before broader rollout.New dependencies
Adds the
morpho-blueandsoladysubmodules. Aave v3, Aave v4, and Compound v3 are integrated via minimal vendored interfaces undersrc/interfaces/external/(no new submodules); the Universal Router dependency was already vendored.