Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion swap-asb/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ pub async fn main() -> Result<()> {
let bitcoin_balance = bitcoin_wallet.balance().await?;
tracing::info!(%bitcoin_balance, "Bitcoin wallet balance");

// Connect to Kraken, Bitfinex, and KuCoin
// Connect to Kraken, Bitfinex, KuCoin, and optionally Exolix
let kraken_price_updates =
swap_feed::connect_kraken(config.maker.price_ticker_ws_url_kraken.clone())?;
let bitfinex_price_updates =
Expand All @@ -214,12 +214,28 @@ pub async fn main() -> Result<()> {
config.maker.price_ticker_rest_url_kucoin.clone(),
reqwest::Client::new(),
)?;
let exolix_price_updates = config
.maker
.exolix_api_key
.as_ref()
.map(|api_key| {
swap_feed::connect_exolix(
config.maker.price_ticker_rest_url_exolix.clone(),
Some(api_key.clone()),
reqwest::Client::new(),
)
})
.transpose()?;
if exolix_price_updates.is_some() {
tracing::info!("Exolix price feed enabled");
}

let kraken_rate = ExchangeRate::new(
config.maker.ask_spread,
kraken_price_updates,
bitfinex_price_updates,
kucoin_price_updates,
exolix_price_updates,
);
let namespace = XmrBtcNamespace::from_is_testnet(testnet);

Expand Down
17 changes: 15 additions & 2 deletions swap-env/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::defaults::{
BITFINEX_PRICE_TICKER_WS_URL, GetDefaults, KRAKEN_PRICE_TICKER_WS_URL,
KUCOIN_PRICE_TICKER_REST_URL,
BITFINEX_PRICE_TICKER_WS_URL, EXOLIX_PRICE_TICKER_REST_URL, GetDefaults,
KRAKEN_PRICE_TICKER_WS_URL, KUCOIN_PRICE_TICKER_REST_URL,
};
use crate::env::{Mainnet, Testnet};
use crate::prompt;
Expand Down Expand Up @@ -144,6 +144,13 @@ pub struct Maker {
pub price_ticker_ws_url_bitfinex: Url,
#[serde(default = "default_price_ticker_rest_url_kucoin")]
pub price_ticker_rest_url_kucoin: Url,
#[serde(default = "default_price_ticker_rest_url_exolix")]
pub price_ticker_rest_url_exolix: Url,
/// Optional Exolix API key. When set, the Exolix rate endpoint is
/// polled and included in the price average alongside Kraken,
/// Bitfinex, and KuCoin.
#[serde(default)]
pub exolix_api_key: Option<String>,
/// If specified, Bitcoin received from successful swaps will be sent to this address.
#[serde(default, with = "swap_serde::bitcoin::address_serde::option")]
pub external_bitcoin_redeem_address: Option<bitcoin::Address>,
Expand Down Expand Up @@ -189,6 +196,10 @@ fn default_price_ticker_rest_url_kucoin() -> Url {
Url::parse(KUCOIN_PRICE_TICKER_REST_URL).expect("default kucoin rest url to be valid")
}

fn default_price_ticker_rest_url_exolix() -> Url {
Url::parse(EXOLIX_PRICE_TICKER_REST_URL).expect("default exolix rest url to be valid")
}

fn default_developer_tip() -> Decimal {
Decimal::ZERO
}
Expand Down Expand Up @@ -346,6 +357,8 @@ pub fn query_user_for_initial_config_with_network(
price_ticker_ws_url_kraken: defaults.price_ticker_ws_url_kraken,
price_ticker_ws_url_bitfinex: defaults.price_ticker_ws_url_bitfinex,
price_ticker_rest_url_kucoin: defaults.price_ticker_rest_url_kucoin,
price_ticker_rest_url_exolix: defaults.price_ticker_rest_url_exolix,
exolix_api_key: None,
external_bitcoin_redeem_address: None,
developer_tip,
refund_policy: defaults.refund_policy,
Expand Down
4 changes: 4 additions & 0 deletions swap-env/src/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub const DEFAULT_SPREAD: f64 = 0.02f64;
pub const KRAKEN_PRICE_TICKER_WS_URL: &str = "wss://ws.kraken.com";
pub const BITFINEX_PRICE_TICKER_WS_URL: &str = "wss://api-pub.bitfinex.com/ws/2";
pub const KUCOIN_PRICE_TICKER_REST_URL: &str = "https://api.kucoin.com/api/v1/bullet-public";
pub const EXOLIX_PRICE_TICKER_REST_URL: &str = "https://exolix.com/api/v2/rate";

pub fn default_rendezvous_points() -> Vec<Multiaddr> {
vec![
Expand Down Expand Up @@ -116,6 +117,7 @@ pub struct Defaults {
pub price_ticker_ws_url_kraken: Url,
pub price_ticker_ws_url_bitfinex: Url,
pub price_ticker_rest_url_kucoin: Url,
pub price_ticker_rest_url_exolix: Url,
pub bitcoin_confirmation_target: u16,
pub use_mempool_space_fee_estimation: bool,
pub developer_tip: Decimal,
Expand All @@ -134,6 +136,7 @@ impl GetDefaults for Mainnet {
price_ticker_ws_url_kraken: Url::parse(KRAKEN_PRICE_TICKER_WS_URL)?,
price_ticker_ws_url_bitfinex: Url::parse(BITFINEX_PRICE_TICKER_WS_URL)?,
price_ticker_rest_url_kucoin: Url::parse(KUCOIN_PRICE_TICKER_REST_URL)?,
price_ticker_rest_url_exolix: Url::parse(EXOLIX_PRICE_TICKER_REST_URL)?,
bitcoin_confirmation_target: 1,
use_mempool_space_fee_estimation: true,
developer_tip: Decimal::ZERO,
Expand All @@ -156,6 +159,7 @@ impl GetDefaults for Testnet {
price_ticker_ws_url_kraken: Url::parse(KRAKEN_PRICE_TICKER_WS_URL)?,
price_ticker_ws_url_bitfinex: Url::parse(BITFINEX_PRICE_TICKER_WS_URL)?,
price_ticker_rest_url_kucoin: Url::parse(KUCOIN_PRICE_TICKER_REST_URL)?,
price_ticker_rest_url_exolix: Url::parse(EXOLIX_PRICE_TICKER_REST_URL)?,
bitcoin_confirmation_target: 1,
use_mempool_space_fee_estimation: true,
developer_tip: Decimal::ZERO,
Expand Down
1 change: 1 addition & 0 deletions swap-feed/src/bin/combo_ticker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ async fn main() -> Result<()> {
kraken_ticker,
bitfinex_ticker,
kucoin_ticker,
None,
);

let mut timer = tokio::time::interval(std::time::Duration::from_secs(1));
Expand Down
29 changes: 29 additions & 0 deletions swap-feed/src/bin/exolix_ticker.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use anyhow::{Context, Result};
use url::Url;

/// Hand-test binary for the Exolix price feed.
///
/// Usage: `exolix_ticker [API_KEY]`
/// Alternatively, set `EXOLIX_API_KEY` in the environment.
#[tokio::main]
async fn main() -> Result<()> {
tracing::subscriber::set_global_default(
tracing_subscriber::fmt().with_env_filter("debug").finish(),
)?;

let api_key = std::env::args()
.nth(1)
.or_else(|| std::env::var("EXOLIX_API_KEY").ok());

let rest_url = Url::parse("https://exolix.com/api/v2/rate")?;
let mut ticker =
swap_feed::exolix::connect(rest_url, api_key, reqwest::Client::new())
.context("Failed to connect to Exolix")?;

loop {
match ticker.wait_for_next_update().await? {
Ok(update) => println!("Price update: {}", update.1.ask),
Err(e) => println!("Error: {e:#}"),
}
}
}
245 changes: 245 additions & 0 deletions swap-feed/src/exolix.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
use anyhow::Result;
use std::time::Duration;
use url::Url;

/// Default poll interval for the Exolix rate endpoint.
pub const POLL_INTERVAL: Duration = Duration::from_secs(30);

/// Connect to the Exolix REST API and poll it for XMR/BTC rate updates.
///
/// Unlike the websocket-based feeds, Exolix only exposes a REST rate endpoint,
/// so we emulate a "stream of updates" by polling on a fixed interval. The
/// reconnection/backoff machinery in [`crate::ticker`] transparently reuses
/// this stream shape.
///
/// See: <https://exolix.com/developers>
pub fn connect(
rest_url: Url,
api_key: Option<String>,
client: reqwest::Client,
) -> Result<PriceUpdates> {
crate::ticker::connect(
"Exolix",
ExolixParams {
rest_url,
api_key,
client,
},
connection::new,
)
}

pub type PriceUpdates = crate::ticker::PriceUpdates<wire::PriceUpdate>;
pub type PriceUpdate = crate::ticker::PriceUpdate<wire::PriceUpdate>;
pub type Error = crate::ticker::Error;

#[derive(Clone)]
pub struct ExolixParams {
pub rest_url: Url,
pub api_key: Option<String>,
pub client: reqwest::Client,
}

pub(crate) mod connection {
use super::{ExolixParams, POLL_INTERVAL, wire};
use anyhow::{Context, Result};
use futures::StreamExt;
use futures::stream::{self, BoxStream};
use std::convert::Infallible;
use std::sync::Arc;

pub async fn new(
params: Arc<ExolixParams>,
) -> Result<BoxStream<'static, Result<wire::PriceUpdate, Infallible>>> {
// Do a synchronous first fetch so connection failures (bad key,
// wrong URL) surface immediately to the ticker's backoff machinery
// instead of being buried behind a 30s sleep. The successful sample
// is emitted as the very first stream item so subscribers leave
// `NotYetAvailable` without waiting for the poll interval.
let initial = fetch_rate(&params)
.await
.context("Failed initial Exolix rate fetch")?;

tracing::debug!("Connected to Exolix REST API");

enum State {
First(wire::PriceUpdate, Arc<ExolixParams>),
Polling(Arc<ExolixParams>),
}

let stream = stream::unfold(State::First(initial, params), |state| async move {
match state {
State::First(update, params) => Some((Ok(update), State::Polling(params))),
State::Polling(params) => {
tokio::time::sleep(POLL_INTERVAL).await;
// Per-poll failures must NOT tear down the whole feed.
// Websocket feeds only reconnect on transport loss; a
// single bad REST response (429, 500, decode error)
// should be logged and retried on the next tick. We
// therefore skip item-errors by recursing the unfold
// until we get a healthy sample.
loop {
match fetch_rate(&params).await {
Ok(update) => {
return Some((Ok(update), State::Polling(params)));
}
Err(err) => {
tracing::warn!(
error = %err,
"Exolix poll failed, will retry after next interval",
);
tokio::time::sleep(POLL_INTERVAL).await;
}
}
}
}
}
})
.boxed();

Ok(stream)
}

async fn fetch_rate(params: &ExolixParams) -> Result<wire::PriceUpdate, FetchError> {
let mut url = params.rest_url.clone();
url.query_pairs_mut()
.append_pair("coinFrom", "XMR")
.append_pair("networkFrom", "XMR")
.append_pair("coinTo", "BTC")
.append_pair("networkTo", "BTC")
.append_pair("amount", "1")
.append_pair("rateType", "float");

let mut request = params.client.get(url).header("Accept", "application/json");
if let Some(key) = params.api_key.as_deref() {
request = request.header("Authorization", key);
}

let response = request
.send()
.await
.map_err(FetchError::Request)?;
let status = response.status();
if !status.is_success() {
let body = response
.text()
.await
.map_err(FetchError::BodyRead)?;
return Err(FetchError::Status { status, body });
}

let bytes = response
.bytes()
.await
.map_err(FetchError::BodyRead)?;
let body: wire::RateResponse =
serde_json::from_slice(&bytes).map_err(FetchError::Decode)?;
wire::PriceUpdate::try_from(body).map_err(FetchError::Parse)
}

#[derive(Debug, thiserror::Error)]
pub enum FetchError {
#[error("Exolix HTTP request failed")]
Request(#[source] reqwest::Error),
#[error("Failed to read Exolix response body")]
BodyRead(#[source] reqwest::Error),
#[error("Exolix returned non-success status {status}: {body}")]
Status {
status: reqwest::StatusCode,
body: String,
},
#[error("Failed to decode Exolix JSON response")]
Decode(#[source] serde_json::Error),
#[error("Invalid Exolix rate payload")]
Parse(#[from] wire::Error),
}

}

pub mod wire {
use bitcoin::amount::ParseAmountError;
use rust_decimal::Decimal;
use serde::Deserialize;

/// Raw response from `GET /api/v2/rate`.
///
/// Only the fields we care about are captured.
#[derive(Debug, Deserialize)]
pub struct RateResponse {
/// Rate as BTC received per 1 XMR sent (we query `amount=1`).
pub rate: Decimal,
}

#[derive(Clone, Debug, PartialEq)]
pub struct PriceUpdate {
pub ask: bitcoin::Amount,
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Exolix returned a non-positive rate: {0}")]
NonPositive(Decimal),
#[error("Failed to parse Exolix rate {rate} as a Bitcoin amount")]
AmountParse {
rate: Decimal,
#[source]
source: ParseAmountError,
},
}

impl TryFrom<RateResponse> for PriceUpdate {
type Error = Error;

fn try_from(value: RateResponse) -> Result<Self, Error> {
if value.rate <= Decimal::ZERO {
return Err(Error::NonPositive(value.rate));
}
// Route through the decimal string representation to avoid
// binary-float drift. This matches how kraken/kucoin parse
// their wire values.
let rendered = value.rate.to_string();
let ask = bitcoin::Amount::from_str_in(&rendered, bitcoin::Denomination::Bitcoin)
.map_err(|source| Error::AmountParse {
rate: value.rate,
source,
})?;
Ok(PriceUpdate { ask })
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_rate_response() {
let body = r#"{"fromAmount":1,"toAmount":0.00468629,"rate":0.00468629,"message":null,"minAmount":0.14233428,"withdrawMin":0.00000624,"maxAmount":2000,"priceImpact":"0"}"#;
let response: RateResponse = serde_json::from_str(body).unwrap();
let update: PriceUpdate = response.try_into().unwrap();
assert_eq!(update.ask.to_sat(), 468_629);
}

#[test]
fn parses_rate_response_with_high_precision() {
// More than 8 decimal places of BTC would not fit in sats and
// must fail cleanly rather than being silently rounded.
let body = r#"{"rate":0.123456789}"#;
let response: RateResponse = serde_json::from_str(body).unwrap();
assert!(PriceUpdate::try_from(response).is_err());
}

#[test]
fn rejects_zero_rate() {
let body = r#"{"rate":0}"#;
let response: RateResponse = serde_json::from_str(body).unwrap();
assert!(PriceUpdate::try_from(response).is_err());
}

#[test]
fn rejects_negative_rate() {
let body = r#"{"rate":-0.00468629}"#;
let response: RateResponse = serde_json::from_str(body).unwrap();
assert!(PriceUpdate::try_from(response).is_err());
}
}
}
Loading
Loading