use std::sync::Arc;
use alloy_primitives::{Address, B256, U256};
use alloy_signer_local::PrivateKeySigner;
use cow_chains::{EvmCall, SupportedChainId};
use cow_errors::CowError;
use cow_orderbook::types::Order;
use cow_shed::CowShedSdk;
use cow_types::OrderKind;
use foldhash::HashMap;
use crate::{
provider::{
BridgeNetworkInfo, BridgeProvider, BridgeStatusFuture, BridgingParamsFuture,
BuyTokensFuture, HookBridgeProvider, IntermediateTokensFuture, NetworksFuture, QuoteFuture,
SignedHookFuture, UnsignedCallFuture,
},
types::{
AcrossChainConfig, AcrossDepositStatus, AcrossSuggestedFeesResponse, BridgeAmounts,
BridgeCosts, BridgeError, BridgeFees, BridgeLimits, BridgeProviderInfo, BridgeProviderType,
BridgeQuoteAmountsAndCosts, BridgeQuoteResult, BridgeStatus, BridgeStatusResult,
BridgingFee, BuyTokensParams, GetProviderBuyTokens, IntermediateTokenInfo,
QuoteBridgeRequest, QuoteBridgeResponse,
},
utils::{apply_bps, apply_pct_fee, pct_to_bps},
};
#[must_use]
pub fn across_spoke_pool_addresses() -> HashMap<u64, Address> {
let mut m = HashMap::default();
let insert = |m: &mut HashMap<u64, Address>, chain_id: u64, addr: &str| {
if let Ok(a) = addr.parse::<Address>() {
m.insert(chain_id, a);
}
};
insert(
&mut m,
SupportedChainId::Mainnet.as_u64(),
"0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5",
);
insert(
&mut m,
SupportedChainId::ArbitrumOne.as_u64(),
"0xe35e9842fceaca96570b734083f4a58e8f7c5f2a",
);
insert(&mut m, SupportedChainId::Base.as_u64(), "0x09aea4b2242abC8bb4BB78D537A67a245A7bEC64");
insert(
&mut m,
SupportedChainId::Sepolia.as_u64(),
"0x5ef6C01E11889d86803e0B23e3cB3F9E9d97B662",
);
insert(
&mut m,
SupportedChainId::Polygon.as_u64(),
"0x9295ee1d8C5b022Be115A2AD3c30C72E34e7F096",
);
insert(
&mut m,
SupportedChainId::BnbChain.as_u64(),
"0x4e8E101924eDE233C13e2D8622DC8aED2872d505",
);
insert(&mut m, SupportedChainId::Linea.as_u64(), "0x7E63A5f1a8F0B4d0934B2f2327DAED3F6bb2ee75");
insert(&mut m, SupportedChainId::Plasma.as_u64(), "0x50039fAEfebef707cFD94D6d462fE6D10B39207a");
insert(&mut m, SupportedChainId::Ink.as_u64(), "0xeF684C38F94F48775959ECf2012D7E864ffb9dd4");
insert(&mut m, 10, "0x6f26Bf09B1C792e3228e5467807a900A503c0281");
m
}
#[must_use]
pub fn across_math_contract_addresses() -> HashMap<u64, Address> {
let mut m = HashMap::default();
let insert = |m: &mut HashMap<u64, Address>, chain_id: u64, addr: &str| {
if let Ok(a) = addr.parse::<Address>() {
m.insert(chain_id, a);
}
};
insert(
&mut m,
SupportedChainId::Mainnet.as_u64(),
"0xf2ae6728b6f146556977Af0A68bFbf5bADA22863",
);
insert(
&mut m,
SupportedChainId::ArbitrumOne.as_u64(),
"0x5771A4b4029832e79a75De7B485E5fBbec28848f",
);
insert(&mut m, SupportedChainId::Base.as_u64(), "0xd4e943dc6ddc885f6229ce33c2e3dfe402a12c81");
m
}
#[must_use]
pub fn across_token_mapping() -> HashMap<u64, AcrossChainConfig> {
let mut configs = HashMap::default();
let make_config = |chain_id: u64, tokens: &[(&str, &str)]| -> AcrossChainConfig {
let token_map: HashMap<String, Address> = tokens
.iter()
.filter_map(|(sym, addr)| addr.parse::<Address>().ok().map(|a| ((*sym).to_owned(), a)))
.collect();
AcrossChainConfig { chain_id, tokens: token_map }
};
configs.insert(
SupportedChainId::Mainnet.as_u64(),
make_config(
SupportedChainId::Mainnet.as_u64(),
&[
("usdc", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
("weth", "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
("wbtc", "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"),
("dai", "0x6B175474E89094C44Da98b954EedeAC495271d0F"),
("usdt", "0xdAC17F958D2ee523a2206206994597C13D831ec7"),
],
),
);
configs.insert(
SupportedChainId::Polygon.as_u64(),
make_config(
SupportedChainId::Polygon.as_u64(),
&[
("usdc", "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"),
("weth", "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619"),
("wbtc", "0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6"),
("dai", "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063"),
("usdt", "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"),
],
),
);
configs.insert(
SupportedChainId::ArbitrumOne.as_u64(),
make_config(
SupportedChainId::ArbitrumOne.as_u64(),
&[
("usdc", "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
("weth", "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
("wbtc", "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"),
("dai", "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"),
("usdt", "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"),
],
),
);
configs.insert(
SupportedChainId::Base.as_u64(),
make_config(
SupportedChainId::Base.as_u64(),
&[
("usdc", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"),
("weth", "0x4200000000000000000000000000000000000006"),
("dai", "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb"),
],
),
);
configs.insert(
10,
make_config(
10,
&[
("usdc", "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85"),
("weth", "0x4200000000000000000000000000000000000006"),
("wbtc", "0x68f180fcCe6836688e9084f035309E29Bf0A2095"),
("dai", "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"),
("usdt", "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58"),
],
),
);
configs
}
#[must_use]
pub fn get_chain_configs(
source_chain_id: u64,
target_chain_id: u64,
) -> Option<(AcrossChainConfig, AcrossChainConfig)> {
let mapping = across_token_mapping();
let source = mapping.get(&source_chain_id)?.clone();
let target = mapping.get(&target_chain_id)?.clone();
Some((source, target))
}
#[must_use]
pub fn get_token_symbol(
token_address: Address,
chain_config: &AcrossChainConfig,
) -> Option<String> {
chain_config.tokens.iter().find(|(_, addr)| **addr == token_address).map(|(sym, _)| sym.clone())
}
#[must_use]
pub fn get_token_address(token_symbol: &str, chain_config: &AcrossChainConfig) -> Option<Address> {
chain_config.tokens.get(token_symbol).copied()
}
#[must_use]
pub fn get_token_by_address_and_chain_id(
token_address: Address,
chain_id: u64,
) -> Option<(String, Address)> {
let mapping = across_token_mapping();
let config = mapping.get(&chain_id)?;
config
.tokens
.iter()
.find(|(_, addr)| **addr == token_address)
.map(|(sym, addr)| (sym.clone(), *addr))
}
pub fn to_bridge_quote_result(
request: &super::types::QuoteBridgeRequest,
slippage_bps: u32,
suggested_fees: &AcrossSuggestedFeesResponse,
) -> Result<BridgeQuoteResult, BridgeError> {
let amounts_and_costs = to_amounts_and_costs(request, slippage_bps, suggested_fees)?;
let bridge_fee =
suggested_fees.relayer_capital_fee.total.parse::<u128>().map_or(U256::ZERO, U256::from);
let destination_gas_fee =
suggested_fees.relayer_gas_fee.total.parse::<u128>().map_or(U256::ZERO, U256::from);
let min_deposit =
suggested_fees.limits.min_deposit.parse::<u128>().map_or(U256::ZERO, U256::from);
let max_deposit =
suggested_fees.limits.max_deposit.parse::<u128>().map_or(U256::ZERO, U256::from);
let quote_timestamp = suggested_fees.timestamp.parse::<u64>().map_or(0, |v| v);
let expected_fill_time = suggested_fees.estimated_fill_time_sec.parse::<u64>().ok();
Ok(BridgeQuoteResult {
id: None,
signature: None,
attestation_signature: None,
quote_body: serde_json::to_string(suggested_fees).ok(),
is_sell: request.kind == OrderKind::Sell,
amounts_and_costs,
expected_fill_time_seconds: expected_fill_time,
quote_timestamp,
fees: BridgeFees { bridge_fee, destination_gas_fee },
limits: BridgeLimits { min_deposit, max_deposit },
})
}
fn to_amounts_and_costs(
request: &super::types::QuoteBridgeRequest,
slippage_bps: u32,
suggested_fees: &AcrossSuggestedFeesResponse,
) -> Result<BridgeQuoteAmountsAndCosts, BridgeError> {
let sell_amount_before_fee = request.sell_amount;
let buy_decimals = U256::from(10u64).pow(U256::from(request.buy_token_decimals));
let sell_decimals = U256::from(10u64).pow(U256::from(request.sell_token_decimals));
let buy_amount_before_fee = (sell_amount_before_fee * buy_decimals) / sell_decimals;
let total_relay_fee_pct: u128 =
suggested_fees.total_relay_fee.pct.parse().map_err(|_parse_err| {
BridgeError::QuoteError("invalid totalRelayFee.pct".to_owned())
})?;
let buy_amount_after_fee = apply_pct_fee(buy_amount_before_fee, total_relay_fee_pct)?;
let fee_sell_token =
sell_amount_before_fee - apply_pct_fee(sell_amount_before_fee, total_relay_fee_pct)?;
let fee_buy_token = buy_amount_before_fee - buy_amount_after_fee;
let buy_amount_after_slippage = apply_bps(buy_amount_after_fee, slippage_bps);
let fee_bps = pct_to_bps(total_relay_fee_pct)?;
Ok(BridgeQuoteAmountsAndCosts {
before_fee: BridgeAmounts {
sell_amount: sell_amount_before_fee,
buy_amount: buy_amount_before_fee,
},
after_fee: BridgeAmounts {
sell_amount: sell_amount_before_fee,
buy_amount: buy_amount_after_fee,
},
after_slippage: BridgeAmounts {
sell_amount: sell_amount_before_fee,
buy_amount: buy_amount_after_slippage,
},
costs: BridgeCosts {
bridging_fee: BridgingFee {
fee_bps,
amount_in_sell_currency: fee_sell_token,
amount_in_buy_currency: fee_buy_token,
},
},
slippage_bps,
})
}
#[must_use]
pub const fn map_across_status_to_bridge_status(status: AcrossDepositStatus) -> BridgeStatus {
match status {
AcrossDepositStatus::Filled | AcrossDepositStatus::SlowFillRequested => {
BridgeStatus::Executed
}
AcrossDepositStatus::Pending => BridgeStatus::InProgress,
AcrossDepositStatus::Expired => BridgeStatus::Expired,
AcrossDepositStatus::Refunded => BridgeStatus::Refund,
}
}
#[must_use]
pub fn is_valid_across_status_response(response: &serde_json::Value) -> bool {
response.get("status").and_then(|s| s.as_str()).is_some()
}
pub const ACROSS_FUNDS_DEPOSITED_TOPIC: &str = "event FundsDeposited(bytes32 inputToken, bytes32 outputToken, uint256 inputAmount, uint256 outputAmount, uint256 indexed destinationChainId, uint256 indexed depositId, uint32 quoteTimestamp, uint32 fillDeadline, uint32 exclusivityDeadline, bytes32 indexed depositor, bytes32 recipient, bytes32 exclusiveRelayer, bytes message)";
pub const ACROSS_DEPOSIT_EVENT_INTERFACE: &str = ACROSS_FUNDS_DEPOSITED_TOPIC;
pub const COW_TRADE_EVENT_SIGNATURE: &str = "event Trade(address indexed owner, address sellToken, address buyToken, uint256 sellAmount, uint256 buyAmount, uint256 feeAmount, bytes orderUid)";
pub const COW_TRADE_EVENT_INTERFACE: &str = COW_TRADE_EVENT_SIGNATURE;
#[derive(Debug, Clone)]
pub struct EvmLogEntry {
pub address: Address,
pub topics: Vec<B256>,
pub data: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct CowTradeEvent {
pub owner: Address,
pub sell_token: Address,
pub buy_token: Address,
pub sell_amount: U256,
pub buy_amount: U256,
pub fee_amount: U256,
pub order_uid: String,
}
use crate::types::AcrossDepositEvent;
use alloy_primitives::{hex, keccak256};
#[must_use]
fn across_funds_deposited_topic0() -> B256 {
keccak256(
"FundsDeposited(bytes32,bytes32,uint256,uint256,uint256,uint256,uint32,uint32,uint32,bytes32,bytes32,bytes32,bytes)",
)
}
#[must_use]
fn cow_trade_event_topic0() -> B256 {
keccak256("Trade(address,address,address,uint256,uint256,uint256,bytes)")
}
#[must_use]
fn bytes32_to_address(word: &[u8]) -> Address {
if word.len() < 32 {
return Address::ZERO;
}
Address::from_slice(&word[12..32])
}
const fn bytes32_to_u256(word: &[u8]) -> U256 {
if word.len() < 32 {
return U256::ZERO;
}
U256::from_be_slice(word)
}
#[must_use]
fn bytes32_to_u32(word: &[u8]) -> u32 {
if word.len() < 32 {
return 0;
}
u32::from_be_bytes([word[28], word[29], word[30], word[31]])
}
#[must_use]
pub fn get_across_deposit_events(chain_id: u64, logs: &[EvmLogEntry]) -> Vec<AcrossDepositEvent> {
let spoke_pool_addresses = across_spoke_pool_addresses();
let Some(spoke_pool_address) = spoke_pool_addresses.get(&chain_id) else {
return vec![];
};
let topic0 = across_funds_deposited_topic0();
logs.iter()
.filter(|log| {
log.address == *spoke_pool_address && log.topics.first().is_some_and(|t| *t == topic0)
})
.filter_map(parse_across_deposit_event)
.collect()
}
fn parse_across_deposit_event(log: &EvmLogEntry) -> Option<AcrossDepositEvent> {
if log.topics.len() < 4 {
return None;
}
let destination_chain_id = bytes32_to_u256(log.topics[1].as_slice()).to::<u64>();
let deposit_id = bytes32_to_u256(log.topics[2].as_slice());
let depositor = bytes32_to_address(log.topics[3].as_slice());
let data = &log.data;
if data.len() < 9 * 32 {
return None;
}
let input_token = bytes32_to_address(&data[0..32]);
let output_token = bytes32_to_address(&data[32..64]);
let input_amount = bytes32_to_u256(&data[64..96]);
let output_amount = bytes32_to_u256(&data[96..128]);
let quote_timestamp = bytes32_to_u32(&data[128..160]);
let fill_deadline = bytes32_to_u32(&data[160..192]);
let exclusivity_deadline = bytes32_to_u32(&data[192..224]);
let recipient = bytes32_to_address(&data[224..256]);
let exclusive_relayer = bytes32_to_address(&data[256..288]);
Some(AcrossDepositEvent {
input_token,
output_token,
input_amount,
output_amount,
destination_chain_id,
deposit_id,
quote_timestamp,
fill_deadline,
exclusivity_deadline,
depositor,
recipient,
exclusive_relayer,
})
}
#[must_use]
pub fn get_cow_trade_events(
chain_id: u64,
logs: &[EvmLogEntry],
settlement_override: Option<Address>,
) -> Vec<CowTradeEvent> {
let chain = cow_chains::SupportedChainId::try_from_u64(chain_id);
let default_prod = chain.map(cow_chains::settlement_contract);
let default_staging =
chain.map(|c| cow_chains::settlement_contract_for_env(c, cow_chains::Env::Staging));
let topic0 = cow_trade_event_topic0();
logs.iter()
.filter(|log| {
let addr_match = default_prod.is_some_and(|a| a == log.address) ||
default_staging.is_some_and(|a| a == log.address) ||
settlement_override.is_some_and(|a| a == log.address);
addr_match && log.topics.first().is_some_and(|t| *t == topic0)
})
.filter_map(parse_cow_trade_event)
.collect()
}
fn parse_cow_trade_event(log: &EvmLogEntry) -> Option<CowTradeEvent> {
if log.topics.len() < 2 {
return None;
}
let owner = bytes32_to_address(log.topics[1].as_slice());
let data = &log.data;
if data.len() < 7 * 32 {
return None;
}
let sell_token = bytes32_to_address(&data[0..32]);
let buy_token = bytes32_to_address(&data[32..64]);
let sell_amount = bytes32_to_u256(&data[64..96]);
let buy_amount = bytes32_to_u256(&data[96..128]);
let fee_amount = bytes32_to_u256(&data[128..160]);
let offset = bytes32_to_u256(&data[160..192]).to::<usize>();
let uid_start = offset + 32; if data.len() < offset + 32 {
return None;
}
let uid_len = bytes32_to_u256(&data[offset..offset + 32]).to::<usize>();
if data.len() < uid_start + uid_len {
return None;
}
let order_uid = format!("0x{}", hex::encode(&data[uid_start..uid_start + uid_len]));
Some(CowTradeEvent {
owner,
sell_token,
buy_token,
sell_amount,
buy_amount,
fee_amount,
order_uid,
})
}
use crate::types::BridgingDepositParams;
#[must_use]
pub fn get_deposit_params(
chain_id: u64,
order_id: &str,
logs: &[EvmLogEntry],
settlement_override: Option<Address>,
) -> Option<BridgingDepositParams> {
let deposit_events = get_across_deposit_events(chain_id, logs);
if deposit_events.is_empty() {
return None;
}
let cow_trade_events = get_cow_trade_events(chain_id, logs, settlement_override);
let order_trade_index = cow_trade_events.iter().position(|e| e.order_uid == order_id)?;
let deposit_event = deposit_events.get(order_trade_index)?;
Some(BridgingDepositParams {
input_token_address: deposit_event.input_token,
output_token_address: deposit_event.output_token,
input_amount: deposit_event.input_amount,
output_amount: Some(deposit_event.output_amount),
owner: deposit_event.depositor,
quote_timestamp: Some(u64::from(deposit_event.quote_timestamp)),
fill_deadline: Some(u64::from(deposit_event.fill_deadline)),
recipient: deposit_event.recipient,
source_chain_id: chain_id,
destination_chain_id: deposit_event.destination_chain_id,
bridging_id: deposit_event.deposit_id.to_string(),
})
}
#[derive(Debug, Clone)]
pub struct AcrossDepositCallParams {
pub request: QuoteBridgeRequest,
pub suggested_fees: AcrossSuggestedFeesResponse,
pub cow_shed_account: Address,
}
pub fn create_across_deposit_call(
params: &AcrossDepositCallParams,
) -> Result<cow_chains::EvmCall, BridgeError> {
let spoke_pools = across_spoke_pool_addresses();
let spoke_pool = spoke_pools.get(¶ms.request.sell_chain_id).ok_or_else(|| {
BridgeError::TxBuildError(format!(
"spoke pool not found for chain {}",
params.request.sell_chain_id
))
})?;
let receiver = params
.request
.receiver
.as_deref()
.and_then(|r| r.parse::<Address>().ok())
.map_or(params.request.account, |a| a);
let suggested = ¶ms.suggested_fees;
let fill_deadline: u32 = suggested.fill_deadline.parse().map_or(0, |v| v);
let exclusivity_deadline: u32 = suggested.exclusivity_deadline.parse().map_or(0, |v| v);
let quote_timestamp: u32 = suggested.timestamp.parse().map_or(0, |v| v);
let exclusive_relayer: Address =
suggested.exclusive_relayer.parse().map_or(Address::ZERO, |a| a);
let selector = &keccak256(
"depositV3(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes)",
)[..4];
let mut calldata = Vec::with_capacity(4 + 12 * 32 + 64);
calldata.extend_from_slice(selector);
calldata.extend_from_slice(&left_pad_address(params.cow_shed_account));
calldata.extend_from_slice(&left_pad_address(receiver));
calldata.extend_from_slice(&left_pad_address(params.request.sell_token));
let buy_token = params.request.buy_token.to_evm().ok_or_else(|| {
BridgeError::TxBuildError("Across requires an EVM buy_token; got TokenAddress::Raw".into())
})?;
calldata.extend_from_slice(&left_pad_address(buy_token));
calldata.extend_from_slice(&pad_u256(params.request.sell_amount));
let total_fee_pct: u128 = suggested.total_relay_fee.pct.parse().map_or(0, |v| v);
let output_amount = crate::utils::apply_pct_fee(params.request.sell_amount, total_fee_pct)
.map_or(params.request.sell_amount, |v| v);
calldata.extend_from_slice(&pad_u256(output_amount));
calldata.extend_from_slice(&pad_u256(U256::from(params.request.buy_chain_id)));
calldata.extend_from_slice(&left_pad_address(exclusive_relayer));
calldata.extend_from_slice(&pad_u256(U256::from(quote_timestamp)));
calldata.extend_from_slice(&pad_u256(U256::from(fill_deadline)));
calldata.extend_from_slice(&pad_u256(U256::from(exclusivity_deadline)));
calldata.extend_from_slice(&pad_u256(U256::from(12u64 * 32))); calldata.extend_from_slice(&pad_u256(U256::ZERO));
Ok(cow_chains::EvmCall { to: *spoke_pool, data: calldata, value: U256::ZERO })
}
fn left_pad_address(addr: Address) -> [u8; 32] {
let mut buf = [0u8; 32];
buf[12..32].copy_from_slice(addr.as_slice());
buf
}
const fn pad_u256(val: U256) -> [u8; 32] {
val.to_be_bytes::<32>()
}
pub const ACROSS_HOOK_DAPP_ID: &str = "cow-sdk://bridging/providers/across";
pub const ACROSS_API_BASE: &str = "https://app.across.to/api";
const ACROSS_LOGO_URL: &str = "https://files.cow.fi/cow-sdk/bridging/providers/across-logo.svg";
#[must_use]
pub fn across_supported_chains() -> Vec<u64> {
vec![
SupportedChainId::Mainnet.as_u64(),
SupportedChainId::Polygon.as_u64(),
SupportedChainId::ArbitrumOne.as_u64(),
SupportedChainId::Base.as_u64(),
10, ]
}
#[derive(Debug, Clone)]
pub struct AcrossBridgeProviderOptions {
pub api_base: String,
pub slippage_bps: u32,
}
impl Default for AcrossBridgeProviderOptions {
fn default() -> Self {
Self { api_base: ACROSS_API_BASE.to_owned(), slippage_bps: 50 }
}
}
#[derive(Debug, Clone)]
pub struct AcrossBridgeProvider {
client: reqwest::Client,
options: AcrossBridgeProviderOptions,
info: BridgeProviderInfo,
cow_shed: Arc<CowShedSdk>,
}
impl AcrossBridgeProvider {
#[must_use]
pub fn new(cow_shed: Arc<CowShedSdk>) -> Self {
Self::with_options(cow_shed, AcrossBridgeProviderOptions::default())
}
#[must_use]
pub fn with_options(cow_shed: Arc<CowShedSdk>, options: AcrossBridgeProviderOptions) -> Self {
Self { client: reqwest::Client::new(), options, info: default_across_info(), cow_shed }
}
#[must_use]
pub fn with_api_base(mut self, base: impl Into<String>) -> Self {
self.options.api_base = base.into();
self
}
async fn fetch_suggested_fees(
&self,
req: &QuoteBridgeRequest,
) -> Result<AcrossSuggestedFeesResponse, CowError> {
let (source_cfg, target_cfg) =
get_chain_configs(req.sell_chain_id, req.buy_chain_id).ok_or_else(|| {
CowError::Api { status: 0, body: "across: unsupported chain pair".into() }
})?;
let output_token = get_token_symbol(req.sell_token, &source_cfg)
.and_then(|sym| get_token_address(&sym, &target_cfg))
.map_or(req.sell_token, |a| a);
let url = reqwest::Url::parse_with_params(
&format!("{}/suggested-fees", self.options.api_base),
&[
("inputToken", format!("{:#x}", req.sell_token)),
("outputToken", format!("{output_token:#x}")),
("originChainId", req.sell_chain_id.to_string()),
("destinationChainId", req.buy_chain_id.to_string()),
("amount", req.sell_amount.to_string()),
],
)
.map_err(|e| CowError::Parse { field: "across_url", reason: e.to_string() })?;
let resp = self.client.get(url).send().await?;
let status = resp.status().as_u16();
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(CowError::Api { status, body });
}
let parsed: AcrossSuggestedFeesResponse = resp.json().await?;
Ok(parsed)
}
async fn quote_inner(&self, req: &QuoteBridgeRequest) -> Result<QuoteBridgeResponse, CowError> {
let fees = self.fetch_suggested_fees(req).await?;
let quote_result = to_bridge_quote_result(req, self.options.slippage_bps, &fees)
.map_err(|e| CowError::Api { status: 0, body: e.to_string() })?;
Ok(QuoteBridgeResponse {
provider: "across".into(),
sell_amount: quote_result.amounts_and_costs.before_fee.sell_amount,
buy_amount: quote_result.amounts_and_costs.after_slippage.buy_amount,
fee_amount: quote_result.amounts_and_costs.costs.bridging_fee.amount_in_buy_currency,
estimated_secs: quote_result.expected_fill_time_seconds.map_or(0, |v| v),
bridge_hook: None,
})
}
async fn status_inner(
&self,
deposit_id: &str,
origin_chain_id: u64,
) -> Result<BridgeStatusResult, CowError> {
let url = reqwest::Url::parse_with_params(
&format!("{}/deposit/status", self.options.api_base),
&[("depositId", deposit_id.to_owned()), ("originChainId", origin_chain_id.to_string())],
)
.map_err(|e| CowError::Parse { field: "across_url", reason: e.to_string() })?;
let resp = self.client.get(url).send().await?;
let status = resp.status().as_u16();
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(CowError::Api { status, body });
}
let parsed: crate::types::AcrossDepositStatusResponse = resp.json().await?;
Ok(BridgeStatusResult {
status: map_across_status_to_bridge_status(parsed.status),
fill_time_in_seconds: None,
deposit_tx_hash: parsed.deposit_tx_hash,
fill_tx_hash: parsed.fill_tx,
})
}
}
#[must_use]
pub fn default_across_info() -> BridgeProviderInfo {
BridgeProviderInfo {
name: "across".to_owned(),
logo_url: ACROSS_LOGO_URL.to_owned(),
dapp_id: ACROSS_HOOK_DAPP_ID.to_owned(),
website: "https://across.to".to_owned(),
provider_type: BridgeProviderType::HookBridgeProvider,
}
}
fn chain_display_name(chain_id: u64) -> String {
SupportedChainId::try_from_u64(chain_id)
.map_or_else(|| format!("Chain {chain_id}"), |c| format!("{c}"))
}
fn token_symbol_to_info(symbol: &str, address: Address, chain_id: u64) -> IntermediateTokenInfo {
let upper = symbol.to_ascii_uppercase();
let (decimals, name) = match upper.as_str() {
"USDC" | "USDT" | "DAI" => (if upper == "DAI" { 18 } else { 6 }, upper.clone()),
"WETH" => (18u8, "Wrapped Ether".to_owned()),
"WBTC" => (8u8, "Wrapped BTC".to_owned()),
_ => (18u8, upper.clone()),
};
IntermediateTokenInfo {
chain_id,
address: address.into(),
decimals,
symbol: upper,
name,
logo_url: None,
}
}
fn tokens_for_chain(chain_id: u64) -> Vec<IntermediateTokenInfo> {
let mapping = across_token_mapping();
mapping
.get(&chain_id)
.map(|cfg| {
cfg.tokens
.iter()
.map(|(sym, addr)| token_symbol_to_info(sym, *addr, chain_id))
.collect()
})
.unwrap_or_default()
}
impl BridgeProvider for AcrossBridgeProvider {
fn info(&self) -> &BridgeProviderInfo {
&self.info
}
fn supports_route(&self, sell_chain: u64, buy_chain: u64) -> bool {
if sell_chain == buy_chain {
return false;
}
let supported = across_supported_chains();
supported.contains(&sell_chain) && supported.contains(&buy_chain)
}
fn get_networks<'a>(&'a self) -> NetworksFuture<'a> {
Box::pin(async move {
Ok(across_supported_chains()
.into_iter()
.map(|chain_id| BridgeNetworkInfo {
chain_id,
name: chain_display_name(chain_id),
logo_url: None,
})
.collect())
})
}
fn get_buy_tokens<'a>(&'a self, params: BuyTokensParams) -> BuyTokensFuture<'a> {
let info = self.info.clone();
Box::pin(async move {
let tokens = tokens_for_chain(params.buy_chain_id);
Ok(GetProviderBuyTokens { provider_info: info, tokens })
})
}
fn get_intermediate_tokens<'a>(
&'a self,
request: &'a QuoteBridgeRequest,
) -> IntermediateTokensFuture<'a> {
let source_chain = request.sell_chain_id;
let target_chain = request.buy_chain_id;
let sell_token = request.sell_token;
Box::pin(async move {
let Some((source_cfg, target_cfg)) = get_chain_configs(source_chain, target_chain)
else {
return Ok(Vec::new());
};
let candidates: Vec<IntermediateTokenInfo> = source_cfg
.tokens
.iter()
.filter(|(sym, _)| target_cfg.tokens.contains_key(*sym))
.map(|(sym, addr)| token_symbol_to_info(sym, *addr, source_chain))
.collect();
let mut sorted = candidates;
sorted.sort_by_key(|t| if t.address == sell_token { 0 } else { 1 });
Ok(sorted)
})
}
fn get_quote<'a>(&'a self, req: &'a QuoteBridgeRequest) -> QuoteFuture<'a> {
Box::pin(self.quote_inner(req))
}
fn get_bridging_params<'a>(
&'a self,
chain_id: u64,
order: &'a Order,
tx_hash: B256,
settlement_override: Option<Address>,
) -> BridgingParamsFuture<'a> {
let api_base = self.options.api_base.clone();
let client = self.client.clone();
Box::pin(async move {
let _ = (chain_id, order, tx_hash, settlement_override, api_base, client);
Ok(None)
})
}
fn get_explorer_url(&self, bridging_id: &str) -> String {
format!("https://app.across.to/transactions/{bridging_id}")
}
fn get_status<'a>(
&'a self,
bridging_id: &'a str,
origin_chain_id: u64,
) -> BridgeStatusFuture<'a> {
Box::pin(self.status_inner(bridging_id, origin_chain_id))
}
fn as_hook_bridge_provider(&self) -> Option<&dyn HookBridgeProvider> {
Some(self)
}
}
impl HookBridgeProvider for AcrossBridgeProvider {
fn get_unsigned_bridge_call<'a>(
&'a self,
request: &'a QuoteBridgeRequest,
_quote: &'a QuoteBridgeResponse,
) -> UnsignedCallFuture<'a> {
Box::pin(async move {
let fees = self.fetch_suggested_fees(request).await?;
let params = AcrossDepositCallParams {
request: request.clone(),
suggested_fees: fees,
cow_shed_account: request.account,
};
create_across_deposit_call(¶ms)
.map_err(|e| CowError::Api { status: 0, body: e.to_string() })
})
}
fn get_signed_hook<'a>(
&'a self,
_chain_id: SupportedChainId,
unsigned_call: &'a EvmCall,
bridge_hook_nonce: &'a str,
deadline: u64,
hook_gas_limit: u64,
signer: &'a PrivateKeySigner,
) -> SignedHookFuture<'a> {
let cow_shed = Arc::clone(&self.cow_shed);
Box::pin(async move {
use crate::types::BridgeHook as BridgeHookType;
let nonce = CowShedSdk::derive_nonce(bridge_hook_nonce);
let call = cow_shed::CowShedCall {
target: unsigned_call.to,
calldata: unsigned_call.data.clone(),
value: unsigned_call.value,
allow_failure: false,
is_delegate_call: false,
};
let params = cow_shed::CowShedHookParams {
calls: vec![call],
nonce,
deadline: U256::from(deadline),
};
let proxy = alloy_signer::Signer::address(signer);
let signed = cow_shed.sign_hook(proxy, ¶ms, signer).await?;
let post_hook = cow_types::CowHook {
target: format!("{proxy:#x}"),
call_data: format!("0x{}", alloy_primitives::hex::encode(&unsigned_call.data)),
gas_limit: hook_gas_limit.to_string(),
dapp_id: Some(ACROSS_HOOK_DAPP_ID.to_owned()),
};
let _ = signed;
Ok(BridgeHookType { post_hook, recipient: format!("{:#x}", unsigned_call.to) })
})
}
}
#[cfg(test)]
#[allow(clippy::tests_outside_test_module, reason = "inner test module + cfg guard for WASM skip")]
mod provider_tests {
use super::*;
fn test_provider() -> AcrossBridgeProvider {
AcrossBridgeProvider::new(Arc::new(CowShedSdk::new(1)))
}
fn sample_request() -> QuoteBridgeRequest {
QuoteBridgeRequest {
sell_chain_id: SupportedChainId::Mainnet.as_u64(),
buy_chain_id: SupportedChainId::ArbitrumOne.as_u64(),
sell_token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".parse().unwrap(),
sell_token_decimals: 6,
buy_token: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
.parse::<Address>()
.unwrap()
.into(),
buy_token_decimals: 6,
sell_amount: U256::from(1_000_000u64),
account: Address::ZERO,
owner: None,
receiver: None,
bridge_recipient: None,
slippage_bps: 50,
bridge_slippage_bps: None,
kind: OrderKind::Sell,
}
}
#[test]
fn info_matches_default_helper() {
let p = test_provider();
let default = default_across_info();
assert_eq!(p.info().name, default.name);
assert_eq!(p.info().dapp_id, ACROSS_HOOK_DAPP_ID);
assert!(p.info().is_hook_bridge_provider());
}
#[test]
fn name_defaults_to_across() {
assert_eq!(test_provider().name(), "across");
}
#[test]
fn supports_route_requires_both_chains_supported() {
let p = test_provider();
assert!(p.supports_route(
SupportedChainId::Mainnet.as_u64(),
SupportedChainId::ArbitrumOne.as_u64()
));
assert!(p.supports_route(SupportedChainId::Base.as_u64(), 10));
assert!(!p.supports_route(
SupportedChainId::Mainnet.as_u64(),
SupportedChainId::Sepolia.as_u64()
));
}
#[test]
fn supports_route_rejects_same_chain() {
let p = test_provider();
assert!(!p.supports_route(1, 1));
}
#[test]
fn supports_route_rejects_unknown_chain() {
let p = test_provider();
assert!(!p.supports_route(1, 9999));
assert!(!p.supports_route(9999, 1));
}
#[test]
fn across_supported_chains_matches_token_mapping() {
let supported = across_supported_chains();
let mapping = across_token_mapping();
for id in &supported {
assert!(mapping.contains_key(id), "chain {id} missing from token mapping");
}
}
#[test]
fn explorer_url_formats_correctly() {
let url = test_provider().get_explorer_url("0xdeadbeef");
assert_eq!(url, "https://app.across.to/transactions/0xdeadbeef");
}
#[test]
fn with_api_base_overrides_default() {
let p = test_provider().with_api_base("http://localhost:9999");
assert_eq!(p.options.api_base, "http://localhost:9999");
}
#[test]
fn with_options_applies_custom_slippage() {
let options =
AcrossBridgeProviderOptions { api_base: ACROSS_API_BASE.to_owned(), slippage_bps: 200 };
let p = AcrossBridgeProvider::with_options(Arc::new(CowShedSdk::new(1)), options);
assert_eq!(p.options.slippage_bps, 200);
}
#[tokio::test]
async fn get_networks_returns_all_supported_chains() {
let p = test_provider();
let networks = p.get_networks().await.unwrap();
assert_eq!(networks.len(), across_supported_chains().len());
let mainnet = networks.iter().find(|n| n.chain_id == 1).expect("mainnet present");
assert_eq!(mainnet.name, "Ethereum");
}
#[tokio::test]
async fn get_buy_tokens_returns_destination_chain_tokens() {
let p = test_provider();
let tokens = p
.get_buy_tokens(BuyTokensParams {
sell_chain_id: 1,
buy_chain_id: SupportedChainId::ArbitrumOne.as_u64(),
sell_token_address: None,
})
.await
.unwrap();
assert!(!tokens.tokens.is_empty());
assert!(tokens.tokens.iter().any(|t| t.symbol == "USDC"));
assert_eq!(tokens.provider_info.name, "across");
}
#[tokio::test]
async fn get_buy_tokens_empty_for_unknown_chain() {
let p = test_provider();
let tokens = p
.get_buy_tokens(BuyTokensParams {
sell_chain_id: 1,
buy_chain_id: 9999,
sell_token_address: None,
})
.await
.unwrap();
assert!(tokens.tokens.is_empty());
}
#[tokio::test]
async fn get_intermediate_tokens_filters_by_shared_symbols() {
let p = test_provider();
let req = sample_request();
let tokens = p.get_intermediate_tokens(&req).await.unwrap();
assert!(!tokens.is_empty());
let target_mapping = across_token_mapping();
let target = &target_mapping[&req.buy_chain_id].tokens;
for t in &tokens {
let key = t.symbol.to_ascii_lowercase();
assert!(target.contains_key(&key), "target missing symbol {}", t.symbol);
}
}
#[tokio::test]
async fn get_intermediate_tokens_empty_for_unsupported_route() {
let p = test_provider();
let mut req = sample_request();
req.buy_chain_id = 9999;
let tokens = p.get_intermediate_tokens(&req).await.unwrap();
assert!(tokens.is_empty());
}
#[tokio::test]
async fn get_bridging_params_returns_none_until_pr8() {
let p = test_provider();
let order = cow_orderbook::api::mock_get_order(&format!("0x{}", "aa".repeat(56)));
let out = p.get_bridging_params(1, &order, B256::ZERO, None).await.unwrap();
assert!(out.is_none());
}
fn mock_fees_body() -> serde_json::Value {
serde_json::json!({
"total_relay_fee": {"pct": "1000000000000000", "total": "100"},
"relayer_capital_fee": {"pct": "500000000000000", "total": "50"},
"relayer_gas_fee": {"pct": "500000000000000", "total": "50"},
"lp_fee": {"pct": "0", "total": "0"},
"timestamp": "1700000000",
"is_amount_too_low": false,
"quote_block": "18000000",
"spoke_pool_address": "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5",
"exclusive_relayer": "0x0000000000000000000000000000000000000000",
"exclusivity_deadline":"0",
"estimated_fill_time_sec":"30",
"fill_deadline": "1800000000",
"limits": {
"min_deposit": "1000",
"max_deposit": "1000000000",
"max_deposit_instant": "100000000",
"max_deposit_short_delay": "500000000",
"recommended_deposit_instant":"50000000"
}
})
}
fn mock_status_body(status: &str) -> serde_json::Value {
serde_json::json!({
"status": status,
"origin_chain_id": "1",
"deposit_id": "42",
"deposit_tx_hash": "0xdeadbeef",
"fill_tx": "0xbeefbeef",
"destination_chain_id": "42161",
"deposit_refund_tx_hash": null
})
}
async fn provider_pointing_at(server_uri: &str) -> AcrossBridgeProvider {
AcrossBridgeProvider::new(Arc::new(CowShedSdk::new(1))).with_api_base(server_uri.to_owned())
}
#[tokio::test]
async fn get_quote_parses_suggested_fees_and_fills_response() {
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/suggested-fees"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_fees_body()))
.mount(&server)
.await;
let p = provider_pointing_at(&server.uri()).await;
let quote = p.get_quote(&sample_request()).await.unwrap();
assert_eq!(quote.provider, "across");
assert_eq!(quote.estimated_secs, 30);
assert!(!quote.buy_amount.is_zero());
}
#[tokio::test]
async fn get_quote_propagates_http_error() {
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/suggested-fees"))
.respond_with(ResponseTemplate::new(503).set_body_string("upstream down"))
.mount(&server)
.await;
let p = provider_pointing_at(&server.uri()).await;
let err = p.get_quote(&sample_request()).await.unwrap_err();
assert!(matches!(err, CowError::Api { status: 503, ref body } if body == "upstream down"));
}
#[tokio::test]
async fn get_quote_errors_on_unsupported_chain_pair() {
let p = test_provider();
let mut req = sample_request();
req.sell_chain_id = 9999;
let err = p.get_quote(&req).await.unwrap_err();
assert!(
matches!(err, CowError::Api { status: 0, ref body } if body.contains("unsupported"))
);
}
#[tokio::test]
async fn get_status_maps_filled_to_executed() {
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/deposit/status"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_status_body("filled")))
.mount(&server)
.await;
let p = provider_pointing_at(&server.uri()).await;
let status = p.get_status("42", 1).await.unwrap();
assert_eq!(status.status, BridgeStatus::Executed);
assert_eq!(status.deposit_tx_hash.as_deref(), Some("0xdeadbeef"));
assert_eq!(status.fill_tx_hash.as_deref(), Some("0xbeefbeef"));
}
#[tokio::test]
async fn get_status_maps_pending_to_in_progress() {
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/deposit/status"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_status_body("pending")))
.mount(&server)
.await;
let p = provider_pointing_at(&server.uri()).await;
let status = p.get_status("42", 1).await.unwrap();
assert_eq!(status.status, BridgeStatus::InProgress);
}
#[tokio::test]
async fn get_status_propagates_http_error() {
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/deposit/status"))
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let p = provider_pointing_at(&server.uri()).await;
let err = p.get_status("missing", 1).await.unwrap_err();
assert!(matches!(err, CowError::Api { status: 404, .. }));
}
#[tokio::test]
async fn get_unsigned_bridge_call_builds_deposit_v3_calldata() {
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/suggested-fees"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_fees_body()))
.mount(&server)
.await;
let p = provider_pointing_at(&server.uri()).await;
let req = sample_request();
let dummy_quote = QuoteBridgeResponse {
provider: "across".into(),
sell_amount: req.sell_amount,
buy_amount: U256::from(990_000u64),
fee_amount: U256::from(10_000u64),
estimated_secs: 30,
bridge_hook: None,
};
let call = p.get_unsigned_bridge_call(&req, &dummy_quote).await.unwrap();
assert!(!call.data.is_empty());
let expected_selector = &alloy_primitives::keccak256(
b"depositV3(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes)",
)[..4];
assert_eq!(&call.data[..4], expected_selector);
}
#[tokio::test]
async fn get_signed_hook_signs_with_cow_shed() {
let p = test_provider();
let signer: PrivateKeySigner =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".parse().unwrap();
let call = EvmCall {
to: "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5".parse().unwrap(),
data: vec![0xde, 0xad, 0xbe, 0xef],
value: U256::ZERO,
};
let hook = p
.get_signed_hook(
SupportedChainId::Mainnet,
&call,
"nonce-1",
9_999_999,
500_000,
&signer,
)
.await
.unwrap();
assert_eq!(hook.post_hook.dapp_id.as_deref(), Some(ACROSS_HOOK_DAPP_ID));
assert_eq!(hook.post_hook.gas_limit, "500000");
assert!(hook.recipient.starts_with("0x"));
}
fn sample_fees() -> AcrossSuggestedFeesResponse {
serde_json::from_value(serde_json::json!({
"total_relay_fee": { "pct": "100000000000000000", "total": "500" },
"relayer_capital_fee": { "pct": "50000000000000000", "total": "250" },
"relayer_gas_fee": { "pct": "30000000000000000", "total": "150" },
"lp_fee": { "pct": "20000000000000000", "total": "100" },
"timestamp": "1700000000",
"is_amount_too_low": false,
"quote_block": "18000000",
"spoke_pool_address": "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5",
"exclusive_relayer": "0x0000000000000000000000000000000000000000",
"exclusivity_deadline": "0",
"estimated_fill_time_sec": "120",
"fill_deadline": "1700000300",
"limits": {
"min_deposit": "1",
"max_deposit": "999999999999",
"max_deposit_instant": "999999999999",
"max_deposit_short_delay": "999999999999",
"recommended_deposit_instant":"999999999"
}
}))
.expect("sample fees deserialises")
}
#[test]
fn create_across_deposit_call_rejects_raw_buy_token() {
let mut req = sample_request();
req.buy_token = crate::types::TokenAddress::Raw("bc1qtarget".into());
let params = AcrossDepositCallParams {
request: req,
suggested_fees: sample_fees(),
cow_shed_account: Address::ZERO,
};
let err = create_across_deposit_call(¶ms).unwrap_err();
let msg = err.to_string();
assert!(matches!(err, BridgeError::TxBuildError(_)));
assert!(msg.contains("EVM buy_token"), "unexpected err: {err}");
}
}