use anyhow::{anyhow, Result};
use bsv_sdk::transaction::{Beef, MerklePath, Transaction};
use bsv_wallet_toolbox::WalletServices;
pub(crate) async fn deposit_bytes<V: WalletServices>(services: &V, txid: &str) -> Result<Vec<u8>> {
let found = services
.get_raw_tx(txid, false)
.await
.map_err(|e| anyhow!("could not look for deposit {txid}: {e}"))?;
if found.is_not_found() {
return Err(anyhow!(
"no such transaction: every explorer asked says it has no {txid}"
));
}
found.raw_tx.ok_or_else(|| {
anyhow!(
"could not look for deposit {txid}: {}",
found
.error
.unwrap_or_else(|| "no explorer gave an answer".to_string())
)
})
}
pub(crate) async fn broadcast_claim<V: WalletServices>(
services: &V,
deposit_raw: &[u8],
claim_raw: &[u8],
) -> Result<String> {
let deposit_txid = Transaction::from_binary(deposit_raw)
.map_err(|e| anyhow!("parse deposit: {e}"))?
.id();
let claim_txid = Transaction::from_binary(claim_raw)
.map_err(|e| anyhow!("parse claim: {e}"))?
.id();
let proof = match services.get_merkle_path(&deposit_txid, false).await {
Ok(found) => found
.merkle_path
.and_then(|hex| MerklePath::from_hex(&hex).ok()),
Err(_) => None,
};
let mut beef = Beef::new();
match proof {
Some(path) => {
let index = beef.merge_bump(path);
beef.merge_raw_tx(deposit_raw.to_vec(), Some(index));
}
None => {
beef.merge_raw_tx(deposit_raw.to_vec(), None);
}
}
beef.merge_raw_tx(claim_raw.to_vec(), None);
let results = services
.post_beef(&beef.to_binary(), std::slice::from_ref(&claim_txid))
.await
.map_err(|e| anyhow!("broadcast failed: {e}"))?;
if let Some(accepted) = results.iter().find(|r| r.is_success()) {
return Ok(accepted.name.clone());
}
let said: Vec<String> = results
.iter()
.map(|r| {
let detail = r
.error
.clone()
.or_else(|| r.txid_results.iter().find_map(|t| t.data.clone()))
.unwrap_or_else(|| r.status.clone());
format!("{}: {}", r.name, detail)
})
.collect();
Err(anyhow!(
"broadcast rejected by every broadcaster ({})",
if said.is_empty() {
"none answered".to_string()
} else {
said.join("; ")
}
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{p2pkh, raw_tx, txid_of};
use bsv_wallet_toolbox::services::mock::{
error_post_beef_result, MockErrorKind, MockResponse, MockWalletServices,
};
use bsv_wallet_toolbox::services::{GetMerklePathResult, GetRawTxResult};
#[test]
fn the_gift_commands_hold_no_explorer_and_no_http_client() {
for (name, source) in [
("gift_claim.rs", include_str!("gift_claim.rs")),
("gift_inspect.rs", include_str!("gift_inspect.rs")),
] {
let code = source.split("#[cfg(test)]").next().unwrap();
for word in ["reqwest", "woc_base", "api.whatsonchain.com", "/tx/raw"] {
assert!(!code.contains(word), "{name} names `{word}`");
}
}
}
fn raw_tx_answer(raw_tx: Option<Vec<u8>>, could_not_look: bool) -> MockWalletServices {
MockWalletServices::builder()
.get_raw_tx_response(MockResponse::Success(GetRawTxResult {
name: "Services".to_string(),
txid: String::new(),
raw_tx,
error: could_not_look.then(|| "WhatsOnChain: HTTP 500".to_string()),
could_not_look,
}))
.build()
}
#[tokio::test]
async fn the_deposits_bytes_come_through_the_wallets_services() {
let txid = "ab".repeat(32);
let held = raw_tx_answer(Some(vec![1, 2, 3]), false);
assert_eq!(deposit_bytes(&held, &txid).await.unwrap(), vec![1, 2, 3]);
assert_eq!(held.call_count("get_raw_tx"), 1);
let nowhere = raw_tx_answer(None, false);
let err = deposit_bytes(&nowhere, &txid).await.unwrap_err();
assert!(err.to_string().contains("no such transaction"), "{err}");
let down = raw_tx_answer(None, true);
let err = deposit_bytes(&down, &txid).await.unwrap_err();
assert!(err.to_string().contains("could not look"), "{err}");
assert!(!err.to_string().contains("no such transaction"), "{err}");
let fault = MockWalletServices::builder()
.get_raw_tx_error(MockErrorKind::NetworkError, "down")
.build();
let err = deposit_bytes(&fault, &txid).await.unwrap_err();
assert!(err.to_string().contains("could not look"), "{err}");
}
fn deposit_and_claim() -> (Vec<u8>, Vec<u8>) {
let lock = p2pkh([0xdb; 20]);
let deposit = raw_tx(
&[(&"11".repeat(32), 0)],
&[(1000, lock.clone()), (200, lock.clone())],
);
let deposit_txid = txid_of(&deposit);
let claim = raw_tx(&[(&deposit_txid, 0), (&deposit_txid, 1)], &[(1100, lock)]);
(deposit, claim)
}
#[tokio::test]
async fn the_claim_is_broadcast_through_the_wallets_broadcasters() {
let (deposit, claim) = deposit_and_claim();
let claim_txid = txid_of(&claim);
let services = MockWalletServices::builder()
.get_merkle_path_response(MockResponse::Success(GetMerklePathResult {
name: Some("Services".to_string()),
merkle_path: None,
header: None,
error: None,
notes: vec![],
}))
.build();
let provider = broadcast_claim(&services, &deposit, &claim).await.unwrap();
assert_eq!(provider, "MockProvider");
let calls = services.call_history();
let posts: Vec<_> = calls.iter().filter(|c| c.method == "post_beef").collect();
assert_eq!(posts.len(), 1);
assert!(
posts[0].args.iter().any(|a| a.contains(&claim_txid)),
"the claim is the subject: {:?}",
posts[0].args
);
}
#[tokio::test]
async fn a_mined_deposit_carries_its_checked_proof() {
let (deposit, claim) = deposit_and_claim();
let path = MerklePath::from_coinbase_txid(&txid_of(&deposit), 900_000).to_hex();
let services = MockWalletServices::builder()
.get_merkle_path_response(MockResponse::Success(GetMerklePathResult {
name: Some("Services".to_string()),
merkle_path: Some(path),
header: None,
error: None,
notes: vec![],
}))
.build();
broadcast_claim(&services, &deposit, &claim).await.unwrap();
assert_eq!(services.call_count("get_merkle_path"), 1);
assert_eq!(services.call_count("post_beef"), 1);
}
#[tokio::test]
async fn a_refused_claim_is_an_error_naming_the_refusal() {
let (deposit, claim) = deposit_and_claim();
let services = MockWalletServices::builder()
.post_beef_response(MockResponse::Success(vec![error_post_beef_result(
"Arcade",
"non-final",
)]))
.build();
let err = broadcast_claim(&services, &deposit, &claim)
.await
.unwrap_err();
assert!(err.to_string().contains("Arcade: non-final"), "{err}");
}
}