Skip to main content

paralock_evm/
error.rs

1//! Errors and revert decoding.
2
3use std::fmt;
4use std::time::Duration;
5
6use alloy::primitives::{Address, B256, Bytes};
7use alloy::sol_types::{Panic, Revert, SolError, SolInterface};
8use alloy::transports::{RpcError, TransportError, TransportErrorKind};
9use serde::{Deserialize, Serialize};
10
11use crate::artifacts::ParalockContract;
12use crate::bindings::{
13    ITIP20Errors::ITIP20ErrorsErrors, ITIP20Factory::ITIP20FactoryErrors, ITIP20Issuer::ITIP20IssuerErrors,
14    MockUSD::MockUSDErrors, ParalockAdaptorEscrow::ParalockAdaptorEscrowErrors, ParalockHTLC::ParalockHTLCErrors,
15};
16use crate::swap::EscrowStatus;
17
18/// Everything that can go wrong in this crate.
19///
20/// Errors never contain private keys. RPC error messages have the configured RPC URL redacted (it often embeds an
21/// API key).
22#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum EvmError {
25    /// The configuration is unusable (bad RPC URL, fee token on a non-Tempo chain, …).
26    #[error("invalid configuration: {0}")]
27    Config(String),
28    /// An argument failed validation before anything was sent.
29    #[error("invalid input: {0}")]
30    InvalidInput(String),
31    /// The RPC request failed (after the configured retries for transient failures).
32    #[error("RPC error during {method}{}: {message}", code.map(|c| format!(" (code {c})")).unwrap_or_default())]
33    Rpc {
34        /// The JSON-RPC method (or client operation) that failed.
35        method: &'static str,
36        /// JSON-RPC error code, when the node returned one.
37        code: Option<i64>,
38        /// The error message, with the RPC URL redacted.
39        message: String,
40        /// True for transport failures, timeouts, rate limits and 5xx responses: retrying later may succeed.
41        transient: bool,
42    },
43    /// The RPC node serves another chain than the one configured. Nothing was sent.
44    #[error("chain id mismatch: configured {expected}, but the RPC node reports {actual}")]
45    ChainIdMismatch {
46        /// The configured chain id.
47        expected: u64,
48        /// The chain id the node reported.
49        actual: u64,
50    },
51    /// The node's response is malformed or inconsistent (it is treated as untrusted input).
52    #[error("invalid RPC response: {0}")]
53    InvalidResponse(String),
54    /// A transaction reverted: either its simulation (`hash` is `None`, nothing was sent) or on-chain after it was mined.
55    #[error("{}", reverted_message(hash.as_ref(), revert.as_ref()))]
56    TransactionReverted {
57        /// The mined transaction, or `None` when the revert was caught by simulation before sending.
58        hash: Option<B256>,
59        /// The decoded revert, when the node returned revert data.
60        revert: Option<RevertReason>,
61    },
62    /// A sent transaction was not mined within the receipt timeout. It may still be pending: wait again
63    /// (`wait_for_receipt` / `wait_for_sent`), check `transaction_status`, or replace it with higher fees (`bump`,
64    /// same nonce). Retrying the operation from scratch instead would send a second transaction.
65    #[error("transaction {hash}{} was not mined within {timeout:?}", nonce_suffix(*nonce))]
66    ReceiptTimeout {
67        /// The transaction hash (the newest version, if it was replaced with higher fees).
68        hash: B256,
69        /// Its nonce, when known (always for transactions this process sent).
70        nonce: Option<u64>,
71        /// The timeout that elapsed.
72        timeout: Duration,
73    },
74    /// The broadcast of a signed transaction failed and the node could not be asked whether it received it anyway
75    /// (e.g. the response was lost and the follow-up lookup failed too). It **may have been sent**: look it up
76    /// (`transaction_status`, `wait_for_receipt`) or replace it (`bump`) before sending the operation again.
77    #[error("broadcast of transaction {hash} (nonce {nonce}) may have failed: {message}")]
78    BroadcastUnknown {
79        /// The signed transaction's hash.
80        hash: B256,
81        /// Its nonce.
82        nonce: u64,
83        /// The broadcast error, with the RPC URL redacted.
84        message: String,
85    },
86    /// A transaction cannot be replaced: its nonce is already used by a mined transaction (this one, an earlier
87    /// version of it, or another transaction of the account). Wait for its receipt instead (`wait_for_sent`).
88    #[error("nonce {nonce} of transaction {hash} is already used by a mined transaction")]
89    NonceUsed {
90        /// The transaction that was to be replaced.
91        hash: B256,
92        /// Its nonce.
93        nonce: u64,
94    },
95    /// The receipt of a mined transaction is malformed (e.g. it has no status).
96    #[error("invalid receipt for transaction {hash}: {message}")]
97    InvalidReceipt {
98        /// The transaction hash.
99        hash: B256,
100        /// What is wrong with the receipt.
101        message: String,
102    },
103    /// The address does not hold the runtime code of the expected Paralock contract (no code, another contract or
104    /// another build). Refuse to use it as an escrow.
105    #[error("{}", code_mismatch_message(address, *expected, *empty))]
106    CodeMismatch {
107        /// The checked address.
108        address: Address,
109        /// The contract it was expected to hold.
110        expected: ParalockContract,
111        /// True when the address holds no code at all.
112        empty: bool,
113    },
114    /// A waiter gave up.
115    #[error("timed out after {timeout:?} waiting for {what}")]
116    Timeout {
117        /// What was awaited.
118        what: String,
119        /// The timeout that elapsed.
120        timeout: Duration,
121    },
122    /// The client has no signer.
123    #[error("this client is read-only: configure a signer to send transactions")]
124    ReadOnly,
125    /// The signer failed (a KMS error, a mismatched chain id, …).
126    #[error("signer error: {0}")]
127    Signer(String),
128    /// A claim signature is malformed or not canonical.
129    #[error("invalid claim signature: {0}")]
130    Signature(#[from] SignatureError),
131    /// The claim signature does not recover to the swap's signer. Nothing was sent.
132    #[error("claim signature recovers to {recovered}, not the swap's signer {expected}")]
133    WrongSigner {
134        /// The swap's `signer`.
135        expected: Address,
136        /// The address the signature recovers to.
137        recovered: Address,
138    },
139    /// `sha256(preimage)` is not the swap's hashlock. Nothing was sent.
140    #[error("preimage does not hash to {hashlock}")]
141    PreimageMismatch {
142        /// The swap's hashlock.
143        hashlock: B256,
144    },
145    /// The entry exists on-chain but its `Locked` event was not found from the configured start block (the start
146    /// block is after the contract's deployment, or the node has not indexed the log yet).
147    #[error("{key} is {status:?} but no Locked event was found from block {from_block}")]
148    LockEventMissing {
149        /// Hashlock (htlc) or escrow id (adaptor).
150        key: B256,
151        /// Its on-chain status.
152        status: EscrowStatus,
153        /// The configured first block of the scan.
154        from_block: u64,
155    },
156    /// The entry is settled on-chain but neither its `Claimed` nor its `Refunded` event is served yet. Retry later.
157    #[error("{key} is settled but its Claimed/Refunded event is not indexed yet")]
158    SettlementNotIndexed {
159        /// Hashlock (htlc) or escrow id (adaptor).
160        key: B256,
161    },
162    /// The fee estimate exceeds `EvmConfig::max_fee_per_gas`. Nothing was sent.
163    #[error("estimated max fee per gas {estimated} exceeds the configured cap {cap}")]
164    FeeCapExceeded {
165        /// The estimated `maxFeePerGas`.
166        estimated: u128,
167        /// The configured cap.
168        cap: u128,
169    },
170    /// `deploy_paralock_contracts` refuses known public networks.
171    #[error(
172        "chain {chain_id} is {name}: deploy_paralock_contracts only deploys to local chains (it creates a mintable \
173         test stablecoin). Use contracts/script/Deploy.s.sol"
174    )]
175    PublicNetwork {
176        /// The chain id.
177        chain_id: u64,
178        /// The network's name.
179        name: &'static str,
180    },
181}
182
183impl EvmError {
184    /// True when retrying the same operation later may succeed: transient RPC failures, receipt or wait timeouts,
185    /// an uncertain broadcast, and events that are not indexed yet.
186    ///
187    /// When [`EvmError::tx_hash`] is `Some`, a transaction may already be out: retry by waiting for (or bumping)
188    /// that transaction, not by sending the operation again.
189    pub fn is_transient(&self) -> bool {
190        match self {
191            Self::Rpc { transient, .. } => *transient,
192            Self::ReceiptTimeout { .. }
193            | Self::BroadcastUnknown { .. }
194            | Self::Timeout { .. }
195            | Self::SettlementNotIndexed { .. }
196            | Self::LockEventMissing { .. } => true,
197            _ => false,
198        }
199    }
200
201    /// The hash of the transaction this error is about, if one was signed and (possibly) broadcast: a receipt
202    /// timeout, an uncertain broadcast, a mined revert, a nonce already used, or a malformed receipt. `None` means
203    /// the failing step sent nothing (an earlier step of a multi-transaction call, such as the approval in
204    /// `approve_and_lock`, may have).
205    ///
206    /// Keep it when converting this error into another type: it is what lets a caller wait for, look up or replace
207    /// the transaction instead of sending a second one.
208    pub fn tx_hash(&self) -> Option<B256> {
209        match self {
210            Self::ReceiptTimeout { hash, .. }
211            | Self::BroadcastUnknown { hash, .. }
212            | Self::NonceUsed { hash, .. }
213            | Self::InvalidReceipt { hash, .. } => Some(*hash),
214            Self::TransactionReverted { hash, .. } => *hash,
215            _ => None,
216        }
217    }
218
219    /// The nonce of the transaction this error is about, when known (see [`EvmError::tx_hash`]).
220    pub fn nonce(&self) -> Option<u64> {
221        match self {
222            Self::ReceiptTimeout { nonce, .. } => *nonce,
223            Self::BroadcastUnknown { nonce, .. } | Self::NonceUsed { nonce, .. } => Some(*nonce),
224            _ => None,
225        }
226    }
227
228    /// The custom error name of a revert (`"SwapExpired"`, `"SwapSettled"`, …), if this is a decoded revert.
229    pub fn revert_name(&self) -> Option<&str> {
230        match self {
231            Self::TransactionReverted { revert: Some(revert), .. } => revert.name(),
232            _ => None,
233        }
234    }
235
236    /// The decoded revert, if this is a revert.
237    pub fn revert(&self) -> Option<&RevertReason> {
238        match self {
239            Self::TransactionReverted { revert, .. } => revert.as_ref(),
240            _ => None,
241        }
242    }
243}
244
245fn code_mismatch_message(address: &Address, expected: ParalockContract, empty: bool) -> String {
246    let what = if empty { " (it has no code)" } else { "" };
247    format!("{address} does not hold the {} runtime code of this build{what}", expected.name())
248}
249
250fn nonce_suffix(nonce: Option<u64>) -> String {
251    nonce.map(|n| format!(" (nonce {n})")).unwrap_or_default()
252}
253
254fn reverted_message(hash: Option<&B256>, revert: Option<&RevertReason>) -> String {
255    let what = match hash {
256        Some(hash) => format!("transaction {hash} reverted"),
257        None => "transaction would revert (simulation)".to_owned(),
258    };
259    match revert {
260        Some(revert) => format!("{what}: {revert}"),
261        None => what,
262    }
263}
264
265/// Why a claim signature was rejected.
266#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
267#[non_exhaustive]
268pub enum SignatureError {
269    /// `v` is not 0, 1, 27 or 28.
270    #[error("invalid signature recovery value {0}")]
271    InvalidRecovery(u64),
272    /// `s` is 0 or at least the curve order.
273    #[error("signature s is out of range")]
274    SOutOfRange,
275    /// `r` is 0 or at least the curve order.
276    #[error("signature r is out of range")]
277    ROutOfRange,
278    /// `v` is not 27/28 or `s` is high: the contract would reject it (`MalleableSignature`).
279    #[error("signature is not canonical (v must be 27 or 28 and s low)")]
280    NotCanonical,
281    /// No public key recovers from the signature.
282    #[error("signature does not recover to a public key")]
283    Unrecoverable,
284}
285
286/// Which contract a revert came from, to resolve error names shared between contracts (`InvalidAmount()`, …).
287#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase")]
289#[non_exhaustive]
290pub enum RevertSource {
291    /// `ParalockHTLC`.
292    Htlc,
293    /// `ParalockAdaptorEscrow`.
294    AdaptorEscrow,
295    /// A token (TIP-20, MockUSD or another ERC-20), or the TIP-20 factory.
296    Token,
297}
298
299/// A decoded revert: custom error, `Error(string)` or `Panic(uint256)`.
300#[derive(Clone, Debug, PartialEq, Eq)]
301pub struct RevertReason {
302    /// Raw revert data.
303    pub data: Bytes,
304    /// Error name: the custom error (`"SwapExpired"`), `"Error"` for a revert string, `"Panic"` for a panic. `None`
305    /// when the data matched no known error (or was empty).
306    pub name: Option<&'static str>,
307    /// The contract (or token) whose error definitions matched.
308    pub source: Option<RevertSource>,
309    /// Human-readable description with the decoded arguments.
310    pub message: String,
311}
312
313impl RevertReason {
314    /// Decodes revert data, trying `prefer`'s errors first (they share selectors with other contracts), then the
315    /// other Paralock contracts, TIP-20 / TIP-20 factory / MockUSD errors, and finally `Error(string)` /
316    /// `Panic(uint256)`. Never fails: unknown data yields `name: None`.
317    pub fn decode(data: Bytes, prefer: Option<RevertSource>) -> Self {
318        let order: [RevertSource; 3] = match prefer {
319            Some(RevertSource::AdaptorEscrow) => [RevertSource::AdaptorEscrow, RevertSource::Htlc, RevertSource::Token],
320            Some(RevertSource::Token) => [RevertSource::Token, RevertSource::Htlc, RevertSource::AdaptorEscrow],
321            _ => [RevertSource::Htlc, RevertSource::AdaptorEscrow, RevertSource::Token],
322        };
323        for source in order {
324            let decoded = match source {
325                RevertSource::Htlc => describe::<ParalockHTLCErrors>(&data, ParalockHTLCErrors::name_by_selector),
326                RevertSource::AdaptorEscrow => {
327                    describe::<ParalockAdaptorEscrowErrors>(&data, ParalockAdaptorEscrowErrors::name_by_selector)
328                }
329                RevertSource::Token => describe::<ITIP20ErrorsErrors>(&data, ITIP20ErrorsErrors::name_by_selector)
330                    .or_else(|| describe::<ITIP20FactoryErrors>(&data, ITIP20FactoryErrors::name_by_selector))
331                    .or_else(|| describe::<ITIP20IssuerErrors>(&data, ITIP20IssuerErrors::name_by_selector))
332                    .or_else(|| describe::<MockUSDErrors>(&data, MockUSDErrors::name_by_selector)),
333            };
334            if let Some((name, message)) = decoded {
335                return Self { data, name: Some(name), source: Some(source), message };
336            }
337        }
338        if let Ok(revert) = Revert::abi_decode_validate(&data) {
339            let message = format!("Error({:?})", revert.reason);
340            return Self { data, name: Some("Error"), source: None, message };
341        }
342        if let Ok(panic) = Panic::abi_decode_validate(&data) {
343            let message = format!("Panic({:#x})", panic.code);
344            return Self { data, name: Some("Panic"), source: None, message };
345        }
346        let message = if data.is_empty() { "no revert data".to_owned() } else { format!("unknown revert data {data}") };
347        Self { data, name: None, source: None, message }
348    }
349
350    /// The error name, if decoded.
351    pub fn name(&self) -> Option<&str> {
352        self.name
353    }
354
355    /// True if the error is `name` (e.g. `revert.is("SwapSettled")`).
356    pub fn is(&self, name: &str) -> bool {
357        self.name == Some(name)
358    }
359
360    /// The `ParalockHTLC` custom error, if the data decodes as one.
361    pub fn htlc_error(&self) -> Option<ParalockHTLCErrors> {
362        ParalockHTLCErrors::abi_decode_validate(&self.data).ok()
363    }
364
365    /// The `ParalockAdaptorEscrow` custom error, if the data decodes as one.
366    pub fn adaptor_error(&self) -> Option<ParalockAdaptorEscrowErrors> {
367        ParalockAdaptorEscrowErrors::abi_decode_validate(&self.data).ok()
368    }
369}
370
371impl fmt::Display for RevertReason {
372    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373        f.write_str(&self.message)
374    }
375}
376
377/// Decodes `data` as one of `E`'s errors and describes it as `Name { field: value, … }`.
378fn describe<E: SolInterface + fmt::Debug>(
379    data: &[u8],
380    name_by_selector: fn([u8; 4]) -> Option<&'static str>,
381) -> Option<(&'static str, String)> {
382    let selector: [u8; 4] = data.get(..4)?.try_into().ok()?;
383    if !E::valid_selector(selector) {
384        return None;
385    }
386    let decoded = E::abi_decode_validate(data).ok()?;
387    let name = name_by_selector(selector)?;
388    // `Variant(Struct { … })` -> `Struct { … }`; unit errors print as their name.
389    let debug = format!("{decoded:?}");
390    let inner = debug
391        .strip_prefix(name)
392        .and_then(|rest| rest.strip_prefix('('))
393        .and_then(|rest| rest.strip_suffix(')'))
394        .map(str::to_owned)
395        .unwrap_or(debug);
396    Some((name, inner))
397}
398
399/// Context for turning alloy transport errors into [`EvmError`].
400#[derive(Clone, Debug)]
401pub(crate) struct ErrorContext {
402    /// Strings to redact from messages (the configured RPC URL, in its raw and normalized forms).
403    pub redact: Vec<String>,
404}
405
406impl ErrorContext {
407    pub(crate) fn redact(&self, message: &str) -> String {
408        let mut out = message.to_owned();
409        for secret in &self.redact {
410            if !secret.is_empty() {
411                out = out.replace(secret.as_str(), "<rpc-url>");
412            }
413        }
414        out
415    }
416
417    /// Maps a transport error from a plain RPC request.
418    pub(crate) fn rpc(&self, method: &'static str, err: TransportError) -> EvmError {
419        let code = match &err {
420            RpcError::ErrorResp(payload) => Some(payload.code),
421            _ => None,
422        };
423        EvmError::Rpc { method, code, message: self.redact(&err.to_string()), transient: is_transient(&err) }
424    }
425
426    /// Maps a transport error from `eth_call` / `eth_estimateGas`: reverts become [`EvmError::TransactionReverted`]
427    /// (with `hash: None`), everything else an RPC error.
428    pub(crate) fn call(&self, method: &'static str, err: TransportError, source: Option<RevertSource>) -> EvmError {
429        if let Some(data) = revert_data(&err) {
430            return EvmError::TransactionReverted { hash: None, revert: Some(RevertReason::decode(data, source)) };
431        }
432        self.rpc(method, err)
433    }
434}
435
436/// Revert data of an `eth_call` / `eth_estimateGas` error response, if it is a revert.
437pub(crate) fn revert_data(err: &TransportError) -> Option<Bytes> {
438    let RpcError::ErrorResp(payload) = err else { return None };
439    if let Some(data) = payload.as_revert_data() {
440        return Some(data);
441    }
442    // Code 3 is the standard "execution reverted" code; some nodes omit "revert" from the message.
443    if payload.code == 3 {
444        let raw = payload.data.as_ref()?;
445        let text: String = serde_json::from_str(raw.get()).ok()?;
446        let hex = text.strip_prefix("0x")?;
447        return alloy::hex::decode(hex).ok().map(Bytes::from);
448    }
449    if payload.message.contains("revert") {
450        return Some(Bytes::new());
451    }
452    None
453}
454
455/// True for failures worth retrying: transport errors, timeouts, rate limits, 5xx responses and other transient
456/// server conditions.
457pub(crate) fn is_transient(err: &TransportError) -> bool {
458    use alloy::transports::RpcError as E;
459    match err {
460        E::Transport(kind) => match kind {
461            TransportErrorKind::HttpError(http) => http.status == 429 || (500..=599).contains(&http.status),
462            TransportErrorKind::HttpErrorWithRetryAfter { error, .. } => {
463                error.status == 429 || (500..=599).contains(&error.status)
464            }
465            TransportErrorKind::MissingBatchResponse(_) | TransportErrorKind::BackendGone => true,
466            // reqwest failures (connection refused/reset, timeouts, DNS) surface as custom transport errors.
467            TransportErrorKind::Custom(_) => true,
468            _ => false,
469        },
470        E::ErrorResp(payload) => is_retryable_payload(payload),
471        E::NullResp => true,
472        E::DeserError { text, .. } => {
473            // Gateways sometimes answer a JSON-RPC request with an HTML or plain-text error page.
474            let trimmed = text.trim_start();
475            trimmed.starts_with('<') || trimmed.is_empty()
476        }
477        _ => false,
478    }
479}
480
481/// True for JSON-RPC error responses worth retrying: rate limits and node timeouts. Not a refusal of an
482/// `eth_getLogs` range or result size, even under a rate-limit code (Infura answers "query returned more than 10000
483/// results" with -32005): the same request would be refused again, so the scan narrows its window at once instead.
484pub(crate) fn is_retryable_payload(payload: &alloy::rpc::json_rpc::ErrorPayload) -> bool {
485    (payload.is_retry_err() || (payload.code == -32603 && payload.message.contains("timeout")))
486        && !mentions_range_limit(&payload.message)
487}
488
489/// Phrases with which nodes and providers refuse an `eth_getLogs` range or result size (lowercase), as in
490/// `@paralock/evm`'s `isRangeRefusal`.
491const RANGE_REFUSALS: [&str; 22] = [
492    "block range",
493    "range too large",
494    "range is too large",
495    "range too wide",
496    "range is too wide",
497    "range too big",
498    "range is too big",
499    "too many blocks",
500    "too many results",
501    "too many logs",
502    "query returned more than",
503    "limit exceeded",
504    "exceed max",
505    "exceeds max",
506    "exceed the max",
507    "exceeds the max",
508    "exceed limit",
509    "exceeds limit",
510    "exceed the limit",
511    "exceeds the limit",
512    "response size",
513    "payload too large",
514];
515
516/// True when the node refused an `eth_getLogs` request in a way a smaller block range may fix: any JSON-RPC error
517/// response, HTTP 400 or 413 (providers that reject large ranges at the gateway), or an error that mentions the
518/// range or result size ("block range", "query returned more than", "range too large", …). Transport failures,
519/// timeouts, rate limits (HTTP 429, "rate limit") and other HTTP errors are not refusals.
520pub(crate) fn is_range_refusal(err: &TransportError) -> bool {
521    match err {
522        RpcError::ErrorResp(_) => true,
523        RpcError::Transport(kind) => match kind.as_http_error() {
524            Some(http) if http.status == 400 || http.status == 413 => true,
525            Some(http) if http.status == 429 => false,
526            _ => mentions_range_limit(&err.to_string()),
527        },
528        _ => mentions_range_limit(&err.to_string()),
529    }
530}
531
532fn mentions_range_limit(message: &str) -> bool {
533    let message = message.to_lowercase();
534    !message.contains("rate limit") && RANGE_REFUSALS.iter().any(|phrase| message.contains(phrase))
535}
536
537#[cfg(test)]
538mod tests {
539    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
540
541    use super::*;
542    use crate::bindings::{ParalockAdaptorEscrow, ParalockHTLC};
543    use alloy::primitives::U256;
544
545    #[test]
546    fn decodes_custom_errors_with_their_names() {
547        let data = Bytes::from(ParalockHTLC::SwapExpired { expiry: 1_800_003_600 }.abi_encode());
548        let revert = RevertReason::decode(data.clone(), Some(RevertSource::Htlc));
549        assert_eq!(revert.name(), Some("SwapExpired"));
550        assert_eq!(revert.source, Some(RevertSource::Htlc));
551        assert!(revert.message.contains("1800003600"), "{}", revert.message);
552        assert!(matches!(revert.htlc_error(), Some(ParalockHTLCErrors::SwapExpired(_))));
553
554        let unit = RevertReason::decode(Bytes::from(ParalockHTLC::NotSender {}.abi_encode()), None);
555        assert_eq!(unit.name(), Some("NotSender"));
556        assert_eq!(unit.message, "NotSender");
557
558        let malleable = Bytes::from(ParalockAdaptorEscrow::MalleableSignature {}.abi_encode());
559        let revert = RevertReason::decode(malleable, Some(RevertSource::AdaptorEscrow));
560        assert_eq!(revert.name(), Some("MalleableSignature"));
561        assert!(matches!(revert.adaptor_error(), Some(ParalockAdaptorEscrowErrors::MalleableSignature(_))));
562    }
563
564    #[test]
565    fn shared_selectors_resolve_to_the_preferred_contract() {
566        let data = Bytes::from(ParalockAdaptorEscrow::SwapNotFound { id: B256::repeat_byte(1) }.abi_encode());
567        let as_adaptor = RevertReason::decode(data.clone(), Some(RevertSource::AdaptorEscrow));
568        assert_eq!(as_adaptor.source, Some(RevertSource::AdaptorEscrow));
569        let as_htlc = RevertReason::decode(data, Some(RevertSource::Htlc));
570        assert_eq!(as_htlc.source, Some(RevertSource::Htlc));
571        assert_eq!(as_htlc.name(), Some("SwapNotFound"));
572    }
573
574    #[test]
575    fn decodes_token_errors_strings_panics_and_garbage() {
576        let tip20 = crate::bindings::ITIP20Errors::InsufficientBalance {
577            available: U256::from(1),
578            required: U256::from(2),
579            token: Address::repeat_byte(0x20),
580        };
581        let revert = RevertReason::decode(Bytes::from(tip20.abi_encode()), Some(RevertSource::Htlc));
582        assert_eq!(revert.name(), Some("InsufficientBalance"));
583        assert_eq!(revert.source, Some(RevertSource::Token));
584
585        let string = Revert::from("nope").abi_encode();
586        let revert = RevertReason::decode(Bytes::from(string), None);
587        assert_eq!(revert.name(), Some("Error"));
588        assert!(revert.message.contains("nope"));
589
590        let panic = Panic { code: U256::from(0x11) }.abi_encode();
591        assert_eq!(RevertReason::decode(Bytes::from(panic), None).name(), Some("Panic"));
592
593        for garbage in [&b""[..], &[0xde, 0xad], &[0xde, 0xad, 0xbe, 0xef, 1, 2, 3]] {
594            let revert = RevertReason::decode(Bytes::copy_from_slice(garbage), None);
595            assert_eq!(revert.name(), None);
596        }
597        // A known selector with truncated arguments is not decoded (validation), and does not panic.
598        let mut truncated = ParalockHTLC::SwapExpired { expiry: 1 }.abi_encode();
599        truncated.truncate(10);
600        assert_eq!(RevertReason::decode(Bytes::from(truncated), None).name(), None);
601    }
602
603    #[test]
604    fn redacts_the_rpc_url() {
605        let ctx = ErrorContext { redact: vec!["https://rpc.example/key123".to_owned()] };
606        let msg = ctx.redact("error sending request for url (https://rpc.example/key123): connection refused");
607        assert!(!msg.contains("key123"));
608        assert!(msg.contains("<rpc-url>"));
609    }
610
611    #[test]
612    fn error_messages() {
613        let err = EvmError::TransactionReverted {
614            hash: None,
615            revert: Some(RevertReason::decode(
616                Bytes::from(ParalockHTLC::SwapSettled { hashlock: B256::ZERO }.abi_encode()),
617                None,
618            )),
619        };
620        assert_eq!(err.revert_name(), Some("SwapSettled"));
621        assert!(err.to_string().contains("simulation"));
622        assert!(err.to_string().contains("SwapSettled"));
623        assert_eq!(SignatureError::SOutOfRange.to_string(), "signature s is out of range");
624        assert_eq!(SignatureError::InvalidRecovery(29).to_string(), "invalid signature recovery value 29");
625    }
626
627    #[test]
628    fn errors_after_a_broadcast_carry_the_hash_and_nonce() {
629        let hash = B256::repeat_byte(0xab);
630        let timeout = EvmError::ReceiptTimeout { hash, nonce: Some(7), timeout: Duration::from_secs(5) };
631        assert_eq!((timeout.tx_hash(), timeout.nonce()), (Some(hash), Some(7)));
632        assert!(timeout.is_transient());
633        let text = timeout.to_string();
634        assert!(text.contains(&hash.to_string()) && text.contains("nonce 7"), "{text}");
635        let unknown_nonce = EvmError::ReceiptTimeout { hash, nonce: None, timeout: Duration::from_secs(5) };
636        assert_eq!(unknown_nonce.nonce(), None);
637        assert!(!unknown_nonce.to_string().contains("nonce"), "{unknown_nonce}");
638
639        let uncertain = EvmError::BroadcastUnknown { hash, nonce: 3, message: "connection reset".into() };
640        assert_eq!((uncertain.tx_hash(), uncertain.nonce()), (Some(hash), Some(3)));
641        assert!(uncertain.is_transient());
642        assert!(uncertain.to_string().contains(&hash.to_string()));
643
644        let used = EvmError::NonceUsed { hash, nonce: 3 };
645        assert_eq!((used.tx_hash(), used.nonce(), used.is_transient()), (Some(hash), Some(3), false));
646        let mined = EvmError::TransactionReverted { hash: Some(hash), revert: None };
647        assert_eq!(mined.tx_hash(), Some(hash));
648        assert!(mined.to_string().contains(&hash.to_string()));
649        let receipt = EvmError::InvalidReceipt { hash, message: "no status".into() };
650        assert_eq!(receipt.tx_hash(), Some(hash));
651        // Nothing was sent.
652        assert_eq!(EvmError::TransactionReverted { hash: None, revert: None }.tx_hash(), None);
653        assert_eq!(EvmError::ReadOnly.tx_hash(), None);
654        assert_eq!(EvmError::FeeCapExceeded { estimated: 2, cap: 1 }.nonce(), None);
655
656        let code =
657            EvmError::CodeMismatch { address: Address::repeat_byte(1), expected: ParalockContract::Htlc, empty: true };
658        assert!(code.to_string().contains("ParalockHTLC") && code.to_string().contains("no code"), "{code}");
659    }
660
661    #[test]
662    fn range_refusals_are_told_apart_from_failures() {
663        use alloy::rpc::json_rpc::ErrorPayload;
664        let http = |status: u16, body: &str| TransportErrorKind::http_error(status, body.to_owned());
665        let rpc_error = |code: i64, message: &str| {
666            TransportError::ErrorResp(ErrorPayload { code, message: message.to_owned().into(), data: None })
667        };
668        // Refusals: a smaller window may succeed.
669        assert!(is_range_refusal(&http(413, "")));
670        assert!(is_range_refusal(&http(413, "<html><body>413 Request Entity Too Large</body></html>")));
671        assert!(is_range_refusal(&http(400, "Bad Request")));
672        assert!(is_range_refusal(&http(503, "query returned more than 10000 results")));
673        assert!(is_range_refusal(&http(500, "eth_getLogs block range is too large, max is 1k")));
674        assert!(is_range_refusal(&rpc_error(-32005, "query returned more than 10000 results")));
675        assert!(is_range_refusal(&rpc_error(-32602, "invalid params")));
676        let text = TransportError::deser_err(
677            serde_json::from_str::<serde_json::Value>("nope").unwrap_err(),
678            "Log response size exceeded. You can make eth_getLogs requests with up to a 2K block range",
679        );
680        assert!(is_range_refusal(&text));
681        assert!(is_range_refusal(&TransportErrorKind::custom_str("exceeds the max range of 10000 blocks")));
682        // Failures a smaller window cannot fix.
683        assert!(!is_range_refusal(&TransportErrorKind::custom_str("error sending request: connection refused")));
684        assert!(!is_range_refusal(&http(429, "rate limit exceeded")));
685        assert!(!is_range_refusal(&http(500, "internal server error")));
686        assert!(!is_range_refusal(&http(401, "invalid API key")));
687        assert!(!is_range_refusal(&TransportErrorKind::custom_str("daily rate limit exceeded")));
688        assert!(!is_range_refusal(&TransportError::NullResp));
689    }
690
691    #[test]
692    fn range_refusals_are_not_retried_as_rate_limits() {
693        use alloy::rpc::json_rpc::ErrorPayload;
694        let payload = |code: i64, message: &str| ErrorPayload { code, message: message.to_owned().into(), data: None };
695        assert!(is_retryable_payload(&payload(-32005, "project ID request rate exceeded")));
696        assert!(is_retryable_payload(&payload(429, "Too Many Requests")));
697        assert!(is_retryable_payload(&payload(-32603, "request timeout")));
698        assert!(!is_retryable_payload(&payload(-32005, "query returned more than 10000 results")));
699        assert!(!is_retryable_payload(&payload(-32000, "execution reverted")));
700        let refusal = TransportError::ErrorResp(payload(-32005, "query returned more than 10000 results"));
701        assert!(!is_transient(&refusal) && is_range_refusal(&refusal));
702    }
703}