use std::fmt;
use std::time::Duration;
use alloy::primitives::{Address, B256, Bytes};
use alloy::sol_types::{Panic, Revert, SolError, SolInterface};
use alloy::transports::{RpcError, TransportError, TransportErrorKind};
use serde::{Deserialize, Serialize};
use crate::artifacts::ParalockContract;
use crate::bindings::{
ITIP20Errors::ITIP20ErrorsErrors, ITIP20Factory::ITIP20FactoryErrors, ITIP20Issuer::ITIP20IssuerErrors,
MockUSD::MockUSDErrors, ParalockAdaptorEscrow::ParalockAdaptorEscrowErrors, ParalockHTLC::ParalockHTLCErrors,
};
use crate::swap::EscrowStatus;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum EvmError {
#[error("invalid configuration: {0}")]
Config(String),
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("RPC error during {method}{}: {message}", code.map(|c| format!(" (code {c})")).unwrap_or_default())]
Rpc {
method: &'static str,
code: Option<i64>,
message: String,
transient: bool,
},
#[error("chain id mismatch: configured {expected}, but the RPC node reports {actual}")]
ChainIdMismatch {
expected: u64,
actual: u64,
},
#[error("invalid RPC response: {0}")]
InvalidResponse(String),
#[error("{}", reverted_message(hash.as_ref(), revert.as_ref()))]
TransactionReverted {
hash: Option<B256>,
revert: Option<RevertReason>,
},
#[error("transaction {hash}{} was not mined within {timeout:?}", nonce_suffix(*nonce))]
ReceiptTimeout {
hash: B256,
nonce: Option<u64>,
timeout: Duration,
},
#[error("broadcast of transaction {hash} (nonce {nonce}) may have failed: {message}")]
BroadcastUnknown {
hash: B256,
nonce: u64,
message: String,
},
#[error("nonce {nonce} of transaction {hash} is already used by a mined transaction")]
NonceUsed {
hash: B256,
nonce: u64,
},
#[error("invalid receipt for transaction {hash}: {message}")]
InvalidReceipt {
hash: B256,
message: String,
},
#[error("{}", code_mismatch_message(address, *expected, *empty))]
CodeMismatch {
address: Address,
expected: ParalockContract,
empty: bool,
},
#[error("timed out after {timeout:?} waiting for {what}")]
Timeout {
what: String,
timeout: Duration,
},
#[error("this client is read-only: configure a signer to send transactions")]
ReadOnly,
#[error("signer error: {0}")]
Signer(String),
#[error("invalid claim signature: {0}")]
Signature(#[from] SignatureError),
#[error("claim signature recovers to {recovered}, not the swap's signer {expected}")]
WrongSigner {
expected: Address,
recovered: Address,
},
#[error("preimage does not hash to {hashlock}")]
PreimageMismatch {
hashlock: B256,
},
#[error("{key} is {status:?} but no Locked event was found from block {from_block}")]
LockEventMissing {
key: B256,
status: EscrowStatus,
from_block: u64,
},
#[error("{key} is settled but its Claimed/Refunded event is not indexed yet")]
SettlementNotIndexed {
key: B256,
},
#[error("estimated max fee per gas {estimated} exceeds the configured cap {cap}")]
FeeCapExceeded {
estimated: u128,
cap: u128,
},
#[error(
"chain {chain_id} is {name}: deploy_paralock_contracts only deploys to local chains (it creates a mintable \
test stablecoin). Use contracts/script/Deploy.s.sol"
)]
PublicNetwork {
chain_id: u64,
name: &'static str,
},
}
impl EvmError {
pub fn is_transient(&self) -> bool {
match self {
Self::Rpc { transient, .. } => *transient,
Self::ReceiptTimeout { .. }
| Self::BroadcastUnknown { .. }
| Self::Timeout { .. }
| Self::SettlementNotIndexed { .. }
| Self::LockEventMissing { .. } => true,
_ => false,
}
}
pub fn tx_hash(&self) -> Option<B256> {
match self {
Self::ReceiptTimeout { hash, .. }
| Self::BroadcastUnknown { hash, .. }
| Self::NonceUsed { hash, .. }
| Self::InvalidReceipt { hash, .. } => Some(*hash),
Self::TransactionReverted { hash, .. } => *hash,
_ => None,
}
}
pub fn nonce(&self) -> Option<u64> {
match self {
Self::ReceiptTimeout { nonce, .. } => *nonce,
Self::BroadcastUnknown { nonce, .. } | Self::NonceUsed { nonce, .. } => Some(*nonce),
_ => None,
}
}
pub fn revert_name(&self) -> Option<&str> {
match self {
Self::TransactionReverted { revert: Some(revert), .. } => revert.name(),
_ => None,
}
}
pub fn revert(&self) -> Option<&RevertReason> {
match self {
Self::TransactionReverted { revert, .. } => revert.as_ref(),
_ => None,
}
}
}
fn code_mismatch_message(address: &Address, expected: ParalockContract, empty: bool) -> String {
let what = if empty { " (it has no code)" } else { "" };
format!("{address} does not hold the {} runtime code of this build{what}", expected.name())
}
fn nonce_suffix(nonce: Option<u64>) -> String {
nonce.map(|n| format!(" (nonce {n})")).unwrap_or_default()
}
fn reverted_message(hash: Option<&B256>, revert: Option<&RevertReason>) -> String {
let what = match hash {
Some(hash) => format!("transaction {hash} reverted"),
None => "transaction would revert (simulation)".to_owned(),
};
match revert {
Some(revert) => format!("{what}: {revert}"),
None => what,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SignatureError {
#[error("invalid signature recovery value {0}")]
InvalidRecovery(u64),
#[error("signature s is out of range")]
SOutOfRange,
#[error("signature r is out of range")]
ROutOfRange,
#[error("signature is not canonical (v must be 27 or 28 and s low)")]
NotCanonical,
#[error("signature does not recover to a public key")]
Unrecoverable,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum RevertSource {
Htlc,
AdaptorEscrow,
Token,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RevertReason {
pub data: Bytes,
pub name: Option<&'static str>,
pub source: Option<RevertSource>,
pub message: String,
}
impl RevertReason {
pub fn decode(data: Bytes, prefer: Option<RevertSource>) -> Self {
let order: [RevertSource; 3] = match prefer {
Some(RevertSource::AdaptorEscrow) => [RevertSource::AdaptorEscrow, RevertSource::Htlc, RevertSource::Token],
Some(RevertSource::Token) => [RevertSource::Token, RevertSource::Htlc, RevertSource::AdaptorEscrow],
_ => [RevertSource::Htlc, RevertSource::AdaptorEscrow, RevertSource::Token],
};
for source in order {
let decoded = match source {
RevertSource::Htlc => describe::<ParalockHTLCErrors>(&data, ParalockHTLCErrors::name_by_selector),
RevertSource::AdaptorEscrow => {
describe::<ParalockAdaptorEscrowErrors>(&data, ParalockAdaptorEscrowErrors::name_by_selector)
}
RevertSource::Token => describe::<ITIP20ErrorsErrors>(&data, ITIP20ErrorsErrors::name_by_selector)
.or_else(|| describe::<ITIP20FactoryErrors>(&data, ITIP20FactoryErrors::name_by_selector))
.or_else(|| describe::<ITIP20IssuerErrors>(&data, ITIP20IssuerErrors::name_by_selector))
.or_else(|| describe::<MockUSDErrors>(&data, MockUSDErrors::name_by_selector)),
};
if let Some((name, message)) = decoded {
return Self { data, name: Some(name), source: Some(source), message };
}
}
if let Ok(revert) = Revert::abi_decode_validate(&data) {
let message = format!("Error({:?})", revert.reason);
return Self { data, name: Some("Error"), source: None, message };
}
if let Ok(panic) = Panic::abi_decode_validate(&data) {
let message = format!("Panic({:#x})", panic.code);
return Self { data, name: Some("Panic"), source: None, message };
}
let message = if data.is_empty() { "no revert data".to_owned() } else { format!("unknown revert data {data}") };
Self { data, name: None, source: None, message }
}
pub fn name(&self) -> Option<&str> {
self.name
}
pub fn is(&self, name: &str) -> bool {
self.name == Some(name)
}
pub fn htlc_error(&self) -> Option<ParalockHTLCErrors> {
ParalockHTLCErrors::abi_decode_validate(&self.data).ok()
}
pub fn adaptor_error(&self) -> Option<ParalockAdaptorEscrowErrors> {
ParalockAdaptorEscrowErrors::abi_decode_validate(&self.data).ok()
}
}
impl fmt::Display for RevertReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
fn describe<E: SolInterface + fmt::Debug>(
data: &[u8],
name_by_selector: fn([u8; 4]) -> Option<&'static str>,
) -> Option<(&'static str, String)> {
let selector: [u8; 4] = data.get(..4)?.try_into().ok()?;
if !E::valid_selector(selector) {
return None;
}
let decoded = E::abi_decode_validate(data).ok()?;
let name = name_by_selector(selector)?;
let debug = format!("{decoded:?}");
let inner = debug
.strip_prefix(name)
.and_then(|rest| rest.strip_prefix('('))
.and_then(|rest| rest.strip_suffix(')'))
.map(str::to_owned)
.unwrap_or(debug);
Some((name, inner))
}
#[derive(Clone, Debug)]
pub(crate) struct ErrorContext {
pub redact: Vec<String>,
}
impl ErrorContext {
pub(crate) fn redact(&self, message: &str) -> String {
let mut out = message.to_owned();
for secret in &self.redact {
if !secret.is_empty() {
out = out.replace(secret.as_str(), "<rpc-url>");
}
}
out
}
pub(crate) fn rpc(&self, method: &'static str, err: TransportError) -> EvmError {
let code = match &err {
RpcError::ErrorResp(payload) => Some(payload.code),
_ => None,
};
EvmError::Rpc { method, code, message: self.redact(&err.to_string()), transient: is_transient(&err) }
}
pub(crate) fn call(&self, method: &'static str, err: TransportError, source: Option<RevertSource>) -> EvmError {
if let Some(data) = revert_data(&err) {
return EvmError::TransactionReverted { hash: None, revert: Some(RevertReason::decode(data, source)) };
}
self.rpc(method, err)
}
}
pub(crate) fn revert_data(err: &TransportError) -> Option<Bytes> {
let RpcError::ErrorResp(payload) = err else { return None };
if let Some(data) = payload.as_revert_data() {
return Some(data);
}
if payload.code == 3 {
let raw = payload.data.as_ref()?;
let text: String = serde_json::from_str(raw.get()).ok()?;
let hex = text.strip_prefix("0x")?;
return alloy::hex::decode(hex).ok().map(Bytes::from);
}
if payload.message.contains("revert") {
return Some(Bytes::new());
}
None
}
pub(crate) fn is_transient(err: &TransportError) -> bool {
use alloy::transports::RpcError as E;
match err {
E::Transport(kind) => match kind {
TransportErrorKind::HttpError(http) => http.status == 429 || (500..=599).contains(&http.status),
TransportErrorKind::HttpErrorWithRetryAfter { error, .. } => {
error.status == 429 || (500..=599).contains(&error.status)
}
TransportErrorKind::MissingBatchResponse(_) | TransportErrorKind::BackendGone => true,
TransportErrorKind::Custom(_) => true,
_ => false,
},
E::ErrorResp(payload) => is_retryable_payload(payload),
E::NullResp => true,
E::DeserError { text, .. } => {
let trimmed = text.trim_start();
trimmed.starts_with('<') || trimmed.is_empty()
}
_ => false,
}
}
pub(crate) fn is_retryable_payload(payload: &alloy::rpc::json_rpc::ErrorPayload) -> bool {
(payload.is_retry_err() || (payload.code == -32603 && payload.message.contains("timeout")))
&& !mentions_range_limit(&payload.message)
}
const RANGE_REFUSALS: [&str; 22] = [
"block range",
"range too large",
"range is too large",
"range too wide",
"range is too wide",
"range too big",
"range is too big",
"too many blocks",
"too many results",
"too many logs",
"query returned more than",
"limit exceeded",
"exceed max",
"exceeds max",
"exceed the max",
"exceeds the max",
"exceed limit",
"exceeds limit",
"exceed the limit",
"exceeds the limit",
"response size",
"payload too large",
];
pub(crate) fn is_range_refusal(err: &TransportError) -> bool {
match err {
RpcError::ErrorResp(_) => true,
RpcError::Transport(kind) => match kind.as_http_error() {
Some(http) if http.status == 400 || http.status == 413 => true,
Some(http) if http.status == 429 => false,
_ => mentions_range_limit(&err.to_string()),
},
_ => mentions_range_limit(&err.to_string()),
}
}
fn mentions_range_limit(message: &str) -> bool {
let message = message.to_lowercase();
!message.contains("rate limit") && RANGE_REFUSALS.iter().any(|phrase| message.contains(phrase))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::bindings::{ParalockAdaptorEscrow, ParalockHTLC};
use alloy::primitives::U256;
#[test]
fn decodes_custom_errors_with_their_names() {
let data = Bytes::from(ParalockHTLC::SwapExpired { expiry: 1_800_003_600 }.abi_encode());
let revert = RevertReason::decode(data.clone(), Some(RevertSource::Htlc));
assert_eq!(revert.name(), Some("SwapExpired"));
assert_eq!(revert.source, Some(RevertSource::Htlc));
assert!(revert.message.contains("1800003600"), "{}", revert.message);
assert!(matches!(revert.htlc_error(), Some(ParalockHTLCErrors::SwapExpired(_))));
let unit = RevertReason::decode(Bytes::from(ParalockHTLC::NotSender {}.abi_encode()), None);
assert_eq!(unit.name(), Some("NotSender"));
assert_eq!(unit.message, "NotSender");
let malleable = Bytes::from(ParalockAdaptorEscrow::MalleableSignature {}.abi_encode());
let revert = RevertReason::decode(malleable, Some(RevertSource::AdaptorEscrow));
assert_eq!(revert.name(), Some("MalleableSignature"));
assert!(matches!(revert.adaptor_error(), Some(ParalockAdaptorEscrowErrors::MalleableSignature(_))));
}
#[test]
fn shared_selectors_resolve_to_the_preferred_contract() {
let data = Bytes::from(ParalockAdaptorEscrow::SwapNotFound { id: B256::repeat_byte(1) }.abi_encode());
let as_adaptor = RevertReason::decode(data.clone(), Some(RevertSource::AdaptorEscrow));
assert_eq!(as_adaptor.source, Some(RevertSource::AdaptorEscrow));
let as_htlc = RevertReason::decode(data, Some(RevertSource::Htlc));
assert_eq!(as_htlc.source, Some(RevertSource::Htlc));
assert_eq!(as_htlc.name(), Some("SwapNotFound"));
}
#[test]
fn decodes_token_errors_strings_panics_and_garbage() {
let tip20 = crate::bindings::ITIP20Errors::InsufficientBalance {
available: U256::from(1),
required: U256::from(2),
token: Address::repeat_byte(0x20),
};
let revert = RevertReason::decode(Bytes::from(tip20.abi_encode()), Some(RevertSource::Htlc));
assert_eq!(revert.name(), Some("InsufficientBalance"));
assert_eq!(revert.source, Some(RevertSource::Token));
let string = Revert::from("nope").abi_encode();
let revert = RevertReason::decode(Bytes::from(string), None);
assert_eq!(revert.name(), Some("Error"));
assert!(revert.message.contains("nope"));
let panic = Panic { code: U256::from(0x11) }.abi_encode();
assert_eq!(RevertReason::decode(Bytes::from(panic), None).name(), Some("Panic"));
for garbage in [&b""[..], &[0xde, 0xad], &[0xde, 0xad, 0xbe, 0xef, 1, 2, 3]] {
let revert = RevertReason::decode(Bytes::copy_from_slice(garbage), None);
assert_eq!(revert.name(), None);
}
let mut truncated = ParalockHTLC::SwapExpired { expiry: 1 }.abi_encode();
truncated.truncate(10);
assert_eq!(RevertReason::decode(Bytes::from(truncated), None).name(), None);
}
#[test]
fn redacts_the_rpc_url() {
let ctx = ErrorContext { redact: vec!["https://rpc.example/key123".to_owned()] };
let msg = ctx.redact("error sending request for url (https://rpc.example/key123): connection refused");
assert!(!msg.contains("key123"));
assert!(msg.contains("<rpc-url>"));
}
#[test]
fn error_messages() {
let err = EvmError::TransactionReverted {
hash: None,
revert: Some(RevertReason::decode(
Bytes::from(ParalockHTLC::SwapSettled { hashlock: B256::ZERO }.abi_encode()),
None,
)),
};
assert_eq!(err.revert_name(), Some("SwapSettled"));
assert!(err.to_string().contains("simulation"));
assert!(err.to_string().contains("SwapSettled"));
assert_eq!(SignatureError::SOutOfRange.to_string(), "signature s is out of range");
assert_eq!(SignatureError::InvalidRecovery(29).to_string(), "invalid signature recovery value 29");
}
#[test]
fn errors_after_a_broadcast_carry_the_hash_and_nonce() {
let hash = B256::repeat_byte(0xab);
let timeout = EvmError::ReceiptTimeout { hash, nonce: Some(7), timeout: Duration::from_secs(5) };
assert_eq!((timeout.tx_hash(), timeout.nonce()), (Some(hash), Some(7)));
assert!(timeout.is_transient());
let text = timeout.to_string();
assert!(text.contains(&hash.to_string()) && text.contains("nonce 7"), "{text}");
let unknown_nonce = EvmError::ReceiptTimeout { hash, nonce: None, timeout: Duration::from_secs(5) };
assert_eq!(unknown_nonce.nonce(), None);
assert!(!unknown_nonce.to_string().contains("nonce"), "{unknown_nonce}");
let uncertain = EvmError::BroadcastUnknown { hash, nonce: 3, message: "connection reset".into() };
assert_eq!((uncertain.tx_hash(), uncertain.nonce()), (Some(hash), Some(3)));
assert!(uncertain.is_transient());
assert!(uncertain.to_string().contains(&hash.to_string()));
let used = EvmError::NonceUsed { hash, nonce: 3 };
assert_eq!((used.tx_hash(), used.nonce(), used.is_transient()), (Some(hash), Some(3), false));
let mined = EvmError::TransactionReverted { hash: Some(hash), revert: None };
assert_eq!(mined.tx_hash(), Some(hash));
assert!(mined.to_string().contains(&hash.to_string()));
let receipt = EvmError::InvalidReceipt { hash, message: "no status".into() };
assert_eq!(receipt.tx_hash(), Some(hash));
assert_eq!(EvmError::TransactionReverted { hash: None, revert: None }.tx_hash(), None);
assert_eq!(EvmError::ReadOnly.tx_hash(), None);
assert_eq!(EvmError::FeeCapExceeded { estimated: 2, cap: 1 }.nonce(), None);
let code =
EvmError::CodeMismatch { address: Address::repeat_byte(1), expected: ParalockContract::Htlc, empty: true };
assert!(code.to_string().contains("ParalockHTLC") && code.to_string().contains("no code"), "{code}");
}
#[test]
fn range_refusals_are_told_apart_from_failures() {
use alloy::rpc::json_rpc::ErrorPayload;
let http = |status: u16, body: &str| TransportErrorKind::http_error(status, body.to_owned());
let rpc_error = |code: i64, message: &str| {
TransportError::ErrorResp(ErrorPayload { code, message: message.to_owned().into(), data: None })
};
assert!(is_range_refusal(&http(413, "")));
assert!(is_range_refusal(&http(413, "<html><body>413 Request Entity Too Large</body></html>")));
assert!(is_range_refusal(&http(400, "Bad Request")));
assert!(is_range_refusal(&http(503, "query returned more than 10000 results")));
assert!(is_range_refusal(&http(500, "eth_getLogs block range is too large, max is 1k")));
assert!(is_range_refusal(&rpc_error(-32005, "query returned more than 10000 results")));
assert!(is_range_refusal(&rpc_error(-32602, "invalid params")));
let text = TransportError::deser_err(
serde_json::from_str::<serde_json::Value>("nope").unwrap_err(),
"Log response size exceeded. You can make eth_getLogs requests with up to a 2K block range",
);
assert!(is_range_refusal(&text));
assert!(is_range_refusal(&TransportErrorKind::custom_str("exceeds the max range of 10000 blocks")));
assert!(!is_range_refusal(&TransportErrorKind::custom_str("error sending request: connection refused")));
assert!(!is_range_refusal(&http(429, "rate limit exceeded")));
assert!(!is_range_refusal(&http(500, "internal server error")));
assert!(!is_range_refusal(&http(401, "invalid API key")));
assert!(!is_range_refusal(&TransportErrorKind::custom_str("daily rate limit exceeded")));
assert!(!is_range_refusal(&TransportError::NullResp));
}
#[test]
fn range_refusals_are_not_retried_as_rate_limits() {
use alloy::rpc::json_rpc::ErrorPayload;
let payload = |code: i64, message: &str| ErrorPayload { code, message: message.to_owned().into(), data: None };
assert!(is_retryable_payload(&payload(-32005, "project ID request rate exceeded")));
assert!(is_retryable_payload(&payload(429, "Too Many Requests")));
assert!(is_retryable_payload(&payload(-32603, "request timeout")));
assert!(!is_retryable_payload(&payload(-32005, "query returned more than 10000 results")));
assert!(!is_retryable_payload(&payload(-32000, "execution reverted")));
let refusal = TransportError::ErrorResp(payload(-32005, "query returned more than 10000 results"));
assert!(!is_transient(&refusal) && is_range_refusal(&refusal));
}
}