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:
parent
b3de2ab81b
commit
739e878c8b
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>>>(
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue