diff --git a/swap/src/bin/swap_cli.rs b/swap/src/bin/swap_cli.rs index b5e6be94..96fb9e6f 100644 --- a/swap/src/bin/swap_cli.rs +++ b/swap/src/bin/swap_cli.rs @@ -15,6 +15,8 @@ use anyhow::{bail, Context, Result}; use prettytable::{row, Table}; use reqwest::Url; +use std::cmp::min; +use std::future::Future; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -24,6 +26,7 @@ use swap::cli::command::{Arguments, Command}; use swap::cli::config::{read_config, Config}; use swap::database::Database; use swap::execution_params::GetExecutionParams; +use swap::network::quote::BidQuote; use swap::protocol::bob; use swap::protocol::bob::cancel::CancelError; use swap::protocol::bob::{Builder, EventLoop}; @@ -115,33 +118,7 @@ async fn main() -> Result<()> { let monero_wallet = init_monero_wallet(monero_network, monero_wallet_rpc_process.endpoint()).await?; let bitcoin_wallet = Arc::new(bitcoin_wallet); - - let swap_id = Uuid::new_v4(); - - // TODO: Also wait for more funds if balance < dust - if bitcoin_wallet.balance().await? == Amount::ZERO { - info!( - "Please deposit BTC to {}", - bitcoin_wallet.new_address().await? - ); - - while bitcoin_wallet.balance().await? == Amount::ZERO { - bitcoin_wallet.sync_wallet().await?; - - tokio::time::sleep(Duration::from_secs(1)).await; - } - - debug!("Received {}", bitcoin_wallet.balance().await?); - } else { - info!( - "Still got {} left in wallet, swapping ...", - bitcoin_wallet.balance().await? - ); - } - - let send_bitcoin = bitcoin_wallet.max_giveable(TxLock::script_size()).await?; - - let (event_loop, event_loop_handle) = EventLoop::new( + let (event_loop, mut event_loop_handle) = EventLoop::new( &seed.derive_libp2p_identity(), alice_peer_id, alice_addr, @@ -149,9 +126,26 @@ async fn main() -> Result<()> { )?; let handle = tokio::spawn(event_loop.run()); + let send_bitcoin = determine_btc_to_swap( + event_loop_handle.request_quote(), + bitcoin_wallet.balance(), + bitcoin_wallet.new_address(), + async { + while bitcoin_wallet.balance().await? == Amount::ZERO { + bitcoin_wallet.sync_wallet().await?; + + tokio::time::sleep(Duration::from_secs(1)).await; + } + + bitcoin_wallet.balance().await + }, + bitcoin_wallet.max_giveable(TxLock::script_size()), + ) + .await?; + let swap = Builder::new( db, - swap_id, + Uuid::new_v4(), bitcoin_wallet.clone(), Arc::new(monero_wallet), execution_params, @@ -311,3 +305,133 @@ async fn init_monero_wallet( Ok(monero_wallet) } + +async fn determine_btc_to_swap( + request_quote: impl Future>, + initial_balance: impl Future>, + get_new_address: impl Future>, + wait_for_deposit: impl Future>, + max_giveable: impl Future>, +) -> Result { + debug!("Requesting quote"); + + let bid_quote = request_quote.await.context("failed to request quote")?; + + info!("Received quote: 1 XMR ~ {}", bid_quote.price); + + // TODO: Also wait for more funds if balance < dust + let initial_balance = initial_balance.await?; + + if initial_balance == Amount::ZERO { + info!( + "Please deposit the BTC you want to swap to {} (max {})", + get_new_address.await?, + bid_quote.max_quantity + ); + + let new_balance = wait_for_deposit.await?; + + info!("Received {}", new_balance); + } else { + info!("Found {} in wallet", initial_balance); + } + + let max_giveable = max_giveable.await?; + let max_accepted = bid_quote.max_quantity; + + if max_giveable > max_accepted { + info!( + "Max giveable amount {} exceeds max accepted amount {}!", + max_giveable, max_accepted + ); + } + + Ok(min(max_giveable, max_accepted)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::determine_btc_to_swap; + use ::bitcoin::Amount; + use tracing::subscriber; + + #[tokio::test] + async fn given_no_balance_and_transfers_less_than_max_swaps_max_giveable() { + let _guard = subscriber::set_default(tracing_subscriber::fmt().with_test_writer().finish()); + + let amount = determine_btc_to_swap( + async { Ok(quote_with_max(0.01)) }, + async { Ok(Amount::ZERO) }, + get_dummy_address(), + async { Ok(Amount::from_btc(0.0001)?) }, + async { Ok(Amount::from_btc(0.00009)?) }, + ) + .await + .unwrap(); + + assert_eq!(amount, Amount::from_btc(0.00009).unwrap()) + } + + #[tokio::test] + async fn given_no_balance_and_transfers_more_then_swaps_max_quantity_from_quote() { + let _guard = subscriber::set_default(tracing_subscriber::fmt().with_test_writer().finish()); + + let amount = determine_btc_to_swap( + async { Ok(quote_with_max(0.01)) }, + async { Ok(Amount::ZERO) }, + get_dummy_address(), + async { Ok(Amount::from_btc(0.1)?) }, + async { Ok(Amount::from_btc(0.09)?) }, + ) + .await + .unwrap(); + + assert_eq!(amount, Amount::from_btc(0.01).unwrap()) + } + + #[tokio::test] + async fn given_initial_balance_below_max_quantity_swaps_max_givable() { + let _guard = subscriber::set_default(tracing_subscriber::fmt().with_test_writer().finish()); + + let amount = determine_btc_to_swap( + async { Ok(quote_with_max(0.01)) }, + async { Ok(Amount::from_btc(0.005)?) }, + async { panic!("should not request new address when initial balance is > 0") }, + async { panic!("should not wait for deposit when initial balance > 0") }, + async { Ok(Amount::from_btc(0.0049)?) }, + ) + .await + .unwrap(); + + assert_eq!(amount, Amount::from_btc(0.0049).unwrap()) + } + + #[tokio::test] + async fn given_initial_balance_above_max_quantity_swaps_max_quantity() { + let _guard = subscriber::set_default(tracing_subscriber::fmt().with_test_writer().finish()); + + let amount = determine_btc_to_swap( + async { Ok(quote_with_max(0.01)) }, + async { Ok(Amount::from_btc(0.1)?) }, + async { panic!("should not request new address when initial balance is > 0") }, + async { panic!("should not wait for deposit when initial balance > 0") }, + async { Ok(Amount::from_btc(0.09)?) }, + ) + .await + .unwrap(); + + assert_eq!(amount, Amount::from_btc(0.01).unwrap()) + } + + fn quote_with_max(btc: f64) -> BidQuote { + BidQuote { + price: Amount::from_btc(0.001).unwrap(), + max_quantity: Amount::from_btc(btc).unwrap(), + } + } + + async fn get_dummy_address() -> Result { + Ok("1PdfytjS7C8wwd9Lq5o4x9aXA2YRqaCpH6".parse()?) + } +} diff --git a/swap/src/lib.rs b/swap/src/lib.rs index cf19d91c..f484da3b 100644 --- a/swap/src/lib.rs +++ b/swap/src/lib.rs @@ -23,10 +23,10 @@ pub mod database; pub mod execution_params; pub mod fs; pub mod monero; +pub mod network; pub mod protocol; pub mod seed; pub mod trace; mod monero_ext; -mod network; mod serde_peer_id; diff --git a/swap/src/network.rs b/swap/src/network.rs index 67f39f0e..7a82065b 100644 --- a/swap/src/network.rs +++ b/swap/src/network.rs @@ -1,4 +1,5 @@ pub mod peer_tracker; +pub mod quote; pub mod request_response; pub mod spot_price; pub mod transport; diff --git a/swap/src/network/quote.rs b/swap/src/network/quote.rs new file mode 100644 index 00000000..4e536a0b --- /dev/null +++ b/swap/src/network/quote.rs @@ -0,0 +1,53 @@ +use crate::bitcoin; +use crate::network::request_response::CborCodec; +use libp2p::core::ProtocolName; +use libp2p::request_response::{ + ProtocolSupport, RequestResponse, RequestResponseConfig, RequestResponseEvent, +}; +use serde::{Deserialize, Serialize}; + +pub type OutEvent = RequestResponseEvent<(), BidQuote>; + +#[derive(Debug, Clone, Copy, Default)] +pub struct BidQuoteProtocol; + +impl ProtocolName for BidQuoteProtocol { + fn protocol_name(&self) -> &[u8] { + b"/comit/xmr/btc/bid-quote/1.0.0" + } +} + +/// Represents a quote for buying XMR. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct BidQuote { + /// The price at which the maker is willing to buy at. + #[serde(with = "::bitcoin::util::amount::serde::as_sat")] + pub price: bitcoin::Amount, + /// The maximum quantity the maker is willing to buy. + #[serde(with = "::bitcoin::util::amount::serde::as_sat")] + pub max_quantity: bitcoin::Amount, +} + +pub type Behaviour = RequestResponse>; + +/// Constructs a new instance of the `quote` behaviour to be used by Alice. +/// +/// Alice only supports inbound connections, i.e. handing out quotes. +pub fn alice() -> Behaviour { + Behaviour::new( + CborCodec::default(), + vec![(BidQuoteProtocol, ProtocolSupport::Inbound)], + RequestResponseConfig::default(), + ) +} + +/// Constructs a new instance of the `quote` behaviour to be used by Bob. +/// +/// Bob only supports outbound connections, i.e. requesting quotes. +pub fn bob() -> Behaviour { + Behaviour::new( + CborCodec::default(), + vec![(BidQuoteProtocol, ProtocolSupport::Outbound)], + RequestResponseConfig::default(), + ) +} diff --git a/swap/src/protocol/alice/behaviour.rs b/swap/src/protocol/alice/behaviour.rs index 87b65981..bb6adf87 100644 --- a/swap/src/protocol/alice/behaviour.rs +++ b/swap/src/protocol/alice/behaviour.rs @@ -1,6 +1,6 @@ use crate::execution_params::ExecutionParams; -use crate::network::spot_price::{Request, Response}; -use crate::network::{peer_tracker, spot_price}; +use crate::network::quote::BidQuote; +use crate::network::{peer_tracker, quote, spot_price}; use crate::protocol::alice::{ encrypted_signature, execution_setup, transfer_proof, State0, State3, TransferProof, }; @@ -16,8 +16,12 @@ use tracing::debug; pub enum OutEvent { ConnectionEstablished(PeerId), SpotPriceRequested { - msg: Request, - channel: ResponseChannel, + msg: spot_price::Request, + channel: ResponseChannel, + peer: PeerId, + }, + QuoteRequested { + channel: ResponseChannel, peer: PeerId, }, ExecutionSetupDone { @@ -78,6 +82,34 @@ impl From for OutEvent { } } +impl From for OutEvent { + fn from(event: quote::OutEvent) -> Self { + match event { + quote::OutEvent::Message { + peer, + message: RequestResponseMessage::Request { channel, .. }, + } => OutEvent::QuoteRequested { channel, peer }, + quote::OutEvent::Message { + message: RequestResponseMessage::Response { .. }, + .. + } => OutEvent::Failure(anyhow!( + "Alice is only meant to hand out quotes, not receive them" + )), + quote::OutEvent::ResponseSent { .. } => OutEvent::ResponseSent, + quote::OutEvent::InboundFailure { peer, error, .. } => OutEvent::Failure(anyhow!( + "quote protocol with peer {} failed due to {:?}", + peer, + error + )), + quote::OutEvent::OutboundFailure { peer, error, .. } => OutEvent::Failure(anyhow!( + "quote protocol with peer {} failed due to {:?}", + peer, + error + )), + } + } +} + impl From for OutEvent { fn from(event: execution_setup::OutEvent) -> Self { use crate::protocol::alice::execution_setup::OutEvent::*; @@ -124,6 +156,7 @@ impl From for OutEvent { #[allow(missing_debug_implementations)] pub struct Behaviour { pt: peer_tracker::Behaviour, + quote: quote::Behaviour, spot_price: spot_price::Behaviour, execution_setup: execution_setup::Behaviour, transfer_proof: transfer_proof::Behaviour, @@ -134,6 +167,7 @@ impl Default for Behaviour { fn default() -> Self { Self { pt: Default::default(), + quote: quote::alice(), spot_price: spot_price::alice(), execution_setup: Default::default(), transfer_proof: Default::default(), @@ -143,10 +177,22 @@ impl Default for Behaviour { } impl Behaviour { + pub fn send_quote( + &mut self, + channel: ResponseChannel, + response: BidQuote, + ) -> Result<()> { + self.quote + .send_response(channel, response) + .map_err(|_| anyhow!("failed to respond with quote"))?; + + Ok(()) + } + pub fn send_spot_price( &mut self, - channel: ResponseChannel, - response: Response, + channel: ResponseChannel, + response: spot_price::Response, ) -> Result<()> { self.spot_price .send_response(channel, response) diff --git a/swap/src/protocol/alice/event_loop.rs b/swap/src/protocol/alice/event_loop.rs index 9f8cd9bc..7df924ff 100644 --- a/swap/src/protocol/alice/event_loop.rs +++ b/swap/src/protocol/alice/event_loop.rs @@ -2,8 +2,8 @@ use crate::asb::LatestRate; use crate::database::Database; use crate::execution_params::ExecutionParams; use crate::monero::BalanceTooLow; -use crate::network::spot_price::Response; -use crate::network::{transport, TokioExecutor}; +use crate::network::quote::BidQuote; +use crate::network::{spot_price, transport, TokioExecutor}; use crate::protocol::alice; use crate::protocol::alice::{AliceState, Behaviour, OutEvent, State3, Swap, TransferProof}; use crate::protocol::bob::EncryptedSignature; @@ -171,7 +171,7 @@ where } }; - match self.swarm.send_spot_price(channel, Response { xmr }) { + match self.swarm.send_spot_price(channel, spot_price::Response { xmr }) { Ok(_) => {}, Err(e) => { // if we can't respond, the peer probably just disconnected so it is not a huge deal, only log this on debug @@ -187,6 +187,24 @@ where } } } + OutEvent::QuoteRequested { channel, peer } => { + let quote = match self.make_quote(self.max_buy).await { + Ok(quote) => quote, + Err(e) => { + tracing::warn!(%peer, "failed to make quote: {:#}", e); + continue; + } + }; + + match self.swarm.send_quote(channel, quote) { + Ok(_) => {}, + Err(e) => { + // if we can't respond, the peer probably just disconnected so it is not a huge deal, only log this on debug + debug!(%peer, "failed to respond with quote: {:#}", e); + continue; + } + } + } OutEvent::ExecutionSetupDone{bob_peer_id, state3} => { let _ = self.handle_execution_setup_done(bob_peer_id, *state3).await; } @@ -245,6 +263,18 @@ where Ok(xmr) } + async fn make_quote(&mut self, max_buy: bitcoin::Amount) -> Result { + let rate = self + .rate_service + .latest_rate() + .context("Failed to get latest rate")?; + + Ok(BidQuote { + price: rate.ask, + max_quantity: max_buy, + }) + } + async fn handle_execution_setup_done( &mut self, bob_peer_id: PeerId, diff --git a/swap/src/protocol/bob.rs b/swap/src/protocol/bob.rs index afc952fc..f70eaf63 100644 --- a/swap/src/protocol/bob.rs +++ b/swap/src/protocol/bob.rs @@ -2,8 +2,10 @@ use crate::database::Database; use crate::execution_params::ExecutionParams; use crate::network::{peer_tracker, spot_price}; use crate::protocol::alice::TransferProof; +use crate::protocol::bob; use crate::{bitcoin, monero}; use anyhow::{anyhow, Error, Result}; +pub use execution_setup::{Message0, Message2, Message4}; use libp2p::core::Multiaddr; use libp2p::request_response::{RequestResponseMessage, ResponseChannel}; use libp2p::{NetworkBehaviour, PeerId}; @@ -14,10 +16,11 @@ use uuid::Uuid; pub use self::cancel::cancel; pub use self::encrypted_signature::EncryptedSignature; pub use self::event_loop::{EventLoop, EventLoopHandle}; -pub use self::execution_setup::{Message0, Message2, Message4}; pub use self::refund::refund; pub use self::state::*; pub use self::swap::{run, run_until}; +use crate::network::quote; +use crate::network::quote::BidQuote; pub mod cancel; mod encrypted_signature; @@ -30,7 +33,7 @@ mod transfer_proof; pub struct Swap { pub state: BobState, - pub event_loop_handle: EventLoopHandle, + pub event_loop_handle: bob::EventLoopHandle, pub db: Database, pub bitcoin_wallet: Arc, pub monero_wallet: Arc, @@ -89,7 +92,7 @@ impl Builder { } } - pub fn build(self) -> Result { + pub fn build(self) -> Result { let state = match self.init_params { InitParams::New { btc_amount } => BobState::Started { btc_amount }, InitParams::None => self.db.get_state(self.swap_id)?.try_into_bob()?.into(), @@ -111,6 +114,7 @@ impl Builder { #[derive(Debug)] pub enum OutEvent { ConnectionEstablished(PeerId), + QuoteReceived(BidQuote), SpotPriceReceived(spot_price::Response), ExecutionSetupDone(Result>), TransferProof { @@ -164,6 +168,38 @@ impl From for OutEvent { } } +impl From for OutEvent { + fn from(event: quote::OutEvent) -> Self { + match event { + quote::OutEvent::Message { + message: RequestResponseMessage::Response { response, .. }, + .. + } => OutEvent::QuoteReceived(response), + quote::OutEvent::Message { + message: RequestResponseMessage::Request { .. }, + .. + } => OutEvent::CommunicationError(anyhow!( + "Bob is only meant to receive quotes, not hand them out" + )), + quote::OutEvent::ResponseSent { .. } => OutEvent::ResponseSent, + quote::OutEvent::InboundFailure { peer, error, .. } => { + OutEvent::CommunicationError(anyhow!( + "quote protocol with peer {} failed due to {:?}", + peer, + error + )) + } + quote::OutEvent::OutboundFailure { peer, error, .. } => { + OutEvent::CommunicationError(anyhow!( + "quote protocol with peer {} failed due to {:?}", + peer, + error + )) + } + } + } +} + impl From for OutEvent { fn from(event: execution_setup::OutEvent) -> Self { match event { @@ -206,6 +242,7 @@ impl From for OutEvent { #[allow(missing_debug_implementations)] pub struct Behaviour { pt: peer_tracker::Behaviour, + quote: quote::Behaviour, spot_price: spot_price::Behaviour, execution_setup: execution_setup::Behaviour, transfer_proof: transfer_proof::Behaviour, @@ -216,6 +253,7 @@ impl Default for Behaviour { fn default() -> Self { Self { pt: Default::default(), + quote: quote::bob(), spot_price: spot_price::bob(), execution_setup: Default::default(), transfer_proof: Default::default(), @@ -225,6 +263,10 @@ impl Default for Behaviour { } impl Behaviour { + pub fn request_quote(&mut self, alice: PeerId) { + let _ = self.quote.send_request(&alice, ()); + } + pub fn request_spot_price(&mut self, alice: PeerId, request: spot_price::Request) { let _ = self.spot_price.send_request(&alice, request); } diff --git a/swap/src/protocol/bob/event_loop.rs b/swap/src/protocol/bob/event_loop.rs index 54f1298d..a8609936 100644 --- a/swap/src/protocol/bob/event_loop.rs +++ b/swap/src/protocol/bob/event_loop.rs @@ -1,6 +1,6 @@ use crate::bitcoin::EncryptedSignature; -use crate::network::spot_price::{Request, Response}; -use crate::network::{transport, TokioExecutor}; +use crate::network::quote::BidQuote; +use crate::network::{spot_price, transport, TokioExecutor}; use crate::protocol::alice::TransferProof; use crate::protocol::bob::{Behaviour, OutEvent, State0, State2}; use crate::{bitcoin, monero}; @@ -34,14 +34,16 @@ impl Default for Channels { #[derive(Debug)] pub struct EventLoopHandle { - recv_spot_price: Receiver, start_execution_setup: Sender, done_execution_setup: Receiver>, recv_transfer_proof: Receiver, conn_established: Receiver, dial_alice: Sender<()>, - request_spot_price: Sender, send_encrypted_signature: Sender, + request_spot_price: Sender, + recv_spot_price: Receiver, + request_quote: Sender<()>, + recv_quote: Receiver, } impl EventLoopHandle { @@ -75,7 +77,10 @@ impl EventLoopHandle { } pub async fn request_spot_price(&mut self, btc: bitcoin::Amount) -> Result { - let _ = self.request_spot_price.send(Request { btc }).await?; + let _ = self + .request_spot_price + .send(spot_price::Request { btc }) + .await?; let response = self .recv_spot_price @@ -86,6 +91,18 @@ impl EventLoopHandle { Ok(response.xmr) } + pub async fn request_quote(&mut self) -> Result { + let _ = self.request_quote.send(()).await?; + + let quote = self + .recv_quote + .recv() + .await + .ok_or_else(|| anyhow!("Failed to receive quote from Alice"))?; + + Ok(quote) + } + pub async fn send_encrypted_signature( &mut self, tx_redeem_encsig: EncryptedSignature, @@ -101,14 +118,16 @@ pub struct EventLoop { swarm: libp2p::Swarm, bitcoin_wallet: Arc, alice_peer_id: PeerId, - request_spot_price: Receiver, - recv_spot_price: Sender, + request_spot_price: Receiver, + recv_spot_price: Sender, start_execution_setup: Receiver, done_execution_setup: Sender>, recv_transfer_proof: Sender, dial_alice: Receiver<()>, conn_established: Sender, send_encrypted_signature: Receiver, + request_quote: Receiver<()>, + recv_quote: Sender, } impl EventLoop { @@ -133,38 +152,44 @@ impl EventLoop { swarm.add_address(alice_peer_id, alice_addr); - let quote_response = Channels::new(); let start_execution_setup = Channels::new(); let done_execution_setup = Channels::new(); let recv_transfer_proof = Channels::new(); let dial_alice = Channels::new(); let conn_established = Channels::new(); - let send_quote_request = Channels::new(); let send_encrypted_signature = Channels::new(); + let request_spot_price = Channels::new(); + let recv_spot_price = Channels::new(); + let request_quote = Channels::new(); + let recv_quote = Channels::new(); let event_loop = EventLoop { swarm, alice_peer_id, bitcoin_wallet, - recv_spot_price: quote_response.sender, start_execution_setup: start_execution_setup.receiver, done_execution_setup: done_execution_setup.sender, recv_transfer_proof: recv_transfer_proof.sender, conn_established: conn_established.sender, dial_alice: dial_alice.receiver, - request_spot_price: send_quote_request.receiver, send_encrypted_signature: send_encrypted_signature.receiver, + request_spot_price: request_spot_price.receiver, + recv_spot_price: recv_spot_price.sender, + request_quote: request_quote.receiver, + recv_quote: recv_quote.sender, }; let handle = EventLoopHandle { - recv_spot_price: quote_response.receiver, start_execution_setup: start_execution_setup.sender, done_execution_setup: done_execution_setup.receiver, recv_transfer_proof: recv_transfer_proof.receiver, conn_established: conn_established.receiver, dial_alice: dial_alice.sender, - request_spot_price: send_quote_request.sender, send_encrypted_signature: send_encrypted_signature.sender, + request_spot_price: request_spot_price.sender, + recv_spot_price: recv_spot_price.receiver, + request_quote: request_quote.sender, + recv_quote: recv_quote.receiver, }; Ok((event_loop, handle)) @@ -181,6 +206,9 @@ impl EventLoop { OutEvent::SpotPriceReceived(msg) => { let _ = self.recv_spot_price.send(msg).await; }, + OutEvent::QuoteReceived(msg) => { + let _ = self.recv_quote.send(msg).await; + }, OutEvent::ExecutionSetupDone(res) => { let _ = self.done_execution_setup.send(res.map(|state|*state)).await; } @@ -212,9 +240,14 @@ impl EventLoop { } } }, - quote_request = self.request_spot_price.recv().fuse() => { - if let Some(quote_request) = quote_request { - self.swarm.request_spot_price(self.alice_peer_id, quote_request); + spot_price_request = self.request_spot_price.recv().fuse() => { + if let Some(request) = spot_price_request { + self.swarm.request_spot_price(self.alice_peer_id, request); + } + }, + quote_request = self.request_quote.recv().fuse() => { + if quote_request.is_some() { + self.swarm.request_quote(self.alice_peer_id); } }, option = self.start_execution_setup.recv().fuse() => {