diff --git a/oracle/examples/kintsugi-config.json b/oracle/examples/kintsugi-config.json index bd9774904..3cac35ede 100644 --- a/oracle/examples/kintsugi-config.json +++ b/oracle/examples/kintsugi-config.json @@ -90,12 +90,12 @@ ], "dia": [ [ - "USD", - "BTC=Bitcoin/0x0000000000000000000000000000000000000000" + "BTC=Bitcoin/0x0000000000000000000000000000000000000000", + "USD" ], [ - "USD", - "KSM=Kusama/0x0000000000000000000000000000000000000000" + "KSM=Kusama/0x0000000000000000000000000000000000000000", + "USD" ] ] } @@ -113,6 +113,24 @@ ] ] } + }, + { + "pair": [ + "BTC", + "VKSM" + ], + "feeds": { + "dia_fair_price": [ + [ + "BTC=KBTC", + "USD" + ], + [ + "VKSM", + "USD" + ] + ] + } } ] } \ No newline at end of file diff --git a/oracle/src/config.rs b/oracle/src/config.rs index 38b2c46e3..c8c706673 100644 --- a/oracle/src/config.rs +++ b/oracle/src/config.rs @@ -37,7 +37,7 @@ pub struct PriceConfig { #[serde(default)] pub value: Option, // Feeds to consume to calculate this exchange rate. - #[serde(default)] + #[serde(default = "BTreeMap::new")] pub feeds: BTreeMap>>, } diff --git a/oracle/src/currency.rs b/oracle/src/currency.rs index 638e4410c..f68315bb0 100644 --- a/oracle/src/currency.rs +++ b/oracle/src/currency.rs @@ -20,40 +20,52 @@ pub trait CurrencyInfo { fn decimals(&self, id: &Currency) -> Option; } -#[derive(Default, Debug, Clone, Eq, PartialOrd, Ord)] -pub struct Currency { - symbol: String, - path: Option, +#[derive(Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum Currency { + #[serde(deserialize_with = "deserialize_as_string")] + Symbol(String), + #[serde(deserialize_with = "deserialize_as_tuple")] + Path(String, String), } -impl<'de> Deserialize<'de> for Currency { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - use serde::de::Error; - let value = String::deserialize(deserializer)?; - match value.split('=').collect::>()[..] { - [symbol] => Ok(Self { - symbol: symbol.to_string(), - path: None, - }), - [symbol, path] => Ok(Self { - symbol: symbol.to_string(), - path: Some(path.to_string()), - }), - _ => Err(Error::custom("Invalid currency")), - } +fn deserialize_as_string<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + let value = String::deserialize(deserializer)?; + if value.contains('=') { + return Err(Error::custom("Not string")); + } + Ok(value) +} + +fn deserialize_as_tuple<'de, D>(deserializer: D) -> Result<(String, String), D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + let value = String::deserialize(deserializer)?; + match value.split('=').collect::>()[..] { + [symbol, path] => Ok((symbol.to_string(), path.to_string())), + _ => Err(Error::custom("Not tuple")), } } impl Currency { pub fn symbol(&self) -> String { - self.symbol.to_owned() + match self { + Self::Symbol(symbol) => symbol.to_owned(), + Self::Path(symbol, _) => symbol.to_owned(), + } } pub fn path(&self) -> Option { - self.path.to_owned() + match self { + Self::Symbol(_) => None, + Self::Path(_, path) => Some(path.to_owned()), + } } } @@ -131,7 +143,7 @@ impl fmt::Display for CurrencyPairAndPrice { } } -impl CurrencyPairAndPrice { +impl CurrencyPairAndPrice { pub fn invert(self) -> Self { Self { pair: self.pair.invert(), @@ -270,6 +282,12 @@ mod tests { // BTC/USD * DOT/BTC = USD/DOT assert_reduce!(("BTC" / "USD" @ 19184.24) * ("DOT" / "BTC" @ 0.00032457) = ("USD" / "DOT" @ 0.16060054900429147)); + + // BTC/USD * KSM/USD = BTC/KSM + assert_reduce!(("BTC" / "USD" @ 27356.159557758947) * ("KSM" / "USD" @ 19.743996225593296) = ("BTC" / "KSM" @ 1385.5431922286498)); + + // USD/BTC * USD/KSM = BTC/KSM + assert_reduce!(("USD" / "BTC" @ 3.655107115877481e-5) * ("USD" / "KSM" @ 0.05052177613811538) = ("BTC" / "KSM" @ 1382.2242286321239)); } #[test] diff --git a/oracle/src/error.rs b/oracle/src/error.rs index 282a7aa24..03f8b4958 100644 --- a/oracle/src/error.rs +++ b/oracle/src/error.rs @@ -45,7 +45,7 @@ pub enum Error { InvalidConfig(Box>), #[error("{0} not configured")] NotConfigured(FeedName), - #[error("Invalid dia symbol. Base must be USD & quote must be =. E.g. STDOT=Moonbeam/0xFA36Fe1dA08C89eC72Ea1F0143a35bFd5DAea108")] + #[error("Invalid dia symbol")] InvalidDiaSymbol, #[error("ReqwestError: {0}")] diff --git a/oracle/src/feeds.rs b/oracle/src/feeds.rs index bce2baa23..79cbb209d 100644 --- a/oracle/src/feeds.rs +++ b/oracle/src/feeds.rs @@ -2,6 +2,7 @@ mod blockcypher; mod blockstream; mod coingecko; mod dia; +mod dia_fair_price; mod gateio; mod kraken; @@ -22,6 +23,7 @@ pub use blockcypher::{BlockCypherApi, BlockCypherCli}; pub use blockstream::{BlockstreamApi, BlockstreamCli}; pub use coingecko::{CoinGeckoApi, CoinGeckoCli}; pub use dia::{DiaApi, DiaCli}; +pub use dia_fair_price::{DiaFairPriceApi, DiaFairPriceCli}; pub use gateio::{GateIoApi, GateIoCli}; pub use kraken::{KrakenApi, KrakenCli}; @@ -38,6 +40,8 @@ pub enum FeedName { GateIo, CoinGecko, Dia, + #[serde(rename = "dia_fair_price")] + DiaFairPrice, } impl fmt::Display for FeedName { @@ -83,6 +87,13 @@ impl PriceFeeds { } } + pub fn maybe_add_dia_fair_price(&mut self, opts: DiaFairPriceCli) { + if let Some(api) = DiaFairPriceApi::from_opts(opts) { + log::info!("🔗 DiaFairPrice"); + self.feeds.insert(FeedName::DiaFairPrice, Box::new(api)); + } + } + pub fn maybe_add_gateio(&mut self, opts: GateIoCli) { if let Some(api) = GateIoApi::from_opts(opts) { log::info!("🔗 gate.io"); diff --git a/oracle/src/feeds/dia.rs b/oracle/src/feeds/dia.rs index 59c3b3ca3..edff50e09 100644 --- a/oracle/src/feeds/dia.rs +++ b/oracle/src/feeds/dia.rs @@ -26,10 +26,7 @@ impl Default for DiaApi { } fn extract_response(value: Value) -> Option { - value - .get("Price")? - .as_f64() - .and_then(|x| if x.is_normal() { Some(1.0 / x) } else { None }) + value.get("Price")?.as_f64() } fn set_token_path(base: &mut Url, token_path: &str) { @@ -52,10 +49,10 @@ impl DiaApi { currency_pair: CurrencyPair, _currency_store: &CurrencyStore, ) -> Result, Error> { - if currency_pair.base.symbol() != "USD" { + if currency_pair.quote.symbol() != "USD" { return Err(Error::InvalidDiaSymbol); } - let token_path = currency_pair.quote.path().ok_or(Error::InvalidDiaSymbol)?; + let token_path = currency_pair.base.path().ok_or(Error::InvalidDiaSymbol)?; // https://docs.diadata.org/documentation/api-1/api-endpoints#asset-quotation let mut url = self.url.clone(); @@ -115,7 +112,7 @@ mod tests { "Time": "2022-10-21T07:35:24Z", "Source": "diadata.org" })), - Some(1.0 / 5.842649511778436) + Some(5.842649511778436) ) } } diff --git a/oracle/src/feeds/dia_fair_price.rs b/oracle/src/feeds/dia_fair_price.rs new file mode 100644 index 000000000..cc4a10e4c --- /dev/null +++ b/oracle/src/feeds/dia_fair_price.rs @@ -0,0 +1,112 @@ +#![allow(clippy::single_char_pattern)] +use super::{get_http, PriceFeed}; +use crate::{config::CurrencyStore, currency::*, Error}; +use async_trait::async_trait; +use clap::Parser; +use reqwest::Url; +use serde_json::Value; + +#[derive(Parser, Debug, Clone)] +pub struct DiaFairPriceCli { + /// Fetch the exchange rate from Dia xLSD feed + #[clap(long)] + dia_fair_price_url: Option, +} + +pub struct DiaFairPriceApi { + url: Url, +} + +impl Default for DiaFairPriceApi { + fn default() -> Self { + Self { + url: Url::parse("https://api.diadata.org/xlsd/").unwrap(), + } + } +} + +fn extract_response(value: Value, alias: &str) -> Option { + value + .as_array()? + .into_iter() + .find(|entry| matches!(entry.get("Token").and_then(|value| value.as_str()), Some(token) if token.to_uppercase() == alias))? + .get("FairPrice")? + .as_f64() +} + +impl DiaFairPriceApi { + pub fn from_opts(opts: DiaFairPriceCli) -> Option { + opts.dia_fair_price_url.map(Self::new) + } + + pub fn new(url: Url) -> Self { + Self { url } + } + + async fn get_exchange_rate( + &self, + currency_pair: CurrencyPair, + _currency_store: &CurrencyStore, + ) -> Result, Error> { + if currency_pair.quote.symbol() != "USD" { + return Err(Error::InvalidDiaSymbol); + } + // this allows us to override the expected token name + // which is helpful when using the xlsd feed of a wrapped token + // but we want to submit the currency as the underlying (e.g. KBTC -> BTC) + let alias = currency_pair.base.path().unwrap_or(currency_pair.base.symbol()); + + let url = self.url.clone(); + let data = get_http(url).await?; + + let price = extract_response(data, alias.as_str()).ok_or(Error::InvalidResponse)?; + + Ok(CurrencyPairAndPrice { + pair: currency_pair, + price, + }) + } +} + +#[async_trait] +impl PriceFeed for DiaFairPriceApi { + async fn get_price( + &self, + currency_pair: CurrencyPair, + currency_store: &CurrencyStore, + ) -> Result, Error> { + self.get_exchange_rate(currency_pair, currency_store).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn should_extract_response() { + assert_eq!( + extract_response( + json!([ + { + "Token": "KBTC", + "FairPrice": 27418.406434486784, + "BaseAssetSymbol": "BTC", + "BaseAssetPrice": 27418.406434486784, + "Issuer": "Interlay" + }, + { + "Token": "vKSM", + "FairPrice": 24.611983172737727, + "BaseAssetSymbol": "KSM", + "BaseAssetPrice": 19.827745134261495, + "Issuer": "Bifrost" + } + ]), + "KBTC", + ), + Some(27418.406434486784) + ) + } +} diff --git a/oracle/src/main.rs b/oracle/src/main.rs index 20f7c2d2c..713d7adb8 100644 --- a/oracle/src/main.rs +++ b/oracle/src/main.rs @@ -61,6 +61,10 @@ struct Opts { #[clap(flatten)] dia: feeds::DiaCli, + /// Connection settings for DiaFairPrice + #[clap(flatten)] + dia_fair_price: feeds::DiaFairPriceCli, + /// Connection settings for gate.io #[clap(flatten)] gateio: feeds::GateIoCli, @@ -177,6 +181,7 @@ async fn _main() -> Result<(), Error> { let mut price_feeds = feeds::PriceFeeds::new(currency_store.clone()); price_feeds.maybe_add_coingecko(opts.coingecko); price_feeds.maybe_add_dia(opts.dia); + price_feeds.maybe_add_dia_fair_price(opts.dia_fair_price); price_feeds.maybe_add_gateio(opts.gateio); price_feeds.maybe_add_kraken(opts.kraken);