diff --git a/yarn-project/p2p/src/client/factory.ts b/yarn-project/p2p/src/client/factory.ts index da52e261ceb9..aa154872dada 100644 --- a/yarn-project/p2p/src/client/factory.ts +++ b/yarn-project/p2p/src/client/factory.ts @@ -17,7 +17,11 @@ import { AttestationPool, type AttestationPoolApi } from '../mem_pools/attestati import type { MemPools } from '../mem_pools/interface.js'; import type { TxPoolV2 } from '../mem_pools/tx_pool_v2/interfaces.js'; import { AztecKVTxPoolV2 } from '../mem_pools/tx_pool_v2/tx_pool_v2.js'; -import { createTxValidatorForTransactionsEnteringPendingTxPool } from '../msg_validators/index.js'; +import { + createCheckAllowedSetupCalls, + createTxValidatorForTransactionsEnteringPendingTxPool, + getDefaultAllowedSetupFunctions, +} from '../msg_validators/index.js'; import { DummyP2PService } from '../services/dummy_service.js'; import { LibP2PService } from '../services/index.js'; import { createFileStoreTxSources } from '../services/tx_collection/file_store_tx_source.js'; @@ -75,6 +79,33 @@ export async function createP2PClient( const rollupAddress = inputConfig.l1Contracts.rollupAddress.toString().toLowerCase().replace(/^0x/, ''); const txFileStoreBasePath = `aztec-${inputConfig.l1ChainId}-${inputConfig.rollupVersion}-0x${rollupAddress}`; + const allowedInSetup = [ + ...(await getDefaultAllowedSetupFunctions()), + ...(inputConfig.txPublicSetupAllowListExtend ?? []), + ]; + const checkAllowedSetupCalls = createCheckAllowedSetupCalls( + archiver, + allowedInSetup, + () => epochCache.getEpochAndSlotInNextL1Slot().ts, + ); + + const createTxValidator = async () => { + // We accept transactions if they are not expired by the next slot and block number (checked based on the ExpirationTimestamp field) + const currentBlockNumber = await archiver.getBlockNumber(); + const { ts: nextSlotTimestamp } = epochCache.getEpochAndSlotInNextL1Slot(); + const l1Constants = await archiver.getL1Constants(); + return createTxValidatorForTransactionsEnteringPendingTxPool( + worldStateSynchronizer, + nextSlotTimestamp, + BlockNumber(currentBlockNumber + 1), + { + rollupManaLimit: l1Constants.rollupManaLimit, + maxBlockL2Gas: config.validateMaxL2BlockGas, + maxBlockDAGas: config.validateMaxDABlockGas, + }, + ); + }; + const txPool = deps.txPool ?? new AztecKVTxPoolV2( @@ -83,22 +114,8 @@ export async function createP2PClient( { l2BlockSource: archiver, worldStateSynchronizer, - createTxValidator: async () => { - // We accept transactions if they are not expired by the next slot and block number (checked based on the ExpirationTimestamp field) - const currentBlockNumber = await archiver.getBlockNumber(); - const { ts: nextSlotTimestamp } = epochCache.getEpochAndSlotInNextL1Slot(); - const l1Constants = await archiver.getL1Constants(); - return createTxValidatorForTransactionsEnteringPendingTxPool( - worldStateSynchronizer, - nextSlotTimestamp, - BlockNumber(currentBlockNumber + 1), - { - rollupManaLimit: l1Constants.rollupManaLimit, - maxBlockL2Gas: config.validateMaxL2BlockGas, - maxBlockDAGas: config.validateMaxDABlockGas, - }, - ); - }, + checkAllowedSetupCalls, + createTxValidator, }, telemetry, { diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts index a78a70482024..cee420a796b4 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts @@ -72,6 +72,8 @@ export type TxPoolV2Dependencies = { worldStateSynchronizer: WorldStateSynchronizer; /** Factory that creates a validator for re-validating pool transactions using metadata */ createTxValidator: () => Promise>; + /** Checks whether a tx's setup-phase calls are on the allow list. Precomputed at receipt time. */ + checkAllowedSetupCalls: (tx: Tx) => Promise; }; /** diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_metadata.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_metadata.ts index 1d2f434fb2fb..44ad665446c2 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_metadata.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_metadata.ts @@ -67,6 +67,9 @@ export type TxMetaData = { /** Timestamp by which the transaction must be included (for expiration checks) */ readonly expirationTimestamp: bigint; + /** Whether the tx's setup-phase calls pass the allow list check. Computed at receipt time. */ + readonly allowedSetupCalls: boolean; + /** Validator-compatible data, providing the same access patterns as Tx.data */ readonly data: TxMetaValidationData; @@ -84,8 +87,12 @@ export type TxState = 'pending' | 'protected' | 'mined' | 'deleted'; * Builds TxMetaData from a full Tx object. * Extracts all relevant fields for efficient in-memory storage and querying. * Fr values are captured in closures for zero-cost re-validation. + * + * @param allowedSetupCalls - Whether the tx's setup-phase calls pass the allow list. + * For gossip/RPC txs this is always `true` (already validated by PhasesTxValidator). + * For req/resp txs this should be computed by the caller using the phases validator. */ -export async function buildTxMetaData(tx: Tx): Promise { +export async function buildTxMetaData(tx: Tx, allowedSetupCalls: boolean = true): Promise { const txHashObj = tx.getTxHash(); const txHash = txHashObj.toString(); const txHashBigInt = txHashObj.toBigInt(); @@ -112,6 +119,7 @@ export async function buildTxMetaData(tx: Tx): Promise { feeLimit, nullifiers, expirationTimestamp, + allowedSetupCalls, receivedAt: 0, estimatedSizeBytes, data: { @@ -304,6 +312,7 @@ export function stubTxMetaData( nullifiers?: string[]; expirationTimestamp?: bigint; anchorBlockHeaderHash?: string; + allowedSetupCalls?: boolean; } = {}, ): TxMetaData { const txHashBigInt = Fr.fromHexString(txHash).toBigInt(); @@ -320,6 +329,7 @@ export function stubTxMetaData( feeLimit: overrides.feeLimit ?? 100n, nullifiers: overrides.nullifiers ?? [`0x${normalizedTxHash.slice(2)}null1`], expirationTimestamp, + allowedSetupCalls: overrides.allowedSetupCalls ?? true, receivedAt: 0, estimatedSizeBytes: 0, data: stubTxMetaValidationData({ expirationTimestamp }), diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts index c0d75aeec210..ce92f3c817fe 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts @@ -87,6 +87,7 @@ describe('TxPoolV2 Compatibility Tests', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool.start(); }); @@ -327,6 +328,7 @@ describe('TxPoolV2 Compatibility Tests', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }, undefined, // telemetry { archivedTxLimit: 2 }, @@ -368,6 +370,7 @@ describe('TxPoolV2 Compatibility Tests', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }, undefined, // telemetry { maxPendingTxCount: 3 }, @@ -424,6 +427,7 @@ describe('TxPoolV2 Compatibility Tests', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }, undefined, // telemetry { maxPendingTxCount: 10 }, @@ -467,6 +471,7 @@ describe('TxPoolV2 Compatibility Tests', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }, undefined, // telemetry { maxPendingTxCount: 10 }, @@ -639,6 +644,7 @@ describe('TxPoolV2 Compatibility Tests', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }, undefined, // telemetry { maxPendingTxCount: 0 }, 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 1d41cc370bf1..e962d937829b 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 @@ -27,7 +27,9 @@ import { BlockHeader, GlobalVariables, type Tx, TxEffect, TxHash, type TxValidat import { type MockProxy, mock } from 'jest-mock-extended'; +import { AggregateTxValidator } from '../../msg_validators/tx_validator/aggregate_tx_validator.js'; import { GasLimitsValidator } 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'; @@ -133,6 +135,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool.start(); @@ -542,6 +545,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(rejectingValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await rejectingPool.start(); }); @@ -657,6 +661,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(new GasLimitsValidator()), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await gasPool.start(); }); @@ -1292,6 +1297,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(mockValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await poolWithValidator.start(); }); @@ -2000,6 +2006,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(mockValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await poolWithValidator.start(); }); @@ -2133,6 +2140,164 @@ describe('TxPoolV2', () => { expect(await poolWithValidator.getPendingTxCount()).toBe(2); }); + it('prepareForSlot deletes tx with disallowed setup calls when unprotecting', async () => { + // Create a pool where checkAllowedSetupCalls returns false + const disallowStore = await openTmpStore('p2p-disallow'); + const disallowArchiveStore = await openTmpStore('archive-disallow'); + const setupValidator = new AggregateTxValidator(mockValidator, new AllowedSetupCallsMetaValidator()); + const disallowPool = new AztecKVTxPoolV2(disallowStore, disallowArchiveStore, { + l2BlockSource: mockL2BlockSource, + worldStateSynchronizer: mockWorldState, + createTxValidator: () => Promise.resolve(setupValidator), + checkAllowedSetupCalls: () => Promise.resolve(false), + }); + await disallowPool.start(); + + const tx = await mockTx(1); + + // Add as protected - checkAllowedSetupCalls returns false, so metadata.allowedSetupCalls = false + await disallowPool.addProtectedTxs([tx], slot1Header); + expect(await disallowPool.getTxStatus(tx.getTxHash())).toBe('protected'); + + // Unprotect - AllowedSetupCallsMetaValidator should reject since allowedSetupCalls is false + await disallowPool.prepareForSlot(SlotNumber(2)); + + expect(await disallowPool.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await disallowPool.getPendingTxCount()).toBe(0); + + await disallowPool.stop(); + await disallowStore.delete(); + await disallowArchiveStore.delete(); + }); + + it('prepareForSlot keeps tx with allowed setup calls when unprotecting', async () => { + const tx = await mockTx(1); + + // poolWithValidator has checkAllowedSetupCalls returning true (default) + await poolWithValidator.addProtectedTxs([tx], slot1Header); + + await poolWithValidator.prepareForSlot(SlotNumber(2)); + + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('pending'); + expect(await poolWithValidator.getPendingTxCount()).toBe(1); + }); + + it('prepareForSlot handles mixed allowed/disallowed setup calls on unprotect', async () => { + // Create a pool where checkAllowedSetupCalls returns false for specific txs + const mixedStore = await openTmpStore('p2p-mixed'); + const mixedArchiveStore = await openTmpStore('archive-mixed'); + + const txAllowed = await mockTx(1); + const txDisallowed = await mockTx(2); + const txAlsoAllowed = await mockTx(3); + const disallowedHash = txDisallowed.getTxHash().toString(); + + const setupValidator = new AggregateTxValidator(mockValidator, new AllowedSetupCallsMetaValidator()); + const mixedPool = new AztecKVTxPoolV2(mixedStore, mixedArchiveStore, { + l2BlockSource: mockL2BlockSource, + worldStateSynchronizer: mockWorldState, + createTxValidator: () => Promise.resolve(setupValidator), + // Only disallow setup calls for the second tx + checkAllowedSetupCalls: tx => Promise.resolve(tx.getTxHash().toString() !== disallowedHash), + }); + await mixedPool.start(); + + // Add all as protected + await mixedPool.addProtectedTxs([txAllowed, txDisallowed, txAlsoAllowed], slot1Header); + expect(await mixedPool.getTxStatus(txAllowed.getTxHash())).toBe('protected'); + expect(await mixedPool.getTxStatus(txDisallowed.getTxHash())).toBe('protected'); + expect(await mixedPool.getTxStatus(txAlsoAllowed.getTxHash())).toBe('protected'); + + // Unprotect all - only txDisallowed should be deleted + await mixedPool.prepareForSlot(SlotNumber(2)); + + expect(await mixedPool.getTxStatus(txAllowed.getTxHash())).toBe('pending'); + expect(await mixedPool.getTxStatus(txDisallowed.getTxHash())).toBe('deleted'); + expect(await mixedPool.getTxStatus(txAlsoAllowed.getTxHash())).toBe('pending'); + expect(await mixedPool.getPendingTxCount()).toBe(2); + + await mixedPool.stop(); + await mixedStore.delete(); + await mixedArchiveStore.delete(); + }); + + it('handlePrunedBlocks deletes tx with disallowed setup calls after reload and un-mining', async () => { + db.findLeafIndices.mockResolvedValue([1n]); // Anchor block valid + + // Step 1: Start a pool that allows setup calls, add and mine a tx + const sharedStore = await openTmpStore('p2p-disallow-prune'); + const sharedArchiveStore = await openTmpStore('archive-disallow-prune'); + const pool1 = new AztecKVTxPoolV2(sharedStore, sharedArchiveStore, { + l2BlockSource: mockL2BlockSource, + worldStateSynchronizer: mockWorldState, + createTxValidator: () => Promise.resolve(mockValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), + }); + await pool1.start(); + + const tx = await mockTx(1); + await pool1.addPendingTxs([tx]); + await pool1.handleMinedBlock(makeBlock([tx], slot1Header)); + expect(await pool1.getTxStatus(tx.getTxHash())).toBe('mined'); + await pool1.stop(); + + // Step 2: Restart pool with checkAllowedSetupCalls returning false. + // On reload, tx gets allowedSetupCalls=false in metadata. + const setupValidator = new AggregateTxValidator(mockValidator, new AllowedSetupCallsMetaValidator()); + const pool2 = new AztecKVTxPoolV2(sharedStore, sharedArchiveStore, { + l2BlockSource: mockL2BlockSource, + worldStateSynchronizer: mockWorldState, + createTxValidator: () => Promise.resolve(setupValidator), + checkAllowedSetupCalls: () => Promise.resolve(false), + }); + // Mock getTxEffect to return the mined tx so it stays mined on reload + mockL2BlockSource.getTxEffect.mockResolvedValue({ + txEffect: TxEffect.empty(), + l2BlockNumber: 1, + l2BlockHash: '0x1', + } as any); + await pool2.start(); + expect(await pool2.getTxStatus(tx.getTxHash())).toBe('mined'); + + // Restore original mock + mockL2BlockSource.getTxEffect.mockResolvedValue(undefined); + + // Step 3: Prune - tx gets un-mined and revalidated. + // AllowedSetupCallsMetaValidator rejects it since allowedSetupCalls=false. + await pool2.handlePrunedBlocks(block0Id); + + expect(await pool2.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await pool2.getPendingTxCount()).toBe(0); + + await pool2.stop(); + await sharedStore.delete(); + await sharedArchiveStore.delete(); + }); + + it('pending tx via addPendingTxs has allowedSetupCalls=true regardless of checkAllowedSetupCalls', async () => { + // Create a pool where checkAllowedSetupCalls always returns false + const disallowStore = await openTmpStore('p2p-disallow-pending'); + const disallowArchiveStore = await openTmpStore('archive-disallow-pending'); + const disallowPool = new AztecKVTxPoolV2(disallowStore, disallowArchiveStore, { + l2BlockSource: mockL2BlockSource, + worldStateSynchronizer: mockWorldState, + createTxValidator: () => Promise.resolve(mockValidator), + checkAllowedSetupCalls: () => Promise.resolve(false), + }); + await disallowPool.start(); + + const tx = await mockTx(1); + + // Add via addPendingTxs - this does NOT call checkAllowedSetupCalls, + // so allowedSetupCalls defaults to true + await disallowPool.addPendingTxs([tx]); + expect(disallowPool.getPoolReadAccess().getMetadata(tx.getTxHash().toString())?.allowedSetupCalls).toBe(true); + + await disallowPool.stop(); + await disallowStore.delete(); + await disallowArchiveStore.delete(); + }); + it('validation runs before nullifier conflict check in prepareForSlot', async () => { const txPending = await mockPublicTx(1, 5); const txProtected = await mockPublicTx(2, 10); @@ -2181,6 +2346,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(mockValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await poolWithValidator.start(); }); @@ -4437,6 +4603,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool1.start(); @@ -4462,6 +4629,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool2.start(); @@ -4490,6 +4658,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }, undefined, // telemetry { maxPendingTxCount: 100 }, @@ -4516,6 +4685,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }, undefined, // telemetry { maxPendingTxCount: 3 }, @@ -4548,6 +4718,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool1.start(); @@ -4565,6 +4736,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool2.start(); @@ -4594,6 +4766,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool1.start(); @@ -4628,6 +4801,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }, undefined, // telemetry { maxPendingTxCount: 0 }, // No pending txs allowed @@ -4656,6 +4830,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool1.start(); @@ -4682,6 +4857,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(selectiveValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool2.start(); @@ -4707,6 +4883,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool1.start(); @@ -4732,6 +4909,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool2.start(); @@ -4747,6 +4925,44 @@ describe('TxPoolV2', () => { await testArchiveStore.delete(); } }); + + it('hydration recomputes allowedSetupCalls from checkAllowedSetupCalls', async () => { + const testStore = await openTmpStore('p2p-hydration-setup-test'); + const testArchiveStore = await openTmpStore('archive-hydration-setup-test'); + + try { + // Add a tx with allowedSetupCalls=true (default for addPendingTxs) + const pool1 = new AztecKVTxPoolV2(testStore, testArchiveStore, { + l2BlockSource: mockL2BlockSource, + worldStateSynchronizer: mockWorldState, + createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), + }); + await pool1.start(); + + const tx = await mockTx(1); + await pool1.addPendingTxs([tx]); + const txHashStr = tx.getTxHash().toString(); + expect(pool1.getPoolReadAccess().getMetadata(txHashStr)?.allowedSetupCalls).toBe(true); + await pool1.stop(); + + // Restart with checkAllowedSetupCalls returning false — metadata should reflect it + const pool2 = new AztecKVTxPoolV2(testStore, testArchiveStore, { + l2BlockSource: mockL2BlockSource, + worldStateSynchronizer: mockWorldState, + createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(false), + }); + await pool2.start(); + + expect(pool2.getPoolReadAccess().getMetadata(txHashStr)?.allowedSetupCalls).toBe(false); + + await pool2.stop(); + } finally { + await testStore.delete(); + await testArchiveStore.delete(); + } + }); }); describe('late arrival scenarios', () => { @@ -4926,6 +5142,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }, undefined, // telemetry { minTxPoolAgeMs: 2_000 }, @@ -5024,6 +5241,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }, undefined, { minTxPoolAgeMs: 2_000 }, @@ -5260,6 +5478,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(mockValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await poolWithValidator.start(); @@ -5326,6 +5545,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool1.start(); @@ -5360,6 +5580,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool2.start(); @@ -5394,6 +5615,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(throwingValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool1.start(); @@ -5419,6 +5641,7 @@ describe('TxPoolV2', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool2.start(); diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts index 69b91bd95c52..0902ebf767da 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts @@ -11,7 +11,14 @@ import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-clien import EventEmitter from 'node:events'; import { PoolInstrumentation, PoolName } from '../instrumentation.js'; -import type { AddTxsResult, TxPoolV2, TxPoolV2Config, TxPoolV2Dependencies, TxPoolV2Events } from './interfaces.js'; +import type { + AddTxsResult, + PoolReadAccess, + TxPoolV2, + TxPoolV2Config, + TxPoolV2Dependencies, + TxPoolV2Events, +} from './interfaces.js'; import type { TxState } from './tx_metadata.js'; import { TxPoolV2Impl } from './tx_pool_v2_impl.js'; @@ -165,6 +172,11 @@ export class AztecKVTxPoolV2 extends (EventEmitter as new () => TypedEventEmitte return this.#queue.put(() => Promise.resolve(this.#impl.getLowestPriorityPending(limit))); } + /** Returns read-only access to the pool. Used for testing. */ + getPoolReadAccess(): PoolReadAccess { + return this.#impl.getPoolReadAccess(); + } + // === Configuration === updateConfig(config: Partial): Promise { diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_bench.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_bench.test.ts index 8e196bcf3cfc..3baeaf320601 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_bench.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_bench.test.ts @@ -141,6 +141,7 @@ describe('TxPoolV2: benchmarks', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool.start(); const cleanup = async () => { @@ -495,6 +496,7 @@ describe('TxPoolV2: benchmarks', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); await pool1.start(); @@ -512,6 +514,7 @@ describe('TxPoolV2: benchmarks', () => { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, createTxValidator: () => Promise.resolve(alwaysValidValidator), + checkAllowedSetupCalls: () => Promise.resolve(true), }); const startTime = performance.now(); diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts index 28396e4cde1f..b43b2199a77f 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts @@ -62,6 +62,7 @@ export class TxPoolV2Impl { #l2BlockSource: L2BlockSource; #worldStateSynchronizer: WorldStateSynchronizer; #createTxValidator: TxPoolV2Dependencies['createTxValidator']; + #checkAllowedSetupCalls: TxPoolV2Dependencies['checkAllowedSetupCalls']; // === In-Memory Indices === #indices: TxPoolIndices = new TxPoolIndices(); @@ -93,6 +94,7 @@ export class TxPoolV2Impl { this.#l2BlockSource = deps.l2BlockSource; this.#worldStateSynchronizer = deps.worldStateSynchronizer; this.#createTxValidator = deps.createTxValidator; + this.#checkAllowedSetupCalls = deps.checkAllowedSetupCalls; this.#config = { ...DEFAULT_TX_POOL_V2_CONFIG, ...config }; this.#archive = new TxArchive(archiveStore, this.#config.archivedTxLimit, log); @@ -375,20 +377,25 @@ export class TxPoolV2Impl { async addProtectedTxs(txs: Tx[], block: BlockHeader, opts: { source?: string }): Promise { const slotNumber = block.globalVariables.slotNumber; + // Precompute setup-call allow-list flags outside the store transaction + const allowedFlags = await Promise.all(txs.map(tx => this.#checkAllowedSetupCalls(tx))); + await this.#store.transactionAsync(async () => { - for (const tx of txs) { + for (let i = 0; i < txs.length; i++) { + const tx = txs[i]; const txHash = tx.getTxHash(); const txHashStr = txHash.toString(); const isNew = !this.#indices.has(txHashStr); const minedBlockId = await this.#getMinedBlockId(txHash); if (isNew) { + const meta = await buildTxMetaData(tx, allowedFlags[i]); // New tx - add as mined or protected (callback emitted by #addTx) if (minedBlockId) { - await this.#addTx(tx, { mined: minedBlockId }, opts); + await this.#addTx(tx, { mined: minedBlockId }, opts, meta); this.#indices.setProtection(txHashStr, slotNumber); } else { - await this.#addTx(tx, { protected: slotNumber }, opts); + await this.#addTx(tx, { protected: slotNumber }, opts, meta); } } else { // Existing tx - update protection and mined status @@ -983,7 +990,8 @@ export class TxPoolV2Impl { try { const tx = Tx.fromBuffer(buffer); - const meta = await buildTxMetaData(tx); + const allowedSetupCalls = await this.#checkAllowedSetupCalls(tx); + const meta = await buildTxMetaData(tx, allowedSetupCalls); loaded.push({ tx, meta }); } catch (err) { this.#log.warn(`Failed to deserialize tx ${txHashStr}, deleting`, { err }); 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 91087c1559c5..8e0c6b7de274 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/README.md +++ b/yarn-project/p2p/src/msg_validators/tx_validator/README.md @@ -75,10 +75,12 @@ 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, Timestamp +- DoubleSpend, BlockHeader, GasLimits, Timestamp, AllowedSetupCalls Operates on `TxMetaData` (pre-built by the pool) rather than full `Tx` objects. +The `AllowedSetupCallsMetaValidator` checks a precomputed boolean flag (`TxMetaData.allowedSetupCalls`) rather than re-running the full `PhasesTxValidator`. This flag is computed by `createCheckAllowedSetupCalls` when the tx first enters the pool (via `addProtectedTxs` or startup hydration), so the pool migration validator can reject txs with disallowed setup calls without needing the full `Tx` object or its dependencies. + ## Individual Validators | Validator | What it checks | Benchmarked verification duration | @@ -92,6 +94,7 @@ Operates on `TxMetaData` (pre-built by the pool) rather than full `Tx` objects. | `GasTxValidator` | Gas limits are within bounds (delegates to `GasLimitsValidator`), max fee per gas meets current block fees, 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 | | `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` | — | | `BlockHeaderTxValidator` | Transaction's anchor block hash exists in the archive tree | 98.88 us | | `TxProofValidator` | Client proof verifies correctly | ~250ms | @@ -108,6 +111,7 @@ Operates on `TxMetaData` (pre-built by the pool) rather than full `Tx` objects. | Gas (balance + limits) | Stage 1 | Optional* | — | Yes | — | | GasLimits (standalone) | — | — | — | — | Yes | | Phases | Stage 1 | Yes | — | Yes | — | +| AllowedSetupCalls | — | — | — | — | Yes | | BlockHeader | Stage 1 | Yes | — | Yes | Yes | | Proof | Stage 2 | Optional** | Yes | — | — | 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 7cb2e794566c..92ede7a4afaa 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 @@ -27,7 +27,7 @@ import { } from './factory.js'; import { GasLimitsValidator, GasTxValidator } from './gas_validator.js'; import { MetadataTxValidator } from './metadata_validator.js'; -import { PhasesTxValidator } from './phases_validator.js'; +import { AllowedSetupCallsMetaValidator, PhasesTxValidator } from './phases_validator.js'; import { SizeTxValidator } from './size_validator.js'; import { TimestampTxValidator } from './timestamp_validator.js'; import { TxPermittedValidator } from './tx_permitted_validator.js'; @@ -312,6 +312,7 @@ describe('Validator factory functions', () => { TimestampTxValidator.name, DoubleSpendTxValidator.name, BlockHeaderTxValidator.name, + AllowedSetupCallsMetaValidator.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 9acd13d88c3d..849f105b46be 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/factory.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/factory.ts @@ -58,7 +58,7 @@ import { DoubleSpendTxValidator, type NullifierSource } from './double_spend_val import { GasLimitsValidator, GasTxValidator } from './gas_validator.js'; import { MetadataTxValidator } from './metadata_validator.js'; import { NullifierCache } from './nullifier_cache.js'; -import { PhasesTxValidator } from './phases_validator.js'; +import { AllowedSetupCallsMetaValidator, PhasesTxValidator } from './phases_validator.js'; import { SizeTxValidator } from './size_validator.js'; import { TimestampTxValidator } from './timestamp_validator.js'; import { TxPermittedValidator } from './tx_permitted_validator.js'; @@ -436,5 +436,25 @@ export async function createTxValidatorForTransactionsEnteringPendingTxPool( new TimestampTxValidator({ timestamp, blockNumber }, bindings), new DoubleSpendTxValidator(nullifierSource, bindings), new BlockHeaderTxValidator(archiveSource, bindings), + new AllowedSetupCallsMetaValidator(bindings), ); } + +/** + * Creates a function that checks whether a tx's setup-phase calls are on the allow list. + * + * Uses the `PhasesTxValidator` on the full Tx. The result is stored as a boolean + * flag in `TxMetaData.allowedSetupCalls` at receipt time, so the pending pool + * migration validator can check it without needing the full Tx or its dependencies. + */ +export function createCheckAllowedSetupCalls( + contractDataSource: ContractDataSource, + setupAllowList: AllowedElement[], + getTimestamp: () => UInt64, +): (tx: Tx) => Promise { + return async (tx: Tx) => { + const validator = new PhasesTxValidator(contractDataSource, setupAllowList, getTimestamp()); + const result = await validator.validateTx(tx); + return result.result === 'valid'; + }; +} 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 9de8f7ef19e2..69d5bd9f0cab 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 @@ -141,3 +141,33 @@ export class PhasesTxValidator implements TxValidator { return TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED; } } + +/** Structural interface for the allowed-setup-calls flag check. */ +export interface HasAllowedSetupCallsData { + txHash: { toString(): string }; + allowedSetupCalls: boolean; +} + +/** + * Validates that a transaction's setup-phase calls were allowed at receipt time. + * + * Checks the precomputed `allowedSetupCalls` flag on TxMetaData. The flag is + * computed by running the PhasesTxValidator on the full Tx when it first enters + * the pool. This lightweight validator is used during pending pool migration to + * reject txs whose setup calls are not on the allow list. + */ +export class AllowedSetupCallsMetaValidator implements TxValidator { + #log: Logger; + + constructor(bindings?: LoggerBindings) { + this.#log = createLogger('sequencer:tx_validator:tx_phases_meta', bindings); + } + + validateTx(tx: T): Promise { + if (!tx.allowedSetupCalls) { + this.#log.verbose(`Rejecting tx ${tx.txHash} because its setup calls are not on the allow list`); + return Promise.resolve({ result: 'invalid', reason: [TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED] }); + } + return Promise.resolve({ result: 'valid' }); + } +}