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 3fd7b1ea..473e1429 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,85 @@ 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 + +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, inspectEnsoDelegateRoute, verifyEnsoCompatibility, +} from "multicall-scripter/enso"; + +const route = await enso.getRouteData({ + chainId: 1, + fromAddress: EXECUTOR, + routingStrategy: "delegate", + // tokenIn, tokenOut, amountIn, receiver, slippage... +}); +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], + value, +}); +``` + +`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. + +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 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. +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 +336,15 @@ 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` 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 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 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/SECURITY.md b/SECURITY.md index 11a3e61d..25ba5491 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -120,6 +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 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 new file mode 100644 index 00000000..25e1a066 --- /dev/null +++ b/js/enso.js @@ -0,0 +1,404 @@ +import { decodeFunctionData, encodeAbiParameters, getAddress, keccak256, 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 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; +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 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`); + 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(""); +} + +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)) { + 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); + 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"); } + 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: []})); + + 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))}; + } + + // 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"); + uses[dependency.producer].push({consumer, position: dependency.position}); + } + }); + 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 = expanded.map((call, i) => { + let valueIndex = 0; + if (call.value > 0n) { msgValues.push(call.value); valueIndex = msgValues.length; } + 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); + 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 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), + 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/index.js b/js/index.js index 3bbb2e72..e27c9afb 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 = []; 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-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 new file mode 100644 index 00000000..425d0a53 --- /dev/null +++ b/js/test/enso-differential.js @@ -0,0 +1,94 @@ +// 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 { + 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 = 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()}`); +await verifyEnsoCompatibility(client); + +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)}`; +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 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 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 new file mode 100644 index 00000000..2b3b89a2 --- /dev/null +++ b/js/test/enso-mainnet.js @@ -0,0 +1,177 @@ +// 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 { + createPublicClient, createWalletClient, getContractAddress, http, parseAbi, parseEther, toHex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +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"); +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, +}); +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 erc20 = parseAbi(["function balanceOf(address) view returns (uint256)"]); +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, spender: account.address, + refundReceiver: account.address, routingStrategy: "delegate", tokenIn: ETH, tokenOut, + amountIn: amountIn.toString(), slippage: "100", + }); + 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, + }); + 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); +} + +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 { + 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, 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], + value, gas: 20_000_000n, chain: null, + })}); + assert.equal(receipt.status, "success"); + const received = await client.readContract({address: outputToken, abi: erc20, functionName: "balanceOf", args: [receiver]}) - scripterBefore; + const scripterInput = scripterEthBefore - await client.getBalance({address: account.address}) + - receipt.gasUsed * receipt.effectiveGasPrice; + let minimum = 0n; + if (response.minAmountOut !== undefined) { + 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 results = []; +for (const testCase of LIVE_ENSO_CORPUS) { + try { + 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; + 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`); diff --git a/js/test/enso.test.js b/js/test/enso.test.js new file mode 100644 index 00000000..34730c09 --- /dev/null +++ b/js/test/enso.test.js @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test"; +import { encodeFunctionData, parseAbi } from "viem"; +import { decodeMemTarget, decodeResultLength } from "../encoding.js"; +import { + ENSO_COMPATIBILITY, assertEnsoSimulation, buildEnsoDelegateBatch, inspectEnsoDelegateRoute, +} from "../enso.js"; + +const CALLER = "0x00000000000000000000000000000000000000AA"; +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: CALLER, 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"), 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 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} = inspectEnsoDelegateRoute( + response([balanceOf, transfer], [addressWord(CALLER), addressWord(RECEIVER), "0x"]), + {caller: CALLER, routingStrategy: "delegate", chainId: 1}, + ); + 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); + // Delegate routes may spend the EOA's existing balance with tx.value == 0. + 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]); + expect(value).toBe(0n); + }); + + 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} = 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("")}`); + }); + + 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} = inspectEnsoDelegateRoute( + response([producer, ...consumers], [ZERO]), + {caller: CALLER, routingStrategy: "delegate", chainId: 1}, + ); + 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"}}, {}], + ["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(() => 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 934dfc6a..5a8e426a 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,9 +49,25 @@ 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/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" --sender "$FORK_SENDER" --unlocked --broadcast +DEPLOY_7702=true script/deploy.sh "$FORK_LOCAL" --rpc-timeout 30 --sender "$FORK_SENDER" --unlocked --broadcast +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 [[ "${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 + 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 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' ETH_RPC_URL="$FORK_UPSTREAM" FORK_BLOCK="$FORK_BLOCK" forge test --match-contract CallBuilderTest --threads 1 -vv