Skip to content
Merged
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
129 changes: 91 additions & 38 deletions src/utilsTenderly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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" +
Expand Down Expand Up @@ -247,7 +276,9 @@ export async function simulateUserOperationWithTenderly(
// override so the simulation passes.
if (
!isV6UserOperation &&
(userOperation as UserOperationV7 | UserOperationV8 | UserOperationV9).factory === "0x7702"
isEip7702FactorySentinel(

Copy link
Copy Markdown
Member

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 factoryData is non-empty. The initCode construction above concatenates the data immediately after 0x7702, producing e.g. 0x77021234; EntryPoint requires 0x7702 right-padded to 20 bytes before the initialization data. A live Tenderly run decoded exactly 0x77021234 and reverted through the ordinary deployment path with AA10 sender already constructed. Please normalize the short marker during initCode construction and add a regression test for non-empty factoryData.

(userOperation as UserOperationV7 | UserOperationV8 | UserOperationV9).factory,
)
) {
const eip7702Auth = (userOperation as UserOperationV8 | UserOperationV9).eip7702Auth;
if (eip7702Auth != null && eip7702Auth.address != null) {
Expand Down Expand Up @@ -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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 factoryData as the sender initialization call. In live Tenderly simulation 001431d4-9e83-409d-b667-49cafe163d16, this path returned status=true while executing no internal transfer; adding the missing code override made the same call execute successfully (c727864a-f42e-4e0e-8228-371482f24fa2). Please mirror the full simulation's delegation override here and, when factoryData is non-empty, enqueue the SenderCreator-to-sender initialization before the call-data transaction.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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
Expand All @@ -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" +
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
157 changes: 157 additions & 0 deletions test/utilsTenderly7702.test.js
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" } });
});
});
});
Loading