Merge pull request #1422 from zcash/zcb-tor
zcash_client_backend: Add a Tor client that can fetch USD/ZEC exchange rates
This commit is contained in:
commit
1ef11c23b7
File diff suppressed because it is too large
Load Diff
29
Cargo.toml
29
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"
|
||||
|
@ -85,6 +88,15 @@ bs58 = { version = "0.5", features = ["check"] }
|
|||
byteorder = "1"
|
||||
hex = "0.4"
|
||||
percent-encoding = "2.1.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# HTTP
|
||||
hyper = "1"
|
||||
http-body-util = "0.1"
|
||||
hyper-util = { version = "0.1.1", features = ["tokio"] }
|
||||
tokio-rustls = "0.24"
|
||||
webpki-roots = "0.25"
|
||||
|
||||
# Logging and metrics
|
||||
memuse = "0.2.1"
|
||||
|
@ -104,6 +116,16 @@ tonic-build = { version = "0.12", default-features = false }
|
|||
secrecy = "0.8"
|
||||
subtle = "2.2.3"
|
||||
|
||||
# SQLite databases
|
||||
# - Warning: One of the downstream consumers requires that SQLite be available through
|
||||
# CocoaPods, due to being bound to React Native. We need to ensure that the SQLite
|
||||
# version required for `rusqlite` is a version that is available through CocoaPods.
|
||||
rusqlite = { version = "0.29.0", features = ["bundled"] }
|
||||
schemer = "0.2"
|
||||
schemer-rusqlite = "0.2.2"
|
||||
time = "0.3.22"
|
||||
uuid = "1.1"
|
||||
|
||||
# Static constants and assertions
|
||||
lazy_static = "1"
|
||||
static_assertions = "1"
|
||||
|
@ -115,6 +137,13 @@ proptest = "1"
|
|||
rand_chacha = "0.3"
|
||||
rand_xorshift = "0.3"
|
||||
|
||||
# Tor
|
||||
# - `arti-client` depends on `rusqlite`, and a version mismatch there causes a compilation
|
||||
# failure due to incompatible `libsqlite3-sys` versions.
|
||||
arti-client = { version = "0.11", default-features = false, features = ["compression", "rustls", "tokio"] }
|
||||
tokio = "1"
|
||||
tor-rtcompat = "0.9"
|
||||
|
||||
# ZIP 32
|
||||
aes = "0.8"
|
||||
fpe = "0.6"
|
||||
|
|
11
deny.toml
11
deny.toml
|
@ -31,16 +31,27 @@ allow = [
|
|||
]
|
||||
exceptions = [
|
||||
{ name = "arrayref", allow = ["BSD-2-Clause"] },
|
||||
{ name = "async_executors", allow = ["Unlicense"] },
|
||||
{ name = "bounded-vec-deque", allow = ["BSD-3-Clause"] },
|
||||
{ name = "coarsetime", allow = ["ISC"] },
|
||||
{ name = "curve25519-dalek", allow = ["BSD-3-Clause"] },
|
||||
{ name = "ed25519-dalek", allow = ["BSD-3-Clause"] },
|
||||
{ name = "matchit", allow = ["BSD-3-Clause"] },
|
||||
{ name = "minreq", allow = ["ISC"] },
|
||||
{ name = "option-ext", allow = ["MPL-2.0"] },
|
||||
{ name = "priority-queue", allow = ["MPL-2.0"] },
|
||||
{ name = "ring", allow = ["LicenseRef-ring"] },
|
||||
{ name = "rustls-webpki", allow = ["ISC"] },
|
||||
{ name = "secp256k1", allow = ["CC0-1.0"] },
|
||||
{ name = "secp256k1-sys", allow = ["CC0-1.0"] },
|
||||
{ name = "simple_asn1", allow = ["ISC"] },
|
||||
{ name = "slotmap", allow = ["Zlib"] },
|
||||
{ name = "subtle", allow = ["BSD-3-Clause"] },
|
||||
{ name = "tinystr", allow = ["Unicode-3.0"] },
|
||||
{ name = "unicode-ident", allow = ["Unicode-DFS-2016"] },
|
||||
{ name = "untrusted", allow = ["ISC"] },
|
||||
{ name = "webpki-roots", allow = ["MPL-2.0"] },
|
||||
{ name = "x25519-dalek", allow = ["BSD-3-Clause"] },
|
||||
]
|
||||
|
||||
[[licenses.clarify]]
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -38,6 +38,7 @@ funds to those addresses. See [ZIP 320](https://zips.z.cash/zip-0320) for detail
|
|||
- `zcash_client_backend::scanning`:
|
||||
- `testing` module
|
||||
- `zcash_client_backend::sync` module, behind the `sync` feature flag.
|
||||
- `zcash_client_backend::tor` module, behind the `tor` feature flag.
|
||||
- `zcash_client_backend::wallet::Recipient::map_ephemeral_transparent_outpoint`
|
||||
|
||||
### Changed
|
||||
|
|
|
@ -27,6 +27,7 @@ features = [
|
|||
"lightwalletd-tonic",
|
||||
"transparent-inputs",
|
||||
"test-dependencies",
|
||||
"tor",
|
||||
"unstable",
|
||||
"unstable-serialization",
|
||||
"unstable-spanning-tree",
|
||||
|
@ -94,6 +95,17 @@ jubjub = { workspace = true, optional = true }
|
|||
# - ZIP 321
|
||||
nom = "7"
|
||||
|
||||
# - Tor
|
||||
# -- Exposed error types: `arti_client::Error`, `arti_client::config::ConfigBuildError`,
|
||||
# `hyper::Error`, `hyper::http::Error`, `serde_json::Error`. We could avoid this with
|
||||
# changes to error handling.
|
||||
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
|
||||
|
@ -107,6 +119,18 @@ percent-encoding.workspace = true
|
|||
crossbeam-channel.workspace = true
|
||||
rayon.workspace = true
|
||||
|
||||
# - Tor
|
||||
tokio = { workspace = true, optional = true, features = ["fs"] }
|
||||
tor-rtcompat = { workspace = true, optional = true }
|
||||
|
||||
# - HTTP through Tor
|
||||
http-body-util = { workspace = true, optional = true }
|
||||
hyper-util = { workspace = true, optional = true }
|
||||
rand = { workspace = true, optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
tokio-rustls = { workspace = true, optional = true }
|
||||
webpki-roots = { workspace = true, optional = true }
|
||||
|
||||
[build-dependencies]
|
||||
tonic-build = { workspace = true, features = ["prost"] }
|
||||
which = "4"
|
||||
|
@ -148,6 +172,25 @@ sync = [
|
|||
"dep:futures-util",
|
||||
]
|
||||
|
||||
## Exposes a Tor client for hiding a wallet's IP address while performing certain wallet
|
||||
## 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",
|
||||
"dep:tokio-rustls",
|
||||
"dep:tor-rtcompat",
|
||||
"dep:webpki-roots",
|
||||
]
|
||||
|
||||
## Exposes APIs that are useful for testing, such as `proptest` strategies.
|
||||
test-dependencies = [
|
||||
"dep:proptest",
|
||||
|
|
|
@ -80,6 +80,9 @@ pub mod sync;
|
|||
#[cfg(feature = "unstable-serialization")]
|
||||
pub mod serialization;
|
||||
|
||||
#[cfg(feature = "tor")]
|
||||
pub mod tor;
|
||||
|
||||
pub use decrypt::{decrypt_transaction, DecryptedOutput, TransferType};
|
||||
pub use zcash_protocol::{PoolType, ShieldedProtocol};
|
||||
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
//! Tor support for Zcash wallets.
|
||||
|
||||
use std::{fmt, io, path::Path};
|
||||
|
||||
use arti_client::{config::TorClientConfigBuilder, TorClient};
|
||||
use tor_rtcompat::PreferredRuntime;
|
||||
use tracing::debug;
|
||||
|
||||
pub mod http;
|
||||
|
||||
/// A Tor client that exposes capabilities designed for Zcash wallets.
|
||||
pub struct Client {
|
||||
inner: TorClient<PreferredRuntime>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Creates and bootstraps a Tor client.
|
||||
///
|
||||
/// The client's persistent data and cache are both stored in the given directory.
|
||||
/// Preserving the contents of this directory will speed up subsequent calls to
|
||||
/// `Client::create`.
|
||||
///
|
||||
/// Returns an error if `tor_dir` does not exist, or if bootstrapping fails.
|
||||
pub async fn create(tor_dir: &Path) -> Result<Self, Error> {
|
||||
let runtime = PreferredRuntime::current()?;
|
||||
|
||||
if !tokio::fs::try_exists(tor_dir).await? {
|
||||
return Err(Error::MissingTorDirectory);
|
||||
}
|
||||
|
||||
let config = TorClientConfigBuilder::from_directories(
|
||||
tor_dir.join("arti-data"),
|
||||
tor_dir.join("arti-cache"),
|
||||
)
|
||||
.build()
|
||||
.expect("all required fields initialized");
|
||||
|
||||
let client_builder = TorClient::with_runtime(runtime).config(config);
|
||||
|
||||
debug!("Bootstrapping Tor");
|
||||
let inner = client_builder.create_bootstrapped().await?;
|
||||
debug!("Tor bootstrapped");
|
||||
|
||||
Ok(Self { inner })
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur while creating or using a Tor [`Client`].
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// The directory passed to [`Client::create`] does not exist.
|
||||
MissingTorDirectory,
|
||||
/// An error occurred while using HTTP-over-Tor.
|
||||
Http(self::http::HttpError),
|
||||
/// An IO error occurred while interacting with the filesystem.
|
||||
Io(io::Error),
|
||||
/// A Tor-specific error.
|
||||
Tor(arti_client::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::MissingTorDirectory => write!(f, "Tor directory is missing"),
|
||||
Error::Http(e) => write!(f, "HTTP-over-Tor error: {}", e),
|
||||
Error::Io(e) => write!(f, "IO error: {}", e),
|
||||
Error::Tor(e) => write!(f, "Tor error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Error::MissingTorDirectory => None,
|
||||
Error::Http(e) => Some(e),
|
||||
Error::Io(e) => Some(e),
|
||||
Error::Tor(e) => Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<self::http::HttpError> for Error {
|
||||
fn from(e: self::http::HttpError) -> Self {
|
||||
Error::Http(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(e: io::Error) -> Self {
|
||||
Error::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<arti_client::Error> for Error {
|
||||
fn from(e: arti_client::Error) -> Self {
|
||||
Error::Tor(e)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,209 @@
|
|||
//! HTTP requests over Tor.
|
||||
|
||||
use std::{fmt, future::Future, io, sync::Arc};
|
||||
|
||||
use futures_util::task::SpawnExt;
|
||||
use http_body_util::{BodyExt, Empty};
|
||||
use hyper::{
|
||||
body::{Buf, Bytes, Incoming},
|
||||
client::conn,
|
||||
http::{request::Builder, uri::Scheme},
|
||||
Request, Response, Uri,
|
||||
};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio_rustls::{
|
||||
rustls::{ClientConfig, OwnedTrustAnchor, RootCertStore, ServerName},
|
||||
TlsConnector,
|
||||
};
|
||||
use tor_rtcompat::PreferredRuntime;
|
||||
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>>>(
|
||||
&self,
|
||||
url: Uri,
|
||||
h: impl FnOnce(Builder) -> Builder,
|
||||
f: impl FnOnce(Incoming) -> F,
|
||||
) -> Result<Response<T>, Error> {
|
||||
let is_https = url.scheme().ok_or_else(|| HttpError::NonHttpUrl)? == &Scheme::HTTPS;
|
||||
|
||||
let host = url.host().ok_or_else(|| HttpError::NonHttpUrl)?.to_string();
|
||||
|
||||
let port = match url.port_u16() {
|
||||
Some(port) => port,
|
||||
None if is_https => 443,
|
||||
None => 80,
|
||||
};
|
||||
|
||||
// Connect to the server.
|
||||
debug!("Connecting through Tor to {}:{}", host, port);
|
||||
let stream = self.inner.connect((host.as_str(), port)).await?;
|
||||
|
||||
if is_https {
|
||||
// On apple-darwin targets there's an issue with the native TLS implementation
|
||||
// when used over Tor circuits. We use Rustls instead.
|
||||
//
|
||||
// https://gitlab.torproject.org/tpo/core/arti/-/issues/715
|
||||
let mut root_store = RootCertStore::empty();
|
||||
root_store.add_trust_anchors(webpki_roots::TLS_SERVER_ROOTS.iter().map(|root| {
|
||||
OwnedTrustAnchor::from_subject_spki_name_constraints(
|
||||
root.subject,
|
||||
root.spki,
|
||||
root.name_constraints,
|
||||
)
|
||||
}));
|
||||
let config = ClientConfig::builder()
|
||||
.with_safe_defaults()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
let connector = TlsConnector::from(Arc::new(config));
|
||||
let dnsname = ServerName::try_from(host.as_str()).expect("Already checked");
|
||||
let stream = connector
|
||||
.connect(dnsname, stream)
|
||||
.await
|
||||
.map_err(HttpError::Tls)?;
|
||||
make_http_request(stream, url, h, f).await
|
||||
} else {
|
||||
make_http_request(stream, url, h, f).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_json<T: DeserializeOwned>(&self, url: Uri) -> Result<Response<T>, Error> {
|
||||
self.get(
|
||||
url,
|
||||
|builder| builder.header(hyper::header::ACCEPT, "application/json"),
|
||||
|body| async {
|
||||
Ok(serde_json::from_reader(
|
||||
body.collect()
|
||||
.await
|
||||
.map_err(HttpError::from)?
|
||||
.aggregate()
|
||||
.reader(),
|
||||
)
|
||||
.map_err(HttpError::from)?)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_http_request<T, F: Future<Output = Result<T, Error>>>(
|
||||
stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
url: Uri,
|
||||
h: impl FnOnce(Builder) -> Builder,
|
||||
f: impl FnOnce(Incoming) -> F,
|
||||
) -> Result<Response<T>, Error> {
|
||||
debug!("Making request");
|
||||
let (mut sender, connection) = conn::http1::handshake(TokioIo::new(stream))
|
||||
.await
|
||||
.map_err(HttpError::from)?;
|
||||
|
||||
// Spawn a task to poll the connection and drive the HTTP state.
|
||||
PreferredRuntime::current()?
|
||||
.spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
error!("Connection failed: {}", e);
|
||||
}
|
||||
})
|
||||
.map_err(HttpError::from)?;
|
||||
|
||||
let req = h(Request::builder()
|
||||
.header(
|
||||
hyper::header::HOST,
|
||||
url.authority().expect("Already checked").as_str(),
|
||||
)
|
||||
.uri(url))
|
||||
.body(Empty::<Bytes>::new())
|
||||
.map_err(HttpError::from)?;
|
||||
let (parts, body) = sender
|
||||
.send_request(req)
|
||||
.await
|
||||
.map_err(HttpError::from)?
|
||||
.into_parts();
|
||||
debug!("Response status code: {}", parts.status);
|
||||
|
||||
if parts.status.is_success() {
|
||||
Ok(Response::from_parts(parts, f(body).await?))
|
||||
} else {
|
||||
Err(Error::Http(HttpError::Unsuccessful(parts.status)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occurr while using HTTP-over-Tor.
|
||||
#[derive(Debug)]
|
||||
pub enum HttpError {
|
||||
/// A non-HTTP URL was encountered.
|
||||
NonHttpUrl,
|
||||
/// An HTTP error.
|
||||
Http(hyper::http::Error),
|
||||
/// A [`hyper`] error.
|
||||
Hyper(hyper::Error),
|
||||
/// A JSON parsing error.
|
||||
Json(serde_json::Error),
|
||||
/// An error occurred while spawning a background worker task for driving the HTTP
|
||||
/// connection.
|
||||
Spawn(futures_util::task::SpawnError),
|
||||
/// A TLS-specific IO error.
|
||||
Tls(io::Error),
|
||||
/// The status code indicated that the request was unsuccessful.
|
||||
Unsuccessful(hyper::http::StatusCode),
|
||||
}
|
||||
|
||||
impl fmt::Display for HttpError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
HttpError::NonHttpUrl => write!(f, "Only HTTP or HTTPS URLs are supported"),
|
||||
HttpError::Http(e) => write!(f, "HTTP error: {}", e),
|
||||
HttpError::Hyper(e) => write!(f, "Hyper error: {}", e),
|
||||
HttpError::Json(e) => write!(f, "Failed to parse JSON: {}", e),
|
||||
HttpError::Spawn(e) => write!(f, "Failed to spawn task: {}", e),
|
||||
HttpError::Tls(e) => write!(f, "TLS error: {}", e),
|
||||
HttpError::Unsuccessful(status) => write!(f, "Request was unsuccessful ({:?})", status),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for HttpError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
HttpError::NonHttpUrl => None,
|
||||
HttpError::Http(e) => Some(e),
|
||||
HttpError::Hyper(e) => Some(e),
|
||||
HttpError::Json(e) => Some(e),
|
||||
HttpError::Spawn(e) => Some(e),
|
||||
HttpError::Tls(e) => Some(e),
|
||||
HttpError::Unsuccessful(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<hyper::http::Error> for HttpError {
|
||||
fn from(e: hyper::http::Error) -> Self {
|
||||
HttpError::Http(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<hyper::Error> for HttpError {
|
||||
fn from(e: hyper::Error) -> Self {
|
||||
HttpError::Hyper(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for HttpError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
HttpError::Json(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<futures_util::task::SpawnError> for HttpError {
|
||||
fn from(e: futures_util::task::SpawnError) -> Self {
|
||||
HttpError::Spawn(e)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,197 @@
|
|||
//! 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};
|
||||
|
||||
mod binance;
|
||||
mod coinbase;
|
||||
mod gate_io;
|
||||
mod gemini;
|
||||
mod ku_coin;
|
||||
mod mexc;
|
||||
|
||||
/// Exchanges for which we know how to query data over Tor.
|
||||
pub mod exchanges {
|
||||
pub use super::binance::Binance;
|
||||
pub use super::coinbase::Coinbase;
|
||||
pub use super::gate_io::GateIo;
|
||||
pub use super::gemini::Gemini;
|
||||
pub use super::ku_coin::KuCoin;
|
||||
pub use super::mexc::Mexc;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Unauthenticated connections to all known exchanges with USD/ZEC pairs.
|
||||
///
|
||||
/// Gemini is treated as a "trusted" data source due to being a NYDFS-regulated
|
||||
/// exchange.
|
||||
pub fn unauthenticated_known_with_gemini_trusted() -> Self {
|
||||
Self::builder(exchanges::Gemini::unauthenticated())
|
||||
.with(exchanges::Binance::unauthenticated())
|
||||
.with(exchanges::Coinbase::unauthenticated())
|
||||
.with(exchanges::GateIo::unauthenticated())
|
||||
.with(exchanges::KuCoin::unauthenticated())
|
||||
.with(exchanges::Mexc::unauthenticated())
|
||||
.build()
|
||||
}
|
||||
|
||||
/// 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])
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{Exchange, ExchangeData};
|
||||
use crate::tor::{Client, Error};
|
||||
|
||||
/// Querier for the Binance exchange.
|
||||
pub struct Binance {
|
||||
_private: (),
|
||||
}
|
||||
|
||||
impl Binance {
|
||||
/// Prepares for unauthenticated connections to Binance.
|
||||
pub fn unauthenticated() -> Self {
|
||||
Self { _private: () }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
#[allow(non_snake_case)]
|
||||
struct BinanceData {
|
||||
symbol: String,
|
||||
priceChange: Decimal,
|
||||
priceChangePercent: Decimal,
|
||||
weightedAvgPrice: Decimal,
|
||||
prevClosePrice: Decimal,
|
||||
lastPrice: Decimal,
|
||||
lastQty: Decimal,
|
||||
bidPrice: Decimal,
|
||||
bidQty: Decimal,
|
||||
askPrice: Decimal,
|
||||
askQty: Decimal,
|
||||
openPrice: Decimal,
|
||||
highPrice: Decimal,
|
||||
lowPrice: Decimal,
|
||||
volume: Decimal,
|
||||
quoteVolume: Decimal,
|
||||
openTime: u64,
|
||||
closeTime: u64,
|
||||
firstId: u32,
|
||||
lastId: u32,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Exchange for Binance {
|
||||
async fn query_zec_to_usd(&self, client: &Client) -> Result<ExchangeData, Error> {
|
||||
// API documentation:
|
||||
// https://binance-docs.github.io/apidocs/spot/en/#24hr-ticker-price-change-statistics
|
||||
let res = client
|
||||
.get_json::<BinanceData>(
|
||||
"https://api.binance.com/api/v3/ticker/24hr?symbol=ZECUSDT"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
)
|
||||
.await?;
|
||||
let data = res.into_body();
|
||||
Ok(ExchangeData {
|
||||
bid: data.bidPrice,
|
||||
ask: data.askPrice,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{Exchange, ExchangeData};
|
||||
use crate::tor::{Client, Error};
|
||||
|
||||
/// Querier for the Coinbase exchange.
|
||||
pub struct Coinbase {
|
||||
_private: (),
|
||||
}
|
||||
|
||||
impl Coinbase {
|
||||
/// Prepares for unauthenticated connections to Coinbase.
|
||||
pub fn unauthenticated() -> Self {
|
||||
Self { _private: () }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct CoinbaseData {
|
||||
ask: Decimal,
|
||||
bid: Decimal,
|
||||
volume: Decimal,
|
||||
trade_id: u32,
|
||||
price: Decimal,
|
||||
size: Decimal,
|
||||
time: String,
|
||||
rfq_volume: Option<Decimal>,
|
||||
conversions_volume: Option<Decimal>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Exchange for Coinbase {
|
||||
#[allow(dead_code)]
|
||||
async fn query_zec_to_usd(&self, client: &Client) -> Result<ExchangeData, Error> {
|
||||
// API documentation:
|
||||
// https://docs.cdp.coinbase.com/exchange/reference/exchangerestapi_getproductticker
|
||||
let res = client
|
||||
.get_json::<CoinbaseData>(
|
||||
"https://api.exchange.coinbase.com/products/ZEC-USD/ticker"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
)
|
||||
.await?;
|
||||
let data = res.into_body();
|
||||
Ok(ExchangeData {
|
||||
bid: data.bid,
|
||||
ask: data.ask,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
use async_trait::async_trait;
|
||||
use hyper::StatusCode;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{Exchange, ExchangeData};
|
||||
use crate::tor::{Client, Error};
|
||||
|
||||
/// Querier for the Gate.io exchange.
|
||||
pub struct GateIo {
|
||||
_private: (),
|
||||
}
|
||||
|
||||
impl GateIo {
|
||||
/// Prepares for unauthenticated connections to Gate.io.
|
||||
pub fn unauthenticated() -> Self {
|
||||
Self { _private: () }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct GateIoData {
|
||||
currency_pair: String,
|
||||
last: Decimal,
|
||||
lowest_ask: Decimal,
|
||||
highest_bid: Decimal,
|
||||
change_percentage: Decimal,
|
||||
base_volume: Decimal,
|
||||
quote_volume: Decimal,
|
||||
high_24h: Decimal,
|
||||
low_24h: Decimal,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Exchange for GateIo {
|
||||
async fn query_zec_to_usd(&self, client: &Client) -> Result<ExchangeData, Error> {
|
||||
// API documentation:
|
||||
// https://www.gate.io/docs/developers/apiv4/#retrieve-ticker-information
|
||||
let res = client
|
||||
.get_json::<Vec<GateIoData>>(
|
||||
"https://api.gateio.ws/api/v4/spot/tickers?currency_pair=ZEC_USDT"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
)
|
||||
.await?;
|
||||
let data = res.into_body().into_iter().next().ok_or(Error::Http(
|
||||
super::super::HttpError::Unsuccessful(StatusCode::GONE),
|
||||
))?;
|
||||
|
||||
Ok(ExchangeData {
|
||||
bid: data.highest_bid,
|
||||
ask: data.lowest_ask,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{Exchange, ExchangeData};
|
||||
use crate::tor::{Client, Error};
|
||||
|
||||
/// Querier for the Gemini exchange.
|
||||
pub struct Gemini {
|
||||
_private: (),
|
||||
}
|
||||
|
||||
impl Gemini {
|
||||
/// Prepares for unauthenticated connections to Gemini.
|
||||
pub fn unauthenticated() -> Self {
|
||||
Self { _private: () }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct GeminiData {
|
||||
symbol: String,
|
||||
open: Decimal,
|
||||
high: Decimal,
|
||||
low: Decimal,
|
||||
close: Decimal,
|
||||
changes: Vec<Decimal>,
|
||||
bid: Decimal,
|
||||
ask: Decimal,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Exchange for Gemini {
|
||||
async fn query_zec_to_usd(&self, client: &Client) -> Result<ExchangeData, Error> {
|
||||
// API documentation:
|
||||
// https://docs.gemini.com/rest-api/#ticker-v2
|
||||
let res = client
|
||||
.get_json::<GeminiData>("https://api.gemini.com/v2/ticker/zecusd".parse().unwrap())
|
||||
.await?;
|
||||
let data = res.into_body();
|
||||
Ok(ExchangeData {
|
||||
bid: data.bid,
|
||||
ask: data.ask,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{Exchange, ExchangeData};
|
||||
use crate::tor::{Client, Error};
|
||||
|
||||
/// Querier for the KuCoin exchange.
|
||||
pub struct KuCoin {
|
||||
_private: (),
|
||||
}
|
||||
|
||||
impl KuCoin {
|
||||
/// Prepares for unauthenticated connections to KuCoin.
|
||||
pub fn unauthenticated() -> Self {
|
||||
Self { _private: () }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
#[allow(non_snake_case)]
|
||||
struct KuCoinData {
|
||||
time: u64,
|
||||
symbol: String,
|
||||
buy: Decimal,
|
||||
sell: Decimal,
|
||||
changeRate: Decimal,
|
||||
changePrice: Decimal,
|
||||
high: Decimal,
|
||||
low: Decimal,
|
||||
vol: Decimal,
|
||||
volValue: Decimal,
|
||||
last: Decimal,
|
||||
averagePrice: Decimal,
|
||||
takerFeeRate: Decimal,
|
||||
makerFeeRate: Decimal,
|
||||
takerCoefficient: Decimal,
|
||||
makerCoefficient: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct KuCoinResponse {
|
||||
code: String,
|
||||
data: KuCoinData,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Exchange for KuCoin {
|
||||
async fn query_zec_to_usd(&self, client: &Client) -> Result<ExchangeData, Error> {
|
||||
// API documentation:
|
||||
// https://www.kucoin.com/docs/rest/spot-trading/market-data/get-24hr-stats
|
||||
let res = client
|
||||
.get_json::<KuCoinResponse>(
|
||||
"https://api.kucoin.com/api/v1/market/stats?symbol=ZEC-USDT"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
)
|
||||
.await?;
|
||||
let data = res.into_body().data;
|
||||
Ok(ExchangeData {
|
||||
bid: data.buy,
|
||||
ask: data.sell,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{Exchange, ExchangeData};
|
||||
use crate::tor::{Client, Error};
|
||||
|
||||
/// Querier for the MEXC exchange.
|
||||
pub struct Mexc {
|
||||
_private: (),
|
||||
}
|
||||
|
||||
impl Mexc {
|
||||
/// Prepares for unauthenticated connections to MEXC.
|
||||
pub fn unauthenticated() -> Self {
|
||||
Self { _private: () }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
#[allow(non_snake_case)]
|
||||
struct MexcData {
|
||||
symbol: String,
|
||||
priceChange: Decimal,
|
||||
priceChangePercent: Decimal,
|
||||
prevClosePrice: Decimal,
|
||||
lastPrice: Decimal,
|
||||
bidPrice: Decimal,
|
||||
bidQty: Decimal,
|
||||
askPrice: Decimal,
|
||||
askQty: Decimal,
|
||||
openPrice: Decimal,
|
||||
highPrice: Decimal,
|
||||
lowPrice: Decimal,
|
||||
volume: Decimal,
|
||||
quoteVolume: Decimal,
|
||||
openTime: u64,
|
||||
closeTime: u64,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Exchange for Mexc {
|
||||
async fn query_zec_to_usd(&self, client: &Client) -> Result<ExchangeData, Error> {
|
||||
// API documentation:
|
||||
// https://mexcdevelop.github.io/apidocs/spot_v3_en/#24hr-ticker-price-change-statistics
|
||||
let res = client
|
||||
.get_json::<MexcData>(
|
||||
"https://api.mexc.com/api/v3/ticker/24hr?symbol=ZECUSDT"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
)
|
||||
.await?;
|
||||
let data = res.into_body();
|
||||
Ok(ExchangeData {
|
||||
bid: data.bidPrice,
|
||||
ask: data.askPrice,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -66,14 +66,11 @@ incrementalmerkletree.workspace = true
|
|||
shardtree = { workspace = true, features = ["legacy-api"] }
|
||||
|
||||
# - SQLite databases
|
||||
# Warning: One of the downstream consumers requires that SQLite be available through
|
||||
# CocoaPods, due to being bound to React Native. We need to ensure that the SQLite
|
||||
# version required for `rusqlite` is a version that is available through CocoaPods.
|
||||
rusqlite = { version = "0.29.0", features = ["bundled", "time", "array"] }
|
||||
schemer = "0.2"
|
||||
schemer-rusqlite = "0.2.2"
|
||||
time = "0.3.22"
|
||||
uuid = "1.1"
|
||||
rusqlite = { workspace = true, features = ["time", "array"] }
|
||||
schemer.workspace = true
|
||||
schemer-rusqlite.workspace = true
|
||||
time.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
# Dependencies used internally:
|
||||
# (Breaking upgrades to these are usually backwards-compatible, but check MSRVs.)
|
||||
|
|
Loading…
Reference in New Issue