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