From fc36fc9a30a7f03441cf6fe3e7e05101488452b9 Mon Sep 17 00:00:00 2001 From: Foulk plb | Morpho <71005796+Foulks-Plb@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:08:57 +0200 Subject: [PATCH] feat(evm-simulation): add ecrecover precompile override for signature simulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `SimulateParams.ecrecoverOverride` to simulate signature-gated calls (e.g. EIP-2612 permit) without a real signature. Both backends install an ecrecover shim at 0x…0001 via a `code` state-override so signature recovery resolves to the given address; Tenderly also relocates the genuine precompile via `movePrecompileToAddress`, while the eth_simulateV1 fallback installs the shim only (viem's serializer drops the relocation field). Exports buildEcrecoverShimCode and the precompile/relocation address constants. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../evm-simulation-ecrecover-override.md | 5 ++ packages/evm-simulation/AGENTS.md | 1 + packages/evm-simulation/src/index.ts | 5 ++ .../simulate/backends/eth-simulate-v1.spec.ts | 43 ++++++++++++ .../src/simulate/backends/eth-simulate-v1.ts | 32 +++++++-- .../simulate/backends/tenderly-rpc.spec.ts | 44 +++++++++++++ .../src/simulate/backends/tenderly-rpc.ts | 35 ++++++++-- .../src/simulate/ecrecover-override.spec.ts | 65 +++++++++++++++++++ .../src/simulate/ecrecover-override.ts | 49 ++++++++++++++ .../pipeline/execute-simulation.spec.ts | 17 +++++ .../simulate/pipeline/execute-simulation.ts | 9 ++- .../simulate/pipeline/validate-input.spec.ts | 18 +++++ .../src/simulate/pipeline/validate-input.ts | 15 ++++- .../src/simulate/simulate.spec.ts | 8 +++ .../evm-simulation/src/simulate/simulate.ts | 4 ++ packages/evm-simulation/src/types.ts | 20 +++++- 16 files changed, 357 insertions(+), 13 deletions(-) create mode 100644 .changeset/evm-simulation-ecrecover-override.md create mode 100644 packages/evm-simulation/src/simulate/ecrecover-override.spec.ts create mode 100644 packages/evm-simulation/src/simulate/ecrecover-override.ts diff --git a/.changeset/evm-simulation-ecrecover-override.md b/.changeset/evm-simulation-ecrecover-override.md new file mode 100644 index 000000000..281956d69 --- /dev/null +++ b/.changeset/evm-simulation-ecrecover-override.md @@ -0,0 +1,5 @@ +--- +"@morpho-org/evm-simulation": minor +--- + +Add `SimulateParams.ecrecoverOverride` to simulate signature-gated calls (e.g. EIP-2612 `permit`) without a real signature. When set, both backends install an `ecrecover` shim at the `0x…0001` precompile via a `code` state-override so signature recovery resolves to the given address; the Tenderly backend also relocates the genuine precompile via `movePrecompileToAddress`, while the `eth_simulateV1` fallback installs the shim only (viem's state-override serializer drops the relocation field — behaviourally identical for standard contracts that call `0x…0001` directly). Also exports `buildEcrecoverShimCode`, `ECRECOVER_PRECOMPILE_ADDRESS`, and `ECRECOVER_RELOCATED_ADDRESS`. diff --git a/packages/evm-simulation/AGENTS.md b/packages/evm-simulation/AGENTS.md index d45cf61d6..553a0a1c7 100644 --- a/packages/evm-simulation/AGENTS.md +++ b/packages/evm-simulation/AGENTS.md @@ -5,6 +5,7 @@ - Let `SimulationRevertedError` propagate; a revert belongs to the bundle, not the backend. - Keep backend outputs normalized to `RawSimulationResult`; add new backends under `src/simulate/backends/` with colocated parity specs. - Encode signature authorizations as `approve(spender, amount ?? maxUint256)` and prepend them to the simulated bundle. +- To simulate a signature-gated call in place (e.g. an EIP-2612 `permit`) without a real signature, use `SimulateParams.ecrecoverOverride`: both backends install an `ecrecover` shim at `0x…0001` via a `code` state-override (Tenderly also relocates the genuine precompile via `movePrecompileToAddress`; `eth_simulateV1` installs the shim only — viem's serializer drops the relocation field). Build the shim bytecode via `buildEcrecoverShimCode`; the addresses are the `ECRECOVER_PRECOMPILE_ADDRESS` / `ECRECOVER_RELOCATED_ADDRESS` constants. - Enforce bundler retention by net `(bundler3 address, token)` balance with `DUST_THRESHOLD = 100n`; skip only unknown blue-sdk chains. - Keep all thrown domain errors under `SimulationPackageError`; only `ExternalServiceError` is bypassable by callers. - Add chains through caller `SimulationConfig.chains`; the per-chain `ChainSimulationConfig` is a discriminated union enforcing at least one of `tenderlyRpc` or `simulateV1Url`. Confirm blue-sdk bundler addresses intentionally. diff --git a/packages/evm-simulation/src/index.ts b/packages/evm-simulation/src/index.ts index 5a222c08e..71ff549d9 100644 --- a/packages/evm-simulation/src/index.ts +++ b/packages/evm-simulation/src/index.ts @@ -9,6 +9,11 @@ export { SimulationValidationError, UnsupportedChainError, } from "./errors.js"; +export { + buildEcrecoverShimCode, + ECRECOVER_PRECOMPILE_ADDRESS, + ECRECOVER_RELOCATED_ADDRESS, +} from "./simulate/ecrecover-override.js"; export { simulate } from "./simulate/index.js"; // Types export type { diff --git a/packages/evm-simulation/src/simulate/backends/eth-simulate-v1.spec.ts b/packages/evm-simulation/src/simulate/backends/eth-simulate-v1.spec.ts index 74a52ad7b..fd18467db 100644 --- a/packages/evm-simulation/src/simulate/backends/eth-simulate-v1.spec.ts +++ b/packages/evm-simulation/src/simulate/backends/eth-simulate-v1.spec.ts @@ -175,6 +175,49 @@ describe.sequential("simulateV1", () => { ]); }); + it("installs the ecrecover shim at 0x01 when ecrecoverOverride is set", async () => { + mockSimulateCalls.mockResolvedValueOnce({ + results: [ + { status: "success", gasUsed: 0n, data: "0x" as Hex, logs: [] }, + ], + }); + + await simulateV1({ + rpcUrl: "http://rpc.local", + chainId: 1, + transactions: [BASIC_TX], + ecrecoverOverride: OTHER, + }); + + const callArgs = mockSimulateCalls.mock.calls[0]![0]; + expect(callArgs.stateOverrides).toEqual([ + { address: USER, balance: maxUint256 }, + { + address: "0x0000000000000000000000000000000000000001", + code: `0x73${OTHER.slice(2).toLowerCase()}60005260206000f3`, + }, + ]); + }); + + it("omits the ecrecover override when ecrecoverOverride is unset", async () => { + mockSimulateCalls.mockResolvedValueOnce({ + results: [ + { status: "success", gasUsed: 0n, data: "0x" as Hex, logs: [] }, + ], + }); + + await simulateV1({ + rpcUrl: "http://rpc.local", + chainId: 1, + transactions: [BASIC_TX], + }); + + const callArgs = mockSimulateCalls.mock.calls[0]![0]; + expect(callArgs.stateOverrides).toEqual([ + { address: USER, balance: maxUint256 }, + ]); + }); + it("passes blockNumber as bigint when provided", async () => { mockSimulateCalls.mockResolvedValueOnce({ results: [ diff --git a/packages/evm-simulation/src/simulate/backends/eth-simulate-v1.ts b/packages/evm-simulation/src/simulate/backends/eth-simulate-v1.ts index 1fc5b0ec5..11449caa6 100644 --- a/packages/evm-simulation/src/simulate/backends/eth-simulate-v1.ts +++ b/packages/evm-simulation/src/simulate/backends/eth-simulate-v1.ts @@ -1,10 +1,12 @@ import { + type Address, type BlockTag, createPublicClient, ethAddress, getAddress, http, maxUint256, + type StateOverride, } from "viem"; import { ExternalServiceError, @@ -19,6 +21,10 @@ import type { Transfer, } from "../../types.js"; import { type AssetChangeEntry, groupAssetChanges } from "../asset-changes.js"; +import { + buildEcrecoverShimCode, + ECRECOVER_PRECOMPILE_ADDRESS, +} from "../ecrecover-override.js"; import { parseTransfers } from "../parsing/index.js"; /** @@ -35,6 +41,12 @@ import { parseTransfers } from "../parsing/index.js"; * native ETH moved through internal calls (e.g. a `WETH.withdraw` refund, a * swap that pays out ETH) emits no log and is not reflected in `value`, so it * is not captured. For full native-ETH accounting use Tenderly. + * + * When `ecrecoverOverride` is set, installs an `ecrecover` shim at `0x…0001` + * via a `code` state-override so signature-gated calls recover that address + * (see `buildEcrecoverShimCode`). The genuine precompile is not relocated here: + * viem's state-override serializer drops `movePrecompileToAddress`, which is + * behaviourally identical for standard contracts that call `0x…0001` directly. */ export async function simulateV1(params: { rpcUrl: string; @@ -42,8 +54,10 @@ export async function simulateV1(params: { transactions: SimulationTransaction[]; blockNumber?: bigint | BlockTag; signal?: AbortSignal; + ecrecoverOverride?: Address; }): Promise { - const { rpcUrl, transactions, blockNumber, signal } = params; + const { rpcUrl, transactions, blockNumber, signal, ecrecoverOverride } = + params; const client = createPublicClient({ transport: http(rpcUrl, { @@ -84,14 +98,24 @@ export async function simulateV1(params: { ? { blockTag: blockNumber } : {}; + // Inflate sender ETH balance to prevent false "insufficient gas" reverts. + // Without this, valid ERC20 flows fail when the sender has low ETH. + const stateOverrides: StateOverride = [ + { address: sender, balance: maxUint256 }, + ]; + if (ecrecoverOverride) { + stateOverrides.push({ + address: ECRECOVER_PRECOMPILE_ADDRESS, + code: buildEcrecoverShimCode(ecrecoverOverride), + }); + } + try { const simulationResult = await client.simulateCalls({ account: sender, calls, ...blockParam, - // Inflate sender ETH balance to prevent false "insufficient gas" reverts. - // Without this, valid ERC20 flows fail when the sender has low ETH. - stateOverrides: [{ address: sender, balance: maxUint256 }], + stateOverrides, }); const results = simulationResult.results; diff --git a/packages/evm-simulation/src/simulate/backends/tenderly-rpc.spec.ts b/packages/evm-simulation/src/simulate/backends/tenderly-rpc.spec.ts index d75ffff86..dc66f68d3 100644 --- a/packages/evm-simulation/src/simulate/backends/tenderly-rpc.spec.ts +++ b/packages/evm-simulation/src/simulate/backends/tenderly-rpc.spec.ts @@ -341,6 +341,50 @@ describe.sequential("simulateTenderlyRpc — single tx", () => { ); }); + it("installs the ecrecover shim (with precompile relocation) when ecrecoverOverride is set", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => envelope(successResult()), + }); + installFetchMock(fetchMock); + + await simulateTenderlyRpc({ + config: CONFIG, + transactions: [TX1], + ecrecoverOverride: VAULT, + }); + + const body = requestBody(fetchMock.mock.calls[0]!); + const [, , overrides] = body.params as [ + unknown, + unknown, + Record, + ]; + const precompile = overrides["0x0000000000000000000000000000000000000001"]!; + expect(precompile.code).toBe( + `0x73${VAULT.slice(2).toLowerCase()}60005260206000f3`, + ); + expect(precompile.movePrecompileToAddress).toBe( + "0x00000000000000000000000000000000000ec1ec", + ); + }); + + it("omits the ecrecover override when ecrecoverOverride is unset", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => envelope(successResult()), + }); + installFetchMock(fetchMock); + + await simulateTenderlyRpc({ config: CONFIG, transactions: [TX1] }); + + const body = requestBody(fetchMock.mock.calls[0]!); + const [, , overrides] = body.params as [unknown, unknown, object]; + expect("0x0000000000000000000000000000000000000001" in overrides).toBe( + false, + ); + }); + it("encodes tx.value as hex", async () => { const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true, diff --git a/packages/evm-simulation/src/simulate/backends/tenderly-rpc.ts b/packages/evm-simulation/src/simulate/backends/tenderly-rpc.ts index 929df764a..05776fba2 100644 --- a/packages/evm-simulation/src/simulate/backends/tenderly-rpc.ts +++ b/packages/evm-simulation/src/simulate/backends/tenderly-rpc.ts @@ -24,6 +24,11 @@ import type { TenderlyRpcConfig, } from "../../types.js"; import { type AssetChangeEntry, groupAssetChanges } from "../asset-changes.js"; +import { + buildEcrecoverShimCode, + ECRECOVER_PRECOMPILE_ADDRESS, + ECRECOVER_RELOCATED_ADDRESS, +} from "../ecrecover-override.js"; interface TenderlyRpcCall { from: Address; @@ -115,6 +120,10 @@ const bundleEnvelope = rpcEnvelope(z.array(simResultSchema).min(1)); * @param params.transactions - Bundle to simulate, in execution order. * @param params.blockNumber - Optional pinned block number or `BlockTag`. Defaults to `latest`. * @param params.signal - Optional `AbortSignal` for cancellation / timeout. + * @param params.ecrecoverOverride - Optional signer the `ecrecover` precompile + * should resolve to. When set, installs the `ecrecover` shim at `0x…0001` + * (relocating the genuine precompile via `movePrecompileToAddress`) so + * signature-gated calls validate against that address. * @returns A {@link RawSimulationResult} with one `RawCall` per input transaction. * @throws {SimulationValidationError} when `transactions` is empty. * @throws {SimulationRevertedError} when any simulated tx reports `status: false`. @@ -126,8 +135,10 @@ export async function simulateTenderlyRpc(params: { transactions: SimulationTransaction[]; blockNumber?: bigint | BlockTag; signal?: AbortSignal; + ecrecoverOverride?: Address; }): Promise { - const { config, transactions, blockNumber, signal } = params; + const { config, transactions, blockNumber, signal, ecrecoverOverride } = + params; const firstTx = transactions[0]; if (!firstTx) { @@ -140,7 +151,7 @@ export async function simulateTenderlyRpc(params: { const block = encodeBlock(blockNumber); // Inflate sender ETH balance to avoid false "insufficient funds for gas" // reverts on wallets low on native gas token — mirrors simulateV1. - const stateOverrides = buildStateOverrides(firstTx.from); + const stateOverrides = buildStateOverrides(firstTx.from, ecrecoverOverride); try { if (transactions.length === 1) { @@ -224,10 +235,26 @@ function buildCall(tx: SimulationTransaction): TenderlyRpcCall { }; } +interface TenderlyAccountOverride { + balance?: Hex; + code?: Hex; + movePrecompileToAddress?: Address; +} + function buildStateOverrides( sender: Address, -): Record { - return { [sender]: { balance: numberToHex(maxUint256) } }; + ecrecoverOverride?: Address, +): Record { + const overrides: Record = { + [sender]: { balance: numberToHex(maxUint256) }, + }; + if (ecrecoverOverride) { + overrides[ECRECOVER_PRECOMPILE_ADDRESS] = { + code: buildEcrecoverShimCode(ecrecoverOverride), + movePrecompileToAddress: ECRECOVER_RELOCATED_ADDRESS, + }; + } + return overrides; } function encodeBlock(blockNumber?: bigint | BlockTag): string { diff --git a/packages/evm-simulation/src/simulate/ecrecover-override.spec.ts b/packages/evm-simulation/src/simulate/ecrecover-override.spec.ts new file mode 100644 index 000000000..7fd629f27 --- /dev/null +++ b/packages/evm-simulation/src/simulate/ecrecover-override.spec.ts @@ -0,0 +1,65 @@ +import type { Address } from "viem"; +import { + buildEcrecoverShimCode, + ECRECOVER_PRECOMPILE_ADDRESS, + ECRECOVER_RELOCATED_ADDRESS, +} from "./ecrecover-override.js"; + +describe("buildEcrecoverShimCode", () => { + test("default: encodes PUSH20 MSTORE RETURN", () => { + const owner: Address = "0x1111111111111111111111111111111111111111"; + expect(buildEcrecoverShimCode(owner)).toBe( + "0x73111111111111111111111111111111111111111160005260206000f3", + ); + }); + + test("behavior: lowercases a checksummed owner", () => { + const checksum: Address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + expect(buildEcrecoverShimCode(checksum)).toBe( + `0x73${checksum.slice(2).toLowerCase()}60005260206000f3`, + ); + }); + + test("behavior: checksummed and lowercased inputs produce identical code", () => { + const checksum: Address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + const lower = checksum.toLowerCase() as Address; + expect(checksum).not.toBe(lower); + expect(buildEcrecoverShimCode(checksum)).toBe( + buildEcrecoverShimCode(lower), + ); + }); + + test("behavior: embeds exactly the 20-byte owner between fixed prefix and suffix", () => { + const owner: Address = "0x2222222222222222222222222222222222222222"; + const code = buildEcrecoverShimCode(owner); + expect(code.startsWith("0x73")).toBe(true); + expect(code.endsWith("60005260206000f3")).toBe(true); + // "0x" + "73" + 40 hex (20 bytes) + 16 hex (8 bytes) = 60 chars. + expect(code).toHaveLength(60); + expect(code.slice(4, 44)).toBe(owner.slice(2).toLowerCase()); + }); + + test("behavior: distinct owners produce distinct code", () => { + const a: Address = "0x1111111111111111111111111111111111111111"; + const b: Address = "0x2222222222222222222222222222222222222222"; + expect(buildEcrecoverShimCode(a)).not.toBe(buildEcrecoverShimCode(b)); + }); + + test("error: throws on a malformed address", () => { + expect(() => buildEcrecoverShimCode("0xnope" as Address)).toThrow(); + }); +}); + +describe("ecrecover override constants", () => { + test("default: precompile address is 0x…0001", () => { + expect(ECRECOVER_PRECOMPILE_ADDRESS).toBe( + "0x0000000000000000000000000000000000000001", + ); + }); + + test("default: relocated address is 0x…0ec1ec", () => { + expect(ECRECOVER_RELOCATED_ADDRESS).toBe( + "0x00000000000000000000000000000000000ec1ec", + ); + }); +}); diff --git a/packages/evm-simulation/src/simulate/ecrecover-override.ts b/packages/evm-simulation/src/simulate/ecrecover-override.ts new file mode 100644 index 000000000..881b16d8e --- /dev/null +++ b/packages/evm-simulation/src/simulate/ecrecover-override.ts @@ -0,0 +1,49 @@ +import { type Address, getAddress, type Hex } from "viem"; + +/** + * Canonical address of the `ecrecover` precompile — `0x…0001` on every EVM + * chain. Installing a state-override `code` here replaces signature recovery + * for the simulated bundle, which is how {@link buildEcrecoverShimCode} fakes a + * signer (see `SimulateParams.ecrecoverOverride`). + */ +export const ECRECOVER_PRECOMPILE_ADDRESS = + "0x0000000000000000000000000000000000000001" as const; + +/** + * Address the real `ecrecover` precompile is relocated to when the shim takes + * over `0x…0001`. `eth_simulateV1`'s `movePrecompileToAddress` (and Tenderly's + * equivalent) preserves the genuine precompile here so a backend can keep it + * reachable; standard contracts call `0x…0001` directly and therefore hit the + * shim regardless. The sentinel spells `ec1ec` to read as "ecrec". + */ +export const ECRECOVER_RELOCATED_ADDRESS = + "0x00000000000000000000000000000000000ec1ec" as const; + +/** + * Build the runtime bytecode for an `ecrecover` shim that ignores its calldata + * and always returns `owner`, ABI-encoded as a left-padded 32-byte word — the + * exact shape genuine `ecrecover` returns. Overriding the precompile's `code` + * with this makes any signature-gated path (e.g. EIP-2612 `permit`) validate + * against `owner` without a real signature, which is what + * `SimulateParams.ecrecoverOverride` wires into both simulation backends. + * + * The bytecode is `PUSH20 ; PUSH1 0x00; MSTORE; PUSH1 0x20; PUSH1 0x00; + * RETURN` — `0x73 6000 52 6020 6000 f3`. + * + * @param owner - The address every recovery should resolve to. Normalized with + * `getAddress`, so any casing is accepted. + * @returns The shim's runtime bytecode as a `Hex` string. + * @throws {InvalidAddressError} (from viem `getAddress`) when `owner` is not a + * valid address. + * @example + * ```ts + * import { buildEcrecoverShimCode } from "@morpho-org/evm-simulation"; + * + * buildEcrecoverShimCode("0x1111111111111111111111111111111111111111"); + * // "0x73111111111111111111111111111111111111111160005260206000f3" + * ``` + */ +export function buildEcrecoverShimCode(owner: Address): Hex { + const word = getAddress(owner).slice(2).toLowerCase(); + return `0x73${word}60005260206000f3`; +} diff --git a/packages/evm-simulation/src/simulate/pipeline/execute-simulation.spec.ts b/packages/evm-simulation/src/simulate/pipeline/execute-simulation.spec.ts index 1bbc30d2d..be0d3e5c7 100644 --- a/packages/evm-simulation/src/simulate/pipeline/execute-simulation.spec.ts +++ b/packages/evm-simulation/src/simulate/pipeline/execute-simulation.spec.ts @@ -81,6 +81,23 @@ describe.sequential("executeSimulation — Tenderly + simulateV1 configured", () ); }); + it("forwards ecrecoverOverride to Tenderly and to the simulateV1 fallback", async () => { + mockTenderlyRpc.mockRejectedValueOnce( + new ExternalServiceError("Tenderly 502"), + ); + mockSimulateV1.mockResolvedValueOnce({ calls: [], assetChanges: [] }); + + await executeSimulation({ + config: bothBackends(), + chainId: 1, + transactions: txs, + ecrecoverOverride: USER, + }); + + expect(mockTenderlyRpc.mock.calls[0]![0].ecrecoverOverride).toBe(USER); + expect(mockSimulateV1.mock.calls[0]![0].ecrecoverOverride).toBe(USER); + }); + it("allocates 60% of timeoutMs to Tenderly (budget-ratio pin)", async () => { const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); try { diff --git a/packages/evm-simulation/src/simulate/pipeline/execute-simulation.ts b/packages/evm-simulation/src/simulate/pipeline/execute-simulation.ts index 72e1af787..28eec5c82 100644 --- a/packages/evm-simulation/src/simulate/pipeline/execute-simulation.ts +++ b/packages/evm-simulation/src/simulate/pipeline/execute-simulation.ts @@ -1,4 +1,4 @@ -import type { BlockTag } from "viem"; +import type { Address, BlockTag } from "viem"; import { ExternalServiceError, UnsupportedChainError } from "../../errors.js"; import type { RawSimulationResult, @@ -43,8 +43,10 @@ export async function executeSimulation(params: { chainId: number; transactions: SimulationTransaction[]; blockNumber?: bigint | BlockTag; + ecrecoverOverride?: Address; }): Promise { - const { config, chainId, transactions, blockNumber } = params; + const { config, chainId, transactions, blockNumber, ecrecoverOverride } = + params; const chain = resolveChain(config, chainId); const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS; const deadline = Date.now() + timeoutMs; @@ -58,6 +60,7 @@ export async function executeSimulation(params: { transactions, blockNumber, signal: AbortSignal.timeout(tenderlyTimeout), + ecrecoverOverride, }); } catch (error) { if (!(error instanceof ExternalServiceError)) throw error; @@ -81,6 +84,7 @@ export async function executeSimulation(params: { transactions, blockNumber, signal: AbortSignal.timeout(fallbackBudget), + ecrecoverOverride, }); } } @@ -96,5 +100,6 @@ export async function executeSimulation(params: { transactions, blockNumber, signal: AbortSignal.timeout(timeoutMs), + ecrecoverOverride, }); } diff --git a/packages/evm-simulation/src/simulate/pipeline/validate-input.spec.ts b/packages/evm-simulation/src/simulate/pipeline/validate-input.spec.ts index 27d1e049b..9223739e0 100644 --- a/packages/evm-simulation/src/simulate/pipeline/validate-input.spec.ts +++ b/packages/evm-simulation/src/simulate/pipeline/validate-input.spec.ts @@ -198,4 +198,22 @@ describe("validateInput", () => { ), ).toThrow(SimulationValidationError); }); + + it("does not throw when ecrecoverOverride is a valid non-zero address", () => { + expect(() => + validateInput(params({ ecrecoverOverride: OTHER })), + ).not.toThrow(); + }); + + it("throws when ecrecoverOverride is the zero address", () => { + expect(() => + validateInput(params({ ecrecoverOverride: zeroAddress })), + ).toThrow(SimulationValidationError); + }); + + it("throws when ecrecoverOverride is a malformed address", () => { + expect(() => + validateInput(params({ ecrecoverOverride: "0xnope" as Address })), + ).toThrow(SimulationValidationError); + }); }); diff --git a/packages/evm-simulation/src/simulate/pipeline/validate-input.ts b/packages/evm-simulation/src/simulate/pipeline/validate-input.ts index 7d25d11ba..b14d25c46 100644 --- a/packages/evm-simulation/src/simulate/pipeline/validate-input.ts +++ b/packages/evm-simulation/src/simulate/pipeline/validate-input.ts @@ -8,8 +8,9 @@ import { validateAuthorizations } from "../authorizations/index.js"; * * Throws `SimulationValidationError` with a `fieldErrors[]` list on any invalid input: * empty transactions, malformed / zero-addr fields, missing `data`, negative `value`, - * bad `chainId`, or mixed senders (all txs in a bundle must share the same `from`). - * Also runs `validateAuthorizations` on the optional authorizations array. + * bad `chainId`, a malformed / zero-addr `ecrecoverOverride`, or mixed senders (all txs + * in a bundle must share the same `from`). Also runs `validateAuthorizations` on the + * optional authorizations array. */ export function validateInput(params: SimulateParams): void { const errors: string[] = []; @@ -54,6 +55,16 @@ export function validateInput(params: SimulateParams): void { } } + if ( + params.ecrecoverOverride !== undefined && + (!isAddress(params.ecrecoverOverride) || + params.ecrecoverOverride === zeroAddress) + ) { + errors.push( + `ecrecoverOverride: must be a valid non-zero address (got ${params.ecrecoverOverride})`, + ); + } + if (params.authorizations) { errors.push(...validateAuthorizations(params.authorizations)); } diff --git a/packages/evm-simulation/src/simulate/simulate.spec.ts b/packages/evm-simulation/src/simulate/simulate.spec.ts index a95dfdaa8..c400b197b 100644 --- a/packages/evm-simulation/src/simulate/simulate.spec.ts +++ b/packages/evm-simulation/src/simulate/simulate.spec.ts @@ -97,6 +97,14 @@ describe.sequential("simulate — success", () => { expect(result.simulationTxs).toEqual(params.transactions); }); + it("threads ecrecoverOverride through to the backend", async () => { + mockTenderlyRpc.mockResolvedValueOnce(makeSuccessResult()); + + await simulate(makeConfig(), makeParams({ ecrecoverOverride: SPENDER })); + + expect(mockTenderlyRpc.mock.calls[0]![0].ecrecoverOverride).toBe(SPENDER); + }); + it("surfaces non-empty assetChanges from the backend unchanged", async () => { const logs = [ makeTransferLog({ token: USDC, from: USER, to: VAULT, amount: 1000000n }), diff --git a/packages/evm-simulation/src/simulate/simulate.ts b/packages/evm-simulation/src/simulate/simulate.ts index 41a963849..4fa26a223 100644 --- a/packages/evm-simulation/src/simulate/simulate.ts +++ b/packages/evm-simulation/src/simulate/simulate.ts @@ -39,6 +39,9 @@ import { * @param params.authorizations - Optional token authorizations resolved into prepended approve * transactions before the main bundle runs. * @param params.blockNumber - Optional pinned block number or `BlockTag`. Defaults to `latest`. + * @param params.ecrecoverOverride - Optional signer the `ecrecover` precompile should + * resolve to, for simulating signature-gated calls (e.g. EIP-2612 `permit`) without a + * real signature. Applied as a `code` state-override on both backends. * @throws {SimulationValidationError} for invalid input (mixed senders, bad addresses, * empty transactions, malformed authorizations). * @throws {UnsupportedChainError} when the chain is not configured for any backend. @@ -85,6 +88,7 @@ export async function simulate( chainId: params.chainId, transactions: simulationTxs, blockNumber: params.blockNumber, + ecrecoverOverride: params.ecrecoverOverride, }); if (result.calls.length !== simulationTxs.length) { throw new ExternalServiceError( diff --git a/packages/evm-simulation/src/types.ts b/packages/evm-simulation/src/types.ts index 91ac1a900..1971ee9a6 100644 --- a/packages/evm-simulation/src/types.ts +++ b/packages/evm-simulation/src/types.ts @@ -65,7 +65,12 @@ export interface SimulationTransaction { * How the caller expresses a token authorization that must be in place before the * main transactions run. The package decides HOW to simulate each one: * - "approval" → prepend tx as-is - * - "signature" → today: encode approve(spender, amount); future: ecrecover override? + * - "signature" → encode approve(spender, amount) and prepend it + * + * To instead simulate a signature-gated call in place (e.g. an EIP-2612 `permit` + * already encoded in `transactions`) without a real signature, set + * `SimulateParams.ecrecoverOverride` to the expected signer rather than adding a + * `signature` authorization. */ export type SimulationAuthorization = | { type: "approval"; transaction: SimulationTransaction } @@ -167,6 +172,19 @@ export interface SimulateParams { transactions: SimulationTransaction[]; authorizations?: SimulationAuthorization[]; blockNumber?: bigint | BlockTag; + /** + * Override the `ecrecover` precompile (`0x…0001`) so every signature recovery + * in the bundle resolves to this address. Use it to simulate a signature-gated + * call (e.g. an EIP-2612 `permit`) without a real signature — the on-chain + * signature check passes as if signed by `ecrecoverOverride`. + * + * Applied as a `code` state-override on both backends. On Tenderly the genuine + * precompile is relocated (`movePrecompileToAddress`); the `eth_simulateV1` + * fallback installs the shim only — viem's state-override serializer drops the + * relocation field — which is behaviourally identical for standard contracts + * since they call `0x…0001` directly and hit the shim either way. + */ + ecrecoverOverride?: Address; } // ─── Internal (consumed by backends / pipeline) ───────────────────────────────