From 87ac15e00cb33d9b976cc3c761bef48c4a6f42e9 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 11 Sep 2026 22:56:00 -0300 Subject: [PATCH] test(prover): select the rollup sample block by message shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rollup sample scenario splits 2 then 3 messages across two blocks but committed `data[0]`, the first block, whose inherited message sponge is empty. That is the wrong sample: the block-root variant these scenarios exist to cover is one that continues a non-empty sponge from an earlier block of the same checkpoint. Blocks are proven concurrently, so capture order does not identify a block. A scenario that runs a block-root circuit more than once now declares the shape it means — how many messages the block inherits and how many its own bundle inserts — and the selector resolves that against the captured runs. It throws when nothing was captured, when several runs were captured with no declared shape, and when a declared shape matches no run or more than one, rather than silently pinning whichever input arrived first. The committed sample artifact still holds the old capture. It lives in the protocol circuit crates, which this repository does not carry, so correcting it is a separate change in the repository that owns the generator workflow. --- .../regenerate_rollup_sample_inputs.test.ts | 76 +++++++++++++++++-- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/yarn-project/prover-client/src/test/regenerate_rollup_sample_inputs.test.ts b/yarn-project/prover-client/src/test/regenerate_rollup_sample_inputs.test.ts index 5440cc2252a..2ee4fef5cef 100644 --- a/yarn-project/prover-client/src/test/regenerate_rollup_sample_inputs.test.ts +++ b/yarn-project/prover-client/src/test/regenerate_rollup_sample_inputs.test.ts @@ -36,13 +36,23 @@ import { type CheckpointTopTreeData, TopTreeOrchestrator } from '../orchestrator // before the (two-input) block root, whereas one- or two-tx blocks feed the block root directly, so a // dedicated three-tx scenario is what regenerates the tx-merge sample. The samples for the variants // that thread a start sponge from a previous block are taken from mid-checkpoint blocks, so those -// scenarios need a per-block message distribution. Every scenario also produces the root rollup. +// scenarios need a per-block message distribution, and they declare which block they mean: blocks are +// proven concurrently, so the order the inputs were captured in does not identify them. Every scenario +// also produces the root rollup. const describeOrSkip = isGenerateTestDataEnabled() ? describe : describe.skip; describeOrSkip('prover/regenerate-rollup-sample-inputs', () => { let context: TestContext; let log: Logger; + /** + * How many L1-to-L2 messages a block inherits from earlier blocks of its checkpoint (its start sponge) and how + * many its own bundle inserts. A scenario that runs a block-root circuit once per block identifies the run it + * means by this shape, which is a property of the block itself, rather than by the order the inputs happened to + * be captured in. + */ + type MessageShape = { inherited: number; bundle: number }; + interface Scenario { numCheckpoints: number; numBlocksPerCheckpoint: number; @@ -55,6 +65,8 @@ describeOrSkip('prover/regenerate-rollup-sample-inputs', () => { l1ToL2MessagesPerBlock?: Fr[][]; /** Circuits whose sample inputs this scenario is responsible for regenerating. */ dump: CircuitName[]; + /** The block whose inputs each dumped block-root sample must be taken from. */ + sampleFrom?: Partial>; } // `makeCheckpoint` puts the scenario's whole message list into the first block, so the most a @@ -75,6 +87,9 @@ describeOrSkip('prover/regenerate-rollup-sample-inputs', () => { numTxsPerBlock: 1, numL1ToL2Messages: withMessages, dump: ['rollup-block-root-single-tx', 'rollup-block-merge', 'rollup-checkpoint-root'], + // All the messages go to the first block, so the single-tx sample is the block that carries the bundle; the + // two blocks after it insert nothing and only inherit the sponge. + sampleFrom: { 'rollup-block-root-single-tx': { inherited: 0, bundle: withMessages } }, }, // Messages split across both blocks so the block-root sample is taken from a mid-checkpoint block with a // non-empty bundle, exercising the per-block sponge continuity asserts in the circuit. @@ -85,6 +100,9 @@ describeOrSkip('prover/regenerate-rollup-sample-inputs', () => { numL1ToL2Messages: 0, // Overridden by l1ToL2MessagesPerBlock. l1ToL2MessagesPerBlock: [times(2, i => new Fr(0xb00 + i)), times(3, i => new Fr(0xc00 + i))], dump: ['rollup-block-root'], + // The second block: it inherits the two messages the first block inserted and inserts three of its own, so + // its start sponge is a continuation rather than the initial empty one. + sampleFrom: { 'rollup-block-root': { inherited: 2, bundle: 3 } }, }, // Three txs in a block force a tx-merge to pair the base proofs down to the two the block root // takes; one- or two-tx blocks feed the block root directly and never exercise tx-merge. @@ -104,6 +122,7 @@ describeOrSkip('prover/regenerate-rollup-sample-inputs', () => { numL1ToL2Messages: 0, // Overridden by l1ToL2MessagesPerBlock. l1ToL2MessagesPerBlock: [times(2, i => new Fr(0x900 + i)), times(3, i => new Fr(0xa00 + i))], dump: ['rollup-block-root-no-txs'], + sampleFrom: { 'rollup-block-root-no-txs': { inherited: 2, bundle: 3 } }, }, // The checkpoint-merge only appears with three checkpoints. Independently-built checkpoints do // not carry the inbox message state forward, so this scenario runs with no L1-to-L2 messages and @@ -117,6 +136,51 @@ describeOrSkip('prover/regenerate-rollup-sample-inputs', () => { }, ]; + /** The message shape of a captured input, or undefined for circuits that take no message bundle. */ + const messageShapeOf = (captured: unknown): MessageShape | undefined => { + const { inputs } = (captured ?? {}) as { + inputs?: { message_bundle?: { num_msgs?: string }; start_msg_sponge?: { num_absorbed?: string } }; + }; + const bundle = inputs?.message_bundle?.num_msgs; + const inherited = inputs?.start_msg_sponge?.num_absorbed; + return bundle === undefined || inherited === undefined + ? undefined + : { inherited: Number(BigInt(inherited)), bundle: Number(BigInt(bundle)) }; + }; + + /** + * Picks the one captured input a scenario means to commit for `circuitName`. A circuit the scenario runs once has + * a single candidate; one it runs per block is identified by the message shape the scenario declared. Nothing + * captured, several runs with no declared shape, or a declared shape matching no run or more than one all throw: + * committing whichever input happened to be captured first would silently pin the wrong block. + */ + const selectSample = (circuitName: CircuitName, wanted: MessageShape | undefined): unknown => { + const captured = getTestData(circuitName) ?? []; + const shapes = () => captured.map(entry => JSON.stringify(messageShapeOf(entry) ?? 'no bundle')).join(', '); + if (captured.length === 0) { + throw new Error(`No test data captured for ${circuitName}; scenario does not exercise it.`); + } + if (wanted === undefined) { + if (captured.length > 1) { + throw new Error( + `${circuitName} ran ${captured.length} times (${shapes()}); declare in sampleFrom which run to commit.`, + ); + } + return captured[0]; + } + const matching = captured.filter(entry => { + const shape = messageShapeOf(entry); + return shape?.inherited === wanted.inherited && shape.bundle === wanted.bundle; + }); + if (matching.length !== 1) { + throw new Error( + `Expected exactly one ${circuitName} run inheriting ${wanted.inherited} messages and inserting ` + + `${wanted.bundle}, found ${matching.length} of ${captured.length} runs (${shapes()}).`, + ); + } + return matching[0]; + }; + beforeEach(async () => { log = createLogger('prover-client:test:regenerate-rollup-sample-inputs'); context = await TestContext.new(log, { proverCount: 1 }); @@ -135,6 +199,7 @@ describeOrSkip('prover/regenerate-rollup-sample-inputs', () => { numL1ToL2Messages, l1ToL2MessagesPerBlock, dump, + sampleFrom, }) => { const makeProcessedTxOpts = (_: unknown, txIndex: number) => ({ privateOnly: txIndex % 2 === 0 }); const checkpoints = await timesAsync(numCheckpoints, () => @@ -203,12 +268,9 @@ describeOrSkip('prover/regenerate-rollup-sample-inputs', () => { } for (const circuitName of dump) { - const data = getTestData(circuitName); - if (!data || data.length === 0) { - throw new Error(`No test data captured for ${circuitName}; scenario does not exercise it.`); - } - updateProtocolCircuitSampleInputs(circuitName, TOML.stringify(data[0] as any)); - log.info(`Regenerated sample inputs for ${circuitName}`); + const sample = selectSample(circuitName, sampleFrom?.[circuitName]); + updateProtocolCircuitSampleInputs(circuitName, TOML.stringify(sample as any)); + log.info(`Regenerated sample inputs for ${circuitName}`, messageShapeOf(sample)); } } finally { await Promise.all(subTrees.map(s => s.stop()));