diff --git a/.gitignore b/.gitignore index d89bb47f..40a04136 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ - # Created by https://www.toptal.com/developers/gitignore/api/rust,clion+all,emacs # Edit at https://www.toptal.com/developers/gitignore?templates=rust,clion+all,emacs @@ -154,4 +153,7 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk +# sled DB directory generated during local development +.swap-db/ + # End of https://www.toptal.com/developers/gitignore/api/rust,clion+all,emacs diff --git a/swap/Cargo.toml b/swap/Cargo.toml index af99bcba..e1371f91 100644 --- a/swap/Cargo.toml +++ b/swap/Cargo.toml @@ -14,19 +14,22 @@ base64 = "0.12" bitcoin = { version = "0.23", features = ["rand", "use-serde"] } # TODO: Upgrade other crates in this repo to use this version. bitcoin-harness = { git = "https://github.com/coblox/bitcoin-harness-rs", rev = "3be644cd9512c157d3337a189298b8257ed54d04" } derivative = "2" +ecdsa_fun = { git = "https://github.com/LLFourn/secp256kfun", rev = "510d48ef6a2b19805f7f5c70c598e5b03f668e7a", features = ["libsecp_compat", "serde", "serialization"] } futures = { version = "0.3", default-features = false } genawaiter = "0.99.1" libp2p = { version = "0.29", default-features = false, features = ["tcp-tokio", "yamux", "mplex", "dns", "noise", "request-response"] } libp2p-tokio-socks5 = "0.4" log = { version = "0.4", features = ["serde"] } -monero = "0.9" +monero = { version = "0.9", features = ["serde_support"] } monero-harness = { path = "../monero-harness" } +prettytable-rs = "0.8" rand = "0.7" reqwest = { version = "0.10", default-features = false, features = ["socks"] } serde = { version = "1", features = ["derive"] } serde_cbor = "0.11" serde_derive = "1.0" serde_json = "1" +sha2 = "0.9" sled = "0.34" structopt = "0.3" tempfile = "3" @@ -39,6 +42,7 @@ tracing-futures = { version = "0.2", features = ["std-future", "futures-03"] } tracing-log = "0.1" tracing-subscriber = { version = "0.2", default-features = false, features = ["fmt", "ansi", "env-filter"] } url = "2.1" +uuid = { version = "0.8", features = ["serde", "v4"] } void = "1" xmr-btc = { path = "../xmr-btc" } diff --git a/swap/src/alice.rs b/swap/src/alice.rs index 036e4dc8..52ae7cc4 100644 --- a/swap/src/alice.rs +++ b/swap/src/alice.rs @@ -13,6 +13,7 @@ use rand::rngs::OsRng; use std::{sync::Arc, time::Duration}; use tokio::sync::Mutex; use tracing::{debug, info, warn}; +use uuid::Uuid; mod amounts; mod message0; @@ -31,6 +32,8 @@ use crate::{ transport::SwapTransport, TokioExecutor, }, + state, + storage::Database, SwapAmounts, PUNISH_TIMELOCK, REFUND_TIMELOCK, }; use xmr_btc::{ @@ -43,6 +46,7 @@ use xmr_btc::{ pub async fn swap( bitcoin_wallet: Arc, monero_wallet: Arc, + db: Database, listen: Multiaddr, transport: SwapTransport, behaviour: Alice, @@ -71,7 +75,7 @@ pub async fn swap( // to `ConstantBackoff`. #[async_trait] impl ReceiveBitcoinRedeemEncsig for Network { - async fn receive_bitcoin_redeem_encsig(&mut self) -> xmr_btc::bitcoin::EncryptedSignature { + async fn receive_bitcoin_redeem_encsig(&mut self) -> bitcoin::EncryptedSignature { #[derive(Debug)] struct UnexpectedMessage; @@ -173,6 +177,10 @@ pub async fn swap( other => panic!("Unexpected event: {:?}", other), }; + let swap_id = Uuid::new_v4(); + db.insert_latest_state(swap_id, state::Alice::Handshaken(state3.clone()).into()) + .await?; + info!("Handshake complete, we now have State3 for Alice."); let network = Arc::new(Mutex::new(Network { @@ -183,7 +191,7 @@ pub async fn swap( let mut action_generator = action_generator( network.clone(), bitcoin_wallet.clone(), - state3, + state3.clone(), TX_LOCK_MINE_TIMEOUT, ); @@ -198,33 +206,68 @@ pub async fn swap( public_spend_key, public_view_key, }) => { + db.insert_latest_state(swap_id, state::Alice::BtcLocked(state3.clone()).into()) + .await?; + let (transfer_proof, _) = monero_wallet .transfer(public_spend_key, public_view_key, amount) .await?; + db.insert_latest_state(swap_id, state::Alice::XmrLocked(state3.clone()).into()) + .await?; + let mut guard = network.as_ref().lock().await; guard.send_message2(transfer_proof).await; info!("Sent transfer proof"); } GeneratorState::Yielded(Action::RedeemBtc(tx)) => { + db.insert_latest_state( + swap_id, + state::Alice::BtcRedeemable { + state: state3.clone(), + redeem_tx: tx.clone(), + } + .into(), + ) + .await?; + let _ = bitcoin_wallet.broadcast_signed_transaction(tx).await?; } GeneratorState::Yielded(Action::CancelBtc(tx)) => { let _ = bitcoin_wallet.broadcast_signed_transaction(tx).await?; } GeneratorState::Yielded(Action::PunishBtc(tx)) => { + db.insert_latest_state(swap_id, state::Alice::BtcPunishable(state3.clone()).into()) + .await?; + let _ = bitcoin_wallet.broadcast_signed_transaction(tx).await?; } GeneratorState::Yielded(Action::CreateMoneroWalletForOutput { spend_key, view_key, }) => { + db.insert_latest_state( + swap_id, + state::Alice::BtcRefunded { + state: state3.clone(), + spend_key, + view_key, + } + .into(), + ) + .await?; + monero_wallet .create_and_load_wallet_for_output(spend_key, view_key) .await?; } - GeneratorState::Complete(()) => return Ok(()), + GeneratorState::Complete(()) => { + db.insert_latest_state(swap_id, state::Alice::SwapComplete.into()) + .await?; + + return Ok(()); + } } } } diff --git a/swap/src/bitcoin.rs b/swap/src/bitcoin.rs index 2d88ae2c..15c1e76b 100644 --- a/swap/src/bitcoin.rs +++ b/swap/src/bitcoin.rs @@ -8,13 +8,13 @@ use bitcoin_harness::bitcoind_rpc::PsbtBase64; use reqwest::Url; use tokio::time; use xmr_btc::bitcoin::{ - Amount, BlockHeight, BroadcastSignedTransaction, BuildTxLockPsbt, SignTxLock, - TransactionBlockHeight, TxLock, Txid, WatchForRawTransaction, + BlockHeight, BroadcastSignedTransaction, BuildTxLockPsbt, SignTxLock, TransactionBlockHeight, + WatchForRawTransaction, }; -pub const TX_LOCK_MINE_TIMEOUT: u64 = 3600; +pub use xmr_btc::bitcoin::*; -// This is cut'n'paste from xmr_btc/tests/harness/wallet/bitcoin.rs +pub const TX_LOCK_MINE_TIMEOUT: u64 = 3600; #[derive(Debug)] pub struct Wallet(pub bitcoin_harness::Wallet); diff --git a/swap/src/bob.rs b/swap/src/bob.rs index 2f973b99..3b5c5936 100644 --- a/swap/src/bob.rs +++ b/swap/src/bob.rs @@ -13,6 +13,7 @@ use rand::rngs::OsRng; use std::{process, sync::Arc, time::Duration}; use tokio::sync::Mutex; use tracing::{debug, info, warn}; +use uuid::Uuid; mod amounts; mod message0; @@ -22,14 +23,15 @@ mod message3; use self::{amounts::*, message0::*, message1::*, message2::*, message3::*}; use crate::{ - bitcoin, - bitcoin::TX_LOCK_MINE_TIMEOUT, + bitcoin::{self, TX_LOCK_MINE_TIMEOUT}, monero, network::{ peer_tracker::{self, PeerTracker}, transport::SwapTransport, TokioExecutor, }, + state, + storage::Database, Cmd, Rsp, SwapAmounts, PUNISH_TIMELOCK, REFUND_TIMELOCK, }; use xmr_btc::{ @@ -43,6 +45,7 @@ use xmr_btc::{ pub async fn swap( bitcoin_wallet: Arc, monero_wallet: Arc, + db: Database, btc: u64, addr: Multiaddr, mut cmd_tx: Sender, @@ -141,6 +144,10 @@ pub async fn swap( other => panic!("unexpected event: {:?}", other), }; + let swap_id = Uuid::new_v4(); + db.insert_latest_state(swap_id, state::Bob::Handshaken(state2.clone()).into()) + .await?; + swarm.send_message2(alice.clone(), state2.next_message()); info!("Handshake complete"); @@ -151,7 +158,7 @@ pub async fn swap( network.clone(), monero_wallet.clone(), bitcoin_wallet.clone(), - state2, + state2.clone(), TX_LOCK_MINE_TIMEOUT, ); @@ -160,20 +167,29 @@ pub async fn swap( info!("Resumed execution of generator, got: {:?}", state); + // TODO: Protect against transient errors + // TODO: Ignore transaction-already-in-block-chain errors + match state { GeneratorState::Yielded(bob::Action::LockBtc(tx_lock)) => { let signed_tx_lock = bitcoin_wallet.sign_tx_lock(tx_lock).await?; let _ = bitcoin_wallet .broadcast_signed_transaction(signed_tx_lock) .await?; + db.insert_latest_state(swap_id, state::Bob::BtcLocked(state2.clone()).into()) + .await?; } GeneratorState::Yielded(bob::Action::SendBtcRedeemEncsig(tx_redeem_encsig)) => { + db.insert_latest_state(swap_id, state::Bob::XmrLocked(state2.clone()).into()) + .await?; + let mut guard = network.as_ref().lock().await; guard.0.send_message3(alice.clone(), tx_redeem_encsig); info!("Sent Bitcoin redeem encsig"); - // TODO: Does Bob need to wait for Alice to send an empty response, or can we - // just continue? + // FIXME: Having to wait for Alice's response here is a big problem, because + // we're stuck if she doesn't send her response back. I believe this is + // currently necessary, so we may have to rework this and/or how we use libp2p match guard.0.next().shared().await { OutEvent::Message3 => { debug!("Got Message3 empty response"); @@ -185,21 +201,35 @@ pub async fn swap( spend_key, view_key, }) => { + db.insert_latest_state(swap_id, state::Bob::BtcRedeemed(state2.clone()).into()) + .await?; + monero_wallet .create_and_load_wallet_for_output(spend_key, view_key) .await?; } GeneratorState::Yielded(bob::Action::CancelBtc(tx_cancel)) => { + db.insert_latest_state(swap_id, state::Bob::BtcRefundable(state2.clone()).into()) + .await?; + let _ = bitcoin_wallet .broadcast_signed_transaction(tx_cancel) .await?; } GeneratorState::Yielded(bob::Action::RefundBtc(tx_refund)) => { + db.insert_latest_state(swap_id, state::Bob::BtcRefundable(state2.clone()).into()) + .await?; + let _ = bitcoin_wallet .broadcast_signed_transaction(tx_refund) .await?; } - GeneratorState::Complete(()) => return Ok(()), + GeneratorState::Complete(()) => { + db.insert_latest_state(swap_id, state::Bob::SwapComplete.into()) + .await?; + + return Ok(()); + } } } } diff --git a/swap/src/cli.rs b/swap/src/cli.rs index aff4d67c..2d13fcb8 100644 --- a/swap/src/cli.rs +++ b/swap/src/cli.rs @@ -1,5 +1,6 @@ use libp2p::core::Multiaddr; use url::Url; +use uuid::Uuid; #[derive(structopt::StructOpt, Debug)] #[structopt(name = "xmr-btc-swap", about = "Trustless XMR BTC swaps")] @@ -8,7 +9,7 @@ pub enum Options { #[structopt(default_value = "http://127.0.0.1:8332", long = "bitcoind")] bitcoind_url: Url, - #[structopt(default_value = "http://127.0.0.1:18083", long = "monerod")] + #[structopt(default_value = "http://127.0.0.1:18083/json_rpc", long = "monerod")] monerod_url: Url, #[structopt(default_value = "/ip4/127.0.0.1/tcp/9876", long = "listen-addr")] @@ -27,10 +28,21 @@ pub enum Options { #[structopt(default_value = "http://127.0.0.1:8332", long = "bitcoind")] bitcoind_url: Url, - #[structopt(default_value = "http://127.0.0.1:18083", long = "monerod")] + #[structopt(default_value = "http://127.0.0.1:18083/json_rpc", long = "monerod")] monerod_url: Url, #[structopt(long = "tor")] tor: bool, }, + History, + Recover { + #[structopt(required = true)] + swap_id: Uuid, + + #[structopt(default_value = "http://127.0.0.1:8332", long = "bitcoind")] + bitcoind_url: Url, + + #[structopt(default_value = "http://127.0.0.1:18083/json_rpc", long = "monerod")] + monerod_url: Url, + }, } diff --git a/swap/src/lib.rs b/swap/src/lib.rs index 32743dd3..cdc8673f 100644 --- a/swap/src/lib.rs +++ b/swap/src/lib.rs @@ -6,6 +6,8 @@ pub mod bitcoin; pub mod bob; pub mod monero; pub mod network; +pub mod recover; +pub mod state; pub mod storage; pub mod tor; @@ -32,10 +34,10 @@ pub enum Rsp { pub struct SwapAmounts { /// Amount of BTC to swap. #[serde(with = "::bitcoin::util::amount::serde::as_sat")] - pub btc: ::bitcoin::Amount, + pub btc: bitcoin::Amount, /// Amount of XMR to swap. #[serde(with = "xmr_btc::serde::monero_amount")] - pub xmr: xmr_btc::monero::Amount, + pub xmr: monero::Amount, } // TODO: Display in XMR and BTC (not picos and sats). diff --git a/swap/src/main.rs b/swap/src/main.rs index 2f8fc74c..afdf110c 100644 --- a/swap/src/main.rs +++ b/swap/src/main.rs @@ -16,23 +16,28 @@ use anyhow::Result; use futures::{channel::mpsc, StreamExt}; use libp2p::Multiaddr; use log::LevelFilter; +use prettytable::{row, Table}; use std::{io, io::Write, process, sync::Arc}; use structopt::StructOpt; use swap::{ - alice, - alice::Alice, - bitcoin, bob, - bob::Bob, + alice::{self, Alice}, + bitcoin, + bob::{self, Bob}, monero, network::transport::{build, build_tor, SwapTransport}, + recover::recover, Cmd, Rsp, SwapAmounts, }; use tracing::info; +#[macro_use] +extern crate prettytable; + mod cli; mod trace; use cli::Options; +use swap::storage::Database; // TODO: Add root seed file instead of generating new seed each run. @@ -42,6 +47,9 @@ async fn main() -> Result<()> { trace::init_tracing(LevelFilter::Debug)?; + // This currently creates the directory if it's not there in the first place + let db = Database::open(std::path::Path::new("./.swap-db/")).unwrap(); + match opt { Options::Alice { bitcoind_url, @@ -83,6 +91,7 @@ async fn main() -> Result<()> { swap_as_alice( bitcoin_wallet, monero_wallet, + db, listen_addr, transport, behaviour, @@ -116,6 +125,7 @@ async fn main() -> Result<()> { swap_as_bob( bitcoin_wallet, monero_wallet, + db, satoshis, alice_addr, transport, @@ -123,6 +133,31 @@ async fn main() -> Result<()> { ) .await?; } + Options::History => { + let mut table = Table::new(); + + table.add_row(row!["SWAP ID", "STATE"]); + + for (swap_id, state) in db.all()? { + table.add_row(row![swap_id, state]); + } + + // Print the table to stdout + table.printstd(); + } + Options::Recover { + swap_id, + bitcoind_url, + monerod_url, + } => { + let state = db.get_state(swap_id)?; + let bitcoin_wallet = bitcoin::Wallet::new("bob", bitcoind_url) + .await + .expect("failed to create bitcoin wallet"); + let monero_wallet = monero::Wallet::new(monerod_url); + + recover(bitcoin_wallet, monero_wallet, state).await?; + } } Ok(()) @@ -149,16 +184,26 @@ async fn create_tor_service( async fn swap_as_alice( bitcoin_wallet: Arc, monero_wallet: Arc, + db: Database, addr: Multiaddr, transport: SwapTransport, behaviour: Alice, ) -> Result<()> { - alice::swap(bitcoin_wallet, monero_wallet, addr, transport, behaviour).await + alice::swap( + bitcoin_wallet, + monero_wallet, + db, + addr, + transport, + behaviour, + ) + .await } async fn swap_as_bob( bitcoin_wallet: Arc, monero_wallet: Arc, + db: Database, sats: u64, alice: Multiaddr, transport: SwapTransport, @@ -169,6 +214,7 @@ async fn swap_as_bob( tokio::spawn(bob::swap( bitcoin_wallet, monero_wallet, + db, sats, alice, cmd_tx, diff --git a/swap/src/monero.rs b/swap/src/monero.rs index eb9f9bbc..7d252b69 100644 --- a/swap/src/monero.rs +++ b/swap/src/monero.rs @@ -1,15 +1,11 @@ use anyhow::Result; use async_trait::async_trait; use backoff::{backoff::Constant as ConstantBackoff, future::FutureOperation as _}; -use monero::{Address, Network, PrivateKey}; use monero_harness::rpc::wallet; use std::{str::FromStr, time::Duration}; - use url::Url; -pub use xmr_btc::monero::{ - Amount, CreateWalletForOutput, InsufficientFunds, PrivateViewKey, PublicKey, PublicViewKey, - Transfer, TransferProof, TxHash, WatchForTransfer, *, -}; + +pub use xmr_btc::monero::*; pub struct Wallet(pub wallet::Client); diff --git a/swap/src/recover.rs b/swap/src/recover.rs new file mode 100644 index 00000000..b71b31fd --- /dev/null +++ b/swap/src/recover.rs @@ -0,0 +1,481 @@ +//! This module is used to attempt to recover an unfinished swap. +//! +//! Recovery is only supported for certain states and the strategy followed is +//! to perform the simplest steps that require no further action from the +//! counterparty. +//! +//! The quality of this module is bad because there is a lot of code +//! duplication, both within the module and with respect to +//! `xmr_btc/src/{alice,bob}.rs`. In my opinion, a better approach to support +//! swap recovery would be through the `action_generator`s themselves, but this +//! was deemed too complicated for the time being. + +use crate::{ + bitcoin, monero, + monero::CreateWalletForOutput, + state::{Alice, Bob, Swap}, +}; +use anyhow::Result; +use ecdsa_fun::{adaptor::Adaptor, nonce::Deterministic}; +use futures::{ + future::{select, Either}, + pin_mut, +}; +use sha2::Sha256; +use tracing::info; +use xmr_btc::bitcoin::{ + poll_until_block_height_is_gte, BroadcastSignedTransaction, TransactionBlockHeight, + WatchForRawTransaction, +}; + +pub async fn recover( + bitcoin_wallet: bitcoin::Wallet, + monero_wallet: monero::Wallet, + state: Swap, +) -> Result<()> { + match state { + Swap::Alice(state) => alice_recover(bitcoin_wallet, monero_wallet, state).await, + Swap::Bob(state) => bob_recover(bitcoin_wallet, monero_wallet, state).await, + } +} + +pub async fn alice_recover( + bitcoin_wallet: bitcoin::Wallet, + monero_wallet: monero::Wallet, + state: Alice, +) -> Result<()> { + match state { + Alice::Handshaken(_) | Alice::BtcLocked(_) | Alice::SwapComplete => { + info!("Nothing to do"); + } + Alice::XmrLocked(state) => { + info!("Monero still locked up"); + + let tx_cancel = bitcoin::TxCancel::new( + &state.tx_lock, + state.refund_timelock, + state.a.public(), + state.B.clone(), + ); + + info!("Checking if the Bitcoin cancel transaction has been published"); + if bitcoin_wallet + .0 + .get_raw_transaction(tx_cancel.txid()) + .await + .is_err() + { + info!("Bitcoin cancel transaction not yet published"); + + let tx_lock_height = bitcoin_wallet + .transaction_block_height(state.tx_lock.txid()) + .await; + poll_until_block_height_is_gte( + &bitcoin_wallet, + tx_lock_height + state.refund_timelock, + ) + .await; + + let sig_a = state.a.sign(tx_cancel.digest()); + let sig_b = state.tx_cancel_sig_bob.clone(); + + let tx_cancel = tx_cancel + .clone() + .add_signatures( + &state.tx_lock, + (state.a.public(), sig_a), + (state.B.clone(), sig_b), + ) + .expect("sig_{a,b} to be valid signatures for tx_cancel"); + + // TODO: We should not fail if the transaction is already on the blockchain + bitcoin_wallet + .broadcast_signed_transaction(tx_cancel) + .await?; + } + + info!("Confirmed that Bitcoin cancel transaction is on the blockchain"); + + let tx_cancel_height = bitcoin_wallet + .transaction_block_height(tx_cancel.txid()) + .await; + let poll_until_bob_can_be_punished = poll_until_block_height_is_gte( + &bitcoin_wallet, + tx_cancel_height + state.punish_timelock, + ); + pin_mut!(poll_until_bob_can_be_punished); + + let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &state.refund_address); + + info!("Waiting for either Bitcoin refund or punish timelock"); + match select( + bitcoin_wallet.watch_for_raw_transaction(tx_refund.txid()), + poll_until_bob_can_be_punished, + ) + .await + { + Either::Left((tx_refund_published, ..)) => { + info!("Found Bitcoin refund transaction"); + + let s_a = monero::PrivateKey { + scalar: state.s_a.into_ed25519(), + }; + + let tx_refund_sig = tx_refund + .extract_signature_by_key(tx_refund_published, state.a.public())?; + let tx_refund_encsig = state + .a + .encsign(state.S_b_bitcoin.clone(), tx_refund.digest()); + + let s_b = bitcoin::recover(state.S_b_bitcoin, tx_refund_sig, tx_refund_encsig)?; + let s_b = monero::PrivateKey::from_scalar( + monero::Scalar::from_bytes_mod_order(s_b.to_bytes()), + ); + + monero_wallet + .create_and_load_wallet_for_output(s_a + s_b, state.v) + .await?; + info!("Successfully refunded monero"); + } + Either::Right(_) => { + info!("Punish timelock reached, attempting to punish Bob"); + + let tx_punish = bitcoin::TxPunish::new( + &tx_cancel, + &state.punish_address, + state.punish_timelock, + ); + + let sig_a = state.a.sign(tx_punish.digest()); + let sig_b = state.tx_punish_sig_bob.clone(); + + let sig_tx_punish = tx_punish.add_signatures( + &tx_cancel, + (state.a.public(), sig_a), + (state.B.clone(), sig_b), + )?; + + bitcoin_wallet + .broadcast_signed_transaction(sig_tx_punish) + .await?; + info!("Successfully punished Bob's inactivity by taking bitcoin"); + } + }; + } + Alice::BtcRedeemable { redeem_tx, state } => { + info!("Have the means to redeem the Bitcoin"); + + let tx_lock_height = bitcoin_wallet + .transaction_block_height(state.tx_lock.txid()) + .await; + + let block_height = bitcoin_wallet.0.block_height().await?; + let refund_absolute_expiry = tx_lock_height + state.refund_timelock; + + info!("Checking refund timelock"); + if block_height < refund_absolute_expiry { + info!("Safe to redeem"); + + bitcoin_wallet + .broadcast_signed_transaction(redeem_tx) + .await?; + info!("Successfully redeemed bitcoin"); + } else { + info!("Refund timelock reached"); + + let tx_cancel = bitcoin::TxCancel::new( + &state.tx_lock, + state.refund_timelock, + state.a.public(), + state.B.clone(), + ); + + info!("Checking if the Bitcoin cancel transaction has been published"); + if bitcoin_wallet + .0 + .get_raw_transaction(tx_cancel.txid()) + .await + .is_err() + { + let sig_a = state.a.sign(tx_cancel.digest()); + let sig_b = state.tx_cancel_sig_bob.clone(); + + let tx_cancel = tx_cancel + .clone() + .add_signatures( + &state.tx_lock, + (state.a.public(), sig_a), + (state.B.clone(), sig_b), + ) + .expect("sig_{a,b} to be valid signatures for tx_cancel"); + + // TODO: We should not fail if the transaction is already on the blockchain + bitcoin_wallet + .broadcast_signed_transaction(tx_cancel) + .await?; + } + + info!("Confirmed that Bitcoin cancel transaction is on the blockchain"); + + let tx_cancel_height = bitcoin_wallet + .transaction_block_height(tx_cancel.txid()) + .await; + let poll_until_bob_can_be_punished = poll_until_block_height_is_gte( + &bitcoin_wallet, + tx_cancel_height + state.punish_timelock, + ); + pin_mut!(poll_until_bob_can_be_punished); + + let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &state.refund_address); + + info!("Waiting for either Bitcoin refund or punish timelock"); + match select( + bitcoin_wallet.watch_for_raw_transaction(tx_refund.txid()), + poll_until_bob_can_be_punished, + ) + .await + { + Either::Left((tx_refund_published, ..)) => { + info!("Found Bitcoin refund transaction"); + + let s_a = monero::PrivateKey { + scalar: state.s_a.into_ed25519(), + }; + + let tx_refund_sig = tx_refund + .extract_signature_by_key(tx_refund_published, state.a.public())?; + let tx_refund_encsig = state + .a + .encsign(state.S_b_bitcoin.clone(), tx_refund.digest()); + + let s_b = + bitcoin::recover(state.S_b_bitcoin, tx_refund_sig, tx_refund_encsig)?; + let s_b = monero::PrivateKey::from_scalar( + monero::Scalar::from_bytes_mod_order(s_b.to_bytes()), + ); + + monero_wallet + .create_and_load_wallet_for_output(s_a + s_b, state.v) + .await?; + info!("Successfully refunded monero"); + } + Either::Right(_) => { + info!("Punish timelock reached, attempting to punish Bob"); + + let tx_punish = bitcoin::TxPunish::new( + &tx_cancel, + &state.punish_address, + state.punish_timelock, + ); + + let sig_a = state.a.sign(tx_punish.digest()); + let sig_b = state.tx_punish_sig_bob.clone(); + + let sig_tx_punish = tx_punish.add_signatures( + &tx_cancel, + (state.a.public(), sig_a), + (state.B.clone(), sig_b), + )?; + + bitcoin_wallet + .broadcast_signed_transaction(sig_tx_punish) + .await?; + info!("Successfully punished Bob's inactivity by taking bitcoin"); + } + }; + } + } + Alice::BtcPunishable(state) => { + info!("Punish timelock reached, attempting to punish Bob"); + + let tx_cancel = bitcoin::TxCancel::new( + &state.tx_lock, + state.refund_timelock, + state.a.public(), + state.B.clone(), + ); + let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &state.refund_address); + + info!("Checking if Bitcoin has already been refunded"); + + // TODO: Protect against transient errors so that we can correctly decide if the + // bitcoin has been refunded + match bitcoin_wallet.0.get_raw_transaction(tx_refund.txid()).await { + Ok(tx_refund_published) => { + info!("Bitcoin already refunded"); + + let s_a = monero::PrivateKey { + scalar: state.s_a.into_ed25519(), + }; + + let tx_refund_sig = tx_refund + .extract_signature_by_key(tx_refund_published, state.a.public())?; + let tx_refund_encsig = state + .a + .encsign(state.S_b_bitcoin.clone(), tx_refund.digest()); + + let s_b = bitcoin::recover(state.S_b_bitcoin, tx_refund_sig, tx_refund_encsig)?; + let s_b = monero::PrivateKey::from_scalar( + monero::Scalar::from_bytes_mod_order(s_b.to_bytes()), + ); + + monero_wallet + .create_and_load_wallet_for_output(s_a + s_b, state.v) + .await?; + info!("Successfully refunded monero"); + } + Err(_) => { + info!("Bitcoin not yet refunded"); + + let tx_punish = bitcoin::TxPunish::new( + &tx_cancel, + &state.punish_address, + state.punish_timelock, + ); + + let sig_a = state.a.sign(tx_punish.digest()); + let sig_b = state.tx_punish_sig_bob.clone(); + + let sig_tx_punish = tx_punish.add_signatures( + &tx_cancel, + (state.a.public(), sig_a), + (state.B.clone(), sig_b), + )?; + + bitcoin_wallet + .broadcast_signed_transaction(sig_tx_punish) + .await?; + info!("Successfully punished Bob's inactivity by taking bitcoin"); + } + } + } + Alice::BtcRefunded { + view_key, + spend_key, + .. + } => { + info!("Bitcoin was refunded, attempting to refund monero"); + + monero_wallet + .create_and_load_wallet_for_output(spend_key, view_key) + .await?; + info!("Successfully refunded monero"); + } + }; + + Ok(()) +} + +pub async fn bob_recover( + bitcoin_wallet: crate::bitcoin::Wallet, + monero_wallet: crate::monero::Wallet, + state: Bob, +) -> Result<()> { + match state { + Bob::Handshaken(_) | Bob::SwapComplete => { + info!("Nothing to do"); + } + Bob::BtcLocked(state) | Bob::XmrLocked(state) | Bob::BtcRefundable(state) => { + info!("Bitcoin may still be locked up, attempting to refund"); + + let tx_cancel = bitcoin::TxCancel::new( + &state.tx_lock, + state.refund_timelock, + state.A.clone(), + state.b.public(), + ); + + info!("Checking if the Bitcoin cancel transaction has been published"); + if bitcoin_wallet + .0 + .get_raw_transaction(tx_cancel.txid()) + .await + .is_err() + { + info!("Bitcoin cancel transaction not yet published"); + + let tx_lock_height = bitcoin_wallet + .transaction_block_height(state.tx_lock.txid()) + .await; + poll_until_block_height_is_gte( + &bitcoin_wallet, + tx_lock_height + state.refund_timelock, + ) + .await; + + let sig_a = state.tx_cancel_sig_a.clone(); + let sig_b = state.b.sign(tx_cancel.digest()); + + let tx_cancel = tx_cancel + .clone() + .add_signatures( + &state.tx_lock, + (state.A.clone(), sig_a), + (state.b.public(), sig_b), + ) + .expect("sig_{a,b} to be valid signatures for tx_cancel"); + + // TODO: We should not fail if the transaction is already on the blockchain + bitcoin_wallet + .broadcast_signed_transaction(tx_cancel) + .await?; + } + + info!("Confirmed that Bitcoin cancel transaction is on the blockchain"); + + let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &state.refund_address); + let signed_tx_refund = { + let adaptor = Adaptor::>::default(); + let sig_a = adaptor + .decrypt_signature(&state.s_b.into_secp256k1(), state.tx_refund_encsig.clone()); + let sig_b = state.b.sign(tx_refund.digest()); + + tx_refund + .add_signatures( + &tx_cancel, + (state.A.clone(), sig_a), + (state.b.public(), sig_b), + ) + .expect("sig_{a,b} to be valid signatures for tx_refund") + }; + + // TODO: Check if Bitcoin has already been punished and provide a useful error + // message/log to the user if so + bitcoin_wallet + .broadcast_signed_transaction(signed_tx_refund) + .await?; + info!("Successfully refunded bitcoin"); + } + Bob::BtcRedeemed(state) => { + info!("Bitcoin was redeemed, attempting to redeem monero"); + + let tx_redeem = bitcoin::TxRedeem::new(&state.tx_lock, &state.redeem_address); + let tx_redeem_published = bitcoin_wallet + .0 + .get_raw_transaction(tx_redeem.txid()) + .await?; + + let tx_redeem_encsig = state + .b + .encsign(state.S_a_bitcoin.clone(), tx_redeem.digest()); + let tx_redeem_sig = + tx_redeem.extract_signature_by_key(tx_redeem_published, state.b.public())?; + + let s_a = bitcoin::recover(state.S_a_bitcoin, tx_redeem_sig, tx_redeem_encsig)?; + let s_a = monero::PrivateKey::from_scalar(monero::Scalar::from_bytes_mod_order( + s_a.to_bytes(), + )); + + let s_b = monero::PrivateKey { + scalar: state.s_b.into_ed25519(), + }; + + monero_wallet + .create_and_load_wallet_for_output(s_a + s_b, state.v) + .await?; + info!("Successfully redeemed monero") + } + }; + + Ok(()) +} diff --git a/swap/src/state.rs b/swap/src/state.rs new file mode 100644 index 00000000..701c61e0 --- /dev/null +++ b/swap/src/state.rs @@ -0,0 +1,88 @@ +use serde::{Deserialize, Serialize}; +use std::fmt::Display; +use xmr_btc::{alice, bob, monero, serde::monero_private_key}; + +#[allow(clippy::large_enum_variant)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub enum Swap { + Alice(Alice), + Bob(Bob), +} + +#[allow(clippy::large_enum_variant)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub enum Alice { + Handshaken(alice::State3), + BtcLocked(alice::State3), + XmrLocked(alice::State3), + BtcRedeemable { + state: alice::State3, + redeem_tx: bitcoin::Transaction, + }, + BtcPunishable(alice::State3), + BtcRefunded { + state: alice::State3, + #[serde(with = "monero_private_key")] + spend_key: monero::PrivateKey, + view_key: monero::PrivateViewKey, + }, + SwapComplete, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub enum Bob { + Handshaken(bob::State2), + BtcLocked(bob::State2), + XmrLocked(bob::State2), + BtcRedeemed(bob::State2), + BtcRefundable(bob::State2), + SwapComplete, +} + +impl From for Swap { + fn from(from: Alice) -> Self { + Swap::Alice(from) + } +} + +impl From for Swap { + fn from(from: Bob) -> Self { + Swap::Bob(from) + } +} + +impl Display for Swap { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Swap::Alice(alice) => Display::fmt(alice, f), + Swap::Bob(bob) => Display::fmt(bob, f), + } + } +} + +impl Display for Alice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Alice::Handshaken(_) => f.write_str("Handshake complete"), + Alice::BtcLocked(_) => f.write_str("Bitcoin locked"), + Alice::XmrLocked(_) => f.write_str("Monero locked"), + Alice::BtcRedeemable { .. } => f.write_str("Bitcoin redeemable"), + Alice::BtcPunishable(_) => f.write_str("Bitcoin punishable"), + Alice::BtcRefunded { .. } => f.write_str("Monero refundable"), + Alice::SwapComplete => f.write_str("Swap complete"), + } + } +} + +impl Display for Bob { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Bob::Handshaken(_) => f.write_str("Handshake complete"), + Bob::BtcLocked(_) | Bob::XmrLocked(_) | Bob::BtcRefundable(_) => { + f.write_str("Bitcoin refundable") + } + Bob::BtcRedeemed(_) => f.write_str("Monero redeemable"), + Bob::SwapComplete => f.write_str("Swap complete"), + } + } +} diff --git a/swap/src/storage.rs b/swap/src/storage.rs index 206dbfa1..47c6ce11 100644 --- a/swap/src/storage.rs +++ b/swap/src/storage.rs @@ -1,62 +1,68 @@ -use anyhow::{anyhow, Context, Result}; +use crate::state::Swap; +use anyhow::{anyhow, bail, Context, Result}; use serde::{de::DeserializeOwned, Serialize}; use std::path::Path; +use uuid::Uuid; -pub struct Database -where - T: Serialize + DeserializeOwned, -{ - db: sled::Db, - _marker: std::marker::PhantomData, -} - -impl Database -where - T: Serialize + DeserializeOwned, -{ - // TODO: serialize using lazy/one-time initlisation - const LAST_STATE_KEY: &'static str = "latest_state"; +pub struct Database(sled::Db); +impl Database { pub fn open(path: &Path) -> Result { let db = sled::open(path).with_context(|| format!("Could not open the DB at {:?}", path))?; - Ok(Database { - db, - _marker: Default::default(), - }) + Ok(Database(db)) } - pub async fn insert_latest_state(&self, state: &T) -> Result<()> { - let key = serialize(&Self::LAST_STATE_KEY)?; + pub async fn insert_latest_state(&self, swap_id: Uuid, state: Swap) -> Result<()> { + let key = serialize(&swap_id)?; let new_value = serialize(&state).context("Could not serialize new state value")?; - let old_value = self.db.get(&key)?; + let old_value = self.0.get(&key)?; - self.db + self.0 .compare_and_swap(key, old_value, Some(new_value)) .context("Could not write in the DB")? .context("Stored swap somehow changed, aborting saving")?; // TODO: see if this can be done through sled config - self.db + self.0 .flush_async() .await .map(|_| ()) .context("Could not flush db") } - pub fn get_latest_state(&self) -> anyhow::Result { - let key = serialize(&Self::LAST_STATE_KEY)?; + pub fn get_state(&self, swap_id: Uuid) -> anyhow::Result { + let key = serialize(&swap_id)?; let encoded = self - .db + .0 .get(&key)? .ok_or_else(|| anyhow!("State does not exist {:?}", key))?; let state = deserialize(&encoded).context("Could not deserialize state")?; Ok(state) } + + pub fn all(&self) -> Result> { + self.0 + .iter() + .map(|item| match item { + Ok((key, value)) => { + let swap_id = deserialize::(&key); + let swap = deserialize::(&value).context("failed to deserialize swap"); + + match (swap_id, swap) { + (Ok(swap_id), Ok(swap)) => Ok((swap_id, swap)), + (Ok(_), Err(err)) => Err(err), + _ => bail!("failed to deserialize swap"), + } + } + Err(err) => Err(err).context("failed to retrieve swap from DB"), + }) + .collect() + } } pub fn serialize(t: &T) -> anyhow::Result> @@ -75,86 +81,87 @@ where #[cfg(test)] mod tests { - #![allow(non_snake_case)] + use crate::state::{Alice, Bob}; + use super::*; - use bitcoin::SigHash; - use rand::rngs::OsRng; - use serde::{Deserialize, Serialize}; - use std::str::FromStr; - use xmr_btc::{cross_curve_dleq, monero, serde::monero_private_key}; - - #[derive(Debug, Serialize, Deserialize, PartialEq)] - pub struct TestState { - A: xmr_btc::bitcoin::PublicKey, - a: xmr_btc::bitcoin::SecretKey, - s_a: cross_curve_dleq::Scalar, - #[serde(with = "monero_private_key")] - s_b: monero::PrivateKey, - S_a_monero: ::monero::PublicKey, - S_a_bitcoin: xmr_btc::bitcoin::PublicKey, - v: xmr_btc::monero::PrivateViewKey, - #[serde(with = "::bitcoin::util::amount::serde::as_sat")] - btc: ::bitcoin::Amount, - xmr: xmr_btc::monero::Amount, - refund_timelock: u32, - refund_address: ::bitcoin::Address, - transaction: ::bitcoin::Transaction, - tx_punish_sig: xmr_btc::bitcoin::Signature, + + #[tokio::test] + async fn can_write_and_read_to_multiple_keys() { + let db_dir = tempfile::tempdir().unwrap(); + let db = Database::open(db_dir.path()).unwrap(); + + let state_1 = Swap::Alice(Alice::SwapComplete); + let swap_id_1 = Uuid::new_v4(); + db.insert_latest_state(swap_id_1, state_1.clone()) + .await + .expect("Failed to save second state"); + + let state_2 = Swap::Bob(Bob::SwapComplete); + let swap_id_2 = Uuid::new_v4(); + db.insert_latest_state(swap_id_2, state_2.clone()) + .await + .expect("Failed to save first state"); + + let recovered_1 = db + .get_state(swap_id_1) + .expect("Failed to recover first state"); + + let recovered_2 = db + .get_state(swap_id_2) + .expect("Failed to recover second state"); + + assert_eq!(recovered_1, state_1); + assert_eq!(recovered_2, state_2); } #[tokio::test] - async fn recover_state_from_db() { + async fn can_write_twice_to_one_key() { let db_dir = tempfile::tempdir().unwrap(); let db = Database::open(db_dir.path()).unwrap(); - let a = xmr_btc::bitcoin::SecretKey::new_random(&mut OsRng); - let s_a = cross_curve_dleq::Scalar::random(&mut OsRng); - let s_b = monero::PrivateKey::from_scalar(monero::Scalar::random(&mut OsRng)); - let v_a = xmr_btc::monero::PrivateViewKey::new_random(&mut OsRng); - let S_a_monero = monero::PublicKey::from_private_key(&monero::PrivateKey { - scalar: s_a.into_ed25519(), - }); - let S_a_bitcoin = s_a.into_secp256k1().into(); - let tx_punish_sig = a.sign(SigHash::default()); - - let state = TestState { - A: a.public(), - a, - s_b, - s_a, - S_a_monero, - S_a_bitcoin, - v: v_a, - btc: ::bitcoin::Amount::from_sat(100), - xmr: xmr_btc::monero::Amount::from_piconero(1000), - refund_timelock: 0, - refund_address: ::bitcoin::Address::from_str("1L5wSMgerhHg8GZGcsNmAx5EXMRXSKR3He") - .unwrap(), - transaction: ::bitcoin::Transaction { - version: 0, - lock_time: 0, - input: vec![::bitcoin::TxIn::default()], - output: vec![::bitcoin::TxOut::default()], - }, - tx_punish_sig, - }; - - db.insert_latest_state(&state) + let state = Swap::Alice(Alice::SwapComplete); + + let swap_id = Uuid::new_v4(); + db.insert_latest_state(swap_id, state.clone()) .await .expect("Failed to save state the first time"); - let recovered: TestState = db - .get_latest_state() + let recovered = db + .get_state(swap_id) .expect("Failed to recover state the first time"); // We insert and recover twice to ensure database implementation allows the // caller to write to an existing key - db.insert_latest_state(&recovered) + db.insert_latest_state(swap_id, recovered) .await .expect("Failed to save state the second time"); - let recovered: TestState = db - .get_latest_state() + let recovered = db + .get_state(swap_id) .expect("Failed to recover state the second time"); - assert_eq!(state, recovered); + assert_eq!(recovered, state); + } + + #[tokio::test] + async fn can_fetch_all_keys() { + let db_dir = tempfile::tempdir().unwrap(); + let db = Database::open(db_dir.path()).unwrap(); + + let state_1 = Swap::Alice(Alice::SwapComplete); + let swap_id_1 = Uuid::new_v4(); + db.insert_latest_state(swap_id_1, state_1.clone()) + .await + .expect("Failed to save second state"); + + let state_2 = Swap::Bob(Bob::SwapComplete); + let swap_id_2 = Uuid::new_v4(); + db.insert_latest_state(swap_id_2, state_2.clone()) + .await + .expect("Failed to save first state"); + + let swaps = db.all().unwrap(); + + assert_eq!(swaps.len(), 2); + assert!(swaps.contains(&(swap_id_1, state_1))); + assert!(swaps.contains(&(swap_id_2, state_2))); } } diff --git a/swap/src/trace.rs b/swap/src/trace.rs index 14854cd4..c8f82e89 100644 --- a/swap/src/trace.rs +++ b/swap/src/trace.rs @@ -14,7 +14,10 @@ pub fn init_tracing(level: log::LevelFilter) -> anyhow::Result<()> { let is_terminal = atty::is(Stream::Stdout); let subscriber = FmtSubscriber::builder() - .with_env_filter(format!("swap={}", level)) + .with_env_filter(format!( + "swap={},xmr_btc={},monero_harness={}", + level, level, level + )) .with_ansi(is_terminal) .finish(); diff --git a/swap/tests/e2e.rs b/swap/tests/e2e.rs index 9c8eda1b..358657ca 100644 --- a/swap/tests/e2e.rs +++ b/swap/tests/e2e.rs @@ -1,117 +1,124 @@ -#[cfg(not(feature = "tor"))] -mod e2e_test { - use bitcoin_harness::Bitcoind; - use futures::{channel::mpsc, future::try_join}; - use libp2p::Multiaddr; - use monero_harness::Monero; - use std::sync::Arc; - use swap::{alice, bob, network::transport::build}; - use testcontainers::clients::Cli; - use tracing_subscriber::util::SubscriberInitExt; - - #[tokio::test] - async fn swap() { - let _guard = tracing_subscriber::fmt() - .with_env_filter( - "swap=debug,xmr_btc=debug,hyper=off,reqwest=off,monero_harness=info,testcontainers=info,libp2p=debug", - ) +use bitcoin_harness::Bitcoind; +use futures::{channel::mpsc, future::try_join}; +use libp2p::Multiaddr; +use monero_harness::Monero; +use std::sync::Arc; +use swap::{alice, bob, network::transport::build, storage::Database}; +use tempfile::tempdir; +use testcontainers::clients::Cli; +use xmr_btc::bitcoin; + +// NOTE: For some reason running these tests overflows the stack. In order to +// mitigate this run them with: +// +// RUST_MIN_STACK=100000000 cargo test + +#[tokio::test] +async fn swap() { + use tracing_subscriber::util::SubscriberInitExt as _; + let _guard = tracing_subscriber::fmt() + .with_env_filter("swap=info,xmr_btc=info") .with_ansi(false) .set_default(); - let alice_multiaddr: Multiaddr = "/ip4/127.0.0.1/tcp/9876" - .parse() - .expect("failed to parse Alice's address"); - - let cli = Cli::default(); - let bitcoind = Bitcoind::new(&cli, "0.19.1").unwrap(); - let _ = bitcoind.init(5).await; - - let btc = bitcoin::Amount::from_sat(1_000_000); - let btc_alice = bitcoin::Amount::ZERO; - let btc_bob = btc * 10; - - // this xmr value matches the logic of alice::calculate_amounts i.e. btc * - // 10_000 * 100 - let xmr = 1_000_000_000_000; - let xmr_alice = xmr * 10; - let xmr_bob = 0; - - let alice_btc_wallet = Arc::new( - swap::bitcoin::Wallet::new("alice", bitcoind.node_url.clone()) - .await - .unwrap(), - ); - let bob_btc_wallet = Arc::new( - swap::bitcoin::Wallet::new("bob", bitcoind.node_url.clone()) - .await - .unwrap(), - ); - bitcoind - .mint(bob_btc_wallet.0.new_address().await.unwrap(), btc_bob) - .await - .unwrap(); + let alice_multiaddr: Multiaddr = "/ip4/127.0.0.1/tcp/9876" + .parse() + .expect("failed to parse Alice's address"); + + let cli = Cli::default(); + let bitcoind = Bitcoind::new(&cli, "0.19.1").unwrap(); + dbg!(&bitcoind.node_url); + let _ = bitcoind.init(5).await; + + let btc = bitcoin::Amount::from_sat(1_000_000); + let btc_alice = bitcoin::Amount::ZERO; + let btc_bob = btc * 10; + + // this xmr value matches the logic of alice::calculate_amounts i.e. btc * + // 10_000 * 100 + let xmr = 1_000_000_000_000; + let xmr_alice = xmr * 10; + let xmr_bob = 0; - let (monero, _container) = Monero::new(&cli, Some("swap_".to_string()), vec![ - "alice".to_string(), - "bob".to_string(), - ]) + let alice_btc_wallet = Arc::new( + swap::bitcoin::Wallet::new("alice", bitcoind.node_url.clone()) + .await + .unwrap(), + ); + let bob_btc_wallet = Arc::new( + swap::bitcoin::Wallet::new("bob", bitcoind.node_url.clone()) + .await + .unwrap(), + ); + bitcoind + .mint(bob_btc_wallet.0.new_address().await.unwrap(), btc_bob) .await .unwrap(); - monero - .init(vec![("alice", xmr_alice), ("bob", xmr_bob)]) + + let (monero, _container) = + Monero::new(&cli, None, vec!["alice".to_string(), "bob".to_string()]) .await .unwrap(); + monero + .init(vec![("alice", xmr_alice), ("bob", xmr_bob)]) + .await + .unwrap(); - let alice_xmr_wallet = Arc::new(swap::monero::Wallet( - monero.wallet("alice").unwrap().client(), - )); - let bob_xmr_wallet = Arc::new(swap::monero::Wallet(monero.wallet("bob").unwrap().client())); - - let alice_behaviour = alice::Alice::default(); - let alice_transport = build(alice_behaviour.identity()).unwrap(); - let alice_swap = alice::swap( - alice_btc_wallet.clone(), - alice_xmr_wallet.clone(), - alice_multiaddr.clone(), - alice_transport, - alice_behaviour, - ); - - let (cmd_tx, mut _cmd_rx) = mpsc::channel(1); - let (mut rsp_tx, rsp_rx) = mpsc::channel(1); - let bob_behaviour = bob::Bob::default(); - let bob_transport = build(bob_behaviour.identity()).unwrap(); - let bob_swap = bob::swap( - bob_btc_wallet.clone(), - bob_xmr_wallet.clone(), - btc.as_sat(), - alice_multiaddr, - cmd_tx, - rsp_rx, - bob_transport, - bob_behaviour, - ); - - // automate the verification step by accepting any amounts sent over by Alice - rsp_tx.try_send(swap::Rsp::VerifiedAmounts).unwrap(); - - try_join(alice_swap, bob_swap).await.unwrap(); - - let btc_alice_final = alice_btc_wallet.as_ref().balance().await.unwrap(); - let btc_bob_final = bob_btc_wallet.as_ref().balance().await.unwrap(); - - let xmr_alice_final = alice_xmr_wallet.as_ref().get_balance().await.unwrap(); - - bob_xmr_wallet.as_ref().0.refresh().await.unwrap(); - let xmr_bob_final = bob_xmr_wallet.as_ref().get_balance().await.unwrap(); - - assert_eq!( - btc_alice_final, - btc_alice + btc - bitcoin::Amount::from_sat(xmr_btc::bitcoin::TX_FEE) - ); - assert!(btc_bob_final <= btc_bob - btc); - - assert!(xmr_alice_final.as_piconero() <= xmr_alice - xmr); - assert_eq!(xmr_bob_final.as_piconero(), xmr_bob + xmr); - } + let alice_xmr_wallet = Arc::new(swap::monero::Wallet( + monero.wallet("alice").unwrap().client(), + )); + let bob_xmr_wallet = Arc::new(swap::monero::Wallet(monero.wallet("bob").unwrap().client())); + + let alice_behaviour = alice::Alice::default(); + let alice_transport = build(alice_behaviour.identity()).unwrap(); + + let db = Database::open(std::path::Path::new("../.swap-db/")).unwrap(); + let alice_swap = alice::swap( + alice_btc_wallet.clone(), + alice_xmr_wallet.clone(), + db, + alice_multiaddr.clone(), + alice_transport, + alice_behaviour, + ); + + let db_dir = tempdir().unwrap(); + let db = Database::open(db_dir.path()).unwrap(); + let (cmd_tx, mut _cmd_rx) = mpsc::channel(1); + let (mut rsp_tx, rsp_rx) = mpsc::channel(1); + let bob_behaviour = bob::Bob::default(); + let bob_transport = build(bob_behaviour.identity()).unwrap(); + let bob_swap = bob::swap( + bob_btc_wallet.clone(), + bob_xmr_wallet.clone(), + db, + btc.as_sat(), + alice_multiaddr, + cmd_tx, + rsp_rx, + bob_transport, + bob_behaviour, + ); + + // automate the verification step by accepting any amounts sent over by Alice + rsp_tx.try_send(swap::Rsp::VerifiedAmounts).unwrap(); + + try_join(alice_swap, bob_swap).await.unwrap(); + + let btc_alice_final = alice_btc_wallet.as_ref().balance().await.unwrap(); + let btc_bob_final = bob_btc_wallet.as_ref().balance().await.unwrap(); + + let xmr_alice_final = alice_xmr_wallet.as_ref().get_balance().await.unwrap(); + + bob_xmr_wallet.as_ref().0.refresh().await.unwrap(); + let xmr_bob_final = bob_xmr_wallet.as_ref().get_balance().await.unwrap(); + + assert_eq!( + btc_alice_final, + btc_alice + btc - bitcoin::Amount::from_sat(bitcoin::TX_FEE) + ); + assert!(btc_bob_final <= btc_bob - btc); + + assert!(xmr_alice_final.as_piconero() <= xmr_alice - xmr); + assert_eq!(xmr_bob_final.as_piconero(), xmr_bob + xmr); } diff --git a/swap/tests/tor.rs b/swap/tests/tor.rs index 1a0a6de9..7fd3537f 100644 --- a/swap/tests/tor.rs +++ b/swap/tests/tor.rs @@ -13,7 +13,6 @@ mod tor_test { onion::TorSecretKeyV3, utils::{run_tor, AutoKillChild}, }; - use tracing_subscriber::util::SubscriberInitExt; async fn hello_world( _req: hyper::Request, @@ -76,10 +75,6 @@ mod tor_test { #[tokio::test] async fn test_tor_control_port() -> Result<()> { - let _guard = tracing_subscriber::fmt() - .with_env_filter("info") - .set_default(); - // start tmp tor let (_child, control_port, proxy_port, _tmp_torrc) = run_tmp_tor()?; diff --git a/xmr-btc/src/alice.rs b/xmr-btc/src/alice.rs index 99859e8d..fa35a140 100644 --- a/xmr-btc/src/alice.rs +++ b/xmr-btc/src/alice.rs @@ -97,7 +97,7 @@ where #[derive(Debug)] enum SwapFailed { BeforeBtcLock(Reason), - AfterXmrLock { tx_lock_height: u32, reason: Reason }, + AfterXmrLock(Reason), } /// Reason why the swap has failed. @@ -114,9 +114,7 @@ where #[derive(Debug)] enum RefundFailed { - BtcPunishable { - tx_cancel_was_published: bool, - }, + BtcPunishable, /// Could not find Alice's signature on the refund transaction witness /// stack. BtcRefundSignature, @@ -167,12 +165,7 @@ where .await { Either::Left((encsig, _)) => encsig, - Either::Right(_) => { - return Err(SwapFailed::AfterXmrLock { - reason: Reason::BtcExpired, - tx_lock_height, - }) - } + Either::Right(_) => return Err(SwapFailed::AfterXmrLock(Reason::BtcExpired)), }; tracing::debug!("select returned redeem encsig from message"); @@ -191,10 +184,7 @@ where &tx_redeem.digest(), &tx_redeem_encsig, ) - .map_err(|_| SwapFailed::AfterXmrLock { - reason: Reason::InvalidEncryptedSignature, - tx_lock_height, - })?; + .map_err(|_| SwapFailed::AfterXmrLock(Reason::InvalidEncryptedSignature))?; let sig_a = a.sign(tx_redeem.digest()); let sig_b = @@ -217,12 +207,7 @@ where .await { Either::Left(_) => {} - Either::Right(_) => { - return Err(SwapFailed::AfterXmrLock { - reason: Reason::BtcExpired, - tx_lock_height, - }) - } + Either::Right(_) => return Err(SwapFailed::AfterXmrLock(Reason::BtcExpired)), }; Ok(()) @@ -233,19 +218,8 @@ where error!("swap failed: {:?}", err); } - if let Err(SwapFailed::AfterXmrLock { - reason: Reason::BtcExpired, - tx_lock_height, - }) = swap_result - { + if let Err(SwapFailed::AfterXmrLock(Reason::BtcExpired)) = swap_result { let refund_result: Result<(), RefundFailed> = async { - let poll_until_bob_can_be_punished = poll_until_block_height_is_gte( - bitcoin_client.as_ref(), - tx_lock_height + refund_timelock + punish_timelock, - ) - .shared(); - pin_mut!(poll_until_bob_can_be_punished); - let tx_cancel = bitcoin::TxCancel::new(&tx_lock, refund_timelock, a.public(), B.clone()); let signed_tx_cancel = { @@ -260,19 +234,19 @@ where co.yield_(Action::CancelBtc(signed_tx_cancel)).await; - match select( - bitcoin_client.watch_for_raw_transaction(tx_cancel.txid()), - poll_until_bob_can_be_punished.clone(), + bitcoin_client + .watch_for_raw_transaction(tx_cancel.txid()) + .await; + + let tx_cancel_height = bitcoin_client + .transaction_block_height(tx_cancel.txid()) + .await; + let poll_until_bob_can_be_punished = poll_until_block_height_is_gte( + bitcoin_client.as_ref(), + tx_cancel_height + punish_timelock, ) - .await - { - Either::Left(_) => {} - Either::Right(_) => { - return Err(RefundFailed::BtcPunishable { - tx_cancel_was_published: false, - }) - } - }; + .shared(); + pin_mut!(poll_until_bob_can_be_punished); let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &refund_address); let tx_refund_published = match select( @@ -282,11 +256,7 @@ where .await { Either::Left((tx, _)) => tx, - Either::Right(_) => { - return Err(RefundFailed::BtcPunishable { - tx_cancel_was_published: true, - }); - } + Either::Right(_) => return Err(RefundFailed::BtcPunishable), }; let s_a = monero::PrivateKey { @@ -321,32 +291,9 @@ where // transaction with his refund transaction. Alice would then need to carry on // with the refund on Monero. Doing so may be too verbose with the current, // linear approach. A different design may be required - if let Err(RefundFailed::BtcPunishable { - tx_cancel_was_published, - }) = refund_result - { + if let Err(RefundFailed::BtcPunishable) = refund_result { let tx_cancel = bitcoin::TxCancel::new(&tx_lock, refund_timelock, a.public(), B.clone()); - - if !tx_cancel_was_published { - let tx_cancel_txid = tx_cancel.txid(); - let signed_tx_cancel = { - let sig_a = a.sign(tx_cancel.digest()); - let sig_b = tx_cancel_sig_bob; - - tx_cancel - .clone() - .add_signatures(&tx_lock, (a.public(), sig_a), (B.clone(), sig_b)) - .expect("sig_{a,b} to be valid signatures for tx_cancel") - }; - - co.yield_(Action::CancelBtc(signed_tx_cancel)).await; - - let _ = bitcoin_client - .watch_for_raw_transaction(tx_cancel_txid) - .await; - } - let tx_punish = bitcoin::TxPunish::new(&tx_cancel, &punish_address, punish_timelock); let tx_punish_txid = tx_punish.txid(); @@ -684,7 +631,7 @@ impl State2 { } } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct State3 { pub a: bitcoin::SecretKey, pub B: bitcoin::PublicKey, diff --git a/xmr-btc/src/bitcoin.rs b/xmr-btc/src/bitcoin.rs index e5e6ca6b..a095d64f 100644 --- a/xmr-btc/src/bitcoin.rs +++ b/xmr-btc/src/bitcoin.rs @@ -2,12 +2,7 @@ pub mod transactions; use anyhow::{anyhow, bail, Result}; use async_trait::async_trait; -use bitcoin::{ - hashes::{hex::ToHex, Hash}, - secp256k1, - util::psbt::PartiallySignedTransaction, - SigHash, -}; +use bitcoin::hashes::{hex::ToHex, Hash}; use ecdsa_fun::{adaptor::Adaptor, fun::Point, nonce::Deterministic, ECDSA}; use miniscript::{Descriptor, Segwitv0}; use rand::{CryptoRng, RngCore}; @@ -15,9 +10,9 @@ use serde::{Deserialize, Serialize}; use sha2::Sha256; use std::str::FromStr; -pub use crate::bitcoin::transactions::{TxCancel, TxLock, TxPunish, TxRedeem, TxRefund}; -pub use bitcoin::{Address, Amount, OutPoint, Transaction, Txid}; +pub use bitcoin::{util::psbt::PartiallySignedTransaction, *}; pub use ecdsa_fun::{adaptor::EncryptedSignature, fun::Scalar, Signature}; +pub use transactions::{TxCancel, TxLock, TxPunish, TxRedeem, TxRefund}; pub const TX_FEE: u64 = 10_000; diff --git a/xmr-btc/src/bob.rs b/xmr-btc/src/bob.rs index 89cb6492..e9b98ac8 100644 --- a/xmr-btc/src/bob.rs +++ b/xmr-btc/src/bob.rs @@ -495,7 +495,7 @@ impl State1 { } } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct State2 { pub A: bitcoin::PublicKey, pub b: bitcoin::SecretKey, @@ -507,10 +507,10 @@ pub struct State2 { btc: bitcoin::Amount, pub xmr: monero::Amount, pub refund_timelock: u32, - punish_timelock: u32, + pub punish_timelock: u32, pub refund_address: bitcoin::Address, pub redeem_address: bitcoin::Address, - punish_address: bitcoin::Address, + pub punish_address: bitcoin::Address, pub tx_lock: bitcoin::TxLock, pub tx_cancel_sig_a: Signature, pub tx_refund_encsig: EncryptedSignature, diff --git a/xmr-btc/src/monero.rs b/xmr-btc/src/monero.rs index ca55b904..643c4d32 100644 --- a/xmr-btc/src/monero.rs +++ b/xmr-btc/src/monero.rs @@ -1,12 +1,13 @@ use crate::serde::monero_private_key; use anyhow::Result; use async_trait::async_trait; -pub use curve25519_dalek::scalar::Scalar; -pub use monero::{Address, PrivateKey, PublicKey}; use rand::{CryptoRng, RngCore}; use serde::{Deserialize, Serialize}; use std::ops::{Add, Sub}; +pub use curve25519_dalek::scalar::Scalar; +pub use monero::*; + pub const MIN_CONFIRMATIONS: u32 = 10; pub fn random_private_key(rng: &mut R) -> PrivateKey { diff --git a/xmr-btc/tests/e2e.rs b/xmr-btc/tests/e2e.rs index 4c93bd47..373c759e 100644 --- a/xmr-btc/tests/e2e.rs +++ b/xmr-btc/tests/e2e.rs @@ -13,7 +13,6 @@ mod tests { use rand::rngs::OsRng; use std::convert::TryInto; use testcontainers::clients::Cli; - use tracing_subscriber::util::SubscriberInitExt; use xmr_btc::{ alice, bitcoin, bitcoin::{Amount, TX_FEE}, @@ -22,10 +21,6 @@ mod tests { #[tokio::test] async fn happy_path() { - let _guard = tracing_subscriber::fmt() - .with_env_filter("info") - .set_default(); - let cli = Cli::default(); let (monero, _container) = Monero::new(&cli, Some("hp".to_string()), vec![ "alice".to_string(), @@ -101,10 +96,6 @@ mod tests { #[tokio::test] async fn both_refund() { - let _guard = tracing_subscriber::fmt() - .with_env_filter("info") - .set_default(); - let cli = Cli::default(); let (monero, _container) = Monero::new(&cli, Some("br".to_string()), vec![ "alice".to_string(), @@ -182,10 +173,6 @@ mod tests { #[tokio::test] async fn alice_punishes() { - let _guard = tracing_subscriber::fmt() - .with_env_filter("info") - .set_default(); - let cli = Cli::default(); let (monero, _containers) = Monero::new(&cli, Some("ap".to_string()), vec![ "alice".to_string(), diff --git a/xmr-btc/tests/harness/wallet/monero.rs b/xmr-btc/tests/harness/wallet/monero.rs index 3cdc8a46..fbfd4270 100644 --- a/xmr-btc/tests/harness/wallet/monero.rs +++ b/xmr-btc/tests/harness/wallet/monero.rs @@ -1,12 +1,11 @@ use anyhow::Result; use async_trait::async_trait; use backoff::{backoff::Constant as ConstantBackoff, future::FutureOperation as _}; -use monero::{Address, Network, PrivateKey}; use monero_harness::rpc::wallet; use std::{str::FromStr, time::Duration}; use xmr_btc::monero::{ - Amount, CreateWalletForOutput, InsufficientFunds, PrivateViewKey, PublicKey, PublicViewKey, - Transfer, TransferProof, TxHash, WatchForTransfer, + Address, Amount, CreateWalletForOutput, InsufficientFunds, Network, PrivateKey, PrivateViewKey, + PublicKey, PublicViewKey, Transfer, TransferProof, TxHash, WatchForTransfer, }; pub struct Wallet(pub wallet::Client); diff --git a/xmr-btc/tests/on_chain.rs b/xmr-btc/tests/on_chain.rs index aa8adc61..459b368e 100644 --- a/xmr-btc/tests/on_chain.rs +++ b/xmr-btc/tests/on_chain.rs @@ -20,7 +20,7 @@ use tokio::sync::Mutex; use tracing::info; use xmr_btc::{ alice::{self, ReceiveBitcoinRedeemEncsig}, - bitcoin::{BroadcastSignedTransaction, EncryptedSignature, SignTxLock}, + bitcoin::{self, BroadcastSignedTransaction, EncryptedSignature, SignTxLock}, bob::{self, ReceiveTransferProof}, monero::{CreateWalletForOutput, Transfer, TransferProof}, }; @@ -309,8 +309,7 @@ async fn on_chain_happy_path() { assert_eq!( alice_final_btc_balance, - initial_balances.alice_btc + swap_amounts.btc - - bitcoin::Amount::from_sat(xmr_btc::bitcoin::TX_FEE) + initial_balances.alice_btc + swap_amounts.btc - bitcoin::Amount::from_sat(bitcoin::TX_FEE) ); assert_eq!( bob_final_btc_balance, @@ -411,7 +410,7 @@ async fn on_chain_both_refund_if_alice_never_redeems() { bob_final_btc_balance, // The 2 * TX_FEE corresponds to tx_refund and tx_cancel. initial_balances.bob_btc - - bitcoin::Amount::from_sat(2 * xmr_btc::bitcoin::TX_FEE) + - bitcoin::Amount::from_sat(2 * bitcoin::TX_FEE) - lock_tx_bitcoin_fee ); @@ -508,7 +507,7 @@ async fn on_chain_alice_punishes_if_bob_never_acts_after_fund() { assert_eq!( alice_final_btc_balance, initial_balances.alice_btc + swap_amounts.btc - - bitcoin::Amount::from_sat(2 * xmr_btc::bitcoin::TX_FEE) + - bitcoin::Amount::from_sat(2 * bitcoin::TX_FEE) ); assert_eq!( bob_final_btc_balance,