Skip to content

Commit 8b522a9

Browse files
committed
fix(fast-inbox): predict the next block's inbox messages when simulating public calls (A-1388)
A transaction that consumes an L1-to-L2 message sent moments ago failed public simulation until a block actually consumed the message, because the simulation fork stops at the tip's message tree. Predict the bundle the next block would take — the sequencer's own bucket selection, so lag eligibility and the per-block/per-checkpoint caps match what will be built — and append it to the fork. The prediction treats the next block as non-final (the censorship cutoff only widens consumption on a checkpoint's last block, which the node cannot know) and derives its cursor from the fork's own leaf count, so it can never re-insert messages the fork already holds. Best-effort: unsynced buckets or a missing header leave the simulation on the bare tip, as before.
1 parent 13447c5 commit 8b522a9

4 files changed

Lines changed: 167 additions & 16 deletions

File tree

yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.test.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ import {
2323
} from '@aztec/stdlib/block';
2424
import type { ProposedCheckpointData } from '@aztec/stdlib/checkpoint';
2525
import type { ContractDataSource } from '@aztec/stdlib/contract';
26+
import { EmptyL1RollupConstants } from '@aztec/stdlib/epoch-helpers';
2627
import { GasFees } from '@aztec/stdlib/gas';
2728
import type { MerkleTreeWriteOperations, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server';
29+
import type { InboxBucket, L1ToL2MessageSource } from '@aztec/stdlib/messaging';
2830
import { CheckpointHeader } from '@aztec/stdlib/rollup';
2931
import { mockTx } from '@aztec/stdlib/testing';
30-
import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees';
32+
import { AppendOnlyTreeSnapshot, MerkleTreeId } from '@aztec/stdlib/trees';
3133
import {
3234
BlockHeader,
3335
type CheckpointGlobalVariables,
@@ -48,6 +50,7 @@ const ROLLUP_ADDRESS = EthAddress.random();
4850
describe('NodePublicCallsSimulator', () => {
4951
let blockSource: MockProxy<L2BlockSource>;
5052
let worldStateSynchronizer: MockProxy<WorldStateSynchronizer>;
53+
let l1ToL2MessageSource: MockProxy<L1ToL2MessageSource>;
5154
let contractDataSource: MockProxy<ContractDataSource>;
5255
let globalVariableBuilder: MockProxy<GlobalVariableBuilder>;
5356
let rollupContract: MockProxy<RollupContract>;
@@ -111,6 +114,26 @@ describe('NodePublicCallsSimulator', () => {
111114
gasFees: GasFees.empty(),
112115
});
113116

117+
/**
118+
* Mocks the Inbox so the next-block prediction selects a two-message bundle: the fork's message total (0)
119+
* resolves to bucket 0, and bucket 1 is lag-eligible and holds both messages.
120+
*/
121+
const mockInboxSelection = () => {
122+
const makeBucket = (seq: bigint, totalMsgCount: bigint): InboxBucket => ({
123+
seq,
124+
inboxRollingHash: Fr.ZERO,
125+
totalMsgCount,
126+
timestamp: 0n,
127+
msgCount: Number(totalMsgCount),
128+
lastMessageIndex: totalMsgCount === 0n ? 0n : totalMsgCount - 1n,
129+
});
130+
const bundle = [new Fr(0x1234), new Fr(0x5678)];
131+
l1ToL2MessageSource.getInboxBucketByTotalMsgCount.mockResolvedValue(makeBucket(0n, 0n));
132+
l1ToL2MessageSource.getLatestInboxBucketAtOrBefore.mockResolvedValue(makeBucket(1n, 2n));
133+
l1ToL2MessageSource.getL1ToL2MessagesBetweenBuckets.mockResolvedValue(bundle);
134+
return bundle;
135+
};
136+
114137
const lowGasTx = () =>
115138
mockTx(0x10000, {
116139
numberOfNonRevertiblePublicCallRequests: 0,
@@ -124,6 +147,7 @@ describe('NodePublicCallsSimulator', () => {
124147

125148
blockSource = mock<L2BlockSource>();
126149
worldStateSynchronizer = mock<WorldStateSynchronizer>();
150+
l1ToL2MessageSource = mock<L1ToL2MessageSource>();
127151
contractDataSource = mock<ContractDataSource>();
128152
globalVariableBuilder = mock<GlobalVariableBuilder>();
129153
rollupContract = mock<RollupContract>();
@@ -136,8 +160,18 @@ describe('NodePublicCallsSimulator', () => {
136160
(merkleTreeFork as unknown as { [Symbol.asyncDispose]: () => Promise<void> })[Symbol.asyncDispose] = () =>
137161
Promise.resolve();
138162
worldStateSynchronizer.fork.mockResolvedValue(merkleTreeFork);
163+
merkleTreeFork.getTreeInfo.mockResolvedValue({
164+
treeId: MerkleTreeId.L1_TO_L2_MESSAGE_TREE,
165+
root: Buffer.alloc(32),
166+
size: 0n,
167+
depth: 16,
168+
});
139169
blockSource.getPendingChainValidationStatus.mockResolvedValue({ valid: true });
140170
blockSource.getProposedCheckpointData.mockResolvedValue(undefined);
171+
// No Inbox bucket resolves to the fork's message total by default, so the next-block message prediction
172+
// bails out and tests see the bare tip state unless they opt into it.
173+
l1ToL2MessageSource.getInboxBucketByTotalMsgCount.mockResolvedValue(undefined);
174+
epochCache.getL1Constants.mockReturnValue(EmptyL1RollupConstants);
141175

142176
globalVariableBuilder.buildCheckpointGlobalVariables.mockImplementation((_c, _f, slotNumber) =>
143177
Promise.resolve(checkpointGlobals(slotNumber)),
@@ -163,6 +197,7 @@ describe('NodePublicCallsSimulator', () => {
163197
simulator = new NodePublicCallsSimulator({
164198
blockSource,
165199
worldStateSynchronizer,
200+
l1ToL2MessageSource,
166201
contractDataSource,
167202
globalVariableBuilder,
168203
rollupContract,
@@ -216,16 +251,31 @@ describe('NodePublicCallsSimulator', () => {
216251
expect(rollupContract.getManaTarget).not.toHaveBeenCalled();
217252
});
218253

219-
it('does not insert L1-to-L2 messages', async () => {
254+
it('appends the message bundle the next block would consume', async () => {
220255
const tx = await lowGasTx();
221256
setupMidCheckpoint();
222257
blockSource.getBlockData.mockImplementation((query: BlockQuery) =>
223258
Promise.resolve('number' in query ? makeBlockData(query.number, SlotNumber(42)) : undefined),
224259
);
225260
mockNextL1Slot(SlotNumber(100));
261+
const bundle = mockInboxSelection();
226262

227263
await simulator.simulate(tx);
228264

265+
expect(merkleTreeFork.appendLeaves).toHaveBeenCalledWith(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, bundle);
266+
});
267+
268+
it('simulates against the tip when the parent Inbox bucket is not synced', async () => {
269+
const tx = await lowGasTx();
270+
setupMidCheckpoint();
271+
blockSource.getBlockData.mockImplementation((query: BlockQuery) =>
272+
Promise.resolve('number' in query ? makeBlockData(query.number, SlotNumber(42)) : undefined),
273+
);
274+
mockNextL1Slot(SlotNumber(100));
275+
// Default mock: no bucket resolves the fork's message total.
276+
277+
await expect(simulator.simulate(tx)).resolves.toBeDefined();
278+
229279
expect(merkleTreeFork.appendLeaves).not.toHaveBeenCalled();
230280
});
231281

@@ -273,18 +323,19 @@ describe('NodePublicCallsSimulator', () => {
273323
expect(plan?.chainTipsOverride).toEqual({ pending: CheckpointNumber(1), proven: CheckpointNumber(1) });
274324
});
275325

276-
it('does not insert L1-to-L2 messages when opening a new checkpoint', async () => {
326+
it('appends the message bundle the next block would consume', async () => {
277327
const tx = await lowGasTx();
278328
blockSource.getL2Tips.mockResolvedValue(setupBoundary());
279329
blockSource.getBlockData.mockImplementation((query: BlockQuery) =>
280330
Promise.resolve('number' in query ? makeBlockData(query.number, SlotNumber(5)) : undefined),
281331
);
282332
mockNextL1Slot(SlotNumber(20));
333+
const bundle = mockInboxSelection();
283334

284335
await expect(simulator.simulate(tx)).resolves.toBeDefined();
285336

286-
// Streaming Inbox: the next checkpoint's messages are consumed per block, so none are appended here.
287-
expect(merkleTreeFork.appendLeaves).not.toHaveBeenCalled();
337+
// Only the first block's worth of messages: a fresh checkpoint starts its per-checkpoint budget at the tip.
338+
expect(merkleTreeFork.appendLeaves).toHaveBeenCalledWith(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, bundle);
288339
});
289340

290341
it('targets parentSlot + 1 and carries the parent overrides when pipelining on a proposed checkpoint', async () => {
@@ -382,6 +433,7 @@ describe('NodePublicCallsSimulator', () => {
382433
new NodePublicCallsSimulator({
383434
blockSource,
384435
worldStateSynchronizer,
436+
l1ToL2MessageSource,
385437
contractDataSource,
386438
globalVariableBuilder,
387439
epochCache,

yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.ts

Lines changed: 102 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { INBOX_LAG_SECONDS, MAX_L1_TO_L2_MSGS_PER_BLOCK, MAX_L1_TO_L2_MSGS_PER_CHECKPOINT } from '@aztec/constants';
12
import { PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache';
23
import type { EpochCacheInterface } from '@aztec/epoch-cache';
34
import {
@@ -11,14 +12,17 @@ import { EthAddress } from '@aztec/foundation/eth-address';
1112
import { BadRequestError } from '@aztec/foundation/json-rpc';
1213
import { type Logger, createLogger } from '@aztec/foundation/log';
1314
import { DateProvider } from '@aztec/foundation/timer';
15+
import { type InboxBucketSource, selectInboxBucketForBlock } from '@aztec/sequencer-client';
1416
import { type AvmSimulator, PublicContractsDB, PublicProcessorFactory } from '@aztec/simulator/server';
1517
import { CollectionLimitsConfig, PublicSimulatorConfig } from '@aztec/stdlib/avm';
1618
import { AztecAddress } from '@aztec/stdlib/aztec-address';
1719
import type { L2BlockSource, L2Tips } from '@aztec/stdlib/block';
1820
import { type ProposedCheckpointData, buildCheckpointSimulationOverridesPlan } from '@aztec/stdlib/checkpoint';
1921
import type { ContractDataSource } from '@aztec/stdlib/contract';
20-
import type { WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server';
22+
import type { MerkleTreeWriteOperations, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server';
23+
import { type L1ToL2MessageSource, appendL1ToL2MessagesToTree, getInboxCutoffTimestamp } from '@aztec/stdlib/messaging';
2124
import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p';
25+
import { MerkleTreeId } from '@aztec/stdlib/trees';
2226
import {
2327
type GlobalVariableBuilder,
2428
GlobalVariables,
@@ -30,6 +34,9 @@ import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-clien
3034

3135
import { applyPublicDataOverrides } from './public_data_overrides.js';
3236

37+
/** Inbox queries the simulator needs to predict the message bundle the next block would consume. */
38+
type SimulatorInboxSource = InboxBucketSource & Pick<L1ToL2MessageSource, 'getInboxBucketByTotalMsgCount'>;
39+
3340
/** Config fields the simulator needs — a narrow subset of `AztecNodeConfig`. */
3441
export interface NodePublicCallsSimulatorConfig {
3542
/** Maximum total gas limit accepted for an incoming simulation. */
@@ -42,6 +49,8 @@ export interface NodePublicCallsSimulatorConfig {
4249
export interface NodePublicCallsSimulatorDeps {
4350
blockSource: L2BlockSource;
4451
worldStateSynchronizer: WorldStateSynchronizer;
52+
/** Inbox bucket queries, used to predict the L1-to-L2 messages the next block will consume. */
53+
l1ToL2MessageSource: SimulatorInboxSource;
4554
contractDataSource: ContractDataSource;
4655
globalVariableBuilder: GlobalVariableBuilder;
4756
/**
@@ -73,15 +82,20 @@ export interface NodePublicCallsSimulatorDeps {
7382
* - **When the next block continues an in-progress checkpoint** (the latest proposed block is ahead of
7483
* the proposed-checkpoint frontier): every block in a checkpoint shares the same
7584
* `CheckpointGlobalVariables`, so we copy the latest proposed block's globals verbatim and only
76-
* bump the block number. No L1 calls, no L1-to-L2 message insertion.
85+
* bump the block number. No L1 calls.
7786
* - **When the next block opens a new checkpoint** (the latest proposed block coincides with the
7887
* proposed-checkpoint frontier): we compute fresh globals for the slot the next block will land in,
7988
* applying the same `SimulationOverridesPlan` the sequencer applies so the simulated mana min fee
8089
* matches what the sequencer will write into the block header.
90+
*
91+
* Either way it also predicts the L1-to-L2 message bundle the next block would consume and appends it
92+
* to the fork, so a transaction consuming a message that is in the Inbox but not yet in a block
93+
* simulates against the state it will actually run in.
8194
*/
8295
export class NodePublicCallsSimulator {
8396
private readonly blockSource: L2BlockSource;
8497
private readonly worldStateSynchronizer: WorldStateSynchronizer;
98+
private readonly l1ToL2MessageSource: SimulatorInboxSource;
8599
private readonly contractDataSource: ContractDataSource;
86100
private readonly globalVariableBuilder: GlobalVariableBuilder;
87101
private readonly rollupContract: RollupContract | undefined;
@@ -91,10 +105,12 @@ export class NodePublicCallsSimulator {
91105
private readonly avmSimulator?: AvmSimulator;
92106
private readonly telemetry: TelemetryClient;
93107
private readonly log: Logger;
108+
private readonly dateProvider = new DateProvider();
94109

95110
constructor(deps: NodePublicCallsSimulatorDeps) {
96111
this.blockSource = deps.blockSource;
97112
this.worldStateSynchronizer = deps.worldStateSynchronizer;
113+
this.l1ToL2MessageSource = deps.l1ToL2MessageSource;
98114
this.contractDataSource = deps.contractDataSource;
99115
this.globalVariableBuilder = deps.globalVariableBuilder;
100116
this.rollupContract = deps.rollupContract;
@@ -160,7 +176,7 @@ export class NodePublicCallsSimulator {
160176
const publicProcessorFactory = new PublicProcessorFactory(
161177
this.contractDataSource,
162178
this.avmSimulator,
163-
new DateProvider(),
179+
this.dateProvider,
164180
this.telemetry,
165181
this.log.getBindings(),
166182
);
@@ -175,12 +191,15 @@ export class NodePublicCallsSimulator {
175191
// Ensure world-state has caught up with the latest block we loaded from the archiver
176192
await this.worldStateSynchronizer.syncImmediate(latestBlockNumber);
177193

178-
// Request a new fork of the world state at the latest block number, and apply any overrides to it before
179-
// simulation. The next checkpoint's L1-to-L2 messages are not inserted here: under the streaming Inbox
180-
// (AZIP-22 Fast Inbox) a checkpoint's messages are consumed per block, so the simulation runs against the
181-
// fork's current tree without predicting the next block's message bundle.
194+
// Request a new fork of the world state at the latest block number, then apply the next block's predicted
195+
// L1-to-L2 message bundle and any caller overrides to it before simulation.
182196
await using merkleTreeFork = await this.worldStateSynchronizer.fork(latestBlockNumber);
183197

198+
await this.appendPredictedL1ToL2Messages(merkleTreeFork, {
199+
slotNumber: newGlobalVariables.slotNumber,
200+
checkpointStartBlock: atCheckpointBoundary ? undefined : proposedCheckpointLastBlock,
201+
});
202+
184203
await applyPublicDataOverrides(merkleTreeFork, overrides?.publicStorage);
185204

186205
const config = PublicSimulatorConfig.from({
@@ -219,17 +238,89 @@ export class NodePublicCallsSimulator {
219238
);
220239
}
221240

241+
/**
242+
* Appends the L1-to-L2 message bundle the next block would consume to the simulation fork, so a transaction
243+
* consuming a message that has reached the Inbox but no block yet simulates against the state it will run in.
244+
* Runs the same bucket selection the sequencer runs (lag eligibility plus the per-block and per-checkpoint caps),
245+
* treating the next block as non-final: the censorship cutoff only widens consumption on a checkpoint's last
246+
* block, and the node cannot know whether the next block is it.
247+
*
248+
* Best-effort. Any failure — Inbox buckets not synced yet, a torn archiver snapshot — leaves the fork at the tip
249+
* state, which is what the transaction sees if the next block consumes nothing.
250+
*/
251+
private async appendPredictedL1ToL2Messages(
252+
fork: MerkleTreeWriteOperations,
253+
opts: {
254+
/** Slot the next block lands in; anchors the censorship cutoff. */
255+
slotNumber: SlotNumber;
256+
/** Last block of the checkpoint the next block extends; undefined when the next block opens a checkpoint. */
257+
checkpointStartBlock: BlockNumber | undefined;
258+
},
259+
): Promise<void> {
260+
try {
261+
const parentTotalMsgCount = (await fork.getTreeInfo(MerkleTreeId.L1_TO_L2_MESSAGE_TREE)).size;
262+
const parentBucket = await this.l1ToL2MessageSource.getInboxBucketByTotalMsgCount(parentTotalMsgCount);
263+
if (parentBucket === undefined) {
264+
this.log.debug(`Inbox bucket at message total ${parentTotalMsgCount} not synced; simulating against the tip`, {
265+
parentTotalMsgCount,
266+
});
267+
return;
268+
}
269+
270+
// Origin of the per-checkpoint cap: the total consumed as of the checkpoint's parent. A block extending an
271+
// in-progress checkpoint reads it off that checkpoint's parent block; a block opening one starts from the tip.
272+
const checkpointStartTotalMsgCount =
273+
opts.checkpointStartBlock === undefined
274+
? parentTotalMsgCount
275+
: await this.getConsumedMessageTotal(opts.checkpointStartBlock);
276+
if (checkpointStartTotalMsgCount === undefined) {
277+
this.log.debug(`Block ${opts.checkpointStartBlock} has no header on this node; simulating against the tip`);
278+
return;
279+
}
280+
281+
const selection = await selectInboxBucketForBlock({
282+
messageSource: this.l1ToL2MessageSource,
283+
now: BigInt(Math.floor(this.dateProvider.now() / 1000)),
284+
lagSeconds: BigInt(INBOX_LAG_SECONDS),
285+
parent: { seq: parentBucket.seq, totalMsgCount: parentBucket.totalMsgCount },
286+
checkpointStartTotalMsgCount,
287+
perBlockCap: MAX_L1_TO_L2_MSGS_PER_BLOCK,
288+
perCheckpointCap: MAX_L1_TO_L2_MSGS_PER_CHECKPOINT,
289+
isLastBlock: false,
290+
cutoffTimestamp: getInboxCutoffTimestamp(opts.slotNumber, this.epochCache.getL1Constants(), INBOX_LAG_SECONDS),
291+
});
292+
if (!selection.consume || selection.bundle.length === 0) {
293+
return;
294+
}
295+
296+
await appendL1ToL2MessagesToTree(fork, selection.bundle);
297+
this.log.debug(`Appended ${selection.bundle.length} predicted L1-to-L2 messages to the simulation fork`, {
298+
bucketSeq: selection.bucket.seq,
299+
messageCount: selection.bundle.length,
300+
});
301+
} catch (err) {
302+
this.log.verbose(`Could not predict the next block's L1-to-L2 messages, simulating against the tip: ${err}`);
303+
}
304+
}
305+
306+
/** Cumulative Inbox message total consumed as of `blockNumber`, i.e. its L1-to-L2 message tree leaf count. */
307+
private async getConsumedMessageTotal(blockNumber: BlockNumber): Promise<bigint | undefined> {
308+
if (blockNumber === BlockNumber.ZERO) {
309+
return 0n;
310+
}
311+
const block = await this.blockSource.getBlockData({ number: blockNumber });
312+
return block === undefined ? undefined : BigInt(block.header.state.l1ToL2MessageTree.nextAvailableLeafIndex);
313+
}
314+
222315
/**
223316
* Continues an in-progress checkpoint: the next block extends the checkpoint the latest proposed
224317
* block belongs to. Every block in a checkpoint shares the same `CheckpointGlobalVariables`, so the
225318
* next block's globals are the latest proposed block's globals with only the block number bumped —
226-
* including the proposer's real coinbase/feeRecipient. No L1 reads and no L1-to-L2 message insertion
227-
* happen here.
319+
* including the proposer's real coinbase/feeRecipient. No L1 reads happen here.
228320
*
229321
* A missing header means the archiver reported a proposed tip via `getL2Tips` but no longer has its
230322
* data (a torn snapshot). We throw a transient/retryable error rather than treating the next block as
231-
* opening a new checkpoint: the fork at `latestBlockNumber` already contains the ongoing checkpoint's
232-
* L1-to-L2 messages, so inserting the next checkpoint's messages would append them a second time.
323+
* opening a new checkpoint, whose globals would be built for the wrong slot.
233324
*/
234325
private async copyGlobalVariablesFromLatestProposedBlock(
235326
latestBlockNumber: BlockNumber,

yarn-project/aztec-node/src/aztec-node/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
241241
this.nodePublicCallsSimulator = new NodePublicCallsSimulator({
242242
blockSource: this.blockSource,
243243
worldStateSynchronizer: this.worldStateSynchronizer,
244+
l1ToL2MessageSource: this.l1ToL2MessageSource,
244245
contractDataSource: this.contractDataSource,
245246
globalVariableBuilder: this.globalVariableBuilder,
246247
rollupContract: this.rollupContract,

yarn-project/sequencer-client/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,10 @@ export { Sequencer, SequencerState, type SequencerEvents } from './sequencer/ind
66
// Used by the node to simulate public parts of transactions. Should these be moved to a shared library?
77
// ISSUE(#9832)
88
export * from './global_variable_builder/index.js';
9+
export {
10+
type ConsumedBucketCursor,
11+
type InboxBucketSelection,
12+
type InboxBucketSource,
13+
type SelectInboxBucketInput,
14+
selectInboxBucketForBlock,
15+
} from './sequencer/inbox_bucket_selector.js';

0 commit comments

Comments
 (0)