Skip to main content

walletkit_core/authenticator/
mod.rs

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