Skip to main content

bsv_wallet_cli/
broadcast_verify.rs

1//! Post-broadcast verification — fail loudly when a broadcast was silently dropped.
2//!
3//! # The silent-data-loss bug this closes
4//!
5//! A `send` (CLI `send` or the served `/createAction` endpoint) delegates to
6//! `Wallet::create_action`, which signs the tx and broadcasts it through ARC.
7//! For a **monitor-less served wallet** (the LOW e2e runs `bsv-wallet serve`
8//! with no chain monitor / chaintracks) the wallet has never fetched merkle
9//! proofs for its *confirmed* ancestors, so the BEEF it hands ARC carries the
10//! whole unconfirmed chain. ARC then charges the fee for the **entire package**
11//! and rejects the tx with **error 465 "fee too low"**.
12//!
13//! In `bsv-wallet-toolbox-rs`, an ARC 465 is tagged `service_error = true`, so
14//! `classify_broadcast_results` treats it as a *transient* `ServiceError`
15//! (retryable) rather than a permanent `InvalidTx`. `create_action` therefore
16//! returns `Ok` with a txid — a **phantom txid that never propagates**
17//! (WhatsOnChain 404 forever). The send path reported success and exit 0 while
18//! the funds were never sent. That stranded funds in a LOW mainnet e2e rebalance.
19//!
20//! # What this module does (part 1 — fail loud)
21//!
22//! After a broadcast, ask the network whether the txid actually exists. We only
23//! declare a **`Rejected`** (hard failure) when a broadcaster/indexer that would
24//! hold the tx *if it had been accepted* affirmatively reports it **absent**,
25//! and **no** source reports it present. If we cannot reach any source we return
26//! `Inconclusive` and callers preserve prior behaviour — so a down confirmation
27//! service never turns a real send into a false failure (zero added flakiness).
28//!
29//! A legitimately-accepted tx is present on ARC immediately (ARC keeps recently
30//! submitted txs queryable) and indexed by WoC within seconds, so the happy path
31//! short-circuits to `Confirmed` on the first probe — no meaningful added latency
32//! for a wallet whose ancestors are already proven/shallow.
33
34use std::time::Duration;
35
36use bsv_wallet_toolbox::Chain;
37use reqwest::Client;
38
39/// Default number of probe rounds before declaring a definitive absence.
40const DEFAULT_ATTEMPTS: u32 = 6;
41/// Default delay between probe rounds (ms). Only rejected txs pay the full cost;
42/// an accepted tx short-circuits on the first `Present`.
43const DEFAULT_DELAY_MS: u64 = 1500;
44/// Per-request timeout for a single status probe.
45const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
46
47/// Outcome of verifying that a just-broadcast tx actually reached the network.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum BroadcastVerification {
50    /// At least one source confirms the tx exists (accepted / seen / mined).
51    Confirmed,
52    /// A source that would hold the tx if it had been accepted affirmatively
53    /// reports it absent, and no source reports it present — the broadcast was
54    /// silently dropped (classic ARC 465 fee-too-low on a deep unconfirmed BEEF).
55    /// The funds were NOT sent.
56    Rejected,
57    /// No source could be reached to confirm either way. Callers must NOT treat
58    /// this as a failure (avoids false negatives when the confirmation service
59    /// itself is unreachable).
60    Inconclusive,
61}
62
63impl BroadcastVerification {
64    /// Map a verification into a `Result`, failing loudly only on a definitive
65    /// `Rejected`. `Confirmed` and `Inconclusive` are both treated as "proceed".
66    pub fn into_send_result(self, txid: &str) -> anyhow::Result<()> {
67        match self {
68            BroadcastVerification::Rejected => Err(anyhow::anyhow!(
69                "broadcast rejected: transaction {txid} is not present on the network. \
70                 The broadcaster (ARC) dropped it — most likely error 465 \"fee too low\", \
71                 because a monitor-less wallet presented a deep unconfirmed BEEF and ARC \
72                 charged the fee for the whole unconfirmed package. The funds were NOT sent. \
73                 Fetch merkle proofs for the confirmed ancestors (run `bsv-wallet tick` with \
74                 CHAINTRACKS_URL set) or fund from a confirmed UTXO, then retry."
75            )),
76            BroadcastVerification::Confirmed | BroadcastVerification::Inconclusive => Ok(()),
77        }
78    }
79}
80
81/// Presence of a txid according to a single source.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83enum Presence {
84    /// Source has the tx (HTTP 200).
85    Present,
86    /// Source definitively does not have the tx (HTTP 404).
87    Absent,
88    /// Source could not give a definitive answer (auth error, 5xx, network error).
89    Unknown,
90}
91
92/// A network endpoint we can ask "do you know this txid?".
93#[derive(Clone)]
94struct StatusSource {
95    /// Human-readable name (diagnostics only).
96    name: &'static str,
97    /// URL template containing the literal `{txid}` placeholder.
98    url_template: String,
99    /// Full `Authorization` header value, if the endpoint needs one.
100    auth: Option<String>,
101    /// Whether a 404 from this source is trustworthy evidence of absence.
102    /// ARC keeps recently-submitted txs queryable and WoC indexes mempool txs
103    /// within the probe window, so all default sources are authoritative here;
104    /// the probe window guards against indexing lag by short-circuiting on the
105    /// first `Present`.
106    authoritative_absence: bool,
107}
108
109/// Verifies that a broadcast tx actually reached the network.
110///
111/// Cheap to clone (shares the reqwest connection pool). Built once and shared
112/// via an axum extension on the served path, or per-command on the CLI path.
113#[derive(Clone)]
114pub struct BroadcastVerifier {
115    client: Client,
116    sources: Vec<StatusSource>,
117    attempts: u32,
118    delay: Duration,
119    /// When false (env opt-out) `verify` short-circuits to `Inconclusive`.
120    enabled: bool,
121}
122
123impl BroadcastVerifier {
124    /// Build a verifier for `chain`, reading optional overrides from the env:
125    /// - `BSV_WALLET_SKIP_BROADCAST_VERIFY=1` disables verification entirely.
126    /// - `BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS` overrides the probe-round count.
127    /// - `BSV_WALLET_BROADCAST_VERIFY_DELAY_MS` overrides the inter-round delay.
128    /// - `TAAL_API_KEY` / `MAIN_TAAL_API_KEY` authenticate the TAAL ARC probe.
129    pub fn from_env(chain: Chain) -> Self {
130        let enabled = !env_truthy("BSV_WALLET_SKIP_BROADCAST_VERIFY");
131        let attempts = std::env::var("BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS")
132            .ok()
133            .and_then(|v| v.parse::<u32>().ok())
134            .filter(|n| *n > 0)
135            .unwrap_or(DEFAULT_ATTEMPTS);
136        let delay_ms = std::env::var("BSV_WALLET_BROADCAST_VERIFY_DELAY_MS")
137            .ok()
138            .and_then(|v| v.parse::<u64>().ok())
139            .unwrap_or(DEFAULT_DELAY_MS);
140
141        // TAAL ARC uses a raw `Authorization: <key>` header (no "Bearer " prefix).
142        let taal_key = std::env::var("TAAL_API_KEY")
143            .ok()
144            .filter(|k| !k.is_empty())
145            .or_else(|| {
146                std::env::var("MAIN_TAAL_API_KEY")
147                    .ok()
148                    .filter(|k| !k.is_empty())
149            });
150
151        let mut sources = vec![
152            // TAAL ARC — the primary broadcaster. Authenticated GET when a key is
153            // present; without a key it may 401 (→ Unknown, harmless).
154            StatusSource {
155                name: "arc-taal",
156                url_template: format!("{}/v1/tx/{{txid}}", taal_arc_url(chain)),
157                auth: taal_key.clone(),
158                authoritative_absence: taal_key.is_some(),
159            },
160            // WhatsOnChain — keyless, reliable 200/404. Presence + absence signal.
161            StatusSource {
162                name: "whatsonchain",
163                url_template: format!("{}/tx/hash/{{txid}}", woc_base(chain)),
164                auth: None,
165                authoritative_absence: true,
166            },
167        ];
168        // GorillaPool ARC — keyless fallback broadcaster (mainnet only).
169        if let Some(gp) = gorillapool_arc_url(chain) {
170            sources.push(StatusSource {
171                name: "arc-gorillapool",
172                url_template: format!("{}/v1/tx/{{txid}}", gp),
173                auth: None,
174                authoritative_absence: true,
175            });
176        }
177
178        Self {
179            client: Client::new(),
180            sources,
181            attempts,
182            delay: Duration::from_millis(delay_ms),
183            enabled,
184        }
185    }
186
187    /// Probe the network for `txid`, returning as soon as any source confirms it
188    /// present, otherwise after the full probe window.
189    pub async fn verify(&self, txid: &str) -> BroadcastVerification {
190        if !self.enabled || self.sources.is_empty() {
191            return BroadcastVerification::Inconclusive;
192        }
193
194        let mut last_round_saw_authoritative_absence = false;
195        for attempt in 0..self.attempts {
196            let mut any_present = false;
197            let mut authoritative_absence = false;
198            for src in &self.sources {
199                match probe(&self.client, src, txid).await {
200                    Presence::Present => any_present = true,
201                    Presence::Absent if src.authoritative_absence => authoritative_absence = true,
202                    _ => {}
203                }
204            }
205            if any_present {
206                return BroadcastVerification::Confirmed;
207            }
208            last_round_saw_authoritative_absence = authoritative_absence;
209            if attempt + 1 < self.attempts {
210                tokio::time::sleep(self.delay).await;
211            }
212        }
213
214        if last_round_saw_authoritative_absence {
215            BroadcastVerification::Rejected
216        } else {
217            BroadcastVerification::Inconclusive
218        }
219    }
220}
221
222/// Probe a single source for a txid's presence.
223async fn probe(client: &Client, src: &StatusSource, txid: &str) -> Presence {
224    let url = src.url_template.replace("{txid}", txid);
225    let mut req = client.get(&url).timeout(PROBE_TIMEOUT);
226    if let Some(auth) = &src.auth {
227        req = req.header("Authorization", auth);
228    }
229    match req.send().await {
230        Ok(resp) => match resp.status().as_u16() {
231            200 => Presence::Present,
232            404 => Presence::Absent,
233            other => {
234                tracing::debug!(
235                    source = src.name,
236                    status = other,
237                    "broadcast probe inconclusive"
238                );
239                Presence::Unknown
240            }
241        },
242        Err(e) => {
243            tracing::debug!(source = src.name, error = %e, "broadcast probe request failed");
244            Presence::Unknown
245        }
246    }
247}
248
249fn taal_arc_url(chain: Chain) -> &'static str {
250    match chain {
251        Chain::Main => "https://arc.taal.com",
252        Chain::Test => "https://arc-test.taal.com",
253    }
254}
255
256fn gorillapool_arc_url(chain: Chain) -> Option<&'static str> {
257    match chain {
258        Chain::Main => Some("https://arc.gorillapool.io"),
259        // GorillaPool testnet ARC is not commonly used; omit it.
260        Chain::Test => None,
261    }
262}
263
264fn woc_base(chain: Chain) -> &'static str {
265    match chain {
266        Chain::Main => "https://api.whatsonchain.com/v1/bsv/main",
267        Chain::Test => "https://api.whatsonchain.com/v1/bsv/test",
268    }
269}
270
271fn env_truthy(key: &str) -> bool {
272    std::env::var(key)
273        .map(|v| {
274            let v = v.trim().to_ascii_lowercase();
275            v == "1" || v == "true" || v == "yes" || v == "on"
276        })
277        .unwrap_or(false)
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use axum::http::StatusCode;
284    use axum::routing::get;
285    use axum::Router;
286    use std::net::SocketAddr;
287
288    /// Spin up a local mock that answers `GET /v1/tx/{txid}` with `code` for
289    /// every txid. Returns the base URL (`http://127.0.0.1:PORT`).
290    async fn mock_status_server(code: StatusCode) -> String {
291        let app = Router::new().route("/v1/tx/{txid}", get(move || async move { code }));
292        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
293        let addr: SocketAddr = listener.local_addr().unwrap();
294        tokio::spawn(async move {
295            axum::serve(listener, app).await.ok();
296        });
297        format!("http://{}", addr)
298    }
299
300    /// Build a verifier pointing at a single mock source (fast: 2 attempts, no delay).
301    fn verifier_for(base: &str, authoritative_absence: bool) -> BroadcastVerifier {
302        BroadcastVerifier {
303            client: Client::new(),
304            sources: vec![StatusSource {
305                name: "mock",
306                url_template: format!("{}/v1/tx/{{txid}}", base),
307                auth: None,
308                authoritative_absence,
309            }],
310            attempts: 2,
311            delay: Duration::from_millis(0),
312            enabled: true,
313        }
314    }
315
316    const TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001";
317
318    // The core of the fix: a 465/rejected broadcast (the tx is absent from every
319    // source that would hold it) must resolve to Rejected → an ERROR, never success.
320    #[tokio::test]
321    async fn rejected_broadcast_is_an_error_not_success() {
322        let base = mock_status_server(StatusCode::NOT_FOUND).await;
323        let verifier = verifier_for(&base, /* authoritative */ true);
324
325        let outcome = verifier.verify(TXID).await;
326        assert_eq!(
327            outcome,
328            BroadcastVerification::Rejected,
329            "an absent (404-everywhere) tx must be classified Rejected"
330        );
331        // And the send path must surface it as a hard error, not exit 0.
332        assert!(
333            outcome.into_send_result(TXID).is_err(),
334            "a Rejected verification must map to Err so the send fails loudly"
335        );
336    }
337
338    #[tokio::test]
339    async fn confirmed_broadcast_succeeds() {
340        let base = mock_status_server(StatusCode::OK).await;
341        let verifier = verifier_for(&base, true);
342
343        let outcome = verifier.verify(TXID).await;
344        assert_eq!(outcome, BroadcastVerification::Confirmed);
345        assert!(outcome.into_send_result(TXID).is_ok());
346    }
347
348    #[tokio::test]
349    async fn unreachable_source_is_inconclusive_not_a_failure() {
350        // 503 from every probe → we cannot confirm either way → Inconclusive,
351        // which must NOT be a failure (no false negatives when the service is down).
352        let base = mock_status_server(StatusCode::SERVICE_UNAVAILABLE).await;
353        let verifier = verifier_for(&base, true);
354
355        let outcome = verifier.verify(TXID).await;
356        assert_eq!(outcome, BroadcastVerification::Inconclusive);
357        assert!(outcome.into_send_result(TXID).is_ok());
358    }
359
360    #[tokio::test]
361    async fn non_authoritative_absence_is_inconclusive() {
362        // A 404 from a presence-only source (not authoritative for absence) must
363        // not by itself trigger a Rejected — it stays Inconclusive.
364        let base = mock_status_server(StatusCode::NOT_FOUND).await;
365        let verifier = verifier_for(&base, /* authoritative */ false);
366
367        assert_eq!(
368            verifier.verify(TXID).await,
369            BroadcastVerification::Inconclusive
370        );
371    }
372
373    #[tokio::test]
374    async fn disabled_verifier_is_inconclusive() {
375        let base = mock_status_server(StatusCode::NOT_FOUND).await;
376        let mut verifier = verifier_for(&base, true);
377        verifier.enabled = false;
378        assert_eq!(
379            verifier.verify(TXID).await,
380            BroadcastVerification::Inconclusive
381        );
382    }
383}