-
Notifications
You must be signed in to change notification settings - Fork 16
fix: Tenderly simulation initCode split, 7702 sentinel forms, and bigint precision #223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,41 @@ import type {Authorization7702Hex} from "./utils7702"; | |
| */ | ||
| export type OverrideType = Record<string, Record<string, string | Record<string, string>>>; | ||
|
|
||
| /** | ||
| * 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)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This avoids treating the padded marker as a factory, but the direct simulation is still semantically incomplete: it neither installs the sender's EIP-7702 delegation-code override nor preserves non-empty
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fixed |
||
| 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; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" } }); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The expanded padded-marker detection is valid, but the short-marker case is still malformed when
factoryDatais non-empty. TheinitCodeconstruction above concatenates the data immediately after0x7702, producing e.g.0x77021234; EntryPoint requires0x7702right-padded to 20 bytes before the initialization data. A live Tenderly run decoded exactly0x77021234and reverted through the ordinary deployment path withAA10 sender already constructed. Please normalize the short marker duringinitCodeconstruction and add a regression test for non-emptyfactoryData.