mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
746 lines
27 KiB
Rust
746 lines
27 KiB
Rust
//! Bitcoin P2WPKH signing + broadcast. Uses the `bitcoin` crate plus the
|
|
//! Esplora REST API (https://blockstream.info/api) for UTXO discovery and
|
|
//! transaction broadcast. Tests can point at any URL via
|
|
//! `OPENHUMAN_WALLET_RPC_BTC`.
|
|
//!
|
|
//! Address derivation uses BIP84 (`m/84'/0'/0'/0/0` mainnet) so any wallet
|
|
//! seeded with a standard recovery phrase + this path produces a `bc1q…`
|
|
//! native segwit address.
|
|
|
|
use bitcoin::absolute::LockTime;
|
|
use bitcoin::bip32::{DerivationPath, Xpriv};
|
|
use bitcoin::hashes::Hash;
|
|
use bitcoin::key::{CompressedPublicKey, PrivateKey};
|
|
use bitcoin::secp256k1::{Message, Secp256k1};
|
|
use bitcoin::sighash::{EcdsaSighashType, SighashCache};
|
|
use bitcoin::transaction::Version;
|
|
use bitcoin::{
|
|
consensus::encode::serialize_hex, Address, Amount, Network, OutPoint, ScriptBuf, Sequence,
|
|
Transaction, TxIn, TxOut, Witness,
|
|
};
|
|
use log::debug;
|
|
use serde::Deserialize;
|
|
|
|
use crate::openhuman::config::rpc as config_rpc;
|
|
|
|
use super::super::defaults::{explorer_tx_url, rpc_url_for_chain};
|
|
use super::super::execution::{
|
|
ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, TxLookupInfo,
|
|
TxReceiptInfo, TxState, TxStatusInfo,
|
|
};
|
|
use super::super::ops::{secret_material, WalletChain};
|
|
use super::super::rpc::{rest_get_json, rest_get_text, rest_post_text};
|
|
|
|
const LOG_PREFIX: &str = "[wallet::btc]";
|
|
/// Hardcoded fee rate (sat/vbyte) used to estimate fees for prepared quotes
|
|
/// and to size the change output. Conservative — Bitcoin mempools cap out
|
|
/// around 50 sat/vB during congested periods; 20 keeps us in-range without
|
|
/// burning sats during quiet times.
|
|
const DEFAULT_FEE_RATE_SAT_VB: u64 = 20;
|
|
/// Approx vbytes of a 1-input, 2-output P2WPKH tx (in/out + witness).
|
|
const TYPICAL_TX_VBYTES: u64 = 141;
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct EsploraUtxo {
|
|
pub txid: String,
|
|
pub vout: u32,
|
|
pub value: u64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct EsploraAddressInfo {
|
|
chain_stats: EsploraAddressStats,
|
|
mempool_stats: EsploraAddressStats,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct EsploraAddressStats {
|
|
funded_txo_sum: u64,
|
|
spent_txo_sum: u64,
|
|
}
|
|
|
|
pub fn estimated_btc_fee_sats() -> u64 {
|
|
DEFAULT_FEE_RATE_SAT_VB * TYPICAL_TX_VBYTES
|
|
}
|
|
|
|
/// Generic BTC address validation — any well-formed mainnet address is OK.
|
|
/// Used for recipients (we don't care what address type they prefer; the
|
|
/// `bitcoin` crate's script_pubkey() will encode P2WPKH/P2TR/P2SH correctly).
|
|
pub fn validate_btc_address(addr: &str) -> Result<String, String> {
|
|
let trimmed = addr.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("BTC address is empty".to_string());
|
|
}
|
|
Address::from_str(trimmed)
|
|
.map_err(|e| format!("invalid BTC address '{trimmed}': {e}"))?
|
|
.require_network(Network::Bitcoin)
|
|
.map_err(|e| format!("BTC address '{trimmed}' is not mainnet: {e}"))?;
|
|
Ok(trimmed.to_string())
|
|
}
|
|
|
|
/// Sender-side validation — must be P2WPKH because we only know how to
|
|
/// derive + sign for native segwit (`bc1q…`). Recipients can be any type.
|
|
pub fn validate_btc_sender_address(addr: &str) -> Result<String, String> {
|
|
let trimmed = validate_btc_address(addr)?;
|
|
let parsed = Address::from_str(&trimmed)
|
|
.map_err(|e| format!("invalid BTC sender '{trimmed}': {e}"))?
|
|
.assume_checked();
|
|
if !parsed.script_pubkey().is_p2wpkh() {
|
|
return Err(format!(
|
|
"BTC sender '{trimmed}' is not P2WPKH (only bc1q… native segwit is supported for signing)"
|
|
));
|
|
}
|
|
Ok(trimmed)
|
|
}
|
|
|
|
use std::str::FromStr;
|
|
|
|
pub async fn native_balance(address: &str) -> Result<u128, String> {
|
|
validate_btc_address(address)?;
|
|
let base = rpc_url_for_chain(WalletChain::Btc);
|
|
let url = format!("{}/address/{}", base.trim_end_matches('/'), address);
|
|
let info: EsploraAddressInfo = rest_get_json(&url).await?;
|
|
let confirmed = info
|
|
.chain_stats
|
|
.funded_txo_sum
|
|
.saturating_sub(info.chain_stats.spent_txo_sum);
|
|
let pending = info
|
|
.mempool_stats
|
|
.funded_txo_sum
|
|
.saturating_sub(info.mempool_stats.spent_txo_sum);
|
|
Ok((confirmed + pending) as u128)
|
|
}
|
|
|
|
pub async fn fetch_utxos(address: &str) -> Result<Vec<EsploraUtxo>, String> {
|
|
let base = rpc_url_for_chain(WalletChain::Btc);
|
|
let url = format!("{}/address/{}/utxo", base.trim_end_matches('/'), address);
|
|
rest_get_json(&url).await
|
|
}
|
|
|
|
pub async fn broadcast_raw_hex(tx_hex: &str) -> Result<String, String> {
|
|
let base = rpc_url_for_chain(WalletChain::Btc);
|
|
let url = format!("{}/tx", base.trim_end_matches('/'));
|
|
rest_post_text(&url, tx_hex, "text/plain").await
|
|
}
|
|
|
|
/// Best-effort lookup of an address's spending scriptPubKey by parsing it.
|
|
fn script_pubkey_for_addr(addr: &str) -> Result<ScriptBuf, String> {
|
|
let parsed = Address::from_str(addr)
|
|
.map_err(|e| format!("invalid address '{addr}': {e}"))?
|
|
.require_network(Network::Bitcoin)
|
|
.map_err(|e| format!("address '{addr}' is not mainnet: {e}"))?;
|
|
Ok(parsed.script_pubkey())
|
|
}
|
|
|
|
/// Derive a P2WPKH PrivateKey for `derivation_path` from a BIP39 mnemonic.
|
|
fn derive_btc_private_key(
|
|
mnemonic: &str,
|
|
derivation_path: &str,
|
|
) -> Result<(PrivateKey, CompressedPublicKey), String> {
|
|
use coins_bip39::{English, Mnemonic};
|
|
let mnemonic: Mnemonic<English> = mnemonic
|
|
.trim()
|
|
.parse()
|
|
.map_err(|e| format!("invalid BIP39 mnemonic: {e}"))?;
|
|
let seed = mnemonic
|
|
.to_seed(None)
|
|
.map_err(|e| format!("failed to derive BIP39 seed: {e}"))?;
|
|
let xpriv = Xpriv::new_master(Network::Bitcoin, &seed)
|
|
.map_err(|e| format!("failed to derive BTC master key: {e}"))?;
|
|
let secp = Secp256k1::signing_only();
|
|
let path = DerivationPath::from_str(derivation_path)
|
|
.map_err(|e| format!("invalid BTC derivation path '{derivation_path}': {e}"))?;
|
|
let derived = xpriv
|
|
.derive_priv(&secp, &path)
|
|
.map_err(|e| format!("failed to derive BTC child key: {e}"))?;
|
|
let private_key = PrivateKey::new(derived.private_key, Network::Bitcoin);
|
|
let public_key = CompressedPublicKey::from_private_key(&secp, &private_key)
|
|
.map_err(|e| format!("failed to derive BTC public key: {e}"))?;
|
|
Ok((private_key, public_key))
|
|
}
|
|
|
|
/// Select UTXOs to cover `amount_sats + fee_sats`, returning the selected
|
|
/// set and the change. Simple greedy: largest-first.
|
|
fn select_utxos(
|
|
utxos: &[EsploraUtxo],
|
|
amount_sats: u64,
|
|
fee_sats: u64,
|
|
) -> Result<(Vec<EsploraUtxo>, u64), String> {
|
|
let mut sorted = utxos.to_vec();
|
|
sorted.sort_by(|a, b| b.value.cmp(&a.value));
|
|
let target = amount_sats
|
|
.checked_add(fee_sats)
|
|
.ok_or_else(|| "amount + fee overflow".to_string())?;
|
|
let mut total: u64 = 0;
|
|
let mut chosen = Vec::new();
|
|
for utxo in sorted {
|
|
total = total
|
|
.checked_add(utxo.value)
|
|
.ok_or_else(|| "utxo sum overflow".to_string())?;
|
|
chosen.push(utxo);
|
|
if total >= target {
|
|
return Ok((chosen, total - target));
|
|
}
|
|
}
|
|
Err(format!(
|
|
"insufficient BTC: have {total} sats, need {target} (amount {amount_sats} + fee {fee_sats})"
|
|
))
|
|
}
|
|
|
|
pub async fn execute_btc_quote(mut quote: PreparedTransaction) -> Result<ExecutionResult, String> {
|
|
if !matches!(quote.kind, PreparedKind::NativeTransfer) {
|
|
return Err(format!(
|
|
"BTC only supports native transfers; got kind {:?}",
|
|
quote.kind
|
|
));
|
|
}
|
|
let amount_sats: u64 = quote
|
|
.amount_raw
|
|
.parse()
|
|
.map_err(|e| format!("invalid BTC amount '{}': {e}", quote.amount_raw))?;
|
|
let from_addr = quote.from_address.clone();
|
|
let to_addr = quote.to_address.clone();
|
|
validate_btc_sender_address(&from_addr)?;
|
|
validate_btc_address(&to_addr)?;
|
|
|
|
let utxos = fetch_utxos(&from_addr).await?;
|
|
if utxos.is_empty() {
|
|
return Err(format!("no spendable UTXOs for {from_addr}"));
|
|
}
|
|
let fee_sats = estimated_btc_fee_sats();
|
|
let (selected, change_sats) = select_utxos(&utxos, amount_sats, fee_sats)?;
|
|
|
|
let secret = secret_material(WalletChain::Btc).await?;
|
|
let config = config_rpc::load_config_with_timeout().await?;
|
|
let mnemonic =
|
|
crate::openhuman::encryption::rpc::decrypt_secret(&config, &secret.encrypted_mnemonic)
|
|
.await?
|
|
.value;
|
|
let (private_key, public_key) = derive_btc_private_key(&mnemonic, &secret.derivation_path)?;
|
|
|
|
let from_spk = script_pubkey_for_addr(&from_addr)?;
|
|
let to_spk = script_pubkey_for_addr(&to_addr)?;
|
|
|
|
// Build inputs.
|
|
let mut tx_inputs = Vec::with_capacity(selected.len());
|
|
for utxo in &selected {
|
|
let txid = bitcoin::Txid::from_str(&utxo.txid)
|
|
.map_err(|e| format!("invalid utxo txid '{}': {e}", utxo.txid))?;
|
|
tx_inputs.push(TxIn {
|
|
previous_output: OutPoint {
|
|
txid,
|
|
vout: utxo.vout,
|
|
},
|
|
script_sig: ScriptBuf::new(),
|
|
sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
|
|
witness: Witness::default(),
|
|
});
|
|
}
|
|
// Outputs: recipient + (optional) change to self.
|
|
let mut tx_outputs = vec![TxOut {
|
|
value: Amount::from_sat(amount_sats),
|
|
script_pubkey: to_spk,
|
|
}];
|
|
if change_sats > 546 {
|
|
tx_outputs.push(TxOut {
|
|
value: Amount::from_sat(change_sats),
|
|
script_pubkey: from_spk.clone(),
|
|
});
|
|
}
|
|
|
|
let mut tx = Transaction {
|
|
version: Version::TWO,
|
|
lock_time: LockTime::ZERO,
|
|
input: tx_inputs,
|
|
output: tx_outputs,
|
|
};
|
|
|
|
// Sign each input (BIP143 segwit sighash).
|
|
let secp = Secp256k1::signing_only();
|
|
let mut sighash_cache = SighashCache::new(&mut tx);
|
|
let mut witnesses = Vec::with_capacity(selected.len());
|
|
for (idx, utxo) in selected.iter().enumerate() {
|
|
let sighash = sighash_cache
|
|
.p2wpkh_signature_hash(
|
|
idx,
|
|
&from_spk,
|
|
Amount::from_sat(utxo.value),
|
|
EcdsaSighashType::All,
|
|
)
|
|
.map_err(|e| format!("failed to compute BTC sighash: {e}"))?;
|
|
let msg = Message::from_digest(sighash.to_byte_array());
|
|
let sig = secp.sign_ecdsa(&msg, &private_key.inner);
|
|
let mut witness = Witness::new();
|
|
let mut sig_bytes = sig.serialize_der().to_vec();
|
|
sig_bytes.push(EcdsaSighashType::All as u8);
|
|
witness.push(sig_bytes);
|
|
witness.push(public_key.to_bytes());
|
|
witnesses.push(witness);
|
|
}
|
|
for (input, witness) in tx.input.iter_mut().zip(witnesses.into_iter()) {
|
|
input.witness = witness;
|
|
}
|
|
|
|
let tx_hex = serialize_hex(&tx);
|
|
let txid_hex = broadcast_raw_hex(&tx_hex).await?;
|
|
quote.estimated_fee_raw = fee_sats.to_string();
|
|
quote.status = PreparedStatus::Broadcasted;
|
|
debug!(
|
|
"{LOG_PREFIX} broadcast quote_id={} txid={} amount_sats={} change_sats={}",
|
|
quote.quote_id, txid_hex, amount_sats, change_sats
|
|
);
|
|
let explorer_url = explorer_tx_url(WalletChain::Btc, &txid_hex);
|
|
Ok(ExecutionResult {
|
|
quote_id: quote.quote_id.clone(),
|
|
status: PreparedStatus::Broadcasted,
|
|
chain: WalletChain::Btc,
|
|
evm_network: None,
|
|
transaction_hash: txid_hex,
|
|
explorer_url,
|
|
transaction: quote,
|
|
})
|
|
}
|
|
|
|
/// Esplora `/tx/:txid/status` → normalized status. Confirmations are derived
|
|
/// from the chain tip (`/blocks/tip/height`) when the tx is confirmed.
|
|
pub async fn tx_status(hash: &str) -> Result<TxStatusInfo, String> {
|
|
let base = rpc_url_for_chain(WalletChain::Btc);
|
|
let base = base.trim_end_matches('/');
|
|
let status: serde_json::Value = match rest_get_json(&format!("{base}/tx/{hash}/status")).await {
|
|
Ok(v) => v,
|
|
// Esplora returns 404 for unknown txids; surface as NotFound.
|
|
Err(e) if e.contains("status=404") => {
|
|
return Ok(TxStatusInfo {
|
|
chain: WalletChain::Btc,
|
|
evm_network: None,
|
|
hash: hash.to_string(),
|
|
state: TxState::NotFound,
|
|
confirmations: None,
|
|
block_number: None,
|
|
});
|
|
}
|
|
Err(e) => return Err(e),
|
|
};
|
|
let confirmed = status
|
|
.get("confirmed")
|
|
.and_then(serde_json::Value::as_bool)
|
|
.unwrap_or(false);
|
|
if !confirmed {
|
|
return Ok(TxStatusInfo {
|
|
chain: WalletChain::Btc,
|
|
evm_network: None,
|
|
hash: hash.to_string(),
|
|
state: TxState::Pending,
|
|
confirmations: Some(0),
|
|
block_number: None,
|
|
});
|
|
}
|
|
let block_number = status
|
|
.get("block_height")
|
|
.and_then(serde_json::Value::as_u64);
|
|
let confirmations = match block_number {
|
|
Some(bn) => {
|
|
let tip = rest_get_text(&format!("{base}/blocks/tip/height"))
|
|
.await
|
|
.ok();
|
|
tip.and_then(|t| t.trim().parse::<u64>().ok())
|
|
.map(|tip| tip.saturating_sub(bn).saturating_add(1))
|
|
}
|
|
None => None,
|
|
};
|
|
Ok(TxStatusInfo {
|
|
chain: WalletChain::Btc,
|
|
evm_network: None,
|
|
hash: hash.to_string(),
|
|
state: TxState::Confirmed,
|
|
confirmations,
|
|
block_number,
|
|
})
|
|
}
|
|
|
|
/// Esplora `/tx/:txid` → normalized receipt (fee + confirmed height).
|
|
pub async fn tx_receipt(hash: &str) -> Result<TxReceiptInfo, String> {
|
|
let base = rpc_url_for_chain(WalletChain::Btc);
|
|
let base = base.trim_end_matches('/');
|
|
let tx: serde_json::Value = match rest_get_json(&format!("{base}/tx/{hash}")).await {
|
|
Ok(v) => v,
|
|
Err(e) if e.contains("status=404") => {
|
|
return Ok(TxReceiptInfo {
|
|
chain: WalletChain::Btc,
|
|
evm_network: None,
|
|
hash: hash.to_string(),
|
|
found: false,
|
|
success: None,
|
|
block_number: None,
|
|
gas_used: None,
|
|
fee_raw: None,
|
|
raw: serde_json::Value::Null,
|
|
});
|
|
}
|
|
Err(e) => return Err(e),
|
|
};
|
|
let confirmed = tx
|
|
.get("status")
|
|
.and_then(|s| s.get("confirmed"))
|
|
.and_then(serde_json::Value::as_bool)
|
|
.unwrap_or(false);
|
|
let block_number = tx
|
|
.get("status")
|
|
.and_then(|s| s.get("block_height"))
|
|
.and_then(serde_json::Value::as_u64);
|
|
let fee_raw = tx
|
|
.get("fee")
|
|
.and_then(serde_json::Value::as_u64)
|
|
.map(|f| f.to_string());
|
|
// Leave `success` unset until the tx is confirmed — an unconfirmed mempool
|
|
// tx is pending (see tx_status), not a failure.
|
|
let success = if confirmed { Some(true) } else { None };
|
|
Ok(TxReceiptInfo {
|
|
chain: WalletChain::Btc,
|
|
evm_network: None,
|
|
hash: hash.to_string(),
|
|
found: true,
|
|
success,
|
|
block_number,
|
|
gas_used: None,
|
|
fee_raw,
|
|
raw: tx,
|
|
})
|
|
}
|
|
|
|
/// Esplora `/tx/:txid` → raw transaction passthrough.
|
|
pub async fn lookup_tx(hash: &str) -> Result<TxLookupInfo, String> {
|
|
let base = rpc_url_for_chain(WalletChain::Btc);
|
|
let base = base.trim_end_matches('/');
|
|
match rest_get_json::<serde_json::Value>(&format!("{base}/tx/{hash}")).await {
|
|
Ok(tx) => Ok(TxLookupInfo {
|
|
chain: WalletChain::Btc,
|
|
evm_network: None,
|
|
hash: hash.to_string(),
|
|
found: true,
|
|
raw: tx,
|
|
}),
|
|
Err(e) if e.contains("status=404") => Ok(TxLookupInfo {
|
|
chain: WalletChain::Btc,
|
|
evm_network: None,
|
|
hash: hash.to_string(),
|
|
found: false,
|
|
raw: serde_json::Value::Null,
|
|
}),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::openhuman::wallet::execution::{
|
|
insert_quote_for_test, now_ms, reset_quote_store_for_tests, PreparedKind, PreparedStatus,
|
|
PreparedTransaction,
|
|
};
|
|
use crate::openhuman::wallet::test_support::{sample_btc_address, setup_wallet_in, TEST_LOCK};
|
|
use axum::{
|
|
routing::{get, post},
|
|
Router,
|
|
};
|
|
use serde_json::json;
|
|
use std::sync::Arc;
|
|
use tempfile::TempDir;
|
|
use tokio::net::TcpListener;
|
|
|
|
#[test]
|
|
fn validate_btc_address_accepts_known_p2wpkh() {
|
|
// bech32 P2WPKH from BIP173 examples.
|
|
let addr = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4";
|
|
assert_eq!(validate_btc_address(addr).unwrap(), addr);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_btc_address_rejects_testnet() {
|
|
let err =
|
|
validate_btc_address("tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3")
|
|
.unwrap_err();
|
|
assert!(
|
|
err.contains("not mainnet") || err.contains("invalid"),
|
|
"got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_btc_sender_address_rejects_p2tr() {
|
|
// P2TR (bech32m, bc1p…) is a valid recipient but cannot be a sender —
|
|
// we only know how to sign P2WPKH inputs in this iteration.
|
|
let p2tr = "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr";
|
|
// Generic validation must accept it (recipients can be any type).
|
|
assert_eq!(validate_btc_address(p2tr).unwrap(), p2tr);
|
|
// Sender validation must reject it.
|
|
let err = validate_btc_sender_address(p2tr).unwrap_err();
|
|
assert!(err.contains("not P2WPKH"), "got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn select_utxos_largest_first_returns_change() {
|
|
let utxos = vec![
|
|
EsploraUtxo {
|
|
txid: "a".into(),
|
|
vout: 0,
|
|
value: 5000,
|
|
},
|
|
EsploraUtxo {
|
|
txid: "b".into(),
|
|
vout: 0,
|
|
value: 10_000,
|
|
},
|
|
EsploraUtxo {
|
|
txid: "c".into(),
|
|
vout: 0,
|
|
value: 1_000,
|
|
},
|
|
];
|
|
let (chosen, change) = select_utxos(&utxos, 6_000, 2_000).unwrap();
|
|
assert_eq!(chosen.len(), 1);
|
|
assert_eq!(chosen[0].txid, "b");
|
|
assert_eq!(change, 2_000);
|
|
}
|
|
|
|
#[test]
|
|
fn select_utxos_combines_multiple_when_needed() {
|
|
let utxos = vec![
|
|
EsploraUtxo {
|
|
txid: "a".into(),
|
|
vout: 0,
|
|
value: 5000,
|
|
},
|
|
EsploraUtxo {
|
|
txid: "b".into(),
|
|
vout: 0,
|
|
value: 5000,
|
|
},
|
|
EsploraUtxo {
|
|
txid: "c".into(),
|
|
vout: 0,
|
|
value: 5000,
|
|
},
|
|
];
|
|
let (chosen, change) = select_utxos(&utxos, 11_000, 1_000).unwrap();
|
|
assert_eq!(chosen.len(), 3);
|
|
assert_eq!(change, 3_000);
|
|
}
|
|
|
|
#[test]
|
|
fn select_utxos_errors_when_insufficient() {
|
|
let utxos = vec![EsploraUtxo {
|
|
txid: "a".into(),
|
|
vout: 0,
|
|
value: 1_000,
|
|
}];
|
|
let err = select_utxos(&utxos, 5_000, 1_000).unwrap_err();
|
|
assert!(err.contains("insufficient"), "got: {err}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_btc_quote_builds_psbt_signs_and_broadcasts() {
|
|
let _guard = TEST_LOCK.lock();
|
|
let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
reset_quote_store_for_tests();
|
|
let temp = TempDir::new().unwrap();
|
|
setup_wallet_in(&temp).await.unwrap();
|
|
|
|
// Mock state: collect raw tx hex posted to /tx.
|
|
let raw_txs: Arc<parking_lot::Mutex<Vec<String>>> =
|
|
Arc::new(parking_lot::Mutex::new(Vec::new()));
|
|
let raw_txs_clone = raw_txs.clone();
|
|
let from_addr = sample_btc_address().to_string();
|
|
// Real-shaped UTXO; value high enough to cover amount + fee.
|
|
let utxo_txid = "1111111111111111111111111111111111111111111111111111111111111111";
|
|
let utxo_json = json!([
|
|
{ "txid": utxo_txid, "vout": 0, "value": 100_000u64 }
|
|
]);
|
|
let utxo_clone = utxo_json.clone();
|
|
let app = Router::new()
|
|
.route(
|
|
"/address/{addr}/utxo",
|
|
get(move || {
|
|
let body = utxo_clone.clone();
|
|
async move { axum::Json(body) }
|
|
}),
|
|
)
|
|
.route(
|
|
"/tx",
|
|
post(move |body: String| {
|
|
let raw_txs = raw_txs_clone.clone();
|
|
async move {
|
|
raw_txs.lock().push(body);
|
|
// Return a known fake txid the test can assert on.
|
|
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
|
|
.to_string()
|
|
}
|
|
}),
|
|
);
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.unwrap();
|
|
});
|
|
std::env::set_var("OPENHUMAN_WALLET_RPC_BTC", format!("http://{addr}"));
|
|
|
|
let now = now_ms();
|
|
let quote = PreparedTransaction {
|
|
quote_id: "q_btc_native_1".to_string(),
|
|
kind: PreparedKind::NativeTransfer,
|
|
chain: WalletChain::Btc,
|
|
evm_network: None,
|
|
from_address: from_addr.clone(),
|
|
to_address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(),
|
|
asset_symbol: "BTC".to_string(),
|
|
amount_raw: "50000".to_string(),
|
|
amount_formatted: "0.00050000".to_string(),
|
|
receive_symbol: None,
|
|
min_receive_raw: None,
|
|
calldata: None,
|
|
token_address: None,
|
|
estimated_fee_raw: "5000".to_string(),
|
|
status: PreparedStatus::AwaitingConfirmation,
|
|
created_at_ms: now,
|
|
expires_at_ms: now + 60_000,
|
|
notes: vec![],
|
|
owner: None,
|
|
};
|
|
insert_quote_for_test(quote.clone());
|
|
|
|
let result = execute_btc_quote(quote).await.expect("btc broadcast ok");
|
|
assert_eq!(result.status, PreparedStatus::Broadcasted);
|
|
assert_eq!(
|
|
result.transaction_hash,
|
|
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
|
|
);
|
|
let raw = raw_txs.lock().clone();
|
|
assert_eq!(raw.len(), 1, "exactly one broadcast call");
|
|
let tx_hex = &raw[0];
|
|
assert!(!tx_hex.is_empty(), "tx hex must be non-empty");
|
|
// Witness-segwit transactions include the BIP141 marker+flag (0x0001).
|
|
assert!(
|
|
tx_hex.contains("0001"),
|
|
"expected segwit marker, got: {tx_hex}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_btc_quote_rejects_insufficient_utxos() {
|
|
let _guard = TEST_LOCK.lock();
|
|
let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
reset_quote_store_for_tests();
|
|
let temp = TempDir::new().unwrap();
|
|
setup_wallet_in(&temp).await.unwrap();
|
|
|
|
// Empty UTXO set — must error.
|
|
let app = Router::new().route(
|
|
"/address/{addr}/utxo",
|
|
get(|| async { axum::Json(json!([])) }),
|
|
);
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.unwrap();
|
|
});
|
|
std::env::set_var("OPENHUMAN_WALLET_RPC_BTC", format!("http://{addr}"));
|
|
|
|
let now = now_ms();
|
|
let quote = PreparedTransaction {
|
|
quote_id: "q_btc_native_empty".to_string(),
|
|
kind: PreparedKind::NativeTransfer,
|
|
chain: WalletChain::Btc,
|
|
evm_network: None,
|
|
from_address: sample_btc_address().to_string(),
|
|
to_address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(),
|
|
asset_symbol: "BTC".to_string(),
|
|
amount_raw: "50000".to_string(),
|
|
amount_formatted: "0.00050000".to_string(),
|
|
receive_symbol: None,
|
|
min_receive_raw: None,
|
|
calldata: None,
|
|
token_address: None,
|
|
estimated_fee_raw: "5000".to_string(),
|
|
status: PreparedStatus::AwaitingConfirmation,
|
|
created_at_ms: now,
|
|
expires_at_ms: now + 60_000,
|
|
notes: vec![],
|
|
owner: None,
|
|
};
|
|
let err = execute_btc_quote(quote).await.unwrap_err();
|
|
assert!(err.contains("no spendable UTXOs"), "got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn derive_btc_key_produces_known_p2wpkh_from_test_mnemonic() {
|
|
// BIP84 m/84'/0'/0'/0/0 from "abandon x11 about" → bc1qcr8...
|
|
// The compressed pubkey should serialize to 33 bytes.
|
|
let mnemonic =
|
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
|
let (pk, pubkey) = derive_btc_private_key(mnemonic, "m/84'/0'/0'/0/0").unwrap();
|
|
assert_eq!(pk.network, bitcoin::NetworkKind::Main);
|
|
assert_eq!(pubkey.to_bytes().len(), 33);
|
|
let secp = Secp256k1::signing_only();
|
|
let addr = Address::p2wpkh(&pubkey, Network::Bitcoin);
|
|
// Known good vector for this mnemonic + path:
|
|
assert_eq!(
|
|
addr.to_string(),
|
|
"bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"
|
|
);
|
|
let _ = secp; // suppress unused
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tx_status_confirmed_with_tip_confirmations() {
|
|
let _guard = TEST_LOCK.lock();
|
|
let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let app = Router::new()
|
|
.route(
|
|
"/tx/{txid}/status",
|
|
get(|| async {
|
|
axum::Json(json!({"confirmed": true, "block_height": 800_000u64}))
|
|
}),
|
|
)
|
|
.route("/blocks/tip/height", get(|| async { "800002" }));
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.unwrap();
|
|
});
|
|
std::env::set_var("OPENHUMAN_WALLET_RPC_BTC", format!("http://{addr}"));
|
|
let info = tx_status("deadbeef").await.unwrap();
|
|
assert_eq!(
|
|
info.state,
|
|
crate::openhuman::wallet::execution::TxState::Confirmed
|
|
);
|
|
assert_eq!(info.block_number, Some(800_000));
|
|
assert_eq!(info.confirmations, Some(3));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lookup_tx_not_found_on_404() {
|
|
let _guard = TEST_LOCK.lock();
|
|
let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let app = Router::new().route(
|
|
"/tx/{txid}",
|
|
get(|| async { (axum::http::StatusCode::NOT_FOUND, "Transaction not found") }),
|
|
);
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.unwrap();
|
|
});
|
|
std::env::set_var("OPENHUMAN_WALLET_RPC_BTC", format!("http://{addr}"));
|
|
let info = lookup_tx("deadbeef").await.unwrap();
|
|
assert!(!info.found);
|
|
}
|
|
}
|