diff --git a/bolt-client/README.md b/bolt-client/README.md index 04863aaf..e7164eff 100644 --- a/bolt-client/README.md +++ b/bolt-client/README.md @@ -10,17 +10,17 @@ This is a simple CLI tool to interact with Bolt. 1. Prepare the environment variables (either in a `.env` file, or as CLI arguments): - - `RPC_URL` or `--rpc-url`: the URL of the Bolt RPC server (default: Chainbound's RPC) - - `PRIVATE_KEY` or `--private-key`: the private key of the account to send transactions from - - `NONCE_OFFSET` or `--nonce-offset`: the offset to add to the account's nonce (default: 0) - - `BLOB` or `--blob`: bool flag to send a blob-carrying transaction (default: false) + - `BOLT_RPC_URL` or `--rpc-url`: the URL of the Bolt RPC server (default: Chainbound's RPC) + - `BOLT_PRIVATE_KEY` or `--private-key`: the private key of the account to send transactions from + - `BOLT_NONCE_OFFSET` or `--nonce-offset`: the offset to add to the account's nonce (default: 0) + - `--blob`: bool flag to send a blob-carrying transaction (default: false) **Optionally**, you can use the following flags to fetch the lookahead data from the beacon chain directly instead of relying on the RPC server: - `--use-registry`: bool flag to fetch data from a local node instead of the RPC_URL (default: false) -- `--registry-address`: the address of the bolt-registry contract -- `--beacon-client-url`: the URL of the CL node to use +- `BOLT_REGISTRY_ADDRESS` or `--registry-address`: the address of the bolt-registry contract +- `BOLT_BEACON_CLIENT_URL` or `--beacon-client-url`: the URL of the CL node to use 1. Run the CLI tool with the desired command and arguments, if any. diff --git a/bolt-client/src/main.rs b/bolt-client/src/main.rs index fd4419e8..bef27c29 100644 --- a/bolt-client/src/main.rs +++ b/bolt-client/src/main.rs @@ -2,13 +2,14 @@ use alloy::{ eips::eip2718::Encodable2718, hex, network::{EthereumWallet, TransactionBuilder}, - primitives::Address, + primitives::{Address, B256}, providers::{Provider, ProviderBuilder}, signers::local::PrivateKeySigner, }; use beacon_api_client::mainnet::Client as BeaconApiClient; use clap::Parser; use eyre::{bail, Result}; +use serde_json::{json, Value}; use tracing::info; use url::Url; @@ -24,7 +25,7 @@ struct Opts { #[clap( short = 'p', long, - default_value = "http://135.181.191.125:8015/", + default_value = "http://135.181.191.125:8015/rpc", env = "BOLT_RPC_URL" )] rpc_url: Url, @@ -35,11 +36,14 @@ struct Opts { #[clap(short, long, default_value_t = 0, env = "BOLT_NONCE_OFFSET")] nonce_offset: u64, /// Flag for generating a blob tx instead of a regular tx - #[clap(short = 'B', long, default_value_t = false)] + #[clap(long, default_value_t = false)] blob: bool, /// Number of transactions to send in a sequence #[clap(short, long, default_value_t = 1)] count: u64, + /// Flag for sending all "count" transactions in a single bundle + #[clap(long, default_value_t = false)] + bundle: bool, /// Flag for using the registry to fetch the lookahead #[clap(short, long, default_value_t = false, requires_ifs([("true", "registry_address"), ("true", "beacon_client_url")]))] @@ -65,7 +69,7 @@ async fn main() -> Result<()> { info!("starting bolt-client"); let _ = dotenvy::dotenv(); - let opts = Opts::parse(); + let mut opts = Opts::parse(); let wallet: PrivateKeySigner = opts.private_key.parse().expect("invalid private key"); let transaction_signer: EthereumWallet = wallet.clone().into(); @@ -80,38 +84,77 @@ async fn main() -> Result<()> { let duties = get_proposer_duties(&beacon_api_client, curr_slot, curr_slot / 32).await?; match registry.next_preconfer_from_registry(duties).await { Ok(Some((endpoint, slot))) => (Url::parse(&endpoint)?, slot), - Ok(None) => bail!("no next preconfer slot found"), + Ok(None) => bail!("no next preconfer slot found, try again later"), Err(e) => bail!("error fetching next preconfer slot from registry: {:?}", e), } } else { // TODO: remove "cbOnly=true" let url = - opts.rpc_url.join("proposers/lookahead?onlyActive=true&onlyFuture=true&cbOnly=true")?; - let lookahead_response = reqwest::get(url).await?.json::().await?; + opts.rpc_url.join("proposers/lookahead?activeOnly=true&futureOnly=true&cbOnly=true")?; + let lookahead_response = reqwest::get(url).await?.json::().await?; + if lookahead_response.as_array().unwrap_or(&vec![]).is_empty() { + bail!("no bolt proposer found in lookahead, try again later"); + } let next_preconfer_slot = lookahead_response[0].get("slot").unwrap().as_u64().unwrap(); - (opts.rpc_url.join("/rpc")?, next_preconfer_slot) + (opts.rpc_url, next_preconfer_slot) }; - let mut tx = if opts.blob { generate_random_blob_tx() } else { generate_random_tx() }; - tx.set_from(sender); - tx.set_chain_id(provider.get_chain_id().await?); - tx.set_nonce(provider.get_transaction_count(sender).await? + opts.nonce_offset); + let mut txs_rlp = Vec::with_capacity(opts.count as usize); + let mut tx_hashes = Vec::with_capacity(opts.count as usize); + for _ in 0..opts.count { + let mut tx = if opts.blob { generate_random_blob_tx() } else { generate_random_tx() }; + tx.set_from(sender); + tx.set_chain_id(provider.get_chain_id().await?); + tx.set_nonce(provider.get_transaction_count(sender).await? + opts.nonce_offset); + + // Set the nonce offset for the next transaction + opts.nonce_offset += 1; + + let tx_signed = tx.build(&transaction_signer).await?; + let tx_hash = tx_signed.tx_hash(); + let tx_rlp = hex::encode(tx_signed.encoded_2718()); + + if opts.bundle { + // store transactions in a bundle to send them all at once + txs_rlp.push(tx_rlp); + tx_hashes.push(*tx_hash); + } else { + // Send rpc requests singularly for each transaction + send_rpc_request( + vec![tx_rlp.clone()], + vec![*tx_hash], + target_slot, + target_sidecar_url.clone(), + &wallet, + ) + .await?; + } + } - let tx_signed = tx.build(&transaction_signer).await?; - let tx_hash = tx_signed.tx_hash(); - let tx_rlp = hex::encode(tx_signed.encoded_2718()); + if opts.bundle { + send_rpc_request(txs_rlp, tx_hashes, target_slot, target_sidecar_url, &wallet).await?; + } + Ok(()) +} + +async fn send_rpc_request( + txs_rlp: Vec, + tx_hashes: Vec, + target_slot: u64, + target_sidecar_url: Url, + wallet: &PrivateKeySigner, +) -> Result<()> { let request = prepare_rpc_request( "bolt_requestInclusion", - vec![serde_json::json!({ + json!({ "slot": target_slot, - "txs": vec![tx_rlp], - })], + "txs": txs_rlp, + }), ); - info!("Transaction hash: {}", tx_hash); - - let signature = sign_request(vec![tx_hash], target_slot, &wallet).await?; + info!(?tx_hashes, target_slot, %target_sidecar_url); + let signature = sign_request(tx_hashes, target_slot, wallet).await?; let response = reqwest::Client::new() .post(target_sidecar_url) @@ -126,6 +169,5 @@ async fn main() -> Result<()> { // strip out long series of zeros in the response (to avoid spamming blob contents) let response = response.replace(&"0".repeat(32), ".").replace(&".".repeat(4), ""); info!("Response: {:?}", response); - Ok(()) } diff --git a/bolt-client/src/utils.rs b/bolt-client/src/utils.rs index 5a86a9a9..6863b851 100644 --- a/bolt-client/src/utils.rs +++ b/bolt-client/src/utils.rs @@ -39,12 +39,12 @@ pub fn generate_random_blob_tx() -> TransactionRequest { .with_blob_sidecar(sidecar) } -pub fn prepare_rpc_request(method: &str, params: Vec) -> Value { +pub fn prepare_rpc_request(method: &str, params: Value) -> Value { serde_json::json!({ "id": "1", "jsonrpc": "2.0", "method": method, - "params": params, + "params": vec![params], }) } @@ -68,7 +68,7 @@ pub async fn get_proposer_duties( } pub async fn sign_request( - tx_hashes: Vec<&B256>, + tx_hashes: Vec, target_slot: u64, wallet: &PrivateKeySigner, ) -> eyre::Result { @@ -89,7 +89,12 @@ pub async fn sign_request( mod tests { use std::str::FromStr; - use alloy::{primitives::B256, signers::local::PrivateKeySigner}; + use alloy::{ + primitives::{keccak256, Signature, B256}, + signers::local::PrivateKeySigner, + }; + + use crate::sign_request; #[tokio::test] async fn test_sign_request() -> eyre::Result<()> { @@ -98,7 +103,7 @@ mod tests { B256::from_str("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")?; let target_slot = 42; - let signature = super::sign_request(vec![&tx_hash], target_slot, &wallet).await?; + let signature = sign_request(vec![tx_hash], target_slot, &wallet).await?; let parts: Vec<&str> = signature.split(':').collect(); assert_eq!(parts.len(), 2); @@ -106,4 +111,38 @@ mod tests { assert_eq!(parts[1].len(), 130); Ok(()) } + + #[tokio::test] + async fn test_verify_signature() -> eyre::Result<()> { + // Randomly generated private key + let private_key = "0xfa4c3c87627a58684fb519f7b01a31ef31e56f414e8aa56a15f574381a5a7a9c"; + let tx_hash = "0x6938dbd0649ce26af79b0cca677b493257bd87c17d25ff717feba33c8b3920b3"; + let expected_signature = "0x10386a2aF29854954645C9710A038AcF4B2F1752:0x8db9bbcc1db5257c80138bd1df0185305918dbc8a607f63458ea885a6ccce5177a73417d693953b9f5c017a927e9c8acbf24c05b09a55f1f3fa83db57931ed9e1c"; + let target_slot = 254464; + + let wallet = PrivateKeySigner::from_str(private_key)?; + let tx_hash = B256::from_str(tx_hash)?; + + let signature = sign_request(vec![tx_hash], target_slot, &wallet).await?; + + assert_eq!(signature, expected_signature); + + let expected_signer = expected_signature.split(':').next().unwrap(); + let expected_sig = expected_signature.split(':').last().unwrap(); + let sig = Signature::from_str(expected_sig)?; + + // recompute the prehash again + let digest = { + let mut data = Vec::new(); + data.extend_from_slice(tx_hash.as_slice()); + data.extend_from_slice(target_slot.to_le_bytes().as_slice()); + keccak256(data) + }; + + let recovered_address = sig.recover_address_from_prehash(&digest)?; + assert_eq!(recovered_address, wallet.address()); + assert_eq!(recovered_address.to_string(), expected_signer); + + Ok(()) + } }