@@ -3,7 +3,6 @@ import { generateClaimSecret } from '@aztec/aztec.js/ethereum';
33import { Fr } from '@aztec/aztec.js/fields' ;
44import type { Logger } from '@aztec/aztec.js/log' ;
55import type { AztecNode } from '@aztec/aztec.js/node' ;
6- import { TxExecutionResult } from '@aztec/aztec.js/tx' ;
76import type { Wallet } from '@aztec/aztec.js/wallet' ;
87import type { BlockNumber } from '@aztec/foundation/branded-types' ;
98import { timesAsync } from '@aztec/foundation/collection' ;
@@ -19,11 +18,13 @@ import { type L1ToL2MessageScope, createL1ToL2MessageHelpers } from './message_t
1918
2019jest . setTimeout ( 300_000 ) ;
2120
22- // L1→L2 messaging via Inbox: inbox checkpoint drift after a rollup reorg . Uses CrossChainMessagingTest
21+ // L1→L2 messaging via Inbox: an L1→L2 message survives a rollup prune . Uses CrossChainMessagingTest
2322// (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s, inboxLag=2, minTxsPerBlock=1,
24- // aztecProofSubmissionEpochs=2, aztecEpochDuration=4) with EpochTestSettler for auto-proving and
25- // CrossChainTestHarness for L1↔L2 token portal bridging. The drift scenario runs over private and
26- // public scope via it.each, all sharing one node stood up once in beforeAll.
23+ // aztecProofSubmissionEpochs=2, aztecEpochDuration=4) with EpochTestSettler for auto-proving. Post-flip
24+ // (AZIP-22) messages carry a compact L1-assigned index and stream in by insertion order rather than
25+ // pinning to a checkpoint, so a message inserted while the proposed chain drifts must still be
26+ // re-consumed with a stable index after the chain prunes back. Runs over private and public scope via
27+ // it.each, sharing one node stood up once in beforeAll.
2728describe ( 'single-node/cross-chain/l1_to_l2_inbox_drift' , ( ) => {
2829 let t : CrossChainMessagingTest ;
2930
@@ -35,7 +36,6 @@ describe('single-node/cross-chain/l1_to_l2_inbox_drift', () => {
3536
3637 let sendMessageToL2 : ReturnType < typeof createL1ToL2MessageHelpers > [ 'sendMessageToL2' ] ;
3738 let advanceBlock : ReturnType < typeof createL1ToL2MessageHelpers > [ 'advanceBlock' ] ;
38- let waitForMessageFetched : ReturnType < typeof createL1ToL2MessageHelpers > [ 'waitForMessageFetched' ] ;
3939 let waitForMessageReady : ReturnType < typeof createL1ToL2MessageHelpers > [ 'waitForMessageReady' ] ;
4040
4141 // Whether explicit mark-as-proven calls are honored. The inbox-drift scenario flips this to
@@ -73,7 +73,7 @@ describe('single-node/cross-chain/l1_to_l2_inbox_drift', () => {
7373 ( { logger : log , wallet, user1Address, aztecNode } = t ) ;
7474 ( { contract : testContract } = await TestContract . deploy ( wallet ) . send ( { from : user1Address } ) ) ;
7575
76- ( { sendMessageToL2, advanceBlock, waitForMessageFetched , waitForMessageReady } = createL1ToL2MessageHelpers ( {
76+ ( { sendMessageToL2, advanceBlock, waitForMessageReady } = createL1ToL2MessageHelpers ( {
7777 t,
7878 aztecNode,
7979 wallet,
@@ -127,108 +127,100 @@ describe('single-node/cross-chain/l1_to_l2_inbox_drift', () => {
127127 }
128128 } ;
129129
130- // Inbox checkpoint number can drift on two scenarios: if the rollup reorgs and rolls back its own
131- // checkpoint number, or if the inbox receives too many messages and they are inserted faster than
132- // they are consumed. In this test, we mine several checkpoints without marking them as proven until
133- // we can trigger a reorg, and then wait until the message can be processed to consume it.
134- const canConsumeMessageAfterInboxDrift = async ( scope : L1ToL2MessageScope ) => {
130+ // Post-flip (AZIP-22), L1→L2 messages carry a compact, L1-assigned global leaf index and are streamed
131+ // into the L2 tree in insertion order (subject to inbox lag and per-block/checkpoint caps) rather than
132+ // being pinned to a fixed checkpoint. This scenario stresses that message state survives an L2 reorg:
133+ // we let the proposed chain drift by mining several unproven checkpoints, insert a message during the
134+ // drift, then force the rollup to prune back to the pre-drift block. After the prune, the message must
135+ // still be re-consumed on the new chain and its witness leaf index must remain the L1-assigned index.
136+ const canConsumeMessageAfterRollupPrune = async ( scope : L1ToL2MessageScope ) => {
135137 // Stop the background epoch test settler so the drift scenario below can proceed without
136138 // an auto-prover racing it.
137139 await t . epochTestSettler ?. stop ( ) ;
138140
139141 // Reset the L1 proof window by marking the current pending tip as proven, so L1's prune
140- // deadline doesn't fire mid-test before we finish mining the 4 drift checkpoints below.
142+ // deadline doesn't fire mid-test before we finish mining the drift checkpoints below.
141143 await markAsProven ( ) ;
142144
143- // Stop proving
145+ // Snapshot the block we will prune back to, then stop proving so the proposed chain can drift.
144146 const lastProven = await aztecNode . getBlockNumber ( ) ;
145- const [ checkpointedProvenBlock ] = await aztecNode . getBlocks ( lastProven , 1 , {
146- includeL1PublishInfo : true ,
147- includeAttestations : true ,
148- onlyCheckpointed : true ,
149- } ) ;
150- log . warn ( `Stopping proof submission at checkpoint ${ checkpointedProvenBlock . checkpointNumber } to allow drift` ) ;
147+ log . warn ( `Stopping proof submission at block ${ lastProven } to allow drift` ) ;
151148 markProvenEnabled = false ;
152149
153150 // Mine several checkpoints to ensure drift
154151 log . warn ( `Mining blocks to allow drift` ) ;
155152 await timesAsync ( 4 , advanceCheckpoint ) ;
156153
157- // Generate and send the message to the L1 contract
154+ // Generate and send the message to the L1 contract during the drift
158155 log . warn ( `Sending L1 to L2 message` ) ;
159156 const [ secret , secretHash ] = await generateClaimSecret ( ) ;
160157 const message = { recipient : testContract . address , content : Fr . random ( ) , secretHash } ;
161158 const { msgHash, globalLeafIndex } = await sendMessageToL2 ( message ) ;
162159
163- // Wait until the Aztec node has synced it
164- const msgCheckpointNumber = await waitForMessageFetched ( msgHash ) ;
165- log . warn ( `Message synced for checkpoint ${ msgCheckpointNumber } ` ) ;
166- expect ( checkpointedProvenBlock . checkpointNumber + 4 ) . toBeLessThan ( msgCheckpointNumber ) ;
167-
168- // And keep mining until we prune back to the original block number. Now the "waiting for two blocks"
169- // strategy for the message to be ready to use shouldn't work, since the lastProven block is more than
170- // two blocks behind the message block. This is the scenario we want to test.
160+ // The drift's current L1 pending checkpoint. The node prunes its local view as soon as the proof
161+ // window lapses, but L1 only commits the prune when the rebuilt chain proposes its first checkpoint
162+ // on top of the pre-drift block. Wait for that on-chain commit — L1's pending tip dropping back
163+ // below the drift — before re-enabling proving: marking proven while the drift is still the pending
164+ // tip would pin the drift proven and wedge the rebuild forever with Rollup__InvalidArchive.
165+ const driftPendingCheckpoint = ( await t . cheatCodes . rollup . getTips ( ) ) . pending ;
166+
167+ // Keep mining until the rollup prunes the drifted proposed chain back to the pre-drift block. L1's
168+ // pending tip dropping below the drift is the unambiguous signal that the on-chain prune committed
169+ // (a new checkpoint was proposed on top of the pre-drift block); the node's faster local prune is
170+ // not, which is why we gate on L1 here rather than on the node's block number.
171171 log . warn ( `Waiting until we prune back to ${ lastProven } ` ) ;
172172 await retryUntil (
173- async ( ) =>
174- ( await aztecNode . getBlockNumber ( ) . then ( b => b === lastProven || b === lastProven + 1 ) ) ||
175- ( await tryAdvanceBlock ( ) ) ,
173+ async ( ) => {
174+ await tryAdvanceBlock ( ) ;
175+ return ( await t . cheatCodes . rollup . getTips ( ) ) . pending < driftPendingCheckpoint ;
176+ } ,
176177 'wait for prune' ,
177178 180 ,
178179 ) ;
179- // The drift condition has been established. Re-enable explicit proving so the catch-up blocks
180- // below are not pruned a second time before the message checkpoint becomes ready.
180+ // The prune has committed on L1. Re-enable explicit proving so the catch-up chain that re-consumes
181+ // the message is not pruned a second time before we can consume it — safe now that L1's pending tip
182+ // is the rebuilt chain rather than the drift.
181183 markProvenEnabled = true ;
182184 await markAsProven ( ) ;
183185
184- // Check that there is no witness yet
185- expect ( await aztecNode . getL1ToL2MessageMembershipWitness ( 'latest' , msgHash ) ) . toBeUndefined ( ) ;
186-
187- // Define L2 function to consume the message
188- const consume = ( ) => getConsumeMethod ( scope ) ( message . content , secret , t . ethAccount , globalLeafIndex ) ;
186+ // Post-flip invariant: the compact-indexed message survives the L2 prune and is re-consumed on the
187+ // new chain within a reasonable window (its bucket and rolling-hash state must persist across the
188+ // reorg), becoming consumable from the requested scope.
189+ await waitForMessageReady ( msgHash , scope ) ;
189190
190- // Wait until the message is ready to be consumed, checking that it cannot be consumed beforehand
191- await waitForMessageReady ( msgHash , scope , async ( ) => {
192- if ( scope === 'private' ) {
193- // On private, we simulate the tx locally and check that we get a missing message error, then we advance to the next block
194- await expect ( ( ) => consume ( ) . simulate ( { from : user1Address } ) ) . rejects . toThrow ( / N o L 1 t o L 2 m e s s a g e f o u n d / ) ;
195- await tryAdvanceBlock ( ) ;
196- } else {
197- // In public it is harder to determine when a message becomes consumable.
198- // We send a transaction, this advances the chain and the message MIGHT be consumed in the new block.
199- // If it does get consumed then we check that the block contains the message.
200- // If it fails we check that the block doesn't contain the message
201- const { receipt } = await consume ( ) . send ( { from : user1Address , wait : { dontThrowOnRevert : true } } ) ;
202- if ( receipt . executionResult === TxExecutionResult . SUCCESS ) {
203- // The consume tx must not succeed before the message checkpoint. It can land in a later
204- // checkpoint if the node catches up between the readiness poll and the tx being built.
205- const block = await aztecNode . getBlock ( receipt . blockNumber ! ) ;
206- expect ( block ) . toBeDefined ( ) ;
207- expect ( block ! . checkpointNumber ) . toBeGreaterThanOrEqual ( msgCheckpointNumber ) ;
208- } else {
209- expect ( receipt . executionResult ) . toEqual ( TxExecutionResult . REVERTED ) ;
191+ // The PXE is anchored to the checkpointed tip (syncChainTip: 'checkpointed'), so the re-consumed
192+ // message only becomes visible to the consume simulation once its block is checkpointed — not merely
193+ // proposed at 'latest'. Drive the rebuilt chain forward (advanceBlock marks each step proven so it
194+ // is not pruned again) until the checkpointed tip covers the block that re-consumed the message.
195+ const messageBlock = await aztecNode . getBlockNumber ( ) ;
196+ await retryUntil (
197+ async ( ) => {
198+ if ( ( await aztecNode . getBlockNumber ( 'checkpointed' ) ) >= messageBlock ) {
199+ return true ;
210200 }
211- }
212- await markAsProven ( ) ;
213- } ) ;
201+ await tryAdvanceBlock ( ) ;
202+ return false ;
203+ } ,
204+ 'wait for message block to be checkpointed' ,
205+ 180 ,
206+ ) ;
214207
215- // Verify the membership witness is available for creating the tx (private-land only)
216- if ( scope === 'private' ) {
217- const [ messageIndex ] = ( await aztecNode . getL1ToL2MessageMembershipWitness ( 'latest' , msgHash ) ) ! ;
218- expect ( messageIndex ) . toEqual ( globalLeafIndex . toBigInt ( ) ) ;
219- // And consume the message for private, public was already consumed .
220- await consume ( ) . send ( { from : user1Address } ) ;
221- }
208+ // The witness leaf index is the L1-assigned compact global index and must be stable across the prune.
209+ const [ messageIndex ] = ( await aztecNode . getL1ToL2MessageMembershipWitness ( 'latest' , msgHash ) ) ! ;
210+ expect ( messageIndex ) . toEqual ( globalLeafIndex . toBigInt ( ) ) ;
211+
212+ // The message is consumable on L2 from the requested scope .
213+ const consume = ( ) => getConsumeMethod ( scope ) ( message . content , secret , t . ethAccount , globalLeafIndex ) ;
214+ await consume ( ) . send ( { from : user1Address } ) ;
222215 } ;
223216
224- // Mines four checkpoints without proving, inserting an L1→L2 message after the drift, then
225- // triggers a rollup prune back to the pre-drift block. Verifies the message can be consumed only
226- // after the chain re-syncs to the message's checkpoint, not before, from both private and public
227- // scope (public uses a send+dontThrowOnRevert loop to probe when the message becomes consumable).
217+ // Mines several unproven checkpoints, inserts an L1→L2 message during the drift, then triggers a
218+ // rollup prune back to the pre-drift block and verifies the message is still re-consumed on the new
219+ // chain — with its L1-assigned witness index intact — from both private and public scope.
228220 it . each ( [ 'private' , 'public' ] as const ) (
229- 'can consume L1 to L2 message in %s after inbox drifts away from the rollup ' ,
221+ 'consumes an L1 to L2 message after a rollup prune drops the drifted chain (%s) ' ,
230222 async scope => {
231- await canConsumeMessageAfterInboxDrift ( scope ) ;
223+ await canConsumeMessageAfterRollupPrune ( scope ) ;
232224 } ,
233225 ) ;
234226} ) ;
0 commit comments