1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum BroadcastDisposition {
41 NotBroadcast,
44 Accepted,
47 Ambiguous,
50}
51
52pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum FollowUp {
81 Proceed,
83 Rejected,
86}
87
88pub 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
151pub 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 #[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 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 #[tokio::test]
293 async fn accepted_broadcast_answers_before_the_verification_finishes() {
294 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 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 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 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 let services = MockWalletServices::new();
545
546 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}