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 session_id_r_seed =
647            proof_request
648                .0
649                .session_id
650                .existing()
651                .and_then(|session_id| {
652                    match self.store.get_session_seed(session_id.oprf_seed, now) {
653                        Ok(seed) => seed,
654                        Err(err) => {
655                            tracing::warn!(error = %err, "failed to load cached session seed, continuing without");
656                            None
657                        }
658                    }
659                });
660
661        // Handles credential selection, session resolution, per-credential proofs, response assembly, and validation
662        let result = Box::pin(self.inner.generate_proof(
663            &proof_request.0,
664            nullifier.clone(),
665            &credentials,
666            Some(account_inclusion_proof),
667            session_id_r_seed,
668        ))
669        .await?;
670
671        // Cache session seed if returned. Create-session requests do not carry a
672        // session_id, so use the session_id generated in the proof response.
673        if let Some(seed) = result.session_id_r_seed {
674            if let Some(session_id) = result.proof_response.session_id {
675                if let Err(err) =
676                    self.store
677                        .store_session_seed(session_id.oprf_seed, seed, now)
678                {
679                    tracing::error!("error caching session_id_r_seed: {}", err);
680                }
681            }
682        }
683
684        self.store
685            .replay_guard_set(nullifier.verifiable_oprf_output.output.into(), now)?;
686
687        Ok(result.proof_response.into())
688    }
689
690    /// Generates a WIP-103 Ownership Proof for Issuers.
691    ///
692    /// An Ownership Proof lets the user prove they own the credential `sub`
693    /// associated with a stored credential without revealing their `leaf_index`.
694    ///
695    /// # Security-critical usage constraint
696    /// This method **MUST only** be called as part of a direct
697    /// **user-initiated** action in the client. Callers **MUST NOT** expose this
698    /// method to issuer-triggered, backend-triggered, or unauthenticated request
699    /// flows.
700    ///
701    /// # Arguments
702    /// * `nonce` - A field element provided by the Issuer to prevent replay.
703    /// * `context` - A field element identifying the issuer operation being authorized.
704    /// * `blinding_factor` - The credential blinding factor previously used to
705    ///   derive the credential `sub`.
706    /// * `sub` - The credential `sub` (commitment) to prove ownership of.
707    ///
708    /// # Errors
709    /// - Returns [`WalletKitError::InvalidInput`] if `blinding_factor` and
710    ///   `sub` are inconsistent with each other (i.e. `sub` was not derived
711    ///   from this authenticator's leaf index and the provided blinding factor).
712    /// - Returns a network error if the Merkle inclusion proof cannot be
713    ///   fetched from the indexer.
714    /// - Returns [`WalletKitError::ProofGeneration`] if the ZK proof fails.
715    pub async fn prove_credential_sub(
716        &self,
717        nonce: &FieldElement,
718        context: &FieldElement,
719        blinding_factor: &FieldElement,
720        sub: &FieldElement,
721    ) -> Result<OwnershipProof, WalletKitError> {
722        #[cfg(target_arch = "wasm32")]
723        {
724            let _ = (nonce, context, blinding_factor, sub);
725            return Err(WalletKitError::Generic {
726                error: "credential ownership proofs are not supported on wasm32"
727                    .to_string(),
728            });
729        }
730
731        #[cfg(not(target_arch = "wasm32"))]
732        {
733            let now = std::time::SystemTime::now()
734                .duration_since(std::time::UNIX_EPOCH)
735                .map_err(|e| WalletKitError::Generic {
736                    error: format!("Critical. Unable to determine SystemTime: {e}"),
737                })?
738                .as_secs();
739
740            let inclusion_proof = self.fetch_inclusion_proof_with_cache(now).await?;
741            let proof = self
742                .inner
743                .prove_credential_sub(
744                    nonce.0,
745                    context.0,
746                    blinding_factor.0,
747                    sub.0,
748                    Some(inclusion_proof),
749                )
750                .await?;
751
752            Ok(OwnershipProof(proof))
753        }
754    }
755}
756
757/// Registration status for a World ID being created through the gateway.
758#[derive(Debug, Clone, uniffi::Enum)]
759pub enum RegistrationStatus {
760    /// Request queued but not yet batched.
761    Queued,
762    /// Request currently being batched.
763    Batching,
764    /// Request submitted on-chain.
765    Submitted,
766    /// Request finalized on-chain. The World ID is now registered.
767    Finalized,
768    /// Request failed during processing.
769    Failed {
770        /// Error message returned by the gateway.
771        error: String,
772        /// Specific error code, if available.
773        error_code: Option<String>,
774    },
775}
776
777/// Status of an account operation submitted through the gateway.
778#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
779pub enum GatewayRequestStatus {
780    /// Request queued but not yet batched.
781    Queued,
782    /// Request currently being batched.
783    Batching,
784    /// Request submitted on-chain.
785    Submitted {
786        /// Transaction hash emitted when the request was submitted.
787        tx_hash: String,
788    },
789    /// Request finalized on-chain.
790    Finalized {
791        /// Transaction hash emitted when the request was finalized.
792        tx_hash: String,
793    },
794    /// Request failed during processing.
795    Failed {
796        /// Error message returned by the gateway.
797        error: String,
798        /// Specific error code, if available.
799        error_code: Option<String>,
800    },
801}
802
803impl From<GatewayRequestState> for GatewayRequestStatus {
804    fn from(state: GatewayRequestState) -> Self {
805        match state {
806            GatewayRequestState::Queued => Self::Queued,
807            GatewayRequestState::Batching => Self::Batching,
808            GatewayRequestState::Submitted { tx_hash } => Self::Submitted { tx_hash },
809            GatewayRequestState::Finalized { tx_hash } => Self::Finalized { tx_hash },
810            GatewayRequestState::Failed { error, error_code } => Self::Failed {
811                error,
812                error_code: error_code.map(|code| code.to_string()),
813            },
814        }
815    }
816}
817
818impl From<GatewayRequestState> for RegistrationStatus {
819    fn from(state: GatewayRequestState) -> Self {
820        match state {
821            GatewayRequestState::Queued => Self::Queued,
822            GatewayRequestState::Batching => Self::Batching,
823            GatewayRequestState::Submitted { .. } => Self::Submitted,
824            GatewayRequestState::Finalized { .. } => Self::Finalized,
825            GatewayRequestState::Failed { error, error_code } => Self::Failed {
826                error,
827                error_code: error_code.map(|c: GatewayErrorCode| c.to_string()),
828            },
829        }
830    }
831}
832
833/// Represents an Authenticator in the process of being initialized.
834///
835/// The account is not yet registered in the `WorldIDRegistry` contract.
836/// Use this for non-blocking registration flows where you want to poll the status yourself.
837#[derive(uniffi::Object)]
838pub struct InitializingAuthenticator(CoreInitializingAuthenticator);
839
840#[uniffi::export(async_runtime = "tokio")]
841impl InitializingAuthenticator {
842    /// Registers a new World ID with SDK defaults.
843    ///
844    /// This returns immediately and does not wait for registration to complete.
845    /// The returned `InitializingAuthenticator` can be used to poll the registration status.
846    ///
847    /// # Errors
848    /// See `CoreAuthenticator::register` for potential errors.
849    #[uniffi::constructor]
850    #[tracing::instrument(
851        target = "walletkit_latency",
852        name = "gateway_register",
853        skip_all
854    )]
855    pub async fn register_with_defaults(
856        seed: Vec<u8>,
857        rpc_url: Option<String>,
858        environment: &Environment,
859        region: Option<Region>,
860        recovery_address: Option<String>,
861    ) -> Result<Self, WalletKitError> {
862        let recovery_address =
863            Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
864
865        let config = defaults::default_config(environment, rpc_url, region)?;
866
867        let initializing_authenticator =
868            CoreAuthenticator::register(&seed, config, recovery_address).await?;
869
870        Ok(Self(initializing_authenticator))
871    }
872
873    /// Registers a new World ID using SDK defaults routed through the OHTTP
874    /// relay. Opt-in alternative to
875    /// [`InitializingAuthenticator::register_with_defaults`].
876    ///
877    /// This returns immediately and does not wait for registration to complete.
878    /// The returned `InitializingAuthenticator` can be used to poll the registration status.
879    ///
880    /// # Errors
881    /// See `CoreAuthenticator::register` for potential errors.
882    #[uniffi::constructor]
883    #[tracing::instrument(
884        target = "walletkit_latency",
885        name = "gateway_register",
886        skip_all
887    )]
888    pub async fn register_with_ohttp_defaults(
889        seed: Vec<u8>,
890        rpc_url: Option<String>,
891        environment: &Environment,
892        region: Option<Region>,
893        recovery_address: Option<String>,
894    ) -> Result<Self, WalletKitError> {
895        let recovery_address =
896            Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
897
898        let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
899
900        let initializing_authenticator =
901            CoreAuthenticator::register(&seed, config, recovery_address).await?;
902
903        Ok(Self(initializing_authenticator))
904    }
905
906    /// Registers a new World ID.
907    ///
908    /// This returns immediately and does not wait for registration to complete.
909    /// The returned `InitializingAuthenticator` can be used to poll the registration status.
910    ///
911    /// # Errors
912    /// See `CoreAuthenticator::register` for potential errors.
913    #[uniffi::constructor]
914    #[tracing::instrument(
915        target = "walletkit_latency",
916        name = "gateway_register",
917        skip_all
918    )]
919    pub async fn register(
920        seed: Vec<u8>,
921        config: &str,
922        recovery_address: Option<String>,
923    ) -> Result<Self, WalletKitError> {
924        let recovery_address =
925            Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
926
927        let config =
928            Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
929                attribute: "config".to_string(),
930                reason: "Invalid config".to_string(),
931            })?;
932
933        let initializing_authenticator =
934            CoreAuthenticator::register(&seed, config, recovery_address).await?;
935
936        Ok(Self(initializing_authenticator))
937    }
938
939    /// Polls the registration status from the gateway.
940    ///
941    /// # Errors
942    /// Will error if the network request fails or the gateway returns an error.
943    #[tracing::instrument(
944        target = "walletkit_latency",
945        name = "gateway_poll",
946        skip_all
947    )]
948    pub async fn poll_status(&self) -> Result<RegistrationStatus, WalletKitError> {
949        let status = self.0.poll_status().await?;
950        Ok(status.into())
951    }
952}
953
954/// The signature and signing nonce returned by
955/// [`Authenticator::danger_sign_initiate_recovery_agent_update`].
956///
957/// `UniFFI` does not support returning bare tuples across the FFI boundary, so
958/// the two values are bundled in this record type.
959#[derive(Debug, Clone, uniffi::Record)]
960pub struct RecoveryUpdateSignature {
961    /// Raw bytes of the secp256k1 ECDSA signature over the EIP-712
962    /// `InitiateRecoveryAgentUpdate` payload.
963    pub signature: Vec<u8>,
964    /// The EIP-712 signing nonce that was used; must be included in the
965    /// gateway request alongside the signature.
966    pub nonce: Uint256,
967}
968
969/// Identity material derived from a seed for use during account recovery.
970///
971/// During account recovery the user generates new keys from a seed, but those
972/// keys do not yet exist on-chain. The three values in this record must be
973/// submitted on-chain during the recovery transaction.
974///
975/// All fields are hex-encoded strings suitable for direct use in API requests.
976#[derive(Debug, Clone, uniffi::Record)]
977pub struct RecoveryData {
978    /// Checksummed hex Ethereum address of the on-chain signer.
979    pub authenticator_address: String,
980    /// Hex-encoded U256 compressed `EdDSA` public key of the off-chain signer.
981    pub authenticator_pubkey: String,
982    /// Hex-encoded U256 Poseidon2 hash commitment over the authenticator key set.
983    pub offchain_signer_commitment: String,
984}
985
986impl RecoveryData {
987    /// Derives recovery identity material from a 32-byte seed.
988    ///
989    /// These values must be submitted on-chain as part of the recovery
990    /// transaction before the recovered account can be initialised with
991    /// [`Authenticator::init`] / [`Authenticator::init_with_defaults`].
992    ///
993    /// # Errors
994    /// Returns [`WalletKitError`] if the seed is invalid or serialization fails.
995    pub fn from_seed(seed: &[u8]) -> Result<Self, WalletKitError> {
996        let signer = Signer::from_seed_bytes(seed)?;
997        let authenticator_address = signer.onchain_signer_address().to_checksum(None);
998        let authenticator_pubkey: U256 = signer
999            .offchain_signer_pubkey()
1000            .to_ethereum_representation()?;
1001        let mut key_set = AuthenticatorPublicKeySet::default();
1002        key_set.try_push(signer.offchain_signer_pubkey())?;
1003        let offchain_signer_commitment: U256 = key_set.leaf_hash().into();
1004
1005        Ok(Self {
1006            authenticator_address,
1007            authenticator_pubkey: format!("{authenticator_pubkey:#066x}"),
1008            offchain_signer_commitment: format!("{offchain_signer_commitment:#066x}"),
1009        })
1010    }
1011}
1012
1013/// Validates an authenticator public key without submitting an account
1014/// operation, returning its canonical encoding.
1015///
1016/// This is a free function (not a method on [`Authenticator`]) so consumers
1017/// can validate a key — e.g. one scanned during pairing — before an
1018/// `Authenticator` exists.
1019///
1020/// The returned string is the canonical form of the key (lowercase,
1021/// `0x`-prefixed, zero-padded 32-byte hex), byte-identical to the entries
1022/// returned by [`Authenticator::get_authenticator_pubkeys`]. Use it — not the
1023/// raw input — for string comparisons against key-set entries.
1024///
1025/// # Arguments
1026/// * `authenticator_pubkey` — a compressed `BabyJubJub` public key encoded
1027///   as a `0x`-prefixed, zero-padded 32-byte hex string.
1028///
1029/// # Errors
1030/// Returns [`WalletKitError::InvalidInput`] if the public key is invalid,
1031/// is not in canonical form, or is the `BabyJubJub` identity point.
1032#[uniffi::export]
1033pub fn validate_authenticator_pubkey(
1034    authenticator_pubkey: &str,
1035) -> Result<String, WalletKitError> {
1036    let pubkey =
1037        parse_authenticator_pubkey("authenticator_pubkey", authenticator_pubkey)?;
1038    let encoded = pubkey.to_ethereum_representation()?;
1039    Ok(format!("{encoded:#066x}"))
1040}
1041
1042/// Derives recovery data from a 32-byte seed.
1043///
1044/// This is the foreign-bindings entrypoint for recovery data generation.
1045///
1046/// # Errors
1047/// Returns [`WalletKitError`] if the seed is invalid or serialization fails.
1048#[uniffi::export]
1049#[allow(
1050    clippy::needless_pass_by_value,
1051    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]`"
1052)]
1053pub fn recovery_data_from_seed(seed: Vec<u8>) -> Result<RecoveryData, WalletKitError> {
1054    RecoveryData::from_seed(&seed)
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060
1061    const TEST_SEED: [u8; 32] = [1u8; 32];
1062
1063    async fn test_authenticator(
1064        server: &mut mockito::Server,
1065    ) -> (Authenticator, std::path::PathBuf) {
1066        use crate::storage::tests_utils::{temp_root_path, InMemoryStorageProvider};
1067        use alloy::primitives::address;
1068        use world_id_core::primitives::ServiceEndpoint;
1069        use world_id_proof::artifacts::dummy::DummyZkArtifactSource;
1070
1071        let _ = rustls::crypto::ring::default_provider().install_default();
1072
1073        let packed_account_mock = server
1074            .mock("POST", "/packed-account")
1075            .with_status(200)
1076            .with_header("content-type", "application/json")
1077            .with_body(serde_json::json!({ "packed_account_data": "0x2a" }).to_string())
1078            .create_async()
1079            .await;
1080        let config = Config::new(
1081            None,
1082            480,
1083            address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1084            ServiceEndpoint::direct(server.url()),
1085            ServiceEndpoint::direct(server.url()),
1086            vec![],
1087            2,
1088        )
1089        .expect("valid config");
1090        let root = temp_root_path();
1091        let provider = InMemoryStorageProvider::new(&root);
1092        let store =
1093            CredentialStore::from_provider(&provider).expect("credential store");
1094        let authenticator = Authenticator::init_with_config(
1095            &TEST_SEED,
1096            config,
1097            Arc::new(DummyZkArtifactSource),
1098            Arc::new(store),
1099        )
1100        .await
1101        .expect("authenticator should initialize");
1102        packed_account_mock.assert_async().await;
1103
1104        (authenticator, root)
1105    }
1106
1107    fn encoded_pubkey(seed: &[u8; 32]) -> String {
1108        let pubkey = Signer::from_seed_bytes(seed)
1109            .expect("valid seed")
1110            .offchain_signer_pubkey()
1111            .to_ethereum_representation()
1112            .expect("public key should encode");
1113        format!("{pubkey:#066x}")
1114    }
1115
1116    /// Mocks the indexer's `/authenticator-pubkeys` endpoint with a fixed
1117    /// key-set response (`None` entries are empty slots), asserting the
1118    /// request body and the expected number of hits.
1119    async fn mock_authenticator_pubkeys(
1120        server: &mut mockito::Server,
1121        pubkeys: &[Option<&str>],
1122        expected_hits: usize,
1123    ) -> mockito::Mock {
1124        server
1125            .mock("POST", "/authenticator-pubkeys")
1126            .match_body(mockito::Matcher::JsonString(
1127                serde_json::json!({ "leaf_index": "0x2a" }).to_string(),
1128            ))
1129            .with_status(200)
1130            .with_header("content-type", "application/json")
1131            .with_body(
1132                serde_json::json!({
1133                    "authenticator_pubkeys": pubkeys,
1134                    "offchain_signer_commitment": "0x0"
1135                })
1136                .to_string(),
1137            )
1138            .expect(expected_hits)
1139            .create_async()
1140            .await
1141    }
1142
1143    #[test]
1144    fn test_recovery_data_from_seed() {
1145        let seed = [1u8; 32];
1146        let material = RecoveryData::from_seed(&seed).expect("should derive material");
1147
1148        assert!(material.authenticator_address.starts_with("0x"));
1149        assert_eq!(material.authenticator_address.len(), 42);
1150        assert!(material.authenticator_pubkey.starts_with("0x"));
1151        assert!(material.authenticator_pubkey.len() <= 66);
1152        assert!(material.offchain_signer_commitment.starts_with("0x"));
1153        assert!(material.offchain_signer_commitment.len() <= 66);
1154        assert!(material.authenticator_address.len() > 2);
1155        assert!(material.authenticator_pubkey.len() > 2);
1156        assert!(material.offchain_signer_commitment.len() > 2);
1157    }
1158
1159    #[test]
1160    fn test_recovery_data_rejects_invalid_seed() {
1161        assert!(RecoveryData::from_seed(&[0u8; 16]).is_err());
1162        assert!(RecoveryData::from_seed(&[]).is_err());
1163    }
1164
1165    #[test]
1166    fn test_authenticator_pubkey_validation() {
1167        let canonical = encoded_pubkey(&[2u8; 32]);
1168        assert_eq!(
1169            validate_authenticator_pubkey(&canonical).expect("valid key"),
1170            canonical
1171        );
1172        let uppercase = format!("0x{}", canonical[2..].to_uppercase());
1173        assert_eq!(
1174            validate_authenticator_pubkey(&uppercase)
1175                .expect("uppercase hex should canonicalize"),
1176            canonical
1177        );
1178
1179        for invalid_pubkey in [
1180            "not-a-public-key".to_string(),
1181            format!("0x{}", "ff".repeat(32)),
1182        ] {
1183            assert!(matches!(
1184                validate_authenticator_pubkey(&invalid_pubkey),
1185                Err(WalletKitError::InvalidInput { attribute, .. })
1186                    if attribute == "authenticator_pubkey"
1187            ));
1188        }
1189
1190        let identity = format!("0x{}01", "0".repeat(62));
1191        assert!(matches!(
1192            validate_authenticator_pubkey(&identity),
1193            Err(WalletKitError::InvalidInput { attribute, reason })
1194                if attribute == "authenticator_pubkey" && reason.contains("identity")
1195        ));
1196        let sign_bit_alias = format!("0x80{}01", "0".repeat(60));
1197        assert!(matches!(
1198            validate_authenticator_pubkey(&sign_bit_alias),
1199            Err(WalletKitError::InvalidInput { attribute, reason })
1200                if attribute == "authenticator_pubkey" && reason.contains("canonical")
1201        ));
1202    }
1203
1204    #[tokio::test]
1205    async fn test_poll_status_normalizes_request_id() {
1206        use crate::storage::tests_utils::cleanup_test_storage;
1207
1208        let mut server = mockito::Server::new_async().await;
1209        let (authenticator, root) = test_authenticator(&mut server).await;
1210        let status_mock = server
1211            .mock("GET", "/status/gw_poll_test")
1212            .with_status(200)
1213            .with_header("content-type", "application/json")
1214            .with_body(
1215                serde_json::json!({
1216                    "request_id": "gw_poll_test",
1217                    "kind": "insert_authenticator",
1218                    "status": {
1219                        "state": "finalized",
1220                        "tx_hash": "0x1234"
1221                    }
1222                })
1223                .to_string(),
1224            )
1225            .expect(2)
1226            .create_async()
1227            .await;
1228
1229        for request_id in ["poll_test", "gw_poll_test"] {
1230            assert_eq!(
1231                authenticator
1232                    .poll_status(request_id.to_string())
1233                    .await
1234                    .expect("status poll should succeed"),
1235                GatewayRequestStatus::Finalized {
1236                    tx_hash: "0x1234".to_string()
1237                }
1238            );
1239        }
1240        status_mock.assert_async().await;
1241
1242        drop(server);
1243        cleanup_test_storage(&root);
1244    }
1245
1246    #[tokio::test]
1247    async fn test_remove_authenticator_refuses_unexpected_slot_contents() {
1248        use crate::storage::tests_utils::cleanup_test_storage;
1249
1250        let mut server = mockito::Server::new_async().await;
1251        let (authenticator, root) = test_authenticator(&mut server).await;
1252        let existing_pubkey = encoded_pubkey(&TEST_SEED);
1253        let slot_pubkey = encoded_pubkey(&[2u8; 32]);
1254
1255        let pubkeys_mock = mock_authenticator_pubkeys(
1256            &mut server,
1257            &[
1258                Some(existing_pubkey.as_str()),
1259                None,
1260                Some(slot_pubkey.as_str()),
1261            ],
1262            2,
1263        )
1264        .await;
1265        let nonce_mock = server
1266            .mock("POST", "/signature-nonce")
1267            .expect(0)
1268            .create_async()
1269            .await;
1270        let remove_mock = server
1271            .mock("POST", "/remove-authenticator")
1272            .expect(0)
1273            .create_async()
1274            .await;
1275
1276        let mismatched = authenticator
1277            .remove_authenticator(
1278                Address::ZERO.to_string(),
1279                2,
1280                encoded_pubkey(&[3u8; 32]),
1281            )
1282            .await;
1283        assert!(matches!(
1284            mismatched,
1285            Err(WalletKitError::InvalidInput { attribute, .. })
1286                if attribute == "expected_authenticator_pubkey"
1287        ));
1288
1289        let empty_slot = authenticator
1290            .remove_authenticator(
1291                Address::ZERO.to_string(),
1292                1,
1293                encoded_pubkey(&[3u8; 32]),
1294            )
1295            .await;
1296        assert!(matches!(
1297            empty_slot,
1298            Err(WalletKitError::InvalidInput { attribute, reason })
1299                if attribute == "pubkey_id"
1300                    && reason.contains("no authenticator at key set slot 1")
1301        ));
1302
1303        let out_of_range = authenticator
1304            .remove_authenticator(
1305                Address::ZERO.to_string(),
1306                7,
1307                encoded_pubkey(&[3u8; 32]),
1308            )
1309            .await;
1310        assert!(matches!(
1311            out_of_range,
1312            Err(WalletKitError::InvalidInput { attribute, reason })
1313                if attribute == "pubkey_id" && reason.contains("out of range")
1314        ));
1315
1316        pubkeys_mock.assert_async().await;
1317        nonce_mock.assert_async().await;
1318        remove_mock.assert_async().await;
1319
1320        drop(server);
1321        cleanup_test_storage(&root);
1322    }
1323
1324    #[tokio::test]
1325    async fn test_key_set_reads_return_slots_and_membership() {
1326        use crate::storage::tests_utils::cleanup_test_storage;
1327
1328        let mut server = mockito::Server::new_async().await;
1329        let (authenticator, root) = test_authenticator(&mut server).await;
1330        let existing_pubkey = encoded_pubkey(&TEST_SEED);
1331        let other_pubkey = encoded_pubkey(&[2u8; 32]);
1332
1333        let pubkeys_mock = mock_authenticator_pubkeys(
1334            &mut server,
1335            &[
1336                Some(existing_pubkey.as_str()),
1337                None,
1338                Some(other_pubkey.as_str()),
1339            ],
1340            3,
1341        )
1342        .await;
1343
1344        assert!(authenticator
1345            .has_authenticator_pubkey(existing_pubkey.clone())
1346            .await
1347            .expect("membership read should succeed"));
1348        assert!(!authenticator
1349            .has_authenticator_pubkey(encoded_pubkey(&[3u8; 32]))
1350            .await
1351            .expect("absent key check should succeed"));
1352        assert_eq!(
1353            authenticator
1354                .get_authenticator_pubkeys()
1355                .await
1356                .expect("key set read should succeed"),
1357            vec![Some(existing_pubkey), None, Some(other_pubkey)]
1358        );
1359        pubkeys_mock.assert_async().await;
1360
1361        drop(server);
1362        cleanup_test_storage(&root);
1363    }
1364
1365    #[tokio::test]
1366    async fn test_remove_authenticator_reports_slot_emptied_during_signing() {
1367        use crate::storage::tests_utils::cleanup_test_storage;
1368        use std::sync::atomic::{AtomicUsize, Ordering};
1369
1370        let mut server = mockito::Server::new_async().await;
1371        let (authenticator, root) = test_authenticator(&mut server).await;
1372        let existing_pubkey = encoded_pubkey(&TEST_SEED);
1373        let removed_pubkey = encoded_pubkey(&[2u8; 32]);
1374
1375        // The first read (the wrapper's guard) sees the key at slot 1; the
1376        // second read (the crate's own signing fetch) sees the slot already
1377        // emptied, as if a concurrent operation landed in between. The
1378        // `PublicKeyNotFound` this produces must surface as the `pubkey_id`
1379        // input error, not as an authorization failure.
1380        let full_body = serde_json::json!({
1381            "authenticator_pubkeys": [existing_pubkey.clone(), removed_pubkey.clone()],
1382            "offchain_signer_commitment": "0x0"
1383        })
1384        .to_string();
1385        let emptied_body = serde_json::json!({
1386            "authenticator_pubkeys": [existing_pubkey],
1387            "offchain_signer_commitment": "0x0"
1388        })
1389        .to_string();
1390        let fetches = Arc::new(AtomicUsize::new(0));
1391        let fetches_in_mock = Arc::clone(&fetches);
1392        let pubkeys_mock = server
1393            .mock("POST", "/authenticator-pubkeys")
1394            .with_status(200)
1395            .with_header("content-type", "application/json")
1396            .with_body_from_request(move |_request| {
1397                if fetches_in_mock.fetch_add(1, Ordering::SeqCst) == 0 {
1398                    full_body.clone().into_bytes()
1399                } else {
1400                    emptied_body.clone().into_bytes()
1401                }
1402            })
1403            .expect(2)
1404            .create_async()
1405            .await;
1406        let nonce_mock = server
1407            .mock("POST", "/signature-nonce")
1408            .with_status(200)
1409            .with_header("content-type", "application/json")
1410            .with_body(serde_json::json!({ "signature_nonce": "0x1" }).to_string())
1411            .create_async()
1412            .await;
1413        let remove_mock = server
1414            .mock("POST", "/remove-authenticator")
1415            .expect(0)
1416            .create_async()
1417            .await;
1418
1419        let raced = authenticator
1420            .remove_authenticator(Address::ZERO.to_string(), 1, removed_pubkey)
1421            .await;
1422        assert!(matches!(
1423            raced,
1424            Err(WalletKitError::InvalidInput { attribute, .. })
1425                if attribute == "pubkey_id"
1426        ));
1427
1428        pubkeys_mock.assert_async().await;
1429        nonce_mock.assert_async().await;
1430        remove_mock.assert_async().await;
1431
1432        drop(server);
1433        cleanup_test_storage(&root);
1434    }
1435
1436    #[cfg(feature = "embed-zkeys")]
1437    #[tokio::test]
1438    async fn test_init_with_config_and_materials() {
1439        use crate::{
1440            authenticator::artifacts::caching::CachingZkArtifacts,
1441            storage::tests_utils::{
1442                cleanup_test_storage, temp_root_path, InMemoryStorageProvider,
1443            },
1444        };
1445        use alloy::primitives::address;
1446        use world_id_core::primitives::{Config, ServiceEndpoint};
1447
1448        let _ = rustls::crypto::ring::default_provider().install_default();
1449
1450        let mut mock_server = mockito::Server::new_async().await;
1451        mock_server
1452            .mock("POST", "/")
1453            .with_status(200)
1454            .with_header("content-type", "application/json")
1455            .with_body(
1456                serde_json::json!({
1457                    "jsonrpc": "2.0",
1458                    "id": 1,
1459                    "result": "0x0000000000000000000000000000000000000000000000000000000000000001"
1460                })
1461                .to_string(),
1462            )
1463            .create_async()
1464            .await;
1465
1466        let config = Config::new(
1467            Some(mock_server.url()),
1468            480,
1469            address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1470            ServiceEndpoint::direct(
1471                "https://indexer.us.id-infra.worldcoin.dev".to_string(),
1472            ),
1473            ServiceEndpoint::direct(
1474                "https://gateway.id-infra.worldcoin.dev".to_string(),
1475            ),
1476            vec![],
1477            2,
1478        )
1479        .unwrap();
1480        let config = serde_json::to_string(&config).unwrap();
1481
1482        let root = temp_root_path();
1483        let provider = InMemoryStorageProvider::new(&root);
1484        let store = CredentialStore::from_provider(&provider).expect("store");
1485        store.init(42, 100).expect("init storage");
1486
1487        let artifacts =
1488            Arc::new(CachingZkArtifacts::new(Arc::new(store.paths().unwrap())));
1489
1490        let _authenticator = Authenticator::init(
1491            [2u8; 32].to_vec(),
1492            &config,
1493            artifacts,
1494            Arc::new(store),
1495        )
1496        .await
1497        .unwrap();
1498        drop(mock_server);
1499
1500        cleanup_test_storage(&root);
1501    }
1502}