diff --git a/noir-projects/aztec-nr/authwit/src/account.nr b/noir-projects/aztec-nr/authwit/src/account.nr index 0a42a2fdf95..c4cc7976167 100644 --- a/noir-projects/aztec-nr/authwit/src/account.nr +++ b/noir-projects/aztec-nr/authwit/src/account.nr @@ -1,5 +1,6 @@ use dep::aztec::{ - context::PrivateContext, protocol_types::constants::GENERATOR_INDEX__COMBINED_PAYLOAD, + context::PrivateContext, + protocol_types::constants::{GENERATOR_INDEX__COMBINED_PAYLOAD, GENERATOR_INDEX__TX_NULLIFIER}, hash::poseidon2_hash_with_separator }; @@ -34,7 +35,7 @@ impl AccountActions<&mut PrivateContext> { * @param fee_payload The payload that contains the calls to be executed in the setup phase. */ // docs:start:entrypoint - pub fn entrypoint(self, app_payload: AppPayload, fee_payload: FeePayload) { + pub fn entrypoint(self, app_payload: AppPayload, fee_payload: FeePayload, cancellable: bool) { let valid_fn = self.is_valid_impl; let combined_payload_hash = poseidon2_hash_with_separator( @@ -46,6 +47,10 @@ impl AccountActions<&mut PrivateContext> { fee_payload.execute_calls(self.context); self.context.end_setup(); app_payload.execute_calls(self.context); + if cancellable { + let tx_nullifier = poseidon2_hash_with_separator([app_payload.nonce], GENERATOR_INDEX__TX_NULLIFIER); + self.context.push_nullifier(tx_nullifier); + } } // docs:end:entrypoint diff --git a/noir-projects/noir-contracts/aztec b/noir-projects/noir-contracts/aztec deleted file mode 100644 index 29461675fa8..00000000000 --- a/noir-projects/noir-contracts/aztec +++ /dev/null @@ -1 +0,0 @@ -// deploy-protocol-contracts // diff --git a/noir-projects/noir-contracts/contracts/ecdsa_k_account_contract/src/main.nr b/noir-projects/noir-contracts/contracts/ecdsa_k_account_contract/src/main.nr index e1a3db1ff5f..b125e6bbb15 100644 --- a/noir-projects/noir-contracts/contracts/ecdsa_k_account_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/ecdsa_k_account_contract/src/main.nr @@ -36,11 +36,11 @@ contract EcdsaKAccount { storage.public_key.initialize(&mut pub_key_note).emit(encode_and_encrypt_note_with_keys(&mut context, this_keys.ovpk_m, this_keys.ivpk_m, this)); } - // Note: If you globally change the entrypoint signature don't forget to update default_entrypoint.ts + // Note: If you globally change the entrypoint signature don't forget to update account_entrypoint.ts #[aztec(private)] - fn entrypoint(app_payload: AppPayload, fee_payload: FeePayload) { + fn entrypoint(app_payload: AppPayload, fee_payload: FeePayload, cancellable: bool) { let actions = AccountActions::init(&mut context, is_valid_impl); - actions.entrypoint(app_payload, fee_payload); + actions.entrypoint(app_payload, fee_payload, cancellable); } #[aztec(private)] diff --git a/noir-projects/noir-contracts/contracts/ecdsa_r_account_contract/src/main.nr b/noir-projects/noir-contracts/contracts/ecdsa_r_account_contract/src/main.nr index 1b60e8733b0..600184a9904 100644 --- a/noir-projects/noir-contracts/contracts/ecdsa_r_account_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/ecdsa_r_account_contract/src/main.nr @@ -35,11 +35,11 @@ contract EcdsaRAccount { storage.public_key.initialize(&mut pub_key_note).emit(encode_and_encrypt_note_with_keys(&mut context, this_keys.ovpk_m, this_keys.ivpk_m, this)); } - // Note: If you globally change the entrypoint signature don't forget to update default_entrypoint.ts + // Note: If you globally change the entrypoint signature don't forget to update account_entrypoint.ts #[aztec(private)] - fn entrypoint(app_payload: AppPayload, fee_payload: FeePayload) { + fn entrypoint(app_payload: AppPayload, fee_payload: FeePayload, cancellable: bool) { let actions = AccountActions::init(&mut context, is_valid_impl); - actions.entrypoint(app_payload, fee_payload); + actions.entrypoint(app_payload, fee_payload, cancellable); } #[aztec(private)] diff --git a/noir-projects/noir-contracts/contracts/schnorr_account_contract/src/main.nr b/noir-projects/noir-contracts/contracts/schnorr_account_contract/src/main.nr index 1d8d2441476..6535db06dc8 100644 --- a/noir-projects/noir-contracts/contracts/schnorr_account_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/schnorr_account_contract/src/main.nr @@ -39,12 +39,12 @@ contract SchnorrAccount { // docs:end:initialize } - // Note: If you globally change the entrypoint signature don't forget to update default_entrypoint.ts file + // Note: If you globally change the entrypoint signature don't forget to update account_entrypoint.ts file #[aztec(private)] #[aztec(noinitcheck)] - fn entrypoint(app_payload: AppPayload, fee_payload: FeePayload) { + fn entrypoint(app_payload: AppPayload, fee_payload: FeePayload, cancellable: bool) { let actions = AccountActions::init(&mut context, is_valid_impl); - actions.entrypoint(app_payload, fee_payload); + actions.entrypoint(app_payload, fee_payload, cancellable); } #[aztec(private)] diff --git a/noir-projects/noir-contracts/contracts/schnorr_hardcoded_account_contract/src/main.nr b/noir-projects/noir-contracts/contracts/schnorr_hardcoded_account_contract/src/main.nr index 870f45806ac..5268b67fea6 100644 --- a/noir-projects/noir-contracts/contracts/schnorr_hardcoded_account_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/schnorr_hardcoded_account_contract/src/main.nr @@ -12,11 +12,11 @@ contract SchnorrHardcodedAccount { global public_key_x: Field = 0x16b93f4afae55cab8507baeb8e7ab4de80f5ab1e9e1f5149bf8cd0d375451d90; global public_key_y: Field = 0x208d44b36eb6e73b254921134d002da1a90b41131024e3b1d721259182106205; - // Note: If you globally change the entrypoint signature don't forget to update default_entrypoint.ts + // Note: If you globally change the entrypoint signature don't forget to update account_entrypoint.ts #[aztec(private)] - fn entrypoint(app_payload: AppPayload, fee_payload: FeePayload) { + fn entrypoint(app_payload: AppPayload, fee_payload: FeePayload, cancellable: bool) { let actions = AccountActions::init(&mut context, is_valid_impl); - actions.entrypoint(app_payload, fee_payload); + actions.entrypoint(app_payload, fee_payload, cancellable); } #[aztec(private)] diff --git a/noir-projects/noir-contracts/contracts/schnorr_single_key_account_contract/src/main.nr b/noir-projects/noir-contracts/contracts/schnorr_single_key_account_contract/src/main.nr index fbf81afb5fc..19bc66480af 100644 --- a/noir-projects/noir-contracts/contracts/schnorr_single_key_account_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/schnorr_single_key_account_contract/src/main.nr @@ -8,11 +8,11 @@ contract SchnorrSingleKeyAccount { use crate::{util::recover_address, auth_oracle::get_auth_witness}; - // Note: If you globally change the entrypoint signature don't forget to update default_entrypoint.ts + // Note: If you globally change the entrypoint signature don't forget to update account_entrypoint.ts #[aztec(private)] - fn entrypoint(app_payload: AppPayload, fee_payload: FeePayload) { + fn entrypoint(app_payload: AppPayload, fee_payload: FeePayload, cancellable: bool) { let actions = AccountActions::init(&mut context, is_valid_impl); - actions.entrypoint(app_payload, fee_payload); + actions.entrypoint(app_payload, fee_payload, cancellable); } #[aztec(private)] diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr index 2367626358b..95c3c14e1b3 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr @@ -342,6 +342,7 @@ global GENERATOR_INDEX__BLOCK_HASH: u32 = 28; global GENERATOR_INDEX__SIDE_EFFECT: u32 = 29; global GENERATOR_INDEX__FEE_PAYLOAD: u32 = 30; global GENERATOR_INDEX__COMBINED_PAYLOAD: u32 = 31; +global GENERATOR_INDEX__TX_NULLIFIER: u32 = 32; // Indices with size ≤ 16 global GENERATOR_INDEX__TX_REQUEST: u32 = 33; global GENERATOR_INDEX__SIGNATURE_PAYLOAD: u32 = 34; diff --git a/yarn-project/aztec.js/src/account_manager/deploy_account_method.ts b/yarn-project/aztec.js/src/account_manager/deploy_account_method.ts index 80643c7eb1f..71d838e614c 100644 --- a/yarn-project/aztec.js/src/account_manager/deploy_account_method.ts +++ b/yarn-project/aztec.js/src/account_manager/deploy_account_method.ts @@ -57,7 +57,7 @@ export class DeployAccountMethod extends DeployMethod { exec.calls.push({ name: this.#feePaymentArtifact.name, to: address, - args: encodeArguments(this.#feePaymentArtifact, [emptyAppPayload, feePayload]), + args: encodeArguments(this.#feePaymentArtifact, [emptyAppPayload, feePayload, false]), selector: FunctionSelector.fromNameAndParameters( this.#feePaymentArtifact.name, this.#feePaymentArtifact.parameters, diff --git a/yarn-project/aztec.js/src/contract/base_contract_interaction.ts b/yarn-project/aztec.js/src/contract/base_contract_interaction.ts index af130fe08c5..2ac82320c9b 100644 --- a/yarn-project/aztec.js/src/contract/base_contract_interaction.ts +++ b/yarn-project/aztec.js/src/contract/base_contract_interaction.ts @@ -1,5 +1,5 @@ import { type Tx, type TxExecutionRequest } from '@aztec/circuit-types'; -import { GasSettings } from '@aztec/circuits.js'; +import { type Fr, GasSettings } from '@aztec/circuits.js'; import { createDebugLogger } from '@aztec/foundation/log'; import { type Wallet } from '../account/wallet.js'; @@ -18,6 +18,10 @@ export type SendMethodOptions = { fee?: FeeOptions; /** Whether to run an initial simulation of the tx with high gas limit to figure out actual gas settings (will default to true later down the road). */ estimateGas?: boolean; + /** Custom nonce to inject into the app payload of the transaction. Useful when trying to cancel an ongoing transaction by creating a new one with a higher fee */ + nonce?: Fr; + /** Whether the transaction can be cancelled. If true, an extra nullifier will be emitted: H(nonce, GENERATOR_INDEX__TX_NULLIFIER) */ + cancellable?: boolean; }; /** diff --git a/yarn-project/aztec.js/src/contract/contract_function_interaction.ts b/yarn-project/aztec.js/src/contract/contract_function_interaction.ts index a727d043b6d..ca9daf825e5 100644 --- a/yarn-project/aztec.js/src/contract/contract_function_interaction.ts +++ b/yarn-project/aztec.js/src/contract/contract_function_interaction.ts @@ -57,7 +57,12 @@ export class ContractFunctionInteraction extends BaseContractInteraction { if (!this.txRequest) { const calls = [this.request()]; const fee = opts?.estimateGas ? await this.getFeeOptionsFromEstimatedGas({ calls, fee: opts?.fee }) : opts?.fee; - this.txRequest = await this.wallet.createTxExecutionRequest({ calls, fee }); + this.txRequest = await this.wallet.createTxExecutionRequest({ + calls, + fee, + nonce: opts?.nonce, + cancellable: opts?.cancellable, + }); } return this.txRequest; } diff --git a/yarn-project/aztec.js/src/entrypoint/entrypoint.ts b/yarn-project/aztec.js/src/entrypoint/entrypoint.ts index fb60762860e..779cb18b637 100644 --- a/yarn-project/aztec.js/src/entrypoint/entrypoint.ts +++ b/yarn-project/aztec.js/src/entrypoint/entrypoint.ts @@ -1,4 +1,5 @@ import { type AuthWitness, type FunctionCall, type PackedValues, type TxExecutionRequest } from '@aztec/circuit-types'; +import { type Fr } from '@aztec/circuits.js'; import { EntrypointPayload, type FeeOptions, computeCombinedPayloadHash } from './payload.js'; @@ -17,6 +18,10 @@ export type ExecutionRequestInit = { packedArguments?: PackedValues[]; /** How the fee is going to be payed */ fee?: FeeOptions; + /** An optional nonce. Used to repeat a previous tx with a higher fee so that the first one is cancelled */ + nonce?: Fr; + /** Whether the transaction can be cancelled. If true, an extra nullifier will be emitted: H(nonce, GENERATOR_INDEX__TX_NULLIFIER) */ + cancellable?: boolean; }; /** Creates transaction execution requests out of a set of function calls. */ diff --git a/yarn-project/aztec.js/src/entrypoint/payload.ts b/yarn-project/aztec.js/src/entrypoint/payload.ts index e0e110e6153..0f609fb3235 100644 --- a/yarn-project/aztec.js/src/entrypoint/payload.ts +++ b/yarn-project/aztec.js/src/entrypoint/payload.ts @@ -43,10 +43,10 @@ type EncodedFunctionCall = { export abstract class EntrypointPayload { #packedArguments: PackedValues[] = []; #functionCalls: EncodedFunctionCall[] = []; - #nonce = Fr.random(); + #nonce: Fr; #generatorIndex: number; - protected constructor(functionCalls: FunctionCall[], generatorIndex: number) { + protected constructor(functionCalls: FunctionCall[], generatorIndex: number, nonce = Fr.random()) { for (const call of functionCalls) { this.#packedArguments.push(PackedValues.fromValues(call.args)); } @@ -62,6 +62,7 @@ export abstract class EntrypointPayload { /* eslint-enable camelcase */ this.#generatorIndex = generatorIndex; + this.#nonce = nonce; } /* eslint-disable camelcase */ @@ -126,14 +127,15 @@ export abstract class EntrypointPayload { /** * Creates an execution payload for the app-portion of a transaction from a set of function calls * @param functionCalls - The function calls to execute + * @param nonce - The nonce for the payload, used to emit a nullifier identifying the call * @returns The execution payload */ - static fromAppExecution(functionCalls: FunctionCall[] | Tuple) { + static fromAppExecution(functionCalls: FunctionCall[] | Tuple, nonce = Fr.random()) { if (functionCalls.length > APP_MAX_CALLS) { throw new Error(`Expected at most ${APP_MAX_CALLS} function calls, got ${functionCalls.length}`); } const paddedCalls = padArrayEnd(functionCalls, FunctionCall.empty(), APP_MAX_CALLS); - return new AppEntrypointPayload(paddedCalls, GeneratorIndex.SIGNATURE_PAYLOAD); + return new AppEntrypointPayload(paddedCalls, GeneratorIndex.SIGNATURE_PAYLOAD, nonce); } /** diff --git a/yarn-project/cli-wallet/package.json b/yarn-project/cli-wallet/package.json index 5b85c40668c..ea37249686d 100644 --- a/yarn-project/cli-wallet/package.json +++ b/yarn-project/cli-wallet/package.json @@ -71,6 +71,7 @@ "@aztec/ethereum": "workspace:^", "@aztec/foundation": "workspace:^", "@aztec/kv-store": "workspace:^", + "@aztec/noir-contracts.js": "workspace:^", "commander": "^12.1.0", "inquirer": "^10.1.8", "source-map-support": "^0.5.21", diff --git a/yarn-project/cli-wallet/src/cmds/cancel_tx.ts b/yarn-project/cli-wallet/src/cmds/cancel_tx.ts new file mode 100644 index 00000000000..c05e9a1fead --- /dev/null +++ b/yarn-project/cli-wallet/src/cmds/cancel_tx.ts @@ -0,0 +1,52 @@ +import { type AccountWalletWithSecretKey, type FeePaymentMethod, SentTx, type TxHash, TxStatus } from '@aztec/aztec.js'; +import { type FeeOptions } from '@aztec/aztec.js/entrypoint'; +import { Fr, type GasSettings } from '@aztec/circuits.js'; +import { type LogFn } from '@aztec/foundation/log'; + +export async function cancelTx( + wallet: AccountWalletWithSecretKey, + { + txHash, + gasSettings, + nonce, + cancellable, + }: { txHash: TxHash; gasSettings: GasSettings; nonce: Fr; cancellable: boolean }, + paymentMethod: FeePaymentMethod, + log: LogFn, +) { + const receipt = await wallet.getTxReceipt(txHash); + if (receipt.status !== TxStatus.PENDING || !cancellable) { + log(`Transaction is in status ${receipt.status} and cannot be cancelled`); + return; + } + + const fee: FeeOptions = { + paymentMethod, + gasSettings, + }; + + gasSettings.inclusionFee.mul(new Fr(2)); + + const txRequest = await wallet.createTxExecutionRequest({ + calls: [], + fee, + nonce, + cancellable: true, + }); + + const txPromise = await wallet.proveTx(txRequest, true); + const tx = new SentTx(wallet, wallet.sendTx(txPromise)); + try { + await tx.wait(); + + log('Transaction has been cancelled'); + + const cancelReceipt = await tx.getReceipt(); + log(` Tx fee: ${cancelReceipt.transactionFee}`); + log(` Status: ${cancelReceipt.status}`); + log(` Block number: ${cancelReceipt.blockNumber}`); + log(` Block hash: ${cancelReceipt.blockHash?.toString('hex')}`); + } catch (err: any) { + log(`Could not cancel transaction\n ${err.message}`); + } +} diff --git a/yarn-project/cli-wallet/src/cmds/check_tx.ts b/yarn-project/cli-wallet/src/cmds/check_tx.ts new file mode 100644 index 00000000000..945c0b2a0cd --- /dev/null +++ b/yarn-project/cli-wallet/src/cmds/check_tx.ts @@ -0,0 +1,12 @@ +import { type PXE, type TxHash } from '@aztec/aztec.js'; +import { inspectTx } from '@aztec/cli/utils'; +import { type LogFn } from '@aztec/foundation/log'; + +export async function checkTx(client: PXE, txHash: TxHash, statusOnly: boolean, log: LogFn) { + if (statusOnly) { + const receipt = await client.getTxReceipt(txHash); + return receipt.status; + } else { + await inspectTx(client, txHash, log, { includeBlockInfo: true }); + } +} diff --git a/yarn-project/cli-wallet/src/cmds/index.ts b/yarn-project/cli-wallet/src/cmds/index.ts index b6dc6383e21..2cc1e08cf98 100644 --- a/yarn-project/cli-wallet/src/cmds/index.ts +++ b/yarn-project/cli-wallet/src/cmds/index.ts @@ -1,5 +1,5 @@ import { getIdentities } from '@aztec/accounts/utils'; -import { createCompatibleClient } from '@aztec/aztec.js'; +import { TxHash, createCompatibleClient } from '@aztec/aztec.js'; import { Fr, PublicKeys } from '@aztec/circuits.js'; import { ETHEREUM_HOST, @@ -35,6 +35,8 @@ import { createArtifactOption, createContractAddressOption, createTypeOption, + integerArgParser, + parsePaymentMethod, } from '../utils/options/index.js'; export function injectCommands(program: Command, log: LogFn, debugLogger: DebugLogger, db?: WalletDB) { @@ -229,7 +231,8 @@ export function injectCommands(program: Command, log: LogFn, debugLogger: DebugL ) .addOption(createAccountOption('Alias or address of the account to send the transaction from', !db, db)) .addOption(createTypeOption(false)) - .option('--no-wait', 'Print transaction hash without waiting for it to be mined'); + .option('--no-wait', 'Print transaction hash without waiting for it to be mined') + .option('--no-cancel', 'Do not allow the transaction to be cancelled. This makes for cheaper transactions.'); addOptions(sendCommand, FeeOpts.getOptions()).action(async (functionName, _options, command) => { const { send } = await import('./send.js'); @@ -239,12 +242,13 @@ export function injectCommands(program: Command, log: LogFn, debugLogger: DebugL contractArtifact: artifactPathPromise, contractAddress, from: parsedFromAddress, - noWait, + wait, rpcUrl, type, secretKey, publicKey, alias, + cancel, } = options; const client = await createCompatibleClient(rpcUrl, debugLogger); const account = await createOrRetrieveAccount(client, parsedFromAddress, db, type, secretKey, Fr.ZERO, publicKey); @@ -253,18 +257,20 @@ export function injectCommands(program: Command, log: LogFn, debugLogger: DebugL debugLogger.info(`Using wallet with address ${wallet.getCompleteAddress().address.toString()}`); - const txHash = await send( + const sentTx = await send( wallet, functionName, args, artifactPath, contractAddress, - !noWait, + wait, + cancel, FeeOpts.fromCli(options, log, db), log, ); - if (db && txHash) { - await db.storeTxHash(txHash, log, alias); + if (db && sentTx) { + const txAlias = alias ? alias : `${functionName}-${sentTx.nonce.toString().slice(-4)}`; + await db.storeTx(sentTx, log, txAlias); } }); @@ -528,5 +534,79 @@ export function injectCommands(program: Command, log: LogFn, debugLogger: DebugL await addScopeToWallet(wallet, authorizer, db); }); + program + .command('get-tx') + .description('Gets the status of the recent txs, or a detailed view if a specific transaction hash is provided') + .argument('[txHash]', 'A transaction hash to get the receipt for.', txHash => aliasedTxHashParser(txHash, db)) + .addOption(pxeOption) + .option('-p, --page ', 'The page number to display', value => integerArgParser(value, '--page', 1), 1) + .option( + '-s, --page-size ', + 'The number of transactions to display per page', + value => integerArgParser(value, '--page-size', 1), + 10, + ) + .action(async (txHash, options) => { + const { checkTx } = await import('./check_tx.js'); + const { rpcUrl, pageSize } = options; + let { page } = options; + const client = await createCompatibleClient(rpcUrl, debugLogger); + + if (txHash) { + await checkTx(client, txHash, false, log); + } else if (db) { + const aliases = db.listAliases('transactions'); + const totalPages = Math.ceil(aliases.length / pageSize); + page = Math.min(page - 1, totalPages - 1); + const dataRows = await Promise.all( + aliases.slice(page * pageSize, pageSize * (1 + page)).map(async ({ key, value }) => ({ + alias: key, + txHash: value, + cancellable: db.retrieveTxData(TxHash.fromString(value)).cancellable, + status: await checkTx(client, TxHash.fromString(value), true, log), + })), + ); + log(`Recent transactions:`); + log(''); + log(`${'Alias'.padEnd(32, ' ')} | ${'TxHash'.padEnd(64, ' ')} | ${'Cancellable'.padEnd(12, ' ')} | Status`); + log(''.padEnd(32 + 64 + 12 + 20, '-')); + for (const { alias, txHash, status, cancellable } of dataRows) { + log(`${alias.padEnd(32, ' ')} | ${txHash} | ${cancellable.toString()?.padEnd(12, ' ')} | ${status}`); + log(''.padEnd(32 + 64 + 12 + 20, '-')); + } + log(`Displaying ${Math.min(pageSize, aliases.length)} rows, page ${page + 1}/${totalPages}`); + } else { + log('Recent transactions are not available, please provide a specific transaction hash'); + } + }); + + program + .command('cancel-tx') + .description('Cancels a peding tx by reusing its nonce with a higher fee and an empty payload') + .argument('', 'A transaction hash to cancel.', txHash => aliasedTxHashParser(txHash, db)) + .addOption(pxeOption) + .addOption( + createSecretKeyOption("The sender's secret key", !db, sk => aliasedSecretKeyParser(sk, db)).conflicts('account'), + ) + .addOption(createAccountOption('Alias or address of the account to simulate from', !db, db)) + .addOption(createTypeOption(false)) + .addOption(FeeOpts.paymentMethodOption().default('method=none')) + .action(async (txHash, options) => { + const { cancelTx } = await import('./cancel_tx.js'); + const { from: parsedFromAddress, rpcUrl, type, secretKey, publicKey, payment } = options; + const client = await createCompatibleClient(rpcUrl, debugLogger); + const account = await createOrRetrieveAccount(client, parsedFromAddress, db, type, secretKey, Fr.ZERO, publicKey); + const wallet = await getWalletWithScopes(account, db); + + const txData = db?.retrieveTxData(txHash); + + if (!txData) { + throw new Error('Transaction data not found in the database, cannnot reuse nonce'); + } + const paymentMethod = await parsePaymentMethod(payment, log, db)(wallet); + + await cancelTx(wallet, txData, paymentMethod, log); + }); + return program; } diff --git a/yarn-project/cli-wallet/src/cmds/send.ts b/yarn-project/cli-wallet/src/cmds/send.ts index 585e1ac895d..55872802cc0 100644 --- a/yarn-project/cli-wallet/src/cmds/send.ts +++ b/yarn-project/cli-wallet/src/cmds/send.ts @@ -1,4 +1,5 @@ -import { type AccountWalletWithSecretKey, type AztecAddress, Contract } from '@aztec/aztec.js'; +import { type AccountWalletWithSecretKey, type AztecAddress, Contract, Fr } from '@aztec/aztec.js'; +import { GasSettings } from '@aztec/circuits.js'; import { prepTx } from '@aztec/cli/utils'; import { type LogFn } from '@aztec/foundation/log'; @@ -11,6 +12,7 @@ export async function send( contractArtifactPath: string, contractAddress: AztecAddress, wait: boolean, + cancellable: boolean, feeOpts: IFeeOpts, log: LogFn, ) { @@ -19,27 +21,43 @@ export async function send( const contract = await Contract.at(contractAddress, contractArtifact, wallet); const call = contract.methods[functionName](...functionArgs); + const gasLimits = await call.estimateGas({ ...(await feeOpts.toSendOpts(wallet)) }); + printGasEstimates(feeOpts, gasLimits, log); + if (feeOpts.estimateOnly) { - const gas = await call.estimateGas({ ...(await feeOpts.toSendOpts(wallet)) }); - printGasEstimates(feeOpts, gas, log); return; } - const tx = call.send({ ...(await feeOpts.toSendOpts(wallet)) }); - const txHash = (await tx.getTxHash()).toString(); - log(`\nTransaction hash: ${txHash}`); + const nonce = Fr.random(); + const tx = call.send({ ...(await feeOpts.toSendOpts(wallet)), nonce, cancellable }); + const txHash = await tx.getTxHash(); + log(`\nTransaction hash: ${txHash.toString()}`); if (wait) { - await tx.wait(); + try { + await tx.wait(); - log('Transaction has been mined'); + log('Transaction has been mined'); - const receipt = await tx.getReceipt(); - log(` Tx fee: ${receipt.transactionFee}`); - log(` Status: ${receipt.status}`); - log(` Block number: ${receipt.blockNumber}`); - log(` Block hash: ${receipt.blockHash?.toString('hex')}`); + const receipt = await tx.getReceipt(); + log(` Tx fee: ${receipt.transactionFee}`); + log(` Status: ${receipt.status}`); + log(` Block number: ${receipt.blockNumber}`); + log(` Block hash: ${receipt.blockHash?.toString('hex')}`); + } catch (err: any) { + log(`Transaction failed\n ${err.message}`); + } } else { - log('Transaction pending. Check status with get-tx-receipt'); + log('Transaction pending. Check status with check-tx'); } - return txHash; + const gasSettings = GasSettings.from({ + ...gasLimits, + maxFeesPerGas: feeOpts.gasSettings.maxFeesPerGas, + inclusionFee: feeOpts.gasSettings.inclusionFee, + }); + return { + txHash, + nonce, + cancellable, + gasSettings, + }; } diff --git a/yarn-project/cli-wallet/src/storage/wallet_db.ts b/yarn-project/cli-wallet/src/storage/wallet_db.ts index 0d669c8ad5a..629f94b7764 100644 --- a/yarn-project/cli-wallet/src/storage/wallet_db.ts +++ b/yarn-project/cli-wallet/src/storage/wallet_db.ts @@ -1,5 +1,5 @@ -import { type AuthWitness } from '@aztec/circuit-types'; -import { type AztecAddress, Fr } from '@aztec/circuits.js'; +import { type AuthWitness, type TxHash } from '@aztec/circuit-types'; +import { type AztecAddress, Fr, GasSettings } from '@aztec/circuits.js'; import { type LogFn } from '@aztec/foundation/log'; import { type AztecKVStore, type AztecMap } from '@aztec/kv-store'; @@ -13,6 +13,7 @@ export class WalletDB { #accounts!: AztecMap; #aliases!: AztecMap; #bridgedFeeJuice!: AztecMap; + #transactions!: AztecMap; private static instance: WalletDB; @@ -28,6 +29,7 @@ export class WalletDB { this.#accounts = store.openMap('accounts'); this.#aliases = store.openMap('aliases'); this.#bridgedFeeJuice = store.openMap('bridgedFeeJuice'); + this.#transactions = store.openMap('transactions'); } async pushBridgedFeeJuice(recipient: AztecAddress, secret: Fr, amount: bigint, log: LogFn) { @@ -69,9 +71,9 @@ export class WalletDB { if (alias) { await this.#aliases.set(`accounts:${alias}`, Buffer.from(address.toString())); } - await this.#accounts.set(`${address.toString()}-type`, Buffer.from(type)); - await this.#accounts.set(`${address.toString()}-sk`, secretKey.toBuffer()); - await this.#accounts.set(`${address.toString()}-salt`, salt.toBuffer()); + await this.#accounts.set(`${address.toString()}:type`, Buffer.from(type)); + await this.#accounts.set(`${address.toString()}:sk`, secretKey.toBuffer()); + await this.#accounts.set(`${address.toString()}:salt`, salt.toBuffer()); if (type === 'ecdsasecp256r1ssh' && publicKey) { const publicSigningKey = extractECDSAPublicKeyFromBase64String(publicKey); await this.storeAccountMetadata(address, 'publicSigningKey', publicSigningKey); @@ -99,14 +101,39 @@ export class WalletDB { log(`Authorization witness stored in database with alias${alias ? `es last & ${alias}` : ' last'}`); } - async storeTxHash(txHash: string, log: LogFn, alias?: string) { + async storeTx( + { + txHash, + nonce, + cancellable, + gasSettings, + }: { txHash: TxHash; nonce: Fr; cancellable: boolean; gasSettings: GasSettings }, + log: LogFn, + alias?: string, + ) { if (alias) { - await this.#aliases.set(`transactions:${alias}`, Buffer.from(txHash)); + await this.#aliases.set(`transactions:${alias}`, Buffer.from(txHash.toString())); } - await this.#aliases.set(`transactions:last`, Buffer.from(txHash)); + await this.#transactions.set(`${txHash.toString()}:nonce`, nonce.toBuffer()); + await this.#transactions.set(`${txHash.toString()}:cancellable`, Buffer.from(cancellable ? 'true' : 'false')); + await this.#transactions.set(`${txHash.toString()}:gasSettings`, gasSettings.toBuffer()); + await this.#aliases.set(`transactions:last`, Buffer.from(txHash.toString())); log(`Transaction hash stored in database with alias${alias ? `es last & ${alias}` : ' last'}`); } + retrieveTxData(txHash: TxHash) { + const nonceBuffer = this.#transactions.get(`${txHash.toString()}:nonce`); + if (!nonceBuffer) { + throw new Error( + `Could not find ${txHash.toString()}:nonce. Transaction with hash "${txHash.toString()}" does not exist on this wallet.`, + ); + } + const nonce = Fr.fromBuffer(nonceBuffer); + const cancellable = this.#transactions.get(`${txHash.toString()}:cancellable`)!.toString() === 'true'; + const gasBuffer = this.#transactions.get(`${txHash.toString()}:gasSettings`)!; + return { txHash, nonce, cancellable, gasSettings: GasSettings.fromBuffer(gasBuffer) }; + } + tryRetrieveAlias(arg: string) { try { return this.retrieveAlias(arg); @@ -143,12 +170,12 @@ export class WalletDB { async storeAccountMetadata(aliasOrAddress: AztecAddress | string, metadataKey: string, metadata: Buffer) { const { address } = this.retrieveAccount(aliasOrAddress); - await this.#accounts.set(`${address.toString()}-${metadataKey}`, metadata); + await this.#accounts.set(`${address.toString()}:${metadataKey}`, metadata); } retrieveAccountMetadata(aliasOrAddress: AztecAddress | string, metadataKey: string) { const { address } = this.retrieveAccount(aliasOrAddress); - const result = this.#accounts.get(`${address.toString()}-${metadataKey}`); + const result = this.#accounts.get(`${address.toString()}:${metadataKey}`); if (!result) { throw new Error(`Could not find metadata with key ${metadataKey} for account ${aliasOrAddress}`); } @@ -156,13 +183,13 @@ export class WalletDB { } retrieveAccount(address: AztecAddress | string) { - const secretKeyBuffer = this.#accounts.get(`${address.toString()}-sk`); + const secretKeyBuffer = this.#accounts.get(`${address.toString()}:sk`); if (!secretKeyBuffer) { - throw new Error(`Could not find ${address}-sk. Account "${address.toString}" does not exist on this wallet.`); + throw new Error(`Could not find ${address}:sk. Account "${address.toString}" does not exist on this wallet.`); } const secretKey = Fr.fromBuffer(secretKeyBuffer); - const salt = Fr.fromBuffer(this.#accounts.get(`${address.toString()}-salt`)!); - const type = this.#accounts.get(`${address.toString()}-type`)!.toString('utf8') as AccountType; + const salt = Fr.fromBuffer(this.#accounts.get(`${address.toString()}:salt`)!); + const type = this.#accounts.get(`${address.toString()}:type`)!.toString('utf8') as AccountType; return { address, secretKey, salt, type }; } diff --git a/yarn-project/cli-wallet/src/utils/options/fees.ts b/yarn-project/cli-wallet/src/utils/options/fees.ts index 4d0aa45e4cc..47af6863666 100644 --- a/yarn-project/cli-wallet/src/utils/options/fees.ts +++ b/yarn-project/cli-wallet/src/utils/options/fees.ts @@ -74,14 +74,18 @@ export class FeeOpts implements IFeeOpts { }; } + static paymentMethodOption() { + return new Option( + '--payment ', + 'Fee payment method and arguments. Valid methods are: none, fee_juice, fpc-public, fpc-private.', + ); + } + static getOptions() { return [ new Option('--inclusion-fee ', 'Inclusion fee to pay for the tx.').argParser(parseBigint), new Option('--gas-limits ', 'Gas limits for the tx.'), - new Option( - '--payment ', - 'Fee payment method and arguments. Valid methods are: none, fee_juice, fpc-public, fpc-private.', - ), + FeeOpts.paymentMethodOption(), new Option('--no-estimate-gas', 'Whether to automatically estimate gas limits for the tx.'), new Option('--estimate-gas-only', 'Only report gas estimation for the tx, do not send it.'), ]; @@ -119,7 +123,7 @@ class NoFeeOpts implements IFeeOpts { } } -function parsePaymentMethod( +export function parsePaymentMethod( payment: string, log: LogFn, db?: WalletDB, diff --git a/yarn-project/cli-wallet/src/utils/options/index.ts b/yarn-project/cli-wallet/src/utils/options/index.ts index e800a8dc97a..e9c27b12ba8 100644 --- a/yarn-project/cli-wallet/src/utils/options/index.ts +++ b/yarn-project/cli-wallet/src/utils/options/index.ts @@ -13,6 +13,22 @@ const TARGET_DIR = 'target'; export const ARTIFACT_DESCRIPTION = "Path to a compiled Aztec contract's artifact in JSON format. If executed inside a nargo workspace, a package and contract name can be specified as package@contract"; +export function integerArgParser( + value: string, + argName: string, + min = Number.MIN_SAFE_INTEGER, + max = Number.MAX_SAFE_INTEGER, +) { + const parsed = parseInt(value, 10); + if (parsed < min) { + throw new Error(`${argName} must be greater than ${min}`); + } + if (parsed > max) { + throw new Error(`${argName} must be less than ${max}`); + } + return parsed; +} + export function aliasedTxHashParser(txHash: string, db?: WalletDB) { try { return parseTxHash(txHash); diff --git a/yarn-project/cli-wallet/test/flows/tx_management.sh b/yarn-project/cli-wallet/test/flows/tx_management.sh new file mode 100755 index 00000000000..edde404a604 --- /dev/null +++ b/yarn-project/cli-wallet/test/flows/tx_management.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e +source ../utils/setup.sh + +test_title "Tx management" + +aztec-wallet create-account -a main +aztec-wallet deploy counter_contract@Counter --init initialize --args 0 accounts:main accounts:main -a counter -f main +aztec-wallet send increment -ca counter --args accounts:main accounts:main -f main + +TX_LIST=$(aztec-wallet get-tx) + +echo "${TX_LIST}" + +TX_HASH=$(echo "${TX_LIST}" | grep "transactions:last" | awk '{print $3}') + +section Last transaction hash is ${TX_HASH} + +TX_STATUS=$(aztec-wallet get-tx ${TX_HASH} | grep "Status: " | awk '{print $2}') + +assert_eq ${TX_STATUS} "success" + + + diff --git a/yarn-project/cli-wallet/tsconfig.json b/yarn-project/cli-wallet/tsconfig.json index bfeef9b6a1c..66251395644 100644 --- a/yarn-project/cli-wallet/tsconfig.json +++ b/yarn-project/cli-wallet/tsconfig.json @@ -29,6 +29,9 @@ }, { "path": "../kv-store" + }, + { + "path": "../noir-contracts.js" } ], "include": ["src"] diff --git a/yarn-project/cli/src/cmds/pxe/get_block.ts b/yarn-project/cli/src/cmds/pxe/get_block.ts index 911d93d05bf..c43d3633ef9 100644 --- a/yarn-project/cli/src/cmds/pxe/get_block.ts +++ b/yarn-project/cli/src/cmds/pxe/get_block.ts @@ -1,7 +1,7 @@ import { createCompatibleClient } from '@aztec/aztec.js'; import { type DebugLogger, type LogFn } from '@aztec/foundation/log'; -import { inspectBlock } from '../../inspect.js'; +import { inspectBlock } from '../../utils/inspect.js'; export async function getBlock( rpcUrl: string, diff --git a/yarn-project/cli/src/cmds/pxe/get_tx.ts b/yarn-project/cli/src/cmds/pxe/get_tx.ts deleted file mode 100644 index 0cfe5dd3882..00000000000 --- a/yarn-project/cli/src/cmds/pxe/get_tx.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type TxHash } from '@aztec/aztec.js'; -import { createCompatibleClient } from '@aztec/aztec.js'; -import { type DebugLogger, type LogFn } from '@aztec/foundation/log'; - -import { inspectTx } from '../../inspect.js'; - -export async function getTx(rpcUrl: string, txHash: TxHash, debugLogger: DebugLogger, log: LogFn) { - const client = await createCompatibleClient(rpcUrl, debugLogger); - await inspectTx(client, txHash, log, { includeBlockInfo: true }); -} diff --git a/yarn-project/cli/src/cmds/pxe/index.ts b/yarn-project/cli/src/cmds/pxe/index.ts index e13eb150021..4502505c9fe 100644 --- a/yarn-project/cli/src/cmds/pxe/index.ts +++ b/yarn-project/cli/src/cmds/pxe/index.ts @@ -15,7 +15,6 @@ import { parseOptionalTxHash, parsePartialAddress, parsePublicKey, - parseTxHash, pxeOption, } from '../../utils/commands.js'; @@ -51,16 +50,6 @@ export function injectCommands(program: Command, log: LogFn, debugLogger: DebugL ); }); - program - .command('get-tx') - .description('Gets the receipt for the specified transaction hash.') - .argument('', 'A transaction hash to get the receipt for.', parseTxHash) - .addOption(pxeOption) - .action(async (txHash, options) => { - const { getTx } = await import('./get_tx.js'); - await getTx(options.rpcUrl, txHash, debugLogger, log); - }); - program .command('get-block') .description('Gets info for a given block or latest.') diff --git a/yarn-project/cli/src/utils/index.ts b/yarn-project/cli/src/utils/index.ts index 9271b082888..0c0dbffaef8 100644 --- a/yarn-project/cli/src/utils/index.ts +++ b/yarn-project/cli/src/utils/index.ts @@ -3,3 +3,4 @@ export * from './aztec.js'; export * from './encoding.js'; export * from './github.js'; export * from './portal_manager.js'; +export * from './inspect.js'; diff --git a/yarn-project/cli/src/inspect.ts b/yarn-project/cli/src/utils/inspect.ts similarity index 100% rename from yarn-project/cli/src/inspect.ts rename to yarn-project/cli/src/utils/inspect.ts diff --git a/yarn-project/entrypoints/src/account_entrypoint.ts b/yarn-project/entrypoints/src/account_entrypoint.ts index 65c97571e08..497955901ac 100644 --- a/yarn-project/entrypoints/src/account_entrypoint.ts +++ b/yarn-project/entrypoints/src/account_entrypoint.ts @@ -24,12 +24,12 @@ export class DefaultAccountEntrypoint implements EntrypointInterface { ) {} async createTxExecutionRequest(exec: ExecutionRequestInit): Promise { - const { calls, fee } = exec; - const appPayload = EntrypointPayload.fromAppExecution(calls); + const { calls, fee, nonce, cancellable } = exec; + const appPayload = EntrypointPayload.fromAppExecution(calls, nonce); const feePayload = await EntrypointPayload.fromFeeOptions(this.address, fee); const abi = this.getEntrypointAbi(); - const entrypointPackedArgs = PackedValues.fromValues(encodeArguments(abi, [appPayload, feePayload])); + const entrypointPackedArgs = PackedValues.fromValues(encodeArguments(abi, [appPayload, feePayload, !!cancellable])); const gasSettings = exec.fee?.gasSettings ?? GasSettings.default(); const combinedPayloadAuthWitness = await this.auth.createAuthWit( @@ -143,6 +143,7 @@ export class DefaultAccountEntrypoint implements EntrypointInterface { }, visibility: 'public', }, + { name: 'cancellable', type: { kind: 'boolean' } }, ], returnTypes: [], } as FunctionAbi; diff --git a/yarn-project/yarn.lock b/yarn-project/yarn.lock index 55e67733f99..b27a3416c1e 100644 --- a/yarn-project/yarn.lock +++ b/yarn-project/yarn.lock @@ -425,6 +425,7 @@ __metadata: "@aztec/ethereum": "workspace:^" "@aztec/foundation": "workspace:^" "@aztec/kv-store": "workspace:^" + "@aztec/noir-contracts.js": "workspace:^" "@jest/globals": ^29.5.0 "@types/jest": ^29.5.0 "@types/node": ^18.7.23