diff --git a/Cargo.lock b/Cargo.lock index 17b6a87e8..45bcdd09c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -420,6 +420,46 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +[[package]] +name = "base64-compat" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8d4d2746f89841e49230dd26917df1876050f95abafafbe34f47cb534b88d7" +dependencies = [ + "byteorder", +] + +[[package]] +name = "bdk" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecf7e997526ceefbab7dd99fc0da6834ed8853bd051f53523415ed1dc82b870d" +dependencies = [ + "async-trait", + "bdk-macros", + "bitcoin", + "electrum-client", + "js-sys", + "log", + "miniscript", + "rand 0.7.3", + "serde", + "serde_json", + "sled", + "tokio", +] + +[[package]] +name = "bdk-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81c1980e50ae23bb6efa9283ae8679d6ea2c6fa6a99fe62533f65f4a25a1a56c" +dependencies = [ + "proc-macro2 1.0.30", + "quote 1.0.10", + "syn 1.0.80", +] + [[package]] name = "bech32" version = "0.8.1" @@ -486,6 +526,7 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a41df6ad9642c5c15ae312dd3d074de38fd3eb7cc87ad4ce10f90292a83fe4d" dependencies = [ + "base64-compat", "bech32", "bitcoin_hashes", "secp256k1", @@ -1255,16 +1296,17 @@ dependencies = [ name = "darkfi" version = "0.1.0" dependencies = [ + "anyhow", "async-channel", "async-executor", "async-native-tls", "async-std", "async-trait", "async-tungstenite", + "bdk", "bellman", "bimap", "bitcoin", - "bitcoin_hashes", "bitvec 0.18.5", "blake2b_simd", "blake2s_simd", @@ -1275,7 +1317,6 @@ dependencies = [ "crypto_api_chachapoly", "dirs 4.0.0", "easy-parallel", - "electrum-client", "ff", "futures 0.3.17", "group", @@ -1715,6 +1756,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi 0.3.9", +] + [[package]] name = "fuchsia-cprng" version = "0.1.1" @@ -1852,6 +1903,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.12.4" @@ -2507,6 +2567,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "miniscript" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69450033bf162edf854d4aacaff82ca5ef34fa81f6cf69e1c81a103f0834997" +dependencies = [ + "bitcoin", + "serde", +] + [[package]] name = "miniz_oxide" version = "0.4.4" @@ -3767,6 +3837,22 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5" +[[package]] +name = "sled" +version = "0.34.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935" +dependencies = [ + "crc32fast", + "crossbeam-epoch 0.9.5", + "crossbeam-utils 0.8.5", + "fs2", + "fxhash", + "libc", + "log", + "parking_lot 0.11.2", +] + [[package]] name = "smallvec" version = "1.7.0" diff --git a/Cargo.toml b/Cargo.toml index 7e221fe36..3ffa7a094 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,16 +70,16 @@ spl-token = {version = "3.2.0", features = ["no-entrypoint"], optional = true} spl-associated-token-account = {version = "1.0.3", features = ["no-entrypoint"], optional = true} ## Cashier Bitcoin Dependencies -bitcoin = {version = "0.27.0", optional = true} -bitcoin_hashes = "0.10.0" +anyhow = "1.0.44" +bdk = {version = "0.12.0", optional = true} +bitcoin = {version = "0.27.0", optional = true } secp256k1 = {version = "0.20.3", default-features = false, features = ["rand-std"], optional = true} -electrum-client = {version = "0.8.0", optional = true} ## Cashier Ethereum Dependencies hash-db = {version = "0.15.2", optional = true} keccak-hasher = {version = "0.15.3", optional = true} [features] -btc = ["bitcoin", "secp256k1", "electrum-client"] +btc = ["bdk", "bitcoin", "secp256k1"] sol = ["solana-sdk", "solana-client", "spl-token", "spl-associated-token-account"] eth = ["keccak-hasher", "hash-db"] diff --git a/src/service/btc.rs b/src/service/btc.rs index ed8d25dda..ae750499c 100644 --- a/src/service/btc.rs +++ b/src/service/btc.rs @@ -1,8 +1,13 @@ -use async_std::sync::Arc; -use std::convert::From; +use async_std::sync::{Arc, Mutex}; +use std::collections::{BTreeMap, HashMap}; +use std::convert::{From, TryFrom, TryInto}; +use std::cmp::max; +use std::fmt; +use std::ops::Add; use std::str::FromStr; -use std::time::Duration; +use std::time::{Duration, Instant}; +use anyhow::Context; use async_executor::Executor; use async_trait::async_trait; @@ -11,12 +16,18 @@ use bitcoin::blockdata::{ transaction::{OutPoint, SigHashType, Transaction, TxIn, TxOut}, }; use bitcoin::consensus::encode::serialize_hex; -use bitcoin::hash_types::PubkeyHash as BtcPubKeyHash; +use bitcoin::hash_types::{PubkeyHash as BtcPubKeyHash, Txid}; use bitcoin::network::constants::Network; use bitcoin::util::address::Address; use bitcoin::util::ecdsa::{PrivateKey as BtcPrivKey, PublicKey as BtcPubKey}; use bitcoin::util::psbt::serialize::Serialize; -use electrum_client::{Client as ElectrumClient, ElectrumApi, GetBalanceRes}; +use bdk::electrum_client::{ + Client as ElectrumClient, + ElectrumApi, + GetBalanceRes, + GetHistoryRes, + HeaderNotification +}; use log::*; use secp256k1::{ constants::{PUBLIC_KEY_SIZE, SECRET_KEY_SIZE}, @@ -37,6 +48,40 @@ pub type PrivKey = BtcPrivKey; const KEYPAIR_LENGTH: usize = SECRET_KEY_SIZE + PUBLIC_KEY_SIZE; +#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct BlockHeight(u32); + +impl From for u32 { +fn from(height: BlockHeight) -> Self { + height.0 + } +} + +impl TryFrom for BlockHeight { + type Error = BtcFailed; + fn try_from(value: HeaderNotification) -> BtcResult { + Ok(Self( + value + .height + .try_into() + .context("Failed to fit usize into u32")?, + )) + } +} + +impl Add for BlockHeight { + type Output = BlockHeight; + fn add(self, rhs: u32) -> Self::Output { + BlockHeight(self.0 + rhs) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ExpiredTimelocks { + None, + Cancel, + Punish, +} #[derive(Clone, Debug, PartialEq)] pub struct Keypair { secret: SecretKey, @@ -155,17 +200,163 @@ impl Account { Script::new_p2pkh(&btc_pubkey_hash) } } +fn print_status_change(txid: Txid, old: Option, new: ScriptStatus) -> ScriptStatus { + match (old, new) { + (None, new_status) => { + debug!(target: "BTC BRIDGE", "Found relevant Bitcoin transaction: {:?} {:?}", txid, new_status); + } + (Some(old_status), new_status) if old_status != new_status => { + debug!(target: "BTC BRIDGE", "Bitcoin transaction status changed: {:?} {} {}", txid, new_status, old_status); + } + _ => {} + } + new +} + +fn sync_interval(avg_block_time: Duration) -> Duration { + max(avg_block_time / 10, Duration::from_secs(1)) +} +pub struct Client { + electrum: ElectrumClient, + subscriptions: Arc>>, + latest_block_height: BlockHeight, + last_sync: Instant, + sync_interval: Duration, + script_history: BTreeMap>, + +} +impl Client { + pub fn new(electrum_url: &str) -> BtcResult { + + let config = bdk::electrum_client::ConfigBuilder::default() + .retry(5) + .build(); + let client = ElectrumClient::from_config(electrum_url, config)?; + + let electrum = ElectrumClient::new(electrum_url) + .map_err(|err| crate::Error::from(super::BtcFailed::from(err)))?; + + let latest_block = electrum + .block_headers_subscribe()?; + + //testnet avg block time + let interval = sync_interval(Duration::from_secs(300)); + + Ok(Self { + electrum: electrum, + subscriptions: Arc::new(Mutex::new(Vec::new())), + latest_block_height: BlockHeight::try_from(latest_block) + .map_err(|_| crate::Error::TryFromError)?, + last_sync: Instant::now(), + sync_interval: interval, + script_history: Default::default(), + }) + } + fn update_state(&mut self) -> Result<()> { + let now = Instant::now(); + if now < self.last_sync + self.sync_interval { + return Ok(()); + } + + self.last_sync = now; + self.update_latest_block()?; + self.update_script_histories()?; + + Ok(()) + } + fn update_latest_block(&mut self) -> BtcResult<()> { + let latest_block = self + .electrum + .block_headers_subscribe()?; + let latest_block_height = BlockHeight::try_from(latest_block) + .map_err(|err| crate::Error::from(super::BtcFailed::from(err)))?; + + if latest_block_height > self.latest_block_height { + // debug!( target: "BTC BRIDGE", "{} {}" + // u32::from(latest_block_height), + // "Got notification for new block" + // ); + self.latest_block_height = latest_block_height; + } + + Ok(()) + } + + fn update_script_histories(&mut self) -> BtcResult<()> { + let histories = self + .electrum + .batch_script_get_history(self.script_history.keys())?; + + if histories.len() != self.script_history.len() { + debug!( + "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(()) + } + + pub fn status_of_script(&mut self, tx: &T) -> BtcResult + 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.update_state()?; + + 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() { + debug!("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) + .map_err(|_| crate::Error::TryFromError)?, + u32::from(self.latest_block_height), + ), + )) + } + } + } + } + +} pub struct BtcClient { main_account: Account, + client: Arc>, notify_channel: ( async_channel::Sender, async_channel::Receiver, ), - client: Arc, network: Network, } - impl BtcClient { pub async fn new(main_keypair: Keypair, network: &str) -> Result> { //TODO @@ -181,19 +372,18 @@ impl BtcClient { let main_account = Account::new(&main_keypair, network); - let electrum_client = ElectrumClient::new(url) - .map_err(|err| crate::Error::from(super::BtcFailed::from(err)))?; - Ok(Arc::new(Self { main_account, + client: Arc::new(Mutex::new(Client::new(url)?)), notify_channel, - client: Arc::new(electrum_client), network, })) } + async fn handle_subscribe_request( self: Arc, + tx: impl Watchable + Send + 'static, btc_keys: Account, drk_pub_key: jubjub::SubgroupPoint, ) -> BtcResult<()> { @@ -201,51 +391,54 @@ impl BtcClient { target: "BTC BRIDGE", "Handle subscribe request" ); - let client = &self.client; - - let keys_clone = btc_keys.clone(); - // p2pkh script - let script = keys_clone.script_pubkey; + let client = self.client.clone(); + let electrum = &client.lock().await.electrum; + let script = tx.script(); + let txid = tx.id(); + // Check if we're already subscribed + if client.lock().await + .subscriptions.lock().await.contains(&script) { + return Ok(()); + } //Fetch any current balance - let prev_balance = client.script_get_balance(&script)?; + let prev_balance = electrum.script_get_balance(&script)?; let cur_balance: GetBalanceRes; + //let status = client.script_subscribe(&script)?; + let mut last_status = None; + - let status = client.script_subscribe(&script)?; loop { - let current_status = client.script_pop(&script)?; - debug!(target: "BTC BRIDGE", "script status: {:?}", status); - debug!(target: "BTC BRIDGE", "current_script status: {:?}", current_status); - if current_status == status { - async_std::task::sleep(Duration::from_secs(5)).await; - debug!( - target: "BTC BRIDGE", - "ScriptPubKey status has not changed, amtucfd: {}, amtcfd: {}", - client.script_get_balance(&script)?.unconfirmed, - client.script_get_balance(&script)?.confirmed - ); - continue; - } + async_std::task::sleep(Duration::from_secs(5)).await; + //let current_status = client.script_pop(&script)?; - match current_status { - Some(_) => { - // Script has a notification update - debug!(target: "BTC BRIDGE", "ScripPubKey notify update"); - //TODO: unsubscribe is never successful - //let _ = client.script_unsubscribe(&script)?; - break; - } - None => { - return Err(BtcFailed::ElectrumError( - "ScriptPubKey was not found".to_string(), - )); + let new_status = match client.lock().await.status_of_script(&tx) { + Ok(new_status) => new_status, + Err(error) => { + debug!(target: "BTC BRIDGE", "Failed to get status of script: {:#}", error); + return Err(BtcFailed::BtcError("Failed to get status of script".to_string())); } }; + + last_status = Some(print_status_change(txid, last_status, new_status)); + + match new_status { + ScriptStatus::Unseen => continue, + ScriptStatus::InMempool => continue, + ScriptStatus::Confirmed(inner) => { + let confirmations = inner.confirmations(); + debug!(target: "BTC BRIDGE", "Received confirmed tx: {:#}", confirmations); + if confirmations > 0 { + break + } + }, + } + } // Endloop - cur_balance = client.script_get_balance(&script)?; + cur_balance = electrum.script_get_balance(&script)?; let send_notification = self.notify_channel.0.clone(); @@ -271,17 +464,18 @@ impl BtcClient { .map_err(Error::from)?; debug!(target: "BTC BRIDGE", "Received {} btc", ui_amnt); - let _ = self.send_btc_to_main_wallet(amnt as u64, btc_keys)?; + let _ = self.send_btc_to_main_wallet(amnt as u64, btc_keys).await; Ok(()) } - fn send_btc_to_main_wallet(self: Arc, amount: u64, btc_keys: Account) -> BtcResult<()> { + async fn send_btc_to_main_wallet(self: Arc, amount: u64, btc_keys: Account) -> BtcResult<()> { debug!(target: "BTC BRIDGE", "Sending {} BTC to main wallet", amount); - let client = &self.client; + let client = self.client.lock().await; + let electrum = &client.electrum; let keys_clone = btc_keys.clone(); let script = keys_clone.script_pubkey; - let utxo = client.script_list_unspent(&script)?; + let utxo = electrum.script_list_unspent(&script)?; let mut inputs = Vec::new(); let mut amounts: u64 = 0; @@ -311,13 +505,10 @@ impl BtcClient { version: 2, }; - //TODO: Better handling of fees, don't cast to u64 let tx_size = transaction.get_size(); - //Estimate fee for getting in next block let fee_per_kb = client.estimate_fee(1)?; let _fee = tx_size as f64 * fee_per_kb * 100000_f64; - //let value = amounts - fee as u64; let transaction = Transaction { input: inputs, @@ -346,11 +537,12 @@ impl BtcClient { debug!(target: "BTC BRIDGE", "Signed tx: {:?}", serialize_hex(&signed_tx)); - let txid = client.transaction_broadcast_raw(&signed_tx.serialize().to_vec())?; + let txid = electrum.transaction_broadcast_raw(&signed_tx.serialize().to_vec())?; debug!(target: "BTC BRIDGE", "Sent {} satoshi to main wallet, txid: {}", amount, txid); Ok(()) } + } #[async_trait] @@ -367,12 +559,22 @@ impl NetworkClient for BtcClient { let private_key = serialize(&keypair); let public_key = btc_keys.address.to_string(); + let keys_clone = btc_keys.clone(); + let script = keys_clone.script_pubkey; + + let txid = self.client.lock().await.electrum + .script_get_history(&script).unwrap()[0].tx_hash; + // start scheduler for checking balance debug!(target: "BRIDGE BITCOIN", "Subscribing for deposit"); executor .spawn(async move { - let result = self.handle_subscribe_request(btc_keys, drk_pub_key).await; + let result = self.handle_subscribe_request( + (txid, script), + btc_keys, + drk_pub_key + ).await; if let Err(e) = result { error!(target: "BTC BRIDGE SUBSCRIPTION","{}", e.to_string()); } @@ -397,9 +599,20 @@ impl NetworkClient for BtcClient { let btc_keys = Account::new(&keypair, self.network); let public_key = btc_keys.address.to_string(); + let keys_clone = btc_keys.clone(); + let script = keys_clone.script_pubkey; + + //Ugly + let txid = self.client.lock().await.electrum. + script_get_history(&script).unwrap()[0].tx_hash; + executor .spawn(async move { - let result = self.handle_subscribe_request(btc_keys, drk_pub_key).await; + let result = self.handle_subscribe_request( + (txid, script), + btc_keys, + drk_pub_key + ).await; if let Err(e) = result { error!(target: "BTC BRIDGE SUBSCRIPTION","{}", e.to_string()); } @@ -418,14 +631,14 @@ impl NetworkClient for BtcClient { amount: u64, ) -> Result<()> { // address is not a btc address, so derive the btc address - let client = &self.client; + let electrum = &self.client.lock().await.electrum; let public_key = deserialize(&address)?; let script_pubkey = Account::derive_btc_script_pubkey(public_key, self.network); let main_script_pubkey = &self.main_account.script_pubkey; - let main_utxo = client - .script_list_unspent(main_script_pubkey) + let main_utxo = electrum + .script_list_unspent(&main_script_pubkey) .map_err(|e| Error::from(BtcFailed::from(e)))?; let transaction = Transaction { @@ -455,7 +668,7 @@ impl NetworkClient for BtcClient { &self.main_account.keypair.context, )?; - let txid = client + let txid = electrum .transaction_broadcast_raw(&signed_tx.serialize().to_vec()) .map_err(|e| Error::from(BtcFailed::from(e)))?; @@ -502,6 +715,97 @@ pub fn sign_transaction( output: tx.output, }) } +#[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 { + depth: u32, +} + +impl Confirmed { + pub fn new(depth: u32) -> Self { + Self { depth } + } + 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()) + } + } + } +} + +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() + } +} impl Encodable for bitcoin::Transaction { fn encode(&self, s: S) -> Result { let tx = self.serialize(); @@ -605,7 +909,6 @@ pub enum BtcFailed { KeypairError(String), Notification(String), } - impl std::error::Error for BtcFailed {} impl std::fmt::Display for BtcFailed { @@ -649,8 +952,8 @@ impl From for BtcFailed { BtcFailed::BadBtcAddress(err.to_string()) } } -impl From for BtcFailed { - fn from(err: electrum_client::Error) -> BtcFailed { +impl From for BtcFailed { + fn from(err: bdk::electrum_client::Error) -> BtcFailed { BtcFailed::ElectrumError(err.to_string()) } } @@ -660,6 +963,12 @@ impl From for BtcFailed { BtcFailed::DecodeAndEncodeError(err.to_string()) } } +impl From for BtcFailed { + fn from(err: anyhow::Error) -> BtcFailed { + BtcFailed::DecodeAndEncodeError(err.to_string()) + } +} + pub type BtcResult = std::result::Result;