diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index a79abb677d51..fa993c710498 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -15,6 +15,7 @@ import { compactArray } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { Fr } from '@aztec/foundation/fields'; import { type Logger, createLogger } from '@aztec/foundation/log'; +import { SerialQueue } from '@aztec/foundation/queue'; import { DateProvider, Timer } from '@aztec/foundation/timer'; import { SiblingPath } from '@aztec/foundation/trees'; import type { AztecKVStore } from '@aztec/kv-store'; @@ -101,6 +102,9 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { private packageVersion: string; private metrics: NodeMetrics; + // Serial queue to ensure that we only send one tx at a time + private txQueue: SerialQueue = new SerialQueue(); + public readonly tracer: Tracer; constructor( @@ -123,6 +127,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { this.packageVersion = getPackageVersion(); this.metrics = new NodeMetrics(telemetry, 'AztecNodeService'); this.tracer = telemetry.getTracer('AztecNodeService'); + this.txQueue.start(); this.log.info(`Aztec Node version: ${this.packageVersion}`); this.log.info(`Aztec Node started on chain 0x${l1ChainId.toString(16)}`, config.l1Contracts); @@ -419,6 +424,16 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { * @param tx - The transaction to be submitted. */ public async sendTx(tx: Tx) { + await this.txQueue + .put(async () => { + await this.#sendTx(tx); + }) + .catch(error => { + this.log.error(`Error sending tx`, { error }); + }); + } + + async #sendTx(tx: Tx) { const timer = new Timer(); const txHash = (await tx.getTxHash()).toString(); @@ -464,6 +479,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { */ public async stop() { this.log.info(`Stopping`); + await this.txQueue.end(); await this.sequencer?.stop(); await this.p2pClient.stop(); await this.worldStateSynchronizer.stop(); diff --git a/yarn-project/aztec.js/src/api/contract.ts b/yarn-project/aztec.js/src/api/contract.ts index b7461a99d786..06b41925e63e 100644 --- a/yarn-project/aztec.js/src/api/contract.ts +++ b/yarn-project/aztec.js/src/api/contract.ts @@ -40,6 +40,7 @@ export { ContractFunctionInteraction, type SendMethodOptions } from '../contract export { TxProfileResult } from '@aztec/stdlib/tx'; export { DefaultWaitOpts, SentTx, type WaitOpts } from '../contract/sent_tx.js'; +export { ProvenTx } from '../contract/proven_tx.js'; export { ContractBase, type ContractMethod, diff --git a/yarn-project/bb-prover/src/bb/execute.ts b/yarn-project/bb-prover/src/bb/execute.ts index d3dc854a72b1..2830059862ef 100644 --- a/yarn-project/bb-prover/src/bb/execute.ts +++ b/yarn-project/bb-prover/src/bb/execute.ts @@ -62,6 +62,7 @@ type BBExecResult = { * @param command - The command to execute * @param args - The arguments to pass * @param logger - A log function + * @param timeout - An optional timeout before killing the BB process * @param resultParser - An optional handler for detecting success or failure * @returns The completed partial witness outputted from the circuit */ @@ -70,6 +71,7 @@ export function executeBB( command: string, args: string[], logger: LogFn, + timeout?: number, resultParser = (code: number) => code === 0, ): Promise { return new Promise(resolve => { @@ -80,6 +82,18 @@ export function executeBB( const bb = proc.spawn(pathToBB, [command, ...args], { env, }); + + let timeoutId: NodeJS.Timeout | undefined; + if (timeout !== undefined) { + timeoutId = setTimeout(() => { + logger(`BB execution timed out after ${timeout}ms, killing process`); + if (bb.pid) { + bb.kill('SIGKILL'); + } + resolve({ status: BB_RESULT.FAILURE, exitCode: -1, signal: 'TIMEOUT' }); + }, timeout); + } + bb.stdout.on('data', data => { const message = data.toString('utf-8').replace(/\n$/, ''); logger(message); @@ -89,6 +103,9 @@ export function executeBB( logger(message); }); bb.on('close', (exitCode: number, signal?: string) => { + if (timeoutId) { + clearTimeout(timeoutId); + } if (resultParser(exitCode)) { resolve({ status: BB_RESULT.SUCCESS, exitCode, signal }); } else { @@ -604,7 +621,8 @@ export async function verifyClientIvcProof( const args = ['--scheme', 'client_ivc', '-p', proofPath, '-k', keyPath]; const timer = new Timer(); const command = 'verify'; - const result = await executeBB(pathToBB, command, args, log); + const timeout = 1000; // 1s verification timeout for invalid proofs + const result = await executeBB(pathToBB, command, args, log, timeout); const duration = timer.ms(); if (result.status == BB_RESULT.SUCCESS) { return { status: BB_RESULT.SUCCESS, durationMs: duration }; diff --git a/yarn-project/end-to-end/src/e2e_prover/full.test.ts b/yarn-project/end-to-end/src/e2e_prover/full.test.ts index 13f9a0213646..8906407b9ea1 100644 --- a/yarn-project/end-to-end/src/e2e_prover/full.test.ts +++ b/yarn-project/end-to-end/src/e2e_prover/full.test.ts @@ -1,9 +1,13 @@ -import { type AztecAddress, EthAddress, waitForProven } from '@aztec/aztec.js'; +import { type AztecAddress, EthAddress, ProvenTx, Tx, TxReceipt, TxStatus, waitForProven } from '@aztec/aztec.js'; import { RollupContract } from '@aztec/ethereum'; import { parseBooleanEnv } from '@aztec/foundation/config'; import { getTestData, isGenerateTestDataEnabled } from '@aztec/foundation/testing'; import { updateProtocolCircuitSampleInputs } from '@aztec/foundation/testing/files'; +import type { FieldsOf } from '@aztec/foundation/types'; import { FeeJuicePortalAbi, RewardDistributorAbi, TestERC20Abi } from '@aztec/l1-artifacts'; +import { Gas } from '@aztec/stdlib/gas'; +import { PrivateKernelTailCircuitPublicInputs } from '@aztec/stdlib/kernel'; +import { ClientIvcProof } from '@aztec/stdlib/proofs'; import TOML from '@iarna/toml'; import '@jest/globals'; @@ -284,4 +288,82 @@ describe('full_prover', () => { expect(String((results[0] as PromiseRejectedResult).reason)).toMatch(/Tx dropped by P2P node/); expect(String((results[1] as PromiseRejectedResult).reason)).toMatch(/Tx dropped by P2P node/); }); + + it( + 'should prevent large influxes of txs with invalid proofs from causing ddos attacks', + async () => { + if (!REAL_PROOFS) { + t.logger.warn(`Skipping test with fake proofs`); + return; + } + + const NUM_INVALID_TXS = 20; + + // Create and prove a tx + logger.info(`Creating and proving tx`); + const sendAmount = 1n; + const interaction = provenAssets[0].methods.transfer(recipient, sendAmount); + const provenTx = await interaction.prove({ skipPublicSimulation: true }); + const wallet = (provenTx as any).wallet; + + // Verify the tx proof + logger.info(`Verifying the valid tx proof`); + await expect(t.circuitProofVerifier?.verifyProof(provenTx)).resolves.toBeTrue(); + + // Spam node with invalid txs + logger.info(`Submitting ${NUM_INVALID_TXS} invalid transactions to simulate a ddos attack`); + const invalidTxPromises = []; + const data = provenTx.data; + for (let i = 0; i < NUM_INVALID_TXS; i++) { + // Use a random ClientIvcProof and alter the public tx data to generate a unique invalid tx hash + const invalidProvenTx = new ProvenTx( + wallet, + new Tx( + new PrivateKernelTailCircuitPublicInputs( + data.constants, + data.rollupValidationRequests, + data.gasUsed.add(new Gas(i + 1, 0)), + data.feePayer, + data.forPublic, + data.forRollup, + ), + ClientIvcProof.random(), + provenTx.contractClassLogs, + provenTx.enqueuedPublicFunctionCalls, + provenTx.publicTeardownFunctionCall, + ), + ); + + const sentTx = invalidProvenTx.send(); + invalidTxPromises.push(sentTx.wait({ timeout: 10, interval: 0.1, dontThrowOnRevert: true })); + } + + logger.info(`Sending proven tx`); + const validTx = provenTx.send(); + + // Flag the valid transfer on the token simulator + tokenSim.transferPrivate(sender, recipient, sendAmount); + + // Warp to the next epoch + const epoch = await cheatCodes.rollup.getEpoch(); + logger.info(`Advancing from epoch ${epoch} to next epoch`); + await cheatCodes.rollup.advanceToNextEpoch(); + + const results = await Promise.allSettled([...invalidTxPromises, validTx.wait({ timeout: 300, interval: 10 })]); + + // Assert that the large influx of invalid txs are rejected and do not ddos the node + for (let i = 0; i < NUM_INVALID_TXS; i++) { + const invalidTxReceipt = (results[i] as PromiseFulfilledResult>).value; + expect(invalidTxReceipt.status).toBe(TxStatus.DROPPED); + expect(invalidTxReceipt.error).toMatch(/Tx dropped by P2P node/); + } + + // Assert that the valid tx is successfully sent and mined + const validTxReceipt = (results[NUM_INVALID_TXS] as PromiseFulfilledResult>).value; + expect(validTxReceipt.status).toBe(TxStatus.SUCCESS); + + logger.info(`Valid tx was mined and invalid txs were dropped by P2P node`); + }, + TIMEOUT, + ); });