From 89b3d07eba29ceea3ae97bd2e67ce3070f2e8654 Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Thu, 6 May 2021 16:11:07 +1000 Subject: [PATCH] Network protocol tests for spot_price behaviour Each test spawns swarm for Alice and Bob that only contains the spot_price behaviours and uses a memory transport. Tests cover happy path (i.e. expected price is returned) and error scenarios. Implementation of `TestRate` on `LatestRate` allows testing rate fetch error and quote calculation error behaviour. Thanks to @thomaseizinger for ramping up the test framework for comit-rs in the past! --- swap/src/network.rs | 3 + swap/src/network/test.rs | 162 +++++++++++ swap/src/protocol/alice/spot_price.rs | 384 ++++++++++++++++++++++++++ swap/src/protocol/bob.rs | 2 +- swap/src/protocol/bob/spot_price.rs | 4 +- 5 files changed, 552 insertions(+), 3 deletions(-) create mode 100644 swap/src/network/test.rs 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/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/spot_price.rs b/swap/src/protocol/alice/spot_price.rs index d960de28..032d9de5 100644 --- a/swap/src/protocol/alice/spot_price.rs +++ b/swap/src/protocol/alice/spot_price.rs @@ -14,6 +14,7 @@ use std::collections::VecDeque; use std::fmt::Debug; use std::task::{Context, Poll}; +#[derive(Debug)] pub enum OutEvent { ExecutionSetupParams { peer: PeerId, @@ -244,3 +245,386 @@ impl Error { } } } + +#[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 292c2f40..ae263f97 100644 --- a/swap/src/protocol/bob.rs +++ b/swap/src/protocol/bob.rs @@ -16,7 +16,7 @@ pub mod cancel; pub mod event_loop; mod execution_setup; pub mod refund; -mod spot_price; +pub mod spot_price; pub mod state; pub mod swap; diff --git a/swap/src/protocol/bob/spot_price.rs b/swap/src/protocol/bob/spot_price.rs index b16f06f1..e2a4cef7 100644 --- a/swap/src/protocol/bob/spot_price.rs +++ b/swap/src/protocol/bob/spot_price.rs @@ -6,7 +6,7 @@ use libp2p::request_response::{ProtocolSupport, RequestResponseConfig}; use libp2p::PeerId; const PROTOCOL: &str = spot_price::PROTOCOL; -type SpotPriceOutEvent = spot_price::OutEvent; +pub type SpotPriceOutEvent = spot_price::OutEvent; /// Constructs a new instance of the `spot-price` behaviour to be used by Bob. /// @@ -37,7 +37,7 @@ impl From<(PeerId, spot_price::Message)> for OutEvent { crate::impl_from_rr_event!(SpotPriceOutEvent, OutEvent, PROTOCOL); -#[derive(Clone, Debug, thiserror::Error)] +#[derive(Clone, Debug, thiserror::Error, PartialEq)] pub enum Error { #[error("Seller currently does not accept incoming swap requests, please try again later")] NoSwapsAccepted,