Skip to main content

world_id_authenticator/
authenticator.rs

1//! This module contains all the base functionality to support Authenticators in World ID.
2//!
3//! An Authenticator is the application layer with which a user interacts with the Protocol.
4
5use std::sync::Arc;
6
7use crate::api_types::{
8    AccountInclusionProof, CreateAccountRequest, GatewayRequestState, GatewayStatusResponse,
9    IndexerAuthenticatorPubkeysResponse, IndexerErrorCode, IndexerPackedAccountRequest,
10    IndexerPackedAccountResponse, IndexerQueryRequest, IndexerSignatureNonceResponse,
11    InsertAuthenticatorRequest, RemoveAuthenticatorRequest, ServiceApiError,
12    UpdateAuthenticatorRequest,
13};
14use world_id_primitives::{
15    Credential, FieldElement, ProofRequest, RequestItem, ResponseItem, SessionNullifier, Signer,
16};
17
18use crate::registry::{
19    WorldIdRegistry::WorldIdRegistryInstance, domain, sign_insert_authenticator,
20    sign_remove_authenticator, sign_update_authenticator,
21};
22use alloy::{
23    primitives::Address,
24    providers::DynProvider,
25    signers::{Signature, SignerSync},
26};
27use ark_serialize::CanonicalSerialize;
28use eddsa_babyjubjub::{EdDSAPublicKey, EdDSASignature};
29use groth16_material::circom::CircomGroth16Material;
30use reqwest::StatusCode;
31use ruint::{aliases::U256, uint};
32use secrecy::ExposeSecret;
33use taceo_oprf::client::Connector;
34pub use world_id_primitives::{Config, TREE_DEPTH, authenticator::ProtocolSigner};
35use world_id_primitives::{
36    PrimitiveError, SessionId, ZeroKnowledgeProof,
37    authenticator::{
38        AuthenticatorPublicKeySet, SparseAuthenticatorPubkeysError,
39        decode_sparse_authenticator_pubkeys,
40    },
41    merkle::MerkleInclusionProof,
42};
43use world_id_proof::{
44    AuthenticatorProofInput, FullOprfOutput, OprfEntrypoint,
45    proof::{ProofError, generate_nullifier_proof},
46};
47
48#[expect(unused_imports, reason = "used for docs")]
49use world_id_primitives::Nullifier;
50
51static MASK_RECOVERY_COUNTER: U256 =
52    uint!(0xFFFFFFFF00000000000000000000000000000000000000000000000000000000_U256);
53static MASK_PUBKEY_ID: U256 =
54    uint!(0x00000000FFFFFFFF000000000000000000000000000000000000000000000000_U256);
55static MASK_LEAF_INDEX: U256 =
56    uint!(0x000000000000000000000000000000000000000000000000FFFFFFFFFFFFFFFF_U256);
57
58/// An Authenticator is the base layer with which a user interacts with the Protocol.
59pub struct Authenticator {
60    /// General configuration for the Authenticator.
61    pub config: Config,
62    /// The packed account data for the holder's World ID is a `uint256` defined in the `WorldIDRegistry` contract as:
63    /// `recovery_counter` (32 bits) | `pubkey_id` (commitment to all off-chain public keys) (32 bits) | `leaf_index` (192 bits)
64    pub packed_account_data: U256,
65    signer: Signer,
66    registry: Option<Arc<WorldIdRegistryInstance<DynProvider>>>,
67    http_client: reqwest::Client,
68    ws_connector: Connector,
69    query_material: Option<Arc<CircomGroth16Material>>,
70    nullifier_material: Option<Arc<CircomGroth16Material>>,
71}
72
73impl std::fmt::Debug for Authenticator {
74    // avoiding logging other attributes to avoid accidental leak of leaf_index
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("Authenticator")
77            .field("config", &self.config)
78            .finish_non_exhaustive()
79    }
80}
81
82impl Authenticator {
83    async fn response_body_or_fallback(response: reqwest::Response) -> String {
84        response
85            .text()
86            .await
87            .unwrap_or_else(|e| format!("Unable to read response body: {e}"))
88    }
89
90    /// Initialize an Authenticator from a seed and config.
91    ///
92    /// This method will error if the World ID account does not exist on the registry.
93    ///
94    /// # Errors
95    /// - Will error if the provided seed is invalid (not 32 bytes).
96    /// - Will error if the RPC URL is invalid.
97    /// - Will error if there are contract call failures.
98    /// - Will error if the account does not exist (`AccountDoesNotExist`).
99    pub async fn init(seed: &[u8], config: Config) -> Result<Self, AuthenticatorError> {
100        let signer = Signer::from_seed_bytes(seed)?;
101
102        let registry: Option<Arc<WorldIdRegistryInstance<DynProvider>>> =
103            config.rpc_url().map(|rpc_url| {
104                let provider = alloy::providers::ProviderBuilder::new()
105                    .with_chain_id(config.chain_id())
106                    .connect_http(rpc_url.clone());
107                Arc::new(crate::registry::WorldIdRegistry::new(
108                    *config.registry_address(),
109                    alloy::providers::Provider::erased(provider),
110                ))
111            });
112
113        let http_client = reqwest::Client::new();
114
115        let packed_account_data = Self::get_packed_account_data(
116            signer.onchain_signer_address(),
117            registry.as_deref(),
118            &config,
119            &http_client,
120        )
121        .await?;
122
123        #[cfg(not(target_arch = "wasm32"))]
124        let ws_connector = {
125            let mut root_store = rustls::RootCertStore::empty();
126            root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
127            let rustls_config = rustls::ClientConfig::builder()
128                .with_root_certificates(root_store)
129                .with_no_client_auth();
130            Connector::Rustls(Arc::new(rustls_config))
131        };
132
133        #[cfg(target_arch = "wasm32")]
134        let ws_connector = Connector;
135
136        Ok(Self {
137            packed_account_data,
138            signer,
139            config,
140            registry,
141            http_client,
142            ws_connector,
143            query_material: None,
144            nullifier_material: None,
145        })
146    }
147
148    /// Sets the proof materials for the Authenticator, returning a new instance.
149    ///
150    /// Proof materials are required for proof generation, blinding factors and starting
151    /// sessions. Given the proof circuits are large, this may be loaded only when necessary.
152    #[must_use]
153    pub fn with_proof_materials(
154        self,
155        query_material: Arc<CircomGroth16Material>,
156        nullifier_material: Arc<CircomGroth16Material>,
157    ) -> Self {
158        Self {
159            query_material: Some(query_material),
160            nullifier_material: Some(nullifier_material),
161            ..self
162        }
163    }
164
165    /// Registers a new World ID in the `WorldIDRegistry`.
166    ///
167    /// Given the registration process is asynchronous, this method will return a `InitializingAuthenticator`
168    /// object.
169    ///
170    /// # Errors
171    /// - See `init` for additional error details.
172    pub async fn register(
173        seed: &[u8],
174        config: Config,
175        recovery_address: Option<Address>,
176    ) -> Result<InitializingAuthenticator, AuthenticatorError> {
177        let http_client = reqwest::Client::new();
178        InitializingAuthenticator::new(seed, config, recovery_address, http_client).await
179    }
180
181    /// Initializes (if the World ID already exists in the registry) or registers a new World ID.
182    ///
183    /// The registration process is asynchronous and may take some time. This method will block
184    /// the thread until the registration is in a final state (success or terminal error). For better
185    /// user experience in end authenticator clients, it is recommended to implement custom polling logic.
186    ///
187    /// Explicit `init` or `register` calls are also recommended as the authenticator should know
188    /// if a new World ID should be truly created. For example, an authenticator may have been revoked
189    /// access to an existing World ID.
190    ///
191    /// # Errors
192    /// - See `init` for additional error details.
193    pub async fn init_or_register(
194        seed: &[u8],
195        config: Config,
196        recovery_address: Option<Address>,
197    ) -> Result<Self, AuthenticatorError> {
198        match Self::init(seed, config.clone()).await {
199            Ok(authenticator) => Ok(authenticator),
200            Err(AuthenticatorError::AccountDoesNotExist) => {
201                // Authenticator is not registered, create it.
202                let http_client = reqwest::Client::new();
203                let initializing_authenticator = InitializingAuthenticator::new(
204                    seed,
205                    config.clone(),
206                    recovery_address,
207                    http_client,
208                )
209                .await?;
210
211                let backoff = backon::ExponentialBuilder::default()
212                    .with_min_delay(std::time::Duration::from_millis(800))
213                    .with_factor(1.5)
214                    .without_max_times()
215                    .with_total_delay(Some(std::time::Duration::from_secs(120)));
216
217                let poller = || async {
218                    let poll_status = initializing_authenticator.poll_status().await;
219                    let result = match poll_status {
220                        Ok(GatewayRequestState::Finalized { .. }) => Ok(()),
221                        Ok(GatewayRequestState::Failed { error_code, error }) => Err(
222                            PollResult::TerminalError(AuthenticatorError::RegistrationError {
223                                error_code: error_code.map(|v| v.to_string()).unwrap_or_default(),
224                                error_message: error,
225                            }),
226                        ),
227                        Err(AuthenticatorError::GatewayError { status, body }) => {
228                            if status.is_client_error() {
229                                Err(PollResult::TerminalError(
230                                    AuthenticatorError::GatewayError { status, body },
231                                ))
232                            } else {
233                                Err(PollResult::Retryable)
234                            }
235                        }
236                        _ => Err(PollResult::Retryable),
237                    };
238
239                    match result {
240                        Ok(()) => match Self::init(seed, config.clone()).await {
241                            Ok(auth) => Ok(auth),
242                            Err(AuthenticatorError::AccountDoesNotExist) => {
243                                Err(PollResult::Retryable)
244                            }
245                            Err(e) => Err(PollResult::TerminalError(e)),
246                        },
247                        Err(e) => Err(e),
248                    }
249                };
250
251                let result = backon::Retryable::retry(poller, backoff)
252                    .when(|e| matches!(e, PollResult::Retryable))
253                    .await;
254
255                match result {
256                    Ok(authenticator) => Ok(authenticator),
257                    Err(PollResult::TerminalError(e)) => Err(e),
258                    Err(PollResult::Retryable) => Err(AuthenticatorError::Timeout),
259                }
260            }
261            Err(e) => Err(e),
262        }
263    }
264
265    /// Returns the packed account data for the holder's World ID.
266    ///
267    /// The packed account data is a 256 bit integer which includes the World ID's leaf index, their recovery counter,
268    /// and their pubkey id/commitment.
269    ///
270    /// # Errors
271    /// Will error if the network call fails or if the account does not exist.
272    pub async fn get_packed_account_data(
273        onchain_signer_address: Address,
274        registry: Option<&WorldIdRegistryInstance<DynProvider>>,
275        config: &Config,
276        http_client: &reqwest::Client,
277    ) -> Result<U256, AuthenticatorError> {
278        // If the registry is available through direct RPC calls, use it. Otherwise fallback to the indexer.
279        let raw_index = if let Some(registry) = registry {
280            // TODO: Better error handling to expose the specific failure
281            registry
282                .getPackedAccountData(onchain_signer_address)
283                .call()
284                .await?
285        } else {
286            let url = format!("{}/packed-account", config.indexer_url());
287            let req = IndexerPackedAccountRequest {
288                authenticator_address: onchain_signer_address,
289            };
290            let resp = http_client.post(&url).json(&req).send().await?;
291            let status = resp.status();
292            if !status.is_success() {
293                let body = Self::response_body_or_fallback(resp).await;
294                if let Ok(error_resp) =
295                    serde_json::from_str::<ServiceApiError<IndexerErrorCode>>(&body)
296                {
297                    return match error_resp.code {
298                        IndexerErrorCode::AccountDoesNotExist => {
299                            Err(AuthenticatorError::AccountDoesNotExist)
300                        }
301                        _ => Err(AuthenticatorError::IndexerError {
302                            status,
303                            body: error_resp.message,
304                        }),
305                    };
306                }
307                return Err(AuthenticatorError::IndexerError { status, body });
308            }
309
310            let response: IndexerPackedAccountResponse = resp.json().await?;
311            response.packed_account_data
312        };
313
314        if raw_index == U256::ZERO {
315            return Err(AuthenticatorError::AccountDoesNotExist);
316        }
317
318        Ok(raw_index)
319    }
320
321    /// Returns the k256 public key of the Authenticator signer which is used to verify on-chain operations,
322    /// chiefly with the `WorldIdRegistry` contract.
323    #[must_use]
324    pub const fn onchain_address(&self) -> Address {
325        self.signer.onchain_signer_address()
326    }
327
328    /// Returns the `EdDSA` public key of the Authenticator signer which is used to verify off-chain operations. For example,
329    /// the Nullifier Oracle uses it to verify requests for nullifiers.
330    #[must_use]
331    pub fn offchain_pubkey(&self) -> EdDSAPublicKey {
332        self.signer.offchain_signer_pubkey()
333    }
334
335    /// Returns the compressed `EdDSA` public key of the Authenticator signer which is used to verify off-chain operations.
336    /// For example, the Nullifier Oracle uses it to verify requests for nullifiers.
337    /// # Errors
338    /// Will error if the public key cannot be serialized.
339    pub fn offchain_pubkey_compressed(&self) -> Result<U256, AuthenticatorError> {
340        let pk = self.signer.offchain_signer_pubkey().pk;
341        let mut compressed_bytes = Vec::new();
342        pk.serialize_compressed(&mut compressed_bytes)
343            .map_err(|e| PrimitiveError::Serialization(e.to_string()))?;
344        Ok(U256::from_le_slice(&compressed_bytes))
345    }
346
347    /// Returns a reference to the `WorldIdRegistry` contract instance.
348    #[must_use]
349    pub fn registry(&self) -> Option<Arc<WorldIdRegistryInstance<DynProvider>>> {
350        self.registry.clone()
351    }
352
353    /// Returns the index for the holder's World ID.
354    ///
355    /// # Definition
356    ///
357    /// The `leaf_index` is the main (internal) identifier of a World ID. It is registered in
358    /// the `WorldIDRegistry` and represents the index at the Merkle tree where the World ID
359    /// resides.
360    ///
361    /// # Notes
362    /// - The `leaf_index` is used as input in the nullifier generation, ensuring a nullifier
363    ///   will always be the same for the same RP context and the same World ID (allowing for uniqueness).
364    /// - The `leaf_index` is generally not exposed outside Authenticators. It is not a secret because
365    ///   it's not exposed to RPs outside ZK-circuits, but the only acceptable exposure outside an Authenticator
366    ///   is to fetch Merkle inclusion proofs from an indexer or it may create a pseudonymous identifier.
367    /// - The `leaf_index` is stored as a `uint64` inside packed account data.
368    #[must_use]
369    pub fn leaf_index(&self) -> u64 {
370        (self.packed_account_data & MASK_LEAF_INDEX).to::<u64>()
371    }
372
373    /// Returns the recovery counter for the holder's World ID.
374    ///
375    /// The recovery counter is used to efficiently invalidate all the old keys when an account is recovered.
376    #[must_use]
377    pub fn recovery_counter(&self) -> U256 {
378        let recovery_counter = self.packed_account_data & MASK_RECOVERY_COUNTER;
379        recovery_counter >> 224
380    }
381
382    /// Returns the pubkey id (or commitment) for the holder's World ID.
383    ///
384    /// This is a commitment to all the off-chain public keys that are authorized to act on behalf of the holder.
385    #[must_use]
386    pub fn pubkey_id(&self) -> U256 {
387        let pubkey_id = self.packed_account_data & MASK_PUBKEY_ID;
388        pubkey_id >> 192
389    }
390
391    /// Fetches a Merkle inclusion proof for the holder's World ID given their account index.
392    ///
393    /// # Errors
394    /// - Will error if the provided indexer URL is not valid or if there are HTTP call failures.
395    /// - Will error if the user is not registered on the `WorldIDRegistry`.
396    pub async fn fetch_inclusion_proof(
397        &self,
398    ) -> Result<(MerkleInclusionProof<TREE_DEPTH>, AuthenticatorPublicKeySet), AuthenticatorError>
399    {
400        let url = format!("{}/inclusion-proof", self.config.indexer_url());
401        let req = IndexerQueryRequest {
402            leaf_index: self.leaf_index(),
403        };
404        let response = self.http_client.post(&url).json(&req).send().await?;
405        let status = response.status();
406        if !status.is_success() {
407            return Err(AuthenticatorError::IndexerError {
408                status,
409                body: Self::response_body_or_fallback(response).await,
410            });
411        }
412        let response = response.json::<AccountInclusionProof<TREE_DEPTH>>().await?;
413
414        Ok((response.inclusion_proof, response.authenticator_pubkeys))
415    }
416
417    /// Fetches the current authenticator public key set for the account.
418    ///
419    /// This is used by mutation operations to compute old/new offchain signer commitments
420    /// without requiring Merkle proof generation.
421    ///
422    /// # Errors
423    /// - Will error if the provided indexer URL is not valid or if there are HTTP call failures.
424    /// - Will error if the user is not registered on the `WorldIDRegistry`.
425    pub async fn fetch_authenticator_pubkeys(
426        &self,
427    ) -> Result<AuthenticatorPublicKeySet, AuthenticatorError> {
428        let url = format!("{}/authenticator-pubkeys", self.config.indexer_url());
429        let req = IndexerQueryRequest {
430            leaf_index: self.leaf_index(),
431        };
432        let response = self.http_client.post(&url).json(&req).send().await?;
433        let status = response.status();
434        if !status.is_success() {
435            return Err(AuthenticatorError::IndexerError {
436                status,
437                body: Self::response_body_or_fallback(response).await,
438            });
439        }
440        let response = response
441            .json::<IndexerAuthenticatorPubkeysResponse>()
442            .await?;
443        Self::decode_indexer_pubkeys(response.authenticator_pubkeys)
444    }
445
446    /// Returns the signing nonce for the holder's World ID.
447    ///
448    /// # Errors
449    /// Will return an error if the registry contract call fails.
450    pub async fn signing_nonce(&self) -> Result<U256, AuthenticatorError> {
451        let registry = self.registry();
452        if let Some(registry) = registry {
453            let nonce = registry.getSignatureNonce(self.leaf_index()).call().await?;
454            Ok(nonce)
455        } else {
456            let url = format!("{}/signature-nonce", self.config.indexer_url());
457            let req = IndexerQueryRequest {
458                leaf_index: self.leaf_index(),
459            };
460            let resp = self.http_client.post(&url).json(&req).send().await?;
461
462            let status = resp.status();
463            if !status.is_success() {
464                return Err(AuthenticatorError::IndexerError {
465                    status,
466                    body: Self::response_body_or_fallback(resp).await,
467                });
468            }
469
470            let response: IndexerSignatureNonceResponse = resp.json().await?;
471            Ok(response.signature_nonce)
472        }
473    }
474
475    /// Signs an arbitrary challenge with the authenticator's on-chain key following
476    /// [ERC-191](https://eips.ethereum.org/EIPS/eip-191).
477    ///
478    /// # Warning
479    /// This is considered a dangerous operation because it leaks the user's on-chain key,
480    /// hence its `leaf_index`. The only acceptable use is to prove the user's `leaf_index`
481    /// to a Recovery Agent. The Recovery Agent is the only party beyond the user who needs
482    /// to know the `leaf_index`.
483    ///
484    /// # Use
485    /// - This method is used to prove ownership over a leaf index **only for Recovery Agents**.
486    pub fn danger_sign_challenge(&self, challenge: &[u8]) -> Result<Signature, AuthenticatorError> {
487        self.signer
488            .onchain_signer()
489            .sign_message_sync(challenge)
490            .map_err(|e| AuthenticatorError::Generic(format!("signature error: {e}")))
491    }
492
493    /// Checks that the OPRF Nodes configuration is valid and returns the list of URLs and the threshold to use.
494    ///
495    /// # Errors
496    /// Will return an error if there are no OPRF Nodes configured or if the threshold is invalid.
497    fn check_oprf_config(&self) -> Result<(&[String], usize), AuthenticatorError> {
498        let services = self.config.nullifier_oracle_urls();
499        if services.is_empty() {
500            return Err(AuthenticatorError::Generic(
501                "No nullifier oracle URLs configured".to_string(),
502            ));
503        }
504        let requested_threshold = self.config.nullifier_oracle_threshold();
505        if requested_threshold == 0 {
506            return Err(AuthenticatorError::InvalidConfig {
507                attribute: "nullifier_oracle_threshold",
508                reason: "must be at least 1".to_string(),
509            });
510        }
511        let threshold = requested_threshold.min(services.len());
512        Ok((services, threshold))
513    }
514
515    fn decode_indexer_pubkeys(
516        pubkeys: Vec<Option<U256>>,
517    ) -> Result<AuthenticatorPublicKeySet, AuthenticatorError> {
518        decode_sparse_authenticator_pubkeys(pubkeys).map_err(|e| match e {
519            SparseAuthenticatorPubkeysError::SlotOutOfBounds {
520                slot_index,
521                max_supported_slot,
522            } => AuthenticatorError::InvalidIndexerPubkeySlot {
523                slot_index,
524                max_supported_slot,
525            },
526            SparseAuthenticatorPubkeysError::InvalidCompressedPubkey { slot_index, reason } => {
527                PrimitiveError::Deserialization(format!(
528                    "invalid authenticator public key returned by indexer at slot {slot_index}: {reason}"
529                ))
530                .into()
531            }
532        })
533    }
534
535    fn insert_or_reuse_authenticator_key(
536        key_set: &mut AuthenticatorPublicKeySet,
537        new_authenticator_pubkey: EdDSAPublicKey,
538    ) -> Result<usize, AuthenticatorError> {
539        if let Some(index) = key_set.iter().position(Option::is_none) {
540            key_set.try_set_at_index(index, new_authenticator_pubkey)?;
541            Ok(index)
542        } else {
543            key_set.try_push(new_authenticator_pubkey)?;
544            Ok(key_set.len() - 1)
545        }
546    }
547
548    /// Generates a nullifier for a World ID Proof (through OPRF Nodes).
549    ///
550    /// A [`Nullifier`] is a unique, one-time use, anonymous identifier for a World ID
551    /// on a specific RP context. See [`Nullifier`] for more details.
552    ///
553    /// A Nullifier takes an `action` as input:
554    /// - If `proof_request` is for a Session Proof, a random internal `action` is generated. This
555    ///   is opaque to RPs, and verified internally in the verification contract.
556    /// - If `proof_request` is for a Uniqueness Proof, the `action` is provided by the RP,
557    ///   if not provided a default of [`FieldElement::ZERO`] is used.
558    ///
559    /// # Errors
560    ///
561    /// - Will raise a [`ProofError`] if there is any issue generating the nullifier. For example,
562    ///   network issues, unexpected incorrect responses from OPRF Nodes.
563    /// - Raises an error if the OPRF Nodes configuration is not correctly set.
564    pub async fn generate_nullifier(
565        &self,
566        proof_request: &ProofRequest,
567        inclusion_proof: MerkleInclusionProof<TREE_DEPTH>,
568        key_set: AuthenticatorPublicKeySet,
569    ) -> Result<FullOprfOutput, AuthenticatorError> {
570        let mut rng = rand::rngs::OsRng;
571
572        let (services, threshold) = self.check_oprf_config()?;
573
574        let query_material = self
575            .query_material
576            .as_ref()
577            .ok_or(AuthenticatorError::ProofMaterialsNotLoaded)?;
578
579        let key_index = key_set
580            .iter()
581            .position(|pk| {
582                pk.as_ref()
583                    .is_some_and(|pk| pk.pk == self.offchain_pubkey().pk)
584            })
585            .ok_or(AuthenticatorError::PublicKeyNotFound)? as u64;
586
587        let authenticator_input = AuthenticatorProofInput::new(
588            key_set,
589            inclusion_proof,
590            self.signer
591                .offchain_signer_private_key()
592                .expose_secret()
593                .clone(),
594            key_index,
595        );
596
597        let oprf_entry_point = OprfEntrypoint::new(
598            services,
599            threshold,
600            query_material,
601            &authenticator_input,
602            &self.ws_connector,
603        );
604        Ok(oprf_entry_point
605            .gen_nullifier(&mut rng, proof_request)
606            .await?)
607    }
608
609    // TODO add more docs
610    /// Generates a blinding factor for a Credential sub (through OPRF Nodes).
611    ///
612    /// # Errors
613    ///
614    /// - Will raise a [`ProofError`] if there is any issue generating the blinding factor.
615    ///   For example, network issues, unexpected incorrect responses from OPRF Nodes.
616    /// - Raises an error if the OPRF Nodes configuration is not correctly set.
617    pub async fn generate_credential_blinding_factor(
618        &self,
619        issuer_schema_id: u64,
620    ) -> Result<FieldElement, AuthenticatorError> {
621        let mut rng = rand::rngs::OsRng;
622        let (services, threshold) = self.check_oprf_config()?;
623
624        let query_material = self
625            .query_material
626            .as_ref()
627            .ok_or(AuthenticatorError::ProofMaterialsNotLoaded)?;
628
629        let (inclusion_proof, key_set) = self.fetch_inclusion_proof().await?;
630        let key_index = key_set
631            .iter()
632            .position(|pk| {
633                pk.as_ref()
634                    .is_some_and(|pk| pk.pk == self.offchain_pubkey().pk)
635            })
636            .ok_or(AuthenticatorError::PublicKeyNotFound)? as u64;
637
638        let authenticator_input = AuthenticatorProofInput::new(
639            key_set,
640            inclusion_proof,
641            self.signer
642                .offchain_signer_private_key()
643                .expose_secret()
644                .clone(),
645            key_index,
646        );
647
648        let oprf_entry_point = OprfEntrypoint::new(
649            services,
650            threshold,
651            query_material,
652            &authenticator_input,
653            &self.ws_connector,
654        );
655
656        let (blinding_factor, _share_epoch) = oprf_entry_point
657            .gen_credential_blinding_factor(&mut rng, issuer_schema_id)
658            .await?;
659
660        Ok(blinding_factor)
661    }
662
663    /// Creates a Session for a World ID with an RP.
664    ///
665    /// Internally, this generates the session's random seed (`r`) using OPRF Nodes. This seed is used to
666    /// compute the [`SessionId::commitment`] for Session Proofs.
667    ///
668    /// # Returns
669    /// - `session_id`: The generated [`SessionId`] to be shared with the requesting RP.
670    /// - `session_id_r_seed`: The `r` value used for this session so the Authenticator can cache it.
671    ///
672    /// # Seed (`session_id_r_seed`)
673    /// - If a `session_id_r_seed` (`r`) is not provided, it'll be derived/re-derived with the OPRF nodes.
674    /// - Even if `r` has been generated before, the same `r` will be computed agaian for the same
675    ///   context (i.e. `rpId`, [`SessionId::oprf_seed`]). This means caching `r` is optional but recommended.
676    /// -  Caching behavior is the responsibility of the Authenticator (and/or its relevant SDKs), not this crate.
677    /// - More information about the seed can be found in [`SessionId::from_r_seed`].
678    pub async fn generate_session_id(
679        &self,
680        proof_request: &ProofRequest,
681        session_id_r_seed: Option<FieldElement>,
682    ) -> Result<(SessionId, FieldElement), AuthenticatorError> {
683        let mut rng = rand::rngs::OsRng;
684
685        // TODO: Generate using OPRF Nodes with `oprf_seed` as input
686        let session_id_r_seed = session_id_r_seed.unwrap_or(FieldElement::random(&mut rng));
687
688        let session_id = SessionId::from_r_seed(
689            self.leaf_index(),
690            session_id_r_seed,
691            proof_request.session_id.map(|v| v.oprf_seed()),
692            &mut rng,
693        )
694        .map_err(|_| AuthenticatorError::InvalidSessionId)?;
695
696        if let Some(request_session_id) = proof_request.session_id {
697            if request_session_id != session_id {
698                return Err(AuthenticatorError::SessionIdMismatch);
699            }
700        }
701
702        Ok((session_id, session_id_r_seed))
703    }
704
705    /// Generates a single World ID Proof from a provided `[ProofRequest]` and `[Credential]`. This
706    /// method generates the raw proof to be translated into a Uniqueness Proof or a Session Proof for the RP.
707    ///
708    /// This assumes the RP's `[ProofRequest]` has already been parsed to determine
709    /// which `[Credential]` is appropriate for the request. This method responds to a
710    /// specific `[RequestItem]` (a `[ProofRequest]` may contain multiple items).
711    ///
712    /// # Arguments
713    /// - `oprf_nullifier`: The output representing the nullifier, generated from the `generate_nullifier` function. All proofs
714    ///   require this attribute.
715    /// - `request_item`: The specific `RequestItem` that is being resolved from the RP's `ProofRequest`.
716    /// - `credential`: The Credential to be used for the proof that fulfills the `RequestItem`.
717    /// - `credential_sub_blinding_factor`: The blinding factor for the Credential's sub.
718    /// - `session_id_r_seed`: The session ID random seed, obtained via [`generate_session_id`](Self::generate_session_id).
719    ///   For Uniqueness Proofs (when `session_id` is `None`), this value is ignored by the circuit
720    ///   but must still be provided.
721    /// - `session_id`: The expected session ID provided by the RP. Only needed for Session Proofs. Obtained from the RP's [`ProofRequest`].
722    /// - `request_timestamp`: The timestamp of the request. Obtained from the RP's [`ProofRequest`].
723    ///
724    /// # Errors
725    /// - Will error if the any of the provided parameters are not valid.
726    /// - Will error if any of the required network requests fail.
727    /// - Will error if the user does not have a registered World ID.
728    #[allow(clippy::too_many_arguments)]
729    pub fn generate_single_proof(
730        &self,
731        oprf_nullifier: FullOprfOutput,
732        request_item: &RequestItem,
733        credential: &Credential,
734        credential_sub_blinding_factor: FieldElement,
735        session_id_r_seed: FieldElement,
736        session_id: Option<SessionId>,
737        request_timestamp: u64,
738    ) -> Result<ResponseItem, AuthenticatorError> {
739        let mut rng = rand::rngs::OsRng;
740
741        let merkle_root: FieldElement = oprf_nullifier.query_proof_input.merkle_root.into();
742        let action_from_query: FieldElement = oprf_nullifier.query_proof_input.action.into();
743
744        let expires_at_min = request_item.effective_expires_at_min(request_timestamp);
745
746        let nullifier_material = self
747            .nullifier_material
748            .as_ref()
749            .ok_or(AuthenticatorError::ProofMaterialsNotLoaded)?;
750
751        let (proof, _public_inputs, nullifier) = generate_nullifier_proof(
752            nullifier_material,
753            &mut rng,
754            credential,
755            credential_sub_blinding_factor,
756            oprf_nullifier,
757            request_item,
758            session_id.map(|v| v.commitment()),
759            session_id_r_seed,
760            expires_at_min,
761        )?;
762
763        let proof = ZeroKnowledgeProof::from_groth16_proof(&proof, merkle_root);
764
765        // Construct the appropriate response item based on proof type
766        let nullifier_fe: FieldElement = nullifier.into();
767        let response_item = if session_id.is_some() {
768            let session_nullifier = SessionNullifier::new(nullifier_fe, action_from_query);
769            ResponseItem::new_session(
770                request_item.identifier.clone(),
771                request_item.issuer_schema_id,
772                proof,
773                session_nullifier,
774                expires_at_min,
775            )
776        } else {
777            ResponseItem::new_uniqueness(
778                request_item.identifier.clone(),
779                request_item.issuer_schema_id,
780                proof,
781                nullifier_fe.into(),
782                expires_at_min,
783            )
784        };
785
786        Ok(response_item)
787    }
788
789    /// Inserts a new authenticator to the account.
790    ///
791    /// # Errors
792    /// Will error if the provided RPC URL is not valid or if there are HTTP call failures.
793    ///
794    /// # Note
795    /// TODO: After successfully inserting an authenticator, the `packed_account_data` should be
796    /// refreshed from the registry to reflect the new `pubkey_id` commitment.
797    pub async fn insert_authenticator(
798        &self,
799        new_authenticator_pubkey: EdDSAPublicKey,
800        new_authenticator_address: Address,
801    ) -> Result<String, AuthenticatorError> {
802        let leaf_index = self.leaf_index();
803        let nonce = self.signing_nonce().await?;
804        let mut key_set = self.fetch_authenticator_pubkeys().await?;
805        let old_offchain_signer_commitment = key_set.leaf_hash();
806        let encoded_offchain_pubkey = new_authenticator_pubkey.to_ethereum_representation()?;
807        let index =
808            Self::insert_or_reuse_authenticator_key(&mut key_set, new_authenticator_pubkey)?;
809        let new_offchain_signer_commitment = key_set.leaf_hash();
810
811        let eip712_domain = domain(self.config.chain_id(), *self.config.registry_address());
812
813        #[allow(clippy::cast_possible_truncation)]
814        // truncating is intentional, and index will always fit in 32 bits
815        let signature = sign_insert_authenticator(
816            &self.signer.onchain_signer(),
817            leaf_index,
818            new_authenticator_address,
819            index as u32,
820            encoded_offchain_pubkey,
821            new_offchain_signer_commitment.into(),
822            nonce,
823            &eip712_domain,
824        )
825        .map_err(|e| {
826            AuthenticatorError::Generic(format!("Failed to sign insert authenticator: {e}"))
827        })?;
828
829        #[allow(clippy::cast_possible_truncation)]
830        // truncating is intentional, and index will always fit in 32 bits
831        let req = InsertAuthenticatorRequest {
832            leaf_index,
833            new_authenticator_address,
834            pubkey_id: index as u32,
835            new_authenticator_pubkey: encoded_offchain_pubkey,
836            old_offchain_signer_commitment: old_offchain_signer_commitment.into(),
837            new_offchain_signer_commitment: new_offchain_signer_commitment.into(),
838            signature: signature.as_bytes().to_vec(),
839            nonce,
840        };
841
842        let resp = self
843            .http_client
844            .post(format!(
845                "{}/insert-authenticator",
846                self.config.gateway_url()
847            ))
848            .json(&req)
849            .send()
850            .await?;
851
852        let status = resp.status();
853        if status.is_success() {
854            let body: GatewayStatusResponse = resp.json().await?;
855            Ok(body.request_id)
856        } else {
857            let body_text = Self::response_body_or_fallback(resp).await;
858            Err(AuthenticatorError::GatewayError {
859                status,
860                body: body_text,
861            })
862        }
863    }
864
865    /// Updates an existing authenticator slot with a new authenticator.
866    ///
867    /// # Errors
868    /// Returns an error if the gateway rejects the request or a network error occurs.
869    ///
870    /// # Note
871    /// TODO: After successfully updating an authenticator, the `packed_account_data` should be
872    /// refreshed from the registry to reflect the new `pubkey_id` commitment.
873    pub async fn update_authenticator(
874        &self,
875        old_authenticator_address: Address,
876        new_authenticator_address: Address,
877        new_authenticator_pubkey: EdDSAPublicKey,
878        index: u32,
879    ) -> Result<String, AuthenticatorError> {
880        let leaf_index = self.leaf_index();
881        let nonce = self.signing_nonce().await?;
882        let mut key_set = self.fetch_authenticator_pubkeys().await?;
883        let old_commitment: U256 = key_set.leaf_hash().into();
884        let encoded_offchain_pubkey = new_authenticator_pubkey.to_ethereum_representation()?;
885        key_set.try_set_at_index(index as usize, new_authenticator_pubkey)?;
886        let new_commitment: U256 = key_set.leaf_hash().into();
887
888        let eip712_domain = domain(self.config.chain_id(), *self.config.registry_address());
889
890        let signature = sign_update_authenticator(
891            &self.signer.onchain_signer(),
892            leaf_index,
893            old_authenticator_address,
894            new_authenticator_address,
895            index,
896            encoded_offchain_pubkey,
897            new_commitment,
898            nonce,
899            &eip712_domain,
900        )
901        .map_err(|e| {
902            AuthenticatorError::Generic(format!("Failed to sign update authenticator: {e}"))
903        })?;
904
905        let req = UpdateAuthenticatorRequest {
906            leaf_index,
907            old_authenticator_address,
908            new_authenticator_address,
909            old_offchain_signer_commitment: old_commitment,
910            new_offchain_signer_commitment: new_commitment,
911            signature: signature.as_bytes().to_vec(),
912            nonce,
913            pubkey_id: index,
914            new_authenticator_pubkey: encoded_offchain_pubkey,
915        };
916
917        let resp = self
918            .http_client
919            .post(format!(
920                "{}/update-authenticator",
921                self.config.gateway_url()
922            ))
923            .json(&req)
924            .send()
925            .await?;
926
927        let status = resp.status();
928        if status.is_success() {
929            let gateway_resp: GatewayStatusResponse = resp.json().await?;
930            Ok(gateway_resp.request_id)
931        } else {
932            let body_text = Self::response_body_or_fallback(resp).await;
933            Err(AuthenticatorError::GatewayError {
934                status,
935                body: body_text,
936            })
937        }
938    }
939
940    /// Removes an authenticator from the account.
941    ///
942    /// # Errors
943    /// Returns an error if the gateway rejects the request or a network error occurs.
944    ///
945    /// # Note
946    /// TODO: After successfully removing an authenticator, the `packed_account_data` should be
947    /// refreshed from the registry to reflect the new `pubkey_id` commitment.
948    pub async fn remove_authenticator(
949        &self,
950        authenticator_address: Address,
951        index: u32,
952    ) -> Result<String, AuthenticatorError> {
953        let leaf_index = self.leaf_index();
954        let nonce = self.signing_nonce().await?;
955        let mut key_set = self.fetch_authenticator_pubkeys().await?;
956        let old_commitment: U256 = key_set.leaf_hash().into();
957        let existing_pubkey = key_set
958            .get(index as usize)
959            .ok_or(AuthenticatorError::PublicKeyNotFound)?;
960
961        let encoded_old_offchain_pubkey = existing_pubkey.to_ethereum_representation()?;
962
963        key_set.try_clear_at_index(index as usize)?;
964        let new_commitment: U256 = key_set.leaf_hash().into();
965
966        let eip712_domain = domain(self.config.chain_id(), *self.config.registry_address());
967
968        let signature = sign_remove_authenticator(
969            &self.signer.onchain_signer(),
970            leaf_index,
971            authenticator_address,
972            index,
973            encoded_old_offchain_pubkey,
974            new_commitment,
975            nonce,
976            &eip712_domain,
977        )
978        .map_err(|e| {
979            AuthenticatorError::Generic(format!("Failed to sign remove authenticator: {e}"))
980        })?;
981
982        let req = RemoveAuthenticatorRequest {
983            leaf_index,
984            authenticator_address,
985            old_offchain_signer_commitment: old_commitment,
986            new_offchain_signer_commitment: new_commitment,
987            signature: signature.as_bytes().to_vec(),
988            nonce,
989            pubkey_id: Some(index),
990            authenticator_pubkey: Some(encoded_old_offchain_pubkey),
991        };
992
993        let resp = self
994            .http_client
995            .post(format!(
996                "{}/remove-authenticator",
997                self.config.gateway_url()
998            ))
999            .json(&req)
1000            .send()
1001            .await?;
1002
1003        let status = resp.status();
1004        if status.is_success() {
1005            let gateway_resp: GatewayStatusResponse = resp.json().await?;
1006            Ok(gateway_resp.request_id)
1007        } else {
1008            let body_text = Self::response_body_or_fallback(resp).await;
1009            Err(AuthenticatorError::GatewayError {
1010                status,
1011                body: body_text,
1012            })
1013        }
1014    }
1015}
1016
1017/// Represents an account in the process of being initialized,
1018/// i.e. it is not yet registered in the `WorldIDRegistry` contract.
1019pub struct InitializingAuthenticator {
1020    request_id: String,
1021    http_client: reqwest::Client,
1022    config: Config,
1023}
1024
1025impl InitializingAuthenticator {
1026    /// Returns the gateway request ID for this pending account creation.
1027    #[must_use]
1028    pub fn request_id(&self) -> &str {
1029        &self.request_id
1030    }
1031
1032    /// Creates a new World ID account by adding it to the registry using the gateway.
1033    ///
1034    /// # Errors
1035    /// - See `Signer::from_seed_bytes` for additional error details.
1036    /// - Will error if the gateway rejects the request or a network error occurs.
1037    async fn new(
1038        seed: &[u8],
1039        config: Config,
1040        recovery_address: Option<Address>,
1041        http_client: reqwest::Client,
1042    ) -> Result<Self, AuthenticatorError> {
1043        let signer = Signer::from_seed_bytes(seed)?;
1044
1045        let mut key_set = AuthenticatorPublicKeySet::default();
1046        key_set.try_push(signer.offchain_signer_pubkey())?;
1047        let leaf_hash = key_set.leaf_hash();
1048
1049        let offchain_pubkey_compressed = {
1050            let pk = signer.offchain_signer_pubkey().pk;
1051            let mut compressed_bytes = Vec::new();
1052            pk.serialize_compressed(&mut compressed_bytes)
1053                .map_err(|e| PrimitiveError::Serialization(e.to_string()))?;
1054            U256::from_le_slice(&compressed_bytes)
1055        };
1056
1057        let req = CreateAccountRequest {
1058            recovery_address,
1059            authenticator_addresses: vec![signer.onchain_signer_address()],
1060            authenticator_pubkeys: vec![offchain_pubkey_compressed],
1061            offchain_signer_commitment: leaf_hash.into(),
1062        };
1063
1064        let resp = http_client
1065            .post(format!("{}/create-account", config.gateway_url()))
1066            .json(&req)
1067            .send()
1068            .await?;
1069
1070        let status = resp.status();
1071        if status.is_success() {
1072            let body: GatewayStatusResponse = resp.json().await?;
1073            Ok(Self {
1074                request_id: body.request_id,
1075                http_client,
1076                config,
1077            })
1078        } else {
1079            let body_text = Authenticator::response_body_or_fallback(resp).await;
1080            Err(AuthenticatorError::GatewayError {
1081                status,
1082                body: body_text,
1083            })
1084        }
1085    }
1086
1087    /// Poll the status of the World ID creation request.
1088    ///
1089    /// # Errors
1090    /// - Will error if the network request fails.
1091    /// - Will error if the gateway returns an error response.
1092    pub async fn poll_status(&self) -> Result<GatewayRequestState, AuthenticatorError> {
1093        let resp = self
1094            .http_client
1095            .get(format!(
1096                "{}/status/{}",
1097                self.config.gateway_url(),
1098                self.request_id
1099            ))
1100            .send()
1101            .await?;
1102
1103        let status = resp.status();
1104
1105        if status.is_success() {
1106            let body: GatewayStatusResponse = resp.json().await?;
1107            Ok(body.status)
1108        } else {
1109            let body_text = Authenticator::response_body_or_fallback(resp).await;
1110            Err(AuthenticatorError::GatewayError {
1111                status,
1112                body: body_text,
1113            })
1114        }
1115    }
1116}
1117
1118impl ProtocolSigner for Authenticator {
1119    fn sign(&self, message: FieldElement) -> EdDSASignature {
1120        self.signer
1121            .offchain_signer_private_key()
1122            .expose_secret()
1123            .sign(*message)
1124    }
1125}
1126
1127/// A trait for types that can be represented as a `U256` on-chain.
1128pub trait OnchainKeyRepresentable {
1129    /// Converts an off-chain public key into a `U256` representation for on-chain use in the `WorldIDRegistry` contract.
1130    ///
1131    /// The `U256` representation is a 32-byte little-endian encoding of the **compressed** (single point) public key.
1132    ///
1133    /// # Errors
1134    /// Will error if the public key unexpectedly fails to serialize.
1135    fn to_ethereum_representation(&self) -> Result<U256, PrimitiveError>;
1136}
1137
1138impl OnchainKeyRepresentable for EdDSAPublicKey {
1139    // REVIEW: updating to BE
1140    fn to_ethereum_representation(&self) -> Result<U256, PrimitiveError> {
1141        let mut compressed_bytes = Vec::new();
1142        self.pk
1143            .serialize_compressed(&mut compressed_bytes)
1144            .map_err(|e| PrimitiveError::Serialization(e.to_string()))?;
1145        Ok(U256::from_le_slice(&compressed_bytes))
1146    }
1147}
1148
1149/// Errors that can occur when interacting with the Authenticator.
1150#[derive(Debug, thiserror::Error)]
1151pub enum AuthenticatorError {
1152    /// Primitive error
1153    #[error(transparent)]
1154    PrimitiveError(#[from] PrimitiveError),
1155
1156    /// This operation requires a registered account and an account is not registered
1157    /// for this authenticator. Call `create_account` first to register it.
1158    #[error("Account is not registered for this authenticator.")]
1159    AccountDoesNotExist,
1160
1161    /// An error occurred while interacting with the EVM contract.
1162    #[error("Error interacting with EVM contract: {0}")]
1163    ContractError(#[from] alloy::contract::Error),
1164
1165    /// Network/HTTP request error.
1166    #[error("Network error: {0}")]
1167    NetworkError(#[from] reqwest::Error),
1168
1169    /// Public key not found in the Authenticator public key set. Usually indicates the local state is out of sync with the registry.
1170    #[error("Public key not found.")]
1171    PublicKeyNotFound,
1172
1173    /// Gateway returned an error response.
1174    #[error("Gateway error (status {status}): {body}")]
1175    GatewayError {
1176        /// HTTP status code
1177        status: StatusCode,
1178        /// Response body
1179        body: String,
1180    },
1181
1182    /// Indexer returned an error response.
1183    #[error("Indexer error (status {status}): {body}")]
1184    IndexerError {
1185        /// HTTP status code
1186        status: StatusCode,
1187        /// Response body
1188        body: String,
1189    },
1190
1191    /// Account creation timed out while polling for confirmation.
1192    #[error("Account creation timed out")]
1193    Timeout,
1194
1195    /// Configuration is invalid or missing required values.
1196    #[error("Invalid configuration for {attribute}: {reason}")]
1197    InvalidConfig {
1198        /// The config attribute that is invalid.
1199        attribute: &'static str,
1200        /// Description of why it is invalid.
1201        reason: String,
1202    },
1203
1204    /// The provided credential is not valid for the provided proof request.
1205    #[error("The provided credential is not valid for the provided proof request")]
1206    InvalidCredentialForProofRequest,
1207
1208    /// Error during the World ID registration process.
1209    ///
1210    /// This usually occurs from an on-chain revert.
1211    #[error("Registration error ({error_code}): {error_message}")]
1212    RegistrationError {
1213        /// Error code from the registration process.
1214        error_code: String,
1215        /// Detailed error message.
1216        error_message: String,
1217    },
1218
1219    /// Error on proof generation
1220    #[error(transparent)]
1221    ProofError(#[from] ProofError),
1222
1223    /// Indexer returned an authenticator key slot that exceeds supported key capacity.
1224    #[error(
1225        "Invalid indexer authenticator pubkey slot {slot_index}; max supported slot is {max_supported_slot}"
1226    )]
1227    InvalidIndexerPubkeySlot {
1228        /// Slot index returned by the indexer.
1229        slot_index: usize,
1230        /// Highest supported slot index.
1231        max_supported_slot: usize,
1232    },
1233
1234    /// Proof materials not loaded. Call `with_proof_materials` before generating proofs.
1235    #[error("Proof materials not loaded. Call `with_proof_materials` before generating proofs.")]
1236    ProofMaterialsNotLoaded,
1237
1238    /// The session ID computed for this proof does not match the expected session ID from the proof request.
1239    ///
1240    /// This indicates the `session_id` provided by the RP is invalid or compromised, as
1241    /// the only other failure option is OPRFs not having performed correct computations.
1242    #[error("the expected session id and the generated session id do not match")]
1243    SessionIdMismatch,
1244
1245    /// The provided session ID is invalid.
1246    #[error("invalid session id")]
1247    InvalidSessionId,
1248
1249    /// Generic error for other unexpected issues.
1250    #[error("{0}")]
1251    Generic(String),
1252}
1253
1254#[derive(Debug)]
1255enum PollResult {
1256    Retryable,
1257    TerminalError(AuthenticatorError),
1258}
1259
1260#[cfg(test)]
1261mod tests {
1262    use super::*;
1263    use alloy::primitives::{U256, address};
1264    use world_id_primitives::authenticator::MAX_AUTHENTICATOR_KEYS;
1265
1266    fn test_pubkey(seed_byte: u8) -> EdDSAPublicKey {
1267        Signer::from_seed_bytes(&[seed_byte; 32])
1268            .unwrap()
1269            .offchain_signer_pubkey()
1270    }
1271
1272    fn encoded_test_pubkey(seed_byte: u8) -> U256 {
1273        test_pubkey(seed_byte).to_ethereum_representation().unwrap()
1274    }
1275
1276    #[test]
1277    fn test_insert_or_reuse_authenticator_key_reuses_empty_slot() {
1278        let mut key_set =
1279            AuthenticatorPublicKeySet::new(vec![test_pubkey(1), test_pubkey(2), test_pubkey(4)])
1280                .unwrap();
1281        key_set[1] = None;
1282        let new_key = test_pubkey(3);
1283
1284        let index =
1285            Authenticator::insert_or_reuse_authenticator_key(&mut key_set, new_key).unwrap();
1286
1287        assert_eq!(index, 1);
1288        assert_eq!(key_set.len(), 3);
1289        assert_eq!(key_set[1].as_ref().unwrap().pk, test_pubkey(3).pk);
1290    }
1291
1292    #[test]
1293    fn test_insert_or_reuse_authenticator_key_appends_when_no_empty_slot() {
1294        let mut key_set = AuthenticatorPublicKeySet::new(vec![test_pubkey(1)]).unwrap();
1295        let new_key = test_pubkey(2);
1296
1297        let index =
1298            Authenticator::insert_or_reuse_authenticator_key(&mut key_set, new_key).unwrap();
1299
1300        assert_eq!(index, 1);
1301        assert_eq!(key_set.len(), 2);
1302        assert_eq!(key_set[1].as_ref().unwrap().pk, test_pubkey(2).pk);
1303    }
1304
1305    #[test]
1306    fn test_decode_indexer_pubkeys_trims_trailing_empty_slots() {
1307        let mut encoded_pubkeys = vec![Some(encoded_test_pubkey(1)), Some(encoded_test_pubkey(2))];
1308        encoded_pubkeys.extend(vec![None; MAX_AUTHENTICATOR_KEYS + 5]);
1309
1310        let key_set = Authenticator::decode_indexer_pubkeys(encoded_pubkeys).unwrap();
1311
1312        assert_eq!(key_set.len(), 2);
1313        assert_eq!(key_set[0].as_ref().unwrap().pk, test_pubkey(1).pk);
1314        assert_eq!(key_set[1].as_ref().unwrap().pk, test_pubkey(2).pk);
1315    }
1316
1317    #[test]
1318    fn test_decode_indexer_pubkeys_rejects_used_slot_beyond_max() {
1319        let mut encoded_pubkeys = vec![None; MAX_AUTHENTICATOR_KEYS + 1];
1320        encoded_pubkeys[MAX_AUTHENTICATOR_KEYS] = Some(encoded_test_pubkey(1));
1321
1322        let error = Authenticator::decode_indexer_pubkeys(encoded_pubkeys).unwrap_err();
1323        assert!(matches!(
1324            error,
1325            AuthenticatorError::InvalidIndexerPubkeySlot {
1326                slot_index,
1327                max_supported_slot
1328            } if slot_index == MAX_AUTHENTICATOR_KEYS && max_supported_slot == MAX_AUTHENTICATOR_KEYS - 1
1329        ));
1330    }
1331
1332    /// Tests that `get_packed_account_data` correctly fetches the packed account data from the indexer
1333    /// when no RPC is configured.
1334    #[tokio::test]
1335    async fn test_get_packed_account_data_from_indexer() {
1336        let mut server = mockito::Server::new_async().await;
1337        let indexer_url = server.url();
1338
1339        let test_address = address!("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0");
1340        let expected_packed_index = U256::from(42);
1341
1342        let mock = server
1343            .mock("POST", "/packed-account")
1344            .match_header("content-type", "application/json")
1345            .match_body(mockito::Matcher::JsonString(
1346                serde_json::json!({
1347                    "authenticator_address": test_address
1348                })
1349                .to_string(),
1350            ))
1351            .with_status(200)
1352            .with_header("content-type", "application/json")
1353            .with_body(
1354                serde_json::json!({
1355                    "packed_account_data": format!("{:#x}", expected_packed_index)
1356                })
1357                .to_string(),
1358            )
1359            .create_async()
1360            .await;
1361
1362        let config = Config::new(
1363            None,
1364            1,
1365            address!("0x0000000000000000000000000000000000000001"),
1366            indexer_url,
1367            "http://gateway.example.com".to_string(),
1368            Vec::new(),
1369            2,
1370        )
1371        .unwrap();
1372
1373        let http_client = reqwest::Client::new();
1374
1375        let result = Authenticator::get_packed_account_data(
1376            test_address,
1377            None, // No registry, force indexer usage
1378            &config,
1379            &http_client,
1380        )
1381        .await
1382        .unwrap();
1383
1384        assert_eq!(result, expected_packed_index);
1385        mock.assert_async().await;
1386        drop(server);
1387    }
1388
1389    #[tokio::test]
1390    async fn test_get_packed_account_data_from_indexer_error() {
1391        let mut server = mockito::Server::new_async().await;
1392        let indexer_url = server.url();
1393
1394        let test_address = address!("0x0000000000000000000000000000000000000099");
1395
1396        let mock = server
1397            .mock("POST", "/packed-account")
1398            .with_status(400)
1399            .with_header("content-type", "application/json")
1400            .with_body(
1401                serde_json::json!({
1402                    "code": "account_does_not_exist",
1403                    "message": "There is no account for this authenticator address"
1404                })
1405                .to_string(),
1406            )
1407            .create_async()
1408            .await;
1409
1410        let config = Config::new(
1411            None,
1412            1,
1413            address!("0x0000000000000000000000000000000000000001"),
1414            indexer_url,
1415            "http://gateway.example.com".to_string(),
1416            Vec::new(),
1417            2,
1418        )
1419        .unwrap();
1420
1421        let http_client = reqwest::Client::new();
1422
1423        let result =
1424            Authenticator::get_packed_account_data(test_address, None, &config, &http_client).await;
1425
1426        assert!(matches!(
1427            result,
1428            Err(AuthenticatorError::AccountDoesNotExist)
1429        ));
1430        mock.assert_async().await;
1431        drop(server);
1432    }
1433
1434    #[tokio::test]
1435    #[cfg(not(target_arch = "wasm32"))]
1436    async fn test_signing_nonce_from_indexer() {
1437        let mut server = mockito::Server::new_async().await;
1438        let indexer_url = server.url();
1439
1440        let leaf_index = U256::from(1);
1441        let expected_nonce = U256::from(5);
1442
1443        let mock = server
1444            .mock("POST", "/signature-nonce")
1445            .match_header("content-type", "application/json")
1446            .match_body(mockito::Matcher::JsonString(
1447                serde_json::json!({
1448                    "leaf_index": format!("{:#x}", leaf_index)
1449                })
1450                .to_string(),
1451            ))
1452            .with_status(200)
1453            .with_header("content-type", "application/json")
1454            .with_body(
1455                serde_json::json!({
1456                    "signature_nonce": format!("{:#x}", expected_nonce)
1457                })
1458                .to_string(),
1459            )
1460            .create_async()
1461            .await;
1462
1463        let config = Config::new(
1464            None,
1465            1,
1466            address!("0x0000000000000000000000000000000000000001"),
1467            indexer_url,
1468            "http://gateway.example.com".to_string(),
1469            Vec::new(),
1470            2,
1471        )
1472        .unwrap();
1473
1474        let authenticator = Authenticator {
1475            config,
1476            packed_account_data: leaf_index, // This sets leaf_index() to 1
1477            signer: Signer::from_seed_bytes(&[1u8; 32]).unwrap(),
1478            registry: None, // No registry - forces indexer usage
1479            http_client: reqwest::Client::new(),
1480            ws_connector: Connector::Plain,
1481            query_material: None,
1482            nullifier_material: None,
1483        };
1484
1485        let nonce = authenticator.signing_nonce().await.unwrap();
1486
1487        assert_eq!(nonce, expected_nonce);
1488        mock.assert_async().await;
1489        drop(server);
1490    }
1491
1492    #[test]
1493    fn test_danger_sign_challenge_returns_valid_signature() {
1494        let authenticator = Authenticator {
1495            config: Config::new(
1496                None,
1497                1,
1498                address!("0x0000000000000000000000000000000000000001"),
1499                "http://indexer.example.com".to_string(),
1500                "http://gateway.example.com".to_string(),
1501                Vec::new(),
1502                2,
1503            )
1504            .unwrap(),
1505            packed_account_data: U256::from(1),
1506            signer: Signer::from_seed_bytes(&[1u8; 32]).unwrap(),
1507            registry: None,
1508            http_client: reqwest::Client::new(),
1509            ws_connector: Connector::Plain,
1510            query_material: None,
1511            nullifier_material: None,
1512        };
1513
1514        let challenge = b"test challenge";
1515        let signature = authenticator.danger_sign_challenge(challenge).unwrap();
1516
1517        let recovered = signature
1518            .recover_address_from_msg(challenge)
1519            .expect("should recover address");
1520        assert_eq!(recovered, authenticator.onchain_address());
1521    }
1522
1523    #[test]
1524    fn test_danger_sign_challenge_different_challenges_different_signatures() {
1525        let authenticator = Authenticator {
1526            config: Config::new(
1527                None,
1528                1,
1529                address!("0x0000000000000000000000000000000000000001"),
1530                "http://indexer.example.com".to_string(),
1531                "http://gateway.example.com".to_string(),
1532                Vec::new(),
1533                2,
1534            )
1535            .unwrap(),
1536            packed_account_data: U256::from(1),
1537            signer: Signer::from_seed_bytes(&[1u8; 32]).unwrap(),
1538            registry: None,
1539            http_client: reqwest::Client::new(),
1540            ws_connector: Connector::Plain,
1541            query_material: None,
1542            nullifier_material: None,
1543        };
1544
1545        let sig_a = authenticator.danger_sign_challenge(b"challenge A").unwrap();
1546        let sig_b = authenticator.danger_sign_challenge(b"challenge B").unwrap();
1547        assert_ne!(sig_a, sig_b);
1548    }
1549
1550    #[test]
1551    fn test_danger_sign_challenge_deterministic() {
1552        let authenticator = Authenticator {
1553            config: Config::new(
1554                None,
1555                1,
1556                address!("0x0000000000000000000000000000000000000001"),
1557                "http://indexer.example.com".to_string(),
1558                "http://gateway.example.com".to_string(),
1559                Vec::new(),
1560                2,
1561            )
1562            .unwrap(),
1563            packed_account_data: U256::from(1),
1564            signer: Signer::from_seed_bytes(&[1u8; 32]).unwrap(),
1565            registry: None,
1566            http_client: reqwest::Client::new(),
1567            ws_connector: Connector::Plain,
1568            query_material: None,
1569            nullifier_material: None,
1570        };
1571
1572        let challenge = b"deterministic test";
1573        let sig1 = authenticator.danger_sign_challenge(challenge).unwrap();
1574        let sig2 = authenticator.danger_sign_challenge(challenge).unwrap();
1575        assert_eq!(sig1, sig2);
1576    }
1577
1578    #[tokio::test]
1579    #[cfg(not(target_arch = "wasm32"))]
1580    async fn test_signing_nonce_from_indexer_error() {
1581        let mut server = mockito::Server::new_async().await;
1582        let indexer_url = server.url();
1583
1584        let mock = server
1585            .mock("POST", "/signature-nonce")
1586            .with_status(400)
1587            .with_header("content-type", "application/json")
1588            .with_body(
1589                serde_json::json!({
1590                    "code": "invalid_leaf_index",
1591                    "message": "Account index cannot be zero"
1592                })
1593                .to_string(),
1594            )
1595            .create_async()
1596            .await;
1597
1598        let config = Config::new(
1599            None,
1600            1,
1601            address!("0x0000000000000000000000000000000000000001"),
1602            indexer_url,
1603            "http://gateway.example.com".to_string(),
1604            Vec::new(),
1605            2,
1606        )
1607        .unwrap();
1608
1609        let authenticator = Authenticator {
1610            config,
1611            packed_account_data: U256::ZERO,
1612            signer: Signer::from_seed_bytes(&[1u8; 32]).unwrap(),
1613            registry: None,
1614            http_client: reqwest::Client::new(),
1615            ws_connector: Connector::Plain,
1616            query_material: None,
1617            nullifier_material: None,
1618        };
1619
1620        let result = authenticator.signing_nonce().await;
1621
1622        assert!(matches!(
1623            result,
1624            Err(AuthenticatorError::IndexerError { .. })
1625        ));
1626        mock.assert_async().await;
1627        drop(server);
1628    }
1629}