zcash_client_backend: Add USD/ZEC exchange rate querying over Tor

This fetches price data from multiple exchanges over Tor, and takes the
median of the successful responses.

Closes zcash/librustzcash#1416.
This commit is contained in:
Jack Grigg 2024-07-18 00:17:04 +00:00
parent b3de2ab81b
commit 739e878c8b
7 changed files with 194 additions and 1 deletions

12
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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"

View File

@ -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",

View File

@ -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 {

View File

@ -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<T, F: Future<Output = Result<T, Error>>>(

View File

@ -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<ExchangeData, Error>;
}
/// 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<dyn Exchange>,
others: Vec<Box<dyn Exchange>>,
}
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<Decimal, Error> {
// 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<Decimal> = 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<Decimal>| {
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])
}
}
}