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.
This commit is contained in:
Daniel Karzel 2021-05-07 19:06:58 +10:00
parent 6d3cf0af91
commit 652aae9590
No known key found for this signature in database
GPG Key ID: 30C3FC2E438ADB6E
12 changed files with 319 additions and 81 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

View File

@ -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_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_MAX_BUY_AMOUNT: f64 = 0.02f64;
const DEFAULT_SPREAD: f64 = 0.02f64; const DEFAULT_SPREAD: f64 = 0.02f64;
@ -81,6 +82,8 @@ pub struct TorConf {
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Maker { 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")] #[serde(with = "::bitcoin::util::amount::serde::as_btc")]
pub max_buy_btc: bitcoin::Amount, pub max_buy_btc: bitcoin::Amount,
pub ask_spread: Decimal, pub ask_spread: Decimal,
@ -200,6 +203,12 @@ 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()) 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.") .with_prompt("Enter maximum Bitcoin amount you are willing to accept per swap or hit enter to use default.")
.default(DEFAULT_MAX_BUY_AMOUNT) .default(DEFAULT_MAX_BUY_AMOUNT)
@ -234,6 +243,7 @@ pub fn query_user_for_initial_testnet_config() -> Result<Config> {
socks5_port: tor_socks5_port, socks5_port: tor_socks5_port,
}, },
maker: Maker { maker: Maker {
min_buy_btc: min_buy,
max_buy_btc: max_buy, max_buy_btc: max_buy,
ask_spread, ask_spread,
}, },
@ -271,6 +281,7 @@ mod tests {
}, },
tor: Default::default(), tor: Default::default(),
maker: Maker { 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(), max_buy_btc: bitcoin::Amount::from_btc(DEFAULT_MAX_BUY_AMOUNT).unwrap(),
ask_spread: Decimal::from_f64(DEFAULT_SPREAD).unwrap(), ask_spread: Decimal::from_f64(DEFAULT_SPREAD).unwrap(),
}, },

View File

@ -123,6 +123,7 @@ async fn main() -> Result<()> {
&seed, &seed,
current_balance, current_balance,
lock_fee, lock_fee,
config.maker.min_buy_btc,
config.maker.max_buy_btc, config.maker.max_buy_btc,
kraken_rate.clone(), kraken_rate.clone(),
resume_only, resume_only,
@ -140,6 +141,7 @@ async fn main() -> Result<()> {
Arc::new(monero_wallet), Arc::new(monero_wallet),
Arc::new(db), Arc::new(db),
kraken_rate, kraken_rate,
config.maker.min_buy_btc,
config.maker.max_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 { } else {
info!("Found {} in wallet", initial_balance); tracing::info!(
initial_balance %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 {
current_maximum_giveable
}; };
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,
}); });
@ -218,8 +231,13 @@ 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,
}, },
@ -240,7 +258,11 @@ 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,
}, },
@ -268,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,
@ -368,7 +391,32 @@ mod tests {
} }
#[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 =
@ -380,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,
}, },
@ -461,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,
@ -540,8 +589,12 @@ mod tests {
assert_eq!(buy1, buy2); assert_eq!(buy1, buy2);
} }
( (
alice::spot_price::Error::MaxBuyAmountExceeded { .. }, alice::spot_price::Error::AmountBelowMinimum { .. },
alice::spot_price::Error::MaxBuyAmountExceeded { .. }, alice::spot_price::Error::AmountBelowMinimum { .. },
)
| (
alice::spot_price::Error::AmountAboveMaximum { .. },
alice::spot_price::Error::AmountAboveMaximum { .. },
) )
| ( | (
alice::spot_price::Error::LatestRateFetchFailed(_), alice::spot_price::Error::LatestRateFetchFailed(_),
@ -583,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,
@ -599,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();