use alloy_json_rpc::RpcError;
use alloy_primitives::TxHash;
use alloy_transport::TransportErrorKind;
use std::fmt;
use thiserror::Error;
const ALREADY_RELAYED_PATTERNS: &[&str] = &[
"nonce already used",
"already received",
"already processed",
"message already received",
"nonce used",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AttestationFailureKind {
ApiReportedFailed,
AttestationMissing,
MessageMissing,
}
impl fmt::Display for AttestationFailureKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let msg = match self {
Self::ApiReportedFailed => "Iris API reported failed status",
Self::AttestationMissing => "attestation field missing in complete response",
Self::MessageMissing => "message field missing in complete response",
};
f.write_str(msg)
}
}
#[derive(Error, Debug)]
#[non_exhaustive]
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(transparent)]
Contract(#[from] alloy_contract::Error),
#[error("Attestation failed: {0}")]
AttestationFailed(AttestationFailureKind),
#[error("Transaction not found: {tx_hash}")]
TransactionNotFound { tx_hash: TxHash },
#[error("MessageSent event not found in transaction logs: {tx_hash}")]
MessageSentEventMissing { tx_hash: TxHash },
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
#[error("Timeout waiting for attestation")]
AttestationTimeout,
#[error("Invalid URL: {0}")]
InvalidUrl(#[from] url::ParseError),
#[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::Contract(alloy_contract::Error::TransportError(rpc_error)) => {
Self::rpc_error_is_already_relayed(rpc_error)
}
_ => 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::*;
fn error_payload(message: &'static str) -> alloy_json_rpc::ErrorPayload {
alloy_json_rpc::ErrorPayload {
code: 3,
message: message.into(),
data: None,
}
}
#[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_rpc_payload() {
let check = |message: &'static str| {
CctpError::Rpc(RpcError::ErrorResp(error_payload(message))).is_already_relayed()
};
assert!(check("nonce already used"));
assert!(check("message already received"));
assert!(check("Already Received"));
assert!(check("NONCE ALREADY USED"));
assert!(check("Nonce Already Used"));
assert!(!check("some other error"));
}
#[test]
fn test_is_timeout() {
let err = CctpError::AttestationTimeout;
assert!(err.is_timeout());
let err = CctpError::InvalidConfig("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());
assert!(!CctpError::TransactionNotFound {
tx_hash: TxHash::ZERO,
}
.is_already_relayed());
assert!(!CctpError::MessageSentEventMissing {
tx_hash: TxHash::ZERO,
}
.is_already_relayed());
}
#[test]
fn test_contract_variant_routes_through_is_already_relayed() {
let err: CctpError = alloy_contract::Error::ContractNotDeployed.into();
assert!(matches!(err, CctpError::Contract(_)));
assert!(!err.is_already_relayed());
}
#[test]
fn test_attestation_failure_kind_renders_prose() {
let render = |kind: AttestationFailureKind| CctpError::AttestationFailed(kind).to_string();
assert_eq!(
render(AttestationFailureKind::ApiReportedFailed),
"Attestation failed: Iris API reported failed status",
);
assert_eq!(
render(AttestationFailureKind::AttestationMissing),
"Attestation failed: attestation field missing in complete response",
);
assert_eq!(
render(AttestationFailureKind::MessageMissing),
"Attestation failed: message field missing in complete response",
);
}
#[test]
fn test_invalid_url_preserves_typed_parse_error() {
let parse_err = url::Url::parse("not a url").unwrap_err();
let err: CctpError = parse_err.into();
assert!(matches!(err, CctpError::InvalidUrl(_)));
}
#[test]
fn test_contract_transport_error_inspects_rpc_payload() {
let contract_err_with_message = |message: &'static str| -> CctpError {
alloy_contract::Error::TransportError(alloy_transport::TransportError::ErrorResp(
error_payload(message),
))
.into()
};
assert!(
contract_err_with_message("execution reverted: nonce already used")
.is_already_relayed()
);
assert!(
!contract_err_with_message("execution reverted: insufficient allowance")
.is_already_relayed()
);
}
}