use foldhash::HashMap;
use alloy_primitives::{Address, U256};
use serde::{Deserialize, Serialize};
use crate::app_data::CowHook;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BridgeProviderType {
HookBridgeProvider,
ReceiverAccountBridgeProvider,
}
impl BridgeProviderType {
#[must_use]
pub const fn is_hook_bridge_provider(self) -> bool {
matches!(self, Self::HookBridgeProvider)
}
#[must_use]
pub const fn is_receiver_account_bridge_provider(self) -> bool {
matches!(self, Self::ReceiverAccountBridgeProvider)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BridgeProviderInfo {
pub name: String,
pub logo_url: String,
pub dapp_id: String,
pub website: String,
pub provider_type: BridgeProviderType,
}
impl BridgeProviderInfo {
#[must_use]
pub const fn is_hook_bridge_provider(&self) -> bool {
self.provider_type.is_hook_bridge_provider()
}
#[must_use]
pub const fn is_receiver_account_bridge_provider(&self) -> bool {
self.provider_type.is_receiver_account_bridge_provider()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BridgeStatus {
InProgress,
Executed,
Expired,
Refund,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BridgeStatusResult {
pub status: BridgeStatus,
pub fill_time_in_seconds: Option<u64>,
pub deposit_tx_hash: Option<String>,
pub fill_tx_hash: Option<String>,
}
impl BridgeStatusResult {
#[must_use]
pub const fn new(status: BridgeStatus) -> Self {
Self { status, fill_time_in_seconds: None, deposit_tx_hash: None, fill_tx_hash: None }
}
}
#[derive(Debug, Clone)]
pub struct QuoteBridgeRequest {
pub sell_chain_id: u64,
pub buy_chain_id: u64,
pub sell_token: Address,
pub sell_token_decimals: u8,
pub buy_token: Address,
pub buy_token_decimals: u8,
pub sell_amount: U256,
pub account: Address,
pub owner: Option<Address>,
pub receiver: Option<String>,
pub bridge_recipient: Option<String>,
pub slippage_bps: u32,
pub bridge_slippage_bps: Option<u32>,
pub kind: crate::OrderKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BridgeAmounts {
pub sell_amount: U256,
pub buy_amount: U256,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BridgeCosts {
pub bridging_fee: BridgingFee,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BridgingFee {
pub fee_bps: u32,
pub amount_in_sell_currency: U256,
pub amount_in_buy_currency: U256,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BridgeQuoteAmountsAndCosts {
pub costs: BridgeCosts,
pub before_fee: BridgeAmounts,
pub after_fee: BridgeAmounts,
pub after_slippage: BridgeAmounts,
pub slippage_bps: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BridgeLimits {
pub min_deposit: U256,
pub max_deposit: U256,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BridgeFees {
pub bridge_fee: U256,
pub destination_gas_fee: U256,
}
#[derive(Debug, Clone)]
pub struct QuoteBridgeResponse {
pub provider: String,
pub sell_amount: U256,
pub buy_amount: U256,
pub fee_amount: U256,
pub estimated_secs: u64,
pub bridge_hook: Option<CowHook>,
}
impl QuoteBridgeResponse {
#[must_use]
pub const fn has_bridge_hook(&self) -> bool {
self.bridge_hook.is_some()
}
#[must_use]
pub fn provider_ref(&self) -> &str {
&self.provider
}
#[must_use]
pub const fn net_buy_amount(&self) -> U256 {
self.buy_amount.saturating_sub(self.fee_amount)
}
}
#[derive(Debug, Clone)]
pub struct BridgeQuoteResult {
pub id: Option<String>,
pub signature: Option<String>,
pub attestation_signature: Option<String>,
pub quote_body: Option<String>,
pub is_sell: bool,
pub amounts_and_costs: BridgeQuoteAmountsAndCosts,
pub expected_fill_time_seconds: Option<u64>,
pub quote_timestamp: u64,
pub fees: BridgeFees,
pub limits: BridgeLimits,
}
#[derive(Debug, Clone)]
pub struct BridgeQuoteResults {
pub provider_info: BridgeProviderInfo,
pub quote: BridgeQuoteResult,
pub bridge_call_details: Option<BridgeCallDetails>,
pub bridge_receiver_override: Option<String>,
}
#[derive(Debug, Clone)]
pub struct BridgeCallDetails {
pub unsigned_bridge_call: crate::config::EvmCall,
pub pre_authorized_bridging_hook: BridgeHook,
}
#[derive(Debug, Clone)]
pub struct BridgeHook {
pub post_hook: CowHook,
pub recipient: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BridgingDepositParams {
pub input_token_address: Address,
pub output_token_address: Address,
pub input_amount: U256,
pub output_amount: Option<U256>,
pub owner: Address,
pub quote_timestamp: Option<u64>,
pub fill_deadline: Option<u64>,
pub recipient: Address,
pub source_chain_id: u64,
pub destination_chain_id: u64,
pub bridging_id: String,
}
#[derive(Debug, Clone)]
pub struct CrossChainOrder {
pub chain_id: u64,
pub status_result: BridgeStatusResult,
pub bridging_params: BridgingDepositParams,
pub trade_tx_hash: String,
pub explorer_url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct MultiQuoteResult {
pub provider_dapp_id: String,
pub quote: Option<BridgeQuoteAmountsAndCosts>,
pub error: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum BridgeError {
#[error("no providers available")]
NoProviders,
#[error("no quote available for this route")]
NoQuote,
#[error("sell and buy chains must be different for cross-chain bridging")]
SameChain,
#[error("bridging only supports SELL orders")]
OnlySellOrderSupported,
#[error("no intermediate tokens available")]
NoIntermediateTokens,
#[error("bridge API error: {0}")]
ApiError(String),
#[error("invalid API JSON response: {0}")]
InvalidApiResponse(String),
#[error("transaction build error: {0}")]
TxBuildError(String),
#[error("quote error: {0}")]
QuoteError(String),
#[error("no routes available")]
NoRoutes,
#[error("invalid bridge: {0}")]
InvalidBridge(String),
#[error("quote does not match deposit address")]
QuoteDoesNotMatchDepositAddress,
#[error("sell amount too small")]
SellAmountTooSmall,
#[error("provider not found: {dapp_id}")]
ProviderNotFound {
dapp_id: String,
},
#[error("provider request timed out")]
Timeout,
#[error(transparent)]
Cow(#[from] crate::CowError),
}
#[must_use]
pub const fn bridge_error_priority(error: &BridgeError) -> u32 {
match error {
BridgeError::SellAmountTooSmall => 10,
BridgeError::OnlySellOrderSupported => 9,
BridgeError::NoProviders |
BridgeError::NoQuote |
BridgeError::SameChain |
BridgeError::NoIntermediateTokens |
BridgeError::ApiError(_) |
BridgeError::InvalidApiResponse(_) |
BridgeError::TxBuildError(_) |
BridgeError::QuoteError(_) |
BridgeError::NoRoutes |
BridgeError::InvalidBridge(_) |
BridgeError::QuoteDoesNotMatchDepositAddress |
BridgeError::ProviderNotFound { .. } |
BridgeError::Timeout |
BridgeError::Cow(_) => 1,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcrossPctFee {
pub pct: String,
pub total: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcrossSuggestedFeesLimits {
pub min_deposit: String,
pub max_deposit: String,
pub max_deposit_instant: String,
pub max_deposit_short_delay: String,
pub recommended_deposit_instant: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcrossSuggestedFeesResponse {
pub total_relay_fee: AcrossPctFee,
pub relayer_capital_fee: AcrossPctFee,
pub relayer_gas_fee: AcrossPctFee,
pub lp_fee: AcrossPctFee,
pub timestamp: String,
pub is_amount_too_low: bool,
pub quote_block: String,
pub spoke_pool_address: String,
pub exclusive_relayer: String,
pub exclusivity_deadline: String,
pub estimated_fill_time_sec: String,
pub fill_deadline: String,
pub limits: AcrossSuggestedFeesLimits,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AcrossDepositStatus {
Filled,
SlowFillRequested,
Pending,
Expired,
Refunded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcrossDepositStatusResponse {
pub status: AcrossDepositStatus,
pub origin_chain_id: String,
pub deposit_id: String,
pub deposit_tx_hash: Option<String>,
pub fill_tx: Option<String>,
pub destination_chain_id: Option<String>,
pub deposit_refund_tx_hash: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AcrossDepositEvent {
pub input_token: Address,
pub output_token: Address,
pub input_amount: U256,
pub output_amount: U256,
pub destination_chain_id: u64,
pub deposit_id: U256,
pub quote_timestamp: u32,
pub fill_deadline: u32,
pub exclusivity_deadline: u32,
pub depositor: Address,
pub recipient: Address,
pub exclusive_relayer: Address,
}
#[derive(Debug, Clone)]
pub struct AcrossChainConfig {
pub chain_id: u64,
pub tokens: HashMap<String, Address>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BungeeBridge {
Across,
CircleCctp,
GnosisNative,
}
impl BungeeBridge {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Across => "across",
Self::CircleCctp => "cctp",
Self::GnosisNative => "gnosis-native-bridge",
}
}
#[must_use]
pub fn from_display_name(name: &str) -> Option<Self> {
match name {
"Across" => Some(Self::Across),
"Circle CCTP" => Some(Self::CircleCctp),
"Gnosis Native" => Some(Self::GnosisNative),
_ => None,
}
}
#[must_use]
pub const fn display_name(&self) -> &'static str {
match self {
Self::Across => "Across",
Self::CircleCctp => "Circle CCTP",
Self::GnosisNative => "Gnosis Native",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum BungeeEventStatus {
Completed,
Pending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BungeeBridgeName {
Across,
Cctp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BungeeEvent {
pub identifier: String,
pub src_transaction_hash: Option<String>,
pub bridge_name: BungeeBridgeName,
pub from_chain_id: u64,
pub is_cowswap_trade: bool,
pub order_id: String,
pub src_tx_status: BungeeEventStatus,
pub dest_tx_status: BungeeEventStatus,
pub dest_transaction_hash: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct BungeeTxDataBytesIndex {
pub bytes_start_index: usize,
pub bytes_length: usize,
pub bytes_string_start_index: usize,
pub bytes_string_length: usize,
}
#[derive(Debug, Clone)]
pub struct DecodedBungeeTxData {
pub route_id: String,
pub encoded_function_data: String,
pub function_selector: String,
}
#[derive(Debug, Clone)]
pub struct DecodedBungeeAmounts {
pub input_amount_bytes: String,
pub input_amount: U256,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hook_bridge_provider_is_hook() {
assert!(BridgeProviderType::HookBridgeProvider.is_hook_bridge_provider());
assert!(!BridgeProviderType::HookBridgeProvider.is_receiver_account_bridge_provider());
}
#[test]
fn receiver_account_bridge_provider_is_receiver() {
assert!(
BridgeProviderType::ReceiverAccountBridgeProvider.is_receiver_account_bridge_provider()
);
assert!(!BridgeProviderType::ReceiverAccountBridgeProvider.is_hook_bridge_provider());
}
#[test]
fn bridge_provider_info_delegates_hook() {
let info = BridgeProviderInfo {
name: "test".into(),
logo_url: String::new(),
dapp_id: String::new(),
website: String::new(),
provider_type: BridgeProviderType::HookBridgeProvider,
};
assert!(info.is_hook_bridge_provider());
assert!(!info.is_receiver_account_bridge_provider());
}
#[test]
fn bridge_provider_info_delegates_receiver() {
let info = BridgeProviderInfo {
name: "test".into(),
logo_url: String::new(),
dapp_id: String::new(),
website: String::new(),
provider_type: BridgeProviderType::ReceiverAccountBridgeProvider,
};
assert!(info.is_receiver_account_bridge_provider());
assert!(!info.is_hook_bridge_provider());
}
#[test]
fn bridge_status_variants_are_distinct() {
let statuses = [
BridgeStatus::InProgress,
BridgeStatus::Executed,
BridgeStatus::Expired,
BridgeStatus::Refund,
BridgeStatus::Unknown,
];
for (i, a) in statuses.iter().enumerate() {
for (j, b) in statuses.iter().enumerate() {
if i == j {
assert_eq!(a, b);
} else {
assert_ne!(a, b);
}
}
}
}
#[test]
fn bridge_status_result_new_sets_status_only() {
let r = BridgeStatusResult::new(BridgeStatus::Executed);
assert_eq!(r.status, BridgeStatus::Executed);
assert!(r.fill_time_in_seconds.is_none());
assert!(r.deposit_tx_hash.is_none());
assert!(r.fill_tx_hash.is_none());
}
#[test]
fn bridge_status_result_new_all_statuses() {
for status in [
BridgeStatus::InProgress,
BridgeStatus::Executed,
BridgeStatus::Expired,
BridgeStatus::Refund,
BridgeStatus::Unknown,
] {
let r = BridgeStatusResult::new(status);
assert_eq!(r.status, status);
}
}
fn make_quote(hook: Option<CowHook>, fee: U256) -> QuoteBridgeResponse {
QuoteBridgeResponse {
provider: "across".into(),
sell_amount: U256::from(1000u64),
buy_amount: U256::from(950u64),
fee_amount: fee,
estimated_secs: 60,
bridge_hook: hook,
}
}
#[test]
fn has_bridge_hook_true_when_some() {
let hook = CowHook {
target: "0xdead".into(),
call_data: "0x".into(),
gas_limit: "100000".into(),
dapp_id: None,
};
let q = make_quote(Some(hook), U256::ZERO);
assert!(q.has_bridge_hook());
}
#[test]
fn has_bridge_hook_false_when_none() {
let q = make_quote(None, U256::ZERO);
assert!(!q.has_bridge_hook());
}
#[test]
fn provider_ref_returns_provider_name() {
let q = make_quote(None, U256::ZERO);
assert_eq!(q.provider_ref(), "across");
}
#[test]
fn net_buy_amount_subtracts_fee() {
let q = make_quote(None, U256::from(50u64));
assert_eq!(q.net_buy_amount(), U256::from(900u64));
}
#[test]
fn net_buy_amount_saturates_at_zero() {
let q = make_quote(None, U256::from(2000u64));
assert_eq!(q.net_buy_amount(), U256::ZERO);
}
#[test]
fn net_buy_amount_zero_fee() {
let q = make_quote(None, U256::ZERO);
assert_eq!(q.net_buy_amount(), U256::from(950u64));
}
#[test]
fn bungee_bridge_as_str() {
assert_eq!(BungeeBridge::Across.as_str(), "across");
assert_eq!(BungeeBridge::CircleCctp.as_str(), "cctp");
assert_eq!(BungeeBridge::GnosisNative.as_str(), "gnosis-native-bridge");
}
#[test]
fn bungee_bridge_display_name() {
assert_eq!(BungeeBridge::Across.display_name(), "Across");
assert_eq!(BungeeBridge::CircleCctp.display_name(), "Circle CCTP");
assert_eq!(BungeeBridge::GnosisNative.display_name(), "Gnosis Native");
}
#[test]
fn bungee_bridge_from_display_name_valid() {
assert_eq!(BungeeBridge::from_display_name("Across"), Some(BungeeBridge::Across));
assert_eq!(BungeeBridge::from_display_name("Circle CCTP"), Some(BungeeBridge::CircleCctp));
assert_eq!(
BungeeBridge::from_display_name("Gnosis Native"),
Some(BungeeBridge::GnosisNative)
);
}
#[test]
fn bungee_bridge_from_display_name_invalid() {
assert_eq!(BungeeBridge::from_display_name("across"), None);
assert_eq!(BungeeBridge::from_display_name(""), None);
assert_eq!(BungeeBridge::from_display_name("Unknown"), None);
}
#[test]
fn bungee_bridge_roundtrip_display_name() {
for bridge in [BungeeBridge::Across, BungeeBridge::CircleCctp, BungeeBridge::GnosisNative] {
let name = bridge.display_name();
assert_eq!(BungeeBridge::from_display_name(name), Some(bridge));
}
}
#[test]
fn sell_amount_too_small_has_highest_priority() {
assert_eq!(bridge_error_priority(&BridgeError::SellAmountTooSmall), 10);
}
#[test]
fn only_sell_order_supported_has_second_priority() {
assert_eq!(bridge_error_priority(&BridgeError::OnlySellOrderSupported), 9);
}
#[test]
fn other_errors_have_base_priority() {
let base_errors: Vec<BridgeError> = vec![
BridgeError::NoProviders,
BridgeError::NoQuote,
BridgeError::SameChain,
BridgeError::NoIntermediateTokens,
BridgeError::ApiError("test".into()),
BridgeError::InvalidApiResponse("test".into()),
BridgeError::TxBuildError("test".into()),
BridgeError::QuoteError("test".into()),
BridgeError::NoRoutes,
BridgeError::InvalidBridge("test".into()),
BridgeError::QuoteDoesNotMatchDepositAddress,
BridgeError::ProviderNotFound { dapp_id: "test".into() },
BridgeError::Timeout,
];
for e in &base_errors {
assert_eq!(bridge_error_priority(e), 1, "expected priority 1 for {e}");
}
}
#[test]
fn bridge_error_display_messages() {
assert_eq!(BridgeError::NoProviders.to_string(), "no providers available");
assert_eq!(BridgeError::NoQuote.to_string(), "no quote available for this route");
assert_eq!(
BridgeError::SameChain.to_string(),
"sell and buy chains must be different for cross-chain bridging"
);
assert_eq!(
BridgeError::OnlySellOrderSupported.to_string(),
"bridging only supports SELL orders"
);
assert_eq!(
BridgeError::NoIntermediateTokens.to_string(),
"no intermediate tokens available"
);
assert_eq!(BridgeError::ApiError("oops".into()).to_string(), "bridge API error: oops");
assert_eq!(
BridgeError::InvalidApiResponse("bad".into()).to_string(),
"invalid API JSON response: bad"
);
assert_eq!(
BridgeError::TxBuildError("fail".into()).to_string(),
"transaction build error: fail"
);
assert_eq!(BridgeError::QuoteError("nope".into()).to_string(), "quote error: nope");
assert_eq!(BridgeError::NoRoutes.to_string(), "no routes available");
assert_eq!(BridgeError::InvalidBridge("x".into()).to_string(), "invalid bridge: x");
assert_eq!(
BridgeError::QuoteDoesNotMatchDepositAddress.to_string(),
"quote does not match deposit address"
);
assert_eq!(BridgeError::SellAmountTooSmall.to_string(), "sell amount too small");
assert_eq!(
BridgeError::ProviderNotFound { dapp_id: "foo".into() }.to_string(),
"provider not found: foo"
);
assert_eq!(BridgeError::Timeout.to_string(), "provider request timed out");
}
#[test]
fn bridge_provider_type_serde_roundtrip() {
for v in [
BridgeProviderType::HookBridgeProvider,
BridgeProviderType::ReceiverAccountBridgeProvider,
] {
let json = serde_json::to_string(&v).unwrap();
let back: BridgeProviderType = serde_json::from_str(&json).unwrap();
assert_eq!(v, back);
}
}
#[test]
fn bridge_status_serde_roundtrip() {
for v in [
BridgeStatus::InProgress,
BridgeStatus::Executed,
BridgeStatus::Expired,
BridgeStatus::Refund,
BridgeStatus::Unknown,
] {
let json = serde_json::to_string(&v).unwrap();
let back: BridgeStatus = serde_json::from_str(&json).unwrap();
assert_eq!(v, back);
}
}
#[test]
fn bungee_bridge_serde_roundtrip() {
for v in [BungeeBridge::Across, BungeeBridge::CircleCctp, BungeeBridge::GnosisNative] {
let json = serde_json::to_string(&v).unwrap();
let back: BungeeBridge = serde_json::from_str(&json).unwrap();
assert_eq!(v, back);
}
}
#[test]
fn across_deposit_status_serde_roundtrip() {
for v in [
AcrossDepositStatus::Filled,
AcrossDepositStatus::SlowFillRequested,
AcrossDepositStatus::Pending,
AcrossDepositStatus::Expired,
AcrossDepositStatus::Refunded,
] {
let json = serde_json::to_string(&v).unwrap();
let back: AcrossDepositStatus = serde_json::from_str(&json).unwrap();
assert_eq!(v, back);
}
}
#[test]
fn across_deposit_status_camel_case_serialization() {
assert_eq!(serde_json::to_string(&AcrossDepositStatus::Filled).unwrap(), "\"filled\"");
assert_eq!(
serde_json::to_string(&AcrossDepositStatus::SlowFillRequested).unwrap(),
"\"slowFillRequested\""
);
assert_eq!(serde_json::to_string(&AcrossDepositStatus::Pending).unwrap(), "\"pending\"");
}
#[test]
fn bungee_event_status_screaming_snake_case() {
assert_eq!(serde_json::to_string(&BungeeEventStatus::Completed).unwrap(), "\"COMPLETED\"");
assert_eq!(serde_json::to_string(&BungeeEventStatus::Pending).unwrap(), "\"PENDING\"");
}
#[test]
fn bungee_bridge_name_lowercase_serialization() {
assert_eq!(serde_json::to_string(&BungeeBridgeName::Across).unwrap(), "\"across\"");
assert_eq!(serde_json::to_string(&BungeeBridgeName::Cctp).unwrap(), "\"cctp\"");
}
}