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