From 9e6e825a63554f889d02d73750d7120937821f25 Mon Sep 17 00:00:00 2001 From: Phil Windle Date: Tue, 24 Feb 2026 20:31:42 +0000 Subject: [PATCH 1/3] Validate num txs in block proposals --- yarn-project/p2p/src/config.ts | 7 +- .../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 +- .../stdlib/src/interfaces/validator.ts | 3 +- yarn-project/validator-client/src/factory.ts | 1 + .../validator-client/src/validator.ts | 1 + 9 files changed, 176 insertions(+), 7 deletions(-) diff --git a/yarn-project/p2p/src/config.ts b/yarn-project/p2p/src/config.ts index f9f951019191..2acb4290a8b8 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; @@ -464,6 +464,11 @@ export const p2pConfigMappings: ConfigMappingsType = { description: 'Minimum age (ms) a transaction must have been in the pool before it is eligible for block building.', ...numberConfigHelper(2_000), }, + maxTxsPerBlock: { + env: 'SEQ_MAX_TX_PER_BLOCK', + description: 'The maximum number of txs to include in a block. Used for validating proposals from peers.', + parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined), + }, ...sharedSequencerConfigMappings, ...p2pReqRespConfigMappings, ...batchTxRequesterConfigMappings, 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 7215f2bc449e..5d6edd034eb9 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -224,9 +224,13 @@ export class LibP2PService extends 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/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 5a0c8a2b88493dd41c8c09b47c9f5f4947d6b648 Mon Sep 17 00:00:00 2001 From: Phil Windle Date: Wed, 25 Feb 2026 12:13:34 +0000 Subject: [PATCH 2/3] refactor: move maxTxsPerBlock config to sharedSequencerConfigMappings Deduplicate the SEQ_MAX_TX_PER_BLOCK config mapping by moving it to the shared sequencer config in stdlib, removing the duplicate definitions from p2p and sequencer-client configs. Co-Authored-By: Claude Opus 4.6 --- yarn-project/p2p/src/config.ts | 5 ----- yarn-project/sequencer-client/src/config.ts | 5 ----- yarn-project/stdlib/src/config/sequencer-config.ts | 9 +++++++-- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/yarn-project/p2p/src/config.ts b/yarn-project/p2p/src/config.ts index 2acb4290a8b8..b7de5cc3038e 100644 --- a/yarn-project/p2p/src/config.ts +++ b/yarn-project/p2p/src/config.ts @@ -464,11 +464,6 @@ export const p2pConfigMappings: ConfigMappingsType = { description: 'Minimum age (ms) a transaction must have been in the pool before it is eligible for block building.', ...numberConfigHelper(2_000), }, - maxTxsPerBlock: { - env: 'SEQ_MAX_TX_PER_BLOCK', - description: 'The maximum number of txs to include in a block. Used for validating proposals from peers.', - parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined), - }, ...sharedSequencerConfigMappings, ...p2pReqRespConfigMappings, ...batchTxRequesterConfigMappings, diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index 469651fba387..167c146f2bd0 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -77,11 +77,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..32e0deac63cb 100644 --- a/yarn-project/stdlib/src/config/sequencer-config.ts +++ b/yarn-project/stdlib/src/config/sequencer-config.ts @@ -1,4 +1,4 @@ -import type { ConfigMappingsType } from '@aztec/foundation/config'; +import { type ConfigMappingsType, numberConfigHelper } from '@aztec/foundation/config'; import type { SequencerConfig } from '../interfaces/configs.js'; @@ -9,7 +9,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 +26,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(32), + }, }; From 9f9e2fc8b3dc00b693b62af071dbbd4d22ecfcd2 Mon Sep 17 00:00:00 2001 From: Phil Windle Date: Wed, 25 Feb 2026 12:20:09 +0000 Subject: [PATCH 3/3] refactor: extract DEFAULT_MAX_TXS_PER_BLOCK constant to stdlib Co-Authored-By: Claude Opus 4.6 --- yarn-project/sequencer-client/src/config.ts | 3 ++- yarn-project/stdlib/src/config/sequencer-config.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index 167c146f2bd0..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, diff --git a/yarn-project/stdlib/src/config/sequencer-config.ts b/yarn-project/stdlib/src/config/sequencer-config.ts index 32e0deac63cb..31d0eca9458a 100644 --- a/yarn-project/stdlib/src/config/sequencer-config.ts +++ b/yarn-project/stdlib/src/config/sequencer-config.ts @@ -2,6 +2,9 @@ import { type ConfigMappingsType, numberConfigHelper } from '@aztec/foundation/c 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 @@ -29,6 +32,6 @@ export const sharedSequencerConfigMappings: ConfigMappingsType< maxTxsPerBlock: { env: 'SEQ_MAX_TX_PER_BLOCK', description: 'The maximum number of txs to include in a block.', - ...numberConfigHelper(32), + ...numberConfigHelper(DEFAULT_MAX_TXS_PER_BLOCK), }, };