diff --git a/Cargo.toml b/Cargo.toml index 814863ff..c16baf41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,10 @@ resolver = "2" [workspace.dependencies] anyhow = "1.0" -array-bytes = "6.1.0" backoff = "0.4.0" bdk = "0.28.1" -bitcoin = "0.29.2" clap = "4.1.1" derivative = "2.2.0" -dirs = "5.0.1" futures = "0.3.28" hex = "0.4.3" log = "0.4.19" @@ -19,18 +16,14 @@ p256k1 = "5.1" rand = "0.8.5" regex = "~1.8.4" reqwest = "0.11.20" -ring = "0.16.20" ripemd = "0.1.3" rs_merkle = "1.4.1" -secp256k1 = "0.27.0" serde = "1.0" serde_json = "1.0" sha2 = "0.10.7" -stacks-core = { version = "0.1.0", path = "./stacks-core" } strum = "0.25.0" thiserror = "1.0.43" tokio = "1.32.0" -toml = "0.8.0" tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } url = "2.4.1" diff --git a/romeo/Cargo.toml b/romeo/Cargo.toml index 58483c26..7c40bab6 100644 --- a/romeo/Cargo.toml +++ b/romeo/Cargo.toml @@ -23,3 +23,7 @@ tracing-subscriber.workspace = true tracing.workspace = true url.workspace = true rs_merkle.workspace = true + +[dev-dependencies] +assert_matches = "1.5.0" +mockito = "1.2.0" diff --git a/romeo/src/bitcoin_client.rs b/romeo/src/bitcoin_client.rs index f5bc13be..601f8611 100644 --- a/romeo/src/bitcoin_client.rs +++ b/romeo/src/bitcoin_client.rs @@ -1,6 +1,7 @@ //! RPC Bitcoin client use std::{ + fmt::Debug, sync::{Arc, Mutex}, time::Duration, }; @@ -9,48 +10,52 @@ use anyhow::anyhow; use bdk::{ bitcoin::{Block, PrivateKey, Script, Transaction, Txid}, bitcoincore_rpc::{self, Auth, Client as RPCClient, RpcApi}, - blockchain::{ - ConfigurableBlockchain, ElectrumBlockchain, ElectrumBlockchainConfig, - }, + blockchain::{ElectrumBlockchain, GetHeight, WalletSync}, database::MemoryDatabase, template::P2TR, SignOptions, SyncOptions, Wallet, }; +use derivative::Derivative; use sbtc_core::operations::op_return::utils::reorder_outputs; +use stacks_core::wallet::BitcoinCredentials; use tokio::{task::spawn_blocking, time::sleep}; use tracing::trace; +use url::Url; -use crate::{config::Config, event::TransactionStatus}; +use crate::event::TransactionStatus; const BLOCK_POLLING_INTERVAL: Duration = Duration::from_secs(5); +/// [Client] +pub type BitcoinClient = Client; + /// Bitcoin RPC client -#[derive(Clone)] -pub struct Client { - config: Config, - blockchain: Arc, +/// unless testing use [ElectrumBlockchain] for `ElectrumClient`. +#[derive(Derivative, Debug)] +#[derivative(Clone)] +pub struct Client { + bitcoin_url: Url, + bitcoin_auth: Auth, + #[derivative(Clone(bound = ""))] + blockchain: Arc, // required for fulfillment txs wallet: Arc>>, } -impl Client { +impl Client { /// Create a new RPC client - pub fn new(config: Config) -> anyhow::Result { - let url = config.electrum_node_url.as_str().to_string(); - let network = config.bitcoin_network; - let p2tr_private_key = PrivateKey::from_wif( - &config.bitcoin_credentials.wif_p2tr().to_string(), - )?; + pub fn new( + bitcoin_url: Url, + electrum_blockchain: B, + credentials: BitcoinCredentials, + ) -> anyhow::Result { + let network = credentials.network(); + let p2tr_private_key = PrivateKey::new( + credentials.private_key_p2tr(), + credentials.network(), + ); - let blockchain = - ElectrumBlockchain::from_config(&ElectrumBlockchainConfig { - url, - socks5: None, - retry: 3, - timeout: Some(10), - stop_gap: 10, - validate_domain: false, - })?; + let blockchain = electrum_blockchain; let wallet = Wallet::new( P2TR(p2tr_private_key), @@ -59,13 +64,32 @@ impl Client { MemoryDatabase::default(), )?; + if bitcoin_url.username().is_empty() { + return Err(anyhow::anyhow!("Username in {bitcoin_url} is empty")); + } + + if bitcoin_url.password().is_none() { + return Err(anyhow::anyhow!("Password in {bitcoin_url} is empty")); + } + + let username = bitcoin_url.username().to_string(); + let password = bitcoin_url.password().unwrap_or_default().to_string(); + + let mut bitcoin_url = bitcoin_url; + bitcoin_url.set_username("").unwrap(); + bitcoin_url.set_password(None).unwrap(); + Ok(Self { - config, + bitcoin_url, + bitcoin_auth: Auth::UserPass(username, password), blockchain: Arc::new(blockchain), wallet: Arc::new(Mutex::new(wallet)), }) } +} +impl Client { + /// Create a new RPC client async fn execute( &self, f: F, @@ -74,24 +98,10 @@ impl Client { F: FnOnce(RPCClient) -> bitcoincore_rpc::Result + Send + 'static, T: Send + 'static, { - let mut url = self.config.bitcoin_node_url.clone(); - - let username = url.username().to_string(); - let password = url.password().unwrap_or_default().to_string(); - - if username.is_empty() { - return Err(anyhow::anyhow!("Username is empty")); - } - - if password.is_empty() { - return Err(anyhow::anyhow!("Password is empty")); - } - - url.set_username("").unwrap(); - url.set_password(None).unwrap(); - - let client = - RPCClient::new(url.as_ref(), Auth::UserPass(username, password))?; + let client = RPCClient::new( + self.bitcoin_url.as_ref(), + self.bitcoin_auth.clone(), + )?; Ok(spawn_blocking(move || f(client)).await?) } @@ -142,13 +152,12 @@ impl Client { block_height: u32, ) -> anyhow::Result<(u32, Block)> { let block_hash = loop { - let res = self + match self .execute(move |client| { client.get_block_hash(block_height as u64) }) - .await?; - - match res { + .await? + { Ok(hash) => { trace!( "Got Bitcoin block hash at height {}: {}", @@ -159,15 +168,8 @@ impl Client { } Err(bitcoincore_rpc::Error::JsonRpc( bitcoincore_rpc::jsonrpc::Error::Rpc(err), - )) => { - if err.code == -8 { - trace!("Bitcoin block not found, retrying..."); - } else { - Err(anyhow!( - "Error fetching Bitcoin block: {:?}", - err - ))?; - } + )) if err.code == -8 => { + trace!("Bitcoin block not found, retrying..."); } Err(bitcoincore_rpc::Error::JsonRpc( bitcoincore_rpc::jsonrpc::Error::Transport(_), @@ -175,7 +177,7 @@ impl Client { trace!("Bitcoin client connection error, retrying..."); } Err(err) => { - Err(anyhow!("Error fetching Bitcoin block: {:?}", err))? + Err(anyhow!("Error fetching Bitcoin block: {:?}", err))?; } }; @@ -197,14 +199,17 @@ impl Client { Ok(info.blocks as u32) } +} +impl Client +where + Arc: Send, +{ /// Sign and broadcast a transaction pub async fn sign_and_broadcast( &self, outputs: Vec<(Script, u64)>, ) -> anyhow::Result { - sleep(Duration::from_secs(3)).await; - let blockchain = self.blockchain.clone(); let wallet = self.wallet.clone(); @@ -242,21 +247,24 @@ impl Client { } #[cfg(test)] -// test that wallet returns correct address mod tests { - use std::path::Path; - use bdk::bitcoin::Network as BitcoinNetwork; + use assert_matches::assert_matches; + use bdk::{ + bitcoin::Network as BitcoinNetwork, + blockchain::{ConfigurableBlockchain, ElectrumBlockchainConfig}, + }; use blockstack_lib::vm::ContractName; use stacks_core::{wallet::Wallet, Network}; - use super::Client; - use crate::config::Config; + use super::*; + use crate::{config::Config, test::MNEMONIC}; #[test] + // test that wallet returns correct address fn test_wallet_address() { - let wallet = Wallet::new("twice kind fence tip hidden tilt action fragile skin nothing glory cousin green tomorrow spring wrist shed math olympic multiply hip blue scout claw").unwrap(); + let wallet = Wallet::new(MNEMONIC[0]).unwrap(); let stacks_network = Network::Testnet; let stacks_credentials = wallet.credentials(stacks_network, 0).unwrap(); @@ -267,7 +275,9 @@ mod tests { let conf = Config { state_directory: Path::new("/tmp/romeo").to_path_buf(), bitcoin_credentials, - bitcoin_node_url: "http://localhost:18443".parse().unwrap(), + bitcoin_node_url: "http://user:pwd@localhost:18443" + .parse() + .unwrap(), electrum_node_url: "ssl://blockstream.info:993".parse().unwrap(), bitcoin_network: "testnet".parse().unwrap(), contract_name: ContractName::from("asset"), @@ -278,11 +288,26 @@ mod tests { strict: true, }; - let client = Client::new(conf.clone()).unwrap(); + let electrum_blockchain = + ElectrumBlockchain::from_config(&ElectrumBlockchainConfig { + url: conf.electrum_node_url.to_string(), + socks5: None, + retry: 3, + timeout: Some(10), + stop_gap: 10, + validate_domain: false, + }) + .unwrap(); + + let client = Client::new( + conf.bitcoin_node_url.clone(), + electrum_blockchain, + conf.bitcoin_credentials.clone(), + ) + .unwrap(); let client_sbtc_wallet = client .wallet - .clone() .lock() .unwrap() .get_address(bdk::wallet::AddressIndex::Peek(0)) @@ -299,4 +324,81 @@ mod tests { expected_sbtc_wallet ); } + + fn client( + url: &str, + ) -> anyhow::Result> { + let wallet = Wallet::new(MNEMONIC[WALLET_INDEX]).unwrap(); + let credentials = wallet + .bitcoin_credentials(BitcoinNetwork::Testnet, 0) + .unwrap(); + + Client::new(url.parse().unwrap(), (), credentials) + } + + #[test] + fn no_password() { + let broken_client = + |url: &str| client::<0>(url).expect_err("missing password"); + + let err_string = "Password in http://user@host/ is empty"; + assert_eq!(broken_client("http://user:@host").to_string(), err_string,); + assert_eq!(broken_client("http://user@host").to_string(), err_string,); + let err_string = "Username in http://host/ is empty"; + assert_eq!(broken_client("http://@host").to_string(), err_string); + assert_eq!(broken_client("http://host").to_string(), err_string,); + } + + #[test] + fn stripped_url_auth_is_field() { + let client = client::<0>("http://user:pass@host").unwrap(); + assert_eq!(client.bitcoin_url, "http://host".parse().unwrap()); + assert_eq!( + client.bitcoin_auth, + Auth::UserPass("user".into(), "pass".into()) + ); + } + + #[tokio::test] + async fn get_block() { + let mut server = mockito::Server::new(); + let host = format!("http://devnet:devnet@{}", server.host_with_port()); + let mock_hash = server + .mock("POST", "/") + .with_status(200) + .with_header("content-type", "application/json") + .match_body(mockito::Matcher::PartialJsonString( + r#"{"method": "getblockhash"}"#.to_string(), + )) + .with_body( + // Regardless of input. + r#"{ "result": "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206", "error": null, "id": 0 }"#, + ) + .create(); + + let mock_block = server + .mock("POST", "/") + .with_status(200) + .with_header("content-type", "application/json") + .match_body(mockito::Matcher::PartialJsonString( + r#"{"method": "getblock"}"#.to_string(), + )) + .with_body( + // Regardless of input. + r#"{ "result":"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff7f20020000000101000000010000000000000000000000000000000000000000000000000000000000000000ffffffff4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000", "id": 0}"#, + ) + .create(); + + let client = client::<0>(host.as_str()).unwrap(); + + assert_matches!(client.get_block(0).await.unwrap(), (0, block) =>{ + // given the current devenv config; block hash 0 + assert_eq!("0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206", + block.header.block_hash().to_string()); + }); + + // endpoints where served. + mock_hash.assert(); + mock_block.assert() + } } diff --git a/romeo/src/lib.rs b/romeo/src/lib.rs index 8230107e..9d112046 100644 --- a/romeo/src/lib.rs +++ b/romeo/src/lib.rs @@ -14,3 +14,4 @@ pub mod stacks_client; pub mod state; pub mod system; pub mod task; +pub mod test; diff --git a/romeo/src/system.rs b/romeo/src/system.rs index 30d27b25..30dc9c3b 100644 --- a/romeo/src/system.rs +++ b/romeo/src/system.rs @@ -1,8 +1,13 @@ //! System -use std::{fs::create_dir_all, io::Cursor}; +use std::{fs::create_dir_all, io::Cursor, time::Duration}; -use bdk::bitcoin::Txid as BitcoinTxId; +use bdk::{ + bitcoin::Txid as BitcoinTxId, + blockchain::{ + ConfigurableBlockchain, ElectrumBlockchain, ElectrumBlockchainConfig, + }, +}; use blockstack_lib::{ burnchains::Txid as StacksTxId, chainstate::stacks::{ @@ -24,7 +29,7 @@ use tokio::{ use tracing::{debug, info, trace}; use crate::{ - bitcoin_client::Client as BitcoinClient, + bitcoin_client::BitcoinClient, config::Config, event::Event, proof_data::{ProofData, ProofDataClarityValues}, @@ -46,8 +51,23 @@ const DUMMY_STACKS_ID: StacksTxId = StacksTxId([ /// The system is bootstrapped by emitting the CreateAssetContract task. pub async fn run(config: Config) { let (tx, mut rx) = mpsc::channel::(128); // TODO: Make capacity configurable - let bitcoin_client = BitcoinClient::new(config.clone()) - .expect("Failed to instantiate bitcoin client"); + let electrum_blockchain = + ElectrumBlockchain::from_config(&ElectrumBlockchainConfig { + url: config.electrum_node_url.to_string(), + socks5: None, + retry: 3, + timeout: Some(10), + stop_gap: 10, + validate_domain: false, + }) + .unwrap(); + + let bitcoin_client = BitcoinClient::new( + config.electrum_node_url.clone(), + electrum_blockchain, + config.bitcoin_credentials.clone(), + ) + .expect("Failed to instantiate bitcoin client"); let stacks_client: LockedClient = StacksClient::new(config.clone(), reqwest::Client::new()).into(); @@ -62,9 +82,10 @@ pub async fn run(config: Config) { // Bootstrap for task in bootstrap_tasks { + let bitcoin_client = bitcoin_client.clone(); spawn( config.clone(), - bitcoin_client.clone(), + bitcoin_client, stacks_client.clone(), task, tx.clone(), @@ -419,6 +440,7 @@ async fn fulfill_asset( ) .expect("Could not create withdrawal fulfillment outputs"); + tokio::time::sleep(Duration::from_secs(3)).await; let txid = bitcoin_client .sign_and_broadcast(outputs.to_vec()) .await diff --git a/romeo/src/test/mod.rs b/romeo/src/test/mod.rs new file mode 100644 index 00000000..08084e2d --- /dev/null +++ b/romeo/src/test/mod.rs @@ -0,0 +1,3 @@ +#![cfg(test)] + +pub const MNEMONIC: [&str;1] = ["twice kind fence tip hidden tilt action fragile skin nothing glory cousin green tomorrow spring wrist shed math olympic multiply hip blue scout claw"];