From dfc83143aff8acf5ebbe7b9b2d443431553fef74 Mon Sep 17 00:00:00 2001 From: Philippe McLean Date: Thu, 19 Feb 2026 10:56:47 -0800 Subject: [PATCH 1/4] fix(daemon): run parallel RPC requests on rpc_threads pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rpc_threads.install() was wrapping the lazy iterator *construction* in requests_iter(), but parallel work only executes when the iterator is consumed (.collect()). By the time collect() ran in requests(), the install scope had ended and Rayon dispatched onto the global pool. Move install() to wrap the collect() call in requests() (and the equivalent in get_transactions()), so the actual parallel work — and the per-thread DAEMON_INSTANCE TCP connections — run on the dedicated rpc_threads pool as intended. NOTE: this fix is required for parallel integration tests to work correctly. Without it, concurrent tests share global-pool threads whose DAEMON_INSTANCE thread-locals were initialised by a different test's daemon, causing cross-test RPC connection pollution. --- src/daemon.rs | 65 ++++++++++++++++++++++++++++----------------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index cf381fa8d..500ee1ed1 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -523,28 +523,32 @@ impl Daemon { // buffering the replies into a vector. If any of the requests fail, processing is terminated and an Err is returned. #[trace] fn requests(&self, method: &str, params_list: Vec) -> Result> { - self.requests_iter(method, params_list).collect() + self.rpc_threads + .install(|| self.requests_iter(method, params_list).collect()) } // Send requests in parallel over multiple RPC connections, iterating over the results without buffering them. // Errors are included in the iterator and do not terminate other pending requests. + // + // IMPORTANT: The returned parallel iterator must be collected inside self.rpc_threads.install() + // to ensure it runs on the daemon's own thread pool, not the global rayon pool. This is necessary + // because the per-thread DAEMON_INSTANCE thread-locals would otherwise be shared across different + // daemon instances in the same process (e.g. during parallel tests). #[trace] fn requests_iter<'a>( &'a self, method: &'a str, params_list: Vec, ) -> impl ParallelIterator> + IndexedParallelIterator + 'a { - self.rpc_threads.install(move || { - params_list.into_par_iter().map(move |params| { - // Store a local per-thread Daemon, each with its own TCP connection. These will - // get initialized as necessary for the `rpc_threads` pool thread managed by rayon. - thread_local!(static DAEMON_INSTANCE: OnceCell = OnceCell::new()); - - DAEMON_INSTANCE.with(|daemon| { - daemon - .get_or_init(|| self.retry_reconnect()) - .retry_request(&method, ¶ms) - }) + params_list.into_par_iter().map(move |params| { + // Store a local per-thread Daemon, each with its own TCP connection. These will + // get initialized as necessary for the `rpc_threads` pool thread managed by rayon. + thread_local!(static DAEMON_INSTANCE: OnceCell = OnceCell::new()); + + DAEMON_INSTANCE.with(|daemon| { + daemon + .get_or_init(|| self.retry_reconnect()) + .retry_request(&method, ¶ms) }) }) } @@ -647,20 +651,22 @@ impl Daemon { .map(|txhash| json!([txhash, /*verbose=*/ false])) .collect(); - self.requests_iter("getrawtransaction", params_list) - .zip(txids) - .filter_map(|(res, txid)| match res { - Ok(val) => Some(tx_from_value(val).map(|tx| (**txid, tx))), - // Ignore 'tx not found' errors - Err(Error(ErrorKind::RpcError(code, _, _), _)) - if code == RPC_INVALID_ADDRESS_OR_KEY => - { - None - } - // Terminate iteration if any other errors are encountered - Err(e) => Some(Err(e)), - }) - .collect() + self.rpc_threads.install(|| { + self.requests_iter("getrawtransaction", params_list) + .zip(txids) + .filter_map(|(res, txid)| match res { + Ok(val) => Some(tx_from_value(val).map(|tx| (**txid, tx))), + // Ignore 'tx not found' errors + Err(Error(ErrorKind::RpcError(code, _, _), _)) + if code == RPC_INVALID_ADDRESS_OR_KEY => + { + None + } + // Terminate iteration if any other errors are encountered + Err(e) => Some(Err(e)), + }) + .collect() + }) } #[trace] @@ -773,11 +779,12 @@ impl Daemon { result.append(&mut headers); - info!("downloaded {}/{} block headers ({:.0}%)", + info!( + "downloaded {}/{} block headers ({:.0}%)", result.len(), tip_height, - result.len() as f32 / tip_height as f32 * 100.0); - + result.len() as f32 / tip_height as f32 * 100.0 + ); } let mut blockhash = *DEFAULT_BLOCKHASH; From f7a84749e26db089e26e3afb396fe2750a8b9888 Mon Sep 17 00:00:00 2001 From: Philippe McLean Date: Thu, 19 Feb 2026 10:57:11 -0800 Subject: [PATCH 2/4] test(infra): make test runner and port allocation more robust - Use TcpListener instead of UdpSocket for port allocation to avoid races between parallel test instances; track allocated ports in a process-wide HashSet to prevent reuse within the same test run - Poll the REST and Electrum servers until they accept connections before returning, eliminating timing-dependent startup --- tests/common.rs | 45 +++++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/tests/common.rs b/tests/common.rs index 3662920a1..fcb2dc095 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -19,7 +19,7 @@ use noded::bitcoincore_rpc::{self, RpcApi}; use electrs::{ chain::{Address, BlockHash, Network, Txid}, - config::Config, + config::{Config, RpcLogging}, daemon::Daemon, electrum::RPC as ElectrumRPC, metrics::Metrics, @@ -27,7 +27,6 @@ use electrs::{ rest, signal::Waiter, }; -use electrs::config::RpcLogging; pub struct TestRunner { config: Arc, @@ -290,23 +289,33 @@ impl bitcoincore_rpc::RpcApi for TestRunner { pub fn init_rest_tester() -> Result<(rest::Handle, net::SocketAddr, TestRunner)> { let tester = TestRunner::new()?; + let addr = tester.config.http_addr; let rest_server = rest::start(Arc::clone(&tester.config), Arc::clone(&tester.query)); - log::info!("REST server running on {}", tester.config.http_addr); - Ok((rest_server, tester.config.http_addr, tester)) + wait_for_tcp(addr, "REST"); + Ok((rest_server, addr, tester)) } pub fn init_electrum_tester() -> Result<(ElectrumRPC, net::SocketAddr, TestRunner)> { let tester = TestRunner::new()?; + let addr = tester.config.electrum_rpc_addr; let electrum_server = ElectrumRPC::start( Arc::clone(&tester.config), Arc::clone(&tester.query), &tester.metrics, Arc::clone(&tester.salt_rwlock), ); - log::info!( - "Electrum server running on {}", - tester.config.electrum_rpc_addr - ); - Ok((electrum_server, tester.config.electrum_rpc_addr, tester)) + wait_for_tcp(addr, "Electrum"); + Ok((electrum_server, addr, tester)) +} + +fn wait_for_tcp(addr: net::SocketAddr, name: &str) { + for _ in 0..50 { + if net::TcpStream::connect(addr).is_ok() { + log::info!("{} server running on {}", name, addr); + return; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + panic!("{} server failed to start on {}", name, addr); } #[cfg(not(feature = "liquid"))] @@ -348,9 +357,21 @@ fn init_log() -> StdErrLog { } fn rand_available_addr() -> net::SocketAddr { - // note this has a potential but unlikely race condition, if the port is grabbed before the caller binds it - let socket = net::UdpSocket::bind("127.0.0.1:0").unwrap(); - socket.local_addr().unwrap() + use std::collections::HashSet; + use std::sync::Mutex; + + lazy_static::lazy_static! { + static ref USED_PORTS: Mutex> = Mutex::new(HashSet::new()); + } + + loop { + let mut used = USED_PORTS.lock().unwrap(); + let socket = net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = socket.local_addr().unwrap(); + if used.insert(addr.port()) { + return addr; + } + } } error_chain::error_chain! { From d6a9a238964b87248fce544a60005f0da3918fdd Mon Sep 17 00:00:00 2001 From: Philippe McLean Date: Thu, 19 Feb 2026 10:58:24 -0800 Subject: [PATCH 3/4] test(rest): split monolithic test into focused per-endpoint tests Break test_rest() into individual test functions, each covering a specific REST endpoint, with assertions on concrete field values rather than just shape checks. --- tests/rest.rs | 1654 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 1150 insertions(+), 504 deletions(-) diff --git a/tests/rest.rs b/tests/rest.rs index 8cb7a4583..921c82333 100644 --- a/tests/rest.rs +++ b/tests/rest.rs @@ -1,7 +1,10 @@ +use bitcoin::hashes::{sha256, Hash}; use bitcoin::hex::FromHex; use bitcoind::bitcoincore_rpc::RpcApi; use serde_json::Value; use std::collections::HashSet; +use std::io::Read; +use std::net; #[cfg(not(feature = "liquid"))] use {bitcoin::Amount, serde_json::from_value}; @@ -12,62 +15,169 @@ pub mod common; use common::Result; +fn get(rest_addr: net::SocketAddr, path: &str) -> std::result::Result { + ureq::get(&format!("http://{}{}", rest_addr, path)).call() +} + +fn get_json(rest_addr: net::SocketAddr, path: &str) -> Result { + Ok(get(rest_addr, path)?.into_json::()?) +} + +fn get_plain(rest_addr: net::SocketAddr, path: &str) -> Result { + Ok(get(rest_addr, path)?.into_string()?) +} + #[test] -fn test_rest() -> Result<()> { +fn test_rest_tx() -> Result<()> { let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); - let get = |path: &str| ureq::get(&format!("http://{}{}", rest_addr, path)).call(); - let get_json = |path: &str| -> Result { Ok(get(path)?.into_json::()?) }; - let get_plain = |path: &str| -> Result { Ok(get(path)?.into_string()?) }; - // Send transaction and confirm it let addr1 = tester.newaddress()?; let txid1_confirmed = tester.send(&addr1, "1.19123 BTC".parse().unwrap())?; tester.mine()?; + let mine_height = tester.get_block_count()?; // Send transaction and leave it unconfirmed let txid2_mempool = tester.send(&addr1, "0.7113 BTC".parse().unwrap())?; // Test GET /tx/:txid - let res = get_json(&format!("/tx/{}", txid1_confirmed))?; + let res = get_json(rest_addr, &format!("/tx/{}", txid1_confirmed))?; log::debug!("tx: {:#?}", res); + + // Verify TransactionValue fields with actual values + assert_eq!( + res["txid"].as_str(), + Some(txid1_confirmed.to_string().as_str()) + ); + assert_eq!(res["version"].as_u64(), Some(2)); + assert!(res["locktime"].as_u64().is_some()); + assert!(res["size"].as_u64().unwrap() > 0); + assert!(res["weight"].as_u64().unwrap() > 0); + assert!(res["fee"].as_u64().unwrap() > 0); + #[cfg(feature = "liquid")] + { + assert_eq!(res["discount_vsize"].as_u64().unwrap(), 228); + assert_eq!(res["discount_weight"].as_u64().unwrap(), 912); + } + + // Verify status on the TransactionValue itself + assert_eq!(res["status"]["confirmed"].as_bool(), Some(true)); + assert_eq!(res["status"]["block_height"].as_u64(), Some(mine_height)); + assert!(res["status"]["block_hash"].is_string()); + assert!(res["status"]["block_time"].as_u64().unwrap() > 0); + + // Verify vout fields and find our target output let outs = res["vout"].as_array().expect("array of outs"); assert!(outs.iter().any(|vout| { vout["scriptpubkey_address"].as_str() == Some(&addr1.to_string()) && vout["value"].as_u64() == Some(119123000) })); - #[cfg(feature = "liquid")] + for vout in outs { + assert!(vout["scriptpubkey"].is_string()); + assert!(vout["scriptpubkey_asm"].is_string()); + assert!(vout["scriptpubkey_type"].is_string()); + } + // Verify our target output's scriptpubkey_type (Bitcoin uses segwit address types) + #[cfg(not(feature = "liquid"))] { - assert_eq!(res["discount_vsize"].as_u64().unwrap(), 228); - assert_eq!(res["discount_weight"].as_u64().unwrap(), 912); + let target_vout = outs + .iter() + .find(|v| v["scriptpubkey_address"].as_str() == Some(&addr1.to_string())) + .unwrap(); + let spk_type = target_vout["scriptpubkey_type"].as_str().unwrap(); + assert!( + spk_type == "v0_p2wpkh" || spk_type == "v1_p2tr", + "unexpected scriptpubkey_type: {}", + spk_type + ); } - // Test GET /tx/:txid/status - let res = get_json(&format!("/tx/{}/status", txid1_confirmed))?; + // Verify vin fields (non-coinbase input) + let vin0 = &res["vin"][0]; + assert!(vin0["txid"].is_string()); + assert!(vin0["vout"].is_u64()); + assert_eq!(vin0["is_coinbase"].as_bool(), Some(false)); + assert!(vin0["sequence"].as_u64().is_some()); + assert!(vin0["scriptsig"].is_string()); + assert!(vin0["scriptsig_asm"].is_string()); + // prevout should be present for non-coinbase inputs + assert!(vin0["prevout"].is_object()); + assert!(vin0["prevout"]["scriptpubkey"].is_string()); + assert!(vin0["prevout"]["scriptpubkey_type"].is_string()); + #[cfg(not(feature = "liquid"))] + assert!(vin0["prevout"]["value"].as_u64().unwrap() > 0); + + // Verify coinbase tx input + let block_hash = res["status"]["block_hash"].as_str().unwrap(); + let block_txs = get_json(rest_addr, &format!("/block/{}/txs", block_hash))?; + let coinbase_tx = &block_txs.as_array().unwrap()[0]; + let cb_vin = &coinbase_tx["vin"][0]; + assert_eq!(cb_vin["is_coinbase"].as_bool(), Some(true)); + assert!(cb_vin["scriptsig"].is_string()); + assert!(cb_vin["scriptsig_asm"].is_string()); + assert!(cb_vin["prevout"].is_null()); + + // Test GET /tx/:txid/status (confirmed) + let res = get_json(rest_addr, &format!("/tx/{}/status", txid1_confirmed))?; assert_eq!(res["confirmed"].as_bool(), Some(true)); - assert_eq!(res["block_height"].as_u64(), Some(102)); + assert_eq!(res["block_height"].as_u64(), Some(mine_height)); + assert!(res["block_hash"].is_string()); + assert!(res["block_time"].as_u64().unwrap() > 0); - let res = get_json(&format!("/tx/{}/status", txid2_mempool))?; + // Test GET /tx/:txid/status (unconfirmed) + let res = get_json(rest_addr, &format!("/tx/{}/status", txid2_mempool))?; assert_eq!(res["confirmed"].as_bool(), Some(false)); assert_eq!(res["block_height"].as_u64(), None); + assert!(res["block_hash"].is_null()); + assert!(res["block_time"].is_null()); + + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_address() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let addr1 = tester.newaddress()?; + let txid1_confirmed = tester.send(&addr1, "1.19123 BTC".parse().unwrap())?; + tester.mine()?; + + let txid2_mempool = tester.send(&addr1, "0.7113 BTC".parse().unwrap())?; // Test GET /address/:address - let res = get_json(&format!("/address/{}", addr1))?; + let res = get_json(rest_addr, &format!("/address/{}", addr1))?; + assert_eq!(res["address"].as_str(), Some(addr1.to_string().as_str())); + + // chain_stats: 1 confirmed funding tx, nothing spent + assert_eq!(res["chain_stats"]["tx_count"].as_u64(), Some(1)); assert_eq!(res["chain_stats"]["funded_txo_count"].as_u64(), Some(1)); + assert_eq!(res["chain_stats"]["spent_txo_count"].as_u64(), Some(0)); #[cfg(not(feature = "liquid"))] - assert_eq!( - res["chain_stats"]["funded_txo_sum"].as_u64(), - Some(119123000) - ); + { + assert_eq!( + res["chain_stats"]["funded_txo_sum"].as_u64(), + Some(119123000) + ); + assert_eq!(res["chain_stats"]["spent_txo_sum"].as_u64(), Some(0)); + } + + // mempool_stats: 1 unconfirmed funding tx; the wallet may also spend + // addr1's confirmed UTXO as an input, so spent_txo_count can be 0 or 1 + assert!(res["mempool_stats"]["tx_count"].as_u64().unwrap() >= 1); assert_eq!(res["mempool_stats"]["funded_txo_count"].as_u64(), Some(1)); + assert!(res["mempool_stats"]["spent_txo_count"].is_u64()); #[cfg(not(feature = "liquid"))] - assert_eq!( - res["mempool_stats"]["funded_txo_sum"].as_u64(), - Some(71130000) - ); + { + assert_eq!( + res["mempool_stats"]["funded_txo_sum"].as_u64(), + Some(71130000) + ); + assert!(res["mempool_stats"]["spent_txo_sum"].is_u64()); + } // Test GET /address/:address/txs - let res = get_json(&format!("/address/{}/txs", addr1))?; + let res = get_json(rest_addr, &format!("/address/{}/txs", addr1))?; let txs = res.as_array().expect("array of transactions"); let mut txids = txs .iter() @@ -79,34 +189,42 @@ fn test_rest() -> Result<()> { // Test GET /address-prefix/:prefix let addr1_prefix = &addr1.to_string()[0..8]; - let res = get_json(&format!("/address-prefix/{}", addr1_prefix))?; + let res = get_json(rest_addr, &format!("/address-prefix/{}", addr1_prefix))?; let found = res.as_array().expect("array of matching addresses"); assert_eq!(found.len(), 1); assert_eq!(found[0].as_str(), Some(addr1.to_string().as_str())); + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_blocks() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + // Test GET /blocks/tip/hash let bestblockhash = tester.node_client().get_best_block_hash()?; - let res = get_plain("/blocks/tip/hash")?; + let res = get_plain(rest_addr, "/blocks/tip/hash")?; assert_eq!(res, bestblockhash.to_string()); let bestblockhash = tester.mine()?; - let res = get_plain("/blocks/tip/hash")?; + let res = get_plain(rest_addr, "/blocks/tip/hash")?; assert_eq!(res, bestblockhash.to_string()); // Test GET /blocks/tip/height let bestblockheight = tester.node_client().get_block_count()?; - let res = get_plain("/blocks/tip/height")?; + let res = get_plain(rest_addr, "/blocks/tip/height")?; assert_eq!( res.parse::().expect("tip block height as an int"), bestblockheight ); // Test GET /block-height/:height - let res = get_plain(&format!("/block-height/{}", bestblockheight))?; + let res = get_plain(rest_addr, &format!("/block-height/{}", bestblockheight))?; assert_eq!(res, bestblockhash.to_string()); // Test GET /blocks - let res = get_json("/blocks")?; + let res = get_json(rest_addr, "/blocks")?; let last_blocks = res.as_array().unwrap(); assert_eq!(last_blocks.len(), 10); // limited to 10 per page assert_eq!( @@ -114,19 +232,62 @@ fn test_rest() -> Result<()> { Some(bestblockhash.to_string().as_str()) ); + // Verify first block (tip) has correct height + assert_eq!( + last_blocks[0]["height"].as_u64(), + Some(bestblockheight) + ); + + // Verify block list entries have all BlockValue fields with value checks + for block in last_blocks { + assert!(block["id"].is_string()); + assert!(block["height"].is_u64()); + assert!(block["version"].is_u64()); + assert!(block["timestamp"].as_u64().unwrap() > 0); + assert!(block["tx_count"].as_u64().unwrap() >= 1); // coinbase at minimum + assert!(block["size"].as_u64().unwrap() > 0); + assert!(block["weight"].as_u64().unwrap() > 0); + assert!(block["merkle_root"].is_string()); + assert!(block["mediantime"].as_u64().unwrap() > 0); + #[cfg(not(feature = "liquid"))] + { + assert!(block["nonce"].is_u64()); + assert!(block["bits"].is_u64()); + assert!(block["difficulty"].is_f64()); + } + } + + // Verify previousblockhash links blocks together correctly + for i in 0..last_blocks.len() - 1 { + assert_eq!( + last_blocks[i]["previousblockhash"].as_str(), + last_blocks[i + 1]["id"].as_str() + ); + } + let bestblockhash = tester.mine()?; - let res = get_json("/blocks")?; + let res = get_json(rest_addr, "/blocks")?; let last_blocks = res.as_array().unwrap(); assert_eq!( last_blocks[0]["id"].as_str(), Some(bestblockhash.to_string().as_str()) ); + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_block() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let addr1 = tester.newaddress()?; + // Test GET /block/:hash let txid = tester.send(&addr1, "0.98765432 BTC".parse().unwrap())?; let blockhash = tester.mine()?; - let res = get_json(&format!("/block/{}", blockhash))?; + let res = get_json(rest_addr, &format!("/block/{}", blockhash))?; assert_eq!(res["id"].as_str(), Some(blockhash.to_string().as_str())); assert_eq!( res["height"].as_u64(), @@ -134,8 +295,32 @@ fn test_rest() -> Result<()> { ); assert_eq!(res["tx_count"].as_u64(), Some(2)); + // Cross-reference BlockValue fields against bitcoind's getblockheader + let node_header: Value = + tester.call("getblockheader", &[blockhash.to_string().into()])?; + assert_eq!(res["version"].as_u64(), node_header["version"].as_u64()); + assert_eq!(res["timestamp"].as_u64(), node_header["time"].as_u64()); + assert_eq!( + res["merkle_root"].as_str(), + node_header["merkleroot"].as_str() + ); + assert_eq!( + res["previousblockhash"].as_str(), + node_header["previousblockhash"].as_str() + ); + assert_eq!(res["mediantime"].as_u64(), node_header["mediantime"].as_u64()); + assert!(res["size"].as_u64().unwrap() > 0); + assert!(res["weight"].as_u64().unwrap() > 0); + #[cfg(not(feature = "liquid"))] + { + assert_eq!(res["nonce"].as_u64(), node_header["nonce"].as_u64()); + // bits is serialized differently (compact target int vs hex string), just check presence + assert!(res["bits"].is_u64()); + assert!(res["difficulty"].is_f64()); + } + // Test GET /block/:hash/raw - let mut res = get(&format!("/block/{}/raw", blockhash))?.into_reader(); + let mut res = get(rest_addr, &format!("/block/{}/raw", blockhash))?.into_reader(); let mut rest_rawblock = Vec::new(); res.read_to_end(&mut rest_rawblock).unwrap(); let node_hexblock = // uses low-level call() to support Elements @@ -143,7 +328,7 @@ fn test_rest() -> Result<()> { assert_eq!(rest_rawblock, Vec::from_hex(&node_hexblock).unwrap()); // Test GET /block/:hash/txs - let res = get_json(&format!("/block/{}/txs", blockhash))?; + let res = get_json(rest_addr, &format!("/block/{}/txs", blockhash))?; let block_txs = res.as_array().expect("list of txs"); assert_eq!(block_txs.len(), 2); assert_eq!(block_txs[0]["vin"][0]["is_coinbase"].as_bool(), Some(true)); @@ -153,33 +338,64 @@ fn test_rest() -> Result<()> { ); // Test GET /block/:hash/txid/:index - let res = get_plain(&format!("/block/{}/txid/1", blockhash))?; + let res = get_plain(rest_addr, &format!("/block/{}/txid/1", blockhash))?; assert_eq!(res, txid.to_string()); + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_mempool() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let addr1 = tester.newaddress()?; + // Test GET /mempool/txids let txid = tester.send(&addr1, "3.21 BTC".parse().unwrap())?; - let res = get_json("/mempool/txids")?; + let res = get_json(rest_addr, "/mempool/txids")?; let mempool_txids = res.as_array().expect("list of txids"); assert_eq!(mempool_txids.len(), 1); assert_eq!(mempool_txids[0].as_str(), Some(txid.to_string().as_str())); tester.send(&addr1, "0.0001 BTC".parse().unwrap())?; - let res = get_json("/mempool/txids")?; + let res = get_json(rest_addr, "/mempool/txids")?; let mempool_txids = res.as_array().expect("list of txids"); assert_eq!(mempool_txids.len(), 2); // Test GET /mempool - assert_eq!(get_json("/mempool")?["count"].as_u64(), Some(2)); + let mempool_stats = get_json(rest_addr, "/mempool")?; + assert_eq!(mempool_stats["count"].as_u64(), Some(2)); + assert!(mempool_stats["vsize"].as_u64().unwrap() > 0); + assert!(mempool_stats["total_fee"].as_u64().unwrap() > 0); + assert!(mempool_stats["fee_histogram"].is_array()); tester.send(&addr1, "0.00022 BTC".parse().unwrap())?; - assert_eq!(get_json("/mempool")?["count"].as_u64(), Some(3)); + assert_eq!(get_json(rest_addr, "/mempool")?["count"].as_u64(), Some(3)); tester.mine()?; - assert_eq!(get_json("/mempool")?["count"].as_u64(), Some(0)); + let mempool_after = get_json(rest_addr, "/mempool")?; + assert_eq!(mempool_after["count"].as_u64(), Some(0)); + assert_eq!(mempool_after["vsize"].as_u64(), Some(0)); + assert_eq!(mempool_after["total_fee"].as_u64(), Some(0)); + assert_eq!( + mempool_after["fee_histogram"].as_array().unwrap().len(), + 0 + ); + + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_broadcast_tx() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let addr1 = tester.newaddress()?; // Test POST /tx let txid = tester.send(&addr1, "9.9 BTC".parse().unwrap())?; - let tx_hex = get_plain(&format!("/tx/{}/hex", txid))?; + let tx_hex = get_plain(rest_addr, &format!("/tx/{}/hex", txid))?; // Re-send the tx created by send(). It'll be accepted again since its still in the mempool. let broadcast1_resp = ureq::post(&format!("http://{}/tx", rest_addr)).send_string(&tx_hex)?; assert_eq!(broadcast1_resp.status(), 200); @@ -190,6 +406,14 @@ fn test_rest() -> Result<()> { let broadcast2_resp = broadcast2_res.unwrap_err().into_response().unwrap(); assert_eq!(broadcast2_resp.status(), 400); + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_package_validation() -> Result<()> { + let (rest_handle, rest_addr, _tester) = common::init_rest_tester().unwrap(); + // Test POST /txs/package - simple validation test // Test with invalid JSON first to verify the endpoint exists let invalid_package_result = ureq::post(&format!("http://{}/txs/package", rest_addr)) @@ -211,305 +435,681 @@ fn test_rest() -> Result<()> { let status = empty_package_resp.status(); assert_eq!(status, 400); - // Reorg handling tests + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_block_status() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let addr1 = tester.newaddress()?; + tester.send(&addr1, "0.5 BTC".parse().unwrap())?; + let blockhash1 = tester.mine()?; + let blockhash2 = tester.mine()?; // tip + + let block_count = tester.node_client().get_block_count()?; + + // Non-tip block should have next_best pointing to next block + let res = get_json(rest_addr, &format!("/block/{}/status", blockhash1))?; + assert_eq!(res["in_best_chain"].as_bool(), Some(true)); + assert_eq!(res["height"].as_u64(), Some(block_count - 1)); + assert_eq!( + res["next_best"].as_str(), + Some(blockhash2.to_string().as_str()) + ); + + // Tip block should have next_best as null + let res = get_json(rest_addr, &format!("/block/{}/status", blockhash2))?; + assert_eq!(res["in_best_chain"].as_bool(), Some(true)); + assert_eq!(res["height"].as_u64(), Some(block_count)); + assert!(res["next_best"].is_null()); + + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_block_txids() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let addr1 = tester.newaddress()?; + let txid = tester.send(&addr1, "0.5 BTC".parse().unwrap())?; + let blockhash = tester.mine()?; + + let res = get_json(rest_addr, &format!("/block/{}/txids", blockhash))?; + let txids = res.as_array().expect("array of txids"); + + // Should match tx_count from /block/:hash + let block = get_json(rest_addr, &format!("/block/{}", blockhash))?; + assert_eq!(txids.len(), block["tx_count"].as_u64().unwrap() as usize); + + // First txid should be the coinbase (not our user txid) + assert_ne!( + txids[0].as_str(), + Some(txid.to_string().as_str()), + "first txid should be coinbase, not user tx" + ); + // Our txid should be present + assert!(txids + .iter() + .any(|t| t.as_str() == Some(&txid.to_string()))); + + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_block_header() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let blockhash = tester.mine()?; + let header_hex = get_plain(rest_addr, &format!("/block/{}/header", blockhash))?; + + // Verify it's valid hex + let header_bytes = Vec::from_hex(&header_hex).expect("valid hex"); + assert!(!header_bytes.is_empty()); + + // On Bitcoin, verify the header is 80 bytes and its hash matches the block hash #[cfg(not(feature = "liquid"))] { - let get_conf_height = |txid| -> Result> { - Ok(get_json(&format!("/tx/{}/status", txid))?["block_height"].as_u64()) - }; - let get_chain_stats = |addr| -> Result { - Ok(get_json(&format!("/address/{}", addr))?["chain_stats"].take()) - }; - let get_chain_txs = |addr| -> Result> { - Ok(from_value(get_json(&format!( - "/address/{}/txs/chain", - addr - ))?)?) - }; - let get_outspend = |outpoint: &bitcoin::OutPoint| -> Result { - get_json(&format!("/tx/{}/outspend/{}", outpoint.txid, outpoint.vout)) - }; - - let init_height = tester.node_client().get_block_count()?; - - let address = tester.newaddress()?; - let miner_address = tester.newaddress()?; - - let txid_a = tester.send(&address, Amount::from_sat(100000))?; - let txid_b = tester.send(&address, Amount::from_sat(200000))?; - let txid_c = tester.send(&address, Amount::from_sat(500000))?; - - let tx_a = tester.get_raw_transaction(&txid_a, None)?; - let tx_b = tester.get_raw_transaction(&txid_b, None)?; - let tx_c = tester.get_raw_transaction(&txid_c, None)?; - - // Confirm tx_a, tx_b and tx_c - let blockhash_1 = tester.mine()?; + assert_eq!(header_bytes.len(), 80); + let header: bitcoin::block::Header = + bitcoin::consensus::deserialize(&header_bytes).expect("valid header"); + assert_eq!(header.block_hash().to_string(), blockhash.to_string()); + } - assert_eq!( - get_plain("/blocks/tip/height")?, - (init_height + 1).to_string() - ); - assert_eq!(get_plain("/blocks/tip/hash")?, blockhash_1.to_string()); - assert_eq!(get_conf_height(&txid_a)?, Some(init_height + 1)); - assert_eq!(get_conf_height(&txid_b)?, Some(init_height + 1)); - assert_eq!(get_conf_height(&txid_c)?, Some(init_height + 1)); - assert_eq!( - get_chain_stats(&address)?["funded_txo_sum"].as_u64(), - Some(800000) - ); - assert_eq!(get_chain_txs(&address)?.len(), 3); + rest_handle.stop(); + Ok(()) +} - let c_outspend = get_outspend(&tx_c.input[0].previous_output)?; - assert_eq!( - c_outspend["txid"].as_str(), - Some(txid_c.to_string().as_str()) - ); - assert_eq!( - c_outspend["status"]["block_height"].as_u64(), - Some(init_height + 1) - ); +#[test] +fn test_rest_address_mempool_txs() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); - // Reorg the last block, re-confirm tx_a at the same height - tester.invalidate_block(&blockhash_1)?; - tester.call::( - "generateblock", - &[ - miner_address.to_string().into(), - [txid_a.to_string()].into(), - ], - )?; - // Re-confirm tx_b at a different height - tester.call::( - "generateblock", - &[ - miner_address.to_string().into(), - [txid_b.to_string()].into(), - ], - )?; - // Don't re-confirm tx_c at all + let addr1 = tester.newaddress()?; - let blockhash_2 = tester.get_best_block_hash()?; + // Send tx to address but don't mine + let txid = tester.send(&addr1, "0.5 BTC".parse().unwrap())?; - tester.sync()?; + // Verify it appears in mempool txs + let res = get_json(rest_addr, &format!("/address/{}/txs/mempool", addr1))?; + let txs = res.as_array().expect("array of txs"); + assert_eq!(txs.len(), 1); + assert_eq!(txs[0]["txid"].as_str(), Some(txid.to_string().as_str())); + assert_eq!(txs[0]["status"]["confirmed"].as_bool(), Some(false)); + assert!(txs[0]["fee"].as_u64().unwrap() > 0); - assert_eq!( - get_plain("/blocks/tip/height")?, - (init_height + 2).to_string() - ); - assert_eq!(get_plain("/blocks/tip/hash")?, blockhash_2.to_string()); + // Mine and verify mempool list is now empty + tester.mine()?; + let res = get_json(rest_addr, &format!("/address/{}/txs/mempool", addr1))?; + let txs = res.as_array().expect("array of txs"); + assert!(txs.is_empty()); - // Test address stats (GET /address/:address) - assert_eq!( - get_chain_stats(&address)?["funded_txo_sum"].as_u64(), - Some(300000) - ); + rest_handle.stop(); + Ok(()) +} - // Test address history (GET /address/:address/txs/chain) - let addr_txs = get_chain_txs(&address)?; - assert_eq!(addr_txs.len(), 2); - assert_eq!( - addr_txs[0]["txid"].as_str(), - Some(txid_b.to_string().as_str()) - ); - assert_eq!( - addr_txs[0]["status"]["block_height"].as_u64(), - Some(init_height + 2) - ); - assert_eq!( - addr_txs[1]["txid"].as_str(), - Some(txid_a.to_string().as_str()) - ); - assert_eq!( - addr_txs[1]["status"]["block_height"].as_u64(), - Some(init_height + 1) - ); +#[test] +fn test_rest_address_utxo() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); - // Test transaction status lookup (GET /tx/:txid/status) - assert_eq!(get_conf_height(&txid_a)?, Some(init_height + 1)); - assert_eq!(get_conf_height(&txid_b)?, Some(init_height + 2)); - assert_eq!(get_conf_height(&txid_c)?, None); + let addr1 = tester.newaddress()?; - // Test spend edge lookup (GET /tx/:txid/outspend/:vout) - let a_spends = get_outspend(&tx_a.input[0].previous_output)?; - assert_eq!(a_spends["txid"].as_str(), Some(txid_a.to_string().as_str())); - assert_eq!( - a_spends["status"]["block_height"].as_u64(), - Some(init_height + 1) - ); - let b_spends = get_outspend(&tx_b.input[0].previous_output)?; - assert_eq!(b_spends["txid"].as_str(), Some(txid_b.to_string().as_str())); - assert_eq!( - b_spends["status"]["block_height"].as_u64(), - Some(init_height + 2) - ); - let c_spends = get_outspend(&tx_c.input[0].previous_output)?; - assert_eq!(c_spends["status"]["confirmed"].as_bool(), Some(false)); - - // Test a deeper reorg, all the way back to exclude tx_b - tester.generate_to_address(15, &address)?; - tester.sync()?; - tester.invalidate_block(&blockhash_2)?; - - for _ in 0..20 { - // Mine some empty blocks, intentionally without tx_b - tester.call::( - "generateblock", - &[miner_address.to_string().into(), Vec::::new().into()], - )?; - } - tester.sync()?; + // Send to address and mine - verify confirmed UTXO + let sent_txid = tester.send(&addr1, "0.5 BTC".parse().unwrap())?; + tester.mine()?; + let mine_height = tester.get_block_count()?; - assert_eq!( - get_plain("/blocks/tip/height")?, - (init_height + 21).to_string() - ); - assert_eq!( - get_plain("/blocks/tip/hash")?, - tester.get_best_block_hash()?.to_string() - ); + let res = get_json(rest_addr, &format!("/address/{}/utxo", addr1))?; + let utxos = res.as_array().expect("array of utxos"); + assert_eq!(utxos.len(), 1); + assert_eq!( + utxos[0]["txid"].as_str(), + Some(sent_txid.to_string().as_str()) + ); + assert!(utxos[0]["vout"].is_u64()); + assert_eq!(utxos[0]["status"]["confirmed"].as_bool(), Some(true)); + assert_eq!(utxos[0]["status"]["block_height"].as_u64(), Some(mine_height)); + assert!(utxos[0]["status"]["block_hash"].is_string()); + assert!(utxos[0]["status"]["block_time"].as_u64().unwrap() > 0); + #[cfg(not(feature = "liquid"))] + assert_eq!(utxos[0]["value"].as_u64(), Some(50000000)); + + // Send again without mining - the wallet may spend the existing UTXO as input, + // so we just verify that UTXOs exist and have correct fields + tester.send(&addr1, "0.3 BTC".parse().unwrap())?; + let res = get_json(rest_addr, &format!("/address/{}/utxo", addr1))?; + let utxos = res.as_array().expect("array of utxos"); + assert!(!utxos.is_empty()); + for utxo in utxos { + assert!(utxo["txid"].is_string()); + assert!(utxo["vout"].is_u64()); + assert!(utxo["status"].is_object()); + assert!(utxo["status"]["confirmed"].is_boolean()); + } - assert_eq!( - get_chain_stats(&address)?["funded_txo_sum"].as_u64(), - Some(100000) - ); + rest_handle.stop(); + Ok(()) +} - let addr_txs = get_chain_txs(&address)?; - assert_eq!(addr_txs.len(), 1); - assert_eq!( - addr_txs[0]["txid"].as_str(), - Some(txid_a.to_string().as_str()) - ); - assert_eq!( - addr_txs[0]["status"]["block_height"].as_u64(), - Some(init_height + 1) - ); +#[test] +fn test_rest_scripthash() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); - assert_eq!(get_conf_height(&txid_a)?, Some(init_height + 1)); - assert_eq!(get_conf_height(&txid_b)?, None); - assert_eq!(get_conf_height(&txid_c)?, None); + let addr1 = tester.newaddress()?; + tester.send(&addr1, "0.5 BTC".parse().unwrap())?; + tester.mine()?; + tester.send(&addr1, "0.3 BTC".parse().unwrap())?; // mempool tx + + // Get the scriptpubkey from a tx to addr1 + let addr_txs = get_json(rest_addr, &format!("/address/{}/txs", addr1))?; + let txs = addr_txs.as_array().unwrap(); + let vout = txs[0]["vout"] + .as_array() + .unwrap() + .iter() + .find(|v| v["scriptpubkey_address"].as_str() == Some(&addr1.to_string())) + .expect("vout to our address"); + let scriptpubkey_hex = vout["scriptpubkey"].as_str().unwrap(); + let scriptpubkey_bytes = Vec::from_hex(scriptpubkey_hex).unwrap(); + + // Compute scripthash (SHA256 of scriptpubkey bytes) + let scripthash = sha256::Hash::hash(&scriptpubkey_bytes).to_string(); + + // Verify /scripthash/:hash matches /address/:address + // (the top-level objects differ by "address" vs "scripthash" key, so compare stats) + let addr_stats = get_json(rest_addr, &format!("/address/{}", addr1))?; + let sh_stats = get_json(rest_addr, &format!("/scripthash/{}", scripthash))?; + assert_eq!(addr_stats["chain_stats"], sh_stats["chain_stats"]); + assert_eq!(addr_stats["mempool_stats"], sh_stats["mempool_stats"]); + + // Verify /scripthash/:hash/txs matches /address/:address/txs + let addr_txs = get_json(rest_addr, &format!("/address/{}/txs", addr1))?; + let sh_txs = get_json(rest_addr, &format!("/scripthash/{}/txs", scripthash))?; + assert_eq!(addr_txs, sh_txs); + + // Verify /scripthash/:hash/txs/chain matches /address/:address/txs/chain + let addr_chain = get_json(rest_addr, &format!("/address/{}/txs/chain", addr1))?; + let sh_chain = get_json(rest_addr, &format!("/scripthash/{}/txs/chain", scripthash))?; + assert_eq!(addr_chain, sh_chain); + + // Verify /scripthash/:hash/txs/mempool matches /address/:address/txs/mempool + let addr_mempool = get_json(rest_addr, &format!("/address/{}/txs/mempool", addr1))?; + let sh_mempool = get_json(rest_addr, &format!("/scripthash/{}/txs/mempool", scripthash))?; + assert_eq!(addr_mempool, sh_mempool); + + // Verify /scripthash/:hash/utxo matches /address/:address/utxo + let addr_utxo = get_json(rest_addr, &format!("/address/{}/utxo", addr1))?; + let sh_utxo = get_json(rest_addr, &format!("/scripthash/{}/utxo", scripthash))?; + assert_eq!(addr_utxo, sh_utxo); - let a_spends = get_outspend(&tx_a.input[0].previous_output)?; - assert_eq!( - a_spends["status"]["block_height"].as_u64(), - Some(init_height + 1) - ); - let b_spends = get_outspend(&tx_b.input[0].previous_output)?; - assert_eq!(b_spends["spent"].as_bool(), Some(false)); - let c_spends = get_outspend(&tx_b.input[0].previous_output)?; - assert_eq!(c_spends["spent"].as_bool(), Some(false)); - - // Invalidate the tip with no replacement, shortening the chain by one block - tester.invalidate_block(&tester.get_best_block_hash()?)?; - tester.sync()?; - assert_eq!( - get_plain("/blocks/tip/height")?, - (init_height + 20).to_string() - ); + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_tx_outspends() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); - // Reorg everything back to genesis - tester.invalidate_block(&tester.get_block_hash(1)?)?; - tester.sync()?; + let addr1 = tester.newaddress()?; + let txid = tester.send(&addr1, "0.5 BTC".parse().unwrap())?; + tester.mine()?; + let mine_height = tester.get_block_count()?; + + // Check outspends of a freshly mined tx - outputs should be unspent + let res = get_json(rest_addr, &format!("/tx/{}/outspends", txid))?; + let outspends = res.as_array().expect("array of outspends"); + assert!(!outspends.is_empty()); + for outspend in outspends { + assert_eq!(outspend["spent"].as_bool(), Some(false)); + assert!(outspend["txid"].is_null()); + assert!(outspend["vin"].is_null()); + assert!(outspend["status"].is_null()); + } - assert_eq!(get_plain("/blocks/tip/height")?, 0.to_string()); - assert_eq!( - get_chain_stats(&address)?["funded_txo_sum"].as_u64(), - Some(0) + // The send tx spent some input. Check that the parent tx shows a spent output. + let tx_detail = get_json(rest_addr, &format!("/tx/{}", txid))?; + let spent_txid = tx_detail["vin"][0]["txid"].as_str().unwrap(); + let spent_vout = tx_detail["vin"][0]["vout"].as_u64().unwrap(); + let spent_vin = 0u64; // our tx is the spender, using vin index 0 + + let res = get_json(rest_addr, &format!("/tx/{}/outspends", spent_txid))?; + let outspends = res.as_array().expect("array of outspends"); + let spent_entry = &outspends[spent_vout as usize]; + assert_eq!(spent_entry["spent"].as_bool(), Some(true)); + assert_eq!( + spent_entry["txid"].as_str(), + Some(txid.to_string().as_str()) + ); + assert_eq!(spent_entry["vin"].as_u64(), Some(spent_vin)); + assert_eq!(spent_entry["status"]["confirmed"].as_bool(), Some(true)); + assert_eq!(spent_entry["status"]["block_height"].as_u64(), Some(mine_height)); + assert!(spent_entry["status"]["block_hash"].is_string()); + assert!(spent_entry["status"]["block_time"].as_u64().unwrap() > 0); + + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_tx_merkle_proof() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let addr1 = tester.newaddress()?; + let txid = tester.send(&addr1, "0.5 BTC".parse().unwrap())?; + tester.mine()?; + let mine_height = tester.get_block_count()?; + + let res = get_json(rest_addr, &format!("/tx/{}/merkle-proof", txid))?; + assert_eq!(res["block_height"].as_u64(), Some(mine_height)); + let merkle = res["merkle"].as_array().expect("merkle array"); + assert!(!merkle.is_empty()); + for entry in merkle { + let hex = entry.as_str().expect("merkle entry is string"); + assert_eq!(hex.len(), 64, "merkle hash should be 64 hex chars"); + assert!( + hex.chars().all(|c| c.is_ascii_hexdigit()), + "merkle hash should be valid hex" ); - assert_eq!(get_chain_txs(&address)?.len(), 0); - assert_eq!(get_conf_height(&txid_a)?, None); - assert_eq!(get_conf_height(&txid_b)?, None); - assert_eq!(get_conf_height(&txid_c)?, None); - let a_spends = get_outspend(&tx_a.input[0].previous_output)?; - assert_eq!(a_spends["spent"].as_bool(), Some(false)); - - // Mine some blocks so that the followup tests have some coins to play with - tester.generate_to_address(101, &miner_address)?; - tester.sync()?; } + assert!(res["pos"].as_u64().is_some()); - // bitcoin 28.0 only tests - submitpackage - #[cfg(all(not(feature = "liquid"), feature = "bitcoind_28_0"))] - { - // Test with a real transaction package - create parent-child transactions - // submitpackage requires between 2 and 25 transactions with proper dependencies - let package_addr1 = tester.newaddress()?; - let package_addr2 = tester.newaddress()?; - - // Create parent transaction - let tx1_result = tester.node_client().call::( - "createrawtransaction", - &[ - serde_json::json!([]), - serde_json::json!({package_addr1.to_string(): 0.5}), - ], - )?; - let tx1_unsigned_hex = tx1_result.as_str().expect("raw tx hex").to_string(); - - let tx1_fund_result = tester - .node_client() - .call::("fundrawtransaction", &[serde_json::json!(tx1_unsigned_hex)])?; - let tx1_funded_hex = tx1_fund_result["hex"] - .as_str() - .expect("funded tx hex") - .to_string(); - - let tx1_sign_result = tester.node_client().call::( - "signrawtransactionwithwallet", - &[serde_json::json!(tx1_funded_hex)], + rest_handle.stop(); + Ok(()) +} + +#[cfg(not(feature = "liquid"))] +#[test] +fn test_rest_tx_merkleblock_proof() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let addr1 = tester.newaddress()?; + let txid = tester.send(&addr1, "0.5 BTC".parse().unwrap())?; + tester.mine()?; + + let hex = get_plain(rest_addr, &format!("/tx/{}/merkleblock-proof", txid))?; + assert!(!hex.is_empty()); + // Verify it's valid hex + let bytes = Vec::from_hex(&hex).expect("valid hex"); + assert!(!bytes.is_empty()); + + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_mempool_recent() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let addr1 = tester.newaddress()?; + let txid1 = tester.send(&addr1, "0.5 BTC".parse().unwrap())?; + let txid2 = tester.send(&addr1, "0.3 BTC".parse().unwrap())?; + + let res = get_json(rest_addr, "/mempool/recent")?; + let recent = res.as_array().expect("array of recent txs"); + assert!(recent.len() >= 2); + + for entry in recent { + assert!(entry["txid"].is_string()); + assert!(entry["fee"].as_u64().unwrap() > 0); + assert!(entry["vsize"].as_u64().unwrap() > 0); + #[cfg(not(feature = "liquid"))] + assert!(entry["value"].as_u64().unwrap() > 0); + } + + // Verify our sent txids are included + let recent_txids: HashSet<&str> = recent + .iter() + .map(|e| e["txid"].as_str().unwrap()) + .collect(); + assert!(recent_txids.contains(txid1.to_string().as_str())); + assert!(recent_txids.contains(txid2.to_string().as_str())); + + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_fee_estimates() -> Result<()> { + let (rest_handle, rest_addr, _tester) = common::init_rest_tester().unwrap(); + + let res = get_json(rest_addr, "/fee-estimates")?; + // On regtest, may be empty but should be a JSON object + assert!(res.is_object()); + + rest_handle.stop(); + Ok(()) +} + +#[test] +fn test_rest_broadcast_get() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let addr1 = tester.newaddress()?; + let txid = tester.send(&addr1, "0.5 BTC".parse().unwrap())?; + let tx_hex = get_plain(rest_addr, &format!("/tx/{}/hex", txid))?; + + // Re-send via GET /broadcast?tx=:txhex (legacy endpoint) + let res = get_plain(rest_addr, &format!("/broadcast?tx={}", tx_hex))?; + assert_eq!(res, txid.to_string()); + + rest_handle.stop(); + Ok(()) +} + +#[cfg(not(feature = "liquid"))] +#[test] +fn test_rest_reorg() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let get_conf_height = |txid| -> Result> { + Ok(get_json(rest_addr, &format!("/tx/{}/status", txid))?["block_height"].as_u64()) + }; + let get_chain_stats = |addr| -> Result { + Ok(get_json(rest_addr, &format!("/address/{}", addr))?["chain_stats"].take()) + }; + let get_chain_txs = |addr| -> Result> { + Ok(from_value(get_json( + rest_addr, + &format!("/address/{}/txs/chain", addr), + )?)?) + }; + let get_outspend = |outpoint: &bitcoin::OutPoint| -> Result { + get_json( + rest_addr, + &format!("/tx/{}/outspend/{}", outpoint.txid, outpoint.vout), + ) + }; + + let init_height = tester.node_client().get_block_count()?; + + let address = tester.newaddress()?; + let miner_address = tester.newaddress()?; + + let txid_a = tester.send(&address, Amount::from_sat(100000))?; + let txid_b = tester.send(&address, Amount::from_sat(200000))?; + let txid_c = tester.send(&address, Amount::from_sat(500000))?; + + let tx_a = tester.get_raw_transaction(&txid_a, None)?; + let tx_b = tester.get_raw_transaction(&txid_b, None)?; + let tx_c = tester.get_raw_transaction(&txid_c, None)?; + + // Confirm tx_a, tx_b and tx_c + let blockhash_1 = tester.mine()?; + + assert_eq!( + get_plain(rest_addr, "/blocks/tip/height")?, + (init_height + 1).to_string() + ); + assert_eq!( + get_plain(rest_addr, "/blocks/tip/hash")?, + blockhash_1.to_string() + ); + assert_eq!(get_conf_height(&txid_a)?, Some(init_height + 1)); + assert_eq!(get_conf_height(&txid_b)?, Some(init_height + 1)); + assert_eq!(get_conf_height(&txid_c)?, Some(init_height + 1)); + assert_eq!( + get_chain_stats(&address)?["funded_txo_sum"].as_u64(), + Some(800000) + ); + assert_eq!(get_chain_txs(&address)?.len(), 3); + + let c_outspend = get_outspend(&tx_c.input[0].previous_output)?; + assert_eq!( + c_outspend["txid"].as_str(), + Some(txid_c.to_string().as_str()) + ); + assert_eq!( + c_outspend["status"]["block_height"].as_u64(), + Some(init_height + 1) + ); + + // Reorg the last block, re-confirm tx_a at the same height + tester.invalidate_block(&blockhash_1)?; + tester.call::( + "generateblock", + &[ + miner_address.to_string().into(), + [txid_a.to_string()].into(), + ], + )?; + // Re-confirm tx_b at a different height + tester.call::( + "generateblock", + &[ + miner_address.to_string().into(), + [txid_b.to_string()].into(), + ], + )?; + // Don't re-confirm tx_c at all + + let blockhash_2 = tester.get_best_block_hash()?; + + tester.sync()?; + + assert_eq!( + get_plain(rest_addr, "/blocks/tip/height")?, + (init_height + 2).to_string() + ); + assert_eq!( + get_plain(rest_addr, "/blocks/tip/hash")?, + blockhash_2.to_string() + ); + + // Test address stats (GET /address/:address) + assert_eq!( + get_chain_stats(&address)?["funded_txo_sum"].as_u64(), + Some(300000) + ); + + // Test address history (GET /address/:address/txs/chain) + let addr_txs = get_chain_txs(&address)?; + assert_eq!(addr_txs.len(), 2); + assert_eq!( + addr_txs[0]["txid"].as_str(), + Some(txid_b.to_string().as_str()) + ); + assert_eq!( + addr_txs[0]["status"]["block_height"].as_u64(), + Some(init_height + 2) + ); + assert_eq!( + addr_txs[1]["txid"].as_str(), + Some(txid_a.to_string().as_str()) + ); + assert_eq!( + addr_txs[1]["status"]["block_height"].as_u64(), + Some(init_height + 1) + ); + + // Test transaction status lookup (GET /tx/:txid/status) + assert_eq!(get_conf_height(&txid_a)?, Some(init_height + 1)); + assert_eq!(get_conf_height(&txid_b)?, Some(init_height + 2)); + assert_eq!(get_conf_height(&txid_c)?, None); + + // Test spend edge lookup (GET /tx/:txid/outspend/:vout) + let a_spends = get_outspend(&tx_a.input[0].previous_output)?; + assert_eq!(a_spends["txid"].as_str(), Some(txid_a.to_string().as_str())); + assert_eq!( + a_spends["status"]["block_height"].as_u64(), + Some(init_height + 1) + ); + let b_spends = get_outspend(&tx_b.input[0].previous_output)?; + assert_eq!(b_spends["txid"].as_str(), Some(txid_b.to_string().as_str())); + assert_eq!( + b_spends["status"]["block_height"].as_u64(), + Some(init_height + 2) + ); + let c_spends = get_outspend(&tx_c.input[0].previous_output)?; + assert_eq!(c_spends["status"]["confirmed"].as_bool(), Some(false)); + + // Test a deeper reorg, all the way back to exclude tx_b + tester.generate_to_address(15, &address)?; + tester.sync()?; + tester.invalidate_block(&blockhash_2)?; + + for _ in 0..20 { + // Mine some empty blocks, intentionally without tx_b + tester.call::( + "generateblock", + &[miner_address.to_string().into(), Vec::::new().into()], )?; - let tx1_signed_hex = tx1_sign_result["hex"] - .as_str() - .expect("signed tx hex") - .to_string(); - - // Decode parent transaction to get its txid and find the output to spend - let tx1_decoded = tester - .node_client() - .call::("decoderawtransaction", &[serde_json::json!(tx1_signed_hex)])?; - let tx1_txid = tx1_decoded["txid"].as_str().expect("parent txid"); - - // Find the output going to package_addr1 (the one we want to spend) - let tx1_vouts = tx1_decoded["vout"].as_array().expect("parent vouts"); - let mut spend_vout_index = None; - let mut spend_vout_value = 0u64; - - for (i, vout) in tx1_vouts.iter().enumerate() { - if let Some(script_pub_key) = vout.get("scriptPubKey") { - if let Some(address) = script_pub_key.get("address") { - if address.as_str() == Some(&package_addr1.to_string()) { - spend_vout_index = Some(i); - // Convert from BTC to satoshis - spend_vout_value = - (vout["value"].as_f64().expect("vout value") * 100_000_000.0) as u64; - break; - } + } + tester.sync()?; + + assert_eq!( + get_plain(rest_addr, "/blocks/tip/height")?, + (init_height + 21).to_string() + ); + assert_eq!( + get_plain(rest_addr, "/blocks/tip/hash")?, + tester.get_best_block_hash()?.to_string() + ); + + assert_eq!( + get_chain_stats(&address)?["funded_txo_sum"].as_u64(), + Some(100000) + ); + + let addr_txs = get_chain_txs(&address)?; + assert_eq!(addr_txs.len(), 1); + assert_eq!( + addr_txs[0]["txid"].as_str(), + Some(txid_a.to_string().as_str()) + ); + assert_eq!( + addr_txs[0]["status"]["block_height"].as_u64(), + Some(init_height + 1) + ); + + assert_eq!(get_conf_height(&txid_a)?, Some(init_height + 1)); + assert_eq!(get_conf_height(&txid_b)?, None); + assert_eq!(get_conf_height(&txid_c)?, None); + + let a_spends = get_outspend(&tx_a.input[0].previous_output)?; + assert_eq!( + a_spends["status"]["block_height"].as_u64(), + Some(init_height + 1) + ); + let b_spends = get_outspend(&tx_b.input[0].previous_output)?; + assert_eq!(b_spends["spent"].as_bool(), Some(false)); + let c_spends = get_outspend(&tx_b.input[0].previous_output)?; + assert_eq!(c_spends["spent"].as_bool(), Some(false)); + + // Invalidate the tip with no replacement, shortening the chain by one block + tester.invalidate_block(&tester.get_best_block_hash()?)?; + tester.sync()?; + assert_eq!( + get_plain(rest_addr, "/blocks/tip/height")?, + (init_height + 20).to_string() + ); + + // Reorg everything back to genesis + tester.invalidate_block(&tester.get_block_hash(1)?)?; + tester.sync()?; + + assert_eq!(get_plain(rest_addr, "/blocks/tip/height")?, 0.to_string()); + assert_eq!( + get_chain_stats(&address)?["funded_txo_sum"].as_u64(), + Some(0) + ); + assert_eq!(get_chain_txs(&address)?.len(), 0); + assert_eq!(get_conf_height(&txid_a)?, None); + assert_eq!(get_conf_height(&txid_b)?, None); + assert_eq!(get_conf_height(&txid_c)?, None); + let a_spends = get_outspend(&tx_a.input[0].previous_output)?; + assert_eq!(a_spends["spent"].as_bool(), Some(false)); + + rest_handle.stop(); + Ok(()) +} + +// bitcoin 28.0 only tests - submitpackage +#[cfg(all(not(feature = "liquid"), feature = "bitcoind_28_0"))] +#[test] +fn test_rest_submit_package() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + // Test with a real transaction package - create parent-child transactions + // submitpackage requires between 2 and 25 transactions with proper dependencies + let package_addr1 = tester.newaddress()?; + let package_addr2 = tester.newaddress()?; + + // Create parent transaction + let tx1_result = tester.node_client().call::( + "createrawtransaction", + &[ + serde_json::json!([]), + serde_json::json!({package_addr1.to_string(): 0.5}), + ], + )?; + let tx1_unsigned_hex = tx1_result.as_str().expect("raw tx hex").to_string(); + + let tx1_fund_result = tester + .node_client() + .call::("fundrawtransaction", &[serde_json::json!(tx1_unsigned_hex)])?; + let tx1_funded_hex = tx1_fund_result["hex"] + .as_str() + .expect("funded tx hex") + .to_string(); + + let tx1_sign_result = tester.node_client().call::( + "signrawtransactionwithwallet", + &[serde_json::json!(tx1_funded_hex)], + )?; + let tx1_signed_hex = tx1_sign_result["hex"] + .as_str() + .expect("signed tx hex") + .to_string(); + + // Decode parent transaction to get its txid and find the output to spend + let tx1_decoded = tester + .node_client() + .call::("decoderawtransaction", &[serde_json::json!(tx1_signed_hex)])?; + let tx1_txid = tx1_decoded["txid"].as_str().expect("parent txid"); + + // Find the output going to package_addr1 (the one we want to spend) + let tx1_vouts = tx1_decoded["vout"].as_array().expect("parent vouts"); + let mut spend_vout_index = None; + let mut spend_vout_value = 0u64; + + for (i, vout) in tx1_vouts.iter().enumerate() { + if let Some(script_pub_key) = vout.get("scriptPubKey") { + if let Some(address) = script_pub_key.get("address") { + if address.as_str() == Some(&package_addr1.to_string()) { + spend_vout_index = Some(i); + // Convert from BTC to satoshis + spend_vout_value = + (vout["value"].as_f64().expect("vout value") * 100_000_000.0) as u64; + break; } } } + } - let spend_vout_index = spend_vout_index.expect("Could not find output to spend"); - - // Create child transaction that spends from parent - // Leave some satoshis for fee (e.g., 1000 sats) - let child_output_value = spend_vout_value - 1000; - let child_output_btc = child_output_value as f64 / 100_000_000.0; - - let tx2_result = tester.node_client().call::( - "createrawtransaction", - &[ - serde_json::json!([{ - "txid": tx1_txid, - "vout": spend_vout_index - }]), - serde_json::json!({package_addr2.to_string(): child_output_btc}), - ], - )?; - let tx2_unsigned_hex = tx2_result.as_str().expect("raw tx hex").to_string(); + let spend_vout_index = spend_vout_index.expect("Could not find output to spend"); + + // Create child transaction that spends from parent + // Leave some satoshis for fee (e.g., 1000 sats) + let child_output_value = spend_vout_value - 1000; + let child_output_btc = child_output_value as f64 / 100_000_000.0; - // Sign the child transaction - // We need to provide the parent transaction's output details for signing - let tx2_sign_result = tester.node_client().call::( + let tx2_result = tester.node_client().call::( + "createrawtransaction", + &[ + serde_json::json!([{ + "txid": tx1_txid, + "vout": spend_vout_index + }]), + serde_json::json!({package_addr2.to_string(): child_output_btc}), + ], + )?; + let tx2_unsigned_hex = tx2_result.as_str().expect("raw tx hex").to_string(); + + // Sign the child transaction + // We need to provide the parent transaction's output details for signing + let tx2_sign_result = tester.node_client().call::( "signrawtransactionwithwallet", &[ serde_json::json!(tx2_unsigned_hex), @@ -521,210 +1121,256 @@ fn test_rest() -> Result<()> { }]) ], )?; - let tx2_signed_hex = tx2_sign_result["hex"] - .as_str() - .expect("signed tx hex") - .to_string(); - - // Debug: try calling submitpackage directly to see the result - eprintln!("Trying submitpackage directly with parent-child transactions..."); - let direct_result = tester.node_client().call::( - "submitpackage", - &[serde_json::json!([ - tx1_signed_hex.clone(), - tx2_signed_hex.clone() - ])], - ); - match direct_result { - Ok(result) => { - eprintln!("Direct submitpackage succeeded: {:#?}", result); - } - Err(e) => { - eprintln!("Direct submitpackage failed: {:?}", e); - } + let tx2_signed_hex = tx2_sign_result["hex"] + .as_str() + .expect("signed tx hex") + .to_string(); + + // Debug: try calling submitpackage directly to see the result + log::debug!("Trying submitpackage directly with parent-child transactions..."); + let direct_result = tester.node_client().call::( + "submitpackage", + &[serde_json::json!([ + tx1_signed_hex.clone(), + tx2_signed_hex.clone() + ])], + ); + match direct_result { + Ok(result) => { + log::debug!("Direct submitpackage succeeded: {:#?}", result); + } + Err(e) => { + log::debug!("Direct submitpackage failed: {:?}", e); } + } - // Now submit this transaction package via the package endpoint - let package_json = - serde_json::json!([tx1_signed_hex.clone(), tx2_signed_hex.clone()]).to_string(); - let package_result = ureq::post(&format!("http://{}/txs/package", rest_addr)) - .set("Content-Type", "application/json") - .send_string(&package_json); + // Now submit this transaction package via the package endpoint + let package_json = + serde_json::json!([tx1_signed_hex.clone(), tx2_signed_hex.clone()]).to_string(); + let package_result = ureq::post(&format!("http://{}/txs/package", rest_addr)) + .set("Content-Type", "application/json") + .send_string(&package_json); - let package_resp = package_result.unwrap(); - assert_eq!(package_resp.status(), 200); - let package_result = package_resp.into_json::()?; + let package_resp = package_result.unwrap(); + assert_eq!(package_resp.status(), 200); + let package_result = package_resp.into_json::()?; - // Verify the response structure - assert!(package_result["tx-results"].is_object()); - assert!(package_result["package_msg"].is_string()); + // Verify the response structure + assert!(package_result["tx-results"].is_object()); + assert!(package_result["package_msg"].is_string()); - let tx_results = package_result["tx-results"].as_object().unwrap(); - assert_eq!(tx_results.len(), 2); + let tx_results = package_result["tx-results"].as_object().unwrap(); + assert_eq!(tx_results.len(), 2); - // The transactions should be processed (whether accepted or rejected) - assert!(!tx_results.is_empty()); - } + // The transactions should be processed (whether accepted or rejected) + assert!(!tx_results.is_empty()); - // Elements-only tests - #[cfg(feature = "liquid")] - { - // Test confidential transactions - { - let (c_addr, uc_addr) = tester.ct_newaddress()?; - let txid = tester.send(&c_addr, "3.5 BTC".parse().unwrap())?; - tester.mine()?; - - let tx = get_json(&format!("/tx/{}", txid))?; - log::debug!("blinded tx = {:#?}", tx); - assert_eq!(tx["status"]["confirmed"].as_bool(), Some(true)); - let outs = tx["vout"].as_array().expect("array of outs"); - let vout = outs - .iter() - .find(|vout| vout["scriptpubkey_address"].as_str() == Some(&uc_addr.to_string())) - .expect("our output"); - assert!(vout["value"].is_null()); - assert!(vout["valuecommitment"].is_string()); - assert!(vout["assetcommitment"].is_string()); - } + rest_handle.stop(); + Ok(()) +} - // Test blinded asset issuance - { - use bitcoin::hashes::{sha256, Hash}; - let contract_hash = sha256::Hash::hash(&[0x11, 0x22, 0x33, 0x44]).to_string(); - let contract_hash = contract_hash.as_str(); - let issuance = tester.node_client().call::( - "issueasset", - &[1.5.into(), 0.into(), true.into(), contract_hash.into()], - )?; - tester.mine()?; - - let assetid = issuance["asset"].as_str().expect("asset id"); - let issuance_txid = issuance["txid"].as_str().expect("issuance txid"); - - // Test GET /asset/:assetid - let asset = get_json(&format!("/asset/{}", assetid))?; - let stats = &asset["chain_stats"]; - assert_eq!(asset["asset_id"].as_str(), Some(assetid)); - assert_eq!(asset["issuance_txin"]["txid"].as_str(), Some(issuance_txid)); - assert_eq!(asset["contract_hash"].as_str(), Some(contract_hash)); - assert_eq!(asset["status"]["confirmed"].as_bool(), Some(true)); - assert_eq!(stats["issuance_count"].as_u64(), Some(1)); - assert_eq!(stats["has_blinded_issuances"].as_bool(), Some(true)); - assert_eq!(stats["issued_amount"].as_u64(), Some(0)); - - // Test GET /tx/:txid for issuance tx - let issuance_tx = get_json(&format!("/tx/{}", issuance_txid))?; - let issuance_in_index = asset["issuance_txin"]["vin"].as_u64().unwrap(); - let issuance_in = &issuance_tx["vin"][issuance_in_index as usize]; - let issuance_data = &issuance_in["issuance"]; - assert_eq!(issuance_data["asset_id"].as_str(), Some(assetid)); - assert_eq!(issuance_data["is_reissuance"].as_bool(), Some(false)); - assert_eq!(issuance_data["contract_hash"].as_str(), Some(contract_hash)); - assert!(issuance_data["assetamount"].is_null()); - assert!(issuance_data["assetamountcommitment"].is_string()); - } +// Elements-only tests - // Test unblinded asset issuance - { - let issuance = tester - .node_client() - .call::("issueasset", &[1.5.into(), 0.into(), false.into()])?; - tester.mine()?; - let assetid = issuance["asset"].as_str().expect("asset id"); - let issuance_txid = issuance["txid"].as_str().expect("issuance txid"); - - // Test GET /asset/:assetid - let asset = get_json(&format!("/asset/{}", assetid))?; - let stats = &asset["chain_stats"]; - assert_eq!(stats["has_blinded_issuances"].as_bool(), Some(false)); - assert_eq!(stats["issued_amount"].as_u64(), Some(150000000)); - - // Test GET /tx/:txid for issuance tx - let issuance_tx = get_json(&format!("/tx/{}", issuance_txid))?; - let issuance_in_index = asset["issuance_txin"]["vin"].as_u64().unwrap(); - let issuance_in = &issuance_tx["vin"][issuance_in_index as usize]; - let issuance_data = &issuance_in["issuance"]; - assert_eq!(issuance_data["assetamount"].as_u64(), Some(150000000)); - assert!(issuance_data["assetamountcommitment"].is_null()); - } +#[cfg(feature = "liquid")] +#[test] +fn test_rest_liquid_confidential_tx() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); - // Test a regular (non-issuance) transaction sending an issued asset - { - let issuance = tester - .node_client() - .call::("issueasset", &[1.5.into(), 0.into(), false.into()])?; - let assetid = issuance["asset"].as_str().expect("asset id"); - tester.mine()?; - - let (c_addr, uc_addr) = tester.ct_newaddress()?; - - // With blinding off - let txid = tester.send_asset( - &uc_addr, - "0.3 BTC".parse().unwrap(), // not actually BTC, but this is what Amount expects - assetid.parse().unwrap(), - )?; - let tx = get_json(&format!("/tx/{}", txid))?; - let outs = tx["vout"].as_array().expect("array of outs"); - let vout = outs - .iter() - .find(|vout| vout["scriptpubkey_address"].as_str() == Some(&uc_addr.to_string())) - .expect("our output"); - assert_eq!(vout["asset"].as_str(), Some(assetid)); - assert_eq!(vout["value"].as_u64(), Some(30000000)); - - // With blinding on - let txid = tester.send_asset( - &c_addr, - "0.3 BTC".parse().unwrap(), - assetid.parse().unwrap(), - )?; - let tx = get_json(&format!("/tx/{}", txid))?; - let outs = tx["vout"].as_array().expect("array of outs"); - let vout = outs - .iter() - .find(|vout| vout["scriptpubkey_address"].as_str() == Some(&uc_addr.to_string())) - .expect("our output"); - assert!(vout["asset"].is_null()); - assert!(vout["value"].is_null()); - assert!(vout["assetcommitment"].is_string()); - assert!(vout["valuecommitment"].is_string()); - } + let (c_addr, uc_addr) = tester.ct_newaddress()?; + let txid = tester.send(&c_addr, "3.5 BTC".parse().unwrap())?; + tester.mine()?; + let mine_height = tester.get_block_count()?; + + let tx = get_json(rest_addr, &format!("/tx/{}", txid))?; + log::debug!("blinded tx = {:#?}", tx); + assert_eq!(tx["status"]["confirmed"].as_bool(), Some(true)); + assert_eq!(tx["status"]["block_height"].as_u64(), Some(mine_height)); + assert!(tx["status"]["block_hash"].is_string()); + let outs = tx["vout"].as_array().expect("array of outs"); + let vout = outs + .iter() + .find(|vout| vout["scriptpubkey_address"].as_str() == Some(&uc_addr.to_string())) + .expect("our output"); + assert!(vout["value"].is_null()); + assert!(vout["valuecommitment"].is_string()); + assert!(vout["assetcommitment"].is_string()); + assert!(vout["scriptpubkey_type"].is_string()); - // Test GET /block/:hash - { - let block1_hash = get_plain("/block-height/1")?; - let block1 = get_json(&format!("/block/{}", block1_hash))?; - - // No PoW-related stuff - assert!(block1["bits"].is_null()); - assert!(block1["nonce"].is_null()); - assert!(block1["difficulty"].is_null()); - - // Dynamic Federations (dynafed) fields - // Block #1 should have the Full dynafed params - // See https://docs.rs/elements/latest/elements/dynafed/enum.Params.html - assert!(block1["ext"]["current"]["signblockscript"].is_string()); - assert!(block1["ext"]["current"]["fedpegscript"].is_string()); - assert!(block1["ext"]["current"]["fedpeg_program"].is_string()); - assert!(block1["ext"]["current"]["signblock_witness_limit"].is_u64()); - assert!(block1["ext"]["current"]["extension_space"].is_array()); - assert!(block1["ext"]["proposed"].is_object()); - assert!(block1["ext"]["signblock_witness"].is_array()); - - // Block #2 should have the Compact params - let block2_hash = get_plain("/block-height/2")?; - let block2 = get_json(&format!("/block/{}", block2_hash))?; - assert!(block2["ext"]["current"]["signblockscript"].is_string()); - assert!(block2["ext"]["current"]["signblock_witness_limit"].is_u64()); - // With the `elided_root` in place of `fedpegscript`/`fedpeg_program`/`extension_space`` - assert!(block2["ext"]["current"]["elided_root"].is_string()); - assert!(block2["ext"]["current"]["fedpegscript"].is_null()); - assert!(block2["ext"]["current"]["fedpeg_program"].is_null()); - assert!(block2["ext"]["current"]["extension_space"].is_null()); - } - } + rest_handle.stop(); + Ok(()) +} + +#[cfg(feature = "liquid")] +#[test] +fn test_rest_liquid_blinded_issuance() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + use bitcoin::hashes::{sha256, Hash}; + let contract_hash = sha256::Hash::hash(&[0x11, 0x22, 0x33, 0x44]).to_string(); + let contract_hash = contract_hash.as_str(); + let issuance = tester.node_client().call::( + "issueasset", + &[1.5.into(), 0.into(), true.into(), contract_hash.into()], + )?; + tester.mine()?; + + let assetid = issuance["asset"].as_str().expect("asset id"); + let issuance_txid = issuance["txid"].as_str().expect("issuance txid"); + + // Test GET /asset/:assetid + let asset = get_json(rest_addr, &format!("/asset/{}", assetid))?; + let stats = &asset["chain_stats"]; + assert_eq!(asset["asset_id"].as_str(), Some(assetid)); + assert_eq!(asset["issuance_txin"]["txid"].as_str(), Some(issuance_txid)); + assert_eq!(asset["contract_hash"].as_str(), Some(contract_hash)); + assert_eq!(asset["status"]["confirmed"].as_bool(), Some(true)); + assert_eq!(stats["issuance_count"].as_u64(), Some(1)); + assert_eq!(stats["has_blinded_issuances"].as_bool(), Some(true)); + assert_eq!(stats["issued_amount"].as_u64(), Some(0)); + + // Test GET /tx/:txid for issuance tx + let issuance_tx = get_json(rest_addr, &format!("/tx/{}", issuance_txid))?; + let issuance_in_index = asset["issuance_txin"]["vin"].as_u64().unwrap(); + let issuance_in = &issuance_tx["vin"][issuance_in_index as usize]; + let issuance_data = &issuance_in["issuance"]; + assert_eq!(issuance_data["asset_id"].as_str(), Some(assetid)); + assert_eq!(issuance_data["is_reissuance"].as_bool(), Some(false)); + assert_eq!(issuance_data["contract_hash"].as_str(), Some(contract_hash)); + assert!(issuance_data["asset_entropy"].is_string()); + assert!(issuance_data["assetamount"].is_null()); + assert!(issuance_data["assetamountcommitment"].is_string()); + + // Verify asset stats + // TODO properly validate asset stats + assert_eq!(stats["tx_count"].as_u64(), Some(1)); + + rest_handle.stop(); + Ok(()) +} + +#[cfg(feature = "liquid")] +#[test] +fn test_rest_liquid_unblinded_issuance() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let issuance = tester + .node_client() + .call::("issueasset", &[1.5.into(), 0.into(), false.into()])?; + tester.mine()?; + let assetid = issuance["asset"].as_str().expect("asset id"); + let issuance_txid = issuance["txid"].as_str().expect("issuance txid"); + + // Test GET /asset/:assetid + let asset = get_json(rest_addr, &format!("/asset/{}", assetid))?; + let stats = &asset["chain_stats"]; + assert_eq!(stats["has_blinded_issuances"].as_bool(), Some(false)); + assert_eq!(stats["issued_amount"].as_u64(), Some(150000000)); + assert_eq!(stats["issuance_count"].as_u64(), Some(1)); + assert_eq!(stats["tx_count"].as_u64(), Some(1)); + + // Test GET /tx/:txid for issuance tx + let issuance_tx = get_json(rest_addr, &format!("/tx/{}", issuance_txid))?; + let issuance_in_index = asset["issuance_txin"]["vin"].as_u64().unwrap(); + let issuance_in = &issuance_tx["vin"][issuance_in_index as usize]; + let issuance_data = &issuance_in["issuance"]; + assert_eq!(issuance_data["assetamount"].as_u64(), Some(150000000)); + assert!(issuance_data["assetamountcommitment"].is_null()); + + rest_handle.stop(); + Ok(()) +} + +#[cfg(feature = "liquid")] +#[test] +fn test_rest_liquid_asset_transfer() -> Result<()> { + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let issuance = tester + .node_client() + .call::("issueasset", &[1.5.into(), 0.into(), false.into()])?; + let assetid = issuance["asset"].as_str().expect("asset id"); + tester.mine()?; + + let (c_addr, uc_addr) = tester.ct_newaddress()?; + + // With blinding off + let txid = tester.send_asset( + &uc_addr, + "0.3 BTC".parse().unwrap(), // not actually BTC, but this is what Amount expects + assetid.parse().unwrap(), + )?; + let tx = get_json(rest_addr, &format!("/tx/{}", txid))?; + let outs = tx["vout"].as_array().expect("array of outs"); + let vout = outs + .iter() + .find(|vout| vout["scriptpubkey_address"].as_str() == Some(&uc_addr.to_string())) + .expect("our output"); + assert_eq!(vout["asset"].as_str(), Some(assetid)); + assert_eq!(vout["value"].as_u64(), Some(30000000)); + assert!(vout["scriptpubkey_type"].is_string()); + assert_eq!( + vout["scriptpubkey_address"].as_str(), + Some(uc_addr.to_string().as_str()) + ); + + // With blinding on + let txid = tester.send_asset( + &c_addr, + "0.3 BTC".parse().unwrap(), + assetid.parse().unwrap(), + )?; + let tx = get_json(rest_addr, &format!("/tx/{}", txid))?; + let outs = tx["vout"].as_array().expect("array of outs"); + let vout = outs + .iter() + .find(|vout| vout["scriptpubkey_address"].as_str() == Some(&uc_addr.to_string())) + .expect("our output"); + assert!(vout["asset"].is_null()); + assert!(vout["value"].is_null()); + assert!(vout["assetcommitment"].is_string()); + assert!(vout["valuecommitment"].is_string()); + + rest_handle.stop(); + Ok(()) +} + +#[cfg(feature = "liquid")] +#[test] +fn test_rest_liquid_block() -> Result<()> { + let (rest_handle, rest_addr, _tester) = common::init_rest_tester().unwrap(); + + // Test GET /block/:hash + let block1_hash = get_plain(rest_addr, "/block-height/1")?; + let block1 = get_json(rest_addr, &format!("/block/{}", block1_hash))?; + + // No PoW-related stuff + assert!(block1["bits"].is_null()); + assert!(block1["nonce"].is_null()); + assert!(block1["difficulty"].is_null()); + + // TODO properly validate dynafed parameters in first and second blocks + // Dynamic Federations (dynafed) fields + // Block #1 should have the Full dynafed params + // See https://docs.rs/elements/latest/elements/dynafed/enum.Params.html + assert!(block1["ext"]["current"]["signblockscript"].is_string()); + assert!(block1["ext"]["current"]["fedpegscript"].is_string()); + assert!(block1["ext"]["current"]["fedpeg_program"].is_string()); + assert!(block1["ext"]["current"]["signblock_witness_limit"].is_u64()); + assert!(block1["ext"]["proposed"].is_object()); + // TODO + + assert!(block1["ext"]["signblock_witness"].is_array()); + + // Block #2 should have the Compact params + let block2_hash = get_plain(rest_addr, "/block-height/2")?; + let block2 = get_json(rest_addr, &format!("/block/{}", block2_hash))?; + assert!(block2["ext"]["current"]["signblockscript"].is_string()); + assert!(block2["ext"]["current"]["signblock_witness_limit"].is_u64()); + // With the `elided_root` in place of `fedpegscript`/`fedpeg_program`/`extension_space`` + assert!(block2["ext"]["current"]["elided_root"].is_string()); + assert!(block2["ext"]["current"]["fedpegscript"].is_null()); + assert!(block2["ext"]["current"]["fedpeg_program"].is_null()); + assert!(block2["ext"]["current"]["extension_space"].is_null()); rest_handle.stop(); Ok(()) From 19ab74c27adafa811a31be35c885a5fa2b8d532d Mon Sep 17 00:00:00 2001 From: Philippe McLean Date: Thu, 19 Feb 2026 10:58:35 -0800 Subject: [PATCH 4/4] test(electrum): split monolithic test into focused per-scenario tests Break test_electrum() into individual test functions (balance, history, payment, raw), each covering a specific scenario, and extract shared setup into a WalletTester struct. --- tests/electrum.rs | 170 +++++++++++++++++++++++++++++----------------- 1 file changed, 107 insertions(+), 63 deletions(-) diff --git a/tests/electrum.rs b/tests/electrum.rs index 8bf148336..a3e54327f 100644 --- a/tests/electrum.rs +++ b/tests/electrum.rs @@ -9,36 +9,51 @@ use electrumd::jsonrpc::serde_json::json; use electrumd::ElectrumD; use electrs::chain::Address; +use electrs::electrum::RPC as ElectrumRPC; #[cfg(not(feature = "liquid"))] use bitcoin::address; -/// Test the Electrum RPC server using an headless Electrum wallet -/// This only runs on Bitcoin (non-Liquid) mode. -#[cfg_attr(not(feature = "liquid"), test)] -#[cfg_attr(feature = "liquid", allow(dead_code))] -fn test_electrum() -> Result<()> { - // Spawn an Electrs Electrum RPC server - let (electrum_server, electrum_addr, mut tester) = common::init_electrum_tester().unwrap(); - - // Spawn an headless Electrum wallet RPC daemon, connected to Electrs - let mut electrum_wallet_conf = electrumd::Conf::default(); - let server_arg = format!("{}:t", electrum_addr.to_string()); - electrum_wallet_conf.args = if std::env::var_os("RUST_LOG").is_some() { - vec!["-v", "--server", &server_arg] - } else { - vec!["--server", &server_arg] - }; - electrum_wallet_conf.view_stdout = true; - let electrum_wallet = ElectrumD::with_conf(electrumd::exe_path()?, &electrum_wallet_conf)?; - - let notify_wallet = || { - electrum_server.notify(); +struct WalletTester { + electrum_server: ElectrumRPC, + electrum_wallet: ElectrumD, + tester: common::TestRunner, +} + +impl WalletTester { + fn new() -> Result { + let (electrum_server, electrum_addr, tester) = common::init_electrum_tester().unwrap(); + + let mut electrum_wallet_conf = electrumd::Conf::default(); + let server_arg = format!("{}:t", electrum_addr); + electrum_wallet_conf.args = if std::env::var_os("RUST_LOG").is_some() { + vec!["-v", "--server", &server_arg] + } else { + vec!["--server", &server_arg] + }; + electrum_wallet_conf.view_stdout = true; + let electrum_wallet = + ElectrumD::with_conf(electrumd::exe_path()?, &electrum_wallet_conf)?; + + log::info!( + "Electrum wallet version: {:?}", + electrum_wallet.call("version", &json!([]))? + ); + + Ok(WalletTester { + electrum_server, + electrum_wallet, + tester, + }) + } + + fn notify_wallet(&self) { + self.electrum_server.notify(); std::thread::sleep(std::time::Duration::from_millis(200)); - }; + } - let assert_balance = |confirmed: f64, unconfirmed: f64| { - let balance = electrum_wallet.call("getbalance", &json!([])).unwrap(); + fn assert_balance(&self, confirmed: f64, unconfirmed: f64) { + let balance = self.electrum_wallet.call("getbalance", &json!([])).unwrap(); log::info!("balance: {}", balance); assert_eq!( @@ -53,15 +68,16 @@ fn test_electrum() -> Result<()> { } else { assert!(balance["unconfirmed"].is_null()) } - }; + } - let newaddress = || -> Address { + fn newaddress(&self) -> Address { #[cfg(not(feature = "liquid"))] type ParseAddrType = Address; #[cfg(feature = "liquid")] type ParseAddrType = Address; - let addr = electrum_wallet + let addr = self + .electrum_wallet .call("createnewaddress", &json!([])) .unwrap() .as_str() @@ -73,37 +89,55 @@ fn test_electrum() -> Result<()> { let addr = addr.assume_checked(); addr - }; + } +} - log::info!( - "Electrum wallet version: {:?}", - electrum_wallet.call("version", &json!([]))? - ); +/// Test balance tracking with confirmed and unconfirmed transactions +#[cfg_attr(not(feature = "liquid"), test)] +#[cfg_attr(feature = "liquid", allow(dead_code))] +fn test_electrum_balance() -> Result<()> { + let mut wt = WalletTester::new()?; + + let addr1 = wt.newaddress(); + let addr2 = wt.newaddress(); + + wt.assert_balance(0.0, 0.0); + + wt.tester.send(&addr1, "0.1 BTC".parse().unwrap())?; + wt.notify_wallet(); + wt.assert_balance(0.0, 0.1); - // Send some funds and verify that the balance checks out - let addr1 = newaddress(); - let addr2 = newaddress(); + wt.tester.mine()?; + wt.notify_wallet(); + wt.assert_balance(0.1, 0.0); - assert_balance(0.0, 0.0); + wt.tester.send(&addr2, "0.2 BTC".parse().unwrap())?; + wt.notify_wallet(); + wt.assert_balance(0.1, 0.2); - let txid1 = tester.send(&addr1, "0.1 BTC".parse().unwrap())?; - notify_wallet(); - assert_balance(0.0, 0.1); + wt.tester.mine()?; + wt.notify_wallet(); + wt.assert_balance(0.3, 0.0); - tester.mine()?; - notify_wallet(); - assert_balance(0.1, 0.0); + Ok(()) +} + +/// Test transaction history via onchain_history +#[cfg_attr(not(feature = "liquid"), test)] +#[cfg_attr(feature = "liquid", allow(dead_code))] +fn test_electrum_history() -> Result<()> { + let mut wt = WalletTester::new()?; - let txid2 = tester.send(&addr2, "0.2 BTC".parse().unwrap())?; - notify_wallet(); - assert_balance(0.1, 0.2); + let addr1 = wt.newaddress(); + let addr2 = wt.newaddress(); - tester.mine()?; - notify_wallet(); - assert_balance(0.3, 0.0); + let txid1 = wt.tester.send(&addr1, "0.1 BTC".parse().unwrap())?; + wt.tester.mine()?; + let txid2 = wt.tester.send(&addr2, "0.2 BTC".parse().unwrap())?; + wt.tester.mine()?; + wt.notify_wallet(); - // Verify that the transaction history checks out - let history = electrum_wallet.call("onchain_history", &json!([]))?; + let history = wt.electrum_wallet.call("onchain_history", &json!([]))?; log::debug!("history = {:#?}", history); assert_eq!( history["transactions"][0]["txid"].as_str(), @@ -119,37 +153,47 @@ fn test_electrum() -> Result<()> { assert_eq!(history["transactions"][1]["height"].as_u64(), Some(103)); assert_eq!(history["transactions"][1]["bc_value"].as_str(), Some("0.2")); - // Send an outgoing payment - electrum_wallet.call( + Ok(()) +} + +/// Test sending an outgoing payment +#[cfg_attr(not(feature = "liquid"), test)] +#[cfg_attr(feature = "liquid", allow(dead_code))] +fn test_electrum_payment() -> Result<()> { + let mut wt = WalletTester::new()?; + + let addr1 = wt.newaddress(); + wt.tester.send(&addr1, "0.3 BTC".parse().unwrap())?; + wt.tester.mine()?; + wt.notify_wallet(); + wt.assert_balance(0.3, 0.0); + + wt.electrum_wallet.call( "broadcast", - &json!([electrum_wallet.call( + &json!([wt.electrum_wallet.call( "payto", &json!({ - "destination": tester.node_client().get_new_address(None, None)?, + "destination": wt.tester.node_client().get_new_address(None, None)?, "amount": 0.16, "fee": 0.001, }), )?]), )?; - notify_wallet(); - assert_balance(0.139, 0.0); + wt.notify_wallet(); + wt.assert_balance(0.139, 0.0); - tester.mine()?; - notify_wallet(); - assert_balance(0.139, 0.0); + wt.tester.mine()?; + wt.notify_wallet(); + wt.assert_balance(0.139, 0.0); Ok(()) } /// Test the Electrum RPC server using a raw TCP socket -/// This only runs on Bitcoin (non-Liquid) mode. #[cfg_attr(not(feature = "liquid"), test)] #[cfg_attr(feature = "liquid", allow(dead_code))] -#[ignore = "must be launched singularly, otherwise conflict with the other server"] fn test_electrum_raw() { - // Spawn an Electrs Electrum RPC server let (_electrum_server, electrum_addr, mut _tester) = common::init_electrum_tester().unwrap(); - std::thread::sleep(std::time::Duration::from_millis(1000)); let mut stream = TcpStream::connect(electrum_addr).unwrap(); let write = "{\"jsonrpc\": \"2.0\", \"method\": \"server.version\", \"id\": 0}";