1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum BroadcastDisposition {
53 NotBroadcast,
56 Accepted,
59 Ambiguous,
62}
63
64pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum FollowUp {
93 Proceed,
95 Rejected,
98}
99
100pub 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
175pub 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
233pub 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 #[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 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 #[tokio::test]
380 async fn accepted_broadcast_answers_before_the_verification_finishes() {
381 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 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 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 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 let services = MockWalletServices::new();
620
621 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 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 assert!(storage
708 .broadcast_seen_for("TaalArcBeef", &wanted)
709 .await
710 .unwrap()
711 .is_empty());
712 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 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}