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