use async_trait::async_trait;
use reqwest::header::{HeaderMap, HeaderValue};
use serde::Deserialize;
use serde_json;
use tracing;
use crate::services::traits::PostBeefProvider;
use crate::services::types::{ArcConfig, PostBeefResult, PostTxResultForTxid};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ArcResponse {
txid: String,
#[serde(default)]
extra_info: Option<String>,
#[serde(default)]
tx_status: Option<String>,
#[serde(default)]
competing_txs: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct ArcGetTxData {
#[serde(default)]
txid: String,
#[serde(default)]
tx_status: Option<String>,
#[serde(default)]
block_hash: Option<String>,
#[serde(default)]
block_height: Option<u32>,
#[serde(default)]
competing_txs: Option<Vec<String>>,
#[serde(default)]
extra_info: Option<String>,
}
pub struct ArcProvider {
name: String,
base_url: String,
config: ArcConfig,
client: reqwest::Client,
}
impl ArcProvider {
pub fn new(name: &str, base_url: &str, config: ArcConfig, client: reqwest::Client) -> Self {
Self {
name: name.to_string(),
base_url: base_url.trim_end_matches('/').to_string(),
config,
client,
}
}
pub fn build_headers(&self) -> HeaderMap {
build_arc_headers(&self.config)
}
async fn get_tx_data(&self, txid: &str) -> Option<ArcGetTxData> {
let url = format!("{}/v1/tx/{}", self.base_url, txid);
let headers = self.build_headers();
match self
.client
.get(&url)
.headers(headers)
.timeout(std::time::Duration::from_secs(15))
.send()
.await
{
Ok(resp) if resp.status().is_success() => resp.json::<ArcGetTxData>().await.ok(),
Ok(resp) => {
tracing::debug!(txid = txid, status = %resp.status(), "get_tx_data: non-success status");
None
}
Err(e) => {
tracing::debug!(txid = txid, error = %e, "get_tx_data: request failed");
None
}
}
}
}
pub fn build_arc_headers(config: &ArcConfig) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
headers.insert(
"XDeployment-ID",
HeaderValue::from_str(&config.deployment_id)
.unwrap_or_else(|_| HeaderValue::from_static("wallet-toolbox")),
);
if let Some(ref api_key) = config.api_key {
if !api_key.is_empty() {
if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", api_key)) {
headers.insert("Authorization", val);
}
}
}
if let Some(ref callback_url) = config.callback_url {
if !callback_url.is_empty() {
if let Ok(val) = HeaderValue::from_str(callback_url) {
headers.insert("X-CallbackUrl", val);
}
}
}
if let Some(ref callback_token) = config.callback_token {
if !callback_token.is_empty() {
if let Ok(val) = HeaderValue::from_str(callback_token) {
headers.insert("X-CallbackToken", val);
}
}
}
if let Some(ref custom_headers) = config.headers {
for (key, value) in custom_headers {
if let Ok(val) = HeaderValue::from_str(value) {
if let Ok(name) = reqwest::header::HeaderName::from_bytes(key.as_bytes()) {
headers.insert(name, val);
}
}
}
}
headers
}
const BEEF_V2_BYTES: [u8; 4] = [0x02, 0x00, 0xBE, 0xEF];
pub fn maybe_downgrade_beef_v2(beef: &[u8]) -> Vec<u8> {
if beef.len() < 4 {
return beef.to_vec();
}
if beef[0..4] != BEEF_V2_BYTES {
return beef.to_vec();
}
let mut cursor = std::io::Cursor::new(beef);
match bsv::transaction::Beef::from_binary(&mut cursor) {
Ok(parsed_beef) => {
if parsed_beef.txs.iter().any(|btx| btx.is_txid_only()) {
return beef.to_vec();
}
let mut downgraded = parsed_beef;
downgraded.version = bsv::transaction::beef::BEEF_V1;
let mut out = Vec::new();
match downgraded.to_binary(&mut out) {
Ok(()) => {
tracing::debug!(
"Downgraded BEEF V2 to V1 ({} bytes -> {} bytes)",
beef.len(),
out.len()
);
out
}
Err(e) => {
tracing::warn!("Failed to serialize downgraded BEEF: {}", e);
beef.to_vec()
}
}
}
Err(e) => {
tracing::warn!("Failed to parse BEEF for V2 downgrade check: {}", e);
beef.to_vec()
}
}
}
pub fn build_arc_post_body(beef_bytes: &[u8]) -> serde_json::Value {
let beef_hex = beef_bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
serde_json::json!({ "rawTx": beef_hex })
}
#[async_trait]
impl PostBeefProvider for ArcProvider {
fn name(&self) -> &str {
&self.name
}
async fn post_beef(&self, beef: &[u8], txids: &[String]) -> PostBeefResult {
let beef_bytes = maybe_downgrade_beef_v2(beef);
let json_body = build_arc_post_body(&beef_bytes);
let url = format!("{}/v1/tx", self.base_url);
let headers = self.build_headers();
let result = self
.client
.post(&url)
.headers(headers)
.json(&json_body)
.timeout(std::time::Duration::from_secs(30))
.send()
.await;
match result {
Ok(response) => {
let status_code = response.status();
match response.json::<ArcResponse>().await {
Ok(arc_resp) => {
let tx_status = arc_resp.tx_status.unwrap_or_default();
let extra_info = arc_resp.extra_info.unwrap_or_default();
let is_double_spend = tx_status == "DOUBLE_SPEND_ATTEMPTED";
let is_orphan_mempool = tx_status == "SEEN_IN_ORPHAN_MEMPOOL";
let is_rejection = is_double_spend || is_orphan_mempool;
let primary_result = PostTxResultForTxid {
txid: arc_resp.txid.clone(),
status: if status_code.is_success() && !is_rejection {
"success".to_string()
} else {
"error".to_string()
},
already_known: None,
double_spend: if is_double_spend { Some(true) } else { None },
block_hash: None,
block_height: None,
competing_txs: arc_resp.competing_txs,
service_error: if !status_code.is_success() && !is_rejection {
Some(true)
} else {
None
},
orphan_mempool: if is_orphan_mempool { Some(true) } else { None },
};
let mut overall_status = primary_result.status.clone();
let mut txid_results = vec![primary_result];
for txid in txids {
if *txid == arc_resp.txid {
continue;
}
let extra_result = if let Some(data) = self.get_tx_data(txid).await {
let status = match data.tx_status.as_deref() {
Some("SEEN_ON_NETWORK" | "STORED") => "success",
_ => "error",
};
if status == "error" && overall_status == "success" {
overall_status = "error".to_string();
}
PostTxResultForTxid {
txid: txid.clone(),
status: status.to_string(),
already_known: None,
double_spend: None,
block_hash: data.block_hash,
block_height: data.block_height,
competing_txs: data.competing_txs,
service_error: if status == "error" {
Some(true)
} else {
None
},
orphan_mempool: None,
}
} else {
overall_status = "error".to_string();
PostTxResultForTxid {
txid: txid.clone(),
status: "error".to_string(),
already_known: None,
double_spend: None,
block_hash: None,
block_height: None,
competing_txs: None,
service_error: Some(true),
orphan_mempool: None,
}
};
txid_results.push(extra_result);
}
PostBeefResult {
name: self.name.clone(),
status: overall_status,
error: if tx_status != "success"
&& !tx_status.is_empty()
&& (is_double_spend || !status_code.is_success())
{
Some(format!("{} {}", tx_status, extra_info))
} else {
None
},
txid_results,
}
}
Err(e) => {
tracing::warn!(
"[{}] Failed to parse ARC response (status {}): {}",
self.name,
status_code,
e
);
PostBeefResult {
name: self.name.clone(),
status: "error".to_string(),
error: Some(format!("HTTP {} - parse error: {}", status_code, e)),
txid_results: txids
.iter()
.map(|txid| PostTxResultForTxid {
txid: txid.clone(),
status: "error".to_string(),
already_known: None,
double_spend: None,
block_hash: None,
block_height: None,
competing_txs: None,
service_error: Some(true),
orphan_mempool: None,
})
.collect(),
}
}
}
}
Err(e) => {
tracing::warn!("[{}] ARC request failed: {}", self.name, e);
PostBeefResult {
name: self.name.clone(),
status: "error".to_string(),
error: Some(format!("Request failed: {}", e)),
txid_results: txids
.iter()
.map(|txid| PostTxResultForTxid {
txid: txid.clone(),
status: "error".to_string(),
already_known: None,
double_spend: None,
block_hash: None,
block_height: None,
competing_txs: None,
service_error: Some(true),
orphan_mempool: None,
})
.collect(),
}
}
}
}
}