Skip to content

Commit 651d40c

Browse files
committed
test(fast-inbox): streaming inbox e2e latency and mid-checkpoint inclusion (A-1385)
New end-to-end coverage the legacy suite could not express (streaming Inbox, AZIP-22 / FI-15): a message included in a non-first block of a checkpoint (mid-checkpoint), the L1-inclusion to L2-availability latency within the streaming bound (slot-denominated), a message-only zero-tx block on an empty pool, and a public consume of a streaming-inserted message by compact index with double-spend protection.
1 parent f5f378f commit 651d40c

1 file changed

Lines changed: 328 additions & 0 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import type { AztecAddress } from '@aztec/aztec.js/addresses';
2+
import { generateClaimSecret } from '@aztec/aztec.js/ethereum';
3+
import { Fr } from '@aztec/aztec.js/fields';
4+
import type { Logger } from '@aztec/aztec.js/log';
5+
import type { AztecNode } from '@aztec/aztec.js/node';
6+
import { TxExecutionResult } from '@aztec/aztec.js/tx';
7+
import type { Wallet } from '@aztec/aztec.js/wallet';
8+
import { INBOX_LAG_SECONDS } from '@aztec/constants';
9+
import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types';
10+
import { retryUntil } from '@aztec/foundation/retry';
11+
import { TestContract } from '@aztec/noir-test-contracts.js/Test';
12+
import { getSlotAtTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
13+
14+
import { jest } from '@jest/globals';
15+
16+
import { L1_DIRECT_WRITE_ACCOUNT_INDEX, PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js';
17+
import { CrossChainMessagingTest } from './cross_chain_messaging_test.js';
18+
import { createL1ToL2MessageHelpers } from './message_test_helpers.js';
19+
20+
jest.setTimeout(600_000);
21+
22+
// Streaming Inbox (AZIP-22 Fast Inbox / FI-15) e2e coverage the legacy suite could not express: pre-flip
23+
// every L1->L2 message entered at the first block of the *next* checkpoint, so mid-checkpoint inclusion,
24+
// message-only blocks, and per-block streaming latency had no observable surface. Runs the production
25+
// pipelining sequencer via CrossChainMessagingTest with a widened slot (36s / 6s blocks -> up to ~4 blocks
26+
// per checkpoint) so a message can become lag-eligible partway through a checkpoint and land in a non-first
27+
// block. minTxsPerBlock=0 lets a checkpoint carry a zero-tx block whose only content is a streaming bundle.
28+
//
29+
// Grounded on l1_to_l2.test.ts (send/wait helpers, TestContract arbitrary-sender consume) and
30+
// cross_chain_public_message.test.ts (same-block public consume). All cases share one node stood up once.
31+
describe('single-node/cross-chain/streaming_inbox', () => {
32+
let t: CrossChainMessagingTest;
33+
34+
let log: Logger;
35+
let aztecNode: AztecNode;
36+
let wallet: Wallet;
37+
let user1Address: AztecAddress;
38+
let testContract: TestContract;
39+
40+
let sendMessageToL2: ReturnType<typeof createL1ToL2MessageHelpers>['sendMessageToL2'];
41+
let advanceBlock: ReturnType<typeof createL1ToL2MessageHelpers>['advanceBlock'];
42+
let waitForMessageReady: ReturnType<typeof createL1ToL2MessageHelpers>['waitForMessageReady'];
43+
44+
const markAsProven = () => t.cheatCodes.rollup.markAsProven();
45+
46+
beforeAll(async () => {
47+
t = new CrossChainMessagingTest(
48+
'streaming_inbox',
49+
// A 36s slot with 6s blocks yields up to ~4 blocks per checkpoint (the pipelining timing model gives
50+
// maxBlocks = floor((36 - 0.5 - (0.5 + D)) / D) = 4 for D=6), which is what lets a message aged past
51+
// INBOX_LAG_SECONDS land in a non-first block of the same checkpoint. minTxsPerBlock=0 permits a
52+
// zero-tx message-only block (the FI-05 relaxation).
53+
{ ...PIPELINING_SETUP_OPTS, aztecSlotDuration: 36, blockDurationMs: 6000, minTxsPerBlock: 0 },
54+
{ aztecProofSubmissionEpochs: 2, aztecEpochDuration: 4 },
55+
{ syncChainTip: 'checkpointed' },
56+
// Pass arbitrary L1->L2 messages straight to a TestContract; no token bridge needed.
57+
{ l1HarnessAccountIndex: L1_DIRECT_WRITE_ACCOUNT_INDEX, deployTokenBridge: false },
58+
);
59+
await t.setup();
60+
61+
({ logger: log, wallet, user1Address, aztecNode } = t);
62+
({ contract: testContract } = await TestContract.deploy(wallet).send({ from: user1Address }));
63+
64+
({ sendMessageToL2, advanceBlock, waitForMessageReady } = createL1ToL2MessageHelpers({
65+
t,
66+
aztecNode,
67+
wallet,
68+
user1Address,
69+
log,
70+
markAsProven,
71+
}));
72+
}, 600_000);
73+
74+
afterAll(async () => {
75+
await t.teardown();
76+
});
77+
78+
/** The L1 block timestamp at which an L1->L2 message was inserted; equals the message's Inbox bucket key. */
79+
const getMessageL1Timestamp = async (l1BlockNumber: bigint): Promise<bigint> => {
80+
const block = await t.harnessL1Client.getBlock({ blockNumber: l1BlockNumber });
81+
return block.timestamp;
82+
};
83+
84+
/**
85+
* Finds the L2 block that inserted `msgHash` into the L1-to-L2 message tree by scanning forward from
86+
* `fromBlock` for the first block whose committed tree resolves a membership witness. Under the streaming
87+
* Inbox a message enters the tree at the block that consumes its Inbox bucket, which need not be the first
88+
* block of a checkpoint. Returns the block-data (checkpoint number + index within checkpoint) of that block.
89+
*/
90+
const findInsertingBlock = async (msgHash: Fr, fromBlock: BlockNumber) => {
91+
return retryUntil(
92+
async () => {
93+
const tip = await aztecNode.getBlockNumber();
94+
for (let n = fromBlock; n <= tip; n = BlockNumber(n + 1)) {
95+
const witness = await aztecNode.getL1ToL2MessageMembershipWitness(n, msgHash);
96+
if (witness !== undefined) {
97+
const data = await aztecNode.getBlockData(n);
98+
return { blockNumber: n, checkpointNumber: data!.checkpointNumber, index: data!.indexWithinCheckpoint };
99+
}
100+
}
101+
return undefined;
102+
},
103+
`find block inserting message ${msgHash.toString()}`,
104+
240,
105+
0.5,
106+
);
107+
};
108+
109+
/**
110+
* Runs `fn` while a background loop feeds empty txs, so checkpoints build multiple blocks promptly rather
111+
* than stalling on an empty pool. advanceBlock also refreshes the L1 proof window, keeping the chain from
112+
* pruning mid-test. Callers must pass a no-op `onNotReady` to any readiness wait so it does not send its own
113+
* wallet txs concurrently (which would race the feeder on the wallet nonce).
114+
*/
115+
const withBackgroundFeeder = async <T>(fn: () => Promise<T>): Promise<T> => {
116+
let feeding = true;
117+
const feeder = (async () => {
118+
while (feeding) {
119+
try {
120+
await advanceBlock();
121+
} catch (err) {
122+
log.warn(`Feeder tx failed: ${(err as Error).message}`);
123+
}
124+
}
125+
})();
126+
try {
127+
return await fn();
128+
} finally {
129+
feeding = false;
130+
await feeder;
131+
}
132+
};
133+
134+
// Test 1 (mid-checkpoint inclusion): a message sent mid-checkpoint becomes available in a *later* block of
135+
// the same checkpoint (indexWithinCheckpoint > 0), which the legacy first-block-of-next-checkpoint flow
136+
// could never produce. Feeds a steady tx stream so checkpoints fill to multiple blocks, times the send so
137+
// the message ages past INBOX_LAG_SECONDS partway through a checkpoint's build, then locates the inserting
138+
// block. Retries with fresh messages so a message that happens to age exactly at a checkpoint boundary (and
139+
// lands at index 0) does not fail the run.
140+
it('includes a message in a non-first block of a checkpoint (mid-checkpoint streaming)', async () => {
141+
const { slotDuration } = t.constants;
142+
143+
await withBackgroundFeeder(async () => {
144+
let inserting: { blockNumber: BlockNumber; checkpointNumber: number; index: number } | undefined;
145+
let insertedMsgHash: Fr | undefined;
146+
147+
for (let attempt = 0; attempt < 4 && inserting === undefined; attempt++) {
148+
// Aim the send so the message ages past the lag partway through a checkpoint's build window. The
149+
// eligibility instant is T + INBOX_LAG_SECONDS; targeting it a few seconds into an upcoming build
150+
// window lands it on a non-first block across the ~4-block checkpoint. The eligible window is wide
151+
// (any block after the first whose build time exceeds T + lag), so exact timing is not required.
152+
const nowTs = BigInt(await t.cheatCodes.eth.lastBlockTimestamp());
153+
const currentSlot = getSlotAtTimestamp(nowTs, t.constants);
154+
const targetSlot = SlotNumber(Number(currentSlot) + 3);
155+
const sendTargetTs = getTimestampForSlot(targetSlot, t.constants) - BigInt(INBOX_LAG_SECONDS) + 4n;
156+
log.warn(`Attempt ${attempt}: waiting for L1 to reach ${sendTargetTs} before sending message`, {
157+
currentSlot,
158+
targetSlot,
159+
});
160+
await retryUntil(
161+
async () => BigInt(await t.cheatCodes.eth.lastBlockTimestamp()) >= sendTargetTs,
162+
`L1 reaches ${sendTargetTs}`,
163+
Number(slotDuration) * 6,
164+
0.2,
165+
);
166+
167+
const blockAtSend = await aztecNode.getBlockNumber();
168+
const [, secretHash] = await generateClaimSecret();
169+
const message = { recipient: testContract.address, content: Fr.random(), secretHash };
170+
const { msgHash } = await sendMessageToL2(message);
171+
log.warn(`Sent message ${msgHash.toString()} at block ${blockAtSend}`);
172+
173+
// The background feeder drives block production; findInsertingBlock polls the committed tree without
174+
// sending its own wallet txs (which would race the feeder on the nonce).
175+
const found = await findInsertingBlock(msgHash, BlockNumber(blockAtSend + 1));
176+
log.warn(`Message ${msgHash.toString()} inserted at block ${found.blockNumber}`, {
177+
checkpointNumber: found.checkpointNumber,
178+
index: found.index,
179+
});
180+
181+
if (found.index > 0) {
182+
inserting = found;
183+
insertedMsgHash = msgHash;
184+
} else {
185+
log.warn(`Message landed at index 0 (checkpoint boundary); retrying with a fresh message`);
186+
}
187+
}
188+
189+
expect(inserting).toBeDefined();
190+
// A non-first block of its checkpoint carried the message: streaming placed it mid-checkpoint, which the
191+
// legacy path (all messages at the first block of the next checkpoint) could never do.
192+
expect(inserting!.index).toBeGreaterThan(0);
193+
// The immediately preceding block did not yet have the message, confirming this block is the one that
194+
// inserted it (rather than the message having been present since an earlier block of the checkpoint).
195+
const priorWitness = await aztecNode.getL1ToL2MessageMembershipWitness(
196+
BlockNumber(inserting!.blockNumber - 1),
197+
insertedMsgHash!,
198+
);
199+
expect(priorWitness).toBeUndefined();
200+
});
201+
});
202+
203+
// Test 2 (latency bound): the delay between a message's L1 inclusion and the L2 block that makes it
204+
// available stays within the streaming bound. Asserted in slot-denominated terms (L1/L2 timestamps, not
205+
// wall-clock): the including block's timestamp minus the message's L1 timestamp must be at most
206+
// INBOX_LAG_SECONDS + 2 * slotDuration (lag + a full slot straddle + one slot of CI slack). No lower bound
207+
// is asserted (eligibility is already enforced by L1 and the validator). The wall-clock latency is logged
208+
// for information only.
209+
it('makes a message available within the streaming latency bound', async () => {
210+
const { slotDuration } = t.constants;
211+
const maxDelaySeconds = BigInt(INBOX_LAG_SECONDS) + 2n * BigInt(slotDuration);
212+
213+
await withBackgroundFeeder(async () => {
214+
const blockAtSend = await aztecNode.getBlockNumber();
215+
const wallClockAtSend = Date.now();
216+
const [, secretHash] = await generateClaimSecret();
217+
const message = { recipient: testContract.address, content: Fr.random(), secretHash };
218+
const { msgHash, txReceipt } = await sendMessageToL2(message);
219+
const messageL1Ts = await getMessageL1Timestamp(txReceipt.blockNumber!);
220+
log.warn(`Sent message ${msgHash.toString()} with L1 timestamp ${messageL1Ts}`);
221+
222+
// The background feeder drives block production; findInsertingBlock polls the committed tree without
223+
// sending its own wallet txs (which would race the feeder on the nonce).
224+
const inserting = await findInsertingBlock(msgHash, BlockNumber(blockAtSend + 1));
225+
const wallClockLatencyMs = Date.now() - wallClockAtSend;
226+
const insertingBlock = (await aztecNode.getBlock(inserting.blockNumber))!;
227+
const includingBlockTs = insertingBlock.header.globalVariables.timestamp;
228+
const delaySeconds = includingBlockTs - messageL1Ts;
229+
230+
// Informational only (A-1178 timings style): the wall-clock number flakes under CI load, so it is never
231+
// asserted on; the slot-denominated bound below is the real check.
232+
log.warn(`Streaming latency for message ${msgHash.toString()}`, {
233+
messageL1Ts,
234+
includingBlockTs,
235+
delaySeconds: Number(delaySeconds),
236+
maxDelaySeconds: Number(maxDelaySeconds),
237+
wallClockLatencyMs,
238+
});
239+
240+
expect(delaySeconds).toBeGreaterThan(0n);
241+
expect(delaySeconds).toBeLessThanOrEqual(maxDelaySeconds);
242+
});
243+
});
244+
245+
// Test 3 (message-only block): on an empty tx pool, the block that consumes a message carries zero txs and a
246+
// non-empty streaming bundle (the FI-05 shape exercised on a live chain), and the chain keeps proving past
247+
// it. Drains the pool first, then sends a single message and asserts the inserting block has no tx effects.
248+
it('produces a message-only block on an empty tx pool and keeps proving', async () => {
249+
// Let the pool drain so the checkpoint that consumes the message is not padded with unrelated txs.
250+
await retryUntil(
251+
async () => !(await aztecNode.getPendingTxCount()),
252+
'tx pool drains',
253+
Number(t.constants.slotDuration) * 3,
254+
0.5,
255+
);
256+
257+
const blockAtSend = await aztecNode.getBlockNumber();
258+
const [, secretHash] = await generateClaimSecret();
259+
const message = { recipient: testContract.address, content: Fr.random(), secretHash };
260+
const { msgHash } = await sendMessageToL2(message);
261+
log.warn(`Sent message ${msgHash.toString()} on an empty pool`);
262+
263+
// Do not feed txs; the sequencer builds empty checkpoints until the message ages past the lag, at which
264+
// point a zero-tx block consumes it. findInsertingBlock polls the committed tree without sending txs, so
265+
// the pool stays empty and the block that consumes the message carries only the bundle.
266+
const inserting = await findInsertingBlock(msgHash, BlockNumber(blockAtSend + 1));
267+
268+
const insertingBlock = (await aztecNode.getBlock(inserting.blockNumber, { includeTransactions: true }))!;
269+
log.warn(`Message ${msgHash.toString()} inserted at block ${inserting.blockNumber}`, {
270+
checkpointNumber: inserting.checkpointNumber,
271+
index: inserting.index,
272+
txCount: insertingBlock.body.txEffects.length,
273+
});
274+
275+
// The inserting block carried the message with no txs: a message-only block.
276+
expect(insertingBlock.body.txEffects.length).toBe(0);
277+
// The membership witness resolving proves the block's bundle was non-empty (it inserted the message).
278+
expect(await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash)).toBeDefined();
279+
280+
// The chain keeps proving past the message-only block.
281+
await markAsProven();
282+
await retryUntil(
283+
async () => (await aztecNode.getBlockNumber('proven')) >= inserting.blockNumber,
284+
`proven tip reaches block ${inserting.blockNumber}`,
285+
Number(t.constants.slotDuration) * t.epochDuration * 3,
286+
1,
287+
);
288+
expect(await aztecNode.getBlockNumber('proven')).toBeGreaterThanOrEqual(inserting.blockNumber);
289+
});
290+
291+
// Test 4 (send-then-consume on the streaming path): a message inserted by the streaming Inbox is consumed by
292+
// a public L2 tx, passing the compact leaf index, and cannot be consumed twice. Same-block consumption is
293+
// available post-A-1432 (a block's BlockConstantData.l1_to_l2_tree_snapshot pins to that block's post-bundle
294+
// root, so the public/AVM read sees the just-inserted message), but which block consumes the message relative
295+
// to its insertion depends on sequencer timing under the production sequencer; the block relationship is
296+
// logged for visibility while the robust invariants asserted are the successful compact-index consume and the
297+
// double-spend revert. Mirrors cross_chain_public_message.test.ts.
298+
it('consumes a streaming-inserted message by compact index and rejects double-spend', async () => {
299+
const l1Account = t.ethAccount;
300+
const blockAtSend = await aztecNode.getBlockNumber();
301+
const [secret, secretHash] = await generateClaimSecret();
302+
const message = { recipient: testContract.address, content: Fr.random(), secretHash };
303+
const { msgHash, globalLeafIndex } = await sendMessageToL2(message);
304+
log.warn(`Sent message ${msgHash.toString()} with compact index ${globalLeafIndex}`);
305+
306+
await waitForMessageReady(msgHash, 'public');
307+
const inserting = await findInsertingBlock(msgHash, BlockNumber(blockAtSend + 1));
308+
309+
const { receipt: txReceipt } = await testContract.methods
310+
.consume_message_from_arbitrary_sender_public(message.content, secret, l1Account, globalLeafIndex.toBigInt())
311+
.send({ from: user1Address });
312+
expect(txReceipt.blockNumber).toBeGreaterThan(0);
313+
// The compact leaf index from the Inbox event resolves to the same message the node inserted.
314+
const [resolvedIndex] = (await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash))!;
315+
expect(resolvedIndex).toBe(globalLeafIndex.toBigInt());
316+
log.warn(`Consumed message ${msgHash.toString()} in block ${txReceipt.blockNumber}`, {
317+
insertingBlock: inserting.blockNumber,
318+
consumeBlock: txReceipt.blockNumber,
319+
sameBlock: Number(txReceipt.blockNumber) === Number(inserting.blockNumber),
320+
});
321+
322+
// The message was inserted and consumed; a second consume must revert (the leaf is nullified).
323+
const { receipt: failedReceipt } = await testContract.methods
324+
.consume_message_from_arbitrary_sender_public(message.content, secret, l1Account, globalLeafIndex.toBigInt())
325+
.send({ from: user1Address, wait: { dontThrowOnRevert: true } });
326+
expect(failedReceipt.executionResult).toBe(TxExecutionResult.REVERTED);
327+
});
328+
});

0 commit comments

Comments
 (0)