feat(margin-sdk): add @uniswap/margin-sdk for the v4 margin trading periphery - #655
feat(margin-sdk): add @uniswap/margin-sdk for the v4 margin trading periphery#655ccashwell wants to merge 19 commits into
Conversation
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>
|
● Reviewed · 2026-08-04 15:32 UTC · 2 reviews · view run ↗
Adds AssessmentThe package is additive and follows the established |
There was a problem hiding this comment.
Note
✅ Approved — see full review in the sticky comment ↑
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. |
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>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
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.
|
…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>
|
Pushed Withdrawing collateral without touching debt
|
@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 theliquidity-launcher-sdkconventions: viem-only runtime dependency, minimalas const satisfies AbiABIs, descriptor +PublicClientread pattern, bun test, three-target tsc build.What's included
encode.tsincreasePosition,decreasePosition(+closePositionsugar over theFULL_CLOSEsentinel),addCollateral,execute,multicall, forwarded Permit2permit— with SDK-side mirrors of the contract guards (mandatory slippage caps, uint128 bounds, pool↔market reconciliation, native-ETH equity handling)account.tsaccountOf: Solady clone-with-immutable-args CREATE2 derivation, so frontends resolve account addresses with zero RPC callsmath.tssizeIncrease/sizeDecreasefrom a quote + slippage, works for both the 18d/6d long and reversed-decimal short), leverage↔LTV conversions, health factors matchingdescribePositionsemanticsplanner.ts/actions.tsMarginPlannerforexecuteplans: v4 routing actions + the0x30-range margin opcodes, enforcing offchain the structural rules the router reverts on (SET_ACCOUNTordering,PULL_TO_ACCOUNTzero-amount /CONTRACT_BALANCEfootguns)reads.tsuseReadContract(s)/ viem multicall) +PublicClienthelpersabis.ts/addresses.ts/types.tsValidation
The SDK is anchored to the deployed mainnet contracts, not just the source:
DeadlinePassedreverts (a wrong selector would revert empty).accountOftest vectors were read from the live router; the CWIA derivation reproduces them byte-for-byte.cast-generated ground truth.0.86e18read 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:
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.MarginRouterNative): open/top-up with rawmsg.value, no approvals.CrossVenueHedge.fork): isolated sub-accounts, delta-neutral within live-pool impact, independent unwind.executeplans (MarginRouterExecute): aMarginPlannerplan reproducing the curated open action-for-action, repay-from-wallet, and the owner escape hatch.Notes for reviewers
0.0.0with aminorchangeset — the standard release flow mints0.1.0.0x0000000004BBC92D0657580CAe35aEBF054E5CDC); a redeploy requires regenerating them (the test files document how).