use std::sync::Arc;
use crate::{
config::{Env, SupportedChainId, api_base_url, order_explorer_link},
error::CowError,
order_book::{
rate_limit::{RateLimiter, RetryPolicy},
types::{
AppDataObject, Auction, CompetitionOrderStatus, GetOrdersRequest, GetTradesRequest,
Order, OrderCancellations, OrderCreation, OrderQuoteRequest, OrderQuoteResponse,
OrderUid, SolverCompetition, TotalSurplus, Trade,
},
},
};
#[derive(Debug, Clone)]
pub struct OrderBookApi {
client: reqwest::Client,
base_url: String,
chain: SupportedChainId,
env: Env,
rate_limiter: Arc<RateLimiter>,
retry_policy: RetryPolicy,
extra_headers: Vec<(String, String)>,
}
impl OrderBookApi {
#[allow(clippy::shadow_reuse, reason = "builder pattern chains naturally shadow")]
fn build_client() -> reqwest::Client {
let builder = reqwest::Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let builder = builder.timeout(std::time::Duration::from_secs(30));
builder.build().unwrap_or_default()
}
#[must_use]
pub fn new(chain: SupportedChainId, env: Env) -> Self {
Self {
client: Self::build_client(),
base_url: api_base_url(chain, env).into(),
chain,
env,
rate_limiter: Arc::new(RateLimiter::default_orderbook()),
retry_policy: RetryPolicy::default_orderbook(),
extra_headers: Vec::new(),
}
}
#[must_use]
pub fn new_with_url(chain: SupportedChainId, env: Env, base_url: impl Into<String>) -> Self {
Self {
client: Self::build_client(),
base_url: base_url.into(),
chain,
env,
rate_limiter: Arc::new(RateLimiter::default_orderbook()),
retry_policy: RetryPolicy::default_orderbook(),
extra_headers: Vec::new(),
}
}
#[must_use]
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_headers.push((name.into(), value.into()));
self
}
#[must_use]
pub fn with_headers<K, V>(mut self, headers: impl IntoIterator<Item = (K, V)>) -> Self
where
K: Into<String>,
V: Into<String>,
{
for (k, v) in headers {
self.extra_headers.push((k.into(), v.into()));
}
self
}
#[must_use]
pub fn with_rate_limiter(mut self, limiter: Arc<RateLimiter>) -> Self {
self.rate_limiter = limiter;
self
}
#[must_use]
#[allow(
clippy::missing_const_for_fn,
reason = "RetryPolicy contains a Duration whose Drop is non-const; \
the lint fires spuriously on the reassignment"
)]
pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
self.retry_policy = policy;
self
}
pub async fn get_version(&self) -> Result<String, CowError> {
self.get("/api/v1/version").await
}
pub async fn get_quote(&self, req: &OrderQuoteRequest) -> Result<OrderQuoteResponse, CowError> {
self.post("/api/v1/quote", req).await
}
pub async fn send_order(&self, order: &OrderCreation) -> Result<String, CowError> {
let uid: OrderUid = self.post("/api/v1/orders", order).await?;
Ok(uid.0)
}
pub async fn get_order(&self, uid: &str) -> Result<Order, CowError> {
self.get(&format!("/api/v1/orders/{uid}")).await
}
pub async fn cancel_orders(&self, body: &OrderCancellations) -> Result<(), CowError> {
let url = format!("{}/api/v1/orders", self.base_url);
let resp = self.send_with_policy(|client| client.delete(&url).json(body)).await?;
if resp.status().is_success() { Ok(()) } else { Err(api_error(resp).await) }
}
pub async fn get_native_price(
&self,
token: alloy_primitives::Address,
) -> Result<f64, CowError> {
#[derive(serde::Deserialize)]
struct NativePrice {
price: f64,
}
let r: NativePrice = self.get(&format!("/api/v1/token/{token}/native_price")).await?;
Ok(r.price)
}
pub async fn get_orders_for_account(
&self,
owner: alloy_primitives::Address,
limit: Option<u32>,
) -> Result<Vec<Order>, CowError> {
let n = limit.map_or(1000, |v| v);
self.get(&format!("/api/v1/account/{owner}/orders?limit={n}")).await
}
pub async fn get_orders(&self, req: &GetOrdersRequest) -> Result<Vec<Order>, CowError> {
let limit = req.limit.map_or(1000, |v| v);
let offset = req.offset.map_or(0, |v| v);
self.get(&format!("/api/v1/account/{}/orders?limit={limit}&offset={offset}", req.owner))
.await
}
pub async fn get_order_multi_env(&self, uid: &str) -> Result<Order, CowError> {
match self.get_order(uid).await {
Ok(order) => return Ok(order),
Err(CowError::Api { status: 404, .. }) => {}
Err(e) => return Err(e),
}
let other_env = if matches!(self.env, Env::Prod) { Env::Staging } else { Env::Prod };
let other_url = api_base_url(self.chain, other_env);
let other = Self {
client: self.client.clone(),
base_url: other_url.into(),
chain: self.chain,
env: other_env,
rate_limiter: Arc::clone(&self.rate_limiter),
retry_policy: self.retry_policy.clone(),
extra_headers: self.extra_headers.clone(),
};
other.get_order(uid).await
}
#[must_use]
pub fn get_order_link(&self, uid: &str) -> String {
order_explorer_link(self.chain, uid)
}
pub async fn get_trades_for_account(
&self,
owner: alloy_primitives::Address,
limit: Option<u32>,
) -> Result<Vec<Trade>, CowError> {
let n = limit.map_or(10, |v| v);
self.get(&format!("/api/v2/trades?owner={owner}&limit={n}")).await
}
pub async fn get_trades(
&self,
order_uid: Option<&str>,
limit: Option<u32>,
) -> Result<Vec<Trade>, CowError> {
let n = limit.map_or(10, |v| v);
let path = match order_uid {
Some(uid) => format!("/api/v2/trades?orderUid={uid}&limit={n}"),
None => format!("/api/v2/trades?limit={n}"),
};
self.get(&path).await
}
pub async fn get_trades_with_request(
&self,
req: &GetTradesRequest,
) -> Result<Vec<Trade>, CowError> {
let limit = req.limit.map_or(10, |v| v);
let offset = req.offset.map_or(0, |v| v);
let mut params = format!("limit={limit}&offset={offset}");
if let Some(owner) = req.owner {
params.push_str(&format!("&owner={owner}"));
}
if let Some(uid) = &req.order_uid {
params.push_str(&format!("&orderUid={uid}"));
}
self.get(&format!("/api/v2/trades?{params}")).await
}
pub async fn get_auction(&self) -> Result<Auction, CowError> {
self.get("/api/v1/auction").await
}
pub async fn get_solver_competition(
&self,
auction_id: i64,
) -> Result<SolverCompetition, CowError> {
self.get(&format!("/api/v1/solver_competition/{auction_id}")).await
}
pub async fn get_solver_competition_by_tx(
&self,
tx_hash: &str,
) -> Result<SolverCompetition, CowError> {
self.get(&format!("/api/v1/solver_competition/by_tx_hash/{tx_hash}")).await
}
pub async fn get_order_status(&self, uid: &str) -> Result<CompetitionOrderStatus, CowError> {
self.get(&format!("/api/v1/orders/{uid}/status")).await
}
pub async fn get_orders_by_tx(&self, tx_hash: &str) -> Result<Vec<Order>, CowError> {
self.get(&format!("/api/v1/transactions/{tx_hash}/orders")).await
}
pub async fn get_solver_competition_latest(&self) -> Result<SolverCompetition, CowError> {
self.get("/api/v1/solver_competition/latest").await
}
pub async fn get_solver_competition_v2(
&self,
auction_id: i64,
) -> Result<SolverCompetition, CowError> {
self.get(&format!("/api/v2/solver_competition/{auction_id}")).await
}
pub async fn get_solver_competition_by_tx_v2(
&self,
tx_hash: &str,
) -> Result<SolverCompetition, CowError> {
self.get(&format!("/api/v2/solver_competition/by_tx_hash/{tx_hash}")).await
}
pub async fn get_solver_competition_latest_v2(&self) -> Result<SolverCompetition, CowError> {
self.get("/api/v2/solver_competition/latest").await
}
pub async fn get_total_surplus(
&self,
address: alloy_primitives::Address,
) -> Result<TotalSurplus, CowError> {
self.get(&format!("/api/v1/users/{address}/total_surplus")).await
}
pub async fn get_app_data(&self, app_data_hash: &str) -> Result<AppDataObject, CowError> {
self.get(&format!("/api/v1/app_data/{app_data_hash}")).await
}
pub async fn upload_app_data(
&self,
app_data_hash: &str,
full_app_data: &str,
) -> Result<AppDataObject, CowError> {
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
full_app_data: &'a str,
}
let url = format!("{}/api/v1/app_data/{app_data_hash}", self.base_url);
let resp = self.client.put(&url).json(&Body { full_app_data }).send().await?;
if resp.status().is_success() {
Ok(resp.json::<AppDataObject>().await?)
} else {
Err(api_error(resp).await)
}
}
pub async fn upload_app_data_auto(
&self,
full_app_data: &str,
) -> Result<AppDataObject, CowError> {
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
full_app_data: &'a str,
}
let url = format!("{}/api/v1/app_data", self.base_url);
let resp = self.client.put(&url).json(&Body { full_app_data }).send().await?;
if resp.status().is_success() {
Ok(resp.json::<AppDataObject>().await?)
} else {
Err(api_error(resp).await)
}
}
async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, CowError> {
let url = format!("{}{}", self.base_url, path);
let resp = self.send_with_policy(|client| client.get(&url)).await?;
if resp.status().is_success() {
Ok(resp.json::<T>().await?)
} else {
Err(api_error(resp).await)
}
}
async fn post<B, T>(&self, path: &str, body: &B) -> Result<T, CowError>
where
B: serde::Serialize,
T: serde::de::DeserializeOwned,
{
let url = format!("{}{}", self.base_url, path);
let resp = self.send_with_policy(|client| client.post(&url).json(body)).await?;
if resp.status().is_success() {
Ok(resp.json::<T>().await?)
} else {
Err(api_error(resp).await)
}
}
async fn send_with_policy<F>(&self, mut build: F) -> Result<reqwest::Response, CowError>
where
F: for<'a> FnMut(&'a reqwest::Client) -> reqwest::RequestBuilder,
{
let max = self.retry_policy.max_attempts.max(1);
let mut attempt: u32 = 0;
loop {
self.rate_limiter.acquire().await;
let mut builder = build(&self.client);
for (name, value) in &self.extra_headers {
builder = builder.header(name, value);
}
let result = builder.send().await;
let last_attempt = attempt + 1 >= max;
match result {
Ok(resp) => {
let status = resp.status().as_u16();
if !self.retry_policy.should_retry_status(status) || last_attempt {
return Ok(resp);
}
}
Err(e) => {
if !self.retry_policy.should_retry_error(&e) || last_attempt {
return Err(e.into());
}
}
}
let delay = self.retry_policy.delay_for_attempt(attempt);
self.retry_policy.wait(delay).await;
attempt += 1;
}
}
}
pub async fn request<T: serde::de::DeserializeOwned>(
base_url: &str,
path: &str,
method: reqwest::Method,
body: Option<&impl serde::Serialize>,
) -> Result<T, CowError> {
let client = reqwest::Client::new();
let url = format!("{base_url}{path}");
let mut req = client.request(method, &url).header("Accept", "application/json");
if let Some(b) = body {
req = req.json(b);
}
let resp = req.send().await?;
if resp.status().is_success() { Ok(resp.json().await?) } else { Err(api_error(resp).await) }
}
#[must_use]
pub fn mock_get_order(uid: &str) -> Order {
use crate::{OrderKind, SigningScheme, order_book::types::OrderStatus};
use alloy_primitives::Address;
Order {
uid: uid.to_owned(),
owner: Address::ZERO,
creation_date: "2024-01-01T00:00:00Z".to_owned(),
status: OrderStatus::Open,
class: None,
sell_token: Address::ZERO,
buy_token: Address::ZERO,
receiver: Some(Address::ZERO),
sell_amount: "1000000000000000000".to_owned(),
buy_amount: "900000000000000000".to_owned(),
valid_to: 1_999_999_999,
app_data: "0x0000000000000000000000000000000000000000000000000000000000000000".to_owned(),
full_app_data: None,
fee_amount: "0".to_owned(),
kind: OrderKind::Sell,
partially_fillable: false,
executed_sell_amount: "0".to_owned(),
executed_buy_amount: "0".to_owned(),
executed_sell_amount_before_fees: "0".to_owned(),
executed_fee_amount: "0".to_owned(),
invalidated: false,
is_liquidity_order: None,
signing_scheme: SigningScheme::Eip712,
signature: "0x".to_owned(),
interactions: None,
total_fee: None,
full_fee_amount: None,
available_balance: None,
quote_id: None,
executed_fee: None,
ethflow_data: None,
onchain_order_data: None,
onchain_user: None,
}
}
async fn api_error(resp: reqwest::Response) -> CowError {
let status = resp.status().as_u16();
let body = match resp.text().await {
Ok(text) => text,
Err(err) => {
tracing::warn!(%status, %err, "failed to read API error response body");
String::new()
}
};
CowError::Api { status, body }
}