1use std::time::{Duration, Instant};
92
93use bsv_wallet_toolbox::{
94 services::ARCADE_V2_MAINNET, BroadcastStatus, Chain, BROADCAST_PROVIDER_CHAIN,
95 BROADCAST_PROVIDER_NETWORK, PROVIDER_ARCADE_V2,
96};
97use reqwest::Client;
98
99const DEFAULT_ATTEMPTS: u32 = 14;
122const DEFAULT_DELAY_MS: u64 = 2500;
124const INITIAL_DELAY_MS: u64 = 250;
131const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum BroadcastVerification {
138 Confirmed,
140 Rejected,
146 Inconclusive,
150}
151
152impl BroadcastVerification {
153 pub fn into_send_result(self, txid: &str) -> anyhow::Result<()> {
156 match self {
157 BroadcastVerification::Rejected => Err(anyhow::anyhow!(
158 "broadcast rejected: transaction {txid} is absent from BOTH the broadcaster \
159 it was submitted to AND an independent chain index, after the full probe \
160 window. The broadcaster dropped it — most likely error 465 \"fee too low\", \
161 because a monitor-less wallet presented a deep unconfirmed BEEF and ARC \
162 charged the fee for the whole unconfirmed package. The funds were NOT sent. \
163 Fetch merkle proofs for the confirmed ancestors (run `bsv-wallet tick` with \
164 CHAINTRACKS_URL set) or fund from a confirmed UTXO, then retry."
165 )),
166 BroadcastVerification::Confirmed | BroadcastVerification::Inconclusive => Ok(()),
167 }
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
174pub enum NetworkEvidence {
175 Seen,
178 Mined,
180}
181
182impl NetworkEvidence {
183 pub fn memory_status(self) -> &'static str {
185 match self {
186 NetworkEvidence::Seen => bsv_wallet_toolbox::BROADCAST_STATUS_SEEN,
187 NetworkEvidence::Mined => bsv_wallet_toolbox::BROADCAST_STATUS_MINED,
188 }
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum ChainIndexAnswer {
195 Present(NetworkEvidence),
197 Absent,
199 Unknown,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct PresenceReport {
214 pub verification: BroadcastVerification,
216 pub evidence: Option<NetworkEvidence>,
218 pub evidence_provider: &'static str,
224 pub chain_index: ChainIndexAnswer,
226 pub broadcaster_fatal: bool,
229 pub network_absent: bool,
235}
236
237impl PresenceReport {
238 pub fn from_verification(verification: BroadcastVerification) -> Self {
240 Self {
241 verification,
242 evidence: None,
243 evidence_provider: BROADCAST_PROVIDER_NETWORK,
244 chain_index: ChainIndexAnswer::Unknown,
245 broadcaster_fatal: false,
246 network_absent: false,
247 }
248 }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253enum Presence {
254 Held,
258 Present(NetworkEvidence),
260 Fatal,
264 Absent,
266 Unknown,
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277enum AbsenceAuthority {
278 None,
287
288 Broadcaster,
304
305 ChainIndex,
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317enum SourceKind {
318 Arcade,
320 ClassicArc,
322 ChainIndex,
324}
325
326#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
343struct AbsenceVotes {
344 broadcaster: bool,
346 chain_index: bool,
348}
349
350impl AbsenceVotes {
351 fn record(&mut self, authority: AbsenceAuthority) {
352 match authority {
353 AbsenceAuthority::Broadcaster => self.broadcaster = true,
354 AbsenceAuthority::ChainIndex => self.chain_index = true,
355 AbsenceAuthority::None => {}
357 }
358 }
359
360 fn is_definitive(self) -> bool {
362 self.broadcaster && self.chain_index
363 }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq)]
372enum BroadcastPlane {
373 ArcadeV2 { base: String },
383 ClassicArc { base: String },
386}
387
388impl BroadcastPlane {
389 fn resolve(chain: Chain, arcade_mode: bool, arc_url: Option<String>) -> Self {
394 let arc_url = arc_url
395 .map(|s| s.trim().to_string())
396 .filter(|s| !s.is_empty());
397 if arcade_mode {
398 BroadcastPlane::ArcadeV2 {
399 base: normalize_base(&arc_url.unwrap_or_else(|| ARCADE_V2_MAINNET.to_string())),
400 }
401 } else {
402 BroadcastPlane::ClassicArc {
403 base: normalize_base(&arc_url.unwrap_or_else(|| taal_arc_url(chain).to_string())),
404 }
405 }
406 }
407
408 fn from_env(chain: Chain) -> Self {
409 Self::resolve(
410 chain,
411 crate::services_env::arcade_mode_enabled(),
412 std::env::var("ARC_URL").ok(),
413 )
414 }
415
416 fn base(&self) -> &str {
417 match self {
418 BroadcastPlane::ArcadeV2 { base } | BroadcastPlane::ClassicArc { base } => base,
419 }
420 }
421
422 fn name(&self) -> &'static str {
423 match self {
424 BroadcastPlane::ArcadeV2 { .. } => "broadcaster(arcade-v2)",
425 BroadcastPlane::ClassicArc { .. } => "broadcaster(arc)",
426 }
427 }
428
429 fn kind(&self) -> SourceKind {
430 match self {
431 BroadcastPlane::ArcadeV2 { .. } => SourceKind::Arcade,
432 BroadcastPlane::ClassicArc { .. } => SourceKind::ClassicArc,
433 }
434 }
435
436 fn status_template(&self) -> String {
438 match self {
439 BroadcastPlane::ArcadeV2 { base } => format!("{base}/tx/{{txid}}"),
441 BroadcastPlane::ClassicArc { base } => format!("{base}/v1/tx/{{txid}}"),
443 }
444 }
445}
446
447#[derive(Clone, Debug)]
449struct StatusSource {
450 name: &'static str,
452 url_template: String,
454 auth: Option<String>,
456 absence: AbsenceAuthority,
458 kind: SourceKind,
460}
461
462fn build_sources(
468 chain: Chain,
469 plane: &BroadcastPlane,
470 taal_key: Option<String>,
471) -> Vec<StatusSource> {
472 let mut sources = vec![StatusSource {
473 name: plane.name(),
474 url_template: plane.status_template(),
475 auth: match plane {
477 BroadcastPlane::ArcadeV2 { .. } => None,
478 BroadcastPlane::ClassicArc { .. } => taal_key.clone(),
479 },
480 absence: AbsenceAuthority::Broadcaster,
481 kind: plane.kind(),
482 }];
483
484 sources.push(StatusSource {
487 name: "whatsonchain",
488 url_template: format!("{}/tx/hash/{{txid}}", woc_base(chain)),
489 auth: None,
490 absence: AbsenceAuthority::ChainIndex,
491 kind: SourceKind::ChainIndex,
492 });
493
494 if let Some(gp) = gorillapool_arc_url(chain) {
498 if normalize_base(gp) != plane.base() {
499 sources.push(StatusSource {
500 name: "arc-gorillapool",
501 url_template: format!("{gp}/v1/tx/{{txid}}"),
502 auth: None,
503 absence: AbsenceAuthority::None,
504 kind: SourceKind::ClassicArc,
505 });
506 }
507 }
508 if let Some(key) = taal_key {
511 let taal = taal_arc_url(chain);
512 if normalize_base(taal) != plane.base() {
513 sources.push(StatusSource {
514 name: "arc-taal",
515 url_template: format!("{taal}/v1/tx/{{txid}}"),
516 auth: Some(key),
517 absence: AbsenceAuthority::None,
518 kind: SourceKind::ClassicArc,
519 });
520 }
521 }
522
523 sources
524}
525
526#[derive(Clone)]
531pub struct BroadcastVerifier {
532 client: Client,
533 sources: Vec<StatusSource>,
534 attempts: u32,
535 delay: Duration,
536 enabled: bool,
538}
539
540impl BroadcastVerifier {
541 pub fn from_env(chain: Chain) -> Self {
549 let enabled = !env_truthy("BSV_WALLET_SKIP_BROADCAST_VERIFY");
550 let attempts = std::env::var("BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS")
551 .ok()
552 .and_then(|v| v.parse::<u32>().ok())
553 .filter(|n| *n > 0)
554 .unwrap_or(DEFAULT_ATTEMPTS);
555 let delay_ms = std::env::var("BSV_WALLET_BROADCAST_VERIFY_DELAY_MS")
556 .ok()
557 .and_then(|v| v.parse::<u64>().ok())
558 .unwrap_or(DEFAULT_DELAY_MS);
559
560 let taal_key = std::env::var("TAAL_API_KEY")
562 .ok()
563 .filter(|k| !k.is_empty())
564 .or_else(|| {
565 std::env::var("MAIN_TAAL_API_KEY")
566 .ok()
567 .filter(|k| !k.is_empty())
568 });
569
570 let plane = BroadcastPlane::from_env(chain);
571 tracing::debug!(plane = ?plane, "broadcast verifier plane");
572
573 Self {
574 client: Client::new(),
575 sources: build_sources(chain, &plane, taal_key),
576 attempts,
577 delay: Duration::from_millis(delay_ms),
578 enabled,
579 }
580 }
581
582 pub fn single_pass(chain: Chain) -> Self {
596 let mut v = Self::from_env(chain);
597 v.attempts = 1;
598 v.delay = Duration::ZERO;
599 v
600 }
601
602 fn absence_window(&self) -> Duration {
603 (1..self.attempts)
604 .map(|round| self.delay_before_round(round))
605 .sum::<Duration>()
606 + PROBE_TIMEOUT
607 }
608
609 fn delay_before_round(&self, round: u32) -> Duration {
615 let exponent = round.saturating_sub(1).min(16);
616 let grown = Duration::from_millis(INITIAL_DELAY_MS.saturating_mul(1u64 << exponent));
617 grown.min(self.delay)
618 }
619
620 pub async fn verify(&self, txid: &str) -> BroadcastVerification {
623 self.verify_report(txid).await.verification
624 }
625
626 pub async fn verify_report(&self, txid: &str) -> PresenceReport {
638 let mut report = PresenceReport::from_verification(BroadcastVerification::Inconclusive);
639 if !self.enabled || self.sources.is_empty() {
640 return report;
641 }
642
643 let deadline = Instant::now() + self.absence_window();
644 let mut last: Option<RoundResult> = None;
648
649 for attempt in 0..self.attempts {
650 let mut round = RoundResult::default();
651 for src in &self.sources {
652 match probe(&self.client, src, txid).await {
653 Presence::Present(evidence) => {
654 if src.kind == SourceKind::ChainIndex {
655 report.verification = BroadcastVerification::Confirmed;
657 report.evidence = Some(evidence);
658 report.evidence_provider = BROADCAST_PROVIDER_CHAIN;
659 report.chain_index = ChainIndexAnswer::Present(evidence);
660 return report;
661 }
662 if src.absence == AbsenceAuthority::Broadcaster {
663 round.broadcaster_answered = true;
664 let provider = if src.kind == SourceKind::Arcade {
665 PROVIDER_ARCADE_V2
666 } else {
667 BROADCAST_PROVIDER_NETWORK
668 };
669 round.broadcaster_evidence = Some((evidence, provider));
670 } else {
671 round.third_party_evidence = Some(evidence);
674 }
675 }
676 Presence::Held => {
677 round.held = true;
678 if src.absence == AbsenceAuthority::Broadcaster {
679 round.broadcaster_answered = true;
680 }
681 }
682 Presence::Fatal => {
683 round.fatal = true;
684 round.broadcaster_answered = true;
685 round.votes.record(src.absence);
686 }
687 Presence::Absent => {
688 if src.absence == AbsenceAuthority::Broadcaster {
689 round.broadcaster_answered = true;
690 }
691 round.votes.record(src.absence);
692 }
693 Presence::Unknown => {}
694 }
695 }
696 let settled = round.held
697 || round.broadcaster_evidence.is_some()
698 || round.third_party_evidence.is_some();
699 last = Some(round);
700
701 if settled {
705 break;
706 }
707
708 if attempt + 1 < self.attempts {
709 if Instant::now() >= deadline {
710 break;
713 }
714 tokio::time::sleep(self.delay_before_round(attempt + 1)).await;
715 }
716 }
717
718 if let Some(round) = last {
719 report.broadcaster_fatal = round.fatal;
720 report.chain_index = if round.votes.chain_index {
721 ChainIndexAnswer::Absent
722 } else {
723 ChainIndexAnswer::Unknown
724 };
725 if let Some(evidence) = round.third_party_evidence {
728 report.evidence = Some(evidence);
729 report.evidence_provider = BROADCAST_PROVIDER_NETWORK;
730 } else if let Some((evidence, provider)) = round.broadcaster_evidence {
731 report.evidence = Some(evidence);
732 report.evidence_provider = provider;
733 }
734 report.network_absent = round.votes.chain_index
735 && round.broadcaster_answered
736 && round.third_party_evidence.is_none();
737 let held = round.held
738 || round.broadcaster_evidence.is_some()
739 || round.third_party_evidence.is_some();
740 report.verification = if held {
741 BroadcastVerification::Confirmed
742 } else if round.votes.is_definitive() {
743 BroadcastVerification::Rejected
744 } else {
745 BroadcastVerification::Inconclusive
746 };
747 }
748 report
749 }
750
751 #[allow(dead_code)]
758 pub fn explicit(arcade: bool, broadcaster_base: &str, chain_index_base: Option<&str>) -> Self {
759 let plane = if arcade {
760 BroadcastPlane::ArcadeV2 {
761 base: normalize_base(broadcaster_base),
762 }
763 } else {
764 BroadcastPlane::ClassicArc {
765 base: normalize_base(broadcaster_base),
766 }
767 };
768 let mut sources = vec![StatusSource {
769 name: plane.name(),
770 url_template: plane.status_template(),
771 auth: None,
772 absence: AbsenceAuthority::Broadcaster,
773 kind: plane.kind(),
774 }];
775 if let Some(base) = chain_index_base {
776 sources.push(StatusSource {
777 name: "chain-index",
778 url_template: format!("{}/tx/hash/{{txid}}", normalize_base(base)),
779 auth: None,
780 absence: AbsenceAuthority::ChainIndex,
781 kind: SourceKind::ChainIndex,
782 });
783 }
784 Self {
785 client: Client::new(),
786 sources,
787 attempts: 1,
788 delay: Duration::ZERO,
789 enabled: true,
790 }
791 }
792}
793
794#[derive(Debug, Clone, Copy, Default)]
796struct RoundResult {
797 votes: AbsenceVotes,
798 held: bool,
800 broadcaster_answered: bool,
803 fatal: bool,
805 broadcaster_evidence: Option<(NetworkEvidence, &'static str)>,
808 third_party_evidence: Option<NetworkEvidence>,
810}
811
812fn presence_of_body(src: &StatusSource, body: &str) -> Presence {
814 let json: Option<serde_json::Value> = serde_json::from_str(body).ok();
815 match src.kind {
816 SourceKind::ChainIndex => {
817 let confirmations = json
818 .as_ref()
819 .and_then(|v| v.get("confirmations"))
820 .and_then(|c| c.as_i64())
821 .unwrap_or(0);
822 if confirmations >= 1 {
823 Presence::Present(NetworkEvidence::Mined)
824 } else {
825 Presence::Present(NetworkEvidence::Seen)
826 }
827 }
828 SourceKind::Arcade | SourceKind::ClassicArc => {
829 let Some(tx_status) = json
830 .as_ref()
831 .and_then(|v| v.get("txStatus"))
832 .and_then(|s| s.as_str())
833 else {
834 return Presence::Held;
835 };
836 let status = match src.kind {
837 SourceKind::Arcade => BroadcastStatus::from_arcade_status(tx_status),
838 _ => BroadcastStatus::from_arc_status(tx_status),
839 };
840 match status {
841 BroadcastStatus::Seen => Presence::Present(NetworkEvidence::Seen),
842 BroadcastStatus::Mined => Presence::Present(NetworkEvidence::Mined),
843 BroadcastStatus::Rejected => {
844 if src.absence == AbsenceAuthority::Broadcaster {
845 Presence::Fatal
846 } else {
847 Presence::Unknown
850 }
851 }
852 BroadcastStatus::Accepted | BroadcastStatus::Unknown => Presence::Held,
853 }
854 }
855 }
856}
857
858async fn probe(client: &Client, src: &StatusSource, txid: &str) -> Presence {
860 let url = src.url_template.replace("{txid}", txid);
861 let mut req = client.get(&url).timeout(PROBE_TIMEOUT);
862 if let Some(auth) = &src.auth {
863 req = req.header("Authorization", auth);
864 }
865 match req.send().await {
866 Ok(resp) => {
867 let status = resp.status().as_u16();
868 match status {
869 200 => {
870 let body = resp.text().await.unwrap_or_default();
871 let presence = presence_of_body(src, &body);
872 tracing::debug!(source = src.name, ?presence, "broadcast probe");
873 presence
874 }
875 404 => {
876 if src.absence == AbsenceAuthority::Broadcaster && !is_json(&resp) {
885 tracing::debug!(
886 source = src.name,
887 url = %url,
888 "broadcaster 404 is not a JSON tx-status body — treating as \
889 route-not-found (check ARC_URL / path shape), not absence"
890 );
891 return Presence::Unknown;
892 }
893 Presence::Absent
894 }
895 other => {
896 tracing::debug!(
897 source = src.name,
898 status = other,
899 "broadcast probe inconclusive"
900 );
901 Presence::Unknown
902 }
903 }
904 }
905 Err(e) => {
906 tracing::debug!(source = src.name, error = %e, "broadcast probe request failed");
907 Presence::Unknown
908 }
909 }
910}
911
912fn is_json(resp: &reqwest::Response) -> bool {
915 resp.headers()
916 .get(reqwest::header::CONTENT_TYPE)
917 .and_then(|v| v.to_str().ok())
918 .map(|ct| ct.to_ascii_lowercase().contains("json"))
919 .unwrap_or(false)
920}
921
922fn normalize_base(url: &str) -> String {
923 url.trim().trim_end_matches('/').to_string()
924}
925
926fn taal_arc_url(chain: Chain) -> &'static str {
927 match chain {
928 Chain::Main => "https://arc.taal.com",
929 Chain::Test => "https://arc-test.taal.com",
930 }
931}
932
933fn gorillapool_arc_url(chain: Chain) -> Option<&'static str> {
934 match chain {
935 Chain::Main => Some("https://arc.gorillapool.io"),
936 Chain::Test => None,
938 }
939}
940
941fn woc_base(chain: Chain) -> &'static str {
942 match chain {
943 Chain::Main => "https://api.whatsonchain.com/v1/bsv/main",
944 Chain::Test => "https://api.whatsonchain.com/v1/bsv/test",
945 }
946}
947
948fn env_truthy(key: &str) -> bool {
949 std::env::var(key)
950 .map(|v| {
951 let v = v.trim().to_ascii_lowercase();
952 v == "1" || v == "true" || v == "yes" || v == "on"
953 })
954 .unwrap_or(false)
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960 use axum::http::StatusCode;
961 use axum::routing::get;
962 use axum::Router;
963 use std::net::SocketAddr;
964
965 const TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001";
967 const SYNTHETIC_ARCADE: &str = "https://arcade.invalid";
968 const SYNTHETIC_ARC: &str = "https://arc.invalid";
969 const SYNTHETIC_KEY: &str = "test-key-not-a-real-credential";
970
971 #[tokio::test]
979 async fn single_pass_is_one_attempt_and_disabled_means_inconclusive() {
980 let v = BroadcastVerifier::single_pass(Chain::Main);
981 assert_eq!(v.attempts, 1);
982 assert_eq!(v.delay, Duration::ZERO);
983 let off = BroadcastVerifier {
984 enabled: false,
985 ..v
986 };
987 assert_eq!(
988 off.verify(&"cd".repeat(32)).await,
989 BroadcastVerification::Inconclusive
990 );
991 }
992
993 #[test]
994 fn arcade_plane_uses_bare_tx_path_not_v1() {
995 let plane = BroadcastPlane::resolve(
999 Chain::Main,
1000 true,
1001 Some(SYNTHETIC_ARCADE.to_string()),
1002 );
1003 assert_eq!(
1004 plane.status_template(),
1005 format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
1006 );
1007 assert!(
1008 !plane.status_template().contains("/v1/"),
1009 "Arcade V2 must NOT be probed on the classic ARC /v1 path"
1010 );
1011 assert_eq!(plane.kind(), SourceKind::Arcade);
1012 }
1013
1014 #[test]
1015 fn classic_arc_plane_uses_v1_tx_path() {
1016 let plane = BroadcastPlane::resolve(
1017 Chain::Main,
1018 false,
1019 Some(SYNTHETIC_ARC.to_string()),
1020 );
1021 assert_eq!(
1022 plane.status_template(),
1023 format!("{SYNTHETIC_ARC}/v1/tx/{{txid}}")
1024 );
1025 assert_eq!(plane.kind(), SourceKind::ClassicArc);
1026 }
1027
1028 #[test]
1029 fn arcade_mode_defaults_to_the_arcade_endpoint_when_arc_url_is_unset() {
1030 let plane = BroadcastPlane::resolve(Chain::Main, true, None);
1031 assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
1032 }
1033
1034 #[test]
1035 fn classic_mode_defaults_to_taal_and_respects_chain() {
1036 assert_eq!(
1037 BroadcastPlane::resolve(Chain::Main, false, None).base(),
1038 "https://arc.taal.com"
1039 );
1040 assert_eq!(
1041 BroadcastPlane::resolve(Chain::Test, false, None).base(),
1042 "https://arc-test.taal.com"
1043 );
1044 }
1045
1046 #[test]
1047 fn empty_arc_url_falls_back_to_the_default_rather_than_an_empty_base() {
1048 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(" ".to_string()));
1049 assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
1050 }
1051
1052 #[test]
1053 fn trailing_slash_in_arc_url_does_not_produce_a_double_slash() {
1054 let plane = BroadcastPlane::resolve(
1055 Chain::Main,
1056 true,
1057 Some(format!("{SYNTHETIC_ARCADE}/").to_string()),
1058 );
1059 assert_eq!(
1060 plane.status_template(),
1061 format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
1062 );
1063 }
1064
1065 #[test]
1066 fn the_broadcaster_we_used_is_always_the_first_source_consulted() {
1067 for plane in [
1070 BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string())),
1071 BroadcastPlane::resolve(Chain::Main, false, Some(SYNTHETIC_ARC.to_string())),
1072 ] {
1073 let sources = build_sources(Chain::Main, &plane, None);
1074 assert_eq!(sources[0].absence, AbsenceAuthority::Broadcaster);
1075 assert_eq!(sources[0].kind, plane.kind());
1076 assert!(
1077 sources[0].url_template.starts_with(plane.base()),
1078 "source 0 ({}) must be the configured broadcaster {}",
1079 sources[0].url_template,
1080 plane.base()
1081 );
1082 }
1083 }
1084
1085 #[test]
1086 fn arcade_broadcaster_probe_is_keyless_even_when_a_taal_key_exists() {
1087 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
1088 let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
1089 assert!(sources[0].auth.is_none());
1090 }
1091
1092 #[test]
1093 fn classic_broadcaster_probe_carries_the_taal_key_when_present() {
1094 let plane = BroadcastPlane::resolve(Chain::Main, false, None);
1095 let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
1096 assert_eq!(sources[0].auth.as_deref(), Some(SYNTHETIC_KEY));
1097 }
1098
1099 #[test]
1100 fn keyless_taal_is_not_probed_at_all() {
1101 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
1103 let sources = build_sources(Chain::Main, &plane, None);
1104 assert!(!sources.iter().any(|s| s.name == "arc-taal"));
1105 }
1106
1107 #[test]
1108 fn a_store_is_never_listed_twice_when_it_is_also_the_broadcaster() {
1109 let plane = BroadcastPlane::resolve(
1112 Chain::Main,
1113 false,
1114 Some("https://arc.gorillapool.io".to_string()),
1115 );
1116 let sources = build_sources(Chain::Main, &plane, None);
1117 let gp_rows: Vec<_> = sources
1118 .iter()
1119 .filter(|s| s.url_template.contains("arc.gorillapool.io"))
1120 .collect();
1121 assert_eq!(gp_rows.len(), 1);
1122 assert_eq!(gp_rows[0].absence, AbsenceAuthority::Broadcaster);
1123 }
1124
1125 #[test]
1130 fn a_third_party_arc_store_is_never_authoritative_for_absence() {
1131 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
1136 let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
1137 for s in sources.iter().filter(|s| s.name.starts_with("arc-")) {
1138 assert_eq!(
1139 s.absence,
1140 AbsenceAuthority::None,
1141 "{} is not the broadcaster; its absence must carry no weight",
1142 s.name
1143 );
1144 }
1145 }
1146
1147 #[test]
1148 fn whatsonchain_is_the_chain_index_authority() {
1149 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
1150 let sources = build_sources(Chain::Main, &plane, None);
1151 let woc = sources.iter().find(|s| s.name == "whatsonchain").unwrap();
1152 assert_eq!(woc.absence, AbsenceAuthority::ChainIndex);
1153 assert_eq!(woc.kind, SourceKind::ChainIndex);
1154 }
1155
1156 #[test]
1157 fn absence_is_definitive_only_when_broadcaster_and_chain_index_agree() {
1158 let mut none = AbsenceVotes::default();
1159 assert!(!none.is_definitive(), "no votes is not evidence");
1160
1161 none.record(AbsenceAuthority::None);
1163 assert!(!none.is_definitive());
1164
1165 let mut broadcaster_only = AbsenceVotes::default();
1166 broadcaster_only.record(AbsenceAuthority::Broadcaster);
1167 assert!(
1168 !broadcaster_only.is_definitive(),
1169 "the primary may 404 while the tx went out through the failover provider"
1170 );
1171
1172 let mut index_only = AbsenceVotes::default();
1173 index_only.record(AbsenceAuthority::ChainIndex);
1174 assert!(
1175 !index_only.is_definitive(),
1176 "a chain index can simply be lagging its mempool ingestion"
1177 );
1178
1179 let mut both = AbsenceVotes::default();
1180 both.record(AbsenceAuthority::Broadcaster);
1181 both.record(AbsenceAuthority::ChainIndex);
1182 assert!(both.is_definitive());
1183 }
1184
1185 fn src_of(kind: SourceKind, absence: AbsenceAuthority) -> StatusSource {
1190 StatusSource {
1191 name: "test",
1192 url_template: "http://127.0.0.1:1/tx/{txid}".to_string(),
1193 auth: None,
1194 absence,
1195 kind,
1196 }
1197 }
1198
1199 #[test]
1200 fn a_200_body_is_read_by_source_kind() {
1201 let arcade = src_of(SourceKind::Arcade, AbsenceAuthority::Broadcaster);
1202 assert_eq!(
1203 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"RECEIVED"}"#),
1204 Presence::Held,
1205 "a pre-gate status is held, not network evidence"
1206 );
1207 assert_eq!(
1208 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"ACCEPTED_BY_NETWORK"}"#),
1209 Presence::Held
1210 );
1211 assert_eq!(
1212 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#),
1213 Presence::Present(NetworkEvidence::Seen)
1214 );
1215 assert_eq!(
1216 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"MINED"}"#),
1217 Presence::Present(NetworkEvidence::Mined)
1218 );
1219 assert_eq!(
1220 presence_of_body(&arcade, r#"{"txid":"x","txStatus":"REJECTED"}"#),
1221 Presence::Fatal
1222 );
1223 assert_eq!(
1224 presence_of_body(&arcade, "{}"),
1225 Presence::Held,
1226 "a 200 without a readable status still means the store holds it"
1227 );
1228 assert_eq!(presence_of_body(&arcade, "not json"), Presence::Held);
1229
1230 let arc = src_of(SourceKind::ClassicArc, AbsenceAuthority::None);
1231 assert_eq!(
1232 presence_of_body(&arc, r#"{"txStatus":"SEEN_IN_ORPHAN_MEMPOOL"}"#),
1233 Presence::Held,
1234 "an orphan-pool hit is held: the node lacks the parent"
1235 );
1236 assert_eq!(
1237 presence_of_body(&arc, r#"{"txStatus":"SEEN_ON_NETWORK"}"#),
1238 Presence::Present(NetworkEvidence::Seen)
1239 );
1240 assert_eq!(
1241 presence_of_body(&arc, r#"{"txStatus":"REJECTED"}"#),
1242 Presence::Unknown,
1243 "a third-party rejection of somebody's copy is no vote"
1244 );
1245
1246 let woc = src_of(SourceKind::ChainIndex, AbsenceAuthority::ChainIndex);
1247 assert_eq!(
1248 presence_of_body(&woc, r#"{"txid":"x","confirmations":0}"#),
1249 Presence::Present(NetworkEvidence::Seen)
1250 );
1251 assert_eq!(
1252 presence_of_body(&woc, r#"{"txid":"x","confirmations":3}"#),
1253 Presence::Present(NetworkEvidence::Mined)
1254 );
1255 assert_eq!(
1256 presence_of_body(&woc, r#"{"txid":"x"}"#),
1257 Presence::Present(NetworkEvidence::Seen)
1258 );
1259 }
1260
1261 async fn mock_status_server(code: StatusCode) -> String {
1268 mock_status_server_full(code, Some("application/json"), "{}").await
1269 }
1270
1271 async fn mock_status_server_ct(code: StatusCode, content_type: Option<&'static str>) -> String {
1273 mock_status_server_full(code, content_type, "{}").await
1274 }
1275
1276 async fn mock_status_server_body(body: &'static str) -> String {
1278 mock_status_server_full(StatusCode::OK, Some("application/json"), body).await
1279 }
1280
1281 async fn mock_status_server_full(
1282 code: StatusCode,
1283 content_type: Option<&'static str>,
1284 body: &'static str,
1285 ) -> String {
1286 let handler = move || async move {
1287 let mut resp = axum::response::Response::new(axum::body::Body::from(body));
1288 *resp.status_mut() = code;
1289 if let Some(ct) = content_type {
1290 resp.headers_mut()
1291 .insert(reqwest::header::CONTENT_TYPE.as_str(), ct.parse().unwrap());
1292 } else {
1293 resp.headers_mut()
1294 .remove(reqwest::header::CONTENT_TYPE.as_str());
1295 }
1296 resp
1297 };
1298 let app = Router::new()
1299 .route("/tx/{txid}", get(handler))
1300 .route("/v1/tx/{txid}", get(handler))
1301 .route("/tx/hash/{txid}", get(handler));
1302 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1303 let addr: SocketAddr = listener.local_addr().unwrap();
1304 tokio::spawn(async move {
1305 axum::serve(listener, app).await.ok();
1306 });
1307 format!("http://{}", addr)
1308 }
1309
1310 fn source(name: &'static str, base: &str, absence: AbsenceAuthority) -> StatusSource {
1311 source_kind(name, base, absence, SourceKind::ClassicArc)
1312 }
1313
1314 fn source_kind(
1315 name: &'static str,
1316 base: &str,
1317 absence: AbsenceAuthority,
1318 kind: SourceKind,
1319 ) -> StatusSource {
1320 StatusSource {
1321 name,
1322 url_template: format!("{base}/tx/{{txid}}"),
1323 auth: None,
1324 absence,
1325 kind,
1326 }
1327 }
1328
1329 fn verifier_with(sources: Vec<StatusSource>) -> BroadcastVerifier {
1331 BroadcastVerifier {
1332 client: Client::new(),
1333 sources,
1334 attempts: 2,
1335 delay: Duration::from_millis(0),
1336 enabled: true,
1337 }
1338 }
1339
1340 #[tokio::test]
1341 async fn rejected_when_broadcaster_and_chain_index_both_report_absent() {
1342 let base = mock_status_server(StatusCode::NOT_FOUND).await;
1346 let verifier = verifier_with(vec![
1347 source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1348 source("chain-index", &base, AbsenceAuthority::ChainIndex),
1349 ]);
1350
1351 let report = verifier.verify_report(TXID).await;
1352 assert_eq!(report.verification, BroadcastVerification::Rejected);
1353 assert!(report.network_absent);
1354 assert!(!report.broadcaster_fatal);
1355 assert_eq!(report.evidence, None);
1356 assert!(
1357 report.verification.into_send_result(TXID).is_err(),
1358 "a Rejected verification must map to Err so the send fails loudly"
1359 );
1360 }
1361
1362 #[tokio::test]
1363 async fn the_false_negative_that_motivated_this_fix_is_now_inconclusive() {
1364 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1370 let verifier = verifier_with(vec![
1371 source(
1373 "broadcaster",
1374 "http://127.0.0.1:1",
1375 AbsenceAuthority::Broadcaster,
1376 ),
1377 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1378 source("arc-third-party", &absent, AbsenceAuthority::None),
1379 ]);
1380 let report = verifier.verify_report(TXID).await;
1381 assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1382 assert!(
1383 !report.network_absent,
1384 "the absence clock does not run while the broadcaster is unreachable"
1385 );
1386 }
1387
1388 #[tokio::test]
1389 async fn third_party_absence_alone_never_rejects() {
1390 let base = mock_status_server(StatusCode::NOT_FOUND).await;
1391 let verifier = verifier_with(vec![
1392 source("arc-third-party-a", &base, AbsenceAuthority::None),
1393 source("arc-third-party-b", &base, AbsenceAuthority::None),
1394 ]);
1395 assert_eq!(
1396 verifier.verify(TXID).await,
1397 BroadcastVerification::Inconclusive
1398 );
1399 }
1400
1401 #[tokio::test]
1402 async fn broadcaster_absence_alone_never_rejects() {
1403 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1406 let verifier = verifier_with(vec![
1407 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1408 source(
1410 "chain-index",
1411 "http://127.0.0.1:1",
1412 AbsenceAuthority::ChainIndex,
1413 ),
1414 ]);
1415 assert_eq!(
1416 verifier.verify(TXID).await,
1417 BroadcastVerification::Inconclusive
1418 );
1419 }
1420
1421 #[tokio::test]
1422 async fn chain_index_absence_alone_never_rejects() {
1423 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1424 let verifier = verifier_with(vec![
1425 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1427 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1428 ]);
1429 assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
1431
1432 let unauth = mock_status_server(StatusCode::UNAUTHORIZED).await;
1434 let verifier = verifier_with(vec![
1435 source("broadcaster", &unauth, AbsenceAuthority::Broadcaster),
1436 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1437 ]);
1438 assert_eq!(
1439 verifier.verify(TXID).await,
1440 BroadcastVerification::Inconclusive
1441 );
1442 }
1443
1444 #[tokio::test]
1445 async fn presence_from_any_source_confirms_even_when_others_say_absent() {
1446 let present = mock_status_server(StatusCode::OK).await;
1448 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1449 let verifier = verifier_with(vec![
1450 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1451 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1452 source("arc-third-party", &present, AbsenceAuthority::None),
1453 ]);
1454 let outcome = verifier.verify(TXID).await;
1455 assert_eq!(outcome, BroadcastVerification::Confirmed);
1456 assert!(outcome.into_send_result(TXID).is_ok());
1457 }
1458
1459 #[tokio::test]
1460 async fn confirmed_broadcast_succeeds() {
1461 let base = mock_status_server(StatusCode::OK).await;
1462 let verifier = verifier_with(vec![source(
1463 "broadcaster",
1464 &base,
1465 AbsenceAuthority::Broadcaster,
1466 )]);
1467 let outcome = verifier.verify(TXID).await;
1468 assert_eq!(outcome, BroadcastVerification::Confirmed);
1469 assert!(outcome.into_send_result(TXID).is_ok());
1470 }
1471
1472 #[tokio::test]
1473 async fn unreachable_source_is_inconclusive_not_a_failure() {
1474 let base = mock_status_server(StatusCode::SERVICE_UNAVAILABLE).await;
1477 let verifier = verifier_with(vec![
1478 source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1479 source("chain-index", &base, AbsenceAuthority::ChainIndex),
1480 ]);
1481 let outcome = verifier.verify(TXID).await;
1482 assert_eq!(outcome, BroadcastVerification::Inconclusive);
1483 assert!(outcome.into_send_result(TXID).is_ok());
1484 }
1485
1486 #[tokio::test]
1487 async fn a_routing_404_from_the_broadcaster_is_not_absence() {
1488 let text_404 = mock_status_server_ct(StatusCode::NOT_FOUND, Some("text/plain")).await;
1491 let json_404 = mock_status_server(StatusCode::NOT_FOUND).await;
1492 let verifier = verifier_with(vec![
1493 source("broadcaster", &text_404, AbsenceAuthority::Broadcaster),
1494 source("chain-index", &json_404, AbsenceAuthority::ChainIndex),
1495 ]);
1496 assert_eq!(
1497 verifier.verify(TXID).await,
1498 BroadcastVerification::Inconclusive
1499 );
1500 }
1501
1502 #[tokio::test]
1503 async fn disabled_verifier_is_inconclusive() {
1504 let base = mock_status_server(StatusCode::NOT_FOUND).await;
1505 let mut verifier = verifier_with(vec![
1506 source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1507 source("chain-index", &base, AbsenceAuthority::ChainIndex),
1508 ]);
1509 verifier.enabled = false;
1510 assert_eq!(
1511 verifier.verify(TXID).await,
1512 BroadcastVerification::Inconclusive
1513 );
1514 }
1515
1516 #[tokio::test]
1519 async fn seen_on_network_from_the_arcade_plane_is_network_evidence_for_arcade() {
1520 let seen = mock_status_server_body(r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#).await;
1521 let verifier = verifier_with(vec![source_kind(
1522 "broadcaster",
1523 &seen,
1524 AbsenceAuthority::Broadcaster,
1525 SourceKind::Arcade,
1526 )]);
1527 let report = verifier.verify_report(TXID).await;
1528 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1529 assert_eq!(report.evidence, Some(NetworkEvidence::Seen));
1530 assert_eq!(report.evidence_provider, PROVIDER_ARCADE_V2);
1531 assert_eq!(report.chain_index, ChainIndexAnswer::Unknown);
1532 assert!(!report.network_absent && !report.broadcaster_fatal);
1533 }
1534
1535 #[tokio::test]
1536 async fn a_broadcasters_seen_with_a_chain_index_miss_is_network_absent() {
1537 let seen =
1542 mock_status_server_body(r#"{"txid":"x","txStatus":"SEEN_MULTIPLE_NODES"}"#).await;
1543 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1544 let verifier = verifier_with(vec![
1545 source_kind(
1546 "broadcaster",
1547 &seen,
1548 AbsenceAuthority::Broadcaster,
1549 SourceKind::Arcade,
1550 ),
1551 source_kind(
1552 "chain-index",
1553 &absent,
1554 AbsenceAuthority::ChainIndex,
1555 SourceKind::ChainIndex,
1556 ),
1557 ]);
1558 let report = verifier.verify_report(TXID).await;
1559 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1560 assert_eq!(report.evidence, Some(NetworkEvidence::Seen));
1561 assert_eq!(report.evidence_provider, PROVIDER_ARCADE_V2);
1562 assert_eq!(report.chain_index, ChainIndexAnswer::Absent);
1563 assert!(
1564 report.network_absent,
1565 "the chain index was asked and said no"
1566 );
1567 assert!(!report.broadcaster_fatal);
1568
1569 let explicit = BroadcastVerifier::explicit(true, &seen, Some(&absent));
1571 assert_eq!(explicit.sources.len(), 2);
1572 assert_eq!(explicit.sources[0].kind, SourceKind::Arcade);
1573 assert_eq!(explicit.sources[1].kind, SourceKind::ChainIndex);
1574 let report = explicit.verify_report(TXID).await;
1575 assert!(report.network_absent);
1576 assert_eq!(report.chain_index, ChainIndexAnswer::Absent);
1577 }
1578
1579 #[tokio::test]
1580 async fn a_peer_nodes_seen_blocks_the_absence() {
1581 let held = mock_status_server_body(r#"{"txid":"x","txStatus":"RECEIVED"}"#).await;
1584 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1585 let peer = mock_status_server_body(r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#).await;
1586 let verifier = verifier_with(vec![
1587 source_kind(
1588 "broadcaster",
1589 &held,
1590 AbsenceAuthority::Broadcaster,
1591 SourceKind::Arcade,
1592 ),
1593 source_kind(
1594 "chain-index",
1595 &absent,
1596 AbsenceAuthority::ChainIndex,
1597 SourceKind::ChainIndex,
1598 ),
1599 source_kind(
1600 "arc-third-party",
1601 &peer,
1602 AbsenceAuthority::None,
1603 SourceKind::ClassicArc,
1604 ),
1605 ]);
1606 let report = verifier.verify_report(TXID).await;
1607 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1608 assert_eq!(report.evidence, Some(NetworkEvidence::Seen));
1609 assert_eq!(report.evidence_provider, BROADCAST_PROVIDER_NETWORK);
1610 assert_eq!(report.chain_index, ChainIndexAnswer::Absent);
1611 assert!(!report.network_absent);
1612 }
1613
1614 #[tokio::test]
1615 async fn a_pre_gate_status_is_held_only_and_the_absence_clock_runs() {
1616 let held = mock_status_server_body(r#"{"txid":"x","txStatus":"RECEIVED"}"#).await;
1620 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1621 let verifier = verifier_with(vec![
1622 source_kind(
1623 "broadcaster",
1624 &held,
1625 AbsenceAuthority::Broadcaster,
1626 SourceKind::Arcade,
1627 ),
1628 source_kind(
1629 "chain-index",
1630 &absent,
1631 AbsenceAuthority::ChainIndex,
1632 SourceKind::ChainIndex,
1633 ),
1634 ]);
1635 let report = verifier.verify_report(TXID).await;
1636 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1637 assert_eq!(report.evidence, None);
1638 assert!(report.network_absent);
1639 assert!(!report.broadcaster_fatal);
1640 }
1641
1642 #[tokio::test]
1643 async fn a_fatal_verdict_from_the_broadcaster_with_an_index_miss_is_rejected() {
1644 let fatal = mock_status_server_body(r#"{"txid":"x","txStatus":"REJECTED"}"#).await;
1645 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1646 let verifier = verifier_with(vec![
1647 source_kind(
1648 "broadcaster",
1649 &fatal,
1650 AbsenceAuthority::Broadcaster,
1651 SourceKind::Arcade,
1652 ),
1653 source_kind(
1654 "chain-index",
1655 &absent,
1656 AbsenceAuthority::ChainIndex,
1657 SourceKind::ChainIndex,
1658 ),
1659 ]);
1660 let report = verifier.verify_report(TXID).await;
1661 assert_eq!(report.verification, BroadcastVerification::Rejected);
1662 assert!(report.broadcaster_fatal);
1663 assert!(report.network_absent);
1664
1665 let verifier = verifier_with(vec![
1667 source_kind(
1668 "broadcaster",
1669 &fatal,
1670 AbsenceAuthority::Broadcaster,
1671 SourceKind::Arcade,
1672 ),
1673 source_kind(
1674 "chain-index",
1675 "http://127.0.0.1:1",
1676 AbsenceAuthority::ChainIndex,
1677 SourceKind::ChainIndex,
1678 ),
1679 ]);
1680 let report = verifier.verify_report(TXID).await;
1681 assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1682 assert!(report.broadcaster_fatal);
1683 assert!(!report.network_absent);
1684 }
1685
1686 #[tokio::test]
1687 async fn a_chain_index_hit_is_network_evidence_for_everyone() {
1688 let held = mock_status_server_body(r#"{"txid":"x","txStatus":"SENT_TO_NETWORK"}"#).await;
1689 let mined = mock_status_server_body(r#"{"txid":"x","confirmations":2}"#).await;
1690 let verifier = verifier_with(vec![
1691 source_kind(
1692 "broadcaster",
1693 &held,
1694 AbsenceAuthority::Broadcaster,
1695 SourceKind::Arcade,
1696 ),
1697 source_kind(
1698 "chain-index",
1699 &mined,
1700 AbsenceAuthority::ChainIndex,
1701 SourceKind::ChainIndex,
1702 ),
1703 ]);
1704 let report = verifier.verify_report(TXID).await;
1705 assert_eq!(report.verification, BroadcastVerification::Confirmed);
1706 assert_eq!(report.evidence, Some(NetworkEvidence::Mined));
1707 assert_eq!(report.evidence_provider, BROADCAST_PROVIDER_CHAIN);
1708 assert_eq!(
1709 report.chain_index,
1710 ChainIndexAnswer::Present(NetworkEvidence::Mined)
1711 );
1712 assert!(!report.network_absent);
1713 }
1714
1715 #[tokio::test]
1716 async fn a_third_party_rejection_alone_is_inconclusive() {
1717 let fatal = mock_status_server_body(r#"{"txid":"x","txStatus":"REJECTED"}"#).await;
1718 let verifier = verifier_with(vec![source_kind(
1719 "arc-third-party",
1720 &fatal,
1721 AbsenceAuthority::None,
1722 SourceKind::ClassicArc,
1723 )]);
1724 let report = verifier.verify_report(TXID).await;
1725 assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1726 assert!(!report.broadcaster_fatal);
1727 }
1728
1729 #[test]
1730 fn absence_window_is_bounded_and_reflects_the_configured_rounds() {
1731 let v = BroadcastVerifier {
1732 client: Client::new(),
1733 sources: vec![],
1734 attempts: DEFAULT_ATTEMPTS,
1735 delay: Duration::from_millis(DEFAULT_DELAY_MS),
1736 enabled: true,
1737 };
1738 assert_eq!(
1742 v.absence_window(),
1743 Duration::from_millis(26_250) + PROBE_TIMEOUT
1744 );
1745 }
1746
1747 #[test]
1748 fn probe_schedule_starts_short_grows_and_caps() {
1749 let v = BroadcastVerifier {
1753 client: Client::new(),
1754 sources: vec![],
1755 attempts: DEFAULT_ATTEMPTS,
1756 delay: Duration::from_millis(DEFAULT_DELAY_MS),
1757 enabled: true,
1758 };
1759 let gaps: Vec<u64> = (1..v.attempts)
1760 .map(|r| v.delay_before_round(r).as_millis() as u64)
1761 .collect();
1762 assert_eq!(
1763 gaps,
1764 vec![250, 500, 1000, 2000, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500]
1765 );
1766 assert!(gaps.windows(2).all(|w| w[0] <= w[1]), "never shrinks");
1767 assert!(
1768 gaps.iter().all(|g| *g <= DEFAULT_DELAY_MS),
1769 "never exceeds the cap"
1770 );
1771
1772 let tight = BroadcastVerifier {
1774 delay: Duration::from_millis(100),
1775 ..v
1776 };
1777 assert!(
1778 (1..tight.attempts).all(|r| tight.delay_before_round(r) == Duration::from_millis(100))
1779 );
1780
1781 let one = BroadcastVerifier::single_pass(Chain::Main);
1783 assert_eq!(one.absence_window(), PROBE_TIMEOUT);
1784 }
1785
1786 #[tokio::test]
1787 async fn a_present_tx_is_confirmed_on_the_first_probe_without_waiting() {
1788 let present = mock_status_server(StatusCode::OK).await;
1792 let verifier = BroadcastVerifier {
1793 client: Client::new(),
1794 sources: vec![source(
1795 "broadcaster",
1796 &present,
1797 AbsenceAuthority::Broadcaster,
1798 )],
1799 attempts: DEFAULT_ATTEMPTS,
1800 delay: Duration::from_millis(DEFAULT_DELAY_MS),
1801 enabled: true,
1802 };
1803 let started = std::time::Instant::now();
1804 assert_eq!(
1805 verifier.verify(TXID).await,
1806 BroadcastVerification::Confirmed
1807 );
1808 assert!(
1809 started.elapsed() < Duration::from_millis(INITIAL_DELAY_MS),
1810 "took {:?}",
1811 started.elapsed()
1812 );
1813 }
1814
1815 #[tokio::test]
1816 async fn an_absent_tx_is_retried_on_the_growing_schedule() {
1817 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1821 let verifier = BroadcastVerifier {
1822 client: Client::new(),
1823 sources: vec![
1824 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1825 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1826 ],
1827 attempts: 4,
1828 delay: Duration::from_millis(200),
1829 enabled: true,
1830 };
1831 assert!((1..4).all(|r| verifier.delay_before_round(r) == Duration::from_millis(200)));
1833 let started = std::time::Instant::now();
1834 assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
1835 let elapsed = started.elapsed();
1836 assert!(
1837 elapsed >= Duration::from_millis(600) && elapsed < Duration::from_millis(2_000),
1838 "took {:?}",
1839 elapsed
1840 );
1841 }
1842}