Skip to content

Commit 9608d2f

Browse files
committed
refactor(publisher): extract bundle simulation into SequencerBundleSimulator
Moves the bundleSimulate logic out of SequencerPublisher into a dedicated SequencerBundleSimulator class. - Always uses targetSlot's start timestamp for the simulation block.timestamp (no longer minned with predictedNextL1Ts). Known limitation documented. - New second-pass policy: if any entry reverts in pass 1, re-simulate the reduced bundle; if pass 2 surfaces any revert OR returns fallback, abort the entire send. - On first-pass fallback (eth_simulateV1 unsupported), caller sends the bundle as-is with MAX_L1_TX_LIMIT. - multicall3HasCode is now cached only when true, so a transient miss won't permanently lock out future sends. - aborted result carries an explicit reason and only includes truly-reverted entries in droppedRequests, so backupFailedTx records aren't misleading.
1 parent 52ebc5a commit 9608d2f

3 files changed

Lines changed: 321 additions & 239 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import type { EpochCache } from '@aztec/epoch-cache';
2+
import {
3+
MULTI_CALL_3_ADDRESS,
4+
Multicall3,
5+
type RollupContract,
6+
buildSimulationOverridesStateOverride,
7+
} from '@aztec/ethereum/contracts';
8+
import { type L1TxUtils, MAX_L1_TX_LIMIT } from '@aztec/ethereum/l1-tx-utils';
9+
import { formatViemError } from '@aztec/ethereum/utils';
10+
import type { SlotNumber } from '@aztec/foundation/branded-types';
11+
import { type Logger, createLogger } from '@aztec/foundation/log';
12+
import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
13+
14+
import type { Hex, StateOverride } from 'viem';
15+
16+
import type { RequestWithExpiry } from './sequencer-publisher.js';
17+
18+
/**
19+
* Result of {@link SequencerBundleSimulator.simulate}.
20+
*
21+
* - `success`: simulation succeeded. `requests` is the filtered survivor list, `gasLimit` is
22+
* the bumped gas limit derived from `gasUsed` (plus blob evaluation gas). `droppedRequests`
23+
* lists the entries that were observed to revert in simulation.
24+
* - `fallback`: the node does not support eth_simulateV1 (or the simulate call threw). No
25+
* per-entry filtering was possible. The caller should send the bundle as-is with a safe gas
26+
* limit (e.g. {@link MAX_L1_TX_LIMIT}).
27+
* - `aborted`: the bundle cannot be sent. `droppedRequests` contains only entries that were
28+
* actually observed to revert (so they can be reported as simulation failures); it is empty
29+
* when the abort was caused by infrastructure issues like a missing Multicall3 contract or a
30+
* second-pass eth_simulateV1 fallback.
31+
*/
32+
export type BundleSimulateResult =
33+
| { kind: 'success'; requests: RequestWithExpiry[]; gasLimit: bigint; droppedRequests: RequestWithExpiry[] }
34+
| { kind: 'fallback' }
35+
| { kind: 'aborted'; reason: AbortReason; droppedRequests: RequestWithExpiry[] };
36+
37+
export type AbortReason =
38+
| 'multicall-missing'
39+
| 'empty-bundle'
40+
| 'all-reverted'
41+
| 'second-pass-reverts'
42+
| 'second-pass-fallback';
43+
44+
type SimulatePassResult =
45+
| { kind: 'decoded'; survivors: RequestWithExpiry[]; droppedRequests: RequestWithExpiry[]; gasUsed: bigint }
46+
| { kind: 'fallback' };
47+
48+
/**
49+
* Bundle-level simulator for the aggregate3 payload that `SequencerPublisher` is about to send.
50+
*
51+
* Runs `eth_simulateV1` against `Multicall3.aggregate3`, drops entries that revert, and returns
52+
* a gasLimit for the survivors. When `eth_simulateV1` is unavailable, signals fallback to the
53+
* caller so it can send the bundle as-is with a conservative gas limit.
54+
*/
55+
export class SequencerBundleSimulator {
56+
private readonly log: Logger;
57+
/** Cached only when true; a missing Multicall3 should be re-checked on subsequent calls. */
58+
private multicall3HasCode = false;
59+
60+
constructor(
61+
private readonly deps: {
62+
getL1TxUtils: () => L1TxUtils;
63+
rollupContract: RollupContract;
64+
epochCache: EpochCache;
65+
log?: Logger;
66+
},
67+
) {
68+
this.log = deps.log ?? createLogger('sequencer:publisher:bundle-simulator');
69+
}
70+
71+
/**
72+
* Simulates the given bundle at the target slot's start timestamp and filters out entries
73+
* that revert.
74+
*
75+
* - If all entries pass on the first pass, returns `success` with the gasLimit.
76+
* - If some entries revert, re-simulates the survivors. If the second pass is clean, returns
77+
* `success` with the survivors and dropped entries. If the second pass surfaces any revert,
78+
* returns `aborted` — we refuse to send a bundle whose composition still has internal
79+
* reverts after one round of filtering.
80+
* - If eth_simulateV1 is unavailable, returns `fallback`. The caller is expected to send the
81+
* bundle as-is with a safe gas limit.
82+
*
83+
* The simulation `block.timestamp` is always the target L2 slot's start timestamp, since
84+
* propose's `validateHeader` and EIP-712 signature checks both derive a slot from
85+
* `block.timestamp` and compare against the slot the validator signed for.
86+
*
87+
* Known limitation: on networks where L1 is mining behind cadence (missed L1 slots, anvil with
88+
* overridden timestamps), the actual `block.timestamp` at send time can land in the prior L2
89+
* slot. In that case `propose` would revert silently inside the multicall. The simulator does
90+
* not detect this case because it simulates AT the target timestamp — the prior implementation
91+
* used `min(predictedNextL1Ts, targetTimestamp)` to surface this failure mode at simulate time.
92+
*/
93+
public async simulate(validRequests: RequestWithExpiry[], targetSlot: SlotNumber): Promise<BundleSimulateResult> {
94+
if (validRequests.length === 0) {
95+
return { kind: 'aborted', reason: 'empty-bundle', droppedRequests: [] };
96+
}
97+
// Pin the publisher we'll use across the whole simulate call so that the publisher's rotation
98+
// can't change l1TxUtils mid-flight.
99+
const l1TxUtils = this.deps.getL1TxUtils();
100+
if (!(await this.ensureMulticall3Deployed(l1TxUtils))) {
101+
return { kind: 'aborted', reason: 'multicall-missing', droppedRequests: [] };
102+
}
103+
104+
const proposeRequest = validRequests.find(r => r.action === 'propose');
105+
const simulateTimestamp = getTimestampForSlot(targetSlot, this.deps.epochCache.getL1Constants());
106+
const firstPassOverrides = await this.buildStateOverrides(!!proposeRequest);
107+
108+
const firstPass = await this.simulateAndDecode(l1TxUtils, validRequests, simulateTimestamp, firstPassOverrides);
109+
110+
if (firstPass.kind === 'fallback') {
111+
this.log.warn('Bundle simulate fallback (eth_simulateV1 unavailable); caller will send bundle as-is', {
112+
actions: validRequests.map(r => r.action),
113+
});
114+
return { kind: 'fallback' };
115+
}
116+
117+
if (firstPass.survivors.length === 0) {
118+
this.log.warn('All bundle entries dropped in sim; aborting send', {
119+
actions: validRequests.map(r => r.action),
120+
});
121+
return { kind: 'aborted', reason: 'all-reverted', droppedRequests: validRequests };
122+
}
123+
124+
if (firstPass.droppedRequests.length === 0) {
125+
return this.buildSuccessResult(l1TxUtils, firstPass.survivors, [], firstPass.gasUsed, proposeRequest);
126+
}
127+
128+
this.log.warn('Some bundle entries reverted; re-simulating reduced bundle', {
129+
droppedActions: firstPass.droppedRequests.map(r => r.action),
130+
remainingActions: firstPass.survivors.map(r => r.action),
131+
});
132+
// Rebuild overrides for the reduced bundle: if propose was dropped, we no longer need the
133+
// blob-check override.
134+
const proposeSurvived = proposeRequest !== undefined && firstPass.survivors.includes(proposeRequest);
135+
const secondPassOverrides = proposeSurvived ? firstPassOverrides : await this.buildStateOverrides(false);
136+
const secondPass = await this.simulateAndDecode(
137+
l1TxUtils,
138+
firstPass.survivors,
139+
simulateTimestamp,
140+
secondPassOverrides,
141+
);
142+
143+
// We refuse to chase reverts through repeated trimming: anything other than a clean second
144+
// pass aborts the whole send.
145+
if (secondPass.kind === 'fallback') {
146+
this.log.error('Re-simulate returned fallback; aborting send', {
147+
survivingActions: firstPass.survivors.map(r => r.action),
148+
});
149+
return { kind: 'aborted', reason: 'second-pass-fallback', droppedRequests: firstPass.droppedRequests };
150+
}
151+
if (secondPass.droppedRequests.length > 0) {
152+
this.log.error('Re-simulate surfaced reverts; aborting send', {
153+
secondPassDroppedActions: secondPass.droppedRequests.map(r => r.action),
154+
});
155+
return {
156+
kind: 'aborted',
157+
reason: 'second-pass-reverts',
158+
droppedRequests: [...firstPass.droppedRequests, ...secondPass.droppedRequests],
159+
};
160+
}
161+
162+
return this.buildSuccessResult(
163+
l1TxUtils,
164+
secondPass.survivors,
165+
firstPass.droppedRequests,
166+
secondPass.gasUsed,
167+
proposeRequest,
168+
);
169+
}
170+
171+
private buildSuccessResult(
172+
l1TxUtils: L1TxUtils,
173+
survivors: RequestWithExpiry[],
174+
droppedRequests: RequestWithExpiry[],
175+
bundleGasUsed: bigint,
176+
proposeRequest: RequestWithExpiry | undefined,
177+
): BundleSimulateResult {
178+
const proposeSurvived = proposeRequest !== undefined && survivors.includes(proposeRequest);
179+
const blobEvaluationGas = proposeSurvived ? (proposeRequest?.blobEvaluationGas ?? 0n) : 0n;
180+
const gasLimit = this.computeGasLimit(l1TxUtils, bundleGasUsed, blobEvaluationGas);
181+
this.log.debug('Bundle simulate complete', {
182+
survivingRequests: survivors.length,
183+
bundleGasUsed,
184+
gasLimit,
185+
actions: survivors.map(r => r.action),
186+
});
187+
return { kind: 'success', requests: survivors, gasLimit, droppedRequests };
188+
}
189+
190+
/**
191+
* `gasLimit = bumpGasLimit(ceil(gasUsed * 64 / 63))`, plus blob evaluation gas if a propose
192+
* survived, capped at the L1 block gas limit.
193+
*/
194+
private computeGasLimit(l1TxUtils: L1TxUtils, bundleGasUsed: bigint, blobEvaluationGas: bigint): bigint {
195+
const gasUsedWithEip150 = (bundleGasUsed * 64n + 62n) / 63n;
196+
const gasLimit = l1TxUtils.bumpGasLimit(gasUsedWithEip150) + blobEvaluationGas;
197+
return gasLimit > MAX_L1_TX_LIMIT ? MAX_L1_TX_LIMIT : gasLimit;
198+
}
199+
200+
/**
201+
* eth_simulateV1 cannot carry blob sidecar data, so disable the rollup's on-chain blob check
202+
* when a propose is in the bundle.
203+
*/
204+
private buildStateOverrides(hasProposeAction: boolean): Promise<StateOverride> {
205+
return buildSimulationOverridesStateOverride(
206+
this.deps.rollupContract,
207+
hasProposeAction ? { disableBlobCheck: true } : undefined,
208+
);
209+
}
210+
211+
private async ensureMulticall3Deployed(l1TxUtils: L1TxUtils): Promise<boolean> {
212+
if (this.multicall3HasCode) {
213+
return true;
214+
}
215+
this.multicall3HasCode = await Multicall3.hasCode(l1TxUtils);
216+
if (!this.multicall3HasCode) {
217+
this.log.error(`Multicall3 bytecode missing at ${MULTI_CALL_3_ADDRESS}; cannot send bundled tx`);
218+
}
219+
return this.multicall3HasCode;
220+
}
221+
222+
private async simulateAndDecode(
223+
l1TxUtils: L1TxUtils,
224+
requests: RequestWithExpiry[],
225+
simulateTimestamp: bigint,
226+
stateOverrides: StateOverride,
227+
): Promise<SimulatePassResult> {
228+
let simResult: Awaited<ReturnType<typeof Multicall3.simulateAggregate3>>;
229+
try {
230+
simResult = await Multicall3.simulateAggregate3(
231+
requests.map(r => ({ to: r.request.to! as Hex, data: r.request.data! as Hex, abi: r.request.abi })),
232+
l1TxUtils,
233+
{
234+
blockOverrides: { time: simulateTimestamp, gasLimit: MAX_L1_TX_LIMIT * 2n },
235+
stateOverrides,
236+
gas: MAX_L1_TX_LIMIT,
237+
fallbackGasEstimate: MAX_L1_TX_LIMIT,
238+
},
239+
);
240+
} catch (err) {
241+
this.log.warn('Bundle simulate threw; treating as fallback', {
242+
err: formatViemError(err),
243+
actions: requests.map(r => r.action),
244+
});
245+
return { kind: 'fallback' };
246+
}
247+
248+
if (simResult.kind === 'fallback') {
249+
return { kind: 'fallback' };
250+
}
251+
252+
const survivors: RequestWithExpiry[] = [];
253+
const droppedRequests: RequestWithExpiry[] = [];
254+
for (let i = 0; i < requests.length; i++) {
255+
const entry = simResult.entries[i];
256+
if (entry.success) {
257+
survivors.push(requests[i]);
258+
continue;
259+
}
260+
droppedRequests.push(requests[i]);
261+
this.log.warn('Bundle entry dropped: action reverted in sim', {
262+
action: requests[i].action,
263+
revertReason: entry.revertReason ?? entry.returnData,
264+
returnData: entry.returnData,
265+
});
266+
}
267+
return { kind: 'decoded', survivors, droppedRequests, gasUsed: simResult.gasUsed };
268+
}
269+
}

yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ describe('SequencerPublisher', () => {
573573
expect(l1TxUtils.simulate).toHaveBeenCalledTimes(2);
574574
});
575575

576-
it('falls back to MAX_L1_TX_LIMIT when second-pass simulate returns 0x', async () => {
576+
it('aborts the send when second-pass simulate returns fallback', async () => {
577577
addTwoRequests();
578578

579579
// First simulate: propose fails.
@@ -590,15 +590,11 @@ describe('SequencerPublisher', () => {
590590
.mockResolvedValueOnce({ gasUsed: 500_000n, result: firstResult })
591591
.mockResolvedValueOnce({ gasUsed: 1_000_000n, result: '0x' });
592592

593-
forwardSpy.mockResolvedValue({ receipt: proposeTxReceipt, errorMsg: undefined });
594-
595593
const result = await publisher.sendRequests();
596594

597-
expect(result).toBeDefined();
598-
expect(result?.sentActions).toEqual(['invalidate-by-invalid-attestation']);
599-
// Gas falls back to MAX_L1_TX_LIMIT when second-pass returns '0x'.
600-
const gasLimit = forwardSpy.mock.calls[0][2]?.gasLimit;
601-
expect(gasLimit).toEqual(MAX_L1_TX_LIMIT);
595+
expect(result).toBeUndefined();
596+
expect(forwardSpy).not.toHaveBeenCalled();
597+
expect(l1TxUtils.simulate).toHaveBeenCalledTimes(2);
602598
});
603599
});
604600

0 commit comments

Comments
 (0)