Skip to content

Commit adaf40f

Browse files
committed
fix(onchain): return canonical txid for already-known raw broadcasts
broadcast_raw_tx now computes the transaction's canonical txid locally and returns it on success. Electrum "already known / already in mempool / already in block chain" responses are treated as success and return that same txid, so apps that broadcast separately from Trezor signing can complete funding bookkeeping when retrying after an ambiguous network failure, without relying on a signer-provided txid. Duplicate-response classification is a focused, case-insensitive helper that does not turn relay-policy or invalid-transaction rejections into success. Connectivity failures, unrelated broadcast rejections, invalid hex, and invalid transaction data keep their existing typed BroadcastError variants. There is no FFI signature change. Refs #122
1 parent be38d75 commit adaf40f

3 files changed

Lines changed: 176 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- `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.
56
- 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.
67

78
## 0.3.3 - 2026-06-22

src/modules/onchain/implementation.rs

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,45 +1009,97 @@ impl BitcoinAddressValidator {
10091009
}
10101010
}
10111011

1012-
/// Broadcast a signed raw transaction via Electrum.
1012+
/// Returns true when an Electrum broadcast error indicates the transaction was
1013+
/// already accepted (already in a block, in the mempool, or otherwise known)
1014+
/// rather than a real failure, meaning a retry of a previously successful
1015+
/// broadcast.
10131016
///
1014-
/// Takes a hex-encoded serialized transaction and an Electrum server URL.
1015-
/// Returns the transaction ID on success.
1016-
pub async fn broadcast_raw_tx(
1017-
serialized_tx: String,
1018-
electrum_url: &str,
1019-
) -> Result<String, BroadcastError> {
1020-
let tx_bytes = hex::decode(&serialized_tx).map_err(|e| BroadcastError::InvalidHex {
1017+
/// Matching is case-insensitive. It must NOT classify relay-policy or
1018+
/// invalid-transaction rejections (e.g. `bad-txns-inputs-missingorspent`,
1019+
/// `min relay fee not met`) as already-known.
1020+
pub(crate) fn is_already_known_broadcast_error(message: &str) -> bool {
1021+
const ALREADY_KNOWN_MARKERS: [&str; 7] = [
1022+
"already in block chain",
1023+
"already in blockchain",
1024+
"already in mempool",
1025+
"already-in-block-chain",
1026+
"already-in-mempool",
1027+
"txn-already-known",
1028+
"transaction already exists",
1029+
];
1030+
let lowered = message.to_lowercase();
1031+
ALREADY_KNOWN_MARKERS
1032+
.iter()
1033+
.any(|marker| lowered.contains(marker))
1034+
}
1035+
1036+
/// Decode a hex-encoded transaction, validate that it deserializes, and compute
1037+
/// its canonical txid. Returns the raw bytes (for broadcast) alongside the txid.
1038+
///
1039+
/// Note: within this module the transaction is a `bdk::bitcoin::Transaction`
1040+
/// (bitcoin 0.30), whose canonical-txid method is `.txid()` (the equivalent of
1041+
/// `compute_txid()` in newer bitcoin releases). It returns the txid, never the
1042+
/// witness txid (wtxid).
1043+
pub(crate) fn decode_and_compute_txid(
1044+
serialized_tx: &str,
1045+
) -> Result<(Vec<u8>, Txid), BroadcastError> {
1046+
let tx_bytes = hex::decode(serialized_tx).map_err(|e| BroadcastError::InvalidHex {
10211047
error_details: format!("Invalid transaction hex: {}", e),
10221048
})?;
10231049

1024-
// Validate that the bytes are a valid transaction
1025-
let _tx: Transaction =
1050+
let tx: Transaction =
10261051
deserialize(&tx_bytes).map_err(|e| BroadcastError::InvalidTransaction {
10271052
error_details: format!("Invalid transaction data: {}", e),
10281053
})?;
10291054

1055+
let txid = tx.txid();
1056+
Ok((tx_bytes, txid))
1057+
}
1058+
1059+
/// Broadcast a signed raw transaction via Electrum.
1060+
///
1061+
/// Takes a hex-encoded serialized transaction and an Electrum server URL.
1062+
/// Returns the transaction's canonical txid (computed locally) on success.
1063+
///
1064+
/// If Electrum reports that the transaction is already known (already in a block,
1065+
/// in the mempool, or otherwise accepted), this is treated as success and the same
1066+
/// locally computed txid is returned, so retrying a broadcast after an ambiguous
1067+
/// network failure completes cleanly. Genuine connectivity failures and unrelated
1068+
/// broadcast rejections are preserved as typed errors.
1069+
pub async fn broadcast_raw_tx(
1070+
serialized_tx: String,
1071+
electrum_url: &str,
1072+
) -> Result<String, BroadcastError> {
1073+
let (tx_bytes, local_txid) = decode_and_compute_txid(&serialized_tx)?;
10301074
let electrum_url_owned = electrum_url.to_string();
10311075

1032-
let txid = tokio::task::spawn_blocking(move || {
1076+
tokio::task::spawn_blocking(move || {
10331077
let client = bdk::electrum_client::Client::new(&electrum_url_owned).map_err(|e| {
10341078
BroadcastError::ElectrumError {
10351079
error_details: format!("Failed to connect to Electrum: {}", e),
10361080
}
10371081
})?;
10381082

1039-
client
1040-
.transaction_broadcast_raw(&tx_bytes)
1041-
.map_err(|e| BroadcastError::ElectrumError {
1042-
error_details: format!("Broadcast failed: {}", e),
1043-
})
1083+
match client.transaction_broadcast_raw(&tx_bytes) {
1084+
Ok(_) => Ok(()),
1085+
Err(e) => {
1086+
let message = e.to_string();
1087+
if is_already_known_broadcast_error(&message) {
1088+
Ok(())
1089+
} else {
1090+
Err(BroadcastError::ElectrumError {
1091+
error_details: format!("Broadcast failed: {}", message),
1092+
})
1093+
}
1094+
}
1095+
}
10441096
})
10451097
.await
10461098
.map_err(|e| BroadcastError::TaskError {
10471099
error_details: format!("Broadcast task failed: {}", e),
10481100
})??;
10491101

1050-
Ok(txid.to_string())
1102+
Ok(local_txid.to_string())
10511103
}
10521104

10531105
// ============================================================================

src/modules/onchain/tests.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,112 @@ mod tests {
849849
));
850850
}
851851

852+
#[test]
853+
fn test_decode_and_compute_txid_legacy_returns_canonical_txid() {
854+
use super::super::implementation::decode_and_compute_txid;
855+
856+
// Bitcoin block 170 transaction (Satoshi -> Hal Finney), a legacy
857+
// (non-segwit) transaction with a well-known txid.
858+
let raw_hex = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
859+
let expected_txid = "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16";
860+
861+
let (_bytes, txid) = decode_and_compute_txid(raw_hex).expect("valid legacy tx");
862+
assert_eq!(txid.to_string(), expected_txid);
863+
}
864+
865+
#[test]
866+
fn test_decode_and_compute_txid_segwit_returns_txid_not_wtxid() {
867+
use super::super::implementation::decode_and_compute_txid;
868+
use bdk::bitcoin::absolute::LockTime;
869+
use bdk::bitcoin::consensus::serialize;
870+
use bdk::bitcoin::{OutPoint, Sequence, TxIn, Txid, Witness};
871+
872+
// Build a transaction carrying witness data so that its txid (serialized
873+
// without witness) and wtxid (serialized with witness) diverge.
874+
let mut witness = Witness::new();
875+
witness.push([0x30u8; 72]); // dummy signature
876+
witness.push([0x02u8; 33]); // dummy pubkey
877+
878+
let prev_txid =
879+
Txid::from_str("4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b")
880+
.unwrap();
881+
882+
let tx = Transaction {
883+
version: 2,
884+
lock_time: LockTime::from_consensus(0),
885+
input: vec![TxIn {
886+
previous_output: OutPoint {
887+
txid: prev_txid,
888+
vout: 0,
889+
},
890+
script_sig: ScriptBuf::new(),
891+
sequence: Sequence::MAX,
892+
witness,
893+
}],
894+
output: vec![TxOut {
895+
value: 10_000,
896+
script_pubkey: ScriptBuf::new(),
897+
}],
898+
};
899+
900+
// A witness-bearing transaction must have distinct txid and wtxid.
901+
assert_ne!(tx.txid().to_string(), tx.wtxid().to_string());
902+
903+
let raw_hex = hex::encode(serialize(&tx));
904+
let (_bytes, txid) = decode_and_compute_txid(&raw_hex).expect("valid segwit tx");
905+
assert_eq!(txid, tx.txid());
906+
assert_ne!(txid.to_string(), tx.wtxid().to_string());
907+
}
908+
909+
#[test]
910+
fn test_is_already_known_broadcast_error_accepts_known_responses() {
911+
use super::super::implementation::is_already_known_broadcast_error;
912+
913+
let cases = [
914+
"already in block chain",
915+
"already in blockchain",
916+
"already in mempool",
917+
"already-in-block-chain",
918+
"already-in-mempool",
919+
"txn-already-known",
920+
"transaction already exists",
921+
// Case-insensitive and embedded in a larger server message.
922+
"Transaction already in block chain",
923+
"TXN-ALREADY-KNOWN",
924+
"Broadcast failed: Electrum server error: {\"code\":-27,\"message\":\"transaction already in block chain\"}",
925+
"sendrawtransaction RPC error: transaction already in mempool",
926+
];
927+
928+
for case in cases {
929+
assert!(
930+
is_already_known_broadcast_error(case),
931+
"expected already-known match for: {case}"
932+
);
933+
}
934+
}
935+
936+
#[test]
937+
fn test_is_already_known_broadcast_error_rejects_unrelated_errors() {
938+
use super::super::implementation::is_already_known_broadcast_error;
939+
940+
let cases = [
941+
"bad-txns-inputs-missingorspent",
942+
"min relay fee not met",
943+
"scriptsig-not-pushonly",
944+
"bad-txns-in-belowout",
945+
"Failed to connect to Electrum: connection refused",
946+
"dust",
947+
"",
948+
];
949+
950+
for case in cases {
951+
assert!(
952+
!is_already_known_broadcast_error(case),
953+
"unexpected already-known match for: {case}"
954+
);
955+
}
956+
}
957+
852958
// ========================================================================
853959
// Account Info Tests
854960
// ========================================================================

0 commit comments

Comments
 (0)