use std::collections::BTreeMap;
use alloy_primitives::{Address, B256, keccak256};
use sha2::{Digest, Sha256};
use crate::types::BridgeError;
use super::types::{DefuseToken, NearQuote, NearQuoteRequest};
pub const ATTESTATION_PREFIX_BYTES: [u8; 4] = [0x0a, 0x77, 0x35, 0x70];
pub const ATTESTATION_VERSION_BYTES: [u8; 1] = [0x00];
pub const ATTESTATION_SIG_LEN: usize = 65;
#[must_use]
pub fn adapt_token(token: &DefuseToken) -> Option<crate::types::IntermediateTokenInfo> {
let chain_id = blockchain_key_to_chain_id(&token.blockchain)?;
let address: crate::types::TokenAddress = if is_non_evm_chain_id(chain_id) {
crate::types::TokenAddress::Raw(token.contract_address.clone().unwrap_or_default())
} else {
let evm: Address = match token.contract_address.as_deref() {
Some(raw) => raw.parse::<Address>().ok()?,
None => cow_chains::EVM_NATIVE_CURRENCY_ADDRESS,
};
evm.into()
};
Some(crate::types::IntermediateTokenInfo {
chain_id,
address,
decimals: token.decimals,
symbol: token.symbol.clone(),
name: token.symbol.clone(),
logo_url: None,
})
}
#[must_use]
pub fn adapt_tokens(tokens: &[DefuseToken]) -> Vec<crate::types::IntermediateTokenInfo> {
tokens.iter().filter_map(adapt_token).collect()
}
#[must_use]
pub const fn is_non_evm_chain_id(chain_id: u64) -> bool {
chain_id == 1_000_000_000 || chain_id == 1_000_000_001
}
#[must_use]
pub fn blockchain_key_to_chain_id(key: &str) -> Option<u64> {
match key {
"eth" => Some(1),
"arb" => Some(42_161),
"avax" => Some(43_114),
"base" => Some(8_453),
"bsc" => Some(56),
"gnosis" => Some(100),
"op" => Some(10),
"pol" => Some(137),
"plasma" => Some(9_745),
"btc" => Some(1_000_000_000),
"sol" => Some(1_000_000_001),
_ => None,
}
}
fn canonicalise_value(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let sorted: BTreeMap<String, serde_json::Value> = map
.iter()
.filter(|(_, v)| !v.is_null())
.map(|(k, v)| (k.clone(), canonicalise_value(v)))
.collect();
serde_json::Value::Object(sorted.into_iter().collect())
}
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(canonicalise_value).collect())
}
serde_json::Value::Null |
serde_json::Value::Bool(_) |
serde_json::Value::Number(_) |
serde_json::Value::String(_) => value.clone(),
}
}
pub type QuoteHashOutput = (B256, String);
pub fn hash_quote_payload(
quote: &NearQuote,
quote_request: &NearQuoteRequest,
timestamp: &str,
) -> Result<QuoteHashOutput, BridgeError> {
let mut payload = serde_json::Map::<String, serde_json::Value>::new();
macro_rules! insert_str {
($map:ident, $field:expr, $val:expr) => {
$map.insert($field.to_owned(), serde_json::Value::String($val.to_owned()));
};
}
macro_rules! insert_opt_str {
($map:ident, $field:expr, $val:expr) => {
if let Some(v) = $val.as_ref() {
$map.insert($field.to_owned(), serde_json::Value::String(v.clone()));
}
};
}
payload.insert("dry".into(), serde_json::Value::Bool(false));
payload.insert("swapType".into(), serde_json::to_value(quote_request.swap_type).map_err(j)?);
payload.insert(
"slippageTolerance".into(),
serde_json::to_value(quote_request.slippage_tolerance).map_err(j)?,
);
insert_str!(payload, "originAsset", "e_request.origin_asset);
payload
.insert("depositType".into(), serde_json::to_value(quote_request.deposit_type).map_err(j)?);
insert_str!(payload, "destinationAsset", "e_request.destination_asset);
insert_str!(payload, "amount", "e_request.amount);
insert_str!(payload, "refundTo", "e_request.refund_to);
payload
.insert("refundType".into(), serde_json::to_value(quote_request.refund_type).map_err(j)?);
insert_str!(payload, "recipient", "e_request.recipient);
payload.insert(
"recipientType".into(),
serde_json::to_value(quote_request.recipient_type).map_err(j)?,
);
insert_str!(payload, "deadline", "e_request.deadline);
if let Some(ms) = quote_request.quote_waiting_time_ms {
payload.insert("quoteWaitingTimeMs".into(), serde_json::Value::from(ms));
}
insert_opt_str!(payload, "referral", quote_request.referral);
insert_opt_str!(payload, "virtualChainRecipient", quote_request.virtual_chain_recipient);
insert_opt_str!(
payload,
"virtualChainRefundRecipient",
quote_request.virtual_chain_refund_recipient
);
payload
.insert("depositMode".into(), serde_json::to_value(quote_request.deposit_mode).map_err(j)?);
insert_str!(payload, "amountIn", "e.amount_in);
insert_str!(payload, "amountInFormatted", "e.amount_in_formatted);
insert_str!(payload, "amountInUsd", "e.amount_in_usd);
insert_str!(payload, "minAmountIn", "e.min_amount_in);
insert_str!(payload, "amountOut", "e.amount_out);
insert_str!(payload, "amountOutFormatted", "e.amount_out_formatted);
insert_str!(payload, "amountOutUsd", "e.amount_out_usd);
insert_str!(payload, "minAmountOut", "e.min_amount_out);
insert_str!(payload, "timestamp", timestamp);
let value = serde_json::Value::Object(payload);
let canonical = canonicalise_value(&value);
let stringified = serde_json::to_string(&canonical).map_err(j)?;
let digest = Sha256::digest(stringified.as_bytes());
let hash = B256::from_slice(&digest);
Ok((hash, stringified))
}
fn j(e: serde_json::Error) -> BridgeError {
BridgeError::InvalidApiResponse(format!("quote hash serialization failed: {e}"))
}
pub fn recover_attestation(
deposit_address: Address,
quote_hash: B256,
signature: &str,
) -> Result<Address, BridgeError> {
let sig_bytes = parse_hex_bytes(signature)?;
if sig_bytes.len() != ATTESTATION_SIG_LEN {
return Err(BridgeError::InvalidApiResponse(format!(
"attestation signature must be {ATTESTATION_SIG_LEN} bytes, got {}",
sig_bytes.len(),
)));
}
let mut message = Vec::with_capacity(4 + 1 + 20 + 32);
message.extend_from_slice(&ATTESTATION_PREFIX_BYTES);
message.extend_from_slice(&ATTESTATION_VERSION_BYTES);
message.extend_from_slice(deposit_address.as_slice());
message.extend_from_slice(quote_hash.as_slice());
debug_assert_eq!(
message.len(),
57,
"attestation message must be exactly 57 bytes (prefix+version+addr+hash)",
);
let digest = keccak256(&message);
let sig = alloy_primitives::Signature::try_from(sig_bytes.as_slice()).map_err(|e| {
BridgeError::InvalidApiResponse(format!("invalid attestation signature bytes: {e}"))
})?;
sig.recover_address_from_prehash(&digest)
.map_err(|e| BridgeError::QuoteError(format!("attestation signature recovery failed: {e}")))
}
fn parse_hex_bytes(hex: &str) -> Result<Vec<u8>, BridgeError> {
let trimmed = hex.trim_start_matches("0x");
if !trimmed.len().is_multiple_of(2) {
return Err(BridgeError::InvalidApiResponse(format!(
"hex string has odd length: {}",
trimmed.len(),
)));
}
let mut out = Vec::with_capacity(trimmed.len() / 2);
for chunk in trimmed.as_bytes().chunks_exact(2) {
let hi = hex_nibble(chunk[0])?;
let lo = hex_nibble(chunk[1])?;
out.push((hi << 4) | lo);
}
Ok(out)
}
fn hex_nibble(b: u8) -> Result<u8, BridgeError> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(10 + (b - b'a')),
b'A'..=b'F' => Ok(10 + (b - b'A')),
_ => Err(BridgeError::InvalidApiResponse(format!("non-hex byte: 0x{b:02x}"))),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[must_use]
pub fn calculate_deadline(seconds_from_now: u64) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
format_iso8601_utc(now.saturating_add(seconds_from_now))
}
#[cfg(target_arch = "wasm32")]
#[must_use]
pub fn calculate_deadline(_seconds_from_now: u64) -> String {
format_iso8601_utc(0)
}
#[must_use]
pub fn format_iso8601_utc(unix_secs: u64) -> String {
let days_raw = unix_secs / 86_400;
let days = i64::try_from(days_raw).unwrap_or_default();
let secs_of_day = unix_secs % 86_400;
let h = secs_of_day / 3_600;
let m = (secs_of_day / 60) % 60;
let s = secs_of_day % 60;
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y_base = (yoe as i64) + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5) + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { y_base + 1 } else { y_base };
format!("{year:04}-{month:02}-{d:02}T{h:02}:{m:02}:{s:02}.000Z")
}
#[cfg(all(test, not(target_arch = "wasm32")))]
#[allow(clippy::tests_outside_test_module, reason = "inner module + cfg guard for WASM test skip")]
mod tests {
use super::*;
fn sample_token() -> DefuseToken {
DefuseToken {
asset_id: "nep141:usdc.e".into(),
decimals: 6,
blockchain: "eth".into(),
symbol: "USDC".into(),
price: 1.0,
price_updated_at: "2025-09-05T12:00:38.695Z".into(),
contract_address: Some("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".into()),
}
}
#[test]
fn blockchain_key_to_chain_id_covers_all_11_keys() {
for (k, expected) in [
("eth", 1),
("arb", 42_161),
("avax", 43_114),
("base", 8_453),
("bsc", 56),
("gnosis", 100),
("op", 10),
("pol", 137),
("plasma", 9_745),
("btc", 1_000_000_000),
("sol", 1_000_000_001),
] {
assert_eq!(blockchain_key_to_chain_id(k), Some(expected), "key {k}");
}
}
#[test]
fn blockchain_key_to_chain_id_unknown_returns_none() {
assert_eq!(blockchain_key_to_chain_id("unknown"), None);
assert_eq!(blockchain_key_to_chain_id(""), None);
}
#[test]
fn adapt_token_parses_evm_with_contract_address() {
let t = sample_token();
let out = adapt_token(&t).expect("EVM USDC should adapt");
assert_eq!(out.chain_id, 1);
assert_eq!(out.decimals, 6);
assert_eq!(out.symbol, "USDC");
}
#[test]
fn adapt_token_850_fallback_for_evm_native() {
let mut t = sample_token();
t.contract_address = None;
t.symbol = "ETH".into();
let out = adapt_token(&t).expect("EVM native should adapt with sentinel");
assert_eq!(out.address, cow_chains::EVM_NATIVE_CURRENCY_ADDRESS);
}
#[test]
fn adapt_token_non_evm_emits_raw_variant() {
use crate::types::TokenAddress;
let mut t = sample_token();
t.blockchain = "btc".into();
t.contract_address = None;
t.symbol = "BTC".into();
let out = adapt_token(&t).expect("non-EVM token adapts to Raw variant");
assert_eq!(out.chain_id, 1_000_000_000);
assert!(matches!(out.address, TokenAddress::Raw(ref s) if s.is_empty()));
let mut t = sample_token();
t.blockchain = "sol".into();
t.contract_address = Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into());
t.symbol = "USDC-SOL".into();
let out = adapt_token(&t).expect("SOL SPL token adapts to Raw variant");
assert_eq!(out.chain_id, 1_000_000_001);
assert!(matches!(
out.address,
TokenAddress::Raw(ref s) if s == "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
));
}
#[test]
fn adapt_token_unknown_chain_is_none() {
let mut t = sample_token();
t.blockchain = "some-new-chain".into();
assert!(adapt_token(&t).is_none());
}
#[test]
fn adapt_tokens_drops_unknown_chains_but_keeps_non_evm() {
let mut btc = sample_token();
btc.blockchain = "btc".into();
btc.contract_address = None;
btc.symbol = "BTC".into();
let mut unknown = sample_token();
unknown.blockchain = "future".into();
let out = adapt_tokens(&[sample_token(), btc, unknown]);
assert_eq!(out.len(), 2);
}
#[test]
fn canonicalise_value_sorts_keys_recursively() {
let input = serde_json::json!({
"z": 1,
"a": {
"c": 3,
"b": 2,
},
});
let out = canonicalise_value(&input);
let s = serde_json::to_string(&out).unwrap();
assert_eq!(s, r#"{"a":{"b":2,"c":3},"z":1}"#);
}
#[test]
fn canonicalise_value_drops_null_properties() {
let input = serde_json::json!({ "a": 1, "b": null, "c": 3 });
let s = serde_json::to_string(&canonicalise_value(&input)).unwrap();
assert_eq!(s, r#"{"a":1,"c":3}"#);
}
fn sample_quote_request() -> NearQuoteRequest {
NearQuoteRequest {
dry: false,
swap_type: super::super::types::NearSwapType::ExactInput,
deposit_mode: super::super::types::NearDepositMode::Simple,
slippage_tolerance: 50,
origin_asset: "nep141:eth".into(),
deposit_type: super::super::types::NearDepositType::OriginChain,
destination_asset: "nep141:btc".into(),
amount: "1000000".into(),
refund_to: "0xabc".into(),
refund_type: super::super::types::NearRefundType::OriginChain,
recipient: "bc1q...".into(),
recipient_type: super::super::types::NearRecipientType::DestinationChain,
deadline: "2099-01-01T00:00:00.000Z".into(),
app_fees: None,
quote_waiting_time_ms: None,
referral: None,
virtual_chain_recipient: None,
virtual_chain_refund_recipient: None,
custom_recipient_msg: None,
session_id: None,
connected_wallets: None,
}
}
fn sample_quote() -> NearQuote {
NearQuote {
amount_in: "1000000".into(),
amount_in_formatted: "1.0".into(),
amount_in_usd: "1.0".into(),
min_amount_in: "1000000".into(),
amount_out: "999500".into(),
amount_out_formatted: "0.9995".into(),
amount_out_usd: "0.99".into(),
min_amount_out: "999000".into(),
time_estimate: 120,
deadline: "2099-01-01T00:00:00.000Z".into(),
time_when_inactive: "2099-01-01T01:00:00.000Z".into(),
deposit_address: "0xdead000000000000000000000000000000000000".into(),
}
}
#[test]
fn hash_quote_payload_is_deterministic() {
let (h1, s1) = hash_quote_payload(&sample_quote(), &sample_quote_request(), "t").unwrap();
let (h2, s2) = hash_quote_payload(&sample_quote(), &sample_quote_request(), "t").unwrap();
assert_eq!(h1, h2);
assert_eq!(s1, s2);
}
#[test]
fn hash_quote_payload_canonical_string_sorts_keys() {
let (_, s) = hash_quote_payload(&sample_quote(), &sample_quote_request(), "t").unwrap();
let amount_in_pos = s.find("\"amountIn\"").unwrap();
let amount_out_pos = s.find("\"amountOut\"").unwrap();
let deadline_pos = s.find("\"deadline\"").unwrap();
assert!(amount_in_pos < amount_out_pos);
assert!(amount_out_pos < deadline_pos);
}
#[test]
fn hash_quote_payload_changes_when_amount_changes() {
let mut r1 = sample_quote_request();
r1.amount = "1".into();
let mut r2 = sample_quote_request();
r2.amount = "2".into();
let (h1, _) = hash_quote_payload(&sample_quote(), &r1, "t").unwrap();
let (h2, _) = hash_quote_payload(&sample_quote(), &r2, "t").unwrap();
assert_ne!(h1, h2);
}
#[test]
fn hash_quote_payload_changes_when_timestamp_changes() {
let (h1, _) = hash_quote_payload(&sample_quote(), &sample_quote_request(), "a").unwrap();
let (h2, _) = hash_quote_payload(&sample_quote(), &sample_quote_request(), "b").unwrap();
assert_ne!(h1, h2);
}
#[test]
fn recover_attestation_rejects_wrong_length_signature() {
let addr = Address::repeat_byte(0xab);
let hash = B256::repeat_byte(0xcd);
let err = recover_attestation(addr, hash, "0x1234").unwrap_err();
assert!(matches!(err, BridgeError::InvalidApiResponse(_)));
}
#[test]
fn recover_attestation_rejects_non_hex_signature() {
let addr = Address::repeat_byte(0xab);
let hash = B256::repeat_byte(0xcd);
let err = recover_attestation(addr, hash, "0xzzzz").unwrap_err();
assert!(matches!(err, BridgeError::InvalidApiResponse(_)));
}
#[test]
fn recover_attestation_round_trips_with_local_signer() {
use alloy_signer::SignerSync;
use alloy_signer_local::PrivateKeySigner;
use std::str::FromStr;
let signer = PrivateKeySigner::from_str(
"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
)
.unwrap();
let deposit_address = Address::repeat_byte(0x11);
let quote_hash = B256::repeat_byte(0x22);
let mut message = Vec::with_capacity(57);
message.extend_from_slice(&ATTESTATION_PREFIX_BYTES);
message.extend_from_slice(&ATTESTATION_VERSION_BYTES);
message.extend_from_slice(deposit_address.as_slice());
message.extend_from_slice(quote_hash.as_slice());
let digest = keccak256(&message);
let sig = signer.sign_hash_sync(&digest).unwrap();
let sig_hex = format!("0x{}", hex_encode(&sig.as_bytes()));
let recovered = recover_attestation(deposit_address, quote_hash, &sig_hex).unwrap();
assert_eq!(recovered, signer.address());
}
#[allow(clippy::unwrap_used, reason = "fmt::Write to String is infallible")]
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write;
write!(&mut s, "{b:02x}").unwrap();
}
s
}
#[test]
fn format_iso8601_utc_epoch() {
assert_eq!(format_iso8601_utc(0), "1970-01-01T00:00:00.000Z");
}
#[test]
fn format_iso8601_utc_known_timestamps() {
assert_eq!(format_iso8601_utc(1_609_459_200), "2021-01-01T00:00:00.000Z");
assert_eq!(format_iso8601_utc(1_718_455_696), "2024-06-15T12:48:16.000Z");
}
#[test]
fn format_iso8601_utc_day_month_year_boundaries() {
assert_eq!(format_iso8601_utc(946_684_799), "1999-12-31T23:59:59.000Z");
assert_eq!(format_iso8601_utc(946_684_800), "2000-01-01T00:00:00.000Z");
}
}