1use std::sync::atomic::{AtomicUsize, Ordering};
92use std::sync::Arc;
93use std::time::{Duration, Instant};
94
95use bsv_wallet_toolbox::{
96 services::ARCADE_V2_MAINNET, BroadcastStatus, Chain, BROADCAST_PROVIDER_CHAIN,
97 BROADCAST_PROVIDER_NETWORK, PROVIDER_ARCADE_V2,
98};
99use reqwest::Client;
100
101const DEFAULT_ATTEMPTS: u32 = 14;
124const DEFAULT_DELAY_MS: u64 = 2500;
126const INITIAL_DELAY_MS: u64 = 250;
133const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum BroadcastVerification {
140 Confirmed,
142 Rejected,
148 Inconclusive,
152}
153
154impl BroadcastVerification {
155 pub fn into_send_result(self, txid: &str) -> anyhow::Result<()> {
158 match self {
159 BroadcastVerification::Rejected => Err(anyhow::anyhow!(
160 "broadcast rejected: transaction {txid} is absent from BOTH the broadcaster \
161 it was submitted to AND an independent chain index, after the full probe \
162 window. The broadcaster dropped it — most likely error 465 \"fee too low\", \
163 because a monitor-less wallet presented a deep unconfirmed BEEF and ARC \
164 charged the fee for the whole unconfirmed package. The funds were NOT sent. \
165 Fetch merkle proofs for the confirmed ancestors (run `bsv-wallet tick` with \
166 CHAINTRACKS_URL set) or fund from a confirmed UTXO, then retry."
167 )),
168 BroadcastVerification::Confirmed | BroadcastVerification::Inconclusive => Ok(()),
169 }
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
176pub enum NetworkEvidence {
177 Seen,
180 Mined,
182}
183
184impl NetworkEvidence {
185 pub fn memory_status(self) -> &'static str {
187 match self {
188 NetworkEvidence::Seen => bsv_wallet_toolbox::BROADCAST_STATUS_SEEN,
189 NetworkEvidence::Mined => bsv_wallet_toolbox::BROADCAST_STATUS_MINED,
190 }
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum ChainIndexAnswer {
198 Present(NetworkEvidence),
201 Absent,
204 Unknown,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct PresenceReport {
220 pub verification: BroadcastVerification,
222 pub evidence: Option<NetworkEvidence>,
224 pub evidence_provider: &'static str,
230 pub chain_index: ChainIndexAnswer,
232 pub broadcaster_fatal: bool,
235 pub network_absent: bool,
241}
242
243impl PresenceReport {
244 pub fn from_verification(verification: BroadcastVerification) -> Self {
246 Self {
247 verification,
248 evidence: None,
249 evidence_provider: BROADCAST_PROVIDER_NETWORK,
250 chain_index: ChainIndexAnswer::Unknown,
251 broadcaster_fatal: false,
252 network_absent: false,
253 }
254 }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259enum Presence {
260 Held,
264 Present(NetworkEvidence),
266 Fatal,
270 Absent,
272 Unknown,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283enum AbsenceAuthority {
284 None,
293
294 Broadcaster,
310
311 ChainIndex,
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326enum SourceKind {
327 Arcade,
329 ClassicArc,
331 ChainIndex,
333 BitailsIndex,
338}
339
340#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
358struct AbsenceVotes {
359 broadcaster: bool,
361 chain_index: bool,
363}
364
365impl AbsenceVotes {
366 fn record(&mut self, authority: AbsenceAuthority) {
367 match authority {
368 AbsenceAuthority::Broadcaster => self.broadcaster = true,
369 AbsenceAuthority::ChainIndex => self.chain_index = true,
370 AbsenceAuthority::None => {}
372 }
373 }
374
375 fn is_definitive(self) -> bool {
377 self.broadcaster && self.chain_index
378 }
379}
380
381#[derive(Debug, Clone, PartialEq, Eq)]
387enum BroadcastPlane {
388 ArcadeV2 { base: String },
398 ClassicArc { base: String },
401}
402
403impl BroadcastPlane {
404 fn resolve(chain: Chain, arcade_mode: bool, arc_url: Option<String>) -> Self {
409 let arc_url = arc_url
410 .map(|s| s.trim().to_string())
411 .filter(|s| !s.is_empty());
412 if arcade_mode {
413 BroadcastPlane::ArcadeV2 {
414 base: normalize_base(&arc_url.unwrap_or_else(|| ARCADE_V2_MAINNET.to_string())),
415 }
416 } else {
417 BroadcastPlane::ClassicArc {
418 base: normalize_base(&arc_url.unwrap_or_else(|| taal_arc_url(chain).to_string())),
419 }
420 }
421 }
422
423 fn from_env(chain: Chain) -> Self {
424 Self::resolve(
425 chain,
426 crate::services_env::arcade_mode_enabled(),
427 std::env::var("ARC_URL").ok(),
428 )
429 }
430
431 fn base(&self) -> &str {
432 match self {
433 BroadcastPlane::ArcadeV2 { base } | BroadcastPlane::ClassicArc { base } => base,
434 }
435 }
436
437 fn name(&self) -> &'static str {
438 match self {
439 BroadcastPlane::ArcadeV2 { .. } => "broadcaster(arcade-v2)",
440 BroadcastPlane::ClassicArc { .. } => "broadcaster(arc)",
441 }
442 }
443
444 fn kind(&self) -> SourceKind {
445 match self {
446 BroadcastPlane::ArcadeV2 { .. } => SourceKind::Arcade,
447 BroadcastPlane::ClassicArc { .. } => SourceKind::ClassicArc,
448 }
449 }
450
451 fn status_template(&self) -> String {
453 match self {
454 BroadcastPlane::ArcadeV2 { base } => format!("{base}/tx/{{txid}}"),
456 BroadcastPlane::ClassicArc { base } => format!("{base}/v1/tx/{{txid}}"),
458 }
459 }
460}
461
462#[derive(Clone, Debug)]
464struct StatusSource {
465 name: &'static str,
467 url_template: String,
469 auth: Option<String>,
471 absence: AbsenceAuthority,
473 kind: SourceKind,
475}
476
477fn build_sources(
483 chain: Chain,
484 plane: &BroadcastPlane,
485 taal_key: Option<String>,
486) -> Vec<StatusSource> {
487 let mut sources = vec![StatusSource {
488 name: plane.name(),
489 url_template: plane.status_template(),
490 auth: match plane {
492 BroadcastPlane::ArcadeV2 { .. } => None,
493 BroadcastPlane::ClassicArc { .. } => taal_key.clone(),
494 },
495 absence: AbsenceAuthority::Broadcaster,
496 kind: plane.kind(),
497 }];
498
499 sources.push(StatusSource {
511 name: "whatsonchain",
512 url_template: format!("{}/tx/hash/{{txid}}", woc_base(chain)),
513 auth: None,
514 absence: AbsenceAuthority::ChainIndex,
515 kind: SourceKind::ChainIndex,
516 });
517 sources.push(StatusSource {
522 name: "bitails",
523 url_template: format!("{}/tx/{{txid}}", bitails_base(chain)),
524 auth: None,
525 absence: AbsenceAuthority::ChainIndex,
526 kind: SourceKind::BitailsIndex,
527 });
528
529 if let Some(gp) = gorillapool_arc_url(chain) {
533 if normalize_base(gp) != plane.base() {
534 sources.push(StatusSource {
535 name: "arc-gorillapool",
536 url_template: format!("{gp}/v1/tx/{{txid}}"),
537 auth: None,
538 absence: AbsenceAuthority::None,
539 kind: SourceKind::ClassicArc,
540 });
541 }
542 }
543 if let Some(key) = taal_key {
546 let taal = taal_arc_url(chain);
547 if normalize_base(taal) != plane.base() {
548 sources.push(StatusSource {
549 name: "arc-taal",
550 url_template: format!("{taal}/v1/tx/{{txid}}"),
551 auth: Some(key),
552 absence: AbsenceAuthority::None,
553 kind: SourceKind::ClassicArc,
554 });
555 }
556 }
557
558 sources
559}
560
561#[derive(Clone)]
566pub struct BroadcastVerifier {
567 client: Client,
568 sources: Vec<StatusSource>,
569 attempts: u32,
570 delay: Duration,
571 enabled: bool,
573 rotation: Arc<AtomicUsize>,
577}
578
579impl BroadcastVerifier {
580 pub fn from_env(chain: Chain) -> Self {
588 let enabled = !env_truthy("BSV_WALLET_SKIP_BROADCAST_VERIFY");
589 let attempts = std::env::var("BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS")
590 .ok()
591 .and_then(|v| v.parse::<u32>().ok())
592 .filter(|n| *n > 0)
593 .unwrap_or(DEFAULT_ATTEMPTS);
594 let delay_ms = std::env::var("BSV_WALLET_BROADCAST_VERIFY_DELAY_MS")
595 .ok()
596 .and_then(|v| v.parse::<u64>().ok())
597 .unwrap_or(DEFAULT_DELAY_MS);
598
599 let taal_key = std::env::var("TAAL_API_KEY")
601 .ok()
602 .filter(|k| !k.is_empty())
603 .or_else(|| {
604 std::env::var("MAIN_TAAL_API_KEY")
605 .ok()
606 .filter(|k| !k.is_empty())
607 });
608
609 let plane = BroadcastPlane::from_env(chain);
610 tracing::debug!(plane = ?plane, "broadcast verifier plane");
611
612 Self {
613 client: Client::new(),
614 sources: build_sources(chain, &plane, taal_key),
615 attempts,
616 delay: Duration::from_millis(delay_ms),
617 enabled,
618 rotation: Arc::default(),
619 }
620 }
621
622 pub fn single_pass(chain: Chain) -> Self {
636 let mut v = Self::from_env(chain);
637 v.attempts = 1;
638 v.delay = Duration::ZERO;
639 v
640 }
641
642 fn absence_window(&self) -> Duration {
643 (1..self.attempts)
644 .map(|round| self.delay_before_round(round))
645 .sum::<Duration>()
646 + PROBE_TIMEOUT
647 }
648
649 fn delay_before_round(&self, round: u32) -> Duration {
655 let exponent = round.saturating_sub(1).min(16);
656 let grown = Duration::from_millis(INITIAL_DELAY_MS.saturating_mul(1u64 << exponent));
657 grown.min(self.delay)
658 }
659
660 pub async fn verify(&self, txid: &str) -> BroadcastVerification {
663 self.verify_report(txid).await.verification
664 }
665
666 pub async fn verify_report(&self, txid: &str) -> PresenceReport {
678 let mut report = PresenceReport::from_verification(BroadcastVerification::Inconclusive);
679 if !self.enabled || self.sources.is_empty() {
680 return report;
681 }
682
683 let deadline = Instant::now() + self.absence_window();
684 let mut last: Option<RoundResult> = None;
688
689 for attempt in 0..self.attempts {
690 let mut round = RoundResult::default();
691 let mut chain_indexes_asked = false;
692 for src in &self.sources {
693 if src.absence == AbsenceAuthority::ChainIndex {
694 if chain_indexes_asked {
697 continue;
698 }
699 chain_indexes_asked = true;
700 match self.ask_chain_indexes(txid).await {
701 ChainIndexAnswer::Present(evidence) => {
702 report.verification = BroadcastVerification::Confirmed;
704 report.evidence = Some(evidence);
705 report.evidence_provider = BROADCAST_PROVIDER_CHAIN;
706 report.chain_index = ChainIndexAnswer::Present(evidence);
707 return report;
708 }
709 ChainIndexAnswer::Absent => round.votes.record(src.absence),
710 ChainIndexAnswer::Unknown => {}
711 }
712 continue;
713 }
714 match probe(&self.client, src, txid).await {
715 Presence::Present(evidence) => {
716 if src.absence == AbsenceAuthority::Broadcaster {
717 round.broadcaster_answered = true;
718 let provider = if src.kind == SourceKind::Arcade {
719 PROVIDER_ARCADE_V2
720 } else {
721 BROADCAST_PROVIDER_NETWORK
722 };
723 round.broadcaster_evidence = Some((evidence, provider));
724 } else {
725 round.third_party_evidence = Some(evidence);
728 }
729 }
730 Presence::Held => {
731 round.held = true;
732 if src.absence == AbsenceAuthority::Broadcaster {
733 round.broadcaster_answered = true;
734 }
735 }
736 Presence::Fatal => {
737 round.fatal = true;
738 round.broadcaster_answered = true;
739 round.votes.record(src.absence);
740 }
741 Presence::Absent => {
742 if src.absence == AbsenceAuthority::Broadcaster {
743 round.broadcaster_answered = true;
744 }
745 round.votes.record(src.absence);
746 }
747 Presence::Unknown => {}
748 }
749 }
750 let settled = round.held
751 || round.broadcaster_evidence.is_some()
752 || round.third_party_evidence.is_some();
753 last = Some(round);
754
755 if settled {
759 break;
760 }
761
762 if attempt + 1 < self.attempts {
763 if Instant::now() >= deadline {
764 break;
767 }
768 tokio::time::sleep(self.delay_before_round(attempt + 1)).await;
769 }
770 }
771
772 if let Some(round) = last {
773 report.broadcaster_fatal = round.fatal;
774 report.chain_index = if round.votes.chain_index {
775 ChainIndexAnswer::Absent
776 } else {
777 ChainIndexAnswer::Unknown
778 };
779 if let Some(evidence) = round.third_party_evidence {
782 report.evidence = Some(evidence);
783 report.evidence_provider = BROADCAST_PROVIDER_NETWORK;
784 } else if let Some((evidence, provider)) = round.broadcaster_evidence {
785 report.evidence = Some(evidence);
786 report.evidence_provider = provider;
787 }
788 report.network_absent = round.votes.chain_index
789 && round.broadcaster_answered
790 && round.third_party_evidence.is_none();
791 let held = round.held
792 || round.broadcaster_evidence.is_some()
793 || round.third_party_evidence.is_some();
794 report.verification = if held {
795 BroadcastVerification::Confirmed
796 } else if round.votes.is_definitive() {
797 BroadcastVerification::Rejected
798 } else {
799 BroadcastVerification::Inconclusive
800 };
801 }
802 report
803 }
804
805 #[allow(dead_code)]
812 pub fn explicit(arcade: bool, broadcaster_base: &str, chain_index_base: Option<&str>) -> Self {
813 let plane = if arcade {
814 BroadcastPlane::ArcadeV2 {
815 base: normalize_base(broadcaster_base),
816 }
817 } else {
818 BroadcastPlane::ClassicArc {
819 base: normalize_base(broadcaster_base),
820 }
821 };
822 let mut sources = vec![StatusSource {
823 name: plane.name(),
824 url_template: plane.status_template(),
825 auth: None,
826 absence: AbsenceAuthority::Broadcaster,
827 kind: plane.kind(),
828 }];
829 if let Some(base) = chain_index_base {
830 sources.push(StatusSource {
833 name: "chain-index",
834 url_template: format!("{}/tx/hash/{{txid}}", normalize_base(base)),
835 auth: None,
836 absence: AbsenceAuthority::ChainIndex,
837 kind: SourceKind::ChainIndex,
838 });
839 }
840 Self {
841 client: Client::new(),
842 sources,
843 attempts: 1,
844 delay: Duration::ZERO,
845 enabled: true,
846 rotation: Arc::default(),
847 }
848 }
849
850 async fn ask_chain_indexes(&self, txid: &str) -> ChainIndexAnswer {
859 let indexes: Vec<&StatusSource> = self
860 .sources
861 .iter()
862 .filter(|s| s.absence == AbsenceAuthority::ChainIndex)
863 .collect();
864 if indexes.is_empty() {
865 return ChainIndexAnswer::Unknown;
866 }
867 let start = self.rotation.fetch_add(1, Ordering::Relaxed) % indexes.len();
868 let mut absent = 0;
869 for offset in 0..indexes.len() {
870 let src = indexes[(start + offset) % indexes.len()];
871 match probe(&self.client, src, txid).await {
872 Presence::Present(evidence) => return ChainIndexAnswer::Present(evidence),
873 Presence::Absent => absent += 1,
874 Presence::Held | Presence::Fatal | Presence::Unknown => {}
875 }
876 }
877 if absent == indexes.len() {
878 ChainIndexAnswer::Absent
879 } else {
880 ChainIndexAnswer::Unknown
881 }
882 }
883}
884
885#[derive(Debug, Clone, Copy, Default)]
887struct RoundResult {
888 votes: AbsenceVotes,
889 held: bool,
891 broadcaster_answered: bool,
894 fatal: bool,
896 broadcaster_evidence: Option<(NetworkEvidence, &'static str)>,
899 third_party_evidence: Option<NetworkEvidence>,
901}
902
903fn presence_of_body(src: &StatusSource, body: &str) -> Presence {
905 let json: Option<serde_json::Value> = serde_json::from_str(body).ok();
906 match src.kind {
907 SourceKind::ChainIndex => {
908 let confirmations = json
909 .as_ref()
910 .and_then(|v| v.get("confirmations"))
911 .and_then(|c| c.as_i64())
912 .unwrap_or(0);
913 if confirmations >= 1 {
914 Presence::Present(NetworkEvidence::Mined)
915 } else {
916 Presence::Present(NetworkEvidence::Seen)
917 }
918 }
919 SourceKind::BitailsIndex => {
920 let mined = json
921 .as_ref()
922 .and_then(|v| v.get("blockHeight"))
923 .is_some_and(|h| h.as_u64().is_some());
924 if mined {
925 Presence::Present(NetworkEvidence::Mined)
926 } else {
927 Presence::Present(NetworkEvidence::Seen)
928 }
929 }
930 SourceKind::Arcade | SourceKind::ClassicArc => {
931 let Some(tx_status) = json
932 .as_ref()
933 .and_then(|v| v.get("txStatus"))
934 .and_then(|s| s.as_str())
935 else {
936 return Presence::Held;
937 };
938 let status = match src.kind {
939 SourceKind::Arcade => BroadcastStatus::from_arcade_status(tx_status),
940 _ => BroadcastStatus::from_arc_status(tx_status),
941 };
942 match status {
943 BroadcastStatus::Seen => Presence::Present(NetworkEvidence::Seen),
944 BroadcastStatus::Mined => Presence::Present(NetworkEvidence::Mined),
945 BroadcastStatus::Rejected => {
946 if src.absence == AbsenceAuthority::Broadcaster {
947 Presence::Fatal
948 } else {
949 Presence::Unknown
952 }
953 }
954 BroadcastStatus::Accepted | BroadcastStatus::Unknown => Presence::Held,
955 }
956 }
957 }
958}
959
960async fn probe(client: &Client, src: &StatusSource, txid: &str) -> Presence {
962 let url = src.url_template.replace("{txid}", txid);
963 let mut req = client.get(&url).timeout(PROBE_TIMEOUT);
964 if let Some(auth) = &src.auth {
965 req = req.header("Authorization", auth);
966 }
967 match req.send().await {
968 Ok(resp) => {
969 let status = resp.status().as_u16();
970 match status {
971 200 => {
972 let body = resp.text().await.unwrap_or_default();
973 let presence = presence_of_body(src, &body);
974 tracing::debug!(source = src.name, ?presence, "broadcast probe");
975 presence
976 }
977 404 => {
978 if src.absence == AbsenceAuthority::Broadcaster && !is_json(&resp) {
987 tracing::debug!(
988 source = src.name,
989 url = %url,
990 "broadcaster 404 is not a JSON tx-status body — treating as \
991 route-not-found (check ARC_URL / path shape), not absence"
992 );
993 return Presence::Unknown;
994 }
995 Presence::Absent
996 }
997 other => {
998 tracing::debug!(
999 source = src.name,
1000 status = other,
1001 "broadcast probe inconclusive"
1002 );
1003 Presence::Unknown
1004 }
1005 }
1006 }
1007 Err(e) => {
1008 tracing::debug!(source = src.name, error = %e, "broadcast probe request failed");
1009 Presence::Unknown
1010 }
1011 }
1012}
1013
1014fn is_json(resp: &reqwest::Response) -> bool {
1017 resp.headers()
1018 .get(reqwest::header::CONTENT_TYPE)
1019 .and_then(|v| v.to_str().ok())
1020 .map(|ct| ct.to_ascii_lowercase().contains("json"))
1021 .unwrap_or(false)
1022}
1023
1024fn normalize_base(url: &str) -> String {
1025 url.trim().trim_end_matches('/').to_string()
1026}
1027
1028fn taal_arc_url(chain: Chain) -> &'static str {
1029 match chain {
1030 Chain::Main => "https://arc.taal.com",
1031 Chain::Test => "https://arc-test.taal.com",
1032 }
1033}
1034
1035fn gorillapool_arc_url(chain: Chain) -> Option<&'static str> {
1036 match chain {
1037 Chain::Main => Some("https://arc.gorillapool.io"),
1038 Chain::Test => None,
1040 }
1041}
1042
1043fn woc_base(chain: Chain) -> &'static str {
1044 match chain {
1045 Chain::Main => "https://api.whatsonchain.com/v1/bsv/main",
1046 Chain::Test => "https://api.whatsonchain.com/v1/bsv/test",
1047 }
1048}
1049
1050fn bitails_base(chain: Chain) -> &'static str {
1053 match chain {
1054 Chain::Main => "https://api.bitails.io",
1055 Chain::Test => "https://test-api.bitails.io",
1056 }
1057}
1058
1059fn env_truthy(key: &str) -> bool {
1060 std::env::var(key)
1061 .map(|v| {
1062 let v = v.trim().to_ascii_lowercase();
1063 v == "1" || v == "true" || v == "yes" || v == "on"
1064 })
1065 .unwrap_or(false)
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071 use axum::http::StatusCode;
1072 use axum::routing::get;
1073 use axum::Router;
1074 use std::net::SocketAddr;
1075
1076 const TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001";
1078 const SYNTHETIC_ARCADE: &str = "https://arcade.invalid";
1079 const SYNTHETIC_ARC: &str = "https://arc.invalid";
1080 const SYNTHETIC_KEY: &str = "test-key-not-a-real-credential";
1081
1082 #[tokio::test]
1090 async fn single_pass_is_one_attempt_and_disabled_means_inconclusive() {
1091 let v = BroadcastVerifier::single_pass(Chain::Main);
1092 assert_eq!(v.attempts, 1);
1093 assert_eq!(v.delay, Duration::ZERO);
1094 let off = BroadcastVerifier {
1095 enabled: false,
1096 ..v
1097 };
1098 assert_eq!(
1099 off.verify(&"cd".repeat(32)).await,
1100 BroadcastVerification::Inconclusive
1101 );
1102 }
1103
1104 #[test]
1105 fn arcade_plane_uses_bare_tx_path_not_v1() {
1106 let plane = BroadcastPlane::resolve(
1110 Chain::Main,
1111 true,
1112 Some(SYNTHETIC_ARCADE.to_string()),
1113 );
1114 assert_eq!(
1115 plane.status_template(),
1116 format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
1117 );
1118 assert!(
1119 !plane.status_template().contains("/v1/"),
1120 "Arcade V2 must NOT be probed on the classic ARC /v1 path"
1121 );
1122 assert_eq!(plane.kind(), SourceKind::Arcade);
1123 }
1124
1125 #[test]
1126 fn classic_arc_plane_uses_v1_tx_path() {
1127 let plane = BroadcastPlane::resolve(
1128 Chain::Main,
1129 false,
1130 Some(SYNTHETIC_ARC.to_string()),
1131 );
1132 assert_eq!(
1133 plane.status_template(),
1134 format!("{SYNTHETIC_ARC}/v1/tx/{{txid}}")
1135 );
1136 assert_eq!(plane.kind(), SourceKind::ClassicArc);
1137 }
1138
1139 #[test]
1140 fn arcade_mode_defaults_to_the_arcade_endpoint_when_arc_url_is_unset() {
1141 let plane = BroadcastPlane::resolve(Chain::Main, true, None);
1142 assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
1143 }
1144
1145 #[test]
1146 fn classic_mode_defaults_to_taal_and_respects_chain() {
1147 assert_eq!(
1148 BroadcastPlane::resolve(Chain::Main, false, None).base(),
1149 "https://arc.taal.com"
1150 );
1151 assert_eq!(
1152 BroadcastPlane::resolve(Chain::Test, false, None).base(),
1153 "https://arc-test.taal.com"
1154 );
1155 }
1156
1157 #[test]
1158 fn empty_arc_url_falls_back_to_the_default_rather_than_an_empty_base() {
1159 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(" ".to_string()));
1160 assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
1161 }
1162
1163 #[test]
1164 fn trailing_slash_in_arc_url_does_not_produce_a_double_slash() {
1165 let plane = BroadcastPlane::resolve(
1166 Chain::Main,
1167 true,
1168 Some(format!("{SYNTHETIC_ARCADE}/").to_string()),
1169 );
1170 assert_eq!(
1171 plane.status_template(),
1172 format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
1173 );
1174 }
1175
1176 #[test]
1177 fn the_broadcaster_we_used_is_always_the_first_source_consulted() {
1178 for plane in [
1181 BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string())),
1182 BroadcastPlane::resolve(Chain::Main, false, Some(SYNTHETIC_ARC.to_string())),
1183 ] {
1184 let sources = build_sources(Chain::Main, &plane, None);
1185 assert_eq!(sources[0].absence, AbsenceAuthority::Broadcaster);
1186 assert_eq!(sources[0].kind, plane.kind());
1187 assert!(
1188 sources[0].url_template.starts_with(plane.base()),
1189 "source 0 ({}) must be the configured broadcaster {}",
1190 sources[0].url_template,
1191 plane.base()
1192 );
1193 }
1194 }
1195
1196 #[test]
1197 fn arcade_broadcaster_probe_is_keyless_even_when_a_taal_key_exists() {
1198 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
1199 let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
1200 assert!(sources[0].auth.is_none());
1201 }
1202
1203 #[test]
1204 fn classic_broadcaster_probe_carries_the_taal_key_when_present() {
1205 let plane = BroadcastPlane::resolve(Chain::Main, false, None);
1206 let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
1207 assert_eq!(sources[0].auth.as_deref(), Some(SYNTHETIC_KEY));
1208 }
1209
1210 #[test]
1211 fn keyless_taal_is_not_probed_at_all() {
1212 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
1214 let sources = build_sources(Chain::Main, &plane, None);
1215 assert!(!sources.iter().any(|s| s.name == "arc-taal"));
1216 }
1217
1218 #[test]
1219 fn a_store_is_never_listed_twice_when_it_is_also_the_broadcaster() {
1220 let plane = BroadcastPlane::resolve(
1223 Chain::Main,
1224 false,
1225 Some("https://arc.gorillapool.io".to_string()),
1226 );
1227 let sources = build_sources(Chain::Main, &plane, None);
1228 let gp_rows: Vec<_> = sources
1229 .iter()
1230 .filter(|s| s.url_template.contains("arc.gorillapool.io"))
1231 .collect();
1232 assert_eq!(gp_rows.len(), 1);
1233 assert_eq!(gp_rows[0].absence, AbsenceAuthority::Broadcaster);
1234 }
1235
1236 #[test]
1241 fn a_third_party_arc_store_is_never_authoritative_for_absence() {
1242 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
1247 let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
1248 for s in sources.iter().filter(|s| s.name.starts_with("arc-")) {
1249 assert_eq!(
1250 s.absence,
1251 AbsenceAuthority::None,
1252 "{} is not the broadcaster; its absence must carry no weight",
1253 s.name
1254 );
1255 }
1256 }
1257
1258 #[test]
1259 fn whatsonchain_is_the_chain_index_authority() {
1260 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
1261 let sources = build_sources(Chain::Main, &plane, None);
1262 let woc = sources.iter().find(|s| s.name == "whatsonchain").unwrap();
1263 assert_eq!(woc.absence, AbsenceAuthority::ChainIndex);
1264 assert_eq!(woc.kind, SourceKind::ChainIndex);
1265 }
1266
1267 #[test]
1268 fn absence_is_definitive_only_when_broadcaster_and_chain_index_agree() {
1269 let mut none = AbsenceVotes::default();
1270 assert!(!none.is_definitive(), "no votes is not evidence");
1271
1272 none.record(AbsenceAuthority::None);
1274 assert!(!none.is_definitive());
1275
1276 let mut broadcaster_only = AbsenceVotes::default();
1277 broadcaster_only.record(AbsenceAuthority::Broadcaster);
1278 assert!(
1279 !broadcaster_only.is_definitive(),
1280 "the primary may 404 while the tx went out through the failover provider"
1281 );
1282
1283 let mut index_only = AbsenceVotes::default();
1284 index_only.record(AbsenceAuthority::ChainIndex);
1285 assert!(
1286 !index_only.is_definitive(),
1287 "a chain index can simply be lagging its mempool ingestion"
1288 );
1289
1290 let mut both = AbsenceVotes::default();
1291 both.record(AbsenceAuthority::Broadcaster);
1292 both.record(AbsenceAuthority::ChainIndex);
1293 assert!(both.is_definitive());
1294 }
1295
1296 fn src_of(kind: SourceKind, absence: AbsenceAuthority) -> StatusSource {
1301 StatusSource {
1302 name: "test",
1303 url_template: "http://127.0.0.1:1/tx/{txid}".to_string(),
1304 auth: None,
1305 absence,
1306 kind,
1307 }
1308 }
1309
1310 #[test]
1311 fn a_200_body_is_read_by_source_kind() {
1312 let arcade = src_of(SourceKind::Arcade, AbsenceAuthority::Broadcaster);
1313 assert_eq!(
1314 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"RECEIVED"}"#),
1315 Presence::Held,
1316 "a pre-gate status is held, not network evidence"
1317 );
1318 assert_eq!(
1319 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"ACCEPTED_BY_NETWORK"}"#),
1320 Presence::Held
1321 );
1322 assert_eq!(
1323 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#),
1324 Presence::Present(NetworkEvidence::Seen)
1325 );
1326 assert_eq!(
1327 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"MINED"}"#),
1328 Presence::Present(NetworkEvidence::Mined)
1329 );
1330 assert_eq!(
1331 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"REJECTED"}"#),
1332 Presence::Fatal
1333 );
1334 assert_eq!(
1335 presence_of_body(&arcade, "{}"),
1336 Presence::Held,
1337 "a 200 without a readable status still means the store holds it"
1338 );
1339 assert_eq!(presence_of_body(&arcade, "not json"), Presence::Held);
1340
1341 let arc = src_of(SourceKind::ClassicArc, AbsenceAuthority::None);
1342 assert_eq!(
1343 presence_of_body(&arc, r#"{"txStatus":"SEEN_IN_ORPHAN_MEMPOOL"}"#),
1344 Presence::Held,
1345 "an orphan-pool hit is held: the node lacks the parent"
1346 );
1347 assert_eq!(
1348 presence_of_body(&arc, r#"{"txStatus":"SEEN_ON_NETWORK"}"#),
1349 Presence::Present(NetworkEvidence::Seen)
1350 );
1351 assert_eq!(
1352 presence_of_body(&arc, r#"{"txStatus":"REJECTED"}"#),
1353 Presence::Unknown,
1354 "a third-party rejection of somebody's copy is no vote"
1355 );
1356
1357 let woc = src_of(SourceKind::ChainIndex, AbsenceAuthority::ChainIndex);
1358 assert_eq!(
1359 presence_of_body(&woc, r#"{"txid":"x","confirmations":0}"#),
1360 Presence::Present(NetworkEvidence::Seen)
1361 );
1362 assert_eq!(
1363 presence_of_body(&woc, r#"{"txid":"x","confirmations":3}"#),
1364 Presence::Present(NetworkEvidence::Mined)
1365 );
1366 assert_eq!(
1367 presence_of_body(&woc, r#"{"txid":"x"}"#),
1368 Presence::Present(NetworkEvidence::Seen)
1369 );
1370 }
1371
1372 async fn mock_status_server(code: StatusCode) -> String {
1379 mock_status_server_full(code, Some("application/json"), "{}").await
1380 }
1381
1382 async fn mock_status_server_ct(code: StatusCode, content_type: Option<&'static str>) -> String {
1384 mock_status_server_full(code, content_type, "{}").await
1385 }
1386
1387 async fn mock_status_server_body(body: &'static str) -> String {
1389 mock_status_server_full(StatusCode::OK, Some("application/json"), body).await
1390 }
1391
1392 async fn mock_status_server_full(
1393 code: StatusCode,
1394 content_type: Option<&'static str>,
1395 body: &'static str,
1396 ) -> String {
1397 let handler = move || async move {
1398 let mut resp = axum::response::Response::new(axum::body::Body::from(body));
1399 *resp.status_mut() = code;
1400 if let Some(ct) = content_type {
1401 resp.headers_mut()
1402 .insert(reqwest::header::CONTENT_TYPE.as_str(), ct.parse().unwrap());
1403 } else {
1404 resp.headers_mut()
1405 .remove(reqwest::header::CONTENT_TYPE.as_str());
1406 }
1407 resp
1408 };
1409 let app = Router::new()
1410 .route("/tx/{txid}", get(handler))
1411 .route("/v1/tx/{txid}", get(handler))
1412 .route("/tx/hash/{txid}", get(handler));
1413 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1414 let addr: SocketAddr = listener.local_addr().unwrap();
1415 tokio::spawn(async move {
1416 axum::serve(listener, app).await.ok();
1417 });
1418 format!("http://{}", addr)
1419 }
1420
1421 fn source(name: &'static str, base: &str, absence: AbsenceAuthority) -> StatusSource {
1422 source_kind(name, base, absence, SourceKind::ClassicArc)
1423 }
1424
1425 fn source_kind(
1426 name: &'static str,
1427 base: &str,
1428 absence: AbsenceAuthority,
1429 kind: SourceKind,
1430 ) -> StatusSource {
1431 StatusSource {
1432 name,
1433 url_template: format!("{base}/tx/{{txid}}"),
1434 auth: None,
1435 absence,
1436 kind,
1437 }
1438 }
1439
1440 fn verifier_with(sources: Vec<StatusSource>) -> BroadcastVerifier {
1442 BroadcastVerifier {
1443 client: Client::new(),
1444 sources,
1445 attempts: 2,
1446 delay: Duration::from_millis(0),
1447 enabled: true,
1448 rotation: Arc::default(),
1449 }
1450 }
1451
1452 #[tokio::test]
1453 async fn rejected_when_broadcaster_and_chain_index_both_report_absent() {
1454 let base = mock_status_server(StatusCode::NOT_FOUND).await;
1458 let verifier = verifier_with(vec![
1459 source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1460 source("chain-index", &base, AbsenceAuthority::ChainIndex),
1461 ]);
1462
1463 let report = verifier.verify_report(TXID).await;
1464 assert_eq!(report.verification, BroadcastVerification::Rejected);
1465 assert!(report.network_absent);
1466 assert!(!report.broadcaster_fatal);
1467 assert_eq!(report.evidence, None);
1468 assert!(
1469 report.verification.into_send_result(TXID).is_err(),
1470 "a Rejected verification must map to Err so the send fails loudly"
1471 );
1472 }
1473
1474 #[tokio::test]
1475 async fn the_false_negative_that_motivated_this_fix_is_now_inconclusive() {
1476 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1482 let verifier = verifier_with(vec![
1483 source(
1485 "broadcaster",
1486 "http://127.0.0.1:1",
1487 AbsenceAuthority::Broadcaster,
1488 ),
1489 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1490 source("arc-third-party", &absent, AbsenceAuthority::None),
1491 ]);
1492 let report = verifier.verify_report(TXID).await;
1493 assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1494 assert!(
1495 !report.network_absent,
1496 "the absence clock does not run while the broadcaster is unreachable"
1497 );
1498 }
1499
1500 #[tokio::test]
1501 async fn third_party_absence_alone_never_rejects() {
1502 let base = mock_status_server(StatusCode::NOT_FOUND).await;
1503 let verifier = verifier_with(vec![
1504 source("arc-third-party-a", &base, AbsenceAuthority::None),
1505 source("arc-third-party-b", &base, AbsenceAuthority::None),
1506 ]);
1507 assert_eq!(
1508 verifier.verify(TXID).await,
1509 BroadcastVerification::Inconclusive
1510 );
1511 }
1512
1513 #[tokio::test]
1514 async fn broadcaster_absence_alone_never_rejects() {
1515 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1518 let verifier = verifier_with(vec![
1519 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1520 source(
1522 "chain-index",
1523 "http://127.0.0.1:1",
1524 AbsenceAuthority::ChainIndex,
1525 ),
1526 ]);
1527 assert_eq!(
1528 verifier.verify(TXID).await,
1529 BroadcastVerification::Inconclusive
1530 );
1531 }
1532
1533 #[tokio::test]
1534 async fn chain_index_absence_alone_never_rejects() {
1535 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1536 let verifier = verifier_with(vec![
1537 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1539 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1540 ]);
1541 assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
1543
1544 let unauth = mock_status_server(StatusCode::UNAUTHORIZED).await;
1546 let verifier = verifier_with(vec![
1547 source("broadcaster", &unauth, AbsenceAuthority::Broadcaster),
1548 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1549 ]);
1550 assert_eq!(
1551 verifier.verify(TXID).await,
1552 BroadcastVerification::Inconclusive
1553 );
1554 }
1555
1556 #[tokio::test]
1557 async fn presence_from_any_source_confirms_even_when_others_say_absent() {
1558 let present = mock_status_server(StatusCode::OK).await;
1560 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1561 let verifier = verifier_with(vec![
1562 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1563 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1564 source("arc-third-party", &present, AbsenceAuthority::None),
1565 ]);
1566 let outcome = verifier.verify(TXID).await;
1567 assert_eq!(outcome, BroadcastVerification::Confirmed);
1568 assert!(outcome.into_send_result(TXID).is_ok());
1569 }
1570
1571 #[tokio::test]
1572 async fn confirmed_broadcast_succeeds() {
1573 let base = mock_status_server(StatusCode::OK).await;
1574 let verifier = verifier_with(vec![source(
1575 "broadcaster",
1576 &base,
1577 AbsenceAuthority::Broadcaster,
1578 )]);
1579 let outcome = verifier.verify(TXID).await;
1580 assert_eq!(outcome, BroadcastVerification::Confirmed);
1581 assert!(outcome.into_send_result(TXID).is_ok());
1582 }
1583
1584 #[tokio::test]
1585 async fn unreachable_source_is_inconclusive_not_a_failure() {
1586 let base = mock_status_server(StatusCode::SERVICE_UNAVAILABLE).await;
1589 let verifier = verifier_with(vec![
1590 source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1591 source("chain-index", &base, AbsenceAuthority::ChainIndex),
1592 ]);
1593 let outcome = verifier.verify(TXID).await;
1594 assert_eq!(outcome, BroadcastVerification::Inconclusive);
1595 assert!(outcome.into_send_result(TXID).is_ok());
1596 }
1597
1598 #[tokio::test]
1599 async fn a_routing_404_from_the_broadcaster_is_not_absence() {
1600 let text_404 = mock_status_server_ct(StatusCode::NOT_FOUND, Some("text/plain")).await;
1603 let json_404 = mock_status_server(StatusCode::NOT_FOUND).await;
1604 let verifier = verifier_with(vec![
1605 source("broadcaster", &text_404, AbsenceAuthority::Broadcaster),
1606 source("chain-index", &json_404, AbsenceAuthority::ChainIndex),
1607 ]);
1608 assert_eq!(
1609 verifier.verify(TXID).await,
1610 BroadcastVerification::Inconclusive
1611 );
1612 }
1613
1614 #[tokio::test]
1615 async fn disabled_verifier_is_inconclusive() {
1616 let base = mock_status_server(StatusCode::NOT_FOUND).await;
1617 let mut verifier = verifier_with(vec![
1618 source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1619 source("chain-index", &base, AbsenceAuthority::ChainIndex),
1620 ]);
1621 verifier.enabled = false;
1622 assert_eq!(
1623 verifier.verify(TXID).await,
1624 BroadcastVerification::Inconclusive
1625 );
1626 }
1627
1628 #[tokio::test]
1631 async fn seen_on_network_from_the_arcade_plane_is_network_evidence_for_arcade() {
1632 let seen = mock_status_server_body(r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#).await;
1633 let verifier = verifier_with(vec![source_kind(
1634 "broadcaster",
1635 &seen,
1636 AbsenceAuthority::Broadcaster,
1637 SourceKind::Arcade,
1638 )]);
1639 let report = verifier.verify_report(TXID).await;
1640 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1641 assert_eq!(report.evidence, Some(NetworkEvidence::Seen));
1642 assert_eq!(report.evidence_provider, PROVIDER_ARCADE_V2);
1643 assert_eq!(report.chain_index, ChainIndexAnswer::Unknown);
1644 assert!(!report.network_absent && !report.broadcaster_fatal);
1645 }
1646
1647 #[tokio::test]
1648 async fn a_broadcasters_seen_with_a_chain_index_miss_is_network_absent() {
1649 let seen =
1654 mock_status_server_body(r#"{"txid":"x","txStatus":"SEEN_MULTIPLE_NODES"}"#).await;
1655 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1656 let verifier = verifier_with(vec![
1657 source_kind(
1658 "broadcaster",
1659 &seen,
1660 AbsenceAuthority::Broadcaster,
1661 SourceKind::Arcade,
1662 ),
1663 source_kind(
1664 "chain-index",
1665 &absent,
1666 AbsenceAuthority::ChainIndex,
1667 SourceKind::ChainIndex,
1668 ),
1669 ]);
1670 let report = verifier.verify_report(TXID).await;
1671 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1672 assert_eq!(report.evidence, Some(NetworkEvidence::Seen));
1673 assert_eq!(report.evidence_provider, PROVIDER_ARCADE_V2);
1674 assert_eq!(report.chain_index, ChainIndexAnswer::Absent);
1675 assert!(
1676 report.network_absent,
1677 "the chain index was asked and said no"
1678 );
1679 assert!(!report.broadcaster_fatal);
1680
1681 let explicit = BroadcastVerifier::explicit(true, &seen, Some(&absent));
1683 assert_eq!(explicit.sources.len(), 2);
1684 assert_eq!(explicit.sources[0].kind, SourceKind::Arcade);
1685 assert_eq!(explicit.sources[1].kind, SourceKind::ChainIndex);
1686 let report = explicit.verify_report(TXID).await;
1687 assert!(report.network_absent);
1688 assert_eq!(report.chain_index, ChainIndexAnswer::Absent);
1689 }
1690
1691 #[tokio::test]
1692 async fn a_peer_nodes_seen_blocks_the_absence() {
1693 let held = mock_status_server_body(r#"{"txid":"x","txStatus":"RECEIVED"}"#).await;
1696 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1697 let peer = mock_status_server_body(r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#).await;
1698 let verifier = verifier_with(vec![
1699 source_kind(
1700 "broadcaster",
1701 &held,
1702 AbsenceAuthority::Broadcaster,
1703 SourceKind::Arcade,
1704 ),
1705 source_kind(
1706 "chain-index",
1707 &absent,
1708 AbsenceAuthority::ChainIndex,
1709 SourceKind::ChainIndex,
1710 ),
1711 source_kind(
1712 "arc-third-party",
1713 &peer,
1714 AbsenceAuthority::None,
1715 SourceKind::ClassicArc,
1716 ),
1717 ]);
1718 let report = verifier.verify_report(TXID).await;
1719 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1720 assert_eq!(report.evidence, Some(NetworkEvidence::Seen));
1721 assert_eq!(report.evidence_provider, BROADCAST_PROVIDER_NETWORK);
1722 assert_eq!(report.chain_index, ChainIndexAnswer::Absent);
1723 assert!(!report.network_absent);
1724 }
1725
1726 #[tokio::test]
1727 async fn a_pre_gate_status_is_held_only_and_the_absence_clock_runs() {
1728 let held = mock_status_server_body(r#"{"txid":"x","txStatus":"RECEIVED"}"#).await;
1732 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1733 let verifier = verifier_with(vec![
1734 source_kind(
1735 "broadcaster",
1736 &held,
1737 AbsenceAuthority::Broadcaster,
1738 SourceKind::Arcade,
1739 ),
1740 source_kind(
1741 "chain-index",
1742 &absent,
1743 AbsenceAuthority::ChainIndex,
1744 SourceKind::ChainIndex,
1745 ),
1746 ]);
1747 let report = verifier.verify_report(TXID).await;
1748 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1749 assert_eq!(report.evidence, None);
1750 assert!(report.network_absent);
1751 assert!(!report.broadcaster_fatal);
1752 }
1753
1754 #[tokio::test]
1755 async fn a_fatal_verdict_from_the_broadcaster_with_an_index_miss_is_rejected() {
1756 let fatal = mock_status_server_body(r#"{"txid":"x","txStatus":"REJECTED"}"#).await;
1757 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1758 let verifier = verifier_with(vec![
1759 source_kind(
1760 "broadcaster",
1761 &fatal,
1762 AbsenceAuthority::Broadcaster,
1763 SourceKind::Arcade,
1764 ),
1765 source_kind(
1766 "chain-index",
1767 &absent,
1768 AbsenceAuthority::ChainIndex,
1769 SourceKind::ChainIndex,
1770 ),
1771 ]);
1772 let report = verifier.verify_report(TXID).await;
1773 assert_eq!(report.verification, BroadcastVerification::Rejected);
1774 assert!(report.broadcaster_fatal);
1775 assert!(report.network_absent);
1776
1777 let verifier = verifier_with(vec![
1779 source_kind(
1780 "broadcaster",
1781 &fatal,
1782 AbsenceAuthority::Broadcaster,
1783 SourceKind::Arcade,
1784 ),
1785 source_kind(
1786 "chain-index",
1787 "http://127.0.0.1:1",
1788 AbsenceAuthority::ChainIndex,
1789 SourceKind::ChainIndex,
1790 ),
1791 ]);
1792 let report = verifier.verify_report(TXID).await;
1793 assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1794 assert!(report.broadcaster_fatal);
1795 assert!(!report.network_absent);
1796 }
1797
1798 #[tokio::test]
1799 async fn a_chain_index_hit_is_network_evidence_for_everyone() {
1800 let held = mock_status_server_body(r#"{"txid":"x","txStatus":"SENT_TO_NETWORK"}"#).await;
1801 let mined = mock_status_server_body(r#"{"txid":"x","confirmations":2}"#).await;
1802 let verifier = verifier_with(vec![
1803 source_kind(
1804 "broadcaster",
1805 &held,
1806 AbsenceAuthority::Broadcaster,
1807 SourceKind::Arcade,
1808 ),
1809 source_kind(
1810 "chain-index",
1811 &mined,
1812 AbsenceAuthority::ChainIndex,
1813 SourceKind::ChainIndex,
1814 ),
1815 ]);
1816 let report = verifier.verify_report(TXID).await;
1817 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1818 assert_eq!(report.evidence, Some(NetworkEvidence::Mined));
1819 assert_eq!(report.evidence_provider, BROADCAST_PROVIDER_CHAIN);
1820 assert_eq!(
1821 report.chain_index,
1822 ChainIndexAnswer::Present(NetworkEvidence::Mined)
1823 );
1824 assert!(!report.network_absent);
1825 }
1826
1827 #[tokio::test]
1828 async fn a_third_party_rejection_alone_is_inconclusive() {
1829 let fatal = mock_status_server_body(r#"{"txid":"x","txStatus":"REJECTED"}"#).await;
1830 let verifier = verifier_with(vec![source_kind(
1831 "arc-third-party",
1832 &fatal,
1833 AbsenceAuthority::None,
1834 SourceKind::ClassicArc,
1835 )]);
1836 let report = verifier.verify_report(TXID).await;
1837 assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1838 assert!(!report.broadcaster_fatal);
1839 }
1840
1841 #[test]
1842 fn absence_window_is_bounded_and_reflects_the_configured_rounds() {
1843 let v = BroadcastVerifier {
1844 client: Client::new(),
1845 sources: vec![],
1846 attempts: DEFAULT_ATTEMPTS,
1847 delay: Duration::from_millis(DEFAULT_DELAY_MS),
1848 enabled: true,
1849 rotation: Arc::default(),
1850 };
1851 assert_eq!(
1855 v.absence_window(),
1856 Duration::from_millis(26_250) + PROBE_TIMEOUT
1857 );
1858 }
1859
1860 #[test]
1861 fn probe_schedule_starts_short_grows_and_caps() {
1862 let v = BroadcastVerifier {
1866 client: Client::new(),
1867 sources: vec![],
1868 attempts: DEFAULT_ATTEMPTS,
1869 delay: Duration::from_millis(DEFAULT_DELAY_MS),
1870 enabled: true,
1871 rotation: Arc::default(),
1872 };
1873 let gaps: Vec<u64> = (1..v.attempts)
1874 .map(|r| v.delay_before_round(r).as_millis() as u64)
1875 .collect();
1876 assert_eq!(
1877 gaps,
1878 vec![250, 500, 1000, 2000, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500]
1879 );
1880 assert!(gaps.windows(2).all(|w| w[0] <= w[1]), "never shrinks");
1881 assert!(
1882 gaps.iter().all(|g| *g <= DEFAULT_DELAY_MS),
1883 "never exceeds the cap"
1884 );
1885
1886 let tight = BroadcastVerifier {
1888 delay: Duration::from_millis(100),
1889 ..v
1890 };
1891 assert!(
1892 (1..tight.attempts).all(|r| tight.delay_before_round(r) == Duration::from_millis(100))
1893 );
1894
1895 let one = BroadcastVerifier::single_pass(Chain::Main);
1897 assert_eq!(one.absence_window(), PROBE_TIMEOUT);
1898 }
1899
1900 #[tokio::test]
1901 async fn a_present_tx_is_confirmed_on_the_first_probe_without_waiting() {
1902 let present = mock_status_server(StatusCode::OK).await;
1906 let verifier = BroadcastVerifier {
1907 client: Client::new(),
1908 sources: vec![source(
1909 "broadcaster",
1910 &present,
1911 AbsenceAuthority::Broadcaster,
1912 )],
1913 attempts: DEFAULT_ATTEMPTS,
1914 delay: Duration::from_millis(DEFAULT_DELAY_MS),
1915 enabled: true,
1916 rotation: Arc::default(),
1917 };
1918 let started = std::time::Instant::now();
1919 assert_eq!(
1920 verifier.verify(TXID).await,
1921 BroadcastVerification::Confirmed
1922 );
1923 assert!(
1924 started.elapsed() < Duration::from_millis(INITIAL_DELAY_MS),
1925 "took {:?}",
1926 started.elapsed()
1927 );
1928 }
1929
1930 #[tokio::test]
1931 async fn an_absent_tx_is_retried_on_the_growing_schedule() {
1932 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1936 let verifier = BroadcastVerifier {
1937 client: Client::new(),
1938 sources: vec![
1939 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1940 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1941 ],
1942 attempts: 4,
1943 delay: Duration::from_millis(200),
1944 enabled: true,
1945 rotation: Arc::default(),
1946 };
1947 assert!((1..4).all(|r| verifier.delay_before_round(r) == Duration::from_millis(200)));
1949 let started = std::time::Instant::now();
1950 assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
1951 let elapsed = started.elapsed();
1952 assert!(
1953 elapsed >= Duration::from_millis(600) && elapsed < Duration::from_millis(2_000),
1954 "took {:?}",
1955 elapsed
1956 );
1957 }
1958 async fn chain_index_fixture(code: u16, body: &str) -> crate::test_support::Fixture {
1964 let route = format!("/tx/{TXID}");
1965 crate::test_support::Fixture::start(&[(&route, code, body)], 500).await
1966 }
1967
1968 fn chain_index(name: &'static str, base: &str) -> StatusSource {
1969 source_kind(
1970 name,
1971 base,
1972 AbsenceAuthority::ChainIndex,
1973 SourceKind::ChainIndex,
1974 )
1975 }
1976
1977 #[tokio::test]
1981 async fn one_chain_indexs_absence_is_not_absence_while_the_other_could_not_look() {
1982 let broadcaster = mock_status_server(StatusCode::NOT_FOUND).await;
1983 let absent = chain_index_fixture(404, "").await;
1984 let down = chain_index_fixture(500, "").await;
1985 let verifier = verifier_with(vec![
1986 source("broadcaster", &broadcaster, AbsenceAuthority::Broadcaster),
1987 chain_index("index-a", &absent.base),
1988 chain_index("index-b", &down.base),
1989 ]);
1990
1991 let report = verifier.verify_report(TXID).await;
1992 assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1993 assert_eq!(report.chain_index, ChainIndexAnswer::Unknown);
1994 assert!(!report.network_absent);
1995 }
1996
1997 #[tokio::test]
2001 async fn the_chain_index_start_rotates() {
2002 let broadcaster = mock_status_server(StatusCode::NOT_FOUND).await;
2003 let a = chain_index_fixture(200, r#"{"confirmations":0}"#).await;
2004 let b = chain_index_fixture(200, r#"{"confirmations":0}"#).await;
2005 let verifier = verifier_with(vec![
2006 source("broadcaster", &broadcaster, AbsenceAuthority::Broadcaster),
2007 chain_index("index-a", &a.base),
2008 chain_index("index-b", &b.base),
2009 ]);
2010
2011 for _ in 0..4 {
2012 let report = verifier.verify_report(TXID).await;
2013 assert_eq!(
2014 report.chain_index,
2015 ChainIndexAnswer::Present(NetworkEvidence::Seen)
2016 );
2017 }
2018 assert_eq!((a.total(), b.total()), (2, 2), "each asked in its turn");
2019 }
2020
2021 #[test]
2024 fn bitails_is_a_second_chain_index() {
2025 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
2026 let sources = build_sources(Chain::Main, &plane, None);
2027 let indexes: Vec<_> = sources
2028 .iter()
2029 .filter(|s| s.absence == AbsenceAuthority::ChainIndex)
2030 .map(|s| s.name)
2031 .collect();
2032 assert_eq!(indexes, vec!["whatsonchain", "bitails"]);
2033 }
2034
2035 #[tokio::test]
2038 async fn two_chain_indexes_absent_with_the_broadcaster_is_rejected() {
2039 let broadcaster = mock_status_server(StatusCode::NOT_FOUND).await;
2040 let a = chain_index_fixture(404, "").await;
2041 let b = chain_index_fixture(404, "").await;
2042 let verifier = verifier_with(vec![
2043 source("broadcaster", &broadcaster, AbsenceAuthority::Broadcaster),
2044 chain_index("index-a", &a.base),
2045 chain_index("index-b", &b.base),
2046 ]);
2047 let report = verifier.verify_report(TXID).await;
2048 assert_eq!(report.verification, BroadcastVerification::Rejected);
2049 assert_eq!(report.chain_index, ChainIndexAnswer::Absent);
2050 assert!(report.network_absent);
2051 }
2052
2053 #[tokio::test]
2057 async fn the_second_chain_indexs_positive_stands_after_a_negative_or_a_fault() {
2058 let broadcaster = mock_status_server(StatusCode::NOT_FOUND).await;
2059 for first_answer in [404u16, 500] {
2060 let first = chain_index_fixture(first_answer, "").await;
2061 let second = chain_index_fixture(200, r#"{"confirmations":3}"#).await;
2062 let verifier = verifier_with(vec![
2063 source("broadcaster", &broadcaster, AbsenceAuthority::Broadcaster),
2064 chain_index("index-a", &first.base),
2065 chain_index("index-b", &second.base),
2066 ]);
2067 for _ in 0..2 {
2068 let report = verifier.verify_report(TXID).await;
2069 assert_eq!(report.verification, BroadcastVerification::Confirmed);
2070 assert_eq!(
2071 report.chain_index,
2072 ChainIndexAnswer::Present(NetworkEvidence::Mined)
2073 );
2074 }
2075 }
2076 }
2077
2078 #[test]
2080 fn a_bitails_body_is_read_by_its_block_height() {
2081 let bitails = src_of(SourceKind::BitailsIndex, AbsenceAuthority::ChainIndex);
2082 assert_eq!(
2083 presence_of_body(&bitails, r#"{"txid":"ab","blockHeight":900001}"#),
2084 Presence::Present(NetworkEvidence::Mined)
2085 );
2086 for unmined in [r#"{"txid":"ab"}"#, r#"{"txid":"ab","blockHeight":null}"#] {
2087 assert_eq!(
2088 presence_of_body(&bitails, unmined),
2089 Presence::Present(NetworkEvidence::Seen)
2090 );
2091 }
2092 }
2093}