Skip to main content

tycho_ethereum/rpc/
errors.rs

1use std::fmt::Display;
2
3use alloy::transports::{RpcError as AlloyRpcError, TransportErrorKind};
4use thiserror::Error;
5
6/// Redacts the path component of any URLs in `s`.
7///
8/// Prevents API keys embedded in URL paths (e.g. `/6da0db2d2bfcd644f87e6f75db0a0d3b`) from
9/// leaking into logs or error messages.
10fn redact_url_paths(s: &str) -> String {
11    let mut result = String::with_capacity(s.len());
12    let mut pos = 0;
13
14    while pos < s.len() {
15        let Some(idx) = s[pos..].find("://") else {
16            result.push_str(&s[pos..]);
17            break;
18        };
19
20        let abs = pos + idx;
21        result.push_str(&s[pos..abs + 3]); // include "://"
22        pos = abs + 3;
23
24        // Find end of URL token (whitespace or closing delimiter)
25        let token_end = s[pos..]
26            .find(|c: char| c.is_whitespace() || matches!(c, '(' | ')' | '"' | '\''))
27            .map(|i| pos + i)
28            .unwrap_or(s.len());
29
30        // If there's a path component, replace it with /***
31        if let Some(slash) = s[pos..token_end].find('/') {
32            result.push_str(&s[pos..pos + slash]); // host only
33            result.push_str("/***");
34        } else {
35            result.push_str(&s[pos..token_end]); // host with no path
36        }
37        pos = token_end;
38    }
39
40    result
41}
42
43#[derive(Error, Debug)]
44pub struct ReqwestError {
45    pub msg: String,
46    #[source]
47    pub source: AlloyRpcError<TransportErrorKind>,
48}
49
50impl Display for ReqwestError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "{}: {}", self.msg, redact_url_paths(&self.source.to_string()))
53    }
54}
55
56#[derive(Error, Debug)]
57pub enum RequestError {
58    Reqwest(ReqwestError),
59    Other(String),
60}
61
62impl Display for RequestError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            RequestError::Reqwest(e) => write!(f, "{e}"),
66            RequestError::Other(e) => write!(f, "{e}"),
67        }
68    }
69}
70
71#[derive(Error, Debug)]
72pub enum RPCError {
73    #[error("RPC setup error: {0}")]
74    SetupError(String),
75    #[error("Request error: {0}")]
76    RequestError(RequestError),
77    #[error("Tracing failure: {0}")]
78    TracingFailure(String),
79    #[error("Unknown error: {0}")]
80    UnknownError(String),
81}
82
83impl RPCError {
84    pub(super) fn from_alloy<S: ToString>(
85        msg: S,
86        error: AlloyRpcError<TransportErrorKind>,
87    ) -> Self {
88        RPCError::RequestError(RequestError::Reqwest(ReqwestError {
89            msg: msg.to_string(),
90            source: error,
91        }))
92    }
93
94    /// Returns true when the error is a JSON-RPC "execution reverted" response, i.e. the EVM
95    /// executed the call and reverted. Such failures are deterministic properties of the called
96    /// contract, not transport or provider issues.
97    pub fn is_execution_reverted(&self) -> bool {
98        let RPCError::RequestError(RequestError::Reqwest(e)) = self else {
99            return false;
100        };
101        let AlloyRpcError::ErrorResp(payload) = &e.source else {
102            return false;
103        };
104        // EIP-1474 reserves code 3 for eth_call reverts; some providers surface reverts
105        // under generic codes (e.g. -32000) with an "execution reverted" message instead.
106        payload.code == 3 ||
107            payload
108                .message
109                .to_lowercase()
110                .contains("execution reverted")
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use alloy::rpc::json_rpc::ErrorPayload;
117    use rstest::rstest;
118
119    use super::{redact_url_paths, AlloyRpcError, RPCError, TransportErrorKind};
120
121    #[rstest]
122    #[case::eip_1474_revert_code(3, "execution reverted", true)]
123    #[case::revert_under_generic_code(-32000, "execution reverted: something", true)]
124    #[case::header_not_found(-32000, "header not found", false)]
125    #[case::invalid_request(-32600, "invalid request", false)]
126    fn is_execution_reverted_classification(
127        #[case] code: i64,
128        #[case] message: &str,
129        #[case] expected: bool,
130    ) {
131        let payload = ErrorPayload { code, message: message.to_string().into(), data: None };
132        let err = RPCError::from_alloy(
133            "eth_call failed",
134            AlloyRpcError::<TransportErrorKind>::ErrorResp(payload),
135        );
136        assert_eq!(err.is_execution_reverted(), expected);
137    }
138
139    #[test]
140    fn is_execution_reverted_false_for_non_request_errors() {
141        assert!(!RPCError::SetupError("bad url".to_string()).is_execution_reverted());
142        assert!(!RPCError::UnknownError("boom".to_string()).is_execution_reverted());
143    }
144
145    #[test]
146    fn redacts_path_component() {
147        let input =
148            "error sending request for url (https://arbitrum.chainstack.com/supersecretkey)";
149        let output = redact_url_paths(input);
150        assert_eq!(output, "error sending request for url (https://arbitrum.chainstack.com/***)");
151    }
152
153    #[test]
154    fn preserves_url_without_path() {
155        let input = "error sending request for url (https://arbitrum.chainstack.com)";
156        let output = redact_url_paths(input);
157        assert_eq!(output, input);
158    }
159
160    #[test]
161    fn passthrough_when_no_url() {
162        let input = "connection refused";
163        assert_eq!(redact_url_paths(input), input);
164    }
165
166    #[test]
167    fn redacts_multiple_urls() {
168        let input = "primary https://a.example.com/key1 fallback https://b.example.com/key2";
169        let output = redact_url_paths(input);
170        assert_eq!(output, "primary https://a.example.com/*** fallback https://b.example.com/***");
171    }
172
173    #[test]
174    fn redacts_url_at_end_of_string() {
175        let input = "failed: https://node.example.com/apikey";
176        let output = redact_url_paths(input);
177        assert_eq!(output, "failed: https://node.example.com/***");
178    }
179}