diff --git a/swap-asb/src/main.rs b/swap-asb/src/main.rs index e9aec55b17..2daa2e04bb 100644 --- a/swap-asb/src/main.rs +++ b/swap-asb/src/main.rs @@ -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 = @@ -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); diff --git a/swap-env/src/config.rs b/swap-env/src/config.rs index c1c8132290..2e1b1ec501 100644 --- a/swap-env/src/config.rs +++ b/swap-env/src/config.rs @@ -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; @@ -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, /// 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, @@ -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 } @@ -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, diff --git a/swap-env/src/defaults.rs b/swap-env/src/defaults.rs index 2e41716799..aa6df9e4d4 100644 --- a/swap-env/src/defaults.rs +++ b/swap-env/src/defaults.rs @@ -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 { vec![ @@ -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, @@ -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, @@ -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, diff --git a/swap-feed/src/bin/combo_ticker.rs b/swap-feed/src/bin/combo_ticker.rs index 2c1ba497be..0416ef0863 100644 --- a/swap-feed/src/bin/combo_ticker.rs +++ b/swap-feed/src/bin/combo_ticker.rs @@ -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)); diff --git a/swap-feed/src/bin/exolix_ticker.rs b/swap-feed/src/bin/exolix_ticker.rs new file mode 100644 index 0000000000..e8e2918016 --- /dev/null +++ b/swap-feed/src/bin/exolix_ticker.rs @@ -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:#}"), + } + } +} diff --git a/swap-feed/src/exolix.rs b/swap-feed/src/exolix.rs new file mode 100644 index 0000000000..0b8a29e544 --- /dev/null +++ b/swap-feed/src/exolix.rs @@ -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: +pub fn connect( + rest_url: Url, + api_key: Option, + client: reqwest::Client, +) -> Result { + crate::ticker::connect( + "Exolix", + ExolixParams { + rest_url, + api_key, + client, + }, + connection::new, + ) +} + +pub type PriceUpdates = crate::ticker::PriceUpdates; +pub type PriceUpdate = crate::ticker::PriceUpdate; +pub type Error = crate::ticker::Error; + +#[derive(Clone)] +pub struct ExolixParams { + pub rest_url: Url, + pub api_key: Option, + 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, + ) -> Result>> { + // 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(¶ms) + .await + .context("Failed initial Exolix rate fetch")?; + + tracing::debug!("Connected to Exolix REST API"); + + enum State { + First(wire::PriceUpdate, Arc), + Polling(Arc), + } + + 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(¶ms).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 { + 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 for PriceUpdate { + type Error = Error; + + fn try_from(value: RateResponse) -> Result { + 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()); + } + } +} diff --git a/swap-feed/src/lib.rs b/swap-feed/src/lib.rs index 1ef5b9dd09..3c0cbd56cf 100644 --- a/swap-feed/src/lib.rs +++ b/swap-feed/src/lib.rs @@ -1,4 +1,5 @@ pub mod bitfinex; +pub mod exolix; pub mod kraken; pub mod kucoin; pub mod rate; @@ -26,3 +27,11 @@ pub fn connect_kucoin( ) -> anyhow::Result { kucoin::connect(url, client) } + +pub fn connect_exolix( + url: url::Url, + api_key: Option, + client: reqwest::Client, +) -> anyhow::Result { + exolix::connect(url, api_key, client) +} diff --git a/swap-feed/src/rate.rs b/swap-feed/src/rate.rs index eb4146a3e8..c0a343f4fb 100644 --- a/swap-feed/src/rate.rs +++ b/swap-feed/src/rate.rs @@ -110,13 +110,14 @@ impl crate::traits::LatestRate for FixedRate { } /// Produces [`Rate`]s based on [`PriceUpdate`]s from kraken, bitfinex, kucoin, -/// and a configured spread. +/// optionally exolix, and a configured spread. #[derive(Debug, Clone)] pub struct ExchangeRate { ask_spread: Decimal, kraken_price_updates: crate::kraken::PriceUpdates, bitfinex_price_updates: crate::bitfinex::PriceUpdates, kucoin_price_updates: crate::kucoin::PriceUpdates, + exolix_price_updates: Option, } impl ExchangeRate { @@ -125,23 +126,29 @@ impl ExchangeRate { kraken_price_updates: crate::kraken::PriceUpdates, bitfinex_price_updates: crate::bitfinex::PriceUpdates, kucoin_price_updates: crate::kucoin::PriceUpdates, + exolix_price_updates: Option, ) -> Self { Self { ask_spread, kraken_price_updates, bitfinex_price_updates, kucoin_price_updates, + exolix_price_updates, } } } #[derive(PartialEq, Clone, Debug, thiserror::Error)] pub enum Error { - #[error("All exchanges failed (Kraken: {0}, Bitfinex: {1}, KuCoin: {2})")] + #[error( + "All exchanges failed (Kraken: {0}, Bitfinex: {1}, KuCoin: {2}, Exolix: {})", + .3.as_ref().map(|e| e.to_string()).unwrap_or_else(|| "not configured".into()) + )] AllExchanges( crate::kraken::Error, crate::bitfinex::Error, crate::kucoin::Error, + Option, ), #[error("All exchange data is stale by >10 minutes")] AllStaleData, @@ -159,7 +166,11 @@ impl crate::traits::LatestRate for ExchangeRate { let kraken_update = self.kraken_price_updates.latest_update(); let bitfinex_update = self.bitfinex_price_updates.latest_update(); let kucoin_update = self.kucoin_price_updates.latest_update(); - average_ask(kraken_update, bitfinex_update, kucoin_update) + let exolix_update = self + .exolix_price_updates + .as_mut() + .map(|feed| feed.latest_update()); + average_ask(kraken_update, bitfinex_update, kucoin_update, exolix_update) .map(|average_ask| Rate::new(average_ask, self.ask_spread)) } } @@ -168,12 +179,21 @@ fn average_ask( kraken_update: crate::kraken::PriceUpdate, bitfinex_update: crate::bitfinex::PriceUpdate, kucoin_update: crate::kucoin::PriceUpdate, + exolix_update: Option, ) -> Result { - if kraken_update.is_err() && bitfinex_update.is_err() && kucoin_update.is_err() { + let exolix_configured = exolix_update.is_some(); + let expected_sources = 3 + usize::from(exolix_configured); + + let all_failed = kraken_update.is_err() + && bitfinex_update.is_err() + && kucoin_update.is_err() + && exolix_update.as_ref().map(|u| u.is_err()).unwrap_or(true); + if all_failed { return Err(Error::AllExchanges( kraken_update.unwrap_err(), bitfinex_update.unwrap_err(), kucoin_update.unwrap_err(), + exolix_update.map(|u| u.unwrap_err()), )); } @@ -181,10 +201,12 @@ fn average_ask( let kraken_update = kraken_update.map(|(ts, u)| (now - ts, u.ask)); let bitfinex_update = bitfinex_update.map(|(ts, u)| (now - ts, u.ask)); let kucoin_update = kucoin_update.map(|(ts, u)| (now - ts, u.ask)); + let exolix_update = exolix_update.map(|u| u.map(|(ts, u)| (now - ts, u.ask))); let asks: Vec<_> = [ kraken_update.as_ref().ok(), bitfinex_update.as_ref().ok(), kucoin_update.as_ref().ok(), + exolix_update.as_ref().and_then(|u| u.as_ref().ok()), ] .into_iter() .flatten() @@ -195,7 +217,7 @@ fn average_ask( if asks.is_empty() { return Err(Error::AllStaleData); } - let degraded = asks.len() < 3; + let degraded = asks.len() < expected_sources; let average_ask = asks.iter().copied().sum::() / (asks.len() as u64); let min_ask = asks.iter().min().expect(">0 asks"); @@ -204,9 +226,9 @@ fn average_ask( let spread = *max_ask - *min_ask; if degraded { - tracing::warn!(?kraken_update, ?bitfinex_update, ?kucoin_update, %average_ask, %spread, %degraded, "Computing latest XMR/BTC rate"); + tracing::warn!(?kraken_update, ?bitfinex_update, ?kucoin_update, ?exolix_update, %average_ask, %spread, %degraded, "Computing latest XMR/BTC rate"); } else { - tracing::debug!(?kraken_update, ?bitfinex_update, ?kucoin_update, %average_ask, %spread, %degraded, "Computing latest XMR/BTC rate"); + tracing::debug!(?kraken_update, ?bitfinex_update, ?kucoin_update, ?exolix_update, %average_ask, %spread, %degraded, "Computing latest XMR/BTC rate"); } if Decimal::from(spread.to_sat()) @@ -296,7 +318,7 @@ mod tests { }, )); assert_eq!( - average_ask(kraken_update, bitfinex_update, kucoin_update), + average_ask(kraken_update, bitfinex_update, kucoin_update, None), Ok(bitcoin::Amount::ONE_BTC) ); } @@ -318,7 +340,7 @@ mod tests { }, )); assert_eq!( - average_ask(kraken_update, bitfinex_update, kucoin_update), + average_ask(kraken_update, bitfinex_update, kucoin_update, None), Ok(bitcoin::Amount::ONE_BTC) ); } @@ -335,7 +357,7 @@ mod tests { )); let kucoin_update = Err(crate::kucoin::Error::NotYetAvailable); assert_eq!( - average_ask(kraken_update, bitfinex_update, kucoin_update), + average_ask(kraken_update, bitfinex_update, kucoin_update, None), Ok(bitcoin::Amount::ONE_BTC) ); } @@ -346,11 +368,12 @@ mod tests { let bitfinex_update = Err(crate::bitfinex::Error::NotYetAvailable); let kucoin_update = Err(crate::kucoin::Error::NotYetAvailable); assert_eq!( - average_ask(kraken_update, bitfinex_update, kucoin_update), + average_ask(kraken_update, bitfinex_update, kucoin_update, None), Err(Error::AllExchanges( crate::kraken::Error::NotYetAvailable, crate::bitfinex::Error::NotYetAvailable, - crate::kucoin::Error::NotYetAvailable + crate::kucoin::Error::NotYetAvailable, + None, )) ); } @@ -377,7 +400,7 @@ mod tests { }, )); assert_eq!( - average_ask(kraken_update, bitfinex_update, kucoin_update), + average_ask(kraken_update, bitfinex_update, kucoin_update, None), Err(Error::SpreadTooWide) ); } @@ -405,7 +428,7 @@ mod tests { }, )); assert_eq!( - average_ask(kraken_update, bitfinex_update, kucoin_update), + average_ask(kraken_update, bitfinex_update, kucoin_update, None), Ok(bitcoin::Amount::ONE_BTC) ); } @@ -428,7 +451,106 @@ mod tests { }, )); assert_eq!( - average_ask(kraken_update, bitfinex_update, kucoin_update), + average_ask(kraken_update, bitfinex_update, kucoin_update, None), + Ok(bitcoin::Amount::ONE_BTC) + ); + } + + #[test] + fn with_exolix() { + let now = Instant::now(); + let kraken_update = Ok(( + now, + crate::kraken::wire::PriceUpdate { + ask: bitcoin::Amount::from_btc(0.95).unwrap(), + }, + )); + let bitfinex_update = Ok(( + now, + crate::bitfinex::wire::PriceUpdate { + ask: bitcoin::Amount::from_btc(1.00).unwrap(), + }, + )); + let kucoin_update = Ok(( + now, + crate::kucoin::wire::PriceUpdate { + ask: bitcoin::Amount::from_btc(1.00).unwrap(), + }, + )); + let exolix_update = Some(Ok(( + now, + crate::exolix::wire::PriceUpdate { + ask: bitcoin::Amount::from_btc(1.05).unwrap(), + }, + ))); + assert_eq!( + average_ask(kraken_update, bitfinex_update, kucoin_update, exolix_update), + Ok(bitcoin::Amount::ONE_BTC) + ); + } + + #[test] + fn with_exolix_configured_but_failing() { + let now = Instant::now(); + let kraken_update = Ok(( + now, + crate::kraken::wire::PriceUpdate { + ask: bitcoin::Amount::from_btc(0.95).unwrap(), + }, + )); + let bitfinex_update = Ok(( + now, + crate::bitfinex::wire::PriceUpdate { + ask: bitcoin::Amount::from_btc(1.00).unwrap(), + }, + )); + let kucoin_update = Ok(( + now, + crate::kucoin::wire::PriceUpdate { + ask: bitcoin::Amount::from_btc(1.05).unwrap(), + }, + )); + // Exolix is configured but currently unavailable. We should + // still produce the average of the working feeds rather than + // erroring out. + let exolix_update = Some(Err(crate::exolix::Error::NotYetAvailable)); + assert_eq!( + average_ask(kraken_update, bitfinex_update, kucoin_update, exolix_update), + Ok(bitcoin::Amount::ONE_BTC) + ); + } + + #[test] + fn all_four_sources_failing_includes_exolix_in_error() { + let kraken_update = Err(crate::kraken::Error::NotYetAvailable); + let bitfinex_update = Err(crate::bitfinex::Error::NotYetAvailable); + let kucoin_update = Err(crate::kucoin::Error::NotYetAvailable); + let exolix_update = Some(Err(crate::exolix::Error::NotYetAvailable)); + assert_eq!( + average_ask(kraken_update, bitfinex_update, kucoin_update, exolix_update), + Err(Error::AllExchanges( + crate::kraken::Error::NotYetAvailable, + crate::bitfinex::Error::NotYetAvailable, + crate::kucoin::Error::NotYetAvailable, + Some(crate::exolix::Error::NotYetAvailable), + )) + ); + } + + #[test] + fn exolix_only_all_others_failed() { + let now = Instant::now(); + let kraken_update = Err(crate::kraken::Error::NotYetAvailable); + let bitfinex_update = Err(crate::bitfinex::Error::NotYetAvailable); + let kucoin_update = Err(crate::kucoin::Error::NotYetAvailable); + let exolix_update = Some(Ok(( + now, + crate::exolix::wire::PriceUpdate { + ask: bitcoin::Amount::ONE_BTC, + }, + ))); + assert_eq!( + average_ask(kraken_update, bitfinex_update, kucoin_update, exolix_update), Ok(bitcoin::Amount::ONE_BTC) ); } @@ -455,7 +577,7 @@ mod tests { }, )); assert_eq!( - average_ask(kraken_update, bitfinex_update, kucoin_update), + average_ask(kraken_update, bitfinex_update, kucoin_update, None), Err(Error::AllStaleData) ); } diff --git a/swap-orchestrator/src/main.rs b/swap-orchestrator/src/main.rs index 453000dc07..04dfcedb45 100644 --- a/swap-orchestrator/src/main.rs +++ b/swap-orchestrator/src/main.rs @@ -260,6 +260,8 @@ fn main() { 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, refund_policy: defaults.refund_policy, developer_tip,