From 8b434d7ff173ff689fa7fbbf82c15b4c962dadd9 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 23 Jul 2026 19:18:57 -0400 Subject: [PATCH 01/17] feat(margin-sdk): scaffold the @uniswap/margin-sdk package 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 --- bun.lock | 34 ++++++++++++--- sdks/margin-sdk/.eslintrc.js | 16 +++++++ sdks/margin-sdk/package.json | 67 +++++++++++++++++++++++++++++ sdks/margin-sdk/tsconfig.base.json | 19 ++++++++ sdks/margin-sdk/tsconfig.cjs.json | 9 ++++ sdks/margin-sdk/tsconfig.esm.json | 9 ++++ sdks/margin-sdk/tsconfig.types.json | 9 ++++ 7 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 sdks/margin-sdk/.eslintrc.js create mode 100644 sdks/margin-sdk/package.json create mode 100644 sdks/margin-sdk/tsconfig.base.json create mode 100644 sdks/margin-sdk/tsconfig.cjs.json create mode 100644 sdks/margin-sdk/tsconfig.esm.json create mode 100644 sdks/margin-sdk/tsconfig.types.json diff --git a/bun.lock b/bun.lock index c3e1d6089..33dcb422b 100644 --- a/bun.lock +++ b/bun.lock @@ -42,7 +42,7 @@ }, "sdks/liquidity-launcher-sdk": { "name": "@uniswap/liquidity-launcher-sdk", - "version": "0.0.0", + "version": "1.0.0", "dependencies": { "@uniswap/sdk-core": "workspace:*", "@uniswap/v3-sdk": "workspace:*", @@ -62,6 +62,24 @@ "typescript": "npm:typescript@^5.6.2", }, }, + "sdks/margin-sdk": { + "name": "@uniswap/margin-sdk", + "version": "0.0.0", + "dependencies": { + "viem": "^2.23.5", + }, + "devDependencies": { + "@types/node": "^18.7.16", + "@typescript-eslint/eslint-plugin": "^8.38.0", + "@typescript-eslint/parser": "^8.38.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.22.0", + "prettier": "^2.4.1", + "tslib": "^2.3.0", + "typescript": "npm:typescript@^5.6.2", + }, + }, "sdks/permit2-sdk": { "name": "@uniswap/permit2-sdk", "version": "1.4.0", @@ -82,7 +100,7 @@ }, "sdks/router-sdk": { "name": "@uniswap/router-sdk", - "version": "2.10.5", + "version": "2.11.0", "dependencies": { "@ethersproject/abi": "^5.5.0", "@ethersproject/solidity": "^5.0.9", @@ -138,7 +156,7 @@ }, "sdks/smart-wallet-sdk": { "name": "@uniswap/smart-wallet-sdk", - "version": "2.6.6", + "version": "2.8.0", "dependencies": { "@uniswap/sdk-core": "workspace:~", "viem": "^2.23.5", @@ -235,7 +253,7 @@ }, "sdks/universal-router-sdk": { "name": "@uniswap/universal-router-sdk", - "version": "5.8.0", + "version": "5.11.0", "dependencies": { "@ethersproject/abi": "^5.5.0", "@ethersproject/abstract-signer": "^5.7.0", @@ -272,7 +290,7 @@ }, "sdks/v2-sdk": { "name": "@uniswap/v2-sdk", - "version": "4.20.5", + "version": "4.21.0", "dependencies": { "@ethersproject/address": "^5.0.2", "@ethersproject/bignumber": "^5.5.0", @@ -300,7 +318,7 @@ }, "sdks/v3-sdk": { "name": "@uniswap/v3-sdk", - "version": "3.30.5", + "version": "3.31.0", "dependencies": { "@ethersproject/abi": "^5.5.0", "@ethersproject/abstract-signer": "^5.7.0", @@ -332,7 +350,7 @@ }, "sdks/v4-sdk": { "name": "@uniswap/v4-sdk", - "version": "2.2.3", + "version": "2.3.0", "dependencies": { "@ethersproject/abi": "^5.5.0", "@ethersproject/abstract-signer": "^5.7.0", @@ -932,6 +950,8 @@ "@uniswap/liquidity-launcher-sdk": ["@uniswap/liquidity-launcher-sdk@workspace:sdks/liquidity-launcher-sdk"], + "@uniswap/margin-sdk": ["@uniswap/margin-sdk@workspace:sdks/margin-sdk"], + "@uniswap/permit2-sdk": ["@uniswap/permit2-sdk@workspace:sdks/permit2-sdk"], "@uniswap/router-sdk": ["@uniswap/router-sdk@workspace:sdks/router-sdk"], diff --git a/sdks/margin-sdk/.eslintrc.js b/sdks/margin-sdk/.eslintrc.js new file mode 100644 index 000000000..011099c60 --- /dev/null +++ b/sdks/margin-sdk/.eslintrc.js @@ -0,0 +1,16 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + extends: ['plugin:@typescript-eslint/recommended', 'plugin:import/typescript', 'prettier'], + plugins: ['@typescript-eslint', 'import'], + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + 'import/order': ['error', { 'newlines-between': 'always', alphabetize: { order: 'asc' } }], + }, + ignorePatterns: ['dist', 'node_modules'], +} diff --git a/sdks/margin-sdk/package.json b/sdks/margin-sdk/package.json new file mode 100644 index 000000000..891df3858 --- /dev/null +++ b/sdks/margin-sdk/package.json @@ -0,0 +1,67 @@ +{ + "name": "@uniswap/margin-sdk", + "version": "0.0.0", + "description": "πŸ“ˆ An SDK for opening and managing leveraged spot positions through the Uniswap v4 margin trading periphery (MarginRouter + lending adapters)", + "repository": "https://github.com/Uniswap/sdks.git", + "keywords": [ + "uniswap", + "ethereum", + "margin", + "leverage", + "v4", + "morpho", + "aave" + ], + "license": "MIT", + "main": "./dist/cjs/src/index.js", + "module": "./dist/esm/src/index.js", + "types": "./dist/types/src/index.d.ts", + "files": [ + "dist" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "clean": "rm -rf dist", + "build": "bun run clean && bun run build:cjs && bun run build:esm && bun run build:types", + "build:cjs": "tsc -p tsconfig.cjs.json", + "build:esm": "tsc -p tsconfig.esm.json", + "build:types": "tsc -p tsconfig.types.json", + "typecheck": "tsc -p tsconfig.base.json --noEmit", + "release": "changeset publish", + "lint": "eslint src --ext .ts", + "test": "bun test" + }, + "exports": { + ".": { + "types": "./dist/types/src/index.d.ts", + "import": "./dist/esm/src/index.js", + "require": "./dist/cjs/src/index.js" + } + }, + "sideEffects": false, + "dependencies": { + "viem": "^2.23.5" + }, + "devDependencies": { + "@types/node": "^18.7.16", + "@typescript-eslint/eslint-plugin": "^8.38.0", + "@typescript-eslint/parser": "^8.38.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.22.0", + "prettier": "^2.4.1", + "tslib": "^2.3.0", + "typescript": "npm:typescript@^5.6.2" + }, + "prettier": { + "printWidth": 120, + "semi": false, + "singleQuote": true + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/sdks/margin-sdk/tsconfig.base.json b/sdks/margin-sdk/tsconfig.base.json new file mode 100644 index 000000000..3a22844ad --- /dev/null +++ b/sdks/margin-sdk/tsconfig.base.json @@ -0,0 +1,19 @@ +{ + "include": ["src"], + "compilerOptions": { + "rootDir": ".", + "baseUrl": ".", + "target": "es2020", + "module": "esnext", + "importHelpers": true, + "declaration": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/sdks/margin-sdk/tsconfig.cjs.json b/sdks/margin-sdk/tsconfig.cjs.json new file mode 100644 index 000000000..9e1703e56 --- /dev/null +++ b/sdks/margin-sdk/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist/cjs", + "target": "es2020" + }, + "include": ["src/**/*"] +} diff --git a/sdks/margin-sdk/tsconfig.esm.json b/sdks/margin-sdk/tsconfig.esm.json new file mode 100644 index 000000000..d1aaee8c3 --- /dev/null +++ b/sdks/margin-sdk/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "outDir": "dist/esm", + "target": "es2020" + }, + "include": ["src/**/*"] +} diff --git a/sdks/margin-sdk/tsconfig.types.json b/sdks/margin-sdk/tsconfig.types.json new file mode 100644 index 000000000..1a5613e41 --- /dev/null +++ b/sdks/margin-sdk/tsconfig.types.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist/types" + }, + "include": ["src"] +} From 738b3d78051501de0c4ecde3c5fca52932b872a2 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 23 Jul 2026 19:19:08 -0400 Subject: [PATCH 02/17] feat(margin-sdk): add chains, addresses, struct mirrors, and verified 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 --- sdks/margin-sdk/src/abis.ts | 818 ++++++++++++++++++++++++++ sdks/margin-sdk/src/addresses.test.ts | 49 ++ sdks/margin-sdk/src/addresses.ts | 55 ++ sdks/margin-sdk/src/chains.ts | 17 + sdks/margin-sdk/src/constants.ts | 41 ++ sdks/margin-sdk/src/errors.ts | 48 ++ sdks/margin-sdk/src/types.ts | 153 +++++ 7 files changed, 1181 insertions(+) create mode 100644 sdks/margin-sdk/src/abis.ts create mode 100644 sdks/margin-sdk/src/addresses.test.ts create mode 100644 sdks/margin-sdk/src/addresses.ts create mode 100644 sdks/margin-sdk/src/chains.ts create mode 100644 sdks/margin-sdk/src/constants.ts create mode 100644 sdks/margin-sdk/src/errors.ts create mode 100644 sdks/margin-sdk/src/types.ts diff --git a/sdks/margin-sdk/src/abis.ts b/sdks/margin-sdk/src/abis.ts new file mode 100644 index 000000000..6a6a949bc --- /dev/null +++ b/sdks/margin-sdk/src/abis.ts @@ -0,0 +1,818 @@ +import { type Abi } from 'viem' + +/** + * Minimal, exact ABIs for the margin stack. Each is `as const satisfies Abi` so viem/wagmi infer + * argument and return types. Function and struct layouts were verified against the deployed + * mainnet contracts (each entry point selector confirmed via an expired-deadline `DeadlinePassed` + * revert; every read called live). + */ + +const MARKET_COMPONENTS = [ + { name: 'collateral', type: 'address' }, + { name: 'debt', type: 'address' }, +] as const + +const POOL_KEY_COMPONENTS = [ + { name: 'currency0', type: 'address' }, + { name: 'currency1', type: 'address' }, + { name: 'fee', type: 'uint24' }, + { name: 'tickSpacing', type: 'int24' }, + { name: 'hooks', type: 'address' }, +] as const + +export const MARGIN_ROUTER_ABI = [ + // ------------------------------------------------------------------------- + // Position entry points + // ------------------------------------------------------------------------- + { + type: 'function', + name: 'increasePosition', + stateMutability: 'payable', + inputs: [ + { + name: 'params', + type: 'tuple', + components: [ + { name: 'adapter', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + { name: 'poolKey', type: 'tuple', components: POOL_KEY_COMPONENTS }, + { name: 'equity', type: 'uint256' }, + { name: 'collateralToBuy', type: 'uint128' }, + { name: 'maxDebtIn', type: 'uint128' }, + { name: 'minHopPriceX36', type: 'uint256' }, + { name: 'maxLtvAfter', type: 'uint256' }, + { name: 'subId', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + ], + outputs: [{ name: 'account', type: 'address' }], + }, + { + type: 'function', + name: 'decreasePosition', + stateMutability: 'nonpayable', + inputs: [ + { + name: 'params', + type: 'tuple', + components: [ + { name: 'adapter', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + { name: 'poolKey', type: 'tuple', components: POOL_KEY_COMPONENTS }, + { name: 'debtToRepay', type: 'uint256' }, + { name: 'maxCollateralIn', type: 'uint128' }, + { name: 'minHopPriceX36', type: 'uint256' }, + { name: 'maxLtvAfter', type: 'uint256' }, + { name: 'subId', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + ], + outputs: [{ name: 'account', type: 'address' }], + }, + { + type: 'function', + name: 'addCollateral', + stateMutability: 'payable', + inputs: [ + { + name: 'params', + type: 'tuple', + components: [ + { name: 'adapter', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + { name: 'amount', type: 'uint256' }, + { name: 'subId', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + ], + outputs: [{ name: 'account', type: 'address' }], + }, + { + type: 'function', + name: 'execute', + stateMutability: 'payable', + inputs: [ + { name: 'unlockData', type: 'bytes' }, + { name: 'deadline', type: 'uint256' }, + ], + outputs: [], + }, + // ------------------------------------------------------------------------- + // Accounts + // ------------------------------------------------------------------------- + { + type: 'function', + name: 'accountOf', + stateMutability: 'view', + inputs: [ + { name: 'owner', type: 'address' }, + { name: 'subId', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'address' }], + }, + { + type: 'function', + name: 'createAccount', + stateMutability: 'nonpayable', + inputs: [ + { name: 'owner', type: 'address' }, + { name: 'subId', type: 'uint256' }, + ], + outputs: [{ name: 'account', type: 'address' }], + }, + { + type: 'function', + name: 'accountImplementation', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'address' }], + }, + { + type: 'function', + name: 'manager', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'address' }], + }, + // ------------------------------------------------------------------------- + // Governance + // ------------------------------------------------------------------------- + { + type: 'function', + name: 'governance', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'address' }], + }, + { + type: 'function', + name: 'pendingGovernance', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'address' }], + }, + { + type: 'function', + name: 'isAdapterAllowed', + stateMutability: 'view', + inputs: [{ name: 'adapter', type: 'address' }], + outputs: [{ name: '', type: 'bool' }], + }, + { + type: 'function', + name: 'setAdapterAllowed', + stateMutability: 'nonpayable', + inputs: [ + { name: 'adapter', type: 'address' }, + { name: 'allowed', type: 'bool' }, + ], + outputs: [], + }, + { + type: 'function', + name: 'transferGovernance', + stateMutability: 'nonpayable', + inputs: [{ name: 'newGovernance', type: 'address' }], + outputs: [], + }, + { + type: 'function', + name: 'acceptGovernance', + stateMutability: 'nonpayable', + inputs: [], + outputs: [], + }, + // ------------------------------------------------------------------------- + // Batching & Permit2 forwarding (inherited Multicall_v4 / Permit2Forwarder) + // ------------------------------------------------------------------------- + { + type: 'function', + name: 'multicall', + stateMutability: 'payable', + inputs: [{ name: 'data', type: 'bytes[]' }], + outputs: [{ name: 'results', type: 'bytes[]' }], + }, + { + type: 'function', + name: 'permit', + stateMutability: 'payable', + inputs: [ + { name: 'owner', type: 'address' }, + { + name: 'permitSingle', + type: 'tuple', + components: [ + { + name: 'details', + type: 'tuple', + components: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint160' }, + { name: 'expiration', type: 'uint48' }, + { name: 'nonce', type: 'uint48' }, + ], + }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' }, + ], + }, + { name: 'signature', type: 'bytes' }, + ], + outputs: [{ name: 'err', type: 'bytes' }], + }, + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + { + type: 'event', + name: 'PositionIncreased', + inputs: [ + { name: 'owner', type: 'address', indexed: true }, + { name: 'account', type: 'address', indexed: true }, + { name: 'collateral', type: 'address', indexed: false }, + { name: 'debt', type: 'address', indexed: false }, + { name: 'equity', type: 'uint256', indexed: false }, + { name: 'collateralBought', type: 'uint256', indexed: false }, + { name: 'debtDrawn', type: 'uint256', indexed: false }, + { name: 'collateralTotal', type: 'uint256', indexed: false }, + { name: 'debtTotal', type: 'uint256', indexed: false }, + { name: 'currentLtv', type: 'uint256', indexed: false }, + { name: 'maxLtv', type: 'uint256', indexed: false }, + { name: 'healthFactorWad', type: 'uint256', indexed: false }, + ], + }, + { + type: 'event', + name: 'PositionDecreased', + inputs: [ + { name: 'owner', type: 'address', indexed: true }, + { name: 'account', type: 'address', indexed: true }, + { name: 'collateral', type: 'address', indexed: false }, + { name: 'debt', type: 'address', indexed: false }, + { name: 'debtRepaid', type: 'uint256', indexed: false }, + { name: 'collateralWithdrawn', type: 'uint256', indexed: false }, + { name: 'collateralReturned', type: 'uint256', indexed: false }, + { name: 'collateralTotal', type: 'uint256', indexed: false }, + { name: 'debtTotal', type: 'uint256', indexed: false }, + { name: 'currentLtv', type: 'uint256', indexed: false }, + { name: 'healthFactorWad', type: 'uint256', indexed: false }, + ], + }, + { + type: 'event', + name: 'CollateralAdded', + inputs: [ + { name: 'owner', type: 'address', indexed: true }, + { name: 'account', type: 'address', indexed: true }, + { name: 'collateral', type: 'address', indexed: false }, + { name: 'amount', type: 'uint256', indexed: false }, + { name: 'collateralTotal', type: 'uint256', indexed: false }, + { name: 'debtTotal', type: 'uint256', indexed: false }, + { name: 'currentLtv', type: 'uint256', indexed: false }, + { name: 'healthFactorWad', type: 'uint256', indexed: false }, + ], + }, + { + type: 'event', + name: 'AccountCreated', + inputs: [ + { name: 'owner', type: 'address', indexed: true }, + { name: 'account', type: 'address', indexed: true }, + { name: 'subId', type: 'uint256', indexed: false }, + ], + }, + { + type: 'event', + name: 'AdapterAllowed', + inputs: [ + { name: 'adapter', type: 'address', indexed: true }, + { name: 'allowed', type: 'bool', indexed: false }, + ], + }, + { + type: 'event', + name: 'GovernanceTransferStarted', + inputs: [ + { name: 'currentGovernance', type: 'address', indexed: true }, + { name: 'pendingGovernance', type: 'address', indexed: true }, + ], + }, + { + type: 'event', + name: 'GovernanceTransferred', + inputs: [ + { name: 'previousGovernance', type: 'address', indexed: true }, + { name: 'newGovernance', type: 'address', indexed: true }, + ], + }, + // ------------------------------------------------------------------------- + // Errors + // ------------------------------------------------------------------------- + { type: 'error', name: 'DeadlinePassed', inputs: [{ name: 'deadline', type: 'uint256' }] }, + { type: 'error', name: 'SlippageBoundRequired', inputs: [] }, + { type: 'error', name: 'PositionUnhealthy', inputs: [] }, + { type: 'error', name: 'AdapterNotAllowed', inputs: [{ name: 'adapter', type: 'address' }] }, + { type: 'error', name: 'NativeCollateralMismatch', inputs: [] }, + { + type: 'error', + name: 'IncompleteFill', + inputs: [ + { name: 'requested', type: 'uint256' }, + { name: 'received', type: 'uint256' }, + ], + }, + { type: 'error', name: 'NoActiveAccount', inputs: [] }, + { type: 'error', name: 'MarketSwapMismatch', inputs: [] }, + { type: 'error', name: 'UnsupportedAction', inputs: [{ name: 'action', type: 'uint256' }] }, + { + type: 'error', + name: 'V4TooLittleReceived', + inputs: [ + { name: 'minAmountOutReceived', type: 'uint256' }, + { name: 'amountReceived', type: 'uint256' }, + ], + }, + { + type: 'error', + name: 'V4TooMuchRequested', + inputs: [ + { name: 'maxAmountInRequested', type: 'uint256' }, + { name: 'amountRequested', type: 'uint256' }, + ], + }, + { + type: 'error', + name: 'V4TooMuchRequestedPerHopSingle', + inputs: [ + { name: 'minPrice', type: 'uint256' }, + { name: 'priceX36', type: 'uint256' }, + ], + }, +] as const satisfies Abi + +/** + * The venue-agnostic `ILendingAdapter` read surface plus the shared two-step ownership handoff. + * Identical across the Morpho Blue, Aave v3, and Aave v4 adapters β€” only the adapter address (and + * the `setMarket` governance signature, see the per-venue ABIs) changes. + */ +export const LENDING_ADAPTER_ABI = [ + { + type: 'function', + name: 'lendingProtocol', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'address' }], + }, + { + type: 'function', + name: 'isSupportedMarket', + stateMutability: 'view', + inputs: [{ name: 'market', type: 'tuple', components: MARKET_COMPONENTS }], + outputs: [{ name: '', type: 'bool' }], + }, + { + type: 'function', + name: 'positionOf', + stateMutability: 'view', + inputs: [ + { name: 'account', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + ], + outputs: [ + { name: 'collateralAmount', type: 'uint256' }, + { name: 'debtAmount', type: 'uint256' }, + ], + }, + { + type: 'function', + name: 'maxLtvWad', + stateMutability: 'view', + inputs: [{ name: 'market', type: 'tuple', components: MARKET_COMPONENTS }], + outputs: [{ name: '', type: 'uint256' }], + }, + { + type: 'function', + name: 'currentLtvWad', + stateMutability: 'view', + inputs: [ + { name: 'account', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + ], + outputs: [{ name: '', type: 'uint256' }], + }, + { + type: 'function', + name: 'describePosition', + stateMutability: 'view', + inputs: [ + { name: 'account', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + ], + outputs: [ + { + name: 'data', + type: 'tuple', + components: [ + { name: 'collateralAmount', type: 'uint256' }, + { name: 'debtAmount', type: 'uint256' }, + { name: 'maxLtv', type: 'uint256' }, + { name: 'currentLtv', type: 'uint256' }, + { name: 'healthFactorWad', type: 'uint256' }, + ], + }, + ], + }, + // encode* views: the (target, value, callData) the account executes. Useful for building the + // owner escape-hatch `MarginAccount.execute(adapter, callData)` without protocol-specific code. + { + type: 'function', + name: 'encodeSupplyCollateral', + stateMutability: 'view', + inputs: [ + { name: 'account', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [ + { name: 'target', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'callData', type: 'bytes' }, + ], + }, + { + type: 'function', + name: 'encodeWithdrawCollateral', + stateMutability: 'view', + inputs: [ + { name: 'account', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + { name: 'amount', type: 'uint256' }, + { name: 'receiver', type: 'address' }, + ], + outputs: [ + { name: 'target', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'callData', type: 'bytes' }, + ], + }, + { + type: 'function', + name: 'encodeBorrow', + stateMutability: 'view', + inputs: [ + { name: 'account', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [ + { name: 'target', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'callData', type: 'bytes' }, + ], + }, + { + type: 'function', + name: 'encodeRepay', + stateMutability: 'view', + inputs: [ + { name: 'account', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [ + { name: 'target', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'callData', type: 'bytes' }, + ], + }, + // Two-step ownership (OwnableAdapter / Owner type) + { + type: 'function', + name: 'owner', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'address' }], + }, + { + type: 'function', + name: 'pendingOwner', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'address' }], + }, + { + type: 'function', + name: 'transferOwnership', + stateMutability: 'nonpayable', + inputs: [{ name: 'newOwner', type: 'address' }], + outputs: [], + }, + { + type: 'function', + name: 'acceptOwnership', + stateMutability: 'nonpayable', + inputs: [], + outputs: [], + }, + { + type: 'error', + name: 'MarketNotSupported', + inputs: [ + { name: 'collateral', type: 'address' }, + { name: 'debt', type: 'address' }, + ], + }, + { type: 'error', name: 'NotOwner', inputs: [{ name: 'caller', type: 'address' }] }, + { type: 'error', name: 'ZeroOwner', inputs: [] }, + { type: 'error', name: 'NotPendingOwner', inputs: [{ name: 'caller', type: 'address' }] }, +] as const satisfies Abi + +/** MorphoLendingAdapter extras: Morpho Blue `MarketParams`-keyed market curation. */ +export const MORPHO_LENDING_ADAPTER_ABI = [ + ...LENDING_ADAPTER_ABI, + { + type: 'function', + name: 'setMarket', + stateMutability: 'nonpayable', + inputs: [ + { + name: 'marketParams', + type: 'tuple', + components: [ + { name: 'loanToken', type: 'address' }, + { name: 'collateralToken', type: 'address' }, + { name: 'oracle', type: 'address' }, + { name: 'irm', type: 'address' }, + { name: 'lltv', type: 'uint256' }, + ], + }, + ], + outputs: [], + }, + { + type: 'event', + name: 'MarketSet', + inputs: [ + { name: 'id', type: 'bytes32', indexed: true }, + { name: 'collateral', type: 'address', indexed: true }, + { name: 'debt', type: 'address', indexed: true }, + { name: 'oracle', type: 'address', indexed: false }, + { name: 'irm', type: 'address', indexed: false }, + { name: 'lltv', type: 'uint256', indexed: false }, + ], + }, + { type: 'error', name: 'MorphoMarketNotCreated', inputs: [] }, +] as const satisfies Abi + +/** AaveLendingAdapter (Aave v3) extras: pair-allowlist market curation. */ +export const AAVE_LENDING_ADAPTER_ABI = [ + ...LENDING_ADAPTER_ABI, + { + type: 'function', + name: 'setMarket', + stateMutability: 'nonpayable', + inputs: [ + { name: 'collateral', type: 'address' }, + { name: 'debt', type: 'address' }, + { name: 'allowed', type: 'bool' }, + ], + outputs: [], + }, + { + type: 'event', + name: 'MarketSet', + inputs: [ + { name: 'collateral', type: 'address', indexed: true }, + { name: 'debt', type: 'address', indexed: true }, + { name: 'allowed', type: 'bool', indexed: false }, + ], + }, +] as const satisfies Abi + +/** AaveV4LendingAdapter extras: per-Spoke reserveId-routed market curation. */ +export const AAVE_V4_LENDING_ADAPTER_ABI = [ + ...LENDING_ADAPTER_ABI, + { + type: 'function', + name: 'setMarket', + stateMutability: 'nonpayable', + inputs: [ + { name: 'collateral', type: 'address' }, + { name: 'debt', type: 'address' }, + { name: 'collateralReserveId', type: 'uint256' }, + { name: 'debtReserveId', type: 'uint256' }, + { name: 'allowed', type: 'bool' }, + ], + outputs: [], + }, + { + type: 'event', + name: 'MarketSet', + inputs: [ + { name: 'collateral', type: 'address', indexed: true }, + { name: 'debt', type: 'address', indexed: true }, + { name: 'collateralReserveId', type: 'uint256', indexed: false }, + { name: 'debtReserveId', type: 'uint256', indexed: false }, + { name: 'allowed', type: 'bool', indexed: false }, + ], + }, + { + type: 'error', + name: 'ReserveMismatch', + inputs: [ + { name: 'reserveId', type: 'uint256' }, + { name: 'expected', type: 'address' }, + { name: 'actual', type: 'address' }, + ], + }, + { + type: 'error', + name: 'HubMismatch', + inputs: [ + { name: 'collateralHub', type: 'address' }, + { name: 'debtHub', type: 'address' }, + ], + }, +] as const satisfies Abi + +/** The per-user MarginAccount clone (fund-moving primitives + owner escape hatch). */ +export const MARGIN_ACCOUNT_ABI = [ + { + type: 'function', + name: 'owner', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'address' }], + }, + { + type: 'function', + name: 'manager', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'address' }], + }, + { + type: 'function', + name: 'supplyCollateral', + stateMutability: 'nonpayable', + inputs: [ + { name: 'adapter', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: 'supplied', type: 'uint256' }], + }, + { + type: 'function', + name: 'withdrawCollateral', + stateMutability: 'nonpayable', + inputs: [ + { name: 'adapter', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + { name: 'amount', type: 'uint256' }, + { name: 'to', type: 'address' }, + ], + outputs: [{ name: 'withdrawn', type: 'uint256' }], + }, + { + type: 'function', + name: 'borrow', + stateMutability: 'nonpayable', + inputs: [ + { name: 'adapter', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + { name: 'amount', type: 'uint256' }, + { name: 'to', type: 'address' }, + ], + outputs: [{ name: 'borrowed', type: 'uint256' }], + }, + { + type: 'function', + name: 'repay', + stateMutability: 'nonpayable', + inputs: [ + { name: 'adapter', type: 'address' }, + { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: 'repaid', type: 'uint256' }], + }, + { + type: 'function', + name: 'sweep', + stateMutability: 'nonpayable', + inputs: [ + { name: 'currency', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'to', type: 'address' }, + ], + outputs: [], + }, + { + type: 'function', + name: 'execute', + stateMutability: 'payable', + inputs: [ + { name: 'adapter', type: 'address' }, + { name: 'adapterCall', type: 'bytes' }, + ], + outputs: [{ name: 'result', type: 'bytes' }], + }, + { + type: 'event', + name: 'CollateralSupplied', + inputs: [ + { name: 'caller', type: 'address', indexed: true }, + { name: 'adapter', type: 'address', indexed: true }, + { name: 'collateral', type: 'address', indexed: true }, + { name: 'amount', type: 'uint256', indexed: false }, + ], + }, + { + type: 'event', + name: 'CollateralWithdrawn', + inputs: [ + { name: 'caller', type: 'address', indexed: true }, + { name: 'adapter', type: 'address', indexed: true }, + { name: 'collateral', type: 'address', indexed: true }, + { name: 'amount', type: 'uint256', indexed: false }, + { name: 'to', type: 'address', indexed: false }, + ], + }, + { + type: 'event', + name: 'Borrowed', + inputs: [ + { name: 'caller', type: 'address', indexed: true }, + { name: 'adapter', type: 'address', indexed: true }, + { name: 'debt', type: 'address', indexed: true }, + { name: 'amount', type: 'uint256', indexed: false }, + { name: 'to', type: 'address', indexed: false }, + ], + }, + { + type: 'event', + name: 'Repaid', + inputs: [ + { name: 'caller', type: 'address', indexed: true }, + { name: 'adapter', type: 'address', indexed: true }, + { name: 'debt', type: 'address', indexed: true }, + { name: 'amount', type: 'uint256', indexed: false }, + ], + }, + { + type: 'event', + name: 'Swept', + inputs: [ + { name: 'caller', type: 'address', indexed: true }, + { name: 'currency', type: 'address', indexed: true }, + { name: 'amount', type: 'uint256', indexed: false }, + { name: 'to', type: 'address', indexed: false }, + ], + }, + { + type: 'event', + name: 'Executed', + inputs: [ + { name: 'caller', type: 'address', indexed: true }, + { name: 'adapter', type: 'address', indexed: true }, + { name: 'target', type: 'address', indexed: false }, + ], + }, + { type: 'error', name: 'NotAuthorized', inputs: [] }, + { type: 'error', name: 'ReceiverNotAllowed', inputs: [{ name: 'to', type: 'address' }] }, +] as const satisfies Abi + +/** Minimal Permit2 AllowanceTransfer surface used by the equity-funding flow. */ +export const PERMIT2_ABI = [ + { + type: 'function', + name: 'approve', + stateMutability: 'nonpayable', + inputs: [ + { name: 'token', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint160' }, + { name: 'expiration', type: 'uint48' }, + ], + outputs: [], + }, + { + type: 'function', + name: 'allowance', + stateMutability: 'view', + inputs: [ + { name: 'owner', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'spender', type: 'address' }, + ], + outputs: [ + { name: 'amount', type: 'uint160' }, + { name: 'expiration', type: 'uint48' }, + { name: 'nonce', type: 'uint48' }, + ], + }, +] as const satisfies Abi diff --git a/sdks/margin-sdk/src/addresses.test.ts b/sdks/margin-sdk/src/addresses.test.ts new file mode 100644 index 000000000..ac37af60c --- /dev/null +++ b/sdks/margin-sdk/src/addresses.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' +import { getAddress } from 'viem' + +import { MARGIN_ADDRESSES, getMarginAddresses } from './addresses' +import { SupportedChainId, isMarginSupportedChain } from './chains' +import { MarginSdkError, isMarginSdkError } from './errors' + +describe('addresses', () => { + test('every address is checksummed', () => { + for (const addresses of Object.values(MARGIN_ADDRESSES)) { + if (!addresses) continue + const flat = [ + addresses.marginRouter, + addresses.marginAccountImplementation, + addresses.permit2, + addresses.poolManager, + addresses.weth9, + ...Object.values(addresses.lendingAdapters), + ] + for (const address of flat) { + expect(address).toBe(getAddress(address)) + } + } + }) + + test('mainnet stack resolves with all three venues', () => { + const mainnet = getMarginAddresses(SupportedChainId.MAINNET) + expect(mainnet).toBeDefined() + expect(mainnet!.lendingAdapters.morphoBlue).toBeDefined() + expect(mainnet!.lendingAdapters.aaveV3).toBeDefined() + expect(mainnet!.lendingAdapters.aaveV4).toBeDefined() + expect(isMarginSupportedChain(1)).toBe(true) + }) + + test('undeployed chains resolve to undefined', () => { + expect(getMarginAddresses(8453)).toBeUndefined() + expect(isMarginSupportedChain(8453)).toBe(false) + }) +}) + +describe('errors', () => { + test('MarginSdkError carries a stable code and survives structural checks', () => { + const error = new MarginSdkError('INVALID_AMOUNT', 'nope') + expect(error.code).toBe('INVALID_AMOUNT') + expect(isMarginSdkError(error)).toBe(true) + expect(isMarginSdkError({ name: 'MarginSdkError', code: 'INVALID_AMOUNT' })).toBe(true) + expect(isMarginSdkError(new Error('nope'))).toBe(false) + }) +}) diff --git a/sdks/margin-sdk/src/addresses.ts b/sdks/margin-sdk/src/addresses.ts new file mode 100644 index 000000000..bfb7c70dd --- /dev/null +++ b/sdks/margin-sdk/src/addresses.ts @@ -0,0 +1,55 @@ +import { type Address, getAddress } from 'viem' + +import { SupportedChainId } from './chains' + +/** The lending venues integrated behind `ILendingAdapter` today. */ +export type LendingVenue = 'morphoBlue' | 'aaveV3' | 'aaveV4' + +/** + * Per-chain addresses of the margin trading stack. Keyed by numeric chain id. + */ +export interface MarginAddresses { + /** MarginRouter: the entry point and the manager of every MarginAccount it deploys. */ + marginRouter: Address + /** The MarginAccount implementation every account clone delegates to (CWIA template). */ + marginAccountImplementation: Address + /** + * Deployed lending adapters by venue. Each is a singleton encoder over a governed market + * routing table; the caller selects the venue per call by passing the matching adapter. The + * Aave v4 adapter is bound to a single Spoke β€” a second Spoke is a second adapter instance. + */ + lendingAdapters: Partial> + /** Permit2 (canonical address on every chain). Equity/collateral is pulled through it. */ + permit2: Address + /** The canonical Uniswap v4 PoolManager the leverage swaps run through. */ + poolManager: Address + /** WETH9. Native-ETH equity is wrapped to this; the market collateral must then be WETH. */ + weth9: Address +} + +const PERMIT2 = getAddress('0x000000000022D473030F116dDEE9F6B43aC78BA3') + +/** + * All deployed margin stacks, keyed by numeric chain id. Mainnet addresses are the + * `DeployMargin.s.sol` deployment documented in v4-periphery `docs/margin-trading.md`, verified + * onchain (router `accountImplementation()` / `manager()` / `isAdapterAllowed(...)` read back). + */ +export const MARGIN_ADDRESSES: Partial> = { + [SupportedChainId.MAINNET]: { + marginRouter: getAddress('0x0000000004BBC92D0657580CAe35aEBF054E5CDC'), + marginAccountImplementation: getAddress('0x83Fc96d2B162dAF8532e5677C6Ec32A1Cb7882E4'), + lendingAdapters: { + morphoBlue: getAddress('0x9A7f8F5A9496D3c9dc0BEEfb44cCaC17CAAF28fa'), + aaveV3: getAddress('0x8EeacdB24c7650478496845A61f03fF6BC263222'), + aaveV4: getAddress('0x3a9Cc5eEbAC911E5a316de1F2bCD166016d7469E'), + }, + permit2: PERMIT2, + poolManager: getAddress('0x000000000004444c5dc75cB358380D2e3dE08A90'), + weth9: getAddress('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'), + }, +} + +/** Returns the margin addresses for a chain, or `undefined` if the stack is not deployed there. */ +export function getMarginAddresses(chainId: number): MarginAddresses | undefined { + return MARGIN_ADDRESSES[chainId] +} diff --git a/sdks/margin-sdk/src/chains.ts b/sdks/margin-sdk/src/chains.ts new file mode 100644 index 000000000..82655a23c --- /dev/null +++ b/sdks/margin-sdk/src/chains.ts @@ -0,0 +1,17 @@ +/** + * Chains where the margin trading periphery is deployed. Values are numeric chain ids so the SDK + * stays framework-agnostic: callers map their own chain enum (e.g. sdk-core `ChainId`, a proto + * chain id, or wagmi's `chain.id`) to a number before calling in. + */ +export enum SupportedChainId { + MAINNET = 1, +} + +const SUPPORTED_CHAIN_IDS = new Set( + Object.values(SupportedChainId).filter((v): v is number => typeof v === 'number') +) + +/** Whether the margin stack is deployed on `chainId` (i.e. {@link getMarginAddresses} resolves). */ +export function isMarginSupportedChain(chainId: number): chainId is SupportedChainId { + return SUPPORTED_CHAIN_IDS.has(chainId) +} diff --git a/sdks/margin-sdk/src/constants.ts b/sdks/margin-sdk/src/constants.ts new file mode 100644 index 000000000..f6c17e3df --- /dev/null +++ b/sdks/margin-sdk/src/constants.ts @@ -0,0 +1,41 @@ +import { type Address } from 'viem' + +/** WAD fixed-point scale: 1e18 == 100% for `Ltv` values and 1x for `LeverageX18` values. */ +export const WAD = 10n ** 18n + +/** 1x leverage in WAD (the `LeverageX18` lower bound; sub-1x leverage is invalid). */ +export const ONE_X18 = WAD + +export const MAX_UINT256 = (1n << 256n) - 1n +export const MAX_UINT160 = (1n << 160n) - 1n +export const MAX_UINT128 = (1n << 128n) - 1n +export const MAX_UINT48 = (1n << 48n) - 1n + +/** + * `decreasePosition` sentinel: passing this as `debtToRepay` fully closes the position β€” repay all + * debt (by shares, avoiding interest dust), withdraw all collateral, and return the residual + * (realized PnL) to the caller. + */ +export const FULL_CLOSE = MAX_UINT256 + +/** + * v4 periphery `ActionConstants.OPEN_DELTA`: an encoded `0` amount that resolves to the full open + * balance/delta for the action. NOT honored by `PULL_TO_ACCOUNT`, where an encoded `0` reverts. + */ +export const OPEN_DELTA = 0n + +/** + * v4 periphery `ActionConstants.CONTRACT_BALANCE`: resolves to the router's entire balance of the + * currency. For `PULL_TO_ACCOUNT` it is honored only on the router-balance path (`payerIsUser` + * false). + */ +export const CONTRACT_BALANCE = 1n << 255n + +/** v4 periphery `ActionConstants.MSG_SENDER`: recipient sentinel mapping to the authenticated caller. */ +export const MSG_SENDER: Address = '0x0000000000000000000000000000000000000001' + +/** v4 periphery `ActionConstants.ADDRESS_THIS`: recipient sentinel mapping to the router itself. */ +export const ADDRESS_THIS: Address = '0x0000000000000000000000000000000000000002' + +/** Basis-points denominator (1 bps = 0.01%, 10_000 bps = 100%). */ +export const BPS_DENOMINATOR = 10_000n diff --git a/sdks/margin-sdk/src/errors.ts b/sdks/margin-sdk/src/errors.ts new file mode 100644 index 000000000..1084e9192 --- /dev/null +++ b/sdks/margin-sdk/src/errors.ts @@ -0,0 +1,48 @@ +/** + * Stable error codes for every input-validation failure the SDK can raise. This list is the single + * source of truth: the SDK owns the validation logic and the user-facing messages, and consumers + * **forward** these errors rather than re-authoring their own β€” catch with {@link isMarginSdkError}, + * then surface `error.message` and/or branch on `error.code`. + */ +export type MarginErrorCode = + | 'UNSUPPORTED_CHAIN' + | 'INVALID_LEVERAGE' + | 'INVALID_AMOUNT' + | 'AMOUNT_OVERFLOW' + | 'INVALID_SLIPPAGE' + | 'INVALID_MARKET' + | 'MARKET_MISMATCH' + | 'SLIPPAGE_BOUND_REQUIRED' + | 'INVALID_PLAN' + | 'INVALID_INPUT' + +/** + * Error type thrown by all SDK input validation. Carries a stable {@link MarginErrorCode} and a + * user-facing `message`. Consumers forward both. + */ +export class MarginSdkError extends Error { + readonly code: MarginErrorCode + + constructor(code: MarginErrorCode, message: string) { + super(message) + this.name = 'MarginSdkError' + this.code = code + // Restore prototype chain for instanceof across the tsβ†’es target downlevel. + Object.setPrototypeOf(this, MarginSdkError.prototype) + } +} + +/** + * Type guard for forwarding. Structural (checks `name` + `code`) rather than `instanceof` so it + * still holds across a dual cjs/esm install or bundling, where two copies of the class can + * otherwise defeat `instanceof`. + */ +export function isMarginSdkError(error: unknown): error is MarginSdkError { + return ( + error instanceof MarginSdkError || + (typeof error === 'object' && + error !== null && + (error as { name?: unknown }).name === 'MarginSdkError' && + typeof (error as { code?: unknown }).code === 'string') + ) +} diff --git a/sdks/margin-sdk/src/types.ts b/sdks/margin-sdk/src/types.ts new file mode 100644 index 000000000..b503475be --- /dev/null +++ b/sdks/margin-sdk/src/types.ts @@ -0,0 +1,153 @@ +import { type Address, type Hex } from 'viem' + +/** + * TypeScript mirrors of the onchain structs the margin flows encode. Field order and types match + * the deployed contracts exactly: + * - Market: v4-periphery `types/Market.sol` + * - PoolKey: v4-core `types/PoolKey.sol` + * - IncreaseParams / DecreaseParams / AddCollateralParams: v4-periphery `IMarginRouter.sol` + * - PositionData: v4-periphery `types/PositionData.sol` + * + * `Ltv` values are WAD-scaled bigints (1e18 == 100%); `LeverageX18` values are WAD multipliers + * (1e18 == 1x). Fields the contract documents as optional-with-zero-default are optional here and + * filled by the encoders. + */ + +/** + * The lending-protocol-agnostic market descriptor: the `(collateral, debt)` token pair. Direction + * is set entirely by the pairing β€” the position is long the collateral and short the debt. Margin + * markets are ERC-20 only (use WETH, never the native-ETH zero address). + */ +export interface Market { + /** The ERC-20 token supplied as collateral in the lending market. */ + collateral: Address + /** The ERC-20 token borrowed as debt in the lending market. */ + debt: Address +} + +/** The v4 pool descriptor the leverage swap routes through. `currency0 < currency1`. */ +export interface PoolKey { + currency0: Address + currency1: Address + /** uint24, hundredths of a bip. */ + fee: number + /** int24. */ + tickSpacing: number + /** address(0) for a hookless pool. */ + hooks: Address +} + +/** Parameters for `increasePosition` (open a position or add leverage to one). */ +export interface IncreaseParams { + /** The allowlisted lending adapter that selects the venue (Morpho Blue, Aave v3, Aave v4). */ + adapter: Address + /** The (collateral, debt) pair. This sets direction: long the collateral, short the debt. */ + market: Market + /** The v4 pool the leverage swap routes through; its currencies must equal the market pair. */ + poolKey: PoolKey + /** + * Collateral equity the caller contributes, in the collateral token's native decimals. Pulled + * via Permit2. Ignored when the call sends native ETH (`value > 0`) β€” pass 0 there. + */ + equity: bigint + /** uint128. The exact collateral to buy on the swap (exact-output side), in native decimals. */ + collateralToBuy: bigint + /** + * uint128. The mandatory, binding slippage bound: the absolute cap on debt spent as swap input, + * in the debt token's native decimals. Derive it from a quote, not spot price. + */ + maxDebtIn: bigint + /** Optional additional per-hop price bound (X36 fixed-point). Zero (default) disables it. */ + minHopPriceX36?: bigint + /** + * Optional resulting-LTV bound (WAD, 1e18 == 100%), asserted after the position settles. Zero + * (default) skips the check. + */ + maxLtvAfter?: bigint + /** Sub-account index; (caller, subId) determines the MarginAccount. Default 0. */ + subId?: bigint + /** Unix timestamp after which the call reverts `DeadlinePassed`. */ + deadline: bigint +} + +/** Parameters for `decreasePosition` (partial delever, or full close via {@link FULL_CLOSE}). */ +export interface DecreaseParams { + /** The lending adapter. Close/decrease never require the adapter to be allowlisted. */ + adapter: Address + /** The (collateral, debt) pair. */ + market: Market + /** The v4 pool the decrease swap routes through. */ + poolKey: PoolKey + /** + * The exact debt to repay (exact-output side of the swap), in the debt token's native decimals, + * or `FULL_CLOSE` (`type(uint256).max`) to fully close: repay all, withdraw all, return the + * residual to the caller. + */ + debtToRepay: bigint + /** + * uint128. The mandatory, binding slippage bound: the absolute cap on collateral sold, in the + * collateral token's native decimals. A zero-debt full close takes a swap-free path and ignores + * it. + */ + maxCollateralIn: bigint + /** Optional additional per-hop price bound (X36 fixed-point). Zero (default) disables it. */ + minHopPriceX36?: bigint + /** + * The maximum LTV the position may have after a partial decrease (WAD). Mandatory for a partial + * decrease; ignored on a full close. + */ + maxLtvAfter?: bigint + /** Sub-account index identifying which MarginAccount to decrease or close. Default 0. */ + subId?: bigint + /** Unix timestamp after which the call reverts `DeadlinePassed`. */ + deadline: bigint +} + +/** Parameters for `addCollateral` (supply collateral without changing debt; no swap). */ +export interface AddCollateralParams { + /** The allowlisted lending adapter. */ + adapter: Address + /** The (collateral, debt) pair. */ + market: Market + /** + * The collateral to add, in native decimals. Pulled via Permit2. Ignored when the call sends + * native ETH (`value > 0`) β€” pass 0 there. + */ + amount: bigint + /** Sub-account index. The account is deployed if it does not yet exist. Default 0. */ + subId?: bigint + /** Unix timestamp after which the call reverts `DeadlinePassed`. */ + deadline: bigint +} + +/** + * A consolidated position snapshot returned by `ILendingAdapter.describePosition`. Amounts are + * interest-accrued; there are no price fields (prices and liquidation prices are left to the + * offchain quoter). + */ +export interface PositionData { + /** Supplied collateral with accrued interest, in the collateral token's native decimals. */ + collateralAmount: bigint + /** Outstanding debt with accrued interest, in the debt token's native decimals. */ + debtAmount: bigint + /** The market's maximum (liquidation) LTV (WAD, 1e18 == 100%). */ + maxLtv: bigint + /** The position's current LTV (WAD); zero when there is no debt. */ + currentLtv: bigint + /** + * `maxLtv / currentLtv` in WAD (1e18 == 1.0; below 1e18 is liquidatable). `type(uint256).max` + * when there is no debt. + */ + healthFactorWad: bigint +} + +/** A single hop of a multi-hop v4 swap path (v4-periphery `libraries/PathKey.sol`). */ +export interface PathKey { + intermediateCurrency: Address + /** uint24. */ + fee: number + /** int24. */ + tickSpacing: number + hooks: Address + hookData: Hex +} From 3310c4b40b6c70e50f835ae5bc73a3b514c1624d Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 23 Jul 2026 19:19:19 -0400 Subject: [PATCH 03/17] feat(margin-sdk): add account derivation, market helpers, and sizing 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 --- sdks/margin-sdk/src/account.test.ts | 118 ++++++++++++++ sdks/margin-sdk/src/account.ts | 86 ++++++++++ sdks/margin-sdk/src/market.test.ts | 70 ++++++++ sdks/margin-sdk/src/market.ts | 80 ++++++++++ sdks/margin-sdk/src/math.test.ts | 183 +++++++++++++++++++++ sdks/margin-sdk/src/math.ts | 240 ++++++++++++++++++++++++++++ 6 files changed, 777 insertions(+) create mode 100644 sdks/margin-sdk/src/account.test.ts create mode 100644 sdks/margin-sdk/src/account.ts create mode 100644 sdks/margin-sdk/src/market.test.ts create mode 100644 sdks/margin-sdk/src/market.ts create mode 100644 sdks/margin-sdk/src/math.test.ts create mode 100644 sdks/margin-sdk/src/math.ts diff --git a/sdks/margin-sdk/src/account.test.ts b/sdks/margin-sdk/src/account.test.ts new file mode 100644 index 000000000..0454eb988 --- /dev/null +++ b/sdks/margin-sdk/src/account.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'bun:test' +import { type Address, keccak256 } from 'viem' + +import { + cloneInitCode, + getMarginAccountAddress, + marginAccountArgs, + marginAccountSalt, + predictMarginAccountAddress, +} from './account' +import { MARGIN_ADDRESSES } from './addresses' +import { SupportedChainId } from './chains' +import { MarginSdkError } from './errors' + +const MAINNET = MARGIN_ADDRESSES[SupportedChainId.MAINNET]! +const ROUTER = MAINNET.marginRouter +const IMPL = MAINNET.marginAccountImplementation + +/** + * Ground truth: `MarginRouter.accountOf(owner, subId)` read from the live mainnet router + * (0x0000000004BBC92D0657580CAe35aEBF054E5CDC) on 2026-07-23. + */ +const ONCHAIN_VECTORS: ReadonlyArray<[Address, bigint, Address]> = [ + ['0x0000000000000000000000000000000000000001', 0n, '0x64487fb85302b5A2f38EF91144155986D331D2Fe'], + ['0x0000000000000000000000000000000000000001', 1n, '0x823C9d821fEfF5cB48e29356047efaeE01E8f52C'], + ['0x0000000000000000000000000000000000000001', 42n, '0x9E70eB12fEdf4854B0E5E76463b16b44577e5e30'], + ['0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', 0n, '0x9DEC18Fa954B9336421acBF8c6bc1E01434955Ed'], + ['0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', 1n, '0xf11Fb98E85EEF933C7467cDDD875b5841426799d'], + ['0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', 42n, '0x506Ec41dF068255352B94C63A120d35aFfF37966'], + ['0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', 0n, '0xdC5dD9910A8964bA49a0971f4D70a213Fb94ada6'], + ['0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', 1n, '0x175A5D0E0de01718240eCBF8d0Bf9696192e9A2e'], + ['0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', 42n, '0xE9a97Ebfa7E0184CF4373E0Bc75cAD00E48fe86c'], +] + +describe('predictMarginAccountAddress', () => { + test('matches the live mainnet router accountOf for every vector', () => { + for (const [owner, subId, expected] of ONCHAIN_VECTORS) { + expect(predictMarginAccountAddress({ owner, subId, marginRouter: ROUTER, accountImplementation: IMPL })).toBe( + expected + ) + } + }) + + test('getMarginAccountAddress resolves the mainnet deployment', () => { + expect(getMarginAccountAddress(1, '0x0000000000000000000000000000000000000001', 42n)).toBe( + '0x9E70eB12fEdf4854B0E5E76463b16b44577e5e30' + ) + }) + + test('getMarginAccountAddress defaults subId to 0', () => { + expect(getMarginAccountAddress(1, '0x0000000000000000000000000000000000000001')).toBe( + '0x64487fb85302b5A2f38EF91144155986D331D2Fe' + ) + }) + + test('getMarginAccountAddress throws UNSUPPORTED_CHAIN off-deployment', () => { + expect(() => getMarginAccountAddress(84532, '0x0000000000000000000000000000000000000001')).toThrow(MarginSdkError) + }) + + test('addresses are distinct per owner and per subId', () => { + const a = predictMarginAccountAddress({ + owner: '0x0000000000000000000000000000000000000001', + subId: 0n, + marginRouter: ROUTER, + accountImplementation: IMPL, + }) + const b = predictMarginAccountAddress({ + owner: '0x0000000000000000000000000000000000000001', + subId: 1n, + marginRouter: ROUTER, + accountImplementation: IMPL, + }) + const c = predictMarginAccountAddress({ + owner: '0x0000000000000000000000000000000000000002', + subId: 0n, + marginRouter: ROUTER, + accountImplementation: IMPL, + }) + expect(new Set([a, b, c]).size).toBe(3) + }) +}) + +describe('CWIA building blocks', () => { + const OWNER: Address = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' + + test('args are abi.encode(owner, manager)', () => { + const args = marginAccountArgs(OWNER, ROUTER) + expect(args).toBe( + `0x${'0'.repeat(24)}${OWNER.slice(2).toLowerCase()}${'0'.repeat(24)}${ROUTER.slice(2).toLowerCase()}` + ) + }) + + test('salt is keccak256(abi.encode(owner, manager, subId))', () => { + const salt = marginAccountSalt(OWNER, ROUTER, 7n) + expect(salt).toBe( + keccak256( + `0x${'0'.repeat(24)}${OWNER.slice(2).toLowerCase()}${'0'.repeat(24)}${ROUTER.slice( + 2 + ).toLowerCase()}${'0'.repeat(63)}7` + ) + ) + }) + + test('initcode is the Solady CWIA layout with a 0x2d+args runtime length', () => { + const args = marginAccountArgs(OWNER, ROUTER) + const initCode = cloneInitCode(IMPL, args) + // 20-byte prologue + 20-byte implementation + 15-byte suffix + 64-byte args + expect((initCode.length - 2) / 2).toBe(20 + 20 + 15 + 64) + // runtime length = 0x2d + 64 = 0x6d, PUSH2-encoded after the 0x61 opcode + expect(initCode.slice(0, 8)).toBe('0x61006d') + expect(initCode.toLowerCase()).toContain(IMPL.slice(2).toLowerCase()) + expect(initCode.toLowerCase().endsWith(args.slice(2).toLowerCase())).toBe(true) + }) + + test('rejects oversized immutable args', () => { + expect(() => cloneInitCode(IMPL, `0x${'00'.repeat(0xffd3)}`)).toThrow(MarginSdkError) + }) +}) diff --git a/sdks/margin-sdk/src/account.ts b/sdks/margin-sdk/src/account.ts new file mode 100644 index 000000000..fe3509ab0 --- /dev/null +++ b/sdks/margin-sdk/src/account.ts @@ -0,0 +1,86 @@ +import { type Address, type Hex, concatHex, encodeAbiParameters, getAddress, keccak256, numberToHex } from 'viem' + +import { getMarginAddresses } from './addresses' +import { MarginSdkError } from './errors' + +/** + * Offchain mirror of `MarginRouter.accountOf`: the deterministic MarginAccount address for an + * `(owner, subId)` pair, computable without an RPC. Accounts are Solady clone-with-immutable-args + * (CWIA) CREATE2 deploys with `(owner, manager)` baked into the clone bytecode and a salt binding + * `(owner, manager, subId)`, so the address is a pure function of those inputs. Verified against + * the live mainnet router's `accountOf` (see account.test.ts). + */ + +/** The immutable args baked into an account clone: `abi.encode(owner, manager)`. */ +export function marginAccountArgs(owner: Address, manager: Address): Hex { + return encodeAbiParameters([{ type: 'address' }, { type: 'address' }], [owner, manager]) +} + +/** The CREATE2 salt for an account: `keccak256(abi.encode(owner, manager, subId))`. */ +export function marginAccountSalt(owner: Address, manager: Address, subId: bigint): Hex { + return keccak256( + encodeAbiParameters([{ type: 'address' }, { type: 'address' }, { type: 'uint256' }], [owner, manager, subId]) + ) +} + +/** + * The Solady CWIA initcode for a clone of `implementation` carrying `args`: + * `0x61{0x2d+len(args):2} 3d81600a3d39f3 363d3d373d3d3d363d73 {implementation} 5af43d82803e903d91602b57fd5bf3 {args}` + * (creation prologue, runtime prefix, delegate target, runtime suffix, immutable args). + */ +export function cloneInitCode(implementation: Address, args: Hex): Hex { + const argsLength = (args.length - 2) / 2 + // Solady reverts deployment when args exceed 0xffff - 0x2d; mirror that bound here. + if (!Number.isInteger(argsLength) || argsLength > 0xffd2) { + throw new MarginSdkError('INVALID_INPUT', `invalid immutable args length: ${argsLength}`) + } + return concatHex([ + '0x61', + numberToHex(0x2d + argsLength, { size: 2 }), + '0x3d81600a3d39f3363d3d373d3d3d363d73', + implementation, + '0x5af43d82803e903d91602b57fd5bf3', + args, + ]) +} + +export interface PredictAccountParams { + /** The position owner (the address that calls the router entry points). */ + owner: Address + /** The sub-account index (one owner can hold many independent positions). */ + subId: bigint + /** The MarginRouter: both the CREATE2 deployer and the manager baked into the clone. */ + marginRouter: Address + /** The MarginAccount implementation the clone delegates to. */ + accountImplementation: Address +} + +/** + * Predicts the MarginAccount address for `(owner, subId)` under a given router deployment, + * whether or not the account has been deployed yet. Equivalent to `router.accountOf(owner, subId)`. + */ +export function predictMarginAccountAddress(params: PredictAccountParams): Address { + const { owner, subId, marginRouter, accountImplementation } = params + const initCodeHash = keccak256(cloneInitCode(accountImplementation, marginAccountArgs(owner, marginRouter))) + const digest = keccak256( + concatHex(['0xff', marginRouter, marginAccountSalt(owner, marginRouter, subId), initCodeHash]) + ) + return getAddress(`0x${digest.slice(26)}`) +} + +/** + * Chain-aware convenience over {@link predictMarginAccountAddress} using the canonical deployment + * addresses for `chainId`. Throws `UNSUPPORTED_CHAIN` where the margin stack is not deployed. + */ +export function getMarginAccountAddress(chainId: number, owner: Address, subId = 0n): Address { + const addresses = getMarginAddresses(chainId) + if (!addresses) { + throw new MarginSdkError('UNSUPPORTED_CHAIN', `margin trading is not deployed on chain ${chainId}`) + } + return predictMarginAccountAddress({ + owner, + subId, + marginRouter: addresses.marginRouter, + accountImplementation: addresses.marginAccountImplementation, + }) +} diff --git a/sdks/margin-sdk/src/market.test.ts b/sdks/margin-sdk/src/market.test.ts new file mode 100644 index 000000000..dbc5b707b --- /dev/null +++ b/sdks/margin-sdk/src/market.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' +import { type Address } from 'viem' + +import { MarginSdkError } from './errors' +import { + marketHasCurrencies, + poolKeyMatchesMarket, + sortsBefore, + swapZeroForOne, + toPoolKey, + validateMarket, +} from './market' +import { type PoolKey } from './types' + +const WETH: Address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' +const USDC: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' +const ZERO: Address = '0x0000000000000000000000000000000000000000' +const OTHER: Address = '0x9A7f8F5A9496D3c9dc0BEEfb44cCaC17CAAF28fa' + +const LONG = { collateral: WETH, debt: USDC } // long ETH +const SHORT = { collateral: USDC, debt: WETH } // short ETH +const POOL: PoolKey = { currency0: USDC, currency1: WETH, fee: 3000, tickSpacing: 60, hooks: ZERO } + +describe('toPoolKey', () => { + test('sorts currencies canonically regardless of input order', () => { + expect(toPoolKey({ currencyA: WETH, currencyB: USDC, fee: 3000, tickSpacing: 60 })).toEqual(POOL) + expect(toPoolKey({ currencyA: USDC, currencyB: WETH, fee: 3000, tickSpacing: 60 })).toEqual(POOL) + expect(sortsBefore(USDC, WETH)).toBe(true) + }) + + test('rejects identical currencies', () => { + expect(() => toPoolKey({ currencyA: WETH, currencyB: WETH, fee: 3000, tickSpacing: 60 })).toThrow(MarginSdkError) + }) +}) + +describe('market validation', () => { + test('accepts long and short pairings, matches pools order-insensitively', () => { + validateMarket(LONG) + validateMarket(SHORT) + expect(poolKeyMatchesMarket(POOL, LONG)).toBe(true) + expect(poolKeyMatchesMarket(POOL, SHORT)).toBe(true) + expect(marketHasCurrencies(LONG, USDC, WETH)).toBe(true) + expect(marketHasCurrencies(LONG, USDC, OTHER)).toBe(false) + }) + + test('rejects native-ETH and self-pairs', () => { + expect(() => validateMarket({ collateral: ZERO, debt: USDC })).toThrow(MarginSdkError) + expect(() => validateMarket({ collateral: WETH, debt: WETH })).toThrow(MarginSdkError) + }) +}) + +describe('swapZeroForOne (Market.toSwapParams direction mirror)', () => { + test('long open sells USDC debt: USDC is currency0 β†’ zeroForOne', () => { + expect(swapZeroForOne(LONG, USDC, POOL)).toBe(true) + }) + + test('long close sells WETH collateral: WETH is currency1 β†’ !zeroForOne', () => { + expect(swapZeroForOne(LONG, WETH, POOL)).toBe(false) + }) + + test('short open sells WETH debt β†’ !zeroForOne', () => { + expect(swapZeroForOne(SHORT, WETH, POOL)).toBe(false) + }) + + test('rejects a pool/market mismatch and a non-market input (MarketSwapMismatch mirror)', () => { + const wrongPool: PoolKey = { ...POOL, currency0: OTHER } + expect(() => swapZeroForOne(LONG, USDC, wrongPool)).toThrow(MarginSdkError) + expect(() => swapZeroForOne(LONG, OTHER, POOL)).toThrow(MarginSdkError) + }) +}) diff --git a/sdks/margin-sdk/src/market.ts b/sdks/margin-sdk/src/market.ts new file mode 100644 index 000000000..0be19fe5e --- /dev/null +++ b/sdks/margin-sdk/src/market.ts @@ -0,0 +1,80 @@ +import { type Address, isAddressEqual, zeroAddress } from 'viem' + +import { MarginSdkError } from './errors' +import { type Market, type PoolKey } from './types' + +/** + * Market and pool-key helpers mirroring the onchain `Market` type: the single choke point that + * reconciles a v4 pool with a `(collateral, debt)` market and derives swap direction. + */ + +/** Whether `a` sorts before `b` under v4's canonical currency ordering (numeric address order). */ +export function sortsBefore(a: Address, b: Address): boolean { + return BigInt(a) < BigInt(b) +} + +/** + * Builds a canonically-ordered v4 `PoolKey` from an unordered currency pair. Defaults to a + * hookless pool. + */ +export function toPoolKey(p: { + currencyA: Address + currencyB: Address + fee: number + tickSpacing: number + hooks?: Address +}): PoolKey { + if (isAddressEqual(p.currencyA, p.currencyB)) { + throw new MarginSdkError('INVALID_MARKET', 'pool currencies must be distinct') + } + const [currency0, currency1] = sortsBefore(p.currencyA, p.currencyB) + ? [p.currencyA, p.currencyB] + : [p.currencyB, p.currencyA] + return { currency0, currency1, fee: p.fee, tickSpacing: p.tickSpacing, hooks: p.hooks ?? zeroAddress } +} + +/** Validates a market: distinct, non-zero ERC-20 addresses (native ETH is not a margin currency). */ +export function validateMarket(market: Market): void { + if (isAddressEqual(market.collateral, zeroAddress) || isAddressEqual(market.debt, zeroAddress)) { + throw new MarginSdkError( + 'INVALID_MARKET', + 'margin markets are ERC-20 only: use WETH, not the native-ETH zero address' + ) + } + if (isAddressEqual(market.collateral, market.debt)) { + throw new MarginSdkError('INVALID_MARKET', 'market collateral and debt must be distinct tokens') + } +} + +/** + * True iff the unordered pair `{a, b}` equals the market's `{collateral, debt}` pair + * (order-insensitive), mirroring `Market.hasCurrencies`. + */ +export function marketHasCurrencies(market: Market, a: Address, b: Address): boolean { + return ( + (isAddressEqual(a, market.collateral) && isAddressEqual(b, market.debt)) || + (isAddressEqual(a, market.debt) && isAddressEqual(b, market.collateral)) + ) +} + +/** Whether the pool trades exactly the market's two currencies (order-independent). */ +export function poolKeyMatchesMarket(poolKey: PoolKey, market: Market): boolean { + return marketHasCurrencies(market, poolKey.currency0, poolKey.currency1) +} + +/** + * Mirrors `Market.toSwapParams` direction derivation: the `zeroForOne` flag for a swap that sells + * `input` through `poolKey`. Open/increase flows sell the market's debt (buy collateral); + * close/decrease flows sell the collateral (buy debt to repay). Throws `MARKET_MISMATCH` when the + * pool's currencies are not the market pair or `input` is not one of the market's currencies β€” + * the same condition the contract rejects with `MarketSwapMismatch`. + */ +export function swapZeroForOne(market: Market, input: Address, poolKey: PoolKey): boolean { + if (!poolKeyMatchesMarket(poolKey, market)) { + throw new MarginSdkError('MARKET_MISMATCH', 'pool currencies do not match the market (collateral, debt) pair') + } + if (!isAddressEqual(input, market.collateral) && !isAddressEqual(input, market.debt)) { + throw new MarginSdkError('MARKET_MISMATCH', 'swap input must be one of the market currencies') + } + return isAddressEqual(input, poolKey.currency0) +} diff --git a/sdks/margin-sdk/src/math.test.ts b/sdks/margin-sdk/src/math.test.ts new file mode 100644 index 000000000..3689305e2 --- /dev/null +++ b/sdks/margin-sdk/src/math.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from 'bun:test' +import { parseUnits } from 'viem' + +import { MAX_UINT128, MAX_UINT256, WAD } from './constants' +import { MarginSdkError } from './errors' +import { + collateralToBuyForLeverage, + estimateLtv, + healthFactor, + impliedLtv, + leverageForLtv, + parseLeverageX18, + quoteCollateralForDebt, + quoteDebtForCollateral, + sizeDecrease, + sizeIncrease, + toUint128, + totalExposure, + withSlippageDown, + withSlippageUp, +} from './math' + +describe('parseLeverageX18', () => { + test('parses numbers and strings', () => { + expect(parseLeverageX18(2)).toBe(2n * WAD) + expect(parseLeverageX18('2.5')).toBe(25n * 10n ** 17n) + expect(parseLeverageX18(1)).toBe(WAD) + }) + + test('rejects sub-1x leverage (LeverageBelowOne mirror)', () => { + expect(() => parseLeverageX18(0.5)).toThrow(MarginSdkError) + expect(() => parseLeverageX18('0.99')).toThrow(MarginSdkError) + expect(() => parseLeverageX18(NaN)).toThrow(MarginSdkError) + }) +}) + +describe('leverage & exposure', () => { + const equity = parseUnits('1', 18) // 1 WETH + + test('2x doubles exposure and buys equity-worth of collateral', () => { + expect(totalExposure(equity, 2n * WAD)).toBe(2n * equity) + expect(collateralToBuyForLeverage(equity, 2n * WAD)).toBe(equity) + }) + + test('1x buys nothing', () => { + expect(collateralToBuyForLeverage(equity, WAD)).toBe(0n) + }) + + test('impliedLtv: 2xβ‰ˆ50%, 3xβ‰ˆ67%, 4xβ‰ˆ75%', () => { + expect(impliedLtv(2n * WAD)).toBe(5n * 10n ** 17n) + expect(impliedLtv(3n * WAD)).toBe(666666666666666666n) + expect(impliedLtv(4n * WAD)).toBe(75n * 10n ** 16n) + expect(impliedLtv(WAD)).toBe(0n) + }) + + test('leverageForLtv inverts impliedLtv', () => { + expect(leverageForLtv(5n * 10n ** 17n)).toBe(2n * WAD) + expect(leverageForLtv(75n * 10n ** 16n)).toBe(4n * WAD) + // the mainnet Morpho WETH/USDC LLTV: 86% β†’ ~7.14x + expect(leverageForLtv(86n * 10n ** 16n)).toBe(7142857142857142857n) + expect(() => leverageForLtv(WAD)).toThrow(MarginSdkError) + }) + + test('healthFactor mirrors describePosition semantics', () => { + expect(healthFactor(86n * 10n ** 16n, 43n * 10n ** 16n)).toBe(2n * WAD) + expect(healthFactor(86n * 10n ** 16n, 0n)).toBe(MAX_UINT256) + }) +}) + +describe('quotes & slippage (decimal-aware)', () => { + test('long quote: WETH amount β†’ USDC cost at 3000 USDC/WETH', () => { + expect( + quoteDebtForCollateral({ + collateralAmount: parseUnits('1', 18), + priceDebtPerCollateralToken: parseUnits('3000', 6), + collateralDecimals: 18, + }) + ).toBe(parseUnits('3000', 6)) + }) + + test('short quote (reversed decimals): USDC amount β†’ WETH cost at 1/3000 WETH/USDC', () => { + expect( + quoteCollateralForDebt({ + debtAmount: parseUnits('3000', 6), + priceCollateralPerDebtToken: parseUnits('0.000333333333333333', 18), + debtDecimals: 6, + }) + ).toBe(999999999999999000n) // β‰ˆ1 WETH, floor-rounded + }) + + test('slippage helpers', () => { + expect(withSlippageUp(10_000n, 50)).toBe(10_050n) + expect(withSlippageDown(10_000n, 50)).toBe(9_950n) + expect(() => withSlippageUp(1n, -1)).toThrow(MarginSdkError) + expect(() => withSlippageDown(1n, 10_001)).toThrow(MarginSdkError) + }) + + test('toUint128 bounds', () => { + expect(toUint128(MAX_UINT128)).toBe(MAX_UINT128) + expect(() => toUint128(MAX_UINT128 + 1n)).toThrow(MarginSdkError) + expect(() => toUint128(-1n)).toThrow(MarginSdkError) + }) +}) + +describe('sizeIncrease (docs Β§7.3 example)', () => { + test('1 WETH equity, 2x, 3000 USDC/WETH, 50 bps', () => { + const { collateralToBuy, maxDebtIn, totalCollateral } = sizeIncrease({ + equity: parseUnits('1', 18), + leverageX18: parseLeverageX18(2), + priceDebtPerCollateralToken: parseUnits('3000', 6), + collateralDecimals: 18, + slippageBps: 50, + }) + expect(collateralToBuy).toBe(parseUnits('1', 18)) + expect(maxDebtIn).toBe(parseUnits('3015', 6)) + expect(totalCollateral).toBe(parseUnits('2', 18)) + }) + + test('short ETH sizing (6-decimal collateral, 18-decimal debt)', () => { + // 3000 USDC equity at 2x: buy 3000 more USDC, paying WETH at 3000 USDC/WETH β‰ˆ 1 WETH + const { collateralToBuy, maxDebtIn } = sizeIncrease({ + equity: parseUnits('3000', 6), + leverageX18: parseLeverageX18(2), + priceDebtPerCollateralToken: parseUnits('0.000333333333333333', 18), + collateralDecimals: 6, + slippageBps: 100, + }) + expect(collateralToBuy).toBe(parseUnits('3000', 6)) + expect(maxDebtIn).toBe(1009999999999998990n) // β‰ˆ1.01 WETH + }) + + test('rejects 1x (nothing to buy) and zero equity', () => { + expect(() => + sizeIncrease({ + equity: parseUnits('1', 18), + leverageX18: WAD, + priceDebtPerCollateralToken: parseUnits('3000', 6), + collateralDecimals: 18, + slippageBps: 50, + }) + ).toThrow(MarginSdkError) + expect(() => + sizeIncrease({ + equity: 0n, + leverageX18: 2n * WAD, + priceDebtPerCollateralToken: parseUnits('3000', 6), + collateralDecimals: 18, + slippageBps: 50, + }) + ).toThrow(MarginSdkError) + }) +}) + +describe('sizeDecrease', () => { + test('caps collateral sold for a debt repay plus headroom', () => { + const { maxCollateralIn } = sizeDecrease({ + debtToRepay: parseUnits('3000', 6), + priceCollateralPerDebtToken: parseUnits('0.000333333333333333', 18), + debtDecimals: 6, + slippageBps: 100, + }) + expect(maxCollateralIn).toBe(1009999999999998990n) + }) + + test('rejects zero debt', () => { + expect(() => + sizeDecrease({ debtToRepay: 0n, priceCollateralPerDebtToken: 1n, debtDecimals: 6, slippageBps: 0 }) + ).toThrow(MarginSdkError) + }) +}) + +describe('estimateLtv', () => { + test('2 WETH collateral, 3000 USDC debt at 3000 USDC/WETH β†’ 50%', () => { + expect( + estimateLtv({ + collateralAmount: parseUnits('2', 18), + debtAmount: parseUnits('3000', 6), + priceDebtPerCollateralToken: parseUnits('3000', 6), + collateralDecimals: 18, + }) + ).toBe(5n * 10n ** 17n) + }) +}) diff --git a/sdks/margin-sdk/src/math.ts b/sdks/margin-sdk/src/math.ts new file mode 100644 index 000000000..35d31abfd --- /dev/null +++ b/sdks/margin-sdk/src/math.ts @@ -0,0 +1,240 @@ +import { parseUnits } from 'viem' + +import { BPS_DENOMINATOR, MAX_UINT128, MAX_UINT256, ONE_X18, WAD } from './constants' +import { MarginSdkError } from './errors' + +/** + * Leverage, sizing, and health math for margin positions. All ratios are WAD-scaled bigints + * (`Ltv`: 1e18 == 100%; `LeverageX18`: 1e18 == 1x). All token amounts are in each token's native + * decimals β€” the sizing helpers take explicit decimals so longs (18d collateral / 6d debt) and + * shorts (6d collateral / 18d debt) use the same code. Prices for sizing must come from a real + * quote (a v4 quoter), not spot or the lending oracle; the lending market's oracle is for health, + * not for sizing the swap. + */ + +/** + * Parses a human leverage multiplier (`2`, `'2.5'`) into a WAD `LeverageX18`. Mirrors the onchain + * constructor's bound: sub-1x leverage is invalid. + */ +export function parseLeverageX18(leverage: number | string): bigint { + const asString = typeof leverage === 'number' ? leverage.toString() : leverage + if (typeof leverage === 'number' && !Number.isFinite(leverage)) { + throw new MarginSdkError('INVALID_LEVERAGE', `leverage must be a finite number, got ${leverage}`) + } + let x18: bigint + try { + x18 = parseUnits(asString, 18) + } catch { + throw new MarginSdkError('INVALID_LEVERAGE', `cannot parse leverage value: ${asString}`) + } + assertLeverageX18(x18) + return x18 +} + +/** Asserts a WAD leverage value is at least 1x (mirrors onchain `LeverageBelowOne`). */ +export function assertLeverageX18(leverageX18: bigint): void { + if (leverageX18 < ONE_X18) { + throw new MarginSdkError('INVALID_LEVERAGE', `leverage must be >= 1x (1e18), got ${leverageX18}`) + } +} + +/** + * The levered total exposure for `equity` at `leverageX18`: `equity * L / 1e18`, rounded down + * (mirrors onchain `LeverageX18.mulEquity`). Same units as `equity`. + */ +export function totalExposure(equity: bigint, leverageX18: bigint): bigint { + assertLeverageX18(leverageX18) + return (equity * leverageX18) / WAD +} + +/** + * The collateral to buy on the leverage swap for `equity` at `leverageX18`: + * `equity * (L - 1) / 1e18`. Same units as `equity` (the collateral token). + */ +export function collateralToBuyForLeverage(equity: bigint, leverageX18: bigint): bigint { + assertLeverageX18(leverageX18) + return (equity * (leverageX18 - ONE_X18)) / WAD +} + +/** + * The oracle-price-independent LTV a fresh position lands at for a target leverage: + * `(L - 1) / L` in WAD. 2x β‰ˆ 50%, 3x β‰ˆ 67%, 4x β‰ˆ 75%. + */ +export function impliedLtv(leverageX18: bigint): bigint { + assertLeverageX18(leverageX18) + return ((leverageX18 - ONE_X18) * WAD) / leverageX18 +} + +/** + * The inverse of {@link impliedLtv}: the leverage implied by an LTV, `1 / (1 - ltv)` in WAD. + * Useful for translating a market's max (liquidation) LTV into a max leverage: e.g. LLTV 86% β†’ + * ~7.14x (open below it β€” the position starts at the liquidation point otherwise). + */ +export function leverageForLtv(ltv: bigint): bigint { + if (ltv < 0n || ltv >= WAD) { + throw new MarginSdkError('INVALID_INPUT', `ltv must be in [0, 1e18), got ${ltv}`) + } + return (WAD * WAD) / (WAD - ltv) +} + +/** + * The position health factor `maxLtv / currentLtv` in WAD (1e18 == 1.0; below 1e18 is + * liquidatable), `type(uint256).max` when there is no debt β€” mirroring + * `ILendingAdapter.describePosition`. + */ +export function healthFactor(maxLtv: bigint, currentLtv: bigint): bigint { + if (currentLtv === 0n) return MAX_UINT256 + return (maxLtv * WAD) / currentLtv +} + +/** + * A pool-price estimate of a position's LTV: `debtValue / collateralValue` in WAD, valuing debt + * at `priceDebtPerCollateralToken` (debt-wei per one whole collateral token, i.e. + * `parseUnits(humanPrice, debtDecimals)`). The venue's oracle LTV (`currentLtvWad`) is + * authoritative for liquidation; this is for previews. + */ +export function estimateLtv(p: { + collateralAmount: bigint + debtAmount: bigint + priceDebtPerCollateralToken: bigint + collateralDecimals: number +}): bigint { + const collateralValueInDebt = + (p.collateralAmount * p.priceDebtPerCollateralToken) / 10n ** BigInt(p.collateralDecimals) + if (collateralValueInDebt === 0n) { + throw new MarginSdkError('INVALID_INPUT', 'collateral value is zero; LTV is undefined') + } + return (p.debtAmount * WAD) / collateralValueInDebt +} + +/** + * Converts a collateral amount into its debt-token cost at a quoted price: + * `collateralAmount * price / 10^collateralDecimals`, where `price` is debt-wei per one whole + * collateral token (`parseUnits(humanPrice, debtDecimals)`). Rounds down β€” apply slippage + * headroom before using it as a swap cap. + */ +export function quoteDebtForCollateral(p: { + collateralAmount: bigint + priceDebtPerCollateralToken: bigint + collateralDecimals: number +}): bigint { + return (p.collateralAmount * p.priceDebtPerCollateralToken) / 10n ** BigInt(p.collateralDecimals) +} + +/** + * Converts a debt amount into its collateral-token cost at a quoted price: + * `debtAmount * price / 10^debtDecimals`, where `price` is collateral-wei per one whole debt + * token (`parseUnits(humanPrice, collateralDecimals)`). + */ +export function quoteCollateralForDebt(p: { + debtAmount: bigint + priceCollateralPerDebtToken: bigint + debtDecimals: number +}): bigint { + return (p.debtAmount * p.priceCollateralPerDebtToken) / 10n ** BigInt(p.debtDecimals) +} + +/** `amount * (10_000 + slippageBps) / 10_000` β€” headroom for a maximum-input swap cap. */ +export function withSlippageUp(amount: bigint, slippageBps: number): bigint { + validateBps(slippageBps) + return (amount * (BPS_DENOMINATOR + BigInt(slippageBps))) / BPS_DENOMINATOR +} + +/** `amount * (10_000 - slippageBps) / 10_000` β€” a floor for a minimum-output expectation. */ +export function withSlippageDown(amount: bigint, slippageBps: number): bigint { + validateBps(slippageBps) + if (BigInt(slippageBps) > BPS_DENOMINATOR) { + throw new MarginSdkError('INVALID_SLIPPAGE', `slippage above 100% (${slippageBps} bps) floors to nothing`) + } + return (amount * (BPS_DENOMINATOR - BigInt(slippageBps))) / BPS_DENOMINATOR +} + +function validateBps(bps: number): void { + if (!Number.isInteger(bps) || bps < 0) { + throw new MarginSdkError('INVALID_SLIPPAGE', `slippage must be a non-negative integer bps value, got ${bps}`) + } +} + +/** Asserts an amount fits the contract's uint128 swap-amount fields. */ +export function toUint128(amount: bigint, label = 'amount'): bigint { + if (amount < 0n) throw new MarginSdkError('INVALID_AMOUNT', `${label} must be non-negative, got ${amount}`) + if (amount > MAX_UINT128) throw new MarginSdkError('AMOUNT_OVERFLOW', `${label} exceeds uint128: ${amount}`) + return amount +} + +/** + * Sizes an `increasePosition` swap from equity and target leverage: + * `collateralToBuy = equity * (L - 1) / 1e18` (the exact-output side) and + * `maxDebtIn = quote(collateralToBuy) + slippage` (the binding input cap). + * + * `priceDebtPerCollateralToken` is debt-wei per one whole collateral token from a real quote β€” + * e.g. a 3_000 USDC/WETH quote is `parseUnits('3000', 6)`. For a short the decimals reverse + * naturally: price in WETH-wei per whole USDC. + */ +export function sizeIncrease(p: { + /** Equity in the collateral token's native decimals. */ + equity: bigint + /** Target leverage as WAD (use {@link parseLeverageX18}). */ + leverageX18: bigint + /** Quoted price: debt-wei per one whole collateral token. */ + priceDebtPerCollateralToken: bigint + /** The collateral token's decimals (18 for WETH, 6 for USDC). */ + collateralDecimals: number + /** Slippage headroom in bps applied to the quoted debt cost. */ + slippageBps: number +}): { collateralToBuy: bigint; maxDebtIn: bigint; totalCollateral: bigint } { + if (p.equity <= 0n) { + throw new MarginSdkError('INVALID_AMOUNT', 'equity must be positive to size an increase from leverage') + } + const collateralToBuy = toUint128(collateralToBuyForLeverage(p.equity, p.leverageX18), 'collateralToBuy') + if (collateralToBuy === 0n) { + throw new MarginSdkError( + 'INVALID_AMOUNT', + 'leverage sizes to zero collateral to buy; use addCollateral for a 1x (unlevered) supply' + ) + } + const quoted = quoteDebtForCollateral({ + collateralAmount: collateralToBuy, + priceDebtPerCollateralToken: p.priceDebtPerCollateralToken, + collateralDecimals: p.collateralDecimals, + }) + const maxDebtIn = toUint128(withSlippageUp(quoted, p.slippageBps), 'maxDebtIn') + if (maxDebtIn === 0n) { + throw new MarginSdkError('INVALID_AMOUNT', 'quoted debt input rounds to zero; check the price scale and decimals') + } + return { collateralToBuy, maxDebtIn, totalCollateral: totalExposure(p.equity, p.leverageX18) } +} + +/** + * Sizes the collateral cap for a `decreasePosition` swap that must buy `debtToRepay` of debt: + * `maxCollateralIn = quote(debtToRepay) + slippage`. For a full close pass the position's current + * debt (read via `describePosition`) plus an interest-accrual buffer in `slippageBps` β€” debt + * accrues between the read and inclusion, and the close swap is sized onchain off the live total. + */ +export function sizeDecrease(p: { + /** The debt to repay, in the debt token's native decimals (current debt for a full close). */ + debtToRepay: bigint + /** Quoted price: collateral-wei per one whole debt token. */ + priceCollateralPerDebtToken: bigint + /** The debt token's decimals. */ + debtDecimals: number + /** Slippage (and, for closes, interest-accrual) headroom in bps. */ + slippageBps: number +}): { maxCollateralIn: bigint } { + if (p.debtToRepay <= 0n) { + throw new MarginSdkError('INVALID_AMOUNT', 'debtToRepay must be positive') + } + const quoted = quoteCollateralForDebt({ + debtAmount: p.debtToRepay, + priceCollateralPerDebtToken: p.priceCollateralPerDebtToken, + debtDecimals: p.debtDecimals, + }) + const maxCollateralIn = toUint128(withSlippageUp(quoted, p.slippageBps), 'maxCollateralIn') + if (maxCollateralIn === 0n) { + throw new MarginSdkError( + 'INVALID_AMOUNT', + 'quoted collateral input rounds to zero; check the price scale and decimals' + ) + } + return { maxCollateralIn } +} From 659f7c21be8284b942aa2ca7028a9ac4c1f13408 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 23 Jul 2026 19:19:30 -0400 Subject: [PATCH 04/17] feat(margin-sdk): add entry-point encoders and the execute-plan builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sdks/margin-sdk/src/actions.ts | 216 +++++++++++++++++ sdks/margin-sdk/src/encode.test.ts | 214 +++++++++++++++++ sdks/margin-sdk/src/encode.ts | 345 ++++++++++++++++++++++++++++ sdks/margin-sdk/src/planner.test.ts | 160 +++++++++++++ sdks/margin-sdk/src/planner.ts | 258 +++++++++++++++++++++ 5 files changed, 1193 insertions(+) create mode 100644 sdks/margin-sdk/src/actions.ts create mode 100644 sdks/margin-sdk/src/encode.test.ts create mode 100644 sdks/margin-sdk/src/encode.ts create mode 100644 sdks/margin-sdk/src/planner.test.ts create mode 100644 sdks/margin-sdk/src/planner.ts diff --git a/sdks/margin-sdk/src/actions.ts b/sdks/margin-sdk/src/actions.ts new file mode 100644 index 000000000..b63137010 --- /dev/null +++ b/sdks/margin-sdk/src/actions.ts @@ -0,0 +1,216 @@ +import { type AbiParameter } from 'viem' + +/** + * Action opcodes an `execute` plan can dispatch, and the ABI shape of each action's parameter + * blob. The router's interpreter is the inherited V4Router set (swap / settle / take) plus + * `SWEEP`/`WRAP`/`UNWRAP` (intercepted by MarginRouter with PositionManager-identical semantics) + * plus the margin opcodes at `0x30`+ (`0x1c`–`0x2f` is reserved for future core v4 actions). + * Opcodes the router does not handle revert `UnsupportedAction`. + */ + +/** v4 routing actions the MarginRouter interpreter supports (subset of v4-periphery `Actions`). */ +export enum V4RouterAction { + SWAP_EXACT_IN_SINGLE = 0x06, + SWAP_EXACT_IN = 0x07, + SWAP_EXACT_OUT_SINGLE = 0x08, + SWAP_EXACT_OUT = 0x09, + SETTLE = 0x0b, + SETTLE_ALL = 0x0c, + TAKE = 0x0e, + TAKE_ALL = 0x0f, + TAKE_PORTION = 0x10, + SWEEP = 0x14, + WRAP = 0x15, + UNWRAP = 0x16, +} + +/** Margin actions (v4-periphery `MarginActions`), occupying the distinct `0x30` opcode range. */ +export enum MarginAction { + /** Supply collateral from the active account. Allowlist-gated (exposure-increasing). */ + ACCOUNT_SUPPLY_COLLATERAL = 0x30, + /** Withdraw collateral from the active account's lending position. Never allowlist-gated. */ + ACCOUNT_WITHDRAW_COLLATERAL = 0x31, + /** Borrow debt against the active account. Allowlist-gated (exposure-increasing). */ + ACCOUNT_BORROW = 0x32, + /** Repay the active account's debt. Never allowlist-gated. */ + ACCOUNT_REPAY = 0x33, + /** Sweep a token balance out of the active account. Never allowlist-gated. */ + ACCOUNT_SWEEP = 0x34, + /** Assert the active account's LTV does not exceed a bound (`PositionUnhealthy` otherwise). */ + ASSERT_HEALTH = 0x35, + /** Assert an exact-output swap delivered the full amount (`IncompleteFill` otherwise). */ + ASSERT_FILL = 0x36, + /** Bind the active account (derived from the authenticated caller + subId, never calldata). */ + SET_ACCOUNT = 0x37, + /** Move a token into the active account (Permit2 pull or router balance). Zero amount reverts. */ + PULL_TO_ACCOUNT = 0x38, +} + +export type PlanAction = V4RouterAction | MarginAction + +const MARKET = { + type: 'tuple', + components: [ + { name: 'collateral', type: 'address' }, + { name: 'debt', type: 'address' }, + ], +} as const + +const POOL_KEY = { + type: 'tuple', + components: [ + { name: 'currency0', type: 'address' }, + { name: 'currency1', type: 'address' }, + { name: 'fee', type: 'uint24' }, + { name: 'tickSpacing', type: 'int24' }, + { name: 'hooks', type: 'address' }, + ], +} as const + +const PATH_KEY_COMPONENTS = [ + { name: 'intermediateCurrency', type: 'address' }, + { name: 'fee', type: 'uint24' }, + { name: 'tickSpacing', type: 'int24' }, + { name: 'hooks', type: 'address' }, + { name: 'hookData', type: 'bytes' }, +] as const + +/** + * ABI parameters for each action's encoded blob, exactly as the router decodes them + * (v4-periphery `CalldataDecoder` for the routing set, `MarginCalldataDecoder` for the margin + * set). Cross-checked against `cast abi-encode` vectors in planner.test.ts. + */ +export const ACTION_ABI: Record = { + [V4RouterAction.SWAP_EXACT_IN_SINGLE]: [ + { + type: 'tuple', + components: [ + { name: 'poolKey', ...POOL_KEY }, + { name: 'zeroForOne', type: 'bool' }, + { name: 'amountIn', type: 'uint128' }, + { name: 'amountOutMinimum', type: 'uint128' }, + { name: 'minHopPriceX36', type: 'uint256' }, + { name: 'hookData', type: 'bytes' }, + ], + }, + ], + [V4RouterAction.SWAP_EXACT_IN]: [ + { + type: 'tuple', + components: [ + { name: 'currencyIn', type: 'address' }, + { name: 'path', type: 'tuple[]', components: PATH_KEY_COMPONENTS }, + { name: 'minHopPriceX36', type: 'uint256[]' }, + { name: 'amountIn', type: 'uint128' }, + { name: 'amountOutMinimum', type: 'uint128' }, + ], + }, + ], + [V4RouterAction.SWAP_EXACT_OUT_SINGLE]: [ + { + type: 'tuple', + components: [ + { name: 'poolKey', ...POOL_KEY }, + { name: 'zeroForOne', type: 'bool' }, + { name: 'amountOut', type: 'uint128' }, + { name: 'amountInMaximum', type: 'uint128' }, + { name: 'minHopPriceX36', type: 'uint256' }, + { name: 'hookData', type: 'bytes' }, + ], + }, + ], + [V4RouterAction.SWAP_EXACT_OUT]: [ + { + type: 'tuple', + components: [ + { name: 'currencyOut', type: 'address' }, + { name: 'path', type: 'tuple[]', components: PATH_KEY_COMPONENTS }, + { name: 'minHopPriceX36', type: 'uint256[]' }, + { name: 'amountOut', type: 'uint128' }, + { name: 'amountInMaximum', type: 'uint128' }, + ], + }, + ], + [V4RouterAction.SETTLE]: [ + { name: 'currency', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'payerIsUser', type: 'bool' }, + ], + [V4RouterAction.SETTLE_ALL]: [ + { name: 'currency', type: 'address' }, + { name: 'maxAmount', type: 'uint256' }, + ], + [V4RouterAction.TAKE]: [ + { name: 'currency', type: 'address' }, + { name: 'recipient', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + [V4RouterAction.TAKE_ALL]: [ + { name: 'currency', type: 'address' }, + { name: 'minAmount', type: 'uint256' }, + ], + [V4RouterAction.TAKE_PORTION]: [ + { name: 'currency', type: 'address' }, + { name: 'recipient', type: 'address' }, + { name: 'bips', type: 'uint256' }, + ], + [V4RouterAction.SWEEP]: [ + { name: 'currency', type: 'address' }, + { name: 'to', type: 'address' }, + ], + [V4RouterAction.WRAP]: [{ name: 'amount', type: 'uint256' }], + [V4RouterAction.UNWRAP]: [{ name: 'amount', type: 'uint256' }], + [MarginAction.ACCOUNT_SUPPLY_COLLATERAL]: [ + { name: 'adapter', type: 'address' }, + { name: 'market', ...MARKET }, + { name: 'amount', type: 'uint256' }, + ], + [MarginAction.ACCOUNT_WITHDRAW_COLLATERAL]: [ + { name: 'adapter', type: 'address' }, + { name: 'market', ...MARKET }, + { name: 'amount', type: 'uint256' }, + { name: 'to', type: 'address' }, + ], + [MarginAction.ACCOUNT_BORROW]: [ + { name: 'adapter', type: 'address' }, + { name: 'market', ...MARKET }, + { name: 'amount', type: 'uint256' }, + { name: 'to', type: 'address' }, + ], + [MarginAction.ACCOUNT_REPAY]: [ + { name: 'adapter', type: 'address' }, + { name: 'market', ...MARKET }, + { name: 'amount', type: 'uint256' }, + ], + [MarginAction.ACCOUNT_SWEEP]: [ + { name: 'currency', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'to', type: 'address' }, + ], + [MarginAction.ASSERT_HEALTH]: [ + { name: 'adapter', type: 'address' }, + { name: 'market', ...MARKET }, + { name: 'maxLtv', type: 'uint256' }, + ], + [MarginAction.ASSERT_FILL]: [ + { name: 'currency', type: 'address' }, + { name: 'minAmount', type: 'uint256' }, + ], + [MarginAction.SET_ACCOUNT]: [{ name: 'subId', type: 'uint256' }], + [MarginAction.PULL_TO_ACCOUNT]: [ + { name: 'currency', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'payerIsUser', type: 'bool' }, + ], +} + +/** The margin actions that operate on the active account (require a preceding `SET_ACCOUNT`). */ +export const ACCOUNT_SCOPED_ACTIONS: ReadonlySet = new Set([ + MarginAction.ACCOUNT_SUPPLY_COLLATERAL, + MarginAction.ACCOUNT_WITHDRAW_COLLATERAL, + MarginAction.ACCOUNT_BORROW, + MarginAction.ACCOUNT_REPAY, + MarginAction.ACCOUNT_SWEEP, + MarginAction.ASSERT_HEALTH, + MarginAction.PULL_TO_ACCOUNT, +]) diff --git a/sdks/margin-sdk/src/encode.test.ts b/sdks/margin-sdk/src/encode.test.ts new file mode 100644 index 000000000..520c08671 --- /dev/null +++ b/sdks/margin-sdk/src/encode.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from 'bun:test' +import { type Address, decodeFunctionData, toFunctionSelector } from 'viem' + +import { MARGIN_ROUTER_ABI } from './abis' +import { FULL_CLOSE } from './constants' +import { + addCollateralCall, + closePositionCall, + decreasePositionCall, + encodeAddCollateral, + encodeDecreasePosition, + encodeExecute, + encodeIncreasePosition, + encodeRouterMulticall, + increasePositionCall, +} from './encode' +import { MarginSdkError } from './errors' +import { type IncreaseParams, type PoolKey } from './types' + +const WETH: Address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' +const USDC: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' +const ADAPTER: Address = '0x9A7f8F5A9496D3c9dc0BEEfb44cCaC17CAAF28fa' +const ROUTER: Address = '0x0000000004BBC92D0657580CAe35aEBF054E5CDC' +const ZERO: Address = '0x0000000000000000000000000000000000000000' + +const LONG_MARKET = { collateral: WETH, debt: USDC } +const POOL: PoolKey = { currency0: USDC, currency1: WETH, fee: 3000, tickSpacing: 60, hooks: ZERO } + +const BASE_INCREASE: IncreaseParams = { + adapter: ADAPTER, + market: LONG_MARKET, + poolKey: POOL, + equity: 10n ** 18n, + collateralToBuy: 10n ** 18n, + maxDebtIn: 10_000n * 10n ** 6n, + deadline: 1n, +} + +/** + * Ground-truth calldata generated with `cast calldata` from the deployed contract's signatures; + * each selector was additionally confirmed against the live mainnet router (an expired deadline + * reverts `DeadlinePassed`, proving the selector dispatched). + */ +const CAST_INCREASE_CALLDATA = + '0xba63804f0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000002540be4000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001' + +const CAST_DECREASE_CALLDATA = + '0x12d833730000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f42400000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009b6e64a8ec6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001' + +const CAST_ADD_COLLATERAL_CALLDATA = + '0x434f7ded0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001' + +const CAST_EXECUTE_CALLDATA = + '0xab5898e80000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006300000000000000000000000000000000000000000000000000000000000000021234000000000000000000000000000000000000000000000000000000000000' + +describe('entry point selectors (verified against the live mainnet router)', () => { + const selectors: Record = { + increasePosition: '0xba63804f', + decreasePosition: '0x12d83373', + addCollateral: '0x434f7ded', + execute: '0xab5898e8', + accountOf: '0x0c1905e5', + createAccount: '0x5fbfb9cf', + multicall: '0xac9650d8', + permit: '0x2b67b570', + } + for (const [name, selector] of Object.entries(selectors)) { + test(`${name} β†’ ${selector}`, () => { + const item = MARGIN_ROUTER_ABI.find((entry) => entry.type === 'function' && entry.name === name) + expect(item).toBeDefined() + expect(toFunctionSelector(item as never)).toBe(selector) + }) + } +}) + +describe('encodeIncreasePosition', () => { + test('matches cast-generated calldata byte-for-byte', () => { + expect(encodeIncreasePosition(BASE_INCREASE)).toBe(CAST_INCREASE_CALLDATA as `0x${string}`) + }) + + test('round-trips through decodeFunctionData', () => { + const data = encodeIncreasePosition({ ...BASE_INCREASE, minHopPriceX36: 5n, maxLtvAfter: 7n, subId: 9n }) + const { functionName, args } = decodeFunctionData({ abi: MARGIN_ROUTER_ABI, data }) + expect(functionName).toBe('increasePosition') + const params = (args as readonly [Record])[0] + expect(params.minHopPriceX36).toBe(5n) + expect(params.maxLtvAfter).toBe(7n) + expect(params.subId).toBe(9n) + }) + + test('rejects zero maxDebtIn (the binding slippage cap)', () => { + expect(() => encodeIncreasePosition({ ...BASE_INCREASE, maxDebtIn: 0n })).toThrow(MarginSdkError) + }) + + test('rejects zero collateralToBuy', () => { + expect(() => encodeIncreasePosition({ ...BASE_INCREASE, collateralToBuy: 0n })).toThrow(MarginSdkError) + }) + + test('rejects amounts above uint128', () => { + expect(() => encodeIncreasePosition({ ...BASE_INCREASE, collateralToBuy: 1n << 128n })).toThrow(MarginSdkError) + }) + + test('rejects a pool that does not trade the market pair', () => { + const wrongPool: PoolKey = { ...POOL, currency1: ADAPTER } + expect(() => encodeIncreasePosition({ ...BASE_INCREASE, poolKey: wrongPool })).toThrow(MarginSdkError) + }) + + test('rejects a native-ETH market currency', () => { + expect(() => encodeIncreasePosition({ ...BASE_INCREASE, market: { collateral: ZERO, debt: USDC } })).toThrow( + MarginSdkError + ) + }) + + test('native equity: value is set and a non-zero equity field is rejected', () => { + const call = increasePositionCall({ + marginRouter: ROUTER, + params: { ...BASE_INCREASE, equity: 0n }, + nativeEquity: 10n ** 18n, + }) + expect(call.value).toBe(10n ** 18n) + expect(call.address).toBe(ROUTER) + expect(() => increasePositionCall({ marginRouter: ROUTER, params: BASE_INCREASE, nativeEquity: 1n })).toThrow( + MarginSdkError + ) + }) +}) + +describe('encodeDecreasePosition', () => { + const partial = { + adapter: ADAPTER, + market: LONG_MARKET, + poolKey: POOL, + debtToRepay: 10n ** 6n, + maxCollateralIn: 10n ** 18n, + maxLtvAfter: 7n * 10n ** 17n, + deadline: 1n, + } + + test('matches cast-generated calldata byte-for-byte', () => { + expect(encodeDecreasePosition(partial)).toBe(CAST_DECREASE_CALLDATA as `0x${string}`) + }) + + test('partial decrease requires maxLtvAfter', () => { + expect(() => encodeDecreasePosition({ ...partial, maxLtvAfter: 0n })).toThrow(MarginSdkError) + }) + + test('partial decrease requires maxCollateralIn', () => { + expect(() => encodeDecreasePosition({ ...partial, maxCollateralIn: 0n })).toThrow(MarginSdkError) + }) + + test('full close ignores maxLtvAfter and allows zero maxCollateralIn (zero-debt path)', () => { + const data = encodeDecreasePosition({ ...partial, debtToRepay: FULL_CLOSE, maxLtvAfter: 0n, maxCollateralIn: 0n }) + const { args } = decodeFunctionData({ abi: MARGIN_ROUTER_ABI, data }) + expect((args as readonly [Record])[0].debtToRepay).toBe(FULL_CLOSE) + }) + + test('closePositionCall sets the FULL_CLOSE sentinel', () => { + const call = closePositionCall({ + marginRouter: ROUTER, + params: { + adapter: ADAPTER, + market: LONG_MARKET, + poolKey: POOL, + maxCollateralIn: 5n * 10n ** 18n, + deadline: 1n, + }, + }) + const params = (call.args as readonly [Record])[0] + expect(params.debtToRepay).toBe(FULL_CLOSE) + expect(params.maxLtvAfter).toBe(0n) + expect(call.value).toBeUndefined() + expect(decreasePositionCall({ marginRouter: ROUTER, params: partial }).functionName).toBe('decreasePosition') + }) +}) + +describe('encodeAddCollateral', () => { + const params = { adapter: ADAPTER, market: LONG_MARKET, amount: 10n ** 18n, deadline: 1n } + + test('matches cast-generated calldata byte-for-byte', () => { + expect(encodeAddCollateral(params)).toBe(CAST_ADD_COLLATERAL_CALLDATA as `0x${string}`) + }) + + test('rejects a zero amount without native value', () => { + expect(() => encodeAddCollateral({ ...params, amount: 0n })).toThrow(MarginSdkError) + }) + + test('native amount: value set, zero amount field required', () => { + const call = addCollateralCall({ + marginRouter: ROUTER, + params: { ...params, amount: 0n }, + nativeAmount: 2n * 10n ** 18n, + }) + expect(call.value).toBe(2n * 10n ** 18n) + expect(() => addCollateralCall({ marginRouter: ROUTER, params, nativeAmount: 1n })).toThrow(MarginSdkError) + }) +}) + +describe('encodeExecute / multicall', () => { + test('execute matches cast-generated calldata byte-for-byte', () => { + expect(encodeExecute('0x1234', 99n)).toBe(CAST_EXECUTE_CALLDATA as `0x${string}`) + }) + + test('multicall wraps inner calldata', () => { + const inner = encodeAddCollateral({ adapter: ADAPTER, market: LONG_MARKET, amount: 1n, deadline: 1n }) + const data = encodeRouterMulticall([inner]) + const { functionName, args } = decodeFunctionData({ abi: MARGIN_ROUTER_ABI, data }) + expect(functionName).toBe('multicall') + expect((args as readonly [readonly `0x${string}`[]])[0][0]).toBe(inner) + }) + + test('multicall rejects an empty batch', () => { + expect(() => encodeRouterMulticall([])).toThrow(MarginSdkError) + }) +}) diff --git a/sdks/margin-sdk/src/encode.ts b/sdks/margin-sdk/src/encode.ts new file mode 100644 index 000000000..b80f8c683 --- /dev/null +++ b/sdks/margin-sdk/src/encode.ts @@ -0,0 +1,345 @@ +import { type Address, type Hex, encodeFunctionData } from 'viem' + +import { MARGIN_ROUTER_ABI, PERMIT2_ABI } from './abis' +import { FULL_CLOSE, MAX_UINT48 } from './constants' +import { MarginSdkError } from './errors' +import { poolKeyMatchesMarket, validateMarket } from './market' +import { toUint128 } from './math' +import { type AddCollateralParams, type DecreaseParams, type IncreaseParams } from './types' + +/** + * Calldata encoders and write descriptors for the MarginRouter entry points. Each entry point is + * exposed two ways: + * - an `encode*` function returning raw calldata (for custom submission paths, multicall + * batching, or smart-wallet batching), and + * - a `*Call` **descriptor** β€” `{ address, abi, functionName, args, value }` β€” that drops + * straight into viem `simulateContract`/`writeContract` or wagmi `useWriteContract`. + * + * Always `simulateContract` before `writeContract` so reverts (`SlippageBoundRequired`, + * `PositionUnhealthy`, `AdapterNotAllowed`, `DeadlinePassed`, `NativeCollateralMismatch`, + * `IncompleteFill`) surface with a decoded message. + */ + +/** A framework-agnostic contract write descriptor. */ +export interface ContractWrite { + address: Address + abi: typeof MARGIN_ROUTER_ABI + functionName: string + args: readonly unknown[] + value?: bigint +} + +type IncreaseArgs = { + adapter: Address + market: { collateral: Address; debt: Address } + poolKey: IncreaseParams['poolKey'] + equity: bigint + collateralToBuy: bigint + maxDebtIn: bigint + minHopPriceX36: bigint + maxLtvAfter: bigint + subId: bigint + deadline: bigint +} + +function normalizeIncrease(params: IncreaseParams, isNative: boolean): IncreaseArgs { + validateMarket(params.market) + if (!poolKeyMatchesMarket(params.poolKey, params.market)) { + throw new MarginSdkError('MARKET_MISMATCH', 'pool currencies do not match the market (collateral, debt) pair') + } + if (params.collateralToBuy <= 0n) { + throw new MarginSdkError( + 'INVALID_AMOUNT', + 'collateralToBuy must be positive (use addCollateral for a swap-free supply)' + ) + } + if (params.maxDebtIn <= 0n) { + throw new MarginSdkError('SLIPPAGE_BOUND_REQUIRED', 'maxDebtIn is the binding slippage cap and must be non-zero') + } + if (isNative && params.equity !== 0n) { + throw new MarginSdkError( + 'INVALID_INPUT', + 'native-ETH equity is msg.value; pass equity 0 (a non-zero equity field would be ignored onchain)' + ) + } + if (params.equity < 0n) { + throw new MarginSdkError('INVALID_AMOUNT', 'equity must be non-negative') + } + return { + adapter: params.adapter, + market: params.market, + poolKey: params.poolKey, + equity: params.equity, + collateralToBuy: toUint128(params.collateralToBuy, 'collateralToBuy'), + maxDebtIn: toUint128(params.maxDebtIn, 'maxDebtIn'), + minHopPriceX36: params.minHopPriceX36 ?? 0n, + maxLtvAfter: params.maxLtvAfter ?? 0n, + subId: params.subId ?? 0n, + deadline: params.deadline, + } +} + +/** + * Encodes `increasePosition` calldata: open a position (deploying the account if needed) or add + * leverage to one. Equity is pulled via Permit2 unless the transaction carries native ETH. + */ +export function encodeIncreasePosition(params: IncreaseParams, opts?: { nativeEquity?: bigint }): Hex { + const isNative = (opts?.nativeEquity ?? 0n) > 0n + return encodeFunctionData({ + abi: MARGIN_ROUTER_ABI, + functionName: 'increasePosition', + args: [normalizeIncrease(params, isNative)], + }) +} + +/** + * `increasePosition` write descriptor. Set `nativeEquity` to fund the position with native ETH + * (wrapped to WETH onchain; the market collateral must be WETH) β€” it becomes the transaction + * value and `params.equity` must be 0. + */ +export function increasePositionCall(p: { + marginRouter: Address + params: IncreaseParams + nativeEquity?: bigint +}): ContractWrite { + const isNative = (p.nativeEquity ?? 0n) > 0n + return { + address: p.marginRouter, + abi: MARGIN_ROUTER_ABI, + functionName: 'increasePosition', + args: [normalizeIncrease(p.params, isNative)], + value: isNative ? p.nativeEquity : undefined, + } +} + +type DecreaseArgs = { + adapter: Address + market: { collateral: Address; debt: Address } + poolKey: DecreaseParams['poolKey'] + debtToRepay: bigint + maxCollateralIn: bigint + minHopPriceX36: bigint + maxLtvAfter: bigint + subId: bigint + deadline: bigint +} + +function normalizeDecrease(params: DecreaseParams): DecreaseArgs { + validateMarket(params.market) + if (!poolKeyMatchesMarket(params.poolKey, params.market)) { + throw new MarginSdkError('MARKET_MISMATCH', 'pool currencies do not match the market (collateral, debt) pair') + } + if (params.debtToRepay <= 0n) { + throw new MarginSdkError('INVALID_AMOUNT', 'debtToRepay must be positive (or FULL_CLOSE to close the position)') + } + const isFullClose = params.debtToRepay === FULL_CLOSE + if (!isFullClose) { + // The contract requires both bounds on a partial decrease; a full close ignores maxLtvAfter, + // and a zero-debt full close also ignores maxCollateralIn (swap-free path). + if (params.maxCollateralIn <= 0n) { + throw new MarginSdkError( + 'SLIPPAGE_BOUND_REQUIRED', + 'maxCollateralIn is the binding slippage cap and must be non-zero on a partial decrease' + ) + } + if ((params.maxLtvAfter ?? 0n) <= 0n) { + throw new MarginSdkError( + 'SLIPPAGE_BOUND_REQUIRED', + 'maxLtvAfter is mandatory on a partial decrease (it bounds the resulting position health)' + ) + } + } + return { + adapter: params.adapter, + market: params.market, + poolKey: params.poolKey, + debtToRepay: params.debtToRepay, + maxCollateralIn: toUint128(params.maxCollateralIn, 'maxCollateralIn'), + minHopPriceX36: params.minHopPriceX36 ?? 0n, + maxLtvAfter: params.maxLtvAfter ?? 0n, + subId: params.subId ?? 0n, + deadline: params.deadline, + } +} + +/** + * Encodes `decreasePosition` calldata: partial delever (repay `debtToRepay` by selling + * collateral), or full close when `debtToRepay` is {@link FULL_CLOSE}. Close and decrease never + * require an allowlisted adapter, so a position is always exitable. + */ +export function encodeDecreasePosition(params: DecreaseParams): Hex { + return encodeFunctionData({ + abi: MARGIN_ROUTER_ABI, + functionName: 'decreasePosition', + args: [normalizeDecrease(params)], + }) +} + +/** `decreasePosition` write descriptor. */ +export function decreasePositionCall(p: { marginRouter: Address; params: DecreaseParams }): ContractWrite { + return { + address: p.marginRouter, + abi: MARGIN_ROUTER_ABI, + functionName: 'decreasePosition', + args: [normalizeDecrease(p.params)], + } +} + +/** + * Encodes a full close: `decreasePosition` with `debtToRepay == type(uint256).max` β€” repay all + * debt, withdraw all collateral, and return the residual (realized PnL) to the caller. Size + * `maxCollateralIn` from the position's current debt plus a quote (see `sizeDecrease`); a + * zero-debt position closes swap-free and ignores it. + */ +export function encodeClosePosition(params: Omit): Hex { + return encodeDecreasePosition({ ...params, debtToRepay: FULL_CLOSE, maxLtvAfter: 0n }) +} + +/** Full-close write descriptor (see {@link encodeClosePosition}). */ +export function closePositionCall(p: { + marginRouter: Address + params: Omit +}): ContractWrite { + return decreasePositionCall({ + marginRouter: p.marginRouter, + params: { ...p.params, debtToRepay: FULL_CLOSE, maxLtvAfter: 0n }, + }) +} + +type AddCollateralArgs = { + adapter: Address + market: { collateral: Address; debt: Address } + amount: bigint + subId: bigint + deadline: bigint +} + +function normalizeAddCollateral(params: AddCollateralParams, isNative: boolean): AddCollateralArgs { + validateMarket(params.market) + if (isNative && params.amount !== 0n) { + throw new MarginSdkError( + 'INVALID_INPUT', + 'native-ETH collateral is msg.value; pass amount 0 (a non-zero amount field would be ignored onchain)' + ) + } + if (!isNative && params.amount <= 0n) { + throw new MarginSdkError('INVALID_AMOUNT', 'amount must be positive') + } + return { + adapter: params.adapter, + market: params.market, + amount: params.amount, + subId: params.subId ?? 0n, + deadline: params.deadline, + } +} + +/** Encodes `addCollateral` calldata: supply collateral without changing debt (no swap). */ +export function encodeAddCollateral(params: AddCollateralParams, opts?: { nativeAmount?: bigint }): Hex { + const isNative = (opts?.nativeAmount ?? 0n) > 0n + return encodeFunctionData({ + abi: MARGIN_ROUTER_ABI, + functionName: 'addCollateral', + args: [normalizeAddCollateral(params, isNative)], + }) +} + +/** `addCollateral` write descriptor. `nativeAmount` funds it with native ETH (collateral must be WETH). */ +export function addCollateralCall(p: { + marginRouter: Address + params: AddCollateralParams + nativeAmount?: bigint +}): ContractWrite { + const isNative = (p.nativeAmount ?? 0n) > 0n + return { + address: p.marginRouter, + abi: MARGIN_ROUTER_ABI, + functionName: 'addCollateral', + args: [normalizeAddCollateral(p.params, isNative)], + value: isNative ? p.nativeAmount : undefined, + } +} + +/** + * Encodes `execute` calldata for a finalized plan (see `MarginPlanner`). ⚠️ Only execute plans + * your own code built β€” a plan has full authority over the caller's sub-accounts. + */ +export function encodeExecute(unlockData: Hex, deadline: bigint): Hex { + return encodeFunctionData({ abi: MARGIN_ROUTER_ABI, functionName: 'execute', args: [unlockData, deadline] }) +} + +/** `execute` write descriptor. `value` carries native ETH for plans that `WRAP`. */ +export function executeCall(p: { + marginRouter: Address + unlockData: Hex + deadline: bigint + value?: bigint +}): ContractWrite { + return { + address: p.marginRouter, + abi: MARGIN_ROUTER_ABI, + functionName: 'execute', + args: [p.unlockData, p.deadline], + value: p.value, + } +} + +/** + * Encodes a router `multicall(bytes[])`, e.g. to batch a forwarded Permit2 `permit` with an + * `increasePosition` in one transaction. Do not batch two native-ETH position calls β€” `msg.value` + * is shared across a multicall. + */ +export function encodeRouterMulticall(calls: Hex[]): Hex { + if (calls.length === 0) throw new MarginSdkError('INVALID_INPUT', 'multicall requires at least one call') + return encodeFunctionData({ abi: MARGIN_ROUTER_ABI, functionName: 'multicall', args: [calls] }) +} + +/** A Permit2 `PermitSingle` message (sign with EIP-712, then forward via {@link encodeRouterPermit}). */ +export interface PermitSingle { + details: { + token: Address + amount: bigint + expiration: number + nonce: number + } + spender: Address + sigDeadline: bigint +} + +/** + * Encodes the router's forwarded Permit2 `permit(owner, permitSingle, signature)` β€” the gasless + * alternative to an onchain `Permit2.approve`, batchable with a position call via + * {@link encodeRouterMulticall}. + */ +export function encodeRouterPermit(owner: Address, permitSingle: PermitSingle, signature: Hex): Hex { + return encodeFunctionData({ + abi: MARGIN_ROUTER_ABI, + functionName: 'permit', + args: [owner, permitSingle, signature], + }) +} + +/** + * Permit2 `approve(token, router, amount, expiration)` write descriptor β€” the second step of the + * two-step Permit2 setup (the first is a standard ERC-20 `approve(permit2, ...)`, e.g. with + * viem's `erc20Abi`). `expiration` defaults to the uint48 maximum (no expiry). + */ +export function permit2ApproveCall(p: { + permit2: Address + token: Address + spender: Address + amount: bigint + expiration?: number +}): { + address: Address + abi: typeof PERMIT2_ABI + functionName: 'approve' + args: readonly [Address, Address, bigint, number] +} { + return { + address: p.permit2, + abi: PERMIT2_ABI, + functionName: 'approve', + args: [p.token, p.spender, p.amount, p.expiration ?? Number(MAX_UINT48)], + } +} diff --git a/sdks/margin-sdk/src/planner.test.ts b/sdks/margin-sdk/src/planner.test.ts new file mode 100644 index 000000000..5a11b9077 --- /dev/null +++ b/sdks/margin-sdk/src/planner.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from 'bun:test' +import { type Address, decodeAbiParameters } from 'viem' + +import { MarginAction, V4RouterAction } from './actions' +import { CONTRACT_BALANCE, MSG_SENDER, OPEN_DELTA } from './constants' +import { MarginSdkError } from './errors' +import { MarginPlanner } from './planner' +import { type PoolKey } from './types' + +const WETH: Address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' +const USDC: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' +const ADAPTER: Address = '0x9A7f8F5A9496D3c9dc0BEEfb44cCaC17CAAF28fa' +const ZERO: Address = '0x0000000000000000000000000000000000000000' +const ADDR2: Address = '0x0000000000000000000000000000000000000002' + +const MARKET = { collateral: WETH, debt: USDC } +const POOL: PoolKey = { currency0: USDC, currency1: WETH, fee: 3000, tickSpacing: 60, hooks: ZERO } + +/** Ground-truth blobs generated with `cast abi-encode` against the decoder signatures. */ +const CAST = { + setAccount7: '0x0000000000000000000000000000000000000000000000000000000000000007', + pullWeth1e18True: + '0x000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000001', + supplyOpenDelta: + '0x0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000000', + borrow3e9ToRouter: + '0x0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b2d05e000000000000000000000000000000000000000000000000000000000000000002', + assertHealth07: + '0x0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000009b6e64a8ec60000', + assertFillWeth1e18: + '0x000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a7640000', + swapExactOutSingle: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000b3b53fc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000', + settleUsdcOpenDeltaRouter: + '0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + sweepWethMsgSender: + '0x000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000001', + unlockDataSetAccountPull: + '0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000023738000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000001', +} as const + +describe('MarginPlanner action encodings (vs cast abi-encode ground truth)', () => { + test('setAccount', () => { + const p = new MarginPlanner().setAccount(7n) + expect(p.actions).toEqual([MarginAction.SET_ACCOUNT]) + expect(p.params[0]).toBe(CAST.setAccount7 as `0x${string}`) + }) + + test('pullToAccount', () => { + const p = new MarginPlanner().setAccount(7n).pullToAccount(WETH, 10n ** 18n, true) + expect(p.params[1]).toBe(CAST.pullWeth1e18True as `0x${string}`) + }) + + test('supplyCollateral with OPEN_DELTA', () => { + const p = new MarginPlanner().setAccount(0n).supplyCollateral(ADAPTER, MARKET, OPEN_DELTA) + expect(p.params[1]).toBe(CAST.supplyOpenDelta as `0x${string}`) + }) + + test('borrow to ADDRESS_THIS', () => { + const p = new MarginPlanner().setAccount(0n).borrow(ADAPTER, MARKET, 3_000n * 10n ** 6n, ADDR2) + expect(p.params[1]).toBe(CAST.borrow3e9ToRouter as `0x${string}`) + }) + + test('assertHealth', () => { + const p = new MarginPlanner().setAccount(0n).assertHealth(ADAPTER, MARKET, 7n * 10n ** 17n) + expect(p.params[1]).toBe(CAST.assertHealth07 as `0x${string}`) + }) + + test('assertFill', () => { + const p = new MarginPlanner().assertFill(WETH, 10n ** 18n) + expect(p.params[0]).toBe(CAST.assertFillWeth1e18 as `0x${string}`) + }) + + test('swapExactOutSingle', () => { + const p = new MarginPlanner().swapExactOutSingle({ + poolKey: POOL, + zeroForOne: true, + amountOut: 10n ** 18n, + amountInMaximum: 3_015n * 10n ** 6n, + }) + expect(p.params[0]).toBe(CAST.swapExactOutSingle as `0x${string}`) + }) + + test('settle with OPEN_DELTA from router balance', () => { + const p = new MarginPlanner().settle(USDC, OPEN_DELTA, false) + expect(p.params[0]).toBe(CAST.settleUsdcOpenDeltaRouter as `0x${string}`) + }) + + test('sweep to MSG_SENDER', () => { + const p = new MarginPlanner().sweep(WETH, MSG_SENDER) + expect(p.params[0]).toBe(CAST.sweepWethMsgSender as `0x${string}`) + }) +}) + +describe('MarginPlanner.finalize', () => { + test('unlockData matches cast abi-encode(bytes,bytes[]) ground truth', () => { + const unlockData = new MarginPlanner() + .setAccount(7n) + .pullToAccount(WETH, 10n ** 18n, true) + .finalize() + expect(unlockData).toBe(CAST.unlockDataSetAccountPull as `0x${string}`) + }) + + test('packs one opcode byte per action in order', () => { + const unlockData = new MarginPlanner() + .setAccount(0n) + .swapExactOutSingle({ poolKey: POOL, zeroForOne: true, amountOut: 1n, amountInMaximum: 1n }) + .assertFill(WETH, 1n) + .supplyCollateral(ADAPTER, MARKET, OPEN_DELTA) + .borrow(ADAPTER, MARKET, 1n, ADDR2) + .settle(USDC, OPEN_DELTA, false) + .assertHealth(ADAPTER, MARKET, 7n * 10n ** 17n) + .sweep(WETH, MSG_SENDER) + .finalize() + const [actions, params] = decodeAbiParameters([{ type: 'bytes' }, { type: 'bytes[]' }], unlockData) + expect(actions).toBe( + `0x${[ + MarginAction.SET_ACCOUNT, + V4RouterAction.SWAP_EXACT_OUT_SINGLE, + MarginAction.ASSERT_FILL, + MarginAction.ACCOUNT_SUPPLY_COLLATERAL, + MarginAction.ACCOUNT_BORROW, + V4RouterAction.SETTLE, + MarginAction.ASSERT_HEALTH, + V4RouterAction.SWEEP, + ] + .map((a) => a.toString(16).padStart(2, '0')) + .join('')}` + ) + expect(params).toHaveLength(8) + }) + + test('rejects an empty plan', () => { + expect(() => new MarginPlanner().finalize()).toThrow(MarginSdkError) + }) + + test('rejects account-scoped actions before SET_ACCOUNT (NoActiveAccount mirror)', () => { + expect(() => new MarginPlanner().supplyCollateral(ADAPTER, MARKET, 0n).finalize()).toThrow(MarginSdkError) + // ASSERT_FILL and plain routing actions are not account-scoped + expect(() => new MarginPlanner().assertFill(WETH, 1n).finalize()).not.toThrow() + expect(() => new MarginPlanner().sweep(WETH, MSG_SENDER).finalize()).not.toThrow() + }) + + test('pullToAccount guards the zero-amount and CONTRACT_BALANCE-from-user footguns', () => { + const p = new MarginPlanner().setAccount(0n) + expect(() => p.pullToAccount(WETH, 0n, true)).toThrow(MarginSdkError) + expect(() => p.pullToAccount(WETH, CONTRACT_BALANCE, true)).toThrow(MarginSdkError) + expect(() => p.pullToAccount(WETH, CONTRACT_BALANCE, false)).not.toThrow() + }) + + test('multi-hop swaps default per-hop bounds to zero entries', () => { + const p = new MarginPlanner().swapExactIn({ + currencyIn: USDC, + path: [{ intermediateCurrency: WETH, fee: 3000, tickSpacing: 60, hooks: ZERO, hookData: '0x' }], + amountIn: 1n, + amountOutMinimum: 1n, + }) + expect(p.actions).toEqual([V4RouterAction.SWAP_EXACT_IN]) + }) +}) diff --git a/sdks/margin-sdk/src/planner.ts b/sdks/margin-sdk/src/planner.ts new file mode 100644 index 000000000..4aa8cfa02 --- /dev/null +++ b/sdks/margin-sdk/src/planner.ts @@ -0,0 +1,258 @@ +import { type Address, type Hex, concatHex, encodeAbiParameters, numberToHex } from 'viem' + +import { ACCOUNT_SCOPED_ACTIONS, ACTION_ABI, MarginAction, type PlanAction, V4RouterAction } from './actions' +import { CONTRACT_BALANCE } from './constants' +import { MarginSdkError } from './errors' +import { type Market, type PathKey, type PoolKey } from './types' + +/** + * Builds the `unlockData` for `MarginRouter.execute`: an ordered plan of v4 routing and margin + * actions run atomically in one PoolManager unlock. `finalize()` produces + * `abi.encode(bytes actions, bytes[] params)` where `actions` is the packed opcode string and + * `params[i]` is the ABI-encoded parameters for `actions[i]`. + * + * `execute` does no entry validation β€” the plan carries exactly the guardrails it encodes: + * 1. Open each account-scoped section with {@link setAccount} (enforced by `finalize`). + * 2. Encode swap bounds (`amountInMaximum` / `amountOutMinimum`), {@link assertFill} after an + * exact-output swap, and {@link assertHealth} per touched (account, market) β€” after each + * account section, not once at the end. + * 3. Net the router to zero: terminate with {@link sweep} for every currency the plan may leave + * on the router. Residual balances are claimable by the next caller. + * 4. Supply and borrow require an allowlisted adapter; withdraw, repay, and account-sweep do not. + * + * ⚠️ Signing an `execute` plan is equivalent to handing over the sub-account: a malicious plan + * can borrow to the market maximum and direct everything to an arbitrary address with no token + * approval required. Never execute a plan built by an untrusted party. + */ +export class MarginPlanner { + readonly actions: PlanAction[] = [] + readonly params: Hex[] = [] + + /** Appends a raw action with pre-encoded params. Prefer the typed helpers below. */ + addAction(action: PlanAction, params: Hex): this { + if (!(action in ACTION_ABI)) { + throw new MarginSdkError('INVALID_PLAN', `unsupported action opcode: 0x${action.toString(16)}`) + } + this.actions.push(action) + this.params.push(params) + return this + } + + private add(action: PlanAction, values: readonly unknown[]): this { + return this.addAction(action, encodeAbiParameters([...ACTION_ABI[action]], values)) + } + + // ------------------------------------------------------------------------- + // Margin actions + // ------------------------------------------------------------------------- + + /** Binds the active account for subsequent account-scoped actions (deploys it if needed). */ + setAccount(subId: bigint): this { + return this.add(MarginAction.SET_ACCOUNT, [subId]) + } + + /** + * Moves `amount` of `currency` into the active account: pulled from the caller via Permit2 + * (`payerIsUser` true) or from the router's own balance (false). A zero amount reverts onchain + * (no `OPEN_DELTA` sentinel here); `CONTRACT_BALANCE` is honored only on the router-balance + * path; native currency is unsupported β€” wrap to WETH first. + */ + pullToAccount(currency: Address, amount: bigint, payerIsUser: boolean): this { + if (amount === 0n) { + throw new MarginSdkError('INVALID_AMOUNT', 'PULL_TO_ACCOUNT rejects a zero amount (no OPEN_DELTA sentinel)') + } + if (amount === CONTRACT_BALANCE && payerIsUser) { + throw new MarginSdkError('INVALID_AMOUNT', 'CONTRACT_BALANCE is only honored when pulling the router balance') + } + return this.add(MarginAction.PULL_TO_ACCOUNT, [currency, amount, payerIsUser]) + } + + /** + * Supplies `amount` of the market's collateral from the active account (0 == `OPEN_DELTA`, the + * account's full collateral-token balance). Requires an allowlisted adapter. + */ + supplyCollateral(adapter: Address, market: Market, amount: bigint): this { + return this.add(MarginAction.ACCOUNT_SUPPLY_COLLATERAL, [adapter, market, amount]) + } + + /** Withdraws `amount` of the market's collateral from the active account's position to `to`. */ + withdrawCollateral(adapter: Address, market: Market, amount: bigint, to: Address): this { + return this.add(MarginAction.ACCOUNT_WITHDRAW_COLLATERAL, [adapter, market, amount, to]) + } + + /** Borrows `amount` of the market's debt against the active account, delivered to `to`. */ + borrow(adapter: Address, market: Market, amount: bigint, to: Address): this { + return this.add(MarginAction.ACCOUNT_BORROW, [adapter, market, amount, to]) + } + + /** Repays `amount` of the active account's debt (`type(uint256).max` == full repay by shares). */ + repay(adapter: Address, market: Market, amount: bigint): this { + return this.add(MarginAction.ACCOUNT_REPAY, [adapter, market, amount]) + } + + /** Sweeps `amount` of `currency` out of the active account to `to` (manager or owner only). */ + accountSweep(currency: Address, amount: bigint, to: Address): this { + return this.add(MarginAction.ACCOUNT_SWEEP, [currency, amount, to]) + } + + /** Asserts the active account's LTV in `market` is at most `maxLtv` (WAD; 0 skips the check). */ + assertHealth(adapter: Address, market: Market, maxLtv: bigint): this { + return this.add(MarginAction.ASSERT_HEALTH, [adapter, market, maxLtv]) + } + + /** + * Asserts the router holds at least `minAmount` credit of `currency` β€” i.e. the preceding + * exact-output swap delivered the full requested amount (all-or-nothing on a thin pool). + */ + assertFill(currency: Address, minAmount: bigint): this { + return this.add(MarginAction.ASSERT_FILL, [currency, minAmount]) + } + + // ------------------------------------------------------------------------- + // v4 routing actions + // ------------------------------------------------------------------------- + + swapExactInSingle(p: { + poolKey: PoolKey + zeroForOne: boolean + amountIn: bigint + amountOutMinimum: bigint + minHopPriceX36?: bigint + hookData?: Hex + }): this { + return this.add(V4RouterAction.SWAP_EXACT_IN_SINGLE, [ + { + poolKey: p.poolKey, + zeroForOne: p.zeroForOne, + amountIn: p.amountIn, + amountOutMinimum: p.amountOutMinimum, + minHopPriceX36: p.minHopPriceX36 ?? 0n, + hookData: p.hookData ?? '0x', + }, + ]) + } + + swapExactOutSingle(p: { + poolKey: PoolKey + zeroForOne: boolean + amountOut: bigint + amountInMaximum: bigint + minHopPriceX36?: bigint + hookData?: Hex + }): this { + return this.add(V4RouterAction.SWAP_EXACT_OUT_SINGLE, [ + { + poolKey: p.poolKey, + zeroForOne: p.zeroForOne, + amountOut: p.amountOut, + amountInMaximum: p.amountInMaximum, + minHopPriceX36: p.minHopPriceX36 ?? 0n, + hookData: p.hookData ?? '0x', + }, + ]) + } + + swapExactIn(p: { + currencyIn: Address + path: PathKey[] + amountIn: bigint + amountOutMinimum: bigint + /** Per-hop price bounds; defaults to a zero (disabled) entry per hop. */ + minHopPriceX36?: bigint[] + }): this { + return this.add(V4RouterAction.SWAP_EXACT_IN, [ + { + currencyIn: p.currencyIn, + path: p.path, + minHopPriceX36: p.minHopPriceX36 ?? p.path.map(() => 0n), + amountIn: p.amountIn, + amountOutMinimum: p.amountOutMinimum, + }, + ]) + } + + swapExactOut(p: { + currencyOut: Address + path: PathKey[] + amountOut: bigint + amountInMaximum: bigint + minHopPriceX36?: bigint[] + }): this { + return this.add(V4RouterAction.SWAP_EXACT_OUT, [ + { + currencyOut: p.currencyOut, + path: p.path, + minHopPriceX36: p.minHopPriceX36 ?? p.path.map(() => 0n), + amountOut: p.amountOut, + amountInMaximum: p.amountInMaximum, + }, + ]) + } + + /** Pays `amount` of `currency` into the PoolManager (0 == `OPEN_DELTA` full debt). */ + settle(currency: Address, amount: bigint, payerIsUser: boolean): this { + return this.add(V4RouterAction.SETTLE, [currency, amount, payerIsUser]) + } + + /** Settles the full open debt in `currency`, reverting if it exceeds `maxAmount`. */ + settleAll(currency: Address, maxAmount: bigint): this { + return this.add(V4RouterAction.SETTLE_ALL, [currency, maxAmount]) + } + + /** Takes `amount` of `currency` from the PoolManager to `recipient` (0 == full credit). */ + take(currency: Address, recipient: Address, amount: bigint): this { + return this.add(V4RouterAction.TAKE, [currency, recipient, amount]) + } + + /** Takes the full open credit in `currency`, reverting if it is below `minAmount`. */ + takeAll(currency: Address, minAmount: bigint): this { + return this.add(V4RouterAction.TAKE_ALL, [currency, minAmount]) + } + + /** Takes `bips` (out of 10_000) of the full credit in `currency` to `recipient`. */ + takePortion(currency: Address, recipient: Address, bips: bigint): this { + return this.add(V4RouterAction.TAKE_PORTION, [currency, recipient, bips]) + } + + /** Sweeps the router's entire balance of `currency` to `to` (use to net the router to zero). */ + sweep(currency: Address, to: Address): this { + return this.add(V4RouterAction.SWEEP, [currency, to]) + } + + /** Wraps `amount` of the router's native ETH to WETH (`CONTRACT_BALANCE` == entire balance). */ + wrap(amount: bigint): this { + return this.add(V4RouterAction.WRAP, [amount]) + } + + /** Unwraps `amount` of the router's WETH to native ETH (`CONTRACT_BALANCE` == entire balance). */ + unwrap(amount: bigint): this { + return this.add(V4RouterAction.UNWRAP, [amount]) + } + + // ------------------------------------------------------------------------- + // Finalization + // ------------------------------------------------------------------------- + + /** + * Encodes the plan as `execute` `unlockData`: `abi.encode(bytes actions, bytes[] params)`. + * Rejects empty plans and plans that run an account-scoped action before any `SET_ACCOUNT` + * (which would revert `NoActiveAccount` onchain). + */ + finalize(): Hex { + if (this.actions.length === 0) { + throw new MarginSdkError('INVALID_PLAN', 'cannot finalize an empty plan') + } + let accountSet = false + for (const action of this.actions) { + if (action === MarginAction.SET_ACCOUNT) accountSet = true + else if (!accountSet && ACCOUNT_SCOPED_ACTIONS.has(action)) { + throw new MarginSdkError( + 'INVALID_PLAN', + `action 0x${action.toString(16)} is account-scoped: open the section with setAccount(subId)` + ) + } + } + const packedActions = concatHex(this.actions.map((a) => numberToHex(a, { size: 1 }))) + return encodeAbiParameters([{ type: 'bytes' }, { type: 'bytes[]' }], [packedActions, this.params]) + } +} From d4afa03c9fdbe8421109b4fc7cf4deddacee0352 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 23 Jul 2026 19:19:41 -0400 Subject: [PATCH 05/17] feat(margin-sdk): add read layer, package exports, docs, and changeset 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 --- .changeset/margin-sdk-initial-release.md | 5 + sdks/margin-sdk/.gitignore | 24 ++ sdks/margin-sdk/README.md | 267 +++++++++++++++++++++++ sdks/margin-sdk/src/index.ts | 32 +++ sdks/margin-sdk/src/reads.ts | 174 +++++++++++++++ 5 files changed, 502 insertions(+) create mode 100644 .changeset/margin-sdk-initial-release.md create mode 100644 sdks/margin-sdk/.gitignore create mode 100644 sdks/margin-sdk/README.md create mode 100644 sdks/margin-sdk/src/index.ts create mode 100644 sdks/margin-sdk/src/reads.ts diff --git a/.changeset/margin-sdk-initial-release.md b/.changeset/margin-sdk-initial-release.md new file mode 100644 index 000000000..53ae75bea --- /dev/null +++ b/.changeset/margin-sdk-initial-release.md @@ -0,0 +1,5 @@ +--- +'@uniswap/margin-sdk': minor +--- + +Initial release of the margin trading SDK for the Uniswap v4 margin periphery (MarginRouter + Morpho Blue / Aave v3 / Aave v4 lending adapters): entry-point encoders and write descriptors (increase/decrease/close/addCollateral/execute/multicall/permit), offchain MarginAccount address derivation (Solady CWIA, verified against the live mainnet router), decimal-aware leverage/LTV/health sizing math, a validated `execute`-plan builder over the v4 routing + margin action set, venue-agnostic read descriptors, and the mainnet deployment address registry. diff --git a/sdks/margin-sdk/.gitignore b/sdks/margin-sdk/.gitignore new file mode 100644 index 000000000..04ca89baf --- /dev/null +++ b/sdks/margin-sdk/.gitignore @@ -0,0 +1,24 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +build/ + +# Coverage +coverage/ + +# Cache +.cache/ +.turbo/ + +# IDE +.idea/ +.vscode/ + +# Logs +*.log +npm-debug.log* + +# OS +.DS_Store diff --git a/sdks/margin-sdk/README.md b/sdks/margin-sdk/README.md new file mode 100644 index 000000000..53b7f39d3 --- /dev/null +++ b/sdks/margin-sdk/README.md @@ -0,0 +1,267 @@ +# @uniswap/margin-sdk + +A framework-agnostic TypeScript SDK for the **Uniswap v4 margin trading periphery**: open, manage, +and close 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`. + +The SDK covers: + +- **Calldata + write descriptors** for every router entry point (`increasePosition`, + `decreasePosition`, `addCollateral`, `execute`, `multicall`, forwarded Permit2 `permit`), + validated against the deployed contracts byte-for-byte. +- **Offchain account derivation** β€” `predictMarginAccountAddress` mirrors `router.accountOf` + (Solady clone-with-immutable-args CREATE2) with no RPC round-trip. +- **Leverage & health math** β€” decimal-aware position sizing (`sizeIncrease` / `sizeDecrease`), + leverage↔LTV conversions, health factors, slippage helpers. +- **A plan builder** (`MarginPlanner`) for the advanced `execute` entry point: compose v4 routing + actions and margin account actions into one atomic flash-accounted plan. +- **Read descriptors** that drop into wagmi `useReadContract(s)` / viem `readContract`, identical + across all three lending venues. + +Built on [viem](https://viem.sh); no other runtime dependencies. + +## How a position works + +A margin position is leveraged spot exposure assembled in a single transaction inside one +`PoolManager` unlock: borrow the **debt** token, swap it into the **collateral** token +(exact-output), and supply the collateral (your equity plus the bought amount) to the lending +market. The position is **long the collateral and short the debt** β€” direction is set entirely by +the `(collateral, debt)` pairing, there is no separate flag: + +| Goal | Market | Resulting position | +| ------------------ | ---------------------------------- | ------------------- | +| Long WETH vs USDC | `{ collateral: WETH, debt: USDC }` | hold WETH, owe USDC | +| Short WETH vs USDC | `{ collateral: USDC, debt: WETH }` | hold USDC, owe WETH | + +Each position lives in a per-user **`MarginAccount`** β€” a soulbound clone addressed by +`(owner, subId)` β€” which is itself the borrower/supplier on the lending venue. One owner can hold +many independent positions under distinct `subId`s (e.g. a delta-neutral long + short pair). + +## Install + +```bash +npm install @uniswap/margin-sdk viem +``` + +## Quickstart: open a 2x long + +```ts +import { createPublicClient, createWalletClient, custom, erc20Abi, http, parseUnits } from 'viem' +import { mainnet } from 'viem/chains' +import { + getMarginAddresses, + increasePositionCall, + parseLeverageX18, + permit2ApproveCall, + sizeIncrease, + toPoolKey, +} from '@uniswap/margin-sdk' + +const addresses = getMarginAddresses(mainnet.id)! +const publicClient = createPublicClient({ chain: mainnet, transport: http() }) +const walletClient = createWalletClient({ chain: mainnet, transport: custom(window.ethereum) }) + +const WETH = addresses.weth9 +const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' +const market = { collateral: WETH, debt: USDC } // long WETH, short USDC +const poolKey = toPoolKey({ currencyA: WETH, currencyB: USDC, fee: 3000, tickSpacing: 60 }) + +// 1. Size the swap from equity + target leverage. The price MUST come from a real quote +// (debt-wei per one whole collateral token), not spot; maxDebtIn is the binding slippage cap. +const equity = parseUnits('1', 18) // 1 WETH +const { collateralToBuy, maxDebtIn } = sizeIncrease({ + equity, + leverageX18: parseLeverageX18(2), + priceDebtPerCollateralToken: parseUnits('3000', 6), // 3000 USDC/WETH quote + collateralDecimals: 18, + slippageBps: 50, +}) + +// 2. One-time Permit2 setup for the equity token: +// ERC20.approve(permit2) then Permit2.approve(token, router). +await walletClient.writeContract({ + account, + address: WETH, + abi: erc20Abi, + functionName: 'approve', + args: [addresses.permit2, 2n ** 256n - 1n], +}) +await walletClient.writeContract({ + account, + ...permit2ApproveCall({ permit2: addresses.permit2, token: WETH, spender: addresses.marginRouter, amount: equity }), +}) + +// 3. Simulate (surfaces decoded reverts), then send. +const { request } = await publicClient.simulateContract({ + account, + ...increasePositionCall({ + marginRouter: addresses.marginRouter, + params: { + adapter: addresses.lendingAdapters.morphoBlue!, + market, + poolKey, + equity, + collateralToBuy, + maxDebtIn, + deadline: BigInt(Math.floor(Date.now() / 1000) + 900), + }, + }), +}) +await walletClient.writeContract(request) +``` + +**Native ETH equity**: pass `nativeEquity` instead (the router wraps it to WETH; the market +collateral must be WETH, and `params.equity` must be `0n`): + +```ts +increasePositionCall({ + marginRouter: addresses.marginRouter, + params: { ...params, equity: 0n }, + nativeEquity: parseUnits('1', 18), +}) +``` + +## Read a position + +```ts +import { getMarginAccountAddress, getPosition, healthFactor } from '@uniswap/margin-sdk' + +// No RPC needed for the account address β€” it's a pure function of (owner, subId, deployment). +const account = getMarginAccountAddress(mainnet.id, owner, 0n) + +const position = await getPosition(publicClient, { + adapter: addresses.lendingAdapters.morphoBlue!, + account, + market, +}) +// { collateralAmount, debtAmount, maxLtv, currentLtv, healthFactorWad } β€” interest-accrued, +// WAD ratios (1e18 == 100%). +``` + +Every read also has a pure `*Call` descriptor (e.g. `describePositionCall`, `positionOfCall`, +`isSupportedMarketCall`) for wagmi `useReadContracts` / viem `multicall`. + +## Close or delever + +```ts +import { FULL_CLOSE, closePositionCall, decreasePositionCall, sizeDecrease } from '@uniswap/margin-sdk' + +// Full close: repay all debt, withdraw all collateral, return the residual (realized PnL). +// Size the collateral cap from the CURRENT debt plus headroom (debt accrues interest). +const { maxCollateralIn } = sizeDecrease({ + debtToRepay: position.debtAmount, + priceCollateralPerDebtToken: parseUnits('0.000333333333333333', 18), // WETH per USDC quote + debtDecimals: 6, + slippageBps: 100, +}) +const close = closePositionCall({ + marginRouter: addresses.marginRouter, + params: { adapter, market, poolKey, maxCollateralIn, deadline }, +}) + +// Partial delever: repay a fixed amount and bound the resulting LTV (mandatory). +const delever = decreasePositionCall({ + marginRouter: addresses.marginRouter, + params: { + adapter, + market, + poolKey, + debtToRepay: parseUnits('1000', 6), + maxCollateralIn, + maxLtvAfter: parseUnits('0.7', 18), // keep LTV ≀ 70% + deadline, + }, +}) +``` + +Closing and delevering **never** require the adapter to be allowlisted, so a position is always +exitable β€” even if its adapter is later removed from governance's allowlist. + +## Going short & venue selection + +A short is the same call with the market pairing reversed and the venue chosen per call by +adapter β€” nothing else changes: + +```ts +const shortMarket = { collateral: USDC, debt: WETH } +const params = { + adapter: addresses.lendingAdapters.aaveV3!, // or aaveV4 + market: shortMarket, + poolKey, // same USDC/WETH pool + equity: parseUnits('3000', 6), // ⚠️ USDC decimals now + collateralToBuy, // 6-decimal USDC + maxDebtIn, // 18-decimal WETH + subId: 1n, // isolate from the long under subId 0 + deadline, +} +``` + +Mind the decimals: for a short, `equity`/`collateralToBuy` are in the collateral token's decimals +(USDC: 6) and `maxDebtIn` in the debt token's (WETH: 18) β€” `sizeIncrease` handles this when given +the correct `collateralDecimals` and a correctly-scaled price. **Keep one Aave position per +`subId`**: Aave (v3 and each v4 Spoke) tracks health account-wide, so co-locating two Aave markets +under one sub-account blends their reads and can break a later decrease/close. Morpho markets are +isolated and unaffected. + +## Advanced: `execute` plans + +`execute(unlockData, deadline)` runs an arbitrary plan of v4 routing + margin actions atomically β€” +flows the curated entry points cannot express (adjust margin and leverage together, migrate +between sub-accounts, repay from the wallet). `MarginPlanner` builds and validates the plan: + +```ts +import { MarginPlanner, OPEN_DELTA, MSG_SENDER, executeCall } from '@uniswap/margin-sdk' + +// Repay 500 USDC of debt straight from the caller's wallet (no swap, no withdraw): +const unlockData = new MarginPlanner() + .setAccount(0n) // bind the caller's sub-account 0 (always caller-derived) + .pullToAccount(USDC, parseUnits('500', 6), true) // pull via Permit2 + .repay(adapter, market, parseUnits('500', 6)) + .assertHealth(adapter, market, parseUnits('0.8', 18)) // opt-in health guard + .finalize() + +const call = executeCall({ marginRouter: addresses.marginRouter, unlockData, deadline }) +``` + +`execute` performs **no entry validation** β€” the plan carries exactly the guardrails it encodes. +Encode swap bounds, `assertFill` after exact-output swaps, `assertHealth` per touched account, and +terminate with `sweep` for every currency the plan may leave on the router (residuals are +claimable by the next caller). The planner enforces the structural rules it can check offchain +(account-scoped actions need a preceding `setAccount`; `pullToAccount` rejects the zero-amount and +`CONTRACT_BALANCE`-from-user footguns). + +> ⚠️ **Signing an `execute` plan is equivalent to handing over the sub-account.** A malicious plan +> can borrow to the market maximum and direct everything to an arbitrary address with no token +> approval required. Never execute a plan built by an untrusted party β€” build the calldata +> yourself with `MarginPlanner`. + +## Deployments + +Resolved via `getMarginAddresses(chainId)`; Ethereum mainnet today: + +| Contract | Address | +| ---------------------------- | -------------------------------------------- | +| MarginRouter | `0x0000000004BBC92D0657580CAe35aEBF054E5CDC` | +| MarginAccount implementation | `0x83Fc96d2B162dAF8532e5677C6Ec32A1Cb7882E4` | +| MorphoLendingAdapter | `0x9A7f8F5A9496D3c9dc0BEEfb44cCaC17CAAF28fa` | +| AaveLendingAdapter (v3) | `0x8EeacdB24c7650478496845A61f03fF6BC263222` | +| AaveV4LendingAdapter | `0x3a9Cc5eEbAC911E5a316de1F2bCD166016d7469E` | + +The SDK's ABIs, selectors, and account derivation are test-anchored against this live deployment +(see `src/*.test.ts`). + +## Error handling + +All SDK validation throws `MarginSdkError` with a stable `code` +(`INVALID_LEVERAGE`, `INVALID_AMOUNT`, `AMOUNT_OVERFLOW`, `SLIPPAGE_BOUND_REQUIRED`, +`MARKET_MISMATCH`, `INVALID_PLAN`, …) β€” catch with `isMarginSdkError` and forward. Onchain reverts +(`SlippageBoundRequired`, `PositionUnhealthy`, `AdapterNotAllowed`, `DeadlinePassed`, +`NativeCollateralMismatch`, `IncompleteFill`, …) are declared in `MARGIN_ROUTER_ABI`, so viem's +`simulateContract` decodes them into readable messages β€” always simulate before writing. + +## Reference + +Full protocol and integration documentation lives in v4-periphery +[`docs/margin-trading.md`](https://github.com/Uniswap/v4-periphery/blob/margin-trading/docs/margin-trading.md), +including the security model, the `execute` opcode reference, and venue-specific notes +(Aave v4 hub-and-spoke, reserve ids, premium-inclusive debt). diff --git a/sdks/margin-sdk/src/index.ts b/sdks/margin-sdk/src/index.ts new file mode 100644 index 000000000..e9b220f30 --- /dev/null +++ b/sdks/margin-sdk/src/index.ts @@ -0,0 +1,32 @@ +/** + * @uniswap/margin-sdk + * + * A framework-agnostic toolkit for the Uniswap v4 margin trading periphery: leveraged spot + * positions built from a v4 swap plus a borrow/supply against an external lending venue (Morpho + * Blue, Aave v3, Aave v4), all behind one MarginRouter. + */ + +// Chains & addresses +export * from './chains' +export * from './addresses' + +// Constants & errors +export * from './constants' +export * from './errors' + +// Onchain struct mirrors & ABIs +export * from './types' +export * from './abis' + +// Markets, account derivation, leverage & health math +export * from './market' +export * from './account' +export * from './math' + +// Entry-point encoders & the execute-plan builder +export * from './encode' +export * from './actions' +export * from './planner' + +// Reads (descriptors + viem helpers) +export * from './reads' diff --git a/sdks/margin-sdk/src/reads.ts b/sdks/margin-sdk/src/reads.ts new file mode 100644 index 000000000..7a980e6ea --- /dev/null +++ b/sdks/margin-sdk/src/reads.ts @@ -0,0 +1,174 @@ +import { type Abi, type Address, type PublicClient } from 'viem' + +import { LENDING_ADAPTER_ABI, MARGIN_ACCOUNT_ABI, MARGIN_ROUTER_ABI } from './abis' +import { type Market, type PositionData } from './types' + +/** + * Read layer. Every read is exposed two ways: + * - a pure `*Call` **descriptor** β€” `{ address, abi, functionName, args }` β€” that drops straight + * into wagmi `useReadContract(s)`, viem multicall, or any rpc client. The SDK never binds a + * transport. + * - an async helper that executes the descriptor against a viem `PublicClient`, for scripts and + * quick server-side use. + * + * The `ILendingAdapter` read surface is identical across the Morpho Blue, Aave v3, and Aave v4 + * adapters, so the same read code works for any venue β€” only the adapter address changes. All + * position amounts are interest-accrued. + */ + +/** A framework-agnostic contract read descriptor. */ +export interface ContractCall { + address: Address + abi: TAbi + functionName: string + args: readonly unknown[] +} + +/** Execute a descriptor against a viem `PublicClient`. */ +export async function readContract(client: PublicClient, call: ContractCall): Promise { + return client.readContract({ + address: call.address, + abi: call.abi, + functionName: call.functionName, + args: call.args, + }) as Promise +} + +// --------------------------------------------------------------------------- +// Router: accounts, allowlist, governance +// --------------------------------------------------------------------------- + +/** `router.accountOf(owner, subId)` β€” the deterministic MarginAccount address (deployed or not). */ +export function accountOfCall(p: { + marginRouter: Address + owner: Address + subId?: bigint +}): ContractCall { + return { + address: p.marginRouter, + abi: MARGIN_ROUTER_ABI, + functionName: 'accountOf', + args: [p.owner, p.subId ?? 0n], + } +} + +/** Reads the MarginAccount address for `(owner, subId)`. Prefer `predictMarginAccountAddress` offchain. */ +export async function getAccount( + client: PublicClient, + p: { marginRouter: Address; owner: Address; subId?: bigint } +): Promise
{ + return readContract
(client, accountOfCall(p)) +} + +/** Whether the account at `address` has been deployed yet (positions can exist only after deploy). */ +export async function isAccountDeployed(client: PublicClient, account: Address): Promise { + const code = await client.getCode({ address: account }) + return !!code && code !== '0x' +} + +/** `router.isAdapterAllowed(adapter)` β€” whether the adapter can be used to add exposure. */ +export function isAdapterAllowedCall(p: { + marginRouter: Address + adapter: Address +}): ContractCall { + return { address: p.marginRouter, abi: MARGIN_ROUTER_ABI, functionName: 'isAdapterAllowed', args: [p.adapter] } +} + +/** Reads the adapter allowlist status (close/decrease never require it). */ +export async function getIsAdapterAllowed( + client: PublicClient, + p: { marginRouter: Address; adapter: Address } +): Promise { + return readContract(client, isAdapterAllowedCall(p)) +} + +/** `router.governance()`. */ +export function governanceCall(marginRouter: Address): ContractCall { + return { address: marginRouter, abi: MARGIN_ROUTER_ABI, functionName: 'governance', args: [] } +} + +// --------------------------------------------------------------------------- +// Adapter: market support and position state +// --------------------------------------------------------------------------- + +/** `adapter.isSupportedMarket(market)` β€” whether the (collateral, debt) pair is routable. */ +export function isSupportedMarketCall(p: { + adapter: Address + market: Market +}): ContractCall { + return { address: p.adapter, abi: LENDING_ADAPTER_ABI, functionName: 'isSupportedMarket', args: [p.market] } +} + +/** Reads whether the adapter routes `market`. */ +export async function getIsSupportedMarket( + client: PublicClient, + p: { adapter: Address; market: Market } +): Promise { + return readContract(client, isSupportedMarketCall(p)) +} + +/** `adapter.positionOf(account, market)` β€” (collateral, debt) amounts with accrued interest. */ +export function positionOfCall(p: { + adapter: Address + account: Address + market: Market +}): ContractCall { + return { address: p.adapter, abi: LENDING_ADAPTER_ABI, functionName: 'positionOf', args: [p.account, p.market] } +} + +/** `adapter.maxLtvWad(market)` β€” the market's maximum (liquidation) LTV (WAD, 1e18 == 100%). */ +export function maxLtvCall(p: { adapter: Address; market: Market }): ContractCall { + return { address: p.adapter, abi: LENDING_ADAPTER_ABI, functionName: 'maxLtvWad', args: [p.market] } +} + +/** `adapter.currentLtvWad(account, market)` β€” the position's current LTV (WAD). */ +export function currentLtvCall(p: { + adapter: Address + account: Address + market: Market +}): ContractCall { + return { address: p.adapter, abi: LENDING_ADAPTER_ABI, functionName: 'currentLtvWad', args: [p.account, p.market] } +} + +/** + * `adapter.describePosition(account, market)` β€” the consolidated snapshot: amounts, max/current + * LTV, and health factor in one call. + */ +export function describePositionCall(p: { + adapter: Address + account: Address + market: Market +}): ContractCall { + return { + address: p.adapter, + abi: LENDING_ADAPTER_ABI, + functionName: 'describePosition', + args: [p.account, p.market], + } +} + +/** + * Reads the consolidated position snapshot. Note for cross-collateral venues (Aave v3/v4): LTV + * and health factor are account-level, so keep one position per `(owner, subId)` β€” never co-locate + * two Aave markets under one sub-account. + */ +export async function getPosition( + client: PublicClient, + p: { adapter: Address; account: Address; market: Market } +): Promise { + return readContract(client, describePositionCall(p)) +} + +// --------------------------------------------------------------------------- +// Account views +// --------------------------------------------------------------------------- + +/** `account.owner()` β€” the immutable owner baked into the clone bytecode. */ +export function accountOwnerCall(account: Address): ContractCall { + return { address: account, abi: MARGIN_ACCOUNT_ABI, functionName: 'owner', args: [] } +} + +/** `account.manager()` β€” the immutable manager (the MarginRouter) baked into the clone bytecode. */ +export function accountManagerCall(account: Address): ContractCall { + return { address: account, abi: MARGIN_ACCOUNT_ABI, functionName: 'manager', args: [] } +} From bfd12c652022e81aac1b7fab0f73f8ef0b81c0f0 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 23 Jul 2026 19:47:00 -0400 Subject: [PATCH 06/17] feat(margin-sdk): add end-to-end demo flows against the live mainnet 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 --- sdks/margin-sdk/README.md | 12 + sdks/margin-sdk/demo/01-long-lifecycle.ts | 246 ++++++++++++++++++ sdks/margin-sdk/demo/02-native-eth.ts | 116 +++++++++ sdks/margin-sdk/demo/03-short-aave.ts | 148 +++++++++++ sdks/margin-sdk/demo/04-hedge-subaccounts.ts | 141 +++++++++++ sdks/margin-sdk/demo/05-execute-plans.ts | 173 +++++++++++++ sdks/margin-sdk/demo/README.md | 33 +++ sdks/margin-sdk/demo/lib/env.ts | 140 +++++++++++ sdks/margin-sdk/demo/lib/helpers.ts | 248 +++++++++++++++++++ sdks/margin-sdk/demo/run-all.ts | 24 ++ sdks/margin-sdk/package.json | 3 +- 11 files changed, 1283 insertions(+), 1 deletion(-) create mode 100644 sdks/margin-sdk/demo/01-long-lifecycle.ts create mode 100644 sdks/margin-sdk/demo/02-native-eth.ts create mode 100644 sdks/margin-sdk/demo/03-short-aave.ts create mode 100644 sdks/margin-sdk/demo/04-hedge-subaccounts.ts create mode 100644 sdks/margin-sdk/demo/05-execute-plans.ts create mode 100644 sdks/margin-sdk/demo/README.md create mode 100644 sdks/margin-sdk/demo/lib/env.ts create mode 100644 sdks/margin-sdk/demo/lib/helpers.ts create mode 100644 sdks/margin-sdk/demo/run-all.ts diff --git a/sdks/margin-sdk/README.md b/sdks/margin-sdk/README.md index 53b7f39d3..d902f6e52 100644 --- a/sdks/margin-sdk/README.md +++ b/sdks/margin-sdk/README.md @@ -250,6 +250,18 @@ Resolved via `getMarginAddresses(chainId)`; Ethereum mainnet today: The SDK's ABIs, selectors, and account derivation are test-anchored against this live deployment (see `src/*.test.ts`). +## End-to-end demos + +[`demo/`](./demo) contains runnable flows that validate the SDK against the live deployment on an +anvil mainnet fork β€” each mirrors a v4-periphery contract test: the full long lifecycle, +native-ETH equity, Aave v3/v4 shorts, a cross-venue delta-neutral hedge on sub-accounts, and raw +`execute` plans (including a `MarginPlanner` reconstruction of the curated open and the owner +escape hatch). With [foundry](https://getfoundry.sh) installed: + +```bash +bun run demo +``` + ## Error handling All SDK validation throws `MarginSdkError` with a stable `code` diff --git a/sdks/margin-sdk/demo/01-long-lifecycle.ts b/sdks/margin-sdk/demo/01-long-lifecycle.ts new file mode 100644 index 000000000..a041730b7 --- /dev/null +++ b/sdks/margin-sdk/demo/01-long-lifecycle.ts @@ -0,0 +1,246 @@ +/** + * Demo 01 β€” Full long lifecycle on Morpho Blue. + * Mirrors v4-periphery `MarginRouterIntegration.t.sol` + `MarginRouterE2E.fork.t.sol`: + * open a 2x long WETH/USDC, verify events and health, add collateral, add leverage with no new + * equity, partially delever, then fully close and collect the residual. + */ +import { parseUnits } from 'viem' + +import { type Ctx, withAnvil } from './lib/env' +import { + assert, + assertApprox, + balanceOf, + deadline, + deal, + ensurePermit2, + fmt, + fmtWad, + note, + ok, + quoteSwapInput, + routerEvent, + section, + send, +} from './lib/helpers' +import { + FULL_CLOSE, + addCollateralCall, + closePositionCall, + collateralToBuyForLeverage, + decreasePositionCall, + getPosition, + getAccount, + healthFactor, + impliedLtv, + increasePositionCall, + isAccountDeployed, + getIsAdapterAllowed, + getIsSupportedMarket, + parseLeverageX18, + predictMarginAccountAddress, +} from '../src' + +const SUB_ID = 0n + +export async function run(ctx: Ctx): Promise { + const { addresses, deployer, longMarket: market, poolKey, weth } = ctx + const adapter = addresses.lendingAdapters.morphoBlue! + const router = addresses.marginRouter + + section('01 Β· Long lifecycle on Morpho Blue (mirrors MarginRouterIntegration + E2E fork tests)') + + // -- 0. Preconditions read through SDK descriptors -------------------------------------------- + assert(await getIsAdapterAllowed(ctx.publicClient, { marginRouter: router, adapter }), 'Morpho adapter allowlisted') + assert(await getIsSupportedMarket(ctx.publicClient, { adapter, market }), 'WETH/USDC long market routable on Morpho') + + // -- 1. Offchain account derivation matches the router ---------------------------------------- + const predicted = predictMarginAccountAddress({ + owner: deployer, + subId: SUB_ID, + marginRouter: router, + accountImplementation: addresses.marginAccountImplementation, + }) + const onchain = await getAccount(ctx.publicClient, { marginRouter: router, owner: deployer, subId: SUB_ID }) + assert(predicted === onchain, `predictMarginAccountAddress == router.accountOf (${predicted})`) + assert(!(await isAccountDeployed(ctx.publicClient, predicted)), 'account not deployed yet (lazy CREATE2)') + + // -- 2. Fund equity + Permit2 setup ------------------------------------------------------------ + const equity = parseUnits('1', 18) + await deal(ctx, weth, deployer, parseUnits('10', 18)) + await ensurePermit2(ctx, weth) + ok('dealt 10 WETH and completed the two-step Permit2 approval') + + // -- 3. Size the swap from a REAL quote (never spot), then open 2x ----------------------------- + const leverage = parseLeverageX18(2) + const collateralToBuy = collateralToBuyForLeverage(equity, leverage) + const { quoted, capped: maxDebtIn } = await quoteSwapInput(ctx, market, market.debt, collateralToBuy, 50) + note( + `quote: buying ${fmt(collateralToBuy, 18, 'WETH')} costs ${fmt(quoted, 6, 'USDC')} β†’ cap ${fmt( + maxDebtIn, + 6, + 'USDC' + )}` + ) + + const openReceipt = await send( + ctx, + increasePositionCall({ + marginRouter: router, + params: { + adapter, + market, + poolKey, + equity, + collateralToBuy, + maxDebtIn, + maxLtvAfter: impliedLtv(leverage) + parseUnits('0.05', 18), // bound leverage by health too + subId: SUB_ID, + deadline: await deadline(ctx), + }, + }) + ) + + // -- 4. Events decode through the SDK ABI ------------------------------------------------------ + const created = routerEvent<{ owner: string; account: string; subId: bigint }>(openReceipt, 'AccountCreated') + assert(created?.account === predicted, 'AccountCreated emitted for the predicted address') + const increased = routerEvent<{ + equity: bigint + collateralBought: bigint + debtDrawn: bigint + collateralTotal: bigint + debtTotal: bigint + currentLtv: bigint + maxLtv: bigint + healthFactorWad: bigint + }>(openReceipt, 'PositionIncreased') + assert(increased !== undefined, 'PositionIncreased emitted') + assert( + increased!.equity === equity && increased!.collateralBought === collateralToBuy, + 'event amounts match the request' + ) + assert(increased!.debtDrawn <= maxDebtIn, 'debt drawn respected the binding maxDebtIn cap') + note(`entry: ${fmt(increased!.debtDrawn, 6, 'USDC')} drawn against ${fmt(increased!.collateralTotal, 18, 'WETH')}`) + + // -- 5. Position state matches the SDK math ---------------------------------------------------- + let position = await getPosition(ctx.publicClient, { adapter, account: predicted, market }) + assert(position.collateralAmount === 2n * equity, `2x: collateral is exactly ${fmt(2n * equity, 18, 'WETH')}`) + assertApprox(position.currentLtv, impliedLtv(leverage), 300, 'oracle LTV β‰ˆ impliedLtv(2x) = 50%') + assertApprox( + healthFactor(position.maxLtv, position.currentLtv), + position.healthFactorWad, + 1, + 'SDK healthFactor == onchain healthFactorWad' + ) + note( + `health: LTV ${fmtWad(position.currentLtv)} vs max ${fmtWad(position.maxLtv)} β†’ HF ${fmtWad( + position.healthFactorWad + )}` + ) + + // -- 6. addCollateral improves health without touching debt ------------------------------------ + const topUp = parseUnits('0.5', 18) + const addReceipt = await send( + ctx, + addCollateralCall({ + marginRouter: router, + params: { adapter, market, amount: topUp, subId: SUB_ID, deadline: await deadline(ctx) }, + }) + ) + const added = routerEvent<{ amount: bigint; debtTotal: bigint; currentLtv: bigint }>(addReceipt, 'CollateralAdded') + assert(added?.amount === topUp && added.debtTotal === position.debtAmount, 'CollateralAdded: debt unchanged') + assert(added!.currentLtv < position.currentLtv, 'LTV improved after the top-up') + + // -- 7. Pure leverage increase: equity = 0, same account --------------------------------------- + const extraBuy = parseUnits('0.2', 18) + const extraQuote = await quoteSwapInput(ctx, market, market.debt, extraBuy, 50) + const increaseReceipt = await send( + ctx, + increasePositionCall({ + marginRouter: router, + params: { + adapter, + market, + poolKey, + equity: 0n, + collateralToBuy: extraBuy, + maxDebtIn: extraQuote.capped, + subId: SUB_ID, + deadline: await deadline(ctx), + }, + }) + ) + assert(routerEvent(increaseReceipt, 'AccountCreated') === undefined, 'no second AccountCreated (same account)') + const before = position + position = await getPosition(ctx.publicClient, { adapter, account: predicted, market }) + note(`releveraged: ${fmt(position.collateralAmount, 18, 'WETH')} against ${fmt(position.debtAmount, 6, 'USDC')} debt`) + assert( + position.debtAmount > before.debtAmount && position.collateralAmount === before.collateralAmount + topUp + extraBuy, + 'leverage-only increase drew more debt with no new equity' + ) + + // -- 8. Partial decrease with a mandatory resulting-LTV bound ----------------------------------- + const repay = parseUnits('500', 6) + const decreaseQuote = await quoteSwapInput(ctx, market, market.collateral, repay, 50) + await send( + ctx, + decreasePositionCall({ + marginRouter: router, + params: { + adapter, + market, + poolKey, + debtToRepay: repay, + maxCollateralIn: decreaseQuote.capped, + maxLtvAfter: position.currentLtv + parseUnits('0.02', 18), + subId: SUB_ID, + deadline: await deadline(ctx), + }, + }) + ) + const afterDecrease = await getPosition(ctx.publicClient, { adapter, account: predicted, market }) + assertApprox(afterDecrease.debtAmount, position.debtAmount - repay, 5, 'debt reduced by exactly the repay amount') + assert(afterDecrease.currentLtv < position.currentLtv, 'partial delever lowered the LTV') + + // -- 9. Full close: FULL_CLOSE sentinel, residual (realized PnL) returned to the caller -------- + const closeQuote = await quoteSwapInput(ctx, market, market.collateral, afterDecrease.debtAmount, 100) + const wethBefore = await balanceOf(ctx, weth, deployer) + const closeReceipt = await send( + ctx, + closePositionCall({ + marginRouter: router, + params: { + adapter, + market, + poolKey, + maxCollateralIn: closeQuote.capped, + subId: SUB_ID, + deadline: await deadline(ctx), + }, + }) + ) + const decreased = routerEvent<{ debtTotal: bigint; collateralTotal: bigint; collateralReturned: bigint }>( + closeReceipt, + 'PositionDecreased' + ) + assert(decreased?.debtTotal === 0n && decreased.collateralTotal === 0n, 'full close emptied the position') + const residual = (await balanceOf(ctx, weth, deployer)) - wethBefore + assert( + residual === decreased!.collateralReturned && residual > 0n, + `residual returned to caller: ${fmt(residual, 18, 'WETH')}` + ) + const totalEquity = equity + topUp + assertApprox(residual, totalEquity, 200, 'residual β‰ˆ contributed equity (same-block: only swap fees lost)') + + const final = await getPosition(ctx.publicClient, { adapter, account: predicted, market }) + assert( + final.collateralAmount === 0n && final.debtAmount === 0n && final.healthFactorWad === FULL_CLOSE, + 'position fully cleared' + ) + + ok('01 complete: open β†’ verify β†’ top-up β†’ releverage β†’ delever β†’ close, all through SDK calls') +} + +if (import.meta.main) { + await withAnvil(run) +} diff --git a/sdks/margin-sdk/demo/02-native-eth.ts b/sdks/margin-sdk/demo/02-native-eth.ts new file mode 100644 index 000000000..8b3764019 --- /dev/null +++ b/sdks/margin-sdk/demo/02-native-eth.ts @@ -0,0 +1,116 @@ +/** + * Demo 02 β€” Native-ETH equity flows. + * Mirrors v4-periphery `MarginRouterNative.t.sol`: open a long funded with raw ETH as + * `msg.value` (the router wraps to WETH β€” no ERC-20 or Permit2 approval needed for the equity), + * top up collateral with native ETH, then close. + */ +import { parseUnits } from 'viem' + +import { type Ctx, withAnvil } from './lib/env' +import { + assert, + assertApprox, + balanceOf, + deadline, + fmt, + note, + ok, + quoteSwapInput, + routerEvent, + section, + send, +} from './lib/helpers' +import { + addCollateralCall, + closePositionCall, + collateralToBuyForLeverage, + getMarginAccountAddress, + getPosition, + increasePositionCall, + parseLeverageX18, +} from '../src' + +const SUB_ID = 1n + +export async function run(ctx: Ctx): Promise { + const { addresses, deployer, longMarket: market, poolKey, weth } = ctx + const adapter = addresses.lendingAdapters.morphoBlue! + const router = addresses.marginRouter + + section('02 Β· Native-ETH equity (mirrors MarginRouterNative.t.sol)') + + const account = getMarginAccountAddress(1, deployer, SUB_ID) + const equity = parseUnits('1', 18) + const leverage = parseLeverageX18(2) + const collateralToBuy = collateralToBuyForLeverage(equity, leverage) + const { capped: maxDebtIn } = await quoteSwapInput(ctx, market, market.debt, collateralToBuy, 50) + + // Open with `nativeEquity`: the SDK sets the transaction value and requires equity == 0n. + // The market collateral must be WETH or the router reverts NativeCollateralMismatch. + const openReceipt = await send( + ctx, + increasePositionCall({ + marginRouter: router, + params: { + adapter, + market, + poolKey, + equity: 0n, + collateralToBuy, + maxDebtIn, + subId: SUB_ID, + deadline: await deadline(ctx), + }, + nativeEquity: equity, + }) + ) + const increased = routerEvent<{ equity: bigint; collateralTotal: bigint }>(openReceipt, 'PositionIncreased') + assert(increased?.equity === equity, 'msg.value became the position equity (wrapped to WETH)') + assert(increased!.collateralTotal === 2n * equity, '2x native open holds exactly 2 WETH collateral') + note(`opened with raw ETH: no ERC-20 approval, no Permit2 β€” value=${fmt(equity, 18, 'ETH')}`) + + // Top up collateral with native ETH too. + const topUp = parseUnits('0.25', 18) + await send( + ctx, + addCollateralCall({ + marginRouter: router, + params: { adapter, market, amount: 0n, subId: SUB_ID, deadline: await deadline(ctx) }, + nativeAmount: topUp, + }) + ) + const position = await getPosition(ctx.publicClient, { adapter, account, market }) + note(`collateral after native top-up: ${position.collateralAmount} (expected ${2n * equity + topUp})`) + assert(position.collateralAmount === 2n * equity + topUp, 'native addCollateral credited the account in WETH') + + // Close: the residual comes back as the collateral token (WETH), not native ETH. + const closeQuote = await quoteSwapInput(ctx, market, market.collateral, position.debtAmount, 100) + const wethBefore = await balanceOf(ctx, weth, deployer) + await send( + ctx, + closePositionCall({ + marginRouter: router, + params: { + adapter, + market, + poolKey, + maxCollateralIn: closeQuote.capped, + subId: SUB_ID, + deadline: await deadline(ctx), + }, + }) + ) + const residual = (await balanceOf(ctx, weth, deployer)) - wethBefore + assertApprox( + residual, + equity + topUp, + 200, + `close returned β‰ˆ the ETH contributed, as WETH (${fmt(residual, 18, 'WETH')})` + ) + + ok('02 complete: native-ETH open, native top-up, close β€” equity never touched an approval') +} + +if (import.meta.main) { + await withAnvil(run) +} diff --git a/sdks/margin-sdk/demo/03-short-aave.ts b/sdks/margin-sdk/demo/03-short-aave.ts new file mode 100644 index 000000000..b2637a89a --- /dev/null +++ b/sdks/margin-sdk/demo/03-short-aave.ts @@ -0,0 +1,148 @@ +/** + * Demo 03 β€” Short ETH through Aave v3 and Aave v4. + * Mirrors v4-periphery `AaveLendingAdapter.fork.t.sol`, `AaveV4LendingAdapter.fork.t.sol`, and + * `MarginRouterShortInverse.t.sol`: a short is the SAME `increasePosition` call with the market + * pairing reversed β€” collateral USDC (6 decimals), debt WETH (18 decimals) β€” and the venue chosen + * per call by adapter. The same read code serves every venue. + */ +import { parseUnits } from 'viem' + +import { type Ctx, withAnvil } from './lib/env' +import { + assert, + assertApprox, + deadline, + deal, + ensurePermit2, + fmt, + fmtWad, + note, + ok, + quoteSwapInput, + routerEvent, + section, + send, +} from './lib/helpers' +import { + type LendingVenue, + closePositionCall, + collateralToBuyForLeverage, + getIsSupportedMarket, + getMarginAccountAddress, + getPosition, + impliedLtv, + increasePositionCall, + parseLeverageX18, + WAD, +} from '../src' + +// One Aave position per (owner, subId): Aave health is account-wide, so each venue gets its own +// sub-account (the docs' Β§3.2 rule the router does not enforce for you). +const SUB_IDS: Partial> = { aaveV3: 2n, aaveV4: 3n } + +async function shortLifecycle(ctx: Ctx, venue: LendingVenue): Promise { + const { addresses, deployer, shortMarket: market, poolKey } = ctx + const adapter = addresses.lendingAdapters[venue]! + const router = addresses.marginRouter + const subId = SUB_IDS[venue]! + + note(`β€” venue: ${venue} (adapter ${adapter}, subId ${subId}) β€”`) + assert( + await getIsSupportedMarket(ctx.publicClient, { adapter, market }), + `USDC/WETH short market routable on ${venue}` + ) + + // Decimals reverse on a short: equity & collateralToBuy in 6-decimal USDC, maxDebtIn in + // 18-decimal WETH. The SDK sizing is decimal-agnostic β€” direction comes from the market pairing. + const equity = parseUnits('2000', 6) + const leverage = parseLeverageX18(2) + const collateralToBuy = collateralToBuyForLeverage(equity, leverage) + const { quoted, capped: maxDebtIn } = await quoteSwapInput(ctx, market, market.debt, collateralToBuy, 50) + note( + `quote: buying ${fmt(collateralToBuy, 6, 'USDC')} costs ${fmt(quoted, 18, 'WETH')} β†’ cap ${fmt( + maxDebtIn, + 18, + 'WETH' + )}` + ) + + const openReceipt = await send( + ctx, + increasePositionCall({ + marginRouter: router, + params: { + adapter, + market, + poolKey, + equity, + collateralToBuy, + maxDebtIn, + maxLtvAfter: impliedLtv(leverage) + parseUnits('0.05', 18), + subId, + deadline: await deadline(ctx), + }, + }) + ) + const increased = routerEvent<{ collateral: string; debt: string; debtDrawn: bigint; maxLtv: bigint }>( + openReceipt, + 'PositionIncreased' + ) + assert( + increased!.collateral.toLowerCase() === market.collateral.toLowerCase() && + increased!.debt.toLowerCase() === market.debt.toLowerCase(), + 'short direction is just the reversed (collateral, debt) pairing β€” no flag' + ) + + const account = getMarginAccountAddress(1, deployer, subId) + const position = await getPosition(ctx.publicClient, { adapter, account, market }) + // Aave positionOf reads the rebasing aToken balance: allow index-rounding wei, unlike Morpho. + assertApprox(position.collateralAmount, 2n * equity, 1, `2x short holds β‰ˆ${fmt(2n * equity, 6, 'USDC')} collateral`) + assertApprox(position.currentLtv, WAD / 2n, 500, 'account-level LTV β‰ˆ 50% at 2x') + // Both Aave adapters surface the USDC reserve's 78% liquidation threshold as maxLtv (docs Β§10). + assertApprox(position.maxLtv, parseUnits('0.78', 18), 100, `${venue} maxLtv β‰ˆ the 78% USDC liquidation threshold`) + note( + `short live on ${venue}: ${fmt(position.collateralAmount, 6, 'USDC')} vs ${fmt( + position.debtAmount, + 18, + 'WETH' + )} debt, ` + `LTV ${fmtWad(position.currentLtv)}, HF ${fmtWad(position.healthFactorWad)}` + ) + + // Close it: sell USDC collateral, buy back the WETH debt (premium-inclusive on Aave v4). + const closeQuote = await quoteSwapInput(ctx, market, market.collateral, position.debtAmount, 100) + const closeReceipt = await send( + ctx, + closePositionCall({ + marginRouter: router, + params: { adapter, market, poolKey, maxCollateralIn: closeQuote.capped, subId, deadline: await deadline(ctx) }, + }) + ) + const decreased = routerEvent<{ debtTotal: bigint; collateralTotal: bigint; collateralReturned: bigint }>( + closeReceipt, + 'PositionDecreased' + ) + assert(decreased?.debtTotal === 0n && decreased.collateralTotal === 0n, `${venue} short fully closed`) + assertApprox( + decreased!.collateralReturned, + equity, + 200, + `residual β‰ˆ USDC equity (${fmt(decreased!.collateralReturned, 6, 'USDC')})` + ) +} + +export async function run(ctx: Ctx): Promise { + section('03 Β· Short ETH on Aave v3 + Aave v4 (mirrors the Aave adapter fork tests)') + + await deal(ctx, ctx.usdc, ctx.deployer, parseUnits('50000', 6)) + await ensurePermit2(ctx, ctx.usdc) + ok('dealt 50,000 USDC and completed the Permit2 setup') + + await shortLifecycle(ctx, 'aaveV3') + await shortLifecycle(ctx, 'aaveV4') + + ok('03 complete: identical SDK code shorted ETH on two venues β€” only the adapter address changed') +} + +if (import.meta.main) { + await withAnvil(run) +} diff --git a/sdks/margin-sdk/demo/04-hedge-subaccounts.ts b/sdks/margin-sdk/demo/04-hedge-subaccounts.ts new file mode 100644 index 000000000..2cadfe9b7 --- /dev/null +++ b/sdks/margin-sdk/demo/04-hedge-subaccounts.ts @@ -0,0 +1,141 @@ +/** + * Demo 04 β€” Delta-neutral hedge across sub-accounts. + * Mirrors v4-periphery `MarginRouterHedge.fork.t.sol` (cross-venue): a Morpho long on subId 4 and + * an Aave v3 short on subId 5, sized to the same ETH notional. The two positions live in isolated + * MarginAccounts derived from the same owner, net ETH delta β‰ˆ 0, and closing one leaves the other + * untouched. + */ +import { parseUnits } from 'viem' + +import { type Ctx, withAnvil } from './lib/env' +import { + assert, + assertApprox, + deadline, + deal, + ensurePermit2, + fmt, + note, + ok, + quoteSwapInput, + section, + send, +} from './lib/helpers' +import { + closePositionCall, + collateralToBuyForLeverage, + getMarginAccountAddress, + getPosition, + increasePositionCall, + parseLeverageX18, +} from '../src' + +const LONG_SUB_ID = 4n +const SHORT_SUB_ID = 5n + +export async function run(ctx: Ctx): Promise { + const { addresses, deployer, longMarket, shortMarket, poolKey, weth, usdc } = ctx + const router = addresses.marginRouter + const morpho = addresses.lendingAdapters.morphoBlue! + const aaveV3 = addresses.lendingAdapters.aaveV3! + + section('04 Β· Cross-venue hedge on sub-accounts (mirrors MarginRouterHedge.fork.t.sol)') + + await deal(ctx, weth, deployer, parseUnits('5', 18)) + await deal(ctx, usdc, deployer, parseUnits('20000', 6)) + await ensurePermit2(ctx, weth) + await ensurePermit2(ctx, usdc) + + // Two distinct accounts from one owner β€” pure functions of (owner, subId). + const longAccount = getMarginAccountAddress(1, deployer, LONG_SUB_ID) + const shortAccount = getMarginAccountAddress(1, deployer, SHORT_SUB_ID) + assert(longAccount !== shortAccount, `isolated accounts: long ${longAccount}, short ${shortAccount}`) + + // Leg 1 β€” 2x long on Morpho: 0.5 WETH equity β†’ 1 WETH collateral (1 WETH of long exposure). + const longEquity = parseUnits('0.5', 18) + const leverage = parseLeverageX18(2) + const longBuy = collateralToBuyForLeverage(longEquity, leverage) + const longQuote = await quoteSwapInput(ctx, longMarket, longMarket.debt, longBuy, 50) + await send( + ctx, + increasePositionCall({ + marginRouter: router, + params: { + adapter: morpho, + market: longMarket, + poolKey, + equity: longEquity, + collateralToBuy: longBuy, + maxDebtIn: longQuote.capped, + subId: LONG_SUB_ID, + deadline: await deadline(ctx), + }, + }) + ) + + // Leg 2 β€” 2x short on Aave v3, sized so the WETH debt matches the long's WETH collateral. + // Size from NEAR-SPOT (a tiny probe quote scaled up): a full-size exact-out quote embeds the + // swap's own price impact, which would systematically oversize the short's debt draw. + const probe = parseUnits('0.01', 18) + const { quoted: probeUsdc } = await quoteSwapInput(ctx, longMarket, longMarket.debt, probe, 0) + const targetShortNotional = 2n * longEquity + const shortBuyUsdc = (probeUsdc * targetShortNotional) / probe + const shortEquity = shortBuyUsdc // (L-1)/1 of a 2x: equity equals the bought amount + const shortQuote = await quoteSwapInput(ctx, shortMarket, shortMarket.debt, shortBuyUsdc, 50) + await send( + ctx, + increasePositionCall({ + marginRouter: router, + params: { + adapter: aaveV3, + market: shortMarket, + poolKey, + equity: shortEquity, + collateralToBuy: shortBuyUsdc, + maxDebtIn: shortQuote.capped, + subId: SHORT_SUB_ID, + deadline: await deadline(ctx), + }, + }) + ) + + // Net ETH delta: long collateral (WETH held) vs short debt (WETH owed). + const long = await getPosition(ctx.publicClient, { adapter: morpho, account: longAccount, market: longMarket }) + const short = await getPosition(ctx.publicClient, { adapter: aaveV3, account: shortAccount, market: shortMarket }) + note(`long: +${fmt(long.collateralAmount, 18, 'WETH')} Β· short: -${fmt(short.debtAmount, 18, 'WETH')}`) + // The residual delta is each leg's real price impact + fee on the live 0.05% pool. + assertApprox(short.debtAmount, long.collateralAmount, 150, 'net ETH delta β‰ˆ 0 (within live-pool swap impact)') + + // Close the short; the long is untouched (accounts are fully isolated). + const closeQuote = await quoteSwapInput(ctx, shortMarket, shortMarket.collateral, short.debtAmount, 100) + await send( + ctx, + closePositionCall({ + marginRouter: router, + params: { + adapter: aaveV3, + market: shortMarket, + poolKey, + maxCollateralIn: closeQuote.capped, + subId: SHORT_SUB_ID, + deadline: await deadline(ctx), + }, + }) + ) + const shortAfter = await getPosition(ctx.publicClient, { + adapter: aaveV3, + account: shortAccount, + market: shortMarket, + }) + const longAfter = await getPosition(ctx.publicClient, { adapter: morpho, account: longAccount, market: longMarket }) + assert(shortAfter.debtAmount === 0n && shortAfter.collateralAmount === 0n, 'short leg closed') + assert(longAfter.collateralAmount === long.collateralAmount, 'long collateral untouched by closing the short') + // interest-accrued reads: the long's debt grows by a few wei across the blocks the close took + assertApprox(longAfter.debtAmount, long.debtAmount, 1, 'long debt unchanged (Β± accrued interest)') + + ok('04 complete: one owner, two isolated positions, delta-neutral, independently unwindable') +} + +if (import.meta.main) { + await withAnvil(run) +} diff --git a/sdks/margin-sdk/demo/05-execute-plans.ts b/sdks/margin-sdk/demo/05-execute-plans.ts new file mode 100644 index 000000000..edd6ffb38 --- /dev/null +++ b/sdks/margin-sdk/demo/05-execute-plans.ts @@ -0,0 +1,173 @@ +/** + * Demo 05 β€” The `execute` entry point and the owner escape hatch. + * Mirrors v4-periphery `MarginRouterExecute.t.sol` / `MarginRouterExecute.fork.t.sol`: + * (a) rebuild the curated open as a raw MarginPlanner plan β€” the exact action sequence + * `MarginRouter._increase` encodes internally β€” and run it through `execute`; + * (b) repay debt straight from the wallet (a flow no curated entry point can express); + * (c) exit through the owner-only `MarginAccount.execute` escape hatch, bypassing the router. + */ +import { parseEventLogs, parseUnits } from 'viem' + +import { type Ctx, withAnvil } from './lib/env' +import { + assert, + assertApprox, + balanceOf, + deadline, + deal, + ensurePermit2, + fmt, + note, + ok, + quoteSwapInput, + section, + send, +} from './lib/helpers' +import { + LENDING_ADAPTER_ABI, + MARGIN_ACCOUNT_ABI, + MarginPlanner, + OPEN_DELTA, + closePositionCall, + collateralToBuyForLeverage, + executeCall, + getMarginAccountAddress, + getPosition, + impliedLtv, + parseLeverageX18, + swapZeroForOne, +} from '../src' + +const SUB_ID = 6n + +export async function run(ctx: Ctx): Promise { + const { addresses, deployer, longMarket: market, poolKey, weth, usdc } = ctx + const adapter = addresses.lendingAdapters.morphoBlue! + const router = addresses.marginRouter + const account = getMarginAccountAddress(1, deployer, SUB_ID) + + section('05 Β· execute plans + owner escape hatch (mirrors the MarginRouterExecute tests)') + + await deal(ctx, weth, deployer, parseUnits('5', 18)) + await deal(ctx, usdc, deployer, parseUnits('5000', 6)) + await ensurePermit2(ctx, weth) + await ensurePermit2(ctx, usdc) + + // -- (a) Manual open: the byte-level plan the curated increasePosition builds internally ------ + const equity = parseUnits('1', 18) + const leverage = parseLeverageX18(2) + const collateralToBuy = collateralToBuyForLeverage(equity, leverage) + const { capped: maxDebtIn } = await quoteSwapInput(ctx, market, market.debt, collateralToBuy, 50) + + const openPlan = new MarginPlanner() + .setAccount(SUB_ID) // bind (and lazily deploy) the caller's sub-account + .pullToAccount(market.collateral, equity, true) // equity via Permit2, straight to the account + .swapExactOutSingle({ + poolKey, + zeroForOne: swapZeroForOne(market, market.debt, poolKey), // opens sell the debt + amountOut: collateralToBuy, + amountInMaximum: maxDebtIn, // the binding slippage cap, from a real quote + }) + .assertFill(market.collateral, collateralToBuy) // all-or-nothing on thin pools + .take(market.collateral, account, OPEN_DELTA) // bought collateral β†’ the account + .supplyCollateral(adapter, market, OPEN_DELTA) // supply the account's full balance + .borrow(adapter, market, OPEN_DELTA, router) // draw exactly the swap's debt, to the router + .settle(market.debt, OPEN_DELTA, false) // router pays the PoolManager + .assertHealth(adapter, market, impliedLtv(leverage) + parseUnits('0.05', 18)) + .finalize() + + const openReceipt = await send( + ctx, + executeCall({ marginRouter: router, unlockData: openPlan, deadline: await deadline(ctx) }) + ) + // execute plans emit account-level events (not Position* snapshots) β€” decode with the SDK ABI. + const accountEvents = parseEventLogs({ abi: MARGIN_ACCOUNT_ABI, logs: openReceipt.logs }) + const eventNames = accountEvents.map((event) => event.eventName) + assert(eventNames.includes('CollateralSupplied') && eventNames.includes('Borrowed'), 'account-level events emitted') + + let position = await getPosition(ctx.publicClient, { adapter, account, market }) + assert( + position.collateralAmount === 2n * equity, + `manual plan opened the same 2x position: ${fmt(position.collateralAmount, 18, 'WETH')}` + ) + assertApprox( + position.currentLtv, + impliedLtv(leverage), + 300, + 'manual open lands at impliedLtv(2x) like the curated flow' + ) + + // -- (b) Repay-from-wallet: inexpressible via the curated entry points ------------------------ + const repay = parseUnits('200', 6) + const repayPlan = new MarginPlanner() + .setAccount(SUB_ID) + .pullToAccount(market.debt, repay, true) // USDC from the wallet into the account + .repay(adapter, market, repay) // repay without selling any collateral + .assertHealth(adapter, market, position.currentLtv + parseUnits('0.01', 18)) + .finalize() + const debtBefore = position.debtAmount + const repayReceipt = await send( + ctx, + executeCall({ marginRouter: router, unlockData: repayPlan, deadline: await deadline(ctx) }) + ) + const repaid = parseEventLogs({ abi: MARGIN_ACCOUNT_ABI, logs: repayReceipt.logs, eventName: 'Repaid' }) + assert( + repaid.length === 1 && (repaid[0].args as { amount: bigint }).amount === repay, + 'Repaid event: exact wallet amount' + ) + position = await getPosition(ctx.publicClient, { adapter, account, market }) + assertApprox(position.debtAmount, debtBefore - repay, 5, 'debt cut from the wallet; collateral untouched') + + // -- (c) Owner escape hatch: act on Morpho directly, no router involvement -------------------- + // The adapter is an encoder: read the exact (target, value, callData) the account would run. + const withdraw = parseUnits('0.05', 18) + const [, , callData] = await ctx.publicClient.readContract({ + address: adapter, + abi: LENDING_ADAPTER_ABI, + functionName: 'encodeWithdrawCollateral', + args: [account, market, withdraw, deployer], + account, // encode as the account would (adapters may bind the caller) + }) + const wethBefore = await balanceOf(ctx, weth, deployer) + await send(ctx, { + address: account, + abi: MARGIN_ACCOUNT_ABI, + functionName: 'execute', // owner-only; forwards to the adapter's lendingProtocol() + args: [adapter, callData], + }) + assert( + (await balanceOf(ctx, weth, deployer)) - wethBefore === withdraw, + 'escape hatch withdrew collateral to the owner, router bypassed' + ) + const afterHatch = await getPosition(ctx.publicClient, { adapter, account, market }) + assert( + afterHatch.collateralAmount === position.collateralAmount - withdraw, + 'position reflects the direct Morpho withdrawal' + ) + note('the owner can always exit on the lending protocol directly β€” funds are never trapped behind the router') + + // Clean finish through the curated close. + const closeQuote = await quoteSwapInput(ctx, market, market.collateral, afterHatch.debtAmount, 100) + await send( + ctx, + closePositionCall({ + marginRouter: router, + params: { + adapter, + market, + poolKey, + maxCollateralIn: closeQuote.capped, + subId: SUB_ID, + deadline: await deadline(ctx), + }, + }) + ) + const final = await getPosition(ctx.publicClient, { adapter, account, market }) + assert(final.collateralAmount === 0n && final.debtAmount === 0n, 'position closed') + + ok('05 complete: a raw plan reproduced the curated open, repaid from the wallet, and exited via the escape hatch') +} + +if (import.meta.main) { + await withAnvil(run) +} diff --git a/sdks/margin-sdk/demo/README.md b/sdks/margin-sdk/demo/README.md new file mode 100644 index 000000000..470bc3571 --- /dev/null +++ b/sdks/margin-sdk/demo/README.md @@ -0,0 +1,33 @@ +# margin-sdk demos + +End-to-end validation that the SDK can drive **every flow the v4-periphery margin contract tests +exercise**, against the **live mainnet deployment** (router, adapters, Morpho Blue, Aave v3, +Aave v4, and the real USDC/WETH 0.05% v4 pool) on an anvil fork. The sender is the margin +deployer/governance EOA (`0x58e28b95a2ee57c4E90613AFce9e8CCEED3aB1E8`), impersonated by anvil. + +```bash +bun run demo # all flows on one fork +bun demo/01-long-lifecycle.ts # or any single flow +MARGIN_DEMO_RPC= bun run demo # custom fork RPC (defaults to publicnode) +``` + +Requires [foundry](https://getfoundry.sh) (`anvil` on the PATH). Each flow boots (or shares) a +fork pinned ~32 blocks below head, funds the sender with `deal`-style storage writes, and runs +every transaction through `simulateContract` β†’ `writeContract` with receipt-status checks β€” a +revert anywhere fails the run. + +| Demo | Mirrors (v4-periphery test) | What it proves | +| ---- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `01` | `MarginRouterIntegration.t.sol`, `MarginRouterE2E.fork.t.sol` | Full long lifecycle on Morpho: offchain `accountOf` prediction, Permit2 setup, quoter-sized 2x open, event decoding, SDK-vs-onchain health math, top-up, leverage-only increase, partial delever, full close with residual returned. | +| `02` | `MarginRouterNative.t.sol` | Native-ETH equity: open and top up with raw `msg.value` (no approvals), close returns WETH. | +| `03` | `AaveLendingAdapter.fork.t.sol`, `AaveV4LendingAdapter.fork.t.sol`, `MarginRouterShortInverse.t.sol` | Short ETH (collateral USDC / debt WETH, reversed decimals) with identical SDK code on Aave v3 **and** Aave v4 β€” only the adapter address changes; the 78% USDC liquidation threshold reads back on both. | +| `04` | `MarginRouterCrossVenueHedge.fork.t.sol` | Delta-neutral long (Morpho) + short (Aave v3) under one owner on isolated sub-accounts; net ETH delta within live-pool swap impact; closing one leg leaves the other untouched. | +| `05` | `MarginRouterExecute.t.sol`, `MarginRouterExecute.fork.t.sol` | The `execute` entry point: a raw `MarginPlanner` plan reproducing the curated open action-for-action, a repay-from-wallet plan no curated entry can express, and the owner-only `MarginAccount.execute` escape hatch acting on Morpho directly. | + +Anvil-fork notes baked into the harness (`lib/env.ts`, `lib/helpers.ts`): + +- the fork is pinned below head because load-balanced public RPCs serve inconsistent tip state; +- sends carry padded gas β€” anvil's fork-mode `eth_estimateGas` runs a hair low on lazily-loaded + cold slots; +- Aave reads are aToken-rebasing (Β± wei) and Morpho debt accrues per block, so cross-block + assertions use tolerances where the venue's accounting demands it. diff --git a/sdks/margin-sdk/demo/lib/env.ts b/sdks/margin-sdk/demo/lib/env.ts new file mode 100644 index 000000000..beeb702ff --- /dev/null +++ b/sdks/margin-sdk/demo/lib/env.ts @@ -0,0 +1,140 @@ +import { type Subprocess } from 'bun' +import { + http, + type Address, + type PublicClient, + type TestClient, + type WalletClient, + createPublicClient, + createTestClient, + createWalletClient, +} from 'viem' +import { mainnet } from 'viem/chains' + +import { + type MarginAddresses, + type Market, + type PoolKey, + SupportedChainId, + getMarginAddresses, + toPoolKey, +} from '../../src' + +/** + * Demo environment: an anvil fork of Ethereum mainnet with the margin deployer impersonated as + * the sender. Every flow runs against the LIVE deployed margin stack (router, adapters, Morpho + * Blue, Aave v3, Aave v4) and the real, liquid USDC/WETH 0.05% v4 pool β€” the same surfaces the + * v4-periphery fork tests exercise. + */ + +/** The margin deployment deployer/governance EOA β€” the impersonated sender for all demo flows. */ +export const DEPLOYER: Address = '0x58e28b95a2ee57c4E90613AFce9e8CCEED3aB1E8' + +export const USDC: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + +/** Canonical mainnet v4 Quoter (sdk-core `v4QuoterAddress`); used to derive real swap quotes. */ +export const V4_QUOTER: Address = '0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203' + +const DEFAULT_FORK_RPC = process.env.MARGIN_DEMO_RPC ?? process.env.RPC_URL ?? 'https://ethereum-rpc.publicnode.com' + +export interface Ctx { + rpcUrl: string + publicClient: PublicClient + testClient: TestClient + wallet: WalletClient + deployer: Address + addresses: MarginAddresses + weth: Address + usdc: Address + /** The real mainnet v4 USDC/WETH 0.05% hookless pool (the most liquid USDC/WETH v4 pool). */ + poolKey: PoolKey + /** Long ETH: collateral WETH, debt USDC. */ + longMarket: Market + /** Short ETH: collateral USDC, debt WETH. */ + shortMarket: Market +} + +export function buildCtx(rpcUrl: string): Ctx { + const addresses = getMarginAddresses(SupportedChainId.MAINNET)! + const weth = addresses.weth9 + const transport = http(rpcUrl) + return { + rpcUrl, + publicClient: createPublicClient({ chain: mainnet, transport }), + testClient: createTestClient({ chain: mainnet, mode: 'anvil', transport }), + wallet: createWalletClient({ chain: mainnet, transport, account: DEPLOYER }), + deployer: DEPLOYER, + addresses, + weth, + usdc: USDC, + poolKey: toPoolKey({ currencyA: weth, currencyB: USDC, fee: 500, tickSpacing: 10 }), + longMarket: { collateral: weth, debt: USDC }, + shortMarket: { collateral: USDC, debt: weth }, + } +} + +async function waitForRpc(rpcUrl: string, timeoutMs = 60_000): Promise { + const start = Date.now() + for (;;) { + try { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_blockNumber', params: [] }), + }) + const body = (await response.json()) as { result?: string } + if (body.result) return + } catch { + // anvil not up yet + } + if (Date.now() - start > timeoutMs) throw new Error(`anvil did not become ready on ${rpcUrl}`) + await new Promise((resolve) => setTimeout(resolve, 250)) + } +} + +async function latestBlockNumber(rpcUrl: string): Promise { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_blockNumber', params: [] }), + }) + const body = (await response.json()) as { result: string } + return BigInt(body.result) +} + +export async function startAnvil(): Promise<{ rpcUrl: string; proc: Subprocess }> { + const port = 8545 + Math.floor(Math.random() * 500) + const rpcUrl = `http://127.0.0.1:${port}` + // Pin the fork a safe depth below head: public load-balanced RPCs serve inconsistent state + // near the tip, which makes anvil's lazily-fetched fork state flaky. + const forkBlock = (await latestBlockNumber(DEFAULT_FORK_RPC)) - 32n + const proc = Bun.spawn( + [ + 'anvil', + '--fork-url', + DEFAULT_FORK_RPC, + '--fork-block-number', + forkBlock.toString(), + '--port', + String(port), + '--auto-impersonate', + '--silent', + ], + { stdout: 'ignore', stderr: 'inherit' } + ) + process.on('exit', () => proc.kill()) + await waitForRpc(rpcUrl) + return { rpcUrl, proc } +} + +/** Boots a fork, funds the deployer with gas ETH, runs `fn`, and tears the fork down. */ +export async function withAnvil(fn: (ctx: Ctx) => Promise): Promise { + const { rpcUrl, proc } = await startAnvil() + try { + const ctx = buildCtx(rpcUrl) + await ctx.testClient.setBalance({ address: DEPLOYER, value: 1_000n * 10n ** 18n }) + await fn(ctx) + } finally { + proc.kill() + } +} diff --git a/sdks/margin-sdk/demo/lib/helpers.ts b/sdks/margin-sdk/demo/lib/helpers.ts new file mode 100644 index 000000000..14c7dbeb6 --- /dev/null +++ b/sdks/margin-sdk/demo/lib/helpers.ts @@ -0,0 +1,248 @@ +import { + type Abi, + type Address, + type Hex, + type TransactionReceipt, + encodeAbiParameters, + erc20Abi, + formatUnits, + keccak256, + parseEventLogs, + toHex, +} from 'viem' + +import { type Ctx, V4_QUOTER } from './env' +import { + MARGIN_ROUTER_ABI, + MAX_UINT160, + type Market, + swapZeroForOne, + withSlippageUp, + permit2ApproveCall, +} from '../../src' + +// --------------------------------------------------------------------------- +// Logging & assertions +// --------------------------------------------------------------------------- + +const GREEN = '\x1b[32m' +const CYAN = '\x1b[36m' +const DIM = '\x1b[2m' +const RESET = '\x1b[0m' + +export function section(title: string): void { + console.log(`\n${CYAN}━━━ ${title} ━━━${RESET}`) +} + +export function ok(message: string): void { + console.log(` ${GREEN}βœ“${RESET} ${message}`) +} + +export function note(message: string): void { + console.log(` ${DIM}Β· ${message}${RESET}`) +} + +export function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(`βœ— assertion failed: ${message}`) + ok(message) +} + +/** Asserts `actual` is within `toleranceBps` of `expected`. */ +export function assertApprox(actual: bigint, expected: bigint, toleranceBps: number, message: string): void { + const diff = actual > expected ? actual - expected : expected - actual + const bound = (expected * BigInt(toleranceBps)) / 10_000n + assert(diff <= bound, `${message} (actual ${actual}, expected ~${expected} Β±${toleranceBps}bps)`) +} + +export function fmt(amount: bigint, decimals: number, symbol: string): string { + const human = Number(formatUnits(amount, decimals)) + return `${human.toLocaleString('en-US', { maximumFractionDigits: 6 })} ${symbol}` +} + +export function fmtWad(ratio: bigint): string { + return `${(Number(formatUnits(ratio, 18)) * 100).toFixed(2)}%` +} + +// --------------------------------------------------------------------------- +// Transactions +// --------------------------------------------------------------------------- + +/** Simulates a write descriptor as the deployer (surfacing decoded reverts), then sends it. */ +export async function send( + ctx: Ctx, + call: { + address: Address + abi: Abi | readonly unknown[] + functionName: string + args: readonly unknown[] + value?: bigint + } +): Promise { + const { request } = await ctx.publicClient.simulateContract({ + account: ctx.deployer, + address: call.address, + abi: call.abi as Abi, + functionName: call.functionName, + args: call.args as unknown[], + value: call.value, + }) + // anvil's fork-mode gas estimates run a hair low (cold-slot surcharges on lazily-loaded + // state), so pad the demo sends rather than trusting the estimate to the wei. + const hash = await ctx.wallet.writeContract({ ...request, gas: 5_000_000n } as never) + const receipt = await ctx.publicClient.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') { + // Replay as eth_call at the parent block to surface the revert reason. + const tx = await ctx.publicClient.getTransaction({ hash }) + let reason = 'unknown' + try { + await ctx.publicClient.call({ + account: tx.from, + to: tx.to!, + data: tx.input, + value: tx.value, + gas: tx.gas, + blockNumber: receipt.blockNumber - 1n, + }) + reason = 'replay succeeded (state divergence between call and tx?)' + } catch (error) { + reason = (error as Error).message.split('\n').slice(0, 6).join(' | ') + } + throw new Error( + `transaction reverted on-send: ${call.functionName} (gas used ${receipt.gasUsed}/${tx.gas}) β†’ ${reason}` + ) + } + return receipt +} + +/** Decodes the first `eventName` log in a receipt using the SDK router ABI. */ +export function routerEvent>( + receipt: TransactionReceipt, + eventName: string +): T | undefined { + const logs = parseEventLogs({ abi: MARGIN_ROUTER_ABI, logs: receipt.logs, eventName: eventName as never }) + return logs[0]?.args as T | undefined +} + +// --------------------------------------------------------------------------- +// Token funding & approvals +// --------------------------------------------------------------------------- + +const balanceSlotCache = new Map() + +/** + * Foundry-`deal` equivalent over anvil: probes the token's balance-mapping slot by writing + * candidate `keccak256(abi.encode(holder, slot))` cells until `balanceOf` reflects the value. + */ +export async function deal(ctx: Ctx, token: Address, to: Address, amount: bigint): Promise { + const readBalance = () => + ctx.publicClient.readContract({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [to] }) + const writeCell = async (slot: bigint, value: Hex) => { + const cell = keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [to, slot])) + await ctx.testClient.setStorageAt({ address: token, index: cell, value }) + return cell + } + const cached = balanceSlotCache.get(token) + const candidates = cached !== undefined ? [cached] : Array.from({ length: 50 }, (_, i) => BigInt(i)) + for (const slot of candidates) { + const cell = keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [to, slot])) + const previous = await ctx.publicClient.getStorageAt({ address: token, slot: cell }) + await writeCell(slot, toHex(amount, { size: 32 })) + if ((await readBalance()) === amount) { + balanceSlotCache.set(token, slot) + return + } + await writeCell(slot, previous ?? toHex(0n, { size: 32 })) + } + throw new Error(`could not locate the balance mapping slot for ${token}`) +} + +/** The two-step Permit2 setup for `token`: ERC20 β†’ Permit2, then Permit2 β†’ MarginRouter. */ +export async function ensurePermit2(ctx: Ctx, token: Address): Promise { + await send(ctx, { + address: token, + abi: erc20Abi, + functionName: 'approve', + args: [ctx.addresses.permit2, (1n << 256n) - 1n], + }) + await send( + ctx, + permit2ApproveCall({ + permit2: ctx.addresses.permit2, + token, + spender: ctx.addresses.marginRouter, + amount: MAX_UINT160, + }) + ) +} + +export async function balanceOf(ctx: Ctx, token: Address, owner: Address): Promise { + return ctx.publicClient.readContract({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [owner] }) +} + +// --------------------------------------------------------------------------- +// Real quotes via the canonical mainnet v4 Quoter +// --------------------------------------------------------------------------- + +const QUOTER_ABI = [ + { + type: 'function', + name: 'quoteExactOutputSingle', + stateMutability: 'nonpayable', + inputs: [ + { + name: 'params', + type: 'tuple', + components: [ + { + name: 'poolKey', + type: 'tuple', + components: [ + { name: 'currency0', type: 'address' }, + { name: 'currency1', type: 'address' }, + { name: 'fee', type: 'uint24' }, + { name: 'tickSpacing', type: 'int24' }, + { name: 'hooks', type: 'address' }, + ], + }, + { name: 'zeroForOne', type: 'bool' }, + { name: 'exactAmount', type: 'uint128' }, + { name: 'hookData', type: 'bytes' }, + ], + }, + ], + outputs: [ + { name: 'amountIn', type: 'uint256' }, + { name: 'gasEstimate', type: 'uint256' }, + ], + }, +] as const + +/** + * Quotes the exact-output swap a margin flow performs β€” `input` is the currency sold (the + * market's debt on an open, its collateral on a close/decrease) β€” and returns the quoted input + * plus a slippage-buffered cap, the way the docs instruct integrators to derive `maxDebtIn` / + * `maxCollateralIn` (from a quote, never spot). + */ +export async function quoteSwapInput( + ctx: Ctx, + market: Market, + input: Address, + exactOut: bigint, + slippageBps: number +): Promise<{ quoted: bigint; capped: bigint; zeroForOne: boolean }> { + const zeroForOne = swapZeroForOne(market, input, ctx.poolKey) + const { result } = await ctx.publicClient.simulateContract({ + address: V4_QUOTER, + abi: QUOTER_ABI, + functionName: 'quoteExactOutputSingle', + args: [{ poolKey: ctx.poolKey, zeroForOne, exactAmount: exactOut, hookData: '0x' }], + }) + const quoted = result[0] + return { quoted, capped: withSlippageUp(quoted, slippageBps), zeroForOne } +} + +/** A demo deadline 30 minutes past the fork head. */ +export async function deadline(ctx: Ctx): Promise { + const block = await ctx.publicClient.getBlock() + return block.timestamp + 1_800n +} diff --git a/sdks/margin-sdk/demo/run-all.ts b/sdks/margin-sdk/demo/run-all.ts new file mode 100644 index 000000000..c46f72494 --- /dev/null +++ b/sdks/margin-sdk/demo/run-all.ts @@ -0,0 +1,24 @@ +/** + * Runs every demo flow sequentially against one anvil mainnet fork, impersonating the margin + * deployer (0x58e28b95a2ee57c4E90613AFce9e8CCEED3aB1E8) as the sender. Each flow mirrors a + * v4-periphery contract test and exercises the SDK end-to-end against the LIVE deployed stack. + * + * bun demo/run-all.ts (or: bun run demo) + * MARGIN_DEMO_RPC= bun demo/run-all.ts + */ +import { run as longLifecycle } from './01-long-lifecycle' +import { run as nativeEth } from './02-native-eth' +import { run as shortAave } from './03-short-aave' +import { run as hedge } from './04-hedge-subaccounts' +import { run as executePlans } from './05-execute-plans' +import { withAnvil } from './lib/env' + +await withAnvil(async (ctx) => { + console.log(`forked mainnet Β· sender ${ctx.deployer} Β· router ${ctx.addresses.marginRouter}`) + await longLifecycle(ctx) + await nativeEth(ctx) + await shortAave(ctx) + await hedge(ctx) + await executePlans(ctx) + console.log('\nall demo flows passed βœ“') +}) diff --git a/sdks/margin-sdk/package.json b/sdks/margin-sdk/package.json index 891df3858..a244b99e7 100644 --- a/sdks/margin-sdk/package.json +++ b/sdks/margin-sdk/package.json @@ -31,7 +31,8 @@ "typecheck": "tsc -p tsconfig.base.json --noEmit", "release": "changeset publish", "lint": "eslint src --ext .ts", - "test": "bun test" + "test": "bun test", + "demo": "bun demo/run-all.ts" }, "exports": { ".": { From 2525cb7b97c53f93967624ff700127447d38994e Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 24 Jul 2026 11:13:13 -0400 Subject: [PATCH 07/17] feat(margin-sdk): harden encoder and planner inputs 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 --- sdks/margin-sdk/src/encode.test.ts | 41 +++++++++++++++++++++++---- sdks/margin-sdk/src/encode.ts | 40 +++++++++++++++++++++++---- sdks/margin-sdk/src/errors.ts | 2 ++ sdks/margin-sdk/src/market.test.ts | 31 +++++++++++++++++++-- sdks/margin-sdk/src/market.ts | 43 ++++++++++++++++++++++++++--- sdks/margin-sdk/src/planner.test.ts | 31 +++++++++++++++++---- sdks/margin-sdk/src/planner.ts | 28 +++++++++++++++---- 7 files changed, 187 insertions(+), 29 deletions(-) diff --git a/sdks/margin-sdk/src/encode.test.ts b/sdks/margin-sdk/src/encode.test.ts index 520c08671..3313b1786 100644 --- a/sdks/margin-sdk/src/encode.test.ts +++ b/sdks/margin-sdk/src/encode.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'bun:test' import { type Address, decodeFunctionData, toFunctionSelector } from 'viem' -import { MARGIN_ROUTER_ABI } from './abis' -import { FULL_CLOSE } from './constants' +import { MARGIN_ROUTER_ABI } from './abis.js' +import { FULL_CLOSE } from './constants.js' import { addCollateralCall, closePositionCall, @@ -13,9 +13,9 @@ import { encodeIncreasePosition, encodeRouterMulticall, increasePositionCall, -} from './encode' -import { MarginSdkError } from './errors' -import { type IncreaseParams, type PoolKey } from './types' +} from './encode.js' +import { MarginSdkError } from './errors.js' +import { type IncreaseParams, type PoolKey } from './types.js' const WETH: Address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' const USDC: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' @@ -212,3 +212,34 @@ describe('encodeExecute / multicall', () => { expect(() => encodeRouterMulticall([])).toThrow(MarginSdkError) }) }) + +describe('deadline validation', () => { + test('rejects zero and negative deadlines', () => { + expect(() => encodeIncreasePosition({ ...BASE_INCREASE, deadline: 0n })).toThrow(MarginSdkError) + expect(() => encodeExecute('0x1234', -1n)).toThrow(MarginSdkError) + }) + + test('rejects millisecond timestamps (Date.now() footgun)', () => { + const ms = BigInt(1_784_900_000_000) // a Date.now()-scale value + expect(() => encodeIncreasePosition({ ...BASE_INCREASE, deadline: ms })).toThrow(/milliseconds/) + expect(() => encodeAddCollateral({ adapter: ADAPTER, market: LONG_MARKET, amount: 1n, deadline: ms })).toThrow( + MarginSdkError + ) + }) + + test('accepts plausible second timestamps', () => { + expect(() => encodeIncreasePosition({ ...BASE_INCREASE, deadline: 1_784_900_000n })).not.toThrow() + }) +}) + +describe('address validation', () => { + test('rejects a malformed adapter address', () => { + expect(() => encodeIncreasePosition({ ...BASE_INCREASE, adapter: '0x1234' as never })).toThrow(MarginSdkError) + }) + + test('rejects a malformed market token address', () => { + expect(() => + encodeIncreasePosition({ ...BASE_INCREASE, market: { collateral: 'not-an-address' as never, debt: USDC } }) + ).toThrow(MarginSdkError) + }) +}) diff --git a/sdks/margin-sdk/src/encode.ts b/sdks/margin-sdk/src/encode.ts index b80f8c683..5e712b1db 100644 --- a/sdks/margin-sdk/src/encode.ts +++ b/sdks/margin-sdk/src/encode.ts @@ -1,11 +1,31 @@ import { type Address, type Hex, encodeFunctionData } from 'viem' -import { MARGIN_ROUTER_ABI, PERMIT2_ABI } from './abis' -import { FULL_CLOSE, MAX_UINT48 } from './constants' -import { MarginSdkError } from './errors' -import { poolKeyMatchesMarket, validateMarket } from './market' -import { toUint128 } from './math' -import { type AddCollateralParams, type DecreaseParams, type IncreaseParams } from './types' +import { MARGIN_ROUTER_ABI, PERMIT2_ABI } from './abis.js' +import { FULL_CLOSE, MAX_UINT48 } from './constants.js' +import { MarginSdkError } from './errors.js' +import { poolKeyMatchesMarket, validateAddress, validateMarket } from './market.js' +import { toUint128 } from './math.js' +import { type AddCollateralParams, type DecreaseParams, type IncreaseParams } from './types.js' + +// A Unix-seconds deadline beyond this is almost certainly a milliseconds value (Date.now()), +// which would silently disable the deadline for the next ~3,000 years. +const MAX_REASONABLE_DEADLINE = 100_000_000_000n // year ~5138 + +/** Asserts a deadline is a plausible Unix-seconds timestamp (positive, not milliseconds). */ +export function validateDeadline(deadline: bigint): void { + if (deadline <= 0n) { + throw new MarginSdkError( + 'INVALID_DEADLINE', + `deadline must be a positive Unix timestamp in seconds, got ${deadline}` + ) + } + if (deadline > MAX_REASONABLE_DEADLINE) { + throw new MarginSdkError( + 'INVALID_DEADLINE', + `deadline ${deadline} looks like milliseconds β€” pass Unix SECONDS (e.g. BigInt(Math.floor(Date.now() / 1000)) + buffer)` + ) + } +} /** * Calldata encoders and write descriptors for the MarginRouter entry points. Each entry point is @@ -43,7 +63,9 @@ type IncreaseArgs = { } function normalizeIncrease(params: IncreaseParams, isNative: boolean): IncreaseArgs { + validateAddress(params.adapter, 'adapter') validateMarket(params.market) + validateDeadline(params.deadline) if (!poolKeyMatchesMarket(params.poolKey, params.market)) { throw new MarginSdkError('MARKET_MISMATCH', 'pool currencies do not match the market (collateral, debt) pair') } @@ -125,7 +147,9 @@ type DecreaseArgs = { } function normalizeDecrease(params: DecreaseParams): DecreaseArgs { + validateAddress(params.adapter, 'adapter') validateMarket(params.market) + validateDeadline(params.deadline) if (!poolKeyMatchesMarket(params.poolKey, params.market)) { throw new MarginSdkError('MARKET_MISMATCH', 'pool currencies do not match the market (collateral, debt) pair') } @@ -215,7 +239,9 @@ type AddCollateralArgs = { } function normalizeAddCollateral(params: AddCollateralParams, isNative: boolean): AddCollateralArgs { + validateAddress(params.adapter, 'adapter') validateMarket(params.market) + validateDeadline(params.deadline) if (isNative && params.amount !== 0n) { throw new MarginSdkError( 'INVALID_INPUT', @@ -265,6 +291,7 @@ export function addCollateralCall(p: { * your own code built β€” a plan has full authority over the caller's sub-accounts. */ export function encodeExecute(unlockData: Hex, deadline: bigint): Hex { + validateDeadline(deadline) return encodeFunctionData({ abi: MARGIN_ROUTER_ABI, functionName: 'execute', args: [unlockData, deadline] }) } @@ -275,6 +302,7 @@ export function executeCall(p: { deadline: bigint value?: bigint }): ContractWrite { + validateDeadline(p.deadline) return { address: p.marginRouter, abi: MARGIN_ROUTER_ABI, diff --git a/sdks/margin-sdk/src/errors.ts b/sdks/margin-sdk/src/errors.ts index 1084e9192..40dc7b916 100644 --- a/sdks/margin-sdk/src/errors.ts +++ b/sdks/margin-sdk/src/errors.ts @@ -13,6 +13,8 @@ export type MarginErrorCode = | 'INVALID_MARKET' | 'MARKET_MISMATCH' | 'SLIPPAGE_BOUND_REQUIRED' + | 'INVALID_DEADLINE' + | 'INVALID_RECIPIENT' | 'INVALID_PLAN' | 'INVALID_INPUT' diff --git a/sdks/margin-sdk/src/market.test.ts b/sdks/margin-sdk/src/market.test.ts index dbc5b707b..a97c0c275 100644 --- a/sdks/margin-sdk/src/market.test.ts +++ b/sdks/margin-sdk/src/market.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test' import { type Address } from 'viem' -import { MarginSdkError } from './errors' +import { MarginSdkError } from './errors.js' import { marketHasCurrencies, poolKeyMatchesMarket, @@ -9,8 +9,8 @@ import { swapZeroForOne, toPoolKey, validateMarket, -} from './market' -import { type PoolKey } from './types' +} from './market.js' +import { type PoolKey } from './types.js' const WETH: Address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' const USDC: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' @@ -68,3 +68,28 @@ describe('swapZeroForOne (Market.toSwapParams direction mirror)', () => { expect(() => swapZeroForOne(LONG, OTHER, POOL)).toThrow(MarginSdkError) }) }) + +describe('toPoolKey bounds', () => { + test('rejects out-of-range fees but accepts the dynamic-fee flag', () => { + expect(() => toPoolKey({ currencyA: WETH, currencyB: USDC, fee: 1_000_001, tickSpacing: 60 })).toThrow( + MarginSdkError + ) + expect(() => toPoolKey({ currencyA: WETH, currencyB: USDC, fee: -1, tickSpacing: 60 })).toThrow(MarginSdkError) + expect(() => toPoolKey({ currencyA: WETH, currencyB: USDC, fee: 0x800000, tickSpacing: 60 })).not.toThrow() + }) + + test('rejects out-of-range tick spacings', () => { + expect(() => toPoolKey({ currencyA: WETH, currencyB: USDC, fee: 3000, tickSpacing: 0 })).toThrow(MarginSdkError) + expect(() => toPoolKey({ currencyA: WETH, currencyB: USDC, fee: 3000, tickSpacing: 32_768 })).toThrow( + MarginSdkError + ) + expect(() => toPoolKey({ currencyA: WETH, currencyB: USDC, fee: 3000, tickSpacing: 2.5 })).toThrow(MarginSdkError) + }) + + test('rejects malformed addresses', () => { + expect(() => toPoolKey({ currencyA: '0xnope' as never, currencyB: USDC, fee: 3000, tickSpacing: 60 })).toThrow( + MarginSdkError + ) + expect(() => validateMarket({ collateral: '0x12' as never, debt: USDC })).toThrow(MarginSdkError) + }) +}) diff --git a/sdks/margin-sdk/src/market.ts b/sdks/margin-sdk/src/market.ts index 0be19fe5e..84ee48848 100644 --- a/sdks/margin-sdk/src/market.ts +++ b/sdks/margin-sdk/src/market.ts @@ -1,13 +1,30 @@ -import { type Address, isAddressEqual, zeroAddress } from 'viem' +import { type Address, isAddress, isAddressEqual, zeroAddress } from 'viem' -import { MarginSdkError } from './errors' -import { type Market, type PoolKey } from './types' +import { MarginSdkError } from './errors.js' +import { type Market, type PoolKey } from './types.js' /** * Market and pool-key helpers mirroring the onchain `Market` type: the single choke point that * reconciles a v4 pool with a `(collateral, debt)` market and derives swap direction. */ +/** v4 `LPFeeLibrary.MAX_LP_FEE`: the largest static LP fee, in hundredths of a bip (100%). */ +export const MAX_LP_FEE = 1_000_000 + +/** v4 `LPFeeLibrary.DYNAMIC_FEE_FLAG`: the `fee` sentinel marking a dynamic-fee pool. */ +export const DYNAMIC_FEE_FLAG = 0x800000 + +/** v4 `TickMath` tick-spacing bounds. */ +export const MIN_TICK_SPACING = 1 +export const MAX_TICK_SPACING = 32_767 + +/** Asserts `value` is a well-formed 20-byte hex address, wrapping viem's check in a typed error. */ +export function validateAddress(value: Address, label: string): void { + if (!isAddress(value, { strict: false })) { + throw new MarginSdkError('INVALID_INPUT', `${label} is not a valid address: ${value}`) + } +} + /** Whether `a` sorts before `b` under v4's canonical currency ordering (numeric address order). */ export function sortsBefore(a: Address, b: Address): boolean { return BigInt(a) < BigInt(b) @@ -15,7 +32,8 @@ export function sortsBefore(a: Address, b: Address): boolean { /** * Builds a canonically-ordered v4 `PoolKey` from an unordered currency pair. Defaults to a - * hookless pool. + * hookless pool. Enforces the pool-manager bounds offchain: `fee` is a static LP fee up to + * `MAX_LP_FEE` or exactly `DYNAMIC_FEE_FLAG`, and `tickSpacing` is within v4's tick-spacing range. */ export function toPoolKey(p: { currencyA: Address @@ -24,9 +42,24 @@ export function toPoolKey(p: { tickSpacing: number hooks?: Address }): PoolKey { + validateAddress(p.currencyA, 'currencyA') + validateAddress(p.currencyB, 'currencyB') + if (p.hooks !== undefined) validateAddress(p.hooks, 'hooks') if (isAddressEqual(p.currencyA, p.currencyB)) { throw new MarginSdkError('INVALID_MARKET', 'pool currencies must be distinct') } + if (!Number.isInteger(p.fee) || p.fee < 0 || (p.fee > MAX_LP_FEE && p.fee !== DYNAMIC_FEE_FLAG)) { + throw new MarginSdkError( + 'INVALID_INPUT', + `fee must be an integer in [0, ${MAX_LP_FEE}] (hundredths of a bip) or the DYNAMIC_FEE_FLAG, got ${p.fee}` + ) + } + if (!Number.isInteger(p.tickSpacing) || p.tickSpacing < MIN_TICK_SPACING || p.tickSpacing > MAX_TICK_SPACING) { + throw new MarginSdkError( + 'INVALID_INPUT', + `tickSpacing must be an integer in [${MIN_TICK_SPACING}, ${MAX_TICK_SPACING}], got ${p.tickSpacing}` + ) + } const [currency0, currency1] = sortsBefore(p.currencyA, p.currencyB) ? [p.currencyA, p.currencyB] : [p.currencyB, p.currencyA] @@ -35,6 +68,8 @@ export function toPoolKey(p: { /** Validates a market: distinct, non-zero ERC-20 addresses (native ETH is not a margin currency). */ export function validateMarket(market: Market): void { + validateAddress(market.collateral, 'market.collateral') + validateAddress(market.debt, 'market.debt') if (isAddressEqual(market.collateral, zeroAddress) || isAddressEqual(market.debt, zeroAddress)) { throw new MarginSdkError( 'INVALID_MARKET', diff --git a/sdks/margin-sdk/src/planner.test.ts b/sdks/margin-sdk/src/planner.test.ts index 5a11b9077..eb330e99c 100644 --- a/sdks/margin-sdk/src/planner.test.ts +++ b/sdks/margin-sdk/src/planner.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from 'bun:test' import { type Address, decodeAbiParameters } from 'viem' -import { MarginAction, V4RouterAction } from './actions' -import { CONTRACT_BALANCE, MSG_SENDER, OPEN_DELTA } from './constants' -import { MarginSdkError } from './errors' -import { MarginPlanner } from './planner' -import { type PoolKey } from './types' +import { MarginAction, V4RouterAction } from './actions.js' +import { CONTRACT_BALANCE, MSG_SENDER, OPEN_DELTA } from './constants.js' +import { MarginSdkError } from './errors.js' +import { MarginPlanner } from './planner.js' +import { type PoolKey } from './types.js' const WETH: Address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' const USDC: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' @@ -158,3 +158,24 @@ describe('MarginPlanner.finalize', () => { expect(p.actions).toEqual([V4RouterAction.SWAP_EXACT_IN]) }) }) + +describe('zero-recipient guards on fund-out actions', () => { + const planner = () => new MarginPlanner().setAccount(0n) + + test('account fund-out actions reject the zero address', () => { + expect(() => planner().withdrawCollateral(ADAPTER, MARKET, 1n, ZERO)).toThrow(MarginSdkError) + expect(() => planner().borrow(ADAPTER, MARKET, 1n, ZERO)).toThrow(MarginSdkError) + expect(() => planner().accountSweep(WETH, 1n, ZERO)).toThrow(MarginSdkError) + }) + + test('router fund-out actions reject the zero address', () => { + expect(() => planner().take(WETH, ZERO, 1n)).toThrow(MarginSdkError) + expect(() => planner().takePortion(WETH, ZERO, 100n)).toThrow(MarginSdkError) + expect(() => planner().sweep(WETH, ZERO)).toThrow(MarginSdkError) + }) + + test('the MSG_SENDER / ADDRESS_THIS sentinels remain valid recipients', () => { + expect(() => planner().take(WETH, MSG_SENDER, 1n).sweep(WETH, MSG_SENDER)).not.toThrow() + expect(() => planner().borrow(ADAPTER, MARKET, 1n, ADDR2)).not.toThrow() + }) +}) diff --git a/sdks/margin-sdk/src/planner.ts b/sdks/margin-sdk/src/planner.ts index 4aa8cfa02..9fd7eb7b0 100644 --- a/sdks/margin-sdk/src/planner.ts +++ b/sdks/margin-sdk/src/planner.ts @@ -1,9 +1,16 @@ -import { type Address, type Hex, concatHex, encodeAbiParameters, numberToHex } from 'viem' +import { type Address, type Hex, concatHex, encodeAbiParameters, isAddressEqual, numberToHex, zeroAddress } from 'viem' -import { ACCOUNT_SCOPED_ACTIONS, ACTION_ABI, MarginAction, type PlanAction, V4RouterAction } from './actions' -import { CONTRACT_BALANCE } from './constants' -import { MarginSdkError } from './errors' -import { type Market, type PathKey, type PoolKey } from './types' +import { ACCOUNT_SCOPED_ACTIONS, ACTION_ABI, MarginAction, type PlanAction, V4RouterAction } from './actions.js' +import { CONTRACT_BALANCE } from './constants.js' +import { MarginSdkError } from './errors.js' +import { type Market, type PathKey, type PoolKey } from './types.js' + +/** Fund-out destinations must never be the zero address (tokens would burn or the call reverts). */ +function validateRecipient(to: Address, action: string): void { + if (isAddressEqual(to, zeroAddress)) { + throw new MarginSdkError('INVALID_RECIPIENT', `${action} recipient must not be the zero address`) + } +} /** * Builds the `unlockData` for `MarginRouter.execute`: an ordered plan of v4 routing and margin @@ -75,13 +82,18 @@ export class MarginPlanner { return this.add(MarginAction.ACCOUNT_SUPPLY_COLLATERAL, [adapter, market, amount]) } - /** Withdraws `amount` of the market's collateral from the active account's position to `to`. */ + /** + * Withdraws `amount` of the market's collateral from the active account's position to `to` + * (the account constrains `to` to its manager β€” the router β€” or its owner). + */ withdrawCollateral(adapter: Address, market: Market, amount: bigint, to: Address): this { + validateRecipient(to, 'ACCOUNT_WITHDRAW_COLLATERAL') return this.add(MarginAction.ACCOUNT_WITHDRAW_COLLATERAL, [adapter, market, amount, to]) } /** Borrows `amount` of the market's debt against the active account, delivered to `to`. */ borrow(adapter: Address, market: Market, amount: bigint, to: Address): this { + validateRecipient(to, 'ACCOUNT_BORROW') return this.add(MarginAction.ACCOUNT_BORROW, [adapter, market, amount, to]) } @@ -92,6 +104,7 @@ export class MarginPlanner { /** Sweeps `amount` of `currency` out of the active account to `to` (manager or owner only). */ accountSweep(currency: Address, amount: bigint, to: Address): this { + validateRecipient(to, 'ACCOUNT_SWEEP') return this.add(MarginAction.ACCOUNT_SWEEP, [currency, amount, to]) } @@ -201,6 +214,7 @@ export class MarginPlanner { /** Takes `amount` of `currency` from the PoolManager to `recipient` (0 == full credit). */ take(currency: Address, recipient: Address, amount: bigint): this { + validateRecipient(recipient, 'TAKE') return this.add(V4RouterAction.TAKE, [currency, recipient, amount]) } @@ -211,11 +225,13 @@ export class MarginPlanner { /** Takes `bips` (out of 10_000) of the full credit in `currency` to `recipient`. */ takePortion(currency: Address, recipient: Address, bips: bigint): this { + validateRecipient(recipient, 'TAKE_PORTION') return this.add(V4RouterAction.TAKE_PORTION, [currency, recipient, bips]) } /** Sweeps the router's entire balance of `currency` to `to` (use to net the router to zero). */ sweep(currency: Address, to: Address): this { + validateRecipient(to, 'SWEEP') return this.add(V4RouterAction.SWEEP, [currency, to]) } From c0b7bd63b8f8b2f13ff082ca90ec51b520155593 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 24 Jul 2026 11:13:13 -0400 Subject: [PATCH 08/17] test(margin-sdk): cover the read layer and fund-authorization encoders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sdks/margin-sdk/src/reads.test.ts | 144 ++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 sdks/margin-sdk/src/reads.test.ts diff --git a/sdks/margin-sdk/src/reads.test.ts b/sdks/margin-sdk/src/reads.test.ts new file mode 100644 index 000000000..215d410be --- /dev/null +++ b/sdks/margin-sdk/src/reads.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'bun:test' +import { type Address, encodeFunctionData } from 'viem' + +import { LENDING_ADAPTER_ABI, MARGIN_ACCOUNT_ABI, MARGIN_ROUTER_ABI, PERMIT2_ABI } from './abis.js' +import { encodeRouterPermit, permit2ApproveCall } from './encode.js' +import { + accountManagerCall, + accountOfCall, + accountOwnerCall, + currentLtvCall, + describePositionCall, + governanceCall, + isAdapterAllowedCall, + isSupportedMarketCall, + maxLtvCall, + positionOfCall, +} from './reads.js' + +const ROUTER: Address = '0x0000000004BBC92D0657580CAe35aEBF054E5CDC' +const ADAPTER: Address = '0x9A7f8F5A9496D3c9dc0BEEfb44cCaC17CAAF28fa' +const ACCOUNT: Address = '0x64487fb85302b5A2f38EF91144155986D331D2Fe' +const OWNER: Address = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' +const WETH: Address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' +const USDC: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' +const MARKET = { collateral: WETH, debt: USDC } + +/** + * The read layer is the backend's position/health-monitoring surface. Every descriptor must wire + * the exact (address, abi, functionName, args) the venue-agnostic ILendingAdapter/router expose; + * `encodeFunctionData` over each descriptor also proves the args ABI-encode against the SDK ABI. + */ +describe('read descriptors', () => { + const cases: Array<{ + name: string + call: { address: Address; abi: unknown; functionName: string; args: readonly unknown[] } + address: Address + args: readonly unknown[] + }> = [ + { + name: 'accountOf', + call: accountOfCall({ marginRouter: ROUTER, owner: OWNER, subId: 7n }), + address: ROUTER, + args: [OWNER, 7n], + }, + { + name: 'isAdapterAllowed', + call: isAdapterAllowedCall({ marginRouter: ROUTER, adapter: ADAPTER }), + address: ROUTER, + args: [ADAPTER], + }, + { name: 'governance', call: governanceCall(ROUTER), address: ROUTER, args: [] }, + { + name: 'isSupportedMarket', + call: isSupportedMarketCall({ adapter: ADAPTER, market: MARKET }), + address: ADAPTER, + args: [MARKET], + }, + { + name: 'positionOf', + call: positionOfCall({ adapter: ADAPTER, account: ACCOUNT, market: MARKET }), + address: ADAPTER, + args: [ACCOUNT, MARKET], + }, + { name: 'maxLtvWad', call: maxLtvCall({ adapter: ADAPTER, market: MARKET }), address: ADAPTER, args: [MARKET] }, + { + name: 'currentLtvWad', + call: currentLtvCall({ adapter: ADAPTER, account: ACCOUNT, market: MARKET }), + address: ADAPTER, + args: [ACCOUNT, MARKET], + }, + { + name: 'describePosition', + call: describePositionCall({ adapter: ADAPTER, account: ACCOUNT, market: MARKET }), + address: ADAPTER, + args: [ACCOUNT, MARKET], + }, + { name: 'owner', call: accountOwnerCall(ACCOUNT), address: ACCOUNT, args: [] }, + { name: 'manager', call: accountManagerCall(ACCOUNT), address: ACCOUNT, args: [] }, + ] + + for (const { name, call, address, args } of cases) { + test(`${name}Call wires address/function/args and ABI-encodes`, () => { + const functionName = + name === 'maxLtvWad' ? 'maxLtvWad' : name === 'currentLtvWad' ? 'currentLtvWad' : call.functionName + expect(call.address).toBe(address) + expect(call.functionName).toBe(functionName) + expect(call.args).toEqual(args) + // encoding against the SDK ABI proves the descriptor's args match the function's inputs + expect(() => + encodeFunctionData({ + abi: call.abi as never, + functionName: call.functionName as never, + args: call.args as never, + }) + ).not.toThrow() + }) + } + + test('accountOfCall defaults subId to 0', () => { + expect(accountOfCall({ marginRouter: ROUTER, owner: OWNER }).args).toEqual([OWNER, 0n]) + }) + + test('descriptors reference the expected ABIs', () => { + expect(accountOfCall({ marginRouter: ROUTER, owner: OWNER }).abi).toBe(MARGIN_ROUTER_ABI) + expect(positionOfCall({ adapter: ADAPTER, account: ACCOUNT, market: MARKET }).abi).toBe(LENDING_ADAPTER_ABI) + expect(accountOwnerCall(ACCOUNT).abi).toBe(MARGIN_ACCOUNT_ABI) + }) +}) + +describe('fund-authorization encoders (cast byte vectors)', () => { + test('encodeRouterPermit matches cast calldata byte-for-byte (expiration/nonce order)', () => { + // cast calldata 'permit(address,((address,uint160,uint48,uint48),address,uint256),bytes)' + // 0xf39F… '((WETH,1000000000,1750000000,3),ROUTER,1790000000)' 0xdeadbeef + const expected = + '0x2b67b570000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000003b9aca0000000000000000000000000000000000000000000000000000000000684ee18000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000004bbc92d0657580cae35aebf054e5cdc000000000000000000000000000000000000000000000000000000006ab13b8000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000' + expect( + encodeRouterPermit( + OWNER, + { + details: { token: WETH, amount: 1_000_000_000n, expiration: 1_750_000_000, nonce: 3 }, + spender: ROUTER, + sigDeadline: 1_790_000_000n, + }, + '0xdeadbeef' + ) + ).toBe(expected as `0x${string}`) + }) + + test('permit2ApproveCall encodes to the cast approve calldata byte-for-byte', () => { + // cast calldata 'approve(address,address,uint160,uint48)' WETH ROUTER 1e18 281474976710655 + const expected = + '0x87517c45000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000004bbc92d0657580cae35aebf054e5cdc0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000ffffffffffff' + const call = permit2ApproveCall({ + permit2: '0x000000000022D473030F116dDEE9F6B43aC78BA3', + token: WETH, + spender: ROUTER, + amount: 10n ** 18n, + }) + expect(call.abi).toBe(PERMIT2_ABI) + expect(encodeFunctionData({ abi: call.abi, functionName: call.functionName, args: call.args as never })).toBe( + expected as `0x${string}` + ) + }) +}) From a9f2c48c74aef44ab72a335e23c324a9cd19790d Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 24 Jul 2026 11:13:27 -0400 Subject: [PATCH 09/17] fix(margin-sdk): make the published package load under native Node (CJS + ESM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bun.lock | 7 +- sdks/margin-sdk/package.json | 12 ++- sdks/margin-sdk/scripts/check-package.mjs | 75 +++++++++++++++++++ .../scripts/write-module-markers.mjs | 20 +++++ sdks/margin-sdk/src/account.test.ts | 8 +- sdks/margin-sdk/src/account.ts | 4 +- sdks/margin-sdk/src/addresses.test.ts | 6 +- sdks/margin-sdk/src/addresses.ts | 2 +- sdks/margin-sdk/src/index.ts | 26 +++---- sdks/margin-sdk/src/math.test.ts | 6 +- sdks/margin-sdk/src/math.ts | 4 +- sdks/margin-sdk/src/reads.ts | 4 +- 12 files changed, 138 insertions(+), 36 deletions(-) create mode 100644 sdks/margin-sdk/scripts/check-package.mjs create mode 100644 sdks/margin-sdk/scripts/write-module-markers.mjs diff --git a/bun.lock b/bun.lock index 33dcb422b..bf0173188 100644 --- a/bun.lock +++ b/bun.lock @@ -66,7 +66,7 @@ "name": "@uniswap/margin-sdk", "version": "0.0.0", "dependencies": { - "viem": "^2.23.5", + "tslib": "^2.3.0", }, "devDependencies": { "@types/node": "^18.7.16", @@ -76,8 +76,11 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.22.0", "prettier": "^2.4.1", - "tslib": "^2.3.0", "typescript": "npm:typescript@^5.6.2", + "viem": "^2.23.5", + }, + "peerDependencies": { + "viem": "^2.23.5", }, }, "sdks/permit2-sdk": { diff --git a/sdks/margin-sdk/package.json b/sdks/margin-sdk/package.json index a244b99e7..67313fc8e 100644 --- a/sdks/margin-sdk/package.json +++ b/sdks/margin-sdk/package.json @@ -24,14 +24,15 @@ }, "scripts": { "clean": "rm -rf dist", - "build": "bun run clean && bun run build:cjs && bun run build:esm && bun run build:types", + "build": "bun run clean && bun run build:cjs && bun run build:esm && bun run build:types && node scripts/write-module-markers.mjs", "build:cjs": "tsc -p tsconfig.cjs.json", "build:esm": "tsc -p tsconfig.esm.json", "build:types": "tsc -p tsconfig.types.json", "typecheck": "tsc -p tsconfig.base.json --noEmit", "release": "changeset publish", "lint": "eslint src --ext .ts", - "test": "bun test", + "test": "bun test && bun run check:package", + "check:package": "bun run build && node scripts/check-package.mjs", "demo": "bun demo/run-all.ts" }, "exports": { @@ -43,6 +44,9 @@ }, "sideEffects": false, "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { "viem": "^2.23.5" }, "devDependencies": { @@ -53,8 +57,8 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.22.0", "prettier": "^2.4.1", - "tslib": "^2.3.0", - "typescript": "npm:typescript@^5.6.2" + "typescript": "npm:typescript@^5.6.2", + "viem": "^2.23.5" }, "prettier": { "printWidth": 120, diff --git a/sdks/margin-sdk/scripts/check-package.mjs b/sdks/margin-sdk/scripts/check-package.mjs new file mode 100644 index 000000000..58c9825b8 --- /dev/null +++ b/sdks/margin-sdk/scripts/check-package.mjs @@ -0,0 +1,75 @@ +// Built-artifact smoke test: packs the package exactly as npm would publish it, installs it into +// an isolated consumer directory with ONLY its declared runtime/peer dependencies resolvable, and +// loads it under native Node in both module systems. This is what catches the failure class CI's +// source-level tests cannot: undeclared runtime deps (tslib), extensionless ESM specifiers, +// missing module-type markers β€” bugs that only exist in the published artifact. +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')) + +// bun's isolated linker keeps deps in the package's own node_modules; hoisted layouts use the +// workspace root. Check both. +function resolveDep(name) { + for (const base of [path.join(pkgRoot, 'node_modules'), path.resolve(pkgRoot, '../../node_modules')]) { + const candidate = path.join(base, name) + if (fs.existsSync(candidate)) return fs.realpathSync(candidate) + } + throw new Error(`declared dependency ${name} is not installed in the workspace`) +} + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'margin-sdk-package-check-')) +try { + // 1. Pack the real publish artifact. + execFileSync('npm', ['pack', '--pack-destination', tmp], { cwd: pkgRoot, stdio: 'pipe' }) + const tarball = fs.readdirSync(tmp).find((f) => f.endsWith('.tgz')) + if (!tarball) throw new Error('npm pack produced no tarball') + execFileSync('tar', ['-xzf', tarball], { cwd: tmp }) + + // 2. "Install": place the extracted package under node_modules, then link ONLY the deps the + // package.json declares. An undeclared runtime import fails here exactly as it would on a + // consumer's clean install. + const installDir = path.join(tmp, 'node_modules', ...pkg.name.split('/')) + fs.mkdirSync(path.dirname(installDir), { recursive: true }) + fs.renameSync(path.join(tmp, 'package'), installDir) + for (const name of [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.peerDependencies ?? {})]) { + const link = path.join(tmp, 'node_modules', ...name.split('/')) + fs.mkdirSync(path.dirname(link), { recursive: true }) + fs.symlinkSync(resolveDep(name), link, 'dir') + } + + // 3. Load and exercise the package under native Node, both module systems. The functional check + // (a known mainnet accountOf vector) proves the module graph actually initialized. + const functionalCheck = ` + const account = sdk.predictMarginAccountAddress({ + owner: '0x0000000000000000000000000000000000000001', + subId: 0n, + marginRouter: '0x0000000004BBC92D0657580CAe35aEBF054E5CDC', + accountImplementation: '0x83Fc96d2B162dAF8532e5677C6Ec32A1Cb7882E4', + }) + if (account !== '0x64487fb85302b5A2f38EF91144155986D331D2Fe') { + throw new Error('predictMarginAccountAddress returned ' + account) + } + if (!Array.isArray(sdk.MARGIN_ROUTER_ABI) || typeof sdk.MarginPlanner !== 'function') { + throw new Error('expected exports missing') + } + ` + fs.writeFileSync( + path.join(tmp, 'check.cjs'), + `const sdk = require('${pkg.name}')\n${functionalCheck}\nconsole.log('CJS OK')\n` + ) + fs.writeFileSync( + path.join(tmp, 'check.mjs'), + `import * as sdk from '${pkg.name}'\n${functionalCheck}\nconsole.log('ESM OK')\n` + ) + for (const consumer of ['check.cjs', 'check.mjs']) { + execFileSync('node', [consumer], { cwd: tmp, stdio: 'inherit' }) + } + console.log(`package check passed: ${pkg.name} loads under native Node (CJS + ESM) with declared deps only`) +} finally { + fs.rmSync(tmp, { recursive: true, force: true }) +} diff --git a/sdks/margin-sdk/scripts/write-module-markers.mjs b/sdks/margin-sdk/scripts/write-module-markers.mjs new file mode 100644 index 000000000..db440ee05 --- /dev/null +++ b/sdks/margin-sdk/scripts/write-module-markers.mjs @@ -0,0 +1,20 @@ +// Writes per-directory module-type markers into the build output. The package root has no "type" +// field, so without these Node treats every emitted .js as CommonJS β€” which breaks native ESM +// consumers of dist/esm (and would break dist/cjs if the root ever gained "type": "module"). +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) + +for (const [dir, type] of [ + ['dist/esm', 'module'], + ['dist/cjs', 'commonjs'], +]) { + const target = path.join(root, dir) + if (!fs.existsSync(target)) { + console.error(`write-module-markers: missing ${dir} β€” run the build first`) + process.exit(1) + } + fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({ type }) + '\n') +} diff --git a/sdks/margin-sdk/src/account.test.ts b/sdks/margin-sdk/src/account.test.ts index 0454eb988..3ff8227d0 100644 --- a/sdks/margin-sdk/src/account.test.ts +++ b/sdks/margin-sdk/src/account.test.ts @@ -7,10 +7,10 @@ import { marginAccountArgs, marginAccountSalt, predictMarginAccountAddress, -} from './account' -import { MARGIN_ADDRESSES } from './addresses' -import { SupportedChainId } from './chains' -import { MarginSdkError } from './errors' +} from './account.js' +import { MARGIN_ADDRESSES } from './addresses.js' +import { SupportedChainId } from './chains.js' +import { MarginSdkError } from './errors.js' const MAINNET = MARGIN_ADDRESSES[SupportedChainId.MAINNET]! const ROUTER = MAINNET.marginRouter diff --git a/sdks/margin-sdk/src/account.ts b/sdks/margin-sdk/src/account.ts index fe3509ab0..320efaba0 100644 --- a/sdks/margin-sdk/src/account.ts +++ b/sdks/margin-sdk/src/account.ts @@ -1,7 +1,7 @@ import { type Address, type Hex, concatHex, encodeAbiParameters, getAddress, keccak256, numberToHex } from 'viem' -import { getMarginAddresses } from './addresses' -import { MarginSdkError } from './errors' +import { getMarginAddresses } from './addresses.js' +import { MarginSdkError } from './errors.js' /** * Offchain mirror of `MarginRouter.accountOf`: the deterministic MarginAccount address for an diff --git a/sdks/margin-sdk/src/addresses.test.ts b/sdks/margin-sdk/src/addresses.test.ts index ac37af60c..9322ab791 100644 --- a/sdks/margin-sdk/src/addresses.test.ts +++ b/sdks/margin-sdk/src/addresses.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'bun:test' import { getAddress } from 'viem' -import { MARGIN_ADDRESSES, getMarginAddresses } from './addresses' -import { SupportedChainId, isMarginSupportedChain } from './chains' -import { MarginSdkError, isMarginSdkError } from './errors' +import { MARGIN_ADDRESSES, getMarginAddresses } from './addresses.js' +import { SupportedChainId, isMarginSupportedChain } from './chains.js' +import { MarginSdkError, isMarginSdkError } from './errors.js' describe('addresses', () => { test('every address is checksummed', () => { diff --git a/sdks/margin-sdk/src/addresses.ts b/sdks/margin-sdk/src/addresses.ts index bfb7c70dd..bda08b660 100644 --- a/sdks/margin-sdk/src/addresses.ts +++ b/sdks/margin-sdk/src/addresses.ts @@ -1,6 +1,6 @@ import { type Address, getAddress } from 'viem' -import { SupportedChainId } from './chains' +import { SupportedChainId } from './chains.js' /** The lending venues integrated behind `ILendingAdapter` today. */ export type LendingVenue = 'morphoBlue' | 'aaveV3' | 'aaveV4' diff --git a/sdks/margin-sdk/src/index.ts b/sdks/margin-sdk/src/index.ts index e9b220f30..08af38e08 100644 --- a/sdks/margin-sdk/src/index.ts +++ b/sdks/margin-sdk/src/index.ts @@ -7,26 +7,26 @@ */ // Chains & addresses -export * from './chains' -export * from './addresses' +export * from './chains.js' +export * from './addresses.js' // Constants & errors -export * from './constants' -export * from './errors' +export * from './constants.js' +export * from './errors.js' // Onchain struct mirrors & ABIs -export * from './types' -export * from './abis' +export * from './types.js' +export * from './abis.js' // Markets, account derivation, leverage & health math -export * from './market' -export * from './account' -export * from './math' +export * from './market.js' +export * from './account.js' +export * from './math.js' // Entry-point encoders & the execute-plan builder -export * from './encode' -export * from './actions' -export * from './planner' +export * from './encode.js' +export * from './actions.js' +export * from './planner.js' // Reads (descriptors + viem helpers) -export * from './reads' +export * from './reads.js' diff --git a/sdks/margin-sdk/src/math.test.ts b/sdks/margin-sdk/src/math.test.ts index 3689305e2..10b43c5ae 100644 --- a/sdks/margin-sdk/src/math.test.ts +++ b/sdks/margin-sdk/src/math.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'bun:test' import { parseUnits } from 'viem' -import { MAX_UINT128, MAX_UINT256, WAD } from './constants' -import { MarginSdkError } from './errors' +import { MAX_UINT128, MAX_UINT256, WAD } from './constants.js' +import { MarginSdkError } from './errors.js' import { collateralToBuyForLeverage, estimateLtv, @@ -18,7 +18,7 @@ import { totalExposure, withSlippageDown, withSlippageUp, -} from './math' +} from './math.js' describe('parseLeverageX18', () => { test('parses numbers and strings', () => { diff --git a/sdks/margin-sdk/src/math.ts b/sdks/margin-sdk/src/math.ts index 35d31abfd..3717b24ac 100644 --- a/sdks/margin-sdk/src/math.ts +++ b/sdks/margin-sdk/src/math.ts @@ -1,7 +1,7 @@ import { parseUnits } from 'viem' -import { BPS_DENOMINATOR, MAX_UINT128, MAX_UINT256, ONE_X18, WAD } from './constants' -import { MarginSdkError } from './errors' +import { BPS_DENOMINATOR, MAX_UINT128, MAX_UINT256, ONE_X18, WAD } from './constants.js' +import { MarginSdkError } from './errors.js' /** * Leverage, sizing, and health math for margin positions. All ratios are WAD-scaled bigints diff --git a/sdks/margin-sdk/src/reads.ts b/sdks/margin-sdk/src/reads.ts index 7a980e6ea..301f2f2b2 100644 --- a/sdks/margin-sdk/src/reads.ts +++ b/sdks/margin-sdk/src/reads.ts @@ -1,7 +1,7 @@ import { type Abi, type Address, type PublicClient } from 'viem' -import { LENDING_ADAPTER_ABI, MARGIN_ACCOUNT_ABI, MARGIN_ROUTER_ABI } from './abis' -import { type Market, type PositionData } from './types' +import { LENDING_ADAPTER_ABI, MARGIN_ACCOUNT_ABI, MARGIN_ROUTER_ABI } from './abis.js' +import { type Market, type PositionData } from './types.js' /** * Read layer. Every read is exposed two ways: From 0be65be9d8874c0a6067eb83a31dfccaf0960e5f Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 24 Jul 2026 11:13:42 -0400 Subject: [PATCH 10/17] feat(margin-sdk): run the anvil-fork demo suite in CI behind FORK_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sdks/margin-sdk/demo/lib/env.ts | 3 ++- sdks/margin-sdk/package.json | 3 ++- sdks/margin-sdk/scripts/fork-tests.ts | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 sdks/margin-sdk/scripts/fork-tests.ts diff --git a/sdks/margin-sdk/demo/lib/env.ts b/sdks/margin-sdk/demo/lib/env.ts index beeb702ff..3e86cfeeb 100644 --- a/sdks/margin-sdk/demo/lib/env.ts +++ b/sdks/margin-sdk/demo/lib/env.ts @@ -35,7 +35,8 @@ export const USDC: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' /** Canonical mainnet v4 Quoter (sdk-core `v4QuoterAddress`); used to derive real swap quotes. */ export const V4_QUOTER: Address = '0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203' -const DEFAULT_FORK_RPC = process.env.MARGIN_DEMO_RPC ?? process.env.RPC_URL ?? 'https://ethereum-rpc.publicnode.com' +const DEFAULT_FORK_RPC = + process.env.MARGIN_DEMO_RPC ?? process.env.FORK_URL ?? process.env.RPC_URL ?? 'https://ethereum-rpc.publicnode.com' export interface Ctx { rpcUrl: string diff --git a/sdks/margin-sdk/package.json b/sdks/margin-sdk/package.json index 67313fc8e..d493a9a66 100644 --- a/sdks/margin-sdk/package.json +++ b/sdks/margin-sdk/package.json @@ -31,8 +31,9 @@ "typecheck": "tsc -p tsconfig.base.json --noEmit", "release": "changeset publish", "lint": "eslint src --ext .ts", - "test": "bun test && bun run check:package", + "test": "bun test && bun run check:package && bun run test:fork", "check:package": "bun run build && node scripts/check-package.mjs", + "test:fork": "bun scripts/fork-tests.ts", "demo": "bun demo/run-all.ts" }, "exports": { diff --git a/sdks/margin-sdk/scripts/fork-tests.ts b/sdks/margin-sdk/scripts/fork-tests.ts new file mode 100644 index 000000000..5758b5864 --- /dev/null +++ b/sdks/margin-sdk/scripts/fork-tests.ts @@ -0,0 +1,17 @@ +// Gated fork-test entry: runs the end-to-end demo suite (an anvil mainnet fork exercising the +// live margin deployment) when a fork RPC is configured, and skips cleanly otherwise. CI provides +// FORK_URL; locally use MARGIN_DEMO_RPC or FORK_URL. +const rpc = process.env.MARGIN_DEMO_RPC ?? process.env.FORK_URL + +if (!rpc) { + console.log('fork tests skipped: set FORK_URL (or MARGIN_DEMO_RPC) to run the anvil-fork demo suite') + process.exit(0) +} + +const proc = Bun.spawnSync(['bun', 'demo/run-all.ts'], { + cwd: new URL('..', import.meta.url).pathname, + env: { ...process.env, MARGIN_DEMO_RPC: rpc }, + stdout: 'inherit', + stderr: 'inherit', +}) +process.exit(proc.exitCode ?? 1) From 205a650cd79286ccdb2f28d15d8fbba3fcbd8481 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 24 Jul 2026 11:14:01 -0400 Subject: [PATCH 11/17] feat(margin-sdk): add an ABI drift gate against the v4-periphery source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sdks/margin-sdk/README.md | 22 ++++ sdks/margin-sdk/package.json | 1 + sdks/margin-sdk/scripts/check-abi-drift.ts | 122 +++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 sdks/margin-sdk/scripts/check-abi-drift.ts diff --git a/sdks/margin-sdk/README.md b/sdks/margin-sdk/README.md index d902f6e52..e9c9c9923 100644 --- a/sdks/margin-sdk/README.md +++ b/sdks/margin-sdk/README.md @@ -250,6 +250,28 @@ Resolved via `getMarginAddresses(chainId)`; Ethereum mainnet today: The SDK's ABIs, selectors, and account derivation are test-anchored against this live deployment (see `src/*.test.ts`). +> The margin contracts ([v4-periphery#563](https://github.com/Uniswap/v4-periphery/pull/563)) are +> still in review and router governance has not yet moved to a timelock/multisig, so the package +> is published as a `0.0.x` pre-release until the deployment is final. + +## Validation gates + +Beyond the unit suite (`bun test`), three gates validate what unit tests structurally cannot: + +- **`bun run check:package`** β€” packs the publish artifact, installs it into an isolated consumer + with only its declared dependencies resolvable, and loads it under **native Node in both module + systems** (CJS `require` + ESM `import`), running a real account-derivation vector. Catches + undeclared runtime deps, extensionless ESM emit, and missing module-type markers. Runs as part + of `test`. +- **`bun run check:abi-drift`** β€” compiles the contracts from a local v4-periphery checkout + (`V4_PERIPHERY_PATH`, default `~/dev/v4-periphery`) and asserts every hand-written SDK ABI entry + matches the Solidity: canonical signatures, outputs, state mutability, and event index layouts, + for the router, the account, and all three adapters. viem encodes tuples positionally, so this + is the gate that catches a silent field reorder in the draft contracts. +- **`bun run test:fork`** β€” the end-to-end demo suite against an anvil mainnet fork (see below); + runs inside `test` when `FORK_URL` (or `MARGIN_DEMO_RPC`) is set and skips cleanly otherwise, + so CI with the `FORK_URL` secret exercises the SDK against the live deployment on every run. + ## End-to-end demos [`demo/`](./demo) contains runnable flows that validate the SDK against the live deployment on an diff --git a/sdks/margin-sdk/package.json b/sdks/margin-sdk/package.json index d493a9a66..762d7ec68 100644 --- a/sdks/margin-sdk/package.json +++ b/sdks/margin-sdk/package.json @@ -33,6 +33,7 @@ "lint": "eslint src --ext .ts", "test": "bun test && bun run check:package && bun run test:fork", "check:package": "bun run build && node scripts/check-package.mjs", + "check:abi-drift": "bun scripts/check-abi-drift.ts", "test:fork": "bun scripts/fork-tests.ts", "demo": "bun demo/run-all.ts" }, diff --git a/sdks/margin-sdk/scripts/check-abi-drift.ts b/sdks/margin-sdk/scripts/check-abi-drift.ts new file mode 100644 index 000000000..fd138ff72 --- /dev/null +++ b/sdks/margin-sdk/scripts/check-abi-drift.ts @@ -0,0 +1,122 @@ +/** + * ABI drift gate: verifies every hand-written SDK ABI entry against the Solidity source of truth. + * + * viem encodes tuples positionally, so a silent field reorder in the (still-draft) contracts + * would produce valid-looking but wrong calldata with unit tests still green. This script compiles + * the contracts with `forge inspect` from a local v4-periphery checkout and asserts that every + * function / event / error in the SDK ABIs exists there with an identical canonical signature, + * identical outputs, state mutability, and event index layout. + * + * V4_PERIPHERY_PATH=~/dev/v4-periphery bun run check:abi-drift + * + * Run it whenever the contracts move; it requires foundry and a checkout of the margin-trading + * branch, so it is a manual/optional gate rather than part of the default CI test run. + */ +import { type AbiParameter } from 'viem' + +import { + AAVE_LENDING_ADAPTER_ABI, + AAVE_V4_LENDING_ADAPTER_ABI, + LENDING_ADAPTER_ABI, + MARGIN_ACCOUNT_ABI, + MARGIN_ROUTER_ABI, + MORPHO_LENDING_ADAPTER_ABI, +} from '../src/abis.js' + +const peripheryPath = process.env.V4_PERIPHERY_PATH ?? `${process.env.HOME}/dev/v4-periphery` + +interface AbiItem { + type: string + name?: string + inputs?: readonly AbiParameter[] + outputs?: readonly AbiParameter[] + stateMutability?: string +} + +function canonicalType(param: AbiParameter): string { + if (param.type.startsWith('tuple')) { + const components = (param as { components?: readonly AbiParameter[] }).components ?? [] + return `(${components.map(canonicalType).join(',')})${param.type.slice('tuple'.length)}` + } + return param.type +} + +function signature(item: AbiItem): string { + return `${item.name}(${(item.inputs ?? []).map(canonicalType).join(',')})` +} + +function indexedLayout(item: AbiItem): string { + return (item.inputs ?? []).map((input) => ((input as { indexed?: boolean }).indexed ? '1' : '0')).join('') +} + +async function forgeAbi(contract: string): Promise { + const proc = Bun.spawnSync(['forge', 'inspect', contract, 'abi', '--json'], { cwd: peripheryPath }) + if (proc.exitCode !== 0) { + throw new Error(`forge inspect ${contract} failed:\n${proc.stderr.toString()}`) + } + return JSON.parse(proc.stdout.toString()) as AbiItem[] +} + +function diffAbi(label: string, sdkAbi: readonly unknown[], solidity: AbiItem[]): string[] { + const problems: string[] = [] + const bySignature = new Map() + for (const item of solidity) { + if (item.type === 'function' || item.type === 'event' || item.type === 'error') { + bySignature.set(`${item.type}:${signature(item)}`, item) + } + } + for (const entry of sdkAbi as AbiItem[]) { + if (entry.type !== 'function' && entry.type !== 'event' && entry.type !== 'error') continue + const key = `${entry.type}:${signature(entry)}` + const onchain = bySignature.get(key) + if (!onchain) { + problems.push(`${label}: ${key} not found in the Solidity ABI (renamed, removed, or reordered fields?)`) + continue + } + if (entry.type === 'function') { + const sdkOutputs = (entry.outputs ?? []).map(canonicalType).join(',') + const solOutputs = (onchain.outputs ?? []).map(canonicalType).join(',') + if (sdkOutputs !== solOutputs) { + problems.push(`${label}: ${key} outputs drifted: sdk (${sdkOutputs}) vs solidity (${solOutputs})`) + } + if (entry.stateMutability !== onchain.stateMutability) { + problems.push( + `${label}: ${key} stateMutability drifted: sdk ${entry.stateMutability} vs solidity ${onchain.stateMutability}` + ) + } + } + if (entry.type === 'event' && indexedLayout(entry) !== indexedLayout(onchain)) { + problems.push( + `${label}: ${key} indexed layout drifted: sdk ${indexedLayout(entry)} vs solidity ${indexedLayout(onchain)}` + ) + } + } + return problems +} + +const CHECKS: Array<[string, readonly unknown[], string]> = [ + ['MARGIN_ROUTER_ABI', MARGIN_ROUTER_ABI, 'src/MarginRouter.sol:MarginRouter'], + ['MARGIN_ACCOUNT_ABI', MARGIN_ACCOUNT_ABI, 'src/MarginAccount.sol:MarginAccount'], + ['MORPHO_LENDING_ADAPTER_ABI', MORPHO_LENDING_ADAPTER_ABI, 'src/MorphoLendingAdapter.sol:MorphoLendingAdapter'], + ['AAVE_LENDING_ADAPTER_ABI', AAVE_LENDING_ADAPTER_ABI, 'src/AaveLendingAdapter.sol:AaveLendingAdapter'], + ['AAVE_V4_LENDING_ADAPTER_ABI', AAVE_V4_LENDING_ADAPTER_ABI, 'src/AaveV4LendingAdapter.sol:AaveV4LendingAdapter'], + // The venue-agnostic surface must hold on every venue. + ['LENDING_ADAPTER_ABI vs Morpho', LENDING_ADAPTER_ABI, 'src/MorphoLendingAdapter.sol:MorphoLendingAdapter'], + ['LENDING_ADAPTER_ABI vs Aave v3', LENDING_ADAPTER_ABI, 'src/AaveLendingAdapter.sol:AaveLendingAdapter'], + ['LENDING_ADAPTER_ABI vs Aave v4', LENDING_ADAPTER_ABI, 'src/AaveV4LendingAdapter.sol:AaveV4LendingAdapter'], +] + +const allProblems: string[] = [] +for (const [label, sdkAbi, contract] of CHECKS) { + const solidity = await forgeAbi(contract) + const problems = diffAbi(label, sdkAbi, solidity) + allProblems.push(...problems) + console.log(problems.length === 0 ? `βœ“ ${label} matches ${contract}` : `βœ— ${label}: ${problems.length} drift(s)`) +} + +if (allProblems.length > 0) { + console.error(`\n${allProblems.length} ABI drift problem(s):`) + for (const problem of allProblems) console.error(` - ${problem}`) + process.exit(1) +} +console.log(`\nall SDK ABIs match the Solidity at ${peripheryPath}`) From 7797ffda1c9ace4fd542e7170079ce5ba6dfc214 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 24 Jul 2026 11:14:01 -0400 Subject: [PATCH 12/17] chore(margin-sdk): keep the initial release a 0.0.x pre-release 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 --- .changeset/margin-sdk-initial-release.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/margin-sdk-initial-release.md b/.changeset/margin-sdk-initial-release.md index 53ae75bea..907358b52 100644 --- a/.changeset/margin-sdk-initial-release.md +++ b/.changeset/margin-sdk-initial-release.md @@ -1,5 +1,5 @@ --- -'@uniswap/margin-sdk': minor +'@uniswap/margin-sdk': patch --- -Initial release of the margin trading SDK for the Uniswap v4 margin periphery (MarginRouter + Morpho Blue / Aave v3 / Aave v4 lending adapters): entry-point encoders and write descriptors (increase/decrease/close/addCollateral/execute/multicall/permit), offchain MarginAccount address derivation (Solady CWIA, verified against the live mainnet router), decimal-aware leverage/LTV/health sizing math, a validated `execute`-plan builder over the v4 routing + margin action set, venue-agnostic read descriptors, and the mainnet deployment address registry. +Initial pre-release of the margin trading SDK for the Uniswap v4 margin periphery (MarginRouter + Morpho Blue / Aave v3 / Aave v4 lending adapters): entry-point encoders and write descriptors (increase/decrease/close/addCollateral/execute/multicall/permit), offchain MarginAccount address derivation (Solady CWIA, verified against the live mainnet router), decimal-aware leverage/LTV/health sizing math, a validated `execute`-plan builder over the v4 routing + margin action set, venue-agnostic read descriptors, and the mainnet deployment address registry. Published as 0.0.x deliberately: the contracts (Uniswap/v4-periphery#563) are still in review and governance has not yet been handed to a timelock/multisig β€” the package graduates to 0.1.0 once the deployment is final. From 5f6fa69b4b06ce69a9b87994b782e1f126d797c2 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 24 Jul 2026 11:18:14 -0400 Subject: [PATCH 13/17] fix(margin-sdk): tolerate per-block interest accrual in a demo assertion 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 --- sdks/margin-sdk/demo/01-long-lifecycle.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sdks/margin-sdk/demo/01-long-lifecycle.ts b/sdks/margin-sdk/demo/01-long-lifecycle.ts index a041730b7..d4d246ab4 100644 --- a/sdks/margin-sdk/demo/01-long-lifecycle.ts +++ b/sdks/margin-sdk/demo/01-long-lifecycle.ts @@ -148,7 +148,9 @@ export async function run(ctx: Ctx): Promise { }) ) const added = routerEvent<{ amount: bigint; debtTotal: bigint; currentLtv: bigint }>(addReceipt, 'CollateralAdded') - assert(added?.amount === topUp && added.debtTotal === position.debtAmount, 'CollateralAdded: debt unchanged') + assert(added?.amount === topUp, 'CollateralAdded: exact top-up amount') + // debt is untouched by the add but accrues a few wei of interest across the blocks in between + assertApprox(added!.debtTotal, position.debtAmount, 1, 'CollateralAdded: debt unchanged (Β± accrued interest)') assert(added!.currentLtv < position.currentLtv, 'LTV improved after the top-up') // -- 7. Pure leverage increase: equity = 0, same account --------------------------------------- From ea156062977741d8109d452414a72ba37f3040d9 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 24 Jul 2026 11:50:14 -0400 Subject: [PATCH 14/17] feat(margin-sdk): replace hand-written ABIs with forge-generated bindings pinned to v4-periphery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sdks/margin-sdk/package.json | 3 +- sdks/margin-sdk/scripts/check-abi-drift.ts | 122 - sdks/margin-sdk/scripts/generate-abis.ts | 179 + sdks/margin-sdk/src/abis.ts | 801 +--- sdks/margin-sdk/src/generated/abis.ts | 4072 ++++++++++++++++++++ 5 files changed, 4272 insertions(+), 905 deletions(-) delete mode 100644 sdks/margin-sdk/scripts/check-abi-drift.ts create mode 100644 sdks/margin-sdk/scripts/generate-abis.ts create mode 100644 sdks/margin-sdk/src/generated/abis.ts diff --git a/sdks/margin-sdk/package.json b/sdks/margin-sdk/package.json index 762d7ec68..1fa9bd633 100644 --- a/sdks/margin-sdk/package.json +++ b/sdks/margin-sdk/package.json @@ -33,7 +33,8 @@ "lint": "eslint src --ext .ts", "test": "bun test && bun run check:package && bun run test:fork", "check:package": "bun run build && node scripts/check-package.mjs", - "check:abi-drift": "bun scripts/check-abi-drift.ts", + "regenerate:abis": "bun scripts/generate-abis.ts", + "check:abis": "bun scripts/generate-abis.ts --check", "test:fork": "bun scripts/fork-tests.ts", "demo": "bun demo/run-all.ts" }, diff --git a/sdks/margin-sdk/scripts/check-abi-drift.ts b/sdks/margin-sdk/scripts/check-abi-drift.ts deleted file mode 100644 index fd138ff72..000000000 --- a/sdks/margin-sdk/scripts/check-abi-drift.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * ABI drift gate: verifies every hand-written SDK ABI entry against the Solidity source of truth. - * - * viem encodes tuples positionally, so a silent field reorder in the (still-draft) contracts - * would produce valid-looking but wrong calldata with unit tests still green. This script compiles - * the contracts with `forge inspect` from a local v4-periphery checkout and asserts that every - * function / event / error in the SDK ABIs exists there with an identical canonical signature, - * identical outputs, state mutability, and event index layout. - * - * V4_PERIPHERY_PATH=~/dev/v4-periphery bun run check:abi-drift - * - * Run it whenever the contracts move; it requires foundry and a checkout of the margin-trading - * branch, so it is a manual/optional gate rather than part of the default CI test run. - */ -import { type AbiParameter } from 'viem' - -import { - AAVE_LENDING_ADAPTER_ABI, - AAVE_V4_LENDING_ADAPTER_ABI, - LENDING_ADAPTER_ABI, - MARGIN_ACCOUNT_ABI, - MARGIN_ROUTER_ABI, - MORPHO_LENDING_ADAPTER_ABI, -} from '../src/abis.js' - -const peripheryPath = process.env.V4_PERIPHERY_PATH ?? `${process.env.HOME}/dev/v4-periphery` - -interface AbiItem { - type: string - name?: string - inputs?: readonly AbiParameter[] - outputs?: readonly AbiParameter[] - stateMutability?: string -} - -function canonicalType(param: AbiParameter): string { - if (param.type.startsWith('tuple')) { - const components = (param as { components?: readonly AbiParameter[] }).components ?? [] - return `(${components.map(canonicalType).join(',')})${param.type.slice('tuple'.length)}` - } - return param.type -} - -function signature(item: AbiItem): string { - return `${item.name}(${(item.inputs ?? []).map(canonicalType).join(',')})` -} - -function indexedLayout(item: AbiItem): string { - return (item.inputs ?? []).map((input) => ((input as { indexed?: boolean }).indexed ? '1' : '0')).join('') -} - -async function forgeAbi(contract: string): Promise { - const proc = Bun.spawnSync(['forge', 'inspect', contract, 'abi', '--json'], { cwd: peripheryPath }) - if (proc.exitCode !== 0) { - throw new Error(`forge inspect ${contract} failed:\n${proc.stderr.toString()}`) - } - return JSON.parse(proc.stdout.toString()) as AbiItem[] -} - -function diffAbi(label: string, sdkAbi: readonly unknown[], solidity: AbiItem[]): string[] { - const problems: string[] = [] - const bySignature = new Map() - for (const item of solidity) { - if (item.type === 'function' || item.type === 'event' || item.type === 'error') { - bySignature.set(`${item.type}:${signature(item)}`, item) - } - } - for (const entry of sdkAbi as AbiItem[]) { - if (entry.type !== 'function' && entry.type !== 'event' && entry.type !== 'error') continue - const key = `${entry.type}:${signature(entry)}` - const onchain = bySignature.get(key) - if (!onchain) { - problems.push(`${label}: ${key} not found in the Solidity ABI (renamed, removed, or reordered fields?)`) - continue - } - if (entry.type === 'function') { - const sdkOutputs = (entry.outputs ?? []).map(canonicalType).join(',') - const solOutputs = (onchain.outputs ?? []).map(canonicalType).join(',') - if (sdkOutputs !== solOutputs) { - problems.push(`${label}: ${key} outputs drifted: sdk (${sdkOutputs}) vs solidity (${solOutputs})`) - } - if (entry.stateMutability !== onchain.stateMutability) { - problems.push( - `${label}: ${key} stateMutability drifted: sdk ${entry.stateMutability} vs solidity ${onchain.stateMutability}` - ) - } - } - if (entry.type === 'event' && indexedLayout(entry) !== indexedLayout(onchain)) { - problems.push( - `${label}: ${key} indexed layout drifted: sdk ${indexedLayout(entry)} vs solidity ${indexedLayout(onchain)}` - ) - } - } - return problems -} - -const CHECKS: Array<[string, readonly unknown[], string]> = [ - ['MARGIN_ROUTER_ABI', MARGIN_ROUTER_ABI, 'src/MarginRouter.sol:MarginRouter'], - ['MARGIN_ACCOUNT_ABI', MARGIN_ACCOUNT_ABI, 'src/MarginAccount.sol:MarginAccount'], - ['MORPHO_LENDING_ADAPTER_ABI', MORPHO_LENDING_ADAPTER_ABI, 'src/MorphoLendingAdapter.sol:MorphoLendingAdapter'], - ['AAVE_LENDING_ADAPTER_ABI', AAVE_LENDING_ADAPTER_ABI, 'src/AaveLendingAdapter.sol:AaveLendingAdapter'], - ['AAVE_V4_LENDING_ADAPTER_ABI', AAVE_V4_LENDING_ADAPTER_ABI, 'src/AaveV4LendingAdapter.sol:AaveV4LendingAdapter'], - // The venue-agnostic surface must hold on every venue. - ['LENDING_ADAPTER_ABI vs Morpho', LENDING_ADAPTER_ABI, 'src/MorphoLendingAdapter.sol:MorphoLendingAdapter'], - ['LENDING_ADAPTER_ABI vs Aave v3', LENDING_ADAPTER_ABI, 'src/AaveLendingAdapter.sol:AaveLendingAdapter'], - ['LENDING_ADAPTER_ABI vs Aave v4', LENDING_ADAPTER_ABI, 'src/AaveV4LendingAdapter.sol:AaveV4LendingAdapter'], -] - -const allProblems: string[] = [] -for (const [label, sdkAbi, contract] of CHECKS) { - const solidity = await forgeAbi(contract) - const problems = diffAbi(label, sdkAbi, solidity) - allProblems.push(...problems) - console.log(problems.length === 0 ? `βœ“ ${label} matches ${contract}` : `βœ— ${label}: ${problems.length} drift(s)`) -} - -if (allProblems.length > 0) { - console.error(`\n${allProblems.length} ABI drift problem(s):`) - for (const problem of allProblems) console.error(` - ${problem}`) - process.exit(1) -} -console.log(`\nall SDK ABIs match the Solidity at ${peripheryPath}`) diff --git a/sdks/margin-sdk/scripts/generate-abis.ts b/sdks/margin-sdk/scripts/generate-abis.ts new file mode 100644 index 000000000..e9c9fc3a8 --- /dev/null +++ b/sdks/margin-sdk/scripts/generate-abis.ts @@ -0,0 +1,179 @@ +/** + * ABI binding generator: emits `src/generated/abis.ts` from a **pinned** v4-periphery commit, + * compiled with forge β€” no hand-written ABI ever ships. viem encodes tuples positionally, so the + * bindings must be byte-derived from the contracts the SDK targets; regenerating from the pin and + * diffing (`--check`) proves the committed bindings match that source exactly. + * + * bun run regenerate:abis # regenerate from V4_PERIPHERY_PATH (must be at the pin) + * bun run regenerate:abis --update-pin # re-pin to the checkout's HEAD and regenerate + * bun run check:abis # CI gate: regenerate to memory and diff, write nothing + * + * Env: + * V4_PERIPHERY_PATH local v4-periphery checkout (default ~/dev/v4-periphery) + * V4_PERIPHERY_COMMIT expected commit override (defaults to the pin in the generated file) + * + * The pin lives as a human-readable "Pinned to v4-periphery commit " line in the generated + * file header β€” the single source of truth the CI workflow greps for, mirroring the + * liquidity-launcher lock-bytecode gate. + */ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const PACKAGE_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const OUTPUT_PATH = path.join(PACKAGE_ROOT, 'src/generated/abis.ts') +const REPOSITORY = 'Uniswap/v4-periphery' + +const CONTRACTS: Array<{ exportName: string; contract: string }> = [ + { exportName: 'MARGIN_ROUTER_ABI', contract: 'src/MarginRouter.sol:MarginRouter' }, + { exportName: 'MARGIN_ACCOUNT_ABI', contract: 'src/MarginAccount.sol:MarginAccount' }, + { exportName: 'MORPHO_LENDING_ADAPTER_ABI', contract: 'src/MorphoLendingAdapter.sol:MorphoLendingAdapter' }, + { exportName: 'AAVE_LENDING_ADAPTER_ABI', contract: 'src/AaveLendingAdapter.sol:AaveLendingAdapter' }, + { exportName: 'AAVE_V4_LENDING_ADAPTER_ABI', contract: 'src/AaveV4LendingAdapter.sol:AaveV4LendingAdapter' }, + { exportName: 'ILENDING_ADAPTER_ABI', contract: 'src/interfaces/ILendingAdapter.sol:ILendingAdapter' }, +] + +// The venue-agnostic surface reads.ts binds to: the ILendingAdapter interface plus the two-step +// ownership handoff and the shared errors every adapter carries (assembled from the compiled +// Morpho adapter ABI so nothing is hand-written). +const SHARED_ADAPTER_FUNCTIONS = ['owner', 'pendingOwner', 'transferOwnership', 'acceptOwnership'] +const SHARED_ADAPTER_ERRORS = ['MarketNotSupported', 'NotOwner', 'ZeroOwner', 'NotPendingOwner'] + +type AbiItem = Record & { type: string; name?: string } + +const checkMode = process.argv.includes('--check') +const updatePin = process.argv.includes('--update-pin') +const peripheryPath = process.env.V4_PERIPHERY_PATH ?? `${process.env.HOME}/dev/v4-periphery` + +function git(args: string[]): string { + const proc = Bun.spawnSync(['git', '-C', peripheryPath, ...args]) + if (proc.exitCode !== 0) throw new Error(`git ${args.join(' ')} failed:\n${proc.stderr.toString()}`) + return proc.stdout.toString().trim() +} + +function readCommittedPin(): string | undefined { + if (!fs.existsSync(OUTPUT_PATH)) return undefined + return fs.readFileSync(OUTPUT_PATH, 'utf8').match(/Pinned to v4-periphery commit ([0-9a-f]{40})/)?.[1] +} + +/** Strips solc metadata (internalType) and re-serializes with a stable key order. */ +function normalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalize) + if (typeof value === 'object' && value !== null) { + const entry = value as Record + const ordered: Record = {} + for (const key of ['type', 'name', 'stateMutability', 'anonymous', 'indexed', 'components', 'inputs', 'outputs']) { + if (key in entry && key !== 'internalType') ordered[key] = normalize(entry[key]) + } + return ordered + } + return value +} + +function forgeAbi(contract: string): AbiItem[] { + const proc = Bun.spawnSync(['forge', 'inspect', contract, 'abi', '--json'], { cwd: peripheryPath }) + if (proc.exitCode !== 0) throw new Error(`forge inspect ${contract} failed:\n${proc.stderr.toString()}`) + return normalize(JSON.parse(proc.stdout.toString())) as AbiItem[] +} + +function render(pin: string, abis: Map): string { + const morpho = abis.get('MORPHO_LENDING_ADAPTER_ABI')! + const shared = [ + ...abis.get('ILENDING_ADAPTER_ABI')!, + ...morpho.filter((item) => item.type === 'function' && SHARED_ADAPTER_FUNCTIONS.includes(item.name ?? '')), + ...morpho.filter((item) => item.type === 'error' && SHARED_ADAPTER_ERRORS.includes(item.name ?? '')), + ] + + const sections = [...CONTRACTS, { exportName: 'LENDING_ADAPTER_ABI', contract: '(assembled, see header)' }].map( + ({ exportName, contract }) => { + const abi = exportName === 'LENDING_ADAPTER_ABI' ? shared : abis.get(exportName)! + return `/** ${contract} */\nexport const ${exportName} = ${JSON.stringify(abi, null, 2)} as const satisfies Abi` + } + ) + + return `/** + * GENERATED FILE β€” DO NOT EDIT. + * + * Forge-generated ABI bindings for the margin trading periphery. + * Pinned to v4-periphery commit ${pin} + * (https://github.com/${REPOSITORY}/commit/${pin}) + * + * Regenerate with \`bun run regenerate:abis\`; CI verifies the bindings against a fresh build of + * the pinned commit via \`bun run check:abis\`. LENDING_ADAPTER_ABI is the venue-agnostic surface: + * the compiled ILendingAdapter interface plus the ownership functions and shared errors selected + * from the compiled MorphoLendingAdapter ABI (identical across venues; the check gate proves the + * per-venue ABIs against their own contracts). + */ +import { type Abi } from 'viem' + +/** The v4-periphery source this file was generated from. */ +export const V4_PERIPHERY_PIN = { repository: '${REPOSITORY}', commit: '${pin}' } as const + +${sections.join('\n\n')} +` +} + +function prettify(source: string): string { + const proc = Bun.spawnSync(['bunx', 'prettier', '--stdin-filepath', OUTPUT_PATH], { + cwd: PACKAGE_ROOT, + stdin: Buffer.from(source), + }) + if (proc.exitCode !== 0) throw new Error(`prettier failed:\n${proc.stderr.toString()}`) + return proc.stdout.toString() +} + +// -- main ------------------------------------------------------------------ + +const committedPin = readCommittedPin() +const expectedPin = process.env.V4_PERIPHERY_COMMIT ?? committedPin +const head = git(['rev-parse', 'HEAD']) + +if (updatePin) { + if (checkMode) throw new Error('--update-pin cannot be combined with --check') +} else { + if (!expectedPin) throw new Error('no existing pin found β€” run with --update-pin to establish one') + if (head !== expectedPin) { + console.error(`v4-periphery checkout at ${peripheryPath} is at ${head}`) + console.error(`but the bindings are pinned to ${expectedPin}`) + console.error('check out the pinned commit (or pass --update-pin to re-pin to HEAD)') + process.exit(1) + } +} +// untracked files can't affect the compiled output; only tracked modifications poison the pin +if (git(['status', '--porcelain', '--untracked-files=no']).length > 0) { + console.error(`v4-periphery checkout at ${peripheryPath} has uncommitted tracked changes β€” refusing to generate`) + process.exit(1) +} +git(['submodule', 'update', '--init', '--recursive']) + +const abis = new Map() +for (const { exportName, contract } of CONTRACTS) { + abis.set(exportName, forgeAbi(contract)) + console.log(`βœ“ compiled ${contract} (${abis.get(exportName)!.length} ABI items)`) +} + +const generated = prettify(render(updatePin ? head : expectedPin!, abis)) + +if (checkMode) { + const committed = fs.existsSync(OUTPUT_PATH) ? fs.readFileSync(OUTPUT_PATH, 'utf8') : '' + if (committed !== generated) { + console.error('βœ— committed ABI bindings do not match a fresh build of the pinned v4-periphery commit') + console.error(' run `bun run regenerate:abis` and commit the result') + const committedLines = committed.split('\n') + const generatedLines = generated.split('\n') + for (let i = 0; i < Math.max(committedLines.length, generatedLines.length); i++) { + if (committedLines[i] !== generatedLines[i]) { + console.error(` first difference at line ${i + 1}:`) + console.error(` committed: ${committedLines[i] ?? ''}`) + console.error(` generated: ${generatedLines[i] ?? ''}`) + break + } + } + process.exit(1) + } + console.log(`βœ“ committed ABI bindings match the pinned v4-periphery build (${expectedPin})`) +} else { + fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }) + fs.writeFileSync(OUTPUT_PATH, generated) + console.log(`wrote ${path.relative(PACKAGE_ROOT, OUTPUT_PATH)} pinned to ${updatePin ? head : expectedPin}`) +} diff --git a/sdks/margin-sdk/src/abis.ts b/sdks/margin-sdk/src/abis.ts index 6a6a949bc..67de305dd 100644 --- a/sdks/margin-sdk/src/abis.ts +++ b/sdks/margin-sdk/src/abis.ts @@ -1,792 +1,29 @@ import { type Abi } from 'viem' /** - * Minimal, exact ABIs for the margin stack. Each is `as const satisfies Abi` so viem/wagmi infer - * argument and return types. Function and struct layouts were verified against the deployed - * mainnet contracts (each entry point selector confirmed via an expired-deadline `DeadlinePassed` - * revert; every read called live). + * ABIs for the margin stack. The margin-contract ABIs are FORGE-GENERATED from a pinned + * v4-periphery commit (`src/generated/abis.ts`, regenerated with `bun run regenerate:abis` and + * CI-verified against a fresh build of the pin with `bun run check:abis`) β€” nothing hand-written + * can drift from the deployed contracts. Each is `as const satisfies Abi` so viem/wagmi infer + * argument and return types; the deployed entry-point selectors are additionally anchored against + * the live mainnet router in encode.test.ts. */ - -const MARKET_COMPONENTS = [ - { name: 'collateral', type: 'address' }, - { name: 'debt', type: 'address' }, -] as const - -const POOL_KEY_COMPONENTS = [ - { name: 'currency0', type: 'address' }, - { name: 'currency1', type: 'address' }, - { name: 'fee', type: 'uint24' }, - { name: 'tickSpacing', type: 'int24' }, - { name: 'hooks', type: 'address' }, -] as const - -export const MARGIN_ROUTER_ABI = [ - // ------------------------------------------------------------------------- - // Position entry points - // ------------------------------------------------------------------------- - { - type: 'function', - name: 'increasePosition', - stateMutability: 'payable', - inputs: [ - { - name: 'params', - type: 'tuple', - components: [ - { name: 'adapter', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - { name: 'poolKey', type: 'tuple', components: POOL_KEY_COMPONENTS }, - { name: 'equity', type: 'uint256' }, - { name: 'collateralToBuy', type: 'uint128' }, - { name: 'maxDebtIn', type: 'uint128' }, - { name: 'minHopPriceX36', type: 'uint256' }, - { name: 'maxLtvAfter', type: 'uint256' }, - { name: 'subId', type: 'uint256' }, - { name: 'deadline', type: 'uint256' }, - ], - }, - ], - outputs: [{ name: 'account', type: 'address' }], - }, - { - type: 'function', - name: 'decreasePosition', - stateMutability: 'nonpayable', - inputs: [ - { - name: 'params', - type: 'tuple', - components: [ - { name: 'adapter', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - { name: 'poolKey', type: 'tuple', components: POOL_KEY_COMPONENTS }, - { name: 'debtToRepay', type: 'uint256' }, - { name: 'maxCollateralIn', type: 'uint128' }, - { name: 'minHopPriceX36', type: 'uint256' }, - { name: 'maxLtvAfter', type: 'uint256' }, - { name: 'subId', type: 'uint256' }, - { name: 'deadline', type: 'uint256' }, - ], - }, - ], - outputs: [{ name: 'account', type: 'address' }], - }, - { - type: 'function', - name: 'addCollateral', - stateMutability: 'payable', - inputs: [ - { - name: 'params', - type: 'tuple', - components: [ - { name: 'adapter', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - { name: 'amount', type: 'uint256' }, - { name: 'subId', type: 'uint256' }, - { name: 'deadline', type: 'uint256' }, - ], - }, - ], - outputs: [{ name: 'account', type: 'address' }], - }, - { - type: 'function', - name: 'execute', - stateMutability: 'payable', - inputs: [ - { name: 'unlockData', type: 'bytes' }, - { name: 'deadline', type: 'uint256' }, - ], - outputs: [], - }, - // ------------------------------------------------------------------------- - // Accounts - // ------------------------------------------------------------------------- - { - type: 'function', - name: 'accountOf', - stateMutability: 'view', - inputs: [ - { name: 'owner', type: 'address' }, - { name: 'subId', type: 'uint256' }, - ], - outputs: [{ name: '', type: 'address' }], - }, - { - type: 'function', - name: 'createAccount', - stateMutability: 'nonpayable', - inputs: [ - { name: 'owner', type: 'address' }, - { name: 'subId', type: 'uint256' }, - ], - outputs: [{ name: 'account', type: 'address' }], - }, - { - type: 'function', - name: 'accountImplementation', - stateMutability: 'view', - inputs: [], - outputs: [{ name: '', type: 'address' }], - }, - { - type: 'function', - name: 'manager', - stateMutability: 'view', - inputs: [], - outputs: [{ name: '', type: 'address' }], - }, - // ------------------------------------------------------------------------- - // Governance - // ------------------------------------------------------------------------- - { - type: 'function', - name: 'governance', - stateMutability: 'view', - inputs: [], - outputs: [{ name: '', type: 'address' }], - }, - { - type: 'function', - name: 'pendingGovernance', - stateMutability: 'view', - inputs: [], - outputs: [{ name: '', type: 'address' }], - }, - { - type: 'function', - name: 'isAdapterAllowed', - stateMutability: 'view', - inputs: [{ name: 'adapter', type: 'address' }], - outputs: [{ name: '', type: 'bool' }], - }, - { - type: 'function', - name: 'setAdapterAllowed', - stateMutability: 'nonpayable', - inputs: [ - { name: 'adapter', type: 'address' }, - { name: 'allowed', type: 'bool' }, - ], - outputs: [], - }, - { - type: 'function', - name: 'transferGovernance', - stateMutability: 'nonpayable', - inputs: [{ name: 'newGovernance', type: 'address' }], - outputs: [], - }, - { - type: 'function', - name: 'acceptGovernance', - stateMutability: 'nonpayable', - inputs: [], - outputs: [], - }, - // ------------------------------------------------------------------------- - // Batching & Permit2 forwarding (inherited Multicall_v4 / Permit2Forwarder) - // ------------------------------------------------------------------------- - { - type: 'function', - name: 'multicall', - stateMutability: 'payable', - inputs: [{ name: 'data', type: 'bytes[]' }], - outputs: [{ name: 'results', type: 'bytes[]' }], - }, - { - type: 'function', - name: 'permit', - stateMutability: 'payable', - inputs: [ - { name: 'owner', type: 'address' }, - { - name: 'permitSingle', - type: 'tuple', - components: [ - { - name: 'details', - type: 'tuple', - components: [ - { name: 'token', type: 'address' }, - { name: 'amount', type: 'uint160' }, - { name: 'expiration', type: 'uint48' }, - { name: 'nonce', type: 'uint48' }, - ], - }, - { name: 'spender', type: 'address' }, - { name: 'sigDeadline', type: 'uint256' }, - ], - }, - { name: 'signature', type: 'bytes' }, - ], - outputs: [{ name: 'err', type: 'bytes' }], - }, - // ------------------------------------------------------------------------- - // Events - // ------------------------------------------------------------------------- - { - type: 'event', - name: 'PositionIncreased', - inputs: [ - { name: 'owner', type: 'address', indexed: true }, - { name: 'account', type: 'address', indexed: true }, - { name: 'collateral', type: 'address', indexed: false }, - { name: 'debt', type: 'address', indexed: false }, - { name: 'equity', type: 'uint256', indexed: false }, - { name: 'collateralBought', type: 'uint256', indexed: false }, - { name: 'debtDrawn', type: 'uint256', indexed: false }, - { name: 'collateralTotal', type: 'uint256', indexed: false }, - { name: 'debtTotal', type: 'uint256', indexed: false }, - { name: 'currentLtv', type: 'uint256', indexed: false }, - { name: 'maxLtv', type: 'uint256', indexed: false }, - { name: 'healthFactorWad', type: 'uint256', indexed: false }, - ], - }, - { - type: 'event', - name: 'PositionDecreased', - inputs: [ - { name: 'owner', type: 'address', indexed: true }, - { name: 'account', type: 'address', indexed: true }, - { name: 'collateral', type: 'address', indexed: false }, - { name: 'debt', type: 'address', indexed: false }, - { name: 'debtRepaid', type: 'uint256', indexed: false }, - { name: 'collateralWithdrawn', type: 'uint256', indexed: false }, - { name: 'collateralReturned', type: 'uint256', indexed: false }, - { name: 'collateralTotal', type: 'uint256', indexed: false }, - { name: 'debtTotal', type: 'uint256', indexed: false }, - { name: 'currentLtv', type: 'uint256', indexed: false }, - { name: 'healthFactorWad', type: 'uint256', indexed: false }, - ], - }, - { - type: 'event', - name: 'CollateralAdded', - inputs: [ - { name: 'owner', type: 'address', indexed: true }, - { name: 'account', type: 'address', indexed: true }, - { name: 'collateral', type: 'address', indexed: false }, - { name: 'amount', type: 'uint256', indexed: false }, - { name: 'collateralTotal', type: 'uint256', indexed: false }, - { name: 'debtTotal', type: 'uint256', indexed: false }, - { name: 'currentLtv', type: 'uint256', indexed: false }, - { name: 'healthFactorWad', type: 'uint256', indexed: false }, - ], - }, - { - type: 'event', - name: 'AccountCreated', - inputs: [ - { name: 'owner', type: 'address', indexed: true }, - { name: 'account', type: 'address', indexed: true }, - { name: 'subId', type: 'uint256', indexed: false }, - ], - }, - { - type: 'event', - name: 'AdapterAllowed', - inputs: [ - { name: 'adapter', type: 'address', indexed: true }, - { name: 'allowed', type: 'bool', indexed: false }, - ], - }, - { - type: 'event', - name: 'GovernanceTransferStarted', - inputs: [ - { name: 'currentGovernance', type: 'address', indexed: true }, - { name: 'pendingGovernance', type: 'address', indexed: true }, - ], - }, - { - type: 'event', - name: 'GovernanceTransferred', - inputs: [ - { name: 'previousGovernance', type: 'address', indexed: true }, - { name: 'newGovernance', type: 'address', indexed: true }, - ], - }, - // ------------------------------------------------------------------------- - // Errors - // ------------------------------------------------------------------------- - { type: 'error', name: 'DeadlinePassed', inputs: [{ name: 'deadline', type: 'uint256' }] }, - { type: 'error', name: 'SlippageBoundRequired', inputs: [] }, - { type: 'error', name: 'PositionUnhealthy', inputs: [] }, - { type: 'error', name: 'AdapterNotAllowed', inputs: [{ name: 'adapter', type: 'address' }] }, - { type: 'error', name: 'NativeCollateralMismatch', inputs: [] }, - { - type: 'error', - name: 'IncompleteFill', - inputs: [ - { name: 'requested', type: 'uint256' }, - { name: 'received', type: 'uint256' }, - ], - }, - { type: 'error', name: 'NoActiveAccount', inputs: [] }, - { type: 'error', name: 'MarketSwapMismatch', inputs: [] }, - { type: 'error', name: 'UnsupportedAction', inputs: [{ name: 'action', type: 'uint256' }] }, - { - type: 'error', - name: 'V4TooLittleReceived', - inputs: [ - { name: 'minAmountOutReceived', type: 'uint256' }, - { name: 'amountReceived', type: 'uint256' }, - ], - }, - { - type: 'error', - name: 'V4TooMuchRequested', - inputs: [ - { name: 'maxAmountInRequested', type: 'uint256' }, - { name: 'amountRequested', type: 'uint256' }, - ], - }, - { - type: 'error', - name: 'V4TooMuchRequestedPerHopSingle', - inputs: [ - { name: 'minPrice', type: 'uint256' }, - { name: 'priceX36', type: 'uint256' }, - ], - }, -] as const satisfies Abi +export { + AAVE_LENDING_ADAPTER_ABI, + AAVE_V4_LENDING_ADAPTER_ABI, + ILENDING_ADAPTER_ABI, + LENDING_ADAPTER_ABI, + MARGIN_ACCOUNT_ABI, + MARGIN_ROUTER_ABI, + MORPHO_LENDING_ADAPTER_ABI, + V4_PERIPHERY_PIN, +} from './generated/abis.js' /** - * The venue-agnostic `ILendingAdapter` read surface plus the shared two-step ownership handoff. - * Identical across the Morpho Blue, Aave v3, and Aave v4 adapters β€” only the adapter address (and - * the `setMarket` governance signature, see the per-venue ABIs) changes. + * Minimal Permit2 AllowanceTransfer surface used by the equity-funding flow. Hand-written and + * excluded from the generated bindings deliberately: canonical Permit2 is immutable and identical + * on every chain, so there is no source to drift from. */ -export const LENDING_ADAPTER_ABI = [ - { - type: 'function', - name: 'lendingProtocol', - stateMutability: 'view', - inputs: [], - outputs: [{ name: '', type: 'address' }], - }, - { - type: 'function', - name: 'isSupportedMarket', - stateMutability: 'view', - inputs: [{ name: 'market', type: 'tuple', components: MARKET_COMPONENTS }], - outputs: [{ name: '', type: 'bool' }], - }, - { - type: 'function', - name: 'positionOf', - stateMutability: 'view', - inputs: [ - { name: 'account', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - ], - outputs: [ - { name: 'collateralAmount', type: 'uint256' }, - { name: 'debtAmount', type: 'uint256' }, - ], - }, - { - type: 'function', - name: 'maxLtvWad', - stateMutability: 'view', - inputs: [{ name: 'market', type: 'tuple', components: MARKET_COMPONENTS }], - outputs: [{ name: '', type: 'uint256' }], - }, - { - type: 'function', - name: 'currentLtvWad', - stateMutability: 'view', - inputs: [ - { name: 'account', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - ], - outputs: [{ name: '', type: 'uint256' }], - }, - { - type: 'function', - name: 'describePosition', - stateMutability: 'view', - inputs: [ - { name: 'account', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - ], - outputs: [ - { - name: 'data', - type: 'tuple', - components: [ - { name: 'collateralAmount', type: 'uint256' }, - { name: 'debtAmount', type: 'uint256' }, - { name: 'maxLtv', type: 'uint256' }, - { name: 'currentLtv', type: 'uint256' }, - { name: 'healthFactorWad', type: 'uint256' }, - ], - }, - ], - }, - // encode* views: the (target, value, callData) the account executes. Useful for building the - // owner escape-hatch `MarginAccount.execute(adapter, callData)` without protocol-specific code. - { - type: 'function', - name: 'encodeSupplyCollateral', - stateMutability: 'view', - inputs: [ - { name: 'account', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - { name: 'amount', type: 'uint256' }, - ], - outputs: [ - { name: 'target', type: 'address' }, - { name: 'value', type: 'uint256' }, - { name: 'callData', type: 'bytes' }, - ], - }, - { - type: 'function', - name: 'encodeWithdrawCollateral', - stateMutability: 'view', - inputs: [ - { name: 'account', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - { name: 'amount', type: 'uint256' }, - { name: 'receiver', type: 'address' }, - ], - outputs: [ - { name: 'target', type: 'address' }, - { name: 'value', type: 'uint256' }, - { name: 'callData', type: 'bytes' }, - ], - }, - { - type: 'function', - name: 'encodeBorrow', - stateMutability: 'view', - inputs: [ - { name: 'account', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - { name: 'amount', type: 'uint256' }, - ], - outputs: [ - { name: 'target', type: 'address' }, - { name: 'value', type: 'uint256' }, - { name: 'callData', type: 'bytes' }, - ], - }, - { - type: 'function', - name: 'encodeRepay', - stateMutability: 'view', - inputs: [ - { name: 'account', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - { name: 'amount', type: 'uint256' }, - ], - outputs: [ - { name: 'target', type: 'address' }, - { name: 'value', type: 'uint256' }, - { name: 'callData', type: 'bytes' }, - ], - }, - // Two-step ownership (OwnableAdapter / Owner type) - { - type: 'function', - name: 'owner', - stateMutability: 'view', - inputs: [], - outputs: [{ name: '', type: 'address' }], - }, - { - type: 'function', - name: 'pendingOwner', - stateMutability: 'view', - inputs: [], - outputs: [{ name: '', type: 'address' }], - }, - { - type: 'function', - name: 'transferOwnership', - stateMutability: 'nonpayable', - inputs: [{ name: 'newOwner', type: 'address' }], - outputs: [], - }, - { - type: 'function', - name: 'acceptOwnership', - stateMutability: 'nonpayable', - inputs: [], - outputs: [], - }, - { - type: 'error', - name: 'MarketNotSupported', - inputs: [ - { name: 'collateral', type: 'address' }, - { name: 'debt', type: 'address' }, - ], - }, - { type: 'error', name: 'NotOwner', inputs: [{ name: 'caller', type: 'address' }] }, - { type: 'error', name: 'ZeroOwner', inputs: [] }, - { type: 'error', name: 'NotPendingOwner', inputs: [{ name: 'caller', type: 'address' }] }, -] as const satisfies Abi - -/** MorphoLendingAdapter extras: Morpho Blue `MarketParams`-keyed market curation. */ -export const MORPHO_LENDING_ADAPTER_ABI = [ - ...LENDING_ADAPTER_ABI, - { - type: 'function', - name: 'setMarket', - stateMutability: 'nonpayable', - inputs: [ - { - name: 'marketParams', - type: 'tuple', - components: [ - { name: 'loanToken', type: 'address' }, - { name: 'collateralToken', type: 'address' }, - { name: 'oracle', type: 'address' }, - { name: 'irm', type: 'address' }, - { name: 'lltv', type: 'uint256' }, - ], - }, - ], - outputs: [], - }, - { - type: 'event', - name: 'MarketSet', - inputs: [ - { name: 'id', type: 'bytes32', indexed: true }, - { name: 'collateral', type: 'address', indexed: true }, - { name: 'debt', type: 'address', indexed: true }, - { name: 'oracle', type: 'address', indexed: false }, - { name: 'irm', type: 'address', indexed: false }, - { name: 'lltv', type: 'uint256', indexed: false }, - ], - }, - { type: 'error', name: 'MorphoMarketNotCreated', inputs: [] }, -] as const satisfies Abi - -/** AaveLendingAdapter (Aave v3) extras: pair-allowlist market curation. */ -export const AAVE_LENDING_ADAPTER_ABI = [ - ...LENDING_ADAPTER_ABI, - { - type: 'function', - name: 'setMarket', - stateMutability: 'nonpayable', - inputs: [ - { name: 'collateral', type: 'address' }, - { name: 'debt', type: 'address' }, - { name: 'allowed', type: 'bool' }, - ], - outputs: [], - }, - { - type: 'event', - name: 'MarketSet', - inputs: [ - { name: 'collateral', type: 'address', indexed: true }, - { name: 'debt', type: 'address', indexed: true }, - { name: 'allowed', type: 'bool', indexed: false }, - ], - }, -] as const satisfies Abi - -/** AaveV4LendingAdapter extras: per-Spoke reserveId-routed market curation. */ -export const AAVE_V4_LENDING_ADAPTER_ABI = [ - ...LENDING_ADAPTER_ABI, - { - type: 'function', - name: 'setMarket', - stateMutability: 'nonpayable', - inputs: [ - { name: 'collateral', type: 'address' }, - { name: 'debt', type: 'address' }, - { name: 'collateralReserveId', type: 'uint256' }, - { name: 'debtReserveId', type: 'uint256' }, - { name: 'allowed', type: 'bool' }, - ], - outputs: [], - }, - { - type: 'event', - name: 'MarketSet', - inputs: [ - { name: 'collateral', type: 'address', indexed: true }, - { name: 'debt', type: 'address', indexed: true }, - { name: 'collateralReserveId', type: 'uint256', indexed: false }, - { name: 'debtReserveId', type: 'uint256', indexed: false }, - { name: 'allowed', type: 'bool', indexed: false }, - ], - }, - { - type: 'error', - name: 'ReserveMismatch', - inputs: [ - { name: 'reserveId', type: 'uint256' }, - { name: 'expected', type: 'address' }, - { name: 'actual', type: 'address' }, - ], - }, - { - type: 'error', - name: 'HubMismatch', - inputs: [ - { name: 'collateralHub', type: 'address' }, - { name: 'debtHub', type: 'address' }, - ], - }, -] as const satisfies Abi - -/** The per-user MarginAccount clone (fund-moving primitives + owner escape hatch). */ -export const MARGIN_ACCOUNT_ABI = [ - { - type: 'function', - name: 'owner', - stateMutability: 'view', - inputs: [], - outputs: [{ name: '', type: 'address' }], - }, - { - type: 'function', - name: 'manager', - stateMutability: 'view', - inputs: [], - outputs: [{ name: '', type: 'address' }], - }, - { - type: 'function', - name: 'supplyCollateral', - stateMutability: 'nonpayable', - inputs: [ - { name: 'adapter', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - { name: 'amount', type: 'uint256' }, - ], - outputs: [{ name: 'supplied', type: 'uint256' }], - }, - { - type: 'function', - name: 'withdrawCollateral', - stateMutability: 'nonpayable', - inputs: [ - { name: 'adapter', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - { name: 'amount', type: 'uint256' }, - { name: 'to', type: 'address' }, - ], - outputs: [{ name: 'withdrawn', type: 'uint256' }], - }, - { - type: 'function', - name: 'borrow', - stateMutability: 'nonpayable', - inputs: [ - { name: 'adapter', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - { name: 'amount', type: 'uint256' }, - { name: 'to', type: 'address' }, - ], - outputs: [{ name: 'borrowed', type: 'uint256' }], - }, - { - type: 'function', - name: 'repay', - stateMutability: 'nonpayable', - inputs: [ - { name: 'adapter', type: 'address' }, - { name: 'market', type: 'tuple', components: MARKET_COMPONENTS }, - { name: 'amount', type: 'uint256' }, - ], - outputs: [{ name: 'repaid', type: 'uint256' }], - }, - { - type: 'function', - name: 'sweep', - stateMutability: 'nonpayable', - inputs: [ - { name: 'currency', type: 'address' }, - { name: 'amount', type: 'uint256' }, - { name: 'to', type: 'address' }, - ], - outputs: [], - }, - { - type: 'function', - name: 'execute', - stateMutability: 'payable', - inputs: [ - { name: 'adapter', type: 'address' }, - { name: 'adapterCall', type: 'bytes' }, - ], - outputs: [{ name: 'result', type: 'bytes' }], - }, - { - type: 'event', - name: 'CollateralSupplied', - inputs: [ - { name: 'caller', type: 'address', indexed: true }, - { name: 'adapter', type: 'address', indexed: true }, - { name: 'collateral', type: 'address', indexed: true }, - { name: 'amount', type: 'uint256', indexed: false }, - ], - }, - { - type: 'event', - name: 'CollateralWithdrawn', - inputs: [ - { name: 'caller', type: 'address', indexed: true }, - { name: 'adapter', type: 'address', indexed: true }, - { name: 'collateral', type: 'address', indexed: true }, - { name: 'amount', type: 'uint256', indexed: false }, - { name: 'to', type: 'address', indexed: false }, - ], - }, - { - type: 'event', - name: 'Borrowed', - inputs: [ - { name: 'caller', type: 'address', indexed: true }, - { name: 'adapter', type: 'address', indexed: true }, - { name: 'debt', type: 'address', indexed: true }, - { name: 'amount', type: 'uint256', indexed: false }, - { name: 'to', type: 'address', indexed: false }, - ], - }, - { - type: 'event', - name: 'Repaid', - inputs: [ - { name: 'caller', type: 'address', indexed: true }, - { name: 'adapter', type: 'address', indexed: true }, - { name: 'debt', type: 'address', indexed: true }, - { name: 'amount', type: 'uint256', indexed: false }, - ], - }, - { - type: 'event', - name: 'Swept', - inputs: [ - { name: 'caller', type: 'address', indexed: true }, - { name: 'currency', type: 'address', indexed: true }, - { name: 'amount', type: 'uint256', indexed: false }, - { name: 'to', type: 'address', indexed: false }, - ], - }, - { - type: 'event', - name: 'Executed', - inputs: [ - { name: 'caller', type: 'address', indexed: true }, - { name: 'adapter', type: 'address', indexed: true }, - { name: 'target', type: 'address', indexed: false }, - ], - }, - { type: 'error', name: 'NotAuthorized', inputs: [] }, - { type: 'error', name: 'ReceiverNotAllowed', inputs: [{ name: 'to', type: 'address' }] }, -] as const satisfies Abi - -/** Minimal Permit2 AllowanceTransfer surface used by the equity-funding flow. */ export const PERMIT2_ABI = [ { type: 'function', diff --git a/sdks/margin-sdk/src/generated/abis.ts b/sdks/margin-sdk/src/generated/abis.ts new file mode 100644 index 000000000..3c6d44c41 --- /dev/null +++ b/sdks/margin-sdk/src/generated/abis.ts @@ -0,0 +1,4072 @@ +/** + * GENERATED FILE β€” DO NOT EDIT. + * + * Forge-generated ABI bindings for the margin trading periphery. + * Pinned to v4-periphery commit fe8105a9e31ac6e30c9b18bd1078047cab3e1cea + * (https://github.com/Uniswap/v4-periphery/commit/fe8105a9e31ac6e30c9b18bd1078047cab3e1cea) + * + * Regenerate with `bun run regenerate:abis`; CI verifies the bindings against a fresh build of + * the pinned commit via `bun run check:abis`. LENDING_ADAPTER_ABI is the venue-agnostic surface: + * the compiled ILendingAdapter interface plus the ownership functions and shared errors selected + * from the compiled MorphoLendingAdapter ABI (identical across venues; the check gate proves the + * per-venue ABIs against their own contracts). + */ +import { type Abi } from 'viem' + +/** The v4-periphery source this file was generated from. */ +export const V4_PERIPHERY_PIN = { + repository: 'Uniswap/v4-periphery', + commit: 'fe8105a9e31ac6e30c9b18bd1078047cab3e1cea', +} as const + +/** src/MarginRouter.sol:MarginRouter */ +export const MARGIN_ROUTER_ABI = [ + { + type: 'constructor', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'poolManager_', + }, + { + type: 'address', + name: 'permit2_', + }, + { + type: 'address', + name: 'weth9_', + }, + { + type: 'address', + name: 'accountImplementation', + }, + { + type: 'address', + name: 'governance_', + }, + ], + }, + { + type: 'receive', + stateMutability: 'payable', + }, + { + type: 'function', + name: 'WETH9', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'acceptGovernance', + stateMutability: 'nonpayable', + inputs: [], + outputs: [], + }, + { + type: 'function', + name: 'accountImplementation', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'accountOf', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'owner', + }, + { + type: 'uint256', + name: 'subId', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'addCollateral', + stateMutability: 'payable', + inputs: [ + { + type: 'tuple', + name: 'params', + components: [ + { + type: 'address', + name: 'adapter', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + { + type: 'uint256', + name: 'subId', + }, + { + type: 'uint256', + name: 'deadline', + }, + ], + }, + ], + outputs: [ + { + type: 'address', + name: 'account', + }, + ], + }, + { + type: 'function', + name: 'createAccount', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'owner', + }, + { + type: 'uint256', + name: 'subId', + }, + ], + outputs: [ + { + type: 'address', + name: 'account', + }, + ], + }, + { + type: 'function', + name: 'decreasePosition', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'tuple', + name: 'params', + components: [ + { + type: 'address', + name: 'adapter', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'tuple', + name: 'poolKey', + components: [ + { + type: 'address', + name: 'currency0', + }, + { + type: 'address', + name: 'currency1', + }, + { + type: 'uint24', + name: 'fee', + }, + { + type: 'int24', + name: 'tickSpacing', + }, + { + type: 'address', + name: 'hooks', + }, + ], + }, + { + type: 'uint256', + name: 'debtToRepay', + }, + { + type: 'uint128', + name: 'maxCollateralIn', + }, + { + type: 'uint256', + name: 'minHopPriceX36', + }, + { + type: 'uint256', + name: 'maxLtvAfter', + }, + { + type: 'uint256', + name: 'subId', + }, + { + type: 'uint256', + name: 'deadline', + }, + ], + }, + ], + outputs: [ + { + type: 'address', + name: 'account', + }, + ], + }, + { + type: 'function', + name: 'execute', + stateMutability: 'payable', + inputs: [ + { + type: 'bytes', + name: 'unlockData', + }, + { + type: 'uint256', + name: 'deadline', + }, + ], + outputs: [], + }, + { + type: 'function', + name: 'governance', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'increasePosition', + stateMutability: 'payable', + inputs: [ + { + type: 'tuple', + name: 'params', + components: [ + { + type: 'address', + name: 'adapter', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'tuple', + name: 'poolKey', + components: [ + { + type: 'address', + name: 'currency0', + }, + { + type: 'address', + name: 'currency1', + }, + { + type: 'uint24', + name: 'fee', + }, + { + type: 'int24', + name: 'tickSpacing', + }, + { + type: 'address', + name: 'hooks', + }, + ], + }, + { + type: 'uint256', + name: 'equity', + }, + { + type: 'uint128', + name: 'collateralToBuy', + }, + { + type: 'uint128', + name: 'maxDebtIn', + }, + { + type: 'uint256', + name: 'minHopPriceX36', + }, + { + type: 'uint256', + name: 'maxLtvAfter', + }, + { + type: 'uint256', + name: 'subId', + }, + { + type: 'uint256', + name: 'deadline', + }, + ], + }, + ], + outputs: [ + { + type: 'address', + name: 'account', + }, + ], + }, + { + type: 'function', + name: 'isAdapterAllowed', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'adapter', + }, + ], + outputs: [ + { + type: 'bool', + name: '', + }, + ], + }, + { + type: 'function', + name: 'manager', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'msgSender', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'multicall', + stateMutability: 'payable', + inputs: [ + { + type: 'bytes[]', + name: 'data', + }, + ], + outputs: [ + { + type: 'bytes[]', + name: 'results', + }, + ], + }, + { + type: 'function', + name: 'pendingGovernance', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'permit', + stateMutability: 'payable', + inputs: [ + { + type: 'address', + name: 'owner', + }, + { + type: 'tuple', + name: 'permitSingle', + components: [ + { + type: 'tuple', + name: 'details', + components: [ + { + type: 'address', + name: 'token', + }, + { + type: 'uint160', + name: 'amount', + }, + { + type: 'uint48', + name: 'expiration', + }, + { + type: 'uint48', + name: 'nonce', + }, + ], + }, + { + type: 'address', + name: 'spender', + }, + { + type: 'uint256', + name: 'sigDeadline', + }, + ], + }, + { + type: 'bytes', + name: 'signature', + }, + ], + outputs: [ + { + type: 'bytes', + name: 'err', + }, + ], + }, + { + type: 'function', + name: 'permit2', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'permitBatch', + stateMutability: 'payable', + inputs: [ + { + type: 'address', + name: 'owner', + }, + { + type: 'tuple', + name: '_permitBatch', + components: [ + { + type: 'tuple[]', + name: 'details', + components: [ + { + type: 'address', + name: 'token', + }, + { + type: 'uint160', + name: 'amount', + }, + { + type: 'uint48', + name: 'expiration', + }, + { + type: 'uint48', + name: 'nonce', + }, + ], + }, + { + type: 'address', + name: 'spender', + }, + { + type: 'uint256', + name: 'sigDeadline', + }, + ], + }, + { + type: 'bytes', + name: 'signature', + }, + ], + outputs: [ + { + type: 'bytes', + name: 'err', + }, + ], + }, + { + type: 'function', + name: 'poolManager', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'setAdapterAllowed', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'adapter', + }, + { + type: 'bool', + name: 'allowed', + }, + ], + outputs: [], + }, + { + type: 'function', + name: 'transferGovernance', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'newGovernance', + }, + ], + outputs: [], + }, + { + type: 'function', + name: 'unlockCallback', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'bytes', + name: 'data', + }, + ], + outputs: [ + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'event', + name: 'AccountCreated', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'owner', + indexed: true, + }, + { + type: 'address', + name: 'account', + indexed: true, + }, + { + type: 'uint256', + name: 'subId', + indexed: false, + }, + ], + }, + { + type: 'event', + name: 'AdapterAllowed', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'adapter', + indexed: true, + }, + { + type: 'bool', + name: 'allowed', + indexed: false, + }, + ], + }, + { + type: 'event', + name: 'CollateralAdded', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'owner', + indexed: true, + }, + { + type: 'address', + name: 'account', + indexed: true, + }, + { + type: 'address', + name: 'collateral', + indexed: false, + }, + { + type: 'uint256', + name: 'amount', + indexed: false, + }, + { + type: 'uint256', + name: 'collateralTotal', + indexed: false, + }, + { + type: 'uint256', + name: 'debtTotal', + indexed: false, + }, + { + type: 'uint256', + name: 'currentLtv', + indexed: false, + }, + { + type: 'uint256', + name: 'healthFactorWad', + indexed: false, + }, + ], + }, + { + type: 'event', + name: 'GovernanceTransferStarted', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'currentGovernance', + indexed: true, + }, + { + type: 'address', + name: 'pendingGovernance', + indexed: true, + }, + ], + }, + { + type: 'event', + name: 'GovernanceTransferred', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'previousGovernance', + indexed: true, + }, + { + type: 'address', + name: 'newGovernance', + indexed: true, + }, + ], + }, + { + type: 'event', + name: 'PositionDecreased', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'owner', + indexed: true, + }, + { + type: 'address', + name: 'account', + indexed: true, + }, + { + type: 'address', + name: 'collateral', + indexed: false, + }, + { + type: 'address', + name: 'debt', + indexed: false, + }, + { + type: 'uint256', + name: 'debtRepaid', + indexed: false, + }, + { + type: 'uint256', + name: 'collateralWithdrawn', + indexed: false, + }, + { + type: 'uint256', + name: 'collateralReturned', + indexed: false, + }, + { + type: 'uint256', + name: 'collateralTotal', + indexed: false, + }, + { + type: 'uint256', + name: 'debtTotal', + indexed: false, + }, + { + type: 'uint256', + name: 'currentLtv', + indexed: false, + }, + { + type: 'uint256', + name: 'healthFactorWad', + indexed: false, + }, + ], + }, + { + type: 'event', + name: 'PositionIncreased', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'owner', + indexed: true, + }, + { + type: 'address', + name: 'account', + indexed: true, + }, + { + type: 'address', + name: 'collateral', + indexed: false, + }, + { + type: 'address', + name: 'debt', + indexed: false, + }, + { + type: 'uint256', + name: 'equity', + indexed: false, + }, + { + type: 'uint256', + name: 'collateralBought', + indexed: false, + }, + { + type: 'uint256', + name: 'debtDrawn', + indexed: false, + }, + { + type: 'uint256', + name: 'collateralTotal', + indexed: false, + }, + { + type: 'uint256', + name: 'debtTotal', + indexed: false, + }, + { + type: 'uint256', + name: 'currentLtv', + indexed: false, + }, + { + type: 'uint256', + name: 'maxLtv', + indexed: false, + }, + { + type: 'uint256', + name: 'healthFactorWad', + indexed: false, + }, + ], + }, + { + type: 'error', + name: 'AdapterNotAllowed', + inputs: [ + { + type: 'address', + name: 'adapter', + }, + ], + }, + { + type: 'error', + name: 'ContractLocked', + inputs: [], + }, + { + type: 'error', + name: 'DeadlinePassed', + inputs: [ + { + type: 'uint256', + name: 'deadline', + }, + ], + }, + { + type: 'error', + name: 'DeltaNotNegative', + inputs: [ + { + type: 'address', + name: 'currency', + }, + ], + }, + { + type: 'error', + name: 'DeltaNotPositive', + inputs: [ + { + type: 'address', + name: 'currency', + }, + ], + }, + { + type: 'error', + name: 'IncompleteFill', + inputs: [ + { + type: 'uint256', + name: 'requested', + }, + { + type: 'uint256', + name: 'received', + }, + ], + }, + { + type: 'error', + name: 'InputLengthMismatch', + inputs: [], + }, + { + type: 'error', + name: 'InsufficientBalance', + inputs: [], + }, + { + type: 'error', + name: 'InvalidBips', + inputs: [], + }, + { + type: 'error', + name: 'InvalidEthSender', + inputs: [], + }, + { + type: 'error', + name: 'InvalidHopPriceLength', + inputs: [], + }, + { + type: 'error', + name: 'MarketSwapMismatch', + inputs: [], + }, + { + type: 'error', + name: 'NativeCollateralMismatch', + inputs: [], + }, + { + type: 'error', + name: 'NoActiveAccount', + inputs: [], + }, + { + type: 'error', + name: 'NotOwner', + inputs: [ + { + type: 'address', + name: 'caller', + }, + ], + }, + { + type: 'error', + name: 'NotPendingOwner', + inputs: [ + { + type: 'address', + name: 'caller', + }, + ], + }, + { + type: 'error', + name: 'NotPoolManager', + inputs: [], + }, + { + type: 'error', + name: 'PositionUnhealthy', + inputs: [], + }, + { + type: 'error', + name: 'SlippageBoundRequired', + inputs: [], + }, + { + type: 'error', + name: 'UnsupportedAction', + inputs: [ + { + type: 'uint256', + name: 'action', + }, + ], + }, + { + type: 'error', + name: 'V4TooLittleReceived', + inputs: [ + { + type: 'uint256', + name: 'minAmountOutReceived', + }, + { + type: 'uint256', + name: 'amountReceived', + }, + ], + }, + { + type: 'error', + name: 'V4TooLittleReceivedPerHop', + inputs: [ + { + type: 'uint256', + name: 'hopIndex', + }, + { + type: 'uint256', + name: 'minPrice', + }, + { + type: 'uint256', + name: 'price', + }, + ], + }, + { + type: 'error', + name: 'V4TooLittleReceivedPerHopSingle', + inputs: [ + { + type: 'uint256', + name: 'minPrice', + }, + { + type: 'uint256', + name: 'price', + }, + ], + }, + { + type: 'error', + name: 'V4TooMuchRequested', + inputs: [ + { + type: 'uint256', + name: 'maxAmountInRequested', + }, + { + type: 'uint256', + name: 'amountRequested', + }, + ], + }, + { + type: 'error', + name: 'V4TooMuchRequestedPerHop', + inputs: [ + { + type: 'uint256', + name: 'hopIndex', + }, + { + type: 'uint256', + name: 'minPrice', + }, + { + type: 'uint256', + name: 'price', + }, + ], + }, + { + type: 'error', + name: 'V4TooMuchRequestedPerHopSingle', + inputs: [ + { + type: 'uint256', + name: 'minPrice', + }, + { + type: 'uint256', + name: 'price', + }, + ], + }, + { + type: 'error', + name: 'ZeroAddress', + inputs: [], + }, + { + type: 'error', + name: 'ZeroOwner', + inputs: [], + }, +] as const satisfies Abi + +/** src/MarginAccount.sol:MarginAccount */ +export const MARGIN_ACCOUNT_ABI = [ + { + type: 'function', + name: 'borrow', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'adapter', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + { + type: 'address', + name: 'to', + }, + ], + outputs: [ + { + type: 'uint256', + name: 'borrowed', + }, + ], + }, + { + type: 'function', + name: 'execute', + stateMutability: 'payable', + inputs: [ + { + type: 'address', + name: 'adapter', + }, + { + type: 'bytes', + name: 'adapterCall', + }, + ], + outputs: [ + { + type: 'bytes', + name: 'result', + }, + ], + }, + { + type: 'function', + name: 'manager', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: 'managerAddr', + }, + ], + }, + { + type: 'function', + name: 'owner', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: 'ownerAddr', + }, + ], + }, + { + type: 'function', + name: 'receive', + stateMutability: 'payable', + inputs: [], + outputs: [], + }, + { + type: 'function', + name: 'repay', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'adapter', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'uint256', + name: 'repaid', + }, + ], + }, + { + type: 'function', + name: 'supplyCollateral', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'adapter', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'uint256', + name: '', + }, + ], + }, + { + type: 'function', + name: 'sweep', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'currency', + }, + { + type: 'uint256', + name: 'amount', + }, + { + type: 'address', + name: 'to', + }, + ], + outputs: [], + }, + { + type: 'function', + name: 'withdrawCollateral', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'adapter', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + { + type: 'address', + name: 'to', + }, + ], + outputs: [ + { + type: 'uint256', + name: 'withdrawn', + }, + ], + }, + { + type: 'event', + name: 'Borrowed', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'caller', + indexed: true, + }, + { + type: 'address', + name: 'adapter', + indexed: true, + }, + { + type: 'address', + name: 'debt', + indexed: true, + }, + { + type: 'uint256', + name: 'amount', + indexed: false, + }, + { + type: 'address', + name: 'to', + indexed: false, + }, + ], + }, + { + type: 'event', + name: 'CollateralSupplied', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'caller', + indexed: true, + }, + { + type: 'address', + name: 'adapter', + indexed: true, + }, + { + type: 'address', + name: 'collateral', + indexed: true, + }, + { + type: 'uint256', + name: 'amount', + indexed: false, + }, + ], + }, + { + type: 'event', + name: 'CollateralWithdrawn', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'caller', + indexed: true, + }, + { + type: 'address', + name: 'adapter', + indexed: true, + }, + { + type: 'address', + name: 'collateral', + indexed: true, + }, + { + type: 'uint256', + name: 'amount', + indexed: false, + }, + { + type: 'address', + name: 'to', + indexed: false, + }, + ], + }, + { + type: 'event', + name: 'Executed', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'caller', + indexed: true, + }, + { + type: 'address', + name: 'adapter', + indexed: true, + }, + { + type: 'address', + name: 'target', + indexed: false, + }, + ], + }, + { + type: 'event', + name: 'Repaid', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'caller', + indexed: true, + }, + { + type: 'address', + name: 'adapter', + indexed: true, + }, + { + type: 'address', + name: 'debt', + indexed: true, + }, + { + type: 'uint256', + name: 'amount', + indexed: false, + }, + ], + }, + { + type: 'event', + name: 'Swept', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'caller', + indexed: true, + }, + { + type: 'address', + name: 'currency', + indexed: true, + }, + { + type: 'uint256', + name: 'amount', + indexed: false, + }, + { + type: 'address', + name: 'to', + indexed: false, + }, + ], + }, + { + type: 'error', + name: 'AddressEmptyCode', + inputs: [ + { + type: 'address', + name: 'target', + }, + ], + }, + { + type: 'error', + name: 'AddressInsufficientBalance', + inputs: [ + { + type: 'address', + name: 'account', + }, + ], + }, + { + type: 'error', + name: 'FailedInnerCall', + inputs: [], + }, + { + type: 'error', + name: 'NotAuthorized', + inputs: [], + }, + { + type: 'error', + name: 'ReceiverNotAllowed', + inputs: [ + { + type: 'address', + name: 'to', + }, + ], + }, + { + type: 'error', + name: 'SafeERC20FailedOperation', + inputs: [ + { + type: 'address', + name: 'token', + }, + ], + }, +] as const satisfies Abi + +/** src/MorphoLendingAdapter.sol:MorphoLendingAdapter */ +export const MORPHO_LENDING_ADAPTER_ABI = [ + { + type: 'constructor', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'morpho_', + }, + { + type: 'address', + name: 'owner_', + }, + ], + }, + { + type: 'function', + name: 'acceptOwnership', + stateMutability: 'nonpayable', + inputs: [], + outputs: [], + }, + { + type: 'function', + name: 'currentLtvWad', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: '', + }, + ], + }, + { + type: 'function', + name: 'describePosition', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'tuple', + name: 'data', + components: [ + { + type: 'uint256', + name: 'collateralAmount', + }, + { + type: 'uint256', + name: 'debtAmount', + }, + { + type: 'uint256', + name: 'maxLtv', + }, + { + type: 'uint256', + name: 'currentLtv', + }, + { + type: 'uint256', + name: 'healthFactorWad', + }, + ], + }, + ], + }, + { + type: 'function', + name: 'encodeBorrow', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + { + type: 'uint256', + name: '', + }, + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'function', + name: 'encodeRepay', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + { + type: 'uint256', + name: '', + }, + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'function', + name: 'encodeSupplyCollateral', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + { + type: 'uint256', + name: '', + }, + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'function', + name: 'encodeWithdrawCollateral', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + { + type: 'address', + name: 'receiver', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + { + type: 'uint256', + name: '', + }, + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'function', + name: 'isSupportedMarket', + stateMutability: 'view', + inputs: [ + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'bool', + name: '', + }, + ], + }, + { + type: 'function', + name: 'lendingProtocol', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'maxLtvWad', + stateMutability: 'view', + inputs: [ + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: '', + }, + ], + }, + { + type: 'function', + name: 'morpho', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'owner', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'pendingOwner', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'positionOf', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: 'collateralAmount', + }, + { + type: 'uint256', + name: 'debtAmount', + }, + ], + }, + { + type: 'function', + name: 'setMarket', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'tuple', + name: 'marketParams', + components: [ + { + type: 'address', + name: 'loanToken', + }, + { + type: 'address', + name: 'collateralToken', + }, + { + type: 'address', + name: 'oracle', + }, + { + type: 'address', + name: 'irm', + }, + { + type: 'uint256', + name: 'lltv', + }, + ], + }, + ], + outputs: [], + }, + { + type: 'function', + name: 'transferOwnership', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'newOwner', + }, + ], + outputs: [], + }, + { + type: 'event', + name: 'MarketSet', + anonymous: false, + inputs: [ + { + type: 'bytes32', + name: 'id', + indexed: true, + }, + { + type: 'address', + name: 'collateral', + indexed: true, + }, + { + type: 'address', + name: 'debt', + indexed: true, + }, + { + type: 'address', + name: 'oracle', + indexed: false, + }, + { + type: 'address', + name: 'irm', + indexed: false, + }, + { + type: 'uint256', + name: 'lltv', + indexed: false, + }, + ], + }, + { + type: 'error', + name: 'MarketNotSupported', + inputs: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'error', + name: 'MathOverflowedMulDiv', + inputs: [], + }, + { + type: 'error', + name: 'MorphoMarketNotCreated', + inputs: [], + }, + { + type: 'error', + name: 'NotOwner', + inputs: [ + { + type: 'address', + name: 'caller', + }, + ], + }, + { + type: 'error', + name: 'NotPendingOwner', + inputs: [ + { + type: 'address', + name: 'caller', + }, + ], + }, + { + type: 'error', + name: 'ZeroOwner', + inputs: [], + }, +] as const satisfies Abi + +/** src/AaveLendingAdapter.sol:AaveLendingAdapter */ +export const AAVE_LENDING_ADAPTER_ABI = [ + { + type: 'constructor', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'provider', + }, + { + type: 'address', + name: 'owner_', + }, + ], + }, + { + type: 'function', + name: 'acceptOwnership', + stateMutability: 'nonpayable', + inputs: [], + outputs: [], + }, + { + type: 'function', + name: 'currentLtvWad', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: '', + }, + ], + }, + { + type: 'function', + name: 'dataProvider', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'describePosition', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'tuple', + name: 'data', + components: [ + { + type: 'uint256', + name: 'collateralAmount', + }, + { + type: 'uint256', + name: 'debtAmount', + }, + { + type: 'uint256', + name: 'maxLtv', + }, + { + type: 'uint256', + name: 'currentLtv', + }, + { + type: 'uint256', + name: 'healthFactorWad', + }, + ], + }, + ], + }, + { + type: 'function', + name: 'encodeBorrow', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + { + type: 'uint256', + name: '', + }, + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'function', + name: 'encodeRepay', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + { + type: 'uint256', + name: '', + }, + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'function', + name: 'encodeSupplyCollateral', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + { + type: 'uint256', + name: '', + }, + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'function', + name: 'encodeWithdrawCollateral', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + { + type: 'address', + name: 'receiver', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + { + type: 'uint256', + name: '', + }, + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'function', + name: 'isSupportedMarket', + stateMutability: 'view', + inputs: [ + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'bool', + name: '', + }, + ], + }, + { + type: 'function', + name: 'lendingProtocol', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'maxLtvWad', + stateMutability: 'view', + inputs: [ + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: '', + }, + ], + }, + { + type: 'function', + name: 'owner', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'pendingOwner', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'pool', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'positionOf', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: 'collateralAmount', + }, + { + type: 'uint256', + name: 'debtAmount', + }, + ], + }, + { + type: 'function', + name: 'setMarket', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + { + type: 'bool', + name: 'allowed', + }, + ], + outputs: [], + }, + { + type: 'function', + name: 'transferOwnership', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'newOwner', + }, + ], + outputs: [], + }, + { + type: 'event', + name: 'MarketSet', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'collateral', + indexed: true, + }, + { + type: 'address', + name: 'debt', + indexed: true, + }, + { + type: 'bool', + name: 'allowed', + indexed: false, + }, + ], + }, + { + type: 'error', + name: 'AccountMismatch', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'address', + name: 'caller', + }, + ], + }, + { + type: 'error', + name: 'MarketNotSupported', + inputs: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'error', + name: 'NotOwner', + inputs: [ + { + type: 'address', + name: 'caller', + }, + ], + }, + { + type: 'error', + name: 'NotPendingOwner', + inputs: [ + { + type: 'address', + name: 'caller', + }, + ], + }, + { + type: 'error', + name: 'ZeroAddress', + inputs: [], + }, + { + type: 'error', + name: 'ZeroOwner', + inputs: [], + }, +] as const satisfies Abi + +/** src/AaveV4LendingAdapter.sol:AaveV4LendingAdapter */ +export const AAVE_V4_LENDING_ADAPTER_ABI = [ + { + type: 'constructor', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'spoke_', + }, + { + type: 'address', + name: 'owner_', + }, + ], + }, + { + type: 'function', + name: 'acceptOwnership', + stateMutability: 'nonpayable', + inputs: [], + outputs: [], + }, + { + type: 'function', + name: 'currentLtvWad', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: '', + }, + ], + }, + { + type: 'function', + name: 'describePosition', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'tuple', + name: 'data', + components: [ + { + type: 'uint256', + name: 'collateralAmount', + }, + { + type: 'uint256', + name: 'debtAmount', + }, + { + type: 'uint256', + name: 'maxLtv', + }, + { + type: 'uint256', + name: 'currentLtv', + }, + { + type: 'uint256', + name: 'healthFactorWad', + }, + ], + }, + ], + }, + { + type: 'function', + name: 'encodeBorrow', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + { + type: 'uint256', + name: '', + }, + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'function', + name: 'encodeRepay', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + { + type: 'uint256', + name: '', + }, + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'function', + name: 'encodeSupplyCollateral', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + { + type: 'uint256', + name: '', + }, + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'function', + name: 'encodeWithdrawCollateral', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + { + type: 'address', + name: '', + }, + ], + outputs: [ + { + type: 'address', + name: '', + }, + { + type: 'uint256', + name: '', + }, + { + type: 'bytes', + name: '', + }, + ], + }, + { + type: 'function', + name: 'isSupportedMarket', + stateMutability: 'view', + inputs: [ + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'bool', + name: '', + }, + ], + }, + { + type: 'function', + name: 'lendingProtocol', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'maxLtvWad', + stateMutability: 'view', + inputs: [ + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: '', + }, + ], + }, + { + type: 'function', + name: 'owner', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'pendingOwner', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'positionOf', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: 'collateralAmount', + }, + { + type: 'uint256', + name: 'debtAmount', + }, + ], + }, + { + type: 'function', + name: 'setMarket', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + { + type: 'uint256', + name: 'collateralReserveId', + }, + { + type: 'uint256', + name: 'debtReserveId', + }, + { + type: 'bool', + name: 'allowed', + }, + ], + outputs: [], + }, + { + type: 'function', + name: 'spoke', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'transferOwnership', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'newOwner', + }, + ], + outputs: [], + }, + { + type: 'event', + name: 'MarketSet', + anonymous: false, + inputs: [ + { + type: 'address', + name: 'collateral', + indexed: true, + }, + { + type: 'address', + name: 'debt', + indexed: true, + }, + { + type: 'uint256', + name: 'collateralReserveId', + indexed: false, + }, + { + type: 'uint256', + name: 'debtReserveId', + indexed: false, + }, + { + type: 'bool', + name: 'allowed', + indexed: false, + }, + ], + }, + { + type: 'error', + name: 'AccountMismatch', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'address', + name: 'caller', + }, + ], + }, + { + type: 'error', + name: 'HubMismatch', + inputs: [ + { + type: 'address', + name: 'collateralHub', + }, + { + type: 'address', + name: 'debtHub', + }, + ], + }, + { + type: 'error', + name: 'MarketNotSupported', + inputs: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'error', + name: 'MathOverflowedMulDiv', + inputs: [], + }, + { + type: 'error', + name: 'NotOwner', + inputs: [ + { + type: 'address', + name: 'caller', + }, + ], + }, + { + type: 'error', + name: 'NotPendingOwner', + inputs: [ + { + type: 'address', + name: 'caller', + }, + ], + }, + { + type: 'error', + name: 'ReserveMismatch', + inputs: [ + { + type: 'uint256', + name: 'reserveId', + }, + { + type: 'address', + name: 'actualUnderlying', + }, + { + type: 'address', + name: 'expectedUnderlying', + }, + ], + }, + { + type: 'error', + name: 'ZeroAddress', + inputs: [], + }, + { + type: 'error', + name: 'ZeroOwner', + inputs: [], + }, +] as const satisfies Abi + +/** src/interfaces/ILendingAdapter.sol:ILendingAdapter */ +export const ILENDING_ADAPTER_ABI = [ + { + type: 'function', + name: 'currentLtvWad', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: '', + }, + ], + }, + { + type: 'function', + name: 'describePosition', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'tuple', + name: 'data', + components: [ + { + type: 'uint256', + name: 'collateralAmount', + }, + { + type: 'uint256', + name: 'debtAmount', + }, + { + type: 'uint256', + name: 'maxLtv', + }, + { + type: 'uint256', + name: 'currentLtv', + }, + { + type: 'uint256', + name: 'healthFactorWad', + }, + ], + }, + ], + }, + { + type: 'function', + name: 'encodeBorrow', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: 'target', + }, + { + type: 'uint256', + name: 'value', + }, + { + type: 'bytes', + name: 'callData', + }, + ], + }, + { + type: 'function', + name: 'encodeRepay', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: 'target', + }, + { + type: 'uint256', + name: 'value', + }, + { + type: 'bytes', + name: 'callData', + }, + ], + }, + { + type: 'function', + name: 'encodeSupplyCollateral', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: 'target', + }, + { + type: 'uint256', + name: 'value', + }, + { + type: 'bytes', + name: 'callData', + }, + ], + }, + { + type: 'function', + name: 'encodeWithdrawCollateral', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + { + type: 'address', + name: 'receiver', + }, + ], + outputs: [ + { + type: 'address', + name: 'target', + }, + { + type: 'uint256', + name: 'value', + }, + { + type: 'bytes', + name: 'callData', + }, + ], + }, + { + type: 'function', + name: 'isSupportedMarket', + stateMutability: 'view', + inputs: [ + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'bool', + name: '', + }, + ], + }, + { + type: 'function', + name: 'lendingProtocol', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'maxLtvWad', + stateMutability: 'view', + inputs: [ + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: '', + }, + ], + }, + { + type: 'function', + name: 'positionOf', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: 'collateralAmount', + }, + { + type: 'uint256', + name: 'debtAmount', + }, + ], + }, +] as const satisfies Abi + +/** (assembled, see header) */ +export const LENDING_ADAPTER_ABI = [ + { + type: 'function', + name: 'currentLtvWad', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: '', + }, + ], + }, + { + type: 'function', + name: 'describePosition', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'tuple', + name: 'data', + components: [ + { + type: 'uint256', + name: 'collateralAmount', + }, + { + type: 'uint256', + name: 'debtAmount', + }, + { + type: 'uint256', + name: 'maxLtv', + }, + { + type: 'uint256', + name: 'currentLtv', + }, + { + type: 'uint256', + name: 'healthFactorWad', + }, + ], + }, + ], + }, + { + type: 'function', + name: 'encodeBorrow', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: 'target', + }, + { + type: 'uint256', + name: 'value', + }, + { + type: 'bytes', + name: 'callData', + }, + ], + }, + { + type: 'function', + name: 'encodeRepay', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: 'target', + }, + { + type: 'uint256', + name: 'value', + }, + { + type: 'bytes', + name: 'callData', + }, + ], + }, + { + type: 'function', + name: 'encodeSupplyCollateral', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + ], + outputs: [ + { + type: 'address', + name: 'target', + }, + { + type: 'uint256', + name: 'value', + }, + { + type: 'bytes', + name: 'callData', + }, + ], + }, + { + type: 'function', + name: 'encodeWithdrawCollateral', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'uint256', + name: 'amount', + }, + { + type: 'address', + name: 'receiver', + }, + ], + outputs: [ + { + type: 'address', + name: 'target', + }, + { + type: 'uint256', + name: 'value', + }, + { + type: 'bytes', + name: 'callData', + }, + ], + }, + { + type: 'function', + name: 'isSupportedMarket', + stateMutability: 'view', + inputs: [ + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'bool', + name: '', + }, + ], + }, + { + type: 'function', + name: 'lendingProtocol', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'maxLtvWad', + stateMutability: 'view', + inputs: [ + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: '', + }, + ], + }, + { + type: 'function', + name: 'positionOf', + stateMutability: 'view', + inputs: [ + { + type: 'address', + name: 'account', + }, + { + type: 'tuple', + name: 'market', + components: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + ], + outputs: [ + { + type: 'uint256', + name: 'collateralAmount', + }, + { + type: 'uint256', + name: 'debtAmount', + }, + ], + }, + { + type: 'function', + name: 'acceptOwnership', + stateMutability: 'nonpayable', + inputs: [], + outputs: [], + }, + { + type: 'function', + name: 'owner', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'pendingOwner', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'address', + name: '', + }, + ], + }, + { + type: 'function', + name: 'transferOwnership', + stateMutability: 'nonpayable', + inputs: [ + { + type: 'address', + name: 'newOwner', + }, + ], + outputs: [], + }, + { + type: 'error', + name: 'MarketNotSupported', + inputs: [ + { + type: 'address', + name: 'collateral', + }, + { + type: 'address', + name: 'debt', + }, + ], + }, + { + type: 'error', + name: 'NotOwner', + inputs: [ + { + type: 'address', + name: 'caller', + }, + ], + }, + { + type: 'error', + name: 'NotPendingOwner', + inputs: [ + { + type: 'address', + name: 'caller', + }, + ], + }, + { + type: 'error', + name: 'ZeroOwner', + inputs: [], + }, +] as const satisfies Abi From 7d5d56d84d9b3861986a95bda209b403a62dda5a Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 24 Jul 2026 11:50:39 -0400 Subject: [PATCH 15/17] feat(margin-sdk): exercise the browser target in the package check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bun.lock | 79 ++++++++++++++++++++++- sdks/margin-sdk/package.json | 1 + sdks/margin-sdk/scripts/check-package.mjs | 58 ++++++++++++++++- 3 files changed, 134 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index bf0173188..aa08525e0 100644 --- a/bun.lock +++ b/bun.lock @@ -75,6 +75,7 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.22.0", + "jsdom": "^26.0.0", "prettier": "^2.4.1", "typescript": "npm:typescript@^5.6.2", "viem": "^2.23.5", @@ -395,6 +396,8 @@ "packages": { "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -659,6 +662,16 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -1255,12 +1268,16 @@ "crypto-browserify": ["crypto-browserify@3.12.1", "", { "dependencies": { "browserify-cipher": "^1.0.1", "browserify-sign": "^4.2.3", "create-ecdh": "^4.0.4", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "diffie-hellman": "^5.0.3", "hash-base": "~3.0.4", "inherits": "^2.0.4", "pbkdf2": "^3.1.2", "public-encrypt": "^4.0.3", "randombytes": "^2.1.0", "randomfill": "^1.0.4" } }, "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ=="], + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], "danger": ["danger@11.2.6", "", { "dependencies": { "@gitbeaker/node": "^21.3.0", "@octokit/rest": "^18.12.0", "async-retry": "1.2.3", "chalk": "^2.3.0", "commander": "^2.18.0", "core-js": "^3.8.2", "debug": "^4.1.1", "fast-json-patch": "^3.0.0-1", "get-stdin": "^6.0.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.1", "hyperlinker": "^1.0.0", "json5": "^2.1.0", "jsonpointer": "^5.0.0", "jsonwebtoken": "^9.0.0", "lodash.find": "^4.6.0", "lodash.includes": "^4.3.0", "lodash.isobject": "^3.0.2", "lodash.keys": "^4.0.8", "lodash.mapvalues": "^4.6.0", "lodash.memoize": "^4.1.2", "memfs-or-file-map-to-github-branch": "^1.2.1", "micromatch": "^4.0.4", "node-cleanup": "^2.1.2", "node-fetch": "^2.6.7", "override-require": "^1.1.1", "p-limit": "^2.1.0", "parse-diff": "^0.7.0", "parse-git-config": "^2.0.3", "parse-github-url": "^1.0.2", "parse-link-header": "^2.0.0", "pinpoint": "^1.1.0", "prettyjson": "^1.2.1", "readline-sync": "^1.4.9", "regenerator-runtime": "^0.13.9", "require-from-string": "^2.0.2", "supports-hyperlinks": "^1.0.1" }, "bin": { "danger": "distribution/commands/danger.js", "danger-ci": "distribution/commands/danger-ci.js", "danger-js": "distribution/commands/danger.js", "danger-pr": "distribution/commands/danger-pr.js", "danger-init": "distribution/commands/danger-init.js", "danger-local": "distribution/commands/danger-local.js", "danger-runner": "distribution/commands/danger-runner.js", "danger-process": "distribution/commands/danger-process.js", "danger-reset-status": "distribution/commands/danger-reset-status.js" } }, "sha512-EEeuDmUcxPGJ166q7Zzz1WEiV+e0qbPopaX4sXxds8U5doGMdw/8oOUOVye7JiHIBuss3KvQWt4YHZeD3jSCfw=="], "dash-ast": ["dash-ast@1.0.0", "", {}, "sha512-Vy4dx7gquTeMcQR/hDkYLGUnwVil6vk4FOOct+djUnHOUWt+zJPJAaRIXaAFkPXtJjvlY7o3rfRu0/3hpnwoUA=="], + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], @@ -1271,6 +1288,8 @@ "decamelize": ["decamelize@4.0.0", "", {}, "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="], @@ -1341,6 +1360,8 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], @@ -1567,6 +1588,8 @@ "hosted-git-info": ["hosted-git-info@2.8.9", "", {}, "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw=="], + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + "htmlescape": ["htmlescape@1.1.1", "", {}, "sha512-eVcrzgbR4tim7c7soKQKtxa/kQM4TzjnlU83rcZ9bHU6t31ehfV7SktN6McWgwPWg+JYMA/O3qpGxBvFq1z2Jg=="], "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], @@ -1663,6 +1686,8 @@ "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-primitive": ["is-primitive@3.0.1", "", {}, "sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], @@ -1709,6 +1734,8 @@ "jsbi": ["jsbi@3.2.5", "", {}, "sha512-aBE4n43IPvjaddScbvWRA2YlTzKEynHzu7MqOyTipdHucf/VxS63ViCjxYRg86M8Rxwbt/GfzHl1kKERkt45fQ=="], + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -1893,6 +1920,8 @@ "npm-run-all": ["npm-run-all@4.1.5", "", { "dependencies": { "ansi-styles": "^3.2.1", "chalk": "^2.4.1", "cross-spawn": "^6.0.5", "memorystream": "^0.3.1", "minimatch": "^3.0.4", "pidtree": "^0.3.0", "read-pkg": "^3.0.0", "shell-quote": "^1.6.1", "string.prototype.padend": "^3.0.0" }, "bin": { "run-p": "bin/run-p/index.js", "run-s": "bin/run-s/index.js", "npm-run-all": "bin/npm-run-all/index.js" } }, "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ=="], + "nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -1963,6 +1992,8 @@ "parse-passwd": ["parse-passwd@1.0.0", "", {}, "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "path-browserify": ["path-browserify@0.0.1", "", {}, "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -2089,6 +2120,8 @@ "rlp": ["rlp@2.2.7", "", { "dependencies": { "bn.js": "^5.2.0" }, "bin": { "rlp": "bin/rlp" } }, "sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ=="], + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], @@ -2101,6 +2134,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scrypt-js": ["scrypt-js@3.0.1", "", {}, "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA=="], "secp256k1": ["secp256k1@4.0.4", "", { "dependencies": { "elliptic": "^6.5.7", "node-addon-api": "^5.0.0", "node-gyp-build": "^4.2.0" } }, "sha512-6JfvwvjUOn8F/jUoBY2Q1v5WY5XS+rj8qSe0v8Y4ezH4InLgTEeOOPQsRll9OV429Pvo6BCHGavIyJfr3TAhsw=="], @@ -2223,6 +2258,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "syncpack": ["syncpack@8.5.14", "", { "dependencies": { "chalk": "4.1.2", "commander": "10.0.0", "cosmiconfig": "8.0.0", "expect-more": "1.3.0", "fp-ts": "2.13.1", "fs-extra": "11.1.0", "glob": "8.1.0", "minimatch": "6.1.6", "read-yaml-file": "2.1.0", "semver": "7.3.8" }, "bin": { "syncpack": "dist/bin.js", "syncpack-list": "dist/bin-list/index.js", "syncpack-format": "dist/bin-format/index.js", "syncpack-fix-mismatches": "dist/bin-fix-mismatches/index.js", "syncpack-list-mismatches": "dist/bin-list-mismatches/index.js", "syncpack-set-semver-ranges": "dist/bin-set-semver-ranges/index.js", "syncpack-lint-semver-ranges": "dist/bin-lint-semver-ranges/index.js" } }, "sha512-+ESXgFXgLEievTVui2TQ/ejdPSX1hb+EXZYSrZfNOoFT2IvaAzGT9OQfiXYjka7ao3fRru9pRtsFoWTy1vyXCQ=="], "syntax-error": ["syntax-error@1.4.0", "", { "dependencies": { "acorn-node": "^1.2.0" } }, "sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w=="], @@ -2247,6 +2284,10 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], @@ -2259,7 +2300,9 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], @@ -2373,9 +2416,15 @@ "w-json": ["w-json@1.3.11", "", {}, "sha512-Xa8vTinB5XBIYZlcN8YyHpE625pBU6k+lvCetTQM+FKxRtLJxAY9zUVZbRqCqkMeEGbQpKvGUzwh4wZKGem+ag=="], - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -2405,6 +2454,10 @@ "xcase": ["xcase@2.0.1", "", {}, "sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -2423,6 +2476,8 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@2.1.0", "", {}, "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw=="], @@ -2701,6 +2756,10 @@ "js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "jsdom/http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "jsdom/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "keccak/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "load-json-file/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], @@ -2729,6 +2788,8 @@ "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "normalize-package-data/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], "npm-run-all/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], @@ -2791,6 +2852,8 @@ "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "tr46/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "ts-command-line-args/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], @@ -2807,6 +2870,8 @@ "util/inherits": ["inherits@2.0.3", "", {}, "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "wordwrapjs/typical": ["typical@5.2.0", "", {}, "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg=="], "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -2923,6 +2988,10 @@ "hardhat-watcher/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "jsdom/http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "jsdom/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "log-symbols/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -2935,6 +3004,10 @@ "mocha/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "npm-run-all/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "npm-run-all/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], diff --git a/sdks/margin-sdk/package.json b/sdks/margin-sdk/package.json index 1fa9bd633..9f080285c 100644 --- a/sdks/margin-sdk/package.json +++ b/sdks/margin-sdk/package.json @@ -59,6 +59,7 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.22.0", + "jsdom": "^26.0.0", "prettier": "^2.4.1", "typescript": "npm:typescript@^5.6.2", "viem": "^2.23.5" diff --git a/sdks/margin-sdk/scripts/check-package.mjs b/sdks/margin-sdk/scripts/check-package.mjs index 58c9825b8..f037464dc 100644 --- a/sdks/margin-sdk/scripts/check-package.mjs +++ b/sdks/margin-sdk/scripts/check-package.mjs @@ -69,7 +69,63 @@ try { for (const consumer of ['check.cjs', 'check.mjs']) { execFileSync('node', [consumer], { cwd: tmp, stdio: 'inherit' }) } - console.log(`package check passed: ${pkg.name} loads under native Node (CJS + ESM) with declared deps only`) + + // 4a. Browser target, static: the shipped ESM module graph must reference no Node builtins. + // This is the actual browser failure mode for an isomorphic library β€” a stray + // `node:crypto`/`fs` import that Node smoke tests would never catch. + const { builtinModules } = await import('node:module') + const builtins = new Set(builtinModules) + const esmDir = path.join(installDir, 'dist', 'esm') + const offenders = [] + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walk(full) + else if (entry.name.endsWith('.js')) { + const source = fs.readFileSync(full, 'utf8') + for (const match of source.matchAll(/(?:from\s*|import\s*\(\s*|require\s*\(\s*)['"]([^'"]+)['"]/g)) { + const spec = match[1] + if (spec.startsWith('node:') || builtins.has(spec)) { + offenders.push(`${path.relative(installDir, full)} imports ${spec}`) + } + } + } + } + } + walk(esmDir) + if (offenders.length > 0) { + throw new Error(`shipped ESM references Node builtins (breaks the browser target):\n ${offenders.join('\n ')}`) + } + + // 4b. Browser target, dynamic: load the installed ESM entry with jsdom's window/document as the + // global environment and run the functional vector. jsdom is a devDependency resolved from + // the package root; the SDK still resolves its own deps from the isolated install. + const esmEntry = path.join(installDir, 'dist', 'esm', 'src', 'index.js') + fs.writeFileSync( + path.join(tmp, 'check-browser.mjs'), + `import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' +const require = createRequire(${JSON.stringify(path.join(pkgRoot, 'package.json'))}) +const { JSDOM } = require('jsdom') +const dom = new JSDOM('', { url: 'https://margin-sdk.test' }) +for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document, self: dom.window })) { + try { + Object.defineProperty(globalThis, key, { value, configurable: true }) + } catch { + /* some globals are non-configurable on newer Node; the import below is the real check */ + } +} +const sdk = await import(pathToFileURL(${JSON.stringify(esmEntry)}).href) +${functionalCheck} +console.log('BROWSER (jsdom) OK') +` + ) + execFileSync('node', ['check-browser.mjs'], { cwd: tmp, stdio: 'inherit' }) + + console.log( + `package check passed: ${pkg.name} loads under native Node (CJS + ESM) with declared deps only, ` + + 'ships no Node builtins, and loads under jsdom' + ) } finally { fs.rmSync(tmp, { recursive: true, force: true }) } From e3da418b780ccf4f6f20411e7d213326f1e8a35d Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 24 Jul 2026 11:50:39 -0400 Subject: [PATCH 16/17] ci(margin-sdk): verify the ABI bindings against the pinned v4-periphery build on every PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/margin-sdk-abi-check.yml | 86 ++++++++++++++++++++++ sdks/margin-sdk/README.md | 20 +++-- 2 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/margin-sdk-abi-check.yml diff --git a/.github/workflows/margin-sdk-abi-check.yml b/.github/workflows/margin-sdk-abi-check.yml new file mode 100644 index 000000000..58734c7f8 --- /dev/null +++ b/.github/workflows/margin-sdk-abi-check.yml @@ -0,0 +1,86 @@ +name: "Margin SDK ABI Consistency" + +# CONSISTENCY GATE β€” proves the committed forge-generated ABI bindings in sdks/margin-sdk +# (src/generated/abis.ts) actually match a fresh build of the v4-periphery margin contracts at +# the commit they claim to be pinned to. viem encodes tuples positionally, so a contract field +# reorder with stale bindings would produce silently-wrong calldata; if someone edits the pin or +# the bindings and forgets to regenerate (or hand-edits the generated file), this job fails the PR. +# +# It compiles the pinned commit with forge and diffs the regenerated bindings against the +# committed file via `bun run check:abis` (the generator's --check mode). Nothing is written; the +# job only reports pass/fail. Mirrors the liquidity-launcher lock-bytecode gate. +# +# Runs only when the package changes (path filter) β€” this is not needed on unrelated PRs. + +on: + pull_request: + paths: + - "sdks/margin-sdk/**" + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + check-margin-abis: + name: Verify ABI bindings match the pinned v4-periphery commit + runs-on: ubuntu-latest + steps: + - uses: bullfrogsec/bullfrog@dcde5841b19b7ef693224207a7fdec67fce604db # v0.8.3 + with: + egress-policy: audit + + - name: βœ… Checkout sdks + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 + with: + persist-credentials: false + + - name: πŸ’½ Setup Node + uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 + with: + node-version: 24 + + - name: Setup Bun + uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2 + with: + bun-version: 1.3.10 + + - name: πŸ“₯ Install dependencies + run: bun install --frozen-lockfile + env: + HUSKY: "0" + + - name: πŸͺ¨ Install Foundry + uses: foundry-rs/foundry-toolchain@82dee4ba654bd2146511f85f0d013af94670c4de # v1 + with: + version: nightly + + - name: πŸ“Œ Read the pinned v4-periphery commit from the bindings header + id: pin + run: | + # Single source of truth: the "Pinned to v4-periphery commit " line in the + # committed bindings. Keeps the pin human-readable and reviewable in diffs. + sha=$(grep -oE 'Pinned to v4-periphery commit [0-9a-f]{40}' \ + sdks/margin-sdk/src/generated/abis.ts | grep -oE '[0-9a-f]{40}') + if [ -z "$sha" ]; then echo "could not read pinned commit"; exit 1; fi + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "Pinned v4-periphery commit: $sha" + + - name: ⬇️ Clone v4-periphery at the pinned commit (submodules over https) + run: | + # Rewrite any SSH submodule URLs to https BEFORE submodule init so the runner (which + # has no SSH key) can fetch the public repos. The generator script runs + # `git submodule update --init --recursive` itself. + git config --global url."https://github.com/".insteadOf "git@github.com:" + git clone https://github.com/Uniswap/v4-periphery.git "$RUNNER_TEMP/v4-periphery" + git -C "$RUNNER_TEMP/v4-periphery" fetch origin "$PINNED_SHA" || true + git -C "$RUNNER_TEMP/v4-periphery" checkout "$PINNED_SHA" + env: + PINNED_SHA: ${{ steps.pin.outputs.sha }} + + - name: πŸ”Ž Check committed bindings against the pinned commit's build + working-directory: sdks/margin-sdk + env: + V4_PERIPHERY_PATH: ${{ runner.temp }}/v4-periphery + V4_PERIPHERY_COMMIT: ${{ steps.pin.outputs.sha }} + run: bun run check:abis diff --git a/sdks/margin-sdk/README.md b/sdks/margin-sdk/README.md index e9c9c9923..a25f1ebc4 100644 --- a/sdks/margin-sdk/README.md +++ b/sdks/margin-sdk/README.md @@ -260,14 +260,18 @@ Beyond the unit suite (`bun test`), three gates validate what unit tests structu - **`bun run check:package`** β€” packs the publish artifact, installs it into an isolated consumer with only its declared dependencies resolvable, and loads it under **native Node in both module - systems** (CJS `require` + ESM `import`), running a real account-derivation vector. Catches - undeclared runtime deps, extensionless ESM emit, and missing module-type markers. Runs as part - of `test`. -- **`bun run check:abi-drift`** β€” compiles the contracts from a local v4-periphery checkout - (`V4_PERIPHERY_PATH`, default `~/dev/v4-periphery`) and asserts every hand-written SDK ABI entry - matches the Solidity: canonical signatures, outputs, state mutability, and event index layouts, - for the router, the account, and all three adapters. viem encodes tuples positionally, so this - is the gate that catches a silent field reorder in the draft contracts. + systems** (CJS `require` + ESM `import`) plus the **browser target** (a static scan proving the + shipped ESM references no Node builtins, and a jsdom-globals load), running a real + account-derivation vector in each. Catches undeclared runtime deps, extensionless ESM emit, + missing module-type markers, and accidental Node-only imports. Runs as part of `test`. +- **`bun run check:abis`** β€” the margin-contract ABIs are **forge-generated, never hand-written**: + `src/generated/abis.ts` is produced by `bun run regenerate:abis` from a v4-periphery checkout + **pinned to a specific commit** (recorded in the file header). The check mode recompiles the + pinned commit and diffs the regenerated bindings against the committed file; the + `margin-sdk-abi-check` CI workflow runs it on every PR touching the package, cloning + v4-periphery at the pin. viem encodes tuples positionally, so this closes the + silent-wrong-calldata risk of a contract field reorder. When the contracts move, re-pin with + `bun scripts/generate-abis.ts --update-pin` against the new checkout. - **`bun run test:fork`** β€” the end-to-end demo suite against an anvil mainnet fork (see below); runs inside `test` when `FORK_URL` (or `MARGIN_DEMO_RPC`) is set and skips cleanly otherwise, so CI with the `FORK_URL` secret exercises the SDK against the live deployment on every run. From efc867056efba347b5b753f6f5b1301cfc2cefbe Mon Sep 17 00:00:00 2001 From: Ayoakala Date: Mon, 3 Aug 2026 15:51:57 -0400 Subject: [PATCH 17/17] feat(margin-sdk): add collateral withdrawal and the IMarginAccount escape-hatch encoders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/margin-sdk-initial-release.md | 2 +- sdks/margin-sdk/README.md | 64 +++++ .../margin-sdk/demo/06-withdraw-collateral.ts | 213 +++++++++++++++ sdks/margin-sdk/demo/README.md | 1 + sdks/margin-sdk/demo/run-all.ts | 2 + sdks/margin-sdk/src/account.ts | 42 ++- sdks/margin-sdk/src/encode.test.ts | 127 ++++++++- sdks/margin-sdk/src/encode.ts | 257 +++++++++++++++++- sdks/margin-sdk/src/planner.test.ts | 80 +++++- sdks/margin-sdk/src/planner.ts | 104 ++++++- 10 files changed, 873 insertions(+), 19 deletions(-) create mode 100644 sdks/margin-sdk/demo/06-withdraw-collateral.ts diff --git a/.changeset/margin-sdk-initial-release.md b/.changeset/margin-sdk-initial-release.md index 907358b52..6f2f588c9 100644 --- a/.changeset/margin-sdk-initial-release.md +++ b/.changeset/margin-sdk-initial-release.md @@ -2,4 +2,4 @@ '@uniswap/margin-sdk': patch --- -Initial pre-release of the margin trading SDK for the Uniswap v4 margin periphery (MarginRouter + Morpho Blue / Aave v3 / Aave v4 lending adapters): entry-point encoders and write descriptors (increase/decrease/close/addCollateral/execute/multicall/permit), offchain MarginAccount address derivation (Solady CWIA, verified against the live mainnet router), decimal-aware leverage/LTV/health sizing math, a validated `execute`-plan builder over the v4 routing + margin action set, venue-agnostic read descriptors, and the mainnet deployment address registry. Published as 0.0.x deliberately: the contracts (Uniswap/v4-periphery#563) are still in review and governance has not yet been handed to a timelock/multisig β€” the package graduates to 0.1.0 once the deployment is final. +Initial pre-release of the margin trading SDK for the Uniswap v4 margin periphery (MarginRouter + Morpho Blue / Aave v3 / Aave v4 lending adapters): entry-point encoders and write descriptors (increase/decrease/close/addCollateral/execute/multicall/permit), collateral withdrawal via the `withdrawCollateralPlan` curated `execute` plan plus account-direct encoders for the `IMarginAccount` owner-escape-hatch primitives (withdrawCollateral/supplyCollateral/borrow/repay/sweep), offchain MarginAccount address derivation (Solady CWIA, verified against the live mainnet router), decimal-aware leverage/LTV/health sizing math, a validated `execute`-plan builder over the v4 routing + margin action set, venue-agnostic read descriptors, and the mainnet deployment address registry. Published as 0.0.x deliberately: the contracts (Uniswap/v4-periphery#563) are still in review and governance has not yet been handed to a timelock/multisig β€” the package graduates to 0.1.0 once the deployment is final. diff --git a/sdks/margin-sdk/README.md b/sdks/margin-sdk/README.md index a25f1ebc4..75c781740 100644 --- a/sdks/margin-sdk/README.md +++ b/sdks/margin-sdk/README.md @@ -177,6 +177,70 @@ const delever = decreasePositionCall({ Closing and delevering **never** require the adapter to be allowlisted, so a position is always exitable β€” even if its adapter is later removed from governance's allowlist. +## Withdraw collateral (without touching debt) + +`decreasePosition` withdraws collateral as part of repaying debt. To pull collateral out while +leaving debt untouched β€” de-risking, or taking excess equity off the table β€” there is no curated +entry point (the router's only write entry points are `increasePosition`, `decreasePosition`, +`addCollateral`, and `execute`), so this composes the `IMarginAccount.withdrawCollateral` primitive +into a minimal `execute` plan: + +```ts +import { executeCall, getPosition, withdrawCollateralPlan } from '@uniswap/margin-sdk' + +const position = await getPosition(publicClient, { adapter, account, market }) + +const unlockData = withdrawCollateralPlan({ + adapter, + market, + amount: position.collateralAmount / 4n, // explicit β€” read live, never a sentinel + to: owner, // must be the account's owner or the router + maxLtvAfter: (position.maxLtv * 80n) / 100n, // mandatory: keep 20% headroom +}) + +const call = executeCall({ marginRouter: addresses.marginRouter, unlockData, deadline }) +``` + +Three things the helper enforces that a hand-rolled plan does not: + +- **The recipient must be a literal address** β€” the account's owner or the MarginRouter. Unlike the + router-level `TAKE`/`SWEEP` opcodes, the `ACCOUNT_*` actions are **not** run through + `_mapRecipient`, so the `MSG_SENDER`/`ADDRESS_THIS` sentinels arrive at the account as the literal + addresses `0x…01`/`0x…02` and revert `ReceiverNotAllowed`. +- **The amount must be explicit.** `OPEN_DELTA` is not a full-balance sentinel on this action: it + resolves to the router's open delta owed to the pool, which is the correct amount inside a + swap-bearing delever but **zero** in a swap-free withdrawal β€” silently withdrawing nothing. +- **`maxLtvAfter` is mandatory.** Withdrawing raises LTV and `ASSERT_HEALTH` skips a zero bound, so + an unbounded withdrawal can walk a position to the liquidation edge in one transaction. + +To exit a WETH-collateral position as native ETH, withdraw `to: addresses.marginRouter` and continue +the plan with `unwrap` + `sweep` rather than using the helper. + +Withdrawals are not allowlist-gated either β€” like closing, a position must always be exitable. + +**Owner escape hatch.** The account's primitives are callable by `{manager, owner}`, so the owner can +withdraw directly without the router if it is ever deprecated, paused, or compromised: + +```ts +import { accountWithdrawCollateralCall, getMarginAccountAddress } from '@uniswap/margin-sdk' + +const call = accountWithdrawCollateralCall({ + account: getMarginAccountAddress(mainnet.id, owner, 0n), + params: { adapter, market, amount, to: owner }, +}) +``` + +This path carries **no** health assertion β€” the lending venue's own borrow-limit check is the only +backstop, so an unsafe withdrawal reverts inside the venue rather than with `PositionUnhealthy`. +Prefer the router path for normal operation. + +The sibling primitives are encoded the same way, for recovering a position when the router is +unavailable: `accountSupplyCollateralCall`, `accountRepayCall` (pass `FULL_CLOSE` for a share-based +full repay that leaves no interest dust), `accountBorrowCall`, and `accountSweepCall` (pass the zero +address as the currency to sweep native ETH). All of them supply from or deliver to the account +itself, so they never pull from the owner's wallet, and `borrow` bypasses both the adapter allowlist +and any health assertion β€” use `increasePositionCall` instead unless the router is unavailable. + ## Going short & venue selection A short is the same call with the market pairing reversed and the venue chosen per call by diff --git a/sdks/margin-sdk/demo/06-withdraw-collateral.ts b/sdks/margin-sdk/demo/06-withdraw-collateral.ts new file mode 100644 index 000000000..83e8be6b1 --- /dev/null +++ b/sdks/margin-sdk/demo/06-withdraw-collateral.ts @@ -0,0 +1,213 @@ +/** + * Demo 06 β€” Collateral withdrawal without touching debt. + * The router has no curated withdraw entry point, so this exercises the three withdrawal paths the + * SDK exposes over `IMarginAccount.withdrawCollateral`: + * 1. `withdrawCollateralPlan` β€” the curated `execute` plan (the normal path). + * 2. A manual `MarginPlanner` plan that exits a WETH-collateral position as native ETH + * (withdraw to the router β†’ `unwrap` β†’ `sweep`). + * 3. `accountWithdrawCollateralCall` β€” the owner escape hatch, bypassing the router entirely. + * Also pins the two guards that make the curated helper safe: the mandatory `maxLtvAfter` really + * binds, and the sentinels the account cannot resolve are rejected before a transaction is built. + */ +import { parseUnits, zeroAddress } from 'viem' + +import { type Ctx, withAnvil } from './lib/env' +import { + assert, + balanceOf, + deadline, + deal, + ensurePermit2, + fmt, + fmtWad, + note, + ok, + quoteSwapInput, + section, + send, +} from './lib/helpers' +import { + CONTRACT_BALANCE, + MSG_SENDER, + MarginPlanner, + accountWithdrawCollateralCall, + collateralToBuyForLeverage, + executeCall, + getMarginAccountAddress, + getPosition, + increasePositionCall, + isMarginSdkError, + parseLeverageX18, + withdrawCollateralPlan, +} from '../src' + +const SUB_ID = 7n +const NATIVE_SUB_ID = 8n + +/** Opens a fresh 2x long under `subId` and returns the account address. */ +async function open2xLong(ctx: Ctx, subId: bigint, equity: bigint): Promise<`0x${string}`> { + const { addresses, deployer, longMarket: market, poolKey } = ctx + const adapter = addresses.lendingAdapters.morphoBlue! + const collateralToBuy = collateralToBuyForLeverage(equity, parseLeverageX18(2)) + const { capped: maxDebtIn } = await quoteSwapInput(ctx, market, market.debt, collateralToBuy, 50) + await send( + ctx, + increasePositionCall({ + marginRouter: addresses.marginRouter, + params: { + adapter, + market, + poolKey, + equity, + collateralToBuy, + maxDebtIn, + subId, + deadline: await deadline(ctx), + }, + }) + ) + return getMarginAccountAddress(1, deployer, subId) +} + +export async function run(ctx: Ctx): Promise { + const { addresses, deployer, longMarket: market, weth } = ctx + const adapter = addresses.lendingAdapters.morphoBlue! + const router = addresses.marginRouter + + section('06 Β· Withdraw collateral (mirrors ACCOUNT_WITHDRAW_COLLATERAL + the owner escape hatch)') + + const equity = parseUnits('1', 18) + await deal(ctx, weth, deployer, parseUnits('10', 18)) + await ensurePermit2(ctx, weth) + + // -- 1. Curated plan: withdraw a slice of collateral straight to the owner -------------------- + const account = await open2xLong(ctx, SUB_ID, equity) + const before = await getPosition(ctx.publicClient, { adapter, account, market }) + note(`opened 2x: collateral ${fmt(before.collateralAmount, 18, 'WETH')}, LTV ${fmtWad(before.currentLtv)}`) + + const slice = before.collateralAmount / 10n + const wethBefore = await balanceOf(ctx, weth, deployer) + await send( + ctx, + executeCall({ + marginRouter: router, + unlockData: withdrawCollateralPlan({ + adapter, + market, + amount: slice, + to: deployer, // the account's owner β€” a literal address, never MSG_SENDER + maxLtvAfter: (before.maxLtv * 90n) / 100n, // 10% headroom under the liquidation LTV + subId: SUB_ID, + }), + deadline: await deadline(ctx), + }) + ) + + const after = await getPosition(ctx.publicClient, { adapter, account, market }) + const received = (await balanceOf(ctx, weth, deployer)) - wethBefore + assert(received === slice, `owner received exactly the withdrawn collateral (${fmt(received, 18, 'WETH')})`) + assert(after.collateralAmount === before.collateralAmount - slice, 'position collateral fell by exactly that amount') + assert(after.debtAmount >= before.debtAmount, 'debt untouched by the withdrawal (interest may accrue)') + assert(after.currentLtv > before.currentLtv, `LTV rose as expected (${fmtWad(after.currentLtv)})`) + ok('curated withdrawCollateralPlan: collateral out, debt untouched, health still bounded') + + // -- 2. The mandatory health bound actually binds --------------------------------------------- + // Withdrawing nearly everything against a bound just above the current LTV must revert + // PositionUnhealthy rather than silently walking the position to the liquidation edge. + let reverted = false + try { + await send( + ctx, + executeCall({ + marginRouter: router, + unlockData: withdrawCollateralPlan({ + adapter, + market, + amount: (after.collateralAmount * 90n) / 100n, + to: deployer, + maxLtvAfter: after.currentLtv + 10n ** 15n, // only 0.1% of headroom + subId: SUB_ID, + }), + deadline: await deadline(ctx), + }) + ) + } catch { + reverted = true + } + assert(reverted, 'an over-large withdrawal reverts against its maxLtvAfter bound (ASSERT_HEALTH)') + + // The SDK refuses to build the unbounded version at all, so it can never reach the chain. + let rejectedZeroBound = false + try { + withdrawCollateralPlan({ adapter, market, amount: slice, to: deployer, maxLtvAfter: 0n, subId: SUB_ID }) + } catch (error) { + rejectedZeroBound = isMarginSdkError(error) + } + assert(rejectedZeroBound, 'a zero maxLtvAfter is rejected offchain (ASSERT_HEALTH would skip it)') + + // ...as are the recipients the account cannot resolve: ACCOUNT_* actions are never mapped + // through _mapRecipient, so MSG_SENDER would arrive as the literal 0x…01 and revert. + let rejectedSentinel = false + try { + withdrawCollateralPlan({ adapter, market, amount: slice, to: MSG_SENDER, maxLtvAfter: before.maxLtv, subId: SUB_ID }) + } catch (error) { + rejectedSentinel = isMarginSdkError(error) + } + assert(rejectedSentinel, 'the MSG_SENDER sentinel is rejected offchain (account requires a literal recipient)') + ok('both guards hold: the bound binds onchain, and the unsafe variants never build') + + // -- 3. Exit to native ETH: withdraw to the router, unwrap, sweep ------------------------------ + const nativeAccount = await open2xLong(ctx, NATIVE_SUB_ID, equity) + const nativePosition = await getPosition(ctx.publicClient, { adapter, account: nativeAccount, market }) + const nativeSlice = nativePosition.collateralAmount / 10n + const ethBefore = await ctx.publicClient.getBalance({ address: deployer }) + + const nativeExit = new MarginPlanner() + .setAccount(NATIVE_SUB_ID) + .withdrawCollateral(adapter, market, nativeSlice, router) // stage on the router, not the owner + .assertHealth(adapter, market, (nativePosition.maxLtv * 90n) / 100n) + .unwrap(CONTRACT_BALANCE) // WETH β†’ ETH on the router + .sweep(zeroAddress, MSG_SENDER) // router-level sweep DOES resolve the sentinel + .finalize() + + const nativeReceipt = await send( + ctx, + executeCall({ marginRouter: router, unlockData: nativeExit, deadline: await deadline(ctx) }) + ) + const gasSpent = nativeReceipt.gasUsed * nativeReceipt.effectiveGasPrice + const ethReceived = (await ctx.publicClient.getBalance({ address: deployer })) - ethBefore + gasSpent + assert(ethReceived === nativeSlice, `owner received the collateral as native ETH (${fmt(ethReceived, 18, 'ETH')})`) + assert( + (await balanceOf(ctx, weth, router)) === 0n && (await ctx.publicClient.getBalance({ address: router })) === 0n, + 'router netted to zero β€” no WETH or ETH left claimable by the next caller' + ) + ok('native exit: withdraw β†’ unwrap β†’ sweep, with the router holding nothing afterwards') + + // -- 4. Owner escape hatch: withdraw directly from the account, no router involved ------------- + const escapeBefore = await getPosition(ctx.publicClient, { adapter, account, market }) + const escapeSlice = escapeBefore.collateralAmount / 20n + const escapeWethBefore = await balanceOf(ctx, weth, deployer) + await send( + ctx, + accountWithdrawCollateralCall({ + account, + params: { adapter, market, amount: escapeSlice, to: deployer }, + }) + ) + const escapeAfter = await getPosition(ctx.publicClient, { adapter, account, market }) + assert( + (await balanceOf(ctx, weth, deployer)) - escapeWethBefore === escapeSlice, + 'escape hatch delivered the collateral to the owner without the router' + ) + assert( + escapeAfter.collateralAmount === escapeBefore.collateralAmount - escapeSlice, + 'escape-hatch withdrawal reduced the position by exactly that amount' + ) + note(`escape hatch carries no health assertion β€” LTV now ${fmtWad(escapeAfter.currentLtv)} (venue-checked only)`) + + ok('06 complete: curated plan, native exit, and owner escape hatch all withdraw collateral') +} + +if (import.meta.main) { + await withAnvil(run) +} diff --git a/sdks/margin-sdk/demo/README.md b/sdks/margin-sdk/demo/README.md index 470bc3571..c250c4244 100644 --- a/sdks/margin-sdk/demo/README.md +++ b/sdks/margin-sdk/demo/README.md @@ -23,6 +23,7 @@ revert anywhere fails the run. | `03` | `AaveLendingAdapter.fork.t.sol`, `AaveV4LendingAdapter.fork.t.sol`, `MarginRouterShortInverse.t.sol` | Short ETH (collateral USDC / debt WETH, reversed decimals) with identical SDK code on Aave v3 **and** Aave v4 β€” only the adapter address changes; the 78% USDC liquidation threshold reads back on both. | | `04` | `MarginRouterCrossVenueHedge.fork.t.sol` | Delta-neutral long (Morpho) + short (Aave v3) under one owner on isolated sub-accounts; net ETH delta within live-pool swap impact; closing one leg leaves the other untouched. | | `05` | `MarginRouterExecute.t.sol`, `MarginRouterExecute.fork.t.sol` | The `execute` entry point: a raw `MarginPlanner` plan reproducing the curated open action-for-action, a repay-from-wallet plan no curated entry can express, and the owner-only `MarginAccount.execute` escape hatch acting on Morpho directly. | +| `06` | `MarginRouterExecuteNative.t.sol` (`test_execute_exitToNative_withdrawUnwrapSweep`) | Collateral withdrawal with debt untouched β€” the flow with no curated entry point: `withdrawCollateralPlan`, a native exit (withdraw β†’ `unwrap` β†’ `sweep`, router netted to zero), and the account-direct owner escape hatch. Pins that `maxLtvAfter` really binds onchain and that the unsafe variants (zero bound, `MSG_SENDER` recipient) never build. | Anvil-fork notes baked into the harness (`lib/env.ts`, `lib/helpers.ts`): diff --git a/sdks/margin-sdk/demo/run-all.ts b/sdks/margin-sdk/demo/run-all.ts index c46f72494..0c613cfbe 100644 --- a/sdks/margin-sdk/demo/run-all.ts +++ b/sdks/margin-sdk/demo/run-all.ts @@ -11,6 +11,7 @@ import { run as nativeEth } from './02-native-eth' import { run as shortAave } from './03-short-aave' import { run as hedge } from './04-hedge-subaccounts' import { run as executePlans } from './05-execute-plans' +import { run as withdrawCollateral } from './06-withdraw-collateral' import { withAnvil } from './lib/env' await withAnvil(async (ctx) => { @@ -20,5 +21,6 @@ await withAnvil(async (ctx) => { await shortAave(ctx) await hedge(ctx) await executePlans(ctx) + await withdrawCollateral(ctx) console.log('\nall demo flows passed βœ“') }) diff --git a/sdks/margin-sdk/src/account.ts b/sdks/margin-sdk/src/account.ts index 320efaba0..d10efaa5d 100644 --- a/sdks/margin-sdk/src/account.ts +++ b/sdks/margin-sdk/src/account.ts @@ -1,6 +1,17 @@ -import { type Address, type Hex, concatHex, encodeAbiParameters, getAddress, keccak256, numberToHex } from 'viem' +import { + type Address, + type Hex, + concatHex, + encodeAbiParameters, + getAddress, + isAddressEqual, + keccak256, + numberToHex, + zeroAddress, +} from 'viem' import { getMarginAddresses } from './addresses.js' +import { ADDRESS_THIS, MSG_SENDER } from './constants.js' import { MarginSdkError } from './errors.js' /** @@ -11,6 +22,35 @@ import { MarginSdkError } from './errors.js' * the live mainnet router's `accountOf` (see account.test.ts). */ +/** + * Offchain mirror of `MarginAccount._requireReceiver`: every fund-out primitive on the account + * (`withdrawCollateral`, `borrow`, `sweep`) constrains its recipient to the clone's baked-in + * `{owner, manager}` and otherwise reverts `ReceiverNotAllowed(to)`. + * + * Critically, the router does **not** run these recipients through `_mapRecipient` β€” the + * `ACCOUNT_*` handlers pass `to` straight into the account (`MarginRouter._handleAction`), unlike + * the router-level `SWEEP`/`TAKE` opcodes which do resolve it. So the v4 sentinels that work + * everywhere else in a plan (`MSG_SENDER`, `ADDRESS_THIS`) arrive at the account as the literal + * addresses `0x…01` / `0x…02`, match neither owner nor manager, and revert. Rejecting them here + * turns a mid-plan onchain revert into a build-time error. + * + * Pass the resolved owner EOA (funds to the user) or the MarginRouter address (funds staged for a + * later action in the same plan). + */ +export function validateAccountRecipient(to: Address, label: string): void { + if (isAddressEqual(to, zeroAddress)) { + throw new MarginSdkError('INVALID_RECIPIENT', `${label} recipient must not be the zero address`) + } + if (isAddressEqual(to, MSG_SENDER) || isAddressEqual(to, ADDRESS_THIS)) { + throw new MarginSdkError( + 'INVALID_RECIPIENT', + `${label} does not resolve the MSG_SENDER/ADDRESS_THIS sentinels β€” the account requires a literal ` + + `recipient and reverts ReceiverNotAllowed for anything but its owner or the MarginRouter. ` + + `Pass the owner address or the router address explicitly.` + ) + } +} + /** The immutable args baked into an account clone: `abi.encode(owner, manager)`. */ export function marginAccountArgs(owner: Address, manager: Address): Hex { return encodeAbiParameters([{ type: 'address' }, { type: 'address' }], [owner, manager]) diff --git a/sdks/margin-sdk/src/encode.test.ts b/sdks/margin-sdk/src/encode.test.ts index 3313b1786..f01f82b76 100644 --- a/sdks/margin-sdk/src/encode.test.ts +++ b/sdks/margin-sdk/src/encode.test.ts @@ -1,12 +1,22 @@ import { describe, expect, test } from 'bun:test' import { type Address, decodeFunctionData, toFunctionSelector } from 'viem' -import { MARGIN_ROUTER_ABI } from './abis.js' -import { FULL_CLOSE } from './constants.js' +import { MARGIN_ACCOUNT_ABI, MARGIN_ROUTER_ABI } from './abis.js' +import { ADDRESS_THIS, FULL_CLOSE, MSG_SENDER } from './constants.js' import { + accountBorrowCall, + accountRepayCall, + accountSupplyCollateralCall, + accountSweepCall, + accountWithdrawCollateralCall, addCollateralCall, closePositionCall, decreasePositionCall, + encodeAccountBorrow, + encodeAccountRepay, + encodeAccountSupplyCollateral, + encodeAccountSweep, + encodeAccountWithdrawCollateral, encodeAddCollateral, encodeDecreasePosition, encodeExecute, @@ -232,6 +242,119 @@ describe('deadline validation', () => { }) }) +describe('account-direct withdrawCollateral (owner escape hatch)', () => { + const OWNER: Address = '0x1111111111111111111111111111111111111111' + const ACCOUNT: Address = '0x2222222222222222222222222222222222222222' + const params = { adapter: ADAPTER, market: LONG_MARKET, amount: 10n ** 18n, to: OWNER } + + /** `cast calldata "withdrawCollateral(address,(address,address),uint256,address)" ...` */ + const CAST_WITHDRAW_CALLDATA = + '0xe3f81c670000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000001111111111111111111111111111111111111111' + + test('selector matches the pinned account ABI', () => { + const item = MARGIN_ACCOUNT_ABI.find((e) => e.type === 'function' && e.name === 'withdrawCollateral') + expect(item).toBeDefined() + expect(toFunctionSelector(item as never)).toBe('0xe3f81c67') + }) + + test('matches cast-generated calldata byte-for-byte', () => { + expect(encodeAccountWithdrawCollateral(params)).toBe(CAST_WITHDRAW_CALLDATA as `0x${string}`) + }) + + test('descriptor targets the account, not the router', () => { + const call = accountWithdrawCollateralCall({ account: ACCOUNT, params }) + expect(call.address).toBe(ACCOUNT) + expect(call.functionName).toBe('withdrawCollateral') + expect(call.args).toEqual([ADAPTER, LONG_MARKET, 10n ** 18n, OWNER]) + }) + + test('round-trips through decodeFunctionData', () => { + const decoded = decodeFunctionData({ + abi: MARGIN_ACCOUNT_ABI, + data: encodeAccountWithdrawCollateral(params), + }) + expect(decoded.functionName).toBe('withdrawCollateral') + expect(decoded.args?.[3]).toBe(OWNER) + }) + + test('rejects the sentinels the account cannot resolve', () => { + // ReceiverNotAllowed onchain: ACCOUNT_* recipients are never mapped through _mapRecipient. + expect(() => encodeAccountWithdrawCollateral({ ...params, to: MSG_SENDER })).toThrow(/sentinel/) + expect(() => encodeAccountWithdrawCollateral({ ...params, to: ADDRESS_THIS })).toThrow(/sentinel/) + }) + + test('rejects a zero recipient and a non-positive amount', () => { + expect(() => encodeAccountWithdrawCollateral({ ...params, to: ZERO })).toThrow(MarginSdkError) + expect(() => encodeAccountWithdrawCollateral({ ...params, amount: 0n })).toThrow(MarginSdkError) + expect(() => encodeAccountWithdrawCollateral({ ...params, amount: -1n })).toThrow(MarginSdkError) + }) + + test('rejects a native-ETH market (collateral must be an ERC-20)', () => { + expect(() => encodeAccountWithdrawCollateral({ ...params, market: { collateral: ZERO, debt: USDC } })).toThrow( + MarginSdkError + ) + }) +}) + +describe('account-direct sibling primitives (owner escape hatch)', () => { + const OWNER: Address = '0x1111111111111111111111111111111111111111' + const ACCOUNT: Address = '0x2222222222222222222222222222222222222222' + const base = { adapter: ADAPTER, market: LONG_MARKET, amount: 10n ** 18n } + + /** Ground truth from `cast calldata` against the IMarginAccount signatures. */ + const CAST = { + supplyCollateral: + '0x785e28ab0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000de0b6b3a7640000', + repayFull: + '0x004e7e480000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + borrow: + '0x2cefd3210000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b2d05e000000000000000000000000001111111111111111111111111111111111111111', + sweepNative: + '0xdc2c256f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000001111111111111111111111111111111111111111', + } as const + + test('supplyCollateral matches cast ground truth', () => { + expect(encodeAccountSupplyCollateral(base)).toBe(CAST.supplyCollateral as `0x${string}`) + }) + + test('repay accepts FULL_CLOSE for a share-based full repay', () => { + expect(encodeAccountRepay({ ...base, amount: FULL_CLOSE })).toBe(CAST.repayFull as `0x${string}`) + }) + + test('supplyCollateral rejects the max sentinel (no such semantics there)', () => { + expect(() => encodeAccountSupplyCollateral({ ...base, amount: FULL_CLOSE })).toThrow(/no max-amount sentinel/) + }) + + test('borrow matches cast ground truth and validates the recipient', () => { + expect(encodeAccountBorrow({ ...base, amount: 3_000n * 10n ** 6n, to: OWNER })).toBe(CAST.borrow as `0x${string}`) + expect(() => encodeAccountBorrow({ ...base, to: MSG_SENDER })).toThrow(/sentinel/) + expect(() => encodeAccountBorrow({ ...base, to: ZERO })).toThrow(MarginSdkError) + }) + + test('sweep matches cast ground truth and allows native ETH as the currency', () => { + expect(encodeAccountSweep({ currency: ZERO, amount: 10n ** 18n, to: OWNER })).toBe( + CAST.sweepNative as `0x${string}` + ) + }) + + test('sweep still rejects a sentinel recipient and a zero amount', () => { + expect(() => encodeAccountSweep({ currency: WETH, amount: 1n, to: ADDRESS_THIS })).toThrow(/sentinel/) + expect(() => encodeAccountSweep({ currency: WETH, amount: 0n, to: OWNER })).toThrow(MarginSdkError) + }) + + test('descriptors target the account with the account ABI', () => { + for (const call of [ + accountSupplyCollateralCall({ account: ACCOUNT, params: base }), + accountRepayCall({ account: ACCOUNT, params: base }), + accountBorrowCall({ account: ACCOUNT, params: { ...base, to: OWNER } }), + accountSweepCall({ account: ACCOUNT, params: { currency: WETH, amount: 1n, to: OWNER } }), + ]) { + expect(call.address).toBe(ACCOUNT) + expect(call.abi).toBe(MARGIN_ACCOUNT_ABI) + } + }) +}) + describe('address validation', () => { test('rejects a malformed adapter address', () => { expect(() => encodeIncreasePosition({ ...BASE_INCREASE, adapter: '0x1234' as never })).toThrow(MarginSdkError) diff --git a/sdks/margin-sdk/src/encode.ts b/sdks/margin-sdk/src/encode.ts index 5e712b1db..1221086cc 100644 --- a/sdks/margin-sdk/src/encode.ts +++ b/sdks/margin-sdk/src/encode.ts @@ -1,11 +1,12 @@ import { type Address, type Hex, encodeFunctionData } from 'viem' -import { MARGIN_ROUTER_ABI, PERMIT2_ABI } from './abis.js' +import { MARGIN_ACCOUNT_ABI, MARGIN_ROUTER_ABI, PERMIT2_ABI } from './abis.js' +import { validateAccountRecipient } from './account.js' import { FULL_CLOSE, MAX_UINT48 } from './constants.js' import { MarginSdkError } from './errors.js' import { poolKeyMatchesMarket, validateAddress, validateMarket } from './market.js' import { toUint128 } from './math.js' -import { type AddCollateralParams, type DecreaseParams, type IncreaseParams } from './types.js' +import { type AddCollateralParams, type DecreaseParams, type IncreaseParams, type Market } from './types.js' // A Unix-seconds deadline beyond this is almost certainly a milliseconds value (Date.now()), // which would silently disable the deadline for the next ~3,000 years. @@ -49,6 +50,17 @@ export interface ContractWrite { value?: bigint } +/** + * A write descriptor targeting a MarginAccount directly rather than the router β€” the owner escape + * hatch. Same shape as {@link ContractWrite} with the account ABI. + */ +export interface AccountContractWrite { + address: Address + abi: typeof MARGIN_ACCOUNT_ABI + functionName: string + args: readonly unknown[] +} + type IncreaseArgs = { adapter: Address market: { collateral: Address; debt: Address } @@ -286,6 +298,247 @@ export function addCollateralCall(p: { } } +// --------------------------------------------------------------------------- +// MarginAccount primitives (the owner escape hatch) +// --------------------------------------------------------------------------- + +/** + * Parameters for the account-direct `withdrawCollateral` primitive + * (`IMarginAccount.withdrawCollateral`). + */ +export interface AccountWithdrawCollateralParams { + /** The lending adapter that encodes the withdrawal call. Not allowlist-gated. */ + adapter: Address + /** The (collateral, debt) pair identifying the lending market. */ + market: Market + /** The exact collateral to withdraw, in the collateral token's native decimals. */ + amount: bigint + /** + * The recipient; must be the account's owner or its manager (the MarginRouter), or the account + * reverts `ReceiverNotAllowed(to)`. + */ + to: Address +} + +type AccountWithdrawCollateralArgs = readonly [ + adapter: Address, + market: { collateral: Address; debt: Address }, + amount: bigint, + to: Address, +] + +function normalizeAccountWithdrawCollateral( + params: AccountWithdrawCollateralParams +): AccountWithdrawCollateralArgs { + validateAddress(params.adapter, 'adapter') + validateMarket(params.market) + validateAccountRecipient(params.to, 'withdrawCollateral') + if (params.amount <= 0n) { + throw new MarginSdkError('INVALID_AMOUNT', 'amount must be positive') + } + return [params.adapter, params.market, params.amount, params.to] +} + +/** + * Encodes `IMarginAccount.withdrawCollateral` calldata β€” the owner escape hatch, called **on the + * account** rather than through the router. + * + * Prefer the router path for normal operation (`withdrawCollateralPlan` + `executeCall`), which can + * compose the withdrawal with a health assertion and other actions atomically. This direct path + * exists for when the router is deprecated, paused, or compromised: the account's primitives are + * callable by `{manager, owner}`, so the owner can always exit without the router. Note that it + * carries **no** health assertion β€” the lending protocol's own borrow-limit check is the only + * backstop, so a withdrawal that would leave the position unhealthy reverts inside the venue rather + * than with `PositionUnhealthy`. + */ +export function encodeAccountWithdrawCollateral(params: AccountWithdrawCollateralParams): Hex { + return encodeFunctionData({ + abi: MARGIN_ACCOUNT_ABI, + functionName: 'withdrawCollateral', + args: normalizeAccountWithdrawCollateral(params), + }) +} + +/** + * Account-direct `withdrawCollateral` write descriptor. `account` is the MarginAccount address β€” + * derive it with `getMarginAccountAddress(chainId, owner, subId)`; the transaction must be sent by + * that account's owner. + */ +export function accountWithdrawCollateralCall(p: { + account: Address + params: AccountWithdrawCollateralParams +}): AccountContractWrite { + validateAddress(p.account, 'account') + return { + address: p.account, + abi: MARGIN_ACCOUNT_ABI, + functionName: 'withdrawCollateral', + args: normalizeAccountWithdrawCollateral(p.params), + } +} + +/** Parameters for the account-direct `supplyCollateral` / `repay` primitives (no recipient). */ +export interface AccountMarketAmountParams { + /** The lending adapter that encodes the call. */ + adapter: Address + /** The (collateral, debt) pair identifying the lending market. */ + market: Market + /** The amount, in the relevant token's native decimals. */ + amount: bigint +} + +function normalizeAccountMarketAmount( + params: AccountMarketAmountParams, + label: string, + allowFullSentinel = false +): readonly [Address, { collateral: Address; debt: Address }, bigint] { + validateAddress(params.adapter, 'adapter') + validateMarket(params.market) + if (params.amount <= 0n) { + throw new MarginSdkError('INVALID_AMOUNT', `${label} amount must be positive`) + } + if (!allowFullSentinel && params.amount === FULL_CLOSE) { + throw new MarginSdkError('INVALID_AMOUNT', `${label} has no max-amount sentinel; pass an explicit amount`) + } + return [params.adapter, params.market, params.amount] +} + +/** + * Encodes `IMarginAccount.supplyCollateral` calldata β€” the owner escape hatch. The collateral must + * already sit in the account (the account approves the venue and supplies its own balance); this + * does **not** pull from the owner's wallet. Use `addCollateralCall` on the router for the normal + * Permit2-funded path. + */ +export function encodeAccountSupplyCollateral(params: AccountMarketAmountParams): Hex { + return encodeFunctionData({ + abi: MARGIN_ACCOUNT_ABI, + functionName: 'supplyCollateral', + args: normalizeAccountMarketAmount(params, 'supplyCollateral'), + }) +} + +/** Account-direct `supplyCollateral` write descriptor. */ +export function accountSupplyCollateralCall(p: { + account: Address + params: AccountMarketAmountParams +}): AccountContractWrite { + validateAddress(p.account, 'account') + return { + address: p.account, + abi: MARGIN_ACCOUNT_ABI, + functionName: 'supplyCollateral', + args: normalizeAccountMarketAmount(p.params, 'supplyCollateral'), + } +} + +/** + * Encodes `IMarginAccount.repay` calldata β€” the owner escape hatch. The debt token must already sit + * in the account. Pass {@link FULL_CLOSE} (`type(uint256).max`) for a full **share-based** repay, + * which leaves no interest dust behind β€” the amount-denominated path can leave rounding dust that + * then blocks a full collateral withdrawal's health check. + */ +export function encodeAccountRepay(params: AccountMarketAmountParams): Hex { + return encodeFunctionData({ + abi: MARGIN_ACCOUNT_ABI, + functionName: 'repay', + args: normalizeAccountMarketAmount(params, 'repay', true), + }) +} + +/** Account-direct `repay` write descriptor. `FULL_CLOSE` repays everything by shares. */ +export function accountRepayCall(p: { account: Address; params: AccountMarketAmountParams }): AccountContractWrite { + validateAddress(p.account, 'account') + return { + address: p.account, + abi: MARGIN_ACCOUNT_ABI, + functionName: 'repay', + args: normalizeAccountMarketAmount(p.params, 'repay', true), + } +} + +/** Parameters for the account-direct `borrow` primitive. */ +export interface AccountBorrowParams extends AccountMarketAmountParams { + /** + * The recipient; must be the account's owner or its manager (the MarginRouter), or the account + * reverts `ReceiverNotAllowed(to)`. + */ + to: Address +} + +/** + * Encodes `IMarginAccount.borrow` calldata β€” the owner escape hatch. ⚠️ Borrowing is + * exposure-increasing and this path bypasses both the adapter allowlist and any health assertion, + * so the venue's own borrow limit is the only backstop. Prefer `increasePositionCall`. + */ +export function encodeAccountBorrow(params: AccountBorrowParams): Hex { + validateAccountRecipient(params.to, 'borrow') + return encodeFunctionData({ + abi: MARGIN_ACCOUNT_ABI, + functionName: 'borrow', + args: [...normalizeAccountMarketAmount(params, 'borrow'), params.to], + }) +} + +/** Account-direct `borrow` write descriptor. */ +export function accountBorrowCall(p: { account: Address; params: AccountBorrowParams }): AccountContractWrite { + validateAddress(p.account, 'account') + validateAccountRecipient(p.params.to, 'borrow') + return { + address: p.account, + abi: MARGIN_ACCOUNT_ABI, + functionName: 'borrow', + args: [...normalizeAccountMarketAmount(p.params, 'borrow'), p.params.to], + } +} + +/** Parameters for the account-direct `sweep` primitive. */ +export interface AccountSweepParams { + /** + * The currency to sweep out of the account. The zero address means native ETH β€” unlike a market + * currency, this is valid here (the account has a `receive()` and can hold ETH). + */ + currency: Address + /** The amount to sweep, in the currency's native decimals. */ + amount: bigint + /** + * The recipient; must be the account's owner or its manager (the MarginRouter), or the account + * reverts `ReceiverNotAllowed(to)`. + */ + to: Address +} + +function normalizeAccountSweep(params: AccountSweepParams): readonly [Address, bigint, Address] { + validateAddress(params.currency, 'currency') + validateAccountRecipient(params.to, 'sweep') + if (params.amount <= 0n) { + throw new MarginSdkError('INVALID_AMOUNT', 'sweep amount must be positive') + } + return [params.currency, params.amount, params.to] +} + +/** + * Encodes `IMarginAccount.sweep` calldata β€” the owner escape hatch for recovering a stray token (or + * native ETH, via the zero address) sitting on the account. + */ +export function encodeAccountSweep(params: AccountSweepParams): Hex { + return encodeFunctionData({ + abi: MARGIN_ACCOUNT_ABI, + functionName: 'sweep', + args: normalizeAccountSweep(params), + }) +} + +/** Account-direct `sweep` write descriptor. */ +export function accountSweepCall(p: { account: Address; params: AccountSweepParams }): AccountContractWrite { + validateAddress(p.account, 'account') + return { + address: p.account, + abi: MARGIN_ACCOUNT_ABI, + functionName: 'sweep', + args: normalizeAccountSweep(p.params), + } +} + /** * Encodes `execute` calldata for a finalized plan (see `MarginPlanner`). ⚠️ Only execute plans * your own code built β€” a plan has full authority over the caller's sub-accounts. diff --git a/sdks/margin-sdk/src/planner.test.ts b/sdks/margin-sdk/src/planner.test.ts index eb330e99c..ea3dbc9f7 100644 --- a/sdks/margin-sdk/src/planner.test.ts +++ b/sdks/margin-sdk/src/planner.test.ts @@ -2,16 +2,17 @@ import { describe, expect, test } from 'bun:test' import { type Address, decodeAbiParameters } from 'viem' import { MarginAction, V4RouterAction } from './actions.js' -import { CONTRACT_BALANCE, MSG_SENDER, OPEN_DELTA } from './constants.js' +import { ADDRESS_THIS, CONTRACT_BALANCE, MSG_SENDER, OPEN_DELTA } from './constants.js' import { MarginSdkError } from './errors.js' -import { MarginPlanner } from './planner.js' +import { MarginPlanner, withdrawCollateralPlan } from './planner.js' import { type PoolKey } from './types.js' const WETH: Address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' const USDC: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' const ADAPTER: Address = '0x9A7f8F5A9496D3c9dc0BEEfb44cCaC17CAAF28fa' const ZERO: Address = '0x0000000000000000000000000000000000000000' -const ADDR2: Address = '0x0000000000000000000000000000000000000002' +const OWNER: Address = '0x1111111111111111111111111111111111111111' +const ROUTER: Address = '0x0000000004BBC92D0657580CAe35aEBF054E5CDC' const MARKET = { collateral: WETH, debt: USDC } const POOL: PoolKey = { currency0: USDC, currency1: WETH, fee: 3000, tickSpacing: 60, hooks: ZERO } @@ -23,12 +24,17 @@ const CAST = { '0x000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000001', supplyOpenDelta: '0x0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000000', + // `to` is the LITERAL router address, not the ADDRESS_THIS sentinel: the router forwards + // ACCOUNT_BORROW recipients to the account unmapped, and the account's _requireReceiver only + // accepts its baked-in {owner, manager}. The curated increase encodes `address(this)` here too. borrow3e9ToRouter: - '0x0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b2d05e000000000000000000000000000000000000000000000000000000000000000002', + '0x0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b2d05e000000000000000000000000000000000004bbc92d0657580cae35aebf054e5cdc', assertHealth07: '0x0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000009b6e64a8ec60000', assertFillWeth1e18: '0x000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a7640000', + withdrawWeth1e18ToOwner: + '0x0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000001111111111111111111111111111111111111111', swapExactOutSingle: '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000b3b53fc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000', settleUsdcOpenDeltaRouter: @@ -56,8 +62,8 @@ describe('MarginPlanner action encodings (vs cast abi-encode ground truth)', () expect(p.params[1]).toBe(CAST.supplyOpenDelta as `0x${string}`) }) - test('borrow to ADDRESS_THIS', () => { - const p = new MarginPlanner().setAccount(0n).borrow(ADAPTER, MARKET, 3_000n * 10n ** 6n, ADDR2) + test('borrow to the literal router address', () => { + const p = new MarginPlanner().setAccount(0n).borrow(ADAPTER, MARKET, 3_000n * 10n ** 6n, ROUTER) expect(p.params[1]).toBe(CAST.borrow3e9ToRouter as `0x${string}`) }) @@ -107,7 +113,7 @@ describe('MarginPlanner.finalize', () => { .swapExactOutSingle({ poolKey: POOL, zeroForOne: true, amountOut: 1n, amountInMaximum: 1n }) .assertFill(WETH, 1n) .supplyCollateral(ADAPTER, MARKET, OPEN_DELTA) - .borrow(ADAPTER, MARKET, 1n, ADDR2) + .borrow(ADAPTER, MARKET, 1n, ROUTER) .settle(USDC, OPEN_DELTA, false) .assertHealth(ADAPTER, MARKET, 7n * 10n ** 17n) .sweep(WETH, MSG_SENDER) @@ -168,14 +174,70 @@ describe('zero-recipient guards on fund-out actions', () => { expect(() => planner().accountSweep(WETH, 1n, ZERO)).toThrow(MarginSdkError) }) + test('account fund-out actions reject the unmapped v4 sentinels', () => { + // The router passes ACCOUNT_* recipients straight to the account without _mapRecipient, so a + // sentinel arrives as the literal 0x…01/0x…02 and reverts ReceiverNotAllowed. + for (const sentinel of [MSG_SENDER, ADDRESS_THIS]) { + expect(() => planner().withdrawCollateral(ADAPTER, MARKET, 1n, sentinel)).toThrow(/sentinel/) + expect(() => planner().borrow(ADAPTER, MARKET, 1n, sentinel)).toThrow(/sentinel/) + expect(() => planner().accountSweep(WETH, 1n, sentinel)).toThrow(/sentinel/) + } + }) + test('router fund-out actions reject the zero address', () => { expect(() => planner().take(WETH, ZERO, 1n)).toThrow(MarginSdkError) expect(() => planner().takePortion(WETH, ZERO, 100n)).toThrow(MarginSdkError) expect(() => planner().sweep(WETH, ZERO)).toThrow(MarginSdkError) }) - test('the MSG_SENDER / ADDRESS_THIS sentinels remain valid recipients', () => { + test('the MSG_SENDER / ADDRESS_THIS sentinels remain valid ROUTER-level recipients', () => { + // Only the router-level opcodes resolve them (via _mapRecipient); the account-scoped ones do + // not, which is what the sentinel-rejection test above pins. expect(() => planner().take(WETH, MSG_SENDER, 1n).sweep(WETH, MSG_SENDER)).not.toThrow() - expect(() => planner().borrow(ADAPTER, MARKET, 1n, ADDR2)).not.toThrow() + expect(() => planner().take(WETH, ADDRESS_THIS, 1n).takePortion(WETH, ADDRESS_THIS, 100n)).not.toThrow() + }) +}) + +describe('withdrawCollateralPlan', () => { + const base = { adapter: ADAPTER, market: MARKET, amount: 10n ** 18n, to: OWNER, maxLtvAfter: 8n * 10n ** 17n } + + test('composes SET_ACCOUNT β†’ ACCOUNT_WITHDRAW_COLLATERAL β†’ ASSERT_HEALTH', () => { + const [actions] = decodeAbiParameters([{ type: 'bytes' }, { type: 'bytes[]' }], withdrawCollateralPlan(base)) + expect(actions).toBe( + `0x${[MarginAction.SET_ACCOUNT, MarginAction.ACCOUNT_WITHDRAW_COLLATERAL, MarginAction.ASSERT_HEALTH] + .map((a) => a.toString(16).padStart(2, '0')) + .join('')}` + ) + }) + + test('the withdraw params match cast-generated ground truth', () => { + const [, params] = decodeAbiParameters([{ type: 'bytes' }, { type: 'bytes[]' }], withdrawCollateralPlan(base)) + expect(params[1]).toBe(CAST.withdrawWeth1e18ToOwner) + }) + + test('threads subId through SET_ACCOUNT', () => { + const [, params] = decodeAbiParameters( + [{ type: 'bytes' }, { type: 'bytes[]' }], + withdrawCollateralPlan({ ...base, subId: 7n }) + ) + expect(params[0]).toBe(CAST.setAccount7) + }) + + test('rejects a zero amount β€” there is no full-balance sentinel on this action', () => { + expect(() => withdrawCollateralPlan({ ...base, amount: OPEN_DELTA })).toThrow(/no full-balance sentinel/) + expect(() => withdrawCollateralPlan({ ...base, amount: -1n })).toThrow(MarginSdkError) + }) + + test('requires a non-zero maxLtvAfter (ASSERT_HEALTH skips a zero bound)', () => { + expect(() => withdrawCollateralPlan({ ...base, maxLtvAfter: 0n })).toThrow(/mandatory/) + }) + + test('rejects recipients the account would reject', () => { + expect(() => withdrawCollateralPlan({ ...base, to: ZERO })).toThrow(MarginSdkError) + expect(() => withdrawCollateralPlan({ ...base, to: MSG_SENDER })).toThrow(/sentinel/) + }) + + test('rejects a native-ETH market', () => { + expect(() => withdrawCollateralPlan({ ...base, market: { collateral: ZERO, debt: USDC } })).toThrow(MarginSdkError) }) }) diff --git a/sdks/margin-sdk/src/planner.ts b/sdks/margin-sdk/src/planner.ts index 9fd7eb7b0..63476f3e5 100644 --- a/sdks/margin-sdk/src/planner.ts +++ b/sdks/margin-sdk/src/planner.ts @@ -1,11 +1,18 @@ import { type Address, type Hex, concatHex, encodeAbiParameters, isAddressEqual, numberToHex, zeroAddress } from 'viem' +import { validateAccountRecipient } from './account.js' import { ACCOUNT_SCOPED_ACTIONS, ACTION_ABI, MarginAction, type PlanAction, V4RouterAction } from './actions.js' import { CONTRACT_BALANCE } from './constants.js' import { MarginSdkError } from './errors.js' +import { validateMarket } from './market.js' import { type Market, type PathKey, type PoolKey } from './types.js' -/** Fund-out destinations must never be the zero address (tokens would burn or the call reverts). */ +/** + * Router-level fund-out destinations must never be the zero address (tokens would burn or the call + * reverts). These opcodes DO resolve the `MSG_SENDER`/`ADDRESS_THIS` sentinels through + * `_mapRecipient`, so the sentinels are valid here β€” unlike the account-scoped fund-out actions, + * which use {@link validateAccountRecipient}. + */ function validateRecipient(to: Address, action: string): void { if (isAddressEqual(to, zeroAddress)) { throw new MarginSdkError('INVALID_RECIPIENT', `${action} recipient must not be the zero address`) @@ -85,15 +92,21 @@ export class MarginPlanner { /** * Withdraws `amount` of the market's collateral from the active account's position to `to` * (the account constrains `to` to its manager β€” the router β€” or its owner). + * + * ⚠️ `amount` is NOT an `OPEN_DELTA`-means-everything sentinel here. Onchain, `OPEN_DELTA` (0) + * resolves to the router's open delta owed to the PoolManager in the collateral currency β€” the + * right amount inside a swap-bearing delever, but **zero** in a swap-free withdraw plan, which + * would silently withdraw nothing. For a standalone withdraw read the live collateral with + * `getPosition` and pass an explicit amount, or use {@link withdrawCollateralPlan}. */ withdrawCollateral(adapter: Address, market: Market, amount: bigint, to: Address): this { - validateRecipient(to, 'ACCOUNT_WITHDRAW_COLLATERAL') + validateAccountRecipient(to, 'ACCOUNT_WITHDRAW_COLLATERAL') return this.add(MarginAction.ACCOUNT_WITHDRAW_COLLATERAL, [adapter, market, amount, to]) } /** Borrows `amount` of the market's debt against the active account, delivered to `to`. */ borrow(adapter: Address, market: Market, amount: bigint, to: Address): this { - validateRecipient(to, 'ACCOUNT_BORROW') + validateAccountRecipient(to, 'ACCOUNT_BORROW') return this.add(MarginAction.ACCOUNT_BORROW, [adapter, market, amount, to]) } @@ -104,7 +117,7 @@ export class MarginPlanner { /** Sweeps `amount` of `currency` out of the active account to `to` (manager or owner only). */ accountSweep(currency: Address, amount: bigint, to: Address): this { - validateRecipient(to, 'ACCOUNT_SWEEP') + validateAccountRecipient(to, 'ACCOUNT_SWEEP') return this.add(MarginAction.ACCOUNT_SWEEP, [currency, amount, to]) } @@ -272,3 +285,86 @@ export class MarginPlanner { return encodeAbiParameters([{ type: 'bytes' }, { type: 'bytes[]' }], [packedActions, this.params]) } } + +// --------------------------------------------------------------------------- +// Curated plans +// --------------------------------------------------------------------------- + +/** Parameters for {@link withdrawCollateralPlan}. */ +export interface WithdrawCollateralPlanParams { + /** The lending adapter. Withdrawals are not allowlist-gated β€” a position must always be exitable. */ + adapter: Address + /** The (collateral, debt) pair to withdraw from. */ + market: Market + /** + * The exact collateral to withdraw, in the collateral token's native decimals. Must be positive: + * there is no full-balance sentinel on this action (see {@link MarginPlanner.withdrawCollateral}). + * Derive it from a live `getPosition` read. + */ + amount: bigint + /** + * The recipient. Must be the account's owner or the MarginRouter β€” the account reverts + * `ReceiverNotAllowed` otherwise, and the `MSG_SENDER`/`ADDRESS_THIS` sentinels are not resolved + * for account-scoped actions. Pass the router to stage the funds for a later action in the plan + * (e.g. `unwrap` + `sweep` to exit as native ETH). + */ + to: Address + /** + * The maximum LTV the position may have afterwards (WAD; 1e18 == 100%). **Mandatory here.** + * Withdrawing collateral raises LTV, and the underlying `ASSERT_HEALTH` opcode treats zero as + * "skip", so a zero bound would let a single transaction walk the position to the liquidation + * edge without reverting. Size it from `maxLtv` on a `getPosition` read. + */ + maxLtvAfter: bigint + /** Sub-account index identifying which MarginAccount to withdraw from. Default 0. */ + subId?: bigint +} + +/** + * Builds the `unlockData` for a swap-free collateral withdrawal β€” reducing a position's collateral + * without touching its debt. + * + * The router has no curated `withdrawCollateral` entry point (its only write entry points are + * `increasePosition`, `decreasePosition`, `addCollateral`, and `execute`), so this composes the + * `IMarginAccount.withdrawCollateral` primitive into a minimal `execute` plan and closes the three + * footguns of hand-rolling it: the non-sentinel recipient, the explicit amount, and the mandatory + * health bound. Pass the result to `executeCall`. + * + * To exit as native ETH from a WETH-collateral market, pass `to: marginRouter` and continue the + * returned plan with `unwrap` + `sweep` instead of using this helper. + * + * @example + * ```ts + * const position = await getPosition(client, { adapter, account, market }) + * const unlockData = withdrawCollateralPlan({ + * adapter, + * market, + * amount: position.collateralAmount / 4n, + * to: owner, + * maxLtvAfter: (position.maxLtv * 80n) / 100n, // keep 20% headroom under liquidation + * }) + * await walletClient.writeContract(executeCall({ marginRouter, unlockData, deadline })) + * ``` + */ +export function withdrawCollateralPlan(params: WithdrawCollateralPlanParams): Hex { + validateMarket(params.market) + if (params.amount <= 0n) { + throw new MarginSdkError( + 'INVALID_AMOUNT', + 'amount must be positive: ACCOUNT_WITHDRAW_COLLATERAL has no full-balance sentinel, and a zero ' + + 'amount resolves to the (empty) pool delta in a swap-free plan, withdrawing nothing' + ) + } + if (params.maxLtvAfter <= 0n) { + throw new MarginSdkError( + 'SLIPPAGE_BOUND_REQUIRED', + 'maxLtvAfter is mandatory for a withdrawal: withdrawing collateral raises LTV, and ASSERT_HEALTH ' + + 'skips a zero bound, so the withdrawal would be unbounded against liquidation' + ) + } + return new MarginPlanner() + .setAccount(params.subId ?? 0n) + .withdrawCollateral(params.adapter, params.market, params.amount, params.to) + .assertHealth(params.adapter, params.market, params.maxLtvAfter) + .finalize() +}