@@ -32,7 +32,14 @@ import { tmpdir } from 'os';
3232import { join } from 'path' ;
3333
3434import type { WorldStateTreeMapSizes } from '../synchronizer/factory.js' ;
35- import { assertSameState , compareChains , mockBlock , mockEmptyBlock , updateBlockState } from '../test/utils.js' ;
35+ import {
36+ assertSameState ,
37+ compareChains ,
38+ mockBlock ,
39+ mockBlockWithIndex ,
40+ mockEmptyBlock ,
41+ updateBlockState ,
42+ } from '../test/utils.js' ;
3643import { INITIAL_NULLIFIER_TREE_SIZE , INITIAL_PUBLIC_DATA_TREE_SIZE } from '../world-state-db/merkle_tree_db.js' ;
3744import type { WorldStateStatusSummary } from './message.js' ;
3845import { NativeWorldStateService , WORLD_STATE_DB_VERSION , WORLD_STATE_DIR } from './native_world_state.js' ;
@@ -149,14 +156,140 @@ describe('NativeWorldState', () => {
149156 expect ( status . meta . messageTreeMeta . size ) . toBe ( BigInt ( NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP ) ) ;
150157 } ) ;
151158
152- it ( 'throws error if messages are provided for non-first block' , async ( ) => {
153- const isFirstBlock = false ;
154- const numMessages = 1 ;
155- const { block, messages } = await mockBlock ( BlockNumber ( 1 ) , 1 , fork , 1 , numMessages , isFirstBlock ) ;
159+ it ( 'appends a non-first block bundle without padding' , async ( ) => {
160+ const numMessages = 3 ;
161+ const { block, messages } = await mockBlockWithIndex (
162+ BlockNumber ( 1 ) ,
163+ /*indexWithinCheckpoint=*/ 1 ,
164+ 1 ,
165+ fork ,
166+ numMessages ,
167+ 1 ,
168+ ) ;
169+
170+ const status = await ws . handleL2BlockAndMessages ( block , messages ) ;
171+
172+ // Non-first blocks append their bundle exactly as given (no padding to NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP).
173+ expect ( status . meta . messageTreeMeta . size ) . toBe ( BigInt ( numMessages ) ) ;
174+ } ) ;
175+ } ) ;
176+
177+ describe ( 'Per-block message insertion' , ( ) => {
178+ let ws : NativeWorldStateService ;
179+
180+ beforeEach ( async ( ) => {
181+ ws = await NativeWorldStateService . tmp ( ) ;
182+ } ) ;
183+
184+ afterEach ( async ( ) => {
185+ await ws . close ( ) ;
186+ } ) ;
187+
188+ it ( 'advances the L1-to-L2 message tree per block, including on non-first blocks' , async ( ) => {
189+ const fork = await ws . fork ( ) ;
190+
191+ // Block 1 is first-in-checkpoint, so its bundle is padded to NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP (this is how
192+ // the circuits build the tree).
193+ const { block : b1 , messages : m1 } = await mockBlockWithIndex ( BlockNumber ( 1 ) , /*index=*/ 0 , 1 , fork , 3 , 1 ) ;
194+ const s1 = await ws . handleL2BlockAndMessages ( b1 , m1 ) ;
195+ expect ( s1 . meta . messageTreeMeta . size ) . toBe ( BigInt ( NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP ) ) ;
196+ expect ( s1 . meta . messageTreeMeta . unfinalizedBlockHeight ) . toBe ( 1 ) ;
197+
198+ // Block 2 is non-first and carries no messages: the message tree size and root are unchanged, but the tree is
199+ // still committed as a new block (so its per-block history stays in lockstep with the other trees).
200+ const { block : b2 , messages : m2 } = await mockBlockWithIndex ( BlockNumber ( 2 ) , /*index=*/ 1 , 1 , fork , 0 , 1 ) ;
201+ const s2 = await ws . handleL2BlockAndMessages ( b2 , m2 ) ;
202+ expect ( s2 . meta . messageTreeMeta . size ) . toBe ( BigInt ( NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP ) ) ;
203+ expect ( s2 . meta . messageTreeMeta . root ) . toEqual ( s1 . meta . messageTreeMeta . root ) ;
204+ expect ( s2 . meta . messageTreeMeta . unfinalizedBlockHeight ) . toBe ( 2 ) ;
205+
206+ // Block 3 is non-first and carries messages: the bundle is appended unpadded, so the tree grows by exactly the
207+ // bundle size and the root changes on a non-first block.
208+ const { block : b3 , messages : m3 } = await mockBlockWithIndex ( BlockNumber ( 3 ) , /*index=*/ 2 , 1 , fork , 5 , 1 ) ;
209+ const s3 = await ws . handleL2BlockAndMessages ( b3 , m3 ) ;
210+ expect ( s3 . meta . messageTreeMeta . size ) . toBe ( BigInt ( NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP + 5 ) ) ;
211+ expect ( s3 . meta . messageTreeMeta . root ) . not . toEqual ( s2 . meta . messageTreeMeta . root ) ;
212+ expect ( s3 . meta . messageTreeMeta . unfinalizedBlockHeight ) . toBe ( 3 ) ;
156213
157- await expect ( ws . handleL2BlockAndMessages ( block , messages ) ) . rejects . toThrow (
158- 'L1 to L2 messages must be empty for non-first blocks' ,
214+ await fork . close ( ) ;
215+
216+ // A fork opened at block 2 sees exactly the first two bundles (3 padded + 0); at block 3 it also sees the third.
217+ const forkAt2 = await ws . fork ( BlockNumber ( 2 ) ) ;
218+ expect ( ( await forkAt2 . getTreeInfo ( MerkleTreeId . L1_TO_L2_MESSAGE_TREE ) ) . size ) . toBe (
219+ BigInt ( NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP ) ,
220+ ) ;
221+ await forkAt2 . close ( ) ;
222+
223+ const forkAt3 = await ws . fork ( BlockNumber ( 3 ) ) ;
224+ expect ( ( await forkAt3 . getTreeInfo ( MerkleTreeId . L1_TO_L2_MESSAGE_TREE ) ) . size ) . toBe (
225+ BigInt ( NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP + 5 ) ,
159226 ) ;
227+ await forkAt3 . close ( ) ;
228+ } ) ;
229+
230+ it ( 'unwinds per block, reverting exactly the messages appended by a non-first block' , async ( ) => {
231+ const fork = await ws . fork ( ) ;
232+
233+ const { block : b1 , messages : m1 } = await mockBlockWithIndex ( BlockNumber ( 1 ) , /*index=*/ 0 , 1 , fork , 2 , 1 ) ;
234+ const s1 = await ws . handleL2BlockAndMessages ( b1 , m1 ) ;
235+
236+ // Non-first block carrying 4 messages.
237+ const { block : b2 , messages : m2 } = await mockBlockWithIndex ( BlockNumber ( 2 ) , /*index=*/ 1 , 1 , fork , 4 , 1 ) ;
238+ const s2 = await ws . handleL2BlockAndMessages ( b2 , m2 ) ;
239+ expect ( s2 . meta . messageTreeMeta . size ) . toBe ( BigInt ( NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP + 4 ) ) ;
240+
241+ // Non-first block carrying 5 messages.
242+ const { block : b3 , messages : m3 } = await mockBlockWithIndex ( BlockNumber ( 3 ) , /*index=*/ 2 , 1 , fork , 5 , 1 ) ;
243+ const s3 = await ws . handleL2BlockAndMessages ( b3 , m3 ) ;
244+ expect ( s3 . meta . messageTreeMeta . size ) . toBe ( BigInt ( NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP + 9 ) ) ;
245+ await fork . close ( ) ;
246+
247+ // Unwind block 3: the message tree returns to the post-block-2 state.
248+ const afterUnwind3 = await ws . unwindBlocks ( BlockNumber ( 2 ) ) ;
249+ expect ( afterUnwind3 . meta . messageTreeMeta . size ) . toBe ( s2 . meta . messageTreeMeta . size ) ;
250+ expect ( afterUnwind3 . meta . messageTreeMeta . root ) . toEqual ( s2 . meta . messageTreeMeta . root ) ;
251+
252+ // Unwind block 2 (a message-carrying non-first block): its 4 messages are reverted, back to the post-block-1
253+ // state — the pending-chain rollback does not assume the message tree only changes at checkpoint starts.
254+ const afterUnwind2 = await ws . unwindBlocks ( BlockNumber ( 1 ) ) ;
255+ expect ( afterUnwind2 . meta . messageTreeMeta . size ) . toBe ( s1 . meta . messageTreeMeta . size ) ;
256+ expect ( afterUnwind2 . meta . messageTreeMeta . root ) . toEqual ( s1 . meta . messageTreeMeta . root ) ;
257+
258+ // Re-syncing after the unwind reconverges: fresh non-first blocks append cleanly on top of block 1.
259+ const resyncFork = await ws . fork ( ) ;
260+ const { block : b2b , messages : m2b } = await mockBlockWithIndex ( BlockNumber ( 2 ) , /*index=*/ 1 , 1 , resyncFork , 4 , 1 ) ;
261+ const s2b = await ws . handleL2BlockAndMessages ( b2b , m2b ) ;
262+ expect ( s2b . meta . messageTreeMeta . size ) . toBe ( BigInt ( NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP + 4 ) ) ;
263+
264+ const { block : b3b , messages : m3b } = await mockBlockWithIndex ( BlockNumber ( 3 ) , /*index=*/ 2 , 1 , resyncFork , 5 , 1 ) ;
265+ const s3b = await ws . handleL2BlockAndMessages ( b3b , m3b ) ;
266+ expect ( s3b . meta . messageTreeMeta . size ) . toBe ( BigInt ( NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP + 9 ) ) ;
267+ await resyncFork . close ( ) ;
268+ } ) ;
269+
270+ it ( 'leaves the message tree byte-identical on every non-first block of a legacy-shaped checkpoint' , async ( ) => {
271+ const fork = await ws . fork ( ) ;
272+
273+ // Legacy call shape: the whole (padded) checkpoint bundle is attached to the first block; non-first blocks carry
274+ // an empty bundle. With this shape the code change is a no-op, so the message tree must match the pre-change
275+ // behaviour (identical trees) at every block, not just at the checkpoint end.
276+ const { block : b1 , messages : m1 } = await mockBlockWithIndex ( BlockNumber ( 1 ) , /*index=*/ 0 , 2 , fork , 6 , 2 ) ;
277+ const s1 = await ws . handleL2BlockAndMessages ( b1 , m1 ) ;
278+ expect ( s1 . meta . messageTreeMeta . size ) . toBe ( BigInt ( NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP ) ) ;
279+
280+ for ( let index = 1 ; index <= 2 ; index ++ ) {
281+ const blockNumber = index + 1 ;
282+ const { block, messages } = await mockBlockWithIndex ( BlockNumber ( blockNumber ) , index , 2 , fork , 0 , 2 ) ;
283+ const status = await ws . handleL2BlockAndMessages ( block , messages ) ;
284+
285+ // The message tree is untouched by non-first blocks in the legacy shape.
286+ expect ( status . meta . messageTreeMeta . size ) . toEqual ( s1 . meta . messageTreeMeta . size ) ;
287+ expect ( status . meta . messageTreeMeta . root ) . toEqual ( s1 . meta . messageTreeMeta . root ) ;
288+ // But the chain as a whole still advances: the archive tree grows with each block.
289+ expect ( status . meta . archiveTreeMeta . unfinalizedBlockHeight ) . toBe ( blockNumber ) ;
290+ }
291+
292+ await fork . close ( ) ;
160293 } ) ;
161294 } ) ;
162295
0 commit comments