Skip to content

Commit 6857461

Browse files
committed
feat(fast-inbox): block proposals carry a 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 L1-to-L2 message bundle from their own Inbox view instead of trusting a proposer-supplied list. Plumbing only for now — the sequencer passes `undefined` pre-flip (populated in FI-12) and the legacy validation path ignores it (validated in FI-13). - Signed, not an unsigned lookup aid: the reference lives 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 Inbox lookup. This discharges the P2-2 residual from the A-1371 design (signedness + republish path). - Optional-tail wire encoding, no topic-version bump: an unset proposal serializes byte-identically to the legacy format, and decoders treat EOF after the existing fields as "no reference", so mixed-version gossip keeps working. Gossip topic versions derive from deployment identity, not node software version, so mixed decoders are a hard requirement. - Wire-compat evidence: golden fixtures captured from the pre-change bytes pin (a) unset serialization is byte-identical, (b) legacy buffers decode as no-reference, for both proposal types. - `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. - Audited every `BlockProposal.fromBuffer` / `CheckpointProposal.fromBuffer` decode site in `p2p/src` and `message_validator.ts`: none assume a strict buffer length, so the appended tail is tolerated. Tests: `InboxBucketRef` round-trip/equals/schema; block + checkpoint proposal round-trips with the reference set (with and without signed txs); byte-identity vs golden fixtures when unset; legacy-buffer tolerance; signature coverage (recovery over a set reference, and recovery breaks when the reference is tampered with or injected); payload hashes differ set-vs-unset; checkpoint header-consistency check throws on mismatch. Replaces #24786.
1 parent b92ad55 commit 6857461

8 files changed

Lines changed: 501 additions & 15 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Fr } from '@aztec/foundation/curves/bn254';
2+
import { jsonParseWithSchema, jsonStringify } from '@aztec/foundation/json-rpc';
3+
4+
import type { InboxBucket } from './inbox_bucket.js';
5+
import { InboxBucketRef } from './inbox_bucket.js';
6+
7+
describe('InboxBucketRef', () => {
8+
it('serializes and deserializes round-trip', () => {
9+
const ref = new InboxBucketRef(42n, 1_700_000_000n, Fr.random());
10+
const deserialized = InboxBucketRef.fromBuffer(ref.toBuffer());
11+
expect(deserialized).toEqual(ref);
12+
expect(deserialized.equals(ref)).toBe(true);
13+
});
14+
15+
it('serializes to the fixed advertised size', () => {
16+
const ref = InboxBucketRef.random();
17+
expect(ref.toBuffer().length).toBe(InboxBucketRef.SIZE);
18+
expect(ref.getSize()).toBe(InboxBucketRef.SIZE);
19+
});
20+
21+
it('equals distinguishes each component', () => {
22+
const ref = new InboxBucketRef(7n, 100n, new Fr(9n));
23+
expect(ref.equals(new InboxBucketRef(8n, 100n, new Fr(9n)))).toBe(false);
24+
expect(ref.equals(new InboxBucketRef(7n, 101n, new Fr(9n)))).toBe(false);
25+
expect(ref.equals(new InboxBucketRef(7n, 100n, new Fr(10n)))).toBe(false);
26+
expect(ref.equals(new InboxBucketRef(7n, 100n, new Fr(9n)))).toBe(true);
27+
});
28+
29+
it('derives from a bucket snapshot', () => {
30+
const bucket: InboxBucket = {
31+
seq: 12n,
32+
inboxRollingHash: new Fr(0xabcn),
33+
totalMsgCount: 30n,
34+
timestamp: 1_650_000_000n,
35+
msgCount: 3,
36+
lastMessageIndex: 29n,
37+
};
38+
const ref = InboxBucketRef.fromBucket(bucket);
39+
expect(ref.bucketSeq).toBe(bucket.seq);
40+
expect(ref.bucketTimestamp).toBe(bucket.timestamp);
41+
expect(ref.inboxRollingHash).toEqual(bucket.inboxRollingHash);
42+
});
43+
44+
it('round-trips through its zod schema', () => {
45+
const ref = InboxBucketRef.random();
46+
const parsed = jsonParseWithSchema(jsonStringify(ref), InboxBucketRef.schema);
47+
expect(parsed).toEqual(ref);
48+
});
49+
});

yarn-project/stdlib/src/messaging/inbox_bucket.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Fr } from '@aztec/foundation/curves/bn254';
2-
import { schemas } from '@aztec/foundation/schemas';
2+
import { type ZodFor, schemas } from '@aztec/foundation/schemas';
3+
import { BufferReader, bigintToUInt64BE, serializeToBuffer } from '@aztec/foundation/serialize';
4+
import type { FieldsOf } from '@aztec/foundation/types';
35

46
import { z } from 'zod';
57

@@ -35,3 +37,89 @@ export const InboxBucketSchema = z.object({
3537
msgCount: schemas.Integer,
3638
lastMessageIndex: schemas.BigInt,
3739
}) satisfies z.ZodType<InboxBucket>;
40+
41+
/**
42+
* Reference to a settled Inbox rolling-hash bucket, carried alongside a block proposal (AZIP-22 Fast Inbox) so a
43+
* validator can look the bucket up in its own Inbox view and derive the consumed-message bundle itself, rather than
44+
* trusting a proposer-supplied message list. Pins the bucket by its dense sequence number and recency-key timestamp
45+
* and asserts the expected consensus rolling hash. A wrong reference can only cause a lookup miss or hash mismatch; it
46+
* can never change what a validator accepts, because the checkpoint header's `inboxRollingHash` remains the signed
47+
* consensus commitment (mirrors the unsigned bucket hint on L1's `Rollup.propose`).
48+
*/
49+
export class InboxBucketRef {
50+
constructor(
51+
/** Dense, monotonically increasing sequence number of the referenced bucket in the Inbox ring. */
52+
public readonly bucketSeq: bigint,
53+
/** L1 block timestamp (in seconds) at which the referenced bucket was opened; its recency key. */
54+
public readonly bucketTimestamp: bigint,
55+
/** Consensus rolling hash (truncated sha256 chain) after the last message absorbed into the referenced bucket. */
56+
public readonly inboxRollingHash: Fr,
57+
) {}
58+
59+
/** Serialized size in bytes: two uint64 fields plus one field element. */
60+
static readonly SIZE = 8 + 8 + Fr.SIZE_IN_BYTES;
61+
62+
static get schema(): ZodFor<InboxBucketRef> {
63+
return z
64+
.object({
65+
bucketSeq: schemas.BigInt,
66+
bucketTimestamp: schemas.BigInt,
67+
inboxRollingHash: Fr.schema,
68+
})
69+
.transform(InboxBucketRef.from);
70+
}
71+
72+
static from(fields: FieldsOf<InboxBucketRef>): InboxBucketRef {
73+
return new InboxBucketRef(fields.bucketSeq, fields.bucketTimestamp, fields.inboxRollingHash);
74+
}
75+
76+
/** Derives a wire reference from a bucket snapshot as tracked by the archiver. */
77+
static fromBucket(bucket: InboxBucket): InboxBucketRef {
78+
return new InboxBucketRef(bucket.seq, bucket.timestamp, bucket.inboxRollingHash);
79+
}
80+
81+
toBuffer(): Buffer {
82+
return serializeToBuffer([
83+
bigintToUInt64BE(this.bucketSeq),
84+
bigintToUInt64BE(this.bucketTimestamp),
85+
this.inboxRollingHash,
86+
]);
87+
}
88+
89+
static fromBuffer(buffer: Buffer | BufferReader): InboxBucketRef {
90+
const reader = BufferReader.asReader(buffer);
91+
return new InboxBucketRef(reader.readUInt64(), reader.readUInt64(), reader.readObject(Fr));
92+
}
93+
94+
getSize(): number {
95+
return InboxBucketRef.SIZE;
96+
}
97+
98+
equals(other: InboxBucketRef): boolean {
99+
return (
100+
this.bucketSeq === other.bucketSeq &&
101+
this.bucketTimestamp === other.bucketTimestamp &&
102+
this.inboxRollingHash.equals(other.inboxRollingHash)
103+
);
104+
}
105+
106+
static empty(): InboxBucketRef {
107+
return new InboxBucketRef(0n, 0n, Fr.ZERO);
108+
}
109+
110+
static random(): InboxBucketRef {
111+
return new InboxBucketRef(
112+
BigInt(Math.floor(Math.random() * 1000)),
113+
BigInt(Math.floor(Math.random() * 1_000_000)),
114+
Fr.random(),
115+
);
116+
}
117+
118+
toInspect() {
119+
return {
120+
bucketSeq: this.bucketSeq.toString(),
121+
bucketTimestamp: this.bucketTimestamp.toString(),
122+
inboxRollingHash: this.inboxRollingHash.toString(),
123+
};
124+
}
125+
}

yarn-project/stdlib/src/p2p/block_proposal.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,34 @@
11
// Serde test for the block proposal type
2+
import { IndexWithinCheckpoint } from '@aztec/foundation/branded-types';
23
import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer';
4+
import { Fr } from '@aztec/foundation/curves/bn254';
35
import { Signature } from '@aztec/foundation/eth-signature';
6+
import { bufferToHex, hexToBuffer } from '@aztec/foundation/string';
47

8+
import { InboxBucketRef } from '../messaging/inbox_bucket.js';
59
import { TEST_COORDINATION_SIGNATURE_CONTEXT, makeBlockProposal } from '../tests/mocks.js';
10+
import { BlockHeader } from '../tx/block_header.js';
611
import { Tx } from '../tx/tx.js';
12+
import { TxHash } from '../tx/tx_hash.js';
713
import { BlockProposal } from './block_proposal.js';
14+
import { EMPTY_COORDINATION_SIGNATURE_CONTEXT } from './signature_utils.js';
815
import { SignedTxs } from './signed_txs.js';
16+
import { LEGACY_BLOCK_PROPOSAL_HEX, LEGACY_BLOCK_PROPOSAL_PAYLOAD_HEX } from './wire_compat_fixtures.js';
17+
18+
/**
19+
* Deterministic legacy-shaped proposal (no signedTxs, no bucketRef) matching the golden fixtures in
20+
* wire_compat_fixtures.ts. Constructed identically to how those bytes were captured on the pre-change code.
21+
*/
22+
const makeLegacyFixtureProposal = () =>
23+
new BlockProposal(
24+
BlockHeader.empty(),
25+
IndexWithinCheckpoint(3),
26+
new Fr(42n),
27+
new Fr(99n),
28+
[TxHash.fromField(new Fr(7n)), TxHash.fromField(new Fr(8n))],
29+
Signature.empty(),
30+
EMPTY_COORDINATION_SIGNATURE_CONTEXT,
31+
);
932

1033
describe('Block Proposal serialization / deserialization', () => {
1134
const checkEquivalence = (serialized: BlockProposal, deserialized: BlockProposal) => {
@@ -92,4 +115,115 @@ describe('Block Proposal serialization / deserialization', () => {
92115

93116
expect(tampered.getSender()).toBeUndefined();
94117
});
118+
119+
describe('bucket reference (AZIP-22 Fast Inbox)', () => {
120+
it('round-trips with a bucket reference set', async () => {
121+
const bucketRef = InboxBucketRef.random();
122+
const proposal = await makeBlockProposal({ bucketRef });
123+
124+
const deserialized = BlockProposal.fromBuffer(proposal.toBuffer());
125+
126+
expect(deserialized.bucketRef).toBeDefined();
127+
expect(deserialized.bucketRef!.equals(bucketRef)).toBe(true);
128+
expect(deserialized.getSize()).toEqual(proposal.getSize());
129+
expect(deserialized).toEqual(proposal);
130+
});
131+
132+
it('round-trips with a bucket reference set alongside signed txs', async () => {
133+
const bucketRef = InboxBucketRef.random();
134+
const txs = await Promise.all([Tx.random(), Tx.random()]);
135+
const proposal = await makeBlockProposal({ txs, bucketRef });
136+
137+
const deserialized = BlockProposal.fromBuffer(proposal.toBuffer());
138+
139+
expect(deserialized.bucketRef!.equals(bucketRef)).toBe(true);
140+
expect(deserialized.txs?.length).toEqual(txs.length);
141+
expect(deserialized).toEqual(proposal);
142+
});
143+
144+
it('serializes byte-identically to the legacy format when unset', () => {
145+
const proposal = makeLegacyFixtureProposal();
146+
expect(proposal.bucketRef).toBeUndefined();
147+
expect(bufferToHex(proposal.toBuffer())).toEqual(LEGACY_BLOCK_PROPOSAL_HEX);
148+
expect(bufferToHex(proposal.getPayloadToSign())).toEqual(LEGACY_BLOCK_PROPOSAL_PAYLOAD_HEX);
149+
});
150+
151+
it('decodes a legacy buffer (no tail) as having no bucket reference', () => {
152+
const deserialized = BlockProposal.fromBuffer(hexToBuffer(LEGACY_BLOCK_PROPOSAL_HEX));
153+
expect(deserialized.bucketRef).toBeUndefined();
154+
// Re-encoding a legacy buffer yields the same legacy bytes: no phantom tail is introduced.
155+
expect(bufferToHex(deserialized.toBuffer())).toEqual(LEGACY_BLOCK_PROPOSAL_HEX);
156+
});
157+
158+
it('appends the bucket reference only when set, changing the signed payload', async () => {
159+
const bucketRef = InboxBucketRef.random();
160+
const withRef = await makeBlockProposal({ bucketRef });
161+
const withoutRef = await makeBlockProposal({
162+
blockHeader: withRef.blockHeader,
163+
indexWithinCheckpoint: withRef.indexWithinCheckpoint,
164+
inHash: withRef.inHash,
165+
archiveRoot: withRef.archiveRoot,
166+
txHashes: withRef.txHashes,
167+
});
168+
169+
const withRefPayload = withRef.getPayloadToSign();
170+
const withoutRefPayload = withoutRef.getPayloadToSign();
171+
172+
// The set payload extends the unset payload by exactly the reference bytes (appended tail, no marker).
173+
expect(withRefPayload.length).toEqual(withoutRefPayload.length + InboxBucketRef.SIZE);
174+
expect(withRefPayload.subarray(0, withoutRefPayload.length)).toEqual(withoutRefPayload);
175+
// The payload hashes differ, so the attestation pool treats set-vs-unset as distinct payloads.
176+
expect(withRef.getPayloadHash().toString()).not.toEqual(withoutRef.getPayloadHash().toString());
177+
});
178+
179+
it('covers the bucket reference under the proposal signature', async () => {
180+
const signer = Secp256k1Signer.random();
181+
const bucketRef = InboxBucketRef.random();
182+
const proposal = await makeBlockProposal({ signer, bucketRef });
183+
184+
const deserialized = BlockProposal.fromBuffer(proposal.toBuffer());
185+
expect(deserialized.getSender()).toEqual(signer.address);
186+
});
187+
188+
it('breaks sender recovery when the bucket reference is tampered with', async () => {
189+
const signer = Secp256k1Signer.random();
190+
const bucketRef = new InboxBucketRef(5n, 100n, new Fr(7n));
191+
const proposal = await makeBlockProposal({ signer, bucketRef });
192+
expect(proposal.getSender()).toEqual(signer.address);
193+
194+
// A relay swapping the signed reference for a different one is not covered by the original signature.
195+
const tampered = new BlockProposal(
196+
proposal.blockHeader,
197+
proposal.indexWithinCheckpoint,
198+
proposal.inHash,
199+
proposal.archiveRoot,
200+
proposal.txHashes,
201+
proposal.signature,
202+
proposal.signatureContext,
203+
proposal.signedTxs,
204+
new InboxBucketRef(6n, 100n, new Fr(7n)),
205+
);
206+
expect(tampered.getSender()).not.toEqual(signer.address);
207+
});
208+
209+
it('breaks sender recovery when a bucket reference is injected into an unsigned proposal', async () => {
210+
const signer = Secp256k1Signer.random();
211+
const proposal = await makeBlockProposal({ signer });
212+
expect(proposal.getSender()).toEqual(signer.address);
213+
214+
// A relay injecting a reference the proposer never signed over is rejected by signature recovery.
215+
const injected = new BlockProposal(
216+
proposal.blockHeader,
217+
proposal.indexWithinCheckpoint,
218+
proposal.inHash,
219+
proposal.archiveRoot,
220+
proposal.txHashes,
221+
proposal.signature,
222+
proposal.signatureContext,
223+
proposal.signedTxs,
224+
InboxBucketRef.random(),
225+
);
226+
expect(injected.getSender()).not.toEqual(signer.address);
227+
});
228+
});
95229
});

0 commit comments

Comments
 (0)