mirror of
https://github.com/comit-network/xmr-btc-swap.git
synced 2025-01-24 14:22:35 -05:00
Merge #394
394: Add a configurable spread to the ASB r=thomaseizinger a=thomaseizinger Fixes #381. Co-authored-by: Thomas Eizinger <thomas@eizinger.io>
This commit is contained in:
commit
32912ebd4a
@ -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.
|
||||
|
||||
### Changed
|
||||
|
||||
|
@ -1,7 +1,5 @@
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
mod fixed_rate;
|
||||
mod rate;
|
||||
|
||||
pub use self::fixed_rate::FixedRate;
|
||||
pub use self::rate::Rate;
|
||||
pub use rate::Rate;
|
||||
|
@ -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 {
|
||||
|
@ -1,20 +0,0 @@
|
||||
use crate::asb::Rate;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct FixedRate(Rate);
|
||||
|
||||
impl FixedRate {
|
||||
pub const RATE: f64 = 0.01;
|
||||
|
||||
pub fn value(&self) -> Rate {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FixedRate {
|
||||
fn default() -> Self {
|
||||
Self(Rate {
|
||||
ask: bitcoin::Amount::from_btc(Self::RATE).expect("Static value should never fail"),
|
||||
})
|
||||
}
|
||||
}
|
@ -4,23 +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 {
|
||||
pub ask: bitcoin::Amount,
|
||||
/// 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,
|
||||
};
|
||||
|
||||
// This function takes the quote amount as it is what Bob sends to Alice in the
|
||||
// swap request
|
||||
pub fn new(ask: bitcoin::Amount, ask_spread: Decimal) -> Self {
|
||||
Self { ask, ask_spread }
|
||||
}
|
||||
|
||||
/// 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<bitcoin::Amount> {
|
||||
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)
|
||||
}
|
||||
|
||||
/// Calculate a sell quote for a given BTC amount.
|
||||
pub fn sell_quote(&self, quote: bitcoin::Amount) -> Result<monero::Amount> {
|
||||
Self::quote(self.ask, quote)
|
||||
Self::quote(self.ask()?, quote)
|
||||
}
|
||||
|
||||
fn quote(rate: bitcoin::Amount, quote: bitcoin::Amount) -> Result<monero::Amount> {
|
||||
@ -59,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();
|
||||
|
||||
@ -71,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
|
||||
}
|
||||
}
|
||||
|
@ -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?;
|
||||
|
||||
@ -92,7 +96,7 @@ async fn main() -> Result<()> {
|
||||
info!("Monero balance: {}", monero_balance);
|
||||
}
|
||||
|
||||
let kraken_rate_updates = kraken::connect()?;
|
||||
let kraken_price_updates = kraken::connect()?;
|
||||
|
||||
let mut swarm = swarm::new::<Behaviour>(&seed)?;
|
||||
Swarm::listen_on(&mut swarm, config.network.listen)
|
||||
@ -104,7 +108,7 @@ async fn main() -> Result<()> {
|
||||
Arc::new(bitcoin_wallet),
|
||||
Arc::new(monero_wallet),
|
||||
Arc::new(db),
|
||||
kraken_rate_updates,
|
||||
KrakenRate::new(ask_spread, kraken_price_updates),
|
||||
max_buy,
|
||||
)
|
||||
.unwrap();
|
||||
|
@ -9,8 +9,8 @@ async fn main() -> Result<()> {
|
||||
let mut ticker = swap::kraken::connect().context("Failed to connect to kraken")?;
|
||||
|
||||
loop {
|
||||
match ticker.wait_for_update().await? {
|
||||
Ok(rate) => println!("Rate update: {}", rate),
|
||||
match ticker.wait_for_next_update().await? {
|
||||
Ok(update) => println!("Price update: {}", update.ask),
|
||||
Err(e) => println!("Error: {:#}", e),
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
use crate::asb::Rate;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use futures::{SinkExt, StreamExt, TryStreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use std::convert::{Infallible, TryFrom};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@ -10,9 +9,9 @@ use tokio::sync::watch;
|
||||
/// Connect to Kraken websocket API for a constant stream of rate updates.
|
||||
///
|
||||
/// If the connection fails, it will automatically be re-established.
|
||||
pub fn connect() -> Result<RateUpdateStream> {
|
||||
let (rate_update, rate_update_receiver) = watch::channel(Err(Error::NotYetAvailable));
|
||||
let rate_update = Arc::new(rate_update);
|
||||
pub fn connect() -> Result<PriceUpdates> {
|
||||
let (price_update, price_update_receiver) = watch::channel(Err(Error::NotYetAvailable));
|
||||
let price_update = Arc::new(price_update);
|
||||
|
||||
tokio::spawn(async move {
|
||||
// The default backoff config is fine for us apart from one thing:
|
||||
@ -26,12 +25,12 @@ pub fn connect() -> Result<RateUpdateStream> {
|
||||
let result = backoff::future::retry_notify::<Infallible, _, _, _, _, _>(
|
||||
backoff,
|
||||
|| {
|
||||
let rate_update = rate_update.clone();
|
||||
let price_update = price_update.clone();
|
||||
async move {
|
||||
let mut stream = connection::new().await?;
|
||||
|
||||
while let Some(update) = stream.try_next().await.map_err(to_backoff)? {
|
||||
let send_result = rate_update.send(Ok(update));
|
||||
let send_result = price_update.send(Ok(update));
|
||||
|
||||
if send_result.is_err() {
|
||||
return Err(backoff::Error::Permanent(anyhow!(
|
||||
@ -54,30 +53,30 @@ pub fn connect() -> Result<RateUpdateStream> {
|
||||
tracing::warn!("Rate updates incurred an unrecoverable error: {:#}", e);
|
||||
|
||||
// in case the retries fail permanently, let the subscribers know
|
||||
rate_update.send(Err(Error::PermanentFailure))
|
||||
price_update.send(Err(Error::PermanentFailure))
|
||||
}
|
||||
Ok(never) => match never {},
|
||||
}
|
||||
});
|
||||
|
||||
Ok(RateUpdateStream {
|
||||
inner: rate_update_receiver,
|
||||
Ok(PriceUpdates {
|
||||
inner: price_update_receiver,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RateUpdateStream {
|
||||
inner: watch::Receiver<RateUpdate>,
|
||||
pub struct PriceUpdates {
|
||||
inner: watch::Receiver<PriceUpdate>,
|
||||
}
|
||||
|
||||
impl RateUpdateStream {
|
||||
pub async fn wait_for_update(&mut self) -> Result<RateUpdate> {
|
||||
impl PriceUpdates {
|
||||
pub async fn wait_for_next_update(&mut self) -> Result<PriceUpdate> {
|
||||
self.inner.changed().await?;
|
||||
|
||||
Ok(self.inner.borrow().clone())
|
||||
}
|
||||
|
||||
pub fn latest_update(&mut self) -> RateUpdate {
|
||||
pub fn latest_update(&mut self) -> PriceUpdate {
|
||||
self.inner.borrow().clone()
|
||||
}
|
||||
}
|
||||
@ -90,7 +89,7 @@ pub enum Error {
|
||||
PermanentFailure,
|
||||
}
|
||||
|
||||
type RateUpdate = Result<Rate, Error>;
|
||||
type PriceUpdate = Result<wire::PriceUpdate, Error>;
|
||||
|
||||
/// Maps a [`connection::Error`] to a backoff error, effectively defining our
|
||||
/// retry strategy.
|
||||
@ -120,7 +119,7 @@ mod connection {
|
||||
use futures::stream::BoxStream;
|
||||
use tokio_tungstenite::tungstenite;
|
||||
|
||||
pub async fn new() -> Result<BoxStream<'static, Result<Rate, Error>>> {
|
||||
pub async fn new() -> Result<BoxStream<'static, Result<wire::PriceUpdate, Error>>> {
|
||||
let (mut rate_stream, _) = tokio_tungstenite::connect_async("wss://ws.kraken.com")
|
||||
.await
|
||||
.context("Failed to connect to Kraken websocket API")?;
|
||||
@ -134,12 +133,12 @@ mod connection {
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Parse a websocket message into a [`Rate`].
|
||||
/// Parse a websocket message into a [`wire::PriceUpdate`].
|
||||
///
|
||||
/// Messages which are not actually ticker updates are ignored and result in
|
||||
/// `None` being returned. In the context of a [`TryStream`], these will
|
||||
/// simply be filtered out.
|
||||
async fn parse_message(msg: tungstenite::Message) -> Result<Option<Rate>, Error> {
|
||||
async fn parse_message(msg: tungstenite::Message) -> Result<Option<wire::PriceUpdate>, Error> {
|
||||
let msg = match msg {
|
||||
tungstenite::Message::Text(msg) => msg,
|
||||
tungstenite::Message::Close(close_frame) => {
|
||||
@ -182,7 +181,7 @@ mod connection {
|
||||
return Ok(None);
|
||||
}
|
||||
// if the message is not an event, it is a ticker update or an unknown event
|
||||
Err(_) => match serde_json::from_str::<wire::TickerUpdate>(&msg) {
|
||||
Err(_) => match serde_json::from_str::<wire::PriceUpdate>(&msg) {
|
||||
Ok(ticker) => ticker,
|
||||
Err(e) => {
|
||||
tracing::warn!(%e, "Failed to deserialize message '{}' as ticker update", msg);
|
||||
@ -192,8 +191,6 @@ mod connection {
|
||||
},
|
||||
};
|
||||
|
||||
let update = Rate::try_from(update)?;
|
||||
|
||||
Ok(Some(update))
|
||||
}
|
||||
|
||||
@ -224,7 +221,7 @@ mod wire {
|
||||
use bitcoin::util::amount::ParseAmountError;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
#[serde(tag = "event")]
|
||||
pub enum Event {
|
||||
#[serde(rename = "systemStatus")]
|
||||
@ -247,18 +244,25 @@ mod wire {
|
||||
BitcoinParseAmount(#[from] ParseAmountError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
/// Represents an update within the price ticker.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(try_from = "TickerUpdate")]
|
||||
pub struct PriceUpdate {
|
||||
pub ask: bitcoin::Amount,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct TickerUpdate(Vec<TickerField>);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum TickerField {
|
||||
Data(TickerData),
|
||||
Metadata(Value),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TickerData {
|
||||
#[serde(rename = "a")]
|
||||
ask: Vec<RateElement>,
|
||||
@ -266,14 +270,14 @@ mod wire {
|
||||
bid: Vec<RateElement>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum RateElement {
|
||||
Text(String),
|
||||
Number(u64),
|
||||
}
|
||||
|
||||
impl TryFrom<TickerUpdate> for Rate {
|
||||
impl TryFrom<TickerUpdate> for PriceUpdate {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: TickerUpdate) -> Result<Self, Error> {
|
||||
@ -293,7 +297,7 @@ mod wire {
|
||||
_ => return Err(Error::UnexpectedAskRateElementType),
|
||||
};
|
||||
|
||||
Ok(Self { ask })
|
||||
Ok(PriceUpdate { ask })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<Self> {
|
||||
let piconeros_dec =
|
||||
amount.mul(Decimal::from_u64(PICONERO_OFFSET).expect("constant to fit into u64"));
|
||||
|
@ -1,4 +1,4 @@
|
||||
use crate::asb::{FixedRate, Rate};
|
||||
use crate::asb::Rate;
|
||||
use crate::database::Database;
|
||||
use crate::env::Config;
|
||||
use crate::monero::BalanceTooLow;
|
||||
@ -14,6 +14,7 @@ use libp2p::request_response::{RequestId, ResponseChannel};
|
||||
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;
|
||||
@ -311,7 +312,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,
|
||||
})
|
||||
}
|
||||
@ -381,6 +382,26 @@ pub trait LatestRate {
|
||||
fn latest_rate(&mut self) -> Result<Rate, Self::Error>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FixedRate(Rate);
|
||||
|
||||
impl FixedRate {
|
||||
pub const RATE: f64 = 0.01;
|
||||
|
||||
pub fn value(&self) -> Rate {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
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, spread))
|
||||
}
|
||||
}
|
||||
|
||||
impl LatestRate for FixedRate {
|
||||
type Error = Infallible;
|
||||
|
||||
@ -389,11 +410,31 @@ impl LatestRate for FixedRate {
|
||||
}
|
||||
}
|
||||
|
||||
impl LatestRate for kraken::RateUpdateStream {
|
||||
/// 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<Rate, Self::Error> {
|
||||
self.latest_update()
|
||||
let update = self.price_updates.latest_update()?;
|
||||
let rate = Rate::new(update.ask, self.ask_spread);
|
||||
|
||||
Ok(rate)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -15,11 +15,11 @@ use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use swap::asb::FixedRate;
|
||||
use swap::bitcoin::{CancelTimelock, PunishTimelock};
|
||||
use swap::database::Database;
|
||||
use swap::env::{Config, GetConfig};
|
||||
use swap::network::swarm;
|
||||
use swap::protocol::alice::event_loop::FixedRate;
|
||||
use swap::protocol::alice::{AliceState, Swap};
|
||||
use swap::protocol::bob::BobState;
|
||||
use swap::protocol::{alice, bob};
|
||||
|
Loading…
Reference in New Issue
Block a user