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, GatewayRequestId, GatewayRequestState},
14    primitives::{AuthenticatorPublicKeySet, Config, MAX_AUTHENTICATOR_KEYS},
15    Authenticator as CoreAuthenticator, AuthenticatorError,
16    Credential as CoreCredential, CredentialInput, EdDSAPublicKey,
17    InitializingAuthenticator as CoreInitializingAuthenticator,
18    OnchainKeyRepresentable, Signer,
19};
20
21use crate::requests::{ProofRequest, ProofResponse};
22use crate::storage::CredentialStore;
23use crate::OwnershipProof;
24
25pub mod artifacts;
26mod with_storage;
27
28/// The Authenticator is the main component with which users interact with the World ID Protocol.
29#[derive(Debug, uniffi::Object)]
30pub struct Authenticator {
31    inner: CoreAuthenticator,
32    store: Arc<CredentialStore>,
33}
34
35impl Authenticator {
36    /// Initializes a new Authenticator from a seed and an already-parsed
37    /// [`Config`].
38    ///
39    /// # Errors
40    /// See `CoreAuthenticator::init` for potential errors.
41    pub async fn init_with_config(
42        seed: &[u8],
43        config: Config,
44        artifacts: Arc<dyn WalletKitZkArtifactSource>,
45        store: Arc<CredentialStore>,
46    ) -> Result<Self, WalletKitError> {
47        let authenticator = CoreAuthenticator::init(seed, config, artifacts).await?;
48
49        Ok(Self {
50            inner: authenticator,
51            store,
52        })
53    }
54}
55
56fn parse_authenticator_pubkey(
57    attribute: &str,
58    encoded_pubkey: impl AsRef<str>,
59) -> Result<EdDSAPublicKey, WalletKitError> {
60    let encoded_pubkey = encoded_pubkey.as_ref();
61    let invalid_input = |reason: String| WalletKitError::InvalidInput {
62        attribute: attribute.to_string(),
63        reason,
64    };
65    let hex = encoded_pubkey.strip_prefix("0x").ok_or_else(|| {
66        invalid_input("Public key must start with a 0x prefix".to_string())
67    })?;
68
69    if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
70        return Err(invalid_input(
71            "Public key must be exactly 32 bytes (64 hex characters) after the 0x prefix"
72                .to_string(),
73        ));
74    }
75
76    let encoded = U256::from_str_radix(hex, 16)
77        .map_err(|error| invalid_input(error.to_string()))?;
78    let pubkey = EdDSAPublicKey::from_compressed_bytes(encoded.to_le_bytes())
79        .map_err(|error| invalid_input(error.to_string()))?;
80
81    // `from_compressed_bytes` accepts the curve's neutral element and a
82    // sign-bit alias of it. Empty key-set slots hash as the neutral element
83    // on-chain (a slot holding it is commitment-indistinguishable from an
84    // empty slot, and it is unusable for verification), so reject it and any
85    // encoding that does not round-trip to the canonical form.
86    let canonical = pubkey
87        .to_ethereum_representation()
88        .map_err(|error| invalid_input(error.to_string()))?;
89    if canonical != encoded {
90        return Err(invalid_input(
91            "Public key is not the canonical compressed point encoding".to_string(),
92        ));
93    }
94    if canonical == U256::from(1u64) {
95        return Err(invalid_input(
96            "Public key must not be the BabyJubJub identity point".to_string(),
97        ));
98    }
99
100    Ok(pubkey)
101}
102
103#[uniffi::export(async_runtime = "tokio")]
104impl Authenticator {
105    /// Returns the packed account data for the holder's World ID.
106    ///
107    /// The packed account data is a 256 bit integer which includes the user's leaf index, their recovery counter,
108    /// and their pubkey id/commitment.
109    #[must_use]
110    pub fn packed_account_data(&self) -> Uint256 {
111        self.inner.packed_account_data.into()
112    }
113
114    /// Returns the leaf index for the holder's World ID.
115    ///
116    /// This is the index in the Merkle tree where the holder's World ID account is registered. It
117    /// should only be used inside the authenticator and never shared.
118    #[must_use]
119    pub fn leaf_index(&self) -> u64 {
120        self.inner.leaf_index()
121    }
122
123    /// Returns the Authenticator's `onchain_address`.
124    ///
125    /// See `world_id_core::Authenticator::onchain_address` for more details.
126    #[must_use]
127    pub fn onchain_address(&self) -> String {
128        self.inner.onchain_address().to_string()
129    }
130
131    /// Returns the packed account data for the holder's World ID fetching it from the on-chain registry.
132    ///
133    /// # Errors
134    /// Will error if the provided RPC URL is not valid or if there are RPC call failures.
135    #[tracing::instrument(
136        target = "walletkit_latency",
137        name = "rpc_account_data",
138        skip_all
139    )]
140    pub async fn get_packed_account_data_remote(
141        &self,
142    ) -> Result<Uint256, WalletKitError> {
143        let packed_account_data = self.inner.fetch_packed_account_data().await?;
144        Ok(packed_account_data.into())
145    }
146
147    /// Generates a blinding factor for a Credential sub (through OPRF Nodes).
148    ///
149    /// See [`CoreAuthenticator::generate_credential_blinding_factor`] for more details.
150    ///
151    /// # Errors
152    ///
153    /// - Will generally error if there are network issues or if the OPRF Nodes return an error.
154    /// - Raises an error if the OPRF Nodes configuration is not correctly set.
155    #[tracing::instrument(
156        target = "walletkit_latency",
157        name = "oprf_blinding_factor",
158        skip_all
159    )]
160    pub async fn generate_credential_blinding_factor_remote(
161        &self,
162        issuer_schema_id: u64,
163    ) -> Result<FieldElement, WalletKitError> {
164        Ok(self
165            .inner
166            .generate_credential_blinding_factor(issuer_schema_id)
167            .await
168            .map(Into::into)?)
169    }
170
171    /// Compute the `sub` for a credential from the authenticator's leaf index and a `blinding_factor`.
172    #[must_use]
173    pub fn compute_credential_sub(
174        &self,
175        blinding_factor: &FieldElement,
176    ) -> FieldElement {
177        CoreCredential::compute_sub(self.inner.leaf_index(), blinding_factor.0).into()
178    }
179
180    /// Signs an arbitrary challenge with the authenticator's on-chain key.
181    ///
182    /// # Warning
183    /// This is considered a dangerous operation because it leaks the user's on-chain key,
184    /// hence its `leaf_index`. The only acceptable use is to prove the user's `leaf_index`
185    /// to a Recovery Agent. The Recovery Agent is the only party beyond the user who needs
186    /// to know the `leaf_index`.
187    ///
188    /// # Errors
189    /// May error if very unexpectedly the signing process fails. Not expected.
190    #[allow(
191        clippy::needless_pass_by_value,
192        reason = "seed is passed by value so uniffi 0.32 maps it to a `RustBuffer` (Kotlin `ByteArray` / Swift `Data`) rather than the non-`Send` `ForeignBytes` view produced for `&[u8]`"
193    )]
194    pub fn danger_sign_challenge(
195        &self,
196        challenge: Vec<u8>,
197    ) -> Result<Vec<u8>, WalletKitError> {
198        let signature = self.inner.danger_sign_challenge(&challenge)?;
199        Ok(signature.as_bytes().to_vec())
200    }
201
202    /// Signs the EIP-712 `InitiateRecoveryAgentUpdate` payload and returns the
203    /// raw signature bytes and signing nonce without submitting anything to the
204    /// gateway.
205    ///
206    /// Callers can use the returned bytes to build and submit the gateway
207    /// request themselves.
208    ///
209    /// # Warning
210    /// This method uses the `onchain_signer` (secp256k1 ECDSA) and produces a
211    /// recoverable signature. Any holder of the signature together with the
212    /// EIP-712 parameters can call `ecrecover` to obtain the `onchain_address`,
213    /// which can then be looked up in the registry to derive the user's
214    /// `leaf_index`. Only expose the output to trusted parties (e.g. a Recovery
215    /// Agent).
216    ///
217    /// # Arguments
218    /// * `new_recovery_agent` — the checksummed hex address of the new recovery
219    ///   agent (e.g. `"0x1234…"`).
220    ///
221    /// # Errors
222    /// - Returns [`WalletKitError::InvalidInput`] if `new_recovery_agent` is not
223    ///   a valid address.
224    /// - Returns an error if the nonce fetch or signing step fails.
225    pub async fn danger_sign_initiate_recovery_agent_update(
226        &self,
227        new_recovery_agent: String,
228    ) -> Result<RecoveryUpdateSignature, WalletKitError> {
229        let new_recovery_agent =
230            Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;
231        let (sig, nonce) = self
232            .inner
233            .danger_sign_initiate_recovery_agent_update(new_recovery_agent)
234            .await?;
235        Ok(RecoveryUpdateSignature {
236            signature: sig.as_bytes().to_vec(),
237            nonce: nonce.into(),
238        })
239    }
240
241    /// Updates the holder's recovery agent (WIP-102).
242    ///
243    /// On a V2 registry the new agent becomes effective immediately, but for a
244    /// revert window any authenticator can call
245    /// [`Self::revert_recovery_agent_update`] to roll back. During that window
246    /// the *previous* agent remains the only valid signer for `recoverAccount`,
247    /// which mitigates a compromised authenticator silently swapping in an
248    /// attacker-controlled recovery address.
249    ///
250    /// # Arguments
251    /// * `new_recovery_agent` — the checksummed hex address of the new recovery
252    ///   agent (e.g. `"0x1234…"`).
253    ///
254    /// # Errors
255    /// - Returns [`WalletKitError::InvalidInput`] if `new_recovery_agent` is not
256    ///   a valid address.
257    /// - Returns a network error if the gateway request fails.
258    pub async fn update_recovery_agent(
259        &self,
260        new_recovery_agent: String,
261    ) -> Result<String, WalletKitError> {
262        let new_recovery_agent =
263            Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;
264
265        let request_id = self.inner.update_recovery_agent(new_recovery_agent).await?;
266
267        Ok(request_id.to_string())
268    }
269
270    /// Reverts an in-flight recovery agent update during the revert window
271    /// (WIP-102).
272    ///
273    /// Must be called within the revert window after
274    /// [`Self::update_recovery_agent`]. During that window any authenticator
275    /// can revert the update; the previous recovery agent stays effective
276    /// until the window expires.
277    ///
278    /// Signs an EIP-712 `CancelRecoveryAgentUpdate` payload (the typehash is
279    /// reused on V2) and submits it to the gateway.
280    ///
281    /// # Errors
282    /// Returns a network error if the gateway request fails.
283    pub async fn revert_recovery_agent_update(&self) -> Result<String, WalletKitError> {
284        let request_id = self.inner.revert_recovery_agent_update().await?;
285
286        Ok(request_id.to_string())
287    }
288
289    /// Inserts an authenticator into the holder's World ID account.
290    ///
291    /// # Arguments
292    /// * `new_authenticator_pubkey` — a compressed `BabyJubJub` public key encoded
293    ///   as a `0x`-prefixed, zero-padded 32-byte hex string.
294    /// * `new_authenticator_address` — the Ethereum address associated with the
295    ///   new authenticator. Callers may pass the zero address for a proving-only
296    ///   authenticator.
297    ///
298    /// # Errors
299    /// - Returns [`WalletKitError::InvalidInput`] if the public key or address is
300    ///   invalid.
301    /// - Returns a network error if an indexer or gateway request fails.
302    #[tracing::instrument(
303        target = "walletkit_latency",
304        name = "gateway_insert_authenticator",
305        skip_all
306    )]
307    pub async fn insert_authenticator(
308        &self,
309        new_authenticator_pubkey: String,
310        new_authenticator_address: String,
311    ) -> Result<String, WalletKitError> {
312        let new_authenticator_pubkey = parse_authenticator_pubkey(
313            "new_authenticator_pubkey",
314            new_authenticator_pubkey,
315        )?;
316        let new_authenticator_address = Address::parse_from_ffi(
317            &new_authenticator_address,
318            "new_authenticator_address",
319        )?;
320
321        let request_id = self
322            .inner
323            .insert_authenticator(new_authenticator_pubkey, new_authenticator_address)
324            .await?;
325
326        Ok(request_id.to_string())
327    }
328
329    /// Returns whether the holder's account already contains an authenticator
330    /// public key.
331    ///
332    /// This performs a read-only indexer fetch and does not submit an account
333    /// operation.
334    ///
335    /// # Arguments
336    /// * `authenticator_pubkey` — a compressed `BabyJubJub` public key encoded
337    ///   as a `0x`-prefixed, zero-padded 32-byte hex string.
338    ///
339    /// # Errors
340    /// - Returns [`WalletKitError::InvalidInput`] if the public key is invalid.
341    /// - Returns a network error if the indexer request fails.
342    #[tracing::instrument(
343        target = "walletkit_latency",
344        name = "indexer_authenticator_pubkeys",
345        skip_all
346    )]
347    pub async fn has_authenticator_pubkey(
348        &self,
349        authenticator_pubkey: String,
350    ) -> Result<bool, WalletKitError> {
351        let authenticator_pubkey =
352            parse_authenticator_pubkey("authenticator_pubkey", authenticator_pubkey)?;
353        let pubkeys = self.inner.fetch_authenticator_pubkeys().await?;
354        Ok(pubkeys
355            .iter()
356            .flatten()
357            .any(|existing_pubkey| existing_pubkey == &authenticator_pubkey))
358    }
359
360    /// Returns the account's authenticator public keys, indexed by key-set slot.
361    ///
362    /// Each entry is the compressed `BabyJubJub` public key at that slot encoded
363    /// as a `0x`-prefixed, zero-padded 32-byte hex string, or `None` for an
364    /// empty slot. A key's position in this list is the `pubkey_id` expected by
365    /// [`Self::remove_authenticator`].
366    ///
367    /// This performs a read-only indexer fetch and does not submit an account
368    /// operation.
369    ///
370    /// # Errors
371    /// - Returns a network error if the indexer request fails.
372    /// - Returns an error if a stored public key cannot be encoded.
373    #[tracing::instrument(
374        target = "walletkit_latency",
375        name = "indexer_authenticator_pubkeys",
376        skip_all
377    )]
378    pub async fn get_authenticator_pubkeys(
379        &self,
380    ) -> Result<Vec<Option<String>>, WalletKitError> {
381        let key_set = self.inner.fetch_authenticator_pubkeys().await?;
382        key_set
383            .iter()
384            .map(|slot| {
385                slot.as_ref()
386                    .map(|pubkey| {
387                        let encoded = pubkey.to_ethereum_representation()?;
388                        Ok(format!("{encoded:#066x}"))
389                    })
390                    .transpose()
391            })
392            .collect()
393    }
394
395    /// Removes an authenticator from the holder's World ID account.
396    ///
397    /// # Arguments
398    /// * `authenticator_address` — the Ethereum address associated with the
399    ///   authenticator being removed. Callers must pass the zero address for a
400    ///   proving-only authenticator.
401    /// * `pubkey_id` — the stable key-set slot of the authenticator being removed.
402    /// * `expected_authenticator_pubkey` — the compressed `BabyJubJub` public key
403    ///   the caller intends to remove, encoded as a `0x`-prefixed, zero-padded
404    ///   32-byte hex string. The removal is refused if `pubkey_id` currently
405    ///   holds a different key, catching callers acting on a stale key-set view
406    ///   (see [`Self::get_authenticator_pubkeys`]). This check is best-effort:
407    ///   the signing flow re-reads the key set afterwards, so a concurrent
408    ///   change to the slot between the check and that read can still remove
409    ///   whichever key the slot holds at signing time. Callers that need an
410    ///   exact-target guarantee must serialize account operations across the
411    ///   account's authenticators.
412    ///
413    /// # Errors
414    /// - Returns [`WalletKitError::InvalidInput`] if the address or public key
415    ///   is invalid, if `pubkey_id` is out of range, if the slot is empty, or
416    ///   if the slot holds a different key.
417    /// - Returns a network error if an indexer or gateway request fails.
418    #[tracing::instrument(
419        target = "walletkit_latency",
420        name = "gateway_remove_authenticator",
421        skip_all
422    )]
423    pub async fn remove_authenticator(
424        &self,
425        authenticator_address: String,
426        pubkey_id: u32,
427        expected_authenticator_pubkey: String,
428    ) -> Result<String, WalletKitError> {
429        let expected_pubkey = parse_authenticator_pubkey(
430            "expected_authenticator_pubkey",
431            expected_authenticator_pubkey,
432        )?;
433        let authenticator_address =
434            Address::parse_from_ffi(&authenticator_address, "authenticator_address")?;
435
436        if pubkey_id as usize >= MAX_AUTHENTICATOR_KEYS {
437            return Err(WalletKitError::InvalidInput {
438                attribute: "pubkey_id".to_string(),
439                reason: format!(
440                    "pubkey_id {pubkey_id} is out of range; the key set has at \
441                     most {MAX_AUTHENTICATOR_KEYS} slots"
442                ),
443            });
444        }
445
446        let empty_slot = || WalletKitError::InvalidInput {
447            attribute: "pubkey_id".to_string(),
448            reason: format!("no authenticator at key set slot {pubkey_id}"),
449        };
450        let key_set = self.inner.fetch_authenticator_pubkeys().await?;
451        let actual_pubkey = key_set.get(pubkey_id as usize).ok_or_else(empty_slot)?;
452        if actual_pubkey != &expected_pubkey {
453            return Err(WalletKitError::InvalidInput {
454                attribute: "expected_authenticator_pubkey".to_string(),
455                reason: format!(
456                    "key set slot {pubkey_id} holds a different authenticator public key"
457                ),
458            });
459        }
460
461        let request_id = self
462            .inner
463            .remove_authenticator(authenticator_address, pubkey_id)
464            .await
465            .map_err(|error| match error {
466                // The slot emptied between the check above and the crate's own
467                // signing read; report it as the input problem it is rather
468                // than an authorization failure.
469                AuthenticatorError::PublicKeyNotFound => empty_slot(),
470                other => other.into(),
471            })?;
472
473        Ok(request_id.to_string())
474    }
475
476    /// Polls the gateway once for the status of an account operation.
477    ///
478    /// # Errors
479    /// Returns a network error if the gateway request fails.
480    #[tracing::instrument(
481        target = "walletkit_latency",
482        name = "gateway_poll",
483        skip_all
484    )]
485    pub async fn poll_status(
486        &self,
487        request_id: String,
488    ) -> Result<GatewayRequestStatus, WalletKitError> {
489        let request_id = GatewayRequestId::new(
490            request_id.strip_prefix("gw_").unwrap_or(&request_id),
491        );
492        let status = self.inner.poll_status(&request_id).await?;
493        Ok(status.into())
494    }
495}
496
497#[uniffi::export(async_runtime = "tokio")]
498impl Authenticator {
499    /// Initializes a new Authenticator from a seed and with SDK defaults.
500    ///
501    /// The user's World ID must already be registered in the `WorldIDRegistry`,
502    /// otherwise a [`WalletKitError::AccountDoesNotExist`] error will be returned.
503    ///
504    /// # Errors
505    /// See `CoreAuthenticator::init` for potential errors.
506    #[uniffi::constructor]
507    #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
508    pub async fn init_with_defaults(
509        seed: Vec<u8>,
510        rpc_url: Option<String>,
511        environment: &Environment,
512        region: Option<Region>,
513        artifacts: Arc<dyn WalletKitZkArtifactSource>,
514        store: Arc<CredentialStore>,
515    ) -> Result<Self, WalletKitError> {
516        let config = defaults::default_config(environment, rpc_url, region)?;
517        Self::init_with_config(&seed, config, artifacts, store).await
518    }
519
520    /// Initializes a new Authenticator from a seed using SDK defaults routed
521    /// through the OHTTP relay. Opt-in alternative to
522    /// [`Authenticator::init_with_defaults`].
523    ///
524    /// The user's World ID must already be registered in the `WorldIDRegistry`,
525    /// otherwise a [`WalletKitError::AccountDoesNotExist`] error will be returned.
526    ///
527    /// # Errors
528    /// See `CoreAuthenticator::init` for potential errors.
529    #[uniffi::constructor]
530    #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
531    pub async fn init_with_ohttp_defaults(
532        seed: Vec<u8>,
533        rpc_url: Option<String>,
534        environment: &Environment,
535        region: Option<Region>,
536        artifacts: Arc<dyn WalletKitZkArtifactSource>,
537        store: Arc<CredentialStore>,
538    ) -> Result<Self, WalletKitError> {
539        let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
540        Self::init_with_config(&seed, config, artifacts, store).await
541    }
542
543    /// Initializes a new Authenticator from a seed and config.
544    ///
545    /// The user's World ID must already be registered in the `WorldIDRegistry`,
546    /// otherwise a [`WalletKitError::AccountDoesNotExist`] error will be returned.
547    ///
548    /// # Errors
549    /// Will error if the provided seed is not valid or if the config is not valid.
550    #[uniffi::constructor]
551    #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
552    pub async fn init(
553        seed: Vec<u8>,
554        config: &str,
555        artifacts: Arc<dyn WalletKitZkArtifactSource>,
556        store: Arc<CredentialStore>,
557    ) -> Result<Self, WalletKitError> {
558        let config =
559            Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
560                attribute: "config".to_string(),
561                reason: "Invalid config".to_string(),
562            })?;
563        Self::init_with_config(&seed, config, artifacts, store).await
564    }
565
566    /// Generates a proof for the given proof request.
567    ///
568    /// # Errors
569    /// Returns an error if proof generation fails.
570    pub async fn generate_proof(
571        &self,
572        proof_request: &ProofRequest,
573        now: Option<u64>,
574    ) -> Result<ProofResponse, WalletKitError> {
575        let now = if let Some(n) = now {
576            n
577        } else {
578            #[cfg(target_arch = "wasm32")]
579            {
580                return Err(WalletKitError::InvalidInput {
581                    attribute: "now".to_string(),
582                    reason: "`now` must be provided on wasm32 targets".to_string(),
583                });
584            }
585
586            #[cfg(not(target_arch = "wasm32"))]
587            {
588                let start = std::time::SystemTime::now();
589                start
590                    .duration_since(std::time::UNIX_EPOCH)
591                    .map_err(|e| WalletKitError::Generic {
592                        error: format!("Critical. Unable to determine SystemTime: {e}"),
593                    })?
594                    .as_secs()
595            }
596        };
597
598        // Build CredentialInput list from storage
599        // Note: We simply load all non-expired credentials. Filtering for the requested schema IDs is done in `generate_proof`.
600        // We could avoid unnecessary loading by filtering via `world_id_primitives::ProofRequest::credentials_to_prove`. We consider this an
601        // unnecessary optimization for now.
602        let credentials: Vec<_> = self
603            .store
604            .list_credentials(None, now)?
605            .iter()
606            .filter(|c| !c.is_expired)
607            .filter_map(|cred| {
608                if let Ok(Some((credential, blinding_factor))) =
609                    self.store.get_credential(cred.issuer_schema_id, now)
610                {
611                    Some(CredentialInput {
612                        credential: credential.into(),
613                        blinding_factor: blinding_factor.into(),
614                    })
615                } else {
616                    tracing::warn!(
617                        issuer_schema_id = %cred.issuer_schema_id,
618                        credential_id = %cred.credential_id,
619                        "credential listed but not loadable, skipping"
620                    );
621                    None
622                }
623            })
624            .collect();
625
626        let account_inclusion_proof =
627            self.fetch_inclusion_proof_with_cache(now).await?;
628
629        // Generate the nullifier and check the replay guard
630        // Box::pin to heap-allocate the large upstream futures and keep this future below clippy::large_futures threshold
631        let nullifier = Box::pin(self.inner.generate_nullifier(
632            &proof_request.0,
633            now,
634            Some(account_inclusion_proof.clone()),
635        ))
636        .await?;
637
638        if self
639            .store
640            .is_nullifier_replay(nullifier.verifiable_oprf_output.output.into(), now)?
641        {
642            return Err(WalletKitError::NullifierReplay);
643        }
644
645        // Get cached `session_id_r_seed` if session ID is provided in the proof request
646        let rp_id = proof_request.0.rp_id.into_inner();
647        let session_id_r_seed =
648            proof_request
649                .0
650                .session_id
651                .existing()
652                .and_then(|session_id| match self.store.get_session_seed(
653                    rp_id,
654                    session_id.oprf_seed,
655                    now,
656                ) {
657                    Ok(seed) => seed,
658                    Err(err) => {
659                        tracing::warn!(error = %err, "failed to load cached session seed, continuing without");
660                        None
661                    }
662                });
663
664        // Handles credential selection, session resolution, per-credential proofs, response assembly, and validation
665        let result = Box::pin(self.inner.generate_proof(
666            &proof_request.0,
667            nullifier.clone(),
668            &credentials,
669            Some(account_inclusion_proof),
670            session_id_r_seed,
671        ))
672        .await?;
673
674        // Cache session seed if returned. Create-session requests do not carry a
675        // session_id, so use the session_id generated in the proof response.
676        if let Some(seed) = result.session_id_r_seed {
677            if let Some(session_id) = result.proof_response.session_id {
678                if let Err(err) = self.store.store_session_seed(
679                    rp_id,
680                    session_id.oprf_seed,
681                    seed,
682                    now,
683                ) {
684                    tracing::error!("error caching session_id_r_seed: {}", err);
685                }
686            }
687        }
688
689        self.store
690            .replay_guard_set(nullifier.verifiable_oprf_output.output.into(), now)?;
691
692        Ok(result.proof_response.into())
693    }
694
695    /// Generates a WIP-103 Ownership Proof for Issuers.
696    ///
697    /// An Ownership Proof lets the user prove they own the credential `sub`
698    /// associated with a stored credential without revealing their `leaf_index`.
699    ///
700    /// # Security-critical usage constraint
701    /// This method **MUST only** be called as part of a direct
702    /// **user-initiated** action in the client. Callers **MUST NOT** expose this
703    /// method to issuer-triggered, backend-triggered, or unauthenticated request
704    /// flows.
705    ///
706    /// # Arguments
707    /// * `nonce` - A field element provided by the Issuer to prevent replay.
708    /// * `context` - A field element identifying the issuer operation being authorized.
709    /// * `blinding_factor` - The credential blinding factor previously used to
710    ///   derive the credential `sub`.
711    /// * `sub` - The credential `sub` (commitment) to prove ownership of.
712    ///
713    /// # Errors
714    /// - Returns [`WalletKitError::InvalidInput`] if `blinding_factor` and
715    ///   `sub` are inconsistent with each other (i.e. `sub` was not derived
716    ///   from this authenticator's leaf index and the provided blinding factor).
717    /// - Returns a network error if the Merkle inclusion proof cannot be
718    ///   fetched from the indexer.
719    /// - Returns [`WalletKitError::ProofGeneration`] if the ZK proof fails.
720    pub async fn prove_credential_sub(
721        &self,
722        nonce: &FieldElement,
723        context: &FieldElement,
724        blinding_factor: &FieldElement,
725        sub: &FieldElement,
726    ) -> Result<OwnershipProof, WalletKitError> {
727        #[cfg(target_arch = "wasm32")]
728        {
729            let _ = (nonce, context, blinding_factor, sub);
730            return Err(WalletKitError::Generic {
731                error: "credential ownership proofs are not supported on wasm32"
732                    .to_string(),
733            });
734        }
735
736        #[cfg(not(target_arch = "wasm32"))]
737        {
738            let now = std::time::SystemTime::now()
739                .duration_since(std::time::UNIX_EPOCH)
740                .map_err(|e| WalletKitError::Generic {
741                    error: format!("Critical. Unable to determine SystemTime: {e}"),
742                })?
743                .as_secs();
744
745            let inclusion_proof = self.fetch_inclusion_proof_with_cache(now).await?;
746            let proof = self
747                .inner
748                .prove_credential_sub(
749                    nonce.0,
750                    context.0,
751                    blinding_factor.0,
752                    sub.0,
753                    Some(inclusion_proof),
754                )
755                .await?;
756
757            Ok(OwnershipProof(proof))
758        }
759    }
760}
761
762/// Registration status for a World ID being created through the gateway.
763#[derive(Debug, Clone, uniffi::Enum)]
764pub enum RegistrationStatus {
765    /// Request queued but not yet batched.
766    Queued,
767    /// Request currently being batched.
768    Batching,
769    /// Request submitted on-chain.
770    Submitted,
771    /// Request finalized on-chain. The World ID is now registered.
772    Finalized,
773    /// Request failed during processing.
774    Failed {
775        /// Error message returned by the gateway.
776        error: String,
777        /// Specific error code, if available.
778        error_code: Option<String>,
779    },
780}
781
782/// Status of an account operation submitted through the gateway.
783#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
784pub enum GatewayRequestStatus {
785    /// Request queued but not yet batched.
786    Queued,
787    /// Request currently being batched.
788    Batching,
789    /// Request submitted on-chain.
790    Submitted {
791        /// Transaction hash emitted when the request was submitted.
792        tx_hash: String,
793    },
794    /// Request finalized on-chain.
795    Finalized {
796        /// Transaction hash emitted when the request was finalized.
797        tx_hash: String,
798    },
799    /// Request failed during processing.
800    Failed {
801        /// Error message returned by the gateway.
802        error: String,
803        /// Specific error code, if available.
804        error_code: Option<String>,
805    },
806}
807
808impl From<GatewayRequestState> for GatewayRequestStatus {
809    fn from(state: GatewayRequestState) -> Self {
810        match state {
811            GatewayRequestState::Queued => Self::Queued,
812            GatewayRequestState::Batching => Self::Batching,
813            GatewayRequestState::Submitted { tx_hash } => Self::Submitted { tx_hash },
814            GatewayRequestState::Finalized { tx_hash } => Self::Finalized { tx_hash },
815            GatewayRequestState::Failed { error, error_code } => Self::Failed {
816                error,
817                error_code: error_code.map(|code| code.to_string()),
818            },
819        }
820    }
821}
822
823impl From<GatewayRequestState> for RegistrationStatus {
824    fn from(state: GatewayRequestState) -> Self {
825        match state {
826            GatewayRequestState::Queued => Self::Queued,
827            GatewayRequestState::Batching => Self::Batching,
828            GatewayRequestState::Submitted { .. } => Self::Submitted,
829            GatewayRequestState::Finalized { .. } => Self::Finalized,
830            GatewayRequestState::Failed { error, error_code } => Self::Failed {
831                error,
832                error_code: error_code.map(|c: GatewayErrorCode| c.to_string()),
833            },
834        }
835    }
836}
837
838/// Represents an Authenticator in the process of being initialized.
839///
840/// The account is not yet registered in the `WorldIDRegistry` contract.
841/// Use this for non-blocking registration flows where you want to poll the status yourself.
842#[derive(uniffi::Object)]
843pub struct InitializingAuthenticator(CoreInitializingAuthenticator);
844
845#[uniffi::export(async_runtime = "tokio")]
846impl InitializingAuthenticator {
847    /// Registers a new World ID with SDK defaults.
848    ///
849    /// This returns immediately and does not wait for registration to complete.
850    /// The returned `InitializingAuthenticator` can be used to poll the registration status.
851    ///
852    /// # Errors
853    /// See `CoreAuthenticator::register` for potential errors.
854    #[uniffi::constructor]
855    #[tracing::instrument(
856        target = "walletkit_latency",
857        name = "gateway_register",
858        skip_all
859    )]
860    pub async fn register_with_defaults(
861        seed: Vec<u8>,
862        rpc_url: Option<String>,
863        environment: &Environment,
864        region: Option<Region>,
865        recovery_address: Option<String>,
866    ) -> Result<Self, WalletKitError> {
867        let recovery_address =
868            Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
869
870        let config = defaults::default_config(environment, rpc_url, region)?;
871
872        let initializing_authenticator =
873            CoreAuthenticator::register(&seed, config, recovery_address).await?;
874
875        Ok(Self(initializing_authenticator))
876    }
877
878    /// Registers a new World ID using SDK defaults routed through the OHTTP
879    /// relay. Opt-in alternative to
880    /// [`InitializingAuthenticator::register_with_defaults`].
881    ///
882    /// This returns immediately and does not wait for registration to complete.
883    /// The returned `InitializingAuthenticator` can be used to poll the registration status.
884    ///
885    /// # Errors
886    /// See `CoreAuthenticator::register` for potential errors.
887    #[uniffi::constructor]
888    #[tracing::instrument(
889        target = "walletkit_latency",
890        name = "gateway_register",
891        skip_all
892    )]
893    pub async fn register_with_ohttp_defaults(
894        seed: Vec<u8>,
895        rpc_url: Option<String>,
896        environment: &Environment,
897        region: Option<Region>,
898        recovery_address: Option<String>,
899    ) -> Result<Self, WalletKitError> {
900        let recovery_address =
901            Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
902
903        let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
904
905        let initializing_authenticator =
906            CoreAuthenticator::register(&seed, config, recovery_address).await?;
907
908        Ok(Self(initializing_authenticator))
909    }
910
911    /// Registers a new World ID.
912    ///
913    /// This returns immediately and does not wait for registration to complete.
914    /// The returned `InitializingAuthenticator` can be used to poll the registration status.
915    ///
916    /// # Errors
917    /// See `CoreAuthenticator::register` for potential errors.
918    #[uniffi::constructor]
919    #[tracing::instrument(
920        target = "walletkit_latency",
921        name = "gateway_register",
922        skip_all
923    )]
924    pub async fn register(
925        seed: Vec<u8>,
926        config: &str,
927        recovery_address: Option<String>,
928    ) -> Result<Self, WalletKitError> {
929        let recovery_address =
930            Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
931
932        let config =
933            Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
934                attribute: "config".to_string(),
935                reason: "Invalid config".to_string(),
936            })?;
937
938        let initializing_authenticator =
939            CoreAuthenticator::register(&seed, config, recovery_address).await?;
940
941        Ok(Self(initializing_authenticator))
942    }
943
944    /// Polls the registration status from the gateway.
945    ///
946    /// # Errors
947    /// Will error if the network request fails or the gateway returns an error.
948    #[tracing::instrument(
949        target = "walletkit_latency",
950        name = "gateway_poll",
951        skip_all
952    )]
953    pub async fn poll_status(&self) -> Result<RegistrationStatus, WalletKitError> {
954        let status = self.0.poll_status().await?;
955        Ok(status.into())
956    }
957}
958
959/// The signature and signing nonce returned by
960/// [`Authenticator::danger_sign_initiate_recovery_agent_update`].
961///
962/// `UniFFI` does not support returning bare tuples across the FFI boundary, so
963/// the two values are bundled in this record type.
964#[derive(Debug, Clone, uniffi::Record)]
965pub struct RecoveryUpdateSignature {
966    /// Raw bytes of the secp256k1 ECDSA signature over the EIP-712
967    /// `InitiateRecoveryAgentUpdate` payload.
968    pub signature: Vec<u8>,
969    /// The EIP-712 signing nonce that was used; must be included in the
970    /// gateway request alongside the signature.
971    pub nonce: Uint256,
972}
973
974/// Identity material derived from a seed for use during account recovery.
975///
976/// During account recovery the user generates new keys from a seed, but those
977/// keys do not yet exist on-chain. The three values in this record must be
978/// submitted on-chain during the recovery transaction.
979///
980/// All fields are hex-encoded strings suitable for direct use in API requests.
981#[derive(Debug, Clone, uniffi::Record)]
982pub struct RecoveryData {
983    /// Checksummed hex Ethereum address of the on-chain signer.
984    pub authenticator_address: String,
985    /// Hex-encoded U256 compressed `EdDSA` public key of the off-chain signer.
986    pub authenticator_pubkey: String,
987    /// Hex-encoded U256 Poseidon2 hash commitment over the authenticator key set.
988    pub offchain_signer_commitment: String,
989}
990
991impl RecoveryData {
992    /// Derives recovery identity material from a 32-byte seed.
993    ///
994    /// These values must be submitted on-chain as part of the recovery
995    /// transaction before the recovered account can be initialised with
996    /// [`Authenticator::init`] / [`Authenticator::init_with_defaults`].
997    ///
998    /// # Errors
999    /// Returns [`WalletKitError`] if the seed is invalid or serialization fails.
1000    pub fn from_seed(seed: &[u8]) -> Result<Self, WalletKitError> {
1001        let signer = Signer::from_seed_bytes(seed)?;
1002        let authenticator_address = signer.onchain_signer_address().to_checksum(None);
1003        let authenticator_pubkey: U256 = signer
1004            .offchain_signer_pubkey()
1005            .to_ethereum_representation()?;
1006        let mut key_set = AuthenticatorPublicKeySet::default();
1007        key_set.try_push(signer.offchain_signer_pubkey())?;
1008        let offchain_signer_commitment: U256 = key_set.leaf_hash().into();
1009
1010        Ok(Self {
1011            authenticator_address,
1012            authenticator_pubkey: format!("{authenticator_pubkey:#066x}"),
1013            offchain_signer_commitment: format!("{offchain_signer_commitment:#066x}"),
1014        })
1015    }
1016}
1017
1018/// Validates an authenticator public key without submitting an account
1019/// operation, returning its canonical encoding.
1020///
1021/// This is a free function (not a method on [`Authenticator`]) so consumers
1022/// can validate a key — e.g. one scanned during pairing — before an
1023/// `Authenticator` exists.
1024///
1025/// The returned string is the canonical form of the key (lowercase,
1026/// `0x`-prefixed, zero-padded 32-byte hex), byte-identical to the entries
1027/// returned by [`Authenticator::get_authenticator_pubkeys`]. Use it — not the
1028/// raw input — for string comparisons against key-set entries.
1029///
1030/// # Arguments
1031/// * `authenticator_pubkey` — a compressed `BabyJubJub` public key encoded
1032///   as a `0x`-prefixed, zero-padded 32-byte hex string.
1033///
1034/// # Errors
1035/// Returns [`WalletKitError::InvalidInput`] if the public key is invalid,
1036/// is not in canonical form, or is the `BabyJubJub` identity point.
1037#[uniffi::export]
1038pub fn validate_authenticator_pubkey(
1039    authenticator_pubkey: &str,
1040) -> Result<String, WalletKitError> {
1041    let pubkey =
1042        parse_authenticator_pubkey("authenticator_pubkey", authenticator_pubkey)?;
1043    let encoded = pubkey.to_ethereum_representation()?;
1044    Ok(format!("{encoded:#066x}"))
1045}
1046
1047/// Derives recovery data from a 32-byte seed.
1048///
1049/// This is the foreign-bindings entrypoint for recovery data generation.
1050///
1051/// # Errors
1052/// Returns [`WalletKitError`] if the seed is invalid or serialization fails.
1053#[uniffi::export]
1054#[allow(
1055    clippy::needless_pass_by_value,
1056    reason = "seed is passed by value so uniffi 0.32 maps it to a `RustBuffer` (Kotlin `ByteArray` / Swift `Data`) rather than the non-`Send` `ForeignBytes` view produced for `&[u8]`"
1057)]
1058pub fn recovery_data_from_seed(seed: Vec<u8>) -> Result<RecoveryData, WalletKitError> {
1059    RecoveryData::from_seed(&seed)
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065
1066    const TEST_SEED: [u8; 32] = [1u8; 32];
1067
1068    async fn test_authenticator(
1069        server: &mut mockito::Server,
1070    ) -> (Authenticator, std::path::PathBuf) {
1071        use crate::storage::tests_utils::{temp_root_path, InMemoryStorageProvider};
1072        use alloy::primitives::address;
1073        use world_id_core::primitives::ServiceEndpoint;
1074        use world_id_proof::artifacts::dummy::DummyZkArtifactSource;
1075
1076        let _ = rustls::crypto::ring::default_provider().install_default();
1077
1078        let packed_account_mock = server
1079            .mock("POST", "/packed-account")
1080            .with_status(200)
1081            .with_header("content-type", "application/json")
1082            .with_body(serde_json::json!({ "packed_account_data": "0x2a" }).to_string())
1083            .create_async()
1084            .await;
1085        let config = Config::new(
1086            None,
1087            480,
1088            address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1089            ServiceEndpoint::direct(server.url()),
1090            ServiceEndpoint::direct(server.url()),
1091            vec![],
1092            2,
1093        )
1094        .expect("valid config");
1095        let root = temp_root_path();
1096        let provider = InMemoryStorageProvider::new(&root);
1097        let store =
1098            CredentialStore::from_provider(&provider).expect("credential store");
1099        let authenticator = Authenticator::init_with_config(
1100            &TEST_SEED,
1101            config,
1102            Arc::new(DummyZkArtifactSource),
1103            Arc::new(store),
1104        )
1105        .await
1106        .expect("authenticator should initialize");
1107        packed_account_mock.assert_async().await;
1108
1109        (authenticator, root)
1110    }
1111
1112    fn encoded_pubkey(seed: &[u8; 32]) -> String {
1113        let pubkey = Signer::from_seed_bytes(seed)
1114            .expect("valid seed")
1115            .offchain_signer_pubkey()
1116            .to_ethereum_representation()
1117            .expect("public key should encode");
1118        format!("{pubkey:#066x}")
1119    }
1120
1121    /// Mocks the indexer's `/authenticator-pubkeys` endpoint with a fixed
1122    /// key-set response (`None` entries are empty slots), asserting the
1123    /// request body and the expected number of hits.
1124    async fn mock_authenticator_pubkeys(
1125        server: &mut mockito::Server,
1126        pubkeys: &[Option<&str>],
1127        expected_hits: usize,
1128    ) -> mockito::Mock {
1129        server
1130            .mock("POST", "/authenticator-pubkeys")
1131            .match_body(mockito::Matcher::JsonString(
1132                serde_json::json!({ "leaf_index": "0x2a" }).to_string(),
1133            ))
1134            .with_status(200)
1135            .with_header("content-type", "application/json")
1136            .with_body(
1137                serde_json::json!({
1138                    "authenticator_pubkeys": pubkeys,
1139                    "offchain_signer_commitment": "0x0"
1140                })
1141                .to_string(),
1142            )
1143            .expect(expected_hits)
1144            .create_async()
1145            .await
1146    }
1147
1148    #[test]
1149    fn test_recovery_data_from_seed() {
1150        let seed = [1u8; 32];
1151        let material = RecoveryData::from_seed(&seed).expect("should derive material");
1152
1153        assert!(material.authenticator_address.starts_with("0x"));
1154        assert_eq!(material.authenticator_address.len(), 42);
1155        assert!(material.authenticator_pubkey.starts_with("0x"));
1156        assert!(material.authenticator_pubkey.len() <= 66);
1157        assert!(material.offchain_signer_commitment.starts_with("0x"));
1158        assert!(material.offchain_signer_commitment.len() <= 66);
1159        assert!(material.authenticator_address.len() > 2);
1160        assert!(material.authenticator_pubkey.len() > 2);
1161        assert!(material.offchain_signer_commitment.len() > 2);
1162    }
1163
1164    #[test]
1165    fn test_recovery_data_rejects_invalid_seed() {
1166        assert!(RecoveryData::from_seed(&[0u8; 16]).is_err());
1167        assert!(RecoveryData::from_seed(&[]).is_err());
1168    }
1169
1170    #[test]
1171    fn test_authenticator_pubkey_validation() {
1172        let canonical = encoded_pubkey(&[2u8; 32]);
1173        assert_eq!(
1174            validate_authenticator_pubkey(&canonical).expect("valid key"),
1175            canonical
1176        );
1177        let uppercase = format!("0x{}", canonical[2..].to_uppercase());
1178        assert_eq!(
1179            validate_authenticator_pubkey(&uppercase)
1180                .expect("uppercase hex should canonicalize"),
1181            canonical
1182        );
1183
1184        for invalid_pubkey in [
1185            "not-a-public-key".to_string(),
1186            format!("0x{}", "ff".repeat(32)),
1187        ] {
1188            assert!(matches!(
1189                validate_authenticator_pubkey(&invalid_pubkey),
1190                Err(WalletKitError::InvalidInput { attribute, .. })
1191                    if attribute == "authenticator_pubkey"
1192            ));
1193        }
1194
1195        let identity = format!("0x{}01", "0".repeat(62));
1196        assert!(matches!(
1197            validate_authenticator_pubkey(&identity),
1198            Err(WalletKitError::InvalidInput { attribute, reason })
1199                if attribute == "authenticator_pubkey" && reason.contains("identity")
1200        ));
1201        let sign_bit_alias = format!("0x80{}01", "0".repeat(60));
1202        assert!(matches!(
1203            validate_authenticator_pubkey(&sign_bit_alias),
1204            Err(WalletKitError::InvalidInput { attribute, reason })
1205                if attribute == "authenticator_pubkey" && reason.contains("canonical")
1206        ));
1207    }
1208
1209    #[tokio::test]
1210    async fn test_poll_status_normalizes_request_id() {
1211        use crate::storage::tests_utils::cleanup_test_storage;
1212
1213        let mut server = mockito::Server::new_async().await;
1214        let (authenticator, root) = test_authenticator(&mut server).await;
1215        let status_mock = server
1216            .mock("GET", "/status/gw_poll_test")
1217            .with_status(200)
1218            .with_header("content-type", "application/json")
1219            .with_body(
1220                serde_json::json!({
1221                    "request_id": "gw_poll_test",
1222                    "kind": "insert_authenticator",
1223                    "status": {
1224                        "state": "finalized",
1225                        "tx_hash": "0x1234"
1226                    }
1227                })
1228                .to_string(),
1229            )
1230            .expect(2)
1231            .create_async()
1232            .await;
1233
1234        for request_id in ["poll_test", "gw_poll_test"] {
1235            assert_eq!(
1236                authenticator
1237                    .poll_status(request_id.to_string())
1238                    .await
1239                    .expect("status poll should succeed"),
1240                GatewayRequestStatus::Finalized {
1241                    tx_hash: "0x1234".to_string()
1242                }
1243            );
1244        }
1245        status_mock.assert_async().await;
1246
1247        drop(server);
1248        cleanup_test_storage(&root);
1249    }
1250
1251    #[tokio::test]
1252    async fn test_remove_authenticator_refuses_unexpected_slot_contents() {
1253        use crate::storage::tests_utils::cleanup_test_storage;
1254
1255        let mut server = mockito::Server::new_async().await;
1256        let (authenticator, root) = test_authenticator(&mut server).await;
1257        let existing_pubkey = encoded_pubkey(&TEST_SEED);
1258        let slot_pubkey = encoded_pubkey(&[2u8; 32]);
1259
1260        let pubkeys_mock = mock_authenticator_pubkeys(
1261            &mut server,
1262            &[
1263                Some(existing_pubkey.as_str()),
1264                None,
1265                Some(slot_pubkey.as_str()),
1266            ],
1267            2,
1268        )
1269        .await;
1270        let nonce_mock = server
1271            .mock("POST", "/signature-nonce")
1272            .expect(0)
1273            .create_async()
1274            .await;
1275        let remove_mock = server
1276            .mock("POST", "/remove-authenticator")
1277            .expect(0)
1278            .create_async()
1279            .await;
1280
1281        let mismatched = authenticator
1282            .remove_authenticator(
1283                Address::ZERO.to_string(),
1284                2,
1285                encoded_pubkey(&[3u8; 32]),
1286            )
1287            .await;
1288        assert!(matches!(
1289            mismatched,
1290            Err(WalletKitError::InvalidInput { attribute, .. })
1291                if attribute == "expected_authenticator_pubkey"
1292        ));
1293
1294        let empty_slot = authenticator
1295            .remove_authenticator(
1296                Address::ZERO.to_string(),
1297                1,
1298                encoded_pubkey(&[3u8; 32]),
1299            )
1300            .await;
1301        assert!(matches!(
1302            empty_slot,
1303            Err(WalletKitError::InvalidInput { attribute, reason })
1304                if attribute == "pubkey_id"
1305                    && reason.contains("no authenticator at key set slot 1")
1306        ));
1307
1308        let out_of_range = authenticator
1309            .remove_authenticator(
1310                Address::ZERO.to_string(),
1311                7,
1312                encoded_pubkey(&[3u8; 32]),
1313            )
1314            .await;
1315        assert!(matches!(
1316            out_of_range,
1317            Err(WalletKitError::InvalidInput { attribute, reason })
1318                if attribute == "pubkey_id" && reason.contains("out of range")
1319        ));
1320
1321        pubkeys_mock.assert_async().await;
1322        nonce_mock.assert_async().await;
1323        remove_mock.assert_async().await;
1324
1325        drop(server);
1326        cleanup_test_storage(&root);
1327    }
1328
1329    #[tokio::test]
1330    async fn test_key_set_reads_return_slots_and_membership() {
1331        use crate::storage::tests_utils::cleanup_test_storage;
1332
1333        let mut server = mockito::Server::new_async().await;
1334        let (authenticator, root) = test_authenticator(&mut server).await;
1335        let existing_pubkey = encoded_pubkey(&TEST_SEED);
1336        let other_pubkey = encoded_pubkey(&[2u8; 32]);
1337
1338        let pubkeys_mock = mock_authenticator_pubkeys(
1339            &mut server,
1340            &[
1341                Some(existing_pubkey.as_str()),
1342                None,
1343                Some(other_pubkey.as_str()),
1344            ],
1345            3,
1346        )
1347        .await;
1348
1349        assert!(authenticator
1350            .has_authenticator_pubkey(existing_pubkey.clone())
1351            .await
1352            .expect("membership read should succeed"));
1353        assert!(!authenticator
1354            .has_authenticator_pubkey(encoded_pubkey(&[3u8; 32]))
1355            .await
1356            .expect("absent key check should succeed"));
1357        assert_eq!(
1358            authenticator
1359                .get_authenticator_pubkeys()
1360                .await
1361                .expect("key set read should succeed"),
1362            vec![Some(existing_pubkey), None, Some(other_pubkey)]
1363        );
1364        pubkeys_mock.assert_async().await;
1365
1366        drop(server);
1367        cleanup_test_storage(&root);
1368    }
1369
1370    #[tokio::test]
1371    async fn test_remove_authenticator_reports_slot_emptied_during_signing() {
1372        use crate::storage::tests_utils::cleanup_test_storage;
1373        use std::sync::atomic::{AtomicUsize, Ordering};
1374
1375        let mut server = mockito::Server::new_async().await;
1376        let (authenticator, root) = test_authenticator(&mut server).await;
1377        let existing_pubkey = encoded_pubkey(&TEST_SEED);
1378        let removed_pubkey = encoded_pubkey(&[2u8; 32]);
1379
1380        // The first read (the wrapper's guard) sees the key at slot 1; the
1381        // second read (the crate's own signing fetch) sees the slot already
1382        // emptied, as if a concurrent operation landed in between. The
1383        // `PublicKeyNotFound` this produces must surface as the `pubkey_id`
1384        // input error, not as an authorization failure.
1385        let full_body = serde_json::json!({
1386            "authenticator_pubkeys": [existing_pubkey.clone(), removed_pubkey.clone()],
1387            "offchain_signer_commitment": "0x0"
1388        })
1389        .to_string();
1390        let emptied_body = serde_json::json!({
1391            "authenticator_pubkeys": [existing_pubkey],
1392            "offchain_signer_commitment": "0x0"
1393        })
1394        .to_string();
1395        let fetches = Arc::new(AtomicUsize::new(0));
1396        let fetches_in_mock = Arc::clone(&fetches);
1397        let pubkeys_mock = server
1398            .mock("POST", "/authenticator-pubkeys")
1399            .with_status(200)
1400            .with_header("content-type", "application/json")
1401            .with_body_from_request(move |_request| {
1402                if fetches_in_mock.fetch_add(1, Ordering::SeqCst) == 0 {
1403                    full_body.clone().into_bytes()
1404                } else {
1405                    emptied_body.clone().into_bytes()
1406                }
1407            })
1408            .expect(2)
1409            .create_async()
1410            .await;
1411        let nonce_mock = server
1412            .mock("POST", "/signature-nonce")
1413            .with_status(200)
1414            .with_header("content-type", "application/json")
1415            .with_body(serde_json::json!({ "signature_nonce": "0x1" }).to_string())
1416            .create_async()
1417            .await;
1418        let remove_mock = server
1419            .mock("POST", "/remove-authenticator")
1420            .expect(0)
1421            .create_async()
1422            .await;
1423
1424        let raced = authenticator
1425            .remove_authenticator(Address::ZERO.to_string(), 1, removed_pubkey)
1426            .await;
1427        assert!(matches!(
1428            raced,
1429            Err(WalletKitError::InvalidInput { attribute, .. })
1430                if attribute == "pubkey_id"
1431        ));
1432
1433        pubkeys_mock.assert_async().await;
1434        nonce_mock.assert_async().await;
1435        remove_mock.assert_async().await;
1436
1437        drop(server);
1438        cleanup_test_storage(&root);
1439    }
1440
1441    #[cfg(feature = "embed-zkeys")]
1442    #[tokio::test]
1443    async fn test_init_with_config_and_materials() {
1444        use crate::{
1445            authenticator::artifacts::caching::CachingZkArtifacts,
1446            storage::tests_utils::{
1447                cleanup_test_storage, temp_root_path, InMemoryStorageProvider,
1448            },
1449        };
1450        use alloy::primitives::address;
1451        use world_id_core::primitives::{Config, ServiceEndpoint};
1452
1453        let _ = rustls::crypto::ring::default_provider().install_default();
1454
1455        let mut mock_server = mockito::Server::new_async().await;
1456        mock_server
1457            .mock("POST", "/")
1458            .with_status(200)
1459            .with_header("content-type", "application/json")
1460            .with_body(
1461                serde_json::json!({
1462                    "jsonrpc": "2.0",
1463                    "id": 1,
1464                    "result": "0x0000000000000000000000000000000000000000000000000000000000000001"
1465                })
1466                .to_string(),
1467            )
1468            .create_async()
1469            .await;
1470
1471        let config = Config::new(
1472            Some(mock_server.url()),
1473            480,
1474            address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1475            ServiceEndpoint::direct(
1476                "https://indexer.us.id-infra.worldcoin.dev".to_string(),
1477            ),
1478            ServiceEndpoint::direct(
1479                "https://gateway.id-infra.worldcoin.dev".to_string(),
1480            ),
1481            vec![],
1482            2,
1483        )
1484        .unwrap();
1485        let config = serde_json::to_string(&config).unwrap();
1486
1487        let root = temp_root_path();
1488        let provider = InMemoryStorageProvider::new(&root);
1489        let store = CredentialStore::from_provider(&provider).expect("store");
1490        store.init(42, 100).expect("init storage");
1491
1492        let artifacts =
1493            Arc::new(CachingZkArtifacts::new(Arc::new(store.paths().unwrap())));
1494
1495        let _authenticator = Authenticator::init(
1496            [2u8; 32].to_vec(),
1497            &config,
1498            artifacts,
1499            Arc::new(store),
1500        )
1501        .await
1502        .unwrap();
1503        drop(mock_server);
1504
1505        cleanup_test_storage(&root);
1506    }
1507}