1use std::time::{Duration, Instant};
92
93use bsv_wallet_toolbox::{
94 services::ARCADE_V2_MAINNET, BroadcastStatus, Chain, BROADCAST_PROVIDER_NETWORK,
95 PROVIDER_ARCADE_V2,
96};
97use reqwest::Client;
98
99const DEFAULT_ATTEMPTS: u32 = 14;
122const DEFAULT_DELAY_MS: u64 = 2500;
124const INITIAL_DELAY_MS: u64 = 250;
131const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum BroadcastVerification {
138 Confirmed,
140 Rejected,
146 Inconclusive,
150}
151
152impl BroadcastVerification {
153 pub fn into_send_result(self, txid: &str) -> anyhow::Result<()> {
156 match self {
157 BroadcastVerification::Rejected => Err(anyhow::anyhow!(
158 "broadcast rejected: transaction {txid} is absent from BOTH the broadcaster \
159 it was submitted to AND an independent chain index, after the full probe \
160 window. The broadcaster dropped it — most likely error 465 \"fee too low\", \
161 because a monitor-less wallet presented a deep unconfirmed BEEF and ARC \
162 charged the fee for the whole unconfirmed package. The funds were NOT sent. \
163 Fetch merkle proofs for the confirmed ancestors (run `bsv-wallet tick` with \
164 CHAINTRACKS_URL set) or fund from a confirmed UTXO, then retry."
165 )),
166 BroadcastVerification::Confirmed | BroadcastVerification::Inconclusive => Ok(()),
167 }
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
174pub enum NetworkEvidence {
175 Seen,
178 Mined,
180}
181
182impl NetworkEvidence {
183 pub fn memory_status(self) -> &'static str {
185 match self {
186 NetworkEvidence::Seen => bsv_wallet_toolbox::BROADCAST_STATUS_SEEN,
187 NetworkEvidence::Mined => bsv_wallet_toolbox::BROADCAST_STATUS_MINED,
188 }
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct PresenceReport {
196 pub verification: BroadcastVerification,
198 pub evidence: Option<NetworkEvidence>,
200 pub evidence_provider: &'static str,
205 pub broadcaster_fatal: bool,
208 pub network_absent: bool,
213}
214
215impl PresenceReport {
216 pub fn from_verification(verification: BroadcastVerification) -> Self {
218 Self {
219 verification,
220 evidence: None,
221 evidence_provider: BROADCAST_PROVIDER_NETWORK,
222 broadcaster_fatal: false,
223 network_absent: false,
224 }
225 }
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230enum Presence {
231 Held,
235 Present(NetworkEvidence),
237 Fatal,
241 Absent,
243 Unknown,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254enum AbsenceAuthority {
255 None,
264
265 Broadcaster,
281
282 ChainIndex,
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294enum SourceKind {
295 Arcade,
297 ClassicArc,
299 ChainIndex,
301}
302
303#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
320struct AbsenceVotes {
321 broadcaster: bool,
323 chain_index: bool,
325}
326
327impl AbsenceVotes {
328 fn record(&mut self, authority: AbsenceAuthority) {
329 match authority {
330 AbsenceAuthority::Broadcaster => self.broadcaster = true,
331 AbsenceAuthority::ChainIndex => self.chain_index = true,
332 AbsenceAuthority::None => {}
334 }
335 }
336
337 fn is_definitive(self) -> bool {
339 self.broadcaster && self.chain_index
340 }
341}
342
343#[derive(Debug, Clone, PartialEq, Eq)]
349enum BroadcastPlane {
350 ArcadeV2 { base: String },
360 ClassicArc { base: String },
363}
364
365impl BroadcastPlane {
366 fn resolve(chain: Chain, arcade_mode: bool, arc_url: Option<String>) -> Self {
371 let arc_url = arc_url
372 .map(|s| s.trim().to_string())
373 .filter(|s| !s.is_empty());
374 if arcade_mode {
375 BroadcastPlane::ArcadeV2 {
376 base: normalize_base(&arc_url.unwrap_or_else(|| ARCADE_V2_MAINNET.to_string())),
377 }
378 } else {
379 BroadcastPlane::ClassicArc {
380 base: normalize_base(&arc_url.unwrap_or_else(|| taal_arc_url(chain).to_string())),
381 }
382 }
383 }
384
385 fn from_env(chain: Chain) -> Self {
386 Self::resolve(
387 chain,
388 crate::services_env::arcade_mode_enabled(),
389 std::env::var("ARC_URL").ok(),
390 )
391 }
392
393 fn base(&self) -> &str {
394 match self {
395 BroadcastPlane::ArcadeV2 { base } | BroadcastPlane::ClassicArc { base } => base,
396 }
397 }
398
399 fn name(&self) -> &'static str {
400 match self {
401 BroadcastPlane::ArcadeV2 { .. } => "broadcaster(arcade-v2)",
402 BroadcastPlane::ClassicArc { .. } => "broadcaster(arc)",
403 }
404 }
405
406 fn kind(&self) -> SourceKind {
407 match self {
408 BroadcastPlane::ArcadeV2 { .. } => SourceKind::Arcade,
409 BroadcastPlane::ClassicArc { .. } => SourceKind::ClassicArc,
410 }
411 }
412
413 fn status_template(&self) -> String {
415 match self {
416 BroadcastPlane::ArcadeV2 { base } => format!("{base}/tx/{{txid}}"),
418 BroadcastPlane::ClassicArc { base } => format!("{base}/v1/tx/{{txid}}"),
420 }
421 }
422}
423
424#[derive(Clone, Debug)]
426struct StatusSource {
427 name: &'static str,
429 url_template: String,
431 auth: Option<String>,
433 absence: AbsenceAuthority,
435 kind: SourceKind,
437}
438
439fn build_sources(
445 chain: Chain,
446 plane: &BroadcastPlane,
447 taal_key: Option<String>,
448) -> Vec<StatusSource> {
449 let mut sources = vec![StatusSource {
450 name: plane.name(),
451 url_template: plane.status_template(),
452 auth: match plane {
454 BroadcastPlane::ArcadeV2 { .. } => None,
455 BroadcastPlane::ClassicArc { .. } => taal_key.clone(),
456 },
457 absence: AbsenceAuthority::Broadcaster,
458 kind: plane.kind(),
459 }];
460
461 sources.push(StatusSource {
464 name: "whatsonchain",
465 url_template: format!("{}/tx/hash/{{txid}}", woc_base(chain)),
466 auth: None,
467 absence: AbsenceAuthority::ChainIndex,
468 kind: SourceKind::ChainIndex,
469 });
470
471 if let Some(gp) = gorillapool_arc_url(chain) {
475 if normalize_base(gp) != plane.base() {
476 sources.push(StatusSource {
477 name: "arc-gorillapool",
478 url_template: format!("{gp}/v1/tx/{{txid}}"),
479 auth: None,
480 absence: AbsenceAuthority::None,
481 kind: SourceKind::ClassicArc,
482 });
483 }
484 }
485 if let Some(key) = taal_key {
488 let taal = taal_arc_url(chain);
489 if normalize_base(taal) != plane.base() {
490 sources.push(StatusSource {
491 name: "arc-taal",
492 url_template: format!("{taal}/v1/tx/{{txid}}"),
493 auth: Some(key),
494 absence: AbsenceAuthority::None,
495 kind: SourceKind::ClassicArc,
496 });
497 }
498 }
499
500 sources
501}
502
503#[derive(Clone)]
508pub struct BroadcastVerifier {
509 client: Client,
510 sources: Vec<StatusSource>,
511 attempts: u32,
512 delay: Duration,
513 enabled: bool,
515}
516
517impl BroadcastVerifier {
518 pub fn from_env(chain: Chain) -> Self {
526 let enabled = !env_truthy("BSV_WALLET_SKIP_BROADCAST_VERIFY");
527 let attempts = std::env::var("BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS")
528 .ok()
529 .and_then(|v| v.parse::<u32>().ok())
530 .filter(|n| *n > 0)
531 .unwrap_or(DEFAULT_ATTEMPTS);
532 let delay_ms = std::env::var("BSV_WALLET_BROADCAST_VERIFY_DELAY_MS")
533 .ok()
534 .and_then(|v| v.parse::<u64>().ok())
535 .unwrap_or(DEFAULT_DELAY_MS);
536
537 let taal_key = std::env::var("TAAL_API_KEY")
539 .ok()
540 .filter(|k| !k.is_empty())
541 .or_else(|| {
542 std::env::var("MAIN_TAAL_API_KEY")
543 .ok()
544 .filter(|k| !k.is_empty())
545 });
546
547 let plane = BroadcastPlane::from_env(chain);
548 tracing::debug!(plane = ?plane, "broadcast verifier plane");
549
550 Self {
551 client: Client::new(),
552 sources: build_sources(chain, &plane, taal_key),
553 attempts,
554 delay: Duration::from_millis(delay_ms),
555 enabled,
556 }
557 }
558
559 pub fn single_pass(chain: Chain) -> Self {
573 let mut v = Self::from_env(chain);
574 v.attempts = 1;
575 v.delay = Duration::ZERO;
576 v
577 }
578
579 fn absence_window(&self) -> Duration {
580 (1..self.attempts)
581 .map(|round| self.delay_before_round(round))
582 .sum::<Duration>()
583 + PROBE_TIMEOUT
584 }
585
586 fn delay_before_round(&self, round: u32) -> Duration {
592 let exponent = round.saturating_sub(1).min(16);
593 let grown = Duration::from_millis(INITIAL_DELAY_MS.saturating_mul(1u64 << exponent));
594 grown.min(self.delay)
595 }
596
597 pub async fn verify(&self, txid: &str) -> BroadcastVerification {
600 self.verify_report(txid).await.verification
601 }
602
603 pub async fn verify_report(&self, txid: &str) -> PresenceReport {
611 let mut report = PresenceReport::from_verification(BroadcastVerification::Inconclusive);
612 if !self.enabled || self.sources.is_empty() {
613 return report;
614 }
615
616 let deadline = Instant::now() + self.absence_window();
617 let mut last: Option<RoundResult> = None;
621
622 for attempt in 0..self.attempts {
623 let mut round = RoundResult::default();
624 for src in &self.sources {
625 match probe(&self.client, src, txid).await {
626 Presence::Present(evidence) => {
629 report.verification = BroadcastVerification::Confirmed;
630 report.evidence = Some(evidence);
631 report.evidence_provider = if src.kind == SourceKind::Arcade
632 && src.absence == AbsenceAuthority::Broadcaster
633 {
634 PROVIDER_ARCADE_V2
635 } else {
636 BROADCAST_PROVIDER_NETWORK
637 };
638 return report;
639 }
640 Presence::Held => {
641 round.held = true;
642 if src.absence == AbsenceAuthority::Broadcaster {
643 round.broadcaster_answered = true;
644 }
645 }
646 Presence::Fatal => {
647 round.fatal = true;
648 round.broadcaster_answered = true;
649 round.votes.record(src.absence);
650 }
651 Presence::Absent => {
652 if src.absence == AbsenceAuthority::Broadcaster {
653 round.broadcaster_answered = true;
654 }
655 round.votes.record(src.absence);
656 }
657 Presence::Unknown => {}
658 }
659 }
660 let held = round.held;
661 last = Some(round);
662
663 if held {
668 break;
669 }
670
671 if attempt + 1 < self.attempts {
672 if Instant::now() >= deadline {
673 break;
676 }
677 tokio::time::sleep(self.delay_before_round(attempt + 1)).await;
678 }
679 }
680
681 if let Some(round) = last {
682 report.broadcaster_fatal = round.fatal;
683 report.network_absent = round.votes.chain_index && round.broadcaster_answered;
684 report.verification = if round.held {
685 BroadcastVerification::Confirmed
686 } else if round.votes.is_definitive() {
687 BroadcastVerification::Rejected
688 } else {
689 BroadcastVerification::Inconclusive
690 };
691 }
692 report
693 }
694}
695
696#[derive(Debug, Clone, Copy, Default)]
698struct RoundResult {
699 votes: AbsenceVotes,
700 held: bool,
702 broadcaster_answered: bool,
704 fatal: bool,
706}
707
708fn presence_of_body(src: &StatusSource, body: &str) -> Presence {
710 let json: Option<serde_json::Value> = serde_json::from_str(body).ok();
711 match src.kind {
712 SourceKind::ChainIndex => {
713 let confirmations = json
714 .as_ref()
715 .and_then(|v| v.get("confirmations"))
716 .and_then(|c| c.as_i64())
717 .unwrap_or(0);
718 if confirmations >= 1 {
719 Presence::Present(NetworkEvidence::Mined)
720 } else {
721 Presence::Present(NetworkEvidence::Seen)
722 }
723 }
724 SourceKind::Arcade | SourceKind::ClassicArc => {
725 let Some(tx_status) = json
726 .as_ref()
727 .and_then(|v| v.get("txStatus"))
728 .and_then(|s| s.as_str())
729 else {
730 return Presence::Held;
731 };
732 let status = match src.kind {
733 SourceKind::Arcade => BroadcastStatus::from_arcade_status(tx_status),
734 _ => BroadcastStatus::from_arc_status(tx_status),
735 };
736 match status {
737 BroadcastStatus::Seen => Presence::Present(NetworkEvidence::Seen),
738 BroadcastStatus::Mined => Presence::Present(NetworkEvidence::Mined),
739 BroadcastStatus::Rejected => {
740 if src.absence == AbsenceAuthority::Broadcaster {
741 Presence::Fatal
742 } else {
743 Presence::Unknown
746 }
747 }
748 BroadcastStatus::Accepted | BroadcastStatus::Unknown => Presence::Held,
749 }
750 }
751 }
752}
753
754async fn probe(client: &Client, src: &StatusSource, txid: &str) -> Presence {
756 let url = src.url_template.replace("{txid}", txid);
757 let mut req = client.get(&url).timeout(PROBE_TIMEOUT);
758 if let Some(auth) = &src.auth {
759 req = req.header("Authorization", auth);
760 }
761 match req.send().await {
762 Ok(resp) => {
763 let status = resp.status().as_u16();
764 match status {
765 200 => {
766 let body = resp.text().await.unwrap_or_default();
767 let presence = presence_of_body(src, &body);
768 tracing::debug!(source = src.name, ?presence, "broadcast probe");
769 presence
770 }
771 404 => {
772 if src.absence == AbsenceAuthority::Broadcaster && !is_json(&resp) {
781 tracing::debug!(
782 source = src.name,
783 url = %url,
784 "broadcaster 404 is not a JSON tx-status body — treating as \
785 route-not-found (check ARC_URL / path shape), not absence"
786 );
787 return Presence::Unknown;
788 }
789 Presence::Absent
790 }
791 other => {
792 tracing::debug!(
793 source = src.name,
794 status = other,
795 "broadcast probe inconclusive"
796 );
797 Presence::Unknown
798 }
799 }
800 }
801 Err(e) => {
802 tracing::debug!(source = src.name, error = %e, "broadcast probe request failed");
803 Presence::Unknown
804 }
805 }
806}
807
808fn is_json(resp: &reqwest::Response) -> bool {
811 resp.headers()
812 .get(reqwest::header::CONTENT_TYPE)
813 .and_then(|v| v.to_str().ok())
814 .map(|ct| ct.to_ascii_lowercase().contains("json"))
815 .unwrap_or(false)
816}
817
818fn normalize_base(url: &str) -> String {
819 url.trim().trim_end_matches('/').to_string()
820}
821
822fn taal_arc_url(chain: Chain) -> &'static str {
823 match chain {
824 Chain::Main => "https://arc.taal.com",
825 Chain::Test => "https://arc-test.taal.com",
826 }
827}
828
829fn gorillapool_arc_url(chain: Chain) -> Option<&'static str> {
830 match chain {
831 Chain::Main => Some("https://arc.gorillapool.io"),
832 Chain::Test => None,
834 }
835}
836
837fn woc_base(chain: Chain) -> &'static str {
838 match chain {
839 Chain::Main => "https://api.whatsonchain.com/v1/bsv/main",
840 Chain::Test => "https://api.whatsonchain.com/v1/bsv/test",
841 }
842}
843
844fn env_truthy(key: &str) -> bool {
845 std::env::var(key)
846 .map(|v| {
847 let v = v.trim().to_ascii_lowercase();
848 v == "1" || v == "true" || v == "yes" || v == "on"
849 })
850 .unwrap_or(false)
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856 use axum::http::StatusCode;
857 use axum::routing::get;
858 use axum::Router;
859 use std::net::SocketAddr;
860
861 const TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001";
863 const SYNTHETIC_ARCADE: &str = "https://arcade.invalid";
864 const SYNTHETIC_ARC: &str = "https://arc.invalid";
865 const SYNTHETIC_KEY: &str = "test-key-not-a-real-credential";
866
867 #[tokio::test]
875 async fn single_pass_is_one_attempt_and_disabled_means_inconclusive() {
876 let v = BroadcastVerifier::single_pass(Chain::Main);
877 assert_eq!(v.attempts, 1);
878 assert_eq!(v.delay, Duration::ZERO);
879 let off = BroadcastVerifier {
880 enabled: false,
881 ..v
882 };
883 assert_eq!(
884 off.verify(&"cd".repeat(32)).await,
885 BroadcastVerification::Inconclusive
886 );
887 }
888
889 #[test]
890 fn arcade_plane_uses_bare_tx_path_not_v1() {
891 let plane = BroadcastPlane::resolve(
895 Chain::Main,
896 true,
897 Some(SYNTHETIC_ARCADE.to_string()),
898 );
899 assert_eq!(
900 plane.status_template(),
901 format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
902 );
903 assert!(
904 !plane.status_template().contains("/v1/"),
905 "Arcade V2 must NOT be probed on the classic ARC /v1 path"
906 );
907 assert_eq!(plane.kind(), SourceKind::Arcade);
908 }
909
910 #[test]
911 fn classic_arc_plane_uses_v1_tx_path() {
912 let plane = BroadcastPlane::resolve(
913 Chain::Main,
914 false,
915 Some(SYNTHETIC_ARC.to_string()),
916 );
917 assert_eq!(
918 plane.status_template(),
919 format!("{SYNTHETIC_ARC}/v1/tx/{{txid}}")
920 );
921 assert_eq!(plane.kind(), SourceKind::ClassicArc);
922 }
923
924 #[test]
925 fn arcade_mode_defaults_to_the_arcade_endpoint_when_arc_url_is_unset() {
926 let plane = BroadcastPlane::resolve(Chain::Main, true, None);
927 assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
928 }
929
930 #[test]
931 fn classic_mode_defaults_to_taal_and_respects_chain() {
932 assert_eq!(
933 BroadcastPlane::resolve(Chain::Main, false, None).base(),
934 "https://arc.taal.com"
935 );
936 assert_eq!(
937 BroadcastPlane::resolve(Chain::Test, false, None).base(),
938 "https://arc-test.taal.com"
939 );
940 }
941
942 #[test]
943 fn empty_arc_url_falls_back_to_the_default_rather_than_an_empty_base() {
944 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(" ".to_string()));
945 assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
946 }
947
948 #[test]
949 fn trailing_slash_in_arc_url_does_not_produce_a_double_slash() {
950 let plane = BroadcastPlane::resolve(
951 Chain::Main,
952 true,
953 Some(format!("{SYNTHETIC_ARCADE}/").to_string()),
954 );
955 assert_eq!(
956 plane.status_template(),
957 format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
958 );
959 }
960
961 #[test]
962 fn the_broadcaster_we_used_is_always_the_first_source_consulted() {
963 for plane in [
966 BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string())),
967 BroadcastPlane::resolve(Chain::Main, false, Some(SYNTHETIC_ARC.to_string())),
968 ] {
969 let sources = build_sources(Chain::Main, &plane, None);
970 assert_eq!(sources[0].absence, AbsenceAuthority::Broadcaster);
971 assert_eq!(sources[0].kind, plane.kind());
972 assert!(
973 sources[0].url_template.starts_with(plane.base()),
974 "source 0 ({}) must be the configured broadcaster {}",
975 sources[0].url_template,
976 plane.base()
977 );
978 }
979 }
980
981 #[test]
982 fn arcade_broadcaster_probe_is_keyless_even_when_a_taal_key_exists() {
983 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
984 let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
985 assert!(sources[0].auth.is_none());
986 }
987
988 #[test]
989 fn classic_broadcaster_probe_carries_the_taal_key_when_present() {
990 let plane = BroadcastPlane::resolve(Chain::Main, false, None);
991 let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
992 assert_eq!(sources[0].auth.as_deref(), Some(SYNTHETIC_KEY));
993 }
994
995 #[test]
996 fn keyless_taal_is_not_probed_at_all() {
997 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
999 let sources = build_sources(Chain::Main, &plane, None);
1000 assert!(!sources.iter().any(|s| s.name == "arc-taal"));
1001 }
1002
1003 #[test]
1004 fn a_store_is_never_listed_twice_when_it_is_also_the_broadcaster() {
1005 let plane = BroadcastPlane::resolve(
1008 Chain::Main,
1009 false,
1010 Some("https://arc.gorillapool.io".to_string()),
1011 );
1012 let sources = build_sources(Chain::Main, &plane, None);
1013 let gp_rows: Vec<_> = sources
1014 .iter()
1015 .filter(|s| s.url_template.contains("arc.gorillapool.io"))
1016 .collect();
1017 assert_eq!(gp_rows.len(), 1);
1018 assert_eq!(gp_rows[0].absence, AbsenceAuthority::Broadcaster);
1019 }
1020
1021 #[test]
1026 fn a_third_party_arc_store_is_never_authoritative_for_absence() {
1027 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
1032 let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
1033 for s in sources.iter().filter(|s| s.name.starts_with("arc-")) {
1034 assert_eq!(
1035 s.absence,
1036 AbsenceAuthority::None,
1037 "{} is not the broadcaster; its absence must carry no weight",
1038 s.name
1039 );
1040 }
1041 }
1042
1043 #[test]
1044 fn whatsonchain_is_the_chain_index_authority() {
1045 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
1046 let sources = build_sources(Chain::Main, &plane, None);
1047 let woc = sources.iter().find(|s| s.name == "whatsonchain").unwrap();
1048 assert_eq!(woc.absence, AbsenceAuthority::ChainIndex);
1049 assert_eq!(woc.kind, SourceKind::ChainIndex);
1050 }
1051
1052 #[test]
1053 fn absence_is_definitive_only_when_broadcaster_and_chain_index_agree() {
1054 let mut none = AbsenceVotes::default();
1055 assert!(!none.is_definitive(), "no votes is not evidence");
1056
1057 none.record(AbsenceAuthority::None);
1059 assert!(!none.is_definitive());
1060
1061 let mut broadcaster_only = AbsenceVotes::default();
1062 broadcaster_only.record(AbsenceAuthority::Broadcaster);
1063 assert!(
1064 !broadcaster_only.is_definitive(),
1065 "the primary may 404 while the tx went out through the failover provider"
1066 );
1067
1068 let mut index_only = AbsenceVotes::default();
1069 index_only.record(AbsenceAuthority::ChainIndex);
1070 assert!(
1071 !index_only.is_definitive(),
1072 "a chain index can simply be lagging its mempool ingestion"
1073 );
1074
1075 let mut both = AbsenceVotes::default();
1076 both.record(AbsenceAuthority::Broadcaster);
1077 both.record(AbsenceAuthority::ChainIndex);
1078 assert!(both.is_definitive());
1079 }
1080
1081 fn src_of(kind: SourceKind, absence: AbsenceAuthority) -> StatusSource {
1086 StatusSource {
1087 name: "test",
1088 url_template: "http://127.0.0.1:1/tx/{txid}".to_string(),
1089 auth: None,
1090 absence,
1091 kind,
1092 }
1093 }
1094
1095 #[test]
1096 fn a_200_body_is_read_by_source_kind() {
1097 let arcade = src_of(SourceKind::Arcade, AbsenceAuthority::Broadcaster);
1098 assert_eq!(
1099 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"RECEIVED"}"#),
1100 Presence::Held,
1101 "a pre-gate status is held, not network evidence"
1102 );
1103 assert_eq!(
1104 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"ACCEPTED_BY_NETWORK"}"#),
1105 Presence::Held
1106 );
1107 assert_eq!(
1108 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#),
1109 Presence::Present(NetworkEvidence::Seen)
1110 );
1111 assert_eq!(
1112 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"MINED"}"#),
1113 Presence::Present(NetworkEvidence::Mined)
1114 );
1115 assert_eq!(
1116 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"REJECTED"}"#),
1117 Presence::Fatal
1118 );
1119 assert_eq!(
1120 presence_of_body(&arcade, "{}"),
1121 Presence::Held,
1122 "a 200 without a readable status still means the store holds it"
1123 );
1124 assert_eq!(presence_of_body(&arcade, "not json"), Presence::Held);
1125
1126 let arc = src_of(SourceKind::ClassicArc, AbsenceAuthority::None);
1127 assert_eq!(
1128 presence_of_body(&arc, r#"{"txStatus":"SEEN_IN_ORPHAN_MEMPOOL"}"#),
1129 Presence::Held,
1130 "an orphan-pool hit is held: the node lacks the parent"
1131 );
1132 assert_eq!(
1133 presence_of_body(&arc, r#"{"txStatus":"SEEN_ON_NETWORK"}"#),
1134 Presence::Present(NetworkEvidence::Seen)
1135 );
1136 assert_eq!(
1137 presence_of_body(&arc, r#"{"txStatus":"REJECTED"}"#),
1138 Presence::Unknown,
1139 "a third-party rejection of somebody's copy is no vote"
1140 );
1141
1142 let woc = src_of(SourceKind::ChainIndex, AbsenceAuthority::ChainIndex);
1143 assert_eq!(
1144 presence_of_body(&woc, r#"{"txid":"x","confirmations":0}"#),
1145 Presence::Present(NetworkEvidence::Seen)
1146 );
1147 assert_eq!(
1148 presence_of_body(&woc, r#"{"txid":"x","confirmations":3}"#),
1149 Presence::Present(NetworkEvidence::Mined)
1150 );
1151 assert_eq!(
1152 presence_of_body(&woc, r#"{"txid":"x"}"#),
1153 Presence::Present(NetworkEvidence::Seen)
1154 );
1155 }
1156
1157 async fn mock_status_server(code: StatusCode) -> String {
1164 mock_status_server_full(code, Some("application/json"), "{}").await
1165 }
1166
1167 async fn mock_status_server_ct(code: StatusCode, content_type: Option<&'static str>) -> String {
1169 mock_status_server_full(code, content_type, "{}").await
1170 }
1171
1172 async fn mock_status_server_body(body: &'static str) -> String {
1174 mock_status_server_full(StatusCode::OK, Some("application/json"), body).await
1175 }
1176
1177 async fn mock_status_server_full(
1178 code: StatusCode,
1179 content_type: Option<&'static str>,
1180 body: &'static str,
1181 ) -> String {
1182 let handler = move || async move {
1183 let mut resp = axum::response::Response::new(axum::body::Body::from(body));
1184 *resp.status_mut() = code;
1185 if let Some(ct) = content_type {
1186 resp.headers_mut()
1187 .insert(reqwest::header::CONTENT_TYPE.as_str(), ct.parse().unwrap());
1188 } else {
1189 resp.headers_mut()
1190 .remove(reqwest::header::CONTENT_TYPE.as_str());
1191 }
1192 resp
1193 };
1194 let app = Router::new()
1195 .route("/tx/{txid}", get(handler))
1196 .route("/v1/tx/{txid}", get(handler))
1197 .route("/tx/hash/{txid}", get(handler));
1198 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1199 let addr: SocketAddr = listener.local_addr().unwrap();
1200 tokio::spawn(async move {
1201 axum::serve(listener, app).await.ok();
1202 });
1203 format!("http://{}", addr)
1204 }
1205
1206 fn source(name: &'static str, base: &str, absence: AbsenceAuthority) -> StatusSource {
1207 source_kind(name, base, absence, SourceKind::ClassicArc)
1208 }
1209
1210 fn source_kind(
1211 name: &'static str,
1212 base: &str,
1213 absence: AbsenceAuthority,
1214 kind: SourceKind,
1215 ) -> StatusSource {
1216 StatusSource {
1217 name,
1218 url_template: format!("{base}/tx/{{txid}}"),
1219 auth: None,
1220 absence,
1221 kind,
1222 }
1223 }
1224
1225 fn verifier_with(sources: Vec<StatusSource>) -> BroadcastVerifier {
1227 BroadcastVerifier {
1228 client: Client::new(),
1229 sources,
1230 attempts: 2,
1231 delay: Duration::from_millis(0),
1232 enabled: true,
1233 }
1234 }
1235
1236 #[tokio::test]
1237 async fn rejected_when_broadcaster_and_chain_index_both_report_absent() {
1238 let base = mock_status_server(StatusCode::NOT_FOUND).await;
1242 let verifier = verifier_with(vec![
1243 source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1244 source("chain-index", &base, AbsenceAuthority::ChainIndex),
1245 ]);
1246
1247 let report = verifier.verify_report(TXID).await;
1248 assert_eq!(report.verification, BroadcastVerification::Rejected);
1249 assert!(report.network_absent);
1250 assert!(!report.broadcaster_fatal);
1251 assert_eq!(report.evidence, None);
1252 assert!(
1253 report.verification.into_send_result(TXID).is_err(),
1254 "a Rejected verification must map to Err so the send fails loudly"
1255 );
1256 }
1257
1258 #[tokio::test]
1259 async fn the_false_negative_that_motivated_this_fix_is_now_inconclusive() {
1260 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1266 let verifier = verifier_with(vec![
1267 source(
1269 "broadcaster",
1270 "http://127.0.0.1:1",
1271 AbsenceAuthority::Broadcaster,
1272 ),
1273 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1274 source("arc-third-party", &absent, AbsenceAuthority::None),
1275 ]);
1276 let report = verifier.verify_report(TXID).await;
1277 assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1278 assert!(
1279 !report.network_absent,
1280 "the absence clock does not run while the broadcaster is unreachable"
1281 );
1282 }
1283
1284 #[tokio::test]
1285 async fn third_party_absence_alone_never_rejects() {
1286 let base = mock_status_server(StatusCode::NOT_FOUND).await;
1287 let verifier = verifier_with(vec![
1288 source("arc-third-party-a", &base, AbsenceAuthority::None),
1289 source("arc-third-party-b", &base, AbsenceAuthority::None),
1290 ]);
1291 assert_eq!(
1292 verifier.verify(TXID).await,
1293 BroadcastVerification::Inconclusive
1294 );
1295 }
1296
1297 #[tokio::test]
1298 async fn broadcaster_absence_alone_never_rejects() {
1299 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1302 let verifier = verifier_with(vec![
1303 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1304 source(
1306 "chain-index",
1307 "http://127.0.0.1:1",
1308 AbsenceAuthority::ChainIndex,
1309 ),
1310 ]);
1311 assert_eq!(
1312 verifier.verify(TXID).await,
1313 BroadcastVerification::Inconclusive
1314 );
1315 }
1316
1317 #[tokio::test]
1318 async fn chain_index_absence_alone_never_rejects() {
1319 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1320 let verifier = verifier_with(vec![
1321 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1323 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1324 ]);
1325 assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
1327
1328 let unauth = mock_status_server(StatusCode::UNAUTHORIZED).await;
1330 let verifier = verifier_with(vec![
1331 source("broadcaster", &unauth, AbsenceAuthority::Broadcaster),
1332 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1333 ]);
1334 assert_eq!(
1335 verifier.verify(TXID).await,
1336 BroadcastVerification::Inconclusive
1337 );
1338 }
1339
1340 #[tokio::test]
1341 async fn presence_from_any_source_confirms_even_when_others_say_absent() {
1342 let present = mock_status_server(StatusCode::OK).await;
1344 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1345 let verifier = verifier_with(vec![
1346 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1347 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1348 source("arc-third-party", &present, AbsenceAuthority::None),
1349 ]);
1350 let outcome = verifier.verify(TXID).await;
1351 assert_eq!(outcome, BroadcastVerification::Confirmed);
1352 assert!(outcome.into_send_result(TXID).is_ok());
1353 }
1354
1355 #[tokio::test]
1356 async fn confirmed_broadcast_succeeds() {
1357 let base = mock_status_server(StatusCode::OK).await;
1358 let verifier = verifier_with(vec![source(
1359 "broadcaster",
1360 &base,
1361 AbsenceAuthority::Broadcaster,
1362 )]);
1363 let outcome = verifier.verify(TXID).await;
1364 assert_eq!(outcome, BroadcastVerification::Confirmed);
1365 assert!(outcome.into_send_result(TXID).is_ok());
1366 }
1367
1368 #[tokio::test]
1369 async fn unreachable_source_is_inconclusive_not_a_failure() {
1370 let base = mock_status_server(StatusCode::SERVICE_UNAVAILABLE).await;
1373 let verifier = verifier_with(vec![
1374 source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1375 source("chain-index", &base, AbsenceAuthority::ChainIndex),
1376 ]);
1377 let outcome = verifier.verify(TXID).await;
1378 assert_eq!(outcome, BroadcastVerification::Inconclusive);
1379 assert!(outcome.into_send_result(TXID).is_ok());
1380 }
1381
1382 #[tokio::test]
1383 async fn a_routing_404_from_the_broadcaster_is_not_absence() {
1384 let text_404 = mock_status_server_ct(StatusCode::NOT_FOUND, Some("text/plain")).await;
1387 let json_404 = mock_status_server(StatusCode::NOT_FOUND).await;
1388 let verifier = verifier_with(vec![
1389 source("broadcaster", &text_404, AbsenceAuthority::Broadcaster),
1390 source("chain-index", &json_404, AbsenceAuthority::ChainIndex),
1391 ]);
1392 assert_eq!(
1393 verifier.verify(TXID).await,
1394 BroadcastVerification::Inconclusive
1395 );
1396 }
1397
1398 #[tokio::test]
1399 async fn disabled_verifier_is_inconclusive() {
1400 let base = mock_status_server(StatusCode::NOT_FOUND).await;
1401 let mut verifier = verifier_with(vec![
1402 source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1403 source("chain-index", &base, AbsenceAuthority::ChainIndex),
1404 ]);
1405 verifier.enabled = false;
1406 assert_eq!(
1407 verifier.verify(TXID).await,
1408 BroadcastVerification::Inconclusive
1409 );
1410 }
1411
1412 #[tokio::test]
1415 async fn seen_on_network_from_the_arcade_plane_is_network_evidence_for_arcade() {
1416 let seen = mock_status_server_body(r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#).await;
1417 let verifier = verifier_with(vec![source_kind(
1418 "broadcaster",
1419 &seen,
1420 AbsenceAuthority::Broadcaster,
1421 SourceKind::Arcade,
1422 )]);
1423 let report = verifier.verify_report(TXID).await;
1424 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1425 assert_eq!(report.evidence, Some(NetworkEvidence::Seen));
1426 assert_eq!(report.evidence_provider, PROVIDER_ARCADE_V2);
1427 assert!(!report.network_absent && !report.broadcaster_fatal);
1428 }
1429
1430 #[tokio::test]
1431 async fn a_pre_gate_status_is_held_only_and_the_absence_clock_runs() {
1432 let held = mock_status_server_body(r#"{"txid":"x","txStatus":"RECEIVED"}"#).await;
1436 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1437 let verifier = verifier_with(vec![
1438 source_kind(
1439 "broadcaster",
1440 &held,
1441 AbsenceAuthority::Broadcaster,
1442 SourceKind::Arcade,
1443 ),
1444 source_kind(
1445 "chain-index",
1446 &absent,
1447 AbsenceAuthority::ChainIndex,
1448 SourceKind::ChainIndex,
1449 ),
1450 ]);
1451 let report = verifier.verify_report(TXID).await;
1452 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1453 assert_eq!(report.evidence, None);
1454 assert!(report.network_absent);
1455 assert!(!report.broadcaster_fatal);
1456 }
1457
1458 #[tokio::test]
1459 async fn a_fatal_verdict_from_the_broadcaster_with_an_index_miss_is_rejected() {
1460 let fatal = mock_status_server_body(r#"{"txid":"x","txStatus":"REJECTED"}"#).await;
1461 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1462 let verifier = verifier_with(vec![
1463 source_kind(
1464 "broadcaster",
1465 &fatal,
1466 AbsenceAuthority::Broadcaster,
1467 SourceKind::Arcade,
1468 ),
1469 source_kind(
1470 "chain-index",
1471 &absent,
1472 AbsenceAuthority::ChainIndex,
1473 SourceKind::ChainIndex,
1474 ),
1475 ]);
1476 let report = verifier.verify_report(TXID).await;
1477 assert_eq!(report.verification, BroadcastVerification::Rejected);
1478 assert!(report.broadcaster_fatal);
1479 assert!(report.network_absent);
1480
1481 let verifier = verifier_with(vec![
1483 source_kind(
1484 "broadcaster",
1485 &fatal,
1486 AbsenceAuthority::Broadcaster,
1487 SourceKind::Arcade,
1488 ),
1489 source_kind(
1490 "chain-index",
1491 "http://127.0.0.1:1",
1492 AbsenceAuthority::ChainIndex,
1493 SourceKind::ChainIndex,
1494 ),
1495 ]);
1496 let report = verifier.verify_report(TXID).await;
1497 assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1498 assert!(report.broadcaster_fatal);
1499 assert!(!report.network_absent);
1500 }
1501
1502 #[tokio::test]
1503 async fn a_chain_index_hit_is_network_evidence_for_everyone() {
1504 let held = mock_status_server_body(r#"{"txid":"x","txStatus":"SENT_TO_NETWORK"}"#).await;
1505 let mined = mock_status_server_body(r#"{"txid":"x","confirmations":2}"#).await;
1506 let verifier = verifier_with(vec![
1507 source_kind(
1508 "broadcaster",
1509 &held,
1510 AbsenceAuthority::Broadcaster,
1511 SourceKind::Arcade,
1512 ),
1513 source_kind(
1514 "chain-index",
1515 &mined,
1516 AbsenceAuthority::ChainIndex,
1517 SourceKind::ChainIndex,
1518 ),
1519 ]);
1520 let report = verifier.verify_report(TXID).await;
1521 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1522 assert_eq!(report.evidence, Some(NetworkEvidence::Mined));
1523 assert_eq!(report.evidence_provider, BROADCAST_PROVIDER_NETWORK);
1524 assert!(!report.network_absent);
1525 }
1526
1527 #[tokio::test]
1528 async fn a_third_party_rejection_alone_is_inconclusive() {
1529 let fatal = mock_status_server_body(r#"{"txid":"x","txStatus":"REJECTED"}"#).await;
1530 let verifier = verifier_with(vec![source_kind(
1531 "arc-third-party",
1532 &fatal,
1533 AbsenceAuthority::None,
1534 SourceKind::ClassicArc,
1535 )]);
1536 let report = verifier.verify_report(TXID).await;
1537 assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1538 assert!(!report.broadcaster_fatal);
1539 }
1540
1541 #[test]
1542 fn absence_window_is_bounded_and_reflects_the_configured_rounds() {
1543 let v = BroadcastVerifier {
1544 client: Client::new(),
1545 sources: vec![],
1546 attempts: DEFAULT_ATTEMPTS,
1547 delay: Duration::from_millis(DEFAULT_DELAY_MS),
1548 enabled: true,
1549 };
1550 assert_eq!(
1554 v.absence_window(),
1555 Duration::from_millis(26_250) + PROBE_TIMEOUT
1556 );
1557 }
1558
1559 #[test]
1560 fn probe_schedule_starts_short_grows_and_caps() {
1561 let v = BroadcastVerifier {
1565 client: Client::new(),
1566 sources: vec![],
1567 attempts: DEFAULT_ATTEMPTS,
1568 delay: Duration::from_millis(DEFAULT_DELAY_MS),
1569 enabled: true,
1570 };
1571 let gaps: Vec<u64> = (1..v.attempts)
1572 .map(|r| v.delay_before_round(r).as_millis() as u64)
1573 .collect();
1574 assert_eq!(
1575 gaps,
1576 vec![250, 500, 1000, 2000, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500]
1577 );
1578 assert!(gaps.windows(2).all(|w| w[0] <= w[1]), "never shrinks");
1579 assert!(
1580 gaps.iter().all(|g| *g <= DEFAULT_DELAY_MS),
1581 "never exceeds the cap"
1582 );
1583
1584 let tight = BroadcastVerifier {
1586 delay: Duration::from_millis(100),
1587 ..v
1588 };
1589 assert!(
1590 (1..tight.attempts).all(|r| tight.delay_before_round(r) == Duration::from_millis(100))
1591 );
1592
1593 let one = BroadcastVerifier::single_pass(Chain::Main);
1595 assert_eq!(one.absence_window(), PROBE_TIMEOUT);
1596 }
1597
1598 #[tokio::test]
1599 async fn a_present_tx_is_confirmed_on_the_first_probe_without_waiting() {
1600 let present = mock_status_server(StatusCode::OK).await;
1604 let verifier = BroadcastVerifier {
1605 client: Client::new(),
1606 sources: vec![source(
1607 "broadcaster",
1608 &present,
1609 AbsenceAuthority::Broadcaster,
1610 )],
1611 attempts: DEFAULT_ATTEMPTS,
1612 delay: Duration::from_millis(DEFAULT_DELAY_MS),
1613 enabled: true,
1614 };
1615 let started = std::time::Instant::now();
1616 assert_eq!(
1617 verifier.verify(TXID).await,
1618 BroadcastVerification::Confirmed
1619 );
1620 assert!(
1621 started.elapsed() < Duration::from_millis(INITIAL_DELAY_MS),
1622 "took {:?}",
1623 started.elapsed()
1624 );
1625 }
1626
1627 #[tokio::test]
1628 async fn an_absent_tx_is_retried_on_the_growing_schedule() {
1629 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1633 let verifier = BroadcastVerifier {
1634 client: Client::new(),
1635 sources: vec![
1636 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1637 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1638 ],
1639 attempts: 4,
1640 delay: Duration::from_millis(200),
1641 enabled: true,
1642 };
1643 assert!((1..4).all(|r| verifier.delay_before_round(r) == Duration::from_millis(200)));
1645 let started = std::time::Instant::now();
1646 assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
1647 let elapsed = started.elapsed();
1648 assert!(
1649 elapsed >= Duration::from_millis(600) && elapsed < Duration::from_millis(2_000),
1650 "took {:?}",
1651 elapsed
1652 );
1653 }
1654}