diff --git a/Cargo.lock b/Cargo.lock index 8b0a72c..81a2442 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -475,7 +475,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.1.66" +version = "0.1.67" dependencies = [ "android_logger", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 745cfc9..8aeeb16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.1.66" +version = "0.1.67" edition = "2021" [lib] diff --git a/Package.swift b/Package.swift index 246d7a9..a8505a4 100644 --- a/Package.swift +++ b/Package.swift @@ -3,8 +3,8 @@ import PackageDescription -let tag = "v0.1.66" -let checksum = "cc68047c13418f127114afb776faa460573d9d08f3571371f5ed6e94ed30c029" +let tag = "v0.1.67" +let checksum = "96e05d5061ab9692f63ebfaccc980600cf11042b02c920459edd03e0a881fe0e" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let package = Package( diff --git a/bindings/android/gradle.properties b/bindings/android/gradle.properties index 62cba1f..b60a220 100644 --- a/bindings/android/gradle.properties +++ b/bindings/android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.1.66 +version=0.1.67 diff --git a/bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so b/bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so index c5c97f0..bce3418 100755 Binary files a/bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so and b/bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so differ diff --git a/bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so b/bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so index acfc3e4..9c32e8e 100755 Binary files a/bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so and b/bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so differ diff --git a/bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so b/bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so index 8a0afeb..1fa8f52 100755 Binary files a/bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so and b/bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so differ diff --git a/bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so b/bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so index b9acdd9..1cb60af 100755 Binary files a/bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so and b/bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so differ diff --git a/bindings/ios/BitkitCore.xcframework.zip b/bindings/ios/BitkitCore.xcframework.zip index 817bc81..9614b20 100644 Binary files a/bindings/ios/BitkitCore.xcframework.zip and b/bindings/ios/BitkitCore.xcframework.zip differ diff --git a/bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/libbitkitcore.a b/bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/libbitkitcore.a index 58a5438..0f64676 100644 Binary files a/bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/libbitkitcore.a and b/bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/libbitkitcore.a differ diff --git a/bindings/ios/BitkitCore.xcframework/ios-arm64/libbitkitcore.a b/bindings/ios/BitkitCore.xcframework/ios-arm64/libbitkitcore.a index c0284a1..c52bc3c 100644 Binary files a/bindings/ios/BitkitCore.xcframework/ios-arm64/libbitkitcore.a and b/bindings/ios/BitkitCore.xcframework/ios-arm64/libbitkitcore.a differ diff --git a/src/modules/onchain/implementation.rs b/src/modules/onchain/implementation.rs index dcfff70..cf181e3 100644 --- a/src/modules/onchain/implementation.rs +++ b/src/modules/onchain/implementation.rs @@ -1,4 +1,6 @@ +use std::any::Any; use std::collections::{HashMap, HashSet}; +use std::panic::{catch_unwind, AssertUnwindSafe}; use std::str::FromStr; use base64::{engine::general_purpose, Engine as _}; @@ -1308,6 +1310,39 @@ pub(crate) fn connect_and_get_tip( Ok((client, tip_height)) } +fn panic_payload_to_string(payload: &(dyn Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "unknown panic payload".to_string() + } +} + +pub(super) async fn run_account_info_blocking( + task_name: &'static str, + task: F, +) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tokio::task::spawn_blocking(move || { + catch_unwind(AssertUnwindSafe(task)).map_err(|payload| AccountInfoError::SyncError { + error_details: format!( + "{} task panicked: {}", + task_name, + panic_payload_to_string(payload.as_ref()) + ), + })? + }) + .await + .map_err(|e| AccountInfoError::SyncError { + error_details: format!("{} task failed: {}", task_name, e), + })? +} + /// Create a BDK wallet (in-memory, unsynced) from the resolved setup. /// /// Addresses can be derived from the returned wallet without syncing; call @@ -1827,70 +1862,77 @@ pub async fn get_address_info( let electrum_url_owned = electrum_url.to_string(); let addr_str = address.to_string(); - let result = - tokio::task::spawn_blocking(move || { - let (client, tip_height) = connect_and_get_tip(&electrum_url_owned)?; + let result = run_account_info_blocking("address info", move || { + let (client, tip_height) = connect_and_get_tip(&electrum_url_owned)?; - let script = bdk_addr.script_pubkey(); + let script = bdk_addr.script_pubkey(); - // Get UTXOs for this address - let utxos = client.script_list_unspent(&script).map_err(|e| { - AccountInfoError::ElectrumError { + // Get UTXOs for this address + let utxos = + client + .script_list_unspent(&script) + .map_err(|e| AccountInfoError::ElectrumError { error_details: format!("Failed to list UTXOs: {}", e), - } - })?; + })?; - // Get history for transfer count - let history = client.script_get_history(&script).map_err(|e| { - AccountInfoError::ElectrumError { + // Get history for transfer count + let history = + client + .script_get_history(&script) + .map_err(|e| AccountInfoError::ElectrumError { error_details: format!("Failed to get history: {}", e), - } - })?; + })?; - let account_utxos: Vec = utxos - .iter() - .map(|utxo| { - let height = u32::try_from(utxo.height).unwrap_or(0); - let confirmations = if height > 0 { - tip_height.saturating_sub(height) + 1 - } else { - 0 - }; - - let vout = - u32::try_from(utxo.tx_pos).map_err(|_| AccountInfoError::WalletError { - error_details: format!("Output index {} exceeds u32", utxo.tx_pos), - })?; + let account_utxos: Vec = utxos + .iter() + .map(|utxo| { + let height = + u32::try_from(utxo.height).map_err(|_| AccountInfoError::ElectrumError { + error_details: format!("UTXO height {} exceeds u32", utxo.height), + })?; + let confirmations = if height > 0 { + tip_height.saturating_sub(height) + 1 + } else { + 0 + }; + + let vout = + u32::try_from(utxo.tx_pos).map_err(|_| AccountInfoError::WalletError { + error_details: format!("Output index {} exceeds u32", utxo.tx_pos), + })?; - Ok(AccountUtxo { - txid: utxo.tx_hash.to_string(), - vout, - amount: utxo.value, - block_height: height, - address: addr_str.clone(), - path: String::new(), // No derivation path for single address - confirmations, - coinbase: false, - own: true, - required: None, - }) + Ok(AccountUtxo { + txid: utxo.tx_hash.to_string(), + vout, + amount: utxo.value, + block_height: height, + address: addr_str.clone(), + path: String::new(), // No derivation path for single address + confirmations, + coinbase: false, + own: true, + required: None, }) - .collect::, AccountInfoError>>()?; + }) + .collect::, AccountInfoError>>()?; - let balance: u64 = utxos.iter().map(|u| u.value).sum(); + let balance = utxos.iter().try_fold(0u64, |balance, utxo| { + balance + .checked_add(utxo.value) + .ok_or_else(|| AccountInfoError::ElectrumError { + error_details: "Address UTXO balance overflow".to_string(), + }) + })?; - Ok::<_, AccountInfoError>(SingleAddressInfoResult { - address: addr_str, - balance, - utxos: account_utxos, - transfers: u32::try_from(history.len()).unwrap_or(u32::MAX), - block_height: tip_height, - }) + Ok::<_, AccountInfoError>(SingleAddressInfoResult { + address: addr_str, + balance, + utxos: account_utxos, + transfers: u32::try_from(history.len()).unwrap_or(u32::MAX), + block_height: tip_height, }) - .await - .map_err(|e| AccountInfoError::SyncError { - error_details: format!("Task failed: {}", e), - })??; + }) + .await?; Ok(result) } diff --git a/src/modules/onchain/tests.rs b/src/modules/onchain/tests.rs index 595f77f..2501524 100644 --- a/src/modules/onchain/tests.rs +++ b/src/modules/onchain/tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use super::super::implementation::{ - onchain_to_bdk_network, LegacyRnNativeSegwitRecoverySpendable, + onchain_to_bdk_network, run_account_info_blocking, LegacyRnNativeSegwitRecoverySpendable, }; use crate::modules::onchain::{AccountType, AddressType, BitcoinAddressValidator}; use crate::modules::scanner::NetworkType; @@ -13,7 +13,11 @@ mod tests { use bdk::wallet::{AddressIndex as BdkAddressIndex, Wallet}; use bitcoin::bip32::Xpub; use bitcoin::{Network, NetworkKind}; + use serde_json::{json, Value}; + use std::io::{BufRead, BufReader, Write}; + use std::net::{Shutdown, TcpListener, TcpStream}; use std::str::FromStr; + use std::thread; fn legacy_rn_recovery_script( mnemonic_phrase: &str, @@ -860,6 +864,132 @@ mod tests { const TEST_REGTEST_BECH32_ADDR: &str = "bcrt1qj2gz3meule5mc4r4knv65vjds3g88rlxs0jlmq"; const TEST_TESTNET_BECH32_ADDR: &str = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"; + #[derive(Clone, Copy)] + enum FakeAddressInfoElectrum { + Empty, + MalformedUtxos, + MalformedHistory, + DisconnectAfterTip, + } + + fn start_fake_address_info_electrum(scenario: FakeAddressInfoElectrum) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + thread::spawn(move || { + if let Ok((stream, _)) = listener.accept() { + handle_fake_address_info_electrum(stream, scenario); + } + }); + format!("tcp://{}", addr) + } + + fn handle_fake_address_info_electrum(mut stream: TcpStream, scenario: FakeAddressInfoElectrum) { + let read_stream = stream.try_clone().unwrap(); + let mut reader = BufReader::new(read_stream); + let header_hex = "00".repeat(80); + + if !write_electrum_method_response( + &mut reader, + &mut stream, + "blockchain.headers.subscribe", + json!({ + "height": 100, + "hex": header_hex, + }), + ) { + return; + } + + if matches!(scenario, FakeAddressInfoElectrum::DisconnectAfterTip) { + let _ = stream.shutdown(Shutdown::Both); + return; + } + + let utxo_result = match scenario { + FakeAddressInfoElectrum::MalformedUtxos => json!([ + { + "height": "not-a-number", + "tx_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "tx_pos": 0, + "value": 1 + } + ]), + _ => json!([]), + }; + if !write_electrum_method_response( + &mut reader, + &mut stream, + "blockchain.scripthash.listunspent", + utxo_result, + ) { + return; + } + + let history_result = match scenario { + FakeAddressInfoElectrum::MalformedHistory => json!([ + { + "height": 0, + "tx_hash": 42 + } + ]), + _ => json!([]), + }; + let _ = write_electrum_method_response( + &mut reader, + &mut stream, + "blockchain.scripthash.get_history", + history_result, + ); + } + + fn write_electrum_method_response( + reader: &mut BufReader, + stream: &mut TcpStream, + expected_method: &str, + result: Value, + ) -> bool { + let Some((id, method)) = read_electrum_request(reader) else { + return false; + }; + if method != expected_method { + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32601, + "message": format!("unexpected method: {}", method), + }, + }); + writeln!(stream, "{}", response).unwrap(); + stream.flush().unwrap(); + return false; + } + + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }); + writeln!(stream, "{}", response).unwrap(); + stream.flush().unwrap(); + true + } + + fn read_electrum_request(reader: &mut BufReader) -> Option<(Value, String)> { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap() == 0 { + return None; + } + let request: Value = serde_json::from_str(&line).unwrap(); + let id = request.get("id").cloned().unwrap_or(Value::Null); + let method = request + .get("method") + .and_then(Value::as_str) + .expect("Electrum request missing method") + .to_string(); + Some((id, method)) + } + // --- Unit Tests: Helper Functions --- #[test] @@ -1571,6 +1701,130 @@ mod tests { assert!(result.is_err(), "Expected error for invalid electrum URL"); } + #[tokio::test] + async fn test_get_address_info_empty_history_and_utxos() { + use crate::modules::onchain::get_address_info; + use crate::modules::onchain::Network as OnchainNetwork; + + let electrum_url = start_fake_address_info_electrum(FakeAddressInfoElectrum::Empty); + let result = get_address_info( + TEST_TESTNET_BECH32_ADDR, + &electrum_url, + Some(OnchainNetwork::Testnet), + ) + .await + .expect("empty Electrum history and UTXOs should succeed"); + + assert_eq!(result.address, TEST_TESTNET_BECH32_ADDR); + assert_eq!(result.balance, 0); + assert!(result.utxos.is_empty()); + assert_eq!(result.transfers, 0); + assert_eq!(result.block_height, 100); + } + + #[tokio::test] + async fn test_get_address_info_malformed_utxo_response_returns_error() { + use crate::modules::onchain::get_address_info; + use crate::modules::onchain::{AccountInfoError, Network as OnchainNetwork}; + + let electrum_url = + start_fake_address_info_electrum(FakeAddressInfoElectrum::MalformedUtxos); + let result = get_address_info( + TEST_TESTNET_BECH32_ADDR, + &electrum_url, + Some(OnchainNetwork::Testnet), + ) + .await; + + match result.unwrap_err() { + AccountInfoError::ElectrumError { error_details } => { + assert!(error_details.contains("Failed to list UTXOs")); + } + other => panic!("Expected ElectrumError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_get_address_info_malformed_history_response_returns_error() { + use crate::modules::onchain::get_address_info; + use crate::modules::onchain::{AccountInfoError, Network as OnchainNetwork}; + + let electrum_url = + start_fake_address_info_electrum(FakeAddressInfoElectrum::MalformedHistory); + let result = get_address_info( + TEST_TESTNET_BECH32_ADDR, + &electrum_url, + Some(OnchainNetwork::Testnet), + ) + .await; + + match result.unwrap_err() { + AccountInfoError::ElectrumError { error_details } => { + assert!(error_details.contains("Failed to get history")); + } + other => panic!("Expected ElectrumError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_get_address_info_network_disconnect_returns_error() { + use crate::modules::onchain::get_address_info; + use crate::modules::onchain::{AccountInfoError, Network as OnchainNetwork}; + + let electrum_url = + start_fake_address_info_electrum(FakeAddressInfoElectrum::DisconnectAfterTip); + let result = get_address_info( + TEST_TESTNET_BECH32_ADDR, + &electrum_url, + Some(OnchainNetwork::Testnet), + ) + .await; + + match result.unwrap_err() { + AccountInfoError::ElectrumError { error_details } => { + assert!(error_details.contains("Failed to list UTXOs")); + } + other => panic!("Expected ElectrumError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_get_address_info_network_mismatch() { + use crate::modules::onchain::get_address_info; + use crate::modules::onchain::{AccountInfoError, Network as OnchainNetwork}; + + let result = get_address_info( + TEST_TESTNET_BECH32_ADDR, + ACCOUNT_INFO_ELECTRUM_URL, + Some(OnchainNetwork::Bitcoin), + ) + .await; + + match result.unwrap_err() { + AccountInfoError::NetworkMismatch { .. } => {} + other => panic!("Expected NetworkMismatch, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_account_info_blocking_task_panic_returns_error() { + use crate::modules::onchain::AccountInfoError; + + let result = + run_account_info_blocking("test address info", || -> Result<(), AccountInfoError> { + panic!("simulated address info panic") + }) + .await; + + match result.unwrap_err() { + AccountInfoError::SyncError { error_details } => { + assert!(error_details.contains("test address info task panicked")); + assert!(error_details.contains("simulated address info panic")); + } + other => panic!("Expected SyncError, got: {:?}", other), + } + } + // ======================================================================== // Transaction History Tests // ========================================================================