use async_trait::async_trait;
use reqwest::Client;
use super::util::classify_reqwest_err;
use crate::transaction::broadcaster::{BroadcastFailure, BroadcastResponse, Broadcaster};
use crate::transaction::Transaction;
pub struct WhatsOnChainBroadcaster {
network: String,
client: Client,
}
impl WhatsOnChainBroadcaster {
pub fn new(network: &str) -> Self {
Self {
network: network.to_string(),
client: Client::new(),
}
}
fn base_url(&self) -> String {
format!("https://api.whatsonchain.com/v1/bsv/{}", self.network)
}
}
pub struct WhatsOnChainBroadcasterWithUrl {
network: String,
base_url: String,
client: Client,
}
impl WhatsOnChainBroadcasterWithUrl {
pub fn new(network: &str, base_url: &str) -> Self {
Self {
network: network.to_string(),
base_url: base_url.trim_end_matches('/').to_string(),
client: Client::new(),
}
}
}
async fn broadcast_to_woc_url(
client: &Client,
url: &str,
network: &str,
tx: &Transaction,
) -> Result<BroadcastResponse, BroadcastFailure> {
let raw_hex = tx.to_hex().map_err(|e| BroadcastFailure {
status: 0,
code: "SERIALIZE_ERROR".to_string(),
description: format!("failed to serialize transaction: {}", e),
..Default::default()
})?;
let response = client
.post(url)
.header("Accept", "text/plain")
.json(&serde_json::json!({ "txhex": raw_hex }))
.send()
.await
.map_err(|e| {
let (code, description) = classify_reqwest_err(&e);
BroadcastFailure {
status: 0,
code: code.to_string(),
description,
..Default::default()
}
})?;
let status = response.status().as_u16() as u32;
let body_text = response.text().await.map_err(|e| {
let (code, description) = classify_reqwest_err(&e);
BroadcastFailure {
status,
code: code.to_string(),
description: format!("failed to read WoC response body: {description}"),
..Default::default()
}
})?;
if status == 200 || status == 201 {
let txid = body_text.trim().trim_matches('"').to_string();
let is_valid_txid = txid.len() == 64 && txid.chars().all(|c| c.is_ascii_hexdigit());
if !is_valid_txid {
return Err(BroadcastFailure {
status,
code: "MALFORMED_SUCCESS_BODY".to_string(),
description: format!(
"WhatsOnChain ({network} {status}) 2xx body not a 64-char hex txid: {}",
truncate_for_preview(&body_text)
),
..Default::default()
});
}
Ok(BroadcastResponse {
status: "success".to_string(),
txid,
message: String::new(),
..Default::default()
})
} else {
Err(BroadcastFailure {
status,
code: status.to_string(),
description: truncate_for_preview(&body_text),
..Default::default()
})
}
}
#[async_trait]
impl Broadcaster for WhatsOnChainBroadcaster {
async fn broadcast(&self, tx: &Transaction) -> Result<BroadcastResponse, BroadcastFailure> {
let url = format!("{}/tx/raw", self.base_url());
broadcast_to_woc_url(&self.client, &url, &self.network, tx).await
}
}
#[async_trait]
impl Broadcaster for WhatsOnChainBroadcasterWithUrl {
async fn broadcast(&self, tx: &Transaction) -> Result<BroadcastResponse, BroadcastFailure> {
let url = format!("{}/v1/bsv/{}/tx/raw", self.base_url, self.network);
broadcast_to_woc_url(&self.client, &url, &self.network, tx).await
}
}
fn truncate_for_preview(s: &str) -> String {
const MAX_CHARS: usize = 4096;
if s.chars().count() <= MAX_CHARS {
return s.to_string();
}
s.chars().take(MAX_CHARS).collect()
}
pub async fn wait_for_visibility(txid: &str, max_wait_secs: u64) -> Result<(), BroadcastFailure> {
wait_for_visibility_against("https://api.whatsonchain.com", "main", txid, max_wait_secs).await
}
pub async fn wait_for_visibility_against(
base_url: &str,
network: &str,
txid: &str,
max_wait_secs: u64,
) -> Result<(), BroadcastFailure> {
let url = format!(
"{}/v1/bsv/{}/tx/{}",
base_url.trim_end_matches('/'),
network,
txid
);
let client = reqwest::Client::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(max_wait_secs);
let mut sleep_ms: u64 = 2_000;
let mut attempts: u32 = 0;
enum LastAttempt {
Status(u16),
NetworkError(String),
}
#[allow(unused_assignments)]
let mut last: Option<LastAttempt> = None;
loop {
attempts += 1;
match client.get(&url).send().await {
Ok(r) => {
let s = r.status().as_u16();
last = Some(LastAttempt::Status(s));
if s == 200 {
return Ok(());
}
if matches!(s, 400 | 401 | 403) {
return Err(BroadcastFailure {
status: s as u32,
code: format!("VISIBILITY_HTTP_{}", s),
description: format!(
"wait_for_visibility {}: HTTP {} after {} attempt(s); not retrying — caller-side errors aren't recoverable by waiting",
url, s, attempts
),
..Default::default()
});
}
}
Err(e) => {
last = Some(LastAttempt::NetworkError(e.to_string()));
}
}
let now = std::time::Instant::now();
if now >= deadline {
break;
}
let remaining = deadline.saturating_duration_since(now).as_millis() as u64;
let to_sleep = sleep_ms.min(remaining);
tokio::time::sleep(std::time::Duration::from_millis(to_sleep)).await;
sleep_ms = sleep_ms.saturating_mul(2);
}
let (last_status_for_failure, last_desc) = match &last {
Some(LastAttempt::Status(s)) => (*s as u32, format!("last status: {}", s)),
Some(LastAttempt::NetworkError(e)) => (0u32, format!("last network error: {}", e)),
None => (0u32, "no responses observed".to_string()),
};
Err(BroadcastFailure {
status: last_status_for_failure,
code: "NOT_VISIBLE".to_string(),
description: format!(
"tx {} not visible on WoC after {} attempts within {}s; {}",
txid, attempts, max_wait_secs, last_desc
),
..Default::default()
})
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers;
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_test_tx() -> Transaction {
Transaction::new()
}
#[tokio::test]
async fn test_woc_broadcast_success() {
let mock_server = MockServer::start().await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/v1/bsv/main/tx/raw"))
.respond_with(ResponseTemplate::new(200).set_body_string(
"\"a3f7d2e1b8c4506f9d2e3a4b5c6d7e8f9a0b1c2d3e4f5061728394a5b6c7d8e9\"",
))
.mount(&mock_server)
.await;
let broadcaster = WhatsOnChainBroadcasterWithUrl::new("main", &mock_server.uri());
let tx = make_test_tx();
let result = broadcaster.broadcast(&tx).await;
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(
resp.txid,
"a3f7d2e1b8c4506f9d2e3a4b5c6d7e8f9a0b1c2d3e4f5061728394a5b6c7d8e9"
);
assert_eq!(resp.status, "success");
}
#[tokio::test]
async fn test_woc_sends_accept_text_plain_header() {
let mock_server = MockServer::start().await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/v1/bsv/main/tx/raw"))
.and(matchers::header("Accept", "text/plain"))
.and(matchers::header("Content-Type", "application/json"))
.respond_with(ResponseTemplate::new(200).set_body_string(
"\"a3f7d2e1b8c4506f9d2e3a4b5c6d7e8f9a0b1c2d3e4f5061728394a5b6c7d8e9\"",
))
.mount(&mock_server)
.await;
let woc = WhatsOnChainBroadcasterWithUrl::new("main", &mock_server.uri());
let tx = make_test_tx();
let resp = woc.broadcast(&tx).await.expect("broadcast ok");
assert_eq!(
resp.txid,
"a3f7d2e1b8c4506f9d2e3a4b5c6d7e8f9a0b1c2d3e4f5061728394a5b6c7d8e9"
);
}
#[tokio::test]
async fn test_woc_2xx_with_non_hex_body_returns_malformed() {
let mock_server = MockServer::start().await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/v1/bsv/main/tx/raw"))
.respond_with(ResponseTemplate::new(200).set_body_string("\"error: invalid\""))
.mount(&mock_server)
.await;
let woc = WhatsOnChainBroadcasterWithUrl::new("main", &mock_server.uri());
let err = woc.broadcast(&make_test_tx()).await.unwrap_err();
assert_eq!(err.code, "MALFORMED_SUCCESS_BODY");
assert!(
err.description.contains("not a 64-char hex txid"),
"unexpected description: {}",
err.description
);
}
#[tokio::test]
async fn test_woc_broadcast_failure() {
let mock_server = MockServer::start().await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/v1/bsv/main/tx/raw"))
.respond_with(ResponseTemplate::new(400).set_body_string("Invalid transaction format"))
.mount(&mock_server)
.await;
let broadcaster = WhatsOnChainBroadcasterWithUrl::new("main", &mock_server.uri());
let tx = make_test_tx();
let result = broadcaster.broadcast(&tx).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.status, 400);
assert_eq!(err.code, "400");
assert_eq!(err.description, "Invalid transaction format");
}
#[tokio::test]
async fn test_wait_for_visibility_returns_ok_on_first_200() {
let mock_server = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/v1/bsv/main/tx/abc123"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let result = wait_for_visibility_against(&mock_server.uri(), "main", "abc123", 60).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_wait_for_visibility_errors_after_404_exhaustion() {
let mock_server = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/v1/bsv/main/tx/notfound"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
let result = wait_for_visibility_against(&mock_server.uri(), "main", "notfound", 3).await;
let err = result.unwrap_err();
assert_eq!(err.code, "NOT_VISIBLE");
assert_eq!(err.status, 404);
assert!(
err.description.contains("last status: 404"),
"expected description to mention 'last status: 404', got: {}",
err.description
);
}
#[tokio::test]
async fn test_wait_for_visibility_succeeds_mid_budget_after_404() {
let mock_server = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/v1/bsv/main/tx/midbudget"))
.respond_with(ResponseTemplate::new(404))
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/v1/bsv/main/tx/midbudget"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let result = wait_for_visibility_against(&mock_server.uri(), "main", "midbudget", 60).await;
assert!(
result.is_ok(),
"expected Ok after 404 → 200, got: {:?}",
result
);
}
#[tokio::test]
async fn test_wait_for_visibility_bails_fast_on_401() {
let mock_server = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/v1/bsv/main/tx/unauth"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let start = std::time::Instant::now();
let result = wait_for_visibility_against(&mock_server.uri(), "main", "unauth", 60).await;
let elapsed = start.elapsed();
let err = result.unwrap_err();
assert_eq!(err.status, 401);
assert_eq!(err.code, "VISIBILITY_HTTP_401");
assert!(
elapsed < std::time::Duration::from_secs(5),
"expected bail-fast under 5s, took {:?}",
elapsed
);
}
#[tokio::test]
async fn test_wait_for_visibility_all_network_errors_returns_zero_status() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral");
let addr = listener.local_addr().expect("local_addr");
drop(listener);
let dead_url = format!("http://{}", addr);
let result = wait_for_visibility_against(&dead_url, "main", "abc123", 1).await;
let err = result.unwrap_err();
assert_eq!(
err.status, 0,
"all-network-error branch must report status 0 (no HTTP \
response was ever received)"
);
assert_eq!(err.code, "NOT_VISIBLE");
assert!(
err.description.contains("last network error"),
"expected description to include 'last network error', got: {}",
err.description
);
}
}