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