use crate::bitcoin::timelocks::BlockHeight; use crate::bitcoin::{Address, Amount, Transaction}; use crate::env; use ::bitcoin::util::psbt::PartiallySignedTransaction; use ::bitcoin::Txid; use anyhow::{bail, Context, Result}; use bdk::blockchain::{noop_progress, Blockchain, ElectrumBlockchain}; use bdk::database::BatchDatabase; use bdk::descriptor::Segwitv0; use bdk::electrum_client::{ElectrumApi, GetHistoryRes}; use bdk::keys::DerivableKey; use bdk::wallet::AddressIndex; use bdk::{FeeRate, KeychainKind}; use bitcoin::{Network, Script}; use reqwest::Url; use std::collections::{BTreeMap, HashMap}; use std::convert::TryFrom; use std::fmt; use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{watch, Mutex}; const SLED_TREE_NAME: &str = "default_tree"; /// Assuming we add a spread of 3% we don't want to pay more than 3% of the /// amount for tx fees. const MAX_RELATIVE_TX_FEE: f64 = 0.03; const MAX_ABSOLUTE_TX_FEE: u64 = 100_000; pub struct Wallet { client: Arc>, wallet: Arc>>, finality_confirmations: u32, network: Network, target_block: usize, } impl Wallet { pub async fn new( electrum_rpc_url: Url, wallet_dir: &Path, key: impl DerivableKey + Clone, env_config: env::Config, target_block: usize, ) -> Result { let client = bdk::electrum_client::Client::new(electrum_rpc_url.as_str()) .context("Failed to initialize Electrum RPC client")?; let db = bdk::sled::open(wallet_dir)?.open_tree(SLED_TREE_NAME)?; let wallet = bdk::Wallet::new( bdk::template::Bip84(key.clone(), KeychainKind::External), Some(bdk::template::Bip84(key, KeychainKind::Internal)), env_config.bitcoin_network, db, ElectrumBlockchain::from(client), )?; let electrum = bdk::electrum_client::Client::new(electrum_rpc_url.as_str()) .context("Failed to initialize Electrum RPC client")?; let network = wallet.network(); Ok(Self { client: Arc::new(Mutex::new(Client::new( electrum, env_config.bitcoin_sync_interval(), )?)), wallet: Arc::new(Mutex::new(wallet)), finality_confirmations: env_config.bitcoin_finality_confirmations, network, target_block, }) } /// Broadcast the given transaction to the network and emit a log statement /// if done so successfully. /// /// Returns the transaction ID and a future for when the transaction meets /// the configured finality confirmations. pub async fn broadcast( &self, transaction: Transaction, kind: &str, ) -> Result<(Txid, Subscription)> { let txid = transaction.txid(); // to watch for confirmations, watching a single output is enough let subscription = self .subscribe_to((txid, transaction.output[0].script_pubkey.clone())) .await; self.wallet .lock() .await .broadcast(transaction) .with_context(|| { format!("Failed to broadcast Bitcoin {} transaction {}", kind, txid) })?; tracing::info!(%txid, "Published Bitcoin {} transaction", kind); Ok((txid, subscription)) } pub async fn sign_and_finalize(&self, psbt: PartiallySignedTransaction) -> Result { let (signed_psbt, finalized) = self.wallet.lock().await.sign(psbt, None)?; if !finalized { bail!("PSBT is not finalized") } let tx = signed_psbt.extract_tx(); Ok(tx) } pub async fn get_raw_transaction(&self, txid: Txid) -> Result { self.get_tx(txid) .await? .with_context(|| format!("Could not get raw tx with id: {}", txid)) } pub async fn status_of_script(&self, tx: &T) -> Result where T: Watchable, { self.client.lock().await.status_of_script(tx) } pub async fn subscribe_to(&self, tx: impl Watchable + Send + 'static) -> Subscription { let txid = tx.id(); let script = tx.script(); let sub = self .client .lock() .await .subscriptions .entry((txid, script.clone())) .or_insert_with(|| { let (sender, receiver) = watch::channel(ScriptStatus::Unseen); let client = self.client.clone(); tokio::spawn(async move { let mut last_status = None; loop { tokio::time::sleep(Duration::from_secs(5)).await; let new_status = match client.lock().await.status_of_script(&tx) { Ok(new_status) => new_status, Err(e) => { tracing::warn!(%txid, "Failed to get status of script: {:#}", e); return; } }; if Some(new_status) != last_status { tracing::debug!(%txid, "Transaction is {}", new_status); } last_status = Some(new_status); let all_receivers_gone = sender.send(new_status).is_err(); if all_receivers_gone { tracing::debug!(%txid, "All receivers gone, removing subscription"); client.lock().await.subscriptions.remove(&(txid, script)); return; } } }); Subscription { receiver, finality_confirmations: self.finality_confirmations, txid, } }) .clone(); sub } } /// Represents a subscription to the status of a given transaction. #[derive(Debug, Clone)] pub struct Subscription { receiver: watch::Receiver, finality_confirmations: u32, txid: Txid, } impl Subscription { pub async fn wait_until_final(&self) -> Result<()> { let conf_target = self.finality_confirmations; let txid = self.txid; tracing::info!(%txid, "Waiting for {} confirmation{} of Bitcoin transaction", conf_target, if conf_target > 1 { "s" } else { "" }); let mut seen_confirmations = 0; self.wait_until(|status| match status { ScriptStatus::Confirmed(inner) => { let confirmations = inner.confirmations(); if confirmations > seen_confirmations { tracing::info!(%txid, "Bitcoin tx has {} out of {} confirmation{}", confirmations, conf_target, if conf_target > 1 { "s" } else { "" }); seen_confirmations = confirmations; } inner.meets_target(conf_target) }, _ => false }) .await } pub async fn wait_until_seen(&self) -> Result<()> { self.wait_until(ScriptStatus::has_been_seen).await } pub async fn wait_until_confirmed_with(&self, target: T) -> Result<()> where u32: PartialOrd, T: Copy, { self.wait_until(|status| status.is_confirmed_with(target)) .await } async fn wait_until(&self, mut predicate: impl FnMut(&ScriptStatus) -> bool) -> Result<()> { let mut receiver = self.receiver.clone(); while !predicate(&receiver.borrow()) { receiver .changed() .await .context("Failed while waiting for next status update")?; } Ok(()) } } impl Wallet where C: EstimateFeeRate, D: BatchDatabase, { pub async fn balance(&self) -> Result { let balance = self .wallet .lock() .await .get_balance() .context("Failed to calculate Bitcoin balance")?; Ok(Amount::from_sat(balance)) } pub async fn new_address(&self) -> Result
{ let address = self .wallet .lock() .await .get_address(AddressIndex::New) .context("Failed to get new Bitcoin address")?; Ok(address) } pub async fn transaction_fee(&self, txid: Txid) -> Result { let fees = self .wallet .lock() .await .list_transactions(true)? .iter() .find(|tx| tx.txid == txid) .context("Could not find tx in bdk wallet when trying to determine fees")? .fees; Ok(Amount::from_sat(fees)) } pub async fn send_to_address( &self, address: Address, amount: Amount, ) -> Result { let wallet = self.wallet.lock().await; let client = self.client.lock().await; let fee_rate = client.estimate_feerate(self.target_block)?; let mut tx_builder = wallet.build_tx(); tx_builder.add_recipient(address.script_pubkey(), amount.as_sat()); tx_builder.fee_rate(fee_rate); let (psbt, _details) = tx_builder.finish()?; Ok(psbt) } /// Calculates the maximum "giveable" amount of this wallet. /// /// We define this as the maximum amount we can pay to a single output, /// already accounting for the fees we need to spend to get the /// transaction confirmed. pub async fn max_giveable(&self, locking_script_size: usize) -> Result { let wallet = self.wallet.lock().await; let client = self.client.lock().await; let fee_rate = client.estimate_feerate(self.target_block)?; let mut tx_builder = wallet.build_tx(); let dummy_script = Script::from(vec![0u8; locking_script_size]); tx_builder.set_single_recipient(dummy_script); tx_builder.drain_wallet(); tx_builder.fee_rate(fee_rate); let (_, details) = tx_builder.finish().context("Failed to build transaction")?; let max_giveable = details.sent - details.fees; Ok(Amount::from_sat(max_giveable)) } /// Estimate total tx fee for a pre-defined target block based on the /// transaction weight. The max fee cannot be more than MAX_PERCENTAGE_FEE /// of amount pub async fn estimate_fee( &self, weight: usize, transfer_amount: bitcoin::Amount, ) -> Result { let client = self.client.lock().await; let fee_rate = client.estimate_feerate(self.target_block)?; let min_relay_fee = client.min_relay_fee()?; tracing::debug!("Min relay fee: {}", min_relay_fee); Ok(estimate_fee( weight, transfer_amount, fee_rate, min_relay_fee, )) } } fn estimate_fee( weight: usize, transfer_amount: Amount, fee_rate: FeeRate, min_relay_fee: Amount, ) -> Amount { // Doing some heavy math here :) // `usize` is 32 or 64 bits wide, but `f32`'s mantissa is only 23 bits wide. // This is fine because such a big transaction cannot exist and there are also // no negative fees. #[allow( clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss )] let sats_per_vbyte = ((weight as f32) / 4.0 * fee_rate.as_sat_vb()) as u64; tracing::debug!( "Estimated fee for weight: {} for fee_rate: {:?} is in total: {}", weight, fee_rate, sats_per_vbyte ); // Similar as above: we do not care about fractional fees and have to cast a // couple of times. #[allow( clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss )] let max_allowed_fee = (transfer_amount.as_sat() as f64 * MAX_RELATIVE_TX_FEE).ceil() as u64; if sats_per_vbyte < min_relay_fee.as_sat() { tracing::warn!( "Estimated fee of {} is smaller than the min relay fee, defaulting to min relay fee {}", sats_per_vbyte, min_relay_fee.as_sat() ); min_relay_fee } else if sats_per_vbyte > max_allowed_fee && sats_per_vbyte > MAX_ABSOLUTE_TX_FEE { tracing::warn!( "Hard bound of transaction fees reached. Falling back to: {} sats", MAX_ABSOLUTE_TX_FEE ); bitcoin::Amount::from_sat(MAX_ABSOLUTE_TX_FEE) } else if sats_per_vbyte > max_allowed_fee { tracing::warn!( "Relative bound of transaction fees reached. Falling back to: {} sats", max_allowed_fee ); bitcoin::Amount::from_sat(max_allowed_fee) } else { bitcoin::Amount::from_sat(sats_per_vbyte) } } impl Wallet where B: Blockchain, D: BatchDatabase, { pub async fn get_tx(&self, txid: Txid) -> Result> { let tx = self.wallet.lock().await.client().get_tx(&txid)?; Ok(tx) } pub async fn sync(&self) -> Result<()> { self.wallet .lock() .await .sync(noop_progress(), None) .context("Failed to sync balance of Bitcoin wallet")?; Ok(()) } } impl Wallet { // TODO: Get rid of this by changing bounds on bdk::Wallet pub fn get_network(&self) -> bitcoin::Network { self.network } } pub trait EstimateFeeRate { fn estimate_feerate(&self, target_block: usize) -> Result; fn min_relay_fee(&self) -> Result; } #[cfg(test)] impl Wallet<(), bdk::database::MemoryDatabase, EFR> where EFR: EstimateFeeRate, { /// Creates a new, funded wallet to be used within tests. pub fn new_funded(amount: u64, estimate_fee_rate: EFR) -> Self { use bdk::database::MemoryDatabase; use bdk::{LocalUtxo, TransactionDetails}; use bitcoin::OutPoint; use std::str::FromStr; use testutils::testutils; let descriptors = testutils!(@descriptors ("wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/*)")); let mut database = MemoryDatabase::new(); bdk::populate_test_db!( &mut database, testutils! { @tx ( (@external descriptors, 0) => amount ) (@confirmations 1) }, Some(100) ); let wallet = bdk::Wallet::new_offline(&descriptors.0, None, Network::Regtest, database).unwrap(); Self { client: Arc::new(Mutex::new(estimate_fee_rate)), wallet: Arc::new(Mutex::new(wallet)), finality_confirmations: 1, network: Network::Regtest, target_block: 1, } } } /// Defines a watchable transaction. /// /// For a transaction to be watchable, we need to know two things: Its /// transaction ID and the specific output script that is going to change. /// A transaction can obviously have multiple outputs but our protocol purposes, /// we are usually interested in a specific one. pub trait Watchable { fn id(&self) -> Txid; fn script(&self) -> Script; } impl Watchable for (Txid, Script) { fn id(&self) -> Txid { self.0 } fn script(&self) -> Script { self.1.clone() } } pub struct Client { electrum: bdk::electrum_client::Client, latest_block: BlockHeight, last_ping: Instant, interval: Duration, script_history: BTreeMap>, subscriptions: HashMap<(Txid, Script), Subscription>, } impl Client { fn new(electrum: bdk::electrum_client::Client, interval: Duration) -> Result { let latest_block = electrum .block_headers_subscribe() .context("Failed to subscribe to header notifications")?; Ok(Self { electrum, latest_block: BlockHeight::try_from(latest_block)?, last_ping: Instant::now(), interval, script_history: Default::default(), subscriptions: Default::default(), }) } /// Ping the electrum server unless we already did within the set interval. /// /// Returns a boolean indicating whether we actually pinged the server. fn ping(&mut self) -> bool { if self.last_ping.elapsed() <= self.interval { return false; } match self.electrum.ping() { Ok(()) => { self.last_ping = Instant::now(); true } Err(error) => { tracing::debug!(?error, "Failed to ping electrum server"); false } } } fn drain_notifications(&mut self) -> Result<()> { let pinged = self.ping(); if !pinged { return Ok(()); } self.drain_blockheight_notifications()?; self.update_script_histories()?; Ok(()) } fn status_of_script(&mut self, tx: &T) -> Result where T: Watchable, { let txid = tx.id(); let script = tx.script(); if !self.script_history.contains_key(&script) { self.script_history.insert(script.clone(), vec![]); } self.drain_notifications()?; let history = self.script_history.entry(script).or_default(); let history_of_tx = history .iter() .filter(|entry| entry.tx_hash == txid) .collect::>(); match history_of_tx.as_slice() { [] => Ok(ScriptStatus::Unseen), [remaining @ .., last] => { if !remaining.is_empty() { tracing::warn!("Found more than a single history entry for script. This is highly unexpected and those history entries will be ignored.") } if last.height <= 0 { Ok(ScriptStatus::InMempool) } else { Ok(ScriptStatus::Confirmed( Confirmed::from_inclusion_and_latest_block( u32::try_from(last.height)?, u32::from(self.latest_block), ), )) } } } } fn drain_blockheight_notifications(&mut self) -> Result<()> { let latest_block = std::iter::from_fn(|| self.electrum.block_headers_pop().transpose()) .last() .transpose() .context("Failed to pop header notification")?; if let Some(new_block) = latest_block { tracing::debug!( "Got notification for new block at height {}", new_block.height ); self.latest_block = BlockHeight::try_from(new_block)?; } Ok(()) } fn update_script_histories(&mut self) -> Result<()> { let histories = self .electrum .batch_script_get_history(self.script_history.keys()) .context("Failed to get script histories")?; if histories.len() != self.script_history.len() { bail!( "Expected {} history entries, received {}", self.script_history.len(), histories.len() ); } let scripts = self.script_history.keys().cloned(); let histories = histories.into_iter(); self.script_history = scripts.zip(histories).collect::>(); Ok(()) } } impl EstimateFeeRate for Client { fn estimate_feerate(&self, target_block: usize) -> Result { // https://github.com/romanz/electrs/blob/f9cf5386d1b5de6769ee271df5eef324aa9491bc/src/rpc.rs#L213 // Returned estimated fees are per BTC/kb. let fee_per_byte = self.electrum.estimate_fee(target_block)?; // we do not expect fees being that high. #[allow(clippy::cast_possible_truncation)] Ok(FeeRate::from_btc_per_kvb(fee_per_byte as f32)) } fn min_relay_fee(&self) -> Result { // https://github.com/romanz/electrs/blob/f9cf5386d1b5de6769ee271df5eef324aa9491bc/src/rpc.rs#L219 // Returned fee is in BTC/kb let relay_fee = bitcoin::Amount::from_btc(self.electrum.relay_fee()?)?; Ok(relay_fee) } } #[derive(Debug, Copy, Clone, PartialEq)] pub enum ScriptStatus { Unseen, InMempool, Confirmed(Confirmed), } impl ScriptStatus { pub fn from_confirmations(confirmations: u32) -> Self { match confirmations { 0 => Self::InMempool, confirmations => Self::Confirmed(Confirmed::new(confirmations - 1)), } } } #[derive(Debug, Copy, Clone, PartialEq)] pub struct Confirmed { /// The depth of this transaction within the blockchain. /// /// Will be zero if the transaction is included in the latest block. depth: u32, } impl Confirmed { pub fn new(depth: u32) -> Self { Self { depth } } /// Compute the depth of a transaction based on its inclusion height and the /// latest known block. /// /// Our information about the latest block might be outdated. To avoid an /// overflow, we make sure the depth is 0 in case the inclusion height /// exceeds our latest known block, pub fn from_inclusion_and_latest_block(inclusion_height: u32, latest_block: u32) -> Self { let depth = latest_block.saturating_sub(inclusion_height); Self { depth } } pub fn confirmations(&self) -> u32 { self.depth + 1 } pub fn meets_target(&self, target: T) -> bool where u32: PartialOrd, { self.confirmations() >= target } } impl ScriptStatus { /// Check if the script has any confirmations. pub fn is_confirmed(&self) -> bool { matches!(self, ScriptStatus::Confirmed(_)) } /// Check if the script has met the given confirmation target. pub fn is_confirmed_with(&self, target: T) -> bool where u32: PartialOrd, { match self { ScriptStatus::Confirmed(inner) => inner.meets_target(target), _ => false, } } pub fn has_been_seen(&self) -> bool { matches!(self, ScriptStatus::InMempool | ScriptStatus::Confirmed(_)) } } impl fmt::Display for ScriptStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ScriptStatus::Unseen => write!(f, "unseen"), ScriptStatus::InMempool => write!(f, "in mempool"), ScriptStatus::Confirmed(inner) => { write!(f, "confirmed with {} blocks", inner.confirmations()) } } } } #[cfg(test)] mod tests { use super::*; use proptest::prelude::*; #[test] fn given_depth_0_should_meet_confirmation_target_one() { let script = ScriptStatus::Confirmed(Confirmed { depth: 0 }); let confirmed = script.is_confirmed_with(1); assert!(confirmed) } #[test] fn given_confirmations_1_should_meet_confirmation_target_one() { let script = ScriptStatus::from_confirmations(1); let confirmed = script.is_confirmed_with(1); assert!(confirmed) } #[test] fn given_inclusion_after_lastest_known_block_at_least_depth_0() { let included_in = 10; let latest_block = 9; let confirmed = Confirmed::from_inclusion_and_latest_block(included_in, latest_block); assert_eq!(confirmed.depth, 0) } #[test] fn given_one_BTC_and_100k_sats_per_vb_fees_should_not_hit_max() { // 400 weight = 100 vbyte let weight = 400; let amount = bitcoin::Amount::from_sat(100_000_000); let sat_per_vb = 100.0; let fee_rate = FeeRate::from_sat_per_vb(sat_per_vb); let relay_fee = bitcoin::Amount::ONE_SAT; let is_fee = estimate_fee(weight, amount, fee_rate, relay_fee); // weight / 4.0 * sat_per_vb let should_fee = bitcoin::Amount::from_sat(10_000); assert_eq!(is_fee, should_fee); } #[test] fn given_1BTC_and_1_sat_per_vb_fees_and_100ksat_min_relay_fee_should_hit_min() { // 400 weight = 100 vbyte let weight = 400; let amount = bitcoin::Amount::from_sat(100_000_000); let sat_per_vb = 1.0; let fee_rate = FeeRate::from_sat_per_vb(sat_per_vb); let relay_fee = bitcoin::Amount::from_sat(100_000); let is_fee = estimate_fee(weight, amount, fee_rate, relay_fee); // weight / 4.0 * sat_per_vb would be smaller than relay fee hence we take min // relay fee let should_fee = bitcoin::Amount::from_sat(100_000); assert_eq!(is_fee, should_fee); } #[test] fn given_1mio_sat_and_1k_sats_per_vb_fees_should_hit_relative_max() { // 400 weight = 100 vbyte let weight = 400; let amount = bitcoin::Amount::from_sat(1_000_000); let sat_per_vb = 1_000.0; let fee_rate = FeeRate::from_sat_per_vb(sat_per_vb); let relay_fee = bitcoin::Amount::ONE_SAT; let is_fee = estimate_fee(weight, amount, fee_rate, relay_fee); // weight / 4.0 * sat_per_vb would be greater than 3% hence we take max // relative fee. let should_fee = bitcoin::Amount::from_sat(30_000); assert_eq!(is_fee, should_fee); } #[test] fn given_1BTC_and_4mio_sats_per_vb_fees_should_hit_total_max() { // even if we send 1BTC we don't want to pay 0.3BTC in fees. This would be // $1,650 at the moment. let weight = 400; let amount = bitcoin::Amount::from_sat(100_000_000); let sat_per_vb = 4_000_000.0; let fee_rate = FeeRate::from_sat_per_vb(sat_per_vb); let relay_fee = bitcoin::Amount::ONE_SAT; let is_fee = estimate_fee(weight, amount, fee_rate, relay_fee); // weight / 4.0 * sat_per_vb would be greater than 3% hence we take total // max allowed fee. let should_fee = bitcoin::Amount::from_sat(MAX_ABSOLUTE_TX_FEE); assert_eq!(is_fee, should_fee); } proptest! { #[test] fn given_randon_amount_random_fee_and_random_relay_rate_but_fix_weight_does_not_panic( amount in prop::num::u64::ANY, sat_per_vb in prop::num::f32::POSITIVE, relay_fee in prop::num::u64::ANY ) { let weight = 400; let amount = bitcoin::Amount::from_sat(amount); let fee_rate = FeeRate::from_sat_per_vb(sat_per_vb); let relay_fee = bitcoin::Amount::from_sat(relay_fee); let _is_fee = estimate_fee(weight, amount, fee_rate, relay_fee); } } proptest! { #[test] fn given_amount_in_range_fix_fee_fix_relay_rate_fix_weight_fee_always_smaller_max( amount in 0u64..100_000_000, ) { let weight = 400; let amount = bitcoin::Amount::from_sat(amount); let sat_per_vb = 100.0; let fee_rate = FeeRate::from_sat_per_vb(sat_per_vb); let relay_fee = bitcoin::Amount::ONE_SAT; let is_fee = estimate_fee(weight, amount, fee_rate, relay_fee); // weight / 4 * 1_000 is always lower than MAX_ABSOLUTE_TX_FEE assert!(is_fee.as_sat() < MAX_ABSOLUTE_TX_FEE); } } proptest! { #[test] fn given_amount_high_fix_fee_fix_relay_rate_fix_weight_fee_always_max( amount in 100_000_000u64.., ) { let weight = 400; let amount = bitcoin::Amount::from_sat(amount); let sat_per_vb = 1_000.0; let fee_rate = FeeRate::from_sat_per_vb(sat_per_vb); let relay_fee = bitcoin::Amount::ONE_SAT; let is_fee = estimate_fee(weight, amount, fee_rate, relay_fee); // weight / 4 * 1_000 is always higher than MAX_ABSOLUTE_TX_FEE assert!(is_fee.as_sat() >= MAX_ABSOLUTE_TX_FEE); } } }