diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee491b2..37152d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Resume-only mode for the ASB. + When started with `--resume-only` the ASB does not accept new, incoming swap requests but only finishes swaps that are resumed upon startup. + ### Fixed - An issue where both the ASB and the CLI point to the same default directory `xmr-btc-swap` for storing data. @@ -15,6 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 If you want to access data created by a previous version you will have to rename the data folder or one of the following: 1. For the CLI you can use `--data-dir` to point to the old directory. 2. For the ASB you can change the data-dir in the config file of the ASB. +- The CLI receives proper Error messages if setting up a swap with the ASB fails. + This is a breaking change because the spot-price protocol response changed. + Expected errors scenarios that are now reported back to the CLI: + 1. Balance of ASB too low + 2. Buy amount sent by CLI exceeds maximum buy amount accepted by ASB + 3. ASB is running in resume-only mode and does not accept incoming swap requests ## [0.5.0] - 2021-04-17 diff --git a/swap/src/asb/command.rs b/swap/src/asb/command.rs index 298f5570..85c82d1f 100644 --- a/swap/src/asb/command.rs +++ b/swap/src/asb/command.rs @@ -34,6 +34,12 @@ pub enum Command { default_value = "0.02" )] ask_spread: Decimal, + + #[structopt( + long = "resume-only", + help = "For maintenance only. When set, no new swap requests will be accepted, but existing unfinished swaps will be resumed." + )] + resume_only: bool, }, History, WithdrawBtc { diff --git a/swap/src/bin/asb.rs b/swap/src/bin/asb.rs index 4d99aadb..5d099055 100644 --- a/swap/src/bin/asb.rs +++ b/swap/src/bin/asb.rs @@ -81,6 +81,7 @@ async fn main() -> Result<()> { Command::Start { max_buy, ask_spread, + resume_only, } => { let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; let monero_wallet = init_monero_wallet(&config, env_config).await?; @@ -118,7 +119,17 @@ async fn main() -> Result<()> { } }; - let mut swarm = swarm::alice(&seed)?; + let current_balance = monero_wallet.get_balance().await?; + let lock_fee = monero_wallet.static_tx_fee_estimate(); + let kraken_rate = KrakenRate::new(ask_spread, kraken_price_updates); + let mut swarm = swarm::alice( + &seed, + current_balance, + lock_fee, + max_buy, + kraken_rate.clone(), + resume_only, + )?; for listen in config.network.listen { Swarm::listen_on(&mut swarm, listen.clone()) @@ -131,7 +142,7 @@ async fn main() -> Result<()> { Arc::new(bitcoin_wallet), Arc::new(monero_wallet), Arc::new(db), - KrakenRate::new(ask_spread, kraken_price_updates), + kraken_rate, max_buy, ) .unwrap(); diff --git a/swap/src/monero.rs b/swap/src/monero.rs index c52bd0b5..6886cbf1 100644 --- a/swap/src/monero.rs +++ b/swap/src/monero.rs @@ -192,12 +192,6 @@ pub struct InsufficientFunds { pub actual: Amount, } -#[derive(Debug, Clone, Copy, thiserror::Error)] -#[error("The balance is too low, current balance: {balance}")] -pub struct BalanceTooLow { - pub balance: Amount, -} - #[derive(thiserror::Error, Debug, Clone, PartialEq)] #[error("Overflow, cannot convert {0} to u64")] pub struct OverflowError(pub String); diff --git a/swap/src/network.rs b/swap/src/network.rs index 59b2b46f..fb4606fb 100644 --- a/swap/src/network.rs +++ b/swap/src/network.rs @@ -10,3 +10,6 @@ pub mod swarm; pub mod tor_transport; pub mod transfer_proof; pub mod transport; + +#[cfg(any(test, feature = "test"))] +pub mod test; diff --git a/swap/src/network/spot_price.rs b/swap/src/network/spot_price.rs index 560f8b17..e0c5c29b 100644 --- a/swap/src/network/spot_price.rs +++ b/swap/src/network/spot_price.rs @@ -1,17 +1,12 @@ +use crate::monero; use crate::network::cbor_request_response::CborCodec; -use crate::protocol::{alice, bob}; -use crate::{bitcoin, monero}; use libp2p::core::ProtocolName; -use libp2p::request_response::{ - ProtocolSupport, RequestResponse, RequestResponseConfig, RequestResponseEvent, - RequestResponseMessage, -}; -use libp2p::PeerId; +use libp2p::request_response::{RequestResponse, RequestResponseEvent, RequestResponseMessage}; use serde::{Deserialize, Serialize}; -const PROTOCOL: &str = "/comit/xmr/btc/spot-price/1.0.0"; -type OutEvent = RequestResponseEvent; -type Message = RequestResponseMessage; +pub const PROTOCOL: &str = "/comit/xmr/btc/spot-price/1.0.0"; +pub type OutEvent = RequestResponseEvent; +pub type Message = RequestResponseMessage; pub type Behaviour = RequestResponse>; @@ -40,62 +35,62 @@ pub struct Request { } #[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Response { - pub xmr: monero::Amount, +pub enum Response { + Xmr(monero::Amount), + Error(Error), } -/// Constructs a new instance of the `spot-price` behaviour to be used by Alice. -/// -/// Alice only supports inbound connections, i.e. providing spot prices for BTC -/// in XMR. -pub fn alice() -> Behaviour { - Behaviour::new( - CborCodec::default(), - vec![(SpotPriceProtocol, ProtocolSupport::Inbound)], - RequestResponseConfig::default(), - ) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Error { + NoSwapsAccepted, + MaxBuyAmountExceeded { + #[serde(with = "::bitcoin::util::amount::serde::as_sat")] + max: bitcoin::Amount, + #[serde(with = "::bitcoin::util::amount::serde::as_sat")] + buy: bitcoin::Amount, + }, + BalanceTooLow { + #[serde(with = "::bitcoin::util::amount::serde::as_sat")] + buy: bitcoin::Amount, + }, + /// To be used for errors that cannot be explained on the CLI side (e.g. + /// rate update problems on the seller side) + Other, } -/// Constructs a new instance of the `spot-price` behaviour to be used by Bob. -/// -/// Bob only supports outbound connections, i.e. requesting a spot price for a -/// given amount of BTC in XMR. -pub fn bob() -> Behaviour { - Behaviour::new( - CborCodec::default(), - vec![(SpotPriceProtocol, ProtocolSupport::Outbound)], - RequestResponseConfig::default(), - ) -} +#[cfg(test)] +mod tests { + use super::*; + use crate::monero; -impl From<(PeerId, Message)> for alice::OutEvent { - fn from((peer, message): (PeerId, Message)) -> Self { - match message { - Message::Request { - request, channel, .. - } => Self::SpotPriceRequested { - request, - channel, - peer, - }, - Message::Response { .. } => Self::unexpected_response(peer), - } + #[test] + fn snapshot_test_serialize() { + let amount = monero::Amount::from_piconero(100_000u64); + let xmr = r#"{"Xmr":100000}"#.to_string(); + let serialized = serde_json::to_string(&Response::Xmr(amount)).unwrap(); + assert_eq!(xmr, serialized); + + let error = r#"{"Error":"NoSwapsAccepted"}"#.to_string(); + let serialized = serde_json::to_string(&Response::Error(Error::NoSwapsAccepted)).unwrap(); + assert_eq!(error, serialized); + + let error = r#"{"Error":{"MaxBuyAmountExceeded":{"max":0,"buy":0}}}"#.to_string(); + let serialized = serde_json::to_string(&Response::Error(Error::MaxBuyAmountExceeded { + max: Default::default(), + buy: Default::default(), + })) + .unwrap(); + assert_eq!(error, serialized); + + let error = r#"{"Error":{"BalanceTooLow":{"buy":0}}}"#.to_string(); + let serialized = serde_json::to_string(&Response::Error(Error::BalanceTooLow { + buy: Default::default(), + })) + .unwrap(); + assert_eq!(error, serialized); + + let error = r#"{"Error":"Other"}"#.to_string(); + let serialized = serde_json::to_string(&Response::Error(Error::Other)).unwrap(); + assert_eq!(error, serialized); } } -crate::impl_from_rr_event!(OutEvent, alice::OutEvent, PROTOCOL); - -impl From<(PeerId, Message)> for bob::OutEvent { - fn from((peer, message): (PeerId, Message)) -> Self { - match message { - Message::Request { .. } => Self::unexpected_request(peer), - Message::Response { - response, - request_id, - } => Self::SpotPriceReceived { - id: request_id, - response, - }, - } - } -} -crate::impl_from_rr_event!(OutEvent, bob::OutEvent, PROTOCOL); diff --git a/swap/src/network/swarm.rs b/swap/src/network/swarm.rs index ebcce5fa..818a1210 100644 --- a/swap/src/network/swarm.rs +++ b/swap/src/network/swarm.rs @@ -1,13 +1,28 @@ use crate::network::transport; +use crate::protocol::alice::event_loop::LatestRate; use crate::protocol::{alice, bob}; use crate::seed::Seed; -use crate::tor; +use crate::{monero, tor}; use anyhow::Result; use libp2p::swarm::{NetworkBehaviour, SwarmBuilder}; use libp2p::{PeerId, Swarm}; +use std::fmt::Debug; -pub fn alice(seed: &Seed) -> Result> { - with_clear_net(seed, alice::Behaviour::default()) +pub fn alice( + seed: &Seed, + balance: monero::Amount, + lock_fee: monero::Amount, + max_buy: bitcoin::Amount, + latest_rate: LR, + resume_only: bool, +) -> Result>> +where + LR: LatestRate + Send + 'static + Debug, +{ + with_clear_net( + seed, + alice::Behaviour::new(balance, lock_fee, max_buy, latest_rate, resume_only), + ) } pub async fn bob( diff --git a/swap/src/network/test.rs b/swap/src/network/test.rs new file mode 100644 index 00000000..ea4b1a8d --- /dev/null +++ b/swap/src/network/test.rs @@ -0,0 +1,162 @@ +use futures::future; +use libp2p::core::muxing::StreamMuxerBox; +use libp2p::core::transport::memory::MemoryTransport; +use libp2p::core::upgrade::{SelectUpgrade, Version}; +use libp2p::core::{Executor, Multiaddr}; +use libp2p::mplex::MplexConfig; +use libp2p::noise::{self, NoiseConfig, X25519Spec}; +use libp2p::swarm::{ + IntoProtocolsHandler, NetworkBehaviour, ProtocolsHandler, SwarmBuilder, SwarmEvent, +}; +use libp2p::{identity, yamux, PeerId, Swarm, Transport}; +use std::fmt::Debug; +use std::future::Future; +use std::pin::Pin; +use std::time::Duration; +use tokio::time; + +/// An adaptor struct for libp2p that spawns futures into the current +/// thread-local runtime. +struct GlobalSpawnTokioExecutor; + +impl Executor for GlobalSpawnTokioExecutor { + fn exec(&self, future: Pin + Send>>) { + let _ = tokio::spawn(future); + } +} + +#[allow(missing_debug_implementations)] +pub struct Actor { + pub swarm: Swarm, + pub addr: Multiaddr, + pub peer_id: PeerId, +} + +pub async fn new_connected_swarm_pair(behaviour_fn: F) -> (Actor, Actor) +where + B: NetworkBehaviour, + F: Fn(PeerId, identity::Keypair) -> B + Clone, + <<::ProtocolsHandler as IntoProtocolsHandler>::Handler as ProtocolsHandler>::InEvent: Clone, +::OutEvent: Debug{ + let (swarm, addr, peer_id) = new_swarm(behaviour_fn.clone()); + let mut alice = Actor { + swarm, + addr, + peer_id, + }; + + let (swarm, addr, peer_id) = new_swarm(behaviour_fn); + let mut bob = Actor { + swarm, + addr, + peer_id, + }; + + connect(&mut alice.swarm, &mut bob.swarm).await; + + (alice, bob) +} + +pub fn new_swarm B>( + behaviour_fn: F, +) -> (Swarm, Multiaddr, PeerId) +where + B: NetworkBehaviour, +{ + let id_keys = identity::Keypair::generate_ed25519(); + let peer_id = PeerId::from(id_keys.public()); + + let dh_keys = noise::Keypair::::new() + .into_authentic(&id_keys) + .expect("failed to create dh_keys"); + let noise = NoiseConfig::xx(dh_keys).into_authenticated(); + + let transport = MemoryTransport::default() + .upgrade(Version::V1) + .authenticate(noise) + .multiplex(SelectUpgrade::new( + yamux::YamuxConfig::default(), + MplexConfig::new(), + )) + .timeout(Duration::from_secs(5)) + .map(|(peer, muxer), _| (peer, StreamMuxerBox::new(muxer))) + .boxed(); + + let mut swarm: Swarm = SwarmBuilder::new(transport, behaviour_fn(peer_id, id_keys), peer_id) + .executor(Box::new(GlobalSpawnTokioExecutor)) + .build(); + + let address_port = rand::random::(); + let addr = format!("/memory/{}", address_port) + .parse::() + .unwrap(); + + Swarm::listen_on(&mut swarm, addr.clone()).unwrap(); + + (swarm, addr, peer_id) +} + +pub async fn await_events_or_timeout( + alice_event: impl Future, + bob_event: impl Future, +) -> (A, B) { + time::timeout( + Duration::from_secs(10), + future::join(alice_event, bob_event), + ) + .await + .expect("network behaviours to emit an event within 10 seconds") +} + +/// Connects two swarms with each other. +/// +/// This assumes the transport that is in use can be used by Bob to connect to +/// the listen address that is emitted by Alice. In other words, they have to be +/// on the same network. The memory transport used by the above `new_swarm` +/// function fulfills this. +/// +/// We also assume that the swarms don't emit any behaviour events during the +/// connection phase. Any event emitted is considered a bug from this functions +/// PoV because they would be lost. +pub async fn connect(alice: &mut Swarm, bob: &mut Swarm) +where + BA: NetworkBehaviour, + BB: NetworkBehaviour, + ::OutEvent: Debug, + ::OutEvent: Debug, +{ + let mut alice_connected = false; + let mut bob_connected = false; + + while !alice_connected && !bob_connected { + let (alice_event, bob_event) = future::join(alice.next_event(), bob.next_event()).await; + + match alice_event { + SwarmEvent::ConnectionEstablished { .. } => { + alice_connected = true; + } + SwarmEvent::NewListenAddr(addr) => { + bob.dial_addr(addr).unwrap(); + } + SwarmEvent::Behaviour(event) => { + panic!( + "alice unexpectedly emitted a behaviour event during connection: {:?}", + event + ); + } + _ => {} + } + match bob_event { + SwarmEvent::ConnectionEstablished { .. } => { + bob_connected = true; + } + SwarmEvent::Behaviour(event) => { + panic!( + "bob unexpectedly emitted a behaviour event during connection: {:?}", + event + ); + } + _ => {} + } + } +} diff --git a/swap/src/protocol/alice.rs b/swap/src/protocol/alice.rs index 5909a701..2410e289 100644 --- a/swap/src/protocol/alice.rs +++ b/swap/src/protocol/alice.rs @@ -14,6 +14,7 @@ pub use self::swap::{run, run_until}; mod behaviour; pub mod event_loop; mod execution_setup; +mod spot_price; pub mod state; pub mod swap; diff --git a/swap/src/protocol/alice/behaviour.rs b/swap/src/protocol/alice/behaviour.rs index fb38403b..08c87b45 100644 --- a/swap/src/protocol/alice/behaviour.rs +++ b/swap/src/protocol/alice/behaviour.rs @@ -1,6 +1,8 @@ +use crate::monero; use crate::network::quote::BidQuote; -use crate::network::{encrypted_signature, quote, spot_price, transfer_proof}; -use crate::protocol::alice::{execution_setup, State3}; +use crate::network::{encrypted_signature, quote, transfer_proof}; +use crate::protocol::alice::event_loop::LatestRate; +use crate::protocol::alice::{execution_setup, spot_price, State3}; use anyhow::{anyhow, Error}; use libp2p::request_response::{RequestId, ResponseChannel}; use libp2p::{NetworkBehaviour, PeerId}; @@ -8,10 +10,14 @@ use uuid::Uuid; #[derive(Debug)] pub enum OutEvent { - SpotPriceRequested { - request: spot_price::Request, - channel: ResponseChannel, + SwapRequestDeclined { peer: PeerId, + error: spot_price::Error, + }, + ExecutionSetupStart { + peer: PeerId, + btc: bitcoin::Amount, + xmr: monero::Amount, }, QuoteRequested { channel: ResponseChannel, @@ -60,19 +66,37 @@ impl OutEvent { #[derive(NetworkBehaviour)] #[behaviour(out_event = "OutEvent", event_process = false)] #[allow(missing_debug_implementations)] -pub struct Behaviour { +pub struct Behaviour +where + LR: LatestRate + Send + 'static, +{ pub quote: quote::Behaviour, - pub spot_price: spot_price::Behaviour, + pub spot_price: spot_price::Behaviour, pub execution_setup: execution_setup::Behaviour, pub transfer_proof: transfer_proof::Behaviour, pub encrypted_signature: encrypted_signature::Behaviour, } -impl Default for Behaviour { - fn default() -> Self { +impl Behaviour +where + LR: LatestRate + Send + 'static, +{ + pub fn new( + balance: monero::Amount, + lock_fee: monero::Amount, + max_buy: bitcoin::Amount, + latest_rate: LR, + resume_only: bool, + ) -> Self { Self { quote: quote::alice(), - spot_price: spot_price::alice(), + spot_price: spot_price::Behaviour::new( + balance, + lock_fee, + max_buy, + latest_rate, + resume_only, + ), execution_setup: Default::default(), transfer_proof: transfer_proof::alice(), encrypted_signature: encrypted_signature::alice(), diff --git a/swap/src/protocol/alice/event_loop.rs b/swap/src/protocol/alice/event_loop.rs index 45258ef8..e6b21304 100644 --- a/swap/src/protocol/alice/event_loop.rs +++ b/swap/src/protocol/alice/event_loop.rs @@ -1,12 +1,12 @@ use crate::asb::Rate; use crate::database::Database; use crate::env::Config; -use crate::monero::BalanceTooLow; use crate::network::quote::BidQuote; -use crate::network::{spot_price, transfer_proof}; +use crate::network::transfer_proof; +use crate::protocol::alice::spot_price::Error; use crate::protocol::alice::{AliceState, Behaviour, OutEvent, State0, State3, Swap}; use crate::{bitcoin, kraken, monero}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use futures::future; use futures::future::{BoxFuture, FutureExt}; use futures::stream::{FuturesUnordered, StreamExt}; @@ -17,6 +17,7 @@ use rand::rngs::OsRng; use rust_decimal::Decimal; use std::collections::HashMap; use std::convert::Infallible; +use std::fmt::Debug; use std::sync::Arc; use tokio::sync::mpsc; use uuid::Uuid; @@ -32,13 +33,16 @@ type OutgoingTransferProof = BoxFuture<'static, Result<(PeerId, transfer_proof::Request, bmrng::Responder<()>)>>; #[allow(missing_debug_implementations)] -pub struct EventLoop { - swarm: libp2p::Swarm, +pub struct EventLoop +where + LR: LatestRate + Send + 'static + Debug, +{ + swarm: libp2p::Swarm>, env_config: Config, bitcoin_wallet: Arc, monero_wallet: Arc, db: Arc, - latest_rate: RS, + latest_rate: LR, max_buy: bitcoin::Amount, swap_sender: mpsc::Sender, @@ -60,10 +64,11 @@ pub struct EventLoop { impl EventLoop where - LR: LatestRate, + LR: LatestRate + Send + 'static + Debug, { + #[allow(clippy::too_many_arguments)] pub fn new( - swarm: Swarm, + swarm: Swarm>, env_config: Config, bitcoin_wallet: Arc, monero_wallet: Arc, @@ -143,24 +148,8 @@ where tokio::select! { swarm_event = self.swarm.next_event() => { match swarm_event { - SwarmEvent::Behaviour(OutEvent::SpotPriceRequested { request, channel, peer }) => { - let btc = request.btc; - let xmr = match self.handle_spot_price_request(btc, self.monero_wallet.clone()).await { - Ok(xmr) => xmr, - Err(e) => { - tracing::warn!(%peer, "Failed to produce spot price for {}: {:#}", btc, e); - continue; - } - }; + SwarmEvent::Behaviour(OutEvent::ExecutionSetupStart { peer, btc, xmr }) => { - match self.swarm.behaviour_mut().spot_price.send_response(channel, spot_price::Response { xmr }) { - Ok(_) => {}, - Err(_) => { - // if we can't respond, the peer probably just disconnected so it is not a huge deal, only log this on debug - tracing::debug!(%peer, "Failed to respond with spot price"); - continue; - } - } let tx_redeem_fee = self.bitcoin_wallet .estimate_fee(bitcoin::TxRedeem::weight(), btc) .await; @@ -178,7 +167,7 @@ where (redeem_address, punish_address) } _ => { - tracing::error!("Could not get new address."); + tracing::error!(%peer, "Failed to get new address during execution setup."); continue; } }; @@ -191,7 +180,7 @@ where (tx_redeem_fee, tx_punish_fee) } _ => { - tracing::error!("Could not calculate transaction fees."); + tracing::error!(%peer, "Failed to calculate transaction fees during execution setup."); continue; } }; @@ -215,7 +204,30 @@ where self.swarm.behaviour_mut().execution_setup.run(peer, state0); } + SwarmEvent::Behaviour(OutEvent::SwapRequestDeclined { peer, error }) => { + match error { + Error::ResumeOnlyMode | Error::MaxBuyAmountExceeded { .. } => { + tracing::warn!(%peer, "Ignoring spot price request because: {}", error); + } + Error::BalanceTooLow { .. } + | Error::LatestRateFetchFailed(_) + | Error::SellQuoteCalculationFailed(_) => { + tracing::error!(%peer, "Ignoring spot price request because: {}", error); + } + } + } SwarmEvent::Behaviour(OutEvent::QuoteRequested { channel, peer }) => { + // TODO: Move the spot-price update into dedicated update stream to decouple it from quote requests + let current_balance = self.monero_wallet.get_balance().await; + match current_balance { + Ok(balance) => { + self.swarm.behaviour_mut().spot_price.update_balance(balance); + } + Err(e) => { + tracing::error!("Failed to fetch Monero balance: {:#}", e); + } + } + let quote = match self.make_quote(self.max_buy).await { Ok(quote) => quote, Err(e) => { @@ -343,36 +355,6 @@ where } } - async fn handle_spot_price_request( - &mut self, - btc: bitcoin::Amount, - monero_wallet: Arc, - ) -> Result { - let rate = self - .latest_rate - .latest_rate() - .context("Failed to get latest rate")?; - - if btc > self.max_buy { - bail!(MaximumBuyAmountExceeded { - actual: btc, - max: self.max_buy - }) - } - - let xmr_balance = monero_wallet.get_balance().await?; - let xmr_lock_fees = monero_wallet.static_tx_fee_estimate(); - let xmr = rate.sell_quote(btc)?; - - if xmr_balance < xmr + xmr_lock_fees { - bail!(BalanceTooLow { - balance: xmr_balance - }) - } - - Ok(xmr) - } - async fn make_quote(&mut self, max_buy: bitcoin::Amount) -> Result { let rate = self .latest_rate @@ -491,7 +473,7 @@ impl LatestRate for FixedRate { /// Produces [`Rate`]s based on [`PriceUpdate`]s from kraken and a configured /// spread. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct KrakenRate { ask_spread: Decimal, price_updates: kraken::PriceUpdates, @@ -551,13 +533,6 @@ impl EventLoopHandle { } } -#[derive(Debug, Clone, Copy, thiserror::Error)] -#[error("Refusing to buy {actual} because the maximum configured limit is {max}")] -pub struct MaximumBuyAmountExceeded { - pub max: bitcoin::Amount, - pub actual: bitcoin::Amount, -} - #[allow(missing_debug_implementations)] struct MpscChannels { sender: mpsc::Sender, diff --git a/swap/src/protocol/alice/spot_price.rs b/swap/src/protocol/alice/spot_price.rs new file mode 100644 index 00000000..032d9de5 --- /dev/null +++ b/swap/src/protocol/alice/spot_price.rs @@ -0,0 +1,630 @@ +use crate::monero; +use crate::network::cbor_request_response::CborCodec; +use crate::network::spot_price; +use crate::network::spot_price::SpotPriceProtocol; +use crate::protocol::alice; +use crate::protocol::alice::event_loop::LatestRate; +use libp2p::request_response::{ + ProtocolSupport, RequestResponseConfig, RequestResponseEvent, RequestResponseMessage, + ResponseChannel, +}; +use libp2p::swarm::{NetworkBehaviourAction, NetworkBehaviourEventProcess, PollParameters}; +use libp2p::{NetworkBehaviour, PeerId}; +use std::collections::VecDeque; +use std::fmt::Debug; +use std::task::{Context, Poll}; + +#[derive(Debug)] +pub enum OutEvent { + ExecutionSetupParams { + peer: PeerId, + btc: bitcoin::Amount, + xmr: monero::Amount, + }, + Error { + peer: PeerId, + error: Error, + }, +} + +#[derive(NetworkBehaviour)] +#[behaviour(out_event = "OutEvent", poll_method = "poll", event_process = true)] +#[allow(missing_debug_implementations)] +pub struct Behaviour +where + LR: LatestRate + Send + 'static, +{ + behaviour: spot_price::Behaviour, + + #[behaviour(ignore)] + events: VecDeque, + + #[behaviour(ignore)] + balance: monero::Amount, + #[behaviour(ignore)] + lock_fee: monero::Amount, + #[behaviour(ignore)] + max_buy: bitcoin::Amount, + #[behaviour(ignore)] + latest_rate: LR, + #[behaviour(ignore)] + resume_only: bool, +} + +/// Behaviour that handles spot prices. +/// All the logic how to react to a spot price request is contained here, events +/// reporting the successful handling of a spot price request or a failure are +/// bubbled up to the parent behaviour. +impl Behaviour +where + LR: LatestRate + Send + 'static, +{ + pub fn new( + balance: monero::Amount, + lock_fee: monero::Amount, + max_buy: bitcoin::Amount, + latest_rate: LR, + resume_only: bool, + ) -> Self { + Self { + behaviour: spot_price::Behaviour::new( + CborCodec::default(), + vec![(SpotPriceProtocol, ProtocolSupport::Inbound)], + RequestResponseConfig::default(), + ), + events: Default::default(), + balance, + lock_fee, + max_buy, + latest_rate, + resume_only, + } + } + + pub fn update_balance(&mut self, balance: monero::Amount) { + self.balance = balance; + } + + fn decline( + &mut self, + peer: PeerId, + channel: ResponseChannel, + error: Error, + ) { + if self + .behaviour + .send_response( + channel, + spot_price::Response::Error(error.to_error_response()), + ) + .is_err() + { + tracing::debug!(%peer, "Unable to send error response for spot price request"); + } + + self.events.push_back(OutEvent::Error { peer, error }); + } + + fn poll( + &mut self, + _cx: &mut Context<'_>, + _params: &mut impl PollParameters, + ) -> Poll> { + if let Some(event) = self.events.pop_front() { + return Poll::Ready(NetworkBehaviourAction::GenerateEvent(event)); + } + + // We trust in libp2p to poll us. + Poll::Pending + } +} + +impl NetworkBehaviourEventProcess for Behaviour +where + LR: LatestRate + Send + 'static, +{ + fn inject_event(&mut self, event: spot_price::OutEvent) { + let (peer, message) = match event { + RequestResponseEvent::Message { peer, message } => (peer, message), + RequestResponseEvent::OutboundFailure { peer, error, .. } => { + tracing::error!(%peer, "Failure sending spot price response: {:#}", error); + return; + } + RequestResponseEvent::InboundFailure { peer, error, .. } => { + tracing::warn!(%peer, "Inbound failure when handling spot price request: {:#}", error); + return; + } + RequestResponseEvent::ResponseSent { peer, .. } => { + tracing::debug!(%peer, "Spot price response sent"); + return; + } + }; + + let (request, channel) = match message { + RequestResponseMessage::Request { + request, channel, .. + } => (request, channel), + RequestResponseMessage::Response { .. } => { + tracing::error!("Unexpected message"); + return; + } + }; + + if self.resume_only { + self.decline(peer, channel, Error::ResumeOnlyMode); + return; + } + + let btc = request.btc; + if btc > self.max_buy { + self.decline(peer, channel, Error::MaxBuyAmountExceeded { + max: self.max_buy, + buy: btc, + }); + return; + } + + let rate = match self.latest_rate.latest_rate() { + Ok(rate) => rate, + Err(e) => { + self.decline(peer, channel, Error::LatestRateFetchFailed(Box::new(e))); + return; + } + }; + let xmr = match rate.sell_quote(btc) { + Ok(xmr) => xmr, + Err(e) => { + self.decline(peer, channel, Error::SellQuoteCalculationFailed(e)); + return; + } + }; + + let xmr_balance = self.balance; + let xmr_lock_fees = self.lock_fee; + + if xmr_balance < xmr + xmr_lock_fees { + self.decline(peer, channel, Error::BalanceTooLow { buy: btc }); + return; + } + + if self + .behaviour + .send_response(channel, spot_price::Response::Xmr(xmr)) + .is_err() + { + tracing::error!(%peer, "Failed to send spot price response of {} for {}", xmr, btc) + } + + self.events + .push_back(OutEvent::ExecutionSetupParams { peer, btc, xmr }); + } +} + +impl From for alice::OutEvent { + fn from(event: OutEvent) -> Self { + match event { + OutEvent::ExecutionSetupParams { peer, btc, xmr } => { + Self::ExecutionSetupStart { peer, btc, xmr } + } + OutEvent::Error { peer, error } => Self::SwapRequestDeclined { peer, error }, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("ASB is running in resume-only mode")] + ResumeOnlyMode, + #[error("Maximum buy {max} exceeded {buy}")] + MaxBuyAmountExceeded { + max: bitcoin::Amount, + buy: bitcoin::Amount, + }, + #[error("This seller's XMR balance is currently too low to fulfill the swap request to buy {buy}, please try again later")] + BalanceTooLow { buy: bitcoin::Amount }, + + #[error("Failed to fetch latest rate")] + LatestRateFetchFailed(#[source] Box), + + #[error("Failed to calculate quote: {0}")] + SellQuoteCalculationFailed(#[source] anyhow::Error), +} + +impl Error { + pub fn to_error_response(&self) -> spot_price::Error { + match self { + Error::ResumeOnlyMode => spot_price::Error::NoSwapsAccepted, + Error::MaxBuyAmountExceeded { max, buy } => spot_price::Error::MaxBuyAmountExceeded { + max: *max, + buy: *buy, + }, + Error::BalanceTooLow { buy } => spot_price::Error::BalanceTooLow { buy: *buy }, + Error::LatestRateFetchFailed(_) | Error::SellQuoteCalculationFailed(_) => { + spot_price::Error::Other + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::asb::Rate; + use crate::monero; + use crate::network::test::{await_events_or_timeout, connect, new_swarm}; + use crate::protocol::{alice, bob}; + use anyhow::anyhow; + use libp2p::Swarm; + use rust_decimal::Decimal; + + impl Default for AliceBehaviourValues { + fn default() -> Self { + Self { + balance: monero::Amount::from_monero(1.0).unwrap(), + lock_fee: monero::Amount::ZERO, + max_buy: bitcoin::Amount::from_btc(0.01).unwrap(), + rate: TestRate::default(), // 0.01 + resume_only: false, + } + } + } + + #[tokio::test] + async fn given_alice_has_sufficient_balance_then_returns_price() { + let mut test = SpotPriceTest::setup(AliceBehaviourValues::default()).await; + + let btc_to_swap = bitcoin::Amount::from_btc(0.01).unwrap(); + let expected_xmr = monero::Amount::from_monero(1.0).unwrap(); + + let request = spot_price::Request { btc: btc_to_swap }; + + test.send_request(request); + test.assert_price((btc_to_swap, expected_xmr), expected_xmr) + .await; + } + + #[tokio::test] + async fn given_alice_has_insufficient_balance_then_returns_error() { + let mut test = SpotPriceTest::setup( + AliceBehaviourValues::default().with_balance(monero::Amount::ZERO), + ) + .await; + + let btc_to_swap = bitcoin::Amount::from_btc(0.01).unwrap(); + + let request = spot_price::Request { btc: btc_to_swap }; + + test.send_request(request); + test.assert_error( + alice::spot_price::Error::BalanceTooLow { buy: btc_to_swap }, + bob::spot_price::Error::BalanceTooLow { buy: btc_to_swap }, + ) + .await; + } + + #[tokio::test] + async fn given_alice_has_insufficient_balance_after_balance_update_then_returns_error() { + let mut test = SpotPriceTest::setup(AliceBehaviourValues::default()).await; + + let btc_to_swap = bitcoin::Amount::from_btc(0.01).unwrap(); + let expected_xmr = monero::Amount::from_monero(1.0).unwrap(); + + let request = spot_price::Request { btc: btc_to_swap }; + + test.send_request(request); + test.assert_price((btc_to_swap, expected_xmr), expected_xmr) + .await; + + test.alice_swarm + .behaviour_mut() + .update_balance(monero::Amount::ZERO); + + let request = spot_price::Request { btc: btc_to_swap }; + + test.send_request(request); + test.assert_error( + alice::spot_price::Error::BalanceTooLow { buy: btc_to_swap }, + bob::spot_price::Error::BalanceTooLow { buy: btc_to_swap }, + ) + .await; + } + + #[tokio::test] + async fn given_alice_has_insufficient_balance_because_of_lock_fee_then_returns_error() { + let mut test = SpotPriceTest::setup( + AliceBehaviourValues::default().with_lock_fee(monero::Amount::from_piconero(1)), + ) + .await; + + let btc_to_swap = bitcoin::Amount::from_btc(0.01).unwrap(); + + let request = spot_price::Request { btc: btc_to_swap }; + + test.send_request(request); + test.assert_error( + alice::spot_price::Error::BalanceTooLow { buy: btc_to_swap }, + bob::spot_price::Error::BalanceTooLow { buy: btc_to_swap }, + ) + .await; + } + + #[tokio::test] + async fn given_max_buy_exceeded_then_returns_error() { + let max_buy = bitcoin::Amount::from_btc(0.001).unwrap(); + + let mut test = + SpotPriceTest::setup(AliceBehaviourValues::default().with_max_buy(max_buy)).await; + + let btc_to_swap = bitcoin::Amount::from_btc(0.01).unwrap(); + + let request = spot_price::Request { btc: btc_to_swap }; + + test.send_request(request); + test.assert_error( + alice::spot_price::Error::MaxBuyAmountExceeded { + buy: btc_to_swap, + max: max_buy, + }, + bob::spot_price::Error::MaxBuyAmountExceeded { + buy: btc_to_swap, + max: max_buy, + }, + ) + .await; + } + + #[tokio::test] + async fn given_alice_in_resume_only_mode_then_returns_error() { + let mut test = + SpotPriceTest::setup(AliceBehaviourValues::default().with_resume_only(true)).await; + + let btc_to_swap = bitcoin::Amount::from_btc(0.01).unwrap(); + + let request = spot_price::Request { btc: btc_to_swap }; + + test.send_request(request); + test.assert_error( + alice::spot_price::Error::ResumeOnlyMode, + bob::spot_price::Error::NoSwapsAccepted, + ) + .await; + } + + #[tokio::test] + async fn given_rate_fetch_problem_then_returns_error() { + let mut test = + SpotPriceTest::setup(AliceBehaviourValues::default().with_rate(TestRate::error_rate())) + .await; + + let btc_to_swap = bitcoin::Amount::from_btc(0.01).unwrap(); + + let request = spot_price::Request { btc: btc_to_swap }; + + test.send_request(request); + test.assert_error( + alice::spot_price::Error::LatestRateFetchFailed(Box::new(TestRateError {})), + bob::spot_price::Error::Other, + ) + .await; + } + + #[tokio::test] + async fn given_rate_calculation_problem_then_returns_error() { + let mut test = SpotPriceTest::setup( + AliceBehaviourValues::default().with_rate(TestRate::from_rate_and_spread(0.0, 0)), + ) + .await; + + let btc_to_swap = bitcoin::Amount::from_btc(0.01).unwrap(); + + let request = spot_price::Request { btc: btc_to_swap }; + + test.send_request(request); + test.assert_error( + alice::spot_price::Error::SellQuoteCalculationFailed(anyhow!( + "Error text irrelevant, won't be checked here" + )), + bob::spot_price::Error::Other, + ) + .await; + } + + struct SpotPriceTest { + alice_swarm: Swarm>, + bob_swarm: Swarm, + + alice_peer_id: PeerId, + } + + impl SpotPriceTest { + pub async fn setup(values: AliceBehaviourValues) -> Self { + let (mut alice_swarm, _, alice_peer_id) = new_swarm(|_, _| { + Behaviour::new( + values.balance, + values.lock_fee, + values.max_buy, + values.rate.clone(), + values.resume_only, + ) + }); + let (mut bob_swarm, ..) = new_swarm(|_, _| bob::spot_price::bob()); + + connect(&mut alice_swarm, &mut bob_swarm).await; + + Self { + alice_swarm, + bob_swarm, + alice_peer_id, + } + } + + pub fn send_request(&mut self, spot_price_request: spot_price::Request) { + self.bob_swarm + .behaviour_mut() + .send_request(&self.alice_peer_id, spot_price_request); + } + + async fn assert_price( + &mut self, + alice_assert: (bitcoin::Amount, monero::Amount), + bob_assert: monero::Amount, + ) { + match await_events_or_timeout(self.alice_swarm.next(), self.bob_swarm.next()).await { + ( + alice::spot_price::OutEvent::ExecutionSetupParams { btc, xmr, .. }, + spot_price::OutEvent::Message { message, .. }, + ) => { + assert_eq!(alice_assert, (btc, xmr)); + + let response = match message { + RequestResponseMessage::Response { response, .. } => response, + _ => panic!("Unexpected message {:?} for Bob", message), + }; + + match response { + spot_price::Response::Xmr(xmr) => { + assert_eq!(bob_assert, xmr) + } + _ => panic!("Unexpected response {:?} for Bob", response), + } + } + (alice_event, bob_event) => panic!( + "Received unexpected event, alice emitted {:?} and bob emitted {:?}", + alice_event, bob_event + ), + } + } + + async fn assert_error( + &mut self, + alice_assert: alice::spot_price::Error, + bob_assert: bob::spot_price::Error, + ) { + match await_events_or_timeout(self.alice_swarm.next(), self.bob_swarm.next()).await { + ( + alice::spot_price::OutEvent::Error { error, .. }, + spot_price::OutEvent::Message { message, .. }, + ) => { + // TODO: Somehow make PartialEq work on Alice's spot_price::Error + match (alice_assert, error) { + ( + alice::spot_price::Error::BalanceTooLow { .. }, + alice::spot_price::Error::BalanceTooLow { .. }, + ) + | ( + alice::spot_price::Error::MaxBuyAmountExceeded { .. }, + alice::spot_price::Error::MaxBuyAmountExceeded { .. }, + ) + | ( + alice::spot_price::Error::LatestRateFetchFailed(_), + alice::spot_price::Error::LatestRateFetchFailed(_), + ) + | ( + alice::spot_price::Error::SellQuoteCalculationFailed(_), + alice::spot_price::Error::SellQuoteCalculationFailed(_), + ) + | ( + alice::spot_price::Error::ResumeOnlyMode, + alice::spot_price::Error::ResumeOnlyMode, + ) => {} + (alice_assert, error) => { + panic!("Expected: {:?} Actual: {:?}", alice_assert, error) + } + } + + let response = match message { + RequestResponseMessage::Response { response, .. } => response, + _ => panic!("Unexpected message {:?} for Bob", message), + }; + + match response { + spot_price::Response::Error(error) => { + assert_eq!(bob_assert, error.into()) + } + _ => panic!("Unexpected response {:?} for Bob", response), + } + } + (alice_event, bob_event) => panic!( + "Received unexpected event, alice emitted {:?} and bob emitted {:?}", + alice_event, bob_event + ), + } + } + } + + struct AliceBehaviourValues { + pub balance: monero::Amount, + pub lock_fee: monero::Amount, + pub max_buy: bitcoin::Amount, + pub rate: TestRate, // 0.01 + pub resume_only: bool, + } + + impl AliceBehaviourValues { + pub fn with_balance(mut self, balance: monero::Amount) -> AliceBehaviourValues { + self.balance = balance; + self + } + + pub fn with_lock_fee(mut self, lock_fee: monero::Amount) -> AliceBehaviourValues { + self.lock_fee = lock_fee; + self + } + + pub fn with_max_buy(mut self, max_buy: bitcoin::Amount) -> AliceBehaviourValues { + self.max_buy = max_buy; + self + } + + pub fn with_resume_only(mut self, resume_only: bool) -> AliceBehaviourValues { + self.resume_only = resume_only; + self + } + + pub fn with_rate(mut self, rate: TestRate) -> AliceBehaviourValues { + self.rate = rate; + self + } + } + + #[derive(Clone, Debug)] + pub enum TestRate { + Rate(Rate), + Err(TestRateError), + } + + impl TestRate { + pub const RATE: f64 = 0.01; + + pub fn from_rate_and_spread(rate: f64, spread: u64) -> Self { + let ask = bitcoin::Amount::from_btc(rate).expect("Static value should never fail"); + let spread = Decimal::from(spread); + Self::Rate(Rate::new(ask, spread)) + } + + pub fn error_rate() -> Self { + Self::Err(TestRateError {}) + } + } + + impl Default for TestRate { + fn default() -> Self { + TestRate::from_rate_and_spread(Self::RATE, 0) + } + } + + #[derive(Debug, Clone, thiserror::Error)] + #[error("Could not fetch rate")] + pub struct TestRateError {} + + impl LatestRate for TestRate { + type Error = TestRateError; + + fn latest_rate(&mut self) -> Result { + match self { + TestRate::Rate(rate) => Ok(*rate), + TestRate::Err(error) => Err(error.clone()), + } + } + } +} diff --git a/swap/src/protocol/bob.rs b/swap/src/protocol/bob.rs index fbcc7dd1..ae263f97 100644 --- a/swap/src/protocol/bob.rs +++ b/swap/src/protocol/bob.rs @@ -16,6 +16,7 @@ pub mod cancel; pub mod event_loop; mod execution_setup; pub mod refund; +pub mod spot_price; pub mod state; pub mod swap; diff --git a/swap/src/protocol/bob/behaviour.rs b/swap/src/protocol/bob/behaviour.rs index 91a7c47b..6eab91fa 100644 --- a/swap/src/protocol/bob/behaviour.rs +++ b/swap/src/protocol/bob/behaviour.rs @@ -1,5 +1,6 @@ use crate::network::quote::BidQuote; use crate::network::{encrypted_signature, quote, redial, spot_price, transfer_proof}; +use crate::protocol::bob; use crate::protocol::bob::{execution_setup, State2}; use anyhow::{anyhow, Error, Result}; use libp2p::core::Multiaddr; @@ -71,7 +72,7 @@ impl Behaviour { pub fn new(alice: PeerId) -> Self { Self { quote: quote::bob(), - spot_price: spot_price::bob(), + spot_price: bob::spot_price::bob(), execution_setup: Default::default(), transfer_proof: transfer_proof::bob(), encrypted_signature: encrypted_signature::bob(), diff --git a/swap/src/protocol/bob/event_loop.rs b/swap/src/protocol/bob/event_loop.rs index c4bfc0a8..e32fa7e0 100644 --- a/swap/src/protocol/bob/event_loop.rs +++ b/swap/src/protocol/bob/event_loop.rs @@ -1,9 +1,11 @@ use crate::bitcoin::EncryptedSignature; use crate::network::quote::BidQuote; +use crate::network::spot_price::Response; use crate::network::{encrypted_signature, spot_price}; +use crate::protocol::bob; use crate::protocol::bob::{Behaviour, OutEvent, State0, State2}; use crate::{bitcoin, monero}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use futures::future::{BoxFuture, OptionFuture}; use futures::{FutureExt, StreamExt}; use libp2p::request_response::{RequestId, ResponseChannel}; @@ -261,11 +263,18 @@ impl EventLoopHandle { } pub async fn request_spot_price(&mut self, btc: bitcoin::Amount) -> Result { - Ok(self + let response = self .spot_price .send_receive(spot_price::Request { btc }) - .await? - .xmr) + .await?; + + match response { + Response::Xmr(xmr) => Ok(xmr), + Response::Error(error) => { + let error: bob::spot_price::Error = error.into(); + bail!(error); + } + } } pub async fn request_quote(&mut self) -> Result { diff --git a/swap/src/protocol/bob/spot_price.rs b/swap/src/protocol/bob/spot_price.rs new file mode 100644 index 00000000..e2a4cef7 --- /dev/null +++ b/swap/src/protocol/bob/spot_price.rs @@ -0,0 +1,69 @@ +use crate::network::cbor_request_response::CborCodec; +use crate::network::spot_price; +use crate::network::spot_price::SpotPriceProtocol; +use crate::protocol::bob::OutEvent; +use libp2p::request_response::{ProtocolSupport, RequestResponseConfig}; +use libp2p::PeerId; + +const PROTOCOL: &str = spot_price::PROTOCOL; +pub type SpotPriceOutEvent = spot_price::OutEvent; + +/// Constructs a new instance of the `spot-price` behaviour to be used by Bob. +/// +/// Bob only supports outbound connections, i.e. requesting a spot price for a +/// given amount of BTC in XMR. +pub fn bob() -> spot_price::Behaviour { + spot_price::Behaviour::new( + CborCodec::default(), + vec![(SpotPriceProtocol, ProtocolSupport::Outbound)], + RequestResponseConfig::default(), + ) +} + +impl From<(PeerId, spot_price::Message)> for OutEvent { + fn from((peer, message): (PeerId, spot_price::Message)) -> Self { + match message { + spot_price::Message::Request { .. } => Self::unexpected_request(peer), + spot_price::Message::Response { + response, + request_id, + } => Self::SpotPriceReceived { + id: request_id, + response, + }, + } + } +} + +crate::impl_from_rr_event!(SpotPriceOutEvent, OutEvent, PROTOCOL); + +#[derive(Clone, Debug, thiserror::Error, PartialEq)] +pub enum Error { + #[error("Seller currently does not accept incoming swap requests, please try again later")] + NoSwapsAccepted, + #[error("Seller refused to buy {buy} because the maximum configured buy limit is {max}")] + MaxBuyAmountExceeded { + max: bitcoin::Amount, + buy: bitcoin::Amount, + }, + #[error("Seller's XMR balance is currently too low to fulfill the swap request to buy {buy}, please try again later")] + BalanceTooLow { buy: bitcoin::Amount }, + + /// To be used for errors that cannot be explained on the CLI side (e.g. + /// rate update problems on the seller side) + #[error("Seller encountered a problem, please try again later.")] + Other, +} + +impl From for Error { + fn from(error: spot_price::Error) -> Self { + match error { + spot_price::Error::NoSwapsAccepted => Error::NoSwapsAccepted, + spot_price::Error::MaxBuyAmountExceeded { max, buy } => { + Error::MaxBuyAmountExceeded { max, buy } + } + spot_price::Error::BalanceTooLow { buy } => Error::BalanceTooLow { buy }, + spot_price::Error::Other => Error::Other, + } + } +} diff --git a/swap/tests/harness/mod.rs b/swap/tests/harness/mod.rs index cadce4c4..c70d979d 100644 --- a/swap/tests/harness/mod.rs +++ b/swap/tests/harness/mod.rs @@ -90,7 +90,8 @@ where env_config, alice_bitcoin_wallet.clone(), alice_monero_wallet.clone(), - ); + ) + .await; let bob_seed = Seed::random().unwrap(); let bob_starting_balances = StartingBalances::new(btc_amount * 10, monero::Amount::ZERO, None); @@ -213,7 +214,7 @@ pub async fn init_electrs_container( Ok(docker) } -fn start_alice( +async fn start_alice( seed: &Seed, db_path: PathBuf, listen_address: Multiaddr, @@ -223,7 +224,21 @@ fn start_alice( ) -> (AliceApplicationHandle, Receiver) { let db = Arc::new(Database::open(db_path.as_path()).unwrap()); - let mut swarm = swarm::alice(&seed).unwrap(); + let current_balance = monero_wallet.get_balance().await.unwrap(); + let lock_fee = monero_wallet.static_tx_fee_estimate(); + let max_buy = bitcoin::Amount::from_sat(u64::MAX); + let latest_rate = FixedRate::default(); + let resume_only = false; + + let mut swarm = swarm::alice( + &seed, + current_balance, + lock_fee, + max_buy, + latest_rate, + resume_only, + ) + .unwrap(); swarm.listen_on(listen_address).unwrap(); let (event_loop, swap_handle) = alice::EventLoop::new( @@ -496,7 +511,8 @@ impl TestContext { self.env_config, self.alice_bitcoin_wallet.clone(), self.alice_monero_wallet.clone(), - ); + ) + .await; self.alice_handle = alice_handle; self.alice_swap_handle = alice_swap_handle;