use alloy_json_rpc::RpcError;
use alloy_transport::TransportErrorKind;
use thiserror::Error;
const ALREADY_RELAYED_PATTERNS: &[&str] = &[
"nonce already used",
"already received",
"already processed",
"message already received",
"nonce used",
];
#[derive(Error, Debug)]
pub enum CctpError {
#[error("Unsupported chain: {0:?}")]
UnsupportedChain(alloy_chains::NamedChain),
#[error("Message already relayed (transfer successful via third party): {original}")]
AlreadyRelayed { original: String },
#[error("Not implemented: {0}")]
NotImplemented(String),
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("Provider error: {0}")]
Provider(String),
#[error("Contract call failed: {0}")]
ContractCall(String),
#[error("Attestation failed: {reason}")]
AttestationFailed { reason: String },
#[error("Transaction failed: {reason}")]
TransactionFailed { reason: String },
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
#[error("Timeout waiting for attestation")]
AttestationTimeout,
#[error("Invalid URL: {reason}")]
InvalidUrl { reason: String },
#[error("RPC error: {0}")]
Rpc(#[from] alloy_json_rpc::RpcError<alloy_transport::TransportErrorKind>),
#[error("ABI encoding/decoding error: {0}")]
Abi(#[from] alloy_sol_types::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Hex conversion error: {0}")]
Hex(#[from] alloy_primitives::hex::FromHexError),
}
impl CctpError {
pub fn is_already_relayed(&self) -> bool {
match self {
CctpError::AlreadyRelayed { .. } => true,
CctpError::Rpc(rpc_error) => Self::rpc_error_is_already_relayed(rpc_error),
CctpError::Provider(msg)
| CctpError::ContractCall(msg)
| CctpError::TransactionFailed { reason: msg } => {
Self::message_matches_already_relayed(msg)
}
_ => false,
}
}
fn rpc_error_is_already_relayed(error: &RpcError<TransportErrorKind>) -> bool {
match error {
RpcError::ErrorResp(payload) => {
Self::message_matches_already_relayed(&payload.message)
|| payload
.data
.as_ref()
.is_some_and(|d| Self::message_matches_already_relayed(&d.to_string()))
}
RpcError::LocalUsageError(e) => Self::message_matches_already_relayed(&e.to_string()),
_ => false,
}
}
fn message_matches_already_relayed(message: &str) -> bool {
let lower = message.to_lowercase();
ALREADY_RELAYED_PATTERNS
.iter()
.any(|pattern| lower.contains(pattern))
}
pub fn is_timeout(&self) -> bool {
if matches!(self, CctpError::AttestationTimeout) {
return true;
}
if let CctpError::Network(e) = self {
return e.is_timeout();
}
false
}
pub fn is_rate_limited(&self) -> bool {
match self {
CctpError::Network(e) => e.status().is_some_and(|s| s.as_u16() == 429),
CctpError::Rpc(RpcError::Transport(TransportErrorKind::HttpError(err))) => {
err.status == 429
}
_ => false,
}
}
pub fn is_transient(&self) -> bool {
self.is_timeout() || self.is_rate_limited() || self.is_network_error()
}
fn is_network_error(&self) -> bool {
matches!(self, CctpError::Network(_))
|| matches!(
self,
CctpError::Rpc(RpcError::Transport(
TransportErrorKind::BackendGone | TransportErrorKind::HttpError(_)
))
)
}
}
pub type Result<T> = std::result::Result<T, CctpError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_already_relayed_explicit_variant() {
let err = CctpError::AlreadyRelayed {
original: "test".to_string(),
};
assert!(err.is_already_relayed());
}
#[test]
fn test_is_already_relayed_provider_error() {
let err = CctpError::Provider("nonce already used".to_string());
assert!(err.is_already_relayed());
let err = CctpError::Provider("message already received".to_string());
assert!(err.is_already_relayed());
let err = CctpError::Provider("some other error".to_string());
assert!(!err.is_already_relayed());
}
#[test]
fn test_is_already_relayed_contract_call_error() {
let err = CctpError::ContractCall("execution reverted: nonce used".to_string());
assert!(err.is_already_relayed());
let err = CctpError::ContractCall("already processed".to_string());
assert!(err.is_already_relayed());
let err = CctpError::ContractCall("insufficient funds".to_string());
assert!(!err.is_already_relayed());
}
#[test]
fn test_is_already_relayed_transaction_failed() {
let err = CctpError::TransactionFailed {
reason: "Already Received".to_string(),
};
assert!(err.is_already_relayed());
}
#[test]
fn test_is_already_relayed_case_insensitive() {
let err = CctpError::Provider("NONCE ALREADY USED".to_string());
assert!(err.is_already_relayed());
let err = CctpError::Provider("Nonce Already Used".to_string());
assert!(err.is_already_relayed());
}
#[test]
fn test_is_timeout() {
let err = CctpError::AttestationTimeout;
assert!(err.is_timeout());
let err = CctpError::Provider("some error".to_string());
assert!(!err.is_timeout());
}
#[test]
fn test_unrelated_errors_not_already_relayed() {
assert!(!CctpError::AttestationTimeout.is_already_relayed());
assert!(!CctpError::InvalidConfig("test".to_string()).is_already_relayed());
assert!(!CctpError::NotImplemented("test".to_string()).is_already_relayed());
}
}