Skip to content

Commit 7f36f7b

Browse files
committed
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.
1 parent ff85153 commit 7f36f7b

8 files changed

Lines changed: 502 additions & 15 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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+
isOpen: true,
38+
};
39+
const ref = InboxBucketRef.fromBucket(bucket);
40+
expect(ref.bucketSeq).toBe(bucket.seq);
41+
expect(ref.bucketTimestamp).toBe(bucket.timestamp);
42+
expect(ref.inboxRollingHash).toEqual(bucket.inboxRollingHash);
43+
});
44+
45+
it('round-trips through its zod schema', () => {
46+
const ref = InboxBucketRef.random();
47+
const parsed = jsonParseWithSchema(jsonStringify(ref), InboxBucketRef.schema);
48+
expect(parsed).toEqual(ref);
49+
});
50+
});

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

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

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)