diff --git a/barretenberg/cpp/CMakePresets.json b/barretenberg/cpp/CMakePresets.json index 8402a16b9974..7892e8c1d05e 100644 --- a/barretenberg/cpp/CMakePresets.json +++ b/barretenberg/cpp/CMakePresets.json @@ -462,7 +462,9 @@ "binaryDir": "${sourceDir}/build-${presetName}", "environment": { "CC": "zig cc", - "CXX": "zig c++" + "CXX": "zig c++", + "CFLAGS": "-g0", + "CXXFLAGS": "-g0" }, "cacheVariables": { "ENABLE_PIC": "ON", diff --git a/barretenberg/cpp/cmake/lmdb.cmake b/barretenberg/cpp/cmake/lmdb.cmake index 1a25e8f5c90c..733ba27cca6e 100644 --- a/barretenberg/cpp/cmake/lmdb.cmake +++ b/barretenberg/cpp/cmake/lmdb.cmake @@ -15,7 +15,7 @@ ExternalProject_Add( SOURCE_DIR ${LMDB_PREFIX}/src/lmdb_repo BUILD_IN_SOURCE YES CONFIGURE_COMMAND "" # No configure step - BUILD_COMMAND ${CMAKE_COMMAND} -E env CC=${CMAKE_C_COMPILER}${CMAKE_C_COMPILER_ARG1} AR=${CMAKE_AR} make -e -C libraries/liblmdb XCFLAGS=-fPIC liblmdb.a + BUILD_COMMAND ${CMAKE_COMMAND} -E env --unset=CFLAGS --unset=CXXFLAGS CC=${CMAKE_C_COMPILER}${CMAKE_C_COMPILER_ARG1} AR=${CMAKE_AR} make -e -C libraries/liblmdb XCFLAGS=-fPIC liblmdb.a INSTALL_COMMAND "" UPDATE_COMMAND "" # No update step BUILD_BYPRODUCTS ${LMDB_LIB} diff --git a/barretenberg/cpp/src/barretenberg/vm2/testing/avm_inputs.testdata.bin b/barretenberg/cpp/src/barretenberg/vm2/testing/avm_inputs.testdata.bin index 13cf29d653c4..515bb19cc8da 100644 Binary files a/barretenberg/cpp/src/barretenberg/vm2/testing/avm_inputs.testdata.bin and b/barretenberg/cpp/src/barretenberg/vm2/testing/avm_inputs.testdata.bin differ diff --git a/barretenberg/cpp/src/barretenberg/vm2/testing/minimal_tx.testdata.bin b/barretenberg/cpp/src/barretenberg/vm2/testing/minimal_tx.testdata.bin index 10e8459aa257..469535671806 100644 Binary files a/barretenberg/cpp/src/barretenberg/vm2/testing/minimal_tx.testdata.bin and b/barretenberg/cpp/src/barretenberg/vm2/testing/minimal_tx.testdata.bin differ diff --git a/docs/docs-operate/operators/reference/changelog/v4.md b/docs/docs-operate/operators/reference/changelog/v4.md index 42e5dbd12538..f74031791feb 100644 --- a/docs/docs-operate/operators/reference/changelog/v4.md +++ b/docs/docs-operate/operators/reference/changelog/v4.md @@ -70,6 +70,55 @@ The `getL2Tips()` RPC endpoint now returns a restructured response with addition - Replace `tips.latest` with `tips.proposed` - For `checkpointed`, `proven`, and `finalized` tips, access block info via `.block` (e.g., `tips.proven.block.number`) +### Block gas limits reworked + +The byte-based block size limit has been removed and replaced with field-based blob limits and automatic gas budget computation from L1 rollup limits. + +**Removed:** + +```bash +--maxBlockSizeInBytes ($SEQ_MAX_BLOCK_SIZE_IN_BYTES) +``` + +**Changed to optional (now auto-computed from L1 if not set):** + +```bash +--maxL2BlockGas ($SEQ_MAX_L2_BLOCK_GAS) +--maxDABlockGas ($SEQ_MAX_DA_BLOCK_GAS) +``` + +**New:** + +```bash +--gasPerBlockAllocationMultiplier ($SEQ_GAS_PER_BLOCK_ALLOCATION_MULTIPLIER) +``` + +**Migration**: Remove `SEQ_MAX_BLOCK_SIZE_IN_BYTES` from your configuration. Per-block L2 and DA gas budgets are now derived automatically as `(checkpointLimit / maxBlocks) * multiplier`, where the multiplier defaults to 2. You can still override `SEQ_MAX_L2_BLOCK_GAS` and `SEQ_MAX_DA_BLOCK_GAS` explicitly, but they will be capped at the checkpoint-level limits. + +### Setup phase allow list requires function selectors + +The transaction setup phase allow list now enforces function selectors, restricting which specific functions can run during setup on whitelisted contracts. Previously, any public function on a whitelisted contract or class was permitted. + +The semantics of the environment variable `TX_PUBLIC_SETUP_ALLOWLIST` have changed: + +**v3.x:** + +```bash +--txPublicSetupAllowList ($TX_PUBLIC_SETUP_ALLOWLIST) +``` + +The variable fully **replaced** the hardcoded defaults. Format allowed entries without selectors: `I:address`, `C:classId`. + +**v4.0.0:** + +```bash +--txPublicSetupAllowListExtend ($TX_PUBLIC_SETUP_ALLOWLIST) +``` + +The variable now **extends** the hardcoded defaults (which are always present). Selectors are now mandatory. Format: `I:address:selector,C:classId:selector`. + +**Migration**: If you were using `TX_PUBLIC_SETUP_ALLOWLIST`, ensure all entries include function selectors. Note the variable now adds to defaults rather than replacing them. If you were not setting this variable, no action is needed — the hardcoded defaults now include the correct selectors automatically. + ## Removed features ## New features @@ -149,6 +198,10 @@ P2P_RPC_PRICE_BUMP_PERCENTAGE=10 # default: 10 (percent) Set to `0` to disable the percentage-based bump (still requires strictly higher fee). +### Setup allow list extendable via network config + +The setup phase allow list can now be extended via the network configuration JSON (`txPublicSetupAllowListExtend` field). This allows network operators to distribute additional allowed setup functions to all nodes without requiring code changes. The local environment variable takes precedence over the network-json value. + ## Changed defaults ## Troubleshooting 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 51748193cdfb..33eebf46284e 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/archiver/src/archiver.ts b/yarn-project/archiver/src/archiver.ts index de82a0482186..28c0cfa720ab 100644 --- a/yarn-project/archiver/src/archiver.ts +++ b/yarn-project/archiver/src/archiver.ts @@ -120,7 +120,11 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra }, private readonly blobClient: BlobClientInterface, instrumentation: ArchiverInstrumentation, - protected override readonly l1Constants: L1RollupConstants & { l1StartBlockHash: Buffer32; genesisArchiveRoot: Fr }, + protected override readonly l1Constants: L1RollupConstants & { + l1StartBlockHash: Buffer32; + genesisArchiveRoot: Fr; + rollupManaLimit?: number; + }, synchronizer: ArchiverL1Synchronizer, events: ArchiverEmitter, l2TipsCache?: L2TipsCache, @@ -133,7 +137,9 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra this.synchronizer = synchronizer; this.events = events; this.l2TipsCache = l2TipsCache ?? new L2TipsCache(this.dataStore.blockStore); - this.updater = new ArchiverDataStoreUpdater(this.dataStore, this.l2TipsCache); + this.updater = new ArchiverDataStoreUpdater(this.dataStore, this.l2TipsCache, { + rollupManaLimit: l1Constants.rollupManaLimit, + }); // Running promise starts with a small interval inbetween runs, so all iterations needed for the initial sync // are done as fast as possible. This then gets updated once the initial sync completes. diff --git a/yarn-project/archiver/src/factory.ts b/yarn-project/archiver/src/factory.ts index ca4d60f8a780..f7f2d46b44db 100644 --- a/yarn-project/archiver/src/factory.ts +++ b/yarn-project/archiver/src/factory.ts @@ -85,6 +85,7 @@ export async function createArchiver( genesisArchiveRoot, slashingProposerAddress, targetCommitteeSize, + rollupManaLimit, ] = await Promise.all([ rollup.getL1StartBlock(), rollup.getL1GenesisTime(), @@ -92,6 +93,7 @@ export async function createArchiver( rollup.getGenesisArchiveTreeRoot(), rollup.getSlashingProposerAddress(), rollup.getTargetCommitteeSize(), + rollup.getManaLimit(), ] as const); const l1StartBlockHash = await publicClient @@ -110,6 +112,7 @@ export async function createArchiver( proofSubmissionEpochs: Number(proofSubmissionEpochs), targetCommitteeSize, genesisArchiveRoot: Fr.fromString(genesisArchiveRoot.toString()), + rollupManaLimit: Number(rollupManaLimit), }; const archiverConfig = merge( diff --git a/yarn-project/archiver/src/modules/data_store_updater.test.ts b/yarn-project/archiver/src/modules/data_store_updater.test.ts index e261b76faab9..94721e4c22ea 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.test.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.test.ts @@ -5,9 +5,7 @@ import { ContractClassPublishedEvent } from '@aztec/protocol-contracts/class-reg import { ContractInstancePublishedEvent } from '@aztec/protocol-contracts/instance-registry'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { L2Block } from '@aztec/stdlib/block'; -import { Checkpoint } from '@aztec/stdlib/checkpoint'; import { ContractClassLog, PrivateLog } from '@aztec/stdlib/logs'; -import { CheckpointHeader } from '@aztec/stdlib/rollup'; import '@aztec/stdlib/testing/jest'; import { readFileSync } from 'fs'; @@ -15,7 +13,7 @@ import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; import { KVArchiverDataStore } from '../store/kv_archiver_store.js'; -import { makePublishedCheckpoint } from '../test/mock_structs.js'; +import { makeCheckpoint, makePublishedCheckpoint } from '../test/mock_structs.js'; import { ArchiverDataStoreUpdater } from './data_store_updater.js'; /** Loads the sample ContractClassPublished event payload from protocol-contracts fixtures. */ @@ -110,12 +108,7 @@ describe('ArchiverDataStoreUpdater', () => { // Make sure it has a different archive root (which it will by default from random) expect(conflictingBlock.archive.root.equals(localBlock.archive.root)).toBe(false); - const checkpointWithConflict = new Checkpoint( - conflictingBlock.archive, - CheckpointHeader.random({ slotNumber: SlotNumber(100) }), - [conflictingBlock], - CheckpointNumber(1), - ); + const checkpointWithConflict = makeCheckpoint([conflictingBlock]); const publishedCheckpoint = makePublishedCheckpoint(checkpointWithConflict, 10); // This should detect the conflict and prune the local block @@ -135,8 +128,7 @@ describe('ArchiverDataStoreUpdater', () => { block.body.txEffects[0].contractClassLogs = [contractClassLog]; block.body.txEffects[0].privateLogs = [PrivateLog.fromBuffer(getSampleContractInstancePublishedEventPayload())]; - const checkpoint = new Checkpoint(block.archive, CheckpointHeader.random(), [block], CheckpointNumber(1)); - const publishedCheckpoint = makePublishedCheckpoint(checkpoint, 10); + const publishedCheckpoint = makePublishedCheckpoint(makeCheckpoint([block]), 10); await updater.addCheckpoints([publishedCheckpoint]); @@ -166,8 +158,7 @@ describe('ArchiverDataStoreUpdater', () => { await updater.addProposedBlocks([block]); // Create checkpoint with the SAME block (same archive root) - const checkpoint = new Checkpoint(block.archive, CheckpointHeader.random(), [block], CheckpointNumber(1)); - const publishedCheckpoint = makePublishedCheckpoint(checkpoint, 10); + const publishedCheckpoint = makePublishedCheckpoint(makeCheckpoint([block]), 10); await updater.addCheckpoints([publishedCheckpoint]); @@ -196,13 +187,7 @@ describe('ArchiverDataStoreUpdater', () => { }); expect(checkpointBlock.archive.root.equals(localBlock.archive.root)).toBe(false); - const checkpoint = new Checkpoint( - checkpointBlock.archive, - CheckpointHeader.random({ slotNumber: SlotNumber(100) }), - [checkpointBlock], - CheckpointNumber(1), - ); - await updater.addCheckpoints([makePublishedCheckpoint(checkpoint, 10)]); + await updater.addCheckpoints([makePublishedCheckpoint(makeCheckpoint([checkpointBlock]), 10)]); // Verify checkpoint block is stored const storedBlock = await store.getBlock(BlockNumber(1)); diff --git a/yarn-project/archiver/src/modules/data_store_updater.ts b/yarn-project/archiver/src/modules/data_store_updater.ts index dd2e6becd57a..83864240f01d 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.ts @@ -11,7 +11,7 @@ import { ContractInstanceUpdatedEvent, } from '@aztec/protocol-contracts/instance-registry'; import type { L2Block, ValidateCheckpointResult } from '@aztec/stdlib/block'; -import type { PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; +import { type PublishedCheckpoint, validateCheckpoint } from '@aztec/stdlib/checkpoint'; import { type ExecutablePrivateFunctionWithMembershipProof, type UtilityFunctionWithMembershipProof, @@ -48,6 +48,7 @@ export class ArchiverDataStoreUpdater { constructor( private store: KVArchiverDataStore, private l2TipsCache?: L2TipsCache, + private opts: { rollupManaLimit?: number } = {}, ) {} /** @@ -97,6 +98,10 @@ export class ArchiverDataStoreUpdater { checkpoints: PublishedCheckpoint[], pendingChainValidationStatus?: ValidateCheckpointResult, ): Promise { + for (const checkpoint of checkpoints) { + validateCheckpoint(checkpoint.checkpoint, { rollupManaLimit: this.opts?.rollupManaLimit }); + } + const result = await this.store.transactionAsync(async () => { // Before adding checkpoints, check for conflicts with local blocks if any const { prunedBlocks, lastAlreadyInsertedBlockNumber } = await this.pruneMismatchingLocalBlocks(checkpoints); diff --git a/yarn-project/archiver/src/modules/l1_synchronizer.ts b/yarn-project/archiver/src/modules/l1_synchronizer.ts index ae4bca9dc898..5f75863f98db 100644 --- a/yarn-project/archiver/src/modules/l1_synchronizer.ts +++ b/yarn-project/archiver/src/modules/l1_synchronizer.ts @@ -69,13 +69,19 @@ export class ArchiverL1Synchronizer implements Traceable { private readonly epochCache: EpochCache, private readonly dateProvider: DateProvider, private readonly instrumentation: ArchiverInstrumentation, - private readonly l1Constants: L1RollupConstants & { l1StartBlockHash: Buffer32; genesisArchiveRoot: Fr }, + private readonly l1Constants: L1RollupConstants & { + l1StartBlockHash: Buffer32; + genesisArchiveRoot: Fr; + rollupManaLimit?: number; + }, private readonly events: ArchiverEmitter, tracer: Tracer, l2TipsCache?: L2TipsCache, private readonly log: Logger = createLogger('archiver:l1-sync'), ) { - this.updater = new ArchiverDataStoreUpdater(this.store, l2TipsCache); + this.updater = new ArchiverDataStoreUpdater(this.store, l2TipsCache, { + rollupManaLimit: l1Constants.rollupManaLimit, + }); this.tracer = tracer; } diff --git a/yarn-project/archiver/src/test/mock_structs.ts b/yarn-project/archiver/src/test/mock_structs.ts index 974141601f5b..0888d717218c 100644 --- a/yarn-project/archiver/src/test/mock_structs.ts +++ b/yarn-project/archiver/src/test/mock_structs.ts @@ -127,6 +127,25 @@ export function makeL1PublishedData(l1BlockNumber: number): L1PublishedData { return new L1PublishedData(BigInt(l1BlockNumber), BigInt(l1BlockNumber * 1000), makeBlockHash(l1BlockNumber)); } +/** Creates a Checkpoint from a list of blocks with a header that matches the blocks' structure. */ +export function makeCheckpoint(blocks: L2Block[], checkpointNumber = CheckpointNumber(1)): Checkpoint { + const firstBlock = blocks[0]; + const { slotNumber, timestamp, coinbase, feeRecipient, gasFees } = firstBlock.header.globalVariables; + return new Checkpoint( + blocks.at(-1)!.archive, + CheckpointHeader.random({ + lastArchiveRoot: firstBlock.header.lastArchive.root, + slotNumber, + timestamp, + coinbase, + feeRecipient, + gasFees, + }), + blocks, + checkpointNumber, + ); +} + /** Wraps a Checkpoint with L1 published data and random attestations. */ export function makePublishedCheckpoint( checkpoint: Checkpoint, @@ -301,11 +320,6 @@ export async function makeCheckpointWithLogs( return txEffect; }); - const checkpoint = new Checkpoint( - AppendOnlyTreeSnapshot.random(), - CheckpointHeader.random(), - [block], - CheckpointNumber.fromBlockNumber(BlockNumber(blockNumber)), - ); + const checkpoint = makeCheckpoint([block], CheckpointNumber.fromBlockNumber(BlockNumber(blockNumber))); return makePublishedCheckpoint(checkpoint, blockNumber); } diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 4670222c1125..994e18be63a0 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); @@ -342,15 +343,12 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { deps.p2pClientDeps, ); - // We should really not be modifying the config object - config.txPublicSetupAllowList = config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); - // We'll accumulate sentinel watchers here const watchers: Watcher[] = []; // 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 +485,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { // Create and start the sequencer client const checkpointsBuilder = new CheckpointsBuilder( - { ...config, l1GenesisTime, slotDuration: Number(slotDuration) }, + { ...config, l1GenesisTime, slotDuration: Number(slotDuration), rollupManaLimit }, worldStateSynchronizer, archiver, dateProvider, @@ -618,7 +616,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { } public async getAllowedPublicSetup(): Promise { - return this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); + return [...(await getDefaultAllowedSetupFunctions()), ...(this.config.txPublicSetupAllowListExtend ?? [])]; } /** @@ -1277,7 +1275,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 }); @@ -1317,7 +1315,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { blockNumber, l1ChainId: this.l1ChainId, rollupVersion: this.version, - setupAllowList: this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()), + setupAllowList: [ + ...(await getDefaultAllowedSetupFunctions()), + ...(this.config.txPublicSetupAllowListExtend ?? []), + ], gasFees: await this.getCurrentMinFees(), skipFeeEnforcement, txsPermitted: !this.config.disableTransactions, 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/cli/src/config/network_config.ts b/yarn-project/cli/src/config/network_config.ts index 998acadae315..4e835919014a 100644 --- a/yarn-project/cli/src/config/network_config.ts +++ b/yarn-project/cli/src/config/network_config.ts @@ -142,4 +142,7 @@ export async function enrichEnvironmentWithNetworkConfig(networkName: NetworkNam if (networkConfig.blockDurationMs !== undefined) { enrichVar('SEQ_BLOCK_DURATION_MS', String(networkConfig.blockDurationMs)); } + if (networkConfig.txPublicSetupAllowListExtend) { + enrichVar('TX_PUBLIC_SETUP_ALLOWLIST', networkConfig.txPublicSetupAllowListExtend); + } } diff --git a/yarn-project/constants/src/constants.ts b/yarn-project/constants/src/constants.ts index f27eb9dcaf3a..6956d82f9f7c 100644 --- a/yarn-project/constants/src/constants.ts +++ b/yarn-project/constants/src/constants.ts @@ -7,6 +7,8 @@ import { GENESIS_BLOCK_HEADER_HASH as GENESIS_BLOCK_HEADER_HASH_BIGINT, INITIAL_CHECKPOINT_NUMBER as INITIAL_CHECKPOINT_NUM_RAW, INITIAL_L2_BLOCK_NUM as INITIAL_L2_BLOCK_NUM_RAW, + MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT, + MAX_PROCESSABLE_L2_GAS, } from './constants.gen.js'; // Typescript-land-only constants @@ -17,16 +19,24 @@ export const SPONSORED_FPC_SALT = BigInt(0); export * from './constants.gen.js'; /** The initial L2 block number (typed as BlockNumber). This is the first block number in the Aztec L2 chain. */ -// Shadow the export from constants.gen above // eslint-disable-next-line import-x/export export const INITIAL_L2_BLOCK_NUM: BlockNumber = BlockNumber(INITIAL_L2_BLOCK_NUM_RAW); /** The initial L2 checkpoint number (typed as CheckpointNumber). This is the first checkpoint number in the Aztec L2 chain. */ -// Shadow the export from constants.gen above - -export const INITIAL_L2_CHECKPOINT_NUM: CheckpointNumber = CheckpointNumber(INITIAL_CHECKPOINT_NUM_RAW); +// eslint-disable-next-line import-x/export +export const INITIAL_CHECKPOINT_NUMBER: CheckpointNumber = CheckpointNumber(INITIAL_CHECKPOINT_NUM_RAW); /** The block header hash for the genesis block 0. */ -// Shadow the export from constants.gen above // eslint-disable-next-line import-x/export export const GENESIS_BLOCK_HEADER_HASH = new Fr(GENESIS_BLOCK_HEADER_HASH_BIGINT); + +// Override the default gas limits set in noir-protocol-circuit constants with saner ones +// Note that these values are not used in noir-land and are only for use in TypeScript code, so we can set them to whatever we want. +// eslint-disable-next-line import-x/export +export const DEFAULT_L2_GAS_LIMIT = MAX_PROCESSABLE_L2_GAS; +// eslint-disable-next-line import-x/export +export const DEFAULT_TEARDOWN_L2_GAS_LIMIT = DEFAULT_L2_GAS_LIMIT / 8; +// eslint-disable-next-line import-x/export +export const DEFAULT_DA_GAS_LIMIT = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT / 4; +// eslint-disable-next-line import-x/export +export const DEFAULT_TEARDOWN_DA_GAS_LIMIT = DEFAULT_DA_GAS_LIMIT / 2; diff --git a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts index 7eb5d7ab15a0..394f86adaa7e 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 6cbd2d15ecae..566c831c9abf 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -201,12 +201,13 @@ 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_MAX_TX_PER_CHECKPOINT' | '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/foundation/src/config/network_config.ts b/yarn-project/foundation/src/config/network_config.ts index b4cdc8549533..7cae851259ea 100644 --- a/yarn-project/foundation/src/config/network_config.ts +++ b/yarn-project/foundation/src/config/network_config.ts @@ -10,6 +10,7 @@ export const NetworkConfigSchema = z l1ChainId: z.number(), blockDurationMs: z.number().positive().optional(), nodeVersion: z.string().optional(), + txPublicSetupAllowListExtend: z.string().optional(), }) .passthrough(); // Allow additional unknown fields to pass through diff --git a/yarn-project/p2p/src/config.test.ts b/yarn-project/p2p/src/config.test.ts index f537cffab724..7c80cedbf670 100644 --- a/yarn-project/p2p/src/config.test.ts +++ b/yarn-project/p2p/src/config.test.ts @@ -5,18 +5,14 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { getP2PDefaultConfig, parseAllowList } from './config.js'; describe('config', () => { - it('parses allow list', async () => { - const instance = { address: await AztecAddress.random() }; + it('parses allow list with required selectors', async () => { const instanceFunction = { address: await AztecAddress.random(), selector: FunctionSelector.random() }; - const classId = { classId: Fr.random() }; const classFunction = { classId: Fr.random(), selector: FunctionSelector.random() }; - const config = [instance, instanceFunction, classId, classFunction]; + const config = [instanceFunction, classFunction]; const configStrings = [ - `I:${instance.address}`, `I:${instanceFunction.address}:${instanceFunction.selector}`, - `C:${classId.classId}`, `C:${classFunction.classId}:${classFunction.selector}`, ]; const stringifiedAllowList = configStrings.join(','); @@ -25,6 +21,30 @@ describe('config', () => { expect(allowList).toEqual(config); }); + it('rejects instance entry without selector', async () => { + const address = await AztecAddress.random(); + expect(() => parseAllowList(`I:${address}`)).toThrow('selector is required'); + }); + + it('rejects class entry without selector', () => { + const classId = Fr.random(); + expect(() => parseAllowList(`C:${classId}`)).toThrow('selector is required'); + }); + + it('rejects entry with unknown type', () => { + expect(() => parseAllowList(`X:0x1234:0x12345678`)).toThrow('unknown type'); + }); + + it('parses empty string', () => { + expect(parseAllowList('')).toEqual([]); + }); + + it('handles whitespace in entries', async () => { + const instanceFunction = { address: await AztecAddress.random(), selector: FunctionSelector.random() }; + const allowList = parseAllowList(` I:${instanceFunction.address}:${instanceFunction.selector} `); + expect(allowList).toEqual([instanceFunction]); + }); + it('defaults missing txs collector type to new', () => { const config = getP2PDefaultConfig(); expect(config.txCollectionMissingTxsCollectorType).toBe('new'); diff --git a/yarn-project/p2p/src/config.ts b/yarn-project/p2p/src/config.ts index 72d10b3c9417..4aff8b2d5f6e 100644 --- a/yarn-project/p2p/src/config.ts +++ b/yarn-project/p2p/src/config.ts @@ -151,8 +151,8 @@ export interface P2PConfig /** The maximum possible size of the P2P DB in KB. Overwrites the general dataStoreMapSizeKb. */ p2pStoreMapSizeKb?: number; - /** Which calls are allowed in the public setup phase of a tx. */ - txPublicSetupAllowList: AllowedElement[]; + /** Additional entries to extend the default setup allow list. */ + txPublicSetupAllowListExtend: AllowedElement[]; /** The maximum number of pending txs before evicting lower priority txs. */ maxPendingTxCount: number; @@ -400,12 +400,13 @@ export const p2pConfigMappings: ConfigMappingsType = { parseEnv: (val: string | undefined) => (val ? +val : undefined), description: 'The maximum possible size of the P2P DB in KB. Overwrites the general dataStoreMapSizeKb.', }, - txPublicSetupAllowList: { + txPublicSetupAllowListExtend: { env: 'TX_PUBLIC_SETUP_ALLOWLIST', parseEnv: (val: string) => parseAllowList(val), - description: 'The list of functions calls allowed to run in setup', + description: + 'Additional entries to extend the default setup allow list. Format: I:address:selector,C:classId:selector', printDefault: () => - 'AuthRegistry, FeeJuice.increase_public_balance, Token.increase_public_balance, FPC.prepare_fee', + 'Default: AuthRegistry._set_authorized, FeeJuice._increase_public_balance, Token._increase_public_balance, Token.transfer_in_public', }, maxPendingTxCount: { env: 'P2P_MAX_PENDING_TX_COUNT', @@ -541,11 +542,9 @@ export const bootnodeConfigMappings = pickConfigMappings( /** * Parses a string to a list of allowed elements. - * Each encoded is expected to be of one of the following formats - * `I:${address}` - * `I:${address}:${selector}` - * `C:${classId}` - * `C:${classId}:${selector}` + * Each entry is expected to be of one of the following formats: + * `I:${address}:${selector}` — instance (contract address) with function selector + * `C:${classId}:${selector}` — class with function selector * * @param value The string to parse * @returns A list of allowed elements @@ -558,31 +557,34 @@ export function parseAllowList(value: string): AllowedElement[] { } for (const val of value.split(',')) { - const [typeString, identifierString, selectorString] = val.split(':'); - const selector = selectorString !== undefined ? FunctionSelector.fromString(selectorString) : undefined; + const trimmed = val.trim(); + if (!trimmed) { + continue; + } + const [typeString, identifierString, selectorString] = trimmed.split(':'); + + if (!selectorString) { + throw new Error( + `Invalid allow list entry "${trimmed}": selector is required. Expected format: I:address:selector or C:classId:selector`, + ); + } + + const selector = FunctionSelector.fromString(selectorString); if (typeString === 'I') { - if (selector) { - entries.push({ - address: AztecAddress.fromString(identifierString), - selector, - }); - } else { - entries.push({ - address: AztecAddress.fromString(identifierString), - }); - } + entries.push({ + address: AztecAddress.fromString(identifierString), + selector, + }); } else if (typeString === 'C') { - if (selector) { - entries.push({ - classId: Fr.fromHexString(identifierString), - selector, - }); - } else { - entries.push({ - classId: Fr.fromHexString(identifierString), - }); - } + entries.push({ + classId: Fr.fromHexString(identifierString), + selector, + }); + } else { + throw new Error( + `Invalid allow list entry "${trimmed}": unknown type "${typeString}". Expected "I" (instance) or "C" (class).`, + ); } } diff --git a/yarn-project/p2p/src/msg_validators/proposal_validator/checkpoint_proposal_validator.test.ts b/yarn-project/p2p/src/msg_validators/proposal_validator/checkpoint_proposal_validator.test.ts index 9bc2e2888864..ea093cd2ab2e 100644 --- a/yarn-project/p2p/src/msg_validators/proposal_validator/checkpoint_proposal_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/proposal_validator/checkpoint_proposal_validator.test.ts @@ -61,4 +61,70 @@ describe('CheckpointProposalValidator', () => { getTxs: () => [], epochCacheMock: () => mock(), }); + + describe('maxTxsPerBlock validation', () => { + const currentSlot = SlotNumber(100); + const nextSlot = SlotNumber(101); + let epochCache: ReturnType>; + + function setupEpochCache(proposerAddress: EthAddress) { + epochCache = mock(); + epochCache.getCurrentAndNextSlot.mockReturnValue({ currentSlot, nextSlot }); + epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(proposerAddress); + } + + it('rejects checkpoint proposal when last block txHashes exceed maxTxsPerBlock', async () => { + const signer = Secp256k1Signer.random(); + setupEpochCache(signer.address); + const validator = new CheckpointProposalValidator(epochCache, { txsPermitted: true, maxTxsPerBlock: 2 }); + + const header = makeCheckpointHeader(0, { slotNumber: currentSlot }); + const proposal = await makeCheckpointProposalAdapter({ + blockHeader: header, + lastBlockHeader: header, + signer, + txHashes: Array.from({ length: 3 }, () => TxHash.random()), + }); + + const result = await validator.validate(proposal); + expect(result).toEqual({ result: 'reject', severity: expect.anything() }); + }); + + it('accepts checkpoint proposal when last block txHashes are within maxTxsPerBlock', async () => { + const signer = Secp256k1Signer.random(); + setupEpochCache(signer.address); + const validator = new CheckpointProposalValidator(epochCache, { txsPermitted: true, maxTxsPerBlock: 5 }); + + const header = makeCheckpointHeader(0, { slotNumber: currentSlot }); + const proposal = await makeCheckpointProposalAdapter({ + blockHeader: header, + lastBlockHeader: header, + signer, + txHashes: Array.from({ length: 3 }, () => TxHash.random()), + }); + + const result = await validator.validate(proposal); + expect(result).toEqual({ result: 'accept' }); + }); + + it('skips maxTxsPerBlock check when not configured', async () => { + const signer = Secp256k1Signer.random(); + setupEpochCache(signer.address); + const validator = new CheckpointProposalValidator(epochCache, { + txsPermitted: true, + maxTxsPerBlock: undefined, + }); + + const header = makeCheckpointHeader(0, { slotNumber: currentSlot }); + const proposal = await makeCheckpointProposalAdapter({ + blockHeader: header, + lastBlockHeader: header, + signer, + txHashes: Array.from({ length: 100 }, () => TxHash.random()), + }); + + const result = await validator.validate(proposal); + expect(result).toEqual({ result: 'accept' }); + }); + }); }); diff --git a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator_test_suite.ts b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator_test_suite.ts index e58a007a3de7..ec12ec3442f6 100644 --- a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator_test_suite.ts +++ b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator_test_suite.ts @@ -58,7 +58,7 @@ export function sharedProposalValidatorTests { epochCache = epochCacheMock(); - validator = validatorFactory(epochCache, { txsPermitted: true }); + validator = validatorFactory(epochCache, { txsPermitted: true, maxTxsPerBlock: undefined }); epochCache.getCurrentAndNextSlot.mockReturnValue({ currentSlot: currentSlot, nextSlot: nextSlot, @@ -231,7 +231,10 @@ export function sharedProposalValidatorTests { it('returns mid tolerance error if txs not permitted and proposal contains txHashes', async () => { const currentProposer = getSigner(); - const validatorWithTxsDisabled = validatorFactory(epochCache, { txsPermitted: false }); + const validatorWithTxsDisabled = validatorFactory(epochCache, { + txsPermitted: false, + maxTxsPerBlock: undefined, + }); const header = makeHeader(1, 100, 100); const mockProposal = await makeProposal({ blockHeader: header, @@ -247,7 +250,10 @@ export function sharedProposalValidatorTests { const currentProposer = getSigner(); - const validatorWithTxsDisabled = validatorFactory(epochCache, { txsPermitted: false }); + const validatorWithTxsDisabled = validatorFactory(epochCache, { + txsPermitted: false, + maxTxsPerBlock: undefined, + }); const header = makeHeader(1, 100, 100); const mockProposal = await makeProposal({ blockHeader: header, diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/allowed_public_setup.ts b/yarn-project/p2p/src/msg_validators/tx_validator/allowed_public_setup.ts index 64578772ea82..6f536c75f09d 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/allowed_public_setup.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/allowed_public_setup.ts @@ -1,33 +1,52 @@ -import { FPCContract } from '@aztec/noir-contracts.js/FPC'; import { TokenContractArtifact } from '@aztec/noir-contracts.js/Token'; import { ProtocolContractAddress } from '@aztec/protocol-contracts'; +import { FunctionSelector } from '@aztec/stdlib/abi'; import { getContractClassFromArtifact } from '@aztec/stdlib/contract'; import type { AllowedElement } from '@aztec/stdlib/interfaces/server'; -let defaultAllowedSetupFunctions: AllowedElement[] | undefined = undefined; +let defaultAllowedSetupFunctions: AllowedElement[] | undefined; + +/** Returns the default list of functions allowed to run in the setup phase of a transaction. */ export async function getDefaultAllowedSetupFunctions(): Promise { if (defaultAllowedSetupFunctions === undefined) { + const tokenClassId = (await getContractClassFromArtifact(TokenContractArtifact)).id; + const setAuthorizedInternalSelector = await FunctionSelector.fromSignature('_set_authorized((Field),Field,bool)'); + const setAuthorizedSelector = await FunctionSelector.fromSignature('set_authorized(Field,bool)'); + const increaseBalanceSelector = await FunctionSelector.fromSignature('_increase_public_balance((Field),u128)'); + const transferInPublicSelector = await FunctionSelector.fromSignature( + 'transfer_in_public((Field),(Field),u128,Field)', + ); + defaultAllowedSetupFunctions = [ - // needed for authwit support + // AuthRegistry: needed for authwit support via private path (set_authorized_private enqueues _set_authorized) + { + address: ProtocolContractAddress.AuthRegistry, + selector: setAuthorizedInternalSelector, + onlySelf: true, + rejectNullMsgSender: true, + }, + // AuthRegistry: needed for authwit support via public path (PublicFeePaymentMethod calls set_authorized directly) { address: ProtocolContractAddress.AuthRegistry, + selector: setAuthorizedSelector, + rejectNullMsgSender: true, }, - // needed for claiming on the same tx as a spend + // FeeJuice: needed for claiming on the same tx as a spend (claim_and_end_setup enqueues this) { address: ProtocolContractAddress.FeeJuice, - // We can't restrict the selector because public functions get routed via dispatch. - // selector: FunctionSelector.fromSignature('_increase_public_balance((Field),u128)'), + selector: increaseBalanceSelector, + onlySelf: true, }, - // needed for private transfers via FPC + // Token: needed for private transfers via FPC (transfer_to_public enqueues this) { - classId: (await getContractClassFromArtifact(TokenContractArtifact)).id, - // We can't restrict the selector because public functions get routed via dispatch. - // selector: FunctionSelector.fromSignature('_increase_public_balance((Field),u128)'), + classId: tokenClassId, + selector: increaseBalanceSelector, + onlySelf: true, }, + // Token: needed for public transfers via FPC (fee_entrypoint_public enqueues this) { - classId: (await getContractClassFromArtifact(FPCContract.artifact)).id, - // We can't restrict the selector because public functions get routed via dispatch. - // selector: FunctionSelector.fromSignature('prepare_fee((Field),Field,(Field),Field)'), + classId: tokenClassId, + selector: transferInPublicSelector, }, ]; } diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.test.ts b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.test.ts index 966aaf930f2e..1ae3bf422bc0 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.test.ts @@ -1,9 +1,16 @@ +import { NULL_MSG_SENDER_CONTRACT_ADDRESS } from '@aztec/constants'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { FunctionSelector } from '@aztec/stdlib/abi'; -import type { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import { makeAztecAddress, makeSelector, mockTx } from '@aztec/stdlib/testing'; -import { TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED, type Tx } from '@aztec/stdlib/tx'; +import { + TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED, + TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT, + TX_ERROR_SETUP_NULL_MSG_SENDER, + TX_ERROR_SETUP_ONLY_SELF_WRONG_SENDER, + type Tx, +} from '@aztec/stdlib/tx'; import { type MockProxy, mock, mockFn } from 'jest-mock-extended'; @@ -138,4 +145,239 @@ describe('PhasesTxValidator', () => { await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED); }); + + it('rejects address match with wrong selector', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + const wrongSelector = makeSelector(99); + await patchNonRevertibleFn(tx, 0, { address: allowedContract, selector: wrongSelector }); + + await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED); + }); + + it('rejects class match with wrong selector', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + const wrongSelector = makeSelector(99); + const address = await patchNonRevertibleFn(tx, 0, { selector: wrongSelector }); + + contractDataSource.getContract.mockImplementationOnce((contractAddress, atTimestamp) => { + if (timestamp !== atTimestamp) { + throw new Error('Unexpected timestamp'); + } + if (address.equals(contractAddress)) { + return Promise.resolve({ + currentContractClassId: allowedContractClass, + originalContractClassId: Fr.random(), + } as any); + } else { + return Promise.resolve(undefined); + } + }); + + await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED); + }); + + it('rejects with unknown contract error when contract is not found', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + const address = await patchNonRevertibleFn(tx, 0, { selector: allowedSetupSelector1 }); + + contractDataSource.getContract.mockImplementationOnce((contractAddress, atTimestamp) => { + if (timestamp !== atTimestamp) { + throw new Error('Unexpected timestamp'); + } + if (address.equals(contractAddress)) { + return Promise.resolve(undefined); + } + return Promise.resolve(undefined); + }); + + await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT); + }); + + it('does not fetch contract instance when matching by address', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + await patchNonRevertibleFn(tx, 0, { address: allowedContract, selector: allowedSetupSelector1 }); + + await expectValid(tx); + + expect(contractDataSource.getContract).not.toHaveBeenCalled(); + }); + + describe('onlySelf validation', () => { + let allowedOnlySelfSelector: FunctionSelector; + let allowedOnlySelfContract: AztecAddress; + let allowedOnlySelfClass: Fr; + + beforeEach(() => { + allowedOnlySelfSelector = makeSelector(10); + allowedOnlySelfContract = makeAztecAddress(); + allowedOnlySelfClass = Fr.random(); + + txValidator = new PhasesTxValidator( + contractDataSource, + [ + { + address: allowedOnlySelfContract, + selector: allowedOnlySelfSelector, + onlySelf: true, + }, + { + classId: allowedOnlySelfClass, + selector: allowedOnlySelfSelector, + onlySelf: true, + }, + { + address: allowedContract, + selector: allowedSetupSelector1, + }, + ], + timestamp, + ); + }); + + it('allows onlySelf address entry when msgSender equals contractAddress', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + await patchNonRevertibleFn(tx, 0, { + address: allowedOnlySelfContract, + selector: allowedOnlySelfSelector, + msgSender: allowedOnlySelfContract, + }); + + await expectValid(tx); + }); + + it('rejects onlySelf address entry when msgSender differs from contractAddress', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + await patchNonRevertibleFn(tx, 0, { + address: allowedOnlySelfContract, + selector: allowedOnlySelfSelector, + msgSender: makeAztecAddress(999), + }); + + await expectInvalid(tx, TX_ERROR_SETUP_ONLY_SELF_WRONG_SENDER); + }); + + it('allows onlySelf class entry when msgSender equals contractAddress', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + const address = await patchNonRevertibleFn(tx, 0, { + selector: allowedOnlySelfSelector, + msgSender: undefined, // will be patched below + }); + + // Patch msgSender to equal contractAddress + tx.data.forPublic!.nonRevertibleAccumulatedData.publicCallRequests[0].msgSender = address; + + contractDataSource.getContract.mockImplementationOnce((contractAddress, atTimestamp) => { + if (timestamp !== atTimestamp) { + throw new Error('Unexpected timestamp'); + } + if (address.equals(contractAddress)) { + return Promise.resolve({ + currentContractClassId: allowedOnlySelfClass, + originalContractClassId: Fr.random(), + } as any); + } + return Promise.resolve(undefined); + }); + + await expectValid(tx); + }); + + it('rejects onlySelf class entry when msgSender differs from contractAddress', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + const address = await patchNonRevertibleFn(tx, 0, { + selector: allowedOnlySelfSelector, + msgSender: makeAztecAddress(), + }); + + contractDataSource.getContract.mockImplementationOnce((contractAddress, atTimestamp) => { + if (timestamp !== atTimestamp) { + throw new Error('Unexpected timestamp'); + } + if (address.equals(contractAddress)) { + return Promise.resolve({ + currentContractClassId: allowedOnlySelfClass, + originalContractClassId: Fr.random(), + } as any); + } + return Promise.resolve(undefined); + }); + + await expectInvalid(tx, TX_ERROR_SETUP_ONLY_SELF_WRONG_SENDER); + }); + + it('allows non-onlySelf entry with different msgSender', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + await patchNonRevertibleFn(tx, 0, { + address: allowedContract, + selector: allowedSetupSelector1, + msgSender: makeAztecAddress(), + }); + + await expectValid(tx); + }); + }); + + describe('rejectNullMsgSender validation', () => { + const nullMsgSender = AztecAddress.fromBigInt(NULL_MSG_SENDER_CONTRACT_ADDRESS); + let rejectNullContract: AztecAddress; + let rejectNullSelector: FunctionSelector; + let noRejectNullContract: AztecAddress; + let noRejectNullSelector: FunctionSelector; + + beforeEach(() => { + rejectNullContract = makeAztecAddress(50); + rejectNullSelector = makeSelector(50); + noRejectNullContract = makeAztecAddress(51); + noRejectNullSelector = makeSelector(51); + + txValidator = new PhasesTxValidator( + contractDataSource, + [ + { + address: rejectNullContract, + selector: rejectNullSelector, + rejectNullMsgSender: true, + }, + { + address: noRejectNullContract, + selector: noRejectNullSelector, + }, + ], + timestamp, + ); + }); + + it('rejects when msgSender is NULL_MSG_SENDER_CONTRACT_ADDRESS', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + await patchNonRevertibleFn(tx, 0, { + address: rejectNullContract, + selector: rejectNullSelector, + msgSender: nullMsgSender, + }); + + await expectInvalid(tx, TX_ERROR_SETUP_NULL_MSG_SENDER); + }); + + it('allows when msgSender is a normal address', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + await patchNonRevertibleFn(tx, 0, { + address: rejectNullContract, + selector: rejectNullSelector, + msgSender: makeAztecAddress(100), + }); + + await expectValid(tx); + }); + + it('allows null msgSender on entries without the flag', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + await patchNonRevertibleFn(tx, 0, { + address: noRejectNullContract, + selector: noRejectNullSelector, + msgSender: nullMsgSender, + }); + + await expectValid(tx); + }); + }); }); diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts index 4b6370fa0477..5a3fcf018b43 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts @@ -1,11 +1,16 @@ +import { NULL_MSG_SENDER_CONTRACT_ADDRESS } from '@aztec/constants'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; import { PublicContractsDB, getCallRequestsWithCalldataByPhase } from '@aztec/simulator/server'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import type { AllowedElement } from '@aztec/stdlib/interfaces/server'; import { type PublicCallRequestWithCalldata, TX_ERROR_DURING_VALIDATION, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED, + TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT, + TX_ERROR_SETUP_NULL_MSG_SENDER, + TX_ERROR_SETUP_ONLY_SELF_WRONG_SENDER, Tx, TxExecutionPhase, type TxValidationResult, @@ -45,7 +50,8 @@ export class PhasesTxValidator implements TxValidator { const setupFns = getCallRequestsWithCalldataByPhase(tx, TxExecutionPhase.SETUP); for (const setupFn of setupFns) { - if (!(await this.isOnAllowList(setupFn, this.setupAllowList))) { + const rejectionReason = await this.checkAllowList(setupFn, this.setupAllowList); + if (rejectionReason) { this.#log.verbose( `Rejecting tx ${tx.getTxHash().toString()} because it calls setup function not on allow list: ${ setupFn.request.contractAddress @@ -53,7 +59,7 @@ export class PhasesTxValidator implements TxValidator { { allowList: this.setupAllowList }, ); - return { result: 'invalid', reason: [TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED] }; + return { result: 'invalid', reason: [rejectionReason] }; } } @@ -66,53 +72,65 @@ export class PhasesTxValidator implements TxValidator { } } - private async isOnAllowList( + /** Returns a rejection reason if the call is not on the allow list, or undefined if it is allowed. */ + private async checkAllowList( publicCall: PublicCallRequestWithCalldata, allowList: AllowedElement[], - ): Promise { + ): Promise { if (publicCall.isEmpty()) { - return true; + return undefined; } const contractAddress = publicCall.request.contractAddress; const functionSelector = publicCall.functionSelector; - // do these checks first since they don't require the contract class + // Check address-based entries first since they don't require the contract class. for (const entry of allowList) { - if ('address' in entry && !('selector' in entry)) { - if (contractAddress.equals(entry.address)) { - return true; - } - } - - if ('address' in entry && 'selector' in entry) { + if ('address' in entry) { if (contractAddress.equals(entry.address) && entry.selector.equals(functionSelector)) { - return true; + if (entry.onlySelf && !publicCall.request.msgSender.equals(contractAddress)) { + return TX_ERROR_SETUP_ONLY_SELF_WRONG_SENDER; + } + if ( + entry.rejectNullMsgSender && + publicCall.request.msgSender.equals(AztecAddress.fromBigInt(NULL_MSG_SENDER_CONTRACT_ADDRESS)) + ) { + return TX_ERROR_SETUP_NULL_MSG_SENDER; + } + return undefined; } } + } - const contractClass = await this.contractsDB.getContractInstance(contractAddress, this.timestamp); - - if (!contractClass) { - throw new Error(`Contract not found: ${contractAddress}`); + // Check class-based entries. Fetch the contract instance lazily (only once). + let contractClassId: undefined | { value: string | undefined }; + for (const entry of allowList) { + if (!('classId' in entry)) { + continue; } - if ('classId' in entry && !('selector' in entry)) { - if (contractClass.currentContractClassId.equals(entry.classId)) { - return true; + if (contractClassId === undefined) { + const instance = await this.contractsDB.getContractInstance(contractAddress, this.timestamp); + contractClassId = { value: instance?.currentContractClassId.toString() }; + if (!contractClassId.value) { + return TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT; } } - if ('classId' in entry && 'selector' in entry) { + if (contractClassId.value === entry.classId.toString() && entry.selector.equals(functionSelector)) { + if (entry.onlySelf && !publicCall.request.msgSender.equals(contractAddress)) { + return TX_ERROR_SETUP_ONLY_SELF_WRONG_SENDER; + } if ( - contractClass.currentContractClassId.equals(entry.classId) && - (entry.selector === undefined || entry.selector.equals(functionSelector)) + entry.rejectNullMsgSender && + publicCall.request.msgSender.equals(AztecAddress.fromBigInt(NULL_MSG_SENDER_CONTRACT_ADDRESS)) ) { - return true; + return TX_ERROR_SETUP_NULL_MSG_SENDER; } + return undefined; } } - return false; + return TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED; } } diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index 8da82a7d195b..de3c5c9f8fc8 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -222,14 +222,12 @@ export class LibP2PService extends WithTracer implements P2PService { this.protocolVersion, ); - this.blockProposalValidator = new BlockProposalValidator(epochCache, { + const proposalValidatorOpts = { txsPermitted: !config.disableTransactions, maxTxsPerBlock: config.maxTxsPerBlock, - }); - this.checkpointProposalValidator = new CheckpointProposalValidator(epochCache, { - txsPermitted: !config.disableTransactions, - maxTxsPerBlock: config.maxTxsPerBlock, - }); + }; + this.blockProposalValidator = new BlockProposalValidator(epochCache, proposalValidatorOpts); + this.checkpointProposalValidator = new CheckpointProposalValidator(epochCache, proposalValidatorOpts); this.checkpointAttestationValidator = config.fishermanMode ? new FishermanAttestationValidator(epochCache, mempools.attestationPool, telemetry) : new CheckpointAttestationValidator(epochCache); @@ -1621,7 +1619,10 @@ export class LibP2PService extends WithTracer implements P2PService { nextSlotTimestamp: UInt64, ): Promise> { const gasFees = await this.getGasFees(currentBlockNumber); - const allowedInSetup = this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); + const allowedInSetup = [ + ...(await getDefaultAllowedSetupFunctions()), + ...(this.config.txPublicSetupAllowListExtend ?? []), + ]; const blockNumber = BlockNumber(currentBlockNumber + 1); return createFirstStageTxValidationsForGossipedTransactions( 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 cb789075c3f8..2311ade5e3ba 100644 --- a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts +++ b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts @@ -153,6 +153,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.test.ts b/yarn-project/sequencer-client/src/client/sequencer-client.test.ts new file mode 100644 index 000000000000..e325cefca47d --- /dev/null +++ b/yarn-project/sequencer-client/src/client/sequencer-client.test.ts @@ -0,0 +1,110 @@ +import { MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT } from '@aztec/constants'; +import { createLogger } from '@aztec/foundation/log'; + +import type { SequencerClientConfig } from '../config.js'; +import { computeBlockLimits } from './sequencer-client.js'; + +describe('computeBlockLimits', () => { + const log = createLogger('test'); + + /** Builds a minimal config with only the fields needed by computeBlockLimits. */ + function makeConfig(overrides: Partial = {}): SequencerClientConfig { + return { + ethereumSlotDuration: 12, + aztecSlotDuration: 72, + attestationPropagationTime: 3, + enforceTimeTable: true, + // No blockDurationMs -> single block mode -> maxNumberOfBlocks = 1 + ...overrides, + } as SequencerClientConfig; + } + + describe('L2 gas', () => { + it('derives maxL2BlockGas from rollupManaLimit when not explicitly set', () => { + const rollupManaLimit = 1_000_000; + // Single block mode (maxNumberOfBlocks=1), default multiplier=2: + // min(1_000_000, ceil(1_000_000 / 1 * 2)) = min(1_000_000, 2_000_000) = 1_000_000 + const result = computeBlockLimits(makeConfig(), rollupManaLimit, 12, log); + expect(result.maxL2BlockGas).toBe(rollupManaLimit); + }); + + it('uses explicit maxL2BlockGas when within rollupManaLimit', () => { + const result = computeBlockLimits(makeConfig({ maxL2BlockGas: 500_000 }), 1_000_000, 12, log); + expect(result.maxL2BlockGas).toBe(500_000); + }); + + it('caps explicit maxL2BlockGas at rollupManaLimit', () => { + const result = computeBlockLimits(makeConfig({ maxL2BlockGas: 2_000_000 }), 1_000_000, 12, log); + expect(result.maxL2BlockGas).toBe(1_000_000); + }); + }); + + describe('DA gas', () => { + const daLimit = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT; + + it('derives maxDABlockGas from DA checkpoint limit when not explicitly set', () => { + // Single block mode (maxNumberOfBlocks=1), default multiplier=2: + // min(daLimit, ceil(daLimit / 1 * 2)) = min(daLimit, daLimit * 2) = daLimit + const result = computeBlockLimits(makeConfig(), 1_000_000, 12, log); + expect(result.maxDABlockGas).toBe(daLimit); + }); + + it('uses explicit maxDABlockGas when within DA checkpoint limit', () => { + const explicit = Math.floor(daLimit / 2); + const result = computeBlockLimits(makeConfig({ maxDABlockGas: explicit }), 1_000_000, 12, log); + expect(result.maxDABlockGas).toBe(explicit); + }); + + it('caps explicit maxDABlockGas at DA checkpoint limit', () => { + const result = computeBlockLimits(makeConfig({ maxDABlockGas: daLimit + 100_000 }), 1_000_000, 12, log); + expect(result.maxDABlockGas).toBe(daLimit); + }); + }); + + describe('TX count', () => { + it('uses explicit maxTxsPerBlock when set', () => { + const result = computeBlockLimits(makeConfig({ maxTxsPerBlock: 10 }), 1_000_000, 12, log); + expect(result.maxTxsPerBlock).toBe(10); + }); + + it('caps maxTxsPerBlock at maxTxsPerCheckpoint', () => { + const result = computeBlockLimits( + makeConfig({ maxTxsPerBlock: 50, maxTxsPerCheckpoint: 30 }), + 1_000_000, + 12, + log, + ); + expect(result.maxTxsPerBlock).toBe(30); + }); + + it('derives maxTxsPerBlock from maxTxsPerCheckpoint when per-block not set', () => { + // Multi-block mode with maxNumberOfBlocks=5, multiplier=2: + // min(100, ceil(100 / 5 * 2)) = min(100, 40) = 40 + const config = makeConfig({ + maxTxsPerCheckpoint: 100, + blockDurationMs: 8000, + }); + const result = computeBlockLimits(config, 1_000_000, 12, log); + expect(result.maxTxsPerBlock).toBe(40); + }); + }); + + describe('multi-block mode', () => { + it('distributes budget across blocks in multi-block mode', () => { + // With blockDurationMs=8000, aztecSlotDuration=72, ethereumSlotDuration=12, + // attestationPropagationTime=3, l1PublishingTime=12: + // checkpointFinalizationTime = 1 + 3*2 + 12 = 19 + // timeReservedAtEnd = 8 + 19 = 27 + // timeAvailableForBlocks = 72 - 1 - 27 = 44 + // maxNumberOfBlocks = floor(44 / 8) = 5 + // With multiplier=2 and rollupManaLimit=1_000_000: + // maxL2BlockGas = min(1_000_000, ceil(1_000_000 / 5 * 2)) = min(1_000_000, 400_000) = 400_000 + const config = makeConfig({ blockDurationMs: 8000 }); + const result = computeBlockLimits(config, 1_000_000, 12, log); + expect(result.maxL2BlockGas).toBe(400_000); + + const daLimit = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT; + expect(result.maxDABlockGas).toBe(Math.min(daLimit, Math.ceil((daLimit / 5) * 2))); + }); + }); +}); diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index c55521d7b233..613c5d172219 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,15 @@ export class SequencerClient { const l1PublishingTimeBasedOnChain = isAnvilTestChain(config.l1ChainId) ? 1 : ethereumSlotDuration; const l1PublishingTime = config.l1PublishingTime ?? l1PublishingTimeBasedOnChain; + const { maxL2BlockGas, maxDABlockGas, maxTxsPerBlock } = computeBlockLimits( + config, + rollupManaLimit, + l1PublishingTime, + log, + ); + + const l1Constants = { l1GenesisTime, slotDuration: Number(slotDuration), ethereumSlotDuration, rollupManaLimit }; + const sequencer = new Sequencer( publisherFactory, validatorClient, @@ -171,7 +183,7 @@ export class SequencerClient { deps.dateProvider, epochCache, rollupContract, - { ...config, l1PublishingTime, maxL2BlockGas: sequencerManaLimit }, + { ...config, l1PublishingTime, maxL2BlockGas, maxDABlockGas, maxTxsPerBlock }, telemetryClient, log, ); @@ -234,3 +246,90 @@ export class SequencerClient { return this.sequencer.maxL2BlockGas; } } + +/** + * Computes per-block L2 gas, DA gas, and TX count 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. + */ +export function computeBlockLimits( + config: SequencerClientConfig, + rollupManaLimit: number, + l1PublishingTime: number, + log: ReturnType, +): { maxL2BlockGas: number; maxDABlockGas: number; maxTxsPerBlock: 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)); + } + + // Compute maxTxsPerBlock + const defaultMaxTxsPerBlock = 32; + let maxTxsPerBlock: number; + if (config.maxTxsPerBlock !== undefined) { + if (config.maxTxsPerCheckpoint !== undefined && config.maxTxsPerBlock > config.maxTxsPerCheckpoint) { + log.warn( + `Provided MAX_TX_PER_BLOCK ${config.maxTxsPerBlock} exceeds MAX_TX_PER_CHECKPOINT ${config.maxTxsPerCheckpoint} (capping)`, + ); + maxTxsPerBlock = config.maxTxsPerCheckpoint; + } else { + maxTxsPerBlock = config.maxTxsPerBlock; + } + } else if (config.maxTxsPerCheckpoint !== undefined) { + maxTxsPerBlock = Math.min( + config.maxTxsPerCheckpoint, + Math.ceil((config.maxTxsPerCheckpoint / maxNumberOfBlocks) * multiplier), + ); + } else { + maxTxsPerBlock = defaultMaxTxsPerBlock; + } + + log.info(`Computed block limits L2=${maxL2BlockGas} DA=${maxDABlockGas} maxTxs=${maxTxsPerBlock}`, { + maxL2BlockGas, + maxDABlockGas, + maxTxsPerBlock, + rollupManaLimit, + daCheckpointLimit, + maxNumberOfBlocks, + multiplier, + }); + + return { maxL2BlockGas, maxDABlockGas, maxTxsPerBlock }; +} diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index d6aa5d93f9f5..f020431f90a3 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -13,7 +13,6 @@ import { type P2PConfig, p2pConfigMappings } from '@aztec/p2p/config'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type ChainConfig, - DEFAULT_MAX_TXS_PER_BLOCK, type SequencerConfig, chainConfigMappings, sharedSequencerConfigMappings, @@ -36,15 +35,12 @@ export type { SequencerConfig }; * Default values for SequencerConfig. * Centralized location for all sequencer configuration defaults. */ -export const DefaultSequencerConfig: ResolvedSequencerConfig = { +export const DefaultSequencerConfig = { sequencerPollingIntervalMS: 500, - maxTxsPerBlock: DEFAULT_MAX_TXS_PER_BLOCK, minTxsPerBlock: 1, buildCheckpointIfEmpty: false, publishTxsWithProposals: false, - maxL2BlockGas: 10e9, - maxDABlockGas: 10e9, - maxBlockSizeInBytes: 1024 * 1024, + gasPerBlockAllocationMultiplier: 2, enforceTimeTable: true, attestationPropagationTime: DEFAULT_P2P_PROPAGATION_TIME, secondsBeforeInvalidatingBlockAsCommitteeMember: 144, // 12 L1 blocks @@ -59,7 +55,7 @@ export const DefaultSequencerConfig: ResolvedSequencerConfig = { shuffleAttestationOrdering: false, skipPushProposedBlocksToArchiver: false, skipPublishingCheckpointsPercent: 0, -}; +} satisfies ResolvedSequencerConfig; /** * Configuration settings for the SequencerClient. @@ -71,7 +67,7 @@ export type SequencerClientConfig = SequencerPublisherConfig & SequencerConfig & L1ReaderConfig & ChainConfig & - Pick & + Pick & Pick; export const sequencerConfigMappings: ConfigMappingsType = { @@ -80,6 +76,11 @@ export const sequencerConfigMappings: ConfigMappingsType = { description: 'The number of ms to wait between polling for checking to build on the next slot.', ...numberConfigHelper(DefaultSequencerConfig.sequencerPollingIntervalMS), }, + maxTxsPerCheckpoint: { + env: 'SEQ_MAX_TX_PER_CHECKPOINT', + description: 'The maximum number of txs across all blocks in a checkpoint.', + parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined), + }, minTxsPerBlock: { env: 'SEQ_MIN_TX_PER_BLOCK', description: 'The minimum number of txs to include in a block.', @@ -97,12 +98,19 @@ export const sequencerConfigMappings: ConfigMappingsType = { maxL2BlockGas: { env: 'SEQ_MAX_L2_BLOCK_GAS', description: 'The maximum L2 block gas.', - ...numberConfigHelper(DefaultSequencerConfig.maxL2BlockGas), + parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined), }, maxDABlockGas: { env: 'SEQ_MAX_DA_BLOCK_GAS', description: 'The maximum DA block gas.', - ...numberConfigHelper(DefaultSequencerConfig.maxDABlockGas), + parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined), + }, + gasPerBlockAllocationMultiplier: { + env: 'SEQ_GAS_PER_BLOCK_ALLOCATION_MULTIPLIER', + description: + 'Per-block gas budget multiplier for both L2 and DA gas. Budget per block is (checkpointLimit / maxBlocks) * multiplier.' + + ' Values greater than one allow early blocks to use more than their even share, relying on checkpoint-level capping for later blocks.', + ...numberConfigHelper(DefaultSequencerConfig.gasPerBlockAllocationMultiplier), }, coinbase: { env: 'COINBASE', @@ -122,11 +130,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', @@ -220,7 +223,7 @@ export const sequencerConfigMappings: ConfigMappingsType = { description: 'Percent probability (0 - 100) of sequencer skipping checkpoint publishing (testing only)', ...numberConfigHelper(DefaultSequencerConfig.skipPublishingCheckpointsPercent), }, - ...pickConfigMappings(p2pConfigMappings, ['txPublicSetupAllowList']), + ...pickConfigMappings(p2pConfigMappings, ['txPublicSetupAllowListExtend']), }; export const sequencerClientConfigMappings: ConfigMappingsType = { 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 b9e960fb7c1f..3e9cd16150c8 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -1,5 +1,3 @@ -import { NUM_CHECKPOINT_END_MARKER_FIELDS, getNumBlockEndBlobFields } from '@aztec/blob-lib/encoding'; -import { BLOBS_PER_CHECKPOINT, FIELDS_PER_BLOB } from '@aztec/constants'; import type { EpochCache } from '@aztec/epoch-cache'; import { BlockNumber, @@ -32,7 +30,7 @@ import { type L2BlockSource, MaliciousCommitteeAttestationsAndSigners, } from '@aztec/stdlib/block'; -import type { Checkpoint } from '@aztec/stdlib/checkpoint'; +import { type Checkpoint, validateCheckpoint } from '@aztec/stdlib/checkpoint'; import { getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers'; import { Gas } from '@aztec/stdlib/gas'; import { @@ -267,6 +265,22 @@ 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, + maxTxsPerBlock: this.config.maxTxsPerBlock, + maxTxsPerCheckpoint: this.config.maxTxsPerCheckpoint, + }); + } catch (err) { + this.log.error(`Built an invalid checkpoint at slot ${this.slot} (skipping proposal)`, err, { + checkpoint: checkpoint.header.toInspect(), + }); + return undefined; + } + // Record checkpoint-level build metrics this.metrics.recordCheckpointBuild( checkpointBuildTimer.ms(), @@ -389,9 +403,6 @@ export class CheckpointProposalJob implements Traceable { const txHashesAlreadyIncluded = new Set(); const initialBlockNumber = BlockNumber(this.syncedToBlockNumber + 1); - // Remaining blob fields available for blocks (checkpoint end marker already subtracted) - let remainingBlobFields = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB - NUM_CHECKPOINT_END_MARKER_FIELDS; - // Last block in the checkpoint will usually be flagged as pending broadcast, so we send it along with the checkpoint proposal let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined; @@ -424,7 +435,6 @@ export class CheckpointProposalJob implements Traceable { blockNumber, indexWithinCheckpoint, txHashesAlreadyIncluded, - remainingBlobFields, }); // TODO(palla/mbps): Review these conditions. We may want to keep trying in some scenarios. @@ -450,12 +460,9 @@ export class CheckpointProposalJob implements Traceable { break; } - const { block, usedTxs, remainingBlobFields: newRemainingBlobFields } = buildResult; + const { block, usedTxs } = buildResult; blocksInCheckpoint.push(block); - // Update remaining blob fields for the next block - remainingBlobFields = newRemainingBlobFields; - // Sync the proposed block to the archiver to make it available // Note that the checkpoint builder uses its own fork so it should not need to wait for this syncing // Eventually we should refactor the checkpoint builder to not need a separate long-lived fork @@ -523,18 +530,10 @@ export class CheckpointProposalJob implements Traceable { indexWithinCheckpoint: IndexWithinCheckpoint; buildDeadline: Date | undefined; txHashesAlreadyIncluded: Set; - remainingBlobFields: number; }, - ): Promise<{ block: L2Block; usedTxs: Tx[]; remainingBlobFields: number } | { error: Error } | undefined> { - const { - blockTimestamp, - forceCreate, - blockNumber, - indexWithinCheckpoint, - buildDeadline, - txHashesAlreadyIncluded, - remainingBlobFields, - } = opts; + ): Promise<{ block: L2Block; usedTxs: Tx[] } | { error: Error } | undefined> { + const { blockTimestamp, forceCreate, blockNumber, indexWithinCheckpoint, buildDeadline, txHashesAlreadyIncluded } = + opts; this.log.verbose( `Preparing block ${blockNumber} index ${indexWithinCheckpoint} at checkpoint ${this.checkpointNumber} for slot ${this.slot}`, @@ -568,16 +567,16 @@ export class CheckpointProposalJob implements Traceable { ); this.setStateFn(SequencerState.CREATING_BLOCK, this.slot); - // Calculate blob fields limit for txs (remaining capacity - this block's end overhead) - const blockEndOverhead = getNumBlockEndBlobFields(indexWithinCheckpoint === 0); - const maxBlobFieldsForTxs = remainingBlobFields - blockEndOverhead; - + // Per-block limits derived at startup by computeBlockLimits(), further capped + // by remaining checkpoint-level budgets inside CheckpointBuilder before each block is built. const blockBuilderOptions: PublicProcessorLimits = { maxTransactions: this.config.maxTxsPerBlock, - maxBlockSize: this.config.maxBlockSizeInBytes, - maxBlockGas: new Gas(this.config.maxDABlockGas, this.config.maxL2BlockGas), - maxBlobFields: maxBlobFieldsForTxs, + maxBlockGas: + this.config.maxL2BlockGas !== undefined || this.config.maxDABlockGas !== undefined + ? new Gas(this.config.maxDABlockGas ?? Infinity, this.config.maxL2BlockGas ?? Infinity) + : undefined, deadline: buildDeadline, + isBuildingProposal: true, }; // Actually build the block by executing txs @@ -607,7 +606,7 @@ export class CheckpointProposalJob implements Traceable { } // Block creation succeeded, emit stats and metrics - const { publicGas, block, publicProcessorDuration, usedTxs, usedTxBlobFields, blockBuildDuration } = buildResult; + const { block, publicProcessorDuration, usedTxs, blockBuildDuration } = buildResult; const blockStats = { eventName: 'l2-block-built', @@ -618,7 +617,7 @@ export class CheckpointProposalJob implements Traceable { const blockHash = await block.hash(); const txHashes = block.body.txEffects.map(tx => tx.txHash); - const manaPerSec = publicGas.l2Gas / (blockBuildDuration / 1000); + const manaPerSec = block.header.totalManaUsed.toNumberUnsafe() / (blockBuildDuration / 1000); this.log.info( `Built block ${block.number} at checkpoint ${this.checkpointNumber} for slot ${this.slot} with ${numTxs} txs`, @@ -626,9 +625,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..464340b385ff 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); @@ -871,7 +873,7 @@ describe('sequencer', () => { sequencer.updateConfig({ enforceTimeTable: true, maxTxsPerBlock: 4, blockDurationMs: 500 }); const txs = await timesParallel(8, i => makeTx(i * 0x10000)); - block = await makeBlock(txs); + block = await makeBlock(txs.slice(0, 4)); TestUtils.mockPendingTxs(p2p, txs); await sequencer.work(); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 527f7144bf60..7da938a6c828 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -110,7 +110,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter) { const filteredConfig = pickFromSchema(config, SequencerConfigSchema); - this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowList')); + this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowListExtend')); this.config = merge(this.config, filteredConfig); this.timetable = new SequencerTimetable( { diff --git a/yarn-project/sequencer-client/src/sequencer/timetable.ts b/yarn-project/sequencer-client/src/sequencer/timetable.ts index 505979f95af7..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.verbose( + 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..42d691191ef8 100644 --- a/yarn-project/sequencer-client/src/test/mock_checkpoint_builder.ts +++ b/yarn-project/sequencer-client/src/test/mock_checkpoint_builder.ts @@ -1,8 +1,8 @@ -import { type BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; +import { type BlockNumber, CheckpointNumber, IndexWithinCheckpoint } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; +import { unfreeze } from '@aztec/foundation/types'; import { L2Block } from '@aztec/stdlib/block'; import { Checkpoint } from '@aztec/stdlib/checkpoint'; -import { Gas } from '@aztec/stdlib/gas'; import type { FullNodeBlockBuilderConfig, ICheckpointBlockBuilder, @@ -86,8 +86,10 @@ export class MockCheckpointBuilder implements ICheckpointBlockBuilder { let usedTxs: Tx[]; if (this.blockProvider) { - // Dynamic mode: get block from provider - block = this.blockProvider(); + // Dynamic mode: get block from provider, cloning to avoid shared references across multiple buildBlock calls + block = L2Block.fromBuffer(this.blockProvider().toBuffer()); + block.header.globalVariables.blockNumber = blockNumber; + await block.header.recomputeHash(); usedTxs = []; this.builtBlocks.push(block); } else { @@ -113,81 +115,79 @@ 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, }; } completeCheckpoint(): Promise { this.completeCheckpointCalled = true; const allBlocks = this.blockProvider ? this.builtBlocks : this.blocks; - const lastBlock = allBlocks[allBlocks.length - 1]; - // Create a CheckpointHeader from the last block's header for testing - const checkpointHeader = this.createCheckpointHeader(lastBlock); - return Promise.resolve( - new Checkpoint( - makeAppendOnlyTreeSnapshot(lastBlock.header.globalVariables.blockNumber + 1), - checkpointHeader, - allBlocks, - this.checkpointNumber, - ), - ); + return this.buildCheckpoint(allBlocks); } getCheckpoint(): Promise { this.getCheckpointCalled = true; const builtBlocks = this.blockProvider ? this.builtBlocks : this.blocks.slice(0, this.blockIndex); - const lastBlock = builtBlocks[builtBlocks.length - 1]; - if (!lastBlock) { + if (builtBlocks.length === 0) { throw new Error('No blocks built yet'); } - // Create a CheckpointHeader from the last block's header for testing - const checkpointHeader = this.createCheckpointHeader(lastBlock); - return Promise.resolve( - new Checkpoint( - makeAppendOnlyTreeSnapshot(lastBlock.header.globalVariables.blockNumber + 1), - checkpointHeader, - builtBlocks, - this.checkpointNumber, - ), - ); + return this.buildCheckpoint(builtBlocks); } - /** - * Creates a CheckpointHeader from a block's header for testing. - * This is a simplified version that creates a minimal CheckpointHeader. - */ - private createCheckpointHeader(block: L2Block): CheckpointHeader { - const header = block.header; - const gv = header.globalVariables; - return CheckpointHeader.empty({ - lastArchiveRoot: header.lastArchive.root, - blockHeadersHash: Fr.random(), // Use random for testing + /** Builds a structurally valid Checkpoint from a list of blocks, fixing up indexes and archive chaining. */ + private async buildCheckpoint(blocks: L2Block[]): Promise { + // Fix up indexWithinCheckpoint and archive chaining so the checkpoint passes structural validation. + for (let i = 0; i < blocks.length; i++) { + blocks[i].indexWithinCheckpoint = IndexWithinCheckpoint(i); + if (i > 0) { + unfreeze(blocks[i].header).lastArchive = blocks[i - 1].archive; + await blocks[i].header.recomputeHash(); + } + } + + const firstBlock = blocks[0]; + const lastBlock = blocks[blocks.length - 1]; + const gv = firstBlock.header.globalVariables; + + const checkpointHeader = CheckpointHeader.empty({ + lastArchiveRoot: firstBlock.header.lastArchive.root, + blockHeadersHash: Fr.random(), slotNumber: gv.slotNumber, timestamp: gv.timestamp, coinbase: gv.coinbase, feeRecipient: gv.feeRecipient, gasFees: gv.gasFees, - totalManaUsed: header.totalManaUsed, + totalManaUsed: lastBlock.header.totalManaUsed, }); + + return new Checkpoint( + makeAppendOnlyTreeSnapshot(lastBlock.header.globalVariables.blockNumber + 1), + checkpointHeader, + blocks, + this.checkpointNumber, + ); } - /** Reset for reuse in another test */ - reset(): void { - this.blocks = []; + /** Resets per-checkpoint state (built blocks, consumed txs) while preserving config (blockProvider, seeded blocks). */ + resetCheckpointState(): void { this.builtBlocks = []; - this.usedTxsPerBlock = []; this.blockIndex = 0; - this.buildBlockCalls = []; this.consumedTxHashes.clear(); this.completeCheckpointCalled = false; this.getCheckpointCalled = false; + } + + /** Reset for reuse in another test */ + reset(): void { + this.blocks = []; + this.usedTxsPerBlock = []; + this.buildBlockCalls = []; this.errorOnBuild = undefined; this.blockProvider = undefined; + this.resetCheckpointState(); } } @@ -249,6 +249,7 @@ export class MockCheckpointsBuilder implements ICheckpointsBuilder { slotDuration: 24, l1ChainId: 1, rollupVersion: 1, + rollupManaLimit: 200_000_000, }; } @@ -275,6 +276,8 @@ export class MockCheckpointsBuilder implements ICheckpointsBuilder { if (!this.checkpointBuilder) { // Auto-create a builder if none was set this.checkpointBuilder = new MockCheckpointBuilder(constants, checkpointNumber); + } else { + this.checkpointBuilder.resetCheckpointState(); } return Promise.resolve(this.checkpointBuilder); @@ -301,6 +304,8 @@ export class MockCheckpointsBuilder implements ICheckpointsBuilder { if (!this.checkpointBuilder) { // Auto-create a builder if none was set this.checkpointBuilder = new MockCheckpointBuilder(constants, checkpointNumber); + } else { + this.checkpointBuilder.resetCheckpointState(); } return Promise.resolve(this.checkpointBuilder); 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 895f6cc76ec0..bbd65131bd4c 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); @@ -198,8 +201,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, + ); } /** @@ -217,6 +230,7 @@ export class PublicTxSimulationTester extends BaseAvmSimulationTester { teardownCall?: TestEnqueuedCall, feePayer?: AztecAddress, privateInsertions?: TestPrivateInsertions, + gasLimits?: Gas, ): Promise { return await this.simulateTxWithLabel( txLabel, @@ -226,6 +240,7 @@ export class PublicTxSimulationTester extends BaseAvmSimulationTester { teardownCall, feePayer, privateInsertions, + gasLimits, ); } diff --git a/yarn-project/simulator/src/public/fixtures/utils.ts b/yarn-project/simulator/src/public/fixtures/utils.ts index c058c9d7c128..b32fb1ec30d6 100644 --- a/yarn-project/simulator/src/public/fixtures/utils.ts +++ b/yarn-project/simulator/src/public/fixtures/utils.ts @@ -62,13 +62,14 @@ export async function createTxForPublicCalls( feePayer = AztecAddress.zero(), gasUsedByPrivate: Gas = Gas.empty(), globals: GlobalVariables = GlobalVariables.empty(), + gasLimits?: Gas, ): Promise { assert( setupCallRequests.length > 0 || appCallRequests.length > 0 || teardownCallRequest !== undefined, "Can't create public tx with no enqueued calls", ); // use max limits - const gasLimits = new Gas(DEFAULT_DA_GAS_LIMIT, DEFAULT_L2_GAS_LIMIT); + gasLimits = gasLimits ?? new Gas(DEFAULT_DA_GAS_LIMIT, DEFAULT_L2_GAS_LIMIT); const forPublic = PartialPrivateTailPublicInputsForPublic.empty(); diff --git a/yarn-project/simulator/src/public/public_processor/public_processor.test.ts b/yarn-project/simulator/src/public/public_processor/public_processor.test.ts index 03276162b887..907ee1f907c6 100644 --- a/yarn-project/simulator/src/public/public_processor/public_processor.test.ts +++ b/yarn-project/simulator/src/public/public_processor/public_processor.test.ts @@ -188,6 +188,22 @@ describe('public_processor', () => { expect(failed).toEqual([]); }); + it('skips tx before processing if estimated blob fields would exceed limit', async function () { + const tx = await mockTxWithPublicCalls(); + // Add note hashes to inflate the estimated blob fields size + for (let i = 0; i < 10; i++) { + tx.data.forPublic!.nonRevertibleAccumulatedData.noteHashes[i] = Fr.random(); + } + // 3 overhead + 1 nullifier + 10 note hashes = 14 estimated fields + // Set a limit that is too small for even one tx + const [processed, failed] = await processor.process([tx], { maxBlobFields: 10, isBuildingProposal: true }); + + expect(processed).toEqual([]); + expect(failed).toEqual([]); + // The simulator should not have been called since the tx was skipped pre-processing + expect(publicTxSimulator.simulate).not.toHaveBeenCalled(); + }); + it('does not exceed max blob fields limit', async function () { // Create 3 private-only transactions const txs = await Promise.all(Array.from([1, 2, 3], seed => mockPrivateOnlyTx({ seed }))); @@ -201,16 +217,13 @@ describe('public_processor', () => { const maxBlobFields = actualBlobFields * 2; // Process all 3 transactions with the blob field limit - const [processed, failed, _usedTxs, _returns, usedTxBlobFields] = await processor.process(txs, { maxBlobFields }); + const [processed, failed] = await processor.process(txs, { maxBlobFields }); // Should only process 2 transactions due to blob field limit expect(processed.length).toBe(2); expect(processed[0].hash).toEqual(txs[0].getTxHash()); expect(processed[1].hash).toEqual(txs[1].getTxHash()); expect(failed).toEqual([]); - - const expectedBlobFields = actualBlobFields * 2; - expect(usedTxBlobFields).toBe(expectedBlobFields); }); it('does not send a transaction to the prover if pre validation fails', async function () { diff --git a/yarn-project/simulator/src/public/public_processor/public_processor.ts b/yarn-project/simulator/src/public/public_processor/public_processor.ts index e3a776edac02..45a3d9e6906e 100644 --- a/yarn-project/simulator/src/public/public_processor/public_processor.ts +++ b/yarn-project/simulator/src/public/public_processor/public_processor.ts @@ -160,8 +160,8 @@ export class PublicProcessor implements Traceable { txs: Iterable | AsyncIterable, limits: PublicProcessorLimits = {}, validator: PublicProcessorValidator = {}, - ): Promise<[ProcessedTx[], FailedTx[], Tx[], NestedProcessReturnValues[], number, DebugLog[]]> { - const { maxTransactions, maxBlockSize, deadline, maxBlockGas, maxBlobFields } = limits; + ): Promise<[ProcessedTx[], FailedTx[], Tx[], NestedProcessReturnValues[], DebugLog[]]> { + const { maxTransactions, deadline, maxBlockGas, maxBlobFields, isBuildingProposal } = limits; const { preprocessValidator, nullifierCache } = validator; const result: ProcessedTx[] = []; const usedTxs: Tx[] = []; @@ -188,22 +188,23 @@ export class PublicProcessor implements Traceable { break; } - // Skip this tx if it'd exceed max block size const txHash = tx.getTxHash().toString(); - const preTxSizeInBytes = tx.getEstimatedPrivateTxEffectsSize(); - if (maxBlockSize !== undefined && totalSizeInBytes + preTxSizeInBytes > maxBlockSize) { - this.log.warn(`Skipping processing of tx ${txHash} sized ${preTxSizeInBytes} bytes due to block size limit`, { - txHash, - sizeInBytes: preTxSizeInBytes, - totalSizeInBytes, - maxBlockSize, - }); + + // Skip this tx if its estimated blob fields would exceed the limit. + // Only done during proposal building: during re-execution we must process the exact txs from the proposal. + const txBlobFields = tx.getPrivateTxEffectsSizeInFields(); + if (isBuildingProposal && maxBlobFields !== undefined && totalBlobFields + txBlobFields > maxBlobFields) { + this.log.warn( + `Skipping tx ${txHash} with ${txBlobFields} fields from private side effects due to blob fields limit`, + { txHash, txBlobFields, totalBlobFields, maxBlobFields }, + ); continue; } - // Skip this tx if its gas limit would exceed the block gas limit + // Skip this tx if its gas limit would exceed the block gas limit (either da or l2). + // Only done during proposal building: during re-execution we must process the exact txs from the proposal. const txGasLimit = tx.data.constants.txContext.gasSettings.gasLimits; - if (maxBlockGas !== undefined && totalBlockGas.add(txGasLimit).gtAny(maxBlockGas)) { + if (isBuildingProposal && maxBlockGas !== undefined && totalBlockGas.add(txGasLimit).gtAny(maxBlockGas)) { this.log.warn(`Skipping processing of tx ${txHash} due to block gas limit`, { txHash, txGasLimit, @@ -252,23 +253,9 @@ export class PublicProcessor implements Traceable { } const txBlobFields = processedTx.txEffect.getNumBlobFields(); - - // If the actual size of this tx would exceed block size, skip it const txSize = txBlobFields * Fr.SIZE_IN_BYTES; - if (maxBlockSize !== undefined && totalSizeInBytes + txSize > maxBlockSize) { - this.log.debug(`Skipping processed tx ${txHash} sized ${txSize} due to max block size.`, { - txHash, - sizeInBytes: txSize, - totalSizeInBytes, - maxBlockSize, - }); - // Need to revert the checkpoint here and don't go any further - await checkpoint.revert(); - this.contractsDB.revertCheckpoint(); - continue; - } - // If the actual blob fields of this tx would exceed the limit, skip it + // If the actual blob fields of this tx would exceed the limit, skip it. // Note: maxBlobFields already accounts for block end blob fields and previous blocks in checkpoint. if (maxBlobFields !== undefined && totalBlobFields + txBlobFields > maxBlobFields) { this.log.debug( @@ -286,6 +273,25 @@ export class PublicProcessor implements Traceable { continue; } + // During re-execution, check if the actual gas used by this tx would push the block over the gas limit. + // Unlike the proposal-building check (which uses declared gas limits pessimistically before processing), + // this uses actual gas and stops processing when the limit is exceeded. + if ( + !isBuildingProposal && + maxBlockGas !== undefined && + totalBlockGas.add(processedTx.gasUsed.totalGas).gtAny(maxBlockGas) + ) { + this.log.warn(`Stopping re-execution since tx ${txHash} would push block gas over limit`, { + txHash, + txGas: processedTx.gasUsed.totalGas, + totalBlockGas, + maxBlockGas, + }); + await checkpoint.revert(); + this.contractsDB.revertCheckpoint(); + break; + } + // FIXME(fcarreiro): it's ugly to have to notify the validator of nullifiers. // I'd rather pass the validators the processedTx as well and let them deal with it. nullifierCache?.addNullifiers(processedTx.txEffect.nullifiers.map(n => n.toBuffer())); @@ -368,7 +374,7 @@ export class PublicProcessor implements Traceable { totalSizeInBytes, }); - return [result, failed, usedTxs, returns, totalBlobFields, debugLogs]; + return [result, failed, usedTxs, returns, debugLogs]; } private async checkWorldStateUnchanged( diff --git a/yarn-project/stdlib/src/block/l2_block.ts b/yarn-project/stdlib/src/block/l2_block.ts index 15f037082a91..e7c78f332a1d 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, @@ -175,7 +176,7 @@ export class L2Block { } & Partial[0]> = {}, ): Promise { const archive = new AppendOnlyTreeSnapshot(Fr.random(), blockNumber + 1); - const header = BlockHeader.random({ blockNumber, ...blockHeaderOverrides }); + const header = BlockHeader.random({ ...blockHeaderOverrides, blockNumber }); const body = await Body.random({ txsPerBlock, makeTxOptions, ...txOptions }); return new L2Block(archive, header, body, checkpointNumber, indexWithinCheckpoint); } @@ -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/checkpoint.ts b/yarn-project/stdlib/src/checkpoint/checkpoint.ts index 2c95d3c0be4a..6f1159533cd1 100644 --- a/yarn-project/stdlib/src/checkpoint/checkpoint.ts +++ b/yarn-project/stdlib/src/checkpoint/checkpoint.ts @@ -6,7 +6,7 @@ import { IndexWithinCheckpoint, SlotNumber, } from '@aztec/foundation/branded-types'; -import { sum } from '@aztec/foundation/collection'; +import { pick, sum } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; import { BufferReader, serializeSignedBigInt, serializeToBuffer } from '@aztec/foundation/serialize'; import type { FieldsOf } from '@aztec/foundation/types'; @@ -152,10 +152,12 @@ export class Checkpoint { startBlockNumber?: number; previousArchive?: AppendOnlyTreeSnapshot; feeAssetPriceModifier?: bigint; + archive?: AppendOnlyTreeSnapshot; } & Partial[0]> & Partial[1]> = {}, ) { - const header = CheckpointHeader.random(options); + const headerOptions = previousArchive ? { lastArchiveRoot: previousArchive.root, ...options } : options; + const header = CheckpointHeader.random(headerOptions); // Create blocks sequentially to chain archive roots properly. // Each block's header.lastArchive must equal the previous block's archive. @@ -166,11 +168,18 @@ export class Checkpoint { indexWithinCheckpoint: IndexWithinCheckpoint(i), ...options, ...(lastArchive ? { lastArchive } : {}), + ...pick(header, 'slotNumber', 'timestamp', 'coinbase', 'feeRecipient', 'gasFees'), }); lastArchive = block.archive; blocks.push(block); } - return new Checkpoint(AppendOnlyTreeSnapshot.random(), header, blocks, checkpointNumber, feeAssetPriceModifier); + return new Checkpoint( + options.archive ?? AppendOnlyTreeSnapshot.random(), + header, + blocks, + checkpointNumber, + feeAssetPriceModifier, + ); } } 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.test.ts b/yarn-project/stdlib/src/checkpoint/validate.test.ts new file mode 100644 index 000000000000..6dfa314dd0c3 --- /dev/null +++ b/yarn-project/stdlib/src/checkpoint/validate.test.ts @@ -0,0 +1,233 @@ +import { BLOBS_PER_CHECKPOINT, FIELDS_PER_BLOB, MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT } from '@aztec/constants'; +import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; + +import { jest } from '@jest/globals'; + +import { AztecAddress } from '../aztec-address/index.js'; +import { GasFees } from '../gas/index.js'; +import { AppendOnlyTreeSnapshot } from '../trees/append_only_tree_snapshot.js'; +import { BlockHeader } from '../tx/block_header.js'; +import { Checkpoint } from './checkpoint.js'; +import { CheckpointValidationError, validateCheckpoint, validateCheckpointStructure } from './validate.js'; + +describe('validateCheckpointStructure', () => { + const checkpointNumber = CheckpointNumber(1); + + const fixedSlot = SlotNumber(42); + const fixedCoinbase = EthAddress.random(); + const fixedFeeRecipient = AztecAddress.fromField(Fr.random()); + const fixedGasFees = GasFees.random(); + const fixedTimestamp = BigInt(Math.floor(Date.now() / 1000)); + + /** Builds a valid random checkpoint with the given number of blocks. All blocks share the same slot, + * coinbase, feeRecipient, gasFees, and timestamp, and the checkpoint header's lastArchiveRoot is + * aligned with the first block. */ + async function makeValidCheckpoint(numBlocks = 2): Promise { + const checkpoint = await Checkpoint.random(checkpointNumber, { + numBlocks, + startBlockNumber: 1, + slotNumber: fixedSlot, + coinbase: fixedCoinbase, + feeRecipient: fixedFeeRecipient, + gasFees: fixedGasFees, + timestamp: fixedTimestamp, + }); + // Align checkpoint header's lastArchiveRoot with the first block. + checkpoint.header.lastArchiveRoot = checkpoint.blocks[0].header.lastArchive.root; + return checkpoint; + } + + it('passes on a valid single-block checkpoint', async () => { + const checkpoint = await makeValidCheckpoint(1); + expect(() => validateCheckpointStructure(checkpoint)).not.toThrow(); + }); + + it('passes on a valid multi-block checkpoint', async () => { + const checkpoint = await makeValidCheckpoint(3); + expect(() => validateCheckpointStructure(checkpoint)).not.toThrow(); + }); + + it('throws when checkpoint slot does not match first block slot', async () => { + const checkpoint = await makeValidCheckpoint(1); + checkpoint.header.slotNumber = SlotNumber(checkpoint.blocks[0].slot + 1); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/all blocks must share the same slot/); + }); + + it('throws when checkpoint lastArchiveRoot does not match first block lastArchive root', async () => { + const checkpoint = await makeValidCheckpoint(1); + checkpoint.header.lastArchiveRoot = AppendOnlyTreeSnapshot.random().root; + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/lastArchiveRoot does not match first block/); + }); + + it('throws on empty block list', async () => { + const checkpoint = await makeValidCheckpoint(1); + checkpoint.blocks = []; + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow('Checkpoint has no blocks'); + }); + + it('throws when block count exceeds MAX_BLOCKS_PER_CHECKPOINT', async () => { + // Build 73 blocks (MAX_BLOCKS_PER_CHECKPOINT = 72) + const checkpoint = await makeValidCheckpoint(1); + // Reuse the single block to fill up 73 slots (structure checks happen before archive chaining in loop) + const block = checkpoint.blocks[0]; + checkpoint.blocks = Array.from({ length: 73 }, (_, i) => { + const cloned = Object.create(Object.getPrototypeOf(block), Object.getOwnPropertyDescriptors(block)); + cloned.indexWithinCheckpoint = IndexWithinCheckpoint(i); + return cloned; + }); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/exceeding limit of 72/); + }); + + it('throws when indexWithinCheckpoint is wrong', async () => { + const checkpoint = await makeValidCheckpoint(2); + // Swap the indices + const block0 = checkpoint.blocks[0]; + block0.indexWithinCheckpoint = IndexWithinCheckpoint(1); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/indexWithinCheckpoint/); + }); + + it('throws when block numbers are not sequential', async () => { + const checkpoint = await makeValidCheckpoint(2); + // Manually set block[1] to a non-sequential number (block[0].number + 2) + const block1 = checkpoint.blocks[1]; + // Override block number via header globalVariables + const gv = block1.header.globalVariables; + gv.blockNumber = BlockNumber(gv.blockNumber + 2); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/not sequential/); + }); + + it('throws when archive roots are not chained', async () => { + const checkpoint = await makeValidCheckpoint(2); + // Break chaining: replace block[1]'s header with a new one that has a random lastArchive + const block1 = checkpoint.blocks[1]; + block1.header = BlockHeader.from({ ...block1.header, lastArchive: AppendOnlyTreeSnapshot.random() }); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/lastArchive root does not match/); + }); + + it('throws when blocks have different slot numbers', async () => { + const checkpoint = await makeValidCheckpoint(2); + // Change block[1]'s slot to something different + const block1 = checkpoint.blocks[1]; + block1.header.globalVariables.slotNumber = SlotNumber(block1.slot + 1); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/all blocks must share the same slot/); + }); + + it('throws when a block global variables do not match checkpoint header', async () => { + const checkpoint = await makeValidCheckpoint(2); + // Mutate coinbase on block[1] to something different from the checkpoint header + checkpoint.blocks[1].header.globalVariables.coinbase = EthAddress.random(); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/global variables.*do not match checkpoint header/); + }); +}); + +describe('validateCheckpoint — limits', () => { + const checkpointNumber = CheckpointNumber(1); + const fixedSlot = SlotNumber(42); + const fixedCoinbase = EthAddress.random(); + const fixedFeeRecipient = AztecAddress.fromField(Fr.random()); + const fixedGasFees = GasFees.random(); + const fixedTimestamp = BigInt(Math.floor(Date.now() / 1000)); + + /** A known mana value injected into every block, making assertions deterministic. */ + const specificMana = 1_000_000; + + /** Opts that leave all limits wide open so structural validity is tested in isolation. */ + const validOpts = { + rollupManaLimit: Number.MAX_SAFE_INTEGER, + maxL2BlockGas: undefined as number | undefined, + maxDABlockGas: undefined as number | undefined, + }; + + /** Builds a structurally valid single-block checkpoint with a known mana value. */ + async function makeCheckpoint(): Promise { + const checkpoint = await Checkpoint.random(checkpointNumber, { + numBlocks: 1, + startBlockNumber: 1, + slotNumber: fixedSlot, + coinbase: fixedCoinbase, + feeRecipient: fixedFeeRecipient, + gasFees: fixedGasFees, + timestamp: fixedTimestamp, + totalManaUsed: new Fr(specificMana), + }); + checkpoint.header.lastArchiveRoot = checkpoint.blocks[0].header.lastArchive.root; + return checkpoint; + } + + it('passes when all limits are within bounds', async () => { + const checkpoint = await makeCheckpoint(); + expect(() => validateCheckpoint(checkpoint, validOpts)).not.toThrow(); + }); + + it('throws when checkpoint mana exceeds rollupManaLimit', async () => { + const checkpoint = await makeCheckpoint(); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, rollupManaLimit: specificMana - 1 })).toThrow( + CheckpointValidationError, + ); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, rollupManaLimit: specificMana - 1 })).toThrow( + /mana cost.*exceeds rollup limit/, + ); + }); + + it('passes when checkpoint mana equals rollupManaLimit', async () => { + const checkpoint = await makeCheckpoint(); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, rollupManaLimit: specificMana })).not.toThrow(); + }); + + it('throws when checkpoint DA gas exceeds MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT', async () => { + const checkpoint = await makeCheckpoint(); + jest.spyOn(checkpoint.blocks[0], 'computeDAGasUsed').mockReturnValue(MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT + 1); + expect(() => validateCheckpoint(checkpoint, validOpts)).toThrow(CheckpointValidationError); + expect(() => validateCheckpoint(checkpoint, validOpts)).toThrow(/DA gas cost.*exceeds limit/); + }); + + it('throws when checkpoint blob field count exceeds limit', async () => { + const checkpoint = await makeCheckpoint(); + const maxBlobFields = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB; + jest.spyOn(checkpoint, 'toBlobFields').mockReturnValue(new Array(maxBlobFields + 1).fill(Fr.ZERO)); + expect(() => validateCheckpoint(checkpoint, validOpts)).toThrow(CheckpointValidationError); + expect(() => validateCheckpoint(checkpoint, validOpts)).toThrow(/blob field count.*exceeds limit/); + }); + + it('throws when a block L2 gas exceeds maxL2BlockGas', async () => { + const checkpoint = await makeCheckpoint(); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, maxL2BlockGas: specificMana - 1 })).toThrow( + CheckpointValidationError, + ); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, maxL2BlockGas: specificMana - 1 })).toThrow( + /L2 gas used.*exceeding limit/, + ); + }); + + it('skips per-block L2 gas check when maxL2BlockGas is undefined', async () => { + const checkpoint = await makeCheckpoint(); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, maxL2BlockGas: undefined })).not.toThrow(); + }); + + it('throws when a block DA gas exceeds maxDABlockGas', async () => { + const checkpoint = await makeCheckpoint(); + jest.spyOn(checkpoint.blocks[0], 'computeDAGasUsed').mockReturnValue(1000); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, maxDABlockGas: 999 })).toThrow( + CheckpointValidationError, + ); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, maxDABlockGas: 999 })).toThrow( + /DA gas used.*exceeding limit/, + ); + }); + + it('skips per-block DA gas check when maxDABlockGas is undefined', async () => { + const checkpoint = await makeCheckpoint(); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, maxDABlockGas: undefined })).not.toThrow(); + }); +}); diff --git a/yarn-project/stdlib/src/checkpoint/validate.ts b/yarn-project/stdlib/src/checkpoint/validate.ts new file mode 100644 index 000000000000..1ceb9fa4c102 --- /dev/null +++ b/yarn-project/stdlib/src/checkpoint/validate.ts @@ -0,0 +1,230 @@ +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 { MAX_BLOCKS_PER_CHECKPOINT } from '../deserialization/index.js'; +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 structural integrity (non-empty, block count, sequential numbers, archive chaining, slot consistency) + * - 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; + maxDABlockGas?: number; + maxTxsPerCheckpoint?: number; + maxTxsPerBlock?: number; + }, +): void { + validateCheckpointStructure(checkpoint); + validateCheckpointLimits(checkpoint, opts); + validateCheckpointBlocksGasLimits(checkpoint, opts); +} + +/** + * Validates structural integrity of a checkpoint. + * - Non-empty block list + * - Block count within MAX_BLOCKS_PER_CHECKPOINT + * - Checkpoint slot matches the first block's slot + * - Checkpoint lastArchiveRoot matches the first block's lastArchive root + * - Sequential block numbers without gaps + * - Sequential indexWithinCheckpoint starting at 0 + * - Archive root chaining between consecutive blocks + * - Consistent slot number across all blocks + * - Global variables (slot, timestamp, coinbase, feeRecipient, gasFees) match checkpoint header for each block + */ +export function validateCheckpointStructure(checkpoint: Checkpoint): void { + const { blocks, number, slot } = checkpoint; + + if (blocks.length === 0) { + throw new CheckpointValidationError('Checkpoint has no blocks', number, slot); + } + + if (blocks.length > MAX_BLOCKS_PER_CHECKPOINT) { + throw new CheckpointValidationError( + `Checkpoint has ${blocks.length} blocks, exceeding limit of ${MAX_BLOCKS_PER_CHECKPOINT}`, + number, + slot, + ); + } + + const firstBlock = blocks[0]; + + if (!checkpoint.header.lastArchiveRoot.equals(firstBlock.header.lastArchive.root)) { + throw new CheckpointValidationError( + `Checkpoint lastArchiveRoot does not match first block's lastArchive root`, + number, + slot, + ); + } + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + + if (block.indexWithinCheckpoint !== i) { + throw new CheckpointValidationError( + `Block at index ${i} has indexWithinCheckpoint ${block.indexWithinCheckpoint}, expected ${i}`, + number, + slot, + ); + } + + if (block.slot !== slot) { + throw new CheckpointValidationError( + `Block ${block.number} has slot ${block.slot}, expected ${slot} (all blocks must share the same slot)`, + number, + slot, + ); + } + + if (!checkpoint.header.matchesGlobalVariables(block.header.globalVariables)) { + throw new CheckpointValidationError( + `Block ${block.number} global variables (slot, timestamp, coinbase, feeRecipient, gasFees) do not match checkpoint header`, + number, + slot, + ); + } + + if (i > 0) { + const prev = blocks[i - 1]; + if (block.number !== prev.number + 1) { + throw new CheckpointValidationError( + `Block numbers are not sequential: block at index ${i - 1} has number ${prev.number}, block at index ${i} has number ${block.number}`, + number, + slot, + ); + } + + if (!block.header.lastArchive.root.equals(prev.archive.root)) { + throw new CheckpointValidationError( + `Block ${block.number} lastArchive root does not match archive root of block ${prev.number}`, + number, + slot, + ); + } + } + } +} + +/** Validates checkpoint blocks gas limits */ +function validateCheckpointBlocksGasLimits( + checkpoint: Checkpoint, + opts: { + maxL2BlockGas?: number; + maxDABlockGas?: number; + maxTxsPerBlock?: number; + }, +): void { + const { maxL2BlockGas, maxDABlockGas, maxTxsPerBlock } = 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, + ); + } + } + } + + if (maxTxsPerBlock !== undefined) { + for (const block of checkpoint.blocks) { + const blockTxCount = block.body.txEffects.length; + if (blockTxCount > maxTxsPerBlock) { + throw new CheckpointValidationError( + `Block ${block.number} in checkpoint has ${blockTxCount} txs exceeding limit of ${maxTxsPerBlock}`, + checkpoint.number, + checkpoint.slot, + ); + } + } + } +} + +/** Validates checkpoint max blob fields, gas limits, and tx limits */ +function validateCheckpointLimits( + checkpoint: Checkpoint, + opts: { + rollupManaLimit?: number; + maxTxsPerCheckpoint?: number; + }, +): void { + const { rollupManaLimit, maxTxsPerCheckpoint } = opts; + + const maxBlobFields = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB; + const maxDAGas = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT; + + if (rollupManaLimit !== undefined) { + 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, + ); + } + + const checkpointBlobFields = checkpoint.toBlobFields().length; + if (checkpointBlobFields > maxBlobFields) { + throw new CheckpointValidationError( + `Checkpoint blob field count ${checkpointBlobFields} exceeds limit of ${maxBlobFields}`, + checkpoint.number, + checkpoint.slot, + ); + } + + if (maxTxsPerCheckpoint !== undefined) { + const checkpointTxCount = sum(checkpoint.blocks.map(block => block.body.txEffects.length)); + if (checkpointTxCount > maxTxsPerCheckpoint) { + throw new CheckpointValidationError( + `Checkpoint tx count ${checkpointTxCount} exceeds limit of ${maxTxsPerCheckpoint}`, + checkpoint.number, + checkpoint.slot, + ); + } + } +} diff --git a/yarn-project/stdlib/src/config/sequencer-config.ts b/yarn-project/stdlib/src/config/sequencer-config.ts index 31d0eca9458a..4dd540a108f1 100644 --- a/yarn-project/stdlib/src/config/sequencer-config.ts +++ b/yarn-project/stdlib/src/config/sequencer-config.ts @@ -1,10 +1,7 @@ -import { type ConfigMappingsType, numberConfigHelper } from '@aztec/foundation/config'; +import type { ConfigMappingsType } from '@aztec/foundation/config'; import type { SequencerConfig } from '../interfaces/configs.js'; -/** Default maximum number of transactions per block. */ -export const DEFAULT_MAX_TXS_PER_BLOCK = 32; - /** * Partial sequencer config mappings for fields that need to be shared across packages. * The full sequencer config mappings remain in sequencer-client, but shared fields @@ -32,6 +29,6 @@ export const sharedSequencerConfigMappings: ConfigMappingsType< maxTxsPerBlock: { env: 'SEQ_MAX_TX_PER_BLOCK', description: 'The maximum number of txs to include in a block.', - ...numberConfigHelper(DEFAULT_MAX_TXS_PER_BLOCK), + parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined), }, }; diff --git a/yarn-project/stdlib/src/interfaces/allowed_element.ts b/yarn-project/stdlib/src/interfaces/allowed_element.ts index b8de0fd4e661..4c7d351f63ed 100644 --- a/yarn-project/stdlib/src/interfaces/allowed_element.ts +++ b/yarn-project/stdlib/src/interfaces/allowed_element.ts @@ -6,18 +6,34 @@ import type { FunctionSelector } from '../abi/function_selector.js'; import type { AztecAddress } from '../aztec-address/index.js'; import { schemas, zodFor } from '../schemas/index.js'; -type AllowedInstance = { address: AztecAddress }; -type AllowedInstanceFunction = { address: AztecAddress; selector: FunctionSelector }; -type AllowedClass = { classId: Fr }; -type AllowedClassFunction = { classId: Fr; selector: FunctionSelector }; +type AllowedInstanceFunction = { + address: AztecAddress; + selector: FunctionSelector; + onlySelf?: boolean; + rejectNullMsgSender?: boolean; +}; +type AllowedClassFunction = { + classId: Fr; + selector: FunctionSelector; + onlySelf?: boolean; + rejectNullMsgSender?: boolean; +}; -export type AllowedElement = AllowedInstance | AllowedInstanceFunction | AllowedClass | AllowedClassFunction; +export type AllowedElement = AllowedInstanceFunction | AllowedClassFunction; export const AllowedElementSchema = zodFor()( z.union([ - z.object({ address: schemas.AztecAddress, selector: schemas.FunctionSelector }), - z.object({ address: schemas.AztecAddress }), - z.object({ classId: schemas.Fr, selector: schemas.FunctionSelector }), - z.object({ classId: schemas.Fr }), + z.object({ + address: schemas.AztecAddress, + selector: schemas.FunctionSelector, + onlySelf: z.boolean().optional(), + rejectNullMsgSender: z.boolean().optional(), + }), + z.object({ + classId: schemas.Fr, + selector: schemas.FunctionSelector, + onlySelf: z.boolean().optional(), + rejectNullMsgSender: z.boolean().optional(), + }), ]), ); diff --git a/yarn-project/stdlib/src/interfaces/block-builder.ts b/yarn-project/stdlib/src/interfaces/block-builder.ts index b5b5ea9a4c1a..87ed1444fff5 100644 --- a/yarn-project/stdlib/src/interfaces/block-builder.ts +++ b/yarn-project/stdlib/src/interfaces/block-builder.ts @@ -36,11 +36,16 @@ export interface IBlockFactory extends ProcessedTxHandler { } export interface PublicProcessorLimits { + /** Maximum number of txs to process. */ maxTransactions?: number; - maxBlockSize?: number; + /** L2 and DA gas limits. */ maxBlockGas?: Gas; + /** Maximum number of blob fields allowed. */ maxBlobFields?: number; + /** Deadline for processing the txs. Processor will stop as soon as it hits this time. */ deadline?: Date; + /** Whether this processor is building a proposal (as opposed to re-executing one). Skipping txs due to gas or blob limits is only done during proposal building. */ + isBuildingProposal?: boolean; } export interface PublicProcessorValidator { @@ -50,16 +55,33 @@ export interface PublicProcessorValidator { export type FullNodeBlockBuilderConfig = Pick & Pick & - Pick; + Pick< + SequencerConfig, + | 'txPublicSetupAllowListExtend' + | 'fakeProcessingDelayPerTxMs' + | 'fakeThrowAfterProcessingTxCount' + | 'maxTxsPerBlock' + | 'maxTxsPerCheckpoint' + | 'maxL2BlockGas' + | 'maxDABlockGas' + > & { + /** Total L2 gas (mana) allowed per checkpoint. Fetched from L1 getManaLimit(). */ + rollupManaLimit: number; + }; export const FullNodeBlockBuilderConfigKeys: (keyof FullNodeBlockBuilderConfig)[] = [ 'l1GenesisTime', 'slotDuration', 'l1ChainId', 'rollupVersion', - 'txPublicSetupAllowList', + 'txPublicSetupAllowListExtend', 'fakeProcessingDelayPerTxMs', 'fakeThrowAfterProcessingTxCount', + 'maxTxsPerBlock', + 'maxTxsPerCheckpoint', + '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 +95,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 88b1366a6889..a290e0c69b0d 100644 --- a/yarn-project/stdlib/src/interfaces/configs.ts +++ b/yarn-project/stdlib/src/interfaces/configs.ts @@ -13,6 +13,8 @@ export interface SequencerConfig { sequencerPollingIntervalMS?: number; /** The maximum number of txs to include in a block. */ maxTxsPerBlock?: number; + /** The maximum number of txs across all blocks in a checkpoint. */ + maxTxsPerCheckpoint?: number; /** The minimum number of txs to include in a block. */ minTxsPerBlock?: number; /** The minimum number of valid txs (after execution) to include in a block. If not set, falls back to minTxsPerBlock. */ @@ -23,6 +25,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. */ @@ -31,10 +35,8 @@ export interface SequencerConfig { acvmWorkingDirectory?: string; /** The path to the ACVM binary */ acvmBinaryPath?: string; - /** The list of functions calls allowed to run in setup */ - txPublicSetupAllowList?: AllowedElement[]; - /** Max block size */ - maxBlockSizeInBytes?: number; + /** Additional entries to extend the default setup allow list. */ + txPublicSetupAllowListExtend?: AllowedElement[]; /** Payload address to vote for */ governanceProposerPayload?: EthAddress; /** Whether to enforce the time table when building blocks */ @@ -85,17 +87,18 @@ export const SequencerConfigSchema = zodFor()( z.object({ sequencerPollingIntervalMS: z.number().optional(), maxTxsPerBlock: z.number().optional(), + maxTxsPerCheckpoint: z.number().optional(), minValidTxsPerBlock: z.number().optional(), minTxsPerBlock: z.number().optional(), 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(), + txPublicSetupAllowListExtend: z.array(AllowedElementSchema).optional(), governanceProposerPayload: schemas.EthAddress.optional(), l1PublishingTime: z.number().optional(), enforceTimeTable: z.boolean().optional(), @@ -132,9 +135,14 @@ type SequencerConfigOptionalKeys = | 'fakeProcessingDelayPerTxMs' | 'fakeThrowAfterProcessingTxCount' | 'l1PublishingTime' - | 'txPublicSetupAllowList' + | 'txPublicSetupAllowListExtend' | 'minValidTxsPerBlock' - | 'minBlocksForCheckpoint'; + | 'minBlocksForCheckpoint' + | 'maxTxsPerBlock' + | 'maxTxsPerCheckpoint' + | 'maxL2BlockGas' + | 'maxDABlockGas' + | 'gasPerBlockAllocationMultiplier'; export type ResolvedSequencerConfig = Prettify< Required> & Pick diff --git a/yarn-project/stdlib/src/interfaces/validator.ts b/yarn-project/stdlib/src/interfaces/validator.ts index 898657f268f4..603dbbc1a8ff 100644 --- a/yarn-project/stdlib/src/interfaces/validator.ts +++ b/yarn-project/stdlib/src/interfaces/validator.ts @@ -62,7 +62,7 @@ export type ValidatorClientConfig = ValidatorHASignerConfig & { }; export type ValidatorClientFullConfig = ValidatorClientConfig & - Pick & + Pick & Pick< SlasherConfig, 'slashBroadcastedInvalidBlockPenalty' | 'slashDuplicateProposalPenalty' | 'slashDuplicateAttestationPenalty' @@ -91,7 +91,7 @@ export const ValidatorClientConfigSchema = zodFor>()( ValidatorClientConfigSchema.extend({ - txPublicSetupAllowList: z.array(AllowedElementSchema).optional(), + txPublicSetupAllowListExtend: z.array(AllowedElementSchema).optional(), broadcastInvalidBlockProposal: z.boolean().optional(), maxTxsPerBlock: z.number().optional(), slashBroadcastedInvalidBlockPenalty: schemas.BigInt, 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..45b98431c5ca 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; @@ -425,10 +427,13 @@ export async function mockCheckpointAndMessages( Partial[1]> = {}, ) { const slotNumber = options.slotNumber ?? SlotNumber(Number(checkpointNumber) * 10); + const globals = GlobalVariables.random({ slotNumber, ...options }); const blocksAndMessages = []; + // Track the previous block's archive to ensure consecutive blocks have consistent archive roots. // The current block's header.lastArchive must equal the previous block's archive. let lastArchive: AppendOnlyTreeSnapshot | undefined = previousArchive; + // Pass maxEffects via txOptions so it reaches TxEffect.random const txOptions = maxEffects !== undefined ? { maxEffects } : {}; for (let i = 0; i < (blocks?.length ?? numBlocks); i++) { @@ -437,11 +442,11 @@ export async function mockCheckpointAndMessages( block: blocks?.[i] ?? (await L2Block.random(blockNumber, { + ...globals, checkpointNumber, indexWithinCheckpoint: IndexWithinCheckpoint(i), txsPerBlock: numTxsPerBlock, txOptions, - slotNumber, ...options, ...makeBlockOptions(blockNumber), ...(lastArchive ? { lastArchive } : {}), @@ -455,12 +460,18 @@ export async function mockCheckpointAndMessages( const messages = blocksAndMessages[0].messages; const inHash = computeInHashFromL1ToL2Messages(messages); - const checkpoint = await Checkpoint.random(checkpointNumber, { numBlocks: 0, slotNumber, inHash, ...options }); + const firstBlockLastArchive = blocksAndMessages[0].block.header.lastArchive; + const checkpoint = await Checkpoint.random(checkpointNumber, { + numBlocks: 0, + inHash, + ...options, + ...globals, + lastArchive: firstBlockLastArchive, + lastArchiveRoot: firstBlockLastArchive.root, + archive: lastArchive, + }); + checkpoint.blocks = blocksAndMessages.map(({ block }) => block); - // Set the checkpoint's archive to match the last block's archive for proper chaining. - // When the archiver reconstructs checkpoints from L1, it uses the checkpoint's archive root - // from the L1 event to set the last block's archive. Without this, the archive chain breaks. - checkpoint.archive = lastArchive!; // Return lastArchive so callers can chain it across multiple checkpoints return { checkpoint, messages, lastArchive }; diff --git a/yarn-project/stdlib/src/tx/block_header.ts b/yarn-project/stdlib/src/tx/block_header.ts index 1a1457e0f96e..6788df00ca98 100644 --- a/yarn-project/stdlib/src/tx/block_header.ts +++ b/yarn-project/stdlib/src/tx/block_header.ts @@ -176,6 +176,12 @@ export class BlockHeader { this._cachedHash = Promise.resolve(new BlockHash(hashed)); } + /** Recomputes the cached hash. Used for testing when header fields are mutated via unfreeze. */ + recomputeHash(): Promise { + this._cachedHash = undefined; + return this.hash(); + } + static random(overrides: Partial> & Partial> = {}): BlockHeader { return BlockHeader.from({ lastArchive: AppendOnlyTreeSnapshot.random(), diff --git a/yarn-project/stdlib/src/tx/tx.test.ts b/yarn-project/stdlib/src/tx/tx.test.ts index 500178d46be8..8dc3affa5880 100644 --- a/yarn-project/stdlib/src/tx/tx.test.ts +++ b/yarn-project/stdlib/src/tx/tx.test.ts @@ -1,6 +1,15 @@ +import { PRIVATE_LOG_SIZE_IN_FIELDS } from '@aztec/constants'; +import { makeTuple } from '@aztec/foundation/array'; import { randomBytes } from '@aztec/foundation/crypto/random'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; import { jsonStringify } from '@aztec/foundation/json-rpc'; +import { AztecAddress } from '../aztec-address/index.js'; +import { LogHash, ScopedLogHash } from '../kernel/log_hash.js'; +import { PrivateKernelTailCircuitPublicInputs } from '../kernel/private_kernel_tail_circuit_public_inputs.js'; +import { PrivateLog } from '../logs/private_log.js'; +import { L2ToL1Message, ScopedL2ToL1Message } from '../messaging/l2_to_l1_message.js'; import { mockTx } from '../tests/mocks.js'; import { Tx, TxArray } from './tx.js'; @@ -16,6 +25,105 @@ describe('Tx', () => { const json = jsonStringify(tx); expect(await Tx.schema.parseAsync(JSON.parse(json))).toEqual(tx); }); + + describe('getPrivateTxEffectsSizeInFields', () => { + function makePrivateOnlyTx() { + const data = PrivateKernelTailCircuitPublicInputs.emptyWithNullifier(); + return Tx.from({ + txHash: Tx.random().txHash, + data, + chonkProof: Tx.random().chonkProof, + contractClassLogFields: [], + publicFunctionCalldata: [], + }); + } + + const someAddress = AztecAddress.fromField(new Fr(27)); + + it('returns overhead only for tx with just a nullifier', () => { + const tx = makePrivateOnlyTx(); + // 3 fields overhead + 1 nullifier (from emptyWithNullifier) + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(3 + 1); + }); + + it('counts note hashes', () => { + const tx = makePrivateOnlyTx(); + const end = tx.data.forRollup!.end; + end.noteHashes[0] = Fr.random(); + end.noteHashes[1] = Fr.random(); + // 3 overhead + 1 nullifier + 2 note hashes + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(3 + 1 + 2); + }); + + it('counts nullifiers', () => { + const tx = makePrivateOnlyTx(); + const end = tx.data.forRollup!.end; + end.nullifiers[1] = Fr.random(); + end.nullifiers[2] = Fr.random(); + // 3 overhead + 3 nullifiers (1 from emptyWithNullifier + 2 new) + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(3 + 3); + }); + + it('counts L2 to L1 messages', () => { + const tx = makePrivateOnlyTx(); + const end = tx.data.forRollup!.end; + end.l2ToL1Msgs[0] = new ScopedL2ToL1Message(new L2ToL1Message(EthAddress.random(), Fr.random()), someAddress); + // 3 overhead + 1 nullifier + 1 L2-to-L1 message + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(3 + 1 + 1); + }); + + it('counts private logs with length field', () => { + const tx = makePrivateOnlyTx(); + const end = tx.data.forRollup!.end; + const emittedLength = 5; + end.privateLogs[0] = new PrivateLog(makeTuple(PRIVATE_LOG_SIZE_IN_FIELDS, Fr.random), emittedLength); + // 3 overhead + 1 nullifier + (5 content + 1 length field) + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(3 + 1 + 6); + }); + + it('counts contract class logs with contract address field', () => { + const tx = makePrivateOnlyTx(); + const end = tx.data.forRollup!.end; + const logLength = 10; + end.contractClassLogsHashes[0] = new ScopedLogHash(new LogHash(Fr.random(), logLength), someAddress); + // 3 overhead + 1 nullifier + (10 content + 1 contract address) + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(3 + 1 + 11); + }); + + it('counts all side effects together', () => { + const tx = makePrivateOnlyTx(); + const end = tx.data.forRollup!.end; + + // 2 additional nullifiers (1 already from emptyWithNullifier) + end.nullifiers[1] = Fr.random(); + end.nullifiers[2] = Fr.random(); + + // 3 note hashes + end.noteHashes[0] = Fr.random(); + end.noteHashes[1] = Fr.random(); + end.noteHashes[2] = Fr.random(); + + // 1 L2-to-L1 message + end.l2ToL1Msgs[0] = new ScopedL2ToL1Message(new L2ToL1Message(EthAddress.random(), Fr.random()), someAddress); + + // 2 private logs with different lengths + end.privateLogs[0] = new PrivateLog(makeTuple(PRIVATE_LOG_SIZE_IN_FIELDS, Fr.random), 4); + end.privateLogs[1] = new PrivateLog(makeTuple(PRIVATE_LOG_SIZE_IN_FIELDS, Fr.random), 7); + + // 1 contract class log + end.contractClassLogsHashes[0] = new ScopedLogHash(new LogHash(Fr.random(), 12), someAddress); + + const expected = + 3 + // overhead + 3 + // note hashes + 3 + // nullifiers + 1 + // L2-to-L1 messages + (4 + 1) + // first private log (content + length) + (7 + 1) + // second private log (content + length) + (12 + 1); // contract class log (content + contract address) + expect(tx.getPrivateTxEffectsSizeInFields()).toBe(expected); + }); + }); }); describe('TxArray', () => { diff --git a/yarn-project/stdlib/src/tx/tx.ts b/yarn-project/stdlib/src/tx/tx.ts index 8bbb1cc9c538..6ff10f6372bd 100644 --- a/yarn-project/stdlib/src/tx/tx.ts +++ b/yarn-project/stdlib/src/tx/tx.ts @@ -1,8 +1,9 @@ +import { DA_GAS_PER_FIELD, TX_DA_GAS_OVERHEAD } from '@aztec/constants'; import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { ZodFor } from '@aztec/foundation/schemas'; import { BufferReader, serializeArrayOfBufferableToVector, serializeToBuffer } from '@aztec/foundation/serialize'; -import type { FieldsOf } from '@aztec/foundation/types'; +import { type FieldsOf, unfreeze } from '@aztec/foundation/types'; import { z } from 'zod'; @@ -264,16 +265,24 @@ export class Tx extends Gossipable { } /** - * Estimates the tx size based on its private effects. Note that the actual size of the tx - * after processing will probably be larger, as public execution would generate more data. + * Returns the number of fields this tx's effects will occupy in the blob, + * based on its private side effects only. Accurate for txs without public calls. + * For txs with public calls, the actual size will be larger due to public execution outputs. */ - getEstimatedPrivateTxEffectsSize() { - return ( - this.data.getNonEmptyNoteHashes().length * Fr.SIZE_IN_BYTES + - this.data.getNonEmptyNullifiers().length * Fr.SIZE_IN_BYTES + - this.data.getEmittedPrivateLogsLength() * Fr.SIZE_IN_BYTES + - this.data.getEmittedContractClassLogsLength() * Fr.SIZE_IN_BYTES - ); + getPrivateTxEffectsSizeInFields(): number { + // 3 fields overhead: tx_start_marker, tx_hash, tx_fee. + // TX_DA_GAS_OVERHEAD is defined as N * DA_GAS_PER_FIELD, so this division is always exact. + const overheadFields = TX_DA_GAS_OVERHEAD / DA_GAS_PER_FIELD; + const noteHashes = this.data.getNonEmptyNoteHashes().length; + const nullifiers = this.data.getNonEmptyNullifiers().length; + const l2ToL1Msgs = this.data.getNonEmptyL2ToL1Msgs().length; + // Each private log occupies (emittedLength + 1) fields: content + length field + const privateLogFields = this.data.getNonEmptyPrivateLogs().reduce((acc, log) => acc + log.emittedLength + 1, 0); + // Each contract class log occupies (length + 1) fields: content + contract address + const contractClassLogFields = this.data + .getNonEmptyContractClassLogsHashes() + .reduce((acc, log) => acc + log.logHash.length + 1, 0); + return overheadFields + noteHashes + nullifiers + l2ToL1Msgs + privateLogFields + contractClassLogFields; } /** @@ -309,7 +318,7 @@ export class Tx extends Gossipable { /** Recomputes the tx hash. Used for testing purposes only when a property of the tx was mutated. */ public async recomputeHash(): Promise { - (this as any).txHash = await Tx.computeTxHash(this); + unfreeze(this).txHash = await Tx.computeTxHash(this); return this.txHash; } diff --git a/yarn-project/stdlib/src/tx/validator/error_texts.ts b/yarn-project/stdlib/src/tx/validator/error_texts.ts index 30f4867d209f..c9214299d46b 100644 --- a/yarn-project/stdlib/src/tx/validator/error_texts.ts +++ b/yarn-project/stdlib/src/tx/validator/error_texts.ts @@ -6,6 +6,9 @@ export const TX_ERROR_GAS_LIMIT_TOO_HIGH = 'Gas limit is higher than the amount // Phases export const TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED = 'Setup function not on allow list'; +export const TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT = 'Setup function targets unknown contract'; +export const TX_ERROR_SETUP_ONLY_SELF_WRONG_SENDER = 'Setup only_self function called with incorrect msg_sender'; +export const TX_ERROR_SETUP_NULL_MSG_SENDER = 'Setup function called with null msg sender'; // Nullifiers export const TX_ERROR_DUPLICATE_NULLIFIER_IN_TX = 'Duplicate nullifier in tx'; diff --git a/yarn-project/validator-client/README.md b/yarn-project/validator-client/README.md index bb232bc28184..c6891987fd6f 100644 --- a/yarn-project/validator-client/README.md +++ b/yarn-project/validator-client/README.md @@ -223,6 +223,44 @@ 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**: `computeBlockLimits()` 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` / `SEQ_MAX_TX_PER_BLOCK` (capped at checkpoint limits). Per-block TX limits follow the same derivation pattern when `SEQ_MAX_TX_PER_CHECKPOINT` is set. + +**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 gas dimensions and for transaction count (when `SEQ_MAX_TX_PER_CHECKPOINT` is set). 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_MAX_TX_PER_BLOCK` | *none* | Per-block tx count. If `SEQ_MAX_TX_PER_CHECKPOINT` is set and per-block is not, derived as `ceil(checkpointLimit / maxBlocks * multiplier)`. | +| `SEQ_MAX_TX_PER_CHECKPOINT` | *none* | Total txs across all blocks in a checkpoint. When set, per-block tx limit is derived from it (unless explicitly overridden) and checkpoint-level capping is enforced. | +| `SEQ_GAS_PER_BLOCK_ALLOCATION_MULTIPLIER` | 2 | Multiplier for per-block budget computation. | + ## Testing Patterns ### Common Mocks diff --git a/yarn-project/validator-client/src/checkpoint_builder.test.ts b/yarn-project/validator-client/src/checkpoint_builder.test.ts index 38945d92aa4e..0d9cf8ae6959 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.test.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.test.ts @@ -1,3 +1,10 @@ +import { NUM_CHECKPOINT_END_MARKER_FIELDS, getNumBlockEndBlobFields } from '@aztec/blob-lib/encoding'; +import { + BLOBS_PER_CHECKPOINT, + DA_GAS_PER_FIELD, + FIELDS_PER_BLOB, + MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT, +} from '@aztec/constants'; import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; @@ -12,6 +19,7 @@ import { type FullNodeBlockBuilderConfig, type MerkleTreeWriteOperations, NoValidTxsError, + type PublicProcessorLimits, type PublicProcessorValidator, } from '@aztec/stdlib/interfaces/server'; import type { CheckpointGlobalVariables, GlobalVariables, ProcessedTx, Tx } from '@aztec/stdlib/tx'; @@ -51,26 +59,34 @@ describe('CheckpointBuilder', () => { public override makeBlockBuilderDeps(_globalVariables: GlobalVariables, _fork: MerkleTreeWriteOperations) { return Promise.resolve({ processor, validator }); } + + /** Expose for testing */ + public testCapLimits(opts: PublicProcessorLimits) { + return this.capLimitsByCheckpointBudgets(opts); + } } - beforeEach(() => { - lightweightCheckpointBuilder = mock({ checkpointNumber, constants }); + /** Creates a mock block with the given mana, tx blob fields, and total block blob fields. */ + function createMockBlock(opts: { manaUsed: number; txBlobFields: number[]; blockBlobFieldCount: number }) { + return { + header: { totalManaUsed: { toNumber: () => opts.manaUsed } }, + body: { + txEffects: opts.txBlobFields.map(n => ({ getNumBlobFields: () => n })), + }, + toBlobFields: () => new Array(opts.blockBlobFieldCount).fill(Fr.ZERO), + computeDAGasUsed: () => opts.txBlobFields.reduce((total, n) => total + n, 0) * DA_GAS_PER_FIELD, + } as unknown as L2Block; + } - fork = mock(); + function setupBuilder(overrideConfig?: Partial) { config = { l1GenesisTime: 0n, slotDuration: 24, l1ChainId: 1, rollupVersion: 1, + rollupManaLimit: 200_000_000, + ...overrideConfig, }; - contractDataSource = mock(); - dateProvider = new TestDateProvider(); - telemetryClient = mock(); - telemetryClient.getMeter.mockReturnValue(mock()); - telemetryClient.getTracer.mockReturnValue(mock()); - - processor = mock(); - validator = mock(); checkpointBuilder = new TestCheckpointBuilder( lightweightCheckpointBuilder as unknown as LightweightCheckpointBuilder, @@ -80,6 +96,23 @@ describe('CheckpointBuilder', () => { dateProvider, telemetryClient, ); + } + + beforeEach(() => { + lightweightCheckpointBuilder = mock({ checkpointNumber, constants }); + lightweightCheckpointBuilder.getBlocks.mockReturnValue([]); + + fork = mock(); + contractDataSource = mock(); + dateProvider = new TestDateProvider(); + telemetryClient = mock(); + telemetryClient.getMeter.mockReturnValue(mock()); + telemetryClient.getTracer.mockReturnValue(mock()); + + processor = mock(); + validator = mock(); + + setupBuilder(); }); describe('buildBlock', () => { @@ -90,11 +123,10 @@ describe('CheckpointBuilder', () => { lightweightCheckpointBuilder.addBlock.mockResolvedValue(expectedBlock); processor.process.mockResolvedValue([ - [{ hash: Fr.random(), gasUsed: { publicGas: Gas.empty() } } as unknown as ProcessedTx], + [{ hash: Fr.random() } as unknown as ProcessedTx], [], // failedTxs [], // usedTxs [], // returnValues - 0, // usedTxBlobFields [], // debugLogs ]); @@ -118,7 +150,6 @@ describe('CheckpointBuilder', () => { [], // failedTxs [], // usedTxs [], // returnValues - 0, // usedTxBlobFields [], // debugLogs ]); @@ -138,7 +169,6 @@ describe('CheckpointBuilder', () => { [failedTx], // failedTxs [], // usedTxs [], // returnValues - 0, // usedTxBlobFields [], // debugLogs ]); @@ -147,4 +177,243 @@ 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); + }); + + it('caps transaction count by remaining checkpoint tx budget', () => { + setupBuilder({ maxTxsPerCheckpoint: 20 }); + + // Prior block with 3 txs (each with 10 blob fields) + lightweightCheckpointBuilder.getBlocks.mockReturnValue([ + createMockBlock({ manaUsed: 0, txBlobFields: [10, 10, 10], blockBlobFieldCount: 40 }), + ]); + + const opts: PublicProcessorLimits = { maxTransactions: 15 }; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + // Remaining txs = 20 - 3 = 17. Per-block = 15. Capped to min(15, 17) = 15. + expect(capped.maxTransactions).toBe(15); + }); + + it('caps transaction count when remaining budget is smaller than per-block limit', () => { + setupBuilder({ maxTxsPerCheckpoint: 10 }); + + // Two prior blocks with 4 txs each = 8 total + lightweightCheckpointBuilder.getBlocks.mockReturnValue([ + createMockBlock({ manaUsed: 0, txBlobFields: [10, 10, 10, 10], blockBlobFieldCount: 50 }), + createMockBlock({ manaUsed: 0, txBlobFields: [10, 10, 10, 10], blockBlobFieldCount: 50 }), + ]); + + const opts: PublicProcessorLimits = { maxTransactions: 5 }; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + // Remaining txs = 10 - 8 = 2. Per-block = 5. Capped to min(5, 2) = 2. + expect(capped.maxTransactions).toBe(2); + }); + + it('sets transaction count from remaining budget when caller does not provide it', () => { + setupBuilder({ maxTxsPerCheckpoint: 15 }); + + // Prior block with 5 txs + lightweightCheckpointBuilder.getBlocks.mockReturnValue([ + createMockBlock({ manaUsed: 0, txBlobFields: [10, 10, 10, 10, 10], blockBlobFieldCount: 60 }), + ]); + + const opts: PublicProcessorLimits = {}; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + // Remaining txs = 15 - 5 = 10 + expect(capped.maxTransactions).toBe(10); + }); + + it('does not cap transaction count when maxTxsPerCheckpoint is not set', () => { + setupBuilder(); // no maxTxsPerCheckpoint + + lightweightCheckpointBuilder.getBlocks.mockReturnValue([]); + + const opts: PublicProcessorLimits = { maxTransactions: 99 }; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + // Passthrough: maxTransactions = 99 + expect(capped.maxTransactions).toBe(99); + }); + + it('does not cap transaction count when maxTxsPerCheckpoint is not set and caller does not provide it', () => { + setupBuilder(); // no maxTxsPerCheckpoint + + lightweightCheckpointBuilder.getBlocks.mockReturnValue([]); + + const opts: PublicProcessorLimits = {}; + const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(opts); + + // Neither config nor caller sets it, so it remains undefined + expect(capped.maxTransactions).toBeUndefined(); + }); + }); }); diff --git a/yarn-project/validator-client/src/checkpoint_builder.ts b/yarn-project/validator-client/src/checkpoint_builder.ts index 9d26252c0a23..a80b3d2697b1 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.ts @@ -1,5 +1,7 @@ +import { NUM_CHECKPOINT_END_MARKER_FIELDS, getNumBlockEndBlobFields } from '@aztec/blob-lib/encoding'; +import { BLOBS_PER_CHECKPOINT, FIELDS_PER_BLOB, MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT } from '@aztec/constants'; import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; -import { merge, pick } from '@aztec/foundation/collection'; +import { merge, pick, sum } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; import { bufferToHex } from '@aztec/foundation/string'; @@ -65,6 +67,7 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { /** * Builds a single block within this checkpoint. + * Automatically caps gas and blob field limits based on checkpoint-level budgets and prior blocks. */ async buildBlock( pendingTxs: Iterable | AsyncIterable, @@ -94,8 +97,14 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { }); const { processor, validator } = await this.makeBlockBuilderDeps(globalVariables, this.fork); - const [publicProcessorDuration, [processedTxs, failedTxs, usedTxs, _, usedTxBlobFields]] = await elapsed(() => - processor.process(pendingTxs, opts, validator), + // Cap gas limits amd available blob fields by remaining checkpoint-level budgets + const cappedOpts: PublicProcessorLimits & { expectedEndState?: StateReference } = { + ...opts, + ...this.capLimitsByCheckpointBudgets(opts), + }; + + const [publicProcessorDuration, [processedTxs, failedTxs, usedTxs]] = await elapsed(() => + processor.process(pendingTxs, cappedOpts, validator), ); // Throw if we didn't collect a single valid tx and we're not allowed to build empty blocks @@ -109,9 +118,6 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { expectedEndState: opts.expectedEndState, }); - // How much public gas was processed - const publicGas = processedTxs.reduce((acc, tx) => acc.add(tx.gasUsed.publicGas), Gas.empty()); - this.log.debug('Built block within checkpoint', { header: block.header.toInspect(), processedTxs: processedTxs.map(tx => tx.hash.toString()), @@ -120,12 +126,10 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { return { block, - publicGas, publicProcessorDuration, numTxs: processedTxs.length, failedTxs, usedTxs, - usedTxBlobFields, }; } @@ -147,8 +151,66 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { return this.checkpointBuilder.clone().completeCheckpoint(); } + /** + * Caps per-block gas and blob field limits by remaining checkpoint-level budgets. + * Computes remaining L2 gas (mana), DA gas, and blob fields from blocks already added to the checkpoint, + * then returns opts with maxBlockGas and maxBlobFields capped accordingly. + */ + protected capLimitsByCheckpointBudgets( + opts: PublicProcessorLimits, + ): Pick { + const existingBlocks = this.checkpointBuilder.getBlocks(); + + // Remaining L2 gas (mana) + // IMPORTANT: This assumes mana is computed solely based on L2 gas used in transactions. + // This may change in the future. + const usedMana = sum(existingBlocks.map(b => b.header.totalManaUsed.toNumber())); + const remainingMana = this.config.rollupManaLimit - usedMana; + + // Remaining DA gas + const usedDAGas = sum(existingBlocks.map(b => b.computeDAGasUsed())) ?? 0; + const remainingDAGas = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT - usedDAGas; + + // Remaining blob fields (block blob fields include both tx data and block-end overhead) + const usedBlobFields = sum(existingBlocks.map(b => b.toBlobFields().length)); + const totalBlobCapacity = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB - NUM_CHECKPOINT_END_MARKER_FIELDS; + const isFirstBlock = existingBlocks.length === 0; + const blockEndOverhead = getNumBlockEndBlobFields(isFirstBlock); + const maxBlobFieldsForTxs = totalBlobCapacity - usedBlobFields - blockEndOverhead; + + // Cap L2 gas by remaining checkpoint mana + const cappedL2Gas = Math.min(opts.maxBlockGas?.l2Gas ?? remainingMana, remainingMana); + + // Cap DA gas by remaining checkpoint DA gas budget + const cappedDAGas = Math.min(opts.maxBlockGas?.daGas ?? remainingDAGas, remainingDAGas); + + // Cap blob fields by remaining checkpoint blob capacity + const cappedBlobFields = + opts.maxBlobFields !== undefined ? Math.min(opts.maxBlobFields, maxBlobFieldsForTxs) : maxBlobFieldsForTxs; + + // Cap transaction count by remaining checkpoint tx budget + let cappedMaxTransactions: number | undefined; + if (this.config.maxTxsPerCheckpoint !== undefined) { + const usedTxs = sum(existingBlocks.map(b => b.body.txEffects.length)); + const remainingTxs = Math.max(0, this.config.maxTxsPerCheckpoint - usedTxs); + cappedMaxTransactions = + opts.maxTransactions !== undefined ? Math.min(opts.maxTransactions, remainingTxs) : remainingTxs; + } else { + cappedMaxTransactions = opts.maxTransactions; + } + + return { + maxBlockGas: new Gas(cappedDAGas, cappedL2Gas), + maxBlobFields: cappedBlobFields, + maxTransactions: cappedMaxTransactions, + }; + } + protected async makeBlockBuilderDeps(globalVariables: GlobalVariables, fork: MerkleTreeWriteOperations) { - const txPublicSetupAllowList = this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); + const txPublicSetupAllowList = [ + ...(await getDefaultAllowedSetupFunctions()), + ...(this.config.txPublicSetupAllowListExtend ?? []), + ]; const contractsDB = new PublicContractsDB(this.contractDataSource, this.log.getBindings()); const guardedFork = new GuardedMerkleTreeOperations(fork); 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..80c7bd532974 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(); @@ -192,6 +193,7 @@ describe('ValidatorClient HA Integration', () => { const metrics = new ValidatorMetrics(getTelemetryClient()); const blockProposalValidator = new BlockProposalValidator(epochCache, { txsPermitted: true, + maxTxsPerBlock: undefined, }); const blockProposalHandler = new BlockProposalHandler( checkpointsBuilder, diff --git a/yarn-project/validator-client/src/validator.integration.test.ts b/yarn-project/validator-client/src/validator.integration.test.ts index dd30f91bd4be..10a89a85bc92 100644 --- a/yarn-project/validator-client/src/validator.integration.test.ts +++ b/yarn-project/validator-client/src/validator.integration.test.ts @@ -23,7 +23,7 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { CommitteeAttestation, L2Block } from '@aztec/stdlib/block'; import { L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import { type L1RollupConstants, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; -import { GasFees } from '@aztec/stdlib/gas'; +import { Gas, GasFees } from '@aztec/stdlib/gas'; import { tryStop } from '@aztec/stdlib/interfaces/server'; import { computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import { type BlockProposal, CheckpointProposal } from '@aztec/stdlib/p2p'; @@ -127,7 +127,8 @@ describe('ValidatorClient Integration', () => { slotDuration: l1Constants.slotDuration, l1ChainId: chainId.toNumber(), rollupVersion: version.toNumber(), - txPublicSetupAllowList: [], + txPublicSetupAllowListExtend: [], + rollupManaLimit: 200_000_000, }, synchronizer, archiver, @@ -242,6 +243,8 @@ describe('ValidatorClient Integration', () => { vkTreeRoot: getVKTreeRoot(), protocolContractsHash, anchorBlockHeader: anchorBlockHeader ?? genesisBlockHeader, + gasLimits: new Gas(100_000, 1_000_000), + gasUsed: new Gas(10_000, 100_000), maxFeesPerGas: new GasFees(1e12, 1e12), feePayer, }); @@ -564,6 +567,35 @@ describe('ValidatorClient Integration', () => { expect(isValid).toBe(false); }); + it('rejects block that would exceed checkpoint mana limit', async () => { + const { blocks } = await buildCheckpoint( + CheckpointNumber(1), + slotNumber, + emptyL1ToL2Messages, + emptyPreviousCheckpointOutHashes, + BlockNumber(1), + 3, + () => buildTxs(2), + ); + + // Measure total mana used by the first two blocks + const manaFirstTwo = + blocks[0].block.header.totalManaUsed.toNumber() + blocks[1].block.header.totalManaUsed.toNumber(); + + // Set rollupManaLimit to only cover the first two blocks' actual mana. + // Block 3 re-execution will have 0 remaining mana, so the actual gas check + // in the public processor will reject all txs, producing a tx count mismatch. + attestor.checkpointsBuilder.updateConfig({ rollupManaLimit: manaFirstTwo }); + + // Blocks 1 and 2 should validate successfully + await attestorValidateBlocks(blocks.slice(0, 2)); + + // Block 3 should fail: remaining checkpoint mana is 0, so the processor + // stops after the first tx's actual gas exceeds the limit. + const isValid = await attestor.validator.validateBlockProposal(blocks[2].proposal, mockPeerId); + expect(isValid).toBe(false); + }); + it('refuses block proposal with mismatching l1 to l2 messages', async () => { const l1ToL2Messages = makeInboxMessages(4, { messagesPerCheckpoint: 4 }); await proposer.archiver.dataStore.addL1ToL2Messages(l1ToL2Messages); diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index 7d9c4b975288..1477a94b01b2 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -25,7 +25,6 @@ import { OffenseType, WANT_TO_SLASH_EVENT } from '@aztec/slasher'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block'; import type { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; -import { Gas } from '@aztec/stdlib/gas'; import type { SlasherConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import type { BlockProposal } from '@aztec/stdlib/p2p'; @@ -110,6 +109,7 @@ describe('ValidatorClient', () => { slotDuration: 24, l1ChainId: 1, rollupVersion: 1, + rollupManaLimit: 200_000_000, }); worldState = mock(); epochCache = mock(); @@ -366,9 +366,7 @@ describe('ValidatorClient', () => { publicProcessorDuration: 0, numTxs: proposal.txHashes.length, failedTxs: [], - publicGas: Gas.empty(), usedTxs: [], - usedTxBlobFields: 0, block: { header: clonedBlockHeader, body: { txEffects: times(proposal.txHashes.length, () => TxEffect.empty()) }, diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index 892c43942e6e..21b4d8d6f80e 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -24,6 +24,7 @@ import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block'; +import { validateCheckpoint } from '@aztec/stdlib/checkpoint'; import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import type { CreateCheckpointProposalLastBlockData, @@ -766,6 +767,18 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) return { isValid: false, reason: 'out_hash_mismatch' }; } + // Final round of validations on the checkpoint, just in case. + try { + validateCheckpoint(computedCheckpoint, { + rollupManaLimit: this.checkpointsBuilder.getConfig().rollupManaLimit, + maxDABlockGas: undefined, + maxL2BlockGas: undefined, + }); + } catch (err) { + this.log.warn(`Checkpoint validation failed: ${err}`, proposalInfo); + return { isValid: false, reason: 'checkpoint_validation_failed' }; + } + this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo); return { isValid: true }; } finally { diff --git a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts index 38a4e9ce81e5..ad85df243053 100644 --- a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts +++ b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts @@ -1,4 +1,4 @@ -import { GENESIS_BLOCK_HEADER_HASH, INITIAL_L2_BLOCK_NUM, INITIAL_L2_CHECKPOINT_NUM } from '@aztec/constants'; +import { GENESIS_BLOCK_HEADER_HASH, INITIAL_CHECKPOINT_NUMBER, INITIAL_L2_BLOCK_NUM } from '@aztec/constants'; import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import type { Fr } from '@aztec/foundation/curves/bn254'; import { type Logger, createLogger } from '@aztec/foundation/log'; @@ -263,15 +263,15 @@ export class ServerWorldStateSynchronizer proposed: latestBlockId, checkpointed: { block: { number: INITIAL_L2_BLOCK_NUM, hash: GENESIS_BLOCK_HEADER_HASH.toString() }, - checkpoint: { number: INITIAL_L2_CHECKPOINT_NUM, hash: genesisCheckpointHeaderHash }, + checkpoint: { number: INITIAL_CHECKPOINT_NUMBER, hash: genesisCheckpointHeaderHash }, }, finalized: { block: { number: status.finalizedBlockNumber, hash: finalizedBlockHash ?? '' }, - checkpoint: { number: INITIAL_L2_CHECKPOINT_NUM, hash: genesisCheckpointHeaderHash }, + checkpoint: { number: INITIAL_CHECKPOINT_NUMBER, hash: genesisCheckpointHeaderHash }, }, proven: { block: { number: provenBlockNumber, hash: provenBlockHash ?? '' }, - checkpoint: { number: INITIAL_L2_CHECKPOINT_NUM, hash: genesisCheckpointHeaderHash }, + checkpoint: { number: INITIAL_CHECKPOINT_NUMBER, hash: genesisCheckpointHeaderHash }, }, }; }