use std::time::Duration;
use bsv_wallet_toolbox::Chain;
use reqwest::Client;
const DEFAULT_ATTEMPTS: u32 = 6;
const DEFAULT_DELAY_MS: u64 = 1500;
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
#[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 not present on the network. \
The broadcaster (ARC) 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)]
enum Presence {
Present,
Absent,
Unknown,
}
#[derive(Clone)]
struct StatusSource {
name: &'static str,
url_template: String,
auth: Option<String>,
authoritative_absence: bool,
}
#[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 mut sources = vec![
StatusSource {
name: "arc-taal",
url_template: format!("{}/v1/tx/{{txid}}", taal_arc_url(chain)),
auth: taal_key.clone(),
authoritative_absence: taal_key.is_some(),
},
StatusSource {
name: "whatsonchain",
url_template: format!("{}/tx/hash/{{txid}}", woc_base(chain)),
auth: None,
authoritative_absence: true,
},
];
if let Some(gp) = gorillapool_arc_url(chain) {
sources.push(StatusSource {
name: "arc-gorillapool",
url_template: format!("{}/v1/tx/{{txid}}", gp),
auth: None,
authoritative_absence: true,
});
}
Self {
client: Client::new(),
sources,
attempts,
delay: Duration::from_millis(delay_ms),
enabled,
}
}
pub async fn verify(&self, txid: &str) -> BroadcastVerification {
if !self.enabled || self.sources.is_empty() {
return BroadcastVerification::Inconclusive;
}
let mut last_round_saw_authoritative_absence = false;
for attempt in 0..self.attempts {
let mut any_present = false;
let mut authoritative_absence = false;
for src in &self.sources {
match probe(&self.client, src, txid).await {
Presence::Present => any_present = true,
Presence::Absent if src.authoritative_absence => authoritative_absence = true,
_ => {}
}
}
if any_present {
return BroadcastVerification::Confirmed;
}
last_round_saw_authoritative_absence = authoritative_absence;
if attempt + 1 < self.attempts {
tokio::time::sleep(self.delay).await;
}
}
if last_round_saw_authoritative_absence {
BroadcastVerification::Rejected
} else {
BroadcastVerification::Inconclusive
}
}
}
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) => match resp.status().as_u16() {
200 => Presence::Present,
404 => 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 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;
async fn mock_status_server(code: StatusCode) -> String {
let app = Router::new().route("/v1/tx/{txid}", get(move || async move { code }));
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 verifier_for(base: &str, authoritative_absence: bool) -> BroadcastVerifier {
BroadcastVerifier {
client: Client::new(),
sources: vec![StatusSource {
name: "mock",
url_template: format!("{}/v1/tx/{{txid}}", base),
auth: None,
authoritative_absence,
}],
attempts: 2,
delay: Duration::from_millis(0),
enabled: true,
}
}
const TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001";
#[tokio::test]
async fn rejected_broadcast_is_an_error_not_success() {
let base = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = verifier_for(&base, true);
let outcome = verifier.verify(TXID).await;
assert_eq!(
outcome,
BroadcastVerification::Rejected,
"an absent (404-everywhere) tx must be classified Rejected"
);
assert!(
outcome.into_send_result(TXID).is_err(),
"a Rejected verification must map to Err so the send fails loudly"
);
}
#[tokio::test]
async fn confirmed_broadcast_succeeds() {
let base = mock_status_server(StatusCode::OK).await;
let verifier = verifier_for(&base, true);
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_for(&base, true);
let outcome = verifier.verify(TXID).await;
assert_eq!(outcome, BroadcastVerification::Inconclusive);
assert!(outcome.into_send_result(TXID).is_ok());
}
#[tokio::test]
async fn non_authoritative_absence_is_inconclusive() {
let base = mock_status_server(StatusCode::NOT_FOUND).await;
let verifier = verifier_for(&base, false);
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_for(&base, true);
verifier.enabled = false;
assert_eq!(
verifier.verify(TXID).await,
BroadcastVerification::Inconclusive
);
}
}