1use crate::{
4 authenticator::artifacts::WalletKitZkArtifactSource, defaults,
5 error::WalletKitError, primitives::ParseFromForeignBinding, Environment,
6 FieldElement, Region,
7};
8use alloy_core::primitives::Address;
9use ruint::aliases::U256;
10use ruint_uniffi::Uint256;
11use std::sync::Arc;
12use world_id_core::{
13 api_types::{GatewayErrorCode, GatewayRequestId, GatewayRequestState},
14 primitives::{AuthenticatorPublicKeySet, Config, MAX_AUTHENTICATOR_KEYS},
15 Authenticator as CoreAuthenticator, AuthenticatorError,
16 Credential as CoreCredential, CredentialInput, EdDSAPublicKey,
17 InitializingAuthenticator as CoreInitializingAuthenticator,
18 OnchainKeyRepresentable, Signer,
19};
20
21use crate::requests::{ProofRequest, ProofResponse};
22use crate::storage::CredentialStore;
23use crate::OwnershipProof;
24
25pub mod artifacts;
26mod with_storage;
27
28#[derive(Debug, uniffi::Object)]
30pub struct Authenticator {
31 inner: CoreAuthenticator,
32 store: Arc<CredentialStore>,
33}
34
35impl Authenticator {
36 pub async fn init_with_config(
42 seed: &[u8],
43 config: Config,
44 artifacts: Arc<dyn WalletKitZkArtifactSource>,
45 store: Arc<CredentialStore>,
46 ) -> Result<Self, WalletKitError> {
47 let authenticator = CoreAuthenticator::init(seed, config, artifacts).await?;
48
49 Ok(Self {
50 inner: authenticator,
51 store,
52 })
53 }
54}
55
56fn parse_authenticator_pubkey(
57 attribute: &str,
58 encoded_pubkey: impl AsRef<str>,
59) -> Result<EdDSAPublicKey, WalletKitError> {
60 let encoded_pubkey = encoded_pubkey.as_ref();
61 let invalid_input = |reason: String| WalletKitError::InvalidInput {
62 attribute: attribute.to_string(),
63 reason,
64 };
65 let hex = encoded_pubkey.strip_prefix("0x").ok_or_else(|| {
66 invalid_input("Public key must start with a 0x prefix".to_string())
67 })?;
68
69 if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
70 return Err(invalid_input(
71 "Public key must be exactly 32 bytes (64 hex characters) after the 0x prefix"
72 .to_string(),
73 ));
74 }
75
76 let encoded = U256::from_str_radix(hex, 16)
77 .map_err(|error| invalid_input(error.to_string()))?;
78 let pubkey = EdDSAPublicKey::from_compressed_bytes(encoded.to_le_bytes())
79 .map_err(|error| invalid_input(error.to_string()))?;
80
81 let canonical = pubkey
87 .to_ethereum_representation()
88 .map_err(|error| invalid_input(error.to_string()))?;
89 if canonical != encoded {
90 return Err(invalid_input(
91 "Public key is not the canonical compressed point encoding".to_string(),
92 ));
93 }
94 if canonical == U256::from(1u64) {
95 return Err(invalid_input(
96 "Public key must not be the BabyJubJub identity point".to_string(),
97 ));
98 }
99
100 Ok(pubkey)
101}
102
103#[uniffi::export(async_runtime = "tokio")]
104impl Authenticator {
105 #[must_use]
110 pub fn packed_account_data(&self) -> Uint256 {
111 self.inner.packed_account_data.into()
112 }
113
114 #[must_use]
119 pub fn leaf_index(&self) -> u64 {
120 self.inner.leaf_index()
121 }
122
123 #[must_use]
127 pub fn onchain_address(&self) -> String {
128 self.inner.onchain_address().to_string()
129 }
130
131 #[tracing::instrument(
136 target = "walletkit_latency",
137 name = "rpc_account_data",
138 skip_all
139 )]
140 pub async fn get_packed_account_data_remote(
141 &self,
142 ) -> Result<Uint256, WalletKitError> {
143 let packed_account_data = self.inner.fetch_packed_account_data().await?;
144 Ok(packed_account_data.into())
145 }
146
147 #[tracing::instrument(
156 target = "walletkit_latency",
157 name = "oprf_blinding_factor",
158 skip_all
159 )]
160 pub async fn generate_credential_blinding_factor_remote(
161 &self,
162 issuer_schema_id: u64,
163 ) -> Result<FieldElement, WalletKitError> {
164 Ok(self
165 .inner
166 .generate_credential_blinding_factor(issuer_schema_id)
167 .await
168 .map(Into::into)?)
169 }
170
171 #[must_use]
173 pub fn compute_credential_sub(
174 &self,
175 blinding_factor: &FieldElement,
176 ) -> FieldElement {
177 CoreCredential::compute_sub(self.inner.leaf_index(), blinding_factor.0).into()
178 }
179
180 #[allow(
191 clippy::needless_pass_by_value,
192 reason = "seed is passed by value so uniffi 0.32 maps it to a `RustBuffer` (Kotlin `ByteArray` / Swift `Data`) rather than the non-`Send` `ForeignBytes` view produced for `&[u8]`"
193 )]
194 pub fn danger_sign_challenge(
195 &self,
196 challenge: Vec<u8>,
197 ) -> Result<Vec<u8>, WalletKitError> {
198 let signature = self.inner.danger_sign_challenge(&challenge)?;
199 Ok(signature.as_bytes().to_vec())
200 }
201
202 pub async fn danger_sign_initiate_recovery_agent_update(
226 &self,
227 new_recovery_agent: String,
228 ) -> Result<RecoveryUpdateSignature, WalletKitError> {
229 let new_recovery_agent =
230 Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;
231 let (sig, nonce) = self
232 .inner
233 .danger_sign_initiate_recovery_agent_update(new_recovery_agent)
234 .await?;
235 Ok(RecoveryUpdateSignature {
236 signature: sig.as_bytes().to_vec(),
237 nonce: nonce.into(),
238 })
239 }
240
241 pub async fn update_recovery_agent(
259 &self,
260 new_recovery_agent: String,
261 ) -> Result<String, WalletKitError> {
262 let new_recovery_agent =
263 Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;
264
265 let request_id = self.inner.update_recovery_agent(new_recovery_agent).await?;
266
267 Ok(request_id.to_string())
268 }
269
270 pub async fn revert_recovery_agent_update(&self) -> Result<String, WalletKitError> {
284 let request_id = self.inner.revert_recovery_agent_update().await?;
285
286 Ok(request_id.to_string())
287 }
288
289 #[tracing::instrument(
303 target = "walletkit_latency",
304 name = "gateway_insert_authenticator",
305 skip_all
306 )]
307 pub async fn insert_authenticator(
308 &self,
309 new_authenticator_pubkey: String,
310 new_authenticator_address: String,
311 ) -> Result<String, WalletKitError> {
312 let new_authenticator_pubkey = parse_authenticator_pubkey(
313 "new_authenticator_pubkey",
314 new_authenticator_pubkey,
315 )?;
316 let new_authenticator_address = Address::parse_from_ffi(
317 &new_authenticator_address,
318 "new_authenticator_address",
319 )?;
320
321 let request_id = self
322 .inner
323 .insert_authenticator(new_authenticator_pubkey, new_authenticator_address)
324 .await?;
325
326 Ok(request_id.to_string())
327 }
328
329 #[tracing::instrument(
343 target = "walletkit_latency",
344 name = "indexer_authenticator_pubkeys",
345 skip_all
346 )]
347 pub async fn has_authenticator_pubkey(
348 &self,
349 authenticator_pubkey: String,
350 ) -> Result<bool, WalletKitError> {
351 let authenticator_pubkey =
352 parse_authenticator_pubkey("authenticator_pubkey", authenticator_pubkey)?;
353 let pubkeys = self.inner.fetch_authenticator_pubkeys().await?;
354 Ok(pubkeys
355 .iter()
356 .flatten()
357 .any(|existing_pubkey| existing_pubkey == &authenticator_pubkey))
358 }
359
360 #[tracing::instrument(
374 target = "walletkit_latency",
375 name = "indexer_authenticator_pubkeys",
376 skip_all
377 )]
378 pub async fn get_authenticator_pubkeys(
379 &self,
380 ) -> Result<Vec<Option<String>>, WalletKitError> {
381 let key_set = self.inner.fetch_authenticator_pubkeys().await?;
382 key_set
383 .iter()
384 .map(|slot| {
385 slot.as_ref()
386 .map(|pubkey| {
387 let encoded = pubkey.to_ethereum_representation()?;
388 Ok(format!("{encoded:#066x}"))
389 })
390 .transpose()
391 })
392 .collect()
393 }
394
395 #[tracing::instrument(
419 target = "walletkit_latency",
420 name = "gateway_remove_authenticator",
421 skip_all
422 )]
423 pub async fn remove_authenticator(
424 &self,
425 authenticator_address: String,
426 pubkey_id: u32,
427 expected_authenticator_pubkey: String,
428 ) -> Result<String, WalletKitError> {
429 let expected_pubkey = parse_authenticator_pubkey(
430 "expected_authenticator_pubkey",
431 expected_authenticator_pubkey,
432 )?;
433 let authenticator_address =
434 Address::parse_from_ffi(&authenticator_address, "authenticator_address")?;
435
436 if pubkey_id as usize >= MAX_AUTHENTICATOR_KEYS {
437 return Err(WalletKitError::InvalidInput {
438 attribute: "pubkey_id".to_string(),
439 reason: format!(
440 "pubkey_id {pubkey_id} is out of range; the key set has at \
441 most {MAX_AUTHENTICATOR_KEYS} slots"
442 ),
443 });
444 }
445
446 let empty_slot = || WalletKitError::InvalidInput {
447 attribute: "pubkey_id".to_string(),
448 reason: format!("no authenticator at key set slot {pubkey_id}"),
449 };
450 let key_set = self.inner.fetch_authenticator_pubkeys().await?;
451 let actual_pubkey = key_set.get(pubkey_id as usize).ok_or_else(empty_slot)?;
452 if actual_pubkey != &expected_pubkey {
453 return Err(WalletKitError::InvalidInput {
454 attribute: "expected_authenticator_pubkey".to_string(),
455 reason: format!(
456 "key set slot {pubkey_id} holds a different authenticator public key"
457 ),
458 });
459 }
460
461 let request_id = self
462 .inner
463 .remove_authenticator(authenticator_address, pubkey_id)
464 .await
465 .map_err(|error| match error {
466 AuthenticatorError::PublicKeyNotFound => empty_slot(),
470 other => other.into(),
471 })?;
472
473 Ok(request_id.to_string())
474 }
475
476 #[tracing::instrument(
481 target = "walletkit_latency",
482 name = "gateway_poll",
483 skip_all
484 )]
485 pub async fn poll_status(
486 &self,
487 request_id: String,
488 ) -> Result<GatewayRequestStatus, WalletKitError> {
489 let request_id = GatewayRequestId::new(
490 request_id.strip_prefix("gw_").unwrap_or(&request_id),
491 );
492 let status = self.inner.poll_status(&request_id).await?;
493 Ok(status.into())
494 }
495}
496
497#[uniffi::export(async_runtime = "tokio")]
498impl Authenticator {
499 #[uniffi::constructor]
507 #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
508 pub async fn init_with_defaults(
509 seed: Vec<u8>,
510 rpc_url: Option<String>,
511 environment: &Environment,
512 region: Option<Region>,
513 artifacts: Arc<dyn WalletKitZkArtifactSource>,
514 store: Arc<CredentialStore>,
515 ) -> Result<Self, WalletKitError> {
516 let config = defaults::default_config(environment, rpc_url, region)?;
517 Self::init_with_config(&seed, config, artifacts, store).await
518 }
519
520 #[uniffi::constructor]
530 #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
531 pub async fn init_with_ohttp_defaults(
532 seed: Vec<u8>,
533 rpc_url: Option<String>,
534 environment: &Environment,
535 region: Option<Region>,
536 artifacts: Arc<dyn WalletKitZkArtifactSource>,
537 store: Arc<CredentialStore>,
538 ) -> Result<Self, WalletKitError> {
539 let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
540 Self::init_with_config(&seed, config, artifacts, store).await
541 }
542
543 #[uniffi::constructor]
551 #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
552 pub async fn init(
553 seed: Vec<u8>,
554 config: &str,
555 artifacts: Arc<dyn WalletKitZkArtifactSource>,
556 store: Arc<CredentialStore>,
557 ) -> Result<Self, WalletKitError> {
558 let config =
559 Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
560 attribute: "config".to_string(),
561 reason: "Invalid config".to_string(),
562 })?;
563 Self::init_with_config(&seed, config, artifacts, store).await
564 }
565
566 pub async fn generate_proof(
571 &self,
572 proof_request: &ProofRequest,
573 now: Option<u64>,
574 ) -> Result<ProofResponse, WalletKitError> {
575 let now = if let Some(n) = now {
576 n
577 } else {
578 #[cfg(target_arch = "wasm32")]
579 {
580 return Err(WalletKitError::InvalidInput {
581 attribute: "now".to_string(),
582 reason: "`now` must be provided on wasm32 targets".to_string(),
583 });
584 }
585
586 #[cfg(not(target_arch = "wasm32"))]
587 {
588 let start = std::time::SystemTime::now();
589 start
590 .duration_since(std::time::UNIX_EPOCH)
591 .map_err(|e| WalletKitError::Generic {
592 error: format!("Critical. Unable to determine SystemTime: {e}"),
593 })?
594 .as_secs()
595 }
596 };
597
598 let credentials: Vec<_> = self
603 .store
604 .list_credentials(None, now)?
605 .iter()
606 .filter(|c| !c.is_expired)
607 .filter_map(|cred| {
608 if let Ok(Some((credential, blinding_factor))) =
609 self.store.get_credential(cred.issuer_schema_id, now)
610 {
611 Some(CredentialInput {
612 credential: credential.into(),
613 blinding_factor: blinding_factor.into(),
614 })
615 } else {
616 tracing::warn!(
617 issuer_schema_id = %cred.issuer_schema_id,
618 credential_id = %cred.credential_id,
619 "credential listed but not loadable, skipping"
620 );
621 None
622 }
623 })
624 .collect();
625
626 let account_inclusion_proof =
627 self.fetch_inclusion_proof_with_cache(now).await?;
628
629 let nullifier = Box::pin(self.inner.generate_nullifier(
632 &proof_request.0,
633 now,
634 Some(account_inclusion_proof.clone()),
635 ))
636 .await?;
637
638 if self
639 .store
640 .is_nullifier_replay(nullifier.verifiable_oprf_output.output.into(), now)?
641 {
642 return Err(WalletKitError::NullifierReplay);
643 }
644
645 let session_id_r_seed =
647 proof_request
648 .0
649 .session_id
650 .existing()
651 .and_then(|session_id| {
652 match self.store.get_session_seed(session_id.oprf_seed, now) {
653 Ok(seed) => seed,
654 Err(err) => {
655 tracing::warn!(error = %err, "failed to load cached session seed, continuing without");
656 None
657 }
658 }
659 });
660
661 let result = Box::pin(self.inner.generate_proof(
663 &proof_request.0,
664 nullifier.clone(),
665 &credentials,
666 Some(account_inclusion_proof),
667 session_id_r_seed,
668 ))
669 .await?;
670
671 if let Some(seed) = result.session_id_r_seed {
674 if let Some(session_id) = result.proof_response.session_id {
675 if let Err(err) =
676 self.store
677 .store_session_seed(session_id.oprf_seed, seed, now)
678 {
679 tracing::error!("error caching session_id_r_seed: {}", err);
680 }
681 }
682 }
683
684 self.store
685 .replay_guard_set(nullifier.verifiable_oprf_output.output.into(), now)?;
686
687 Ok(result.proof_response.into())
688 }
689
690 pub async fn prove_credential_sub(
716 &self,
717 nonce: &FieldElement,
718 context: &FieldElement,
719 blinding_factor: &FieldElement,
720 sub: &FieldElement,
721 ) -> Result<OwnershipProof, WalletKitError> {
722 #[cfg(target_arch = "wasm32")]
723 {
724 let _ = (nonce, context, blinding_factor, sub);
725 return Err(WalletKitError::Generic {
726 error: "credential ownership proofs are not supported on wasm32"
727 .to_string(),
728 });
729 }
730
731 #[cfg(not(target_arch = "wasm32"))]
732 {
733 let now = std::time::SystemTime::now()
734 .duration_since(std::time::UNIX_EPOCH)
735 .map_err(|e| WalletKitError::Generic {
736 error: format!("Critical. Unable to determine SystemTime: {e}"),
737 })?
738 .as_secs();
739
740 let inclusion_proof = self.fetch_inclusion_proof_with_cache(now).await?;
741 let proof = self
742 .inner
743 .prove_credential_sub(
744 nonce.0,
745 context.0,
746 blinding_factor.0,
747 sub.0,
748 Some(inclusion_proof),
749 )
750 .await?;
751
752 Ok(OwnershipProof(proof))
753 }
754 }
755}
756
757#[derive(Debug, Clone, uniffi::Enum)]
759pub enum RegistrationStatus {
760 Queued,
762 Batching,
764 Submitted,
766 Finalized,
768 Failed {
770 error: String,
772 error_code: Option<String>,
774 },
775}
776
777#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
779pub enum GatewayRequestStatus {
780 Queued,
782 Batching,
784 Submitted {
786 tx_hash: String,
788 },
789 Finalized {
791 tx_hash: String,
793 },
794 Failed {
796 error: String,
798 error_code: Option<String>,
800 },
801}
802
803impl From<GatewayRequestState> for GatewayRequestStatus {
804 fn from(state: GatewayRequestState) -> Self {
805 match state {
806 GatewayRequestState::Queued => Self::Queued,
807 GatewayRequestState::Batching => Self::Batching,
808 GatewayRequestState::Submitted { tx_hash } => Self::Submitted { tx_hash },
809 GatewayRequestState::Finalized { tx_hash } => Self::Finalized { tx_hash },
810 GatewayRequestState::Failed { error, error_code } => Self::Failed {
811 error,
812 error_code: error_code.map(|code| code.to_string()),
813 },
814 }
815 }
816}
817
818impl From<GatewayRequestState> for RegistrationStatus {
819 fn from(state: GatewayRequestState) -> Self {
820 match state {
821 GatewayRequestState::Queued => Self::Queued,
822 GatewayRequestState::Batching => Self::Batching,
823 GatewayRequestState::Submitted { .. } => Self::Submitted,
824 GatewayRequestState::Finalized { .. } => Self::Finalized,
825 GatewayRequestState::Failed { error, error_code } => Self::Failed {
826 error,
827 error_code: error_code.map(|c: GatewayErrorCode| c.to_string()),
828 },
829 }
830 }
831}
832
833#[derive(uniffi::Object)]
838pub struct InitializingAuthenticator(CoreInitializingAuthenticator);
839
840#[uniffi::export(async_runtime = "tokio")]
841impl InitializingAuthenticator {
842 #[uniffi::constructor]
850 #[tracing::instrument(
851 target = "walletkit_latency",
852 name = "gateway_register",
853 skip_all
854 )]
855 pub async fn register_with_defaults(
856 seed: Vec<u8>,
857 rpc_url: Option<String>,
858 environment: &Environment,
859 region: Option<Region>,
860 recovery_address: Option<String>,
861 ) -> Result<Self, WalletKitError> {
862 let recovery_address =
863 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
864
865 let config = defaults::default_config(environment, rpc_url, region)?;
866
867 let initializing_authenticator =
868 CoreAuthenticator::register(&seed, config, recovery_address).await?;
869
870 Ok(Self(initializing_authenticator))
871 }
872
873 #[uniffi::constructor]
883 #[tracing::instrument(
884 target = "walletkit_latency",
885 name = "gateway_register",
886 skip_all
887 )]
888 pub async fn register_with_ohttp_defaults(
889 seed: Vec<u8>,
890 rpc_url: Option<String>,
891 environment: &Environment,
892 region: Option<Region>,
893 recovery_address: Option<String>,
894 ) -> Result<Self, WalletKitError> {
895 let recovery_address =
896 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
897
898 let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
899
900 let initializing_authenticator =
901 CoreAuthenticator::register(&seed, config, recovery_address).await?;
902
903 Ok(Self(initializing_authenticator))
904 }
905
906 #[uniffi::constructor]
914 #[tracing::instrument(
915 target = "walletkit_latency",
916 name = "gateway_register",
917 skip_all
918 )]
919 pub async fn register(
920 seed: Vec<u8>,
921 config: &str,
922 recovery_address: Option<String>,
923 ) -> Result<Self, WalletKitError> {
924 let recovery_address =
925 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
926
927 let config =
928 Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
929 attribute: "config".to_string(),
930 reason: "Invalid config".to_string(),
931 })?;
932
933 let initializing_authenticator =
934 CoreAuthenticator::register(&seed, config, recovery_address).await?;
935
936 Ok(Self(initializing_authenticator))
937 }
938
939 #[tracing::instrument(
944 target = "walletkit_latency",
945 name = "gateway_poll",
946 skip_all
947 )]
948 pub async fn poll_status(&self) -> Result<RegistrationStatus, WalletKitError> {
949 let status = self.0.poll_status().await?;
950 Ok(status.into())
951 }
952}
953
954#[derive(Debug, Clone, uniffi::Record)]
960pub struct RecoveryUpdateSignature {
961 pub signature: Vec<u8>,
964 pub nonce: Uint256,
967}
968
969#[derive(Debug, Clone, uniffi::Record)]
977pub struct RecoveryData {
978 pub authenticator_address: String,
980 pub authenticator_pubkey: String,
982 pub offchain_signer_commitment: String,
984}
985
986impl RecoveryData {
987 pub fn from_seed(seed: &[u8]) -> Result<Self, WalletKitError> {
996 let signer = Signer::from_seed_bytes(seed)?;
997 let authenticator_address = signer.onchain_signer_address().to_checksum(None);
998 let authenticator_pubkey: U256 = signer
999 .offchain_signer_pubkey()
1000 .to_ethereum_representation()?;
1001 let mut key_set = AuthenticatorPublicKeySet::default();
1002 key_set.try_push(signer.offchain_signer_pubkey())?;
1003 let offchain_signer_commitment: U256 = key_set.leaf_hash().into();
1004
1005 Ok(Self {
1006 authenticator_address,
1007 authenticator_pubkey: format!("{authenticator_pubkey:#066x}"),
1008 offchain_signer_commitment: format!("{offchain_signer_commitment:#066x}"),
1009 })
1010 }
1011}
1012
1013#[uniffi::export]
1033pub fn validate_authenticator_pubkey(
1034 authenticator_pubkey: &str,
1035) -> Result<String, WalletKitError> {
1036 let pubkey =
1037 parse_authenticator_pubkey("authenticator_pubkey", authenticator_pubkey)?;
1038 let encoded = pubkey.to_ethereum_representation()?;
1039 Ok(format!("{encoded:#066x}"))
1040}
1041
1042#[uniffi::export]
1049#[allow(
1050 clippy::needless_pass_by_value,
1051 reason = "seed is passed by value so uniffi 0.32 maps it to a `RustBuffer` (Kotlin `ByteArray` / Swift `Data`) rather than the non-`Send` `ForeignBytes` view produced for `&[u8]`"
1052)]
1053pub fn recovery_data_from_seed(seed: Vec<u8>) -> Result<RecoveryData, WalletKitError> {
1054 RecoveryData::from_seed(&seed)
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060
1061 const TEST_SEED: [u8; 32] = [1u8; 32];
1062
1063 async fn test_authenticator(
1064 server: &mut mockito::Server,
1065 ) -> (Authenticator, std::path::PathBuf) {
1066 use crate::storage::tests_utils::{temp_root_path, InMemoryStorageProvider};
1067 use alloy::primitives::address;
1068 use world_id_core::primitives::ServiceEndpoint;
1069 use world_id_proof::artifacts::dummy::DummyZkArtifactSource;
1070
1071 let _ = rustls::crypto::ring::default_provider().install_default();
1072
1073 let packed_account_mock = server
1074 .mock("POST", "/packed-account")
1075 .with_status(200)
1076 .with_header("content-type", "application/json")
1077 .with_body(serde_json::json!({ "packed_account_data": "0x2a" }).to_string())
1078 .create_async()
1079 .await;
1080 let config = Config::new(
1081 None,
1082 480,
1083 address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1084 ServiceEndpoint::direct(server.url()),
1085 ServiceEndpoint::direct(server.url()),
1086 vec![],
1087 2,
1088 )
1089 .expect("valid config");
1090 let root = temp_root_path();
1091 let provider = InMemoryStorageProvider::new(&root);
1092 let store =
1093 CredentialStore::from_provider(&provider).expect("credential store");
1094 let authenticator = Authenticator::init_with_config(
1095 &TEST_SEED,
1096 config,
1097 Arc::new(DummyZkArtifactSource),
1098 Arc::new(store),
1099 )
1100 .await
1101 .expect("authenticator should initialize");
1102 packed_account_mock.assert_async().await;
1103
1104 (authenticator, root)
1105 }
1106
1107 fn encoded_pubkey(seed: &[u8; 32]) -> String {
1108 let pubkey = Signer::from_seed_bytes(seed)
1109 .expect("valid seed")
1110 .offchain_signer_pubkey()
1111 .to_ethereum_representation()
1112 .expect("public key should encode");
1113 format!("{pubkey:#066x}")
1114 }
1115
1116 async fn mock_authenticator_pubkeys(
1120 server: &mut mockito::Server,
1121 pubkeys: &[Option<&str>],
1122 expected_hits: usize,
1123 ) -> mockito::Mock {
1124 server
1125 .mock("POST", "/authenticator-pubkeys")
1126 .match_body(mockito::Matcher::JsonString(
1127 serde_json::json!({ "leaf_index": "0x2a" }).to_string(),
1128 ))
1129 .with_status(200)
1130 .with_header("content-type", "application/json")
1131 .with_body(
1132 serde_json::json!({
1133 "authenticator_pubkeys": pubkeys,
1134 "offchain_signer_commitment": "0x0"
1135 })
1136 .to_string(),
1137 )
1138 .expect(expected_hits)
1139 .create_async()
1140 .await
1141 }
1142
1143 #[test]
1144 fn test_recovery_data_from_seed() {
1145 let seed = [1u8; 32];
1146 let material = RecoveryData::from_seed(&seed).expect("should derive material");
1147
1148 assert!(material.authenticator_address.starts_with("0x"));
1149 assert_eq!(material.authenticator_address.len(), 42);
1150 assert!(material.authenticator_pubkey.starts_with("0x"));
1151 assert!(material.authenticator_pubkey.len() <= 66);
1152 assert!(material.offchain_signer_commitment.starts_with("0x"));
1153 assert!(material.offchain_signer_commitment.len() <= 66);
1154 assert!(material.authenticator_address.len() > 2);
1155 assert!(material.authenticator_pubkey.len() > 2);
1156 assert!(material.offchain_signer_commitment.len() > 2);
1157 }
1158
1159 #[test]
1160 fn test_recovery_data_rejects_invalid_seed() {
1161 assert!(RecoveryData::from_seed(&[0u8; 16]).is_err());
1162 assert!(RecoveryData::from_seed(&[]).is_err());
1163 }
1164
1165 #[test]
1166 fn test_authenticator_pubkey_validation() {
1167 let canonical = encoded_pubkey(&[2u8; 32]);
1168 assert_eq!(
1169 validate_authenticator_pubkey(&canonical).expect("valid key"),
1170 canonical
1171 );
1172 let uppercase = format!("0x{}", canonical[2..].to_uppercase());
1173 assert_eq!(
1174 validate_authenticator_pubkey(&uppercase)
1175 .expect("uppercase hex should canonicalize"),
1176 canonical
1177 );
1178
1179 for invalid_pubkey in [
1180 "not-a-public-key".to_string(),
1181 format!("0x{}", "ff".repeat(32)),
1182 ] {
1183 assert!(matches!(
1184 validate_authenticator_pubkey(&invalid_pubkey),
1185 Err(WalletKitError::InvalidInput { attribute, .. })
1186 if attribute == "authenticator_pubkey"
1187 ));
1188 }
1189
1190 let identity = format!("0x{}01", "0".repeat(62));
1191 assert!(matches!(
1192 validate_authenticator_pubkey(&identity),
1193 Err(WalletKitError::InvalidInput { attribute, reason })
1194 if attribute == "authenticator_pubkey" && reason.contains("identity")
1195 ));
1196 let sign_bit_alias = format!("0x80{}01", "0".repeat(60));
1197 assert!(matches!(
1198 validate_authenticator_pubkey(&sign_bit_alias),
1199 Err(WalletKitError::InvalidInput { attribute, reason })
1200 if attribute == "authenticator_pubkey" && reason.contains("canonical")
1201 ));
1202 }
1203
1204 #[tokio::test]
1205 async fn test_poll_status_normalizes_request_id() {
1206 use crate::storage::tests_utils::cleanup_test_storage;
1207
1208 let mut server = mockito::Server::new_async().await;
1209 let (authenticator, root) = test_authenticator(&mut server).await;
1210 let status_mock = server
1211 .mock("GET", "/status/gw_poll_test")
1212 .with_status(200)
1213 .with_header("content-type", "application/json")
1214 .with_body(
1215 serde_json::json!({
1216 "request_id": "gw_poll_test",
1217 "kind": "insert_authenticator",
1218 "status": {
1219 "state": "finalized",
1220 "tx_hash": "0x1234"
1221 }
1222 })
1223 .to_string(),
1224 )
1225 .expect(2)
1226 .create_async()
1227 .await;
1228
1229 for request_id in ["poll_test", "gw_poll_test"] {
1230 assert_eq!(
1231 authenticator
1232 .poll_status(request_id.to_string())
1233 .await
1234 .expect("status poll should succeed"),
1235 GatewayRequestStatus::Finalized {
1236 tx_hash: "0x1234".to_string()
1237 }
1238 );
1239 }
1240 status_mock.assert_async().await;
1241
1242 drop(server);
1243 cleanup_test_storage(&root);
1244 }
1245
1246 #[tokio::test]
1247 async fn test_remove_authenticator_refuses_unexpected_slot_contents() {
1248 use crate::storage::tests_utils::cleanup_test_storage;
1249
1250 let mut server = mockito::Server::new_async().await;
1251 let (authenticator, root) = test_authenticator(&mut server).await;
1252 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1253 let slot_pubkey = encoded_pubkey(&[2u8; 32]);
1254
1255 let pubkeys_mock = mock_authenticator_pubkeys(
1256 &mut server,
1257 &[
1258 Some(existing_pubkey.as_str()),
1259 None,
1260 Some(slot_pubkey.as_str()),
1261 ],
1262 2,
1263 )
1264 .await;
1265 let nonce_mock = server
1266 .mock("POST", "/signature-nonce")
1267 .expect(0)
1268 .create_async()
1269 .await;
1270 let remove_mock = server
1271 .mock("POST", "/remove-authenticator")
1272 .expect(0)
1273 .create_async()
1274 .await;
1275
1276 let mismatched = authenticator
1277 .remove_authenticator(
1278 Address::ZERO.to_string(),
1279 2,
1280 encoded_pubkey(&[3u8; 32]),
1281 )
1282 .await;
1283 assert!(matches!(
1284 mismatched,
1285 Err(WalletKitError::InvalidInput { attribute, .. })
1286 if attribute == "expected_authenticator_pubkey"
1287 ));
1288
1289 let empty_slot = authenticator
1290 .remove_authenticator(
1291 Address::ZERO.to_string(),
1292 1,
1293 encoded_pubkey(&[3u8; 32]),
1294 )
1295 .await;
1296 assert!(matches!(
1297 empty_slot,
1298 Err(WalletKitError::InvalidInput { attribute, reason })
1299 if attribute == "pubkey_id"
1300 && reason.contains("no authenticator at key set slot 1")
1301 ));
1302
1303 let out_of_range = authenticator
1304 .remove_authenticator(
1305 Address::ZERO.to_string(),
1306 7,
1307 encoded_pubkey(&[3u8; 32]),
1308 )
1309 .await;
1310 assert!(matches!(
1311 out_of_range,
1312 Err(WalletKitError::InvalidInput { attribute, reason })
1313 if attribute == "pubkey_id" && reason.contains("out of range")
1314 ));
1315
1316 pubkeys_mock.assert_async().await;
1317 nonce_mock.assert_async().await;
1318 remove_mock.assert_async().await;
1319
1320 drop(server);
1321 cleanup_test_storage(&root);
1322 }
1323
1324 #[tokio::test]
1325 async fn test_key_set_reads_return_slots_and_membership() {
1326 use crate::storage::tests_utils::cleanup_test_storage;
1327
1328 let mut server = mockito::Server::new_async().await;
1329 let (authenticator, root) = test_authenticator(&mut server).await;
1330 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1331 let other_pubkey = encoded_pubkey(&[2u8; 32]);
1332
1333 let pubkeys_mock = mock_authenticator_pubkeys(
1334 &mut server,
1335 &[
1336 Some(existing_pubkey.as_str()),
1337 None,
1338 Some(other_pubkey.as_str()),
1339 ],
1340 3,
1341 )
1342 .await;
1343
1344 assert!(authenticator
1345 .has_authenticator_pubkey(existing_pubkey.clone())
1346 .await
1347 .expect("membership read should succeed"));
1348 assert!(!authenticator
1349 .has_authenticator_pubkey(encoded_pubkey(&[3u8; 32]))
1350 .await
1351 .expect("absent key check should succeed"));
1352 assert_eq!(
1353 authenticator
1354 .get_authenticator_pubkeys()
1355 .await
1356 .expect("key set read should succeed"),
1357 vec![Some(existing_pubkey), None, Some(other_pubkey)]
1358 );
1359 pubkeys_mock.assert_async().await;
1360
1361 drop(server);
1362 cleanup_test_storage(&root);
1363 }
1364
1365 #[tokio::test]
1366 async fn test_remove_authenticator_reports_slot_emptied_during_signing() {
1367 use crate::storage::tests_utils::cleanup_test_storage;
1368 use std::sync::atomic::{AtomicUsize, Ordering};
1369
1370 let mut server = mockito::Server::new_async().await;
1371 let (authenticator, root) = test_authenticator(&mut server).await;
1372 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1373 let removed_pubkey = encoded_pubkey(&[2u8; 32]);
1374
1375 let full_body = serde_json::json!({
1381 "authenticator_pubkeys": [existing_pubkey.clone(), removed_pubkey.clone()],
1382 "offchain_signer_commitment": "0x0"
1383 })
1384 .to_string();
1385 let emptied_body = serde_json::json!({
1386 "authenticator_pubkeys": [existing_pubkey],
1387 "offchain_signer_commitment": "0x0"
1388 })
1389 .to_string();
1390 let fetches = Arc::new(AtomicUsize::new(0));
1391 let fetches_in_mock = Arc::clone(&fetches);
1392 let pubkeys_mock = server
1393 .mock("POST", "/authenticator-pubkeys")
1394 .with_status(200)
1395 .with_header("content-type", "application/json")
1396 .with_body_from_request(move |_request| {
1397 if fetches_in_mock.fetch_add(1, Ordering::SeqCst) == 0 {
1398 full_body.clone().into_bytes()
1399 } else {
1400 emptied_body.clone().into_bytes()
1401 }
1402 })
1403 .expect(2)
1404 .create_async()
1405 .await;
1406 let nonce_mock = server
1407 .mock("POST", "/signature-nonce")
1408 .with_status(200)
1409 .with_header("content-type", "application/json")
1410 .with_body(serde_json::json!({ "signature_nonce": "0x1" }).to_string())
1411 .create_async()
1412 .await;
1413 let remove_mock = server
1414 .mock("POST", "/remove-authenticator")
1415 .expect(0)
1416 .create_async()
1417 .await;
1418
1419 let raced = authenticator
1420 .remove_authenticator(Address::ZERO.to_string(), 1, removed_pubkey)
1421 .await;
1422 assert!(matches!(
1423 raced,
1424 Err(WalletKitError::InvalidInput { attribute, .. })
1425 if attribute == "pubkey_id"
1426 ));
1427
1428 pubkeys_mock.assert_async().await;
1429 nonce_mock.assert_async().await;
1430 remove_mock.assert_async().await;
1431
1432 drop(server);
1433 cleanup_test_storage(&root);
1434 }
1435
1436 #[cfg(feature = "embed-zkeys")]
1437 #[tokio::test]
1438 async fn test_init_with_config_and_materials() {
1439 use crate::{
1440 authenticator::artifacts::caching::CachingZkArtifacts,
1441 storage::tests_utils::{
1442 cleanup_test_storage, temp_root_path, InMemoryStorageProvider,
1443 },
1444 };
1445 use alloy::primitives::address;
1446 use world_id_core::primitives::{Config, ServiceEndpoint};
1447
1448 let _ = rustls::crypto::ring::default_provider().install_default();
1449
1450 let mut mock_server = mockito::Server::new_async().await;
1451 mock_server
1452 .mock("POST", "/")
1453 .with_status(200)
1454 .with_header("content-type", "application/json")
1455 .with_body(
1456 serde_json::json!({
1457 "jsonrpc": "2.0",
1458 "id": 1,
1459 "result": "0x0000000000000000000000000000000000000000000000000000000000000001"
1460 })
1461 .to_string(),
1462 )
1463 .create_async()
1464 .await;
1465
1466 let config = Config::new(
1467 Some(mock_server.url()),
1468 480,
1469 address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1470 ServiceEndpoint::direct(
1471 "https://indexer.us.id-infra.worldcoin.dev".to_string(),
1472 ),
1473 ServiceEndpoint::direct(
1474 "https://gateway.id-infra.worldcoin.dev".to_string(),
1475 ),
1476 vec![],
1477 2,
1478 )
1479 .unwrap();
1480 let config = serde_json::to_string(&config).unwrap();
1481
1482 let root = temp_root_path();
1483 let provider = InMemoryStorageProvider::new(&root);
1484 let store = CredentialStore::from_provider(&provider).expect("store");
1485 store.init(42, 100).expect("init storage");
1486
1487 let artifacts =
1488 Arc::new(CachingZkArtifacts::new(Arc::new(store.paths().unwrap())));
1489
1490 let _authenticator = Authenticator::init(
1491 [2u8; 32].to_vec(),
1492 &config,
1493 artifacts,
1494 Arc::new(store),
1495 )
1496 .await
1497 .unwrap();
1498 drop(mock_server);
1499
1500 cleanup_test_storage(&root);
1501 }
1502}