From e5c0158597651e3cc0e18e07cd697a3aa8fb517c Mon Sep 17 00:00:00 2001 From: rishflab Date: Thu, 11 Mar 2021 18:16:00 +1100 Subject: [PATCH] Greatly reduce load onto the Electrum backend We achieve our optimizations in three ways: 1. Batching calls instead of making them individually. To get access to the batch calls, we replace all our calls to the HTTP interface with RPC calls. 2. Never directly make network calls based on function calls on the wallet. Instead, inquiring about the status of a script always just returns information based on local data. With every call, we check when we last refreshed the local data and do so if the data is considered to be too old. This interval is configurable. 3. Use electrum's notification feature to get updated with the latest blockheight. Co-authored-by: Thomas Eizinger Co-authored-by: Rishab Sharma --- swap/src/asb/config.rs | 14 +- swap/src/bin/asb.rs | 1 - swap/src/bin/swap.rs | 72 ++--- swap/src/bitcoin.rs | 96 ++++--- swap/src/bitcoin/cancel.rs | 36 +++ swap/src/bitcoin/lock.rs | 5 + swap/src/bitcoin/timelocks.rs | 18 +- swap/src/bitcoin/wallet.rs | 435 +++++++++++++++++++++---------- swap/src/cli/command.rs | 43 ++- swap/src/cli/config.rs | 1 + swap/src/protocol/alice/state.rs | 36 ++- swap/src/protocol/alice/steps.rs | 39 +-- swap/src/protocol/alice/swap.rs | 43 ++- swap/src/protocol/bob/state.rs | 140 ++++++---- swap/src/protocol/bob/swap.rs | 15 +- swap/tests/testutils/mod.rs | 12 - 16 files changed, 628 insertions(+), 378 deletions(-) create mode 100644 swap/src/cli/config.rs diff --git a/swap/src/asb/config.rs b/swap/src/asb/config.rs index 9f764041..03a94f7e 100644 --- a/swap/src/asb/config.rs +++ b/swap/src/asb/config.rs @@ -12,7 +12,6 @@ use tracing::info; use url::Url; const DEFAULT_LISTEN_ADDRESS: &str = "/ip4/0.0.0.0/tcp/9939"; -const DEFAULT_ELECTRUM_HTTP_URL: &str = "https://blockstream.info/testnet/api/"; const DEFAULT_ELECTRUM_RPC_URL: &str = "ssl://electrum.blockstream.info:60002"; const DEFAULT_MONERO_WALLET_RPC_TESTNET_URL: &str = "http://127.0.0.1:38083/json_rpc"; @@ -52,7 +51,6 @@ pub struct Network { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Bitcoin { - pub electrum_http_url: Url, pub electrum_rpc_url: Url, } @@ -120,12 +118,6 @@ pub fn query_user_for_initial_testnet_config() -> Result { .interact_text()?; let listen_address = listen_address.as_str().parse()?; - let electrum_http_url: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Enter Electrum HTTP URL or hit return to use default") - .default(DEFAULT_ELECTRUM_HTTP_URL.to_owned()) - .interact_text()?; - let electrum_http_url = Url::parse(electrum_http_url.as_str())?; - let electrum_rpc_url: String = Input::with_theme(&ColorfulTheme::default()) .with_prompt("Enter Electrum RPC URL or hit return to use default") .default(DEFAULT_ELECTRUM_RPC_URL.to_owned()) @@ -144,10 +136,7 @@ pub fn query_user_for_initial_testnet_config() -> Result { network: Network { listen: listen_address, }, - bitcoin: Bitcoin { - electrum_http_url, - electrum_rpc_url, - }, + bitcoin: Bitcoin { electrum_rpc_url }, monero: Monero { wallet_rpc_url: monero_wallet_rpc_url, }, @@ -170,7 +159,6 @@ mod tests { dir: Default::default(), }, bitcoin: Bitcoin { - electrum_http_url: Url::from_str(DEFAULT_ELECTRUM_HTTP_URL).unwrap(), electrum_rpc_url: Url::from_str(DEFAULT_ELECTRUM_RPC_URL).unwrap(), }, network: Network { diff --git a/swap/src/bin/asb.rs b/swap/src/bin/asb.rs index 5574d1a5..e618404b 100644 --- a/swap/src/bin/asb.rs +++ b/swap/src/bin/asb.rs @@ -136,7 +136,6 @@ async fn init_wallets( ) -> Result<(bitcoin::Wallet, monero::Wallet)> { let bitcoin_wallet = bitcoin::Wallet::new( config.bitcoin.electrum_rpc_url, - config.bitcoin.electrum_http_url, BITCOIN_NETWORK, bitcoin_wallet_data_dir, key, diff --git a/swap/src/bin/swap.rs b/swap/src/bin/swap.rs index 771621e4..9c6be51f 100644 --- a/swap/src/bin/swap.rs +++ b/swap/src/bin/swap.rs @@ -21,9 +21,7 @@ use std::sync::Arc; use std::time::Duration; use structopt::StructOpt; use swap::bitcoin::{Amount, TxLock}; -use swap::cli::command::{ - AliceConnectParams, Arguments, BitcoinParams, Command, Data, MoneroParams, -}; +use swap::cli::command::{AliceConnectParams, Arguments, Command, Data, MoneroParams}; use swap::database::Database; use swap::execution_params::{ExecutionParams, GetExecutionParams}; use swap::network::quote::BidQuote; @@ -95,11 +93,7 @@ async fn main() -> Result<()> { receive_monero_address, monero_daemon_host, }, - bitcoin_params: - BitcoinParams { - electrum_http_url, - electrum_rpc_url, - }, + electrum_rpc_url, } => { if receive_monero_address.network != monero_network { bail!( @@ -109,14 +103,9 @@ async fn main() -> Result<()> { ) } - let bitcoin_wallet = init_bitcoin_wallet( - bitcoin_network, - electrum_rpc_url, - electrum_http_url, - seed, - data_dir.clone(), - ) - .await?; + let bitcoin_wallet = + init_bitcoin_wallet(bitcoin_network, electrum_rpc_url, seed, data_dir.clone()) + .await?; let (monero_wallet, _process) = init_monero_wallet( monero_network, data_dir, @@ -196,24 +185,15 @@ async fn main() -> Result<()> { receive_monero_address, monero_daemon_host, }, - bitcoin_params: - BitcoinParams { - electrum_http_url, - electrum_rpc_url, - }, + electrum_rpc_url, } => { if receive_monero_address.network != monero_network { bail!("The given monero address is on network {:?}, expected address of network {:?}.", receive_monero_address.network, monero_network) } - let bitcoin_wallet = init_bitcoin_wallet( - bitcoin_network, - electrum_rpc_url, - electrum_http_url, - seed, - data_dir.clone(), - ) - .await?; + let bitcoin_wallet = + init_bitcoin_wallet(bitcoin_network, electrum_rpc_url, seed, data_dir.clone()) + .await?; let (monero_wallet, _process) = init_monero_wallet( monero_network, data_dir, @@ -255,20 +235,10 @@ async fn main() -> Result<()> { Command::Cancel { swap_id, force, - bitcoin_params: - BitcoinParams { - electrum_http_url, - electrum_rpc_url, - }, + electrum_rpc_url, } => { - let bitcoin_wallet = init_bitcoin_wallet( - bitcoin_network, - electrum_rpc_url, - electrum_http_url, - seed, - data_dir, - ) - .await?; + let bitcoin_wallet = + init_bitcoin_wallet(bitcoin_network, electrum_rpc_url, seed, data_dir).await?; let resume_state = db.get_state(swap_id)?.try_into_bob()?.into(); let cancel = @@ -290,20 +260,10 @@ async fn main() -> Result<()> { Command::Refund { swap_id, force, - bitcoin_params: - BitcoinParams { - electrum_http_url, - electrum_rpc_url, - }, + electrum_rpc_url, } => { - let bitcoin_wallet = init_bitcoin_wallet( - bitcoin_network, - electrum_rpc_url, - electrum_http_url, - seed, - data_dir, - ) - .await?; + let bitcoin_wallet = + init_bitcoin_wallet(bitcoin_network, electrum_rpc_url, seed, data_dir).await?; let resume_state = db.get_state(swap_id)?.try_into_bob()?.into(); @@ -324,7 +284,6 @@ async fn main() -> Result<()> { async fn init_bitcoin_wallet( network: bitcoin::Network, electrum_rpc_url: Url, - electrum_http_url: Url, seed: Seed, data_dir: PathBuf, ) -> Result { @@ -332,7 +291,6 @@ async fn init_bitcoin_wallet( let wallet = bitcoin::Wallet::new( electrum_rpc_url.clone(), - electrum_http_url.clone(), network, &wallet_dir, seed.derive_extended_private_key(network)?, diff --git a/swap/src/bitcoin.rs b/swap/src/bitcoin.rs index 56b4ea44..7577633d 100644 --- a/swap/src/bitcoin.rs +++ b/swap/src/bitcoin.rs @@ -20,6 +20,7 @@ pub use ecdsa_fun::fun::Scalar; pub use ecdsa_fun::Signature; pub use wallet::Wallet; +use crate::bitcoin::wallet::ScriptStatus; use ::bitcoin::hashes::hex::ToHex; use ::bitcoin::hashes::Hash; use ::bitcoin::{secp256k1, SigHash}; @@ -218,46 +219,21 @@ pub fn recover(S: PublicKey, sig: Signature, encsig: EncryptedSignature) -> Resu Ok(s) } -pub async fn poll_until_block_height_is_gte( - wallet: &crate::bitcoin::Wallet, - target: BlockHeight, -) -> Result<()> { - while wallet.get_block_height().await? < target { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } - Ok(()) -} - -pub async fn current_epoch( - bitcoin_wallet: &crate::bitcoin::Wallet, +pub fn current_epoch( cancel_timelock: CancelTimelock, punish_timelock: PunishTimelock, - lock_tx_id: ::bitcoin::Txid, -) -> Result { - let current_block_height = bitcoin_wallet.get_block_height().await?; - let lock_tx_height = bitcoin_wallet.transaction_block_height(lock_tx_id).await?; - let cancel_timelock_height = lock_tx_height + cancel_timelock; - let punish_timelock_height = cancel_timelock_height + punish_timelock; - - match ( - current_block_height < cancel_timelock_height, - current_block_height < punish_timelock_height, - ) { - (true, _) => Ok(ExpiredTimelocks::None), - (false, true) => Ok(ExpiredTimelocks::Cancel), - (false, false) => Ok(ExpiredTimelocks::Punish), + tx_lock_status: ScriptStatus, + tx_cancel_status: ScriptStatus, +) -> ExpiredTimelocks { + if tx_cancel_status.is_confirmed_with(punish_timelock) { + return ExpiredTimelocks::Punish; } -} -pub async fn wait_for_cancel_timelock_to_expire( - bitcoin_wallet: &crate::bitcoin::Wallet, - cancel_timelock: CancelTimelock, - lock_tx_id: ::bitcoin::Txid, -) -> Result<()> { - let tx_lock_height = bitcoin_wallet.transaction_block_height(lock_tx_id).await?; + if tx_lock_status.is_confirmed_with(cancel_timelock) { + return ExpiredTimelocks::Cancel; + } - poll_until_block_height_is_gte(bitcoin_wallet, tx_lock_height + cancel_timelock).await?; - Ok(()) + ExpiredTimelocks::None } #[derive(Clone, Copy, thiserror::Error, Debug)] @@ -275,3 +251,53 @@ pub struct EmptyWitnessStack; #[derive(Clone, Copy, thiserror::Error, Debug)] #[error("input has {0} witnesses, expected 3")] pub struct NotThreeWitnesses(usize); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lock_confirmations_le_to_cancel_timelock_no_timelock_expired() { + let tx_lock_status = ScriptStatus::from_confirmations(4); + let tx_cancel_status = ScriptStatus::Unseen; + + let expired_timelock = current_epoch( + CancelTimelock::new(5), + PunishTimelock::new(5), + tx_lock_status, + tx_cancel_status, + ); + + assert_eq!(expired_timelock, ExpiredTimelocks::None) + } + + #[test] + fn lock_confirmations_ge_to_cancel_timelock_cancel_timelock_expired() { + let tx_lock_status = ScriptStatus::from_confirmations(5); + let tx_cancel_status = ScriptStatus::Unseen; + + let expired_timelock = current_epoch( + CancelTimelock::new(5), + PunishTimelock::new(5), + tx_lock_status, + tx_cancel_status, + ); + + assert_eq!(expired_timelock, ExpiredTimelocks::Cancel) + } + + #[test] + fn cancel_confirmations_ge_to_punish_timelock_punish_timelock_expired() { + let tx_lock_status = ScriptStatus::from_confirmations(10); + let tx_cancel_status = ScriptStatus::from_confirmations(5); + + let expired_timelock = current_epoch( + CancelTimelock::new(5), + PunishTimelock::new(5), + tx_lock_status, + tx_cancel_status, + ); + + assert_eq!(expired_timelock, ExpiredTimelocks::Punish) + } +} diff --git a/swap/src/bitcoin/cancel.rs b/swap/src/bitcoin/cancel.rs index d94c91dd..606ac579 100644 --- a/swap/src/bitcoin/cancel.rs +++ b/swap/src/bitcoin/cancel.rs @@ -5,9 +5,11 @@ use crate::bitcoin::{ use ::bitcoin::util::bip143::SigHashCache; use ::bitcoin::{OutPoint, SigHash, SigHashType, TxIn, TxOut, Txid}; use anyhow::Result; +use bitcoin::Script; use ecdsa_fun::Signature; use miniscript::{Descriptor, DescriptorTrait}; use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; use std::collections::HashMap; use std::ops::Add; @@ -33,6 +35,18 @@ impl Add for BlockHeight { } } +impl PartialOrd for u32 { + fn partial_cmp(&self, other: &CancelTimelock) -> Option { + self.partial_cmp(&other.0) + } +} + +impl PartialEq for u32 { + fn eq(&self, other: &CancelTimelock) -> bool { + self.eq(&other.0) + } +} + /// Represent a timelock, expressed in relative block height as defined in /// [BIP68](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki). /// E.g. The timelock expires 10 blocks after the reference transaction is @@ -55,6 +69,18 @@ impl Add for BlockHeight { } } +impl PartialOrd for u32 { + fn partial_cmp(&self, other: &PunishTimelock) -> Option { + self.partial_cmp(&other.0) + } +} + +impl PartialEq for u32 { + fn eq(&self, other: &PunishTimelock) -> bool { + self.eq(&other.0) + } +} + #[derive(Debug, Clone)] pub struct TxCancel { inner: Transaction, @@ -122,6 +148,16 @@ impl TxCancel { OutPoint::new(self.inner.txid(), 0) } + /// Return the relevant script_pubkey of this transaction. + /// + /// Even though a transaction can have multiple outputs, the nature of our + /// protocol is that there is only one relevant output within this one. + /// As such, subscribing or inquiring the status of this script allows us to + /// check the status of the whole transaction. + pub fn script_pubkey(&self) -> Script { + self.output_descriptor.script_pubkey() + } + pub fn add_signatures( self, (A, sig_a): (PublicKey, Signature), diff --git a/swap/src/bitcoin/lock.rs b/swap/src/bitcoin/lock.rs index f9ef3814..220f80da 100644 --- a/swap/src/bitcoin/lock.rs +++ b/swap/src/bitcoin/lock.rs @@ -4,6 +4,7 @@ use crate::bitcoin::{ use ::bitcoin::util::psbt::PartiallySignedTransaction; use ::bitcoin::{OutPoint, TxIn, TxOut, Txid}; use anyhow::Result; +use bitcoin::Script; use ecdsa_fun::fun::Point; use miniscript::{Descriptor, DescriptorTrait}; use rand::thread_rng; @@ -55,6 +56,10 @@ impl TxLock { .len() } + pub fn script_pubkey(&self) -> Script { + self.output_descriptor.script_pubkey() + } + /// Retreive the index of the locked output in the transaction outputs /// vector fn lock_output_vout(&self) -> usize { diff --git a/swap/src/bitcoin/timelocks.rs b/swap/src/bitcoin/timelocks.rs index 06dfc90c..ab365df8 100644 --- a/swap/src/bitcoin/timelocks.rs +++ b/swap/src/bitcoin/timelocks.rs @@ -1,4 +1,7 @@ +use anyhow::Context; +use bdk::electrum_client::HeaderNotification; use serde::{Deserialize, Serialize}; +use std::convert::{TryFrom, TryInto}; use std::ops::Add; /// Represent a block height, or block number, expressed in absolute block @@ -26,6 +29,19 @@ impl BlockHeight { } } +impl TryFrom for BlockHeight { + type Error = anyhow::Error; + + fn try_from(value: HeaderNotification) -> Result { + 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 { @@ -33,7 +49,7 @@ impl Add for BlockHeight { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum ExpiredTimelocks { None, Cancel, diff --git a/swap/src/bitcoin/wallet.rs b/swap/src/bitcoin/wallet.rs index 1227b2b0..7093743b 100644 --- a/swap/src/bitcoin/wallet.rs +++ b/swap/src/bitcoin/wallet.rs @@ -4,48 +4,31 @@ use crate::execution_params::ExecutionParams; use ::bitcoin::util::psbt::PartiallySignedTransaction; use ::bitcoin::Txid; use anyhow::{anyhow, bail, Context, Result}; -use backoff::backoff::Constant as ConstantBackoff; -use backoff::future::retry; use bdk::blockchain::{noop_progress, Blockchain, ElectrumBlockchain}; use bdk::descriptor::Segwitv0; -use bdk::electrum_client::{self, Client, ElectrumApi}; +use bdk::electrum_client::{self, ElectrumApi, GetHistoryRes}; use bdk::keys::DerivableKey; use bdk::{FeeRate, KeychainKind}; use bitcoin::Script; use reqwest::Url; -use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::convert::TryFrom; +use std::fmt; use std::path::Path; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio::sync::Mutex; -use tokio::time::interval; const SLED_TREE_NAME: &str = "default_tree"; -#[derive(Debug, thiserror::Error)] -enum Error { - #[error("Sending the request failed")] - Io(reqwest::Error), - #[error("Conversion to Integer failed")] - Parse(std::num::ParseIntError), - #[error("The transaction is not minded yet")] - NotYetMined, - #[error("Deserialization failed")] - JsonDeserialization(reqwest::Error), - #[error("Electrum client error")] - ElectrumClient(electrum_client::Error), -} - pub struct Wallet { - inner: Arc>>, - http_url: Url, - rpc_url: Url, + client: Arc>, + wallet: Arc>>, } impl Wallet { pub async fn new( electrum_rpc_url: Url, - electrum_http_url: Url, network: bitcoin::Network, wallet_dir: &Path, key: impl DerivableKey + Clone, @@ -53,8 +36,9 @@ impl Wallet { // Workaround for https://github.com/bitcoindevkit/rust-electrum-client/issues/47. let config = electrum_client::ConfigBuilder::default().retry(2).build(); - let client = Client::from_config(electrum_rpc_url.as_str(), config) - .map_err(|e| anyhow!("Failed to init electrum rpc client: {:?}", e))?; + let client = + bdk::electrum_client::Client::from_config(electrum_rpc_url.as_str(), config.clone()) + .map_err(|e| anyhow!("Failed to init electrum rpc client: {:?}", e))?; let db = bdk::sled::open(wallet_dir)?.open_tree(SLED_TREE_NAME)?; @@ -66,16 +50,20 @@ impl Wallet { ElectrumBlockchain::from(client), )?; + let electrum = bdk::electrum_client::Client::from_config(electrum_rpc_url.as_str(), config) + .map_err(|e| anyhow!("Failed to init electrum rpc client {:?}", e))?; + + let interval = Duration::from_secs(5); + Ok(Self { - inner: Arc::new(Mutex::new(bdk_wallet)), - http_url: electrum_http_url, - rpc_url: electrum_rpc_url, + wallet: Arc::new(Mutex::new(bdk_wallet)), + client: Arc::new(Mutex::new(Client::new(electrum, interval)?)), }) } pub async fn balance(&self) -> Result { let balance = self - .inner + .wallet .lock() .await .get_balance() @@ -86,7 +74,7 @@ impl Wallet { pub async fn new_address(&self) -> Result
{ let address = self - .inner + .wallet .lock() .await .get_new_address() @@ -96,13 +84,14 @@ impl Wallet { } pub async fn get_tx(&self, txid: Txid) -> Result> { - let tx = self.inner.lock().await.client().get_tx(&txid)?; + let tx = self.wallet.lock().await.client().get_tx(&txid)?; + Ok(tx) } pub async fn transaction_fee(&self, txid: Txid) -> Result { let fees = self - .inner + .wallet .lock() .await .list_transactions(true)? @@ -117,7 +106,7 @@ impl Wallet { } pub async fn sync(&self) -> Result<()> { - self.inner + self.wallet .lock() .await .sync(noop_progress(), None) @@ -131,7 +120,7 @@ impl Wallet { address: Address, amount: Amount, ) -> Result { - let wallet = self.inner.lock().await; + let wallet = self.wallet.lock().await; let mut tx_builder = wallet.build_tx(); tx_builder.add_recipient(address.script_pubkey(), amount.as_sat()); @@ -147,7 +136,7 @@ impl Wallet { /// 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.inner.lock().await; + let wallet = self.wallet.lock().await; let mut tx_builder = wallet.build_tx(); @@ -163,7 +152,7 @@ impl Wallet { } pub async fn get_network(&self) -> bitcoin::Network { - self.inner.lock().await.network() + self.wallet.lock().await.network() } /// Broadcast the given transaction to the network and emit a log statement @@ -171,7 +160,7 @@ impl Wallet { pub async fn broadcast(&self, transaction: Transaction, kind: &str) -> Result { let txid = transaction.txid(); - self.inner + self.wallet .lock() .await .broadcast(transaction) @@ -185,7 +174,7 @@ impl Wallet { } pub async fn sign_and_finalize(&self, psbt: PartiallySignedTransaction) -> Result { - let (signed_psbt, finalized) = self.inner.lock().await.sign(psbt, None)?; + let (signed_psbt, finalized) = self.wallet.lock().await.sign(psbt, None)?; if !finalized { bail!("PSBT is not finalized") @@ -202,106 +191,62 @@ impl Wallet { .ok_or_else(|| anyhow!("Could not get raw tx with id: {}", txid)) } - pub async fn watch_for_raw_transaction(&self, txid: Txid) -> Result { - tracing::debug!("watching for tx: {}", txid); - let tx = retry(ConstantBackoff::new(Duration::from_secs(1)), || async { - let client = Client::new(self.rpc_url.as_ref()) - .map_err(|err| backoff::Error::Permanent(Error::ElectrumClient(err)))?; - - let tx = client.transaction_get(&txid).map_err(|err| match err { - electrum_client::Error::Protocol(err) => { - tracing::debug!("Received protocol error {} from Electrum, retrying...", err); - backoff::Error::Transient(Error::NotYetMined) - } - err => backoff::Error::Permanent(Error::ElectrumClient(err)), - })?; - - Result::<_, backoff::Error>::Ok(tx) - }) - .await - .context("Transient errors should be retried")?; - - Ok(tx) + pub async fn status_of_script(&self, script: &Script, txid: &Txid) -> Result { + self.client.lock().await.status_of_script(script, txid) } - pub async fn get_block_height(&self) -> Result { - let url = make_blocks_tip_height_url(&self.http_url)?; + pub async fn watch_until_status( + &self, + txid: Txid, + script: Script, + mut status_fn: impl FnMut(ScriptStatus) -> bool, + ) -> Result<()> { + let mut last_status = None; - let height = retry(ConstantBackoff::new(Duration::from_secs(1)), || async { - let height = reqwest::get(url.clone()) - .await - .map_err(Error::Io)? - .text() - .await - .map_err(Error::Io)? - .parse::() - .map_err(|err| backoff::Error::Permanent(Error::Parse(err)))?; - Result::<_, backoff::Error>::Ok(height) - }) - .await - .context("Transient errors should be retried")?; + loop { + let new_status = self.client.lock().await.status_of_script(&script, &txid)?; - Ok(BlockHeight::new(height)) - } + if Some(new_status) != last_status { + tracing::debug!(%txid, "Transaction is {}", new_status); + } + last_status = Some(new_status); - pub async fn transaction_block_height(&self, txid: Txid) -> Result { - let status_url = make_tx_status_url(&self.http_url, txid)?; + if status_fn(new_status) { + break; + } - #[derive(Serialize, Deserialize, Debug, Clone)] - struct TransactionStatus { - block_height: Option, - confirmed: bool, + tokio::time::sleep(Duration::from_secs(5)).await; } - let height = retry(ConstantBackoff::new(Duration::from_secs(1)), || async { - let block_height = reqwest::get(status_url.clone()) - .await - .map_err(|err| backoff::Error::Transient(Error::Io(err)))? - .json::() - .await - .map_err(|err| backoff::Error::Permanent(Error::JsonDeserialization(err)))? - .block_height - .ok_or(backoff::Error::Transient(Error::NotYetMined))?; - Result::<_, backoff::Error>::Ok(block_height) - }) - .await - .context("Transient errors should be retried")?; - - Ok(BlockHeight::new(height)) + Ok(()) } pub async fn wait_for_transaction_finality( &self, txid: Txid, + script_to_watch: Script, execution_params: ExecutionParams, ) -> Result<()> { let conf_target = execution_params.bitcoin_finality_confirmations; tracing::info!(%txid, "Waiting for {} confirmation{} of Bitcoin transaction", conf_target, if conf_target > 1 { "s" } else { "" }); - // Divide by 4 to not check too often yet still be aware of the new block early - // on. - let mut interval = interval(execution_params.bitcoin_avg_block_time / 4); + let mut seen_confirmations = 0; - loop { - let tx_block_height = self.transaction_block_height(txid).await?; - tracing::debug!("tx_block_height: {:?}", tx_block_height); + self.watch_until_status(txid, script_to_watch, |status| match status { + ScriptStatus::Confirmed(inner) => { + let confirmations = inner.confirmations(); - let block_height = self.get_block_height().await?; - tracing::debug!("latest_block_height: {:?}", block_height); - - if let Some(confirmations) = block_height.checked_sub( - tx_block_height - .checked_sub(BlockHeight::new(1)) - .expect("transaction must be included in block with height >= 1"), - ) { - tracing::debug!(%txid, "confirmations: {:?}", confirmations); - if u32::from(confirmations) >= conf_target { - break; + 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; } - } - interval.tick().await; - } + + inner.meets_target(conf_target) + }, + _ => false + }) + .await?; Ok(()) } @@ -314,42 +259,258 @@ impl Wallet { } } -fn make_tx_status_url(base_url: &Url, txid: Txid) -> Result { - let url = base_url.join(&format!("tx/{}/status", txid))?; - - Ok(url) +struct Client { + electrum: bdk::electrum_client::Client, + latest_block: BlockHeight, + last_ping: Instant, + interval: Duration, + script_history: BTreeMap>, } -fn make_blocks_tip_height_url(base_url: &Url) -> Result { - let url = base_url.join("blocks/tip/height")?; +impl Client { + fn new(electrum: bdk::electrum_client::Client, interval: Duration) -> Result { + let latest_block = electrum.block_headers_subscribe().map_err(|e| { + anyhow!( + "Electrum client failed to subscribe to header notifications: {:?}", + e + ) + })?; - Ok(url) + Ok(Self { + electrum, + latest_block: BlockHeight::try_from(latest_block)?, + last_ping: Instant::now(), + interval, + script_history: 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, script: &Script, txid: &Txid) -> Result { + if !self.script_history.contains_key(script) { + self.script_history.insert(script.clone(), vec![]); + } + + self.drain_notifications()?; + + let history = self.script_history.entry(script.clone()).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() + .map_err(|e| anyhow!("Failed to pop header notification: {:?}", e))?; + + 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()) + .map_err(|e| anyhow!("Failed to get script histories {:?}", e))?; + + 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(()) + } +} + +#[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 crate::cli::command::DEFAULT_ELECTRUM_HTTP_URL; #[test] - fn create_tx_status_url_from_default_base_url_success() { - let base_url = DEFAULT_ELECTRUM_HTTP_URL.parse().unwrap(); - let txid = Txid::default; + fn given_depth_0_should_meet_confirmation_target_one() { + let script = ScriptStatus::Confirmed(Confirmed { depth: 0 }); - let url = make_tx_status_url(&base_url, txid()).unwrap(); + let confirmed = script.is_confirmed_with(1); - assert_eq!(url.as_str(), "https://blockstream.info/testnet/api/tx/0000000000000000000000000000000000000000000000000000000000000000/status"); + assert!(confirmed) } #[test] - fn create_block_tip_height_url_from_default_base_url_success() { - let base_url = DEFAULT_ELECTRUM_HTTP_URL.parse().unwrap(); + fn given_confirmations_1_should_meet_confirmation_target_one() { + let script = ScriptStatus::from_confirmations(1); - let url = make_blocks_tip_height_url(&base_url).unwrap(); + let confirmed = script.is_confirmed_with(1); - assert_eq!( - url.as_str(), - "https://blockstream.info/testnet/api/blocks/tip/height" - ); + 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) } } diff --git a/swap/src/cli/command.rs b/swap/src/cli/command.rs index d58fca57..d3870933 100644 --- a/swap/src/cli/command.rs +++ b/swap/src/cli/command.rs @@ -40,8 +40,11 @@ pub enum Command { #[structopt(flatten)] connect_params: AliceConnectParams, - #[structopt(flatten)] - bitcoin_params: BitcoinParams, + #[structopt(long = "electrum-rpc", + help = "Provide the Bitcoin Electrum RPC URL", + default_value = DEFAULT_ELECTRUM_RPC_URL + )] + electrum_rpc_url: Url, #[structopt(flatten)] monero_params: MoneroParams, @@ -59,8 +62,11 @@ pub enum Command { #[structopt(flatten)] connect_params: AliceConnectParams, - #[structopt(flatten)] - bitcoin_params: BitcoinParams, + #[structopt(long = "electrum-rpc", + help = "Provide the Bitcoin Electrum RPC URL", + default_value = DEFAULT_ELECTRUM_RPC_URL + )] + electrum_rpc_url: Url, #[structopt(flatten)] monero_params: MoneroParams, @@ -76,8 +82,11 @@ pub enum Command { #[structopt(short, long)] force: bool, - #[structopt(flatten)] - bitcoin_params: BitcoinParams, + #[structopt(long = "electrum-rpc", + help = "Provide the Bitcoin Electrum RPC URL", + default_value = DEFAULT_ELECTRUM_RPC_URL + )] + electrum_rpc_url: Url, }, /// Try to cancel a swap and refund my BTC (expert users only) Refund { @@ -90,8 +99,11 @@ pub enum Command { #[structopt(short, long)] force: bool, - #[structopt(flatten)] - bitcoin_params: BitcoinParams, + #[structopt(long = "electrum-rpc", + help = "Provide the Bitcoin Electrum RPC URL", + default_value = DEFAULT_ELECTRUM_RPC_URL + )] + electrum_rpc_url: Url, }, } @@ -128,21 +140,6 @@ pub struct MoneroParams { pub monero_daemon_host: String, } -#[derive(structopt::StructOpt, Debug)] -pub struct BitcoinParams { - #[structopt(long = "electrum-http", - help = "Provide the Bitcoin Electrum HTTP URL", - default_value = DEFAULT_ELECTRUM_HTTP_URL - )] - pub electrum_http_url: Url, - - #[structopt(long = "electrum-rpc", - help = "Provide the Bitcoin Electrum RPC URL", - default_value = DEFAULT_ELECTRUM_RPC_URL - )] - pub electrum_rpc_url: Url, -} - #[derive(Clone, Debug)] pub struct Data(pub PathBuf); diff --git a/swap/src/cli/config.rs b/swap/src/cli/config.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/swap/src/cli/config.rs @@ -0,0 +1 @@ + diff --git a/swap/src/protocol/alice/state.rs b/swap/src/protocol/alice/state.rs index cdb696b2..bbfa0e2d 100644 --- a/swap/src/protocol/alice/state.rs +++ b/swap/src/protocol/alice/state.rs @@ -1,6 +1,5 @@ use crate::bitcoin::{ - current_epoch, wait_for_cancel_timelock_to_expire, CancelTimelock, ExpiredTimelocks, - PunishTimelock, TxCancel, TxPunish, TxRefund, + current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, TxCancel, TxPunish, TxRefund, }; use crate::execution_params::ExecutionParams; use crate::protocol::alice::{Message1, Message3}; @@ -323,25 +322,36 @@ impl State3 { &self, bitcoin_wallet: &bitcoin::Wallet, ) -> Result<()> { - wait_for_cancel_timelock_to_expire( - bitcoin_wallet, - self.cancel_timelock, - self.tx_lock.txid(), - ) - .await + bitcoin_wallet + .watch_until_status( + self.tx_lock.txid(), + self.tx_lock.script_pubkey(), + |status| status.is_confirmed_with(self.cancel_timelock), + ) + .await?; + + Ok(()) } pub async fn expired_timelocks( &self, bitcoin_wallet: &bitcoin::Wallet, ) -> Result { - current_epoch( - bitcoin_wallet, + let tx_cancel = TxCancel::new(&self.tx_lock, self.cancel_timelock, self.a.public(), self.B); + + let tx_lock_status = bitcoin_wallet + .status_of_script(&self.tx_lock.script_pubkey(), &self.tx_lock.txid()) + .await?; + let tx_cancel_status = bitcoin_wallet + .status_of_script(&tx_cancel.script_pubkey(), &tx_cancel.txid()) + .await?; + + Ok(current_epoch( self.cancel_timelock, self.punish_timelock, - self.tx_lock.txid(), - ) - .await + tx_lock_status, + tx_cancel_status, + )) } pub fn tx_punish(&self) -> TxPunish { diff --git a/swap/src/protocol/alice/steps.rs b/swap/src/protocol/alice/steps.rs index 50819175..43732012 100644 --- a/swap/src/protocol/alice/steps.rs +++ b/swap/src/protocol/alice/steps.rs @@ -1,12 +1,11 @@ use crate::bitcoin::{ - poll_until_block_height_is_gte, BlockHeight, CancelTimelock, EncryptedSignature, - PunishTimelock, TxCancel, TxLock, TxRefund, + CancelTimelock, EncryptedSignature, PunishTimelock, TxCancel, TxLock, TxRefund, }; use crate::protocol::alice; use crate::protocol::alice::event_loop::EventLoopHandle; use crate::protocol::alice::TransferProof; use crate::{bitcoin, monero}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use futures::future::{select, Either}; use futures::pin_mut; use libp2p::PeerId; @@ -61,11 +60,11 @@ pub async fn publish_cancel_transaction( tx_cancel_sig_bob: bitcoin::Signature, bitcoin_wallet: &bitcoin::Wallet, ) -> Result { - // First wait for cancel timelock to expire - let tx_lock_height = bitcoin_wallet - .transaction_block_height(tx_lock.txid()) + bitcoin_wallet + .watch_until_status(tx_lock.txid(), tx_lock.script_pubkey(), |status| { + status.is_confirmed_with(cancel_timelock) + }) .await?; - poll_until_block_height_is_gte(bitcoin_wallet, tx_lock_height + cancel_timelock).await?; let tx_cancel = bitcoin::TxCancel::new(&tx_lock, cancel_timelock, a.public(), B); @@ -98,26 +97,36 @@ pub async fn publish_cancel_transaction( pub async fn wait_for_bitcoin_refund( tx_cancel: &TxCancel, - cancel_tx_height: BlockHeight, punish_timelock: PunishTimelock, refund_address: &bitcoin::Address, bitcoin_wallet: &bitcoin::Wallet, ) -> Result<(bitcoin::TxRefund, Option)> { - let punish_timelock_expired = - poll_until_block_height_is_gte(bitcoin_wallet, cancel_tx_height + punish_timelock); - let tx_refund = bitcoin::TxRefund::new(tx_cancel, refund_address); - // TODO(Franck): This only checks the mempool, need to cater for the case where - // the transaction goes directly in a block - let seen_refund_tx = bitcoin_wallet.watch_for_raw_transaction(tx_refund.txid()); + let seen_refund_tx = bitcoin_wallet.watch_until_status( + tx_refund.txid(), + refund_address.script_pubkey(), + |status| status.has_been_seen(), + ); + + let punish_timelock_expired = + bitcoin_wallet.watch_until_status(tx_cancel.txid(), tx_cancel.script_pubkey(), |status| { + status.is_confirmed_with(punish_timelock) + }); pin_mut!(punish_timelock_expired); pin_mut!(seen_refund_tx); match select(punish_timelock_expired, seen_refund_tx).await { Either::Left(_) => Ok((tx_refund, None)), - Either::Right((published_refund_tx, _)) => Ok((tx_refund, Some(published_refund_tx?))), + Either::Right((Ok(()), _)) => { + let published_refund_tx = bitcoin_wallet.get_raw_transaction(tx_refund.txid()).await?; + + Ok((tx_refund, Some(published_refund_tx))) + } + Either::Right((Err(e), _)) => { + bail!(e.context("Failed to monitor refund transaction")) + } } } diff --git a/swap/src/protocol/alice/swap.rs b/swap/src/protocol/alice/swap.rs index 8c1f8bb5..e15ade0d 100644 --- a/swap/src/protocol/alice/swap.rs +++ b/swap/src/protocol/alice/swap.rs @@ -82,13 +82,21 @@ async fn run_until_internal( } => { timeout( execution_params.bob_time_to_act, - bitcoin_wallet.watch_for_raw_transaction(state3.tx_lock.txid()), + bitcoin_wallet.watch_until_status( + state3.tx_lock.txid(), + state3.tx_lock.script_pubkey(), + |status| status.has_been_seen(), + ), ) .await .context("Failed to find lock Bitcoin tx")??; bitcoin_wallet - .wait_for_transaction_finality(state3.tx_lock.txid(), execution_params) + .wait_for_transaction_finality( + state3.tx_lock.txid(), + state3.tx_lock.script_pubkey(), + execution_params, + ) .await?; let state = AliceState::BtcLocked { @@ -213,7 +221,11 @@ async fn run_until_internal( Ok(tx) => match bitcoin_wallet.broadcast(tx, "redeem").await { Ok(txid) => { let publishded_redeem_tx = bitcoin_wallet - .wait_for_transaction_finality(txid, execution_params) + .wait_for_transaction_finality( + txid, + state3.redeem_address.script_pubkey(), + execution_params, + ) .await; match publishded_redeem_tx { @@ -308,13 +320,8 @@ async fn run_until_internal( tx_cancel, monero_wallet_restore_blockheight, } => { - let tx_cancel_height = bitcoin_wallet - .transaction_block_height(tx_cancel.txid()) - .await?; - let (tx_refund, published_refund_tx) = wait_for_bitcoin_refund( &tx_cancel, - tx_cancel_height, state3.punish_timelock, &state3.refund_address, &bitcoin_wallet, @@ -404,25 +411,34 @@ async fn run_until_internal( state3.B, )?; + let punish_script_pubkey = state3.punish_address.script_pubkey(); + let punish_tx_finalised = async { let txid = bitcoin_wallet.broadcast(signed_tx_punish, "punish").await?; bitcoin_wallet - .wait_for_transaction_finality(txid, execution_params) + .wait_for_transaction_finality(txid, punish_script_pubkey, execution_params) .await?; Result::<_, anyhow::Error>::Ok(txid) }; - let refund_tx_seen = bitcoin_wallet.watch_for_raw_transaction(tx_refund.txid()); + let refund_tx_seen = bitcoin_wallet.watch_until_status( + tx_refund.txid(), + state3.refund_address.script_pubkey(), + |status| status.has_been_seen(), + ); pin_mut!(punish_tx_finalised); pin_mut!(refund_tx_seen); match select(refund_tx_seen, punish_tx_finalised).await { - Either::Left((published_refund_tx, _)) => { + Either::Left((Ok(()), _)) => { + let published_refund_tx = + bitcoin_wallet.get_raw_transaction(tx_refund.txid()).await?; + let spend_key = extract_monero_private_key( - published_refund_tx?, + published_refund_tx, &tx_refund, state3.s_a, state3.a.clone(), @@ -448,6 +464,9 @@ async fn run_until_internal( ) .await } + Either::Left((Err(e), _)) => { + bail!(e.context("Failed to monitor refund transaction")) + } Either::Right(_) => { let state = AliceState::BtcPunished; let db_state = (&state).into(); diff --git a/swap/src/protocol/bob/state.rs b/swap/src/protocol/bob/state.rs index 46092393..d743c0f5 100644 --- a/swap/src/protocol/bob/state.rs +++ b/swap/src/protocol/bob/state.rs @@ -1,6 +1,6 @@ use crate::bitcoin::{ - self, current_epoch, wait_for_cancel_timelock_to_expire, CancelTimelock, ExpiredTimelocks, - PunishTimelock, Transaction, TxCancel, Txid, + self, current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel, + TxLock, Txid, }; use crate::execution_params::ExecutionParams; use crate::monero; @@ -9,7 +9,7 @@ use crate::monero_ext::ScalarExt; use crate::protocol::alice::{Message1, Message3}; use crate::protocol::bob::{EncryptedSignature, Message0, Message2, Message4}; use crate::protocol::CROSS_CURVE_PROOF_SYSTEM; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{anyhow, bail, Result}; use ecdsa_fun::adaptor::{Adaptor, HashTranscript}; use ecdsa_fun::nonce::Deterministic; use ecdsa_fun::Signature; @@ -262,31 +262,27 @@ impl State2 { } } - pub async fn lock_btc(self, bitcoin_wallet: &bitcoin::Wallet) -> Result { - let signed_tx = bitcoin_wallet - .sign_and_finalize(self.tx_lock.clone().into()) - .await - .context("Failed to sign Bitcoin lock transaction")?; - - let _ = bitcoin_wallet.broadcast(signed_tx, "lock").await?; - - Ok(State3 { - A: self.A, - b: self.b, - s_b: self.s_b, - S_a_monero: self.S_a_monero, - S_a_bitcoin: self.S_a_bitcoin, - v: self.v, - xmr: self.xmr, - cancel_timelock: self.cancel_timelock, - punish_timelock: self.punish_timelock, - refund_address: self.refund_address, - redeem_address: self.redeem_address, - tx_lock: self.tx_lock, - tx_cancel_sig_a: self.tx_cancel_sig_a, - tx_refund_encsig: self.tx_refund_encsig, - min_monero_confirmations: self.min_monero_confirmations, - }) + pub async fn lock_btc(self) -> Result<(State3, TxLock)> { + Ok(( + State3 { + A: self.A, + b: self.b, + s_b: self.s_b, + S_a_monero: self.S_a_monero, + S_a_bitcoin: self.S_a_bitcoin, + v: self.v, + xmr: self.xmr, + cancel_timelock: self.cancel_timelock, + punish_timelock: self.punish_timelock, + refund_address: self.refund_address, + redeem_address: self.redeem_address, + tx_lock: self.tx_lock.clone(), + tx_cancel_sig_a: self.tx_cancel_sig_a, + tx_refund_encsig: self.tx_refund_encsig, + min_monero_confirmations: self.min_monero_confirmations, + }, + self.tx_lock, + )) } } @@ -354,12 +350,14 @@ impl State3 { &self, bitcoin_wallet: &bitcoin::Wallet, ) -> Result<()> { - wait_for_cancel_timelock_to_expire( - bitcoin_wallet, - self.cancel_timelock, - self.tx_lock.txid(), - ) - .await + bitcoin_wallet + .watch_until_status( + self.tx_lock.txid(), + self.tx_lock.script_pubkey(), + |status| status.is_confirmed_with(self.cancel_timelock), + ) + .await?; + Ok(()) } pub fn cancel(&self) -> State4 { @@ -390,13 +388,21 @@ impl State3 { &self, bitcoin_wallet: &bitcoin::Wallet, ) -> Result { - current_epoch( - bitcoin_wallet, + let tx_cancel = TxCancel::new(&self.tx_lock, self.cancel_timelock, self.A, self.b.public()); + + let tx_lock_status = bitcoin_wallet + .status_of_script(&self.tx_lock.script_pubkey(), &self.tx_lock.txid()) + .await?; + let tx_cancel_status = bitcoin_wallet + .status_of_script(&tx_cancel.script_pubkey(), &tx_cancel.txid()) + .await?; + + Ok(current_epoch( self.cancel_timelock, self.punish_timelock, - self.tx_lock.txid(), - ) - .await + tx_lock_status, + tx_cancel_status, + )) } } @@ -419,10 +425,9 @@ pub struct State4 { impl State4 { pub fn next_message(&self) -> EncryptedSignature { - let tx_redeem = bitcoin::TxRedeem::new(&self.tx_lock, &self.redeem_address); - let tx_redeem_encsig = self.b.encsign(self.S_a_bitcoin, tx_redeem.digest()); - - EncryptedSignature { tx_redeem_encsig } + EncryptedSignature { + tx_redeem_encsig: self.tx_redeem_encsig(), + } } pub fn tx_redeem_encsig(&self) -> bitcoin::EncryptedSignature { @@ -466,10 +471,16 @@ impl State4 { let tx_redeem = bitcoin::TxRedeem::new(&self.tx_lock, &self.redeem_address); let tx_redeem_encsig = self.b.encsign(self.S_a_bitcoin, tx_redeem.digest()); - let tx_redeem_candidate = bitcoin_wallet - .watch_for_raw_transaction(tx_redeem.txid()) + bitcoin_wallet + .watch_until_status( + tx_redeem.txid(), + self.redeem_address.script_pubkey(), + |status| status.has_been_seen(), + ) .await?; + let tx_redeem_candidate = bitcoin_wallet.get_raw_transaction(tx_redeem.txid()).await?; + let tx_redeem_sig = tx_redeem.extract_signature_by_key(tx_redeem_candidate, self.b.public())?; let s_a = bitcoin::recover(self.S_a_bitcoin, tx_redeem_sig, tx_redeem_encsig)?; @@ -488,25 +499,36 @@ impl State4 { &self, bitcoin_wallet: &bitcoin::Wallet, ) -> Result<()> { - wait_for_cancel_timelock_to_expire( - bitcoin_wallet, - self.cancel_timelock, - self.tx_lock.txid(), - ) - .await + bitcoin_wallet + .watch_until_status( + self.tx_lock.txid(), + self.tx_lock.script_pubkey(), + |status| status.is_confirmed_with(self.cancel_timelock), + ) + .await?; + + Ok(()) } pub async fn expired_timelock( &self, bitcoin_wallet: &bitcoin::Wallet, ) -> Result { - current_epoch( - bitcoin_wallet, + let tx_cancel = TxCancel::new(&self.tx_lock, self.cancel_timelock, self.A, self.b.public()); + + let tx_lock_status = bitcoin_wallet + .status_of_script(&self.tx_lock.script_pubkey(), &self.tx_lock.txid()) + .await?; + let tx_cancel_status = bitcoin_wallet + .status_of_script(&tx_cancel.script_pubkey(), &tx_cancel.txid()) + .await?; + + Ok(current_epoch( self.cancel_timelock, self.punish_timelock, - self.tx_lock.txid(), - ) - .await + tx_lock_status, + tx_cancel_status, + )) } pub async fn refund_btc( @@ -530,7 +552,11 @@ impl State4 { let txid = bitcoin_wallet.broadcast(signed_tx_refund, "refund").await?; bitcoin_wallet - .wait_for_transaction_finality(txid, execution_params) + .wait_for_transaction_finality( + txid, + self.refund_address.script_pubkey(), + execution_params, + ) .await?; Ok(()) diff --git a/swap/src/protocol/bob/swap.rs b/swap/src/protocol/bob/swap.rs index 30f84ec6..b051923a 100644 --- a/swap/src/protocol/bob/swap.rs +++ b/swap/src/protocol/bob/swap.rs @@ -6,7 +6,7 @@ use crate::protocol::bob; use crate::protocol::bob::event_loop::EventLoopHandle; use crate::protocol::bob::state::*; use crate::{bitcoin, monero}; -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use async_recursion::async_recursion; use rand::rngs::OsRng; use std::sync::Arc; @@ -99,7 +99,18 @@ async fn run_until_internal( // Do not lock Bitcoin if not connected to Alice. event_loop_handle.dial().await?; // Alice and Bob have exchanged info - let state3 = state2.lock_btc(bitcoin_wallet.as_ref()).await?; + let (state3, tx_lock) = state2.lock_btc().await?; + let signed_tx = bitcoin_wallet + .sign_and_finalize(tx_lock.clone().into()) + .await + .context("Failed to sign Bitcoin lock transaction")?; + let tx_lock_id = bitcoin_wallet.broadcast(signed_tx, "lock").await?; + + bitcoin_wallet + .watch_until_status(tx_lock_id, tx_lock.script_pubkey(), |status| { + status.is_confirmed() + }) + .await?; let state = BobState::BtcLocked(state3); let db_state = state.clone().into(); diff --git a/swap/tests/testutils/mod.rs b/swap/tests/testutils/mod.rs index 9d14bf6a..ce480ed8 100644 --- a/swap/tests/testutils/mod.rs +++ b/swap/tests/testutils/mod.rs @@ -342,10 +342,6 @@ where .electrs .get_host_port(testutils::electrs::RPC_PORT) .expect("Could not map electrs rpc port"); - let electrs_http_port = containers - .electrs - .get_host_port(testutils::electrs::HTTP_PORT) - .expect("Could not map electrs http port"); let alice_seed = Seed::random().unwrap(); let bob_seed = Seed::random().unwrap(); @@ -357,7 +353,6 @@ where alice_starting_balances.clone(), tempdir().unwrap().path(), electrs_rpc_port, - electrs_http_port, alice_seed, execution_params, ) @@ -380,7 +375,6 @@ where bob_starting_balances.clone(), tempdir().unwrap().path(), electrs_rpc_port, - electrs_http_port, bob_seed, execution_params, ) @@ -588,7 +582,6 @@ async fn init_test_wallets( starting_balances: StartingBalances, datadir: &Path, electrum_rpc_port: u16, - electrum_http_port: u16, seed: Seed, execution_params: ExecutionParams, ) -> (Arc, Arc) { @@ -608,14 +601,9 @@ async fn init_test_wallets( let input = format!("tcp://@localhost:{}", electrum_rpc_port); Url::parse(&input).unwrap() }; - let electrum_http_url = { - let input = format!("http://@localhost:{}", electrum_http_port); - Url::parse(&input).unwrap() - }; let btc_wallet = swap::bitcoin::Wallet::new( electrum_rpc_url, - electrum_http_url, bitcoin::Network::Regtest, datadir, seed.derive_extended_private_key(bitcoin::Network::Regtest)