Skip to content

Commit 1a5d02a

Browse files
committed
feat(sequencer): extract pipelined checkpoint out-hash helper, reuse in validators
The proposer-side fix from #23110 (parent checkpointOutHash splice under pipelining) was inlined as a private method on `CheckpointProposalJob`. The validator's block re-execution and checkpoint-proposal validation paths in `proposal_handler.ts` compute the same `previousCheckpointOutHashes` list through the same archiver-driven query, so they have the same off-by-one window: if the parent cp lands on L1 between when the validator pulls and when it re-derives, only the proposer would carry the spliced parent and attestations would mismatch. Extract the proposer's logic into a shared `getPreviousCheckpointOutHashes` helper in `stdlib/src/checkpoint/`. The helper accepts the proposer's already-loaded `proposedCheckpointData` directly, and falls back on `L2BlockSource.getProposedCheckpointData(...)` for callers that don't have it on hand (validator). Wire the helper into the proposer (replacing the private method) and into both validator sites. Add a few diagnostics that helped pinpoint this class of bug: - `prover-node-publisher.ts`: when the L1-recomputed `RootRollupPublicInputs` vector mismatches the prover's, decode the differing indices into labels (`previousArchiveRoot`, `endArchiveRoot`, `outHash`, `checkpointHeaderHashes[i]`, `fees[i].recipient/value`, `constants.*`, `blobPublicInputs[*]`), fetch the L1 `CheckpointLog` for any mismatching `checkpointHeaderHashes[i]`, and emit a structured error log alongside the throw — much easier to triage than the previous opaque dump. - `BlockRollupPublicInputs.toInspect()` and `CheckpointRollupPublicInputs.toInspect()` to keep per-stage orchestrator debug logs short. - Per-stage debug logs in the orchestrator (block-root, block-merge, checkpoint-root) consume the new `toInspect()` outputs. - Lightweight checkpoint builder logs `headerHash` and the size of `previousCheckpointOutHashes` at debug. - Epoch proving job's per-checkpoint start log trimmed to the fields that are actually useful for cross-comparison.
1 parent a9e61e0 commit 1a5d02a

10 files changed

Lines changed: 257 additions & 60 deletions

File tree

yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,14 @@ export class LightweightCheckpointBuilder {
290290
totalManaUsed,
291291
});
292292

293+
this.logger.debug(`Completed checkpoint ${this.checkpointNumber}`, {
294+
checkpointNumber: this.checkpointNumber,
295+
headerHash: header.hash().toString(),
296+
checkpointOutHash: checkpointOutHash.toString(),
297+
numPreviousCheckpointOutHashes: this.previousCheckpointOutHashes.length,
298+
...header.toInspect(),
299+
});
300+
293301
return new Checkpoint(newArchive, header, blocks, this.checkpointNumber, this.feeAssetPriceModifier);
294302
}
295303

yarn-project/prover-client/src/orchestrator/orchestrator.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -896,7 +896,11 @@ export class ProvingOrchestrator implements EpochProver {
896896
},
897897
),
898898
async result => {
899-
this.logger.debug(`Completed ${rollupType} proof for block ${provingState.blockNumber}`);
899+
this.logger.debug(`Completed ${rollupType} proof for block ${provingState.blockNumber}`, {
900+
blockNumber: provingState.blockNumber,
901+
checkpointIndex: provingState.parentCheckpoint.index,
902+
...result.inputs.toInspect(),
903+
});
900904

901905
const leafLocation = provingState.setBlockRootRollupProof(result);
902906
const checkpointProvingState = provingState.parentCheckpoint;
@@ -1015,6 +1019,11 @@ export class ProvingOrchestrator implements EpochProver {
10151019
signal => this.prover.getBlockMergeRollupProof(inputs, signal, provingState.epochNumber),
10161020
),
10171021
async result => {
1022+
this.logger.debug(`Completed block merge rollup proof for checkpoint ${provingState.index}`, {
1023+
checkpointIndex: provingState.index,
1024+
mergeLocation: location,
1025+
...result.inputs.toInspect(),
1026+
});
10181027
provingState.setBlockMergeRollupProof(location, result);
10191028
await this.checkAndEnqueueNextBlockMergeRollup(provingState, location);
10201029
},
@@ -1067,7 +1076,10 @@ export class ProvingOrchestrator implements EpochProver {
10671076
return;
10681077
}
10691078

1070-
this.logger.debug(`Completed ${rollupType} proof for checkpoint ${provingState.index}.`);
1079+
this.logger.debug(`Completed ${rollupType} proof for checkpoint ${provingState.index}`, {
1080+
checkpointIndex: provingState.index,
1081+
...result.inputs.toInspect(),
1082+
});
10711083

10721084
const leafLocation = provingState.setCheckpointRootRollupProof(result);
10731085
const epochProvingState = provingState.parentEpoch;

yarn-project/prover-node/src/job/epoch-proving-job.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,12 @@ export class EpochProvingJob implements Traceable {
191191
const previousHeader = previousBlockHeaders[checkpointIndex];
192192
const l1ToL2Messages = this.getL1ToL2Messages(checkpoint);
193193

194-
this.log.verbose(`Starting processing checkpoint ${checkpoint.number}`, {
194+
this.log.debug(`Starting processing checkpoint ${checkpoint.number}`, {
195195
number: checkpoint.number,
196196
checkpointHash: checkpoint.hash().toString(),
197-
lastArchive: checkpoint.header.lastArchiveRoot,
198-
previousHeader: previousHeader.hash(),
197+
headerHash: checkpoint.header.hash().toString(),
198+
numL1ToL2Messages: l1ToL2Messages.length,
199+
previousBlockNumber: previousHeader.globalVariables.blockNumber,
199200
uuid: this.uuid,
200201
});
201202

yarn-project/prover-node/src/prover-node-publisher.ts

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,14 @@ export class ProverNodePublisher {
203203
const argsPublicInputs = [...publicInputs.toFields()];
204204

205205
if (!areArraysEqual(rollupPublicInputs, argsPublicInputs, (a, b) => a.equals(b))) {
206-
const fmt = (inputs: Fr[] | readonly string[]) => inputs.map(x => x.toString()).join(', ');
207-
throw new Error(
208-
`Root rollup public inputs mismatch:\nRollup: ${fmt(rollupPublicInputs)}\nComputed:${fmt(argsPublicInputs)}`,
209-
);
206+
throw await reportPublicInputsMismatch({
207+
rollupPublicInputs,
208+
argsPublicInputs,
209+
fromCheckpoint,
210+
toCheckpoint,
211+
rollupContract: this.rollupContract,
212+
log: this.log,
213+
});
210214
}
211215
}
212216

@@ -372,3 +376,100 @@ export class ProverNodePublisher {
372376
};
373377
}
374378
}
379+
380+
/**
381+
* Decodes a `Root rollup public inputs mismatch`, fetches the on-chain CheckpointLog for any
382+
* mismatching `checkpointHeaderHashes[i]`, emits a structured error log, and returns a thrown-ready
383+
* Error with a human-readable summary.
384+
*
385+
* Layout of `RootRollupPublicInputs.toFields()`:
386+
* [0] previousArchiveRoot
387+
* [1] endArchiveRoot
388+
* [2] outHash
389+
* [3 .. 3+N-1] checkpointHeaderHashes[i] for i in 0..N-1 (N = MAX_CHECKPOINTS_PER_EPOCH)
390+
* [3+N .. 3+3N-1] fees[i] = (recipient, value) for i in 0..N-1
391+
* [3+3N .. 3+3N+4] EpochConstantData (chainId, version, vkTreeRoot, protocolContractsHash, proverId)
392+
* [3+3N+5 ..] blobPublicInputs (FinalBlobAccumulator)
393+
*/
394+
async function reportPublicInputsMismatch(input: {
395+
rollupPublicInputs: readonly Fr[];
396+
argsPublicInputs: readonly Fr[];
397+
fromCheckpoint: CheckpointNumber;
398+
toCheckpoint: CheckpointNumber;
399+
rollupContract: RollupContract;
400+
log: Logger;
401+
}): Promise<Error> {
402+
const { rollupPublicInputs, argsPublicInputs, fromCheckpoint, toCheckpoint, rollupContract, log } = input;
403+
const N = MAX_CHECKPOINTS_PER_EPOCH;
404+
const constantsStart = 3 + 3 * N;
405+
const blobStart = constantsStart + 5;
406+
const constantLabels = ['chainId', 'version', 'vkTreeRoot', 'protocolContractsHash', 'proverId'];
407+
408+
const diffs: { index: number; label: string; rollup: Fr; computed: Fr; checkpointIndex?: number }[] = [];
409+
const len = Math.max(rollupPublicInputs.length, argsPublicInputs.length);
410+
for (let i = 0; i < len; i++) {
411+
const a = rollupPublicInputs[i] ?? Fr.ZERO;
412+
const b = argsPublicInputs[i] ?? Fr.ZERO;
413+
if (a.equals(b)) {
414+
continue;
415+
}
416+
let label: string;
417+
let checkpointIndex: number | undefined;
418+
if (i === 0) {
419+
label = 'previousArchiveRoot';
420+
} else if (i === 1) {
421+
label = 'endArchiveRoot';
422+
} else if (i === 2) {
423+
label = 'outHash';
424+
} else if (i < 3 + N) {
425+
checkpointIndex = i - 3;
426+
label = `checkpointHeaderHashes[${checkpointIndex}]`;
427+
} else if (i < 3 + 3 * N) {
428+
const feePairIndex = i - (3 + N);
429+
const feeIndex = Math.floor(feePairIndex / 2);
430+
const sub = feePairIndex % 2 === 0 ? 'recipient' : 'value';
431+
label = `fees[${feeIndex}].${sub}`;
432+
} else if (i < blobStart) {
433+
label = `constants.${constantLabels[i - constantsStart]}`;
434+
} else {
435+
label = `blobPublicInputs[${i - blobStart}]`;
436+
}
437+
diffs.push({ index: i, label, rollup: a, computed: b, checkpointIndex });
438+
}
439+
440+
// For each mismatching checkpointHeaderHash, fetch the L1 CheckpointLog so the operator can
441+
// see what was published on-chain alongside the prover's recomputed hash.
442+
const onChainCheckpoints = await Promise.all(
443+
diffs
444+
.filter(d => d.checkpointIndex !== undefined)
445+
.map(async d => {
446+
const checkpointNumber = CheckpointNumber(fromCheckpoint + d.checkpointIndex!);
447+
try {
448+
const cp = await rollupContract.getCheckpoint(checkpointNumber);
449+
return { checkpointIndex: d.checkpointIndex!, checkpointNumber, headerHash: cp.headerHash.toString() };
450+
} catch (err) {
451+
return { checkpointIndex: d.checkpointIndex!, checkpointNumber, error: (err as Error).message };
452+
}
453+
}),
454+
);
455+
456+
log.error(`Root rollup public inputs mismatch`, undefined, {
457+
fromCheckpoint,
458+
toCheckpoint,
459+
numDiffs: diffs.length,
460+
diffs: diffs.map(d => ({
461+
index: d.index,
462+
label: d.label,
463+
rollup: d.rollup.toString(),
464+
computed: d.computed.toString(),
465+
})),
466+
onChainCheckpoints,
467+
});
468+
469+
const fmt = (inputs: readonly Fr[]) => inputs.map(x => x.toString()).join(', ');
470+
const summary = diffs.map(d => `[${d.index} ${d.label}] L1=${d.rollup} prover=${d.computed}`).join('\n');
471+
return new Error(
472+
`Root rollup public inputs mismatch (${diffs.length} fields differ):\n${summary}\n` +
473+
`Rollup: ${fmt(rollupPublicInputs)}\nComputed:${fmt(argsPublicInputs)}`,
474+
);
475+
}

yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts

Lines changed: 19 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,13 @@ import {
3333
MaliciousCommitteeAttestationsAndSigners,
3434
type ValidateCheckpointResult,
3535
} from '@aztec/stdlib/block';
36-
import { type Checkpoint, type ProposedCheckpointData, validateCheckpoint } from '@aztec/stdlib/checkpoint';
3736
import {
38-
computeQuorum,
39-
getEpochAtSlot,
40-
getSlotStartBuildTimestamp,
41-
getTimestampForSlot,
42-
} from '@aztec/stdlib/epoch-helpers';
37+
type Checkpoint,
38+
type ProposedCheckpointData,
39+
getPreviousCheckpointOutHashes,
40+
validateCheckpoint,
41+
} from '@aztec/stdlib/checkpoint';
42+
import { computeQuorum, getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
4343
import { Gas } from '@aztec/stdlib/gas';
4444
import {
4545
type BlockBuilderOptions,
@@ -416,37 +416,6 @@ export class CheckpointProposalJob implements Traceable {
416416
}
417417
}
418418

419-
/**
420-
* Returns the out hashes of all checkpoints in `targetEpoch` that precede the one being built.
421-
* Under pipelining, the parent checkpoint may not be on L1 yet at build time, so the on-chain
422-
* archiver is missing it; in that case we splice in the parent's `checkpointOutHash` from the
423-
* proposed-checkpoint payload (when it is in the same epoch) so the resulting `epochOutHash`
424-
* matches what other validators and L1 will compute once the parent lands.
425-
*/
426-
private async collectPreviousCheckpointOutHashes(): Promise<Fr[]> {
427-
const parentCheckpointNumber = CheckpointNumber(this.checkpointNumber - 1);
428-
const checkpointed = (await this.l2BlockSource.getCheckpointsData({ epoch: this.targetEpoch }))
429-
.filter(c => c.checkpointNumber < this.checkpointNumber)
430-
.map(c => ({ checkpointNumber: c.checkpointNumber, checkpointOutHash: c.checkpointOutHash }));
431-
432-
const shouldSpliceParent =
433-
this.epochCache.isProposerPipeliningEnabled() &&
434-
this.proposedCheckpointData !== undefined &&
435-
this.proposedCheckpointData.checkpointNumber === parentCheckpointNumber &&
436-
getEpochAtSlot(this.proposedCheckpointData.header.slotNumber, this.epochCache.getL1Constants()) ===
437-
this.targetEpoch &&
438-
!checkpointed.some(c => c.checkpointNumber === parentCheckpointNumber);
439-
440-
if (shouldSpliceParent) {
441-
checkpointed.push({
442-
checkpointNumber: parentCheckpointNumber,
443-
checkpointOutHash: this.proposedCheckpointData!.checkpointOutHash,
444-
});
445-
}
446-
447-
return checkpointed.sort((a, b) => a.checkpointNumber - b.checkpointNumber).map(c => c.checkpointOutHash);
448-
}
449-
450419
/**
451420
* Waits for the parent checkpoint to land on L1 before submitting a pipelined checkpoint.
452421
* Polls until the archiver has synced L1 past the parent's slot, then verifies:
@@ -620,11 +589,19 @@ export class CheckpointProposalJob implements Traceable {
620589
const inHash = computeInHashFromL1ToL2Messages(l1ToL2Messages);
621590

622591
// Collect the out hashes of all the checkpoints before this one in the same epoch.
623-
// Under pipelining, the parent checkpoint may not be on L1 yet at build time, so
624-
// `getCheckpointsData` would miss it. Splice in the parent's checkpointOutHash from the
625-
// proposed-checkpoint payload so the resulting `epochOutHash` matches what the validators
626-
// (and L1) compute once the parent lands on L1.
627-
const previousCheckpointOutHashes = await this.collectPreviousCheckpointOutHashes();
592+
// Under pipelining the parent checkpoint may not be on L1 yet at build time, so the helper
593+
// splices in the parent's checkpointOutHash from the locally-known proposed checkpoint so
594+
// the resulting `epochOutHash` matches what validators (and L1) compute once the parent
595+
// lands on L1.
596+
const previousCheckpointOutHashes = await getPreviousCheckpointOutHashes({
597+
blockSource: this.l2BlockSource,
598+
epoch: this.targetEpoch,
599+
checkpointNumber: this.checkpointNumber,
600+
l1Constants: this.epochCache.getL1Constants(),
601+
pipeliningEnabled: this.epochCache.isProposerPipeliningEnabled(),
602+
proposedCheckpointData: this.proposedCheckpointData,
603+
log: this.log,
604+
});
628605

629606
// Get the fee asset price modifier from the oracle
630607
const feeAssetPriceModifier = await this.publisher.getFeeAssetPriceModifier();

yarn-project/stdlib/src/checkpoint/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export * from './checkpoint.js';
22
export * from './checkpoint_data.js';
33
export * from './checkpoint_info.js';
44
export * from './digest.js';
5+
export * from './previous_checkpoint_out_hashes.js';
56
export * from './published_checkpoint.js';
67
export * from './validate.js';
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { CheckpointNumber, type EpochNumber } from '@aztec/foundation/branded-types';
2+
import type { Fr } from '@aztec/foundation/curves/bn254';
3+
import type { Logger } from '@aztec/foundation/log';
4+
5+
import type { L2BlockSource } from '../block/l2_block_source.js';
6+
import { type L1RollupConstants, getEpochAtSlot } from '../epoch-helpers/index.js';
7+
import type { ProposedCheckpointData } from './checkpoint_data.js';
8+
9+
/**
10+
* Returns the out hashes (in epoch order) of all checkpoints in `epoch` that precede
11+
* `checkpointNumber`, used to compute the `epochOutHash` baked into that checkpoint's header.
12+
*
13+
* Under proposer pipelining the parent checkpoint may not be confirmed on L1 by the time the
14+
* builder runs, so the on-chain archiver query (`getCheckpointsData`) is missing it and the
15+
* resulting `epochOutHash` would diverge from what other validators (and L1) compute after the
16+
* parent lands. To avoid that, the parent's `checkpointOutHash` is spliced in from the locally
17+
* known proposed checkpoint when:
18+
* - pipelining is enabled,
19+
* - the archiver lookup is genuinely short (its last entry is not already cp `N-1`),
20+
* - and the proposed cp `N-1` is in the same target epoch (otherwise we're at an epoch boundary
21+
* and the previous-epoch cps must be excluded).
22+
*
23+
* Callers may either pass the already-loaded `proposedCheckpointData` (the proposer has it on
24+
* hand) or leave it undefined, in which case it's fetched via `getProposedCheckpointData`.
25+
*/
26+
export async function getPreviousCheckpointOutHashes(input: {
27+
blockSource: Pick<L2BlockSource, 'getCheckpointsData' | 'getProposedCheckpointData'>;
28+
epoch: EpochNumber;
29+
checkpointNumber: CheckpointNumber;
30+
l1Constants: Pick<L1RollupConstants, 'epochDuration'>;
31+
pipeliningEnabled: boolean;
32+
proposedCheckpointData?: ProposedCheckpointData;
33+
log?: Logger;
34+
}): Promise<Fr[]> {
35+
const { blockSource, epoch, checkpointNumber, l1Constants, pipeliningEnabled, log } = input;
36+
37+
const checkpointed = (await blockSource.getCheckpointsData({ epoch }))
38+
.filter(c => c.checkpointNumber < checkpointNumber)
39+
.sort((a, b) => a.checkpointNumber - b.checkpointNumber);
40+
41+
if (!pipeliningEnabled || checkpointNumber === 0) {
42+
return checkpointed.map(c => c.checkpointOutHash);
43+
}
44+
45+
const parentCheckpoint = CheckpointNumber(checkpointNumber - 1);
46+
if (checkpointed.at(-1)?.checkpointNumber === parentCheckpoint) {
47+
return checkpointed.map(c => c.checkpointOutHash);
48+
}
49+
50+
const proposedParent =
51+
input.proposedCheckpointData ?? (await blockSource.getProposedCheckpointData({ number: parentCheckpoint }));
52+
if (!proposedParent || proposedParent.checkpointNumber !== parentCheckpoint) {
53+
return checkpointed.map(c => c.checkpointOutHash);
54+
}
55+
if (getEpochAtSlot(proposedParent.header.slotNumber, l1Constants) !== epoch) {
56+
return checkpointed.map(c => c.checkpointOutHash);
57+
}
58+
59+
log?.debug(`Splicing pipelined parent cp ${parentCheckpoint} outHash for cp ${checkpointNumber}`);
60+
return [...checkpointed.map(c => c.checkpointOutHash), proposedParent.checkpointOutHash];
61+
}

yarn-project/stdlib/src/rollup/block_rollup_public_inputs.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,19 @@ export class BlockRollupPublicInputs {
119119
return this.toBuffer();
120120
}
121121

122+
toInspect() {
123+
return {
124+
previousArchiveRoot: this.previousArchive.root.toString(),
125+
newArchiveRoot: this.newArchive.root.toString(),
126+
blockHeadersHash: this.blockHeadersHash.toString(),
127+
inHash: this.inHash.toString(),
128+
outHash: this.outHash.toString(),
129+
timestamp: this.timestamp.toString(),
130+
accumulatedFees: this.accumulatedFees.toString(),
131+
accumulatedManaUsed: this.accumulatedManaUsed.toString(),
132+
};
133+
}
134+
122135
static get schema() {
123136
return bufferSchemaFor(BlockRollupPublicInputs);
124137
}

yarn-project/stdlib/src/rollup/checkpoint_rollup_public_inputs.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ export class CheckpointRollupPublicInputs {
101101
return this.toBuffer();
102102
}
103103

104+
toInspect() {
105+
return {
106+
checkpointHeaderHash: this.checkpointHeaderHashes[0].toString(),
107+
previousArchiveRoot: this.previousArchive.root.toString(),
108+
newArchiveRoot: this.newArchive.root.toString(),
109+
previousOutHashRoot: this.previousOutHash.root.toString(),
110+
newOutHashRoot: this.newOutHash.root.toString(),
111+
};
112+
}
113+
104114
/** Creates an instance from a hex string. */
105115
static get schema() {
106116
return bufferSchemaFor(CheckpointRollupPublicInputs);

0 commit comments

Comments
 (0)