Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions yarn-project/stdlib/src/messaging/inbox_bucket.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
90 changes: 89 additions & 1 deletion yarn-project/stdlib/src/messaging/inbox_bucket.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -41,3 +43,89 @@ export const InboxBucketSchema = z.object({
lastMessageIndex: schemas.BigInt,
isOpen: z.boolean(),
}) satisfies z.ZodType<InboxBucket>;

/**
* 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<InboxBucketRef> {
return z
.object({
bucketSeq: schemas.BigInt,
bucketTimestamp: schemas.BigInt,
inboxRollingHash: Fr.schema,
})
.transform(InboxBucketRef.from);
}

static from(fields: FieldsOf<InboxBucketRef>): 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(),
};
}
}
134 changes: 134 additions & 0 deletions yarn-project/stdlib/src/p2p/block_proposal.test.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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);
});
});
});
Loading
Loading