diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts index 159a0faf162f..c7c67d4079cd 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts @@ -32,7 +32,11 @@ import { getTelemetryClient } from '@aztec/telemetry-client'; import { type MockProxy, mock } from 'jest-mock-extended'; import { AggregateTxValidator } from '../../msg_validators/tx_validator/aggregate_tx_validator.js'; -import { GasLimitsValidator, MaxFeePerGasValidator } from '../../msg_validators/tx_validator/gas_validator.js'; +import { + MaxGasLimitsValidator, + MinGasLimitsValidator, +} from '../../msg_validators/tx_validator/gas_limits_validator.js'; +import { MaxFeePerGasValidator } from '../../msg_validators/tx_validator/gas_validator.js'; import { AllowedSetupCallsMetaValidator } from '../../msg_validators/tx_validator/phases_validator.js'; import type { TxMetaData } from './tx_metadata.js'; import { AztecKVTxPoolV2 } from './tx_pool_v2.js'; @@ -729,7 +733,13 @@ describe('TxPoolV2', () => { gasPool = new AztecKVTxPoolV2(gasStore, gasArchiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - createTxValidator: () => Promise.resolve(new GasLimitsValidator()), + createTxValidator: () => + Promise.resolve( + new AggregateTxValidator( + new MinGasLimitsValidator(), + new MaxGasLimitsValidator(), + ), + ), checkAllowedSetupCalls: () => Promise.resolve(true), blockMinFeesProvider: { getCurrentMinFees: () => Promise.resolve(GasFees.empty()) }, }); diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/README.md b/yarn-project/p2p/src/msg_validators/tx_validator/README.md index 80365f8e5b7d..afb465130f19 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/README.md +++ b/yarn-project/p2p/src/msg_validators/tx_validator/README.md @@ -19,7 +19,7 @@ Unsolicited transactions from any peer. Fully validated in two stages with a poo | Step | What runs | On failure | |------|-----------|------------| -| **Stage 1** (fast) | TxPermitted, Data, Metadata, Timestamp, DoubleSpend, Gas, Phases, BlockHeader | Penalize peer, reject tx | +| **Stage 1** (fast) | TxPermitted, Data, Metadata, Timestamp, DoubleSpend, MinGasLimits, MaxGasLimits, Gas, Phases, BlockHeader | Penalize peer, reject tx | | **Pool pre-check** | `canAddPendingTx` — checks for duplicates, pool capacity | Ignore tx (no penalty) | | **Stage 2** (slow) | Proof verification | Penalize peer, reject tx | | **Pool add** | `addPendingTxs` | Accept, ignore, or reject | @@ -34,6 +34,8 @@ Each stage-1 and stage-2 validator is paired with a `PeerErrorSeverity`. If a va Unsolicited transactions from a local wallet/PXE. Runs the full set of checks as a single aggregate validator: - TxPermitted, Size, Data, Metadata, Timestamp, DoubleSpend, Phases, BlockHeader +- MinGasLimits +- MaxGasLimits (skipped for simulations — gas estimation submits limits above the per-tx maximum) - Gas (optional — skipped when `skipFeeEnforcement` is set) - Proof verification (optional — skipped for simulations when no verifier is provided) @@ -56,7 +58,7 @@ State-dependent checks are deferred to either the block building validator (for Transactions already in the pool, about to be sequenced into a block. Re-validates against the current state of the block being built. **This is where invalid txs that entered via req/resp or block proposals are caught** — their invalidity is reported as part of block validation/attestation. Runs: -- Timestamp, DoubleSpend, Phases, Gas, BlockHeader +- Timestamp, DoubleSpend, Phases, MinGasLimits, MaxGasLimits, Gas, BlockHeader Does **not** run: - Proof, Data — already verified on entry (by gossip, RPC, or req/resp validators) @@ -75,7 +77,7 @@ This validator is invoked on **every** transaction potentially entering the pend - Startup hydration — revalidating persisted non-mined txs on node restart Runs: -- DoubleSpend, BlockHeader, GasLimits, MaxFeePerGas, Timestamp, AllowedSetupCalls +- DoubleSpend, BlockHeader, MinGasLimits, MaxGasLimits, MaxFeePerGas, Timestamp, AllowedSetupCalls Operates on `TxMetaData` (pre-built by the pool) rather than full `Tx` objects. @@ -91,8 +93,9 @@ The `AllowedSetupCallsMetaValidator` checks a precomputed boolean flag (`TxMetaD | `MetadataTxValidator` | Chain ID, rollup version, protocol contracts hash, VK tree root | 4.18 us | | `TimestampTxValidator` | Transaction has not expired (expiration timestamp vs next slot) | 1.56 us | | `DoubleSpendTxValidator` | Nullifiers do not already exist in the nullifier tree | 106.08 us | -| `GasTxValidator` | Gas limits are within bounds (delegates to `GasLimitsValidator`), max fee per gas meets current block fees (delegates to `MaxFeePerGasValidator`), and fee payer has sufficient FeeJuice balance | 1.02 ms | -| `GasLimitsValidator` | Gas limits are >= fixed minimums and <= AVM max processable L2 gas. Used standalone in pool migration; also called internally by `GasTxValidator` | 3–10 us | +| `GasTxValidator` | Max fee per gas meets current block fees (delegates to `MaxFeePerGasValidator`), and fee payer has sufficient FeeJuice balance | 1.02 ms | +| `MinGasLimitsValidator` | Gas limits are >= the fixed protocol overheads. Applies on every entry point, with no exemptions | 3–10 us | +| `MaxGasLimitsValidator` | Gas limits are <= AVM max processable L2 gas (optionally clamped further by network admission limits). Exempted on the gas estimation path | 3–10 us | | `MaxFeePerGasValidator` | Max fee per gas >= current block gas fees on both dimensions (DA and L2). Used standalone in pool migration; also called internally by `GasTxValidator` | 3–10 us | | `PhasesTxValidator` | Public function calls in setup phase are on the allow list | 10.12–13.12 us | | `AllowedSetupCallsMetaValidator` | Checks the precomputed `allowedSetupCalls` flag on `TxMetaData`. Used in pool migration instead of the full `PhasesTxValidator` | — | @@ -109,18 +112,20 @@ The `AllowedSetupCallsMetaValidator` checks a precomputed boolean flag (`TxMetaD | Metadata | Stage 1 | Yes | Yes | — | — | | Timestamp | Stage 1 | Yes | — | Yes | Yes | | DoubleSpend | Stage 1 | Yes | — | Yes | Yes | -| Gas (balance + limits) | Stage 1 | Optional* | — | Yes | — | -| GasLimits (standalone) | — | — | — | — | Yes | +| Gas (fee balance) | Stage 1 | Optional* | — | Yes | — | +| MinGasLimits | Stage 1 | Yes | — | Yes | Yes | +| MaxGasLimits | Stage 1 | Yes*** | — | Yes | Yes | | MaxFeePerGas (standalone) | — | — | — | — | Yes | | Phases | Stage 1 | Yes | — | Yes | — | | AllowedSetupCalls | — | — | — | — | Yes | | BlockHeader | Stage 1 | Yes | — | Yes | Yes | | Proof | Stage 2 | Optional** | Yes | — | — | -\* Gas balance check is skipped when `skipFeeEnforcement` is set (testing/dev). `GasTxValidator` internally delegates to `GasLimitsValidator` and `MaxFeePerGasValidator` as its first steps, so gas limits and fee-per-gas are checked wherever `GasTxValidator` runs. Pool migration uses `GasLimitsValidator` and `MaxFeePerGasValidator` standalone because it doesn't need the balance check. +\* Gas balance check is skipped when `skipFeeEnforcement` is set (testing/dev). `GasTxValidator` internally delegates to `MaxFeePerGasValidator` as its first step, so fee-per-gas is checked wherever `GasTxValidator` runs. Pool migration uses `MaxFeePerGasValidator` standalone because it doesn't need the balance check. Declared gas-limit validation is owned solely by `MinGasLimitsValidator` and `MaxGasLimitsValidator`. \** Proof verification is skipped for simulations (no verifier provided). +\*** Only the ceiling is skipped for simulations: gas estimation submits limits above the per-tx maximum, and the wallet clamps the real tx to the admission limit afterward. The floor still applies, since a tx below it can never be mined. -The gas-limit bounds `GasLimitsValidator` enforces here — the per-tx protocol maxima and the network admission limits — are documented in [`stdlib/src/gas/README.md`](../../../../stdlib/src/gas/README.md) under "Gas and Data Limits". +The gas-limit bounds `MaxGasLimitsValidator` enforces here — the per-tx protocol maxima and the network admission limits — are documented in [`stdlib/src/gas/README.md`](../../../../stdlib/src/gas/README.md) under "Gas and Data Limits". ## Fee-Per-Gas Rejection Strategy diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/factory.test.ts b/yarn-project/p2p/src/msg_validators/tx_validator/factory.test.ts index 3828826a0443..6d3a3c83483c 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/factory.test.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/factory.test.ts @@ -1,14 +1,16 @@ +import { MAX_PROCESSABLE_L2_GAS, MAX_TX_DA_GAS } from '@aztec/constants'; import { BlockNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { ContractDataSource } from '@aztec/stdlib/contract'; -import { GasFees } from '@aztec/stdlib/gas'; +import { Gas, GasFees, GasSettings } from '@aztec/stdlib/gas'; import type { ClientProtocolCircuitVerifier, MerkleTreeReadOperations, WorldStateSynchronizer, } from '@aztec/stdlib/interfaces/server'; import { PeerErrorSeverity } from '@aztec/stdlib/p2p'; -import type { GlobalVariables } from '@aztec/stdlib/tx'; +import { mockTx } from '@aztec/stdlib/testing'; +import { type GlobalVariables, TX_ERROR_GAS_LIMIT_TOO_HIGH, TX_ERROR_INSUFFICIENT_GAS_LIMIT } from '@aztec/stdlib/tx'; import { type MockProxy, mock } from 'jest-mock-extended'; @@ -26,7 +28,8 @@ import { createTxValidatorForOnDemandReceivedTxs, createTxValidatorForTransactionsEnteringPendingTxPool, } from './factory.js'; -import { GasLimitsValidator, GasTxValidator, MaxFeePerGasValidator } from './gas_validator.js'; +import { MaxGasLimitsValidator, MinGasLimitsValidator } from './gas_limits_validator.js'; +import { GasTxValidator, MaxFeePerGasValidator } from './gas_validator.js'; import { MetadataTxValidator } from './metadata_validator.js'; import { AllowedSetupCallsMetaValidator, PhasesTxValidator } from './phases_validator.js'; import { SizeTxValidator } from './size_validator.js'; @@ -39,6 +42,17 @@ function getValidatorNames(aggregate: AggregateTxValidator): string[] { return aggregate.validators.map(v => v.constructor.name); } +/** A tx with no public calls, carrying the given gas settings. */ +async function mockPrivateTxWithGasSettings(gasSettings: GasSettings) { + const tx = await mockTx(1, { + numberOfNonRevertiblePublicCallRequests: 0, + numberOfRevertiblePublicCallRequests: 0, + hasPublicTeardownCallRequest: false, + }); + tx.data.constants.txContext.gasSettings = gasSettings; + return tx; +} + describe('Validator factory functions', () => { let synchronizer: MockProxy; let contractSource: MockProxy; @@ -72,12 +86,69 @@ describe('Validator factory functions', () => { 'phasesValidator', 'blockHeaderValidator', 'doubleSpendValidator', + 'minGasLimitsValidator', + 'maxGasLimitsValidator', 'gasValidator', 'dataValidator', 'contractInstanceValidator', ]); }); + it('forwards the network admission limits to the gas limits validator', async () => { + const maxTxL2Gas = Math.floor(MAX_PROCESSABLE_L2_GAS / 2); + const validators = createFirstStageTxValidationsForGossipedTransactions( + 0n, + BlockNumber(2), + synchronizer, + new GasFees(1, 1), + 1, + 2, + Fr.ZERO, + contractSource, + true, + [], + undefined, + { maxTxL2Gas }, + ); + + // Over the network admission limit but under the protocol ceiling, so only forwarded opts can reject it. + const tx = await mockPrivateTxWithGasSettings( + GasSettings.fallback({ gasLimits: new Gas(MAX_TX_DA_GAS, maxTxL2Gas + 1), maxFeesPerGas: new GasFees(1, 1) }), + ); + const result = await validators.maxGasLimitsValidator.validator.validateTx(tx); + expect(result.result).toBe('invalid'); + expect((result as { reason: string[] }).reason[0]).toContain(TX_ERROR_GAS_LIMIT_TOO_HIGH); + }); + + it('forwards the network DA admission limit to the gas limits validator', async () => { + const maxTxDAGas = Math.floor(MAX_TX_DA_GAS / 2); + const validators = createFirstStageTxValidationsForGossipedTransactions( + 0n, + BlockNumber(2), + synchronizer, + new GasFees(1, 1), + 1, + 2, + Fr.ZERO, + contractSource, + true, + [], + undefined, + { maxTxDAGas }, + ); + + // Over the network DA admission limit but under the protocol DA ceiling. + const tx = await mockPrivateTxWithGasSettings( + GasSettings.fallback({ + gasLimits: new Gas(maxTxDAGas + 1, MAX_PROCESSABLE_L2_GAS), + maxFeesPerGas: new GasFees(1, 1), + }), + ); + const result = await validators.maxGasLimitsValidator.validator.validateTx(tx); + expect(result.result).toBe('invalid'); + expect((result as { reason: string[] }).reason[0]).toContain(TX_ERROR_GAS_LIMIT_TOO_HIGH); + }); + it('does not include a proof validator', () => { const validators = createFirstStageTxValidationsForGossipedTransactions( 0n, @@ -116,6 +187,8 @@ describe('Validator factory functions', () => { expect(validators.dataValidator.severity).toBe(PeerErrorSeverity.MidToleranceError); expect(validators.metadataValidator.severity).toBe(PeerErrorSeverity.MidToleranceError); expect(validators.doubleSpendValidator.severity).toBe(PeerErrorSeverity.MidToleranceError); + expect(validators.minGasLimitsValidator.severity).toBe(PeerErrorSeverity.MidToleranceError); + expect(validators.maxGasLimitsValidator.severity).toBe(PeerErrorSeverity.MidToleranceError); expect(validators.gasValidator.severity).toBe(PeerErrorSeverity.MidToleranceError); expect(validators.phasesValidator.severity).toBe(PeerErrorSeverity.MidToleranceError); }); @@ -225,7 +298,8 @@ describe('Validator factory functions', () => { DoubleSpendTxValidator.name, DataTxValidator.name, ContractInstanceTxValidator.name, - GasLimitsValidator.name, + MinGasLimitsValidator.name, + MaxGasLimitsValidator.name, GasTxValidator.name, TxProofValidator.name, ]); @@ -245,15 +319,16 @@ describe('Validator factory functions', () => { const aggregate = validator as AggregateTxValidator; const names = getValidatorNames(aggregate); - // Declared gas-limit admission is not fee enforcement, so it stays even with fees skipped. - expect(names).toContain(GasLimitsValidator.name); + // Gas-limit validation is not fee enforcement, so it stays even with fees skipped. + expect(names).toContain(MinGasLimitsValidator.name); + expect(names).toContain(MaxGasLimitsValidator.name); expect(names).not.toContain(GasTxValidator.name); expect(names).toContain(TxProofValidator.name); }); - it('excludes the gas-limits admission validator during simulation', () => { - // Gas estimation submits intentionally-inflated forEstimation limits, so the admission limit must not - // reject the estimation tx; the wallet clamps the real tx afterward. + it('excludes only the gas-limits ceiling during simulation', () => { + // Gas estimation submits intentionally-inflated forEstimation limits, so the ceiling must not reject the + // estimation tx; the wallet clamps the real tx afterward. The floor has no such exemption. const validator = createTxValidatorForAcceptingTxsOverRPC(db, contractSource, undefined, { l1ChainId: 1, rollupVersion: 2, @@ -267,7 +342,77 @@ describe('Validator factory functions', () => { }); const aggregate = validator as AggregateTxValidator; - expect(getValidatorNames(aggregate)).not.toContain(GasLimitsValidator.name); + const names = getValidatorNames(aggregate); + expect(names).not.toContain(MaxGasLimitsValidator.name); + expect(names).toContain(MinGasLimitsValidator.name); + }); + + describe('gas-limit validation', () => { + // Estimation limits exceed the per-tx protocol maximum by construction, so whether the tx is rejected is + // decided solely by isSimulation; skipFeeEnforcement must not affect it. The aggregate collects reasons + // from every validator, so asserting on the specific error is robust to other validators failing on the + // mocked db. + it.each` + isSimulation | skipFeeEnforcement | rejected + ${false} | ${false} | ${true} + ${false} | ${true} | ${true} + ${true} | ${false} | ${false} + ${true} | ${true} | ${false} + `( + 'isSimulation=$isSimulation, skipFeeEnforcement=$skipFeeEnforcement: over-limit tx rejected=$rejected', + async ({ isSimulation, skipFeeEnforcement, rejected }) => { + db.findLeafIndices.mockResolvedValue([]); + const validator = createTxValidatorForAcceptingTxsOverRPC(db, contractSource, undefined, { + l1ChainId: 1, + rollupVersion: 2, + setupAllowList: [], + gasFees: new GasFees(1, 1), + skipFeeEnforcement, + isSimulation, + timestamp: 100n, + blockNumber: BlockNumber(5), + txsPermitted: true, + }); + const tx = await mockPrivateTxWithGasSettings( + GasSettings.forEstimation({ maxFeesPerGas: new GasFees(1, 1) }), + ); + const result = await validator.validateTx(tx); + const reasons = result.result === 'invalid' ? result.reason : []; + expect(reasons.some(r => r.includes(TX_ERROR_GAS_LIMIT_TOO_HIGH))).toBe(rejected); + }, + ); + + // Estimation only ever needs the ceiling exempted. The minimum is a protocol floor that the real tx can + // never satisfy, so a simulation that passes it would only fail again on sendTx. + it.each` + isSimulation | skipFeeEnforcement + ${false} | ${false} + ${false} | ${true} + ${true} | ${false} + ${true} | ${true} + `( + 'isSimulation=$isSimulation, skipFeeEnforcement=$skipFeeEnforcement: under-minimum tx is rejected', + async ({ isSimulation, skipFeeEnforcement }) => { + db.findLeafIndices.mockResolvedValue([]); + const validator = createTxValidatorForAcceptingTxsOverRPC(db, contractSource, undefined, { + l1ChainId: 1, + rollupVersion: 2, + setupAllowList: [], + gasFees: new GasFees(1, 1), + skipFeeEnforcement, + isSimulation, + timestamp: 100n, + blockNumber: BlockNumber(5), + txsPermitted: true, + }); + const tx = await mockPrivateTxWithGasSettings( + GasSettings.fallback({ gasLimits: Gas.empty(), maxFeesPerGas: new GasFees(1, 1) }), + ); + const result = await validator.validateTx(tx); + const reasons = result.result === 'invalid' ? result.reason : []; + expect(reasons.some(r => r.includes(TX_ERROR_INSUFFICIENT_GAS_LIMIT))).toBe(true); + }, + ); }); it('excludes proof validator when no verifier is provided', () => { @@ -309,10 +454,25 @@ describe('Validator factory functions', () => { PhasesTxValidator.name, BlockHeaderTxValidator.name, DoubleSpendTxValidator.name, + MinGasLimitsValidator.name, + MaxGasLimitsValidator.name, GasTxValidator.name, ]); }); + it('rejects declared gas limits above the protocol ceiling', async () => { + // Block proposal txs get only well-formedness checks on receipt; this is where an over-declared limit must + // be caught, or execution would trip the simulator's MAX_PROCESSABLE_L2_GAS assertion. + db.findLeafIndices.mockResolvedValue([]); + const result = createTxValidatorForBlockBuilding(db, contractSource, globalVariables, []); + + const tx = await mockPrivateTxWithGasSettings(GasSettings.forEstimation({ maxFeesPerGas: new GasFees(1, 1) })); + const validationResult = await result.preprocessValidator!.validateTx(tx); + expect(validationResult.result).toBe('invalid'); + const reasons = (validationResult as { reason: string[] }).reason; + expect(reasons.some(r => r.includes(TX_ERROR_GAS_LIMIT_TOO_HIGH))).toBe(true); + }); + it('returns a nullifierCache alongside the preprocessValidator', () => { const result = createTxValidatorForBlockBuilding(db, contractSource, globalVariables, []); @@ -333,7 +493,8 @@ describe('Validator factory functions', () => { const aggregate = validator as AggregateTxValidator; expect(getValidatorNames(aggregate)).toEqual([ - GasLimitsValidator.name, + MinGasLimitsValidator.name, + MaxGasLimitsValidator.name, MaxFeePerGasValidator.name, TimestampTxValidator.name, DoubleSpendTxValidator.name, diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/factory.ts b/yarn-project/p2p/src/msg_validators/tx_validator/factory.ts index c434da61a76c..bb72090941d3 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/factory.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/factory.ts @@ -57,7 +57,8 @@ import { CachedTxValidator } from './cached_tx_validator.js'; import { ContractInstanceTxValidator } from './contract_instance_validator.js'; import { DataTxValidator } from './data_validator.js'; import { DoubleSpendTxValidator, type NullifierSource } from './double_spend_validator.js'; -import { GasLimitsValidator, GasTxValidator, MaxFeePerGasValidator } from './gas_validator.js'; +import { MaxGasLimitsValidator, MinGasLimitsValidator } from './gas_limits_validator.js'; +import { GasTxValidator, MaxFeePerGasValidator } from './gas_validator.js'; import { MetadataTxValidator } from './metadata_validator.js'; import { NullifierCache } from './nullifier_cache.js'; import { AllowedSetupCallsMetaValidator, PhasesTxValidator } from './phases_validator.js'; @@ -86,7 +87,8 @@ export interface TransactionValidator { * without consulting the pool or running proof verification. * * The `doubleSpendValidator` failure is special-cased by the caller (`handleGossipedTx`) - * to determine severity based on how recently the nullifier appeared. + * to determine severity based on how recently the nullifier appeared. The caller reports the + * first failing entry among equally severe ones. */ export function createFirstStageTxValidationsForGossipedTransactions( timestamp: UInt64, @@ -156,13 +158,20 @@ export function createFirstStageTxValidationsForGossipedTransactions( ), severity: PeerErrorSeverity.MidToleranceError, // This is handled specifically at the point of rejection by considering a recent window where it may have been valid }, + minGasLimitsValidator: { + validator: new MinGasLimitsValidator(bindings), + severity: PeerErrorSeverity.MidToleranceError, + }, + maxGasLimitsValidator: { + validator: new MaxGasLimitsValidator({ ...gasLimitOpts, bindings }), + severity: PeerErrorSeverity.MidToleranceError, + }, gasValidator: { validator: new GasTxValidator( new DatabasePublicStateSource(merkleTree), ProtocolContractAddress.FeeJuice, gasFees, bindings, - gasLimitOpts, ), severity: PeerErrorSeverity.MidToleranceError, }, @@ -337,16 +346,17 @@ export function createTxValidatorForAcceptingTxsOverRPC( new DoubleSpendTxValidator(new NullifierCache(db), bindings), new DataTxValidator(bindings), new ContractInstanceTxValidator(bindings), + // Declared gas-limit admission is not fee enforcement, so it runs even when fees are skipped. The floor + // has no exemption: a tx declaring less than the fixed overheads can never be mined, so rejecting it + // during simulation is the earliest useful feedback rather than a surprise on sendTx. + new MinGasLimitsValidator(bindings), ]; - // Declared gas-limit admission is not fee enforcement, so it runs even when fees are skipped, but it is - // skipped during simulation: gas estimation submits intentionally-inflated `forEstimation` limits (above - // the per-tx max) and the wallet clamps the real tx to the admission limit afterward, so enforcing the - // limit on the estimation tx would reject a valid estimation. The fee-balance check below stays behind - // `skipFeeEnforcement`, and GasTxValidator is constructed without the limit opts so it does not re-run - // this same check. + // Only the ceiling is exempted during simulation: gas estimation submits intentionally-inflated + // `forEstimation` limits (above the per-tx max) and the wallet clamps the real tx to the admission limit + // afterward, so enforcing the ceiling on the estimation tx would reject a valid estimation. if (!isSimulation) { - validators.push(new GasLimitsValidator({ maxTxL2Gas, maxTxDAGas, bindings })); + validators.push(new MaxGasLimitsValidator({ maxTxL2Gas, maxTxDAGas, bindings })); } if (!skipFeeEnforcement) { @@ -416,6 +426,10 @@ function createTxValidatorForValidatingAgainstCurrentState( new PhasesTxValidator(contractDataSource, setupAllowList, globalVariables.timestamp, bindings), new BlockHeaderTxValidator(archiveSource, bindings), new DoubleSpendTxValidator(nullifierSource, bindings), + new MinGasLimitsValidator(bindings), + // No limit opts: enforce only the per-tx protocol ceiling. Network admission limits are relay policy and + // must not invalidate a proposed block. + new MaxGasLimitsValidator({ bindings }), new GasTxValidator(publicStateSource, ProtocolContractAddress.FeeJuice, globalVariables.gasFees, bindings), ); } @@ -455,7 +469,8 @@ export async function createTxValidatorForTransactionsEnteringPendingTxPool( }, }; return new AggregateTxValidator( - new GasLimitsValidator({ ...gasLimitOpts, bindings }), + new MinGasLimitsValidator(bindings), + new MaxGasLimitsValidator({ ...gasLimitOpts, bindings }), new MaxFeePerGasValidator(gasFees, bindings), new TimestampTxValidator({ timestamp, blockNumber }, bindings), new DoubleSpendTxValidator(nullifierSource, bindings), diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/gas_limits_validator.test.ts b/yarn-project/p2p/src/msg_validators/tx_validator/gas_limits_validator.test.ts new file mode 100644 index 000000000000..317104d6a393 --- /dev/null +++ b/yarn-project/p2p/src/msg_validators/tx_validator/gas_limits_validator.test.ts @@ -0,0 +1,175 @@ +import { + MAX_PROCESSABLE_L2_GAS, + MAX_TX_DA_GAS, + PRIVATE_TX_L2_GAS_OVERHEAD, + PUBLIC_TX_L2_GAS_OVERHEAD, + TX_DA_GAS_OVERHEAD, +} from '@aztec/constants'; +import { Gas, GasFees, GasSettings } from '@aztec/stdlib/gas'; +import { mockTx } from '@aztec/stdlib/testing'; +import { TX_ERROR_GAS_LIMIT_TOO_HIGH, TX_ERROR_INSUFFICIENT_GAS_LIMIT, type Tx } from '@aztec/stdlib/tx'; + +import assert from 'assert'; + +import { MaxGasLimitsValidator, MinGasLimitsValidator } from './gas_limits_validator.js'; + +/** Sets the gas limits under test. These validators read nothing else, so the fees are arbitrary. */ +const setGasLimits = (tx: Tx, gasLimits: Gas) => { + tx.data.constants.txContext.gasSettings = GasSettings.fallback({ gasLimits, maxFeesPerGas: new GasFees(11, 22) }); +}; + +/** A tx with no public calls. */ +const makePrivateTx = async () => { + const privateTx = await mockTx(1, { + numberOfNonRevertiblePublicCallRequests: 0, + numberOfRevertiblePublicCallRequests: 0, + hasPublicTeardownCallRequest: false, + }); + assert(!privateTx.data.forPublic); + return privateTx; +}; + +describe('gas limits validators', () => { + let tx: Tx; + + beforeEach(async () => { + tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 2 }); + }); + + describe('MinGasLimitsValidator', () => { + const expectValid = async (tx: Tx) => { + await expect(new MinGasLimitsValidator().validateTx(tx)).resolves.toEqual({ result: 'valid' }); + }; + + const expectInvalid = async (tx: Tx) => { + const result = await new MinGasLimitsValidator().validateTx(tx); + expect(result.result).toEqual('invalid'); + expect((result as { reason: string[] }).reason[0]).toContain(TX_ERROR_INSUFFICIENT_GAS_LIMIT); + }; + + it('accepts public tx at exactly the minimum gas limits', async () => { + assert(!!tx.data.forPublic); + setGasLimits(tx, new Gas(TX_DA_GAS_OVERHEAD, PUBLIC_TX_L2_GAS_OVERHEAD)); + await expectValid(tx); + }); + + it('accepts private tx at exactly the minimum gas limits', async () => { + const privateTx = await makePrivateTx(); + setGasLimits(privateTx, new Gas(TX_DA_GAS_OVERHEAD, PRIVATE_TX_L2_GAS_OVERHEAD)); + await expectValid(privateTx); + }); + + it('rejects public tx below the public L2 gas minimum', async () => { + assert(!!tx.data.forPublic); + setGasLimits(tx, new Gas(TX_DA_GAS_OVERHEAD, PUBLIC_TX_L2_GAS_OVERHEAD - 1)); + await expectInvalid(tx); + }); + + it('rejects private tx below the private L2 gas minimum', async () => { + const privateTx = await makePrivateTx(); + setGasLimits(privateTx, new Gas(TX_DA_GAS_OVERHEAD, PRIVATE_TX_L2_GAS_OVERHEAD - 1)); + await expectInvalid(privateTx); + }); + + it('rejects public tx at private L2 gas minimum (between the two thresholds)', async () => { + assert(!!tx.data.forPublic); + // PRIVATE_TX_L2_GAS_OVERHEAD is enough for a private tx but not for a public tx. + setGasLimits(tx, new Gas(TX_DA_GAS_OVERHEAD, PRIVATE_TX_L2_GAS_OVERHEAD)); + await expectInvalid(tx); + }); + + it('rejects tx below DA gas minimum', async () => { + setGasLimits(tx, new Gas(TX_DA_GAS_OVERHEAD - 1, PUBLIC_TX_L2_GAS_OVERHEAD)); + await expectInvalid(tx); + }); + + it('rejects tx below both DA and L2 gas minimums', async () => { + setGasLimits(tx, new Gas(TX_DA_GAS_OVERHEAD - 1, PUBLIC_TX_L2_GAS_OVERHEAD - 1)); + await expectInvalid(tx); + }); + + it('ignores limits above the protocol ceiling', async () => { + // The ceiling is owned by MaxGasLimitsValidator, which factories include separately so that the + // estimation exemption cannot take the floor with it. + setGasLimits(tx, new Gas(MAX_TX_DA_GAS + 1, MAX_PROCESSABLE_L2_GAS + 1)); + await expectValid(tx); + }); + }); + + describe('MaxGasLimitsValidator', () => { + const expectValid = async (tx: Tx, validator = new MaxGasLimitsValidator()) => { + await expect(validator.validateTx(tx)).resolves.toEqual({ result: 'valid' }); + }; + + const expectInvalid = async (tx: Tx, validator = new MaxGasLimitsValidator()) => { + await expect(validator.validateTx(tx)).resolves.toEqual({ + result: 'invalid', + reason: [expect.stringContaining(TX_ERROR_GAS_LIMIT_TOO_HIGH)], + }); + }; + + it('rejects public tx if L2 gas limit is too high', async () => { + setGasLimits(tx, new Gas(MAX_TX_DA_GAS, MAX_PROCESSABLE_L2_GAS + 1)); + await expectInvalid(tx); + }); + + it('rejects private tx if L2 gas limit is too high', async () => { + const privateTx = await makePrivateTx(); + setGasLimits(privateTx, new Gas(MAX_TX_DA_GAS, MAX_PROCESSABLE_L2_GAS + 1)); + await expectInvalid(privateTx); + }); + + it('ignores limits below the protocol minimums', async () => { + // The floor is owned by MinGasLimitsValidator. + setGasLimits(tx, Gas.empty()); + await expectValid(tx); + }); + + describe('network admission limits (maxTxL2Gas, maxTxDAGas)', () => { + // Network limits are deployment config; any value below the protocol ceiling stands in for one here. + const maxTxL2Gas = Math.floor(MAX_PROCESSABLE_L2_GAS / 2); + const maxTxDAGas = Math.floor(MAX_TX_DA_GAS / 2); + + it('rejects tx exceeding maxTxL2Gas', async () => { + setGasLimits(tx, new Gas(MAX_TX_DA_GAS, maxTxL2Gas + 1)); + await expectInvalid(tx, new MaxGasLimitsValidator({ maxTxL2Gas })); + }); + + it('accepts tx at exactly maxTxL2Gas', async () => { + setGasLimits(tx, new Gas(MAX_TX_DA_GAS, maxTxL2Gas)); + await expectValid(tx, new MaxGasLimitsValidator({ maxTxL2Gas })); + }); + + it('clamps maxTxL2Gas to the per-tx protocol maximum', async () => { + // Passing a higher network limit cannot raise the ceiling above MAX_PROCESSABLE_L2_GAS. + setGasLimits(tx, new Gas(MAX_TX_DA_GAS, MAX_PROCESSABLE_L2_GAS + 1)); + await expectInvalid(tx, new MaxGasLimitsValidator({ maxTxL2Gas: MAX_PROCESSABLE_L2_GAS + 1_000 })); + }); + + it('falls back to MAX_PROCESSABLE_L2_GAS when no L2 limit is set', async () => { + setGasLimits(tx, new Gas(MAX_TX_DA_GAS, MAX_PROCESSABLE_L2_GAS + 1)); + await expectInvalid(tx); + }); + + it('rejects tx exceeding maxTxDAGas', async () => { + setGasLimits(tx, new Gas(maxTxDAGas + 1, PUBLIC_TX_L2_GAS_OVERHEAD)); + await expectInvalid(tx, new MaxGasLimitsValidator({ maxTxDAGas })); + }); + + it('accepts tx at exactly maxTxDAGas', async () => { + setGasLimits(tx, new Gas(maxTxDAGas, PUBLIC_TX_L2_GAS_OVERHEAD)); + await expectValid(tx, new MaxGasLimitsValidator({ maxTxDAGas })); + }); + + it('caps DA at the max tx blob size when no DA limit is set', async () => { + setGasLimits(tx, new Gas(MAX_TX_DA_GAS + 1, PUBLIC_TX_L2_GAS_OVERHEAD)); + await expectInvalid(tx); + }); + + it('accepts a tx at exactly the max tx blob size DA limit when no DA limit is set', async () => { + setGasLimits(tx, new Gas(MAX_TX_DA_GAS, PUBLIC_TX_L2_GAS_OVERHEAD)); + await expectValid(tx); + }); + }); + }); +}); diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/gas_limits_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/gas_limits_validator.ts new file mode 100644 index 000000000000..b106c4495fa7 --- /dev/null +++ b/yarn-project/p2p/src/msg_validators/tx_validator/gas_limits_validator.ts @@ -0,0 +1,148 @@ +import { + MAX_PROCESSABLE_L2_GAS, + MAX_TX_DA_GAS, + PRIVATE_TX_L2_GAS_OVERHEAD, + PUBLIC_TX_L2_GAS_OVERHEAD, + TX_DA_GAS_OVERHEAD, +} from '@aztec/constants'; +import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; +import { Gas } from '@aztec/stdlib/gas'; +import { + TX_ERROR_GAS_LIMIT_TOO_HIGH, + TX_ERROR_INSUFFICIENT_GAS_LIMIT, + type TxValidationResult, + type TxValidator, +} from '@aztec/stdlib/tx'; + +/** + * Structural interface for types that carry gas limit data, used by {@link MinGasLimitsValidator} and + * {@link MaxGasLimitsValidator}. + */ +export interface HasGasLimitData { + txHash: { toString(): string }; + data: { + // We just need to know whether there is something here or not + forPublic?: unknown; + constants: { + txContext: { + gasSettings: { gasLimits: Gas }; + }; + }; + }; +} + +/** + * Validates that a transaction declares at least the gas it is guaranteed to be charged. + * + * Rejects transactions whose gas limits fall below the fixed protocol overheads + * ({@link TX_DA_GAS_OVERHEAD} and {@link PRIVATE_TX_L2_GAS_OVERHEAD} / {@link PUBLIC_TX_L2_GAS_OVERHEAD}). + * This is a cheap, stateless check that operates on gas settings alone. + * + * The floor is a protocol property with no exemptions: a tx below it can never be mined, so every entry + * point applies it, including gas estimation. + * + * Generic over T so it can validate both full {@link Tx} objects and {@link TxMetaData} + * (used during pending pool migration). + */ +export class MinGasLimitsValidator implements TxValidator { + #log: Logger; + + constructor(bindings?: LoggerBindings) { + this.#log = createLogger('sequencer:tx_validator:tx_gas', bindings); + } + + validateTx(tx: T): Promise { + return Promise.resolve(this.validateGasLimit(tx)); + } + + /** Checks gas limits are >= the fixed protocol overheads (L2 and DA). */ + validateGasLimit(tx: T): TxValidationResult { + const gasLimits = tx.data.constants.txContext.gasSettings.gasLimits; + const minGasLimits = new Gas( + TX_DA_GAS_OVERHEAD, + tx.data.forPublic ? PUBLIC_TX_L2_GAS_OVERHEAD : PRIVATE_TX_L2_GAS_OVERHEAD, + ); + + if (minGasLimits.gtAny(gasLimits)) { + this.#log.verbose(`Rejecting transaction due to the gas limit(s) not being above the minimum gas limit`, { + gasLimits, + minGasLimits, + }); + return { + result: 'invalid', + reason: [ + `${TX_ERROR_INSUFFICIENT_GAS_LIMIT} (required=da:${minGasLimits.daGas},l2:${minGasLimits.l2Gas} got=da:${gasLimits.daGas},l2:${gasLimits.l2Gas})`, + ], + }; + } + + return { result: 'valid' }; + } +} + +/** + * Validates that a transaction does not declare more gas than it is allowed to. + * + * Rejects transactions whose gas limits exceed the per-tx protocol maxima, optionally tightened to the + * network admission limits. This is a cheap, stateless check that operates on gas settings alone. + * + * Unlike the floor, the ceiling is exempted on the gas estimation path, where limits are deliberately + * inflated past what the protocol allows (see {@link GasSettings.forEstimation}). + * + * Generic over T so it can validate both full {@link Tx} objects and {@link TxMetaData} + * (used during pending pool migration). + */ +export class MaxGasLimitsValidator implements TxValidator { + #log: Logger; + #effectiveMaxL2Gas: number; + #effectiveMaxDAGas: number; + + /** + * @param maxTxL2Gas - The network admission limit on L2 gas a single tx may declare (the per-block mana + * allocation, see {@link computeNetworkTxGasLimits}). Defaults to the per-tx protocol maximum, so callers + * that pass nothing (e.g. block building) enforce only the protocol ceiling. + * @param maxTxDAGas - The network admission limit on DA gas a single tx may declare. Defaults to the + * per-tx protocol maximum {@link MAX_TX_DA_GAS}. + */ + constructor(opts?: { maxTxL2Gas?: number; maxTxDAGas?: number; bindings?: LoggerBindings }) { + this.#log = createLogger('sequencer:tx_validator:tx_gas', opts?.bindings); + // The passed limits are network admission limits; clamp to the per-tx protocol maxima as a hard ceiling. + // MAX_TX_DA_GAS bounds DA by what a single tx can actually post to a blob; declaring more is meaningless + // and would let a tx reserve checkpoint/block DA budget during proposal building it can't use. + this.#effectiveMaxL2Gas = Math.min(MAX_PROCESSABLE_L2_GAS, opts?.maxTxL2Gas ?? Infinity); + this.#effectiveMaxDAGas = Math.min(MAX_TX_DA_GAS, opts?.maxTxDAGas ?? Infinity); + } + + validateTx(tx: T): Promise { + return Promise.resolve(this.validateGasLimit(tx)); + } + + /** Checks gas limits are <= the effective max gas (L2 and DA). */ + validateGasLimit(tx: T): TxValidationResult { + const gasLimits = tx.data.constants.txContext.gasSettings.gasLimits; + + if (gasLimits.l2Gas > this.#effectiveMaxL2Gas) { + this.#log.verbose(`Rejecting transaction due to the L2 gas limit being higher than the effective maximum`, { + gasLimits, + effectiveMaxL2Gas: this.#effectiveMaxL2Gas, + }); + return { + result: 'invalid', + reason: [`${TX_ERROR_GAS_LIMIT_TOO_HIGH} (l2Gas=${gasLimits.l2Gas}, max=${this.#effectiveMaxL2Gas})`], + }; + } + + if (gasLimits.daGas > this.#effectiveMaxDAGas) { + this.#log.verbose(`Rejecting transaction due to the DA gas limit being higher than the effective maximum`, { + gasLimits, + effectiveMaxDAGas: this.#effectiveMaxDAGas, + }); + return { + result: 'invalid', + reason: [`${TX_ERROR_GAS_LIMIT_TOO_HIGH} (daGas=${gasLimits.daGas}, max=${this.#effectiveMaxDAGas})`], + }; + } + + return { result: 'valid' }; + } +} diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.test.ts b/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.test.ts index 3126ddd53d78..51a472f106df 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.test.ts @@ -1,10 +1,4 @@ -import { - MAX_PROCESSABLE_L2_GAS, - MAX_TX_DA_GAS, - PRIVATE_TX_L2_GAS_OVERHEAD, - PUBLIC_TX_L2_GAS_OVERHEAD, - TX_DA_GAS_OVERHEAD, -} from '@aztec/constants'; +import { MAX_PROCESSABLE_L2_GAS, MAX_TX_DA_GAS } from '@aztec/constants'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { Writeable } from '@aztec/foundation/types'; import { ProtocolContractAddress } from '@aztec/protocol-contracts'; @@ -14,18 +8,11 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { Gas, GasFees, GasSettings } from '@aztec/stdlib/gas'; import { mockTx } from '@aztec/stdlib/testing'; import type { PublicStateSource } from '@aztec/stdlib/trees'; -import { - TX_ERROR_GAS_LIMIT_TOO_HIGH, - TX_ERROR_INSUFFICIENT_FEE_PAYER_BALANCE, - TX_ERROR_INSUFFICIENT_FEE_PER_GAS, - TX_ERROR_INSUFFICIENT_GAS_LIMIT, - type Tx, -} from '@aztec/stdlib/tx'; +import { TX_ERROR_INSUFFICIENT_FEE_PAYER_BALANCE, TX_ERROR_INSUFFICIENT_FEE_PER_GAS, type Tx } from '@aztec/stdlib/tx'; -import assert from 'assert'; import { type MockProxy, mock, mockFn } from 'jest-mock-extended'; -import { GasLimitsValidator, GasTxValidator, MaxFeePerGasValidator } from './gas_validator.js'; +import { GasTxValidator, MaxFeePerGasValidator } from './gas_validator.js'; import { patchNonRevertibleFn, patchRevertibleFn } from './test_utils.js'; const DEFAULT_GAS_LIMITS = new Gas(MAX_TX_DA_GAS, MAX_PROCESSABLE_L2_GAS); @@ -116,238 +103,17 @@ describe('GasTxValidator', () => { await expectInvalid(tx, TX_ERROR_INSUFFICIENT_FEE_PAYER_BALANCE); }); - const makePrivateTx = async () => { - const privateTx = await mockTx(1, { - numberOfNonRevertiblePublicCallRequests: 0, - numberOfRevertiblePublicCallRequests: 0, - hasPublicTeardownCallRequest: false, - }); - assert(!privateTx.data.forPublic); - privateTx.data.feePayer = payer; - privateTx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: DEFAULT_GAS_LIMITS, + it('does not enforce gas limits, which are owned by the gas limits validators', async () => { + // Gas estimation submits limits above the per-tx protocol maximum (GasSettings.forEstimation); whether they + // are admissible is decided by MinGasLimitsValidator/MaxGasLimitsValidator wherever a factory includes them, + // never by fee enforcement. + tx.data.constants.txContext.gasSettings = GasSettings.fallback({ + gasLimits: new Gas(MAX_TX_DA_GAS, MAX_PROCESSABLE_L2_GAS * 2), maxFeesPerGas: gasFees.clone(), + teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), }); - return privateTx; - }; - - describe('gas limits', () => { - it('accepts public tx at exactly the minimum gas limits', async () => { - assert(!!tx.data.forPublic); - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(TX_DA_GAS_OVERHEAD, PUBLIC_TX_L2_GAS_OVERHEAD), - maxFeesPerGas: gasFees.clone(), - }); - mockBalance(tx.data.constants.txContext.gasSettings.getFeeLimit().toBigInt()); - await expectValid(tx); - }); - - it('accepts private tx at exactly the minimum gas limits', async () => { - const privateTx = await makePrivateTx(); - privateTx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(TX_DA_GAS_OVERHEAD, PRIVATE_TX_L2_GAS_OVERHEAD), - maxFeesPerGas: gasFees.clone(), - }); - mockBalance(privateTx.data.constants.txContext.gasSettings.getFeeLimit().toBigInt()); - await expectValid(privateTx); - }); - - it('rejects public tx below the public L2 gas minimum', async () => { - assert(!!tx.data.forPublic); - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(TX_DA_GAS_OVERHEAD, PUBLIC_TX_L2_GAS_OVERHEAD - 1), - maxFeesPerGas: gasFees.clone(), - }); - await expectInvalid(tx, TX_ERROR_INSUFFICIENT_GAS_LIMIT); - }); - - it('rejects private tx below the private L2 gas minimum', async () => { - const privateTx = await makePrivateTx(); - privateTx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(TX_DA_GAS_OVERHEAD, PRIVATE_TX_L2_GAS_OVERHEAD - 1), - maxFeesPerGas: gasFees.clone(), - }); - await expectInvalid(privateTx, TX_ERROR_INSUFFICIENT_GAS_LIMIT); - }); - - it('rejects public tx at private L2 gas minimum (between the two thresholds)', async () => { - assert(!!tx.data.forPublic); - // PRIVATE_TX_L2_GAS_OVERHEAD is enough for a private tx but not for a public tx. - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(TX_DA_GAS_OVERHEAD, PRIVATE_TX_L2_GAS_OVERHEAD), - maxFeesPerGas: gasFees.clone(), - }); - await expectInvalid(tx, TX_ERROR_INSUFFICIENT_GAS_LIMIT); - }); - - it('rejects tx below DA gas minimum', async () => { - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(TX_DA_GAS_OVERHEAD - 1, PUBLIC_TX_L2_GAS_OVERHEAD), - maxFeesPerGas: gasFees.clone(), - }); - await expectInvalid(tx, TX_ERROR_INSUFFICIENT_GAS_LIMIT); - }); - - it('rejects tx below both DA and L2 gas minimums', async () => { - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(1, 1), - maxFeesPerGas: gasFees.clone(), - }); - await expectInvalid(tx, TX_ERROR_INSUFFICIENT_GAS_LIMIT); - }); - - it('rejects public tx if L2 gas limit is too high', async () => { - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(MAX_TX_DA_GAS, MAX_PROCESSABLE_L2_GAS + 1), - maxFeesPerGas: gasFees.clone(), - teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), - }); - await expectInvalid(tx, TX_ERROR_GAS_LIMIT_TOO_HIGH); - }); - - it('rejects private tx if L2 gas limit is too high', async () => { - const privateTx = await makePrivateTx(); - privateTx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(MAX_TX_DA_GAS, MAX_PROCESSABLE_L2_GAS + 1), - maxFeesPerGas: gasFees.clone(), - teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), - }); - await expectInvalid(privateTx, TX_ERROR_GAS_LIMIT_TOO_HIGH); - }); - - describe('network admission limits (maxTxL2Gas, maxTxDAGas)', () => { - it('rejects tx exceeding maxTxL2Gas', async () => { - const maxTxL2Gas = 1_000_000; - const validator = new GasLimitsValidator({ maxTxL2Gas }); - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(MAX_TX_DA_GAS, maxTxL2Gas + 1), - maxFeesPerGas: gasFees.clone(), - teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), - }); - await expect(validator.validateTx(tx)).resolves.toEqual({ - result: 'invalid', - reason: [expect.stringContaining(TX_ERROR_GAS_LIMIT_TOO_HIGH)], - }); - }); - - it('accepts tx at exactly maxTxL2Gas', async () => { - const maxTxL2Gas = 1_000_000; - const validator = new GasLimitsValidator({ maxTxL2Gas }); - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(MAX_TX_DA_GAS, maxTxL2Gas), - maxFeesPerGas: gasFees.clone(), - teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), - }); - await expect(validator.validateTx(tx)).resolves.toEqual({ result: 'valid' }); - }); - - it('clamps maxTxL2Gas to the per-tx protocol maximum', async () => { - // Passing a higher network limit cannot raise the ceiling above MAX_PROCESSABLE_L2_GAS. - const validator = new GasLimitsValidator({ maxTxL2Gas: MAX_PROCESSABLE_L2_GAS + 1_000 }); - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(MAX_TX_DA_GAS, MAX_PROCESSABLE_L2_GAS + 1), - maxFeesPerGas: gasFees.clone(), - teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), - }); - await expect(validator.validateTx(tx)).resolves.toEqual({ - result: 'invalid', - reason: [expect.stringContaining(TX_ERROR_GAS_LIMIT_TOO_HIGH)], - }); - }); - - it('falls back to MAX_PROCESSABLE_L2_GAS when no L2 limit is set', async () => { - const validator = new GasLimitsValidator(); - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(MAX_TX_DA_GAS, MAX_PROCESSABLE_L2_GAS + 1), - maxFeesPerGas: gasFees.clone(), - teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), - }); - await expect(validator.validateTx(tx)).resolves.toEqual({ - result: 'invalid', - reason: [expect.stringContaining(TX_ERROR_GAS_LIMIT_TOO_HIGH)], - }); - }); - - it('rejects tx exceeding maxTxDAGas', async () => { - const maxTxDAGas = 100_000; - const validator = new GasLimitsValidator({ maxTxDAGas }); - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(maxTxDAGas + 1, PUBLIC_TX_L2_GAS_OVERHEAD), - maxFeesPerGas: gasFees.clone(), - teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), - }); - await expect(validator.validateTx(tx)).resolves.toEqual({ - result: 'invalid', - reason: [expect.stringContaining(TX_ERROR_GAS_LIMIT_TOO_HIGH)], - }); - }); - - it('accepts tx at exactly maxTxDAGas', async () => { - const maxTxDAGas = 100_000; - const validator = new GasLimitsValidator({ maxTxDAGas }); - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(maxTxDAGas, PUBLIC_TX_L2_GAS_OVERHEAD), - maxFeesPerGas: gasFees.clone(), - teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), - }); - await expect(validator.validateTx(tx)).resolves.toEqual({ result: 'valid' }); - }); - - it('caps DA at the max tx blob size when no DA limit is set', async () => { - const validator = new GasLimitsValidator(); - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(MAX_TX_DA_GAS + 1, PUBLIC_TX_L2_GAS_OVERHEAD), - maxFeesPerGas: gasFees.clone(), - teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), - }); - await expect(validator.validateTx(tx)).resolves.toEqual({ - result: 'invalid', - reason: [expect.stringContaining(TX_ERROR_GAS_LIMIT_TOO_HIGH)], - }); - }); - - it('accepts a tx at exactly the max tx blob size DA limit when no DA limit is set', async () => { - const validator = new GasLimitsValidator(); - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(MAX_TX_DA_GAS, PUBLIC_TX_L2_GAS_OVERHEAD), - maxFeesPerGas: gasFees.clone(), - teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), - }); - await expect(validator.validateTx(tx)).resolves.toEqual({ result: 'valid' }); - }); - - it('forwards L2 limits through GasTxValidator', async () => { - const maxTxL2Gas = 1_000_000; - const validator = new GasTxValidator(publicStateSource, feeJuiceAddress, gasFees, undefined, { - maxTxL2Gas, - }); - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(MAX_TX_DA_GAS, maxTxL2Gas + 1), - maxFeesPerGas: gasFees.clone(), - teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), - }); - await expect(validator.validateTx(tx)).resolves.toEqual({ - result: 'invalid', - reason: [expect.stringContaining(TX_ERROR_GAS_LIMIT_TOO_HIGH)], - }); - }); - - it('forwards DA limits through GasTxValidator', async () => { - const maxTxDAGas = 100_000; - const validator = new GasTxValidator(publicStateSource, feeJuiceAddress, gasFees, undefined, { - maxTxDAGas, - }); - tx.data.constants.txContext.gasSettings = GasSettings.fallback({ - gasLimits: new Gas(maxTxDAGas + 1, PUBLIC_TX_L2_GAS_OVERHEAD), - maxFeesPerGas: gasFees.clone(), - teardownGasLimits: new Gas(TEARDOWN_DA_GAS, 1), - }); - await expect(validator.validateTx(tx)).resolves.toEqual({ - result: 'invalid', - reason: [expect.stringContaining(TX_ERROR_GAS_LIMIT_TOO_HIGH)], - }); - }); - }); + mockBalance(tx.data.constants.txContext.gasSettings.getFeeLimit().toBigInt()); + await expectValid(tx); }); it('rejects txs with not enough fee per da gas', async () => { diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.ts index f076bb0a481c..a8688a02bc14 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.ts @@ -1,20 +1,11 @@ -import { - MAX_PROCESSABLE_L2_GAS, - MAX_TX_DA_GAS, - PRIVATE_TX_L2_GAS_OVERHEAD, - PUBLIC_TX_L2_GAS_OVERHEAD, - TX_DA_GAS_OVERHEAD, -} from '@aztec/constants'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; import { computeFeePayerBalanceStorageSlot } from '@aztec/protocol-contracts/fee-juice'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { Gas, GasFees } from '@aztec/stdlib/gas'; +import type { GasFees } from '@aztec/stdlib/gas'; import type { PublicStateSource } from '@aztec/stdlib/trees'; import { - TX_ERROR_GAS_LIMIT_TOO_HIGH, TX_ERROR_INSUFFICIENT_FEE_PAYER_BALANCE, TX_ERROR_INSUFFICIENT_FEE_PER_GAS, - TX_ERROR_INSUFFICIENT_GAS_LIMIT, type Tx, type TxValidationResult, type TxValidator, @@ -22,20 +13,6 @@ import { import { getFeePayerClaimAmount, getTxFeeLimit } from './fee_payer_balance.js'; -/** Structural interface for types that carry gas limit data, used by {@link GasLimitsValidator}. */ -export interface HasGasLimitData { - txHash: { toString(): string }; - data: { - // We just need to know whether there is something here or not - forPublic?: unknown; - constants: { - txContext: { - gasSettings: { gasLimits: Gas }; - }; - }; - }; -} - /** Structural interface for types that carry max fee per gas data, used by {@link MaxFeePerGasValidator}. */ export interface HasMaxFeePerGasData { txHash: { toString(): string }; @@ -48,90 +25,6 @@ export interface HasMaxFeePerGasData { }; } -/** - * Validates that a transaction's gas limits are within acceptable bounds. - * - * Rejects transactions whose gas limits fall below the fixed minimums (FIXED_DA_GAS, - * FIXED_L2_GAS) or exceed the AVM's maximum processable L2 gas. This is a cheap, - * stateless check that operates on gas settings alone. - * - * Generic over T so it can validate both full {@link Tx} objects and {@link TxMetaData} - * (used during pending pool migration). - * - * Used by: pending pool migration (via factory), and indirectly by {@link GasTxValidator}. - */ -export class GasLimitsValidator implements TxValidator { - #log: Logger; - #effectiveMaxL2Gas: number; - #effectiveMaxDAGas: number; - - /** - * @param maxTxL2Gas - The network admission limit on L2 gas a single tx may declare (the per-block mana - * allocation, see {@link computeNetworkTxGasLimits}). Defaults to the per-tx protocol maximum, so callers - * that pass nothing (e.g. block building) enforce only the protocol ceiling. - * @param maxTxDAGas - The network admission limit on DA gas a single tx may declare. Defaults to the - * per-tx protocol maximum {@link MAX_TX_DA_GAS}. - */ - constructor(opts?: { maxTxL2Gas?: number; maxTxDAGas?: number; bindings?: LoggerBindings }) { - this.#log = createLogger('sequencer:tx_validator:tx_gas', opts?.bindings); - // The passed limits are network admission limits; clamp to the per-tx protocol maxima as a hard ceiling. - // MAX_TX_DA_GAS bounds DA by what a single tx can actually post to a blob; declaring more is meaningless - // and would let a tx reserve checkpoint/block DA budget during proposal building it can't use. - this.#effectiveMaxL2Gas = Math.min(MAX_PROCESSABLE_L2_GAS, opts?.maxTxL2Gas ?? Infinity); - this.#effectiveMaxDAGas = Math.min(MAX_TX_DA_GAS, opts?.maxTxDAGas ?? Infinity); - } - - validateTx(tx: T): Promise { - return Promise.resolve(this.validateGasLimit(tx)); - } - - /** Checks gas limits are >= fixed minimums and <= effective max gas (L2 and DA). */ - validateGasLimit(tx: T): TxValidationResult { - const gasLimits = tx.data.constants.txContext.gasSettings.gasLimits; - const minGasLimits = new Gas( - TX_DA_GAS_OVERHEAD, - tx.data.forPublic ? PUBLIC_TX_L2_GAS_OVERHEAD : PRIVATE_TX_L2_GAS_OVERHEAD, - ); - - if (minGasLimits.gtAny(gasLimits)) { - this.#log.verbose(`Rejecting transaction due to the gas limit(s) not being above the minimum gas limit`, { - gasLimits, - minGasLimits, - }); - return { - result: 'invalid', - reason: [ - `${TX_ERROR_INSUFFICIENT_GAS_LIMIT} (required=da:${minGasLimits.daGas},l2:${minGasLimits.l2Gas} got=da:${gasLimits.daGas},l2:${gasLimits.l2Gas})`, - ], - }; - } - - if (gasLimits.l2Gas > this.#effectiveMaxL2Gas) { - this.#log.verbose(`Rejecting transaction due to the L2 gas limit being higher than the effective maximum`, { - gasLimits, - effectiveMaxL2Gas: this.#effectiveMaxL2Gas, - }); - return { - result: 'invalid', - reason: [`${TX_ERROR_GAS_LIMIT_TOO_HIGH} (l2Gas=${gasLimits.l2Gas}, max=${this.#effectiveMaxL2Gas})`], - }; - } - - if (gasLimits.daGas > this.#effectiveMaxDAGas) { - this.#log.verbose(`Rejecting transaction due to the DA gas limit being higher than the effective maximum`, { - gasLimits, - effectiveMaxDAGas: this.#effectiveMaxDAGas, - }); - return { - result: 'invalid', - reason: [`${TX_ERROR_GAS_LIMIT_TOO_HIGH} (daGas=${gasLimits.daGas}, max=${this.#effectiveMaxDAGas})`], - }; - } - - return { result: 'valid' }; - } -} - /** * Validates that a transaction's max fee per gas meets the current block's gas fees. * @@ -181,47 +74,39 @@ export class MaxFeePerGasValidator implements TxV /** * Validates that a transaction can pay its gas fees. * - * Runs three checks in order: - * 1. **Gas limits** (delegates to {@link GasLimitsValidator}) — rejects if limits are - * out of bounds. - * 2. **Max fee per gas** — rejects the tx if its maxFeesPerGas is below - * the current block's gas fees. - * 3. **Fee payer balance** — reads the fee payer's FeeJuice balance from public state, + * Runs two checks in order: + * 1. **Max fee per gas** (delegates to {@link MaxFeePerGasValidator}) — rejects the tx if its + * maxFeesPerGas is below the current block's gas fees. + * 2. **Fee payer balance** — reads the fee payer's FeeJuice balance from public state, * adds any pending claim from a setup-phase `_increase_public_balance` call, and * rejects if the total is less than the tx's fee limit (gasLimits * maxFeePerGas). * + * Gas limits are deliberately not checked here: they are owned by {@link MinGasLimitsValidator} and + * {@link MaxGasLimitsValidator}, which factories include separately so that exemptions (e.g. gas estimation) + * don't change fee enforcement. + * * Used by: gossip (stage 1), RPC, and block building validators. */ export class GasTxValidator implements TxValidator { #log: Logger; #publicDataSource: PublicStateSource; #feeJuiceAddress: AztecAddress; - #gasFees: GasFees; - #gasLimitOpts?: { maxTxL2Gas?: number; maxTxDAGas?: number }; + #maxFeePerGasValidator: MaxFeePerGasValidator; constructor( publicDataSource: PublicStateSource, feeJuiceAddress: AztecAddress, gasFees: GasFees, - private bindings?: LoggerBindings, - opts?: { maxTxL2Gas?: number; maxTxDAGas?: number }, + bindings?: LoggerBindings, ) { this.#log = createLogger('sequencer:tx_validator:tx_gas', bindings); this.#publicDataSource = publicDataSource; this.#feeJuiceAddress = feeJuiceAddress; - this.#gasFees = gasFees; - this.#gasLimitOpts = opts; + this.#maxFeePerGasValidator = new MaxFeePerGasValidator(gasFees, bindings); } async validateTx(tx: Tx): Promise { - const gasLimitValidation = new GasLimitsValidator({ - ...this.#gasLimitOpts, - bindings: this.bindings, - }).validateGasLimit(tx); - if (gasLimitValidation.result === 'invalid') { - return gasLimitValidation; - } - const maxFeeValidation = new MaxFeePerGasValidator(this.#gasFees, this.bindings).validateMaxFeePerGas(tx); + const maxFeeValidation = this.#maxFeePerGasValidator.validateMaxFeePerGas(tx); if (maxFeeValidation.result === 'invalid') { return maxFeeValidation; } diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/index.ts b/yarn-project/p2p/src/msg_validators/tx_validator/index.ts index 4a21303b725e..4542220a3965 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/index.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/index.ts @@ -4,6 +4,7 @@ export * from './double_spend_validator.js'; export * from './metadata_validator.js'; export * from './tx_proof_validator.js'; export * from './block_header_validator.js'; +export * from './gas_limits_validator.js'; export * from './gas_validator.js'; export * from './phases_validator.js'; export * from './test_utils.js'; diff --git a/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.ts b/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.ts index 85fe0129fd04..e55074bee671 100644 --- a/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.ts +++ b/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.ts @@ -197,7 +197,7 @@ export class PublicTxSimulator implements PublicTxSimulatorInterface { context.halt(); - // Such transactions should be filtered by GasTxValidator. + // Gas estimation deliberately declares limits above this ceiling; block building rejects them before execution. assert( context.getActualGasUsed().l2Gas <= MAX_PROCESSABLE_L2_GAS, `Transaction consumes ${context.getActualGasUsed().l2Gas} L2 gas, which exceeds the maximum processable gas of ${MAX_PROCESSABLE_L2_GAS}`, diff --git a/yarn-project/stdlib/src/gas/README.md b/yarn-project/stdlib/src/gas/README.md index 1bce97106cdb..b98eef59ace6 100644 --- a/yarn-project/stdlib/src/gas/README.md +++ b/yarn-project/stdlib/src/gas/README.md @@ -226,7 +226,8 @@ must also be buildable into a block and fit a valid checkpoint. ### Per-tx protocol maxima Hard ceilings on what any single tx may declare, independent of network configuration. Declaring more is -rejected everywhere a tx is validated. +rejected by `GasLimitsValidator` at every tx validation entry point except simulation, which is exempt +because gas estimation deliberately declares limits above the per-tx maximum. - **`MAX_TX_DA_GAS`** (271,200) — `MAX_TX_BLOB_DATA_SIZE_IN_FIELDS` (8,475) × `DA_GAS_PER_FIELD` (32). This is the most DA a single tx's effects can encode into a blob, so it is the most DA gas a tx could ever use. @@ -300,8 +301,8 @@ The outermost limits, enforced as proposal validity in `validateCheckpointLimits | Limit | Value (mainnet defaults) | Scope | Where enforced | | --------------------------------------- | ------------------------------- | ------------- | ----------------------------------------------------------- | -| `MAX_TX_DA_GAS` | 271,200 | per-tx | every gas validator (hard ceiling) | -| `MAX_PROCESSABLE_L2_GAS` | 6,540,000 | per-tx | every gas validator (hard ceiling) | +| `MAX_TX_DA_GAS` | 271,200 | per-tx | `GasLimitsValidator` (hard ceiling; simulation exempt) | +| `MAX_PROCESSABLE_L2_GAS` | 6,540,000 | per-tx | `GasLimitsValidator` (hard ceiling; simulation exempt) | | Network DA admission limit | min(271,200, ceil(784,448/10×1.5)) = 117,668 | per-tx (relay) | RPC, gossip, pending pool (`GasLimitsValidator`) | | Network L2 admission limit | min(6,540,000, ceil(manaLimit/10×1.2)) | per-tx (relay) | RPC, gossip, pending pool (`GasLimitsValidator`) | | Per-block fair share + caps | remaining budget / blocks × multiplier, min absolute caps & blob-field cap | per-block | `CheckpointBuilder.capLimitsByCheckpointBudgets` | diff --git a/yarn-project/stdlib/src/gas/gas_settings.ts b/yarn-project/stdlib/src/gas/gas_settings.ts index 4672f16be52a..514d3c27f268 100644 --- a/yarn-project/stdlib/src/gas/gas_settings.ts +++ b/yarn-project/stdlib/src/gas/gas_settings.ts @@ -132,9 +132,9 @@ export class GasSettings { * the effective gas available for app logic is gasLimits - teardownGasLimits - privateOverhead. * To ensure estimation never hits gas caps, we set both limits above what the protocol allows: * teardown gets MAX_PROCESSABLE and gasLimits gets teardown + MAX_PROCESSABLE, so the full - * processable amount remains available for each phase independently. To be used in conjunction - * with skipTxValidation: true during public simulation, or the node would reject the transaction - * outright due to gas limits being above protocol max. + * processable amount remains available for each phase independently. Tx validation exempts + * simulated txs from gas-limit admission (`isValidTx` with `isSimulation: true`), so these + * inflated limits pass validation; the wallet clamps the real tx to the admission limit afterward. */ static forEstimation(overrides: { gasLimits?: Gas; diff --git a/yarn-project/validator-client/README.md b/yarn-project/validator-client/README.md index 06afb9a0328d..b10e5ceea63e 100644 --- a/yarn-project/validator-client/README.md +++ b/yarn-project/validator-client/README.md @@ -246,7 +246,7 @@ Per-block budgets prevent one block from consuming the entire checkpoint budget. ### Per-transaction enforcement -**Mempool entry** (`GasLimitsValidator`): L2 gas must be ≤ `MAX_PROCESSABLE_L2_GAS` (6,540,000) and ≥ fixed minimums. +**Mempool entry** (`MaxGasLimitsValidator` / `MinGasLimitsValidator`): 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. diff --git a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts index ccde76c5e0f0..a9b544539cb6 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts @@ -309,13 +309,13 @@ export abstract class BaseWallet implements Wallet { // (the node's per-tx admission limit), so the proposer does not skip the tx for over-declaring gas. let fullGasSettings; if (forEstimation) { - // Estimation deliberately uses very high internal limits and skips tx validation, so we do not - // validate against the network admission limit here. + // Estimation deliberately uses very high internal limits, which the node exempts from gas-limit + // admission during simulation, so we do not validate against the network admission limit here. fullGasSettings = GasSettings.forEstimation(gasSettingsOverrides); } else { const maxTxGasLimits = await this.getMaxTxGasLimits(); // If the caller declared explicit gas limits, reject them up front when they exceed the network's - // per-tx admission limit (mirroring the node's GasLimitsValidator). Otherwise fill in the limit. + // per-tx admission limit (mirroring the node's MaxGasLimitsValidator). Otherwise fill in the limit. if (gasSettingsOverrides.gasLimits) { assertGasLimitsWithinNetworkLimits(gasSettingsOverrides.gasLimits, maxTxGasLimits); }