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