diff --git a/crates/e2e-tests/src/bitcoin_node.rs b/crates/e2e-tests/src/bitcoin_node.rs index f8c156264..157bc9ca8 100644 --- a/crates/e2e-tests/src/bitcoin_node.rs +++ b/crates/e2e-tests/src/bitcoin_node.rs @@ -14,6 +14,7 @@ use std::path::Path; use std::path::PathBuf; use std::process::Child; use std::process::Command; +use std::sync::Arc; use std::time::Duration; use tracing::info; use tracing::warn; @@ -25,7 +26,7 @@ pub const RPC_USER: &str = "test"; pub const RPC_PASSWORD: &str = "test"; pub struct BitcoinNodeHandle { - rpc_client: Client, + rpc_client: Arc, #[allow(unused)] data_dir: PathBuf, process: Child, @@ -98,10 +99,10 @@ impl BitcoinNodeHandle { } } - let rpc_client = Client::new_with_auth( + let rpc_client = Arc::new(Client::new_with_auth( &rpc_url, Auth::UserPass(RPC_USER.to_string(), RPC_PASSWORD.to_string()), - )?; + )?); Ok(Self { rpc_client, data_dir, @@ -124,10 +125,12 @@ impl BitcoinNodeHandle { self.startup_diagnostics() )); } - match self.rpc_client.get_blockchain_info() { + match crate::btc_rpc_call(&self.rpc_client, |rpc| rpc.get_blockchain_info()).await { Ok(_) => { info!("Bitcoin node is ready"); - match self.rpc_client.create_wallet("test") { + match crate::btc_rpc_call(&self.rpc_client, |rpc| rpc.create_wallet("test")) + .await + { Ok(_) => info!("Created test wallet"), Err(e) => info!("Wallet creation: {}", e), } @@ -169,53 +172,62 @@ impl BitcoinNodeHandle { diagnostics.join("; ") } - pub fn generate_blocks(&self, count: u64) -> Result> { - let blocks = self - .rpc_client - .generate_to_address(count as usize, &self.get_new_address()?)? - .into_model() - .map_err(|e| anyhow!("invalid block hash: {e}"))? - .0; - info!("Generated {} blocks", count); - Ok(blocks) + pub async fn generate_blocks(&self, count: u64) -> Result> { + let address = self.get_new_address().await?; + crate::btc_rpc_call(&self.rpc_client, move |rpc| { + let blocks = rpc + .generate_to_address(count as usize, &address)? + .into_model() + .map_err(|e| anyhow!("invalid block hash: {e}"))? + .0; + info!("Generated {} blocks", count); + Ok(blocks) + }) + .await } - pub fn send_to_address(&self, address: &Address, amount: Amount) -> Result { - let txid = self - .rpc_client - .send_to_address(address, amount)? - .into_model() - .map_err(|e| anyhow!("invalid txid: {e}"))? - .txid; - info!("Sent {} to {}: {}", amount, address, txid); - Ok(txid) + pub async fn send_to_address(&self, address: &Address, amount: Amount) -> Result { + let address = address.clone(); + crate::btc_rpc_call(&self.rpc_client, move |rpc| { + let txid = rpc + .send_to_address(&address, amount)? + .into_model() + .map_err(|e| anyhow!("invalid txid: {e}"))? + .txid; + info!("Sent {} to {}: {}", amount, address, txid); + Ok(txid) + }) + .await } - pub fn get_balance(&self) -> Result { - Ok(self - .rpc_client - .get_balance()? - .into_model() - .map_err(|e| anyhow!("invalid balance: {e}"))? - .0) + pub async fn get_balance(&self) -> Result { + crate::btc_rpc_call(&self.rpc_client, |rpc| { + Ok(rpc + .get_balance()? + .into_model() + .map_err(|e| anyhow!("invalid balance: {e}"))? + .0) + }) + .await } - pub fn get_new_address(&self) -> Result
{ - let address = self.rpc_client.new_address()?; - Ok(address) + pub async fn get_new_address(&self) -> Result
{ + crate::btc_rpc_call(&self.rpc_client, |rpc| Ok(rpc.new_address()?)).await } - pub fn get_block_count(&self) -> Result { - Ok(self.rpc_client.get_block_count()?.0) + pub async fn get_block_count(&self) -> Result { + crate::btc_rpc_call(&self.rpc_client, |rpc| Ok(rpc.get_block_count()?.0)).await } pub async fn wait_for_transaction(&self, txid: &Txid, timeout: Duration) -> Result<()> { let start = std::time::Instant::now(); + let txid = *txid; loop { if start.elapsed() > timeout { return Err(anyhow!("Transaction {} not found within timeout", txid)); } - match self.rpc_client.get_transaction(*txid) { + match crate::btc_rpc_call(&self.rpc_client, move |rpc| rpc.get_transaction(txid)).await + { Ok(_) => { info!("Transaction {} confirmed", txid); return Ok(()); @@ -313,7 +325,7 @@ impl BitcoinNodeBuilder { let node_handle = BitcoinNodeHandle::new(rpc_port, data_dir, bitcoin_core_path)?; node_handle.wait_until_ready().await?; if self.initial_blocks > 0 { - node_handle.generate_blocks(self.initial_blocks)?; + node_handle.generate_blocks(self.initial_blocks).await?; } info!( "Created Bitcoin node at RPC port {} with {} initial blocks", diff --git a/crates/e2e-tests/src/e2e_flow.rs b/crates/e2e-tests/src/e2e_flow.rs index 6ef1fec67..6e61a7aba 100644 --- a/crates/e2e-tests/src/e2e_flow.rs +++ b/crates/e2e-tests/src/e2e_flow.rs @@ -277,12 +277,16 @@ mod tests { info!("Sending Bitcoin to deposit address..."); let txid = networks .bitcoin_node - .send_to_address(&deposit_address, Amount::from_sat(amount_sats))?; + .send_to_address(&deposit_address, Amount::from_sat(amount_sats)) + .await?; info!("Transaction sent: {}", txid); info!("Mining blocks for confirmation..."); let blocks_to_mine = 10; - networks.bitcoin_node.generate_blocks(blocks_to_mine)?; + networks + .bitcoin_node + .generate_blocks(blocks_to_mine) + .await?; info!("{blocks_to_mine} blocks mined"); info!("Creating deposit request on Sui..."); @@ -424,7 +428,7 @@ mod tests { } loop { - let mined_blocks = bitcoin_node.generate_blocks(1)?; + let mined_blocks = bitcoin_node.generate_blocks(1).await?; let block_hash = mined_blocks .last() .copied() @@ -511,7 +515,7 @@ mod tests { let hashi = networks.hashi_network.nodes()[0].hashi().clone(); let user_key = networks.sui_network.user_keys.first().unwrap(); let withdrawal_amount_sats = 30_000u64; - let btc_destination = networks.bitcoin_node.get_new_address()?; + let btc_destination = networks.bitcoin_node.get_new_address().await?; let destination_bytes = extract_witness_program(&btc_destination)?; info!( "Requesting withdrawal of {} sats to {}", @@ -580,7 +584,7 @@ mod tests { signer: sui_crypto::ed25519::Ed25519PrivateKey, withdrawal_amount_sats: u64, ) -> Result<()> { - let btc_destination = networks.bitcoin_node.get_new_address()?; + let btc_destination = networks.bitcoin_node.get_new_address().await?; let destination_bytes = extract_witness_program(&btc_destination)?; let mut executor = SuiTxExecutor::from_config(&hashi.config, hashi.onchain_state())?.with_signer(signer); @@ -891,7 +895,7 @@ mod tests { let user_key = networks.sui_network.user_keys.first().unwrap().clone(); // Submit withdrawal 1. Do NOT mine any Bitcoin blocks yet. - let btc_destination1 = networks.bitcoin_node.get_new_address()?; + let btc_destination1 = networks.bitcoin_node.get_new_address().await?; let destination_bytes1 = extract_witness_program(&btc_destination1)?; let mut executor = SuiTxExecutor::from_config(&hashi.config, hashi.onchain_state())? .with_signer(user_key.clone()); @@ -926,7 +930,7 @@ mod tests { // Submit withdrawal 2 immediately — the deposit UTXO is now locked, so // the only available UTXO is the unconfirmed change from withdrawal 1. - let btc_destination2 = networks.bitcoin_node.get_new_address()?; + let btc_destination2 = networks.bitcoin_node.get_new_address().await?; let destination_bytes2 = extract_witness_program(&btc_destination2)?; executor .execute_create_withdrawal_request(withdrawal_amount_sats, destination_bytes2) @@ -1095,14 +1099,14 @@ mod tests { // Submit two withdrawal requests back-to-back without waiting for either // to be committed. The leader should approve both and then batch them // together into a single Bitcoin transaction. - let btc_destination1 = networks.bitcoin_node.get_new_address()?; + let btc_destination1 = networks.bitcoin_node.get_new_address().await?; let destination_bytes1 = extract_witness_program(&btc_destination1)?; executor .execute_create_withdrawal_request(withdrawal_amount_sats, destination_bytes1) .await?; info!("Withdrawal request 1 submitted"); - let btc_destination2 = networks.bitcoin_node.get_new_address()?; + let btc_destination2 = networks.bitcoin_node.get_new_address().await?; let destination_bytes2 = extract_witness_program(&btc_destination2)?; executor .execute_create_withdrawal_request(withdrawal_amount_sats, destination_bytes2) @@ -1193,14 +1197,14 @@ mod tests { let mut executor = SuiTxExecutor::from_config(&hashi.config, hashi.onchain_state())? .with_signer(user_key.clone()); - let btc_destination1 = networks.bitcoin_node.get_new_address()?; + let btc_destination1 = networks.bitcoin_node.get_new_address().await?; let destination_bytes1 = extract_witness_program(&btc_destination1)?; executor .execute_create_withdrawal_request(withdrawal_amount_sats, destination_bytes1) .await?; info!("Withdrawal request 1 submitted"); - let btc_destination2 = networks.bitcoin_node.get_new_address()?; + let btc_destination2 = networks.bitcoin_node.get_new_address().await?; let destination_bytes2 = extract_witness_program(&btc_destination2)?; executor .execute_create_withdrawal_request(withdrawal_amount_sats, destination_bytes2) @@ -1367,7 +1371,7 @@ mod tests { .with_signer(user_key.clone()); // --- Withdrawal 1 --- - let btc_destination1 = networks.bitcoin_node.get_new_address()?; + let btc_destination1 = networks.bitcoin_node.get_new_address().await?; let destination_bytes1 = extract_witness_program(&btc_destination1)?; executor .execute_create_withdrawal_request(withdrawal_amount_sats, destination_bytes1) @@ -1393,14 +1397,14 @@ mod tests { // PendingWithdrawal and its change UTXO remains Pending { chain }. // The AncestorTx for withdrawal 1 will have confirmations=2, so // mempool_chain_depth() returns 0 — the change UTXO is eligible. - networks.bitcoin_node.generate_blocks(2)?; + networks.bitcoin_node.generate_blocks(2).await?; info!("Mined 2 blocks; withdrawal 1 now has 2 Bitcoin confirmations (below threshold 6)"); // --- Withdrawal 2 --- // The only available UTXO is the change from withdrawal 1. Its ancestor // is mined (confirmations=2 ≥ 1) so mempool_chain_depth()=0, making it // eligible even though withdrawal 1 is not yet confirmed on Sui. - let btc_destination2 = networks.bitcoin_node.get_new_address()?; + let btc_destination2 = networks.bitcoin_node.get_new_address().await?; let destination_bytes2 = extract_witness_program(&btc_destination2)?; executor .execute_create_withdrawal_request(withdrawal_amount_sats, destination_bytes2) @@ -1464,7 +1468,7 @@ mod tests { .with_signer(user_key.clone()); // --- Withdrawal A --- - let btc_destination_a = networks.bitcoin_node.get_new_address()?; + let btc_destination_a = networks.bitcoin_node.get_new_address().await?; executor .execute_create_withdrawal_request( withdrawal_amount_sats, @@ -1488,7 +1492,7 @@ mod tests { // --- Withdrawal B --- // UTXO_A has mempool depth 1 ≤ 3 → eligible. - let btc_destination_b = networks.bitcoin_node.get_new_address()?; + let btc_destination_b = networks.bitcoin_node.get_new_address().await?; executor .execute_create_withdrawal_request( withdrawal_amount_sats, @@ -1512,7 +1516,7 @@ mod tests { // --- Withdrawal C --- // UTXO_B has full ancestor chain [B, A] at mempool depth 2 ≤ 3 → eligible. - let btc_destination_c = networks.bitcoin_node.get_new_address()?; + let btc_destination_c = networks.bitcoin_node.get_new_address().await?; executor .execute_create_withdrawal_request( withdrawal_amount_sats, diff --git a/crates/e2e-tests/src/external_bitcoin_node.rs b/crates/e2e-tests/src/external_bitcoin_node.rs new file mode 100644 index 000000000..53f31c548 --- /dev/null +++ b/crates/e2e-tests/src/external_bitcoin_node.rs @@ -0,0 +1,180 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! A lightweight Bitcoin node handle that connects to an already-running node +//! (e.g. a local signet node). Unlike [`crate::BitcoinNodeHandle`], this does NOT +//! spawn or manage the bitcoind process. + +use anyhow::Result; +use anyhow::anyhow; +use bitcoin::Address; +use bitcoin::Amount; +use bitcoin::Txid; +use corepc_client::client_sync::Auth; +use corepc_client::client_sync::v29::Client; +use std::sync::Arc; +use std::time::Duration; +use tracing::info; + +use crate::hashi_network::BitcoinNodeInfo; + +pub struct ExternalBitcoinNode { + rpc_client: Arc, + rpc_url: String, + p2p_address: String, + rpc_user: String, + rpc_pass: String, +} + +impl ExternalBitcoinNode { + /// Connect to an already-running Bitcoin node. + /// + /// # Arguments + /// - `rpc_url`: Bitcoin RPC URL (e.g. `http://127.0.0.1:38332`) + /// - `rpc_user`: RPC username (empty string for no auth) + /// - `rpc_pass`: RPC password + /// - `wallet`: Optional wallet name to load + /// - `p2p_address`: P2P address for Kyoto light client (e.g. `127.0.0.1:38333`) + pub fn new( + rpc_url: &str, + rpc_user: &str, + rpc_pass: &str, + wallet: Option<&str>, + p2p_address: &str, + ) -> Result { + let url = if let Some(wallet_name) = wallet { + format!("{}/wallet/{}", rpc_url, wallet_name) + } else { + rpc_url.to_string() + }; + + let rpc_client = if rpc_user.is_empty() { + Client::new(&url) + } else { + Client::new_with_auth( + &url, + Auth::UserPass(rpc_user.to_string(), rpc_pass.to_string()), + )? + }; + + // Verify connectivity + let blockchain_info = rpc_client.get_blockchain_info().map_err(|e| { + anyhow!( + "Failed to connect to Bitcoin node at {}: {}. \ + Ensure the node is running.", + rpc_url, + e + ) + })?; + info!( + "Connected to external Bitcoin node: chain={}, blocks={}", + blockchain_info.chain, blockchain_info.blocks + ); + + Ok(Self { + rpc_client: Arc::new(rpc_client), + rpc_url: rpc_url.to_string(), + p2p_address: p2p_address.to_string(), + rpc_user: rpc_user.to_string(), + rpc_pass: rpc_pass.to_string(), + }) + } + + pub fn rpc_client(&self) -> &Client { + &self.rpc_client + } + + pub fn rpc_user(&self) -> &str { + &self.rpc_user + } + + pub fn rpc_pass(&self) -> &str { + &self.rpc_pass + } + + pub async fn send_to_address(&self, address: &Address, amount: Amount) -> Result { + let address = address.clone(); + crate::btc_rpc_call(&self.rpc_client, move |rpc| { + let txid = rpc + .send_to_address(&address, amount)? + .into_model() + .map_err(|e| anyhow!("invalid txid: {e}"))? + .txid; + info!("Sent {} to {}: {}", amount, address, txid); + Ok(txid) + }) + .await + } + + pub async fn get_block_count(&self) -> Result { + crate::btc_rpc_call(&self.rpc_client, |rpc| Ok(rpc.get_block_count()?.0)).await + } + + /// Wait until `txid` has at least `min_confirmations` confirmations. + pub async fn wait_for_confirmations( + &self, + txid: &Txid, + min_confirmations: u32, + timeout: Duration, + ) -> Result<()> { + let start = std::time::Instant::now(); + let txid = *txid; + loop { + if start.elapsed() > timeout { + return Err(anyhow!( + "Timeout waiting for {} confirmations on tx {} after {:?}", + min_confirmations, + txid, + timeout + )); + } + + match crate::btc_rpc_call(&self.rpc_client, move |rpc| rpc.get_transaction(txid)).await + { + Ok(info) => { + let confirmations = info.confirmations; + if confirmations >= min_confirmations as i64 { + info!( + "Transaction {} has {} confirmations (needed {})", + txid, confirmations, min_confirmations + ); + return Ok(()); + } + info!( + "Transaction {} has {} confirmations, waiting for {}...", + txid, confirmations, min_confirmations + ); + } + Err(_) => { + info!("Transaction {} not yet visible, waiting...", txid); + } + } + tokio::time::sleep(Duration::from_secs(10)).await; + } + } + + pub async fn get_balance(&self) -> Result { + crate::btc_rpc_call(&self.rpc_client, |rpc| { + Ok(rpc + .get_balance()? + .into_model() + .map_err(|e| anyhow!("invalid balance: {e}"))? + .0) + }) + .await + } + + pub async fn get_new_address(&self) -> Result
{ + crate::btc_rpc_call(&self.rpc_client, |rpc| Ok(rpc.new_address()?)).await + } +} + +impl BitcoinNodeInfo for ExternalBitcoinNode { + fn rpc_url(&self) -> &str { + &self.rpc_url + } + + fn p2p_address(&self) -> String { + self.p2p_address.clone() + } +} diff --git a/crates/e2e-tests/src/hashi_network.rs b/crates/e2e-tests/src/hashi_network.rs index 816d50fc4..d6ce1bf6d 100644 --- a/crates/e2e-tests/src/hashi_network.rs +++ b/crates/e2e-tests/src/hashi_network.rs @@ -17,12 +17,26 @@ use sui_transaction_builder::ObjectInput; use sui_transaction_builder::TransactionBuilder; use tracing::debug; -use crate::BitcoinNodeHandle; use crate::SuiNetworkHandle; const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); const TEST_WEIGHT_DIVISOR: u16 = 100; +/// Trait for Bitcoin node connectivity used by the hashi network builder. +pub trait BitcoinNodeInfo { + fn rpc_url(&self) -> &str; + fn p2p_address(&self) -> String; +} + +impl BitcoinNodeInfo for crate::BitcoinNodeHandle { + fn rpc_url(&self) -> &str { + self.rpc_url() + } + fn p2p_address(&self) -> String { + self.p2p_address() + } +} + pub struct HashiNodeHandle { config: HashiConfig, /// The running service and Hashi instance. Both are dropped together on shutdown @@ -235,6 +249,10 @@ pub struct HashiNetworkBuilder { /// Node index whose shares should be corrupted by all other nodes, /// triggering the complaint recovery flow. pub test_corrupt_shares_target: Option, + /// Bitcoin chain ID (genesis block hash). Defaults to regtest. + pub bitcoin_chain_id: String, + /// Optional override for bitcoin RPC auth credentials. + pub bitcoin_rpc_auth: Option<(String, String)>, } impl HashiNetworkBuilder { @@ -248,9 +266,21 @@ impl HashiNetworkBuilder { withdrawal_max_batch_size: None, max_mempool_chain_depth: None, test_corrupt_shares_target: None, + bitcoin_chain_id: hashi::constants::BITCOIN_REGTEST_CHAIN_ID.to_string(), + bitcoin_rpc_auth: None, } } + pub fn with_bitcoin_chain_id(mut self, id: &str) -> Self { + self.bitcoin_chain_id = id.to_string(); + self + } + + pub fn with_bitcoin_rpc_auth(mut self, user: String, pass: String) -> Self { + self.bitcoin_rpc_auth = Some((user, pass)); + self + } + pub fn with_num_nodes(mut self, num_nodes: usize) -> Self { self.num_nodes = num_nodes; self @@ -295,7 +325,7 @@ impl HashiNetworkBuilder { self, dir: &Path, sui: &SuiNetworkHandle, - bitcoin: &BitcoinNodeHandle, + bitcoin: &impl BitcoinNodeInfo, hashi_ids: HashiIds, ) -> Result { // Start a mock screener server for integration tests @@ -342,12 +372,17 @@ impl HashiNetworkBuilder { config.operator_private_key = Some(private_key.to_pem()?); config.sui_rpc = Some(sui_rpc.clone()); config.bitcoin_rpc = Some(bitcoin_rpc.clone()); + let (rpc_user, rpc_pass) = self.bitcoin_rpc_auth.clone().unwrap_or_else(|| { + ( + crate::bitcoin_node::RPC_USER.into(), + crate::bitcoin_node::RPC_PASSWORD.into(), + ) + }); config.bitcoin_rpc_auth = Some(hashi::btc_monitor::config::BtcRpcAuth::UserPass( - crate::bitcoin_node::RPC_USER.into(), - crate::bitcoin_node::RPC_PASSWORD.into(), + rpc_user, rpc_pass, )); config.bitcoin_trusted_peers = Some(vec![bitcoin.p2p_address()]); - config.bitcoin_chain_id = Some(hashi::constants::BITCOIN_REGTEST_CHAIN_ID.to_string()); + config.bitcoin_chain_id = Some(self.bitcoin_chain_id.clone()); config.sui_chain_id = service_info.chain_id.clone(); config.screener_endpoint = Some(screener_endpoint.clone()); config.db = Some(dir.join(validator_address.to_string())); diff --git a/crates/e2e-tests/src/lib.rs b/crates/e2e-tests/src/lib.rs index 71b5fdd58..2beb7035a 100644 --- a/crates/e2e-tests/src/lib.rs +++ b/crates/e2e-tests/src/lib.rs @@ -15,15 +15,32 @@ use std::path::Path; use std::process::Command; +use std::sync::Arc; use anyhow::Result; +use corepc_client::client_sync::v29::Client; pub mod bitcoin_node; pub mod e2e_flow; +pub mod external_bitcoin_node; pub mod hashi_network; -mod publish; +pub mod publish; pub mod sui_network; +/// Offload a blocking Bitcoin Core RPC call to the tokio blocking thread pool. +/// +/// Same pattern as `btc_rpc_call` in `hashi::btc_monitor::monitor`. +pub async fn btc_rpc_call(client: &Arc, f: F) -> T +where + F: FnOnce(&Client) -> T + Send + 'static, + T: Send + 'static, +{ + let client = Arc::clone(client); + tokio::task::spawn_blocking(move || f(&client)) + .await + .expect("btc_rpc_call: spawn_blocking task panicked") +} + pub use bitcoin_node::BitcoinNodeBuilder; pub use bitcoin_node::BitcoinNodeHandle; pub use hashi_network::HashiNetwork; @@ -211,6 +228,7 @@ impl TestNetworksBuilder { dir.as_ref(), &mut sui_network.client, sui_network.user_keys.first().unwrap(), + hashi::constants::BITCOIN_REGTEST_CHAIN_ID, ) .await?; diff --git a/crates/e2e-tests/src/main.rs b/crates/e2e-tests/src/main.rs index bf67a4961..7d70db5a1 100644 --- a/crates/e2e-tests/src/main.rs +++ b/crates/e2e-tests/src/main.rs @@ -57,9 +57,37 @@ struct LocalnetOpts { data_dir: std::path::PathBuf, } +/// Options for connecting to an external Bitcoin node (signet, testnet4, etc). +/// These are only used when `--bitcoin-network` is not `regtest`. +#[derive(Args)] +struct ExternalBitcoinOpts { + /// Bitcoin RPC URL + #[clap(long, default_value = "http://127.0.0.1:38332")] + btc_rpc_url: String, + + /// Bitcoin RPC username + #[clap(long, default_value = "")] + btc_rpc_user: String, + + /// Bitcoin RPC password + #[clap(long, default_value = "")] + btc_rpc_pass: String, + + /// Bitcoin wallet name (used for send_to_address) + #[clap(long)] + btc_wallet: Option, + + /// Bitcoin P2P address for Kyoto light client + #[clap(long, default_value = "127.0.0.1:38333")] + btc_p2p_address: String, +} + #[derive(Subcommand)] enum Commands { - /// Start a local development environment (bitcoind + Sui + Hashi validators) + /// Start a local development environment (Sui localnet + Hashi validators + Bitcoin) + /// + /// With --bitcoin-network regtest (default), a local bitcoind is spawned. + /// With --bitcoin-network signet, connects to an external node (see --btc-rpc-url). Start { /// Number of Hashi validators to run #[clap(long, default_value = "4")] @@ -69,10 +97,17 @@ enum Commands { #[clap(long, default_value = "9000")] sui_rpc_port: u16, - /// Bitcoin regtest RPC port + /// Bitcoin regtest RPC port (only used in regtest mode) #[clap(long, default_value = "18443")] btc_rpc_port: u16, + /// Bitcoin network: "regtest" spawns a local node, others connect externally + #[clap(long, default_value = "regtest")] + bitcoin_network: String, + + #[command(flatten)] + btc_opts: ExternalBitcoinOpts, + /// Enable verbose tracing output #[clap(long, short)] verbose: bool, @@ -171,6 +206,16 @@ struct LocalnetState { /// Path to a PEM-encoded funded Sui keypair (from genesis) #[serde(skip_serializing_if = "Option::is_none")] funded_sui_keypair_path: Option, + /// Bitcoin network: "regtest", "signet", "testnet4", or "mainnet" + #[serde(default = "default_bitcoin_network")] + bitcoin_network: String, + /// Bitcoin wallet name for RPC calls (e.g. "mining", "test") + #[serde(default)] + btc_wallet: Option, +} + +fn default_bitcoin_network() -> String { + "regtest".to_string() } impl LocalnetState { @@ -220,16 +265,20 @@ async fn main() -> Result<()> { num_validators, sui_rpc_port, btc_rpc_port, + bitcoin_network, + btc_opts, verbose, opts, } => { - cmd_start( + cmd_start(StartConfig { num_validators, sui_rpc_port, btc_rpc_port, + bitcoin_network, + btc_opts, verbose, - &opts.data_dir, - ) + data_dir: opts.data_dir, + }) .await } Commands::Stop { opts } => cmd_stop(&opts.data_dir).await, @@ -255,15 +304,40 @@ async fn main() -> Result<()> { } } -async fn cmd_start( +struct StartConfig { num_validators: usize, sui_rpc_port: u16, btc_rpc_port: u16, + bitcoin_network: String, + btc_opts: ExternalBitcoinOpts, verbose: bool, - data_dir: &Path, -) -> Result<()> { + data_dir: std::path::PathBuf, +} + +impl StartConfig { + fn chain_id(&self) -> Result<&'static str> { + match self.bitcoin_network.as_str() { + "regtest" => Ok(hashi::constants::BITCOIN_REGTEST_CHAIN_ID), + "signet" => Ok(hashi::constants::BITCOIN_SIGNET_CHAIN_ID), + "testnet4" => Ok(hashi::constants::BITCOIN_TESTNET4_CHAIN_ID), + "mainnet" => Ok(hashi::constants::BITCOIN_MAINNET_CHAIN_ID), + other => anyhow::bail!( + "Unknown bitcoin network '{}'. Use regtest, signet, testnet4, or mainnet", + other + ), + } + } + + fn is_regtest(&self) -> bool { + self.bitcoin_network == "regtest" + } +} + +async fn cmd_start(cfg: StartConfig) -> Result<()> { + cfg.chain_id()?; // Validate early + // Check for existing running instance - if let Ok(state) = LocalnetState::load(data_dir) { + if let Ok(state) = LocalnetState::load(&cfg.data_dir) { if state.is_alive() { anyhow::bail!( "Localnet is already running (PID {}). Stop it first with `hashi-localnet stop`.", @@ -273,108 +347,206 @@ async fn cmd_start( print_warning("Found stale state file, cleaning up..."); } - let default_level = if verbose { - tracing::level_filters::LevelFilter::INFO - } else { - tracing::level_filters::LevelFilter::OFF - }; - - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::builder() - .with_default_directive(default_level.into()) - .from_env_lossy(), - ) - .with_target(false) - .init(); + init_tracing(cfg.verbose); use std::io::Write; print!( - "{} Starting localnet with {} validators...", + "{} Starting localnet with {} validators (btc: {})...", "ℹ".blue().bold(), - num_validators + cfg.num_validators, + cfg.bitcoin_network, ); std::io::stdout().flush().ok(); + if cfg.is_regtest() { + start_regtest(&cfg).await + } else { + start_external(&cfg).await + } +} + +/// Regtest mode: spawn bitcoind, Sui localnet, and Hashi validators. +async fn start_regtest(cfg: &StartConfig) -> Result<()> { let test_networks = TestNetworksBuilder::new() - .with_nodes(num_validators) - .with_sui_rpc_port(sui_rpc_port) - .with_btc_rpc_port(btc_rpc_port) + .with_nodes(cfg.num_validators) + .with_sui_rpc_port(cfg.sui_rpc_port) + .with_btc_rpc_port(cfg.btc_rpc_port) + .build() + .await?; + + let state = persist_localnet_state( + &cfg.data_dir, + &test_networks.sui_network, + test_networks.bitcoin_node().rpc_url(), + e2e_tests::bitcoin_node::RPC_USER, + e2e_tests::bitcoin_node::RPC_PASSWORD, + test_networks.hashi_network().ids(), + cfg, + )?; + print_ready(&state); + + tokio::signal::ctrl_c().await?; + cleanup_state_files(&cfg.data_dir); + drop(test_networks); + Ok(()) +} + +/// External node mode: connect to an existing Bitcoin node (signet, testnet4, etc). +async fn start_external(cfg: &StartConfig) -> Result<()> { + let btc = &cfg.btc_opts; + let external_node = e2e_tests::external_bitcoin_node::ExternalBitcoinNode::new( + &btc.btc_rpc_url, + &btc.btc_rpc_user, + &btc.btc_rpc_pass, + btc.btc_wallet.as_deref(), + &btc.btc_p2p_address, + )?; + + let dir = tempfile::Builder::new() + .prefix("hashi-test-env-") + .tempdir()?; + tracing::info!("test env: {}", dir.path().display()); + + let mut sui_network = e2e_tests::SuiNetworkBuilder::default() + .with_num_validators(cfg.num_validators) + .with_rpc_port(cfg.sui_rpc_port) + .dir(&dir.path().join("sui")) .build() .await?; - let sui_rpc_url = &test_networks.sui_network().rpc_url; - let btc_rpc_url = test_networks.bitcoin_node().rpc_url(); - let ids = test_networks.hashi_network().ids(); + TestNetworksBuilder::cp_packages(dir.as_ref())?; + let chain_id = cfg.chain_id()?; + let hashi_ids = e2e_tests::publish::publish( + dir.as_ref(), + &mut sui_network.client, + sui_network.user_keys.first().unwrap(), + chain_id, + ) + .await?; + + let hashi_network = e2e_tests::HashiNetworkBuilder::new() + .with_num_nodes(cfg.num_validators) + .with_bitcoin_chain_id(chain_id) + .with_bitcoin_rpc_auth(btc.btc_rpc_user.clone(), btc.btc_rpc_pass.clone()) + .build( + &dir.path().join("hashi"), + &sui_network, + &external_node, + hashi_ids, + ) + .await?; + + let state = persist_localnet_state( + &cfg.data_dir, + &sui_network, + &btc.btc_rpc_url, + &btc.btc_rpc_user, + &btc.btc_rpc_pass, + hashi_ids, + cfg, + )?; + print_ready(&state); + + tokio::signal::ctrl_c().await?; + cleanup_state_files(&cfg.data_dir); + drop(hashi_network); + drop(external_node); + drop(sui_network); + drop(dir); + Ok(()) +} - // Write the funded genesis key to disk so faucet/CLI commands can use it +/// Write the funded genesis key and localnet state to disk. +fn persist_localnet_state( + data_dir: &Path, + sui_network: &e2e_tests::SuiNetworkHandle, + btc_rpc_url: &str, + btc_rpc_user: &str, + btc_rpc_pass: &str, + ids: hashi::config::HashiIds, + cfg: &StartConfig, +) -> Result { + std::fs::create_dir_all(data_dir)?; + + // Write the funded genesis key to disk so deposit/faucet commands can use it let funded_key_path = data_dir.join("funded_keypair.pem"); - let funded_key = test_networks - .sui_network() + let funded_key = sui_network .user_keys .first() .context("No funded user keys in localnet genesis")?; - let pem = funded_key - .to_pem() - .context("Failed to serialize funded key as PEM")?; - std::fs::create_dir_all(data_dir)?; - { - use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - let mut file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&funded_key_path) - .with_context(|| { - format!( - "Failed to write funded key to {}", - funded_key_path.display() - ) - })?; - file.write_all(pem.as_bytes())?; - } + write_pem_key(&funded_key_path, &funded_key.to_pem()?)?; let state = LocalnetState { pid: std::process::id(), - sui_rpc_url: sui_rpc_url.clone(), + sui_rpc_url: sui_network.rpc_url.clone(), btc_rpc_url: btc_rpc_url.to_string(), - btc_rpc_user: e2e_tests::bitcoin_node::RPC_USER.to_string(), - btc_rpc_password: e2e_tests::bitcoin_node::RPC_PASSWORD.to_string(), + btc_rpc_user: btc_rpc_user.to_string(), + btc_rpc_password: btc_rpc_pass.to_string(), package_id: ids.package_id.to_string(), hashi_object_id: ids.hashi_object_id.to_string(), - num_validators, + num_validators: cfg.num_validators, data_dir: data_dir.to_path_buf(), funded_sui_keypair_path: Some(funded_key_path.to_string_lossy().into_owned()), + bitcoin_network: cfg.bitcoin_network.clone(), + btc_wallet: if cfg.is_regtest() { + Some("test".to_string()) + } else { + cfg.btc_opts.btc_wallet.clone() + }, }; state.save(data_dir)?; - - // Write a CLI config file so `hashi` CLI can auto-discover the localnet write_cli_config(data_dir, &state)?; + Ok(state) +} - // Overwrite the "ℹ Starting..." line with a checkmark - print!("\r{}", " ".repeat(60)); +fn write_pem_key(path: &Path, pem: &str) -> Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("Failed to write key to {}", path.display()))?; + file.write_all(pem.as_bytes())?; + Ok(()) +} + +fn init_tracing(verbose: bool) { + let default_level = if verbose { + tracing::level_filters::LevelFilter::INFO + } else { + tracing::level_filters::LevelFilter::OFF + }; + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::builder() + .with_default_directive(default_level.into()) + .from_env_lossy(), + ) + .with_target(false) + .init(); +} + +fn print_ready(state: &LocalnetState) { + print!("\r{}", " ".repeat(80)); println!( - "\r{} Localnet started with {} validators", + "\r{} Localnet started with {} validators (btc: {})", "✓".green().bold(), - num_validators + state.num_validators, + state.bitcoin_network, ); println!(); - print_connection_details(&state); - + print_connection_details(state); print_info("Press Ctrl+C to stop the localnet."); +} - // Wait for Ctrl+C - tokio::signal::ctrl_c().await?; - +fn cleanup_state_files(data_dir: &Path) { print_info("Shutting down..."); - // Cleanup happens via Drop on test_networks let _ = std::fs::remove_file(LocalnetState::state_file_path(data_dir)); let _ = std::fs::remove_file(cli_config_path(data_dir)); print_success("Localnet stopped."); - - Ok(()) } async fn cmd_stop(data_dir: &Path) -> Result<()> { @@ -450,6 +622,13 @@ fn cmd_mine(blocks: u64, data_dir: &Path) -> Result<()> { anyhow::bail!("Localnet process is not running."); } + if state.bitcoin_network != "regtest" { + anyhow::bail!( + "Mining is only supported on regtest. Current network: {}", + state.bitcoin_network + ); + } + let client = corepc_client::client_sync::v29::Client::new_with_auth( &state.btc_rpc_url, corepc_client::client_sync::Auth::UserPass(state.btc_rpc_user, state.btc_rpc_password), @@ -511,9 +690,10 @@ fn cmd_keygen(action: KeygenCommands) -> Result<()> { let btc_network = match network.as_str() { "mainnet" => bitcoin::Network::Bitcoin, "testnet4" => bitcoin::Network::Testnet4, + "signet" => bitcoin::Network::Signet, "regtest" => bitcoin::Network::Regtest, other => anyhow::bail!( - "Unknown Bitcoin network: {}. Use mainnet, testnet4, or regtest", + "Unknown Bitcoin network: {}. Use mainnet, testnet4, signet, or regtest", other ), }; @@ -668,6 +848,14 @@ fn cmd_faucet_btc(address: &str, blocks: u64, data_dir: &Path) -> Result<()> { anyhow::bail!("Localnet process is not running."); } + if state.bitcoin_network != "regtest" { + anyhow::bail!( + "BTC faucet (mining) is only supported on regtest. Current network: {}. \ + Use a signet faucet instead.", + state.bitcoin_network + ); + } + let btc_addr: bitcoin::Address = address.parse().context("Invalid Bitcoin address")?; let btc_addr = btc_addr @@ -750,7 +938,7 @@ async fn cmd_deposit(amount: u64, recipient: Option<&str>, data_dir: &Path) -> R } // Derive deposit address - let btc_network = bitcoin::Network::Regtest; + let btc_network = hashi::btc_monitor::config::parse_btc_network(Some(&state.bitcoin_network))?; let deposit_address = hashi::cli::commands::deposit::cli_derive_deposit_address( &mpc_pubkey, Some(&recipient_addr), @@ -763,24 +951,33 @@ async fn cmd_deposit(amount: u64, recipient: Option<&str>, data_dir: &Path) -> R amount, deposit_address )); - // Use /wallet/test for Bitcoin Core v28+ regtest - let wallet_url = format!("{}/wallet/test", state.btc_rpc_url); - let btc_rpc = corepc_client::client_sync::v29::Client::new_with_auth( + let wallet_url = match &state.btc_wallet { + Some(wallet) => format!("{}/wallet/{}", state.btc_rpc_url, wallet), + None => state.btc_rpc_url.clone(), + }; + let btc_rpc = std::sync::Arc::new(corepc_client::client_sync::v29::Client::new_with_auth( &wallet_url, corepc_client::client_sync::Auth::UserPass(state.btc_rpc_user, state.btc_rpc_password), - )?; + )?); - let txid = btc_rpc - .send_to_address(&deposit_address, bitcoin::Amount::from_sat(amount))? + let txid = { + let addr = deposit_address.clone(); + e2e_tests::btc_rpc_call(&btc_rpc, move |rpc| { + rpc.send_to_address(&addr, bitcoin::Amount::from_sat(amount)) + }) + .await? .into_model() .context("Invalid txid from send_to_address")? - .txid; + .txid + }; // Find the vout - let tx = btc_rpc - .get_raw_transaction(txid) - .and_then(|r| r.transaction().map_err(Into::into)) - .context("Failed to fetch raw transaction")?; + let tx = e2e_tests::btc_rpc_call(&btc_rpc, move |rpc| { + rpc.get_raw_transaction(txid) + .and_then(|r| r.transaction().map_err(Into::into)) + }) + .await + .context("Failed to fetch raw transaction")?; let vout = tx .output .iter() @@ -792,11 +989,44 @@ async fn cmd_deposit(amount: u64, recipient: Option<&str>, data_dir: &Path) -> R print_success(&format!("BTC sent! txid: {} vout: {}", txid, vout)); - // Step 2: Mine blocks - print_info("Mining 10 blocks..."); - let mine_addr = btc_rpc.new_address()?; - btc_rpc.generate_to_address(10, &mine_addr)?; - print_success("Mined 10 blocks"); + // Step 2: Confirm the transaction + if state.bitcoin_network == "regtest" { + print_info("Mining 10 blocks..."); + let mine_addr = e2e_tests::btc_rpc_call(&btc_rpc, |rpc| rpc.new_address()).await?; + e2e_tests::btc_rpc_call(&btc_rpc, move |rpc| rpc.generate_to_address(10, &mine_addr)) + .await?; + print_success("Mined 10 blocks"); + } else { + print_info(&format!( + "Waiting for block confirmation on {} (this may take ~10 minutes)...", + state.bitcoin_network + )); + // Poll for at least 1 confirmation + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(900); + loop { + if start.elapsed() > timeout { + anyhow::bail!("Timeout waiting for transaction confirmation"); + } + match e2e_tests::btc_rpc_call(&btc_rpc, move |rpc| rpc.get_transaction(txid)).await { + Ok(info) => { + let confirmations = info.confirmations; + if confirmations >= 1 { + print_success(&format!( + "Transaction confirmed ({} confirmations)", + confirmations + )); + break; + } + print_info(&format!(" {} confirmations, waiting...", confirmations)); + } + Err(_) => { + print_info(" Transaction not yet visible, waiting..."); + } + } + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + } + } // Step 3: Submit deposit request on Sui print_info("Submitting deposit request on Sui..."); @@ -835,7 +1065,7 @@ fn write_cli_config(data_dir: &Path, state: &LocalnetState) -> Result<()> { rpc_url: Some(state.btc_rpc_url.clone()), rpc_user: Some(state.btc_rpc_user.clone()), rpc_password: Some(state.btc_rpc_password.clone()), - network: Some("regtest".to_string()), + network: Some(state.bitcoin_network.clone()), private_key_path: None, }), }; diff --git a/crates/e2e-tests/src/publish.rs b/crates/e2e-tests/src/publish.rs index dce7342c8..dfa75a7f4 100644 --- a/crates/e2e-tests/src/publish.rs +++ b/crates/e2e-tests/src/publish.rs @@ -13,6 +13,7 @@ pub async fn publish( dir: &Path, client: &mut Client, private_key: &Ed25519PrivateKey, + bitcoin_chain_id: &str, ) -> Result { let params = hashi::publish::BuildParams { sui_binary: sui_binary(), @@ -21,6 +22,5 @@ pub async fn publish( environment: Some("testnet"), }; let compiled = hashi::publish::build_package(¶ms)?; - let bitcoin_chain_id = hashi::constants::BITCOIN_REGTEST_CHAIN_ID; hashi::publish::publish_and_init(client, private_key, compiled, bitcoin_chain_id).await } diff --git a/crates/hashi-monitor/src/rpc/btc.rs b/crates/hashi-monitor/src/rpc/btc.rs index 1fd8c6746..612e8e171 100644 --- a/crates/hashi-monitor/src/rpc/btc.rs +++ b/crates/hashi-monitor/src/rpc/btc.rs @@ -150,13 +150,15 @@ mod tests { "expected unknown tx lookup to return none" ); - let destination = node.get_new_address()?; - let txid = node.send_to_address(&destination, Amount::from_sat(50_000))?; + let destination = node.get_new_address().await?; + let txid = node + .send_to_address(&destination, Amount::from_sat(50_000)) + .await?; let unconfirmed = btc_rpc_client.lookup_confirmation(txid)?; assert!(unconfirmed.is_none(), "expected unconfirmed transaction"); - node.generate_blocks(1)?; + node.generate_blocks(1).await?; let confirmed = btc_rpc_client.lookup_confirmation(txid)?; assert!(confirmed.is_some(), "expected confirmed transaction");