diff --git a/Cargo.toml b/Cargo.toml index e0174381..9d880136 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,2 @@ [workspace] -members = ["monero-harness", "xmr-btc"] +members = ["monero-harness", "xmr-btc", "swap"] diff --git a/monero-harness/src/lib.rs b/monero-harness/src/lib.rs index 128d9d97..1d60ff13 100644 --- a/monero-harness/src/lib.rs +++ b/monero-harness/src/lib.rs @@ -48,18 +48,17 @@ const WAIT_WALLET_SYNC_MILLIS: u64 = 1000; /// Wallet sub-account indices. const ACCOUNT_INDEX_PRIMARY: u32 = 0; -#[derive(Debug)] -pub struct Monero<'c> { - pub docker: Container<'c, Cli, image::Monero>, - pub monerod_rpc_port: u16, - pub miner_wallet_rpc_port: u16, - pub alice_wallet_rpc_port: u16, - pub bob_wallet_rpc_port: u16, +#[derive(Copy, Clone, Debug)] +pub struct Monero { + monerod_rpc_port: u16, + miner_wallet_rpc_port: u16, + alice_wallet_rpc_port: u16, + bob_wallet_rpc_port: u16, } -impl<'c> Monero<'c> { +impl<'c> Monero { /// Starts a new regtest monero container. - pub fn new(cli: &'c Cli) -> Self { + pub fn new(cli: &'c Cli) -> (Self, Container<'c, Cli, image::Monero>) { let mut rng = rand::thread_rng(); let monerod_rpc_port: u16 = rng.gen_range(1024, u16::MAX); let miner_wallet_rpc_port: u16 = rng.gen_range(1024, u16::MAX); @@ -91,13 +90,15 @@ impl<'c> Monero<'c> { let docker = cli.run(image); println!("image ran"); - Self { + ( + Self { + monerod_rpc_port, + miner_wallet_rpc_port, + alice_wallet_rpc_port, + bob_wallet_rpc_port, + }, docker, - monerod_rpc_port, - miner_wallet_rpc_port, - alice_wallet_rpc_port, - bob_wallet_rpc_port, - } + ) } pub fn miner_wallet_rpc_client(&self) -> wallet::Client { @@ -156,21 +157,6 @@ impl<'c> Monero<'c> { Ok(()) } - /// Just create a wallet and start mining (you probably want `init()`). - pub async fn init_just_miner(&self, blocks: u32) -> Result<()> { - let wallet = self.miner_wallet_rpc_client(); - let monerod = self.monerod_rpc_client(); - - wallet.create_wallet("miner_wallet").await?; - let miner = self.get_address_miner().await?.address; - - let _ = monerod.generate_blocks(blocks, &miner).await?; - - let _ = tokio::spawn(mine(monerod.clone(), miner)); - - Ok(()) - } - async fn fund_account(&self, address: &str, miner: &str, funding: u64) -> Result<()> { let monerod = self.monerod_rpc_client(); @@ -208,64 +194,42 @@ impl<'c> Monero<'c> { } /// Get addresses for the primary account. - pub async fn get_address_miner(&self) -> Result { + async fn get_address_miner(&self) -> Result { let wallet = self.miner_wallet_rpc_client(); wallet.get_address(ACCOUNT_INDEX_PRIMARY).await } /// Get addresses for the Alice's account. - pub async fn get_address_alice(&self) -> Result { + async fn get_address_alice(&self) -> Result { let wallet = self.alice_wallet_rpc_client(); wallet.get_address(ACCOUNT_INDEX_PRIMARY).await } /// Get addresses for the Bob's account. - pub async fn get_address_bob(&self) -> Result { + async fn get_address_bob(&self) -> Result { let wallet = self.bob_wallet_rpc_client(); wallet.get_address(ACCOUNT_INDEX_PRIMARY).await } - /// Gets the balance of the wallet primary account. - pub async fn get_balance_primary(&self) -> Result { - let wallet = self.miner_wallet_rpc_client(); - wallet.get_balance(ACCOUNT_INDEX_PRIMARY).await - } - /// Gets the balance of Alice's account. - pub async fn get_balance_alice(&self) -> Result { + async fn get_balance_alice(&self) -> Result { let wallet = self.alice_wallet_rpc_client(); wallet.get_balance(ACCOUNT_INDEX_PRIMARY).await } /// Gets the balance of Bob's account. - pub async fn get_balance_bob(&self) -> Result { + async fn get_balance_bob(&self) -> Result { let wallet = self.bob_wallet_rpc_client(); wallet.get_balance(ACCOUNT_INDEX_PRIMARY).await } /// Transfers moneroj from the primary account. - pub async fn transfer_from_primary(&self, amount: u64, address: &str) -> Result { + async fn transfer_from_primary(&self, amount: u64, address: &str) -> Result { let wallet = self.miner_wallet_rpc_client(); wallet .transfer(ACCOUNT_INDEX_PRIMARY, amount, address) .await } - - /// Transfers moneroj from Alice's account. - pub async fn transfer_from_alice(&self, amount: u64, address: &str) -> Result { - let wallet = self.alice_wallet_rpc_client(); - wallet - .transfer(ACCOUNT_INDEX_PRIMARY, amount, address) - .await - } - - /// Transfers moneroj from Bob's account. - pub async fn transfer_from_bob(&self, amount: u64, address: &str) -> Result { - let wallet = self.bob_wallet_rpc_client(); - wallet - .transfer(ACCOUNT_INDEX_PRIMARY, amount, address) - .await - } } /// Mine a block ever BLOCK_TIME_SECS seconds. diff --git a/monero-harness/tests/client.rs b/monero-harness/tests/client.rs index 102b97b3..6a257a52 100644 --- a/monero-harness/tests/client.rs +++ b/monero-harness/tests/client.rs @@ -5,29 +5,24 @@ use testcontainers::clients::Cli; const ALICE_FUND_AMOUNT: u64 = 1_000_000_000_000; const BOB_FUND_AMOUNT: u64 = 0; -fn init_cli() -> Cli { - Cli::default() -} - -async fn init_monero(tc: &'_ Cli) -> Monero<'_> { - let monero = Monero::new(tc); - let _ = monero.init(ALICE_FUND_AMOUNT, BOB_FUND_AMOUNT).await; - - monero -} - #[tokio::test] async fn init_accounts_for_alice_and_bob() { - let cli = init_cli(); - let monero = init_monero(&cli).await; + let tc = Cli::default(); + let (monero, _container) = Monero::new(&tc); + monero + .init(ALICE_FUND_AMOUNT, BOB_FUND_AMOUNT) + .await + .unwrap(); let got_balance_alice = monero - .get_balance_alice() + .alice_wallet_rpc_client() + .get_balance(0) .await .expect("failed to get alice's balance"); let got_balance_bob = monero - .get_balance_bob() + .bob_wallet_rpc_client() + .get_balance(0) .await .expect("failed to get bob's balance"); diff --git a/monero-harness/tests/monerod.rs b/monero-harness/tests/monerod.rs index 52270db6..08465297 100644 --- a/monero-harness/tests/monerod.rs +++ b/monero-harness/tests/monerod.rs @@ -1,8 +1,6 @@ -use monero_harness::{rpc::monerod::Client, Monero}; +use monero_harness::Monero; use spectral::prelude::*; -use std::time::Duration; use testcontainers::clients::Cli; -use tokio::time; fn init_cli() -> Cli { Cli::default() @@ -11,8 +9,8 @@ fn init_cli() -> Cli { #[tokio::test] async fn connect_to_monerod() { let tc = init_cli(); - let monero = Monero::new(&tc); - let cli = Client::localhost(monero.monerod_rpc_port); + let (monero, _container) = Monero::new(&tc); + let cli = monero.monerod_rpc_client(); let header = cli .get_block_header_by_height(0) @@ -21,27 +19,3 @@ async fn connect_to_monerod() { assert_that!(header.height).is_equal_to(0); } - -#[tokio::test] -async fn miner_is_running_and_producing_blocks() { - let tc = init_cli(); - let monero = Monero::new(&tc); - let cli = Client::localhost(monero.monerod_rpc_port); - - monero - .init_just_miner(2) - .await - .expect("Failed to initialize"); - - // Only need 3 seconds since we mine a block every second but - // give it 5 just for good measure. - time::delay_for(Duration::from_secs(5)).await; - - // We should have at least 5 blocks by now. - let header = cli - .get_block_header_by_height(5) - .await - .expect("failed to get block"); - - assert_that!(header.height).is_equal_to(5); -} diff --git a/monero-harness/tests/wallet.rs b/monero-harness/tests/wallet.rs index 91ad7d06..bf77d625 100644 --- a/monero-harness/tests/wallet.rs +++ b/monero-harness/tests/wallet.rs @@ -1,24 +1,21 @@ -use monero_harness::{rpc::wallet::Client, Monero}; +use monero_harness::Monero; use spectral::prelude::*; use testcontainers::clients::Cli; #[tokio::test] async fn wallet_and_accounts() { let tc = Cli::default(); - let monero = Monero::new(&tc); - let miner_wallet = Client::localhost(monero.miner_wallet_rpc_port); + let (monero, _container) = Monero::new(&tc); + let cli = monero.miner_wallet_rpc_client(); println!("creating wallet ..."); - let _ = miner_wallet + let _ = cli .create_wallet("wallet") .await .expect("failed to create wallet"); - let got = miner_wallet - .get_balance(0) - .await - .expect("failed to get balance"); + let got = cli.get_balance(0).await.expect("failed to get balance"); let want = 0; assert_that!(got).is_equal_to(want); @@ -27,8 +24,8 @@ async fn wallet_and_accounts() { #[tokio::test] async fn create_account_and_retrieve_it() { let tc = Cli::default(); - let monero = Monero::new(&tc); - let cli = Client::localhost(monero.miner_wallet_rpc_port); + let (monero, _container) = Monero::new(&tc); + let cli = monero.miner_wallet_rpc_client(); let label = "Iron Man"; // This is intentionally _not_ Alice or Bob. @@ -61,18 +58,20 @@ async fn transfer_and_check_tx_key() { let fund_bob = 0; let tc = Cli::default(); - let monero = Monero::new(&tc); + let (monero, _container) = Monero::new(&tc); let _ = monero.init(fund_alice, fund_bob).await; let address_bob = monero - .get_address_bob() + .bob_wallet_rpc_client() + .get_address(0) .await .expect("failed to get Bob's address") .address; let transfer_amount = 100; let transfer = monero - .transfer_from_alice(transfer_amount, &address_bob) + .alice_wallet_rpc_client() + .transfer(0, transfer_amount, &address_bob) .await .expect("transfer failed"); diff --git a/swap/Cargo.toml b/swap/Cargo.toml new file mode 100644 index 00000000..5efe6e57 --- /dev/null +++ b/swap/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "swap" +version = "0.1.0" +authors = ["CoBloX developers "] +edition = "2018" +description = "XMR/BTC trustless atomic swaps." + +[dependencies] +anyhow = "1" +async-trait = "0.1" +atty = "0.2" +backoff = { version = "0.2", features = ["tokio"] } +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 = "d402b36d3d6406150e3bfb71492ff4a0a7cb290e" } +derivative = "2" +futures = { version = "0.3", default-features = false } +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" +rand = "0.7" +reqwest = { version = "0.10", default-features = false } +serde = { version = "1", features = ["derive"] } +serde_derive = "1.0" +serde_json = "1" +structopt = "0.3" +time = "0.2" +tokio = { version = "0.2", features = ["rt-threaded", "time", "macros", "sync"] } +tracing = { version = "0.1", features = ["attributes"] } +tracing-core = "0.1" +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" +void = "1" +xmr-btc = { path = "../xmr-btc" } \ No newline at end of file diff --git a/swap/src/alice.rs b/swap/src/alice.rs new file mode 100644 index 00000000..09423290 --- /dev/null +++ b/swap/src/alice.rs @@ -0,0 +1,259 @@ +//! Run an XMR/BTC swap in the role of Alice. +//! Alice holds XMR and wishes receive BTC. +use anyhow::Result; +use libp2p::{ + core::{identity::Keypair, Multiaddr}, + request_response::ResponseChannel, + NetworkBehaviour, PeerId, +}; +use rand::rngs::OsRng; +use std::thread; +use tracing::debug; + +mod amounts; +mod message0; +mod message1; + +use self::{amounts::*, message0::*, message1::*}; +use crate::{ + network::{ + peer_tracker::{self, PeerTracker}, + request_response::AliceToBob, + transport, TokioExecutor, + }, + SwapAmounts, PUNISH_TIMELOCK, REFUND_TIMELOCK, +}; +use xmr_btc::{alice::State0, bob, monero}; + +pub type Swarm = libp2p::Swarm; + +// FIXME: This whole function is horrible, needs total re-write. +#[allow(unused_assignments)] // Due to the mutable message0? +pub async fn swap( + listen: Multiaddr, + redeem_address: ::bitcoin::Address, + punish_address: ::bitcoin::Address, +) -> Result<()> { + let mut message0: Option = None; + let mut last_amounts: Option = None; + + let mut swarm = new_swarm(listen)?; + + loop { + match swarm.next().await { + OutEvent::ConnectionEstablished(id) => { + tracing::info!("Connection established with: {}", id); + } + OutEvent::Request(amounts::OutEvent::Btc { btc, channel }) => { + debug!("Got request from Bob to swap {}", btc); + let p = calculate_amounts(btc); + last_amounts = Some(p); + swarm.send_amounts(channel, p); + } + OutEvent::Message0(msg) => { + debug!("Got message0 from Bob"); + // TODO: Do this in a more Rusty/functional way. + message0 = Some(msg); + break; + } + other => panic!("Unexpected event: {:?}", other), + }; + } + + let (xmr, btc) = match last_amounts { + Some(p) => (p.xmr, p.btc), + None => unreachable!("should have amounts by here"), + }; + + let xmr = monero::Amount::from_piconero(xmr.as_piconero()); + // TODO: This should be the Amount exported by xmr_btc. + let btc = ::bitcoin::Amount::from_sat(btc.as_sat()); + + // TODO: Pass this in using + let rng = &mut OsRng; + let state0 = State0::new( + rng, + btc, + xmr, + REFUND_TIMELOCK, + PUNISH_TIMELOCK, + redeem_address, + punish_address, + ); + swarm.set_state0(state0.clone()); + + let state1 = match message0 { + Some(msg) => state0.receive(msg).expect("failed to receive msg 0"), + None => panic!("should have the message by here"), + }; + + let (state2, channel) = match swarm.next().await { + OutEvent::Message1 { msg, channel } => { + let state2 = state1.receive(msg); + (state2, channel) + } + other => panic!("Unexpected event: {:?}", other), + }; + + let msg = state2.next_message(); + swarm.send_message1(channel, msg); + + tracing::info!("handshake complete, we now have State2 for Alice."); + + tracing::warn!("parking thread ..."); + thread::park(); + Ok(()) +} + +fn new_swarm(listen: Multiaddr) -> Result { + use anyhow::Context as _; + + let behaviour = Alice::default(); + + let local_key_pair = behaviour.identity(); + let local_peer_id = behaviour.peer_id(); + + let transport = transport::build(local_key_pair)?; + + let mut swarm = libp2p::swarm::SwarmBuilder::new(transport, behaviour, local_peer_id.clone()) + .executor(Box::new(TokioExecutor { + handle: tokio::runtime::Handle::current(), + })) + .build(); + + Swarm::listen_on(&mut swarm, listen.clone()) + .with_context(|| format!("Address is not supported: {:#}", listen))?; + + tracing::info!("Initialized swarm: {}", local_peer_id); + + Ok(swarm) +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +pub enum OutEvent { + ConnectionEstablished(PeerId), + Request(amounts::OutEvent), // Not-uniform with Bob on purpose, ready for adding Xmr event. + Message0(bob::Message0), + Message1 { + msg: bob::Message1, + channel: ResponseChannel, + }, +} + +impl From for OutEvent { + fn from(event: peer_tracker::OutEvent) -> Self { + match event { + peer_tracker::OutEvent::ConnectionEstablished(id) => { + OutEvent::ConnectionEstablished(id) + } + } + } +} + +impl From for OutEvent { + fn from(event: amounts::OutEvent) -> Self { + OutEvent::Request(event) + } +} + +impl From for OutEvent { + fn from(event: message0::OutEvent) -> Self { + match event { + message0::OutEvent::Msg(msg) => OutEvent::Message0(msg), + } + } +} + +impl From for OutEvent { + fn from(event: message1::OutEvent) -> Self { + match event { + message1::OutEvent::Msg { msg, channel } => OutEvent::Message1 { msg, channel }, + } + } +} + +/// A `NetworkBehaviour` that represents an XMR/BTC swap node as Alice. +#[derive(NetworkBehaviour)] +#[behaviour(out_event = "OutEvent", event_process = false)] +#[allow(missing_debug_implementations)] +pub struct Alice { + pt: PeerTracker, + amounts: Amounts, + message0: Message0, + message1: Message1, + #[behaviour(ignore)] + identity: Keypair, +} + +impl Alice { + pub fn identity(&self) -> Keypair { + self.identity.clone() + } + + pub fn peer_id(&self) -> PeerId { + PeerId::from(self.identity.public()) + } + + /// Alice always sends her messages as a response to a request from Bob. + pub fn send_amounts(&mut self, channel: ResponseChannel, amounts: SwapAmounts) { + let msg = AliceToBob::Amounts(amounts); + self.amounts.send(channel, msg); + } + + /// Message0 gets sent within the network layer using this state0. + pub fn set_state0(&mut self, state: State0) { + let _ = self.message0.set_state(state); + } + + /// Send Message1 to Bob in response to receiving his Message1. + pub fn send_message1( + &mut self, + channel: ResponseChannel, + msg: xmr_btc::alice::Message1, + ) { + self.message1.send(channel, msg) + } +} + +impl Default for Alice { + fn default() -> Self { + let identity = Keypair::generate_ed25519(); + + Self { + pt: PeerTracker::default(), + amounts: Amounts::default(), + message0: Message0::default(), + message1: Message1::default(), + identity, + } + } +} + +fn calculate_amounts(btc: ::bitcoin::Amount) -> SwapAmounts { + const XMR_PER_BTC: u64 = 100; // TODO: Get this from an exchange. + + // TODO: Check that this is correct. + // XMR uses 12 zerose BTC uses 8. + let picos = (btc.as_sat() * 10000) * XMR_PER_BTC; + let xmr = monero::Amount::from_piconero(picos); + + SwapAmounts { btc, xmr } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ONE_BTC: u64 = 100_000_000; + const HUNDRED_XMR: u64 = 100_000_000_000_000; + + #[test] + fn one_bitcoin_equals_a_hundred_moneroj() { + let btc = ::bitcoin::Amount::from_sat(ONE_BTC); + let want = monero::Amount::from_piconero(HUNDRED_XMR); + + let SwapAmounts { xmr: got, .. } = calculate_amounts(btc); + assert_eq!(got, want); + } +} diff --git a/swap/src/alice/amounts.rs b/swap/src/alice/amounts.rs new file mode 100644 index 00000000..39396861 --- /dev/null +++ b/swap/src/alice/amounts.rs @@ -0,0 +1,113 @@ +use anyhow::Result; +use libp2p::{ + request_response::{ + handler::RequestProtocol, ProtocolSupport, RequestId, RequestResponse, + RequestResponseConfig, RequestResponseEvent, RequestResponseMessage, ResponseChannel, + }, + swarm::{NetworkBehaviourAction, NetworkBehaviourEventProcess, PollParameters}, + NetworkBehaviour, PeerId, +}; +use std::{ + collections::VecDeque, + task::{Context, Poll}, + time::Duration, +}; +use tracing::{debug, error}; + +use crate::network::request_response::{AliceToBob, BobToAlice, Codec, Protocol, TIMEOUT}; + +#[derive(Debug)] +pub enum OutEvent { + Btc { + btc: ::bitcoin::Amount, + channel: ResponseChannel, + }, +} + +/// A `NetworkBehaviour` that represents getting the amounts of an XMR/BTC swap. +#[derive(NetworkBehaviour)] +#[behaviour(out_event = "OutEvent", poll_method = "poll")] +#[allow(missing_debug_implementations)] +pub struct Amounts { + rr: RequestResponse, + #[behaviour(ignore)] + events: VecDeque, +} + +impl Amounts { + /// Alice always sends her messages as a response to a request from Bob. + pub fn send(&mut self, channel: ResponseChannel, msg: AliceToBob) { + self.rr.send_response(channel, msg); + } + + pub async fn request_amounts( + &mut self, + alice: PeerId, + btc: ::bitcoin::Amount, + ) -> Result { + let msg = BobToAlice::AmountsFromBtc(btc); + let id = self.rr.send_request(&alice, msg); + debug!("Request sent to: {}", alice); + + Ok(id) + } + + fn poll( + &mut self, + _: &mut Context<'_>, + _: &mut impl PollParameters, + ) -> Poll, OutEvent>> { + if let Some(event) = self.events.pop_front() { + return Poll::Ready(NetworkBehaviourAction::GenerateEvent(event)); + } + + Poll::Pending + } +} + +impl Default for Amounts { + fn default() -> Self { + let timeout = Duration::from_secs(TIMEOUT); + + let mut config = RequestResponseConfig::default(); + config.set_request_timeout(timeout); + + Self { + rr: RequestResponse::new( + Codec::default(), + vec![(Protocol, ProtocolSupport::Full)], + config, + ), + events: Default::default(), + } + } +} + +impl NetworkBehaviourEventProcess> for Amounts { + fn inject_event(&mut self, event: RequestResponseEvent) { + match event { + RequestResponseEvent::Message { + message: + RequestResponseMessage::Request { + request, channel, .. + }, + .. + } => match request { + BobToAlice::AmountsFromBtc(btc) => { + self.events.push_back(OutEvent::Btc { btc, channel }) + } + other => debug!("got request: {:?}", other), + }, + RequestResponseEvent::Message { + message: RequestResponseMessage::Response { .. }, + .. + } => panic!("Alice should not get a Response"), + RequestResponseEvent::InboundFailure { error, .. } => { + error!("Inbound failure: {:?}", error); + } + RequestResponseEvent::OutboundFailure { error, .. } => { + error!("Outbound failure: {:?}", error); + } + } + } +} diff --git a/swap/src/alice/message0.rs b/swap/src/alice/message0.rs new file mode 100644 index 00000000..460c63e4 --- /dev/null +++ b/swap/src/alice/message0.rs @@ -0,0 +1,114 @@ +use anyhow::{bail, Result}; +use libp2p::{ + request_response::{ + handler::RequestProtocol, ProtocolSupport, RequestResponse, RequestResponseConfig, + RequestResponseEvent, RequestResponseMessage, + }, + swarm::{NetworkBehaviourAction, NetworkBehaviourEventProcess, PollParameters}, + NetworkBehaviour, +}; +use rand::rngs::OsRng; +use std::{ + collections::VecDeque, + task::{Context, Poll}, + time::Duration, +}; +use tracing::{debug, error}; + +use crate::network::request_response::{AliceToBob, BobToAlice, Codec, Protocol, TIMEOUT}; +use xmr_btc::{alice::State0, bob}; + +#[derive(Debug)] +pub enum OutEvent { + Msg(bob::Message0), +} + +/// A `NetworkBehaviour` that represents send/recv of message 0. +#[derive(NetworkBehaviour)] +#[behaviour(out_event = "OutEvent", poll_method = "poll")] +#[allow(missing_debug_implementations)] +pub struct Message0 { + rr: RequestResponse, + #[behaviour(ignore)] + events: VecDeque, + #[behaviour(ignore)] + state: Option, +} + +impl Message0 { + pub fn set_state(&mut self, state: State0) -> Result<()> { + if self.state.is_some() { + bail!("Trying to set state a second time"); + } + self.state = Some(state); + + Ok(()) + } + + fn poll( + &mut self, + _: &mut Context<'_>, + _: &mut impl PollParameters, + ) -> Poll, OutEvent>> { + if let Some(event) = self.events.pop_front() { + return Poll::Ready(NetworkBehaviourAction::GenerateEvent(event)); + } + + Poll::Pending + } +} + +impl Default for Message0 { + fn default() -> Self { + let timeout = Duration::from_secs(TIMEOUT); + let mut config = RequestResponseConfig::default(); + config.set_request_timeout(timeout); + + Self { + rr: RequestResponse::new( + Codec::default(), + vec![(Protocol, ProtocolSupport::Full)], + config, + ), + events: Default::default(), + state: None, + } + } +} + +impl NetworkBehaviourEventProcess> for Message0 { + fn inject_event(&mut self, event: RequestResponseEvent) { + match event { + RequestResponseEvent::Message { + message: + RequestResponseMessage::Request { + request, channel, .. + }, + .. + } => match request { + BobToAlice::Message0(msg) => { + let response = match &self.state { + None => panic!("No state, did you forget to set it?"), + Some(state) => { + // TODO: Get OsRng from somewhere? + AliceToBob::Message0(state.next_message(&mut OsRng)) + } + }; + self.rr.send_response(channel, response); + self.events.push_back(OutEvent::Msg(msg)); + } + other => debug!("got request: {:?}", other), + }, + RequestResponseEvent::Message { + message: RequestResponseMessage::Response { .. }, + .. + } => panic!("Alice should not get a Response"), + RequestResponseEvent::InboundFailure { error, .. } => { + error!("Inbound failure: {:?}", error); + } + RequestResponseEvent::OutboundFailure { error, .. } => { + error!("Outbound failure: {:?}", error); + } + } + } +} diff --git a/swap/src/alice/message1.rs b/swap/src/alice/message1.rs new file mode 100644 index 00000000..27df9e20 --- /dev/null +++ b/swap/src/alice/message1.rs @@ -0,0 +1,102 @@ +use libp2p::{ + request_response::{ + handler::RequestProtocol, ProtocolSupport, RequestResponse, RequestResponseConfig, + RequestResponseEvent, RequestResponseMessage, ResponseChannel, + }, + swarm::{NetworkBehaviourAction, NetworkBehaviourEventProcess, PollParameters}, + NetworkBehaviour, +}; +use std::{ + collections::VecDeque, + task::{Context, Poll}, + time::Duration, +}; +use tracing::{debug, error}; + +use crate::network::request_response::{AliceToBob, BobToAlice, Codec, Protocol, TIMEOUT}; +use xmr_btc::bob; + +#[derive(Debug)] +pub enum OutEvent { + Msg { + /// Received message from Bob. + msg: bob::Message1, + /// Channel to send back Alice's message 1. + channel: ResponseChannel, + }, +} + +/// A `NetworkBehaviour` that represents send/recv of message 1. +#[derive(NetworkBehaviour)] +#[behaviour(out_event = "OutEvent", poll_method = "poll")] +#[allow(missing_debug_implementations)] +pub struct Message1 { + rr: RequestResponse, + #[behaviour(ignore)] + events: VecDeque, +} + +impl Message1 { + pub fn send(&mut self, channel: ResponseChannel, msg: xmr_btc::alice::Message1) { + let msg = AliceToBob::Message1(msg); + self.rr.send_response(channel, msg); + } + + fn poll( + &mut self, + _: &mut Context<'_>, + _: &mut impl PollParameters, + ) -> Poll, OutEvent>> { + if let Some(event) = self.events.pop_front() { + return Poll::Ready(NetworkBehaviourAction::GenerateEvent(event)); + } + + Poll::Pending + } +} + +impl Default for Message1 { + fn default() -> Self { + let timeout = Duration::from_secs(TIMEOUT); + let mut config = RequestResponseConfig::default(); + config.set_request_timeout(timeout); + + Self { + rr: RequestResponse::new( + Codec::default(), + vec![(Protocol, ProtocolSupport::Full)], + config, + ), + events: Default::default(), + } + } +} + +impl NetworkBehaviourEventProcess> for Message1 { + fn inject_event(&mut self, event: RequestResponseEvent) { + match event { + RequestResponseEvent::Message { + message: + RequestResponseMessage::Request { + request, channel, .. + }, + .. + } => match request { + BobToAlice::Message1(msg) => { + self.events.push_back(OutEvent::Msg { msg, channel }); + } + other => debug!("got request: {:?}", other), + }, + RequestResponseEvent::Message { + message: RequestResponseMessage::Response { .. }, + .. + } => panic!("Alice should not get a Response"), + RequestResponseEvent::InboundFailure { error, .. } => { + error!("Inbound failure: {:?}", error); + } + RequestResponseEvent::OutboundFailure { error, .. } => { + error!("Outbound failure: {:?}", error); + } + } + } +} diff --git a/swap/src/bitcoin.rs b/swap/src/bitcoin.rs new file mode 100644 index 00000000..2a4a8e8d --- /dev/null +++ b/swap/src/bitcoin.rs @@ -0,0 +1,99 @@ +use anyhow::Result; +use async_trait::async_trait; +use backoff::{future::FutureOperation as _, ExponentialBackoff}; +use bitcoin::{util::psbt::PartiallySignedTransaction, Address, Transaction}; +use bitcoin_harness::bitcoind_rpc::PsbtBase64; +use reqwest::Url; +use xmr_btc::bitcoin::{ + Amount, BroadcastSignedTransaction, BuildTxLockPsbt, SignTxLock, TxLock, Txid, + WatchForRawTransaction, +}; + +// This is cut'n'paste from xmr_btc/tests/harness/wallet/bitcoin.rs + +#[derive(Debug)] +pub struct Wallet(pub bitcoin_harness::Wallet); + +impl Wallet { + pub async fn new(name: &str, url: &Url) -> Result { + let wallet = bitcoin_harness::Wallet::new(name, url.clone()).await?; + + Ok(Self(wallet)) + } + + pub async fn balance(&self) -> Result { + let balance = self.0.balance().await?; + Ok(balance) + } + + pub async fn new_address(&self) -> Result
{ + self.0.new_address().await.map_err(Into::into) + } + + pub async fn transaction_fee(&self, txid: Txid) -> Result { + let fee = self + .0 + .get_wallet_transaction(txid) + .await + .map(|res| bitcoin::Amount::from_btc(-res.fee))??; + + Ok(fee) + } +} + +#[async_trait] +impl BuildTxLockPsbt for Wallet { + async fn build_tx_lock_psbt( + &self, + output_address: Address, + output_amount: Amount, + ) -> Result { + let psbt = self.0.fund_psbt(output_address, output_amount).await?; + let as_hex = base64::decode(psbt)?; + + let psbt = bitcoin::consensus::deserialize(&as_hex)?; + + Ok(psbt) + } +} + +#[async_trait] +impl SignTxLock for Wallet { + async fn sign_tx_lock(&self, tx_lock: TxLock) -> Result { + let psbt = PartiallySignedTransaction::from(tx_lock); + + let psbt = bitcoin::consensus::serialize(&psbt); + let as_base64 = base64::encode(psbt); + + let psbt = self.0.wallet_process_psbt(PsbtBase64(as_base64)).await?; + let PsbtBase64(signed_psbt) = PsbtBase64::from(psbt); + + let as_hex = base64::decode(signed_psbt)?; + let psbt: PartiallySignedTransaction = bitcoin::consensus::deserialize(&as_hex)?; + + let tx = psbt.extract_tx(); + + Ok(tx) + } +} + +#[async_trait] +impl BroadcastSignedTransaction for Wallet { + async fn broadcast_signed_transaction(&self, transaction: Transaction) -> Result { + let txid = self.0.send_raw_transaction(transaction).await?; + Ok(txid) + } +} + +#[async_trait] +impl WatchForRawTransaction for Wallet { + async fn watch_for_raw_transaction(&self, txid: Txid) -> Transaction { + (|| async { Ok(self.0.get_raw_transaction(txid).await?) }) + .retry(ExponentialBackoff { + max_elapsed_time: None, + ..Default::default() + }) + .await + .expect("transient errors to be retried") + } +} diff --git a/swap/src/bob.rs b/swap/src/bob.rs new file mode 100644 index 00000000..22c339ee --- /dev/null +++ b/swap/src/bob.rs @@ -0,0 +1,230 @@ +//! Run an XMR/BTC swap in the role of Bob. +//! Bob holds BTC and wishes receive XMR. +use anyhow::Result; +use futures::{ + channel::mpsc::{Receiver, Sender}, + StreamExt, +}; +use libp2p::{core::identity::Keypair, Multiaddr, NetworkBehaviour, PeerId}; +use rand::rngs::OsRng; +use std::{process, thread}; +use tracing::{debug, info}; + +mod amounts; +mod message0; +mod message1; + +use self::{amounts::*, message0::*, message1::*}; +use crate::{ + network::{ + peer_tracker::{self, PeerTracker}, + transport, TokioExecutor, + }, + Cmd, Rsp, SwapAmounts, PUNISH_TIMELOCK, REFUND_TIMELOCK, +}; +use xmr_btc::{ + alice, + bitcoin::BuildTxLockPsbt, + bob::{self, State0}, +}; + +// FIXME: This whole function is horrible, needs total re-write. +pub async fn swap( + btc: u64, + addr: Multiaddr, + mut cmd_tx: Sender, + mut rsp_rx: Receiver, + refund_address: ::bitcoin::Address, + wallet: W, +) -> Result<()> +where + W: BuildTxLockPsbt + Send + Sync + 'static, +{ + let mut swarm = new_swarm()?; + + libp2p::Swarm::dial_addr(&mut swarm, addr)?; + let alice = match swarm.next().await { + OutEvent::ConnectionEstablished(alice) => alice, + other => panic!("unexpected event: {:?}", other), + }; + info!("Connection established."); + + swarm.request_amounts(alice.clone(), btc); + + let (btc, xmr) = match swarm.next().await { + OutEvent::Amounts(amounts) => { + debug!("Got amounts from Alice: {:?}", amounts); + let cmd = Cmd::VerifyAmounts(amounts); + cmd_tx.try_send(cmd)?; + let response = rsp_rx.next().await; + if response == Some(Rsp::Abort) { + info!("Amounts no good, aborting ..."); + process::exit(0); + } + + info!("User verified amounts, continuing with swap ..."); + (amounts.btc, amounts.xmr) + } + other => panic!("unexpected event: {:?}", other), + }; + + // FIXME: Too many `bitcoin` crates/modules. + let xmr = xmr_btc::monero::Amount::from_piconero(xmr.as_piconero()); + let btc = ::bitcoin::Amount::from_sat(btc.as_sat()); + + // TODO: Pass this in using + let rng = &mut OsRng; + let state0 = State0::new( + rng, + btc, + xmr, + REFUND_TIMELOCK, + PUNISH_TIMELOCK, + refund_address, + ); + + swarm.send_message0(alice.clone(), state0.next_message(rng)); + let state1 = match swarm.next().await { + OutEvent::Message0(msg) => { + state0.receive(&wallet, msg).await? // TODO: More graceful error + // handling. + } + other => panic!("unexpected event: {:?}", other), + }; + + swarm.send_message1(alice.clone(), state1.next_message()); + let _state2 = match swarm.next().await { + OutEvent::Message1(msg) => { + state1.receive(msg) // TODO: More graceful error handling. + } + other => panic!("unexpected event: {:?}", other), + }; + + info!("handshake complete, we now have State2 for Bob."); + + thread::park(); + Ok(()) +} + +pub type Swarm = libp2p::Swarm; + +fn new_swarm() -> Result { + let behaviour = Bob::default(); + + let local_key_pair = behaviour.identity(); + let local_peer_id = behaviour.peer_id(); + + let transport = transport::build(local_key_pair)?; + + let swarm = libp2p::swarm::SwarmBuilder::new(transport, behaviour, local_peer_id.clone()) + .executor(Box::new(TokioExecutor { + handle: tokio::runtime::Handle::current(), + })) + .build(); + + info!("Initialized swarm with identity {}", local_peer_id); + + Ok(swarm) +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +pub enum OutEvent { + ConnectionEstablished(PeerId), + Amounts(SwapAmounts), + Message0(alice::Message0), + Message1(alice::Message1), +} + +impl From for OutEvent { + fn from(event: peer_tracker::OutEvent) -> Self { + match event { + peer_tracker::OutEvent::ConnectionEstablished(id) => { + OutEvent::ConnectionEstablished(id) + } + } + } +} + +impl From for OutEvent { + fn from(event: amounts::OutEvent) -> Self { + match event { + amounts::OutEvent::Amounts(amounts) => OutEvent::Amounts(amounts), + } + } +} + +impl From for OutEvent { + fn from(event: message0::OutEvent) -> Self { + match event { + message0::OutEvent::Msg(msg) => OutEvent::Message0(msg), + } + } +} + +impl From for OutEvent { + fn from(event: message1::OutEvent) -> Self { + match event { + message1::OutEvent::Msg(msg) => OutEvent::Message1(msg), + } + } +} + +/// A `NetworkBehaviour` that represents an XMR/BTC swap node as Bob. +#[derive(NetworkBehaviour)] +#[behaviour(out_event = "OutEvent", event_process = false)] +#[allow(missing_debug_implementations)] +pub struct Bob { + pt: PeerTracker, + amounts: Amounts, + message0: Message0, + message1: Message1, + #[behaviour(ignore)] + identity: Keypair, +} + +impl Bob { + pub fn identity(&self) -> Keypair { + self.identity.clone() + } + + pub fn peer_id(&self) -> PeerId { + PeerId::from(self.identity.public()) + } + + /// Sends a message to Alice to get current amounts based on `btc`. + pub fn request_amounts(&mut self, alice: PeerId, btc: u64) { + let btc = ::bitcoin::Amount::from_sat(btc); + let _id = self.amounts.request_amounts(alice.clone(), btc); + debug!("Requesting amounts from: {}", alice); + } + + /// Sends Bob's first message to Alice. + pub fn send_message0(&mut self, alice: PeerId, msg: bob::Message0) { + self.message0.send(alice, msg) + } + + /// Sends Bob's second message to Alice. + pub fn send_message1(&mut self, alice: PeerId, msg: bob::Message1) { + self.message1.send(alice, msg) + } + + /// Returns Alice's peer id if we are connected. + pub fn peer_id_of_alice(&self) -> Option { + self.pt.counterparty_peer_id() + } +} + +impl Default for Bob { + fn default() -> Bob { + let identity = Keypair::generate_ed25519(); + + Self { + pt: PeerTracker::default(), + amounts: Amounts::default(), + message0: Message0::default(), + message1: Message1::default(), + identity, + } + } +} diff --git a/swap/src/bob/amounts.rs b/swap/src/bob/amounts.rs new file mode 100644 index 00000000..46c71920 --- /dev/null +++ b/swap/src/bob/amounts.rs @@ -0,0 +1,98 @@ +use anyhow::Result; +use libp2p::{ + request_response::{ + handler::RequestProtocol, ProtocolSupport, RequestId, RequestResponse, + RequestResponseConfig, RequestResponseEvent, RequestResponseMessage, + }, + swarm::{NetworkBehaviourAction, NetworkBehaviourEventProcess, PollParameters}, + NetworkBehaviour, PeerId, +}; +use std::{ + collections::VecDeque, + task::{Context, Poll}, + time::Duration, +}; +use tracing::{debug, error}; + +use crate::{ + network::request_response::{AliceToBob, BobToAlice, Codec, Protocol, TIMEOUT}, + SwapAmounts, +}; + +#[derive(Debug)] +pub enum OutEvent { + Amounts(SwapAmounts), +} + +/// A `NetworkBehaviour` that represents getting the amounts of an XMR/BTC swap. +#[derive(NetworkBehaviour)] +#[behaviour(out_event = "OutEvent", poll_method = "poll")] +#[allow(missing_debug_implementations)] +pub struct Amounts { + rr: RequestResponse, + #[behaviour(ignore)] + events: VecDeque, +} + +impl Amounts { + pub fn request_amounts(&mut self, alice: PeerId, btc: ::bitcoin::Amount) -> Result { + let msg = BobToAlice::AmountsFromBtc(btc); + let id = self.rr.send_request(&alice, msg); + + Ok(id) + } + + fn poll( + &mut self, + _: &mut Context<'_>, + _: &mut impl PollParameters, + ) -> Poll, OutEvent>> { + if let Some(event) = self.events.pop_front() { + return Poll::Ready(NetworkBehaviourAction::GenerateEvent(event)); + } + + Poll::Pending + } +} + +impl Default for Amounts { + fn default() -> Self { + let timeout = Duration::from_secs(TIMEOUT); + + let mut config = RequestResponseConfig::default(); + config.set_request_timeout(timeout); + + Self { + rr: RequestResponse::new( + Codec::default(), + vec![(Protocol, ProtocolSupport::Full)], + config, + ), + events: Default::default(), + } + } +} + +impl NetworkBehaviourEventProcess> for Amounts { + fn inject_event(&mut self, event: RequestResponseEvent) { + match event { + RequestResponseEvent::Message { + message: RequestResponseMessage::Request { .. }, + .. + } => panic!("Bob should never get a request from Alice"), + RequestResponseEvent::Message { + message: RequestResponseMessage::Response { response, .. }, + .. + } => match response { + AliceToBob::Amounts(p) => self.events.push_back(OutEvent::Amounts(p)), + other => debug!("got response: {:?}", other), + }, + RequestResponseEvent::InboundFailure { error, .. } => { + error!("Inbound failure: {:?}", error); + } + RequestResponseEvent::OutboundFailure { error, .. } => { + error!("Outbound failure: {:?}", error); + } + } + } +} diff --git a/swap/src/bob/message0.rs b/swap/src/bob/message0.rs new file mode 100644 index 00000000..268244a1 --- /dev/null +++ b/swap/src/bob/message0.rs @@ -0,0 +1,92 @@ +use libp2p::{ + request_response::{ + handler::RequestProtocol, ProtocolSupport, RequestResponse, RequestResponseConfig, + RequestResponseEvent, RequestResponseMessage, + }, + swarm::{NetworkBehaviourAction, NetworkBehaviourEventProcess, PollParameters}, + NetworkBehaviour, PeerId, +}; +use std::{ + collections::VecDeque, + task::{Context, Poll}, + time::Duration, +}; +use tracing::{debug, error}; + +use crate::network::request_response::{AliceToBob, BobToAlice, Codec, Protocol, TIMEOUT}; +use xmr_btc::{alice, bob}; + +#[derive(Debug)] +pub enum OutEvent { + Msg(alice::Message0), +} + +/// A `NetworkBehaviour` that represents send/recv of message 0. +#[derive(NetworkBehaviour)] +#[behaviour(out_event = "OutEvent", poll_method = "poll")] +#[allow(missing_debug_implementations)] +pub struct Message0 { + rr: RequestResponse, + #[behaviour(ignore)] + events: VecDeque, +} + +impl Message0 { + pub fn send(&mut self, alice: PeerId, msg: bob::Message0) { + let msg = BobToAlice::Message0(msg); + let _id = self.rr.send_request(&alice, msg); + } + + fn poll( + &mut self, + _: &mut Context<'_>, + _: &mut impl PollParameters, + ) -> Poll, OutEvent>> { + if let Some(event) = self.events.pop_front() { + return Poll::Ready(NetworkBehaviourAction::GenerateEvent(event)); + } + + Poll::Pending + } +} + +impl Default for Message0 { + fn default() -> Self { + let timeout = Duration::from_secs(TIMEOUT); + let mut config = RequestResponseConfig::default(); + config.set_request_timeout(timeout); + + Self { + rr: RequestResponse::new( + Codec::default(), + vec![(Protocol, ProtocolSupport::Full)], + config, + ), + events: Default::default(), + } + } +} + +impl NetworkBehaviourEventProcess> for Message0 { + fn inject_event(&mut self, event: RequestResponseEvent) { + match event { + RequestResponseEvent::Message { + message: RequestResponseMessage::Request { .. }, + .. + } => panic!("Bob should never get a request from Alice"), + RequestResponseEvent::Message { + message: RequestResponseMessage::Response { response, .. }, + .. + } => match response { + AliceToBob::Message0(msg) => self.events.push_back(OutEvent::Msg(msg)), + other => debug!("got response: {:?}", other), + }, + RequestResponseEvent::InboundFailure { error, .. } => { + error!("Inbound failure: {:?}", error); + } + RequestResponseEvent::OutboundFailure { error, .. } => { + error!("Outbound failure: {:?}", error); + } + } + } +} diff --git a/swap/src/bob/message1.rs b/swap/src/bob/message1.rs new file mode 100644 index 00000000..9e85ce3d --- /dev/null +++ b/swap/src/bob/message1.rs @@ -0,0 +1,92 @@ +use libp2p::{ + request_response::{ + handler::RequestProtocol, ProtocolSupport, RequestResponse, RequestResponseConfig, + RequestResponseEvent, RequestResponseMessage, + }, + swarm::{NetworkBehaviourAction, NetworkBehaviourEventProcess, PollParameters}, + NetworkBehaviour, PeerId, +}; +use std::{ + collections::VecDeque, + task::{Context, Poll}, + time::Duration, +}; +use tracing::{debug, error}; + +use crate::network::request_response::{AliceToBob, BobToAlice, Codec, Protocol, TIMEOUT}; +use xmr_btc::{alice, bob}; + +#[derive(Debug)] +pub enum OutEvent { + Msg(alice::Message1), +} + +/// A `NetworkBehaviour` that represents send/recv of message 1. +#[derive(NetworkBehaviour)] +#[behaviour(out_event = "OutEvent", poll_method = "poll")] +#[allow(missing_debug_implementations)] +pub struct Message1 { + rr: RequestResponse, + #[behaviour(ignore)] + events: VecDeque, +} + +impl Message1 { + pub fn send(&mut self, alice: PeerId, msg: bob::Message1) { + let msg = BobToAlice::Message1(msg); + let _id = self.rr.send_request(&alice, msg); + } + + fn poll( + &mut self, + _: &mut Context<'_>, + _: &mut impl PollParameters, + ) -> Poll, OutEvent>> { + if let Some(event) = self.events.pop_front() { + return Poll::Ready(NetworkBehaviourAction::GenerateEvent(event)); + } + + Poll::Pending + } +} + +impl Default for Message1 { + fn default() -> Self { + let timeout = Duration::from_secs(TIMEOUT); + let mut config = RequestResponseConfig::default(); + config.set_request_timeout(timeout); + + Self { + rr: RequestResponse::new( + Codec::default(), + vec![(Protocol, ProtocolSupport::Full)], + config, + ), + events: Default::default(), + } + } +} + +impl NetworkBehaviourEventProcess> for Message1 { + fn inject_event(&mut self, event: RequestResponseEvent) { + match event { + RequestResponseEvent::Message { + message: RequestResponseMessage::Request { .. }, + .. + } => panic!("Bob should never get a request from Alice"), + RequestResponseEvent::Message { + message: RequestResponseMessage::Response { response, .. }, + .. + } => match response { + AliceToBob::Message1(msg) => self.events.push_back(OutEvent::Msg(msg)), + other => debug!("got response: {:?}", other), + }, + RequestResponseEvent::InboundFailure { error, .. } => { + error!("Inbound failure: {:?}", error); + } + RequestResponseEvent::OutboundFailure { error, .. } => { + error!("Outbound failure: {:?}", error); + } + } + } +} diff --git a/swap/src/cli.rs b/swap/src/cli.rs new file mode 100644 index 00000000..fd4f9a59 --- /dev/null +++ b/swap/src/cli.rs @@ -0,0 +1,14 @@ +#[derive(structopt::StructOpt, Debug)] +pub struct Options { + /// Run the swap as Alice. + #[structopt(long = "as-alice")] + pub as_alice: bool, + + /// Run the swap as Bob and try to swap this many XMR (in piconero). + #[structopt(long = "picos")] + pub piconeros: Option, + + /// Run the swap as Bob and try to swap this many BTC (in satoshi). + #[structopt(long = "sats")] + pub satoshis: Option, +} diff --git a/swap/src/lib.rs b/swap/src/lib.rs new file mode 100644 index 00000000..404e9177 --- /dev/null +++ b/swap/src/lib.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display}; + +pub mod alice; +pub mod bitcoin; +pub mod bob; +pub mod network; + +pub const ONE_BTC: u64 = 100_000_000; + +const REFUND_TIMELOCK: u32 = 10; // Relative timelock, this is number of blocks. TODO: What should it be? +const PUNISH_TIMELOCK: u32 = 20; // FIXME: What should this be? + +pub type Never = std::convert::Infallible; + +/// Commands sent from Bob to the main task. +#[derive(Clone, Copy, Debug)] +pub enum Cmd { + VerifyAmounts(SwapAmounts), +} + +/// Responses sent from the main task back to Bob. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Rsp { + VerifiedAmounts, + Abort, +} + +/// XMR/BTC swap amounts. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +pub struct SwapAmounts { + /// Amount of BTC to swap. + #[serde(with = "::bitcoin::util::amount::serde::as_sat")] + pub btc: ::bitcoin::Amount, + /// Amount of XMR to swap. + #[serde(with = "xmr_btc::serde::monero_amount")] + pub xmr: xmr_btc::monero::Amount, +} + +// TODO: Display in XMR and BTC (not picos and sats). +impl Display for SwapAmounts { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{} sats for {} piconeros", + self.btc.as_sat(), + self.xmr.as_piconero() + ) + } +} diff --git a/swap/src/main.rs b/swap/src/main.rs new file mode 100644 index 00000000..5a62120d --- /dev/null +++ b/swap/src/main.rs @@ -0,0 +1,166 @@ +#![warn( + unused_extern_crates, + missing_debug_implementations, + missing_copy_implementations, + rust_2018_idioms, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::fallible_impl_from, + clippy::cast_precision_loss, + clippy::cast_possible_wrap, + clippy::dbg_macro +)] +#![forbid(unsafe_code)] + +use anyhow::{bail, Result}; +use futures::{channel::mpsc, StreamExt}; +use libp2p::Multiaddr; +use log::LevelFilter; +use std::{io, io::Write, process}; +use structopt::StructOpt; +use tracing::info; +use url::Url; + +mod cli; +mod trace; + +use cli::Options; +use swap::{alice, bitcoin::Wallet, bob, Cmd, Rsp, SwapAmounts}; +use xmr_btc::bitcoin::BuildTxLockPsbt; + +// TODO: Add root seed file instead of generating new seed each run. +// TODO: Remove all instances of the todo! macro + +// TODO: Add a config file with these in it. +// Alice's address and port until we have a config file. +pub const PORT: u16 = 9876; // Arbitrarily chosen. +pub const ADDR: &str = "127.0.0.1"; +pub const BITCOIND_JSON_RPC_URL: &str = "127.0.0.1:8332"; + +#[tokio::main] +async fn main() -> Result<()> { + let opt = Options::from_args(); + + trace::init_tracing(LevelFilter::Debug)?; + + let addr = format!("/ip4/{}/tcp/{}", ADDR, PORT); + let alice: Multiaddr = addr.parse().expect("failed to parse Alice's address"); + + if opt.as_alice { + info!("running swap node as Alice ..."); + + if opt.piconeros.is_some() || opt.satoshis.is_some() { + bail!("Alice cannot set the amount to swap via the cli"); + } + + let url = Url::parse(BITCOIND_JSON_RPC_URL).expect("failed to parse url"); + let bitcoin_wallet = Wallet::new("alice", &url) + .await + .expect("failed to create bitcoin wallet"); + + let redeem = bitcoin_wallet + .new_address() + .await + .expect("failed to get new redeem address"); + let punish = bitcoin_wallet + .new_address() + .await + .expect("failed to get new punish address"); + + swap_as_alice(alice.clone(), redeem, punish).await?; + } else { + info!("running swap node as Bob ..."); + + let url = Url::parse(BITCOIND_JSON_RPC_URL).expect("failed to parse url"); + let bitcoin_wallet = Wallet::new("bob", &url) + .await + .expect("failed to create bitcoin wallet"); + + let refund = bitcoin_wallet + .new_address() + .await + .expect("failed to get new address"); + + match (opt.piconeros, opt.satoshis) { + (Some(_), Some(_)) => bail!("Please supply only a single amount to swap"), + (None, None) => bail!("Please supply an amount to swap"), + (Some(_picos), _) => todo!("support starting with picos"), + (None, Some(sats)) => { + swap_as_bob(sats, alice, refund, bitcoin_wallet).await?; + } + }; + } + + Ok(()) +} + +async fn swap_as_alice( + addr: Multiaddr, + redeem: bitcoin::Address, + punish: bitcoin::Address, +) -> Result<()> { + alice::swap(addr, redeem, punish).await +} + +async fn swap_as_bob( + sats: u64, + alice: Multiaddr, + refund: bitcoin::Address, + wallet: W, +) -> Result<()> +where + W: BuildTxLockPsbt + Send + Sync + 'static, +{ + let (cmd_tx, mut cmd_rx) = mpsc::channel(1); + let (mut rsp_tx, rsp_rx) = mpsc::channel(1); + tokio::spawn(bob::swap(sats, alice, cmd_tx, rsp_rx, refund, wallet)); + + loop { + let read = cmd_rx.next().await; + match read { + Some(cmd) => match cmd { + Cmd::VerifyAmounts(p) => { + let rsp = verify(p); + rsp_tx.try_send(rsp)?; + if rsp == Rsp::Abort { + process::exit(0); + } + } + }, + None => { + info!("Channel closed from other end"); + return Ok(()); + } + } + } +} + +fn verify(amounts: SwapAmounts) -> Rsp { + let mut s = String::new(); + println!("Got rate from Alice for XMR/BTC swap\n"); + println!("{}", amounts); + print!("Would you like to continue with this swap [y/N]: "); + + let _ = io::stdout().flush(); + io::stdin() + .read_line(&mut s) + .expect("Did not enter a correct string"); + + if let Some('\n') = s.chars().next_back() { + s.pop(); + } + if let Some('\r') = s.chars().next_back() { + s.pop(); + } + + if !is_yes(&s) { + println!("No worries, try again later - Alice updates her rate regularly"); + return Rsp::Abort; + } + + Rsp::VerifiedAmounts +} + +fn is_yes(s: &str) -> bool { + matches!(s, "y" | "Y" | "yes" | "YES" | "Yes") +} diff --git a/swap/src/network.rs b/swap/src/network.rs new file mode 100644 index 00000000..90bf44de --- /dev/null +++ b/swap/src/network.rs @@ -0,0 +1,18 @@ +use futures::prelude::*; +use libp2p::core::Executor; +use std::pin::Pin; +use tokio::runtime::Handle; + +pub mod peer_tracker; +pub mod request_response; +pub mod transport; + +pub struct TokioExecutor { + pub handle: Handle, +} + +impl Executor for TokioExecutor { + fn exec(&self, future: Pin + Send>>) { + let _ = self.handle.spawn(future); + } +} diff --git a/swap/src/network/peer_tracker.rs b/swap/src/network/peer_tracker.rs new file mode 100644 index 00000000..08e97077 --- /dev/null +++ b/swap/src/network/peer_tracker.rs @@ -0,0 +1,106 @@ +use futures::task::Context; +use libp2p::{ + core::{connection::ConnectionId, ConnectedPoint}, + swarm::{ + protocols_handler::DummyProtocolsHandler, NetworkBehaviour, NetworkBehaviourAction, + PollParameters, + }, + Multiaddr, PeerId, +}; +use std::{collections::VecDeque, task::Poll}; + +#[derive(Debug)] +pub enum OutEvent { + ConnectionEstablished(PeerId), +} + +/// A NetworkBehaviour that tracks connections to the counterparty. Although the +/// libp2p `NetworkBehaviour` abstraction encompasses connections to multiple +/// peers we only ever connect to a single counterparty. Peer Tracker tracks +/// that connection. +#[derive(Default, Debug)] +pub struct PeerTracker { + connected: Option<(PeerId, Multiaddr)>, + events: VecDeque, +} + +impl PeerTracker { + /// Returns the peer id of counterparty if we are connected. + pub fn counterparty_peer_id(&self) -> Option { + if let Some((id, _)) = &self.connected { + return Some(id.clone()); + } + None + } + + /// Returns the multiaddr of counterparty if we are connected. + pub fn counterparty_addr(&self) -> Option { + if let Some((_, addr)) = &self.connected { + return Some(addr.clone()); + } + None + } +} + +impl NetworkBehaviour for PeerTracker { + type ProtocolsHandler = DummyProtocolsHandler; + type OutEvent = OutEvent; + + fn new_handler(&mut self) -> Self::ProtocolsHandler { + DummyProtocolsHandler::default() + } + + fn addresses_of_peer(&mut self, _: &PeerId) -> Vec { + let mut addresses: Vec = vec![]; + + if let Some(addr) = self.counterparty_addr() { + addresses.push(addr) + } + + addresses + } + + fn inject_connected(&mut self, _: &PeerId) {} + + fn inject_disconnected(&mut self, _: &PeerId) {} + + fn inject_connection_established( + &mut self, + peer: &PeerId, + _: &ConnectionId, + point: &ConnectedPoint, + ) { + match point { + ConnectedPoint::Dialer { address } => { + self.connected = Some((peer.clone(), address.clone())); + } + ConnectedPoint::Listener { + local_addr: _, + send_back_addr, + } => { + self.connected = Some((peer.clone(), send_back_addr.clone())); + } + } + + self.events + .push_back(OutEvent::ConnectionEstablished(peer.clone())); + } + + fn inject_connection_closed(&mut self, _: &PeerId, _: &ConnectionId, _: &ConnectedPoint) { + self.connected = None; + } + + fn inject_event(&mut self, _: PeerId, _: ConnectionId, _: void::Void) {} + + fn poll( + &mut self, + _: &mut Context<'_>, + _: &mut impl PollParameters, + ) -> Poll> { + if let Some(event) = self.events.pop_front() { + return Poll::Ready(NetworkBehaviourAction::GenerateEvent(event)); + } + + Poll::Pending + } +} diff --git a/swap/src/network/request_response.rs b/swap/src/network/request_response.rs new file mode 100644 index 00000000..b0061a9b --- /dev/null +++ b/swap/src/network/request_response.rs @@ -0,0 +1,116 @@ +use async_trait::async_trait; +use futures::prelude::*; +use libp2p::{ + core::upgrade, + request_response::{ProtocolName, RequestResponseCodec}, +}; +use serde::{Deserialize, Serialize}; +use std::{fmt::Debug, io}; + +use crate::SwapAmounts; +use xmr_btc::{alice, bob, monero}; + +/// Time to wait for a response back once we send a request. +pub const TIMEOUT: u64 = 3600; // One hour. + +// TODO: Think about whether there is a better way to do this, e.g., separate +// Codec for each Message and a macro that implements them. + +/// Messages Bob sends to Alice. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[allow(clippy::large_enum_variant)] +pub enum BobToAlice { + #[serde(with = "::bitcoin::util::amount::serde::as_sat")] + AmountsFromBtc(::bitcoin::Amount), + AmountsFromXmr(monero::Amount), + Message0(bob::Message0), + Message1(bob::Message1), +} + +/// Messages Alice sends to Bob. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[allow(clippy::large_enum_variant)] +pub enum AliceToBob { + Amounts(SwapAmounts), + Message0(alice::Message0), + Message1(alice::Message1), +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct Protocol; + +impl ProtocolName for Protocol { + fn protocol_name(&self) -> &[u8] { + b"/xmr/btc/1.0.0" + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Codec; + +#[async_trait] +impl RequestResponseCodec for Codec { + type Protocol = Protocol; + type Request = BobToAlice; + type Response = AliceToBob; + + async fn read_request(&mut self, _: &Self::Protocol, io: &mut T) -> io::Result + where + T: AsyncRead + Unpin + Send, + { + let message = upgrade::read_one(io, 1024) + .await + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let mut de = serde_json::Deserializer::from_slice(&message); + let msg = BobToAlice::deserialize(&mut de)?; + + Ok(msg) + } + + async fn read_response( + &mut self, + _: &Self::Protocol, + io: &mut T, + ) -> io::Result + where + T: AsyncRead + Unpin + Send, + { + let message = upgrade::read_one(io, 1024) + .await + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let mut de = serde_json::Deserializer::from_slice(&message); + let msg = AliceToBob::deserialize(&mut de)?; + + Ok(msg) + } + + async fn write_request( + &mut self, + _: &Self::Protocol, + io: &mut T, + req: Self::Request, + ) -> io::Result<()> + where + T: AsyncWrite + Unpin + Send, + { + let bytes = serde_json::to_vec(&req)?; + upgrade::write_one(io, &bytes).await?; + + Ok(()) + } + + async fn write_response( + &mut self, + _: &Self::Protocol, + io: &mut T, + res: Self::Response, + ) -> io::Result<()> + where + T: AsyncWrite + Unpin + Send, + { + let bytes = serde_json::to_vec(&res)?; + upgrade::write_one(io, &bytes).await?; + + Ok(()) + } +} diff --git a/swap/src/network/transport.rs b/swap/src/network/transport.rs new file mode 100644 index 00000000..19da6c8f --- /dev/null +++ b/swap/src/network/transport.rs @@ -0,0 +1,44 @@ +use anyhow::Result; +use libp2p::{ + core::{ + identity, + muxing::StreamMuxerBox, + transport::Boxed, + upgrade::{SelectUpgrade, Version}, + Transport, + }, + dns::DnsConfig, + mplex::MplexConfig, + noise::{self, NoiseConfig, X25519Spec}, + tcp::TokioTcpConfig, + yamux, PeerId, +}; + +// TOOD: Add the tor transport builder. + +/// Builds a libp2p transport with the following features: +/// - TcpConnection +/// - DNS name resolution +/// - authentication via noise +/// - multiplexing via yamux or mplex +pub fn build(id_keys: identity::Keypair) -> Result { + let dh_keys = noise::Keypair::::new().into_authentic(&id_keys)?; + let noise = NoiseConfig::xx(dh_keys).into_authenticated(); + + let tcp = TokioTcpConfig::new().nodelay(true); + let dns = DnsConfig::new(tcp)?; + + let transport = dns + .upgrade(Version::V1) + .authenticate(noise) + .multiplex(SelectUpgrade::new( + yamux::Config::default(), + MplexConfig::new(), + )) + .map(|(peer, muxer), _| (peer, StreamMuxerBox::new(muxer))) + .boxed(); + + Ok(transport) +} + +pub type SwapTransport = Boxed<(PeerId, StreamMuxerBox)>; diff --git a/swap/src/trace.rs b/swap/src/trace.rs new file mode 100644 index 00000000..14854cd4 --- /dev/null +++ b/swap/src/trace.rs @@ -0,0 +1,25 @@ +use atty::{self, Stream}; +use log::LevelFilter; +use tracing::{info, subscriber}; +use tracing_log::LogTracer; +use tracing_subscriber::FmtSubscriber; + +pub fn init_tracing(level: log::LevelFilter) -> anyhow::Result<()> { + if level == LevelFilter::Off { + return Ok(()); + } + + // Upstream log filter. + LogTracer::init_with_filter(LevelFilter::Debug)?; + + let is_terminal = atty::is(Stream::Stdout); + let subscriber = FmtSubscriber::builder() + .with_env_filter(format!("swap={}", level)) + .with_ansi(is_terminal) + .finish(); + + subscriber::set_global_default(subscriber)?; + info!("Initialized tracing with level: {}", level); + + Ok(()) +} diff --git a/xmr-btc/Cargo.toml b/xmr-btc/Cargo.toml index b8afd051..127dcb64 100644 --- a/xmr-btc/Cargo.toml +++ b/xmr-btc/Cargo.toml @@ -4,17 +4,20 @@ version = "0.1.0" authors = ["CoBloX Team "] edition = "2018" +# TODO: Check for stale dependencies, this looks like its a bit of a mess. + [dependencies] anyhow = "1" async-trait = "0.1" -bitcoin = { version = "0.23", features = ["rand"] } -cross-curve-dleq = { git = "https://github.com/comit-network/cross-curve-dleq", rev = "49171f5e08473d46f951fb1fc338fe437d974d3c" } +bitcoin = { version = "0.23", features = ["rand", "serde"] } +cross-curve-dleq = { git = "https://github.com/comit-network/cross-curve-dleq", rev = "1931c0436f259e1a1f53a4ec8acbbaaf614bd1e4", features = ["serde"] } curve25519-dalek = "2" -ecdsa_fun = { git = "https://github.com/LLFourn/secp256kfun", rev = "510d48ef6a2b19805f7f5c70c598e5b03f668e7a", features = ["libsecp_compat"] } -ed25519-dalek = "1.0.0-pre.4" # Cannot be 1 because they depend on curve25519-dalek version 3 -lazy_static = "1.4" -miniscript = "1" -monero = "0.9" +ecdsa_fun = { git = "https://github.com/LLFourn/secp256kfun", rev = "510d48ef6a2b19805f7f5c70c598e5b03f668e7a", features = ["libsecp_compat", "serde", "serialization"] } +ed25519-dalek = { version = "1.0.0-pre.4", features = ["serde"] }# Cannot be 1 because they depend on curve25519-dalek version 3 +futures = "0.3" +genawaiter = "0.99.1" +miniscript = { version = "1", features = ["serde"] } +monero = { version = "0.9", features = ["serde_support"] } rand = "0.7" reqwest = { version = "0.10", default-features = false, features = ["socks"] } serde = { version = "1", features = ["derive"] } @@ -26,12 +29,16 @@ torut = { version = "0.1", optional = true } tracing = "0.1" [dev-dependencies] +backoff = { version = "0.2", features = ["tokio"] } base64 = "0.12" -bitcoin-harness = { git = "https://github.com/coblox/bitcoin-harness-rs", rev = "d402b36d3d6406150e3bfb71492ff4a0a7cb290e" } +bitcoin-harness = { git = "https://github.com/coblox/bitcoin-harness-rs", rev = "7ff30a559ab57cc3aa71189e71433ef6b2a6c3a2" } futures = "0.3" hyper = "0.13" monero-harness = { path = "../monero-harness" } port_check = "0.1" +reqwest = { version = "0.10", default-features = false } +serde_cbor = "0.11" +sled = "0.34" spectral = "0.6" tempfile = "3" testcontainers = "0.10" diff --git a/xmr-btc/src/alice.rs b/xmr-btc/src/alice.rs index 0bba27bb..860027fd 100644 --- a/xmr-btc/src/alice.rs +++ b/xmr-btc/src/alice.rs @@ -3,6 +3,7 @@ use crate::{ bitcoin::{BroadcastSignedTransaction, WatchForRawTransaction}, bob, monero, monero::{CreateWalletForOutput, Transfer}, + serde::{bitcoin_amount, cross_curve_dleq_scalar, ecdsa_fun_signature}, transport::{ReceiveMessage, SendMessage}, }; use anyhow::{anyhow, Result}; @@ -11,6 +12,7 @@ use ecdsa_fun::{ nonce::Deterministic, }; use rand::{CryptoRng, RngCore}; +use serde::{Deserialize, Serialize}; use sha2::Sha256; use std::convert::{TryFrom, TryInto}; @@ -129,11 +131,13 @@ impl State { } } -#[derive(Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct State0 { a: bitcoin::SecretKey, + #[serde(with = "cross_curve_dleq_scalar")] s_a: cross_curve_dleq::Scalar, v_a: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, xmr: monero::Amount, refund_timelock: u32, @@ -215,14 +219,16 @@ impl State0 { } } -#[derive(Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct State1 { a: bitcoin::SecretKey, B: bitcoin::PublicKey, + #[serde(with = "cross_curve_dleq_scalar")] s_a: cross_curve_dleq::Scalar, S_b_monero: monero::PublicKey, S_b_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, xmr: monero::Amount, refund_timelock: u32, @@ -253,14 +259,16 @@ impl State1 { } } -#[derive(Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct State2 { a: bitcoin::SecretKey, B: bitcoin::PublicKey, + #[serde(with = "cross_curve_dleq_scalar")] s_a: cross_curve_dleq::Scalar, S_b_monero: monero::PublicKey, S_b_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, xmr: monero::Amount, refund_timelock: u32, @@ -328,24 +336,28 @@ impl State2 { } } -#[derive(Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct State3 { - a: bitcoin::SecretKey, - B: bitcoin::PublicKey, - s_a: cross_curve_dleq::Scalar, - S_b_monero: monero::PublicKey, - S_b_bitcoin: bitcoin::PublicKey, - v: monero::PrivateViewKey, + pub a: bitcoin::SecretKey, + pub B: bitcoin::PublicKey, + #[serde(with = "cross_curve_dleq_scalar")] + pub s_a: cross_curve_dleq::Scalar, + pub S_b_monero: monero::PublicKey, + pub S_b_bitcoin: bitcoin::PublicKey, + pub v: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, - xmr: monero::Amount, - refund_timelock: u32, - punish_timelock: u32, - refund_address: bitcoin::Address, - redeem_address: bitcoin::Address, - punish_address: bitcoin::Address, - tx_lock: bitcoin::TxLock, - tx_punish_sig_bob: bitcoin::Signature, - tx_cancel_sig_bob: bitcoin::Signature, + pub xmr: monero::Amount, + pub refund_timelock: u32, + pub punish_timelock: u32, + pub refund_address: bitcoin::Address, + pub redeem_address: bitcoin::Address, + pub punish_address: bitcoin::Address, + pub tx_lock: bitcoin::TxLock, + #[serde(with = "ecdsa_fun_signature")] + pub tx_punish_sig_bob: bitcoin::Signature, + #[serde(with = "ecdsa_fun_signature")] + pub tx_cancel_sig_bob: bitcoin::Signature, } impl State3 { @@ -356,7 +368,7 @@ impl State3 { tracing::info!("watching for lock btc with txid: {}", self.tx_lock.txid()); let tx = bitcoin_wallet .watch_for_raw_transaction(self.tx_lock.txid()) - .await?; + .await; tracing::info!("tx lock seen with txid: {}", tx.txid()); @@ -381,14 +393,16 @@ impl State3 { } } -#[derive(Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct State4 { a: bitcoin::SecretKey, B: bitcoin::PublicKey, + #[serde(with = "cross_curve_dleq_scalar")] s_a: cross_curve_dleq::Scalar, S_b_monero: monero::PublicKey, S_b_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, xmr: monero::Amount, refund_timelock: u32, @@ -397,7 +411,9 @@ pub struct State4 { redeem_address: bitcoin::Address, punish_address: bitcoin::Address, tx_lock: bitcoin::TxLock, + #[serde(with = "ecdsa_fun_signature")] tx_punish_sig_bob: bitcoin::Signature, + #[serde(with = "ecdsa_fun_signature")] tx_cancel_sig_bob: bitcoin::Signature, } @@ -484,14 +500,16 @@ impl State4 { } } -#[derive(Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct State5 { a: bitcoin::SecretKey, B: bitcoin::PublicKey, + #[serde(with = "cross_curve_dleq_scalar")] s_a: cross_curve_dleq::Scalar, S_b_monero: monero::PublicKey, S_b_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, xmr: monero::Amount, refund_timelock: u32, @@ -501,7 +519,9 @@ pub struct State5 { punish_address: bitcoin::Address, tx_lock: bitcoin::TxLock, tx_lock_proof: monero::TransferProof, + #[serde(with = "ecdsa_fun_signature")] tx_punish_sig_bob: bitcoin::Signature, + #[serde(with = "ecdsa_fun_signature")] tx_cancel_sig_bob: bitcoin::Signature, lock_xmr_fee: monero::Amount, } @@ -554,7 +574,7 @@ impl State5 { let tx_refund_candidate = bitcoin_wallet .watch_for_raw_transaction(tx_refund.txid()) - .await?; + .await; let tx_refund_sig = tx_refund.extract_signature_by_key(tx_refund_candidate, self.a.public())?; @@ -575,14 +595,16 @@ impl State5 { } } -#[derive(Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct State6 { a: bitcoin::SecretKey, B: bitcoin::PublicKey, + #[serde(with = "cross_curve_dleq_scalar")] s_a: cross_curve_dleq::Scalar, S_b_monero: monero::PublicKey, S_b_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, xmr: monero::Amount, refund_timelock: u32, @@ -591,6 +613,7 @@ pub struct State6 { redeem_address: bitcoin::Address, punish_address: bitcoin::Address, tx_lock: bitcoin::TxLock, + #[serde(with = "ecdsa_fun_signature")] tx_punish_sig_bob: bitcoin::Signature, tx_redeem_encsig: EncryptedSignature, lock_xmr_fee: monero::Amount, diff --git a/xmr-btc/src/alice/message.rs b/xmr-btc/src/alice/message.rs index 7c95604b..6bbaa9ac 100644 --- a/xmr-btc/src/alice/message.rs +++ b/xmr-btc/src/alice/message.rs @@ -1,5 +1,6 @@ use anyhow::Result; use ecdsa_fun::{adaptor::EncryptedSignature, Signature}; +use serde::{Deserialize, Serialize}; use std::convert::TryFrom; use crate::{bitcoin, monero}; @@ -11,7 +12,7 @@ pub enum Message { Message2(Message2), } -#[derive(Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Message0 { pub(crate) A: bitcoin::PublicKey, pub(crate) S_a_monero: monero::PublicKey, @@ -22,13 +23,13 @@ pub struct Message0 { pub(crate) punish_address: bitcoin::Address, } -#[derive(Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Message1 { pub(crate) tx_cancel_sig: Signature, pub(crate) tx_refund_encsig: EncryptedSignature, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Message2 { pub(crate) tx_lock_proof: monero::TransferProof, } diff --git a/xmr-btc/src/bitcoin.rs b/xmr-btc/src/bitcoin.rs index 5bad1f9d..f85c0271 100644 --- a/xmr-btc/src/bitcoin.rs +++ b/xmr-btc/src/bitcoin.rs @@ -6,29 +6,27 @@ use bitcoin::{ hashes::{hex::ToHex, Hash}, secp256k1, util::psbt::PartiallySignedTransaction, - SigHash, Transaction, + SigHash, }; -pub use bitcoin::{Address, Amount, OutPoint, Txid}; use ecdsa_fun::{ adaptor::Adaptor, - fun::{ - marker::{Jacobian, Mark}, - Point, Scalar, - }, + fun::{Point, Scalar}, nonce::Deterministic, ECDSA, }; -pub use ecdsa_fun::{adaptor::EncryptedSignature, Signature}; use miniscript::{Descriptor, Segwitv0}; use rand::{CryptoRng, RngCore}; +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 ecdsa_fun::{adaptor::EncryptedSignature, Signature}; pub const TX_FEE: u64 = 10_000; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct SecretKey { inner: Scalar, public: Point, @@ -83,12 +81,12 @@ impl SecretKey { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PublicKey(Point); -impl From for Point { +impl From for Point { fn from(from: PublicKey) -> Self { - from.0.mark::() + from.0 } } @@ -189,7 +187,7 @@ pub trait BroadcastSignedTransaction { #[async_trait] pub trait WatchForRawTransaction { - async fn watch_for_raw_transaction(&self, txid: Txid) -> Result; + async fn watch_for_raw_transaction(&self, txid: Txid) -> Transaction; } pub fn recover(S: PublicKey, sig: Signature, encsig: EncryptedSignature) -> Result { diff --git a/xmr-btc/src/bitcoin/transactions.rs b/xmr-btc/src/bitcoin/transactions.rs index 9eb7878d..3242f471 100644 --- a/xmr-btc/src/bitcoin/transactions.rs +++ b/xmr-btc/src/bitcoin/transactions.rs @@ -8,9 +8,10 @@ use bitcoin::{ }; use ecdsa_fun::Signature; use miniscript::Descriptor; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TxLock { inner: Transaction, output_descriptor: Descriptor<::bitcoin::PublicKey>, @@ -260,6 +261,10 @@ impl TxCancel { } } + pub fn txid(&self) -> Txid { + self.inner.txid() + } + pub fn digest(&self) -> SigHash { self.digest } @@ -459,6 +464,10 @@ impl TxPunish { } } + pub fn txid(&self) -> Txid { + self.inner.txid() + } + pub fn digest(&self) -> SigHash { self.digest } diff --git a/xmr-btc/src/bob.rs b/xmr-btc/src/bob.rs index 57515f1a..1dfb8b81 100644 --- a/xmr-btc/src/bob.rs +++ b/xmr-btc/src/bob.rs @@ -5,7 +5,8 @@ use crate::{ WatchForRawTransaction, }, monero, - monero::{CheckTransfer, CreateWalletForOutput}, + monero::{CreateWalletForOutput, WatchForTransfer}, + serde::{bitcoin_amount, cross_curve_dleq_scalar, monero_private_key}, transport::{ReceiveMessage, SendMessage}, }; use anyhow::{anyhow, Result}; @@ -15,6 +16,7 @@ use ecdsa_fun::{ Signature, }; use rand::{CryptoRng, RngCore}; +use serde::{Deserialize, Serialize}; use sha2::Sha256; use std::convert::{TryFrom, TryInto}; @@ -27,7 +29,7 @@ pub use message::{Message, Message0, Message1, Message2, Message3}; pub async fn next_state< R: RngCore + CryptoRng, B: WatchForRawTransaction + SignTxLock + BuildTxLockPsbt + BroadcastSignedTransaction, - M: CreateWalletForOutput + CheckTransfer, + M: CreateWalletForOutput + WatchForTransfer, T: SendMessage + ReceiveMessage, >( bitcoin_wallet: &B, @@ -50,13 +52,15 @@ pub async fn next_state< let message1 = transport.receive_message().await?.try_into()?; let state2 = state1.receive(message1)?; + + let message2 = state2.next_message(); + transport.send_message(message2.into()).await?; Ok(state2.into()) } State::State2(state2) => { - let message2 = state2.next_message(); let state3 = state2.lock_btc(bitcoin_wallet).await?; tracing::info!("bob has locked btc"); - transport.send_message(message2.into()).await?; + Ok(state3.into()) } State::State3(state3) => { @@ -102,11 +106,13 @@ impl_from_child_enum!(State3, State); impl_from_child_enum!(State4, State); impl_from_child_enum!(State5, State); -#[derive(Debug)] +#[derive(Debug, Deserialize, Serialize)] pub struct State0 { b: bitcoin::SecretKey, + #[serde(with = "cross_curve_dleq_scalar")] s_b: cross_curve_dleq::Scalar, v_b: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, xmr: monero::Amount, refund_timelock: u32, @@ -190,14 +196,16 @@ impl State0 { } } -#[derive(Debug)] +#[derive(Debug, Deserialize, Serialize)] pub struct State1 { A: bitcoin::PublicKey, b: bitcoin::SecretKey, + #[serde(with = "cross_curve_dleq_scalar")] s_b: cross_curve_dleq::Scalar, S_a_monero: monero::PublicKey, S_a_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, xmr: monero::Amount, refund_timelock: u32, @@ -253,24 +261,26 @@ impl State1 { } } -#[derive(Debug)] +#[derive(Debug, Deserialize, Serialize)] pub struct State2 { - A: bitcoin::PublicKey, - b: bitcoin::SecretKey, - s_b: cross_curve_dleq::Scalar, - S_a_monero: monero::PublicKey, - S_a_bitcoin: bitcoin::PublicKey, - v: monero::PrivateViewKey, + pub A: bitcoin::PublicKey, + pub b: bitcoin::SecretKey, + #[serde(with = "cross_curve_dleq_scalar")] + pub s_b: cross_curve_dleq::Scalar, + pub S_a_monero: monero::PublicKey, + pub S_a_bitcoin: bitcoin::PublicKey, + pub v: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, - xmr: monero::Amount, - refund_timelock: u32, + pub xmr: monero::Amount, + pub refund_timelock: u32, punish_timelock: u32, - refund_address: bitcoin::Address, - redeem_address: bitcoin::Address, + pub refund_address: bitcoin::Address, + pub redeem_address: bitcoin::Address, punish_address: bitcoin::Address, - tx_lock: bitcoin::TxLock, - tx_cancel_sig_a: Signature, - tx_refund_encsig: EncryptedSignature, + pub tx_lock: bitcoin::TxLock, + pub tx_cancel_sig_a: Signature, + pub tx_refund_encsig: EncryptedSignature, } impl State2 { @@ -324,14 +334,16 @@ impl State2 { } } -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize)] pub struct State3 { A: bitcoin::PublicKey, b: bitcoin::SecretKey, + #[serde(with = "cross_curve_dleq_scalar")] s_b: cross_curve_dleq::Scalar, S_a_monero: monero::PublicKey, S_a_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, xmr: monero::Amount, refund_timelock: u32, @@ -347,7 +359,7 @@ pub struct State3 { impl State3 { pub async fn watch_for_lock_xmr(self, xmr_wallet: &W, msg: alice::Message2) -> Result where - W: monero::CheckTransfer, + W: monero::WatchForTransfer, { let S_b_monero = monero::PublicKey::from_private_key(&monero::PrivateKey::from_scalar( self.s_b.into_ed25519(), @@ -355,7 +367,13 @@ impl State3 { let S = self.S_a_monero + S_b_monero; xmr_wallet - .check_transfer(S, self.v.public(), msg.tx_lock_proof, self.xmr) + .watch_for_transfer( + S, + self.v.public(), + msg.tx_lock_proof, + self.xmr, + monero::MIN_CONFIRMATIONS, + ) .await?; Ok(State4 { @@ -429,14 +447,16 @@ impl State3 { } } -#[derive(Debug)] +#[derive(Debug, Deserialize, Serialize)] pub struct State4 { A: bitcoin::PublicKey, b: bitcoin::SecretKey, + #[serde(with = "cross_curve_dleq_scalar")] s_b: cross_curve_dleq::Scalar, S_a_monero: monero::PublicKey, S_a_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, xmr: monero::Amount, refund_timelock: u32, @@ -466,7 +486,7 @@ impl State4 { let tx_redeem_candidate = bitcoin_wallet .watch_for_raw_transaction(tx_redeem.txid()) - .await?; + .await; let tx_redeem_sig = tx_redeem.extract_signature_by_key(tx_redeem_candidate, self.b.public())?; @@ -496,15 +516,18 @@ impl State4 { } } -#[derive(Debug)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct State5 { A: bitcoin::PublicKey, b: bitcoin::SecretKey, + #[serde(with = "monero_private_key")] s_a: monero::PrivateKey, + #[serde(with = "cross_curve_dleq_scalar")] s_b: cross_curve_dleq::Scalar, S_a_monero: monero::PublicKey, S_a_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, + #[serde(with = "bitcoin_amount")] btc: bitcoin::Amount, xmr: monero::Amount, refund_timelock: u32, diff --git a/xmr-btc/src/bob/message.rs b/xmr-btc/src/bob/message.rs index de02b7a5..bfd977c6 100644 --- a/xmr-btc/src/bob/message.rs +++ b/xmr-btc/src/bob/message.rs @@ -1,9 +1,10 @@ use crate::{bitcoin, monero}; use anyhow::Result; use ecdsa_fun::{adaptor::EncryptedSignature, Signature}; +use serde::{Deserialize, Serialize}; use std::convert::TryFrom; -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum Message { Message0(Message0), Message1(Message1), @@ -11,7 +12,7 @@ pub enum Message { Message3(Message3), } -#[derive(Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Message0 { pub(crate) B: bitcoin::PublicKey, pub(crate) S_b_monero: monero::PublicKey, @@ -21,18 +22,18 @@ pub struct Message0 { pub(crate) refund_address: bitcoin::Address, } -#[derive(Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Message1 { pub(crate) tx_lock: bitcoin::TxLock, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Message2 { pub(crate) tx_punish_sig: Signature, pub(crate) tx_cancel_sig: Signature, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Message3 { pub(crate) tx_redeem_encsig: EncryptedSignature, } diff --git a/xmr-btc/src/lib.rs b/xmr-btc/src/lib.rs index 9526bad4..dcef49ba 100644 --- a/xmr-btc/src/lib.rs +++ b/xmr-btc/src/lib.rs @@ -49,6 +49,564 @@ pub mod alice; pub mod bitcoin; pub mod bob; pub mod monero; +pub mod serde; #[cfg(feature = "tor")] pub mod tor; pub mod transport; + +use async_trait::async_trait; +use ecdsa_fun::{adaptor::Adaptor, nonce::Deterministic}; +use futures::{ + future::{select, Either}, + Future, FutureExt, +}; +use genawaiter::sync::{Gen, GenBoxed}; +use sha2::Sha256; +use std::{sync::Arc, time::Duration}; +use tokio::time::timeout; +use tracing::error; + +// TODO: Replace this with something configurable, such as an function argument. +/// Time that Bob has to publish the Bitcoin lock transaction before both +/// parties will abort, in seconds. +const SECS_TO_ACT_BOB: u64 = 60; + +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +pub enum BobAction { + LockBitcoin(bitcoin::TxLock), + SendBitcoinRedeemEncsig(bitcoin::EncryptedSignature), + CreateMoneroWalletForOutput { + spend_key: monero::PrivateKey, + view_key: monero::PrivateViewKey, + }, + CancelBitcoin(bitcoin::Transaction), + RefundBitcoin(bitcoin::Transaction), +} + +// TODO: This could be moved to the monero module +#[async_trait] +pub trait ReceiveTransferProof { + async fn receive_transfer_proof(&mut self) -> monero::TransferProof; +} + +#[async_trait] +pub trait BlockHeight { + async fn block_height(&self) -> u32; +} + +#[async_trait] +pub trait TransactionBlockHeight { + async fn transaction_block_height(&self, txid: bitcoin::Txid) -> u32; +} + +/// Perform the on-chain protocol to swap monero and bitcoin as Bob. +/// +/// This is called post handshake, after all the keys, addresses and most of the +/// signatures have been exchanged. +pub fn action_generator_bob( + mut network: N, + monero_client: Arc, + bitcoin_client: Arc, + // TODO: Replace this with a new, slimmer struct? + bob::State2 { + A, + b, + s_b, + S_a_monero, + S_a_bitcoin, + v, + xmr, + refund_timelock, + redeem_address, + refund_address, + tx_lock, + tx_cancel_sig_a, + tx_refund_encsig, + .. + }: bob::State2, +) -> GenBoxed +where + N: ReceiveTransferProof + Send + Sync + 'static, + M: monero::WatchForTransfer + Send + Sync + 'static, + B: BlockHeight + + TransactionBlockHeight + + bitcoin::WatchForRawTransaction + + Send + + Sync + + 'static, +{ + #[derive(Debug)] + enum SwapFailed { + BeforeBtcLock, + AfterBtcLock(Reason), + AfterBtcRedeem(Reason), + } + + /// Reason why the swap has failed. + #[derive(Debug)] + enum Reason { + /// The refund timelock has been reached. + BtcExpired, + /// Alice did not lock up enough monero in the shared output. + InsufficientXmr(monero::InsufficientFunds), + /// Could not find Bob's signature on the redeem transaction witness + /// stack. + BtcRedeemSignature, + /// Could not recover secret `s_a` from Bob's redeem transaction + /// signature. + SecretRecovery, + } + + async fn poll_until(condition_future: impl Future + Clone) { + loop { + if condition_future.clone().await { + return; + } + + tokio::time::delay_for(std::time::Duration::from_secs(1)).await; + } + } + + async fn bitcoin_block_height_is_gte(bitcoin_client: &B, n_blocks: u32) -> bool + where + B: BlockHeight, + { + bitcoin_client.block_height().await >= n_blocks + } + + Gen::new_boxed(|co| async move { + let swap_result: Result<(), SwapFailed> = async { + co.yield_(BobAction::LockBitcoin(tx_lock.clone())).await; + + timeout( + Duration::from_secs(SECS_TO_ACT_BOB), + bitcoin_client.watch_for_raw_transaction(tx_lock.txid()), + ) + .await + .map(|tx| tx.txid()) + .map_err(|_| SwapFailed::BeforeBtcLock)?; + + let tx_lock_height = bitcoin_client + .transaction_block_height(tx_lock.txid()) + .await; + let btc_has_expired = bitcoin_block_height_is_gte( + bitcoin_client.as_ref(), + tx_lock_height + refund_timelock, + ) + .shared(); + let poll_until_btc_has_expired = poll_until(btc_has_expired).shared(); + futures::pin_mut!(poll_until_btc_has_expired); + + let transfer_proof = match select( + network.receive_transfer_proof(), + poll_until_btc_has_expired.clone(), + ) + .await + { + Either::Left((proof, _)) => proof, + Either::Right(_) => return Err(SwapFailed::AfterBtcLock(Reason::BtcExpired)), + }; + + let S_b_monero = monero::PublicKey::from_private_key(&monero::PrivateKey::from_scalar( + s_b.into_ed25519(), + )); + let S = S_a_monero + S_b_monero; + + match select( + monero_client.watch_for_transfer( + S, + v.public(), + transfer_proof, + xmr, + monero::MIN_CONFIRMATIONS, + ), + poll_until_btc_has_expired.clone(), + ) + .await + { + Either::Left((Err(e), _)) => { + return Err(SwapFailed::AfterBtcLock(Reason::InsufficientXmr(e))) + } + Either::Right(_) => return Err(SwapFailed::AfterBtcLock(Reason::BtcExpired)), + _ => {} + } + + let tx_redeem = bitcoin::TxRedeem::new(&tx_lock, &redeem_address); + let tx_redeem_encsig = b.encsign(S_a_bitcoin.clone(), tx_redeem.digest()); + + co.yield_(BobAction::SendBitcoinRedeemEncsig(tx_redeem_encsig.clone())) + .await; + + let tx_redeem_published = match select( + bitcoin_client.watch_for_raw_transaction(tx_redeem.txid()), + poll_until_btc_has_expired, + ) + .await + { + Either::Left((tx, _)) => tx, + Either::Right(_) => return Err(SwapFailed::AfterBtcLock(Reason::BtcExpired)), + }; + + let tx_redeem_sig = tx_redeem + .extract_signature_by_key(tx_redeem_published, b.public()) + .map_err(|_| SwapFailed::AfterBtcRedeem(Reason::BtcRedeemSignature))?; + let s_a = bitcoin::recover(S_a_bitcoin, tx_redeem_sig, tx_redeem_encsig) + .map_err(|_| SwapFailed::AfterBtcRedeem(Reason::SecretRecovery))?; + let s_a = monero::PrivateKey::from_scalar(monero::Scalar::from_bytes_mod_order( + s_a.to_bytes(), + )); + + let s_b = monero::PrivateKey { + scalar: s_b.into_ed25519(), + }; + + co.yield_(BobAction::CreateMoneroWalletForOutput { + spend_key: s_a + s_b, + view_key: v, + }) + .await; + + Ok(()) + } + .await; + + if let Err(err @ SwapFailed::AfterBtcLock(_)) = swap_result { + error!("Swap failed, reason: {:?}", err); + + let tx_cancel = + bitcoin::TxCancel::new(&tx_lock, refund_timelock, A.clone(), b.public()); + let tx_cancel_txid = tx_cancel.txid(); + let signed_tx_cancel = { + let sig_a = tx_cancel_sig_a.clone(); + let sig_b = b.sign(tx_cancel.digest()); + + tx_cancel + .clone() + .add_signatures(&tx_lock, (A.clone(), sig_a), (b.public(), sig_b)) + .expect("sig_{a,b} to be valid signatures for tx_cancel") + }; + + co.yield_(BobAction::CancelBitcoin(signed_tx_cancel)).await; + + let _ = bitcoin_client + .watch_for_raw_transaction(tx_cancel_txid) + .await; + + let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &refund_address); + let tx_refund_txid = tx_refund.txid(); + let signed_tx_refund = { + let adaptor = Adaptor::>::default(); + + let sig_a = + adaptor.decrypt_signature(&s_b.into_secp256k1(), tx_refund_encsig.clone()); + let sig_b = b.sign(tx_refund.digest()); + + tx_refund + .add_signatures(&tx_cancel, (A.clone(), sig_a), (b.public(), sig_b)) + .expect("sig_{a,b} to be valid signatures for tx_refund") + }; + + co.yield_(BobAction::RefundBitcoin(signed_tx_refund)).await; + + let _ = bitcoin_client + .watch_for_raw_transaction(tx_refund_txid) + .await; + } + }) +} + +#[derive(Debug)] +pub enum AliceAction { + // This action also includes proving to Bob that this has happened, given that our current + // protocol requires a transfer proof to verify that the coins have been locked on Monero + LockXmr { + amount: monero::Amount, + public_spend_key: monero::PublicKey, + public_view_key: monero::PublicViewKey, + }, + RedeemBtc(bitcoin::Transaction), + CreateMoneroWalletForOutput { + spend_key: monero::PrivateKey, + view_key: monero::PrivateViewKey, + }, + CancelBtc(bitcoin::Transaction), + PunishBtc(bitcoin::Transaction), +} + +// TODO: This could be moved to the bitcoin module +#[async_trait] +pub trait ReceiveBitcoinRedeemEncsig { + async fn receive_bitcoin_redeem_encsig(&mut self) -> bitcoin::EncryptedSignature; +} + +/// Perform the on-chain protocol to swap monero and bitcoin as Alice. +/// +/// This is called post handshake, after all the keys, addresses and most of the +/// signatures have been exchanged. +pub fn action_generator_alice( + mut network: N, + bitcoin_client: Arc, + // TODO: Replace this with a new, slimmer struct? + alice::State3 { + a, + B, + s_a, + S_b_monero, + S_b_bitcoin, + v, + xmr, + refund_timelock, + punish_timelock, + refund_address, + redeem_address, + punish_address, + tx_lock, + tx_punish_sig_bob, + tx_cancel_sig_bob, + .. + }: alice::State3, +) -> GenBoxed +where + N: ReceiveBitcoinRedeemEncsig + Send + Sync + 'static, + B: BlockHeight + + TransactionBlockHeight + + bitcoin::WatchForRawTransaction + + Send + + Sync + + 'static, +{ + #[derive(Debug)] + enum SwapFailed { + BeforeBtcLock, + AfterXmrLock(Reason), + } + + /// Reason why the swap has failed. + #[derive(Debug)] + enum Reason { + /// The refund timelock has been reached. + BtcExpired, + } + + enum RefundFailed { + BtcPunishable { + tx_cancel_was_published: bool, + }, + /// Could not find Alice's signature on the refund transaction witness + /// stack. + BtcRefundSignature, + /// Could not recover secret `s_b` from Alice's refund transaction + /// signature. + SecretRecovery, + } + + async fn poll_until(condition_future: impl Future + Clone) { + loop { + if condition_future.clone().await { + return; + } + + tokio::time::delay_for(std::time::Duration::from_secs(1)).await; + } + } + + async fn bitcoin_block_height_is_gte(bitcoin_client: &B, n_blocks: u32) -> bool + where + B: BlockHeight, + { + bitcoin_client.block_height().await >= n_blocks + } + + Gen::new_boxed(|co| async move { + let swap_result: Result<(), SwapFailed> = async { + timeout( + Duration::from_secs(SECS_TO_ACT_BOB), + bitcoin_client.watch_for_raw_transaction(tx_lock.txid()), + ) + .await + .map_err(|_| SwapFailed::BeforeBtcLock)?; + + let tx_lock_height = bitcoin_client + .transaction_block_height(tx_lock.txid()) + .await; + let btc_has_expired = bitcoin_block_height_is_gte( + bitcoin_client.as_ref(), + tx_lock_height + refund_timelock, + ) + .shared(); + let poll_until_btc_has_expired = poll_until(btc_has_expired).shared(); + futures::pin_mut!(poll_until_btc_has_expired); + + let S_a = monero::PublicKey::from_private_key(&monero::PrivateKey { + scalar: s_a.into_ed25519(), + }); + + co.yield_(AliceAction::LockXmr { + amount: xmr, + public_spend_key: S_a + S_b_monero, + public_view_key: v.public(), + }) + .await; + + // TODO: Watch for LockXmr using watch-only wallet. Doing so will prevent Alice + // from cancelling/refunding unnecessarily. + + let tx_redeem_encsig = match select( + network.receive_bitcoin_redeem_encsig(), + poll_until_btc_has_expired.clone(), + ) + .await + { + Either::Left((encsig, _)) => encsig, + Either::Right(_) => return Err(SwapFailed::AfterXmrLock(Reason::BtcExpired)), + }; + + let (signed_tx_redeem, tx_redeem_txid) = { + let adaptor = Adaptor::>::default(); + + let tx_redeem = bitcoin::TxRedeem::new(&tx_lock, &redeem_address); + + let sig_a = a.sign(tx_redeem.digest()); + let sig_b = + adaptor.decrypt_signature(&s_a.into_secp256k1(), tx_redeem_encsig.clone()); + + let tx = tx_redeem + .add_signatures(&tx_lock, (a.public(), sig_a), (B.clone(), sig_b)) + .expect("sig_{a,b} to be valid signatures for tx_redeem"); + let txid = tx.txid(); + + (tx, txid) + }; + + co.yield_(AliceAction::RedeemBtc(signed_tx_redeem)).await; + + match select( + bitcoin_client.watch_for_raw_transaction(tx_redeem_txid), + poll_until_btc_has_expired, + ) + .await + { + Either::Left(_) => {} + Either::Right(_) => return Err(SwapFailed::AfterXmrLock(Reason::BtcExpired)), + }; + + Ok(()) + } + .await; + + if let Err(SwapFailed::AfterXmrLock(Reason::BtcExpired)) = swap_result { + let refund_result: Result<(), RefundFailed> = async { + let bob_can_be_punished = + bitcoin_block_height_is_gte(bitcoin_client.as_ref(), punish_timelock).shared(); + let poll_until_bob_can_be_punished = poll_until(bob_can_be_punished).shared(); + futures::pin_mut!(poll_until_bob_can_be_punished); + + let tx_cancel = + bitcoin::TxCancel::new(&tx_lock, refund_timelock, a.public(), B.clone()); + match select( + bitcoin_client.watch_for_raw_transaction(tx_cancel.txid()), + poll_until_bob_can_be_punished.clone(), + ) + .await + { + Either::Left(_) => {} + Either::Right(_) => { + return Err(RefundFailed::BtcPunishable { + tx_cancel_was_published: false, + }) + } + }; + + let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &refund_address); + let tx_refund_published = match select( + bitcoin_client.watch_for_raw_transaction(tx_refund.txid()), + poll_until_bob_can_be_punished, + ) + .await + { + Either::Left((tx, _)) => tx, + Either::Right(_) => { + return Err(RefundFailed::BtcPunishable { + tx_cancel_was_published: true, + }) + } + }; + + let s_a = monero::PrivateKey { + scalar: s_a.into_ed25519(), + }; + + let tx_refund_sig = tx_refund + .extract_signature_by_key(tx_refund_published, B.clone()) + .map_err(|_| RefundFailed::BtcRefundSignature)?; + let tx_refund_encsig = a.encsign(S_b_bitcoin.clone(), tx_refund.digest()); + + let s_b = bitcoin::recover(S_b_bitcoin, tx_refund_sig, tx_refund_encsig) + .map_err(|_| RefundFailed::SecretRecovery)?; + let s_b = monero::PrivateKey::from_scalar(monero::Scalar::from_bytes_mod_order( + s_b.to_bytes(), + )); + + co.yield_(AliceAction::CreateMoneroWalletForOutput { + spend_key: s_a + s_b, + view_key: v, + }) + .await; + + Ok(()) + } + .await; + + // LIMITATION: When approaching the punish scenario, Bob could theoretically + // wake up in between Alice's publication of tx cancel and beat Alice's punish + // 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 + { + 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_(AliceAction::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(); + let signed_tx_punish = { + let sig_a = a.sign(tx_punish.digest()); + let sig_b = tx_punish_sig_bob; + + tx_punish + .add_signatures(&tx_cancel, (a.public(), sig_a), (B, sig_b)) + .expect("sig_{a,b} to be valid signatures for tx_cancel") + }; + + co.yield_(AliceAction::PunishBtc(signed_tx_punish)).await; + + let _ = bitcoin_client + .watch_for_raw_transaction(tx_punish_txid) + .await; + } + } + }) +} diff --git a/xmr-btc/src/monero.rs b/xmr-btc/src/monero.rs index 459fa708..3a57b9d4 100644 --- a/xmr-btc/src/monero.rs +++ b/xmr-btc/src/monero.rs @@ -1,9 +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 std::ops::Add; +use serde::{Deserialize, Serialize}; +use std::ops::{Add, Sub}; + +pub const MIN_CONFIRMATIONS: u32 = 10; pub fn random_private_key(rng: &mut R) -> PrivateKey { let scalar = Scalar::random(rng); @@ -11,8 +15,8 @@ pub fn random_private_key(rng: &mut R) -> PrivateKey { PrivateKey::from_scalar(scalar) } -#[derive(Clone, Copy, Debug)] -pub struct PrivateViewKey(PrivateKey); +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] +pub struct PrivateViewKey(#[serde(with = "monero_private_key")] PrivateKey); impl PrivateViewKey { pub fn new_random(rng: &mut R) -> Self { @@ -50,7 +54,7 @@ impl From for PublicKey { #[derive(Clone, Copy, Debug)] pub struct PublicViewKey(PublicKey); -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, PartialOrd)] pub struct Amount(u64); impl Amount { @@ -66,15 +70,32 @@ impl Amount { } } +impl Add for Amount { + type Output = Amount; + + fn add(self, rhs: Self) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl Sub for Amount { + type Output = Amount; + + fn sub(self, rhs: Self) -> Self::Output { + Self(self.0 - rhs.0) + } +} + impl From for u64 { fn from(from: Amount) -> u64 { from.0 } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct TransferProof { tx_hash: TxHash, + #[serde(with = "monero_private_key")] tx_key: PrivateKey, } @@ -91,7 +112,7 @@ impl TransferProof { } // TODO: add constructor/ change String to fixed length byte array -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct TxHash(pub String); impl From for String { @@ -107,18 +128,26 @@ pub trait Transfer { public_spend_key: PublicKey, public_view_key: PublicViewKey, amount: Amount, - ) -> Result<(TransferProof, Amount)>; + ) -> anyhow::Result<(TransferProof, Amount)>; } #[async_trait] -pub trait CheckTransfer { - async fn check_transfer( +pub trait WatchForTransfer { + async fn watch_for_transfer( &self, public_spend_key: PublicKey, public_view_key: PublicViewKey, transfer_proof: TransferProof, amount: Amount, - ) -> Result<()>; + expected_confirmations: u32, + ) -> Result<(), InsufficientFunds>; +} + +#[derive(Debug, Clone, Copy, thiserror::Error)] +#[error("transaction does not pay enough: expected {expected:?}, got {actual:?}")] +pub struct InsufficientFunds { + pub expected: Amount, + pub actual: Amount, } #[async_trait] @@ -127,5 +156,5 @@ pub trait CreateWalletForOutput { &self, private_spend_key: PrivateKey, private_view_key: PrivateViewKey, - ) -> Result<()>; + ) -> anyhow::Result<()>; } diff --git a/xmr-btc/src/serde.rs b/xmr-btc/src/serde.rs new file mode 100644 index 00000000..f8fa4ab9 --- /dev/null +++ b/xmr-btc/src/serde.rs @@ -0,0 +1,231 @@ +pub mod ecdsa_fun_signature { + use serde::{de, de::Visitor, Deserializer, Serializer}; + use std::{convert::TryFrom, fmt}; + + struct Bytes64Visitor; + + impl<'de> Visitor<'de> for Bytes64Visitor { + type Value = ecdsa_fun::Signature; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "a string containing 64 bytes") + } + + fn visit_bytes(self, s: &[u8]) -> Result + where + E: de::Error, + { + if let Ok(value) = <[u8; 64]>::try_from(s) { + let sig = ecdsa_fun::Signature::from_bytes(value) + .expect("bytes represent an integer greater than or equal to the curve order"); + Ok(sig) + } else { + Err(de::Error::invalid_length(s.len(), &self)) + } + } + } + + pub fn serialize(x: &ecdsa_fun::Signature, s: S) -> Result + where + S: Serializer, + { + s.serialize_bytes(&x.to_bytes()) + } + + pub fn deserialize<'de, D>( + deserializer: D, + ) -> Result>::Error> + where + D: Deserializer<'de>, + { + let sig = deserializer.deserialize_bytes(Bytes64Visitor)?; + Ok(sig) + } +} + +pub mod cross_curve_dleq_scalar { + use serde::{de, de::Visitor, Deserializer, Serializer}; + use std::{convert::TryFrom, fmt}; + + struct Bytes32Visitor; + + impl<'de> Visitor<'de> for Bytes32Visitor { + type Value = cross_curve_dleq::Scalar; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "a string containing 32 bytes") + } + + fn visit_bytes(self, s: &[u8]) -> Result + where + E: de::Error, + { + if let Ok(value) = <[u8; 32]>::try_from(s) { + Ok(cross_curve_dleq::Scalar::from(value)) + } else { + Err(de::Error::invalid_length(s.len(), &self)) + } + } + } + + pub fn serialize(x: &cross_curve_dleq::Scalar, s: S) -> Result + where + S: Serializer, + { + // Serialise as ed25519 because the inner bytes are private + // TODO: Open PR in cross_curve_dleq to allow accessing the inner bytes + s.serialize_bytes(&x.into_ed25519().to_bytes()) + } + + pub fn deserialize<'de, D>( + deserializer: D, + ) -> Result>::Error> + where + D: Deserializer<'de>, + { + let dleq = deserializer.deserialize_bytes(Bytes32Visitor)?; + Ok(dleq) + } +} + +pub mod monero_private_key { + use serde::{de, de::Visitor, Deserializer, Serializer}; + use std::fmt; + + struct BytesVisitor; + + impl<'de> Visitor<'de> for BytesVisitor { + type Value = monero::PrivateKey; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "a string containing 32 bytes") + } + + fn visit_bytes(self, s: &[u8]) -> Result + where + E: de::Error, + { + if let Ok(key) = monero::PrivateKey::from_slice(s) { + Ok(key) + } else { + Err(de::Error::invalid_length(s.len(), &self)) + } + } + } + + pub fn serialize(x: &monero::PrivateKey, s: S) -> Result + where + S: Serializer, + { + s.serialize_bytes(x.as_bytes()) + } + + pub fn deserialize<'de, D>( + deserializer: D, + ) -> Result>::Error> + where + D: Deserializer<'de>, + { + let key = deserializer.deserialize_bytes(BytesVisitor)?; + Ok(key) + } +} + +pub mod bitcoin_amount { + use bitcoin::Amount; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(x: &Amount, s: S) -> Result + where + S: Serializer, + { + s.serialize_u64(x.as_sat()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>::Error> + where + D: Deserializer<'de>, + { + let sats = u64::deserialize(deserializer)?; + let amount = Amount::from_sat(sats); + + Ok(amount) + } +} + +pub mod monero_amount { + use crate::monero::Amount; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(x: &Amount, s: S) -> Result + where + S: Serializer, + { + s.serialize_u64(x.as_piconero()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>::Error> + where + D: Deserializer<'de>, + { + let picos = u64::deserialize(deserializer)?; + let amount = Amount::from_piconero(picos); + + Ok(amount) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ::bitcoin::SigHash; + use curve25519_dalek::scalar::Scalar; + use rand::rngs::OsRng; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + pub struct CrossCurveDleqScalar( + #[serde(with = "cross_curve_dleq_scalar")] cross_curve_dleq::Scalar, + ); + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + pub struct ECDSAFunSignature(#[serde(with = "ecdsa_fun_signature")] ecdsa_fun::Signature); + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + pub struct MoneroPrivateKey(#[serde(with = "monero_private_key")] crate::monero::PrivateKey); + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + pub struct BitcoinAmount(#[serde(with = "bitcoin_amount")] ::bitcoin::Amount); + + #[test] + fn serde_cross_curv_dleq_scalar() { + let scalar = CrossCurveDleqScalar(cross_curve_dleq::Scalar::random(&mut OsRng)); + let encoded = serde_cbor::to_vec(&scalar).unwrap(); + let decoded: CrossCurveDleqScalar = serde_cbor::from_slice(&encoded).unwrap(); + assert_eq!(scalar, decoded); + } + + #[test] + fn serde_ecdsa_fun_sig() { + let secret_key = crate::bitcoin::SecretKey::new_random(&mut OsRng); + let sig = ECDSAFunSignature(secret_key.sign(SigHash::default())); + let encoded = serde_cbor::to_vec(&sig).unwrap(); + let decoded: ECDSAFunSignature = serde_cbor::from_slice(&encoded).unwrap(); + assert_eq!(sig, decoded); + } + + #[test] + fn serde_monero_private_key() { + let key = MoneroPrivateKey(monero::PrivateKey::from_scalar(Scalar::random(&mut OsRng))); + let encoded = serde_cbor::to_vec(&key).unwrap(); + let decoded: MoneroPrivateKey = serde_cbor::from_slice(&encoded).unwrap(); + assert_eq!(key, decoded); + } + #[test] + fn serde_bitcoin_amount() { + let amount = BitcoinAmount(::bitcoin::Amount::from_sat(100)); + let encoded = serde_cbor::to_vec(&amount).unwrap(); + let decoded: BitcoinAmount = serde_cbor::from_slice(&encoded).unwrap(); + assert_eq!(amount, decoded); + } +} diff --git a/xmr-btc/tests/e2e.rs b/xmr-btc/tests/e2e.rs index 41423f5d..148dcbe4 100644 --- a/xmr-btc/tests/e2e.rs +++ b/xmr-btc/tests/e2e.rs @@ -1,145 +1,4 @@ -use crate::harness::wallet; -use bitcoin_harness::Bitcoind; -use harness::{ - node::{AliceNode, BobNode}, - transport::Transport, -}; -use monero_harness::Monero; -use rand::rngs::OsRng; -use testcontainers::clients::Cli; -use tokio::sync::{ - mpsc, - mpsc::{Receiver, Sender}, -}; -use xmr_btc::{alice, bitcoin, bob, monero}; - -mod harness; - -const TEN_XMR: u64 = 10_000_000_000_000; -const RELATIVE_REFUND_TIMELOCK: u32 = 1; -const RELATIVE_PUNISH_TIMELOCK: u32 = 1; - -pub async fn init_bitcoind(tc_client: &Cli) -> Bitcoind<'_> { - let bitcoind = Bitcoind::new(tc_client, "0.19.1").expect("failed to create bitcoind"); - let _ = bitcoind.init(5).await; - - bitcoind -} - -pub struct InitialBalances { - alice_xmr: u64, - alice_btc: bitcoin::Amount, - bob_xmr: u64, - bob_btc: bitcoin::Amount, -} - -pub struct SwapAmounts { - xmr: monero::Amount, - btc: bitcoin::Amount, -} - -pub fn init_alice_and_bob_transports() -> ( - Transport, - Transport, -) { - let (a_sender, b_receiver): (Sender, Receiver) = - mpsc::channel(5); - let (b_sender, a_receiver): (Sender, Receiver) = mpsc::channel(5); - - let a_transport = Transport { - sender: a_sender, - receiver: a_receiver, - }; - - let b_transport = Transport { - sender: b_sender, - receiver: b_receiver, - }; - - (a_transport, b_transport) -} - -pub async fn init_test<'a>( - monero: &'a Monero<'a>, - bitcoind: &Bitcoind<'_>, -) -> ( - alice::State0, - bob::State0, - AliceNode<'a>, - BobNode<'a>, - InitialBalances, - SwapAmounts, -) { - // must be bigger than our hardcoded fee of 10_000 - let btc_amount = bitcoin::Amount::from_sat(10_000_000); - let xmr_amount = monero::Amount::from_piconero(1_000_000_000_000); - - let swap_amounts = SwapAmounts { - xmr: xmr_amount, - btc: btc_amount, - }; - - let fund_alice = TEN_XMR; - let fund_bob = 0; - monero.init(fund_alice, fund_bob).await.unwrap(); - - let alice_monero_wallet = wallet::monero::AliceWallet(&monero); - let bob_monero_wallet = wallet::monero::BobWallet(&monero); - - let alice_btc_wallet = wallet::bitcoin::Wallet::new("alice", &bitcoind.node_url) - .await - .unwrap(); - let bob_btc_wallet = wallet::bitcoin::make_wallet("bob", &bitcoind, btc_amount) - .await - .unwrap(); - - let (alice_transport, bob_transport) = init_alice_and_bob_transports(); - let alice = AliceNode::new(alice_transport, alice_btc_wallet, alice_monero_wallet); - - let bob = BobNode::new(bob_transport, bob_btc_wallet, bob_monero_wallet); - - let alice_initial_btc_balance = alice.bitcoin_wallet.balance().await.unwrap(); - let bob_initial_btc_balance = bob.bitcoin_wallet.balance().await.unwrap(); - - let alice_initial_xmr_balance = alice.monero_wallet.0.get_balance_alice().await.unwrap(); - let bob_initial_xmr_balance = bob.monero_wallet.0.get_balance_bob().await.unwrap(); - - let redeem_address = alice.bitcoin_wallet.new_address().await.unwrap(); - let punish_address = redeem_address.clone(); - let refund_address = bob.bitcoin_wallet.new_address().await.unwrap(); - - let alice_state0 = alice::State0::new( - &mut OsRng, - btc_amount, - xmr_amount, - RELATIVE_REFUND_TIMELOCK, - RELATIVE_PUNISH_TIMELOCK, - redeem_address.clone(), - punish_address.clone(), - ); - let bob_state0 = bob::State0::new( - &mut OsRng, - btc_amount, - xmr_amount, - RELATIVE_REFUND_TIMELOCK, - RELATIVE_PUNISH_TIMELOCK, - refund_address, - ); - let initial_balances = InitialBalances { - alice_xmr: alice_initial_xmr_balance, - alice_btc: alice_initial_btc_balance, - bob_xmr: bob_initial_xmr_balance, - bob_btc: bob_initial_btc_balance, - }; - ( - alice_state0, - bob_state0, - alice, - bob, - initial_balances, - swap_amounts, - ) -} +pub mod harness; mod tests { // NOTE: For some reason running these tests overflows the stack. In order to @@ -149,13 +8,17 @@ mod tests { use crate::{ harness, - harness::node::{run_alice_until, run_bob_until}, - init_bitcoind, init_test, + harness::{ + init_bitcoind, init_test, + node::{run_alice_until, run_bob_until}, + ALICE_TEST_DB_FOLDER, BOB_TEST_DB_FOLDER, + }, }; use futures::future; use monero_harness::Monero; use rand::rngs::OsRng; - use std::convert::TryInto; + + use std::{convert::TryInto, path::Path}; use testcontainers::clients::Cli; use tracing_subscriber::util::SubscriberInitExt; use xmr_btc::{ @@ -171,7 +34,7 @@ mod tests { .set_default(); let cli = Cli::default(); - let monero = Monero::new(&cli); + let (monero, _container) = Monero::new(&cli); let bitcoind = init_bitcoind(&cli).await; let ( @@ -181,7 +44,7 @@ mod tests { mut bob_node, initial_balances, swap_amounts, - ) = init_test(&monero, &bitcoind).await; + ) = init_test(&monero, &bitcoind, None, None).await; let (alice_state, bob_state) = future::try_join( run_alice_until( @@ -212,21 +75,11 @@ mod tests { .await .unwrap(); - let alice_final_xmr_balance = alice_node - .monero_wallet - .0 - .get_balance_alice() - .await - .unwrap(); + let alice_final_xmr_balance = alice_node.monero_wallet.get_balance().await.unwrap(); - bob_node - .monero_wallet - .0 - .wait_for_bob_wallet_block_height() - .await - .unwrap(); + monero.wait_for_bob_wallet_block_height().await.unwrap(); - let bob_final_xmr_balance = bob_node.monero_wallet.0.get_balance_bob().await.unwrap(); + let bob_final_xmr_balance = bob_node.monero_wallet.get_balance().await.unwrap(); assert_eq!( alice_final_btc_balance, @@ -240,13 +93,11 @@ mod tests { assert_eq!( alice_final_xmr_balance, - initial_balances.alice_xmr - - u64::from(swap_amounts.xmr) - - u64::from(alice_state6.lock_xmr_fee()) + initial_balances.alice_xmr - swap_amounts.xmr - alice_state6.lock_xmr_fee() ); assert_eq!( bob_final_xmr_balance, - initial_balances.bob_xmr + u64::from(swap_amounts.xmr) + initial_balances.bob_xmr + swap_amounts.xmr ); } @@ -257,7 +108,7 @@ mod tests { .set_default(); let cli = Cli::default(); - let monero = Monero::new(&cli); + let (monero, _container) = Monero::new(&cli); let bitcoind = init_bitcoind(&cli).await; let ( @@ -267,7 +118,7 @@ mod tests { mut bob_node, initial_balances, swap_amounts, - ) = init_test(&monero, &bitcoind).await; + ) = init_test(&monero, &bitcoind, None, None).await; let (alice_state, bob_state) = future::try_join( run_alice_until( @@ -309,19 +160,9 @@ mod tests { .await .unwrap(); - alice_node - .monero_wallet - .0 - .wait_for_alice_wallet_block_height() - .await - .unwrap(); - let alice_final_xmr_balance = alice_node - .monero_wallet - .0 - .get_balance_alice() - .await - .unwrap(); - let bob_final_xmr_balance = bob_node.monero_wallet.0.get_balance_bob().await.unwrap(); + monero.wait_for_alice_wallet_block_height().await.unwrap(); + let alice_final_xmr_balance = alice_node.monero_wallet.get_balance().await.unwrap(); + let bob_final_xmr_balance = bob_node.monero_wallet.get_balance().await.unwrap(); assert_eq!(alice_final_btc_balance, initial_balances.alice_btc); assert_eq!( @@ -332,7 +173,7 @@ mod tests { // Because we create a new wallet when claiming Monero, we can only assert on // this new wallet owning all of `xmr_amount` after refund - assert_eq!(alice_final_xmr_balance, u64::from(swap_amounts.xmr)); + assert_eq!(alice_final_xmr_balance, swap_amounts.xmr); assert_eq!(bob_final_xmr_balance, initial_balances.bob_xmr); } @@ -343,7 +184,7 @@ mod tests { .set_default(); let cli = Cli::default(); - let monero = Monero::new(&cli); + let (monero, _container) = Monero::new(&cli); let bitcoind = init_bitcoind(&cli).await; let ( @@ -353,7 +194,7 @@ mod tests { mut bob_node, initial_balances, swap_amounts, - ) = init_test(&monero, &bitcoind).await; + ) = init_test(&monero, &bitcoind, None, None).await; let (alice_state, bob_state) = future::try_join( run_alice_until( @@ -400,4 +241,116 @@ mod tests { initial_balances.bob_btc - swap_amounts.btc - lock_tx_bitcoin_fee ); } + + #[tokio::test] + async fn recover_protocol_state_from_db() { + let _guard = tracing_subscriber::fmt() + .with_env_filter("info") + .set_default(); + + let cli = Cli::default(); + let (monero, _container) = Monero::new(&cli); + let bitcoind = init_bitcoind(&cli).await; + let alice_db = harness::storage::Database::open(Path::new(ALICE_TEST_DB_FOLDER)).unwrap(); + let bob_db = harness::storage::Database::open(Path::new(BOB_TEST_DB_FOLDER)).unwrap(); + + let ( + alice_state0, + bob_state0, + mut alice_node, + mut bob_node, + initial_balances, + swap_amounts, + ) = init_test(&monero, &bitcoind, None, None).await; + + { + let (alice_state, bob_state) = future::try_join( + run_alice_until( + &mut alice_node, + alice_state0.into(), + harness::alice::is_state5, + &mut OsRng, + ), + run_bob_until( + &mut bob_node, + bob_state0.into(), + harness::bob::is_state3, + &mut OsRng, + ), + ) + .await + .unwrap(); + + let alice_state5: alice::State5 = alice_state.try_into().unwrap(); + let bob_state3: bob::State3 = bob_state.try_into().unwrap(); + + // save state to db + alice_db.insert_latest_state(&alice_state5).await.unwrap(); + bob_db.insert_latest_state(&bob_state3).await.unwrap(); + }; + + let (alice_state6, bob_state5) = { + // recover state from db + let alice_state5: alice::State5 = alice_db.get_latest_state().unwrap(); + let bob_state3: bob::State3 = bob_db.get_latest_state().unwrap(); + + let (alice_state, bob_state) = future::try_join( + run_alice_until( + &mut alice_node, + alice_state5.into(), + harness::alice::is_state6, + &mut OsRng, + ), + run_bob_until( + &mut bob_node, + bob_state3.into(), + harness::bob::is_state5, + &mut OsRng, + ), + ) + .await + .unwrap(); + + let alice_state: alice::State6 = alice_state.try_into().unwrap(); + let bob_state: bob::State5 = bob_state.try_into().unwrap(); + + (alice_state, bob_state) + }; + + let alice_final_btc_balance = alice_node.bitcoin_wallet.balance().await.unwrap(); + let bob_final_btc_balance = bob_node.bitcoin_wallet.balance().await.unwrap(); + + let lock_tx_bitcoin_fee = bob_node + .bitcoin_wallet + .transaction_fee(bob_state5.tx_lock_id()) + .await + .unwrap(); + + let alice_final_xmr_balance = alice_node.monero_wallet.0.get_balance(0).await.unwrap(); + + monero.wait_for_bob_wallet_block_height().await.unwrap(); + + let bob_final_xmr_balance = bob_node.monero_wallet.0.get_balance(0).await.unwrap(); + + assert_eq!( + alice_final_btc_balance, + initial_balances.alice_btc + swap_amounts.btc + - bitcoin::Amount::from_sat(bitcoin::TX_FEE) + ); + assert_eq!( + bob_final_btc_balance, + initial_balances.bob_btc - swap_amounts.btc - lock_tx_bitcoin_fee + ); + + assert_eq!( + alice_final_xmr_balance, + initial_balances.alice_xmr.as_piconero() + - swap_amounts.xmr.as_piconero() + - alice_state6.lock_xmr_fee().as_piconero() + ); + assert_eq!( + bob_final_xmr_balance, + initial_balances.bob_xmr.as_piconero() + swap_amounts.xmr.as_piconero() + ); + } } diff --git a/xmr-btc/tests/harness/mod.rs b/xmr-btc/tests/harness/mod.rs index 4f2cba46..022e8715 100644 --- a/xmr-btc/tests/harness/mod.rs +++ b/xmr-btc/tests/harness/mod.rs @@ -1,10 +1,15 @@ pub mod node; +pub mod storage; pub mod transport; pub mod wallet; pub mod bob { use xmr_btc::bob::State; + pub fn is_state2(state: &State) -> bool { + matches!(state, State::State2 { .. }) + } + // TODO: use macro or generics pub fn is_state5(state: &State) -> bool { matches!(state, State::State5 { .. }) @@ -19,6 +24,10 @@ pub mod bob { pub mod alice { use xmr_btc::alice::State; + pub fn is_state3(state: &State) -> bool { + matches!(state, State::State3 { .. }) + } + // TODO: use macro or generics pub fn is_state4(state: &State) -> bool { matches!(state, State::State4 { .. }) @@ -34,3 +43,150 @@ pub mod alice { matches!(state, State::State6 { .. }) } } + +use bitcoin_harness::Bitcoind; +use monero_harness::Monero; +use node::{AliceNode, BobNode}; +use rand::rngs::OsRng; +use testcontainers::clients::Cli; +use tokio::sync::{ + mpsc, + mpsc::{Receiver, Sender}, +}; +use transport::Transport; +use xmr_btc::{bitcoin, monero}; + +const TEN_XMR: u64 = 10_000_000_000_000; +const RELATIVE_REFUND_TIMELOCK: u32 = 1; +const RELATIVE_PUNISH_TIMELOCK: u32 = 1; +pub const ALICE_TEST_DB_FOLDER: &str = "../target/e2e-test-alice-recover"; +pub const BOB_TEST_DB_FOLDER: &str = "../target/e2e-test-bob-recover"; + +pub async fn init_bitcoind(tc_client: &Cli) -> Bitcoind<'_> { + let bitcoind = Bitcoind::new(tc_client, "0.19.1").expect("failed to create bitcoind"); + let _ = bitcoind.init(5).await; + + bitcoind +} + +pub struct InitialBalances { + pub alice_xmr: monero::Amount, + pub alice_btc: bitcoin::Amount, + pub bob_xmr: monero::Amount, + pub bob_btc: bitcoin::Amount, +} + +pub struct SwapAmounts { + pub xmr: monero::Amount, + pub btc: bitcoin::Amount, +} + +pub fn init_alice_and_bob_transports() -> ( + Transport, + Transport, +) { + let (a_sender, b_receiver): ( + Sender, + Receiver, + ) = mpsc::channel(5); + let (b_sender, a_receiver): ( + Sender, + Receiver, + ) = mpsc::channel(5); + + let a_transport = Transport { + sender: a_sender, + receiver: a_receiver, + }; + + let b_transport = Transport { + sender: b_sender, + receiver: b_receiver, + }; + + (a_transport, b_transport) +} + +pub async fn init_test( + monero: &Monero, + bitcoind: &Bitcoind<'_>, + refund_timelock: Option, + punish_timelock: Option, +) -> ( + xmr_btc::alice::State0, + xmr_btc::bob::State0, + AliceNode, + BobNode, + InitialBalances, + SwapAmounts, +) { + // must be bigger than our hardcoded fee of 10_000 + let btc_amount = bitcoin::Amount::from_sat(10_000_000); + let xmr_amount = monero::Amount::from_piconero(1_000_000_000_000); + + let swap_amounts = SwapAmounts { + xmr: xmr_amount, + btc: btc_amount, + }; + + let fund_alice = TEN_XMR; + let fund_bob = 0; + monero.init(fund_alice, fund_bob).await.unwrap(); + + let alice_monero_wallet = wallet::monero::Wallet(monero.alice_wallet_rpc_client()); + let bob_monero_wallet = wallet::monero::Wallet(monero.bob_wallet_rpc_client()); + + let alice_btc_wallet = wallet::bitcoin::Wallet::new("alice", &bitcoind.node_url) + .await + .unwrap(); + let bob_btc_wallet = wallet::bitcoin::make_wallet("bob", &bitcoind, btc_amount) + .await + .unwrap(); + + let (alice_transport, bob_transport) = init_alice_and_bob_transports(); + let alice = AliceNode::new(alice_transport, alice_btc_wallet, alice_monero_wallet); + + let bob = BobNode::new(bob_transport, bob_btc_wallet, bob_monero_wallet); + + let alice_initial_btc_balance = alice.bitcoin_wallet.balance().await.unwrap(); + let bob_initial_btc_balance = bob.bitcoin_wallet.balance().await.unwrap(); + + let alice_initial_xmr_balance = alice.monero_wallet.get_balance().await.unwrap(); + let bob_initial_xmr_balance = bob.monero_wallet.get_balance().await.unwrap(); + + let redeem_address = alice.bitcoin_wallet.new_address().await.unwrap(); + let punish_address = redeem_address.clone(); + let refund_address = bob.bitcoin_wallet.new_address().await.unwrap(); + + let alice_state0 = xmr_btc::alice::State0::new( + &mut OsRng, + btc_amount, + xmr_amount, + refund_timelock.unwrap_or(RELATIVE_REFUND_TIMELOCK), + punish_timelock.unwrap_or(RELATIVE_PUNISH_TIMELOCK), + redeem_address.clone(), + punish_address.clone(), + ); + let bob_state0 = xmr_btc::bob::State0::new( + &mut OsRng, + btc_amount, + xmr_amount, + refund_timelock.unwrap_or(RELATIVE_REFUND_TIMELOCK), + punish_timelock.unwrap_or(RELATIVE_PUNISH_TIMELOCK), + refund_address, + ); + let initial_balances = InitialBalances { + alice_xmr: alice_initial_xmr_balance, + alice_btc: alice_initial_btc_balance, + bob_xmr: bob_initial_xmr_balance, + bob_btc: bob_initial_btc_balance, + }; + ( + alice_state0, + bob_state0, + alice, + bob, + initial_balances, + swap_amounts, + ) +} diff --git a/xmr-btc/tests/harness/node.rs b/xmr-btc/tests/harness/node.rs index 88ee0a6a..7b4bf207 100644 --- a/xmr-btc/tests/harness/node.rs +++ b/xmr-btc/tests/harness/node.rs @@ -5,18 +5,18 @@ use xmr_btc::{alice, bob}; // TODO: merge this with bob node // This struct is responsible for I/O -pub struct AliceNode<'a> { +pub struct AliceNode { transport: Transport, pub bitcoin_wallet: wallet::bitcoin::Wallet, - pub monero_wallet: wallet::monero::AliceWallet<'a>, + pub monero_wallet: wallet::monero::Wallet, } -impl<'a> AliceNode<'a> { +impl AliceNode { pub fn new( transport: Transport, bitcoin_wallet: wallet::bitcoin::Wallet, - monero_wallet: wallet::monero::AliceWallet<'a>, - ) -> AliceNode<'a> { + monero_wallet: wallet::monero::Wallet, + ) -> AliceNode { Self { transport, bitcoin_wallet, @@ -25,8 +25,8 @@ impl<'a> AliceNode<'a> { } } -pub async fn run_alice_until<'a, R: RngCore + CryptoRng>( - alice: &mut AliceNode<'a>, +pub async fn run_alice_until( + alice: &mut AliceNode, initial_state: alice::State, is_state: fn(&alice::State) -> bool, rng: &mut R, @@ -49,18 +49,18 @@ pub async fn run_alice_until<'a, R: RngCore + CryptoRng>( // TODO: merge this with alice node // This struct is responsible for I/O -pub struct BobNode<'a> { +pub struct BobNode { transport: Transport, pub bitcoin_wallet: wallet::bitcoin::Wallet, - pub monero_wallet: wallet::monero::BobWallet<'a>, + pub monero_wallet: wallet::monero::Wallet, } -impl<'a> BobNode<'a> { +impl BobNode { pub fn new( transport: Transport, bitcoin_wallet: wallet::bitcoin::Wallet, - monero_wallet: wallet::monero::BobWallet<'a>, - ) -> BobNode<'a> { + monero_wallet: wallet::monero::Wallet, + ) -> BobNode { Self { transport, bitcoin_wallet, @@ -69,8 +69,8 @@ impl<'a> BobNode<'a> { } } -pub async fn run_bob_until<'a, R: RngCore + CryptoRng>( - bob: &mut BobNode<'a>, +pub async fn run_bob_until( + bob: &mut BobNode, initial_state: bob::State, is_state: fn(&bob::State) -> bool, rng: &mut R, diff --git a/xmr-btc/tests/harness/storage.rs b/xmr-btc/tests/harness/storage.rs new file mode 100644 index 00000000..1f2947cb --- /dev/null +++ b/xmr-btc/tests/harness/storage.rs @@ -0,0 +1,159 @@ +use anyhow::{anyhow, Context, Result}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::path::Path; + +pub struct Database { + db: sled::Db, +} + +impl Database { + const LAST_STATE_KEY: &'static str = "latest_state"; + + pub fn open(path: &Path) -> Result { + let path = path + .to_str() + .ok_or_else(|| anyhow!("The path is not utf-8 valid: {:?}", path))?; + let db = sled::open(path).with_context(|| format!("Could not open the DB at {}", path))?; + + Ok(Database { db }) + } + + pub async fn insert_latest_state(&self, state: &T) -> Result<()> + where + T: Serialize + DeserializeOwned, + { + let key = serialize(&Self::LAST_STATE_KEY)?; + let new_value = serialize(&state).context("Could not serialize new state value")?; + + let old_value = self.db.get(&key)?; + + self.db + .compare_and_swap(key, old_value, Some(new_value)) + .context("Could not write in the DB")? + .context("Stored swap somehow changed, aborting saving")?; // let _ = + + self.db + .flush_async() + .await + .map(|_| ()) + .context("Could not flush db") + } + + pub fn get_latest_state(&self) -> anyhow::Result + where + T: DeserializeOwned, + { + let key = serialize(&Self::LAST_STATE_KEY)?; + + let encoded = self + .db + .get(&key)? + .ok_or_else(|| anyhow!("State does not exist {:?}", key))?; + + let state = deserialize(&encoded).context("Could not deserialize state")?; + Ok(state) + } +} + +pub fn serialize(t: &T) -> anyhow::Result> +where + T: Serialize, +{ + Ok(serde_cbor::to_vec(t)?) +} + +pub fn deserialize(v: &[u8]) -> anyhow::Result +where + T: DeserializeOwned, +{ + Ok(serde_cbor::from_slice(&v)?) +} + +#[cfg(test)] +mod tests { + #![allow(non_snake_case)] + use super::*; + use bitcoin::SigHash; + use curve25519_dalek::scalar::Scalar; + use ecdsa_fun::fun::rand_core::OsRng; + use std::str::FromStr; + use xmr_btc::serde::{ + bitcoin_amount, cross_curve_dleq_scalar, ecdsa_fun_signature, monero_private_key, + }; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + pub struct TestState { + A: xmr_btc::bitcoin::PublicKey, + a: xmr_btc::bitcoin::SecretKey, + #[serde(with = "cross_curve_dleq_scalar")] + 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_amount")] + btc: ::bitcoin::Amount, + xmr: xmr_btc::monero::Amount, + refund_timelock: u32, + refund_address: ::bitcoin::Address, + transaction: ::bitcoin::Transaction, + #[serde(with = "ecdsa_fun_signature")] + tx_punish_sig: xmr_btc::bitcoin::Signature, + } + + #[tokio::test] + async fn recover_state_from_db() { + let db = Database::open(Path::new("../target/test_recover.db")).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(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) + .await + .expect("Failed to save state the first time"); + let recovered: TestState = db + .get_latest_state() + .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) + .await + .expect("Failed to save state the second time"); + let recovered: TestState = db + .get_latest_state() + .expect("Failed to recover state the second time"); + + assert_eq!(state, recovered); + } +} diff --git a/xmr-btc/tests/harness/wallet/bitcoin.rs b/xmr-btc/tests/harness/wallet/bitcoin.rs index 524baaa4..c39ba3f7 100644 --- a/xmr-btc/tests/harness/wallet/bitcoin.rs +++ b/xmr-btc/tests/harness/wallet/bitcoin.rs @@ -1,12 +1,16 @@ use anyhow::Result; use async_trait::async_trait; +use backoff::{backoff::Constant as ConstantBackoff, future::FutureOperation as _}; use bitcoin::{util::psbt::PartiallySignedTransaction, Address, Amount, Transaction, Txid}; use bitcoin_harness::{bitcoind_rpc::PsbtBase64, Bitcoind}; use reqwest::Url; use std::time::Duration; use tokio::time; -use xmr_btc::bitcoin::{ - BroadcastSignedTransaction, BuildTxLockPsbt, SignTxLock, TxLock, WatchForRawTransaction, +use xmr_btc::{ + bitcoin::{ + BroadcastSignedTransaction, BuildTxLockPsbt, SignTxLock, TxLock, WatchForRawTransaction, + }, + BlockHeight, TransactionBlockHeight, }; #[derive(Debug)] @@ -108,12 +112,47 @@ impl BroadcastSignedTransaction for Wallet { #[async_trait] impl WatchForRawTransaction for Wallet { - async fn watch_for_raw_transaction(&self, txid: Txid) -> Result { - loop { - if let Ok(tx) = self.0.get_raw_transaction(txid).await { - return Ok(tx); - } - time::delay_for(Duration::from_millis(200)).await; + async fn watch_for_raw_transaction(&self, txid: Txid) -> Transaction { + (|| async { Ok(self.0.get_raw_transaction(txid).await?) }) + .retry(ConstantBackoff::new(Duration::from_secs(1))) + .await + .expect("transient errors to be retried") + } +} + +#[async_trait] +impl BlockHeight for Wallet { + async fn block_height(&self) -> u32 { + (|| async { Ok(self.0.block_height().await?) }) + .retry(ConstantBackoff::new(Duration::from_secs(1))) + .await + .expect("transient errors to be retried") + } +} + +#[async_trait] +impl TransactionBlockHeight for Wallet { + async fn transaction_block_height(&self, txid: Txid) -> u32 { + #[derive(Debug)] + enum Error { + Io, + NotYetMined, } + + (|| async { + let block_height = self + .0 + .transaction_block_height(txid) + .await + .map_err(|_| backoff::Error::Transient(Error::Io))?; + + let block_height = + block_height.ok_or_else(|| backoff::Error::Transient(Error::NotYetMined))?; + + Result::<_, backoff::Error>::Ok(block_height) + }) + .retry(ConstantBackoff::new(Duration::from_secs(1))) + .await + .expect("transient errors to be retried") } } diff --git a/xmr-btc/tests/harness/wallet/monero.rs b/xmr-btc/tests/harness/wallet/monero.rs index ca5e9039..3cdc8a46 100644 --- a/xmr-btc/tests/harness/wallet/monero.rs +++ b/xmr-btc/tests/harness/wallet/monero.rs @@ -1,18 +1,27 @@ -use anyhow::{bail, Result}; +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::Monero; -use std::str::FromStr; +use monero_harness::rpc::wallet; +use std::{str::FromStr, time::Duration}; use xmr_btc::monero::{ - Amount, CheckTransfer, CreateWalletForOutput, PrivateViewKey, PublicKey, PublicViewKey, - Transfer, TransferProof, TxHash, + Amount, CreateWalletForOutput, InsufficientFunds, PrivateViewKey, PublicKey, PublicViewKey, + Transfer, TransferProof, TxHash, WatchForTransfer, }; -#[derive(Debug)] -pub struct AliceWallet<'c>(pub &'c Monero<'c>); +pub struct Wallet(pub wallet::Client); + +impl Wallet { + /// Get the balance of the primary account. + pub async fn get_balance(&self) -> Result { + let amount = self.0.get_balance(0).await?; + + Ok(Amount::from_piconero(amount)) + } +} #[async_trait] -impl Transfer for AliceWallet<'_> { +impl Transfer for Wallet { async fn transfer( &self, public_spend_key: PublicKey, @@ -24,7 +33,7 @@ impl Transfer for AliceWallet<'_> { let res = self .0 - .transfer_from_alice(amount.as_piconero(), &destination_address.to_string()) + .transfer(0, amount.as_piconero(), &destination_address.to_string()) .await?; let tx_hash = TxHash(res.tx_hash); @@ -37,7 +46,7 @@ impl Transfer for AliceWallet<'_> { } #[async_trait] -impl CreateWalletForOutput for AliceWallet<'_> { +impl CreateWalletForOutput for Wallet { async fn create_and_load_wallet_for_output( &self, private_spend_key: PrivateKey, @@ -50,7 +59,6 @@ impl CreateWalletForOutput for AliceWallet<'_> { let _ = self .0 - .alice_wallet_rpc_client() .generate_from_keys( &address.to_string(), &private_spend_key.to_string(), @@ -62,63 +70,57 @@ impl CreateWalletForOutput for AliceWallet<'_> { } } -#[derive(Debug)] -pub struct BobWallet<'c>(pub &'c Monero<'c>); - #[async_trait] -impl CheckTransfer for BobWallet<'_> { - async fn check_transfer( +impl WatchForTransfer for Wallet { + async fn watch_for_transfer( &self, public_spend_key: PublicKey, public_view_key: PublicViewKey, transfer_proof: TransferProof, - amount: Amount, - ) -> Result<()> { - let address = Address::standard(Network::Mainnet, public_spend_key, public_view_key.into()); - - let cli = self.0.bob_wallet_rpc_client(); - - let res = cli - .check_tx_key( - &String::from(transfer_proof.tx_hash()), - &transfer_proof.tx_key().to_string(), - &address.to_string(), - ) - .await?; - - if res.received != u64::from(amount) { - bail!( - "tx_lock doesn't pay enough: expected {:?}, got {:?}", - res.received, - amount - ) + expected_amount: Amount, + expected_confirmations: u32, + ) -> Result<(), InsufficientFunds> { + enum Error { + TxNotFound, + InsufficientConfirmations, + InsufficientFunds { expected: Amount, actual: Amount }, } - Ok(()) - } -} - -#[async_trait] -impl CreateWalletForOutput for BobWallet<'_> { - async fn create_and_load_wallet_for_output( - &self, - private_spend_key: PrivateKey, - private_view_key: PrivateViewKey, - ) -> Result<()> { - let public_spend_key = PublicKey::from_private_key(&private_spend_key); - let public_view_key = PublicKey::from_private_key(&private_view_key.into()); - - let address = Address::standard(Network::Mainnet, public_spend_key, public_view_key); + let address = Address::standard(Network::Mainnet, public_spend_key, public_view_key.into()); - let _ = self - .0 - .bob_wallet_rpc_client() - .generate_from_keys( - &address.to_string(), - &private_spend_key.to_string(), - &PrivateKey::from(private_view_key).to_string(), - ) - .await?; + let res = (|| async { + // NOTE: Currently, this is conflating IO errors with the transaction not being + // in the blockchain yet, or not having enough confirmations on it. All these + // errors warrant a retry, but the strategy should probably differ per case + let proof = self + .0 + .check_tx_key( + &String::from(transfer_proof.tx_hash()), + &transfer_proof.tx_key().to_string(), + &address.to_string(), + ) + .await + .map_err(|_| backoff::Error::Transient(Error::TxNotFound))?; + + if proof.received != expected_amount.as_piconero() { + return Err(backoff::Error::Permanent(Error::InsufficientFunds { + expected: expected_amount, + actual: Amount::from_piconero(proof.received), + })); + } + + if proof.confirmations < expected_confirmations { + return Err(backoff::Error::Transient(Error::InsufficientConfirmations)); + } + + Ok(proof) + }) + .retry(ConstantBackoff::new(Duration::from_secs(1))) + .await; + + if let Err(Error::InsufficientFunds { expected, actual }) = res { + return Err(InsufficientFunds { expected, actual }); + }; Ok(()) } diff --git a/xmr-btc/tests/on_chain.rs b/xmr-btc/tests/on_chain.rs new file mode 100644 index 00000000..c2876fc3 --- /dev/null +++ b/xmr-btc/tests/on_chain.rs @@ -0,0 +1,251 @@ +pub mod harness; + +use std::{convert::TryInto, sync::Arc}; + +use anyhow::Result; +use async_trait::async_trait; +use futures::{ + channel::mpsc::{channel, Receiver, Sender}, + future::try_join, + SinkExt, StreamExt, +}; +use genawaiter::GeneratorState; +use harness::{ + init_bitcoind, init_test, + node::{run_alice_until, run_bob_until}, +}; +use monero_harness::Monero; +use rand::rngs::OsRng; +use testcontainers::clients::Cli; +use tracing::info; +use xmr_btc::{ + action_generator_alice, action_generator_bob, alice, + bitcoin::{BroadcastSignedTransaction, EncryptedSignature, SignTxLock}, + bob, + monero::{CreateWalletForOutput, Transfer, TransferProof}, + AliceAction, BobAction, ReceiveBitcoinRedeemEncsig, ReceiveTransferProof, +}; + +type AliceNetwork = Network; +type BobNetwork = Network; + +#[derive(Debug)] +struct Network { + // TODO: It is weird to use mpsc's in a situation where only one message is expected, but the + // ownership rules of Rust are making this painful + pub receiver: Receiver, +} + +impl Network { + pub fn new() -> (Network, Sender) { + let (sender, receiver) = channel(1); + + (Self { receiver }, sender) + } +} + +#[async_trait] +impl ReceiveTransferProof for BobNetwork { + async fn receive_transfer_proof(&mut self) -> TransferProof { + self.receiver.next().await.unwrap() + } +} + +#[async_trait] +impl ReceiveBitcoinRedeemEncsig for AliceNetwork { + async fn receive_bitcoin_redeem_encsig(&mut self) -> EncryptedSignature { + self.receiver.next().await.unwrap() + } +} + +async fn swap_as_alice( + network: AliceNetwork, + // FIXME: It would be more intuitive to have a single network/transport struct instead of + // splitting into two, but Rust ownership rules make this tedious + mut sender: Sender, + monero_wallet: &harness::wallet::monero::Wallet, + bitcoin_wallet: Arc, + state: alice::State3, +) -> Result<()> { + let mut action_generator = action_generator_alice(network, bitcoin_wallet.clone(), state); + + loop { + let state = action_generator.async_resume().await; + + info!("resumed execution of generator, got: {:?}", state); + + match state { + GeneratorState::Yielded(AliceAction::LockXmr { + amount, + public_spend_key, + public_view_key, + }) => { + let (transfer_proof, _) = monero_wallet + .transfer(public_spend_key, public_view_key, amount) + .await?; + + sender.send(transfer_proof).await.unwrap(); + } + GeneratorState::Yielded(AliceAction::RedeemBtc(tx)) + | GeneratorState::Yielded(AliceAction::CancelBtc(tx)) + | GeneratorState::Yielded(AliceAction::PunishBtc(tx)) => { + let _ = bitcoin_wallet.broadcast_signed_transaction(tx).await?; + } + GeneratorState::Yielded(AliceAction::CreateMoneroWalletForOutput { + spend_key, + view_key, + }) => { + monero_wallet + .create_and_load_wallet_for_output(spend_key, view_key) + .await?; + } + GeneratorState::Complete(()) => return Ok(()), + } + } +} + +async fn swap_as_bob( + network: BobNetwork, + mut sender: Sender, + monero_wallet: Arc, + bitcoin_wallet: Arc, + state: bob::State2, +) -> Result<()> { + let mut action_generator = action_generator_bob( + network, + monero_wallet.clone(), + bitcoin_wallet.clone(), + state, + ); + + loop { + let state = action_generator.async_resume().await; + + info!("resumed execution of generator, got: {:?}", state); + + match state { + GeneratorState::Yielded(BobAction::LockBitcoin(tx_lock)) => { + let signed_tx_lock = bitcoin_wallet.sign_tx_lock(tx_lock).await?; + let _ = bitcoin_wallet + .broadcast_signed_transaction(signed_tx_lock) + .await?; + } + GeneratorState::Yielded(BobAction::SendBitcoinRedeemEncsig(tx_redeem_encsig)) => { + sender.send(tx_redeem_encsig).await.unwrap(); + } + GeneratorState::Yielded(BobAction::CreateMoneroWalletForOutput { + spend_key, + view_key, + }) => { + monero_wallet + .create_and_load_wallet_for_output(spend_key, view_key) + .await?; + } + GeneratorState::Yielded(BobAction::CancelBitcoin(tx_cancel)) => { + let _ = bitcoin_wallet + .broadcast_signed_transaction(tx_cancel) + .await?; + } + GeneratorState::Yielded(BobAction::RefundBitcoin(tx_refund)) => { + let _ = bitcoin_wallet + .broadcast_signed_transaction(tx_refund) + .await?; + } + GeneratorState::Complete(()) => return Ok(()), + } + } +} + +// 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 on_chain_happy_path() { + let cli = Cli::default(); + let (monero, _container) = Monero::new(&cli); + let bitcoind = init_bitcoind(&cli).await; + + let (alice_state0, bob_state0, mut alice_node, mut bob_node, initial_balances, swap_amounts) = + init_test(&monero, &bitcoind, Some(100), Some(100)).await; + + // run the handshake as part of the setup + let (alice_state, bob_state) = try_join( + run_alice_until( + &mut alice_node, + alice_state0.into(), + harness::alice::is_state3, + &mut OsRng, + ), + run_bob_until( + &mut bob_node, + bob_state0.into(), + harness::bob::is_state2, + &mut OsRng, + ), + ) + .await + .unwrap(); + let alice: alice::State3 = alice_state.try_into().unwrap(); + let bob: bob::State2 = bob_state.try_into().unwrap(); + let tx_lock_txid = bob.tx_lock.txid(); + + let alice_bitcoin_wallet = Arc::new(alice_node.bitcoin_wallet); + let bob_bitcoin_wallet = Arc::new(bob_node.bitcoin_wallet); + let alice_monero_wallet = Arc::new(alice_node.monero_wallet); + let bob_monero_wallet = Arc::new(bob_node.monero_wallet); + + let (alice_network, bob_sender) = Network::::new(); + let (bob_network, alice_sender) = Network::::new(); + + try_join( + swap_as_alice( + alice_network, + alice_sender, + &alice_monero_wallet.clone(), + alice_bitcoin_wallet.clone(), + alice, + ), + swap_as_bob( + bob_network, + bob_sender, + bob_monero_wallet.clone(), + bob_bitcoin_wallet.clone(), + bob, + ), + ) + .await + .unwrap(); + + let alice_final_btc_balance = alice_bitcoin_wallet.balance().await.unwrap(); + let bob_final_btc_balance = bob_bitcoin_wallet.balance().await.unwrap(); + + let lock_tx_bitcoin_fee = bob_bitcoin_wallet + .transaction_fee(tx_lock_txid) + .await + .unwrap(); + + let alice_final_xmr_balance = alice_monero_wallet.get_balance().await.unwrap(); + + monero.wait_for_bob_wallet_block_height().await.unwrap(); + let bob_final_xmr_balance = bob_monero_wallet.get_balance().await.unwrap(); + + assert_eq!( + alice_final_btc_balance, + initial_balances.alice_btc + swap_amounts.btc + - bitcoin::Amount::from_sat(xmr_btc::bitcoin::TX_FEE) + ); + assert_eq!( + bob_final_btc_balance, + initial_balances.bob_btc - swap_amounts.btc - lock_tx_bitcoin_fee + ); + + // Getting the Monero LockTx fee is tricky in a clean way, I think checking this + // condition is sufficient + assert!(alice_final_xmr_balance <= initial_balances.alice_xmr - swap_amounts.xmr,); + assert_eq!( + bob_final_xmr_balance, + initial_balances.bob_xmr + swap_amounts.xmr + ); +}