diff --git a/barretenberg/cpp/src/barretenberg/vm2/testing/avm_inputs.testdata.bin b/barretenberg/cpp/src/barretenberg/vm2/testing/avm_inputs.testdata.bin index 13cf29d653c4..515bb19cc8da 100644 Binary files a/barretenberg/cpp/src/barretenberg/vm2/testing/avm_inputs.testdata.bin and b/barretenberg/cpp/src/barretenberg/vm2/testing/avm_inputs.testdata.bin differ diff --git a/barretenberg/cpp/src/barretenberg/vm2/testing/minimal_tx.testdata.bin b/barretenberg/cpp/src/barretenberg/vm2/testing/minimal_tx.testdata.bin index 10e8459aa257..469535671806 100644 Binary files a/barretenberg/cpp/src/barretenberg/vm2/testing/minimal_tx.testdata.bin and b/barretenberg/cpp/src/barretenberg/vm2/testing/minimal_tx.testdata.bin differ diff --git a/docs/docs-operate/operators/reference/changelog/v4.md b/docs/docs-operate/operators/reference/changelog/v4.md index dc8e0cce3d35..97a762c6cc67 100644 --- a/docs/docs-operate/operators/reference/changelog/v4.md +++ b/docs/docs-operate/operators/reference/changelog/v4.md @@ -70,6 +70,31 @@ The `getL2Tips()` RPC endpoint now returns a restructured response with addition - Replace `tips.latest` with `tips.proposed` - For `checkpointed`, `proven`, and `finalized` tips, access block info via `.block` (e.g., `tips.proven.block.number`) +### Block gas limits reworked + +The byte-based block size limit has been removed and replaced with field-based blob limits and automatic gas budget computation from L1 rollup limits. + +**Removed:** + +```bash +--maxBlockSizeInBytes ($SEQ_MAX_BLOCK_SIZE_IN_BYTES) +``` + +**Changed to optional (now auto-computed from L1 if not set):** + +```bash +--maxL2BlockGas ($SEQ_MAX_L2_BLOCK_GAS) +--maxDABlockGas ($SEQ_MAX_DA_BLOCK_GAS) +``` + +**New:** + +```bash +--gasPerBlockAllocationMultiplier ($SEQ_GAS_PER_BLOCK_ALLOCATION_MULTIPLIER) +``` + +**Migration**: Remove `SEQ_MAX_BLOCK_SIZE_IN_BYTES` from your configuration. Per-block L2 and DA gas budgets are now derived automatically as `(checkpointLimit / maxBlocks) * multiplier`, where the multiplier defaults to 2. You can still override `SEQ_MAX_L2_BLOCK_GAS` and `SEQ_MAX_DA_BLOCK_GAS` explicitly, but they will be capped at the checkpoint-level limits. + ### Setup phase allow list requires function selectors The transaction setup phase allow list now enforces function selectors, restricting which specific functions can run during setup on whitelisted contracts. Previously, any public function on a whitelisted contract or class was permitted. @@ -117,11 +142,13 @@ This replaces the previous hardcoded default and allows network operators to set Node operators can now update validator attester keys, coinbase, and fee recipient without restarting the node by calling the new `reloadKeystore` admin RPC endpoint. What is updated on reload: + - Validator attester keys (add, remove, or replace) - Coinbase and fee recipient per validator - Publisher-to-validator mapping What is NOT updated (requires restart): + - L1 publisher signers - Prover keys - HA signer connections @@ -133,6 +160,7 @@ New validators must use a publisher key already initialized at startup. Reload i The admin JSON-RPC endpoint now supports auto-generated API key authentication. **Behavior:** + - A cryptographically secure API key is auto-generated at first startup and displayed once via stdout - Only the SHA-256 hash is persisted to `/admin/api_key_hash` - The key is reused across restarts when `--data-directory` is set diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr index 98dbda352970..483824ce5ccf 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr @@ -1064,7 +1064,7 @@ pub global GAS_ESTIMATION_DA_GAS_LIMIT: u32 = GAS_ESTIMATION_TEARDOWN_DA_GAS_LIMIT + MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT; // Default gas limits. Users should use gas estimation, or they will overpay gas fees. -// TODO: consider moving to typescript +// TODO: These are overridden in typescript-land. Remove them from here. pub global DEFAULT_TEARDOWN_L2_GAS_LIMIT: u32 = 1_000_000; // Arbitrary default number. pub global DEFAULT_L2_GAS_LIMIT: u32 = MAX_PROCESSABLE_L2_GAS; // Arbitrary default number. pub global DEFAULT_TEARDOWN_DA_GAS_LIMIT: u32 = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT / 2; // Arbitrary default number. diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 5d8bc7bd69d8..0fe264bf11e6 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -271,10 +271,11 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { config.l1Contracts = { ...config.l1Contracts, ...l1ContractsAddresses }; const rollupContract = new RollupContract(publicClient, config.l1Contracts.rollupAddress.toString()); - const [l1GenesisTime, slotDuration, rollupVersionFromRollup] = await Promise.all([ + const [l1GenesisTime, slotDuration, rollupVersionFromRollup, rollupManaLimit] = await Promise.all([ rollupContract.getL1GenesisTime(), rollupContract.getSlotDuration(), rollupContract.getVersion(), + rollupContract.getManaLimit().then(Number), ] as const); config.rollupVersion ??= Number(rollupVersionFromRollup); @@ -347,7 +348,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { // Create FullNodeCheckpointsBuilder for block proposal handling and tx validation const validatorCheckpointsBuilder = new FullNodeCheckpointsBuilder( - { ...config, l1GenesisTime, slotDuration: Number(slotDuration) }, + { ...config, l1GenesisTime, slotDuration: Number(slotDuration), rollupManaLimit }, worldStateSynchronizer, archiver, dateProvider, @@ -484,7 +485,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { // Create and start the sequencer client const checkpointsBuilder = new CheckpointsBuilder( - { ...config, l1GenesisTime, slotDuration: Number(slotDuration) }, + { ...config, l1GenesisTime, slotDuration: Number(slotDuration), rollupManaLimit }, worldStateSynchronizer, archiver, dateProvider, @@ -1275,7 +1276,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { const processor = publicProcessorFactory.create(merkleTreeFork, newGlobalVariables, config); // REFACTOR: Consider merging ProcessReturnValues into ProcessedTx - const [processedTxs, failedTxs, _usedTxs, returns, _blobFields, debugLogs] = await processor.process([tx]); + const [processedTxs, failedTxs, _usedTxs, returns, debugLogs] = await processor.process([tx]); // REFACTOR: Consider returning the error rather than throwing if (failedTxs.length) { this.log.warn(`Simulated tx ${txHash} fails: ${failedTxs[0].error}`, { txHash }); diff --git a/yarn-project/bb-prover/src/avm_proving_tests/avm_check_circuit3.test.ts b/yarn-project/bb-prover/src/avm_proving_tests/avm_check_circuit3.test.ts index 79f9b28cdfb5..c1e8e4d8d686 100644 --- a/yarn-project/bb-prover/src/avm_proving_tests/avm_check_circuit3.test.ts +++ b/yarn-project/bb-prover/src/avm_proving_tests/avm_check_circuit3.test.ts @@ -1,8 +1,10 @@ +import { DEFAULT_L2_GAS_LIMIT, MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT } from '@aztec/constants'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { AvmTestContractArtifact } from '@aztec/noir-test-contracts.js/AvmTest'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { ContractInstanceWithAddress } from '@aztec/stdlib/contract'; +import { Gas } from '@aztec/stdlib/gas'; import { L2ToL1Message, ScopedL2ToL1Message } from '@aztec/stdlib/messaging'; import { NativeWorldStateService } from '@aztec/world-state'; @@ -187,9 +189,14 @@ describe('AVM check-circuit – unhappy paths 3', () => { it( 'a nested exceptional halt is recovered from in caller', async () => { + // The contract requires >200k DA gas (it allocates da_gas_left - 200_000 to the nested call). + // Use a higher DA gas limit than the default since DEFAULT_DA_GAS_LIMIT is ~196k. + const gasLimits = new Gas(MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT, DEFAULT_L2_GAS_LIMIT); await tester.simProveVerifyAppLogic( { address: avmTestContractInstance.address, fnName: 'external_call_to_divide_by_zero_recovers', args: [] }, /*expectRevert=*/ false, + /*txLabel=*/ 'unlabeledTx', + gasLimits, ); }, TIMEOUT, diff --git a/yarn-project/bb-prover/src/avm_proving_tests/avm_proving_tester.ts b/yarn-project/bb-prover/src/avm_proving_tests/avm_proving_tester.ts index 54b9292be26e..2fc15a2599a9 100644 --- a/yarn-project/bb-prover/src/avm_proving_tests/avm_proving_tester.ts +++ b/yarn-project/bb-prover/src/avm_proving_tests/avm_proving_tester.ts @@ -10,6 +10,7 @@ import { import type { PublicTxResult } from '@aztec/simulator/server'; import { AvmCircuitInputs, AvmCircuitPublicInputs, PublicSimulatorConfig } from '@aztec/stdlib/avm'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { Gas } from '@aztec/stdlib/gas'; import type { MerkleTreeWriteOperations } from '@aztec/stdlib/interfaces/server'; import type { GlobalVariables } from '@aztec/stdlib/tx'; import { NativeWorldStateService } from '@aztec/world-state'; @@ -211,6 +212,7 @@ export class AvmProvingTester extends PublicTxSimulationTester { privateInsertions?: TestPrivateInsertions, txLabel: string = 'unlabeledTx', disableRevertCheck: boolean = false, + gasLimits?: Gas, ): Promise { const simTimer = new Timer(); const simRes = await this.simulateTx( @@ -221,6 +223,7 @@ export class AvmProvingTester extends PublicTxSimulationTester { feePayer, privateInsertions, txLabel, + gasLimits, ); const simDuration = simTimer.ms(); this.logger.info(`Simulation took ${simDuration} ms for tx ${txLabel}`); @@ -247,6 +250,7 @@ export class AvmProvingTester extends PublicTxSimulationTester { teardownCall?: TestEnqueuedCall, feePayer?: AztecAddress, privateInsertions?: TestPrivateInsertions, + gasLimits?: Gas, ) { return await this.simProveVerify( sender, @@ -258,6 +262,7 @@ export class AvmProvingTester extends PublicTxSimulationTester { privateInsertions, txLabel, true, + gasLimits, ); } @@ -265,6 +270,7 @@ export class AvmProvingTester extends PublicTxSimulationTester { appCall: TestEnqueuedCall, expectRevert?: boolean, txLabel: string = 'unlabeledTx', + gasLimits?: Gas, ) { await this.simProveVerify( /*sender=*/ AztecAddress.fromNumber(42), @@ -275,6 +281,8 @@ export class AvmProvingTester extends PublicTxSimulationTester { /*feePayer=*/ undefined, /*privateInsertions=*/ undefined, txLabel, + /*disableRevertCheck=*/ false, + gasLimits, ); } } diff --git a/yarn-project/constants/src/constants.ts b/yarn-project/constants/src/constants.ts index f27eb9dcaf3a..6956d82f9f7c 100644 --- a/yarn-project/constants/src/constants.ts +++ b/yarn-project/constants/src/constants.ts @@ -7,6 +7,8 @@ import { GENESIS_BLOCK_HEADER_HASH as GENESIS_BLOCK_HEADER_HASH_BIGINT, INITIAL_CHECKPOINT_NUMBER as INITIAL_CHECKPOINT_NUM_RAW, INITIAL_L2_BLOCK_NUM as INITIAL_L2_BLOCK_NUM_RAW, + MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT, + MAX_PROCESSABLE_L2_GAS, } from './constants.gen.js'; // Typescript-land-only constants @@ -17,16 +19,24 @@ export const SPONSORED_FPC_SALT = BigInt(0); export * from './constants.gen.js'; /** The initial L2 block number (typed as BlockNumber). This is the first block number in the Aztec L2 chain. */ -// Shadow the export from constants.gen above // eslint-disable-next-line import-x/export export const INITIAL_L2_BLOCK_NUM: BlockNumber = BlockNumber(INITIAL_L2_BLOCK_NUM_RAW); /** The initial L2 checkpoint number (typed as CheckpointNumber). This is the first checkpoint number in the Aztec L2 chain. */ -// Shadow the export from constants.gen above - -export const INITIAL_L2_CHECKPOINT_NUM: CheckpointNumber = CheckpointNumber(INITIAL_CHECKPOINT_NUM_RAW); +// eslint-disable-next-line import-x/export +export const INITIAL_CHECKPOINT_NUMBER: CheckpointNumber = CheckpointNumber(INITIAL_CHECKPOINT_NUM_RAW); /** The block header hash for the genesis block 0. */ -// Shadow the export from constants.gen above // eslint-disable-next-line import-x/export export const GENESIS_BLOCK_HEADER_HASH = new Fr(GENESIS_BLOCK_HEADER_HASH_BIGINT); + +// Override the default gas limits set in noir-protocol-circuit constants with saner ones +// Note that these values are not used in noir-land and are only for use in TypeScript code, so we can set them to whatever we want. +// eslint-disable-next-line import-x/export +export const DEFAULT_L2_GAS_LIMIT = MAX_PROCESSABLE_L2_GAS; +// eslint-disable-next-line import-x/export +export const DEFAULT_TEARDOWN_L2_GAS_LIMIT = DEFAULT_L2_GAS_LIMIT / 8; +// eslint-disable-next-line import-x/export +export const DEFAULT_DA_GAS_LIMIT = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT / 4; +// eslint-disable-next-line import-x/export +export const DEFAULT_TEARDOWN_DA_GAS_LIMIT = DEFAULT_DA_GAS_LIMIT / 2; diff --git a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts index 26e874e2629f..59a7dd5c6178 100644 --- a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts +++ b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts @@ -450,7 +450,7 @@ describe('L1Publisher integration', () => { const checkpoint = await buildCheckpoint(globalVariables, txs, currentL1ToL2Messages); const block = checkpoint.blocks[0]; - const totalManaUsed = txs.reduce((acc, tx) => acc.add(new Fr(tx.gasUsed.totalGas.l2Gas)), Fr.ZERO); + const totalManaUsed = txs.reduce((acc, tx) => acc.add(new Fr(tx.gasUsed.billedGas.l2Gas)), Fr.ZERO); expect(totalManaUsed.toBigInt()).toEqual(block.header.totalManaUsed.toBigInt()); prevHeader = block.header; diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index c7bdfed39b44..176f346f605b 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -200,12 +200,12 @@ export type EnvVar = | 'SENTINEL_ENABLED' | 'SENTINEL_HISTORY_LENGTH_IN_EPOCHS' | 'SENTINEL_HISTORIC_PROVEN_PERFORMANCE_LENGTH_IN_EPOCHS' - | 'SEQ_MAX_BLOCK_SIZE_IN_BYTES' | 'SEQ_MAX_TX_PER_BLOCK' | 'SEQ_MIN_TX_PER_BLOCK' | 'SEQ_PUBLISH_TXS_WITH_PROPOSALS' | 'SEQ_MAX_DA_BLOCK_GAS' | 'SEQ_MAX_L2_BLOCK_GAS' + | 'SEQ_GAS_PER_BLOCK_ALLOCATION_MULTIPLIER' | 'SEQ_PUBLISHER_PRIVATE_KEY' | 'SEQ_PUBLISHER_PRIVATE_KEYS' | 'SEQ_PUBLISHER_ADDRESSES' diff --git a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts index ab32be72936d..d8784c80cd39 100644 --- a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts +++ b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts @@ -154,6 +154,10 @@ export class LightweightCheckpointBuilder { return this.blocks.length; } + public getBlocks() { + return this.blocks; + } + /** * Adds a new block to the checkpoint. The tx effects must have already been inserted into the db if * this is called after tx processing, if that's not the case, then set `insertTxsEffects` to true. diff --git a/yarn-project/prover-node/src/job/epoch-proving-job.test.ts b/yarn-project/prover-node/src/job/epoch-proving-job.test.ts index c94818623302..2ff1d48c0f21 100644 --- a/yarn-project/prover-node/src/job/epoch-proving-job.test.ts +++ b/yarn-project/prover-node/src/job/epoch-proving-job.test.ts @@ -134,7 +134,7 @@ describe('epoch-proving-job', () => { publicProcessor.process.mockImplementation(async txs => { const txsArray = await toArray(txs); const processedTxs = await Promise.all(txsArray.map(tx => mock({ hash: tx.getTxHash() }))); - return [processedTxs, [], txsArray, [], 0, []]; + return [processedTxs, [], txsArray, [], []]; }); }); @@ -179,7 +179,7 @@ describe('epoch-proving-job', () => { publicProcessor.process.mockImplementation(async txs => { const txsArray = await toArray(txs); const errors = txsArray.map(tx => ({ error: new Error('Failed to process tx'), tx })); - return [[], errors, [], [], 0, []]; + return [[], errors, [], [], []]; }); const job = createJob(); @@ -190,7 +190,7 @@ describe('epoch-proving-job', () => { }); it('fails if does not process all txs for a block', async () => { - publicProcessor.process.mockImplementation(_txs => Promise.resolve([[], [], [], [], 0, []])); + publicProcessor.process.mockImplementation(_txs => Promise.resolve([[], [], [], [], []])); const job = createJob(); await job.run(); diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index c55521d7b233..015401c0377c 100644 --- a/yarn-project/sequencer-client/src/client/sequencer-client.ts +++ b/yarn-project/sequencer-client/src/client/sequencer-client.ts @@ -1,4 +1,5 @@ import type { BlobClientInterface } from '@aztec/blob-client/client'; +import { MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT } from '@aztec/constants'; import { EpochCache } from '@aztec/epoch-cache'; import { isAnvilTestChain } from '@aztec/ethereum/chain'; import { getPublicClient } from '@aztec/ethereum/client'; @@ -18,10 +19,15 @@ import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; import { L1Metrics, type TelemetryClient } from '@aztec/telemetry-client'; import { FullNodeCheckpointsBuilder, NodeKeystoreAdapter, type ValidatorClient } from '@aztec/validator-client'; -import { type SequencerClientConfig, getPublisherConfigFromSequencerConfig } from '../config.js'; +import { + DefaultSequencerConfig, + type SequencerClientConfig, + getPublisherConfigFromSequencerConfig, +} from '../config.js'; import { GlobalVariableBuilder } from '../global_variable_builder/index.js'; import { SequencerPublisherFactory } from '../publisher/sequencer-publisher-factory.js'; import { Sequencer, type SequencerConfig } from '../sequencer/index.js'; +import { SequencerTimetable } from '../sequencer/timetable.js'; /** * Encapsulates the full sequencer and publisher. @@ -137,17 +143,14 @@ export class SequencerClient { }); const ethereumSlotDuration = config.ethereumSlotDuration; - const l1Constants = { l1GenesisTime, slotDuration: Number(slotDuration), ethereumSlotDuration }; - const globalsBuilder = new GlobalVariableBuilder({ ...config, ...l1Constants, rollupVersion }); - - let sequencerManaLimit = config.maxL2BlockGas ?? rollupManaLimit; - if (sequencerManaLimit > rollupManaLimit) { - log.warn( - `Provided maxL2BlockGas ${sequencerManaLimit} is greater than the max allowed by L1. Setting limit to ${rollupManaLimit}.`, - ); - sequencerManaLimit = rollupManaLimit; - } + const globalsBuilder = new GlobalVariableBuilder({ + ...config, + l1GenesisTime, + slotDuration: Number(slotDuration), + ethereumSlotDuration, + rollupVersion, + }); // When running in anvil, assume we can post a tx up until one second before the end of an L1 slot. // Otherwise, we need the full L1 slot duration for publishing to ensure inclusion. @@ -157,6 +160,12 @@ export class SequencerClient { const l1PublishingTimeBasedOnChain = isAnvilTestChain(config.l1ChainId) ? 1 : ethereumSlotDuration; const l1PublishingTime = config.l1PublishingTime ?? l1PublishingTimeBasedOnChain; + // Combine user-defined block-level limits with checkpoint-level limits (from L1/constants/config) + // to derive the final per-block gas budgets fed into the sequencer. + const { maxL2BlockGas, maxDABlockGas } = this.computeBlockGasLimits(config, rollupManaLimit, l1PublishingTime, log); + + const l1Constants = { l1GenesisTime, slotDuration: Number(slotDuration), ethereumSlotDuration, rollupManaLimit }; + const sequencer = new Sequencer( publisherFactory, validatorClient, @@ -171,7 +180,7 @@ export class SequencerClient { deps.dateProvider, epochCache, rollupContract, - { ...config, l1PublishingTime, maxL2BlockGas: sequencerManaLimit }, + { ...config, l1PublishingTime, maxL2BlockGas, maxDABlockGas }, telemetryClient, log, ); @@ -233,4 +242,69 @@ export class SequencerClient { get maxL2BlockGas(): number | undefined { return this.sequencer.maxL2BlockGas; } + + /** + * Computes per-block L2 and DA gas budgets based on the L1 rollup limits and the timetable. + * If the user explicitly set a limit, it is capped at the corresponding checkpoint limit. + * Otherwise, derives it as (checkpointLimit / maxBlocks) * multiplier, capped at the checkpoint limit. + */ + private static computeBlockGasLimits( + config: SequencerClientConfig, + rollupManaLimit: number, + l1PublishingTime: number, + log: ReturnType, + ): { maxL2BlockGas: number; maxDABlockGas: number } { + const maxNumberOfBlocks = new SequencerTimetable({ + ethereumSlotDuration: config.ethereumSlotDuration, + aztecSlotDuration: config.aztecSlotDuration, + l1PublishingTime, + p2pPropagationTime: config.attestationPropagationTime, + blockDurationMs: config.blockDurationMs, + enforce: config.enforceTimeTable ?? DefaultSequencerConfig.enforceTimeTable, + }).maxNumberOfBlocks; + + const multiplier = config.gasPerBlockAllocationMultiplier ?? DefaultSequencerConfig.gasPerBlockAllocationMultiplier; + + // Compute maxL2BlockGas + let maxL2BlockGas: number; + if (config.maxL2BlockGas !== undefined) { + if (config.maxL2BlockGas > rollupManaLimit) { + log.warn( + `Provided MAX_L2_BLOCK_GAS ${config.maxL2BlockGas} exceeds L1 rollup mana limit ${rollupManaLimit} (capping)`, + ); + maxL2BlockGas = rollupManaLimit; + } else { + maxL2BlockGas = config.maxL2BlockGas; + } + } else { + maxL2BlockGas = Math.min(rollupManaLimit, Math.ceil((rollupManaLimit / maxNumberOfBlocks) * multiplier)); + } + + // Compute maxDABlockGas + const daCheckpointLimit = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT; + let maxDABlockGas: number; + if (config.maxDABlockGas !== undefined) { + if (config.maxDABlockGas > daCheckpointLimit) { + log.warn( + `Provided MAX_DA_BLOCK_GAS ${config.maxDABlockGas} exceeds DA checkpoint limit ${daCheckpointLimit} (capping)`, + ); + maxDABlockGas = daCheckpointLimit; + } else { + maxDABlockGas = config.maxDABlockGas; + } + } else { + maxDABlockGas = Math.min(daCheckpointLimit, Math.ceil((daCheckpointLimit / maxNumberOfBlocks) * multiplier)); + } + + log.info(`Computed block gas limits L2=${maxL2BlockGas} DA=${maxDABlockGas}`, { + maxL2BlockGas, + maxDABlockGas, + rollupManaLimit, + daCheckpointLimit, + maxNumberOfBlocks, + multiplier, + }); + + return { maxL2BlockGas, maxDABlockGas }; + } } diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index 64a7d321a2a8..8268d92245a3 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -36,15 +36,13 @@ export type { SequencerConfig }; * Default values for SequencerConfig. * Centralized location for all sequencer configuration defaults. */ -export const DefaultSequencerConfig: ResolvedSequencerConfig = { +export const DefaultSequencerConfig = { sequencerPollingIntervalMS: 500, maxTxsPerBlock: DEFAULT_MAX_TXS_PER_BLOCK, minTxsPerBlock: 1, buildCheckpointIfEmpty: false, publishTxsWithProposals: false, - maxL2BlockGas: 10e9, - maxDABlockGas: 10e9, - maxBlockSizeInBytes: 1024 * 1024, + gasPerBlockAllocationMultiplier: 2, enforceTimeTable: true, attestationPropagationTime: DEFAULT_P2P_PROPAGATION_TIME, secondsBeforeInvalidatingBlockAsCommitteeMember: 144, // 12 L1 blocks @@ -59,7 +57,7 @@ export const DefaultSequencerConfig: ResolvedSequencerConfig = { shuffleAttestationOrdering: false, skipPushProposedBlocksToArchiver: false, skipPublishingCheckpointsPercent: 0, -}; +} satisfies ResolvedSequencerConfig; /** * Configuration settings for the SequencerClient. @@ -97,12 +95,19 @@ export const sequencerConfigMappings: ConfigMappingsType = { maxL2BlockGas: { env: 'SEQ_MAX_L2_BLOCK_GAS', description: 'The maximum L2 block gas.', - ...numberConfigHelper(DefaultSequencerConfig.maxL2BlockGas), + parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined), }, maxDABlockGas: { env: 'SEQ_MAX_DA_BLOCK_GAS', description: 'The maximum DA block gas.', - ...numberConfigHelper(DefaultSequencerConfig.maxDABlockGas), + parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined), + }, + gasPerBlockAllocationMultiplier: { + env: 'SEQ_GAS_PER_BLOCK_ALLOCATION_MULTIPLIER', + 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.gasPerBlockAllocationMultiplier), }, coinbase: { env: 'COINBASE', @@ -122,11 +127,6 @@ export const sequencerConfigMappings: ConfigMappingsType = { env: 'ACVM_BINARY_PATH', description: 'The path to the ACVM binary', }, - maxBlockSizeInBytes: { - env: 'SEQ_MAX_BLOCK_SIZE_IN_BYTES', - description: 'Max block size', - ...numberConfigHelper(DefaultSequencerConfig.maxBlockSizeInBytes), - }, enforceTimeTable: { env: 'SEQ_ENFORCE_TIME_TABLE', description: 'Whether to enforce the time table when building blocks', diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts index 4361634eb771..f1a702e992f2 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts @@ -1,9 +1,3 @@ -import { - NUM_BLOCK_END_BLOB_FIELDS, - NUM_CHECKPOINT_END_MARKER_FIELDS, - NUM_FIRST_BLOCK_END_BLOB_FIELDS, -} from '@aztec/blob-lib/encoding'; -import { BLOBS_PER_CHECKPOINT, FIELDS_PER_BLOB } from '@aztec/constants'; import type { EpochCache } from '@aztec/epoch-cache'; import { BlockNumber, @@ -84,7 +78,7 @@ describe('CheckpointProposalJob', () => { let job: TestCheckpointProposalJob; let timetable: SequencerTimetable; - let l1Constants: L1RollupConstants; + let l1Constants: L1RollupConstants & { rollupManaLimit: number }; let config: ResolvedSequencerConfig; let lastBlockNumber: BlockNumber; @@ -147,6 +141,7 @@ describe('CheckpointProposalJob', () => { epochDuration: 16, proofSubmissionEpochs: 4, targetCommitteeSize: 48, + rollupManaLimit: Infinity, }; dateProvider = new TestDateProvider(); @@ -768,53 +763,6 @@ describe('CheckpointProposalJob', () => { // waitUntilTimeInSlot should NOT be called since the only block is the last block expect(waitSpy).not.toHaveBeenCalled(); }); - - it('tracks remaining blob field capacity across multiple blocks', async () => { - jest - .spyOn(job.getTimetable(), 'canStartNextBlock') - .mockReturnValueOnce({ canStart: true, deadline: 10, isLastBlock: false }) - .mockReturnValueOnce({ canStart: true, deadline: 18, isLastBlock: true }) - .mockReturnValue({ canStart: false, deadline: undefined, isLastBlock: false }); - - const txs = await Promise.all([makeTx(1, chainId), makeTx(2, chainId), makeTx(3, chainId)]); - - p2p.getPendingTxCount.mockResolvedValue(10); - p2p.iterateEligiblePendingTxs.mockImplementation(() => mockTxIterator(Promise.resolve(txs))); - - // Create 2 blocks - block 1 has 2 txs, block 2 has 1 tx - const block1 = await makeBlock(txs.slice(0, 2), globalVariables); - const globalVariables2 = new GlobalVariables( - chainId, - version, - BlockNumber(newBlockNumber + 1), - SlotNumber(newSlotNumber), - 0n, - coinbase, - feeRecipient, - gasFees, - ); - const block2 = await makeBlock([txs[2]], globalVariables2); - - checkpointBuilder.seedBlocks([block1, block2], [txs.slice(0, 2), [txs[2]]]); - validatorClient.collectAttestations.mockResolvedValue(getAttestations(block2)); - - await job.execute(); - - // Verify blob field limits were correctly calculated - expect(checkpointBuilder.buildBlockCalls).toHaveLength(2); - - const initialCapacity = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB - NUM_CHECKPOINT_END_MARKER_FIELDS; - - // Block 1 (first in checkpoint): gets initial capacity - first block overhead (7) - const block1MaxBlobFields = initialCapacity - NUM_FIRST_BLOCK_END_BLOB_FIELDS; - expect(checkpointBuilder.buildBlockCalls[0].opts.maxBlobFields).toBe(block1MaxBlobFields); - - // Block 2: gets remaining capacity - subsequent block overhead (6) - const block1BlobFieldsUsed = block1.body.txEffects.reduce((sum, tx) => sum + tx.getNumBlobFields(), 0); - const remainingAfterBlock1 = block1MaxBlobFields - block1BlobFieldsUsed; - const block2MaxBlobFields = remainingAfterBlock1 - NUM_BLOCK_END_BLOB_FIELDS; - expect(checkpointBuilder.buildBlockCalls[1].opts.maxBlobFields).toBe(block2MaxBlobFields); - }); }); describe('build single block', () => { @@ -833,7 +781,6 @@ describe('CheckpointProposalJob', () => { indexWithinCheckpoint: IndexWithinCheckpoint(1), buildDeadline: undefined, blockTimestamp: 0n, - remainingBlobFields: 1, txHashesAlreadyIncluded: new Set(), }); @@ -855,7 +802,6 @@ describe('CheckpointProposalJob', () => { indexWithinCheckpoint: IndexWithinCheckpoint(1), buildDeadline: undefined, blockTimestamp: 0n, - remainingBlobFields: 1, txHashesAlreadyIncluded: new Set(), }); @@ -1116,9 +1062,8 @@ class TestCheckpointProposalJob extends CheckpointProposalJob { indexWithinCheckpoint: IndexWithinCheckpoint; buildDeadline: Date | undefined; txHashesAlreadyIncluded: Set; - remainingBlobFields: number; }, - ): Promise<{ block: L2Block; usedTxs: Tx[]; remainingBlobFields: number } | { error: Error } | undefined> { + ): Promise<{ block: L2Block; usedTxs: Tx[] } | { error: Error } | undefined> { return super.buildSingleBlock(checkpointBuilder, opts); } } diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts index ad88b7d040c1..1b7cabc8fe9e 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts @@ -208,7 +208,7 @@ describe('CheckpointProposalJob Timing Tests', () => { let slasherClient: MockProxy; let metrics: MockProxy; - let l1Constants: L1RollupConstants; + let l1Constants: L1RollupConstants & { rollupManaLimit: number }; let config: ResolvedSequencerConfig; // Test state @@ -330,6 +330,7 @@ describe('CheckpointProposalJob Timing Tests', () => { epochDuration: 16, proofSubmissionEpochs: 4, targetCommitteeSize: 48, + rollupManaLimit: Infinity, }; // Initialize test state diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 466d2f259f17..d461b68c30b4 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -1,5 +1,3 @@ -import { NUM_CHECKPOINT_END_MARKER_FIELDS, getNumBlockEndBlobFields } from '@aztec/blob-lib/encoding'; -import { BLOBS_PER_CHECKPOINT, FIELDS_PER_BLOB } from '@aztec/constants'; import type { EpochCache } from '@aztec/epoch-cache'; import { BlockNumber, @@ -32,7 +30,7 @@ import { type L2BlockSource, MaliciousCommitteeAttestationsAndSigners, } from '@aztec/stdlib/block'; -import type { Checkpoint } from '@aztec/stdlib/checkpoint'; +import { type Checkpoint, validateCheckpoint } from '@aztec/stdlib/checkpoint'; import { getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers'; import { Gas } from '@aztec/stdlib/gas'; import { @@ -267,6 +265,20 @@ export class CheckpointProposalJob implements Traceable { this.setStateFn(SequencerState.ASSEMBLING_CHECKPOINT, this.slot); const checkpoint = await checkpointBuilder.completeCheckpoint(); + // Final validation round for the checkpoint before we propose it, just for safety + try { + validateCheckpoint(checkpoint, { + rollupManaLimit: this.l1Constants.rollupManaLimit, + maxL2BlockGas: this.config.maxL2BlockGas, + maxDABlockGas: this.config.maxDABlockGas, + }); + } catch (err) { + this.log.error(`Built an invalid checkpoint at slot ${this.slot} (skipping proposal)`, err, { + checkpoint: checkpoint.header.toInspect(), + }); + return undefined; + } + // Record checkpoint-level build metrics this.metrics.recordCheckpointBuild( checkpointBuildTimer.ms(), @@ -389,9 +401,6 @@ export class CheckpointProposalJob implements Traceable { const txHashesAlreadyIncluded = new Set(); const initialBlockNumber = BlockNumber(this.syncedToBlockNumber + 1); - // Remaining blob fields available for blocks (checkpoint end marker already subtracted) - let remainingBlobFields = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB - NUM_CHECKPOINT_END_MARKER_FIELDS; - // Last block in the checkpoint will usually be flagged as pending broadcast, so we send it along with the checkpoint proposal let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined; @@ -424,7 +433,6 @@ export class CheckpointProposalJob implements Traceable { blockNumber, indexWithinCheckpoint, txHashesAlreadyIncluded, - remainingBlobFields, }); // TODO(palla/mbps): Review these conditions. We may want to keep trying in some scenarios. @@ -450,12 +458,9 @@ export class CheckpointProposalJob implements Traceable { break; } - const { block, usedTxs, remainingBlobFields: newRemainingBlobFields } = buildResult; + const { block, usedTxs } = buildResult; blocksInCheckpoint.push(block); - // Update remaining blob fields for the next block - remainingBlobFields = newRemainingBlobFields; - // Sync the proposed block to the archiver to make it available // Note that the checkpoint builder uses its own fork so it should not need to wait for this syncing // Eventually we should refactor the checkpoint builder to not need a separate long-lived fork @@ -523,18 +528,10 @@ export class CheckpointProposalJob implements Traceable { indexWithinCheckpoint: IndexWithinCheckpoint; buildDeadline: Date | undefined; txHashesAlreadyIncluded: Set; - remainingBlobFields: number; }, - ): Promise<{ block: L2Block; usedTxs: Tx[]; remainingBlobFields: number } | { error: Error } | undefined> { - const { - blockTimestamp, - forceCreate, - blockNumber, - indexWithinCheckpoint, - buildDeadline, - txHashesAlreadyIncluded, - remainingBlobFields, - } = opts; + ): Promise<{ block: L2Block; usedTxs: Tx[] } | { error: Error } | undefined> { + const { blockTimestamp, forceCreate, blockNumber, indexWithinCheckpoint, buildDeadline, txHashesAlreadyIncluded } = + opts; this.log.verbose( `Preparing block ${blockNumber} index ${indexWithinCheckpoint} at checkpoint ${this.checkpointNumber} for slot ${this.slot}`, @@ -568,16 +565,16 @@ export class CheckpointProposalJob implements Traceable { ); this.setStateFn(SequencerState.CREATING_BLOCK, this.slot); - // Calculate blob fields limit for txs (remaining capacity - this block's end overhead) - const blockEndOverhead = getNumBlockEndBlobFields(indexWithinCheckpoint === 0); - const maxBlobFieldsForTxs = remainingBlobFields - blockEndOverhead; - + // Per-block limits derived at startup by SequencerClient.computeBlockGasLimits(), further capped + // by remaining checkpoint-level budgets inside CheckpointBuilder before each block is built. const blockBuilderOptions: PublicProcessorLimits = { maxTransactions: this.config.maxTxsPerBlock, - maxBlockSize: this.config.maxBlockSizeInBytes, - maxBlockGas: new Gas(this.config.maxDABlockGas, this.config.maxL2BlockGas), - maxBlobFields: maxBlobFieldsForTxs, + maxBlockGas: + this.config.maxL2BlockGas !== undefined || this.config.maxDABlockGas !== undefined + ? new Gas(this.config.maxDABlockGas ?? Infinity, this.config.maxL2BlockGas ?? Infinity) + : undefined, deadline: buildDeadline, + isBuildingProposal: true, }; // Actually build the block by executing txs @@ -607,7 +604,7 @@ export class CheckpointProposalJob implements Traceable { } // Block creation succeeded, emit stats and metrics - const { publicGas, block, publicProcessorDuration, usedTxs, usedTxBlobFields, blockBuildDuration } = buildResult; + const { block, publicProcessorDuration, usedTxs, blockBuildDuration } = buildResult; const blockStats = { eventName: 'l2-block-built', @@ -618,7 +615,7 @@ export class CheckpointProposalJob implements Traceable { const blockHash = await block.hash(); const txHashes = block.body.txEffects.map(tx => tx.txHash); - const manaPerSec = publicGas.l2Gas / (blockBuildDuration / 1000); + const manaPerSec = block.header.totalManaUsed.toNumberUnsafe() / (blockBuildDuration / 1000); this.log.info( `Built block ${block.number} at checkpoint ${this.checkpointNumber} for slot ${this.slot} with ${numTxs} txs`, @@ -626,9 +623,9 @@ export class CheckpointProposalJob implements Traceable { ); this.eventEmitter.emit('block-proposed', { blockNumber: block.number, slot: this.slot }); - this.metrics.recordBuiltBlock(blockBuildDuration, publicGas.l2Gas); + this.metrics.recordBuiltBlock(blockBuildDuration, block.header.totalManaUsed.toNumberUnsafe()); - return { block, usedTxs, remainingBlobFields: maxBlobFieldsForTxs - usedTxBlobFields }; + return { block, usedTxs }; } catch (err: any) { this.eventEmitter.emit('block-build-failed', { reason: err.message, slot: this.slot }); this.log.error(`Error building block`, err, { blockNumber, slot: this.slot }); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts index 65ed41a5ae48..254485252ec5 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts @@ -67,6 +67,7 @@ describe('CheckpointVoter HA Integration', () => { l1GenesisTime: 1n, slotDuration: 24, ethereumSlotDuration: DefaultL1ContractsConfig.ethereumSlotDuration, + rollupManaLimit: Infinity, }; /** diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index cb625f07002d..4f8e011c1c82 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -78,7 +78,9 @@ describe('sequencer', () => { let block: L2Block; let globalVariables: GlobalVariables; - let l1Constants: Pick; + let l1Constants: Pick & { + rollupManaLimit: number; + }; let sequencer: TestSequencer; @@ -160,7 +162,7 @@ describe('sequencer', () => { ); const l1GenesisTime = BigInt(Math.floor(Date.now() / 1000)); - l1Constants = { l1GenesisTime, slotDuration, ethereumSlotDuration }; + l1Constants = { l1GenesisTime, slotDuration, ethereumSlotDuration, rollupManaLimit: Infinity }; epochCache = mockDeep(); epochCache.isEscapeHatchOpen.mockResolvedValue(false); diff --git a/yarn-project/sequencer-client/src/sequencer/timetable.ts b/yarn-project/sequencer-client/src/sequencer/timetable.ts index 86b88a1ba99f..e692fb1a6159 100644 --- a/yarn-project/sequencer-client/src/sequencer/timetable.ts +++ b/yarn-project/sequencer-client/src/sequencer/timetable.ts @@ -1,4 +1,4 @@ -import { createLogger } from '@aztec/aztec.js/log'; +import type { Logger } from '@aztec/foundation/log'; import { CHECKPOINT_ASSEMBLE_TIME, CHECKPOINT_INITIALIZATION_TIME, @@ -80,7 +80,7 @@ export class SequencerTimetable { enforce: boolean; }, private readonly metrics?: SequencerMetrics, - private readonly log = createLogger('sequencer:timetable'), + private readonly log?: Logger, ) { this.ethereumSlotDuration = opts.ethereumSlotDuration; this.aztecSlotDuration = opts.aztecSlotDuration; @@ -132,7 +132,7 @@ export class SequencerTimetable { const initializeDeadline = this.aztecSlotDuration - minWorkToDo; this.initializeDeadline = initializeDeadline; - this.log.info( + this.log?.info( `Sequencer timetable initialized with ${this.maxNumberOfBlocks} blocks per slot (${this.enforce ? 'enforced' : 'not enforced'})`, { ethereumSlotDuration: this.ethereumSlotDuration, @@ -206,7 +206,7 @@ export class SequencerTimetable { } this.metrics?.recordStateTransitionBufferMs(Math.floor(bufferSeconds * 1000), newState); - this.log.trace(`Enough time to transition to ${newState}`, { maxAllowedTime, secondsIntoSlot }); + this.log?.trace(`Enough time to transition to ${newState}`, { maxAllowedTime, secondsIntoSlot }); } /** @@ -242,7 +242,7 @@ export class SequencerTimetable { const canStart = available >= this.minExecutionTime; const deadline = secondsIntoSlot + available; - this.log.verbose( + this.log?.verbose( `${canStart ? 'Can' : 'Cannot'} start single-block checkpoint at ${secondsIntoSlot}s into slot`, { secondsIntoSlot, maxAllowed, available, deadline }, ); @@ -262,7 +262,7 @@ export class SequencerTimetable { // Found an available sub-slot! Is this the last one? const isLastBlock = subSlot === this.maxNumberOfBlocks; - this.log.verbose( + this.log?.verbose( `Can start ${isLastBlock ? 'last block' : 'block'} in sub-slot ${subSlot} with deadline ${deadline}s`, { secondsIntoSlot, deadline, timeUntilDeadline, subSlot, maxBlocks: this.maxNumberOfBlocks }, ); @@ -272,7 +272,7 @@ export class SequencerTimetable { } // No sub-slots available with enough time - this.log.verbose(`No time left to start any more blocks`, { + this.log?.verbose(`No time left to start any more blocks`, { secondsIntoSlot, maxBlocks: this.maxNumberOfBlocks, initializationOffset: this.initializationOffset, diff --git a/yarn-project/sequencer-client/src/sequencer/types.ts b/yarn-project/sequencer-client/src/sequencer/types.ts index ef4cebf699c2..312c9613cce5 100644 --- a/yarn-project/sequencer-client/src/sequencer/types.ts +++ b/yarn-project/sequencer-client/src/sequencer/types.ts @@ -3,4 +3,7 @@ import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; export type SequencerRollupConstants = Pick< L1RollupConstants, 'ethereumSlotDuration' | 'l1GenesisTime' | 'slotDuration' ->; +> & { + /** Total L2 gas (mana) allowed per checkpoint. Fetched from L1 getManaLimit(). */ + rollupManaLimit: number; +}; diff --git a/yarn-project/sequencer-client/src/test/mock_checkpoint_builder.ts b/yarn-project/sequencer-client/src/test/mock_checkpoint_builder.ts index 60cc606570f8..9baf133dc1fc 100644 --- a/yarn-project/sequencer-client/src/test/mock_checkpoint_builder.ts +++ b/yarn-project/sequencer-client/src/test/mock_checkpoint_builder.ts @@ -2,7 +2,6 @@ import { type BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-ty import { Fr } from '@aztec/foundation/curves/bn254'; import { L2Block } from '@aztec/stdlib/block'; import { Checkpoint } from '@aztec/stdlib/checkpoint'; -import { Gas } from '@aztec/stdlib/gas'; import type { FullNodeBlockBuilderConfig, ICheckpointBlockBuilder, @@ -113,12 +112,10 @@ export class MockCheckpointBuilder implements ICheckpointBlockBuilder { return { block, - publicGas: Gas.empty(), publicProcessorDuration: 0, numTxs: block?.body?.txEffects?.length ?? usedTxs.length, usedTxs, failedTxs: [], - usedTxBlobFields: block?.body?.txEffects?.reduce((sum, tx) => sum + tx.getNumBlobFields(), 0) ?? 0, }; } @@ -249,6 +246,7 @@ export class MockCheckpointsBuilder implements ICheckpointsBuilder { slotDuration: 24, l1ChainId: 1, rollupVersion: 1, + rollupManaLimit: 200_000_000, }; } diff --git a/yarn-project/simulator/src/public/fixtures/public_tx_simulation_tester.ts b/yarn-project/simulator/src/public/fixtures/public_tx_simulation_tester.ts index 4261d5881d52..839d073c0cdc 100644 --- a/yarn-project/simulator/src/public/fixtures/public_tx_simulation_tester.ts +++ b/yarn-project/simulator/src/public/fixtures/public_tx_simulation_tester.ts @@ -117,6 +117,7 @@ export class PublicTxSimulationTester extends BaseAvmSimulationTester { feePayer: AztecAddress = sender, /* need some unique first nullifier for note-nonce computations */ privateInsertions: TestPrivateInsertions = { nonRevertible: { nullifiers: [new Fr(420000 + this.txCount)] } }, + gasLimits?: Gas, ): Promise { const setupCallRequests = await asyncMap(setupCalls, call => this.#createPubicCallRequestForCall(call, call.sender ?? sender), @@ -142,6 +143,7 @@ export class PublicTxSimulationTester extends BaseAvmSimulationTester { ) : new Gas(TX_DA_GAS_OVERHEAD, PUBLIC_TX_L2_GAS_OVERHEAD), defaultGlobals(), + gasLimits, ); } @@ -154,8 +156,9 @@ export class PublicTxSimulationTester extends BaseAvmSimulationTester { /* need some unique first nullifier for note-nonce computations */ privateInsertions?: TestPrivateInsertions, txLabel: string = 'unlabeledTx', + gasLimits?: Gas, ): Promise { - const tx = await this.createTx(sender, setupCalls, appCalls, teardownCall, feePayer, privateInsertions); + const tx = await this.createTx(sender, setupCalls, appCalls, teardownCall, feePayer, privateInsertions, gasLimits); await this.setFeePayerBalance(feePayer); @@ -200,8 +203,18 @@ export class PublicTxSimulationTester extends BaseAvmSimulationTester { teardownCall?: TestEnqueuedCall, feePayer?: AztecAddress, privateInsertions?: TestPrivateInsertions, + gasLimits?: Gas, ): Promise { - return await this.simulateTx(sender, setupCalls, appCalls, teardownCall, feePayer, privateInsertions, txLabel); + return await this.simulateTx( + sender, + setupCalls, + appCalls, + teardownCall, + feePayer, + privateInsertions, + txLabel, + gasLimits, + ); } /** @@ -219,6 +232,7 @@ export class PublicTxSimulationTester extends BaseAvmSimulationTester { teardownCall?: TestEnqueuedCall, feePayer?: AztecAddress, privateInsertions?: TestPrivateInsertions, + gasLimits?: Gas, ): Promise { return await this.simulateTxWithLabel( txLabel, @@ -228,6 +242,7 @@ export class PublicTxSimulationTester extends BaseAvmSimulationTester { teardownCall, feePayer, privateInsertions, + gasLimits, ); } diff --git a/yarn-project/simulator/src/public/fixtures/utils.ts b/yarn-project/simulator/src/public/fixtures/utils.ts index c058c9d7c128..b32fb1ec30d6 100644 --- a/yarn-project/simulator/src/public/fixtures/utils.ts +++ b/yarn-project/simulator/src/public/fixtures/utils.ts @@ -62,13 +62,14 @@ export async function createTxForPublicCalls( feePayer = AztecAddress.zero(), gasUsedByPrivate: Gas = Gas.empty(), globals: GlobalVariables = GlobalVariables.empty(), + gasLimits?: Gas, ): Promise { assert( setupCallRequests.length > 0 || appCallRequests.length > 0 || teardownCallRequest !== undefined, "Can't create public tx with no enqueued calls", ); // use max limits - const gasLimits = new Gas(DEFAULT_DA_GAS_LIMIT, DEFAULT_L2_GAS_LIMIT); + gasLimits = gasLimits ?? new Gas(DEFAULT_DA_GAS_LIMIT, DEFAULT_L2_GAS_LIMIT); const forPublic = PartialPrivateTailPublicInputsForPublic.empty(); diff --git a/yarn-project/simulator/src/public/public_processor/public_processor.test.ts b/yarn-project/simulator/src/public/public_processor/public_processor.test.ts index 03276162b887..907ee1f907c6 100644 --- a/yarn-project/simulator/src/public/public_processor/public_processor.test.ts +++ b/yarn-project/simulator/src/public/public_processor/public_processor.test.ts @@ -188,6 +188,22 @@ describe('public_processor', () => { expect(failed).toEqual([]); }); + it('skips tx before processing if estimated blob fields would exceed limit', async function () { + const tx = await mockTxWithPublicCalls(); + // Add note hashes to inflate the estimated blob fields size + for (let i = 0; i < 10; i++) { + tx.data.forPublic!.nonRevertibleAccumulatedData.noteHashes[i] = Fr.random(); + } + // 3 overhead + 1 nullifier + 10 note hashes = 14 estimated fields + // Set a limit that is too small for even one tx + const [processed, failed] = await processor.process([tx], { maxBlobFields: 10, isBuildingProposal: true }); + + expect(processed).toEqual([]); + expect(failed).toEqual([]); + // The simulator should not have been called since the tx was skipped pre-processing + expect(publicTxSimulator.simulate).not.toHaveBeenCalled(); + }); + it('does not exceed max blob fields limit', async function () { // Create 3 private-only transactions const txs = await Promise.all(Array.from([1, 2, 3], seed => mockPrivateOnlyTx({ seed }))); @@ -201,16 +217,13 @@ describe('public_processor', () => { const maxBlobFields = actualBlobFields * 2; // Process all 3 transactions with the blob field limit - const [processed, failed, _usedTxs, _returns, usedTxBlobFields] = await processor.process(txs, { maxBlobFields }); + const [processed, failed] = await processor.process(txs, { maxBlobFields }); // Should only process 2 transactions due to blob field limit expect(processed.length).toBe(2); expect(processed[0].hash).toEqual(txs[0].getTxHash()); expect(processed[1].hash).toEqual(txs[1].getTxHash()); expect(failed).toEqual([]); - - const expectedBlobFields = actualBlobFields * 2; - expect(usedTxBlobFields).toBe(expectedBlobFields); }); it('does not send a transaction to the prover if pre validation fails', async function () { diff --git a/yarn-project/simulator/src/public/public_processor/public_processor.ts b/yarn-project/simulator/src/public/public_processor/public_processor.ts index e3a776edac02..45a3d9e6906e 100644 --- a/yarn-project/simulator/src/public/public_processor/public_processor.ts +++ b/yarn-project/simulator/src/public/public_processor/public_processor.ts @@ -160,8 +160,8 @@ export class PublicProcessor implements Traceable { txs: Iterable | AsyncIterable, limits: PublicProcessorLimits = {}, validator: PublicProcessorValidator = {}, - ): Promise<[ProcessedTx[], FailedTx[], Tx[], NestedProcessReturnValues[], number, DebugLog[]]> { - const { maxTransactions, maxBlockSize, deadline, maxBlockGas, maxBlobFields } = limits; + ): Promise<[ProcessedTx[], FailedTx[], Tx[], NestedProcessReturnValues[], DebugLog[]]> { + const { maxTransactions, deadline, maxBlockGas, maxBlobFields, isBuildingProposal } = limits; const { preprocessValidator, nullifierCache } = validator; const result: ProcessedTx[] = []; const usedTxs: Tx[] = []; @@ -188,22 +188,23 @@ export class PublicProcessor implements Traceable { break; } - // Skip this tx if it'd exceed max block size const txHash = tx.getTxHash().toString(); - const preTxSizeInBytes = tx.getEstimatedPrivateTxEffectsSize(); - if (maxBlockSize !== undefined && totalSizeInBytes + preTxSizeInBytes > maxBlockSize) { - this.log.warn(`Skipping processing of tx ${txHash} sized ${preTxSizeInBytes} bytes due to block size limit`, { - txHash, - sizeInBytes: preTxSizeInBytes, - totalSizeInBytes, - maxBlockSize, - }); + + // Skip this tx if its estimated blob fields would exceed the limit. + // Only done during proposal building: during re-execution we must process the exact txs from the proposal. + const txBlobFields = tx.getPrivateTxEffectsSizeInFields(); + if (isBuildingProposal && maxBlobFields !== undefined && totalBlobFields + txBlobFields > maxBlobFields) { + this.log.warn( + `Skipping tx ${txHash} with ${txBlobFields} fields from private side effects due to blob fields limit`, + { txHash, txBlobFields, totalBlobFields, maxBlobFields }, + ); continue; } - // Skip this tx if its gas limit would exceed the block gas limit + // Skip this tx if its gas limit would exceed the block gas limit (either da or l2). + // Only done during proposal building: during re-execution we must process the exact txs from the proposal. const txGasLimit = tx.data.constants.txContext.gasSettings.gasLimits; - if (maxBlockGas !== undefined && totalBlockGas.add(txGasLimit).gtAny(maxBlockGas)) { + if (isBuildingProposal && maxBlockGas !== undefined && totalBlockGas.add(txGasLimit).gtAny(maxBlockGas)) { this.log.warn(`Skipping processing of tx ${txHash} due to block gas limit`, { txHash, txGasLimit, @@ -252,23 +253,9 @@ export class PublicProcessor implements Traceable { } const txBlobFields = processedTx.txEffect.getNumBlobFields(); - - // If the actual size of this tx would exceed block size, skip it const txSize = txBlobFields * Fr.SIZE_IN_BYTES; - if (maxBlockSize !== undefined && totalSizeInBytes + txSize > maxBlockSize) { - this.log.debug(`Skipping processed tx ${txHash} sized ${txSize} due to max block size.`, { - txHash, - sizeInBytes: txSize, - totalSizeInBytes, - maxBlockSize, - }); - // Need to revert the checkpoint here and don't go any further - await checkpoint.revert(); - this.contractsDB.revertCheckpoint(); - continue; - } - // If the actual blob fields of this tx would exceed the limit, skip it + // If the actual blob fields of this tx would exceed the limit, skip it. // Note: maxBlobFields already accounts for block end blob fields and previous blocks in checkpoint. if (maxBlobFields !== undefined && totalBlobFields + txBlobFields > maxBlobFields) { this.log.debug( @@ -286,6 +273,25 @@ export class PublicProcessor implements Traceable { continue; } + // During re-execution, check if the actual gas used by this tx would push the block over the gas limit. + // Unlike the proposal-building check (which uses declared gas limits pessimistically before processing), + // this uses actual gas and stops processing when the limit is exceeded. + if ( + !isBuildingProposal && + maxBlockGas !== undefined && + totalBlockGas.add(processedTx.gasUsed.totalGas).gtAny(maxBlockGas) + ) { + this.log.warn(`Stopping re-execution since tx ${txHash} would push block gas over limit`, { + txHash, + txGas: processedTx.gasUsed.totalGas, + totalBlockGas, + maxBlockGas, + }); + await checkpoint.revert(); + this.contractsDB.revertCheckpoint(); + break; + } + // FIXME(fcarreiro): it's ugly to have to notify the validator of nullifiers. // I'd rather pass the validators the processedTx as well and let them deal with it. nullifierCache?.addNullifiers(processedTx.txEffect.nullifiers.map(n => n.toBuffer())); @@ -368,7 +374,7 @@ export class PublicProcessor implements Traceable { totalSizeInBytes, }); - return [result, failed, usedTxs, returns, totalBlobFields, debugLogs]; + return [result, failed, usedTxs, returns, debugLogs]; } private async checkWorldStateUnchanged( diff --git a/yarn-project/stdlib/src/block/l2_block.ts b/yarn-project/stdlib/src/block/l2_block.ts index 15f037082a91..362a36f996a5 100644 --- a/yarn-project/stdlib/src/block/l2_block.ts +++ b/yarn-project/stdlib/src/block/l2_block.ts @@ -1,4 +1,5 @@ import { type BlockBlobData, encodeBlockBlobData } from '@aztec/blob-lib/encoding'; +import { DA_GAS_PER_FIELD } from '@aztec/constants'; import { BlockNumber, CheckpointNumber, @@ -221,4 +222,15 @@ export class L2Block { timestamp: this.header.globalVariables.timestamp, }; } + + /** + * Compute how much DA gas this block uses. + * + * @remarks This assumes DA gas is computed solely based on the number of blob fields in transactions. + * This may change in the future, but we cannot access the actual DA gas used in a block since it's not exposed + * in the L2BlockHeader, so we have to rely on recomputing it. + */ + computeDAGasUsed(): number { + return this.body.txEffects.reduce((total, txEffect) => total + txEffect.getNumBlobFields(), 0) * DA_GAS_PER_FIELD; + } } diff --git a/yarn-project/stdlib/src/checkpoint/index.ts b/yarn-project/stdlib/src/checkpoint/index.ts index d86f88c87bbb..96c176e1d861 100644 --- a/yarn-project/stdlib/src/checkpoint/index.ts +++ b/yarn-project/stdlib/src/checkpoint/index.ts @@ -2,3 +2,4 @@ export * from './checkpoint.js'; export * from './checkpoint_data.js'; export * from './checkpoint_info.js'; export * from './published_checkpoint.js'; +export * from './validate.js'; diff --git a/yarn-project/stdlib/src/checkpoint/validate.ts b/yarn-project/stdlib/src/checkpoint/validate.ts new file mode 100644 index 000000000000..a89d9409f189 --- /dev/null +++ b/yarn-project/stdlib/src/checkpoint/validate.ts @@ -0,0 +1,114 @@ +import { BLOBS_PER_CHECKPOINT, FIELDS_PER_BLOB, MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT } from '@aztec/constants'; +import type { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { sum } from '@aztec/foundation/collection'; + +import type { Checkpoint } from './checkpoint.js'; + +export class CheckpointValidationError extends Error { + constructor( + message: string, + public readonly checkpointNumber: CheckpointNumber, + public readonly slot: SlotNumber, + ) { + super(message); + this.name = 'CheckpointValidationError'; + } +} + +/** + * Validates a checkpoint. Throws a CheckpointValidationError if any validation fails. + * - Validates checkpoint blob field count against maxBlobFields limit + * - Validates total L2 gas used by checkpoint blocks against the Rollup contract mana limit + * - Validates total DA gas used by checkpoint blocks against MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT + * - Validates individual block L2 gas and DA gas against maxL2BlockGas and maxDABlockGas limits + */ +export function validateCheckpoint( + checkpoint: Checkpoint, + opts: { + rollupManaLimit: number; + maxL2BlockGas: number | undefined; + maxDABlockGas: number | undefined; + }, +): void { + validateCheckpointLimits(checkpoint, opts); + validateCheckpointBlocksGasLimits(checkpoint, opts); +} + +/** Validates checkpoint blocks gas limits */ +function validateCheckpointBlocksGasLimits( + checkpoint: Checkpoint, + opts: { + maxL2BlockGas: number | undefined; + maxDABlockGas: number | undefined; + }, +): void { + const { maxL2BlockGas, maxDABlockGas } = opts; + + if (maxL2BlockGas !== undefined) { + for (const block of checkpoint.blocks) { + const blockL2Gas = block.header.totalManaUsed.toNumber(); + if (blockL2Gas > maxL2BlockGas) { + throw new CheckpointValidationError( + `Block ${block.number} in checkpoint has L2 gas used ${blockL2Gas} exceeding limit of ${maxL2BlockGas}`, + checkpoint.number, + checkpoint.slot, + ); + } + } + } + + if (maxDABlockGas !== undefined) { + for (const block of checkpoint.blocks) { + const blockDAGas = block.computeDAGasUsed(); + if (blockDAGas > maxDABlockGas) { + throw new CheckpointValidationError( + `Block ${block.number} in checkpoint has DA gas used ${blockDAGas} exceeding limit of ${maxDABlockGas}`, + checkpoint.number, + checkpoint.slot, + ); + } + } + } +} + +/** Validates checkpoint max blob fields and gas limits */ +function validateCheckpointLimits( + checkpoint: Checkpoint, + opts: { + rollupManaLimit: number; + }, +): void { + const { rollupManaLimit } = opts; + + const maxBlobFields = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB; + const maxDAGas = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT; + + const checkpointMana = sum(checkpoint.blocks.map(block => block.header.totalManaUsed.toNumber())); + if (checkpointMana > rollupManaLimit) { + throw new CheckpointValidationError( + `Checkpoint mana cost ${checkpointMana} exceeds rollup limit of ${rollupManaLimit}`, + checkpoint.number, + checkpoint.slot, + ); + } + + const checkpointDAGas = sum(checkpoint.blocks.map(block => block.computeDAGasUsed())); + if (checkpointDAGas > maxDAGas) { + throw new CheckpointValidationError( + `Checkpoint DA gas cost ${checkpointDAGas} exceeds limit of ${maxDAGas}`, + checkpoint.number, + checkpoint.slot, + ); + } + + if (maxBlobFields !== undefined) { + const checkpointBlobFields = checkpoint.toBlobFields().length; + if (checkpointBlobFields > maxBlobFields) { + throw new CheckpointValidationError( + `Checkpoint blob field count ${checkpointBlobFields} exceeds limit of ${maxBlobFields}`, + checkpoint.number, + checkpoint.slot, + ); + } + } +} diff --git a/yarn-project/stdlib/src/interfaces/block-builder.ts b/yarn-project/stdlib/src/interfaces/block-builder.ts index f0c4eb780468..07a986ab2384 100644 --- a/yarn-project/stdlib/src/interfaces/block-builder.ts +++ b/yarn-project/stdlib/src/interfaces/block-builder.ts @@ -36,11 +36,16 @@ export interface IBlockFactory extends ProcessedTxHandler { } export interface PublicProcessorLimits { + /** Maximum number of txs to process. */ maxTransactions?: number; - maxBlockSize?: number; + /** L2 and DA gas limits. */ maxBlockGas?: Gas; + /** Maximum number of blob fields allowed. */ maxBlobFields?: number; + /** Deadline for processing the txs. Processor will stop as soon as it hits this time. */ deadline?: Date; + /** Whether this processor is building a proposal (as opposed to re-executing one). Skipping txs due to gas or blob limits is only done during proposal building. */ + isBuildingProposal?: boolean; } export interface PublicProcessorValidator { @@ -52,8 +57,16 @@ export type FullNodeBlockBuilderConfig = Pick & Pick< SequencerConfig, - 'txPublicSetupAllowListExtend' | 'fakeProcessingDelayPerTxMs' | 'fakeThrowAfterProcessingTxCount' - >; + | 'txPublicSetupAllowListExtend' + | 'fakeProcessingDelayPerTxMs' + | 'fakeThrowAfterProcessingTxCount' + | 'maxTxsPerBlock' + | 'maxL2BlockGas' + | 'maxDABlockGas' + > & { + /** Total L2 gas (mana) allowed per checkpoint. Fetched from L1 getManaLimit(). */ + rollupManaLimit: number; + }; export const FullNodeBlockBuilderConfigKeys: (keyof FullNodeBlockBuilderConfig)[] = [ 'l1GenesisTime', @@ -63,6 +76,10 @@ export const FullNodeBlockBuilderConfigKeys: (keyof FullNodeBlockBuilderConfig)[ 'txPublicSetupAllowListExtend', 'fakeProcessingDelayPerTxMs', 'fakeThrowAfterProcessingTxCount', + 'maxTxsPerBlock', + 'maxL2BlockGas', + 'maxDABlockGas', + 'rollupManaLimit', ] as const; /** Thrown when no valid transactions are available to include in a block after processing, and this is not the first block in a checkpoint. */ @@ -76,12 +93,10 @@ export class NoValidTxsError extends Error { /** Result of building a block within a checkpoint. */ export type BuildBlockInCheckpointResult = { block: L2Block; - publicGas: Gas; publicProcessorDuration: number; numTxs: number; failedTxs: FailedTx[]; usedTxs: Tx[]; - usedTxBlobFields: number; }; /** Interface for building blocks within a checkpoint context. */ diff --git a/yarn-project/stdlib/src/interfaces/configs.ts b/yarn-project/stdlib/src/interfaces/configs.ts index 88c7db90d3eb..bb18db1ee5fc 100644 --- a/yarn-project/stdlib/src/interfaces/configs.ts +++ b/yarn-project/stdlib/src/interfaces/configs.ts @@ -23,6 +23,8 @@ export interface SequencerConfig { maxL2BlockGas?: number; /** The maximum DA block gas. */ maxDABlockGas?: number; + /** Per-block gas budget multiplier for both L2 and DA gas. Budget = (checkpointLimit / maxBlocks) * multiplier. */ + gasPerBlockAllocationMultiplier?: number; /** Recipient of block reward. */ coinbase?: EthAddress; /** Address to receive fees. */ @@ -33,8 +35,6 @@ export interface SequencerConfig { acvmBinaryPath?: string; /** Additional entries to extend the default setup allow list. */ txPublicSetupAllowListExtend?: AllowedElement[]; - /** Max block size */ - maxBlockSizeInBytes?: number; /** Payload address to vote for */ governanceProposerPayload?: EthAddress; /** Whether to enforce the time table when building blocks */ @@ -90,12 +90,12 @@ export const SequencerConfigSchema = zodFor()( maxL2BlockGas: z.number().optional(), publishTxsWithProposals: z.boolean().optional(), maxDABlockGas: z.number().optional(), + gasPerBlockAllocationMultiplier: z.number().optional(), coinbase: schemas.EthAddress.optional(), feeRecipient: schemas.AztecAddress.optional(), acvmWorkingDirectory: z.string().optional(), acvmBinaryPath: z.string().optional(), txPublicSetupAllowListExtend: z.array(AllowedElementSchema).optional(), - maxBlockSizeInBytes: z.number().optional(), governanceProposerPayload: schemas.EthAddress.optional(), l1PublishingTime: z.number().optional(), enforceTimeTable: z.boolean().optional(), @@ -134,7 +134,10 @@ type SequencerConfigOptionalKeys = | 'l1PublishingTime' | 'txPublicSetupAllowListExtend' | 'minValidTxsPerBlock' - | 'minBlocksForCheckpoint'; + | 'minBlocksForCheckpoint' + | 'maxL2BlockGas' + | 'maxDABlockGas' + | 'gasPerBlockAllocationMultiplier'; export type ResolvedSequencerConfig = Prettify< Required> & Pick diff --git a/yarn-project/stdlib/src/kernel/private_kernel_tail_circuit_public_inputs.ts b/yarn-project/stdlib/src/kernel/private_kernel_tail_circuit_public_inputs.ts index b3b8b8f79f4a..3bf2c6787830 100644 --- a/yarn-project/stdlib/src/kernel/private_kernel_tail_circuit_public_inputs.ts +++ b/yarn-project/stdlib/src/kernel/private_kernel_tail_circuit_public_inputs.ts @@ -234,6 +234,15 @@ export class PrivateKernelTailCircuitPublicInputs { return noteHashes.filter(n => !n.isZero()); } + getNonEmptyL2ToL1Msgs() { + const l2ToL1Msgs = this.forPublic + ? this.forPublic.nonRevertibleAccumulatedData.l2ToL1Msgs.concat( + this.forPublic.revertibleAccumulatedData.l2ToL1Msgs, + ) + : this.forRollup!.end.l2ToL1Msgs; + return l2ToL1Msgs.filter(m => !m.isEmpty()); + } + getNonEmptyNullifiers() { const nullifiers = this.forPublic ? this.forPublic.nonRevertibleAccumulatedData.nullifiers.concat( diff --git a/yarn-project/stdlib/src/tests/mocks.ts b/yarn-project/stdlib/src/tests/mocks.ts index ceffb21c01a8..79d33955c3d6 100644 --- a/yarn-project/stdlib/src/tests/mocks.ts +++ b/yarn-project/stdlib/src/tests/mocks.ts @@ -98,6 +98,7 @@ export const mockTx = async ( publicCalldataSize = 2, feePayer, chonkProof = ChonkProof.random(), + gasLimits, maxFeesPerGas = new GasFees(10, 10), maxPriorityFeesPerGas, gasUsed = Gas.empty(), @@ -114,6 +115,7 @@ export const mockTx = async ( publicCalldataSize?: number; feePayer?: AztecAddress; chonkProof?: ChonkProof; + gasLimits?: Gas; maxFeesPerGas?: GasFees; maxPriorityFeesPerGas?: GasFees; gasUsed?: Gas; @@ -132,7 +134,7 @@ export const mockTx = async ( const data = PrivateKernelTailCircuitPublicInputs.empty(); const firstNullifier = new Nullifier(new Fr(seed + 1), Fr.ZERO, 0); data.constants.anchorBlockHeader = anchorBlockHeader; - data.constants.txContext.gasSettings = GasSettings.default({ maxFeesPerGas, maxPriorityFeesPerGas }); + data.constants.txContext.gasSettings = GasSettings.default({ gasLimits, maxFeesPerGas, maxPriorityFeesPerGas }); data.feePayer = feePayer ?? (await AztecAddress.random()); data.gasUsed = gasUsed; data.constants.txContext.chainId = chainId; diff --git a/yarn-project/stdlib/src/tx/tx.test.ts b/yarn-project/stdlib/src/tx/tx.test.ts index 500178d46be8..8dc3affa5880 100644 --- a/yarn-project/stdlib/src/tx/tx.test.ts +++ b/yarn-project/stdlib/src/tx/tx.test.ts @@ -1,6 +1,15 @@ +import { PRIVATE_LOG_SIZE_IN_FIELDS } from '@aztec/constants'; +import { makeTuple } from '@aztec/foundation/array'; import { randomBytes } from '@aztec/foundation/crypto/random'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; import { jsonStringify } from '@aztec/foundation/json-rpc'; +import { AztecAddress } from '../aztec-address/index.js'; +import { LogHash, ScopedLogHash } from '../kernel/log_hash.js'; +import { PrivateKernelTailCircuitPublicInputs } from '../kernel/private_kernel_tail_circuit_public_inputs.js'; +import { PrivateLog } from '../logs/private_log.js'; +import { L2ToL1Message, ScopedL2ToL1Message } from '../messaging/l2_to_l1_message.js'; import { mockTx } from '../tests/mocks.js'; import { Tx, TxArray } from './tx.js'; @@ -16,6 +25,105 @@ describe('Tx', () => { const json = jsonStringify(tx); expect(await Tx.schema.parseAsync(JSON.parse(json))).toEqual(tx); }); + + describe('getPrivateTxEffectsSizeInFields', () => { + function makePrivateOnlyTx() { + const data = PrivateKernelTailCircuitPublicInputs.emptyWithNullifier(); + return Tx.from({ + txHash: Tx.random().txHash, + data, + chonkProof: Tx.random().chonkProof, + contractClassLogFields: [], + publicFunctionCalldata: [], + }); + } + + const someAddress = AztecAddress.fromField(new Fr(27)); + + it('returns overhead only for tx with just a nullifier', () => { + const tx = makePrivateOnlyTx(); + // 3 fields overhead + 1 nullifier (from emptyWithNullifier) + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(3 + 1); + }); + + it('counts note hashes', () => { + const tx = makePrivateOnlyTx(); + const end = tx.data.forRollup!.end; + end.noteHashes[0] = Fr.random(); + end.noteHashes[1] = Fr.random(); + // 3 overhead + 1 nullifier + 2 note hashes + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(3 + 1 + 2); + }); + + it('counts nullifiers', () => { + const tx = makePrivateOnlyTx(); + const end = tx.data.forRollup!.end; + end.nullifiers[1] = Fr.random(); + end.nullifiers[2] = Fr.random(); + // 3 overhead + 3 nullifiers (1 from emptyWithNullifier + 2 new) + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(3 + 3); + }); + + it('counts L2 to L1 messages', () => { + const tx = makePrivateOnlyTx(); + const end = tx.data.forRollup!.end; + end.l2ToL1Msgs[0] = new ScopedL2ToL1Message(new L2ToL1Message(EthAddress.random(), Fr.random()), someAddress); + // 3 overhead + 1 nullifier + 1 L2-to-L1 message + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(3 + 1 + 1); + }); + + it('counts private logs with length field', () => { + const tx = makePrivateOnlyTx(); + const end = tx.data.forRollup!.end; + const emittedLength = 5; + end.privateLogs[0] = new PrivateLog(makeTuple(PRIVATE_LOG_SIZE_IN_FIELDS, Fr.random), emittedLength); + // 3 overhead + 1 nullifier + (5 content + 1 length field) + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(3 + 1 + 6); + }); + + it('counts contract class logs with contract address field', () => { + const tx = makePrivateOnlyTx(); + const end = tx.data.forRollup!.end; + const logLength = 10; + end.contractClassLogsHashes[0] = new ScopedLogHash(new LogHash(Fr.random(), logLength), someAddress); + // 3 overhead + 1 nullifier + (10 content + 1 contract address) + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(3 + 1 + 11); + }); + + it('counts all side effects together', () => { + const tx = makePrivateOnlyTx(); + const end = tx.data.forRollup!.end; + + // 2 additional nullifiers (1 already from emptyWithNullifier) + end.nullifiers[1] = Fr.random(); + end.nullifiers[2] = Fr.random(); + + // 3 note hashes + end.noteHashes[0] = Fr.random(); + end.noteHashes[1] = Fr.random(); + end.noteHashes[2] = Fr.random(); + + // 1 L2-to-L1 message + end.l2ToL1Msgs[0] = new ScopedL2ToL1Message(new L2ToL1Message(EthAddress.random(), Fr.random()), someAddress); + + // 2 private logs with different lengths + end.privateLogs[0] = new PrivateLog(makeTuple(PRIVATE_LOG_SIZE_IN_FIELDS, Fr.random), 4); + end.privateLogs[1] = new PrivateLog(makeTuple(PRIVATE_LOG_SIZE_IN_FIELDS, Fr.random), 7); + + // 1 contract class log + end.contractClassLogsHashes[0] = new ScopedLogHash(new LogHash(Fr.random(), 12), someAddress); + + const expected = + 3 + // overhead + 3 + // note hashes + 3 + // nullifiers + 1 + // L2-to-L1 messages + (4 + 1) + // first private log (content + length) + (7 + 1) + // second private log (content + length) + (12 + 1); // contract class log (content + contract address) + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(expected); + }); + }); }); describe('TxArray', () => { diff --git a/yarn-project/stdlib/src/tx/tx.ts b/yarn-project/stdlib/src/tx/tx.ts index 8bbb1cc9c538..6ff10f6372bd 100644 --- a/yarn-project/stdlib/src/tx/tx.ts +++ b/yarn-project/stdlib/src/tx/tx.ts @@ -1,8 +1,9 @@ +import { DA_GAS_PER_FIELD, TX_DA_GAS_OVERHEAD } from '@aztec/constants'; import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { ZodFor } from '@aztec/foundation/schemas'; import { BufferReader, serializeArrayOfBufferableToVector, serializeToBuffer } from '@aztec/foundation/serialize'; -import type { FieldsOf } from '@aztec/foundation/types'; +import { type FieldsOf, unfreeze } from '@aztec/foundation/types'; import { z } from 'zod'; @@ -264,16 +265,24 @@ export class Tx extends Gossipable { } /** - * Estimates the tx size based on its private effects. Note that the actual size of the tx - * after processing will probably be larger, as public execution would generate more data. + * Returns the number of fields this tx's effects will occupy in the blob, + * based on its private side effects only. Accurate for txs without public calls. + * For txs with public calls, the actual size will be larger due to public execution outputs. */ - getEstimatedPrivateTxEffectsSize() { - return ( - this.data.getNonEmptyNoteHashes().length * Fr.SIZE_IN_BYTES + - this.data.getNonEmptyNullifiers().length * Fr.SIZE_IN_BYTES + - this.data.getEmittedPrivateLogsLength() * Fr.SIZE_IN_BYTES + - this.data.getEmittedContractClassLogsLength() * Fr.SIZE_IN_BYTES - ); + getPrivateTxEffectsSizeInFields(): number { + // 3 fields overhead: tx_start_marker, tx_hash, tx_fee. + // TX_DA_GAS_OVERHEAD is defined as N * DA_GAS_PER_FIELD, so this division is always exact. + const overheadFields = TX_DA_GAS_OVERHEAD / DA_GAS_PER_FIELD; + const noteHashes = this.data.getNonEmptyNoteHashes().length; + const nullifiers = this.data.getNonEmptyNullifiers().length; + const l2ToL1Msgs = this.data.getNonEmptyL2ToL1Msgs().length; + // Each private log occupies (emittedLength + 1) fields: content + length field + const privateLogFields = this.data.getNonEmptyPrivateLogs().reduce((acc, log) => acc + log.emittedLength + 1, 0); + // Each contract class log occupies (length + 1) fields: content + contract address + const contractClassLogFields = this.data + .getNonEmptyContractClassLogsHashes() + .reduce((acc, log) => acc + log.logHash.length + 1, 0); + return overheadFields + noteHashes + nullifiers + l2ToL1Msgs + privateLogFields + contractClassLogFields; } /** @@ -309,7 +318,7 @@ export class Tx extends Gossipable { /** Recomputes the tx hash. Used for testing purposes only when a property of the tx was mutated. */ public async recomputeHash(): Promise { - (this as any).txHash = await Tx.computeTxHash(this); + unfreeze(this).txHash = await Tx.computeTxHash(this); return this.txHash; } diff --git a/yarn-project/validator-client/README.md b/yarn-project/validator-client/README.md index bb232bc28184..9c25164b0c7c 100644 --- a/yarn-project/validator-client/README.md +++ b/yarn-project/validator-client/README.md @@ -223,6 +223,42 @@ This is useful for monitoring network health without participating in consensus. - `createCheckpointProposal(...)` → `CheckpointProposal`: Signs checkpoint proposal - `attestToCheckpointProposal(proposal, attestors)` → `CheckpointAttestation[]`: Creates attestations for given addresses +## Block Building Limits + +L1 enforces gas and blob capacity per checkpoint. The node enforces these during block building to avoid L1 rejection. Three dimensions are metered: L2 gas (mana), DA gas, and blob fields. DA gas maps to blob fields today (`daGas = blobFields * 32`) but both are tracked independently. + +### Checkpoint limits + +| Dimension | Source | Budget | +| --- | --- | --- | +| L2 gas (mana) | `rollup.getManaLimit()` | Fetched from L1 at startup | +| DA gas | `MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT` | 786,432 (6 blobs × 4096 fields × 32 gas/field) | +| Blob fields | `BLOBS_PER_CHECKPOINT × FIELDS_PER_BLOB` | 24,576 minus checkpoint/block-end overhead | + +### Per-block budgets + +Per-block budgets prevent one block from consuming the entire checkpoint budget. + +**Proposer**: `SequencerClient.computeBlockGasLimits()` derives budgets at startup as `min(checkpointLimit, ceil(checkpointLimit / maxBlocks * multiplier))`, where `maxBlocks` comes from the timetable and `multiplier` defaults to 2. The multiplier greater than 1 allows early blocks to use more than their even share of the checkpoint budget, since different blocks hit different limit dimensions (L2 gas, DA gas, blob fields) — a strict even split would waste capacity. Operators can override via `SEQ_MAX_L2_BLOCK_GAS` / `SEQ_MAX_DA_BLOCK_GAS` (capped at checkpoint limits). + +**Validator**: Does not enforce per-block gas budgets. Only checkpoint-level limits are checked, so that proposers can freely distribute capacity across blocks within a checkpoint. + +**Checkpoint-level capping**: `CheckpointBuilder.capLimitsByCheckpointBudgets()` always runs before tx processing, capping per-block limits by `checkpointBudget - sum(used by prior blocks)` for all three dimensions. This applies to both proposer and validator paths. + +### Per-transaction enforcement + +**Mempool entry** (`GasLimitsValidator`): L2 gas must be ≤ `MAX_PROCESSABLE_L2_GAS` (6,540,000) and ≥ fixed minimums. + +**Block building** (`PublicProcessor.process`): Before processing, txs are skipped if their estimated blob fields or gas limits would exceed the block budget. After processing, actual values are checked and the tx is reverted if limits are exceeded. + +### Gas limit configuration + +| Variable | Default | Description | +| --- | --- | --- | +| `SEQ_MAX_L2_BLOCK_GAS` | *auto* | Per-block L2 gas. Auto-derived from `rollupManaLimit / maxBlocks * multiplier`. | +| `SEQ_MAX_DA_BLOCK_GAS` | *auto* | Per-block DA gas. Auto-derived from checkpoint DA limit / maxBlocks * multiplier. | +| `SEQ_GAS_PER_BLOCK_ALLOCATION_MULTIPLIER` | 2 | Multiplier for per-block budget computation. | + ## Testing Patterns ### Common Mocks diff --git a/yarn-project/validator-client/src/checkpoint_builder.test.ts b/yarn-project/validator-client/src/checkpoint_builder.test.ts index abf782d6b8ea..19f307fa5a0b 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.test.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.test.ts @@ -1,3 +1,10 @@ +import { NUM_CHECKPOINT_END_MARKER_FIELDS, getNumBlockEndBlobFields } from '@aztec/blob-lib/encoding'; +import { + BLOBS_PER_CHECKPOINT, + DA_GAS_PER_FIELD, + FIELDS_PER_BLOB, + MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT, +} from '@aztec/constants'; import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; @@ -12,6 +19,7 @@ import { type FullNodeBlockBuilderConfig, type MerkleTreeWriteOperations, NoValidTxsError, + type PublicProcessorLimits, type PublicProcessorValidator, } from '@aztec/stdlib/interfaces/server'; import type { CheckpointGlobalVariables, GlobalVariables, ProcessedTx, Tx } from '@aztec/stdlib/tx'; @@ -51,26 +59,34 @@ describe('CheckpointBuilder', () => { public override makeBlockBuilderDeps(_globalVariables: GlobalVariables, _fork: MerkleTreeWriteOperations) { return Promise.resolve({ processor, validator }); } + + /** Expose for testing */ + public testCapLimits(opts: PublicProcessorLimits) { + return this.capLimitsByCheckpointBudgets(opts); + } } - beforeEach(() => { - lightweightCheckpointBuilder = mock({ checkpointNumber, constants }); + /** Creates a mock block with the given mana, tx blob fields, and total block blob fields. */ + function createMockBlock(opts: { manaUsed: number; txBlobFields: number[]; blockBlobFieldCount: number }) { + return { + header: { totalManaUsed: { toNumber: () => opts.manaUsed } }, + body: { + txEffects: opts.txBlobFields.map(n => ({ getNumBlobFields: () => n })), + }, + toBlobFields: () => new Array(opts.blockBlobFieldCount).fill(Fr.ZERO), + computeDAGasUsed: () => opts.txBlobFields.reduce((total, n) => total + n, 0) * DA_GAS_PER_FIELD, + } as unknown as L2Block; + } - fork = mock(); + function setupBuilder(overrideConfig?: Partial) { config = { l1GenesisTime: 0n, slotDuration: 24, l1ChainId: 1, rollupVersion: 1, + rollupManaLimit: 200_000_000, + ...overrideConfig, }; - contractDataSource = mock(); - dateProvider = new TestDateProvider(); - telemetryClient = mock(); - telemetryClient.getMeter.mockReturnValue(mock()); - telemetryClient.getTracer.mockReturnValue(mock()); - - processor = mock(); - validator = mock(); checkpointBuilder = new TestCheckpointBuilder( lightweightCheckpointBuilder as unknown as LightweightCheckpointBuilder, @@ -80,6 +96,23 @@ describe('CheckpointBuilder', () => { dateProvider, telemetryClient, ); + } + + beforeEach(() => { + lightweightCheckpointBuilder = mock({ checkpointNumber, constants }); + lightweightCheckpointBuilder.getBlocks.mockReturnValue([]); + + fork = mock(); + contractDataSource = mock(); + dateProvider = new TestDateProvider(); + telemetryClient = mock(); + telemetryClient.getMeter.mockReturnValue(mock()); + telemetryClient.getTracer.mockReturnValue(mock()); + + processor = mock(); + validator = mock(); + + setupBuilder(); }); describe('buildBlock', () => { @@ -90,11 +123,10 @@ describe('CheckpointBuilder', () => { lightweightCheckpointBuilder.addBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); processor.process.mockResolvedValue([ - [{ hash: Fr.random(), gasUsed: { publicGas: Gas.empty() } } as unknown as ProcessedTx], + [{ hash: Fr.random() } as unknown as ProcessedTx], [], // failedTxs [], // usedTxs [], // returnValues - 0, // usedTxBlobFields [], // debugLogs ]); @@ -118,7 +150,6 @@ describe('CheckpointBuilder', () => { [], // failedTxs [], // usedTxs [], // returnValues - 0, // usedTxBlobFields [], // debugLogs ]); @@ -138,7 +169,6 @@ describe('CheckpointBuilder', () => { [failedTx], // failedTxs [], // usedTxs [], // returnValues - 0, // usedTxBlobFields [], // debugLogs ]); @@ -147,4 +177,173 @@ describe('CheckpointBuilder', () => { expect(lightweightCheckpointBuilder.addBlock).not.toHaveBeenCalled(); }); }); + + describe('capLimitsByCheckpointBudgets', () => { + const totalBlobCapacity = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB - NUM_CHECKPOINT_END_MARKER_FIELDS; + const firstBlockEndOverhead = getNumBlockEndBlobFields(true); + const nonFirstBlockEndOverhead = getNumBlockEndBlobFields(false); + + it('caps L2 gas by remaining checkpoint mana', () => { + const rollupManaLimit = 1_000_000; + const priorManaUsed = 600_000; + setupBuilder({ rollupManaLimit }); + + lightweightCheckpointBuilder.getBlocks.mockReturnValue([ + createMockBlock({ manaUsed: priorManaUsed, txBlobFields: [10], blockBlobFieldCount: 20 }), + ]); + + const opts: PublicProcessorLimits = { maxBlockGas: new Gas(Infinity, 800_000) }; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + // Remaining mana = 1_000_000 - 600_000 = 400_000. Per-block = 800_000. Capped to 400_000. + expect(capped.maxBlockGas!.l2Gas).toBe(400_000); + }); + + it('uses per-block L2 gas limit when tighter than remaining mana', () => { + const rollupManaLimit = 1_000_000; + const priorManaUsed = 200_000; + setupBuilder({ rollupManaLimit }); + + lightweightCheckpointBuilder.getBlocks.mockReturnValue([ + createMockBlock({ manaUsed: priorManaUsed, txBlobFields: [10], blockBlobFieldCount: 20 }), + ]); + + const opts: PublicProcessorLimits = { maxBlockGas: new Gas(Infinity, 500_000) }; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + // Remaining mana = 800_000. Per-block = 500_000. Uses 500_000. + expect(capped.maxBlockGas!.l2Gas).toBe(500_000); + }); + + it('uses per-block L2 gas limit when remaining mana is larger', () => { + setupBuilder(); // rollupManaLimit defaults to 200_000_000 + + lightweightCheckpointBuilder.getBlocks.mockReturnValue([ + createMockBlock({ manaUsed: 100_000, txBlobFields: [10], blockBlobFieldCount: 20 }), + ]); + + const opts: PublicProcessorLimits = { maxBlockGas: new Gas(Infinity, 500_000) }; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + // Remaining mana = 200_000_000 - 100_000 >> 500_000, so per-block limit is used + expect(capped.maxBlockGas!.l2Gas).toBe(500_000); + }); + + it('caps DA gas by remaining checkpoint DA gas budget', () => { + // Each prior tx blob field = DA_GAS_PER_FIELD DA gas + const txBlobFields = [1000]; // 1000 fields * 32 = 32000 DA gas + const priorDAGas = 1000 * DA_GAS_PER_FIELD; + setupBuilder(); + + lightweightCheckpointBuilder.getBlocks.mockReturnValue([ + createMockBlock({ manaUsed: 0, txBlobFields, blockBlobFieldCount: 1010 }), + ]); + + const perBlockDAGas = 500_000; + const opts: PublicProcessorLimits = { maxBlockGas: new Gas(perBlockDAGas, Infinity) }; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + // Remaining DA gas = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT - priorDAGas + const expectedRemainingDAGas = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT - priorDAGas; + expect(capped.maxBlockGas!.daGas).toBe(Math.min(perBlockDAGas, expectedRemainingDAGas)); + }); + + it('sets maxBlockGas from remaining budgets when caller does not provide it', () => { + const rollupManaLimit = 1_000_000; + const priorManaUsed = 600_000; + setupBuilder({ rollupManaLimit }); + + lightweightCheckpointBuilder.getBlocks.mockReturnValue([ + createMockBlock({ manaUsed: priorManaUsed, txBlobFields: [100], blockBlobFieldCount: 110 }), + ]); + + const opts: PublicProcessorLimits = {}; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + expect(capped.maxBlockGas!.l2Gas).toBe(400_000); + expect(capped.maxBlockGas!.daGas).toBe(MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT - 100 * DA_GAS_PER_FIELD); + }); + + it('caps blob fields by remaining checkpoint blob capacity', () => { + const blockBlobFieldCount = 100; // Prior block used 100 blob fields + setupBuilder(); + + lightweightCheckpointBuilder.getBlocks.mockReturnValue([ + createMockBlock({ manaUsed: 0, txBlobFields: [], blockBlobFieldCount }), + ]); + + const opts: PublicProcessorLimits = { maxBlobFields: 99999 }; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + // Second block: remaining = totalBlobCapacity - 100, minus non-first block end overhead + const expectedMaxBlobFields = totalBlobCapacity - blockBlobFieldCount - nonFirstBlockEndOverhead; + expect(capped.maxBlobFields).toBe(expectedMaxBlobFields); + }); + + it('sets blob fields from remaining capacity when caller does not set them', () => { + setupBuilder(); + + lightweightCheckpointBuilder.getBlocks.mockReturnValue([]); + + const opts: PublicProcessorLimits = {}; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + // First block: full capacity minus first block end overhead + const expectedMaxBlobFields = totalBlobCapacity - firstBlockEndOverhead; + expect(capped.maxBlobFields).toBe(expectedMaxBlobFields); + }); + + it('accumulates limits across multiple prior blocks', () => { + const rollupManaLimit = 1_000_000; + setupBuilder({ rollupManaLimit }); + + const block1 = createMockBlock({ manaUsed: 300_000, txBlobFields: [200], blockBlobFieldCount: 210 }); + const block2 = createMockBlock({ manaUsed: 200_000, txBlobFields: [150], blockBlobFieldCount: 160 }); + lightweightCheckpointBuilder.getBlocks.mockReturnValue([block1, block2]); + + const opts: PublicProcessorLimits = { maxBlockGas: new Gas(Infinity, Infinity) }; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + // Remaining mana = 1_000_000 - 300_000 - 200_000 = 500_000 + expect(capped.maxBlockGas!.l2Gas).toBe(500_000); + + // Remaining DA gas = MAX - (200 + 150) * DA_GAS_PER_FIELD + const expectedRemainingDAGas = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT - (200 + 150) * DA_GAS_PER_FIELD; + expect(capped.maxBlockGas!.daGas).toBe(expectedRemainingDAGas); + + // Remaining blob fields = capacity - 210 - 160 - nonFirstBlockEndOverhead + const expectedBlobFields = totalBlobCapacity - 210 - 160 - nonFirstBlockEndOverhead; + expect(capped.maxBlobFields).toBe(expectedBlobFields); + }); + + it('tracks remaining blob field capacity across multiple blocks', () => { + setupBuilder(); + + const block1BlobFieldCount = 200; + const block2BlobFieldCount = 150; + + // After one block has been built, remaining capacity should account for that block's usage + const block1 = createMockBlock({ manaUsed: 0, txBlobFields: [], blockBlobFieldCount: block1BlobFieldCount }); + lightweightCheckpointBuilder.getBlocks.mockReturnValue([block1]); + + const afterOneBlock = (checkpointBuilder as TestCheckpointBuilder).testCapLimits({}); + + const expectedAfterOneBlock = totalBlobCapacity - block1BlobFieldCount - nonFirstBlockEndOverhead; + expect(afterOneBlock.maxBlobFields).toBe(expectedAfterOneBlock); + + // After two blocks have been built, remaining capacity should further decrease + const block2 = createMockBlock({ manaUsed: 0, txBlobFields: [], blockBlobFieldCount: block2BlobFieldCount }); + lightweightCheckpointBuilder.getBlocks.mockReturnValue([block1, block2]); + + const afterTwoBlocks = (checkpointBuilder as TestCheckpointBuilder).testCapLimits({}); + + const expectedAfterTwoBlocks = + totalBlobCapacity - block1BlobFieldCount - block2BlobFieldCount - nonFirstBlockEndOverhead; + expect(afterTwoBlocks.maxBlobFields).toBe(expectedAfterTwoBlocks); + + // Verify the limit actually decreased between calls + expect(afterTwoBlocks.maxBlobFields).toBeLessThan(afterOneBlock.maxBlobFields!); + expect(afterOneBlock.maxBlobFields! - afterTwoBlocks.maxBlobFields!).toBe(block2BlobFieldCount); + }); + }); }); diff --git a/yarn-project/validator-client/src/checkpoint_builder.ts b/yarn-project/validator-client/src/checkpoint_builder.ts index 74059c27ce35..3c387b1da257 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.ts @@ -1,5 +1,7 @@ +import { NUM_CHECKPOINT_END_MARKER_FIELDS, getNumBlockEndBlobFields } from '@aztec/blob-lib/encoding'; +import { BLOBS_PER_CHECKPOINT, FIELDS_PER_BLOB, MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT } from '@aztec/constants'; import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; -import { merge, pick } from '@aztec/foundation/collection'; +import { merge, pick, sum } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; import { bufferToHex } from '@aztec/foundation/string'; @@ -65,6 +67,7 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { /** * Builds a single block within this checkpoint. + * Automatically caps gas and blob field limits based on checkpoint-level budgets and prior blocks. */ async buildBlock( pendingTxs: Iterable | AsyncIterable, @@ -94,8 +97,14 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { }); const { processor, validator } = await this.makeBlockBuilderDeps(globalVariables, this.fork); - const [publicProcessorDuration, [processedTxs, failedTxs, usedTxs, _, usedTxBlobFields]] = await elapsed(() => - processor.process(pendingTxs, opts, validator), + // Cap gas limits amd available blob fields by remaining checkpoint-level budgets + const cappedOpts: PublicProcessorLimits & { expectedEndState?: StateReference } = { + ...opts, + ...this.capLimitsByCheckpointBudgets(opts), + }; + + const [publicProcessorDuration, [processedTxs, failedTxs, usedTxs]] = await elapsed(() => + processor.process(pendingTxs, cappedOpts, validator), ); // Throw if we didn't collect a single valid tx and we're not allowed to build empty blocks @@ -109,9 +118,6 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { expectedEndState: opts.expectedEndState, }); - // How much public gas was processed - const publicGas = processedTxs.reduce((acc, tx) => acc.add(tx.gasUsed.publicGas), Gas.empty()); - this.log.debug('Built block within checkpoint', { header: block.header.toInspect(), processedTxs: processedTxs.map(tx => tx.hash.toString()), @@ -120,12 +126,10 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { return { block, - publicGas, publicProcessorDuration, numTxs: processedTxs.length, failedTxs, usedTxs, - usedTxBlobFields, }; } @@ -147,6 +151,49 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { return this.checkpointBuilder.clone().completeCheckpoint(); } + /** + * Caps per-block gas and blob field limits by remaining checkpoint-level budgets. + * Computes remaining L2 gas (mana), DA gas, and blob fields from blocks already added to the checkpoint, + * then returns opts with maxBlockGas and maxBlobFields capped accordingly. + */ + protected capLimitsByCheckpointBudgets( + opts: PublicProcessorLimits, + ): Pick { + const existingBlocks = this.checkpointBuilder.getBlocks(); + + // Remaining L2 gas (mana) + // IMPORTANT: This assumes mana is computed solely based on L2 gas used in transactions. + // This may change in the future. + const usedMana = sum(existingBlocks.map(b => b.header.totalManaUsed.toNumber())); + const remainingMana = this.config.rollupManaLimit - usedMana; + + // Remaining DA gas + const usedDAGas = sum(existingBlocks.map(b => b.computeDAGasUsed())) ?? 0; + const remainingDAGas = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT - usedDAGas; + + // Remaining blob fields (block blob fields include both tx data and block-end overhead) + const usedBlobFields = sum(existingBlocks.map(b => b.toBlobFields().length)); + const totalBlobCapacity = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB - NUM_CHECKPOINT_END_MARKER_FIELDS; + const isFirstBlock = existingBlocks.length === 0; + const blockEndOverhead = getNumBlockEndBlobFields(isFirstBlock); + const maxBlobFieldsForTxs = totalBlobCapacity - usedBlobFields - blockEndOverhead; + + // Cap L2 gas by remaining checkpoint mana + const cappedL2Gas = Math.min(opts.maxBlockGas?.l2Gas ?? remainingMana, remainingMana); + + // Cap DA gas by remaining checkpoint DA gas budget + const cappedDAGas = Math.min(opts.maxBlockGas?.daGas ?? remainingDAGas, remainingDAGas); + + // Cap blob fields by remaining checkpoint blob capacity + const cappedBlobFields = + opts.maxBlobFields !== undefined ? Math.min(opts.maxBlobFields, maxBlobFieldsForTxs) : maxBlobFieldsForTxs; + + return { + maxBlockGas: new Gas(cappedDAGas, cappedL2Gas), + maxBlobFields: cappedBlobFields, + }; + } + protected async makeBlockBuilderDeps(globalVariables: GlobalVariables, fork: MerkleTreeWriteOperations) { const txPublicSetupAllowList = [ ...(await getDefaultAllowedSetupFunctions()), diff --git a/yarn-project/validator-client/src/validator.ha.integration.test.ts b/yarn-project/validator-client/src/validator.ha.integration.test.ts index 5370ba592af5..cba52926ec05 100644 --- a/yarn-project/validator-client/src/validator.ha.integration.test.ts +++ b/yarn-project/validator-client/src/validator.ha.integration.test.ts @@ -90,6 +90,7 @@ describe('ValidatorClient HA Integration', () => { slotDuration: 24, l1ChainId: 1, rollupVersion: 1, + rollupManaLimit: 200_000_000, }); worldState = mock(); epochCache = mock(); diff --git a/yarn-project/validator-client/src/validator.integration.test.ts b/yarn-project/validator-client/src/validator.integration.test.ts index 811a84927be0..967f3da9e042 100644 --- a/yarn-project/validator-client/src/validator.integration.test.ts +++ b/yarn-project/validator-client/src/validator.integration.test.ts @@ -23,7 +23,7 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { CommitteeAttestation, L2Block } from '@aztec/stdlib/block'; import { L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import { type L1RollupConstants, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; -import { GasFees } from '@aztec/stdlib/gas'; +import { Gas, GasFees } from '@aztec/stdlib/gas'; import { tryStop } from '@aztec/stdlib/interfaces/server'; import { computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import { type BlockProposal, CheckpointProposal } from '@aztec/stdlib/p2p'; @@ -127,6 +127,7 @@ describe('ValidatorClient Integration', () => { slotDuration: l1Constants.slotDuration, l1ChainId: chainId.toNumber(), rollupVersion: version.toNumber(), + rollupManaLimit: 200_000_000, txPublicSetupAllowListExtend: [], }, synchronizer, @@ -242,6 +243,8 @@ describe('ValidatorClient Integration', () => { vkTreeRoot: getVKTreeRoot(), protocolContractsHash, anchorBlockHeader: anchorBlockHeader ?? genesisBlockHeader, + gasLimits: new Gas(100_000, 1_000_000), + gasUsed: new Gas(10_000, 100_000), maxFeesPerGas: new GasFees(1e12, 1e12), feePayer, }); @@ -564,6 +567,35 @@ describe('ValidatorClient Integration', () => { expect(isValid).toBe(false); }); + it('rejects block that would exceed checkpoint mana limit', async () => { + const { blocks } = await buildCheckpoint( + CheckpointNumber(1), + slotNumber, + emptyL1ToL2Messages, + emptyPreviousCheckpointOutHashes, + BlockNumber(1), + 3, + () => buildTxs(2), + ); + + // Measure total mana used by the first two blocks + const manaFirstTwo = + blocks[0].block.header.totalManaUsed.toNumber() + blocks[1].block.header.totalManaUsed.toNumber(); + + // Set rollupManaLimit to only cover the first two blocks' actual mana. + // Block 3 re-execution will have 0 remaining mana, so the actual gas check + // in the public processor will reject all txs, producing a tx count mismatch. + attestor.checkpointsBuilder.updateConfig({ rollupManaLimit: manaFirstTwo }); + + // Blocks 1 and 2 should validate successfully + await attestorValidateBlocks(blocks.slice(0, 2)); + + // Block 3 should fail: remaining checkpoint mana is 0, so the processor + // stops after the first tx's actual gas exceeds the limit. + const isValid = await attestor.validator.validateBlockProposal(blocks[2].proposal, mockPeerId); + expect(isValid).toBe(false); + }); + it('refuses block proposal with mismatching l1 to l2 messages', async () => { const l1ToL2Messages = makeInboxMessages(4, { messagesPerCheckpoint: 4 }); await proposer.archiver.dataStore.addL1ToL2Messages(l1ToL2Messages); diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index 7d9c4b975288..1477a94b01b2 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -25,7 +25,6 @@ import { OffenseType, WANT_TO_SLASH_EVENT } from '@aztec/slasher'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block'; import type { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; -import { Gas } from '@aztec/stdlib/gas'; import type { SlasherConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import type { BlockProposal } from '@aztec/stdlib/p2p'; @@ -110,6 +109,7 @@ describe('ValidatorClient', () => { slotDuration: 24, l1ChainId: 1, rollupVersion: 1, + rollupManaLimit: 200_000_000, }); worldState = mock(); epochCache = mock(); @@ -366,9 +366,7 @@ describe('ValidatorClient', () => { publicProcessorDuration: 0, numTxs: proposal.txHashes.length, failedTxs: [], - publicGas: Gas.empty(), usedTxs: [], - usedTxBlobFields: 0, block: { header: clonedBlockHeader, body: { txEffects: times(proposal.txHashes.length, () => TxEffect.empty()) }, diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index 0fb99d4c6e41..2fad5db0bca7 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -24,6 +24,7 @@ import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block'; +import { validateCheckpoint } from '@aztec/stdlib/checkpoint'; import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import type { CreateCheckpointProposalLastBlockData, @@ -766,6 +767,18 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) return { isValid: false, reason: 'out_hash_mismatch' }; } + // Final round of validations on the checkpoint, just in case. + try { + validateCheckpoint(computedCheckpoint, { + rollupManaLimit: this.checkpointsBuilder.getConfig().rollupManaLimit, + maxDABlockGas: undefined, + maxL2BlockGas: undefined, + }); + } catch (err) { + this.log.warn(`Checkpoint validation failed: ${err}`, proposalInfo); + return { isValid: false, reason: 'checkpoint_validation_failed' }; + } + this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo); return { isValid: true }; } finally { diff --git a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts index 38a4e9ce81e5..ad85df243053 100644 --- a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts +++ b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts @@ -1,4 +1,4 @@ -import { GENESIS_BLOCK_HEADER_HASH, INITIAL_L2_BLOCK_NUM, INITIAL_L2_CHECKPOINT_NUM } from '@aztec/constants'; +import { GENESIS_BLOCK_HEADER_HASH, INITIAL_CHECKPOINT_NUMBER, INITIAL_L2_BLOCK_NUM } from '@aztec/constants'; import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import type { Fr } from '@aztec/foundation/curves/bn254'; import { type Logger, createLogger } from '@aztec/foundation/log'; @@ -263,15 +263,15 @@ export class ServerWorldStateSynchronizer proposed: latestBlockId, checkpointed: { block: { number: INITIAL_L2_BLOCK_NUM, hash: GENESIS_BLOCK_HEADER_HASH.toString() }, - checkpoint: { number: INITIAL_L2_CHECKPOINT_NUM, hash: genesisCheckpointHeaderHash }, + checkpoint: { number: INITIAL_CHECKPOINT_NUMBER, hash: genesisCheckpointHeaderHash }, }, finalized: { block: { number: status.finalizedBlockNumber, hash: finalizedBlockHash ?? '' }, - checkpoint: { number: INITIAL_L2_CHECKPOINT_NUM, hash: genesisCheckpointHeaderHash }, + checkpoint: { number: INITIAL_CHECKPOINT_NUMBER, hash: genesisCheckpointHeaderHash }, }, proven: { block: { number: provenBlockNumber, hash: provenBlockHash ?? '' }, - checkpoint: { number: INITIAL_L2_CHECKPOINT_NUM, hash: genesisCheckpointHeaderHash }, + checkpoint: { number: INITIAL_CHECKPOINT_NUMBER, hash: genesisCheckpointHeaderHash }, }, }; }