use alloy_primitives::Address;
use serde::{Deserialize, Serialize};
use std::fmt;
#[cfg(feature = "subgraph")]
use crate::subgraph::SubgraphError;
use crate::{chain::UnsupportedChain, signature::SignatureError};
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[cfg(all(feature = "http-client", not(target_arch = "wasm32")))]
#[error("transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("transport error: {0}")]
TransportFailed(String),
#[error("serde error: {0}")]
Serde(#[from] serde_json::Error),
#[error("url error: {0}")]
Url(#[from] url::ParseError),
#[error(transparent)]
UnsupportedChain(#[from] UnsupportedChain),
#[error("orderbook error ({}{}): {}",
api.error_type,
api.data.as_ref().map(|_| ", +data").unwrap_or(""),
api.description,
)]
OrderbookApi {
status: u16,
api: ApiError,
},
#[error("unexpected orderbook status {status}: {body}")]
UnexpectedStatus {
status: u16,
body: String,
},
#[cfg(feature = "subgraph")]
#[error(transparent)]
Subgraph(#[from] SubgraphError),
#[error(transparent)]
Signature(#[from] SignatureError),
#[error(transparent)]
VerifyOwner(#[from] VerifyOwnerError),
#[error(transparent)]
AppData(#[from] crate::app_data::AppDataError),
#[error("invalid OrderCreation: {field} {reason}")]
OrderCreationInvalid {
field: &'static str,
reason: &'static str,
},
#[error("invalid QuoteRequest: {field} {reason}")]
QuoteRequestInvalid {
field: &'static str,
reason: &'static str,
},
#[error("chain mismatch: signing for {client} but the OrderBookApi targets {api}")]
ChainMismatch {
client: crate::chain::Chain,
api: crate::chain::Chain,
},
#[error("quote field {field} mismatch: requested {requested}, returned {returned}")]
QuoteFieldMismatch {
field: &'static str,
requested: String,
returned: String,
},
#[error("orderbook response exceeded {max} byte cap")]
ResponseTooLarge {
max: usize,
},
#[error("invalid protocol_fee_bps {value:?}: {reason}")]
InvalidProtocolFeeBps {
value: String,
reason: &'static str,
},
#[error("quote sellAmount is zero, network cost projection undefined")]
QuoteSellAmountZero,
#[error("quote fee math overflow at {stage}")]
QuoteFeeMathOverflow {
stage: &'static str,
},
#[error("app-data hash mismatch: expected {expected}, computed {computed}")]
AppDataHashMismatch {
expected: String,
computed: String,
},
}
#[derive(Debug, thiserror::Error)]
pub enum VerifyOwnerError {
#[error(transparent)]
Signature(#[from] SignatureError),
#[error("signer mismatch: declared {declared}, recovered {recovered}")]
SignerMismatch {
declared: Address,
recovered: Address,
},
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum OrderbookApiErrorType {
DuplicatedOrder,
DuplicateOrder,
QuoteNotFound,
QuoteNotVerified,
InvalidQuote,
MissingFrom,
WrongOwner,
InvalidEip1271Signature,
InsufficientBalance,
InsufficientAllowance,
InvalidSignature,
SellAmountOverflow,
TransferSimulationFailed,
ZeroAmount,
IncompatibleSigningScheme,
TooManyLimitOrders,
TooMuchGas,
UnsupportedBuyTokenDestination,
UnsupportedSellTokenSource,
UnsupportedOrderType,
InsufficientValidTo,
ExcessiveValidTo,
InvalidNativeSellToken,
SameBuyAndSellToken,
UnsupportedToken,
InvalidAppData,
AppDataHashMismatch,
AppDataMismatch,
AppdataFromMismatch,
MetadataSerializationFailed,
OldOrderActivelyBidOn,
Forbidden,
NonZeroFee,
InsufficientFee,
NoLiquidity,
Unknown(String),
}
impl OrderbookApiErrorType {
pub fn as_str(&self) -> &str {
match self {
Self::DuplicatedOrder => "DuplicatedOrder",
Self::DuplicateOrder => "DuplicateOrder",
Self::QuoteNotFound => "QuoteNotFound",
Self::QuoteNotVerified => "QuoteNotVerified",
Self::InvalidQuote => "InvalidQuote",
Self::MissingFrom => "MissingFrom",
Self::WrongOwner => "WrongOwner",
Self::InvalidEip1271Signature => "InvalidEip1271Signature",
Self::InsufficientBalance => "InsufficientBalance",
Self::InsufficientAllowance => "InsufficientAllowance",
Self::InvalidSignature => "InvalidSignature",
Self::SellAmountOverflow => "SellAmountOverflow",
Self::TransferSimulationFailed => "TransferSimulationFailed",
Self::ZeroAmount => "ZeroAmount",
Self::IncompatibleSigningScheme => "IncompatibleSigningScheme",
Self::TooManyLimitOrders => "TooManyLimitOrders",
Self::TooMuchGas => "TooMuchGas",
Self::UnsupportedBuyTokenDestination => "UnsupportedBuyTokenDestination",
Self::UnsupportedSellTokenSource => "UnsupportedSellTokenSource",
Self::UnsupportedOrderType => "UnsupportedOrderType",
Self::InsufficientValidTo => "InsufficientValidTo",
Self::ExcessiveValidTo => "ExcessiveValidTo",
Self::InvalidNativeSellToken => "InvalidNativeSellToken",
Self::SameBuyAndSellToken => "SameBuyAndSellToken",
Self::UnsupportedToken => "UnsupportedToken",
Self::InvalidAppData => "InvalidAppData",
Self::AppDataHashMismatch => "AppDataHashMismatch",
Self::AppDataMismatch => "AppDataMismatch",
Self::AppdataFromMismatch => "AppdataFromMismatch",
Self::MetadataSerializationFailed => "MetadataSerializationFailed",
Self::OldOrderActivelyBidOn => "OldOrderActivelyBidOn",
Self::Forbidden => "Forbidden",
Self::NonZeroFee => "NonZeroFee",
Self::InsufficientFee => "InsufficientFee",
Self::NoLiquidity => "NoLiquidity",
Self::Unknown(s) => s,
}
}
}
impl fmt::Display for OrderbookApiErrorType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl From<&str> for OrderbookApiErrorType {
fn from(value: &str) -> Self {
match value {
"DuplicatedOrder" => Self::DuplicatedOrder,
"DuplicateOrder" => Self::DuplicateOrder,
"QuoteNotFound" => Self::QuoteNotFound,
"QuoteNotVerified" => Self::QuoteNotVerified,
"InvalidQuote" => Self::InvalidQuote,
"MissingFrom" => Self::MissingFrom,
"WrongOwner" => Self::WrongOwner,
"InvalidEip1271Signature" => Self::InvalidEip1271Signature,
"InsufficientBalance" => Self::InsufficientBalance,
"InsufficientAllowance" => Self::InsufficientAllowance,
"InvalidSignature" => Self::InvalidSignature,
"SellAmountOverflow" => Self::SellAmountOverflow,
"TransferSimulationFailed" => Self::TransferSimulationFailed,
"ZeroAmount" => Self::ZeroAmount,
"IncompatibleSigningScheme" => Self::IncompatibleSigningScheme,
"TooManyLimitOrders" => Self::TooManyLimitOrders,
"TooMuchGas" => Self::TooMuchGas,
"UnsupportedBuyTokenDestination" => Self::UnsupportedBuyTokenDestination,
"UnsupportedSellTokenSource" => Self::UnsupportedSellTokenSource,
"UnsupportedOrderType" => Self::UnsupportedOrderType,
"InsufficientValidTo" => Self::InsufficientValidTo,
"ExcessiveValidTo" => Self::ExcessiveValidTo,
"InvalidNativeSellToken" => Self::InvalidNativeSellToken,
"SameBuyAndSellToken" => Self::SameBuyAndSellToken,
"UnsupportedToken" => Self::UnsupportedToken,
"InvalidAppData" => Self::InvalidAppData,
"AppDataHashMismatch" => Self::AppDataHashMismatch,
"AppDataMismatch" => Self::AppDataMismatch,
"AppdataFromMismatch" => Self::AppdataFromMismatch,
"MetadataSerializationFailed" => Self::MetadataSerializationFailed,
"OldOrderActivelyBidOn" => Self::OldOrderActivelyBidOn,
"Forbidden" => Self::Forbidden,
"NonZeroFee" => Self::NonZeroFee,
"InsufficientFee" => Self::InsufficientFee,
"NoLiquidity" => Self::NoLiquidity,
other => Self::Unknown(other.to_owned()),
}
}
}
impl From<String> for OrderbookApiErrorType {
fn from(value: String) -> Self {
Self::from(value.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum RetryHint {
Retry,
Backoff {
seconds: u64,
},
Drop,
AlreadySubmitted,
}
const WATCH_TOWER_APP_DATA_BACKOFF_SECS: u64 = 60;
const WATCH_TOWER_BALANCE_ALLOWANCE_BACKOFF_SECS: u64 = 10 * 60;
const WATCH_TOWER_LIMIT_ORDER_BACKOFF_SECS: u64 = 60 * 60;
impl RetryHint {
pub const fn is_retryable(self) -> bool {
matches!(self, Self::Retry | Self::Backoff { .. })
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ApiError {
#[serde(rename = "errorType")]
pub error_type: String,
pub description: String,
#[serde(default)]
pub data: Option<serde_json::Value>,
}
impl ApiError {
pub fn error_kind(&self) -> OrderbookApiErrorType {
OrderbookApiErrorType::from(self.error_type.as_str())
}
pub fn retry_hint(&self) -> RetryHint {
match self.error_kind() {
OrderbookApiErrorType::DuplicatedOrder | OrderbookApiErrorType::DuplicateOrder => {
RetryHint::AlreadySubmitted
}
OrderbookApiErrorType::QuoteNotFound
| OrderbookApiErrorType::InvalidQuote
| OrderbookApiErrorType::InsufficientValidTo
| OrderbookApiErrorType::InvalidEip1271Signature
| OrderbookApiErrorType::InsufficientFee => RetryHint::Retry,
OrderbookApiErrorType::InsufficientAllowance
| OrderbookApiErrorType::InsufficientBalance => RetryHint::Backoff {
seconds: WATCH_TOWER_BALANCE_ALLOWANCE_BACKOFF_SECS,
},
OrderbookApiErrorType::TooManyLimitOrders => RetryHint::Backoff {
seconds: WATCH_TOWER_LIMIT_ORDER_BACKOFF_SECS,
},
OrderbookApiErrorType::InvalidAppData => RetryHint::Backoff {
seconds: WATCH_TOWER_APP_DATA_BACKOFF_SECS,
},
OrderbookApiErrorType::QuoteNotVerified
| OrderbookApiErrorType::MissingFrom
| OrderbookApiErrorType::WrongOwner
| OrderbookApiErrorType::InvalidSignature
| OrderbookApiErrorType::SellAmountOverflow
| OrderbookApiErrorType::TransferSimulationFailed
| OrderbookApiErrorType::ZeroAmount
| OrderbookApiErrorType::IncompatibleSigningScheme
| OrderbookApiErrorType::TooMuchGas
| OrderbookApiErrorType::UnsupportedBuyTokenDestination
| OrderbookApiErrorType::UnsupportedSellTokenSource
| OrderbookApiErrorType::UnsupportedOrderType
| OrderbookApiErrorType::ExcessiveValidTo
| OrderbookApiErrorType::InvalidNativeSellToken
| OrderbookApiErrorType::SameBuyAndSellToken
| OrderbookApiErrorType::UnsupportedToken
| OrderbookApiErrorType::AppDataHashMismatch
| OrderbookApiErrorType::AppDataMismatch
| OrderbookApiErrorType::AppdataFromMismatch
| OrderbookApiErrorType::MetadataSerializationFailed
| OrderbookApiErrorType::OldOrderActivelyBidOn
| OrderbookApiErrorType::Forbidden
| OrderbookApiErrorType::NonZeroFee
| OrderbookApiErrorType::NoLiquidity
| OrderbookApiErrorType::Unknown(_) => RetryHint::Drop,
}
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.error_type, self.description)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_error_round_trips_minimal_body() {
let json = serde_json::json!({
"errorType": "InsufficientFee",
"description": "fee too low",
});
let parsed: ApiError = serde_json::from_value(json).unwrap();
assert_eq!(parsed.error_type, "InsufficientFee");
assert_eq!(parsed.description, "fee too low");
assert!(parsed.data.is_none());
}
#[test]
fn api_error_keeps_data_field_when_present() {
let json = serde_json::json!({
"errorType": "QuoteNotFound",
"description": "no quote for token pair",
"data": { "fee_amount": "1234" },
});
let parsed: ApiError = serde_json::from_value(json).unwrap();
assert!(parsed.data.is_some());
assert_eq!(parsed.data.unwrap()["fee_amount"], "1234");
}
#[test]
fn api_error_type_parses_known_and_unknown_values() {
assert_eq!(
OrderbookApiErrorType::from("DuplicatedOrder"),
OrderbookApiErrorType::DuplicatedOrder
);
assert_eq!(
OrderbookApiErrorType::from("DuplicateOrder"),
OrderbookApiErrorType::DuplicateOrder
);
assert_eq!(
OrderbookApiErrorType::from("NewServerError"),
OrderbookApiErrorType::Unknown("NewServerError".to_owned())
);
}
#[test]
fn api_error_exposes_typed_error_type() {
let api = ApiError {
error_type: "InsufficientBalance".to_owned(),
description: "balance too low".to_owned(),
data: None,
};
assert_eq!(api.error_kind(), OrderbookApiErrorType::InsufficientBalance);
}
#[test]
fn retry_hint_classifies_orderbook_errors() {
let cases = [
("DuplicatedOrder", RetryHint::AlreadySubmitted),
("DuplicateOrder", RetryHint::AlreadySubmitted),
("QuoteNotFound", RetryHint::Retry),
(
"InsufficientBalance",
RetryHint::Backoff {
seconds: WATCH_TOWER_BALANCE_ALLOWANCE_BACKOFF_SECS,
},
),
(
"InsufficientAllowance",
RetryHint::Backoff {
seconds: WATCH_TOWER_BALANCE_ALLOWANCE_BACKOFF_SECS,
},
),
(
"TooManyLimitOrders",
RetryHint::Backoff {
seconds: WATCH_TOWER_LIMIT_ORDER_BACKOFF_SECS,
},
),
("InvalidSignature", RetryHint::Drop),
("NewServerError", RetryHint::Drop),
];
for (error_type, expected) in cases {
let api = ApiError {
error_type: error_type.to_owned(),
description: "test".to_owned(),
data: None,
};
assert_eq!(api.retry_hint(), expected, "{error_type}");
}
}
}