use std::time::{Duration, Instant};
use bsv_wallet_toolbox::{
services::ARCADE_V2_MAINNET, BroadcastStatus, Chain, BROADCAST_PROVIDER_CHAIN,
BROADCAST_PROVIDER_NETWORK, PROVIDER_ARCADE_V2,
};
use reqwest::Client;
const DEFAULT_ATTEMPTS: u32 = 14;
const DEFAULT_DELAY_MS: u64 = 2500;
const INITIAL_DELAY_MS: u64 = 250;
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BroadcastVerification {
Confirmed,
Rejected,
Inconclusive,
}
impl BroadcastVerification {
pub fn into_send_result(self, txid: &str) -> anyhow::Result<()> {
match self {
BroadcastVerification::Rejected => Err(anyhow::anyhow!(
"broadcast rejected: transaction {txid} is absent from BOTH the broadcaster \
it was submitted to AND an independent chain index, after the full probe \
window. The broadcaster dropped it — most likely error 465 \"fee too low\", \
because a monitor-less wallet presented a deep unconfirmed BEEF and ARC \
charged the fee for the whole unconfirmed package. The funds were NOT sent. \
Fetch merkle proofs for the confirmed ancestors (run `bsv-wallet tick` with \
CHAINTRACKS_URL set) or fund from a confirmed UTXO, then retry."
)),
BroadcastVerification::Confirmed | BroadcastVerification::Inconclusive => Ok(()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum NetworkEvidence {
Seen,
Mined,
}
impl NetworkEvidence {
pub fn memory_status(self) -> &'static str {
match self {
NetworkEvidence::Seen => bsv_wallet_toolbox::BROADCAST_STATUS_SEEN,
NetworkEvidence::Mined => bsv_wallet_toolbox::BROADCAST_STATUS_MINED,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChainIndexAnswer {
Present(NetworkEvidence),
Absent,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PresenceReport {
pub verification: BroadcastVerification,
pub evidence: Option<NetworkEvidence>,
pub evidence_provider: &'static str,
pub chain_index: ChainIndexAnswer,
pub broadcaster_fatal: bool,
pub network_absent: bool,
}
impl PresenceReport {
pub fn from_verification(verification: BroadcastVerification) -> Self {
Self {
verification,
evidence: None,
evidence_provider: BROADCAST_PROVIDER_NETWORK,
chain_index: ChainIndexAnswer::Unknown,
broadcaster_fatal: false,
network_absent: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Presence {
Held,
Present(NetworkEvidence),
Fatal,
Absent,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AbsenceAuthority {
None,
Broadcaster,
ChainIndex,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SourceKind {
Arcade,
ClassicArc,
ChainIndex,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct AbsenceVotes {
broadcaster: bool,
chain_index: bool,
}
impl AbsenceVotes {
fn record(&mut self, authority: AbsenceAuthority) {
match authority {
AbsenceAuthority::Broadcaster => self.broadcaster = true,
AbsenceAuthority::ChainIndex => self.chain_index = true,
AbsenceAuthority::None => {}
}
}
fn is_definitive(self) -> bool {
self.broadcaster && self.chain_index
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum BroadcastPlane {
ArcadeV2 { base: String },
ClassicArc { base: String },
}
impl BroadcastPlane {
fn resolve(chain: Chain, arcade_mode: bool, arc_url: Option<String>) -> Self {
let arc_url = arc_url
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if arcade_mode {
BroadcastPlane::ArcadeV2 {
base: normalize_base(&arc_url.unwrap_or_else(|| ARCADE_V2_MAINNET.to_string())),
}
} else {
BroadcastPlane::ClassicArc {
base: normalize_base(&arc_url.unwrap_or_else(|| taal_arc_url(chain).to_string())),
}
}
}
fn from_env(chain: Chain) -> Self {
Self::resolve(
chain,
crate::services_env::arcade_mode_enabled(),
std::env::var("ARC_URL").ok(),
)
}
fn base(&self) -> &str {
match self {
BroadcastPlane::ArcadeV2 { base } | BroadcastPlane::ClassicArc { base } => base,
}
}
fn name(&self) -> &'static str {
match self {
BroadcastPlane::ArcadeV2 { .. } => "broadcaster(arcade-v2)",
BroadcastPlane::ClassicArc { .. } => "broadcaster(arc)",
}
}
fn kind(&self) -> SourceKind {
match self {
BroadcastPlane::ArcadeV2 { .. } => SourceKind::Arcade,
BroadcastPlane::ClassicArc { .. } => SourceKind::ClassicArc,
}
}
fn status_template(&self) -> String {
match self {
BroadcastPlane::ArcadeV2 { base } => format!("{base}/tx/{{txid}}"),
BroadcastPlane::ClassicArc { base } => format!("{base}/v1/tx/{{txid}}"),
}
}
}
#[derive(Clone, Debug)]
struct StatusSource {
name: &'static str,
url_template: String,
auth: Option<String>,
absence: AbsenceAuthority,
kind: SourceKind,
}
fn build_sources(
chain: Chain,
plane: &BroadcastPlane,
taal_key: Option<String>,
) -> Vec<StatusSource> {
let mut sources = vec![StatusSource {
name: plane.name(),
url_template: plane.status_template(),
auth: match plane {
BroadcastPlane::ArcadeV2 { .. } => None,
BroadcastPlane::ClassicArc { .. } => taal_key.clone(),
},
absence: AbsenceAuthority::Broadcaster,
kind: plane.kind(),
}];
sources.push(StatusSource {
name: "whatsonchain",
url_template: format!("{}/tx/hash/{{txid}}", woc_base(chain)),
auth: None,
absence: AbsenceAuthority::ChainIndex,
kind: SourceKind::ChainIndex,
});
if let Some(gp) = gorillapool_arc_url(chain) {
if normalize_base(gp) != plane.base() {
sources.push(StatusSource {
name: "arc-gorillapool",
url_template: format!("{gp}/v1/tx/{{txid}}"),
auth: None,
absence: AbsenceAuthority::None,
kind: SourceKind::ClassicArc,
});
}
}
if let Some(key) = taal_key {
let taal = taal_arc_url(chain);
if normalize_base(taal) != plane.base() {
sources.push(StatusSource {
name: "arc-taal",
url_template: format!("{taal}/v1/tx/{{txid}}"),
auth: Some(key),
absence: AbsenceAuthority::None,
kind: SourceKind::ClassicArc,
});
}
}
sources
}
#[derive(Clone)]
pub struct BroadcastVerifier {
client: Client,
sources: Vec<StatusSource>,
attempts: u32,
delay: Duration,
enabled: bool,
}
impl BroadcastVerifier {
pub fn from_env(chain: Chain) -> Self {
let enabled = !env_truthy("BSV_WALLET_SKIP_BROADCAST_VERIFY");
let attempts = std::env::var("BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_ATTEMPTS);
let delay_ms = std::env::var("BSV_WALLET_BROADCAST_VERIFY_DELAY_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_DELAY_MS);
let taal_key = std::env::var("TAAL_API_KEY")
.ok()
.filter(|k| !k.is_empty())
.or_else(|| {
std::env::var("MAIN_TAAL_API_KEY")
.ok()
.filter(|k| !k.is_empty())
});
let plane = BroadcastPlane::from_env(chain);
tracing::debug!(plane = ?plane, "broadcast verifier plane");
Self {
client: Client::new(),
sources: build_sources(chain, &plane, taal_key),
attempts,
delay: Duration::from_millis(delay_ms),
enabled,
}
}
pub fn single_pass(chain: Chain) -> Self {
let mut v = Self::from_env(chain);
v.attempts = 1;
v.delay = Duration::ZERO;
v
}
fn absence_window(&self) -> Duration {
(1..self.attempts)
.map(|round| self.delay_before_round(round))
.sum::<Duration>()
+ PROBE_TIMEOUT
}
fn delay_before_round(&self, round: u32) -> Duration {
let exponent = round.saturating_sub(1).min(16);
let grown = Duration::from_millis(INITIAL_DELAY_MS.saturating_mul(1u64 << exponent));
grown.min(self.delay)
}
pub async fn verify(&self, txid: &str) -> BroadcastVerification {
self.verify_report(txid).await.verification
}
pub async fn verify_report(&self, txid: &str) -> PresenceReport {
let mut report = PresenceReport::from_verification(BroadcastVerification::Inconclusive);
if !self.enabled || self.sources.is_empty() {
return report;
}
let deadline = Instant::now() + self.absence_window();
let mut last: Option<RoundResult> = None;
for attempt in 0..self.attempts {
let mut round = RoundResult::default();
for src in &self.sources {
match probe(&self.client, src, txid).await {
Presence::Present(evidence) => {
if src.kind == SourceKind::ChainIndex {
report.verification = BroadcastVerification::Confirmed;
report.evidence = Some(evidence);
report.evidence_provider = BROADCAST_PROVIDER_CHAIN;
report.chain_index = ChainIndexAnswer::Present(evidence);
return report;
}
if src.absence == AbsenceAuthority::Broadcaster {
round.broadcaster_answered = true;
let provider = if src.kind == SourceKind::Arcade {
PROVIDER_ARCADE_V2
} else {
BROADCAST_PROVIDER_NETWORK
};
round.broadcaster_evidence = Some((evidence, provider));
} else {
round.third_party_evidence = Some(evidence);
}
}
Presence::Held => {
round.held = true;
if src.absence == AbsenceAuthority::Broadcaster {
round.broadcaster_answered = true;
}
}
Presence::Fatal => {
round.fatal = true;
round.broadcaster_answered = true;
round.votes.record(src.absence);
}
Presence::Absent => {
if src.absence == AbsenceAuthority::Broadcaster {
round.broadcaster_answered = true;
}
round.votes.record(src.absence);
}
Presence::Unknown => {}
}
}
let settled = round.held
|| round.broadcaster_evidence.is_some()
|| round.third_party_evidence.is_some();
last = Some(round);
if settled {
break;
}
if attempt + 1 < self.attempts {
if Instant::now() >= deadline {
break;
}
tokio::time::sleep(self.delay_before_round(attempt + 1)).await;
}
}
if let Some(round) = last {
report.broadcaster_fatal = round.fatal;
report.chain_index = if round.votes.chain_index {
ChainIndexAnswer::Absent
} else {
ChainIndexAnswer::Unknown
};
if let Some(evidence) = round.third_party_evidence {
report.evidence = Some(evidence);
report.evidence_provider = BROADCAST_PROVIDER_NETWORK;
} else if let Some((evidence, provider)) = round.broadcaster_evidence {
report.evidence = Some(evidence);
report.evidence_provider = provider;
}
report.network_absent = round.votes.chain_index
&& round.broadcaster_answered
&& round.third_party_evidence.is_none();
let held = round.held
|| round.broadcaster_evidence.is_some()
|| round.third_party_evidence.is_some();
report.verification = if held {
BroadcastVerification::Confirmed
} else if round.votes.is_definitive() {
BroadcastVerification::Rejected
} else {
BroadcastVerification::Inconclusive
};
}
report
}
#[allow(dead_code)]
pub fn explicit(arcade: bool, broadcaster_base: &str, chain_index_base: Option<&str>) -> Self {
let plane = if arcade {
BroadcastPlane::ArcadeV2 {
base: normalize_base(broadcaster_base),
}
} else {
BroadcastPlane::ClassicArc {
base: normalize_base(broadcaster_base),
}
};
let mut sources = vec![StatusSource {
name: plane.name(),
url_template: plane.status_template(),
auth: None,
absence: AbsenceAuthority::Broadcaster,
kind: plane.kind(),
}];
if let Some(base) = chain_index_base {
sources.push(StatusSource {
name: "chain-index",
url_template: format!("{}/tx/hash/{{txid}}", normalize_base(base)),
auth: None,
absence: AbsenceAuthority::ChainIndex,
kind: SourceKind::ChainIndex,
});
}
Self {
client: Client::new(),
sources,
attempts: 1,
delay: Duration::ZERO,
enabled: true,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
struct RoundResult {
votes: AbsenceVotes,
held: bool,
broadcaster_answered: bool,
fatal: bool,
broadcaster_evidence: Option<(NetworkEvidence, &'static str)>,
third_party_evidence: Option<NetworkEvidence>,
}
fn presence_of_body(src: &StatusSource, body: &str) -> Presence {
let json: Option<serde_json::Value> = serde_json::from_str(body).ok();
match src.kind {
SourceKind::ChainIndex => {
let confirmations = json
.as_ref()
.and_then(|v| v.get("confirmations"))
.and_then(|c| c.as_i64())
.unwrap_or(0);
if confirmations >= 1 {
Presence::Present(NetworkEvidence::Mined)
} else {
Presence::Present(NetworkEvidence::Seen)
}
}
SourceKind::Arcade | SourceKind::ClassicArc => {
let Some(tx_status) = json
.as_ref()
.and_then(|v| v.get("txStatus"))
.and_then(|s| s.as_str())
else {
return Presence::Held;
};
let status = match src.kind {
SourceKind::Arcade => BroadcastStatus::from_arcade_status(tx_status),
_ => BroadcastStatus::from_arc_status(tx_status),
};
match status {
BroadcastStatus::Seen => Presence::Present(NetworkEvidence::Seen),
BroadcastStatus::Mined => Presence::Present(NetworkEvidence::Mined),
BroadcastStatus::Rejected => {
if src.absence == AbsenceAuthority::Broadcaster {
Presence::Fatal
} else {
Presence::Unknown
}
}
BroadcastStatus::Accepted | BroadcastStatus::Unknown => Presence::Held,
}
}
}
}
async fn probe(client: &Client, src: &StatusSource, txid: &str) -> Presence {
let url = src.url_template.replace("{txid}", txid);
let mut req = client.get(&url).timeout(PROBE_TIMEOUT);
if let Some(auth) = &src.auth {
req = req.header("Authorization", auth);
}
match req.send().await {
Ok(resp) => {
let status = resp.status().as_u16();
match status {
200 => {
let body = resp.text().await.unwrap_or_default();
let presence = presence_of_body(src, &body);
tracing::debug!(source = src.name, ?presence, "broadcast probe");
presence
}
404 => {
if src.absence == AbsenceAuthority::Broadcaster && !is_json(&resp) {
tracing::debug!(
source = src.name,
url = %url,
"broadcaster 404 is not a JSON tx-status body — treating as \
route-not-found (check ARC_URL / path shape), not absence"
);
return Presence::Unknown;
}
Presence::Absent
}
other => {
tracing::debug!(
source = src.name,
status = other,
"broadcast probe inconclusive"
);
Presence::Unknown
}
}
}
Err(e) => {
tracing::debug!(source = src.name, error = %e, "broadcast probe request failed");
Presence::Unknown
}
}
}
fn is_json(resp: &reqwest::Response) -> bool {
resp.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|ct| ct.to_ascii_lowercase().contains("json"))
.unwrap_or(false)
}
fn normalize_base(url: &str) -> String {
url.trim().trim_end_matches('/').to_string()
}
fn taal_arc_url(chain: Chain) -> &'static str {
match chain {
Chain::Main => "https://arc.taal.com",
Chain::Test => "https://arc-test.taal.com",
}
}
fn gorillapool_arc_url(chain: Chain) -> Option<&'static str> {
match chain {
Chain::Main => Some("https://arc.gorillapool.io"),
Chain::Test => None,
}
}
fn woc_base(chain: Chain) -> &'static str {
match chain {
Chain::Main => "https://api.whatsonchain.com/v1/bsv/main",
Chain::Test => "https://api.whatsonchain.com/v1/bsv/test",
}
}
fn env_truthy(key: &str) -> bool {
std::env::var(key)
.map(|v| {
let v = v.trim().to_ascii_lowercase();
v == "1" || v == "true" || v == "yes" || v == "on"
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
use axum::routing::get;
use axum::Router;
use std::net::SocketAddr;
const TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001";
const SYNTHETIC_ARCADE: &str = "https://arcade.invalid";
const SYNTHETIC_ARC: &str = "https://arc.invalid";
const SYNTHETIC_KEY: &str = "test-key-not-a-real-credential";
#[tokio::test]
async fn single_pass_is_one_attempt_and_disabled_means_inconclusive() {
let v = BroadcastVerifier::single_pass(Chain::Main);
assert_eq!(v.attempts, 1);
assert_eq!(v.delay, Duration::ZERO);
let off = BroadcastVerifier {
enabled: false,
..v
};
assert_eq!(
off.verify(&"cd".repeat(32)).await,
BroadcastVerification::Inconclusive
);
}
#[test]
fn arcade_plane_uses_bare_tx_path_not_v1() {
let plane = BroadcastPlane::resolve(
Chain::Main,
true,
Some(SYNTHETIC_ARCADE.to_string()),
);
assert_eq!(
plane.status_template(),
format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
);
assert!(
!plane.status_template().contains("/v1/"),
"Arcade V2 must NOT be probed on the classic ARC /v1 path"
);
assert_eq!(plane.kind(), SourceKind::Arcade);
}
#[test]
fn classic_arc_plane_uses_v1_tx_path() {
let plane = BroadcastPlane::resolve(
Chain::Main,
false,
Some(SYNTHETIC_ARC.to_string()),
);
assert_eq!(
plane.status_template(),
format!("{SYNTHETIC_ARC}/v1/tx/{{txid}}")
);
assert_eq!(plane.kind(), SourceKind::ClassicArc);
}
#[test]
fn arcade_mode_defaults_to_the_arcade_endpoint_when_arc_url_is_unset() {
let plane = BroadcastPlane::resolve(Chain::Main, true, None);
assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
}
#[test]
fn classic_mode_defaults_to_taal_and_respects_chain() {
assert_eq!(
BroadcastPlane::resolve(Chain::Main, false, None).base(),
"https://arc.taal.com"
);
assert_eq!(
BroadcastPlane::resolve(Chain::Test, false, None).base(),
"https://arc-test.taal.com"
);
}
#[test]
fn empty_arc_url_falls_back_to_the_default_rather_than_an_empty_base() {
let plane = BroadcastPlane::resolve(Chain::Main, true, Some(" ".to_string()));
assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
}
#[test]
fn trailing_slash_in_arc_url_does_not_produce_a_double_slash() {
let plane = BroadcastPlane::resolve(
Chain::Main,
true,
Some(format!("{SYNTHETIC_ARCADE}/").to_string()),
);
assert_eq!(
plane.status_template(),
format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
);
}
#[test]
fn the_broadcaster_we_used_is_always_the_first_source_consulted() {
for plane in [
BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string())),
BroadcastPlane::resolve(Chain::Main, false, Some(SYNTHETIC_ARC.to_string())),
] {
let sources = build_sources(Chain::Main, &plane, None);
assert_eq!(sources[0].absence, AbsenceAuthority::Broadcaster);
assert_eq!(sources[0].kind, plane.kind());
assert!(
sources[0].url_template.starts_with(plane.base()),
"source 0 ({}) must be the configured broadcaster {}",
sources[0].url_template,
plane.base()
);
}
}
#[test]
fn arcade_broadcaster_probe_is_keyless_even_when_a_taal_key_exists() {
let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
assert!(sources[0].auth.is_none());
}
#[test]
fn classic_broadcaster_probe_carries_the_taal_key_when_present() {
let plane = BroadcastPlane::resolve(Chain::Main, false, None);
let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
assert_eq!(sources[0].auth.as_deref(), Some(SYNTHETIC_KEY));
}
#[test]
fn keyless_taal_is_not_probed_at_all() {
let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
let sources = build_sources(Chain::Main, &plane, None);
assert!(!sources.iter().any(|s| s.name == "arc-taal"));
}
#[test]
fn a_store_is_never_listed_twice_when_it_is_also_the_broadcaster() {
let plane = BroadcastPlane::resolve(
Chain::Main,
false,
Some("https://arc.gorillapool.io".to_string()),
);
let sources = build_sources(Chain::Main, &plane, None);
let gp_rows: Vec<_> = sources
.iter()
.filter(|s| s.url_template.contains("arc.gorillapool.io"))
.collect();
assert_eq!(gp_rows.len(), 1);
assert_eq!(gp_rows[0].absence, AbsenceAuthority::Broadcaster);
}
#[test]
fn a_third_party_arc_store_is_never_authoritative_for_absence() {
let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
for s in sources.iter().filter(|s| s.name.starts_with("arc-")) {
assert_eq!(
s.absence,
AbsenceAuthority::None,
"{} is not the broadcaster; its absence must carry no weight",
s.name
);
}
}
#[test]
fn whatsonchain_is_the_chain_index_authority() {
let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
let sources = build_sources(Chain::Main, &plane, None);
let woc = sources.iter().find(|s| s.name == "whatsonchain").unwrap();
assert_eq!(woc.absence, AbsenceAuthority::ChainIndex);
assert_eq!(woc.kind, SourceKind::ChainIndex);
}
#[test]
fn absence_is_definitive_only_when_broadcaster_and_chain_index_agree() {
let mut none = AbsenceVotes::default();
assert!(!none.is_definitive(), "no votes is not evidence");
none.record(AbsenceAuthority::None);
assert!(!none.is_definitive());
let mut broadcaster_only = AbsenceVotes::default();
broadcaster_only.record(AbsenceAuthority::Broadcaster);
assert!(
!broadcaster_only.is_definitive(),
"the primary may 404 while the tx went out through the failover provider"
);
let mut index_only = AbsenceVotes::default();
index_only.record(AbsenceAuthority::ChainIndex);
assert!(
!index_only.is_definitive(),
"a chain index can simply be lagging its mempool ingestion"
);
let mut both = AbsenceVotes::default();
both.record(AbsenceAuthority::Broadcaster);
both.record(AbsenceAuthority::ChainIndex);
assert!(both.is_definitive());
}
fn src_of(kind: SourceKind, absence: AbsenceAuthority) -> StatusSource {
StatusSource {
name: "test",
url_template: "http://127.0.0.1:1/tx/{txid}".to_string(),
auth: None,
absence,
kind,
}
}
#[test]
fn a_200_body_is_read_by_source_kind() {
let arcade = src_of(SourceKind::Arcade, AbsenceAuthority::Broadcaster);
assert_eq!(
presence_of_body(&arcade, r#"{"txid":"x","txStatus":"RECEIVED"}"#),
Presence::Held,
"a pre-gate status is held, not network evidence"
);
assert_eq!(
presence_of_body(&arcade, r#"{"txid":"x","txStatus":"ACCEPTED_BY_NETWORK"}"#),
Presence::Held
);
assert_eq!(
presence_of_body(&arcade, r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#),
Presence::Present(NetworkEvidence::Seen)
);
assert_eq!(
presence_of_body(&arcade, r#"{"txid":"x","txStatus":"MINED"}"#),
Presence::Present(NetworkEvidence::Mined)
);
assert_eq!(
presence_of_body(&arcade, r#"{"txid":"x","txStatus":"REJECTED"}"#),
Presence::Fatal
);
assert_eq!(
presence_of_body(&arcade, "{}"),
Presence::Held,
"a 200 without a readable status still means the store holds it"
);
assert_eq!(presence_of_body(&arcade, "not json"), Presence::Held);
let arc = src_of(SourceKind::ClassicArc, AbsenceAuthority::None);
assert_eq!(
presence_of_body(&arc, r#"{"txStatus":"SEEN_IN_ORPHAN_MEMPOOL"}"#),
Presence::Held,
"an orphan-pool hit is held: the node lacks the parent"
);
assert_eq!(
presence_of_body(&arc, r#"{"txStatus":"SEEN_ON_NETWORK"}"#),
Presence::Present(NetworkEvidence::Seen)
);
assert_eq!(
presence_of_body(&arc, r#"{"txStatus":"REJECTED"}"#),
Presence::Unknown,
"a third-party rejection of somebody's copy is no vote"
);
let woc = src_of(SourceKind::ChainIndex, AbsenceAuthority::ChainIndex);
assert_eq!(
presence_of_body(&woc, r#"{"txid":"x","confirmations":0}"#),
Presence::Present(NetworkEvidence::Seen)
);
assert_eq!(
presence_of_body(&woc, r#"{"txid":"x","confirmations":3}"#),
Presence::Present(NetworkEvidence::Mined)
);
assert_eq!(
presence_of_body(&woc, r#"{"txid":"x"}"#),
Presence::Present(NetworkEvidence::Seen)
);
}
async fn mock_status_server(code: StatusCode) -> String {
mock_status_server_full(code, Some("application/json"), "{}").await
}
async fn mock_status_server_ct(code: StatusCode, content_type: Option<&'static str>) -> String {
mock_status_server_full(code, content_type, "{}").await
}
async fn mock_status_server_body(body: &'static str) -> String {
mock_status_server_full(StatusCode::OK, Some("application/json"), body).await
}
async fn mock_status_server_full(
code: StatusCode,
content_type: Option<&'static str>,
body: &'static str,
) -> String {
let handler = move || async move {
let mut resp = axum::response::Response::new(axum::body::Body::from(body));
*resp.status_mut() = code;
if let Some(ct) = content_type {
resp.headers_mut()
.insert(reqwest::header::CONTENT_TYPE.as_str(), ct.parse().unwrap());
} else {
resp.headers_mut()
.remove(reqwest::header::CONTENT_TYPE.as_str());
}
resp
};
let app = Router::new()
.route("/tx/{txid}", get(handler))
.route("/v1/tx/{txid}", get(handler))
.route("/tx/hash/{txid}", get(handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
format!("http://{}", addr)
}
fn source(name: &'static str, base: &str, absence: AbsenceAuthority) -> StatusSource {
source_kind(name, base, absence, SourceKind::ClassicArc)
}
fn source_kind(
name: &'static str,
base: &str,
absence: AbsenceAuthority,
kind: SourceKind,
) -> StatusSource {
StatusSource {
name,
url_template: format!("{base}/tx/{{txid}}"),
auth: None,
absence,
kind,
}
}
fn verifier_with(sources: Vec<StatusSource>) -> BroadcastVerifier {
BroadcastVerifier {
client: Client::new(),
sources,
attempts: 2,
delay: Duration::from_millis(0),
enabled: true,
}
}
#[tokio::test]
async fn rejected_when_broadcaster_and_chain_index_both_report_absent() {
let base = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = verifier_with(vec![
source("broadcaster", &base, AbsenceAuthority::Broadcaster),
source("chain-index", &base, AbsenceAuthority::ChainIndex),
]);
let report = verifier.verify_report(TXID).await;
assert_eq!(report.verification, BroadcastVerification::Rejected);
assert!(report.network_absent);
assert!(!report.broadcaster_fatal);
assert_eq!(report.evidence, None);
assert!(
report.verification.into_send_result(TXID).is_err(),
"a Rejected verification must map to Err so the send fails loudly"
);
}
#[tokio::test]
async fn the_false_negative_that_motivated_this_fix_is_now_inconclusive() {
let absent = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = verifier_with(vec![
source(
"broadcaster",
"http://127.0.0.1:1",
AbsenceAuthority::Broadcaster,
),
source("chain-index", &absent, AbsenceAuthority::ChainIndex),
source("arc-third-party", &absent, AbsenceAuthority::None),
]);
let report = verifier.verify_report(TXID).await;
assert_eq!(report.verification, BroadcastVerification::Inconclusive);
assert!(
!report.network_absent,
"the absence clock does not run while the broadcaster is unreachable"
);
}
#[tokio::test]
async fn third_party_absence_alone_never_rejects() {
let base = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = verifier_with(vec![
source("arc-third-party-a", &base, AbsenceAuthority::None),
source("arc-third-party-b", &base, AbsenceAuthority::None),
]);
assert_eq!(
verifier.verify(TXID).await,
BroadcastVerification::Inconclusive
);
}
#[tokio::test]
async fn broadcaster_absence_alone_never_rejects() {
let absent = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = verifier_with(vec![
source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
source(
"chain-index",
"http://127.0.0.1:1",
AbsenceAuthority::ChainIndex,
),
]);
assert_eq!(
verifier.verify(TXID).await,
BroadcastVerification::Inconclusive
);
}
#[tokio::test]
async fn chain_index_absence_alone_never_rejects() {
let absent = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = verifier_with(vec![
source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
source("chain-index", &absent, AbsenceAuthority::ChainIndex),
]);
assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
let unauth = mock_status_server(StatusCode::UNAUTHORIZED).await;
let verifier = verifier_with(vec![
source("broadcaster", &unauth, AbsenceAuthority::Broadcaster),
source("chain-index", &absent, AbsenceAuthority::ChainIndex),
]);
assert_eq!(
verifier.verify(TXID).await,
BroadcastVerification::Inconclusive
);
}
#[tokio::test]
async fn presence_from_any_source_confirms_even_when_others_say_absent() {
let present = mock_status_server(StatusCode::OK).await;
let absent = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = verifier_with(vec![
source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
source("chain-index", &absent, AbsenceAuthority::ChainIndex),
source("arc-third-party", &present, AbsenceAuthority::None),
]);
let outcome = verifier.verify(TXID).await;
assert_eq!(outcome, BroadcastVerification::Confirmed);
assert!(outcome.into_send_result(TXID).is_ok());
}
#[tokio::test]
async fn confirmed_broadcast_succeeds() {
let base = mock_status_server(StatusCode::OK).await;
let verifier = verifier_with(vec![source(
"broadcaster",
&base,
AbsenceAuthority::Broadcaster,
)]);
let outcome = verifier.verify(TXID).await;
assert_eq!(outcome, BroadcastVerification::Confirmed);
assert!(outcome.into_send_result(TXID).is_ok());
}
#[tokio::test]
async fn unreachable_source_is_inconclusive_not_a_failure() {
let base = mock_status_server(StatusCode::SERVICE_UNAVAILABLE).await;
let verifier = verifier_with(vec![
source("broadcaster", &base, AbsenceAuthority::Broadcaster),
source("chain-index", &base, AbsenceAuthority::ChainIndex),
]);
let outcome = verifier.verify(TXID).await;
assert_eq!(outcome, BroadcastVerification::Inconclusive);
assert!(outcome.into_send_result(TXID).is_ok());
}
#[tokio::test]
async fn a_routing_404_from_the_broadcaster_is_not_absence() {
let text_404 = mock_status_server_ct(StatusCode::NOT_FOUND, Some("text/plain")).await;
let json_404 = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = verifier_with(vec![
source("broadcaster", &text_404, AbsenceAuthority::Broadcaster),
source("chain-index", &json_404, AbsenceAuthority::ChainIndex),
]);
assert_eq!(
verifier.verify(TXID).await,
BroadcastVerification::Inconclusive
);
}
#[tokio::test]
async fn disabled_verifier_is_inconclusive() {
let base = mock_status_server(StatusCode::NOT_FOUND).await;
let mut verifier = verifier_with(vec![
source("broadcaster", &base, AbsenceAuthority::Broadcaster),
source("chain-index", &base, AbsenceAuthority::ChainIndex),
]);
verifier.enabled = false;
assert_eq!(
verifier.verify(TXID).await,
BroadcastVerification::Inconclusive
);
}
#[tokio::test]
async fn seen_on_network_from_the_arcade_plane_is_network_evidence_for_arcade() {
let seen = mock_status_server_body(r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#).await;
let verifier = verifier_with(vec![source_kind(
"broadcaster",
&seen,
AbsenceAuthority::Broadcaster,
SourceKind::Arcade,
)]);
let report = verifier.verify_report(TXID).await;
assert_eq!(report.verification, BroadcastVerification::Confirmed);
assert_eq!(report.evidence, Some(NetworkEvidence::Seen));
assert_eq!(report.evidence_provider, PROVIDER_ARCADE_V2);
assert_eq!(report.chain_index, ChainIndexAnswer::Unknown);
assert!(!report.network_absent && !report.broadcaster_fatal);
}
#[tokio::test]
async fn a_broadcasters_seen_with_a_chain_index_miss_is_network_absent() {
let seen =
mock_status_server_body(r#"{"txid":"x","txStatus":"SEEN_MULTIPLE_NODES"}"#).await;
let absent = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = verifier_with(vec![
source_kind(
"broadcaster",
&seen,
AbsenceAuthority::Broadcaster,
SourceKind::Arcade,
),
source_kind(
"chain-index",
&absent,
AbsenceAuthority::ChainIndex,
SourceKind::ChainIndex,
),
]);
let report = verifier.verify_report(TXID).await;
assert_eq!(report.verification, BroadcastVerification::Confirmed);
assert_eq!(report.evidence, Some(NetworkEvidence::Seen));
assert_eq!(report.evidence_provider, PROVIDER_ARCADE_V2);
assert_eq!(report.chain_index, ChainIndexAnswer::Absent);
assert!(
report.network_absent,
"the chain index was asked and said no"
);
assert!(!report.broadcaster_fatal);
let explicit = BroadcastVerifier::explicit(true, &seen, Some(&absent));
assert_eq!(explicit.sources.len(), 2);
assert_eq!(explicit.sources[0].kind, SourceKind::Arcade);
assert_eq!(explicit.sources[1].kind, SourceKind::ChainIndex);
let report = explicit.verify_report(TXID).await;
assert!(report.network_absent);
assert_eq!(report.chain_index, ChainIndexAnswer::Absent);
}
#[tokio::test]
async fn a_peer_nodes_seen_blocks_the_absence() {
let held = mock_status_server_body(r#"{"txid":"x","txStatus":"RECEIVED"}"#).await;
let absent = mock_status_server(StatusCode::NOT_FOUND).await;
let peer = mock_status_server_body(r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#).await;
let verifier = verifier_with(vec![
source_kind(
"broadcaster",
&held,
AbsenceAuthority::Broadcaster,
SourceKind::Arcade,
),
source_kind(
"chain-index",
&absent,
AbsenceAuthority::ChainIndex,
SourceKind::ChainIndex,
),
source_kind(
"arc-third-party",
&peer,
AbsenceAuthority::None,
SourceKind::ClassicArc,
),
]);
let report = verifier.verify_report(TXID).await;
assert_eq!(report.verification, BroadcastVerification::Confirmed);
assert_eq!(report.evidence, Some(NetworkEvidence::Seen));
assert_eq!(report.evidence_provider, BROADCAST_PROVIDER_NETWORK);
assert_eq!(report.chain_index, ChainIndexAnswer::Absent);
assert!(!report.network_absent);
}
#[tokio::test]
async fn a_pre_gate_status_is_held_only_and_the_absence_clock_runs() {
let held = mock_status_server_body(r#"{"txid":"x","txStatus":"RECEIVED"}"#).await;
let absent = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = verifier_with(vec![
source_kind(
"broadcaster",
&held,
AbsenceAuthority::Broadcaster,
SourceKind::Arcade,
),
source_kind(
"chain-index",
&absent,
AbsenceAuthority::ChainIndex,
SourceKind::ChainIndex,
),
]);
let report = verifier.verify_report(TXID).await;
assert_eq!(report.verification, BroadcastVerification::Confirmed);
assert_eq!(report.evidence, None);
assert!(report.network_absent);
assert!(!report.broadcaster_fatal);
}
#[tokio::test]
async fn a_fatal_verdict_from_the_broadcaster_with_an_index_miss_is_rejected() {
let fatal = mock_status_server_body(r#"{"txid":"x","txStatus":"REJECTED"}"#).await;
let absent = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = verifier_with(vec![
source_kind(
"broadcaster",
&fatal,
AbsenceAuthority::Broadcaster,
SourceKind::Arcade,
),
source_kind(
"chain-index",
&absent,
AbsenceAuthority::ChainIndex,
SourceKind::ChainIndex,
),
]);
let report = verifier.verify_report(TXID).await;
assert_eq!(report.verification, BroadcastVerification::Rejected);
assert!(report.broadcaster_fatal);
assert!(report.network_absent);
let verifier = verifier_with(vec![
source_kind(
"broadcaster",
&fatal,
AbsenceAuthority::Broadcaster,
SourceKind::Arcade,
),
source_kind(
"chain-index",
"http://127.0.0.1:1",
AbsenceAuthority::ChainIndex,
SourceKind::ChainIndex,
),
]);
let report = verifier.verify_report(TXID).await;
assert_eq!(report.verification, BroadcastVerification::Inconclusive);
assert!(report.broadcaster_fatal);
assert!(!report.network_absent);
}
#[tokio::test]
async fn a_chain_index_hit_is_network_evidence_for_everyone() {
let held = mock_status_server_body(r#"{"txid":"x","txStatus":"SENT_TO_NETWORK"}"#).await;
let mined = mock_status_server_body(r#"{"txid":"x","confirmations":2}"#).await;
let verifier = verifier_with(vec![
source_kind(
"broadcaster",
&held,
AbsenceAuthority::Broadcaster,
SourceKind::Arcade,
),
source_kind(
"chain-index",
&mined,
AbsenceAuthority::ChainIndex,
SourceKind::ChainIndex,
),
]);
let report = verifier.verify_report(TXID).await;
assert_eq!(report.verification, BroadcastVerification::Confirmed);
assert_eq!(report.evidence, Some(NetworkEvidence::Mined));
assert_eq!(report.evidence_provider, BROADCAST_PROVIDER_CHAIN);
assert_eq!(
report.chain_index,
ChainIndexAnswer::Present(NetworkEvidence::Mined)
);
assert!(!report.network_absent);
}
#[tokio::test]
async fn a_third_party_rejection_alone_is_inconclusive() {
let fatal = mock_status_server_body(r#"{"txid":"x","txStatus":"REJECTED"}"#).await;
let verifier = verifier_with(vec![source_kind(
"arc-third-party",
&fatal,
AbsenceAuthority::None,
SourceKind::ClassicArc,
)]);
let report = verifier.verify_report(TXID).await;
assert_eq!(report.verification, BroadcastVerification::Inconclusive);
assert!(!report.broadcaster_fatal);
}
#[test]
fn absence_window_is_bounded_and_reflects_the_configured_rounds() {
let v = BroadcastVerifier {
client: Client::new(),
sources: vec![],
attempts: DEFAULT_ATTEMPTS,
delay: Duration::from_millis(DEFAULT_DELAY_MS),
enabled: true,
};
assert_eq!(
v.absence_window(),
Duration::from_millis(26_250) + PROBE_TIMEOUT
);
}
#[test]
fn probe_schedule_starts_short_grows_and_caps() {
let v = BroadcastVerifier {
client: Client::new(),
sources: vec![],
attempts: DEFAULT_ATTEMPTS,
delay: Duration::from_millis(DEFAULT_DELAY_MS),
enabled: true,
};
let gaps: Vec<u64> = (1..v.attempts)
.map(|r| v.delay_before_round(r).as_millis() as u64)
.collect();
assert_eq!(
gaps,
vec![250, 500, 1000, 2000, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500]
);
assert!(gaps.windows(2).all(|w| w[0] <= w[1]), "never shrinks");
assert!(
gaps.iter().all(|g| *g <= DEFAULT_DELAY_MS),
"never exceeds the cap"
);
let tight = BroadcastVerifier {
delay: Duration::from_millis(100),
..v
};
assert!(
(1..tight.attempts).all(|r| tight.delay_before_round(r) == Duration::from_millis(100))
);
let one = BroadcastVerifier::single_pass(Chain::Main);
assert_eq!(one.absence_window(), PROBE_TIMEOUT);
}
#[tokio::test]
async fn a_present_tx_is_confirmed_on_the_first_probe_without_waiting() {
let present = mock_status_server(StatusCode::OK).await;
let verifier = BroadcastVerifier {
client: Client::new(),
sources: vec![source(
"broadcaster",
&present,
AbsenceAuthority::Broadcaster,
)],
attempts: DEFAULT_ATTEMPTS,
delay: Duration::from_millis(DEFAULT_DELAY_MS),
enabled: true,
};
let started = std::time::Instant::now();
assert_eq!(
verifier.verify(TXID).await,
BroadcastVerification::Confirmed
);
assert!(
started.elapsed() < Duration::from_millis(INITIAL_DELAY_MS),
"took {:?}",
started.elapsed()
);
}
#[tokio::test]
async fn an_absent_tx_is_retried_on_the_growing_schedule() {
let absent = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = BroadcastVerifier {
client: Client::new(),
sources: vec![
source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
source("chain-index", &absent, AbsenceAuthority::ChainIndex),
],
attempts: 4,
delay: Duration::from_millis(200),
enabled: true,
};
assert!((1..4).all(|r| verifier.delay_before_round(r) == Duration::from_millis(200)));
let started = std::time::Instant::now();
assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
let elapsed = started.elapsed();
assert!(
elapsed >= Duration::from_millis(600) && elapsed < Duration::from_millis(2_000),
"took {:?}",
elapsed
);
}
}