From cfe074b2f859ec1b22539b3334e755a4e85a3555 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 27 Feb 2026 18:03:21 -0300 Subject: [PATCH 01/10] feat(sequencer): set block building limits from checkpoint limits --- .../operators/reference/changelog/v4.md | 25 ++ .../aztec-node/src/aztec-node/server.ts | 9 +- .../e2e_l1_publisher/e2e_l1_publisher.test.ts | 2 +- yarn-project/foundation/src/config/env_var.ts | 2 +- .../light/lightweight_checkpoint_builder.ts | 4 + .../src/job/epoch-proving-job.test.ts | 6 +- .../src/client/sequencer-client.ts | 96 +++++++- yarn-project/sequencer-client/src/config.ts | 23 +- .../sequencer/checkpoint_proposal_job.test.ts | 61 +---- .../checkpoint_proposal_job.timing.test.ts | 3 +- .../src/sequencer/checkpoint_proposal_job.ts | 45 +--- .../checkpoint_voter.ha.integration.test.ts | 1 + .../src/sequencer/sequencer.test.ts | 6 +- .../src/sequencer/timetable.ts | 14 +- .../sequencer-client/src/sequencer/types.ts | 5 +- .../src/test/mock_checkpoint_builder.ts | 4 +- .../public_processor/public_processor.test.ts | 21 +- .../public_processor/public_processor.ts | 39 +-- .../stdlib/src/interfaces/block-builder.ts | 20 +- yarn-project/stdlib/src/interfaces/configs.ts | 11 +- ...ivate_kernel_tail_circuit_public_inputs.ts | 9 + yarn-project/stdlib/src/tests/mocks.ts | 4 +- yarn-project/stdlib/src/tx/tx.test.ts | 108 +++++++++ yarn-project/stdlib/src/tx/tx.ts | 30 ++- yarn-project/validator-client/README.md | 36 +++ .../src/block_proposal_handler.ts | 11 + .../src/checkpoint_builder.test.ts | 228 ++++++++++++++++-- .../src/checkpoint_builder.ts | 69 +++++- .../src/validator.ha.integration.test.ts | 1 + .../src/validator.integration.test.ts | 4 +- .../validator-client/src/validator.test.ts | 4 +- 31 files changed, 686 insertions(+), 215 deletions(-) diff --git a/docs/docs-operate/operators/reference/changelog/v4.md b/docs/docs-operate/operators/reference/changelog/v4.md index de6d28118215..091b979b9004 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. + ## Removed features ## New features diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 907a6a2c5e2c..178d7bdd4c6f 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); @@ -350,7 +351,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, @@ -487,7 +488,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, @@ -1278,7 +1279,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/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..fd89cbf8ce21 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,10 @@ export class SequencerClient { const l1PublishingTimeBasedOnChain = isAnvilTestChain(config.l1ChainId) ? 1 : ethereumSlotDuration; const l1PublishingTime = config.l1PublishingTime ?? l1PublishingTimeBasedOnChain; + 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 +178,7 @@ export class SequencerClient { deps.dateProvider, epochCache, rollupContract, - { ...config, l1PublishingTime, maxL2BlockGas: sequencerManaLimit }, + { ...config, l1PublishingTime, maxL2BlockGas, maxDABlockGas }, telemetryClient, log, ); @@ -233,4 +240,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 469651fba387..4b65c1d0cc6b 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -35,15 +35,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: 32, 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 @@ -56,7 +54,7 @@ export const DefaultSequencerConfig: ResolvedSequencerConfig = { shuffleAttestationOrdering: false, skipPushProposedBlocksToArchiver: false, skipPublishingCheckpointsPercent: 0, -}; +} satisfies ResolvedSequencerConfig; /** * Configuration settings for the SequencerClient. @@ -99,12 +97,18 @@ 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.', + ...numberConfigHelper(DefaultSequencerConfig.gasPerBlockAllocationMultiplier), }, coinbase: { env: 'COINBASE', @@ -124,11 +128,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 184e83a76506..90a56a08c316 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, @@ -384,9 +382,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; @@ -419,7 +414,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. @@ -445,12 +439,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 @@ -518,18 +509,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}`, @@ -563,15 +546,13 @@ 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; - + // Gas and blob field limits are capped by checkpoint-level budgets inside CheckpointBuilder.buildBlock() 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, }; @@ -602,7 +583,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', @@ -613,7 +594,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`, @@ -621,9 +602,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/public_processor/public_processor.test.ts b/yarn-project/simulator/src/public/public_processor/public_processor.test.ts index 03276162b887..f86b4a635292 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 }); + + 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..3c78a570221a 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 } = limits; const { preprocessValidator, nullifierCache } = validator; const result: ProcessedTx[] = []; const usedTxs: Tx[] = []; @@ -188,20 +188,19 @@ 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 + const txBlobFields = tx.getPrivateTxEffectsSizeInFields(); + if (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) const txGasLimit = tx.data.constants.txContext.gasSettings.gasLimits; if (maxBlockGas !== undefined && totalBlockGas.add(txGasLimit).gtAny(maxBlockGas)) { this.log.warn(`Skipping processing of tx ${txHash} due to block gas limit`, { @@ -252,21 +251,7 @@ 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 // Note: maxBlobFields already accounts for block end blob fields and previous blocks in checkpoint. @@ -368,7 +353,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/interfaces/block-builder.ts b/yarn-project/stdlib/src/interfaces/block-builder.ts index b5b5ea9a4c1a..3ea9423bf8f1 100644 --- a/yarn-project/stdlib/src/interfaces/block-builder.ts +++ b/yarn-project/stdlib/src/interfaces/block-builder.ts @@ -37,7 +37,6 @@ export interface IBlockFactory extends ProcessedTxHandler { export interface PublicProcessorLimits { maxTransactions?: number; - maxBlockSize?: number; maxBlockGas?: Gas; maxBlobFields?: number; deadline?: Date; @@ -50,7 +49,18 @@ export interface PublicProcessorValidator { export type FullNodeBlockBuilderConfig = Pick & Pick & - Pick; + Pick< + SequencerConfig, + | 'txPublicSetupAllowList' + | 'fakeProcessingDelayPerTxMs' + | 'fakeThrowAfterProcessingTxCount' + | 'maxTxsPerBlock' + | 'maxL2BlockGas' + | 'maxDABlockGas' + > & { + /** Total L2 gas (mana) allowed per checkpoint. Fetched from L1 getManaLimit(). */ + rollupManaLimit: number; + }; export const FullNodeBlockBuilderConfigKeys: (keyof FullNodeBlockBuilderConfig)[] = [ 'l1GenesisTime', @@ -60,6 +70,10 @@ export const FullNodeBlockBuilderConfigKeys: (keyof FullNodeBlockBuilderConfig)[ 'txPublicSetupAllowList', '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. */ @@ -73,12 +87,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 3cd2912c078f..f2aab9ae0301 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; /** The list of functions calls allowed to run in setup */ txPublicSetupAllowList?: AllowedElement[]; - /** Max block size */ - maxBlockSizeInBytes?: number; /** Payload address to vote for */ governanceProposerPayload?: EthAddress; /** Whether to enforce the time table when building blocks */ @@ -86,12 +86,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(), txPublicSetupAllowList: z.array(AllowedElementSchema).optional(), - maxBlockSizeInBytes: z.number().optional(), governanceProposerPayload: schemas.EthAddress.optional(), l1PublishingTime: z.number().optional(), enforceTimeTable: z.boolean().optional(), @@ -128,7 +128,10 @@ type SequencerConfigOptionalKeys = | 'l1PublishingTime' | 'txPublicSetupAllowList' | '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..35e599df93db 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,23 @@ 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 + 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 +317,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..e37a433754ef 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. Operators can override via `SEQ_MAX_L2_BLOCK_GAS` / `SEQ_MAX_DA_BLOCK_GAS` (capped at checkpoint limits). + +**Validator**: Does not compute per-block budgets. Uses operator-configured values if set, otherwise relies solely on checkpoint-level capping (see below). + +**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/block_proposal_handler.ts b/yarn-project/validator-client/src/block_proposal_handler.ts index 0c8812aee9a8..78abafd61d48 100644 --- a/yarn-project/validator-client/src/block_proposal_handler.ts +++ b/yarn-project/validator-client/src/block_proposal_handler.ts @@ -10,6 +10,7 @@ import type { P2P, PeerId } from '@aztec/p2p'; import { BlockProposalValidator } from '@aztec/p2p/msg_validators'; import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block'; import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; +import { Gas } from '@aztec/stdlib/gas'; import type { ITxProvider, ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import type { BlockProposal } from '@aztec/stdlib/p2p'; @@ -483,6 +484,16 @@ export class BlockProposalHandler { const result = await checkpointBuilder.buildBlock(txs, blockNumber, blockHeader.globalVariables.timestamp, { deadline, expectedEndState: blockHeader.state, + maxTransactions: config.maxTxsPerBlock, + // Note: Unlike the sequencer, the validator does not compute per-block gas budgets from + // checkpoint limits (using timetable maxNumberOfBlocks * multiplier). These limits are only + // set if the operator explicitly configures maxL2BlockGas/maxDABlockGas. + // Checkpoint-level caps are still enforced inside CheckpointBuilder.buildBlock via + // capLimitsByCheckpointBudgets, so L1 rejection is prevented even without per-block limits. + maxBlockGas: + config.maxL2BlockGas !== undefined || config.maxDABlockGas !== undefined + ? new Gas(config.maxDABlockGas ?? Infinity, config.maxL2BlockGas ?? Infinity) + : undefined, }); const { block, failedTxs } = result; diff --git a/yarn-project/validator-client/src/checkpoint_builder.test.ts b/yarn-project/validator-client/src/checkpoint_builder.test.ts index abf782d6b8ea..1121aa2b07f3 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,33 @@ 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), + } 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 +95,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 +122,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 +149,6 @@ describe('CheckpointBuilder', () => { [], // failedTxs [], // usedTxs [], // returnValues - 0, // usedTxBlobFields [], // debugLogs ]); @@ -138,7 +168,6 @@ describe('CheckpointBuilder', () => { [failedTx], // failedTxs [], // usedTxs [], // returnValues - 0, // usedTxBlobFields [], // debugLogs ]); @@ -147,4 +176,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 c8ec5c5671fe..a450ee4ce530 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.ts @@ -1,5 +1,12 @@ +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 } 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 +72,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 +102,11 @@ 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 = { 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 +120,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 +128,10 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { return { block, - publicGas, publicProcessorDuration, numTxs: processedTxs.length, failedTxs, usedTxs, - usedTxBlobFields, }; } @@ -147,6 +153,53 @@ 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: DA gas = tx blob fields * DA_GAS_PER_FIELD + // IMPORTANT: 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 here. + const usedDAGas = + sum(existingBlocks.map(b => sum(b.body.txEffects.map(tx => tx.getNumBlobFields())))) * DA_GAS_PER_FIELD; + 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 = this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); const contractsDB = new PublicContractsDB(this.contractDataSource, this.log.getBindings()); 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 dd30f91bd4be..317d9290855e 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'; @@ -128,6 +128,7 @@ describe('ValidatorClient Integration', () => { l1ChainId: chainId.toNumber(), rollupVersion: version.toNumber(), txPublicSetupAllowList: [], + rollupManaLimit: 200_000_000, }, synchronizer, archiver, @@ -242,6 +243,7 @@ describe('ValidatorClient Integration', () => { vkTreeRoot: getVKTreeRoot(), protocolContractsHash, anchorBlockHeader: anchorBlockHeader ?? genesisBlockHeader, + gasLimits: new Gas(100_000, 1_000_000), maxFeesPerGas: new GasFees(1e12, 1e12), feePayer, }); 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()) }, From 470ad17b24d9c2650ca0fd32dbd128200de4d8da Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 27 Feb 2026 18:41:14 -0300 Subject: [PATCH 02/10] feat(sequencer): only skip txs due to gas/blob limits during proposal building During re-execution (validation/proving), the public processor must process the exact txs from the proposal. Pre-processing skip checks for estimated blob fields and gas limits are now gated behind a new `isBuildingProposal` flag on PublicProcessorLimits, which is only set by the sequencer. Co-Authored-By: Claude Opus 4.6 --- .../src/sequencer/checkpoint_proposal_job.ts | 3 ++- .../public/public_processor/public_processor.ts | 14 ++++++++------ .../stdlib/src/interfaces/block-builder.ts | 6 ++++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 90a56a08c316..9409ad3f51a5 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -546,7 +546,7 @@ export class CheckpointProposalJob implements Traceable { ); this.setStateFn(SequencerState.CREATING_BLOCK, this.slot); - // Gas and blob field limits are capped by checkpoint-level budgets inside CheckpointBuilder.buildBlock() + // Note that gas and blob field limits are further capped by checkpoint-level budgets inside CheckpointBuilder const blockBuilderOptions: PublicProcessorLimits = { maxTransactions: this.config.maxTxsPerBlock, maxBlockGas: @@ -554,6 +554,7 @@ export class CheckpointProposalJob implements Traceable { ? new Gas(this.config.maxDABlockGas ?? Infinity, this.config.maxL2BlockGas ?? Infinity) : undefined, deadline: buildDeadline, + isBuildingProposal: true, }; // Actually build the block by executing txs 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 3c78a570221a..ed54b6f64e0b 100644 --- a/yarn-project/simulator/src/public/public_processor/public_processor.ts +++ b/yarn-project/simulator/src/public/public_processor/public_processor.ts @@ -161,7 +161,7 @@ export class PublicProcessor implements Traceable { limits: PublicProcessorLimits = {}, validator: PublicProcessorValidator = {}, ): Promise<[ProcessedTx[], FailedTx[], Tx[], NestedProcessReturnValues[], DebugLog[]]> { - const { maxTransactions, deadline, maxBlockGas, maxBlobFields } = limits; + const { maxTransactions, deadline, maxBlockGas, maxBlobFields, isBuildingProposal } = limits; const { preprocessValidator, nullifierCache } = validator; const result: ProcessedTx[] = []; const usedTxs: Tx[] = []; @@ -190,9 +190,10 @@ export class PublicProcessor implements Traceable { const txHash = tx.getTxHash().toString(); - // Skip this tx if its estimated blob fields would exceed the limit + // 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 (maxBlobFields !== undefined && totalBlobFields + txBlobFields > maxBlobFields) { + 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 }, @@ -200,9 +201,10 @@ export class PublicProcessor implements Traceable { continue; } - // Skip this tx if its gas limit would exceed the block gas limit (either da or l2) + // 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, @@ -253,7 +255,7 @@ export class PublicProcessor implements Traceable { const txBlobFields = processedTx.txEffect.getNumBlobFields(); const txSize = txBlobFields * Fr.SIZE_IN_BYTES; - // 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( diff --git a/yarn-project/stdlib/src/interfaces/block-builder.ts b/yarn-project/stdlib/src/interfaces/block-builder.ts index 3ea9423bf8f1..5674bb3c6906 100644 --- a/yarn-project/stdlib/src/interfaces/block-builder.ts +++ b/yarn-project/stdlib/src/interfaces/block-builder.ts @@ -36,10 +36,16 @@ export interface IBlockFactory extends ProcessedTxHandler { } export interface PublicProcessorLimits { + /** Maximum number of txs to process. */ maxTransactions?: 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 { From 65779d59eb1a5bede4cc83417bc28a1ad3d54e77 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 27 Feb 2026 18:57:50 -0300 Subject: [PATCH 03/10] fix(gas): saner defaults for da gas limit per tx --- .../crates/types/src/constants.nr | 2 +- yarn-project/constants/src/constants.ts | 20 ++++++++++++++----- .../server_world_state_synchronizer.ts | 8 ++++---- 3 files changed, 20 insertions(+), 10 deletions(-) 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/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/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 }, }, }; } From a88619c69a81a37cf865c3aa4a22f3c69ac30696 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 27 Feb 2026 18:59:13 -0300 Subject: [PATCH 04/10] fix: fix spread when computing processor limits --- yarn-project/validator-client/src/checkpoint_builder.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/yarn-project/validator-client/src/checkpoint_builder.ts b/yarn-project/validator-client/src/checkpoint_builder.ts index a450ee4ce530..08955862fe9e 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.ts @@ -103,7 +103,10 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { const { processor, validator } = await this.makeBlockBuilderDeps(globalVariables, this.fork); // Cap gas limits amd available blob fields by remaining checkpoint-level budgets - const cappedOpts = { opts, ...this.capLimitsByCheckpointBudgets(opts) }; + const cappedOpts: PublicProcessorLimits & { expectedEndState?: StateReference } = { + ...opts, + ...this.capLimitsByCheckpointBudgets(opts), + }; const [publicProcessorDuration, [processedTxs, failedTxs, usedTxs]] = await elapsed(() => processor.process(pendingTxs, cappedOpts, validator), From 21b54b8062fa18d40e9e502403eea312bebbeb97 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 27 Feb 2026 19:12:36 -0300 Subject: [PATCH 05/10] fix(processor): check gas limits using used gas on reexecution --- .../public_processor/public_processor.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 ed54b6f64e0b..45a3d9e6906e 100644 --- a/yarn-project/simulator/src/public/public_processor/public_processor.ts +++ b/yarn-project/simulator/src/public/public_processor/public_processor.ts @@ -273,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())); From 51235f76e7b1dba68e201ee833f1ee5278fd2d45 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 27 Feb 2026 19:29:21 -0300 Subject: [PATCH 06/10] test: check that validators reject exceeded gas limits --- .../src/validator.integration.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/yarn-project/validator-client/src/validator.integration.test.ts b/yarn-project/validator-client/src/validator.integration.test.ts index 317d9290855e..d1dcfd91478b 100644 --- a/yarn-project/validator-client/src/validator.integration.test.ts +++ b/yarn-project/validator-client/src/validator.integration.test.ts @@ -244,6 +244,7 @@ describe('ValidatorClient Integration', () => { 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, }); @@ -566,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); From e2e6d006604b9ba4db0b403829b29e727bb3739a Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 2 Mar 2026 10:23:26 -0300 Subject: [PATCH 07/10] chore: re-validate checkpoint limits before proposal and attestation --- .../src/sequencer/checkpoint_proposal_job.ts | 16 ++- yarn-project/stdlib/src/block/l2_block.ts | 12 ++ yarn-project/stdlib/src/checkpoint/index.ts | 1 + .../stdlib/src/checkpoint/validate.ts | 114 ++++++++++++++++++ .../src/checkpoint_builder.ts | 8 +- .../validator-client/src/validator.ts | 13 ++ 6 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 yarn-project/stdlib/src/checkpoint/validate.ts 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 9409ad3f51a5..91e5b0ad4b97 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -25,7 +25,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 { @@ -260,6 +260,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(), 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/validator-client/src/checkpoint_builder.ts b/yarn-project/validator-client/src/checkpoint_builder.ts index 08955862fe9e..7590b1510714 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.ts @@ -172,12 +172,8 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { const usedMana = sum(existingBlocks.map(b => b.header.totalManaUsed.toNumber())); const remainingMana = this.config.rollupManaLimit - usedMana; - // Remaining DA gas: DA gas = tx blob fields * DA_GAS_PER_FIELD - // IMPORTANT: 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 here. - const usedDAGas = - sum(existingBlocks.map(b => sum(b.body.txEffects.map(tx => tx.getNumBlobFields())))) * DA_GAS_PER_FIELD; + // 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) diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index 8cfb1dc6bd65..e06fff231257 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, @@ -751,6 +752,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 { From f3b702f6cd3bdce921e77e39554d0d8228202d8d Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 2 Mar 2026 13:24:43 -0300 Subject: [PATCH 08/10] test: fix tests related to avm gas limits --- .../vm2/testing/avm_inputs.testdata.bin | Bin 2084088 -> 2084088 bytes .../vm2/testing/minimal_tx.testdata.bin | Bin 189007 -> 189007 bytes .../avm_check_circuit3.test.ts | 7 +++++++ .../avm_proving_tests/avm_proving_tester.ts | 8 ++++++++ .../fixtures/public_tx_simulation_tester.ts | 19 ++++++++++++++++-- .../simulator/src/public/fixtures/utils.ts | 3 ++- .../public_processor/public_processor.test.ts | 2 +- .../src/checkpoint_builder.test.ts | 1 + .../src/checkpoint_builder.ts | 7 +------ 9 files changed, 37 insertions(+), 10 deletions(-) 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 13cf29d653c4605453ac2ec6aad2470c813eead6..515bb19cc8da277e82a21918549876e8753e9e1a 100644 GIT binary patch delta 191 zcmZwBD-wcG7(ikEiwY_N;`@O%hatKNi%X!Gugzq_83Rd+!J$hd*w}^ZA$shFZ%#dR zp4NH#zKNKRyTqE`7#zAZf{j~v9-_}~_~zBu zmRVb7@0*nN<1V+3Aw+fA9naT~a1Z-uokmrzRiTx{uN`#JgMmIwSQuak8zVSyF~$T_ f%-~@T9}6rIAjAsmMytq7{w@_PVbj!Ue0qHW@sKtU 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 10e8459aa2572d62545c75002be4111371cb597e..4695356718067a7051b82964591f1c81abc644cf 100644 GIT binary patch delta 103 zcmV-t0GR*J#0$^F3$R}SKQuKrGGjM5W;r=zIW%N7V>2}|W@9-vFk(3{GBRQ^G&yB9 zH#uWtIWssmGB#s3WMnouWo2S9V>B^0W??Zilb!)20RxlzJ?yiu0!ljp1B3cKhx$DM JxB5K-RiuiJAzX=E=$C#)+wEMwY3`MkXf7re;QF$%&Rm78c2g=9Y;@ zNvUZjmX?WW28o8rrpbm$Kv^Rr!$kAR^B7eac_#m|eY1Hzlb1CkPxCL^_FuM)+ke?I Hg)RXA*sCJg 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/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 f86b4a635292..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 @@ -196,7 +196,7 @@ describe('public_processor', () => { } // 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 }); + const [processed, failed] = await processor.process([tx], { maxBlobFields: 10, isBuildingProposal: true }); expect(processed).toEqual([]); expect(failed).toEqual([]); diff --git a/yarn-project/validator-client/src/checkpoint_builder.test.ts b/yarn-project/validator-client/src/checkpoint_builder.test.ts index 1121aa2b07f3..19f307fa5a0b 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.test.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.test.ts @@ -74,6 +74,7 @@ describe('CheckpointBuilder', () => { 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; } diff --git a/yarn-project/validator-client/src/checkpoint_builder.ts b/yarn-project/validator-client/src/checkpoint_builder.ts index 7590b1510714..3a9d62084fe6 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.ts @@ -1,10 +1,5 @@ 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 { 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, sum } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; From a6c1d7ac0b26b5b51f6388a2ccba9bc0fcb4689a Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 3 Mar 2026 09:49:20 -0300 Subject: [PATCH 09/10] fix(validator): do not check block-level limits on validation See A-613 --- yarn-project/validator-client/README.md | 2 +- .../validator-client/src/block_proposal_handler.ts | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/yarn-project/validator-client/README.md b/yarn-project/validator-client/README.md index e37a433754ef..adc0b4b85cc8 100644 --- a/yarn-project/validator-client/README.md +++ b/yarn-project/validator-client/README.md @@ -241,7 +241,7 @@ 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. Operators can override via `SEQ_MAX_L2_BLOCK_GAS` / `SEQ_MAX_DA_BLOCK_GAS` (capped at checkpoint limits). -**Validator**: Does not compute per-block budgets. Uses operator-configured values if set, otherwise relies solely on checkpoint-level capping (see below). +**Validator**: Does not compute per-block budgets. Relies solely on checkpoint-level capping. **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. diff --git a/yarn-project/validator-client/src/block_proposal_handler.ts b/yarn-project/validator-client/src/block_proposal_handler.ts index 78abafd61d48..0c8812aee9a8 100644 --- a/yarn-project/validator-client/src/block_proposal_handler.ts +++ b/yarn-project/validator-client/src/block_proposal_handler.ts @@ -10,7 +10,6 @@ import type { P2P, PeerId } from '@aztec/p2p'; import { BlockProposalValidator } from '@aztec/p2p/msg_validators'; import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block'; import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; -import { Gas } from '@aztec/stdlib/gas'; import type { ITxProvider, ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import type { BlockProposal } from '@aztec/stdlib/p2p'; @@ -484,16 +483,6 @@ export class BlockProposalHandler { const result = await checkpointBuilder.buildBlock(txs, blockNumber, blockHeader.globalVariables.timestamp, { deadline, expectedEndState: blockHeader.state, - maxTransactions: config.maxTxsPerBlock, - // Note: Unlike the sequencer, the validator does not compute per-block gas budgets from - // checkpoint limits (using timetable maxNumberOfBlocks * multiplier). These limits are only - // set if the operator explicitly configures maxL2BlockGas/maxDABlockGas. - // Checkpoint-level caps are still enforced inside CheckpointBuilder.buildBlock via - // capLimitsByCheckpointBudgets, so L1 rejection is prevented even without per-block limits. - maxBlockGas: - config.maxL2BlockGas !== undefined || config.maxDABlockGas !== undefined - ? new Gas(config.maxDABlockGas ?? Infinity, config.maxL2BlockGas ?? Infinity) - : undefined, }); const { block, failedTxs } = result; From 1dedb440d7107733a22c50fb49495ec1c6b707cd Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 3 Mar 2026 09:56:35 -0300 Subject: [PATCH 10/10] chore: add inline comments --- yarn-project/sequencer-client/src/client/sequencer-client.ts | 2 ++ yarn-project/sequencer-client/src/config.ts | 3 ++- .../sequencer-client/src/sequencer/checkpoint_proposal_job.ts | 3 ++- yarn-project/stdlib/src/tx/tx.ts | 3 ++- yarn-project/validator-client/README.md | 4 ++-- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index fd89cbf8ce21..015401c0377c 100644 --- a/yarn-project/sequencer-client/src/client/sequencer-client.ts +++ b/yarn-project/sequencer-client/src/client/sequencer-client.ts @@ -160,6 +160,8 @@ 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 }; diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index 2dfbd41ea54f..8268d92245a3 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -105,7 +105,8 @@ export const sequencerConfigMappings: ConfigMappingsType = { 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.', + '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: { 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 2dc4cdc34c5a..d461b68c30b4 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -565,7 +565,8 @@ export class CheckpointProposalJob implements Traceable { ); this.setStateFn(SequencerState.CREATING_BLOCK, this.slot); - // Note that gas and blob field limits are further capped by checkpoint-level budgets inside CheckpointBuilder + // 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, maxBlockGas: diff --git a/yarn-project/stdlib/src/tx/tx.ts b/yarn-project/stdlib/src/tx/tx.ts index 35e599df93db..6ff10f6372bd 100644 --- a/yarn-project/stdlib/src/tx/tx.ts +++ b/yarn-project/stdlib/src/tx/tx.ts @@ -270,7 +270,8 @@ export class Tx extends Gossipable { * For txs with public calls, the actual size will be larger due to public execution outputs. */ getPrivateTxEffectsSizeInFields(): number { - // 3 fields overhead: tx_start_marker, tx_hash, tx_fee + // 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; diff --git a/yarn-project/validator-client/README.md b/yarn-project/validator-client/README.md index adc0b4b85cc8..9c25164b0c7c 100644 --- a/yarn-project/validator-client/README.md +++ b/yarn-project/validator-client/README.md @@ -239,9 +239,9 @@ L1 enforces gas and blob capacity per checkpoint. The node enforces these during 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. Operators can override via `SEQ_MAX_L2_BLOCK_GAS` / `SEQ_MAX_DA_BLOCK_GAS` (capped at checkpoint limits). +**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 compute per-block budgets. Relies solely on checkpoint-level capping. +**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.