diff --git a/yarn-project/cli/src/index.ts b/yarn-project/cli/src/index.ts index 35847632d76..b7faa6207c9 100644 --- a/yarn-project/cli/src/index.ts +++ b/yarn-project/cli/src/index.ts @@ -1,10 +1,8 @@ import { - AztecAddress, Contract, ContractDeployer, Fr, GrumpkinScalar, - Point, generatePublicKey, getSchnorrAccount, isContractDeployed, @@ -14,10 +12,10 @@ import { JsonStringify } from '@aztec/foundation/json-rpc'; import { DebugLogger, LogFn } from '@aztec/foundation/log'; import { fileURLToPath } from '@aztec/foundation/url'; import { compileContract, generateNoirInterface, generateTypescriptInterface } from '@aztec/noir-compiler/cli'; -import { CompleteAddress, ContractData, L2BlockL2Logs, TxHash } from '@aztec/types'; +import { CompleteAddress, ContractData, L2BlockL2Logs } from '@aztec/types'; import { createSecp256k1PeerId } from '@libp2p/peer-id-factory'; -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { readFileSync } from 'fs'; import { dirname, resolve } from 'path'; import { mnemonicToAccount } from 'viem/accounts'; @@ -30,20 +28,19 @@ import { getAbiFunction, getContractAbi, getExampleContractArtifacts, - getSaltFromHexString, getTxSender, + parseAztecAddress, + parsePartialAddress, + parsePrivateKey, + parsePublicKey, + parseSaltFromHexString, + parseTxHash, prepTx, - stripLeadingHex, } from './utils.js'; const accountCreationSalt = Fr.ZERO; -const { - ETHEREUM_HOST = 'http://localhost:8545', - PXE_HOST = 'http://localhost:8080', - PRIVATE_KEY, - API_KEY, -} = process.env; +const { ETHEREUM_HOST = 'http://localhost:8545', PRIVATE_KEY, API_KEY } = process.env; /** * Returns commander program that defines the CLI. @@ -59,6 +56,17 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { program.name('aztec-cli').description('CLI for interacting with Aztec.').version(version); + const pxeOption = new Option('-u, --rpc-url ', 'URL of the PXE') + .env('PXE_HOST') + .default('http://localhost:8080') + .makeOptionMandatory(true); + + const createPrivateKeyOption = (description: string, mandatory: boolean) => + new Option('-k, --private-key ', description) + .env('PRIVATE_KEY') + .argParser(parsePrivateKey) + .makeOptionMandatory(mandatory); + program .command('deploy-l1-contracts') .description('Deploys all necessary Ethereum contracts for Aztec.') @@ -134,17 +142,13 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { 'Creates an aztec account that can be used for sending transactions. Registers the account on the PXE and deploys an account contract. Uses a Schnorr single-key account which uses the same key for encryption and authentication (not secure for production usage).', ) .summary('Creates an aztec account that can be used for sending transactions.') - .option( - '-k, --private-key ', - 'Private key for note encryption and transaction signing. Uses random by default.', - PRIVATE_KEY, + .addOption( + createPrivateKeyOption('Private key for note encryption and transaction signing. Uses random by default.', false), ) - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) + .addOption(pxeOption) .action(async options => { const client = await createCompatibleClient(options.rpcUrl, debugLogger); - const privateKey = options.privateKey - ? GrumpkinScalar.fromString(stripLeadingHex(options.privateKey)) - : GrumpkinScalar.random(); + const privateKey = options.privateKey ?? GrumpkinScalar.random(); const account = getSchnorrAccount(client, privateKey, privateKey, accountCreationSalt); const wallet = await account.waitDeploy(); @@ -165,38 +169,38 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { "A compiled Aztec.nr contract's ABI in JSON format or name of a contract ABI exported by @aztec/noir-contracts", ) .option('-a, --args ', 'Contract constructor arguments', []) - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) + .addOption(pxeOption) .option( '-k, --public-key ', 'Optional encryption public key for this address. Set this value only if this contract is expected to receive private notes, which will be encrypted using this public key.', + parsePublicKey, ) .option( '-s, --salt ', 'Optional deployment salt as a hex string for generating the deployment address.', - getSaltFromHexString, + parseSaltFromHexString, ) // `options.wait` is default true. Passing `--no-wait` will set it to false. // https://github.com/tj/commander.js#other-option-types-negatable-boolean-and-booleanvalue .option('--no-wait', 'Skip waiting for the contract to be deployed. Print the hash of deployment transaction') - .action(async (abiPath, options: any) => { + .action(async (abiPath, { rpcUrl, publicKey, args: rawArgs, salt, wait }) => { const contractAbi = await getContractAbi(abiPath, log); const constructorAbi = contractAbi.functions.find(({ name }) => name === 'constructor'); - const client = await createCompatibleClient(options.rpcUrl, debugLogger); - const publicKey = options.publicKey ? Point.fromString(options.publicKey) : undefined; - + const client = await createCompatibleClient(rpcUrl, debugLogger); const deployer = new ContractDeployer(contractAbi, client, publicKey); const constructor = getAbiFunction(contractAbi, 'constructor'); if (!constructor) throw new Error(`Constructor not found in contract ABI`); - debugLogger(`Input arguments: ${options.args.map((x: any) => `"${x}"`).join(', ')}`); - const args = encodeArgs(options.args, constructorAbi!.parameters); + debugLogger(`Input arguments: ${rawArgs.map((x: any) => `"${x}"`).join(', ')}`); + const args = encodeArgs(rawArgs, constructorAbi!.parameters); debugLogger(`Encoded arguments: ${args.join(', ')}`); - const tx = deployer.deploy(...args).send({ contractAddressSalt: options.salt }); + + const tx = deployer.deploy(...args).send({ contractAddressSalt: salt }); const txHash = await tx.getTxHash(); debugLogger(`Deploy tx sent with hash ${txHash}`); - if (options.wait) { + if (wait) { const deployed = await tx.wait(); log(`\nContract deployed at ${deployed.contractAddress!.toString()}\n`); } else { @@ -207,11 +211,15 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { program .command('check-deploy') .description('Checks if a contract is deployed to the specified Aztec address.') - .requiredOption('-ca, --contract-address
', 'An Aztec address to check if contract has been deployed to.') - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) + .requiredOption( + '-ca, --contract-address
', + 'An Aztec address to check if contract has been deployed to.', + parseAztecAddress, + ) + .addOption(pxeOption) .action(async options => { const client = await createCompatibleClient(options.rpcUrl, debugLogger); - const address = AztecAddress.fromString(options.contractAddress); + const address = options.contractAddress; const isDeployed = await isContractDeployed(client, address); if (isDeployed) log(`\nContract found at ${address.toString()}\n`); else log(`\nNo contract found at ${address.toString()}\n`); @@ -220,14 +228,13 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { program .command('get-tx-receipt') .description('Gets the receipt for the specified transaction hash.') - .argument('', 'A transaction hash to get the receipt for.') - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) - .action(async (_txHash, options) => { + .argument('', 'A transaction hash to get the receipt for.', parseTxHash) + .addOption(pxeOption) + .action(async (txHash, options) => { const client = await createCompatibleClient(options.rpcUrl, debugLogger); - const txHash = TxHash.fromString(_txHash); const receipt = await client.getTxReceipt(txHash); if (!receipt) { - log(`No receipt found for transaction hash ${_txHash}`); + log(`No receipt found for transaction hash ${txHash.toString()}`); } else { log(`\nTransaction receipt: \n${JsonStringify(receipt, true)}\n`); } @@ -236,15 +243,14 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { program .command('get-contract-data') .description('Gets information about the Aztec contract deployed at the specified address.') - .argument('', 'Aztec address of the contract.') - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) + .argument('', 'Aztec address of the contract.', parseAztecAddress) + .addOption(pxeOption) .option('-b, --include-bytecode ', "Include the contract's public function bytecode, if any.", false) .action(async (contractAddress, options) => { const client = await createCompatibleClient(options.rpcUrl, debugLogger); - const address = AztecAddress.fromString(contractAddress); const contractDataWithOrWithoutBytecode = options.includeBytecode - ? await client.getExtendedContractData(address) - : await client.getContractData(address); + ? await client.getExtendedContractData(contractAddress) + : await client.getContractData(contractAddress); if (!contractDataWithOrWithoutBytecode) { log(`No contract data found at ${contractAddress}`); @@ -270,7 +276,7 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { .description('Gets all the unencrypted logs from L2 blocks in the range specified.') .option('-f, --from ', 'Initial block number for getting logs (defaults to 1).') .option('-l, --limit ', 'How many blocks to fetch (defaults to 100).') - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) + .addOption(pxeOption) .action(async options => { const { from, limit } = options; const fromBlock = from ? parseInt(from) : 1; @@ -289,24 +295,24 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { program .command('register-recipient') .description('Register a recipient in the PXE.') - .requiredOption('-a, --address ', "The account's Aztec address.") - .requiredOption('-p, --public-key ', 'The account public key.') - .requiredOption('-pa, --partial-address ', 'URL of the PXE', PXE_HOST) - .action(async options => { - const client = await createCompatibleClient(options.rpcUrl, debugLogger); - const address = AztecAddress.fromString(options.address); - const publicKey = Point.fromString(options.publicKey); - const partialAddress = Fr.fromString(options.partialAddress); - + .requiredOption('-a, --address ', "The account's Aztec address.", parseAztecAddress) + .requiredOption('-p, --public-key ', 'The account public key.', parsePublicKey) + .requiredOption( + '-pa, --partial-address ', + 'The partially computed address of the account contract.', + parsePartialAddress, + ) + .addOption(pxeOption) + .action(async ({ address, publicKey, partialAddress, rpcUrl }) => { + const client = await createCompatibleClient(rpcUrl, debugLogger); await client.registerRecipient(await CompleteAddress.create(address, publicKey, partialAddress)); - log(`\nRegistered details for account with address: ${options.address}\n`); + log(`\nRegistered details for account with address: ${address}\n`); }); program .command('get-accounts') .description('Gets all the Aztec accounts stored in the PXE.') - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) + .addOption(pxeOption) .action(async (options: any) => { const client = await createCompatibleClient(options.rpcUrl, debugLogger); const accounts = await client.getRegisteredAccounts(); @@ -323,15 +329,14 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { program .command('get-account') .description('Gets an account given its Aztec address.') - .argument('
', 'The Aztec address to get account for') - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) - .action(async (_address, options) => { + .argument('
', 'The Aztec address to get account for', parseAztecAddress) + .addOption(pxeOption) + .action(async (address, options) => { const client = await createCompatibleClient(options.rpcUrl, debugLogger); - const address = AztecAddress.fromString(_address); const account = await client.getRegisteredAccount(address); if (!account) { - log(`Unknown account ${_address}`); + log(`Unknown account ${address.toString()}`); } else { log(account.toReadableString()); } @@ -340,7 +345,7 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { program .command('get-recipients') .description('Gets all the recipients stored in the PXE.') - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) + .addOption(pxeOption) .action(async (options: any) => { const client = await createCompatibleClient(options.rpcUrl, debugLogger); const recipients = await client.getRecipients(); @@ -357,15 +362,14 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { program .command('get-recipient') .description('Gets a recipient given its Aztec address.') - .argument('
', 'The Aztec address to get recipient for') - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) - .action(async (_address, options) => { + .argument('
', 'The Aztec address to get recipient for', parseAztecAddress) + .addOption(pxeOption) + .action(async (address, options) => { const client = await createCompatibleClient(options.rpcUrl, debugLogger); - const address = AztecAddress.fromString(_address); const recipient = await client.getRecipient(address); if (!recipient) { - log(`Unknown recipient ${_address}`); + log(`Unknown recipient ${address.toString()}`); } else { log(recipient.toReadableString()); } @@ -380,20 +384,13 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { '-c, --contract-abi ', "A compiled Aztec.nr contract's ABI in JSON format or name of a contract ABI exported by @aztec/noir-contracts", ) - .requiredOption('-ca, --contract-address
', 'Aztec address of the contract.') - .requiredOption('-k, --private-key ', "The sender's private key.", PRIVATE_KEY) - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) + .requiredOption('-ca, --contract-address
', 'Aztec address of the contract.', parseAztecAddress) + .addOption(createPrivateKeyOption("The sender's private key.", true)) + .addOption(pxeOption) .option('--no-wait', 'Print transaction hash without waiting for it to be mined') .action(async (functionName, options) => { - const { contractAddress, functionArgs, contractAbi } = await prepTx( - options.contractAbi, - options.contractAddress, - functionName, - options.args, - log, - ); - - const privateKey = GrumpkinScalar.fromString(stripLeadingHex(options.privateKey)); + const { functionArgs, contractAbi } = await prepTx(options.contractAbi, functionName, options.args, log); + const { contractAddress, privateKey } = options; const client = await createCompatibleClient(options.rpcUrl, debugLogger); const wallet = await getSchnorrAccount(client, privateKey, privateKey, accountCreationSalt).getWallet(); @@ -425,17 +422,12 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { '-c, --contract-abi ', "A compiled Aztec.nr contract's ABI in JSON format or name of a contract ABI exported by @aztec/noir-contracts", ) - .requiredOption('-ca, --contract-address
', 'Aztec address of the contract.') + .requiredOption('-ca, --contract-address
', 'Aztec address of the contract.', parseAztecAddress) .option('-f, --from ', 'Aztec address of the caller. If empty, will use the first account from RPC.') - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) + .addOption(pxeOption) .action(async (functionName, options) => { - const { contractAddress, functionArgs, contractAbi } = await prepTx( - options.contractAbi, - options.contractAddress, - functionName, - options.args, - log, - ); + const { functionArgs, contractAbi } = await prepTx(options.contractAbi, functionName, options.args, log); + const fnAbi = getAbiFunction(contractAbi, functionName); if (fnAbi.parameters.length !== options.args.length) { throw Error( @@ -444,7 +436,7 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { } const client = await createCompatibleClient(options.rpcUrl, debugLogger); const from = await getTxSender(client, options.from); - const result = await client.viewTx(functionName, functionArgs, contractAddress, from); + const result = await client.viewTx(functionName, functionArgs, options.contractAddress, from); log('\nView result: ', result, '\n'); }); @@ -475,7 +467,7 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { program .command('block-number') .description('Gets the current Aztec L2 block number.') - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) + .addOption(pxeOption) .action(async (options: any) => { const client = await createCompatibleClient(options.rpcUrl, debugLogger); const num = await client.getBlockNumber(); @@ -506,7 +498,7 @@ export function getProgram(log: LogFn, debugLogger: DebugLogger): Command { program .command('get-node-info') .description('Gets the information of an aztec node at a URL.') - .requiredOption('-u, --rpc-url ', 'URL of the PXE', PXE_HOST) + .addOption(pxeOption) .action(async options => { const client = await createCompatibleClient(options.rpcUrl, debugLogger); const info = await client.getNodeInfo(); diff --git a/yarn-project/cli/src/test/utils.test.ts b/yarn-project/cli/src/test/utils.test.ts index 2e04691cba2..17619dc8f8c 100644 --- a/yarn-project/cli/src/test/utils.test.ts +++ b/yarn-project/cli/src/test/utils.test.ts @@ -5,7 +5,7 @@ import { InvalidArgumentError } from 'commander'; import { MockProxy, mock } from 'jest-mock-extended'; import { encodeArgs } from '../encoding.js'; -import { getSaltFromHexString, getTxSender, stripLeadingHex } from '../utils.js'; +import { getTxSender, parseSaltFromHexString, stripLeadingHex } from '../utils.js'; import { mockContractAbi } from './mocks.js'; describe('CLI Utils', () => { @@ -141,7 +141,7 @@ describe('CLI Utils', () => { }); }); - describe('getSaltFromHex', () => { + describe('parseSaltFromHexString', () => { it.each([ ['0', Fr.ZERO], ['0x0', Fr.ZERO], @@ -152,11 +152,11 @@ describe('CLI Utils', () => { ['0xa', new Fr(0xa)], ['fff', new Fr(0xfff)], ])('correctly generates salt from a hex string', (hex, expected) => { - expect(getSaltFromHexString(hex)).toEqual(expected); + expect(parseSaltFromHexString(hex)).toEqual(expected); }); it.each(['foo', '', ' ', ' 0x1', '01foo', 'foo1', '0xfoo'])('throws an error for invalid hex strings', str => { - expect(() => getSaltFromHexString(str)).toThrow(InvalidArgumentError); + expect(() => parseSaltFromHexString(str)).toThrow(InvalidArgumentError); }); }); }); diff --git a/yarn-project/cli/src/utils.ts b/yarn-project/cli/src/utils.ts index 54cde4490de..2d1db6b43c2 100644 --- a/yarn-project/cli/src/utils.ts +++ b/yarn-project/cli/src/utils.ts @@ -1,4 +1,4 @@ -import { AztecAddress, Fr, PXE } from '@aztec/aztec.js'; +import { AztecAddress, Fr, GrumpkinScalar, PXE, Point, TxHash } from '@aztec/aztec.js'; import { L1ContractArtifactsForDeployment, createEthereumChain, deployL1Contracts } from '@aztec/ethereum'; import { ContractAbi } from '@aztec/foundation/abi'; import { DebugLogger, LogFn } from '@aztec/foundation/log'; @@ -135,7 +135,7 @@ export async function getTxSender(pxe: PXE, _from?: string) { try { from = AztecAddress.fromString(_from); } catch { - throw new Error(`Invalid option 'from' passed: ${_from}`); + throw new InvalidArgumentError(`Invalid option 'from' passed: ${_from}`); } } else { const accounts = await pxe.getRegisteredAccounts(); @@ -150,30 +150,18 @@ export async function getTxSender(pxe: PXE, _from?: string) { /** * Performs necessary checks, conversions & operations to call a contract fn from the CLI. * @param contractFile - Directory of the compiled contract ABI. - * @param _contractAddress - Aztec Address of the contract. + * @param contractAddress - Aztec Address of the contract. * @param functionName - Name of the function to be called. * @param _functionArgs - Arguments to call the function with. * @param log - Logger instance that will output to the CLI * @returns Formatted contract address, function arguments and caller's aztec address. */ -export async function prepTx( - contractFile: string, - _contractAddress: string, - functionName: string, - _functionArgs: string[], - log: LogFn, -) { - let contractAddress; - try { - contractAddress = AztecAddress.fromString(_contractAddress); - } catch { - throw new Error(`Unable to parse contract address ${_contractAddress}.`); - } +export async function prepTx(contractFile: string, functionName: string, _functionArgs: string[], log: LogFn) { const contractAbi = await getContractAbi(contractFile, log); const functionAbi = getAbiFunction(contractAbi, functionName); const functionArgs = encodeArgs(_functionArgs, functionAbi.parameters); - return { contractAddress, functionArgs, contractAbi }; + return { functionArgs, contractAbi }; } /** @@ -193,7 +181,7 @@ export const stripLeadingHex = (hex: string) => { * @param str - Hex encoded string * @returns A integer to be used as salt */ -export function getSaltFromHexString(str: string): Fr { +export function parseSaltFromHexString(str: string): Fr { const hex = stripLeadingHex(str); // ensure it's a hex string @@ -208,3 +196,74 @@ export function getSaltFromHexString(str: string): Fr { // finally, turn it into an integer return Fr.fromBuffer(Buffer.from(padded, 'hex')); } + +/** + * Parses an AztecAddress from a string. Throws InvalidArgumentError if the string is not a valid. + * @param address - A serialised Aztec address + * @returns An Aztec address + */ +export function parseAztecAddress(address: string): AztecAddress { + try { + return AztecAddress.fromString(address); + } catch { + throw new InvalidArgumentError(`Invalid address: ${address}`); + } +} + +/** + * Parses a TxHash from a string. Throws InvalidArgumentError if the string is not a valid. + * @param txHash - A transaction hash + * @returns A TxHash instance + */ +export function parseTxHash(txHash: string): TxHash { + try { + return TxHash.fromString(txHash); + } catch { + throw new InvalidArgumentError(`Invalid transaction hash: ${txHash}`); + } +} + +/** + * Parses a public key from a string. Throws InvalidArgumentError if the string is not a valid. + * @param publicKey - A public key + * @returns A Point instance + */ +export function parsePublicKey(publicKey: string): Point { + try { + return Point.fromString(publicKey); + } catch (err) { + throw new InvalidArgumentError(`Invalid public key: ${publicKey}`); + } +} + +/** + * Parses a partial address from a string. Throws InvalidArgumentError if the string is not a valid. + * @param address - A partial address + * @returns A Fr instance + */ +export function parsePartialAddress(address: string): Fr { + try { + return Fr.fromString(address); + } catch (err) { + throw new InvalidArgumentError(`Invalid partial address: ${address}`); + } +} + +/** + * Parses a private key from a string. Throws InvalidArgumentError if the string is not a valid. + * @param privateKey - A string + * @returns A private key + */ +export function parsePrivateKey(privateKey: string): GrumpkinScalar { + try { + const value = GrumpkinScalar.fromString(privateKey); + // most likely a badly formatted key was passed + if (value.isZero()) { + throw new Error('Private key must not be zero'); + } + + return value; + } catch (err) { + throw new InvalidArgumentError(`Invalid private key: ${privateKey}`); + } +}