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, and since 0.3.58
22//!   every unproven descendant retired with it);
23//! * keep the **inline** verification ONLY for an **ambiguous** result — a
24//!   transient service fault where the transaction may or may not be out, the
25//!   one case where the client must not be told "sent" on a coin flip.
26//!
27//! # The broadcast memory (0.3.58)
28//!
29//! The background probe is also the served wallet's only source of NETWORK
30//! evidence (a `serve` has no monitor). When it finds the transaction seen on
31//! the network, [`apply_presence_report`] records `seen` for the txid AND for
32//! every unproven ancestor the package carried: presence of the child implies
33//! the parents connected, and only `seen` lets the next broadcast leave an
34//! ancestor out of the package. An acceptance alone never does (2026-09-02).
35//!
36//! The decision (`disposition`) and the follow-up mechanics (`follow_up`) are
37//! pure over injected closures so the timing contract is testable without a
38//! network or a funded wallet.
39
40use std::future::Future;
41
42use bsv_sdk::wallet::{CreateActionResult, SendWithResultStatus};
43use bsv_wallet_toolbox::{
44    BroadcastMemory, MonitorStorage, RetireOutcome, StorageSqlx, WalletServices,
45};
46
47use crate::broadcast_verify::{BroadcastVerification, NetworkEvidence, PresenceReport};
48
49/// How the immediate broadcast of a `create_action` went, as far as the wallet
50/// could tell.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum BroadcastDisposition {
53    /// Nothing was broadcast: `noSend`, `acceptDelayedBroadcast`, or a deferred
54    /// signing (`signableTransaction`) flow. Nothing to verify.
55    NotBroadcast,
56    /// The broadcaster ACCEPTED the transaction (2xx with a non-fatal status):
57    /// `sendWithResults` reports it `unproven`.
58    Accepted,
59    /// The wallet could not tell: a transient service fault left the entry at
60    /// `sending`, or the toolbox reported nothing for this txid.
61    Ambiguous,
62}
63
64/// Classify a `create_action` result. Only immediate broadcasts are ever
65/// verified; an accepted one is verified in the background, an ambiguous one
66/// inline.
67pub fn disposition(
68    result: &CreateActionResult,
69    no_send: bool,
70    accept_delayed: bool,
71) -> BroadcastDisposition {
72    if no_send || accept_delayed || result.signable_transaction.is_some() {
73        return BroadcastDisposition::NotBroadcast;
74    }
75    let Some(txid) = result.txid else {
76        return BroadcastDisposition::NotBroadcast;
77    };
78    let entry = result
79        .send_with_results
80        .as_ref()
81        .and_then(|rs| rs.iter().find(|r| r.txid == txid));
82    match entry {
83        Some(r) if matches!(r.status, SendWithResultStatus::Unproven) => {
84            BroadcastDisposition::Accepted
85        }
86        _ => BroadcastDisposition::Ambiguous,
87    }
88}
89
90/// What the request handler does once the follow-up returns.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum FollowUp {
93    /// Answer the client with the txid.
94    Proceed,
95    /// The inline verification found the transaction definitively absent; the
96    /// tx has been retired and the client must see a failure.
97    Rejected,
98}
99
100/// Run the post-broadcast verification for `txid` according to `disposition`.
101///
102/// * `NotBroadcast`: nothing happens.
103/// * `Accepted`: `verify` is SPAWNED; this function returns at once. The
104///   report is handed to `on_report` in that background task.
105/// * `Ambiguous`: `verify` is AWAITED and its report handed to `on_report`
106///   before returning; a `Rejected` verdict returns [`FollowUp::Rejected`].
107///
108/// `on_report` is where the wallet acts (see [`apply_presence_report`]): a
109/// `Rejected` verdict retires the transaction, network evidence feeds the
110/// broadcast memory, and a `Confirmed`-but-held or `Inconclusive` report
111/// touches no money (an absence must be definitive before any money moves,
112/// see `broadcast_verify`).
113pub async fn follow_up<V, VF, R, RF>(
114    disposition: BroadcastDisposition,
115    txid: String,
116    verify: V,
117    on_report: R,
118) -> FollowUp
119where
120    V: FnOnce(String) -> VF + Send + 'static,
121    VF: Future<Output = PresenceReport> + Send + 'static,
122    R: FnOnce(String, PresenceReport) -> RF + Send + 'static,
123    RF: Future<Output = ()> + Send + 'static,
124{
125    match disposition {
126        BroadcastDisposition::NotBroadcast => FollowUp::Proceed,
127        BroadcastDisposition::Accepted => {
128            tokio::spawn(async move {
129                let report = verify(txid.clone()).await;
130                match report.verification {
131                    BroadcastVerification::Confirmed => {
132                        tracing::debug!(
133                            txid = %txid,
134                            evidence = ?report.evidence,
135                            "post-broadcast verification: present"
136                        );
137                    }
138                    BroadcastVerification::Inconclusive => {
139                        tracing::info!(
140                            txid = %txid,
141                            "post-broadcast verification: inconclusive (kept; the reconcile sweeps decide later)"
142                        );
143                    }
144                    BroadcastVerification::Rejected => {
145                        tracing::warn!(
146                            txid = %txid,
147                            "post-broadcast verification: ACCEPTED by the broadcaster but definitively absent afterwards — retiring"
148                        );
149                    }
150                }
151                on_report(txid, report).await;
152            });
153            FollowUp::Proceed
154        }
155        BroadcastDisposition::Ambiguous => {
156            let report = verify(txid.clone()).await;
157            let verification = report.verification;
158            if verification == BroadcastVerification::Rejected {
159                tracing::warn!(
160                    txid = %txid,
161                    "ambiguous broadcast verified definitively absent — retiring and failing the request"
162                );
163            }
164            on_report(txid, report).await;
165            match verification {
166                BroadcastVerification::Rejected => FollowUp::Rejected,
167                BroadcastVerification::Confirmed | BroadcastVerification::Inconclusive => {
168                    FollowUp::Proceed
169                }
170            }
171        }
172    }
173}
174
175/// Act on a presence report for `txid` whose package carried `ancestors`
176/// (its unproven ancestors, from the BEEF the wallet built):
177///
178/// * network evidence → `seen` (or `mined`) for the txid under the plane
179///   that reported it, and `seen` for every ancestor (presence of the child
180///   implies the parents connected). The txid's own req is lifted to
181///   `unmined` exactly as a push `SEEN_ON_NETWORK` would.
182/// * a `Rejected` verdict → [`retire_rejected_broadcast`].
183/// * anything else → nothing.
184pub async fn apply_presence_report(
185    storage: &StorageSqlx,
186    services: &dyn WalletServices,
187    txid: &str,
188    ancestors: &[String],
189    report: &PresenceReport,
190) {
191    if let Some(evidence) = report.evidence {
192        let provider = report.evidence_provider;
193        if let Err(e) = storage
194            .mark_transaction_seen_on_network_by(txid, provider)
195            .await
196        {
197            tracing::warn!(txid = %txid, error = %e, "could not record the network presence");
198        }
199        if evidence == NetworkEvidence::Mined {
200            if let Err(e) = storage
201                .record_broadcast_status(txid, provider, evidence.memory_status())
202                .await
203            {
204                tracing::warn!(txid = %txid, error = %e, "could not record the mined evidence");
205            }
206        }
207        if !ancestors.is_empty() {
208            if let Err(e) = storage
209                .sqlx_broadcast_memory()
210                .record_broadcast_status_many(
211                    provider,
212                    bsv_wallet_toolbox::BROADCAST_STATUS_SEEN,
213                    ancestors,
214                )
215                .await
216            {
217                tracing::warn!(txid = %txid, error = %e, "could not credit the ancestors");
218            }
219        }
220        tracing::info!(
221            txid = %txid,
222            evidence = ?evidence,
223            provider,
224            ancestors = ancestors.len(),
225            "network presence recorded: the package's ancestors connected"
226        );
227    }
228    if report.verification == BroadcastVerification::Rejected {
229        retire_rejected_broadcast(storage, services, txid).await;
230    }
231}
232
233/// Fail a definitively-absent broadcast and give its inputs back, through the
234/// toolbox's RELEASE-RULE path (`StorageSqlx::retire_undeliverable_txid`): the
235/// tx is alive-checked first, each input is released only on its own chain
236/// verification, the tx's own outputs go unspendable and the tx is `failed`
237/// with its req `invalid` (the unfail canary keeps re-checking it), and every
238/// unproven descendant is retired with it. Every outcome is logged; nothing
239/// here can fail the request that already answered.
240pub async fn retire_rejected_broadcast(
241    storage: &StorageSqlx,
242    services: &dyn WalletServices,
243    txid: &str,
244) -> Option<RetireOutcome> {
245    match storage
246        .retire_undeliverable_txid(services, txid, "invalid")
247        .await
248    {
249        Ok(Some(outcome @ RetireOutcome::Retired { restored, kept })) => {
250            tracing::warn!(
251                txid = %txid,
252                restored,
253                kept,
254                "retired absent broadcast: tx failed, {} input(s) released (chain-verified), {} kept locked",
255                restored,
256                kept
257            );
258            Some(outcome)
259        }
260        Ok(Some(RetireOutcome::Alive)) => {
261            tracing::info!(
262                txid = %txid,
263                "absent per the probe but known to the status service — kept (promoted), nothing released"
264            );
265            Some(RetireOutcome::Alive)
266        }
267        Ok(None) => {
268            tracing::warn!(
269                txid = %txid,
270                "absent broadcast has no proven_tx_req — nothing to retire"
271            );
272            None
273        }
274        Err(e) => {
275            tracing::error!(txid = %txid, error = %e, "failed to retire absent broadcast");
276            None
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use bsv_sdk::wallet::{SendWithResult, SignableTransaction};
285    use std::sync::atomic::{AtomicBool, Ordering};
286    use std::sync::Arc;
287    use std::time::{Duration, Instant};
288
289    const TXID_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000001";
290
291    fn txid_bytes() -> [u8; 32] {
292        let mut t = [0u8; 32];
293        t[31] = 1;
294        t
295    }
296
297    fn result(status: Option<SendWithResultStatus>) -> CreateActionResult {
298        let txid = txid_bytes();
299        CreateActionResult {
300            txid: Some(txid),
301            tx: Some(vec![1, 0, 0, 0]),
302            no_send_change: None,
303            send_with_results: status.map(|s| vec![SendWithResult { txid, status: s }]),
304            signable_transaction: None,
305            input_type: None,
306            inputs: None,
307            reference_number: None,
308            beef: None,
309        }
310    }
311
312    fn report(v: BroadcastVerification) -> PresenceReport {
313        PresenceReport::from_verification(v)
314    }
315
316    // ---- disposition ------------------------------------------------------
317
318    #[test]
319    fn accepted_broadcast_reports_unproven() {
320        assert_eq!(
321            disposition(&result(Some(SendWithResultStatus::Unproven)), false, false),
322            BroadcastDisposition::Accepted
323        );
324    }
325
326    #[test]
327    fn transient_fault_leaves_sending_which_is_ambiguous() {
328        assert_eq!(
329            disposition(&result(Some(SendWithResultStatus::Sending)), false, false),
330            BroadcastDisposition::Ambiguous
331        );
332    }
333
334    #[test]
335    fn no_report_for_our_txid_is_ambiguous() {
336        // An older toolbox (or a missing entry) must fall back to the safe path.
337        assert_eq!(
338            disposition(&result(None), false, false),
339            BroadcastDisposition::Ambiguous
340        );
341        let mut other = result(Some(SendWithResultStatus::Unproven));
342        other.send_with_results.as_mut().unwrap()[0].txid = [9u8; 32];
343        assert_eq!(
344            disposition(&other, false, false),
345            BroadcastDisposition::Ambiguous
346        );
347    }
348
349    #[test]
350    fn nothing_to_verify_when_nothing_was_broadcast() {
351        let accepted = result(Some(SendWithResultStatus::Unproven));
352        assert_eq!(
353            disposition(&accepted, true, false),
354            BroadcastDisposition::NotBroadcast
355        );
356        assert_eq!(
357            disposition(&accepted, false, true),
358            BroadcastDisposition::NotBroadcast
359        );
360        let mut deferred = result(Some(SendWithResultStatus::Unproven));
361        deferred.signable_transaction = Some(SignableTransaction {
362            tx: vec![1],
363            reference: b"ref".to_vec(),
364        });
365        assert_eq!(
366            disposition(&deferred, false, false),
367            BroadcastDisposition::NotBroadcast
368        );
369        let mut no_txid = result(Some(SendWithResultStatus::Unproven));
370        no_txid.txid = None;
371        assert_eq!(
372            disposition(&no_txid, false, false),
373            BroadcastDisposition::NotBroadcast
374        );
375    }
376
377    // ---- follow_up timing contract -----------------------------------------
378
379    #[tokio::test]
380    async fn accepted_broadcast_answers_before_the_verification_finishes() {
381        // The verifier takes 300 ms and comes back Rejected. The handler must
382        // return long before that (the probe was SPAWNED, not awaited), and the
383        // report hook must still fire afterwards, in the background.
384        let (tx, rx) = tokio::sync::oneshot::channel::<(String, PresenceReport)>();
385        let started = Instant::now();
386        let outcome = follow_up(
387            BroadcastDisposition::Accepted,
388            TXID_HEX.to_string(),
389            |_txid| async {
390                tokio::time::sleep(Duration::from_millis(300)).await;
391                report(BroadcastVerification::Rejected)
392            },
393            move |txid, report| async move {
394                tx.send((txid, report)).ok();
395            },
396        )
397        .await;
398        let elapsed = started.elapsed();
399        assert_eq!(outcome, FollowUp::Proceed);
400        assert!(
401            elapsed < Duration::from_millis(150),
402            "an accepted broadcast must not wait for the verifier (took {:?})",
403            elapsed
404        );
405        let (retired, report) = tokio::time::timeout(Duration::from_secs(2), rx)
406            .await
407            .expect("the background verification must run to its verdict")
408            .expect("report hook fired");
409        assert_eq!(retired, TXID_HEX);
410        assert_eq!(report.verification, BroadcastVerification::Rejected);
411        assert!(
412            started.elapsed() >= Duration::from_millis(300),
413            "the verdict arrives only after the probe window"
414        );
415    }
416
417    #[tokio::test]
418    async fn accepted_and_present_hands_the_report_over_without_retiring() {
419        // The hook sees every report (it records network evidence); the
420        // retire decision is the hook's, keyed on the verdict.
421        let (tx, rx) = tokio::sync::oneshot::channel::<PresenceReport>();
422        let outcome = follow_up(
423            BroadcastDisposition::Accepted,
424            TXID_HEX.to_string(),
425            |_txid| async {
426                let mut r = report(BroadcastVerification::Confirmed);
427                r.evidence = Some(NetworkEvidence::Seen);
428                r
429            },
430            move |_txid, report| async move {
431                tx.send(report).ok();
432            },
433        )
434        .await;
435        assert_eq!(outcome, FollowUp::Proceed);
436        let report = tokio::time::timeout(Duration::from_secs(2), rx)
437            .await
438            .expect("hook runs")
439            .expect("report");
440        assert_eq!(report.verification, BroadcastVerification::Confirmed);
441        assert_eq!(report.evidence, Some(NetworkEvidence::Seen));
442    }
443
444    #[tokio::test]
445    async fn ambiguous_broadcast_is_verified_inline_and_a_rejection_fails_the_request() {
446        // The one case that still blocks: the wallet could not tell whether the
447        // tx went out. The verifier is AWAITED, and a definitive absence reaches
448        // the hook BEFORE the handler answers with a failure.
449        let fired = Arc::new(AtomicBool::new(false));
450        let f = fired.clone();
451        let started = Instant::now();
452        let outcome = follow_up(
453            BroadcastDisposition::Ambiguous,
454            TXID_HEX.to_string(),
455            |_txid| async {
456                tokio::time::sleep(Duration::from_millis(200)).await;
457                report(BroadcastVerification::Rejected)
458            },
459            move |txid, report| async move {
460                assert_eq!(txid, TXID_HEX);
461                assert_eq!(report.verification, BroadcastVerification::Rejected);
462                f.store(true, Ordering::SeqCst);
463            },
464        )
465        .await;
466        assert_eq!(outcome, FollowUp::Rejected);
467        assert!(
468            started.elapsed() >= Duration::from_millis(200),
469            "an ambiguous broadcast must wait for the verdict"
470        );
471        assert!(
472            fired.load(Ordering::SeqCst),
473            "the hook runs before the failure is returned"
474        );
475    }
476
477    #[tokio::test]
478    async fn ambiguous_broadcast_proceeds_on_confirmed_or_inconclusive() {
479        for verdict in [
480            BroadcastVerification::Confirmed,
481            BroadcastVerification::Inconclusive,
482        ] {
483            let outcome = follow_up(
484                BroadcastDisposition::Ambiguous,
485                TXID_HEX.to_string(),
486                move |_txid| async move { report(verdict) },
487                move |_txid, _report| async move {},
488            )
489            .await;
490            assert_eq!(outcome, FollowUp::Proceed);
491        }
492    }
493
494    #[tokio::test]
495    async fn nothing_broadcast_means_nothing_verified() {
496        let probed = Arc::new(AtomicBool::new(false));
497        let p = probed.clone();
498        let outcome = follow_up(
499            BroadcastDisposition::NotBroadcast,
500            TXID_HEX.to_string(),
501            move |_txid| async move {
502                p.store(true, Ordering::SeqCst);
503                report(BroadcastVerification::Rejected)
504            },
505            |_txid, _report| async {},
506        )
507        .await;
508        assert_eq!(outcome, FollowUp::Proceed);
509        tokio::time::sleep(Duration::from_millis(50)).await;
510        assert!(!probed.load(Ordering::SeqCst));
511    }
512
513    // ---- the report hook against real storage ------------------------------
514
515    /// The exact storage the handler's hook runs against: an `unproven` tx
516    /// (broadcast accepted), req `unmined`, one input locked, one change output.
517    async fn seeded_storage() -> (StorageSqlx, i64, i64) {
518        use bsv_wallet_toolbox::WalletStorageWriter;
519
520        let storage = StorageSqlx::in_memory().await.expect("in-memory storage");
521        let storage_key = "02".to_string() + &"ab".repeat(32);
522        storage
523            .migrate("follow-up-tests", &storage_key)
524            .await
525            .expect("migrate");
526        storage.make_available().await.expect("make_available");
527        let identity = "02".to_string() + &"cd".repeat(32);
528        let (user, _) = storage.find_or_insert_user(&identity).await.expect("user");
529        let basket = storage
530            .find_or_create_default_basket(user.user_id)
531            .await
532            .expect("basket");
533        let now = chrono::Utc::now();
534        let lock = hex::decode("76a914dbc0a7c84983c5bf199b7b2d41b3acf0408ee5aa88ac").unwrap();
535        let parent_txid = "aa".repeat(32);
536
537        let parent_id = sqlx::query(
538            "INSERT INTO transactions (user_id, status, reference, is_outgoing, satoshis, version, lock_time, description, txid, raw_tx, created_at, updated_at) \
539             VALUES (?, 'completed', 'parent', 0, 50000, 1, 0, 'parent', ?, X'01000000', ?, ?)",
540        )
541        .bind(user.user_id)
542        .bind(&parent_txid)
543        .bind(now)
544        .bind(now)
545        .execute(storage.pool())
546        .await
547        .unwrap()
548        .last_insert_rowid();
549        let tx_id = sqlx::query(
550            "INSERT INTO transactions (user_id, status, reference, is_outgoing, satoshis, version, lock_time, description, txid, raw_tx, created_at, updated_at) \
551             VALUES (?, 'unproven', 'ours', 1, -2000, 1, 0, 'ours', ?, X'01000000', ?, ?)",
552        )
553        .bind(user.user_id)
554        .bind(TXID_HEX)
555        .bind(now)
556        .bind(now)
557        .execute(storage.pool())
558        .await
559        .unwrap()
560        .last_insert_rowid();
561        let input_id = sqlx::query(
562            "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) \
563             VALUES (?, ?, ?, 0, 50000, ?, ?, 'P2PKH', 0, 1, ?, 'storage', 'change', 'input', ?, ?)",
564        )
565        .bind(user.user_id)
566        .bind(parent_id)
567        .bind(basket.basket_id)
568        .bind(&lock)
569        .bind(&parent_txid)
570        .bind(tx_id)
571        .bind(now)
572        .bind(now)
573        .execute(storage.pool())
574        .await
575        .unwrap()
576        .last_insert_rowid();
577        let own_id = sqlx::query(
578            "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) \
579             VALUES (?, ?, ?, 0, 48000, ?, ?, 'P2PKH', 1, 1, 'storage', 'change', 'our change', ?, ?)",
580        )
581        .bind(user.user_id)
582        .bind(tx_id)
583        .bind(basket.basket_id)
584        .bind(&lock)
585        .bind(TXID_HEX)
586        .bind(now)
587        .bind(now)
588        .execute(storage.pool())
589        .await
590        .unwrap()
591        .last_insert_rowid();
592        sqlx::query(
593            "INSERT INTO proven_tx_reqs (txid, status, attempts, history, notified, notify, raw_tx, created_at, updated_at) \
594             VALUES (?, 'unmined', 0, '{}', 0, '{}', X'01000000', ?, ?)",
595        )
596        .bind(TXID_HEX)
597        .bind(now)
598        .bind(now)
599        .execute(storage.pool())
600        .await
601        .unwrap();
602        (storage, input_id, own_id)
603    }
604
605    async fn output_state(storage: &StorageSqlx, id: i64) -> (i64, Option<i64>) {
606        sqlx::query_as("SELECT spendable, spent_by FROM outputs WHERE output_id = ?")
607            .bind(id)
608            .fetch_one(storage.pool())
609            .await
610            .unwrap()
611    }
612
613    #[tokio::test]
614    async fn async_rejection_marks_the_tx_failed_and_releases_verified_inputs() {
615        use bsv_wallet_toolbox::services::mock::MockWalletServices;
616
617        let (storage, input_id, own_id) = seeded_storage().await;
618        // The chain oracle: the tx is unknown (not alive), its input is unspent.
619        let services = MockWalletServices::new();
620
621        // Drive the exact handler wiring: an ACCEPTED broadcast whose background
622        // verification comes back Rejected runs the report hook, which retires.
623        let storage = Arc::new(storage);
624        let services = Arc::new(services);
625        let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
626        let (s, v) = (storage.clone(), services.clone());
627        follow_up(
628            BroadcastDisposition::Accepted,
629            TXID_HEX.to_string(),
630            |_txid| async { report(BroadcastVerification::Rejected) },
631            move |txid, report| async move {
632                apply_presence_report(&s, &*v, &txid, &[], &report).await;
633                done_tx.send(()).ok();
634            },
635        )
636        .await;
637        tokio::time::timeout(Duration::from_secs(5), done_rx)
638            .await
639            .expect("background retire runs")
640            .expect("hook fired");
641
642        let status: String = sqlx::query_scalar("SELECT status FROM transactions WHERE txid = ?")
643            .bind(TXID_HEX)
644            .fetch_one(storage.pool())
645            .await
646            .unwrap();
647        assert_eq!(status, "failed");
648        let req: String = sqlx::query_scalar("SELECT status FROM proven_tx_reqs WHERE txid = ?")
649            .bind(TXID_HEX)
650            .fetch_one(storage.pool())
651            .await
652            .unwrap();
653        assert_eq!(req, "invalid");
654        assert_eq!(
655            output_state(&storage, input_id).await,
656            (1, None),
657            "the chain-verified input is back in coin selection"
658        );
659        assert_eq!(
660            output_state(&storage, own_id).await,
661            (0, None),
662            "the failed tx's change can never fund anything"
663        );
664        // No provider ever skips it again.
665        let memory: String = sqlx::query_scalar(
666            "SELECT status FROM broadcast_seen WHERE txid = ? AND provider = 'network'",
667        )
668        .bind(TXID_HEX)
669        .fetch_one(storage.pool())
670        .await
671        .unwrap();
672        assert_eq!(memory, "rejected");
673    }
674
675    #[tokio::test]
676    async fn network_presence_credits_the_txid_and_its_ancestors_as_seen() {
677        use bsv_wallet_toolbox::services::mock::MockWalletServices;
678        use bsv_wallet_toolbox::PROVIDER_ARCADE_V2;
679
680        let (storage, input_id, own_id) = seeded_storage().await;
681        let ancestors = vec!["11".repeat(32), "22".repeat(32)];
682        let mut r = report(BroadcastVerification::Confirmed);
683        r.evidence = Some(NetworkEvidence::Seen);
684        r.evidence_provider = PROVIDER_ARCADE_V2;
685        apply_presence_report(
686            &storage,
687            &MockWalletServices::new(),
688            TXID_HEX,
689            &ancestors,
690            &r,
691        )
692        .await;
693
694        let mut wanted = ancestors.clone();
695        wanted.push(TXID_HEX.to_string());
696        let seen = storage
697            .broadcast_seen_for(PROVIDER_ARCADE_V2, &wanted)
698            .await
699            .unwrap();
700        assert_eq!(
701            seen.len(),
702            3,
703            "the txid and both ancestors qualify: {:?}",
704            seen
705        );
706        // Another provider is not credited by an Arcade report.
707        assert!(storage
708            .broadcast_seen_for("TaalArcBeef", &wanted)
709            .await
710            .unwrap()
711            .is_empty());
712        // Nothing about the money changed.
713        assert_eq!(output_state(&storage, input_id).await.0, 0);
714        assert_eq!(output_state(&storage, own_id).await.0, 1);
715        let status: String = sqlx::query_scalar("SELECT status FROM transactions WHERE txid = ?")
716            .bind(TXID_HEX)
717            .fetch_one(storage.pool())
718            .await
719            .unwrap();
720        assert_eq!(status, "unproven");
721
722        // A mined verdict from the chain index credits everyone.
723        let mut mined = report(BroadcastVerification::Confirmed);
724        mined.evidence = Some(NetworkEvidence::Mined);
725        apply_presence_report(&storage, &MockWalletServices::new(), TXID_HEX, &[], &mined).await;
726        let memory: String = sqlx::query_scalar(
727            "SELECT status FROM broadcast_seen WHERE txid = ? AND provider = 'network'",
728        )
729        .bind(TXID_HEX)
730        .fetch_one(storage.pool())
731        .await
732        .unwrap();
733        assert_eq!(memory, "mined");
734    }
735
736    #[tokio::test]
737    async fn a_held_or_inconclusive_report_touches_nothing() {
738        use bsv_wallet_toolbox::services::mock::MockWalletServices;
739
740        let (storage, input_id, own_id) = seeded_storage().await;
741        for verdict in [
742            BroadcastVerification::Confirmed,
743            BroadcastVerification::Inconclusive,
744        ] {
745            let mut r = report(verdict);
746            r.network_absent = true;
747            apply_presence_report(&storage, &MockWalletServices::new(), TXID_HEX, &[], &r).await;
748        }
749        assert_eq!(output_state(&storage, input_id).await.0, 0);
750        assert_eq!(output_state(&storage, own_id).await.0, 1);
751        let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM broadcast_seen WHERE txid = ?")
752            .bind(TXID_HEX)
753            .fetch_one(storage.pool())
754            .await
755            .unwrap();
756        assert_eq!(rows, 0, "no evidence, no memory row");
757    }
758
759    #[tokio::test]
760    async fn retire_keeps_an_input_the_chain_cannot_vouch_for() {
761        use bsv_wallet_toolbox::services::mock::{MockResponse, MockWalletServices};
762
763        let (storage, input_id, _own_id) = seeded_storage().await;
764        let services = MockWalletServices::builder()
765            .is_utxo_response(MockResponse::Success(false))
766            .build();
767        let outcome = retire_rejected_broadcast(&storage, &services, TXID_HEX).await;
768        assert_eq!(
769            outcome,
770            Some(RetireOutcome::Retired {
771                restored: 0,
772                kept: 1
773            })
774        );
775        let (spendable, spent_by) = output_state(&storage, input_id).await;
776        assert_eq!(spendable, 0, "an unknown never releases money");
777        assert!(spent_by.is_some());
778    }
779
780    #[tokio::test]
781    async fn retire_of_an_unknown_txid_is_a_logged_noop() {
782        use bsv_wallet_toolbox::services::mock::MockWalletServices;
783
784        let (storage, input_id, _own_id) = seeded_storage().await;
785        let outcome =
786            retire_rejected_broadcast(&storage, &MockWalletServices::new(), &"ee".repeat(32)).await;
787        assert_eq!(outcome, None);
788        assert_eq!(output_state(&storage, input_id).await.0, 0);
789    }
790}