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:
Jack Grigg 2024-07-18 19:35:21 +01:00 committed by GitHub
commit 1ef11c23b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 4997 additions and 200 deletions

2682
Cargo.lock generated

File diff suppressed because it is too large Load Diff

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

View File

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

View File

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

View File

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

View File

@ -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};

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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])
}
}
}

View File

@ -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,
})
}
}

View File

@ -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,
})
}
}

View File

@ -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,
})
}
}

View File

@ -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,
})
}
}

View File

@ -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,
})
}
}

View File

@ -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,
})
}
}

View File

@ -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.)