From 7f36f7b7e6715fc4722aa85107aafddd26eec578 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sat, 18 Jul 2026 04:14:56 -0300 Subject: [PATCH] feat(fast-inbox): block proposals carry an optional inbox bucket reference (A-1381) Adds an optional InboxBucketRef (bucketSeq, bucketTimestamp, inboxRollingHash) to BlockProposal and to the last block of CheckpointProposal (AZIP-22 Fast Inbox), so validators can derive the consumed message bundle from their own Inbox view rather than trusting a proposer-supplied list. - The reference goes inside the signed payload (getPayloadToSign), so a relay cannot strip or inject it without breaking signature recovery. The checkpoint header's inboxRollingHash remains the consensus commitment; a wrong reference can only miss the lookup. - Optional-tail wire encoding: unset proposals serialize byte-identically to the legacy format, and decoders treat EOF after the existing fields as "no reference", so mixed-version gossip keeps working with no topic-version bump. - CheckpointProposal enforces at construction that a set last-block reference's rolling hash equals the checkpoint header's inboxRollingHash, mirroring the existing inHash cross-check. - Plumbing only: the sequencer passes undefined pre-flip and the legacy validation path ignores the field (populated in FI-12, validated in FI-13). Golden fixtures pin the pre-change bytes for the byte-identity and cross-version tolerance tests. --- .../stdlib/src/messaging/inbox_bucket.test.ts | 50 +++++++ .../stdlib/src/messaging/inbox_bucket.ts | 90 +++++++++++- .../stdlib/src/p2p/block_proposal.test.ts | 134 ++++++++++++++++++ yarn-project/stdlib/src/p2p/block_proposal.ts | 54 +++++-- .../src/p2p/checkpoint_proposal.test.ts | 126 ++++++++++++++++ .../stdlib/src/p2p/checkpoint_proposal.ts | 35 ++++- .../stdlib/src/p2p/wire_compat_fixtures.ts | 22 +++ yarn-project/stdlib/src/tests/mocks.ts | 6 + 8 files changed, 502 insertions(+), 15 deletions(-) create mode 100644 yarn-project/stdlib/src/messaging/inbox_bucket.test.ts create mode 100644 yarn-project/stdlib/src/p2p/checkpoint_proposal.test.ts create mode 100644 yarn-project/stdlib/src/p2p/wire_compat_fixtures.ts diff --git a/yarn-project/stdlib/src/messaging/inbox_bucket.test.ts b/yarn-project/stdlib/src/messaging/inbox_bucket.test.ts new file mode 100644 index 000000000000..0a945240777e --- /dev/null +++ b/yarn-project/stdlib/src/messaging/inbox_bucket.test.ts @@ -0,0 +1,50 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { jsonParseWithSchema, jsonStringify } from '@aztec/foundation/json-rpc'; + +import type { InboxBucket } from './inbox_bucket.js'; +import { InboxBucketRef } from './inbox_bucket.js'; + +describe('InboxBucketRef', () => { + it('serializes and deserializes round-trip', () => { + const ref = new InboxBucketRef(42n, 1_700_000_000n, Fr.random()); + const deserialized = InboxBucketRef.fromBuffer(ref.toBuffer()); + expect(deserialized).toEqual(ref); + expect(deserialized.equals(ref)).toBe(true); + }); + + it('serializes to the fixed advertised size', () => { + const ref = InboxBucketRef.random(); + expect(ref.toBuffer().length).toBe(InboxBucketRef.SIZE); + expect(ref.getSize()).toBe(InboxBucketRef.SIZE); + }); + + it('equals distinguishes each component', () => { + const ref = new InboxBucketRef(7n, 100n, new Fr(9n)); + expect(ref.equals(new InboxBucketRef(8n, 100n, new Fr(9n)))).toBe(false); + expect(ref.equals(new InboxBucketRef(7n, 101n, new Fr(9n)))).toBe(false); + expect(ref.equals(new InboxBucketRef(7n, 100n, new Fr(10n)))).toBe(false); + expect(ref.equals(new InboxBucketRef(7n, 100n, new Fr(9n)))).toBe(true); + }); + + it('derives from a bucket snapshot', () => { + const bucket: InboxBucket = { + seq: 12n, + inboxRollingHash: new Fr(0xabcn), + totalMsgCount: 30n, + timestamp: 1_650_000_000n, + msgCount: 3, + lastMessageIndex: 29n, + isOpen: true, + }; + const ref = InboxBucketRef.fromBucket(bucket); + expect(ref.bucketSeq).toBe(bucket.seq); + expect(ref.bucketTimestamp).toBe(bucket.timestamp); + expect(ref.inboxRollingHash).toEqual(bucket.inboxRollingHash); + }); + + it('round-trips through its zod schema', () => { + const ref = InboxBucketRef.random(); + const parsed = jsonParseWithSchema(jsonStringify(ref), InboxBucketRef.schema); + expect(parsed).toEqual(ref); + }); +}); diff --git a/yarn-project/stdlib/src/messaging/inbox_bucket.ts b/yarn-project/stdlib/src/messaging/inbox_bucket.ts index 5dd08e09d2b9..fca1a098d1c6 100644 --- a/yarn-project/stdlib/src/messaging/inbox_bucket.ts +++ b/yarn-project/stdlib/src/messaging/inbox_bucket.ts @@ -1,5 +1,7 @@ import { Fr } from '@aztec/foundation/curves/bn254'; -import { schemas } from '@aztec/foundation/schemas'; +import { type ZodFor, schemas } from '@aztec/foundation/schemas'; +import { BufferReader, bigintToUInt64BE, serializeToBuffer } from '@aztec/foundation/serialize'; +import type { FieldsOf } from '@aztec/foundation/types'; import { z } from 'zod'; @@ -41,3 +43,89 @@ export const InboxBucketSchema = z.object({ lastMessageIndex: schemas.BigInt, isOpen: z.boolean(), }) satisfies z.ZodType; + +/** + * Reference to a settled Inbox rolling-hash bucket, carried alongside a block proposal (AZIP-22 Fast Inbox) so a + * validator can look the bucket up in its own Inbox view and derive the consumed-message bundle itself, rather than + * trusting a proposer-supplied message list. Pins the bucket by its dense sequence number and recency-key timestamp + * and asserts the expected consensus rolling hash. A wrong reference can only cause a lookup miss or hash mismatch; it + * can never change what a validator accepts, because the checkpoint header's `inboxRollingHash` remains the signed + * consensus commitment (mirrors the unsigned bucket hint on L1's `Rollup.propose`). + */ +export class InboxBucketRef { + constructor( + /** Dense, monotonically increasing sequence number of the referenced bucket in the Inbox ring. */ + public readonly bucketSeq: bigint, + /** L1 block timestamp (in seconds) at which the referenced bucket was opened; its recency key. */ + public readonly bucketTimestamp: bigint, + /** Consensus rolling hash (truncated sha256 chain) after the last message absorbed into the referenced bucket. */ + public readonly inboxRollingHash: Fr, + ) {} + + /** Serialized size in bytes: two uint64 fields plus one field element. */ + static readonly SIZE = 8 + 8 + Fr.SIZE_IN_BYTES; + + static get schema(): ZodFor { + return z + .object({ + bucketSeq: schemas.BigInt, + bucketTimestamp: schemas.BigInt, + inboxRollingHash: Fr.schema, + }) + .transform(InboxBucketRef.from); + } + + static from(fields: FieldsOf): InboxBucketRef { + return new InboxBucketRef(fields.bucketSeq, fields.bucketTimestamp, fields.inboxRollingHash); + } + + /** Derives a wire reference from a bucket snapshot as tracked by the archiver. */ + static fromBucket(bucket: InboxBucket): InboxBucketRef { + return new InboxBucketRef(bucket.seq, bucket.timestamp, bucket.inboxRollingHash); + } + + toBuffer(): Buffer { + return serializeToBuffer([ + bigintToUInt64BE(this.bucketSeq), + bigintToUInt64BE(this.bucketTimestamp), + this.inboxRollingHash, + ]); + } + + static fromBuffer(buffer: Buffer | BufferReader): InboxBucketRef { + const reader = BufferReader.asReader(buffer); + return new InboxBucketRef(reader.readUInt64(), reader.readUInt64(), reader.readObject(Fr)); + } + + getSize(): number { + return InboxBucketRef.SIZE; + } + + equals(other: InboxBucketRef): boolean { + return ( + this.bucketSeq === other.bucketSeq && + this.bucketTimestamp === other.bucketTimestamp && + this.inboxRollingHash.equals(other.inboxRollingHash) + ); + } + + static empty(): InboxBucketRef { + return new InboxBucketRef(0n, 0n, Fr.ZERO); + } + + static random(): InboxBucketRef { + return new InboxBucketRef( + BigInt(Math.floor(Math.random() * 1000)), + BigInt(Math.floor(Math.random() * 1_000_000)), + Fr.random(), + ); + } + + toInspect() { + return { + bucketSeq: this.bucketSeq.toString(), + bucketTimestamp: this.bucketTimestamp.toString(), + inboxRollingHash: this.inboxRollingHash.toString(), + }; + } +} diff --git a/yarn-project/stdlib/src/p2p/block_proposal.test.ts b/yarn-project/stdlib/src/p2p/block_proposal.test.ts index 2f6601b74506..1e37f9f001df 100644 --- a/yarn-project/stdlib/src/p2p/block_proposal.test.ts +++ b/yarn-project/stdlib/src/p2p/block_proposal.test.ts @@ -1,11 +1,34 @@ // Serde test for the block proposal type +import { IndexWithinCheckpoint } from '@aztec/foundation/branded-types'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; +import { Fr } from '@aztec/foundation/curves/bn254'; import { Signature } from '@aztec/foundation/eth-signature'; +import { bufferToHex, hexToBuffer } from '@aztec/foundation/string'; +import { InboxBucketRef } from '../messaging/inbox_bucket.js'; import { TEST_COORDINATION_SIGNATURE_CONTEXT, makeBlockProposal } from '../tests/mocks.js'; +import { BlockHeader } from '../tx/block_header.js'; import { Tx } from '../tx/tx.js'; +import { TxHash } from '../tx/tx_hash.js'; import { BlockProposal } from './block_proposal.js'; +import { EMPTY_COORDINATION_SIGNATURE_CONTEXT } from './signature_utils.js'; import { SignedTxs } from './signed_txs.js'; +import { LEGACY_BLOCK_PROPOSAL_HEX, LEGACY_BLOCK_PROPOSAL_PAYLOAD_HEX } from './wire_compat_fixtures.js'; + +/** + * Deterministic legacy-shaped proposal (no signedTxs, no bucketRef) matching the golden fixtures in + * wire_compat_fixtures.ts. Constructed identically to how those bytes were captured on the pre-change code. + */ +const makeLegacyFixtureProposal = () => + new BlockProposal( + BlockHeader.empty(), + IndexWithinCheckpoint(3), + new Fr(42n), + new Fr(99n), + [TxHash.fromField(new Fr(7n)), TxHash.fromField(new Fr(8n))], + Signature.empty(), + EMPTY_COORDINATION_SIGNATURE_CONTEXT, + ); describe('Block Proposal serialization / deserialization', () => { const checkEquivalence = (serialized: BlockProposal, deserialized: BlockProposal) => { @@ -92,4 +115,115 @@ describe('Block Proposal serialization / deserialization', () => { expect(tampered.getSender()).toBeUndefined(); }); + + describe('bucket reference (AZIP-22 Fast Inbox)', () => { + it('round-trips with a bucket reference set', async () => { + const bucketRef = InboxBucketRef.random(); + const proposal = await makeBlockProposal({ bucketRef }); + + const deserialized = BlockProposal.fromBuffer(proposal.toBuffer()); + + expect(deserialized.bucketRef).toBeDefined(); + expect(deserialized.bucketRef!.equals(bucketRef)).toBe(true); + expect(deserialized.getSize()).toEqual(proposal.getSize()); + expect(deserialized).toEqual(proposal); + }); + + it('round-trips with a bucket reference set alongside signed txs', async () => { + const bucketRef = InboxBucketRef.random(); + const txs = await Promise.all([Tx.random(), Tx.random()]); + const proposal = await makeBlockProposal({ txs, bucketRef }); + + const deserialized = BlockProposal.fromBuffer(proposal.toBuffer()); + + expect(deserialized.bucketRef!.equals(bucketRef)).toBe(true); + expect(deserialized.txs?.length).toEqual(txs.length); + expect(deserialized).toEqual(proposal); + }); + + it('serializes byte-identically to the legacy format when unset', () => { + const proposal = makeLegacyFixtureProposal(); + expect(proposal.bucketRef).toBeUndefined(); + expect(bufferToHex(proposal.toBuffer())).toEqual(LEGACY_BLOCK_PROPOSAL_HEX); + expect(bufferToHex(proposal.getPayloadToSign())).toEqual(LEGACY_BLOCK_PROPOSAL_PAYLOAD_HEX); + }); + + it('decodes a legacy buffer (no tail) as having no bucket reference', () => { + const deserialized = BlockProposal.fromBuffer(hexToBuffer(LEGACY_BLOCK_PROPOSAL_HEX)); + expect(deserialized.bucketRef).toBeUndefined(); + // Re-encoding a legacy buffer yields the same legacy bytes: no phantom tail is introduced. + expect(bufferToHex(deserialized.toBuffer())).toEqual(LEGACY_BLOCK_PROPOSAL_HEX); + }); + + it('appends the bucket reference only when set, changing the signed payload', async () => { + const bucketRef = InboxBucketRef.random(); + const withRef = await makeBlockProposal({ bucketRef }); + const withoutRef = await makeBlockProposal({ + blockHeader: withRef.blockHeader, + indexWithinCheckpoint: withRef.indexWithinCheckpoint, + inHash: withRef.inHash, + archiveRoot: withRef.archiveRoot, + txHashes: withRef.txHashes, + }); + + const withRefPayload = withRef.getPayloadToSign(); + const withoutRefPayload = withoutRef.getPayloadToSign(); + + // The set payload extends the unset payload by exactly the reference bytes (appended tail, no marker). + expect(withRefPayload.length).toEqual(withoutRefPayload.length + InboxBucketRef.SIZE); + expect(withRefPayload.subarray(0, withoutRefPayload.length)).toEqual(withoutRefPayload); + // The payload hashes differ, so the attestation pool treats set-vs-unset as distinct payloads. + expect(withRef.getPayloadHash().toString()).not.toEqual(withoutRef.getPayloadHash().toString()); + }); + + it('covers the bucket reference under the proposal signature', async () => { + const signer = Secp256k1Signer.random(); + const bucketRef = InboxBucketRef.random(); + const proposal = await makeBlockProposal({ signer, bucketRef }); + + const deserialized = BlockProposal.fromBuffer(proposal.toBuffer()); + expect(deserialized.getSender()).toEqual(signer.address); + }); + + it('breaks sender recovery when the bucket reference is tampered with', async () => { + const signer = Secp256k1Signer.random(); + const bucketRef = new InboxBucketRef(5n, 100n, new Fr(7n)); + const proposal = await makeBlockProposal({ signer, bucketRef }); + expect(proposal.getSender()).toEqual(signer.address); + + // A relay swapping the signed reference for a different one is not covered by the original signature. + const tampered = new BlockProposal( + proposal.blockHeader, + proposal.indexWithinCheckpoint, + proposal.inHash, + proposal.archiveRoot, + proposal.txHashes, + proposal.signature, + proposal.signatureContext, + proposal.signedTxs, + new InboxBucketRef(6n, 100n, new Fr(7n)), + ); + expect(tampered.getSender()).not.toEqual(signer.address); + }); + + it('breaks sender recovery when a bucket reference is injected into an unsigned proposal', async () => { + const signer = Secp256k1Signer.random(); + const proposal = await makeBlockProposal({ signer }); + expect(proposal.getSender()).toEqual(signer.address); + + // A relay injecting a reference the proposer never signed over is rejected by signature recovery. + const injected = new BlockProposal( + proposal.blockHeader, + proposal.indexWithinCheckpoint, + proposal.inHash, + proposal.archiveRoot, + proposal.txHashes, + proposal.signature, + proposal.signatureContext, + proposal.signedTxs, + InboxBucketRef.random(), + ); + expect(injected.getSender()).not.toEqual(signer.address); + }); + }); }); diff --git a/yarn-project/stdlib/src/p2p/block_proposal.ts b/yarn-project/stdlib/src/p2p/block_proposal.ts index 1d29fa05ff99..13b76b08e938 100644 --- a/yarn-project/stdlib/src/p2p/block_proposal.ts +++ b/yarn-project/stdlib/src/p2p/block_proposal.ts @@ -18,6 +18,7 @@ import type { L2Block } from '../block/l2_block.js'; import type { L2BlockInfo } from '../block/l2_block_info.js'; import { MAX_TXS_PER_BLOCK } from '../deserialization/index.js'; import { DutyType, type SigningContext } from '../ha-signing/index.js'; +import { InboxBucketRef } from '../messaging/inbox_bucket.js'; import { BlockHeader } from '../tx/block_header.js'; import { TxHash } from '../tx/index.js'; import type { Tx } from '../tx/tx.js'; @@ -87,6 +88,13 @@ export class BlockProposal extends Gossipable implements Signable { /** The signed transactions in the block (optional, for DA guarantees) */ public readonly signedTxs?: SignedTxs, + + /** + * Reference to the Inbox bucket this block proposes to consume (AZIP-22 Fast Inbox). Optional pre-flip: the + * sequencer leaves it unset until the streaming Inbox is enabled, at which point validators derive the consumed + * message bundle from it. Covered by the proposal signature (part of `getPayloadToSign`). + */ + public readonly bucketRef?: InboxBucketRef, ) { super(); } @@ -124,7 +132,9 @@ export class BlockProposal extends Gossipable implements Signable { /** * Get the payload to sign for this block proposal. - * The signature is over: blockHeader + indexWithinCheckpoint + inHash + archiveRoot + txHashes + * The signature is over: blockHeader + indexWithinCheckpoint + inHash + archiveRoot + txHashes, plus the bucket + * reference when set. Appending only when set keeps the pre-flip signed payload byte-identical to the legacy format, + * while binding the reference to the signature so a relay cannot strip or inject it without breaking recovery. */ getPayloadToSign(): Buffer { return serializeToBuffer([ @@ -134,6 +144,7 @@ export class BlockProposal extends Gossipable implements Signable { this.archiveRoot, this.txHashes.length, this.txHashes, + ...(this.bucketRef ? [this.bucketRef] : []), ]); } @@ -163,6 +174,7 @@ export class BlockProposal extends Gossipable implements Signable { signatureContext: CoordinationSignatureContext, proposalSigner: (typedData: TypedDataDefinition, context: SigningContext) => Promise, txsSigner?: (typedData: TypedDataDefinition, context: SigningContext) => Promise, + bucketRef?: InboxBucketRef, ): Promise { // Create a temporary proposal to get the payload to sign const tempProposal = new BlockProposal( @@ -173,6 +185,8 @@ export class BlockProposal extends Gossipable implements Signable { txHashes, Signature.empty(), signatureContext, + undefined, + bucketRef, ); // Create the block signing context @@ -208,6 +222,7 @@ export class BlockProposal extends Gossipable implements Signable { sig, signatureContext, signedTxs, + bucketRef, ); } @@ -261,6 +276,12 @@ export class BlockProposal extends Gossipable implements Signable { } else { buffer.push(0); // hasSignedTxs = false } + // Optional bucket-reference tail (AZIP-22 Fast Inbox). Appended only when set, so pre-flip proposals serialize + // byte-identically to the legacy format and mixed-version peers keep decoding them. + if (this.bucketRef) { + buffer.push(1); // hasBucketRef = true + buffer.push(this.bucketRef.toBuffer()); + } return serializeToBuffer(buffer); } @@ -279,20 +300,21 @@ export class BlockProposal extends Gossipable implements Signable { } const txHashes = reader.readArray(txHashCount, TxHash); + let signedTxs: SignedTxs | undefined; if (!reader.isEmpty()) { const hasSignedTxs = reader.readNumber(); if (hasSignedTxs) { - const signedTxs = SignedTxs.fromBuffer(reader); - return new BlockProposal( - blockHeader, - indexWithinCheckpoint, - inHash, - archiveRoot, - txHashes, - signature, - signatureContext, - signedTxs, - ); + signedTxs = SignedTxs.fromBuffer(reader); + } + } + + // Optional bucket-reference tail (AZIP-22 Fast Inbox). Legacy buffers end after the signedTxs flag, so EOF here + // decodes as "no reference" — this is the cross-version tolerance that keeps mixed-version gossip working. + let bucketRef: InboxBucketRef | undefined; + if (!reader.isEmpty()) { + const hasBucketRef = reader.readNumber(); + if (hasBucketRef) { + bucketRef = InboxBucketRef.fromBuffer(reader); } } @@ -304,6 +326,8 @@ export class BlockProposal extends Gossipable implements Signable { txHashes, signature, signatureContext, + signedTxs, + bucketRef, ); } @@ -319,7 +343,8 @@ export class BlockProposal extends Gossipable implements Signable { 4 /* txHashes.length */ + this.txHashes.length * TxHash.SIZE + 4 /* hasSignedTxs flag */ + - (this.signedTxs ? this.signedTxs.getSize() : 0) + (this.signedTxs ? this.signedTxs.getSize() : 0) + + (this.bucketRef ? 4 /* hasBucketRef flag */ + this.bucketRef.getSize() : 0) ); } @@ -357,6 +382,7 @@ export class BlockProposal extends Gossipable implements Signable { txHashes: this.txHashes.map(h => h.toString()), chainId: this.signatureContext.chainId, rollupAddress: this.signatureContext.rollupAddress.toString(), + bucketRef: this.bucketRef?.toInspect(), }; } @@ -383,6 +409,8 @@ export class BlockProposal extends Gossipable implements Signable { this.txHashes, this.signature, this.signatureContext, + undefined, + this.bucketRef, ); } } diff --git a/yarn-project/stdlib/src/p2p/checkpoint_proposal.test.ts b/yarn-project/stdlib/src/p2p/checkpoint_proposal.test.ts new file mode 100644 index 000000000000..9f4698ec58a4 --- /dev/null +++ b/yarn-project/stdlib/src/p2p/checkpoint_proposal.test.ts @@ -0,0 +1,126 @@ +// Serde and consistency tests for the checkpoint proposal type +import { IndexWithinCheckpoint } from '@aztec/foundation/branded-types'; +import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { Signature } from '@aztec/foundation/eth-signature'; +import { bufferToHex, hexToBuffer } from '@aztec/foundation/string'; + +import { InboxBucketRef } from '../messaging/inbox_bucket.js'; +import { CheckpointHeader } from '../rollup/checkpoint_header.js'; +import { makeCheckpointProposal } from '../tests/mocks.js'; +import { BlockHeader } from '../tx/block_header.js'; +import { TxHash } from '../tx/tx_hash.js'; +import { CheckpointProposal } from './checkpoint_proposal.js'; +import { EMPTY_COORDINATION_SIGNATURE_CONTEXT } from './signature_utils.js'; +import { LEGACY_CHECKPOINT_PROPOSAL_HEX } from './wire_compat_fixtures.js'; + +/** + * Deterministic legacy-shaped checkpoint proposal (lastBlock without signedTxs or bucketRef) matching the golden + * fixture in wire_compat_fixtures.ts. Constructed identically to how those bytes were captured on the pre-change code. + */ +const makeLegacyFixtureCheckpointProposal = () => + new CheckpointProposal( + CheckpointHeader.empty(), + new Fr(123n), + 0n, + Signature.empty(), + EMPTY_COORDINATION_SIGNATURE_CONTEXT, + { + blockHeader: BlockHeader.empty(), + indexWithinCheckpoint: IndexWithinCheckpoint(4), + txHashes: [TxHash.fromField(new Fr(7n))], + signature: Signature.empty(), + }, + ); + +describe('CheckpointProposal serialization / deserialization', () => { + it('round-trips with a lastBlock', async () => { + const proposal = await makeCheckpointProposal({ lastBlock: {} }); + const deserialized = CheckpointProposal.fromBuffer(proposal.toBuffer()); + // The mock supplies a BlockProposal as lastBlock while decoding rebuilds a plain CheckpointLastBlock, so compare + // the re-serialized bytes rather than deep-equal. + expect(deserialized.getSize()).toEqual(proposal.getSize()); + expect(deserialized.toBuffer()).toEqual(proposal.toBuffer()); + }); + + describe('bucket reference (AZIP-22 Fast Inbox)', () => { + it('round-trips with a bucket reference on the last block', async () => { + const checkpointHeader = CheckpointHeader.random(); + const bucketRef = new InboxBucketRef(17n, 1_700_000_000n, checkpointHeader.inboxRollingHash); + const proposal = await makeCheckpointProposal({ checkpointHeader, lastBlock: { bucketRef } }); + + const deserialized = CheckpointProposal.fromBuffer(proposal.toBuffer()); + + expect(deserialized.lastBlock?.bucketRef?.equals(bucketRef)).toBe(true); + expect(deserialized.getSize()).toEqual(proposal.getSize()); + expect(deserialized.toBuffer()).toEqual(proposal.toBuffer()); + }); + + it('serializes byte-identically to the legacy format when unset', () => { + const proposal = makeLegacyFixtureCheckpointProposal(); + expect(proposal.lastBlock?.bucketRef).toBeUndefined(); + expect(bufferToHex(proposal.toBuffer())).toEqual(LEGACY_CHECKPOINT_PROPOSAL_HEX); + }); + + it('decodes a legacy buffer (no tail) as having no bucket reference', () => { + const deserialized = CheckpointProposal.fromBuffer(hexToBuffer(LEGACY_CHECKPOINT_PROPOSAL_HEX)); + expect(deserialized.lastBlock).toBeDefined(); + expect(deserialized.lastBlock?.bucketRef).toBeUndefined(); + expect(bufferToHex(deserialized.toBuffer())).toEqual(LEGACY_CHECKPOINT_PROPOSAL_HEX); + }); + + it('carries the bucket reference through getBlockProposal, covered by the block signature', async () => { + const signer = Secp256k1Signer.random(); + const checkpointHeader = CheckpointHeader.random(); + const bucketRef = new InboxBucketRef(3n, 42n, checkpointHeader.inboxRollingHash); + const proposal = await makeCheckpointProposal({ signer, checkpointHeader, lastBlock: { bucketRef } }); + + const blockProposal = proposal.getBlockProposal(); + expect(blockProposal?.bucketRef?.equals(bucketRef)).toBe(true); + expect(blockProposal?.getSender()).toEqual(signer.address); + expect(proposal.getSender()).toEqual(signer.address); + }); + + it('accepts a last-block reference whose rolling hash matches the checkpoint header', () => { + const checkpointHeader = CheckpointHeader.random({ inboxRollingHash: new Fr(0x1234n) }); + expect( + () => + new CheckpointProposal( + checkpointHeader, + Fr.random(), + 0n, + Signature.empty(), + EMPTY_COORDINATION_SIGNATURE_CONTEXT, + { + blockHeader: BlockHeader.empty(), + indexWithinCheckpoint: IndexWithinCheckpoint(4), + txHashes: [], + signature: Signature.empty(), + bucketRef: new InboxBucketRef(1n, 2n, new Fr(0x1234n)), + }, + ), + ).not.toThrow(); + }); + + it('throws when the last-block reference rolling hash does not match the checkpoint header', () => { + const checkpointHeader = CheckpointHeader.random({ inboxRollingHash: new Fr(0x1234n) }); + expect( + () => + new CheckpointProposal( + checkpointHeader, + Fr.random(), + 0n, + Signature.empty(), + EMPTY_COORDINATION_SIGNATURE_CONTEXT, + { + blockHeader: BlockHeader.empty(), + indexWithinCheckpoint: IndexWithinCheckpoint(4), + txHashes: [], + signature: Signature.empty(), + bucketRef: new InboxBucketRef(1n, 2n, new Fr(0x5678n)), + }, + ), + ).toThrow(/bucketRef rolling hash/); + }); + }); +}); diff --git a/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts b/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts index 286be1c2b64f..6700563587f3 100644 --- a/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts +++ b/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts @@ -15,6 +15,7 @@ import type { TypedDataDefinition } from 'viem'; import type { L2BlockInfo } from '../block/l2_block_info.js'; import { MAX_TXS_PER_BLOCK } from '../deserialization/index.js'; import { DutyType, type SigningContext } from '../ha-signing/index.js'; +import { InboxBucketRef } from '../messaging/inbox_bucket.js'; import { CheckpointHeader } from '../rollup/checkpoint_header.js'; import { BlockHeader } from '../tx/block_header.js'; import { TxHash } from '../tx/index.js'; @@ -69,6 +70,11 @@ export type CheckpointLastBlock = Omit & { signature: Signature; /** The signed transactions in the last block (optional, for DA guarantees) */ signedTxs?: SignedTxs; + /** + * Reference to the Inbox bucket the last block proposes to consume (AZIP-22 Fast Inbox). Optional pre-flip; when set, + * its rolling hash must equal the checkpoint header's `inboxRollingHash` (enforced at construction). + */ + bucketRef?: InboxBucketRef; }; /** @@ -110,6 +116,13 @@ export class CheckpointProposal extends Gossipable implements Signable { `CheckpointProposal lastBlock inHash ${lastBlock.inHash} does not match checkpoint inHash ${checkpointHeader.inHash}`, ); } + // The last block's bucket reference (AZIP-22 Fast Inbox) commits to the same rolling hash as the checkpoint header, + // mirroring the inHash cross-check above. Optional pre-flip: only enforced when the reference is set. + if (lastBlock?.bucketRef && !lastBlock.bucketRef.inboxRollingHash.equals(checkpointHeader.inboxRollingHash)) { + throw new Error( + `CheckpointProposal lastBlock bucketRef rolling hash ${lastBlock.bucketRef.inboxRollingHash} does not match checkpoint inboxRollingHash ${checkpointHeader.inboxRollingHash}`, + ); + } if (lastBlock && 'archiveRoot' in lastBlock && !lastBlock.archiveRoot.equals(archive)) { throw new Error( `CheckpointProposal lastBlock archive ${lastBlock.archiveRoot} does not match checkpoint archive ${archive}`, @@ -150,6 +163,7 @@ export class CheckpointProposal extends Gossipable implements Signable { this.lastBlock.signature, this.signatureContext, this.lastBlock.signedTxs, + this.lastBlock.bucketRef, ); } @@ -288,6 +302,12 @@ export class CheckpointProposal extends Gossipable implements Signable { } else { buffer.push(0); // hasSignedTxs = false } + // Optional bucket-reference tail (AZIP-22 Fast Inbox). Appended only when set, so pre-flip proposals serialize + // byte-identically to the legacy format and mixed-version peers keep decoding them. + if (this.lastBlock.bucketRef) { + buffer.push(1); // hasBucketRef = true + buffer.push(this.lastBlock.bucketRef.toBuffer()); + } } else { buffer.push(0); // hasLastBlock = false } @@ -324,12 +344,23 @@ export class CheckpointProposal extends Gossipable implements Signable { } } + // Optional bucket-reference tail (AZIP-22 Fast Inbox). Legacy buffers end after the signedTxs flag, so EOF here + // decodes as "no reference" — the cross-version tolerance that keeps mixed-version gossip working. + let bucketRef: InboxBucketRef | undefined; + if (!reader.isEmpty()) { + const hasBucketRef = reader.readNumber(); + if (hasBucketRef) { + bucketRef = InboxBucketRef.fromBuffer(reader); + } + } + return new CheckpointProposal(checkpointHeader, archive, feeAssetPriceModifier, signature, signatureContext, { blockHeader, indexWithinCheckpoint, txHashes, signature: blockSignature, signedTxs, + bucketRef, }); } @@ -354,7 +385,8 @@ export class CheckpointProposal extends Gossipable implements Signable { 4 /* txHashes.length */ + this.lastBlock.txHashes.length * TxHash.SIZE + 4 /* hasSignedTxs flag */ + - (this.lastBlock.signedTxs ? this.lastBlock.signedTxs.getSize() : 0); + (this.lastBlock.signedTxs ? this.lastBlock.signedTxs.getSize() : 0) + + (this.lastBlock.bucketRef ? 4 /* hasBucketRef flag */ + this.lastBlock.bucketRef.getSize() : 0); } return size; @@ -400,6 +432,7 @@ export class CheckpointProposal extends Gossipable implements Signable { indexWithinCheckpoint: this.lastBlock.indexWithinCheckpoint, txHashes: this.lastBlock.txHashes.map(h => h.toString()), signature: this.lastBlock.signature.toString(), + bucketRef: this.lastBlock.bucketRef?.toInspect(), } : undefined, }; diff --git a/yarn-project/stdlib/src/p2p/wire_compat_fixtures.ts b/yarn-project/stdlib/src/p2p/wire_compat_fixtures.ts new file mode 100644 index 000000000000..61f5720243ce --- /dev/null +++ b/yarn-project/stdlib/src/p2p/wire_compat_fixtures.ts @@ -0,0 +1,22 @@ +// Golden wire fixtures captured from the pre-Fast-Inbox serialization format (AZIP-22, A-1381). They pin the exact +// bytes a legacy peer produces and consumes, so the optional bucket-reference tail added to proposals stays wire +// compatible: an unset proposal must serialize to these bytes, and decoding these bytes must yield no bucket reference. +// +// Both fixtures come from a deterministic proposal built with: +// BlockHeader.empty(), IndexWithinCheckpoint(3), inHash=Fr(42), archiveRoot=Fr(99), +// txHashes=[TxHash.fromField(Fr(7)), TxHash.fromField(Fr(8))], Signature.empty(), EMPTY_COORDINATION_SIGNATURE_CONTEXT +// (checkpoint: CheckpointHeader.empty(), archive=Fr(123), feeAssetPriceModifier=0, empty signature/context, and a +// lastBlock with BlockHeader.empty(), IndexWithinCheckpoint(4), txHashes=[TxHash.fromField(Fr(7))], empty signature). +// DO NOT regenerate from current code — the whole point is that these bytes predate the change. + +/** Legacy `BlockProposal.toBuffer()` bytes (no signedTxs, no bucket reference). */ +export const LEGACY_BLOCK_PROPOSAL_HEX = + '0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000002a00000000000000000000000000000000000000000000000000000000000000630000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000800000000'; + +/** Legacy `CheckpointProposal.toBuffer()` bytes (lastBlock without signedTxs or bucket reference). */ +export const LEGACY_CHECKPOINT_PROPOSAL_HEX = + '0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000700000000'; + +/** Legacy `BlockProposal.getPayloadToSign()` bytes (no bucket reference). */ +export const LEGACY_BLOCK_PROPOSAL_PAYLOAD_HEX = + '0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000002a00000000000000000000000000000000000000000000000000000000000000630000000200000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000008'; diff --git a/yarn-project/stdlib/src/tests/mocks.ts b/yarn-project/stdlib/src/tests/mocks.ts index fcd0fcb1af43..6a7df40b724c 100644 --- a/yarn-project/stdlib/src/tests/mocks.ts +++ b/yarn-project/stdlib/src/tests/mocks.ts @@ -51,6 +51,7 @@ import { PrivateToAvmAccumulatedData } from '../kernel/private_to_avm_accumulate import { PrivateToPublicAccumulatedDataBuilder } from '../kernel/private_to_public_accumulated_data_builder.js'; import { PublicCallRequestArrayLengths } from '../kernel/public_call_request.js'; import { computeInHashFromL1ToL2Messages } from '../messaging/in_hash.js'; +import { InboxBucketRef } from '../messaging/inbox_bucket.js'; import { BlockProposal } from '../p2p/block_proposal.js'; import { CheckpointAttestation } from '../p2p/checkpoint_attestation.js'; import { CheckpointProposal } from '../p2p/checkpoint_proposal.js'; @@ -549,6 +550,7 @@ export interface MakeBlockProposalOptions { txHashes?: TxHash[]; txs?: Tx[]; signatureContext?: CoordinationSignatureContext; + bucketRef?: InboxBucketRef; } export interface MakeCheckpointProposalOptions { @@ -563,6 +565,7 @@ export interface MakeCheckpointProposalOptions { indexWithinCheckpoint?: IndexWithinCheckpoint; txHashes?: TxHash[]; txs?: Tx[]; + bucketRef?: InboxBucketRef; }; } @@ -601,6 +604,7 @@ export const makeBlockProposal = (options?: MakeBlockProposalOptions): Promise Promise.resolve(signTypedData(signer, typedData)), (typedData, _context) => Promise.resolve(signTypedData(signer, typedData)), + bucketRef, ); }; @@ -634,6 +639,7 @@ export const makeCheckpointProposal = async (options?: MakeCheckpointProposalOpt txs: options.lastBlock.txs, signer, signatureContext, + bucketRef: options.lastBlock.bucketRef, }) : undefined;