|
| 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