diff --git a/Cargo.lock b/Cargo.lock index 4f1ca9995..9d9bc2d03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3349,6 +3349,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" @@ -5615,6 +5626,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 4f3966c79..fc050ad30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,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/supply-chain/config.toml b/supply-chain/config.toml index 2286b072a..9311656c0 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -984,6 +984,10 @@ criteria = "safe-to-deploy" version = "0.29.0" criteria = "safe-to-deploy" +[[exemptions.rust_decimal]] +version = "1.35.0" +criteria = "safe-to-deploy" + [[exemptions.rustix]] version = "0.38.34" criteria = "safe-to-deploy" diff --git a/zcash_client_backend/Cargo.toml b/zcash_client_backend/Cargo.toml index d85a1cf91..593d2d76c 100644 --- a/zcash_client_backend/Cargo.toml +++ b/zcash_client_backend/Cargo.toml @@ -103,6 +103,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 @@ -173,10 +176,13 @@ sync = [ ## operations. tor = [ "dep:arti-client", + "dep:async-trait", "dep:futures-util", "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.rs b/zcash_client_backend/src/tor.rs index 9552549f0..bab418a66 100644 --- a/zcash_client_backend/src/tor.rs +++ b/zcash_client_backend/src/tor.rs @@ -6,7 +6,7 @@ use arti_client::{config::TorClientConfigBuilder, TorClient}; use tor_rtcompat::PreferredRuntime; use tracing::debug; -mod http; +pub mod http; /// A Tor client that exposes capabilities designed for Zcash wallets. pub struct Client { diff --git a/zcash_client_backend/src/tor/http.rs b/zcash_client_backend/src/tor/http.rs index dcc289ba4..c1aa62bfc 100644 --- a/zcash_client_backend/src/tor/http.rs +++ b/zcash_client_backend/src/tor/http.rs @@ -22,6 +22,8 @@ use tracing::{debug, error}; use super::{Client, Error}; +pub mod cryptex; + impl Client { #[tracing::instrument(skip(self, h, f))] async fn get>>( diff --git a/zcash_client_backend/src/tor/http/cryptex.rs b/zcash_client_backend/src/tor/http/cryptex.rs new file mode 100644 index 000000000..510452473 --- /dev/null +++ b/zcash_client_backend/src/tor/http/cryptex.rs @@ -0,0 +1,166 @@ +//! Cryptocurrency exchange rate APIs. + +use async_trait::async_trait; +use futures_util::{future::join_all, join}; +use rand::{seq::IteratorRandom, thread_rng}; +use rust_decimal::Decimal; +use tracing::{error, trace}; + +use crate::tor::{Client, Error}; + +/// An exchange that can be queried for ZEC data. +#[async_trait] +pub trait Exchange: 'static { + /// Queries data about the USD/ZEC pair. + /// + /// The returned bid and ask data must be denominated in USD, i.e. the latest bid and + /// ask for 1 ZEC. + async fn query_zec_to_usd(&self, client: &Client) -> Result; +} + +/// Data queried from an [`Exchange`]. +#[derive(Debug)] +pub struct ExchangeData { + /// The highest current bid. + pub bid: Decimal, + + /// The lowest current ask. + pub ask: Decimal, +} + +impl ExchangeData { + /// Returns the mid-point between current best bid and current best ask, to avoid + /// manipulation by targeted trade fulfilment. + fn exchange_rate(&self) -> Decimal { + (self.bid + self.ask) / Decimal::TWO + } +} + +/// A set of [`Exchange`]s that can be queried for ZEC data. +pub struct Exchanges { + trusted: Box, + others: Vec>, +} + +impl Exchanges { + /// Returns an `Exchanges` builder. + /// + /// The `trusted` exchange will always have its data used, _if_ data is successfully + /// obtained via Tor (i.e. no transient failures). + pub fn builder(trusted: impl Exchange) -> ExchangesBuilder { + ExchangesBuilder::new(trusted) + } +} + +/// Builder type for [`Exchanges`]. +/// +/// Every [`Exchanges`] is configured with a "trusted" [`Exchange`] that will always have +/// its data used, if data is successfully obtained via Tor (i.e. no transient failures). +/// Additional data sources can be provided to [`ExchangesBuilder::with`] for resiliency +/// against transient network failures or adversarial market manipulation on individual +/// sources. +/// +/// The number of times [`ExchangesBuilder::with`] is called will affect the behaviour of +/// the final [`Exchanges`]: +/// - With no additional sources, the trusted [`Exchange`] is used on its own. +/// - With one additional source, the trusted [`Exchange`] is used preferentially, +/// with the additional source as a backup if the trusted source cannot be queried. +/// - With two or more additional sources, a minimum of three successful responses are +/// required from any of the sources. +pub struct ExchangesBuilder(Exchanges); + +impl ExchangesBuilder { + /// Constructs a new [`Exchanges`] builder. + /// + /// The `trusted` exchange will always have its data used, _if_ data is successfully + /// obtained via Tor (i.e. no transient failures). + pub fn new(trusted: impl Exchange) -> Self { + Self(Exchanges { + trusted: Box::new(trusted), + others: vec![], + }) + } + + /// Adds another [`Exchange`] as a data source. + pub fn with(mut self, other: impl Exchange) -> Self { + self.0.others.push(Box::new(other)); + self + } + + /// Builds the [`Exchanges`]. + pub fn build(self) -> Exchanges { + self.0 + } +} + +impl Client { + /// Fetches the latest USD/ZEC exchange rate, derived from the given exchanges. + /// + /// Returns: + /// - `Ok(rate)` if at least one exchange request succeeds. + /// - `Err(_)` if none of the exchange queries succeed. + pub async fn get_latest_zec_to_usd_rate( + &self, + exchanges: &Exchanges, + ) -> Result { + // Fetch the data in parallel. + let res = join!( + exchanges.trusted.query_zec_to_usd(self), + join_all(exchanges.others.iter().map(|e| e.query_zec_to_usd(self))) + ); + trace!(?res, "Data results"); + let (trusted_res, other_res) = res; + + // Split into successful queries and errors. + let mut rates: Vec = vec![]; + let mut errors = vec![]; + for res in other_res { + match res { + Ok(d) => rates.push(d.exchange_rate()), + Err(e) => errors.push(e), + } + } + + // "Never go to sea with two chronometers; take one or three." + // Randomly drop one rate if necessary to have an odd number of rates, as long as + // we have either at least three rates, or fewer than three sources. + if exchanges.others.len() >= 2 && rates.len() + usize::from(trusted_res.is_ok()) < 3 { + error!("Too many exchange requests failed"); + return Err(errors + .into_iter() + .next() + .expect("At least one request failed")); + } + let evict_random = |s: &mut Vec| { + if let Some(index) = (0..s.len()).choose(&mut thread_rng()) { + s.remove(index); + } + }; + match trusted_res { + Ok(trusted) => { + if rates.len() % 2 != 0 { + evict_random(&mut rates); + } + rates.push(trusted.exchange_rate()); + } + Err(e) => { + if rates.len() % 2 == 0 { + evict_random(&mut rates); + } + errors.push(e); + } + } + + // If all of the requests failed, log all errors and return one of them. + if rates.is_empty() { + error!("All exchange requests failed"); + Err(errors.into_iter().next().expect("All requests failed")) + } else { + // We have an odd number of rates; take the median. + assert!(rates.len() % 2 != 0); + rates.sort(); + let median = rates.len() / 2; + Ok(rates[median]) + } + } +}