Skip to main content

walletkit_core/authenticator/
mod.rs

1//! The Authenticator is the main component with which users interact with the World ID Protocol.
2
3use crate::{
4    defaults, error::WalletKitError, primitives::ParseFromForeignBinding, Environment,
5    FieldElement, Region,
6};
7use alloy_core::primitives::Address;
8use ruint::aliases::U256;
9use ruint_uniffi::Uint256;
10use std::sync::Arc;
11use world_id_core::{
12    api_types::{GatewayErrorCode, GatewayRequestState},
13    primitives::{AuthenticatorPublicKeySet, Config},
14    Authenticator as CoreAuthenticator, Credential as CoreCredential, CredentialInput,
15    InitializingAuthenticator as CoreInitializingAuthenticator,
16    OnchainKeyRepresentable, Signer,
17};
18
19use crate::requests::{ProofRequest, ProofResponse};
20use crate::storage::CredentialStore;
21#[cfg(not(target_arch = "wasm32"))]
22use crate::storage::StoragePaths;
23use crate::OwnershipProof;
24
25mod with_storage;
26
27/// ZK Proof material for both Groth16 proofs (query & nullifier proofs)
28#[derive(Clone, uniffi::Object)]
29pub struct Groth16Materials {
30    query: Arc<world_id_core::proof::CircomGroth16Material>,
31    nullifier: Arc<world_id_core::proof::CircomGroth16Material>,
32}
33
34impl std::fmt::Debug for Groth16Materials {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("Groth16Materials").finish_non_exhaustive()
37    }
38}
39
40/// Constructors that require embedded zkeys compiled into the binary.
41///
42/// Enable the `embed-zkeys` Cargo feature to activate these.
43#[cfg(feature = "embed-zkeys")]
44#[uniffi::export]
45impl Groth16Materials {
46    /// Loads Groth16 material from the embedded (compiled-in) zkeys and graphs.
47    ///
48    /// Requires the `embed-zkeys` feature. The material is baked into the binary at
49    /// compile time so no filesystem access is required, and this works on every
50    /// platform including WASM.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if the embedded material cannot be loaded or verified.
55    #[uniffi::constructor]
56    pub fn from_embedded() -> Result<Self, WalletKitError> {
57        let query =
58            world_id_core::proof::load_embedded_query_material().map_err(|error| {
59                WalletKitError::Groth16MaterialEmbeddedLoad {
60                    error: error.to_string(),
61                }
62            })?;
63        let nullifier = world_id_core::proof::load_embedded_nullifier_material()
64            .map_err(|error| WalletKitError::Groth16MaterialEmbeddedLoad {
65                error: error.to_string(),
66            })?;
67        Ok(Self {
68            query: Arc::new(query),
69            nullifier: Arc::new(nullifier),
70        })
71    }
72}
73
74/// Constructors that load Groth16 material from the native filesystem.
75///
76/// Not available on WASM targets (no filesystem access).
77#[cfg(not(target_arch = "wasm32"))]
78#[uniffi::export]
79impl Groth16Materials {
80    /// Loads Groth16 material from cached files on disk.
81    ///
82    /// Use `storage::cache_embedded_groth16_material` (requires the `embed-zkeys` feature)
83    /// to populate the cache before calling this.
84    ///
85    /// Not available on WASM (no filesystem).
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if the cached files cannot be read or verified.
90    #[uniffi::constructor]
91    // `Arc<StoragePaths>` must be taken by value: UniFFI constructors receive
92    // object arguments as owned `Arc`s across the FFI boundary, so passing by
93    // reference is not an option here.
94    #[expect(
95        clippy::needless_pass_by_value,
96        reason = "UniFFI constructors require owned Arc arguments"
97    )]
98    pub fn from_cache(paths: Arc<StoragePaths>) -> Result<Self, WalletKitError> {
99        let query_zkey = paths.query_zkey_path();
100        let nullifier_zkey = paths.nullifier_zkey_path();
101        let query_graph = paths.query_graph_path();
102        let nullifier_graph = paths.nullifier_graph_path();
103
104        let query = world_id_core::proof::load_query_material_from_paths(
105            &query_zkey,
106            &query_graph,
107        )
108        .map_err(|error| WalletKitError::Groth16MaterialCacheInvalid {
109            path: format!(
110                "{} and {}",
111                query_zkey.to_string_lossy(),
112                query_graph.to_string_lossy()
113            ),
114            error: error.to_string(),
115        })?;
116
117        let nullifier = world_id_core::proof::load_nullifier_material_from_paths(
118            &nullifier_zkey,
119            &nullifier_graph,
120        )
121        .map_err(|error| WalletKitError::Groth16MaterialCacheInvalid {
122            path: format!(
123                "{} and {}",
124                nullifier_zkey.to_string_lossy(),
125                nullifier_graph.to_string_lossy()
126            ),
127            error: error.to_string(),
128        })?;
129
130        Ok(Self {
131            query: Arc::new(query),
132            nullifier: Arc::new(nullifier),
133        })
134    }
135}
136
137/// The Authenticator is the main component with which users interact with the World ID Protocol.
138#[derive(Debug, uniffi::Object)]
139pub struct Authenticator {
140    inner: CoreAuthenticator,
141    store: Arc<CredentialStore>,
142}
143
144impl Authenticator {
145    /// Initializes a new Authenticator from a seed and an already-parsed
146    /// [`Config`].
147    ///
148    /// # Errors
149    /// See `CoreAuthenticator::init` for potential errors.
150    pub async fn init_with_config(
151        seed: &[u8],
152        config: Config,
153        materials: Arc<Groth16Materials>,
154        store: Arc<CredentialStore>,
155    ) -> Result<Self, WalletKitError> {
156        let authenticator = CoreAuthenticator::init(seed, config)
157            .await?
158            .with_proof_materials(
159                Arc::clone(&materials.query),
160                Arc::clone(&materials.nullifier),
161            );
162        Ok(Self {
163            inner: authenticator,
164            store,
165        })
166    }
167}
168
169#[uniffi::export(async_runtime = "tokio")]
170impl Authenticator {
171    /// Returns the packed account data for the holder's World ID.
172    ///
173    /// The packed account data is a 256 bit integer which includes the user's leaf index, their recovery counter,
174    /// and their pubkey id/commitment.
175    #[must_use]
176    pub fn packed_account_data(&self) -> Uint256 {
177        self.inner.packed_account_data.into()
178    }
179
180    /// Returns the leaf index for the holder's World ID.
181    ///
182    /// This is the index in the Merkle tree where the holder's World ID account is registered. It
183    /// should only be used inside the authenticator and never shared.
184    #[must_use]
185    pub fn leaf_index(&self) -> u64 {
186        self.inner.leaf_index()
187    }
188
189    /// Returns the Authenticator's `onchain_address`.
190    ///
191    /// See `world_id_core::Authenticator::onchain_address` for more details.
192    #[must_use]
193    pub fn onchain_address(&self) -> String {
194        self.inner.onchain_address().to_string()
195    }
196
197    /// Returns the packed account data for the holder's World ID fetching it from the on-chain registry.
198    ///
199    /// # Errors
200    /// Will error if the provided RPC URL is not valid or if there are RPC call failures.
201    #[tracing::instrument(
202        target = "walletkit_latency",
203        name = "rpc_account_data",
204        skip_all
205    )]
206    pub async fn get_packed_account_data_remote(
207        &self,
208    ) -> Result<Uint256, WalletKitError> {
209        let packed_account_data = self.inner.fetch_packed_account_data().await?;
210        Ok(packed_account_data.into())
211    }
212
213    /// Generates a blinding factor for a Credential sub (through OPRF Nodes).
214    ///
215    /// See [`CoreAuthenticator::generate_credential_blinding_factor`] for more details.
216    ///
217    /// # Errors
218    ///
219    /// - Will generally error if there are network issues or if the OPRF Nodes return an error.
220    /// - Raises an error if the OPRF Nodes configuration is not correctly set.
221    #[tracing::instrument(
222        target = "walletkit_latency",
223        name = "oprf_blinding_factor",
224        skip_all
225    )]
226    pub async fn generate_credential_blinding_factor_remote(
227        &self,
228        issuer_schema_id: u64,
229    ) -> Result<FieldElement, WalletKitError> {
230        Ok(self
231            .inner
232            .generate_credential_blinding_factor(issuer_schema_id)
233            .await
234            .map(Into::into)?)
235    }
236
237    /// Compute the `sub` for a credential from the authenticator's leaf index and a `blinding_factor`.
238    #[must_use]
239    pub fn compute_credential_sub(
240        &self,
241        blinding_factor: &FieldElement,
242    ) -> FieldElement {
243        CoreCredential::compute_sub(self.inner.leaf_index(), blinding_factor.0).into()
244    }
245
246    /// Signs an arbitrary challenge with the authenticator's on-chain key.
247    ///
248    /// # Warning
249    /// This is considered a dangerous operation because it leaks the user's on-chain key,
250    /// hence its `leaf_index`. The only acceptable use is to prove the user's `leaf_index`
251    /// to a Recovery Agent. The Recovery Agent is the only party beyond the user who needs
252    /// to know the `leaf_index`.
253    ///
254    /// # Errors
255    /// May error if very unexpectedly the signing process fails. Not expected.
256    pub fn danger_sign_challenge(
257        &self,
258        challenge: &[u8],
259    ) -> Result<Vec<u8>, WalletKitError> {
260        let signature = self.inner.danger_sign_challenge(challenge)?;
261        Ok(signature.as_bytes().to_vec())
262    }
263
264    /// Signs the EIP-712 `InitiateRecoveryAgentUpdate` payload and returns the
265    /// raw signature bytes and signing nonce without submitting anything to the
266    /// gateway.
267    ///
268    /// This is the signing-only counterpart of [`Self::initiate_recovery_agent_update`].
269    /// Callers can use the returned bytes to build and submit the gateway request
270    /// themselves.
271    ///
272    /// # Warning
273    /// This method uses the `onchain_signer` (secp256k1 ECDSA) and produces a
274    /// recoverable signature. Any holder of the signature together with the
275    /// EIP-712 parameters can call `ecrecover` to obtain the `onchain_address`,
276    /// which can then be looked up in the registry to derive the user's
277    /// `leaf_index`. Only expose the output to trusted parties (e.g. a Recovery
278    /// Agent).
279    ///
280    /// # Arguments
281    /// * `new_recovery_agent` — the checksummed hex address of the new recovery
282    ///   agent (e.g. `"0x1234…"`).
283    ///
284    /// # Errors
285    /// - Returns [`WalletKitError::InvalidInput`] if `new_recovery_agent` is not
286    ///   a valid address.
287    /// - Returns an error if the nonce fetch or signing step fails.
288    pub async fn danger_sign_initiate_recovery_agent_update(
289        &self,
290        new_recovery_agent: String,
291    ) -> Result<RecoveryUpdateSignature, WalletKitError> {
292        let new_recovery_agent =
293            Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;
294        let (sig, nonce) = self
295            .inner
296            .danger_sign_initiate_recovery_agent_update(new_recovery_agent)
297            .await?;
298        Ok(RecoveryUpdateSignature {
299            signature: sig.as_bytes().to_vec(),
300            nonce: nonce.into(),
301        })
302    }
303
304    /// Initiates a time-locked recovery agent update (14-day cooldown).
305    ///
306    /// Signs an EIP-712 `InitiateRecoveryAgentUpdate` payload and submits it to
307    /// the gateway. Returns the gateway request ID that can be used to poll
308    /// status.
309    ///
310    /// # Arguments
311    /// * `new_recovery_agent` — the checksummed hex address of the new recovery
312    ///   agent (e.g. `"0x1234…"`).
313    ///
314    /// # Errors
315    /// - Returns [`WalletKitError::InvalidInput`] if `new_recovery_agent` is not
316    ///   a valid address.
317    /// - Returns a network error if the gateway request fails.
318    #[allow(deprecated)]
319    pub async fn initiate_recovery_agent_update(
320        &self,
321        new_recovery_agent: String,
322    ) -> Result<String, WalletKitError> {
323        let new_recovery_agent =
324            Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;
325
326        let request_id = self
327            .inner
328            .initiate_recovery_agent_update(new_recovery_agent)
329            .await?;
330
331        Ok(request_id.to_string())
332    }
333
334    /// Executes a pending recovery agent update after the 14-day cooldown has
335    /// elapsed.
336    ///
337    /// This call is **permissionless** — no signature is required. The contract
338    /// enforces the cooldown and will revert with
339    /// `RecoveryAgentUpdateStillInCooldown` if called too early.
340    ///
341    /// Returns the gateway request ID that can be used to poll status.
342    ///
343    /// # Errors
344    /// Returns a network error if the gateway request fails.
345    #[allow(deprecated)]
346    pub async fn execute_recovery_agent_update(
347        &self,
348    ) -> Result<String, WalletKitError> {
349        let request_id = self.inner.execute_recovery_agent_update().await?;
350
351        Ok(request_id.to_string())
352    }
353
354    /// Cancels a pending time-locked recovery agent update before the cooldown
355    /// expires.
356    ///
357    /// Signs an EIP-712 `CancelRecoveryAgentUpdate` payload and submits it to
358    /// the gateway. Returns the gateway request ID that can be used to poll
359    /// status.
360    ///
361    /// # Errors
362    /// Returns a network error if the gateway request fails.
363    #[allow(deprecated)]
364    pub async fn cancel_recovery_agent_update(&self) -> Result<String, WalletKitError> {
365        let request_id = self.inner.cancel_recovery_agent_update().await?;
366
367        Ok(request_id.to_string())
368    }
369}
370
371#[uniffi::export(async_runtime = "tokio")]
372impl Authenticator {
373    /// Initializes a new Authenticator from a seed and with SDK defaults.
374    ///
375    /// The user's World ID must already be registered in the `WorldIDRegistry`,
376    /// otherwise a [`WalletKitError::AccountDoesNotExist`] error will be returned.
377    ///
378    /// # Errors
379    /// See `CoreAuthenticator::init` for potential errors.
380    #[uniffi::constructor]
381    #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
382    pub async fn init_with_defaults(
383        seed: &[u8],
384        rpc_url: Option<String>,
385        environment: &Environment,
386        region: Option<Region>,
387        materials: Arc<Groth16Materials>,
388        store: Arc<CredentialStore>,
389    ) -> Result<Self, WalletKitError> {
390        let config = defaults::default_config(environment, rpc_url, region)?;
391        Self::init_with_config(seed, config, materials, store).await
392    }
393
394    /// Initializes a new Authenticator from a seed using SDK defaults routed
395    /// through the OHTTP relay. Opt-in alternative to
396    /// [`Authenticator::init_with_defaults`].
397    ///
398    /// The user's World ID must already be registered in the `WorldIDRegistry`,
399    /// otherwise a [`WalletKitError::AccountDoesNotExist`] error will be returned.
400    ///
401    /// # Errors
402    /// See `CoreAuthenticator::init` for potential errors.
403    #[uniffi::constructor]
404    #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
405    pub async fn init_with_ohttp_defaults(
406        seed: &[u8],
407        rpc_url: Option<String>,
408        environment: &Environment,
409        region: Option<Region>,
410        materials: Arc<Groth16Materials>,
411        store: Arc<CredentialStore>,
412    ) -> Result<Self, WalletKitError> {
413        let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
414        Self::init_with_config(seed, config, materials, store).await
415    }
416
417    /// Initializes a new Authenticator from a seed and config.
418    ///
419    /// The user's World ID must already be registered in the `WorldIDRegistry`,
420    /// otherwise a [`WalletKitError::AccountDoesNotExist`] error will be returned.
421    ///
422    /// # Errors
423    /// Will error if the provided seed is not valid or if the config is not valid.
424    #[uniffi::constructor]
425    #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
426    pub async fn init(
427        seed: &[u8],
428        config: &str,
429        materials: Arc<Groth16Materials>,
430        store: Arc<CredentialStore>,
431    ) -> Result<Self, WalletKitError> {
432        let config =
433            Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
434                attribute: "config".to_string(),
435                reason: "Invalid config".to_string(),
436            })?;
437        Self::init_with_config(seed, config, materials, store).await
438    }
439
440    /// Generates a proof for the given proof request.
441    ///
442    /// # Errors
443    /// Returns an error if proof generation fails.
444    pub async fn generate_proof(
445        &self,
446        proof_request: &ProofRequest,
447        now: Option<u64>,
448    ) -> Result<ProofResponse, WalletKitError> {
449        let now = if let Some(n) = now {
450            n
451        } else {
452            #[cfg(target_arch = "wasm32")]
453            {
454                return Err(WalletKitError::InvalidInput {
455                    attribute: "now".to_string(),
456                    reason: "`now` must be provided on wasm32 targets".to_string(),
457                });
458            }
459
460            #[cfg(not(target_arch = "wasm32"))]
461            {
462                let start = std::time::SystemTime::now();
463                start
464                    .duration_since(std::time::UNIX_EPOCH)
465                    .map_err(|e| WalletKitError::Generic {
466                        error: format!("Critical. Unable to determine SystemTime: {e}"),
467                    })?
468                    .as_secs()
469            }
470        };
471
472        // Build CredentialInput list from storage
473        // Note: We simply load all non-expired credentials. Filtering for the requested schema IDs is done in `generate_proof`.
474        // We could avoid unnecessary loading by filtering via `world_id_primitives::ProofRequest::credentials_to_prove`. We consider this an
475        // unnecessary optimization for now.
476        let credentials: Vec<_> = self
477            .store
478            .list_credentials(None, now)?
479            .iter()
480            .filter(|c| !c.is_expired)
481            .filter_map(|cred| {
482                if let Ok(Some((credential, blinding_factor))) =
483                    self.store.get_credential(cred.issuer_schema_id, now)
484                {
485                    Some(CredentialInput {
486                        credential: credential.into(),
487                        blinding_factor: blinding_factor.into(),
488                    })
489                } else {
490                    tracing::warn!(
491                        issuer_schema_id = %cred.issuer_schema_id,
492                        credential_id = %cred.credential_id,
493                        "credential listed but not loadable, skipping"
494                    );
495                    None
496                }
497            })
498            .collect();
499
500        let account_inclusion_proof =
501            self.fetch_inclusion_proof_with_cache(now).await?;
502
503        // Generate the nullifier and check the replay guard
504        // Box::pin to heap-allocate the large upstream futures and keep this future below clippy::large_futures threshold
505        let nullifier = Box::pin(self.inner.generate_nullifier(
506            &proof_request.0,
507            Some(account_inclusion_proof.clone()),
508        ))
509        .await?;
510
511        if self
512            .store
513            .is_nullifier_replay(nullifier.verifiable_oprf_output.output.into(), now)?
514        {
515            return Err(WalletKitError::NullifierReplay);
516        }
517
518        // Get cached `session_id_r_seed` if session ID is provided in the proof request
519        let session_id_r_seed =
520            proof_request
521                .0
522                .session_id
523                .and_then(|session_id| {
524                    match self.store.get_session_seed(session_id.oprf_seed, now) {
525                        Ok(seed) => seed,
526                        Err(err) => {
527                            tracing::warn!(error = %err, "failed to load cached session seed, continuing without");
528                            None
529                        }
530                    }
531                });
532
533        // Handles credential selection, session resolution, per-credential proofs, response assembly, and validation
534        let result = Box::pin(self.inner.generate_proof(
535            &proof_request.0,
536            nullifier.clone(),
537            &credentials,
538            Some(account_inclusion_proof),
539            session_id_r_seed,
540        ))
541        .await?;
542
543        // Cache session seed if returned. Create-session requests do not carry a
544        // session_id, so use the session_id generated in the proof response.
545        if let Some(seed) = result.session_id_r_seed {
546            if let Some(session_id) = result.proof_response.session_id {
547                if let Err(err) =
548                    self.store
549                        .store_session_seed(session_id.oprf_seed, seed, now)
550                {
551                    tracing::error!("error caching session_id_r_seed: {}", err);
552                }
553            }
554        }
555
556        self.store
557            .replay_guard_set(nullifier.verifiable_oprf_output.output.into(), now)?;
558
559        Ok(result.proof_response.into())
560    }
561
562    /// Generates a WIP-103 Ownership Proof for Issuers.
563    ///
564    /// An Ownership Proof lets the user prove they own the credential `sub`
565    /// associated with a stored credential without revealing their `leaf_index`.
566    ///
567    /// # Security-critical usage constraint
568    /// This method **MUST only** be called as part of a direct
569    /// **user-initiated** action in the client. Callers **MUST NOT** expose this
570    /// method to issuer-triggered, backend-triggered, or unauthenticated request
571    /// flows.
572    ///
573    /// # Arguments
574    /// * `nonce` - A field element provided by the Issuer to prevent replay.
575    /// * `blinding_factor` - The credential blinding factor previously used to
576    ///   derive the credential `sub`.
577    /// * `sub` - The credential `sub` (commitment) to prove ownership of.
578    ///
579    /// # Errors
580    /// - Returns [`WalletKitError::InvalidInput`] if `blinding_factor` and
581    ///   `sub` are inconsistent with each other (i.e. `sub` was not derived
582    ///   from this authenticator's leaf index and the provided blinding factor).
583    /// - Returns a network error if the Merkle inclusion proof cannot be
584    ///   fetched from the indexer.
585    /// - Returns [`WalletKitError::ProofGeneration`] if the ZK proof fails.
586    pub async fn prove_credential_sub(
587        &self,
588        nonce: &FieldElement,
589        blinding_factor: &FieldElement,
590        sub: &FieldElement,
591    ) -> Result<OwnershipProof, WalletKitError> {
592        #[cfg(target_arch = "wasm32")]
593        {
594            let _ = (nonce, blinding_factor, sub);
595            return Err(WalletKitError::Generic {
596                error: "credential ownership proofs are not supported on wasm32"
597                    .to_string(),
598            });
599        }
600
601        #[cfg(not(target_arch = "wasm32"))]
602        {
603            let now = std::time::SystemTime::now()
604                .duration_since(std::time::UNIX_EPOCH)
605                .map_err(|e| WalletKitError::Generic {
606                    error: format!("Critical. Unable to determine SystemTime: {e}"),
607                })?
608                .as_secs();
609
610            let inclusion_proof = self.fetch_inclusion_proof_with_cache(now).await?;
611            let proof = self
612                .inner
613                .prove_credential_sub(
614                    nonce.0,
615                    blinding_factor.0,
616                    sub.0,
617                    Some(inclusion_proof),
618                )
619                .await?;
620
621            Ok(OwnershipProof(proof))
622        }
623    }
624}
625
626/// Registration status for a World ID being created through the gateway.
627#[derive(Debug, Clone, uniffi::Enum)]
628pub enum RegistrationStatus {
629    /// Request queued but not yet batched.
630    Queued,
631    /// Request currently being batched.
632    Batching,
633    /// Request submitted on-chain.
634    Submitted,
635    /// Request finalized on-chain. The World ID is now registered.
636    Finalized,
637    /// Request failed during processing.
638    Failed {
639        /// Error message returned by the gateway.
640        error: String,
641        /// Specific error code, if available.
642        error_code: Option<String>,
643    },
644}
645
646impl From<GatewayRequestState> for RegistrationStatus {
647    fn from(state: GatewayRequestState) -> Self {
648        match state {
649            GatewayRequestState::Queued => Self::Queued,
650            GatewayRequestState::Batching => Self::Batching,
651            GatewayRequestState::Submitted { .. } => Self::Submitted,
652            GatewayRequestState::Finalized { .. } => Self::Finalized,
653            GatewayRequestState::Failed { error, error_code } => Self::Failed {
654                error,
655                error_code: error_code.map(|c: GatewayErrorCode| c.to_string()),
656            },
657        }
658    }
659}
660
661/// Represents an Authenticator in the process of being initialized.
662///
663/// The account is not yet registered in the `WorldIDRegistry` contract.
664/// Use this for non-blocking registration flows where you want to poll the status yourself.
665#[derive(uniffi::Object)]
666pub struct InitializingAuthenticator(CoreInitializingAuthenticator);
667
668#[uniffi::export(async_runtime = "tokio")]
669impl InitializingAuthenticator {
670    /// Registers a new World ID with SDK defaults.
671    ///
672    /// This returns immediately and does not wait for registration to complete.
673    /// The returned `InitializingAuthenticator` can be used to poll the registration status.
674    ///
675    /// # Errors
676    /// See `CoreAuthenticator::register` for potential errors.
677    #[uniffi::constructor]
678    #[tracing::instrument(
679        target = "walletkit_latency",
680        name = "gateway_register",
681        skip_all
682    )]
683    pub async fn register_with_defaults(
684        seed: &[u8],
685        rpc_url: Option<String>,
686        environment: &Environment,
687        region: Option<Region>,
688        recovery_address: Option<String>,
689    ) -> Result<Self, WalletKitError> {
690        let recovery_address =
691            Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
692
693        let config = defaults::default_config(environment, rpc_url, region)?;
694
695        let initializing_authenticator =
696            CoreAuthenticator::register(seed, config, recovery_address).await?;
697
698        Ok(Self(initializing_authenticator))
699    }
700
701    /// Registers a new World ID using SDK defaults routed through the OHTTP
702    /// relay. Opt-in alternative to
703    /// [`InitializingAuthenticator::register_with_defaults`].
704    ///
705    /// This returns immediately and does not wait for registration to complete.
706    /// The returned `InitializingAuthenticator` can be used to poll the registration status.
707    ///
708    /// # Errors
709    /// See `CoreAuthenticator::register` for potential errors.
710    #[uniffi::constructor]
711    #[tracing::instrument(
712        target = "walletkit_latency",
713        name = "gateway_register",
714        skip_all
715    )]
716    pub async fn register_with_ohttp_defaults(
717        seed: &[u8],
718        rpc_url: Option<String>,
719        environment: &Environment,
720        region: Option<Region>,
721        recovery_address: Option<String>,
722    ) -> Result<Self, WalletKitError> {
723        let recovery_address =
724            Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
725
726        let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
727
728        let initializing_authenticator =
729            CoreAuthenticator::register(seed, config, recovery_address).await?;
730
731        Ok(Self(initializing_authenticator))
732    }
733
734    /// Registers a new World ID.
735    ///
736    /// This returns immediately and does not wait for registration to complete.
737    /// The returned `InitializingAuthenticator` can be used to poll the registration status.
738    ///
739    /// # Errors
740    /// See `CoreAuthenticator::register` for potential errors.
741    #[uniffi::constructor]
742    #[tracing::instrument(
743        target = "walletkit_latency",
744        name = "gateway_register",
745        skip_all
746    )]
747    pub async fn register(
748        seed: &[u8],
749        config: &str,
750        recovery_address: Option<String>,
751    ) -> Result<Self, WalletKitError> {
752        let recovery_address =
753            Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
754
755        let config =
756            Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
757                attribute: "config".to_string(),
758                reason: "Invalid config".to_string(),
759            })?;
760
761        let initializing_authenticator =
762            CoreAuthenticator::register(seed, config, recovery_address).await?;
763
764        Ok(Self(initializing_authenticator))
765    }
766
767    /// Polls the registration status from the gateway.
768    ///
769    /// # Errors
770    /// Will error if the network request fails or the gateway returns an error.
771    #[tracing::instrument(
772        target = "walletkit_latency",
773        name = "gateway_poll",
774        skip_all
775    )]
776    pub async fn poll_status(&self) -> Result<RegistrationStatus, WalletKitError> {
777        let status = self.0.poll_status().await?;
778        Ok(status.into())
779    }
780}
781
782/// The signature and signing nonce returned by
783/// [`Authenticator::danger_sign_initiate_recovery_agent_update`].
784///
785/// `UniFFI` does not support returning bare tuples across the FFI boundary, so
786/// the two values are bundled in this record type.
787#[derive(Debug, Clone, uniffi::Record)]
788pub struct RecoveryUpdateSignature {
789    /// Raw bytes of the secp256k1 ECDSA signature over the EIP-712
790    /// `InitiateRecoveryAgentUpdate` payload.
791    pub signature: Vec<u8>,
792    /// The EIP-712 signing nonce that was used; must be included in the
793    /// gateway request alongside the signature.
794    pub nonce: Uint256,
795}
796
797/// Identity material derived from a seed for use during account recovery.
798///
799/// During account recovery the user generates new keys from a seed, but those
800/// keys do not yet exist on-chain. The three values in this record must be
801/// submitted on-chain during the recovery transaction.
802///
803/// All fields are hex-encoded strings suitable for direct use in API requests.
804#[derive(Debug, Clone, uniffi::Record)]
805pub struct RecoveryData {
806    /// Checksummed hex Ethereum address of the on-chain signer.
807    pub authenticator_address: String,
808    /// Hex-encoded U256 compressed `EdDSA` public key of the off-chain signer.
809    pub authenticator_pubkey: String,
810    /// Hex-encoded U256 Poseidon2 hash commitment over the authenticator key set.
811    pub offchain_signer_commitment: String,
812}
813
814impl RecoveryData {
815    /// Derives recovery identity material from a 32-byte seed.
816    ///
817    /// These values must be submitted on-chain as part of the recovery
818    /// transaction before the recovered account can be initialised with
819    /// [`Authenticator::init`] / [`Authenticator::init_with_defaults`].
820    ///
821    /// # Errors
822    /// Returns [`WalletKitError`] if the seed is invalid or serialization fails.
823    pub fn from_seed(seed: &[u8]) -> Result<Self, WalletKitError> {
824        let signer = Signer::from_seed_bytes(seed)?;
825        let authenticator_address = signer.onchain_signer_address().to_checksum(None);
826        let authenticator_pubkey: U256 = signer
827            .offchain_signer_pubkey()
828            .to_ethereum_representation()?;
829        let mut key_set = AuthenticatorPublicKeySet::default();
830        key_set.try_push(signer.offchain_signer_pubkey())?;
831        let offchain_signer_commitment: U256 = key_set.leaf_hash().into();
832
833        Ok(Self {
834            authenticator_address,
835            authenticator_pubkey: format!("{authenticator_pubkey:#066x}"),
836            offchain_signer_commitment: format!("{offchain_signer_commitment:#066x}"),
837        })
838    }
839}
840
841/// Derives recovery data from a 32-byte seed.
842///
843/// This is the foreign-bindings entrypoint for recovery data generation.
844///
845/// # Errors
846/// Returns [`WalletKitError`] if the seed is invalid or serialization fails.
847#[uniffi::export]
848pub fn recovery_data_from_seed(seed: &[u8]) -> Result<RecoveryData, WalletKitError> {
849    RecoveryData::from_seed(seed)
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    #[test]
857    fn test_recovery_data_from_seed() {
858        let seed = [1u8; 32];
859        let material = RecoveryData::from_seed(&seed).expect("should derive material");
860
861        assert!(material.authenticator_address.starts_with("0x"));
862        assert_eq!(material.authenticator_address.len(), 42);
863        assert!(material.authenticator_pubkey.starts_with("0x"));
864        assert!(material.authenticator_pubkey.len() <= 66);
865        assert!(material.offchain_signer_commitment.starts_with("0x"));
866        assert!(material.offchain_signer_commitment.len() <= 66);
867        assert!(material.authenticator_address.len() > 2);
868        assert!(material.authenticator_pubkey.len() > 2);
869        assert!(material.offchain_signer_commitment.len() > 2);
870    }
871
872    #[test]
873    fn test_recovery_data_rejects_invalid_seed() {
874        assert!(RecoveryData::from_seed(&[0u8; 16]).is_err());
875        assert!(RecoveryData::from_seed(&[]).is_err());
876    }
877
878    #[cfg(feature = "embed-zkeys")]
879    #[tokio::test]
880    async fn test_init_with_config_and_materials() {
881        use crate::storage::tests_utils::{
882            cleanup_test_storage, temp_root_path, InMemoryStorageProvider,
883        };
884        use alloy::primitives::address;
885        use world_id_core::primitives::{Config, ServiceEndpoint};
886
887        let _ = rustls::crypto::ring::default_provider().install_default();
888
889        let mut mock_server = mockito::Server::new_async().await;
890        mock_server
891            .mock("POST", "/")
892            .with_status(200)
893            .with_header("content-type", "application/json")
894            .with_body(
895                serde_json::json!({
896                    "jsonrpc": "2.0",
897                    "id": 1,
898                    "result": "0x0000000000000000000000000000000000000000000000000000000000000001"
899                })
900                .to_string(),
901            )
902            .create_async()
903            .await;
904
905        let config = Config::new(
906            Some(mock_server.url()),
907            480,
908            address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
909            ServiceEndpoint::direct(
910                "https://indexer.us.id-infra.worldcoin.dev".to_string(),
911            ),
912            ServiceEndpoint::direct(
913                "https://gateway.id-infra.worldcoin.dev".to_string(),
914            ),
915            vec![],
916            2,
917        )
918        .unwrap();
919        let config = serde_json::to_string(&config).unwrap();
920
921        let root = temp_root_path();
922        let provider = InMemoryStorageProvider::new(&root);
923        let store = CredentialStore::from_provider(&provider).expect("store");
924        store.init(42, 100).expect("init storage");
925
926        let materials =
927            Arc::new(Groth16Materials::from_embedded().expect("load materials"));
928        let _authenticator =
929            Authenticator::init(&[2u8; 32], &config, materials, Arc::new(store))
930                .await
931                .unwrap();
932        drop(mock_server);
933
934        cleanup_test_storage(&root);
935    }
936}