From dfe48ed8f49fd097401f90477344c3a364886590 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 3 Jul 2026 05:14:40 -0300 Subject: [PATCH 1/2] test(e2e): dedupe multi-node block-production and governance test setups Repoint block-production/blob_promotion and recovery/pipeline_prune onto the shared setupBlockProductionWithProver helper (new mockGossipSubNetworkLatency / maxTxsPerCheckpoint / clearInheritedCoinbase / disableCheckpointPromotionOnFirstNode options), extract a shared governance round-driving driver (createGovernanceTestDriver + driveGovernanceRound) adopted by add_rollup and upgrade_governance_proposer, and hoist the invalidate_block attack configs into a typed file-local table. Pure dedup refactor: no timing/config values change. --- .../block-production/blob_promotion.test.ts | 72 ++------ .../src/multi-node/block-production/setup.ts | 41 ++++- .../multi-node/governance/add_rollup.test.ts | 120 ++------------ .../src/multi-node/governance/setup.ts | 155 ++++++++++++++++++ .../upgrade_governance_proposer.test.ts | 146 +++-------------- .../invalidate_block.parallel.test.ts | 104 ++++++------ .../recovery/pipeline_prune.test.ts | 95 +++-------- 7 files changed, 311 insertions(+), 422 deletions(-) diff --git a/yarn-project/end-to-end/src/multi-node/block-production/blob_promotion.test.ts b/yarn-project/end-to-end/src/multi-node/block-production/blob_promotion.test.ts index 10dc50a6e584..d2f5a935e767 100644 --- a/yarn-project/end-to-end/src/multi-node/block-production/blob_promotion.test.ts +++ b/yarn-project/end-to-end/src/multi-node/block-production/blob_promotion.test.ts @@ -1,20 +1,14 @@ -import type { Archiver } from '@aztec/archiver'; -import type { AztecNodeConfig } from '@aztec/aztec-node'; import { Fr } from '@aztec/aztec.js/fields'; import { waitForTx } from '@aztec/aztec.js/node'; -import { asyncMap } from '@aztec/foundation/async-map'; import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import { retryUntil } from '@aztec/foundation/retry'; import { executeTimeout } from '@aztec/foundation/timer'; -import type { TestWallet } from '../../test-wallet/test_wallet.js'; import { proveAndSendTxs } from '../../test-wallet/utils.js'; -import { MultiNodeTestContext, buildMockGossipValidators } from '../multi_node_test_context.js'; import { type BlockProductionWithProverFixture, - NODE_COUNT, - WIDE_SLOT_TIMING, jest, + setupBlockProductionWithProver, waitForProvenCheckpoint, } from './setup.js'; @@ -36,57 +30,6 @@ describe('multi-node/block-production/blob_promotion', () => { await fixture?.test?.teardown(); }); - /** - * Sets up the pipelining wide-slot context: same timing profile as {@link setupBlockProductionWithProver} plus 500ms mock - * gossip latency, a tighter `maxTxsPerCheckpoint`, and node-0 with checkpoint promotion disabled so - * the blob-promotion behavior of the other nodes can be asserted against it. - */ - async function setupBlobPromotion(): Promise { - const validators = buildMockGossipValidators(NODE_COUNT); - - const test = await MultiNodeTestContext.setup({ - ...WIDE_SLOT_TIMING, - numberOfAccounts: 0, - initialValidators: validators, - mockGossipSubNetwork: true, - mockGossipSubNetworkLatency: 500, // adverse network conditions - startProverNode: true, - maxTxsPerCheckpoint: 24, - inboxLag: 2, - minTxsPerBlock: 1, - maxTxsPerBlock: PIPELINE_MAX_TXS_PER_BLOCK, - pxeOpts: { syncChainTip: 'checkpointed' }, - skipInitialSequencer: true, - }); - - const { context, logger, rollup } = test; - const wallet = context.wallet as TestWallet; - const from = context.accounts[0]; // auto-created by setup - - logger.warn(`Initial setup complete. Starting ${NODE_COUNT} validator nodes.`); - // Clear inherited coinbase so each validator derives coinbase from its own attester key - const nodes = await asyncMap(validators, ({ privateKey }, i) => - test.createValidatorNode([privateKey], { - dontStartSequencer: true, - coinbase: undefined, - // Disable checkpoint promotion on the first node so it always fetches blobs, - // allowing us to assert that other nodes skip blob fetching via promotion. - ...(i === 0 ? { skipPromoteProposedCheckpointDuringL1Sync: true } : {}), - } as Partial), - ); - logger.warn(`Started ${NODE_COUNT} validator nodes.`, { validators: validators.map(v => v.attester.toString()) }); - - wallet.updateNode(nodes[0]); - const archiver = nodes[0].getBlockSource() as Archiver; - - const contract = await test.registerTestContract(wallet); - logger.warn(`Test setup completed.`, { validators: validators.map(v => v.attester.toString()) }); - - const { failEvents } = test.watchNodeSequencerEvents(nodes); - - return { test, context, logger, rollup, archiver, validators, nodes, contract, wallet, from, failEvents }; - } - /** * Waits until the archiver's checkpointed chain tip has reached `targetBlockNumber`, then retrieves all * checkpoints and returns the number of the first one with at least `targetBlockCount` blocks. Used to @@ -121,7 +64,18 @@ describe('multi-node/block-production/blob_promotion', () => { // mined, then verifies node-0 (promotion disabled) fetches blobs while nodes 1-3 (promotion enabled) // skip blob fetching entirely, and that a high-block-count checkpoint built under load still proves. it('promotion-disabled node fetches blobs while peers skip them, and the checkpoint proves', async () => { - fixture = await setupBlobPromotion(); + // Same wide-slot prover-backed cluster as the rest of this directory, plus adverse gossip latency, a + // tighter maxTxsPerCheckpoint, and node 0 with checkpoint promotion disabled so its blob-fetching can + // be contrasted with the promotion-enabled peers. + fixture = await setupBlockProductionWithProver({ + syncChainTip: 'checkpointed', + minTxsPerBlock: 1, + maxTxsPerBlock: PIPELINE_MAX_TXS_PER_BLOCK, + maxTxsPerCheckpoint: 24, + mockGossipSubNetworkLatency: 500, + clearInheritedCoinbase: true, + disableCheckpointPromotionOnFirstNode: true, + }); const { test, context, logger, nodes, contract, from } = fixture; // Spy on getBlobSidecar on all validator nodes before sequencers start, so we check that nodes diff --git a/yarn-project/end-to-end/src/multi-node/block-production/setup.ts b/yarn-project/end-to-end/src/multi-node/block-production/setup.ts index d04826ca89cf..20a4bec1ca73 100644 --- a/yarn-project/end-to-end/src/multi-node/block-production/setup.ts +++ b/yarn-project/end-to-end/src/multi-node/block-production/setup.ts @@ -76,11 +76,14 @@ export type BlockProductionWithProverFixture = { failEvents: TrackedSequencerEvent[]; }; +/** Per-validator node config, or a function deriving it from the validator's 0-based index. */ +type ValidatorNodeOpts = Partial & { dontStartSequencer?: boolean }; + /** Shared spine: builds N mock-gossip validators, sets up the context, spawns one node per validator. */ async function buildValidatorCluster(opts: { nodeCount: number; setupOpts: Partial; - nodeOpts?: Partial & { dontStartSequencer?: boolean }; + nodeOpts?: ValidatorNodeOpts | ((index: number) => ValidatorNodeOpts); }): Promise { const validators = buildMockGossipValidators(opts.nodeCount); @@ -93,8 +96,11 @@ async function buildValidatorCluster(opts: { const from = context.accounts[0]; logger.warn(`Initial setup complete. Starting ${opts.nodeCount} validator nodes.`); - const nodes = await asyncMap(validators, ({ privateKey }) => - test.createValidatorNode([privateKey], { ...opts.nodeOpts }), + const nodes = await asyncMap(validators, ({ privateKey }, i) => + test.createValidatorNode( + [privateKey], + typeof opts.nodeOpts === 'function' ? opts.nodeOpts(i) : { ...opts.nodeOpts }, + ), ); logger.warn(`Started ${opts.nodeCount} validator nodes.`, { validators: validators.map(v => v.attester.toString()) }); @@ -124,16 +130,33 @@ export function setupSimpleBlockProduction(opts: { /** * Creates validators and sets up a wide-slot test context with the pipelining timing profile and a prover * node, then starts (paused) validator nodes and points the wallet at node 0. Mirrors the per-test - * setup from the dissolved `mbps.parallel` file. + * setup from the dissolved `mbps.parallel` file. The blob-promotion and pipeline-prune suites layer their + * adverse-network shape on via `mockGossipSubNetworkLatency`, `maxTxsPerCheckpoint`, `clearInheritedCoinbase`, + * and `disableCheckpointPromotionOnFirstNode`. */ export async function setupBlockProductionWithProver(opts: { syncChainTip: 'proposed' | 'checkpointed'; minTxsPerBlock?: number; maxTxsPerBlock?: number; + maxTxsPerCheckpoint?: number; buildCheckpointIfEmpty?: boolean; skipPushProposedBlocksToArchiver?: boolean; + /** Injects artificial mock-gossip propagation latency (ms) to model adverse network conditions. */ + mockGossipSubNetworkLatency?: number; + /** Clears each validator node's inherited coinbase so it derives one from its own attester key. */ + clearInheritedCoinbase?: boolean; + /** + * Disables checkpoint promotion on node 0 (`skipPromoteProposedCheckpointDuringL1Sync`), so node 0 fetches + * blobs during L1 sync while its peers promote their own proposed checkpoints and skip the blob fetch. + */ + disableCheckpointPromotionOnFirstNode?: boolean; }): Promise { - const { syncChainTip = 'checkpointed', ...setupOpts } = opts; + const { + syncChainTip = 'checkpointed', + clearInheritedCoinbase = false, + disableCheckpointPromotionOnFirstNode = false, + ...setupOpts + } = opts; // WIDE_SLOT_TIMING is the wide 72s/12s pipelining cadence (see A-914 on why the tighter 36s/4s breaks // non-proposer nodes); the JSDoc on the profile carries the full rationale. @@ -149,7 +172,13 @@ export async function setupBlockProductionWithProver(opts: { skipInitialSequencer: true, inboxLag: 2, }, - nodeOpts: { dontStartSequencer: true }, + nodeOpts: (index: number) => ({ + dontStartSequencer: true, + ...(clearInheritedCoinbase ? { coinbase: undefined } : {}), + ...(disableCheckpointPromotionOnFirstNode && index === 0 + ? { skipPromoteProposedCheckpointDuringL1Sync: true } + : {}), + }), }); const { rollup } = test; diff --git a/yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts b/yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts index 21272c08e4a7..a81fff2c73f8 100644 --- a/yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts +++ b/yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts @@ -6,7 +6,7 @@ import { generateClaimSecret } from '@aztec/aztec.js/ethereum'; import { Fr } from '@aztec/aztec.js/fields'; import { waitForL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; import { RollupCheatCodes } from '@aztec/aztec/testing'; -import { FeeAssetHandlerContract, RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; +import { FeeAssetHandlerContract, RegistryContract } from '@aztec/ethereum/contracts'; import { deployRollupForUpgrade } from '@aztec/ethereum/deploy-aztec-l1-contracts'; import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; @@ -17,7 +17,6 @@ import { retryUntil } from '@aztec/foundation/retry'; import { sleep } from '@aztec/foundation/sleep'; import { GovernanceAbi, - GovernanceProposerAbi, OutboxAbi, RegisterNewRollupVersionPayloadAbi, RegisterNewRollupVersionPayloadBytecode, @@ -42,7 +41,7 @@ import { MultiNodeTestContext, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { GOVERNANCE_TIMING, jest } from './setup.js'; +import { GOVERNANCE_TIMING, createGovernanceTestDriver, driveGovernanceRound, jest } from './setup.js'; // Don't set this to a higher value than 9 because each node will use a different L1 publisher account and anvil seeds const NUM_VALIDATORS = 4; @@ -112,44 +111,16 @@ describe('multi-node/governance/add_rollup', () => { it('Should cast votes to add new rollup to registry', async () => { const { context, logger } = test; + const driver = await createGovernanceTestDriver(test, l1TxUtils); + const { governance, governanceProposer, rollup } = driver; + const registry = getContract({ address: getAddress(context.deployL1ContractsValues.l1ContractAddresses.registryAddress.toString()), abi: RegistryAbi, client: context.deployL1ContractsValues.l1Client, }); - const governanceProposer = getContract({ - address: getAddress(context.deployL1ContractsValues.l1ContractAddresses.governanceProposerAddress.toString()), - abi: GovernanceProposerAbi, - client: context.deployL1ContractsValues.l1Client, - }); - - const roundSize = await governanceProposer.read.ROUND_SIZE(); - - const governance = getContract({ - address: getAddress(context.deployL1ContractsValues.l1ContractAddresses.governanceAddress.toString()), - abi: GovernanceAbi, - client: context.deployL1ContractsValues.l1Client, - }); - - const rollup = new RollupContract( - context.deployL1ContractsValues.l1Client, - context.deployL1ContractsValues.l1ContractAddresses.rollupAddress, - ); - - const emperor = context.deployL1ContractsValues.l1Client.account; - - const waitL1Block = async () => { - await l1TxUtils.sendAndMonitorTransaction({ - to: emperor.address, - value: 1n, - }); - }; - - const currentSlot = await rollup.getSlotNumber(); - const nextRoundSlot = SlotNumber.fromBigInt((BigInt(currentSlot) / roundSize) * roundSize + roundSize); - const nextRoundTimestamp = await rollup.getTimestampForSlot(nextRoundSlot); - await context.cheatCodes.eth.warp(Number(nextRoundTimestamp)); + await driver.warpToNextRound(); // Build the new rollup's genesis from the same funded-account set the context used (the additionally // funded accounts plus the sponsored FPC), so the second bridging step can fund `fundedAccounts[1]`. @@ -225,27 +196,7 @@ describe('multi-node/governance/add_rollup', () => { [context.deployL1ContractsValues.l1ContractAddresses.registryAddress.toString(), newRollup.address], ); - const govInfo = async () => { - const bn = await context.cheatCodes.eth.blockNumber(); - const slot = await rollup.getSlotNumber(); - const round = await governanceProposer.read.computeRound([BigInt(slot)]); - - const info = await governanceProposer.read.getRoundData([ - context.deployL1ContractsValues.l1ContractAddresses.rollupAddress.toString(), - round, - ]); - const leaderVotes = await governanceProposer.read.signalCount([ - context.deployL1ContractsValues.l1ContractAddresses.rollupAddress.toString(), - round, - info.payloadWithMostSignals, - ]); - logger.info( - `Governance stats for round ${round} (Slot: ${slot}, BN: ${bn}). Leader: ${info.payloadWithMostSignals} have ${leaderVotes} signals`, - ); - return { bn, slot, round, info, leaderVotes }; - }; - - await waitL1Block(); + await driver.waitL1Block(); logger.info('Creating nodes'); nodes = await Promise.all( @@ -262,7 +213,7 @@ describe('multi-node/governance/add_rollup', () => { logger.info('Start progressing time to cast votes'); const quorumSize = await governanceProposer.read.QUORUM_SIZE(); - logger.info(`Quorum size: ${quorumSize}, round size: ${await governanceProposer.read.ROUND_SIZE()}`); + logger.info(`Quorum size: ${quorumSize}, round size: ${driver.roundSize}`); const bridging = async ( node: AztecNodeService, @@ -429,56 +380,9 @@ describe('multi-node/governance/add_rollup', () => { context.aztecNodeConfig.l1RpcUrls, ); - // Poll once per L2 slot for the round leader to reach quorum, since validators signal once per slot - const govData = await retryUntil( - async () => { - const govData = await govInfo(); - if (govData.leaderVotes >= quorumSize) { - return govData; - } - }, - 'governance leader reaches quorum', - 600, - context.aztecNodeConfig.aztecSlotDuration, - ); - - const currentSlot2 = await rollup.getSlotNumber(); - const nextRoundSlot2 = SlotNumber.fromBigInt((BigInt(currentSlot2) / roundSize) * roundSize + roundSize); - const nextRoundTimestamp2 = await rollup.getTimestampForSlot(nextRoundSlot2); - logger.info(`Warpping to ${nextRoundTimestamp2}`); - await context.cheatCodes.eth.warp(Number(nextRoundTimestamp2)); - - await waitL1Block(); - - logger.info(`Executing proposal ${govData.round}`); - - await l1TxUtils.sendAndMonitorTransaction({ - to: governanceProposer.address, - data: encodeFunctionData({ - abi: GovernanceProposerAbi, - functionName: 'submitRoundWinner', - args: [govData.round], - }), - }); - logger.info(`Submitted winner for round ${govData.round}`); - - const proposal = await governance.read.getProposal([0n]); - - const timeToActive = proposal.creation + proposal.config.votingDelay; - logger.info(`Warping to ${timeToActive + 1n}`); - await context.cheatCodes.eth.warp(Number(timeToActive + 1n)); - logger.info(`Warped to ${timeToActive + 1n}`); - await waitL1Block(); - - logger.info(`Voting`); - await rollup.vote(l1TxUtils, 0n); - logger.info(`Voted`); - - const timeToExecutable = timeToActive + proposal.config.votingDuration + proposal.config.executionDelay + 1n; - logger.info(`Warping to ${timeToExecutable}`); - await context.cheatCodes.eth.warp(Number(timeToExecutable)); - logger.info(`Warped to ${timeToExecutable}`); - await waitL1Block(); + // Drive the signaled payload to quorum and vote it through to an executable proposal. The 600s quorum + // timeout covers the many slots validators need to accumulate signals while bridging runs concurrently. + await driveGovernanceRound(driver, { quorumTimeoutSeconds: 600 }); const canonicalBefore = EthAddress.fromString(await registry.read.getCanonicalRollup()); expect(canonicalBefore.equals(EthAddress.fromString(rollup.address))).toBe(true); @@ -534,7 +438,7 @@ describe('multi-node/governance/add_rollup', () => { const time = await newRollup.getTimestampForSlot(futureSlot); if (time > BigInt(await context.cheatCodes.eth.lastBlockTimestamp())) { await context.cheatCodes.eth.warp(Number(time)); - await waitL1Block(); + await driver.waitL1Block(); } const newVersion = await newRollup.getVersion(); diff --git a/yarn-project/end-to-end/src/multi-node/governance/setup.ts b/yarn-project/end-to-end/src/multi-node/governance/setup.ts index 186cdb7874a7..1f6124909054 100644 --- a/yarn-project/end-to-end/src/multi-node/governance/setup.ts +++ b/yarn-project/end-to-end/src/multi-node/governance/setup.ts @@ -1,4 +1,14 @@ +import { RollupContract } from '@aztec/ethereum/contracts'; +import type { L1TxUtils } from '@aztec/ethereum/l1-tx-utils'; +import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; +import { SlotNumber } from '@aztec/foundation/branded-types'; +import { retryUntil } from '@aztec/foundation/retry'; +import { GovernanceAbi, GovernanceProposerAbi } from '@aztec/l1-artifacts'; + import { jest } from '@jest/globals'; +import { type GetContractReturnType, encodeFunctionData, getAddress, getContract } from 'viem'; + +import type { MultiNodeTestContext } from '../multi_node_test_context.js'; jest.setTimeout(1000 * 60 * 10); @@ -14,4 +24,149 @@ export const GOVERNANCE_TIMING = { aztecProofSubmissionEpochs: 640, } as const; +/** The current round leader's payload and its signal count, as observed by {@link GovernanceTestDriver.govInfo}. */ +type GovRoundState = { round: bigint; leaderVotes: bigint }; + +/** + * Wraps the L1 governance-proposer / governance / rollup contracts and the round-driving mechanics shared by + * the governance suites (`add_rollup`, `upgrade_governance_proposer`): warping to round boundaries, polling + * round state, waiting for signal quorum, submitting the round winner, and voting a proposal through to + * executable. Scenario-specific payload construction, node signaling, and post-execution assertions stay in + * each test. + */ +export interface GovernanceTestDriver { + governanceProposer: GetContractReturnType; + governance: GetContractReturnType; + rollup: RollupContract; + roundSize: bigint; + emperor: ExtendedViemWalletClient['account']; + /** Sends a 1-wei self-transfer to mine a fresh L1 block, so warped time takes effect. */ + waitL1Block: () => Promise; + /** Reads the current round's leading payload and its signal count. */ + govInfo: () => Promise; + /** Warps L1 to the start of the next governance-proposer round. */ + warpToNextRound: () => Promise; + /** Polls once per L2 slot until the round leader reaches quorum, returning the round state at that point. */ + waitForQuorum: (quorumSize: bigint, timeoutSeconds: number) => Promise; + /** Submits the winning payload of `round` to the governance proposer, creating the on-chain proposal. */ + submitRoundWinner: (round: bigint) => Promise; + /** + * Warps past the proposal's voting delay, casts a yes vote (asserting it lands), then warps past the + * voting duration and execution delay so the proposal becomes executable. + */ + voteToExecutable: () => Promise; +} + +/** Builds a {@link GovernanceTestDriver} over the context's L1 governance contracts. */ +export async function createGovernanceTestDriver( + test: MultiNodeTestContext, + l1TxUtils: L1TxUtils, +): Promise { + const { l1Client, l1ContractAddresses } = test.context.deployL1ContractsValues; + const rollupAddress = l1ContractAddresses.rollupAddress.toString(); + + const governanceProposer = getContract({ + address: getAddress(l1ContractAddresses.governanceProposerAddress.toString()), + abi: GovernanceProposerAbi, + client: l1Client, + }); + const governance = getContract({ + address: getAddress(l1ContractAddresses.governanceAddress.toString()), + abi: GovernanceAbi, + client: l1Client, + }); + const rollup = new RollupContract(l1Client, l1ContractAddresses.rollupAddress); + const roundSize = await governanceProposer.read.ROUND_SIZE(); + const emperor = l1Client.account; + + const waitL1Block = async () => { + await l1TxUtils.sendAndMonitorTransaction({ to: emperor.address, value: 1n }); + }; + + const govInfo = async (): Promise => { + const bn = await test.context.cheatCodes.eth.blockNumber(); + const slot = await rollup.getSlotNumber(); + const round = await governanceProposer.read.computeRound([BigInt(slot)]); + const info = await governanceProposer.read.getRoundData([rollupAddress, round]); + const leaderVotes = await governanceProposer.read.signalCount([rollupAddress, round, info.payloadWithMostSignals]); + test.logger.info( + `Governance stats for round ${round} (Slot: ${slot}, BN: ${bn}). Leader: ${info.payloadWithMostSignals} have ${leaderVotes} signals`, + ); + return { round, leaderVotes }; + }; + + const warpToNextRound = async () => { + const currentSlot = await rollup.getSlotNumber(); + const nextRoundSlot = SlotNumber.fromBigInt((BigInt(currentSlot) / roundSize) * roundSize + roundSize); + const nextRoundTimestamp = await rollup.getTimestampForSlot(nextRoundSlot); + await test.context.cheatCodes.eth.warp(Number(nextRoundTimestamp)); + }; + + const waitForQuorum = (quorumSize: bigint, timeoutSeconds: number) => + retryUntil( + async () => { + const data = await govInfo(); + return data.leaderVotes >= quorumSize ? data : undefined; + }, + 'governance leader reaches quorum', + timeoutSeconds, + GOVERNANCE_TIMING.aztecSlotDuration, + ); + + const submitRoundWinner = async (round: bigint) => { + await l1TxUtils.sendAndMonitorTransaction({ + to: governanceProposer.address, + data: encodeFunctionData({ abi: GovernanceProposerAbi, functionName: 'submitRoundWinner', args: [round] }), + }); + }; + + const voteToExecutable = async () => { + const proposal = await governance.read.getProposal([0n]); + + const timeToActive = proposal.creation + proposal.config.votingDelay; + await test.context.cheatCodes.eth.warp(Number(timeToActive + 1n)); + await waitL1Block(); + + const voteTx = await rollup.vote(l1TxUtils, 0n); + expect(voteTx.receipt?.status).toBe('success'); + + const timeToExecutable = timeToActive + proposal.config.votingDuration + proposal.config.executionDelay + 1n; + await test.context.cheatCodes.eth.warp(Number(timeToExecutable)); + await waitL1Block(); + }; + + return { + governanceProposer, + governance, + rollup, + roundSize, + emperor, + waitL1Block, + govInfo, + warpToNextRound, + waitForQuorum, + submitRoundWinner, + voteToExecutable, + }; +} + +/** + * Drives one governance round to an executable proposal: waits for the signaled payload to reach quorum, + * warps to the next round, submits the round winner, then votes the resulting proposal through its voting + * and execution delays. Returns the round state observed at quorum; the caller performs the scenario-specific + * execution and assertions. + */ +export async function driveGovernanceRound( + driver: GovernanceTestDriver, + opts: { quorumTimeoutSeconds: number }, +): Promise<{ govData: GovRoundState }> { + const quorumSize = await driver.governanceProposer.read.QUORUM_SIZE(); + const govData = await driver.waitForQuorum(quorumSize, opts.quorumTimeoutSeconds); + await driver.warpToNextRound(); + await driver.waitL1Block(); + await driver.submitRoundWinner(govData.round); + await driver.voteToExecutable(); + return { govData }; +} + export { jest }; diff --git a/yarn-project/end-to-end/src/multi-node/governance/upgrade_governance_proposer.test.ts b/yarn-project/end-to-end/src/multi-node/governance/upgrade_governance_proposer.test.ts index 59bbcc337c55..36a6dd07bf23 100644 --- a/yarn-project/end-to-end/src/multi-node/governance/upgrade_governance_proposer.test.ts +++ b/yarn-project/end-to-end/src/multi-node/governance/upgrade_governance_proposer.test.ts @@ -1,24 +1,16 @@ -import { RollupContract } from '@aztec/ethereum/contracts'; import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; import { L1TxUtils, createL1TxUtils } from '@aztec/ethereum/l1-tx-utils'; -import { SlotNumber } from '@aztec/foundation/branded-types'; -import { retryUntil } from '@aztec/foundation/retry'; import { sleep } from '@aztec/foundation/sleep'; -import { - GovernanceAbi, - GovernanceProposerAbi, - NewGovernanceProposerPayloadAbi, - NewGovernanceProposerPayloadBytecode, -} from '@aztec/l1-artifacts'; +import { NewGovernanceProposerPayloadAbi, NewGovernanceProposerPayloadBytecode } from '@aztec/l1-artifacts'; -import { encodeFunctionData, getAddress, getContract } from 'viem'; +import { getAddress } from 'viem'; import { MOCK_GOSSIP_MULTI_VALIDATOR_OPTS, MultiNodeTestContext, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { GOVERNANCE_TIMING, jest } from './setup.js'; +import { GOVERNANCE_TIMING, createGovernanceTestDriver, driveGovernanceRound, jest } from './setup.js'; // Don't set this to a higher value than 9 because each node will use a different L1 publisher account and anvil seeds const NUM_VALIDATORS = 4; @@ -62,40 +54,12 @@ describe('multi-node/governance/upgrade_governance_proposer', () => { // warps past round boundary, submits the round winner, then drives the full governance lifecycle // (vote, execution delay, execute). Asserts the governance contract's governanceProposer changes. it('should cast votes to upgrade governanceProposer', async () => { - const governanceProposer = getContract({ - address: getAddress( - test.context.deployL1ContractsValues.l1ContractAddresses.governanceProposerAddress.toString(), - ), - abi: GovernanceProposerAbi, - client: test.context.deployL1ContractsValues.l1Client, - }); - - const roundSize = await governanceProposer.read.ROUND_SIZE(); - - const governance = getContract({ - address: getAddress(test.context.deployL1ContractsValues.l1ContractAddresses.governanceAddress.toString()), - abi: GovernanceAbi, - client: test.context.deployL1ContractsValues.l1Client, - }); - - const rollup = new RollupContract( - test.context.deployL1ContractsValues.l1Client, - test.context.deployL1ContractsValues.l1ContractAddresses.rollupAddress, - ); + const driver = await createGovernanceTestDriver(test, l1TxUtils); + const { governance, rollup } = driver; const gseAddress = await rollup.getGSE(); - const waitL1Block = async () => { - await l1TxUtils.sendAndMonitorTransaction({ - to: emperor.address, - value: 1n, - }); - }; - - const currentSlot = await rollup.getSlotNumber(); - const nextRoundSlot = SlotNumber.fromBigInt((BigInt(currentSlot) / roundSize) * roundSize + roundSize); - const nextRoundTimestamp = await rollup.getTimestampForSlot(nextRoundSlot); - await test.context.cheatCodes.eth.warp(Number(nextRoundTimestamp)); + await driver.warpToNextRound(); const { address: newPayloadAddress } = await deployL1Contract( test.context.deployL1ContractsValues.l1Client, @@ -103,34 +67,11 @@ describe('multi-node/governance/upgrade_governance_proposer', () => { NewGovernanceProposerPayloadBytecode, [test.context.deployL1ContractsValues.l1ContractAddresses.registryAddress.toString(), gseAddress.toString()], ); - test.logger.info(`Deployed new payload at ${newPayloadAddress}`); - const emperor = test.context.deployL1ContractsValues.l1Client.account; - - const govInfo = async () => { - const bn = await test.context.cheatCodes.eth.blockNumber(); - const slot = await rollup.getSlotNumber(); - const round = await governanceProposer.read.computeRound([BigInt(slot)]); - - const info = await governanceProposer.read.getRoundData([ - test.context.deployL1ContractsValues.l1ContractAddresses.rollupAddress.toString(), - round, - ]); - const leaderVotes = await governanceProposer.read.signalCount([ - test.context.deployL1ContractsValues.l1ContractAddresses.rollupAddress.toString(), - round, - info.payloadWithMostSignals, - ]); - test.logger.info( - `Governance stats for round ${round} (Slot: ${slot}, BN: ${bn}). Leader: ${info.payloadWithMostSignals} have ${leaderVotes} signals`, - ); - return { bn, slot, round, info, leaderVotes }; - }; - - await waitL1Block(); + await driver.waitL1Block(); - const govBefore = await govInfo(); + const govBefore = await driver.govInfo(); test.logger.info('Creating nodes'); // Nodes are torn down by test.teardown(); they only need to be running to signal the payload. @@ -143,78 +84,29 @@ describe('multi-node/governance/upgrade_governance_proposer', () => { await sleep(4000); test.logger.info('Start progressing time to cast signals'); - const quorumSize = await governanceProposer.read.QUORUM_SIZE(); - test.logger.info(`Quorum size: ${quorumSize}, round size: ${await governanceProposer.read.ROUND_SIZE()}`); - - const govData = await retryUntil( - async () => { - const data = await govInfo(); - return data.leaderVotes >= quorumSize ? data : undefined; - }, - 'quorum of signals', - Number(quorumSize) * GOVERNANCE_TIMING.aztecSlotDuration * 3, - GOVERNANCE_TIMING.aztecSlotDuration, - ); - - expect(govData.leaderVotes).toBeGreaterThan(govBefore.leaderVotes); - - const currentSlot2 = await rollup.getSlotNumber(); - const nextRoundSlot2 = SlotNumber.fromBigInt((BigInt(currentSlot2) / roundSize) * roundSize + roundSize); - const nextRoundTimestamp2 = await rollup.getTimestampForSlot(nextRoundSlot2); - test.logger.info(`Warping to ${nextRoundTimestamp2}`); - await test.context.cheatCodes.eth.warp(Number(nextRoundTimestamp2)); + const quorumSize = await driver.governanceProposer.read.QUORUM_SIZE(); + test.logger.info(`Quorum size: ${quorumSize}, round size: ${driver.roundSize}`); - await waitL1Block(); - - test.logger.info(`Submitting winner of round ${govData.round}`); - - await l1TxUtils.sendAndMonitorTransaction({ - to: governanceProposer.address, - data: encodeFunctionData({ - abi: GovernanceProposerAbi, - functionName: 'submitRoundWinner', - args: [govData.round], - }), + const { govData } = await driveGovernanceRound(driver, { + quorumTimeoutSeconds: Number(quorumSize) * GOVERNANCE_TIMING.aztecSlotDuration * 3, }); - test.logger.info(`Submitted winner of round ${govData.round}`); - - const proposal = await governance.read.getProposal([0n]); - - const timeToActive = proposal.creation + proposal.config.votingDelay; - test.logger.info(`Warping to ${timeToActive + 1n}`); - await test.context.cheatCodes.eth.warp(Number(timeToActive + 1n)); - test.logger.info(`Warped to ${timeToActive + 1n}`); - await waitL1Block(); - - test.logger.info(`Voting`); - const voteTx = await rollup.vote(l1TxUtils, 0n); - expect(voteTx.receipt?.status).toBe('success'); - test.logger.info(`Voted`); - - const proposalState = await governance.read.getProposal([0n]); - test.logger.info(`Proposal state`, proposalState); + expect(govData.leaderVotes).toBeGreaterThan(govBefore.leaderVotes); - const timeToExecutable = timeToActive + proposal.config.votingDuration + proposal.config.executionDelay + 1n; - test.logger.info(`Warping to ${timeToExecutable}`); - await test.context.cheatCodes.eth.warp(Number(timeToExecutable)); - test.logger.info(`Warped to ${timeToExecutable}`); - await waitL1Block(); + const governanceProposerAddress = getAddress( + test.context.deployL1ContractsValues.l1ContractAddresses.governanceProposerAddress.toString(), + ); test.logger.info(`Checking governance proposer`); - expect(await governance.read.governanceProposer()).toEqual( - getAddress(test.context.deployL1ContractsValues.l1ContractAddresses.governanceProposerAddress.toString()), - ); + expect(await governance.read.governanceProposer()).toEqual(governanceProposerAddress); test.logger.info(`Governance proposer is correct`); test.logger.info(`Executing proposal`); - const executeTx = await governance.write.execute([0n], { account: emperor }); + const executeTx = await governance.write.execute([0n], { account: driver.emperor }); await test.context.deployL1ContractsValues.l1Client.waitForTransactionReceipt({ hash: executeTx }); test.logger.info(`Executed proposal`); const newGovernanceProposer = await governance.read.governanceProposer(); - expect(newGovernanceProposer).not.toEqual( - getAddress(test.context.deployL1ContractsValues.l1ContractAddresses.governanceProposerAddress.toString()), - ); + expect(newGovernanceProposer).not.toEqual(governanceProposerAddress); expect(await governance.read.getProposalState([0n])).toEqual(5); test.logger.info(`Governance proposer is correct`); }, 1_000_000); diff --git a/yarn-project/end-to-end/src/multi-node/invalid-attestations/invalidate_block.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/invalid-attestations/invalidate_block.parallel.test.ts index d8c069fb2ac2..03557d7eed81 100644 --- a/yarn-project/end-to-end/src/multi-node/invalid-attestations/invalidate_block.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/invalid-attestations/invalidate_block.parallel.test.ts @@ -40,6 +40,53 @@ const VALIDATOR_COUNT = 6; const BASE_ANVIL_PORT = getAnvilPort(); +/** Sequencer-config overrides that force (`attackConfig`) then clear (`disableConfig`) one invalid-attestations attack. */ +interface InvalidationCase { + attackConfig: Record; + disableConfig: Record; +} + +// Attack configs shared by the runInvalidationTest / runDoubleInvalidationTest its below: each turns on one +// (or more) malicious sequencer flag to force an invalid checkpoint, then flips the same flags off to recover. +const INVALIDATION_CASES = { + insufficientAttestations: { + attackConfig: { skipCollectingAttestations: true }, + disableConfig: { skipCollectingAttestations: false }, + }, + fakeAttestation: { + attackConfig: { injectFakeAttestation: true }, + disableConfig: { injectFakeAttestation: false }, + }, + highSValue: { + attackConfig: { injectHighSValueAttestation: true }, + disableConfig: { injectHighSValueAttestation: false }, + }, + unrecoverableSignature: { + attackConfig: { injectUnrecoverableSignatureAttestation: true }, + disableConfig: { injectUnrecoverableSignatureAttestation: false }, + }, + shuffledAttestations: { + attackConfig: { shuffleAttestationOrdering: true }, + disableConfig: { shuffleAttestationOrdering: false }, + }, + withheldBlob: { + // skipCollectingAttestations makes the checkpoint invalid; skipBroadcastProposals withholds the p2p + // proposal so peers only see it on L1; skipPushProposedBlocksToArchiver denies even the proposer's own + // archiver a local copy — otherwise it could promote that copy, skip the blob fetch, detect the bad + // attestations and invalidate without ever exercising the calldata-first path, masking a regression. + attackConfig: { + skipCollectingAttestations: true, + skipBroadcastProposals: true, + skipPushProposedBlocksToArchiver: true, + }, + disableConfig: { + skipCollectingAttestations: false, + skipBroadcastProposals: false, + skipPushProposedBlocksToArchiver: false, + }, + }, +} satisfies Record; + // Six-validator suite (one key per node) exercising checkpoint invalidation paths. All nodes use // a mocked gossip bus. The setup injects bad configs (insufficient attestations, fake/high-s/ // unrecoverable signatures, shuffled attestations, parent-validity bypasses) to force invalid @@ -778,56 +825,36 @@ describe('multi-node/invalid-attestations/invalidate_block', () => { // Slot S: Checkpoint N is proposed with invalid attestations // Slot S+1: Checkpoint N is invalidated, and checkpoint N' (same number) is proposed instead, but also has invalid attestations // Slot S+2: Proposer tries to invalidate checkpoint N, when they should invalidate checkpoint N' instead, and fails - it('chain progresses if a checkpoint with insufficient attestations is invalidated with an invalid one', async () => { - await runDoubleInvalidationTest({ - attackConfig: { skipCollectingAttestations: true }, - disableConfig: { skipCollectingAttestations: false }, - }); - }); + it('chain progresses if a checkpoint with insufficient attestations is invalidated with an invalid one', () => + runDoubleInvalidationTest(INVALIDATION_CASES.insufficientAttestations)); // Same double-invalidation scenario as above but using injectFakeAttestation instead of // skipCollectingAttestations. Regression for a London Q4-2025 attack vector. // Regression for Joe's Q42025 London attack. Same as above but with an invalid signature instead of insufficient ones. - it('chain progresses if a checkpoint with an invalid attestation is invalidated with an invalid one', async () => { - await runDoubleInvalidationTest({ - attackConfig: { injectFakeAttestation: true }, - disableConfig: { injectFakeAttestation: false }, - }); - }); + it('chain progresses if a checkpoint with an invalid attestation is invalidated with an invalid one', () => + runDoubleInvalidationTest(INVALIDATION_CASES.fakeAttestation)); // Injects a high-s ECDSA signature (rejected by L1 OpenZeppelin ECDSA but valid offchain), // waits for the resulting bad checkpoint, then verifies a good proposer invalidates it. // Regression for A-71: Ensure the node correctly invalidates checkpoints where an attestation has a malleable // signature (high-s value). The Rollup contract uses OpenZeppelin's ECDSA recover which rejects high-s values // per EIP-2, so these signatures recover to address(0) on L1 but may succeed offchain. - it('proposer invalidates checkpoint with high-s value attestation', async () => { - await runInvalidationTest({ - attackConfig: { injectHighSValueAttestation: true }, - disableConfig: { injectHighSValueAttestation: false }, - }); - }); + it('proposer invalidates checkpoint with high-s value attestation', () => + runInvalidationTest(INVALIDATION_CASES.highSValue)); // Injects an unrecoverable signature (e.g. r=0; ecrecover returns address(0) on L1). // Waits for the bad checkpoint then verifies a good proposer invalidates it. Regression for A-71. // Regression for A-71: Ensure the node correctly invalidates checkpoints where an attestation's signature // cannot be recovered (e.g. r=0). On L1, ecrecover returns address(0) for such signatures. - it('proposer invalidates checkpoint with unrecoverable signature attestation', async () => { - await runInvalidationTest({ - attackConfig: { injectUnrecoverableSignatureAttestation: true }, - disableConfig: { injectUnrecoverableSignatureAttestation: false }, - }); - }); + it('proposer invalidates checkpoint with unrecoverable signature attestation', () => + runInvalidationTest(INVALIDATION_CASES.unrecoverableSignature)); // Injects shuffled attestation ordering (accepted offchain but rejected by L1 which requires the // committee order). Waits for the bad checkpoint then verifies a good proposer invalidates it. // Regression for the node accepting attestations that did not conform to the committee order, // but L1 requires the same ordering. See #18219. - it('proposer invalidates previous block with shuffled attestations', async () => { - await runInvalidationTest({ - attackConfig: { shuffleAttestationOrdering: true }, - disableConfig: { shuffleAttestationOrdering: false }, - }); - }); + it('proposer invalidates previous block with shuffled attestations', () => + runInvalidationTest(INVALIDATION_CASES.shuffledAttestations)); // A checkpoint with invalid attestations must be rejected from L1 calldata, before its blob is fetched. // The attack forces the bad checkpoint to be reachable only from L1 calldata — no blob, no local blocks @@ -842,22 +869,7 @@ describe('multi-node/invalid-attestations/invalidate_block', () => { jest.spyOn(node.getBlobClient()!, 'sendBlobsToFilestore').mockResolvedValue(false), ); - await runInvalidationTest({ - // skipCollectingAttestations makes the checkpoint invalid; skipBroadcastProposals withholds the p2p - // proposal so peers only see it on L1; skipPushProposedBlocksToArchiver denies even the proposer's own - // archiver a local copy — otherwise it could promote that copy, skip the blob fetch, detect the bad - // attestations and invalidate without ever exercising the calldata-first path, masking a regression. - attackConfig: { - skipCollectingAttestations: true, - skipBroadcastProposals: true, - skipPushProposedBlocksToArchiver: true, - }, - disableConfig: { - skipCollectingAttestations: false, - skipBroadcastProposals: false, - skipPushProposedBlocksToArchiver: false, - }, - }); + await runInvalidationTest(INVALIDATION_CASES.withheldBlob); // The bad checkpoint's blob upload was intercepted, so it never reached the shared store: the // invalidation above proves a proposer rejected it from L1 calldata without ever fetching its blob. diff --git a/yarn-project/end-to-end/src/multi-node/recovery/pipeline_prune.test.ts b/yarn-project/end-to-end/src/multi-node/recovery/pipeline_prune.test.ts index 296aa8e06537..9ed928bbdddd 100644 --- a/yarn-project/end-to-end/src/multi-node/recovery/pipeline_prune.test.ts +++ b/yarn-project/end-to-end/src/multi-node/recovery/pipeline_prune.test.ts @@ -1,33 +1,22 @@ -import type { Archiver } from '@aztec/archiver'; -import type { AztecNodeService } from '@aztec/aztec-node'; -import type { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses'; +import type { EthAddress } from '@aztec/aztec.js/addresses'; import { Fr } from '@aztec/aztec.js/fields'; -import type { Logger } from '@aztec/aztec.js/log'; import { waitForTx } from '@aztec/aztec.js/node'; import type { EpochCacheInterface } from '@aztec/epoch-cache'; -import { asyncMap } from '@aztec/foundation/async-map'; import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { executeTimeout } from '@aztec/foundation/timer'; -import type { TestContract } from '@aztec/noir-test-contracts.js/Test'; import type { SequencerEvents } from '@aztec/sequencer-client'; import { L2BlockSourceEvents } from '@aztec/stdlib/block'; -import { jest } from '@jest/globals'; - -import type { EndToEndContext } from '../../fixtures/utils.js'; -import type { TestWallet } from '../../test-wallet/test_wallet.js'; import { proveAndSendTxs } from '../../test-wallet/utils.js'; import { + type BlockProductionWithProverFixture, type BlockProposedEvent, - MultiNodeTestContext, - type RegisteredValidator, - WIDE_SLOT_TIMING, - buildMockGossipValidators, -} from '../multi_node_test_context.js'; + jest, + setupBlockProductionWithProver, +} from '../block-production/setup.js'; jest.setTimeout(1000 * 60 * 20); -const NODE_COUNT = 4; const EXPECTED_BLOCKS_PER_CHECKPOINT = 8; // Send enough transactions to trigger multiple blocks within a checkpoint assuming 2 txs per block. @@ -45,68 +34,11 @@ const TX_COUNT = 34; * with mockGossipSubNetwork and no initial sequencer. */ describe('multi-node/recovery/pipeline_prune', () => { - let context: EndToEndContext; - let logger: Logger; - let archiver: Archiver; - - let test: MultiNodeTestContext; - let validators: RegisteredValidator[]; - let nodes: AztecNodeService[]; - let contract: TestContract; - let wallet: TestWallet; - let from: AztecAddress; - - /** Creates validators and sets up the test context with MBPS and proposer pipelining. */ - async function setupTest(opts: { - syncChainTip: 'proposed' | 'checkpointed'; - minTxsPerBlock?: number; - maxTxsPerBlock?: number; - }) { - const { syncChainTip = 'checkpointed', ...setupOpts } = opts; - - validators = buildMockGossipValidators(NODE_COUNT); - - test = await MultiNodeTestContext.setup({ - ...WIDE_SLOT_TIMING, - numberOfAccounts: 0, - initialValidators: validators, - mockGossipSubNetwork: true, - mockGossipSubNetworkLatency: 500, // adverse network conditions - startProverNode: true, - maxTxsPerCheckpoint: 24, - inboxLag: 2, - ...setupOpts, - pxeOpts: { syncChainTip }, - skipInitialSequencer: true, - }); - - ({ context, logger } = test); - wallet = context.wallet as TestWallet; - from = context.accounts[0]; // auto-created by setup - - logger.warn(`Initial setup complete. Starting ${NODE_COUNT} validator nodes.`); - // Clear inherited coinbase so each validator derives coinbase from its own attester key - nodes = await asyncMap(validators, ({ privateKey }, i) => - test.createValidatorNode([privateKey], { - dontStartSequencer: true, - coinbase: undefined, - // Disable checkpoint promotion on the first node so it always fetches blobs, - // allowing us to assert that other nodes skip blob fetching via promotion. - ...(i === 0 ? { skipPromoteProposedCheckpointDuringL1Sync: true } : {}), - }), - ); - logger.warn(`Started ${NODE_COUNT} validator nodes.`, { validators: validators.map(v => v.attester.toString()) }); - - wallet.updateNode(nodes[0]); - archiver = nodes[0].getBlockSource() as Archiver; - - contract = await test.registerTestContract(wallet); - logger.warn(`Test setup completed.`, { validators: validators.map(v => v.attester.toString()) }); - } + let fixture: BlockProductionWithProverFixture; afterEach(async () => { jest.restoreAllMocks(); - await test?.teardown(); + await fixture?.test?.teardown(); }); // Establishes a baseline at checkpoint 1. Identifies the next proposer and disables its @@ -114,7 +46,18 @@ describe('multi-node/recovery/pipeline_prune', () => { // re-enables publishing. Waits for all txs to be mined, asserts a MBPS checkpoint exists, // verifies the pipelining offset, and checks recovery blockNumber > baseline. it('prunes uncheckpointed blocks when proposer fails to deliver', async () => { - await setupTest({ syncChainTip: 'checkpointed', minTxsPerBlock: 1, maxTxsPerBlock: 2 }); + // Same wide-slot prover-backed cluster as block-production, under adverse gossip latency with node 0's + // checkpoint promotion disabled (see setupBlockProductionWithProver). + fixture = await setupBlockProductionWithProver({ + syncChainTip: 'checkpointed', + minTxsPerBlock: 1, + maxTxsPerBlock: 2, + maxTxsPerCheckpoint: 24, + mockGossipSubNetworkLatency: 500, + clearInheritedCoinbase: true, + disableCheckpointPromotionOnFirstNode: true, + }); + const { test, context, logger, archiver, validators, nodes, contract, from } = fixture; const blockProposedEvents: BlockProposedEvent[] = []; const sequencers = test.getSequencers(nodes); From 60e33b00c07efc03f9d783d21c24a7e3d4f9ecaa Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 3 Jul 2026 19:08:15 -0300 Subject: [PATCH 2/2] test: restore inline attack configs in invalidate_block --- .../invalidate_block.parallel.test.ts | 104 ++++++++---------- 1 file changed, 46 insertions(+), 58 deletions(-) diff --git a/yarn-project/end-to-end/src/multi-node/invalid-attestations/invalidate_block.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/invalid-attestations/invalidate_block.parallel.test.ts index 03557d7eed81..d8c069fb2ac2 100644 --- a/yarn-project/end-to-end/src/multi-node/invalid-attestations/invalidate_block.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/invalid-attestations/invalidate_block.parallel.test.ts @@ -40,53 +40,6 @@ const VALIDATOR_COUNT = 6; const BASE_ANVIL_PORT = getAnvilPort(); -/** Sequencer-config overrides that force (`attackConfig`) then clear (`disableConfig`) one invalid-attestations attack. */ -interface InvalidationCase { - attackConfig: Record; - disableConfig: Record; -} - -// Attack configs shared by the runInvalidationTest / runDoubleInvalidationTest its below: each turns on one -// (or more) malicious sequencer flag to force an invalid checkpoint, then flips the same flags off to recover. -const INVALIDATION_CASES = { - insufficientAttestations: { - attackConfig: { skipCollectingAttestations: true }, - disableConfig: { skipCollectingAttestations: false }, - }, - fakeAttestation: { - attackConfig: { injectFakeAttestation: true }, - disableConfig: { injectFakeAttestation: false }, - }, - highSValue: { - attackConfig: { injectHighSValueAttestation: true }, - disableConfig: { injectHighSValueAttestation: false }, - }, - unrecoverableSignature: { - attackConfig: { injectUnrecoverableSignatureAttestation: true }, - disableConfig: { injectUnrecoverableSignatureAttestation: false }, - }, - shuffledAttestations: { - attackConfig: { shuffleAttestationOrdering: true }, - disableConfig: { shuffleAttestationOrdering: false }, - }, - withheldBlob: { - // skipCollectingAttestations makes the checkpoint invalid; skipBroadcastProposals withholds the p2p - // proposal so peers only see it on L1; skipPushProposedBlocksToArchiver denies even the proposer's own - // archiver a local copy — otherwise it could promote that copy, skip the blob fetch, detect the bad - // attestations and invalidate without ever exercising the calldata-first path, masking a regression. - attackConfig: { - skipCollectingAttestations: true, - skipBroadcastProposals: true, - skipPushProposedBlocksToArchiver: true, - }, - disableConfig: { - skipCollectingAttestations: false, - skipBroadcastProposals: false, - skipPushProposedBlocksToArchiver: false, - }, - }, -} satisfies Record; - // Six-validator suite (one key per node) exercising checkpoint invalidation paths. All nodes use // a mocked gossip bus. The setup injects bad configs (insufficient attestations, fake/high-s/ // unrecoverable signatures, shuffled attestations, parent-validity bypasses) to force invalid @@ -825,36 +778,56 @@ describe('multi-node/invalid-attestations/invalidate_block', () => { // Slot S: Checkpoint N is proposed with invalid attestations // Slot S+1: Checkpoint N is invalidated, and checkpoint N' (same number) is proposed instead, but also has invalid attestations // Slot S+2: Proposer tries to invalidate checkpoint N, when they should invalidate checkpoint N' instead, and fails - it('chain progresses if a checkpoint with insufficient attestations is invalidated with an invalid one', () => - runDoubleInvalidationTest(INVALIDATION_CASES.insufficientAttestations)); + it('chain progresses if a checkpoint with insufficient attestations is invalidated with an invalid one', async () => { + await runDoubleInvalidationTest({ + attackConfig: { skipCollectingAttestations: true }, + disableConfig: { skipCollectingAttestations: false }, + }); + }); // Same double-invalidation scenario as above but using injectFakeAttestation instead of // skipCollectingAttestations. Regression for a London Q4-2025 attack vector. // Regression for Joe's Q42025 London attack. Same as above but with an invalid signature instead of insufficient ones. - it('chain progresses if a checkpoint with an invalid attestation is invalidated with an invalid one', () => - runDoubleInvalidationTest(INVALIDATION_CASES.fakeAttestation)); + it('chain progresses if a checkpoint with an invalid attestation is invalidated with an invalid one', async () => { + await runDoubleInvalidationTest({ + attackConfig: { injectFakeAttestation: true }, + disableConfig: { injectFakeAttestation: false }, + }); + }); // Injects a high-s ECDSA signature (rejected by L1 OpenZeppelin ECDSA but valid offchain), // waits for the resulting bad checkpoint, then verifies a good proposer invalidates it. // Regression for A-71: Ensure the node correctly invalidates checkpoints where an attestation has a malleable // signature (high-s value). The Rollup contract uses OpenZeppelin's ECDSA recover which rejects high-s values // per EIP-2, so these signatures recover to address(0) on L1 but may succeed offchain. - it('proposer invalidates checkpoint with high-s value attestation', () => - runInvalidationTest(INVALIDATION_CASES.highSValue)); + it('proposer invalidates checkpoint with high-s value attestation', async () => { + await runInvalidationTest({ + attackConfig: { injectHighSValueAttestation: true }, + disableConfig: { injectHighSValueAttestation: false }, + }); + }); // Injects an unrecoverable signature (e.g. r=0; ecrecover returns address(0) on L1). // Waits for the bad checkpoint then verifies a good proposer invalidates it. Regression for A-71. // Regression for A-71: Ensure the node correctly invalidates checkpoints where an attestation's signature // cannot be recovered (e.g. r=0). On L1, ecrecover returns address(0) for such signatures. - it('proposer invalidates checkpoint with unrecoverable signature attestation', () => - runInvalidationTest(INVALIDATION_CASES.unrecoverableSignature)); + it('proposer invalidates checkpoint with unrecoverable signature attestation', async () => { + await runInvalidationTest({ + attackConfig: { injectUnrecoverableSignatureAttestation: true }, + disableConfig: { injectUnrecoverableSignatureAttestation: false }, + }); + }); // Injects shuffled attestation ordering (accepted offchain but rejected by L1 which requires the // committee order). Waits for the bad checkpoint then verifies a good proposer invalidates it. // Regression for the node accepting attestations that did not conform to the committee order, // but L1 requires the same ordering. See #18219. - it('proposer invalidates previous block with shuffled attestations', () => - runInvalidationTest(INVALIDATION_CASES.shuffledAttestations)); + it('proposer invalidates previous block with shuffled attestations', async () => { + await runInvalidationTest({ + attackConfig: { shuffleAttestationOrdering: true }, + disableConfig: { shuffleAttestationOrdering: false }, + }); + }); // A checkpoint with invalid attestations must be rejected from L1 calldata, before its blob is fetched. // The attack forces the bad checkpoint to be reachable only from L1 calldata — no blob, no local blocks @@ -869,7 +842,22 @@ describe('multi-node/invalid-attestations/invalidate_block', () => { jest.spyOn(node.getBlobClient()!, 'sendBlobsToFilestore').mockResolvedValue(false), ); - await runInvalidationTest(INVALIDATION_CASES.withheldBlob); + await runInvalidationTest({ + // skipCollectingAttestations makes the checkpoint invalid; skipBroadcastProposals withholds the p2p + // proposal so peers only see it on L1; skipPushProposedBlocksToArchiver denies even the proposer's own + // archiver a local copy — otherwise it could promote that copy, skip the blob fetch, detect the bad + // attestations and invalidate without ever exercising the calldata-first path, masking a regression. + attackConfig: { + skipCollectingAttestations: true, + skipBroadcastProposals: true, + skipPushProposedBlocksToArchiver: true, + }, + disableConfig: { + skipCollectingAttestations: false, + skipBroadcastProposals: false, + skipPushProposedBlocksToArchiver: false, + }, + }); // The bad checkpoint's blob upload was intercepted, so it never reached the shared store: the // invalidation above proves a proposer rejected it from L1 calldata without ever fetching its blob.