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    IndexerErrorCode, IndexerPackedAccountRequest, IndexerPackedAccountResponse,
10    IndexerQueryRequest, IndexerSignatureNonceResponse, InsertAuthenticatorRequest,
11    RemoveAuthenticatorRequest, ServiceApiError, UpdateAuthenticatorRequest,
12};
13use world_id_primitives::{
14    Credential, FieldElement, ProofRequest, RequestItem, ResponseItem, SessionNullifier, Signer,
15};
16
17use crate::registry::{
18    WorldIdRegistry::WorldIdRegistryInstance, domain, sign_insert_authenticator,
19    sign_remove_authenticator, sign_update_authenticator,
20};
21use alloy::{
22    primitives::{Address, U256},
23    providers::DynProvider,
24    uint,
25};
26use ark_babyjubjub::EdwardsAffine;
27use ark_serialize::CanonicalSerialize;
28use eddsa_babyjubjub::{EdDSAPublicKey, EdDSASignature};
29use groth16_material::circom::CircomGroth16Material;
30use reqwest::StatusCode;
31use secrecy::ExposeSecret;
32use taceo_oprf::client::Connector;
33pub use world_id_primitives::{Config, TREE_DEPTH, authenticator::ProtocolSigner};
34use world_id_primitives::{
35    PrimitiveError, ZeroKnowledgeProof, authenticator::AuthenticatorPublicKeySet,
36    merkle::MerkleInclusionProof,
37};
38use world_id_proof::{
39    AuthenticatorProofInput,
40    credential_blinding_factor::OprfCredentialBlindingFactor,
41    nullifier::OprfNullifier,
42    proof::{ProofError, generate_nullifier_proof},
43};
44
45static MASK_RECOVERY_COUNTER: U256 =
46    uint!(0xFFFFFFFF00000000000000000000000000000000000000000000000000000000_U256);
47static MASK_PUBKEY_ID: U256 =
48    uint!(0x00000000FFFFFFFF000000000000000000000000000000000000000000000000_U256);
49static MASK_LEAF_INDEX: U256 =
50    uint!(0x000000000000000000000000000000000000000000000000FFFFFFFFFFFFFFFF_U256);
51
52/// An Authenticator is the base layer with which a user interacts with the Protocol.
53pub struct Authenticator {
54    /// General configuration for the Authenticator.
55    pub config: Config,
56    /// The packed account data for the holder's World ID is a `uint256` defined in the `WorldIDRegistry` contract as:
57    /// `recovery_counter` (32 bits) | `pubkey_id` (commitment to all off-chain public keys) (32 bits) | `leaf_index` (192 bits)
58    pub packed_account_data: U256,
59    signer: Signer,
60    registry: Option<Arc<WorldIdRegistryInstance<DynProvider>>>,
61    http_client: reqwest::Client,
62    ws_connector: Connector,
63    query_material: Arc<CircomGroth16Material>,
64    nullifier_material: Arc<CircomGroth16Material>,
65}
66
67#[expect(clippy::missing_fields_in_debug)]
68impl std::fmt::Debug for Authenticator {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.debug_struct("Authenticator")
71            .field("config", &self.config)
72            .field("packed_account_data", &self.packed_account_data)
73            .field("signer", &self.signer)
74            .finish()
75    }
76}
77
78impl Authenticator {
79    /// Initialize an Authenticator from a seed and config.
80    ///
81    /// This method will error if the World ID account does not exist on the registry.
82    ///
83    /// # Errors
84    /// - Will error if the provided seed is invalid (not 32 bytes).
85    /// - Will error if the RPC URL is invalid.
86    /// - Will error if there are contract call failures.
87    /// - Will error if the account does not exist (`AccountDoesNotExist`).
88    #[cfg(feature = "embed-zkeys")]
89    pub async fn init(seed: &[u8], config: Config) -> Result<Self, AuthenticatorError> {
90        let signer = Signer::from_seed_bytes(seed)?;
91
92        let registry = config.rpc_url().map_or_else(
93            || None,
94            |rpc_url| {
95                let provider = alloy::providers::ProviderBuilder::new()
96                    .with_chain_id(config.chain_id())
97                    .connect_http(rpc_url.clone());
98                Some(crate::registry::WorldIdRegistry::new(
99                    *config.registry_address(),
100                    alloy::providers::Provider::erased(provider),
101                ))
102            },
103        );
104
105        let http_client = reqwest::Client::new();
106
107        let packed_account_data = Self::get_packed_account_data(
108            signer.onchain_signer_address(),
109            registry.as_ref(),
110            &config,
111            &http_client,
112        )
113        .await?;
114
115        let mut root_store = rustls::RootCertStore::empty();
116        root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
117        let rustls_config = rustls::ClientConfig::builder()
118            .with_root_certificates(root_store)
119            .with_no_client_auth();
120        let ws_connector = Connector::Rustls(Arc::new(rustls_config));
121
122        let cache_dir = config.zkey_cache_dir();
123        let query_material = Arc::new(
124            world_id_proof::proof::load_embedded_query_material(cache_dir).map_err(|e| {
125                AuthenticatorError::Generic(format!("Failed to load cached query material: {e}"))
126            })?,
127        );
128        let nullifier_material = Arc::new(
129            world_id_proof::proof::load_embedded_nullifier_material(cache_dir).map_err(|e| {
130                AuthenticatorError::Generic(format!(
131                    "Failed to load cached nullifier material: {e}"
132                ))
133            })?,
134        );
135
136        Ok(Self {
137            packed_account_data,
138            signer,
139            config,
140            registry: registry.map(Arc::new),
141            http_client,
142            ws_connector,
143            query_material,
144            nullifier_material,
145        })
146    }
147
148    /// Registers a new World ID in the `WorldIDRegistry`.
149    ///
150    /// Given the registration process is asynchronous, this method will return a `InitializingAuthenticator`
151    /// object.
152    ///
153    /// # Errors
154    /// - See `init` for additional error details.
155    pub async fn register(
156        seed: &[u8],
157        config: Config,
158        recovery_address: Option<Address>,
159    ) -> Result<InitializingAuthenticator, AuthenticatorError> {
160        let http_client = reqwest::Client::new();
161        InitializingAuthenticator::new(seed, config, recovery_address, http_client).await
162    }
163
164    /// Initializes (if the World ID already exists in the registry) or registers a new World ID.
165    ///
166    /// The registration process is asynchronous and may take some time. This method will block
167    /// the thread until the registration is in a final state (success or terminal error). For better
168    /// user experience in end authenticator clients, it is recommended to implement custom polling logic.
169    ///
170    /// Explicit `init` or `register` calls are also recommended as the authenticator should know
171    /// if a new World ID should be truly created. For example, an authenticator may have been revoked
172    /// access to an existing World ID.
173    ///
174    /// # Errors
175    /// - See `init` for additional error details.
176    #[cfg(feature = "embed-zkeys")]
177    pub async fn init_or_register(
178        seed: &[u8],
179        config: Config,
180        recovery_address: Option<Address>,
181    ) -> Result<Self, AuthenticatorError> {
182        match Self::init(seed, config.clone()).await {
183            Ok(authenticator) => Ok(authenticator),
184            Err(AuthenticatorError::AccountDoesNotExist) => {
185                // Authenticator is not registered, create it.
186                let http_client = reqwest::Client::new();
187                let initializing_authenticator = InitializingAuthenticator::new(
188                    seed,
189                    config.clone(),
190                    recovery_address,
191                    http_client,
192                )
193                .await?;
194
195                let backoff = backon::ExponentialBuilder::default()
196                    .with_min_delay(std::time::Duration::from_millis(800))
197                    .with_factor(1.5)
198                    .without_max_times()
199                    .with_total_delay(Some(std::time::Duration::from_secs(120)));
200
201                let poller = || async {
202                    let poll_status = initializing_authenticator.poll_status().await;
203                    let result = match poll_status {
204                        Ok(GatewayRequestState::Finalized { .. }) => Ok(()),
205                        Ok(GatewayRequestState::Failed { error_code, error }) => Err(
206                            PollResult::TerminalError(AuthenticatorError::RegistrationError {
207                                error_code: error_code.map(|v| v.to_string()).unwrap_or_default(),
208                                error_message: error,
209                            }),
210                        ),
211                        Err(AuthenticatorError::GatewayError { status, body }) => {
212                            if status.is_client_error() {
213                                Err(PollResult::TerminalError(
214                                    AuthenticatorError::GatewayError { status, body },
215                                ))
216                            } else {
217                                Err(PollResult::Retryable)
218                            }
219                        }
220                        _ => Err(PollResult::Retryable),
221                    };
222
223                    match result {
224                        Ok(()) => match Self::init(seed, config.clone()).await {
225                            Ok(auth) => Ok(auth),
226                            Err(AuthenticatorError::AccountDoesNotExist) => {
227                                Err(PollResult::Retryable)
228                            }
229                            Err(e) => Err(PollResult::TerminalError(e)),
230                        },
231                        Err(e) => Err(e),
232                    }
233                };
234
235                let result = backon::Retryable::retry(poller, backoff)
236                    .when(|e| matches!(e, PollResult::Retryable))
237                    .await;
238
239                match result {
240                    Ok(authenticator) => Ok(authenticator),
241                    Err(PollResult::TerminalError(e)) => Err(e),
242                    Err(PollResult::Retryable) => Err(AuthenticatorError::Timeout),
243                }
244            }
245            Err(e) => Err(e),
246        }
247    }
248
249    /// Returns the packed account data for the holder's World ID.
250    ///
251    /// The packed account data is a 256 bit integer which includes the World ID's leaf index, their recovery counter,
252    /// and their pubkey id/commitment.
253    ///
254    /// # Errors
255    /// Will error if the network call fails or if the account does not exist.
256    pub async fn get_packed_account_data(
257        onchain_signer_address: Address,
258        registry: Option<&WorldIdRegistryInstance<DynProvider>>,
259        config: &Config,
260        http_client: &reqwest::Client,
261    ) -> Result<U256, AuthenticatorError> {
262        // If the registry is available through direct RPC calls, use it. Otherwise fallback to the indexer.
263        let raw_index = if let Some(registry) = registry {
264            // TODO: Better error handling to expose the specific failure
265            registry
266                .getPackedAccountData(onchain_signer_address)
267                .call()
268                .await?
269        } else {
270            let url = format!("{}/packed-account", config.indexer_url());
271            let req = IndexerPackedAccountRequest {
272                authenticator_address: onchain_signer_address,
273            };
274            let resp = http_client.post(&url).json(&req).send().await?;
275
276            let status = resp.status();
277            if !status.is_success() {
278                // Try to parse the error response
279                if let Ok(error_resp) = resp.json::<ServiceApiError<IndexerErrorCode>>().await {
280                    return match error_resp.code {
281                        IndexerErrorCode::AccountDoesNotExist => {
282                            Err(AuthenticatorError::AccountDoesNotExist)
283                        }
284                        _ => Err(AuthenticatorError::IndexerError {
285                            status,
286                            body: error_resp.message,
287                        }),
288                    };
289                }
290                return Err(AuthenticatorError::IndexerError {
291                    status,
292                    body: "Failed to parse indexer error response".to_string(),
293                });
294            }
295
296            let response: IndexerPackedAccountResponse = resp.json().await?;
297            response.packed_account_data
298        };
299
300        if raw_index == U256::ZERO {
301            return Err(AuthenticatorError::AccountDoesNotExist);
302        }
303
304        Ok(raw_index)
305    }
306
307    /// Returns the k256 public key of the Authenticator signer which is used to verify on-chain operations,
308    /// chiefly with the `WorldIdRegistry` contract.
309    #[must_use]
310    pub const fn onchain_address(&self) -> Address {
311        self.signer.onchain_signer_address()
312    }
313
314    /// Returns the `EdDSA` public key of the Authenticator signer which is used to verify off-chain operations. For example,
315    /// the Nullifier Oracle uses it to verify requests for nullifiers.
316    #[must_use]
317    pub fn offchain_pubkey(&self) -> EdDSAPublicKey {
318        self.signer.offchain_signer_pubkey()
319    }
320
321    /// Returns the compressed `EdDSA` public key of the Authenticator signer which is used to verify off-chain operations.
322    /// For example, the Nullifier Oracle uses it to verify requests for nullifiers.
323    /// # Errors
324    /// Will error if the public key cannot be serialized.
325    pub fn offchain_pubkey_compressed(&self) -> Result<U256, AuthenticatorError> {
326        let pk = self.signer.offchain_signer_pubkey().pk;
327        let mut compressed_bytes = Vec::new();
328        pk.serialize_compressed(&mut compressed_bytes)
329            .map_err(|e| PrimitiveError::Serialization(e.to_string()))?;
330        Ok(U256::from_le_slice(&compressed_bytes))
331    }
332
333    /// Returns a reference to the `WorldIdRegistry` contract instance.
334    #[must_use]
335    pub fn registry(&self) -> Option<Arc<WorldIdRegistryInstance<DynProvider>>> {
336        self.registry.clone()
337    }
338
339    /// Returns the index for the holder's World ID.
340    ///
341    /// # Definition
342    ///
343    /// The `leaf_index` is the main (internal) identifier of a World ID. It is registered in
344    /// the `WorldIDRegistry` and represents the index at the Merkle tree where the World ID
345    /// resides.
346    ///
347    /// # Notes
348    /// - The `leaf_index` is used as input in the nullifier generation, ensuring a nullifier
349    ///   will always be the same for the same RP context and the same World ID (allowing for uniqueness).
350    /// - The `leaf_index` is generally not exposed outside Authenticators. It is not a secret because
351    ///   it's not exposed to RPs outside ZK-circuits, but the only acceptable exposure outside an Authenticator
352    ///   is to fetch Merkle inclusion proofs from an indexer or it may create a pseudonymous identifier.
353    /// - The `leaf_index` is stored as a `uint64` inside packed account data.
354    #[must_use]
355    pub fn leaf_index(&self) -> u64 {
356        (self.packed_account_data & MASK_LEAF_INDEX).to::<u64>()
357    }
358
359    /// Returns the recovery counter for the holder's World ID.
360    ///
361    /// The recovery counter is used to efficiently invalidate all the old keys when an account is recovered.
362    #[must_use]
363    pub fn recovery_counter(&self) -> U256 {
364        let recovery_counter = self.packed_account_data & MASK_RECOVERY_COUNTER;
365        recovery_counter >> 224
366    }
367
368    /// Returns the pubkey id (or commitment) for the holder's World ID.
369    ///
370    /// This is a commitment to all the off-chain public keys that are authorized to act on behalf of the holder.
371    #[must_use]
372    pub fn pubkey_id(&self) -> U256 {
373        let pubkey_id = self.packed_account_data & MASK_PUBKEY_ID;
374        pubkey_id >> 192
375    }
376
377    /// Fetches a Merkle inclusion proof for the holder's World ID given their account index.
378    ///
379    /// # Errors
380    /// - Will error if the provided indexer URL is not valid or if there are HTTP call failures.
381    /// - Will error if the user is not registered on the `WorldIDRegistry`.
382    pub async fn fetch_inclusion_proof(
383        &self,
384    ) -> Result<(MerkleInclusionProof<TREE_DEPTH>, AuthenticatorPublicKeySet), AuthenticatorError>
385    {
386        let url = format!("{}/inclusion-proof", self.config.indexer_url());
387        let req = IndexerQueryRequest {
388            leaf_index: self.leaf_index(),
389        };
390        let response = self.http_client.post(&url).json(&req).send().await?;
391        let response = response.json::<AccountInclusionProof<TREE_DEPTH>>().await?;
392
393        Ok((response.inclusion_proof, response.authenticator_pubkeys))
394    }
395
396    /// Returns the signing nonce for the holder's World ID.
397    ///
398    /// # Errors
399    /// Will return an error if the registry contract call fails.
400    pub async fn signing_nonce(&self) -> Result<U256, AuthenticatorError> {
401        let registry = self.registry();
402        if let Some(registry) = registry {
403            let nonce = registry.getSignatureNonce(self.leaf_index()).call().await?;
404            Ok(nonce)
405        } else {
406            let url = format!("{}/signature-nonce", self.config.indexer_url());
407            let req = IndexerQueryRequest {
408                leaf_index: self.leaf_index(),
409            };
410            let resp = self.http_client.post(&url).json(&req).send().await?;
411
412            let status = resp.status();
413            if !status.is_success() {
414                return Err(AuthenticatorError::IndexerError {
415                    status,
416                    body: resp
417                        .json()
418                        .await
419                        .unwrap_or_else(|_| "Unable to parse response".to_string()),
420                });
421            }
422
423            let response: IndexerSignatureNonceResponse = resp.json().await?;
424            Ok(response.signature_nonce)
425        }
426    }
427
428    /// Checks that the OPRF Nodes configuration is valid and returns the list of URLs and the threshold to use.
429    ///
430    /// # Errors
431    /// Will return an error if there are no OPRF Nodes configured or if the threshold is invalid.
432    fn check_oprf_config(&self) -> Result<(&[String], usize), AuthenticatorError> {
433        let services = self.config.nullifier_oracle_urls();
434        if services.is_empty() {
435            return Err(AuthenticatorError::Generic(
436                "No nullifier oracle URLs configured".to_string(),
437            ));
438        }
439        let requested_threshold = self.config.nullifier_oracle_threshold();
440        if requested_threshold == 0 {
441            return Err(AuthenticatorError::InvalidConfig {
442                attribute: "nullifier_oracle_threshold",
443                reason: "must be at least 1".to_string(),
444            });
445        }
446        let threshold = requested_threshold.min(services.len());
447        Ok((services, threshold))
448    }
449
450    /// Generates a nullifier for a World ID Proof (through OPRF Nodes).
451    ///
452    /// A nullifier is a unique, one-time use, anonymous identifier for a World ID
453    /// on a specific RP context. It is used to ensure that a single World ID can only
454    /// perform an action once.
455    ///
456    /// # Errors
457    ///
458    /// - Will raise a [`ProofError`] if there is any issue generating the nullifier. For example,
459    ///   network issues, unexpected incorrect responses from OPRF Nodes.
460    /// - Raises an error if the OPRF Nodes configuration is not correctly set.
461    pub async fn generate_nullifier(
462        &self,
463        proof_request: &ProofRequest,
464    ) -> Result<OprfNullifier, AuthenticatorError> {
465        let (services, threshold) = self.check_oprf_config()?;
466
467        let (inclusion_proof, key_set) = self.fetch_inclusion_proof().await?;
468        let key_index = key_set
469            .iter()
470            .position(|pk| pk.pk == self.offchain_pubkey().pk)
471            .ok_or(AuthenticatorError::PublicKeyNotFound)? as u64;
472
473        let authenticator_input = AuthenticatorProofInput::new(
474            key_set,
475            inclusion_proof,
476            self.signer
477                .offchain_signer_private_key()
478                .expose_secret()
479                .clone(),
480            key_index,
481        );
482
483        Ok(OprfNullifier::generate(
484            services,
485            threshold,
486            &self.query_material,
487            authenticator_input,
488            proof_request,
489            self.ws_connector.clone(),
490        )
491        .await?)
492    }
493
494    // TODO add more docs
495    /// Generates a blinding factor for a Credential sub (through OPRF Nodes).
496    ///
497    /// # Errors
498    ///
499    /// - Will raise a [`ProofError`] if there is any issue generating the blinding factor.
500    ///   For example, network issues, unexpected incorrect responses from OPRF Nodes.
501    /// - Raises an error if the OPRF Nodes configuration is not correctly set.
502    pub async fn generate_credential_blinding_factor(
503        &self,
504        issuer_schema_id: u64,
505    ) -> Result<FieldElement, AuthenticatorError> {
506        let (services, threshold) = self.check_oprf_config()?;
507
508        let (inclusion_proof, key_set) = self.fetch_inclusion_proof().await?;
509        let key_index = key_set
510            .iter()
511            .position(|pk| pk.pk == self.offchain_pubkey().pk)
512            .ok_or(AuthenticatorError::PublicKeyNotFound)? as u64;
513
514        let authenticator_input = AuthenticatorProofInput::new(
515            key_set,
516            inclusion_proof,
517            self.signer
518                .offchain_signer_private_key()
519                .expose_secret()
520                .clone(),
521            key_index,
522        );
523
524        let blinding_factor = OprfCredentialBlindingFactor::generate(
525            services,
526            threshold,
527            &self.query_material,
528            authenticator_input,
529            issuer_schema_id,
530            FieldElement::ZERO, // for now action is always zero, might change in future
531            self.ws_connector.clone(),
532        )
533        .await?;
534
535        Ok(blinding_factor.verifiable_oprf_output.output.into())
536    }
537
538    /// Generates a single World ID Proof from a provided `[ProofRequest]` and `[Credential]`. This
539    /// method generates the raw proof to be translated into a Uniqueness Proof or a Session Proof for the RP.
540    ///
541    /// This assumes the RP's `[ProofRequest]` has already been parsed to determine
542    /// which `[Credential]` is appropriate for the request. This method responds to a
543    /// specific `[RequestItem]` (a `[ProofRequest]` may contain multiple items).
544    ///
545    /// # Arguments
546    /// - `oprf_nullifier`: The `[OprfNullifier]` output generated from the `generate_nullifier` function.
547    /// - `request_item`: The specific `RequestItem` that is being resolved from the RP's `ProofRequest`.
548    /// - `credential`: The Credential to be used for the proof that fulfills the `RequestItem`.
549    /// - `credential_sub_blinding_factor`: The blinding factor for the Credential's sub.
550    /// - `session_id_r_seed`: The session ID random seed. Obtained from the RP's [`ProofRequest`].
551    /// - `session_id`: The expected session ID provided by the RP. Only needed for Session Proofs. Obtained from the RP's [`ProofRequest`].
552    /// - `request_timestamp`: The timestamp of the request. Obtained from the RP's [`ProofRequest`].
553    ///
554    /// # Errors
555    /// - Will error if the any of the provided parameters are not valid.
556    /// - Will error if any of the required network requests fail.
557    /// - Will error if the user does not have a registered World ID.
558    #[allow(clippy::too_many_arguments)]
559    pub fn generate_single_proof(
560        &self,
561        oprf_nullifier: OprfNullifier,
562        request_item: &RequestItem,
563        credential: &Credential,
564        credential_sub_blinding_factor: FieldElement,
565        session_id_r_seed: FieldElement,
566        session_id: Option<FieldElement>,
567        request_timestamp: u64,
568    ) -> Result<ResponseItem, AuthenticatorError> {
569        let mut rng = rand::rngs::OsRng;
570
571        let merkle_root: FieldElement = oprf_nullifier.query_proof_input.merkle_root.into();
572        let action_from_query: FieldElement = oprf_nullifier.query_proof_input.action.into();
573
574        let expires_at_min = request_item.effective_expires_at_min(request_timestamp);
575
576        let (proof, _public_inputs, nullifier) = generate_nullifier_proof(
577            &self.nullifier_material,
578            &mut rng,
579            credential,
580            credential_sub_blinding_factor,
581            oprf_nullifier,
582            request_item,
583            session_id,
584            session_id_r_seed,
585            expires_at_min,
586        )?;
587
588        let proof = ZeroKnowledgeProof::from_groth16_proof(&proof, merkle_root);
589
590        // Construct the appropriate response item based on proof type
591        let nullifier_fe: FieldElement = nullifier.into();
592        let response_item = if session_id.is_some() {
593            let session_nullifier = SessionNullifier::new(nullifier_fe, action_from_query);
594            ResponseItem::new_session(
595                request_item.identifier.clone(),
596                request_item.issuer_schema_id,
597                proof,
598                session_nullifier,
599                expires_at_min,
600            )
601        } else {
602            ResponseItem::new_uniqueness(
603                request_item.identifier.clone(),
604                request_item.issuer_schema_id,
605                proof,
606                nullifier_fe,
607                expires_at_min,
608            )
609        };
610
611        Ok(response_item)
612    }
613
614    /// Inserts a new authenticator to the account.
615    ///
616    /// # Errors
617    /// Will error if the provided RPC URL is not valid or if there are HTTP call failures.
618    ///
619    /// # Note
620    /// TODO: After successfully inserting an authenticator, the `packed_account_data` should be
621    /// refreshed from the registry to reflect the new `pubkey_id` commitment.
622    pub async fn insert_authenticator(
623        &mut self,
624        new_authenticator_pubkey: EdDSAPublicKey,
625        new_authenticator_address: Address,
626    ) -> Result<String, AuthenticatorError> {
627        let leaf_index = self.leaf_index();
628        let nonce = self.signing_nonce().await?;
629        let (inclusion_proof, mut key_set) = self.fetch_inclusion_proof().await?;
630        let old_offchain_signer_commitment = key_set.leaf_hash();
631        let encoded_offchain_pubkey = new_authenticator_pubkey.to_ethereum_representation()?;
632        key_set.try_push(new_authenticator_pubkey)?;
633        let index = key_set.len() - 1;
634        let new_offchain_signer_commitment = key_set.leaf_hash();
635
636        let eip712_domain = domain(self.config.chain_id(), *self.config.registry_address());
637
638        #[allow(clippy::cast_possible_truncation)]
639        // truncating is intentional, and index will always fit in 32 bits
640        let signature = sign_insert_authenticator(
641            &self.signer.onchain_signer(),
642            leaf_index,
643            new_authenticator_address,
644            index as u32,
645            encoded_offchain_pubkey,
646            new_offchain_signer_commitment.into(),
647            nonce,
648            &eip712_domain,
649        )
650        .await
651        .map_err(|e| {
652            AuthenticatorError::Generic(format!("Failed to sign insert authenticator: {e}"))
653        })?;
654
655        #[allow(clippy::cast_possible_truncation)]
656        // truncating is intentional, and index will always fit in 32 bits
657        let req = InsertAuthenticatorRequest {
658            leaf_index,
659            new_authenticator_address,
660            pubkey_id: index as u32,
661            new_authenticator_pubkey: encoded_offchain_pubkey,
662            old_offchain_signer_commitment: old_offchain_signer_commitment.into(),
663            new_offchain_signer_commitment: new_offchain_signer_commitment.into(),
664            sibling_nodes: inclusion_proof
665                .siblings
666                .iter()
667                .map(|s| (*s).into())
668                .collect(),
669            signature: signature.as_bytes().to_vec(),
670            nonce,
671        };
672
673        let resp = self
674            .http_client
675            .post(format!(
676                "{}/insert-authenticator",
677                self.config.gateway_url()
678            ))
679            .json(&req)
680            .send()
681            .await?;
682
683        let status = resp.status();
684        if status.is_success() {
685            let body: GatewayStatusResponse = resp.json().await?;
686            Ok(body.request_id)
687        } else {
688            let body_text = resp.text().await.unwrap_or_else(|_| String::new());
689            Err(AuthenticatorError::GatewayError {
690                status,
691                body: body_text,
692            })
693        }
694    }
695
696    /// Updates an existing authenticator slot with a new authenticator.
697    ///
698    /// # Errors
699    /// Returns an error if the gateway rejects the request or a network error occurs.
700    ///
701    /// # Note
702    /// TODO: After successfully updating an authenticator, the `packed_account_data` should be
703    /// refreshed from the registry to reflect the new `pubkey_id` commitment.
704    pub async fn update_authenticator(
705        &mut self,
706        old_authenticator_address: Address,
707        new_authenticator_address: Address,
708        new_authenticator_pubkey: EdDSAPublicKey,
709        index: u32,
710    ) -> Result<String, AuthenticatorError> {
711        let leaf_index = self.leaf_index();
712        let nonce = self.signing_nonce().await?;
713        let (inclusion_proof, mut key_set) = self.fetch_inclusion_proof().await?;
714        let old_commitment: U256 = key_set.leaf_hash().into();
715        let encoded_offchain_pubkey = new_authenticator_pubkey.to_ethereum_representation()?;
716        key_set.try_set_at_index(index as usize, new_authenticator_pubkey)?;
717        let new_commitment: U256 = key_set.leaf_hash().into();
718
719        let eip712_domain = domain(self.config.chain_id(), *self.config.registry_address());
720
721        let signature = sign_update_authenticator(
722            &self.signer.onchain_signer(),
723            leaf_index,
724            old_authenticator_address,
725            new_authenticator_address,
726            index,
727            encoded_offchain_pubkey,
728            new_commitment,
729            nonce,
730            &eip712_domain,
731        )
732        .await
733        .map_err(|e| {
734            AuthenticatorError::Generic(format!("Failed to sign update authenticator: {e}"))
735        })?;
736
737        let sibling_nodes: Vec<U256> = inclusion_proof
738            .siblings
739            .iter()
740            .map(|s| (*s).into())
741            .collect();
742
743        let req = UpdateAuthenticatorRequest {
744            leaf_index,
745            old_authenticator_address,
746            new_authenticator_address,
747            old_offchain_signer_commitment: old_commitment,
748            new_offchain_signer_commitment: new_commitment,
749            sibling_nodes,
750            signature: signature.as_bytes().to_vec(),
751            nonce,
752            pubkey_id: index,
753            new_authenticator_pubkey: encoded_offchain_pubkey,
754        };
755
756        let resp = self
757            .http_client
758            .post(format!(
759                "{}/update-authenticator",
760                self.config.gateway_url()
761            ))
762            .json(&req)
763            .send()
764            .await?;
765
766        let status = resp.status();
767        if status.is_success() {
768            let gateway_resp: GatewayStatusResponse = resp.json().await?;
769            Ok(gateway_resp.request_id)
770        } else {
771            let body_text = resp.text().await.unwrap_or_else(|_| String::new());
772            Err(AuthenticatorError::GatewayError {
773                status,
774                body: body_text,
775            })
776        }
777    }
778
779    /// Removes an authenticator from the account.
780    ///
781    /// # Errors
782    /// Returns an error if the gateway rejects the request or a network error occurs.
783    ///
784    /// # Note
785    /// TODO: After successfully removing an authenticator, the `packed_account_data` should be
786    /// refreshed from the registry to reflect the new `pubkey_id` commitment.
787    pub async fn remove_authenticator(
788        &mut self,
789        authenticator_address: Address,
790        index: u32,
791    ) -> Result<String, AuthenticatorError> {
792        let leaf_index = self.leaf_index();
793        let nonce = self.signing_nonce().await?;
794        let (inclusion_proof, mut key_set) = self.fetch_inclusion_proof().await?;
795        let old_commitment: U256 = key_set.leaf_hash().into();
796        let existing_pubkey = key_set
797            .get(index as usize)
798            .ok_or(AuthenticatorError::PublicKeyNotFound)?;
799
800        let encoded_old_offchain_pubkey = existing_pubkey.to_ethereum_representation()?;
801
802        key_set[index as usize] = EdDSAPublicKey {
803            pk: EdwardsAffine::default(),
804        };
805        let new_commitment: U256 = key_set.leaf_hash().into();
806
807        let eip712_domain = domain(self.config.chain_id(), *self.config.registry_address());
808
809        let signature = sign_remove_authenticator(
810            &self.signer.onchain_signer(),
811            leaf_index,
812            authenticator_address,
813            index,
814            encoded_old_offchain_pubkey,
815            new_commitment,
816            nonce,
817            &eip712_domain,
818        )
819        .await
820        .map_err(|e| {
821            AuthenticatorError::Generic(format!("Failed to sign remove authenticator: {e}"))
822        })?;
823
824        let sibling_nodes: Vec<U256> = inclusion_proof
825            .siblings
826            .iter()
827            .map(|s| (*s).into())
828            .collect();
829
830        let req = RemoveAuthenticatorRequest {
831            leaf_index,
832            authenticator_address,
833            old_offchain_signer_commitment: old_commitment,
834            new_offchain_signer_commitment: new_commitment,
835            sibling_nodes,
836            signature: signature.as_bytes().to_vec(),
837            nonce,
838            pubkey_id: Some(index),
839            authenticator_pubkey: Some(encoded_old_offchain_pubkey),
840        };
841
842        let resp = self
843            .http_client
844            .post(format!(
845                "{}/remove-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 gateway_resp: GatewayStatusResponse = resp.json().await?;
855            Ok(gateway_resp.request_id)
856        } else {
857            let body_text = resp.text().await.unwrap_or_else(|_| String::new());
858            Err(AuthenticatorError::GatewayError {
859                status,
860                body: body_text,
861            })
862        }
863    }
864}
865
866/// Represents an account in the process of being initialized,
867/// i.e. it is not yet registered in the `WorldIDRegistry` contract.
868pub struct InitializingAuthenticator {
869    request_id: String,
870    http_client: reqwest::Client,
871    config: Config,
872}
873
874impl InitializingAuthenticator {
875    /// Creates a new World ID account by adding it to the registry using the gateway.
876    ///
877    /// # Errors
878    /// - See `Signer::from_seed_bytes` for additional error details.
879    /// - Will error if the gateway rejects the request or a network error occurs.
880    async fn new(
881        seed: &[u8],
882        config: Config,
883        recovery_address: Option<Address>,
884        http_client: reqwest::Client,
885    ) -> Result<Self, AuthenticatorError> {
886        let signer = Signer::from_seed_bytes(seed)?;
887
888        let mut key_set = AuthenticatorPublicKeySet::new(None)?;
889        key_set.try_push(signer.offchain_signer_pubkey())?;
890        let leaf_hash = key_set.leaf_hash();
891
892        let offchain_pubkey_compressed = {
893            let pk = signer.offchain_signer_pubkey().pk;
894            let mut compressed_bytes = Vec::new();
895            pk.serialize_compressed(&mut compressed_bytes)
896                .map_err(|e| PrimitiveError::Serialization(e.to_string()))?;
897            U256::from_le_slice(&compressed_bytes)
898        };
899
900        let req = CreateAccountRequest {
901            recovery_address,
902            authenticator_addresses: vec![signer.onchain_signer_address()],
903            authenticator_pubkeys: vec![offchain_pubkey_compressed],
904            offchain_signer_commitment: leaf_hash.into(),
905        };
906
907        let resp = http_client
908            .post(format!("{}/create-account", config.gateway_url()))
909            .json(&req)
910            .send()
911            .await?;
912
913        let status = resp.status();
914        if status.is_success() {
915            let body: GatewayStatusResponse = resp.json().await?;
916            Ok(Self {
917                request_id: body.request_id,
918                http_client,
919                config,
920            })
921        } else {
922            let body_text = resp.text().await.unwrap_or_else(|_| String::new());
923            Err(AuthenticatorError::GatewayError {
924                status,
925                body: body_text,
926            })
927        }
928    }
929
930    /// Poll the status of the World ID creation request.
931    ///
932    /// # Errors
933    /// - Will error if the network request fails.
934    /// - Will error if the gateway returns an error response.
935    pub async fn poll_status(&self) -> Result<GatewayRequestState, AuthenticatorError> {
936        let resp = self
937            .http_client
938            .get(format!(
939                "{}/status/{}",
940                self.config.gateway_url(),
941                self.request_id
942            ))
943            .send()
944            .await?;
945
946        let status = resp.status();
947
948        if status.is_success() {
949            let body: GatewayStatusResponse = resp.json().await?;
950            Ok(body.status)
951        } else {
952            let body_text = resp.text().await.unwrap_or_else(|_| String::new());
953            Err(AuthenticatorError::GatewayError {
954                status,
955                body: body_text,
956            })
957        }
958    }
959}
960
961impl ProtocolSigner for Authenticator {
962    fn sign(&self, message: FieldElement) -> EdDSASignature {
963        self.signer
964            .offchain_signer_private_key()
965            .expose_secret()
966            .sign(*message)
967    }
968}
969
970/// A trait for types that can be represented as a `U256` on-chain.
971pub trait OnchainKeyRepresentable {
972    /// Converts an off-chain public key into a `U256` representation for on-chain use in the `WorldIDRegistry` contract.
973    ///
974    /// The `U256` representation is a 32-byte little-endian encoding of the **compressed** (single point) public key.
975    ///
976    /// # Errors
977    /// Will error if the public key unexpectedly fails to serialize.
978    fn to_ethereum_representation(&self) -> Result<U256, PrimitiveError>;
979}
980
981impl OnchainKeyRepresentable for EdDSAPublicKey {
982    // REVIEW: updating to BE
983    fn to_ethereum_representation(&self) -> Result<U256, PrimitiveError> {
984        let mut compressed_bytes = Vec::new();
985        self.pk
986            .serialize_compressed(&mut compressed_bytes)
987            .map_err(|e| PrimitiveError::Serialization(e.to_string()))?;
988        Ok(U256::from_le_slice(&compressed_bytes))
989    }
990}
991
992/// Errors that can occur when interacting with the Authenticator.
993#[derive(Debug, thiserror::Error)]
994pub enum AuthenticatorError {
995    /// Primitive error
996    #[error(transparent)]
997    PrimitiveError(#[from] PrimitiveError),
998
999    /// This operation requires a registered account and an account is not registered
1000    /// for this authenticator. Call `create_account` first to register it.
1001    #[error("Account is not registered for this authenticator.")]
1002    AccountDoesNotExist,
1003
1004    /// The account already exists for this authenticator. Call `leaf_index` to get the leaf index.
1005    #[error("Account already exists for this authenticator.")]
1006    AccountAlreadyExists,
1007
1008    /// An error occurred while interacting with the EVM contract.
1009    #[error("Error interacting with EVM contract: {0}")]
1010    ContractError(#[from] alloy::contract::Error),
1011
1012    /// Network/HTTP request error.
1013    #[error("Network error: {0}")]
1014    NetworkError(#[from] reqwest::Error),
1015
1016    /// Public key not found in the Authenticator public key set. Usually indicates the local state is out of sync with the registry.
1017    #[error("Public key not found.")]
1018    PublicKeyNotFound,
1019
1020    /// Gateway returned an error response.
1021    #[error("Gateway error (status {status}): {body}")]
1022    GatewayError {
1023        /// HTTP status code
1024        status: StatusCode,
1025        /// Response body
1026        body: String,
1027    },
1028
1029    /// Indexer returned an error response.
1030    #[error("Indexer error (status {status}): {body}")]
1031    IndexerError {
1032        /// HTTP status code
1033        status: StatusCode,
1034        /// Response body
1035        body: String,
1036    },
1037
1038    /// Account creation timed out while polling for confirmation.
1039    #[error("Account creation timed out")]
1040    Timeout,
1041
1042    /// Configuration is invalid or missing required values.
1043    #[error("Invalid configuration for {attribute}: {reason}")]
1044    InvalidConfig {
1045        /// The config attribute that is invalid.
1046        attribute: &'static str,
1047        /// Description of why it is invalid.
1048        reason: String,
1049    },
1050
1051    /// The provided credential is not valid for the provided proof request.
1052    #[error("The provided credential is not valid for the provided proof request")]
1053    InvalidCredentialForProofRequest,
1054
1055    /// Error during the World ID registration process.
1056    ///
1057    /// This usually occurs from an on-chain revert.
1058    #[error("Registration error ({error_code}): {error_message}")]
1059    RegistrationError {
1060        /// Error code from the registration process.
1061        error_code: String,
1062        /// Detailed error message.
1063        error_message: String,
1064    },
1065
1066    /// Error on proof generation
1067    #[error(transparent)]
1068    ProofError(#[from] ProofError),
1069
1070    /// Generic error for other unexpected issues.
1071    #[error("{0}")]
1072    Generic(String),
1073}
1074
1075#[cfg(feature = "embed-zkeys")]
1076#[derive(Debug)]
1077enum PollResult {
1078    Retryable,
1079    TerminalError(AuthenticatorError),
1080}
1081
1082#[cfg(all(test, feature = "embed-zkeys"))]
1083mod tests {
1084    use super::*;
1085    use alloy::primitives::{U256, address};
1086    use std::{path::PathBuf, sync::OnceLock};
1087
1088    fn test_materials() -> (Arc<CircomGroth16Material>, Arc<CircomGroth16Material>) {
1089        static QUERY: OnceLock<Arc<CircomGroth16Material>> = OnceLock::new();
1090        static NULLIFIER: OnceLock<Arc<CircomGroth16Material>> = OnceLock::new();
1091
1092        let query = QUERY.get_or_init(|| {
1093            Arc::new(
1094                world_id_proof::proof::load_embedded_query_material(Option::<PathBuf>::None)
1095                    .unwrap(),
1096            )
1097        });
1098        let nullifier = NULLIFIER.get_or_init(|| {
1099            Arc::new(
1100                world_id_proof::proof::load_embedded_nullifier_material(Option::<PathBuf>::None)
1101                    .unwrap(),
1102            )
1103        });
1104
1105        (Arc::clone(query), Arc::clone(nullifier))
1106    }
1107
1108    /// Tests that `get_packed_account_data` correctly fetches the packed account data from the indexer
1109    /// when no RPC is configured.
1110    #[tokio::test]
1111    async fn test_get_packed_account_data_from_indexer() {
1112        let mut server = mockito::Server::new_async().await;
1113        let indexer_url = server.url();
1114
1115        let test_address = address!("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0");
1116        let expected_packed_index = U256::from(42);
1117
1118        let mock = server
1119            .mock("POST", "/packed-account")
1120            .match_header("content-type", "application/json")
1121            .match_body(mockito::Matcher::JsonString(
1122                serde_json::json!({
1123                    "authenticator_address": test_address
1124                })
1125                .to_string(),
1126            ))
1127            .with_status(200)
1128            .with_header("content-type", "application/json")
1129            .with_body(
1130                serde_json::json!({
1131                    "packed_account_data": format!("{:#x}", expected_packed_index)
1132                })
1133                .to_string(),
1134            )
1135            .create_async()
1136            .await;
1137
1138        let config = Config::new(
1139            None,
1140            1,
1141            address!("0x0000000000000000000000000000000000000001"),
1142            indexer_url,
1143            "http://gateway.example.com".to_string(),
1144            Vec::new(),
1145            2,
1146        )
1147        .unwrap();
1148
1149        let http_client = reqwest::Client::new();
1150
1151        let result = Authenticator::get_packed_account_data(
1152            test_address,
1153            None, // No registry, force indexer usage
1154            &config,
1155            &http_client,
1156        )
1157        .await
1158        .unwrap();
1159
1160        assert_eq!(result, expected_packed_index);
1161        mock.assert_async().await;
1162        drop(server);
1163    }
1164
1165    #[tokio::test]
1166    async fn test_get_packed_account_data_from_indexer_error() {
1167        let mut server = mockito::Server::new_async().await;
1168        let indexer_url = server.url();
1169
1170        let test_address = address!("0x0000000000000000000000000000000000000099");
1171
1172        let mock = server
1173            .mock("POST", "/packed-account")
1174            .with_status(400)
1175            .with_header("content-type", "application/json")
1176            .with_body(
1177                serde_json::json!({
1178                    "code": "account_does_not_exist",
1179                    "message": "There is no account for this authenticator address"
1180                })
1181                .to_string(),
1182            )
1183            .create_async()
1184            .await;
1185
1186        let config = Config::new(
1187            None,
1188            1,
1189            address!("0x0000000000000000000000000000000000000001"),
1190            indexer_url,
1191            "http://gateway.example.com".to_string(),
1192            Vec::new(),
1193            2,
1194        )
1195        .unwrap();
1196
1197        let http_client = reqwest::Client::new();
1198
1199        let result =
1200            Authenticator::get_packed_account_data(test_address, None, &config, &http_client).await;
1201
1202        assert!(matches!(
1203            result,
1204            Err(AuthenticatorError::AccountDoesNotExist)
1205        ));
1206        mock.assert_async().await;
1207        drop(server);
1208    }
1209
1210    #[tokio::test]
1211    async fn test_signing_nonce_from_indexer() {
1212        let mut server = mockito::Server::new_async().await;
1213        let indexer_url = server.url();
1214
1215        let leaf_index = U256::from(1);
1216        let expected_nonce = U256::from(5);
1217
1218        let mock = server
1219            .mock("POST", "/signature-nonce")
1220            .match_header("content-type", "application/json")
1221            .match_body(mockito::Matcher::JsonString(
1222                serde_json::json!({
1223                    "leaf_index": format!("{:#x}", leaf_index)
1224                })
1225                .to_string(),
1226            ))
1227            .with_status(200)
1228            .with_header("content-type", "application/json")
1229            .with_body(
1230                serde_json::json!({
1231                    "signature_nonce": format!("{:#x}", expected_nonce)
1232                })
1233                .to_string(),
1234            )
1235            .create_async()
1236            .await;
1237
1238        let config = Config::new(
1239            None,
1240            1,
1241            address!("0x0000000000000000000000000000000000000001"),
1242            indexer_url,
1243            "http://gateway.example.com".to_string(),
1244            Vec::new(),
1245            2,
1246        )
1247        .unwrap();
1248
1249        let (query_material, nullifier_material) = test_materials();
1250        let authenticator = Authenticator {
1251            config,
1252            packed_account_data: leaf_index, // This sets leaf_index() to 1
1253            signer: Signer::from_seed_bytes(&[1u8; 32]).unwrap(),
1254            registry: None, // No registry - forces indexer usage
1255            http_client: reqwest::Client::new(),
1256            ws_connector: Connector::Plain,
1257            query_material,
1258            nullifier_material,
1259        };
1260
1261        let nonce = authenticator.signing_nonce().await.unwrap();
1262
1263        assert_eq!(nonce, expected_nonce);
1264        mock.assert_async().await;
1265        drop(server);
1266    }
1267
1268    #[tokio::test]
1269    async fn test_signing_nonce_from_indexer_error() {
1270        let mut server = mockito::Server::new_async().await;
1271        let indexer_url = server.url();
1272
1273        let mock = server
1274            .mock("POST", "/signature-nonce")
1275            .with_status(400)
1276            .with_header("content-type", "application/json")
1277            .with_body(
1278                serde_json::json!({
1279                    "code": "invalid_leaf_index",
1280                    "message": "Account index cannot be zero"
1281                })
1282                .to_string(),
1283            )
1284            .create_async()
1285            .await;
1286
1287        let config = Config::new(
1288            None,
1289            1,
1290            address!("0x0000000000000000000000000000000000000001"),
1291            indexer_url,
1292            "http://gateway.example.com".to_string(),
1293            Vec::new(),
1294            2,
1295        )
1296        .unwrap();
1297
1298        let (query_material, nullifier_material) = test_materials();
1299        let authenticator = Authenticator {
1300            config,
1301            packed_account_data: U256::ZERO,
1302            signer: Signer::from_seed_bytes(&[1u8; 32]).unwrap(),
1303            registry: None,
1304            http_client: reqwest::Client::new(),
1305            ws_connector: Connector::Plain,
1306            query_material,
1307            nullifier_material,
1308        };
1309
1310        let result = authenticator.signing_nonce().await;
1311
1312        assert!(matches!(
1313            result,
1314            Err(AuthenticatorError::IndexerError { .. })
1315        ));
1316        mock.assert_async().await;
1317        drop(server);
1318    }
1319}