Skip to main content

bsv_wallet_cli/server/
broadcast_follow_up.rs

1//! What the served `/createAction` does AFTER `Wallet::create_action` returns.
2//!
3//! # Why this exists
4//!
5//! The handler used to run `BroadcastVerifier::verify` INLINE after every
6//! immediate broadcast, so a clean broadcast still paid for a full presence
7//! probe before the client got its txid (measured on beta.zanaadu.com: 5.5 s
8//! for a publish whose build+sign took 6 ms). That loop was there because the
9//! toolbox once classified a definitive ARC/Arcade rejection (465 fee too low,
10//! `REJECTED`) as a *transient* service error and handed back a phantom txid.
11//!
12//! Since bsv-wallet-toolbox-rs 0.3.55 a definitive rejection is a permanent
13//! failure of `create_action` itself (tx failed, inputs released, the caller
14//! gets an error), and an ACCEPTED broadcast is reported in
15//! `sendWithResults[].status == "unproven"`. So the handler can now:
16//!
17//! * answer **immediately** when the broadcaster ACCEPTED the transaction, and
18//!   run the presence verification in the **background** — on a definitive
19//!   `Rejected` verdict the tx is failed and its inputs released through the
20//!   toolbox's RELEASE-RULE path (`retire_undeliverable_txid`: alive-check,
21//!   per-input chain verification, own outputs unspendable);
22//! * keep the **inline** verification ONLY for an **ambiguous** result — a
23//!   transient service fault where the transaction may or may not be out, the
24//!   one case where the client must not be told "sent" on a coin flip.
25//!
26//! The decision (`disposition`) and the follow-up mechanics (`follow_up`) are
27//! pure over injected closures so the timing contract is testable without a
28//! network or a funded wallet.
29
30use std::future::Future;
31
32use bsv_sdk::wallet::{CreateActionResult, SendWithResultStatus};
33use bsv_wallet_toolbox::{RetireOutcome, StorageSqlx, WalletServices};
34
35use crate::broadcast_verify::BroadcastVerification;
36
37/// How the immediate broadcast of a `create_action` went, as far as the wallet
38/// could tell.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum BroadcastDisposition {
41    /// Nothing was broadcast: `noSend`, `acceptDelayedBroadcast`, or a deferred
42    /// signing (`signableTransaction`) flow. Nothing to verify.
43    NotBroadcast,
44    /// The broadcaster ACCEPTED the transaction (2xx with a non-fatal status):
45    /// `sendWithResults` reports it `unproven`.
46    Accepted,
47    /// The wallet could not tell: a transient service fault left the entry at
48    /// `sending`, or the toolbox reported nothing for this txid.
49    Ambiguous,
50}
51
52/// Classify a `create_action` result. Only immediate broadcasts are ever
53/// verified; an accepted one is verified in the background, an ambiguous one
54/// inline.
55pub fn disposition(
56    result: &CreateActionResult,
57    no_send: bool,
58    accept_delayed: bool,
59) -> BroadcastDisposition {
60    if no_send || accept_delayed || result.signable_transaction.is_some() {
61        return BroadcastDisposition::NotBroadcast;
62    }
63    let Some(txid) = result.txid else {
64        return BroadcastDisposition::NotBroadcast;
65    };
66    let entry = result
67        .send_with_results
68        .as_ref()
69        .and_then(|rs| rs.iter().find(|r| r.txid == txid));
70    match entry {
71        Some(r) if matches!(r.status, SendWithResultStatus::Unproven) => {
72            BroadcastDisposition::Accepted
73        }
74        _ => BroadcastDisposition::Ambiguous,
75    }
76}
77
78/// What the request handler does once the follow-up returns.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum FollowUp {
81    /// Answer the client with the txid.
82    Proceed,
83    /// The inline verification found the transaction definitively absent; the
84    /// tx has been retired and the client must see a failure.
85    Rejected,
86}
87
88/// Run the post-broadcast verification for `txid` according to `disposition`.
89///
90/// * `NotBroadcast`: nothing happens.
91/// * `Accepted`: `verify` is SPAWNED; this function returns at once. If the
92///   verdict is `Rejected`, `on_rejected` runs in that background task.
93/// * `Ambiguous`: `verify` is AWAITED; a `Rejected` verdict runs `on_rejected`
94///   before returning [`FollowUp::Rejected`].
95///
96/// A `Confirmed` or `Inconclusive` verdict never touches the transaction: an
97/// absence must be definitive before any money moves (see `broadcast_verify`).
98pub async fn follow_up<V, VF, R, RF>(
99    disposition: BroadcastDisposition,
100    txid: String,
101    verify: V,
102    on_rejected: R,
103) -> FollowUp
104where
105    V: FnOnce(String) -> VF + Send + 'static,
106    VF: Future<Output = BroadcastVerification> + Send + 'static,
107    R: FnOnce(String) -> RF + Send + 'static,
108    RF: Future<Output = ()> + Send + 'static,
109{
110    match disposition {
111        BroadcastDisposition::NotBroadcast => FollowUp::Proceed,
112        BroadcastDisposition::Accepted => {
113            tokio::spawn(async move {
114                match verify(txid.clone()).await {
115                    BroadcastVerification::Confirmed => {
116                        tracing::debug!(txid = %txid, "post-broadcast verification: present");
117                    }
118                    BroadcastVerification::Inconclusive => {
119                        tracing::info!(
120                            txid = %txid,
121                            "post-broadcast verification: inconclusive (kept; the reconcile sweeps decide later)"
122                        );
123                    }
124                    BroadcastVerification::Rejected => {
125                        tracing::warn!(
126                            txid = %txid,
127                            "post-broadcast verification: ACCEPTED by the broadcaster but definitively absent afterwards — retiring"
128                        );
129                        on_rejected(txid).await;
130                    }
131                }
132            });
133            FollowUp::Proceed
134        }
135        BroadcastDisposition::Ambiguous => match verify(txid.clone()).await {
136            BroadcastVerification::Rejected => {
137                tracing::warn!(
138                    txid = %txid,
139                    "ambiguous broadcast verified definitively absent — retiring and failing the request"
140                );
141                on_rejected(txid).await;
142                FollowUp::Rejected
143            }
144            BroadcastVerification::Confirmed | BroadcastVerification::Inconclusive => {
145                FollowUp::Proceed
146            }
147        },
148    }
149}
150
151/// Fail a definitively-absent broadcast and give its inputs back, through the
152/// toolbox's RELEASE-RULE path (`StorageSqlx::retire_undeliverable_txid`): the
153/// tx is alive-checked first, each input is released only on its own chain
154/// verification, the tx's own outputs go unspendable and the tx is `failed`
155/// with its req `invalid` (the unfail canary keeps re-checking it). Every
156/// outcome is logged; nothing here can fail the request that already answered.
157pub async fn retire_rejected_broadcast(
158    storage: &StorageSqlx,
159    services: &dyn WalletServices,
160    txid: &str,
161) -> Option<RetireOutcome> {
162    match storage
163        .retire_undeliverable_txid(services, txid, "invalid")
164        .await
165    {
166        Ok(Some(outcome @ RetireOutcome::Retired { restored, kept })) => {
167            tracing::warn!(
168                txid = %txid,
169                restored,
170                kept,
171                "retired absent broadcast: tx failed, {} input(s) released (chain-verified), {} kept locked",
172                restored,
173                kept
174            );
175            Some(outcome)
176        }
177        Ok(Some(RetireOutcome::Alive)) => {
178            tracing::info!(
179                txid = %txid,
180                "absent per the probe but known to the status service — kept (promoted), nothing released"
181            );
182            Some(RetireOutcome::Alive)
183        }
184        Ok(None) => {
185            tracing::warn!(
186                txid = %txid,
187                "absent broadcast has no proven_tx_req — nothing to retire"
188            );
189            None
190        }
191        Err(e) => {
192            tracing::error!(txid = %txid, error = %e, "failed to retire absent broadcast");
193            None
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use bsv_sdk::wallet::{SendWithResult, SignableTransaction};
202    use std::sync::atomic::{AtomicBool, Ordering};
203    use std::sync::Arc;
204    use std::time::{Duration, Instant};
205
206    const TXID_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000001";
207
208    fn txid_bytes() -> [u8; 32] {
209        let mut t = [0u8; 32];
210        t[31] = 1;
211        t
212    }
213
214    fn result(status: Option<SendWithResultStatus>) -> CreateActionResult {
215        let txid = txid_bytes();
216        CreateActionResult {
217            txid: Some(txid),
218            tx: Some(vec![1, 0, 0, 0]),
219            no_send_change: None,
220            send_with_results: status.map(|s| vec![SendWithResult { txid, status: s }]),
221            signable_transaction: None,
222            input_type: None,
223            inputs: None,
224            reference_number: None,
225            beef: None,
226        }
227    }
228
229    // ---- disposition ------------------------------------------------------
230
231    #[test]
232    fn accepted_broadcast_reports_unproven() {
233        assert_eq!(
234            disposition(&result(Some(SendWithResultStatus::Unproven)), false, false),
235            BroadcastDisposition::Accepted
236        );
237    }
238
239    #[test]
240    fn transient_fault_leaves_sending_which_is_ambiguous() {
241        assert_eq!(
242            disposition(&result(Some(SendWithResultStatus::Sending)), false, false),
243            BroadcastDisposition::Ambiguous
244        );
245    }
246
247    #[test]
248    fn no_report_for_our_txid_is_ambiguous() {
249        // An older toolbox (or a missing entry) must fall back to the safe path.
250        assert_eq!(
251            disposition(&result(None), false, false),
252            BroadcastDisposition::Ambiguous
253        );
254        let mut other = result(Some(SendWithResultStatus::Unproven));
255        other.send_with_results.as_mut().unwrap()[0].txid = [9u8; 32];
256        assert_eq!(
257            disposition(&other, false, false),
258            BroadcastDisposition::Ambiguous
259        );
260    }
261
262    #[test]
263    fn nothing_to_verify_when_nothing_was_broadcast() {
264        let accepted = result(Some(SendWithResultStatus::Unproven));
265        assert_eq!(
266            disposition(&accepted, true, false),
267            BroadcastDisposition::NotBroadcast
268        );
269        assert_eq!(
270            disposition(&accepted, false, true),
271            BroadcastDisposition::NotBroadcast
272        );
273        let mut deferred = result(Some(SendWithResultStatus::Unproven));
274        deferred.signable_transaction = Some(SignableTransaction {
275            tx: vec![1],
276            reference: b"ref".to_vec(),
277        });
278        assert_eq!(
279            disposition(&deferred, false, false),
280            BroadcastDisposition::NotBroadcast
281        );
282        let mut no_txid = result(Some(SendWithResultStatus::Unproven));
283        no_txid.txid = None;
284        assert_eq!(
285            disposition(&no_txid, false, false),
286            BroadcastDisposition::NotBroadcast
287        );
288    }
289
290    // ---- follow_up timing contract -----------------------------------------
291
292    #[tokio::test]
293    async fn accepted_broadcast_answers_before_the_verification_finishes() {
294        // The verifier takes 300 ms and comes back Rejected. The handler must
295        // return long before that (the probe was SPAWNED, not awaited), and the
296        // retire hook must still fire afterwards, in the background.
297        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
298        let started = Instant::now();
299        let outcome = follow_up(
300            BroadcastDisposition::Accepted,
301            TXID_HEX.to_string(),
302            |_txid| async {
303                tokio::time::sleep(Duration::from_millis(300)).await;
304                BroadcastVerification::Rejected
305            },
306            move |txid| async move {
307                tx.send(txid).ok();
308            },
309        )
310        .await;
311        let elapsed = started.elapsed();
312        assert_eq!(outcome, FollowUp::Proceed);
313        assert!(
314            elapsed < Duration::from_millis(150),
315            "an accepted broadcast must not wait for the verifier (took {:?})",
316            elapsed
317        );
318        let retired = tokio::time::timeout(Duration::from_secs(2), rx)
319            .await
320            .expect("the background verification must run to its verdict")
321            .expect("retire hook fired");
322        assert_eq!(retired, TXID_HEX);
323        assert!(
324            started.elapsed() >= Duration::from_millis(300),
325            "the verdict arrives only after the probe window"
326        );
327    }
328
329    #[tokio::test]
330    async fn accepted_and_present_never_retires() {
331        let fired = Arc::new(AtomicBool::new(false));
332        let f = fired.clone();
333        let outcome = follow_up(
334            BroadcastDisposition::Accepted,
335            TXID_HEX.to_string(),
336            |_txid| async { BroadcastVerification::Confirmed },
337            move |_txid| async move {
338                f.store(true, Ordering::SeqCst);
339            },
340        )
341        .await;
342        assert_eq!(outcome, FollowUp::Proceed);
343        tokio::time::sleep(Duration::from_millis(100)).await;
344        assert!(!fired.load(Ordering::SeqCst));
345    }
346
347    #[tokio::test]
348    async fn accepted_and_inconclusive_never_retires() {
349        // An absence that is not definitive must never release money.
350        let fired = Arc::new(AtomicBool::new(false));
351        let f = fired.clone();
352        follow_up(
353            BroadcastDisposition::Accepted,
354            TXID_HEX.to_string(),
355            |_txid| async { BroadcastVerification::Inconclusive },
356            move |_txid| async move {
357                f.store(true, Ordering::SeqCst);
358            },
359        )
360        .await;
361        tokio::time::sleep(Duration::from_millis(100)).await;
362        assert!(!fired.load(Ordering::SeqCst));
363    }
364
365    #[tokio::test]
366    async fn ambiguous_broadcast_is_verified_inline_and_a_rejection_fails_the_request() {
367        // The one case that still blocks: the wallet could not tell whether the
368        // tx went out. The verifier is AWAITED, and a definitive absence retires
369        // the tx BEFORE the handler answers with a failure.
370        let fired = Arc::new(AtomicBool::new(false));
371        let f = fired.clone();
372        let started = Instant::now();
373        let outcome = follow_up(
374            BroadcastDisposition::Ambiguous,
375            TXID_HEX.to_string(),
376            |_txid| async {
377                tokio::time::sleep(Duration::from_millis(200)).await;
378                BroadcastVerification::Rejected
379            },
380            move |txid| async move {
381                assert_eq!(txid, TXID_HEX);
382                f.store(true, Ordering::SeqCst);
383            },
384        )
385        .await;
386        assert_eq!(outcome, FollowUp::Rejected);
387        assert!(
388            started.elapsed() >= Duration::from_millis(200),
389            "an ambiguous broadcast must wait for the verdict"
390        );
391        assert!(
392            fired.load(Ordering::SeqCst),
393            "the tx is retired before the failure is returned"
394        );
395    }
396
397    #[tokio::test]
398    async fn ambiguous_broadcast_proceeds_on_confirmed_or_inconclusive() {
399        for verdict in [
400            BroadcastVerification::Confirmed,
401            BroadcastVerification::Inconclusive,
402        ] {
403            let fired = Arc::new(AtomicBool::new(false));
404            let f = fired.clone();
405            let outcome = follow_up(
406                BroadcastDisposition::Ambiguous,
407                TXID_HEX.to_string(),
408                move |_txid| async move { verdict },
409                move |_txid| async move {
410                    f.store(true, Ordering::SeqCst);
411                },
412            )
413            .await;
414            assert_eq!(outcome, FollowUp::Proceed);
415            assert!(!fired.load(Ordering::SeqCst));
416        }
417    }
418
419    #[tokio::test]
420    async fn nothing_broadcast_means_nothing_verified() {
421        let probed = Arc::new(AtomicBool::new(false));
422        let p = probed.clone();
423        let outcome = follow_up(
424            BroadcastDisposition::NotBroadcast,
425            TXID_HEX.to_string(),
426            move |_txid| async move {
427                p.store(true, Ordering::SeqCst);
428                BroadcastVerification::Rejected
429            },
430            |_txid| async {},
431        )
432        .await;
433        assert_eq!(outcome, FollowUp::Proceed);
434        tokio::time::sleep(Duration::from_millis(50)).await;
435        assert!(!probed.load(Ordering::SeqCst));
436    }
437
438    // ---- the retire hook against real storage ------------------------------
439
440    /// The exact storage the handler's hook runs against: an `unproven` tx
441    /// (broadcast accepted), req `unmined`, one input locked, one change output.
442    async fn seeded_storage() -> (StorageSqlx, i64, i64) {
443        use bsv_wallet_toolbox::WalletStorageWriter;
444
445        let storage = StorageSqlx::in_memory().await.expect("in-memory storage");
446        let storage_key = "02".to_string() + &"ab".repeat(32);
447        storage
448            .migrate("follow-up-tests", &storage_key)
449            .await
450            .expect("migrate");
451        storage.make_available().await.expect("make_available");
452        let identity = "02".to_string() + &"cd".repeat(32);
453        let (user, _) = storage.find_or_insert_user(&identity).await.expect("user");
454        let basket = storage
455            .find_or_create_default_basket(user.user_id)
456            .await
457            .expect("basket");
458        let now = chrono::Utc::now();
459        let lock = hex::decode("76a914dbc0a7c84983c5bf199b7b2d41b3acf0408ee5aa88ac").unwrap();
460        let parent_txid = "aa".repeat(32);
461
462        let parent_id = sqlx::query(
463            "INSERT INTO transactions (user_id, status, reference, is_outgoing, satoshis, version, lock_time, description, txid, raw_tx, created_at, updated_at) \
464             VALUES (?, 'completed', 'parent', 0, 50000, 1, 0, 'parent', ?, X'01000000', ?, ?)",
465        )
466        .bind(user.user_id)
467        .bind(&parent_txid)
468        .bind(now)
469        .bind(now)
470        .execute(storage.pool())
471        .await
472        .unwrap()
473        .last_insert_rowid();
474        let tx_id = sqlx::query(
475            "INSERT INTO transactions (user_id, status, reference, is_outgoing, satoshis, version, lock_time, description, txid, raw_tx, created_at, updated_at) \
476             VALUES (?, 'unproven', 'ours', 1, -2000, 1, 0, 'ours', ?, X'01000000', ?, ?)",
477        )
478        .bind(user.user_id)
479        .bind(TXID_HEX)
480        .bind(now)
481        .bind(now)
482        .execute(storage.pool())
483        .await
484        .unwrap()
485        .last_insert_rowid();
486        let input_id = sqlx::query(
487            "INSERT INTO outputs (user_id, transaction_id, basket_id, vout, satoshis, locking_script, txid, type, spendable, change, spent_by, provided_by, purpose, output_description, created_at, updated_at) \
488             VALUES (?, ?, ?, 0, 50000, ?, ?, 'P2PKH', 0, 1, ?, 'storage', 'change', 'input', ?, ?)",
489        )
490        .bind(user.user_id)
491        .bind(parent_id)
492        .bind(basket.basket_id)
493        .bind(&lock)
494        .bind(&parent_txid)
495        .bind(tx_id)
496        .bind(now)
497        .bind(now)
498        .execute(storage.pool())
499        .await
500        .unwrap()
501        .last_insert_rowid();
502        let own_id = sqlx::query(
503            "INSERT INTO outputs (user_id, transaction_id, basket_id, vout, satoshis, locking_script, txid, type, spendable, change, provided_by, purpose, output_description, created_at, updated_at) \
504             VALUES (?, ?, ?, 0, 48000, ?, ?, 'P2PKH', 1, 1, 'storage', 'change', 'our change', ?, ?)",
505        )
506        .bind(user.user_id)
507        .bind(tx_id)
508        .bind(basket.basket_id)
509        .bind(&lock)
510        .bind(TXID_HEX)
511        .bind(now)
512        .bind(now)
513        .execute(storage.pool())
514        .await
515        .unwrap()
516        .last_insert_rowid();
517        sqlx::query(
518            "INSERT INTO proven_tx_reqs (txid, status, attempts, history, notified, notify, raw_tx, created_at, updated_at) \
519             VALUES (?, 'unmined', 0, '{}', 0, '{}', X'01000000', ?, ?)",
520        )
521        .bind(TXID_HEX)
522        .bind(now)
523        .bind(now)
524        .execute(storage.pool())
525        .await
526        .unwrap();
527        (storage, input_id, own_id)
528    }
529
530    async fn output_state(storage: &StorageSqlx, id: i64) -> (i64, Option<i64>) {
531        sqlx::query_as("SELECT spendable, spent_by FROM outputs WHERE output_id = ?")
532            .bind(id)
533            .fetch_one(storage.pool())
534            .await
535            .unwrap()
536    }
537
538    #[tokio::test]
539    async fn async_rejection_marks_the_tx_failed_and_releases_verified_inputs() {
540        use bsv_wallet_toolbox::services::mock::MockWalletServices;
541
542        let (storage, input_id, own_id) = seeded_storage().await;
543        // The chain oracle: the tx is unknown (not alive), its input is unspent.
544        let services = MockWalletServices::new();
545
546        // Drive the exact handler wiring: an ACCEPTED broadcast whose background
547        // verification comes back Rejected runs the retire hook.
548        let storage = Arc::new(storage);
549        let services = Arc::new(services);
550        let (done_tx, done_rx) = tokio::sync::oneshot::channel::<Option<RetireOutcome>>();
551        let (s, v) = (storage.clone(), services.clone());
552        follow_up(
553            BroadcastDisposition::Accepted,
554            TXID_HEX.to_string(),
555            |_txid| async { BroadcastVerification::Rejected },
556            move |txid| async move {
557                let outcome = retire_rejected_broadcast(&s, &*v, &txid).await;
558                done_tx.send(outcome).ok();
559            },
560        )
561        .await;
562        let outcome = tokio::time::timeout(Duration::from_secs(5), done_rx)
563            .await
564            .expect("background retire runs")
565            .expect("hook fired");
566        assert_eq!(
567            outcome,
568            Some(RetireOutcome::Retired {
569                restored: 1,
570                kept: 0
571            })
572        );
573
574        let status: String = sqlx::query_scalar("SELECT status FROM transactions WHERE txid = ?")
575            .bind(TXID_HEX)
576            .fetch_one(storage.pool())
577            .await
578            .unwrap();
579        assert_eq!(status, "failed");
580        let req: String = sqlx::query_scalar("SELECT status FROM proven_tx_reqs WHERE txid = ?")
581            .bind(TXID_HEX)
582            .fetch_one(storage.pool())
583            .await
584            .unwrap();
585        assert_eq!(req, "invalid");
586        assert_eq!(
587            output_state(&storage, input_id).await,
588            (1, None),
589            "the chain-verified input is back in coin selection"
590        );
591        assert_eq!(
592            output_state(&storage, own_id).await,
593            (0, None),
594            "the failed tx's change can never fund anything"
595        );
596    }
597
598    #[tokio::test]
599    async fn retire_keeps_an_input_the_chain_cannot_vouch_for() {
600        use bsv_wallet_toolbox::services::mock::{MockResponse, MockWalletServices};
601
602        let (storage, input_id, _own_id) = seeded_storage().await;
603        let services = MockWalletServices::builder()
604            .is_utxo_response(MockResponse::Success(false))
605            .build();
606        let outcome = retire_rejected_broadcast(&storage, &services, TXID_HEX).await;
607        assert_eq!(
608            outcome,
609            Some(RetireOutcome::Retired {
610                restored: 0,
611                kept: 1
612            })
613        );
614        let (spendable, spent_by) = output_state(&storage, input_id).await;
615        assert_eq!(spendable, 0, "an unknown never releases money");
616        assert!(spent_by.is_some());
617    }
618
619    #[tokio::test]
620    async fn retire_of_an_unknown_txid_is_a_logged_noop() {
621        use bsv_wallet_toolbox::services::mock::MockWalletServices;
622
623        let (storage, input_id, _own_id) = seeded_storage().await;
624        let outcome =
625            retire_rejected_broadcast(&storage, &MockWalletServices::new(), &"ee".repeat(32)).await;
626        assert_eq!(outcome, None);
627        assert_eq!(output_state(&storage, input_id).await.0, 0);
628    }
629}