use anyhow::{anyhow, Context, Result};
use bsv_sdk::transaction::{Beef, Transaction};
use bsv_wallet_toolbox::services::GetBeefResult;
use bsv_wallet_toolbox::{Chain, WalletServices};
use std::future::Future;
use crate::brc29;
use crate::commands::fund;
use crate::context::WalletContext;
pub fn woc_base(chain: Chain) -> &'static str {
match chain {
Chain::Main => "https://api.whatsonchain.com/v1/bsv/main",
Chain::Test => "https://api.whatsonchain.com/v1/bsv/test",
}
}
pub async fn run(ctx: &WalletContext, txid: &str, vout: Option<u32>) -> Result<()> {
let (resolved_vout, accepted) = receive_txid(ctx, txid, vout).await?;
if ctx.json_output {
println!(
"{}",
serde_json::json!({
"txid": txid,
"vout": resolved_vout,
"accepted": accepted,
})
);
} else if accepted {
println!("Received tx {} vout {}", txid, resolved_vout);
} else {
println!("Transaction was not accepted");
}
Ok(())
}
pub(crate) async fn receive_txid(
ctx: &WalletContext,
txid: &str,
vout: Option<u32>,
) -> Result<(u32, bool)> {
let base = woc_base(ctx.chain);
let client = reqwest::Client::new();
let deposit_script = brc29::deposit_script(&ctx.root_key)?;
let services = ctx.wallet.services();
let (beef_bytes, resolved_vout) =
fetch_beef_and_vout(&client, base, txid, vout, &deposit_script, || {
second_courier(services, txid)
})
.await?;
let accepted = fund::internalize_beef(ctx, &beef_bytes, resolved_vout).await?;
Ok((resolved_vout, accepted))
}
async fn fetch_beef_and_vout<S, SF>(
client: &reqwest::Client,
base: &str,
txid: &str,
vout: Option<u32>,
deposit_script: &[u8],
second: S,
) -> Result<(Vec<u8>, u32)>
where
S: FnOnce() -> SF,
SF: Future<Output = Result<Vec<u8>>>,
{
let beef_bytes = match first_courier(client, base, txid).await {
Ok(bytes) => bytes,
Err(first) => {
tracing::warn!(
marker = "break_glass_beef_courier",
txid = %txid,
error = %first,
"the first BEEF courier did not deliver; asking the second"
);
second().await.map_err(|second| {
anyhow!(
"no courier delivered a BEEF for {txid} (could not look): \
WhatsOnChain: {first}; the wallet's services: {second}"
)
})?
}
};
let resolved_vout = match vout {
Some(v) => v,
None => vout_paying(&beef_bytes, txid, deposit_script)?,
};
Ok((beef_bytes, resolved_vout))
}
async fn first_courier(client: &reqwest::Client, base: &str, txid: &str) -> Result<Vec<u8>> {
let beef_hex = client
.get(format!("{}/tx/{}/beef", base, txid))
.send()
.await
.with_context(|| format!("WoC BEEF fetch failed for {}", txid))?
.error_for_status()?
.text()
.await?;
Ok(hex::decode(beef_hex.trim())?)
}
async fn second_courier<V: WalletServices>(services: &V, txid: &str) -> Result<Vec<u8>> {
let found = services
.get_beef(txid, &[])
.await
.map_err(|e| anyhow!("{e}"))?;
beef_from(found)
}
fn beef_from(found: GetBeefResult) -> Result<Vec<u8>> {
found.beef.ok_or_else(|| {
anyhow!(
"{}",
found
.error
.unwrap_or_else(|| "no BEEF and no reason given".to_string())
)
})
}
fn vout_paying(beef_bytes: &[u8], txid: &str, deposit_script: &[u8]) -> Result<u32> {
crate::atomic_beef::refuse_invalid_bytes(beef_bytes)?;
let beef = Beef::from_binary(beef_bytes).context("the BEEF does not parse")?;
let held = beef
.find_txid(txid)
.ok_or_else(|| anyhow!("the BEEF does not carry transaction {}", txid))?;
let tx = match (held.tx(), held.raw_tx()) {
(Some(tx), _) => tx.clone(),
(None, Some(raw)) => Transaction::from_binary(raw)
.with_context(|| format!("transaction {} in the BEEF does not parse", txid))?,
(None, None) => return Err(anyhow!("the BEEF carries only the txid of {}", txid)),
};
tx.outputs
.iter()
.position(|o| o.locking_script.to_binary() == deposit_script)
.map(|n| n as u32)
.ok_or_else(|| {
anyhow!(
"tx {} has no output to our deposit script {}",
txid,
hex::encode(deposit_script)
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{p2pkh, raw_tx, txid_of, Fixture};
use bsv_sdk::transaction::{Beef, MerklePath};
use bsv_wallet_toolbox::services::mock::MockWalletServices;
async fn no_second_courier() -> Result<Vec<u8>> {
panic!("the second courier was asked while the first delivered")
}
#[tokio::test]
async fn the_output_index_is_read_from_the_beefs_own_transaction() {
let ours = p2pkh([0xdb; 20]);
let raw = raw_tx(
&[(&"11".repeat(32), 0)],
&[(500, p2pkh([0x01; 20])), (1000, ours.clone())],
);
let txid = txid_of(&raw);
let mut beef = Beef::new();
let bump = beef.merge_bump(MerklePath::from_coinbase_txid(&txid, 900_000));
beef.merge_raw_tx(raw, Some(bump));
let beef_route = format!("/tx/{txid}/beef");
let tx_route = format!("/tx/{txid}");
let explorer = Fixture::start(&[(&beef_route, 200, &beef.to_hex())], 500).await;
let got = fetch_beef_and_vout(
&reqwest::Client::new(),
&explorer.base,
&txid,
None,
&ours,
no_second_courier,
)
.await;
assert_eq!(explorer.count(&tx_route), 0, "{:?}", explorer.hits());
let (_, vout) = got.expect("the BEEF carries the transaction");
assert_eq!(vout, 1);
assert_eq!(explorer.hits(), vec![beef_route.clone()]);
let (_, vout) = fetch_beef_and_vout(
&reqwest::Client::new(),
&explorer.base,
&txid,
Some(0),
&ours,
no_second_courier,
)
.await
.unwrap();
assert_eq!(vout, 0);
let err = vout_paying(&beef.to_binary(), &txid, &p2pkh([0x77; 20])).unwrap_err();
assert!(
err.to_string().contains("no output to our deposit script"),
"{err}"
);
let err = vout_paying(&beef.to_binary(), &"cd".repeat(32), &ours).unwrap_err();
assert!(
err.to_string().contains("does not carry transaction"),
"{err}"
);
}
#[tokio::test]
async fn a_courier_fault_falls_through_to_the_second_courier() {
let txid = "ab".repeat(32);
let explorer = Fixture::start(&[], 500).await;
let services = MockWalletServices::new();
let got = fetch_beef_and_vout(
&reqwest::Client::new(),
&explorer.base,
&txid,
Some(0),
&p2pkh([0xdb; 20]),
|| second_courier(&services, &txid),
)
.await;
assert!(
got.is_ok(),
"one courier's fault ended the command: {got:?}"
);
assert_eq!(explorer.total(), 1, "the first courier was asked once");
assert_eq!(services.call_count("get_beef"), 1);
}
#[tokio::test]
async fn the_second_courier_is_not_asked_while_the_first_delivers() {
let txid = "ab".repeat(32);
let route = format!("/tx/{txid}/beef");
let explorer = Fixture::start(&[(&route, 200, "0100beef")], 500).await;
let services = MockWalletServices::new();
let (bytes, _) = fetch_beef_and_vout(
&reqwest::Client::new(),
&explorer.base,
&txid,
Some(0),
&p2pkh([0xdb; 20]),
|| second_courier(&services, &txid),
)
.await
.unwrap();
assert_eq!(bytes, vec![0x01, 0x00, 0xbe, 0xef]);
assert_eq!(services.call_count("get_beef"), 0);
}
#[tokio::test]
async fn no_courier_delivering_is_could_not_look_with_both_reasons() {
let txid = "ab".repeat(32);
let explorer = Fixture::start(&[], 503).await;
let err = fetch_beef_and_vout(
&reqwest::Client::new(),
&explorer.base,
&txid,
Some(0),
&p2pkh([0xdb; 20]),
|| async { Err(anyhow!("both explorers down")) },
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("could not look"), "{err}");
assert!(err.contains("503"), "{err}");
assert!(err.contains("both explorers down"), "{err}");
let none = GetBeefResult {
name: "Services".to_string(),
txid: txid.clone(),
beef: None,
has_proof: false,
error: Some("Transaction not found".to_string()),
};
assert_eq!(
beef_from(none).unwrap_err().to_string(),
"Transaction not found"
);
}
#[test]
fn the_beef_courier_is_named_at_the_site() {
let source = include_str!("receive.rs");
let code = source.split("#[cfg(test)]").next().unwrap();
assert!(code.contains("Break-glass (Rule 28, C2): a COURIER"));
}
#[test]
fn the_couriers_beef_is_refused_for_invalid_bytes_at_their_offset() {
let ours = p2pkh([0xdb; 20]);
let mut raw = vec![1, 0, 0, 0, 0, 1];
raw.extend_from_slice(&1000u64.to_le_bytes());
raw.push(ours.len() as u8);
raw.extend_from_slice(&ours);
raw.extend_from_slice(&[0, 0, 0, 0]);
let mut h = bsv_sdk::primitives::sha256d(&raw).to_vec();
h.reverse();
let txid = hex::encode(h);
let mut beef = Beef::new();
let bump = beef.merge_bump(MerklePath::from_coinbase_txid(&txid, 900_000));
beef.merge_raw_tx(raw.clone(), Some(bump));
let bytes = beef.to_binary();
let at = bytes.windows(raw.len()).position(|w| w == raw).unwrap();
let text = vout_paying(&bytes, &txid, &ours)
.expect_err("a transaction with no input is invalid bytes")
.to_string();
assert!(
text.contains(&format!("Invalid BEEF at byte {at}")) && text.contains("NoInputs"),
"{text}"
);
let text = vout_paying(&bytes[..bytes.len() - 6], &txid, &ours)
.expect_err("cut bytes")
.to_string();
assert!(
text.contains("Invalid BEEF at byte") && text.contains("Truncated"),
"{text}"
);
}
}