From a99d12b9dfdbdfa7f2a68a6ea1481efd4337dc02 Mon Sep 17 00:00:00 2001 From: Thomas Eizinger Date: Thu, 1 Apr 2021 13:00:15 +1100 Subject: [PATCH] Add a configurable spread to the ASB Fixes #381. --- CHANGELOG.md | 3 ++ swap/src/asb/command.rs | 7 +++ swap/src/asb/rate.rs | 77 ++++++++++++++++++++++----- swap/src/bin/asb.rs | 8 ++- swap/src/monero.rs | 4 ++ swap/src/protocol/alice/event_loop.rs | 30 +++++++++-- 6 files changed, 109 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f96cedf4..ef1ba088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A changelog file. - Automatic resume of unfinished swaps for the `asb` upon startup. Unfinished swaps from earlier versions will be skipped. +- A configurable spread for the ASB that is applied to the asking price received from the Kraken price ticker. + The default value is 2% and can be configured using the `--ask-spread` parameter. + See `./asb --help` for details. ### Fixed diff --git a/swap/src/asb/command.rs b/swap/src/asb/command.rs index eb0ea13b..298f5570 100644 --- a/swap/src/asb/command.rs +++ b/swap/src/asb/command.rs @@ -1,6 +1,7 @@ use crate::bitcoin::Amount; use bitcoin::util::amount::ParseAmountError; use bitcoin::{Address, Denomination}; +use rust_decimal::Decimal; use std::path::PathBuf; #[derive(structopt::StructOpt, Debug)] @@ -27,6 +28,12 @@ pub enum Command { 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, }, History, WithdrawBtc { diff --git a/swap/src/asb/rate.rs b/swap/src/asb/rate.rs index 9058f90d..ffcdcf7c 100644 --- a/swap/src/asb/rate.rs +++ b/swap/src/asb/rate.rs @@ -4,31 +4,47 @@ use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use std::fmt::{Debug, Display, Formatter}; -/// Prices at which 1 XMR will be traded, in BTC (XMR/BTC pair) -/// The `ask` represents the minimum price in BTC for which we are willing to -/// sell 1 XMR. +/// Represents the rate at which we are willing to trade 1 XMR. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Rate { + /// Represents the asking price from the market. ask: bitcoin::Amount, + /// The spread which should be applied to the market asking price. + ask_spread: Decimal, } +const ZERO_SPREAD: Decimal = Decimal::from_parts(0, 0, 0, false, 0); + impl Rate { pub const ZERO: Rate = Rate { ask: bitcoin::Amount::ZERO, + ask_spread: ZERO_SPREAD, }; - pub fn new(ask: bitcoin::Amount) -> Self { - Self { ask } + pub fn new(ask: bitcoin::Amount, ask_spread: Decimal) -> Self { + Self { ask, ask_spread } } - pub fn ask(&self) -> bitcoin::Amount { - self.ask + /// Computes the asking price at which we are willing to sell 1 XMR. + /// + /// This applies the spread to the market asking price. + pub fn ask(&self) -> Result { + let sats = self.ask.as_sat(); + let sats = Decimal::from(sats); + + let additional_sats = sats * self.ask_spread; + let additional_sats = bitcoin::Amount::from_sat( + additional_sats + .to_u64() + .context("Failed to fit spread into u64")?, + ); + + Ok(self.ask + additional_sats) } - // This function takes the quote amount as it is what Bob sends to Alice in the - // swap request + /// Calculate a sell quote for a given BTC amount. pub fn sell_quote(&self, quote: bitcoin::Amount) -> Result { - Self::quote(self.ask, quote) + Self::quote(self.ask()?, quote) } fn quote(rate: bitcoin::Amount, quote: bitcoin::Amount) -> Result { @@ -67,11 +83,13 @@ impl Display for Rate { mod tests { use super::*; + const TWO_PERCENT: Decimal = Decimal::from_parts(2, 0, 0, false, 2); + const ONE: Decimal = Decimal::from_parts(1, 0, 0, false, 0); + #[test] fn sell_quote() { - let rate = Rate { - ask: bitcoin::Amount::from_btc(0.002_500).unwrap(), - }; + let asking_price = bitcoin::Amount::from_btc(0.002_500).unwrap(); + let rate = Rate::new(asking_price, ZERO_SPREAD); let btc_amount = bitcoin::Amount::from_btc(2.5).unwrap(); @@ -79,4 +97,37 @@ mod tests { assert_eq!(xmr_amount, monero::Amount::from_monero(1000.0).unwrap()) } + + #[test] + fn applies_spread_to_asking_price() { + let asking_price = bitcoin::Amount::from_sat(100); + let rate = Rate::new(asking_price, TWO_PERCENT); + + let amount = rate.ask().unwrap(); + + assert_eq!(amount.as_sat(), 102); + } + + #[test] + fn given_spread_of_two_percent_when_caluclating_sell_quote_factor_between_should_be_two_percent( + ) { + let asking_price = bitcoin::Amount::from_btc(0.004).unwrap(); + + let rate_no_spread = Rate::new(asking_price, ZERO_SPREAD); + let rate_with_spread = Rate::new(asking_price, TWO_PERCENT); + + let xmr_no_spread = rate_no_spread.sell_quote(bitcoin::Amount::ONE_BTC).unwrap(); + let xmr_with_spread = rate_with_spread + .sell_quote(bitcoin::Amount::ONE_BTC) + .unwrap(); + + let xmr_factor = + xmr_no_spread.as_piconero_decimal() / xmr_with_spread.as_piconero_decimal() - ONE; + + assert!(xmr_with_spread < xmr_no_spread); + assert_eq!(xmr_factor.round_dp(8), TWO_PERCENT); // round to 8 decimal + // places to show that + // it is really close + // to two percent + } } diff --git a/swap/src/bin/asb.rs b/swap/src/bin/asb.rs index 6694e35d..3140d946 100644 --- a/swap/src/bin/asb.rs +++ b/swap/src/bin/asb.rs @@ -26,6 +26,7 @@ use swap::env::GetConfig; use swap::fs::default_config_path; use swap::monero::Amount; use swap::network::swarm; +use swap::protocol::alice::event_loop::KrakenRate; use swap::protocol::alice::{run, Behaviour, EventLoop}; use swap::seed::Seed; use swap::trace::init_tracing; @@ -74,7 +75,10 @@ async fn main() -> Result<()> { let env_config = env::Testnet::get_config(); match opt.cmd { - Command::Start { max_buy } => { + Command::Start { + max_buy, + ask_spread, + } => { let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; let monero_wallet = init_monero_wallet(&config, env_config).await?; @@ -104,7 +108,7 @@ async fn main() -> Result<()> { Arc::new(bitcoin_wallet), Arc::new(monero_wallet), Arc::new(db), - kraken_price_updates, + KrakenRate::new(ask_spread, kraken_price_updates), max_buy, ) .unwrap(); diff --git a/swap/src/monero.rs b/swap/src/monero.rs index 407f7aa2..c52bd0b5 100644 --- a/swap/src/monero.rs +++ b/swap/src/monero.rs @@ -96,6 +96,10 @@ impl Amount { Self::from_decimal(decimal) } + pub fn as_piconero_decimal(&self) -> Decimal { + Decimal::from(self.as_piconero()) + } + fn from_decimal(amount: Decimal) -> Result { let piconeros_dec = amount.mul(Decimal::from_u64(PICONERO_OFFSET).expect("constant to fit into u64")); diff --git a/swap/src/protocol/alice/event_loop.rs b/swap/src/protocol/alice/event_loop.rs index 7837c96c..601c18ff 100644 --- a/swap/src/protocol/alice/event_loop.rs +++ b/swap/src/protocol/alice/event_loop.rs @@ -13,6 +13,7 @@ use futures::stream::{FuturesUnordered, StreamExt}; use libp2p::swarm::SwarmEvent; use libp2p::{PeerId, Swarm}; use rand::rngs::OsRng; +use rust_decimal::Decimal; use std::collections::HashMap; use std::convert::Infallible; use std::sync::Arc; @@ -278,7 +279,7 @@ where .context("Failed to get latest rate")?; Ok(BidQuote { - price: rate.ask(), + price: rate.ask().context("Failed to compute asking price")?, max_quantity: max_buy, }) } @@ -360,8 +361,9 @@ impl FixedRate { impl Default for FixedRate { fn default() -> Self { let ask = bitcoin::Amount::from_btc(Self::RATE).expect("Static value should never fail"); + let spread = Decimal::from(0u64); - Self(Rate::new(ask)) + Self(Rate::new(ask, spread)) } } @@ -373,13 +375,31 @@ impl LatestRate for FixedRate { } } -impl LatestRate for kraken::PriceUpdates { +/// Produces [`Rate`]s based on [`PriceUpdate`]s from kraken and a configured +/// spread. +#[derive(Debug)] +pub struct KrakenRate { + ask_spread: Decimal, + price_updates: kraken::PriceUpdates, +} + +impl KrakenRate { + pub fn new(ask_spread: Decimal, price_updates: kraken::PriceUpdates) -> Self { + Self { + ask_spread, + price_updates, + } + } +} + +impl LatestRate for KrakenRate { type Error = kraken::Error; fn latest_rate(&mut self) -> Result { - let update = self.latest_update()?; + let update = self.price_updates.latest_update()?; + let rate = Rate::new(update.ask, self.ask_spread); - Ok(Rate::new(update.ask)) + Ok(rate) } }