diff --git a/src/utilsTenderly.ts b/src/utilsTenderly.ts index 3c31913..d98e8a8 100644 --- a/src/utilsTenderly.ts +++ b/src/utilsTenderly.ts @@ -17,6 +17,41 @@ import type {Authorization7702Hex} from "./utils7702"; */ export type OverrideType = Record>>; +/** + * The 20-byte right-padded EIP-7702 marker EntryPoint v0.8+ expects at the + * head of `initCode` (any trailing bytes are the sender initialization call). + */ +const EIP7702_INITCODE_MARKER = "0x7702000000000000000000000000000000000000"; + +/** + * EIP-7702 userOps mark the factory field with a sentinel instead of a real + * factory: either the short form "0x7702" or the 20-byte right-padded form + * accepted by EntryPoint v0.8 (initCode starting with bytes 0x7702). + */ +function isEip7702FactorySentinel(factory: string | null | undefined): boolean { + if (factory == null) { + return false; + } + const factoryLowerCase = factory.toLowerCase(); + return factoryLowerCase === "0x7702" || factoryLowerCase === EIP7702_INITCODE_MARKER; +} + +/** + * Rebuild the packed `initCode` from split factory/factoryData fields, + * normalizing the short "0x7702" sentinel to the 20-byte right-padded marker + * so non-empty factoryData lands after a well-formed head. + */ +function buildWireInitCode(factory: string | null, factoryData: string | null): string { + if (factory == null) { + return "0x"; + } + let initCode = isEip7702FactorySentinel(factory) ? EIP7702_INITCODE_MARKER : factory; + if (factoryData != null) { + initCode += factoryData.slice(2); + } + return initCode; +} + /** * Shares an existing Tenderly simulation so it can be viewed via a public link. * @param tenderlyAccountSlug - The Tenderly account slug. @@ -176,13 +211,7 @@ export async function simulateUserOperationWithTenderly( callData = `0x1fad948c${encodedUserOperation.slice(2)}`; } else { userOperation = userOperation as UserOperationV7 | UserOperationV8 | UserOperationV9; - let initCode = "0x"; - if (userOperation.factory != null) { - initCode = userOperation.factory; - if (userOperation.factoryData != null) { - initCode += userOperation.factoryData.slice(2); - } - } + const initCode = buildWireInitCode(userOperation.factory, userOperation.factoryData); const accountGasLimits = "0x" + @@ -247,7 +276,9 @@ export async function simulateUserOperationWithTenderly( // override so the simulation passes. if ( !isV6UserOperation && - (userOperation as UserOperationV7 | UserOperationV8 | UserOperationV9).factory === "0x7702" + isEip7702FactorySentinel( + (userOperation as UserOperationV7 | UserOperationV8 | UserOperationV9).factory, + ) ) { const eip7702Auth = (userOperation as UserOperationV8 | UserOperationV9).eip7702Auth; if (eip7702Auth != null && eip7702Auth.address != null) { @@ -478,19 +509,37 @@ export async function simulateUserOperationCallDataWithTenderly( let callData = userOperation.callData; if ("initCode" in userOperation) { if (userOperation.initCode != null && userOperation.initCode.length > 2) { - factory = userOperation.initCode.slice(0, 22); - factoryData = userOperation.initCode.slice(22); + // initCode = 20-byte factory address ("0x" + 40 hex chars) ‖ factoryData + factory = userOperation.initCode.slice(0, 42); + factoryData = `0x${userOperation.initCode.slice(42)}`; } } else { factory = userOperation.factory; factoryData = userOperation.factoryData; - // EIP-7702 userOps use factory:"0x7702" as a sentinel with - // factoryData:null. This doesn't represent an actual factory - // deployment, so normalize to null. - if (factory === "0x7702") { - factory = null; - factoryData = null; + // EIP-7702 userOps use a "0x7702" factory sentinel instead of a real + // factory. Mirror the full handleOps simulation: install the sender's + // delegation code (0xef0100 ‖ delegatee) as a state override, and treat + // non-empty factoryData as the sender initialization call — made by the + // SenderCreator to the sender itself, not to a factory. + if (isEip7702FactorySentinel(factory)) { + const eip7702Auth = (userOperation as UserOperationV8ToSimulate | UserOperationV9ToSimulate) + .eip7702Auth; + if (eip7702Auth != null && eip7702Auth.address != null) { + const delegationCode = `0xef0100${eip7702Auth.address.toLowerCase().replace("0x", "")}`; + const senderLower = userOperation.sender.toLowerCase(); + stateOverrides = stateOverrides ? { ...stateOverrides } : {}; + stateOverrides[senderLower] = { + ...(stateOverrides[senderLower] || {}), + code: delegationCode, + }; + } + if (factoryData != null && factoryData !== "0x") { + factory = userOperation.sender; + } else { + factory = null; + factoryData = null; + } } // IAccountExecute.executeUserOp rewrite: when callData starts with the @@ -499,13 +548,7 @@ export async function simulateUserOperationCallDataWithTenderly( // sender.call(callData). Replicate here. const EXECUTE_USEROP_SELECTOR = "0x8dd7712f"; if (callData.toLowerCase().startsWith(EXECUTE_USEROP_SELECTOR)) { - let initCode = "0x"; - if (userOperation.factory != null) { - initCode = userOperation.factory; - if (userOperation.factoryData != null) { - initCode += userOperation.factoryData.slice(2); - } - } + const initCode = buildWireInitCode(userOperation.factory, userOperation.factoryData); const accountGasLimits = "0x" + @@ -763,8 +806,8 @@ export async function callTenderlySimulateBundle( to: string; data: string; gas?: number | null; - gasPrice?: number | null; - value?: number | null; + gasPrice?: bigint | number | null; + value?: bigint | number | null; blockNumber?: number | null; simulationType?: "full" | "quick" | "abi"; stateOverrides?: OverrideType | null; @@ -803,27 +846,37 @@ export async function callTenderlySimulateBundle( transactionObject.gas = transaction.gas; } if (transaction.gasPrice != null) { - transactionObject.gas_price = transaction.gasPrice; + // serialize bigint as a decimal string so wei amounts above 2^53 + // don't lose precision + transactionObject.gas_price = + typeof transaction.gasPrice === "bigint" + ? transaction.gasPrice.toString() + : transaction.gasPrice; } if (transaction.value != null) { - transactionObject.value = transaction.value; + transactionObject.value = + typeof transaction.value === "bigint" + ? transaction.value.toString() + : transaction.value; } if (transaction.stateOverrides != null) { - const stateOverrides = transaction.stateOverrides; - for (const address in stateOverrides) { - for (const key in stateOverrides[address]) { + // build a copy instead of rewriting the caller-owned object in place + const stateOverrides: OverrideType = {}; + for (const address in transaction.stateOverrides) { + const entry = { ...transaction.stateOverrides[address] }; + for (const key in entry) { if (key !== "balance" && key !== "code" && key !== "storage" && key !== "stateDiff") { throw new RangeError(`Invalid stateOverrides key: ${key}.`); - } else if ( - "storage" in stateOverrides[address] && - "stateDiff" in stateOverrides[address] - ) { - throw new RangeError("can't set both storage and stateDiff for stateOverrides"); - } else if ("stateDiff" in stateOverrides[address]) { - stateOverrides[address].storage = stateOverrides[address].stateDiff; - delete stateOverrides[address].stateDiff; } } + if ("storage" in entry && "stateDiff" in entry) { + throw new RangeError("can't set both storage and stateDiff for stateOverrides"); + } + if ("stateDiff" in entry) { + entry.storage = entry.stateDiff; + delete entry.stateDiff; + } + stateOverrides[address] = entry; } transactionObject.state_objects = stateOverrides; } diff --git a/test/utilsTenderly7702.test.js b/test/utilsTenderly7702.test.js new file mode 100644 index 0000000..1aa01a9 --- /dev/null +++ b/test/utilsTenderly7702.test.js @@ -0,0 +1,157 @@ +// Regression tests for EIP-7702 handling in the Tenderly simulation helpers: +// short/padded factory markers, fresh-delegation code overrides, and +// non-empty factoryData (sender initialization). Mocks global fetch and +// inspects the simulate-bundle request bodies. + +const ak = require("../dist/index.cjs"); + +const ENTRYPOINT_V8 = "0x4337084d9e255ff0702461cf8895ce9e3b5ff108"; +const SENDER_CREATOR_V8 = "0x449ed7c3e6fee6a97311d4b55475df59c44add33"; +const PADDED_MARKER = "0x7702000000000000000000000000000000000000"; +const SENDER = "0x1f9090aae28b8a3dceadf281b0f12828e676c326"; +const DELEGATEE = "0xe6cae83bde06e4c305530e199d7217f42808555b"; +const DELEGATION_CODE = `0xef0100${DELEGATEE.slice(2)}`; + +function makeV8UserOperation(overrides = {}) { + return { + sender: SENDER, + nonce: 1n, + factory: "0x7702", + factoryData: null, + callData: "0xb61d27f6", + callGasLimit: 100000n, + verificationGasLimit: 100000n, + preVerificationGas: 50000n, + maxFeePerGas: 1000000n, + maxPriorityFeePerGas: 1000000n, + paymaster: null, + paymasterVerificationGasLimit: null, + paymasterPostOpGasLimit: null, + paymasterData: null, + signature: "0x", + eip7702Auth: { address: DELEGATEE }, + ...overrides, + }; +} + +describe("Tenderly EIP-7702 simulation handling", () => { + const originalFetch = global.fetch; + let requests; + + beforeEach(() => { + requests = []; + global.fetch = async (url, init) => { + const body = JSON.parse(init.body); + requests.push({ url, body }); + return { + ok: true, + status: 200, + statusText: "OK", + text: async () => + JSON.stringify({ + simulation_results: body.simulations.map((_s, i) => ({ + transaction: {}, + simulation: { id: `sim-${i}` }, + })), + }), + }; + }; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe("full handleOps simulation (simulateUserOperationWithTenderly)", () => { + async function runFullSimulation(userOperation) { + await ak.simulateUserOperationWithTenderly( + "account", + "project", + "key", + 1n, + ENTRYPOINT_V8, + userOperation, + ); + expect(requests).toHaveLength(1); + return requests[0].body.simulations[0]; + } + + test("short marker with non-empty factoryData packs a padded marker head", async () => { + const sim = await runFullSimulation( + makeV8UserOperation({ factory: "0x7702", factoryData: "0xdeadbeef" }), + ); + // initCode must be 0x7702 right-padded to 20 bytes ‖ factoryData … + expect(sim.input).toContain(`${PADDED_MARKER.slice(2)}deadbeef`); + // … not the malformed direct concatenation 0x7702‖factoryData + expect(sim.input).not.toContain("7702deadbeef"); + }); + + test("padded marker with non-empty factoryData packs identically", async () => { + const sim = await runFullSimulation( + makeV8UserOperation({ factory: PADDED_MARKER, factoryData: "0xdeadbeef" }), + ); + expect(sim.input).toContain(`${PADDED_MARKER.slice(2)}deadbeef`); + }); + + test("installs the sender delegation-code override", async () => { + const sim = await runFullSimulation(makeV8UserOperation()); + expect(sim.state_objects[SENDER].code).toBe(DELEGATION_CODE); + }); + }); + + describe("direct call-data simulation (simulateUserOperationCallDataWithTenderly)", () => { + async function runCallDataSimulation(userOperation, stateOverrides) { + await ak.simulateUserOperationCallDataWithTenderly( + "account", + "project", + "key", + 1n, + ENTRYPOINT_V8, + userOperation, + null, + stateOverrides, + ); + expect(requests).toHaveLength(1); + return requests[0].body.simulations; + } + + test("fresh delegation installs the sender code override", async () => { + const sims = await runCallDataSimulation(makeV8UserOperation()); + expect(sims).toHaveLength(1); + expect(sims[0].to).toBe(SENDER); + expect(sims[0].state_objects[SENDER].code).toBe(DELEGATION_CODE); + }); + + test("non-empty factoryData enqueues a SenderCreator-to-sender initialization", async () => { + const sims = await runCallDataSimulation( + makeV8UserOperation({ factoryData: "0xdeadbeef" }), + ); + expect(sims).toHaveLength(2); + // initialization call: SenderCreator -> sender with the init data + expect(sims[0].from).toBe(SENDER_CREATOR_V8); + expect(sims[0].to).toBe(SENDER); + expect(sims[0].input).toBe("0xdeadbeef"); + // call-data execution follows, both with the delegation override + expect(sims[1].to).toBe(SENDER); + expect(sims[1].input).toBe("0xb61d27f6"); + expect(sims[0].state_objects[SENDER].code).toBe(DELEGATION_CODE); + expect(sims[1].state_objects[SENDER].code).toBe(DELEGATION_CODE); + }); + + test("without eip7702Auth no code override is installed", async () => { + const sims = await runCallDataSimulation(makeV8UserOperation({ eip7702Auth: null })); + expect(sims).toHaveLength(1); + expect(sims[0].state_objects).toBeUndefined(); + }); + + test("merges the delegation override without mutating caller state overrides", async () => { + const callerOverrides = { [SENDER]: { balance: "0x1" } }; + const sims = await runCallDataSimulation(makeV8UserOperation(), callerOverrides); + expect(sims[0].state_objects[SENDER]).toEqual({ + balance: "0x1", + code: DELEGATION_CODE, + }); + expect(callerOverrides).toEqual({ [SENDER]: { balance: "0x1" } }); + }); + }); +});