Skip to content

Commit 991642b

Browse files
spalladinoclaude
andcommitted
fix: bound and bind the Inbox endpoint gate, and stop it retracting a valid outcome
Four fixes from a review of the endpoint gate. An endpoint refusal no longer overwrites a `valid` outcome this node already recorded for the same checkpoint. The gate runs on both calls p2p makes for one proposal, so a second look failing on a local RPC problem used to downgrade the slot to `unvalidated` — which the sentinel counts as a missed proposal for that slot's proposer, feeding epoch performance and the inactivity signal. Only that exact checkpoint is protected; another archive at the slot still records normally. The test helper asserting refusals is renamed to say what it checks: the refusal is not slashable and marks no invalid slot, but it does record `unvalidated`, which is not a neutral outcome. The verdict 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 resolution 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, so an answer whose block is no longer the one at that height is refused as unverifiable rather than passed. A provider that lags uniformly is still invisible from here, and the README says so. The advertised two-second ceiling is now a real bound. It was only handed to `retryUntil`, which checks its deadline after an attempt returns, so one stalled RPC consumed the whole remaining duty; it is now a race, via a new `DutyBudget.runWithin`, and the abandoned loop checks the signal instead of starting another read. Tracker pruning no longer runs in the acceptance path. It reads L1 tips, and the restructure had put it on the cached path too, where the validator calls this method directly without an outer timeout — a hanging tips read stalled the attestation. It is bookkeeping, so it runs detached, and the pipelining parent is not recorded at all once the duty has been stopped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1e1b76a commit 991642b

9 files changed

Lines changed: 297 additions & 68 deletions

yarn-project/validator-client/README.md

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -130,21 +130,35 @@ the signed rolling hash; it says nothing about whether that bucket has settled.
130130
the still-open current bucket therefore passes here and is still rejected by `propose` with
131131
`Rollup__InboxBucketStillMutable`. Settlement remains an L1-only check that this one does not replace.
132132

133-
Load: one head read plus one `eth_call` per checkpoint proposal validated, that is per slot, and a second pair on
134-
validators when the attestation path reuses a cached valid verdict. The head is read to pin the call to an explicit
135-
L1 block, so the verdict names the view it was made in; viem caches it briefly, and the contract wrapper's own
136-
block-tag guard reads it again. Failures are re-read for up to two seconds, bounded by the slot's duty budget.
137-
138-
The check never fails open. An unreadable L1 view (RPC outage, timeout, a provider trailing the head, a block the
139-
provider will not serve) is reported as `inbox_endpoint_unverifiable`, and a view that answers without showing the
140-
signed position ending a live bucket (interior position, evicted endpoint, different rolling hash) as
141-
`inbox_endpoint_not_live`. Both are refusals to validate now, not accusations: the bucket ring, the local provider
142-
and L1 itself all move independently of the moment the proposal was signed. Neither reaches slashing, the
133+
The resolution is bound to the identity of the block it was read at, not to a height: the head is read for its
134+
number and hash, the `eth_call` is pinned to that number, and the block is read again afterwards. A provider serving
135+
a stale fork, or one the chain reorged under, answers a call at a height as readily as the canonical chain does, so
136+
an answer whose block is no longer the one at that height names no view and cannot verify anything. What this does
137+
not detect is a provider that lags uniformly: its own view is self-consistent, and only the endpoint the node's
138+
provider can see is ever checked. Load is therefore two block reads plus one `eth_call` per checkpoint proposal
139+
validated, that is per slot, and a second set on validators when the attestation path reuses a cached valid verdict.
140+
141+
A failure is re-read for up to two seconds. That window is a ceiling on the whole step, enforced as a race and
142+
capped by whatever is left of the slot's duty budget: a provider that accepts the call and never answers is
143+
abandoned at it, cannot start another read afterwards, and leaves the following stages their remaining budget.
144+
145+
The check never fails open. An unreadable or unidentifiable L1 view (RPC outage, timeout, a block the provider will
146+
not serve, a block replaced under the call) is reported as `inbox_endpoint_unverifiable`, and a view that answers
147+
without showing the signed position ending a live bucket (interior position, evicted endpoint, different rolling
148+
hash) as `inbox_endpoint_not_live`. Both are refusals to validate now, not accusations: the bucket ring, the local
149+
provider and L1 itself all move independently of the moment the proposal was signed. Neither reaches slashing, the
143150
invalid-proposal slot marker or a peer penalty, and neither is remembered as the proposal's verdict, so a view that
144-
recovers within the slot still permits a valid verdict. The proposer's own checkpoints are covered by the endpoint
145-
its sequencer resolved against the same live ring when it built the checkpoint's final block, plus the publication
146-
preflight it runs before submitting. Historical checkpoints ingested by the archiver and checkpoints replayed for
147-
proving are outside this gate: they may reference endpoints the ring has long evicted.
151+
recovers within the slot still permits a valid verdict.
152+
153+
A refusal is not free, though: like every other outcome this node cannot complete, it records `unvalidated` for the
154+
slot, which the sentinel reports as a missed proposal for that slot's proposer when no checkpoint for it lands on
155+
L1. What it will not do is overwrite a `valid` this node already recorded for the same checkpoint, so a later RPC
156+
failure here cannot retract a validation that succeeded.
157+
158+
The proposer's own checkpoints are covered by the endpoint its sequencer resolved against the same live ring when
159+
it built the checkpoint's final block, plus the publication preflight it runs before submitting. Historical
160+
checkpoints ingested by the archiver and checkpoints replayed for proving are outside this gate: they may reference
161+
endpoints the ring has long evicted.
148162

149163
### Attestation Creation
150164

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,43 @@ describe('checkInboxEndpoint', () => {
9595
await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toEqual({
9696
verified: false,
9797
reason: 'unreadable',
98+
l1BlockNumber: 900n,
9899
err,
99100
});
100101
});
102+
103+
// A height is not an identity: a provider serving a stale fork answers a call at it as readily as the canonical
104+
// chain does. The block is re-read afterwards, and an answer that belongs to a block that is no longer there
105+
// names no view, so it cannot verify anything — including a bucket that matches exactly.
106+
it('refuses a matching answer read at a block that is no longer the one at that height', async () => {
107+
const inbox = makeFakeInbox(ring);
108+
inbox.onRead(() => inbox.setViewHash('0xreplaced'));
109+
110+
await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toEqual({
111+
verified: false,
112+
reason: 'view_replaced',
113+
l1BlockNumber: 900n,
114+
});
115+
});
116+
117+
it('refuses when the block the answer was read at cannot be identified afterwards', async () => {
118+
const inbox = makeFakeInbox(ring);
119+
inbox.onRead(() => inbox.setViewHash(null));
120+
121+
await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toMatchObject({
122+
verified: false,
123+
reason: 'unreadable',
124+
l1BlockNumber: 900n,
125+
});
126+
});
127+
128+
it('refuses when the head answers without a block identity', async () => {
129+
const inbox = makeFakeInbox(ring);
130+
inbox.setViewHash(null);
131+
132+
await expect(checkInboxEndpoint(inbox, 200n, hashAt200)).resolves.toMatchObject({
133+
verified: false,
134+
reason: 'unreadable',
135+
});
136+
});
101137
});

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

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import type { InboxContract } from '@aztec/ethereum/contracts';
2-
import type { ViemClient } from '@aztec/ethereum/types';
32
import type { Fr } from '@aztec/foundation/curves/bn254';
43

4+
/** The identity of the L1 block a read was made at: a height alone is not one, since a fork answers at it too. */
5+
type L1View = { number: bigint; hash: string };
6+
57
/**
6-
* The L1 reads the checkpoint endpoint check makes: the bucket resolution itself, and the head it is made at, so a
7-
* verdict names the L1 view that produced it instead of mixing results from a `latest` that moves between reads.
8-
* {@link InboxContract} satisfies it directly; nothing else needs to be fetched to answer the question.
8+
* The L1 reads the checkpoint endpoint check makes: the bucket resolution itself, and the block it is made at,
9+
* read before and after so a verdict names the L1 view that produced it instead of mixing results from a `latest`
10+
* that moves between reads. {@link InboxContract} and a viem client satisfy it directly; nothing else needs to be
11+
* fetched to answer the question.
912
*/
1013
export type InboxEndpointReader = Pick<InboxContract, 'getBucketAtOrBeforeTotal'> & {
11-
client: Pick<ViemClient, 'getBlockNumber'>;
14+
client: {
15+
getBlock(args: {
16+
blockNumber?: bigint;
17+
includeTransactions: false;
18+
}): Promise<{ number: bigint | null; hash: string | null } | undefined>;
19+
};
1220
};
1321

1422
/** Why a checkpoint's final message position is not a live Inbox bucket endpoint in the L1 view that was read. */
@@ -23,12 +31,18 @@ export type InboxEndpointRejection =
2331
/**
2432
* The outcome of one endpoint check. A rejection describes the L1 view that was read at `l1BlockNumber`, not the
2533
* proposer: the bucket ring, this node's provider and the chain itself all move independently of the moment the
26-
* checkpoint was signed. `unreadable` is a view this node could not obtain at all.
34+
* checkpoint was signed. The last two are views this node could not obtain, or could not identify, at all.
2735
*/
2836
export type InboxEndpointCheckResult =
2937
| { verified: true; l1BlockNumber: bigint; bucketSeq: bigint }
3038
| { verified: false; reason: InboxEndpointRejection; l1BlockNumber: bigint; endpointTotal?: bigint }
31-
| { verified: false; reason: 'unreadable'; err: unknown };
39+
/** A read threw, or answered without a block identity: the provider is unreachable, erroring or unsynced. */
40+
| { verified: false; reason: 'unreadable'; l1BlockNumber?: bigint; err: unknown }
41+
/** The block the resolution was read at is no longer the one at that height, so the answer names no view. */
42+
| { verified: false; reason: 'view_replaced'; l1BlockNumber: bigint };
43+
44+
/** What an L1 block read answers with when it names no block: no verdict can be bound to a view like that. */
45+
const UNIDENTIFIED_BLOCK = 'the L1 block was returned without a number or a hash';
3246

3347
/**
3448
* Confirms through L1 that `totalMsgCount` is the end of a live Inbox bucket committing to `inboxRollingHash`.
@@ -38,15 +52,34 @@ export type InboxEndpointCheckResult =
3852
* below the bound, so only an exact total is a match: a lower one means the position sits inside a bucket. The
3953
* bucket's own rolling hash then has to be the one the checkpoint signed, or the boundary commits to different
4054
* message content than the checkpoint was built on.
55+
*
56+
* The resolution is read at one captured block and bound to that block's identity: a provider serving a stale
57+
* fork, or one the chain reorged under, answers a call by height as readily as the canonical chain does. Re-reading
58+
* the block at that height afterwards is what names the view the answer came from, and a view that cannot be shown
59+
* to be the one queried yields a refusal rather than a pass.
4160
*/
4261
export async function checkInboxEndpoint(
4362
inbox: InboxEndpointReader,
4463
totalMsgCount: bigint,
4564
inboxRollingHash: Fr,
4665
): Promise<InboxEndpointCheckResult> {
66+
let queried: L1View | undefined;
4767
try {
48-
const l1BlockNumber = await inbox.client.getBlockNumber();
68+
queried = await readL1View(inbox.client);
69+
if (queried === undefined) {
70+
return { verified: false, reason: 'unreadable', err: UNIDENTIFIED_BLOCK };
71+
}
72+
const l1BlockNumber = queried.number;
4973
const found = await inbox.getBucketAtOrBeforeTotal(totalMsgCount, { blockNumber: l1BlockNumber });
74+
75+
const confirmed = await readL1View(inbox.client, l1BlockNumber);
76+
if (confirmed === undefined) {
77+
return { verified: false, reason: 'unreadable', l1BlockNumber, err: UNIDENTIFIED_BLOCK };
78+
}
79+
if (confirmed.hash !== queried.hash) {
80+
return { verified: false, reason: 'view_replaced', l1BlockNumber };
81+
}
82+
5083
if (found === undefined) {
5184
return { verified: false, reason: 'no_live_endpoint', l1BlockNumber };
5285
}
@@ -59,6 +92,12 @@ export async function checkInboxEndpoint(
5992
}
6093
return { verified: true, l1BlockNumber, bucketSeq: found.seq };
6194
} catch (err) {
62-
return { verified: false, reason: 'unreadable', err };
95+
return { verified: false, reason: 'unreadable', l1BlockNumber: queried?.number, err };
6396
}
6497
}
98+
99+
/** Reads the block at `blockNumber`, or the head when it is omitted, as a number and hash that identify it. */
100+
async function readL1View(client: InboxEndpointReader['client'], blockNumber?: bigint): Promise<L1View | undefined> {
101+
const block = await client.getBlock({ blockNumber, includeTransactions: false });
102+
return block?.number == null || block.hash == null ? undefined : { number: block.number, hash: block.hash };
103+
}

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { TimeoutError } from '@aztec/foundation/error';
12
import type { DateProvider } from '@aztec/foundation/timer';
23
import { execWithSignal } from '@aztec/foundation/timer';
34

@@ -86,4 +87,25 @@ export class DutyBudget {
8687
const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(remainingMs)]);
8788
return await execWithSignal(fn, signal, () => new DutyBudgetExpiredError(what, this.deadline));
8889
}
90+
91+
/**
92+
* Runs `fn` bounded by the shorter of the budget and `withinMs`, for a stage that advertises a ceiling of its
93+
* own. The ceiling is the race, not a deadline the stage consults between attempts: an attempt that never
94+
* settles is abandoned at it, and what is left of the duty stays available to the stages after it.
95+
*
96+
* Like {@link run}, the abandoned attempt keeps running, so `fn` has to honour the signal it is handed rather
97+
* than start anything further with it.
98+
*/
99+
public async runWithin<T>(what: string, withinMs: number, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
100+
if (this.controller.signal.aborted) {
101+
throw new DutyBudgetExpiredError(what, this.deadline);
102+
}
103+
const boundMs = Math.min(this.remainingMs() || EXPIRED_BUDGET_GRACE_MS, withinMs);
104+
const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(boundMs)]);
105+
return await execWithSignal(fn, signal, () =>
106+
this.controller.signal.aborted
107+
? new DutyBudgetExpiredError(what, this.deadline)
108+
: new TimeoutError(`Timeout running ${what} after ${boundMs}ms`),
109+
);
110+
}
89111
}

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,20 @@ export type FakeInbox = InboxEndpointReader & {
1313
setBuckets(buckets: LiveBucket[]): void;
1414
/** Makes every read fail, the way an unreachable provider does. */
1515
setUnreadable(err: Error | undefined): void;
16+
/** Makes every bucket read hang, the way a provider that accepts a call and never answers does. */
17+
setUnresponsive(): void;
18+
/** Replaces the block reported at the head's height, the way a provider answering from a stale fork does. */
19+
setViewHash(hash: string | null): void;
1620
/** Runs before each bucket read with its index, so a test can move the L1 view between two attempts. */
1721
onRead(hook: (readIndex: number) => void): void;
1822
};
1923

2024
/** The L1 head fake reads are pinned to, unless a test asks for another one. */
2125
const DEFAULT_HEAD = 900n;
2226

27+
/** The hash of the block at the head's height, unless a test replaces it mid-check. */
28+
const DEFAULT_HEAD_HASH = '0xhead';
29+
2330
/**
2431
* An Inbox holding the given live buckets, resolving an upper bound the way the contract does: the newest live
2532
* bucket ending at or below it, or nothing at all once the ring has evicted every bucket that could have matched.
@@ -32,7 +39,10 @@ export function makeFakeInbox(
3239
let live = buckets;
3340
let failHead = opts.failHead;
3441
let failBucket = opts.failBucket;
42+
let unresponsive = false;
43+
let viewHash: string | null = DEFAULT_HEAD_HASH;
3544
let beforeRead: (readIndex: number) => void = () => {};
45+
const head = opts.head ?? DEFAULT_HEAD;
3646
const reads: FakeInbox['reads'] = [];
3747
return {
3848
reads,
@@ -43,18 +53,28 @@ export function makeFakeInbox(
4353
failHead = err;
4454
failBucket = err;
4555
},
56+
setUnresponsive: () => {
57+
unresponsive = true;
58+
},
59+
setViewHash: hash => {
60+
viewHash = hash;
61+
},
4662
onRead: hook => {
4763
beforeRead = hook;
4864
},
4965
client: {
50-
getBlockNumber: () => (failHead ? Promise.reject(failHead) : Promise.resolve(opts.head ?? DEFAULT_HEAD)),
66+
getBlock: ({ blockNumber }) =>
67+
failHead ? Promise.reject(failHead) : Promise.resolve({ number: blockNumber ?? head, hash: viewHash }),
5168
},
5269
getBucketAtOrBeforeTotal: (upperBound, readOpts) => {
5370
beforeRead(reads.length);
5471
reads.push({ upperBound, blockNumber: readOpts?.blockNumber });
5572
if (failBucket) {
5673
return Promise.reject(failBucket);
5774
}
75+
if (unresponsive) {
76+
return new Promise(() => {});
77+
}
5878
const match = [...live].sort((a, b) => Number(a.total - b.total)).findLast(bucket => bucket.total <= upperBound);
5979
return Promise.resolve(
6080
match && {

0 commit comments

Comments
 (0)