Skip to main content

world_id_authenticator/
authenticator.rs

1//! This module contains all the base functionality to support Authenticators in World ID. See
2//! [`Authenticator`] for a definition.
3
4use crate::{
5    error::{AuthenticatorError, PollResult},
6    init::InitializingAuthenticator,
7};
8
9use std::sync::Arc;
10
11use crate::{
12    api_types::{
13        AccountInclusionProof, GatewayRequestState, IndexerAuthenticatorPubkeysResponse,
14        IndexerErrorCode, IndexerPackedAccountRequest, IndexerPackedAccountResponse,
15        IndexerQueryRequest, IndexerSignatureNonceResponse, ServiceApiError,
16    },
17    service_client::{ServiceClient, ServiceKind},
18};
19use serde::{Deserialize, Serialize};
20use world_id_primitives::{Credential, FieldElement, ProofResponse, Signer};
21
22pub use crate::ohttp::OhttpClientConfig;
23use crate::registry::WorldIdRegistry::WorldIdRegistryInstance;
24use alloy::{
25    primitives::Address,
26    providers::DynProvider,
27    signers::{Signature, SignerSync},
28};
29use ark_serialize::CanonicalSerialize;
30use eddsa_babyjubjub::EdDSAPublicKey;
31use groth16_material::circom::CircomGroth16Material;
32use ruint::{aliases::U256, uint};
33use taceo_oprf::client::Connector;
34pub use world_id_primitives::{Config, TREE_DEPTH, authenticator::ProtocolSigner};
35use world_id_primitives::{
36    PrimitiveError,
37    authenticator::{
38        AuthenticatorPublicKeySet, SparseAuthenticatorPubkeysError,
39        decode_sparse_authenticator_pubkeys,
40    },
41};
42
43#[expect(unused_imports, reason = "used for docs")]
44use world_id_primitives::{Nullifier, SessionId};
45
46static MASK_RECOVERY_COUNTER: U256 =
47    uint!(0xFFFFFFFF00000000000000000000000000000000000000000000000000000000_U256);
48static MASK_PUBKEY_ID: U256 =
49    uint!(0x00000000FFFFFFFF000000000000000000000000000000000000000000000000_U256);
50static MASK_LEAF_INDEX: U256 =
51    uint!(0x000000000000000000000000000000000000000000000000FFFFFFFFFFFFFFFF_U256);
52
53/// Configuration for an [`Authenticator`], extends base protocol [`Config`] by
54/// optional OHTTP relay settings for the indexer and gateway services.
55#[derive(Clone, Debug, Serialize, Deserialize)]
56pub struct AuthenticatorConfig {
57    /// Base protocol configuration (indexer URL, gateway URL, RPC, etc.).
58    #[serde(flatten)]
59    pub config: Config,
60    /// Optional OHTTP relay configuration for indexer requests.
61    #[serde(default)]
62    pub ohttp_indexer: Option<OhttpClientConfig>,
63    /// Optional OHTTP relay configuration for gateway requests.
64    #[serde(default)]
65    pub ohttp_gateway: Option<OhttpClientConfig>,
66}
67
68impl AuthenticatorConfig {
69    /// Loads an authenticator configuration from JSON.
70    ///
71    /// Accepts both plain `Config` JSON (OHTTP fields default to `None`) and
72    /// extended JSON with `ohttp_indexer` / `ohttp_gateway` fields.
73    ///
74    /// # Errors
75    /// Will error if the JSON is not valid.
76    pub fn from_json(json_str: &str) -> Result<Self, AuthenticatorError> {
77        serde_json::from_str(json_str).map_err(|e| {
78            AuthenticatorError::from(PrimitiveError::Serialization(format!(
79                "failed to parse authenticator config: {e}"
80            )))
81        })
82    }
83}
84
85impl From<Config> for AuthenticatorConfig {
86    fn from(config: Config) -> Self {
87        Self {
88            config,
89            ohttp_indexer: None,
90            ohttp_gateway: None,
91        }
92    }
93}
94
95/// Input for a single credential proof within a proof request.
96pub struct CredentialInput {
97    /// The credential to prove.
98    pub credential: Credential,
99    /// The blinding factor for the credential's sub.
100    pub blinding_factor: FieldElement,
101}
102
103/// Output from proof generation process.
104///
105/// The [`Authenticator`] herein deliberately does not handle caching or replay guards as
106/// those are SDK concerns.
107#[derive(Debug)]
108pub struct ProofResult {
109    /// The session_id_r_seed (`r`), if a session proof was generated.
110    ///
111    /// The SDK should cache this keyed by [`SessionId::oprf_seed`].
112    pub session_id_r_seed: Option<FieldElement>,
113
114    /// The response to deliver to an RP.
115    pub proof_response: ProofResponse,
116}
117
118/// An Authenticator is the agent of a **user** interacting with the World ID Protocol.
119///
120/// # Definition
121///
122/// A software or hardware agent (e.g., app, device, web client, or service) that controls a
123/// set of authorized keypairs for a World ID Account and is functionally capable of interacting
124/// with the Protocol, and is therefore permitted to act on that account's behalf. An Authenticator
125/// is the agent of users/holders. Each Authenticator is registered in the `WorldIDRegistry`
126/// through their authorized keypairs.
127///
128/// For example, an Authenticator can live in a mobile wallet or a web application.
129pub struct Authenticator {
130    /// General configuration for the Authenticator.
131    pub config: Config,
132    /// The packed account data for the holder's World ID is a `uint256` defined in the `WorldIDRegistry` contract as:
133    /// `recovery_counter` (32 bits) | `pubkey_id` (commitment to all off-chain public keys) (32 bits) | `leaf_index` (192 bits)
134    pub packed_account_data: U256,
135    pub(crate) signer: Signer,
136    pub(crate) registry: Option<Arc<WorldIdRegistryInstance<DynProvider>>>,
137    pub(crate) indexer_client: ServiceClient,
138    pub(crate) gateway_client: ServiceClient,
139    pub(crate) ws_connector: Connector,
140    pub(crate) query_material: Option<Arc<CircomGroth16Material>>,
141    pub(crate) nullifier_material: Option<Arc<CircomGroth16Material>>,
142}
143
144impl std::fmt::Debug for Authenticator {
145    // avoiding logging other attributes to avoid accidental leak of leaf_index
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        f.debug_struct("Authenticator")
148            .field("config", &self.config)
149            .finish_non_exhaustive()
150    }
151}
152
153impl Authenticator {
154    /// Initialize an Authenticator from a seed and config.
155    ///
156    /// This method will error if the World ID account does not exist on the registry.
157    ///
158    /// # Errors
159    /// - Will error if the provided seed is invalid (not 32 bytes).
160    /// - Will error if the RPC URL is invalid.
161    /// - Will error if there are contract call failures.
162    /// - Will error if the account does not exist (`AccountDoesNotExist`).
163    pub async fn init(
164        seed: &[u8],
165        config: AuthenticatorConfig,
166    ) -> Result<Self, AuthenticatorError> {
167        let AuthenticatorConfig {
168            config,
169            ohttp_indexer,
170            ohttp_gateway,
171        } = config;
172
173        let signer = Signer::from_seed_bytes(seed)?;
174
175        let registry: Option<Arc<WorldIdRegistryInstance<DynProvider>>> =
176            config.rpc_url().map(|rpc_url| {
177                let provider = alloy::providers::ProviderBuilder::new()
178                    .with_chain_id(config.chain_id())
179                    .connect_http(rpc_url.clone());
180                Arc::new(crate::registry::WorldIdRegistry::new(
181                    *config.registry_address(),
182                    alloy::providers::Provider::erased(provider),
183                ))
184            });
185
186        let http_client = reqwest::Client::new();
187
188        let indexer_client = ServiceClient::new(
189            http_client.clone(),
190            ServiceKind::Indexer,
191            config.indexer_url(),
192            ohttp_indexer,
193        )?;
194
195        let gateway_client = ServiceClient::new(
196            http_client,
197            ServiceKind::Gateway,
198            config.gateway_url(),
199            ohttp_gateway,
200        )?;
201
202        let packed_account_data = Self::get_packed_account_data(
203            signer.onchain_signer_address(),
204            registry.as_deref(),
205            &config,
206            &indexer_client,
207        )
208        .await?;
209
210        #[cfg(not(target_arch = "wasm32"))]
211        let ws_connector = {
212            let mut root_store = rustls::RootCertStore::empty();
213            root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
214            let rustls_config = rustls::ClientConfig::builder()
215                .with_root_certificates(root_store)
216                .with_no_client_auth();
217            Connector::Rustls(Arc::new(rustls_config))
218        };
219
220        #[cfg(target_arch = "wasm32")]
221        let ws_connector = Connector;
222
223        Ok(Self {
224            packed_account_data,
225            signer,
226            config,
227            registry,
228            indexer_client,
229            gateway_client,
230            ws_connector,
231            query_material: None,
232            nullifier_material: None,
233        })
234    }
235
236    /// Sets the proof materials for the Authenticator, returning a new instance.
237    ///
238    /// Proof materials are required for proof generation, blinding factors and starting
239    /// sessions. Given the proof circuits are large, this may be loaded only when necessary.
240    #[must_use]
241    pub fn with_proof_materials(
242        self,
243        query_material: Arc<CircomGroth16Material>,
244        nullifier_material: Arc<CircomGroth16Material>,
245    ) -> Self {
246        Self {
247            query_material: Some(query_material),
248            nullifier_material: Some(nullifier_material),
249            ..self
250        }
251    }
252
253    /// Registers a new World ID in the `WorldIDRegistry`.
254    ///
255    /// Given the registration process is asynchronous, this method will return a `InitializingAuthenticator`
256    /// object.
257    ///
258    /// # Errors
259    /// - See `init` for additional error details.
260    pub async fn register(
261        seed: &[u8],
262        config: AuthenticatorConfig,
263        recovery_address: Option<Address>,
264    ) -> Result<InitializingAuthenticator, AuthenticatorError> {
265        let AuthenticatorConfig {
266            config,
267            ohttp_gateway,
268            ..
269        } = config;
270        let gateway_client = ServiceClient::new(
271            reqwest::Client::new(),
272            ServiceKind::Gateway,
273            config.gateway_url(),
274            ohttp_gateway,
275        )?;
276        InitializingAuthenticator::new(seed, config, recovery_address, gateway_client).await
277    }
278
279    /// Initializes (if the World ID already exists in the registry) or registers a new World ID.
280    ///
281    /// The registration process is asynchronous and may take some time. This method will block
282    /// the thread until the registration is in a final state (success or terminal error). For better
283    /// user experience in end authenticator clients, it is recommended to implement custom polling logic.
284    ///
285    /// Explicit `init` or `register` calls are also recommended as the authenticator should know
286    /// if a new World ID should be truly created. For example, an authenticator may have been revoked
287    /// access to an existing World ID.
288    ///
289    /// # Errors
290    /// - See `init` for additional error details.
291    pub async fn init_or_register(
292        seed: &[u8],
293        config: AuthenticatorConfig,
294        recovery_address: Option<Address>,
295    ) -> Result<Self, AuthenticatorError> {
296        match Self::init(seed, config.clone()).await {
297            Ok(authenticator) => Ok(authenticator),
298            Err(AuthenticatorError::AccountDoesNotExist) => {
299                let gateway_client = ServiceClient::new(
300                    reqwest::Client::new(),
301                    ServiceKind::Gateway,
302                    config.config.gateway_url(),
303                    config.ohttp_gateway.clone(),
304                )?;
305                let initializing_authenticator = InitializingAuthenticator::new(
306                    seed,
307                    config.config.clone(),
308                    recovery_address,
309                    gateway_client,
310                )
311                .await?;
312
313                let backoff = backon::ExponentialBuilder::default()
314                    .with_min_delay(std::time::Duration::from_millis(800))
315                    .with_factor(1.5)
316                    .without_max_times()
317                    .with_total_delay(Some(std::time::Duration::from_secs(120)));
318
319                let poller = || async {
320                    let poll_status = initializing_authenticator.poll_status().await;
321                    let result = match poll_status {
322                        Ok(GatewayRequestState::Finalized { .. }) => Ok(()),
323                        Ok(GatewayRequestState::Failed { error_code, error }) => Err(
324                            PollResult::TerminalError(AuthenticatorError::RegistrationError {
325                                error_code: error_code.map(|v| v.to_string()).unwrap_or_default(),
326                                error_message: error,
327                            }),
328                        ),
329                        Err(AuthenticatorError::GatewayError { status, body }) => {
330                            if status.is_client_error() {
331                                Err(PollResult::TerminalError(
332                                    AuthenticatorError::GatewayError { status, body },
333                                ))
334                            } else {
335                                Err(PollResult::Retryable)
336                            }
337                        }
338                        _ => Err(PollResult::Retryable),
339                    };
340
341                    match result {
342                        Ok(()) => match Self::init(seed, config.clone()).await {
343                            Ok(auth) => Ok(auth),
344                            Err(AuthenticatorError::AccountDoesNotExist) => {
345                                Err(PollResult::Retryable)
346                            }
347                            Err(e) => Err(PollResult::TerminalError(e)),
348                        },
349                        Err(e) => Err(e),
350                    }
351                };
352
353                let result = backon::Retryable::retry(poller, backoff)
354                    .when(|e| matches!(e, PollResult::Retryable))
355                    .await;
356
357                match result {
358                    Ok(authenticator) => Ok(authenticator),
359                    Err(PollResult::TerminalError(e)) => Err(e),
360                    Err(PollResult::Retryable) => Err(AuthenticatorError::Timeout),
361                }
362            }
363            Err(e) => Err(e),
364        }
365    }
366
367    /// Re-fetches the packed account data for this authenticator from the indexer or registry.
368    ///
369    /// # Errors
370    /// Will error if the network call fails or if the account does not exist.
371    pub async fn refresh_packed_account_data(&self) -> Result<U256, AuthenticatorError> {
372        Self::get_packed_account_data(
373            self.onchain_address(),
374            self.registry().as_deref(),
375            &self.config,
376            &self.indexer_client,
377        )
378        .await
379    }
380
381    /// Returns the packed account data for the holder's World ID.
382    ///
383    /// The packed account data is a 256 bit integer which includes the World ID's leaf index, their recovery counter,
384    /// and their pubkey id/commitment.
385    ///
386    /// # Errors
387    /// Will error if the network call fails or if the account does not exist.
388    async fn get_packed_account_data(
389        onchain_signer_address: Address,
390        registry: Option<&WorldIdRegistryInstance<DynProvider>>,
391        config: &Config,
392        indexer_client: &ServiceClient,
393    ) -> Result<U256, AuthenticatorError> {
394        // If the registry is available through direct RPC calls, use it. Otherwise fallback to the indexer.
395        let raw_index = if let Some(registry) = registry {
396            // TODO: Better error handling to expose the specific failure
397            registry
398                .getPackedAccountData(onchain_signer_address)
399                .call()
400                .await?
401        } else {
402            let req = IndexerPackedAccountRequest {
403                authenticator_address: onchain_signer_address,
404            };
405            match indexer_client
406                .post_json::<_, IndexerPackedAccountResponse>(
407                    config.indexer_url(),
408                    "/packed-account",
409                    &req,
410                )
411                .await
412            {
413                Ok(response) => response.packed_account_data,
414                Err(AuthenticatorError::IndexerError { status, body }) => {
415                    if let Ok(error_resp) =
416                        serde_json::from_str::<ServiceApiError<IndexerErrorCode>>(&body)
417                    {
418                        return match error_resp.code {
419                            IndexerErrorCode::AccountDoesNotExist => {
420                                Err(AuthenticatorError::AccountDoesNotExist)
421                            }
422                            _ => Err(AuthenticatorError::IndexerError {
423                                status,
424                                body: error_resp.message,
425                            }),
426                        };
427                    }
428
429                    return Err(AuthenticatorError::IndexerError { status, body });
430                }
431                Err(other) => return Err(other),
432            }
433        };
434
435        if raw_index == U256::ZERO {
436            return Err(AuthenticatorError::AccountDoesNotExist);
437        }
438
439        Ok(raw_index)
440    }
441
442    /// Returns the k256 public key of the Authenticator signer which is used to verify on-chain operations,
443    /// chiefly with the `WorldIdRegistry` contract.
444    #[must_use]
445    pub const fn onchain_address(&self) -> Address {
446        self.signer.onchain_signer_address()
447    }
448
449    /// Returns the `EdDSA` public key of the Authenticator signer which is used to verify off-chain operations. For example,
450    /// the Nullifier Oracle uses it to verify requests for nullifiers.
451    #[must_use]
452    pub fn offchain_pubkey(&self) -> EdDSAPublicKey {
453        self.signer.offchain_signer_pubkey()
454    }
455
456    /// Returns the compressed `EdDSA` public key of the Authenticator signer which is used to verify off-chain operations.
457    /// For example, the Nullifier Oracle uses it to verify requests for nullifiers.
458    /// # Errors
459    /// Will error if the public key cannot be serialized.
460    pub fn offchain_pubkey_compressed(&self) -> Result<U256, AuthenticatorError> {
461        let pk = self.signer.offchain_signer_pubkey().pk;
462        let mut compressed_bytes = Vec::new();
463        pk.serialize_compressed(&mut compressed_bytes)
464            .map_err(|e| PrimitiveError::Serialization(e.to_string()))?;
465        Ok(U256::from_le_slice(&compressed_bytes))
466    }
467
468    /// Returns a reference to the `WorldIdRegistry` contract instance.
469    #[must_use]
470    pub fn registry(&self) -> Option<Arc<WorldIdRegistryInstance<DynProvider>>> {
471        self.registry.clone()
472    }
473
474    /// Returns the index for the holder's World ID.
475    ///
476    /// # Definition
477    ///
478    /// The `leaf_index` is the main (internal) identifier of a World ID. It is registered in
479    /// the `WorldIDRegistry` and represents the index at the Merkle tree where the World ID
480    /// resides.
481    ///
482    /// # Notes
483    /// - The `leaf_index` is used as input in the nullifier generation, ensuring a nullifier
484    ///   will always be the same for the same RP context and the same World ID (allowing for uniqueness).
485    /// - The `leaf_index` is generally not exposed outside Authenticators. It is not a secret because
486    ///   it's not exposed to RPs outside ZK-circuits, but the only acceptable exposure outside an Authenticator
487    ///   is to fetch Merkle inclusion proofs from an indexer or it may create a pseudonymous identifier.
488    /// - The `leaf_index` is stored as a `uint64` inside packed account data.
489    #[must_use]
490    pub fn leaf_index(&self) -> u64 {
491        (self.packed_account_data & MASK_LEAF_INDEX).to::<u64>()
492    }
493
494    /// Returns the recovery counter for the holder's World ID.
495    ///
496    /// The recovery counter is used to efficiently invalidate all the old keys when an account is recovered.
497    #[must_use]
498    pub fn recovery_counter(&self) -> U256 {
499        let recovery_counter = self.packed_account_data & MASK_RECOVERY_COUNTER;
500        recovery_counter >> 224
501    }
502
503    /// Returns the pubkey id (or commitment) for the holder's World ID.
504    ///
505    /// This is a commitment to all the off-chain public keys that are authorized to act on behalf of the holder.
506    #[must_use]
507    pub fn pubkey_id(&self) -> U256 {
508        let pubkey_id = self.packed_account_data & MASK_PUBKEY_ID;
509        pubkey_id >> 192
510    }
511
512    /// Fetches a Merkle inclusion proof for the holder's World ID given their account index.
513    ///
514    /// # Errors
515    /// - Will error if the provided indexer URL is not valid or if there are HTTP call failures.
516    /// - Will error if the user is not registered on the `WorldIDRegistry`.
517    pub async fn fetch_inclusion_proof(
518        &self,
519    ) -> Result<AccountInclusionProof<TREE_DEPTH>, AuthenticatorError> {
520        let req = IndexerQueryRequest {
521            leaf_index: self.leaf_index(),
522        };
523        let response: AccountInclusionProof<TREE_DEPTH> = self
524            .indexer_client
525            .post_json(self.config.indexer_url(), "/inclusion-proof", &req)
526            .await?;
527
528        Ok(response)
529    }
530
531    /// Fetches the current authenticator public key set for the account.
532    ///
533    /// This is used by mutation operations to compute old/new offchain signer commitments
534    /// without requiring Merkle proof generation.
535    ///
536    /// # Errors
537    /// - Will error if the provided indexer URL is not valid or if there are HTTP call failures.
538    /// - Will error if the user is not registered on the `WorldIDRegistry`.
539    pub async fn fetch_authenticator_pubkeys(
540        &self,
541    ) -> Result<AuthenticatorPublicKeySet, AuthenticatorError> {
542        let req = IndexerQueryRequest {
543            leaf_index: self.leaf_index(),
544        };
545        let response: IndexerAuthenticatorPubkeysResponse = self
546            .indexer_client
547            .post_json(self.config.indexer_url(), "/authenticator-pubkeys", &req)
548            .await?;
549        Self::decode_indexer_pubkeys(response.authenticator_pubkeys)
550    }
551
552    /// Returns the signing nonce for the holder's World ID.
553    ///
554    /// # Errors
555    /// Will return an error if the registry contract call fails.
556    pub async fn signing_nonce(&self) -> Result<U256, AuthenticatorError> {
557        let registry = self.registry();
558        if let Some(registry) = registry {
559            let nonce = registry.getSignatureNonce(self.leaf_index()).call().await?;
560            Ok(nonce)
561        } else {
562            let req = IndexerQueryRequest {
563                leaf_index: self.leaf_index(),
564            };
565            let response: IndexerSignatureNonceResponse = self
566                .indexer_client
567                .post_json(self.config.indexer_url(), "/signature-nonce", &req)
568                .await?;
569            Ok(response.signature_nonce)
570        }
571    }
572
573    /// Signs an arbitrary challenge with the authenticator's on-chain key following
574    /// [ERC-191](https://eips.ethereum.org/EIPS/eip-191).
575    ///
576    /// # Warning
577    /// This is considered a dangerous operation because it leaks the user's on-chain key,
578    /// hence its `leaf_index`. The only acceptable use is to prove the user's `leaf_index`
579    /// to a Recovery Agent. The Recovery Agent is the only party beyond the user who needs
580    /// to know the `leaf_index`.
581    ///
582    /// # Use
583    /// - This method is used to prove ownership over a leaf index **only for Recovery Agents**.
584    pub fn danger_sign_challenge(&self, challenge: &[u8]) -> Result<Signature, AuthenticatorError> {
585        self.signer
586            .onchain_signer()
587            .sign_message_sync(challenge)
588            .map_err(|e| AuthenticatorError::Generic(format!("signature error: {e}")))
589    }
590
591    pub(crate) fn decode_indexer_pubkeys(
592        pubkeys: Vec<Option<U256>>,
593    ) -> Result<AuthenticatorPublicKeySet, AuthenticatorError> {
594        decode_sparse_authenticator_pubkeys(pubkeys).map_err(|e| match e {
595            SparseAuthenticatorPubkeysError::SlotOutOfBounds {
596                slot_index,
597                max_supported_slot,
598            } => AuthenticatorError::InvalidIndexerPubkeySlot {
599                slot_index,
600                max_supported_slot,
601            },
602            SparseAuthenticatorPubkeysError::InvalidCompressedPubkey { slot_index, reason } => {
603                PrimitiveError::Deserialization(format!(
604                    "invalid authenticator public key returned by indexer at slot {slot_index}: {reason}"
605                ))
606                .into()
607            }
608        })
609    }
610
611    pub(crate) fn insert_or_reuse_authenticator_key(
612        key_set: &mut AuthenticatorPublicKeySet,
613        new_authenticator_pubkey: EdDSAPublicKey,
614    ) -> Result<usize, AuthenticatorError> {
615        if let Some(index) = key_set.iter().position(Option::is_none) {
616            key_set.try_set_at_index(index, new_authenticator_pubkey)?;
617            Ok(index)
618        } else {
619            key_set.try_push(new_authenticator_pubkey)?;
620            Ok(key_set.len() - 1)
621        }
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use crate::{error::AuthenticatorError, traits::OnchainKeyRepresentable};
629    use alloy::primitives::{U256, address};
630    use world_id_primitives::authenticator::MAX_AUTHENTICATOR_KEYS;
631
632    fn test_pubkey(seed_byte: u8) -> EdDSAPublicKey {
633        Signer::from_seed_bytes(&[seed_byte; 32])
634            .unwrap()
635            .offchain_signer_pubkey()
636    }
637
638    fn encoded_test_pubkey(seed_byte: u8) -> U256 {
639        test_pubkey(seed_byte).to_ethereum_representation().unwrap()
640    }
641
642    #[test]
643    fn test_insert_or_reuse_authenticator_key_reuses_empty_slot() {
644        let mut key_set =
645            AuthenticatorPublicKeySet::new(vec![test_pubkey(1), test_pubkey(2), test_pubkey(4)])
646                .unwrap();
647        key_set[1] = None;
648        let new_key = test_pubkey(3);
649
650        let index =
651            Authenticator::insert_or_reuse_authenticator_key(&mut key_set, new_key).unwrap();
652
653        assert_eq!(index, 1);
654        assert_eq!(key_set.len(), 3);
655        assert_eq!(key_set[1].as_ref().unwrap().pk, test_pubkey(3).pk);
656    }
657
658    #[test]
659    fn test_insert_or_reuse_authenticator_key_appends_when_no_empty_slot() {
660        let mut key_set = AuthenticatorPublicKeySet::new(vec![test_pubkey(1)]).unwrap();
661        let new_key = test_pubkey(2);
662
663        let index =
664            Authenticator::insert_or_reuse_authenticator_key(&mut key_set, new_key).unwrap();
665
666        assert_eq!(index, 1);
667        assert_eq!(key_set.len(), 2);
668        assert_eq!(key_set[1].as_ref().unwrap().pk, test_pubkey(2).pk);
669    }
670
671    #[test]
672    fn test_decode_indexer_pubkeys_trims_trailing_empty_slots() {
673        let mut encoded_pubkeys = vec![Some(encoded_test_pubkey(1)), Some(encoded_test_pubkey(2))];
674        encoded_pubkeys.extend(vec![None; MAX_AUTHENTICATOR_KEYS + 5]);
675
676        let key_set = Authenticator::decode_indexer_pubkeys(encoded_pubkeys).unwrap();
677
678        assert_eq!(key_set.len(), 2);
679        assert_eq!(key_set[0].as_ref().unwrap().pk, test_pubkey(1).pk);
680        assert_eq!(key_set[1].as_ref().unwrap().pk, test_pubkey(2).pk);
681    }
682
683    #[test]
684    fn test_decode_indexer_pubkeys_rejects_used_slot_beyond_max() {
685        let mut encoded_pubkeys = vec![None; MAX_AUTHENTICATOR_KEYS + 1];
686        encoded_pubkeys[MAX_AUTHENTICATOR_KEYS] = Some(encoded_test_pubkey(1));
687
688        let error = Authenticator::decode_indexer_pubkeys(encoded_pubkeys).unwrap_err();
689        assert!(matches!(
690            error,
691            AuthenticatorError::InvalidIndexerPubkeySlot {
692                slot_index,
693                max_supported_slot
694            } if slot_index == MAX_AUTHENTICATOR_KEYS && max_supported_slot == MAX_AUTHENTICATOR_KEYS - 1
695        ));
696    }
697
698    #[tokio::test]
699    async fn test_get_packed_account_data_from_indexer() {
700        let mut server = mockito::Server::new_async().await;
701        let indexer_url = server.url();
702        let test_address = address!("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0");
703        let expected_packed_index = U256::from(42);
704        let mock = server
705            .mock("POST", "/packed-account")
706            .match_header("content-type", "application/json")
707            .match_body(mockito::Matcher::JsonString(
708                serde_json::json!({ "authenticator_address": test_address }).to_string(),
709            ))
710            .with_status(200)
711            .with_header("content-type", "application/json")
712            .with_body(
713                serde_json::json!({ "packed_account_data": format!("{:#x}", expected_packed_index) }).to_string(),
714            )
715            .create_async()
716            .await;
717        let config = Config::new(
718            None,
719            1,
720            address!("0x0000000000000000000000000000000000000001"),
721            indexer_url,
722            "http://gateway.example.com".to_string(),
723            Vec::new(),
724            2,
725        )
726        .unwrap();
727
728        let indexer_client = ServiceClient::new(
729            reqwest::Client::new(),
730            ServiceKind::Indexer,
731            config.indexer_url(),
732            None,
733        )
734        .unwrap();
735
736        let result = Authenticator::get_packed_account_data(
737            test_address,
738            None, // No registry, force indexer usage
739            &config,
740            &indexer_client,
741        )
742        .await
743        .unwrap();
744
745        assert_eq!(result, expected_packed_index);
746        mock.assert_async().await;
747        drop(server);
748    }
749
750    #[tokio::test]
751    async fn test_get_packed_account_data_from_indexer_error() {
752        let mut server = mockito::Server::new_async().await;
753        let indexer_url = server.url();
754        let test_address = address!("0x0000000000000000000000000000000000000099");
755        let mock = server
756            .mock("POST", "/packed-account")
757            .with_status(400)
758            .with_header("content-type", "application/json")
759            .with_body(serde_json::json!({ "code": "account_does_not_exist", "message": "There is no account for this authenticator address" }).to_string())
760            .create_async()
761            .await;
762        let config = Config::new(
763            None,
764            1,
765            address!("0x0000000000000000000000000000000000000001"),
766            indexer_url,
767            "http://gateway.example.com".to_string(),
768            Vec::new(),
769            2,
770        )
771        .unwrap();
772
773        let indexer_client = ServiceClient::new(
774            reqwest::Client::new(),
775            ServiceKind::Indexer,
776            config.indexer_url(),
777            None,
778        )
779        .unwrap();
780
781        let result =
782            Authenticator::get_packed_account_data(test_address, None, &config, &indexer_client)
783                .await;
784
785        assert!(matches!(
786            result,
787            Err(AuthenticatorError::AccountDoesNotExist)
788        ));
789        mock.assert_async().await;
790        drop(server);
791    }
792
793    #[tokio::test]
794    #[cfg(not(target_arch = "wasm32"))]
795    async fn test_signing_nonce_from_indexer() {
796        let mut server = mockito::Server::new_async().await;
797        let indexer_url = server.url();
798        let leaf_index = U256::from(1);
799        let expected_nonce = U256::from(5);
800        let mock = server
801            .mock("POST", "/signature-nonce")
802            .match_header("content-type", "application/json")
803            .match_body(mockito::Matcher::JsonString(
804                serde_json::json!({ "leaf_index": format!("{:#x}", leaf_index) }).to_string(),
805            ))
806            .with_status(200)
807            .with_header("content-type", "application/json")
808            .with_body(
809                serde_json::json!({ "signature_nonce": format!("{:#x}", expected_nonce) })
810                    .to_string(),
811            )
812            .create_async()
813            .await;
814        let config = Config::new(
815            None,
816            1,
817            address!("0x0000000000000000000000000000000000000001"),
818            indexer_url,
819            "http://gateway.example.com".to_string(),
820            Vec::new(),
821            2,
822        )
823        .unwrap();
824
825        let http_client = reqwest::Client::new();
826        let authenticator = Authenticator {
827            config: config.clone(),
828            packed_account_data: leaf_index,
829            signer: Signer::from_seed_bytes(&[1u8; 32]).unwrap(),
830            registry: None,
831            indexer_client: ServiceClient::new(
832                http_client.clone(),
833                ServiceKind::Indexer,
834                config.indexer_url(),
835                None,
836            )
837            .unwrap(),
838            gateway_client: ServiceClient::new(
839                http_client,
840                ServiceKind::Gateway,
841                config.gateway_url(),
842                None,
843            )
844            .unwrap(),
845            ws_connector: Connector::Plain,
846            query_material: None,
847            nullifier_material: None,
848        };
849        let nonce = authenticator.signing_nonce().await.unwrap();
850        assert_eq!(nonce, expected_nonce);
851        mock.assert_async().await;
852        drop(server);
853    }
854
855    #[test]
856    fn test_danger_sign_challenge_returns_valid_signature() {
857        let config = Config::new(
858            None,
859            1,
860            address!("0x0000000000000000000000000000000000000001"),
861            "http://indexer.example.com".to_string(),
862            "http://gateway.example.com".to_string(),
863            Vec::new(),
864            2,
865        )
866        .unwrap();
867        let http_client = reqwest::Client::new();
868        let authenticator = Authenticator {
869            indexer_client: ServiceClient::new(
870                http_client.clone(),
871                ServiceKind::Indexer,
872                config.indexer_url(),
873                None,
874            )
875            .unwrap(),
876            gateway_client: ServiceClient::new(
877                http_client,
878                ServiceKind::Gateway,
879                config.gateway_url(),
880                None,
881            )
882            .unwrap(),
883            config,
884            packed_account_data: U256::from(1),
885            signer: Signer::from_seed_bytes(&[1u8; 32]).unwrap(),
886            registry: None,
887            ws_connector: Connector::Plain,
888            query_material: None,
889            nullifier_material: None,
890        };
891        let challenge = b"test challenge";
892        let signature = authenticator.danger_sign_challenge(challenge).unwrap();
893        let recovered = signature
894            .recover_address_from_msg(challenge)
895            .expect("should recover address");
896        assert_eq!(recovered, authenticator.onchain_address());
897    }
898
899    #[test]
900    fn test_danger_sign_challenge_different_challenges_different_signatures() {
901        let config = Config::new(
902            None,
903            1,
904            address!("0x0000000000000000000000000000000000000001"),
905            "http://indexer.example.com".to_string(),
906            "http://gateway.example.com".to_string(),
907            Vec::new(),
908            2,
909        )
910        .unwrap();
911        let http_client = reqwest::Client::new();
912        let authenticator = Authenticator {
913            indexer_client: ServiceClient::new(
914                http_client.clone(),
915                ServiceKind::Indexer,
916                config.indexer_url(),
917                None,
918            )
919            .unwrap(),
920            gateway_client: ServiceClient::new(
921                http_client,
922                ServiceKind::Gateway,
923                config.gateway_url(),
924                None,
925            )
926            .unwrap(),
927            config,
928            packed_account_data: U256::from(1),
929            signer: Signer::from_seed_bytes(&[1u8; 32]).unwrap(),
930            registry: None,
931            ws_connector: Connector::Plain,
932            query_material: None,
933            nullifier_material: None,
934        };
935        let sig_a = authenticator.danger_sign_challenge(b"challenge A").unwrap();
936        let sig_b = authenticator.danger_sign_challenge(b"challenge B").unwrap();
937        assert_ne!(sig_a, sig_b);
938    }
939
940    #[test]
941    fn test_danger_sign_challenge_deterministic() {
942        let config = Config::new(
943            None,
944            1,
945            address!("0x0000000000000000000000000000000000000001"),
946            "http://indexer.example.com".to_string(),
947            "http://gateway.example.com".to_string(),
948            Vec::new(),
949            2,
950        )
951        .unwrap();
952        let http_client = reqwest::Client::new();
953        let authenticator = Authenticator {
954            indexer_client: ServiceClient::new(
955                http_client.clone(),
956                ServiceKind::Indexer,
957                config.indexer_url(),
958                None,
959            )
960            .unwrap(),
961            gateway_client: ServiceClient::new(
962                http_client,
963                ServiceKind::Gateway,
964                config.gateway_url(),
965                None,
966            )
967            .unwrap(),
968            config,
969            packed_account_data: U256::from(1),
970            signer: Signer::from_seed_bytes(&[1u8; 32]).unwrap(),
971            registry: None,
972            ws_connector: Connector::Plain,
973            query_material: None,
974            nullifier_material: None,
975        };
976        let challenge = b"deterministic test";
977        let sig1 = authenticator.danger_sign_challenge(challenge).unwrap();
978        let sig2 = authenticator.danger_sign_challenge(challenge).unwrap();
979        assert_eq!(sig1, sig2);
980    }
981
982    #[tokio::test]
983    #[cfg(not(target_arch = "wasm32"))]
984    async fn test_signing_nonce_from_indexer_error() {
985        let mut server = mockito::Server::new_async().await;
986        let indexer_url = server.url();
987        let mock = server
988            .mock("POST", "/signature-nonce")
989            .with_status(400)
990            .with_header("content-type", "application/json")
991            .with_body(serde_json::json!({ "code": "invalid_leaf_index", "message": "Account index cannot be zero" }).to_string())
992            .create_async()
993            .await;
994        let config = Config::new(
995            None,
996            1,
997            address!("0x0000000000000000000000000000000000000001"),
998            indexer_url,
999            "http://gateway.example.com".to_string(),
1000            Vec::new(),
1001            2,
1002        )
1003        .unwrap();
1004
1005        let http_client = reqwest::Client::new();
1006        let authenticator = Authenticator {
1007            config: config.clone(),
1008            packed_account_data: U256::ZERO,
1009            signer: Signer::from_seed_bytes(&[1u8; 32]).unwrap(),
1010            registry: None,
1011            indexer_client: ServiceClient::new(
1012                http_client.clone(),
1013                ServiceKind::Indexer,
1014                config.indexer_url(),
1015                None,
1016            )
1017            .unwrap(),
1018            gateway_client: ServiceClient::new(
1019                http_client,
1020                ServiceKind::Gateway,
1021                config.gateway_url(),
1022                None,
1023            )
1024            .unwrap(),
1025            ws_connector: Connector::Plain,
1026            query_material: None,
1027            nullifier_material: None,
1028        };
1029        let result = authenticator.signing_nonce().await;
1030        assert!(matches!(
1031            result,
1032            Err(AuthenticatorError::IndexerError { .. })
1033        ));
1034        mock.assert_async().await;
1035        drop(server);
1036    }
1037
1038    #[test]
1039    fn test_authenticator_config_from_json_plain_config() {
1040        let json = serde_json::json!({
1041            "chain_id": 480,
1042            "registry_address": "0x0000000000000000000000000000000000000001",
1043            "indexer_url": "http://indexer.example.com",
1044            "gateway_url": "http://gateway.example.com",
1045            "nullifier_oracle_urls": [],
1046            "nullifier_oracle_threshold": 2
1047        });
1048
1049        let config = AuthenticatorConfig::from_json(&json.to_string()).unwrap();
1050        assert!(config.ohttp_indexer.is_none());
1051        assert!(config.ohttp_gateway.is_none());
1052        assert_eq!(config.config.gateway_url(), "http://gateway.example.com");
1053    }
1054
1055    #[test]
1056    fn test_authenticator_config_from_json_with_ohttp() {
1057        let json = serde_json::json!({
1058            "chain_id": 480,
1059            "registry_address": "0x0000000000000000000000000000000000000001",
1060            "indexer_url": "http://indexer.example.com",
1061            "gateway_url": "http://gateway.example.com",
1062            "nullifier_oracle_urls": [],
1063            "nullifier_oracle_threshold": 2,
1064            "ohttp_indexer": {
1065                "relay_url": "https://relay.example.com/gateway",
1066                "key_config_base64": "dGVzdC1rZXk="
1067            },
1068            "ohttp_gateway": {
1069                "relay_url": "https://relay.example.com/gateway",
1070                "key_config_base64": "dGVzdC1rZXk="
1071            }
1072        });
1073
1074        let config = AuthenticatorConfig::from_json(&json.to_string()).unwrap();
1075        let ohttp_indexer = config.ohttp_indexer.unwrap();
1076        assert_eq!(ohttp_indexer.relay_url, "https://relay.example.com/gateway");
1077        assert_eq!(ohttp_indexer.key_config_base64, "dGVzdC1rZXk=");
1078        let ohttp_gateway = config.ohttp_gateway.unwrap();
1079        assert_eq!(ohttp_gateway.relay_url, "https://relay.example.com/gateway");
1080        assert_eq!(ohttp_gateway.key_config_base64, "dGVzdC1rZXk=");
1081    }
1082}