From 6cb00449808d58168c783f3237cf87518043c878 Mon Sep 17 00:00:00 2001 From: criptoworld Date: Tue, 14 Jul 2026 16:37:43 +0200 Subject: [PATCH] fix(pool): keep the node's rejection reason when a broadcast fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed broadcast was recorded as the bare context line ("Failed to broadcast transaction"): every backend wraps the node's reason in a `.context()`, so `e.to_string()` threw away the only part that says *why*. Two consequences, one cosmetic and one not: - The dashboard showed a message that explains nothing. Debugging a real incident on the node, "Failed to broadcast transaction" was indistinguishable from "the Bitcoin Core fallback is broken" — the tx was in fact a double spend (bad-txns-inputs-missingorspent), which no backend would ever accept. - is_retriable_broadcast_error() matches on "non-final", "locktime" and "too-long-mempool-chain", all of which live in the discarded source. It was therefore blind: a tx the node would accept one block later got marked permanently failed instead of being retried. Render with `{:#}` so the whole error chain reaches both the retry decision and the user. Co-Authored-By: Claude Opus 4.8 --- src/pool/manager.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/pool/manager.rs b/src/pool/manager.rs index b601507..fffae47 100644 --- a/src/pool/manager.rs +++ b/src/pool/manager.rs @@ -487,7 +487,10 @@ impl PoolManager { results.push((tx.id, Ok(txid))); } Err(e) => { - let err_msg = e.to_string(); + // `{:#}` keeps the node's rejection reason, which lives in the error's source: + // it is what tells a retriable "non-final" apart from a permanent + // "bad-txns-inputs-missingorspent", and it is what the dashboard shows the user. + let err_msg = format!("{:#}", e); if is_retriable_broadcast_error(&err_msg) { tracing::info!( "Broadcast deferred for {} (will retry): {}", @@ -1404,6 +1407,34 @@ mod tests { use crate::config::Config; use crate::db::Database; + // Every broadcast backend wraps the node's rejection reason in a `.context()`, so the reason + // ("non-final", "bad-txns-inputs-missingorspent"…) only lives in the error's *source*. Render + // with `{}` and all we keep is our own context line — which then (a) reaches the dashboard as + // a useless "Failed to broadcast transaction" and (b) makes is_retriable_broadcast_error blind, + // so a tx the node would accept a block later is marked permanently failed. + #[test] + fn broadcast_error_keeps_the_node_rejection_reason() { + let err = anyhow::anyhow!("JSON-RPC error: non-final transaction") + .context("Failed to broadcast transaction"); + + let flat = err.to_string(); + assert!(!is_retriable_broadcast_error(&flat)); + + let full = format!("{:#}", err); + assert!(full.contains("non-final"), "full chain must carry the reason: {}", full); + assert!(is_retriable_broadcast_error(&full)); + } + + #[test] + fn permanent_rejections_are_not_retried() { + let err = anyhow::anyhow!( + "JSON-RPC error: {{\"code\":-26,\"message\":\"bad-txns-inputs-missingorspent\"}}" + ) + .context("Failed to broadcast transaction"); + + assert!(!is_retriable_broadcast_error(&format!("{:#}", err))); + } + fn test_manager() -> (PoolManager, tempfile::TempDir) { let dir = tempfile::tempdir().expect("tempdir"); let db = Arc::new(Database::open(&dir.path().join("test.db")).expect("db open"));