Skip to content

Commit 1e1b76a

Browse files
spalladinoclaude
andcommitted
fix: run the Inbox endpoint gate once, after a cached content verdict
An endpoint refusal used to be withheld from the validation cache, so an RPC hiccup during the all-nodes callback threw away a content verdict that cost a full checkpoint rebuild and block re-execution, and the attestation callback moments later rebuilt the whole checkpoint again inside the same duty budget. The cached-valid branch also made a second, separate endpoint call. The gate is now one step in handleCheckpointProposal: the content verdict is computed once (cached or fresh) and cached unconditionally, and only then is the endpoint confirmed, against the last block read for the same proposal. Refusals stay non-slashable, set no invalid-slot marker and no peer penalty, record an unvalidated outcome, and are never remembered as the proposal's verdict, so a recovered L1 view still yields a valid verdict on the next call — now without rebuilding. Blob upload moves next to the content verdict so it still fires once per proposal. Also: the README no longer claims the check "closes" a live bucket, and names settlement as the L1-only check it does not replace; the internal endpoint-check module is no longer re-exported; and the three fake Inboxes in the tests collapse into one helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7ecb080 commit 1e1b76a

7 files changed

Lines changed: 179 additions & 194 deletions

File tree

yarn-project/validator-client/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ When a `CheckpointProposal` is received, before creating attestations:
113113
6. Verify checkpoint header fields match last block's global variables:
114114
- slotNumber, coinbase, feeRecipient, gasFees
115115
7. Verify lastArchiveRoot matches first block's lastArchive
116-
8. Confirm against L1 that the last block's consumed message total closes a live Inbox bucket committing to the
116+
8. Confirm against L1 that the last block's consumed message total ends a live Inbox bucket committing to the
117117
checkpoint's signed `inboxRollingHash`
118118
```
119119

@@ -125,14 +125,19 @@ the last step reads the Inbox contract before the proposal may be recorded as va
125125
optimistic checkpoint parent, or attested to. It runs on every node, validator or not, because the all-nodes
126126
validation callback is what makes a proposal the accepted parent for the next slot.
127127

128+
The gate is narrower than what L1 enforces. It confirms only that a live bucket *ends* at that total committing to
129+
the signed rolling hash; it says nothing about whether that bucket has settled. A checkpoint ending at the total of
130+
the still-open current bucket therefore passes here and is still rejected by `propose` with
131+
`Rollup__InboxBucketStillMutable`. Settlement remains an L1-only check that this one does not replace.
132+
128133
Load: one head read plus one `eth_call` per checkpoint proposal validated, that is per slot, and a second pair on
129134
validators when the attestation path reuses a cached valid verdict. The head is read to pin the call to an explicit
130135
L1 block, so the verdict names the view it was made in; viem caches it briefly, and the contract wrapper's own
131136
block-tag guard reads it again. Failures are re-read for up to two seconds, bounded by the slot's duty budget.
132137

133138
The check never fails open. An unreadable L1 view (RPC outage, timeout, a provider trailing the head, a block the
134139
provider will not serve) is reported as `inbox_endpoint_unverifiable`, and a view that answers without showing the
135-
signed position closing a live bucket (interior position, evicted endpoint, different rolling hash) as
140+
signed position ending a live bucket (interior position, evicted endpoint, different rolling hash) as
136141
`inbox_endpoint_not_live`. Both are refusals to validate now, not accusations: the bucket ring, the local provider
137142
and L1 itself all move independently of the moment the proposal was signed. Neither reaches slashing, the
138143
invalid-proposal slot marker or a peer penalty, and neither is remembered as the proposal's verdict, so a view that

yarn-project/validator-client/src/checkpoint_endpoint_check.test.ts

Lines changed: 10 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,8 @@ import { Fr } from '@aztec/foundation/curves/bn254';
22

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

5-
import { type InboxEndpointReader, checkInboxEndpoint } from './checkpoint_endpoint_check.js';
6-
7-
/** A live bucket of the fake Inbox ring: the cumulative total it ends at and the prefix hash it commits to. */
8-
type LiveBucket = { seq: bigint; total: bigint; rollingHash: Fr };
9-
10-
/** Records what each endpoint read asked for, so a verdict can be checked against the L1 view it was made in. */
11-
type EndpointRead = { upperBound: bigint; blockNumber: bigint | undefined };
12-
13-
/**
14-
* An Inbox holding the given live buckets, resolving an upper bound the way the contract does: the newest live
15-
* bucket ending at or below it, or nothing at all once the ring has evicted every bucket that could have matched.
16-
*/
17-
function makeInbox(
18-
buckets: LiveBucket[],
19-
opts: { head?: bigint; failHead?: Error; failBucket?: Error } = {},
20-
): InboxEndpointReader & { reads: EndpointRead[] } {
21-
const head = opts.head ?? 900n;
22-
const ordered = [...buckets].sort((a, b) => Number(a.total - b.total));
23-
const reads: EndpointRead[] = [];
24-
return {
25-
reads,
26-
client: {
27-
getBlockNumber: () => (opts.failHead ? Promise.reject(opts.failHead) : Promise.resolve(head)),
28-
},
29-
getBucketAtOrBeforeTotal: (upperBound, readOpts) => {
30-
reads.push({ upperBound, blockNumber: readOpts?.blockNumber });
31-
if (opts.failBucket) {
32-
return Promise.reject(opts.failBucket);
33-
}
34-
const match = ordered.filter(bucket => bucket.total <= upperBound).pop();
35-
return Promise.resolve(
36-
match && {
37-
seq: match.seq,
38-
bucket: { rollingHash: match.rollingHash, totalMsgCount: match.total, timestamp: 1n, msgCount: 1 },
39-
},
40-
);
41-
},
42-
};
43-
}
5+
import { checkInboxEndpoint } from './checkpoint_endpoint_check.js';
6+
import { type LiveBucket, makeFakeInbox } from './fake_inbox_test_helper.js';
447

458
describe('checkInboxEndpoint', () => {
469
const hashAt200 = Fr.random();
@@ -51,7 +14,7 @@ describe('checkInboxEndpoint', () => {
5114
];
5215

5316
it('verifies a position where a live bucket ends with the signed rolling hash', async () => {
54-
const inbox = makeInbox(ring);
17+
const inbox = makeFakeInbox(ring);
5518

5619
await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toEqual({
5720
verified: true,
@@ -61,7 +24,7 @@ describe('checkInboxEndpoint', () => {
6124
});
6225

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

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

@@ -71,7 +34,7 @@ describe('checkInboxEndpoint', () => {
7134

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

7639
await expect(checkInboxEndpoint(inbox, 256n, hashAt200)).resolves.toEqual({
7740
verified: false,
@@ -82,7 +45,7 @@ describe('checkInboxEndpoint', () => {
8245
});
8346

8447
it('rejects a boundary that commits to a different message prefix than the one signed', async () => {
85-
const inbox = makeInbox(ring);
48+
const inbox = makeFakeInbox(ring);
8649

8750
await expect(checkInboxEndpoint(inbox, 200n, Fr.random())).resolves.toEqual({
8851
verified: false,
@@ -93,7 +56,7 @@ describe('checkInboxEndpoint', () => {
9356
});
9457

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

9861
await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toEqual({
9962
verified: false,
@@ -105,7 +68,7 @@ describe('checkInboxEndpoint', () => {
10568
// An empty Inbox still has a genesis bucket ending at zero, so a checkpoint consuming nothing at the start of
10669
// the chain is verified by the same rule as any other, without special-casing a missing endpoint into success.
10770
it('verifies the genesis position of an Inbox that never received a message', async () => {
108-
const inbox = makeInbox([{ seq: 0n, total: 0n, rollingHash: Fr.ZERO }]);
71+
const inbox = makeFakeInbox([{ seq: 0n, total: 0n, rollingHash: Fr.ZERO }]);
10972

11073
await expect(checkInboxEndpoint(inbox, 0n, Fr.ZERO)).resolves.toEqual({
11174
verified: true,
@@ -116,7 +79,7 @@ describe('checkInboxEndpoint', () => {
11679

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

12184
await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toEqual({
12285
verified: false,
@@ -127,7 +90,7 @@ describe('checkInboxEndpoint', () => {
12790

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

13295
await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toEqual({
13396
verified: false,
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Fr } from '@aztec/foundation/curves/bn254';
2+
3+
import type { InboxEndpointReader } from './checkpoint_endpoint_check.js';
4+
5+
/** A live bucket of the fake Inbox ring: the cumulative total it ends at and the prefix hash it commits to. */
6+
export type LiveBucket = { seq: bigint; total: bigint; rollingHash: Fr };
7+
8+
/** A fake live Inbox ring for the endpoint check, whose contents and readability tests can move between reads. */
9+
export type FakeInbox = InboxEndpointReader & {
10+
/** What each endpoint read asked for, and the L1 view it was made in. */
11+
reads: { upperBound: bigint; blockNumber: bigint | undefined }[];
12+
/** Replaces the live ring, the way an eviction or a reorg moves it between two reads. */
13+
setBuckets(buckets: LiveBucket[]): void;
14+
/** Makes every read fail, the way an unreachable provider does. */
15+
setUnreadable(err: Error | undefined): void;
16+
/** Runs before each bucket read with its index, so a test can move the L1 view between two attempts. */
17+
onRead(hook: (readIndex: number) => void): void;
18+
};
19+
20+
/** The L1 head fake reads are pinned to, unless a test asks for another one. */
21+
const DEFAULT_HEAD = 900n;
22+
23+
/**
24+
* An Inbox holding the given live buckets, resolving an upper bound the way the contract does: the newest live
25+
* bucket ending at or below it, or nothing at all once the ring has evicted every bucket that could have matched.
26+
* Defaults to the genesis bucket of an Inbox that never received a message.
27+
*/
28+
export function makeFakeInbox(
29+
buckets: LiveBucket[] = [{ seq: 0n, total: 0n, rollingHash: Fr.ZERO }],
30+
opts: { head?: bigint; failHead?: Error; failBucket?: Error } = {},
31+
): FakeInbox {
32+
let live = buckets;
33+
let failHead = opts.failHead;
34+
let failBucket = opts.failBucket;
35+
let beforeRead: (readIndex: number) => void = () => {};
36+
const reads: FakeInbox['reads'] = [];
37+
return {
38+
reads,
39+
setBuckets: next => {
40+
live = next;
41+
},
42+
setUnreadable: err => {
43+
failHead = err;
44+
failBucket = err;
45+
},
46+
onRead: hook => {
47+
beforeRead = hook;
48+
},
49+
client: {
50+
getBlockNumber: () => (failHead ? Promise.reject(failHead) : Promise.resolve(opts.head ?? DEFAULT_HEAD)),
51+
},
52+
getBucketAtOrBeforeTotal: (upperBound, readOpts) => {
53+
beforeRead(reads.length);
54+
reads.push({ upperBound, blockNumber: readOpts?.blockNumber });
55+
if (failBucket) {
56+
return Promise.reject(failBucket);
57+
}
58+
const match = [...live].sort((a, b) => Number(a.total - b.total)).findLast(bucket => bucket.total <= upperBound);
59+
return Promise.resolve(
60+
match && {
61+
seq: match.seq,
62+
bucket: { rollingHash: match.rollingHash, totalMsgCount: match.total, timestamp: 1n, msgCount: 1 },
63+
},
64+
);
65+
},
66+
};
67+
}

yarn-project/validator-client/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
export * from './proposal_handler.js';
22
export * from './checkpoint_builder.js';
3-
export * from './checkpoint_endpoint_check.js';
43
export * from './config.js';
54
export * from './factory.js';
65
export * from './validator.js';

yarn-project/validator-client/src/proposal_handler.test.ts

Lines changed: 7 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { describe, expect, it, jest } from '@jest/globals';
4040
import { type MockProxy, mock } from 'jest-mock-extended';
4141

4242
import type { CheckpointBuilder, FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
43-
import type { InboxEndpointReader } from './checkpoint_endpoint_check.js';
43+
import { type FakeInbox, makeFakeInbox } from './fake_inbox_test_helper.js';
4444
import type { ValidatorMetrics } from './metrics.js';
4545
import {
4646
type CheckpointProposalValidationResult,
@@ -73,56 +73,6 @@ function mockEmptyInboxView(source: MockProxy<L1ToL2MessageSource>) {
7373
);
7474
}
7575

76-
/** A live Inbox bucket: the cumulative message total it ends at, and the prefix hash it commits to. */
77-
type LiveBucket = { seq: bigint; total: bigint; rollingHash: Fr };
78-
79-
/** A fake live Inbox ring for the checkpoint endpoint gate, whose contents and readability tests can move. */
80-
type FakeInbox = InboxEndpointReader & {
81-
/** What each endpoint read asked for, and the L1 view it was made in. */
82-
reads: { upperBound: bigint; blockNumber: bigint | undefined }[];
83-
/** Replaces the live ring, the way an eviction or a reorg moves it between two reads. */
84-
setBuckets(buckets: LiveBucket[]): void;
85-
/** Makes every read fail, the way an unreachable provider does. */
86-
setUnreadable(err: Error | undefined): void;
87-
/** Runs before each read with its index, so a test can move the L1 view between two attempts. */
88-
onRead(hook: (readIndex: number) => void): void;
89-
};
90-
91-
/** An Inbox resolving an upper bound to the newest live bucket ending at or below it, as the contract does. */
92-
function makeFakeInbox(buckets: LiveBucket[] = [{ seq: 0n, total: 0n, rollingHash: Fr.ZERO }]): FakeInbox {
93-
let live = buckets;
94-
let unreadable: Error | undefined;
95-
let beforeRead: (readIndex: number) => void = () => {};
96-
const reads: FakeInbox['reads'] = [];
97-
return {
98-
reads,
99-
setBuckets: next => {
100-
live = next;
101-
},
102-
setUnreadable: err => {
103-
unreadable = err;
104-
},
105-
onRead: hook => {
106-
beforeRead = hook;
107-
},
108-
client: { getBlockNumber: () => (unreadable ? Promise.reject(unreadable) : Promise.resolve(777n)) },
109-
getBucketAtOrBeforeTotal: (upperBound, opts) => {
110-
beforeRead(reads.length);
111-
reads.push({ upperBound, blockNumber: opts?.blockNumber });
112-
if (unreadable) {
113-
return Promise.reject(unreadable);
114-
}
115-
const match = [...live].sort((a, b) => Number(a.total - b.total)).findLast(b => b.total <= upperBound);
116-
return Promise.resolve(
117-
match && {
118-
seq: match.seq,
119-
bucket: { rollingHash: match.rollingHash, totalMsgCount: match.total, timestamp: 1n, msgCount: 1 },
120-
},
121-
);
122-
},
123-
};
124-
}
125-
12676
/**
12777
* The blocks of slot 1 for checkpoint 1, one per archive root, numbered from 1 and each chaining onto the previous
12878
* one's archive, consuming no Inbox messages.
@@ -1233,7 +1183,7 @@ describe('ProposalHandler checkpoint validation', () => {
12331183
expect(result).toEqual({ isValid: true, checkpointNumber: CheckpointNumber(1) });
12341184
// The last block's own consumed total, resolved once against an explicitly captured L1 head. The
12351185
// intermediate block's position is never asked about.
1236-
expect(inbox.reads).toEqual([{ upperBound: 7n, blockNumber: 777n }]);
1186+
expect(inbox.reads).toEqual([{ upperBound: 7n, blockNumber: 900n }]);
12371187
});
12381188

12391189
// A checkpoint that consumed nothing still ends somewhere: the position it inherited, which has to be a live
@@ -1245,7 +1195,7 @@ describe('ProposalHandler checkpoint validation', () => {
12451195
const result = await validate(header);
12461196

12471197
expect(result).toEqual({ isValid: true, checkpointNumber: CheckpointNumber(1) });
1248-
expect(inbox.reads).toEqual([{ upperBound: 3n, blockNumber: 777n }]);
1198+
expect(inbox.reads).toEqual([{ upperBound: 3n, blockNumber: 900n }]);
12491199
});
12501200

12511201
// Blocks may consume an arbitrary prefix, so a checkpoint can be entirely content-valid and still finish
@@ -1373,7 +1323,8 @@ describe('ProposalHandler checkpoint validation', () => {
13731323
});
13741324

13751325
// A refusal describes the L1 view at that instant, so it is not remembered as this proposal's verdict: the
1376-
// next call re-reads and can still accept it.
1326+
// next call re-reads and can still accept it. The content verdict the refused call paid a full rebuild for
1327+
// is kept, so the attestation call moments later does not rebuild the checkpoint all over again.
13771328
it('accepts on a later call once the endpoint reappears in a recovered view', async () => {
13781329
const { header, inboxRollingHash } = setupContentValidCheckpoint({ midLeafCount: 5, lastLeafCount: 7 });
13791330
inbox.setUnreadable(new Error('l1 rpc request failed'));
@@ -1391,6 +1342,8 @@ describe('ProposalHandler checkpoint validation', () => {
13911342
isValid: true,
13921343
checkpointNumber: CheckpointNumber(1),
13931344
});
1345+
expect(checkpointsBuilder.openCheckpoint).toHaveBeenCalledTimes(1);
1346+
expect(reexecutionTracker.getOutcomeForSlot(SlotNumber(1))).toEqual('valid');
13941347
});
13951348
});
13961349
});

0 commit comments

Comments
 (0)