481: Min buy amount r=da-kami a=da-kami



Co-authored-by: Daniel Karzel <daniel@comit.network>
This commit is contained in:
bors[bot] 2021-05-11 04:08:59 +00:00 committed by GitHub
commit 227c383d76
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 407 additions and 117 deletions

View File

@ -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`. 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. - 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. 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 ### Fixed
@ -41,6 +43,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 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 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 ## [0.5.0] - 2021-04-17
### Changed ### Changed

View File

@ -40,7 +40,7 @@ proptest = "1"
rand = "0.7" rand = "0.7"
rand_chacha = "0.2" rand_chacha = "0.2"
reqwest = { version = "0.11", features = [ "rustls-tls", "stream", "socks" ], default-features = false } 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" rust_decimal_macros = "1"
serde = { version = "1", features = [ "derive" ] } serde = { version = "1", features = [ "derive" ] }
serde_cbor = "0.11" serde_cbor = "0.11"

View File

@ -1,7 +1,5 @@
use crate::bitcoin::Amount; use crate::bitcoin::Amount;
use bitcoin::util::amount::ParseAmountError; use bitcoin::Address;
use bitcoin::{Address, Denomination};
use rust_decimal::Decimal;
use std::path::PathBuf; use std::path::PathBuf;
use uuid::Uuid; use uuid::Uuid;
@ -28,15 +26,6 @@ pub struct Arguments {
pub enum Command { pub enum Command {
#[structopt(about = "Main command to run the ASB.")] #[structopt(about = "Main command to run the ASB.")]
Start { 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( #[structopt(
long = "resume-only", long = "resume-only",
help = "For maintenance only. When set, no new swap requests will be accepted, but existing unfinished swaps will be resumed." 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, pub force: bool,
} }
fn parse_btc(s: &str) -> Result<Amount, ParseAmountError> {
Amount::from_str_in(s, Denomination::Bitcoin)
}

View File

@ -1,10 +1,12 @@
use crate::fs::{ensure_directory_exists, system_config_dir, system_data_dir}; use crate::fs::{ensure_directory_exists, system_config_dir, system_data_dir};
use crate::tor::{DEFAULT_CONTROL_PORT, DEFAULT_SOCKS5_PORT}; use crate::tor::{DEFAULT_CONTROL_PORT, DEFAULT_SOCKS5_PORT};
use anyhow::{Context, Result}; use anyhow::{bail, Context, Result};
use config::ConfigError; use config::ConfigError;
use dialoguer::theme::ColorfulTheme; use dialoguer::theme::ColorfulTheme;
use dialoguer::Input; use dialoguer::Input;
use libp2p::core::Multiaddr; use libp2p::core::Multiaddr;
use rust_decimal::prelude::FromPrimitive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs; use std::fs;
@ -18,6 +20,10 @@ 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_MONERO_WALLET_RPC_TESTNET_URL: &str = "http://127.0.0.1:38083/json_rpc";
const DEFAULT_BITCOIN_CONFIRMATION_TARGET: usize = 3; 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;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct Config { pub struct Config {
pub data: Data, pub data: Data,
@ -25,6 +31,7 @@ pub struct Config {
pub bitcoin: Bitcoin, pub bitcoin: Bitcoin,
pub monero: Monero, pub monero: Monero,
pub tor: TorConf, pub tor: TorConf,
pub maker: Maker,
} }
impl Config { impl Config {
@ -72,6 +79,16 @@ pub struct TorConf {
pub socks5_port: u16, 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 min_buy_btc: bitcoin::Amount,
#[serde(with = "::bitcoin::util::amount::serde::as_btc")]
pub max_buy_btc: bitcoin::Amount,
pub ask_spread: Decimal,
}
impl Default for TorConf { impl Default for TorConf {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -123,10 +140,11 @@ where
F: Fn() -> Result<Config>, F: Fn() -> Result<Config>,
{ {
info!("Config file not found, running initial setup..."); info!("Config file not found, running initial setup...");
ensure_directory_exists(config_path.as_path())?;
let initial_config = config_file()?; let initial_config = config_file()?;
let toml = toml::to_string(&initial_config)?; let toml = toml::to_string(&initial_config)?;
ensure_directory_exists(config_path.as_path())?;
fs::write(&config_path, toml)?; fs::write(&config_path, toml)?;
info!( info!(
@ -185,6 +203,27 @@ pub fn query_user_for_initial_testnet_config() -> Result<Config> {
.default(DEFAULT_SOCKS5_PORT.to_owned()) .default(DEFAULT_SOCKS5_PORT.to_owned())
.interact_text()?; .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)
.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!(); println!();
Ok(Config { Ok(Config {
@ -203,6 +242,11 @@ pub fn query_user_for_initial_testnet_config() -> Result<Config> {
control_port: tor_control_port, control_port: tor_control_port,
socks5_port: tor_socks5_port, socks5_port: tor_socks5_port,
}, },
maker: Maker {
min_buy_btc: min_buy,
max_buy_btc: max_buy,
ask_spread,
},
}) })
} }
@ -236,6 +280,11 @@ mod tests {
wallet_rpc_url: Url::from_str(DEFAULT_MONERO_WALLET_RPC_TESTNET_URL).unwrap(), wallet_rpc_url: Url::from_str(DEFAULT_MONERO_WALLET_RPC_TESTNET_URL).unwrap(),
}, },
tor: Default::default(), 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(),
},
}; };
initial_setup(config_path.clone(), || Ok(expected.clone())).unwrap(); initial_setup(config_path.clone(), || Ok(expected.clone())).unwrap();

View File

@ -79,11 +79,7 @@ async fn main() -> Result<()> {
let env_config = env::Testnet::get_config(); let env_config = env::Testnet::get_config();
match opt.cmd { match opt.cmd {
Command::Start { Command::Start { resume_only } => {
max_buy,
ask_spread,
resume_only,
} => {
let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?;
let monero_wallet = init_monero_wallet(&config, env_config).await?; let monero_wallet = init_monero_wallet(&config, env_config).await?;
@ -122,12 +118,13 @@ async fn main() -> Result<()> {
let current_balance = monero_wallet.get_balance().await?; let current_balance = monero_wallet.get_balance().await?;
let lock_fee = monero_wallet.static_tx_fee_estimate(); 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( let mut swarm = swarm::alice(
&seed, &seed,
current_balance, current_balance,
lock_fee, lock_fee,
max_buy, config.maker.min_buy_btc,
config.maker.max_buy_btc,
kraken_rate.clone(), kraken_rate.clone(),
resume_only, resume_only,
)?; )?;
@ -144,7 +141,8 @@ async fn main() -> Result<()> {
Arc::new(monero_wallet), Arc::new(monero_wallet),
Arc::new(db), Arc::new(db),
kraken_rate, kraken_rate,
max_buy, config.maker.min_buy_btc,
config.maker.max_buy_btc,
) )
.unwrap(); .unwrap();

View File

@ -20,7 +20,7 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use structopt::StructOpt; use structopt::StructOpt;
use swap::bitcoin::{Amount, TxLock}; use swap::bitcoin::TxLock;
use swap::cli::command::{Arguments, Command, MoneroParams}; use swap::cli::command::{Arguments, Command, MoneroParams};
use swap::database::Database; use swap::database::Database;
use swap::env::{Config, GetConfig}; use swap::env::{Config, GetConfig};
@ -94,23 +94,19 @@ async fn main() -> Result<()> {
EventLoop::new(swap_id, swarm, alice_peer_id, bitcoin_wallet.clone())?; EventLoop::new(swap_id, swarm, alice_peer_id, bitcoin_wallet.clone())?;
let event_loop = tokio::spawn(event_loop.run()); 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(), event_loop_handle.request_quote(),
bitcoin_wallet.balance(), max_givable().await?,
bitcoin_wallet.new_address(), bitcoin_wallet.new_address(),
async { || bitcoin_wallet.balance(),
while bitcoin_wallet.balance().await? == Amount::ZERO { max_givable,
bitcoin_wallet.sync().await?; || bitcoin_wallet.sync(),
tokio::time::sleep(Duration::from_secs(1)).await;
}
bitcoin_wallet.balance().await
},
bitcoin_wallet.max_giveable(TxLock::script_size()),
) )
.await?; .await?;
info!("Swapping {} with {} fees", send_bitcoin, fees);
db.insert_peer_id(swap_id, alice_peer_id).await?; db.insert_peer_id(swap_id, alice_peer_id).await?;
let swap = Swap::new( let swap = Swap::new(
@ -332,51 +328,83 @@ async fn init_monero_wallet(
Ok((monero_wallet, monero_wallet_rpc_process)) Ok((monero_wallet, monero_wallet_rpc_process))
} }
async fn determine_btc_to_swap( async fn determine_btc_to_swap<FB, TB, FMG, TMG, FS, TS>(
request_quote: impl Future<Output = Result<BidQuote>>, bid_quote: impl Future<Output = Result<BidQuote>>,
initial_balance: impl Future<Output = Result<bitcoin::Amount>>, mut current_maximum_giveable: bitcoin::Amount,
get_new_address: impl Future<Output = Result<bitcoin::Address>>, get_new_address: impl Future<Output = Result<bitcoin::Address>>,
wait_for_deposit: impl Future<Output = Result<bitcoin::Amount>>, balance: FB,
max_giveable: impl Future<Output = Result<bitcoin::Amount>>, max_giveable: FMG,
) -> Result<bitcoin::Amount> { sync: FS,
) -> Result<(bitcoin::Amount, bitcoin::Amount)>
where
TB: Future<Output = Result<bitcoin::Amount>>,
FB: Fn() -> TB,
TMG: Future<Output = Result<bitcoin::Amount>>,
FMG: Fn() -> TMG,
TS: Future<Output = Result<()>>,
FS: Fn() -> TS,
{
debug!("Requesting quote"); debug!("Requesting quote");
let bid_quote = bid_quote.await?;
let bid_quote = request_quote.await?;
info!("Received quote: 1 XMR ~ {}", bid_quote.price); info!("Received quote: 1 XMR ~ {}", bid_quote.price);
// TODO: Also wait for more funds if balance < dust let max_giveable = if current_maximum_giveable == bitcoin::Amount::ZERO
let initial_balance = initial_balance.await?; || 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!( info!(
"Please deposit the BTC you want to swap to {} (max {})", %deposit_address,
get_new_address.await?, %current_maximum_giveable,
bid_quote.max_quantity %minimum_amount,
%maximum_amount,
"Please deposit BTC you want to swap to",
); );
let new_balance = wait_for_deposit loop {
.await sync().await?;
.context("Failed to wait for Bitcoin deposit")?;
info!("Received {}", new_balance); let new_max_givable = max_giveable().await?;
new_balance
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 { } else {
info!("Found {} in wallet", initial_balance); current_maximum_giveable
initial_balance
}; };
let max_giveable = max_giveable let balance = balance().await?;
.await
.context("Failed to compute max 'giveable' Bitcoin amount")?;
let fees = balance - max_giveable; let fees = balance - max_giveable;
let max_accepted = bid_quote.max_quantity; let max_accepted = bid_quote.max_quantity;
let btc_swap_amount = min(max_giveable, max_accepted); 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)] #[cfg(test)]
@ -390,74 +418,162 @@ mod tests {
async fn given_no_balance_and_transfers_less_than_max_swaps_max_giveable() { 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 _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(quote_with_max(0.01)) },
async { Ok(Amount::ZERO) }, Amount::ZERO,
get_dummy_address(), get_dummy_address(),
async { Ok(Amount::from_btc(0.0001)?) }, || async { Ok(Amount::from_btc(0.001)?) },
async { Ok(Amount::from_btc(0.00009)?) }, || async { Ok(Amount::from_btc(0.0009)?) },
|| async { Ok(()) },
) )
.await .await
.unwrap(); .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] #[tokio::test]
async fn given_no_balance_and_transfers_more_then_swaps_max_quantity_from_quote() { 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 _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(quote_with_max(0.01)) },
async { Ok(Amount::ZERO) }, Amount::ZERO,
get_dummy_address(), get_dummy_address(),
async { Ok(Amount::from_btc(0.1)?) }, || async { Ok(Amount::from_btc(0.1001)?) },
async { Ok(Amount::from_btc(0.09)?) }, || async { Ok(Amount::from_btc(0.1)?) },
|| async { Ok(()) },
) )
.await .await
.unwrap(); .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] #[tokio::test]
async fn given_initial_balance_below_max_quantity_swaps_max_givable() { async fn given_initial_balance_below_max_quantity_swaps_max_givable() {
let _guard = subscriber::set_default(tracing_subscriber::fmt().with_test_writer().finish()); 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(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 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.005)?) },
async { Ok(Amount::from_btc(0.0049)?) }, || async { panic!("should not wait for deposit when initial balance > 0") },
|| async { Ok(()) },
) )
.await .await
.unwrap(); .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] #[tokio::test]
async fn given_initial_balance_above_max_quantity_swaps_max_quantity() { async fn given_initial_balance_above_max_quantity_swaps_max_quantity() {
let _guard = subscriber::set_default(tracing_subscriber::fmt().with_test_writer().finish()); 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(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 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.1001)?) },
async { Ok(Amount::from_btc(0.09)?) }, || async { panic!("should not wait for deposit when initial balance > 0") },
|| async { Ok(()) },
) )
.await .await
.unwrap(); .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 { fn quote_with_max(btc: f64) -> BidQuote {
BidQuote { BidQuote {
price: Amount::from_btc(0.001).unwrap(), price: Amount::from_btc(0.001).unwrap(),
max_quantity: Amount::from_btc(btc).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(),
} }
} }

View File

@ -30,6 +30,9 @@ pub struct BidQuote {
/// The price at which the maker is willing to buy at. /// The price at which the maker is willing to buy at.
#[serde(with = "::bitcoin::util::amount::serde::as_sat")] #[serde(with = "::bitcoin::util::amount::serde::as_sat")]
pub price: bitcoin::Amount, 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. /// The maximum quantity the maker is willing to buy.
#[serde(with = "::bitcoin::util::amount::serde::as_sat")] #[serde(with = "::bitcoin::util::amount::serde::as_sat")]
pub max_quantity: bitcoin::Amount, pub max_quantity: bitcoin::Amount,

View File

@ -43,7 +43,13 @@ pub enum Response {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Error { pub enum Error {
NoSwapsAccepted, 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")] #[serde(with = "::bitcoin::util::amount::serde::as_sat")]
max: bitcoin::Amount, max: bitcoin::Amount,
#[serde(with = "::bitcoin::util::amount::serde::as_sat")] #[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(); let serialized = serde_json::to_string(&Response::Error(Error::NoSwapsAccepted)).unwrap();
assert_eq!(error, serialized); assert_eq!(error, serialized);
let error = r#"{"Error":{"MaxBuyAmountExceeded":{"max":0,"buy":0}}}"#.to_string(); let error = r#"{"Error":{"AmountBelowMinimum":{"min":0,"buy":0}}}"#.to_string();
let serialized = serde_json::to_string(&Response::Error(Error::MaxBuyAmountExceeded { 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(), max: Default::default(),
buy: Default::default(), buy: Default::default(),
})) }))

View File

@ -12,6 +12,7 @@ pub fn alice<LR>(
seed: &Seed, seed: &Seed,
balance: monero::Amount, balance: monero::Amount,
lock_fee: monero::Amount, lock_fee: monero::Amount,
min_buy: bitcoin::Amount,
max_buy: bitcoin::Amount, max_buy: bitcoin::Amount,
latest_rate: LR, latest_rate: LR,
resume_only: bool, resume_only: bool,
@ -21,7 +22,14 @@ where
{ {
with_clear_net( with_clear_net(
seed, 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,
),
) )
} }

View File

@ -84,6 +84,7 @@ where
pub fn new( pub fn new(
balance: monero::Amount, balance: monero::Amount,
lock_fee: monero::Amount, lock_fee: monero::Amount,
min_buy: bitcoin::Amount,
max_buy: bitcoin::Amount, max_buy: bitcoin::Amount,
latest_rate: LR, latest_rate: LR,
resume_only: bool, resume_only: bool,
@ -93,6 +94,7 @@ where
spot_price: spot_price::Behaviour::new( spot_price: spot_price::Behaviour::new(
balance, balance,
lock_fee, lock_fee,
min_buy,
max_buy, max_buy,
latest_rate, latest_rate,
resume_only, resume_only,

View File

@ -43,6 +43,7 @@ where
monero_wallet: Arc<monero::Wallet>, monero_wallet: Arc<monero::Wallet>,
db: Arc<Database>, db: Arc<Database>,
latest_rate: LR, latest_rate: LR,
min_buy: bitcoin::Amount,
max_buy: bitcoin::Amount, max_buy: bitcoin::Amount,
swap_sender: mpsc::Sender<Swap>, swap_sender: mpsc::Sender<Swap>,
@ -74,6 +75,7 @@ where
monero_wallet: Arc<monero::Wallet>, monero_wallet: Arc<monero::Wallet>,
db: Arc<Database>, db: Arc<Database>,
latest_rate: LR, latest_rate: LR,
min_buy: bitcoin::Amount,
max_buy: bitcoin::Amount, max_buy: bitcoin::Amount,
) -> Result<(Self, mpsc::Receiver<Swap>)> { ) -> Result<(Self, mpsc::Receiver<Swap>)> {
let swap_channel = MpscChannels::default(); let swap_channel = MpscChannels::default();
@ -86,6 +88,7 @@ where
db, db,
latest_rate, latest_rate,
swap_sender: swap_channel.sender, swap_sender: swap_channel.sender,
min_buy,
max_buy, max_buy,
recv_encrypted_signature: Default::default(), recv_encrypted_signature: Default::default(),
inflight_encrypted_signatures: Default::default(), inflight_encrypted_signatures: Default::default(),
@ -206,7 +209,9 @@ where
} }
SwarmEvent::Behaviour(OutEvent::SwapRequestDeclined { peer, error }) => { SwarmEvent::Behaviour(OutEvent::SwapRequestDeclined { peer, error }) => {
match error { match error {
Error::ResumeOnlyMode | Error::MaxBuyAmountExceeded { .. } => { Error::ResumeOnlyMode
| Error::AmountBelowMinimum { .. }
| Error::AmountAboveMaximum { .. } => {
tracing::warn!(%peer, "Ignoring spot price request because: {}", error); tracing::warn!(%peer, "Ignoring spot price request because: {}", error);
} }
Error::BalanceTooLow { .. } 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, Ok(quote) => quote,
Err(e) => { Err(e) => {
tracing::warn!(%peer, "Failed to make quote: {:#}", 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<BidQuote> { async fn make_quote(
&mut self,
min_buy: bitcoin::Amount,
max_buy: bitcoin::Amount,
) -> Result<BidQuote> {
let rate = self let rate = self
.latest_rate .latest_rate
.latest_rate() .latest_rate()
@ -363,6 +372,7 @@ where
Ok(BidQuote { Ok(BidQuote {
price: rate.ask().context("Failed to compute asking price")?, price: rate.ask().context("Failed to compute asking price")?,
min_quantity: min_buy,
max_quantity: max_buy, max_quantity: max_buy,
}) })
} }

View File

@ -44,6 +44,8 @@ where
#[behaviour(ignore)] #[behaviour(ignore)]
lock_fee: monero::Amount, lock_fee: monero::Amount,
#[behaviour(ignore)] #[behaviour(ignore)]
min_buy: bitcoin::Amount,
#[behaviour(ignore)]
max_buy: bitcoin::Amount, max_buy: bitcoin::Amount,
#[behaviour(ignore)] #[behaviour(ignore)]
latest_rate: LR, latest_rate: LR,
@ -62,6 +64,7 @@ where
pub fn new( pub fn new(
balance: monero::Amount, balance: monero::Amount,
lock_fee: monero::Amount, lock_fee: monero::Amount,
min_buy: bitcoin::Amount,
max_buy: bitcoin::Amount, max_buy: bitcoin::Amount,
latest_rate: LR, latest_rate: LR,
resume_only: bool, resume_only: bool,
@ -75,6 +78,7 @@ where
events: Default::default(), events: Default::default(),
balance, balance,
lock_fee, lock_fee,
min_buy,
max_buy, max_buy,
latest_rate, latest_rate,
resume_only, resume_only,
@ -156,8 +160,17 @@ where
} }
let btc = request.btc; 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 { if btc > self.max_buy {
self.decline(peer, channel, Error::MaxBuyAmountExceeded { self.decline(peer, channel, Error::AmountAboveMaximum {
max: self.max_buy, max: self.max_buy,
buy: btc, buy: btc,
}); });
@ -183,7 +196,10 @@ where
let xmr_lock_fees = self.lock_fee; let xmr_lock_fees = self.lock_fee;
if xmr_balance < xmr + xmr_lock_fees { 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; return;
} }
@ -215,13 +231,21 @@ impl From<OutEvent> for alice::OutEvent {
pub enum Error { pub enum Error {
#[error("ASB is running in resume-only mode")] #[error("ASB is running in resume-only mode")]
ResumeOnlyMode, ResumeOnlyMode,
#[error("Maximum buy {max} exceeded {buy}")] #[error("Amount {buy} below minimum {min}")]
MaxBuyAmountExceeded { AmountBelowMinimum {
min: bitcoin::Amount,
buy: bitcoin::Amount,
},
#[error("Amount {buy} above maximum {max}")]
AmountAboveMaximum {
max: bitcoin::Amount, max: bitcoin::Amount,
buy: 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")] #[error("Balance {balance} too low to fulfill swapping {buy}")]
BalanceTooLow { buy: bitcoin::Amount }, BalanceTooLow {
balance: monero::Amount,
buy: bitcoin::Amount,
},
#[error("Failed to fetch latest rate")] #[error("Failed to fetch latest rate")]
LatestRateFetchFailed(#[source] Box<dyn std::error::Error + Send + 'static>), LatestRateFetchFailed(#[source] Box<dyn std::error::Error + Send + 'static>),
@ -234,11 +258,15 @@ impl Error {
pub fn to_error_response(&self) -> spot_price::Error { pub fn to_error_response(&self) -> spot_price::Error {
match self { match self {
Error::ResumeOnlyMode => spot_price::Error::NoSwapsAccepted, 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, max: *max,
buy: *buy, buy: *buy,
}, },
Error::BalanceTooLow { buy } => spot_price::Error::BalanceTooLow { buy: *buy }, Error::BalanceTooLow { buy, .. } => spot_price::Error::BalanceTooLow { buy: *buy },
Error::LatestRateFetchFailed(_) | Error::SellQuoteCalculationFailed(_) => { Error::LatestRateFetchFailed(_) | Error::SellQuoteCalculationFailed(_) => {
spot_price::Error::Other spot_price::Error::Other
} }
@ -262,6 +290,7 @@ mod tests {
Self { Self {
balance: monero::Amount::from_monero(1.0).unwrap(), balance: monero::Amount::from_monero(1.0).unwrap(),
lock_fee: monero::Amount::ZERO, lock_fee: monero::Amount::ZERO,
min_buy: bitcoin::Amount::from_btc(0.001).unwrap(),
max_buy: bitcoin::Amount::from_btc(0.01).unwrap(), max_buy: bitcoin::Amount::from_btc(0.01).unwrap(),
rate: TestRate::default(), // 0.01 rate: TestRate::default(), // 0.01
resume_only: false, resume_only: false,
@ -296,7 +325,10 @@ mod tests {
test.send_request(request); test.send_request(request);
test.assert_error( 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 }, bob::spot_price::Error::BalanceTooLow { buy: btc_to_swap },
) )
.await; .await;
@ -323,7 +355,10 @@ mod tests {
test.send_request(request); test.send_request(request);
test.assert_error( 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 }, bob::spot_price::Error::BalanceTooLow { buy: btc_to_swap },
) )
.await; .await;
@ -331,8 +366,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn given_alice_has_insufficient_balance_because_of_lock_fee_then_returns_error() { 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( 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; .await;
@ -342,14 +381,42 @@ mod tests {
test.send_request(request); test.send_request(request);
test.assert_error( 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 }, bob::spot_price::Error::BalanceTooLow { buy: btc_to_swap },
) )
.await; .await;
} }
#[tokio::test] #[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 max_buy = bitcoin::Amount::from_btc(0.001).unwrap();
let mut test = let mut test =
@ -361,11 +428,11 @@ mod tests {
test.send_request(request); test.send_request(request);
test.assert_error( test.assert_error(
alice::spot_price::Error::MaxBuyAmountExceeded { alice::spot_price::Error::AmountAboveMaximum {
buy: btc_to_swap, buy: btc_to_swap,
max: max_buy, max: max_buy,
}, },
bob::spot_price::Error::MaxBuyAmountExceeded { bob::spot_price::Error::AmountAboveMaximum {
buy: btc_to_swap, buy: btc_to_swap,
max: max_buy, max: max_buy,
}, },
@ -442,6 +509,7 @@ mod tests {
Behaviour::new( Behaviour::new(
values.balance, values.balance,
values.lock_fee, values.lock_fee,
values.min_buy,
values.max_buy, values.max_buy,
values.rate.clone(), values.rate.clone(),
values.resume_only, values.resume_only,
@ -508,12 +576,25 @@ mod tests {
// TODO: Somehow make PartialEq work on Alice's spot_price::Error // TODO: Somehow make PartialEq work on Alice's spot_price::Error
match (alice_assert, 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::AmountBelowMinimum { .. },
alice::spot_price::Error::AmountBelowMinimum { .. },
) )
| ( | (
alice::spot_price::Error::MaxBuyAmountExceeded { .. }, alice::spot_price::Error::AmountAboveMaximum { .. },
alice::spot_price::Error::MaxBuyAmountExceeded { .. }, alice::spot_price::Error::AmountAboveMaximum { .. },
) )
| ( | (
alice::spot_price::Error::LatestRateFetchFailed(_), alice::spot_price::Error::LatestRateFetchFailed(_),
@ -555,6 +636,7 @@ mod tests {
struct AliceBehaviourValues { struct AliceBehaviourValues {
pub balance: monero::Amount, pub balance: monero::Amount,
pub lock_fee: monero::Amount, pub lock_fee: monero::Amount,
pub min_buy: bitcoin::Amount,
pub max_buy: bitcoin::Amount, pub max_buy: bitcoin::Amount,
pub rate: TestRate, // 0.01 pub rate: TestRate, // 0.01
pub resume_only: bool, pub resume_only: bool,
@ -571,6 +653,11 @@ mod tests {
self 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 { pub fn with_max_buy(mut self, max_buy: bitcoin::Amount) -> AliceBehaviourValues {
self.max_buy = max_buy; self.max_buy = max_buy;
self self

View File

@ -41,8 +41,13 @@ crate::impl_from_rr_event!(SpotPriceOutEvent, OutEvent, PROTOCOL);
pub enum Error { pub enum Error {
#[error("Seller currently does not accept incoming swap requests, please try again later")] #[error("Seller currently does not accept incoming swap requests, please try again later")]
NoSwapsAccepted, 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}")] #[error("Seller refused to buy {buy} because the maximum configured buy limit is {max}")]
MaxBuyAmountExceeded { AmountAboveMaximum {
max: bitcoin::Amount, max: bitcoin::Amount,
buy: bitcoin::Amount, buy: bitcoin::Amount,
}, },
@ -59,8 +64,11 @@ impl From<spot_price::Error> for Error {
fn from(error: spot_price::Error) -> Self { fn from(error: spot_price::Error) -> Self {
match error { match error {
spot_price::Error::NoSwapsAccepted => Error::NoSwapsAccepted, spot_price::Error::NoSwapsAccepted => Error::NoSwapsAccepted,
spot_price::Error::MaxBuyAmountExceeded { max, buy } => { spot_price::Error::AmountBelowMinimum { min, buy } => {
Error::MaxBuyAmountExceeded { max, 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::BalanceTooLow { buy } => Error::BalanceTooLow { buy },
spot_price::Error::Other => Error::Other, spot_price::Error::Other => Error::Other,

View File

@ -226,6 +226,7 @@ async fn start_alice(
let current_balance = monero_wallet.get_balance().await.unwrap(); let current_balance = monero_wallet.get_balance().await.unwrap();
let lock_fee = monero_wallet.static_tx_fee_estimate(); 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 max_buy = bitcoin::Amount::from_sat(u64::MAX);
let latest_rate = FixedRate::default(); let latest_rate = FixedRate::default();
let resume_only = false; let resume_only = false;
@ -234,6 +235,7 @@ async fn start_alice(
&seed, &seed,
current_balance, current_balance,
lock_fee, lock_fee,
min_buy,
max_buy, max_buy,
latest_rate, latest_rate,
resume_only, resume_only,
@ -248,7 +250,8 @@ async fn start_alice(
monero_wallet, monero_wallet,
db, db,
FixedRate::default(), FixedRate::default(),
bitcoin::Amount::ONE_BTC, min_buy,
max_buy,
) )
.unwrap(); .unwrap();