diff --git a/Cargo.lock b/Cargo.lock index a6c7c590b4..7ea5ecd88f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3370,6 +3370,17 @@ dependencies = [ "time", ] +[[package]] +name = "rust_decimal" +version = "1.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1790d1c4c0ca81211399e0e0af16333276f375209e71a37b67698a373db5b47a" +dependencies = [ + "arrayvec", + "num-traits", + "serde", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -5618,6 +5629,7 @@ dependencies = [ "rand 0.8.5", "rand_core 0.6.4", "rayon", + "rust_decimal", "sapling-crypto", "secrecy", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3f8d7b6e55..11872ab9ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,9 @@ secp256k1 = "0.27" rand = "0.8" rand_core = "0.6" +# Currency conversions +rust_decimal = { version = "1.35", default-features = false, features = ["serde"] } + # Digests blake2b_simd = "1" sha2 = "0.10" diff --git a/zcash_client_backend/Cargo.toml b/zcash_client_backend/Cargo.toml index 42b33bb6fd..d46b117be2 100644 --- a/zcash_client_backend/Cargo.toml +++ b/zcash_client_backend/Cargo.toml @@ -102,6 +102,9 @@ arti-client = { workspace = true, optional = true } hyper = { workspace = true, optional = true, features = ["client", "http1"] } serde_json = { workspace = true, optional = true } +# - Currency conversion +rust_decimal = { workspace = true, optional = true } + # Dependencies used internally: # (Breaking upgrades to these are usually backwards-compatible, but check MSRVs.) # - Documentation @@ -176,6 +179,8 @@ tor = [ "dep:http-body-util", "dep:hyper", "dep:hyper-util", + "dep:rand", + "dep:rust_decimal", "dep:serde", "dep:serde_json", "dep:tokio", diff --git a/zcash_client_backend/src/tor/http.rs b/zcash_client_backend/src/tor/http.rs index d3261eb848..0228c47f01 100644 --- a/zcash_client_backend/src/tor/http.rs +++ b/zcash_client_backend/src/tor/http.rs @@ -20,6 +20,8 @@ use tracing::{debug, error}; use super::{Client, Error}; +mod exchange_rate; + impl Client { async fn get>>( &self, diff --git a/zcash_client_backend/src/tor/http/exchange_rate.rs b/zcash_client_backend/src/tor/http/exchange_rate.rs new file mode 100644 index 0000000000..78208cb75a --- /dev/null +++ b/zcash_client_backend/src/tor/http/exchange_rate.rs @@ -0,0 +1,130 @@ +use futures_util::{join, FutureExt}; +use rand::{seq::IteratorRandom, thread_rng}; +use rust_decimal::Decimal; +use serde::Deserialize; +use tracing::{error, trace}; + +use crate::tor::{Client, Error}; + +impl Client { + /// Fetches the latest USD/ZEC exchange rate. + /// + /// We query the ticker price of the following trading pairs: + /// - `ZECUSDT` on Binance + /// - `ZEC-USD` on Coinbase + /// - `zecusd` on Gemini + /// + /// Returns an error if all three exchange queries fail. + pub async fn get_exchange_rate_usd(&self) -> Result { + // "Never go to sea with two chronometers; take one or three." + + let binance_ticker = self + .get_json::( + "https://api.binance.com/api/v3/ticker/price?symbol=ZECUSDT" + .parse() + .unwrap(), + ) + .map(|res| res.map(|resp| resp.into_body())); + + let coinbase_ticker = self + .get_json::( + "https://api.exchange.coinbase.com/products/ZEC-USD/ticker" + .parse() + .unwrap(), + ) + .map(|res| res.map(|resp| resp.into_body())); + + let gemini_ticker = self + .get_json::("https://api.gemini.com/v2/ticker/zecusd".parse().unwrap()) + .map(|res| res.map(|resp| resp.into_body())); + + // Fetch the tickers in parallel. + let res = join!(binance_ticker, coinbase_ticker, gemini_ticker); + trace!(?res, "Exchange results"); + + match res { + // If all of the requests failed, log all errors and return one of them. + (Err(b), Err(c), Err(g)) => { + error!(binance = %b, coinbase = %c, gemini = %g, "All exchange requests failed"); + Err(b) + } + + // If only one request succeeded, use it. + (Ok(b), Err(_), Err(_)) => Ok(b.price), + (Err(_), Ok(c), Err(_)) => Ok(c.price), + (Err(_), Err(_), Ok(g)) => Ok(g.ask), + + // If two requests succeeded, pick one at random. + (Ok(b), Ok(c), Err(_)) => Ok([b.price, c.price] + .into_iter() + .choose(&mut thread_rng()) + .expect("Not empty")), + (Ok(b), Err(_), Ok(g)) => Ok([b.price, g.ask] + .into_iter() + .choose(&mut thread_rng()) + .expect("Not empty")), + (Err(_), Ok(c), Ok(g)) => Ok([c.price, g.ask] + .into_iter() + .choose(&mut thread_rng()) + .expect("Not empty")), + + // If all three succeeded, take the median. + (Ok(b), Ok(c), Ok(g)) => { + // Hacky median calculation because f64 can be compared but not ordered. + let b_lt_c = b.price < c.ask; + let c_lt_g = c.ask < g.ask; + let b_lt_g = b.price < g.ask; + let rate = if b_lt_c { + if c_lt_g { + c.ask + } else if b_lt_g { + g.ask + } else { + b.price + } + } else if b_lt_g { + b.price + } else if c_lt_g { + g.ask + } else { + c.ask + }; + + Ok(rate) + } + } + } +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct BinanceTicker { + symbol: String, + price: Decimal, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct CoinbaseTicker { + ask: Decimal, + bid: Decimal, + volume: Decimal, + trade_id: u32, + price: Decimal, + size: Decimal, + time: String, + rfq_volume: Decimal, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct GeminiTicker { + symbol: String, + open: Decimal, + high: Decimal, + low: Decimal, + close: Decimal, + changes: Vec, + bid: Decimal, + ask: Decimal, +}