1+ import { INBOX_LAG_SECONDS , MAX_L1_TO_L2_MSGS_PER_BLOCK , MAX_L1_TO_L2_MSGS_PER_CHECKPOINT } from '@aztec/constants' ;
12import { PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache' ;
23import type { EpochCacheInterface } from '@aztec/epoch-cache' ;
34import {
@@ -11,14 +12,17 @@ import { EthAddress } from '@aztec/foundation/eth-address';
1112import { BadRequestError } from '@aztec/foundation/json-rpc' ;
1213import { type Logger , createLogger } from '@aztec/foundation/log' ;
1314import { DateProvider } from '@aztec/foundation/timer' ;
15+ import { type InboxBucketSource , selectInboxBucketForBlock } from '@aztec/sequencer-client' ;
1416import { type AvmSimulator , PublicContractsDB , PublicProcessorFactory } from '@aztec/simulator/server' ;
1517import { CollectionLimitsConfig , PublicSimulatorConfig } from '@aztec/stdlib/avm' ;
1618import { AztecAddress } from '@aztec/stdlib/aztec-address' ;
1719import type { L2BlockSource , L2Tips } from '@aztec/stdlib/block' ;
1820import { type ProposedCheckpointData , buildCheckpointSimulationOverridesPlan } from '@aztec/stdlib/checkpoint' ;
1921import 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' ;
2124import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p' ;
25+ import { MerkleTreeId } from '@aztec/stdlib/trees' ;
2226import {
2327 type GlobalVariableBuilder ,
2428 GlobalVariables ,
@@ -30,6 +34,9 @@ import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-clien
3034
3135import { 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`. */
3441export interface NodePublicCallsSimulatorConfig {
3542 /** Maximum total gas limit accepted for an incoming simulation. */
@@ -42,6 +49,8 @@ export interface NodePublicCallsSimulatorConfig {
4249export 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 */
8295export 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 ,
0 commit comments