use async_trait::async_trait;
use reqwest::header::{HeaderMap, HeaderValue};
use serde::Deserialize;
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>>,
}
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)
}
}
pub fn build_arc_headers(config: &ArcConfig) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
"Content-Type",
HeaderValue::from_static("application/octet-stream"),
);
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);
}
}
}
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()
}
}
}
#[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 url = format!("{}/v1/tx", self.base_url);
let headers = self.build_headers();
let result = self
.client
.post(&url)
.headers(headers)
.body(beef_bytes)
.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"
|| tx_status == "SEEN_IN_ORPHAN_MEMPOOL";
let tx_result = PostTxResultForTxid {
txid: arc_resp.txid,
status: if status_code.is_success() && !is_double_spend {
"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_double_spend {
Some(true)
} else {
None
},
};
let overall_status = if tx_result.status == "success" {
"success"
} else {
"error"
};
PostBeefResult {
name: self.name.clone(),
status: overall_status.to_string(),
error: if overall_status == "error" {
Some(format!("{} {}", tx_status, extra_info))
} else {
None
},
txid_results: vec![tx_result],
}
}
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),
})
.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),
})
.collect(),
}
}
}
}
}