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/network-defaults.yml b/spartan/environments/network-defaults.yml index 6a445101da81..8ca3e5e0a014 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) @@ -207,11 +211,21 @@ _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. + 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/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 diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 21cdf1101744..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 { @@ -590,9 +589,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); @@ -615,14 +615,13 @@ 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, { + 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); const initialHeader = nativeWs.getInitialHeader(); @@ -1053,6 +1052,32 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb } } + /** + * 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 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}`, + ); + } + 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('; ')}`, + ); + } + } + /** * Returns the sequencer client instance. * @returns The sequencer client instance. 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..3f5dd500ff90 --- /dev/null +++ b/yarn-project/cli/src/config/chain_l2_config.test.ts @@ -0,0 +1,91 @@ +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 { type ConsensusComplete, 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); + }); + }); + } +}); + +// 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 }; + + 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'); + }); + + 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 75f69e615a00..d0a892f38702 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. */ +export 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,9 @@ export function enrichEnvironmentWithChainName(networkName: NetworkNames) { const configKey = /^v\d+-devnet-\d+$/.test(networkName) ? 'devnet' : networkName; const generatedConfig = NetworkConfigs[configKey]; if (generatedConfig) { + // 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/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/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 9bb9ae17b8b9..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'; @@ -118,6 +119,24 @@ import type { } from '../service.js'; import { P2PInstrumentation } from './instrumentation.js'; +/** + * 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 buildConsensusTimetable( + config: P2PConfig, + l1Constants: ReturnType, +): ConsensusTimetable { + const blockDuration = config.blockDurationMs / 1000; + return new ConsensusTimetable({ + l1Constants, + blockDuration, + checkpointProposalSyncGrace: + config.checkpointProposalSyncGraceSeconds ?? getDefaultCheckpointProposalSyncGrace(blockDuration), + }); +} + interface ValidationResult { name: string; isValid: TxValidationResult; @@ -234,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()); + // 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, @@ -379,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), + maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint ?? DEFAULT_MAX_BLOCKS_PER_CHECKPOINT, expectedBlockProposalsPerSlot: config.expectedBlockProposalsPerSlot, }); 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/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..e25fb8f11834 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -198,13 +198,6 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter { + // Production geometry: the default budgets derive exactly 10 blocks per checkpoint. + const base: NetworkConsensusConfig = { + aztecSlotDuration: 72, + ethereumSlotDuration: 12, + blockDurationMs: 6000, + maxBlocksPerCheckpoint: 10, + checkpointProposalSyncGraceSeconds: 12, + }; + + it('returns no errors for a sound config', () => { + expect(validateNetworkConsensusConfig(base)).toEqual([]); + }); + + 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('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('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('errors when blockDurationMs is non-positive', () => { + expect(validateNetworkConsensusConfig({ ...base, blockDurationMs: 0 })).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'), + ); + }); + + it('errors for a non-finite value', () => { + expect(validateNetworkConsensusConfig({ ...base, blockDurationMs: NaN })).toContainEqual( + expect.stringContaining('blockDurationMs'), + ); + }); + + 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('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'); + }); +}); + +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, + }); + }); + + 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', () => { + const networkConfig = { + SEQ_BLOCK_DURATION_MS: 6000, + AZTEC_SLASHING_VETOER: '0x0000000000000000000000000000000000000000', + L1_CHAIN_ID: 1, + }; + + it('returns no canonical writes for unset vars', () => { + const env: Record = {}; + expect(checkConsensusEnvOverrides(networkConfig, env)).toEqual({}); + expect(env.SEQ_BLOCK_DURATION_MS).toBeUndefined(); + expect(env.L1_CHAIN_ID).toBeUndefined(); + }); + + it('returns the canonical form of a numerically-equal value without mutating env', () => { + const env: Record = { SEQ_BLOCK_DURATION_MS: '6e3' }; + 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', () => { + const env: Record = { SEQ_BLOCK_DURATION_MS: '3000' }; + expect(() => checkConsensusEnvOverrides(networkConfig, 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[] = []; + 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); + }); + + it('compares non-numeric values as strings', () => { + const matching: Record = { + AZTEC_SLASHING_VETOER: '0x0000000000000000000000000000000000000000', + }; + // 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 = { + AZTEC_SLASHING_VETOER: '0xdfe19Da6a717b7088621d8bBB66be59F2d78e924', + }; + expect(() => checkConsensusEnvOverrides(networkConfig, conflicting)).toThrow(/AZTEC_SLASHING_VETOER/); + }); + + it('ignores vars absent from the network config', () => { + const env: Record = { AZTEC_SLASHING_QUORUM: '99' }; + 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 new file mode 100644 index 000000000000..587405b64568 --- /dev/null +++ b/yarn-project/stdlib/src/config/network-consensus-config.ts @@ -0,0 +1,302 @@ +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, + DEFAULT_MIN_BLOCK_DURATION, + DEFAULT_P2P_PROPAGATION_TIME, +} from '../timetable/budgets.js'; +import { ProposerTimetable } from '../timetable/proposer_timetable.js'; +import { sharedSequencerConfigMappings } from './sequencer-config.js'; + +/** + * 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. 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. 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 { + 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; +} + +/** + * 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. + * + * 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): string[] { + const errors: 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; + } + + 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.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)`, + ); + } + 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 (errors.length > 0) { + return errors; + } + + 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( + `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 (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`, + ); + } + + return errors; +} + +/** + * Enforces that operators do not silently override consensus-critical values diverging from the network config. + * + * 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). + * + * 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, +): Record { + const allowOverride = allowsNetworkConfigOverride(env); + const canonical: Record = {}; + const conflicts: string[] = []; + + 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; + } + + const networkIsNumeric = typeof networkValue === 'number'; + const matches = networkIsNumeric ? Number(current) === networkValue : current === String(networkValue); + if (matches) { + if (networkIsNumeric) { + canonical[envVar] = String(networkValue); + } + continue; + } + + const conflict = `${envVar}=${current} conflicts with the network value ${networkValue}`; + if (allowOverride) { + 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; + } + 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; +} + +/** Whether the env opts into overriding network-wide consensus values (`ALLOW_OVERRIDING_NETWORK_CONFIG`). */ +export function allowsNetworkConfigOverride(env: { [key: string]: string | undefined } = process.env): boolean { + const value = env.ALLOW_OVERRIDING_NETWORK_CONFIG; + 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..d10ccf37d2a7 100644 --- a/yarn-project/stdlib/src/timetable/README.md +++ b/yarn-project/stdlib/src/timetable/README.md @@ -275,12 +275,18 @@ 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 compute is: ```text -max_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 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". 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..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'; @@ -354,3 +357,66 @@ 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 computed count', () => { + const timetable = makeProposerTimetable({ ...productionOpts, maxBlocksPerCheckpoint: 4 }); + expect(timetable.getMaxBlocksPerCheckpoint()).toBe(4); + }); + + 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); + }); + + 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 computed count when no network value is given', () => { + const timetable = makeProposerTimetable(productionOpts); + expect(timetable.getMaxBlocksPerCheckpoint()).toBe(10); + }); + + 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', () => { + 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..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. */ @@ -32,7 +37,11 @@ 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 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; constructor(opts: { @@ -43,6 +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 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, @@ -66,7 +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.maxBlocksPerCheckpoint = this.computeMaxBlocksPerCheckpoint(); + const computed = this.computeMaxBlocksPerCheckpoint(); + this.maxBlocksPerCheckpoint = + 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 ` +