Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/evm-simulation-ecrecover-override.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions packages/evm-simulation/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions packages/evm-simulation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
type Address,
type BlockTag,
createPublicClient,
ethAddress,
getAddress,
http,
maxUint256,
type StateOverride,
} from "viem";
import {
ExternalServiceError,
Expand All @@ -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";

/**
Expand All @@ -35,15 +41,23 @@ 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;
chainId: number;
transactions: SimulationTransaction[];
blockNumber?: bigint | BlockTag;
signal?: AbortSignal;
ecrecoverOverride?: Address;
}): Promise<RawSimulationResult> {
const { rpcUrl, transactions, blockNumber, signal } = params;
const { rpcUrl, transactions, blockNumber, signal, ecrecoverOverride } =
params;

const client = createPublicClient({
transport: http(rpcUrl, {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MockFetch>().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<Address, { code?: Hex; movePrecompileToAddress?: Address }>,
];
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<MockFetch>().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<MockFetch>().mockResolvedValueOnce({
ok: true,
Expand Down
35 changes: 31 additions & 4 deletions packages/evm-simulation/src/simulate/backends/tenderly-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`.
Expand All @@ -126,8 +135,10 @@ export async function simulateTenderlyRpc(params: {
transactions: SimulationTransaction[];
blockNumber?: bigint | BlockTag;
signal?: AbortSignal;
ecrecoverOverride?: Address;
}): Promise<RawSimulationResult> {
const { config, transactions, blockNumber, signal } = params;
const { config, transactions, blockNumber, signal, ecrecoverOverride } =
params;

const firstTx = transactions[0];
if (!firstTx) {
Expand All @@ -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) {
Expand Down Expand Up @@ -224,10 +235,26 @@ function buildCall(tx: SimulationTransaction): TenderlyRpcCall {
};
}

interface TenderlyAccountOverride {
balance?: Hex;
code?: Hex;
movePrecompileToAddress?: Address;
}

function buildStateOverrides(
sender: Address,
): Record<Address, { balance: Hex }> {
return { [sender]: { balance: numberToHex(maxUint256) } };
ecrecoverOverride?: Address,
): Record<Address, TenderlyAccountOverride> {
const overrides: Record<Address, TenderlyAccountOverride> = {
[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 {
Expand Down
65 changes: 65 additions & 0 deletions packages/evm-simulation/src/simulate/ecrecover-override.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <owner> 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",
);
});
});
49 changes: 49 additions & 0 deletions packages/evm-simulation/src/simulate/ecrecover-override.ts
Original file line number Diff line number Diff line change
@@ -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 <owner>; PUSH1 0x00; MSTORE; PUSH1 0x20; PUSH1 0x00;
* RETURN` — `0x73 <owner> 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`;
}
Loading