Skip to content

feat: add margin trading periphery - #563

Open
ccashwell wants to merge 168 commits into
mainfrom
margin-trading
Open

feat: add margin trading periphery#563
ccashwell wants to merge 168 commits into
mainfrom
margin-trading

Conversation

@ccashwell

@ccashwell ccashwell commented Jun 15, 2026

Copy link
Copy Markdown
Member

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 PoolManager unlock 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_SWAP action), 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 (in IncreaseParams/DecreaseParams, or the first ROUTE_SWAP field 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_BALANCE threshold 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

Contract Role
MarginRouter Entry point and orchestrator. Builds each flow as a v4 action plan run inside one unlock. Inherits V4Router, ReentrancyLock, Permit2Forwarder, Multicall_v4, NativeWrapper, and the account factory. Is the trusted manager of every account.
MarginAccount Per-user position container, a Solady clone-with-immutable-args. It is the lending counterparty (onBehalf == 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.
MarginAccountFactory Deterministic, idempotent, permissionless account deployment (the owner is salt-bound, so a third-party deploy cannot change who owns an account). An abstract mixin inherited by the router, so the router is the manager of every account it deploys.
MorphoLendingAdapter Singleton encoder over a governed (collateral, debt) -> MarketParams routing table (Morpho Blue).
AaveLendingAdapter Singleton encoder over the Aave v3 Pool. Governed (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 Singleton encoder over a single Aave v4 Spoke (hub-and-spoke). Governed (collateral, debt) -> (collateralReserveId, debtReserveId) routes, validated onchain at registration; reads premium-inclusive debt.
CompoundV3LendingAdapter Singleton encoder over a single Compound v3 Comet. Single-base: the Comet's base token is the only borrowable debt; borrow/repay map onto base withdraw/supply. The base price feed is read live (it is governance-mutable behind the Comet proxy).
ILendingAdapter / IMarginAccount / IMarginRouter Public surfaces; new lending venues are added behind new adapters with no router or account changes.
Value types Market (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 is encodeSupplyCollateral, encodeEnableCollateral (run by the account after every supply, for venues needing an explicit collateral enable; empty calldata is the skip signal), encodeWithdrawCollateral, encodeBorrow, and encodeRepay.

Entry points: increasePosition (opens or adds leverage; payable, equity via Permit2 or native ETH), decreasePosition (partial delever or full close via the type(uint256).max sentinel), addCollateral, and execute, 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). Plus createAccount/accountOf and the governance surface.

Key design decisions

  • Soulbound, self-custodial accounts. Owner and manager are immutable; only the manager (router) or owner can move funds, and only to the manager or owner. An owner-only execute escape 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.
  • Venue-agnostic via adapters. The router and account speak only ILendingAdapter. Four venues ship today; the same audited router/account served each new venue unchanged.
  • Uniform venue semantics at the edges. Collateral enabling is an explicit post-supply hook on every venue (Aave v3/v4 encode their enable calls; Morpho/Compound skip), which also immunizes supplies against Aave's first-supply auto-enable gap. A debt-free repay encodes an empty no-op on every venue, so a generic repay-then-withdraw exit plan always runs.
  • Allowlist gates exposure-increasing flows only. Supply and borrow require an allowlisted adapter; withdraw, repay, and sweep never do, so a position is always exitable even after its adapter is disallowed.
  • Slippage and fill protection. Mandatory absolute input caps (maxDebtIn/maxCollateralIn) are the binding protection; minHopPriceX36 is 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).
  • Events are indexer-first and never block funds. Every position mutation on every router path emits a PositionUpdated resulting-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.
  • Margin opcodes occupy a distinct 0x30 range (0x1c-0x2f stays reserved for future core v4 actions), and undefined opcodes revert UnsupportedAction regardless of plan state.
  • ERC-20-only markets (no fee-on-transfer / rebasing); every flow nets to zero under that constraint.
  • Two-step, zero-address-guarded ownership for router governance and adapter ownership, with lifecycle events at construction, proposal, and acceptance.

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 v4 Swap logs, 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:

Area Hardening
Routed-swap fill check (M-03) Threshold enforces the swap delta, so pre-existing balances cannot mask a short fill
Aave collateral enabling (M-02) Explicit encodeEnableCollateral post-supply hook on all venues; immune to dust-aToken griefing
Aave data provider (M-01) Re-resolved on each use instead of cached (Aave can repoint it)
Morpho boundary repays (L-01) Exact-debt and debt-free repays no longer underflow; partial decreases gate on the measured repay
Event reads (L-05) Best-effort with saturated deltas; a reverting read or hostile-route state change never rolls back a mutation
ROUTE_SWAP allowances (L-08) The scoped Permit2 allowance to the caller-named UR is zeroed after the swap
Comet base price feed (L-14) Read live per valuation instead of cached
Observability (N-12 and others) Owner-lifecycle events, PositionUpdated on every path, documented per-venue event field semantics

A 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), execute plans, and routed swaps. Gas snapshots are committed in isolate mode.

Deployment

The suite is live on mainnet (2026-08-12 redeploy): MarginRouter at 0x000000000075e82F7B7DdC5DD1B4984b560eF5D4 (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 and indexer/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-blue and solady submodules. Aave v3, Aave v4, and Compound v3 are integrated via minimal vendored interfaces under src/interfaces/external/ (no new submodules); the Universal Router dependency was already vendored.

ccashwell added 30 commits June 11, 2026 16:56
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.
ccashwell and others added 14 commits August 21, 2026 17:51
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
ccashwell marked this pull request as ready for review August 24, 2026 23:59

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread indexer/ponder.config.ts Outdated
Comment thread indexer/src/lendingFlows.ts
Comment thread indexer/src/router.ts
ccashwell and others added 14 commits August 25, 2026 10:13
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>
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.

5 participants