use async_trait::async_trait;
use crate::transaction::{
BroadcastFailure, BroadcastResult, BroadcastStatus, Broadcaster, Transaction,
};
#[cfg_attr(not(feature = "http"), allow(dead_code))]
mod verdict {
use serde::Deserialize;
use serde_json::Value;
use crate::transaction::{
BroadcastFailure, BroadcastResponse, BroadcastResult, BroadcastStatus,
};
const ARC_FAILURE_STATUSES: [&str; 5] = [
"DOUBLE_SPEND_ATTEMPTED",
"REJECTED",
"INVALID",
"MALFORMED",
"MINED_IN_STALE_BLOCK",
];
const ARC_ACCEPTED_STATUSES: [&str; 12] = [
"SUCCESS",
"RECEIVED",
"QUEUED",
"STORED",
"ANNOUNCED_TO_NETWORK",
"REQUESTED_BY_NETWORK",
"SENT_TO_NETWORK",
"ACCEPTED_BY_NETWORK",
"SEEN_ON_NETWORK",
"MINED",
"CONFIRMED",
"IMMUTABLE",
];
const MAX_ARC_STATUS_BYTES: usize = 128;
const MAX_ARC_INFO_BYTES: usize = 8192;
const MAX_COMPETING_TXS: usize = 256;
#[derive(Debug, Default, Deserialize)]
pub(crate) struct ArcTxResponse {
pub(crate) txid: Option<String>,
#[serde(rename = "txStatus")]
pub(crate) tx_status: Option<String>,
#[serde(rename = "extraInfo")]
pub(crate) extra_info: Option<String>,
#[serde(rename = "competingTxs")]
pub(crate) competing_txs: Option<Value>,
pub(crate) status: Option<u16>,
pub(crate) title: Option<String>,
pub(crate) detail: Option<String>,
}
fn bounded_text(value: &str, max_bytes: usize, allow_empty: bool) -> bool {
(allow_empty || !value.is_empty())
&& value.len() <= max_bytes
&& !value
.chars()
.any(|c| matches!(c as u32, 0x00..=0x1f | 0x7f..=0x9f))
}
fn is_txid(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
}
fn snapshot_competing_txs(value: &Value) -> Option<Vec<String>> {
let list = value.as_array()?;
if list.len() > MAX_COMPETING_TXS {
return None;
}
let mut result: Vec<String> = Vec::with_capacity(list.len());
for entry in list {
let candidate = entry.as_str()?;
if !is_txid(candidate) {
return None;
}
let normalized = candidate.to_ascii_lowercase();
if result.contains(&normalized) {
return None;
}
result.push(normalized);
}
Some(result)
}
fn failure(code: &str, txid: &str, description: &str) -> BroadcastFailure {
BroadcastFailure {
status: BroadcastStatus::Error,
code: code.to_string(),
txid: Some(txid.to_string()),
description: description.to_string(),
more: None,
}
}
pub(crate) fn arc_success_verdict(body: ArcTxResponse, expected_txid: &str) -> BroadcastResult {
let invalid =
|description: &str| failure("ERR_INVALID_RESPONSE", expected_txid, description);
let tx_status = match body.tx_status {
Some(status) if bounded_text(&status, MAX_ARC_STATUS_BYTES, false) => status,
_ => return Err(invalid("ARC returned invalid transaction status metadata.")),
};
let extra_info = match body.extra_info {
Some(info) if !bounded_text(&info, MAX_ARC_INFO_BYTES, true) => {
return Err(invalid("ARC returned invalid transaction status metadata."))
}
other => other,
};
let competing_txs = match body.competing_txs {
None => None,
Some(value) => match snapshot_competing_txs(&value) {
Some(list) => Some(list),
None => {
return Err(invalid(
"ARC returned invalid competing transaction identifiers.",
))
}
},
};
let upper_status = tx_status.to_uppercase();
let is_orphan = upper_status.contains("ORPHAN")
|| extra_info
.as_deref()
.is_some_and(|info| info.to_uppercase().contains("ORPHAN"));
if ARC_FAILURE_STATUSES.contains(&upper_status.as_str()) || is_orphan {
if body.txid.as_deref().is_some_and(|echoed| {
is_txid(echoed) && !echoed.eq_ignore_ascii_case(expected_txid)
}) {
return Err(failure(
"ERR_TXID_MISMATCH",
expected_txid,
"ARC returned a failure for another transaction.",
));
}
let description = match extra_info.as_deref() {
Some(info) => format!("{} {}", tx_status, info).trim().to_string(),
None => tx_status.clone(),
};
let mut more = serde_json::Map::new();
if let Some(info) = extra_info.filter(|info| !info.is_empty()) {
more.insert("extraInfo".to_string(), Value::String(info));
}
if let Some(list) = competing_txs {
more.insert(
"competingTxs".to_string(),
Value::Array(list.into_iter().map(Value::String).collect()),
);
}
return Err(BroadcastFailure {
status: BroadcastStatus::Error,
code: tx_status,
txid: Some(expected_txid.to_string()),
description,
more: if more.is_empty() {
None
} else {
Some(Value::Object(more))
},
});
}
if !ARC_ACCEPTED_STATUSES.contains(&upper_status.as_str()) {
return Err(invalid("ARC returned an unknown transaction status."));
}
Ok(BroadcastResponse {
status: BroadcastStatus::Success,
txid: body.txid.unwrap_or_else(|| expected_txid.to_string()),
message: tx_status,
competing_txs,
})
}
}
#[derive(Debug, Clone)]
pub struct ArcConfig {
pub url: String,
pub api_key: Option<String>,
pub timeout_ms: u64,
}
impl Default for ArcConfig {
fn default() -> Self {
Self {
url: "https://arc.taal.com".to_string(),
api_key: None,
timeout_ms: 30_000,
}
}
}
pub struct ArcBroadcaster {
config: ArcConfig,
#[cfg(feature = "http")]
client: reqwest::Client,
}
impl Default for ArcBroadcaster {
fn default() -> Self {
Self::new("https://arc.taal.com", None)
}
}
impl ArcBroadcaster {
pub fn new(url: &str, api_key: Option<String>) -> Self {
Self {
config: ArcConfig {
url: url.to_string(),
api_key,
..Default::default()
},
#[cfg(feature = "http")]
client: reqwest::Client::new(),
}
}
pub fn with_config(config: ArcConfig) -> Self {
Self {
config,
#[cfg(feature = "http")]
client: reqwest::Client::new(),
}
}
pub fn url(&self) -> &str {
&self.config.url
}
pub fn api_key(&self) -> Option<&str> {
self.config.api_key.as_deref()
}
}
#[async_trait(?Send)]
impl Broadcaster for ArcBroadcaster {
#[cfg(feature = "http")]
async fn broadcast(&self, tx: &Transaction) -> BroadcastResult {
use serde::Serialize;
#[derive(Serialize)]
struct ArcRequest {
#[serde(rename = "rawTx")]
raw_tx: String,
}
let url = format!("{}/v1/tx", self.config.url);
let raw_tx = tx.to_hex();
let txid = tx.id();
let mut request = self
.client
.post(&url)
.header("Content-Type", "application/json")
.json(&ArcRequest { raw_tx });
if let Some(ref api_key) = self.config.api_key {
request = request.header("Authorization", format!("Bearer {}", api_key));
}
let response = request
.timeout(std::time::Duration::from_millis(self.config.timeout_ms))
.send()
.await
.map_err(|e| BroadcastFailure {
status: BroadcastStatus::Error,
code: "NETWORK_ERROR".to_string(),
txid: Some(txid.clone()),
description: format!("Network error: {}", e),
more: None,
})?;
let status_code = response.status();
let body: verdict::ArcTxResponse = response.json().await.map_err(|e| BroadcastFailure {
status: BroadcastStatus::Error,
code: "PARSE_ERROR".to_string(),
txid: Some(txid.clone()),
description: format!("Failed to parse response: {}", e),
more: None,
})?;
if status_code.is_success() {
verdict::arc_success_verdict(body, &txid)
} else {
Err(BroadcastFailure {
status: BroadcastStatus::Error,
code: body
.status
.map(|s| s.to_string())
.unwrap_or_else(|| "UNKNOWN".to_string()),
txid: Some(txid),
description: body
.detail
.or(body.title)
.unwrap_or_else(|| "Unknown error".to_string()),
more: body.extra_info.map(serde_json::Value::String),
})
}
}
#[cfg(not(feature = "http"))]
async fn broadcast(&self, tx: &Transaction) -> BroadcastResult {
Err(BroadcastFailure {
status: BroadcastStatus::Error,
code: "NO_HTTP".to_string(),
txid: Some(tx.id()),
description: "HTTP feature not enabled. Add 'http' feature to Cargo.toml".to_string(),
more: None,
})
}
}
#[cfg(test)]
mod tests {
use super::verdict::{arc_success_verdict, ArcTxResponse};
use super::*;
use serde_json::Value;
const TXID: &str = "4d76b00f29e480e0a933cef9d9ffe303d6ab919e2cdb265dd2cea41089baa85a";
fn body(json: Value) -> ArcTxResponse {
serde_json::from_value(json).expect("an ARC body")
}
#[test]
fn test_arc_config_default() {
let config = ArcConfig::default();
assert_eq!(config.url, "https://arc.taal.com");
assert!(config.api_key.is_none());
assert_eq!(config.timeout_ms, 30_000);
}
#[test]
fn test_arc_broadcaster_new() {
let broadcaster =
ArcBroadcaster::new("https://custom.arc.com", Some("api_key".to_string()));
assert_eq!(broadcaster.url(), "https://custom.arc.com");
assert_eq!(broadcaster.api_key(), Some("api_key"));
}
#[test]
fn test_arc_broadcaster_default() {
let broadcaster = ArcBroadcaster::default();
assert_eq!(broadcaster.url(), "https://arc.taal.com");
assert!(broadcaster.api_key().is_none());
}
#[test]
fn test_arc_broadcaster_with_config() {
let config = ArcConfig {
url: "https://test.arc.com".to_string(),
api_key: Some("test-key".to_string()),
timeout_ms: 60_000,
};
let broadcaster = ArcBroadcaster::with_config(config);
assert_eq!(broadcaster.url(), "https://test.arc.com");
assert_eq!(broadcaster.api_key(), Some("test-key"));
}
#[test]
fn a_rejected_tx_status_on_an_http_2xx_is_a_failure_with_the_status_as_code() {
let verdict = arc_success_verdict(
body(serde_json::json!({
"txid": TXID, "txStatus": "REJECTED", "extraInfo": "insufficient fee",
"competingTxs": null, "status": 200, "title": "OK"
})),
TXID,
);
let failure = verdict.expect_err("REJECTED on HTTP 200 is a failure");
assert_eq!(failure.status, BroadcastStatus::Error);
assert_eq!(failure.code, "REJECTED");
assert_eq!(failure.description, "REJECTED insufficient fee");
assert_eq!(failure.txid.as_deref(), Some(TXID));
assert_eq!(
failure.more,
Some(serde_json::json!({"extraInfo": "insufficient fee"}))
);
}
#[test]
fn a_2xx_with_no_tx_status_is_a_failure_not_a_success() {
let verdict = arc_success_verdict(
body(serde_json::json!({"txid": TXID, "status": 200, "title": "OK"})),
TXID,
);
let failure = verdict.expect_err("no txStatus is no verdict");
assert_eq!(failure.code, "ERR_INVALID_RESPONSE");
assert_eq!(
failure.description,
"ARC returned invalid transaction status metadata."
);
}
#[test]
fn double_spend_attempted_carries_the_competing_txs_into_the_failure() {
let competitor = "F67CFB4C12B221AB1B5FEF6FDB5A72F82EBC6AAD8F28C6D0E5427043A2C5B0A9";
let verdict = arc_success_verdict(
body(serde_json::json!({
"txid": TXID, "txStatus": "DOUBLE_SPEND_ATTEMPTED", "extraInfo": "",
"competingTxs": [competitor], "status": 200, "title": "OK"
})),
TXID,
);
let failure = verdict.expect_err("DOUBLE_SPEND_ATTEMPTED on HTTP 200 is a failure");
assert_eq!(failure.code, "DOUBLE_SPEND_ATTEMPTED");
assert_eq!(failure.description, "DOUBLE_SPEND_ATTEMPTED");
assert_eq!(
failure.more,
Some(serde_json::json!({"competingTxs": [competitor.to_ascii_lowercase()]}))
);
}
#[test]
fn an_accepted_tx_status_is_a_success_whose_message_is_the_status_word() {
let verdict = arc_success_verdict(
body(serde_json::json!({
"txid": TXID, "txStatus": "SEEN_ON_NETWORK", "extraInfo": "extra info",
"competingTxs": null, "status": 200, "title": "OK"
})),
TXID,
);
let response = verdict.expect("SEEN_ON_NETWORK is a success");
assert_eq!(response.status, BroadcastStatus::Success);
assert_eq!(response.txid, TXID);
assert_eq!(response.message, "SEEN_ON_NETWORK");
assert!(response.competing_txs.is_none());
}
#[test]
fn the_recorded_arc_bodies_replay_through_the_verdict() {
let vectors: Value = serde_json::from_str(include_str!(
"../../../tests/vectors/arc_tx_status_verdicts.json"
))
.expect("the vector parses");
let txid = vectors["txid"].as_str().expect("txid");
let cases = vectors["cases"].as_array().expect("cases");
assert!(!cases.is_empty());
let mut mismatches = Vec::new();
for case in cases {
let name = case["name"].as_str().expect("name");
let http_status = case["http_status"].as_u64().expect("http_status");
assert!(
(200..300).contains(&http_status),
"{name}: the verdict judges 2xx answers only"
);
let expect = &case["expect"];
let verdict = arc_success_verdict(body(case["body"].clone()), txid);
let why = match (expect["result"].as_str(), &verdict) {
(Some("success"), Ok(response)) => {
let competing: Option<Vec<String>> =
expect["competing_txs"].as_array().map(|list| {
list.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect()
});
if response.txid != txid
|| Some(response.message.as_str()) != expect["message"].as_str()
|| response.competing_txs != competing
{
Some(format!("success differs: {:?}", response))
} else {
None
}
}
(Some("failure"), Err(failure)) => {
let more = if expect["more"].is_null() {
None
} else {
Some(expect["more"].clone())
};
if Some(failure.code.as_str()) != expect["code"].as_str()
|| Some(failure.description.as_str()) != expect["description"].as_str()
|| failure.txid.as_deref() != Some(txid)
|| failure.more != more
{
Some(format!("failure differs: {:?}", failure))
} else {
None
}
}
(expected, got) => Some(format!("expected {:?}, got {:?}", expected, got)),
};
if let Some(why) = why {
mismatches.push(format!(" {name}: {why}"));
}
}
assert!(
mismatches.is_empty(),
"{} of {} recorded bodies get the wrong verdict:\n{}",
mismatches.len(),
cases.len(),
mismatches.join("\n")
);
}
}