Skip to content
Draft
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
7 changes: 6 additions & 1 deletion yarn-project/aztec-node/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Blob, getKzg } from '@aztec/blob-lib';
import { EpochCache } from '@aztec/epoch-cache';
import { createEthereumChain } from '@aztec/ethereum/chain';
import { getPublicClient, makeL1HttpTransport } from '@aztec/ethereum/client';
import { RegistryContract, RollupContract } from '@aztec/ethereum/contracts';
import { InboxContract, RegistryContract, RollupContract } from '@aztec/ethereum/contracts';
import { pickL1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses';
import type { L1TxUtils } from '@aztec/ethereum/l1-tx-utils';
import { compactArray } from '@aztec/foundation/collection';
Expand Down Expand Up @@ -168,6 +168,9 @@ export async function createAztecNodeService(
Object.assign(config, l1ContractsAddresses);

const rollupContract = new RollupContract(publicClient, config.rollupAddress.toString());
// Read-only Inbox handle: proposal validation confirms a checkpoint's final message position against the live
// bucket ring before accepting it, so every node needs one, validator or not.
const inboxContract = new InboxContract(publicClient, config.inboxAddress);
const [l1GenesisTime, slotDuration, epochDuration, rollupVersionFromRollup, rollupManaLimit] = await Promise.all([
rollupContract.getL1GenesisTime(),
rollupContract.getSlotDuration(),
Expand Down Expand Up @@ -339,6 +342,7 @@ export async function createAztecNodeService(
epochCache,
blockSource: archiver,
l1ToL2MessageSource: archiver,
inbox: inboxContract,
keyStoreManager,
blobClient,
reexecutionTracker,
Expand Down Expand Up @@ -375,6 +379,7 @@ export async function createAztecNodeService(
epochCache,
blockSource: archiver,
l1ToL2MessageSource: archiver,
inbox: inboxContract,
p2pClient,
blobClient,
dateProvider,
Expand Down
2 changes: 1 addition & 1 deletion yarn-project/p2p/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ export const p2pConfigMappings: ConfigMappingsType<P2PConfig> = {
},
skipCheckpointProposalValidation: {
description:
'Skip checkpoint proposal validation and always attest, broadcasting the attestation before processing the embedded last block',
'Skip checkpoint proposal validation, including the live Inbox endpoint check, and always attest, broadcasting the attestation before processing the embedded last block. For testing only',
...booleanConfigHelper(false),
},
minTxPoolAgeMs: {
Expand Down
4 changes: 4 additions & 0 deletions yarn-project/p2p/src/services/libp2p/libp2p_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1660,6 +1660,10 @@ export class LibP2PService extends WithTracer implements P2PService {
source: sender.toString(),
});

// The all-nodes callback runs first, and to completion: it is where the proposal is validated — including
// the check that its final message position closes a live Inbox bucket — and where a valid one becomes this
// node's proposed checkpoint. The validator callback below reuses that verdict, so attesting can never
// outrun it.
await this.allNodesCheckpointReceivedCallback(checkpoint, sender);

// Call the checkpoint received callback with the core version (without lastBlock)
Expand Down
47 changes: 47 additions & 0 deletions yarn-project/validator-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ These rules must always hold:
4. **Sequential indexWithinCheckpoint**: Block N must have `indexWithinCheckpoint = parent.indexWithinCheckpoint + 1`
5. **One proposer per slot**: Each slot has exactly one designated proposer. Sending multiple proposals for the same position (slot, indexWithinCheckpoint) with different content is equivocation and slashable
6. **One attestation per slot**: Validators should only attest to one checkpoint per slot. Attesting to different proposals (different archives) for the same slot is equivocation and slashable
7. **A checkpoint ends on a live Inbox bucket boundary**: its blocks may consume any prefix of the message log, but the position the checkpoint finishes at must close a bucket that is still live on L1 and commits to the rolling hash the checkpoint signed. Nodes confirm this against L1 before accepting a proposal (see below); L1 enforces it at publication

## Validation Flow

Expand Down Expand Up @@ -112,8 +113,54 @@ When a `CheckpointProposal` is received, before creating attestations:
6. Verify checkpoint header fields match last block's global variables:
- slotNumber, coinbase, feeRecipient, gasFees
7. Verify lastArchiveRoot matches first block's lastArchive
8. Confirm against L1 that the last block's consumed message total ends a live Inbox bucket committing to the
checkpoint's signed `inboxRollingHash`
```

#### Live Inbox endpoint check

Steps 1-7 are deterministic and local: they say the checkpoint is the one its signed payload describes and that this
node holds the messages it consumed. They cannot say whether the position it finishes at is one L1 will accept, so
the last step reads the Inbox contract before the proposal may be recorded as valid, exposed as this node's
optimistic checkpoint parent, or attested to. It runs on every node, validator or not, because the all-nodes
validation callback is what makes a proposal the accepted parent for the next slot.

The gate is narrower than what L1 enforces. It confirms only that a live bucket *ends* at that total committing to
the signed rolling hash; it says nothing about whether that bucket has settled. A checkpoint ending at the total of
the still-open current bucket therefore passes here and is still rejected by `propose` with
`Rollup__InboxBucketStillMutable`. Settlement remains an L1-only check that this one does not replace.

The resolution is bound to the identity of the block it was read at, not to a height: the head is read for its
number and hash, the `eth_call` is pinned to that number, and the block is read again afterwards. A provider serving
a stale fork, or one the chain reorged under, answers a call at a height as readily as the canonical chain does, so
an answer whose block is no longer the one at that height names no view and cannot verify anything. What this does
not detect is a provider that lags uniformly: its own view is self-consistent, and only the endpoint the node's
provider can see is ever checked. Load is therefore two block reads plus one `eth_call` per checkpoint proposal
validated, that is per slot, and a second set on validators when the attestation path reuses a cached valid verdict.

A failure is re-read for up to two seconds. That window is a ceiling on the whole step, enforced as a race and
capped by whatever is left of the slot's duty budget: a provider that accepts the call and never answers is
abandoned at it, cannot start another read afterwards, and leaves the following stages their remaining budget.

The check never fails open. An unreadable or unidentifiable L1 view (RPC outage, timeout, a block the provider will
not serve, a block replaced under the call) is reported as `inbox_endpoint_unverifiable`, and a view that answers
without showing the signed position ending a live bucket (interior position, evicted endpoint, different rolling
hash) as `inbox_endpoint_not_live`. Both are refusals to validate now, not accusations: the bucket ring, the local
provider and L1 itself all move independently of the moment the proposal was signed. Neither reaches slashing, the
invalid-proposal slot marker or a peer penalty, and neither is remembered as the proposal's verdict, so a view that
recovers within the slot still permits a valid verdict.

A refusal records `unverifiable` for the slot, which the sentinel reports as `checkpoint-unverifiable`. That status
exists so a refusal is not read as an absent proposal: without a record the sentinel would fall back to
`checkpoint-missed`, which is counted against the proposer. `checkpoint-unverifiable` is counted against nobody —
it says this observer could not check, not that the proposer failed. It also never overwrites a `valid` this node
already recorded for the same checkpoint, so a later RPC failure here cannot retract a validation that succeeded.

The proposer's own checkpoints are covered by the endpoint its sequencer resolved against the same live ring when
it built the checkpoint's final block, plus the publication preflight it runs before submitting. Historical
checkpoints ingested by the archiver and checkpoints replayed for proving are outside this gate: they may reference
endpoints the ring has long evicted.

### Attestation Creation

After successful checkpoint validation:
Expand Down
137 changes: 137 additions & 0 deletions yarn-project/validator-client/src/checkpoint_endpoint_check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Fr } from '@aztec/foundation/curves/bn254';

import { describe, expect, it } from '@jest/globals';

import { checkInboxEndpoint } from './checkpoint_endpoint_check.js';
import { type LiveBucket, makeFakeInbox } from './fake_inbox_test_helper.js';

describe('checkInboxEndpoint', () => {
const hashAt200 = Fr.random();
const hashAt400 = Fr.random();
const ring: LiveBucket[] = [
{ seq: 7n, total: 200n, rollingHash: hashAt200 },
{ seq: 8n, total: 400n, rollingHash: hashAt400 },
];

it('verifies a position where a live bucket ends with the signed rolling hash', async () => {
const inbox = makeFakeInbox(ring);

await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toEqual({
verified: true,
l1BlockNumber: 900n,
bucketSeq: 7n,
});
});

it('resolves the bucket at the captured head rather than at a moving latest view', async () => {
const inbox = makeFakeInbox(ring, { head: 1234n });

const result = await checkInboxEndpoint(inbox, 400n, hashAt400);

expect(inbox.reads).toEqual([{ upperBound: 400n, blockNumber: 1234n }]);
expect(result).toEqual({ verified: true, l1BlockNumber: 1234n, bucketSeq: 8n });
});

// The resolver answers with the closest boundary below the bound, so a lower result is a miss, not a match.
it('rejects a position inside a bucket, even though a lower boundary resolves', async () => {
const inbox = makeFakeInbox(ring);

await expect(checkInboxEndpoint(inbox, 256n, hashAt200)).resolves.toEqual({
verified: false,
reason: 'interior_position',
l1BlockNumber: 900n,
endpointTotal: 200n,
});
});

it('rejects a boundary that commits to a different message prefix than the one signed', async () => {
const inbox = makeFakeInbox(ring);

await expect(checkInboxEndpoint(inbox, 200n, Fr.random())).resolves.toEqual({
verified: false,
reason: 'rolling_hash_mismatch',
l1BlockNumber: 900n,
endpointTotal: 200n,
});
});

it('rejects a position no live bucket reaches any more', async () => {
const inbox = makeFakeInbox([{ seq: 20n, total: 5000n, rollingHash: Fr.random() }]);

await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toEqual({
verified: false,
reason: 'no_live_endpoint',
l1BlockNumber: 900n,
});
});

// An empty Inbox still has a genesis bucket ending at zero, so a checkpoint consuming nothing at the start of
// the chain is verified by the same rule as any other, without special-casing a missing endpoint into success.
it('verifies the genesis position of an Inbox that never received a message', async () => {
const inbox = makeFakeInbox([{ seq: 0n, total: 0n, rollingHash: Fr.ZERO }]);

await expect(checkInboxEndpoint(inbox, 0n, Fr.ZERO)).resolves.toEqual({
verified: true,
l1BlockNumber: 900n,
bucketSeq: 0n,
});
});

it('reports an unreadable view when the head cannot be read', async () => {
const err = new Error('l1 rpc request failed');
const inbox = makeFakeInbox(ring, { failHead: err });

await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toEqual({
verified: false,
reason: 'unreadable',
err,
});
});

it('reports an unreadable view when the bucket read fails at the captured head', async () => {
const err = new Error('header not found');
const inbox = makeFakeInbox(ring, { failBucket: err });

await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toEqual({
verified: false,
reason: 'unreadable',
l1BlockNumber: 900n,
err,
});
});

// A height is not an identity: a provider serving a stale fork answers a call at it as readily as the canonical
// chain does. The block is re-read afterwards, and an answer that belongs to a block that is no longer there
// names no view, so it cannot verify anything — including a bucket that matches exactly.
it('refuses a matching answer read at a block that is no longer the one at that height', async () => {
const inbox = makeFakeInbox(ring);
inbox.onRead(() => inbox.setViewHash('0xreplaced'));

await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toEqual({
verified: false,
reason: 'view_replaced',
l1BlockNumber: 900n,
});
});

it('refuses when the block the answer was read at cannot be identified afterwards', async () => {
const inbox = makeFakeInbox(ring);
inbox.onRead(() => inbox.setViewHash(null));

await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toMatchObject({
verified: false,
reason: 'unreadable',
l1BlockNumber: 900n,
});
});

it('refuses when the head answers without a block identity', async () => {
const inbox = makeFakeInbox(ring);
inbox.setViewHash(null);

await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toMatchObject({
verified: false,
reason: 'unreadable',
});
});
});
103 changes: 103 additions & 0 deletions yarn-project/validator-client/src/checkpoint_endpoint_check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { InboxContract } from '@aztec/ethereum/contracts';
import type { Fr } from '@aztec/foundation/curves/bn254';

/** The identity of the L1 block a read was made at: a height alone is not one, since a fork answers at it too. */
type L1View = { number: bigint; hash: string };

/**
* The L1 reads the checkpoint endpoint check makes: the bucket resolution itself, and the block it is made at,
* read before and after so a verdict names the L1 view that produced it instead of mixing results from a `latest`
* that moves between reads. {@link InboxContract} and a viem client satisfy it directly; nothing else needs to be
* fetched to answer the question.
*/
export type InboxEndpointReader = Pick<InboxContract, 'getBucketAtOrBeforeTotal'> & {
client: {
getBlock(args: {
blockNumber?: bigint;
includeTransactions: false;
}): Promise<{ number: bigint | null; hash: string | null } | undefined>;
};
};

/** Why a checkpoint's final message position is not a live Inbox bucket endpoint in the L1 view that was read. */
export type InboxEndpointRejection =
/** No live bucket ends at or below the position: the ring has evicted every bucket that could have matched. */
| 'no_live_endpoint'
/** The closest live boundary ends earlier, so the position falls inside a bucket rather than closing one. */
| 'interior_position'
/** A live bucket ends exactly there, but commits to a different message prefix than the checkpoint signed. */
| 'rolling_hash_mismatch';

/**
* The outcome of one endpoint check. A rejection describes the L1 view that was read at `l1BlockNumber`, not the
* proposer: the bucket ring, this node's provider and the chain itself all move independently of the moment the
* checkpoint was signed. The last two are views this node could not obtain, or could not identify, at all.
*/
export type InboxEndpointCheckResult =
| { verified: true; l1BlockNumber: bigint; bucketSeq: bigint }
| { verified: false; reason: InboxEndpointRejection; l1BlockNumber: bigint; endpointTotal?: bigint }
/** A read threw, or answered without a block identity: the provider is unreachable, erroring or unsynced. */
| { verified: false; reason: 'unreadable'; l1BlockNumber?: bigint; err: unknown }
/** The block the resolution was read at is no longer the one at that height, so the answer names no view. */
| { verified: false; reason: 'view_replaced'; l1BlockNumber: bigint };

/** What an L1 block read answers with when it names no block: no verdict can be bound to a view like that. */
const UNIDENTIFIED_BLOCK = 'the L1 block was returned without a number or a hash';

/**
* Confirms through L1 that `totalMsgCount` is the end of a live Inbox bucket committing to `inboxRollingHash`.
*
* A checkpoint may consume an arbitrary prefix of the message log across its blocks, but the position it finishes
* at has to be a live bucket boundary for L1 to accept it. The resolver answers with the newest boundary at or
* below the bound, so only an exact total is a match: a lower one means the position sits inside a bucket. The
* bucket's own rolling hash then has to be the one the checkpoint signed, or the boundary commits to different
* message content than the checkpoint was built on.
*
* The resolution is read at one captured block and bound to that block's identity: a provider serving a stale
* fork, or one the chain reorged under, answers a call by height as readily as the canonical chain does. Re-reading
* the block at that height afterwards is what names the view the answer came from, and a view that cannot be shown
* to be the one queried yields a refusal rather than a pass.
*/
export async function checkInboxEndpoint(
inbox: InboxEndpointReader,
totalMsgCount: bigint,
inboxRollingHash: Fr,
): Promise<InboxEndpointCheckResult> {
let queried: L1View | undefined;
try {
queried = await readL1View(inbox.client);
if (queried === undefined) {
return { verified: false, reason: 'unreadable', err: UNIDENTIFIED_BLOCK };
}
const l1BlockNumber = queried.number;
const found = await inbox.getBucketAtOrBeforeTotal(totalMsgCount, { blockNumber: l1BlockNumber });

const confirmed = await readL1View(inbox.client, l1BlockNumber);
if (confirmed === undefined) {
return { verified: false, reason: 'unreadable', l1BlockNumber, err: UNIDENTIFIED_BLOCK };
}
if (confirmed.hash !== queried.hash) {
return { verified: false, reason: 'view_replaced', l1BlockNumber };
}

if (found === undefined) {
return { verified: false, reason: 'no_live_endpoint', l1BlockNumber };
}
const endpointTotal = found.bucket.totalMsgCount;
if (endpointTotal !== totalMsgCount) {
return { verified: false, reason: 'interior_position', l1BlockNumber, endpointTotal };
}
if (!found.bucket.rollingHash.equals(inboxRollingHash)) {
return { verified: false, reason: 'rolling_hash_mismatch', l1BlockNumber, endpointTotal };
}
return { verified: true, l1BlockNumber, bucketSeq: found.seq };
} catch (err) {
return { verified: false, reason: 'unreadable', l1BlockNumber: queried?.number, err };
}
}

/** Reads the block at `blockNumber`, or the head when it is omitted, as a number and hash that identify it. */
async function readL1View(client: InboxEndpointReader['client'], blockNumber?: bigint): Promise<L1View | undefined> {
const block = await client.getBlock({ blockNumber, includeTransactions: false });
return block?.number == null || block.hash == null ? undefined : { number: block.number, hash: block.hash };
}
3 changes: 2 additions & 1 deletion yarn-project/validator-client/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export const validatorClientConfigMappings: ConfigMappingsType<
...booleanConfigHelper(false),
},
skipCheckpointProposalValidation: {
description: 'Skip checkpoint proposal validation and always attest (default: false)',
description:
'Skip checkpoint proposal validation, including the live Inbox endpoint check, and always attest. For testing only (default: false)',
defaultValue: false,
},
skipPushProposedBlocksToArchiver: {
Expand Down
Loading
Loading