1use cow_sdk_core::{Cancelled, ChainId, CoreError, ErrorClass, Redacted};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use thiserror::Error;
10
11#[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 pub code: i32,
22 pub message: Redacted<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub data: Option<Redacted<Value>>,
27}
28
29impl RpcErrorPayload {
30 #[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#[non_exhaustive]
43#[derive(Debug, Clone, PartialEq, Error)]
44pub enum BrowserWalletError {
45 #[error("wallet provider is unavailable")]
47 WalletUnavailable,
48 #[error(
50 "wallet discovery requires explicit provider selection because {candidates} injected wallets were found"
51 )]
52 DiscoverySelectionRequired {
53 candidates: usize,
55 },
56 #[error(
58 "wallet discovery selection index {index} is out of range for {candidates} injected wallets"
59 )]
60 DiscoverySelectionOutOfRange {
61 index: usize,
63 candidates: usize,
65 },
66 #[error("wallet provider origin is invalid: {message}")]
68 InvalidProviderOrigin {
69 message: Redacted<String>,
71 },
72 #[error("wallet provider origin is not trusted: {origin}")]
74 UntrustedProviderOrigin {
75 origin: Redacted<String>,
77 },
78 #[error("wallet request `{method}` was rejected by the user ({code})")]
85 UserRejectedRequest {
86 method: String,
88 code: i32,
90 message: Redacted<String>,
93 },
94 #[error(
96 "wallet request `{method}` failed because the provider is disconnected ({code}): {message}"
97 )]
98 Disconnected {
99 method: String,
101 code: i32,
103 message: Redacted<String>,
105 },
106 #[error(
108 "wallet request `{method}` failed because the current chain is not connected ({code}): {message}"
109 )]
110 WrongChain {
111 method: String,
113 code: i32,
115 message: Redacted<String>,
117 },
118 #[error(
120 "wallet request `{method}` failed because the requested chain is not added ({code}): {message}"
121 )]
122 ChainNotAdded {
123 chain_id: Option<ChainId>,
129 method: String,
131 code: i32,
133 message: Redacted<String>,
135 },
136 #[error("wallet chain configuration for chain {chain_id} is invalid: {message}")]
138 InvalidChainConfiguration {
139 chain_id: ChainId,
141 message: Redacted<String>,
143 },
144 #[error(
146 "wallet session chain {session_chain_id} does not match expected chain {expected_chain_id}"
147 )]
148 SessionChainMismatch {
149 expected_chain_id: ChainId,
151 session_chain_id: ChainId,
153 },
154 #[error(
156 "typed-data domain chain {typed_data_chain_id} does not match expected chain {expected_chain_id}"
157 )]
158 TypedDataChainMismatch {
159 expected_chain_id: ChainId,
161 typed_data_chain_id: ChainId,
163 },
164 #[error("wallet method `{method}` is unsupported: {message}")]
166 UnsupportedRpcMethod {
167 method: String,
169 message: Redacted<String>,
171 },
172 #[error("wallet response for `{method}` is malformed: {message}")]
174 MalformedResponse {
175 method: String,
177 message: Redacted<String>,
179 },
180 #[error("wallet rpc error for `{method}` ({code}): {message}")]
182 Rpc {
183 method: String,
185 code: i32,
187 message: Redacted<String>,
189 data: Option<Redacted<Value>>,
191 },
192 #[error("wallet JS interop error: {message}")]
194 JsInterop {
195 message: Redacted<String>,
197 },
198 #[error("wallet serialization error: {message}")]
200 Serialization {
201 message: Redacted<String>,
203 },
204 #[error(transparent)]
206 Core(#[from] CoreError),
207 #[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 #[must_use]
221 pub const fn class(&self) -> ErrorClass {
222 match self {
223 Self::Core(error) => error.class(),
224 Self::Cancelled => ErrorClass::Cancelled,
225 _ => 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
313impl 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}