diff --git a/.changeset/margin-sdk-initial-release.md b/.changeset/margin-sdk-initial-release.md new file mode 100644 index 000000000..6f2f588c9 --- /dev/null +++ b/.changeset/margin-sdk-initial-release.md @@ -0,0 +1,5 @@ +--- +'@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), 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/.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/bun.lock b/bun.lock index c3e1d6089..aa08525e0 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,28 @@ "typescript": "npm:typescript@^5.6.2", }, }, + "sdks/margin-sdk": { + "name": "@uniswap/margin-sdk", + "version": "0.0.0", + "dependencies": { + "tslib": "^2.3.0", + }, + "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", + "jsdom": "^26.0.0", + "prettier": "^2.4.1", + "typescript": "npm:typescript@^5.6.2", + "viem": "^2.23.5", + }, + "peerDependencies": { + "viem": "^2.23.5", + }, + }, "sdks/permit2-sdk": { "name": "@uniswap/permit2-sdk", "version": "1.4.0", @@ -82,7 +104,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 +160,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 +257,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 +294,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 +322,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 +354,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", @@ -374,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=="], @@ -638,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=="], @@ -932,6 +966,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"], @@ -1232,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=="], @@ -1248,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=="], @@ -1318,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=="], @@ -1544,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=="], @@ -1640,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=="], @@ -1686,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=="], @@ -1870,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=="], @@ -1940,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=="], @@ -2066,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=="], @@ -2078,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=="], @@ -2200,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=="], @@ -2224,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=="], @@ -2236,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=="], @@ -2350,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-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "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=="], @@ -2382,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=="], @@ -2400,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=="], @@ -2678,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=="], @@ -2706,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=="], @@ -2768,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=="], @@ -2784,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=="], @@ -2900,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=="], @@ -2912,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/.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/.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..75c781740 --- /dev/null +++ b/sdks/margin-sdk/README.md @@ -0,0 +1,369 @@ +# @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. + +## 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 +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`). + +> 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`) 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. + +## 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` +(`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/demo/01-long-lifecycle.ts b/sdks/margin-sdk/demo/01-long-lifecycle.ts new file mode 100644 index 000000000..d4d246ab4 --- /dev/null +++ b/sdks/margin-sdk/demo/01-long-lifecycle.ts @@ -0,0 +1,248 @@ +/** + * 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, '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 --------------------------------------- + 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/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 new file mode 100644 index 000000000..c250c4244 --- /dev/null +++ b/sdks/margin-sdk/demo/README.md @@ -0,0 +1,34 @@ +# 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. | +| `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`): + +- 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..3e86cfeeb --- /dev/null +++ b/sdks/margin-sdk/demo/lib/env.ts @@ -0,0 +1,141 @@ +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.FORK_URL ?? 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..0c613cfbe --- /dev/null +++ b/sdks/margin-sdk/demo/run-all.ts @@ -0,0 +1,26 @@ +/** + * 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 { run as withdrawCollateral } from './06-withdraw-collateral' +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) + await withdrawCollateral(ctx) + console.log('\nall demo flows passed βœ“') +}) diff --git a/sdks/margin-sdk/package.json b/sdks/margin-sdk/package.json new file mode 100644 index 000000000..9f080285c --- /dev/null +++ b/sdks/margin-sdk/package.json @@ -0,0 +1,76 @@ +{ + "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 && 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 && bun run check:package && bun run test:fork", + "check:package": "bun run build && node scripts/check-package.mjs", + "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" + }, + "exports": { + ".": { + "types": "./dist/types/src/index.d.ts", + "import": "./dist/esm/src/index.js", + "require": "./dist/cjs/src/index.js" + } + }, + "sideEffects": false, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "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", + "jsdom": "^26.0.0", + "prettier": "^2.4.1", + "typescript": "npm:typescript@^5.6.2", + "viem": "^2.23.5" + }, + "prettier": { + "printWidth": 120, + "semi": false, + "singleQuote": true + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/sdks/margin-sdk/scripts/check-package.mjs b/sdks/margin-sdk/scripts/check-package.mjs new file mode 100644 index 000000000..f037464dc --- /dev/null +++ b/sdks/margin-sdk/scripts/check-package.mjs @@ -0,0 +1,131 @@ +// 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' }) + } + + // 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 }) +} 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) 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/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/abis.ts b/sdks/margin-sdk/src/abis.ts new file mode 100644 index 000000000..67de305dd --- /dev/null +++ b/sdks/margin-sdk/src/abis.ts @@ -0,0 +1,55 @@ +import { type Abi } from 'viem' + +/** + * 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. + */ +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' + +/** + * 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 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/account.test.ts b/sdks/margin-sdk/src/account.test.ts new file mode 100644 index 000000000..3ff8227d0 --- /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.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 +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..d10efaa5d --- /dev/null +++ b/sdks/margin-sdk/src/account.ts @@ -0,0 +1,126 @@ +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' + +/** + * 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). + */ + +/** + * 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]) +} + +/** 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/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/addresses.test.ts b/sdks/margin-sdk/src/addresses.test.ts new file mode 100644 index 000000000..9322ab791 --- /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.js' +import { SupportedChainId, isMarginSupportedChain } from './chains.js' +import { MarginSdkError, isMarginSdkError } from './errors.js' + +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..bda08b660 --- /dev/null +++ b/sdks/margin-sdk/src/addresses.ts @@ -0,0 +1,55 @@ +import { type Address, getAddress } from 'viem' + +import { SupportedChainId } from './chains.js' + +/** 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/encode.test.ts b/sdks/margin-sdk/src/encode.test.ts new file mode 100644 index 000000000..f01f82b76 --- /dev/null +++ b/sdks/margin-sdk/src/encode.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, test } from 'bun:test' +import { type Address, decodeFunctionData, toFunctionSelector } from 'viem' + +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, + encodeIncreasePosition, + encodeRouterMulticall, + increasePositionCall, +} from './encode.js' +import { MarginSdkError } from './errors.js' +import { type IncreaseParams, type PoolKey } from './types.js' + +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) + }) +}) + +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('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) + }) + + 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 new file mode 100644 index 000000000..1221086cc --- /dev/null +++ b/sdks/margin-sdk/src/encode.ts @@ -0,0 +1,626 @@ +import { type Address, type Hex, encodeFunctionData } from 'viem' + +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, 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. +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 + * 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 +} + +/** + * 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 } + poolKey: IncreaseParams['poolKey'] + equity: bigint + collateralToBuy: bigint + maxDebtIn: bigint + minHopPriceX36: bigint + maxLtvAfter: bigint + subId: bigint + deadline: bigint +} + +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') + } + 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 { + 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') + } + 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 { + validateAddress(params.adapter, 'adapter') + validateMarket(params.market) + validateDeadline(params.deadline) + 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, + } +} + +// --------------------------------------------------------------------------- +// 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. + */ +export function encodeExecute(unlockData: Hex, deadline: bigint): Hex { + validateDeadline(deadline) + 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 { + validateDeadline(p.deadline) + 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/errors.ts b/sdks/margin-sdk/src/errors.ts new file mode 100644 index 000000000..40dc7b916 --- /dev/null +++ b/sdks/margin-sdk/src/errors.ts @@ -0,0 +1,50 @@ +/** + * 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_DEADLINE' + | 'INVALID_RECIPIENT' + | '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/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 diff --git a/sdks/margin-sdk/src/index.ts b/sdks/margin-sdk/src/index.ts new file mode 100644 index 000000000..08af38e08 --- /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.js' +export * from './addresses.js' + +// Constants & errors +export * from './constants.js' +export * from './errors.js' + +// Onchain struct mirrors & ABIs +export * from './types.js' +export * from './abis.js' + +// Markets, account derivation, leverage & health math +export * from './market.js' +export * from './account.js' +export * from './math.js' + +// Entry-point encoders & the execute-plan builder +export * from './encode.js' +export * from './actions.js' +export * from './planner.js' + +// Reads (descriptors + viem helpers) +export * from './reads.js' diff --git a/sdks/margin-sdk/src/market.test.ts b/sdks/margin-sdk/src/market.test.ts new file mode 100644 index 000000000..a97c0c275 --- /dev/null +++ b/sdks/margin-sdk/src/market.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test' +import { type Address } from 'viem' + +import { MarginSdkError } from './errors.js' +import { + marketHasCurrencies, + poolKeyMatchesMarket, + sortsBefore, + swapZeroForOne, + toPoolKey, + validateMarket, +} from './market.js' +import { type PoolKey } from './types.js' + +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) + }) +}) + +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 new file mode 100644 index 000000000..84ee48848 --- /dev/null +++ b/sdks/margin-sdk/src/market.ts @@ -0,0 +1,115 @@ +import { type Address, isAddress, isAddressEqual, zeroAddress } from 'viem' + +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) +} + +/** + * Builds a canonically-ordered v4 `PoolKey` from an unordered currency pair. Defaults to a + * 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 + currencyB: Address + fee: number + 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] + 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 { + validateAddress(market.collateral, 'market.collateral') + validateAddress(market.debt, 'market.debt') + 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..10b43c5ae --- /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.js' +import { MarginSdkError } from './errors.js' +import { + collateralToBuyForLeverage, + estimateLtv, + healthFactor, + impliedLtv, + leverageForLtv, + parseLeverageX18, + quoteCollateralForDebt, + quoteDebtForCollateral, + sizeDecrease, + sizeIncrease, + toUint128, + totalExposure, + withSlippageDown, + withSlippageUp, +} from './math.js' + +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..3717b24ac --- /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.js' +import { MarginSdkError } from './errors.js' + +/** + * 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 } +} diff --git a/sdks/margin-sdk/src/planner.test.ts b/sdks/margin-sdk/src/planner.test.ts new file mode 100644 index 000000000..ea3dbc9f7 --- /dev/null +++ b/sdks/margin-sdk/src/planner.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from 'bun:test' +import { type Address, decodeAbiParameters } from 'viem' + +import { MarginAction, V4RouterAction } from './actions.js' +import { ADDRESS_THIS, CONTRACT_BALANCE, MSG_SENDER, OPEN_DELTA } from './constants.js' +import { MarginSdkError } from './errors.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 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 } + +/** Ground-truth blobs generated with `cast abi-encode` against the decoder signatures. */ +const CAST = { + setAccount7: '0x0000000000000000000000000000000000000000000000000000000000000007', + pullWeth1e18True: + '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: + '0x0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b2d05e000000000000000000000000000000000004bbc92d0657580cae35aebf054e5cdc', + assertHealth07: + '0x0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000009b6e64a8ec60000', + assertFillWeth1e18: + '0x000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a7640000', + withdrawWeth1e18ToOwner: + '0x0000000000000000000000009a7f8f5a9496d3c9dc0beefb44ccac17caaf28fa000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000001111111111111111111111111111111111111111', + 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 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}`) + }) + + 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, ROUTER) + .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]) + }) +}) + +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('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 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().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 new file mode 100644 index 000000000..63476f3e5 --- /dev/null +++ b/sdks/margin-sdk/src/planner.ts @@ -0,0 +1,370 @@ +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' + +/** + * 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`) + } +} + +/** + * 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` + * (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 { + 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 { + validateAccountRecipient(to, 'ACCOUNT_BORROW') + 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 { + validateAccountRecipient(to, 'ACCOUNT_SWEEP') + 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 { + validateRecipient(recipient, 'TAKE') + 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 { + 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]) + } + + /** 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]) + } +} + +// --------------------------------------------------------------------------- +// 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() +} 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}` + ) + }) +}) diff --git a/sdks/margin-sdk/src/reads.ts b/sdks/margin-sdk/src/reads.ts new file mode 100644 index 000000000..301f2f2b2 --- /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.js' +import { type Market, type PositionData } from './types.js' + +/** + * 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: [] } +} 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 +} 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"] +}