Skip to main content

cow_sdk_browser_wallet/
error.rs

1//! Browser-wallet error types and RPC error normalization.
2//!
3//! Browser runtime failures are normalized before they cross the public Rust boundary so callers
4//! receive typed wallet and transport errors rather than raw JS values.
5
6use cow_sdk_core::{Cancelled, ChainId, CoreError, ErrorClass, Redacted};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use thiserror::Error;
10
11/// Serialized JSON-RPC error payload returned by an EIP-1193 wallet.
12#[allow(
13    clippy::derive_partial_eq_without_eq,
14    reason = "the `data: Option<serde_json::Value>` field cannot participate in `Eq` because `serde_json::Value` does not implement `Eq`"
15)]
16#[non_exhaustive]
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct RpcErrorPayload {
20    /// Numeric wallet or RPC error code.
21    pub code: i32,
22    /// Human-readable wallet or RPC error message.
23    pub message: Redacted<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    /// Optional provider-specific error data.
26    pub data: Option<Redacted<Value>>,
27}
28
29impl RpcErrorPayload {
30    /// Creates a typed JSON-RPC error payload from the current public field set.
31    #[must_use]
32    pub fn new(code: i32, message: impl Into<String>, data: Option<Value>) -> Self {
33        Self {
34            code,
35            message: Redacted::new(message.into()),
36            data: data.map(Redacted::new),
37        }
38    }
39}
40
41/// Errors produced by typed browser-wallet discovery, session, provider, and signer flows.
42#[non_exhaustive]
43#[derive(Debug, Clone, PartialEq, Error)]
44pub enum BrowserWalletError {
45    /// No injected browser wallet provider is available in the current runtime.
46    #[error("wallet provider is unavailable")]
47    WalletUnavailable,
48    /// Discovery found more than one candidate and requires an explicit wallet selection.
49    #[error(
50        "wallet discovery requires explicit provider selection because {candidates} injected wallets were found"
51    )]
52    DiscoverySelectionRequired {
53        /// Number of candidates returned by discovery.
54        candidates: usize,
55    },
56    /// A requested discovery index is outside the available wallet range.
57    #[error(
58        "wallet discovery selection index {index} is out of range for {candidates} injected wallets"
59    )]
60    DiscoverySelectionOutOfRange {
61        /// Requested wallet index.
62        index: usize,
63        /// Number of available discovery candidates.
64        candidates: usize,
65    },
66    /// A provider origin label failed local validation before provider construction.
67    #[error("wallet provider origin is invalid: {message}")]
68    InvalidProviderOrigin {
69        /// Sanitized validation failure description.
70        message: Redacted<String>,
71    },
72    /// An EIP-1193 provider did not have discovery metadata or an explicit trusted origin.
73    #[error("wallet provider origin is not trusted: {origin}")]
74    UntrustedProviderOrigin {
75        /// Redacted provider origin label.
76        origin: Redacted<String>,
77    },
78    /// The wallet explicitly rejected a user-authorized request.
79    ///
80    /// The rejected RPC method and the EIP-1193 code are surfaced; the
81    /// provider's free-form message is retained for programmatic access but
82    /// kept off the public rendering, so this variant reads as cleanly as the
83    /// signing-layer typed-data rejection for the same `4001` condition.
84    #[error("wallet request `{method}` was rejected by the user ({code})")]
85    UserRejectedRequest {
86        /// RPC method that was rejected.
87        method: String,
88        /// Provider error code.
89        code: i32,
90        /// Provider error message, retained for diagnostics and redacted on
91        /// public rendering.
92        message: Redacted<String>,
93    },
94    /// The wallet reported that it is disconnected from all chains.
95    #[error(
96        "wallet request `{method}` failed because the provider is disconnected ({code}): {message}"
97    )]
98    Disconnected {
99        /// RPC method that failed.
100        method: String,
101        /// Provider error code.
102        code: i32,
103        /// Provider error message.
104        message: Redacted<String>,
105    },
106    /// The wallet reported that the currently connected chain is incompatible with the request.
107    #[error(
108        "wallet request `{method}` failed because the current chain is not connected ({code}): {message}"
109    )]
110    WrongChain {
111        /// RPC method that failed.
112        method: String,
113        /// Provider error code.
114        code: i32,
115        /// Provider error message.
116        message: Redacted<String>,
117    },
118    /// The requested chain has not been added to the wallet yet.
119    #[error(
120        "wallet request `{method}` failed because the requested chain is not added ({code}): {message}"
121    )]
122    ChainNotAdded {
123        /// Chain id requested by the wallet call, when the request carried one.
124        ///
125        /// `None` when the failing request did not carry a parseable chain id,
126        /// so the surfaced error never claims a chain the caller did not
127        /// request.
128        chain_id: Option<ChainId>,
129        /// RPC method that failed.
130        method: String,
131        /// Provider error code.
132        code: i32,
133        /// Provider error message.
134        message: Redacted<String>,
135    },
136    /// The typed add-chain input is invalid before any wallet request is attempted.
137    #[error("wallet chain configuration for chain {chain_id} is invalid: {message}")]
138    InvalidChainConfiguration {
139        /// Chain id referenced by the configuration.
140        chain_id: ChainId,
141        /// Validation failure description.
142        message: Redacted<String>,
143    },
144    /// The wallet session chain does not match the expected workflow chain.
145    #[error(
146        "wallet session chain {session_chain_id} does not match expected chain {expected_chain_id}"
147    )]
148    SessionChainMismatch {
149        /// Chain id required by the current workflow.
150        expected_chain_id: ChainId,
151        /// Chain id currently reported by the wallet session.
152        session_chain_id: ChainId,
153    },
154    /// The typed-data payload chain does not match the expected workflow chain.
155    #[error(
156        "typed-data domain chain {typed_data_chain_id} does not match expected chain {expected_chain_id}"
157    )]
158    TypedDataChainMismatch {
159        /// Chain id required by the current workflow.
160        expected_chain_id: ChainId,
161        /// Chain id encoded in the typed-data payload.
162        typed_data_chain_id: ChainId,
163    },
164    /// The wallet does not support the requested RPC method.
165    #[error("wallet method `{method}` is unsupported: {message}")]
166    UnsupportedRpcMethod {
167        /// Unsupported RPC method.
168        method: String,
169        /// Provider-supplied failure description.
170        message: Redacted<String>,
171    },
172    /// The wallet returned a response that does not match the typed contract.
173    #[error("wallet response for `{method}` is malformed: {message}")]
174    MalformedResponse {
175        /// RPC method whose response could not be decoded.
176        method: String,
177        /// Decode or validation failure description.
178        message: Redacted<String>,
179    },
180    /// An unclassified wallet or RPC error payload.
181    #[error("wallet rpc error for `{method}` ({code}): {message}")]
182    Rpc {
183        /// RPC method that failed.
184        method: String,
185        /// Provider error code.
186        code: i32,
187        /// Provider error message.
188        message: Redacted<String>,
189        /// Optional provider-specific error data.
190        data: Option<Redacted<Value>>,
191    },
192    /// JavaScript interop or DOM interaction failed in the browser runtime.
193    #[error("wallet JS interop error: {message}")]
194    JsInterop {
195        /// Interop failure description.
196        message: Redacted<String>,
197    },
198    /// JSON serialization, ABI conversion, or typed-data encoding failed locally.
199    #[error("wallet serialization error: {message}")]
200    Serialization {
201        /// Serialization or local encoding failure description.
202        message: Redacted<String>,
203    },
204    /// Shared core type or validation error.
205    #[error(transparent)]
206    Core(#[from] CoreError),
207    /// A long-running browser-wallet operation was cancelled through a cooperative cancellation token.
208    #[error("operation cancelled")]
209    Cancelled,
210}
211
212impl From<Cancelled> for BrowserWalletError {
213    fn from(_: Cancelled) -> Self {
214        Self::Cancelled
215    }
216}
217
218impl BrowserWalletError {
219    /// Returns the coarse-grained [`ErrorClass`] for this error.
220    #[must_use]
221    pub const fn class(&self) -> ErrorClass {
222        match self {
223            Self::Core(error) => error.class(),
224            Self::Cancelled => ErrorClass::Cancelled,
225            // Every other typed wallet failure (user rejection, disconnected,
226            // wrong chain, malformed response, JS interop, serialization, or
227            // unclassified RPC payload) surfaces from the signing edge.
228            _ => ErrorClass::Signing,
229        }
230    }
231}
232
233impl BrowserWalletError {
234    pub(crate) fn from_rpc(
235        method: &str,
236        payload: RpcErrorPayload,
237        requested_chain: Option<ChainId>,
238    ) -> Self {
239        match payload.code {
240            4001 => Self::UserRejectedRequest {
241                method: method.to_owned(),
242                code: payload.code,
243                message: payload.message,
244            },
245            4900 => Self::Disconnected {
246                method: method.to_owned(),
247                code: payload.code,
248                message: payload.message,
249            },
250            4901 => Self::WrongChain {
251                method: method.to_owned(),
252                code: payload.code,
253                message: payload.message,
254            },
255            4902 => Self::ChainNotAdded {
256                chain_id: requested_chain,
257                method: method.to_owned(),
258                code: payload.code,
259                message: payload.message,
260            },
261            -32601 => Self::UnsupportedRpcMethod {
262                method: method.to_owned(),
263                message: payload.message,
264            },
265            _ => Self::Rpc {
266                method: method.to_owned(),
267                code: payload.code,
268                message: payload.message,
269                data: payload.data,
270            },
271        }
272    }
273
274    pub(crate) fn malformed_response(method: &str, message: impl Into<String>) -> Self {
275        Self::MalformedResponse {
276            method: method.to_owned(),
277            message: message.into().into(),
278        }
279    }
280
281    pub(crate) fn serialization(message: impl Into<String>) -> Self {
282        Self::Serialization {
283            message: message.into().into(),
284        }
285    }
286
287    pub(crate) const fn discovery_selection_required(candidates: usize) -> Self {
288        Self::DiscoverySelectionRequired { candidates }
289    }
290
291    pub(crate) const fn discovery_selection_out_of_range(index: usize, candidates: usize) -> Self {
292        Self::DiscoverySelectionOutOfRange { index, candidates }
293    }
294
295    pub(crate) fn invalid_chain_configuration(
296        chain_id: ChainId,
297        message: impl Into<String>,
298    ) -> Self {
299        Self::InvalidChainConfiguration {
300            chain_id,
301            message: message.into().into(),
302        }
303    }
304
305    #[cfg(target_arch = "wasm32")]
306    pub(crate) fn js(message: impl Into<String>) -> Self {
307        Self::JsInterop {
308            message: message.into().into(),
309        }
310    }
311}
312
313/// Exposes the EIP-1193 provider error code carried by the
314/// `UserRejectedRequest` variant so the signing crate can route the
315/// rejection through `cow_sdk_signing::SigningError::SignerRejection`.
316/// Every other variant returns `None` and falls through to the
317/// signing crate's redacted display path.
318///
319/// The match is exhaustive over every typed rejection-class variant
320/// the wallet error carries today; new EIP-1193 rejection variants
321/// must add a row here so the classification contract stays pinned
322/// against the typed error surface (and not against the `Display`
323/// shape). Pinning lives in
324/// `crates/browser-wallet/tests/signer_error_trait_contract.rs`.
325impl cow_sdk_core::UserRejection for BrowserWalletError {
326    fn user_rejection_code(&self) -> Option<i32> {
327        match self {
328            Self::UserRejectedRequest { code, .. } => Some(*code),
329            _ => None,
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use serde_json::json;
338
339    #[test]
340    fn known_rpc_error_codes_map_to_explicit_browser_wallet_variants() {
341        let cases = [
342            (
343                4001,
344                None,
345                BrowserWalletError::UserRejectedRequest {
346                    method: "eth_requestAccounts".to_owned(),
347                    code: 4001,
348                    message: "code-4001".to_owned().into(),
349                },
350            ),
351            (
352                4900,
353                None,
354                BrowserWalletError::Disconnected {
355                    method: "eth_requestAccounts".to_owned(),
356                    code: 4900,
357                    message: "code-4900".to_owned().into(),
358                },
359            ),
360            (
361                4901,
362                None,
363                BrowserWalletError::WrongChain {
364                    method: "eth_requestAccounts".to_owned(),
365                    code: 4901,
366                    message: "code-4901".to_owned().into(),
367                },
368            ),
369            (
370                4902,
371                Some(u64::from(cow_sdk_core::SupportedChainId::Base)),
372                BrowserWalletError::ChainNotAdded {
373                    chain_id: Some(u64::from(cow_sdk_core::SupportedChainId::Base)),
374                    method: "eth_requestAccounts".to_owned(),
375                    code: 4902,
376                    message: "code-4902".to_owned().into(),
377                },
378            ),
379            (
380                -32601,
381                None,
382                BrowserWalletError::UnsupportedRpcMethod {
383                    method: "eth_requestAccounts".to_owned(),
384                    message: "code--32601".to_owned().into(),
385                },
386            ),
387        ];
388
389        for (code, requested_chain, expected) in cases {
390            let error = BrowserWalletError::from_rpc(
391                "eth_requestAccounts",
392                RpcErrorPayload::new(code, format!("code-{code}"), None),
393                requested_chain,
394            );
395
396            assert_eq!(error, expected);
397        }
398    }
399
400    #[test]
401    fn unknown_rpc_codes_preserve_the_raw_rpc_payload_shape() {
402        let error = BrowserWalletError::from_rpc(
403            "wallet_switchEthereumChain",
404            RpcErrorPayload::new(
405                -32_000,
406                "generic rpc error",
407                Some(json!({ "detail": "kept" })),
408            ),
409            Some(u64::from(cow_sdk_core::SupportedChainId::Mainnet)),
410        );
411
412        assert_eq!(
413            error,
414            BrowserWalletError::Rpc {
415                method: "wallet_switchEthereumChain".to_owned(),
416                code: -32_000,
417                message: "generic rpc error".to_owned().into(),
418                data: Some(json!({ "detail": "kept" }).into()),
419            }
420        );
421    }
422}