diff --git a/cspell.json b/cspell.json index 6a62badf5be..8b9e5d15e7e 100644 --- a/cspell.json +++ b/cspell.json @@ -11,6 +11,7 @@ "asyncify", "auditability", "authwit", + "Automine", "autonat", "autorun", "awslogs", diff --git a/spartan/aztec-network/files/config/config-prover-env.sh b/spartan/aztec-network/files/config/config-prover-env.sh index 11c4ad5aef2..a3eccd01c1b 100644 --- a/spartan/aztec-network/files/config/config-prover-env.sh +++ b/spartan/aztec-network/files/config/config-prover-env.sh @@ -3,7 +3,7 @@ set -eu # Pass the bootnode url as an argument # Ask the bootnode for l1 contract addresses -output=$(node --no-warnings /usr/src/yarn-project/aztec/dest/bin/index.js get-node-info -u $1) +output=$(node --no-warnings /usr/src/yarn-project/aztec/dest/bin/index.js get-node-info --node-url $1) echo "$output" @@ -20,7 +20,7 @@ governance_proposer_address=$(echo "$output" | grep -oP 'GovernanceProposer Addr governance_address=$(echo "$output" | grep -oP 'Governance Address: \K0x[a-fA-F0-9]{40}') # Write the addresses to a file in the shared volume -cat < /shared/contracts/contracts.env +cat </shared/contracts/contracts.env export BOOTSTRAP_NODES=$boot_node_enr export ROLLUP_CONTRACT_ADDRESS=$rollup_address export REGISTRY_CONTRACT_ADDRESS=$registry_address diff --git a/spartan/aztec-network/files/config/config-validator-env.sh b/spartan/aztec-network/files/config/config-validator-env.sh index 71d03fbbc98..6483168f16d 100644 --- a/spartan/aztec-network/files/config/config-validator-env.sh +++ b/spartan/aztec-network/files/config/config-validator-env.sh @@ -1,10 +1,9 @@ #!/bin/bash set -eu - # Pass the bootnode url as an argument # Ask the bootnode for l1 contract addresses -output=$(node --no-warnings /usr/src/yarn-project/aztec/dest/bin/index.js get-node-info -u $1) +output=$(node --no-warnings /usr/src/yarn-project/aztec/dest/bin/index.js get-node-info --node-url $1) echo "$output" @@ -25,9 +24,8 @@ governance_address=$(echo "$output" | grep -oP 'Governance Address: \K0x[a-fA-F0 INDEX=$(echo $POD_NAME | awk -F'-' '{print $NF}') private_key=$(jq -r ".[$INDEX]" /app/config/keys.json) - # Write the addresses to a file in the shared volume -cat < /shared/contracts/contracts.env +cat </shared/contracts/contracts.env export BOOTSTRAP_NODES=$boot_node_enr export ROLLUP_CONTRACT_ADDRESS=$rollup_address export REGISTRY_CONTRACT_ADDRESS=$registry_address diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 587d97371a4..57690bd78d9 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -45,6 +45,7 @@ import { type L1_TO_L2_MSG_TREE_HEIGHT, type NOTE_HASH_TREE_HEIGHT, type NULLIFIER_TREE_HEIGHT, + type NodeInfo, type NullifierLeafPreimage, type PUBLIC_DATA_TREE_HEIGHT, type PrivateLog, @@ -237,6 +238,29 @@ export class AztecNodeService implements AztecNode { return Promise.resolve(this.p2pClient.isReady() ?? false); } + public async getNodeInfo(): Promise { + const [nodeVersion, protocolVersion, chainId, enr, contractAddresses, protocolContractAddresses] = + await Promise.all([ + this.getNodeVersion(), + this.getVersion(), + this.getChainId(), + this.getEncodedEnr(), + this.getL1ContractAddresses(), + this.getProtocolContractAddresses(), + ]); + + const nodeInfo: NodeInfo = { + nodeVersion, + l1ChainId: chainId, + protocolVersion, + enr, + l1ContractAddresses: contractAddresses, + protocolContractAddresses: protocolContractAddresses, + }; + + return nodeInfo; + } + /** * Get a block specified by its number. * @param number - The block number being requested. diff --git a/yarn-project/aztec.js/src/index.ts b/yarn-project/aztec.js/src/index.ts index ca6f1011172..3a67fd9a6fd 100644 --- a/yarn-project/aztec.js/src/index.ts +++ b/yarn-project/aztec.js/src/index.ts @@ -42,7 +42,6 @@ export { ContractDeployer } from './deployment/index.js'; export { AnvilTestWatcher, CheatCodes, - EthCheatCodes, L1FeeJuicePortalManager, L1ToL2TokenPortalManager, L1TokenManager, @@ -165,7 +164,7 @@ export { elapsed } from '@aztec/foundation/timer'; export { type FieldsOf } from '@aztec/foundation/types'; export { fileURLToPath } from '@aztec/foundation/url'; -export { deployL1Contract, deployL1Contracts, type DeployL1Contracts } from '@aztec/ethereum'; +export { type DeployL1Contracts, EthCheatCodes, deployL1Contract, deployL1Contracts } from '@aztec/ethereum'; // Start of section that exports public api via granular api. // Here you *can* do `export *` as the granular api defacto exports things explicitly. diff --git a/yarn-project/aztec.js/src/utils/cheat_codes.ts b/yarn-project/aztec.js/src/utils/cheat_codes.ts index f35ae53c25f..87048d1c0e1 100644 --- a/yarn-project/aztec.js/src/utils/cheat_codes.ts +++ b/yarn-project/aztec.js/src/utils/cheat_codes.ts @@ -1,13 +1,10 @@ import { type EpochProofClaim, type Note, type PXE } from '@aztec/circuit-types'; import { type AztecAddress, EthAddress, Fr } from '@aztec/circuits.js'; import { deriveStorageSlotInMap } from '@aztec/circuits.js/hash'; -import { type L1ContractAddresses } from '@aztec/ethereum'; -import { toBigIntBE, toHex } from '@aztec/foundation/bigint-buffer'; -import { keccak256 } from '@aztec/foundation/crypto'; +import { EthCheatCodes, type L1ContractAddresses } from '@aztec/ethereum'; import { createDebugLogger } from '@aztec/foundation/log'; import { RollupAbi } from '@aztec/l1-artifacts'; -import fs from 'fs'; import { type GetContractReturnType, type Hex, @@ -49,248 +46,6 @@ export class CheatCodes { } } -/** - * A class that provides utility functions for interacting with ethereum (L1). - */ -export class EthCheatCodes { - constructor( - /** - * The RPC URL to use for interacting with the chain - */ - public rpcUrl: string, - /** - * The logger to use for the eth cheatcodes - */ - public logger = createDebugLogger('aztec:cheat_codes:eth'), - ) {} - - async rpcCall(method: string, params: any[]) { - const paramsString = JSON.stringify(params); - const content = { - body: `{"jsonrpc":"2.0", "method": "${method}", "params": ${paramsString}, "id": 1}`, - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }; - return await (await fetch(this.rpcUrl, content)).json(); - } - - /** - * Get the auto mine status of the underlying chain - * @returns True if automine is on, false otherwise - */ - public async isAutoMining(): Promise { - try { - const res = await this.rpcCall('anvil_getAutomine', []); - return res.result; - } catch (err) { - this.logger.error(`Calling "anvil_getAutomine" failed with:`, err); - } - return false; - } - - /** - * Get the current blocknumber - * @returns The current block number - */ - public async blockNumber(): Promise { - const res = await this.rpcCall('eth_blockNumber', []); - return parseInt(res.result, 16); - } - - /** - * Get the current chainId - * @returns The current chainId - */ - public async chainId(): Promise { - const res = await this.rpcCall('eth_chainId', []); - return parseInt(res.result, 16); - } - - /** - * Get the current timestamp - * @returns The current timestamp - */ - public async timestamp(): Promise { - const res = await this.rpcCall('eth_getBlockByNumber', ['latest', true]); - return parseInt(res.result.timestamp, 16); - } - - /** - * Advance the chain by a number of blocks - * @param numberOfBlocks - The number of blocks to mine - * @returns The current chainId - */ - public async mine(numberOfBlocks = 1): Promise { - const res = await this.rpcCall('hardhat_mine', [numberOfBlocks]); - if (res.error) { - throw new Error(`Error mining: ${res.error.message}`); - } - this.logger.verbose(`Mined ${numberOfBlocks} L1 blocks`); - } - - /** - * Set the balance of an account - * @param account - The account to set the balance for - * @param balance - The balance to set - */ - public async setBalance(account: EthAddress, balance: bigint): Promise { - const res = await this.rpcCall('anvil_setBalance', [account.toString(), toHex(balance)]); - if (res.error) { - throw new Error(`Error setting balance for ${account}: ${res.error.message}`); - } - this.logger.verbose(`Set balance for ${account} to ${balance}`); - } - - /** - * Set the interval between blocks (block time) - * @param interval - The interval to use between blocks - */ - public async setBlockInterval(interval: number): Promise { - const res = await this.rpcCall('anvil_setBlockTimestampInterval', [interval]); - if (res.error) { - throw new Error(`Error setting block interval: ${res.error.message}`); - } - this.logger.verbose(`Set L1 block interval to ${interval}`); - } - - /** - * Set the next block timestamp - * @param timestamp - The timestamp to set the next block to - */ - public async setNextBlockTimestamp(timestamp: number): Promise { - const res = await this.rpcCall('evm_setNextBlockTimestamp', [timestamp]); - if (res.error) { - throw new Error(`Error setting next block timestamp: ${res.error.message}`); - } - this.logger.verbose(`Set L1 next block timestamp to ${timestamp}`); - } - - /** - * Set the next block timestamp and mines the block - * @param timestamp - The timestamp to set the next block to - */ - public async warp(timestamp: number | bigint): Promise { - const res = await this.rpcCall('evm_setNextBlockTimestamp', [Number(timestamp)]); - if (res.error) { - throw new Error(`Error warping: ${res.error.message}`); - } - await this.mine(); - this.logger.verbose(`Warped L1 timestamp to ${timestamp}`); - } - - /** - * Dumps the current chain state to a file. - * @param fileName - The file name to dump state into - */ - public async dumpChainState(fileName: string): Promise { - const res = await this.rpcCall('hardhat_dumpState', []); - if (res.error) { - throw new Error(`Error dumping state: ${res.error.message}`); - } - const jsonContent = JSON.stringify(res.result); - fs.writeFileSync(`${fileName}.json`, jsonContent, 'utf8'); - this.logger.verbose(`Dumped state to ${fileName}`); - } - - /** - * Loads the chain state from a file. - * @param fileName - The file name to load state from - */ - public async loadChainState(fileName: string): Promise { - const data = JSON.parse(fs.readFileSync(`${fileName}.json`, 'utf8')); - const res = await this.rpcCall('hardhat_loadState', [data]); - if (res.error) { - throw new Error(`Error loading state: ${res.error.message}`); - } - this.logger.verbose(`Loaded state from ${fileName}`); - } - - /** - * Load the value at a storage slot of a contract address on eth - * @param contract - The contract address - * @param slot - The storage slot - * @returns - The value at the storage slot - */ - public async load(contract: EthAddress, slot: bigint): Promise { - const res = await this.rpcCall('eth_getStorageAt', [contract.toString(), toHex(slot), 'latest']); - return BigInt(res.result); - } - - /** - * Set the value at a storage slot of a contract address on eth - * @param contract - The contract address - * @param slot - The storage slot - * @param value - The value to set the storage slot to - */ - public async store(contract: EthAddress, slot: bigint, value: bigint): Promise { - // for the rpc call, we need to change value to be a 32 byte hex string. - const res = await this.rpcCall('hardhat_setStorageAt', [contract.toString(), toHex(slot), toHex(value, true)]); - if (res.error) { - throw new Error(`Error setting storage for contract ${contract} at ${slot}: ${res.error.message}`); - } - this.logger.verbose(`Set L1 storage for contract ${contract} at ${slot} to ${value}`); - } - - /** - * Computes the slot value for a given map and key. - * @param baseSlot - The base slot of the map (specified in Aztec.nr contract) - * @param key - The key to lookup in the map - * @returns The storage slot of the value in the map - */ - public keccak256(baseSlot: bigint, key: bigint): bigint { - // abi encode (removing the 0x) - concat key and baseSlot (both padded to 32 bytes) - const abiEncoded = toHex(key, true).substring(2) + toHex(baseSlot, true).substring(2); - return toBigIntBE(keccak256(Buffer.from(abiEncoded, 'hex'))); - } - - /** - * Send transactions impersonating an externally owned account or contract. - * @param who - The address to impersonate - */ - public async startImpersonating(who: EthAddress | Hex): Promise { - const res = await this.rpcCall('hardhat_impersonateAccount', [who.toString()]); - if (res.error) { - throw new Error(`Error impersonating ${who}: ${res.error.message}`); - } - this.logger.verbose(`Impersonating ${who}`); - } - - /** - * Stop impersonating an account that you are currently impersonating. - * @param who - The address to stop impersonating - */ - public async stopImpersonating(who: EthAddress | Hex): Promise { - const res = await this.rpcCall('hardhat_stopImpersonatingAccount', [who.toString()]); - if (res.error) { - throw new Error(`Error when stopping the impersonation of ${who}: ${res.error.message}`); - } - this.logger.verbose(`Stopped impersonating ${who}`); - } - - /** - * Set the bytecode for a contract - * @param contract - The contract address - * @param bytecode - The bytecode to set - */ - public async etch(contract: EthAddress, bytecode: `0x${string}`): Promise { - const res = await this.rpcCall('hardhat_setCode', [contract.toString(), bytecode]); - if (res.error) { - throw new Error(`Error setting bytecode for ${contract}: ${res.error.message}`); - } - this.logger.verbose(`Set bytecode for ${contract} to ${bytecode}`); - } - - /** - * Get the bytecode for a contract - * @param contract - The contract address - * @returns The bytecode for the contract - */ - public async getBytecode(contract: EthAddress): Promise<`0x${string}`> { - const res = await this.rpcCall('eth_getCode', [contract.toString(), 'latest']); - return res.result; - } -} - /** Cheat codes for the L1 rollup contract. */ export class RollupCheatCodes { private client: WalletClient & PublicClient; diff --git a/yarn-project/circuit-types/src/interfaces/aztec-node.test.ts b/yarn-project/circuit-types/src/interfaces/aztec-node.test.ts index c2db85e5250..30d729f4750 100644 --- a/yarn-project/circuit-types/src/interfaces/aztec-node.test.ts +++ b/yarn-project/circuit-types/src/interfaces/aztec-node.test.ts @@ -10,6 +10,7 @@ import { L1_TO_L2_MSG_TREE_HEIGHT, NOTE_HASH_TREE_HEIGHT, NULLIFIER_TREE_HEIGHT, + type NodeInfo, PUBLIC_DATA_TREE_HEIGHT, PrivateLog, type ProtocolContractAddresses, @@ -179,6 +180,19 @@ describe('AztecNodeApiSchema', () => { expect(response).toBe(true); }); + it('getNodeInfo', async () => { + const response = await context.client.getNodeInfo(); + expect(response).toEqual({ + ...(await handler.getNodeInfo()), + l1ContractAddresses: Object.fromEntries( + L1ContractsNames.map(name => [name, expect.any(EthAddress)]), + ) as L1ContractAddresses, + protocolContractAddresses: Object.fromEntries( + ProtocolContractsNames.map(name => [name, expect.any(AztecAddress)]), + ) as ProtocolContractAddresses, + }); + }); + it('getBlocks', async () => { const response = await context.client.getBlocks(1, 1); expect(response).toHaveLength(1); @@ -451,6 +465,20 @@ class MockAztecNode implements AztecNode { isReady(): Promise { return Promise.resolve(true); } + getNodeInfo(): Promise { + return Promise.resolve({ + nodeVersion: '1.0', + l1ChainId: 1, + protocolVersion: 1, + enr: 'enr', + l1ContractAddresses: Object.fromEntries( + L1ContractsNames.map(name => [name, EthAddress.random()]), + ) as L1ContractAddresses, + protocolContractAddresses: Object.fromEntries( + ProtocolContractsNames.map(name => [name, AztecAddress.random()]), + ) as ProtocolContractAddresses, + }); + } getBlocks(from: number, limit: number): Promise { return Promise.resolve(times(limit, i => L2Block.random(from + i))); } diff --git a/yarn-project/circuit-types/src/interfaces/aztec-node.ts b/yarn-project/circuit-types/src/interfaces/aztec-node.ts index 457d188acf0..96ac1a1f3ed 100644 --- a/yarn-project/circuit-types/src/interfaces/aztec-node.ts +++ b/yarn-project/circuit-types/src/interfaces/aztec-node.ts @@ -9,6 +9,8 @@ import { L1_TO_L2_MSG_TREE_HEIGHT, NOTE_HASH_TREE_HEIGHT, NULLIFIER_TREE_HEIGHT, + type NodeInfo, + NodeInfoSchema, PUBLIC_DATA_TREE_HEIGHT, PrivateLog, type ProtocolContractAddresses, @@ -230,6 +232,13 @@ export interface AztecNode */ isReady(): Promise; + /** + * Returns the information about the server's node. Includes current Node version, compatible Noir version, + * L1 chain identifier, protocol version, and L1 address of the rollup contract. + * @returns - The node information. + */ + getNodeInfo(): Promise; + /** * Method to request blocks. Will attempt to return all requested blocks but will return only those available. * @param from - The start of the range of blocks to return. @@ -508,6 +517,8 @@ export const AztecNodeApiSchema: ApiSchemaFor = { isReady: z.function().returns(z.boolean()), + getNodeInfo: z.function().returns(NodeInfoSchema), + getBlocks: z.function().args(z.number(), z.number()).returns(z.array(L2Block.schema)), getCurrentBaseFees: z.function().returns(GasFees.schema), diff --git a/yarn-project/cli/src/cmds/l1/index.ts b/yarn-project/cli/src/cmds/l1/index.ts index 80c0d514c3b..5bb1ff71240 100644 --- a/yarn-project/cli/src/cmds/l1/index.ts +++ b/yarn-project/cli/src/cmds/l1/index.ts @@ -109,7 +109,7 @@ export function injectCommands(program: Command, log: LogFn, debugLogger: DebugL 'test test test test test test test test test test test junk', ) .addOption(l1ChainIdOption) - .option('--validator ', 'ethereum address of the validator', parseEthereumAddress) + .option('--validator
', 'ethereum address of the validator', parseEthereumAddress) .option('--rollup
', 'ethereum address of the rollup contract', parseEthereumAddress) .action(async options => { const { removeL1Validator } = await import('./update_l1_validators.js'); diff --git a/yarn-project/cli/src/cmds/l1/update_l1_validators.ts b/yarn-project/cli/src/cmds/l1/update_l1_validators.ts index 9827721418e..e08231e5b7f 100644 --- a/yarn-project/cli/src/cmds/l1/update_l1_validators.ts +++ b/yarn-project/cli/src/cmds/l1/update_l1_validators.ts @@ -1,6 +1,6 @@ import { EthCheatCodes } from '@aztec/aztec.js'; import { type EthAddress } from '@aztec/circuits.js'; -import { createEthereumChain, getL1ContractsConfigEnvVars } from '@aztec/ethereum'; +import { createEthereumChain, getL1ContractsConfigEnvVars, isAnvilTestChain } from '@aztec/ethereum'; import { type DebugLogger, type LogFn } from '@aztec/foundation/log'; import { RollupAbi } from '@aztec/l1-artifacts'; @@ -53,9 +53,18 @@ export async function addL1Validator({ const txHash = await rollup.write.addValidator([validatorAddress.toString()]); dualLog(`Transaction hash: ${txHash}`); await publicClient.waitForTransactionReceipt({ hash: txHash }); - dualLog(`Funding validator on L1`); - const cheatCodes = new EthCheatCodes(rpcUrl, debugLogger); - await cheatCodes.setBalance(validatorAddress, 10n ** 20n); + if (isAnvilTestChain(chainId)) { + dualLog(`Funding validator on L1`); + const cheatCodes = new EthCheatCodes(rpcUrl, debugLogger); + await cheatCodes.setBalance(validatorAddress, 10n ** 20n); + } else { + const balance = await publicClient.getBalance({ address: validatorAddress.toString() }); + const balanceInEth = Number(balance) / 10 ** 18; + dualLog(`Validator balance: ${balanceInEth.toFixed(6)} ETH`); + if (balanceInEth === 0) { + dualLog(`WARNING: Validator has no balance. Remember to fund it!`); + } + } } export async function removeL1Validator({ diff --git a/yarn-project/cli/src/cmds/pxe/get_node_info.ts b/yarn-project/cli/src/cmds/pxe/get_node_info.ts index dd5939277fb..bbef7fde3e8 100644 --- a/yarn-project/cli/src/cmds/pxe/get_node_info.ts +++ b/yarn-project/cli/src/cmds/pxe/get_node_info.ts @@ -1,8 +1,13 @@ -import { createCompatibleClient } from '@aztec/aztec.js'; +import { type AztecNode, type PXE, createAztecNodeClient, createCompatibleClient } from '@aztec/aztec.js'; import { type DebugLogger, type LogFn } from '@aztec/foundation/log'; -export async function getNodeInfo(rpcUrl: string, debugLogger: DebugLogger, log: LogFn) { - const client = await createCompatibleClient(rpcUrl, debugLogger); +export async function getNodeInfo(rpcUrl: string, pxeRequest: boolean, debugLogger: DebugLogger, log: LogFn) { + let client: AztecNode | PXE; + if (pxeRequest) { + client = await createCompatibleClient(rpcUrl, debugLogger); + } else { + client = createAztecNodeClient(rpcUrl); + } const info = await client.getNodeInfo(); log(`Node Version: ${info.nodeVersion}`); log(`Chain Id: ${info.l1ChainId}`); diff --git a/yarn-project/cli/src/cmds/pxe/index.ts b/yarn-project/cli/src/cmds/pxe/index.ts index ad1d9ed59c3..ec3fec68ee7 100644 --- a/yarn-project/cli/src/cmds/pxe/index.ts +++ b/yarn-project/cli/src/cmds/pxe/index.ts @@ -4,7 +4,9 @@ import { type DebugLogger, type LogFn } from '@aztec/foundation/log'; import { type Command } from 'commander'; import { + LOCALHOST, logJson, + makePxeOption, parseAztecAddress, parseEthereumAddress, parseField, @@ -142,11 +144,18 @@ export function injectCommands(program: Command, log: LogFn, debugLogger: DebugL program .command('get-node-info') - .description('Gets the information of an aztec node at a URL.') - .addOption(pxeOption) + .description('Gets the information of an Aztec node from a PXE or directly from an Aztec node.') + .option('--node-url ', 'URL of the node.', `http://${LOCALHOST}:8080`) + .addOption(makePxeOption(false)) .action(async options => { const { getNodeInfo } = await import('./get_node_info.js'); - await getNodeInfo(options.rpcUrl, debugLogger, log); + let url: string; + if (options.nodeUrl) { + url = options.nodeUrl; + } else { + url = options.rpcUrl; + } + await getNodeInfo(url, !options.nodeUrl, debugLogger, log); }); program diff --git a/yarn-project/end-to-end/scripts/native-network/boot-node.sh b/yarn-project/end-to-end/scripts/native-network/boot-node.sh index 943bcdf4a4f..39067971ab9 100755 --- a/yarn-project/end-to-end/scripts/native-network/boot-node.sh +++ b/yarn-project/end-to-end/scripts/native-network/boot-node.sh @@ -13,7 +13,7 @@ exec > >(tee -a "$(dirname $0)/logs/${SCRIPT_NAME}.log") 2> >(tee -a "$(dirname export PORT=${PORT:-"8080"} export DEBUG=${DEBUG:-"aztec:*,-aztec:avm_simulator*,-aztec:libp2p_service*,-aztec:circuits:artifact_hash,-json-rpc*,-aztec:l2_block_stream,-aztec:world-state:*"} export LOG_LEVEL=${LOG_LEVEL:-"debug"} -export ETHEREUM_HOST="http://127.0.0.1:8545" +export ETHEREUM_HOST=${ETHEREUM_HOST:-"http://127.0.0.1:8545"} export P2P_ENABLED="true" export VALIDATOR_DISABLED="true" export SEQ_MAX_SECONDS_BETWEEN_BLOCKS="0" @@ -26,11 +26,11 @@ export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="${OTEL_EXPORTER_OTLP_METRICS_ENDPOIN export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-}" export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="${OTEL_EXPORTER_OTLP_LOGS_ENDPOINT:-}" export OTEL_RESOURCE_ATTRIBUTES="service.name=boot-node" -export VALIDATOR_PRIVATE_KEY="0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a" +export VALIDATOR_PRIVATE_KEY=${VALIDATOR_PRIVATE_KEY:-"0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a"} REPO=$(git rev-parse --show-toplevel) echo "Waiting for l1 contracts to be deployed..." -until [ -f "$REPO"/yarn-project/end-to-end/scripts/native-network/state/l1-contracts.env ] ; do +until [ -f "$REPO"/yarn-project/end-to-end/scripts/native-network/state/l1-contracts.env ]; do sleep 1 done echo "Done waiting." @@ -42,4 +42,4 @@ function filter_noise() { } # Start the Aztec node with the sequencer and archiver -node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js start --node --archiver --sequencer --pxe 2>&1 | filter_noise \ No newline at end of file +node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js start --node --archiver --sequencer 2>&1 | filter_noise diff --git a/yarn-project/end-to-end/scripts/native-network/deploy-l1-contracts.sh b/yarn-project/end-to-end/scripts/native-network/deploy-l1-contracts.sh index 9e9dad3f195..2d4677b1660 100755 --- a/yarn-project/end-to-end/scripts/native-network/deploy-l1-contracts.sh +++ b/yarn-project/end-to-end/scripts/native-network/deploy-l1-contracts.sh @@ -18,21 +18,33 @@ else INIT_VALIDATORS="false" fi -echo "Waiting for Anvil to be up at port 8545..." +export ETHEREUM_HOST=${ETHEREUM_HOST:-"http://127.0.0.1:8545"} +export L1_CHAIN_ID=${L1_CHAIN_ID:-"31337"} +export PRIVATE_KEY=${PRIVATE_KEY:-""} +export SALT=${SALT:-"1337"} + +echo "Waiting for Ethereum node to be up..." until curl -s -X POST -H 'Content-Type: application/json' \ --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ - http://127.0.0.1:8545 2>/dev/null | grep -q 'result' ; do + $ETHEREUM_HOST 2>/dev/null | grep -q 'result'; do sleep 1 done echo "Done waiting." -# Run the deploy-l1-contracts command and capture the output -export ETHEREUM_HOST="http://127.0.0.1:8545" -if [ "$INIT_VALIDATORS" = "true" ]; then - output=$(node --no-warnings $(git rev-parse --show-toplevel)/yarn-project/aztec/dest/bin/index.js deploy-l1-contracts --validators "$VALIDATOR_ADDRESSES" --salt 1337) -else - output=$(node --no-warnings $(git rev-parse --show-toplevel)/yarn-project/aztec/dest/bin/index.js deploy-l1-contracts --salt 1337) -fi +# Construct base command +COMMAND="node --no-warnings $(git rev-parse --show-toplevel)/yarn-project/aztec/dest/bin/index.js \ + deploy-l1-contracts \ + --rpc-url $ETHEREUM_HOST \ + --l1-chain-id $L1_CHAIN_ID \ + --salt $SALT" + +# Add validators if specified +[ "$INIT_VALIDATORS" = "true" ] && COMMAND="$COMMAND --validators $VALIDATOR_ADDRESSES" + +# Add private key if provided +[ -n "$PRIVATE_KEY" ] && COMMAND="$COMMAND --private-key $PRIVATE_KEY" + +output=$($COMMAND) echo "$output" @@ -48,9 +60,8 @@ REWARD_DISTRIBUTOR_CONTRACT_ADDRESS=$(echo "$output" | grep -oP 'RewardDistribut GOVERNANCE_PROPOSER_CONTRACT_ADDRESS=$(echo "$output" | grep -oP 'GovernanceProposer Address: \K0x[a-fA-F0-9]{40}') GOVERNANCE_CONTRACT_ADDRESS=$(echo "$output" | grep -oP 'Governance Address: \K0x[a-fA-F0-9]{40}') - # Save contract addresses to state/l1-contracts.env -cat << EOCONFIG > $(git rev-parse --show-toplevel)/yarn-project/end-to-end/scripts/native-network/state/l1-contracts.env +cat <$(git rev-parse --show-toplevel)/yarn-project/end-to-end/scripts/native-network/state/l1-contracts.env export ROLLUP_CONTRACT_ADDRESS=$ROLLUP_CONTRACT_ADDRESS export REGISTRY_CONTRACT_ADDRESS=$REGISTRY_CONTRACT_ADDRESS export INBOX_CONTRACT_ADDRESS=$INBOX_CONTRACT_ADDRESS diff --git a/yarn-project/end-to-end/scripts/native-network/prover-node.sh b/yarn-project/end-to-end/scripts/native-network/prover-node.sh index 1fa0ac6865c..866ee0f73e9 100755 --- a/yarn-project/end-to-end/scripts/native-network/prover-node.sh +++ b/yarn-project/end-to-end/scripts/native-network/prover-node.sh @@ -14,11 +14,11 @@ exec > >(tee -a "$(dirname $0)/logs/${SCRIPT_NAME}.log") 2> >(tee -a "$(dirname REPO=$(git rev-parse --show-toplevel) echo "Waiting for l1 contracts to be deployed..." -until [ -f "$REPO"/yarn-project/end-to-end/scripts/native-network/state/l1-contracts.env ] ; do +until [ -f "$REPO"/yarn-project/end-to-end/scripts/native-network/state/l1-contracts.env ]; do sleep 1 done echo "Waiting for Aztec Node..." -until curl -s http://127.0.0.1:8080/status >/dev/null ; do +until curl -s http://127.0.0.1:8080/status >/dev/null; do sleep 1 done echo "Done waiting." @@ -26,7 +26,7 @@ echo "Done waiting." source "$REPO"/yarn-project/end-to-end/scripts/native-network/state/l1-contracts.env # Get node info from the boot node -output=$(node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js get-node-info -u http://127.0.0.1:8080) +output=$(node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js get-node-info --node-url http://127.0.0.1:8080) # Extract boot node ENR export BOOTSTRAP_NODES=$(echo "$output" | grep -oP 'Node ENR: \K.*') @@ -34,9 +34,10 @@ export BOOTSTRAP_NODES=$(echo "$output" | grep -oP 'Node ENR: \K.*') # Set environment variables export LOG_LEVEL=${LOG_LEVEL:-"debug"} export DEBUG=${DEBUG:-"aztec:*,-aztec:avm_simulator*,-aztec:libp2p_service*,-aztec:circuits:artifact_hash,-json-rpc*,-aztec:l2_block_stream,-aztec:world-state:*"} -export ETHEREUM_HOST="http://127.0.0.1:8545" +export ETHEREUM_HOST=${ETHEREUM_HOST:-"http://127.0.0.1:8545"} export PROVER_AGENT_COUNT="1" -export PROVER_PUBLISHER_PRIVATE_KEY="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +export PROVER_AGENT_ENABLED="true" +export PROVER_PUBLISHER_PRIVATE_KEY=${PROVER_PUBLISHER_PRIVATE_KEY:-"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"} export PROVER_COORDINATION_NODE_URL="http://127.0.0.1:8080" export AZTEC_NODE_URL="http://127.0.0.1:8080" export OTEL_RESOURCE_ATTRIBUTES="service.name=prover-node-${PORT}" diff --git a/yarn-project/end-to-end/scripts/native-network/pxe.sh b/yarn-project/end-to-end/scripts/native-network/pxe.sh index e02133cf943..c7db13a4c56 100755 --- a/yarn-project/end-to-end/scripts/native-network/pxe.sh +++ b/yarn-project/end-to-end/scripts/native-network/pxe.sh @@ -9,19 +9,20 @@ exec > >(tee -a "$(dirname $0)/logs/${SCRIPT_NAME}.log") 2> >(tee -a "$(dirname # Starts the PXE (Private eXecution Environment) service # Set environment variables -export ETHEREUM_HOST="http://127.0.0.1:8545" -export AZTEC_NODE_URL="http://127.0.0.1:8080" +export ETHEREUM_HOST=${ETHEREUM_HOST:-"http://127.0.0.1:8545"} +export AZTEC_NODE_URL=${AZTEC_NODE_URL:-"http://127.0.0.1:8080"} +export VALIDATOR_NODE_URL=${VALIDATOR_NODE_URL:-"http://127.0.0.1:8081"} export LOG_LEVEL=${LOG_LEVEL:-"debug"} export DEBUG="aztec:*" echo "Waiting for Aztec Node..." -until curl -s http://127.0.0.1:8080/status >/dev/null ; do +until curl -s $AZTEC_NODE_URL/status >/dev/null; do sleep 1 done # We need to also wait for the validator, as the initial node cannot # Produce blocks on it's own echo "Waiting for Validator 0..." -until curl -s http://127.0.0.1:8081/status >/dev/null ; do +until curl -s $VALIDATOR_NODE_URL/status >/dev/null; do sleep 1 done echo "Done waiting." @@ -31,4 +32,4 @@ function filter_noise() { } # Start the PXE service -node --no-warnings $(git rev-parse --show-toplevel)/yarn-project/aztec/dest/bin/index.js start --port=8079 --pxe 2>&1 | filter_noise \ No newline at end of file +node --no-warnings $(git rev-parse --show-toplevel)/yarn-project/aztec/dest/bin/index.js start --port=8079 --pxe 2>&1 | filter_noise diff --git a/yarn-project/end-to-end/scripts/native-network/test-transfer.sh b/yarn-project/end-to-end/scripts/native-network/test-transfer.sh index 50790afbe3e..e54d8966ede 100755 --- a/yarn-project/end-to-end/scripts/native-network/test-transfer.sh +++ b/yarn-project/end-to-end/scripts/native-network/test-transfer.sh @@ -11,6 +11,7 @@ exec > >(tee -a "$(dirname $0)/logs/${SCRIPT_NAME}.log") 2> >(tee -a "$(dirname export BOOTNODE_URL=${BOOTNODE_URL:-http://127.0.0.1:8080} export PXE_URL=${PXE_URL:-http://127.0.0.1:8079} export ETHEREUM_HOST=${ETHEREUM_HOST:-http://127.0.0.1:8545} +export K8S=${K8S:-false} REPO=$(git rev-parse --show-toplevel) # Run our test assuming the port in pxe.sh diff --git a/yarn-project/end-to-end/scripts/native-network/transaction-bot.sh b/yarn-project/end-to-end/scripts/native-network/transaction-bot.sh index 722bfdcf0ce..a42c2417ffd 100755 --- a/yarn-project/end-to-end/scripts/native-network/transaction-bot.sh +++ b/yarn-project/end-to-end/scripts/native-network/transaction-bot.sh @@ -4,6 +4,10 @@ set -eu # Get the name of the script without the path and extension SCRIPT_NAME=$(basename "$0" .sh) +# Set the token contract to use +export BOT_TOKEN_CONTRACT=${BOT_TOKEN_CONTRACT:-"TokenContract"} +export BOT_PXE_URL=${BOT_PXE_URL:-"http://127.0.0.1:8079"} + # Redirect stdout and stderr to .log while also printing to the console exec > >(tee -a "$(dirname $0)/logs/${SCRIPT_NAME}.log") 2> >(tee -a "$(dirname $0)/logs/${SCRIPT_NAME}.log" >&2) @@ -11,24 +15,28 @@ exec > >(tee -a "$(dirname $0)/logs/${SCRIPT_NAME}.log") 2> >(tee -a "$(dirname REPO=$(git rev-parse --show-toplevel) echo "Waiting for Aztec Node..." -until curl -s http://127.0.0.1:8080/status >/dev/null ; do +until curl -s http://127.0.0.1:8080/status >/dev/null; do sleep 1 done echo "Waiting for PXE service..." until curl -s -X POST -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","method":"pxe_getNodeInfo","params":[],"id":67}' \ - http://127.0.0.1:8079 | grep -q '"enr:-'; do - sleep 1 -done -echo "Waiting for l2 contracts to be deployed..." -until [ -f "$REPO"/yarn-project/end-to-end/scripts/native-network/state/l2-contracts.env ] ; do + $BOT_PXE_URL | grep -q '"enr:-'; do sleep 1 done -echo "Done waiting." + +# Don't wait for l2 contracts if using EasyPrivateTokenContract +if [ "${BOT_TOKEN_CONTRACT:-TokenContract}" != "EasyPrivateTokenContract" ]; then + echo "Waiting for l2 contracts to be deployed..." + until [ -f "$REPO"/yarn-project/end-to-end/scripts/native-network/state/l2-contracts.env ]; do + sleep 1 + done + echo "Done waiting." +fi # Set environment variables -export ETHEREUM_HOST="http://127.0.0.1:8545" -export AZTEC_NODE_URL="http://127.0.0.1:8080" +export ETHEREUM_HOST=${ETHEREUM_HOST:-"http://127.0.0.1:8545"} +export AZTEC_NODE_URL=${AZTEC_NODE_URL:-"http://127.0.0.1:8080"} export LOG_LEVEL=${LOG_LEVEL:-"debug"} export DEBUG="aztec:*,-aztec:avm_simulator*,-aztec:libp2p_service*,-aztec:circuits:artifact_hash,-json-rpc*,-aztec:l2_block_stream,-aztec:world-state:*" export BOT_PRIVATE_KEY="0xcafe" @@ -42,4 +50,5 @@ export PXE_PROVER_ENABLED="false" export PROVER_REAL_PROOFS="false" # Start the bot -node --no-warnings $(git rev-parse --show-toplevel)/yarn-project/aztec/dest/bin/index.js start --port=8077 --pxe --bot + +node --no-warnings $REPO/yarn-project/aztec/dest/bin/index.js start --port=8077 --bot --pxe diff --git a/yarn-project/end-to-end/scripts/native-network/validator.sh b/yarn-project/end-to-end/scripts/native-network/validator.sh index 518dbb9db97..fa183829d61 100755 --- a/yarn-project/end-to-end/scripts/native-network/validator.sh +++ b/yarn-project/end-to-end/scripts/native-network/validator.sh @@ -10,19 +10,21 @@ exec > >(tee -a "$(dirname $0)/logs/${SCRIPT_NAME}.log") 2> >(tee -a "$(dirname # PORTS PORT="$1" P2P_PORT="$2" +ADDRESS="${3:-${ADDRESS:-}}" +export VALIDATOR_PRIVATE_KEY="${4:-${VALIDATOR_PRIVATE_KEY:-}}" # Starts the Validator Node REPO=$(git rev-parse --show-toplevel) echo "Waiting for l1 contracts to be deployed..." -until [ -f "$REPO"/yarn-project/end-to-end/scripts/native-network/state/l1-contracts.env ] ; do +until [ -f "$REPO"/yarn-project/end-to-end/scripts/native-network/state/l1-contracts.env ]; do sleep 1 done source "$REPO"/yarn-project/end-to-end/scripts/native-network/state/l1-contracts.env echo "Waiting for Aztec Node..." -until curl -s http://127.0.0.1:8080/status >/dev/null ; do +until curl -s http://127.0.0.1:8080/status >/dev/null; do sleep 1 done echo "Done waiting." @@ -31,21 +33,32 @@ echo "Done waiting." BOOT_NODE_URL="http://127.0.0.1:8080" # Get node info from the boot node -output=$(node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js get-node-info -u $BOOT_NODE_URL) +output=$(node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js get-node-info --node-url $BOOT_NODE_URL) # Extract boot node ENR export BOOTSTRAP_NODES=$(echo "$output" | grep -oP 'Node ENR: \K.*') echo "BOOTSTRAP_NODES: $BOOTSTRAP_NODES" -# Generate a private key for the validator -json_account=$(node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js generate-l1-account) -export ADDRESS=$(echo $json_account | jq -r '.address') -export LOG_LEVEL=${LOG_LEVEL:-"debug"} -export VALIDATOR_PRIVATE_KEY=$(echo $json_account | jq -r '.privateKey') +# Generate a private key for the validator only if not already set +if [ -z "${VALIDATOR_PRIVATE_KEY:-}" ] || [ -z "${ADDRESS:-}" ]; then + echo "Generating new L1 Validator account..." + json_account=$(node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js generate-l1-account) + export ADDRESS=$(echo $json_account | jq -r '.address') + export VALIDATOR_PRIVATE_KEY=$(echo $json_account | jq -r '.privateKey') +fi + export L1_PRIVATE_KEY=$VALIDATOR_PRIVATE_KEY export SEQ_PUBLISHER_PRIVATE_KEY=$VALIDATOR_PRIVATE_KEY export DEBUG=${DEBUG:-"aztec:*,-aztec:avm_simulator*,-aztec:libp2p_service*,-aztec:circuits:artifact_hash,-json-rpc*,-aztec:l2_block_stream,-aztec:world-state:*"} -export ETHEREUM_HOST="http://127.0.0.1:8545" +export ETHEREUM_HOST=${ETHEREUM_HOST:-"http://127.0.0.1:8545"} + +# Automatically detect if we're using Anvil +if curl -s -H "Content-Type: application/json" -X POST --data '{"method":"web3_clientVersion","params":[],"id":49,"jsonrpc":"2.0"}' $ETHEREUM_HOST | jq .result | grep -q anvil; then + IS_ANVIL="true" +else + IS_ANVIL="false" +fi + export P2P_ENABLED="true" export VALIDATOR_DISABLED="false" export SEQ_MAX_SECONDS_BETWEEN_BLOCKS="0" @@ -59,15 +72,24 @@ export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="${OTEL_EXPORTER_OTLP_METRICS_ENDPOIN export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-}" export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="${OTEL_EXPORTER_OTLP_LOGS_ENDPOINT:-}" -# Add L1 validator -# this may fail, so try 3 times -for i in {1..3}; do - node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js add-l1-validator --validator $ADDRESS --rollup $ROLLUP_CONTRACT_ADDRESS && break - sleep 1 -done +# Check if validator is already registered +echo "Checking if validator is already registered..." +debug_output=$(node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js debug-rollup --rollup $ROLLUP_CONTRACT_ADDRESS) +if echo "$debug_output" | grep -q "Validators:.*$ADDRESS"; then + echo "Validator $ADDRESS is already registered" +else + # Add L1 validator + # this may fail, so try 3 times + echo "Adding validator $ADDRESS..." + for i in {1..3}; do + node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js add-l1-validator --validator $ADDRESS --rollup $ROLLUP_CONTRACT_ADDRESS && break + sleep 1 + done +fi -# Fast forward epochs -node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js fast-forward-epochs --rollup $ROLLUP_CONTRACT_ADDRESS --count 1 +# Fast forward epochs if we're on an anvil chain +if [ "$IS_ANVIL" = "true" ]; then + node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js fast-forward-epochs --rollup $ROLLUP_CONTRACT_ADDRESS --count 1 +fi # Start the Validator Node with the sequencer and archiver node --no-warnings "$REPO"/yarn-project/aztec/dest/bin/index.js start --port="$PORT" --node --archiver --sequencer - diff --git a/yarn-project/end-to-end/scripts/native-network/validators.sh b/yarn-project/end-to-end/scripts/native-network/validators.sh index 6a9ac7f4f40..c2454b87481 100755 --- a/yarn-project/end-to-end/scripts/native-network/validators.sh +++ b/yarn-project/end-to-end/scripts/native-network/validators.sh @@ -16,19 +16,26 @@ cd "$(dirname "${BASH_SOURCE[0]}")" CMD=() # Generate validator commands -for ((i=0; i { // Expect the tx to revert await expect(publisher.proposeL2Block(block)).resolves.toEqual(false); - // Expect a proper error to be logged. Full message looks like: - // aztec:sequencer:publisher [ERROR] Rollup process tx reverted. The contract function "propose" reverted. Error: Rollup__InvalidInHash(bytes32 expected, bytes32 actual) (0x00089a9d421a82c4a25f7acbebe69e638d5b064fa8a60e018793dcb0be53752c, 0x00a5a12af159e0608de45d825718827a36d8a7cdfa9ecc7955bc62180ae78e51) blockNumber=1 slotNumber=49 blockHash=0x131c59ebc2ce21224de6473fe954b0d4eb918043432a3a95406bb7e7a4297fbd txHash=0xc01c3c26b6b67003a8cce352afe475faf7e0196a5a3bba963cfda3792750ed28 - expect(loggerErrorSpy).toHaveBeenCalledWith( - expect.stringMatching(/Rollup__InvalidInHash/), + // Test for both calls + expect(loggerErrorSpy).toHaveBeenCalledTimes(2); + + // Test first call + expect(loggerErrorSpy).toHaveBeenNthCalledWith( + 1, + expect.stringMatching(/^L1 Transaction 0x[a-f0-9]{64} reverted$/), + ); + + // Test second call + expect(loggerErrorSpy).toHaveBeenNthCalledWith( + 2, + expect.stringMatching( + /^Rollup process tx reverted\. The contract function "propose" reverted\. Error: Rollup__InvalidInHash/, + ), undefined, expect.objectContaining({ blockHash: expect.any(String), blockNumber: expect.any(Number), slotNumber: expect.any(BigInt), + txHash: expect.any(String), }), ); }); diff --git a/yarn-project/ethereum/package.json b/yarn-project/ethereum/package.json index 887ad01645d..f6be604435c 100644 --- a/yarn-project/ethereum/package.json +++ b/yarn-project/ethereum/package.json @@ -41,6 +41,8 @@ "@jest/globals": "^29.5.0", "@types/jest": "^29.5.0", "@types/node": "^18.14.6", + "@viem/anvil": "^0.0.10", + "get-port": "^7.1.0", "jest": "^29.5.0", "ts-node": "^10.9.1", "typescript": "^5.0.4" diff --git a/yarn-project/ethereum/src/deploy_l1_contracts.ts b/yarn-project/ethereum/src/deploy_l1_contracts.ts index e9d8522f636..8d2f6b64245 100644 --- a/yarn-project/ethereum/src/deploy_l1_contracts.ts +++ b/yarn-project/ethereum/src/deploy_l1_contracts.ts @@ -55,6 +55,7 @@ import { foundry } from 'viem/chains'; import { type L1ContractsConfig } from './config.js'; import { isAnvilTestChain } from './ethereum_chain.js'; import { type L1ContractAddresses } from './l1_contract_addresses.js'; +import { L1TxUtils } from './l1_tx_utils.js'; /** * Return type of the deployL1Contract function. @@ -607,7 +608,9 @@ export async function deployL1Contract( logger?: DebugLogger, ): Promise<{ address: EthAddress; txHash: Hex | undefined }> { let txHash: Hex | undefined = undefined; - let address: Hex | null | undefined = undefined; + let resultingAddress: Hex | null | undefined = undefined; + + const l1TxUtils = new L1TxUtils(publicClient, walletClient, logger); if (libraries) { // @note Assumes that we wont have nested external libraries. @@ -659,21 +662,31 @@ export async function deployL1Contract( const salt = padHex(maybeSalt, { size: 32 }); const deployer: Hex = '0x4e59b44847b379578588920cA78FbF26c0B4956C'; const calldata = encodeDeployData({ abi, bytecode, args }); - address = getContractAddress({ from: deployer, salt, bytecode: calldata, opcode: 'CREATE2' }); - const existing = await publicClient.getBytecode({ address }); + resultingAddress = getContractAddress({ from: deployer, salt, bytecode: calldata, opcode: 'CREATE2' }); + const existing = await publicClient.getBytecode({ address: resultingAddress }); if (existing === undefined || existing === '0x') { - txHash = await walletClient.sendTransaction({ to: deployer, data: concatHex([salt, calldata]) }); - logger?.verbose(`Deploying contract with salt ${salt} to address ${address} in tx ${txHash}`); + const res = await l1TxUtils.sendTransaction({ + to: deployer, + data: concatHex([salt, calldata]), + }); + txHash = res.txHash; + + logger?.verbose(`Deployed contract with salt ${salt} to address ${resultingAddress} in tx ${txHash}.`); } else { - logger?.verbose(`Skipping existing deployment of contract with salt ${salt} to address ${address}`); + logger?.verbose(`Skipping existing deployment of contract with salt ${salt} to address ${resultingAddress}`); } } else { - txHash = await walletClient.deployContract({ abi, bytecode, args }); - logger?.verbose(`Deploying contract in tx ${txHash}`); - const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash, pollingInterval: 100 }); - address = receipt.contractAddress; - if (!address) { + // Regular deployment path + const deployData = encodeDeployData({ abi, bytecode, args }); + const receipt = await l1TxUtils.sendAndMonitorTransaction({ + to: null, + data: deployData, + }); + + txHash = receipt.transactionHash; + resultingAddress = receipt.contractAddress; + if (!resultingAddress) { throw new Error( `No contract address found in receipt: ${JSON.stringify(receipt, (_, val) => typeof val === 'bigint' ? String(val) : val, @@ -682,6 +695,6 @@ export async function deployL1Contract( } } - return { address: EthAddress.fromString(address!), txHash }; + return { address: EthAddress.fromString(resultingAddress!), txHash }; } // docs:end:deployL1Contract diff --git a/yarn-project/ethereum/src/eth_cheat_codes.ts b/yarn-project/ethereum/src/eth_cheat_codes.ts new file mode 100644 index 00000000000..74918bf4653 --- /dev/null +++ b/yarn-project/ethereum/src/eth_cheat_codes.ts @@ -0,0 +1,316 @@ +import { toBigIntBE, toHex } from '@aztec/foundation/bigint-buffer'; +import { keccak256 } from '@aztec/foundation/crypto'; +import { type EthAddress } from '@aztec/foundation/eth-address'; +import { createDebugLogger } from '@aztec/foundation/log'; + +import fs from 'fs'; +import { type Hex } from 'viem'; + +/** + * A class that provides utility functions for interacting with ethereum (L1). + */ +export class EthCheatCodes { + constructor( + /** + * The RPC URL to use for interacting with the chain + */ + public rpcUrl: string, + /** + * The logger to use for the eth cheatcodes + */ + public logger = createDebugLogger('aztec:cheat_codes:eth'), + ) {} + + async rpcCall(method: string, params: any[]) { + const paramsString = JSON.stringify(params); + const content = { + body: `{"jsonrpc":"2.0", "method": "${method}", "params": ${paramsString}, "id": 1}`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }; + return await (await fetch(this.rpcUrl, content)).json(); + } + + /** + * Get the auto mine status of the underlying chain + * @returns True if automine is on, false otherwise + */ + public async isAutoMining(): Promise { + try { + const res = await this.rpcCall('anvil_getAutomine', []); + return res.result; + } catch (err) { + this.logger.error(`Calling "anvil_getAutomine" failed with:`, err); + } + return false; + } + + /** + * Get the current blocknumber + * @returns The current block number + */ + public async blockNumber(): Promise { + const res = await this.rpcCall('eth_blockNumber', []); + return parseInt(res.result, 16); + } + + /** + * Get the current chainId + * @returns The current chainId + */ + public async chainId(): Promise { + const res = await this.rpcCall('eth_chainId', []); + return parseInt(res.result, 16); + } + + /** + * Get the current timestamp + * @returns The current timestamp + */ + public async timestamp(): Promise { + const res = await this.rpcCall('eth_getBlockByNumber', ['latest', true]); + return parseInt(res.result.timestamp, 16); + } + + /** + * Advance the chain by a number of blocks + * @param numberOfBlocks - The number of blocks to mine + */ + public async mine(numberOfBlocks = 1): Promise { + const res = await this.rpcCall('hardhat_mine', [numberOfBlocks]); + if (res.error) { + throw new Error(`Error mining: ${res.error.message}`); + } + this.logger.verbose(`Mined ${numberOfBlocks} L1 blocks`); + } + + /** + * Mines a single block with evm_mine + */ + public async evmMine(): Promise { + const res = await this.rpcCall('evm_mine', []); + if (res.error) { + throw new Error(`Error mining: ${res.error.message}`); + } + } + + /** + * Set the balance of an account + * @param account - The account to set the balance for + * @param balance - The balance to set + */ + public async setBalance(account: EthAddress, balance: bigint): Promise { + const res = await this.rpcCall('anvil_setBalance', [account.toString(), toHex(balance)]); + if (res.error) { + throw new Error(`Error setting balance for ${account}: ${res.error.message}`); + } + this.logger.verbose(`Set balance for ${account} to ${balance}`); + } + + /** + * Set the interval between blocks (block time) + * @param interval - The interval to use between blocks + */ + public async setBlockInterval(interval: number): Promise { + const res = await this.rpcCall('anvil_setBlockTimestampInterval', [interval]); + if (res.error) { + throw new Error(`Error setting block interval: ${res.error.message}`); + } + this.logger.verbose(`Set L1 block interval to ${interval}`); + } + + /** + * Set the next block base fee per gas + * @param baseFee - The base fee to set + */ + public async setNextBlockBaseFeePerGas(baseFee: bigint): Promise { + const res = await this.rpcCall('anvil_setNextBlockBaseFeePerGas', [baseFee.toString()]); + if (res.error) { + throw new Error(`Error setting next block base fee per gas: ${res.error.message}`); + } + this.logger.verbose(`Set L1 next block base fee per gas to ${baseFee}`); + } + + /** + * Set the interval between blocks (block time) + * @param seconds - The interval to use between blocks + */ + public async setIntervalMining(seconds: number): Promise { + const res = await this.rpcCall('anvil_setIntervalMining', [seconds]); + if (res.error) { + throw new Error(`Error setting interval mining: ${res.error.message}`); + } + this.logger.verbose(`Set L1 interval mining to ${seconds} seconds`); + } + + /** + * Set the automine status of the underlying anvil chain + * @param automine - The automine status to set + */ + public async setAutomine(automine: boolean): Promise { + const res = await this.rpcCall('anvil_setAutomine', [automine]); + if (res.error) { + throw new Error(`Error setting automine: ${res.error.message}`); + } + this.logger.verbose(`Set L1 automine to ${automine}`); + } + + /** + * Drop a transaction from the mempool + * @param txHash - The transaction hash + */ + public async dropTransaction(txHash: Hex): Promise { + const res = await this.rpcCall('anvil_dropTransaction', [txHash]); + if (res.error) { + throw new Error(`Error dropping transaction: ${res.error.message}`); + } + this.logger.verbose(`Dropped transaction ${txHash}`); + } + + /** + * Set the next block timestamp + * @param timestamp - The timestamp to set the next block to + */ + public async setNextBlockTimestamp(timestamp: number): Promise { + const res = await this.rpcCall('evm_setNextBlockTimestamp', [timestamp]); + if (res.error) { + throw new Error(`Error setting next block timestamp: ${res.error.message}`); + } + this.logger.verbose(`Set L1 next block timestamp to ${timestamp}`); + } + + /** + * Set the next block timestamp and mines the block + * @param timestamp - The timestamp to set the next block to + */ + public async warp(timestamp: number | bigint): Promise { + const res = await this.rpcCall('evm_setNextBlockTimestamp', [Number(timestamp)]); + if (res.error) { + throw new Error(`Error warping: ${res.error.message}`); + } + await this.mine(); + this.logger.verbose(`Warped L1 timestamp to ${timestamp}`); + } + + /** + * Dumps the current chain state to a file. + * @param fileName - The file name to dump state into + */ + public async dumpChainState(fileName: string): Promise { + const res = await this.rpcCall('hardhat_dumpState', []); + if (res.error) { + throw new Error(`Error dumping state: ${res.error.message}`); + } + const jsonContent = JSON.stringify(res.result); + fs.writeFileSync(`${fileName}.json`, jsonContent, 'utf8'); + this.logger.verbose(`Dumped state to ${fileName}`); + } + + /** + * Loads the chain state from a file. + * @param fileName - The file name to load state from + */ + public async loadChainState(fileName: string): Promise { + const data = JSON.parse(fs.readFileSync(`${fileName}.json`, 'utf8')); + const res = await this.rpcCall('hardhat_loadState', [data]); + if (res.error) { + throw new Error(`Error loading state: ${res.error.message}`); + } + this.logger.verbose(`Loaded state from ${fileName}`); + } + + /** + * Load the value at a storage slot of a contract address on eth + * @param contract - The contract address + * @param slot - The storage slot + * @returns - The value at the storage slot + */ + public async load(contract: EthAddress, slot: bigint): Promise { + const res = await this.rpcCall('eth_getStorageAt', [contract.toString(), toHex(slot), 'latest']); + return BigInt(res.result); + } + + /** + * Set the value at a storage slot of a contract address on eth + * @param contract - The contract address + * @param slot - The storage slot + * @param value - The value to set the storage slot to + */ + public async store(contract: EthAddress, slot: bigint, value: bigint): Promise { + // for the rpc call, we need to change value to be a 32 byte hex string. + const res = await this.rpcCall('hardhat_setStorageAt', [contract.toString(), toHex(slot), toHex(value, true)]); + if (res.error) { + throw new Error(`Error setting storage for contract ${contract} at ${slot}: ${res.error.message}`); + } + this.logger.verbose(`Set L1 storage for contract ${contract} at ${slot} to ${value}`); + } + + /** + * Computes the slot value for a given map and key. + * @param baseSlot - The base slot of the map (specified in Aztec.nr contract) + * @param key - The key to lookup in the map + * @returns The storage slot of the value in the map + */ + public keccak256(baseSlot: bigint, key: bigint): bigint { + // abi encode (removing the 0x) - concat key and baseSlot (both padded to 32 bytes) + const abiEncoded = toHex(key, true).substring(2) + toHex(baseSlot, true).substring(2); + return toBigIntBE(keccak256(Buffer.from(abiEncoded, 'hex'))); + } + + /** + * Send transactions impersonating an externally owned account or contract. + * @param who - The address to impersonate + */ + public async startImpersonating(who: EthAddress | Hex): Promise { + const res = await this.rpcCall('hardhat_impersonateAccount', [who.toString()]); + if (res.error) { + throw new Error(`Error impersonating ${who}: ${res.error.message}`); + } + this.logger.verbose(`Impersonating ${who}`); + } + + /** + * Stop impersonating an account that you are currently impersonating. + * @param who - The address to stop impersonating + */ + public async stopImpersonating(who: EthAddress | Hex): Promise { + const res = await this.rpcCall('hardhat_stopImpersonatingAccount', [who.toString()]); + if (res.error) { + throw new Error(`Error when stopping the impersonation of ${who}: ${res.error.message}`); + } + this.logger.verbose(`Stopped impersonating ${who}`); + } + + /** + * Set the bytecode for a contract + * @param contract - The contract address + * @param bytecode - The bytecode to set + */ + public async etch(contract: EthAddress, bytecode: `0x${string}`): Promise { + const res = await this.rpcCall('hardhat_setCode', [contract.toString(), bytecode]); + if (res.error) { + throw new Error(`Error setting bytecode for ${contract}: ${res.error.message}`); + } + this.logger.verbose(`Set bytecode for ${contract} to ${bytecode}`); + } + + /** + * Get the bytecode for a contract + * @param contract - The contract address + * @returns The bytecode for the contract + */ + public async getBytecode(contract: EthAddress): Promise<`0x${string}`> { + const res = await this.rpcCall('eth_getCode', [contract.toString(), 'latest']); + return res.result; + } + + /** + * Get the raw transaction object for a given transaction hash + * @param txHash - The transaction hash + * @returns The raw transaction + */ + public async getRawTransaction(txHash: Hex): Promise<`0x${string}`> { + const res = await this.rpcCall('debug_getRawTransaction', [txHash]); + return res.result; + } +} diff --git a/yarn-project/ethereum/src/index.ts b/yarn-project/ethereum/src/index.ts index 30a990db651..d6393560093 100644 --- a/yarn-project/ethereum/src/index.ts +++ b/yarn-project/ethereum/src/index.ts @@ -1,8 +1,10 @@ export * from './constants.js'; export * from './deploy_l1_contracts.js'; +export * from './ethereum_chain.js'; +export * from './eth_cheat_codes.js'; +export * from './l1_tx_utils.js'; export * from './l1_contract_addresses.js'; export * from './l1_reader.js'; -export * from './ethereum_chain.js'; export * from './utils.js'; export * from './config.js'; export * from './types.js'; diff --git a/yarn-project/ethereum/src/l1_tx_utils.test.ts b/yarn-project/ethereum/src/l1_tx_utils.test.ts new file mode 100644 index 00000000000..7dffaf011ce --- /dev/null +++ b/yarn-project/ethereum/src/l1_tx_utils.test.ts @@ -0,0 +1,302 @@ +import { EthAddress } from '@aztec/foundation/eth-address'; +import { createDebugLogger } from '@aztec/foundation/log'; +import { sleep } from '@aztec/foundation/sleep'; + +import { type Anvil } from '@viem/anvil'; +import { + type Account, + type Chain, + type HttpTransport, + type PublicClient, + type WalletClient, + createPublicClient, + createWalletClient, + http, +} from 'viem'; +import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts'; +import { foundry } from 'viem/chains'; + +import { EthCheatCodes } from './eth_cheat_codes.js'; +import { L1TxUtils, defaultL1TxUtilsConfig } from './l1_tx_utils.js'; +import { startAnvil } from './test/start_anvil.js'; + +const MNEMONIC = 'test test test test test test test test test test test junk'; +const WEI_CONST = 1_000_000_000n; +// Simple contract that just returns 42 +const SIMPLE_CONTRACT_BYTECODE = '0x69602a60005260206000f3600052600a6016f3'; + +export type PendingTransaction = { + hash: `0x${string}`; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; +}; + +describe('GasUtils', () => { + let gasUtils: L1TxUtils; + let walletClient: WalletClient; + let publicClient: PublicClient; + let anvil: Anvil; + let cheatCodes: EthCheatCodes; + const initialBaseFee = WEI_CONST; // 1 gwei + const logger = createDebugLogger('l1_gas_test'); + + beforeAll(async () => { + const { anvil: anvilInstance, rpcUrl } = await startAnvil(1); + anvil = anvilInstance; + cheatCodes = new EthCheatCodes(rpcUrl); + const hdAccount = mnemonicToAccount(MNEMONIC, { addressIndex: 0 }); + const privKeyRaw = hdAccount.getHdKey().privateKey; + if (!privKeyRaw) { + throw new Error('Failed to get private key'); + } + const privKey = Buffer.from(privKeyRaw).toString('hex'); + const account = privateKeyToAccount(`0x${privKey}`); + + publicClient = createPublicClient({ + transport: http(rpcUrl), + chain: foundry, + }); + + walletClient = createWalletClient({ + transport: http(rpcUrl), + chain: foundry, + account, + }); + + // set base fee + await publicClient.transport.request({ + method: 'anvil_setNextBlockBaseFeePerGas', + params: [initialBaseFee.toString()], + }); + await cheatCodes.evmMine(); + + gasUtils = new L1TxUtils(publicClient, walletClient, logger, { + gasLimitBufferPercentage: 20n, + maxGwei: 500n, + minGwei: 1n, + maxAttempts: 3, + checkIntervalMs: 100, + stallTimeMs: 1000, + }); + }); + + afterEach(async () => { + // Reset base fee + await cheatCodes.setNextBlockBaseFeePerGas(initialBaseFee); + await cheatCodes.evmMine(); + }); + afterAll(async () => { + // disabling interval mining as it seems to cause issues with stopping anvil + await cheatCodes.setIntervalMining(0); // Disable interval mining + await anvil.stop(); + }, 5_000); + + it('sends and monitors a simple transaction', async () => { + const receipt = await gasUtils.sendAndMonitorTransaction({ + to: '0x1234567890123456789012345678901234567890', + data: '0x', + value: 0n, + }); + + expect(receipt.status).toBe('success'); + }, 10_000); + + it('handles gas price spikes by retrying with higher gas price', async () => { + // Disable all forms of mining + await cheatCodes.setAutomine(false); + await cheatCodes.setIntervalMining(0); + + // Ensure initial base fee is low + await cheatCodes.setNextBlockBaseFeePerGas(initialBaseFee); + + const request = { + to: '0x1234567890123456789012345678901234567890' as `0x${string}`, + data: '0x' as `0x${string}`, + value: 0n, + }; + + const estimatedGas = await publicClient.estimateGas(request); + + const originalMaxFeePerGas = WEI_CONST * 10n; + const originalMaxPriorityFeePerGas = WEI_CONST; + + const txHash = await walletClient.sendTransaction({ + ...request, + gas: estimatedGas, + maxFeePerGas: originalMaxFeePerGas, + maxPriorityFeePerGas: originalMaxPriorityFeePerGas, + }); + + const rawTx = await cheatCodes.getRawTransaction(txHash); + + // Temporarily drop the transaction + await cheatCodes.dropTransaction(txHash); + + // Mine a block with higher base fee + await cheatCodes.setNextBlockBaseFeePerGas((WEI_CONST * 15n) / 10n); + await cheatCodes.evmMine(); + + // Re-add the original tx + await publicClient.transport.request({ + method: 'eth_sendRawTransaction', + params: [rawTx], + }); + + // keeping auto-mining disabled to simulate a stuck transaction + // The monitor should detect the stall and create a replacement tx + + // Monitor should detect stall and replace with higher gas price + const monitorFn = gasUtils.monitorTransaction(request, txHash, { gasLimit: estimatedGas }); + + await sleep(2000); + // re-enable mining + await cheatCodes.setIntervalMining(1); + const receipt = await monitorFn; + expect(receipt.status).toBe('success'); + // Verify that a replacement transaction was created + expect(receipt.transactionHash).not.toBe(txHash); + + // Get details of replacement tx to verify higher gas price + const replacementTx = await publicClient.getTransaction({ hash: receipt.transactionHash }); + + expect(replacementTx.maxFeePerGas!).toBeGreaterThan(originalMaxFeePerGas); + expect(replacementTx.maxPriorityFeePerGas!).toBeGreaterThan(originalMaxPriorityFeePerGas); + }, 20_000); + + it('respects max gas price limits during spikes', async () => { + const maxGwei = 500n; + const newBaseFee = (maxGwei - 10n) * WEI_CONST; + + // Set base fee high but still under our max + await cheatCodes.setNextBlockBaseFeePerGas(newBaseFee); + + // Mine a new block to make the base fee change take effect + await cheatCodes.evmMine(); + + const receipt = await gasUtils.sendAndMonitorTransaction({ + to: '0x1234567890123456789012345678901234567890', + data: '0x', + value: 0n, + }); + + expect(receipt.effectiveGasPrice).toBeLessThanOrEqual(maxGwei * WEI_CONST); + }, 60_000); + + it('adds appropriate buffer to gas estimation', async () => { + const stableBaseFee = WEI_CONST * 10n; + await cheatCodes.setNextBlockBaseFeePerGas(stableBaseFee); + await cheatCodes.evmMine(); + + // First deploy without any buffer + const baselineGasUtils = new L1TxUtils(publicClient, walletClient, logger, { + gasLimitBufferPercentage: 0n, + maxGwei: 500n, + minGwei: 10n, // Increased minimum gas price + maxAttempts: 5, + checkIntervalMs: 100, + stallTimeMs: 1000, + }); + + const baselineTx = await baselineGasUtils.sendAndMonitorTransaction({ + to: EthAddress.ZERO.toString(), + data: SIMPLE_CONTRACT_BYTECODE, + }); + + // Get the transaction details to see the gas limit + const baselineDetails = await publicClient.getTransaction({ + hash: baselineTx.transactionHash, + }); + + // Now deploy with 20% buffer + const bufferedGasUtils = new L1TxUtils(publicClient, walletClient, logger, { + gasLimitBufferPercentage: 20n, + maxGwei: 500n, + minGwei: 1n, + maxAttempts: 3, + checkIntervalMs: 100, + stallTimeMs: 1000, + }); + + const bufferedTx = await bufferedGasUtils.sendAndMonitorTransaction({ + to: EthAddress.ZERO.toString(), + data: SIMPLE_CONTRACT_BYTECODE, + }); + + const bufferedDetails = await publicClient.getTransaction({ + hash: bufferedTx.transactionHash, + }); + + // The gas limit should be ~20% higher + expect(bufferedDetails.gas).toBeGreaterThan(baselineDetails.gas); + expect(bufferedDetails.gas).toBeLessThanOrEqual((baselineDetails.gas * 120n) / 100n); + }, 20_000); + + it('calculates correct gas prices for initial attempt', async () => { + // Set base fee to 1 gwei + await cheatCodes.setNextBlockBaseFeePerGas(WEI_CONST); + await cheatCodes.evmMine(); + + const basePriorityFee = await publicClient.estimateMaxPriorityFeePerGas(); + const gasPrice = await gasUtils['getGasPrice'](); + + // With default config, priority fee should be bumped by 20% + const expectedPriorityFee = (basePriorityFee * 120n) / 100n; + + // Base fee should be bumped for potential stalls (1.125^(stallTimeMs/12000) = ~1.125 for default config) + const expectedMaxFee = (WEI_CONST * 1125n) / 1000n + expectedPriorityFee; + + expect(gasPrice.maxPriorityFeePerGas).toBe(expectedPriorityFee); + expect(gasPrice.maxFeePerGas).toBe(expectedMaxFee); + }); + + it('calculates correct gas prices for retry attempts', async () => { + await cheatCodes.setNextBlockBaseFeePerGas(WEI_CONST); + await cheatCodes.evmMine(); + + const initialGasPrice = await gasUtils['getGasPrice'](); + + // Get retry gas price for 2nd attempt + const retryGasPrice = await gasUtils['getGasPrice'](undefined, 1, initialGasPrice); + + // With default config, retry should bump fees by 50% + const expectedPriorityFee = (initialGasPrice.maxPriorityFeePerGas * 150n) / 100n; + const expectedMaxFee = (initialGasPrice.maxFeePerGas * 150n) / 100n; + + expect(retryGasPrice.maxPriorityFeePerGas).toBe(expectedPriorityFee); + expect(retryGasPrice.maxFeePerGas).toBe(expectedMaxFee); + }); + + it('respects minimum gas price bump for replacements', async () => { + const gasUtils = new L1TxUtils(publicClient, walletClient, logger, { + ...defaultL1TxUtilsConfig, + priorityFeeRetryBumpPercentage: 5n, // Set lower than minimum 10% + }); + + const initialGasPrice = await gasUtils['getGasPrice'](); + + // Get retry gas price with attempt = 1 + const retryGasPrice = await gasUtils['getGasPrice'](undefined, 1, initialGasPrice); + + // Should use 10% minimum bump even though config specified 5% + const expectedPriorityFee = (initialGasPrice.maxPriorityFeePerGas * 110n) / 100n; + const expectedMaxFee = (initialGasPrice.maxFeePerGas * 110n) / 100n; + + expect(retryGasPrice.maxPriorityFeePerGas).toBe(expectedPriorityFee); + expect(retryGasPrice.maxFeePerGas).toBe(expectedMaxFee); + }); + + it('adds correct buffer to gas estimation', async () => { + const request = { + to: '0x1234567890123456789012345678901234567890' as `0x${string}`, + data: '0x' as `0x${string}`, + value: 0n, + }; + + const baseEstimate = await publicClient.estimateGas(request); + const bufferedEstimate = await gasUtils.estimateGas(walletClient.account!, request); + + // adds 20% buffer + const expectedEstimate = baseEstimate + (baseEstimate * 20n) / 100n; + expect(bufferedEstimate).toBe(expectedEstimate); + }); +}); diff --git a/yarn-project/ethereum/src/l1_tx_utils.ts b/yarn-project/ethereum/src/l1_tx_utils.ts new file mode 100644 index 00000000000..f95610303b7 --- /dev/null +++ b/yarn-project/ethereum/src/l1_tx_utils.ts @@ -0,0 +1,400 @@ +import { + type ConfigMappingsType, + bigintConfigHelper, + getDefaultConfig, + numberConfigHelper, +} from '@aztec/foundation/config'; +import { type DebugLogger } from '@aztec/foundation/log'; +import { makeBackoff, retry } from '@aztec/foundation/retry'; +import { sleep } from '@aztec/foundation/sleep'; + +import { + type Account, + type Address, + type Chain, + type GetTransactionReturnType, + type Hex, + type HttpTransport, + type PublicClient, + type TransactionReceipt, + type WalletClient, + formatGwei, +} from 'viem'; + +// 1_000_000_000 Gwei = 1 ETH +// 1_000_000_000 Wei = 1 Gwei +// 1_000_000_000_000_000_000 Wei = 1 ETH + +const WEI_CONST = 1_000_000_000n; + +// setting a minimum bump percentage to 10% due to geth's implementation +// https://github.com/ethereum/go-ethereum/blob/e3d61e6db028c412f74bc4d4c7e117a9e29d0de0/core/txpool/legacypool/list.go#L298 +const MIN_REPLACEMENT_BUMP_PERCENTAGE = 10n; + +// Avg ethereum block time is ~12s +const BLOCK_TIME_MS = 12_000; + +export interface L1TxUtilsConfig { + /** + * How much to increase calculated gas limit. + */ + gasLimitBufferPercentage?: bigint; + /** + * Maximum gas price in gwei + */ + maxGwei?: bigint; + /** + * Minimum gas price in gwei + */ + minGwei?: bigint; + /** + * Priority fee bump percentage + */ + priorityFeeBumpPercentage?: bigint; + /** + * How much to increase priority fee by each attempt (percentage) + */ + priorityFeeRetryBumpPercentage?: bigint; + /** + * Maximum number of speed-up attempts + */ + maxAttempts?: number; + /** + * How often to check tx status + */ + checkIntervalMs?: number; + /** + * How long before considering tx stalled + */ + stallTimeMs?: number; + /** + * How long to wait for a tx to be mined before giving up + */ + txTimeoutMs?: number; +} + +export const l1TxUtilsConfigMappings: ConfigMappingsType = { + gasLimitBufferPercentage: { + description: 'How much to increase gas price by each attempt (percentage)', + env: 'L1_GAS_LIMIT_BUFFER_PERCENTAGE', + ...bigintConfigHelper(20n), + }, + minGwei: { + description: 'Minimum gas price in gwei', + env: 'L1_GAS_PRICE_MIN', + ...bigintConfigHelper(1n), + }, + maxGwei: { + description: 'Maximum gas price in gwei', + env: 'L1_GAS_PRICE_MAX', + ...bigintConfigHelper(100n), + }, + priorityFeeBumpPercentage: { + description: 'How much to increase priority fee by each attempt (percentage)', + env: 'L1_PRIORITY_FEE_BUMP_PERCENTAGE', + ...bigintConfigHelper(20n), + }, + priorityFeeRetryBumpPercentage: { + description: 'How much to increase priority fee by each retry attempt (percentage)', + env: 'L1_PRIORITY_FEE_RETRY_BUMP_PERCENTAGE', + ...bigintConfigHelper(50n), + }, + maxAttempts: { + description: 'Maximum number of speed-up attempts', + env: 'L1_TX_MONITOR_MAX_ATTEMPTS', + ...numberConfigHelper(3), + }, + checkIntervalMs: { + description: 'How often to check tx status', + env: 'L1_TX_MONITOR_CHECK_INTERVAL_MS', + ...numberConfigHelper(10_000), + }, + stallTimeMs: { + description: 'How long before considering tx stalled', + env: 'L1_TX_MONITOR_STALL_TIME_MS', + ...numberConfigHelper(30_000), + }, + txTimeoutMs: { + description: 'How long to wait for a tx to be mined before giving up. Set to 0 to disable.', + env: 'L1_TX_MONITOR_TX_TIMEOUT_MS', + ...numberConfigHelper(300_000), // 5 mins + }, +}; + +export const defaultL1TxUtilsConfig = getDefaultConfig(l1TxUtilsConfigMappings); + +export interface L1TxRequest { + to: Address | null; + data: Hex; + value?: bigint; +} + +interface GasPrice { + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; +} + +export class L1TxUtils { + private readonly config: L1TxUtilsConfig; + + constructor( + private readonly publicClient: PublicClient, + private readonly walletClient: WalletClient, + private readonly logger?: DebugLogger, + config?: Partial, + ) { + this.config = { + ...defaultL1TxUtilsConfig, + ...(config || {}), + }; + } + + /** + * Sends a transaction with gas estimation and pricing + * @param request - The transaction request (to, data, value) + * @param gasConfig - Optional gas configuration + * @returns The transaction hash and parameters used + */ + public async sendTransaction( + request: L1TxRequest, + _gasConfig?: Partial & { fixedGas?: bigint }, + ): Promise<{ txHash: Hex; gasLimit: bigint; gasPrice: GasPrice }> { + const gasConfig = { ...this.config, ..._gasConfig }; + const account = this.walletClient.account; + let gasLimit: bigint; + + if (gasConfig.fixedGas) { + gasLimit = gasConfig.fixedGas; + } else { + gasLimit = await this.estimateGas(account, request); + } + + const gasPrice = await this.getGasPrice(gasConfig); + + const txHash = await this.walletClient.sendTransaction({ + ...request, + gas: gasLimit, + maxFeePerGas: gasPrice.maxFeePerGas, + maxPriorityFeePerGas: gasPrice.maxPriorityFeePerGas, + }); + + this.logger?.verbose( + `Sent L1 transaction ${txHash} with gas limit ${gasLimit} and price ${formatGwei(gasPrice.maxFeePerGas)} gwei`, + ); + + return { txHash, gasLimit, gasPrice }; + } + + /** + * Monitors a transaction until completion, handling speed-ups if needed + * @param request - Original transaction request (needed for speed-ups) + * @param initialTxHash - Hash of the initial transaction + * @param params - Parameters used in the initial transaction + * @param gasConfig - Optional gas configuration + */ + public async monitorTransaction( + request: L1TxRequest, + initialTxHash: Hex, + params: { gasLimit: bigint }, + _gasConfig?: Partial, + ): Promise { + const gasConfig = { ...this.config, ..._gasConfig }; + const account = this.walletClient.account; + + // Retry a few times, in case the tx is not yet propagated. + const tx = await retry( + () => this.publicClient.getTransaction({ hash: initialTxHash }), + `Getting L1 transaction ${initialTxHash}`, + makeBackoff([1, 2, 3]), + this.logger, + true, + ); + + if (tx?.nonce === undefined || tx?.nonce === null) { + throw new Error(`Failed to get L1 transaction ${initialTxHash} nonce`); + } + const nonce = tx.nonce; + + const txHashes = new Set([initialTxHash]); + let currentTxHash = initialTxHash; + let attempts = 0; + let lastAttemptSent = Date.now(); + const initialTxTime = lastAttemptSent; + let txTimedOut = false; + + while (!txTimedOut) { + try { + const currentNonce = await this.publicClient.getTransactionCount({ address: account.address }); + if (currentNonce > nonce) { + for (const hash of txHashes) { + try { + const receipt = await this.publicClient.getTransactionReceipt({ hash }); + if (receipt) { + this.logger?.debug(`L1 Transaction ${hash} confirmed`); + if (receipt.status === 'reverted') { + this.logger?.error(`L1 Transaction ${hash} reverted`); + } + return receipt; + } + } catch (err) { + if (err instanceof Error && err.message.includes('reverted')) { + throw err; + } + } + } + } + + // Retry a few times, in case the tx is not yet propagated. + const tx = await retry( + () => this.publicClient.getTransaction({ hash: currentTxHash }), + `Getting L1 transaction ${currentTxHash}`, + makeBackoff([1, 2, 3]), + this.logger, + true, + ); + const timePassed = Date.now() - lastAttemptSent; + + if (tx && timePassed < gasConfig.stallTimeMs!) { + this.logger?.debug(`L1 Transaction ${currentTxHash} pending. Time passed: ${timePassed}ms`); + + // Check timeout before continuing + if (gasConfig.txTimeoutMs) { + txTimedOut = Date.now() - initialTxTime > gasConfig.txTimeoutMs; + if (txTimedOut) { + break; + } + } + + await sleep(gasConfig.checkIntervalMs!); + continue; + } + + if (timePassed > gasConfig.stallTimeMs! && attempts < gasConfig.maxAttempts!) { + attempts++; + const newGasPrice = await this.getGasPrice( + gasConfig, + attempts, + tx.maxFeePerGas && tx.maxPriorityFeePerGas + ? { maxFeePerGas: tx.maxFeePerGas, maxPriorityFeePerGas: tx.maxPriorityFeePerGas } + : undefined, + ); + + this.logger?.debug( + `L1 Transaction ${currentTxHash} appears stuck. Attempting speed-up ${attempts}/${gasConfig.maxAttempts} ` + + `with new priority fee ${formatGwei(newGasPrice.maxPriorityFeePerGas)} gwei`, + ); + + currentTxHash = await this.walletClient.sendTransaction({ + ...request, + nonce, + gas: params.gasLimit, + maxFeePerGas: newGasPrice.maxFeePerGas, + maxPriorityFeePerGas: newGasPrice.maxPriorityFeePerGas, + }); + + txHashes.add(currentTxHash); + lastAttemptSent = Date.now(); + } + await sleep(gasConfig.checkIntervalMs!); + } catch (err: any) { + this.logger?.warn(`Error monitoring tx ${currentTxHash}:`, err); + if (err.message?.includes('reverted')) { + throw err; + } + await sleep(gasConfig.checkIntervalMs!); + } + // Check if tx has timed out. + if (gasConfig.txTimeoutMs) { + txTimedOut = Date.now() - initialTxTime > gasConfig.txTimeoutMs!; + } + } + throw new Error(`L1 Transaction ${currentTxHash} timed out`); + } + + /** + * Sends a transaction and monitors it until completion + * @param request - The transaction request (to, data, value) + * @param gasConfig - Optional gas configuration + * @returns The receipt of the successful transaction + */ + public async sendAndMonitorTransaction( + request: L1TxRequest, + gasConfig?: Partial & { fixedGas?: bigint }, + ): Promise { + const { txHash, gasLimit } = await this.sendTransaction(request, gasConfig); + return this.monitorTransaction(request, txHash, { gasLimit }, gasConfig); + } + + /** + * Gets the current gas price with bounds checking + */ + private async getGasPrice( + _gasConfig?: L1TxUtilsConfig, + attempt: number = 0, + previousGasPrice?: typeof attempt extends 0 ? never : GasPrice, + ): Promise { + const gasConfig = { ...this.config, ..._gasConfig }; + const block = await this.publicClient.getBlock({ blockTag: 'latest' }); + const baseFee = block.baseFeePerGas ?? 0n; + + // Get initial priority fee from the network + let priorityFee = await this.publicClient.estimateMaxPriorityFeePerGas(); + let maxFeePerGas = baseFee; + + // Bump base fee so it's valid for next blocks if it stalls + const numBlocks = Math.ceil(gasConfig.stallTimeMs! / BLOCK_TIME_MS); + for (let i = 0; i < numBlocks; i++) { + // each block can go up 12.5% from previous baseFee + maxFeePerGas = (maxFeePerGas * (1_000n + 125n)) / 1_000n; + } + + if (attempt > 0) { + const configBump = + gasConfig.priorityFeeRetryBumpPercentage ?? defaultL1TxUtilsConfig.priorityFeeRetryBumpPercentage!; + const bumpPercentage = + configBump > MIN_REPLACEMENT_BUMP_PERCENTAGE ? configBump : MIN_REPLACEMENT_BUMP_PERCENTAGE; + + // Calculate minimum required fees based on previous attempt + const minPriorityFee = (previousGasPrice!.maxPriorityFeePerGas * (100n + bumpPercentage)) / 100n; + const minMaxFee = (previousGasPrice!.maxFeePerGas * (100n + bumpPercentage)) / 100n; + + // Add priority fee to maxFeePerGas + maxFeePerGas += priorityFee; + + // Use maximum between current network values and minimum required values + priorityFee = priorityFee > minPriorityFee ? priorityFee : minPriorityFee; + maxFeePerGas = maxFeePerGas > minMaxFee ? maxFeePerGas : minMaxFee; + } else { + // first attempt, just bump priority fee + priorityFee = (priorityFee * (100n + (gasConfig.priorityFeeBumpPercentage || 0n))) / 100n; + maxFeePerGas += priorityFee; + } + + // Ensure we don't exceed maxGwei + const maxGweiInWei = gasConfig.maxGwei! * WEI_CONST; + maxFeePerGas = maxFeePerGas > maxGweiInWei ? maxGweiInWei : maxFeePerGas; + + // Ensure priority fee doesn't exceed max fee + const maxPriorityFeePerGas = priorityFee > maxFeePerGas ? maxFeePerGas : priorityFee; + + this.logger?.debug( + `Gas price calculation (attempt ${attempt}): baseFee=${formatGwei(baseFee)}, ` + + `maxPriorityFee=${formatGwei(maxPriorityFeePerGas)}, maxFee=${formatGwei(maxFeePerGas)}`, + ); + + return { maxFeePerGas, maxPriorityFeePerGas }; + } + + /** + * Estimates gas and adds buffer + */ + public async estimateGas(account: Account, request: L1TxRequest, _gasConfig?: L1TxUtilsConfig): Promise { + const gasConfig = { ...this.config, ..._gasConfig }; + const initialEstimate = await this.publicClient.estimateGas({ account, ...request }); + + // Add buffer based on either fixed amount or percentage + const withBuffer = initialEstimate + (initialEstimate * (gasConfig.gasLimitBufferPercentage ?? 0n)) / 100n; + + return withBuffer; + } +} diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index 44ffca4981f..41a41143c91 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -170,4 +170,14 @@ export type EnvVar = | 'AZTEC_SLOT_DURATION' | 'AZTEC_EPOCH_DURATION' | 'AZTEC_TARGET_COMMITTEE_SIZE' - | 'AZTEC_EPOCH_PROOF_CLAIM_WINDOW_IN_L2_SLOTS'; + | 'AZTEC_EPOCH_PROOF_CLAIM_WINDOW_IN_L2_SLOTS' + | 'L1_GAS_LIMIT_BUFFER_PERCENTAGE' + | 'L1_GAS_LIMIT_BUFFER_FIXED' + | 'L1_GAS_PRICE_MIN' + | 'L1_GAS_PRICE_MAX' + | 'L1_PRIORITY_FEE_BUMP_PERCENTAGE' + | 'L1_PRIORITY_FEE_RETRY_BUMP_PERCENTAGE' + | 'L1_TX_MONITOR_MAX_ATTEMPTS' + | 'L1_TX_MONITOR_CHECK_INTERVAL_MS' + | 'L1_TX_MONITOR_STALL_TIME_MS' + | 'L1_TX_MONITOR_TX_TIMEOUT_MS'; diff --git a/yarn-project/sequencer-client/src/publisher/config.ts b/yarn-project/sequencer-client/src/publisher/config.ts index 561add17597..367f2aa6677 100644 --- a/yarn-project/sequencer-client/src/publisher/config.ts +++ b/yarn-project/sequencer-client/src/publisher/config.ts @@ -1,4 +1,4 @@ -import { type L1ReaderConfig, NULL_KEY } from '@aztec/ethereum'; +import { type L1ReaderConfig, type L1TxUtilsConfig, NULL_KEY, l1TxUtilsConfigMappings } from '@aztec/ethereum'; import { type ConfigMappingsType, getConfigFromMappings, numberConfigHelper } from '@aztec/foundation/config'; /** @@ -19,12 +19,12 @@ export type TxSenderConfig = L1ReaderConfig & { /** * Configuration of the L1Publisher. */ -export interface PublisherConfig { +export type PublisherConfig = L1TxUtilsConfig & { /** * The interval to wait between publish retries. */ l1PublishRetryIntervalMS: number; -} +}; export const getTxSenderConfigMappings: ( scope: 'PROVER' | 'SEQ', @@ -62,13 +62,16 @@ export function getTxSenderConfigFromEnv(scope: 'PROVER' | 'SEQ'): Omit ConfigMappingsType = scope => ({ +export const getPublisherConfigMappings: ( + scope: 'PROVER' | 'SEQ', +) => ConfigMappingsType = scope => ({ l1PublishRetryIntervalMS: { env: `${scope}_PUBLISH_RETRY_INTERVAL_MS`, parseEnv: (val: string) => +val, defaultValue: 1000, description: 'The interval to wait between publish retries.', }, + ...l1TxUtilsConfigMappings, }); export function getPublisherConfigFromEnv(scope: 'PROVER' | 'SEQ'): PublisherConfig { diff --git a/yarn-project/sequencer-client/src/publisher/l1-publisher.test.ts b/yarn-project/sequencer-client/src/publisher/l1-publisher.test.ts index d1916020719..cedbfbe0d7d 100644 --- a/yarn-project/sequencer-client/src/publisher/l1-publisher.test.ts +++ b/yarn-project/sequencer-client/src/publisher/l1-publisher.test.ts @@ -1,17 +1,30 @@ import { L2Block } from '@aztec/circuit-types'; import { EthAddress } from '@aztec/circuits.js'; -import { type L1ContractsConfig, getL1ContractsConfigEnvVars } from '@aztec/ethereum'; +import { + type L1ContractsConfig, + type L1TxRequest, + type L1TxUtilsConfig, + defaultL1TxUtilsConfig, + getL1ContractsConfigEnvVars, +} from '@aztec/ethereum'; import { type ViemSignature } from '@aztec/foundation/eth-signature'; import { sleep } from '@aztec/foundation/sleep'; import { RollupAbi } from '@aztec/l1-artifacts'; import { NoopTelemetryClient } from '@aztec/telemetry-client/noop'; import { type MockProxy, mock } from 'jest-mock-extended'; -import { type GetTransactionReceiptReturnType, type PrivateKeyAccount } from 'viem'; +import { + type GetTransactionReceiptReturnType, + type PrivateKeyAccount, + type TransactionReceipt, + encodeFunctionData, +} from 'viem'; import { type PublisherConfig, type TxSenderConfig } from './config.js'; import { L1Publisher } from './l1-publisher.js'; +const mockRollupAddress = '0xcafe'; + interface MockPublicClient { getTransactionReceipt: ({ hash }: { hash: '0x${string}' }) => Promise; getBlock(): Promise<{ timestamp: bigint }>; @@ -19,6 +32,13 @@ interface MockPublicClient { estimateGas: ({ to, data }: { to: '0x${string}'; data: '0x${string}' }) => Promise; } +interface MockL1TxUtils { + sendAndMonitorTransaction: ( + request: L1TxRequest, + _gasConfig?: Partial, + ) => Promise; +} + interface MockRollupContractWrite { propose: ( args: readonly [`0x${string}`, `0x${string}`] | readonly [`0x${string}`, `0x${string}`, `0x${string}`], @@ -42,6 +62,9 @@ interface MockRollupContractRead { class MockRollupContract { constructor(public write: MockRollupContractWrite, public read: MockRollupContractRead, public abi = RollupAbi) {} + get address() { + return mockRollupAddress; + } } describe('L1Publisher', () => { @@ -50,6 +73,7 @@ describe('L1Publisher', () => { let rollupContract: MockRollupContract; let publicClient: MockProxy; + let l1TxUtils: MockProxy; let proposeTxHash: `0x${string}`; let proposeTxReceipt: GetTransactionReceiptReturnType; @@ -60,8 +84,6 @@ describe('L1Publisher', () => { let blockHash: Buffer; let body: Buffer; - let account: PrivateKeyAccount; - let publisher: L1Publisher; const GAS_GUESS = 300_000n; @@ -87,7 +109,7 @@ describe('L1Publisher', () => { rollupContract = new MockRollupContract(rollupContractWrite, rollupContractRead); publicClient = mock(); - + l1TxUtils = mock(); const config = { l1RpcUrl: `http://127.0.0.1:8545`, l1ChainId: 1, @@ -95,26 +117,30 @@ describe('L1Publisher', () => { l1Contracts: { rollupAddress: EthAddress.ZERO.toString() }, l1PublishRetryIntervalMS: 1, ethereumSlotDuration: getL1ContractsConfigEnvVars().ethereumSlotDuration, - } as unknown as TxSenderConfig & PublisherConfig & Pick; + ...defaultL1TxUtilsConfig, + } as unknown as TxSenderConfig & + PublisherConfig & + Pick & + L1TxUtilsConfig; publisher = new L1Publisher(config, new NoopTelemetryClient()); (publisher as any)['rollupContract'] = rollupContract; (publisher as any)['publicClient'] = publicClient; - - account = (publisher as any)['account']; + (publisher as any)['l1TxUtils'] = l1TxUtils; + publisher as any; rollupContractRead.getCurrentSlot.mockResolvedValue(l2Block.header.globalVariables.slotNumber.toBigInt()); publicClient.getBlock.mockResolvedValue({ timestamp: 12n }); publicClient.estimateGas.mockResolvedValue(GAS_GUESS); + l1TxUtils.sendAndMonitorTransaction.mockResolvedValue(proposeTxReceipt); + (l1TxUtils as any).estimateGas.mockResolvedValue(GAS_GUESS); }); it('publishes and propose l2 block to l1', async () => { rollupContractRead.archive.mockResolvedValue(l2Block.header.lastArchive.root.toString() as `0x${string}`); rollupContractWrite.propose.mockResolvedValueOnce(proposeTxHash); - publicClient.getTransactionReceipt.mockResolvedValueOnce(proposeTxReceipt); - const result = await publisher.proposeL2Block(l2Block); expect(result).toEqual(true); @@ -133,21 +159,22 @@ describe('L1Publisher', () => { [], `0x${body.toString('hex')}`, ] as const; - expect(rollupContractWrite.propose).toHaveBeenCalledWith(args, { - account: account, - gas: L1Publisher.PROPOSE_GAS_GUESS + GAS_GUESS, - }); - expect(publicClient.getTransactionReceipt).toHaveBeenCalledWith({ hash: proposeTxHash }); + expect(l1TxUtils.sendAndMonitorTransaction).toHaveBeenCalledWith( + { + to: mockRollupAddress, + data: encodeFunctionData({ abi: rollupContract.abi, functionName: 'propose', args }), + }, + { fixedGas: GAS_GUESS + L1Publisher.PROPOSE_GAS_GUESS }, + ); }); it('does not retry if sending a propose tx fails', async () => { rollupContractRead.archive.mockResolvedValue(l2Block.header.lastArchive.root.toString() as `0x${string}`); - rollupContractWrite.propose.mockRejectedValueOnce(new Error()).mockResolvedValueOnce(proposeTxHash); + l1TxUtils.sendAndMonitorTransaction.mockRejectedValueOnce(new Error()).mockResolvedValueOnce(proposeTxReceipt); const result = await publisher.proposeL2Block(l2Block); expect(result).toEqual(false); - expect(rollupContractWrite.propose).toHaveBeenCalledTimes(1); }); it('does not retry if simulating a publish and propose tx fails', async () => { @@ -157,45 +184,20 @@ describe('L1Publisher', () => { await expect(publisher.proposeL2Block(l2Block)).rejects.toThrow(); expect(rollupContractRead.validateHeader).toHaveBeenCalledTimes(1); - expect(rollupContractWrite.propose).toHaveBeenCalledTimes(0); }); it('does not retry if sending a publish and propose tx fails', async () => { rollupContractRead.archive.mockResolvedValue(l2Block.header.lastArchive.root.toString() as `0x${string}`); - rollupContractWrite.propose.mockRejectedValueOnce(new Error()); + l1TxUtils.sendAndMonitorTransaction.mockRejectedValueOnce(new Error()).mockResolvedValueOnce(proposeTxReceipt); const result = await publisher.proposeL2Block(l2Block); expect(result).toEqual(false); - expect(rollupContractWrite.propose).toHaveBeenCalledTimes(1); - }); - - it('retries if fetching the receipt fails (propose)', async () => { - rollupContractRead.archive.mockResolvedValue(l2Block.header.lastArchive.root.toString() as `0x${string}`); - rollupContractWrite.propose.mockResolvedValueOnce(proposeTxHash); - publicClient.getTransactionReceipt.mockRejectedValueOnce(new Error()).mockResolvedValueOnce(proposeTxReceipt); - - const result = await publisher.proposeL2Block(l2Block); - - expect(result).toEqual(true); - expect(publicClient.getTransactionReceipt).toHaveBeenCalledTimes(2); - }); - - it('retries if fetching the receipt fails (publish propose)', async () => { - rollupContractRead.archive.mockResolvedValue(l2Block.header.lastArchive.root.toString() as `0x${string}`); - rollupContractWrite.propose.mockResolvedValueOnce(proposeTxHash as `0x${string}`); - publicClient.getTransactionReceipt.mockRejectedValueOnce(new Error()).mockResolvedValueOnce(proposeTxReceipt); - - const result = await publisher.proposeL2Block(l2Block); - - expect(result).toEqual(true); - expect(publicClient.getTransactionReceipt).toHaveBeenCalledTimes(2); }); it('returns false if publish and propose tx reverts', async () => { rollupContractRead.archive.mockResolvedValue(l2Block.header.lastArchive.root.toString() as `0x${string}`); - rollupContractWrite.propose.mockResolvedValueOnce(proposeTxHash); - publicClient.getTransactionReceipt.mockResolvedValueOnce({ ...proposeTxReceipt, status: 'reverted' }); + l1TxUtils.sendAndMonitorTransaction.mockResolvedValueOnce({ ...proposeTxReceipt, status: 'reverted' }); const result = await publisher.proposeL2Block(l2Block); @@ -205,7 +207,7 @@ describe('L1Publisher', () => { it('returns false if propose tx reverts', async () => { rollupContractRead.archive.mockResolvedValue(l2Block.header.lastArchive.root.toString() as `0x${string}`); - publicClient.getTransactionReceipt.mockResolvedValueOnce({ ...proposeTxReceipt, status: 'reverted' }); + l1TxUtils.sendAndMonitorTransaction.mockResolvedValueOnce({ ...proposeTxReceipt, status: 'reverted' }); const result = await publisher.proposeL2Block(l2Block); @@ -214,8 +216,9 @@ describe('L1Publisher', () => { it('returns false if sending publish and progress tx is interrupted', async () => { rollupContractRead.archive.mockResolvedValue(l2Block.header.lastArchive.root.toString() as `0x${string}`); - rollupContractWrite.propose.mockImplementationOnce(() => sleep(10, proposeTxHash) as Promise<`0x${string}`>); - + l1TxUtils.sendAndMonitorTransaction.mockImplementationOnce( + () => sleep(10, proposeTxReceipt) as Promise, + ); const resultPromise = publisher.proposeL2Block(l2Block); publisher.interrupt(); const result = await resultPromise; @@ -226,7 +229,9 @@ describe('L1Publisher', () => { it('returns false if sending propose tx is interrupted', async () => { rollupContractRead.archive.mockResolvedValue(l2Block.header.lastArchive.root.toString() as `0x${string}`); - rollupContractWrite.propose.mockImplementationOnce(() => sleep(10, proposeTxHash) as Promise<`0x${string}`>); + l1TxUtils.sendAndMonitorTransaction.mockImplementationOnce( + () => sleep(10, proposeTxReceipt) as Promise, + ); const resultPromise = publisher.proposeL2Block(l2Block); publisher.interrupt(); diff --git a/yarn-project/sequencer-client/src/publisher/l1-publisher.ts b/yarn-project/sequencer-client/src/publisher/l1-publisher.ts index b1e0aa5a50c..10e4b61f967 100644 --- a/yarn-project/sequencer-client/src/publisher/l1-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/l1-publisher.ts @@ -17,7 +17,13 @@ import { type Proof, type RootRollupPublicInputs, } from '@aztec/circuits.js'; -import { type EthereumChain, type L1ContractsConfig, createEthereumChain } from '@aztec/ethereum'; +import { + type EthereumChain, + type L1ContractsConfig, + L1TxUtils, + type L1TxUtilsConfig, + createEthereumChain, +} from '@aztec/ethereum'; import { makeTuple } from '@aztec/foundation/array'; import { areArraysEqual, compactArray, times } from '@aztec/foundation/collection'; import { type Signature } from '@aztec/foundation/eth-signature'; @@ -44,6 +50,7 @@ import { type PublicActions, type PublicClient, type PublicRpcSchema, + type TransactionReceipt, type WalletActions, type WalletClient, type WalletRpcSchema, @@ -161,8 +168,10 @@ export class L1Publisher { public static PROPOSE_GAS_GUESS: bigint = 12_000_000n; public static PROPOSE_AND_CLAIM_GAS_GUESS: bigint = this.PROPOSE_GAS_GUESS + 100_000n; + private readonly l1TxUtils: L1TxUtils; + constructor( - config: TxSenderConfig & PublisherConfig & Pick, + config: TxSenderConfig & PublisherConfig & Pick & L1TxUtilsConfig, client: TelemetryClient, ) { this.sleepTimeMs = config?.l1PublishRetryIntervalMS ?? 60_000; @@ -195,6 +204,8 @@ export class L1Publisher { client: this.walletClient, }); } + + this.l1TxUtils = new L1TxUtils(this.publicClient, this.walletClient, this.log, config); } protected createWalletClient( @@ -503,36 +514,30 @@ export class L1Publisher { }); this.log.verbose(`Submitting propose transaction`); - - const tx = proofQuote + const result = proofQuote ? await this.sendProposeAndClaimTx(proposeTxArgs, proofQuote) : await this.sendProposeTx(proposeTxArgs); - if (!tx) { + if (!result?.receipt) { this.log.info(`Failed to publish block ${block.number} to L1`, ctx); return false; } - const { hash: txHash, args, functionName, gasLimit } = tx; - - const receipt = await this.getTransactionReceipt(txHash); - if (!receipt) { - this.log.info(`Failed to get receipt for tx ${txHash}`, ctx); - return false; - } + const { receipt, args, functionName } = result; // Tx was mined successfully - if (receipt.status) { - const tx = await this.getTransactionStats(txHash); + if (receipt.status === 'success') { + const tx = await this.getTransactionStats(receipt.transactionHash); const stats: L1PublishBlockStats = { - ...pick(receipt, 'gasPrice', 'gasUsed', 'transactionHash'), + gasPrice: receipt.effectiveGasPrice, + gasUsed: receipt.gasUsed, + transactionHash: receipt.transactionHash, ...pick(tx!, 'calldataGas', 'calldataSize', 'sender'), ...block.getStats(), eventName: 'rollup-published-to-l1', }; this.log.info(`Published L2 block to L1 rollup contract`, { ...stats, ...ctx }); this.metrics.recordProcessBlockTx(timer.ms(), stats); - return true; } @@ -541,7 +546,6 @@ export class L1Publisher { const errorMsg = await this.tryGetErrorFromRevertedTx({ args, functionName, - gasLimit, abi: RollupAbi, address: this.rollupContract.address, blockNumber: receipt.blockNumber, @@ -557,7 +561,6 @@ export class L1Publisher { private async tryGetErrorFromRevertedTx(args: { args: any[]; functionName: string; - gasLimit: bigint; abi: any; address: Hex; blockNumber: bigint | undefined; @@ -720,17 +723,25 @@ export class L1Publisher { ] as const; this.log.info(`SubmitEpochProof proofSize=${args.proof.withoutPublicInputs().length} bytes`); - await this.rollupContract.simulate.submitEpochRootProof(txArgs, { account: this.account }); - return await this.rollupContract.write.submitEpochRootProof(txArgs, { account: this.account }); + + const txReceipt = await this.l1TxUtils.sendAndMonitorTransaction({ + to: this.rollupContract.address, + data: encodeFunctionData({ + abi: this.rollupContract.abi, + functionName: 'submitEpochRootProof', + args: txArgs, + }), + }); + + return txReceipt.transactionHash; } catch (err) { this.log.error(`Rollup submit epoch proof failed`, err); return undefined; } } - private async prepareProposeTx(encodedData: L1ProcessArgs, gasGuess: bigint) { - // We have to jump a few hoops because viem is not happy around estimating gas for view functions - const computeTxsEffectsHashGas = await this.publicClient.estimateGas({ + private async prepareProposeTx(encodedData: L1ProcessArgs) { + const computeTxsEffectsHashGas = await this.l1TxUtils.estimateGas(this.account, { to: this.rollupContract.address, data: encodeFunctionData({ abi: this.rollupContract.abi, @@ -744,7 +755,7 @@ export class L1Publisher { // we will fail estimation in the case where we are simulating for the // first ethereum block within our slot (as current time is not in the // slot yet). - const gasGuesstimate = computeTxsEffectsHashGas + gasGuess; + const gasGuesstimate = computeTxsEffectsHashGas + L1Publisher.PROPOSE_GAS_GUESS; const attestations = encodedData.attestations ? encodedData.attestations.map(attest => attest.toViemSignature()) @@ -766,7 +777,7 @@ export class L1Publisher { `0x${encodedData.body.toString('hex')}`, ] as const; - return { args, gasGuesstimate }; + return { args, gas: gasGuesstimate }; } private getSubmitEpochProofArgs(args: { @@ -797,25 +808,34 @@ export class L1Publisher { private async sendProposeTx( encodedData: L1ProcessArgs, - ): Promise<{ hash: string; args: any; functionName: string; gasLimit: bigint } | undefined> { + ): Promise<{ receipt: TransactionReceipt; args: any; functionName: string } | undefined> { if (this.interrupted) { return undefined; } try { - const { args, gasGuesstimate } = await this.prepareProposeTx(encodedData, L1Publisher.PROPOSE_GAS_GUESS); - + const { args, gas } = await this.prepareProposeTx(encodedData); + const receipt = await this.l1TxUtils.sendAndMonitorTransaction( + { + to: this.rollupContract.address, + data: encodeFunctionData({ + abi: this.rollupContract.abi, + functionName: 'propose', + args, + }), + }, + { + fixedGas: gas, + }, + ); return { - hash: await this.rollupContract.write.propose(args, { - account: this.account, - gas: gasGuesstimate, - }), + receipt, args, functionName: 'propose', - gasLimit: gasGuesstimate, }; } catch (err) { prettyLogViemError(err, this.log); - this.log.error(`Rollup publish failed`, err); + const errorMessage = err instanceof Error ? err.message : String(err); + this.log.error(`Rollup publish failed`, errorMessage); return undefined; } } @@ -823,30 +843,36 @@ export class L1Publisher { private async sendProposeAndClaimTx( encodedData: L1ProcessArgs, quote: EpochProofQuote, - ): Promise<{ hash: string; args: any; functionName: string; gasLimit: bigint } | undefined> { + ): Promise<{ receipt: TransactionReceipt; args: any; functionName: string } | undefined> { if (this.interrupted) { return undefined; } try { - const { args, gasGuesstimate } = await this.prepareProposeTx( - encodedData, - L1Publisher.PROPOSE_AND_CLAIM_GAS_GUESS, - ); this.log.info(`ProposeAndClaim`); this.log.info(inspect(quote.payload)); + const { args, gas } = await this.prepareProposeTx(encodedData); + const receipt = await this.l1TxUtils.sendAndMonitorTransaction( + { + to: this.rollupContract.address, + data: encodeFunctionData({ + abi: this.rollupContract.abi, + functionName: 'proposeAndClaim', + args: [...args, quote.toViemArgs()], + }), + }, + { fixedGas: gas }, + ); + return { - hash: await this.rollupContract.write.proposeAndClaim([...args, quote.toViemArgs()], { - account: this.account, - gas: gasGuesstimate, - }), - functionName: 'proposeAndClaim', + receipt, args, - gasLimit: gasGuesstimate, + functionName: 'proposeAndClaim', }; } catch (err) { prettyLogViemError(err, this.log); - this.log.error(`Rollup publish failed`, err); + const errorMessage = err instanceof Error ? err.message : String(err); + this.log.error(`Rollup publish failed`, errorMessage); return undefined; } } diff --git a/yarn-project/telemetry-client/src/config.ts b/yarn-project/telemetry-client/src/config.ts index 58c643c5076..dcb5d8a8a0c 100644 --- a/yarn-project/telemetry-client/src/config.ts +++ b/yarn-project/telemetry-client/src/config.ts @@ -14,17 +14,17 @@ export const telemetryClientConfigMappings: ConfigMappingsType new URL(val), + parseEnv: (val: string) => val && new URL(val), }, tracesCollectorUrl: { env: 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', description: 'The URL of the telemetry collector for traces', - parseEnv: (val: string) => new URL(val), + parseEnv: (val: string) => val && new URL(val), }, logsCollectorUrl: { env: 'OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', description: 'The URL of the telemetry collector for logs', - parseEnv: (val: string) => new URL(val), + parseEnv: (val: string) => val && new URL(val), }, serviceName: { env: 'OTEL_SERVICE_NAME',