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