diff --git a/.gitignore b/.gitignore index 06716bf18b9e..dbcd78ff2261 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ docs/.yarn/install-state.gz docs/docs/protocol-specs/public-vm/gen/ __pycache__ + +*.local.md diff --git a/ci3/grind_test b/ci3/grind_test index ba70d81797f3..67a730f751ba 100755 --- a/ci3/grind_test +++ b/ci3/grind_test @@ -41,7 +41,8 @@ function build { function grind { echo_header "grinding for $timeout" - local par_cmd="CI=0 TRACK_TEST_FAIL=1 run_test_cmd '${hash_prefix}:NAME=test-{} ${test_cmd}'" + # NAME_POSTFIX allows for grinding compose tests in parallel. + local par_cmd="NAME_POSTFIX={} CI=0 TRACK_TEST_FAIL=1 run_test_cmd '${hash_prefix}:NAME=test-{} ${test_cmd}'" local joblog=$(mktemp) # Run for full timeout, logging all results (no --halt, collect all failure data) diff --git a/spartan/aztec-bot/templates/env.configmap.yaml b/spartan/aztec-bot/templates/env.configmap.yaml index da4414c48818..a741eacd919c 100644 --- a/spartan/aztec-bot/templates/env.configmap.yaml +++ b/spartan/aztec-bot/templates/env.configmap.yaml @@ -12,7 +12,7 @@ data: PXE_SYNC_CHAIN_TIP: {{ .Values.bot.pxeSyncChainTip | quote }} BOT_NO_START: {{ .Values.bot.botNoStart | quote }} BOT_FEE_PAYMENT_METHOD: {{ .Values.bot.feePaymentMethod | quote }} - BOT_AMM_TXS: {{ .Values.bot.ammTxs | quote }} + BOT_MODE: {{ .Values.bot.botMode | quote }} BOT_MAX_CONSECUTIVE_ERRORS: {{ .Values.bot.maxErrors | quote }} BOT_STOP_WHEN_UNHEALTHY: {{ .Values.bot.stopIfUnhealthy | quote }} AZTEC_NODE_URL: {{ .Values.bot.nodeUrl | quote }} diff --git a/spartan/aztec-bot/values.yaml b/spartan/aztec-bot/values.yaml index 10fecf8ed417..5c55acc21846 100644 --- a/spartan/aztec-bot/values.yaml +++ b/spartan/aztec-bot/values.yaml @@ -15,7 +15,7 @@ bot: pxeSyncChainTip: "checkpointed" botNoStart: false feePaymentMethod: "fee_juice" - ammTxs: false + botMode: "transfer" maxErrors: 3 stopIfUnhealthy: true nodeUrl: "" diff --git a/spartan/environments/mbps-net.env b/spartan/environments/mbps-net.env index 083e25e74bee..0493815e812c 100644 --- a/spartan/environments/mbps-net.env +++ b/spartan/environments/mbps-net.env @@ -25,7 +25,7 @@ TEST_ACCOUNTS=true SPONSORED_FPC=true SEQ_MIN_TX_PER_BLOCK=0 SEQ_MAX_TX_PER_BLOCK=8 -AZTEC_EPOCH_DURATION=32 +AZTEC_EPOCH_DURATION=8 REAL_VERIFIER=false PROVER_REAL_PROOFS=false @@ -45,16 +45,24 @@ PUBLISHERS_PER_PROVER=2 PROVER_PUBLISHER_MNEMONIC_START_INDEX=8000 BOT_TRANSFERS_REPLICAS=1 -BOT_TRANSFERS_TX_INTERVAL_SECONDS=8 -BOT_TRANSFERS_FOLLOW_CHAIN=PENDING +BOT_TRANSFERS_TX_INTERVAL_SECONDS=4 +BOT_TRANSFERS_FOLLOW_CHAIN=PROPOSED +BOT_TRANSFERS_PXE_SYNC_CHAIN_TIP=proposed BOT_SWAPS_REPLICAS=1 -BOT_SWAPS_FOLLOW_CHAIN=PENDING -BOT_SWAPS_TX_INTERVAL_SECONDS=8 +BOT_SWAPS_TX_INTERVAL_SECONDS=4 +BOT_SWAPS_FOLLOW_CHAIN=PROPOSED +BOT_SWAPS_PXE_SYNC_CHAIN_TIP=proposed + +BOT_CROSS_CHAIN_REPLICAS=1 +BOT_CROSS_CHAIN_TX_INTERVAL_SECONDS=8 +BOT_CROSS_CHAIN_FOLLOW_CHAIN=PROPOSED +BOT_CROSS_CHAIN_PXE_SYNC_CHAIN_TIP=proposed REDEPLOY_ROLLUP_CONTRACTS=true DEBUG_P2P_INSTRUMENT_MESSAGES=true VALIDATOR_HA_REPLICAS=1 -VALIDATOR_RESOURCE_PROFILE="prod-spot" \ No newline at end of file +VALIDATOR_RESOURCE_PROFILE="prod-spot" + diff --git a/spartan/environments/staging-public.env b/spartan/environments/staging-public.env index 177c427569db..16825e314652 100644 --- a/spartan/environments/staging-public.env +++ b/spartan/environments/staging-public.env @@ -39,8 +39,12 @@ PROVER_PUBLISHER_MNEMONIC_START_INDEX=8000 BOT_TRANSFERS_REPLICAS=1 BOT_TRANSFERS_TX_INTERVAL_SECONDS=250 -BOT_TRANSFERS_FOLLOW_CHAIN=PENDING +BOT_TRANSFERS_FOLLOW_CHAIN=PROPOSED BOT_SWAPS_REPLICAS=1 -BOT_SWAPS_FOLLOW_CHAIN=PENDING +BOT_SWAPS_FOLLOW_CHAIN=PROPOSED BOT_SWAPS_TX_INTERVAL_SECONDS=350 + +BOT_CROSS_CHAIN_REPLICAS=1 +BOT_CROSS_CHAIN_TX_INTERVAL_SECONDS=250 +BOT_CROSS_CHAIN_FOLLOW_CHAIN=PROPOSED diff --git a/spartan/scripts/calculate_publisher_indices.sh b/spartan/scripts/calculate_publisher_indices.sh index 70802390ede8..709cfd52e8cd 100755 --- a/spartan/scripts/calculate_publisher_indices.sh +++ b/spartan/scripts/calculate_publisher_indices.sh @@ -70,6 +70,15 @@ if (( BOT_SWAPS_REPLICAS > 0 )); then BOT_SWAPS_INDICES=$(seq "$BOT_SWAPS_MNEMONIC_START_INDEX" $((BOT_SWAPS_MNEMONIC_START_INDEX + BOT_SWAPS_REPLICAS - 1)) | tr '\n' ',' | sed 's/,$//') fi +# Calculate cross-chain bot indices +BOT_CROSS_CHAIN_REPLICAS=${BOT_CROSS_CHAIN_REPLICAS:-0} +BOT_CROSS_CHAIN_MNEMONIC_START_INDEX=${BOT_CROSS_CHAIN_MNEMONIC_START_INDEX:-7200} + +BOT_CROSS_CHAIN_INDICES="" +if (( BOT_CROSS_CHAIN_REPLICAS > 0 )); then + BOT_CROSS_CHAIN_INDICES=$(seq "$BOT_CROSS_CHAIN_MNEMONIC_START_INDEX" $((BOT_CROSS_CHAIN_MNEMONIC_START_INDEX + BOT_CROSS_CHAIN_REPLICAS - 1)) | tr '\n' ',' | sed 's/,$//') +fi + # Combine all publisher indices ALL_PUBLISHER_INDICES="" if [ -n "$VALIDATOR_PUBLISHER_INDICES" ]; then @@ -100,5 +109,13 @@ if [ -n "$BOT_SWAPS_INDICES" ]; then fi fi +if [ -n "$BOT_CROSS_CHAIN_INDICES" ]; then + if [ -n "$ALL_PUBLISHER_INDICES" ]; then + ALL_PUBLISHER_INDICES="${ALL_PUBLISHER_INDICES},${BOT_CROSS_CHAIN_INDICES}" + else + ALL_PUBLISHER_INDICES="$BOT_CROSS_CHAIN_INDICES" + fi +fi + # Output the comma-separated list of indices echo "$ALL_PUBLISHER_INDICES" diff --git a/spartan/scripts/deploy_network.sh b/spartan/scripts/deploy_network.sh index 9f38cdd4dbc9..abbdaf060174 100755 --- a/spartan/scripts/deploy_network.sh +++ b/spartan/scripts/deploy_network.sh @@ -109,14 +109,19 @@ DEPLOY_ARCHIVAL_NODE=${DEPLOY_ARCHIVAL_NODE:-false} BOT_RESOURCE_PROFILE=${BOT_RESOURCE_PROFILE:-${RESOURCE_PROFILE}} BOT_TRANSFERS_MNEMONIC_START_INDEX=${BOT_TRANSFERS_MNEMONIC_START_INDEX:-7000} BOT_SWAPS_MNEMONIC_START_INDEX=${BOT_SWAPS_MNEMONIC_START_INDEX:-7100} +BOT_CROSS_CHAIN_MNEMONIC_START_INDEX=${BOT_CROSS_CHAIN_MNEMONIC_START_INDEX:-7200} BOT_TRANSFERS_REPLICAS=${BOT_TRANSFERS_REPLICAS:-0} BOT_SWAPS_REPLICAS=${BOT_SWAPS_REPLICAS:-0} +BOT_CROSS_CHAIN_REPLICAS=${BOT_CROSS_CHAIN_REPLICAS:-0} BOT_TRANSFERS_TX_INTERVAL_SECONDS=${BOT_TRANSFERS_TX_INTERVAL_SECONDS:-60} BOT_SWAPS_TX_INTERVAL_SECONDS=${BOT_SWAPS_TX_INTERVAL_SECONDS:-60} +BOT_CROSS_CHAIN_TX_INTERVAL_SECONDS=${BOT_CROSS_CHAIN_TX_INTERVAL_SECONDS:-10} BOT_TRANSFERS_FOLLOW_CHAIN=${BOT_TRANSFERS_FOLLOW_CHAIN:-NONE} BOT_SWAPS_FOLLOW_CHAIN=${BOT_SWAPS_FOLLOW_CHAIN:-NONE} +BOT_CROSS_CHAIN_FOLLOW_CHAIN=${BOT_CROSS_CHAIN_FOLLOW_CHAIN:-PENDING} BOT_TRANSFERS_PXE_SYNC_CHAIN_TIP=${BOT_TRANSFERS_PXE_SYNC_CHAIN_TIP:-checkpointed} BOT_SWAPS_PXE_SYNC_CHAIN_TIP=${BOT_SWAPS_PXE_SYNC_CHAIN_TIP:-checkpointed} +BOT_CROSS_CHAIN_PXE_SYNC_CHAIN_TIP=${BOT_CROSS_CHAIN_PXE_SYNC_CHAIN_TIP:-checkpointed} RPC_INGRESS_ENABLED=${RPC_INGRESS_ENABLED:-false} RPC_INGRESS_HOSTS=${RPC_INGRESS_HOSTS:-[]} @@ -209,6 +214,22 @@ if (( TOTAL_PROVER_PUBLISHERS > 0 )); then LABS_INFRA_INDICES="${LABS_INFRA_INDICES},${PROVER_PUBLISHER_RANGE}" fi +# Add bot L1 accounts to prefunding list +if (( BOT_TRANSFERS_REPLICAS > 0 )); then + BOT_TRANSFERS_RANGE=$(seq "$BOT_TRANSFERS_MNEMONIC_START_INDEX" $((BOT_TRANSFERS_MNEMONIC_START_INDEX + BOT_TRANSFERS_REPLICAS - 1)) | tr '\n' ',' | sed 's/,$//') + LABS_INFRA_INDICES="${LABS_INFRA_INDICES},${BOT_TRANSFERS_RANGE}" +fi + +if (( BOT_SWAPS_REPLICAS > 0 )); then + BOT_SWAPS_RANGE=$(seq "$BOT_SWAPS_MNEMONIC_START_INDEX" $((BOT_SWAPS_MNEMONIC_START_INDEX + BOT_SWAPS_REPLICAS - 1)) | tr '\n' ',' | sed 's/,$//') + LABS_INFRA_INDICES="${LABS_INFRA_INDICES},${BOT_SWAPS_RANGE}" +fi + +if (( BOT_CROSS_CHAIN_REPLICAS > 0 )); then + BOT_CROSS_CHAIN_RANGE=$(seq "$BOT_CROSS_CHAIN_MNEMONIC_START_INDEX" $((BOT_CROSS_CHAIN_MNEMONIC_START_INDEX + BOT_CROSS_CHAIN_REPLICAS - 1)) | tr '\n' ',' | sed 's/,$//') + LABS_INFRA_INDICES="${LABS_INFRA_INDICES},${BOT_CROSS_CHAIN_RANGE}" +fi + # Ensure docker image provided (not needed for pure teardowns) if [[ -z "${AZTEC_DOCKER_IMAGE:-}" && ("${CREATE_AZTEC_INFRA:-}" == "true" || "${CREATE_ROLLUP_CONTRACTS:-}" == "true") ]]; then die "AZTEC_DOCKER_IMAGE is not set" @@ -519,10 +540,16 @@ BOT_SWAPS_MNEMONIC_START_INDEX = ${BOT_SWAPS_MNEMONIC_START_INDEX} BOT_SWAPS_REPLICAS = ${BOT_SWAPS_REPLICAS} BOT_SWAPS_TX_INTERVAL_SECONDS = ${BOT_SWAPS_TX_INTERVAL_SECONDS} BOT_SWAPS_FOLLOW_CHAIN = "${BOT_SWAPS_FOLLOW_CHAIN}" +BOT_CROSS_CHAIN_MNEMONIC_START_INDEX = ${BOT_CROSS_CHAIN_MNEMONIC_START_INDEX} +BOT_CROSS_CHAIN_REPLICAS = ${BOT_CROSS_CHAIN_REPLICAS} +BOT_CROSS_CHAIN_TX_INTERVAL_SECONDS = ${BOT_CROSS_CHAIN_TX_INTERVAL_SECONDS} +BOT_CROSS_CHAIN_FOLLOW_CHAIN = "${BOT_CROSS_CHAIN_FOLLOW_CHAIN}" BOT_TRANSFERS_PXE_SYNC_CHAIN_TIP = "${BOT_TRANSFERS_PXE_SYNC_CHAIN_TIP}" BOT_SWAPS_PXE_SYNC_CHAIN_TIP = "${BOT_SWAPS_PXE_SYNC_CHAIN_TIP}" +BOT_CROSS_CHAIN_PXE_SYNC_CHAIN_TIP = "${BOT_CROSS_CHAIN_PXE_SYNC_CHAIN_TIP}" BOT_TRANSFERS_L2_PRIVATE_KEY = "${BOT_TRANSFERS_L2_PRIVATE_KEY:-0xcafe01}" BOT_SWAPS_L2_PRIVATE_KEY = "${BOT_SWAPS_L2_PRIVATE_KEY:-0xcafe02}" +BOT_CROSS_CHAIN_L2_PRIVATE_KEY = "${BOT_CROSS_CHAIN_L2_PRIVATE_KEY:-0xcafe03}" PROVER_AGENTS_PER_PROVER = ${PROVER_AGENTS_PER_PROVER} PROVER_AGENT_POLL_INTERVAL_MS = ${PROVER_AGENT_POLL_INTERVAL_MS} diff --git a/spartan/terraform/deploy-aztec-infra/main.tf b/spartan/terraform/deploy-aztec-infra/main.tf index 2e36227b6d2f..dccea9a87427 100644 --- a/spartan/terraform/deploy-aztec-infra/main.tf +++ b/spartan/terraform/deploy-aztec-infra/main.tf @@ -625,6 +625,30 @@ locals { bootstrap_nodes_path = "" wait = false } : null + + # Optional: cross-chain message bots + bot_cross_chain = var.BOT_CROSS_CHAIN_REPLICAS > 0 ? { + name = "${var.RELEASE_PREFIX}-bot-cross-chain" + chart = "aztec-bot" + values = [ + "common.yaml", + "bot-cross-chain.yaml", + "bot-resources-${var.BOT_RESOURCE_PROFILE}.yaml", + ] + custom_settings = { + "bot.replicaCount" = var.BOT_CROSS_CHAIN_REPLICAS + "bot.txIntervalSeconds" = var.BOT_CROSS_CHAIN_TX_INTERVAL_SECONDS + "bot.followChain" = var.BOT_CROSS_CHAIN_FOLLOW_CHAIN + "bot.pxeSyncChainTip" = var.BOT_CROSS_CHAIN_PXE_SYNC_CHAIN_TIP + "bot.botPrivateKey" = var.BOT_CROSS_CHAIN_L2_PRIVATE_KEY + "bot.nodeUrl" = local.internal_rpc_url + "bot.mnemonic" = var.BOT_MNEMONIC + "bot.mnemonicStartIndex" = var.BOT_CROSS_CHAIN_MNEMONIC_START_INDEX + } + boot_node_host_path = "" + bootstrap_nodes_path = "" + wait = false + } : null }, local.validator_releases) } diff --git a/spartan/terraform/deploy-aztec-infra/values/bot-amm-swaps.yaml b/spartan/terraform/deploy-aztec-infra/values/bot-amm-swaps.yaml index 9b580ecb6bb4..04339e33cb63 100644 --- a/spartan/terraform/deploy-aztec-infra/values/bot-amm-swaps.yaml +++ b/spartan/terraform/deploy-aztec-infra/values/bot-amm-swaps.yaml @@ -1,8 +1,8 @@ bot: replicaCount: 1 txIntervalSeconds: 10 - ammTxs: true - followChain: "PENDING" + botMode: "amm" + followChain: "PROPOSED" feePaymentMethod: "fee_juice" maxErrors: 3 stopIfUnhealthy: true diff --git a/spartan/terraform/deploy-aztec-infra/values/bot-cross-chain.yaml b/spartan/terraform/deploy-aztec-infra/values/bot-cross-chain.yaml new file mode 100644 index 000000000000..591a6a6291f7 --- /dev/null +++ b/spartan/terraform/deploy-aztec-infra/values/bot-cross-chain.yaml @@ -0,0 +1,25 @@ +bot: + replicaCount: 1 + botMode: "crosschain" + txIntervalSeconds: 10 + followChain: "PROPOSED" + feePaymentMethod: "fee_juice" + maxErrors: 3 + stopIfUnhealthy: true + botPrivateKey: "0xcafe03" + + persistence: + enabled: true + statefulSet: + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 2Gi + + service: + headless: + enabled: true diff --git a/spartan/terraform/deploy-aztec-infra/values/bot-token-transfer.yaml b/spartan/terraform/deploy-aztec-infra/values/bot-token-transfer.yaml index 26b895322bff..28d94d9dfd3d 100644 --- a/spartan/terraform/deploy-aztec-infra/values/bot-token-transfer.yaml +++ b/spartan/terraform/deploy-aztec-infra/values/bot-token-transfer.yaml @@ -1,6 +1,6 @@ bot: replicaCount: 1 - ammTxs: false + botMode: "transfer" txIntervalSeconds: 10 privateTransfersPerTx: 0 publicTransfersPerTx: 1 diff --git a/spartan/terraform/deploy-aztec-infra/variables.tf b/spartan/terraform/deploy-aztec-infra/variables.tf index 1f38933f523b..f3f27dc0cde8 100644 --- a/spartan/terraform/deploy-aztec-infra/variables.tf +++ b/spartan/terraform/deploy-aztec-infra/variables.tf @@ -574,6 +574,43 @@ variable "BOT_SWAPS_L2_PRIVATE_KEY" { default = null } +variable "BOT_CROSS_CHAIN_MNEMONIC_START_INDEX" { + description = "The cross-chain bot mnemonic start index" + type = string + default = "" +} + +variable "BOT_CROSS_CHAIN_REPLICAS" { + description = "Number of cross-chain bot replicas to deploy (0 to disable)" + type = number + default = 0 +} + +variable "BOT_CROSS_CHAIN_TX_INTERVAL_SECONDS" { + description = "Interval in seconds between cross-chain bot transactions" + type = number + default = 10 +} + +variable "BOT_CROSS_CHAIN_FOLLOW_CHAIN" { + description = "Cross-chain bot follow-chain mode" + type = string + default = "PENDING" +} + +variable "BOT_CROSS_CHAIN_L2_PRIVATE_KEY" { + description = "Private key for the cross-chain bot (hex string starting with 0x)" + type = string + nullable = true + default = null +} + +variable "BOT_CROSS_CHAIN_PXE_SYNC_CHAIN_TIP" { + description = "Cross-chain bot PXE sync chain tip mode (e.g., checkpointed)" + type = string + default = "checkpointed" +} + # RPC ingress configuration (GKE-specific) variable "RPC_INGRESS_ENABLED" { description = "Enable GKE ingress for RPC nodes" diff --git a/yarn-project/bb-prover/src/instrumentation.ts b/yarn-project/bb-prover/src/instrumentation.ts index 668da5601747..13a15053df10 100644 --- a/yarn-project/bb-prover/src/instrumentation.ts +++ b/yarn-project/bb-prover/src/instrumentation.ts @@ -58,10 +58,18 @@ export class ProverInstrumentation { circuitName: CircuitName, timerOrMS: Timer | number, ) { - const s = typeof timerOrMS === 'number' ? timerOrMS / 1000 : timerOrMS.s(); - this[metric].record(s, { - [Attributes.PROTOCOL_CIRCUIT_NAME]: circuitName, - }); + // Simulation duration is stored in ms, while the others are stored in seconds + if (metric === 'simulationDuration') { + const ms = typeof timerOrMS === 'number' ? timerOrMS : timerOrMS.ms(); + this[metric].record(Math.trunc(ms), { + [Attributes.PROTOCOL_CIRCUIT_NAME]: circuitName, + }); + } else { + const s = typeof timerOrMS === 'number' ? timerOrMS / 1000 : timerOrMS.s(); + this[metric].record(s, { + [Attributes.PROTOCOL_CIRCUIT_NAME]: circuitName, + }); + } } /** diff --git a/yarn-project/bot/package.json b/yarn-project/bot/package.json index c10c13990873..e655415db67d 100644 --- a/yarn-project/bot/package.json +++ b/yarn-project/bot/package.json @@ -60,14 +60,17 @@ "@aztec/ethereum": "workspace:^", "@aztec/foundation": "workspace:^", "@aztec/kv-store": "workspace:^", + "@aztec/l1-artifacts": "workspace:^", "@aztec/noir-contracts.js": "workspace:^", "@aztec/noir-protocol-circuits-types": "workspace:^", + "@aztec/noir-test-contracts.js": "workspace:^", "@aztec/protocol-contracts": "workspace:^", "@aztec/stdlib": "workspace:^", "@aztec/telemetry-client": "workspace:^", "@aztec/wallets": "workspace:^", "source-map-support": "^0.5.21", "tslib": "^2.4.0", + "viem": "npm:@aztec/viem@2.38.2", "zod": "^3.23.8" }, "devDependencies": { diff --git a/yarn-project/bot/src/config.ts b/yarn-project/bot/src/config.ts index d02ef5ebb401..1e1b104be9b9 100644 --- a/yarn-project/bot/src/config.ts +++ b/yarn-project/bot/src/config.ts @@ -22,6 +22,9 @@ import { z } from 'zod'; const BotFollowChain = ['NONE', 'PROPOSED', 'CHECKPOINTED', 'PROVEN'] as const; type BotFollowChain = (typeof BotFollowChain)[number]; +const BotMode = ['transfer', 'amm', 'crosschain'] as const; +type BotMode = (typeof BotMode)[number]; + export enum SupportedTokenContracts { TokenContract = 'TokenContract', PrivateTokenContract = 'PrivateTokenContract', @@ -76,8 +79,12 @@ export type BotConfig = { maxConsecutiveErrors: number; /** Stops the bot if service becomes unhealthy */ stopWhenUnhealthy: boolean; - /** Deploy an AMM contract and do swaps instead of transfers */ - ammTxs: boolean; + /** Bot mode: transfer, amm, or crosschain. */ + botMode: BotMode; + /** Number of L2→L1 messages per tx (crosschain mode). */ + l2ToL1MessagesPerTx: number; + /** Max L1→L2 messages to keep in-flight (crosschain mode). */ + l1ToL2SeedCount: number; } & Pick; export const BotConfigSchema = zodFor()( @@ -107,7 +114,9 @@ export const BotConfigSchema = zodFor()( contract: z.nativeEnum(SupportedTokenContracts), maxConsecutiveErrors: z.number().int().nonnegative(), stopWhenUnhealthy: z.boolean(), - ammTxs: z.boolean().default(false), + botMode: z.enum(BotMode).default('transfer'), + l2ToL1MessagesPerTx: z.number().int().nonnegative().default(1), + l1ToL2SeedCount: z.number().int().nonnegative().default(1), dataDirectory: z.string().optional(), dataStoreMapSizeKb: z.number().optional(), }) @@ -268,10 +277,26 @@ export const botConfigMappings: ConfigMappingsType = { description: 'Stops the bot if service becomes unhealthy', ...booleanConfigHelper(false), }, - ammTxs: { - env: 'BOT_AMM_TXS', - description: 'Deploy an AMM and send swaps to it', - ...booleanConfigHelper(false), + botMode: { + env: 'BOT_MODE', + description: 'Bot mode: transfer, amm, or crosschain', + defaultValue: 'transfer' as BotMode, + parseEnv(val: string) { + if (!(BotMode as readonly string[]).includes(val)) { + throw new Error(`Invalid value for BOT_MODE: ${val}`); + } + return val as BotMode; + }, + }, + l2ToL1MessagesPerTx: { + env: 'BOT_L2_TO_L1_MESSAGES_PER_TX', + description: 'Number of L2→L1 messages per tx (crosschain mode)', + ...numberConfigHelper(1), + }, + l1ToL2SeedCount: { + env: 'BOT_L1_TO_L2_SEED_COUNT', + description: 'Max L1→L2 messages to keep in-flight (crosschain mode)', + ...numberConfigHelper(1), }, ...pickConfigMappings(dataConfigMappings, ['dataStoreMapSizeKb', 'dataDirectory']), }; diff --git a/yarn-project/bot/src/cross_chain_bot.test.ts b/yarn-project/bot/src/cross_chain_bot.test.ts new file mode 100644 index 000000000000..189a9cc8f946 --- /dev/null +++ b/yarn-project/bot/src/cross_chain_bot.test.ts @@ -0,0 +1,15 @@ +import type { AztecNode, AztecNodeAdmin } from '@aztec/stdlib/interfaces/client'; +import type { EmbeddedWallet } from '@aztec/wallets/embedded'; + +import { type BotConfig, getBotDefaultConfig } from './config.js'; +import { CrossChainBot } from './cross_chain_bot.js'; +import type { BotStore } from './store/index.js'; + +describe('CrossChainBot', () => { + it('rejects followChain=NONE', async () => { + const config: BotConfig = { ...getBotDefaultConfig(), botMode: 'crosschain', followChain: 'NONE' }; + await expect( + CrossChainBot.create(config, {} as EmbeddedWallet, {} as AztecNode, {} as AztecNodeAdmin, {} as BotStore), + ).rejects.toThrow(/followChain/); + }); +}); diff --git a/yarn-project/bot/src/cross_chain_bot.ts b/yarn-project/bot/src/cross_chain_bot.ts new file mode 100644 index 000000000000..0165b5a778a8 --- /dev/null +++ b/yarn-project/bot/src/cross_chain_bot.ts @@ -0,0 +1,209 @@ +/** + * CrossChainBot exercises L2->L1 and L1->L2 messaging. + * + * createAndSendTx onTxMined + * ────────────────────────────────────── ────────────────────────────── + * + * 1. SEED (fire-and-forget) 3. VERIFY L2->L1 + * if store has fewer pending messages Query getTxEffect, confirm + * than seedCount and no seed is the expected L2->L1 messages + * in-flight: appeared in tx effects. + * * kick off L1 inbox tx + * * store msg on completion + * + * 2. BUILD & SEND BATCH + * Always: + * N x create_l2_to_l1_message + * (random content, fixed + * L1 recipient) + * If a ready L1->L2 msg exists: + * 1 x consume_message_from_ + * arbitrary_sender_public + * delete consumed msg from store + * Send batch tx (no wait) + * + */ +import { AztecAddress } from '@aztec/aztec.js/addresses'; +import { BatchCall, NO_WAIT } from '@aztec/aztec.js/contracts'; +import { isL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; +import { TxHash, TxReceipt } from '@aztec/aztec.js/tx'; +import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import type { TestContract } from '@aztec/noir-test-contracts.js/Test'; +import type { AztecNode, AztecNodeAdmin } from '@aztec/stdlib/interfaces/client'; +import type { EmbeddedWallet } from '@aztec/wallets/embedded'; + +import { BaseBot } from './base_bot.js'; +import type { BotConfig } from './config.js'; +import { BotFactory } from './factory.js'; +import { seedL1ToL2Message } from './l1_to_l2_seeding.js'; +import type { BotStore, PendingL1ToL2Message } from './store/index.js'; + +/** Stale message threshold: messages older than this are removed. */ +const STALE_MESSAGE_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours + +/** Bot that exercises both L2→L1 and L1→L2 cross-chain messaging. */ +export class CrossChainBot extends BaseBot { + private l2ToL1Sent = 0; + private l1ToL2Consumed = 0; + private pendingSeedPromise: Promise | undefined; + + protected constructor( + node: AztecNode, + wallet: EmbeddedWallet, + defaultAccountAddress: AztecAddress, + private readonly contract: TestContract, + private readonly l1Client: ExtendedViemWalletClient, + private readonly l1Recipient: EthAddress, + private readonly inboxAddress: EthAddress, + private readonly rollupVersion: bigint, + private readonly store: BotStore, + config: BotConfig, + ) { + super(node, wallet, defaultAccountAddress, config); + } + + static async create( + config: BotConfig, + wallet: EmbeddedWallet, + aztecNode: AztecNode, + aztecNodeAdmin: AztecNodeAdmin | undefined, + store: BotStore, + ): Promise { + if (config.followChain === 'NONE') { + throw new Error(`CrossChainBot requires followChain to be set (got NONE)`); + } + const factory = new BotFactory(config, wallet, store, aztecNode, aztecNodeAdmin); + const { defaultAccountAddress, contract, l1Client, rollupVersion } = await factory.setupCrossChain(); + const l1Recipient = EthAddress.fromString(l1Client.account!.address); + const { l1ContractAddresses } = await aztecNode.getNodeInfo(); + const inboxAddress = EthAddress.fromString(l1ContractAddresses.inboxAddress.toString()); + return new CrossChainBot( + aztecNode, + wallet, + defaultAccountAddress, + contract, + l1Client, + l1Recipient, + inboxAddress, + rollupVersion, + store, + config, + ); + } + + protected async createAndSendTx(logCtx: object): Promise { + const pendingMessages = await this.store.getUnconsumedL1ToL2Messages(); + + // Send an L1→L2 message if we're below the threshold and not already seeding one + if (pendingMessages.length < this.config.l1ToL2SeedCount && !this.pendingSeedPromise) { + this.pendingSeedPromise = this.seedNewL1ToL2Message() + .catch(err => this.log.warn(`Failed to seed L1→L2 message: ${err}`, logCtx)) + .finally(() => { + this.pendingSeedPromise = undefined; + }); + } + + // Build batch: always L2→L1, optionally consume L1→L2 + const calls = []; + + // L2→L1: create messages with random content + for (let i = 0; i < this.config.l2ToL1MessagesPerTx; i++) { + calls.push( + this.contract.methods.create_l2_to_l1_message_arbitrary_recipient_public(Fr.random(), this.l1Recipient), + ); + } + + // L1→L2: consume oldest ready message if available + const readyMsg = await this.getReadyL1ToL2Message(pendingMessages); + if (readyMsg) { + calls.push( + this.contract.methods.consume_message_from_arbitrary_sender_public( + Fr.fromHexString(readyMsg.content), + Fr.fromHexString(readyMsg.secret), + EthAddress.fromString(readyMsg.sender), + new Fr(BigInt(readyMsg.globalLeafIndex)), + ), + ); + // Delete consumed message immediately so it works with FOLLOW_CHAIN=NONE + await this.store.deleteL1ToL2Message(readyMsg.msgHash); + this.l1ToL2Consumed++; + } else { + this.log.warn(`No ready L1→L2 message to consume`, { + ...logCtx, + pendingCount: pendingMessages.length, + }); + } + + const batch = new BatchCall(this.wallet, calls); + const opts = await this.getSendMethodOpts(batch); + + this.log.verbose(`Sending cross-chain batch with ${calls.length} calls`, logCtx); + return batch.send({ ...opts, wait: NO_WAIT }); + } + + protected override async onTxMined(receipt: TxReceipt, logCtx: object): Promise { + // Verify L2→L1 messages appeared in this tx's effects + const indexed = await this.node.getTxEffect(receipt.txHash); + if (indexed) { + const l2ToL1Msgs = indexed.data.l2ToL1Msgs.filter(m => !m.isZero()); + if (l2ToL1Msgs.length >= this.config.l2ToL1MessagesPerTx) { + this.l2ToL1Sent += l2ToL1Msgs.length; + } else { + this.log.error(`Expected ${this.config.l2ToL1MessagesPerTx} L2→L1 messages but found ${l2ToL1Msgs.length}`, { + ...logCtx, + blockNumber: receipt.blockNumber, + txHash: receipt.txHash.toString(), + }); + } + } + + const pendingCount = (await this.store.getUnconsumedL1ToL2Messages()).length; + this.log.info(`CrossChainBot txs mined`, { + ...logCtx, + l2ToL1Sent: this.l2ToL1Sent, + l1ToL2Consumed: this.l1ToL2Consumed, + l1ToL2Pending: pendingCount, + }); + } + + /** Finds the oldest pending message that is ready for consumption. */ + private async getReadyL1ToL2Message( + pendingMessages: PendingL1ToL2Message[], + ): Promise { + const now = Date.now(); + for (const msg of pendingMessages) { + const ready = await isL1ToL2MessageReady(this.node, Fr.fromHexString(msg.msgHash), { + // Use forPublicConsumption: false so we wait until blockNumber >= messageBlockNumber. + // With forPublicConsumption: true, the check returns true one block early (the sequencer + // includes L1→L2 messages before executing the block's txs), but gas estimation simulates + // against the current world state which doesn't yet have the message. + // See https://linear.app/aztec-labs/issue/A-548 for details. + forPublicConsumption: false, + }); + if (ready) { + return msg; + } + + // Time-based stale detection: if the message is old and still not ready, remove it + if (now - msg.timestamp > STALE_MESSAGE_THRESHOLD_MS) { + await this.store.deleteL1ToL2Message(msg.msgHash); + this.log.warn(`Removed stale L1→L2 message ${msg.msgHash}`); + } + } + return undefined; + } + + /** Seeds a new L1→L2 message on L1 and stores it. */ + private async seedNewL1ToL2Message(): Promise { + await seedL1ToL2Message( + this.l1Client, + this.inboxAddress, + this.contract.address, + this.rollupVersion, + this.store, + this.log, + ); + } +} diff --git a/yarn-project/bot/src/factory.ts b/yarn-project/bot/src/factory.ts index d64046d8e4cc..77b474c559b0 100644 --- a/yarn-project/bot/src/factory.ts +++ b/yarn-project/bot/src/factory.ts @@ -8,8 +8,8 @@ import { type DeployOptions, NO_WAIT, } from '@aztec/aztec.js/contracts'; -import { L1FeeJuicePortalManager } from '@aztec/aztec.js/ethereum'; import type { L2AmountClaim } from '@aztec/aztec.js/ethereum'; +import { L1FeeJuicePortalManager } from '@aztec/aztec.js/ethereum'; import { FeeJuicePaymentMethodWithClaim } from '@aztec/aztec.js/fee'; import { deriveKeys } from '@aztec/aztec.js/keys'; import { createLogger } from '@aztec/aztec.js/log'; @@ -17,11 +17,15 @@ import { waitForL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; import { waitForTx } from '@aztec/aztec.js/node'; import { createEthereumChain } from '@aztec/ethereum/chain'; import { createExtendedL1Client } from '@aztec/ethereum/client'; +import { RollupContract } from '@aztec/ethereum/contracts'; +import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; import { Timer } from '@aztec/foundation/timer'; import { AMMContract } from '@aztec/noir-contracts.js/AMM'; import { PrivateTokenContract } from '@aztec/noir-contracts.js/PrivateToken'; import { TokenContract } from '@aztec/noir-contracts.js/Token'; +import { TestContract } from '@aztec/noir-test-contracts.js/Test'; import type { ContractInstanceWithAddress } from '@aztec/stdlib/contract'; import { GasSettings } from '@aztec/stdlib/gas'; import type { AztecNode, AztecNodeAdmin } from '@aztec/stdlib/interfaces/client'; @@ -29,6 +33,7 @@ import { deriveSigningKey } from '@aztec/stdlib/keys'; import { EmbeddedWallet } from '@aztec/wallets/embedded'; import { type BotConfig, SupportedTokenContracts } from './config.js'; +import { seedL1ToL2Message } from './l1_to_l2_seeding.js'; import type { BotStore } from './store/index.js'; import { getBalances, getPrivateBalance, isStandardTokenContract } from './utils.js'; @@ -95,6 +100,94 @@ export class BotFactory { return { wallet: this.wallet, defaultAccountAddress, amm, token0, token1, node: this.aztecNode }; } + /** + * Initializes the cross-chain bot by deploying TestContract, creating an L1 client, + * seeding initial L1→L2 messages, and waiting for the first to be ready. + */ + public async setupCrossChain(): Promise<{ + wallet: EmbeddedWallet; + defaultAccountAddress: AztecAddress; + contract: TestContract; + node: AztecNode; + l1Client: ExtendedViemWalletClient; + rollupVersion: bigint; + }> { + const defaultAccountAddress = await this.setupAccount(); + + // Create L1 client (same pattern as bridgeL1FeeJuice) + const l1RpcUrls = this.config.l1RpcUrls; + if (!l1RpcUrls?.length) { + throw new Error('L1 RPC URLs required for cross-chain bot'); + } + const mnemonicOrPrivateKey = this.config.l1PrivateKey?.getValue() ?? this.config.l1Mnemonic?.getValue(); + if (!mnemonicOrPrivateKey) { + throw new Error('L1 mnemonic or private key required for cross-chain bot'); + } + const { l1ChainId, l1ContractAddresses } = await this.aztecNode.getNodeInfo(); + const chain = createEthereumChain(l1RpcUrls, l1ChainId); + const l1Client = createExtendedL1Client(chain.rpcUrls, mnemonicOrPrivateKey, chain.chainInfo); + + // Fetch Rollup version (needed for Inbox L2Actor struct) + const rollupContract = new RollupContract(l1Client, l1ContractAddresses.rollupAddress.toString()); + const rollupVersion = await rollupContract.getVersion(); + + // Deploy TestContract + const contract = await this.setupTestContract(defaultAccountAddress); + + // Recover any pending messages from store (clean up stale ones first) + await this.store.cleanupOldPendingMessages(); + const pendingMessages = await this.store.getUnconsumedL1ToL2Messages(); + + // Seed initial L1→L2 messages if pipeline is empty + const seedCount = Math.max(0, this.config.l1ToL2SeedCount - pendingMessages.length); + for (let i = 0; i < seedCount; i++) { + await seedL1ToL2Message( + l1Client, + EthAddress.fromString(l1ContractAddresses.inboxAddress.toString()), + contract.address, + rollupVersion, + this.store, + this.log, + ); + } + + // Block until at least one message is ready + const allMessages = await this.store.getUnconsumedL1ToL2Messages(); + if (allMessages.length > 0) { + this.log.info(`Waiting for first L1→L2 message to be ready...`); + const firstMsg = allMessages[0]; + await waitForL1ToL2MessageReady(this.aztecNode, Fr.fromHexString(firstMsg.msgHash), { + timeoutSeconds: this.config.l1ToL2MessageTimeoutSeconds, + // Use forPublicConsumption: false so we wait until the message is in the current world + // state. With true, it returns one block early which causes gas estimation simulation to + // fail since it runs against the current state. + // See https://linear.app/aztec-labs/issue/A-548 for details. + forPublicConsumption: false, + }); + this.log.info(`First L1→L2 message is ready`); + } + + return { + wallet: this.wallet, + defaultAccountAddress, + contract, + node: this.aztecNode, + l1Client, + rollupVersion, + }; + } + + private async setupTestContract(deployer: AztecAddress): Promise { + const deployOpts: DeployOptions = { + from: deployer, + contractAddressSalt: this.config.tokenSalt, + universalDeploy: true, + }; + const deploy = TestContract.deploy(this.wallet); + const instance = await this.registerOrDeployContract('TestContract', deploy, deployOpts); + return TestContract.at(instance.address, this.wallet); + } + /** * Checks if the sender account contract is initialized, and initializes it if necessary. * @returns The sender wallet. diff --git a/yarn-project/bot/src/index.ts b/yarn-project/bot/src/index.ts index a43f2b02b89a..c22724ef8fa2 100644 --- a/yarn-project/bot/src/index.ts +++ b/yarn-project/bot/src/index.ts @@ -1,5 +1,6 @@ export { Bot } from './bot.js'; export { AmmBot } from './amm_bot.js'; +export { CrossChainBot } from './cross_chain_bot.js'; export { BotRunner } from './runner.js'; export { BotStore } from './store/bot_store.js'; export { diff --git a/yarn-project/bot/src/l1_to_l2_seeding.ts b/yarn-project/bot/src/l1_to_l2_seeding.ts new file mode 100644 index 000000000000..36a93b0f1f1f --- /dev/null +++ b/yarn-project/bot/src/l1_to_l2_seeding.ts @@ -0,0 +1,79 @@ +import { generateClaimSecret } from '@aztec/aztec.js/ethereum'; +import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; +import { compactArray } from '@aztec/foundation/collection'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import type { Logger } from '@aztec/foundation/log'; +import { InboxAbi } from '@aztec/l1-artifacts'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; + +import { decodeEventLog, getContract } from 'viem'; + +import type { BotStore, PendingL1ToL2Message } from './store/index.js'; + +/** Sends an L1→L2 message via the Inbox contract and stores it. */ +export async function seedL1ToL2Message( + l1Client: ExtendedViemWalletClient, + inboxAddress: EthAddress, + l2Recipient: AztecAddress, + rollupVersion: bigint, + store: BotStore, + log: Logger, +): Promise { + log.info('Seeding L1→L2 message'); + const [secret, secretHash] = await generateClaimSecret(log); + const content = Fr.random(); + + const inbox = getContract({ + address: inboxAddress.toString(), + abi: InboxAbi, + client: l1Client, + }); + + const txHash = await inbox.write.sendL2Message( + [{ actor: l2Recipient.toString(), version: rollupVersion }, content.toString(), secretHash.toString()], + { gas: 1_000_000n }, + ); + log.info(`L1→L2 message sent in tx ${txHash}`); + + const txReceipt = await l1Client.waitForTransactionReceipt({ hash: txHash }); + if (txReceipt.status !== 'success') { + throw new Error(`L1→L2 message tx failed: ${txHash}`); + } + + // Extract MessageSent event + const messageSentLogs = compactArray( + txReceipt.logs + .filter(l => l.address.toLowerCase() === inboxAddress.toString().toLowerCase()) + .map(l => { + try { + return decodeEventLog({ abi: InboxAbi, eventName: 'MessageSent', data: l.data, topics: l.topics }); + } catch { + return undefined; + } + }), + ); + + if (messageSentLogs.length !== 1) { + throw new Error(`Expected 1 MessageSent event, got ${messageSentLogs.length}`); + } + + const event = messageSentLogs[0]; + + const msgHash = event.args.hash; + const globalLeafIndex = event.args.index; + + const msg: PendingL1ToL2Message = { + content: content.toString(), + secret: secret.toString(), + secretHash: secretHash.toString(), + msgHash, + sender: l1Client.account!.address, + globalLeafIndex: globalLeafIndex.toString(), + timestamp: Date.now(), + }; + + await store.savePendingL1ToL2Message(msg); + log.info(`Seeded L1→L2 message msgHash=${msg.msgHash}`); + return msg; +} diff --git a/yarn-project/bot/src/runner.ts b/yarn-project/bot/src/runner.ts index 168588fccc89..ba681b379c10 100644 --- a/yarn-project/bot/src/runner.ts +++ b/yarn-project/bot/src/runner.ts @@ -10,6 +10,7 @@ import { AmmBot } from './amm_bot.js'; import type { BaseBot } from './base_bot.js'; import { Bot } from './bot.js'; import type { BotConfig } from './config.js'; +import { CrossChainBot } from './cross_chain_bot.js'; import type { BotInfo, BotRunnerApi } from './interface.js'; import { BotStore } from './store/index.js'; @@ -146,9 +147,21 @@ export class BotRunner implements BotRunnerApi, Traceable { async #createBot() { try { - this.bot = this.config.ammTxs - ? AmmBot.create(this.config, this.wallet, this.aztecNode, this.aztecNodeAdmin, this.store) - : Bot.create(this.config, this.wallet, this.aztecNode, this.aztecNodeAdmin, this.store); + switch (this.config.botMode) { + case 'crosschain': + this.bot = CrossChainBot.create(this.config, this.wallet, this.aztecNode, this.aztecNodeAdmin, this.store); + break; + case 'amm': + this.bot = AmmBot.create(this.config, this.wallet, this.aztecNode, this.aztecNodeAdmin, this.store); + break; + case 'transfer': + this.bot = Bot.create(this.config, this.wallet, this.aztecNode, this.aztecNodeAdmin, this.store); + break; + default: { + const _exhaustive: never = this.config.botMode; + throw new Error(`Unsupported bot mode: [${_exhaustive}]`); + } + } await this.bot; } catch (err) { this.log.error(`Error setting up bot: ${err}`); diff --git a/yarn-project/bot/src/store/bot_store.test.ts b/yarn-project/bot/src/store/bot_store.test.ts index ac42cc46f078..1c2ea4d30dc2 100644 --- a/yarn-project/bot/src/store/bot_store.test.ts +++ b/yarn-project/bot/src/store/bot_store.test.ts @@ -202,6 +202,59 @@ describe('BotStore', () => { }); }); + describe('pending L1→L2 messages', () => { + it('recovers pending messages from store on restart', async () => { + await store.savePendingL1ToL2Message({ + content: Fr.random().toString(), + secret: Fr.random().toString(), + secretHash: Fr.random().toString(), + msgHash: Fr.random().toString(), + sender: '0x' + '00'.repeat(20), + globalLeafIndex: '0', + timestamp: Date.now(), + }); + + const messages = await store.getUnconsumedL1ToL2Messages(); + expect(messages.length).toBe(1); + + await store.deleteL1ToL2Message(messages[0].msgHash); + const afterDelete = await store.getUnconsumedL1ToL2Messages(); + expect(afterDelete.length).toBe(0); + }); + + it('cleans up stale pending messages', async () => { + // Save a message with a very old timestamp + await store.savePendingL1ToL2Message({ + content: Fr.random().toString(), + secret: Fr.random().toString(), + secretHash: Fr.random().toString(), + msgHash: Fr.random().toString(), + sender: '0x' + '00'.repeat(20), + globalLeafIndex: '0', + timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000, // 2 days ago + }); + + // Save a fresh message + const freshHash = Fr.random().toString(); + await store.savePendingL1ToL2Message({ + content: Fr.random().toString(), + secret: Fr.random().toString(), + secretHash: Fr.random().toString(), + msgHash: freshHash, + sender: '0x' + '00'.repeat(20), + globalLeafIndex: '1', + timestamp: Date.now(), + }); + + const cleaned = await store.cleanupOldPendingMessages(24 * 60 * 60 * 1000); + expect(cleaned).toBe(1); + + const remaining = await store.getUnconsumedL1ToL2Messages(); + expect(remaining.length).toBe(1); + expect(remaining[0].msgHash).toBe(freshHash); + }); + }); + describe('error handling', () => { it('should handle multiple close calls gracefully', async () => { await store.close(); diff --git a/yarn-project/bot/src/store/bot_store.ts b/yarn-project/bot/src/store/bot_store.ts index a114544455a7..3f9aeabb6b30 100644 --- a/yarn-project/bot/src/store/bot_store.ts +++ b/yarn-project/bot/src/store/bot_store.ts @@ -2,6 +2,7 @@ import { AztecAddress } from '@aztec/aztec.js/addresses'; import type { L2AmountClaim } from '@aztec/aztec.js/ethereum'; import { Fr } from '@aztec/foundation/curves/bn254'; import { type Logger, createLogger } from '@aztec/foundation/log'; +import { DateProvider } from '@aztec/foundation/timer'; import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store'; export interface BridgeClaimData { @@ -10,18 +11,38 @@ export interface BridgeClaimData { recipient: string; } +export interface PendingL1ToL2Message { + /** Random content field sent in the message. */ + content: string; + /** Secret for consuming the message. */ + secret: string; + /** Hash of the secret. */ + secretHash: string; + /** Hash of the L1→L2 message. */ + msgHash: string; + /** L1 sender address (hex). */ + sender: string; + /** Global leaf index in the L1→L2 message tree. */ + globalLeafIndex: string; + /** Timestamp when the message was seeded. */ + timestamp: number; +} + /** * Simple data store for the bot to persist L1 bridge claims. */ export class BotStore { public static readonly SCHEMA_VERSION = 1; private readonly bridgeClaims: AztecAsyncMap; + private readonly pendingL1ToL2: AztecAsyncMap; constructor( private readonly store: AztecAsyncKVStore, private readonly log: Logger = createLogger('bot:store'), + private readonly dateProvider: DateProvider = new DateProvider(), ) { this.bridgeClaims = store.openMap('bridge_claims'); + this.pendingL1ToL2 = store.openMap('pending_l1_to_l2'); } /** @@ -39,7 +60,7 @@ export class BotStore { const data = { claim: serializableClaim, - timestamp: Date.now(), + timestamp: this.dateProvider.now(), recipient: recipient.toString(), }; @@ -115,7 +136,7 @@ export class BotStore { * Cleans up old bridge claims (older than 24 hours). */ public async cleanupOldClaims(maxAgeMs: number = 24 * 60 * 60 * 1000): Promise { - const now = Date.now(); + const now = this.dateProvider.now(); let cleanedCount = 0; const entries = this.bridgeClaims.entriesAsync(); @@ -131,9 +152,43 @@ export class BotStore { return cleanedCount; } - /** - * Closes the store. - */ + /** Saves a pending L1→L2 message keyed by msgHash. */ + public async savePendingL1ToL2Message(msg: PendingL1ToL2Message): Promise { + await this.pendingL1ToL2.set(msg.msgHash, JSON.stringify(msg)); + this.log.info(`Saved pending L1→L2 message ${msg.msgHash}`); + } + + /** Returns all unconsumed pending L1→L2 messages. */ + public async getUnconsumedL1ToL2Messages(): Promise { + const messages: PendingL1ToL2Message[] = []; + for await (const [_, data] of this.pendingL1ToL2.entriesAsync()) { + messages.push(JSON.parse(data)); + } + return messages; + } + + /** Deletes a consumed L1→L2 message from the store. */ + public async deleteL1ToL2Message(msgHash: string): Promise { + await this.pendingL1ToL2.delete(msgHash); + this.log.info(`Deleted consumed L1→L2 message ${msgHash}`); + } + + /** Cleans up pending L1→L2 messages older than maxAgeMs. */ + public async cleanupOldPendingMessages(maxAgeMs: number = 24 * 60 * 60 * 1000): Promise { + const now = this.dateProvider.now(); + let cleanedCount = 0; + for await (const [key, data] of this.pendingL1ToL2.entriesAsync()) { + const parsed = JSON.parse(data); + if (now - parsed.timestamp > maxAgeMs) { + await this.pendingL1ToL2.delete(key); + cleanedCount++; + this.log.info(`Cleaned up old pending L1→L2 message ${key}`); + } + } + return cleanedCount; + } + + /** Closes the store. */ public async close(): Promise { await this.store.close(); this.log.info('Closed bot data store'); diff --git a/yarn-project/bot/src/store/index.ts b/yarn-project/bot/src/store/index.ts index 7469fa581621..616a4f26f1d3 100644 --- a/yarn-project/bot/src/store/index.ts +++ b/yarn-project/bot/src/store/index.ts @@ -1 +1 @@ -export { BotStore, type BridgeClaimData } from './bot_store.js'; +export { BotStore, type BridgeClaimData, type PendingL1ToL2Message } from './bot_store.js'; diff --git a/yarn-project/bot/tsconfig.json b/yarn-project/bot/tsconfig.json index 68c76dbe3053..320945ea8119 100644 --- a/yarn-project/bot/tsconfig.json +++ b/yarn-project/bot/tsconfig.json @@ -24,12 +24,18 @@ { "path": "../kv-store" }, + { + "path": "../l1-artifacts" + }, { "path": "../noir-contracts.js" }, { "path": "../noir-protocol-circuits-types" }, + { + "path": "../noir-test-contracts.js" + }, { "path": "../protocol-contracts" }, diff --git a/yarn-project/end-to-end/src/e2e_bot.test.ts b/yarn-project/end-to-end/src/e2e_bot.test.ts index 8628a7ebdeb9..233293f72314 100644 --- a/yarn-project/end-to-end/src/e2e_bot.test.ts +++ b/yarn-project/end-to-end/src/e2e_bot.test.ts @@ -1,9 +1,18 @@ import { getInitialTestAccountsData } from '@aztec/accounts/testing'; import { Fr } from '@aztec/aztec.js/fields'; import type { AztecNode } from '@aztec/aztec.js/node'; +import { TxReceipt } from '@aztec/aztec.js/tx'; import { DeployAccountMethod } from '@aztec/aztec.js/wallet'; import type { CheatCodes } from '@aztec/aztec/testing'; -import { AmmBot, Bot, type BotConfig, BotStore, SupportedTokenContracts, getBotDefaultConfig } from '@aztec/bot'; +import { + AmmBot, + Bot, + type BotConfig, + BotStore, + CrossChainBot, + SupportedTokenContracts, + getBotDefaultConfig, +} from '@aztec/bot'; import { AVM_MAX_PROCESSABLE_L2_GAS, MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT } from '@aztec/constants'; import { SecretValue } from '@aztec/foundation/config'; import { bufferToHex } from '@aztec/foundation/string'; @@ -49,7 +58,7 @@ describe('e2e_bot', () => { config = { ...getBotDefaultConfig(), followChain: 'CHECKPOINTED', - ammTxs: false, + botMode: 'transfer', }; bot = await Bot.create(config, wallet, aztecNode, undefined, new BotStore(await openTmpStore('bot'))); }); @@ -114,7 +123,7 @@ describe('e2e_bot', () => { ...getBotDefaultConfig(), followChain: 'CHECKPOINTED', - ammTxs: false, + botMode: 'transfer', // this bot has a well defined private key and salt senderPrivateKey: new SecretValue(Fr.fromString('0xcafe')), @@ -152,7 +161,7 @@ describe('e2e_bot', () => { ...getBotDefaultConfig(), followChain: 'CHECKPOINTED', - ammTxs: false, + botMode: 'transfer', // this bot has a well defined private key and salt senderPrivateKey: new SecretValue(Fr.fromString('0xcafe')), @@ -190,7 +199,7 @@ describe('e2e_bot', () => { config = { ...getBotDefaultConfig(), followChain: 'CHECKPOINTED', - ammTxs: true, + botMode: 'amm', }; bot = await AmmBot.create(config, wallet, aztecNode, undefined, new BotStore(await openTmpStore('bot'))); }); @@ -220,7 +229,7 @@ describe('e2e_bot', () => { config = { ...getBotDefaultConfig(), followChain: 'PROPOSED', - ammTxs: false, + botMode: 'transfer', senderPrivateKey: new SecretValue(Fr.random()), l1PrivateKey: new SecretValue(bufferToHex(getPrivateKeyFromIndex(8)!)), l1RpcUrls, @@ -235,4 +244,49 @@ describe('e2e_bot', () => { await Bot.create(config, wallet, aztecNode, aztecNodeAdmin, new BotStore(await openTmpStore('bot'))); }, 300_000); }); + + describe('cross-chain-bot', () => { + let bot: CrossChainBot; + + beforeAll(async () => { + config = { + ...getBotDefaultConfig(), + followChain: 'PROPOSED', + botMode: 'crosschain', + l1RpcUrls, + l1PrivateKey: new SecretValue(bufferToHex(getPrivateKeyFromIndex(9)!)), + flushSetupTransactions: true, + l1ToL2SeedCount: 2, + }; + bot = await CrossChainBot.create( + config, + wallet, + aztecNode, + aztecNodeAdmin, + new BotStore(await openTmpStore('bot')), + ); + }, 600_000); + + it('sends L2→L1 and consumes L1→L2 messages', async () => { + const result = await bot.run(); + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(TxReceipt); + + const receipt = result as TxReceipt; + expect(receipt.blockNumber).toBeDefined(); + + // Verify L2→L1: the block should contain at least one non-zero L2→L1 message + const block = await aztecNode.getBlock(receipt.blockNumber!); + expect(block).toBeDefined(); + const l2ToL1Msgs = block!.body.txEffects.flatMap(e => e.l2ToL1Msgs).filter(m => !m.isZero()); + expect(l2ToL1Msgs.length).toBeGreaterThanOrEqual(1); + }, 120_000); + + it('replenishes the seeding pipeline across ticks', async () => { + // Tick 2: the first tick consumed one message. This tick should seed a + // replacement and still have a ready message to consume. + const result = await bot.run(); + expect(result).toBeDefined(); + }, 120_000); + }); }); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_public_cross_chain.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_public_cross_chain.test.ts index 9fbd0a9a4fda..3c71f20c852b 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_public_cross_chain.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_public_cross_chain.test.ts @@ -16,7 +16,11 @@ import { EpochsTestContext } from './epochs_test.js'; jest.setTimeout(1000 * 60 * 10); // Proves an epoch that contains txs with public function calls that consume L1 to L2 messages -// Regression for this issue https://aztecprotocol.slack.com/archives/C085C1942HG/p1754400213976059 +// Regression for an issue in which the sequencer correctly adds L1-to-L2 messages to its world-state fork +// before processing txs, but the prover node's proving job creates a separate fork without inserting the +// messages first. This causes a block header mismatch (different state roots, fees, mana) when a tx consumes +// a message that was added to the L1-to-L2 message tree in the same block — the prover reverts the tx while +// the sequencer processes it successfully. describe('e2e_epochs/epochs_proof_public_cross_chain', () => { let context: EndToEndContext; let logger: Logger; diff --git a/yarn-project/end-to-end/src/e2e_sequencer_config.test.ts b/yarn-project/end-to-end/src/e2e_sequencer_config.test.ts index 17abe44d2bff..9db69b105ae2 100644 --- a/yarn-project/end-to-end/src/e2e_sequencer_config.test.ts +++ b/yarn-project/end-to-end/src/e2e_sequencer_config.test.ts @@ -41,7 +41,7 @@ describe('e2e_sequencer_config', () => { config = { ...getBotDefaultConfig(), followChain: 'CHECKPOINTED', - ammTxs: false, + botMode: 'transfer', txMinedWaitSeconds: 12, }; wallet = await EmbeddedWallet.create(aztecNode, { ephemeral: true }); diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index 7e3c30c29230..3ed05d7dbb28 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -47,7 +47,10 @@ export type EnvVar = | 'BOT_TX_MINED_WAIT_SECONDS' | 'BOT_MAX_CONSECUTIVE_ERRORS' | 'BOT_STOP_WHEN_UNHEALTHY' - | 'BOT_AMM_TXS' + | 'BOT_MODE' + | 'BOT_L2_TO_L1_MESSAGES_PER_TX' + | 'BOT_L1_TO_L2_SEED_COUNT' + | 'BOT_L1_TO_L2_SEED_INTERVAL' | 'COINBASE' | 'CRS_PATH' | 'DATA_DIRECTORY' diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index 682aaf6997ed..32a725fdc8f0 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -23,7 +23,7 @@ import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block'; -import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; +import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import type { CreateCheckpointProposalLastBlockData, ITxProvider, @@ -595,7 +595,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) proposalInfo: LogData, ): Promise<{ isValid: true } | { isValid: false; reason: string }> { const slot = proposal.slotNumber; - const timeoutSeconds = 10; // TODO(palla/mbps): This should map to the timetable settings + + // Timeout block syncing at the start of the next slot + const config = this.checkpointsBuilder.getConfig(); + const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config)); + const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000)); // Wait for last block to sync by archive let lastBlockHeader: BlockHeader | undefined; diff --git a/yarn-project/yarn.lock b/yarn-project/yarn.lock index b24eaf0d8afe..4e971365bfb2 100644 --- a/yarn-project/yarn.lock +++ b/yarn-project/yarn.lock @@ -1064,8 +1064,10 @@ __metadata: "@aztec/ethereum": "workspace:^" "@aztec/foundation": "workspace:^" "@aztec/kv-store": "workspace:^" + "@aztec/l1-artifacts": "workspace:^" "@aztec/noir-contracts.js": "workspace:^" "@aztec/noir-protocol-circuits-types": "workspace:^" + "@aztec/noir-test-contracts.js": "workspace:^" "@aztec/protocol-contracts": "workspace:^" "@aztec/stdlib": "workspace:^" "@aztec/telemetry-client": "workspace:^" @@ -1081,6 +1083,7 @@ __metadata: ts-node: "npm:^10.9.1" tslib: "npm:^2.4.0" typescript: "npm:^5.3.3" + viem: "npm:@aztec/viem@2.38.2" zod: "npm:^3.23.8" languageName: unknown linkType: soft