diff --git a/yarn-project/aztec.js/src/account_manager/index.ts b/yarn-project/aztec.js/src/account_manager/index.ts index 7f149e8165d5..339aaec4a981 100644 --- a/yarn-project/aztec.js/src/account_manager/index.ts +++ b/yarn-project/aztec.js/src/account_manager/index.ts @@ -70,6 +70,15 @@ export class AccountManager { return this.completeAddress; } + /** + * Gets the address for this given account. + * Does not require the account to be deployed or registered. + * @returns The address. + */ + public getAddress() { + return this.getCompleteAddress().address; + } + /** * Returns the contract instance definition associated with this account. * Does not require the account to be deployed or registered. diff --git a/yarn-project/aztec.js/src/contract/contract_base.ts b/yarn-project/aztec.js/src/contract/contract_base.ts index 0ec2240a4042..7be0183ae8b5 100644 --- a/yarn-project/aztec.js/src/contract/contract_base.ts +++ b/yarn-project/aztec.js/src/contract/contract_base.ts @@ -51,7 +51,7 @@ export class ContractBase { /** The Application Binary Interface for the contract. */ public readonly artifact: ContractArtifact, /** The wallet used for interacting with this contract. */ - protected wallet: Wallet, + public wallet: Wallet, ) { artifact.functions.forEach((f: FunctionArtifact) => { const interactionFunction = (...args: any[]) => { diff --git a/yarn-project/aztec.js/src/contract/deploy_method.ts b/yarn-project/aztec.js/src/contract/deploy_method.ts index 4823ac28210b..4399f05954ed 100644 --- a/yarn-project/aztec.js/src/contract/deploy_method.ts +++ b/yarn-project/aztec.js/src/contract/deploy_method.ts @@ -132,6 +132,16 @@ export class DeployMethod extends Bas return this.functionCalls; } + /** + * Register this contract in the PXE and returns the Contract object. + * @param options - Deployment options. + */ + public async register(options: DeployOptions = {}): Promise { + const instance = this.getInstance(options); + await this.wallet.registerContract({ artifact: this.artifact, instance }); + return this.postDeployCtor(instance.address, this.wallet); + } + /** * Returns calls for registration of the class and deployment of the instance, depending on the provided options. * @param options - Deployment options. diff --git a/yarn-project/aztec.js/src/wallet/base_wallet.ts b/yarn-project/aztec.js/src/wallet/base_wallet.ts index 11c5b7fc3bbb..64fdc0abc99d 100644 --- a/yarn-project/aztec.js/src/wallet/base_wallet.ts +++ b/yarn-project/aztec.js/src/wallet/base_wallet.ts @@ -182,6 +182,9 @@ export abstract class BaseWallet implements Wallet { isContractPubliclyDeployed(address: AztecAddress): Promise { return this.pxe.isContractPubliclyDeployed(address); } + isContractInitialized(address: AztecAddress): Promise { + return this.pxe.isContractInitialized(address); + } getPXEInfo(): Promise { return this.pxe.getPXEInfo(); } diff --git a/yarn-project/aztec/package.json b/yarn-project/aztec/package.json index 07a6061e291c..66340d3b9d68 100644 --- a/yarn-project/aztec/package.json +++ b/yarn-project/aztec/package.json @@ -33,6 +33,7 @@ "@aztec/aztec-node": "workspace:^", "@aztec/aztec.js": "workspace:^", "@aztec/bb-prover": "workspace:^", + "@aztec/bot": "workspace:^", "@aztec/builder": "workspace:^", "@aztec/circuit-types": "workspace:^", "@aztec/circuits.js": "workspace:^", diff --git a/yarn-project/aztec/src/cli/cli.ts b/yarn-project/aztec/src/cli/cli.ts index 0d7101bae321..768efaf0c576 100644 --- a/yarn-project/aztec/src/cli/cli.ts +++ b/yarn-project/aztec/src/cli/cli.ts @@ -36,6 +36,7 @@ export function injectAztecCommands(program: Command, userLog: LogFn, debugLogge .option('-o, --prover-node [options]', cliTexts.proverNode) .option('-p2p, --p2p-bootstrap [options]', cliTexts.p2pBootstrap) .option('-t, --txe [options]', cliTexts.txe) + .option('--bot [options]', cliTexts.bot) .action(async options => { // list of 'stop' functions to call when process ends const signalHandlers: Array<() => Promise> = []; @@ -66,10 +67,12 @@ export function injectAztecCommands(program: Command, userLog: LogFn, debugLogge signalHandlers.push(stop); services = [{ node: nodeServer }, { pxe: pxeServer }]; } else { - // Start Aztec Node if (options.node) { const { startNode } = await import('./cmds/start_node.js'); services = await startNode(options, signalHandlers, userLog); + } else if (options.bot) { + const { startBot } = await import('./cmds/start_bot.js'); + services = await startBot(options, signalHandlers, userLog); } else if (options.proverNode) { const { startProverNode } = await import('./cmds/start_prover_node.js'); services = await startProverNode(options, signalHandlers, userLog); diff --git a/yarn-project/aztec/src/cli/cmds/start_bot.ts b/yarn-project/aztec/src/cli/cmds/start_bot.ts new file mode 100644 index 000000000000..68bc49bff172 --- /dev/null +++ b/yarn-project/aztec/src/cli/cmds/start_bot.ts @@ -0,0 +1,43 @@ +import { type BotConfig, BotRunner, createBotRunnerRpcServer, getBotConfigFromEnv } from '@aztec/bot'; +import { type PXE } from '@aztec/circuit-types'; +import { type ServerList } from '@aztec/foundation/json-rpc/server'; +import { type LogFn } from '@aztec/foundation/log'; + +import { mergeEnvVarsAndCliOptions, parseModuleOptions } from '../util.js'; + +export async function startBot( + options: any, + signalHandlers: (() => Promise)[], + userLog: LogFn, +): Promise { + // Services that will be started in a single multi-rpc server + const services: ServerList = []; + + const { proverNode, archiver, sequencer, p2pBootstrap, txe, prover } = options; + if (proverNode || archiver || sequencer || p2pBootstrap || txe || prover) { + userLog( + `Starting a bot with --prover-node, --prover, --archiver, --sequencer, --p2p-bootstrap, or --txe is not supported.`, + ); + process.exit(1); + } + + await addBot(options, services, signalHandlers); + return services; +} + +export async function addBot( + options: any, + services: ServerList, + signalHandlers: (() => Promise)[], + deps: { pxe?: PXE } = {}, +) { + const envVars = getBotConfigFromEnv(); + const cliOptions = parseModuleOptions(options.bot); + const config = mergeEnvVarsAndCliOptions(envVars, cliOptions); + + const botRunner = new BotRunner(config, { pxe: deps.pxe }); + const botServer = createBotRunnerRpcServer(botRunner); + await botRunner.start(); + services.push({ bot: botServer }); + signalHandlers.push(botRunner.stop); +} diff --git a/yarn-project/aztec/src/cli/cmds/start_node.ts b/yarn-project/aztec/src/cli/cmds/start_node.ts index a24e537e18c4..33dd2a2489bf 100644 --- a/yarn-project/aztec/src/cli/cmds/start_node.ts +++ b/yarn-project/aztec/src/cli/cmds/start_node.ts @@ -3,11 +3,11 @@ import { createAztecNodeRpcServer, getConfigEnvVars as getNodeConfigEnvVars, } from '@aztec/aztec-node'; +import { type PXE } from '@aztec/circuit-types'; import { NULL_KEY } from '@aztec/ethereum'; import { type ServerList } from '@aztec/foundation/json-rpc/server'; import { type LogFn } from '@aztec/foundation/log'; import { createProvingJobSourceServer } from '@aztec/prover-client/prover-agent'; -import { type PXEServiceConfig, createPXERpcServer, getPXEServiceConfig } from '@aztec/pxe'; import { createAndStartTelemetryClient, getConfigEnvVars as getTelemetryClientConfig, @@ -15,7 +15,7 @@ import { import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts'; -import { MNEMONIC, createAztecNode, createAztecPXE, deployContractsToL1 } from '../../sandbox.js'; +import { MNEMONIC, createAztecNode, deployContractsToL1 } from '../../sandbox.js'; import { mergeEnvVarsAndCliOptions, parseModuleOptions } from '../util.js'; const { DEPLOY_AZTEC_CONTRACTS } = process.env; @@ -108,18 +108,17 @@ export const startNode = async ( // Add node stop function to signal handlers signalHandlers.push(node.stop); - // Create a PXE client that connects to the node. + // Add a PXE client that connects to this node if requested + let pxe: PXE | undefined; if (options.pxe) { - const pxeCliOptions = parseModuleOptions(options.pxe); - const pxeConfig = mergeEnvVarsAndCliOptions(getPXEServiceConfig(), pxeCliOptions); - const pxe = await createAztecPXE(node, pxeConfig); - const pxeServer = createPXERpcServer(pxe); - - // Add PXE to services list - services.push({ pxe: pxeServer }); + const { addPXE } = await import('./start_pxe.js'); + pxe = await addPXE(options, services, signalHandlers, userLog, { node }); + } - // Add PXE stop function to signal handlers - signalHandlers.push(pxe.stop); + // Add a txs bot if requested + if (options.bot) { + const { addBot } = await import('./start_bot.js'); + await addBot(options, services, signalHandlers, { pxe }); } return services; diff --git a/yarn-project/aztec/src/cli/cmds/start_pxe.ts b/yarn-project/aztec/src/cli/cmds/start_pxe.ts index 95f4027097b7..799dadac6122 100644 --- a/yarn-project/aztec/src/cli/cmds/start_pxe.ts +++ b/yarn-project/aztec/src/cli/cmds/start_pxe.ts @@ -1,38 +1,40 @@ -import { createAztecNodeClient } from '@aztec/circuit-types'; +import { type AztecNode, createAztecNodeClient } from '@aztec/circuit-types'; import { type ServerList } from '@aztec/foundation/json-rpc/server'; import { type LogFn } from '@aztec/foundation/log'; import { type PXEServiceConfig, createPXERpcServer, createPXEService, getPXEServiceConfig } from '@aztec/pxe'; import { mergeEnvVarsAndCliOptions, parseModuleOptions } from '../util.js'; -const { AZTEC_NODE_URL } = process.env; - -export const startPXE = async (options: any, signalHandlers: (() => Promise)[], userLog: LogFn) => { - // Services that will be started in a single multi-rpc server +export async function startPXE(options: any, signalHandlers: (() => Promise)[], userLog: LogFn) { const services: ServerList = []; - // Starting a PXE with a remote node. - // get env vars first - const pxeConfigEnvVars = getPXEServiceConfig(); - // get config from options - const pxeCliOptions = parseModuleOptions(options.pxe); + await addPXE(options, services, signalHandlers, userLog, {}); + return services; +} - // Determine node url from options or env vars - const nodeUrl = pxeCliOptions.nodeUrl || AZTEC_NODE_URL; - // throw if no Aztec Node URL is provided - if (!nodeUrl) { +export async function addPXE( + options: any, + services: ServerList, + signalHandlers: (() => Promise)[], + userLog: LogFn, + deps: { node?: AztecNode } = {}, +) { + const pxeCliOptions = parseModuleOptions(options.pxe); + const pxeConfig = mergeEnvVarsAndCliOptions(getPXEServiceConfig(), pxeCliOptions); + const nodeUrl = pxeCliOptions.nodeUrl ?? process.env.AZTEC_NODE_URL; + if (!nodeUrl && !deps.node) { userLog('Aztec Node URL (nodeUrl | AZTEC_NODE_URL) option is required to start PXE without --node option'); - throw new Error('Aztec Node URL (nodeUrl | AZTEC_NODE_URL) option is required to start PXE without --node option'); + process.exit(1); } - // merge env vars and cli options - const pxeConfig = mergeEnvVarsAndCliOptions(pxeConfigEnvVars, pxeCliOptions); - - // create a node client - const node = createAztecNodeClient(nodeUrl); - + const node = deps.node ?? createAztecNodeClient(nodeUrl); const pxe = await createPXEService(node, pxeConfig); const pxeServer = createPXERpcServer(pxe); + + // Add PXE to services list services.push({ pxe: pxeServer }); + + // Add PXE stop function to signal handlers signalHandlers.push(pxe.stop); - return services; -}; + + return pxe; +} diff --git a/yarn-project/aztec/src/cli/texts.ts b/yarn-project/aztec/src/cli/texts.ts index af90eb097319..0b6464208c99 100644 --- a/yarn-project/aztec/src/cli/texts.ts +++ b/yarn-project/aztec/src/cli/texts.ts @@ -92,4 +92,14 @@ export const cliTexts = { 'Starts a TXE with options\n' + 'Available options are listed below as cliProperty:ENV_VARIABLE_NAME.\n' + 'txePort:TXE_PORT - number - The port on which the TXE should listen for connections. Default: 8081\n', + bot: + 'Starts a bot that sends token transfer txs at regular intervals, using a local or remote PXE\n' + + 'Available options are listed below as cliProperty:ENV_VARIABLE_NAME.\n' + + 'feePaymentMethod:BOT_FEE_PAYMENT_METHOD - native | none - How to pay for fees for each tx.\n' + + 'senderPrivateKey:BOT_PRIVATE_KEY - hex - Private key for sending txs.\n' + + 'tokenSalt:BOT_TOKEN_SALT - hex - Deployment salt for the token contract.\n' + + 'recipientEncryptionSecret:BOT_RECIPIENT_ENCRYPTION_SECRET - hex - Encryption secret key for the recipient account.\n' + + 'txIntervalSeconds:BOT_TX_INTERVAL_SECONDS - number - Interval between txs are started. Too low a value may result in multiple txs in flight at a time. \n' + + 'privateTransfersPerTx:BOT_PRIVATE_TRANSFERS_PER_TX - number - How many private transfers to execute per tx. \n' + + 'publicTransfersPerTx:BOT_PUBLIC_TRANSFERS_PER_TX - number - How many public transfers to execute per tx.\n', }; diff --git a/yarn-project/aztec/src/cli/util.ts b/yarn-project/aztec/src/cli/util.ts index 8359412b2b78..8b14059e5f1f 100644 --- a/yarn-project/aztec/src/cli/util.ts +++ b/yarn-project/aztec/src/cli/util.ts @@ -1,11 +1,13 @@ import { type ArchiverConfig } from '@aztec/archiver'; import { type AztecNodeConfig } from '@aztec/aztec-node'; import { type AccountManager, type Fr } from '@aztec/aztec.js'; +import { type BotConfig } from '@aztec/bot'; import { type L1ContractAddresses, l1ContractsNames } from '@aztec/ethereum'; import { EthAddress } from '@aztec/foundation/eth-address'; import { type ServerList } from '@aztec/foundation/json-rpc/server'; import { type LogFn, createConsoleLogger } from '@aztec/foundation/log'; import { type P2PConfig } from '@aztec/p2p'; +import { type ProverNodeConfig } from '@aztec/prover-node'; import { type PXEService, type PXEServiceConfig } from '@aztec/pxe'; export interface ServiceStarter { @@ -66,8 +68,10 @@ export const parseModuleOptions = (options: string): Record => { }, {}); }; -export const mergeEnvVarsAndCliOptions = ( - envVars: AztecNodeConfig | PXEServiceConfig | P2PConfig | ArchiverConfig, +export const mergeEnvVarsAndCliOptions = < + T extends AztecNodeConfig | PXEServiceConfig | P2PConfig | ArchiverConfig | BotConfig | ProverNodeConfig, +>( + envVars: AztecNodeConfig | PXEServiceConfig | P2PConfig | ArchiverConfig | BotConfig | ProverNodeConfig, cliOptions: Record, contractsRequired = false, userLog = createConsoleLogger(), diff --git a/yarn-project/aztec/tsconfig.json b/yarn-project/aztec/tsconfig.json index 19fb94f3aca1..69fabc011009 100644 --- a/yarn-project/aztec/tsconfig.json +++ b/yarn-project/aztec/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../bb-prover" }, + { + "path": "../bot" + }, { "path": "../builder" }, diff --git a/yarn-project/bot/.eslintrc.cjs b/yarn-project/bot/.eslintrc.cjs new file mode 100644 index 000000000000..e659927475c0 --- /dev/null +++ b/yarn-project/bot/.eslintrc.cjs @@ -0,0 +1 @@ +module.exports = require('@aztec/foundation/eslint'); diff --git a/yarn-project/bot/README.md b/yarn-project/bot/README.md new file mode 100644 index 000000000000..a1051a62abb0 --- /dev/null +++ b/yarn-project/bot/README.md @@ -0,0 +1,3 @@ +# Transactions Bot + +Simple bot that connects to a PXE to send txs on a recurring basis. diff --git a/yarn-project/bot/package.json b/yarn-project/bot/package.json new file mode 100644 index 000000000000..6e187ffd065b --- /dev/null +++ b/yarn-project/bot/package.json @@ -0,0 +1,84 @@ +{ + "name": "@aztec/bot", + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./dest/index.js" + }, + "inherits": [ + "../package.common.json" + ], + "scripts": { + "build": "yarn clean && tsc -b", + "build:dev": "tsc -b --watch", + "clean": "rm -rf ./dest .tsbuildinfo", + "formatting": "run -T prettier --check ./src && run -T eslint ./src", + "formatting:fix": "run -T eslint --fix ./src && run -T prettier -w ./src", + "bb": "node --no-warnings ./dest/bb/index.js", + "test": "NODE_NO_WARNINGS=1 node --experimental-vm-modules ../node_modules/.bin/jest --passWithNoTests" + }, + "jest": { + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.[cm]?js$": "$1" + }, + "testRegex": "./src/.*\\.test\\.(js|mjs|ts)$", + "rootDir": "./src", + "transform": { + "^.+\\.tsx?$": [ + "@swc/jest", + { + "jsc": { + "parser": { + "syntax": "typescript", + "decorators": true + } + } + } + ] + }, + "extensionsToTreatAsEsm": [ + ".ts" + ], + "reporters": [ + [ + "default", + { + "summaryThreshold": 9999 + } + ] + ] + }, + "dependencies": { + "@aztec/accounts": "workspace:^", + "@aztec/aztec.js": "workspace:^", + "@aztec/circuit-types": "workspace:^", + "@aztec/circuits.js": "workspace:^", + "@aztec/entrypoints": "workspace:^", + "@aztec/foundation": "workspace:^", + "@aztec/noir-contracts.js": "workspace:^", + "@aztec/protocol-contracts": "workspace:^", + "@aztec/types": "workspace:^", + "source-map-support": "^0.5.21", + "tslib": "^2.4.0" + }, + "devDependencies": { + "@jest/globals": "^29.5.0", + "@types/jest": "^29.5.0", + "@types/memdown": "^3.0.0", + "@types/node": "^18.7.23", + "@types/source-map-support": "^0.5.10", + "jest": "^29.5.0", + "jest-mock-extended": "^3.0.3", + "ts-node": "^10.9.1", + "typescript": "^5.0.4" + }, + "files": [ + "dest", + "src", + "!*.test.*" + ], + "types": "./dest/index.d.ts", + "engines": { + "node": ">=18" + } +} diff --git a/yarn-project/bot/src/bot.ts b/yarn-project/bot/src/bot.ts new file mode 100644 index 000000000000..2329f645cc3c --- /dev/null +++ b/yarn-project/bot/src/bot.ts @@ -0,0 +1,68 @@ +import { + type AztecAddress, + BatchCall, + NativeFeePaymentMethod, + NoFeePaymentMethod, + type SendMethodOptions, + type Wallet, + createDebugLogger, +} from '@aztec/aztec.js'; +import { type FunctionCall, type PXE } from '@aztec/circuit-types'; +import { GasSettings } from '@aztec/circuits.js'; +import { times } from '@aztec/foundation/collection'; +import { type TokenContract } from '@aztec/noir-contracts.js'; + +import { type BotConfig } from './config.js'; +import { BotFactory } from './factory.js'; +import { getBalances } from './utils.js'; + +const TRANSFER_AMOUNT = 1; + +export class Bot { + private log = createDebugLogger('aztec:bot'); + + protected constructor( + public readonly wallet: Wallet, + public readonly token: TokenContract, + public readonly recipient: AztecAddress, + public readonly config: BotConfig, + ) {} + + static async create(config: BotConfig, dependencies: { pxe?: PXE } = {}): Promise { + const { wallet, token, recipient } = await new BotFactory(config, dependencies).setup(); + return new Bot(wallet, token, recipient, config); + } + + public async run() { + const { privateTransfersPerTx, publicTransfersPerTx, feePaymentMethod } = this.config; + const { token, recipient, wallet } = this; + const sender = wallet.getAddress(); + + this.log.verbose( + `Sending tx with ${feePaymentMethod} fee with ${privateTransfersPerTx} private and ${publicTransfersPerTx} public transfers`, + ); + + const calls: FunctionCall[] = [ + ...times(privateTransfersPerTx, () => token.methods.transfer(recipient, TRANSFER_AMOUNT).request()), + ...times(publicTransfersPerTx, () => + token.methods.transfer_public(sender, recipient, TRANSFER_AMOUNT, 0).request(), + ), + ]; + + const paymentMethod = feePaymentMethod === 'native' ? new NativeFeePaymentMethod(sender) : new NoFeePaymentMethod(); + const gasSettings = GasSettings.default(); + const opts: SendMethodOptions = { estimateGas: true, fee: { paymentMethod, gasSettings } }; + const tx = new BatchCall(wallet, calls).send(opts); + this.log.verbose(`Sent tx ${tx.getTxHash()}`); + + const receipt = await tx.wait(); + this.log.info(`Tx ${receipt.txHash} mined in block ${receipt.blockNumber}`); + } + + public async getBalances() { + return { + sender: await getBalances(this.token, this.wallet.getAddress()), + recipient: await getBalances(this.token, this.recipient), + }; + } +} diff --git a/yarn-project/bot/src/config.ts b/yarn-project/bot/src/config.ts new file mode 100644 index 000000000000..0d30bf345cff --- /dev/null +++ b/yarn-project/bot/src/config.ts @@ -0,0 +1,63 @@ +import { Fr } from '@aztec/circuits.js'; +import { compact } from '@aztec/foundation/collection'; + +export type BotConfig = { + /** URL to the PXE for sending txs, or undefined if an in-proc PXE is used. */ + pxeUrl: string | undefined; + /** Signing private key for the sender account. */ + senderPrivateKey: Fr; + /** Encryption secret for a recipient account. */ + recipientEncryptionSecret: Fr; + /** Salt for the token contract deployment. */ + tokenSalt: Fr; + /** Every how many seconds should a new tx be sent. */ + txIntervalSeconds: number; + /** How many private token transfers are executed per tx. */ + privateTransfersPerTx: number; + /** How many public token transfers are executed per tx. */ + publicTransfersPerTx: number; + /** How to handle fee payments. */ + feePaymentMethod: 'native' | 'none'; +}; + +export function getBotConfigFromEnv(): BotConfig { + const { + BOT_FEE_PAYMENT_METHOD, + BOT_PRIVATE_KEY, + BOT_TOKEN_SALT, + BOT_RECIPIENT_ENCRYPTION_SECRET, + BOT_TX_INTERVAL_SECONDS, + BOT_PRIVATE_TRANSFERS_PER_TX, + BOT_PUBLIC_TRANSFERS_PER_TX, + } = process.env; + if (BOT_FEE_PAYMENT_METHOD && !['native', 'none'].includes(BOT_FEE_PAYMENT_METHOD)) { + throw new Error(`Invalid bot fee payment method: ${BOT_FEE_PAYMENT_METHOD}`); + } + + return getBotDefaultConfig({ + pxeUrl: process.env.BOT_PXE_URL, + senderPrivateKey: BOT_PRIVATE_KEY ? Fr.fromString(BOT_PRIVATE_KEY) : undefined, + recipientEncryptionSecret: BOT_RECIPIENT_ENCRYPTION_SECRET + ? Fr.fromString(BOT_RECIPIENT_ENCRYPTION_SECRET) + : undefined, + tokenSalt: BOT_TOKEN_SALT ? Fr.fromString(BOT_TOKEN_SALT) : undefined, + txIntervalSeconds: BOT_TX_INTERVAL_SECONDS ? parseInt(BOT_TX_INTERVAL_SECONDS) : undefined, + privateTransfersPerTx: BOT_PRIVATE_TRANSFERS_PER_TX ? parseInt(BOT_PRIVATE_TRANSFERS_PER_TX) : undefined, + publicTransfersPerTx: BOT_PUBLIC_TRANSFERS_PER_TX ? parseInt(BOT_PUBLIC_TRANSFERS_PER_TX) : undefined, + feePaymentMethod: BOT_FEE_PAYMENT_METHOD ? (BOT_FEE_PAYMENT_METHOD as 'native' | 'none') : undefined, + }); +} + +export function getBotDefaultConfig(overrides: Partial = {}): BotConfig { + return { + pxeUrl: undefined, + senderPrivateKey: Fr.random(), + recipientEncryptionSecret: Fr.fromString('0xcafecafe'), + tokenSalt: Fr.fromString('1'), + txIntervalSeconds: 60, + privateTransfersPerTx: 1, + publicTransfersPerTx: 1, + feePaymentMethod: 'none', + ...compact(overrides), + }; +} diff --git a/yarn-project/bot/src/factory.ts b/yarn-project/bot/src/factory.ts new file mode 100644 index 000000000000..c12e4d528669 --- /dev/null +++ b/yarn-project/bot/src/factory.ts @@ -0,0 +1,103 @@ +import { getSchnorrAccount } from '@aztec/accounts/schnorr'; +import { type AccountWallet, BatchCall, createDebugLogger, createPXEClient } from '@aztec/aztec.js'; +import { type FunctionCall, type PXE } from '@aztec/circuit-types'; +import { Fr, deriveSigningKey } from '@aztec/circuits.js'; +import { TokenContract } from '@aztec/noir-contracts.js/Token'; + +import { type BotConfig } from './config.js'; +import { getBalances } from './utils.js'; + +const MINT_BALANCE = 1e12; +const MIN_BALANCE = 1e3; + +export class BotFactory { + private pxe: PXE; + private log = createDebugLogger('aztec:bot'); + + constructor(private readonly config: BotConfig, dependencies: { pxe?: PXE } = {}) { + if (!dependencies.pxe && !config.pxeUrl) { + throw new Error(`Either a PXE client or a PXE URL must be provided`); + } + + this.pxe = dependencies.pxe ?? createPXEClient(config.pxeUrl!); + } + + /** + * Initializes a new bot by setting up the sender account, registering the recipient, + * deploying the token contract, and minting tokens if necessary. + */ + public async setup() { + const recipient = await this.registerRecipient(); + const wallet = await this.setupAccount(); + const token = await this.setupToken(wallet); + await this.mintTokens(token); + return { wallet, token, pxe: this.pxe, recipient }; + } + + /** + * Checks if the sender account contract is initialized, and initializes it if necessary. + * @returns The sender wallet. + */ + private async setupAccount() { + const salt = Fr.ONE; + const signingKey = deriveSigningKey(this.config.senderPrivateKey); + const account = getSchnorrAccount(this.pxe, this.config.senderPrivateKey, signingKey, salt); + const isInit = await this.pxe.isContractInitialized(account.getAddress()); + if (isInit) { + this.log.info(`Account at ${account.getAddress().toString()} already initialized`); + return account.register(); + } else { + this.log.info(`Initializing account at ${account.getAddress().toString()}`); + return account.waitSetup(); + } + } + + /** + * Registers the recipient for txs in the pxe. + */ + private async registerRecipient() { + const recipient = await this.pxe.registerAccount(this.config.recipientEncryptionSecret, Fr.ONE); + return recipient.address; + } + + /** + * Checks if the token contract is deployed and deploys it if necessary. + * @param wallet - Wallet to deploy the token contract from. + * @returns The TokenContract instance. + */ + private async setupToken(wallet: AccountWallet): Promise { + const deploy = TokenContract.deploy(wallet, wallet.getAddress(), 'BotToken', 'BOT', 18); + const deployOpts = { contractAddressSalt: this.config.tokenSalt, universalDeploy: true }; + const address = deploy.getInstance(deployOpts).address; + if (await this.pxe.isContractPubliclyDeployed(address)) { + this.log.info(`Token at ${address.toString()} already deployed`); + return deploy.register(); + } else { + this.log.info(`Deploying token contract at ${address.toString()}`); + return deploy.send(deployOpts).deployed(); + } + } + + /** + * Mints private and public tokens for the sender if their balance is below the minimum. + * @param token - Token contract. + */ + private async mintTokens(token: TokenContract) { + const sender = token.wallet.getAddress(); + const { privateBalance, publicBalance } = await getBalances(token, sender); + const calls: FunctionCall[] = []; + if (privateBalance < MIN_BALANCE) { + this.log.info(`Minting private tokens for ${sender.toString()}`); + calls.push(token.methods.privately_mint_private_note(MINT_BALANCE).request()); + } + if (publicBalance < MIN_BALANCE) { + this.log.info(`Minting public tokens for ${sender.toString()}`); + calls.push(token.methods.mint_public(sender, MINT_BALANCE).request()); + } + if (calls.length === 0) { + this.log.info(`Skipping minting as ${sender.toString()} has enough tokens`); + return; + } + await new BatchCall(token.wallet, calls).send().wait(); + } +} diff --git a/yarn-project/bot/src/index.ts b/yarn-project/bot/src/index.ts new file mode 100644 index 000000000000..83d22f09d5f2 --- /dev/null +++ b/yarn-project/bot/src/index.ts @@ -0,0 +1,4 @@ +export { Bot } from './bot.js'; +export { BotRunner } from './runner.js'; +export { BotConfig, getBotConfigFromEnv, getBotDefaultConfig } from './config.js'; +export { createBotRunnerRpcServer } from './rpc.js'; diff --git a/yarn-project/bot/src/rpc.ts b/yarn-project/bot/src/rpc.ts new file mode 100644 index 000000000000..32487d667c14 --- /dev/null +++ b/yarn-project/bot/src/rpc.ts @@ -0,0 +1,16 @@ +import { TxHash } from '@aztec/circuit-types'; +import { AztecAddress } from '@aztec/foundation/aztec-address'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { Fr } from '@aztec/foundation/fields'; +import { JsonRpcServer } from '@aztec/foundation/json-rpc/server'; + +import { type BotRunner } from './runner.js'; + +/** + * Wraps a bot runner with a JSON RPC HTTP server. + * @param botRunner - The BotRunner. + * @returns An JSON-RPC HTTP server + */ +export function createBotRunnerRpcServer(botRunner: BotRunner) { + return new JsonRpcServer(botRunner, { AztecAddress, EthAddress, Fr, TxHash }, {}, []); +} diff --git a/yarn-project/bot/src/runner.ts b/yarn-project/bot/src/runner.ts new file mode 100644 index 000000000000..1bcd9445cebf --- /dev/null +++ b/yarn-project/bot/src/runner.ts @@ -0,0 +1,102 @@ +import { type PXE, createDebugLogger } from '@aztec/aztec.js'; + +import { Bot } from './bot.js'; +import { type BotConfig } from './config.js'; + +export class BotRunner { + private log = createDebugLogger('aztec:bot'); + private interval?: NodeJS.Timeout; + private bot?: Promise; + private pxe?: PXE; + private running: Set> = new Set(); + + public constructor(private config: BotConfig, dependencies: { pxe?: PXE } = {}) { + this.pxe = dependencies.pxe; + } + + /** Initializes the bot if needed. Blocks until the bot setup is finished. */ + public async setup() { + if (!this.bot) { + this.log.verbose(`Setting up bot`); + await this.#createBot(); + this.log.info(`Bot set up completed`); + } + } + + /** + * Initializes the bot if needed and starts sending txs at regular intervals. + * Blocks until the bot setup is finished. + */ + public async start() { + await this.setup(); + if (!this.interval) { + this.log.info(`Starting bot with interval of ${this.config.txIntervalSeconds}s`); + this.interval = setInterval(() => this.run(), this.config.txIntervalSeconds * 1000); + } + } + + /** + * Stops sending txs. Returns once all ongoing txs are finished. + */ + public async stop() { + if (this.interval) { + this.log.verbose(`Stopping bot`); + clearInterval(this.interval); + this.interval = undefined; + } + if (this.running.size > 0) { + this.log.verbose(`Waiting for ${this.running.size} running txs to finish`); + await Promise.all(this.running); + } + this.log.info(`Stopped bot`); + } + + /** Returns whether the bot is running. */ + public isRunning() { + return !!this.interval; + } + + /** + * Updates the bot config and recreates the bot. Will stop and restart the bot automatically if it was + * running when this method was called. Blocks until the new bot is set up. + */ + public async update(config: BotConfig) { + this.log.verbose(`Updating bot config`); + const wasRunning = this.isRunning(); + if (wasRunning) { + await this.stop(); + } + this.config = { ...this.config, ...config }; + await this.#createBot(); + this.log.info(`Bot config updated`); + if (wasRunning) { + await this.start(); + } + } + + /** + * Triggers a single iteration of the bot. Requires the bot to be initialized. + * Blocks until the run is finished. + */ + public async run() { + if (!this.bot) { + throw new Error(`Bot is not initialized`); + } + this.log.verbose(`Manually triggered bot run`); + const bot = await this.bot; + const promise = bot.run(); + this.running.add(promise); + await promise; + this.running.delete(promise); + } + + /** Returns the current configuration for the bot. */ + public getConfig() { + return this.config; + } + + async #createBot() { + this.bot = Bot.create(this.config, { pxe: this.pxe }); + await this.bot; + } +} diff --git a/yarn-project/bot/src/utils.ts b/yarn-project/bot/src/utils.ts new file mode 100644 index 000000000000..bb8297e143bf --- /dev/null +++ b/yarn-project/bot/src/utils.ts @@ -0,0 +1,17 @@ +import { type AztecAddress } from '@aztec/circuits.js'; +import { type TokenContract } from '@aztec/noir-contracts.js/Token'; + +/** + * Gets the private and public balance of the given token for the given address. + * @param token - Token contract. + * @param who - Address to get the balance for. + * @returns - Private and public token balances as bigints. + */ +export async function getBalances( + token: TokenContract, + who: AztecAddress, +): Promise<{ privateBalance: bigint; publicBalance: bigint }> { + const privateBalance = await token.methods.balance_of_private(who).simulate(); + const publicBalance = await token.methods.balance_of_public(who).simulate(); + return { privateBalance, publicBalance }; +} diff --git a/yarn-project/bot/tsconfig.json b/yarn-project/bot/tsconfig.json new file mode 100644 index 000000000000..eb2c5396d8a0 --- /dev/null +++ b/yarn-project/bot/tsconfig.json @@ -0,0 +1,38 @@ +{ + "extends": "..", + "compilerOptions": { + "outDir": "dest", + "rootDir": "src", + "tsBuildInfoFile": ".tsbuildinfo" + }, + "references": [ + { + "path": "../accounts" + }, + { + "path": "../aztec.js" + }, + { + "path": "../circuit-types" + }, + { + "path": "../circuits.js" + }, + { + "path": "../entrypoints" + }, + { + "path": "../foundation" + }, + { + "path": "../noir-contracts.js" + }, + { + "path": "../protocol-contracts" + }, + { + "path": "../types" + } + ], + "include": ["src"] +} diff --git a/yarn-project/circuit-types/src/interfaces/pxe.ts b/yarn-project/circuit-types/src/interfaces/pxe.ts index 78f9285bf689..dc6d5bcb8131 100644 --- a/yarn-project/circuit-types/src/interfaces/pxe.ts +++ b/yarn-project/circuit-types/src/interfaces/pxe.ts @@ -386,6 +386,13 @@ export interface PXE { */ isContractPubliclyDeployed(address: AztecAddress): Promise; + /** + * Queries the node to check whether the contract instance with the given address has been initialized, + * by checking the standard initialization nullifier. + * @param address - Address of the contract to check. + */ + isContractInitialized(address: AztecAddress): Promise; + /** * Returns the events of a specified type given search parameters. * @param type - The type of the event to search for—Encrypted, or Unencrypted. diff --git a/yarn-project/cli/src/cmds/pxe/get_contract_data.ts b/yarn-project/cli/src/cmds/pxe/get_contract_data.ts index a625bc25f321..98a67d946877 100644 --- a/yarn-project/cli/src/cmds/pxe/get_contract_data.ts +++ b/yarn-project/cli/src/cmds/pxe/get_contract_data.ts @@ -16,14 +16,20 @@ export async function getContractData( const isPrivatelyDeployed = !!instance; const isPubliclyDeployed = await client.isContractPubliclyDeployed(contractAddress); + const isInitialized = await client.isContractInitialized(contractAddress); + const initStr = isInitialized ? 'initialized' : 'not initialized'; + const addrStr = contractAddress.toString(); + if (isPubliclyDeployed && isPrivatelyDeployed) { - log(`Contract is publicly deployed at ${contractAddress.toString()}`); + log(`Contract is ${initStr} and publicly deployed at ${addrStr}`); } else if (isPrivatelyDeployed) { - log(`Contract is registered in the local pxe at ${contractAddress.toString()} but not publicly deployed`); + log(`Contract is ${initStr} and registered in the local pxe at ${addrStr} but not publicly deployed`); } else if (isPubliclyDeployed) { - log(`Contract is publicly deployed at ${contractAddress.toString()} but not registered in the local pxe`); + log(`Contract is ${initStr} and publicly deployed at ${addrStr} but not registered in the local pxe`); + } else if (isInitialized) { + log(`Contract is initialized but not publicly deployed nor registered in the local pxe at ${addrStr}`); } else { - log(`No contract found at ${contractAddress.toString()}`); + log(`No contract found at ${addrStr}`); } if (instance) { diff --git a/yarn-project/deploy_npm.sh b/yarn-project/deploy_npm.sh index 03f33a893b56..4ed483405e03 100755 --- a/yarn-project/deploy_npm.sh +++ b/yarn-project/deploy_npm.sh @@ -104,6 +104,7 @@ deploy_package archiver deploy_package p2p deploy_package prover-client deploy_package sequencer-client +deploy_package bot deploy_package prover-node deploy_package aztec-node deploy_package txe diff --git a/yarn-project/end-to-end/Earthfile b/yarn-project/end-to-end/Earthfile index 467edb6571a1..d9dae7c94e16 100644 --- a/yarn-project/end-to-end/Earthfile +++ b/yarn-project/end-to-end/Earthfile @@ -76,6 +76,9 @@ e2e-blacklist-token-contract: e2e-block-building: DO +E2E_TEST --test=./src/e2e_block_building.test.ts +e2e-bot: + DO +E2E_TEST --test=./src/e2e_bot.test.ts + e2e-card-game: DO +E2E_TEST --test=./src/e2e_card_game.test.ts diff --git a/yarn-project/end-to-end/package.json b/yarn-project/end-to-end/package.json index d942bdecdc73..76f52fa09893 100644 --- a/yarn-project/end-to-end/package.json +++ b/yarn-project/end-to-end/package.json @@ -26,6 +26,7 @@ "@aztec/aztec-node": "workspace:^", "@aztec/aztec.js": "workspace:^", "@aztec/bb-prover": "workspace:^", + "@aztec/bot": "workspace:^", "@aztec/circuit-types": "workspace:^", "@aztec/circuits.js": "workspace:^", "@aztec/entrypoints": "workspace:^", diff --git a/yarn-project/end-to-end/src/e2e_bot.test.ts b/yarn-project/end-to-end/src/e2e_bot.test.ts new file mode 100644 index 000000000000..e7ae93240011 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_bot.test.ts @@ -0,0 +1,36 @@ +import { Fr, type PXE } from '@aztec/aztec.js'; +import { Bot, type BotConfig, getBotDefaultConfig } from '@aztec/bot'; + +import { setup } from './fixtures/utils.js'; + +describe('e2e_bot', () => { + let pxe: PXE; + let teardown: () => Promise; + + let bot: Bot; + let config: BotConfig; + + beforeAll(async () => { + ({ teardown, pxe } = await setup(0)); + const senderPrivateKey = Fr.random(); + config = getBotDefaultConfig({ senderPrivateKey }); + bot = await Bot.create(config, { pxe }); + }); + + afterAll(() => teardown()); + + it('sends token transfers from the bot', async () => { + await bot.run(); + const balances = await bot.getBalances(); + expect(balances.recipient.privateBalance).toEqual(1n); + expect(balances.recipient.publicBalance).toEqual(1n); + }); + + it('reuses the same account and token contract', async () => { + const { wallet, token, recipient } = bot; + const bot2 = await Bot.create(config, { pxe }); + expect(bot2.wallet.getAddress().toString()).toEqual(wallet.getAddress().toString()); + expect(bot2.token.address.toString()).toEqual(token.address.toString()); + expect(bot2.recipient.toString()).toEqual(recipient.toString()); + }); +}); diff --git a/yarn-project/end-to-end/tsconfig.json b/yarn-project/end-to-end/tsconfig.json index a5d5341ad0bb..08932fbdb4a5 100644 --- a/yarn-project/end-to-end/tsconfig.json +++ b/yarn-project/end-to-end/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../bb-prover" }, + { + "path": "../bot" + }, { "path": "../circuit-types" }, diff --git a/yarn-project/foundation/src/collection/object.test.ts b/yarn-project/foundation/src/collection/object.test.ts index 362fc87bbd0b..033271e22894 100644 --- a/yarn-project/foundation/src/collection/object.test.ts +++ b/yarn-project/foundation/src/collection/object.test.ts @@ -1,4 +1,4 @@ -import { mapValues } from './object.js'; +import { compact, mapValues } from './object.js'; describe('mapValues', () => { it('should return a new object with mapped values', () => { @@ -28,3 +28,35 @@ describe('mapValues', () => { expect(result).toEqual({ a: 'string', b: 'boolean', c: 'object' }); }); }); + +describe('compact', () => { + it('should remove keys with undefined values', () => { + const obj = { a: 1, b: undefined, c: 3 }; + const result = compact(obj); + expect(result).toEqual({ a: 1, c: 3 }); + }); + + it('should not remove keys with falsey but not undefined values', () => { + const obj = { a: false, b: 0, c: '', d: null, e: [] }; + const result = compact(obj); + expect(result).toEqual(obj); + }); + + it('should handle an empty object', () => { + const obj = {}; + const result = compact(obj); + expect(result).toEqual({}); + }); + + it('should handle an object with all undefined values', () => { + const obj = { a: undefined, b: undefined, c: undefined }; + const result = compact(obj); + expect(result).toEqual({}); + }); + + it('should handle an object with no undefined values', () => { + const obj = { a: 1, b: 2, c: 3 }; + const result = compact(obj); + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); +}); diff --git a/yarn-project/foundation/src/collection/object.ts b/yarn-project/foundation/src/collection/object.ts index 912599bc5515..b55c3b8688fb 100644 --- a/yarn-project/foundation/src/collection/object.ts +++ b/yarn-project/foundation/src/collection/object.ts @@ -17,3 +17,14 @@ export function mapValues( } return result; } + +/** Returns a new object where all keys with undefined values have been removed. */ +export function compact(obj: T): { [P in keyof T]+?: Exclude } { + const result: any = {}; + for (const key in obj) { + if (obj[key] !== undefined) { + result[key] = obj[key]; + } + } + return result; +} diff --git a/yarn-project/package.json b/yarn-project/package.json index af28c7bce270..054dc6d5ab84 100644 --- a/yarn-project/package.json +++ b/yarn-project/package.json @@ -25,6 +25,7 @@ "aztec-faucet", "aztec-node", "bb-prover", + "bot", "builder", "pxe", "aztec", diff --git a/yarn-project/pxe/src/pxe_service/pxe_service.ts b/yarn-project/pxe/src/pxe_service/pxe_service.ts index 9c2c0fc90657..8179f4fbeb95 100644 --- a/yarn-project/pxe/src/pxe_service/pxe_service.ts +++ b/yarn-project/pxe/src/pxe_service/pxe_service.ts @@ -855,6 +855,11 @@ export class PXEService implements PXE { return !!(await this.node.getContract(address)); } + public async isContractInitialized(address: AztecAddress): Promise { + const initNullifier = siloNullifier(address, address); + return !!(await this.node.getNullifierMembershipWitness('latest', initNullifier)); + } + public getEvents( type: EventType.Encrypted, eventMetadata: EventMetadata, diff --git a/yarn-project/tsconfig.json b/yarn-project/tsconfig.json index e5706e34b3b4..436e1b32328f 100644 --- a/yarn-project/tsconfig.json +++ b/yarn-project/tsconfig.json @@ -27,6 +27,7 @@ { "path": "aztec.js/tsconfig.json" }, { "path": "aztec-node/tsconfig.json" }, { "path": "bb-prover/tsconfig.json" }, + { "path": "bot/tsconfig.json" }, { "path": "pxe/tsconfig.json" }, { "path": "aztec/tsconfig.json" }, { "path": "circuits.js/tsconfig.json" }, diff --git a/yarn-project/yarn.lock b/yarn-project/yarn.lock index 7fda7974f19d..7edca84b17fb 100644 --- a/yarn-project/yarn.lock +++ b/yarn-project/yarn.lock @@ -195,6 +195,7 @@ __metadata: "@aztec/aztec-node": "workspace:^" "@aztec/aztec.js": "workspace:^" "@aztec/bb-prover": "workspace:^" + "@aztec/bot": "workspace:^" "@aztec/builder": "workspace:^" "@aztec/circuit-types": "workspace:^" "@aztec/circuits.js": "workspace:^" @@ -275,6 +276,33 @@ __metadata: languageName: node linkType: soft +"@aztec/bot@workspace:^, @aztec/bot@workspace:bot": + version: 0.0.0-use.local + resolution: "@aztec/bot@workspace:bot" + dependencies: + "@aztec/accounts": "workspace:^" + "@aztec/aztec.js": "workspace:^" + "@aztec/circuit-types": "workspace:^" + "@aztec/circuits.js": "workspace:^" + "@aztec/entrypoints": "workspace:^" + "@aztec/foundation": "workspace:^" + "@aztec/noir-contracts.js": "workspace:^" + "@aztec/protocol-contracts": "workspace:^" + "@aztec/types": "workspace:^" + "@jest/globals": ^29.5.0 + "@types/jest": ^29.5.0 + "@types/memdown": ^3.0.0 + "@types/node": ^18.7.23 + "@types/source-map-support": ^0.5.10 + jest: ^29.5.0 + jest-mock-extended: ^3.0.3 + source-map-support: ^0.5.21 + ts-node: ^10.9.1 + tslib: ^2.4.0 + typescript: ^5.0.4 + languageName: unknown + linkType: soft + "@aztec/builder@workspace:^, @aztec/builder@workspace:builder": version: 0.0.0-use.local resolution: "@aztec/builder@workspace:builder" @@ -402,6 +430,7 @@ __metadata: "@aztec/aztec-node": "workspace:^" "@aztec/aztec.js": "workspace:^" "@aztec/bb-prover": "workspace:^" + "@aztec/bot": "workspace:^" "@aztec/circuit-types": "workspace:^" "@aztec/circuits.js": "workspace:^" "@aztec/entrypoints": "workspace:^"