Skip to content

Commit 8eaa637

Browse files
committed
feat(fast-inbox): world-state accepts per-block message bundles (A-1380)
Lift the non-first-block guard out of NativeWorldStateService.handleL2BlockAndMessages so any block may carry an L1-to-L2 message bundle and transition the message tree. First-in-checkpoint bundles are still padded to NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP (bit-identical trees for the legacy call shape); non-first bundles are appended unpadded (post-flip compact-append semantics). The transitional non-first-blocks- carry-no-messages invariant now lives at the synchronizer call site as an assertion. Per-block unwind already works natively: the append-only message tree commits a per-block snapshot on every sync_block (like the note-hash tree) and unwinds per block regardless of leaf count, with no C++ change required.
1 parent 0cebbd3 commit 8eaa637

6 files changed

Lines changed: 198 additions & 20 deletions

File tree

yarn-project/world-state/src/native/native_world_state.test.ts

Lines changed: 140 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,14 @@ import { tmpdir } from 'os';
3232
import { join } from 'path';
3333

3434
import 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';
3643
import { INITIAL_NULLIFIER_TREE_SIZE, INITIAL_PUBLIC_DATA_TREE_SIZE } from '../world-state-db/merkle_tree_db.js';
3744
import type { WorldStateStatusSummary } from './message.js';
3845
import { 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

yarn-project/world-state/src/native/native_world_state.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -264,18 +264,20 @@ export class NativeWorldStateService implements MerkleTreeDatabase {
264264
}
265265

266266
public async handleL2BlockAndMessages(l2Block: L2Block, l1ToL2Messages: Fr[]): Promise<WorldStateStatusFull> {
267+
// Any block may carry an L1-to-L2 message bundle and transition the L1-to-L2 message tree. Pre-flip the legacy
268+
// synchronizer only ever attaches messages to the first block of a checkpoint (the whole padded checkpoint bundle),
269+
// so first-in-checkpoint bundles are padded to NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP to match how the circuits build
270+
// the tree, producing bit-identical trees. Non-first bundles are appended exactly as given (the post-flip compact
271+
// append from the circuits). The "non-first blocks carry no messages" invariant of the legacy flow is now enforced
272+
// by the caller (the synchronizer) rather than here, so the API accepts the new per-block shape ahead of the flip.
273+
// Padding is the caller's transitional concern and moves entirely to the caller at the flip.
267274
const isFirstBlock = l2Block.indexWithinCheckpoint === 0;
268-
if (!isFirstBlock && l1ToL2Messages.length > 0) {
269-
throw new Error(
270-
`L1 to L2 messages must be empty for non-first blocks, but got ${l1ToL2Messages.length} messages for block ${l2Block.number}.`,
271-
);
272-
}
273275

274-
// We have to pad the given l1 to l2 messages, and the note hashes and nullifiers within tx effects, because that's
275-
// how the trees are built by circuits.
276+
// We have to pad the note hashes and nullifiers within tx effects (and, for a first-in-checkpoint block, the l1 to
277+
// l2 messages) because that's how the trees are built by circuits.
276278
const paddedL1ToL2Messages = isFirstBlock
277279
? padArrayEnd<Fr, number>(l1ToL2Messages, Fr.ZERO, NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP)
278-
: [];
280+
: l1ToL2Messages;
279281

280282
const paddedNoteHashes = l2Block.body.txEffects.flatMap(txEffect =>
281283
padArrayEnd(txEffect.noteHashes, Fr.ZERO, MAX_NOTE_HASHES_PER_TX),

yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types';
1+
import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint } from '@aztec/foundation/branded-types';
22
import { timesParallel } from '@aztec/foundation/collection';
33
import { Fr } from '@aztec/foundation/curves/bn254';
44
import { type Logger, createLogger } from '@aztec/foundation/log';
@@ -279,6 +279,17 @@ describe('ServerWorldStateSynchronizer', () => {
279279
expect(merkleTreeDb.handleL2BlockAndMessages.mock.calls[5][1]).toEqual([]);
280280
});
281281

282+
it('rejects a non-first block that carries L1->L2 messages (transitional invariant)', async () => {
283+
// World state accepts a bundle on any block, but pre-flip the synchronizer must only attach messages to the first
284+
// block of a checkpoint. The call-site guard enforces that until the flip switches to per-block derivation.
285+
const nonFirstBlock = await L2Block.random(BlockNumber(2), { indexWithinCheckpoint: IndexWithinCheckpoint(1) });
286+
287+
await expect(server.callHandleL2Block(nonFirstBlock, [Fr.random()])).rejects.toThrow(
288+
'L1 to L2 messages must be empty for non-first blocks',
289+
);
290+
expect(merkleTreeDb.handleL2BlockAndMessages).not.toHaveBeenCalled();
291+
});
292+
282293
describe('getVerifiedSnapshot', () => {
283294
let snapshot: MockProxy<MerkleTreeReadOperations>;
284295

@@ -355,6 +366,10 @@ class TestWorldStateSynchronizer extends ServerWorldStateSynchronizer {
355366
return this.mockBlockStream;
356367
}
357368

369+
public callHandleL2Block(block: L2Block, messages: Fr[]) {
370+
return this.handleL2Block(block, messages);
371+
}
372+
358373
public override getL2Tips() {
359374
return Promise.resolve({
360375
proposed: this.latest,

yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Fr } from '@aztec/foundation/curves/bn254';
33
import { type Logger, createLogger } from '@aztec/foundation/log';
44
import { promiseWithResolvers } from '@aztec/foundation/promise';
55
import { elapsed } from '@aztec/foundation/timer';
6+
import { assert } from '@aztec/foundation/validation';
67
import {
78
type BlockHash,
89
EventDrivenL2BlockStream,
@@ -406,7 +407,14 @@ export class ServerWorldStateSynchronizer
406407
* @param l1ToL2Messages - The L1 to L2 messages for the block.
407408
* @returns Whether the block handled was produced by this same node.
408409
*/
409-
private async handleL2Block(l2Block: L2Block, l1ToL2Messages: Fr[]): Promise<WorldStateStatusFull> {
410+
protected async handleL2Block(l2Block: L2Block, l1ToL2Messages: Fr[]): Promise<WorldStateStatusFull> {
411+
// Transitional invariant (pre-flip): the legacy per-checkpoint fetch only ever attaches messages to the first
412+
// block of a checkpoint, so no non-first block should carry a bundle here. World state itself now accepts a bundle
413+
// on any block; this rule is owned by the synchronizer until the flip switches it to per-block message derivation.
414+
assert(
415+
l2Block.indexWithinCheckpoint === 0 || l1ToL2Messages.length === 0,
416+
`L1 to L2 messages must be empty for non-first blocks, but got ${l1ToL2Messages.length} messages for block ${l2Block.number} (index ${l2Block.indexWithinCheckpoint} within checkpoint).`,
417+
);
410418
this.log.debug(`Pushing L2 block ${l2Block.number} to merkle tree db `, {
411419
blockNumber: l2Block.number,
412420
blockHash: await l2Block.hash().then(h => h.toString()),

yarn-project/world-state/src/test/utils.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,32 @@ export async function updateBlockState(block: L2Block, l1ToL2Messages: Fr[], for
7777
block.archive = new AppendOnlyTreeSnapshot(Fr.fromBuffer(archiveState.root), Number(archiveState.size));
7878
}
7979

80-
export async function mockBlock(
80+
export function mockBlock(
8181
blockNum: BlockNumber,
8282
size: number,
8383
fork: MerkleTreeWriteOperations,
8484
maxEffects: number | undefined = 1000, // Defaults to the maximum tx effects.
8585
numL1ToL2Messages: number = NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP,
8686
isFirstBlockInCheckpoint: boolean = true,
87+
) {
88+
return mockBlockWithIndex(blockNum, isFirstBlockInCheckpoint ? 0 : 1, size, fork, numL1ToL2Messages, maxEffects);
89+
}
90+
91+
/**
92+
* Builds a mock L2 block at an explicit position within its checkpoint, applying its state (including its L1-to-L2
93+
* message bundle) to the given fork. Unlike {@link mockBlock}, the caller chooses the `indexWithinCheckpoint`, so
94+
* non-first blocks can carry message bundles — exercising the per-block message insertion path.
95+
*/
96+
export async function mockBlockWithIndex(
97+
blockNum: BlockNumber,
98+
indexWithinCheckpoint: number,
99+
size: number,
100+
fork: MerkleTreeWriteOperations,
101+
numL1ToL2Messages: number = NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP,
102+
maxEffects: number | undefined = 1000, // Defaults to the maximum tx effects.
87103
) {
88104
const block = await L2Block.random(blockNum, {
89-
indexWithinCheckpoint: isFirstBlockInCheckpoint ? IndexWithinCheckpoint(0) : IndexWithinCheckpoint(1),
105+
indexWithinCheckpoint: IndexWithinCheckpoint(indexWithinCheckpoint),
90106
txsPerBlock: size,
91107
txOptions: { maxEffects },
92108
});

yarn-project/world-state/src/world-state-db/merkle_tree_db.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ export const INITIAL_PUBLIC_DATA_TREE_SIZE = 2 * MAX_TOTAL_PUBLIC_DATA_UPDATE_RE
3131

3232
export interface MerkleTreeAdminDatabase extends ForkMerkleTreeOperations, ReadonlyWorldStateAccess {
3333
/**
34-
* Handles a single L2 block (i.e. Inserts the new note hashes into the merkle tree).
34+
* Handles a single L2 block: inserts its note hashes, nullifiers, public data writes, and the block's L1-to-L2
35+
* message bundle into the merkle trees. Any block may carry a message bundle and transition the L1-to-L2 message
36+
* tree, not just the first block of a checkpoint. A first-in-checkpoint bundle is padded to
37+
* NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP to match how the circuits build the tree; a non-first bundle is appended
38+
* exactly as given. Padding is a transitional concern of this method that moves entirely to the caller at the flip.
3539
* @param block - The L2 block to handle.
3640
* @param l1ToL2Messages - The L1 to L2 messages for the block.
3741
*/

0 commit comments

Comments
 (0)