From 2a3210dc15f08da9af9edb3150512984aaa1ceb9 Mon Sep 17 00:00:00 2001 From: spypsy <6403450+spypsy@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:12:46 +0000 Subject: [PATCH 01/25] fix: limit offenses when voting in tally slashing mode by slashMaxPayloadSize Fixes [A-507](https://linear.app/aztec-labs/issue/A-507/tally-slasher-execution-can-run-out-of-gas) --- spartan/environments/network-defaults.yml | 2 +- yarn-project/slasher/README.md | 2 +- .../slasher/src/tally_slasher_client.test.ts | 100 ++++++++++++++++++ .../slasher/src/tally_slasher_client.ts | 19 +++- 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/spartan/environments/network-defaults.yml b/spartan/environments/network-defaults.yml index 9291bc82795c..a270e8a7612f 100644 --- a/spartan/environments/network-defaults.yml +++ b/spartan/environments/network-defaults.yml @@ -120,7 +120,7 @@ slasher: &slasher # Rounds after which an offense expires. SLASH_OFFENSE_EXPIRATION_ROUNDS: 4 # Maximum size of slashing payload. - SLASH_MAX_PAYLOAD_SIZE: 50 + SLASH_MAX_PAYLOAD_SIZE: 80 # Rounds to look back when executing slashes. SLASH_EXECUTE_ROUNDS_LOOK_BACK: 4 # Penalty for slashing validators of a valid pruned epoch. diff --git a/yarn-project/slasher/README.md b/yarn-project/slasher/README.md index dbd4454cf1eb..b5270720fa9a 100644 --- a/yarn-project/slasher/README.md +++ b/yarn-project/slasher/README.md @@ -185,7 +185,7 @@ These settings are configured locally on each validator node: - `slashProposeInvalidAttestationsPenalty`: Penalty for PROPOSED_INSUFFICIENT_ATTESTATIONS and PROPOSED_INCORRECT_ATTESTATIONS - `slashAttestDescendantOfInvalidPenalty`: Penalty for ATTESTED_DESCENDANT_OF_INVALID - `slashUnknownPenalty`: Default penalty for unknown offense types -- `slashMaxPayloadSize`: Maximum size of slash payloads (empire model) +- `slashMaxPayloadSize`: Maximum size of slash payloads. In the empire model this limits offenses per payload; in the tally model it limits offenses considered when building the vote for a round (same prioritization: uncontroversial first, then by amount and age), so that execution payload stays within gas limits. - `slashMinPenaltyPercentage`: Agree to slashes if they are at least this percentage of the configured penalty (empire model) - `slashMaxPenaltyPercentage`: Agree to slashes if they are at most this percentage of the configured penalty (empire model) diff --git a/yarn-project/slasher/src/tally_slasher_client.test.ts b/yarn-project/slasher/src/tally_slasher_client.test.ts index 9ca14eaa7a44..95bab349286a 100644 --- a/yarn-project/slasher/src/tally_slasher_client.test.ts +++ b/yarn-project/slasher/src/tally_slasher_client.test.ts @@ -519,6 +519,106 @@ describe('TallySlasherClient', () => { expect(actions).toHaveLength(1); expect(actions[0].type).toBe('vote-offenses'); }); + + it('should truncate to slashMaxPayloadSize when offenses exceed cap', async () => { + const currentRound = 5n; + const targetRound = 3n; // currentRound - offset(2) + const baseSlot = targetRound * BigInt(roundSize); + + // Set cap to 2 so we keep only the top 2 offenses by priority (uncontroversial first, then amount desc) + tallySlasherClient.updateConfig({ slashMaxPayloadSize: 2 }); + + // Add 3 offenses for target round: different amounts so sort order is clear (high amount first) + await addPendingOffense({ + validator: committee[0], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[0], // 1 unit - lowest priority + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[1], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[2], // 3 units - highest priority + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[2], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[1], // 2 units - middle + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + const offenses = await tallySlasherClient.gatherOffensesForRound(currentRound); + + expect(offenses).toHaveLength(2); + // First should be committee[1] (3 units), second committee[2] (2 units); committee[0] (1 unit) truncated + expect(offenses[0].validator.equals(committee[1])).toBe(true); + expect(offenses[0].amount).toEqual(settings.slashingAmounts[2]); + expect(offenses[1].validator.equals(committee[2])).toBe(true); + expect(offenses[1].amount).toEqual(settings.slashingAmounts[1]); + }); + + it('should not truncate when offenses are within cap', async () => { + const currentRound = 5n; + const targetRound = 3n; + const baseSlot = targetRound * BigInt(roundSize); + + tallySlasherClient.updateConfig({ slashMaxPayloadSize: 10 }); + + await addPendingOffense({ + validator: committee[0], + epochOrSlot: baseSlot, + amount: slashingUnit, + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[1], + epochOrSlot: baseSlot, + amount: slashingUnit * 2n, + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + const offenses = await tallySlasherClient.gatherOffensesForRound(currentRound); + expect(offenses).toHaveLength(2); + }); + + it('should produce a valid vote action with truncated offenses', async () => { + const currentRound = 5n; + const targetRound = 3n; + const baseSlot = targetRound * BigInt(roundSize); + + tallySlasherClient.updateConfig({ slashMaxPayloadSize: 1 }); + + // Add 3 offenses, only the highest-amount one should survive truncation + await addPendingOffense({ + validator: committee[0], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[0], + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[1], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[2], + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[2], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[1], + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + const currentSlot = currentRound * BigInt(roundSize); + const action = await tallySlasherClient.getVoteOffensesAction(SlotNumber.fromBigInt(currentSlot)); + + expect(action).toBeDefined(); + assert(action!.type === 'vote-offenses'); + // Only committee[1] (3 units) should have a non-zero vote + expect(action!.votes[0]).toBe(0); // committee[0] truncated + expect(action!.votes[1]).toBe(3); // committee[1] kept (highest amount) + expect(action!.votes[2]).toBe(0); // committee[2] truncated + }); }); describe('getSlashPayloads', () => { diff --git a/yarn-project/slasher/src/tally_slasher_client.ts b/yarn-project/slasher/src/tally_slasher_client.ts index 70ef6fdfeeb6..c5533cc6858f 100644 --- a/yarn-project/slasher/src/tally_slasher_client.ts +++ b/yarn-project/slasher/src/tally_slasher_client.ts @@ -16,6 +16,7 @@ import { type SlashPayloadRound, getEpochsForRound, getSlashConsensusVotesFromOffenses, + offenseDataComparator, } from '@aztec/stdlib/slashing'; import type { Hex } from 'viem'; @@ -46,7 +47,10 @@ export type TallySlasherSettings = Prettify< >; export type TallySlasherClientConfig = SlashOffensesCollectorConfig & - Pick; + Pick< + SlasherConfig, + 'slashValidatorsAlways' | 'slashValidatorsNever' | 'slashExecuteRoundsLookBack' | 'slashMaxPayloadSize' + >; /** * The Tally Slasher client is responsible for managing slashable offenses using @@ -415,8 +419,10 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC /** * Gather offenses to be slashed on a given round. * In tally slashing, round N slashes validators from round N - slashOffsetInRounds. + * Offenses are sorted by priority (uncontroversial first, then amount, then age) and truncated to + * slashMaxPayloadSize so that execution payload stays within gas limits. * @param round - The round to get offenses for, defaults to current round - * @returns Array of pending offenses for the round with offset applied + * @returns Array of pending offenses for the round with offset applied, truncated to max payload size */ public async gatherOffensesForRound(round?: bigint): Promise { const targetRound = this.getSlashedRound(round); @@ -424,7 +430,14 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC return []; } - return await this.offensesStore.getOffensesForRound(targetRound); + const raw = await this.offensesStore.getOffensesForRound(targetRound); + const sorted = [...raw].sort(offenseDataComparator); + const { slashMaxPayloadSize } = this.config; + const selected = sorted.slice(0, slashMaxPayloadSize); + if (selected.length !== sorted.length) { + this.log.warn(`Offense list of ${sorted.length} truncated to max size of ${slashMaxPayloadSize}`); + } + return selected; } /** Returns all pending offenses stored */ From 13e99abaeca641be536bc6d5716a033eae0133b4 Mon Sep 17 00:00:00 2001 From: spypsy Date: Thu, 26 Feb 2026 14:26:34 +0000 Subject: [PATCH 02/25] fix: track last seen nonce in case of stale fallback L1 RPC node (#20855) Follow-up to #20819 --- .../src/l1_tx_utils/l1_tx_utils.test.ts | 25 ++++++++++++++++++- .../ethereum/src/l1_tx_utils/l1_tx_utils.ts | 15 ++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.test.ts b/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.test.ts index 828a351bf5fb..593dd1831a3d 100644 --- a/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.test.ts +++ b/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.test.ts @@ -147,7 +147,7 @@ describe('L1TxUtils', () => { address: l1Client.account.address, }); - // Next send fails at sendRawTransaction (e.g. network error) + // Next send fails at sendRawTransaction (e.g. network error / 429) const originalSendRawTransaction = l1Client.sendRawTransaction.bind(l1Client); using _sendSpy = jest .spyOn(l1Client, 'sendRawTransaction') @@ -163,6 +163,29 @@ describe('L1TxUtils', () => { expect((await l1Client.getTransaction({ hash: txHash })).nonce).toBe(expectedNonce); }, 30_000); + it('bumps nonce when getTransactionCount returns a stale value after a successful send', async () => { + // Send a successful tx first to advance the chain nonce + await gasUtils.sendAndMonitorTransaction(request); + + const expectedNonce = await l1Client.getTransactionCount({ + blockTag: 'pending', + address: l1Client.account.address, + }); + + // Simulate a stale fallback RPC node that returns the pre-send nonce + const originalGetTransactionCount = l1Client.getTransactionCount.bind(l1Client); + using _spy = jest + .spyOn(l1Client, 'getTransactionCount') + .mockImplementationOnce(() => Promise.resolve(expectedNonce - 1)) // stale: one behind + .mockImplementation(originalGetTransactionCount); + + // Despite the stale count, the send should use lastSentNonce+1 = expectedNonce + const { txHash, state } = await gasUtils.sendTransaction(request); + + expect(state.nonce).toBe(expectedNonce); + expect((await l1Client.getTransaction({ hash: txHash })).nonce).toBe(expectedNonce); + }, 30_000); + // Regression for TMNT-312 it('speed-up of blob tx sets non-zero maxFeePerBlobGas', async () => { await cheatCodes.setAutomine(false); diff --git a/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts b/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts index 48b8dfc41aa5..f6292311fc7e 100644 --- a/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts +++ b/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts @@ -45,6 +45,8 @@ const MAX_L1_TX_STATES = 32; export class L1TxUtils extends ReadOnlyL1TxUtils { protected txs: L1TxState[] = []; + /** Last nonce successfully sent to the chain. Used as a lower bound when a fallback RPC node returns a stale count. */ + private lastSentNonce: number | undefined; /** Tx delayer for testing. Only set when enableDelayer config is true. */ public delayer?: Delayer; /** KZG instance for blob operations. */ @@ -105,6 +107,11 @@ export class L1TxUtils extends ReadOnlyL1TxUtils { this.metrics?.recordMinedTx(l1TxState, new Date(l1Timestamp)); } else if (newState === TxUtilsState.NOT_MINED) { this.metrics?.recordDroppedTx(l1TxState); + // The tx was dropped: the chain nonce reverted to l1TxState.nonce, so our lower bound is + // no longer valid. Clear it so the next send fetches the real nonce from the chain. + if (this.lastSentNonce === l1TxState.nonce) { + this.lastSentNonce = undefined; + } } // Update state in the store @@ -246,7 +253,11 @@ export class L1TxUtils extends ReadOnlyL1TxUtils { ); } - const nonce = await this.client.getTransactionCount({ address: account, blockTag: 'pending' }); + const chainNonce = await this.client.getTransactionCount({ address: account, blockTag: 'pending' }); + // If a fallback RPC node returns a stale count (lower than what we last sent), use our + // local lower bound to avoid sending a duplicate of an already-pending transaction. + const nonce = + this.lastSentNonce !== undefined && chainNonce <= this.lastSentNonce ? this.lastSentNonce + 1 : chainNonce; const baseState = { request, gasLimit, blobInputs, gasPrice, nonce }; const txData = this.makeTxData(baseState, { isCancelTx: false }); @@ -254,6 +265,8 @@ export class L1TxUtils extends ReadOnlyL1TxUtils { // Send the new tx const signedRequest = await this.prepareSignedTransaction(txData); const txHash = await this.client.sendRawTransaction({ serializedTransaction: signedRequest }); + // Update after tx is sent successfully + this.lastSentNonce = nonce; // Create the new state for monitoring const l1TxState: L1TxState = { From 38a2fa3b4894971bb815305ade7829495912d774 Mon Sep 17 00:00:00 2001 From: PhilWindle <60546371+PhilWindle@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:35:50 +0000 Subject: [PATCH 03/25] feat: Validate num txs in block proposals (#20850) This PR introduces additional block/checkpoint proposal validation to ensure `SEQ_MAX_TX_PER_BLOCK` is not breached. Also adds additional tests for existing validation criteria. --------- Co-authored-by: Claude Opus 4.6 --- yarn-project/p2p/src/config.ts | 2 +- .../block_proposal_validator.ts | 2 +- .../checkpoint_proposal_validator.ts | 2 +- .../proposal_validator/proposal_validator.ts | 16 +- .../proposal_validator_test_suite.ts | 145 +++++++++++++++++- .../p2p/src/services/libp2p/libp2p_service.ts | 6 +- yarn-project/sequencer-client/src/config.ts | 8 +- .../stdlib/src/config/sequencer-config.ts | 12 +- .../stdlib/src/interfaces/validator.ts | 3 +- yarn-project/validator-client/src/factory.ts | 1 + .../validator-client/src/validator.ts | 1 + 11 files changed, 183 insertions(+), 15 deletions(-) diff --git a/yarn-project/p2p/src/config.ts b/yarn-project/p2p/src/config.ts index f9f951019191..b7de5cc3038e 100644 --- a/yarn-project/p2p/src/config.ts +++ b/yarn-project/p2p/src/config.ts @@ -38,7 +38,7 @@ export interface P2PConfig ChainConfig, TxCollectionConfig, TxFileStoreConfig, - Pick { + Pick { /** A flag dictating whether the P2P subsystem should be enabled. */ p2pEnabled: boolean; diff --git a/yarn-project/p2p/src/msg_validators/proposal_validator/block_proposal_validator.ts b/yarn-project/p2p/src/msg_validators/proposal_validator/block_proposal_validator.ts index 5de0076aea39..a481256e9f37 100644 --- a/yarn-project/p2p/src/msg_validators/proposal_validator/block_proposal_validator.ts +++ b/yarn-project/p2p/src/msg_validators/proposal_validator/block_proposal_validator.ts @@ -4,7 +4,7 @@ import type { BlockProposal, P2PValidator } from '@aztec/stdlib/p2p'; import { ProposalValidator } from '../proposal_validator/proposal_validator.js'; export class BlockProposalValidator extends ProposalValidator implements P2PValidator { - constructor(epochCache: EpochCacheInterface, opts: { txsPermitted: boolean }) { + constructor(epochCache: EpochCacheInterface, opts: { txsPermitted: boolean; maxTxsPerBlock?: number }) { super(epochCache, opts, 'p2p:block_proposal_validator'); } } diff --git a/yarn-project/p2p/src/msg_validators/proposal_validator/checkpoint_proposal_validator.ts b/yarn-project/p2p/src/msg_validators/proposal_validator/checkpoint_proposal_validator.ts index 763912a04814..74804fe45d21 100644 --- a/yarn-project/p2p/src/msg_validators/proposal_validator/checkpoint_proposal_validator.ts +++ b/yarn-project/p2p/src/msg_validators/proposal_validator/checkpoint_proposal_validator.ts @@ -7,7 +7,7 @@ export class CheckpointProposalValidator extends ProposalValidator implements P2PValidator { - constructor(epochCache: EpochCacheInterface, opts: { txsPermitted: boolean }) { + constructor(epochCache: EpochCacheInterface, opts: { txsPermitted: boolean; maxTxsPerBlock?: number }) { super(epochCache, opts, 'p2p:checkpoint_proposal_validator'); } } diff --git a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts index f6fb7102e537..a926d1f3c144 100644 --- a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts +++ b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts @@ -9,10 +9,16 @@ export abstract class ProposalValidator this.maxTxsPerBlock) { + this.logger.warn( + `Penalizing peer for proposal with ${proposal.txHashes.length} transaction(s) when max is ${this.maxTxsPerBlock}`, + ); + return { result: 'reject', severity: PeerErrorSeverity.MidToleranceError }; + } + // Embedded txs must be listed in txHashes const hashSet = new Set(proposal.txHashes.map(h => h.toString())); const missingTxHashes = diff --git a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator_test_suite.ts b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator_test_suite.ts index 6aa79230f8cd..e58a007a3de7 100644 --- a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator_test_suite.ts +++ b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator_test_suite.ts @@ -1,4 +1,5 @@ import type { EpochCacheInterface } from '@aztec/epoch-cache'; +import { NoCommitteeError } from '@aztec/ethereum/contracts'; import type { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import type { EthAddress } from '@aztec/foundation/eth-address'; import { @@ -9,12 +10,13 @@ import { } from '@aztec/stdlib/p2p'; import type { TxHash } from '@aztec/stdlib/tx'; +import { jest } from '@jest/globals'; import type { MockProxy } from 'jest-mock-extended'; export interface ProposalValidatorTestParams { validatorFactory: ( epochCache: EpochCacheInterface, - opts: { txsPermitted: boolean }, + opts: { txsPermitted: boolean; maxTxsPerBlock?: number }, ) => { validate: (proposal: TProposal) => Promise }; makeProposal: (options?: any) => Promise; makeHeader: (epochNumber: number | bigint, slotNumber: number | bigint, blockNumber: number | bigint) => any; @@ -105,6 +107,26 @@ export function sharedProposalValidatorTests { + const currentProposer = getSigner(); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + }); + + // Override getSender to return undefined (invalid signature) + jest.spyOn(mockProposal as any, 'getSender').mockReturnValue(undefined); + + mockGetProposer(getAddress(currentProposer), getAddress()); + const result = await validator.validate(mockProposal); + expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.MidToleranceError }); + + // Should not try to resolve proposer if signature is invalid + expect(epochCache.getProposerAttesterAddressInSlot).not.toHaveBeenCalled(); + }); + it('returns mid tolerance error if proposer is not current proposer for current slot', async () => { const currentProposer = getSigner(); const nextProposer = getSigner(); @@ -152,6 +174,34 @@ export function sharedProposalValidatorTests { + const currentProposer = getSigner(); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + }); + + epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(undefined); + const result = await validator.validate(mockProposal); + expect(result).toEqual({ result: 'accept' }); + }); + + it('returns low tolerance error when getProposerAttesterAddressInSlot throws NoCommitteeError', async () => { + const currentProposer = getSigner(); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + }); + + epochCache.getProposerAttesterAddressInSlot.mockRejectedValue(new NoCommitteeError()); + const result = await validator.validate(mockProposal); + expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.LowToleranceError }); + }); + it('returns undefined if proposal is valid for current slot and proposer', async () => { const currentProposer = getSigner(); const nextProposer = getSigner(); @@ -226,5 +276,98 @@ export function sharedProposalValidatorTests { + it('returns mid tolerance error if embedded txs are not listed in txHashes', async () => { + const currentProposer = getSigner(); + const txHashes = getTxHashes(2); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + txHashes, + }); + + // Create a fake tx whose hash is NOT in txHashes + const fakeTxHash = getTxHashes(1)[0]; + const fakeTx = { getTxHash: () => fakeTxHash, validateTxHash: () => Promise.resolve(true) }; + Object.defineProperty(mockProposal, 'txs', { get: () => [fakeTx], configurable: true }); + + mockGetProposer(getAddress(currentProposer), getAddress()); + const result = await validator.validate(mockProposal); + expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.MidToleranceError }); + }); + + it('returns low tolerance error if embedded tx has invalid tx hash', async () => { + const currentProposer = getSigner(); + const txHashes = getTxHashes(2); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + txHashes, + }); + + // Create a fake tx whose hash IS in txHashes but validateTxHash returns false + const fakeTx = { getTxHash: () => txHashes[0], validateTxHash: () => Promise.resolve(false) }; + Object.defineProperty(mockProposal, 'txs', { get: () => [fakeTx], configurable: true }); + + mockGetProposer(getAddress(currentProposer), getAddress()); + const result = await validator.validate(mockProposal); + expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.LowToleranceError }); + }); + }); + + describe('maxTxsPerBlock validation', () => { + it('rejects proposal when txHashes exceed maxTxsPerBlock', async () => { + const validatorWithMaxTxs = validatorFactory(epochCache, { txsPermitted: true, maxTxsPerBlock: 2 }); + const currentProposer = getSigner(); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + txHashes: getTxHashes(3), + }); + + mockGetProposer(getAddress(currentProposer), getAddress()); + const result = await validatorWithMaxTxs.validate(mockProposal); + expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.MidToleranceError }); + }); + + it('accepts proposal when txHashes count equals maxTxsPerBlock', async () => { + const validatorWithMaxTxs = validatorFactory(epochCache, { txsPermitted: true, maxTxsPerBlock: 2 }); + const currentProposer = getSigner(); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + txHashes: getTxHashes(2), + }); + + mockGetProposer(getAddress(currentProposer), getAddress()); + const result = await validatorWithMaxTxs.validate(mockProposal); + expect(result).toEqual({ result: 'accept' }); + }); + + it('accepts proposal when maxTxsPerBlock is not set (unlimited)', async () => { + // Default validator has no maxTxsPerBlock + const currentProposer = getSigner(); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + txHashes: getTxHashes(10), + }); + + mockGetProposer(getAddress(currentProposer), getAddress()); + const result = await validator.validate(mockProposal); + expect(result).toEqual({ result: 'accept' }); + }); + }); }); } diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index 0122f322ebc0..8da82a7d195b 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -222,9 +222,13 @@ export class LibP2PService extends WithTracer implements P2PService { this.protocolVersion, ); - this.blockProposalValidator = new BlockProposalValidator(epochCache, { txsPermitted: !config.disableTransactions }); + this.blockProposalValidator = new BlockProposalValidator(epochCache, { + txsPermitted: !config.disableTransactions, + maxTxsPerBlock: config.maxTxsPerBlock, + }); this.checkpointProposalValidator = new CheckpointProposalValidator(epochCache, { txsPermitted: !config.disableTransactions, + maxTxsPerBlock: config.maxTxsPerBlock, }); this.checkpointAttestationValidator = config.fishermanMode ? new FishermanAttestationValidator(epochCache, mempools.attestationPool, telemetry) diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index 469651fba387..61dcb2344c17 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -13,6 +13,7 @@ import { type P2PConfig, p2pConfigMappings } from '@aztec/p2p/config'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type ChainConfig, + DEFAULT_MAX_TXS_PER_BLOCK, type SequencerConfig, chainConfigMappings, sharedSequencerConfigMappings, @@ -37,7 +38,7 @@ export type { SequencerConfig }; */ export const DefaultSequencerConfig: ResolvedSequencerConfig = { sequencerPollingIntervalMS: 500, - maxTxsPerBlock: 32, + maxTxsPerBlock: DEFAULT_MAX_TXS_PER_BLOCK, minTxsPerBlock: 1, buildCheckpointIfEmpty: false, publishTxsWithProposals: false, @@ -77,11 +78,6 @@ export const sequencerConfigMappings: ConfigMappingsType = { description: 'The number of ms to wait between polling for checking to build on the next slot.', ...numberConfigHelper(DefaultSequencerConfig.sequencerPollingIntervalMS), }, - maxTxsPerBlock: { - env: 'SEQ_MAX_TX_PER_BLOCK', - description: 'The maximum number of txs to include in a block.', - ...numberConfigHelper(DefaultSequencerConfig.maxTxsPerBlock), - }, minTxsPerBlock: { env: 'SEQ_MIN_TX_PER_BLOCK', description: 'The minimum number of txs to include in a block.', diff --git a/yarn-project/stdlib/src/config/sequencer-config.ts b/yarn-project/stdlib/src/config/sequencer-config.ts index 7619cdce7e68..31d0eca9458a 100644 --- a/yarn-project/stdlib/src/config/sequencer-config.ts +++ b/yarn-project/stdlib/src/config/sequencer-config.ts @@ -1,7 +1,10 @@ -import type { ConfigMappingsType } from '@aztec/foundation/config'; +import { type ConfigMappingsType, numberConfigHelper } from '@aztec/foundation/config'; import type { SequencerConfig } from '../interfaces/configs.js'; +/** Default maximum number of transactions per block. */ +export const DEFAULT_MAX_TXS_PER_BLOCK = 32; + /** * Partial sequencer config mappings for fields that need to be shared across packages. * The full sequencer config mappings remain in sequencer-client, but shared fields @@ -9,7 +12,7 @@ import type { SequencerConfig } from '../interfaces/configs.js'; * to avoid duplication. */ export const sharedSequencerConfigMappings: ConfigMappingsType< - Pick + Pick > = { blockDurationMs: { env: 'SEQ_BLOCK_DURATION_MS', @@ -26,4 +29,9 @@ export const sharedSequencerConfigMappings: ConfigMappingsType< parseEnv: (val: string) => (val ? parseInt(val, 10) : 0), defaultValue: 0, }, + maxTxsPerBlock: { + env: 'SEQ_MAX_TX_PER_BLOCK', + description: 'The maximum number of txs to include in a block.', + ...numberConfigHelper(DEFAULT_MAX_TXS_PER_BLOCK), + }, }; diff --git a/yarn-project/stdlib/src/interfaces/validator.ts b/yarn-project/stdlib/src/interfaces/validator.ts index 09851cfbb98c..608d08520758 100644 --- a/yarn-project/stdlib/src/interfaces/validator.ts +++ b/yarn-project/stdlib/src/interfaces/validator.ts @@ -62,7 +62,7 @@ export type ValidatorClientConfig = ValidatorHASignerConfig & { }; export type ValidatorClientFullConfig = ValidatorClientConfig & - Pick & + Pick & Pick< SlasherConfig, 'slashBroadcastedInvalidBlockPenalty' | 'slashDuplicateProposalPenalty' | 'slashDuplicateAttestationPenalty' @@ -93,6 +93,7 @@ export const ValidatorClientFullConfigSchema = zodFor WatcherEmitter) const metrics = new ValidatorMetrics(telemetry); const blockProposalValidator = new BlockProposalValidator(epochCache, { txsPermitted: !config.disableTransactions, + maxTxsPerBlock: config.maxTxsPerBlock, }); const blockProposalHandler = new BlockProposalHandler( checkpointsBuilder, From 9c65df17e5d1f115410c6a65d80c9233c1a7adfc Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 26 Feb 2026 15:09:40 -0300 Subject: [PATCH 04/25] fix(archiver): enforce checkpoint boundary on rollbackTo (#20908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Reject `rollbackTo` calls targeting a block that is not the last block of its checkpoint, since rollback operates at checkpoint granularity. The error message indicates the nearest valid checkpoint boundaries so the caller can retry with a correct target. - Fix proven checkpoint number to roll back to the target checkpoint instead of resetting to zero. Fixes A-552 ## Test plan - Unit tests added in `archiver-store.test.ts` covering: - Rejection of non-boundary blocks with correct error message - Successful rollback to checkpoint boundary with sync point verification - Proven checkpoint rollback when target is before proven block - Proven checkpoint preservation when target is after proven block - e2e test `e2e_epochs/manual_rollback` passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 --- .../archiver/src/archiver-store.test.ts | 112 ++++++++++++++++++ yarn-project/archiver/src/archiver.ts | 29 ++++- 2 files changed, 135 insertions(+), 6 deletions(-) diff --git a/yarn-project/archiver/src/archiver-store.test.ts b/yarn-project/archiver/src/archiver-store.test.ts index d5cd45f3a3fa..950b90008ea8 100644 --- a/yarn-project/archiver/src/archiver-store.test.ts +++ b/yarn-project/archiver/src/archiver-store.test.ts @@ -432,4 +432,116 @@ describe('Archiver Store', () => { expect(result).toEqual([]); }); }); + + describe('rollbackTo', () => { + beforeEach(() => { + publicClient.getBlock.mockImplementation( + (args: { blockNumber?: bigint } = {}) => + Promise.resolve({ number: args.blockNumber ?? 0n, hash: `0x${'0'.repeat(64)}` }) as any, + ); + }); + + it('rejects rollback to a block that is not at a checkpoint boundary', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + // Checkpoint 1: 3 blocks (1, 2, 3). Checkpoint 2: 3 blocks (4, 5, 6). + const testCheckpoints = await makeChainedCheckpoints(2, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 3, + }); + await archiverStore.addCheckpoints(testCheckpoints); + + // Block 1 is not at a checkpoint boundary (checkpoint 1 ends at block 3) + await expect(archiver.rollbackTo(BlockNumber(1))).rejects.toThrow( + /not at a checkpoint boundary.*Use block 3 to roll back to this checkpoint.*or block 0 to roll back to the previous one/, + ); + + // Block 2 is also not at a checkpoint boundary + await expect(archiver.rollbackTo(BlockNumber(2))).rejects.toThrow( + /not at a checkpoint boundary.*Use block 3 to roll back to this checkpoint.*or block 0 to roll back to the previous one/, + ); + }); + + it('allows rollback to the last block of a checkpoint and updates sync points', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + // Checkpoint 1: 3 blocks (1, 2, 3), L1 block 10. Checkpoint 2: 3 blocks (4, 5, 6), L1 block 20. + const testCheckpoints = await makeChainedCheckpoints(2, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 3, + }); + await archiverStore.addCheckpoints(testCheckpoints); + + // Block 3 is the last block of checkpoint 1 — should succeed + await archiver.rollbackTo(BlockNumber(3)); + + expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + + // Verify sync points are set to checkpoint 1's L1 block number (10) + const synchPoint = await archiverStore.getSynchPoint(); + expect(synchPoint.blocksSynchedTo).toEqual(10n); + expect(synchPoint.messagesSynchedTo?.l1BlockNumber).toEqual(10n); + }); + + it('includes correct boundary info in error for mid-checkpoint rollback', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + // Checkpoint 1: 2 blocks (1, 2). Checkpoint 2: 3 blocks (3, 4, 5). + const checkpoints1 = await makeChainedCheckpoints(1, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 2, + }); + const checkpoints2 = await makeChainedCheckpoints(1, { + previousArchive: checkpoints1[0].checkpoint.blocks.at(-1)!.archive, + startCheckpointNumber: CheckpointNumber(2), + startBlockNumber: 3, + startL1BlockNumber: 20, + blocksPerCheckpoint: 3, + }); + await archiverStore.addCheckpoints([...checkpoints1, ...checkpoints2]); + + // Block 3 is the first of checkpoint 2 (spans 3-5) + // Should suggest block 5 (end of this checkpoint) or block 2 (end of previous) + await expect(archiver.rollbackTo(BlockNumber(3))).rejects.toThrow( + /Checkpoint 2 spans blocks 3 to 5.*Use block 5 to roll back to this checkpoint.*or block 2 to roll back to the previous one/, + ); + }); + + it('rolls back proven checkpoint number when target is before proven block', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + // Checkpoint 1: blocks 1-2, Checkpoint 2: blocks 3-4, Checkpoint 3: blocks 5-6 + const testCheckpoints = await makeChainedCheckpoints(3, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 2, + }); + await archiverStore.addCheckpoints(testCheckpoints); + + // Mark checkpoint 2 as proven + await archiverStore.setProvenCheckpointNumber(CheckpointNumber(2)); + expect(await archiver.getProvenCheckpointNumber()).toEqual(CheckpointNumber(2)); + + // Roll back to block 2 (end of checkpoint 1), which is before proven block 4 + await archiver.rollbackTo(BlockNumber(2)); + + expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getProvenCheckpointNumber()).toEqual(CheckpointNumber(1)); + }); + + it('preserves proven checkpoint number when target is after proven block', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + // Checkpoint 1: blocks 1-2, Checkpoint 2: blocks 3-4, Checkpoint 3: blocks 5-6 + const testCheckpoints = await makeChainedCheckpoints(3, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 2, + }); + await archiverStore.addCheckpoints(testCheckpoints); + + // Mark checkpoint 1 as proven + await archiverStore.setProvenCheckpointNumber(CheckpointNumber(1)); + expect(await archiver.getProvenCheckpointNumber()).toEqual(CheckpointNumber(1)); + + // Roll back to block 4 (end of checkpoint 2), which is after proven block 2 + await archiver.rollbackTo(BlockNumber(4)); + + expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(2)); + expect(await archiver.getProvenCheckpointNumber()).toEqual(CheckpointNumber(1)); + }); + }); }); diff --git a/yarn-project/archiver/src/archiver.ts b/yarn-project/archiver/src/archiver.ts index 4aec6f3c9e69..de82a0482186 100644 --- a/yarn-project/archiver/src/archiver.ts +++ b/yarn-project/archiver/src/archiver.ts @@ -399,7 +399,6 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra } public async rollbackTo(targetL2BlockNumber: BlockNumber): Promise { - // TODO(pw/mbps): This still assumes 1 block per checkpoint const currentBlocks = await this.getL2Tips(); const currentL2Block = currentBlocks.proposed.number; const currentProvenBlock = currentBlocks.proven.block.number; @@ -411,8 +410,25 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra if (!targetL2Block) { throw new Error(`Target L2 block ${targetL2BlockNumber} not found`); } - const targetL1BlockNumber = targetL2Block.l1.blockNumber; const targetCheckpointNumber = targetL2Block.checkpointNumber; + + // Rollback operates at checkpoint granularity: the target block must be the last block of its checkpoint. + const checkpointData = await this.store.getCheckpointData(targetCheckpointNumber); + if (checkpointData) { + const lastBlockInCheckpoint = BlockNumber(checkpointData.startBlock + checkpointData.blockCount - 1); + if (targetL2BlockNumber !== lastBlockInCheckpoint) { + const previousCheckpointBoundary = + checkpointData.startBlock > 1 ? BlockNumber(checkpointData.startBlock - 1) : BlockNumber(0); + throw new Error( + `Target L2 block ${targetL2BlockNumber} is not at a checkpoint boundary. ` + + `Checkpoint ${targetCheckpointNumber} spans blocks ${checkpointData.startBlock} to ${lastBlockInCheckpoint}. ` + + `Use block ${lastBlockInCheckpoint} to roll back to this checkpoint, ` + + `or block ${previousCheckpointBoundary} to roll back to the previous one.`, + ); + } + } + + const targetL1BlockNumber = targetL2Block.l1.blockNumber; const targetL1Block = await this.publicClient.getBlock({ blockNumber: targetL1BlockNumber, includeTransactions: false, @@ -431,13 +447,14 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra await this.store.setCheckpointSynchedL1BlockNumber(targetL1BlockNumber); await this.store.setMessageSynchedL1Block({ l1BlockNumber: targetL1BlockNumber, l1BlockHash: targetL1BlockHash }); if (targetL2BlockNumber < currentProvenBlock) { - this.log.info(`Clearing proven L2 block number`); - await this.updater.setProvenCheckpointNumber(CheckpointNumber.ZERO); + this.log.info(`Rolling back proven L2 checkpoint to ${targetCheckpointNumber}`); + await this.updater.setProvenCheckpointNumber(targetCheckpointNumber); } // TODO(palla/reorg): Set the finalized block when we add support for it. + // const currentFinalizedBlock = currentBlocks.finalized.block.number; // if (targetL2BlockNumber < currentFinalizedBlock) { - // this.log.info(`Clearing finalized L2 block number`); - // await this.store.setFinalizedL2BlockNumber(0); + // this.log.info(`Rolling back finalized L2 checkpoint to ${targetCheckpointNumber}`); + // await this.updater.setFinalizedCheckpointNumber(targetCheckpointNumber); // } } } From 437f09071d5232ea07bbf8bfbac20c37621087d5 Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Thu, 26 Feb 2026 15:58:08 -0300 Subject: [PATCH 05/25] fix: tps zero metrics (#20656) Ref: A-493 - Split `peerCount` metric into total connections and healthy connections - Fixed memory leak in peer manager: `peerConnectedAt` map entries are now deleted after recording connection duration - Fixed mined-delay tracking for `TxPoolV2` - Added attestation pool instrumentation - Renamed metrics/groups - Other zero metric fixes --- .../end-to-end/src/spartan/n_tps.test.ts | 16 +++++----- .../end-to-end/src/spartan/tx_metrics.ts | 2 +- .../attestation_pool/attestation_pool.ts | 9 +++--- .../p2p/src/mem_pools/instrumentation.ts | 30 +++++++++++-------- .../src/mem_pools/tx_pool_v2/tx_pool_v2.ts | 3 ++ .../mem_pools/tx_pool_v2/tx_pool_v2_impl.ts | 5 ++++ .../p2p/src/services/peer-manager/metrics.ts | 7 +++++ .../src/services/peer-manager/peer_manager.ts | 3 +- yarn-project/telemetry-client/src/metrics.ts | 6 ++++ 9 files changed, 54 insertions(+), 27 deletions(-) diff --git a/yarn-project/end-to-end/src/spartan/n_tps.test.ts b/yarn-project/end-to-end/src/spartan/n_tps.test.ts index 72a3d3002955..d7a774b5fc3b 100644 --- a/yarn-project/end-to-end/src/spartan/n_tps.test.ts +++ b/yarn-project/end-to-end/src/spartan/n_tps.test.ts @@ -90,10 +90,10 @@ const mempoolTxMinedDelayQuery = (perc: string) => const mempoolAttestationMinedDelayQuery = (perc: string) => `histogram_quantile(${perc}, sum(rate(aztec_mempool_attestations_mined_delay_milliseconds_bucket{k8s_namespace_name="${config.NAMESPACE}"}[1m])) by (le))`; -const peerCountQuery = () => `avg(aztec_peer_manager_peer_count{k8s_namespace_name="${config.NAMESPACE}"})`; +const peerCountQuery = () => `avg(aztec_peer_manager_peer_count_peers{k8s_namespace_name="${config.NAMESPACE}"})`; -const peerConnectionDurationQuery = (perc: string) => - `histogram_quantile(${perc}, sum(rate(aztec_peer_manager_peer_connection_duration_milliseconds_bucket{k8s_namespace_name="${config.NAMESPACE}"}[1m])) by (le))`; +const peerConnectionDurationQuery = (perc: string, windowSeconds: number) => + `histogram_quantile(${perc}, sum(rate(aztec_peer_manager_peer_connection_duration_milliseconds_bucket{k8s_namespace_name="${config.NAMESPACE}"}[${windowSeconds}s])) by (le))`; describe('sustained N TPS test', () => { jest.setTimeout(60 * 60 * 1000 * 10); // 10 hours @@ -168,8 +168,8 @@ describe('sustained N TPS test', () => { try { const [avgCount, durationP50, durationP95] = await Promise.all([ prometheusClient.querySingleValue(peerCountQuery()), - prometheusClient.querySingleValue(peerConnectionDurationQuery('0.50')), - prometheusClient.querySingleValue(peerConnectionDurationQuery('0.95')), + prometheusClient.querySingleValue(peerConnectionDurationQuery('0.50', TEST_DURATION_SECONDS + 60)), + prometheusClient.querySingleValue(peerConnectionDurationQuery('0.95', TEST_DURATION_SECONDS + 60)), ]); metrics.recordPeerStats(avgCount, durationP50, durationP95); logger.debug('Scraped peer stats', { avgCount, durationP50, durationP95 }); @@ -384,7 +384,7 @@ describe('sustained N TPS test', () => { const tx = await (config.REAL_VERIFIER ? submitProven(wallet, fee) : submitUnproven(wallet, fee)); const t1 = performance.now(); - metrics.recordSentTx(tx, `high_value_${highValueTps}tps`); + metrics.recordSentTx(tx, 'tx_inclusion_time'); const txHash = await tx.send({ wait: NO_WAIT }); const t2 = performance.now(); @@ -461,8 +461,8 @@ describe('sustained N TPS test', () => { logger.warn(`Failed transaction ${idx + 1}: ${result.error}`); }); - const highValueGroup = `high_value_${highValueTps}tps`; - const inclusionStats = metrics.inclusionTimeInSeconds(highValueGroup); + const txInclusionGroup = 'tx_inclusion_time'; + const inclusionStats = metrics.inclusionTimeInSeconds(txInclusionGroup); logger.info(`Transaction inclusion summary: ${successCount} succeeded, ${failureCount} failed`); logger.info('Inclusion time stats', inclusionStats); }); diff --git a/yarn-project/end-to-end/src/spartan/tx_metrics.ts b/yarn-project/end-to-end/src/spartan/tx_metrics.ts index 6234641a9276..20d536432ceb 100644 --- a/yarn-project/end-to-end/src/spartan/tx_metrics.ts +++ b/yarn-project/end-to-end/src/spartan/tx_metrics.ts @@ -296,7 +296,7 @@ export class TxInclusionMetrics { value: stats.mean, }, { - name: `${group}/median_inclusion`, + name: `${group}/p50_inclusion`, unit: 's', value: stats.median, }, diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts index 827d91ce84ea..e61456ef5cbf 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts @@ -359,11 +359,10 @@ export class AttestationPool { } const address = sender.toString(); + const ownKey = this.getAttestationKey(slotNumber, proposalId, address); - await this.checkpointAttestations.set( - this.getAttestationKey(slotNumber, proposalId, address), - attestation.toBuffer(), - ); + await this.checkpointAttestations.set(ownKey, attestation.toBuffer()); + this.metrics.trackMempoolItemAdded(ownKey); this.log.debug(`Added own checkpoint attestation for slot ${slotNumber} from ${address}`, { signature: attestation.signature.toString(), @@ -429,6 +428,7 @@ export class AttestationPool { const attestationEndKey = new Fr(oldestSlot).toString(); for await (const key of this.checkpointAttestations.keysAsync({ end: attestationEndKey })) { await this.checkpointAttestations.delete(key); + this.metrics.trackMempoolItemRemoved(key); numberOfAttestations++; } @@ -526,6 +526,7 @@ export class AttestationPool { // Add the attestation await this.checkpointAttestations.set(key, attestation.toBuffer()); + this.metrics.trackMempoolItemAdded(key); // Track this attestation in the per-signer-per-slot index for duplicate detection const slotSignerKey = this.getSlotSignerKey(slotNumber, signerAddress); diff --git a/yarn-project/p2p/src/mem_pools/instrumentation.ts b/yarn-project/p2p/src/mem_pools/instrumentation.ts index bfd3c7b64ac7..d76d2c30ad4a 100644 --- a/yarn-project/p2p/src/mem_pools/instrumentation.ts +++ b/yarn-project/p2p/src/mem_pools/instrumentation.ts @@ -73,7 +73,7 @@ export class PoolInstrumentation { private defaultAttributes; private meter: Meter; - private txAddedTimestamp: Map = new Map(); + private mempoolItemAddedTimestamp: Map = new Map(); constructor( telemetry: TelemetryClient, @@ -114,22 +114,26 @@ export class PoolInstrumentation { } public transactionsAdded(transactions: Tx[]) { - const timestamp = Date.now(); - for (const transaction of transactions) { - this.txAddedTimestamp.set(transaction.txHash.toBigInt(), timestamp); - } + transactions.forEach(tx => this.trackMempoolItemAdded(tx.txHash.toBigInt())); } public transactionsRemoved(hashes: Iterable | Iterable) { - const timestamp = Date.now(); for (const hash of hashes) { - const key = BigInt(hash); - const addedAt = this.txAddedTimestamp.get(key); - if (addedAt !== undefined) { - this.txAddedTimestamp.delete(key); - if (addedAt < timestamp) { - this.minedDelay.record(timestamp - addedAt); - } + this.trackMempoolItemRemoved(BigInt(hash)); + } + } + + public trackMempoolItemAdded(key: bigint | string): void { + this.mempoolItemAddedTimestamp.set(key, Date.now()); + } + + public trackMempoolItemRemoved(key: bigint | string): void { + const timestamp = Date.now(); + const addedAt = this.mempoolItemAddedTimestamp.get(key); + if (addedAt !== undefined) { + this.mempoolItemAddedTimestamp.delete(key); + if (addedAt < timestamp) { + this.minedDelay.record(timestamp - addedAt); } } } diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts index 4f65a0d6b0ae..69b91bd95c52 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts @@ -58,6 +58,9 @@ export class AztecKVTxPoolV2 extends (EventEmitter as new () => TypedEventEmitte const hashes = txHashes.map(h => (typeof h === 'string' ? TxHash.fromString(h) : TxHash.fromBigInt(h))); this.emit('txs-removed', { txHashes: hashes }); }, + onTxsMined: (txHashes: string[]) => { + this.#metrics?.transactionsRemoved(txHashes); + }, }; // Create the implementation diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts index 88f6e887b9a8..15f6eb4b051e 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts @@ -45,6 +45,7 @@ import { TxPoolIndices } from './tx_pool_indices.js'; export interface TxPoolV2Callbacks { onTxsAdded: (txs: Tx[], opts: { source?: string }) => void; onTxsRemoved: (txHashes: string[] | bigint[]) => void; + onTxsMined: (txHashes: string[]) => void; } /** @@ -498,6 +499,10 @@ export class TxPoolV2Impl { await this.#evictionManager.evictAfterNewBlock(block.header, nullifiers, feePayers); }); + if (found.length > 0) { + this.#callbacks.onTxsMined(found.map(m => m.txHash)); + } + this.#log.info(`Marked ${found.length} txs as mined in block ${blockId.number}`); } diff --git a/yarn-project/p2p/src/services/peer-manager/metrics.ts b/yarn-project/p2p/src/services/peer-manager/metrics.ts index 06cd513727db..2e1f198611a4 100644 --- a/yarn-project/p2p/src/services/peer-manager/metrics.ts +++ b/yarn-project/p2p/src/services/peer-manager/metrics.ts @@ -18,6 +18,7 @@ export class PeerManagerMetrics { private sentGoodbyes: UpDownCounter; private receivedGoodbyes: UpDownCounter; private peerCount: Gauge; + private healthyPeerCount: Gauge; private lowScoreDisconnects: UpDownCounter; private peerConnectionDuration: Histogram; @@ -49,6 +50,7 @@ export class PeerManagerMetrics { goodbyeReasonAttrs, ); this.peerCount = meter.createGauge(Metrics.PEER_MANAGER_PEER_COUNT); + this.healthyPeerCount = meter.createGauge(Metrics.PEER_MANAGER_HEALTHY_PEER_COUNT); this.lowScoreDisconnects = createUpDownCounterWithDefault(meter, Metrics.PEER_MANAGER_LOW_SCORE_DISCONNECTS, { [Attributes.P2P_PEER_SCORE_STATE]: ['Banned', 'Disconnect'], }); @@ -67,6 +69,10 @@ export class PeerManagerMetrics { this.peerCount.record(count); } + public recordHealthyPeerCount(count: number) { + this.healthyPeerCount.record(count); + } + public recordLowScoreDisconnect(scoreState: 'Banned' | 'Disconnect') { this.lowScoreDisconnects.add(1, { [Attributes.P2P_PEER_SCORE_STATE]: scoreState }); } @@ -79,6 +85,7 @@ export class PeerManagerMetrics { const connectedAt = this.peerConnectedAt.get(id.toString()); if (connectedAt) { this.peerConnectionDuration.record(Date.now() - connectedAt); + this.peerConnectedAt.delete(id.toString()); } } } diff --git a/yarn-project/p2p/src/services/peer-manager/peer_manager.ts b/yarn-project/p2p/src/services/peer-manager/peer_manager.ts index 336f726b6dec..669f0e149a9c 100644 --- a/yarn-project/p2p/src/services/peer-manager/peer_manager.ts +++ b/yarn-project/p2p/src/services/peer-manager/peer_manager.ts @@ -515,7 +515,8 @@ export class PeerManager implements PeerManagerInterface { ...this.peerScoring.getStats(), }); - this.metrics.recordPeerCount(healthyConnections.length); + this.metrics.recordPeerCount(connections.length); + this.metrics.recordHealthyPeerCount(healthyConnections.length); // Exit if no peers to connect if (peersToConnect <= 0) { diff --git a/yarn-project/telemetry-client/src/metrics.ts b/yarn-project/telemetry-client/src/metrics.ts index d30a4185e15b..f0065257c1fe 100644 --- a/yarn-project/telemetry-client/src/metrics.ts +++ b/yarn-project/telemetry-client/src/metrics.ts @@ -764,6 +764,12 @@ export const PEER_MANAGER_PEER_COUNT: MetricDefinition = { unit: 'peers', valueType: ValueType.INT, }; +export const PEER_MANAGER_HEALTHY_PEER_COUNT: MetricDefinition = { + name: 'aztec.peer_manager.healthy_peer_count', + description: 'Number of healthy (non-protected, non-banned) peers', + unit: 'peers', + valueType: ValueType.INT, +}; export const PEER_MANAGER_LOW_SCORE_DISCONNECTS: MetricDefinition = { name: 'aztec.peer_manager.low_score_disconnects', description: 'Number of peers disconnected due to low score', From 7e20fbabbda061d3befd7e7b918993156364ca58 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Fri, 27 Feb 2026 02:12:25 +0000 Subject: [PATCH 06/25] fix: handle scientific notation in bigintConfigHelper BigInt() doesn't accept scientific notation strings like "1e+23" or "2E+23". This caused a crash when starting a node with --network testnet, as config values like 200000e18 get converted to "2e+23" by the JSON pipeline. Parse scientific notation losslessly using bigint arithmetic instead of going through float64, preserving exact values. --- .../foundation/src/config/config.test.ts | 35 ++++++++++++++++++- yarn-project/foundation/src/config/index.ts | 15 ++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/yarn-project/foundation/src/config/config.test.ts b/yarn-project/foundation/src/config/config.test.ts index c21021c8d067..a4e0f7ad17b2 100644 --- a/yarn-project/foundation/src/config/config.test.ts +++ b/yarn-project/foundation/src/config/config.test.ts @@ -1,6 +1,6 @@ import { jest } from '@jest/globals'; -import { type ConfigMappingsType, getConfigFromMappings, numberConfigHelper } from './index.js'; +import { type ConfigMappingsType, bigintConfigHelper, getConfigFromMappings, numberConfigHelper } from './index.js'; describe('Config', () => { describe('getConfigFromMappings', () => { @@ -131,4 +131,37 @@ describe('Config', () => { }); }); }); + + describe('bigintConfigHelper', () => { + it('parses plain integer strings', () => { + const { parseEnv } = bigintConfigHelper(); + expect(parseEnv!('123')).toBe(123n); + expect(parseEnv!('0')).toBe(0n); + expect(parseEnv!('200000000000000000000000')).toBe(200000000000000000000000n); + }); + + it('parses scientific notation', () => { + const { parseEnv } = bigintConfigHelper(); + expect(parseEnv!('1e+23')).toBe(100000000000000000000000n); + expect(parseEnv!('2E+23')).toBe(200000000000000000000000n); + expect(parseEnv!('1e23')).toBe(100000000000000000000000n); + expect(parseEnv!('5e18')).toBe(5000000000000000000n); + }); + + it('parses scientific notation with decimal mantissa', () => { + const { parseEnv } = bigintConfigHelper(); + expect(parseEnv!('1.5e10')).toBe(15000000000n); + expect(parseEnv!('2.5e5')).toBe(250000n); + }); + + it('returns default value for empty string', () => { + const { parseEnv } = bigintConfigHelper(42n); + expect(parseEnv!('')).toBe(42n); + }); + + it('throws for non-integer scientific notation results', () => { + const { parseEnv } = bigintConfigHelper(); + expect(() => parseEnv!('1e-3')).toThrow(); + }); + }); }); diff --git a/yarn-project/foundation/src/config/index.ts b/yarn-project/foundation/src/config/index.ts index 7ed7d3ef6ceb..8962dea3b1b0 100644 --- a/yarn-project/foundation/src/config/index.ts +++ b/yarn-project/foundation/src/config/index.ts @@ -177,6 +177,21 @@ export function bigintConfigHelper(defaultVal?: bigint): Pick Date: Fri, 27 Feb 2026 02:12:34 +0000 Subject: [PATCH 07/25] fix: handle scientific notation in bigintConfigHelper --- yarn.lock | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 yarn.lock diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000000..fb57ccd13afb --- /dev/null +++ b/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + From bf41a942be595fecc97072804dab91dd1d78874b Mon Sep 17 00:00:00 2001 From: ludamad Date: Thu, 26 Feb 2026 21:13:12 -0500 Subject: [PATCH 08/25] Delete yarn.lock --- yarn.lock | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 yarn.lock diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index fb57ccd13afb..000000000000 --- a/yarn.lock +++ /dev/null @@ -1,4 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - From e1e7f57bd057b2d2c77927e5ee448aa4e1238239 Mon Sep 17 00:00:00 2001 From: Amin Sammara <84764772+aminsammara@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:20:12 +0000 Subject: [PATCH 09/25] feat(aztec): node enters standby mode on genesis root mismatch (#20938) Closes A-547 Forward-port of #20937. ## Summary - Before calling `getL1Config()`, the node now compares its local genesis archive root against the canonical rollup's via `archiveAt(0)`. - If the roots don't match (e.g. during L1 contract upgrades where the old rollup ABI is incompatible), the node enters **standby mode** instead of crashing. - In standby mode, a lightweight HTTP server is started for K8s liveness probes, and the node polls every 10 minutes until a compatible rollup becomes canonical. - When `rollupVersion` is not explicitly configured (`undefined`), it now falls back to `'canonical'` instead of passing `0` to the registry. ### Tested against testnet (incompatible rollup): ``` Genesis archive root: 0x2727683df35594b1f073a681532520056ca8a775398c8b5a94574c67ef1ce6de Genesis root mismatch: expected 0x2727683df35594b1f073a681532520056ca8a775398c8b5a94574c67ef1ce6de, got 0x0ae6138310f7b877b6c68856451eddec0873fb63f9033931cedcdc46815729e1 from rollup at 0x66a41cb55f9a1e38a45a2ac8685f12a61fbfab77. Entering standby mode. Will poll every 10s for a compatible rollup... Standby status server listening on port 8080 Still waiting. Rollup at 0x66a41cb55f9a1e38a45a2ac8685f12a61fbfab77 has genesis root 0x0ae6138310f7b877b6c68856451eddec0873fb63f9033931cedcdc46815729e1. ``` ``` $ curl http://localhost:8080/status OK ``` ## Test plan - Run `aztec start --node --archiver --network testnet` against an incompatible rollup: verify standby mode with health check responding OK - Run against a compatible network: verify normal startup without entering standby - Ctrl+C during standby: verify clean exit --- yarn-project/aztec/src/cli/cmds/start_node.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/yarn-project/aztec/src/cli/cmds/start_node.ts b/yarn-project/aztec/src/cli/cmds/start_node.ts index abed355ce4f6..a034cf3a6f5a 100644 --- a/yarn-project/aztec/src/cli/cmds/start_node.ts +++ b/yarn-project/aztec/src/cli/cmds/start_node.ts @@ -4,10 +4,14 @@ import { Fr } from '@aztec/aztec.js/fields'; import { getSponsoredFPCAddress } from '@aztec/cli/cli-utils'; import { getL1Config } from '@aztec/cli/config'; import { getPublicClient } from '@aztec/ethereum/client'; +import { RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; import { SecretValue } from '@aztec/foundation/config'; +import { EthAddress } from '@aztec/foundation/eth-address'; import type { NamespacedApiHandlers } from '@aztec/foundation/json-rpc/server'; +import { startHttpRpcServer } from '@aztec/foundation/json-rpc/server'; import { Agent, makeUndiciFetch } from '@aztec/foundation/json-rpc/undici'; import type { LogFn } from '@aztec/foundation/log'; +import { sleep } from '@aztec/foundation/sleep'; import { ProvingJobConsumerSchema, createProvingJobBrokerClient } from '@aztec/prover-client/broker'; import { type CliPXEOptions, type PXEConfig, allPxeConfigMappings } from '@aztec/pxe/config'; import { AztecNodeAdminApiSchema, AztecNodeApiSchema } from '@aztec/stdlib/interfaces/client'; @@ -21,6 +25,8 @@ import { import { EmbeddedWallet } from '@aztec/wallets/embedded'; import { getGenesisValues } from '@aztec/world-state/testing'; +import Koa from 'koa'; + import { createAztecNode } from '../../local-network/index.js'; import { extractNamespacedOptions, @@ -31,6 +37,72 @@ import { import { getVersions } from '../versioning.js'; import { startProverBroker } from './start_prover_broker.js'; +const ROLLUP_POLL_INTERVAL_MS = 600_000; + +/** + * Waits until the canonical rollup's genesis archive root matches the expected local genesis root. + * If the rollup is not yet compatible (e.g. during L1 contract upgrades), enters standby mode: + * starts a lightweight HTTP server for K8s liveness probes and polls until a compatible rollup appears. + */ +async function waitForCompatibleRollup( + publicClient: ReturnType, + registryAddress: EthAddress, + rollupVersion: number | 'canonical', + expectedGenesisRoot: Fr, + port: number | undefined, + userLog: LogFn, +): Promise { + const registry = new RegistryContract(publicClient, registryAddress); + const rollupAddress = await registry.getRollupAddress(rollupVersion); + const rollup = new RollupContract(publicClient, rollupAddress.toString()); + + let l1GenesisRoot: Fr; + try { + l1GenesisRoot = await rollup.getGenesisArchiveTreeRoot(); + } catch (err: any) { + throw new Error( + `Could not retrieve genesis archive root from canonical rollup at ${rollupAddress}: ${err.message}`, + ); + } + + if (l1GenesisRoot.equals(expectedGenesisRoot)) { + return; + } + + userLog( + `Genesis root mismatch: expected ${expectedGenesisRoot}, got ${l1GenesisRoot} from rollup at ${rollupAddress}. ` + + `Entering standby mode. Will poll every ${ROLLUP_POLL_INTERVAL_MS / 1000}s for a compatible rollup...`, + ); + + const standbyServer = await startHttpRpcServer({ getApp: () => new Koa(), isHealthy: () => true }, { port }); + userLog(`Standby status server listening on port ${standbyServer.port}`); + + try { + while (true) { + await sleep(ROLLUP_POLL_INTERVAL_MS); + + const currentRollupAddress = await registry.getRollupAddress(rollupVersion); + const currentRollup = new RollupContract(publicClient, currentRollupAddress.toString()); + + try { + l1GenesisRoot = await currentRollup.getGenesisArchiveTreeRoot(); + } catch { + userLog(`Failed to fetch genesis root from rollup at ${currentRollupAddress}. Retrying...`); + continue; + } + + if (l1GenesisRoot.equals(expectedGenesisRoot)) { + userLog(`Compatible rollup found at ${currentRollupAddress}. Exiting standby mode.`); + return; + } + + userLog(`Still waiting. Rollup at ${currentRollupAddress} has genesis root ${l1GenesisRoot}.`); + } + } finally { + await new Promise((resolve, reject) => standbyServer.close(err => (err ? reject(err) : resolve()))); + } +} + export async function startNode( options: any, signalHandlers: (() => Promise)[], @@ -96,6 +168,20 @@ export async function startNode( if (!nodeConfig.l1Contracts.registryAddress || nodeConfig.l1Contracts.registryAddress.isZero()) { throw new Error('L1 registry address is required to start Aztec Node'); } + + // Wait for a compatible rollup before proceeding with full L1 config fetch. + // This prevents crashes when the canonical rollup hasn't been upgraded yet. + const publicClient = getPublicClient(nodeConfig); + const rollupVersion: number | 'canonical' = nodeConfig.rollupVersion ?? 'canonical'; + await waitForCompatibleRollup( + publicClient, + nodeConfig.l1Contracts.registryAddress, + rollupVersion, + genesisArchiveRoot, + options.port, + userLog, + ); + const { addresses, config } = await getL1Config( nodeConfig.l1Contracts.registryAddress, nodeConfig.l1RpcUrls, From 951823d2712f0f4de3bbcaabb46a24b35442823c Mon Sep 17 00:00:00 2001 From: Alex Gherghisan Date: Fri, 27 Feb 2026 11:48:50 +0000 Subject: [PATCH 10/25] fix: logging of class instances (#20807) --- .../foundation/src/log/bigint-utils.ts | 3 ++ .../foundation/src/log/pino-logger.test.ts | 29 +++++++++++++++++++ .../slasher/src/tally_slasher_client.ts | 6 +--- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/yarn-project/foundation/src/log/bigint-utils.ts b/yarn-project/foundation/src/log/bigint-utils.ts index 6cc94101ac2f..c9083ec1bfd0 100644 --- a/yarn-project/foundation/src/log/bigint-utils.ts +++ b/yarn-project/foundation/src/log/bigint-utils.ts @@ -11,6 +11,9 @@ export function convertBigintsToStrings(obj: unknown): unknown { } if (obj !== null && typeof obj === 'object') { + if (typeof (obj as any).toJSON === 'function') { + return convertBigintsToStrings((obj as any).toJSON()); + } const result: Record = {}; for (const key in obj) { result[key] = convertBigintsToStrings((obj as Record)[key]); diff --git a/yarn-project/foundation/src/log/pino-logger.test.ts b/yarn-project/foundation/src/log/pino-logger.test.ts index 9881535d4f58..22afc9e6a293 100644 --- a/yarn-project/foundation/src/log/pino-logger.test.ts +++ b/yarn-project/foundation/src/log/pino-logger.test.ts @@ -273,6 +273,35 @@ describe('pino-logger', () => { }); }); + it('serializes objects with toJSON() instead of dumping raw properties', () => { + const testLogger = createLogger('tojson-test'); + capturingStream.clear(); + + // Simulate an EthAddress-like object with an internal buffer and a toJSON method + const addressLike = { + buffer: Buffer.from('1234567890abcdef1234567890abcdef12345678', 'hex'), + toJSON() { + return '0x1234567890abcdef1234567890abcdef12345678'; + }, + }; + + testLogger.info('address logging test', { + validator: addressLike, + nested: { addr: addressLike }, + array: [addressLike], + plainString: 'hello', + }); + + const entries = capturingStream.getJsonLines(); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + validator: '0x1234567890abcdef1234567890abcdef12345678', + nested: { addr: '0x1234567890abcdef1234567890abcdef12345678' }, + array: ['0x1234567890abcdef1234567890abcdef12345678'], + plainString: 'hello', + }); + }); + it('returns bindings via getBindings', () => { const testLogger = createLogger('bindings-test', { actor: 'main', instanceId: 'id-123' }); const bindings = testLogger.getBindings(); diff --git a/yarn-project/slasher/src/tally_slasher_client.ts b/yarn-project/slasher/src/tally_slasher_client.ts index 70ef6fdfeeb6..d95d565746a8 100644 --- a/yarn-project/slasher/src/tally_slasher_client.ts +++ b/yarn-project/slasher/src/tally_slasher_client.ts @@ -349,15 +349,11 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC return undefined; } - const offensesToSlashLog = offensesToSlash.map(offense => ({ - ...offense, - amount: offense.amount.toString(), - })); this.log.info(`Voting to slash ${offensesToSlash.length} offenses`, { slotNumber, currentRound, slashedRound, - offensesToSlash: offensesToSlashLog, + offensesToSlash, }); const committees = await this.collectCommitteesActiveDuringRound(slashedRound); From b559bb2c10e0341132b7ec77dd6a05de4570e811 Mon Sep 17 00:00:00 2001 From: spypsy Date: Fri, 27 Feb 2026 12:31:12 +0000 Subject: [PATCH 11/25] truncate offenders, not offenses --- .../slasher/src/tally_slasher_client.test.ts | 35 +++---- .../slasher/src/tally_slasher_client.ts | 25 +++-- .../stdlib/src/slashing/tally.test.ts | 94 +++++++++++++++---- yarn-project/stdlib/src/slashing/tally.ts | 21 ++++- 4 files changed, 120 insertions(+), 55 deletions(-) diff --git a/yarn-project/slasher/src/tally_slasher_client.test.ts b/yarn-project/slasher/src/tally_slasher_client.test.ts index 95bab349286a..24e5d0a38f9e 100644 --- a/yarn-project/slasher/src/tally_slasher_client.test.ts +++ b/yarn-project/slasher/src/tally_slasher_client.test.ts @@ -520,51 +520,44 @@ describe('TallySlasherClient', () => { expect(actions[0].type).toBe('vote-offenses'); }); - it('should truncate to slashMaxPayloadSize when offenses exceed cap', async () => { + it('should return all offenses for the round regardless of slashMaxPayloadSize', async () => { const currentRound = 5n; const targetRound = 3n; // currentRound - offset(2) const baseSlot = targetRound * BigInt(roundSize); - // Set cap to 2 so we keep only the top 2 offenses by priority (uncontroversial first, then amount desc) + // slashMaxPayloadSize has no effect on gatherOffensesForRound; truncation happens later + // in getSlashConsensusVotesFromOffenses after always-slash offenses are merged in. tallySlasherClient.updateConfig({ slashMaxPayloadSize: 2 }); - // Add 3 offenses for target round: different amounts so sort order is clear (high amount first) await addPendingOffense({ validator: committee[0], epochOrSlot: baseSlot, - amount: settings.slashingAmounts[0], // 1 unit - lowest priority + amount: settings.slashingAmounts[0], offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, }); await addPendingOffense({ validator: committee[1], epochOrSlot: baseSlot, - amount: settings.slashingAmounts[2], // 3 units - highest priority + amount: settings.slashingAmounts[2], offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, }); await addPendingOffense({ validator: committee[2], epochOrSlot: baseSlot, - amount: settings.slashingAmounts[1], // 2 units - middle + amount: settings.slashingAmounts[1], offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, }); const offenses = await tallySlasherClient.gatherOffensesForRound(currentRound); - expect(offenses).toHaveLength(2); - // First should be committee[1] (3 units), second committee[2] (2 units); committee[0] (1 unit) truncated - expect(offenses[0].validator.equals(committee[1])).toBe(true); - expect(offenses[0].amount).toEqual(settings.slashingAmounts[2]); - expect(offenses[1].validator.equals(committee[2])).toBe(true); - expect(offenses[1].amount).toEqual(settings.slashingAmounts[1]); + expect(offenses).toHaveLength(3); }); - it('should not truncate when offenses are within cap', async () => { + it('should return all offenses for the round', async () => { const currentRound = 5n; const targetRound = 3n; const baseSlot = targetRound * BigInt(roundSize); - tallySlasherClient.updateConfig({ slashMaxPayloadSize: 10 }); - await addPendingOffense({ validator: committee[0], epochOrSlot: baseSlot, @@ -582,14 +575,14 @@ describe('TallySlasherClient', () => { expect(offenses).toHaveLength(2); }); - it('should produce a valid vote action with truncated offenses', async () => { + it('should produce a valid vote action respecting slashMaxPayloadSize', async () => { const currentRound = 5n; const targetRound = 3n; const baseSlot = targetRound * BigInt(roundSize); + // Cap at 1 slashed validator-epoch pair; the highest-amount validator should survive tallySlasherClient.updateConfig({ slashMaxPayloadSize: 1 }); - // Add 3 offenses, only the highest-amount one should survive truncation await addPendingOffense({ validator: committee[0], epochOrSlot: baseSlot, @@ -614,10 +607,10 @@ describe('TallySlasherClient', () => { expect(action).toBeDefined(); assert(action!.type === 'vote-offenses'); - // Only committee[1] (3 units) should have a non-zero vote - expect(action!.votes[0]).toBe(0); // committee[0] truncated - expect(action!.votes[1]).toBe(3); // committee[1] kept (highest amount) - expect(action!.votes[2]).toBe(0); // committee[2] truncated + // Only committee[1] (3 units, highest) survives; the others are zeroed out + expect(action!.votes[0]).toBe(0); // committee[0]: 1 unit, dropped + expect(action!.votes[1]).toBe(3); // committee[1]: 3 units, kept + expect(action!.votes[2]).toBe(0); // committee[2]: 2 units, dropped }); }); diff --git a/yarn-project/slasher/src/tally_slasher_client.ts b/yarn-project/slasher/src/tally_slasher_client.ts index c5533cc6858f..d3dd4321fe53 100644 --- a/yarn-project/slasher/src/tally_slasher_client.ts +++ b/yarn-project/slasher/src/tally_slasher_client.ts @@ -16,7 +16,6 @@ import { type SlashPayloadRound, getEpochsForRound, getSlashConsensusVotesFromOffenses, - offenseDataComparator, } from '@aztec/stdlib/slashing'; import type { Hex } from 'viem'; @@ -366,12 +365,19 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC const committees = await this.collectCommitteesActiveDuringRound(slashedRound); const epochsForCommittees = getEpochsForRound(slashedRound, this.settings); - const votes = getSlashConsensusVotesFromOffenses( + const { slashMaxPayloadSize } = this.config; + const { votes, truncatedCount } = getSlashConsensusVotesFromOffenses( offensesToSlash, committees, epochsForCommittees.map(e => BigInt(e)), - this.settings, + { ...this.settings, maxSlashedValidators: slashMaxPayloadSize }, ); + if (truncatedCount > 0) { + this.log.warn( + `Vote truncated: ${truncatedCount} validator-epoch pairs dropped to stay within gas limit of ${slashMaxPayloadSize}`, + { slotNumber, currentRound, slashedRound }, + ); + } if (votes.every(v => v === 0)) { this.log.warn(`Computed votes for offenses are all zero. Skipping vote.`, { slotNumber, @@ -419,10 +425,8 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC /** * Gather offenses to be slashed on a given round. * In tally slashing, round N slashes validators from round N - slashOffsetInRounds. - * Offenses are sorted by priority (uncontroversial first, then amount, then age) and truncated to - * slashMaxPayloadSize so that execution payload stays within gas limits. * @param round - The round to get offenses for, defaults to current round - * @returns Array of pending offenses for the round with offset applied, truncated to max payload size + * @returns Array of pending offenses for the round with offset applied */ public async gatherOffensesForRound(round?: bigint): Promise { const targetRound = this.getSlashedRound(round); @@ -430,14 +434,7 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC return []; } - const raw = await this.offensesStore.getOffensesForRound(targetRound); - const sorted = [...raw].sort(offenseDataComparator); - const { slashMaxPayloadSize } = this.config; - const selected = sorted.slice(0, slashMaxPayloadSize); - if (selected.length !== sorted.length) { - this.log.warn(`Offense list of ${sorted.length} truncated to max size of ${slashMaxPayloadSize}`); - } - return selected; + return await this.offensesStore.getOffensesForRound(targetRound); } /** Returns all pending offenses stored */ diff --git a/yarn-project/stdlib/src/slashing/tally.test.ts b/yarn-project/stdlib/src/slashing/tally.test.ts index d79a9dcb280e..4c0fafc58087 100644 --- a/yarn-project/stdlib/src/slashing/tally.test.ts +++ b/yarn-project/stdlib/src/slashing/tally.test.ts @@ -41,7 +41,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2, mockValidator3]]; const epochsForCommittees = [5n]; // Committee for epoch 5 - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(2); // Only 25n from epoch 5 offense for validator1 @@ -62,7 +62,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1]]; const epochsForCommittees = [5n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(3); // Capped at MAX_SLASH_UNITS_PER_VALIDATOR @@ -91,7 +91,7 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n]; // Committees for epochs 5 and 6 - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(2); // validator1 in committee1 @@ -125,7 +125,7 @@ describe('TallySlashingHelpers', () => { [mockValidator1, mockValidator3], ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(2); // validator1 in committee1, epoch 5 offense (20n) @@ -150,7 +150,7 @@ describe('TallySlashingHelpers', () => { const committees: EthAddress[][] = []; const epochsForCommittees: bigint[] = []; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toEqual([]); }); @@ -167,7 +167,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator2, mockValidator3]]; const epochsForCommittees = [5n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(0); // validator2 has no offenses @@ -197,7 +197,7 @@ describe('TallySlashingHelpers', () => { [mockValidator1, mockValidator3], ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(3); // validator1 in committee1, always-slash (30n) @@ -228,7 +228,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2, mockValidator3]]; const epochsForCommittees = [2n]; // Committee for epoch 2 - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(BlockNumber(1)); // validator1: 15n offense maps to epoch 2 @@ -255,7 +255,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2]]; const epochsForCommittees = [2n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(2); // validator1: 10n + 15n = 25n total for epoch 2 @@ -288,7 +288,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2]]; const epochsForCommittees = [3n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(2); // validator1: 8n + 7n + 5n = 20n total @@ -318,7 +318,7 @@ describe('TallySlashingHelpers', () => { [mockValidator1, mockValidator3], ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(3); // validator1 committee1: 20n(always) + 15n(epoch5) = 35n @@ -352,7 +352,7 @@ describe('TallySlashingHelpers', () => { [mockValidator1, mockValidator2], ]; const epochsForCommittees = [0n, 1n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(BlockNumber(1)); // validator1 epoch0: 15n offense @@ -383,7 +383,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2, mockValidator3]]; const epochsForCommittees = [5n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(0); // validator1: 0n amount = 0 slash units @@ -409,7 +409,7 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n, 7n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); // Should be 12 elements (4 per committee), not 8 expect(votes).toHaveLength(12); @@ -437,7 +437,7 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); expect(votes.slice(0, 4)).toEqual([0, 0, 0, 0]); // Padded empty committee @@ -460,13 +460,73 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); expect(votes.slice(0, 4)).toEqual([0, 2, 0, 0]); // validator2 in first committee (20n = 2 units) expect(votes.slice(4, 8)).toEqual([0, 0, 0, 0]); // Padded empty committee }); + it('truncates to maxSlashedValidators unique (validator, epoch) pairs', () => { + const offenses: Offense[] = [ + { validator: mockValidator1, amount: 30n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n }, + { validator: mockValidator2, amount: 20n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n }, + { validator: mockValidator3, amount: 10n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n }, + ]; + + const committees = [[mockValidator1, mockValidator2, mockValidator3, mockValidator4]]; + const epochsForCommittees = [5n]; + // Only 2 slashed validators allowed; validator3 should be zeroed out + const { votes, truncatedCount } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, { + ...settings, + maxSlashedValidators: 2, + }); + + expect(truncatedCount).toBe(1); + expect(votes).toHaveLength(4); + expect(votes[0]).toEqual(3); // validator1: included (1st) + expect(votes[1]).toEqual(2); // validator2: included (2nd) + expect(votes[2]).toEqual(0); // validator3: zeroed out (limit reached) + expect(votes[3]).toEqual(0); // validator4: no offenses + }); + + it('counts the same validator in multiple epochs as separate slashed pairs', () => { + // An always-slash validator appears once per epoch committee, each generating a slash() call + const offenses = [ + { + validator: mockValidator1, + amount: 30n, + offenseType: OffenseType.UNKNOWN, + epochOrSlot: undefined, // always-slash + }, + { validator: mockValidator2, amount: 20n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n }, + { validator: mockValidator3, amount: 10n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 6n }, + ]; + + const committees = [ + [mockValidator1, mockValidator2], + [mockValidator1, mockValidator3], + ]; + const epochsForCommittees = [5n, 6n]; + // Limit of 3: validator1@epoch5, validator2@epoch5, validator1@epoch6 are included; + // validator3@epoch6 is zeroed out + const { votes, truncatedCount } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, { + ...settings, + maxSlashedValidators: 3, + }); + + expect(truncatedCount).toBe(1); + expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize + expect(votes[0]).toEqual(3); // validator1 @ epoch5: included (1st) + expect(votes[1]).toEqual(2); // validator2 @ epoch5: included (2nd) + expect(votes[2]).toEqual(0); // padded + expect(votes[3]).toEqual(0); // padded + expect(votes[4]).toEqual(3); // validator1 @ epoch6: included (3rd) + expect(votes[5]).toEqual(0); // validator3 @ epoch6: zeroed out (limit reached) + expect(votes[6]).toEqual(0); // padded + expect(votes[7]).toEqual(0); // padded + }); + it('handles multiple consecutive empty committees', () => { const offenses: Offense[] = [ { @@ -485,7 +545,7 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n, 7n, 8n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(16); // 4 committees × 4 targetCommitteeSize expect(votes.slice(0, 4)).toEqual([0, 0, 0, 0]); // Committee 0: no matching offenses diff --git a/yarn-project/stdlib/src/slashing/tally.ts b/yarn-project/stdlib/src/slashing/tally.ts index 91bed7e4ff35..63ffe7fb1c28 100644 --- a/yarn-project/stdlib/src/slashing/tally.ts +++ b/yarn-project/stdlib/src/slashing/tally.ts @@ -12,6 +12,8 @@ import type { Offense, ValidatorSlashVote } from './types.js'; * @param committees - Array of committees (each containing array of validator addresses) * @param epochsForCommittees - Array of epochs corresponding to each committee * @param settings - Settings including slashingAmounts and optional validator override lists + * @param settings.maxSlashedValidators - If set, limits the total number of [validator, epoch] pairs + * with non-zero votes. * @returns Array of ValidatorSlashVote, where each vote is how many slash units the validator in that position should be slashed */ export function getSlashConsensusVotesFromOffenses( @@ -22,9 +24,10 @@ export function getSlashConsensusVotesFromOffenses( slashingAmounts: [bigint, bigint, bigint]; epochDuration: number; targetCommitteeSize: number; + maxSlashedValidators?: number; }, -): ValidatorSlashVote[] { - const { slashingAmounts, targetCommitteeSize } = settings; +): { votes: ValidatorSlashVote[]; truncatedCount: number } { + const { slashingAmounts, targetCommitteeSize, maxSlashedValidators } = settings; if (committees.length !== epochsForCommittees.length) { throw new Error('committees and epochsForCommittees must have the same length'); @@ -53,7 +56,19 @@ export function getSlashConsensusVotesFromOffenses( return padArrayEnd(votes, 0, targetCommitteeSize); }); - return votes; + // if a cap is set, zero out the lowest-vote [validator, epoch] pairs so that the most severe slashes stay. + if (maxSlashedValidators === undefined) { + return { votes, truncatedCount: 0 }; + } + + const nonZeroByDescendingVote = [...votes.entries()].filter(([, vote]) => vote > 0).sort(([, a], [, b]) => b - a); + + const toTruncate = nonZeroByDescendingVote.slice(maxSlashedValidators); + for (const [idx] of toTruncate) { + votes[idx] = 0; + } + + return { votes, truncatedCount: toTruncate.length }; } /** Returns the slash vote for the given amount to slash. */ From a2059147add40eed4ee9fa0c6ed549c68cbcf30b Mon Sep 17 00:00:00 2001 From: Amin Sammara <84764772+aminsammara@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:24:30 +0000 Subject: [PATCH 12/25] feat(slasher): make slash grace period relative to rollup upgrade time (#20942) ## Summary Closes A-596 - Anchors `SLASH_GRACE_PERIOD_L2_SLOTS` to the `CanonicalRollupUpdated` event emitted by the Registry when the rollup becomes canonical, instead of comparing against genesis slot 0 - Adds `getCanonicalRollupRegistrationTimestamp()` to `RegistryContract` to query the registration event timestamp - Computes and caches the canonical rollup registration L2 slot in the slasher factory, threading it through to the `SlashOffensesCollector` where the grace period comparison now uses `offenseSlot < registeredSlot + gracePeriodSlots` ## Motivation The rollup can be deployed weeks/months before becoming canonical (registered via `addRollup()`). By upgrade time, the current slot is far past any "slot from genesis" value, making the grace period ineffective. Now operators just set a duration (e.g. 3600 slots = 3 days) and the system anchors it to the actual upgrade time automatically. ## Test plan - Unit tests updated and passing for `SlashOffensesCollector`, `EmpireSlasherClient`, and `TallySlasherClient` - Grace period test verifies offenses within `registeredSlot + gracePeriod` are skipped and offenses after are not --- .../src/spartan/slash_inactivity.test.ts | 2 +- .../ethereum/src/contracts/registry.ts | 32 +++++++++++++++++- yarn-project/slasher/src/config.ts | 3 +- .../slasher/src/empire_slasher_client.test.ts | 1 + .../slasher/src/factory/create_facade.ts | 33 +++++++++++++++++-- .../src/factory/create_implementation.ts | 31 +++++++++++++++-- .../slasher/src/factory/get_settings.ts | 4 +-- .../src/slash_offenses_collector.test.ts | 26 ++++++++------- .../slasher/src/slash_offenses_collector.ts | 11 +++++-- .../slasher/src/slasher_client_facade.ts | 2 ++ .../slasher/src/tally_slasher_client.test.ts | 1 + 11 files changed, 122 insertions(+), 24 deletions(-) diff --git a/yarn-project/end-to-end/src/spartan/slash_inactivity.test.ts b/yarn-project/end-to-end/src/spartan/slash_inactivity.test.ts index 6092f1af2605..bdd7ea3e7fd0 100644 --- a/yarn-project/end-to-end/src/spartan/slash_inactivity.test.ts +++ b/yarn-project/end-to-end/src/spartan/slash_inactivity.test.ts @@ -38,7 +38,7 @@ describe('slash inactivity test', () => { let client: ViemPublicClient; let rollup: RollupContract; - let slashSettings: TallySlasherSettings; + let slashSettings: Omit; let constants: Omit; let monitor: ChainMonitor; let offlineValidator: EthAddress; diff --git a/yarn-project/ethereum/src/contracts/registry.ts b/yarn-project/ethereum/src/contracts/registry.ts index 89156ec13c7a..a4bf15f16bb8 100644 --- a/yarn-project/ethereum/src/contracts/registry.ts +++ b/yarn-project/ethereum/src/contracts/registry.ts @@ -3,7 +3,7 @@ import { createLogger } from '@aztec/foundation/log'; import { RegistryAbi } from '@aztec/l1-artifacts/RegistryAbi'; import { TestERC20Abi } from '@aztec/l1-artifacts/TestERC20Abi'; -import { type GetContractReturnType, type Hex, getContract } from 'viem'; +import { type GetContractReturnType, type Hex, getAbiItem, getContract } from 'viem'; import type { L1ContractAddresses } from '../l1_contract_addresses.js'; import type { ViemClient } from '../types.js'; @@ -128,4 +128,34 @@ export class RegistryContract { public async getRewardDistributor(): Promise { return EthAddress.fromString(await this.registry.read.getRewardDistributor()); } + + /** Returns the L1 timestamp at which the given rollup was registered via addRollup(). */ + public async getCanonicalRollupRegistrationTimestamp( + rollupAddress: EthAddress, + fromBlock?: bigint, + ): Promise { + const event = getAbiItem({ abi: RegistryAbi, name: 'CanonicalRollupUpdated' }); + const start = fromBlock ?? 0n; + const latestBlock = await this.client.getBlockNumber(); + const chunkSize = 1_000n; + + for (let from = start; from <= latestBlock; from += chunkSize) { + const to = from + chunkSize - 1n > latestBlock ? latestBlock : from + chunkSize - 1n; + const logs = await this.client.getLogs({ + address: this.address.toString(), + fromBlock: from, + toBlock: to, + strict: true, + event, + args: { instance: rollupAddress.toString() }, + }); + + if (logs.length > 0) { + const block = await this.client.getBlock({ blockNumber: logs[0].blockNumber }); + return block.timestamp; + } + } + + return undefined; + } } diff --git a/yarn-project/slasher/src/config.ts b/yarn-project/slasher/src/config.ts index 79cef1e58b1a..646225cc6e20 100644 --- a/yarn-project/slasher/src/config.ts +++ b/yarn-project/slasher/src/config.ts @@ -155,7 +155,8 @@ export const slasherConfigMappings: ConfigMappingsType = { ...numberConfigHelper(DefaultSlasherConfig.slashMaxPayloadSize), }, slashGracePeriodL2Slots: { - description: 'Number of L2 slots to wait before considering a slashing offense expired.', + description: + 'Number of L2 slots after the network upgrade during which slashing offenses are ignored. The upgrade time is determined from the CanonicalRollupUpdated event.', env: 'SLASH_GRACE_PERIOD_L2_SLOTS', ...numberConfigHelper(DefaultSlasherConfig.slashGracePeriodL2Slots), }, diff --git a/yarn-project/slasher/src/empire_slasher_client.test.ts b/yarn-project/slasher/src/empire_slasher_client.test.ts index 4aa40aee11f9..1bff7848fdc5 100644 --- a/yarn-project/slasher/src/empire_slasher_client.test.ts +++ b/yarn-project/slasher/src/empire_slasher_client.test.ts @@ -50,6 +50,7 @@ describe('EmpireSlasherClient', () => { slotDuration: 4, ethereumSlotDuration: 12, slashingAmounts: undefined, + rollupRegisteredAtL2Slot: SlotNumber(0), }; const config: SlasherConfig = { diff --git a/yarn-project/slasher/src/factory/create_facade.ts b/yarn-project/slasher/src/factory/create_facade.ts index af6f7ed9cd6c..6787fc65ce75 100644 --- a/yarn-project/slasher/src/factory/create_facade.ts +++ b/yarn-project/slasher/src/factory/create_facade.ts @@ -1,13 +1,15 @@ import { EpochCache } from '@aztec/epoch-cache'; -import { RollupContract } from '@aztec/ethereum/contracts'; +import { RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; import type { L1ReaderConfig } from '@aztec/ethereum/l1-reader'; import type { ViemClient } from '@aztec/ethereum/types'; +import { SlotNumber } from '@aztec/foundation/branded-types'; import { unique } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; import { DateProvider } from '@aztec/foundation/timer'; import type { DataStoreConfig } from '@aztec/kv-store/config'; import { createStore } from '@aztec/kv-store/lmdb-v2'; +import { getSlotAtTimestamp } from '@aztec/stdlib/epoch-helpers'; import type { SlasherConfig } from '@aztec/stdlib/interfaces/server'; import { SlasherClientFacade } from '../slasher_client_facade.js'; @@ -18,7 +20,7 @@ import type { Watcher } from '../watcher.js'; /** Creates a slasher client facade that updates itself whenever the rollup slasher changes */ export async function createSlasherFacade( config: SlasherConfig & DataStoreConfig & { ethereumSlotDuration: number }, - l1Contracts: Pick, + l1Contracts: Pick, l1Client: ViemClient, watchers: Watcher[], dateProvider: DateProvider, @@ -34,6 +36,32 @@ export async function createSlasherFacade( const kvStore = await createStore('slasher', SCHEMA_VERSION, config, logger.getBindings()); const rollup = new RollupContract(l1Client, l1Contracts.rollupAddress); + // Compute and cache the L2 slot at which the rollup was registered as canonical + const settingsMap = kvStore.openMap('slasher-settings'); + const cacheKey = `registeredSlot:${l1Contracts.rollupAddress}`; + let rollupRegisteredAtL2Slot = (await settingsMap.getAsync(cacheKey)) as SlotNumber | undefined; + + if (rollupRegisteredAtL2Slot === undefined) { + const registry = new RegistryContract(l1Client, l1Contracts.registryAddress); + const l1StartBlock = await rollup.getL1StartBlock(); + const registrationTimestamp = await registry.getCanonicalRollupRegistrationTimestamp( + l1Contracts.rollupAddress, + l1StartBlock, + ); + if (registrationTimestamp !== undefined) { + const l1GenesisTime = await rollup.getL1GenesisTime(); + const slotDuration = await rollup.getSlotDuration(); + rollupRegisteredAtL2Slot = getSlotAtTimestamp(registrationTimestamp, { + l1GenesisTime, + slotDuration: Number(slotDuration), + }); + } else { + rollupRegisteredAtL2Slot = SlotNumber(0); + } + await settingsMap.set(cacheKey, rollupRegisteredAtL2Slot); + logger.info(`Canonical rollup registered at L2 slot ${rollupRegisteredAtL2Slot}`); + } + const slashValidatorsNever = config.slashSelfAllowed ? config.slashValidatorsNever : unique([...config.slashValidatorsNever, ...validatorAddresses].map(a => a.toString())).map(EthAddress.fromString); @@ -48,6 +76,7 @@ export async function createSlasherFacade( epochCache, dateProvider, kvStore, + rollupRegisteredAtL2Slot, logger, ); } diff --git a/yarn-project/slasher/src/factory/create_implementation.ts b/yarn-project/slasher/src/factory/create_implementation.ts index d793f3285709..0c6eb8ce6d76 100644 --- a/yarn-project/slasher/src/factory/create_implementation.ts +++ b/yarn-project/slasher/src/factory/create_implementation.ts @@ -5,6 +5,7 @@ import { TallySlashingProposerContract, } from '@aztec/ethereum/contracts'; import type { ViemClient } from '@aztec/ethereum/types'; +import type { SlotNumber } from '@aztec/foundation/branded-types'; import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; import { DateProvider } from '@aztec/foundation/timer'; @@ -31,19 +32,40 @@ export async function createSlasherImplementation( epochCache: EpochCache, dateProvider: DateProvider, kvStore: AztecLMDBStoreV2, + rollupRegisteredAtL2Slot: SlotNumber, logger = createLogger('slasher'), ) { const proposer = await rollup.getSlashingProposer(); if (!proposer) { return new NullSlasherClient(config); } else if (proposer.type === 'tally') { - return createTallySlasher(config, rollup, proposer, watchers, dateProvider, epochCache, kvStore, logger); + return createTallySlasher( + config, + rollup, + proposer, + watchers, + dateProvider, + epochCache, + kvStore, + rollupRegisteredAtL2Slot, + logger, + ); } else { if (!slashFactoryAddress || slashFactoryAddress.equals(EthAddress.ZERO)) { throw new Error('Cannot initialize an empire-based SlasherClient without a SlashFactory address'); } const slashFactory = new SlashFactoryContract(l1Client, slashFactoryAddress.toString()); - return createEmpireSlasher(config, rollup, proposer, slashFactory, watchers, dateProvider, kvStore, logger); + return createEmpireSlasher( + config, + rollup, + proposer, + slashFactory, + watchers, + dateProvider, + kvStore, + rollupRegisteredAtL2Slot, + logger, + ); } } @@ -55,6 +77,7 @@ async function createEmpireSlasher( watchers: Watcher[], dateProvider: DateProvider, kvStore: AztecLMDBStoreV2, + rollupRegisteredAtL2Slot: SlotNumber, logger = createLogger('slasher'), ): Promise { if (slashingProposer.type !== 'empire') { @@ -97,6 +120,7 @@ async function createEmpireSlasher( l1StartBlock, ethereumSlotDuration: config.ethereumSlotDuration, slashingAmounts: undefined, + rollupRegisteredAtL2Slot, }; const payloadsStore = new SlasherPayloadsStore(kvStore, { @@ -130,13 +154,14 @@ async function createTallySlasher( dateProvider: DateProvider, epochCache: EpochCache, kvStore: AztecLMDBStoreV2, + rollupRegisteredAtL2Slot: SlotNumber, logger = createLogger('slasher'), ): Promise { if (slashingProposer.type !== 'tally') { throw new Error('Slashing proposer contract is not of type tally'); } - const settings = await getTallySlasherSettings(rollup, slashingProposer); + const settings = { ...(await getTallySlasherSettings(rollup, slashingProposer)), rollupRegisteredAtL2Slot }; const slasher = await rollup.getSlasherContract(); const offensesStore = new SlasherOffensesStore(kvStore, { diff --git a/yarn-project/slasher/src/factory/get_settings.ts b/yarn-project/slasher/src/factory/get_settings.ts index 078073847e13..6fd10662edcd 100644 --- a/yarn-project/slasher/src/factory/get_settings.ts +++ b/yarn-project/slasher/src/factory/get_settings.ts @@ -5,7 +5,7 @@ import type { TallySlasherSettings } from '../tally_slasher_client.js'; export async function getTallySlasherSettings( rollup: RollupContract, slashingProposer?: TallySlashingProposerContract, -): Promise { +): Promise> { if (!slashingProposer) { const rollupSlashingProposer = await rollup.getSlashingProposer(); if (!rollupSlashingProposer || rollupSlashingProposer.type !== 'tally') { @@ -40,7 +40,7 @@ export async function getTallySlasherSettings( rollup.getTargetCommitteeSize(), ]); - const settings: TallySlasherSettings = { + const settings: Omit = { slashingExecutionDelayInRounds: Number(slashingExecutionDelayInRounds), slashingRoundSize: Number(slashingRoundSize), slashingRoundSizeInEpochs: Number(slashingRoundSizeInEpochs), diff --git a/yarn-project/slasher/src/slash_offenses_collector.test.ts b/yarn-project/slasher/src/slash_offenses_collector.test.ts index f23d1824fb18..3eeeb532494e 100644 --- a/yarn-project/slasher/src/slash_offenses_collector.test.ts +++ b/yarn-project/slasher/src/slash_offenses_collector.test.ts @@ -1,3 +1,4 @@ +import { SlotNumber } from '@aztec/foundation/branded-types'; import { EthAddress } from '@aztec/foundation/eth-address'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { openTmpStore } from '@aztec/kv-store/lmdb'; @@ -18,6 +19,7 @@ describe('SlashOffensesCollector', () => { const settings: SlashOffensesCollectorSettings = { epochDuration: 32, slashingAmounts: [100n, 200n, 300n], + rollupRegisteredAtL2Slot: 100 as SlotNumber, }; const config: SlasherConfig = { @@ -90,27 +92,28 @@ describe('SlashOffensesCollector', () => { }); }); - it('should skip offenses that happen during grace period', async () => { + it('should skip offenses that happen during grace period after upgrade', async () => { const validator1 = EthAddress.random(); const validator2 = EthAddress.random(); - // Create offense during grace period (slot < slashGracePeriodL2Slots = 10) + // Grace period is registeredSlot (100) + gracePeriodL2Slots (10) = 110 + // Create offense during grace period (slot 105 < 110) const gracePeriodOffense: WantToSlashArgs[] = [ { validator: validator1, amount: 1000000000000000000n, offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, // Slot-based offense - epochOrSlot: 5n, // Within grace period (< 10) + epochOrSlot: 105n, // Within grace period (< 110) }, ]; - // Create offense after grace period + // Create offense after grace period (slot 115 >= 110) const validOffense: WantToSlashArgs[] = [ { validator: validator2, amount: 2000000000000000000n, offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, // Slot-based offense - epochOrSlot: 20n, // After grace period (>= 10) + epochOrSlot: 115n, // After grace period (>= 110) }, ]; @@ -134,25 +137,26 @@ describe('SlashOffensesCollector', () => { const validator2 = EthAddress.random(); const validator3 = EthAddress.random(); - // Create an event with multiple offenses in a single array + // Grace period ends at registeredSlot (100) + gracePeriod (10) = 110 + // All offenses are after the grace period const multipleOffensesArgs: WantToSlashArgs[] = [ { validator: validator1, amount: 1000000000000000000n, offenseType: OffenseType.INACTIVITY, - epochOrSlot: 100n, + epochOrSlot: 100n, // epoch 100 → slot 3200, well past grace period }, { validator: validator2, amount: 2000000000000000000n, offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, - epochOrSlot: 50n, + epochOrSlot: 150n, // slot 150 >= 110 }, { validator: validator3, amount: 1500000000000000000n, offenseType: OffenseType.ATTESTED_DESCENDANT_OF_INVALID, - epochOrSlot: 75n, + epochOrSlot: 175n, // slot 175 >= 110 }, ]; @@ -182,14 +186,14 @@ describe('SlashOffensesCollector', () => { validator: validator2, amount: 2000000000000000000n, offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, - epochOrSlot: 50n, + epochOrSlot: 150n, }); expect(offensesByValidator[validator3.toString()]).toMatchObject({ validator: validator3, amount: 1500000000000000000n, offenseType: OffenseType.ATTESTED_DESCENDANT_OF_INVALID, - epochOrSlot: 75n, + epochOrSlot: 175n, }); }); }); diff --git a/yarn-project/slasher/src/slash_offenses_collector.ts b/yarn-project/slasher/src/slash_offenses_collector.ts index 551f868ccec3..59cc7a0e1dc6 100644 --- a/yarn-project/slasher/src/slash_offenses_collector.ts +++ b/yarn-project/slasher/src/slash_offenses_collector.ts @@ -1,3 +1,4 @@ +import type { SlotNumber } from '@aztec/foundation/branded-types'; import { createLogger } from '@aztec/foundation/log'; import type { Prettify } from '@aztec/foundation/types'; import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; @@ -9,7 +10,11 @@ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher } from './watch export type SlashOffensesCollectorConfig = Prettify>; export type SlashOffensesCollectorSettings = Prettify< - Pick & { slashingAmounts: [bigint, bigint, bigint] | undefined } + Pick & { + slashingAmounts: [bigint, bigint, bigint] | undefined; + /** L2 slot at which the rollup was registered as canonical in the Registry. Used to anchor the slash grace period. */ + rollupRegisteredAtL2Slot: SlotNumber; + } >; /** @@ -110,9 +115,9 @@ export class SlashOffensesCollector { return this.offensesStore.markAsSlashed(offenses); } - /** Returns whether to skip an offense if it happened during the grace period at the beginning of the chain */ + /** Returns whether to skip an offense if it happened during the grace period after the network upgrade */ private shouldSkipOffense(offense: Offense): boolean { const offenseSlot = getSlotForOffense(offense, this.settings); - return offenseSlot < this.config.slashGracePeriodL2Slots; + return offenseSlot < this.settings.rollupRegisteredAtL2Slot + this.config.slashGracePeriodL2Slots; } } diff --git a/yarn-project/slasher/src/slasher_client_facade.ts b/yarn-project/slasher/src/slasher_client_facade.ts index 943084816870..0ef4a677ac0a 100644 --- a/yarn-project/slasher/src/slasher_client_facade.ts +++ b/yarn-project/slasher/src/slasher_client_facade.ts @@ -32,6 +32,7 @@ export class SlasherClientFacade implements SlasherClientInterface { private epochCache: EpochCache, private dateProvider: DateProvider, private kvStore: AztecLMDBStoreV2, + private rollupRegisteredAtL2Slot: SlotNumber, private logger = createLogger('slasher'), ) {} @@ -88,6 +89,7 @@ export class SlasherClientFacade implements SlasherClientInterface { this.epochCache, this.dateProvider, this.kvStore, + this.rollupRegisteredAtL2Slot, this.logger, ); } diff --git a/yarn-project/slasher/src/tally_slasher_client.test.ts b/yarn-project/slasher/src/tally_slasher_client.test.ts index 9ca14eaa7a44..62f5b492fa99 100644 --- a/yarn-project/slasher/src/tally_slasher_client.test.ts +++ b/yarn-project/slasher/src/tally_slasher_client.test.ts @@ -50,6 +50,7 @@ describe('TallySlasherClient', () => { l1GenesisTime: BigInt(Math.floor(Date.now() / 1000) - 10000), slotDuration: 4, slashingQuorumSize: 110, + rollupRegisteredAtL2Slot: SlotNumber(0), }; const config: SlasherConfig = { From b8f26a861776ed1d8d1be1cf29b18a4eaeef4a5d Mon Sep 17 00:00:00 2001 From: Alex Gherghisan Date: Fri, 27 Feb 2026 14:28:56 +0000 Subject: [PATCH 13/25] chore: add script to find PRs to backport (#20956) . --- scripts/find_missing_backports.sh | 213 ++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100755 scripts/find_missing_backports.sh diff --git a/scripts/find_missing_backports.sh b/scripts/find_missing_backports.sh new file mode 100755 index 000000000000..2a023871b7f6 --- /dev/null +++ b/scripts/find_missing_backports.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Find failed backports and determine which have been manually resolved vs missed. +# +# Usage: ./scripts/find_missing_backports.sh [--since YYYY-MM-DD] [--branch TARGET] +# +# Requires: gh, jq + +REPO="AztecProtocol/aztec-packages" +SINCE="2026-02-22" +TARGET_BRANCH="v4" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --since) + SINCE="$2" + shift 2 + ;; + --branch) + TARGET_BRANCH="$2" + shift 2 + ;; + *) + echo "Usage: $0 [--since YYYY-MM-DD] [--branch TARGET]" >&2 + exit 1 + ;; + esac +done + +STAGING_BRANCH="backport-to-${TARGET_BRANCH}-staging" +LABEL="backport-to-${TARGET_BRANCH}" + +command -v gh >/dev/null 2>&1 || { echo "Error: 'gh' CLI not found." >&2; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "Error: 'jq' not found." >&2; exit 1; } + +echo "=== Finding Missing $TARGET_BRANCH Backports ===" +echo "Repo: $REPO" +echo "Target branch: $TARGET_BRANCH" +echo "Since: $SINCE" +echo "" + +# --------------------------------------------------------------------------- +# Step 1: Get all merged PRs with $LABEL label since $SINCE +# --------------------------------------------------------------------------- +echo "Step 1: Fetching merged PRs with '$LABEL' label since $SINCE ..." + +ALL_PRS=$(gh pr list --repo "$REPO" \ + --label "$LABEL" \ + --state merged \ + --search "merged:>=$SINCE" \ + --json number,title \ + --limit 200) + +TOTAL_COUNT=$(echo "$ALL_PRS" | jq 'length') +echo " Found $TOTAL_COUNT merged PRs with $LABEL label." + +if [[ "$TOTAL_COUNT" -eq 0 ]]; then + echo "No PRs found. Nothing to do." + exit 0 +fi + +# --------------------------------------------------------------------------- +# Step 2: Filter to only those with a failed cherry-pick comment +# --------------------------------------------------------------------------- +echo "" +echo "Step 2: Checking each PR for failed cherry-pick comments ..." + +FAILED_PRS=() +FAILED_TITLES=() + +while IFS= read -r line; do + pr_number=$(echo "$line" | jq -r '.number') + pr_title=$(echo "$line" | jq -r '.title') + + # Fetch comments and look for the failure marker + # Both old ("Please backport manually") and new ("Dispatching ClaudeBox") variants + # share the prefix "❌ Failed to cherry-pick" + has_failure=$(gh api "repos/$REPO/issues/$pr_number/comments" \ + --paginate \ + --jq '.[].body' 2>/dev/null \ + | grep -c "❌ Failed to cherry-pick" || true) + + if [[ "$has_failure" -gt 0 ]]; then + FAILED_PRS+=("$pr_number") + FAILED_TITLES+=("$pr_title") + echo " #$pr_number - FAILED - $pr_title" + fi +done < <(echo "$ALL_PRS" | jq -c '.[]') + +echo "" +echo " ${#FAILED_PRS[@]} PRs had failed cherry-picks out of $TOTAL_COUNT total." + +if [[ ${#FAILED_PRS[@]} -eq 0 ]]; then + echo "No failed backports found. All clean!" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Step 3: Gather backported PR numbers from staging PRs and staging branch +# --------------------------------------------------------------------------- +echo "" +echo "Step 3: Checking staging branch ($STAGING_BRANCH -> $TARGET_BRANCH) for backported commits ..." + +BACKPORTED_PR_NUMS=() + +# 3a: Get commits from staging PRs (open and merged) +STAGING_PRS=$(gh pr list --repo "$REPO" \ + --base "$TARGET_BRANCH" \ + --head "$STAGING_BRANCH" \ + --state all \ + --json number \ + --limit 50) + +STAGING_PR_COUNT=$(echo "$STAGING_PRS" | jq 'length') +echo " Found $STAGING_PR_COUNT staging PR(s)." + +for staging_pr in $(echo "$STAGING_PRS" | jq -r '.[].number'); do + echo " Fetching commits from staging PR #$staging_pr ..." + + # gh api paginates at 30 commits per page by default + COMMIT_MESSAGES=$(gh api "repos/$REPO/pulls/$staging_pr/commits" \ + --paginate \ + --jq '.[].commit.message' 2>/dev/null || true) + + # Extract PR numbers from commit messages: look for (#XXXX) pattern + while IFS= read -r pr_ref; do + if [[ -n "$pr_ref" ]]; then + BACKPORTED_PR_NUMS+=("$pr_ref") + fi + done < <(echo "$COMMIT_MESSAGES" | grep -oP '\(#\K[0-9]+(?=\))' | sort -u) +done + +# 3b: Also check commits on the staging branch directly (covers commits not yet in a PR) +echo " Checking branch commits via compare API ($TARGET_BRANCH...$STAGING_BRANCH) ..." +BRANCH_COMMITS=$(gh api "repos/$REPO/compare/${TARGET_BRANCH}...${STAGING_BRANCH}" \ + --jq '.commits[].commit.message' 2>/dev/null || true) + +if [[ -n "$BRANCH_COMMITS" ]]; then + while IFS= read -r pr_ref; do + if [[ -n "$pr_ref" ]]; then + BACKPORTED_PR_NUMS+=("$pr_ref") + fi + done < <(echo "$BRANCH_COMMITS" | grep -oP '\(#\K[0-9]+(?=\))' | sort -u) +fi + +# Deduplicate +BACKPORTED_PR_NUMS=($(printf '%s\n' "${BACKPORTED_PR_NUMS[@]}" | sort -u)) + +echo " Found ${#BACKPORTED_PR_NUMS[@]} unique PR references in staging commits." + +# --------------------------------------------------------------------------- +# Step 4: Cross-reference and produce report +# --------------------------------------------------------------------------- +echo "" +echo "==============================================" +echo " BACKPORT STATUS REPORT (since $SINCE)" +echo "==============================================" +echo "" + +RESOLVED=() +RESOLVED_TITLES=() +MISSING=() +MISSING_TITLES=() + +for i in "${!FAILED_PRS[@]}"; do + pr_num="${FAILED_PRS[$i]}" + pr_title="${FAILED_TITLES[$i]}" + + found=0 + for backported in "${BACKPORTED_PR_NUMS[@]}"; do + if [[ "$backported" == "$pr_num" ]]; then + found=1 + break + fi + done + + if [[ "$found" -eq 1 ]]; then + RESOLVED+=("$pr_num") + RESOLVED_TITLES+=("$pr_title") + else + MISSING+=("$pr_num") + MISSING_TITLES+=("$pr_title") + fi +done + +if [[ ${#RESOLVED[@]} -gt 0 ]]; then + echo "RESOLVED (${#RESOLVED[@]}):" + echo "---" + for i in "${!RESOLVED[@]}"; do + echo " ✅ #${RESOLVED[$i]} - ${RESOLVED_TITLES[$i]}" + echo " https://github.com/$REPO/pull/${RESOLVED[$i]}" + done + echo "" +fi + +if [[ ${#MISSING[@]} -gt 0 ]]; then + echo "MISSING (${#MISSING[@]}):" + echo "---" + for i in "${!MISSING[@]}"; do + echo " ❌ #${MISSING[$i]} - ${MISSING_TITLES[$i]}" + echo " https://github.com/$REPO/pull/${MISSING[$i]}" + done + echo "" +else + echo "🎉 All failed backports have been resolved!" + echo "" +fi + +echo "==============================================" +echo "Summary: ${#FAILED_PRS[@]} failed, ${#RESOLVED[@]} resolved, ${#MISSING[@]} missing" +echo "==============================================" From acc23400b6c00c5494bf5552ef53758fb5f99c99 Mon Sep 17 00:00:00 2001 From: spypsy Date: Fri, 27 Feb 2026 16:36:40 +0000 Subject: [PATCH 14/25] chore: remove unused prover-node dep (#20955) Fixes [A-564](https://linear.app/aztec-labs/issue/A-564/ensure-node-sources-are-correctly-injected-into-the-prover-node) --- yarn-project/prover-node/src/factory.ts | 5 ----- yarn-project/prover-node/src/prover-node.test.ts | 9 +++++++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/yarn-project/prover-node/src/factory.ts b/yarn-project/prover-node/src/factory.ts index c0d3660b09dd..ee312b2ce90d 100644 --- a/yarn-project/prover-node/src/factory.ts +++ b/yarn-project/prover-node/src/factory.ts @@ -19,7 +19,6 @@ import { getPublisherConfigFromProverConfig, } from '@aztec/sequencer-client'; import type { - AztecNode, ITxProvider, ProverConfig, ProvingJobBroker, @@ -38,7 +37,6 @@ import { ProverPublisherFactory } from './prover-publisher-factory.js'; export type ProverNodeDeps = { telemetry?: TelemetryClient; log?: Logger; - aztecNodeTxProvider?: Pick; archiver: Archiver; publisherFactory?: ProverPublisherFactory; broker?: ProvingJobBroker; @@ -128,9 +126,6 @@ export async function createProverNode( telemetry, }); - // TODO(#20393): Check that the tx collection node sources are properly injected - // See aztecNodeTxProvider - const proverNodeConfig = { ...pick( config, diff --git a/yarn-project/prover-node/src/prover-node.test.ts b/yarn-project/prover-node/src/prover-node.test.ts index 4b9c28d2c8b7..9effe18edd71 100644 --- a/yarn-project/prover-node/src/prover-node.test.ts +++ b/yarn-project/prover-node/src/prover-node.test.ts @@ -207,6 +207,15 @@ describe('prover-node', () => { expect(proverNode.totalJobCount).toEqual(0); }); + it('gathers txs via the p2p client tx provider', async () => { + await proverNode.handleEpochReadyToProve(EpochNumber.fromBigInt(10n)); + // The prover node must route tx gathering through the shared p2p client's tx provider + expect(p2p.getTxProvider).toHaveBeenCalled(); + // One call per block across all checkpoints in the epoch + const totalBlocks = checkpoints.flatMap(c => c.blocks).length; + expect(txProvider.getTxsForBlock).toHaveBeenCalledTimes(totalBlocks); + }); + it('does not start a proof if there is a tx missing from coordinator', async () => { txProvider.getTxsForBlock.mockResolvedValue({ missingTxs: [TxHash.random()], txs: [] }); await proverNode.handleEpochReadyToProve(EpochNumber.fromBigInt(10n)); From 3c106f2d676fbf4b370ddd65f258478aa1fefbb3 Mon Sep 17 00:00:00 2001 From: Aztec Bot <49558828+AztecBot@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:58:19 -0500 Subject: [PATCH 15/25] fix: increase minFeePadding in e2e_bot bridge resume tests and harden GasFees.mul() (#20962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Increased `minFeePadding` from 9 (10x headroom) to 99 (100x headroom) in both bridge resume tests in `e2e_bot.test.ts`. Gas fees can escalate >13x during rapid block building in the test environment, causing flaky failures with insufficient padding. - Improved `GasFees.mul()` to use bigint arithmetic for integer scalars, avoiding potential precision loss with large values. Non-integer scalars (e.g., 1.5) are handled via `Math.ceil` to ensure fees always round up. ## Test plan - CI should pass the `e2e_bot` test suite, specifically "does not reuse prior bridge claims if recipient address changes" - Existing callers of `GasFees.mul()` with integer and non-integer scalars should work identically 🤖 Generated with [Claude Code](https://claude.com/claude-code) [ClaudeBox log](http://ci.aztec-labs.com/eb7d3f6401d9866b-1) --------- Co-authored-by: ludamad --- yarn-project/end-to-end/src/e2e_bot.test.ts | 11 +++++++---- yarn-project/stdlib/src/gas/gas_fees.ts | 8 +++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_bot.test.ts b/yarn-project/end-to-end/src/e2e_bot.test.ts index 00c5b06e16c3..78a1f6affff9 100644 --- a/yarn-project/end-to-end/src/e2e_bot.test.ts +++ b/yarn-project/end-to-end/src/e2e_bot.test.ts @@ -134,8 +134,9 @@ describe('e2e_bot', () => { // TODO: this should be taken from the `setup` call above l1Mnemonic: new SecretValue('test test test test test test test test test test test junk'), flushSetupTransactions: true, - // Increase fee headroom to handle fee volatility from rapid block building in tests - minFeePadding: 9, + // Increase fee headroom to handle fee volatility from rapid block building in tests. + // Fees can escalate >10x due to blocks built by earlier tests and bridge operations. + minFeePadding: 99, }; { @@ -174,8 +175,10 @@ describe('e2e_bot', () => { // TODO: this should be taken from the `setup` call above l1Mnemonic: new SecretValue('test test test test test test test test test test test junk'), flushSetupTransactions: true, - // Increase fee headroom to handle fee volatility from rapid block building in tests - minFeePadding: 9, + // Increase fee headroom to handle fee volatility from rapid block building in tests. + // This test is especially susceptible because changing salt triggers a new bridge claim, + // adding more block building on top of what earlier tests already produced. + minFeePadding: 99, }; { diff --git a/yarn-project/stdlib/src/gas/gas_fees.ts b/yarn-project/stdlib/src/gas/gas_fees.ts index ca2e700c27da..7387b2df0496 100644 --- a/yarn-project/stdlib/src/gas/gas_fees.ts +++ b/yarn-project/stdlib/src/gas/gas_fees.ts @@ -56,8 +56,14 @@ export class GasFees { return this.clone(); } else if (typeof scalar === 'bigint') { return new GasFees(this.feePerDaGas * scalar, this.feePerL2Gas * scalar); + } else if (Number.isInteger(scalar)) { + const s = BigInt(scalar); + return new GasFees(this.feePerDaGas * s, this.feePerL2Gas * s); } else { - return new GasFees(Number(this.feePerDaGas) * scalar, Number(this.feePerL2Gas) * scalar); + return new GasFees( + BigInt(Math.ceil(Number(this.feePerDaGas) * scalar)), + BigInt(Math.ceil(Number(this.feePerL2Gas) * scalar)), + ); } } From fe1d5aba5fb48cf8a12c82045eb5d9d3ef4cdbc0 Mon Sep 17 00:00:00 2001 From: danielntmd <162406516+danielntmd@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:35:05 -0500 Subject: [PATCH 16/25] feat(sequencer): (A-526) rotate publishers when send fails (#20888) When Multicall3.forward() throws a send-level error, retry with the next available publisher instead of returning undefined. On-chain reverts and simulation errors are not retried. Co-authored-by: danielntmd --- .../publisher/sequencer-publisher-factory.ts | 15 ++ .../src/publisher/sequencer-publisher.test.ts | 135 ++++++++++++++++++ .../src/publisher/sequencer-publisher.ts | 70 +++++++-- 3 files changed, 211 insertions(+), 9 deletions(-) diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts index c58bc4d40afd..0583f969d49e 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts @@ -81,8 +81,23 @@ export class SequencerPublisherFactory { const rollup = this.deps.rollupContract; const slashingProposerContract = await rollup.getSlashingProposer(); + const getNextPublisher = async (excludeAddresses: EthAddress[]): Promise => { + const exclusionFilter: PublisherFilter = (utils: L1TxUtils) => { + if (excludeAddresses.some(addr => addr.equals(utils.getSenderAddress()))) { + return false; + } + return filter(utils); + }; + try { + return await this.deps.publisherManager.getAvailablePublisher(exclusionFilter); + } catch { + return undefined; + } + }; + const publisher = new SequencerPublisher(this.sequencerConfig, { l1TxUtils: l1Publisher, + getNextPublisher, telemetry: this.deps.telemetry, blobClient: this.deps.blobClient, rollupContract: this.deps.rollupContract, diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts index d944412263d5..70fbaa1ebc2c 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts @@ -16,6 +16,7 @@ import { } from '@aztec/ethereum/l1-tx-utils'; import { FormattedViemError } from '@aztec/ethereum/utils'; import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { TimeoutError } from '@aztec/foundation/error'; import { EthAddress } from '@aztec/foundation/eth-address'; import { sleep } from '@aztec/foundation/sleep'; import { TestDateProvider } from '@aztec/foundation/timer'; @@ -299,6 +300,140 @@ describe('SequencerPublisher', () => { expect(result).toEqual(undefined); }); + describe('publisher rotation on send failure', () => { + let secondL1TxUtils: MockProxy; + let getNextPublisher: jest.MockedFunction<(excludeAddresses: EthAddress[]) => Promise>; + let rotatingPublisher: SequencerPublisher; + + beforeEach(() => { + secondL1TxUtils = mock(); + secondL1TxUtils.getBlockNumber.mockResolvedValue(1n); + secondL1TxUtils.getSenderAddress.mockReturnValue(EthAddress.random()); + secondL1TxUtils.getSenderBalance.mockResolvedValue(1000n); + + getNextPublisher = jest.fn(); + + const epochCache = mock(); + epochCache.getEpochAndSlotNow.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(2), + ts: 3n, + nowMs: 3000n, + }); + epochCache.getCommittee.mockResolvedValue({ + committee: [], + seed: 1n, + epoch: EpochNumber(1), + isEscapeHatchOpen: false, + }); + + rotatingPublisher = new SequencerPublisher({ ethereumSlotDuration: 12, l1ChainId: 1 } as any, { + blobClient, + rollupContract: rollup, + l1TxUtils, + epochCache, + slashingProposerContract, + governanceProposerContract, + slashFactoryContract, + dateProvider: new TestDateProvider(), + metrics: l1Metrics, + lastActions: {}, + getNextPublisher, + }); + }); + + it('rotates to next publisher when forward throws and retries successfully', async () => { + forwardSpy + .mockRejectedValueOnce(new Error('RPC error')) + .mockResolvedValueOnce({ receipt: proposeTxReceipt, errorMsg: undefined }); + getNextPublisher.mockResolvedValueOnce(secondL1TxUtils); + + await rotatingPublisher.enqueueProposeCheckpoint( + new Checkpoint(l2Block.archive, header, [l2Block], l2Block.checkpointNumber), + CommitteeAttestationsAndSigners.empty(), + Signature.empty(), + ); + const result = await rotatingPublisher.sendRequests(); + + expect(forwardSpy).toHaveBeenCalledTimes(2); + // First call uses original publisher, second uses the rotated one + expect(forwardSpy).toHaveBeenNthCalledWith( + 1, + expect.anything(), + l1TxUtils, + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + ); + expect(forwardSpy).toHaveBeenNthCalledWith( + 2, + expect.anything(), + secondL1TxUtils, + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + ); + expect(getNextPublisher).toHaveBeenCalledWith([l1TxUtils.getSenderAddress()]); + // Result is defined (rotation succeeded and tx was sent) + expect(result).toBeDefined(); + expect(result?.sentActions).toContain('propose'); + // l1TxUtils updated to the one that succeeded + expect(rotatingPublisher.l1TxUtils).toBe(secondL1TxUtils); + }); + + it('does not rotate on TimeoutError, re-throws instead', async () => { + forwardSpy.mockRejectedValueOnce(new TimeoutError('timed out')); + + await rotatingPublisher.enqueueProposeCheckpoint( + new Checkpoint(l2Block.archive, header, [l2Block], l2Block.checkpointNumber), + CommitteeAttestationsAndSigners.empty(), + Signature.empty(), + ); + // TimeoutError propagates to the outer catch in sendRequests which returns undefined + const result = await rotatingPublisher.sendRequests(); + + expect(result).toBeUndefined(); + expect(getNextPublisher).not.toHaveBeenCalled(); + expect(forwardSpy).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when all publishers are exhausted', async () => { + forwardSpy + .mockRejectedValueOnce(new Error('RPC error on first')) + .mockRejectedValueOnce(new Error('RPC error on second')); + getNextPublisher.mockResolvedValueOnce(secondL1TxUtils).mockResolvedValueOnce(undefined); + + await rotatingPublisher.enqueueProposeCheckpoint( + new Checkpoint(l2Block.archive, header, [l2Block], l2Block.checkpointNumber), + CommitteeAttestationsAndSigners.empty(), + Signature.empty(), + ); + const result = await rotatingPublisher.sendRequests(); + + expect(forwardSpy).toHaveBeenCalledTimes(2); + expect(getNextPublisher).toHaveBeenCalledTimes(2); + expect(result).toBeUndefined(); + }); + + it('does not rotate when forward returns a revert (on-chain failure)', async () => { + forwardSpy.mockResolvedValue({ receipt: { ...proposeTxReceipt, status: 'reverted' }, errorMsg: 'revert reason' }); + + await rotatingPublisher.enqueueProposeCheckpoint( + new Checkpoint(l2Block.archive, header, [l2Block], l2Block.checkpointNumber), + CommitteeAttestationsAndSigners.empty(), + Signature.empty(), + ); + const result = await rotatingPublisher.sendRequests(); + + expect(forwardSpy).toHaveBeenCalledTimes(1); + expect(getNextPublisher).not.toHaveBeenCalled(); + // Result contains the reverted receipt (no rotation) + expect(result?.result).toMatchObject({ receipt: { status: 'reverted' } }); + }); + }); + it('does not send propose tx if rollup validation fails', async () => { l1TxUtils.simulate.mockRejectedValueOnce(new Error('Test error')); diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index c147e81facda..7398d2fa67cb 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -30,6 +30,7 @@ import { toHex as toPaddedHex } from '@aztec/foundation/bigint-buffer'; import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { pick } from '@aztec/foundation/collection'; import type { Fr } from '@aztec/foundation/curves/bn254'; +import { TimeoutError } from '@aztec/foundation/error'; import { EthAddress } from '@aztec/foundation/eth-address'; import { Signature, type ViemSignature } from '@aztec/foundation/eth-signature'; import { type Logger, createLogger } from '@aztec/foundation/log'; @@ -137,6 +138,9 @@ export class SequencerPublisher { /** Address to use for simulations in fisherman mode (actual proposer's address) */ private proposerAddressForSimulation?: EthAddress; + /** Optional callback to obtain a replacement publisher when the current one fails to send. */ + private getNextPublisher?: (excludeAddresses: EthAddress[]) => Promise; + /** L1 fee analyzer for fisherman mode */ private l1FeeAnalyzer?: L1FeeAnalyzer; @@ -175,6 +179,7 @@ export class SequencerPublisher { metrics: SequencerPublisherMetrics; lastActions: Partial>; log?: Logger; + getNextPublisher?: (excludeAddresses: EthAddress[]) => Promise; }, ) { this.log = deps.log ?? createLogger('sequencer:publisher'); @@ -188,6 +193,7 @@ export class SequencerPublisher { this.metrics = deps.metrics ?? new SequencerPublisherMetrics(telemetry, 'SequencerPublisher'); this.tracer = telemetry.getTracer('SequencerPublisher'); this.l1TxUtils = deps.l1TxUtils; + this.getNextPublisher = deps.getNextPublisher; this.rollupContract = deps.rollupContract; @@ -437,19 +443,16 @@ export class SequencerPublisher { }); const blobDataHex = blobConfig?.blobs?.map(b => toHex(b)) as Hex[] | undefined; + const txContext = { multicallData, blobData: blobDataHex, l1BlockNumber }; + this.log.debug('Forwarding transactions', { validRequests: validRequests.map(request => request.action), txConfig, }); - const result = await Multicall3.forward( - validRequests.map(request => request.request), - this.l1TxUtils, - txConfig, - blobConfig, - this.rollupContract.address, - this.log, - ); - const txContext = { multicallData, blobData: blobDataHex, l1BlockNumber }; + const result = await this.forwardWithPublisherRotation(validRequests, txConfig, blobConfig); + if (result === undefined) { + return undefined; + } const { successfulActions = [], failedActions = [] } = this.callbackBundledTransactions( validRequests, result, @@ -472,6 +475,55 @@ export class SequencerPublisher { } } + /** + * Forwards transactions via Multicall3, rotating to the next available publisher if a send + * failure occurs (i.e. the tx never reached the chain). + * On-chain reverts and simulation errors are returned as-is without rotation. + */ + private async forwardWithPublisherRotation( + validRequests: RequestWithExpiry[], + txConfig: RequestWithExpiry['gasConfig'], + blobConfig: L1BlobInputs | undefined, + ) { + const triedAddresses: EthAddress[] = []; + let currentPublisher = this.l1TxUtils; + + while (true) { + triedAddresses.push(currentPublisher.getSenderAddress()); + try { + const result = await Multicall3.forward( + validRequests.map(r => r.request), + currentPublisher, + txConfig, + blobConfig, + this.rollupContract.address, + this.log, + ); + this.l1TxUtils = currentPublisher; + return result; + } catch (err) { + if (err instanceof TimeoutError) { + throw err; + } + const viemError = formatViemError(err); + if (!this.getNextPublisher) { + this.log.error('Failed to publish bundled transactions', viemError); + return undefined; + } + this.log.warn( + `Publisher ${currentPublisher.getSenderAddress()} failed to send, rotating to next publisher`, + viemError, + ); + const nextPublisher = await this.getNextPublisher([...triedAddresses]); + if (!nextPublisher) { + this.log.error('All available publishers exhausted, failed to publish bundled transactions'); + return undefined; + } + currentPublisher = nextPublisher; + } + } + } + private callbackBundledTransactions( requests: RequestWithExpiry[], result: { receipt: TransactionReceipt; errorMsg?: string } | FormattedViemError | undefined, From 65777e0dfe15c562db01d3bb29ee19284a250cb5 Mon Sep 17 00:00:00 2001 From: danielntmd <162406516+danielntmd@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:48:35 -0500 Subject: [PATCH 17/25] chore: (A-554) bump reth version 1.6.0 -> 1.11.1 for eth devnet (#20889) This PR prevents A-554 from happening to services interacting with eth devnets. A-554 has been fixed. Co-authored-by: danielntmd --- spartan/eth-devnet/values.yaml | 2 +- spartan/terraform/deploy-eth-devnet/values/eth-devnet.yaml | 2 +- spartan/terraform/deploy-ethereum-nodes/variables.tf | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spartan/eth-devnet/values.yaml b/spartan/eth-devnet/values.yaml index a4263fa49ba5..1a9c213fb80b 100644 --- a/spartan/eth-devnet/values.yaml +++ b/spartan/eth-devnet/values.yaml @@ -3,7 +3,7 @@ fullnameOverride: "" images: reth: - image: ghcr.io/paradigmxyz/reth:v1.6.0 + image: ghcr.io/paradigmxyz/reth:v1.11.1 pullPolicy: IfNotPresent geth: image: ethereum/client-go:v1.15.5 diff --git a/spartan/terraform/deploy-eth-devnet/values/eth-devnet.yaml b/spartan/terraform/deploy-eth-devnet/values/eth-devnet.yaml index 92cde8c823d6..a57c105553f9 100644 --- a/spartan/terraform/deploy-eth-devnet/values/eth-devnet.yaml +++ b/spartan/terraform/deploy-eth-devnet/values/eth-devnet.yaml @@ -3,7 +3,7 @@ fullnameOverride: "" images: reth: - image: ghcr.io/paradigmxyz/reth:v1.6.0 + image: ghcr.io/paradigmxyz/reth:v1.11.1 pullPolicy: IfNotPresent geth: image: ethereum/client-go:v1.15.5 diff --git a/spartan/terraform/deploy-ethereum-nodes/variables.tf b/spartan/terraform/deploy-ethereum-nodes/variables.tf index 60314f96000f..89f563f6a850 100644 --- a/spartan/terraform/deploy-ethereum-nodes/variables.tf +++ b/spartan/terraform/deploy-ethereum-nodes/variables.tf @@ -48,7 +48,7 @@ variable "lighthouse_p2p_port" { variable "reth_image" { description = "Reth Docker image" type = string - default = "ghcr.io/paradigmxyz/reth:v1.9.3" + default = "ghcr.io/paradigmxyz/reth:v1.11.1" } variable "reth_chart_version" { From 0cfca011572604219a6371d4dd5e326a685605fd Mon Sep 17 00:00:00 2001 From: spypsy Date: Mon, 2 Mar 2026 09:41:55 +0000 Subject: [PATCH 18/25] chore: metric on how many epochs validator has been on committee (#20967) Fixes A-298 --- .../grafana/dashboards/aztec_validators.json | 264 +++++++++++++++++- .../telemetry-client/src/attributes.ts | 3 + yarn-project/telemetry-client/src/metrics.ts | 10 + yarn-project/validator-client/src/metrics.ts | 18 ++ .../validator-client/src/validator.ts | 14 + 5 files changed, 308 insertions(+), 1 deletion(-) diff --git a/spartan/metrics/grafana/dashboards/aztec_validators.json b/spartan/metrics/grafana/dashboards/aztec_validators.json index 61e21af920f0..bcbb3c659543 100644 --- a/spartan/metrics/grafana/dashboards/aztec_validators.json +++ b/spartan/metrics/grafana/dashboards/aztec_validators.json @@ -3141,6 +3141,268 @@ ], "title": "HA Cleanup Operations", "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 105 + }, + "id": 700, + "panels": [], + "title": "Attester Epoch Participation", + "type": "row" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "${data_source}" + }, + "description": "The current epoch number, which represents the total number of epochs elapsed since genesis.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 0, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 4, + "x": 0, + "y": 106 + }, + "id": 701, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.2.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "max(aztec_validator_current_epoch{k8s_namespace_name=\"$namespace\", service_instance_id=~\"$service_instance\"})", + "instant": true, + "legendFormat": "Current Epoch", + "range": false, + "refId": "A" + } + ], + "title": "Current Epoch", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "${data_source}" + }, + "description": "Cumulative number of epochs in which each attester successfully submitted at least one attestation.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Epochs attested", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "decimals": 0, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 20, + "x": 4, + "y": 106 + }, + "id": 702, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.2.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "aztec_validator_attested_epoch_count{k8s_namespace_name=\"$namespace\", service_instance_id=~\"$service_instance\"}", + "legendFormat": "{{k8s_pod_name}} / {{aztec_attester_address}}", + "range": true, + "refId": "A" + } + ], + "title": "Attested Epochs per Attester", + "type": "timeseries" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "${data_source}" + }, + "description": "Fraction of total epochs in which each attester successfully participated (attested epochs ÷ current epoch × 100%).", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 50 + }, + { + "color": "yellow", + "value": 75 + }, + { + "color": "green", + "value": 90 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 114 + }, + "id": 703, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "valueMode": "color" + }, + "pluginVersion": "11.2.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "aztec_validator_attested_epoch_count{k8s_namespace_name=\"$namespace\", service_instance_id=~\"$service_instance\"} / on() group_left() max by() (aztec_validator_current_epoch{k8s_namespace_name=\"$namespace\", service_instance_id=~\"$service_instance\"}) * 100", + "instant": true, + "legendFormat": "{{k8s_pod_name}} / {{aztec_attester_address}}", + "range": false, + "refId": "A" + } + ], + "title": "Attester Participation Rate", + "type": "bargauge" } ], "refresh": "30s", @@ -3245,6 +3507,6 @@ "timezone": "", "title": "Validator node overview", "uid": "aztec-validators", - "version": 8, + "version": 9, "weekStart": "" } diff --git a/yarn-project/telemetry-client/src/attributes.ts b/yarn-project/telemetry-client/src/attributes.ts index feaf353df7d9..24a3db3913bb 100644 --- a/yarn-project/telemetry-client/src/attributes.ts +++ b/yarn-project/telemetry-client/src/attributes.ts @@ -156,3 +156,6 @@ export const HA_DUTY_TYPE = 'aztec.ha_signer.duty_type'; /** HA signer node identifier */ export const HA_NODE_ID = 'aztec.ha_signer.node_id'; + +/** The address of an attester (validator) participating in consensus */ +export const ATTESTER_ADDRESS = 'aztec.attester.address'; diff --git a/yarn-project/telemetry-client/src/metrics.ts b/yarn-project/telemetry-client/src/metrics.ts index f0065257c1fe..4b095a876e3b 100644 --- a/yarn-project/telemetry-client/src/metrics.ts +++ b/yarn-project/telemetry-client/src/metrics.ts @@ -1267,6 +1267,16 @@ export const VALIDATOR_ATTESTATION_FAILED_NODE_ISSUE_COUNT: MetricDefinition = { description: 'The number of failed attestations due to node issues (timeout, missing data, etc.)', valueType: ValueType.INT, }; +export const VALIDATOR_CURRENT_EPOCH: MetricDefinition = { + name: 'aztec.validator.current_epoch', + description: 'The current epoch number, reflecting total epochs elapsed since genesis', + valueType: ValueType.INT, +}; +export const VALIDATOR_ATTESTED_EPOCH_COUNT: MetricDefinition = { + name: 'aztec.validator.attested_epoch_count', + description: 'The number of epochs in which this node successfully submitted at least one attestation', + valueType: ValueType.INT, +}; export const NODEJS_EVENT_LOOP_DELAY_MIN: MetricDefinition = { name: 'nodejs.eventloop.delay.min', diff --git a/yarn-project/validator-client/src/metrics.ts b/yarn-project/validator-client/src/metrics.ts index 26c35cec5948..160ac8c17280 100644 --- a/yarn-project/validator-client/src/metrics.ts +++ b/yarn-project/validator-client/src/metrics.ts @@ -1,3 +1,5 @@ +import type { EpochNumber } from '@aztec/foundation/branded-types'; +import type { EthAddress } from '@aztec/foundation/eth-address'; import type { BlockProposal } from '@aztec/stdlib/p2p'; import { Attributes, @@ -16,6 +18,8 @@ export class ValidatorMetrics { private successfulAttestationsCount: UpDownCounter; private failedAttestationsBadProposalCount: UpDownCounter; private failedAttestationsNodeIssueCount: UpDownCounter; + private currentEpoch: Gauge; + private attestedEpochCount: UpDownCounter; private reexMana: Histogram; private reexTx: Histogram; @@ -64,6 +68,10 @@ export class ValidatorMetrics { }, ); + this.currentEpoch = meter.createGauge(Metrics.VALIDATOR_CURRENT_EPOCH); + + this.attestedEpochCount = createUpDownCounterWithDefault(meter, Metrics.VALIDATOR_ATTESTED_EPOCH_COUNT); + this.reexMana = meter.createHistogram(Metrics.VALIDATOR_RE_EXECUTION_MANA); this.reexTx = meter.createHistogram(Metrics.VALIDATOR_RE_EXECUTION_TX_COUNT); @@ -110,4 +118,14 @@ export class ValidatorMetrics { [Attributes.IS_COMMITTEE_MEMBER]: inCommittee, }); } + + /** Update the gauge tracking the current epoch number (proxy for total epochs elapsed). */ + public setCurrentEpoch(epoch: EpochNumber) { + this.currentEpoch.record(Number(epoch)); + } + + /** Increment the count of epochs in which the given attester submitted at least one attestation. */ + public incAttestedEpochCount(attester: EthAddress) { + this.attestedEpochCount.add(1, { [Attributes.ATTESTER_ADDRESS]: attester.toString() }); + } } diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index 5720b49ad89a..0fb99d4c6e41 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -89,6 +89,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined; private epochCacheUpdateLoop: RunningPromise; + /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */ + private lastAttestedEpochByAttester: Map = new Map(); private proposersOfInvalidBlocks: Set = new Set(); @@ -160,6 +162,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) this.log.trace(`No committee found for slot`); return; } + this.metrics.setCurrentEpoch(epoch); if (epoch !== this.lastEpochForCommitteeUpdateLoop) { const me = this.getValidatorAddresses(); const committeeSet = new Set(committee.map(v => v.toString())); @@ -556,6 +559,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) this.metrics.incSuccessfulAttestations(inCommittee.length); + // Track epoch participation per attester: count each (attester, epoch) pair at most once + const proposalEpoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants()); + for (const attester of inCommittee) { + const key = attester.toString(); + const lastEpoch = this.lastAttestedEpochByAttester.get(key); + if (lastEpoch === undefined || proposalEpoch > lastEpoch) { + this.lastAttestedEpochByAttester.set(key, proposalEpoch); + this.metrics.incAttestedEpochCount(attester); + } + } + // Determine which validators should attest let attestors: EthAddress[]; if (partOfCommittee) { From 38fbbb11f9a0f5e9ab972baef7318ea743e177b5 Mon Sep 17 00:00:00 2001 From: Aztec Bot <49558828+AztecBot@users.noreply.github.com> Date: Mon, 2 Mar 2026 06:06:42 -0500 Subject: [PATCH 19/25] fix: set wallet minFeePadding in BotFactory constructor (#20992) ## Summary - Sets `wallet.setMinFeePadding(config.minFeePadding)` in the `BotFactory` constructor so that all transactions during bot setup (token deployment, minting) use the configured fee padding instead of the wallet's default (0.5). - Previously, only account deployment explicitly used `config.minFeePadding` (line 226 of factory.ts), while token deploy and minting relied on the wallet default, causing insufficient fee headroom when gas prices escalate during rapid block building in tests. ## Root cause The merge-train/spartan PR (#20899) was dequeued because `e2e_bot.test.ts` ("does not reuse prior bridge claims if recipient address changes") failed with: ``` maxFeesPerGas.feePerL2Gas must be greater than or equal to gasFees.feePerL2Gas, but got maxFeesPerGas.feePerL2Gas=698400000 and gasFees.feePerL2Gas=932700000 ``` The test config had `minFeePadding: 99` (100x multiplier), but this was only applied during account deployment, not during subsequent token deployment/minting which used the wallet's default 1.5x multiplier. ## Test plan - [x] `make yarn-project` builds successfully - [ ] CI e2e_bot test passes with the fix [ClaudeBox log](http://ci.aztec-labs.com/e9fdbc1ddac8e593-1) --- yarn-project/bot/src/factory.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/yarn-project/bot/src/factory.ts b/yarn-project/bot/src/factory.ts index 31b05f540b8e..970dd845ec69 100644 --- a/yarn-project/bot/src/factory.ts +++ b/yarn-project/bot/src/factory.ts @@ -49,7 +49,11 @@ export class BotFactory { private readonly store: BotStore, private readonly aztecNode: AztecNode, private readonly aztecNodeAdmin?: AztecNodeAdmin, - ) {} + ) { + // Set fee padding on the wallet so that all transactions during setup + // (token deploy, minting, etc.) use the configured padding, not the default. + this.wallet.setMinFeePadding(config.minFeePadding); + } /** * Initializes a new bot by setting up the sender account, registering the recipient, From c2725555b4d3c86385fb05596afec35d4a60ee93 Mon Sep 17 00:00:00 2001 From: Michal Rzeszutko Date: Mon, 2 Mar 2026 15:18:12 +0100 Subject: [PATCH 20/25] chore: deflake epoch invalidate block test (#21001) ## Summary - Fix flaky "proposer invalidates previous block with shuffled attestations" e2e test that fails with `ValidatorSelection__InvalidCommitteeCommitment` - Only swap two signed attestation positions in `manipulateAttestations`, preserving the bitmap so `MaliciousCommitteeAttestationsAndSigners` signers stay aligned with L1's `reconstructCommitteeFromSigners` ## Root cause `trimAttestations` reduces signed attestations to the minimum required (4 of 5), leaving one position with only an address (no signature). The old swap formula `(proposerIndex+1)%N, (proposerIndex+2)%N` could swap a signed position with an unsigned one, changing the bitmap pattern. `MaliciousCommitteeAttestationsAndSigners` provides the `_signers` array in the original bitmap order, but L1's `reconstructCommitteeFromSigners` maps signers to the new bitmap positions, producing a mismatched committee commitment hash. Whether the swap crosses a signed/unsigned boundary depends on the proposer index and which attestation was trimmed, explaining the flakiness. ## Test plan - Confirmed the bug reproduces deterministically by forcing a signed/unsigned swap (test times out with 15 `InvalidCommitteeCommitment` reverts) - After fix, test passes cleanly with zero committee commitment errors Fixes A-590 --- .../src/sequencer/checkpoint_proposal_job.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 184e83a76506..fb913a2efda0 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -808,11 +808,20 @@ export class CheckpointProposalJob implements Traceable { this.log.warn(`Shuffling attestation ordering in checkpoint for slot ${slotNumber} (proposer #${proposerIndex})`); const shuffled = [...attestations]; - const [i, j] = [(proposerIndex + 1) % shuffled.length, (proposerIndex + 2) % shuffled.length]; - const valueI = shuffled[i]; - const valueJ = shuffled[j]; - shuffled[i] = valueJ; - shuffled[j] = valueI; + + // Find two non-proposer positions that both have non-empty signatures to swap. + // This ensures the bitmap doesn't change, so the MaliciousCommitteeAttestationsAndSigners + // signers array stays correctly aligned with L1's committee reconstruction. + const swappable: number[] = []; + for (let k = 0; k < shuffled.length; k++) { + if (!shuffled[k].signature.isEmpty() && k !== proposerIndex) { + swappable.push(k); + } + } + if (swappable.length >= 2) { + const [i, j] = [swappable[0], swappable[1]]; + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } const signers = new CommitteeAttestationsAndSigners(attestations).getSigners(); return new MaliciousCommitteeAttestationsAndSigners(shuffled, signers); From f2355d91bd882ba7807db161466e4108ddb14823 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 2 Mar 2026 11:22:45 -0300 Subject: [PATCH 21/25] chore(sequencer): e2e tests for invalid signature recovery in checkpoint attestations (#20971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test that high-s or invalid ECDSA signatures posted by a malicious proposer can be properly invalidated. ## Summary - Adds e2e tests for A-71: nodes correctly detect and invalidate checkpoints with malleable (high-s) or unrecoverable attestation signatures - Adds `injectHighSValueAttestation` and `injectUnrecoverableSignatureAttestation` sequencer config options for testing - Updates `Signature.random()` to produce valid ECDSA signatures with low s-values - Adds `generateRecoverableSignature` / `generateUnrecoverableSignature` utilities with unit tests - Adds unit test for high-s value attestation validation in archiver - Refactors e2e invalidate block tests with shared helpers to reduce duplication Fixes A-71 ## Test plan - [x] Two new e2e tests pass: high-s value attestation invalidation, unrecoverable signature invalidation - [x] Unit tests pass: `Signature.random()` validity, `generateRecoverableSignature`, `generateUnrecoverableSignature`, high-s validation - [x] Existing e2e invalidate block tests unchanged in behavior (refactored with helpers) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 --- .../archiver/src/modules/validation.test.ts | 23 +- .../epochs_invalidate_block.parallel.test.ts | 360 +++++++----------- .../secp256k1-signer/malleability.test.ts | 20 + .../src/crypto/secp256k1-signer/utils.ts | 32 ++ .../src/eth-signature/eth_signature.test.ts | 24 +- .../src/eth-signature/eth_signature.ts | 8 +- yarn-project/sequencer-client/src/config.ts | 10 + .../src/sequencer/checkpoint_proposal_job.ts | 34 +- yarn-project/stdlib/src/interfaces/configs.ts | 6 + 9 files changed, 293 insertions(+), 224 deletions(-) diff --git a/yarn-project/archiver/src/modules/validation.test.ts b/yarn-project/archiver/src/modules/validation.test.ts index aa11589bb5d5..0bfeb50f1566 100644 --- a/yarn-project/archiver/src/modules/validation.test.ts +++ b/yarn-project/archiver/src/modules/validation.test.ts @@ -2,7 +2,7 @@ import type { EpochCache } from '@aztec/epoch-cache'; import { CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { times } from '@aztec/foundation/collection'; -import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; +import { Secp256k1Signer, flipSignature } from '@aztec/foundation/crypto/secp256k1-signer'; import { Signature } from '@aztec/foundation/eth-signature'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { CommitteeAttestation, EthAddress } from '@aztec/stdlib/block'; @@ -153,6 +153,27 @@ describe('validateCheckpointAttestations', () => { expect(result.invalidIndex).toBe(0); }); + it('fails if an attestation signature has a high-s value (malleable signature)', async () => { + const checkpoint = await makeCheckpoint(signers.slice(0, 4), committee); + + // Flip the signature at index 2 to give it a high-s value + const original = checkpoint.attestations[2]; + const flipped = flipSignature(original.signature); + checkpoint.attestations[2] = new CommitteeAttestation(original.address, flipped); + + // Verify the flipped signature is detected as invalid + const attestations = getAttestationInfoFromPublishedCheckpoint(checkpoint); + expect(attestations[2].status).toBe('invalid-signature'); + + const result = await validateCheckpointAttestations(checkpoint, epochCache, constants, logger); + assert(!result.valid); + assert(result.reason === 'invalid-attestation'); + expect(result.checkpoint.checkpointNumber).toEqual(checkpoint.checkpoint.number); + expect(result.checkpoint.archive.toString()).toEqual(checkpoint.checkpoint.archive.root.toString()); + expect(result.committee).toEqual(committee); + expect(result.invalidIndex).toBe(2); + }); + it('reports correct index when invalid attestation follows provided address', async () => { const checkpoint = await makeCheckpoint(signers.slice(0, 3), committee); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts index 80c2fec76ff0..e4740dde721d 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts @@ -25,7 +25,6 @@ import { privateKeyToAccount } from 'viem/accounts'; import { getAnvilPort } from '../fixtures/fixtures.js'; import { type EndToEndContext, getPrivateKeyFromIndex } from '../fixtures/utils.js'; -import { proveInteraction } from '../test-wallet/utils.js'; import { EpochsTestContext } from './epochs_test.js'; jest.setTimeout(1000 * 60 * 10); @@ -109,6 +108,120 @@ describe('e2e_epochs/epochs_invalidate_block', () => { await test.teardown(); }); + /** + * Configures all sequencers with an attack config, enables the attack for a single checkpoint, + * disables it after the first checkpoint is mined (also stopping block production), and waits + * for the checkpoint to be invalidated. Verifies the chain rolled back to the initial state. + */ + async function runInvalidationTest(opts: { + attackConfig: Record; + disableConfig: Record; + }) { + const sequencers = nodes.map(node => node.getSequencer()!); + const initialCheckpointNumber = (await nodes[0].getL2Tips()).checkpointed.checkpoint.number; + + sequencers.forEach(sequencer => { + sequencer.updateConfig({ ...opts.attackConfig, minTxsPerBlock: 0 }); + }); + + // Disable the attack after the first checkpoint is mined and prevent further block production + test.monitor.once('checkpoint', ({ checkpointNumber }) => { + logger.warn(`Disabling attack after checkpoint ${checkpointNumber} has been mined`); + sequencers.forEach(sequencer => { + sequencer.updateConfig({ ...opts.disableConfig, minTxsPerBlock: 100 }); + }); + }); + + await Promise.all(sequencers.map(s => s.start())); + + // Wait for the CheckpointInvalidated event + const checkpointInvalidatedFilter = await l1Client.createContractEventFilter({ + address: rollupContract.address, + abi: RollupAbi, + eventName: 'CheckpointInvalidated', + fromBlock: 1n, + toBlock: 'latest', + }); + + const checkpointInvalidatedEvents = await retryUntil( + async () => { + const events = await l1Client.getFilterLogs({ filter: checkpointInvalidatedFilter }); + return events.length > 0 ? events : undefined; + }, + 'CheckpointInvalidated event', + test.L2_SLOT_DURATION_IN_S * 5, + 0.1, + ); + + // Verify the checkpoint was invalidated and the chain rolled back + const [event] = checkpointInvalidatedEvents; + logger.warn(`CheckpointInvalidated event emitted`, { event }); + expect(event.args.checkpointNumber).toBeGreaterThan(initialCheckpointNumber); + expect(await test.rollup.getCheckpointNumber()).toEqual(initialCheckpointNumber); + + logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); + } + + /** + * Configures all sequencers with an attack config, starts them, waits for two consecutive + * invalidations of the same checkpoint (confirming the invalid-then-re-invalidated pattern), + * disables the attack, and verifies the chain progresses and all nodes sync. + */ + async function runDoubleInvalidationTest(opts: { + attackConfig: Record; + disableConfig: Record; + }) { + const sequencers = nodes.map(node => node.getSequencer()!); + sequencers.forEach(sequencer => { + sequencer.updateConfig({ ...opts.attackConfig, minTxsPerBlock: 0 }); + }); + + await Promise.all(sequencers.map(s => s.start())); + + // Wait until we see two invalidations, both should be for the same checkpoint + let lastInvalidatedCheckpointNumber: CheckpointNumber | undefined; + const invalidatePromise = promiseWithResolvers(); + const unsubscribe = rollupContract.listenToCheckpointInvalidated(data => { + logger.warn(`Checkpoint ${data.checkpointNumber} has been invalidated`, data); + if (lastInvalidatedCheckpointNumber === undefined) { + lastInvalidatedCheckpointNumber = data.checkpointNumber; + } else { + expect(data.checkpointNumber).toEqual(lastInvalidatedCheckpointNumber); + invalidatePromise.resolve(); + unsubscribe(); + } + }); + await Promise.race([ + timeoutPromise(1000 * test.L2_SLOT_DURATION_IN_S * 8, 'Waiting for two checkpoint invalidations'), + invalidatePromise.promise, + ]); + + sequencers.forEach(sequencer => { + sequencer.updateConfig(opts.disableConfig); + }); + + // Ensure chain progresses + const targetCheckpointNumber = CheckpointNumber(lastInvalidatedCheckpointNumber! + 2); + logger.warn(`Waiting until checkpoint ${targetCheckpointNumber} has been mined`); + await test.monitor.waitUntilCheckpoint(targetCheckpointNumber); + + // Wait for all nodes to sync + const targetBlock = targetCheckpointNumber; + logger.warn(`Waiting for all nodes to sync to block ${targetBlock}`); + await retryUntil( + async () => { + const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); + logger.info(`Node synced block numbers: ${blockNumbers.join(', ')}`); + return blockNumbers.every(bn => bn > targetBlock); + }, + 'Node sync check', + test.L2_SLOT_DURATION_IN_S * 5, + 0.5, + ); + + logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); + } + it('proposer invalidates previous checkpoint with multiple blocks while posting its own', async () => { const sequencers = nodes.map(node => node.getSequencer()!); const [initialCheckpointNumber, initialBlockNumber] = await nodes[0] @@ -213,123 +326,46 @@ describe('e2e_epochs/epochs_invalidate_block', () => { // 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 () => { - // Configure all sequencers to skip collecting attestations before starting and always build blocks - logger.warn('Configuring all sequencers to skip attestation collection'); - const sequencers = nodes.map(node => node.getSequencer()!); - sequencers.forEach(sequencer => { - sequencer.updateConfig({ skipCollectingAttestations: true, minTxsPerBlock: 0 }); + await runDoubleInvalidationTest({ + attackConfig: { skipCollectingAttestations: true }, + disableConfig: { skipCollectingAttestations: false }, }); - - // Start all sequencers - await Promise.all(sequencers.map(s => s.start())); - logger.warn(`Started all sequencers with skipCollectingAttestations=true`); - - // Wait until we see two invalidations, both should be for the same checkpoint - let lastInvalidatedCheckpointNumber: CheckpointNumber | undefined; - const invalidatePromise = promiseWithResolvers(); - const unsubscribe = rollupContract.listenToCheckpointInvalidated(data => { - logger.warn(`Checkpoint ${data.checkpointNumber} has been invalidated`, data); - if (lastInvalidatedCheckpointNumber === undefined) { - lastInvalidatedCheckpointNumber = data.checkpointNumber; - } else { - expect(data.checkpointNumber).toEqual(lastInvalidatedCheckpointNumber); - invalidatePromise.resolve(); - unsubscribe(); - } - }); - await Promise.race([timeoutPromise(1000 * test.L2_SLOT_DURATION_IN_S * 8), invalidatePromise.promise]); - - // Disable skipCollectingAttestations and send txs so MBPS can produce multi-block checkpoints - sequencers.forEach(sequencer => { - sequencer.updateConfig({ skipCollectingAttestations: false }); - }); - logger.warn('Sending transactions to enable multi-block checkpoints'); - const from = context.accounts[0]; - for (let i = 0; i < 4; i++) { - const tx = await proveInteraction(context.wallet, testContract.methods.emit_nullifier(new Fr(100 + i)), { from }); - await tx.send({ wait: NO_WAIT }); - } - - // Ensure chain progresses - const targetCheckpointNumber = CheckpointNumber(lastInvalidatedCheckpointNumber! + 2); - logger.warn(`Waiting until checkpoint ${targetCheckpointNumber} has been mined`); - await test.monitor.waitUntilCheckpoint(targetCheckpointNumber); - - // Wait for all nodes to sync the new block - const targetBlock = targetCheckpointNumber; - logger.warn(`Waiting for all nodes to sync to block ${targetBlock}`); - await retryUntil( - async () => { - const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); - logger.info(`Node synced block numbers: ${blockNumbers.join(', ')}`); - return blockNumbers.every(bn => bn > targetBlock); - }, - 'Node sync check', - test.L2_SLOT_DURATION_IN_S * 5, - 0.5, - ); - - await test.assertMultipleBlocksPerSlot(2); - - logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); }); // 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 () => { - // Configure all sequencers to skip collecting attestations before starting and always build blocks - logger.warn('Configuring all sequencers to inject one invalid attestation'); - const sequencers = nodes.map(node => node.getSequencer()!); - sequencers.forEach(sequencer => { - sequencer.updateConfig({ injectFakeAttestation: true, minTxsPerBlock: 0 }); + await runDoubleInvalidationTest({ + attackConfig: { injectFakeAttestation: true }, + disableConfig: { injectFakeAttestation: false }, }); + }); - // Start all sequencers - await Promise.all(sequencers.map(s => s.start())); - logger.warn(`Started all sequencers with injectFakeAttestation=true`); - - // Wait until we see two invalidations, both should be for the same checkpoint - let lastInvalidatedCheckpointNumber: CheckpointNumber | undefined; - const invalidatePromise = promiseWithResolvers(); - const unsubscribe = rollupContract.listenToCheckpointInvalidated(data => { - logger.warn(`Checkpoint ${data.checkpointNumber} has been invalidated`, data); - if (lastInvalidatedCheckpointNumber === undefined) { - lastInvalidatedCheckpointNumber = data.checkpointNumber; - } else { - expect(data.checkpointNumber).toEqual(lastInvalidatedCheckpointNumber); - invalidatePromise.resolve(); - unsubscribe(); - } + // 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 }, }); - await Promise.race([ - timeoutPromise(1000 * test.L2_SLOT_DURATION_IN_S * 8, 'Invalidating checkpoints'), - invalidatePromise.promise, - ]); + }); - // Disable injectFakeAttestations - sequencers.forEach(sequencer => { - sequencer.updateConfig({ injectFakeAttestation: false }); + // 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 }, }); + }); - // Ensure chain progresses - const targetCheckpointNumber = CheckpointNumber(lastInvalidatedCheckpointNumber! + 2); - logger.warn(`Waiting until checkpoint ${targetCheckpointNumber} has been mined`); - await test.monitor.waitUntilCheckpoint(targetCheckpointNumber); - - // Wait for all nodes to sync the new block - const targetBlock = targetCheckpointNumber; - logger.warn(`Waiting for all nodes to sync to block ${targetBlock}`); - await retryUntil( - async () => { - const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); - logger.info(`Node synced block numbers: ${blockNumbers.join(', ')}`); - return blockNumbers.every(bn => bn > targetBlock); - }, - 'Node sync check', - test.L2_SLOT_DURATION_IN_S * 5, - 0.5, - ); - - logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); + // 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 }, + }); }); // Here we disable invalidation checks from two of the proposers. Our goal is to get two invalid checkpoints @@ -441,116 +477,6 @@ describe('e2e_epochs/epochs_invalidate_block', () => { logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); }); - it('proposer invalidates previous checkpoint without publishing its own', async () => { - const sequencers = nodes.map(node => node.getSequencer()!); - const initialCheckpointNumber = (await nodes[0].getL2Tips()).checkpointed.checkpoint.number; - - // Configure all sequencers to skip collecting attestations before starting - logger.warn('Configuring all sequencers to skip attestation collection and always publish blocks'); - sequencers.forEach(sequencer => { - sequencer.updateConfig({ skipCollectingAttestations: true, minTxsPerBlock: 0 }); - }); - - // Disable skipCollectingAttestations after the first block is mined and prevent sequencers from publishing any more blocks - test.monitor.once('checkpoint', ({ checkpointNumber }) => { - logger.warn(`Disabling skipCollectingAttestations after L2 block ${checkpointNumber} has been mined`); - sequencers.forEach(sequencer => { - sequencer.updateConfig({ skipCollectingAttestations: false, minTxsPerBlock: 100 }); - }); - }); - - // Start all sequencers - await Promise.all(sequencers.map(s => s.start())); - logger.warn(`Started all sequencers with skipCollectingAttestations=true`); - - // Create a filter for CheckpointInvalidated events - const checkpointInvalidatedFilter = await l1Client.createContractEventFilter({ - address: rollupContract.address, - abi: RollupAbi, - eventName: 'CheckpointInvalidated', - fromBlock: 1n, - toBlock: 'latest', - }); - - // The next proposer should invalidate the previous checkpoint - logger.warn('Waiting for next proposer to invalidate the previous checkpoint'); - - // Wait for the CheckpointInvalidated event - const checkpointInvalidatedEvents = await retryUntil( - async () => { - const events = await l1Client.getFilterLogs({ filter: checkpointInvalidatedFilter }); - return events.length > 0 ? events : undefined; - }, - 'CheckpointInvalidated event', - test.L2_SLOT_DURATION_IN_S * 5, - 0.1, - ); - - // Verify the CheckpointInvalidated event was emitted and that the block was removed - const [event] = checkpointInvalidatedEvents; - logger.warn(`CheckpointInvalidated event emitted`, { event }); - expect(event.args.checkpointNumber).toBeGreaterThan(initialCheckpointNumber); - expect(await test.rollup.getCheckpointNumber()).toEqual(initialCheckpointNumber); - - logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); - }); - - // Same as test above but with shuffled attestations instead of missing attestations - // REFACTOR: Remove code duplication with above test (and others?) - it('proposer invalidates previous block with shuffled attestations', async () => { - const sequencers = nodes.map(node => node.getSequencer()!); - const initialCheckpointNumber = (await nodes[0].getL2Tips()).checkpointed.checkpoint.number; - - // Configure all sequencers to shuffle attestations before starting - logger.warn('Configuring all sequencers to shuffle attestations and always publish blocks'); - sequencers.forEach(sequencer => { - sequencer.updateConfig({ shuffleAttestationOrdering: true, minTxsPerBlock: 0 }); - }); - - // Disable shuffleAttestationOrdering after the first block is mined and prevent sequencers from publishing any more blocks - test.monitor.once('checkpoint', ({ checkpointNumber }) => { - logger.warn(`Disabling shuffleAttestationOrdering after L2 block ${checkpointNumber} has been mined`); - sequencers.forEach(sequencer => { - sequencer.updateConfig({ shuffleAttestationOrdering: false, minTxsPerBlock: 100 }); - }); - }); - - // Start all sequencers - await Promise.all(sequencers.map(s => s.start())); - logger.warn(`Started all sequencers with shuffleAttestationOrdering=true`); - - // Create a filter for CheckpointInvalidated events - const checkpointInvalidatedFilter = await l1Client.createContractEventFilter({ - address: rollupContract.address, - abi: RollupAbi, - eventName: 'CheckpointInvalidated', - fromBlock: 1n, - toBlock: 'latest', - }); - - // The next proposer should invalidate the previous checkpoint - logger.warn('Waiting for next proposer to invalidate the previous checkpoint'); - - // Wait for the CheckpointInvalidated event - const checkpointInvalidatedEvents = await retryUntil( - async () => { - const events = await l1Client.getFilterLogs({ filter: checkpointInvalidatedFilter }); - return events.length > 0 ? events : undefined; - }, - 'CheckpointInvalidated event', - test.L2_SLOT_DURATION_IN_S * 5, - 0.1, - ); - - // Verify the CheckpointInvalidated event was emitted and that the block was removed - const [event] = checkpointInvalidatedEvents; - logger.warn(`CheckpointInvalidated event emitted`, { event }); - expect(event.args.checkpointNumber).toBeGreaterThan(initialCheckpointNumber); - expect(await test.rollup.getCheckpointNumber()).toEqual(initialCheckpointNumber); - - logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); - }); - it('committee member invalidates a block if proposer does not come through', async () => { const sequencers = nodes.map(node => node.getSequencer()!); const initialCheckpointNumber = await nodes[0].getL2Tips().then(t => t.checkpointed.checkpoint.number); diff --git a/yarn-project/foundation/src/crypto/secp256k1-signer/malleability.test.ts b/yarn-project/foundation/src/crypto/secp256k1-signer/malleability.test.ts index 4874b7ceabfe..d787f6908145 100644 --- a/yarn-project/foundation/src/crypto/secp256k1-signer/malleability.test.ts +++ b/yarn-project/foundation/src/crypto/secp256k1-signer/malleability.test.ts @@ -8,6 +8,8 @@ import { Secp256k1Signer } from './secp256k1_signer.js'; import { Secp256k1Error, flipSignature, + generateRecoverableSignature, + generateUnrecoverableSignature, makeEthSignDigest, normalizeSignature, recoverAddress, @@ -139,3 +141,21 @@ describe('ecdsa malleability', () => { expect(recoveredAddress.toString()).toEqual(expectedAddress.toString()); }); }); + +describe('generateRecoverableSignature', () => { + it('produces a signature from which an address can be recovered', () => { + const sig = generateRecoverableSignature(); + const hash = Buffer32.random(); + const recovered = tryRecoverAddress(hash, sig); + expect(recovered).toBeDefined(); + }); +}); + +describe('generateUnrecoverableSignature', () => { + it('produces a signature from which no address can be recovered', () => { + const sig = generateUnrecoverableSignature(); + const hash = Buffer32.random(); + const recovered = tryRecoverAddress(hash, sig); + expect(recovered).toBeUndefined(); + }); +}); diff --git a/yarn-project/foundation/src/crypto/secp256k1-signer/utils.ts b/yarn-project/foundation/src/crypto/secp256k1-signer/utils.ts index a8a459d01b4e..2226ae635550 100644 --- a/yarn-project/foundation/src/crypto/secp256k1-signer/utils.ts +++ b/yarn-project/foundation/src/crypto/secp256k1-signer/utils.ts @@ -210,3 +210,35 @@ export function recoverPublicKey(hash: Buffer32, signature: Signature, opts: Rec const publicKey = sig.recoverPublicKey(hash.buffer).toHex(false); return Buffer.from(publicKey, 'hex'); } + +/** Arbitrary hash used for testing signature recoverability. */ +const PROBE_HASH = Buffer32.fromBuffer(keccak256(Buffer.from('signature-recoverability-probe'))); + +/** + * Generates a random valid ECDSA signature that is recoverable to some address. + * Since Signature.random() produces real signatures via secp256k1 signing, the result is always + * recoverable, but we verify defensively by checking tryRecoverAddress. + */ +export function generateRecoverableSignature(): Signature { + for (let i = 0; i < 100; i++) { + const sig = Signature.random(); + if (tryRecoverAddress(PROBE_HASH, sig) !== undefined) { + return sig; + } + } + throw new Secp256k1Error('Failed to generate a recoverable signature after 100 attempts'); +} + +/** + * Generates a random signature where ECDSA address recovery fails. + * Uses random r/s values (not from real signing) so that r is unlikely to be a valid secp256k1 x-coordinate. + */ +export function generateUnrecoverableSignature(): Signature { + for (let i = 0; i < 100; i++) { + const sig = new Signature(Buffer32.random(), Buffer32.random(), 27); + if (tryRecoverAddress(PROBE_HASH, sig) === undefined) { + return sig; + } + } + throw new Secp256k1Error('Failed to generate an unrecoverable signature after 100 attempts'); +} diff --git a/yarn-project/foundation/src/eth-signature/eth_signature.test.ts b/yarn-project/foundation/src/eth-signature/eth_signature.test.ts index 2c642c2c32a8..dd19ae14520b 100644 --- a/yarn-project/foundation/src/eth-signature/eth_signature.test.ts +++ b/yarn-project/foundation/src/eth-signature/eth_signature.test.ts @@ -1,7 +1,9 @@ import { Buffer32 } from '@aztec/foundation/buffer'; -import { Secp256k1Signer, recoverAddress } from '@aztec/foundation/crypto/secp256k1-signer'; +import { Secp256k1Signer, recoverAddress, tryRecoverAddress } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; +import { secp256k1 } from '@noble/curves/secp256k1'; + import { Signature } from './eth_signature.js'; const randomSigner = () => { @@ -62,4 +64,24 @@ describe('eth signature', () => { const deserialized = Signature.fromString(serialized); checkEquivalence(signature, deserialized); }); + + it('random() produces a valid recoverable signature with low s-value', () => { + const sig = Signature.random(); + + // v should be 27 or 28 + expect([27, 28]).toContain(sig.v); + + // Signature should not be empty + expect(sig.isEmpty()).toBe(false); + + // s should be in the low half of the curve (low s-value) + const sBigInt = sig.s.toBigInt(); + const halfN = secp256k1.CURVE.n / 2n; + expect(sBigInt).toBeLessThanOrEqual(halfN); + + // Signature should be recoverable (tryRecoverAddress should return an address for any hash) + const hash = Buffer32.random(); + const recovered = tryRecoverAddress(hash, sig); + expect(recovered).toBeDefined(); + }); }); diff --git a/yarn-project/foundation/src/eth-signature/eth_signature.ts b/yarn-project/foundation/src/eth-signature/eth_signature.ts index c76343f37540..62545d3cc4e9 100644 --- a/yarn-project/foundation/src/eth-signature/eth_signature.ts +++ b/yarn-project/foundation/src/eth-signature/eth_signature.ts @@ -1,8 +1,10 @@ import { Buffer32 } from '@aztec/foundation/buffer'; import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; +import { secp256k1 } from '@noble/curves/secp256k1'; import { z } from 'zod'; +import { randomBytes } from '../crypto/random/index.js'; import { hasHexPrefix, hexToBuffer } from '../string/index.js'; /** @@ -77,8 +79,12 @@ export class Signature { return new Signature(Buffer32.fromBuffer(hexToBuffer(sig.r)), Buffer32.fromBuffer(hexToBuffer(sig.s)), sig.yParity); } + /** Generates a random valid ECDSA signature with a low s-value by signing a random message with a random key. */ static random(): Signature { - return new Signature(Buffer32.random(), Buffer32.random(), 1); + const privateKey = randomBytes(32); + const message = randomBytes(32); + const { r, s, recovery } = secp256k1.sign(message, privateKey); + return new Signature(Buffer32.fromBigInt(r), Buffer32.fromBigInt(s), recovery ? 28 : 27); } static empty(): Signature { diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index 61dcb2344c17..d6aa5d93f9f5 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -53,6 +53,8 @@ export const DefaultSequencerConfig: ResolvedSequencerConfig = { skipInvalidateBlockAsProposer: false, broadcastInvalidBlockProposal: false, injectFakeAttestation: false, + injectHighSValueAttestation: false, + injectUnrecoverableSignatureAttestation: false, fishermanMode: false, shuffleAttestationOrdering: false, skipPushProposedBlocksToArchiver: false, @@ -182,6 +184,14 @@ export const sequencerConfigMappings: ConfigMappingsType = { description: 'Inject a fake attestation (for testing only)', ...booleanConfigHelper(DefaultSequencerConfig.injectFakeAttestation), }, + injectHighSValueAttestation: { + description: 'Inject a malleable attestation with a high-s value (for testing only)', + ...booleanConfigHelper(DefaultSequencerConfig.injectHighSValueAttestation), + }, + injectUnrecoverableSignatureAttestation: { + description: 'Inject an attestation with an unrecoverable signature (for testing only)', + ...booleanConfigHelper(DefaultSequencerConfig.injectUnrecoverableSignatureAttestation), + }, fishermanMode: { env: 'FISHERMAN_MODE', description: diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index fb913a2efda0..466d2f259f17 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -9,6 +9,11 @@ import { SlotNumber, } from '@aztec/foundation/branded-types'; import { randomInt } from '@aztec/foundation/crypto/random'; +import { + flipSignature, + generateRecoverableSignature, + generateUnrecoverableSignature, +} from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { Signature } from '@aztec/foundation/eth-signature'; @@ -759,7 +764,12 @@ export class CheckpointProposalJob implements Traceable { const sorted = orderAttestations(trimmed, committee); // Manipulate the attestations if we've been configured to do so - if (this.config.injectFakeAttestation || this.config.shuffleAttestationOrdering) { + if ( + this.config.injectFakeAttestation || + this.config.injectHighSValueAttestation || + this.config.injectUnrecoverableSignatureAttestation || + this.config.shuffleAttestationOrdering + ) { return this.manipulateAttestations(proposal.slotNumber, epoch, seed, committee, sorted); } @@ -788,7 +798,11 @@ export class CheckpointProposalJob implements Traceable { this.epochCache.computeProposerIndex(slotNumber, epoch, seed, BigInt(committee.length)), ); - if (this.config.injectFakeAttestation) { + if ( + this.config.injectFakeAttestation || + this.config.injectHighSValueAttestation || + this.config.injectUnrecoverableSignatureAttestation + ) { // Find non-empty attestations that are not from the proposer const nonProposerIndices: number[] = []; for (let i = 0; i < attestations.length; i++) { @@ -798,8 +812,20 @@ export class CheckpointProposalJob implements Traceable { } if (nonProposerIndices.length > 0) { const targetIndex = nonProposerIndices[randomInt(nonProposerIndices.length)]; - this.log.warn(`Injecting fake attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`); - unfreeze(attestations[targetIndex]).signature = Signature.random(); + if (this.config.injectHighSValueAttestation) { + this.log.warn( + `Injecting high-s value attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`, + ); + unfreeze(attestations[targetIndex]).signature = flipSignature(attestations[targetIndex].signature); + } else if (this.config.injectUnrecoverableSignatureAttestation) { + this.log.warn( + `Injecting unrecoverable signature attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`, + ); + unfreeze(attestations[targetIndex]).signature = generateUnrecoverableSignature(); + } else { + this.log.warn(`Injecting fake attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`); + unfreeze(attestations[targetIndex]).signature = generateRecoverableSignature(); + } } return new CommitteeAttestationsAndSigners(attestations); } diff --git a/yarn-project/stdlib/src/interfaces/configs.ts b/yarn-project/stdlib/src/interfaces/configs.ts index 3cd2912c078f..88b1366a6889 100644 --- a/yarn-project/stdlib/src/interfaces/configs.ts +++ b/yarn-project/stdlib/src/interfaces/configs.ts @@ -59,6 +59,10 @@ export interface SequencerConfig { broadcastInvalidBlockProposal?: boolean; /** Inject a fake attestation (for testing only) */ injectFakeAttestation?: boolean; + /** Inject a malleable attestation with a high-s value (for testing only) */ + injectHighSValueAttestation?: boolean; + /** Inject an attestation with an unrecoverable signature (for testing only) */ + injectUnrecoverableSignatureAttestation?: boolean; /** Whether to run in fisherman mode: builds blocks on every slot for validation without publishing */ fishermanMode?: boolean; /** Shuffle attestation ordering to create invalid ordering (for testing only) */ @@ -104,6 +108,8 @@ export const SequencerConfigSchema = zodFor()( secondsBeforeInvalidatingBlockAsNonCommitteeMember: z.number(), broadcastInvalidBlockProposal: z.boolean().optional(), injectFakeAttestation: z.boolean().optional(), + injectHighSValueAttestation: z.boolean().optional(), + injectUnrecoverableSignatureAttestation: z.boolean().optional(), fishermanMode: z.boolean().optional(), shuffleAttestationOrdering: z.boolean().optional(), blockDurationMs: z.number().positive().optional(), From 8680abcca762be23163cf0eaf7d6668454748658 Mon Sep 17 00:00:00 2001 From: Michal Rzeszutko Date: Mon, 2 Mar 2026 15:28:29 +0100 Subject: [PATCH 22/25] chore: deflake duplicate proposals and attestations (#20990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix flakiness in duplicate_proposal_slash and duplicate_attestation_slash e2e tests. Both tests flake at a 3-13% rate because the malicious proposer (1 of 4 validators) is never selected within the timeout window. Three root causes are addressed: 1. **GossipSub mesh checked only for TX topic** — The tests need block proposals and checkpoint proposals to propagate, but `waitForP2PMeshConnectivity` only verified the `tx` topic mesh. Added a `topics` parameter so callers can specify which topics to wait on. Both slash tests now wait on `tx`, `block_proposal`, and `checkpoint_proposal`. 2. **Proposer selection is probabilistic** — With 4 validators and 2 slots/epoch, the malicious proposer has ~25% chance per slot. Added `awaitEpochWithProposer` helper that advances epochs (via L1 time warp) until the target proposer is deterministically selected for at least one slot in the current epoch. 3. **Race between node startup and first proposal** — Nodes started sequencing immediately upon creation, potentially proposing before the P2P mesh was ready. Now all nodes are created with `dontStartSequencer: true`, and sequencers are started simultaneously only after mesh formation, committee existence, and epoch advancement are confirmed. Fixes A-593 Fixes A-595 --- .../duplicate_attestation_slash.test.ts | 37 ++++++++++++--- .../e2e_p2p/duplicate_proposal_slash.test.ts | 37 ++++++++++++--- .../end-to-end/src/e2e_p2p/p2p_network.ts | 39 ++++++++-------- yarn-project/end-to-end/src/e2e_p2p/shared.ts | 45 ++++++++++++++++++- 4 files changed, 127 insertions(+), 31 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts index 2f68d908d458..3ebef6ac94da 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts @@ -4,6 +4,7 @@ import { EthAddress } from '@aztec/aztec.js/addresses'; import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { bufferToHex } from '@aztec/foundation/string'; import { OffenseType } from '@aztec/slasher'; +import { TopicType } from '@aztec/stdlib/p2p'; import { jest } from '@jest/globals'; import fs from 'fs'; @@ -15,7 +16,7 @@ import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNode } from '../fixtures/setup_p2p_test.js'; import { getPrivateKeyFromIndex } from '../fixtures/utils.js'; import { P2PNetworkTest } from './p2p_network.js'; -import { awaitCommitteeExists, awaitOffenseDetected } from './shared.js'; +import { awaitCommitteeExists, awaitEpochWithProposer, awaitOffenseDetected } from './shared.js'; const TEST_TIMEOUT = 600_000; // 10 minutes @@ -141,6 +142,7 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { coinbase: coinbase1, attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals + dontStartSequencer: true, }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 1, @@ -159,6 +161,7 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { coinbase: coinbase2, attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals + dontStartSequencer: true, }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 2, @@ -172,7 +175,10 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { // Create honest nodes with unique validator keys (indices 1 and 2) t.logger.warn('Creating honest nodes'); const honestNode1 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 3, t.bootstrapNodeEnr, @@ -182,7 +188,10 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { shouldCollectMetrics(), ); const honestNode2 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 4, t.bootstrapNodeEnr, @@ -194,10 +203,27 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; - // Wait for P2P mesh and the committee to be fully formed before proceeding - await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS); + // Wait for P2P mesh on all needed topics before starting sequencers + await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS, 30, 0.1, [ + TopicType.tx, + TopicType.block_proposal, + TopicType.checkpoint_proposal, + ]); await awaitCommitteeExists({ rollup, logger: t.logger }); + // Advance to an epoch where the malicious proposer is selected + const epochCache = (honestNode1 as TestAztecNodeService).epochCache; + await awaitEpochWithProposer({ + epochCache, + cheatCodes: t.ctx.cheatCodes.rollup, + targetProposer: maliciousProposerAddress, + logger: t.logger, + }); + + // Start all sequencers simultaneously + t.logger.warn('Starting all sequencers'); + await Promise.all(nodes.map(n => n.getSequencer()!.start())); + // Wait for offenses to be detected // We expect BOTH duplicate proposal AND duplicate attestation offenses // The malicious proposer nodes create duplicate proposals (same key, different coinbase) @@ -236,7 +262,6 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { } // Verify that for each duplicate attestation offense, the attester for that slot is the malicious validator - const epochCache = (honestNode1 as TestAztecNodeService).epochCache; for (const offense of duplicateAttestationOffenses) { const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); const committeeInfo = await epochCache.getCommittee(offenseSlot); diff --git a/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts index 374e4527d4ef..c0b6062acac6 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts @@ -4,6 +4,7 @@ import { EthAddress } from '@aztec/aztec.js/addresses'; import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { bufferToHex } from '@aztec/foundation/string'; import { OffenseType } from '@aztec/slasher'; +import { TopicType } from '@aztec/stdlib/p2p'; import { jest } from '@jest/globals'; import fs from 'fs'; @@ -15,7 +16,7 @@ import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNode } from '../fixtures/setup_p2p_test.js'; import { getPrivateKeyFromIndex } from '../fixtures/utils.js'; import { P2PNetworkTest } from './p2p_network.js'; -import { awaitCommitteeExists, awaitOffenseDetected } from './shared.js'; +import { awaitCommitteeExists, awaitEpochWithProposer, awaitOffenseDetected } from './shared.js'; const TEST_TIMEOUT = 600_000; // 10 minutes @@ -130,6 +131,7 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { validatorPrivateKey: maliciousPrivateKeyHex, coinbase: coinbase1, broadcastEquivocatedProposals: true, + dontStartSequencer: true, }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 1, @@ -147,6 +149,7 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { validatorPrivateKey: maliciousPrivateKeyHex, coinbase: coinbase2, broadcastEquivocatedProposals: true, + dontStartSequencer: true, }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 2, @@ -160,7 +163,10 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { // Create honest nodes with unique validator keys (indices 1 and 2) t.logger.warn('Creating honest nodes'); const honestNode1 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 3, t.bootstrapNodeEnr, @@ -170,7 +176,10 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { shouldCollectMetrics(), ); const honestNode2 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 4, t.bootstrapNodeEnr, @@ -182,10 +191,27 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; - // Wait for P2P mesh and the committee to be fully formed before proceeding - await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS); + // Wait for P2P mesh on all needed topics before starting sequencers + await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS, 30, 0.1, [ + TopicType.tx, + TopicType.block_proposal, + TopicType.checkpoint_proposal, + ]); await awaitCommitteeExists({ rollup, logger: t.logger }); + // Advance to an epoch where the malicious proposer is selected + const epochCache = (honestNode1 as TestAztecNodeService).epochCache; + await awaitEpochWithProposer({ + epochCache, + cheatCodes: t.ctx.cheatCodes.rollup, + targetProposer: maliciousValidatorAddress, + logger: t.logger, + }); + + // Start all sequencers simultaneously + t.logger.warn('Starting all sequencers'); + await Promise.all(nodes.map(n => n.getSequencer()!.start())); + // Wait for offense to be detected // The honest nodes should detect the duplicate proposal from the malicious validator t.logger.warn('Waiting for duplicate proposal offense to be detected...'); @@ -208,7 +234,6 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { } // Verify that for each offense, the proposer for that slot is the malicious validator - const epochCache = (honestNode1 as TestAztecNodeService).epochCache; for (const offense of offenses) { const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); const proposerForSlot = await epochCache.getProposerAttesterAddressInSlot(offenseSlot); diff --git a/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts b/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts index bdb752da9f00..6e90af2fad87 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts @@ -407,6 +407,7 @@ export class P2PNetworkTest { expectedNodeCount?: number, timeoutSeconds = 30, checkIntervalSeconds = 0.1, + topics: TopicType[] = [TopicType.tx], ) { const nodeCount = expectedNodeCount ?? nodes.length; const minPeerCount = nodeCount - 1; @@ -433,26 +434,28 @@ export class P2PNetworkTest { this.logger.warn('All nodes connected to P2P mesh'); - // Wait for GossipSub mesh to form for the tx topic. + // Wait for GossipSub mesh to form for all specified topics. // We only require at least 1 mesh peer per node because GossipSub // stops grafting once it reaches Dlo peers and won't fill the mesh to all available peers. - this.logger.warn('Waiting for GossipSub mesh to form for tx topic...'); - await Promise.all( - nodes.map(async (node, index) => { - const p2p = node.getP2P(); - await retryUntil( - async () => { - const meshPeers = await p2p.getGossipMeshPeerCount(TopicType.tx); - this.logger.debug(`Node ${index} has ${meshPeers} gossip mesh peers for tx topic`); - return meshPeers >= 1 ? true : undefined; - }, - `Node ${index} to have gossip mesh peers for tx topic`, - timeoutSeconds, - checkIntervalSeconds, - ); - }), - ); - this.logger.warn('All nodes have gossip mesh peers for tx topic'); + for (const topic of topics) { + this.logger.warn(`Waiting for GossipSub mesh to form for ${topic} topic...`); + await Promise.all( + nodes.map(async (node, index) => { + const p2p = node.getP2P(); + await retryUntil( + async () => { + const meshPeers = await p2p.getGossipMeshPeerCount(topic); + this.logger.debug(`Node ${index} has ${meshPeers} gossip mesh peers for ${topic} topic`); + return meshPeers >= 1 ? true : undefined; + }, + `Node ${index} to have gossip mesh peers for ${topic} topic`, + timeoutSeconds, + checkIntervalSeconds, + ); + }), + ); + this.logger.warn(`All nodes have gossip mesh peers for ${topic} topic`); + } } async teardown() { diff --git a/yarn-project/end-to-end/src/e2e_p2p/shared.ts b/yarn-project/end-to-end/src/e2e_p2p/shared.ts index 5b3450673e24..b4bf13758ef9 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/shared.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/shared.ts @@ -6,12 +6,13 @@ import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; import { TxHash } from '@aztec/aztec.js/tx'; import type { RollupCheatCodes } from '@aztec/aztec/testing'; +import type { EpochCacheInterface } from '@aztec/epoch-cache'; import type { EmpireSlashingProposerContract, RollupContract, TallySlashingProposerContract, } from '@aztec/ethereum/contracts'; -import { EpochNumber } from '@aztec/foundation/branded-types'; +import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { timesAsync, unique } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { retryUntil } from '@aztec/foundation/retry'; @@ -150,6 +151,48 @@ export async function awaitCommitteeExists({ return committee!.map(c => c.toString() as `0x${string}`); } +/** + * Advance epochs until we find one where the target proposer is selected for at least one slot. + * With N validators and M slots per epoch, a specific proposer may not be selected in any given epoch. + * For example, with 4 validators and 2 slots/epoch, there is about a 44% chance per epoch. + */ +export async function awaitEpochWithProposer({ + epochCache, + cheatCodes, + targetProposer, + logger, + maxAttempts = 20, +}: { + epochCache: EpochCacheInterface; + cheatCodes: RollupCheatCodes; + targetProposer: EthAddress; + logger: Logger; + maxAttempts?: number; +}): Promise { + const { epochDuration } = await cheatCodes.getConfig(); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const currentEpoch = await cheatCodes.getEpoch(); + const startSlot = Number(currentEpoch) * Number(epochDuration); + const endSlot = startSlot + Number(epochDuration); + + logger.info(`Checking epoch ${currentEpoch} (slots ${startSlot}-${endSlot - 1}) for proposer ${targetProposer}`); + + for (let s = startSlot; s < endSlot; s++) { + const proposer = await epochCache.getProposerAttesterAddressInSlot(SlotNumber(s)); + if (proposer && proposer.equals(targetProposer)) { + logger.warn(`Found target proposer ${targetProposer} in slot ${s} of epoch ${currentEpoch}`); + return; + } + } + + logger.info(`Target proposer not found in epoch ${currentEpoch}, advancing to next epoch`); + await cheatCodes.advanceToNextEpoch(); + } + + throw new Error(`Target proposer ${targetProposer} not found in any slot after ${maxAttempts} epoch attempts`); +} + export async function awaitOffenseDetected({ logger, nodeAdmin, From 1ccca76b430ba1c1dd8524a3f1c0e3fddb2fed82 Mon Sep 17 00:00:00 2001 From: Michal Rzeszutko Date: Mon, 2 Mar 2026 15:39:27 +0100 Subject: [PATCH 23/25] chore: deflake epochs mbps test (#21003) ## Summary - Fixes flaky `epochs_mbps.parallel` test by adding a `retryUntil` poll to `assertMultipleBlocksPerSlot`, closing a race condition between two independently-syncing archivers ## Details The `epochs_mbps.parallel` test has been flaking in CI (9 recent failures across PRs 20562-20868) on the "checkpointed block" test case. The root cause is a race condition: 1. `waitForTx` polls the **initial setup node's** archiver and returns when it sees the tx as `CHECKPOINTED`. 2. `assertMultipleBlocksPerSlot` then queries the **first validator node's** (`nodes[0]`) archiver via `archiver.getCheckpoints()`. 3. These are different nodes with independent L1 polling cycles (~50ms interval each). 4. The first validator's archiver may not have indexed the latest checkpoint yet (~200-400ms race window). CI logs confirm: the checkpoint with the expected block count is always produced and published to L1, but the first validator's archiver hasn't indexed it when the assertion runs. ### Fix Added a `retryUntil` poll at the start of `assertMultipleBlocksPerSlot` that waits (up to `L2_SLOT_DURATION_IN_S * 3` = 108s, polling every 0.5s) for `nodes[0]`'s archiver to index a checkpoint with at least `targetBlockCount` blocks. Once found, the existing validation logic runs as before. Fixes A-594 --- .../src/e2e_epochs/epochs_mbps.parallel.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts index 1917f419e9f4..247a56d44ae9 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts @@ -145,6 +145,20 @@ describe('e2e_epochs/epochs_mbps', () => { /** Retrieves all checkpoints from the archiver, checks that one has the target block count, and returns its number. */ async function assertMultipleBlocksPerSlot(targetBlockCount: number, logger: Logger): Promise { + // Wait for the first validator's archiver to index a checkpoint with the target block count. + // waitForTx polls the initial setup node, but this archiver belongs to nodes[0] (the first + // validator). They sync L1 independently, so there's a race window of ~200-400ms. + const waitTimeout = test.L2_SLOT_DURATION_IN_S * 3; + await retryUntil( + async () => { + const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); + return checkpoints.some(pc => pc.checkpoint.blocks.length >= targetBlockCount) || undefined; + }, + `checkpoint with at least ${targetBlockCount} blocks`, + waitTimeout, + 0.5, + ); + const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); logger.warn(`Retrieved ${checkpoints.length} checkpoints from archiver`, { checkpoints: checkpoints.map(pc => pc.checkpoint.getStats()), From 2d3e5d6b7d03b3d0d1637720de2751ca2aa0f970 Mon Sep 17 00:00:00 2001 From: Michal Rzeszutko Date: Mon, 2 Mar 2026 18:52:19 +0100 Subject: [PATCH 24/25] feat: reenable function selectors in txPublicSetupAllowList (#20909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Re-enables function selector checking in the transaction setup phase allow list. Previously, selector restrictions were removed with the comment "We can't restrict the selector because public functions get routed via dispatch," but the current code already correctly extracts selectors from calldata (`calldata[0]` contains the target selector). This fix closes a vulnerability where ANY public function on whitelisted contracts/classes was permitted during setup. - **Made `AllowedElement` require selectors**: Removed `AllowedInstance` and `AllowedClass` variants — all entries now require both an identifier (address or classId) and a function selector - **Re-enabled selectors in the default allow list** with the five traced setup-phase functions: `AuthRegistry._set_authorized` (private FPC path), `AuthRegistry.set_authorized` (public FPC path), `FeeJuice._increase_public_balance`, `Token._increase_public_balance`, `Token.transfer_in_public` - **Removed the unnecessary FPC entry** — FPC's public functions (`_complete_refund`, `_pay_refund`) are set via `set_as_teardown()`, not enqueued in setup - **Changed config from override to extend**: The internal config key is now `txPublicSetupAllowListExtend` (env var `TX_PUBLIC_SETUP_ALLOWLIST` unchanged). Defaults are always present; the config only adds entries on top of them - **Added network-json support**: The extend list can be distributed via `txPublicSetupAllowListExtend` in the network config schema - **Replaced `isOnAllowList` with `checkAllowList`**: Returns a specific rejection reason instead of a boolean. Removed dead branches for selector-less entries. Contract instance is now fetched lazily (only once, only when class-based entries exist). Unknown contracts now return `TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT` instead of a generic validation error - **Improved `parseAllowList`**: Requires selectors, rejects unknown type prefixes, handles whitespace - **Added tests**: Wrong-selector rejection for both address and class matches, unknown contract rejection, lazy fetch verification, config parsing edge cases Fixes A-463 --- .../operators/reference/changelog/v4.md | 28 ++++++++ .../aztec-node/src/aztec-node/server.ts | 10 +-- yarn-project/cli/src/config/network_config.ts | 3 + .../foundation/src/config/network_config.ts | 1 + yarn-project/p2p/src/config.test.ts | 32 +++++++-- yarn-project/p2p/src/config.ts | 66 ++++++++++--------- .../tx_validator/allowed_public_setup.ts | 40 +++++++---- .../tx_validator/phases_validator.test.ts | 62 ++++++++++++++++- .../tx_validator/phases_validator.ts | 54 +++++++-------- .../p2p/src/services/libp2p/libp2p_service.ts | 5 +- yarn-project/sequencer-client/src/config.ts | 4 +- .../src/sequencer/sequencer.ts | 2 +- .../stdlib/src/interfaces/allowed_element.ts | 6 +- .../stdlib/src/interfaces/block-builder.ts | 7 +- yarn-project/stdlib/src/interfaces/configs.ts | 8 +-- .../stdlib/src/interfaces/validator.ts | 4 +- .../stdlib/src/tx/validator/error_texts.ts | 1 + .../src/checkpoint_builder.ts | 5 +- .../src/validator.integration.test.ts | 2 +- 19 files changed, 235 insertions(+), 105 deletions(-) diff --git a/docs/docs-operate/operators/reference/changelog/v4.md b/docs/docs-operate/operators/reference/changelog/v4.md index de6d28118215..dc8e0cce3d35 100644 --- a/docs/docs-operate/operators/reference/changelog/v4.md +++ b/docs/docs-operate/operators/reference/changelog/v4.md @@ -70,6 +70,30 @@ The `getL2Tips()` RPC endpoint now returns a restructured response with addition - Replace `tips.latest` with `tips.proposed` - For `checkpointed`, `proven`, and `finalized` tips, access block info via `.block` (e.g., `tips.proven.block.number`) +### Setup phase allow list requires function selectors + +The transaction setup phase allow list now enforces function selectors, restricting which specific functions can run during setup on whitelisted contracts. Previously, any public function on a whitelisted contract or class was permitted. + +The semantics of the environment variable `TX_PUBLIC_SETUP_ALLOWLIST` have changed: + +**v3.x:** + +```bash +--txPublicSetupAllowList ($TX_PUBLIC_SETUP_ALLOWLIST) +``` + +The variable fully **replaced** the hardcoded defaults. Format allowed entries without selectors: `I:address`, `C:classId`. + +**v4.0.0:** + +```bash +--txPublicSetupAllowListExtend ($TX_PUBLIC_SETUP_ALLOWLIST) +``` + +The variable now **extends** the hardcoded defaults (which are always present). Selectors are now mandatory. Format: `I:address:selector,C:classId:selector`. + +**Migration**: If you were using `TX_PUBLIC_SETUP_ALLOWLIST`, ensure all entries include function selectors. Note the variable now adds to defaults rather than replacing them. If you were not setting this variable, no action is needed — the hardcoded defaults now include the correct selectors automatically. + ## Removed features ## New features @@ -137,6 +161,10 @@ Transaction submission via RPC now returns structured rejection codes when a tra **Impact**: Improved developer experience — callers can now programmatically handle specific rejection reasons. +### Setup allow list extendable via network config + +The setup phase allow list can now be extended via the network configuration JSON (`txPublicSetupAllowListExtend` field). This allows network operators to distribute additional allowed setup functions to all nodes without requiring code changes. The local environment variable takes precedence over the network-json value. + ## Changed defaults ## Troubleshooting diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 907a6a2c5e2c..5d8bc7bd69d8 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -342,9 +342,6 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { deps.p2pClientDeps, ); - // We should really not be modifying the config object - config.txPublicSetupAllowList = config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); - // We'll accumulate sentinel watchers here const watchers: Watcher[] = []; @@ -618,7 +615,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { } public async getAllowedPublicSetup(): Promise { - return this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); + return [...(await getDefaultAllowedSetupFunctions()), ...(this.config.txPublicSetupAllowListExtend ?? [])]; } /** @@ -1318,7 +1315,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { blockNumber, l1ChainId: this.l1ChainId, rollupVersion: this.version, - setupAllowList: this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()), + setupAllowList: [ + ...(await getDefaultAllowedSetupFunctions()), + ...(this.config.txPublicSetupAllowListExtend ?? []), + ], gasFees: await this.getCurrentMinFees(), skipFeeEnforcement, txsPermitted: !this.config.disableTransactions, diff --git a/yarn-project/cli/src/config/network_config.ts b/yarn-project/cli/src/config/network_config.ts index 820f5f1b5da5..a8a9c59b8757 100644 --- a/yarn-project/cli/src/config/network_config.ts +++ b/yarn-project/cli/src/config/network_config.ts @@ -144,4 +144,7 @@ export async function enrichEnvironmentWithNetworkConfig(networkName: NetworkNam if (networkConfig.blockDurationMs !== undefined) { enrichVar('SEQ_BLOCK_DURATION_MS', String(networkConfig.blockDurationMs)); } + if (networkConfig.txPublicSetupAllowListExtend) { + enrichVar('TX_PUBLIC_SETUP_ALLOWLIST', networkConfig.txPublicSetupAllowListExtend); + } } diff --git a/yarn-project/foundation/src/config/network_config.ts b/yarn-project/foundation/src/config/network_config.ts index 5604eca90ff5..18cf67a2df7f 100644 --- a/yarn-project/foundation/src/config/network_config.ts +++ b/yarn-project/foundation/src/config/network_config.ts @@ -9,6 +9,7 @@ export const NetworkConfigSchema = z feeAssetHandlerAddress: z.string().optional(), l1ChainId: z.number(), blockDurationMs: z.number().positive().optional(), + txPublicSetupAllowListExtend: z.string().optional(), }) .passthrough(); // Allow additional unknown fields to pass through diff --git a/yarn-project/p2p/src/config.test.ts b/yarn-project/p2p/src/config.test.ts index f537cffab724..7c80cedbf670 100644 --- a/yarn-project/p2p/src/config.test.ts +++ b/yarn-project/p2p/src/config.test.ts @@ -5,18 +5,14 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { getP2PDefaultConfig, parseAllowList } from './config.js'; describe('config', () => { - it('parses allow list', async () => { - const instance = { address: await AztecAddress.random() }; + it('parses allow list with required selectors', async () => { const instanceFunction = { address: await AztecAddress.random(), selector: FunctionSelector.random() }; - const classId = { classId: Fr.random() }; const classFunction = { classId: Fr.random(), selector: FunctionSelector.random() }; - const config = [instance, instanceFunction, classId, classFunction]; + const config = [instanceFunction, classFunction]; const configStrings = [ - `I:${instance.address}`, `I:${instanceFunction.address}:${instanceFunction.selector}`, - `C:${classId.classId}`, `C:${classFunction.classId}:${classFunction.selector}`, ]; const stringifiedAllowList = configStrings.join(','); @@ -25,6 +21,30 @@ describe('config', () => { expect(allowList).toEqual(config); }); + it('rejects instance entry without selector', async () => { + const address = await AztecAddress.random(); + expect(() => parseAllowList(`I:${address}`)).toThrow('selector is required'); + }); + + it('rejects class entry without selector', () => { + const classId = Fr.random(); + expect(() => parseAllowList(`C:${classId}`)).toThrow('selector is required'); + }); + + it('rejects entry with unknown type', () => { + expect(() => parseAllowList(`X:0x1234:0x12345678`)).toThrow('unknown type'); + }); + + it('parses empty string', () => { + expect(parseAllowList('')).toEqual([]); + }); + + it('handles whitespace in entries', async () => { + const instanceFunction = { address: await AztecAddress.random(), selector: FunctionSelector.random() }; + const allowList = parseAllowList(` I:${instanceFunction.address}:${instanceFunction.selector} `); + expect(allowList).toEqual([instanceFunction]); + }); + it('defaults missing txs collector type to new', () => { const config = getP2PDefaultConfig(); expect(config.txCollectionMissingTxsCollectorType).toBe('new'); diff --git a/yarn-project/p2p/src/config.ts b/yarn-project/p2p/src/config.ts index b7de5cc3038e..050f2b8bb233 100644 --- a/yarn-project/p2p/src/config.ts +++ b/yarn-project/p2p/src/config.ts @@ -150,8 +150,8 @@ export interface P2PConfig /** The maximum possible size of the P2P DB in KB. Overwrites the general dataStoreMapSizeKb. */ p2pStoreMapSizeKb?: number; - /** Which calls are allowed in the public setup phase of a tx. */ - txPublicSetupAllowList: AllowedElement[]; + /** Additional entries to extend the default setup allow list. */ + txPublicSetupAllowListExtend: AllowedElement[]; /** The maximum number of pending txs before evicting lower priority txs. */ maxPendingTxCount: number; @@ -393,12 +393,13 @@ export const p2pConfigMappings: ConfigMappingsType = { parseEnv: (val: string | undefined) => (val ? +val : undefined), description: 'The maximum possible size of the P2P DB in KB. Overwrites the general dataStoreMapSizeKb.', }, - txPublicSetupAllowList: { + txPublicSetupAllowListExtend: { env: 'TX_PUBLIC_SETUP_ALLOWLIST', parseEnv: (val: string) => parseAllowList(val), - description: 'The list of functions calls allowed to run in setup', + description: + 'Additional entries to extend the default setup allow list. Format: I:address:selector,C:classId:selector', printDefault: () => - 'AuthRegistry, FeeJuice.increase_public_balance, Token.increase_public_balance, FPC.prepare_fee', + 'Default: AuthRegistry._set_authorized, FeeJuice._increase_public_balance, Token._increase_public_balance, Token.transfer_in_public', }, maxPendingTxCount: { env: 'P2P_MAX_PENDING_TX_COUNT', @@ -523,11 +524,9 @@ export const bootnodeConfigMappings = pickConfigMappings( /** * Parses a string to a list of allowed elements. - * Each encoded is expected to be of one of the following formats - * `I:${address}` - * `I:${address}:${selector}` - * `C:${classId}` - * `C:${classId}:${selector}` + * Each entry is expected to be of one of the following formats: + * `I:${address}:${selector}` — instance (contract address) with function selector + * `C:${classId}:${selector}` — class with function selector * * @param value The string to parse * @returns A list of allowed elements @@ -540,31 +539,34 @@ export function parseAllowList(value: string): AllowedElement[] { } for (const val of value.split(',')) { - const [typeString, identifierString, selectorString] = val.split(':'); - const selector = selectorString !== undefined ? FunctionSelector.fromString(selectorString) : undefined; + const trimmed = val.trim(); + if (!trimmed) { + continue; + } + const [typeString, identifierString, selectorString] = trimmed.split(':'); + + if (!selectorString) { + throw new Error( + `Invalid allow list entry "${trimmed}": selector is required. Expected format: I:address:selector or C:classId:selector`, + ); + } + + const selector = FunctionSelector.fromString(selectorString); if (typeString === 'I') { - if (selector) { - entries.push({ - address: AztecAddress.fromString(identifierString), - selector, - }); - } else { - entries.push({ - address: AztecAddress.fromString(identifierString), - }); - } + entries.push({ + address: AztecAddress.fromString(identifierString), + selector, + }); } else if (typeString === 'C') { - if (selector) { - entries.push({ - classId: Fr.fromHexString(identifierString), - selector, - }); - } else { - entries.push({ - classId: Fr.fromHexString(identifierString), - }); - } + entries.push({ + classId: Fr.fromHexString(identifierString), + selector, + }); + } else { + throw new Error( + `Invalid allow list entry "${trimmed}": unknown type "${typeString}". Expected "I" (instance) or "C" (class).`, + ); } } diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/allowed_public_setup.ts b/yarn-project/p2p/src/msg_validators/tx_validator/allowed_public_setup.ts index 64578772ea82..b18fa82c4853 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/allowed_public_setup.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/allowed_public_setup.ts @@ -1,33 +1,47 @@ -import { FPCContract } from '@aztec/noir-contracts.js/FPC'; import { TokenContractArtifact } from '@aztec/noir-contracts.js/Token'; import { ProtocolContractAddress } from '@aztec/protocol-contracts'; +import { FunctionSelector } from '@aztec/stdlib/abi'; import { getContractClassFromArtifact } from '@aztec/stdlib/contract'; import type { AllowedElement } from '@aztec/stdlib/interfaces/server'; -let defaultAllowedSetupFunctions: AllowedElement[] | undefined = undefined; +let defaultAllowedSetupFunctions: AllowedElement[] | undefined; + +/** Returns the default list of functions allowed to run in the setup phase of a transaction. */ export async function getDefaultAllowedSetupFunctions(): Promise { if (defaultAllowedSetupFunctions === undefined) { + const tokenClassId = (await getContractClassFromArtifact(TokenContractArtifact)).id; + const setAuthorizedInternalSelector = await FunctionSelector.fromSignature('_set_authorized((Field),Field,bool)'); + const setAuthorizedSelector = await FunctionSelector.fromSignature('set_authorized(Field,bool)'); + const increaseBalanceSelector = await FunctionSelector.fromSignature('_increase_public_balance((Field),u128)'); + const transferInPublicSelector = await FunctionSelector.fromSignature( + 'transfer_in_public((Field),(Field),u128,Field)', + ); + defaultAllowedSetupFunctions = [ - // needed for authwit support + // AuthRegistry: needed for authwit support via private path (set_authorized_private enqueues _set_authorized) + { + address: ProtocolContractAddress.AuthRegistry, + selector: setAuthorizedInternalSelector, + }, + // AuthRegistry: needed for authwit support via public path (PublicFeePaymentMethod calls set_authorized directly) { address: ProtocolContractAddress.AuthRegistry, + selector: setAuthorizedSelector, }, - // needed for claiming on the same tx as a spend + // FeeJuice: needed for claiming on the same tx as a spend (claim_and_end_setup enqueues this) { address: ProtocolContractAddress.FeeJuice, - // We can't restrict the selector because public functions get routed via dispatch. - // selector: FunctionSelector.fromSignature('_increase_public_balance((Field),u128)'), + selector: increaseBalanceSelector, }, - // needed for private transfers via FPC + // Token: needed for private transfers via FPC (transfer_to_public enqueues this) { - classId: (await getContractClassFromArtifact(TokenContractArtifact)).id, - // We can't restrict the selector because public functions get routed via dispatch. - // selector: FunctionSelector.fromSignature('_increase_public_balance((Field),u128)'), + classId: tokenClassId, + selector: increaseBalanceSelector, }, + // Token: needed for public transfers via FPC (fee_entrypoint_public enqueues this) { - classId: (await getContractClassFromArtifact(FPCContract.artifact)).id, - // We can't restrict the selector because public functions get routed via dispatch. - // selector: FunctionSelector.fromSignature('prepare_fee((Field),Field,(Field),Field)'), + classId: tokenClassId, + selector: transferInPublicSelector, }, ]; } diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.test.ts b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.test.ts index 966aaf930f2e..33636eb27d0a 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.test.ts @@ -3,7 +3,11 @@ import type { FunctionSelector } from '@aztec/stdlib/abi'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import { makeAztecAddress, makeSelector, mockTx } from '@aztec/stdlib/testing'; -import { TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED, type Tx } from '@aztec/stdlib/tx'; +import { + TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED, + TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT, + type Tx, +} from '@aztec/stdlib/tx'; import { type MockProxy, mock, mockFn } from 'jest-mock-extended'; @@ -138,4 +142,60 @@ describe('PhasesTxValidator', () => { await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED); }); + + it('rejects address match with wrong selector', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + const wrongSelector = makeSelector(99); + await patchNonRevertibleFn(tx, 0, { address: allowedContract, selector: wrongSelector }); + + await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED); + }); + + it('rejects class match with wrong selector', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + const wrongSelector = makeSelector(99); + const address = await patchNonRevertibleFn(tx, 0, { selector: wrongSelector }); + + contractDataSource.getContract.mockImplementationOnce((contractAddress, atTimestamp) => { + if (timestamp !== atTimestamp) { + throw new Error('Unexpected timestamp'); + } + if (address.equals(contractAddress)) { + return Promise.resolve({ + currentContractClassId: allowedContractClass, + originalContractClassId: Fr.random(), + } as any); + } else { + return Promise.resolve(undefined); + } + }); + + await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED); + }); + + it('rejects with unknown contract error when contract is not found', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + const address = await patchNonRevertibleFn(tx, 0, { selector: allowedSetupSelector1 }); + + contractDataSource.getContract.mockImplementationOnce((contractAddress, atTimestamp) => { + if (timestamp !== atTimestamp) { + throw new Error('Unexpected timestamp'); + } + if (address.equals(contractAddress)) { + return Promise.resolve(undefined); + } + return Promise.resolve(undefined); + }); + + await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT); + }); + + it('does not fetch contract instance when matching by address', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + await patchNonRevertibleFn(tx, 0, { address: allowedContract, selector: allowedSetupSelector1 }); + + await expectValid(tx); + + expect(contractDataSource.getContract).not.toHaveBeenCalled(); + }); }); diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts index 4b6370fa0477..3e8c0f9b3313 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts @@ -6,6 +6,7 @@ import { type PublicCallRequestWithCalldata, TX_ERROR_DURING_VALIDATION, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED, + TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT, Tx, TxExecutionPhase, type TxValidationResult, @@ -45,7 +46,8 @@ export class PhasesTxValidator implements TxValidator { const setupFns = getCallRequestsWithCalldataByPhase(tx, TxExecutionPhase.SETUP); for (const setupFn of setupFns) { - if (!(await this.isOnAllowList(setupFn, this.setupAllowList))) { + const rejectionReason = await this.checkAllowList(setupFn, this.setupAllowList); + if (rejectionReason) { this.#log.verbose( `Rejecting tx ${tx.getTxHash().toString()} because it calls setup function not on allow list: ${ setupFn.request.contractAddress @@ -53,7 +55,7 @@ export class PhasesTxValidator implements TxValidator { { allowList: this.setupAllowList }, ); - return { result: 'invalid', reason: [TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED] }; + return { result: 'invalid', reason: [rejectionReason] }; } } @@ -66,53 +68,47 @@ export class PhasesTxValidator implements TxValidator { } } - private async isOnAllowList( + /** Returns a rejection reason if the call is not on the allow list, or undefined if it is allowed. */ + private async checkAllowList( publicCall: PublicCallRequestWithCalldata, allowList: AllowedElement[], - ): Promise { + ): Promise { if (publicCall.isEmpty()) { - return true; + return undefined; } const contractAddress = publicCall.request.contractAddress; const functionSelector = publicCall.functionSelector; - // do these checks first since they don't require the contract class + // Check address-based entries first since they don't require the contract class. for (const entry of allowList) { - if ('address' in entry && !('selector' in entry)) { - if (contractAddress.equals(entry.address)) { - return true; - } - } - - if ('address' in entry && 'selector' in entry) { + if ('address' in entry) { if (contractAddress.equals(entry.address) && entry.selector.equals(functionSelector)) { - return true; + return undefined; } } + } - const contractClass = await this.contractsDB.getContractInstance(contractAddress, this.timestamp); - - if (!contractClass) { - throw new Error(`Contract not found: ${contractAddress}`); + // Check class-based entries. Fetch the contract instance lazily (only once). + let contractClassId: undefined | { value: string | undefined }; + for (const entry of allowList) { + if (!('classId' in entry)) { + continue; } - if ('classId' in entry && !('selector' in entry)) { - if (contractClass.currentContractClassId.equals(entry.classId)) { - return true; + if (contractClassId === undefined) { + const instance = await this.contractsDB.getContractInstance(contractAddress, this.timestamp); + contractClassId = { value: instance?.currentContractClassId.toString() }; + if (!contractClassId.value) { + return TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT; } } - if ('classId' in entry && 'selector' in entry) { - if ( - contractClass.currentContractClassId.equals(entry.classId) && - (entry.selector === undefined || entry.selector.equals(functionSelector)) - ) { - return true; - } + if (contractClassId.value === entry.classId.toString() && entry.selector.equals(functionSelector)) { + return undefined; } } - return false; + return TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED; } } diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index 8da82a7d195b..6066e7d2b6b3 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -1621,7 +1621,10 @@ export class LibP2PService extends WithTracer implements P2PService { nextSlotTimestamp: UInt64, ): Promise> { const gasFees = await this.getGasFees(currentBlockNumber); - const allowedInSetup = this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); + const allowedInSetup = [ + ...(await getDefaultAllowedSetupFunctions()), + ...(this.config.txPublicSetupAllowListExtend ?? []), + ]; const blockNumber = BlockNumber(currentBlockNumber + 1); return createFirstStageTxValidationsForGossipedTransactions( diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index d6aa5d93f9f5..64a7d321a2a8 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -71,7 +71,7 @@ export type SequencerClientConfig = SequencerPublisherConfig & SequencerConfig & L1ReaderConfig & ChainConfig & - Pick & + Pick & Pick; export const sequencerConfigMappings: ConfigMappingsType = { @@ -220,7 +220,7 @@ export const sequencerConfigMappings: ConfigMappingsType = { description: 'Percent probability (0 - 100) of sequencer skipping checkpoint publishing (testing only)', ...numberConfigHelper(DefaultSequencerConfig.skipPublishingCheckpointsPercent), }, - ...pickConfigMappings(p2pConfigMappings, ['txPublicSetupAllowList']), + ...pickConfigMappings(p2pConfigMappings, ['txPublicSetupAllowListExtend']), }; export const sequencerClientConfigMappings: ConfigMappingsType = { diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 527f7144bf60..7da938a6c828 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -110,7 +110,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter) { const filteredConfig = pickFromSchema(config, SequencerConfigSchema); - this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowList')); + this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowListExtend')); this.config = merge(this.config, filteredConfig); this.timetable = new SequencerTimetable( { diff --git a/yarn-project/stdlib/src/interfaces/allowed_element.ts b/yarn-project/stdlib/src/interfaces/allowed_element.ts index b8de0fd4e661..807f21e5286f 100644 --- a/yarn-project/stdlib/src/interfaces/allowed_element.ts +++ b/yarn-project/stdlib/src/interfaces/allowed_element.ts @@ -6,18 +6,14 @@ import type { FunctionSelector } from '../abi/function_selector.js'; import type { AztecAddress } from '../aztec-address/index.js'; import { schemas, zodFor } from '../schemas/index.js'; -type AllowedInstance = { address: AztecAddress }; type AllowedInstanceFunction = { address: AztecAddress; selector: FunctionSelector }; -type AllowedClass = { classId: Fr }; type AllowedClassFunction = { classId: Fr; selector: FunctionSelector }; -export type AllowedElement = AllowedInstance | AllowedInstanceFunction | AllowedClass | AllowedClassFunction; +export type AllowedElement = AllowedInstanceFunction | AllowedClassFunction; export const AllowedElementSchema = zodFor()( z.union([ z.object({ address: schemas.AztecAddress, selector: schemas.FunctionSelector }), - z.object({ address: schemas.AztecAddress }), z.object({ classId: schemas.Fr, selector: schemas.FunctionSelector }), - z.object({ classId: schemas.Fr }), ]), ); diff --git a/yarn-project/stdlib/src/interfaces/block-builder.ts b/yarn-project/stdlib/src/interfaces/block-builder.ts index b5b5ea9a4c1a..f0c4eb780468 100644 --- a/yarn-project/stdlib/src/interfaces/block-builder.ts +++ b/yarn-project/stdlib/src/interfaces/block-builder.ts @@ -50,14 +50,17 @@ export interface PublicProcessorValidator { export type FullNodeBlockBuilderConfig = Pick & Pick & - Pick; + Pick< + SequencerConfig, + 'txPublicSetupAllowListExtend' | 'fakeProcessingDelayPerTxMs' | 'fakeThrowAfterProcessingTxCount' + >; export const FullNodeBlockBuilderConfigKeys: (keyof FullNodeBlockBuilderConfig)[] = [ 'l1GenesisTime', 'slotDuration', 'l1ChainId', 'rollupVersion', - 'txPublicSetupAllowList', + 'txPublicSetupAllowListExtend', 'fakeProcessingDelayPerTxMs', 'fakeThrowAfterProcessingTxCount', ] as const; diff --git a/yarn-project/stdlib/src/interfaces/configs.ts b/yarn-project/stdlib/src/interfaces/configs.ts index 88b1366a6889..88c7db90d3eb 100644 --- a/yarn-project/stdlib/src/interfaces/configs.ts +++ b/yarn-project/stdlib/src/interfaces/configs.ts @@ -31,8 +31,8 @@ export interface SequencerConfig { acvmWorkingDirectory?: string; /** The path to the ACVM binary */ acvmBinaryPath?: string; - /** The list of functions calls allowed to run in setup */ - txPublicSetupAllowList?: AllowedElement[]; + /** Additional entries to extend the default setup allow list. */ + txPublicSetupAllowListExtend?: AllowedElement[]; /** Max block size */ maxBlockSizeInBytes?: number; /** Payload address to vote for */ @@ -94,7 +94,7 @@ export const SequencerConfigSchema = zodFor()( feeRecipient: schemas.AztecAddress.optional(), acvmWorkingDirectory: z.string().optional(), acvmBinaryPath: z.string().optional(), - txPublicSetupAllowList: z.array(AllowedElementSchema).optional(), + txPublicSetupAllowListExtend: z.array(AllowedElementSchema).optional(), maxBlockSizeInBytes: z.number().optional(), governanceProposerPayload: schemas.EthAddress.optional(), l1PublishingTime: z.number().optional(), @@ -132,7 +132,7 @@ type SequencerConfigOptionalKeys = | 'fakeProcessingDelayPerTxMs' | 'fakeThrowAfterProcessingTxCount' | 'l1PublishingTime' - | 'txPublicSetupAllowList' + | 'txPublicSetupAllowListExtend' | 'minValidTxsPerBlock' | 'minBlocksForCheckpoint'; diff --git a/yarn-project/stdlib/src/interfaces/validator.ts b/yarn-project/stdlib/src/interfaces/validator.ts index 608d08520758..c6596a6aca72 100644 --- a/yarn-project/stdlib/src/interfaces/validator.ts +++ b/yarn-project/stdlib/src/interfaces/validator.ts @@ -62,7 +62,7 @@ export type ValidatorClientConfig = ValidatorHASignerConfig & { }; export type ValidatorClientFullConfig = ValidatorClientConfig & - Pick & + Pick & Pick< SlasherConfig, 'slashBroadcastedInvalidBlockPenalty' | 'slashDuplicateProposalPenalty' | 'slashDuplicateAttestationPenalty' @@ -91,7 +91,7 @@ export const ValidatorClientConfigSchema = zodFor>()( ValidatorClientConfigSchema.extend({ - txPublicSetupAllowList: z.array(AllowedElementSchema).optional(), + txPublicSetupAllowListExtend: z.array(AllowedElementSchema).optional(), broadcastInvalidBlockProposal: z.boolean().optional(), maxTxsPerBlock: z.number().optional(), slashBroadcastedInvalidBlockPenalty: schemas.BigInt, diff --git a/yarn-project/stdlib/src/tx/validator/error_texts.ts b/yarn-project/stdlib/src/tx/validator/error_texts.ts index 30f4867d209f..cf737a2160c4 100644 --- a/yarn-project/stdlib/src/tx/validator/error_texts.ts +++ b/yarn-project/stdlib/src/tx/validator/error_texts.ts @@ -6,6 +6,7 @@ export const TX_ERROR_GAS_LIMIT_TOO_HIGH = 'Gas limit is higher than the amount // Phases export const TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED = 'Setup function not on allow list'; +export const TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT = 'Setup function targets unknown contract'; // Nullifiers export const TX_ERROR_DUPLICATE_NULLIFIER_IN_TX = 'Duplicate nullifier in tx'; diff --git a/yarn-project/validator-client/src/checkpoint_builder.ts b/yarn-project/validator-client/src/checkpoint_builder.ts index c8ec5c5671fe..74059c27ce35 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.ts @@ -148,7 +148,10 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { } protected async makeBlockBuilderDeps(globalVariables: GlobalVariables, fork: MerkleTreeWriteOperations) { - const txPublicSetupAllowList = this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); + const txPublicSetupAllowList = [ + ...(await getDefaultAllowedSetupFunctions()), + ...(this.config.txPublicSetupAllowListExtend ?? []), + ]; const contractsDB = new PublicContractsDB(this.contractDataSource, this.log.getBindings()); const guardedFork = new GuardedMerkleTreeOperations(fork); diff --git a/yarn-project/validator-client/src/validator.integration.test.ts b/yarn-project/validator-client/src/validator.integration.test.ts index dd30f91bd4be..811a84927be0 100644 --- a/yarn-project/validator-client/src/validator.integration.test.ts +++ b/yarn-project/validator-client/src/validator.integration.test.ts @@ -127,7 +127,7 @@ describe('ValidatorClient Integration', () => { slotDuration: l1Constants.slotDuration, l1ChainId: chainId.toNumber(), rollupVersion: version.toNumber(), - txPublicSetupAllowList: [], + txPublicSetupAllowListExtend: [], }, synchronizer, archiver, From e4dbc75b9e2753a2f4f299b35d3a2522a15a8d7d Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 2 Mar 2026 18:12:14 -0300 Subject: [PATCH 25/25] fix(spartan): wire SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT env var So it is respected when we deploy a network. --- spartan/scripts/deploy_network.sh | 2 ++ spartan/terraform/deploy-aztec-infra/main.tf | 1 + spartan/terraform/deploy-aztec-infra/variables.tf | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/spartan/scripts/deploy_network.sh b/spartan/scripts/deploy_network.sh index 3f84610e781b..dc40910b6de2 100755 --- a/spartan/scripts/deploy_network.sh +++ b/spartan/scripts/deploy_network.sh @@ -107,6 +107,7 @@ PROVER_FAILED_PROOF_STORE=${PROVER_FAILED_PROOF_STORE:-} SEQ_MIN_TX_PER_BLOCK=${SEQ_MIN_TX_PER_BLOCK:-0} SEQ_MAX_TX_PER_BLOCK=${SEQ_MAX_TX_PER_BLOCK:-8} SEQ_BLOCK_DURATION_MS=${SEQ_BLOCK_DURATION_MS:-} +SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT=${SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT:-} SEQ_BUILD_CHECKPOINT_IF_EMPTY=${SEQ_BUILD_CHECKPOINT_IF_EMPTY:-} SEQ_ENFORCE_TIME_TABLE=${SEQ_ENFORCE_TIME_TABLE:-} SEQ_SKIP_CHECKPOINT_PUBLISH_PERCENT=${SEQ_SKIP_CHECKPOINT_PUBLISH_PERCENT:-0} @@ -531,6 +532,7 @@ VALIDATOR_HA_REPLICAS = ${VALIDATOR_HA_REPLICAS} SEQ_MIN_TX_PER_BLOCK = ${SEQ_MIN_TX_PER_BLOCK} SEQ_MAX_TX_PER_BLOCK = ${SEQ_MAX_TX_PER_BLOCK} SEQ_BLOCK_DURATION_MS = ${SEQ_BLOCK_DURATION_MS:-null} +SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT = ${SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT:-null} SEQ_BUILD_CHECKPOINT_IF_EMPTY = ${SEQ_BUILD_CHECKPOINT_IF_EMPTY:-null} SEQ_ENFORCE_TIME_TABLE = ${SEQ_ENFORCE_TIME_TABLE:-null} SEQ_SKIP_CHECKPOINT_PUBLISH_PERCENT = ${SEQ_SKIP_CHECKPOINT_PUBLISH_PERCENT} diff --git a/spartan/terraform/deploy-aztec-infra/main.tf b/spartan/terraform/deploy-aztec-infra/main.tf index bf07026f31d8..74021428bac8 100644 --- a/spartan/terraform/deploy-aztec-infra/main.tf +++ b/spartan/terraform/deploy-aztec-infra/main.tf @@ -213,6 +213,7 @@ locals { "validator.node.env.SEQ_MIN_TX_PER_BLOCK" = var.SEQ_MIN_TX_PER_BLOCK "validator.node.env.SEQ_MAX_TX_PER_BLOCK" = var.SEQ_MAX_TX_PER_BLOCK "validator.node.env.SEQ_BLOCK_DURATION_MS" = var.SEQ_BLOCK_DURATION_MS + "validator.node.env.SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT" = var.SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT "validator.node.env.SEQ_BUILD_CHECKPOINT_IF_EMPTY" = var.SEQ_BUILD_CHECKPOINT_IF_EMPTY "validator.node.env.SEQ_ENFORCE_TIME_TABLE" = var.SEQ_ENFORCE_TIME_TABLE "validator.node.env.P2P_TX_POOL_DELETE_TXS_AFTER_REORG" = var.P2P_TX_POOL_DELETE_TXS_AFTER_REORG diff --git a/spartan/terraform/deploy-aztec-infra/variables.tf b/spartan/terraform/deploy-aztec-infra/variables.tf index fadca17716b5..2a0c9a8398b9 100644 --- a/spartan/terraform/deploy-aztec-infra/variables.tf +++ b/spartan/terraform/deploy-aztec-infra/variables.tf @@ -369,6 +369,13 @@ variable "SEQ_BLOCK_DURATION_MS" { default = null } +variable "SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT" { + description = "Time allocated for publishing to L1, in seconds" + type = string + nullable = true + default = null +} + variable "SEQ_BUILD_CHECKPOINT_IF_EMPTY" { description = "Have sequencer build and publish an empty checkpoint if there are no txs" type = string