Skip to main content

walletkit_core/
error.rs

1use thiserror::Error;
2use world_id_core::{
3    primitives::{oprf::WorldIdRequestAuthError, PrimitiveError},
4    AuthenticatorError,
5};
6use world_id_proof::ProofError;
7
8use crate::storage::StorageError;
9
10/// Error outputs from `WalletKit`
11#[derive(Debug, Error, uniffi::Error)]
12pub enum WalletKitError {
13    /// Invalid input provided (e.g., incorrect length, format, etc.)
14    #[error("invalid_input_{attribute}")]
15    InvalidInput {
16        /// The attribute that is invalid
17        attribute: String,
18        /// The reason the input is invalid
19        reason: String,
20    },
21
22    /// The presented data is not a valid U256 integer
23    #[error("invalid_number")]
24    InvalidNumber,
25
26    /// Unexpected error serializing information
27    #[error("serialization_error")]
28    SerializationError {
29        /// The error message from the serialization
30        error: String,
31    },
32
33    /// Network connection error with details
34    #[error("network_error at {url}: {error}")]
35    NetworkError {
36        /// The URL of the request
37        url: String,
38        /// The error message from the request
39        error: String,
40        /// The HTTP status code of the request, if available
41        status: Option<u16>,
42    },
43
44    /// HTTP request failure
45    #[error("request_error")]
46    Reqwest {
47        /// The error message from the request
48        error: String,
49    },
50
51    /// Unhandled error generating a Zero-Knowledge Proof
52    #[error("proof_generation_error: {error}")]
53    ProofGeneration {
54        /// The error message from the proof generation
55        error: String,
56    },
57
58    /// The `semaphore` feature flag is not enabled
59    #[error("semaphore_not_enabled")]
60    SemaphoreNotEnabled,
61
62    /// The requested credential is not issued for this World ID
63    #[error("credential_not_issued")]
64    CredentialNotIssued,
65
66    /// The requested credential has not been submitted on-chain
67    #[error("credential_not_mined")]
68    CredentialNotMined,
69
70    /// This operation requires a registered account and an account is not registered
71    /// for this authenticator. Call `create_account` first to register it.
72    #[error("Account is not registered for this authenticator.")]
73    AccountDoesNotExist,
74
75    /// The public key was not found in the batch, i.e. the authenticator is not authorized to sign for this action
76    #[error("unauthorized_authenticator")]
77    UnauthorizedAuthenticator,
78
79    /// An unexpected error occurred with the Authenticator
80    #[error("unexpected_authenticator_error: {error}")]
81    AuthenticatorError {
82        /// The error message from the authenticator
83        error: String,
84    },
85
86    /// The request could not be fulfilled with the credentials the user has available
87    #[error("unfulfillable_request")]
88    UnfulfillableRequest,
89
90    /// The response generated didn't match the request
91    ///
92    /// This occurs if the response doesn't match the requested proofs - e.g. by ids
93    /// or doesn't satisfy the contraints declared in the request
94    #[error("invalid response: {0}")]
95    ResponseValidation(String),
96
97    /// The generated nullifier has already been used in a proof submission and cannot be used again
98    #[error("nullifier_replay")]
99    NullifierReplay,
100
101    /// The RP's signature on the proof request could not be verified.
102    #[error("invalid_rp_signature")]
103    InvalidRpSignature,
104
105    /// The RP reused a signature nonce.
106    #[error("duplicate_nonce")]
107    DuplicateNonce,
108
109    /// The RP is unknown to the World ID registry.
110    #[error("unknown_rp")]
111    UnknownRp,
112
113    /// The RP is inactive and cannot request proofs.
114    #[error("inactive_rp")]
115    InactiveRp,
116
117    /// The RP's request timestamp is too old.
118    #[error("timestamp_too_old")]
119    TimestampTooOld,
120
121    /// The RP's request timestamp is too far in the future.
122    #[error("timestamp_too_far_in_future")]
123    TimestampTooFarInFuture,
124
125    /// The RP's request timestamp could not be parsed.
126    #[error("invalid_timestamp")]
127    InvalidTimestamp,
128
129    /// The RP's signature has expired.
130    #[error("rp_signature_expired")]
131    RpSignatureExpired,
132
133    /// Cached Groth16 material could not be parsed or verified.
134    #[error("groth16_material_cache_invalid")]
135    Groth16MaterialCacheInvalid {
136        /// Input path(s) used for loading.
137        path: String,
138        /// Underlying error message.
139        error: String,
140    },
141
142    /// Failed to load embedded Groth16 material.
143    #[error("groth16_material_embedded_load")]
144    Groth16MaterialEmbeddedLoad {
145        /// Underlying error message.
146        error: String,
147    },
148
149    /// An unexpected error occurred
150    #[error("unexpected_error: {error}")]
151    Generic {
152        /// The details of the error
153        error: String,
154    },
155
156    /// The recovery binding does not exist
157    #[error("recovery_binding_does_not_exist")]
158    RecoveryBindingDoesNotExist,
159
160    /// The session ID computed for this proof does not match the expected session ID from the proof request.
161    ///
162    /// This indicates the `session_id` provided by the RP is invalid or compromised, as
163    /// the only other failure option is OPRFs not having performed correct computations.
164    #[error("the expected session id and the generated session id do not match")]
165    SessionIdMismatch,
166
167    /// The NFC uniqueness service rejected the request with a permanent error
168    /// that will not resolve on retry (e.g. expired document).
169    #[error("nfc_non_retryable: {error_code}")]
170    NfcNonRetryable {
171        /// The error code from the NFC service (e.g. `document_expired`)
172        error_code: String,
173    },
174
175    /// The debug report was not found
176    #[error("debug_report_not_found")]
177    DebugReportNotFound,
178
179    /// The backend has no identity for the credential's `sub`
180    #[error("identity_not_found")]
181    IdentityNotFound,
182
183    /// The identity has no successful capture to check recovery eligibility against
184    #[error("no_successful_capture_found")]
185    NoSuccessfulCaptureFound,
186
187    /// The user is not eligible for recovery
188    #[error("not_eligible_for_recovery")]
189    NotEligibleForRecovery,
190    /// An error occurred in the OHTTP privacy layer (relay, encapsulation, or framing).
191    #[error("ohttp_error: {error}")]
192    OhttpError {
193        /// The error message from the OHTTP layer
194        error: String,
195    },
196
197    /// The session proof action or session OPRF seed is not valid for the session OPRF module.
198    #[error("invalid_action_for_session")]
199    InvalidActionSession,
200}
201
202#[uniffi::export]
203impl WalletKitError {
204    /// Returns the error message with potential secrets redacted.
205    #[must_use]
206    pub fn sanitized_message(&self) -> String {
207        crate::logger::sanitize_hex_secrets(self.to_string())
208    }
209}
210
211impl From<reqwest::Error> for WalletKitError {
212    fn from(error: reqwest::Error) -> Self {
213        Self::Reqwest {
214            error: error.to_string(),
215        }
216    }
217}
218
219impl From<PrimitiveError> for WalletKitError {
220    fn from(error: PrimitiveError) -> Self {
221        match error {
222            PrimitiveError::InvalidInput { attribute, reason } => {
223                Self::InvalidInput { attribute, reason }
224            }
225            PrimitiveError::Serialization(error) => Self::SerializationError { error },
226            PrimitiveError::Deserialization(reason) => Self::InvalidInput {
227                attribute: "deserialization".to_string(),
228                reason,
229            },
230            PrimitiveError::NotInField => Self::InvalidInput {
231                attribute: "field_element".to_string(),
232                reason: "Provided value is not in the field".to_string(),
233            },
234            PrimitiveError::OutOfBounds => Self::InvalidInput {
235                attribute: "index".to_string(),
236                reason: "Provided index is out of bounds".to_string(),
237            },
238            PrimitiveError::SessionIdCommitmentMismatch => Self::SessionIdMismatch,
239        }
240    }
241}
242
243impl From<WorldIdRequestAuthError> for WalletKitError {
244    fn from(error: WorldIdRequestAuthError) -> Self {
245        match error {
246            WorldIdRequestAuthError::InvalidRpSignature => Self::InvalidRpSignature,
247            WorldIdRequestAuthError::DuplicateNonce => Self::DuplicateNonce,
248            WorldIdRequestAuthError::UnknownRp => Self::UnknownRp,
249            WorldIdRequestAuthError::InactiveRp => Self::InactiveRp,
250            WorldIdRequestAuthError::CreatedAtTooOld => Self::TimestampTooOld,
251            WorldIdRequestAuthError::CreatedAtTooFarInFuture => {
252                Self::TimestampTooFarInFuture
253            }
254            WorldIdRequestAuthError::InvalidTimestamp => Self::InvalidTimestamp,
255            WorldIdRequestAuthError::RpSignatureExpired => Self::RpSignatureExpired,
256            WorldIdRequestAuthError::InvalidActionSession => Self::InvalidActionSession,
257            _ => Self::ProofGeneration {
258                error: error.to_string(),
259            },
260        }
261    }
262}
263
264impl From<ProofError> for WalletKitError {
265    fn from(error: ProofError) -> Self {
266        match error {
267            ProofError::RequestAuthError(error) => Self::from(error),
268            _ => Self::ProofGeneration {
269                error: error.to_string(),
270            },
271        }
272    }
273}
274
275#[cfg(feature = "semaphore")]
276impl From<semaphore_rs::protocol::ProofError> for WalletKitError {
277    fn from(error: semaphore_rs::protocol::ProofError) -> Self {
278        Self::ProofGeneration {
279            error: error.to_string(),
280        }
281    }
282}
283
284impl From<StorageError> for WalletKitError {
285    fn from(error: StorageError) -> Self {
286        Self::Generic {
287            error: error.to_string(),
288        }
289    }
290}
291
292impl From<AuthenticatorError> for WalletKitError {
293    fn from(error: AuthenticatorError) -> Self {
294        match error {
295            AuthenticatorError::AccountDoesNotExist => Self::AccountDoesNotExist,
296
297            AuthenticatorError::NetworkError(error) => Self::NetworkError {
298                url: error
299                    .url()
300                    .map(std::string::ToString::to_string)
301                    .unwrap_or_default(),
302                error: error.to_string(),
303                status: None,
304            },
305            AuthenticatorError::PublicKeyNotFound => Self::UnauthorizedAuthenticator,
306            AuthenticatorError::GatewayError { status, body } => Self::NetworkError {
307                url: "gateway".to_string(),
308                error: body,
309                status: Some(status.as_u16()),
310            },
311            AuthenticatorError::PrimitiveError(error) => Self::from(error),
312
313            AuthenticatorError::ProofError(error) => Self::from(error),
314
315            AuthenticatorError::IndexerError { status, body } => Self::NetworkError {
316                url: "indexer".to_string(),
317                error: body,
318                status: Some(status.as_u16()),
319            },
320            AuthenticatorError::UnfullfilableRequest => Self::UnfulfillableRequest,
321            AuthenticatorError::ResponseValidationError(err) => {
322                Self::ResponseValidation(err.to_string())
323            }
324            AuthenticatorError::OhttpEncapsulationError(_)
325            | AuthenticatorError::BhttpError(_)
326            | AuthenticatorError::OhttpRelayError { .. }
327            | AuthenticatorError::InvalidServiceResponse(_) => Self::OhttpError {
328                error: error.to_string(),
329            },
330
331            _ => Self::AuthenticatorError {
332                error: error.to_string(),
333            },
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn walletkit_error_from_request_auth_error(
343        error: WorldIdRequestAuthError,
344    ) -> WalletKitError {
345        AuthenticatorError::ProofError(ProofError::RequestAuthError(error)).into()
346    }
347
348    fn promoted_error_code(error: &WalletKitError) -> Option<&'static str> {
349        match error {
350            WalletKitError::InvalidRpSignature => Some("invalid_rp_signature"),
351            WalletKitError::DuplicateNonce => Some("duplicate_nonce"),
352            WalletKitError::UnknownRp => Some("unknown_rp"),
353            WalletKitError::InactiveRp => Some("inactive_rp"),
354            WalletKitError::TimestampTooOld => Some("timestamp_too_old"),
355            WalletKitError::TimestampTooFarInFuture => {
356                Some("timestamp_too_far_in_future")
357            }
358            WalletKitError::InvalidTimestamp => Some("invalid_timestamp"),
359            WalletKitError::RpSignatureExpired => Some("rp_signature_expired"),
360            WalletKitError::InvalidActionSession => Some("invalid_action_for_session"),
361            _ => None,
362        }
363    }
364
365    #[test]
366    fn maps_rp_request_auth_errors_to_public_walletkit_errors() {
367        let cases = [
368            (
369                WorldIdRequestAuthError::InvalidRpSignature,
370                "invalid_rp_signature",
371            ),
372            (WorldIdRequestAuthError::DuplicateNonce, "duplicate_nonce"),
373            (WorldIdRequestAuthError::UnknownRp, "unknown_rp"),
374            (WorldIdRequestAuthError::InactiveRp, "inactive_rp"),
375            (
376                WorldIdRequestAuthError::CreatedAtTooOld,
377                "timestamp_too_old",
378            ),
379            (
380                WorldIdRequestAuthError::CreatedAtTooFarInFuture,
381                "timestamp_too_far_in_future",
382            ),
383            (
384                WorldIdRequestAuthError::InvalidTimestamp,
385                "invalid_timestamp",
386            ),
387            (
388                WorldIdRequestAuthError::RpSignatureExpired,
389                "rp_signature_expired",
390            ),
391            (
392                WorldIdRequestAuthError::InvalidActionSession,
393                "invalid_action_for_session",
394            ),
395        ];
396
397        for (request_auth_error, expected_code) in cases {
398            let error = walletkit_error_from_request_auth_error(request_auth_error);
399
400            assert_eq!(promoted_error_code(&error), Some(expected_code));
401            assert_eq!(error.to_string(), expected_code);
402        }
403    }
404
405    #[test]
406    fn keeps_non_promoted_request_auth_errors_as_proof_generation_errors() {
407        let error = walletkit_error_from_request_auth_error(
408            WorldIdRequestAuthError::InvalidMerkleRoot,
409        );
410
411        match error {
412            WalletKitError::ProofGeneration { error } => {
413                assert_eq!(error, "invalid_merkle_root");
414            }
415            other => panic!("expected proof generation error, got {other:?}"),
416        }
417    }
418
419    #[test]
420    fn sanitized_message_redacts_hex_secrets() {
421        let secret = "a".repeat(64);
422        let error = WalletKitError::Generic {
423            error: format!("keystore error: 0x{secret}"),
424        };
425
426        let message = error.sanitized_message();
427
428        assert!(!message.contains(&secret), "secret must not survive");
429        assert!(message.contains("unexpected_error"));
430        assert!(message.contains("0xaa..aa"));
431    }
432}