diff --git a/CHANGELOG.md b/CHANGELOG.md index 3959cd0..f78551a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- `onchain_broadcast_raw_tx` now returns the transaction's canonical txid, computed locally in Rust, and treats Electrum "already known / already in mempool / already in block chain" responses as success (returning that same txid). This lets native apps complete Blocktank funding bookkeeping when they retry a broadcast after an ambiguous network failure, without relying on a signer-provided txid. Genuine connectivity failures and unrelated broadcast rejections remain typed `BroadcastError`s, and there is no FFI signature change. - Surface a locked Trezor during the THP handshake as the typed `TrezorError::DeviceBusy` instead of a generic connection error, so mobile clients back off and prompt the user to unlock rather than reconnecting in a loop. Backed by `trezor-connect-rs` 0.3.4, which classifies `DeviceLocked` as a distinct, non-retryable state: it no longer churns the transport (close/reopen loop) on a locked device and instead makes a single `try_to_unlock` handshake attempt so the device prompts for unlock. ## 0.3.3 - 2026-06-22 diff --git a/Cargo.lock b/Cargo.lock index 561c3b3..e29bbd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -475,7 +475,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.4.0" +version = "0.4.1" dependencies = [ "android_logger", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 1d5074e..28600f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.4.0" +version = "0.4.1" edition = "2021" [lib] diff --git a/Package.swift b/Package.swift index 74dcbb1..1421230 100644 --- a/Package.swift +++ b/Package.swift @@ -3,8 +3,8 @@ import PackageDescription -let tag = "v0.4.0" -let checksum = "6faa4a32a14853eaaf1ece7ddb1e042fa46e0515d933b0befae55c77ebf8ca07" +let tag = "v0.4.1" +let checksum = "867bea0633b707c5da7eef38439b4a91310540cad07baa11be4ddf43da60b0f7" 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 92ead84..e48f767 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.4.0 +version=0.4.1 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 e6b1f04..f5b8946 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 bacf5af..31f15ed 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 7d824f1..1229d19 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 4c59b5d..41a56a8 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 7dbb8b6..6e923f9 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 8eb5c21..ca8fede 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 9ff7b8f..34b9ab4 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/bindings/python/bitkitcore/libbitkitcore.dylib b/bindings/python/bitkitcore/libbitkitcore.dylib index c68538b..ee3e21d 100755 Binary files a/bindings/python/bitkitcore/libbitkitcore.dylib and b/bindings/python/bitkitcore/libbitkitcore.dylib differ diff --git a/bindings/python/setup.py b/bindings/python/setup.py index 7ec08a6..550ab36 100644 --- a/bindings/python/setup.py +++ b/bindings/python/setup.py @@ -2,7 +2,7 @@ setup( name="bitkitcore", - version="0.4.0", + version="0.4.1", packages=find_packages(), package_data={ "bitkitcore": ["*.so", "*.dylib", "*.dll"], diff --git a/src/modules/onchain/implementation.rs b/src/modules/onchain/implementation.rs index 51db551..6f28870 100644 --- a/src/modules/onchain/implementation.rs +++ b/src/modules/onchain/implementation.rs @@ -1009,45 +1009,97 @@ impl BitcoinAddressValidator { } } -/// Broadcast a signed raw transaction via Electrum. +/// Returns true when an Electrum broadcast error indicates the transaction was +/// already accepted (already in a block, in the mempool, or otherwise known) +/// rather than a real failure, meaning a retry of a previously successful +/// broadcast. /// -/// Takes a hex-encoded serialized transaction and an Electrum server URL. -/// Returns the transaction ID on success. -pub async fn broadcast_raw_tx( - serialized_tx: String, - electrum_url: &str, -) -> Result { - let tx_bytes = hex::decode(&serialized_tx).map_err(|e| BroadcastError::InvalidHex { +/// Matching is case-insensitive. It must NOT classify relay-policy or +/// invalid-transaction rejections (e.g. `bad-txns-inputs-missingorspent`, +/// `min relay fee not met`) as already-known. +pub(crate) fn is_already_known_broadcast_error(message: &str) -> bool { + const ALREADY_KNOWN_MARKERS: [&str; 7] = [ + "already in block chain", + "already in blockchain", + "already in mempool", + "already-in-block-chain", + "already-in-mempool", + "txn-already-known", + "transaction already exists", + ]; + let lowered = message.to_lowercase(); + ALREADY_KNOWN_MARKERS + .iter() + .any(|marker| lowered.contains(marker)) +} + +/// Decode a hex-encoded transaction, validate that it deserializes, and compute +/// its canonical txid. Returns the raw bytes (for broadcast) alongside the txid. +/// +/// Note: within this module the transaction is a `bdk::bitcoin::Transaction` +/// (bitcoin 0.30), whose canonical-txid method is `.txid()` (the equivalent of +/// `compute_txid()` in newer bitcoin releases). It returns the txid, never the +/// witness txid (wtxid). +pub(crate) fn decode_and_compute_txid( + serialized_tx: &str, +) -> Result<(Vec, Txid), BroadcastError> { + let tx_bytes = hex::decode(serialized_tx).map_err(|e| BroadcastError::InvalidHex { error_details: format!("Invalid transaction hex: {}", e), })?; - // Validate that the bytes are a valid transaction - let _tx: Transaction = + let tx: Transaction = deserialize(&tx_bytes).map_err(|e| BroadcastError::InvalidTransaction { error_details: format!("Invalid transaction data: {}", e), })?; + let txid = tx.txid(); + Ok((tx_bytes, txid)) +} + +/// Broadcast a signed raw transaction via Electrum. +/// +/// Takes a hex-encoded serialized transaction and an Electrum server URL. +/// Returns the transaction's canonical txid (computed locally) on success. +/// +/// If Electrum reports that the transaction is already known (already in a block, +/// in the mempool, or otherwise accepted), this is treated as success and the same +/// locally computed txid is returned, so retrying a broadcast after an ambiguous +/// network failure completes cleanly. Genuine connectivity failures and unrelated +/// broadcast rejections are preserved as typed errors. +pub async fn broadcast_raw_tx( + serialized_tx: String, + electrum_url: &str, +) -> Result { + let (tx_bytes, local_txid) = decode_and_compute_txid(&serialized_tx)?; let electrum_url_owned = electrum_url.to_string(); - let txid = tokio::task::spawn_blocking(move || { + tokio::task::spawn_blocking(move || { let client = bdk::electrum_client::Client::new(&electrum_url_owned).map_err(|e| { BroadcastError::ElectrumError { error_details: format!("Failed to connect to Electrum: {}", e), } })?; - client - .transaction_broadcast_raw(&tx_bytes) - .map_err(|e| BroadcastError::ElectrumError { - error_details: format!("Broadcast failed: {}", e), - }) + match client.transaction_broadcast_raw(&tx_bytes) { + Ok(_) => Ok(()), + Err(e) => { + let message = e.to_string(); + if is_already_known_broadcast_error(&message) { + Ok(()) + } else { + Err(BroadcastError::ElectrumError { + error_details: format!("Broadcast failed: {}", message), + }) + } + } + } }) .await .map_err(|e| BroadcastError::TaskError { error_details: format!("Broadcast task failed: {}", e), })??; - Ok(txid.to_string()) + Ok(local_txid.to_string()) } // ============================================================================ diff --git a/src/modules/onchain/tests.rs b/src/modules/onchain/tests.rs index d1dbec1..2bd0577 100644 --- a/src/modules/onchain/tests.rs +++ b/src/modules/onchain/tests.rs @@ -849,6 +849,112 @@ mod tests { )); } + #[test] + fn test_decode_and_compute_txid_legacy_returns_canonical_txid() { + use super::super::implementation::decode_and_compute_txid; + + // Bitcoin block 170 transaction (Satoshi -> Hal Finney), a legacy + // (non-segwit) transaction with a well-known txid. + let raw_hex = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000"; + let expected_txid = "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"; + + let (_bytes, txid) = decode_and_compute_txid(raw_hex).expect("valid legacy tx"); + assert_eq!(txid.to_string(), expected_txid); + } + + #[test] + fn test_decode_and_compute_txid_segwit_returns_txid_not_wtxid() { + use super::super::implementation::decode_and_compute_txid; + use bdk::bitcoin::absolute::LockTime; + use bdk::bitcoin::consensus::serialize; + use bdk::bitcoin::{OutPoint, Sequence, TxIn, Txid, Witness}; + + // Build a transaction carrying witness data so that its txid (serialized + // without witness) and wtxid (serialized with witness) diverge. + let mut witness = Witness::new(); + witness.push([0x30u8; 72]); // dummy signature + witness.push([0x02u8; 33]); // dummy pubkey + + let prev_txid = + Txid::from_str("4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b") + .unwrap(); + + let tx = Transaction { + version: 2, + lock_time: LockTime::from_consensus(0), + input: vec![TxIn { + previous_output: OutPoint { + txid: prev_txid, + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness, + }], + output: vec![TxOut { + value: 10_000, + script_pubkey: ScriptBuf::new(), + }], + }; + + // A witness-bearing transaction must have distinct txid and wtxid. + assert_ne!(tx.txid().to_string(), tx.wtxid().to_string()); + + let raw_hex = hex::encode(serialize(&tx)); + let (_bytes, txid) = decode_and_compute_txid(&raw_hex).expect("valid segwit tx"); + assert_eq!(txid, tx.txid()); + assert_ne!(txid.to_string(), tx.wtxid().to_string()); + } + + #[test] + fn test_is_already_known_broadcast_error_accepts_known_responses() { + use super::super::implementation::is_already_known_broadcast_error; + + let cases = [ + "already in block chain", + "already in blockchain", + "already in mempool", + "already-in-block-chain", + "already-in-mempool", + "txn-already-known", + "transaction already exists", + // Case-insensitive and embedded in a larger server message. + "Transaction already in block chain", + "TXN-ALREADY-KNOWN", + "Broadcast failed: Electrum server error: {\"code\":-27,\"message\":\"transaction already in block chain\"}", + "sendrawtransaction RPC error: transaction already in mempool", + ]; + + for case in cases { + assert!( + is_already_known_broadcast_error(case), + "expected already-known match for: {case}" + ); + } + } + + #[test] + fn test_is_already_known_broadcast_error_rejects_unrelated_errors() { + use super::super::implementation::is_already_known_broadcast_error; + + let cases = [ + "bad-txns-inputs-missingorspent", + "min relay fee not met", + "scriptsig-not-pushonly", + "bad-txns-in-belowout", + "Failed to connect to Electrum: connection refused", + "dust", + "", + ]; + + for case in cases { + assert!( + !is_already_known_broadcast_error(case), + "unexpected already-known match for: {case}" + ); + } + } + // ======================================================================== // Account Info Tests // ========================================================================