Skip to content

feat(margin-sdk): add @uniswap/margin-sdk for the v4 margin trading periphery - #655

Open
ccashwell wants to merge 33 commits into
mainfrom
feat/margin-sdk
Open

ccashwell wants to merge 33 commits into
mainfrom
feat/margin-sdk

Conversation

@ccashwell

Copy link
Copy Markdown
Member

@uniswap/margin-sdk

A new workspace package for the margin trading periphery (Uniswap/v4-periphery#563): leveraged spot positions built from a v4 swap composed with a borrow/supply against an external lending venue — Morpho Blue, Aave v3, or Aave v4 — all behind one MarginRouter. Follows the liquidity-launcher-sdk conventions: viem-only runtime dependency, minimal as const satisfies Abi ABIs, descriptor + PublicClient read pattern, bun test, three-target tsc build.

What's included

Module Purpose
encode.ts Calldata encoders + write descriptors for every entry point: increasePosition, decreasePosition (+ closePosition sugar over the FULL_CLOSE sentinel), addCollateral, execute, multicall, forwarded Permit2 permit — with SDK-side mirrors of the contract guards (mandatory slippage caps, uint128 bounds, pool↔market reconciliation, native-ETH equity handling)
account.ts Offchain accountOf: Solady clone-with-immutable-args CREATE2 derivation, so frontends resolve account addresses with zero RPC calls
math.ts Decimal-aware sizing (sizeIncrease / sizeDecrease from a quote + slippage, works for both the 18d/6d long and reversed-decimal short), leverage↔LTV conversions, health factors matching describePosition semantics
planner.ts / actions.ts MarginPlanner for execute plans: v4 routing actions + the 0x30-range margin opcodes, enforcing offchain the structural rules the router reverts on (SET_ACCOUNT ordering, PULL_TO_ACCOUNT zero-amount / CONTRACT_BALANCE footguns)
reads.ts Venue-agnostic read descriptors (drop into wagmi useReadContract(s) / viem multicall) + PublicClient helpers
abis.ts / addresses.ts / types.ts Exact ABIs, the mainnet deployment registry, and onchain struct mirrors

Validation

The SDK is anchored to the deployed mainnet contracts, not just the source:

  • Every entry-point selector confirmed against the live router via expired-deadline DeadlinePassed reverts (a wrong selector would revert empty).
  • The 9 accountOf test vectors were read from the live router; the CWIA derivation reproduces them byte-for-byte.
  • Entry-point calldata and planner param blobs are anchored to cast-generated ground truth.
  • Adapter reads exercised live (Morpho WETH/USDC LLTV 0.86e18 read back exactly as documented).

80 unit tests, lint, typecheck, build, and dep-consistency all green.

End-to-end demos (demo/, bun run demo)

Runnable anvil-fork flows proving the SDK drives everything the v4-periphery contract tests exercise, against the live deployment and the real USDC/WETH 0.05% pool, with sizing from real v4 Quoter quotes — 60 assertions, all passing:

  1. Long lifecycle (mirrors MarginRouterIntegration + E2E.fork): predicted account, Permit2 setup, 2x open with event decoding and SDK-vs-onchain health math (matches to 1 bps), top-up, leverage-only increase, partial delever, full close with residual returned.
  2. Native-ETH equity (MarginRouterNative): open/top-up with raw msg.value, no approvals.
  3. Shorts on Aave v3 + v4 (Aave adapter fork tests): identical code per venue, only the adapter address changes.
  4. Cross-venue hedge (CrossVenueHedge.fork): isolated sub-accounts, delta-neutral within live-pool impact, independent unwind.
  5. execute plans (MarginRouterExecute): a MarginPlanner plan reproducing the curated open action-for-action, repay-from-wallet, and the owner escape hatch.

Notes for reviewers

  • Ships at 0.0.0 with a minor changeset — the standard release flow mints 0.1.0.
  • Test vectors are pinned to the current mainnet deployment (router 0x0000000004BBC92D0657580CAe35aEBF054E5CDC); a redeploy requires regenerating them (the test files document how).
  • Live-pool caveat surfaced by the demos: a full-size exact-out quote embeds its own price impact (~1.3%/WETH on the 0.05% pool today), so delta-targeted sizing should quote near-spot and buffer — demo 04 shows the pattern; could graduate into an SDK helper later.

ccashwell and others added 6 commits July 23, 2026 19:18
Register the new workspace package for the Uniswap v4 margin trading
periphery, mirroring the liquidity-launcher-sdk toolchain: viem-only
runtime dependency, three-target tsc build (cjs/esm/types), bun test,
and the shared eslint/prettier configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… ABIs

Minimal exact ABIs for the MarginRouter, the venue-agnostic
ILendingAdapter surface (plus per-venue setMarket/MarketSet variants for
Morpho Blue, Aave v3, and Aave v4), the MarginAccount, and Permit2.
Every entry-point selector was verified against the deployed mainnet
router via expired-deadline DeadlinePassed reverts, and every read was
called live against the deployed Morpho adapter.

Includes the mainnet deployment registry (router, account
implementation, three lending adapters), WAD/sentinel constants
(OPEN_DELTA, CONTRACT_BALANCE, FULL_CLOSE), typed onchain struct
mirrors, and the stable-coded MarginSdkError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…math

predictMarginAccountAddress mirrors MarginRouter.accountOf offchain:
the Solady clone-with-immutable-args CREATE2 derivation with
(owner, manager) baked into the initcode and an (owner, manager, subId)
salt. Verified against nine accountOf vectors read from the live
mainnet router.

Market helpers mirror the onchain Market type (pool/market
reconciliation and zeroForOne derivation); the math module provides
decimal-aware position sizing (sizeIncrease/sizeDecrease from a quote
plus slippage), leverage/LTV conversions, and health factors matching
describePosition semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Calldata encoders and viem write descriptors for increasePosition,
decreasePosition (plus closePosition FULL_CLOSE sugar), addCollateral,
execute, multicall, and the forwarded Permit2 permit — with SDK-side
mirrors of the contract guards (slippage bounds, uint128 ranges,
pool/market reconciliation, native-equity handling). Encodings are
test-anchored byte-for-byte to cast-generated calldata.

MarginPlanner composes execute plans from the v4 routing actions and
the 0x30-range margin actions, enforcing offchain the structural rules
the router reverts on (SET_ACCOUNT before account-scoped opcodes,
PULL_TO_ACCOUNT zero-amount and CONTRACT_BALANCE-from-user footguns),
and finalizes to abi.encode(bytes actions, bytes[] params) unlockData.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Venue-agnostic read descriptors (accountOf, describePosition,
positionOf, LTV/allowlist/market-support checks, account views) that
drop into wagmi useReadContract(s) or viem, each paired with a
PublicClient helper. README covers the position model, the quickstart
long flow, native-ETH equity, shorts and per-subId venue isolation,
execute-plan composition rules, and the mainnet deployment table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…deployment

Runnable anvil-fork demos (bun run demo) proving the SDK drives every
flow the v4-periphery margin contract tests exercise, with the margin
deployer impersonated as sender and all sizing derived from real v4
Quoter quotes against the live USDC/WETH 0.05% pool:

- 01 long lifecycle (MarginRouterIntegration + E2E fork): predicted
  accountOf, Permit2 setup, 2x open with event decoding and SDK-vs-
  onchain health math, top-up, leverage-only increase, partial
  delever, full close with residual returned
- 02 native-ETH equity (MarginRouterNative): open and top up with raw
  msg.value, no approvals
- 03 shorts on Aave v3 + Aave v4 (Aave adapter fork tests): reversed
  (collateral, debt) pairing and decimals, identical code per venue,
  78% liquidation threshold read back on both
- 04 cross-venue hedge (MarginRouterCrossVenueHedge.fork): isolated
  sub-accounts, delta-neutral within live-pool impact, independent
  unwind
- 05 execute plans (MarginRouterExecute): a MarginPlanner plan
  reproducing the curated open action-for-action, repay-from-wallet,
  and the owner-only MarginAccount.execute escape hatch

The harness pins the fork below head (load-balanced RPCs serve
inconsistent tip state), pads gas (anvil fork-mode estimates run low
on cold slots), checks every receipt status, and deals tokens by
probing balance-mapping slots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ccashwell
ccashwell requested a review from a team as a code owner July 24, 2026 00:01
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

● Reviewed · 2026-08-27 14:22 UTC · 2 reviews · view run ↗

Approved.

Adds @uniswap/margin-sdk, a new self-contained workspace package of viem-only calldata encoders, decimal-aware sizing math, and read descriptors for the v4 margin-trading periphery.

Assessment

The package is additive and follows the established liquidity-launcher-sdk conventions, anchored to the deployed mainnet router with 80 unit tests plus fork demos. The SDK is a signing-side encoder — the caller is the principal, and the on-chain contract remains the enforcing authority for every guard mirrored offchain, so a loose SDK check degrades to a contract revert rather than a loss. Sizing and health math guard the paths that matter: positivity, zero-collateral leverage, round-to-zero quotes, uint128 bounds, and division denominators. Slippage rounds in the protective direction (input caps up, output floors down, >100% rejected), and pricing sources are kept separate — quotes for sizing, oracle for health.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

Approved — see full review in the sticky comment ↑

@graphite-app
graphite-app Bot requested review from a team July 24, 2026 00:05
@graphite-app

graphite-app Bot commented Jul 24, 2026

Copy link
Copy Markdown

Graphite Automations

"Request reviewers once CI passes on sdks monorepo" took an action on this PR • (07/24/26)

3 reviewers were added and 1 assignee was added to this PR based on Siyu Jiang (See-You John)'s automation.

ccashwell and others added 10 commits July 24, 2026 11:13
From the production-readiness review: mirror more contract constraints
offchain so misuse fails with a typed MarginSdkError instead of a
confusing revert or a silently-wrong transaction.

- deadlines are validated as plausible Unix seconds: zero/negative
  rejected, and Date.now()-scale millisecond values rejected loudly (a
  ms deadline would silently disable the timer for ~3,000 years)
- adapter, market, pool, and hook addresses are validated before
  encoding, replacing raw viem errors with typed ones
- toPoolKey enforces the v4 bounds: static LP fee up to MAX_LP_FEE or
  the DYNAMIC_FEE_FLAG, and tick spacing in [1, 32767]
- planner fund-out actions (withdraw, borrow, account sweep, take,
  take-portion, sweep) reject the zero address as recipient; the
  MSG_SENDER/ADDRESS_THIS sentinels remain valid

Relative imports in the touched files gain explicit .js extensions as
part of the native-ESM packaging fix landing in this series.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the production-readiness review: the read descriptors are the
backend's entire position/health-monitoring surface and had no tests,
and the Permit2 forward only asserted its selector — a swapped
uint48 expiration/nonce pair would have been selector-identical while
authorizing the wrong permit.

- every read descriptor's (address, abi, functionName, args) wiring is
  asserted and ABI-encoded against the SDK ABI
- encodeRouterPermit and permit2ApproveCall are pinned byte-for-byte to
  cast-generated calldata vectors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…JS + ESM)

Fixes the two ship blockers from the production-readiness review — the
publish artifact could not be loaded by the Trading API backend target
in either module system, while CI stayed green because nothing ever
imported dist/ or ran under real Node:

- tslib moves from devDependencies to dependencies: importHelpers emits
  'require("tslib")' at the top of the CJS entry, so a clean consumer
  install crashed MODULE_NOT_FOUND on require('@uniswap/margin-sdk')
- every relative import gains an explicit .js extension and the build
  writes per-directory module-type markers (dist/esm {type:module},
  dist/cjs {type:commonjs}); native Node ESM previously failed
  ERR_MODULE_NOT_FOUND on the extensionless specifiers
- viem becomes a peerDependency (its types cross the public API) and
  stays in devDependencies for the test suite

Adds a built-artifact smoke gate (bun run check:package, wired into
test): packs the real publish artifact, installs it into an isolated
consumer with ONLY declared dependencies resolvable, and loads it under
native Node via both require() and import, running a live account-
derivation vector — so this failure class cannot regress silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the production-readiness review: the offline tests validate the
SDK against frozen vectors, so nothing automated proved the SDK
produces calldata the live contracts accept. The end-to-end demo suite
is that proof — wire it into the test run as a gated fork stage.

bun run test:fork executes demo/run-all.ts (the full lifecycle, native
ETH, Aave v3/v4 shorts, hedge, and execute-plan flows against the live
mainnet deployment on an anvil fork) when FORK_URL or MARGIN_DEMO_RPC
is set — CI provides FORK_URL alongside its Foundry install — and
skips cleanly otherwise so local runs stay offline by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the production-readiness review: the SDK carries ~800 lines of
hand-written ABI and viem encodes tuples positionally, so a field
reorder in the still-draft contracts would produce silently-wrong
calldata with the offline suite green.

bun run check:abi-drift compiles the contracts from a local
v4-periphery checkout (V4_PERIPHERY_PATH) via forge inspect and asserts
every SDK ABI entry — router, account, and all three adapters, plus
the venue-agnostic surface against each venue — exists in the Solidity
with an identical canonical signature, outputs, state mutability, and
event index layout. Verified green against the margin-trading branch.

README documents the three validation gates (package smoke, ABI drift,
gated fork suite) and the 0.0.x pre-release posture while
v4-periphery#563 is in review and governance remains the deployer EOA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the production-readiness review: the hardcoded mainnet addresses
point at contracts still in review (v4-periphery#563) with governance
on the deployer EOA pending the timelock/multisig handoff. Downgrade
the initial changeset from minor to patch so the package publishes as
0.0.1 and graduates to 0.1.0 only once the deployment is final.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CollateralAdded debt-unchanged check compared the event's interest-
accrued debtTotal exactly against a position read a few blocks earlier,
so a few wei of Morpho accrual could flake the fork suite depending on
live rates. Compare within 1 bps like the other cross-block assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ings pinned to v4-periphery

From the re-review: the ~800 hand-written ABI lines were the weakest
link — verified today, but with viem's positional tuple encoding a
future contract field reorder would produce silently-wrong calldata.

src/generated/abis.ts is now produced by scripts/generate-abis.ts from
a v4-periphery checkout PINNED to a specific commit (recorded in the
file header, currently fe8105a9 — the mainnet deployment build):
forge-compiled full ABIs for the router, account, and all three
adapters, normalized (stable key order, solc internalType stripped)
and prettier-formatted deterministically. The venue-agnostic
LENDING_ADAPTER_ABI is assembled from the compiled ILendingAdapter
interface plus the ownership/error items of the compiled Morpho
adapter — nothing hand-written remains for the margin contracts.
PERMIT2_ABI stays local deliberately: canonical Permit2 is immutable.

regenerate:abis rebuilds from the pin (refusing dirty or off-pin
checkouts; --update-pin re-pins); check:abis regenerates to memory and
diffs against the committed file, replacing the weaker signature-level
check-abi-drift script. Full ABIs also mean simulate/decode now sees
every inherited error and event, not just the curated subset.

Equivalence is proven, not assumed: the byte-for-byte cast calldata
vectors and live-mainnet selector anchors all pass unchanged against
the generated bindings (105/105).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the re-review: the Node CJS+ESM smoke covered the backend target
but the browser target was never actually loaded. The package check
gains two browser stages over the same packed artifact:

- a static scan of the shipped ESM module graph that fails on any
  Node-builtin specifier (node:*, fs, crypto, ...) — the real browser
  failure mode for an isomorphic library, invisible to Node smoke tests
- a jsdom load: the installed ESM entry is imported with jsdom's
  window/document as globals and runs the account-derivation vector

jsdom is a devDependency only; the SDK under test still resolves
nothing beyond its declared dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry build on every PR

From the re-review: the drift gate existed but was manual and unpinned.
The margin-sdk-abi-check workflow (mirroring the liquidity-launcher
lock-bytecode gate) now runs on every PR touching the package: it reads
the pinned commit from the generated bindings header, clones
v4-periphery at exactly that commit, compiles it with forge, and fails
if the committed bindings differ from a fresh regeneration — closing
the silent-wrong-calldata gap in CI rather than by convention. README
documents the regenerate/re-pin workflow and the browser-load stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@socket-security

socket-security Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedjsdom@​26.1.0951001009570

View full report

@socket-security

socket-security Bot commented Jul 24, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm data-urls is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: sdks/margin-sdk/package.jsonnpm/jsdom@26.1.0npm/data-urls@5.0.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/data-urls@5.0.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm rrweb-cssom is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: sdks/margin-sdk/package.jsonnpm/jsdom@26.1.0npm/rrweb-cssom@0.8.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/rrweb-cssom@0.8.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Medium
System shell access: npm jsdom in module child_process

Module: child_process

Location: Package overview

From: sdks/margin-sdk/package.jsonnpm/jsdom@26.1.0

ℹ Read more on: This package | This alert | What is shell access?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should avoid accessing the shell which can reduce portability, and make it easier for malicious shell access to be introduced.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/jsdom@26.1.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Low
Filesystem access: npm jsdom with module fs

Module: fs

Location: Package overview

From: sdks/margin-sdk/package.jsonnpm/jsdom@26.1.0

ℹ Read more on: This package | This alert | What is filesystem access?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: If a package must read the file system, clarify what it will read and ensure it reads only what it claims to. If appropriate, packages can leave file system access to consumers and operate on data passed to it instead.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/jsdom@26.1.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

…cape-hatch encoders

The router has no curated withdraw entry point — its only write entry points are
increasePosition, decreasePosition, addCollateral, and execute — so withdrawing
collateral without touching debt had to be hand-rolled as an execute plan, where
every failure mode is a silently unsafe position rather than a revert.

- `withdrawCollateralPlan(...)` builds the curated plan
  (SET_ACCOUNT → ACCOUNT_WITHDRAW_COLLATERAL → ASSERT_HEALTH) and closes the three
  footguns: a literal (non-sentinel) recipient, an explicit amount, and a mandatory
  non-zero maxLtvAfter.
- Account-direct encoders for the IMarginAccount owner escape hatch —
  withdrawCollateral, supplyCollateral, borrow, repay, sweep — for recovering a
  position if the router is ever deprecated, paused, or compromised. New
  AccountContractWrite descriptor type, since these target the account, not the
  router. No ABI regeneration needed: the generated MARGIN_ACCOUNT_ABI already
  carries all five plus ReceiverNotAllowed.

Also fixes the recipient guard on the account-scoped fund-out actions. The router
forwards ACCOUNT_WITHDRAW_COLLATERAL / ACCOUNT_BORROW / ACCOUNT_SWEEP recipients
straight to the account without _mapRecipient (unlike the router-level TAKE/SWEEP
opcodes), so the MSG_SENDER/ADDRESS_THIS sentinels arrive as the literal 0x…01 /
0x…02, match neither the account's owner nor its manager, and revert
ReceiverNotAllowed. `validateAccountRecipient` now rejects them at build time.
The planner's borrow test asserted the opposite and passed ADDRESS_THIS as a
recipient; it now uses the literal router address, matching what the curated
increase encodes (`address(this)`).

Calldata and plan params are anchored to cast-generated ground truth. Contract
behaviour verified against v4-periphery at the pinned commit
fe8105a9e31ac6e30c9b18bd1078047cab3e1cea (Market.toSwapParams,
MarginAccount._requireReceiver, MarginRouter._handleAction).

Adds demo/06-withdraw-collateral.ts (16 assertions, green against the live mainnet
deployment on an anvil fork): the curated plan, a native exit via
withdraw → unwrap → sweep with the router netted to zero, and the account-direct
escape hatch. It also pins that maxLtvAfter really binds onchain and that the
unsafe variants (zero bound, MSG_SENDER recipient) never build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Ayoakala

Ayoakala commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Pushed efc86705 — adds collateral withdrawal, which had no path in the SDK. Two things in here worth a look, one of which changes existing behaviour.

Withdrawing collateral without touching debt

decreasePosition withdraws collateral as part of repaying debt, but there's no way to pull collateral out while leaving debt alone (de-risking, taking excess equity off the table). The router has no curated entry point for it — the write entry points are increasePosition, decreasePosition, addCollateral, execute — so it had to be hand-rolled as an execute plan, where all three failure modes are silent rather than reverts:

  • OPEN_DELTA is not "all collateral" on this action. It resolves to _getFullDebt(market.collateral) (MarginRouter.sol:569) — correct inside a swap-bearing delever, but zero in a swap-free plan, so it withdraws nothing and succeeds.
  • assertHealth is opt-in and withdrawing raises LTV. ASSERT_HEALTH skips a zero bound, so an unbounded withdrawal walks the position to the liquidation edge without reverting. The curated decreasePosition makes maxLtvAfter mandatory for exactly this reason; a hand-rolled plan has no such enforcement.
  • The recipient can't be a sentinel (below).

withdrawCollateralPlan(...) builds SET_ACCOUNT → ACCOUNT_WITHDRAW_COLLATERAL → ASSERT_HEALTH and rejects all three at build time. Also added account-direct encoders for the IMarginAccount escape-hatch primitives (withdrawCollateral/supplyCollateral/borrow/repay/sweep) per the TDD's "the owner can call the account's primitives directly, bypassing the router". No ABI regeneration needed — the generated MARGIN_ACCOUNT_ABI already had all five plus ReceiverNotAllowed, and src/generated/abis.ts is untouched.

⚠️ Behaviour change: sentinels on the account-scoped fund-out actions

This is the part I'd most like you to sanity-check.

_mapRecipient is called only for the router-level SWEEP (MarginRouter.sol:514). The three ACCOUNT_* fund-out handlers pass to straight through to the account unmapped:

// MarginRouter.sol:570, :577, :583
IMarginAccount(account).withdrawCollateral(adapter, market, amount, to);
IMarginAccount(account).borrow(adapter, market, amount, to);
IMarginAccount(account).sweep(currency, amount, to);

and MarginAccount._requireReceiver (:163) only accepts the clone's baked-in {owner, manager}. So MSG_SENDER/ADDRESS_THIS — idiomatic everywhere else in a v4 plan — arrive as the literal 0x…01/0x…02 and revert ReceiverNotAllowed. validateAccountRecipient now rejects them at build time; the router-level opcodes keep the lenient zero-address-only guard, since the sentinels are genuinely valid there.

This means two existing tests were asserting the wrong thing. borrow to ADDRESS_THIS asserted the planner accepts ADDR2 (= ADDRESS_THIS), and the borrow3e9ToRouter ground-truth blob ended in …0002 — so it was ground truth for a plan that would have reverted onchain. The curated increase passes address(this) literally (MarginRouter.sol:474), so I regenerated that blob against the literal router address with cast and renamed the test. Flagging explicitly in case the sentinel behaviour was intentional and I've misread the handler — but I couldn't find a mapping path for those three opcodes.

Verification

  • 127 unit tests pass (was 120). New calldata and plan params anchored to fresh cast output; selector 0xe3f81c67 checked against the pinned ABI.
  • demo/06-withdraw-collateral.ts — 16 assertions, green against the live mainnet deployment on an anvil fork, and the full bun run demo suite (01–06) passes on one shared fork. It covers the curated plan, a native exit (withdraw → unwrap → sweep, asserting the router nets to zero), and the account-direct escape hatch — plus it pins that maxLtvAfter really binds onchain and that the unsafe variants never build. Uses sub-IDs 7/8 to stay clear of 01–05.
  • lint, typecheck, check:package (CJS + ESM + jsdom) all clean.
  • check:abis not run locally (my v4-periphery checkout is on main, not the pinned fe8105a9) — but the generated bindings are untouched, and CI checks out the pin itself.

Contract behaviour verified by reading v4-periphery at the pinned commit fe8105a9e31ac6e30c9b18bd1078047cab3e1cea: Market.toSwapParams, MarginAccount._requireReceiver, MarginRouter._handleAction.

Folded into the existing changeset rather than adding a second one, since the package is still unreleased.

Two follow-ups I did not do

  1. A nativePoolIncrease helper. Separately, increasePosition can't route through a native-ETH pool at all: Market.toSwapParams requires the pool's currencies to equal the market's (collateral, debt), and markets must be ERC-20 because MarginAccount.supplyCollateral approves the collateral token. So native ETH/USDC routing — which is where the deep liquidity is — has to go through execute. MarginPlanner can express it today, but hand-rolling it in a backend has the same class of risk as hand-rolled withdrawals.
  2. Contract-side coverage for a native-pool swap. No test at the pin routes a margin swap through a pool with currency0 == address(0). MarginRouterExecuteNative.t.sol covers native equity only and says so: "no swap pool is needed because these plans move value between native, WETH, and the account without touching pool deltas." So ASSERT_FILL/TAKE on native currency mid-unlock is untested on both sides.

Ayoakala and others added 3 commits August 3, 2026 15:56
…rading

Regenerate the forge-generated bindings from upstream e4a5062 (was fe8105a),
picking up every ABI-level change since the last pin:

- MarginRouter: new ZeroAmount() error — zero-amount inputs (collateralToBuy,
  addCollateral amount, PULL_TO_ACCOUNT amount, partial debtToRepay) now revert
  with it instead of SlippageBoundRequired
- MarginAccount: `function receive()` became a true receive() fallback
- MorphoLendingAdapter: ZeroAddress() constructor guard
- CompoundV3LendingAdapter: new fourth-venue adapter, now compiled into the
  bindings as COMPOUND_V3_LENDING_ADAPTER_ABI (setMarket(collateral,debt,bool),
  MarketSet event, ZeroAddress/DebtNotBaseToken/AccountMismatch errors)

The venue-agnostic LENDING_ADAPTER_ABI surface is unchanged (ILendingAdapter
and the MarketAllowlist refactor were doc/storage-only; MarketNotSupported kept
its signature when it moved to file scope).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ccashwell and others added 5 commits August 11, 2026 16:20
…vert split

Track the upstream margin-periphery additions in the SDK surface:

- add 'compoundV3' to LendingVenue and re-export COMPOUND_V3_LENDING_ADAPTER_ABI;
  no mainnet address entry — the adapter is not in the live deployment yet, and
  the addresses test pins that absence as a tripwire to flip on deploy
- document that Compound reads are account-level like Aave (one position per
  (owner, subId)) and that the adapter binds a single Comet whose base token is
  the only borrowable debt
- document the SlippageBoundRequired → ZeroAmount split for zero-amount inputs,
  noting the live mainnet router predates the new error and still reverts those
  paths with SlippageBoundRequired; SDK-side validation already distinguished
  the two (INVALID_AMOUNT vs SLIPPAGE_BOUND_REQUIRED), so behavior is unchanged

Verified: bun test (105 pass), check:abis against the new pin, check:package
(CJS/ESM/browser), and the full anvil-fork demo suite against the live mainnet
deployment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-pin the forge-generated bindings to v4-periphery margin-trading
4202ba3, where position swaps route through a caller-supplied Universal
Router instead of a single v4 pool:

- IncreaseParams/DecreaseParams drop poolKey/minHopPriceX36 for
  universalRouter/routeCommands/routeInputs; encoders mirror the new
  onchain validation (UniversalRouterNotSet, IneffectiveLtvBound) with
  UNIVERSAL_ROUTER_REQUIRED / INEFFECTIVE_LTV_BOUND error codes.
- New buildV4ExactOutRoute builds the canonical single-pool v4 route (a
  byte-for-byte mirror of the periphery's MarginRouteHelpers), the
  ergonomic replacement for the old poolKey field.
- MarginPlanner gains routeSwap (ROUTE_SWAP 0x39) and
  assertAccountBalance (ASSERT_ACCOUNT_BALANCE 0x3a, account-scoped).
- The PositionUpdated snapshot event ships in the regenerated ABI.
- Mainnet addresses retarget to the redeployed suite (router
  0x00000000000Dc78b00e36d3a7997Bd9c4cd9F1f0) and add the Compound v3
  adapter; every address verified onchain (code, governance, allowlist).

Cast-generated calldata vectors and accountOf vectors regenerated
against the live router (new selectors 0x084a1ed3/0x9b304505 confirmed
dispatching via expired-deadline probes).
Every demo flow now builds its swap as a Universal Router route
delivered to the derived MarginAccount (demoRoute over
buildV4ExactOutRoute), matching the redeployed router's API. Demo 05
reconstructs the curated open with ROUTE_SWAP + ASSERT_ACCOUNT_BALANCE
and asserts the per-mutation PositionUpdated snapshots.

The fork boots with --disable-code-size-limit and deploys a post-#491
Universal Router from the pinned v4-periphery artifact (override with
MARGIN_DEMO_UNIVERSAL_ROUTER): the periphery's default-profile UR build
exceeds EIP-170, which forge's test EVM relaxes but anvil enforces.

All five flows pass end-to-end against the live mainnet deployment on
an anvil fork.
…suite

Quickstart, close/delever, and short examples now build Universal
Router routes; the deployment table points at the live redeployed suite
(with Compound v3) and explains why the Universal Router is a per-call
parameter rather than a table entry. Error docs add
IneffectiveLtvBound/UniversalRouterNotSet and drop the stale ZeroAmount
pre-release caveat.
The withdraw-collateral demo landed upstream against the pool-key
increasePosition signature; its open2xLong helper now derives the
account first and supplies the Universal Router route via demoRoute,
like the other demo flows. The withdrawal paths themselves are
swap-free and unchanged. All six fork demos pass against the live
mainnet deployment.
@datadog-official

This comment has been minimized.

ccashwell and others added 8 commits August 12, 2026 12:18
…ccrual

A close route must buy at least the live debt, and the exact-output is
static calldata, so integrators buffered the quote with a flat 10 bps
haircut that conflates swap slippage with interest accrual and over-buys
by orders of magnitude on quiet markets.

New accrual module:
- measureBorrowRatePerSecond samples the interest-accrued debt
  (adapter.positionOf) at two blocks and derives the position's realized
  per-second growth. Venue-agnostic across all four adapters and prices
  in venue quirks a nominal APR read would miss; refuses windows that
  contain a position mutation.
- estimateInterestAccrual / projectDebt compound the rate over a
  blocks-denominated inclusion horizon (3-term Taylor of e^x, the series
  Morpho accrues with; upper-bounds Compound's linear accrual).
- sizeFullClose composes the accrual-buffered debtToBuy with the
  separately-priced swap-slippage cap, mirroring sizeDecrease.

Demo 01's full close now measures the rate over a clean 2-block window
and asserts the resulting buffer undercuts the old flat haircut;
validated against live Morpho on the fork suite (all six flows green).
The pin advances past the PositionUpdated unlock-free-path emissions
(addCollateral and the zero-debt swap-free close). The ABI itself is
unchanged: the events and signatures already existed, they now fire on
more paths. Note the live mainnet router predates this behavior; the
new emissions apply from the next router deployment.
… ABI

Re-pins bindings to v4-periphery 7fc38d2, where every lending adapter
implements IAmountResolver (the resolver side of the proposed Universal
Router RESOLVE instruction). resolveAmount joins the shared adapter
surface assembled into LENDING_ADAPTER_ABI, since it is identical
across venues. Context ABI: abi.encode(kind, account, market) with
kind 0 = DEBT, 1 = COLLATERAL. Route-builder support lands when a
RESOLVE-capable UR deployment exists; until then the accrual estimator
sizes the static buffer.
Router 0x000000000075e82F7B7DdC5DD1B4984b560eF5D4 plus all four adapters
at their new CREATE2 addresses (the account implementation is
unchanged). The suite carries the unlock-free-path PositionUpdated
emissions and the IAmountResolver adapter surface. Every address
verified onchain (code, governance, allowlist, markets, a resolveAmount
probe); accountOf test vectors regenerated from the live router and the
selector dispatch re-confirmed via expired-deadline probes. Full fork
demo suite green against the new deployment.
…ediations)

Captures the M-01/M-02/M-03 and L-05/L-08/L-14 audit fixes. The
ABI-affecting change is M-02's new ILendingAdapter.encodeEnableCollateral
(now 11 items); the other fixes are behavior/internal only. check:abis
and the unit suite pass against the new pin.
…indings

The 2026-08-26 DeployMargin broadcast (blocks 25842465-25842483) redeployed
the full margin suite with the OpenZeppelin audit fix set. Addresses verified
onchain (code, governance, adapter allowlist, canonical markets) before
pinning:

- MarginRouter 0x0000000000F57fCd0d5a78a19907240F1169EDEC
- MarginAccount impl 0xdDD0967e90bCBc2D1F026b3977bb4dE39133b109
- Morpho 0x766C34DcFBA565a1b72ce83ECD96712376Ca1f3D, Aave v3
  0x7E1A543Bd8ed2F16D61DA4b6bC2eC5d240D098aC, Aave v4
  0xAb3C2661c810295Db32125942f04b92c61fAE2Eb, Compound v3
  0x77598B845d0200fc707bD32A8Ad6DCF85C995e0d

ABI bindings regenerated pinned to v4-periphery 0aa9cc3 (picks up the audit
wave: NoDebtToRepay, encodeEnableCollateral, and the owner-lifecycle and
governance-construction events). Account-address vectors re-read from the new
router onchain, both curated selectors re-confirmed dispatching against it
(DeadlinePassed probe), and the full fork demo suite passed against the live
deployment.
The demo suite routes swaps through a Universal Router with unlocked V4_SWAP
support (v4-periphery #491), which the historical mainnet deployments predate,
so it deploys one onto the fork from a v4-periphery forge build. The shared
monorepo test job has no such checkout, so `test:fork` — wired into the
package's `test` script — failed there on every run.

Move it to a dedicated workflow, gated on `sdks/margin-sdk/**`, that clones
v4-periphery at the same commit the committed ABI bindings are pinned to,
builds it, and points the suite at it via V4_PERIPHERY_PATH. The package's
`test` stays unit tests + the package check: fast, no foundry, no periphery
checkout, and unrelated PRs pay nothing for the Solidity build.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants