From 3d491ffb244c7e394ee15c8629f783422a3e1837 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+0xdewy@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:28:12 -0700 Subject: [PATCH 1/8] Experiment with Enso router batches --- README.md | 43 +++++++++++++++++++ SECURITY.md | 5 +++ js/enso.js | 42 +++++++++++++++++++ js/index.js | 47 ++++++++++++++++++--- js/package.json | 4 +- js/test/enso-mainnet.js | 93 +++++++++++++++++++++++++++++++++++++++++ js/test/enso.test.js | 62 +++++++++++++++++++++++++++ script/rehearse.sh | 5 +++ 8 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 js/enso.js create mode 100644 js/test/enso-mainnet.js create mode 100644 js/test/enso.test.js diff --git a/README.md b/README.md index 3fd7b1ea..c39cf8c4 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,43 @@ The declared length must be exact. The executor reverts (`InsufficientReturnData returns too few bytes; incorrect lengths can also make the consumer read adjacent arguments. The declaration is a caller precondition, not a runtime length check. +### Enso router responses + +Enso already returns a complete transaction, so wrap its `router` response as raw calls rather +than decoding its internal route. The adapter preserves every pre-transaction, the main +transaction, calldata, order, and ETH value: + +```javascript +import { buildEnsoRouterBatch } from "multicall-scripter/enso"; + +const route = await enso.getRouteData({ + chainId: 1, + fromAddress: EXECUTOR, + routingStrategy: "router", + // tokenIn, tokenOut, amountIn, receiver, slippage... +}); +const { batch, value } = buildEnsoRouterBatch(route, { + caller: EXECUTOR, + routingStrategy: "router", +}); + +await walletClient.writeContract({ + address: EXECUTOR, abi: EXECUTOR_ABI, functionName: "execute", + args: [batch.targets, batch.offsets, batch.calldatas, batch.msgValues], + value, +}); +``` + +`caller` must match every returned `tx.from`. For the bare executor it is the executor address; +under EIP-7702 it is the delegating EOA. The adapter rejects Enso `delegate` responses because +those require the smart wallet to use `delegatecall`, which this project deliberately excludes. +Use Enso's `router` strategy and send the returned `tx.to`; do not hardcode an Enso address. + +For ERC-20 input, approvals and funding must belong to the execution account. A public bare +executor must receive tokens inside the same atomic batch and must not retain approvals or assets. +EIP-7702 is the natural path when the EOA already owns the input tokens. Cross-chain routes only +initiate work on the source chain; destination execution cannot be composed into the same batch. + ## Rules - **Successful calls can return `false`.** The executor propagates EVM reverts; it does not interpret ERC-20 boolean results. Scripts must enforce their own success conditions. @@ -257,6 +294,12 @@ prints the fork block, deploys all three contracts, checks a second deployment r - a real EIP-7702 authorization, self-call, signed relayed batch and rejected replay work; - a deployment rerun rejects mismatched code, and both Solidity mainnet-fork swaps pass. +If `ENSO_API_KEY` is already exported, the rehearsal also fetches live Enso ETH → USDC and +ETH → DAI `router` routes, converts them into Scripter batches, executes them on the fork, checks +`minAmountOut`, and compares output and gas against the identical direct Enso transactions from +an Anvil snapshot. The script never sources `.env`; +`.env` is ignored by Git. Without the exported key, this optional live check reports `SKIP`. + The process uses public Anvil test accounts and sends transactions only to its own local node. Historical blocks may require an archive RPC; public endpoints can impose rate or history limits. diff --git a/SECURITY.md b/SECURITY.md index 11a3e61d..f7fd4cec 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -120,6 +120,11 @@ Build: the optimizer was off; it is now on (via-IR), and the compilation target enforced; do not use the signature bytes as an identifier. - **The Rust builder** covers scalar chaining and one dynamic value per call. Nested references and element access are JS-only. +- **External route builders remain trusted input.** The Enso adapter validates transaction shape, + caller context, value encoding, and router-only use; it cannot prove that API-provided targets + or calldata express the route the user intended. Inspect route metadata, set a bounded + slippage/minimum output, use a fresh quote, and simulate before signing. Cross-chain completion + is asynchronous and outside the source-chain batch's atomicity. ## Release validation — 2026-09-05 diff --git a/js/enso.js b/js/enso.js new file mode 100644 index 00000000..d6a7ecdb --- /dev/null +++ b/js/enso.js @@ -0,0 +1,42 @@ +import { getAddress } from "viem"; +import { TransactionBuilder } from "./index.js"; + +function address(value, label) { + try { return getAddress(value); } + catch { throw new Error(`Enso ${label} is not a valid address`); } +} + +function transaction(entry, label, caller) { + const tx = label === "tx" ? entry : entry?.tx; + if (!tx || typeof tx !== "object") throw new Error(`Enso ${label} is missing tx`); + const from = address(tx.from, `${label}.from`); + if (from !== caller) throw new Error(`Enso ${label}.from does not match the Scripter execution account`); + return {to: address(tx.to, `${label}.to`), data: tx.data, value: tx.value ?? 0n}; +} + +/** + * Convert an Enso `router` route response into MulticallScripter inputs. + * + * `caller` is the address that targets observe as msg.sender: the bare executor address, or the + * delegating EOA when using SevenSevenZeroTwoCaller. Delegate-strategy responses are deliberately + * rejected because they require the wallet itself to delegatecall Enso's delegate contract. + */ +export function buildEnsoRouterBatch(response, {caller, routingStrategy}) { + if (routingStrategy !== "router") { + throw new Error("Only Enso router responses can be wrapped; delegate responses require delegatecall"); + } + if (!response || typeof response !== "object") throw new Error("Enso response must be an object"); + const executionAccount = address(caller, "caller"); + if (response.preTransactions !== undefined && !Array.isArray(response.preTransactions)) { + throw new Error("Enso preTransactions must be an array"); + } + + const transactions = (response.preTransactions ?? []).map((entry, i) => + transaction(entry, `preTransactions[${i}]`, executionAccount)); + transactions.push(transaction(response.tx, "tx", executionAccount)); + + const builder = new TransactionBuilder(); + for (const tx of transactions) builder.addRawCall(tx.to, tx.data, tx.value); + const batch = builder.build(); + return {batch, value: batch.msgValues.reduce((sum, amount) => sum + amount, 0n)}; +} diff --git a/js/index.js b/js/index.js index 3bbb2e72..a5a29b1f 100644 --- a/js/index.js +++ b/js/index.js @@ -7,7 +7,7 @@ // pointers of the encoded calldata. Anything whose position cannot be known before execution // (a second dynamic value, arrays of dynamic elements) is rejected instead of guessed. -import { encodeFunctionData, getAbiItem } from "viem"; +import { encodeFunctionData, getAbiItem, getAddress } from "viem"; import { STATIC_CALL_FLAG, CALL_FLAG, @@ -26,6 +26,29 @@ const pad32 = (n) => Math.ceil(n / WORD) * WORD; // bytes a call occupies in the executor's calldata region: [length word][data padded to 32] const regionSize = (calldataHex) => WORD + pad32((calldataHex.length - 2) / 2); +function uint256(value, label) { + if (!["bigint", "string", "number"].includes(typeof value) + || (typeof value === "string" && value.trim() === "")) throw new Error(`${label} must fit uint256`); + if (typeof value === "number" && !Number.isSafeInteger(value)) throw new Error("Use BigInt or a string for large integers"); + let parsed; + try { parsed = BigInt(value); } + catch { throw new Error(`${label} must fit uint256`); } + if (parsed < 0n || parsed >= (1n << 256n)) throw new Error(`${label} must fit uint256`); + return parsed; +} + +function address(value) { + try { return getAddress(value); } + catch { throw new Error(`Invalid target address: ${value}`); } +} + +function bytes(value) { + if (typeof value !== "string" || !/^0x(?:[0-9a-fA-F]{2})*$/.test(value)) { + throw new Error("Calldata must be an even-length hex string"); + } + return value; +} + // =============================== ABI layout =============================== // `param` is an ABI parameter object: { type, name?, components? } @@ -289,11 +312,7 @@ export class TransactionBuilder { throw new Error(`Argument count mismatch: ${args.length} vs ${fn.inputs.length}`); } const isStatic = fn.stateMutability === "view" || fn.stateMutability === "pure"; - if (!["bigint", "string", "number"].includes(typeof msgValue) - || (typeof msgValue === "string" && msgValue.trim() === "")) throw new Error("msg.value must fit uint256"); - if (typeof msgValue === "number" && !Number.isSafeInteger(msgValue)) throw new Error("Use BigInt or a string for large integers"); - msgValue = BigInt(msgValue); - if (msgValue < 0n || msgValue >= (1n << 256n)) throw new Error("msg.value must fit uint256"); + msgValue = uint256(msgValue, "msg.value"); if (isStatic && msgValue > 0n) throw new Error(`${functionName} is ${fn.stateMutability}; it cannot receive msg.value`); const refs = []; @@ -351,6 +370,22 @@ export class TransactionBuilder { return positional; } + /** Append already-encoded calldata, such as a transaction returned by a routing API. */ + addRawCall(target, calldata, msgValue = 0n) { + this.calls.push({ + target: address(target), + fnCalldata: bytes(calldata), + calltype_flag: CALL_FLAG, + memTargets: [], + resultLengths: [], + returnOffsets: [], + returnDataSize: 0, + msgValue: uint256(msgValue, "msg.value"), + functionName: null, + }); + return this; + } + /** The four inputs for MulticallScripter.execute(targets, offsets, calldatas, msgValues). */ build() { const targets = []; diff --git a/js/package.json b/js/package.json index ea1cd8c2..a1999c64 100644 --- a/js/package.json +++ b/js/package.json @@ -6,13 +6,15 @@ "main": "./index.js", "exports": { ".": "./index.js", - "./abi": "./abi.js" + "./abi": "./abi.js", + "./enso": "./enso.js" }, "files": [ "index.js", "encoding.js", "offset-schema.json", "abi.js", + "enso.js", "cli.js" ], "bin": { diff --git a/js/test/enso-mainnet.js b/js/test/enso-mainnet.js new file mode 100644 index 00000000..b612300b --- /dev/null +++ b/js/test/enso-mainnet.js @@ -0,0 +1,93 @@ +// Optional authenticated Enso route rehearsal. The caller supplies ENSO_API_KEY in the process +// environment; this script never reads dotenv files or prints the key. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { + createPublicClient, createWalletClient, getContractAddress, http, parseAbi, parseEther, toHex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { buildEnsoRouterBatch } from "../enso.js"; + +const rpc = process.argv[2]; +if (!rpc) throw new Error("Usage: ENSO_API_KEY=... bun js/test/enso-mainnet.js ANVIL_FORK_RPC"); +const apiKey = process.env.ENSO_API_KEY; +if (!apiKey) throw new Error("ENSO_API_KEY is required"); + +const transport = http(rpc, {timeout: 120_000}); +const client = createPublicClient({transport}); +const info = await client.request({method: "anvil_nodeInfo"}); +assert.equal(await client.getChainId(), 31337, "Enso rehearsal requires local chain 31337"); +assert.ok(info.forkConfig?.forkBlockNumber, "Enso rehearsal requires a mainnet fork"); + +const account = privateKeyToAccount("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); +const wallet = createWalletClient({account, transport}); +const artifact = JSON.parse(readFileSync(new URL("../../out/7702Caller.sol/SevenSevenZeroTwoCaller.json", import.meta.url))); +const delegateAddress = getContractAddress({ + opcode: "CREATE2", + from: "0x4e59b44847b379578588920cA78FbF26c0B4956C", + salt: toHex(0x4d756c746963616c6c5363726970746572n, {size: 32}), + bytecode: artifact.bytecode.object, +}); +assert.equal((await client.getCode({address: account.address})).toLowerCase(), + `0xef0100${delegateAddress.slice(2).toLowerCase()}`, "rehearsal account must already delegate to SevenSevenZeroTwoCaller"); + +const amountIn = parseEther("0.005"); +const receiver = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; +const erc20 = parseAbi(["function balanceOf(address) view returns (uint256)"]); +const routes = [ + {symbol: "USDC", token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}, + {symbol: "DAI", token: "0x6B175474E89094C44Da98b954EedeAC495271d0F"}, +]; +for (const {symbol, token} of routes) { + const query = new URLSearchParams({ + chainId: "1", + fromAddress: account.address, + receiver, + routingStrategy: "router", + tokenIn: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + tokenOut: token, + amountIn: amountIn.toString(), + slippage: "100", + }); + const response = await fetch(`https://api.enso.build/api/v1/shortcuts/route?${query}`, { + headers: {Authorization: `Bearer ${apiKey}`, Accept: "application/json"}, + }); + if (!response.ok) throw new Error(`Enso ${symbol} route request failed with HTTP ${response.status}`); + const route = await response.json(); + const {batch, value} = buildEnsoRouterBatch(route, {caller: account.address, routingStrategy: "router"}); + assert.equal(value, amountIn, "Enso route must forward exactly the requested native input"); + + // Execute the exact response directly and through Scripter from identical Anvil state. + const snapshot = await client.request({method: "evm_snapshot"}); + const before = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}); + let directGas = 0n; + const directTransactions = [...(route.preTransactions ?? []).map(entry => entry.tx), route.tx]; + for (const tx of directTransactions) { + const directReceipt = await client.waitForTransactionReceipt({hash: await wallet.sendTransaction({ + to: tx.to, data: tx.data, value: BigInt(tx.value ?? 0), gas: 3_000_000n, chain: null, + })}); + assert.equal(directReceipt.status, "success"); + directGas += directReceipt.gasUsed; + } + const directReceived = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}) - before; + assert.equal(await client.request({method: "evm_revert", params: [snapshot]}), true); + + const wrappedBefore = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}); + const receipt = await client.waitForTransactionReceipt({hash: await wallet.writeContract({ + address: account.address, + abi: artifact.abi, + functionName: "execute", + args: [batch.targets, batch.offsets, batch.calldatas, batch.msgValues], + value, + gas: 3_000_000n, + chain: null, + })}); + assert.equal(receipt.status, "success"); + const received = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}) - wrappedBefore; + const minimum = Array.isArray(route.minAmountOut) ? BigInt(route.minAmountOut[0]) : BigInt(route.minAmountOut); + assert.equal(received, directReceived, "direct and wrapped execution must produce identical output from identical state"); + assert.ok(received >= minimum, "received output must satisfy Enso's minAmountOut"); + const delta = receipt.gasUsed - directGas; + const percent = Number(delta * 10_000n / directGas) / 100; + console.log(`PASS Enso ETH → ${symbol}: direct ${directGas} gas; Scripter ${receipt.gasUsed} gas; ${delta >= 0n ? "+" : ""}${delta} (${percent}%); output ${received}`); +} diff --git a/js/test/enso.test.js b/js/test/enso.test.js new file mode 100644 index 00000000..05c35197 --- /dev/null +++ b/js/test/enso.test.js @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { decodeValueIndex, CALL_FLAG } from "../encoding.js"; +import { buildEnsoRouterBatch } from "../enso.js"; + +const CALLER = "0x00000000000000000000000000000000000000AA"; +const APPROVAL = "0x00000000000000000000000000000000000000bb"; +const ROUTER = "0x00000000000000000000000000000000000000cc"; + +function frames(packed) { + const bytes = Buffer.from(packed.slice(2), "hex"); + const result = []; + for (let cursor = 0; cursor < bytes.length;) { + const length = Number(BigInt(`0x${bytes.subarray(cursor, cursor + 32).toString("hex")}`)); + cursor += 32; + result.push(`0x${bytes.subarray(cursor, cursor + length).toString("hex")}`); + cursor += Math.ceil(length / 32) * 32; + } + return result; +} + +describe("Enso router adapter", () => { + test("preserves pre-transaction order, calldata, targets, and ETH values", () => { + const response = { + preTransactions: [{tx: {from: CALLER, to: APPROVAL, data: "0x095ea7b3", value: "0"}}], + tx: {from: CALLER, to: ROUTER, data: "0x1234abcd00", value: "0x2a"}, + }; + const {batch, value} = buildEnsoRouterBatch(response, {caller: CALLER, routingStrategy: "router"}); + expect(batch.targets).toEqual([APPROVAL, ROUTER]); + expect(frames(batch.calldatas)).toEqual(["0x095ea7b3", "0x1234abcd00"]); + expect(batch.msgValues).toEqual([42n]); + expect(decodeValueIndex(batch.offsets[0], Number(CALL_FLAG))).toBe(0); + expect(decodeValueIndex(batch.offsets[1], Number(CALL_FLAG))).toBe(1); + expect(value).toBe(42n); + }); + + test("accepts a route without pre-transactions", () => { + const {batch, value} = buildEnsoRouterBatch({ + tx: {from: CALLER.toLowerCase(), to: ROUTER.toLowerCase(), data: "0x", value: "0"}, + }, {caller: CALLER, routingStrategy: "router"}); + expect(batch.targets).toEqual([ROUTER]); + expect(frames(batch.calldatas)).toEqual(["0x"]); + expect(batch.msgValues).toEqual([]); + expect(value).toBe(0n); + }); + + test("requires the routing strategy to be stated explicitly", () => { + const response = {tx: {from: CALLER, to: ROUTER, data: "0x", value: "0"}}; + expect(() => buildEnsoRouterBatch(response, {caller: CALLER})).toThrow(/Only Enso router/); + }); + + test.each([ + ["main caller mismatch", {tx: {from: APPROVAL, to: ROUTER, data: "0x", value: "0"}}, {}], + ["pre-transaction caller mismatch", {preTransactions: [{tx: {from: APPROVAL, to: ROUTER, data: "0x", value: "0"}}], tx: {from: CALLER, to: ROUTER, data: "0x", value: "0"}}, {}], + ["delegate response", {tx: {from: CALLER, to: ROUTER, data: "0x", value: "0"}}, {routingStrategy: "delegate"}], + ["malformed calldata", {tx: {from: CALLER, to: ROUTER, data: "0x123", value: "0"}}, {}], + ["negative value", {tx: {from: CALLER, to: ROUTER, data: "0x", value: "-1"}}, {}], + ["missing main transaction", {}, {}], + ["malformed pre-transactions", {preTransactions: {}, tx: {from: CALLER, to: ROUTER, data: "0x", value: "0"}}, {}], + ])("rejects %s", (_, response, options) => { + expect(() => buildEnsoRouterBatch(response, {caller: CALLER, routingStrategy: "router", ...options})).toThrow(); + }); +}); diff --git a/script/rehearse.sh b/script/rehearse.sh index 934dfc6a..2cb61358 100755 --- a/script/rehearse.sh +++ b/script/rehearse.sh @@ -52,5 +52,10 @@ DEPLOY_7702=true script/deploy.sh "$FORK_LOCAL" --sender "$FORK_SENDER" --unlock # Idempotence: the second run must accept the existing exact bytecode and send no deployments. DEPLOY_7702=true script/deploy.sh "$FORK_LOCAL" --sender "$FORK_SENDER" --unlocked --broadcast bun js/test/mainnet.js "$FORK_LOCAL" +if [[ -n "${ENSO_API_KEY:-}" ]]; then + bun js/test/enso-mainnet.js "$FORK_LOCAL" +else + echo "SKIP Enso live route (ENSO_API_KEY is not exported)" +fi # Use the upstream directly: nested forking through Anvil can serialize remote storage fetches. ETH_RPC_URL="$FORK_UPSTREAM" FORK_BLOCK="$FORK_BLOCK" forge test --match-contract CallBuilderTest --threads 1 -vv From 3e4beef99995647a2ba4ba329d87b5a8211b5ff2 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+0xdewy@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:08:37 -0700 Subject: [PATCH 2/8] Bound fork RPC waits and report stages --- js/test/enso-mainnet.js | 1 + script/rehearse.sh | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/js/test/enso-mainnet.js b/js/test/enso-mainnet.js index b612300b..f2654233 100644 --- a/js/test/enso-mainnet.js +++ b/js/test/enso-mainnet.js @@ -51,6 +51,7 @@ for (const {symbol, token} of routes) { }); const response = await fetch(`https://api.enso.build/api/v1/shortcuts/route?${query}`, { headers: {Authorization: `Bearer ${apiKey}`, Accept: "application/json"}, + signal: AbortSignal.timeout(30_000), }); if (!response.ok) throw new Error(`Enso ${symbol} route request failed with HTTP ${response.status}`); const route = await response.json(); diff --git a/script/rehearse.sh b/script/rehearse.sh index 2cb61358..b4335cf6 100755 --- a/script/rehearse.sh +++ b/script/rehearse.sh @@ -28,6 +28,7 @@ FORK_LOCAL="http://127.0.0.1:$FORK_PORT" FORK_SENDER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 FORK_LOG=$(mktemp /tmp/multicall-anvil.XXXXXX.log) anvil --fork-url "$FORK_UPSTREAM" --fork-block-number "$FORK_BLOCK" \ + --timeout 15000 --retries 2 \ --chain-id 31337 --host 127.0.0.1 --port "$FORK_PORT" --silent >"$FORK_LOG" 2>&1 & FORK_PID=$! trap 'kill "$FORK_PID" 2>/dev/null || true; wait "$FORK_PID" 2>/dev/null || true' EXIT @@ -48,14 +49,18 @@ if [[ "$FORK_READY" != true ]]; then fi printf 'Mainnet fork block: %s; local chain: 31337; RPC: %s\n' "$FORK_BLOCK" "$FORK_LOCAL" # Broadcast only to the Anvil process created above. No mainnet signer or private key is loaded. -DEPLOY_7702=true script/deploy.sh "$FORK_LOCAL" --sender "$FORK_SENDER" --unlocked --broadcast +echo '[1/4] Deploying and checking release bytecode' +DEPLOY_7702=true script/deploy.sh "$FORK_LOCAL" --rpc-timeout 30 --sender "$FORK_SENDER" --unlocked --broadcast # Idempotence: the second run must accept the existing exact bytecode and send no deployments. -DEPLOY_7702=true script/deploy.sh "$FORK_LOCAL" --sender "$FORK_SENDER" --unlocked --broadcast +DEPLOY_7702=true script/deploy.sh "$FORK_LOCAL" --rpc-timeout 30 --sender "$FORK_SENDER" --unlocked --broadcast +echo '[2/4] Running protocol and EIP-7702 rehearsal' bun js/test/mainnet.js "$FORK_LOCAL" if [[ -n "${ENSO_API_KEY:-}" ]]; then + echo '[3/4] Comparing live Enso routes directly and through Scripter' bun js/test/enso-mainnet.js "$FORK_LOCAL" else - echo "SKIP Enso live route (ENSO_API_KEY is not exported)" + echo '[3/4] SKIP Enso live route (ENSO_API_KEY is not exported)' fi # Use the upstream directly: nested forking through Anvil can serialize remote storage fetches. +echo '[4/4] Running Solidity fork tests' ETH_RPC_URL="$FORK_UPSTREAM" FORK_BLOCK="$FORK_BLOCK" forge test --match-contract CallBuilderTest --threads 1 -vv From a8ee22e0612b0ecbb5194084d03a4a6cac2bda8c Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+0xdewy@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:50:57 -0700 Subject: [PATCH 3/8] Translate Enso routes into direct Scripter calls --- README.md | 36 +++--- SECURITY.md | 11 +- js/enso.js | 231 +++++++++++++++++++++++++++++++---- js/index.js | 16 --- js/test/enso-differential.js | 68 +++++++++++ js/test/enso-mainnet.js | 40 +++--- js/test/enso.test.js | 102 ++++++++++------ script/rehearse.sh | 12 +- 8 files changed, 395 insertions(+), 121 deletions(-) create mode 100644 js/test/enso-differential.js diff --git a/README.md b/README.md index c39cf8c4..a701f759 100644 --- a/README.md +++ b/README.md @@ -89,24 +89,24 @@ The declared length must be exact. The executor reverts (`InsufficientReturnData returns too few bytes; incorrect lengths can also make the consumer read adjacent arguments. The declaration is a caller precondition, not a runtime length check. -### Enso router responses +### Enso route translation (experimental) -Enso already returns a complete transaction, so wrap its `router` response as raw calls rather -than decoding its internal route. The adapter preserves every pre-transaction, the main -transaction, calldata, order, and ETH value: +The optional adapter uses Enso to find a route, then replaces Enso's Weiroll executor with +MulticallScripter. Request the `delegate` strategy: its `executeShortcut` calldata contains the +underlying command and state arrays for execution in the EOA's context. ```javascript -import { buildEnsoRouterBatch } from "multicall-scripter/enso"; +import { buildEnsoDelegateBatch } from "multicall-scripter/enso"; const route = await enso.getRouteData({ chainId: 1, fromAddress: EXECUTOR, - routingStrategy: "router", + routingStrategy: "delegate", // tokenIn, tokenOut, amountIn, receiver, slippage... }); -const { batch, value } = buildEnsoRouterBatch(route, { +const { batch, value } = buildEnsoDelegateBatch(route, { caller: EXECUTOR, - routingStrategy: "router", + routingStrategy: "delegate", }); await walletClient.writeContract({ @@ -116,10 +116,14 @@ await walletClient.writeContract({ }); ``` -`caller` must match every returned `tx.from`. For the bare executor it is the executor address; -under EIP-7702 it is the delegating EOA. The adapter rejects Enso `delegate` responses because -those require the smart wallet to use `delegatecall`, which this project deliberately excludes. -Use Enso's `router` strategy and send the returned `tx.to`; do not hardcode an Enso address. +`caller` must match every returned `tx.from`; under EIP-7702 it is the delegating EOA. The adapter +decodes the Enso transaction but does not call its `tx.to`: each supported Weiroll command target +is placed directly in the Scripter batch. It fails closed when a route uses semantics that the +fixed offset format cannot preserve, including delegatecalls, runtime-sized return values, +computed ETH values, state replacement, and Weiroll's composite state indices. Treat this as a +route-dependent experiment until the exact live response passes simulation and differential +execution against Enso's VM. Enso also requires a consumed scalar return to be exactly 32 bytes; +Scripter requires at least 32, so simulation is the compatibility check for each concrete route. For ERC-20 input, approvals and funding must belong to the execution account. A public bare executor must receive tokens inside the same atomic batch and must not retain approvals or assets. @@ -294,10 +298,10 @@ prints the fork block, deploys all three contracts, checks a second deployment r - a real EIP-7702 authorization, self-call, signed relayed batch and rejected replay work; - a deployment rerun rejects mismatched code, and both Solidity mainnet-fork swaps pass. -If `ENSO_API_KEY` is already exported, the rehearsal also fetches live Enso ETH → USDC and -ETH → DAI `router` routes, converts them into Scripter batches, executes them on the fork, checks -`minAmountOut`, and compares output and gas against the identical direct Enso transactions from -an Anvil snapshot. The script never sources `.env`; +If `ENSO_API_KEY` is already exported, the rehearsal also requests live Enso `delegate` +ETH → USDC and ETH → DAI routes. It executes each underlying command program through Enso's +EIP-7702 VM and its translated Scripter batch from identical fork state, asserts identical output +and `minAmountOut`, and reports the executor gas difference. The script never sources `.env`; `.env` is ignored by Git. Without the exported key, this optional live check reports `SKIP`. The process uses public Anvil test accounts and sends transactions only to its own local node. diff --git a/SECURITY.md b/SECURITY.md index f7fd4cec..82cbd52f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -120,11 +120,12 @@ Build: the optimizer was off; it is now on (via-IR), and the compilation target enforced; do not use the signature bytes as an identifier. - **The Rust builder** covers scalar chaining and one dynamic value per call. Nested references and element access are JS-only. -- **External route builders remain trusted input.** The Enso adapter validates transaction shape, - caller context, value encoding, and router-only use; it cannot prove that API-provided targets - or calldata express the route the user intended. Inspect route metadata, set a bounded - slippage/minimum output, use a fresh quote, and simulate before signing. Cross-chain completion - is asynchronous and outside the source-chain batch's atomicity. +- **External route builders remain trusted input.** The experimental Enso translator validates + transaction shape, caller context, Weiroll bounds, and only the subset it can reproduce without + changing execution semantics. It cannot prove that API-provided targets or calldata express the + intended route, and a translated static return accepts extra trailing return bytes that Enso's VM + rejects. Inspect route metadata, set a bounded minimum output, use a fresh quote, and simulate + both executions before signing. Cross-chain completion remains outside source-chain atomicity. ## Release validation — 2026-09-05 diff --git a/js/enso.js b/js/enso.js index d6a7ecdb..54ac4f6a 100644 --- a/js/enso.js +++ b/js/enso.js @@ -1,42 +1,223 @@ -import { getAddress } from "viem"; -import { TransactionBuilder } from "./index.js"; +import { decodeFunctionData, getAddress, parseAbi } from "viem"; +import { callPartialReturn, stateChangingCall, staticCall, staticCallPartialReturn } from "./encoding.js"; + +const EXECUTE_SHORTCUT_ABI = parseAbi([ + "function executeShortcut(bytes32 accountId, bytes32 requestId, bytes32[] commands, bytes[] state) payable returns (bytes[])", +]); +const CALL = 1, STATICCALL = 2, VALUECALL = 3, CALLTYPE_MASK = 3; +const DATA = 0x20, EXTENDED = 0x40, TUPLE_RETURN = 0x80; +const VARIABLE = 0x80, VALUE_MASK = 0x7f, END = 0xff; +const USE_STATE = 0xfe, ARRAY_START = 0xfd, TUPLE_START = 0xfc, DYNAMIC_END = 0xfb; +const SPECIAL_INDICES = new Set([USE_STATE, ARRAY_START, TUPLE_START, DYNAMIC_END]); +const strip0x = (value) => value.slice(2); +const byteLength = (value) => strip0x(value).length / 2; +const pad32 = (n) => Math.ceil(n / 32) * 32; +const regionSize = (data) => 32 + pad32(byteLength(data)); + +function hex(value, label) { + if (typeof value !== "string" || !/^0x(?:[0-9a-fA-F]{2})*$/.test(value)) { + throw new Error(`Enso ${label} must be an even-length hex string`); + } + return value.toLowerCase(); +} function address(value, label) { try { return getAddress(value); } catch { throw new Error(`Enso ${label} is not a valid address`); } } -function transaction(entry, label, caller) { +function uint256(value, label) { + if (!["bigint", "string", "number"].includes(typeof value) + || (typeof value === "string" && value.trim() === "") + || (typeof value === "number" && !Number.isSafeInteger(value))) { + throw new Error(`Enso ${label} must fit uint256`); + } + let parsed; + try { parsed = BigInt(value); } + catch { throw new Error(`Enso ${label} must fit uint256`); } + if (parsed < 0n || parsed >= 1n << 256n) throw new Error(`Enso ${label} must fit uint256`); + return parsed; +} + +function word(value, label) { + value = hex(value, label); + if (byteLength(value) !== 32) throw new Error(`Enso ${label} must be 32 bytes`); + return Buffer.from(strip0x(value), "hex"); +} + +function routeTransaction(entry, label, caller) { const tx = label === "tx" ? entry : entry?.tx; if (!tx || typeof tx !== "object") throw new Error(`Enso ${label} is missing tx`); - const from = address(tx.from, `${label}.from`); - if (from !== caller) throw new Error(`Enso ${label}.from does not match the Scripter execution account`); - return {to: address(tx.to, `${label}.to`), data: tx.data, value: tx.value ?? 0n}; -} - -/** - * Convert an Enso `router` route response into MulticallScripter inputs. - * - * `caller` is the address that targets observe as msg.sender: the bare executor address, or the - * delegating EOA when using SevenSevenZeroTwoCaller. Delegate-strategy responses are deliberately - * rejected because they require the wallet itself to delegatecall Enso's delegate contract. - */ -export function buildEnsoRouterBatch(response, {caller, routingStrategy}) { - if (routingStrategy !== "router") { - throw new Error("Only Enso router responses can be wrapped; delegate responses require delegatecall"); + if (address(tx.from, `${label}.from`) !== caller) { + throw new Error(`Enso ${label}.from does not match the execution account`); + } + return { + target: address(tx.to, `${label}.to`), + data: hex(tx.data, `${label}.data`), + value: uint256(tx.value ?? 0n, `${label}.value`), + }; +} + +function readState(slots, index, commandIndex, role) { + const slot = slots[index & VALUE_MASK]; + if (!slot) throw new Error(`Enso command #${commandIndex} ${role} references missing state slot ${index & VALUE_MASK}`); + return slot; +} + +function buildCalldata(selector, indices, slots, commandIndex) { + const heads = [], tails = [], dependencies = []; + for (const index of indices) { + if (index === END) break; + if (SPECIAL_INDICES.has(index)) { + throw new Error(`Enso command #${commandIndex} uses unsupported Weiroll state index 0x${index.toString(16)}`); + } + const slot = readState(slots, index, commandIndex, "argument"); + if (index & VARIABLE) { + if (slot.producer !== undefined) { + throw new Error(`Enso command #${commandIndex} consumes a dynamic return value; its runtime length cannot be translated safely`); + } + const length = byteLength(slot.data); + if (length === 0 || length % 32 !== 0) { + throw new Error(`Enso command #${commandIndex} dynamic state slot ${index & VALUE_MASK} is not word-aligned`); + } + heads.push(null); + tails.push(slot.data); + } else { + if (slot.producer !== undefined) { + if (slot.dynamic) { + throw new Error(`Enso command #${commandIndex} consumes a dynamic return value as a static argument`); + } + dependencies.push({producer: slot.producer, position: 4 + heads.length * 32}); + heads.push("0x" + "00".repeat(32)); + } else { + if (byteLength(slot.data) !== 32) { + throw new Error(`Enso command #${commandIndex} static state slot ${index} is not 32 bytes`); + } + heads.push(slot.data); + } + tails.push(null); + } + } + let tailOffset = heads.length * 32; + for (let i = 0; i < heads.length; i++) { + if (heads[i] === null) { + heads[i] = `0x${BigInt(tailOffset).toString(16).padStart(64, "0")}`; + tailOffset += byteLength(tails[i]); + } + } + return { + data: `0x${selector.toString("hex")}${heads.map(strip0x).join("")}${tails.filter(Boolean).map(strip0x).join("")}`, + dependencies, + }; +} + +function packFrames(calls) { + return "0x" + calls.map(({data}) => { + const length = byteLength(data); + return BigInt(length).toString(16).padStart(64, "0") + + strip0x(data).padEnd(pad32(length) * 2, "0"); + }).join(""); +} + +/** Translate the representable subset of an Enso `delegate` Weiroll program to direct Scripter calls. */ +export function buildEnsoDelegateBatch(response, {caller, routingStrategy}) { + if (routingStrategy !== "delegate") { + throw new Error("Only Enso delegate responses expose a program for direct Scripter execution"); } if (!response || typeof response !== "object") throw new Error("Enso response must be an object"); const executionAccount = address(caller, "caller"); if (response.preTransactions !== undefined && !Array.isArray(response.preTransactions)) { throw new Error("Enso preTransactions must be an array"); } + const preTransactions = (response.preTransactions ?? []).map((entry, i) => + routeTransaction(entry, `preTransactions[${i}]`, executionAccount)); + const main = routeTransaction(response.tx, "tx", executionAccount); + let decoded; + try { decoded = decodeFunctionData({abi: EXECUTE_SHORTCUT_ABI, data: main.data}); } + catch { throw new Error("Enso delegate tx.data is not executeShortcut calldata"); } + const commands = decoded.args[2]; + const slots = decoded.args[3].map((data, i) => ({data: hex(data, `state[${i}]`)})); + const calls = preTransactions.map(tx => ({...tx, isStatic: false, dependencies: []})); - const transactions = (response.preTransactions ?? []).map((entry, i) => - transaction(entry, `preTransactions[${i}]`, executionAccount)); - transactions.push(transaction(response.tx, "tx", executionAccount)); + for (let cursor = 0, commandIndex = 0; cursor < commands.length; cursor++, commandIndex++) { + const command = word(commands[cursor], `commands[${cursor}]`); + const flags = command[4]; + const calltype = flags & CALLTYPE_MASK; + if (calltype === 0) throw new Error(`Enso command #${commandIndex} is a delegatecall`); + if (![CALL, STATICCALL, VALUECALL].includes(calltype)) { + throw new Error(`Enso command #${commandIndex} has invalid call type ${calltype}`); + } + let indices; + if (flags & EXTENDED) { + cursor++; + if (cursor >= commands.length) throw new Error(`Enso command #${commandIndex} is missing its extended index word`); + indices = [...word(commands[cursor], `commands[${cursor}]`)]; + } else { + indices = [...command.subarray(5, 11)]; + } + const target = address(`0x${command.subarray(12).toString("hex")}`, `command #${commandIndex} target`); + let value = 0n; + if (calltype === VALUECALL) { + const valueIndex = indices.shift(); + if (valueIndex === undefined || valueIndex === END || valueIndex & VARIABLE || SPECIAL_INDICES.has(valueIndex)) { + throw new Error(`Enso command #${commandIndex} has an unsupported ETH value reference`); + } + const valueSlot = readState(slots, valueIndex, commandIndex, "ETH value"); + if (valueSlot.producer !== undefined || valueSlot.dynamic || byteLength(valueSlot.data) !== 32) { + throw new Error(`Enso command #${commandIndex} computes its ETH value at runtime`); + } + value = BigInt(valueSlot.data); + } + let built; + if (flags & DATA) { + const dataIndex = indices[0]; + if (dataIndex === undefined || dataIndex === END || SPECIAL_INDICES.has(dataIndex)) { + throw new Error(`Enso command #${commandIndex} has an unsupported raw calldata reference`); + } + const dataSlot = readState(slots, dataIndex, commandIndex, "raw calldata"); + if (dataSlot.producer !== undefined) throw new Error(`Enso command #${commandIndex} computes raw calldata at runtime`); + built = {data: dataSlot.data, dependencies: []}; + } else { + built = buildCalldata(command.subarray(0, 4), indices, slots, commandIndex); + } + const callIndex = calls.length; + calls.push({target, data: built.data, value, isStatic: calltype === STATICCALL, dependencies: built.dependencies}); + const output = command[11]; + if (output === END) continue; + if (output === USE_STATE) throw new Error(`Enso command #${commandIndex} replaces the complete Weiroll state`); + const outputSlot = output & VALUE_MASK; + if (outputSlot >= slots.length) throw new Error(`Enso command #${commandIndex} writes missing state slot ${outputSlot}`); + slots[outputSlot] = {data: "0x", producer: callIndex, dynamic: Boolean((flags & TUPLE_RETURN) || (output & VARIABLE))}; + } - const builder = new TransactionBuilder(); - for (const tx of transactions) builder.addRawCall(tx.to, tx.data, tx.value); - const batch = builder.build(); - return {batch, value: batch.msgValues.reduce((sum, amount) => sum + amount, 0n)}; + const patches = calls.map(() => []); + calls.forEach((call, consumer) => { + for (const dependency of call.dependencies) { + const producer = calls[dependency.producer]; + if (!producer || dependency.producer >= consumer) throw new Error("Enso command dependency is not produced by an earlier call"); + const memTarget = calls.slice(dependency.producer + 1, consumer) + .reduce((sum, item) => sum + regionSize(item.data), 0) + dependency.position; + patches[dependency.producer].push(memTarget); + } + }); + const targets = calls.map(call => call.target); + const msgValues = []; + const offsets = calls.map((call, i) => { + let valueIndex = 0; + if (call.value > 0n) { msgValues.push(call.value); valueIndex = msgValues.length; } + const destinations = patches[i]; + if (destinations.length > 3) throw new Error(`Enso call #${i} feeds ${destinations.length} arguments; Scripter supports at most 3`); + if (destinations.length === 0) return call.isStatic ? staticCall(0, 0) : stateChangingCall(valueIndex); + if (destinations.length === 1) return call.isStatic + ? staticCall(destinations[0], 32) : stateChangingCall(valueIndex, destinations[0], 32); + const lengths = destinations.map(() => 32), sources = destinations.map(() => 0); + return call.isStatic + ? staticCallPartialReturn(destinations, lengths, sources, 32) + : callPartialReturn(valueIndex, destinations, lengths, sources, 32); + }); + const requiredValue = preTransactions.reduce((sum, tx) => sum + tx.value, main.value); + const spentValue = msgValues.reduce((sum, value) => sum + value, 0n); + if (spentValue > requiredValue) throw new Error("Enso commands spend more ETH than the delegate transaction supplies"); + return {batch: {targets, offsets, calldatas: packFrames(calls), msgValues}, value: requiredValue, + commandCount: calls.length - preTransactions.length}; } diff --git a/js/index.js b/js/index.js index a5a29b1f..e27c9afb 100644 --- a/js/index.js +++ b/js/index.js @@ -370,22 +370,6 @@ export class TransactionBuilder { return positional; } - /** Append already-encoded calldata, such as a transaction returned by a routing API. */ - addRawCall(target, calldata, msgValue = 0n) { - this.calls.push({ - target: address(target), - fnCalldata: bytes(calldata), - calltype_flag: CALL_FLAG, - memTargets: [], - resultLengths: [], - returnOffsets: [], - returnDataSize: 0, - msgValue: uint256(msgValue, "msg.value"), - functionName: null, - }); - return this; - } - /** The four inputs for MulticallScripter.execute(targets, offsets, calldatas, msgValues). */ build() { const targets = []; diff --git a/js/test/enso-differential.js b/js/test/enso-differential.js new file mode 100644 index 00000000..31d3bdb4 --- /dev/null +++ b/js/test/enso-differential.js @@ -0,0 +1,68 @@ +// Differential fork test for the Enso Weiroll decoder. It uses no API credential: the same +// synthetic command program runs through Enso's deployed EIP-7702 VM and through Scripter. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { + createPublicClient, createWalletClient, encodeFunctionData, getContractAddress, http, + parseAbi, parseEther, toHex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { buildEnsoDelegateBatch } from "../enso.js"; + +const rpc = process.argv[2]; +if (!rpc) throw new Error("Usage: bun js/test/enso-differential.js ANVIL_FORK_RPC"); +const transport = http(rpc, {timeout: 120_000}); +const client = createPublicClient({transport}); +const account = privateKeyToAccount("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); +const wallet = createWalletClient({account, transport}); +const receiver = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; +const weth = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; +const ensoDelegate = "0x0aeb78d3f961b0394e4a3b94537b543e9e57bab1"; +const artifact = JSON.parse(readFileSync(new URL("../../out/7702Caller.sol/SevenSevenZeroTwoCaller.json", import.meta.url))); +const scripterDelegate = getContractAddress({ + opcode: "CREATE2", from: "0x4e59b44847b379578588920cA78FbF26c0B4956C", + salt: toHex(0x4d756c746963616c6c5363726970746572n, {size: 32}), bytecode: artifact.bytecode.object, +}); +assert.equal((await client.getCode({address: account.address})).toLowerCase(), `0xef0100${scripterDelegate.slice(2).toLowerCase()}`); +assert.ok((await client.getCode({address: ensoDelegate})).length > 2, "Enso EIP-7702 implementation is absent"); + +const shortcutAbi = parseAbi(["function executeShortcut(bytes32,bytes32,bytes32[],bytes[]) payable returns (bytes[])"]); +const erc20 = parseAbi(["function balanceOf(address) view returns (uint256)"]); +const zero = `0x${"00".repeat(32)}`; +const amount = parseEther("0.001"); +const word = (value) => `0x${BigInt(value).toString(16).padStart(64, "0")}`; +const addressWord = (value) => `0x${value.slice(2).padStart(64, "0")}`; +const command = (selector, flags, indices, output, target) => + `0x${selector.slice(2)}${flags.toString(16).padStart(2, "0")}${indices.map(i => i.toString(16).padStart(2, "0")).join("").padEnd(12, "f")}${output.toString(16).padStart(2, "0")}${target.slice(2)}`; +const commands = [ + command("0xd0e30db0", 0x03, [0], 0xff, weth), // WETH.deposit{value: amount}() + command("0x70a08231", 0x02, [1], 2, weth), // amount = WETH.balanceOf(account) + command("0xa9059cbb", 0x01, [3, 2], 0xff, weth), // WETH.transfer(receiver, amount) +]; +const state = [word(amount), addressWord(account.address), "0x", addressWord(receiver)]; +const data = encodeFunctionData({abi: shortcutAbi, functionName: "executeShortcut", args: [zero, zero, commands, state]}); +const route = {tx: {from: account.address, to: ensoDelegate, data, value: amount.toString()}}; +const {batch, value} = buildEnsoDelegateBatch(route, {caller: account.address, routingStrategy: "delegate"}); + +const snapshot = await client.request({method: "evm_snapshot"}); +const before = await client.readContract({address: weth, abi: erc20, functionName: "balanceOf", args: [receiver]}); +await client.request({method: "anvil_setCode", params: [account.address, `0xef0100${ensoDelegate.slice(2)}`]}); +const ensoReceipt = await client.waitForTransactionReceipt({hash: await wallet.sendTransaction({ + to: account.address, data, value, gas: 1_000_000n, chain: null, +})}); +assert.equal(ensoReceipt.status, "success"); +const ensoReceived = await client.readContract({address: weth, abi: erc20, functionName: "balanceOf", args: [receiver]}) - before; +assert.equal(await client.request({method: "evm_revert", params: [snapshot]}), true); + +const scripterBefore = await client.readContract({address: weth, abi: erc20, functionName: "balanceOf", args: [receiver]}); +const scripterReceipt = await client.waitForTransactionReceipt({hash: await wallet.writeContract({ + address: account.address, abi: artifact.abi, functionName: "execute", + args: [batch.targets, batch.offsets, batch.calldatas, batch.msgValues], value, gas: 1_000_000n, chain: null, +})}); +assert.equal(scripterReceipt.status, "success"); +const scripterReceived = await client.readContract({address: weth, abi: erc20, functionName: "balanceOf", args: [receiver]}) - scripterBefore; +assert.equal(scripterReceived, ensoReceived); +assert.equal(scripterReceived, amount); +const delta = scripterReceipt.gasUsed - ensoReceipt.gasUsed; +const percent = Number(delta * 10_000n / ensoReceipt.gasUsed) / 100; +console.log(`PASS same 3-call Weiroll plan: Enso VM ${ensoReceipt.gasUsed} gas; Scripter ${scripterReceipt.gasUsed} gas; ${delta} (${percent}%)`); diff --git a/js/test/enso-mainnet.js b/js/test/enso-mainnet.js index f2654233..d80c5dae 100644 --- a/js/test/enso-mainnet.js +++ b/js/test/enso-mainnet.js @@ -6,7 +6,7 @@ import { createPublicClient, createWalletClient, getContractAddress, http, parseAbi, parseEther, toHex, } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { buildEnsoRouterBatch } from "../enso.js"; +import { buildEnsoDelegateBatch } from "../enso.js"; const rpc = process.argv[2]; if (!rpc) throw new Error("Usage: ENSO_API_KEY=... bun js/test/enso-mainnet.js ANVIL_FORK_RPC"); @@ -28,6 +28,8 @@ const delegateAddress = getContractAddress({ salt: toHex(0x4d756c746963616c6c5363726970746572n, {size: 32}), bytecode: artifact.bytecode.object, }); +const ENSO_EIP7702 = "0x0aeb78d3f961b0394e4a3b94537b543e9e57bab1"; +assert.ok((await client.getCode({address: ENSO_EIP7702})).length > 2, "Enso EIP-7702 implementation is not deployed on this fork"); assert.equal((await client.getCode({address: account.address})).toLowerCase(), `0xef0100${delegateAddress.slice(2).toLowerCase()}`, "rehearsal account must already delegate to SevenSevenZeroTwoCaller"); @@ -43,7 +45,7 @@ for (const {symbol, token} of routes) { chainId: "1", fromAddress: account.address, receiver, - routingStrategy: "router", + routingStrategy: "delegate", tokenIn: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", tokenOut: token, amountIn: amountIn.toString(), @@ -55,22 +57,30 @@ for (const {symbol, token} of routes) { }); if (!response.ok) throw new Error(`Enso ${symbol} route request failed with HTTP ${response.status}`); const route = await response.json(); - const {batch, value} = buildEnsoRouterBatch(route, {caller: account.address, routingStrategy: "router"}); + const {batch, value, commandCount} = buildEnsoDelegateBatch(route, { + caller: account.address, routingStrategy: "delegate", + }); assert.equal(value, amountIn, "Enso route must forward exactly the requested native input"); - // Execute the exact response directly and through Scripter from identical Anvil state. + // Execute the same Weiroll program through Enso's EIP-7702 VM and through Scripter from + // identical state. anvil_setCode changes only the local fork and is reverted with the snapshot. const snapshot = await client.request({method: "evm_snapshot"}); + await client.request({method: "anvil_setCode", params: [account.address, `0xef0100${ENSO_EIP7702.slice(2)}`]}); const before = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}); - let directGas = 0n; - const directTransactions = [...(route.preTransactions ?? []).map(entry => entry.tx), route.tx]; - for (const tx of directTransactions) { - const directReceipt = await client.waitForTransactionReceipt({hash: await wallet.sendTransaction({ + let ensoGas = 0n; + for (const tx of (route.preTransactions ?? []).map(entry => entry.tx)) { + const preReceipt = await client.waitForTransactionReceipt({hash: await wallet.sendTransaction({ to: tx.to, data: tx.data, value: BigInt(tx.value ?? 0), gas: 3_000_000n, chain: null, })}); - assert.equal(directReceipt.status, "success"); - directGas += directReceipt.gasUsed; + assert.equal(preReceipt.status, "success"); + ensoGas += preReceipt.gasUsed; } - const directReceived = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}) - before; + const ensoReceipt = await client.waitForTransactionReceipt({hash: await wallet.sendTransaction({ + to: account.address, data: route.tx.data, value: BigInt(route.tx.value ?? 0), gas: 3_000_000n, chain: null, + })}); + assert.equal(ensoReceipt.status, "success"); + ensoGas += ensoReceipt.gasUsed; + const ensoReceived = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}) - before; assert.equal(await client.request({method: "evm_revert", params: [snapshot]}), true); const wrappedBefore = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}); @@ -86,9 +96,9 @@ for (const {symbol, token} of routes) { assert.equal(receipt.status, "success"); const received = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}) - wrappedBefore; const minimum = Array.isArray(route.minAmountOut) ? BigInt(route.minAmountOut[0]) : BigInt(route.minAmountOut); - assert.equal(received, directReceived, "direct and wrapped execution must produce identical output from identical state"); + assert.equal(received, ensoReceived, "Enso and Scripter execution must produce identical output from identical state"); assert.ok(received >= minimum, "received output must satisfy Enso's minAmountOut"); - const delta = receipt.gasUsed - directGas; - const percent = Number(delta * 10_000n / directGas) / 100; - console.log(`PASS Enso ETH → ${symbol}: direct ${directGas} gas; Scripter ${receipt.gasUsed} gas; ${delta >= 0n ? "+" : ""}${delta} (${percent}%); output ${received}`); + const delta = receipt.gasUsed - ensoGas; + const percent = Number(delta * 10_000n / ensoGas) / 100; + console.log(`PASS Enso ETH → ${symbol}: ${commandCount} calls; Enso VM ${ensoGas} gas; Scripter ${receipt.gasUsed} gas; ${delta >= 0n ? "+" : ""}${delta} (${percent}%); output ${received}`); } diff --git a/js/test/enso.test.js b/js/test/enso.test.js index 05c35197..e5a25bd5 100644 --- a/js/test/enso.test.js +++ b/js/test/enso.test.js @@ -1,14 +1,28 @@ import { describe, expect, test } from "bun:test"; -import { decodeValueIndex, CALL_FLAG } from "../encoding.js"; -import { buildEnsoRouterBatch } from "../enso.js"; +import { encodeFunctionData, parseAbi } from "viem"; +import { decodeMemTarget, decodeResultLength } from "../encoding.js"; +import { buildEnsoDelegateBatch } from "../enso.js"; const CALLER = "0x00000000000000000000000000000000000000AA"; -const APPROVAL = "0x00000000000000000000000000000000000000bb"; -const ROUTER = "0x00000000000000000000000000000000000000cc"; +const TOKEN = "0x00000000000000000000000000000000000000bb"; +const RECEIVER = "0x00000000000000000000000000000000000000cc"; +const ABI = parseAbi(["function executeShortcut(bytes32,bytes32,bytes32[],bytes[]) payable returns (bytes[])"]); +const ZERO = `0x${"00".repeat(32)}`; +const stateWord = (value) => `0x${BigInt(value).toString(16).padStart(64, "0")}`; +const addressWord = (value) => `0x${value.slice(2).padStart(64, "0")}`; + +function command(selector, flags, indices, output, target = TOKEN) { + return `0x${selector.slice(2)}${flags.toString(16).padStart(2, "0")}${indices.map(i => i.toString(16).padStart(2, "0")).join("").padEnd(12, "f")}${output.toString(16).padStart(2, "0")}${target.slice(2)}`; +} + +function response(commands, state, value = 0n) { + return {tx: {from: CALLER, to: TOKEN, value: value.toString(), data: encodeFunctionData({ + abi: ABI, functionName: "executeShortcut", args: [ZERO, ZERO, commands, state], + })}}; +} function frames(packed) { - const bytes = Buffer.from(packed.slice(2), "hex"); - const result = []; + const bytes = Buffer.from(packed.slice(2), "hex"), result = []; for (let cursor = 0; cursor < bytes.length;) { const length = Number(BigInt(`0x${bytes.subarray(cursor, cursor + 32).toString("hex")}`)); cursor += 32; @@ -18,45 +32,55 @@ function frames(packed) { return result; } -describe("Enso router adapter", () => { - test("preserves pre-transaction order, calldata, targets, and ETH values", () => { - const response = { - preTransactions: [{tx: {from: CALLER, to: APPROVAL, data: "0x095ea7b3", value: "0"}}], - tx: {from: CALLER, to: ROUTER, data: "0x1234abcd00", value: "0x2a"}, - }; - const {batch, value} = buildEnsoRouterBatch(response, {caller: CALLER, routingStrategy: "router"}); - expect(batch.targets).toEqual([APPROVAL, ROUTER]); - expect(frames(batch.calldatas)).toEqual(["0x095ea7b3", "0x1234abcd00"]); - expect(batch.msgValues).toEqual([42n]); - expect(decodeValueIndex(batch.offsets[0], Number(CALL_FLAG))).toBe(0); - expect(decodeValueIndex(batch.offsets[1], Number(CALL_FLAG))).toBe(1); - expect(value).toBe(42n); - }); - - test("accepts a route without pre-transactions", () => { - const {batch, value} = buildEnsoRouterBatch({ - tx: {from: CALLER.toLowerCase(), to: ROUTER.toLowerCase(), data: "0x", value: "0"}, - }, {caller: CALLER, routingStrategy: "router"}); - expect(batch.targets).toEqual([ROUTER]); - expect(frames(batch.calldatas)).toEqual(["0x"]); +describe("Enso delegate translator", () => { + test("removes the Enso executor and wires a scalar result into the next protocol call", () => { + const balanceOf = command("0x70a08231", 0x02, [0], 2); + const transfer = command("0xa9059cbb", 0x01, [1, 2], 0xff); + const {batch, value, commandCount} = buildEnsoDelegateBatch( + response([balanceOf, transfer], [addressWord(CALLER), addressWord(RECEIVER), "0x"]), + {caller: CALLER, routingStrategy: "delegate"}, + ); + expect(batch.targets).toEqual([TOKEN, TOKEN]); + expect(frames(batch.calldatas)).toEqual([ + `0x70a08231${CALLER.slice(2).padStart(64, "0").toLowerCase()}`, + `0xa9059cbb${RECEIVER.slice(2).padStart(64, "0").toLowerCase()}${"00".repeat(32)}`, + ]); + expect(decodeMemTarget(batch.offsets[0])).toBe(36n); + expect(decodeResultLength(batch.offsets[0])).toBe(32n); expect(batch.msgValues).toEqual([]); expect(value).toBe(0n); + expect(commandCount).toBe(2); + }); + + test("reconstructs literal dynamic arguments and fixed call value", () => { + const dynamic = `0x${stateWord(3).slice(2)}010203${"00".repeat(29)}`; + const call = command("0x12345678", 0x03, [0, 0x81], 0xff); + const {batch, value} = buildEnsoDelegateBatch(response([call], [stateWord(7), dynamic], 7n), { + caller: CALLER, routingStrategy: "delegate", + }); + expect(frames(batch.calldatas)).toEqual([`0x12345678${stateWord(32).slice(2)}${dynamic.slice(2)}`]); + expect(batch.msgValues).toEqual([7n]); + expect(value).toBe(7n); }); - test("requires the routing strategy to be stated explicitly", () => { - const response = {tx: {from: CALLER, to: ROUTER, data: "0x", value: "0"}}; - expect(() => buildEnsoRouterBatch(response, {caller: CALLER})).toThrow(/Only Enso router/); + test("supports extended commands", () => { + const first = command("0x12345678", 0x41, [], 0xff); + const indices = `0x${[0, 1, 2, 3, 4, 5, 6].map(i => i.toString(16).padStart(2, "0")).join("").padEnd(64, "f")}`; + const {batch} = buildEnsoDelegateBatch(response([first, indices], Array.from({length: 7}, (_, i) => stateWord(i))), { + caller: CALLER, routingStrategy: "delegate", + }); + expect(frames(batch.calldatas)[0]).toBe(`0x12345678${Array.from({length: 7}, (_, i) => stateWord(i).slice(2)).join("")}`); }); test.each([ - ["main caller mismatch", {tx: {from: APPROVAL, to: ROUTER, data: "0x", value: "0"}}, {}], - ["pre-transaction caller mismatch", {preTransactions: [{tx: {from: APPROVAL, to: ROUTER, data: "0x", value: "0"}}], tx: {from: CALLER, to: ROUTER, data: "0x", value: "0"}}, {}], - ["delegate response", {tx: {from: CALLER, to: ROUTER, data: "0x", value: "0"}}, {routingStrategy: "delegate"}], - ["malformed calldata", {tx: {from: CALLER, to: ROUTER, data: "0x123", value: "0"}}, {}], - ["negative value", {tx: {from: CALLER, to: ROUTER, data: "0x", value: "-1"}}, {}], - ["missing main transaction", {}, {}], - ["malformed pre-transactions", {preTransactions: {}, tx: {from: CALLER, to: ROUTER, data: "0x", value: "0"}}, {}], - ])("rejects %s", (_, response, options) => { - expect(() => buildEnsoRouterBatch(response, {caller: CALLER, routingStrategy: "router", ...options})).toThrow(); + ["router strategy", response([], []), {routingStrategy: "router"}], + ["non-shortcut calldata", {tx: {from: CALLER, to: TOKEN, value: "0", data: "0x12345678"}}, {}], + ["delegatecall", response([command("0x12345678", 0x00, [], 0xff)], []), {}], + ["computed value", response([command("0x12345678", 0x01, [], 0), command("0x12345678", 0x03, [0], 0xff)], [ZERO]), {}], + ["dynamic return consumer", response([command("0x12345678", 0x01, [], 0x80), command("0x12345678", 0x01, [0x80], 0xff)], ["0x"]), {}], + ["state replacement", response([command("0x12345678", 0x01, [], 0xfe)], []), {}], + ["composite input", response([command("0x12345678", 0x01, [0xfd], 0xff)], []), {}], + ])("rejects %s", (_, route, options) => { + expect(() => buildEnsoDelegateBatch(route, {caller: CALLER, routingStrategy: "delegate", ...options})).toThrow(); }); }); diff --git a/script/rehearse.sh b/script/rehearse.sh index b4335cf6..48a4f04e 100755 --- a/script/rehearse.sh +++ b/script/rehearse.sh @@ -49,18 +49,20 @@ if [[ "$FORK_READY" != true ]]; then fi printf 'Mainnet fork block: %s; local chain: 31337; RPC: %s\n' "$FORK_BLOCK" "$FORK_LOCAL" # Broadcast only to the Anvil process created above. No mainnet signer or private key is loaded. -echo '[1/4] Deploying and checking release bytecode' +echo '[1/5] Deploying and checking release bytecode' DEPLOY_7702=true script/deploy.sh "$FORK_LOCAL" --rpc-timeout 30 --sender "$FORK_SENDER" --unlocked --broadcast # Idempotence: the second run must accept the existing exact bytecode and send no deployments. DEPLOY_7702=true script/deploy.sh "$FORK_LOCAL" --rpc-timeout 30 --sender "$FORK_SENDER" --unlocked --broadcast -echo '[2/4] Running protocol and EIP-7702 rehearsal' +echo '[2/5] Running protocol and EIP-7702 rehearsal' bun js/test/mainnet.js "$FORK_LOCAL" +echo '[3/5] Comparing Enso Weiroll and Scripter on the same command plan' +bun js/test/enso-differential.js "$FORK_LOCAL" if [[ -n "${ENSO_API_KEY:-}" ]]; then - echo '[3/4] Comparing live Enso routes directly and through Scripter' + echo '[4/5] Translating live Enso routes and comparing executors' bun js/test/enso-mainnet.js "$FORK_LOCAL" else - echo '[3/4] SKIP Enso live route (ENSO_API_KEY is not exported)' + echo '[4/5] SKIP Enso live route (ENSO_API_KEY is not exported)' fi # Use the upstream directly: nested forking through Anvil can serialize remote storage fetches. -echo '[4/4] Running Solidity fork tests' +echo '[5/5] Running Solidity fork tests' ETH_RPC_URL="$FORK_UPSTREAM" FORK_BLOCK="$FORK_BLOCK" forge test --match-contract CallBuilderTest --threads 1 -vv From a7e1d32de82dd58ba431c02173a1a8e12f40fb40 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+0xdewy@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:01:10 -0700 Subject: [PATCH 4/8] Stress Enso translation with complex routes --- README.md | 8 +- js/test/enso-mainnet.js | 158 +++++++++++++++++++++++++++------------- 2 files changed, 113 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index a701f759..24f1a7f1 100644 --- a/README.md +++ b/README.md @@ -298,10 +298,12 @@ prints the fork block, deploys all three contracts, checks a second deployment r - a real EIP-7702 authorization, self-call, signed relayed batch and rejected replay work; - a deployment rerun rejects mismatched code, and both Solidity mainnet-fork swaps pass. -If `ENSO_API_KEY` is already exported, the rehearsal also requests live Enso `delegate` -ETH → USDC and ETH → DAI routes. It executes each underlying command program through Enso's +If `ENSO_API_KEY` is already exported, the rehearsal also requests live Enso `delegate` routes. +It executes each underlying command program through Enso's EIP-7702 VM and its translated Scripter batch from identical fork state, asserts identical output -and `minAmountOut`, and reports the executor gas difference. The script never sources `.env`; +and `minAmountOut`, and reports the executor gas difference. The live matrix also tries vault and +LP zaps plus 2, 4, and 8-leg swap bundles, reporting the largest translated program and the first +unsupported Weiroll feature instead of hiding the boundary. The script never sources `.env`; `.env` is ignored by Git. Without the exported key, this optional live check reports `SKIP`. The process uses public Anvil test accounts and sends transactions only to its own local node. diff --git a/js/test/enso-mainnet.js b/js/test/enso-mainnet.js index d80c5dae..9b444d28 100644 --- a/js/test/enso-mainnet.js +++ b/js/test/enso-mainnet.js @@ -1,4 +1,4 @@ -// Optional authenticated Enso route rehearsal. The caller supplies ENSO_API_KEY in the process +// Optional authenticated Enso stress rehearsal. The caller supplies ENSO_API_KEY in the process // environment; this script never reads dotenv files or prints the key. import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; @@ -23,82 +23,140 @@ const account = privateKeyToAccount("0xac0974bec39a17e36ba4a6b4d238ff944bacb478c const wallet = createWalletClient({account, transport}); const artifact = JSON.parse(readFileSync(new URL("../../out/7702Caller.sol/SevenSevenZeroTwoCaller.json", import.meta.url))); const delegateAddress = getContractAddress({ - opcode: "CREATE2", - from: "0x4e59b44847b379578588920cA78FbF26c0B4956C", - salt: toHex(0x4d756c746963616c6c5363726970746572n, {size: 32}), - bytecode: artifact.bytecode.object, + opcode: "CREATE2", from: "0x4e59b44847b379578588920cA78FbF26c0B4956C", + salt: toHex(0x4d756c746963616c6c5363726970746572n, {size: 32}), bytecode: artifact.bytecode.object, }); const ENSO_EIP7702 = "0x0aeb78d3f961b0394e4a3b94537b543e9e57bab1"; assert.ok((await client.getCode({address: ENSO_EIP7702})).length > 2, "Enso EIP-7702 implementation is not deployed on this fork"); assert.equal((await client.getCode({address: account.address})).toLowerCase(), - `0xef0100${delegateAddress.slice(2).toLowerCase()}`, "rehearsal account must already delegate to SevenSevenZeroTwoCaller"); + `0xef0100${delegateAddress.slice(2).toLowerCase()}`, "rehearsal account must delegate to SevenSevenZeroTwoCaller"); const amountIn = parseEther("0.005"); const receiver = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; +const ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; +const TOKENS = { + WETH: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + DAI: "0x6B175474E89094C44Da98b954EedeAC495271d0F", + USDT: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + FRAX: "0x853d955aCEf822Db058eb8505911ED77F175b99e", + yvWETH: "0xa258C4606Ca8206D8aA700cE2143D7db854D168c", + threeCRV: "0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490", +}; const erc20 = parseAbi(["function balanceOf(address) view returns (uint256)"]); -const routes = [ - {symbol: "USDC", token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}, - {symbol: "DAI", token: "0x6B175474E89094C44Da98b954EedeAC495271d0F"}, -]; -for (const {symbol, token} of routes) { +const headers = {Authorization: `Bearer ${apiKey}`, Accept: "application/json"}; + +async function json(response, label) { + if (!response.ok) { + const detail = (await response.text()).slice(0, 240).replaceAll(/\s+/g, " "); + throw new Error(`${label} request failed with HTTP ${response.status}: ${detail}`); + } + return response.json(); +} + +async function route(label, tokenOut) { const query = new URLSearchParams({ - chainId: "1", - fromAddress: account.address, - receiver, - routingStrategy: "delegate", - tokenIn: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", - tokenOut: token, - amountIn: amountIn.toString(), - slippage: "100", + chainId: "1", fromAddress: account.address, receiver, spender: account.address, + refundReceiver: account.address, routingStrategy: "delegate", tokenIn: ETH, tokenOut, + amountIn: amountIn.toString(), slippage: "100", }); - const response = await fetch(`https://api.enso.build/api/v1/shortcuts/route?${query}`, { - headers: {Authorization: `Bearer ${apiKey}`, Accept: "application/json"}, - signal: AbortSignal.timeout(30_000), - }); - if (!response.ok) throw new Error(`Enso ${symbol} route request failed with HTTP ${response.status}`); - const route = await response.json(); - const {batch, value, commandCount} = buildEnsoDelegateBatch(route, { - caller: account.address, routingStrategy: "delegate", + return json(await fetch(`https://api.enso.build/api/v1/shortcuts/route?${query}`, { + headers, signal: AbortSignal.timeout(30_000), + }), label); +} + +async function bundle(label, path) { + const query = new URLSearchParams({ + chainId: "1", fromAddress: account.address, routingStrategy: "delegate", + receiver, spender: account.address, refundReceiver: account.address, }); - assert.equal(value, amountIn, "Enso route must forward exactly the requested native input"); + const actions = path.slice(1).map((tokenOut, i) => ({ + protocol: "enso", action: "route", args: { + tokenIn: path[i], tokenOut, + amountIn: i === 0 ? amountIn.toString() : {useOutputOfCallAt: i - 1}, + receiver: i === path.length - 2 ? receiver : account.address, + slippage: "100", + }, + })); + return json(await fetch(`https://api.enso.build/api/v1/shortcuts/bundle?${query}`, { + method: "POST", headers: {...headers, "Content-Type": "application/json"}, + body: JSON.stringify(actions), signal: AbortSignal.timeout(45_000), + }), label); +} - // Execute the same Weiroll program through Enso's EIP-7702 VM and through Scripter from - // identical state. anvil_setCode changes only the local fork and is reverted with the snapshot. +async function compare(label, outputToken, response, required) { + const routeSteps = Array.isArray(response.route) ? response.route.length : 0; + const txBytes = (response.tx?.data?.length - 2) / 2; + let translated; + try { + translated = buildEnsoDelegateBatch(response, {caller: account.address, routingStrategy: "delegate"}); + } catch (error) { + const message = `LIMIT ${label}: ${routeSteps} route steps; ${txBytes} tx bytes; ${error.message}`; + if (required) throw new Error(message); + console.log(message); + return null; + } + const {batch, value, commandCount} = translated; const snapshot = await client.request({method: "evm_snapshot"}); await client.request({method: "anvil_setCode", params: [account.address, `0xef0100${ENSO_EIP7702.slice(2)}`]}); - const before = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}); + const before = await client.readContract({address: outputToken, abi: erc20, functionName: "balanceOf", args: [receiver]}); let ensoGas = 0n; - for (const tx of (route.preTransactions ?? []).map(entry => entry.tx)) { - const preReceipt = await client.waitForTransactionReceipt({hash: await wallet.sendTransaction({ - to: tx.to, data: tx.data, value: BigInt(tx.value ?? 0), gas: 3_000_000n, chain: null, + for (const tx of (response.preTransactions ?? []).map(entry => entry.tx)) { + const receipt = await client.waitForTransactionReceipt({hash: await wallet.sendTransaction({ + to: tx.to, data: tx.data, value: BigInt(tx.value ?? 0), gas: 20_000_000n, chain: null, })}); - assert.equal(preReceipt.status, "success"); - ensoGas += preReceipt.gasUsed; + assert.equal(receipt.status, "success"); + ensoGas += receipt.gasUsed; } const ensoReceipt = await client.waitForTransactionReceipt({hash: await wallet.sendTransaction({ - to: account.address, data: route.tx.data, value: BigInt(route.tx.value ?? 0), gas: 3_000_000n, chain: null, + to: account.address, data: response.tx.data, value: BigInt(response.tx.value ?? 0), gas: 20_000_000n, chain: null, })}); assert.equal(ensoReceipt.status, "success"); ensoGas += ensoReceipt.gasUsed; - const ensoReceived = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}) - before; + const ensoReceived = await client.readContract({address: outputToken, abi: erc20, functionName: "balanceOf", args: [receiver]}) - before; assert.equal(await client.request({method: "evm_revert", params: [snapshot]}), true); - const wrappedBefore = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}); + const scripterBefore = await client.readContract({address: outputToken, abi: erc20, functionName: "balanceOf", args: [receiver]}); const receipt = await client.waitForTransactionReceipt({hash: await wallet.writeContract({ - address: account.address, - abi: artifact.abi, - functionName: "execute", + address: account.address, abi: artifact.abi, functionName: "execute", args: [batch.targets, batch.offsets, batch.calldatas, batch.msgValues], - value, - gas: 3_000_000n, - chain: null, + value, gas: 20_000_000n, chain: null, })}); assert.equal(receipt.status, "success"); - const received = await client.readContract({address: token, abi: erc20, functionName: "balanceOf", args: [receiver]}) - wrappedBefore; - const minimum = Array.isArray(route.minAmountOut) ? BigInt(route.minAmountOut[0]) : BigInt(route.minAmountOut); - assert.equal(received, ensoReceived, "Enso and Scripter execution must produce identical output from identical state"); - assert.ok(received >= minimum, "received output must satisfy Enso's minAmountOut"); + const received = await client.readContract({address: outputToken, abi: erc20, functionName: "balanceOf", args: [receiver]}) - scripterBefore; + assert.equal(received, ensoReceived, `${label}: Enso and Scripter output differ`); + if (response.minAmountOut !== undefined) { + const minimum = Array.isArray(response.minAmountOut) ? BigInt(response.minAmountOut[0]) : BigInt(response.minAmountOut); + assert.ok(received >= minimum, `${label}: output is below minAmountOut`); + } const delta = receipt.gasUsed - ensoGas; const percent = Number(delta * 10_000n / ensoGas) / 100; - console.log(`PASS Enso ETH → ${symbol}: ${commandCount} calls; Enso VM ${ensoGas} gas; Scripter ${receipt.gasUsed} gas; ${delta >= 0n ? "+" : ""}${delta} (${percent}%); output ${received}`); + console.log(`PASS ${label}: ${routeSteps} route steps; ${commandCount} calls; ${txBytes} tx bytes; Enso VM ${ensoGas} gas; Scripter ${receipt.gasUsed} gas; ${delta} (${percent}%); output ${received}`); + return {label, commandCount, txBytes, ensoGas, scripterGas: receipt.gasUsed}; +} + +const cases = [ + {label: "ETH → USDC", output: TOKENS.USDC, required: true, load: () => route("ETH → USDC", TOKENS.USDC)}, + {label: "ETH → yvWETH vault", output: TOKENS.yvWETH, load: () => route("ETH → yvWETH", TOKENS.yvWETH)}, + {label: "ETH → 3CRV LP", output: TOKENS.threeCRV, load: () => route("ETH → 3CRV", TOKENS.threeCRV)}, + {label: "2-leg ETH → USDC → DAI", output: TOKENS.DAI, + load: () => bundle("2-leg bundle", [ETH, TOKENS.USDC, TOKENS.DAI])}, + {label: "4-leg ETH → USDC → DAI → WETH → USDT", output: TOKENS.USDT, + load: () => bundle("4-leg bundle", [ETH, TOKENS.USDC, TOKENS.DAI, TOKENS.WETH, TOKENS.USDT])}, + {label: "8-leg swap chain", output: TOKENS.USDC, + load: () => bundle("8-leg bundle", [ETH, TOKENS.USDC, TOKENS.DAI, TOKENS.WETH, TOKENS.USDT, TOKENS.FRAX, TOKENS.WETH, TOKENS.DAI, TOKENS.USDC])}, +]; + +const results = []; +for (const testCase of cases) { + try { + const result = await compare(testCase.label, testCase.output, await testCase.load(), testCase.required ?? false); + if (result) results.push(result); + } catch (error) { + if (testCase.required) throw error; + console.log(`SKIP ${testCase.label}: ${error.message}`); + } } +assert.ok(results.length > 0, "no Enso route was translated and executed"); +const largest = results.reduce((a, b) => b.commandCount > a.commandCount ? b : a); +console.log(`UPPER TESTED LIMIT: ${largest.label}; ${largest.commandCount} direct calls; ${largest.txBytes} Enso calldata bytes; Scripter ${largest.scripterGas} gas`); From 44026d0abe1c605110fcd109b415c36a88c09096 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+0xdewy@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:15:58 -0700 Subject: [PATCH 5/8] Load Enso rehearsal key from gitignored dotenv --- README.md | 5 +++-- js/test/enso-mainnet.js | 4 ++-- script/rehearse.sh | 5 ++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 24f1a7f1..2a24b63e 100644 --- a/README.md +++ b/README.md @@ -303,8 +303,9 @@ It executes each underlying command program through Enso's EIP-7702 VM and its translated Scripter batch from identical fork state, asserts identical output and `minAmountOut`, and reports the executor gas difference. The live matrix also tries vault and LP zaps plus 2, 4, and 8-leg swap bundles, reporting the largest translated program and the first -unsupported Weiroll feature instead of hiding the boundary. The script never sources `.env`; -`.env` is ignored by Git. Without the exported key, this optional live check reports `SKIP`. +unsupported Weiroll feature instead of hiding the boundary. The script accepts an exported key or +passes an existing `.env` directly to Bun without sourcing it into the shell or printing it. +`.env` is ignored by Git. Without either source, this optional live check reports `SKIP`. The process uses public Anvil test accounts and sends transactions only to its own local node. Historical blocks may require an archive RPC; public endpoints can impose rate or history limits. diff --git a/js/test/enso-mainnet.js b/js/test/enso-mainnet.js index 9b444d28..332a713a 100644 --- a/js/test/enso-mainnet.js +++ b/js/test/enso-mainnet.js @@ -1,5 +1,5 @@ -// Optional authenticated Enso stress rehearsal. The caller supplies ENSO_API_KEY in the process -// environment; this script never reads dotenv files or prints the key. +// Optional authenticated Enso stress rehearsal. rehearse.sh supplies ENSO_API_KEY from its +// environment or through Bun's --env-file; this script never prints the key. import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { diff --git a/script/rehearse.sh b/script/rehearse.sh index 48a4f04e..c4252fcb 100755 --- a/script/rehearse.sh +++ b/script/rehearse.sh @@ -60,8 +60,11 @@ bun js/test/enso-differential.js "$FORK_LOCAL" if [[ -n "${ENSO_API_KEY:-}" ]]; then echo '[4/5] Translating live Enso routes and comparing executors' bun js/test/enso-mainnet.js "$FORK_LOCAL" +elif [[ -f .env ]]; then + echo '[4/5] Translating live Enso routes and comparing executors (credentials from .env)' + bun --env-file=.env js/test/enso-mainnet.js "$FORK_LOCAL" else - echo '[4/5] SKIP Enso live route (ENSO_API_KEY is not exported)' + echo '[4/5] SKIP Enso live route (ENSO_API_KEY is neither exported nor present in .env)' fi # Use the upstream directly: nested forking through Anvil can serialize remote storage fetches. echo '[5/5] Running Solidity fork tests' From 882978b1fd8a2c21ecf69f282e05316903d51c73 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+0xdewy@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:20:23 -0700 Subject: [PATCH 6/8] Allow Enso routes to spend delegated account ETH --- README.md | 2 ++ js/enso.js | 2 -- js/test/enso.test.js | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2a24b63e..c77d02ff 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ computed ETH values, state replacement, and Weiroll's composite state indices. T route-dependent experiment until the exact live response passes simulation and differential execution against Enso's VM. Enso also requires a consumed scalar return to be exactly 32 bytes; Scripter requires at least 32, so simulation is the compatibility check for each concrete route. +The returned outer `value` follows Enso's transaction, while individual calls may spend the EOA's +existing ETH balance; inspect `batch.msgValues` when enforcing a maximum spend in a wallet UI. For ERC-20 input, approvals and funding must belong to the execution account. A public bare executor must receive tokens inside the same atomic batch and must not retain approvals or assets. diff --git a/js/enso.js b/js/enso.js index 54ac4f6a..9b652d4e 100644 --- a/js/enso.js +++ b/js/enso.js @@ -216,8 +216,6 @@ export function buildEnsoDelegateBatch(response, {caller, routingStrategy}) { : callPartialReturn(valueIndex, destinations, lengths, sources, 32); }); const requiredValue = preTransactions.reduce((sum, tx) => sum + tx.value, main.value); - const spentValue = msgValues.reduce((sum, value) => sum + value, 0n); - if (spentValue > requiredValue) throw new Error("Enso commands spend more ETH than the delegate transaction supplies"); return {batch: {targets, offsets, calldatas: packFrames(calls), msgValues}, value: requiredValue, commandCount: calls.length - preTransactions.length}; } diff --git a/js/test/enso.test.js b/js/test/enso.test.js index e5a25bd5..e9688b21 100644 --- a/js/test/enso.test.js +++ b/js/test/enso.test.js @@ -55,12 +55,13 @@ describe("Enso delegate translator", () => { test("reconstructs literal dynamic arguments and fixed call value", () => { const dynamic = `0x${stateWord(3).slice(2)}010203${"00".repeat(29)}`; const call = command("0x12345678", 0x03, [0, 0x81], 0xff); - const {batch, value} = buildEnsoDelegateBatch(response([call], [stateWord(7), dynamic], 7n), { + // Delegate routes may spend the EOA's existing balance with tx.value == 0. + const {batch, value} = buildEnsoDelegateBatch(response([call], [stateWord(7), dynamic]), { caller: CALLER, routingStrategy: "delegate", }); expect(frames(batch.calldatas)).toEqual([`0x12345678${stateWord(32).slice(2)}${dynamic.slice(2)}`]); expect(batch.msgValues).toEqual([7n]); - expect(value).toBe(7n); + expect(value).toBe(0n); }); test("supports extended commands", () => { From 219a5c5438f4685c0bddb913b1c39eb116c290c0 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+0xdewy@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:09:31 -0700 Subject: [PATCH 7/8] Expand Enso scalar fan-out through identity precompile --- README.md | 2 ++ js/enso.js | 56 +++++++++++++++++++++++++++++------- js/test/enso-differential.js | 6 +++- js/test/enso-mainnet.js | 4 +-- js/test/enso.test.js | 14 +++++++++ 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c77d02ff..77d2ebfc 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ computed ETH values, state replacement, and Weiroll's composite state indices. T route-dependent experiment until the exact live response passes simulation and differential execution against Enso's VM. Enso also requires a consumed scalar return to be exactly 32 bytes; Scripter requires at least 32, so simulation is the compatibility check for each concrete route. +When one scalar feeds more than three arguments, the adapter expands the fan-out through the +identity precompile; this preserves the fixed offset schema and avoids another deployed contract. The returned outer `value` follows Enso's transaction, while individual calls may spend the EOA's existing ETH balance; inspect `batch.msgValues` when enforcing a maximum spend in a wallet UI. diff --git a/js/enso.js b/js/enso.js index 9b652d4e..78f70633 100644 --- a/js/enso.js +++ b/js/enso.js @@ -9,6 +9,7 @@ const DATA = 0x20, EXTENDED = 0x40, TUPLE_RETURN = 0x80; const VARIABLE = 0x80, VALUE_MASK = 0x7f, END = 0xff; const USE_STATE = 0xfe, ARRAY_START = 0xfd, TUPLE_START = 0xfc, DYNAMIC_END = 0xfb; const SPECIAL_INDICES = new Set([USE_STATE, ARRAY_START, TUPLE_START, DYNAMIC_END]); +const IDENTITY_PRECOMPILE = "0x0000000000000000000000000000000000000004"; const strip0x = (value) => value.slice(2); const byteLength = (value) => strip0x(value).length / 2; const pad32 = (n) => Math.ceil(n / 32) * 32; @@ -190,23 +191,57 @@ export function buildEnsoDelegateBatch(response, {caller, routingStrategy}) { slots[outputSlot] = {data: "0x", producer: callIndex, dynamic: Boolean((flags & TUPLE_RETURN) || (output & VARIABLE))}; } - const patches = calls.map(() => []); + // A producer can name three destinations in one offset word. For larger fan-out, insert + // 32-byte identity-precompile relays immediately after it. Each relay consumes one destination + // and creates three more without changing the value or relying on a deployed helper contract. + const uses = calls.map(() => []); calls.forEach((call, consumer) => { for (const dependency of call.dependencies) { const producer = calls[dependency.producer]; if (!producer || dependency.producer >= consumer) throw new Error("Enso command dependency is not produced by an earlier call"); - const memTarget = calls.slice(dependency.producer + 1, consumer) - .reduce((sum, item) => sum + regionSize(item.data), 0) + dependency.position; - patches[dependency.producer].push(memTarget); + uses[dependency.producer].push({consumer, position: dependency.position}); } }); - const targets = calls.map(call => call.target); + const expanded = [], oldToNew = [], relays = []; + calls.forEach((call, oldIndex) => { + oldToNew[oldIndex] = expanded.length; + expanded.push(call); + const relayIndexes = []; + const relayCount = uses[oldIndex].length > 3 ? Math.ceil((uses[oldIndex].length - 3) / 2) : 0; + for (let i = 0; i < relayCount; i++) { + relayIndexes.push(expanded.length); + expanded.push({ + target: IDENTITY_PRECOMPILE, data: "0x" + "00".repeat(32), value: 0n, + isStatic: true, dependencies: [], + }); + } + relays[oldIndex] = relayIndexes; + }); + const patches = expanded.map(() => []); + uses.forEach((producerUses, oldProducer) => { + const sources = [oldToNew[oldProducer], ...relays[oldProducer]]; + let cursor = 0; + sources.forEach((source, level) => { + const hasNext = level + 1 < sources.length; + const directCount = Math.min(hasNext ? 2 : 3, producerUses.length - cursor); + for (let i = 0; i < directCount; i++, cursor++) { + const use = producerUses[cursor]; + patches[source].push({consumer: oldToNew[use.consumer], position: use.position}); + } + if (hasNext) patches[source].push({consumer: sources[level + 1], position: 0}); + }); + if (cursor !== producerUses.length) throw new Error("Could not expand Enso return-value fan-out"); + }); + const memTargets = patches.map((destinations, producer) => destinations.map(destination => + expanded.slice(producer + 1, destination.consumer) + .reduce((sum, item) => sum + regionSize(item.data), 0) + destination.position)); + const targets = expanded.map(call => call.target); const msgValues = []; - const offsets = calls.map((call, i) => { + const offsets = expanded.map((call, i) => { let valueIndex = 0; if (call.value > 0n) { msgValues.push(call.value); valueIndex = msgValues.length; } - const destinations = patches[i]; - if (destinations.length > 3) throw new Error(`Enso call #${i} feeds ${destinations.length} arguments; Scripter supports at most 3`); + const destinations = memTargets[i]; + if (destinations.length > 3) throw new Error("Internal fan-out expansion exceeded three destinations"); if (destinations.length === 0) return call.isStatic ? staticCall(0, 0) : stateChangingCall(valueIndex); if (destinations.length === 1) return call.isStatic ? staticCall(destinations[0], 32) : stateChangingCall(valueIndex, destinations[0], 32); @@ -216,6 +251,7 @@ export function buildEnsoDelegateBatch(response, {caller, routingStrategy}) { : callPartialReturn(valueIndex, destinations, lengths, sources, 32); }); const requiredValue = preTransactions.reduce((sum, tx) => sum + tx.value, main.value); - return {batch: {targets, offsets, calldatas: packFrames(calls), msgValues}, value: requiredValue, - commandCount: calls.length - preTransactions.length}; + return {batch: {targets, offsets, calldatas: packFrames(expanded), msgValues}, value: requiredValue, + commandCount: expanded.length - preTransactions.length, + relayCount: relays.reduce((sum, indexes) => sum + indexes.length, 0)}; } diff --git a/js/test/enso-differential.js b/js/test/enso-differential.js index 31d3bdb4..87bb4287 100644 --- a/js/test/enso-differential.js +++ b/js/test/enso-differential.js @@ -37,6 +37,10 @@ const command = (selector, flags, indices, output, target) => const commands = [ command("0xd0e30db0", 0x03, [0], 0xff, weth), // WETH.deposit{value: amount}() command("0x70a08231", 0x02, [1], 2, weth), // amount = WETH.balanceOf(account) + command("0x70a08231", 0x02, [2], 0xff, weth), // exercise five-way scalar fan-out + command("0x70a08231", 0x02, [2], 0xff, weth), + command("0x70a08231", 0x02, [2], 0xff, weth), + command("0x70a08231", 0x02, [2], 0xff, weth), command("0xa9059cbb", 0x01, [3, 2], 0xff, weth), // WETH.transfer(receiver, amount) ]; const state = [word(amount), addressWord(account.address), "0x", addressWord(receiver)]; @@ -65,4 +69,4 @@ assert.equal(scripterReceived, ensoReceived); assert.equal(scripterReceived, amount); const delta = scripterReceipt.gasUsed - ensoReceipt.gasUsed; const percent = Number(delta * 10_000n / ensoReceipt.gasUsed) / 100; -console.log(`PASS same 3-call Weiroll plan: Enso VM ${ensoReceipt.gasUsed} gas; Scripter ${scripterReceipt.gasUsed} gas; ${delta} (${percent}%)`); +console.log(`PASS same 7-command Weiroll plan with scalar fan-out: Enso VM ${ensoReceipt.gasUsed} gas; Scripter ${scripterReceipt.gasUsed} gas; ${delta} (${percent}%)`); diff --git a/js/test/enso-mainnet.js b/js/test/enso-mainnet.js index 332a713a..4c5b3157 100644 --- a/js/test/enso-mainnet.js +++ b/js/test/enso-mainnet.js @@ -96,7 +96,7 @@ async function compare(label, outputToken, response, required) { console.log(message); return null; } - const {batch, value, commandCount} = translated; + const {batch, value, commandCount, relayCount} = translated; const snapshot = await client.request({method: "evm_snapshot"}); await client.request({method: "anvil_setCode", params: [account.address, `0xef0100${ENSO_EIP7702.slice(2)}`]}); const before = await client.readContract({address: outputToken, abi: erc20, functionName: "balanceOf", args: [receiver]}); @@ -131,7 +131,7 @@ async function compare(label, outputToken, response, required) { } const delta = receipt.gasUsed - ensoGas; const percent = Number(delta * 10_000n / ensoGas) / 100; - console.log(`PASS ${label}: ${routeSteps} route steps; ${commandCount} calls; ${txBytes} tx bytes; Enso VM ${ensoGas} gas; Scripter ${receipt.gasUsed} gas; ${delta} (${percent}%); output ${received}`); + console.log(`PASS ${label}: ${routeSteps} route steps; ${commandCount} calls (${relayCount} relays); ${txBytes} tx bytes; Enso VM ${ensoGas} gas; Scripter ${receipt.gasUsed} gas; ${delta} (${percent}%); output ${received}`); return {label, commandCount, txBytes, ensoGas, scripterGas: receipt.gasUsed}; } diff --git a/js/test/enso.test.js b/js/test/enso.test.js index e9688b21..df2c29ea 100644 --- a/js/test/enso.test.js +++ b/js/test/enso.test.js @@ -73,6 +73,20 @@ describe("Enso delegate translator", () => { expect(frames(batch.calldatas)[0]).toBe(`0x12345678${Array.from({length: 7}, (_, i) => stateWord(i).slice(2)).join("")}`); }); + test("expands scalar fan-out beyond three destinations through the identity precompile", () => { + const producer = command("0x12345678", 0x02, [], 0); + const consumers = Array.from({length: 4}, () => command("0x87654321", 0x01, [0], 0xff)); + const {batch, commandCount, relayCount} = buildEnsoDelegateBatch( + response([producer, ...consumers], [ZERO]), + {caller: CALLER, routingStrategy: "delegate"}, + ); + expect(commandCount).toBe(6); + expect(relayCount).toBe(1); + expect(batch.targets[1]).toBe("0x0000000000000000000000000000000000000004"); + expect(frames(batch.calldatas)[1]).toBe(ZERO); + expect(batch.offsets).toHaveLength(6); + }); + test.each([ ["router strategy", response([], []), {routingStrategy: "router"}], ["non-shortcut calldata", {tx: {from: CALLER, to: TOKEN, value: "0", data: "0x12345678"}}, {}], From 2e481ff004cb8e7faeebd3d2d5fe58dd9e81aed9 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+0xdewy@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:06:53 -0700 Subject: [PATCH 8/8] Harden Enso routes for production signing --- .github/workflows/test.yml | 2 + README.md | 58 ++++++++++--- SECURITY.md | 15 ++-- js/enso.js | 155 ++++++++++++++++++++++++++++++++++- js/test/enso-corpus.js | 24 ++++++ js/test/enso-corpus.test.js | 15 ++++ js/test/enso-differential.js | 96 +++++++++++++--------- js/test/enso-mainnet.js | 79 ++++++++++-------- js/test/enso.test.js | 96 +++++++++++++++++++--- script/rehearse.sh | 4 +- 10 files changed, 441 insertions(+), 103 deletions(-) create mode 100644 js/test/enso-corpus.js create mode 100644 js/test/enso-corpus.test.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1020f609..ccd8c963 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,6 +60,8 @@ jobs: runs-on: ubuntu-latest env: ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }} + # Optional. When configured, rehearse.sh runs the versioned live Enso route matrix. + ENSO_API_KEY: ${{ secrets.ENSO_API_KEY }} FORK_BLOCK: ${{ inputs.fork_block }} steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 77d2ebfc..473e1429 100644 --- a/README.md +++ b/README.md @@ -89,14 +89,16 @@ The declared length must be exact. The executor reverts (`InsufficientReturnData returns too few bytes; incorrect lengths can also make the consumer read adjacent arguments. The declaration is a caller precondition, not a runtime length check. -### Enso route translation (experimental) +### Enso route translation The optional adapter uses Enso to find a route, then replaces Enso's Weiroll executor with MulticallScripter. Request the `delegate` strategy: its `executeShortcut` calldata contains the underlying command and state arrays for execution in the EOA's context. ```javascript -import { buildEnsoDelegateBatch } from "multicall-scripter/enso"; +import { + buildEnsoDelegateBatch, inspectEnsoDelegateRoute, verifyEnsoCompatibility, +} from "multicall-scripter/enso"; const route = await enso.getRouteData({ chainId: 1, @@ -104,11 +106,26 @@ const route = await enso.getRouteData({ routingStrategy: "delegate", // tokenIn, tokenOut, amountIn, receiver, slippage... }); -const { batch, value } = buildEnsoDelegateBatch(route, { - caller: EXECUTOR, - routingStrategy: "delegate", +await verifyEnsoCompatibility(publicClient); // exact mainnet implementation bytecode +const request = { caller: EXECUTOR, routingStrategy: "delegate", chainId: 1 }; +const inspection = inspectEnsoDelegateRoute(route, request); +const { batch, value, routeHash } = buildEnsoDelegateBatch(route, { + ...request, + policy: { + maxEthSpend: amountIn, + expectedInputToken: ETH, + expectedOutputToken: USDC, + expectedReceiver: RECEIVER, + allowedTargets: APPROVED_PROTOCOL_TARGETS, // maintained independently of Enso + allowedApprovals: [ + // { token, operator, maxAmount, allowAll?: false } + ], + }, }); +// Fork-simulate the original Enso route and `batch` at the same recent block, then call +// assertEnsoSimulation({ routeHash, chainId: 1, forkBlock, enso, scripter, +// minOutput, maxInput: amountIn }) before presenting or signing the transaction. await walletClient.writeContract({ address: EXECUTOR, abi: EXECUTOR_ABI, functionName: "execute", args: [batch.targets, batch.offsets, batch.calldatas, batch.msgValues], @@ -116,18 +133,35 @@ await walletClient.writeContract({ }); ``` -`caller` must match every returned `tx.from`; under EIP-7702 it is the delegating EOA. The adapter +`caller` must match every returned `tx.from`, and the main `tx.to` must be that delegated EOA. +The decoder is pinned to Ethereum, Enso Weiroll 1.4.1 and the reviewed EIP-7702 implementation +code hash. An Enso upgrade fails the compatibility check until this package is reviewed and +updated. The adapter decodes the Enso transaction but does not call its `tx.to`: each supported Weiroll command target is placed directly in the Scripter batch. It fails closed when a route uses semantics that the fixed offset format cannot preserve, including delegatecalls, runtime-sized return values, -computed ETH values, state replacement, and Weiroll's composite state indices. Treat this as a -route-dependent experiment until the exact live response passes simulation and differential -execution against Enso's VM. Enso also requires a consumed scalar return to be exactly 32 bytes; -Scripter requires at least 32, so simulation is the compatibility check for each concrete route. +computed ETH values, state replacement, and Weiroll's composite state indices. + +Inspection only decodes; it does not authorize execution. `buildEnsoDelegateBatch` requires a +maximum aggregate ETH spend, expected input/output assets and recipient, an independent target +allowlist, and explicit bounds for every decoded ERC-20/ERC-721 approval. The address checks are +useful tripwires, not semantic proof of arbitrary calldata. Before signing, simulate both the +original Enso transaction and translated batch from the same state, compare exact input/output +balance deltas with `assertEnsoSimulation`, enforce the quote's minimum output and bind the result +to `routeHash`. Use a fresh block and quote; never derive the production allowlist from +`inspection.targets`. + +The approval scanner recognizes `approve`, `increaseAllowance`, and `setApprovalForAll`. +EIP-2612, DAI-style permit and Permit2 authorization are rejected because their authority cannot +be represented by this policy. Protocol-specific authorization hidden behind another selector is +controlled only by the target allowlist and simulation. + +Enso requires a consumed scalar return to be exactly 32 bytes; Scripter requires at least 32, so +differential simulation remains the compatibility check for each concrete route. When one scalar feeds more than three arguments, the adapter expands the fan-out through the identity precompile; this preserves the fixed offset schema and avoids another deployed contract. -The returned outer `value` follows Enso's transaction, while individual calls may spend the EOA's -existing ETH balance; inspect `batch.msgValues` when enforcing a maximum spend in a wallet UI. +The returned outer `value` follows Enso's transactions, while individual calls may spend the EOA's +existing ETH balance. Policy caps the sum of `batch.msgValues`, including pre-transactions. For ERC-20 input, approvals and funding must belong to the execution account. A public bare executor must receive tokens inside the same atomic batch and must not retain approvals or assets. diff --git a/SECURITY.md b/SECURITY.md index 82cbd52f..25ba5491 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -120,12 +120,15 @@ Build: the optimizer was off; it is now on (via-IR), and the compilation target enforced; do not use the signature bytes as an identifier. - **The Rust builder** covers scalar chaining and one dynamic value per call. Nested references and element access are JS-only. -- **External route builders remain trusted input.** The experimental Enso translator validates - transaction shape, caller context, Weiroll bounds, and only the subset it can reproduce without - changing execution semantics. It cannot prove that API-provided targets or calldata express the - intended route, and a translated static return accepts extra trailing return bytes that Enso's VM - rejects. Inspect route metadata, set a bounded minimum output, use a fresh quote, and simulate - both executions before signing. Cross-chain completion remains outside source-chain atomicity. +- **External route builders remain untrusted input.** The Enso translator is pinned to Ethereum, + Weiroll 1.4.1 and an exact Enso EIP-7702 implementation code hash. It validates transaction + shape, caller context, Weiroll bounds, aggregate ETH spend, expected assets/recipient, decoded + approvals and an independent target allowlist. These checks cannot prove that arbitrary calldata + expresses the intended route. A translated static return also accepts extra trailing return bytes + that Enso's VM rejects. Fork-simulate the original and translated executions from the same block, + require exact input/output balance deltas and bind the result to the returned `routeHash` before + signing. Re-review and update the pin after any Enso deployment or format change. Cross-chain + completion remains outside source-chain atomicity. ## Release validation — 2026-09-05 diff --git a/js/enso.js b/js/enso.js index 78f70633..25e1a066 100644 --- a/js/enso.js +++ b/js/enso.js @@ -1,4 +1,4 @@ -import { decodeFunctionData, getAddress, parseAbi } from "viem"; +import { decodeFunctionData, encodeAbiParameters, getAddress, keccak256, parseAbi } from "viem"; import { callPartialReturn, stateChangingCall, staticCall, staticCallPartialReturn } from "./encoding.js"; const EXECUTE_SHORTCUT_ABI = parseAbi([ @@ -10,6 +10,19 @@ const VARIABLE = 0x80, VALUE_MASK = 0x7f, END = 0xff; const USE_STATE = 0xfe, ARRAY_START = 0xfd, TUPLE_START = 0xfc, DYNAMIC_END = 0xfb; const SPECIAL_INDICES = new Set([USE_STATE, ARRAY_START, TUPLE_START, DYNAMIC_END]); const IDENTITY_PRECOMPILE = "0x0000000000000000000000000000000000000004"; +const NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; +const APPROVE = "0x095ea7b3", INCREASE_ALLOWANCE = "0x39509351", SET_APPROVAL_FOR_ALL = "0xa22cb465"; +const APPROVAL_SELECTORS = new Set([APPROVE, INCREASE_ALLOWANCE, SET_APPROVAL_FOR_ALL]); +const UNSUPPORTED_AUTHORIZATION_SELECTORS = new Set(["0xd505accf", "0x8fcbaf0c", "0x87517c45"]); + +/** Compatibility boundary reviewed against Enso's Weiroll v1.4.1 deployment. */ +export const ENSO_COMPATIBILITY = Object.freeze({ + chainId: 1, + weirollVersion: "1.4.1", + weirollCommit: "900250114203727ff236d3f6313673c17c2d90dd", + eip7702Implementation: "0x0Aeb78D3f961b0394E4A3B94537b543e9E57Bab1", + eip7702CodeHash: "0x93b2dfa2f4fa04a1b1f2b15652bda0319f96e715a9d55eb91cb360ce7227b7d3", +}); const strip0x = (value) => value.slice(2); const byteLength = (value) => strip0x(value).length / 2; const pad32 = (n) => Math.ceil(n / 32) * 32; @@ -120,11 +133,103 @@ function packFrames(calls) { }).join(""); } -/** Translate the representable subset of an Enso `delegate` Weiroll program to direct Scripter calls. */ -export function buildEnsoDelegateBatch(response, {caller, routingStrategy}) { +function calldataWord(data, index, label) { + const start = 10 + index * 64; + if (data.length < start + 64) throw new Error(`Enso ${label} calldata is truncated`); + return `0x${data.slice(start, start + 64)}`; +} + +function approvalFor(call, index) { + const selector = call.data.slice(0, 10); + if (UNSUPPORTED_AUTHORIZATION_SELECTORS.has(selector)) { + throw new Error(`Enso call #${index} uses unsupported permit or Permit2 authorization ${selector}`); + } + if (!APPROVAL_SELECTORS.has(selector)) return null; + const operator = address(`0x${calldataWord(call.data, 0, `call #${index}`).slice(-40)}`, `call #${index} approval operator`); + if (selector === SET_APPROVAL_FOR_ALL) { + const enabled = BigInt(calldataWord(call.data, 1, `call #${index}`)); + if (enabled > 1n) throw new Error(`Enso call #${index} has an invalid setApprovalForAll boolean`); + return {callIndex: index, token: call.target, operator, amount: enabled === 1n ? null : 0n, kind: "setApprovalForAll"}; + } + return {callIndex: index, token: call.target, operator, + amount: BigInt(calldataWord(call.data, 1, `call #${index}`)), + kind: selector === APPROVE ? "approve" : "increaseAllowance"}; +} + +function addressEvidence(calls, expected) { + const needle = expected.slice(2).toLowerCase(); + return calls.some(call => strip0x(call.data).toLowerCase().includes(needle)); +} + +function normalizeApproval(rule, index) { + if (!rule || typeof rule !== "object") throw new Error(`Enso policy.allowedApprovals[${index}] must be an object`); + return { + token: address(rule.token, `policy.allowedApprovals[${index}].token`), + operator: address(rule.operator, `policy.allowedApprovals[${index}].operator`), + maxAmount: uint256(rule.maxAmount, `policy.allowedApprovals[${index}].maxAmount`), + allowAll: rule.allowAll === true, + }; +} + +function enforcePolicy(inspected, policy) { + if (!policy || typeof policy !== "object") throw new Error("Enso policy is required before building an executable batch"); + const maxEthSpend = uint256(policy.maxEthSpend, "policy.maxEthSpend"); + if (inspected.totalEthSpend > maxEthSpend) { + throw new Error(`Enso route spends ${inspected.totalEthSpend} wei, above policy.maxEthSpend ${maxEthSpend}`); + } + if (!Array.isArray(policy.allowedTargets) || policy.allowedTargets.length === 0) { + throw new Error("Enso policy.allowedTargets must be a non-empty independent allowlist"); + } + const allowedTargets = new Set(policy.allowedTargets.map((target, i) => + address(target, `policy.allowedTargets[${i}]`).toLowerCase())); + for (const target of inspected.targets) { + if (target.toLowerCase() !== IDENTITY_PRECOMPILE && !allowedTargets.has(target.toLowerCase())) { + throw new Error(`Enso target ${target} is not allowed by policy`); + } + } + const approvals = (policy.allowedApprovals ?? []).map(normalizeApproval); + for (const approval of inspected.approvals) { + const rule = approvals.find(candidate => candidate.token === approval.token && candidate.operator === approval.operator); + if (!rule) throw new Error(`Enso ${approval.kind} from ${approval.token} to ${approval.operator} is not allowed by policy`); + if (approval.amount === null ? !rule.allowAll : approval.amount > rule.maxAmount) { + throw new Error(`Enso ${approval.kind} exceeds policy for ${approval.token} and ${approval.operator}`); + } + } + const receiver = address(policy.expectedReceiver, "policy.expectedReceiver"); + if (!addressEvidence(inspected.calls, receiver)) { + throw new Error(`Enso expected receiver ${receiver} is not present in translated calldata`); + } + const inputToken = address(policy.expectedInputToken, "policy.expectedInputToken"); + const outputToken = address(policy.expectedOutputToken, "policy.expectedOutputToken"); + const hasInput = inputToken === NATIVE_TOKEN + ? inspected.totalEthSpend > 0n + : inspected.targets.includes(inputToken) || addressEvidence(inspected.calls, inputToken); + if (!hasInput) throw new Error(`Enso expected input token ${inputToken} is not present in the translated route`); + if (!inspected.targets.includes(outputToken) && !addressEvidence(inspected.calls, outputToken)) { + throw new Error(`Enso expected output token ${outputToken} is not present in the translated route`); + } +} + +/** Check that the fork uses the exact Enso EIP-7702 implementation this decoder was reviewed for. */ +export async function verifyEnsoCompatibility(client, {chainId = ENSO_COMPATIBILITY.chainId} = {}) { + if (chainId !== ENSO_COMPATIBILITY.chainId) throw new Error(`Enso compatibility is pinned to chain ${ENSO_COMPATIBILITY.chainId}`); + const code = await client.getCode({address: ENSO_COMPATIBILITY.eip7702Implementation}); + if (!code || code === "0x") throw new Error("Pinned Enso EIP-7702 implementation is not deployed"); + const codeHash = keccak256(code); + if (codeHash !== ENSO_COMPATIBILITY.eip7702CodeHash) { + throw new Error(`Pinned Enso EIP-7702 code hash mismatch: ${codeHash}`); + } + return ENSO_COMPATIBILITY; +} + +/** Decode the representable subset without authorizing it for execution. */ +export function inspectEnsoDelegateRoute(response, {caller, routingStrategy, chainId}) { if (routingStrategy !== "delegate") { throw new Error("Only Enso delegate responses expose a program for direct Scripter execution"); } + if (chainId !== ENSO_COMPATIBILITY.chainId) { + throw new Error(`Enso compatibility is pinned to chain ${ENSO_COMPATIBILITY.chainId}`); + } if (!response || typeof response !== "object") throw new Error("Enso response must be an object"); const executionAccount = address(caller, "caller"); if (response.preTransactions !== undefined && !Array.isArray(response.preTransactions)) { @@ -133,6 +238,14 @@ export function buildEnsoDelegateBatch(response, {caller, routingStrategy}) { const preTransactions = (response.preTransactions ?? []).map((entry, i) => routeTransaction(entry, `preTransactions[${i}]`, executionAccount)); const main = routeTransaction(response.tx, "tx", executionAccount); + if (main.target !== executionAccount) { + throw new Error("Enso delegate tx.to must be the delegated execution account"); + } + const routeHash = keccak256(encodeAbiParameters([{ + type: "tuple[]", components: [ + {name: "target", type: "address"}, {name: "data", type: "bytes"}, {name: "value", type: "uint256"}, + ], + }], [[...preTransactions, main].map(({target, data, value}) => ({target, data, value}))])); let decoded; try { decoded = decodeFunctionData({abi: EXECUTE_SHORTCUT_ABI, data: main.data}); } catch { throw new Error("Enso delegate tx.data is not executeShortcut calldata"); } @@ -251,7 +364,41 @@ export function buildEnsoDelegateBatch(response, {caller, routingStrategy}) { : callPartialReturn(valueIndex, destinations, lengths, sources, 32); }); const requiredValue = preTransactions.reduce((sum, tx) => sum + tx.value, main.value); + const totalEthSpend = msgValues.reduce((sum, value) => sum + value, 0n); + const approvals = expanded.map(approvalFor).filter(Boolean); return {batch: {targets, offsets, calldatas: packFrames(expanded), msgValues}, value: requiredValue, commandCount: expanded.length - preTransactions.length, - relayCount: relays.reduce((sum, indexes) => sum + indexes.length, 0)}; + relayCount: relays.reduce((sum, indexes) => sum + indexes.length, 0), + totalEthSpend, targets: [...new Set(targets)], approvals, + calls: expanded.map(({target, data, value, isStatic}) => ({target, data, value, isStatic})), + compatibility: ENSO_COMPATIBILITY, routeHash}; +} + +/** Translate and authorize an Enso route under an explicit wallet policy. */ +export function buildEnsoDelegateBatch(response, options) { + const inspected = inspectEnsoDelegateRoute(response, options); + enforcePolicy(inspected, options?.policy); + return inspected; +} + +/** Gate signing on an exact differential fork simulation and application-level bounds. */ +export function assertEnsoSimulation({routeHash, chainId, forkBlock, enso, scripter, minOutput, maxInput}) { + if (!enso || !scripter) throw new Error("Both Enso and Scripter simulation results are required"); + if (!/^0x[0-9a-fA-F]{64}$/.test(routeHash ?? "")) throw new Error("A valid translated routeHash is required"); + if (chainId !== ENSO_COMPATIBILITY.chainId) throw new Error(`Simulation must use chain ${ENSO_COMPATIBILITY.chainId}`); + if (!Number.isSafeInteger(forkBlock) || forkBlock <= 0) throw new Error("Simulation forkBlock must be a positive safe integer"); + if (enso.routeHash?.toLowerCase() !== routeHash.toLowerCase() + || scripter.routeHash?.toLowerCase() !== routeHash.toLowerCase()) { + throw new Error("Simulation results do not match the translated routeHash"); + } + const ensoOutput = uint256(enso.outputDelta, "simulation.enso.outputDelta"); + const scripterOutput = uint256(scripter.outputDelta, "simulation.scripter.outputDelta"); + const ensoInput = uint256(enso.inputDelta, "simulation.enso.inputDelta"); + const scripterInput = uint256(scripter.inputDelta, "simulation.scripter.inputDelta"); + if (ensoOutput !== scripterOutput || ensoInput !== scripterInput) { + throw new Error("Enso and Scripter fork simulations have different balance deltas"); + } + if (scripterOutput < uint256(minOutput, "simulation.minOutput")) throw new Error("Scripter simulation output is below minOutput"); + if (scripterInput > uint256(maxInput, "simulation.maxInput")) throw new Error("Scripter simulation input is above maxInput"); + return {inputDelta: scripterInput, outputDelta: scripterOutput}; } diff --git a/js/test/enso-corpus.js b/js/test/enso-corpus.js new file mode 100644 index 00000000..2320d924 --- /dev/null +++ b/js/test/enso-corpus.js @@ -0,0 +1,24 @@ +// Versioned live-query corpus. The manual mainnet-fork job resolves these definitions against +// Enso's current API, then differentially executes every supported response. +export const ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; +export const TOKENS = Object.freeze({ + WETH: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + DAI: "0x6B175474E89094C44Da98b954EedeAC495271d0F", + USDT: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + FRAX: "0x853d955aCEf822Db058eb8505911ED77F175b99e", + yvWETH: "0xa258C4606Ca8206D8aA700cE2143D7db854D168c", + threeCRV: "0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490", +}); + +export const LIVE_ENSO_CORPUS = Object.freeze([ + {label: "ETH → USDC", kind: "route", path: [ETH, TOKENS.USDC], required: true}, + {label: "ETH → yvWETH vault", kind: "route", path: [ETH, TOKENS.yvWETH]}, + {label: "ETH → 3CRV LP", kind: "route", path: [ETH, TOKENS.threeCRV]}, + {label: "2-leg ETH → USDC → DAI", kind: "bundle", path: [ETH, TOKENS.USDC, TOKENS.DAI]}, + {label: "4-leg ETH → USDC → DAI → WETH → USDT", kind: "bundle", + path: [ETH, TOKENS.USDC, TOKENS.DAI, TOKENS.WETH, TOKENS.USDT]}, + {label: "8-leg swap chain", kind: "bundle", + path: [ETH, TOKENS.USDC, TOKENS.DAI, TOKENS.WETH, TOKENS.USDT, TOKENS.FRAX, + TOKENS.WETH, TOKENS.DAI, TOKENS.USDC]}, +]); diff --git a/js/test/enso-corpus.test.js b/js/test/enso-corpus.test.js new file mode 100644 index 00000000..443752d2 --- /dev/null +++ b/js/test/enso-corpus.test.js @@ -0,0 +1,15 @@ +import {expect, test} from "bun:test"; +import {getAddress} from "viem"; +import {ETH, LIVE_ENSO_CORPUS} from "./enso-corpus.js"; + +test("live Enso corpus covers simple, vault, LP and long bundle routes", () => { + expect(LIVE_ENSO_CORPUS.length).toBeGreaterThanOrEqual(6); + expect(LIVE_ENSO_CORPUS.some(({kind}) => kind === "route")).toBe(true); + expect(LIVE_ENSO_CORPUS.some(({kind}) => kind === "bundle")).toBe(true); + expect(Math.max(...LIVE_ENSO_CORPUS.map(({path}) => path.length - 1))).toBeGreaterThanOrEqual(8); + for (const testCase of LIVE_ENSO_CORPUS) { + expect(testCase.path[0]).toBe(ETH); + expect(testCase.path.length).toBeGreaterThanOrEqual(2); + for (const token of testCase.path) expect(() => getAddress(token)).not.toThrow(); + } +}); diff --git a/js/test/enso-differential.js b/js/test/enso-differential.js index 87bb4287..425d0a53 100644 --- a/js/test/enso-differential.js +++ b/js/test/enso-differential.js @@ -7,24 +7,31 @@ import { parseAbi, parseEther, toHex, } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { buildEnsoDelegateBatch } from "../enso.js"; +import { + ENSO_COMPATIBILITY, assertEnsoSimulation, buildEnsoDelegateBatch, + inspectEnsoDelegateRoute, verifyEnsoCompatibility, +} from "../enso.js"; const rpc = process.argv[2]; if (!rpc) throw new Error("Usage: bun js/test/enso-differential.js ANVIL_FORK_RPC"); const transport = http(rpc, {timeout: 120_000}); const client = createPublicClient({transport}); +const info = await client.request({method: "anvil_nodeInfo"}); +assert.equal(await client.getChainId(), 31337, "differential test requires local chain 31337"); +assert.ok(info.forkConfig?.forkBlockNumber, "differential test requires a mainnet fork"); const account = privateKeyToAccount("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); const wallet = createWalletClient({account, transport}); const receiver = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; +const eth = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; const weth = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; -const ensoDelegate = "0x0aeb78d3f961b0394e4a3b94537b543e9e57bab1"; +const ensoDelegate = ENSO_COMPATIBILITY.eip7702Implementation; const artifact = JSON.parse(readFileSync(new URL("../../out/7702Caller.sol/SevenSevenZeroTwoCaller.json", import.meta.url))); const scripterDelegate = getContractAddress({ opcode: "CREATE2", from: "0x4e59b44847b379578588920cA78FbF26c0B4956C", salt: toHex(0x4d756c746963616c6c5363726970746572n, {size: 32}), bytecode: artifact.bytecode.object, }); assert.equal((await client.getCode({address: account.address})).toLowerCase(), `0xef0100${scripterDelegate.slice(2).toLowerCase()}`); -assert.ok((await client.getCode({address: ensoDelegate})).length > 2, "Enso EIP-7702 implementation is absent"); +await verifyEnsoCompatibility(client); const shortcutAbi = parseAbi(["function executeShortcut(bytes32,bytes32,bytes32[],bytes[]) payable returns (bytes[])"]); const erc20 = parseAbi(["function balanceOf(address) view returns (uint256)"]); @@ -34,39 +41,54 @@ const word = (value) => `0x${BigInt(value).toString(16).padStart(64, "0")}`; const addressWord = (value) => `0x${value.slice(2).padStart(64, "0")}`; const command = (selector, flags, indices, output, target) => `0x${selector.slice(2)}${flags.toString(16).padStart(2, "0")}${indices.map(i => i.toString(16).padStart(2, "0")).join("").padEnd(12, "f")}${output.toString(16).padStart(2, "0")}${target.slice(2)}`; -const commands = [ - command("0xd0e30db0", 0x03, [0], 0xff, weth), // WETH.deposit{value: amount}() - command("0x70a08231", 0x02, [1], 2, weth), // amount = WETH.balanceOf(account) - command("0x70a08231", 0x02, [2], 0xff, weth), // exercise five-way scalar fan-out - command("0x70a08231", 0x02, [2], 0xff, weth), - command("0x70a08231", 0x02, [2], 0xff, weth), - command("0x70a08231", 0x02, [2], 0xff, weth), - command("0xa9059cbb", 0x01, [3, 2], 0xff, weth), // WETH.transfer(receiver, amount) -]; -const state = [word(amount), addressWord(account.address), "0x", addressWord(receiver)]; -const data = encodeFunctionData({abi: shortcutAbi, functionName: "executeShortcut", args: [zero, zero, commands, state]}); -const route = {tx: {from: account.address, to: ensoDelegate, data, value: amount.toString()}}; -const {batch, value} = buildEnsoDelegateBatch(route, {caller: account.address, routingStrategy: "delegate"}); +async function compareFanout(fanout) { + const commands = [ + command("0xd0e30db0", 0x03, [0], 0xff, weth), + command("0x70a08231", 0x02, [1], 2, weth), + ...Array.from({length: fanout}, () => command("0x70a08231", 0x02, [2], 0xff, weth)), + command("0xa9059cbb", 0x01, [3, 2], 0xff, weth), + ]; + const state = [word(amount), addressWord(account.address), "0x", addressWord(receiver)]; + const data = encodeFunctionData({abi: shortcutAbi, functionName: "executeShortcut", args: [zero, zero, commands, state]}); + const route = {tx: {from: account.address, to: account.address, data, value: amount.toString()}}; + const inspected = inspectEnsoDelegateRoute(route, {caller: account.address, routingStrategy: "delegate", chainId: 1}); + const {batch, value, routeHash} = buildEnsoDelegateBatch(route, { + caller: account.address, routingStrategy: "delegate", chainId: 1, + policy: { + maxEthSpend: amount, allowedTargets: inspected.targets, expectedReceiver: receiver, + expectedInputToken: eth, expectedOutputToken: weth, + }, + }); + + const snapshot = await client.request({method: "evm_snapshot"}); + const before = await client.readContract({address: weth, abi: erc20, functionName: "balanceOf", args: [receiver]}); + await client.request({method: "anvil_setCode", params: [account.address, `0xef0100${ensoDelegate.slice(2)}`]}); + const ensoReceipt = await client.waitForTransactionReceipt({hash: await wallet.sendTransaction({ + to: account.address, data, value, gas: 3_000_000n, chain: null, + })}); + assert.equal(ensoReceipt.status, "success"); + const ensoReceived = await client.readContract({address: weth, abi: erc20, functionName: "balanceOf", args: [receiver]}) - before; + assert.equal(await client.request({method: "evm_revert", params: [snapshot]}), true); -const snapshot = await client.request({method: "evm_snapshot"}); -const before = await client.readContract({address: weth, abi: erc20, functionName: "balanceOf", args: [receiver]}); -await client.request({method: "anvil_setCode", params: [account.address, `0xef0100${ensoDelegate.slice(2)}`]}); -const ensoReceipt = await client.waitForTransactionReceipt({hash: await wallet.sendTransaction({ - to: account.address, data, value, gas: 1_000_000n, chain: null, -})}); -assert.equal(ensoReceipt.status, "success"); -const ensoReceived = await client.readContract({address: weth, abi: erc20, functionName: "balanceOf", args: [receiver]}) - before; -assert.equal(await client.request({method: "evm_revert", params: [snapshot]}), true); + const scripterBefore = await client.readContract({address: weth, abi: erc20, functionName: "balanceOf", args: [receiver]}); + const scripterReceipt = await client.waitForTransactionReceipt({hash: await wallet.writeContract({ + address: account.address, abi: artifact.abi, functionName: "execute", + args: [batch.targets, batch.offsets, batch.calldatas, batch.msgValues], value, gas: 3_000_000n, chain: null, + })}); + assert.equal(scripterReceipt.status, "success"); + const scripterReceived = await client.readContract({address: weth, abi: erc20, functionName: "balanceOf", args: [receiver]}) - scripterBefore; + assertEnsoSimulation({routeHash, chainId: 1, forkBlock: Number(info.forkConfig.forkBlockNumber), + enso: {routeHash, inputDelta: amount, outputDelta: ensoReceived}, + scripter: {routeHash, inputDelta: amount, outputDelta: scripterReceived}, + minOutput: amount, maxInput: amount, + }); + const delta = scripterReceipt.gasUsed - ensoReceipt.gasUsed; + return {fanout, ensoGas: ensoReceipt.gasUsed, scripterGas: scripterReceipt.gasUsed, delta}; +} -const scripterBefore = await client.readContract({address: weth, abi: erc20, functionName: "balanceOf", args: [receiver]}); -const scripterReceipt = await client.waitForTransactionReceipt({hash: await wallet.writeContract({ - address: account.address, abi: artifact.abi, functionName: "execute", - args: [batch.targets, batch.offsets, batch.calldatas, batch.msgValues], value, gas: 1_000_000n, chain: null, -})}); -assert.equal(scripterReceipt.status, "success"); -const scripterReceived = await client.readContract({address: weth, abi: erc20, functionName: "balanceOf", args: [receiver]}) - scripterBefore; -assert.equal(scripterReceived, ensoReceived); -assert.equal(scripterReceived, amount); -const delta = scripterReceipt.gasUsed - ensoReceipt.gasUsed; -const percent = Number(delta * 10_000n / ensoReceipt.gasUsed) / 100; -console.log(`PASS same 7-command Weiroll plan with scalar fan-out: Enso VM ${ensoReceipt.gasUsed} gas; Scripter ${scripterReceipt.gasUsed} gas; ${delta} (${percent}%)`); +const cases = []; +for (const fanout of [0, 1, 2, 3, 4, 7, 12, 20]) cases.push(await compareFanout(fanout)); +assert.ok(cases.every(({delta}) => delta < 0n), "Scripter must beat Enso on every generated supported plan"); +const largest = cases.at(-1); +const percent = Number(largest.delta * 10_000n / largest.ensoGas) / 100; +console.log(`PASS 8 generated plans against Enso Weiroll ${ENSO_COMPATIBILITY.weirollVersion}; fan-out 0..20; largest Enso VM ${largest.ensoGas} gas; Scripter ${largest.scripterGas} gas; ${largest.delta} (${percent}%)`); diff --git a/js/test/enso-mainnet.js b/js/test/enso-mainnet.js index 4c5b3157..2b3b89a2 100644 --- a/js/test/enso-mainnet.js +++ b/js/test/enso-mainnet.js @@ -6,7 +6,11 @@ import { createPublicClient, createWalletClient, getContractAddress, http, parseAbi, parseEther, toHex, } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { buildEnsoDelegateBatch } from "../enso.js"; +import { + ENSO_COMPATIBILITY, assertEnsoSimulation, buildEnsoDelegateBatch, + inspectEnsoDelegateRoute, verifyEnsoCompatibility, +} from "../enso.js"; +import {ETH, LIVE_ENSO_CORPUS} from "./enso-corpus.js"; const rpc = process.argv[2]; if (!rpc) throw new Error("Usage: ENSO_API_KEY=... bun js/test/enso-mainnet.js ANVIL_FORK_RPC"); @@ -26,23 +30,13 @@ const delegateAddress = getContractAddress({ opcode: "CREATE2", from: "0x4e59b44847b379578588920cA78FbF26c0B4956C", salt: toHex(0x4d756c746963616c6c5363726970746572n, {size: 32}), bytecode: artifact.bytecode.object, }); -const ENSO_EIP7702 = "0x0aeb78d3f961b0394e4a3b94537b543e9e57bab1"; -assert.ok((await client.getCode({address: ENSO_EIP7702})).length > 2, "Enso EIP-7702 implementation is not deployed on this fork"); +const ENSO_EIP7702 = ENSO_COMPATIBILITY.eip7702Implementation; +await verifyEnsoCompatibility(client); assert.equal((await client.getCode({address: account.address})).toLowerCase(), `0xef0100${delegateAddress.slice(2).toLowerCase()}`, "rehearsal account must delegate to SevenSevenZeroTwoCaller"); const amountIn = parseEther("0.005"); const receiver = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; -const ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; -const TOKENS = { - WETH: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - DAI: "0x6B175474E89094C44Da98b954EedeAC495271d0F", - USDT: "0xdAC17F958D2ee523a2206206994597C13D831ec7", - FRAX: "0x853d955aCEf822Db058eb8505911ED77F175b99e", - yvWETH: "0xa258C4606Ca8206D8aA700cE2143D7db854D168c", - threeCRV: "0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490", -}; const erc20 = parseAbi(["function balanceOf(address) view returns (uint256)"]); const headers = {Authorization: `Bearer ${apiKey}`, Accept: "application/json"}; @@ -89,34 +83,57 @@ async function compare(label, outputToken, response, required) { const txBytes = (response.tx?.data?.length - 2) / 2; let translated; try { - translated = buildEnsoDelegateBatch(response, {caller: account.address, routingStrategy: "delegate"}); + const inspected = inspectEnsoDelegateRoute(response, { + caller: account.address, routingStrategy: "delegate", chainId: 1, + }); + translated = buildEnsoDelegateBatch(response, { + caller: account.address, routingStrategy: "delegate", chainId: 1, + // This rehearsal derives the policy from the response because exact differential + // simulation is its security oracle. Production callers use an independent registry. + policy: { + maxEthSpend: amountIn, + expectedReceiver: receiver, + expectedInputToken: ETH, + expectedOutputToken: outputToken, + allowedTargets: inspected.targets, + allowedApprovals: inspected.approvals.map(({token, operator, amount}) => ({ + token, operator, maxAmount: amount ?? 0n, allowAll: amount === null, + })), + }, + }); } catch (error) { const message = `LIMIT ${label}: ${routeSteps} route steps; ${txBytes} tx bytes; ${error.message}`; if (required) throw new Error(message); console.log(message); return null; } - const {batch, value, commandCount, relayCount} = translated; + const {batch, value, commandCount, relayCount, routeHash} = translated; const snapshot = await client.request({method: "evm_snapshot"}); await client.request({method: "anvil_setCode", params: [account.address, `0xef0100${ENSO_EIP7702.slice(2)}`]}); + const ensoEthBefore = await client.getBalance({address: account.address}); const before = await client.readContract({address: outputToken, abi: erc20, functionName: "balanceOf", args: [receiver]}); let ensoGas = 0n; + let ensoFees = 0n; for (const tx of (response.preTransactions ?? []).map(entry => entry.tx)) { const receipt = await client.waitForTransactionReceipt({hash: await wallet.sendTransaction({ to: tx.to, data: tx.data, value: BigInt(tx.value ?? 0), gas: 20_000_000n, chain: null, })}); assert.equal(receipt.status, "success"); ensoGas += receipt.gasUsed; + ensoFees += receipt.gasUsed * receipt.effectiveGasPrice; } const ensoReceipt = await client.waitForTransactionReceipt({hash: await wallet.sendTransaction({ to: account.address, data: response.tx.data, value: BigInt(response.tx.value ?? 0), gas: 20_000_000n, chain: null, })}); assert.equal(ensoReceipt.status, "success"); ensoGas += ensoReceipt.gasUsed; + ensoFees += ensoReceipt.gasUsed * ensoReceipt.effectiveGasPrice; const ensoReceived = await client.readContract({address: outputToken, abi: erc20, functionName: "balanceOf", args: [receiver]}) - before; + const ensoInput = ensoEthBefore - await client.getBalance({address: account.address}) - ensoFees; assert.equal(await client.request({method: "evm_revert", params: [snapshot]}), true); const scripterBefore = await client.readContract({address: outputToken, abi: erc20, functionName: "balanceOf", args: [receiver]}); + const scripterEthBefore = await client.getBalance({address: account.address}); const receipt = await client.waitForTransactionReceipt({hash: await wallet.writeContract({ address: account.address, abi: artifact.abi, functionName: "execute", args: [batch.targets, batch.offsets, batch.calldatas, batch.msgValues], @@ -124,33 +141,31 @@ async function compare(label, outputToken, response, required) { })}); assert.equal(receipt.status, "success"); const received = await client.readContract({address: outputToken, abi: erc20, functionName: "balanceOf", args: [receiver]}) - scripterBefore; - assert.equal(received, ensoReceived, `${label}: Enso and Scripter output differ`); + const scripterInput = scripterEthBefore - await client.getBalance({address: account.address}) + - receipt.gasUsed * receipt.effectiveGasPrice; + let minimum = 0n; if (response.minAmountOut !== undefined) { - const minimum = Array.isArray(response.minAmountOut) ? BigInt(response.minAmountOut[0]) : BigInt(response.minAmountOut); - assert.ok(received >= minimum, `${label}: output is below minAmountOut`); + minimum = Array.isArray(response.minAmountOut) ? BigInt(response.minAmountOut[0]) : BigInt(response.minAmountOut); } + assertEnsoSimulation({routeHash, chainId: 1, forkBlock: Number(info.forkConfig.forkBlockNumber), + enso: {routeHash, inputDelta: ensoInput, outputDelta: ensoReceived}, + scripter: {routeHash, inputDelta: scripterInput, outputDelta: received}, + minOutput: minimum, maxInput: amountIn, + }); const delta = receipt.gasUsed - ensoGas; const percent = Number(delta * 10_000n / ensoGas) / 100; console.log(`PASS ${label}: ${routeSteps} route steps; ${commandCount} calls (${relayCount} relays); ${txBytes} tx bytes; Enso VM ${ensoGas} gas; Scripter ${receipt.gasUsed} gas; ${delta} (${percent}%); output ${received}`); return {label, commandCount, txBytes, ensoGas, scripterGas: receipt.gasUsed}; } -const cases = [ - {label: "ETH → USDC", output: TOKENS.USDC, required: true, load: () => route("ETH → USDC", TOKENS.USDC)}, - {label: "ETH → yvWETH vault", output: TOKENS.yvWETH, load: () => route("ETH → yvWETH", TOKENS.yvWETH)}, - {label: "ETH → 3CRV LP", output: TOKENS.threeCRV, load: () => route("ETH → 3CRV", TOKENS.threeCRV)}, - {label: "2-leg ETH → USDC → DAI", output: TOKENS.DAI, - load: () => bundle("2-leg bundle", [ETH, TOKENS.USDC, TOKENS.DAI])}, - {label: "4-leg ETH → USDC → DAI → WETH → USDT", output: TOKENS.USDT, - load: () => bundle("4-leg bundle", [ETH, TOKENS.USDC, TOKENS.DAI, TOKENS.WETH, TOKENS.USDT])}, - {label: "8-leg swap chain", output: TOKENS.USDC, - load: () => bundle("8-leg bundle", [ETH, TOKENS.USDC, TOKENS.DAI, TOKENS.WETH, TOKENS.USDT, TOKENS.FRAX, TOKENS.WETH, TOKENS.DAI, TOKENS.USDC])}, -]; - const results = []; -for (const testCase of cases) { +for (const testCase of LIVE_ENSO_CORPUS) { try { - const result = await compare(testCase.label, testCase.output, await testCase.load(), testCase.required ?? false); + const output = testCase.path.at(-1); + const response = testCase.kind === "route" + ? await route(testCase.label, output) + : await bundle(testCase.label, testCase.path); + const result = await compare(testCase.label, output, response, testCase.required ?? false); if (result) results.push(result); } catch (error) { if (testCase.required) throw error; diff --git a/js/test/enso.test.js b/js/test/enso.test.js index df2c29ea..34730c09 100644 --- a/js/test/enso.test.js +++ b/js/test/enso.test.js @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; import { encodeFunctionData, parseAbi } from "viem"; import { decodeMemTarget, decodeResultLength } from "../encoding.js"; -import { buildEnsoDelegateBatch } from "../enso.js"; +import { + ENSO_COMPATIBILITY, assertEnsoSimulation, buildEnsoDelegateBatch, inspectEnsoDelegateRoute, +} from "../enso.js"; const CALLER = "0x00000000000000000000000000000000000000AA"; const TOKEN = "0x00000000000000000000000000000000000000bb"; @@ -16,7 +18,7 @@ function command(selector, flags, indices, output, target = TOKEN) { } function response(commands, state, value = 0n) { - return {tx: {from: CALLER, to: TOKEN, value: value.toString(), data: encodeFunctionData({ + return {tx: {from: CALLER, to: CALLER, value: value.toString(), data: encodeFunctionData({ abi: ABI, functionName: "executeShortcut", args: [ZERO, ZERO, commands, state], })}}; } @@ -36,9 +38,9 @@ describe("Enso delegate translator", () => { test("removes the Enso executor and wires a scalar result into the next protocol call", () => { const balanceOf = command("0x70a08231", 0x02, [0], 2); const transfer = command("0xa9059cbb", 0x01, [1, 2], 0xff); - const {batch, value, commandCount} = buildEnsoDelegateBatch( + const {batch, value, commandCount} = inspectEnsoDelegateRoute( response([balanceOf, transfer], [addressWord(CALLER), addressWord(RECEIVER), "0x"]), - {caller: CALLER, routingStrategy: "delegate"}, + {caller: CALLER, routingStrategy: "delegate", chainId: 1}, ); expect(batch.targets).toEqual([TOKEN, TOKEN]); expect(frames(batch.calldatas)).toEqual([ @@ -56,8 +58,8 @@ describe("Enso delegate translator", () => { const dynamic = `0x${stateWord(3).slice(2)}010203${"00".repeat(29)}`; const call = command("0x12345678", 0x03, [0, 0x81], 0xff); // Delegate routes may spend the EOA's existing balance with tx.value == 0. - const {batch, value} = buildEnsoDelegateBatch(response([call], [stateWord(7), dynamic]), { - caller: CALLER, routingStrategy: "delegate", + const {batch, value} = inspectEnsoDelegateRoute(response([call], [stateWord(7), dynamic]), { + caller: CALLER, routingStrategy: "delegate", chainId: 1, }); expect(frames(batch.calldatas)).toEqual([`0x12345678${stateWord(32).slice(2)}${dynamic.slice(2)}`]); expect(batch.msgValues).toEqual([7n]); @@ -67,8 +69,8 @@ describe("Enso delegate translator", () => { test("supports extended commands", () => { const first = command("0x12345678", 0x41, [], 0xff); const indices = `0x${[0, 1, 2, 3, 4, 5, 6].map(i => i.toString(16).padStart(2, "0")).join("").padEnd(64, "f")}`; - const {batch} = buildEnsoDelegateBatch(response([first, indices], Array.from({length: 7}, (_, i) => stateWord(i))), { - caller: CALLER, routingStrategy: "delegate", + const {batch} = inspectEnsoDelegateRoute(response([first, indices], Array.from({length: 7}, (_, i) => stateWord(i))), { + caller: CALLER, routingStrategy: "delegate", chainId: 1, }); expect(frames(batch.calldatas)[0]).toBe(`0x12345678${Array.from({length: 7}, (_, i) => stateWord(i).slice(2)).join("")}`); }); @@ -76,9 +78,9 @@ describe("Enso delegate translator", () => { test("expands scalar fan-out beyond three destinations through the identity precompile", () => { const producer = command("0x12345678", 0x02, [], 0); const consumers = Array.from({length: 4}, () => command("0x87654321", 0x01, [0], 0xff)); - const {batch, commandCount, relayCount} = buildEnsoDelegateBatch( + const {batch, commandCount, relayCount} = inspectEnsoDelegateRoute( response([producer, ...consumers], [ZERO]), - {caller: CALLER, routingStrategy: "delegate"}, + {caller: CALLER, routingStrategy: "delegate", chainId: 1}, ); expect(commandCount).toBe(6); expect(relayCount).toBe(1); @@ -96,6 +98,78 @@ describe("Enso delegate translator", () => { ["state replacement", response([command("0x12345678", 0x01, [], 0xfe)], []), {}], ["composite input", response([command("0x12345678", 0x01, [0xfd], 0xff)], []), {}], ])("rejects %s", (_, route, options) => { - expect(() => buildEnsoDelegateBatch(route, {caller: CALLER, routingStrategy: "delegate", ...options})).toThrow(); + expect(() => inspectEnsoDelegateRoute(route, {caller: CALLER, routingStrategy: "delegate", chainId: 1, ...options})).toThrow(); + }); + + test("requires an explicit target, approval, recipient and ETH policy before execution", () => { + const approve = command("0x095ea7b3", 0x01, [0, 1], 0xff); + const route = response([approve], [addressWord(RECEIVER), stateWord(99)]); + const base = {caller: CALLER, routingStrategy: "delegate", chainId: 1}; + expect(() => buildEnsoDelegateBatch(route, base)).toThrow("policy is required"); + expect(() => buildEnsoDelegateBatch(route, {...base, policy: { + maxEthSpend: 0n, allowedTargets: [TOKEN], allowedApprovals: [], expectedReceiver: RECEIVER, + expectedInputToken: TOKEN, expectedOutputToken: TOKEN, + }})).toThrow("is not allowed by policy"); + const result = buildEnsoDelegateBatch(route, {...base, policy: { + maxEthSpend: 0n, allowedTargets: [TOKEN], expectedReceiver: RECEIVER, + expectedInputToken: TOKEN, expectedOutputToken: TOKEN, + allowedApprovals: [{token: TOKEN, operator: RECEIVER, maxAmount: 99n}], + }}); + expect(result.approvals).toEqual([{callIndex: 0, token: TOKEN, operator: RECEIVER, amount: 99n, kind: "approve"}]); + expect(result.compatibility.weirollVersion).toBe("1.4.1"); + }); + + test("caps aggregate internal ETH spend independently of outer msg.value", () => { + const pay = command("0x12345678", 0x03, [0], 0xff); + const route = response([pay], [stateWord(8)]); + const options = {caller: CALLER, routingStrategy: "delegate", chainId: 1, policy: { + maxEthSpend: 7n, allowedTargets: [TOKEN], expectedReceiver: TOKEN, + expectedInputToken: TOKEN, expectedOutputToken: TOKEN, + }}; + expect(() => buildEnsoDelegateBatch(route, options)).toThrow("above policy.maxEthSpend"); + }); + + test("rejects permit-style authorization that the approval policy cannot bound", () => { + const permit2 = command("0x87517c45", 0x01, [0, 1, 2, 3], 0xff); + expect(() => inspectEnsoDelegateRoute( + response([permit2], [addressWord(TOKEN), addressWord(RECEIVER), stateWord(1), stateWord(1)]), + {caller: CALLER, routingStrategy: "delegate", chainId: 1}, + )).toThrow("unsupported permit or Permit2 authorization"); + }); + + test("pins the supported chain and exact differential simulation deltas", () => { + expect(ENSO_COMPATIBILITY.weirollCommit).toHaveLength(40); + expect(() => inspectEnsoDelegateRoute(response([], []), { + caller: CALLER, routingStrategy: "delegate", chainId: 10, + })).toThrow("pinned to chain 1"); + const routeHash = `0x${"12".repeat(32)}`; + expect(assertEnsoSimulation({routeHash, chainId: 1, forkBlock: 1, + enso: {routeHash, inputDelta: 10n, outputDelta: 20n}, + scripter: {routeHash, inputDelta: 10n, outputDelta: 20n}, + minOutput: 19n, maxInput: 10n, + })).toEqual({inputDelta: 10n, outputDelta: 20n}); + expect(() => assertEnsoSimulation({routeHash, chainId: 1, forkBlock: 1, + enso: {routeHash, inputDelta: 10n, outputDelta: 20n}, + scripter: {routeHash, inputDelta: 11n, outputDelta: 20n}, + minOutput: 0n, maxInput: 20n, + })).toThrow("different balance deltas"); + }); + + test("generated scalar fan-out plans stay within the three-patch offset limit", () => { + let seed = 0x51c7; + for (let run = 0; run < 1024; run++) { + seed = (seed * 1103515245 + 12345) >>> 0; + const fanout = seed % 65; + const producer = command("0x12345678", 0x02, [], 0); + const consumers = Array.from({length: fanout}, () => command("0x87654321", 0x01, [0], 0xff)); + const inspected = inspectEnsoDelegateRoute(response([producer, ...consumers], [ZERO]), { + caller: CALLER, routingStrategy: "delegate", chainId: 1, + }); + const expectedRelays = fanout > 3 ? Math.ceil((fanout - 3) / 2) : 0; + expect(inspected.relayCount).toBe(expectedRelays); + expect(inspected.commandCount).toBe(1 + fanout + expectedRelays); + expect(inspected.batch.targets).toHaveLength(inspected.commandCount); + expect(inspected.batch.offsets).toHaveLength(inspected.commandCount); + } }); }); diff --git a/script/rehearse.sh b/script/rehearse.sh index c4252fcb..5a8e426a 100755 --- a/script/rehearse.sh +++ b/script/rehearse.sh @@ -57,7 +57,9 @@ echo '[2/5] Running protocol and EIP-7702 rehearsal' bun js/test/mainnet.js "$FORK_LOCAL" echo '[3/5] Comparing Enso Weiroll and Scripter on the same command plan' bun js/test/enso-differential.js "$FORK_LOCAL" -if [[ -n "${ENSO_API_KEY:-}" ]]; then +if [[ "${SKIP_ENSO_LIVE:-}" == 1 ]]; then + echo '[4/5] SKIP Enso live route (SKIP_ENSO_LIVE=1)' +elif [[ -n "${ENSO_API_KEY:-}" ]]; then echo '[4/5] Translating live Enso routes and comparing executors' bun js/test/enso-mainnet.js "$FORK_LOCAL" elif [[ -f .env ]]; then