Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions yarn-project/aztec-node/src/aztec-node/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ import {
HashedValues,
MinedTxReceipt,
PendingTxReceipt,
TX_ERROR_CALLDATA_COUNT_MISMATCH,
TX_ERROR_DUPLICATE_NULLIFIER_IN_TX,
TX_ERROR_INCORRECT_L1_CHAIN_ID,
TX_ERROR_INCORRECT_ROLLUP_VERSION,
Expand Down Expand Up @@ -361,9 +360,11 @@ describe('aztec node', () => {
newPublicFunctionCalldata,
);
await tx.recomputeHash();
// The tx also trips the calldata-count check, but the RPC validator stops at the first failure and the
// size check runs ahead of the data check.
expect(await node.isValidTx(tx)).toEqual({
result: 'invalid',
reason: [TX_ERROR_SIZE_ABOVE_LIMIT, TX_ERROR_CALLDATA_COUNT_MISMATCH],
reason: [TX_ERROR_SIZE_ABOVE_LIMIT],
});
});

Expand Down
14 changes: 12 additions & 2 deletions yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -729,7 +733,13 @@ describe('TxPoolV2', () => {
gasPool = new AztecKVTxPoolV2(gasStore, gasArchiveStore, {
l2BlockSource: mockL2BlockSource,
worldStateSynchronizer: mockWorldState,
createTxValidator: () => Promise.resolve(new GasLimitsValidator<TxMetaData>()),
createTxValidator: () =>
Promise.resolve(
new AggregateTxValidator<TxMetaData>(
new MinGasLimitsValidator<TxMetaData>(),
new MaxGasLimitsValidator<TxMetaData>(),
),
),
checkAllowedSetupCalls: () => Promise.resolve(true),
blockMinFeesProvider: { getCurrentMinFees: () => Promise.resolve(GasFees.empty()) },
});
Expand Down
31 changes: 22 additions & 9 deletions yarn-project/p2p/src/msg_validators/tx_validator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Transactions enter the system through different paths. **Unsolicited** transacti

When solicited transactions fail to be mined, they may be migrated to the pending pool. At that point, the pool runs the state-dependent checks that were skipped on initial receipt.

### Failure reporting

`AggregateTxValidator` runs its validators in order. By default it runs all of them and returns every failure reason, which is what block building wants: a rejection there is reported as part of block validation, and the full reason list is the diagnosis.

The RPC, req/resp and pool-migration factories build the fail-fast variant, `AggregateTxValidator.stoppingAtFirstFailure`, so a rejected tx does not pay for the validators behind the one that rejected it — a tx over the gas-limit ceiling never reaches the fee-payer balance read, and a malformed tx never reaches proof verification. The cost is that the caller sees one reason rather than all of them, and that a validator only runs once everything ahead of it passes.

Gossip uses neither: `LibP2PService.runValidations` runs every stage-1 validator concurrently, because the peer penalty is the harshest severity across all failures.

## Entry Points

### 1. Gossip (libp2p pubsub)
Expand All @@ -19,7 +27,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 |
Expand All @@ -34,6 +42,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)

Expand All @@ -56,7 +66,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)
Expand All @@ -75,7 +85,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.

Expand All @@ -91,8 +101,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` | — |
Expand All @@ -109,18 +120,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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ describe('AggregateTxValidator', () => {
await expect(agg.validateTx(txs[4])).resolves.toEqual({ result: 'invalid', reason: ['Denied', 'Denied'] });
});

describe('stoppingAtFirstFailure', () => {
it('returns only the first failure and leaves the later validators unrun', async () => {
const txs = await Promise.all([mockTx(0), mockTx(1)]);
const second = new CountingTxDenyList([txs[0].getTxHash()]);
const agg = AggregateTxValidator.stoppingAtFirstFailure(new TxDenyList([txs[0].getTxHash()]), second);

await expect(agg.validateTx(txs[0])).resolves.toEqual({ result: 'invalid', reason: ['Denied'] });
expect(second.calls).toEqual(0);

await expect(agg.validateTx(txs[1])).resolves.toEqual({ result: 'valid' });
expect(second.calls).toEqual(1);
});
});

class TxDenyList implements TxValidator<AnyTx> {
denyList: Set<string>;

Expand All @@ -33,4 +47,14 @@ describe('AggregateTxValidator', () => {
return Promise.resolve({ result: 'valid' });
}
}

/** A deny list that records how many times it was consulted. */
class CountingTxDenyList extends TxDenyList {
public calls = 0;

public override validateTx(tx: AnyTx): Promise<TxValidationResult> {
this.calls++;
return super.validateTx(tx);
}
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { TxValidationResult, TxValidator } from '@aztec/stdlib/tx';

export class AggregateTxValidator<T> implements TxValidator<T> {
readonly validators: TxValidator<T>[];
#stopAtFirstFailure = false;

constructor(...validators: TxValidator<T>[]) {
if (validators.length === 0) {
throw new Error('At least one validator must be provided');
Expand All @@ -10,12 +12,30 @@ export class AggregateTxValidator<T> implements TxValidator<T> {
this.validators = validators;
}

/**
* Builds an aggregate that returns as soon as a validator rejects, leaving the rest unrun.
*
* The trade-off is that failure reasons are no longer exhaustive: a caller sees only the first one in
* validator order. Use this where rejection is terminal and the remaining validators would do avoidable
* work (world-state reads, proof verification), and order the validators cheapest-first so the saving is
* real. Use the plain constructor where every reason is wanted, such as when they are reported back for
* diagnosis.
*/
static stoppingAtFirstFailure<T>(...validators: TxValidator<T>[]): AggregateTxValidator<T> {
const aggregate = new AggregateTxValidator<T>(...validators);
aggregate.#stopAtFirstFailure = true;
return aggregate;
}

async validateTx(tx: T): Promise<TxValidationResult> {
const reasons: string[] = [];
for (const validator of this.validators) {
const result = await validator.validateTx(tx);
if (result.result === 'invalid') {
reasons.push(...result.reason);
if (this.#stopAtFirstFailure) {
break;
}
}
}
return reasons.length > 0 ? { result: 'invalid', reason: reasons } : { result: 'valid' };
Expand Down
Loading