From: Vihiga Tyonum Date: Sun, 5 Jul 2026 21:23:42 +0000 (+0100) Subject: test: split tests according to typestate X-Git-Url: http://internal-gitweb-vhost/blockdata/script/encode/-script/display/struct.ScriptHash.html?a=commitdiff_plain;h=d425f5b253e861bc5f3432dc9a3f327abbec02f1;p=bdk-cli test: split tests according to typestate Multi-line description of commit, feel free to be detailed. [Ticket: X] --- diff --git a/tests/cli.rs b/tests/cli.rs index 8f59717..9285327 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -13,894 +13,8 @@ mod common; -use crate::common::BdkCli; -use predicates::prelude::*; -use tempfile::TempDir; -// --- KEY COMMAND TESTS --- -mod test_key { - use super::*; - use serde_json::Value; - - #[test] - fn test_cli_key_generate() { - let temp_dir = TempDir::new().unwrap(); - let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); - - cli.key_cmd(&["generate"]) - .assert() - .success() - .stdout(predicate::str::contains("\"xprv\":")) - .stdout(predicate::str::contains("\"mnemonic\":")) - .stdout(predicate::str::contains("\"fingerprint\":")); - } - - #[test] - fn test_cli_key_derive() { - let temp_dir = TempDir::new().unwrap(); - let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); - - let generate_output = cli - .key_cmd(&["generate"]) - .output() - .expect("Failed to execute generate command"); - assert!(generate_output.status.success(), "Generate command failed"); - - let generate_json: Value = - serde_json::from_slice(&generate_output.stdout).expect("Invalid JSON"); - let xprv = generate_json["xprv"].as_str().expect("Missing XPRV"); - - let mut cmd = cli.key_cmd(&[ - "derive", - "--xprv", - xprv, - "--derivation_path", - "m/84'/1'/0'/0", - ]); - - cmd.assert() - .success() - .stdout(predicate::str::contains("\"xprv\":")) - .stdout(predicate::str::contains("\"xpub\":")); - } - - #[test] - fn test_cli_key_restore() { - let temp_dir = TempDir::new().unwrap(); - let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); - - // Execute the command and capture the output - let generate_cmd = cli - .key_cmd(&["generate"]) - .output() - .expect("Failed to execute generate command"); - assert!(generate_cmd.status.success(), "Generate command failed"); - - // Parse the JSON to extract the mnemonic - let generate_json: Value = - serde_json::from_slice(&generate_cmd.stdout).expect("Failed to parse JSON"); - - let mnemonic = generate_json["mnemonic"] - .as_str() - .expect("Mnemonic missing"); - let xprv = generate_json["xprv"].as_str().expect("XPRV missing"); - let finger_print = generate_json["fingerprint"] - .as_str() - .expect("Fingerprint missing"); - - // Restore using the mnemonic - let output_restore = cli - .key_cmd(&["restore", "--mnemonic", mnemonic]) - .output() - .expect("Failed to execute restore command"); - assert!(output_restore.status.success(), "Restore command failed"); - - // Parse the JSON from the restore command - let restore_json: Value = - serde_json::from_slice(&output_restore.stdout).expect("Failed to parse JSON"); - - let restored_xprv = restore_json["xprv"] - .as_str() - .expect("Restored XPRV missing"); - let restored_fingerprint = restore_json["fingerprint"] - .as_str() - .expect("Restored fingerprint missing"); - - // Assert that the restored data exactly matches the generated data - assert_eq!( - xprv, restored_xprv, - "The restored XPRV does not match the generated XPRV!" - ); - - assert_eq!( - finger_print, restored_fingerprint, - "The restored fingerprint does not match the generated fingerprint!" - ); - } -} - -// --- WALLETS COMMAND TESTS --- -mod test_wallets { - use super::*; - - #[test] - fn test_list_wallets_empty() { - let temp_dir = TempDir::new().unwrap(); - let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); - - let mut cmd = cli.build_base_cmd(); - cmd.arg("wallets"); - - cmd.assert() - .failure() - .stderr(predicate::str::contains("No wallets configured yet.")); - } - - #[cfg(feature = "rpc")] - #[test] - fn test_list_wallets_with_entries() { - let temp_dir = TempDir::new().unwrap(); - let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); - - for wallet_name in ["wallet_one", "wallet_two"] { - let desc = cli - .cmd("descriptor", &["--type", "tr"]) - .output() - .expect("Command to generate descriptors failed"); - let desc_values: serde_json::Value = serde_json::from_slice(&desc.stdout).unwrap(); - let pub_desc = &desc_values["public_descriptors"]; - let ext_desc = pub_desc["external"].as_str().unwrap(); - let int_desc = pub_desc["internal"].as_str().unwrap(); - - cli.build_base_cmd() - .arg("wallet") - .arg("--wallet") - .arg(wallet_name) - .arg("config") - .arg("--ext-descriptor") - .arg(ext_desc) - .arg("--int-descriptor") - .arg(int_desc) - .arg("--client-type") - .arg("rpc") - .arg("--database-type") - .arg("sqlite") - .arg("--url") - .arg("http://localhost:18443") - .assert() - .success(); - } - - cli.build_base_cmd() - .arg("wallets") - .assert() - .success() - .stdout(predicate::str::contains("wallet_one")) - .stdout(predicate::str::contains("wallet_two")); - } -} - -// --- DESCRIPTOR COMMAND TESTS --- -mod test_descriptor { - use super::*; - - #[test] - fn test_generate_new_descriptor() { - let temp_dir = TempDir::new().unwrap(); - let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); - - // Run `bdk-cli descriptor --type tr` - cli.cmd("descriptor", &["--type", "tr"]) - .assert() - .success() - .stdout(predicate::str::contains("\"public_descriptors\":")) - .stdout(predicate::str::contains("\"private_descriptors\":")) - .stdout(predicate::str::contains("\"mnemonic\":")) - .stdout(predicate::str::contains("\"fingerprint\":")); - } -} - -// --- COMPILE COMMAND TESTS --- -#[cfg(feature = "compiler")] -mod test_compile { - use super::*; - - #[test] - fn test_compile_valid_policy() { - let temp_dir = TempDir::new().unwrap(); - let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); - - let policy = "pk(02e5b88fdb71c696e1a473f309a47535b7190e21a22bd25e7fc8bd055db3bba12f)"; - - cli.cmd("compile", &[policy, "--type", "wsh"]) - .assert() - .success() - .stdout(predicate::str::contains("\"descriptor\":")) - .stdout(predicate::str::contains("wsh(")); - } - - #[test] - fn test_compile_invalid_policy() { - let temp_dir = TempDir::new().unwrap(); - let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); - - cli.cmd("compile", &["invalid_policy", "--type", "wsh"]) - .assert() - .failure() - .stderr(predicate::str::contains("Invalid policy")); - } -} - -// --- CONFIG COMMAND TESTS --- -#[cfg(feature = "rpc")] -mod test_config { - use super::*; - use serde_json::Value; - - #[test] - fn test_save_and_read_wallet_config() { - let temp_dir = TempDir::new().unwrap(); - let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); - - let desc = cli - .cmd("descriptor", &["--type", "tr"]) - .output() - .expect("Command to generate descriptors failed"); - - let desc_values: Value = - serde_json::from_slice(&desc.stdout).expect("Invalid JSON from output descriptor"); - - let pub_desc = &desc_values["public_descriptors"]; - - let ext_desc = pub_desc["external"].as_str().unwrap(); - let int_desc = pub_desc["internal"].as_str().unwrap(); - let wallet_name = "test_config_wallet"; - let client_type = "rpc"; - let db = "sqlite"; - let url = "http://localhost:18443"; - - let mut cmd_init = cli.build_base_cmd(); - cmd_init - .arg("wallet") - .arg("--wallet") - .arg(wallet_name) - .arg("config") - .arg("--ext-descriptor") - .arg(ext_desc) - .arg("--int-descriptor") - .arg(int_desc) - .arg("--client-type") - .arg(client_type) - .arg("--database-type") - .arg(db) - .arg("--url") - .arg(url); - - cmd_init.assert().success(); - - // verify saved config - let mut cmd = cli.build_base_cmd(); - cmd.arg("wallets"); - - let output = cmd.output().expect("Failed to execute wallets command"); - - assert!( - output.status.success(), - "The wallets command failed to execute" - ); - - let json_output: Value = - serde_json::from_slice(&output.stdout).expect("CLI did not output valid JSON"); - - let config = &json_output[wallet_name]; - - assert!( - !config.is_null(), - "The wallet {wallet_name} was not found in the root JSON object" - ); - - assert_eq!(config["wallet"].as_str().unwrap(), wallet_name); - assert_eq!(config["network"].as_str().unwrap(), "regtest"); - assert_eq!(config["database_type"].as_str().unwrap(), db); - assert_eq!(config["client_type"].as_str().unwrap(), client_type); - assert_eq!(config["server_url"].as_str().unwrap(), url); - assert_eq!(config["ext_descriptor"].as_str().unwrap(), ext_desc); - assert_eq!(config["int_descriptor"].as_str().unwrap(), int_desc); - } -} - -#[cfg(feature = "rpc")] -mod test_offline { - use super::*; - use assert_cmd::Command; - use serde_json::Value; - - static WALLET_NAME: &str = "test_config_wallet"; - - /// Helper to spin up a sandboxed CLI with the generated descriptors - fn setup_wallet_config() -> (BdkCli, Command) { - let temp_dir = TempDir::new().unwrap(); - let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); - - // generate descriptors - let desc = cli - .cmd("descriptor", &["--type", "tr"]) - .output() - .expect("Command to generate descriptors failed"); - - let desc_values: Value = - serde_json::from_slice(&desc.stdout).expect("Invalid JSON from output descriptor"); - - let priv_desc = &desc_values["private_descriptors"]; - - let ext_desc = priv_desc["external"].as_str().unwrap(); - let int_desc = priv_desc["internal"].as_str().unwrap(); - let client_type = "rpc"; - let db: &str = "sqlite"; - let url = "http://localhost:18443"; - - let mut cmd_init = cli.build_base_cmd(); - cmd_init - .arg("wallet") - .arg("--wallet") - .arg(WALLET_NAME) - .arg("config") - .arg("--ext-descriptor") - .arg(ext_desc) - .arg("--int-descriptor") - .arg(int_desc) - .arg("--client-type") - .arg(client_type) - .arg("--database-type") - .arg(db) - .arg("--url") - .arg(url); - (cli, cmd_init) - } - - #[test] - fn test_new_and_unused_address() { - let (cli, mut cmd_init) = setup_wallet_config(); - cmd_init.assert().success(); - - // new address - cli.wallet_cmd(&["--wallet", WALLET_NAME, "new_address"]) - .assert() - .success() - .stdout(predicate::str::contains("\"address\":")); - - // `unused-address` - cli.wallet_cmd(&["--wallet", WALLET_NAME, "unused_address"]) - .assert() - .success() - .stdout(predicate::str::contains("\"address\":")); - } - - #[test] - fn test_empty_wallet_balances_and_lists() { - let (cli, mut cmd_init) = setup_wallet_config(); - cmd_init.assert().success(); - - // balance - cli.wallet_cmd(&["--wallet", WALLET_NAME, "balance"]) - .assert() - .success() - .stdout(predicate::str::contains("\"total\":")) - .stdout(predicate::str::contains("\"trusted_pending\":")) - .stdout(predicate::str::contains("\"untrusted_pending\":")) - .stdout(predicate::str::contains("\"immature\":")) - .stdout(predicate::str::contains("\"confirmed\":")); - - // Unspent UTXOs - cli.wallet_cmd(&["--wallet", WALLET_NAME, "unspent"]) - .assert() - .success() - .stdout(predicate::str::contains("[]")); - - // Transactions - cli.wallet_cmd(&["--wallet", WALLET_NAME, "transactions"]) - .assert() - .success() - .stdout(predicate::str::contains("[]")); - } - - #[test] - fn test_policies_and_public_descriptor() { - let (cli, mut cmd_init) = setup_wallet_config(); - cmd_init.assert().success(); - - // Policies - cli.wallet_cmd(&["--wallet", WALLET_NAME, "policies"]) - .assert() - .success() - .stdout(predicate::str::contains("\"external\":")) - .stdout(predicate::str::contains("\"internal\":")); - - // Public Descriptor - cli.wallet_cmd(&["--wallet", WALLET_NAME, "public_descriptor"]) - .assert() - .success() - .stdout(predicate::str::contains("external")) - .stdout(predicate::str::contains("internal")); - } - - #[test] - fn test_create_tx_insufficient_funds() { - let (cli, mut cmd_init) = setup_wallet_config(); - cmd_init.assert().success(); - - // create transaction - cli.wallet_cmd(&[ - "--wallet", - WALLET_NAME, - "create_tx", - "--to", - "tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4:1000", - ]) - .assert() - .failure() - .stderr(predicate::str::contains("Insufficient")); - } - - #[test] - fn test_combine_psbt_invalid_input() { - let (cli, mut cmd_init) = setup_wallet_config(); - cmd_init.assert().success(); - - // Invalid create-tx. - cli.wallet_cmd(&[ - "--wallet", - WALLET_NAME, - "combine_psbt", - "invalid_psbt", - "another_invalid_psbt", - ]) - .assert() - .failure() - .stderr(predicate::str::contains("Invalid")); - } - - #[cfg(feature = "bip322")] - #[test] - fn test_sign_message_and_verify_message() { - let (cli, mut cmd_init) = setup_wallet_config(); - cmd_init.assert().success(); - - let message = "bdk-cli integration test"; - - // Reveal exactly one address and reuse it for both sign and verify. - let addr_output = cli - .wallet_cmd(&["--wallet", WALLET_NAME, "new_address"]) - .output() - .expect("Failed to generate address"); - assert!( - addr_output.status.success(), - "new_address failed: {}", - String::from_utf8_lossy(&addr_output.stderr) - ); - let addr_json: Value = serde_json::from_slice(&addr_output.stdout).unwrap(); - let address = addr_json["address"] - .as_str() - .expect("missing address") - .to_string(); - println!("DEBUG signing address: {address}"); - - // Sign (default signature_type = "simple"). - let sign_output = cli - .wallet_cmd(&[ - "--wallet", - WALLET_NAME, - "sign_message", - "--message", - message, - "--address", - &address, - ]) - .output() - .expect("Failed to sign message"); - assert!( - sign_output.status.success(), - "sign_message failed: {}", - String::from_utf8_lossy(&sign_output.stderr) - ); - let sign_json: Value = serde_json::from_slice(&sign_output.stdout).unwrap(); - let proof = sign_json["proof"].as_str().expect("missing proof"); - println!("DEBUG proof: {proof}"); - println!("DEBUG verifying same address: {address}"); - - // Verify with the identical address + message. - cli.wallet_cmd(&[ - "--wallet", - WALLET_NAME, - "verify_message", - "--address", - &address, - "--message", - message, - "--proof", - proof, - ]) - .assert() - .success() - .stdout(predicate::str::contains("\"valid\": true")); - } - - #[cfg(feature = "bip322")] - #[test] - fn test_verify_message_rejects_tampered_message() { - let (cli, mut cmd_init) = setup_wallet_config(); - cmd_init.assert().success(); - - let addr_output = cli - .wallet_cmd(&["--wallet", WALLET_NAME, "new_address"]) - .output() - .expect("Failed to generate address"); - let addr_json: Value = serde_json::from_slice(&addr_output.stdout).unwrap(); - let address = addr_json["address"].as_str().unwrap(); - - let sign_output = cli - .wallet_cmd(&[ - "--wallet", - WALLET_NAME, - "sign_message", - "--message", - "original message", - "--address", - address, - ]) - .output() - .expect("Failed to sign message"); - let sign_json: Value = serde_json::from_slice(&sign_output.stdout).unwrap(); - let proof = sign_json["proof"].as_str().unwrap(); - - // A tampered message should fail - cli.wallet_cmd(&[ - "--wallet", - WALLET_NAME, - "verify_message", - "--proof", - proof, - "--message", - "tampered message", - "--address", - address, - ]) - .assert() - .success() - .stdout(predicate::str::contains("\"valid\": false")); - } -} - -#[cfg(feature = "electrum")] -mod test_online { - use crate::common::BdkCli; - use assert_cmd::Command; - use bdk_testenv::{TestEnv, bitcoincore_rpc::RpcApi}; - use bdk_wallet::bitcoin::{Address, Amount, Network, Txid}; - use serde_json::Value; - use std::str::FromStr; - use std::time::Duration; - use tempfile::TempDir; - - static WALLET_NAME: &str = "test_online_wallet"; - static RECIPIENT: &str = "tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4"; - - /// Spins up the envt, a Regtest node + electrs, and saves the wallet config. - fn setup_online_wallet() -> (BdkCli, Command, TestEnv) { - let env = TestEnv::new().expect("Failed to start bdk_testenv"); - let server_url = env.electrsd.electrum_url.as_str(); - - let temp_dir = TempDir::new().unwrap(); - let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); - - let desc = cli - .cmd("descriptor", &["--type", "tr"]) - .output() - .expect("Command to generate descriptors failed"); - let desc_values: Value = - serde_json::from_slice(&desc.stdout).expect("Invalid JSON from output descriptor"); - let priv_desc = &desc_values["private_descriptors"]; - let ext_desc = priv_desc["external"].as_str().unwrap(); - let int_desc = priv_desc["internal"].as_str().unwrap(); - - let mut cmd_init = cli.build_base_cmd(); - cmd_init - .arg("wallet") - .arg("--wallet") - .arg(WALLET_NAME) - .arg("config") - .arg("--ext-descriptor") - .arg(ext_desc) - .arg("--int-descriptor") - .arg(int_desc) - .arg("--client-type") - .arg("electrum") - .arg("--database-type") - .arg("sqlite") - .arg("--url") - .arg(server_url); - - (cli, cmd_init, env) - } - - /// Mines blocks, funds wallet with 0.5 BTC, confirms it, and - /// runs `full_scan` so the wallet's persisted state reflects the funding. - /// Asserts the resulting confirmed balance is exactly 50,000,000 sats. - fn fund_and_sync_wallet(cli: &BdkCli, env: &TestEnv) { - let address = cli_new_address(cli); - - let node_address = env - .rpc_client() - .get_new_address(None, None) - .unwrap() - .assume_checked(); - - let mined_blocks = env - .mine_blocks(101, Some(node_address)) - .expect("Failed to mine initial blocks"); - assert_eq!(mined_blocks.len(), 101, "expected exactly 101 blocks mined"); - env.wait_until_electrum_sees_block(Duration::from_secs(10)) - .expect("Electrum did not catch up to initial blocks"); - - let txid = env - .send(&address, Amount::from_btc(0.5).unwrap()) - .expect("Failed to fund wallet address"); - env.wait_until_electrum_sees_txid(txid, Duration::from_secs(10)) - .expect("Electrum did not see funding tx"); - - env.mine_blocks(3, None) - .expect("Failed to confirm funding tx"); - env.wait_until_electrum_sees_block(Duration::from_secs(10)) - .expect("Electrum did not catch up to confirmation blocks"); - - cli_full_scan(cli); - assert_eq!( - cli_balance(cli), - 50_000_000, - "wallet should show funded balance after full_scan" - ); - } - - /// Runs `wallet --wallet `, asserts the process exited - /// successfully, and parses stdout as JSON. - fn run_wallet_json(cli: &BdkCli, args: &[&str]) -> Value { - let mut full_args = vec!["--wallet", WALLET_NAME]; - full_args.extend_from_slice(args); - - let output = cli - .wallet_cmd(&full_args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn `{}`: {e}", args.join(" "))); - - assert!( - output.status.success(), - "`{}` failed: {}", - args.join(" "), - String::from_utf8_lossy(&output.stderr) - ); - - serde_json::from_slice(&output.stdout).unwrap_or_else(|e| { - panic!( - "`{}` returned non-JSON stdout ({e}): {}", - args.join(" "), - String::from_utf8_lossy(&output.stdout) - ) - }) - } - - fn cli_new_address(cli: &BdkCli) -> Address { - let json = run_wallet_json(cli, &["new_address"]); - let addr = json["address"] - .as_str() - .expect("new_address: missing 'address' field"); - Address::from_str(addr) - .expect("new_address: invalid address string") - .require_network(Network::Regtest) - .expect("new_address: wrong network") - } - - fn cli_full_scan(cli: &BdkCli) { - run_wallet_json(cli, &["full_scan"]); - } - - fn cli_sync(cli: &BdkCli) { - run_wallet_json(cli, &["sync"]); - } - - fn cli_balance(cli: &BdkCli) -> u64 { - let json = run_wallet_json(cli, &["balance"]); - json["confirmed"] - .as_u64() - .expect("balance: missing 'confirmed' field") - } - - fn cli_create_tx(cli: &BdkCli, to: &str) -> String { - let json = run_wallet_json(cli, &["create_tx", "--to", to]); - json["psbt"] - .as_str() - .expect("create_tx: missing 'psbt' field") - .to_string() - } - - /// Returns (signed_psbt_base64, is_finalized). - fn cli_sign(cli: &BdkCli, psbt: &str) -> (String, bool) { - let json = run_wallet_json(cli, &["sign", psbt]); - let signed = json["psbt"] - .as_str() - .expect("sign: missing 'psbt' field") - .to_string(); - let finalized = json["is_finalized"] - .as_bool() - .expect("sign: missing 'is_finalized' field"); - (signed, finalized) - } - - fn cli_extract_psbt(cli: &BdkCli, psbt: &str) -> String { - let json = run_wallet_json(cli, &["extract_psbt", psbt]); - json["raw_tx"] - .as_str() - .expect("extract_psbt: missing 'raw_tx' field") - .to_string() - } - - fn cli_broadcast(cli: &BdkCli, raw_tx: &str) -> Txid { - let json = run_wallet_json(cli, &["broadcast", "--tx", raw_tx]); - let txid = json["txid"] - .as_str() - .expect("broadcast: missing 'txid' field"); - Txid::from_str(txid).expect("broadcast: invalid txid") - } - - #[test] - fn test_full_online_transaction_lifecycle() { - let (cli, mut cmd_init, env) = setup_online_wallet(); - cmd_init.assert().success(); - - fund_and_sync_wallet(&cli, &env); - - // Build, sign, extract, and broadcast a spend - let unsigned_psbt = cli_create_tx(&cli, &format!("{RECIPIENT}:20000")); - - let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); - assert!(finalized, "PSBT should be finalized after signing"); - - let raw_tx = cli_extract_psbt(&cli, &signed_psbt); - - let spend_txid = cli_broadcast(&cli, &raw_tx); - env.rpc_client() - .get_mempool_entry(&spend_txid) - .expect("broadcast tx not found in node mempool"); - - // Confirm the spend and verify the final balance - env.mine_blocks(1, None) - .expect("Failed to mine spend confirmation block"); - env.wait_until_electrum_sees_block(Duration::from_secs(10)) - .expect("Electrum did not catch up to spend confirmation"); - - cli_sync(&cli); - let final_balance = cli_balance(&cli); - assert!( - (49_900_000..50_000_000).contains(&final_balance), - "unexpected post-spend confirmed balance: {final_balance}" - ); - } - - #[test] - fn test_finalize_psbt_on_signed_tx() { - let (cli, mut cmd_init, env) = setup_online_wallet(); - cmd_init.assert().success(); - fund_and_sync_wallet(&cli, &env); - - let unsigned_psbt = cli_create_tx(&cli, &format!("{RECIPIENT}:15000")); - let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); - assert!(finalized, "PSBT should be finalized after signing"); - - // finalize_psbt should be idempotent: finalizing an already-finalized - // PSBT should succeed and still report is_finalized = true. - let finalize_json = run_wallet_json(&cli, &["finalize_psbt", &signed_psbt]); - assert_eq!( - finalize_json["is_finalized"].as_bool(), - Some(true), - "finalize_psbt should report the PSBT as finalized" - ); - } - - #[test] - fn test_combine_psbt_merges_signatures() { - let (cli, mut cmd_init, env) = setup_online_wallet(); - cmd_init.assert().success(); - fund_and_sync_wallet(&cli, &env); - - let unsigned_psbt = cli_create_tx(&cli, &format!("{RECIPIENT}:15000")); - let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); - assert!(finalized, "PSBT should be finalized after signing"); - - // Combine the unsigned and signed versions of the same transaction - let combined_json = run_wallet_json(&cli, &["combine_psbt", &unsigned_psbt, &signed_psbt]); - let combined_psbt = combined_json["psbt"] - .as_str() - .expect("combine_psbt: missing 'psbt' field"); - - // If signature data survived the combine, this should extract cleanly. - let raw_tx = cli_extract_psbt(&cli, combined_psbt); - assert!( - !raw_tx.is_empty(), - "combined PSBT did not extract to a valid raw transaction" - ); - } - - #[test] - fn test_bump_fee_replaces_unconfirmed_tx() { - let (cli, mut cmd_init, env) = setup_online_wallet(); - cmd_init.assert().success(); - fund_and_sync_wallet(&cli, &env); - - // Create and broadcast a low-fee-rate, RBF-enabled transaction. - let unsigned_psbt = run_wallet_json( - &cli, - &[ - "create_tx", - "--to", - &format!("{RECIPIENT}:15000"), - "--fee_rate", - "1.0", - ], - )["psbt"] - .as_str() - .expect("create_tx: missing 'psbt' field") - .to_string(); - - let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); - assert!(finalized); - let raw_tx = cli_extract_psbt(&cli, &signed_psbt); - let original_txid = cli_broadcast(&cli, &raw_tx); - - env.rpc_client() - .get_mempool_entry(&original_txid) - .expect("original tx should be in the mempool before bumping"); - - env.wait_until_electrum_sees_txid(original_txid, Duration::from_secs(10)) - .expect("electrs did not see the broadcast tx"); - cli_sync(&cli); - - // Bump to a much higher fee rate; this should replace the original tx. - let bumped_json = run_wallet_json( - &cli, - &[ - "bump_fee", - "--txid", - &original_txid.to_string(), - "--fee_rate", - "10.0", - ], - ); - let bumped_psbt = bumped_json["psbt"] - .as_str() - .expect("bump_fee: missing 'psbt' field"); - - let (signed_bumped, bumped_finalized) = cli_sign(&cli, bumped_psbt); - assert!( - bumped_finalized, - "bumped PSBT should be finalized after signing" - ); - - let bumped_raw_tx = cli_extract_psbt(&cli, &signed_bumped); - let bumped_txid = cli_broadcast(&cli, &bumped_raw_tx); - - assert_ne!( - original_txid, bumped_txid, - "fee-bumped transaction should have a different txid than the original" - ); - - env.mine_blocks(1, None) - .expect("Failed to confirm bumped tx"); - env.wait_until_electrum_sees_block(Duration::from_secs(10)) - .expect("Electrum did not catch up after bump confirmation"); - cli_sync(&cli); - - let final_balance = cli_balance(&cli); - assert!( - final_balance < 50_000_000, - "balance should reflect exactly one confirmed spend after RBF, got {final_balance}" - ); - } +mod integration { + mod init; + mod offline; + mod online; } diff --git a/tests/cli_flags.rs b/tests/cli_flags.rs deleted file mode 100644 index 23abc81..0000000 --- a/tests/cli_flags.rs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license -// , at your option. -// You may not use this file except in accordance with one or both of these -// licenses. - -//! CLI Flags Tests -//! -//! Tests for global CLI flags and their behavior - -use std::process::Command; - -#[test] -fn test_without_pretty_flag() { - let output = Command::new("cargo") - .args("run -- key generate".split_whitespace()) - .output() - .unwrap(); - - assert!(output.status.success()); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(serde_json::from_str::(&stdout).is_ok()); -} diff --git a/tests/integration/init.rs b/tests/integration/init.rs new file mode 100644 index 0000000..7b40614 --- /dev/null +++ b/tests/integration/init.rs @@ -0,0 +1,294 @@ +use crate::common::BdkCli; +use predicates::prelude::*; +use tempfile::TempDir; +// --- KEY COMMAND TESTS --- +mod test_key { + use super::*; + use serde_json::Value; + + #[test] + fn test_cli_key_generate() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + cli.key_cmd(&["generate"]) + .assert() + .success() + .stdout(predicate::str::contains("\"xprv\":")) + .stdout(predicate::str::contains("\"mnemonic\":")) + .stdout(predicate::str::contains("\"fingerprint\":")); + } + + #[test] + fn test_cli_key_derive() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + let generate_output = cli + .key_cmd(&["generate"]) + .output() + .expect("Failed to execute generate command"); + assert!(generate_output.status.success(), "Generate command failed"); + + let generate_json: Value = + serde_json::from_slice(&generate_output.stdout).expect("Invalid JSON"); + let xprv = generate_json["xprv"].as_str().expect("Missing XPRV"); + + let mut cmd = cli.key_cmd(&[ + "derive", + "--xprv", + xprv, + "--derivation_path", + "m/84'/1'/0'/0", + ]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("\"xprv\":")) + .stdout(predicate::str::contains("\"xpub\":")); + } + + #[test] + fn test_cli_key_restore() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + // Execute the command and capture the output + let generate_cmd = cli + .key_cmd(&["generate"]) + .output() + .expect("Failed to execute generate command"); + assert!(generate_cmd.status.success(), "Generate command failed"); + + // Parse the JSON to extract the mnemonic + let generate_json: Value = + serde_json::from_slice(&generate_cmd.stdout).expect("Failed to parse JSON"); + + let mnemonic = generate_json["mnemonic"] + .as_str() + .expect("Mnemonic missing"); + let xprv = generate_json["xprv"].as_str().expect("XPRV missing"); + let finger_print = generate_json["fingerprint"] + .as_str() + .expect("Fingerprint missing"); + + // Restore using the mnemonic + let output_restore = cli + .key_cmd(&["restore", "--mnemonic", mnemonic]) + .output() + .expect("Failed to execute restore command"); + assert!(output_restore.status.success(), "Restore command failed"); + + // Parse the JSON from the restore command + let restore_json: Value = + serde_json::from_slice(&output_restore.stdout).expect("Failed to parse JSON"); + + let restored_xprv = restore_json["xprv"] + .as_str() + .expect("Restored XPRV missing"); + let restored_fingerprint = restore_json["fingerprint"] + .as_str() + .expect("Restored fingerprint missing"); + + // Assert that the restored data exactly matches the generated data + assert_eq!( + xprv, restored_xprv, + "The restored XPRV does not match the generated XPRV!" + ); + + assert_eq!( + finger_print, restored_fingerprint, + "The restored fingerprint does not match the generated fingerprint!" + ); + } +} + +// --- WALLETS COMMAND TESTS --- +mod test_wallets { + use super::*; + + #[test] + fn test_list_wallets_empty() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + let mut cmd = cli.build_base_cmd(); + cmd.arg("wallets"); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("No wallets configured yet.")); + } + + #[cfg(feature = "rpc")] + #[test] + fn test_list_wallets_with_entries() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + for wallet_name in ["wallet_one", "wallet_two"] { + let desc = cli + .cmd("descriptor", &["--type", "tr"]) + .output() + .expect("Command to generate descriptors failed"); + let desc_values: serde_json::Value = serde_json::from_slice(&desc.stdout).unwrap(); + let pub_desc = &desc_values["public_descriptors"]; + let ext_desc = pub_desc["external"].as_str().unwrap(); + let int_desc = pub_desc["internal"].as_str().unwrap(); + + cli.build_base_cmd() + .arg("wallet") + .arg("--wallet") + .arg(wallet_name) + .arg("config") + .arg("--ext-descriptor") + .arg(ext_desc) + .arg("--int-descriptor") + .arg(int_desc) + .arg("--client-type") + .arg("rpc") + .arg("--database-type") + .arg("sqlite") + .arg("--url") + .arg("http://localhost:18443") + .assert() + .success(); + } + + cli.build_base_cmd() + .arg("wallets") + .assert() + .success() + .stdout(predicate::str::contains("wallet_one")) + .stdout(predicate::str::contains("wallet_two")); + } +} + +// --- DESCRIPTOR COMMAND TESTS --- +mod test_descriptor { + use super::*; + + #[test] + fn test_generate_new_descriptor() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + // Run `bdk-cli descriptor --type tr` + cli.cmd("descriptor", &["--type", "tr"]) + .assert() + .success() + .stdout(predicate::str::contains("\"public_descriptors\":")) + .stdout(predicate::str::contains("\"private_descriptors\":")) + .stdout(predicate::str::contains("\"mnemonic\":")) + .stdout(predicate::str::contains("\"fingerprint\":")); + } +} + +// --- COMPILE COMMAND TESTS --- +#[cfg(feature = "compiler")] +mod test_compile { + use super::*; + + #[test] + fn test_compile_valid_policy() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + let policy = "pk(02e5b88fdb71c696e1a473f309a47535b7190e21a22bd25e7fc8bd055db3bba12f)"; + + cli.cmd("compile", &[policy, "--type", "wsh"]) + .assert() + .success() + .stdout(predicate::str::contains("\"descriptor\":")) + .stdout(predicate::str::contains("wsh(")); + } + + #[test] + fn test_compile_invalid_policy() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + cli.cmd("compile", &["invalid_policy", "--type", "wsh"]) + .assert() + .failure() + .stderr(predicate::str::contains("Invalid policy")); + } +} + +// --- CONFIG COMMAND TESTS --- +#[cfg(feature = "rpc")] +mod test_config { + use super::*; + use serde_json::Value; + + #[test] + fn test_save_and_read_wallet_config() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + + let desc = cli + .cmd("descriptor", &["--type", "tr"]) + .output() + .expect("Command to generate descriptors failed"); + + let desc_values: Value = + serde_json::from_slice(&desc.stdout).expect("Invalid JSON from output descriptor"); + + let pub_desc = &desc_values["public_descriptors"]; + + let ext_desc = pub_desc["external"].as_str().unwrap(); + let int_desc = pub_desc["internal"].as_str().unwrap(); + let wallet_name = "test_config_wallet"; + let client_type = "rpc"; + let db = "sqlite"; + let url = "http://localhost:18443"; + + let mut cmd_init = cli.build_base_cmd(); + cmd_init + .arg("wallet") + .arg("--wallet") + .arg(wallet_name) + .arg("config") + .arg("--ext-descriptor") + .arg(ext_desc) + .arg("--int-descriptor") + .arg(int_desc) + .arg("--client-type") + .arg(client_type) + .arg("--database-type") + .arg(db) + .arg("--url") + .arg(url); + + cmd_init.assert().success(); + + // verify saved config + let mut cmd = cli.build_base_cmd(); + cmd.arg("wallets"); + + let output = cmd.output().expect("Failed to execute wallets command"); + + assert!( + output.status.success(), + "The wallets command failed to execute" + ); + + let json_output: Value = + serde_json::from_slice(&output.stdout).expect("CLI did not output valid JSON"); + + let config = &json_output[wallet_name]; + + assert!( + !config.is_null(), + "The wallet {wallet_name} was not found in the root JSON object" + ); + + assert_eq!(config["wallet"].as_str().unwrap(), wallet_name); + assert_eq!(config["network"].as_str().unwrap(), "regtest"); + assert_eq!(config["database_type"].as_str().unwrap(), db); + assert_eq!(config["client_type"].as_str().unwrap(), client_type); + assert_eq!(config["server_url"].as_str().unwrap(), url); + assert_eq!(config["ext_descriptor"].as_str().unwrap(), ext_desc); + assert_eq!(config["int_descriptor"].as_str().unwrap(), int_desc); + } +} diff --git a/tests/integration/offline.rs b/tests/integration/offline.rs new file mode 100644 index 0000000..9beb8ff --- /dev/null +++ b/tests/integration/offline.rs @@ -0,0 +1,264 @@ +#[cfg(feature = "rpc")] +mod test_offline { + use super::*; + use crate::common::BdkCli; + use assert_cmd::Command; + use predicates::prelude::*; + use serde_json::Value; + use tempfile::TempDir; + + static WALLET_NAME: &str = "test_config_wallet"; + + /// Helper to spin up a sandboxed CLI with the generated descriptors + fn setup_wallet_config() -> (BdkCli, Command) { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + + // generate descriptors + let desc = cli + .cmd("descriptor", &["--type", "tr"]) + .output() + .expect("Command to generate descriptors failed"); + + let desc_values: Value = + serde_json::from_slice(&desc.stdout).expect("Invalid JSON from output descriptor"); + + let priv_desc = &desc_values["private_descriptors"]; + + let ext_desc = priv_desc["external"].as_str().unwrap(); + let int_desc = priv_desc["internal"].as_str().unwrap(); + let client_type = "rpc"; + let db: &str = "sqlite"; + let url = "http://localhost:18443"; + + let mut cmd_init = cli.build_base_cmd(); + cmd_init + .arg("wallet") + .arg("--wallet") + .arg(WALLET_NAME) + .arg("config") + .arg("--ext-descriptor") + .arg(ext_desc) + .arg("--int-descriptor") + .arg(int_desc) + .arg("--client-type") + .arg(client_type) + .arg("--database-type") + .arg(db) + .arg("--url") + .arg(url); + (cli, cmd_init) + } + + #[test] + fn test_new_and_unused_address() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + // new address + cli.wallet_cmd(&["--wallet", WALLET_NAME, "new_address"]) + .assert() + .success() + .stdout(predicate::str::contains("\"address\":")); + + // `unused-address` + cli.wallet_cmd(&["--wallet", WALLET_NAME, "unused_address"]) + .assert() + .success() + .stdout(predicate::str::contains("\"address\":")); + } + + #[test] + fn test_empty_wallet_balances_and_lists() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + // balance + cli.wallet_cmd(&["--wallet", WALLET_NAME, "balance"]) + .assert() + .success() + .stdout(predicate::str::contains("\"total\":")) + .stdout(predicate::str::contains("\"trusted_pending\":")) + .stdout(predicate::str::contains("\"untrusted_pending\":")) + .stdout(predicate::str::contains("\"immature\":")) + .stdout(predicate::str::contains("\"confirmed\":")); + + // Unspent UTXOs + cli.wallet_cmd(&["--wallet", WALLET_NAME, "unspent"]) + .assert() + .success() + .stdout(predicate::str::contains("[]")); + + // Transactions + cli.wallet_cmd(&["--wallet", WALLET_NAME, "transactions"]) + .assert() + .success() + .stdout(predicate::str::contains("[]")); + } + + #[test] + fn test_policies_and_public_descriptor() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + // Policies + cli.wallet_cmd(&["--wallet", WALLET_NAME, "policies"]) + .assert() + .success() + .stdout(predicate::str::contains("\"external\":")) + .stdout(predicate::str::contains("\"internal\":")); + + // Public Descriptor + cli.wallet_cmd(&["--wallet", WALLET_NAME, "public_descriptor"]) + .assert() + .success() + .stdout(predicate::str::contains("external")) + .stdout(predicate::str::contains("internal")); + } + + #[test] + fn test_create_tx_insufficient_funds() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + // create transaction + cli.wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "create_tx", + "--to", + "tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4:1000", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("Insufficient")); + } + + #[test] + fn test_combine_psbt_invalid_input() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + // Invalid create-tx. + cli.wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "combine_psbt", + "invalid_psbt", + "another_invalid_psbt", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("Invalid")); + } + + #[cfg(feature = "bip322")] + #[test] + fn test_sign_message_and_verify_message() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + let message = "bdk-cli integration test"; + + // Reveal exactly one address and reuse it for both sign and verify. + let addr_output = cli + .wallet_cmd(&["--wallet", WALLET_NAME, "new_address"]) + .output() + .expect("Failed to generate address"); + assert!( + addr_output.status.success(), + "new_address failed: {}", + String::from_utf8_lossy(&addr_output.stderr) + ); + let addr_json: Value = serde_json::from_slice(&addr_output.stdout).unwrap(); + let address = addr_json["address"] + .as_str() + .expect("missing address") + .to_string(); + println!("DEBUG signing address: {address}"); + + // Sign (default signature_type = "simple"). + let sign_output = cli + .wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "sign_message", + "--message", + message, + "--address", + &address, + ]) + .output() + .expect("Failed to sign message"); + assert!( + sign_output.status.success(), + "sign_message failed: {}", + String::from_utf8_lossy(&sign_output.stderr) + ); + let sign_json: Value = serde_json::from_slice(&sign_output.stdout).unwrap(); + let proof = sign_json["proof"].as_str().expect("missing proof"); + println!("DEBUG proof: {proof}"); + println!("DEBUG verifying same address: {address}"); + + // Verify with the identical address + message. + cli.wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "verify_message", + "--address", + &address, + "--message", + message, + "--proof", + proof, + ]) + .assert() + .success() + .stdout(predicate::str::contains("\"valid\": true")); + } + + #[cfg(feature = "bip322")] + #[test] + fn test_verify_message_rejects_tampered_message() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + let addr_output = cli + .wallet_cmd(&["--wallet", WALLET_NAME, "new_address"]) + .output() + .expect("Failed to generate address"); + let addr_json: Value = serde_json::from_slice(&addr_output.stdout).unwrap(); + let address = addr_json["address"].as_str().unwrap(); + + let sign_output = cli + .wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "sign_message", + "--message", + "original message", + "--address", + address, + ]) + .output() + .expect("Failed to sign message"); + let sign_json: Value = serde_json::from_slice(&sign_output.stdout).unwrap(); + let proof = sign_json["proof"].as_str().unwrap(); + + // A tampered message should fail + cli.wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "verify_message", + "--proof", + proof, + "--message", + "tampered message", + "--address", + address, + ]) + .assert() + .success() + .stdout(predicate::str::contains("\"valid\": false")); + } +} diff --git a/tests/integration/online.rs b/tests/integration/online.rs new file mode 100644 index 0000000..9474d76 --- /dev/null +++ b/tests/integration/online.rs @@ -0,0 +1,334 @@ +#[cfg(feature = "electrum")] +mod test_online { + use crate::common::BdkCli; + use assert_cmd::Command; + use bdk_testenv::{TestEnv, bitcoincore_rpc::RpcApi}; + use bdk_wallet::bitcoin::{Address, Amount, Network, Txid}; + use serde_json::Value; + use std::str::FromStr; + use std::time::Duration; + use tempfile::TempDir; + + static WALLET_NAME: &str = "test_online_wallet"; + static RECIPIENT: &str = "tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4"; + + /// Spins up the envt, a Regtest node + electrs, and saves the wallet config. + fn setup_online_wallet() -> (BdkCli, Command, TestEnv) { + let env = TestEnv::new().expect("Failed to start bdk_testenv"); + let server_url = env.electrsd.electrum_url.as_str(); + + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + + let desc = cli + .cmd("descriptor", &["--type", "tr"]) + .output() + .expect("Command to generate descriptors failed"); + let desc_values: Value = + serde_json::from_slice(&desc.stdout).expect("Invalid JSON from output descriptor"); + let priv_desc = &desc_values["private_descriptors"]; + let ext_desc = priv_desc["external"].as_str().unwrap(); + let int_desc = priv_desc["internal"].as_str().unwrap(); + + let mut cmd_init = cli.build_base_cmd(); + cmd_init + .arg("wallet") + .arg("--wallet") + .arg(WALLET_NAME) + .arg("config") + .arg("--ext-descriptor") + .arg(ext_desc) + .arg("--int-descriptor") + .arg(int_desc) + .arg("--client-type") + .arg("electrum") + .arg("--database-type") + .arg("sqlite") + .arg("--url") + .arg(server_url); + + (cli, cmd_init, env) + } + + /// Mines blocks, funds wallet with 0.5 BTC, confirms it, and + /// runs `full_scan` so the wallet's persisted state reflects the funding. + /// Asserts the resulting confirmed balance is exactly 50,000,000 sats. + fn fund_and_sync_wallet(cli: &BdkCli, env: &TestEnv) { + let address = cli_new_address(cli); + + let node_address = env + .rpc_client() + .get_new_address(None, None) + .unwrap() + .assume_checked(); + + let mined_blocks = env + .mine_blocks(101, Some(node_address)) + .expect("Failed to mine initial blocks"); + assert_eq!(mined_blocks.len(), 101, "expected exactly 101 blocks mined"); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .expect("Electrum did not catch up to initial blocks"); + + let txid = env + .send(&address, Amount::from_btc(0.5).unwrap()) + .expect("Failed to fund wallet address"); + env.wait_until_electrum_sees_txid(txid, Duration::from_secs(10)) + .expect("Electrum did not see funding tx"); + + env.mine_blocks(3, None) + .expect("Failed to confirm funding tx"); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .expect("Electrum did not catch up to confirmation blocks"); + + cli_full_scan(cli); + assert_eq!( + cli_balance(cli), + 50_000_000, + "wallet should show funded balance after full_scan" + ); + } + + /// Runs `wallet --wallet `, asserts the process exited + /// successfully, and parses stdout as JSON. + fn run_wallet_json(cli: &BdkCli, args: &[&str]) -> Value { + let mut full_args = vec!["--wallet", WALLET_NAME]; + full_args.extend_from_slice(args); + + let output = cli + .wallet_cmd(&full_args) + .output() + .unwrap_or_else(|e| panic!("failed to spawn `{}`: {e}", args.join(" "))); + + assert!( + output.status.success(), + "`{}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + + serde_json::from_slice(&output.stdout).unwrap_or_else(|e| { + panic!( + "`{}` returned non-JSON stdout ({e}): {}", + args.join(" "), + String::from_utf8_lossy(&output.stdout) + ) + }) + } + + fn cli_new_address(cli: &BdkCli) -> Address { + let json = run_wallet_json(cli, &["new_address"]); + let addr = json["address"] + .as_str() + .expect("new_address: missing 'address' field"); + Address::from_str(addr) + .expect("new_address: invalid address string") + .require_network(Network::Regtest) + .expect("new_address: wrong network") + } + + fn cli_full_scan(cli: &BdkCli) { + run_wallet_json(cli, &["full_scan"]); + } + + fn cli_sync(cli: &BdkCli) { + run_wallet_json(cli, &["sync"]); + } + + fn cli_balance(cli: &BdkCli) -> u64 { + let json = run_wallet_json(cli, &["balance"]); + json["confirmed"] + .as_u64() + .expect("balance: missing 'confirmed' field") + } + + fn cli_create_tx(cli: &BdkCli, to: &str) -> String { + let json = run_wallet_json(cli, &["create_tx", "--to", to]); + json["psbt"] + .as_str() + .expect("create_tx: missing 'psbt' field") + .to_string() + } + + /// Returns (signed_psbt_base64, is_finalized). + fn cli_sign(cli: &BdkCli, psbt: &str) -> (String, bool) { + let json = run_wallet_json(cli, &["sign", psbt]); + let signed = json["psbt"] + .as_str() + .expect("sign: missing 'psbt' field") + .to_string(); + let finalized = json["is_finalized"] + .as_bool() + .expect("sign: missing 'is_finalized' field"); + (signed, finalized) + } + + fn cli_extract_psbt(cli: &BdkCli, psbt: &str) -> String { + let json = run_wallet_json(cli, &["extract_psbt", psbt]); + json["raw_tx"] + .as_str() + .expect("extract_psbt: missing 'raw_tx' field") + .to_string() + } + + fn cli_broadcast(cli: &BdkCli, raw_tx: &str) -> Txid { + let json = run_wallet_json(cli, &["broadcast", "--tx", raw_tx]); + let txid = json["txid"] + .as_str() + .expect("broadcast: missing 'txid' field"); + Txid::from_str(txid).expect("broadcast: invalid txid") + } + + #[test] + fn test_full_online_transaction_lifecycle() { + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + + fund_and_sync_wallet(&cli, &env); + + // Build, sign, extract, and broadcast a spend + let unsigned_psbt = cli_create_tx(&cli, &format!("{RECIPIENT}:20000")); + + let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); + assert!(finalized, "PSBT should be finalized after signing"); + + let raw_tx = cli_extract_psbt(&cli, &signed_psbt); + + let spend_txid = cli_broadcast(&cli, &raw_tx); + env.rpc_client() + .get_mempool_entry(&spend_txid) + .expect("broadcast tx not found in node mempool"); + + // Confirm the spend and verify the final balance + env.mine_blocks(1, None) + .expect("Failed to mine spend confirmation block"); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .expect("Electrum did not catch up to spend confirmation"); + + cli_sync(&cli); + let final_balance = cli_balance(&cli); + assert!( + (49_900_000..50_000_000).contains(&final_balance), + "unexpected post-spend confirmed balance: {final_balance}" + ); + } + + #[test] + fn test_finalize_psbt_on_signed_tx() { + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + let unsigned_psbt = cli_create_tx(&cli, &format!("{RECIPIENT}:15000")); + let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); + assert!(finalized, "PSBT should be finalized after signing"); + + // finalize_psbt should be idempotent: finalizing an already-finalized + // PSBT should succeed and still report is_finalized = true. + let finalize_json = run_wallet_json(&cli, &["finalize_psbt", &signed_psbt]); + assert_eq!( + finalize_json["is_finalized"].as_bool(), + Some(true), + "finalize_psbt should report the PSBT as finalized" + ); + } + + #[test] + fn test_combine_psbt_merges_signatures() { + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + let unsigned_psbt = cli_create_tx(&cli, &format!("{RECIPIENT}:15000")); + let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); + assert!(finalized, "PSBT should be finalized after signing"); + + // Combine the unsigned and signed versions of the same transaction + let combined_json = run_wallet_json(&cli, &["combine_psbt", &unsigned_psbt, &signed_psbt]); + let combined_psbt = combined_json["psbt"] + .as_str() + .expect("combine_psbt: missing 'psbt' field"); + + // If signature data survived the combine, this should extract cleanly. + let raw_tx = cli_extract_psbt(&cli, combined_psbt); + assert!( + !raw_tx.is_empty(), + "combined PSBT did not extract to a valid raw transaction" + ); + } + + #[test] + fn test_bump_fee_replaces_unconfirmed_tx() { + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + // Create and broadcast a low-fee-rate, RBF-enabled transaction. + let unsigned_psbt = run_wallet_json( + &cli, + &[ + "create_tx", + "--to", + &format!("{RECIPIENT}:15000"), + "--fee_rate", + "1.0", + ], + )["psbt"] + .as_str() + .expect("create_tx: missing 'psbt' field") + .to_string(); + + let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); + assert!(finalized); + let raw_tx = cli_extract_psbt(&cli, &signed_psbt); + let original_txid = cli_broadcast(&cli, &raw_tx); + + env.rpc_client() + .get_mempool_entry(&original_txid) + .expect("original tx should be in the mempool before bumping"); + + env.wait_until_electrum_sees_txid(original_txid, Duration::from_secs(10)) + .expect("electrs did not see the broadcast tx"); + cli_sync(&cli); + + // Bump to a much higher fee rate; this should replace the original tx. + let bumped_json = run_wallet_json( + &cli, + &[ + "bump_fee", + "--txid", + &original_txid.to_string(), + "--fee_rate", + "10.0", + ], + ); + let bumped_psbt = bumped_json["psbt"] + .as_str() + .expect("bump_fee: missing 'psbt' field"); + + let (signed_bumped, bumped_finalized) = cli_sign(&cli, bumped_psbt); + assert!( + bumped_finalized, + "bumped PSBT should be finalized after signing" + ); + + let bumped_raw_tx = cli_extract_psbt(&cli, &signed_bumped); + let bumped_txid = cli_broadcast(&cli, &bumped_raw_tx); + + assert_ne!( + original_txid, bumped_txid, + "fee-bumped transaction should have a different txid than the original" + ); + + env.mine_blocks(1, None) + .expect("Failed to confirm bumped tx"); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .expect("Electrum did not catch up after bump confirmation"); + cli_sync(&cli); + + let final_balance = cli_balance(&cli); + assert!( + final_balance < 50_000_000, + "balance should reflect exactly one confirmed spend after RBF, got {final_balance}" + ); + } +}