From f0ce5ffefe964115610721ef99607bc6e6b62d37 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 9 Jun 2026 19:36:50 -0300 Subject: [PATCH 01/10] feat: network-wide consensus config with validation and override protection (A-1168) --- .../aztec-node/src/aztec-node/server.ts | 53 +++++ yarn-project/cli/src/config/network_config.ts | 8 + yarn-project/foundation/src/config/env_var.ts | 1 + .../p2p/src/services/libp2p/libp2p_service.ts | 23 ++ .../src/sequencer/sequencer.test.ts | 12 ++ .../src/sequencer/sequencer.ts | 12 +- yarn-project/stdlib/src/config/index.ts | 1 + .../config/network-consensus-config.test.ts | 123 +++++++++++ .../src/config/network-consensus-config.ts | 202 ++++++++++++++++++ yarn-project/stdlib/src/timetable/README.md | 9 +- .../src/timetable/proposer_timetable.test.ts | 49 +++++ .../src/timetable/proposer_timetable.ts | 28 ++- 12 files changed, 511 insertions(+), 10 deletions(-) create mode 100644 yarn-project/stdlib/src/config/network-consensus-config.test.ts create mode 100644 yarn-project/stdlib/src/config/network-consensus-config.ts diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 21cdf1101744..4e04dc772254 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -18,6 +18,7 @@ import { SlotNumber, } from '@aztec/foundation/branded-types'; import { chunkBy, compactArray, pick, unique } from '@aztec/foundation/collection'; +import { getActiveNetworkName } from '@aztec/foundation/config'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { BadRequestError } from '@aztec/foundation/json-rpc'; @@ -80,6 +81,14 @@ import { L1PublishedData, type PublishedCheckpoint, } from '@aztec/stdlib/checkpoint'; +import { + DEFAULT_MAX_BLOCKS_PER_CHECKPOINT, + MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, + MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER, + type NetworkConsensusConfig, + getNetworkConsensusConfig, + validateNetworkConsensusConfig, +} from '@aztec/stdlib/config'; import type { ContractClassPublic, ContractDataSource, @@ -623,6 +632,8 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb : 2 * DEFAULT_MIN_BLOCK_DURATION; config.skipOrphanProposedBlockPruning ||= !!config.useAutomineSequencer; + AztecNodeService.validateConsensusConfig(config, Number(slotDuration), log); + // Create world-state first so we can retrieve the initial header before constructing the archiver. const nativeWs = await createWorldState(config, options.genesis); const initialHeader = nativeWs.getInitialHeader(); @@ -1053,6 +1064,48 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb } } + /** + * Validates the consensus-critical configuration against the rollup contract and (when a known network is + * selected) its in-code preset. Throws on hard inconsistencies, warns on suspicious or unachievable values. + * Runs for every node role since it sits in the shared startup path. + */ + private static validateConsensusConfig(config: AztecNodeConfig, slotDuration: number, log: Logger): void { + const consensusConfig: NetworkConsensusConfig = { + aztecSlotDuration: slotDuration, + ethereumSlotDuration: config.ethereumSlotDuration, + blockDurationMs: config.blockDurationMs, + maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint ?? DEFAULT_MAX_BLOCKS_PER_CHECKPOINT, + checkpointProposalSyncGraceSeconds: config.checkpointProposalSyncGraceSeconds!, + minPerBlockAllocationMultiplier: MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, + minPerBlockDAAllocationMultiplier: MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER, + }; + + const { errors, warnings } = validateNetworkConsensusConfig(consensusConfig); + for (const warning of warnings) { + log.warn(`Consensus config warning: ${warning}`, { warning }); + } + if (errors.length > 0) { + throw new Error(`Invalid consensus configuration: ${errors.join('; ')}`); + } + + const networkName = getActiveNetworkName(); + const preset = getNetworkConsensusConfig(networkName); + if (preset && preset.aztecSlotDuration !== slotDuration) { + const message = + `Network ${networkName} preset expects aztecSlotDuration ${preset.aztecSlotDuration}s but the rollup ` + + `contract reports ${slotDuration}s. This usually means a stale preset or a node pointed at the wrong ` + + `rollup. Set ALLOW_OVERRIDING_NETWORK_CONFIG=1 to override.`; + if ( + process.env.ALLOW_OVERRIDING_NETWORK_CONFIG === '1' || + process.env.ALLOW_OVERRIDING_NETWORK_CONFIG === 'true' + ) { + log.warn(message, { networkName, presetSlotDuration: preset.aztecSlotDuration, slotDuration }); + } else { + throw new Error(message); + } + } + } + /** * Returns the sequencer client instance. * @returns The sequencer client instance. diff --git a/yarn-project/cli/src/config/network_config.ts b/yarn-project/cli/src/config/network_config.ts index df3cdd3d52cc..2f3f0bf4b40e 100644 --- a/yarn-project/cli/src/config/network_config.ts +++ b/yarn-project/cli/src/config/network_config.ts @@ -1,4 +1,6 @@ import { type NetworkConfig, NetworkConfigMapSchema, type NetworkNames } from '@aztec/foundation/config'; +import { createLogger } from '@aztec/foundation/log'; +import { applyNetworkConsensusConfigToEnv } from '@aztec/stdlib/config'; import { readFile } from 'fs/promises'; import { join } from 'path'; @@ -116,6 +118,12 @@ async function fetchNetworkConfigFromUrl( * Does not throw if the network simply doesn't exist in the config - just returns without enriching */ export async function enrichEnvironmentWithNetworkConfig(networkName: NetworkNames) { + // Apply in-code consensus presets first so they are authoritative for consensus-critical vars. This throws + // if an operator env override conflicts with the preset (unless ALLOW_OVERRIDING_NETWORK_CONFIG is set), and + // makes the remote JSON's enrichVar calls below no-op for vars the preset already populated. + const log = createLogger('cli:network_config'); + applyNetworkConsensusConfigToEnv(networkName, process.env, msg => log.warn(msg)); + if (networkName === 'local') { return; // No remote config for local development } diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index 8578700b64ce..3080a0fb9824 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -99,6 +99,7 @@ export type EnvVar = | 'MNEMONIC' | 'NETWORK' | 'NETWORK_CONFIG_LOCATION' + | 'ALLOW_OVERRIDING_NETWORK_CONFIG' | 'USE_GCLOUD_LOGGING' | 'OTEL_EXPORTER_OTLP_METRICS_ENDPOINT' | 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT' diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index 9bb9ae17b8b9..15f470c1b702 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -118,6 +118,29 @@ import type { } from '../service.js'; import { P2PInstrumentation } from './instrumentation.js'; +/** + * Builds the proposer timetable used by both the gossip validators (for receive-window bounds) and + * gossipsub topic scoring (for max-blocks-per-checkpoint). Operational budgets come from p2p config, + * falling back to the shared stdlib defaults. `checkpointProposalInitTime` is not config-mapped, so the + * stdlib default is used here, the same way the sequencer sources it. + */ +function buildProposerTimetable( + config: P2PConfig, + l1Constants: ReturnType, +): ProposerTimetable { + return new ProposerTimetable({ + l1Constants, + blockDuration: config.blockDurationMs / 1000, + minBlockDuration: config.minBlockDuration ?? DEFAULT_MIN_BLOCK_DURATION, + p2pPropagationTime: config.attestationPropagationTime ?? DEFAULT_P2P_PROPAGATION_TIME, + checkpointProposalPrepareTime: config.checkpointProposalPrepareTime ?? DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, + checkpointProposalInitTime: DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, + checkpointProposalSyncGrace: config.checkpointProposalSyncGraceSeconds, + maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint, + }); +} + + interface ValidationResult { name: string; isValid: TxValidationResult; diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index e60344fd8c61..18e9117fa49e 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -404,6 +404,18 @@ describe('sequencer', () => { sequencer.updateConfig(config); }); + describe('perBlockAllocationMultiplier guard', () => { + it('rejects a multiplier below the network minimum', () => { + expect(() => sequencer.updateConfig({ perBlockAllocationMultiplier: 1.0 })).toThrow( + /perBlockAllocationMultiplier/, + ); + }); + + it('accepts a multiplier at or above the network minimum', () => { + expect(() => sequencer.updateConfig({ perBlockAllocationMultiplier: 1.5 })).not.toThrow(); + }); + }); + describe('block building', () => { it('builds a block out of a single tx', async () => { await setupSingleTxBlock(); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index cd2e66d6f4f2..e3eb67b75c09 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -19,7 +19,7 @@ import type { ValidateCheckpointResult, } from '@aztec/stdlib/block'; import type { Checkpoint, ProposedCheckpointData } from '@aztec/stdlib/checkpoint'; -import type { ChainConfig } from '@aztec/stdlib/config'; +import { type ChainConfig, MIN_PER_BLOCK_ALLOCATION_MULTIPLIER } from '@aztec/stdlib/config'; import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; import { MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, @@ -198,11 +198,11 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter { + it.each(PRESET_NETWORKS)('%s preset validates with zero errors and zero warnings', networkName => { + const preset = getNetworkConsensusConfig(networkName); + expect(preset).toBeDefined(); + const { errors, warnings } = validateNetworkConsensusConfig(preset!); + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + }); + + it('returns undefined for networks without a preset', () => { + expect(getNetworkConsensusConfig('local')).toBeUndefined(); + expect(getNetworkConsensusConfig('devnet')).toBeUndefined(); + }); +}); + +describe('validateNetworkConsensusConfig', () => { + const base: NetworkConsensusConfig = { + aztecSlotDuration: 72, + ethereumSlotDuration: 12, + blockDurationMs: 6000, + maxBlocksPerCheckpoint: 10, + checkpointProposalSyncGraceSeconds: 12, + minPerBlockAllocationMultiplier: 1.2, + minPerBlockDAAllocationMultiplier: 1.5, + }; + + it('reports an error when blockDurationMs is non-positive', () => { + expect(validateNetworkConsensusConfig({ ...base, blockDurationMs: 0 }).errors).toContainEqual( + expect.stringContaining('blockDurationMs'), + ); + }); + + it('reports an error when the sub-slot is longer than the slot', () => { + expect(validateNetworkConsensusConfig({ ...base, blockDurationMs: 100_000 }).errors).toContainEqual( + expect.stringContaining('exceeds aztecSlotDuration'), + ); + }); + + it('reports an error when maxBlocksPerCheckpoint is below 1', () => { + expect(validateNetworkConsensusConfig({ ...base, maxBlocksPerCheckpoint: 0 }).errors).toContainEqual( + expect.stringContaining('maxBlocksPerCheckpoint'), + ); + }); + + it('reports an error for a negative sync grace', () => { + expect(validateNetworkConsensusConfig({ ...base, checkpointProposalSyncGraceSeconds: -1 }).errors).toContainEqual( + expect.stringContaining('checkpointProposalSyncGraceSeconds'), + ); + }); + + it('reports an error for multipliers below 1', () => { + expect(validateNetworkConsensusConfig({ ...base, minPerBlockAllocationMultiplier: 0.5 }).errors).toContainEqual( + expect.stringContaining('minPerBlockAllocationMultiplier'), + ); + expect(validateNetworkConsensusConfig({ ...base, minPerBlockDAAllocationMultiplier: 0.5 }).errors).toContainEqual( + expect.stringContaining('minPerBlockDAAllocationMultiplier'), + ); + }); + + it('warns when the slot duration is not a multiple of the ethereum slot duration', () => { + expect(validateNetworkConsensusConfig({ ...base, ethereumSlotDuration: 5 }).warnings).toContainEqual( + expect.stringContaining('not a multiple'), + ); + }); + + it('warns when maxBlocksPerCheckpoint exceeds the achievable count at default budgets', () => { + expect(validateNetworkConsensusConfig({ ...base, maxBlocksPerCheckpoint: 24 }).warnings).toContainEqual( + expect.stringContaining('exceeds the'), + ); + }); +}); + +describe('applyNetworkConsensusConfigToEnv', () => { + const mainnet = getNetworkConsensusConfig('mainnet')!; + + it('populates unset consensus vars from the preset', () => { + const env: Record = {}; + applyNetworkConsensusConfigToEnv('mainnet', env); + expect(env.ETHEREUM_SLOT_DURATION).toBe(String(mainnet.ethereumSlotDuration)); + expect(env.SEQ_BLOCK_DURATION_MS).toBe(String(mainnet.blockDurationMs)); + expect(env.MAX_BLOCKS_PER_CHECKPOINT).toBe(String(mainnet.maxBlocksPerCheckpoint)); + expect(env.CHECKPOINT_PROPOSAL_SYNC_GRACE_SECONDS).toBe(String(mainnet.checkpointProposalSyncGraceSeconds)); + }); + + it('keeps equal operator values', () => { + const env: Record = { SEQ_BLOCK_DURATION_MS: String(mainnet.blockDurationMs) }; + expect(() => applyNetworkConsensusConfigToEnv('mainnet', env)).not.toThrow(); + expect(env.SEQ_BLOCK_DURATION_MS).toBe(String(mainnet.blockDurationMs)); + }); + + it('throws naming the var on a conflicting operator override', () => { + const env: Record = { SEQ_BLOCK_DURATION_MS: '3000' }; + expect(() => applyNetworkConsensusConfigToEnv('mainnet', env)).toThrow(/SEQ_BLOCK_DURATION_MS/); + }); + + it('keeps the operator value and logs when ALLOW_OVERRIDING_NETWORK_CONFIG is set', () => { + const env: Record = { + SEQ_BLOCK_DURATION_MS: '3000', + ALLOW_OVERRIDING_NETWORK_CONFIG: '1', + }; + const logs: string[] = []; + applyNetworkConsensusConfigToEnv('mainnet', env, msg => logs.push(msg)); + expect(env.SEQ_BLOCK_DURATION_MS).toBe('3000'); + expect(logs.some(msg => msg.includes('SEQ_BLOCK_DURATION_MS'))).toBe(true); + }); + + it('is a no-op for networks without a preset', () => { + const env: Record = {}; + applyNetworkConsensusConfigToEnv('local', env); + expect(env).toEqual({}); + }); +}); diff --git a/yarn-project/stdlib/src/config/network-consensus-config.ts b/yarn-project/stdlib/src/config/network-consensus-config.ts new file mode 100644 index 000000000000..cecb0df67649 --- /dev/null +++ b/yarn-project/stdlib/src/config/network-consensus-config.ts @@ -0,0 +1,202 @@ +import type { NetworkNames } from '@aztec/foundation/config'; + +import { + DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, + DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, + DEFAULT_MIN_BLOCK_DURATION, + DEFAULT_P2P_PROPAGATION_TIME, +} from '../timetable/budgets.js'; +import { ProposerTimetable } from '../timetable/proposer_timetable.js'; + +/** + * Network-minimum per-block budget multiplier for L2 gas / tx count. Operators may configure a higher value, + * but never lower: a node admitting txs under a smaller multiplier would accept work it can never pack. + */ +export const MIN_PER_BLOCK_ALLOCATION_MULTIPLIER = 1.2; + +/** Network-minimum per-block budget multiplier for DA gas / blob fields. See {@link MIN_PER_BLOCK_ALLOCATION_MULTIPLIER}. */ +export const MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER = 1.5; + +/** Consensus-critical configuration that must be identical across all nodes of a network. */ +export type NetworkConsensusConfig = { + /** Expected aztecSlotDuration (seconds); cross-checked against the rollup contract at startup. */ + aztecSlotDuration: number; + /** Ethereum slot duration (seconds) of the network's L1. */ + ethereumSlotDuration: number; + /** Duration of a block sub-slot in ms. */ + blockDurationMs: number; + /** Explicit network max blocks per checkpoint (NOT derived from local budgets). */ + maxBlocksPerCheckpoint: number; + /** Consensus grace for received checkpoint proposals to materialize locally (seconds). */ + checkpointProposalSyncGraceSeconds: number; + /** Network-minimum per-block budget multiplier for L2 gas / tx count (operators may set higher). */ + minPerBlockAllocationMultiplier: number; + /** Network-minimum per-block budget multiplier for DA gas / blob fields. */ + minPerBlockDAAllocationMultiplier: number; +}; + +/** + * In-code consensus presets keyed by network name. Networks without a preset (e.g. `local`, `devnet`) return + * `undefined` from {@link getNetworkConsensusConfig} and are not subject to override enforcement. + */ +const NETWORK_CONSENSUS_PRESETS: Partial> = { + mainnet: { + aztecSlotDuration: 72, + ethereumSlotDuration: 12, + blockDurationMs: 6000, + maxBlocksPerCheckpoint: 10, + checkpointProposalSyncGraceSeconds: 12, + minPerBlockAllocationMultiplier: MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, + minPerBlockDAAllocationMultiplier: MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER, + }, + testnet: { + aztecSlotDuration: 72, + ethereumSlotDuration: 12, + blockDurationMs: 6000, + maxBlocksPerCheckpoint: 10, + checkpointProposalSyncGraceSeconds: 12, + minPerBlockAllocationMultiplier: MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, + minPerBlockDAAllocationMultiplier: MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER, + }, +}; + +/** Returns the in-code consensus preset for a network, or `undefined` when none is defined. */ +export function getNetworkConsensusConfig(networkName: NetworkNames): NetworkConsensusConfig | undefined { + return NETWORK_CONSENSUS_PRESETS[networkName]; +} + +/** Maps consensus config fields to the env vars operators may set for them. */ +const CONSENSUS_ENV_VARS = [ + { env: 'ETHEREUM_SLOT_DURATION', field: 'ethereumSlotDuration' }, + { env: 'SEQ_BLOCK_DURATION_MS', field: 'blockDurationMs' }, + { env: 'MAX_BLOCKS_PER_CHECKPOINT', field: 'maxBlocksPerCheckpoint' }, + { env: 'CHECKPOINT_PROPOSAL_SYNC_GRACE_SECONDS', field: 'checkpointProposalSyncGraceSeconds' }, +] as const satisfies ReadonlyArray<{ env: string; field: keyof NetworkConsensusConfig }>; + +/** + * Validates a {@link NetworkConsensusConfig} for self-consistency, independent of any node's local budgets. + * + * Errors are conditions that make the config impossible (non-positive durations, sub-slot longer than the + * slot, fewer than one block per checkpoint, negative grace, multipliers below 1). Warnings are conditions + * that are merely suspicious or unachievable at the production operational budgets: a non-divisible + * slot/ethereum-slot ratio, or a `maxBlocksPerCheckpoint` exceeding what a {@link ProposerTimetable} built + * from the same slot timings and the default budgets can achieve. + */ +export function validateNetworkConsensusConfig(config: NetworkConsensusConfig): { + errors: string[]; + warnings: string[]; +} { + const errors: string[] = []; + const warnings: string[] = []; + + if (config.ethereumSlotDuration <= 0) { + errors.push(`ethereumSlotDuration must be positive (got ${config.ethereumSlotDuration})`); + } + if (config.blockDurationMs <= 0) { + errors.push(`blockDurationMs must be positive (got ${config.blockDurationMs})`); + } + if (config.blockDurationMs / 1000 > config.aztecSlotDuration) { + errors.push( + `blockDurationMs (${config.blockDurationMs}ms) exceeds aztecSlotDuration (${config.aztecSlotDuration}s)`, + ); + } + if (config.maxBlocksPerCheckpoint < 1) { + errors.push(`maxBlocksPerCheckpoint must be at least 1 (got ${config.maxBlocksPerCheckpoint})`); + } + if (config.checkpointProposalSyncGraceSeconds < 0) { + errors.push( + `checkpointProposalSyncGraceSeconds must be non-negative (got ${config.checkpointProposalSyncGraceSeconds})`, + ); + } + if (config.minPerBlockAllocationMultiplier < 1) { + errors.push(`minPerBlockAllocationMultiplier must be at least 1 (got ${config.minPerBlockAllocationMultiplier})`); + } + if (config.minPerBlockDAAllocationMultiplier < 1) { + errors.push( + `minPerBlockDAAllocationMultiplier must be at least 1 (got ${config.minPerBlockDAAllocationMultiplier})`, + ); + } + + if (config.ethereumSlotDuration > 0 && config.aztecSlotDuration % config.ethereumSlotDuration !== 0) { + warnings.push( + `aztecSlotDuration (${config.aztecSlotDuration}s) is not a multiple of ethereumSlotDuration ` + + `(${config.ethereumSlotDuration}s)`, + ); + } + + // Achievability check: a config whose maxBlocksPerCheckpoint exceeds what the production operational budgets + // can pack is a warning rather than an error, since the default MAX_BLOCKS_PER_CHECKPOINT combined with local + // geometry routinely exceeds achievable and local/sandbox startup must not break. + if (errors.length === 0) { + const achievable = new ProposerTimetable({ + l1Constants: { + l1GenesisTime: 0n, + slotDuration: config.aztecSlotDuration, + ethereumSlotDuration: config.ethereumSlotDuration, + }, + blockDuration: config.blockDurationMs / 1000, + minBlockDuration: DEFAULT_MIN_BLOCK_DURATION, + p2pPropagationTime: DEFAULT_P2P_PROPAGATION_TIME, + checkpointProposalPrepareTime: DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, + checkpointProposalInitTime: DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, + checkpointProposalSyncGrace: config.checkpointProposalSyncGraceSeconds, + }).getMaxBlocksPerCheckpoint(); + if (config.maxBlocksPerCheckpoint > achievable) { + warnings.push( + `maxBlocksPerCheckpoint (${config.maxBlocksPerCheckpoint}) exceeds the ${achievable} blocks achievable ` + + `with the default operational budgets for slot duration ${config.aztecSlotDuration}s and block ` + + `duration ${config.blockDurationMs / 1000}s`, + ); + } + } + + return { errors, warnings }; +} + +/** + * Writes a network's consensus preset into the given env, enforcing that operators do not silently override + * consensus-critical values. + * + * For each enforced env var: if it is set to a value numerically different from the preset, this throws unless + * `ALLOW_OVERRIDING_NETWORK_CONFIG` is truthy (in which case it warns and keeps the operator's value). If it is + * unset or already equal, the preset value is written into the env. No-op for networks without a preset. + */ +export function applyNetworkConsensusConfigToEnv( + networkName: NetworkNames, + env: { [key: string]: string | undefined } = process.env, + log?: (msg: string) => void, +): void { + const preset = getNetworkConsensusConfig(networkName); + if (!preset) { + return; + } + + const allowOverride = isTruthyEnv(env.ALLOW_OVERRIDING_NETWORK_CONFIG); + + for (const { env: envVar, field } of CONSENSUS_ENV_VARS) { + const presetValue = preset[field]; + const current = env[envVar]; + + if (current !== undefined && current !== '') { + const parsed = Number(current); + if (!Number.isNaN(parsed) && parsed === presetValue) { + continue; + } + const message = + `Environment variable ${envVar}=${current} conflicts with the ${networkName} network value ${presetValue}. ` + + `Consensus-critical values must match across the network. Set ALLOW_OVERRIDING_NETWORK_CONFIG=1 to override ` + + `(only do this if you know what you are doing).`; + if (allowOverride) { + log?.(message); + continue; + } + throw new Error(message); + } + + env[envVar] = String(presetValue); + } +} + +function isTruthyEnv(value: string | undefined): boolean { + return value === '1' || value?.toLowerCase() === 'true'; +} diff --git a/yarn-project/stdlib/src/timetable/README.md b/yarn-project/stdlib/src/timetable/README.md index 430c40e0f3fa..dc07ac9b51da 100644 --- a/yarn-project/stdlib/src/timetable/README.md +++ b/yarn-project/stdlib/src/timetable/README.md @@ -275,12 +275,17 @@ where `block_index` is zero-based. Sub-slot starts and deadlines do not move when earlier blocks finish early or late. If block `k` finishes early, the proposer waits until `block_build_deadline(k)` before attempting block `k + 1`. If block `k` finishes late, the next sub-slot keeps its original deadline and therefore has less remaining headroom. -The maximum number of full-duration block sub-slots is: +The number of full-duration block sub-slots a node's local operational budgets can achieve is: ```text -max_blocks_per_checkpoint = floor((last_block_build_time - first_subslot_start) / block_duration) +locally_achievable_blocks_per_checkpoint = floor((last_block_build_time - first_subslot_start) / block_duration) ``` +The effective `max_blocks_per_checkpoint` is the explicit network value (when configured) clamped down to this +locally achievable ceiling, or the locally achievable ceiling itself when no network value is given. Clamping never +raises the effective value above what the local budgets can fit, preserving the invariant that every offered +sub-slot's build deadline stays within `last_block_build_time`. + `max_blocks_per_checkpoint` is also an input to the network tx admission limits (it divides the per-checkpoint gas budgets into a per-block share); see [`../gas/README.md`](../gas/README.md) under "Gas and Data Limits". The start deadline is the latest time at which the proposer can still squeeze one minimum-duration block and make the ideal L1 publish path: diff --git a/yarn-project/stdlib/src/timetable/proposer_timetable.test.ts b/yarn-project/stdlib/src/timetable/proposer_timetable.test.ts index 0687232409ab..47b97cfd8cc6 100644 --- a/yarn-project/stdlib/src/timetable/proposer_timetable.test.ts +++ b/yarn-project/stdlib/src/timetable/proposer_timetable.test.ts @@ -354,3 +354,52 @@ describe('e2e multi-block-per-checkpoint capacity', () => { }, ); }); + +describe('ProposerTimetable explicit network maxBlocksPerCheckpoint', () => { + // Production profile derives 10 locally achievable blocks. + const productionOpts = { + l1Constants: l1Constants(72, 12), + blockDuration: 6, + minBlockDuration: 2, + p2pPropagationTime: 2, + checkpointProposalPrepareTime: 1, + }; + + it('uses the network value when below the locally achievable count', () => { + const timetable = makeProposerTimetable({ ...productionOpts, maxBlocksPerCheckpoint: 4 }); + expect(timetable.getMaxBlocksPerCheckpoint()).toBe(4); + expect(timetable.locallyAchievableBlocksPerCheckpoint).toBe(10); + expect(timetable.isClampedByLocalBudgets()).toBe(false); + }); + + it('clamps the network value down to the locally achievable count', () => { + const timetable = makeProposerTimetable({ ...productionOpts, maxBlocksPerCheckpoint: 20 }); + expect(timetable.getMaxBlocksPerCheckpoint()).toBe(10); + expect(timetable.isClampedByLocalBudgets()).toBe(true); + }); + + it('keeps every offered sub-slot build deadline within the last block build time when clamped', () => { + const timetable = makeProposerTimetable({ ...productionOpts, maxBlocksPerCheckpoint: 20 }); + const slot = SlotNumber(5); + const effective = timetable.getMaxBlocksPerCheckpoint(); + expect(timetable.getBlockBuildDeadline(slot, effective - 1)).toBeLessThanOrEqual( + timetable.getLastBlockBuildTime(slot), + ); + }); + + it('uses the locally achievable count when no network value is given', () => { + const timetable = makeProposerTimetable(productionOpts); + expect(timetable.getMaxBlocksPerCheckpoint()).toBe(10); + expect(timetable.isClampedByLocalBudgets()).toBe(false); + }); + + it('throws when local budgets cannot fit a single block even with an explicit network value', () => { + expect(() => + makeProposerTimetable({ + l1Constants: l1Constants(72, 12), + blockDuration: 72, + maxBlocksPerCheckpoint: 5, + }), + ).toThrow(/blocks per checkpoint/); + }); +}); diff --git a/yarn-project/stdlib/src/timetable/proposer_timetable.ts b/yarn-project/stdlib/src/timetable/proposer_timetable.ts index 3379f36e8fd8..8fbee6d4b734 100644 --- a/yarn-project/stdlib/src/timetable/proposer_timetable.ts +++ b/yarn-project/stdlib/src/timetable/proposer_timetable.ts @@ -32,9 +32,19 @@ export class ProposerTimetable extends ConsensusTimetable { /** Proposer initialization budget (`checkpoint_proposal_init_time`) reserved before the first sub-slot, in seconds. */ public readonly checkpointProposalInitTime: number; - /** Maximum number of full-duration block sub-slots derivable from this timing config. */ + /** + * Effective maximum number of block sub-slots per checkpoint: the explicit network value clamped down to + * what the local operational budgets can achieve, or the locally achievable count when no network value is + * given. + */ public readonly maxBlocksPerCheckpoint: number; + /** Maximum number of full-duration block sub-slots the local operational budgets can achieve. */ + public readonly locallyAchievableBlocksPerCheckpoint: number; + + /** Whether the explicit network max blocks per checkpoint was reduced to fit the local operational budgets. */ + private readonly clampedByLocalBudgets: boolean; + constructor(opts: { l1Constants: SlotTimingConstants; blockDuration: number; @@ -43,6 +53,8 @@ export class ProposerTimetable extends ConsensusTimetable { checkpointProposalPrepareTime: number; checkpointProposalInitTime: number; checkpointProposalSyncGrace?: number; + /** Explicit network max blocks per checkpoint; the effective value is clamped down to the locally achievable count. */ + maxBlocksPerCheckpoint?: number; }) { super({ l1Constants: opts.l1Constants, @@ -66,7 +78,14 @@ export class ProposerTimetable extends ConsensusTimetable { // Clamp min block duration to the block duration so a single sub-slot is always startable. this.minBlockDuration = Math.min(budgets.minBlockDuration, this.blockDuration); - this.maxBlocksPerCheckpoint = this.computeMaxBlocksPerCheckpoint(); + this.locallyAchievableBlocksPerCheckpoint = this.computeMaxBlocksPerCheckpoint(); + this.clampedByLocalBudgets = + opts.maxBlocksPerCheckpoint !== undefined && + opts.maxBlocksPerCheckpoint > this.locallyAchievableBlocksPerCheckpoint; + this.maxBlocksPerCheckpoint = + opts.maxBlocksPerCheckpoint !== undefined + ? Math.min(opts.maxBlocksPerCheckpoint, this.locallyAchievableBlocksPerCheckpoint) + : this.locallyAchievableBlocksPerCheckpoint; if (this.maxBlocksPerCheckpoint < 1) { throw new Error( `Invalid timing configuration: derived ${this.maxBlocksPerCheckpoint} blocks per checkpoint for ` + @@ -75,6 +94,11 @@ export class ProposerTimetable extends ConsensusTimetable { } } + /** Whether the explicit network max blocks per checkpoint was clamped down by the local operational budgets. */ + public isClampedByLocalBudgets(): boolean { + return this.clampedByLocalBudgets; + } + /** * Computes the maximum number of full-duration block sub-slots in a checkpoint from the already-resolved * budgets. Derived from the spec's `max_blocks_per_checkpoint = floor((last_block_build_time - From af1901b1b38af9a3f421dbd679fcd3bed119f71c Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 9 Jun 2026 19:46:57 -0300 Subject: [PATCH 02/10] refactor: quiet clamp log on default cap and dedupe override-allowed check --- yarn-project/aztec-node/src/aztec-node/server.ts | 6 ++---- .../sequencer-client/src/sequencer/sequencer.ts | 14 ++++++++++++-- .../stdlib/src/config/network-consensus-config.ts | 6 ++++-- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 4e04dc772254..c5e2787e2174 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -86,6 +86,7 @@ import { MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER, type NetworkConsensusConfig, + allowsNetworkConfigOverride, getNetworkConsensusConfig, validateNetworkConsensusConfig, } from '@aztec/stdlib/config'; @@ -1095,10 +1096,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb `Network ${networkName} preset expects aztecSlotDuration ${preset.aztecSlotDuration}s but the rollup ` + `contract reports ${slotDuration}s. This usually means a stale preset or a node pointed at the wrong ` + `rollup. Set ALLOW_OVERRIDING_NETWORK_CONFIG=1 to override.`; - if ( - process.env.ALLOW_OVERRIDING_NETWORK_CONFIG === '1' || - process.env.ALLOW_OVERRIDING_NETWORK_CONFIG === 'true' - ) { + if (allowsNetworkConfigOverride()) { log.warn(message, { networkName, presetSlotDuration: preset.aztecSlotDuration, slotDuration }); } else { throw new Error(message); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index e3eb67b75c09..1e5131ad2238 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -19,7 +19,11 @@ import type { ValidateCheckpointResult, } from '@aztec/stdlib/block'; import type { Checkpoint, ProposedCheckpointData } from '@aztec/stdlib/checkpoint'; -import { type ChainConfig, MIN_PER_BLOCK_ALLOCATION_MULTIPLIER } from '@aztec/stdlib/config'; +import { + type ChainConfig, + DEFAULT_MAX_BLOCKS_PER_CHECKPOINT, + MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, +} from '@aztec/stdlib/config'; import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; import { MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, @@ -199,7 +203,13 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter Date: Tue, 9 Jun 2026 20:13:14 -0300 Subject: [PATCH 03/10] fix: address review findings on consensus config validation and enforcement - Cross-check all preset consensus fields (not just slot duration) at node startup, covering nodes that bypass the cli env enrichment such as the standalone aztec-node binary and programmatic embedders. - Report instead of crash when the achievability probe derives fewer than one block, and reject non-finite config values instead of silently passing. - Validate preset multipliers against the network minimums rather than >= 1. - Canonicalize numerically-equal operator env values so parseInt-based config parsers cannot diverge from the Number-based equality check (e.g. 6e3). - Record NETWORK from the --network flag so the startup cross-check applies to cli users, and tolerate unknown NETWORK values in embedded contexts. - Parse SEQ_PER_BLOCK_ALLOCATION_MULTIPLIER as a float; parseInt truncated 1.5 to 1, which the new minimum-multiplier guard would have turned into a fatal startup error. - Skip the achievability warning for the intentionally-high default cap. --- .../aztec-node/src/aztec-node/server.ts | 45 ++++-- yarn-project/sequencer-client/src/config.ts | 3 +- .../config/network-consensus-config.test.ts | 67 ++++++++- .../src/config/network-consensus-config.ts | 136 +++++++++++++----- 4 files changed, 194 insertions(+), 57 deletions(-) diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index c5e2787e2174..2e7905153bf0 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -82,12 +82,14 @@ import { type PublishedCheckpoint, } from '@aztec/stdlib/checkpoint'; import { + DEFAULT_BLOCK_DURATION_MS, DEFAULT_MAX_BLOCKS_PER_CHECKPOINT, MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER, type NetworkConsensusConfig, allowsNetworkConfigOverride, getNetworkConsensusConfig, + getPresetMismatches, validateNetworkConsensusConfig, } from '@aztec/stdlib/config'; import type { @@ -1068,20 +1070,25 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb /** * Validates the consensus-critical configuration against the rollup contract and (when a known network is * selected) its in-code preset. Throws on hard inconsistencies, warns on suspicious or unachievable values. - * Runs for every node role since it sits in the shared startup path. + * Runs for every node role since it sits in the shared startup path, which also makes it the enforcement + * layer for nodes that bypass the cli env enrichment (standalone node binary, programmatic embedders). */ private static validateConsensusConfig(config: AztecNodeConfig, slotDuration: number, log: Logger): void { const consensusConfig: NetworkConsensusConfig = { aztecSlotDuration: slotDuration, ethereumSlotDuration: config.ethereumSlotDuration, - blockDurationMs: config.blockDurationMs, + blockDurationMs: config.blockDurationMs ?? DEFAULT_BLOCK_DURATION_MS, maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint ?? DEFAULT_MAX_BLOCKS_PER_CHECKPOINT, checkpointProposalSyncGraceSeconds: config.checkpointProposalSyncGraceSeconds!, minPerBlockAllocationMultiplier: MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, minPerBlockDAAllocationMultiplier: MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER, }; - const { errors, warnings } = validateNetworkConsensusConfig(consensusConfig); + // The default cap is intentionally above what most geometries can achieve, so skip the achievability + // warning for it; an explicitly configured value still warns. + const { errors, warnings } = validateNetworkConsensusConfig(consensusConfig, { + defaultCapMaxBlocks: DEFAULT_MAX_BLOCKS_PER_CHECKPOINT, + }); for (const warning of warnings) { log.warn(`Consensus config warning: ${warning}`, { warning }); } @@ -1089,17 +1096,27 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb throw new Error(`Invalid consensus configuration: ${errors.join('; ')}`); } - const networkName = getActiveNetworkName(); - const preset = getNetworkConsensusConfig(networkName); - if (preset && preset.aztecSlotDuration !== slotDuration) { - const message = - `Network ${networkName} preset expects aztecSlotDuration ${preset.aztecSlotDuration}s but the rollup ` + - `contract reports ${slotDuration}s. This usually means a stale preset or a node pointed at the wrong ` + - `rollup. Set ALLOW_OVERRIDING_NETWORK_CONFIG=1 to override.`; - if (allowsNetworkConfigOverride()) { - log.warn(message, { networkName, presetSlotDuration: preset.aztecSlotDuration, slotDuration }); - } else { - throw new Error(message); + // An unrecognized NETWORK value is not an error here: this path ignored NETWORK before, and programmatic + // embedders may have unrelated values in scope. + let networkName: ReturnType | undefined; + try { + networkName = getActiveNetworkName(); + } catch { + networkName = undefined; + } + const preset = networkName !== undefined ? getNetworkConsensusConfig(networkName) : undefined; + if (preset) { + const mismatches = getPresetMismatches(consensusConfig, preset); + if (mismatches.length > 0) { + const message = + `Consensus config for network ${networkName} does not match its preset: ${mismatches.join('; ')}. ` + + `This usually means an env override, a stale preset, or a node pointed at the wrong rollup. ` + + `Set ALLOW_OVERRIDING_NETWORK_CONFIG=1 to override.`; + if (allowsNetworkConfigOverride()) { + log.warn(message, { networkName, mismatches }); + } else { + throw new Error(message); + } } } } diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index 21939f61deef..0d1d24e52b92 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -3,6 +3,7 @@ import { type L1ReaderConfig, l1ReaderConfigMappings } from '@aztec/ethereum/l1- import { type ConfigMappingsType, booleanConfigHelper, + floatConfigHelper, getConfigFromMappings, numberConfigHelper, optionalNumberConfigHelper, @@ -121,7 +122,7 @@ export const sequencerConfigMappings: ConfigMappingsType = { description: 'Per-block gas budget multiplier for both L2 and DA gas. Budget per block is (checkpointLimit / maxBlocks) * multiplier.' + ' Values greater than one allow early blocks to use more than their even share, relying on checkpoint-level capping for later blocks.', - ...numberConfigHelper(DefaultSequencerConfig.perBlockAllocationMultiplier), + ...floatConfigHelper(DefaultSequencerConfig.perBlockAllocationMultiplier), }, perBlockDAAllocationMultiplier: { env: 'SEQ_PER_BLOCK_DA_ALLOCATION_MULTIPLIER', diff --git a/yarn-project/stdlib/src/config/network-consensus-config.test.ts b/yarn-project/stdlib/src/config/network-consensus-config.test.ts index f213f26d9799..42cd40dc7cf3 100644 --- a/yarn-project/stdlib/src/config/network-consensus-config.test.ts +++ b/yarn-project/stdlib/src/config/network-consensus-config.test.ts @@ -4,6 +4,7 @@ import { type NetworkConsensusConfig, applyNetworkConsensusConfigToEnv, getNetworkConsensusConfig, + getPresetMismatches, validateNetworkConsensusConfig, } from './network-consensus-config.js'; @@ -59,15 +60,37 @@ describe('validateNetworkConsensusConfig', () => { ); }); - it('reports an error for multipliers below 1', () => { - expect(validateNetworkConsensusConfig({ ...base, minPerBlockAllocationMultiplier: 0.5 }).errors).toContainEqual( + it('reports an error for multipliers below the network minimums', () => { + expect(validateNetworkConsensusConfig({ ...base, minPerBlockAllocationMultiplier: 1.1 }).errors).toContainEqual( expect.stringContaining('minPerBlockAllocationMultiplier'), ); - expect(validateNetworkConsensusConfig({ ...base, minPerBlockDAAllocationMultiplier: 0.5 }).errors).toContainEqual( + expect(validateNetworkConsensusConfig({ ...base, minPerBlockDAAllocationMultiplier: 1.4 }).errors).toContainEqual( expect.stringContaining('minPerBlockDAAllocationMultiplier'), ); }); + it('reports an error for non-finite values instead of silently passing', () => { + expect(validateNetworkConsensusConfig({ ...base, blockDurationMs: NaN }).errors).toContainEqual( + expect.stringContaining('blockDurationMs'), + ); + expect( + validateNetworkConsensusConfig({ ...base, blockDurationMs: undefined as unknown as number }).errors, + ).toContainEqual(expect.stringContaining('blockDurationMs')); + }); + + it('warns instead of throwing when not even one block is achievable at default budgets', () => { + // 60s blocks pass the basic sub-slot <= slot check, but the timetable derives < 1 achievable block. + const { errors, warnings } = validateNetworkConsensusConfig({ ...base, blockDurationMs: 60_000 }); + expect(errors).toEqual([]); + expect(warnings).toContainEqual(expect.stringContaining('exceeds the 0 blocks achievable')); + }); + + it('skips the achievability warning when maxBlocksPerCheckpoint equals the default cap', () => { + const config = { ...base, maxBlocksPerCheckpoint: 24 }; + expect(validateNetworkConsensusConfig(config).warnings).not.toEqual([]); + expect(validateNetworkConsensusConfig(config, { defaultCapMaxBlocks: 24 }).warnings).toEqual([]); + }); + it('warns when the slot duration is not a multiple of the ethereum slot duration', () => { expect(validateNetworkConsensusConfig({ ...base, ethereumSlotDuration: 5 }).warnings).toContainEqual( expect.stringContaining('not a multiple'), @@ -115,9 +138,47 @@ describe('applyNetworkConsensusConfigToEnv', () => { expect(logs.some(msg => msg.includes('SEQ_BLOCK_DURATION_MS'))).toBe(true); }); + it('canonicalizes operator values that match numerically but parse differently downstream', () => { + // parseInt-based config parsers read '6e3' as 6, so the matching value must be rewritten canonically. + const env: Record = { SEQ_BLOCK_DURATION_MS: '6e3' }; + applyNetworkConsensusConfigToEnv('mainnet', env); + expect(env.SEQ_BLOCK_DURATION_MS).toBe('6000'); + }); + + it('records the network name into NETWORK without overriding an existing value', () => { + const env: Record = {}; + applyNetworkConsensusConfigToEnv('mainnet', env); + expect(env.NETWORK).toBe('mainnet'); + + const preset: Record = { NETWORK: 'alpha-testnet' }; + applyNetworkConsensusConfigToEnv('testnet', preset); + expect(preset.NETWORK).toBe('alpha-testnet'); + }); + it('is a no-op for networks without a preset', () => { const env: Record = {}; applyNetworkConsensusConfigToEnv('local', env); expect(env).toEqual({}); }); }); + +describe('getPresetMismatches', () => { + const mainnet = getNetworkConsensusConfig('mainnet')!; + + it('returns no mismatches for a config equal to the preset', () => { + expect(getPresetMismatches(mainnet, mainnet)).toEqual([]); + }); + + it('describes every diverging consensus field', () => { + const config = { ...mainnet, blockDurationMs: 3000, maxBlocksPerCheckpoint: 24 }; + const mismatches = getPresetMismatches(config, mainnet); + expect(mismatches).toHaveLength(2); + expect(mismatches).toContainEqual(expect.stringContaining('blockDurationMs')); + expect(mismatches).toContainEqual(expect.stringContaining('maxBlocksPerCheckpoint')); + }); + + it('ignores the multiplier fields, which are constants rather than env-derived values', () => { + const config = { ...mainnet, minPerBlockAllocationMultiplier: 9 }; + expect(getPresetMismatches(config, mainnet)).toEqual([]); + }); +}); diff --git a/yarn-project/stdlib/src/config/network-consensus-config.ts b/yarn-project/stdlib/src/config/network-consensus-config.ts index 51a34e298404..05f70ce2f001 100644 --- a/yarn-project/stdlib/src/config/network-consensus-config.ts +++ b/yarn-project/stdlib/src/config/network-consensus-config.ts @@ -14,7 +14,11 @@ import { ProposerTimetable } from '../timetable/proposer_timetable.js'; */ export const MIN_PER_BLOCK_ALLOCATION_MULTIPLIER = 1.2; -/** Network-minimum per-block budget multiplier for DA gas / blob fields. See {@link MIN_PER_BLOCK_ALLOCATION_MULTIPLIER}. */ +/** + * Network-minimum per-block budget multiplier for DA gas / blob fields. See + * {@link MIN_PER_BLOCK_ALLOCATION_MULTIPLIER}. The DA-specific operator knob and its runtime enforcement land + * with the network tx admission limits (#23947); until then this only constrains the in-code presets. + */ export const MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER = 1.5; /** Consensus-critical configuration that must be identical across all nodes of a network. */ @@ -38,6 +42,10 @@ export type NetworkConsensusConfig = { /** * In-code consensus presets keyed by network name. Networks without a preset (e.g. `local`, `devnet`) return * `undefined` from {@link getNetworkConsensusConfig} and are not subject to override enforcement. + * + * Related sources of these values exist in the remote `network_config.json` (AztecProtocol/networks) and the + * spartan environment defaults; these presets are authoritative for the consensus-critical vars on named + * networks, since they are applied to the env before any other enrichment runs. */ const NETWORK_CONSENSUS_PRESETS: Partial> = { mainnet: { @@ -76,19 +84,34 @@ const CONSENSUS_ENV_VARS = [ /** * Validates a {@link NetworkConsensusConfig} for self-consistency, independent of any node's local budgets. * - * Errors are conditions that make the config impossible (non-positive durations, sub-slot longer than the - * slot, fewer than one block per checkpoint, negative grace, multipliers below 1). Warnings are conditions - * that are merely suspicious or unachievable at the production operational budgets: a non-divisible - * slot/ethereum-slot ratio, or a `maxBlocksPerCheckpoint` exceeding what a {@link ProposerTimetable} built - * from the same slot timings and the default budgets can achieve. + * Errors are conditions that make the config impossible (non-finite or non-positive durations, sub-slot longer + * than the slot, fewer than one block per checkpoint, negative grace, multipliers below the network minimums). + * Warnings are conditions that are merely suspicious or unachievable at the production operational budgets: a + * non-divisible slot/ethereum-slot ratio, or a `maxBlocksPerCheckpoint` exceeding what a + * {@link ProposerTimetable} built from the same slot timings and the default budgets can achieve. The + * achievability warning is heuristic (a node's configured budgets may differ from the defaults) and is skipped + * when `maxBlocksPerCheckpoint` equals `opts.defaultCapMaxBlocks`, since the default cap is intentionally + * above what most geometries can achieve. */ -export function validateNetworkConsensusConfig(config: NetworkConsensusConfig): { +export function validateNetworkConsensusConfig( + config: NetworkConsensusConfig, + opts: { defaultCapMaxBlocks?: number } = {}, +): { errors: string[]; warnings: string[]; } { const errors: string[] = []; const warnings: string[] = []; + for (const [field, value] of Object.entries(config)) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + errors.push(`${field} must be a finite number (got ${value})`); + } + } + if (errors.length > 0) { + return { errors, warnings }; + } + if (config.ethereumSlotDuration <= 0) { errors.push(`ethereumSlotDuration must be positive (got ${config.ethereumSlotDuration})`); } @@ -108,12 +131,16 @@ export function validateNetworkConsensusConfig(config: NetworkConsensusConfig): `checkpointProposalSyncGraceSeconds must be non-negative (got ${config.checkpointProposalSyncGraceSeconds})`, ); } - if (config.minPerBlockAllocationMultiplier < 1) { - errors.push(`minPerBlockAllocationMultiplier must be at least 1 (got ${config.minPerBlockAllocationMultiplier})`); + if (config.minPerBlockAllocationMultiplier < MIN_PER_BLOCK_ALLOCATION_MULTIPLIER) { + errors.push( + `minPerBlockAllocationMultiplier must be at least ${MIN_PER_BLOCK_ALLOCATION_MULTIPLIER} ` + + `(got ${config.minPerBlockAllocationMultiplier})`, + ); } - if (config.minPerBlockDAAllocationMultiplier < 1) { + if (config.minPerBlockDAAllocationMultiplier < MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER) { errors.push( - `minPerBlockDAAllocationMultiplier must be at least 1 (got ${config.minPerBlockDAAllocationMultiplier})`, + `minPerBlockDAAllocationMultiplier must be at least ${MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER} ` + + `(got ${config.minPerBlockDAAllocationMultiplier})`, ); } @@ -125,22 +152,28 @@ export function validateNetworkConsensusConfig(config: NetworkConsensusConfig): } // Achievability check: a config whose maxBlocksPerCheckpoint exceeds what the production operational budgets - // can pack is a warning rather than an error, since the default MAX_BLOCKS_PER_CHECKPOINT combined with local - // geometry routinely exceeds achievable and local/sandbox startup must not break. - if (errors.length === 0) { - const achievable = new ProposerTimetable({ - l1Constants: { - l1GenesisTime: 0n, - slotDuration: config.aztecSlotDuration, - ethereumSlotDuration: config.ethereumSlotDuration, - }, - blockDuration: config.blockDurationMs / 1000, - minBlockDuration: DEFAULT_MIN_BLOCK_DURATION, - p2pPropagationTime: DEFAULT_P2P_PROPAGATION_TIME, - checkpointProposalPrepareTime: DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, - checkpointProposalInitTime: DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, - checkpointProposalSyncGrace: config.checkpointProposalSyncGraceSeconds, - }).getMaxBlocksPerCheckpoint(); + // can pack is a warning rather than an error, since a node's configured budgets may be tighter than the + // defaults and local/sandbox startup must not break. + if (errors.length === 0 && config.maxBlocksPerCheckpoint !== opts.defaultCapMaxBlocks) { + let achievable: number | undefined; + try { + achievable = new ProposerTimetable({ + l1Constants: { + l1GenesisTime: 0n, + slotDuration: config.aztecSlotDuration, + ethereumSlotDuration: config.ethereumSlotDuration, + }, + blockDuration: config.blockDurationMs / 1000, + minBlockDuration: DEFAULT_MIN_BLOCK_DURATION, + p2pPropagationTime: DEFAULT_P2P_PROPAGATION_TIME, + checkpointProposalPrepareTime: DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, + checkpointProposalInitTime: DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, + checkpointProposalSyncGrace: config.checkpointProposalSyncGraceSeconds, + }).getMaxBlocksPerCheckpoint(); + } catch { + // The timetable constructor throws when not even one block fits; report instead of crashing. + achievable = 0; + } if (config.maxBlocksPerCheckpoint > achievable) { warnings.push( `maxBlocksPerCheckpoint (${config.maxBlocksPerCheckpoint}) exceeds the ${achievable} blocks achievable ` + @@ -153,13 +186,37 @@ export function validateNetworkConsensusConfig(config: NetworkConsensusConfig): return { errors, warnings }; } +/** Consensus fields cross-checked between a node's effective config and its network preset. */ +const PRESET_CROSS_CHECK_FIELDS = [ + 'aztecSlotDuration', + 'ethereumSlotDuration', + 'blockDurationMs', + 'maxBlocksPerCheckpoint', + 'checkpointProposalSyncGraceSeconds', +] as const satisfies ReadonlyArray; + +/** + * Compares a node's effective consensus config against a network preset and returns a description of every + * mismatching consensus field. Used at node startup to catch nodes that bypassed env-level enforcement (e.g. + * launched via the standalone node binary or programmatically) with values diverging from their network. + */ +export function getPresetMismatches(config: NetworkConsensusConfig, preset: NetworkConsensusConfig): string[] { + return PRESET_CROSS_CHECK_FIELDS.filter(field => config[field] !== preset[field]).map( + field => `${field} is ${config[field]} but the network preset expects ${preset[field]}`, + ); +} + /** * Writes a network's consensus preset into the given env, enforcing that operators do not silently override * consensus-critical values. * * For each enforced env var: if it is set to a value numerically different from the preset, this throws unless * `ALLOW_OVERRIDING_NETWORK_CONFIG` is truthy (in which case it warns and keeps the operator's value). If it is - * unset or already equal, the preset value is written into the env. No-op for networks without a preset. + * unset or numerically equal, the canonical preset value is written into the env — canonicalization matters + * because the config layer parses some of these vars with `parseInt`, which disagrees with `Number` on forms + * like `6e3` or `0x1770`; without it an operator value that passes the equality check here could still parse to + * a different number downstream. Also records the network name into `NETWORK` (when unset) so later startup + * checks know which preset applies. No-op for networks without a preset. */ export function applyNetworkConsensusConfigToEnv( networkName: NetworkNames, @@ -179,22 +236,23 @@ export function applyNetworkConsensusConfigToEnv( if (current !== undefined && current !== '') { const parsed = Number(current); - if (!Number.isNaN(parsed) && parsed === presetValue) { - continue; + if (Number.isNaN(parsed) || parsed !== presetValue) { + const message = + `Environment variable ${envVar}=${current} conflicts with the ${networkName} network value ${presetValue}. ` + + `Consensus-critical values must match across the network. Set ALLOW_OVERRIDING_NETWORK_CONFIG=1 to override ` + + `(only do this if you know what you are doing).`; + if (allowOverride) { + log?.(message); + continue; + } + throw new Error(message); } - const message = - `Environment variable ${envVar}=${current} conflicts with the ${networkName} network value ${presetValue}. ` + - `Consensus-critical values must match across the network. Set ALLOW_OVERRIDING_NETWORK_CONFIG=1 to override ` + - `(only do this if you know what you are doing).`; - if (allowOverride) { - log?.(message); - continue; - } - throw new Error(message); } env[envVar] = String(presetValue); } + + env.NETWORK ??= networkName; } /** Whether the env opts into overriding network-wide consensus values (`ALLOW_OVERRIDING_NETWORK_CONFIG`). */ From ab7160a82ca8fdbdb43d1952653da124ae2724de Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Wed, 10 Jun 2026 15:25:15 -0300 Subject: [PATCH 04/10] refactor: source network consensus config from generated network defaults Replace the in-code per-network consensus presets with values sourced from the generated network config (cli/src/config/generated/networks.ts, generated from spartan/environments/network-defaults.yml). The stdlib module now exposes only the required-env-var list, types, a config extractor, a self-consistency validate function, and an override-check function. Override enforcement moves to cli enrichEnvironmentWithChainName, gated by a compile-time check that every generated network config carries every consensus-critical var. The node startup check is reduced to verifying the rollup contract reports the same slot and epoch durations the node is configured with. ProposerTimetable now warns (and logs) only when local budgets compute more blocks per checkpoint than the network value allows. --- spartan/environments/network-defaults.yml | 9 + .../aztec-node/src/aztec-node/server.ts | 88 ++--- .../cli/src/config/chain_l2_config.test.ts | 72 ++++ .../cli/src/config/chain_l2_config.ts | 13 +- yarn-project/cli/src/config/network_config.ts | 8 - .../p2p/src/services/libp2p/libp2p_service.ts | 6 +- .../src/sequencer/sequencer.ts | 6 +- .../config/network-consensus-config.test.ts | 209 +++++------ .../src/config/network-consensus-config.ts | 343 +++++++++--------- yarn-project/stdlib/src/timetable/README.md | 13 +- .../src/timetable/proposer_timetable.test.ts | 31 +- .../src/timetable/proposer_timetable.ts | 41 +-- 12 files changed, 439 insertions(+), 400 deletions(-) create mode 100644 yarn-project/cli/src/config/chain_l2_config.test.ts diff --git a/spartan/environments/network-defaults.yml b/spartan/environments/network-defaults.yml index 6a445101da81..59ceb9c6587b 100644 --- a/spartan/environments/network-defaults.yml +++ b/spartan/environments/network-defaults.yml @@ -176,6 +176,10 @@ _prodlike: &prodlike SEQ_BUILD_CHECKPOINT_IF_EMPTY: true # 6 second block times SEQ_BLOCK_DURATION_MS: 6000 + # Maximum number of block sub-slots per checkpoint; must equal what the default proposer budgets derive. + MAX_BLOCKS_PER_CHECKPOINT: 10 + # Grace period for received checkpoint proposals to materialize locally (2 block durations). + CHECKPOINT_PROPOSAL_SYNC_GRACE_SECONDS: 12 #--------------------------------------------------------------------------- # Database Map Sizes (in KB) @@ -212,6 +216,11 @@ networks: AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET: 1 AZTEC_LAG_IN_EPOCHS_FOR_RANDAO: 1 AZTEC_SLASHING_EXECUTION_DELAY_IN_ROUNDS: 1 + # Quorums made explicit to match the Solidity vm.envOr default (round_size / 2 + 1), unchanged behavior. + # Slashing: AZTEC_SLASHING_ROUND_SIZE_IN_EPOCHS (4) * AZTEC_EPOCH_DURATION (8) = 32 slots; 32 / 2 + 1 = 17. + AZTEC_SLASHING_QUORUM: 17 + # Governance: AZTEC_GOVERNANCE_PROPOSER_ROUND_SIZE (300) slots; 300 / 2 + 1 = 151. + AZTEC_GOVERNANCE_PROPOSER_QUORUM: 151 # Network identity L1_CHAIN_ID: 11155111 # Sepolia # Genesis state diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 2e7905153bf0..c6fa6ee42c20 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -18,7 +18,6 @@ import { SlotNumber, } from '@aztec/foundation/branded-types'; import { chunkBy, compactArray, pick, unique } from '@aztec/foundation/collection'; -import { getActiveNetworkName } from '@aztec/foundation/config'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { BadRequestError } from '@aztec/foundation/json-rpc'; @@ -81,17 +80,6 @@ import { L1PublishedData, type PublishedCheckpoint, } from '@aztec/stdlib/checkpoint'; -import { - DEFAULT_BLOCK_DURATION_MS, - DEFAULT_MAX_BLOCKS_PER_CHECKPOINT, - MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, - MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER, - type NetworkConsensusConfig, - allowsNetworkConfigOverride, - getNetworkConsensusConfig, - getPresetMismatches, - validateNetworkConsensusConfig, -} from '@aztec/stdlib/config'; import type { ContractClassPublic, ContractDataSource, @@ -602,9 +590,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb Object.assign(config, l1ContractsAddresses); const rollupContract = new RollupContract(publicClient, config.rollupAddress.toString()); - const [l1GenesisTime, slotDuration, rollupVersionFromRollup, rollupManaLimit] = await Promise.all([ + const [l1GenesisTime, slotDuration, epochDuration, rollupVersionFromRollup, rollupManaLimit] = await Promise.all([ rollupContract.getL1GenesisTime(), rollupContract.getSlotDuration(), + rollupContract.getEpochDuration(), rollupContract.getVersion(), rollupContract.getManaLimit().then(Number), ] as const); @@ -635,7 +624,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb : 2 * DEFAULT_MIN_BLOCK_DURATION; config.skipOrphanProposedBlockPruning ||= !!config.useAutomineSequencer; - AztecNodeService.validateConsensusConfig(config, Number(slotDuration), log); + AztecNodeService.checkConfigMatchesRollup(config, { + slotDuration: Number(slotDuration), + epochDuration: Number(epochDuration), + }); // Create world-state first so we can retrieve the initial header before constructing the archiver. const nativeWs = await createWorldState(config, options.genesis); @@ -1068,56 +1060,28 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb } /** - * Validates the consensus-critical configuration against the rollup contract and (when a known network is - * selected) its in-code preset. Throws on hard inconsistencies, warns on suspicious or unachievable values. - * Runs for every node role since it sits in the shared startup path, which also makes it the enforcement - * layer for nodes that bypass the cli env enrichment (standalone node binary, programmatic embedders). + * Verifies the node's configured L1 timing matches the rollup contract it is pointed at, for the fields the + * node's own config carries. Each comparison is guarded against an undefined config value, so a config that + * does not carry a field is not checked. Throws a single error listing every mismatch. Runs in the shared + * startup path for every node role. */ - private static validateConsensusConfig(config: AztecNodeConfig, slotDuration: number, log: Logger): void { - const consensusConfig: NetworkConsensusConfig = { - aztecSlotDuration: slotDuration, - ethereumSlotDuration: config.ethereumSlotDuration, - blockDurationMs: config.blockDurationMs ?? DEFAULT_BLOCK_DURATION_MS, - maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint ?? DEFAULT_MAX_BLOCKS_PER_CHECKPOINT, - checkpointProposalSyncGraceSeconds: config.checkpointProposalSyncGraceSeconds!, - minPerBlockAllocationMultiplier: MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, - minPerBlockDAAllocationMultiplier: MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER, - }; - - // The default cap is intentionally above what most geometries can achieve, so skip the achievability - // warning for it; an explicitly configured value still warns. - const { errors, warnings } = validateNetworkConsensusConfig(consensusConfig, { - defaultCapMaxBlocks: DEFAULT_MAX_BLOCKS_PER_CHECKPOINT, - }); - for (const warning of warnings) { - log.warn(`Consensus config warning: ${warning}`, { warning }); - } - if (errors.length > 0) { - throw new Error(`Invalid consensus configuration: ${errors.join('; ')}`); + private static checkConfigMatchesRollup( + config: AztecNodeConfig, + rollup: { slotDuration: number; epochDuration: number }, + ): void { + const mismatches: string[] = []; + if (config.aztecSlotDuration !== undefined && config.aztecSlotDuration !== rollup.slotDuration) { + mismatches.push(`aztecSlotDuration is ${config.aztecSlotDuration} but the rollup reports ${rollup.slotDuration}`); + } + if (config.aztecEpochDuration !== undefined && config.aztecEpochDuration !== rollup.epochDuration) { + mismatches.push( + `aztecEpochDuration is ${config.aztecEpochDuration} but the rollup reports ${rollup.epochDuration}`, + ); } - - // An unrecognized NETWORK value is not an error here: this path ignored NETWORK before, and programmatic - // embedders may have unrelated values in scope. - let networkName: ReturnType | undefined; - try { - networkName = getActiveNetworkName(); - } catch { - networkName = undefined; - } - const preset = networkName !== undefined ? getNetworkConsensusConfig(networkName) : undefined; - if (preset) { - const mismatches = getPresetMismatches(consensusConfig, preset); - if (mismatches.length > 0) { - const message = - `Consensus config for network ${networkName} does not match its preset: ${mismatches.join('; ')}. ` + - `This usually means an env override, a stale preset, or a node pointed at the wrong rollup. ` + - `Set ALLOW_OVERRIDING_NETWORK_CONFIG=1 to override.`; - if (allowsNetworkConfigOverride()) { - log.warn(message, { networkName, mismatches }); - } else { - throw new Error(message); - } - } + if (mismatches.length > 0) { + throw new Error( + `The node's configured L1 timing does not match the rollup contract it is pointed at: ${mismatches.join('; ')}`, + ); } } diff --git a/yarn-project/cli/src/config/chain_l2_config.test.ts b/yarn-project/cli/src/config/chain_l2_config.test.ts new file mode 100644 index 000000000000..212a8992f193 --- /dev/null +++ b/yarn-project/cli/src/config/chain_l2_config.test.ts @@ -0,0 +1,72 @@ +import { getConsensusConfigFromNetworkEnv, validateNetworkConsensusConfig } from '@aztec/stdlib/config'; +import { + DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, + DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, + DEFAULT_MIN_BLOCK_DURATION, + DEFAULT_P2P_PROPAGATION_TIME, + ProposerTimetable, +} from '@aztec/stdlib/timetable'; + +import { enrichEnvironmentWithChainName } from './chain_l2_config.js'; +import { devnetConfig, mainnetConfig, testnetConfig } from './generated/networks.js'; + +const generatedConfigs = { + devnet: devnetConfig, + testnet: testnetConfig, + mainnet: mainnetConfig, +} as const; + +describe('generated network configs', () => { + for (const [name, config] of Object.entries(generatedConfigs)) { + describe(name, () => { + it('passes consensus config validation', () => { + expect(validateNetworkConsensusConfig(getConsensusConfigFromNetworkEnv(config))).toEqual([]); + }); + + it('declares MAX_BLOCKS_PER_CHECKPOINT equal to what the default proposer budgets derive', () => { + const consensus = getConsensusConfigFromNetworkEnv(config); + const computed = new ProposerTimetable({ + l1Constants: { + l1GenesisTime: 0n, + slotDuration: consensus.aztecSlotDuration, + ethereumSlotDuration: consensus.ethereumSlotDuration, + }, + blockDuration: consensus.blockDurationMs / 1000, + minBlockDuration: DEFAULT_MIN_BLOCK_DURATION, + p2pPropagationTime: DEFAULT_P2P_PROPAGATION_TIME, + checkpointProposalPrepareTime: DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, + checkpointProposalInitTime: DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, + checkpointProposalSyncGrace: consensus.checkpointProposalSyncGraceSeconds, + }).getMaxBlocksPerCheckpoint(); + expect(computed).toBe(config.MAX_BLOCKS_PER_CHECKPOINT); + }); + }); + } +}); + +describe('enrichEnvironmentWithChainName', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.SEQ_BLOCK_DURATION_MS; + delete process.env.ALLOW_OVERRIDING_NETWORK_CONFIG; + delete process.env.DATA_DIRECTORY; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('throws when a consensus-critical env var conflicts with the network config', () => { + process.env.SEQ_BLOCK_DURATION_MS = '3000'; + expect(() => enrichEnvironmentWithChainName('testnet')).toThrow(/SEQ_BLOCK_DURATION_MS/); + }); + + it('keeps the operator value and continues when ALLOW_OVERRIDING_NETWORK_CONFIG is set', () => { + process.env.SEQ_BLOCK_DURATION_MS = '3000'; + process.env.ALLOW_OVERRIDING_NETWORK_CONFIG = '1'; + expect(() => enrichEnvironmentWithChainName('testnet')).not.toThrow(); + expect(process.env.SEQ_BLOCK_DURATION_MS).toBe('3000'); + }); +}); diff --git a/yarn-project/cli/src/config/chain_l2_config.ts b/yarn-project/cli/src/config/chain_l2_config.ts index 75f69e615a00..29ac63bad996 100644 --- a/yarn-project/cli/src/config/chain_l2_config.ts +++ b/yarn-project/cli/src/config/chain_l2_config.ts @@ -1,4 +1,6 @@ import type { NetworkNames } from '@aztec/foundation/config'; +import { createLogger } from '@aztec/foundation/log'; +import { type ConsensusEnvVar, checkConsensusEnvOverrides } from '@aztec/stdlib/config'; import path from 'path'; @@ -12,6 +14,12 @@ const NetworkConfigs: Partial> = { mainnet: mainnetConfig, }; +/** Every generated network config must define every consensus-critical env var. */ +type ConsensusComplete = Record; +({ devnetConfig, testnetConfig, mainnetConfig }) satisfies Record; + +const log = createLogger('cli:chain_l2_config'); + function enrichEnvironmentWithNetworkConfig(config: NetworkConfigEnv): void { for (const [key, value] of Object.entries(config)) { if (process.env[key] === undefined && value !== undefined) { @@ -31,7 +39,9 @@ function getDefaultDataDir(networkName: NetworkNames): string { * and DefaultSlasherConfig (which match the 'defaults' section of defaults.yml). * * For deployed networks: applies network configuration from generated defaults.yml, - * merging base defaults with network-specific overrides. + * merging base defaults with network-specific overrides. Before merging, enforces that operators have not + * overridden any consensus-critical env var with a value diverging from the network config (throwing unless + * ALLOW_OVERRIDING_NETWORK_CONFIG is set), so all nodes of a network agree on consensus-critical values. * * @param networkName - The network name */ @@ -49,6 +59,7 @@ export function enrichEnvironmentWithChainName(networkName: NetworkNames) { const configKey = /^v\d+-devnet-\d+$/.test(networkName) ? 'devnet' : networkName; const generatedConfig = NetworkConfigs[configKey]; if (generatedConfig) { + checkConsensusEnvOverrides(generatedConfig, process.env, msg => log.warn(msg)); enrichEnvironmentWithNetworkConfig(generatedConfig); } diff --git a/yarn-project/cli/src/config/network_config.ts b/yarn-project/cli/src/config/network_config.ts index 2f3f0bf4b40e..df3cdd3d52cc 100644 --- a/yarn-project/cli/src/config/network_config.ts +++ b/yarn-project/cli/src/config/network_config.ts @@ -1,6 +1,4 @@ import { type NetworkConfig, NetworkConfigMapSchema, type NetworkNames } from '@aztec/foundation/config'; -import { createLogger } from '@aztec/foundation/log'; -import { applyNetworkConsensusConfigToEnv } from '@aztec/stdlib/config'; import { readFile } from 'fs/promises'; import { join } from 'path'; @@ -118,12 +116,6 @@ async function fetchNetworkConfigFromUrl( * Does not throw if the network simply doesn't exist in the config - just returns without enriching */ export async function enrichEnvironmentWithNetworkConfig(networkName: NetworkNames) { - // Apply in-code consensus presets first so they are authoritative for consensus-critical vars. This throws - // if an operator env override conflicts with the preset (unless ALLOW_OVERRIDING_NETWORK_CONFIG is set), and - // makes the remote JSON's enrichVar calls below no-op for vars the preset already populated. - const log = createLogger('cli:network_config'); - applyNetworkConsensusConfigToEnv(networkName, process.env, msg => log.warn(msg)); - if (networkName === 'local') { return; // No remote config for local development } diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index 15f470c1b702..23887ea043f1 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -127,6 +127,7 @@ import { P2PInstrumentation } from './instrumentation.js'; function buildProposerTimetable( config: P2PConfig, l1Constants: ReturnType, + logger?: Logger, ): ProposerTimetable { return new ProposerTimetable({ l1Constants, @@ -137,6 +138,7 @@ function buildProposerTimetable( checkpointProposalInitTime: DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, checkpointProposalSyncGrace: config.checkpointProposalSyncGraceSeconds, maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint, + logger, }); } @@ -261,7 +263,7 @@ export class LibP2PService extends WithTracer implements P2PService { // sourced from p2p config, and inject it into every validator so they share one set of receive-window // bounds. A ProposerTimetable also satisfies every ConsensusTimetable consumer and lets gossipsub // scoring derive the same max-blocks-per-checkpoint the proposer uses. - const consensusTimetable = buildProposerTimetable(config, epochCache.getL1Constants()); + const consensusTimetable = buildProposerTimetable(config, epochCache.getL1Constants(), this.logger); const proposalValidatorOpts = { txsPermitted: !config.disableTransactions, maxTxsPerBlock: config.validateMaxTxsPerBlock ?? config.validateMaxTxsPerCheckpoint, @@ -410,7 +412,7 @@ export class LibP2PService extends WithTracer implements P2PService { slotDurationMs: l1Constants.slotDuration * 1000, heartbeatIntervalMs: config.gossipsubInterval, targetCommitteeSize: l1Constants.targetCommitteeSize, - timetable: buildProposerTimetable(config, l1Constants), + timetable: buildProposerTimetable(config, l1Constants, logger), expectedBlockProposalsPerSlot: config.expectedBlockProposalsPerSlot, }); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 1e5131ad2238..d60015891bb4 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -19,11 +19,7 @@ import type { ValidateCheckpointResult, } from '@aztec/stdlib/block'; import type { Checkpoint, ProposedCheckpointData } from '@aztec/stdlib/checkpoint'; -import { - type ChainConfig, - DEFAULT_MAX_BLOCKS_PER_CHECKPOINT, - MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, -} from '@aztec/stdlib/config'; +import { type ChainConfig, MIN_PER_BLOCK_ALLOCATION_MULTIPLIER } from '@aztec/stdlib/config'; import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; import { MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, diff --git a/yarn-project/stdlib/src/config/network-consensus-config.test.ts b/yarn-project/stdlib/src/config/network-consensus-config.test.ts index 42cd40dc7cf3..21ace7257074 100644 --- a/yarn-project/stdlib/src/config/network-consensus-config.test.ts +++ b/yarn-project/stdlib/src/config/network-consensus-config.test.ts @@ -1,130 +1,119 @@ -import type { NetworkNames } from '@aztec/foundation/config'; - import { type NetworkConsensusConfig, - applyNetworkConsensusConfigToEnv, - getNetworkConsensusConfig, - getPresetMismatches, + checkConsensusEnvOverrides, + getConsensusConfigFromNetworkEnv, validateNetworkConsensusConfig, } from './network-consensus-config.js'; -const PRESET_NETWORKS: NetworkNames[] = ['mainnet', 'testnet']; - -describe('NetworkConsensusConfig presets', () => { - it.each(PRESET_NETWORKS)('%s preset validates with zero errors and zero warnings', networkName => { - const preset = getNetworkConsensusConfig(networkName); - expect(preset).toBeDefined(); - const { errors, warnings } = validateNetworkConsensusConfig(preset!); - expect(errors).toEqual([]); - expect(warnings).toEqual([]); - }); - - it('returns undefined for networks without a preset', () => { - expect(getNetworkConsensusConfig('local')).toBeUndefined(); - expect(getNetworkConsensusConfig('devnet')).toBeUndefined(); - }); -}); - describe('validateNetworkConsensusConfig', () => { + // Production geometry: the default budgets derive exactly 10 blocks per checkpoint. const base: NetworkConsensusConfig = { aztecSlotDuration: 72, ethereumSlotDuration: 12, blockDurationMs: 6000, maxBlocksPerCheckpoint: 10, checkpointProposalSyncGraceSeconds: 12, - minPerBlockAllocationMultiplier: 1.2, - minPerBlockDAAllocationMultiplier: 1.5, }; - it('reports an error when blockDurationMs is non-positive', () => { - expect(validateNetworkConsensusConfig({ ...base, blockDurationMs: 0 }).errors).toContainEqual( - expect.stringContaining('blockDurationMs'), - ); + it('returns no errors for a sound config', () => { + expect(validateNetworkConsensusConfig(base)).toEqual([]); }); - it('reports an error when the sub-slot is longer than the slot', () => { - expect(validateNetworkConsensusConfig({ ...base, blockDurationMs: 100_000 }).errors).toContainEqual( - expect.stringContaining('exceeds aztecSlotDuration'), - ); + it('errors when maxBlocksPerCheckpoint is below the derived count, naming both numbers', () => { + const errors = validateNetworkConsensusConfig({ ...base, maxBlocksPerCheckpoint: 9 }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('9'); + expect(errors[0]).toContain('10'); }); - it('reports an error when maxBlocksPerCheckpoint is below 1', () => { - expect(validateNetworkConsensusConfig({ ...base, maxBlocksPerCheckpoint: 0 }).errors).toContainEqual( - expect.stringContaining('maxBlocksPerCheckpoint'), - ); + it('errors when maxBlocksPerCheckpoint is above the derived count, naming both numbers', () => { + const errors = validateNetworkConsensusConfig({ ...base, maxBlocksPerCheckpoint: 11 }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('11'); + expect(errors[0]).toContain('10'); }); - it('reports an error for a negative sync grace', () => { - expect(validateNetworkConsensusConfig({ ...base, checkpointProposalSyncGraceSeconds: -1 }).errors).toContainEqual( - expect.stringContaining('checkpointProposalSyncGraceSeconds'), + it('errors when the slot duration is not a multiple of the ethereum slot duration', () => { + expect(validateNetworkConsensusConfig({ ...base, ethereumSlotDuration: 5 })).toContainEqual( + expect.stringContaining('must be a multiple'), ); }); - it('reports an error for multipliers below the network minimums', () => { - expect(validateNetworkConsensusConfig({ ...base, minPerBlockAllocationMultiplier: 1.1 }).errors).toContainEqual( - expect.stringContaining('minPerBlockAllocationMultiplier'), - ); - expect(validateNetworkConsensusConfig({ ...base, minPerBlockDAAllocationMultiplier: 1.4 }).errors).toContainEqual( - expect.stringContaining('minPerBlockDAAllocationMultiplier'), + it('errors when blockDurationMs is non-positive', () => { + expect(validateNetworkConsensusConfig({ ...base, blockDurationMs: 0 })).toContainEqual( + expect.stringContaining('blockDurationMs'), ); }); - it('reports an error for non-finite values instead of silently passing', () => { - expect(validateNetworkConsensusConfig({ ...base, blockDurationMs: NaN }).errors).toContainEqual( - expect.stringContaining('blockDurationMs'), + it('errors when the sub-slot is longer than the slot', () => { + expect(validateNetworkConsensusConfig({ ...base, blockDurationMs: 100_000 })).toContainEqual( + expect.stringContaining('exceeds aztecSlotDuration'), ); - expect( - validateNetworkConsensusConfig({ ...base, blockDurationMs: undefined as unknown as number }).errors, - ).toContainEqual(expect.stringContaining('blockDurationMs')); }); - it('warns instead of throwing when not even one block is achievable at default budgets', () => { - // 60s blocks pass the basic sub-slot <= slot check, but the timetable derives < 1 achievable block. - const { errors, warnings } = validateNetworkConsensusConfig({ ...base, blockDurationMs: 60_000 }); - expect(errors).toEqual([]); - expect(warnings).toContainEqual(expect.stringContaining('exceeds the 0 blocks achievable')); + it('errors for a non-finite value', () => { + expect(validateNetworkConsensusConfig({ ...base, blockDurationMs: NaN })).toContainEqual( + expect.stringContaining('blockDurationMs'), + ); }); - it('skips the achievability warning when maxBlocksPerCheckpoint equals the default cap', () => { - const config = { ...base, maxBlocksPerCheckpoint: 24 }; - expect(validateNetworkConsensusConfig(config).warnings).not.toEqual([]); - expect(validateNetworkConsensusConfig(config, { defaultCapMaxBlocks: 24 }).warnings).toEqual([]); + it('errors when a field is missing (NaN from Number(undefined))', () => { + expect( + validateNetworkConsensusConfig({ ...base, maxBlocksPerCheckpoint: undefined as unknown as number }), + ).toContainEqual(expect.stringContaining('maxBlocksPerCheckpoint')); }); - it('warns when the slot duration is not a multiple of the ethereum slot duration', () => { - expect(validateNetworkConsensusConfig({ ...base, ethereumSlotDuration: 5 }).warnings).toContainEqual( - expect.stringContaining('not a multiple'), - ); + it('errors rather than throws when fewer than one block fits the default budgets', () => { + // 60s blocks pass the sub-slot <= slot check, but the default budgets fit < 1 block. + const errors = validateNetworkConsensusConfig({ ...base, blockDurationMs: 60_000 }); + expect(errors).not.toEqual([]); + expect(errors[0]).toContain('cannot be achieved'); }); +}); - it('warns when maxBlocksPerCheckpoint exceeds the achievable count at default budgets', () => { - expect(validateNetworkConsensusConfig({ ...base, maxBlocksPerCheckpoint: 24 }).warnings).toContainEqual( - expect.stringContaining('exceeds the'), - ); +describe('getConsensusConfigFromNetworkEnv', () => { + it('extracts the five timing fields from a generated-config-shaped object', () => { + const config = getConsensusConfigFromNetworkEnv({ + ETHEREUM_SLOT_DURATION: 12, + AZTEC_SLOT_DURATION: 72, + SEQ_BLOCK_DURATION_MS: 6000, + MAX_BLOCKS_PER_CHECKPOINT: 10, + CHECKPOINT_PROPOSAL_SYNC_GRACE_SECONDS: 12, + L1_CHAIN_ID: 1, + }); + expect(config).toEqual({ + aztecSlotDuration: 72, + ethereumSlotDuration: 12, + blockDurationMs: 6000, + maxBlocksPerCheckpoint: 10, + checkpointProposalSyncGraceSeconds: 12, + }); }); }); -describe('applyNetworkConsensusConfigToEnv', () => { - const mainnet = getNetworkConsensusConfig('mainnet')!; +describe('checkConsensusEnvOverrides', () => { + const networkConfig = { + SEQ_BLOCK_DURATION_MS: 6000, + AZTEC_SLASHING_VETOER: '0x0000000000000000000000000000000000000000', + L1_CHAIN_ID: 1, + }; - it('populates unset consensus vars from the preset', () => { + it('leaves unset vars untouched', () => { const env: Record = {}; - applyNetworkConsensusConfigToEnv('mainnet', env); - expect(env.ETHEREUM_SLOT_DURATION).toBe(String(mainnet.ethereumSlotDuration)); - expect(env.SEQ_BLOCK_DURATION_MS).toBe(String(mainnet.blockDurationMs)); - expect(env.MAX_BLOCKS_PER_CHECKPOINT).toBe(String(mainnet.maxBlocksPerCheckpoint)); - expect(env.CHECKPOINT_PROPOSAL_SYNC_GRACE_SECONDS).toBe(String(mainnet.checkpointProposalSyncGraceSeconds)); + checkConsensusEnvOverrides(networkConfig, env); + expect(env.SEQ_BLOCK_DURATION_MS).toBeUndefined(); + expect(env.L1_CHAIN_ID).toBeUndefined(); }); - it('keeps equal operator values', () => { - const env: Record = { SEQ_BLOCK_DURATION_MS: String(mainnet.blockDurationMs) }; - expect(() => applyNetworkConsensusConfigToEnv('mainnet', env)).not.toThrow(); - expect(env.SEQ_BLOCK_DURATION_MS).toBe(String(mainnet.blockDurationMs)); + it('canonicalizes a numerically-equal value', () => { + const env: Record = { SEQ_BLOCK_DURATION_MS: '6e3' }; + checkConsensusEnvOverrides(networkConfig, env); + expect(env.SEQ_BLOCK_DURATION_MS).toBe('6000'); }); - it('throws naming the var on a conflicting operator override', () => { + it('throws naming the var on a conflicting value', () => { const env: Record = { SEQ_BLOCK_DURATION_MS: '3000' }; - expect(() => applyNetworkConsensusConfigToEnv('mainnet', env)).toThrow(/SEQ_BLOCK_DURATION_MS/); + expect(() => checkConsensusEnvOverrides(networkConfig, env)).toThrow(/SEQ_BLOCK_DURATION_MS/); }); it('keeps the operator value and logs when ALLOW_OVERRIDING_NETWORK_CONFIG is set', () => { @@ -133,52 +122,28 @@ describe('applyNetworkConsensusConfigToEnv', () => { ALLOW_OVERRIDING_NETWORK_CONFIG: '1', }; const logs: string[] = []; - applyNetworkConsensusConfigToEnv('mainnet', env, msg => logs.push(msg)); + checkConsensusEnvOverrides(networkConfig, env, msg => logs.push(msg)); expect(env.SEQ_BLOCK_DURATION_MS).toBe('3000'); expect(logs.some(msg => msg.includes('SEQ_BLOCK_DURATION_MS'))).toBe(true); }); - it('canonicalizes operator values that match numerically but parse differently downstream', () => { - // parseInt-based config parsers read '6e3' as 6, so the matching value must be rewritten canonically. - const env: Record = { SEQ_BLOCK_DURATION_MS: '6e3' }; - applyNetworkConsensusConfigToEnv('mainnet', env); - expect(env.SEQ_BLOCK_DURATION_MS).toBe('6000'); - }); - - it('records the network name into NETWORK without overriding an existing value', () => { - const env: Record = {}; - applyNetworkConsensusConfigToEnv('mainnet', env); - expect(env.NETWORK).toBe('mainnet'); - - const preset: Record = { NETWORK: 'alpha-testnet' }; - applyNetworkConsensusConfigToEnv('testnet', preset); - expect(preset.NETWORK).toBe('alpha-testnet'); - }); - - it('is a no-op for networks without a preset', () => { - const env: Record = {}; - applyNetworkConsensusConfigToEnv('local', env); - expect(env).toEqual({}); - }); -}); - -describe('getPresetMismatches', () => { - const mainnet = getNetworkConsensusConfig('mainnet')!; - - it('returns no mismatches for a config equal to the preset', () => { - expect(getPresetMismatches(mainnet, mainnet)).toEqual([]); - }); + it('compares non-numeric values as strings', () => { + const matching: Record = { + AZTEC_SLASHING_VETOER: '0x0000000000000000000000000000000000000000', + }; + expect(() => checkConsensusEnvOverrides(networkConfig, matching)).not.toThrow(); + // Non-numeric values are not canonicalized. + expect(matching.AZTEC_SLASHING_VETOER).toBe('0x0000000000000000000000000000000000000000'); - it('describes every diverging consensus field', () => { - const config = { ...mainnet, blockDurationMs: 3000, maxBlocksPerCheckpoint: 24 }; - const mismatches = getPresetMismatches(config, mainnet); - expect(mismatches).toHaveLength(2); - expect(mismatches).toContainEqual(expect.stringContaining('blockDurationMs')); - expect(mismatches).toContainEqual(expect.stringContaining('maxBlocksPerCheckpoint')); + const conflicting: Record = { + AZTEC_SLASHING_VETOER: '0xdfe19Da6a717b7088621d8bBB66be59F2d78e924', + }; + expect(() => checkConsensusEnvOverrides(networkConfig, conflicting)).toThrow(/AZTEC_SLASHING_VETOER/); }); - it('ignores the multiplier fields, which are constants rather than env-derived values', () => { - const config = { ...mainnet, minPerBlockAllocationMultiplier: 9 }; - expect(getPresetMismatches(config, mainnet)).toEqual([]); + it('ignores vars absent from the network config', () => { + const env: Record = { AZTEC_SLASHING_QUORUM: '99' }; + expect(() => checkConsensusEnvOverrides(networkConfig, env)).not.toThrow(); + expect(env.AZTEC_SLASHING_QUORUM).toBe('99'); }); }); diff --git a/yarn-project/stdlib/src/config/network-consensus-config.ts b/yarn-project/stdlib/src/config/network-consensus-config.ts index 05f70ce2f001..8811b41675b5 100644 --- a/yarn-project/stdlib/src/config/network-consensus-config.ts +++ b/yarn-project/stdlib/src/config/network-consensus-config.ts @@ -1,4 +1,4 @@ -import type { NetworkNames } from '@aztec/foundation/config'; +import type { EnvVar } from '@aztec/foundation/config'; import { DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, @@ -21,87 +21,125 @@ export const MIN_PER_BLOCK_ALLOCATION_MULTIPLIER = 1.2; */ export const MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER = 1.5; -/** Consensus-critical configuration that must be identical across all nodes of a network. */ +/** + * Environment variables whose values must be identical across every node of a network. They fall into three + * categories, all consensus-critical: + * + * - Timing/protocol consensus: slot and epoch durations, block sub-slot duration, max blocks per checkpoint, and + * the checkpoint-proposal materialization grace. Proposers and validators must agree on these to land on the + * same proposed chain and the same checkpoint-proposal receive/handoff deadlines. + * - Network identity and L1-posted deployment params: the L1 chain id and the staking/governance/slashing + * parameters baked into the deployed rollup contract (committee size, lags, thresholds, mana target, fee + * pricing, governance/slashing round sizes, quorums, slash amounts, etc.). A node disagreeing with the rollup + * it points at would compute the wrong epoch geometry, fees, or slashing rounds. + * - Node-side slashing offense consensus: the offense detection/penalty parameters validators apply locally to + * decide which payloads to sign. Validators must agree on these to reach the on-chain slashing quorum. + * + * Deliberately excluded: bootnodes, P2P/store/OTEL/sentinel settings, SEQ_MIN_TX_PER_BLOCK, SEQ_MAX_TX_PER_*, + * AZTEC_SLASHER_ENABLED, PROVER_REAL_PROOFS, TRANSACTIONS_DISABLED, and AZTEC_ENTRY_QUEUE_* (mainnet-only genesis + * params enforced by L1). + */ +export const NETWORK_CONSENSUS_ENV_VARS = [ + // Timing/protocol consensus. + 'ETHEREUM_SLOT_DURATION', + 'AZTEC_SLOT_DURATION', + 'AZTEC_EPOCH_DURATION', + 'SEQ_BLOCK_DURATION_MS', + 'MAX_BLOCKS_PER_CHECKPOINT', + 'CHECKPOINT_PROPOSAL_SYNC_GRACE_SECONDS', + + // Network identity / L1-posted deployment params. + 'L1_CHAIN_ID', + 'AZTEC_TARGET_COMMITTEE_SIZE', + 'AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET', + 'AZTEC_LAG_IN_EPOCHS_FOR_RANDAO', + 'AZTEC_ACTIVATION_THRESHOLD', + 'AZTEC_EJECTION_THRESHOLD', + 'AZTEC_LOCAL_EJECTION_THRESHOLD', + 'AZTEC_EXIT_DELAY_SECONDS', + 'AZTEC_INBOX_LAG', + 'AZTEC_PROOF_SUBMISSION_EPOCHS', + 'AZTEC_MANA_TARGET', + 'AZTEC_PROVING_COST_PER_MANA', + 'AZTEC_INITIAL_ETH_PER_FEE_ASSET', + 'AZTEC_GOVERNANCE_PROPOSER_ROUND_SIZE', + 'AZTEC_GOVERNANCE_PROPOSER_QUORUM', + 'AZTEC_SLASHING_QUORUM', + 'AZTEC_SLASHING_ROUND_SIZE_IN_EPOCHS', + 'AZTEC_SLASHING_LIFETIME_IN_ROUNDS', + 'AZTEC_SLASHING_OFFSET_IN_ROUNDS', + 'AZTEC_SLASHING_EXECUTION_DELAY_IN_ROUNDS', + 'AZTEC_SLASHING_VETOER', + 'AZTEC_SLASHING_DISABLE_DURATION', + 'AZTEC_SLASH_AMOUNT_SMALL', + 'AZTEC_SLASH_AMOUNT_MEDIUM', + 'AZTEC_SLASH_AMOUNT_LARGE', + + // Node-side slashing offense consensus. + 'SLASH_OFFENSE_EXPIRATION_ROUNDS', + 'SLASH_MAX_PAYLOAD_SIZE', + 'SLASH_EXECUTE_ROUNDS_LOOK_BACK', + 'SLASH_DATA_WITHHOLDING_TOLERANCE_SLOTS', + 'SLASH_DATA_WITHHOLDING_PENALTY', + 'SLASH_INACTIVITY_TARGET_PERCENTAGE', + 'SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD', + 'SLASH_INACTIVITY_PENALTY', + 'SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY', + 'SLASH_DUPLICATE_PROPOSAL_PENALTY', + 'SLASH_DUPLICATE_ATTESTATION_PENALTY', + 'SLASH_PROPOSE_DESCENDANT_OF_CHECKPOINT_WITH_INVALID_ATTESTATIONS_PENALTY', + 'SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY', + 'SLASH_UNKNOWN_PENALTY', + 'SLASH_INVALID_BLOCK_PENALTY', + 'SLASH_INVALID_CHECKPOINT_PROPOSAL_PENALTY', + 'SLASH_GRACE_PERIOD_L2_SLOTS', +] as const satisfies readonly EnvVar[]; + +/** A consensus-critical environment variable name; see {@link NETWORK_CONSENSUS_ENV_VARS}. */ +export type ConsensusEnvVar = (typeof NETWORK_CONSENSUS_ENV_VARS)[number]; + +/** The subset of consensus-critical timing config whose geometry can be validated in isolation. */ export type NetworkConsensusConfig = { - /** Expected aztecSlotDuration (seconds); cross-checked against the rollup contract at startup. */ + /** Aztec L2 slot duration in seconds. */ aztecSlotDuration: number; - /** Ethereum slot duration (seconds) of the network's L1. */ + /** Ethereum L1 slot duration in seconds. */ ethereumSlotDuration: number; - /** Duration of a block sub-slot in ms. */ + /** Duration of a block sub-slot in milliseconds. */ blockDurationMs: number; - /** Explicit network max blocks per checkpoint (NOT derived from local budgets). */ + /** Explicit network max blocks per checkpoint (the value the production default budgets must derive). */ maxBlocksPerCheckpoint: number; - /** Consensus grace for received checkpoint proposals to materialize locally (seconds). */ + /** Consensus grace for received checkpoint proposals to materialize locally, in seconds. */ checkpointProposalSyncGraceSeconds: number; - /** Network-minimum per-block budget multiplier for L2 gas / tx count (operators may set higher). */ - minPerBlockAllocationMultiplier: number; - /** Network-minimum per-block budget multiplier for DA gas / blob fields. */ - minPerBlockDAAllocationMultiplier: number; }; /** - * In-code consensus presets keyed by network name. Networks without a preset (e.g. `local`, `devnet`) return - * `undefined` from {@link getNetworkConsensusConfig} and are not subject to override enforcement. - * - * Related sources of these values exist in the remote `network_config.json` (AztecProtocol/networks) and the - * spartan environment defaults; these presets are authoritative for the consensus-critical vars on named - * networks, since they are applied to the env before any other enrichment runs. + * Extracts the timing {@link NetworkConsensusConfig} from a generated network config object. Reads the relevant + * env-var keys and coerces them with `Number()`; missing keys become `NaN`, which + * {@link validateNetworkConsensusConfig} reports as an error. */ -const NETWORK_CONSENSUS_PRESETS: Partial> = { - mainnet: { - aztecSlotDuration: 72, - ethereumSlotDuration: 12, - blockDurationMs: 6000, - maxBlocksPerCheckpoint: 10, - checkpointProposalSyncGraceSeconds: 12, - minPerBlockAllocationMultiplier: MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, - minPerBlockDAAllocationMultiplier: MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER, - }, - testnet: { - aztecSlotDuration: 72, - ethereumSlotDuration: 12, - blockDurationMs: 6000, - maxBlocksPerCheckpoint: 10, - checkpointProposalSyncGraceSeconds: 12, - minPerBlockAllocationMultiplier: MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, - minPerBlockDAAllocationMultiplier: MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER, - }, -}; - -/** Returns the in-code consensus preset for a network, or `undefined` when none is defined. */ -export function getNetworkConsensusConfig(networkName: NetworkNames): NetworkConsensusConfig | undefined { - return NETWORK_CONSENSUS_PRESETS[networkName]; +export function getConsensusConfigFromNetworkEnv( + values: Record, +): NetworkConsensusConfig { + return { + aztecSlotDuration: Number(values['AZTEC_SLOT_DURATION']), + ethereumSlotDuration: Number(values['ETHEREUM_SLOT_DURATION']), + blockDurationMs: Number(values['SEQ_BLOCK_DURATION_MS']), + maxBlocksPerCheckpoint: Number(values['MAX_BLOCKS_PER_CHECKPOINT']), + checkpointProposalSyncGraceSeconds: Number(values['CHECKPOINT_PROPOSAL_SYNC_GRACE_SECONDS']), + }; } -/** Maps consensus config fields to the env vars operators may set for them. */ -const CONSENSUS_ENV_VARS = [ - { env: 'ETHEREUM_SLOT_DURATION', field: 'ethereumSlotDuration' }, - { env: 'SEQ_BLOCK_DURATION_MS', field: 'blockDurationMs' }, - { env: 'MAX_BLOCKS_PER_CHECKPOINT', field: 'maxBlocksPerCheckpoint' }, - { env: 'CHECKPOINT_PROPOSAL_SYNC_GRACE_SECONDS', field: 'checkpointProposalSyncGraceSeconds' }, -] as const satisfies ReadonlyArray<{ env: string; field: keyof NetworkConsensusConfig }>; - /** - * Validates a {@link NetworkConsensusConfig} for self-consistency, independent of any node's local budgets. + * Validates a {@link NetworkConsensusConfig} for self-consistency, returning a list of error messages (empty + * when valid). Used by the cli unit test that gates the generated network configs. * - * Errors are conditions that make the config impossible (non-finite or non-positive durations, sub-slot longer - * than the slot, fewer than one block per checkpoint, negative grace, multipliers below the network minimums). - * Warnings are conditions that are merely suspicious or unachievable at the production operational budgets: a - * non-divisible slot/ethereum-slot ratio, or a `maxBlocksPerCheckpoint` exceeding what a - * {@link ProposerTimetable} built from the same slot timings and the default budgets can achieve. The - * achievability warning is heuristic (a node's configured budgets may differ from the defaults) and is skipped - * when `maxBlocksPerCheckpoint` equals `opts.defaultCapMaxBlocks`, since the default cap is intentionally - * above what most geometries can achieve. + * The check requires `maxBlocksPerCheckpoint` to be *exactly* what a {@link ProposerTimetable} built from the + * same slot timings and the production default budgets derives. This exact-equality requirement ensures the + * published network value is precisely what the production default budgets produce, so every node running those + * defaults agrees on the per-checkpoint block count without clamping. */ -export function validateNetworkConsensusConfig( - config: NetworkConsensusConfig, - opts: { defaultCapMaxBlocks?: number } = {}, -): { - errors: string[]; - warnings: string[]; -} { +export function validateNetworkConsensusConfig(config: NetworkConsensusConfig): string[] { const errors: string[] = []; - const warnings: string[] = []; for (const [field, value] of Object.entries(config)) { if (typeof value !== 'number' || !Number.isFinite(value)) { @@ -109,7 +147,7 @@ export function validateNetworkConsensusConfig( } } if (errors.length > 0) { - return { errors, warnings }; + return errors; } if (config.ethereumSlotDuration <= 0) { @@ -118,6 +156,15 @@ export function validateNetworkConsensusConfig( if (config.blockDurationMs <= 0) { errors.push(`blockDurationMs must be positive (got ${config.blockDurationMs})`); } + if (config.aztecSlotDuration <= 0) { + errors.push(`aztecSlotDuration must be positive (got ${config.aztecSlotDuration})`); + } + if (config.ethereumSlotDuration > 0 && config.aztecSlotDuration % config.ethereumSlotDuration !== 0) { + errors.push( + `aztecSlotDuration (${config.aztecSlotDuration}s) must be a multiple of ethereumSlotDuration ` + + `(${config.ethereumSlotDuration}s)`, + ); + } if (config.blockDurationMs / 1000 > config.aztecSlotDuration) { errors.push( `blockDurationMs (${config.blockDurationMs}ms) exceeds aztecSlotDuration (${config.aztecSlotDuration}s)`, @@ -131,128 +178,94 @@ export function validateNetworkConsensusConfig( `checkpointProposalSyncGraceSeconds must be non-negative (got ${config.checkpointProposalSyncGraceSeconds})`, ); } - if (config.minPerBlockAllocationMultiplier < MIN_PER_BLOCK_ALLOCATION_MULTIPLIER) { - errors.push( - `minPerBlockAllocationMultiplier must be at least ${MIN_PER_BLOCK_ALLOCATION_MULTIPLIER} ` + - `(got ${config.minPerBlockAllocationMultiplier})`, - ); + if (errors.length > 0) { + return errors; } - if (config.minPerBlockDAAllocationMultiplier < MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER) { + + let computed: number; + try { + computed = new ProposerTimetable({ + l1Constants: { + l1GenesisTime: 0n, + slotDuration: config.aztecSlotDuration, + ethereumSlotDuration: config.ethereumSlotDuration, + }, + blockDuration: config.blockDurationMs / 1000, + minBlockDuration: DEFAULT_MIN_BLOCK_DURATION, + p2pPropagationTime: DEFAULT_P2P_PROPAGATION_TIME, + checkpointProposalPrepareTime: DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, + checkpointProposalInitTime: DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, + checkpointProposalSyncGrace: config.checkpointProposalSyncGraceSeconds, + }).getMaxBlocksPerCheckpoint(); + } catch (err) { + // The timetable constructor throws when not even one block fits the default budgets; report instead. errors.push( - `minPerBlockDAAllocationMultiplier must be at least ${MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER} ` + - `(got ${config.minPerBlockDAAllocationMultiplier})`, + `maxBlocksPerCheckpoint (${config.maxBlocksPerCheckpoint}) cannot be achieved: the default operational ` + + `budgets fit fewer than one block for slot duration ${config.aztecSlotDuration}s and block duration ` + + `${config.blockDurationMs / 1000}s (${err instanceof Error ? err.message : String(err)})`, ); + return errors; } - if (config.ethereumSlotDuration > 0 && config.aztecSlotDuration % config.ethereumSlotDuration !== 0) { - warnings.push( - `aztecSlotDuration (${config.aztecSlotDuration}s) is not a multiple of ethereumSlotDuration ` + - `(${config.ethereumSlotDuration}s)`, + if (computed !== config.maxBlocksPerCheckpoint) { + errors.push( + `maxBlocksPerCheckpoint (${config.maxBlocksPerCheckpoint}) does not match the ${computed} blocks the ` + + `production default budgets derive for slot duration ${config.aztecSlotDuration}s and block duration ` + + `${config.blockDurationMs / 1000}s`, ); } - // Achievability check: a config whose maxBlocksPerCheckpoint exceeds what the production operational budgets - // can pack is a warning rather than an error, since a node's configured budgets may be tighter than the - // defaults and local/sandbox startup must not break. - if (errors.length === 0 && config.maxBlocksPerCheckpoint !== opts.defaultCapMaxBlocks) { - let achievable: number | undefined; - try { - achievable = new ProposerTimetable({ - l1Constants: { - l1GenesisTime: 0n, - slotDuration: config.aztecSlotDuration, - ethereumSlotDuration: config.ethereumSlotDuration, - }, - blockDuration: config.blockDurationMs / 1000, - minBlockDuration: DEFAULT_MIN_BLOCK_DURATION, - p2pPropagationTime: DEFAULT_P2P_PROPAGATION_TIME, - checkpointProposalPrepareTime: DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, - checkpointProposalInitTime: DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, - checkpointProposalSyncGrace: config.checkpointProposalSyncGraceSeconds, - }).getMaxBlocksPerCheckpoint(); - } catch { - // The timetable constructor throws when not even one block fits; report instead of crashing. - achievable = 0; - } - if (config.maxBlocksPerCheckpoint > achievable) { - warnings.push( - `maxBlocksPerCheckpoint (${config.maxBlocksPerCheckpoint}) exceeds the ${achievable} blocks achievable ` + - `with the default operational budgets for slot duration ${config.aztecSlotDuration}s and block ` + - `duration ${config.blockDurationMs / 1000}s`, - ); - } - } - - return { errors, warnings }; -} - -/** Consensus fields cross-checked between a node's effective config and its network preset. */ -const PRESET_CROSS_CHECK_FIELDS = [ - 'aztecSlotDuration', - 'ethereumSlotDuration', - 'blockDurationMs', - 'maxBlocksPerCheckpoint', - 'checkpointProposalSyncGraceSeconds', -] as const satisfies ReadonlyArray; - -/** - * Compares a node's effective consensus config against a network preset and returns a description of every - * mismatching consensus field. Used at node startup to catch nodes that bypassed env-level enforcement (e.g. - * launched via the standalone node binary or programmatically) with values diverging from their network. - */ -export function getPresetMismatches(config: NetworkConsensusConfig, preset: NetworkConsensusConfig): string[] { - return PRESET_CROSS_CHECK_FIELDS.filter(field => config[field] !== preset[field]).map( - field => `${field} is ${config[field]} but the network preset expects ${preset[field]}`, - ); + return errors; } /** - * Writes a network's consensus preset into the given env, enforcing that operators do not silently override - * consensus-critical values. + * Enforces that operators do not silently override consensus-critical values diverging from the network config. * - * For each enforced env var: if it is set to a value numerically different from the preset, this throws unless - * `ALLOW_OVERRIDING_NETWORK_CONFIG` is truthy (in which case it warns and keeps the operator's value). If it is - * unset or numerically equal, the canonical preset value is written into the env — canonicalization matters - * because the config layer parses some of these vars with `parseInt`, which disagrees with `Number` on forms - * like `6e3` or `0x1770`; without it an operator value that passes the equality check here could still parse to - * a different number downstream. Also records the network name into `NETWORK` (when unset) so later startup - * checks know which preset applies. No-op for networks without a preset. + * For each var in {@link NETWORK_CONSENSUS_ENV_VARS} present in `networkConfig`: if the operator set it in `env` + * to a conflicting value, this throws unless `ALLOW_OVERRIDING_NETWORK_CONFIG` is truthy (in which case it logs + * and keeps the operator value). On a numeric match, the env value is canonicalized to the network value's + * string form. This function does not populate unset vars (the caller's enrichment loop does that) and never + * touches `NETWORK`. */ -export function applyNetworkConsensusConfigToEnv( - networkName: NetworkNames, +export function checkConsensusEnvOverrides( + networkConfig: Record, env: { [key: string]: string | undefined } = process.env, log?: (msg: string) => void, ): void { - const preset = getNetworkConsensusConfig(networkName); - if (!preset) { - return; - } - const allowOverride = allowsNetworkConfigOverride(env); - for (const { env: envVar, field } of CONSENSUS_ENV_VARS) { - const presetValue = preset[field]; + for (const envVar of NETWORK_CONSENSUS_ENV_VARS) { + const networkValue = networkConfig[envVar]; + if (networkValue === undefined) { + continue; + } + const current = env[envVar]; + if (current === undefined || current === '') { + continue; + } - if (current !== undefined && current !== '') { - const parsed = Number(current); - if (Number.isNaN(parsed) || parsed !== presetValue) { - const message = - `Environment variable ${envVar}=${current} conflicts with the ${networkName} network value ${presetValue}. ` + - `Consensus-critical values must match across the network. Set ALLOW_OVERRIDING_NETWORK_CONFIG=1 to override ` + - `(only do this if you know what you are doing).`; - if (allowOverride) { - log?.(message); - continue; - } - throw new Error(message); + const networkIsNumeric = typeof networkValue === 'number'; + const matches = networkIsNumeric ? Number(current) === networkValue : current === String(networkValue); + if (matches) { + // Canonicalize numeric matches: the config layer parses some vars with parseInt, which reads '6e3' as 6. + // Rewriting to the network value's string form closes that bypass. + if (networkIsNumeric) { + env[envVar] = String(networkValue); } + continue; } - env[envVar] = String(presetValue); + const message = + `Environment variable ${envVar}=${current} conflicts with the network value ${networkValue}. ` + + `Consensus-critical values must match across the network. Set ALLOW_OVERRIDING_NETWORK_CONFIG=1 to override ` + + `(only do this if you know what you are doing).`; + if (allowOverride) { + log?.(message); + continue; + } + throw new Error(message); } - - env.NETWORK ??= networkName; } /** Whether the env opts into overriding network-wide consensus values (`ALLOW_OVERRIDING_NETWORK_CONFIG`). */ diff --git a/yarn-project/stdlib/src/timetable/README.md b/yarn-project/stdlib/src/timetable/README.md index dc07ac9b51da..d10ccf37d2a7 100644 --- a/yarn-project/stdlib/src/timetable/README.md +++ b/yarn-project/stdlib/src/timetable/README.md @@ -275,16 +275,17 @@ where `block_index` is zero-based. Sub-slot starts and deadlines do not move when earlier blocks finish early or late. If block `k` finishes early, the proposer waits until `block_build_deadline(k)` before attempting block `k + 1`. If block `k` finishes late, the next sub-slot keeps its original deadline and therefore has less remaining headroom. -The number of full-duration block sub-slots a node's local operational budgets can achieve is: +The number of full-duration block sub-slots a node's local operational budgets compute is: ```text -locally_achievable_blocks_per_checkpoint = floor((last_block_build_time - first_subslot_start) / block_duration) +computed_blocks_per_checkpoint = floor((last_block_build_time - first_subslot_start) / block_duration) ``` -The effective `max_blocks_per_checkpoint` is the explicit network value (when configured) clamped down to this -locally achievable ceiling, or the locally achievable ceiling itself when no network value is given. Clamping never -raises the effective value above what the local budgets can fit, preserving the invariant that every offered -sub-slot's build deadline stays within `last_block_build_time`. +The effective `max_blocks_per_checkpoint` is this computed value, clamped down to the explicit network value when +the network value is lower. A network value at or above the computed count has no effect and the computed count is +used. When the local budgets compute more blocks than the network allows, the computed count is clamped down to the +network value and a warning is emitted. Clamping never raises the effective value above what the local budgets can +fit, preserving the invariant that every offered sub-slot's build deadline stays within `last_block_build_time`. `max_blocks_per_checkpoint` is also an input to the network tx admission limits (it divides the per-checkpoint gas budgets into a per-block share); see [`../gas/README.md`](../gas/README.md) under "Gas and Data Limits". diff --git a/yarn-project/stdlib/src/timetable/proposer_timetable.test.ts b/yarn-project/stdlib/src/timetable/proposer_timetable.test.ts index 47b97cfd8cc6..be459a86dcb7 100644 --- a/yarn-project/stdlib/src/timetable/proposer_timetable.test.ts +++ b/yarn-project/stdlib/src/timetable/proposer_timetable.test.ts @@ -1,4 +1,7 @@ import { SlotNumber } from '@aztec/foundation/branded-types'; +import { createLogger } from '@aztec/foundation/log'; + +import { jest } from '@jest/globals'; import type { L1RollupConstants } from '../epoch-helpers/index.js'; import { ProposerTimetable } from './proposer_timetable.js'; @@ -365,17 +368,14 @@ describe('ProposerTimetable explicit network maxBlocksPerCheckpoint', () => { checkpointProposalPrepareTime: 1, }; - it('uses the network value when below the locally achievable count', () => { + it('uses the network value when below the locally computed count', () => { const timetable = makeProposerTimetable({ ...productionOpts, maxBlocksPerCheckpoint: 4 }); expect(timetable.getMaxBlocksPerCheckpoint()).toBe(4); - expect(timetable.locallyAchievableBlocksPerCheckpoint).toBe(10); - expect(timetable.isClampedByLocalBudgets()).toBe(false); }); - it('clamps the network value down to the locally achievable count', () => { + it('clamps the network value down to the locally computed count when the network value is higher', () => { const timetable = makeProposerTimetable({ ...productionOpts, maxBlocksPerCheckpoint: 20 }); expect(timetable.getMaxBlocksPerCheckpoint()).toBe(10); - expect(timetable.isClampedByLocalBudgets()).toBe(true); }); it('keeps every offered sub-slot build deadline within the last block build time when clamped', () => { @@ -387,10 +387,27 @@ describe('ProposerTimetable explicit network maxBlocksPerCheckpoint', () => { ); }); - it('uses the locally achievable count when no network value is given', () => { + it('uses the locally computed count when no network value is given', () => { const timetable = makeProposerTimetable(productionOpts); expect(timetable.getMaxBlocksPerCheckpoint()).toBe(10); - expect(timetable.isClampedByLocalBudgets()).toBe(false); + }); + + it('warns when the locally computed count exceeds the network value (clamps down)', () => { + const logger = createLogger('test:stdlib:proposer_timetable'); + const warnSpy = jest.spyOn(logger, 'warn'); + const timetable = makeProposerTimetable({ ...productionOpts, maxBlocksPerCheckpoint: 4, logger }); + expect(timetable.getMaxBlocksPerCheckpoint()).toBe(4); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('does not warn when the network value is at or above the locally computed count', () => { + const logger = createLogger('test:stdlib:proposer_timetable'); + const warnSpy = jest.spyOn(logger, 'warn'); + const atComputed = makeProposerTimetable({ ...productionOpts, maxBlocksPerCheckpoint: 10, logger }); + expect(atComputed.getMaxBlocksPerCheckpoint()).toBe(10); + const aboveComputed = makeProposerTimetable({ ...productionOpts, maxBlocksPerCheckpoint: 20, logger }); + expect(aboveComputed.getMaxBlocksPerCheckpoint()).toBe(10); + expect(warnSpy).not.toHaveBeenCalled(); }); it('throws when local budgets cannot fit a single block even with an explicit network value', () => { diff --git a/yarn-project/stdlib/src/timetable/proposer_timetable.ts b/yarn-project/stdlib/src/timetable/proposer_timetable.ts index 8fbee6d4b734..42f01fdd3672 100644 --- a/yarn-project/stdlib/src/timetable/proposer_timetable.ts +++ b/yarn-project/stdlib/src/timetable/proposer_timetable.ts @@ -1,4 +1,5 @@ import type { SlotNumber } from '@aztec/foundation/branded-types'; +import type { Logger } from '@aztec/foundation/log'; import { type ResolvedTimingBudgets, getDefaultCheckpointProposalSyncGrace, resolveTimingBudgets } from './budgets.js'; import { ConsensusTimetable, type SlotTimingConstants } from './consensus_timetable.js'; @@ -18,6 +19,10 @@ export type SubslotSelection = * * The single hard deadline {@link getAttestationDeadline} is inherited from the composed * {@link ConsensusTimetable}, so the proposer uses one object. + * + * `maxBlocksPerCheckpoint` is computed from the local operational budgets and then clamped down to the optional + * network-provided value when that value is lower; a network value at or above the computed count leaves the + * computed count in effect. When clamping down occurs and a logger is supplied, a warning is emitted. */ export class ProposerTimetable extends ConsensusTimetable { /** Minimum block-building time (`min_block_duration`) in seconds. */ @@ -33,18 +38,12 @@ export class ProposerTimetable extends ConsensusTimetable { public readonly checkpointProposalInitTime: number; /** - * Effective maximum number of block sub-slots per checkpoint: the explicit network value clamped down to - * what the local operational budgets can achieve, or the locally achievable count when no network value is - * given. + * Effective maximum number of block sub-slots per checkpoint: the value the local operational budgets compute, + * clamped down to the explicit network value when that value is lower. A network value above the computed + * count has no effect (the computed count is used) and is not logged. */ public readonly maxBlocksPerCheckpoint: number; - /** Maximum number of full-duration block sub-slots the local operational budgets can achieve. */ - public readonly locallyAchievableBlocksPerCheckpoint: number; - - /** Whether the explicit network max blocks per checkpoint was reduced to fit the local operational budgets. */ - private readonly clampedByLocalBudgets: boolean; - constructor(opts: { l1Constants: SlotTimingConstants; blockDuration: number; @@ -53,8 +52,10 @@ export class ProposerTimetable extends ConsensusTimetable { checkpointProposalPrepareTime: number; checkpointProposalInitTime: number; checkpointProposalSyncGrace?: number; - /** Explicit network max blocks per checkpoint; the effective value is clamped down to the locally achievable count. */ + /** Explicit network max blocks per checkpoint; the effective value is clamped down to this when it is lower. */ maxBlocksPerCheckpoint?: number; + /** Optional logger; warns when the local budgets compute more blocks than the network value allows. */ + logger?: Logger; }) { super({ l1Constants: opts.l1Constants, @@ -78,14 +79,15 @@ export class ProposerTimetable extends ConsensusTimetable { // Clamp min block duration to the block duration so a single sub-slot is always startable. this.minBlockDuration = Math.min(budgets.minBlockDuration, this.blockDuration); - this.locallyAchievableBlocksPerCheckpoint = this.computeMaxBlocksPerCheckpoint(); - this.clampedByLocalBudgets = - opts.maxBlocksPerCheckpoint !== undefined && - opts.maxBlocksPerCheckpoint > this.locallyAchievableBlocksPerCheckpoint; + const computed = this.computeMaxBlocksPerCheckpoint(); this.maxBlocksPerCheckpoint = - opts.maxBlocksPerCheckpoint !== undefined - ? Math.min(opts.maxBlocksPerCheckpoint, this.locallyAchievableBlocksPerCheckpoint) - : this.locallyAchievableBlocksPerCheckpoint; + opts.maxBlocksPerCheckpoint !== undefined ? Math.min(computed, opts.maxBlocksPerCheckpoint) : computed; + if (opts.maxBlocksPerCheckpoint !== undefined && opts.maxBlocksPerCheckpoint < computed) { + opts.logger?.warn(`Locally computed max blocks per checkpoint clamped down to the network-provided value`, { + computed, + maxBlocksPerCheckpoint: opts.maxBlocksPerCheckpoint, + }); + } if (this.maxBlocksPerCheckpoint < 1) { throw new Error( `Invalid timing configuration: derived ${this.maxBlocksPerCheckpoint} blocks per checkpoint for ` + @@ -94,11 +96,6 @@ export class ProposerTimetable extends ConsensusTimetable { } } - /** Whether the explicit network max blocks per checkpoint was clamped down by the local operational budgets. */ - public isClampedByLocalBudgets(): boolean { - return this.clampedByLocalBudgets; - } - /** * Computes the maximum number of full-duration block sub-slots in a checkpoint from the already-resolved * budgets. Derived from the spec's `max_blocks_per_checkpoint = floor((last_block_build_time - From 67181f1e6f218fa3bc710e9dc5e0d0171949c038 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Wed, 10 Jun 2026 15:28:25 -0300 Subject: [PATCH 05/10] docs: drop stale reference to removed in-code presets --- yarn-project/stdlib/src/config/network-consensus-config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/yarn-project/stdlib/src/config/network-consensus-config.ts b/yarn-project/stdlib/src/config/network-consensus-config.ts index 8811b41675b5..fba6347ad04d 100644 --- a/yarn-project/stdlib/src/config/network-consensus-config.ts +++ b/yarn-project/stdlib/src/config/network-consensus-config.ts @@ -17,7 +17,8 @@ export const MIN_PER_BLOCK_ALLOCATION_MULTIPLIER = 1.2; /** * Network-minimum per-block budget multiplier for DA gas / blob fields. See * {@link MIN_PER_BLOCK_ALLOCATION_MULTIPLIER}. The DA-specific operator knob and its runtime enforcement land - * with the network tx admission limits (#23947); until then this only constrains the in-code presets. + * with the network tx admission limits (#23947); until then this constant is documentation of the network + * minimum only. */ export const MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER = 1.5; From b8f0ab83b597016265904d36bd31822f1a69e17e Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Wed, 10 Jun 2026 16:40:07 -0300 Subject: [PATCH 06/10] refactor: address review feedback on consensus config structure - Move MIN_PER_BLOCK_*_ALLOCATION_MULTIPLIER constants to @aztec/constants. - Compose NetworkConsensusConfig by picking fields from L1ContractsConfig and SequencerConfig, and derive getConsensusConfigFromNetworkEnv env names and parsing from the canonical config mappings. - Make checkConsensusEnvOverrides pure: it returns the canonical env writes instead of mutating env; the cli enrichment layer applies them. - Add a compile-time ts-expect-error gate proving ConsensusComplete rejects configs missing a consensus-critical var. - Drop the redundant checkpointProposalSyncGraceSeconds defaulting in the node createAndSync (every consumer has its own fallback). - Switch the p2p gossip layer from ProposerTimetable to ConsensusTimetable and feed gossipsub scoring the network maxBlocksPerCheckpoint directly, dropping the now-unused proposer-budget config fields from p2p. --- .../aztec-node/src/aztec-node/server.ts | 7 -- .../cli/src/config/chain_l2_config.test.ts | 21 +++- .../cli/src/config/chain_l2_config.ts | 6 +- yarn-project/constants/src/constants.ts | 14 +++ yarn-project/p2p/src/config.ts | 11 +- .../gossipsub/topic_score_params.test.ts | 87 ++------------- .../services/gossipsub/topic_score_params.ts | 13 ++- .../p2p/src/services/libp2p/libp2p_service.ts | 46 ++++---- .../src/sequencer/sequencer.ts | 3 +- .../config/network-consensus-config.test.ts | 39 +++++-- .../src/config/network-consensus-config.ts | 101 ++++++++++-------- 11 files changed, 170 insertions(+), 178 deletions(-) diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index c6fa6ee42c20..deea63b5e72f 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -127,7 +127,6 @@ import { } from '@aztec/stdlib/messaging'; import type { CheckpointAttestation } from '@aztec/stdlib/p2p'; import type { Offense } from '@aztec/stdlib/slashing'; -import { DEFAULT_MIN_BLOCK_DURATION } from '@aztec/stdlib/timetable'; import type { NullifierLeafPreimage, PublicDataTreeLeafPreimage } from '@aztec/stdlib/trees'; import { MerkleTreeId, NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees'; import { @@ -616,12 +615,6 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb // Track started resources so we can clean up on partial failure during node creation. const started: { stop?(): Promise | void }[] = []; try { - // Default the consensus materialization grace from the block build duration when unset, so the archiver - // gives received checkpoint proposals roughly two build slots to validate and enter proposed state. - config.checkpointProposalSyncGraceSeconds ??= - config.blockDurationMs !== undefined - ? 2 * Math.ceil(config.blockDurationMs / 1000) - : 2 * DEFAULT_MIN_BLOCK_DURATION; config.skipOrphanProposedBlockPruning ||= !!config.useAutomineSequencer; AztecNodeService.checkConfigMatchesRollup(config, { diff --git a/yarn-project/cli/src/config/chain_l2_config.test.ts b/yarn-project/cli/src/config/chain_l2_config.test.ts index 212a8992f193..3f5dd500ff90 100644 --- a/yarn-project/cli/src/config/chain_l2_config.test.ts +++ b/yarn-project/cli/src/config/chain_l2_config.test.ts @@ -7,7 +7,7 @@ import { ProposerTimetable, } from '@aztec/stdlib/timetable'; -import { enrichEnvironmentWithChainName } from './chain_l2_config.js'; +import { type ConsensusComplete, enrichEnvironmentWithChainName } from './chain_l2_config.js'; import { devnetConfig, mainnetConfig, testnetConfig } from './generated/networks.js'; const generatedConfigs = { @@ -44,6 +44,18 @@ describe('generated network configs', () => { } }); +// Compile-time gate: tsc itself enforces that a complete config satisfies ConsensusComplete while a config +// missing a consensus-critical var does not. If the negative assertion ever stops erroring, the unused +// `@ts-expect-error` directive turns into a build error, flagging that the gate has silently weakened. This +// function is never called; its name is `_`-prefixed so lint ignores it as an intentionally unused binding. +function _consensusCompleteCompileGate() { + const _complete: ConsensusComplete = mainnetConfig satisfies ConsensusComplete; + + const { MAX_BLOCKS_PER_CHECKPOINT: _dropped, ...incomplete } = mainnetConfig; + // @ts-expect-error a config missing a consensus-critical var must not satisfy ConsensusComplete + const _incomplete: ConsensusComplete = incomplete satisfies ConsensusComplete; +} + describe('enrichEnvironmentWithChainName', () => { const originalEnv = { ...process.env }; @@ -69,4 +81,11 @@ describe('enrichEnvironmentWithChainName', () => { expect(() => enrichEnvironmentWithChainName('testnet')).not.toThrow(); expect(process.env.SEQ_BLOCK_DURATION_MS).toBe('3000'); }); + + it('canonicalizes a numerically-equal consensus value to the network form', () => { + // '6e3' equals the network's SEQ_BLOCK_DURATION_MS=6000 numerically; enrichment applies the canonical form. + process.env.SEQ_BLOCK_DURATION_MS = '6e3'; + enrichEnvironmentWithChainName('testnet'); + expect(process.env.SEQ_BLOCK_DURATION_MS).toBe('6000'); + }); }); diff --git a/yarn-project/cli/src/config/chain_l2_config.ts b/yarn-project/cli/src/config/chain_l2_config.ts index 29ac63bad996..d0a892f38702 100644 --- a/yarn-project/cli/src/config/chain_l2_config.ts +++ b/yarn-project/cli/src/config/chain_l2_config.ts @@ -15,7 +15,7 @@ const NetworkConfigs: Partial> = { }; /** Every generated network config must define every consensus-critical env var. */ -type ConsensusComplete = Record; +export type ConsensusComplete = Record; ({ devnetConfig, testnetConfig, mainnetConfig }) satisfies Record; const log = createLogger('cli:chain_l2_config'); @@ -59,7 +59,9 @@ export function enrichEnvironmentWithChainName(networkName: NetworkNames) { const configKey = /^v\d+-devnet-\d+$/.test(networkName) ? 'devnet' : networkName; const generatedConfig = NetworkConfigs[configKey]; if (generatedConfig) { - checkConsensusEnvOverrides(generatedConfig, process.env, msg => log.warn(msg)); + // The check is pure; this layer owns env mutation, so apply its canonical writes before enriching. + const canonical = checkConsensusEnvOverrides(generatedConfig, process.env, msg => log.warn(msg)); + Object.assign(process.env, canonical); enrichEnvironmentWithNetworkConfig(generatedConfig); } diff --git a/yarn-project/constants/src/constants.ts b/yarn-project/constants/src/constants.ts index e07b5e1303af..f7862305f552 100644 --- a/yarn-project/constants/src/constants.ts +++ b/yarn-project/constants/src/constants.ts @@ -15,6 +15,20 @@ import { // Typescript-land-only constants export const SPONSORED_FPC_SALT = BigInt(0); +/** + * Network-minimum per-block budget multiplier for L2 gas / tx count. Operators may configure a higher value, + * but never lower: a node admitting txs under a smaller multiplier would accept work it can never pack. + */ +export const MIN_PER_BLOCK_ALLOCATION_MULTIPLIER = 1.2; + +/** + * Network-minimum per-block budget multiplier for DA gas / blob fields. See + * {@link MIN_PER_BLOCK_ALLOCATION_MULTIPLIER}. The DA-specific operator knob and its runtime enforcement land + * with the network tx admission limits (#23947); until then this constant is documentation of the network + * minimum only. + */ +export const MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER = 1.5; + /** * The most DA gas a single tx can consume. A tx's effects cannot encode more than * MAX_TX_BLOB_DATA_SIZE_IN_FIELDS fields in a blob, each costing DA_GAS_PER_FIELD, so this is the maximum diff --git a/yarn-project/p2p/src/config.ts b/yarn-project/p2p/src/config.ts index 2878e65157ce..e05747c8b1e3 100644 --- a/yarn-project/p2p/src/config.ts +++ b/yarn-project/p2p/src/config.ts @@ -44,10 +44,7 @@ export interface P2PConfig SequencerConfig, | 'expectedBlockProposalsPerSlot' | 'maxTxsPerBlock' - | 'attestationPropagationTime' - | 'checkpointProposalPrepareTime' | 'checkpointProposalSyncGraceSeconds' - | 'minBlockDuration' | 'maxBlocksPerCheckpoint' >, // `blockDurationMs` is optional on the loose `SequencerConfig` but is always populated for p2p via @@ -611,7 +608,13 @@ export const p2pConfigMappings: ConfigMappingsType = { 'Minimum percentage fee increase required to replace an existing tx via RPC. Even at 0%, replacement still requires paying at least 1 unit more.', ...bigintConfigHelper(10n), }, - ...sharedSequencerConfigMappings, + ...pickConfigMappings(sharedSequencerConfigMappings, [ + 'expectedBlockProposalsPerSlot', + 'maxTxsPerBlock', + 'checkpointProposalSyncGraceSeconds', + 'maxBlocksPerCheckpoint', + 'blockDurationMs', + ]), ...p2pReqRespConfigMappings, ...batchTxRequesterConfigMappings, ...chainConfigMappings, diff --git a/yarn-project/p2p/src/services/gossipsub/topic_score_params.test.ts b/yarn-project/p2p/src/services/gossipsub/topic_score_params.test.ts index 1e03c89b6c95..ffefbe677467 100644 --- a/yarn-project/p2p/src/services/gossipsub/topic_score_params.test.ts +++ b/yarn-project/p2p/src/services/gossipsub/topic_score_params.test.ts @@ -1,12 +1,4 @@ -import { DEFAULT_BLOCK_DURATION_MS } from '@aztec/stdlib/config'; import { TopicType, createTopicString } from '@aztec/stdlib/p2p'; -import { - DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, - DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, - DEFAULT_MIN_BLOCK_DURATION, - DEFAULT_P2P_PROPAGATION_TIME, - ProposerTimetable, -} from '@aztec/stdlib/timetable'; import { describe, expect, it } from '@jest/globals'; @@ -23,73 +15,16 @@ import { getExpectedMessagesPerSlot, } from './topic_score_params.js'; -/** - * Builds a {@link ProposerTimetable} for scoring tests, mirroring how p2p builds it from config: - * operational budgets fall back to the shared stdlib defaults. - */ -function makeTimetable(opts: { - slotDurationMs: number; - ethereumSlotDuration: number; - blockDurationMs?: number; - p2pPropagationTime?: number; - checkpointProposalPrepareTime?: number; -}): ProposerTimetable { - return new ProposerTimetable({ - l1Constants: { - l1GenesisTime: 0n, - slotDuration: opts.slotDurationMs / 1000, - ethereumSlotDuration: opts.ethereumSlotDuration, - }, - blockDuration: (opts.blockDurationMs ?? DEFAULT_BLOCK_DURATION_MS) / 1000, - minBlockDuration: DEFAULT_MIN_BLOCK_DURATION, - p2pPropagationTime: opts.p2pPropagationTime ?? DEFAULT_P2P_PROPAGATION_TIME, - checkpointProposalPrepareTime: opts.checkpointProposalPrepareTime ?? DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, - checkpointProposalInitTime: DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, - }); -} - describe('Topic Score Params', () => { - // Standard network parameters for testing (matching production values). + // Standard network parameters for testing (matching production values). Scoring now takes the network-wide + // max-blocks-per-checkpoint directly; 1 exercises single-block-mode scoring. const standardParams = { slotDurationMs: 72000, // 72 seconds heartbeatIntervalMs: 700, // 700ms gossipsub heartbeat targetCommitteeSize: 48, - // 30s block duration in a 72s slot derives exactly one block per checkpoint, so these tests exercise - // single-block-mode scoring without relying on the removed "no blockDurationMs = single block" default. - timetable: makeTimetable({ - slotDurationMs: 72000, - ethereumSlotDuration: 12, - blockDurationMs: 30000, - checkpointProposalPrepareTime: 1, - }), + maxBlocksPerCheckpoint: 1, }; - describe('max blocks per checkpoint from the proposer timetable', () => { - it('matches the production worked example (10 blocks)', () => { - // floor((72 - 6 - 2*2 - 1) / 6) = floor(61/6) = 10 - const timetable = makeTimetable({ - slotDurationMs: 72000, - ethereumSlotDuration: 12, - blockDurationMs: 6000, - p2pPropagationTime: 2, - checkpointProposalPrepareTime: 1, - }); - expect(timetable.getMaxBlocksPerCheckpoint()).toBe(10); - }); - - it('matches the local fast profile (4 blocks)', () => { - // floor((36 - 6 - 2*0.5 - 0.5) / 6) = floor(28.5/6) = 4 - const timetable = makeTimetable({ - slotDurationMs: 36000, - ethereumSlotDuration: 4, - blockDurationMs: 6000, - p2pPropagationTime: 0.5, - checkpointProposalPrepareTime: 0.5, - }); - expect(timetable.getMaxBlocksPerCheckpoint()).toBe(4); - }); - }); - describe('getDecayWindowSlots', () => { it('returns 5 slots for low frequency topics (<=1 msg/slot)', () => { expect(getDecayWindowSlots(0)).toBe(5); @@ -248,19 +183,19 @@ describe('Topic Score Params', () => { it('computes shared values once', () => { const factory = new TopicScoreParamsFactory(standardParams); - expect(factory.blocksPerSlot).toBe(1); // 30s block duration in a 72s slot = single block + expect(factory.blocksPerSlot).toBe(1); // maxBlocksPerCheckpoint = 1 = single block mode expect(factory.heartbeatsPerSlot).toBeCloseTo(72000 / 700); expect(factory.invalidDecay).toBeGreaterThan(0); expect(factory.invalidDecay).toBeLessThan(1); }); - it('derives blocksPerSlot from a multi-block proposer timetable', () => { + it('takes blocksPerSlot straight from maxBlocksPerCheckpoint', () => { const factory = new TopicScoreParamsFactory({ ...standardParams, - timetable: makeTimetable({ slotDurationMs: 72000, ethereumSlotDuration: 12, blockDurationMs: 10000 }), + maxBlocksPerCheckpoint: 6, }); - expect(factory.blocksPerSlot).toBeGreaterThan(1); + expect(factory.blocksPerSlot).toBe(6); }); describe('createForTopic', () => { @@ -284,7 +219,7 @@ describe('Topic Score Params', () => { it('disables P3/P3b for block_proposal in MBPS mode when expectedBlockProposalsPerSlot is 0', () => { const factory = new TopicScoreParamsFactory({ ...standardParams, - timetable: makeTimetable({ slotDurationMs: 72000, ethereumSlotDuration: 12, blockDurationMs: 10000 }), + maxBlocksPerCheckpoint: 6, expectedBlockProposalsPerSlot: 0, }); const params = factory.createForTopic(TopicType.block_proposal); @@ -296,7 +231,7 @@ describe('Topic Score Params', () => { it('enables P3/P3b for block_proposal when expectedBlockProposalsPerSlot is positive', () => { const factory = new TopicScoreParamsFactory({ ...standardParams, - timetable: makeTimetable({ slotDurationMs: 72000, ethereumSlotDuration: 12, blockDurationMs: 10000 }), + maxBlocksPerCheckpoint: 6, expectedBlockProposalsPerSlot: 3, }); const params = factory.createForTopic(TopicType.block_proposal); @@ -308,7 +243,7 @@ describe('Topic Score Params', () => { it('falls back to blocksPerSlot - 1 for block_proposal when expectedBlockProposalsPerSlot is undefined', () => { const factory = new TopicScoreParamsFactory({ ...standardParams, - timetable: makeTimetable({ slotDurationMs: 72000, ethereumSlotDuration: 12, blockDurationMs: 10000 }), + maxBlocksPerCheckpoint: 6, }); const params = factory.createForTopic(TopicType.block_proposal); @@ -552,7 +487,7 @@ describe('Topic Score Params', () => { it('total P3b is -102 when block proposal scoring is enabled (3 topics)', () => { const factory = new TopicScoreParamsFactory({ ...standardParams, - timetable: makeTimetable({ slotDurationMs: 72000, ethereumSlotDuration: 12, blockDurationMs: 4000 }), + maxBlocksPerCheckpoint: 16, expectedBlockProposalsPerSlot: 3, }); diff --git a/yarn-project/p2p/src/services/gossipsub/topic_score_params.ts b/yarn-project/p2p/src/services/gossipsub/topic_score_params.ts index 75756273c5ff..f642964b5312 100644 --- a/yarn-project/p2p/src/services/gossipsub/topic_score_params.ts +++ b/yarn-project/p2p/src/services/gossipsub/topic_score_params.ts @@ -1,5 +1,4 @@ import { TopicType, createTopicString } from '@aztec/stdlib/p2p'; -import type { ProposerTimetable } from '@aztec/stdlib/timetable'; import { createTopicScoreParams } from '@chainsafe/libp2p-gossipsub/score'; @@ -14,10 +13,10 @@ export type TopicScoringNetworkParams = { /** Target committee size (number of validators expected to attest per slot) */ targetCommitteeSize: number; /** - * Proposer timetable, shared with the gossip validators. Provides the max-blocks-per-checkpoint used to - * derive expected per-slot message rates, so scoring stays consistent with block production. + * Max blocks per checkpoint, the network-wide config value. Used to derive expected per-slot message rates + * for scoring; it is a peer-rate threshold input, not a consensus deadline. */ - timetable: ProposerTimetable; + maxBlocksPerCheckpoint: number; /** Expected number of block proposals per slot for scoring override. 0 disables scoring, undefined falls back to blocksPerSlot - 1. */ expectedBlockProposalsPerSlot?: number; }; @@ -269,9 +268,9 @@ export class TopicScoreParamsFactory { constructor(private readonly params: TopicScoringNetworkParams) { const { slotDurationMs, heartbeatIntervalMs } = params; - // Compute values that are the same for all topics. The block count comes straight from the shared - // proposer timetable, so gossipsub scoring agrees with the proposer's max blocks per checkpoint. - this.blocksPerSlot = params.timetable.getMaxBlocksPerCheckpoint(); + // Compute values that are the same for all topics. The block count comes straight from the network-wide + // max-blocks-per-checkpoint config value. + this.blocksPerSlot = params.maxBlocksPerCheckpoint; this.heartbeatsPerSlot = slotDurationMs / heartbeatIntervalMs; this.invalidDecay = computeDecay(heartbeatIntervalMs, slotDurationMs, INVALID_DECAY_WINDOW_SLOTS); diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index 23887ea043f1..92c6b35ff95e 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -7,6 +7,7 @@ import { Timer } from '@aztec/foundation/timer'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; import { protocolContractsHash } from '@aztec/protocol-contracts'; import type { EthAddress, L2BlockSource } from '@aztec/stdlib/block'; +import { DEFAULT_MAX_BLOCKS_PER_CHECKPOINT } from '@aztec/stdlib/config'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import { type BlockMinFeesProvider, GasFees, getNetworkTxGasLimits } from '@aztec/stdlib/gas'; import type { ClientProtocolCircuitVerifier, PeerInfo, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; @@ -24,7 +25,7 @@ import { getTopicsForConfig, metricsTopicStrToLabels, } from '@aztec/stdlib/p2p'; -import { buildProposerTimetable } from '@aztec/stdlib/timetable'; +import { ConsensusTimetable, getDefaultCheckpointProposalSyncGrace } from '@aztec/stdlib/timetable'; import { MerkleTreeId } from '@aztec/stdlib/trees'; import { Tx, type TxValidationResult } from '@aztec/stdlib/tx'; import type { UInt64 } from '@aztec/stdlib/types'; @@ -119,30 +120,23 @@ import type { import { P2PInstrumentation } from './instrumentation.js'; /** - * Builds the proposer timetable used by both the gossip validators (for receive-window bounds) and - * gossipsub topic scoring (for max-blocks-per-checkpoint). Operational budgets come from p2p config, - * falling back to the shared stdlib defaults. `checkpointProposalInitTime` is not config-mapped, so the - * stdlib default is used here, the same way the sequencer sources it. + * Builds the {@link ConsensusTimetable} shared by the gossip validators for proposal/attestation receive-window + * bounds. Derived purely from protocol slot-timing constants plus the block sub-slot duration and the consensus + * materialization grace, so every node agrees on these bounds without depending on proposer operational budgets. */ -function buildProposerTimetable( +function buildConsensusTimetable( config: P2PConfig, l1Constants: ReturnType, - logger?: Logger, -): ProposerTimetable { - return new ProposerTimetable({ +): ConsensusTimetable { + const blockDuration = config.blockDurationMs / 1000; + return new ConsensusTimetable({ l1Constants, - blockDuration: config.blockDurationMs / 1000, - minBlockDuration: config.minBlockDuration ?? DEFAULT_MIN_BLOCK_DURATION, - p2pPropagationTime: config.attestationPropagationTime ?? DEFAULT_P2P_PROPAGATION_TIME, - checkpointProposalPrepareTime: config.checkpointProposalPrepareTime ?? DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, - checkpointProposalInitTime: DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, - checkpointProposalSyncGrace: config.checkpointProposalSyncGraceSeconds, - maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint, - logger, + blockDuration, + checkpointProposalSyncGrace: + config.checkpointProposalSyncGraceSeconds ?? getDefaultCheckpointProposalSyncGrace(blockDuration), }); } - interface ValidationResult { name: string; isValid: TxValidationResult; @@ -259,11 +253,9 @@ export class LibP2PService extends WithTracer implements P2PService { this.protocolVersion, ); - // Build the proposer timetable once from protocol slot-timing constants plus the operational budgets - // sourced from p2p config, and inject it into every validator so they share one set of receive-window - // bounds. A ProposerTimetable also satisfies every ConsensusTimetable consumer and lets gossipsub - // scoring derive the same max-blocks-per-checkpoint the proposer uses. - const consensusTimetable = buildProposerTimetable(config, epochCache.getL1Constants(), this.logger); + // Build the consensus timetable once from protocol slot-timing constants and inject it into every + // validator so they share one set of receive-window bounds, independent of proposer operational budgets. + const consensusTimetable = buildConsensusTimetable(config, epochCache.getL1Constants()); const proposalValidatorOpts = { txsPermitted: !config.disableTransactions, maxTxsPerBlock: config.validateMaxTxsPerBlock ?? config.validateMaxTxsPerCheckpoint, @@ -404,15 +396,15 @@ export class LibP2PService extends WithTracer implements P2PService { const announceTcpMultiaddr = config.p2pIp ? [convertToMultiaddr(config.p2pIp, p2pPort, 'tcp')] : []; - // Create dynamic topic score params based on network configuration. The scoring derives its - // max-blocks-per-checkpoint from the same ProposerTimetable budgets the proposer uses, so p2p - // gossipsub scoring stays consistent with block production. + // Create dynamic topic score params based on network configuration. Scoring uses the network-wide + // max-blocks-per-checkpoint config value directly to size expected per-slot message rates; these are + // peer-rate thresholds, not consensus deadlines, so they need no proposer operational budgets. const l1Constants = epochCache.getL1Constants(); const topicScoreParams = createAllTopicScoreParams(protocolVersion, { slotDurationMs: l1Constants.slotDuration * 1000, heartbeatIntervalMs: config.gossipsubInterval, targetCommitteeSize: l1Constants.targetCommitteeSize, - timetable: buildProposerTimetable(config, l1Constants, logger), + maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint ?? DEFAULT_MAX_BLOCKS_PER_CHECKPOINT, expectedBlockProposalsPerSlot: config.expectedBlockProposalsPerSlot, }); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index d60015891bb4..1dac9b5910e6 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -1,4 +1,5 @@ import { getKzg } from '@aztec/blob-lib'; +import { MIN_PER_BLOCK_ALLOCATION_MULTIPLIER } from '@aztec/constants'; import { type EpochCache, PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache'; import { NoCommitteeError, type RollupContract } from '@aztec/ethereum/contracts'; import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; @@ -19,7 +20,7 @@ import type { ValidateCheckpointResult, } from '@aztec/stdlib/block'; import type { Checkpoint, ProposedCheckpointData } from '@aztec/stdlib/checkpoint'; -import { type ChainConfig, MIN_PER_BLOCK_ALLOCATION_MULTIPLIER } from '@aztec/stdlib/config'; +import type { ChainConfig } from '@aztec/stdlib/config'; import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; import { MIN_PER_BLOCK_ALLOCATION_MULTIPLIER, diff --git a/yarn-project/stdlib/src/config/network-consensus-config.test.ts b/yarn-project/stdlib/src/config/network-consensus-config.test.ts index 21ace7257074..0cef8db0c5bb 100644 --- a/yarn-project/stdlib/src/config/network-consensus-config.test.ts +++ b/yarn-project/stdlib/src/config/network-consensus-config.test.ts @@ -1,9 +1,13 @@ +import { l1ContractsConfigMappings } from '@aztec/ethereum/config'; + import { + NETWORK_CONSENSUS_ENV_VARS, type NetworkConsensusConfig, checkConsensusEnvOverrides, getConsensusConfigFromNetworkEnv, validateNetworkConsensusConfig, } from './network-consensus-config.js'; +import { sharedSequencerConfigMappings } from './sequencer-config.js'; describe('validateNetworkConsensusConfig', () => { // Production geometry: the default budgets derive exactly 10 blocks per checkpoint. @@ -89,6 +93,19 @@ describe('getConsensusConfigFromNetworkEnv', () => { checkpointProposalSyncGraceSeconds: 12, }); }); + + it('uses env names that are all consensus-critical', () => { + const pickedEnvNames = [ + l1ContractsConfigMappings.aztecSlotDuration.env, + l1ContractsConfigMappings.ethereumSlotDuration.env, + sharedSequencerConfigMappings.blockDurationMs.env, + sharedSequencerConfigMappings.maxBlocksPerCheckpoint.env, + sharedSequencerConfigMappings.checkpointProposalSyncGraceSeconds.env, + ]; + for (const env of pickedEnvNames) { + expect(NETWORK_CONSENSUS_ENV_VARS).toContain(env); + } + }); }); describe('checkConsensusEnvOverrides', () => { @@ -98,17 +115,19 @@ describe('checkConsensusEnvOverrides', () => { L1_CHAIN_ID: 1, }; - it('leaves unset vars untouched', () => { + it('returns no canonical writes for unset vars', () => { const env: Record = {}; - checkConsensusEnvOverrides(networkConfig, env); + expect(checkConsensusEnvOverrides(networkConfig, env)).toEqual({}); expect(env.SEQ_BLOCK_DURATION_MS).toBeUndefined(); expect(env.L1_CHAIN_ID).toBeUndefined(); }); - it('canonicalizes a numerically-equal value', () => { + it('returns the canonical form of a numerically-equal value without mutating env', () => { const env: Record = { SEQ_BLOCK_DURATION_MS: '6e3' }; - checkConsensusEnvOverrides(networkConfig, env); - expect(env.SEQ_BLOCK_DURATION_MS).toBe('6000'); + const canonical = checkConsensusEnvOverrides(networkConfig, env); + expect(canonical).toEqual({ SEQ_BLOCK_DURATION_MS: '6000' }); + // The check itself is pure: env is untouched. + expect(env.SEQ_BLOCK_DURATION_MS).toBe('6e3'); }); it('throws naming the var on a conflicting value', () => { @@ -122,7 +141,9 @@ describe('checkConsensusEnvOverrides', () => { ALLOW_OVERRIDING_NETWORK_CONFIG: '1', }; const logs: string[] = []; - checkConsensusEnvOverrides(networkConfig, env, msg => logs.push(msg)); + const canonical = checkConsensusEnvOverrides(networkConfig, env, msg => logs.push(msg)); + // A genuine override is not canonicalized: the operator value is kept and absent from the writes. + expect(canonical).toEqual({}); expect(env.SEQ_BLOCK_DURATION_MS).toBe('3000'); expect(logs.some(msg => msg.includes('SEQ_BLOCK_DURATION_MS'))).toBe(true); }); @@ -131,8 +152,8 @@ describe('checkConsensusEnvOverrides', () => { const matching: Record = { AZTEC_SLASHING_VETOER: '0x0000000000000000000000000000000000000000', }; - expect(() => checkConsensusEnvOverrides(networkConfig, matching)).not.toThrow(); - // Non-numeric values are not canonicalized. + // Non-numeric values are never canonicalized, so no writes are returned. + expect(checkConsensusEnvOverrides(networkConfig, matching)).toEqual({}); expect(matching.AZTEC_SLASHING_VETOER).toBe('0x0000000000000000000000000000000000000000'); const conflicting: Record = { @@ -143,7 +164,7 @@ describe('checkConsensusEnvOverrides', () => { it('ignores vars absent from the network config', () => { const env: Record = { AZTEC_SLASHING_QUORUM: '99' }; - expect(() => checkConsensusEnvOverrides(networkConfig, env)).not.toThrow(); + expect(checkConsensusEnvOverrides(networkConfig, env)).toEqual({}); expect(env.AZTEC_SLASHING_QUORUM).toBe('99'); }); }); diff --git a/yarn-project/stdlib/src/config/network-consensus-config.ts b/yarn-project/stdlib/src/config/network-consensus-config.ts index fba6347ad04d..8cc473963576 100644 --- a/yarn-project/stdlib/src/config/network-consensus-config.ts +++ b/yarn-project/stdlib/src/config/network-consensus-config.ts @@ -1,5 +1,7 @@ -import type { EnvVar } from '@aztec/foundation/config'; +import { type L1ContractsConfig, l1ContractsConfigMappings } from '@aztec/ethereum/config'; +import { type EnvVar, pickConfigMappings } from '@aztec/foundation/config'; +import type { SequencerConfig } from '../interfaces/configs.js'; import { DEFAULT_CHECKPOINT_PROPOSAL_INIT_TIME, DEFAULT_CHECKPOINT_PROPOSAL_PREPARE_TIME, @@ -7,20 +9,7 @@ import { DEFAULT_P2P_PROPAGATION_TIME, } from '../timetable/budgets.js'; import { ProposerTimetable } from '../timetable/proposer_timetable.js'; - -/** - * Network-minimum per-block budget multiplier for L2 gas / tx count. Operators may configure a higher value, - * but never lower: a node admitting txs under a smaller multiplier would accept work it can never pack. - */ -export const MIN_PER_BLOCK_ALLOCATION_MULTIPLIER = 1.2; - -/** - * Network-minimum per-block budget multiplier for DA gas / blob fields. See - * {@link MIN_PER_BLOCK_ALLOCATION_MULTIPLIER}. The DA-specific operator knob and its runtime enforcement land - * with the network tx admission limits (#23947); until then this constant is documentation of the network - * minimum only. - */ -export const MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER = 1.5; +import { sharedSequencerConfigMappings } from './sequencer-config.js'; /** * Environment variables whose values must be identical across every node of a network. They fall into three @@ -99,35 +88,51 @@ export const NETWORK_CONSENSUS_ENV_VARS = [ /** A consensus-critical environment variable name; see {@link NETWORK_CONSENSUS_ENV_VARS}. */ export type ConsensusEnvVar = (typeof NETWORK_CONSENSUS_ENV_VARS)[number]; -/** The subset of consensus-critical timing config whose geometry can be validated in isolation. */ -export type NetworkConsensusConfig = { - /** Aztec L2 slot duration in seconds. */ - aztecSlotDuration: number; - /** Ethereum L1 slot duration in seconds. */ - ethereumSlotDuration: number; - /** Duration of a block sub-slot in milliseconds. */ - blockDurationMs: number; - /** Explicit network max blocks per checkpoint (the value the production default budgets must derive). */ - maxBlocksPerCheckpoint: number; - /** Consensus grace for received checkpoint proposals to materialize locally, in seconds. */ - checkpointProposalSyncGraceSeconds: number; +/** + * The subset of consensus-critical timing config whose geometry can be validated in isolation. Composed by + * picking the canonical fields from their owning config types so the field set never drifts from the config + * layer: slot durations from {@link L1ContractsConfig}, block sub-slot/checkpoint timings from + * {@link SequencerConfig} (whose fields are optional there, hence `Required`). + */ +export type NetworkConsensusConfig = Pick & + Required>; + +/** Config mappings for the slot-timing fields of {@link NetworkConsensusConfig}, picked from their owners. */ +const networkConsensusConfigMappings = { + ...pickConfigMappings(l1ContractsConfigMappings, ['aztecSlotDuration', 'ethereumSlotDuration']), + ...pickConfigMappings(sharedSequencerConfigMappings, [ + 'blockDurationMs', + 'maxBlocksPerCheckpoint', + 'checkpointProposalSyncGraceSeconds', + ]), }; /** - * Extracts the timing {@link NetworkConsensusConfig} from a generated network config object. Reads the relevant - * env-var keys and coerces them with `Number()`; missing keys become `NaN`, which - * {@link validateNetworkConsensusConfig} reports as an error. + * Extracts the timing {@link NetworkConsensusConfig} from a generated network config object. The env-var names + * and the per-field parsing both come from the canonical config mappings (`l1ContractsConfigMappings` and + * `sharedSequencerConfigMappings`), so each field is parsed exactly as the node's config layer would parse it. + * A field whose env var is absent becomes `NaN`, which {@link validateNetworkConsensusConfig} reports as an + * error. Never throws: parse helpers that would throw or yield `undefined` are coerced to `NaN`. */ export function getConsensusConfigFromNetworkEnv( values: Record, ): NetworkConsensusConfig { - return { - aztecSlotDuration: Number(values['AZTEC_SLOT_DURATION']), - ethereumSlotDuration: Number(values['ETHEREUM_SLOT_DURATION']), - blockDurationMs: Number(values['SEQ_BLOCK_DURATION_MS']), - maxBlocksPerCheckpoint: Number(values['MAX_BLOCKS_PER_CHECKPOINT']), - checkpointProposalSyncGraceSeconds: Number(values['CHECKPOINT_PROPOSAL_SYNC_GRACE_SECONDS']), - }; + const result = {} as Record; + for (const [field, mapping] of Object.entries(networkConsensusConfigMappings)) { + const raw = mapping.env !== undefined ? values[mapping.env] : undefined; + if (raw === undefined) { + result[field as keyof NetworkConsensusConfig] = NaN; + continue; + } + let parsed: number | undefined; + try { + parsed = mapping.parseEnv ? mapping.parseEnv(String(raw)) : Number(raw); + } catch { + parsed = NaN; + } + result[field as keyof NetworkConsensusConfig] = parsed ?? NaN; + } + return result; } /** @@ -224,16 +229,24 @@ export function validateNetworkConsensusConfig(config: NetworkConsensusConfig): * * For each var in {@link NETWORK_CONSENSUS_ENV_VARS} present in `networkConfig`: if the operator set it in `env` * to a conflicting value, this throws unless `ALLOW_OVERRIDING_NETWORK_CONFIG` is truthy (in which case it logs - * and keeps the operator value). On a numeric match, the env value is canonicalized to the network value's - * string form. This function does not populate unset vars (the caller's enrichment loop does that) and never - * touches `NETWORK`. + * and keeps the operator value). + * + * This function is pure: it never writes to `env`. Instead it returns the canonical env writes the caller + * should apply — a map of env-var name to canonical string value for every numeric var whose env value matched + * the network value numerically. Applying these closes a bypass where the config layer parses some vars with + * `parseInt` (which reads '6e3' as 6); rewriting them to the network value's string form keeps the operator's + * numerically-equal value but in canonical form. Vars kept under `ALLOW_OVERRIDING_NETWORK_CONFIG` (genuine + * conflicts) are not included, so the operator value is preserved untouched. + * + * @returns Canonical env writes (env-var name -> canonical string value) for the caller to apply. */ export function checkConsensusEnvOverrides( networkConfig: Record, env: { [key: string]: string | undefined } = process.env, log?: (msg: string) => void, -): void { +): Record { const allowOverride = allowsNetworkConfigOverride(env); + const canonical: Record = {}; for (const envVar of NETWORK_CONSENSUS_ENV_VARS) { const networkValue = networkConfig[envVar]; @@ -249,10 +262,8 @@ export function checkConsensusEnvOverrides( const networkIsNumeric = typeof networkValue === 'number'; const matches = networkIsNumeric ? Number(current) === networkValue : current === String(networkValue); if (matches) { - // Canonicalize numeric matches: the config layer parses some vars with parseInt, which reads '6e3' as 6. - // Rewriting to the network value's string form closes that bypass. if (networkIsNumeric) { - env[envVar] = String(networkValue); + canonical[envVar] = String(networkValue); } continue; } @@ -267,6 +278,8 @@ export function checkConsensusEnvOverrides( } throw new Error(message); } + + return canonical; } /** Whether the env opts into overriding network-wide consensus values (`ALLOW_OVERRIDING_NETWORK_CONFIG`). */ From a595589ba214bb6ce6273d3549d12453c904b82b Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Wed, 10 Jun 2026 21:59:17 -0300 Subject: [PATCH 07/10] chore(spartan): allow consensus config overrides for networks diverging from generated defaults The network-consensus-config enforcement throws at startup when a node, the deploy-rollup-contracts job, or any pod running the aztec entrypoint with a known NETWORK sets a consensus-critical env var that differs from the generated network defaults. devnet (36s slots, committee size 1) and testnet (slashing round size 2 epochs) intentionally diverge, so they now set ALLOW_OVERRIDING_NETWORK_CONFIG=true. Plumb that flag from the env files through deploy_network.sh into both the deploy-rollup-contracts job env and the deploy-aztec-infra helm releases (via a new global rendered by the shared aztec-node pod template, reaching validators, rpc/full nodes, prover, fisherman, bootnode and bots). Also pass AZTEC_SLOT_DURATION/AZTEC_EPOCH_DURATION into node pods so devnet nodes carry the real deployed 36s slot value and pass the rollup cross-check instead of inheriting the generated 72s default. --- .../aztec-node/templates/_pod-template.yaml | 12 ++++++++++++ spartan/aztec-node/values.yaml | 5 +++++ spartan/environments/devnet.env | 3 +++ spartan/environments/testnet.env | 3 +++ spartan/scripts/deploy_network.sh | 9 +++++++++ spartan/terraform/deploy-aztec-infra/main.tf | 3 +++ .../terraform/deploy-aztec-infra/variables.tf | 18 ++++++++++++++++++ .../terraform/deploy-rollup-contracts/main.tf | 1 + .../deploy-rollup-contracts/variables.tf | 6 ++++++ 9 files changed, 60 insertions(+) diff --git a/spartan/aztec-node/templates/_pod-template.yaml b/spartan/aztec-node/templates/_pod-template.yaml index bfeb7e899cf9..6fe273d616ff 100644 --- a/spartan/aztec-node/templates/_pod-template.yaml +++ b/spartan/aztec-node/templates/_pod-template.yaml @@ -182,6 +182,18 @@ spec: - name: NETWORK value: "{{ .Values.global.aztecNetwork }}" {{- end }} + {{- if .Values.global.allowOverridingNetworkConfig }} + - name: ALLOW_OVERRIDING_NETWORK_CONFIG + value: "{{ .Values.global.allowOverridingNetworkConfig }}" + {{- end }} + {{- if .Values.global.aztecSlotDuration }} + - name: AZTEC_SLOT_DURATION + value: "{{ .Values.global.aztecSlotDuration }}" + {{- end }} + {{- if .Values.global.aztecEpochDuration }} + - name: AZTEC_EPOCH_DURATION + value: "{{ .Values.global.aztecEpochDuration }}" + {{- end }} - name: NODE_OPTIONS value: {{ join " " .Values.node.nodeJsOptions | quote }} - name: AZTEC_PORT diff --git a/spartan/aztec-node/values.yaml b/spartan/aztec-node/values.yaml index b9d8732f434d..13102e678139 100644 --- a/spartan/aztec-node/values.yaml +++ b/spartan/aztec-node/values.yaml @@ -15,6 +15,11 @@ global: aztecRollupVersion: "canonical" # -- Network name - this is a predefined network - alpha-testnet, devnet aztecNetwork: "" + # -- Allow this deployment's consensus-critical env vars to diverge from the generated defaults for aztecNetwork + allowOverridingNetworkConfig: "" + # -- Slot/epoch durations to pass through when a network's deployed rollup diverges from the generated defaults + aztecSlotDuration: "" + aztecEpochDuration: "" # -- Custom network - (not recommended) - Only for custom testnet usecases, (must have deployed your own protocol contracts first) customAztecNetwork: l1ChainId: diff --git a/spartan/environments/devnet.env b/spartan/environments/devnet.env index 0741b4751f7e..5a8da165d9fb 100644 --- a/spartan/environments/devnet.env +++ b/spartan/environments/devnet.env @@ -37,6 +37,9 @@ USE_NETWORK_CONFIG=${USE_NETWORK_CONFIG:-false} DEPLOY_INTERNAL_BOOTNODE=false +# devnet intentionally diverges from the generated devnet network defaults (36s slots vs 72s, committee size 1 +# vs 48), so the consensus-config enforcement must permit these overrides instead of failing at startup. +ALLOW_OVERRIDING_NETWORK_CONFIG=true AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET=1 AZTEC_LAG_IN_EPOCHS_FOR_RANDAO=1 AZTEC_SLOT_DURATION=36 diff --git a/spartan/environments/testnet.env b/spartan/environments/testnet.env index c25b3c552fe6..613a48747e4f 100644 --- a/spartan/environments/testnet.env +++ b/spartan/environments/testnet.env @@ -6,6 +6,9 @@ NAMESPACE=${NAMESPACE:-testnet} NETWORK=testnet REAL_VERIFIER=true +# testnet intentionally diverges from the generated testnet network defaults (slashing round size 2 epochs vs +# 4), so the consensus-config enforcement must permit these overrides instead of failing at startup. +ALLOW_OVERRIDING_NETWORK_CONFIG=true AZTEC_ENTRY_QUEUE_BOOTSTRAP_VALIDATOR_SET_SIZE=48 AZTEC_ENTRY_QUEUE_BOOTSTRAP_FLUSH_SIZE=48 AZTEC_ENTRY_QUEUE_FLUSH_SIZE_MIN=10 diff --git a/spartan/scripts/deploy_network.sh b/spartan/scripts/deploy_network.sh index 5466deabd4fd..1046cd2d31b5 100755 --- a/spartan/scripts/deploy_network.sh +++ b/spartan/scripts/deploy_network.sh @@ -49,6 +49,11 @@ BASE_STATE_PATH="${CLUSTER}/${NAMESPACE}" # Don't try and retrieve contract addresses, instead allow deployed infra to read from network config USE_NETWORK_CONFIG=${USE_NETWORK_CONFIG:-false} +# Allow this deployment's consensus-critical env vars to diverge from the generated network defaults that the +# aztec entrypoint enforces for a known NETWORK. Networks that intentionally override those defaults (e.g. +# devnet's 36s slots) set this to true in their env file; left unset it stays empty so enforcement is loud. +ALLOW_OVERRIDING_NETWORK_CONFIG=${ALLOW_OVERRIDING_NETWORK_CONFIG:-} + # GCP variables, unused if running on kind GCP_PROJECT_ID=${GCP_PROJECT_ID:-testnet-440309} GCP_REGION=${GCP_REGION:-us-west1-a} @@ -466,6 +471,7 @@ AZTEC_PROVING_COST_PER_MANA = ${AZTEC_PROVING_COST_PER_MANA:-null} AZTEC_EXIT_DELAY_SECONDS = ${AZTEC_EXIT_DELAY_SECONDS:-null} ETHERSCAN_API_KEY = ${ETHERSCAN_API_KEY_TF} NETWORK = $(tf_str "${NETWORK:-}") +ALLOW_OVERRIDING_NETWORK_CONFIG = $(tf_str "${ALLOW_OVERRIDING_NETWORK_CONFIG:-}") JOB_NAME = "deploy-rollup-contracts" JOB_BACKOFF_LIMIT = 3 JOB_TTL_SECONDS_AFTER_FINISHED = 3600 @@ -607,6 +613,9 @@ DEPLOY_INTERNAL_BOOTNODE = ${DEPLOY_INTERNAL_BOOTNODE:-true} PROVER_REAL_PROOFS = ${PROVER_REAL_PROOFS} TRANSACTIONS_DISABLED = ${TRANSACTIONS_DISABLED:-null} NETWORK = $(tf_str "${NETWORK:-}") +ALLOW_OVERRIDING_NETWORK_CONFIG = $(tf_str "${ALLOW_OVERRIDING_NETWORK_CONFIG:-}") +AZTEC_SLOT_DURATION = ${AZTEC_SLOT_DURATION:-null} +AZTEC_EPOCH_DURATION = ${AZTEC_EPOCH_DURATION:-null} STORE_SNAPSHOT_URL = ${STORE_SNAPSHOT_URL_TF} BOT_RESOURCE_PROFILE = "${BOT_RESOURCE_PROFILE}" BOT_MNEMONIC = "${LABS_INFRA_MNEMONIC}" diff --git a/spartan/terraform/deploy-aztec-infra/main.tf b/spartan/terraform/deploy-aztec-infra/main.tf index 8ada41a78365..97f76e9d7cc8 100644 --- a/spartan/terraform/deploy-aztec-infra/main.tf +++ b/spartan/terraform/deploy-aztec-infra/main.tf @@ -116,6 +116,9 @@ locals { "global.aztecImage.pullPolicy" = local.is_kind ? "IfNotPresent" : "Always" "global.useGcloudLogging" = true "global.aztecNetwork" = var.NETWORK + "global.allowOverridingNetworkConfig" = var.ALLOW_OVERRIDING_NETWORK_CONFIG + "global.aztecSlotDuration" = var.AZTEC_SLOT_DURATION + "global.aztecEpochDuration" = var.AZTEC_EPOCH_DURATION "global.customAztecNetwork.registryContractAddress" = var.REGISTRY_CONTRACT_ADDRESS "global.customAztecNetwork.feeAssetHandlerContractAddress" = var.FEE_ASSET_HANDLER_CONTRACT_ADDRESS "global.customAztecNetwork.l1ChainId" = var.L1_CHAIN_ID diff --git a/spartan/terraform/deploy-aztec-infra/variables.tf b/spartan/terraform/deploy-aztec-infra/variables.tf index c3add7304d9d..af2e98b46555 100644 --- a/spartan/terraform/deploy-aztec-infra/variables.tf +++ b/spartan/terraform/deploy-aztec-infra/variables.tf @@ -549,6 +549,24 @@ variable "NETWORK" { nullable = true } +variable "ALLOW_OVERRIDING_NETWORK_CONFIG" { + description = "Allow consensus-critical env vars to diverge from the generated network defaults for NETWORK" + type = string + nullable = true +} + +variable "AZTEC_SLOT_DURATION" { + description = "Aztec slot duration; passed to nodes so they match a rollup deployed with a non-default value" + type = string + nullable = true +} + +variable "AZTEC_EPOCH_DURATION" { + description = "Aztec epoch duration; passed to nodes so they match a rollup deployed with a non-default value" + type = string + nullable = true +} + variable "STORE_SNAPSHOT_URL" { description = "Location to store snapshots in" type = string diff --git a/spartan/terraform/deploy-rollup-contracts/main.tf b/spartan/terraform/deploy-rollup-contracts/main.tf index 23147f1cf70a..690ef063b2e3 100644 --- a/spartan/terraform/deploy-rollup-contracts/main.tf +++ b/spartan/terraform/deploy-rollup-contracts/main.tf @@ -37,6 +37,7 @@ locals { # Environment variables for the container (omit keys with null values) env_vars = { for k, v in { NETWORK = var.NETWORK + ALLOW_OVERRIDING_NETWORK_CONFIG = var.ALLOW_OVERRIDING_NETWORK_CONFIG AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET = var.AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET AZTEC_LAG_IN_EPOCHS_FOR_RANDAO = var.AZTEC_LAG_IN_EPOCHS_FOR_RANDAO AZTEC_SLOT_DURATION = var.AZTEC_SLOT_DURATION diff --git a/spartan/terraform/deploy-rollup-contracts/variables.tf b/spartan/terraform/deploy-rollup-contracts/variables.tf index 8ff087663ab2..f0031b7768ed 100644 --- a/spartan/terraform/deploy-rollup-contracts/variables.tf +++ b/spartan/terraform/deploy-rollup-contracts/variables.tf @@ -248,6 +248,12 @@ variable "NETWORK" { nullable = true } +variable "ALLOW_OVERRIDING_NETWORK_CONFIG" { + description = "Allow consensus-critical env vars to diverge from the generated network defaults for NETWORK" + type = string + nullable = true +} + variable "VERIFY_CONTRACTS" { description = "Verify contracts on Etherscan" type = bool From 316feec11d64584aba7a115a7c8107eba2bb4b0a Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 11 Jun 2026 09:41:19 -0300 Subject: [PATCH 08/10] refactor: bake devnet divergences into preset and accumulate consensus conflicts Move devnet's 36s slot duration and single-validator committee size into the generated devnet network defaults (along with the derived MAX_BLOCKS_PER_CHECKPOINT) so devnet no longer diverges from its preset, removing the need for ALLOW_OVERRIDING_NETWORK_CONFIG. Accumulate all consensus-critical env var conflicts before throwing so operators see every var to reconcile at once. --- spartan/environments/devnet.env | 3 --- spartan/environments/network-defaults.yml | 7 +++++- .../src/config/network-consensus-config.ts | 25 ++++++++++++++----- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/spartan/environments/devnet.env b/spartan/environments/devnet.env index 5a8da165d9fb..0741b4751f7e 100644 --- a/spartan/environments/devnet.env +++ b/spartan/environments/devnet.env @@ -37,9 +37,6 @@ USE_NETWORK_CONFIG=${USE_NETWORK_CONFIG:-false} DEPLOY_INTERNAL_BOOTNODE=false -# devnet intentionally diverges from the generated devnet network defaults (36s slots vs 72s, committee size 1 -# vs 48), so the consensus-config enforcement must permit these overrides instead of failing at startup. -ALLOW_OVERRIDING_NETWORK_CONFIG=true AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET=1 AZTEC_LAG_IN_EPOCHS_FOR_RANDAO=1 AZTEC_SLOT_DURATION=36 diff --git a/spartan/environments/network-defaults.yml b/spartan/environments/network-defaults.yml index 59ceb9c6587b..8ca3e5e0a014 100644 --- a/spartan/environments/network-defaults.yml +++ b/spartan/environments/network-defaults.yml @@ -211,10 +211,15 @@ _prodlike: &prodlike networks: devnet: <<: *prodlike - # L1 contract overrides - faster epochs for development + # L1 contract overrides - faster epochs and shorter slots for development + AZTEC_SLOT_DURATION: 36 AZTEC_EPOCH_DURATION: 8 AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET: 1 AZTEC_LAG_IN_EPOCHS_FOR_RANDAO: 1 + # Single-validator dev network. + AZTEC_TARGET_COMMITTEE_SIZE: 1 + # Must equal what the default proposer budgets derive for a 36s slot / 6s block (see chain_l2_config.test.ts). + MAX_BLOCKS_PER_CHECKPOINT: 4 AZTEC_SLASHING_EXECUTION_DELAY_IN_ROUNDS: 1 # Quorums made explicit to match the Solidity vm.envOr default (round_size / 2 + 1), unchanged behavior. # Slashing: AZTEC_SLASHING_ROUND_SIZE_IN_EPOCHS (4) * AZTEC_EPOCH_DURATION (8) = 32 slots; 32 / 2 + 1 = 17. diff --git a/yarn-project/stdlib/src/config/network-consensus-config.ts b/yarn-project/stdlib/src/config/network-consensus-config.ts index 8cc473963576..587405b64568 100644 --- a/yarn-project/stdlib/src/config/network-consensus-config.ts +++ b/yarn-project/stdlib/src/config/network-consensus-config.ts @@ -247,6 +247,7 @@ export function checkConsensusEnvOverrides( ): Record { const allowOverride = allowsNetworkConfigOverride(env); const canonical: Record = {}; + const conflicts: string[] = []; for (const envVar of NETWORK_CONSENSUS_ENV_VARS) { const networkValue = networkConfig[envVar]; @@ -268,15 +269,27 @@ export function checkConsensusEnvOverrides( continue; } - const message = - `Environment variable ${envVar}=${current} conflicts with the network value ${networkValue}. ` + - `Consensus-critical values must match across the network. Set ALLOW_OVERRIDING_NETWORK_CONFIG=1 to override ` + - `(only do this if you know what you are doing).`; + const conflict = `${envVar}=${current} conflicts with the network value ${networkValue}`; if (allowOverride) { - log?.(message); + log?.( + `Environment variable ${conflict}. Consensus-critical values must match across the network, but ` + + `ALLOW_OVERRIDING_NETWORK_CONFIG is set so the operator value is kept (only do this if you know what ` + + `you are doing).`, + ); continue; } - throw new Error(message); + conflicts.push(conflict); + } + + // Accumulate every conflict so the operator sees all the env vars they need to reconcile at once, rather than + // fixing them one failed startup at a time. + if (conflicts.length > 0) { + throw new Error( + `Environment variables conflict with consensus-critical network values:\n` + + conflicts.map(c => ` - ${c}`).join('\n') + + `\nConsensus-critical values must match across the network. Set ALLOW_OVERRIDING_NETWORK_CONFIG=1 to ` + + `override (only do this if you know what you are doing).`, + ); } return canonical; From 2faaec8921518b03fbeb696c3aae4d4c08e7a74e Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 11 Jun 2026 10:05:22 -0300 Subject: [PATCH 09/10] fix: drop sequencer clamp-warning superseded by timetable refactor The merge-train refactor centralized maxBlocksPerCheckpoint clamp handling into the ProposerTimetable constructor and removed the isClampedByLocalBudgets and locallyAchievableBlocksPerCheckpoint accessors the sequencer-side warning relied on. With maxBlocksPerCheckpoint now validated to equal the derived value, that warning is vestigial; remove it (and a duplicate MIN_PER_BLOCK_ALLOCATION_MULTIPLIER import) to fix the clean compile. --- .../sequencer-client/src/sequencer/sequencer.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 1dac9b5910e6..e25fb8f11834 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -1,5 +1,4 @@ import { getKzg } from '@aztec/blob-lib'; -import { MIN_PER_BLOCK_ALLOCATION_MULTIPLIER } from '@aztec/constants'; import { type EpochCache, PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache'; import { NoCommitteeError, type RollupContract } from '@aztec/ethereum/contracts'; import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; @@ -199,19 +198,6 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter Date: Thu, 11 Jun 2026 10:33:26 -0300 Subject: [PATCH 10/10] test: add 5ms clock tolerance to tx_collection deadline assertion The 'stops collecting a tx from nodes when found' test asserted dateProvider.now() >= deadline with zero tolerance, but RequestTracker's setTimeout can fire a couple ms before the clock catches up (flaked at -2ms). Mirror the existing tolerance already applied to the sibling 'keeps retrying' assertion. --- .../p2p/src/services/tx_collection/tx_collection.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/yarn-project/p2p/src/services/tx_collection/tx_collection.test.ts b/yarn-project/p2p/src/services/tx_collection/tx_collection.test.ts index 5cb61cbeedd9..d472ac3d4c13 100644 --- a/yarn-project/p2p/src/services/tx_collection/tx_collection.test.ts +++ b/yarn-project/p2p/src/services/tx_collection/tx_collection.test.ts @@ -262,7 +262,8 @@ describe('TxCollection', () => { expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[3]]); expect(nodes[0].getTxsByHash).not.toHaveBeenCalledWith(txHashes); - expect(dateProvider.now()).toBeGreaterThanOrEqual(+deadline); + // Allow 5ms tolerance: setTimeout in RequestTracker can fire slightly before dateProvider.now() catches up + expect(dateProvider.now()).toBeGreaterThanOrEqual(+deadline - 5); expect(sortByHash(collected)).toEqual(sortByHash([txs[0], txs[1], txs[2]])); });