From 538b25a6ddab6a65566cdbe18de6c51188958502 Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Fri, 7 May 2021 12:06:03 +1000 Subject: [PATCH 1/4] Only create config-directory on init success We should only create the file if the user finished the initial setup correctly. --- swap/src/asb/config.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/swap/src/asb/config.rs b/swap/src/asb/config.rs index 7657916d..accec056 100644 --- a/swap/src/asb/config.rs +++ b/swap/src/asb/config.rs @@ -123,10 +123,11 @@ where F: Fn() -> Result, { info!("Config file not found, running initial setup..."); - ensure_directory_exists(config_path.as_path())?; let initial_config = config_file()?; let toml = toml::to_string(&initial_config)?; + + ensure_directory_exists(config_path.as_path())?; fs::write(&config_path, toml)?; info!( From 1d62f4916c5c4eae8c84b3a639a2dd99152fc63f Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Fri, 7 May 2021 13:25:25 +1000 Subject: [PATCH 2/4] Move `max_buy` and `ask_spread` into config Max-buy and spread is not something that one would configure on every run. More convenient to keep this in the config. The max-buy Bitcoin value was adapted to `0.02` which is more reasonable for mainnet. Activated feature `serde-float` to serialize the spread (Decimal) as float instead of string. ``` ... [maker] max_buy_btc = 0.02 ask_spread = 0.02 ``` --- CHANGELOG.md | 5 +++++ swap/Cargo.toml | 2 +- swap/src/asb/command.rs | 17 +---------------- swap/src/asb/config.rs | 39 ++++++++++++++++++++++++++++++++++++++- swap/src/bin/asb.rs | 12 ++++-------- 5 files changed, 49 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c0c6a5..eaea6a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 +### Changed + +- The ASB's `--max-buy` and `ask-spread` parameter were removed in favour of entries in the config file. + The initial setup includes setting these two values now. + ## [0.5.0] - 2021-04-17 ### Changed diff --git a/swap/Cargo.toml b/swap/Cargo.toml index a8819fb4..b236c334 100644 --- a/swap/Cargo.toml +++ b/swap/Cargo.toml @@ -40,7 +40,7 @@ proptest = "1" rand = "0.7" rand_chacha = "0.2" reqwest = { version = "0.11", features = [ "rustls-tls", "stream", "socks" ], default-features = false } -rust_decimal = "1" +rust_decimal = { version = "1", features = [ "serde-float" ] } rust_decimal_macros = "1" serde = { version = "1", features = [ "derive" ] } serde_cbor = "0.11" diff --git a/swap/src/asb/command.rs b/swap/src/asb/command.rs index 4ad8e118..3f641653 100644 --- a/swap/src/asb/command.rs +++ b/swap/src/asb/command.rs @@ -1,7 +1,5 @@ use crate::bitcoin::Amount; -use bitcoin::util::amount::ParseAmountError; -use bitcoin::{Address, Denomination}; -use rust_decimal::Decimal; +use bitcoin::Address; use std::path::PathBuf; use uuid::Uuid; @@ -28,15 +26,6 @@ pub struct Arguments { pub enum Command { #[structopt(about = "Main command to run the ASB.")] Start { - #[structopt(long = "max-buy-btc", help = "The maximum amount of BTC the ASB is willing to buy.", default_value = "0.005", parse(try_from_str = parse_btc))] - max_buy: Amount, - #[structopt( - long = "ask-spread", - help = "The spread in percent that should be applied to the asking price.", - 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." @@ -124,7 +113,3 @@ pub struct RecoverCommandParams { )] pub force: bool, } - -fn parse_btc(s: &str) -> Result { - Amount::from_str_in(s, Denomination::Bitcoin) -} diff --git a/swap/src/asb/config.rs b/swap/src/asb/config.rs index accec056..c8859f7e 100644 --- a/swap/src/asb/config.rs +++ b/swap/src/asb/config.rs @@ -1,10 +1,12 @@ use crate::fs::{ensure_directory_exists, system_config_dir, system_data_dir}; use crate::tor::{DEFAULT_CONTROL_PORT, DEFAULT_SOCKS5_PORT}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use config::ConfigError; use dialoguer::theme::ColorfulTheme; use dialoguer::Input; use libp2p::core::Multiaddr; +use rust_decimal::prelude::FromPrimitive; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use std::ffi::OsStr; use std::fs; @@ -18,6 +20,9 @@ const DEFAULT_ELECTRUM_RPC_URL: &str = "ssl://electrum.blockstream.info:60002"; const DEFAULT_MONERO_WALLET_RPC_TESTNET_URL: &str = "http://127.0.0.1:38083/json_rpc"; const DEFAULT_BITCOIN_CONFIRMATION_TARGET: usize = 3; +const DEFAULT_MAX_BUY_AMOUNT: f64 = 0.02f64; +const DEFAULT_SPREAD: f64 = 0.02f64; + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] pub struct Config { pub data: Data, @@ -25,6 +30,7 @@ pub struct Config { pub bitcoin: Bitcoin, pub monero: Monero, pub tor: TorConf, + pub maker: Maker, } impl Config { @@ -72,6 +78,14 @@ pub struct TorConf { pub socks5_port: u16, } +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Maker { + #[serde(with = "::bitcoin::util::amount::serde::as_btc")] + pub max_buy_btc: bitcoin::Amount, + pub ask_spread: Decimal, +} + impl Default for TorConf { fn default() -> Self { Self { @@ -186,6 +200,21 @@ pub fn query_user_for_initial_testnet_config() -> Result { .default(DEFAULT_SOCKS5_PORT.to_owned()) .interact_text()?; + let max_buy = Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Enter maximum Bitcoin amount you are willing to accept per swap or hit enter to use default.") + .default(DEFAULT_MAX_BUY_AMOUNT) + .interact_text()?; + let max_buy = bitcoin::Amount::from_btc(max_buy)?; + + let ask_spread = Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Enter spread (in percent; value between 0.x and 1.0) to be used on top of the market rate or hit enter to use default.") + .default(DEFAULT_SPREAD) + .interact_text()?; + if !(0.0..=1.0).contains(&ask_spread) { + bail!(format!("Invalid spread {}. For the spread value floating point number in interval [0..1] are allowed.", ask_spread)) + } + let ask_spread = Decimal::from_f64(ask_spread).context("Unable to parse spread")?; + println!(); Ok(Config { @@ -204,6 +233,10 @@ pub fn query_user_for_initial_testnet_config() -> Result { control_port: tor_control_port, socks5_port: tor_socks5_port, }, + maker: Maker { + max_buy_btc: max_buy, + ask_spread, + }, }) } @@ -237,6 +270,10 @@ mod tests { wallet_rpc_url: Url::from_str(DEFAULT_MONERO_WALLET_RPC_TESTNET_URL).unwrap(), }, tor: Default::default(), + maker: Maker { + max_buy_btc: bitcoin::Amount::from_btc(DEFAULT_MAX_BUY_AMOUNT).unwrap(), + ask_spread: Decimal::from_f64(DEFAULT_SPREAD).unwrap(), + }, }; initial_setup(config_path.clone(), || Ok(expected.clone())).unwrap(); diff --git a/swap/src/bin/asb.rs b/swap/src/bin/asb.rs index 55158343..927403fd 100644 --- a/swap/src/bin/asb.rs +++ b/swap/src/bin/asb.rs @@ -79,11 +79,7 @@ async fn main() -> Result<()> { let env_config = env::Testnet::get_config(); match opt.cmd { - Command::Start { - max_buy, - ask_spread, - resume_only, - } => { + Command::Start { resume_only } => { let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; let monero_wallet = init_monero_wallet(&config, env_config).await?; @@ -122,12 +118,12 @@ async fn main() -> Result<()> { 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 kraken_rate = KrakenRate::new(config.maker.ask_spread, kraken_price_updates); let mut swarm = swarm::alice( &seed, current_balance, lock_fee, - max_buy, + config.maker.max_buy_btc, kraken_rate.clone(), resume_only, )?; @@ -144,7 +140,7 @@ async fn main() -> Result<()> { Arc::new(monero_wallet), Arc::new(db), kraken_rate, - max_buy, + config.maker.max_buy_btc, ) .unwrap(); From 6d3cf0af91e40466258f378ad62f787caa45131b Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Fri, 7 May 2021 16:12:16 +1000 Subject: [PATCH 3/4] Include too low balance into Alice's error --- swap/src/protocol/alice/spot_price.rs | 52 ++++++++++++++++++++------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/swap/src/protocol/alice/spot_price.rs b/swap/src/protocol/alice/spot_price.rs index 032d9de5..52a63ef0 100644 --- a/swap/src/protocol/alice/spot_price.rs +++ b/swap/src/protocol/alice/spot_price.rs @@ -183,7 +183,10 @@ where let xmr_lock_fees = self.lock_fee; if xmr_balance < xmr + xmr_lock_fees { - self.decline(peer, channel, Error::BalanceTooLow { buy: btc }); + self.decline(peer, channel, Error::BalanceTooLow { + balance: xmr_balance, + buy: btc, + }); return; } @@ -220,8 +223,11 @@ pub enum Error { 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("Balance {balance} too low to fulfill swapping {buy}")] + BalanceTooLow { + balance: monero::Amount, + buy: bitcoin::Amount, + }, #[error("Failed to fetch latest rate")] LatestRateFetchFailed(#[source] Box), @@ -238,7 +244,7 @@ impl Error { max: *max, buy: *buy, }, - Error::BalanceTooLow { buy } => spot_price::Error::BalanceTooLow { buy: *buy }, + Error::BalanceTooLow { buy, .. } => spot_price::Error::BalanceTooLow { buy: *buy }, Error::LatestRateFetchFailed(_) | Error::SellQuoteCalculationFailed(_) => { spot_price::Error::Other } @@ -296,7 +302,10 @@ mod tests { test.send_request(request); test.assert_error( - alice::spot_price::Error::BalanceTooLow { buy: btc_to_swap }, + alice::spot_price::Error::BalanceTooLow { + balance: monero::Amount::ZERO, + buy: btc_to_swap, + }, bob::spot_price::Error::BalanceTooLow { buy: btc_to_swap }, ) .await; @@ -323,7 +332,10 @@ mod tests { test.send_request(request); test.assert_error( - alice::spot_price::Error::BalanceTooLow { buy: btc_to_swap }, + alice::spot_price::Error::BalanceTooLow { + balance: monero::Amount::ZERO, + buy: btc_to_swap, + }, bob::spot_price::Error::BalanceTooLow { buy: btc_to_swap }, ) .await; @@ -331,8 +343,12 @@ mod tests { #[tokio::test] async fn given_alice_has_insufficient_balance_because_of_lock_fee_then_returns_error() { + let balance = monero::Amount::from_monero(1.0).unwrap(); + let mut test = SpotPriceTest::setup( - AliceBehaviourValues::default().with_lock_fee(monero::Amount::from_piconero(1)), + AliceBehaviourValues::default() + .with_balance(balance) + .with_lock_fee(monero::Amount::from_piconero(1)), ) .await; @@ -342,7 +358,10 @@ mod tests { test.send_request(request); test.assert_error( - alice::spot_price::Error::BalanceTooLow { buy: btc_to_swap }, + alice::spot_price::Error::BalanceTooLow { + balance, + buy: btc_to_swap, + }, bob::spot_price::Error::BalanceTooLow { buy: btc_to_swap }, ) .await; @@ -508,10 +527,19 @@ mod tests { // 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::BalanceTooLow { + balance: balance1, + buy: buy1, + }, + alice::spot_price::Error::BalanceTooLow { + balance: balance2, + buy: buy2, + }, + ) => { + assert_eq!(balance1, balance2); + assert_eq!(buy1, buy2); + } + ( alice::spot_price::Error::MaxBuyAmountExceeded { .. }, alice::spot_price::Error::MaxBuyAmountExceeded { .. }, ) From 652aae95903d28072a873f1a65aaeda8df890b2d Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Fri, 7 May 2021 19:06:58 +1000 Subject: [PATCH 4/4] Introduce a minimum buy amount Introduces a minimum buy Bitcoin amount similar to the maximum amount already present. For the CLI the minimum amount is enforced by waiting until at least the minimum is available as max-giveable amount. --- CHANGELOG.md | 4 +- swap/src/asb/config.rs | 11 ++ swap/src/bin/asb.rs | 2 + swap/src/bin/swap.rs | 236 +++++++++++++++++++------- swap/src/network/quote.rs | 3 + swap/src/network/spot_price.rs | 20 ++- swap/src/network/swarm.rs | 10 +- swap/src/protocol/alice/behaviour.rs | 2 + swap/src/protocol/alice/event_loop.rs | 16 +- swap/src/protocol/alice/spot_price.rs | 77 ++++++++- swap/src/protocol/bob/spot_price.rs | 14 +- swap/tests/harness/mod.rs | 5 +- 12 files changed, 319 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaea6a7f..f499a386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 By default we wait for finality of the redeem transaction; this can be disabled by setting `--do-not-await-finality`. - 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. +- A minimum accepted Bitcoin amount for the ASB similar to the maximum amount already present. + For the CLI the minimum amount is enforced by waiting until at least the minimum is available as max-giveable amount. ### Fixed @@ -43,7 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- The ASB's `--max-buy` and `ask-spread` parameter were removed in favour of entries in the config file. +- The ASB's `--max-buy` and `ask-spread` parameter were removed in favour of entries in the config file. The initial setup includes setting these two values now. ## [0.5.0] - 2021-04-17 diff --git a/swap/src/asb/config.rs b/swap/src/asb/config.rs index c8859f7e..24cecf2f 100644 --- a/swap/src/asb/config.rs +++ b/swap/src/asb/config.rs @@ -20,6 +20,7 @@ const DEFAULT_ELECTRUM_RPC_URL: &str = "ssl://electrum.blockstream.info:60002"; const DEFAULT_MONERO_WALLET_RPC_TESTNET_URL: &str = "http://127.0.0.1:38083/json_rpc"; const DEFAULT_BITCOIN_CONFIRMATION_TARGET: usize = 3; +const DEFAULT_MIN_BUY_AMOUNT: f64 = 0.002f64; const DEFAULT_MAX_BUY_AMOUNT: f64 = 0.02f64; const DEFAULT_SPREAD: f64 = 0.02f64; @@ -81,6 +82,8 @@ pub struct TorConf { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Maker { + #[serde(with = "::bitcoin::util::amount::serde::as_btc")] + pub min_buy_btc: bitcoin::Amount, #[serde(with = "::bitcoin::util::amount::serde::as_btc")] pub max_buy_btc: bitcoin::Amount, pub ask_spread: Decimal, @@ -200,6 +203,12 @@ pub fn query_user_for_initial_testnet_config() -> Result { .default(DEFAULT_SOCKS5_PORT.to_owned()) .interact_text()?; + let min_buy = Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Enter minimum Bitcoin amount you are willing to accept per swap or hit enter to use default.") + .default(DEFAULT_MIN_BUY_AMOUNT) + .interact_text()?; + let min_buy = bitcoin::Amount::from_btc(min_buy)?; + let max_buy = Input::with_theme(&ColorfulTheme::default()) .with_prompt("Enter maximum Bitcoin amount you are willing to accept per swap or hit enter to use default.") .default(DEFAULT_MAX_BUY_AMOUNT) @@ -234,6 +243,7 @@ pub fn query_user_for_initial_testnet_config() -> Result { socks5_port: tor_socks5_port, }, maker: Maker { + min_buy_btc: min_buy, max_buy_btc: max_buy, ask_spread, }, @@ -271,6 +281,7 @@ mod tests { }, tor: Default::default(), maker: Maker { + min_buy_btc: bitcoin::Amount::from_btc(DEFAULT_MIN_BUY_AMOUNT).unwrap(), max_buy_btc: bitcoin::Amount::from_btc(DEFAULT_MAX_BUY_AMOUNT).unwrap(), ask_spread: Decimal::from_f64(DEFAULT_SPREAD).unwrap(), }, diff --git a/swap/src/bin/asb.rs b/swap/src/bin/asb.rs index 927403fd..96359070 100644 --- a/swap/src/bin/asb.rs +++ b/swap/src/bin/asb.rs @@ -123,6 +123,7 @@ async fn main() -> Result<()> { &seed, current_balance, lock_fee, + config.maker.min_buy_btc, config.maker.max_buy_btc, kraken_rate.clone(), resume_only, @@ -140,6 +141,7 @@ async fn main() -> Result<()> { Arc::new(monero_wallet), Arc::new(db), kraken_rate, + config.maker.min_buy_btc, config.maker.max_buy_btc, ) .unwrap(); diff --git a/swap/src/bin/swap.rs b/swap/src/bin/swap.rs index ef337512..825b8955 100644 --- a/swap/src/bin/swap.rs +++ b/swap/src/bin/swap.rs @@ -20,7 +20,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use structopt::StructOpt; -use swap::bitcoin::{Amount, TxLock}; +use swap::bitcoin::TxLock; use swap::cli::command::{Arguments, Command, MoneroParams}; use swap::database::Database; use swap::env::{Config, GetConfig}; @@ -94,23 +94,19 @@ async fn main() -> Result<()> { EventLoop::new(swap_id, swarm, alice_peer_id, bitcoin_wallet.clone())?; let event_loop = tokio::spawn(event_loop.run()); - let send_bitcoin = determine_btc_to_swap( + let max_givable = || bitcoin_wallet.max_giveable(TxLock::script_size()); + let (send_bitcoin, fees) = determine_btc_to_swap( event_loop_handle.request_quote(), - bitcoin_wallet.balance(), + max_givable().await?, bitcoin_wallet.new_address(), - async { - while bitcoin_wallet.balance().await? == Amount::ZERO { - bitcoin_wallet.sync().await?; - - tokio::time::sleep(Duration::from_secs(1)).await; - } - - bitcoin_wallet.balance().await - }, - bitcoin_wallet.max_giveable(TxLock::script_size()), + || bitcoin_wallet.balance(), + max_givable, + || bitcoin_wallet.sync(), ) .await?; + info!("Swapping {} with {} fees", send_bitcoin, fees); + db.insert_peer_id(swap_id, alice_peer_id).await?; let swap = Swap::new( @@ -332,51 +328,83 @@ async fn init_monero_wallet( Ok((monero_wallet, monero_wallet_rpc_process)) } -async fn determine_btc_to_swap( - request_quote: impl Future>, - initial_balance: impl Future>, +async fn determine_btc_to_swap( + bid_quote: impl Future>, + mut current_maximum_giveable: bitcoin::Amount, get_new_address: impl Future>, - wait_for_deposit: impl Future>, - max_giveable: impl Future>, -) -> Result { + balance: FB, + max_giveable: FMG, + sync: FS, +) -> Result<(bitcoin::Amount, bitcoin::Amount)> +where + TB: Future>, + FB: Fn() -> TB, + TMG: Future>, + FMG: Fn() -> TMG, + TS: Future>, + FS: Fn() -> TS, +{ debug!("Requesting quote"); - - let bid_quote = request_quote.await?; - + let bid_quote = bid_quote.await?; info!("Received quote: 1 XMR ~ {}", bid_quote.price); - // TODO: Also wait for more funds if balance < dust - let initial_balance = initial_balance.await?; + let max_giveable = if current_maximum_giveable == bitcoin::Amount::ZERO + || current_maximum_giveable < bid_quote.min_quantity + { + let deposit_address = get_new_address.await?; + let minimum_amount = bid_quote.min_quantity; + let maximum_amount = bid_quote.max_quantity; - let balance = if initial_balance == Amount::ZERO { info!( - "Please deposit the BTC you want to swap to {} (max {})", - get_new_address.await?, - bid_quote.max_quantity + %deposit_address, + %current_maximum_giveable, + %minimum_amount, + %maximum_amount, + "Please deposit BTC you want to swap to", ); - let new_balance = wait_for_deposit - .await - .context("Failed to wait for Bitcoin deposit")?; + loop { + sync().await?; - info!("Received {}", new_balance); - new_balance + let new_max_givable = max_giveable().await?; + + if new_max_givable != current_maximum_giveable { + current_maximum_giveable = new_max_givable; + + let new_balance = balance().await?; + tracing::info!( + %new_balance, + %current_maximum_giveable, + "Received BTC", + ); + + if current_maximum_giveable >= bid_quote.min_quantity { + break; + } else { + tracing::info!( + %minimum_amount, + %deposit_address, + "Please deposit more, not enough BTC to trigger swap with", + ); + } + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } + + current_maximum_giveable } else { - info!("Found {} in wallet", initial_balance); - initial_balance + current_maximum_giveable }; - let max_giveable = max_giveable - .await - .context("Failed to compute max 'giveable' Bitcoin amount")?; + let balance = balance().await?; let fees = balance - max_giveable; let max_accepted = bid_quote.max_quantity; let btc_swap_amount = min(max_giveable, max_accepted); - info!("Swapping {} with {} fees", btc_swap_amount, fees); - Ok(btc_swap_amount) + Ok((btc_swap_amount, fees)) } #[cfg(test)] @@ -390,74 +418,162 @@ mod tests { 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( + let (amount, fees) = determine_btc_to_swap( async { Ok(quote_with_max(0.01)) }, - async { Ok(Amount::ZERO) }, + Amount::ZERO, get_dummy_address(), - async { Ok(Amount::from_btc(0.0001)?) }, - async { Ok(Amount::from_btc(0.00009)?) }, + || async { Ok(Amount::from_btc(0.001)?) }, + || async { Ok(Amount::from_btc(0.0009)?) }, + || async { Ok(()) }, ) .await .unwrap(); - assert_eq!(amount, Amount::from_btc(0.00009).unwrap()) + let expected_amount = Amount::from_btc(0.0009).unwrap(); + let expected_fees = Amount::from_btc(0.0001).unwrap(); + + assert_eq!((amount, fees), (expected_amount, expected_fees)) } #[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( + let (amount, fees) = determine_btc_to_swap( async { Ok(quote_with_max(0.01)) }, - async { Ok(Amount::ZERO) }, + Amount::ZERO, get_dummy_address(), - async { Ok(Amount::from_btc(0.1)?) }, - async { Ok(Amount::from_btc(0.09)?) }, + || async { Ok(Amount::from_btc(0.1001)?) }, + || async { Ok(Amount::from_btc(0.1)?) }, + || async { Ok(()) }, ) .await .unwrap(); - assert_eq!(amount, Amount::from_btc(0.01).unwrap()) + let expected_amount = Amount::from_btc(0.01).unwrap(); + let expected_fees = Amount::from_btc(0.0001).unwrap(); + + assert_eq!((amount, fees), (expected_amount, expected_fees)) } #[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( + let (amount, fees) = determine_btc_to_swap( async { Ok(quote_with_max(0.01)) }, - async { Ok(Amount::from_btc(0.005)?) }, + Amount::from_btc(0.0049).unwrap(), 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)?) }, + || async { Ok(Amount::from_btc(0.005)?) }, + || async { panic!("should not wait for deposit when initial balance > 0") }, + || async { Ok(()) }, ) .await .unwrap(); - assert_eq!(amount, Amount::from_btc(0.0049).unwrap()) + let expected_amount = Amount::from_btc(0.0049).unwrap(); + let expected_fees = Amount::from_btc(0.0001).unwrap(); + + assert_eq!((amount, fees), (expected_amount, expected_fees)) } #[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( + let (amount, fees) = determine_btc_to_swap( async { Ok(quote_with_max(0.01)) }, - async { Ok(Amount::from_btc(0.1)?) }, + Amount::from_btc(0.1).unwrap(), 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)?) }, + || async { Ok(Amount::from_btc(0.1001)?) }, + || async { panic!("should not wait for deposit when initial balance > 0") }, + || async { Ok(()) }, ) .await .unwrap(); - assert_eq!(amount, Amount::from_btc(0.01).unwrap()) + let expected_amount = Amount::from_btc(0.01).unwrap(); + let expected_fees = Amount::from_btc(0.0001).unwrap(); + + assert_eq!((amount, fees), (expected_amount, expected_fees)) + } + + #[tokio::test] + async fn given_no_initial_balance_then_min_wait_for_sufficient_deposit() { + let _guard = subscriber::set_default(tracing_subscriber::fmt().with_test_writer().finish()); + + let (amount, fees) = determine_btc_to_swap( + async { Ok(quote_with_min(0.01)) }, + Amount::ZERO, + get_dummy_address(), + || async { Ok(Amount::from_btc(0.0101)?) }, + || async { Ok(Amount::from_btc(0.01)?) }, + || async { Ok(()) }, + ) + .await + .unwrap(); + + let expected_amount = Amount::from_btc(0.01).unwrap(); + let expected_fees = Amount::from_btc(0.0001).unwrap(); + + assert_eq!((amount, fees), (expected_amount, expected_fees)) + } + + #[tokio::test] + async fn given_balance_less_then_min_wait_for_sufficient_deposit() { + let _guard = subscriber::set_default(tracing_subscriber::fmt().with_test_writer().finish()); + + let (amount, fees) = determine_btc_to_swap( + async { Ok(quote_with_min(0.01)) }, + Amount::from_btc(0.0001).unwrap(), + get_dummy_address(), + || async { Ok(Amount::from_btc(0.0101)?) }, + || async { Ok(Amount::from_btc(0.01)?) }, + || async { Ok(()) }, + ) + .await + .unwrap(); + + let expected_amount = Amount::from_btc(0.01).unwrap(); + let expected_fees = Amount::from_btc(0.0001).unwrap(); + + assert_eq!((amount, fees), (expected_amount, expected_fees)) + } + + #[tokio::test] + async fn given_no_initial_balance_and_transfers_less_than_min_keep_waiting() { + let _guard = subscriber::set_default(tracing_subscriber::fmt().with_test_writer().finish()); + + let error = tokio::time::timeout( + Duration::from_secs(1), + determine_btc_to_swap( + async { Ok(quote_with_min(0.1)) }, + Amount::ZERO, + get_dummy_address(), + || async { Ok(Amount::from_btc(0.0101)?) }, + || async { Ok(Amount::from_btc(0.01)?) }, + || async { Ok(()) }, + ), + ) + .await + .unwrap_err(); + + assert!(matches!(error, tokio::time::error::Elapsed { .. })) } fn quote_with_max(btc: f64) -> BidQuote { BidQuote { price: Amount::from_btc(0.001).unwrap(), max_quantity: Amount::from_btc(btc).unwrap(), + min_quantity: Amount::ZERO, + } + } + + fn quote_with_min(btc: f64) -> BidQuote { + BidQuote { + price: Amount::from_btc(0.001).unwrap(), + max_quantity: Amount::max_value(), + min_quantity: Amount::from_btc(btc).unwrap(), } } diff --git a/swap/src/network/quote.rs b/swap/src/network/quote.rs index 1920ae0e..7fb999ff 100644 --- a/swap/src/network/quote.rs +++ b/swap/src/network/quote.rs @@ -30,6 +30,9 @@ 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 minimum quantity the maker is willing to buy. + #[serde(with = "::bitcoin::util::amount::serde::as_sat")] + pub min_quantity: bitcoin::Amount, /// The maximum quantity the maker is willing to buy. #[serde(with = "::bitcoin::util::amount::serde::as_sat")] pub max_quantity: bitcoin::Amount, diff --git a/swap/src/network/spot_price.rs b/swap/src/network/spot_price.rs index e0c5c29b..84838c27 100644 --- a/swap/src/network/spot_price.rs +++ b/swap/src/network/spot_price.rs @@ -43,7 +43,13 @@ pub enum Response { #[derive(Clone, Debug, Serialize, Deserialize)] pub enum Error { NoSwapsAccepted, - MaxBuyAmountExceeded { + AmountBelowMinimum { + #[serde(with = "::bitcoin::util::amount::serde::as_sat")] + min: bitcoin::Amount, + #[serde(with = "::bitcoin::util::amount::serde::as_sat")] + buy: bitcoin::Amount, + }, + AmountAboveMaximum { #[serde(with = "::bitcoin::util::amount::serde::as_sat")] max: bitcoin::Amount, #[serde(with = "::bitcoin::util::amount::serde::as_sat")] @@ -74,8 +80,16 @@ mod tests { 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 { + let error = r#"{"Error":{"AmountBelowMinimum":{"min":0,"buy":0}}}"#.to_string(); + let serialized = serde_json::to_string(&Response::Error(Error::AmountBelowMinimum { + min: Default::default(), + buy: Default::default(), + })) + .unwrap(); + assert_eq!(error, serialized); + + let error = r#"{"Error":{"AmountAboveMaximum":{"max":0,"buy":0}}}"#.to_string(); + let serialized = serde_json::to_string(&Response::Error(Error::AmountAboveMaximum { max: Default::default(), buy: Default::default(), })) diff --git a/swap/src/network/swarm.rs b/swap/src/network/swarm.rs index 818a1210..8460e6ee 100644 --- a/swap/src/network/swarm.rs +++ b/swap/src/network/swarm.rs @@ -12,6 +12,7 @@ pub fn alice( seed: &Seed, balance: monero::Amount, lock_fee: monero::Amount, + min_buy: bitcoin::Amount, max_buy: bitcoin::Amount, latest_rate: LR, resume_only: bool, @@ -21,7 +22,14 @@ where { with_clear_net( seed, - alice::Behaviour::new(balance, lock_fee, max_buy, latest_rate, resume_only), + alice::Behaviour::new( + balance, + lock_fee, + min_buy, + max_buy, + latest_rate, + resume_only, + ), ) } diff --git a/swap/src/protocol/alice/behaviour.rs b/swap/src/protocol/alice/behaviour.rs index 08c87b45..7e036040 100644 --- a/swap/src/protocol/alice/behaviour.rs +++ b/swap/src/protocol/alice/behaviour.rs @@ -84,6 +84,7 @@ where pub fn new( balance: monero::Amount, lock_fee: monero::Amount, + min_buy: bitcoin::Amount, max_buy: bitcoin::Amount, latest_rate: LR, resume_only: bool, @@ -93,6 +94,7 @@ where spot_price: spot_price::Behaviour::new( balance, lock_fee, + min_buy, max_buy, latest_rate, resume_only, diff --git a/swap/src/protocol/alice/event_loop.rs b/swap/src/protocol/alice/event_loop.rs index e6b21304..36a2751c 100644 --- a/swap/src/protocol/alice/event_loop.rs +++ b/swap/src/protocol/alice/event_loop.rs @@ -43,6 +43,7 @@ where monero_wallet: Arc, db: Arc, latest_rate: LR, + min_buy: bitcoin::Amount, max_buy: bitcoin::Amount, swap_sender: mpsc::Sender, @@ -74,6 +75,7 @@ where monero_wallet: Arc, db: Arc, latest_rate: LR, + min_buy: bitcoin::Amount, max_buy: bitcoin::Amount, ) -> Result<(Self, mpsc::Receiver)> { let swap_channel = MpscChannels::default(); @@ -86,6 +88,7 @@ where db, latest_rate, swap_sender: swap_channel.sender, + min_buy, max_buy, recv_encrypted_signature: Default::default(), inflight_encrypted_signatures: Default::default(), @@ -206,7 +209,9 @@ where } SwarmEvent::Behaviour(OutEvent::SwapRequestDeclined { peer, error }) => { match error { - Error::ResumeOnlyMode | Error::MaxBuyAmountExceeded { .. } => { + Error::ResumeOnlyMode + | Error::AmountBelowMinimum { .. } + | Error::AmountAboveMaximum { .. } => { tracing::warn!(%peer, "Ignoring spot price request because: {}", error); } Error::BalanceTooLow { .. } @@ -228,7 +233,7 @@ where } } - let quote = match self.make_quote(self.max_buy).await { + let quote = match self.make_quote(self.min_buy, self.max_buy).await { Ok(quote) => quote, Err(e) => { tracing::warn!(%peer, "Failed to make quote: {:#}", e); @@ -355,7 +360,11 @@ where } } - async fn make_quote(&mut self, max_buy: bitcoin::Amount) -> Result { + async fn make_quote( + &mut self, + min_buy: bitcoin::Amount, + max_buy: bitcoin::Amount, + ) -> Result { let rate = self .latest_rate .latest_rate() @@ -363,6 +372,7 @@ where Ok(BidQuote { price: rate.ask().context("Failed to compute asking price")?, + min_quantity: min_buy, max_quantity: max_buy, }) } diff --git a/swap/src/protocol/alice/spot_price.rs b/swap/src/protocol/alice/spot_price.rs index 52a63ef0..81b0bdb8 100644 --- a/swap/src/protocol/alice/spot_price.rs +++ b/swap/src/protocol/alice/spot_price.rs @@ -44,6 +44,8 @@ where #[behaviour(ignore)] lock_fee: monero::Amount, #[behaviour(ignore)] + min_buy: bitcoin::Amount, + #[behaviour(ignore)] max_buy: bitcoin::Amount, #[behaviour(ignore)] latest_rate: LR, @@ -62,6 +64,7 @@ where pub fn new( balance: monero::Amount, lock_fee: monero::Amount, + min_buy: bitcoin::Amount, max_buy: bitcoin::Amount, latest_rate: LR, resume_only: bool, @@ -75,6 +78,7 @@ where events: Default::default(), balance, lock_fee, + min_buy, max_buy, latest_rate, resume_only, @@ -156,8 +160,17 @@ where } let btc = request.btc; + + if btc < self.min_buy { + self.decline(peer, channel, Error::AmountBelowMinimum { + min: self.min_buy, + buy: btc, + }); + return; + } + if btc > self.max_buy { - self.decline(peer, channel, Error::MaxBuyAmountExceeded { + self.decline(peer, channel, Error::AmountAboveMaximum { max: self.max_buy, buy: btc, }); @@ -218,8 +231,13 @@ impl From for alice::OutEvent { pub enum Error { #[error("ASB is running in resume-only mode")] ResumeOnlyMode, - #[error("Maximum buy {max} exceeded {buy}")] - MaxBuyAmountExceeded { + #[error("Amount {buy} below minimum {min}")] + AmountBelowMinimum { + min: bitcoin::Amount, + buy: bitcoin::Amount, + }, + #[error("Amount {buy} above maximum {max}")] + AmountAboveMaximum { max: bitcoin::Amount, buy: bitcoin::Amount, }, @@ -240,7 +258,11 @@ 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 { + Error::AmountBelowMinimum { min, buy } => spot_price::Error::AmountBelowMinimum { + min: *min, + buy: *buy, + }, + Error::AmountAboveMaximum { max, buy } => spot_price::Error::AmountAboveMaximum { max: *max, buy: *buy, }, @@ -268,6 +290,7 @@ mod tests { Self { balance: monero::Amount::from_monero(1.0).unwrap(), lock_fee: monero::Amount::ZERO, + min_buy: bitcoin::Amount::from_btc(0.001).unwrap(), max_buy: bitcoin::Amount::from_btc(0.01).unwrap(), rate: TestRate::default(), // 0.01 resume_only: false, @@ -368,7 +391,32 @@ mod tests { } #[tokio::test] - async fn given_max_buy_exceeded_then_returns_error() { + async fn given_below_min_buy_then_returns_error() { + let min_buy = bitcoin::Amount::from_btc(0.001).unwrap(); + + let mut test = + SpotPriceTest::setup(AliceBehaviourValues::default().with_min_buy(min_buy)).await; + + let btc_to_swap = bitcoin::Amount::from_btc(0.0001).unwrap(); + + let request = spot_price::Request { btc: btc_to_swap }; + + test.send_request(request); + test.assert_error( + alice::spot_price::Error::AmountBelowMinimum { + buy: btc_to_swap, + min: min_buy, + }, + bob::spot_price::Error::AmountBelowMinimum { + buy: btc_to_swap, + min: min_buy, + }, + ) + .await; + } + + #[tokio::test] + async fn given_above_max_buy_then_returns_error() { let max_buy = bitcoin::Amount::from_btc(0.001).unwrap(); let mut test = @@ -380,11 +428,11 @@ mod tests { test.send_request(request); test.assert_error( - alice::spot_price::Error::MaxBuyAmountExceeded { + alice::spot_price::Error::AmountAboveMaximum { buy: btc_to_swap, max: max_buy, }, - bob::spot_price::Error::MaxBuyAmountExceeded { + bob::spot_price::Error::AmountAboveMaximum { buy: btc_to_swap, max: max_buy, }, @@ -461,6 +509,7 @@ mod tests { Behaviour::new( values.balance, values.lock_fee, + values.min_buy, values.max_buy, values.rate.clone(), values.resume_only, @@ -540,8 +589,12 @@ mod tests { assert_eq!(buy1, buy2); } ( - alice::spot_price::Error::MaxBuyAmountExceeded { .. }, - alice::spot_price::Error::MaxBuyAmountExceeded { .. }, + alice::spot_price::Error::AmountBelowMinimum { .. }, + alice::spot_price::Error::AmountBelowMinimum { .. }, + ) + | ( + alice::spot_price::Error::AmountAboveMaximum { .. }, + alice::spot_price::Error::AmountAboveMaximum { .. }, ) | ( alice::spot_price::Error::LatestRateFetchFailed(_), @@ -583,6 +636,7 @@ mod tests { struct AliceBehaviourValues { pub balance: monero::Amount, pub lock_fee: monero::Amount, + pub min_buy: bitcoin::Amount, pub max_buy: bitcoin::Amount, pub rate: TestRate, // 0.01 pub resume_only: bool, @@ -599,6 +653,11 @@ mod tests { self } + pub fn with_min_buy(mut self, min_buy: bitcoin::Amount) -> AliceBehaviourValues { + self.min_buy = min_buy; + self + } + pub fn with_max_buy(mut self, max_buy: bitcoin::Amount) -> AliceBehaviourValues { self.max_buy = max_buy; self diff --git a/swap/src/protocol/bob/spot_price.rs b/swap/src/protocol/bob/spot_price.rs index e2a4cef7..58d03643 100644 --- a/swap/src/protocol/bob/spot_price.rs +++ b/swap/src/protocol/bob/spot_price.rs @@ -41,8 +41,13 @@ crate::impl_from_rr_event!(SpotPriceOutEvent, OutEvent, PROTOCOL); 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 minimum configured buy limit is {min}")] + AmountBelowMinimum { + min: bitcoin::Amount, + buy: bitcoin::Amount, + }, #[error("Seller refused to buy {buy} because the maximum configured buy limit is {max}")] - MaxBuyAmountExceeded { + AmountAboveMaximum { max: bitcoin::Amount, buy: bitcoin::Amount, }, @@ -59,8 +64,11 @@ 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::AmountBelowMinimum { min, buy } => { + Error::AmountBelowMinimum { min, buy } + } + spot_price::Error::AmountAboveMaximum { max, buy } => { + Error::AmountAboveMaximum { 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 c70d979d..52e1e6f5 100644 --- a/swap/tests/harness/mod.rs +++ b/swap/tests/harness/mod.rs @@ -226,6 +226,7 @@ async fn start_alice( let current_balance = monero_wallet.get_balance().await.unwrap(); let lock_fee = monero_wallet.static_tx_fee_estimate(); + let min_buy = bitcoin::Amount::from_sat(u64::MIN); let max_buy = bitcoin::Amount::from_sat(u64::MAX); let latest_rate = FixedRate::default(); let resume_only = false; @@ -234,6 +235,7 @@ async fn start_alice( &seed, current_balance, lock_fee, + min_buy, max_buy, latest_rate, resume_only, @@ -248,7 +250,8 @@ async fn start_alice( monero_wallet, db, FixedRate::default(), - bitcoin::Amount::ONE_BTC, + min_buy, + max_buy, ) .unwrap();