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