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 rp_id = proof_request.0.rp_id.into_inner();
647 let session_id_r_seed =
648 proof_request
649 .0
650 .session_id
651 .existing()
652 .and_then(|session_id| match self.store.get_session_seed(
653 rp_id,
654 session_id.oprf_seed,
655 now,
656 ) {
657 Ok(seed) => seed,
658 Err(err) => {
659 tracing::warn!(error = %err, "failed to load cached session seed, continuing without");
660 None
661 }
662 });
663
664 let result = Box::pin(self.inner.generate_proof(
666 &proof_request.0,
667 nullifier.clone(),
668 &credentials,
669 Some(account_inclusion_proof),
670 session_id_r_seed,
671 ))
672 .await?;
673
674 if let Some(seed) = result.session_id_r_seed {
677 if let Some(session_id) = result.proof_response.session_id {
678 if let Err(err) = self.store.store_session_seed(
679 rp_id,
680 session_id.oprf_seed,
681 seed,
682 now,
683 ) {
684 tracing::error!("error caching session_id_r_seed: {}", err);
685 }
686 }
687 }
688
689 self.store
690 .replay_guard_set(nullifier.verifiable_oprf_output.output.into(), now)?;
691
692 Ok(result.proof_response.into())
693 }
694
695 pub async fn prove_credential_sub(
721 &self,
722 nonce: &FieldElement,
723 context: &FieldElement,
724 blinding_factor: &FieldElement,
725 sub: &FieldElement,
726 ) -> Result<OwnershipProof, WalletKitError> {
727 #[cfg(target_arch = "wasm32")]
728 {
729 let _ = (nonce, context, blinding_factor, sub);
730 return Err(WalletKitError::Generic {
731 error: "credential ownership proofs are not supported on wasm32"
732 .to_string(),
733 });
734 }
735
736 #[cfg(not(target_arch = "wasm32"))]
737 {
738 let now = std::time::SystemTime::now()
739 .duration_since(std::time::UNIX_EPOCH)
740 .map_err(|e| WalletKitError::Generic {
741 error: format!("Critical. Unable to determine SystemTime: {e}"),
742 })?
743 .as_secs();
744
745 let inclusion_proof = self.fetch_inclusion_proof_with_cache(now).await?;
746 let proof = self
747 .inner
748 .prove_credential_sub(
749 nonce.0,
750 context.0,
751 blinding_factor.0,
752 sub.0,
753 Some(inclusion_proof),
754 )
755 .await?;
756
757 Ok(OwnershipProof(proof))
758 }
759 }
760}
761
762#[derive(Debug, Clone, uniffi::Enum)]
764pub enum RegistrationStatus {
765 Queued,
767 Batching,
769 Submitted,
771 Finalized,
773 Failed {
775 error: String,
777 error_code: Option<String>,
779 },
780}
781
782#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
784pub enum GatewayRequestStatus {
785 Queued,
787 Batching,
789 Submitted {
791 tx_hash: String,
793 },
794 Finalized {
796 tx_hash: String,
798 },
799 Failed {
801 error: String,
803 error_code: Option<String>,
805 },
806}
807
808impl From<GatewayRequestState> for GatewayRequestStatus {
809 fn from(state: GatewayRequestState) -> Self {
810 match state {
811 GatewayRequestState::Queued => Self::Queued,
812 GatewayRequestState::Batching => Self::Batching,
813 GatewayRequestState::Submitted { tx_hash } => Self::Submitted { tx_hash },
814 GatewayRequestState::Finalized { tx_hash } => Self::Finalized { tx_hash },
815 GatewayRequestState::Failed { error, error_code } => Self::Failed {
816 error,
817 error_code: error_code.map(|code| code.to_string()),
818 },
819 }
820 }
821}
822
823impl From<GatewayRequestState> for RegistrationStatus {
824 fn from(state: GatewayRequestState) -> Self {
825 match state {
826 GatewayRequestState::Queued => Self::Queued,
827 GatewayRequestState::Batching => Self::Batching,
828 GatewayRequestState::Submitted { .. } => Self::Submitted,
829 GatewayRequestState::Finalized { .. } => Self::Finalized,
830 GatewayRequestState::Failed { error, error_code } => Self::Failed {
831 error,
832 error_code: error_code.map(|c: GatewayErrorCode| c.to_string()),
833 },
834 }
835 }
836}
837
838#[derive(uniffi::Object)]
843pub struct InitializingAuthenticator(CoreInitializingAuthenticator);
844
845#[uniffi::export(async_runtime = "tokio")]
846impl InitializingAuthenticator {
847 #[uniffi::constructor]
855 #[tracing::instrument(
856 target = "walletkit_latency",
857 name = "gateway_register",
858 skip_all
859 )]
860 pub async fn register_with_defaults(
861 seed: Vec<u8>,
862 rpc_url: Option<String>,
863 environment: &Environment,
864 region: Option<Region>,
865 recovery_address: Option<String>,
866 ) -> Result<Self, WalletKitError> {
867 let recovery_address =
868 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
869
870 let config = defaults::default_config(environment, rpc_url, region)?;
871
872 let initializing_authenticator =
873 CoreAuthenticator::register(&seed, config, recovery_address).await?;
874
875 Ok(Self(initializing_authenticator))
876 }
877
878 #[uniffi::constructor]
888 #[tracing::instrument(
889 target = "walletkit_latency",
890 name = "gateway_register",
891 skip_all
892 )]
893 pub async fn register_with_ohttp_defaults(
894 seed: Vec<u8>,
895 rpc_url: Option<String>,
896 environment: &Environment,
897 region: Option<Region>,
898 recovery_address: Option<String>,
899 ) -> Result<Self, WalletKitError> {
900 let recovery_address =
901 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
902
903 let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
904
905 let initializing_authenticator =
906 CoreAuthenticator::register(&seed, config, recovery_address).await?;
907
908 Ok(Self(initializing_authenticator))
909 }
910
911 #[uniffi::constructor]
919 #[tracing::instrument(
920 target = "walletkit_latency",
921 name = "gateway_register",
922 skip_all
923 )]
924 pub async fn register(
925 seed: Vec<u8>,
926 config: &str,
927 recovery_address: Option<String>,
928 ) -> Result<Self, WalletKitError> {
929 let recovery_address =
930 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
931
932 let config =
933 Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
934 attribute: "config".to_string(),
935 reason: "Invalid config".to_string(),
936 })?;
937
938 let initializing_authenticator =
939 CoreAuthenticator::register(&seed, config, recovery_address).await?;
940
941 Ok(Self(initializing_authenticator))
942 }
943
944 #[tracing::instrument(
949 target = "walletkit_latency",
950 name = "gateway_poll",
951 skip_all
952 )]
953 pub async fn poll_status(&self) -> Result<RegistrationStatus, WalletKitError> {
954 let status = self.0.poll_status().await?;
955 Ok(status.into())
956 }
957}
958
959#[derive(Debug, Clone, uniffi::Record)]
965pub struct RecoveryUpdateSignature {
966 pub signature: Vec<u8>,
969 pub nonce: Uint256,
972}
973
974#[derive(Debug, Clone, uniffi::Record)]
982pub struct RecoveryData {
983 pub authenticator_address: String,
985 pub authenticator_pubkey: String,
987 pub offchain_signer_commitment: String,
989}
990
991impl RecoveryData {
992 pub fn from_seed(seed: &[u8]) -> Result<Self, WalletKitError> {
1001 let signer = Signer::from_seed_bytes(seed)?;
1002 let authenticator_address = signer.onchain_signer_address().to_checksum(None);
1003 let authenticator_pubkey: U256 = signer
1004 .offchain_signer_pubkey()
1005 .to_ethereum_representation()?;
1006 let mut key_set = AuthenticatorPublicKeySet::default();
1007 key_set.try_push(signer.offchain_signer_pubkey())?;
1008 let offchain_signer_commitment: U256 = key_set.leaf_hash().into();
1009
1010 Ok(Self {
1011 authenticator_address,
1012 authenticator_pubkey: format!("{authenticator_pubkey:#066x}"),
1013 offchain_signer_commitment: format!("{offchain_signer_commitment:#066x}"),
1014 })
1015 }
1016}
1017
1018#[uniffi::export]
1038pub fn validate_authenticator_pubkey(
1039 authenticator_pubkey: &str,
1040) -> Result<String, WalletKitError> {
1041 let pubkey =
1042 parse_authenticator_pubkey("authenticator_pubkey", authenticator_pubkey)?;
1043 let encoded = pubkey.to_ethereum_representation()?;
1044 Ok(format!("{encoded:#066x}"))
1045}
1046
1047#[uniffi::export]
1054#[allow(
1055 clippy::needless_pass_by_value,
1056 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]`"
1057)]
1058pub fn recovery_data_from_seed(seed: Vec<u8>) -> Result<RecoveryData, WalletKitError> {
1059 RecoveryData::from_seed(&seed)
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065
1066 const TEST_SEED: [u8; 32] = [1u8; 32];
1067
1068 async fn test_authenticator(
1069 server: &mut mockito::Server,
1070 ) -> (Authenticator, std::path::PathBuf) {
1071 use crate::storage::tests_utils::{temp_root_path, InMemoryStorageProvider};
1072 use alloy::primitives::address;
1073 use world_id_core::primitives::ServiceEndpoint;
1074 use world_id_proof::artifacts::dummy::DummyZkArtifactSource;
1075
1076 let _ = rustls::crypto::ring::default_provider().install_default();
1077
1078 let packed_account_mock = server
1079 .mock("POST", "/packed-account")
1080 .with_status(200)
1081 .with_header("content-type", "application/json")
1082 .with_body(serde_json::json!({ "packed_account_data": "0x2a" }).to_string())
1083 .create_async()
1084 .await;
1085 let config = Config::new(
1086 None,
1087 480,
1088 address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1089 ServiceEndpoint::direct(server.url()),
1090 ServiceEndpoint::direct(server.url()),
1091 vec![],
1092 2,
1093 )
1094 .expect("valid config");
1095 let root = temp_root_path();
1096 let provider = InMemoryStorageProvider::new(&root);
1097 let store =
1098 CredentialStore::from_provider(&provider).expect("credential store");
1099 let authenticator = Authenticator::init_with_config(
1100 &TEST_SEED,
1101 config,
1102 Arc::new(DummyZkArtifactSource),
1103 Arc::new(store),
1104 )
1105 .await
1106 .expect("authenticator should initialize");
1107 packed_account_mock.assert_async().await;
1108
1109 (authenticator, root)
1110 }
1111
1112 fn encoded_pubkey(seed: &[u8; 32]) -> String {
1113 let pubkey = Signer::from_seed_bytes(seed)
1114 .expect("valid seed")
1115 .offchain_signer_pubkey()
1116 .to_ethereum_representation()
1117 .expect("public key should encode");
1118 format!("{pubkey:#066x}")
1119 }
1120
1121 async fn mock_authenticator_pubkeys(
1125 server: &mut mockito::Server,
1126 pubkeys: &[Option<&str>],
1127 expected_hits: usize,
1128 ) -> mockito::Mock {
1129 server
1130 .mock("POST", "/authenticator-pubkeys")
1131 .match_body(mockito::Matcher::JsonString(
1132 serde_json::json!({ "leaf_index": "0x2a" }).to_string(),
1133 ))
1134 .with_status(200)
1135 .with_header("content-type", "application/json")
1136 .with_body(
1137 serde_json::json!({
1138 "authenticator_pubkeys": pubkeys,
1139 "offchain_signer_commitment": "0x0"
1140 })
1141 .to_string(),
1142 )
1143 .expect(expected_hits)
1144 .create_async()
1145 .await
1146 }
1147
1148 #[test]
1149 fn test_recovery_data_from_seed() {
1150 let seed = [1u8; 32];
1151 let material = RecoveryData::from_seed(&seed).expect("should derive material");
1152
1153 assert!(material.authenticator_address.starts_with("0x"));
1154 assert_eq!(material.authenticator_address.len(), 42);
1155 assert!(material.authenticator_pubkey.starts_with("0x"));
1156 assert!(material.authenticator_pubkey.len() <= 66);
1157 assert!(material.offchain_signer_commitment.starts_with("0x"));
1158 assert!(material.offchain_signer_commitment.len() <= 66);
1159 assert!(material.authenticator_address.len() > 2);
1160 assert!(material.authenticator_pubkey.len() > 2);
1161 assert!(material.offchain_signer_commitment.len() > 2);
1162 }
1163
1164 #[test]
1165 fn test_recovery_data_rejects_invalid_seed() {
1166 assert!(RecoveryData::from_seed(&[0u8; 16]).is_err());
1167 assert!(RecoveryData::from_seed(&[]).is_err());
1168 }
1169
1170 #[test]
1171 fn test_authenticator_pubkey_validation() {
1172 let canonical = encoded_pubkey(&[2u8; 32]);
1173 assert_eq!(
1174 validate_authenticator_pubkey(&canonical).expect("valid key"),
1175 canonical
1176 );
1177 let uppercase = format!("0x{}", canonical[2..].to_uppercase());
1178 assert_eq!(
1179 validate_authenticator_pubkey(&uppercase)
1180 .expect("uppercase hex should canonicalize"),
1181 canonical
1182 );
1183
1184 for invalid_pubkey in [
1185 "not-a-public-key".to_string(),
1186 format!("0x{}", "ff".repeat(32)),
1187 ] {
1188 assert!(matches!(
1189 validate_authenticator_pubkey(&invalid_pubkey),
1190 Err(WalletKitError::InvalidInput { attribute, .. })
1191 if attribute == "authenticator_pubkey"
1192 ));
1193 }
1194
1195 let identity = format!("0x{}01", "0".repeat(62));
1196 assert!(matches!(
1197 validate_authenticator_pubkey(&identity),
1198 Err(WalletKitError::InvalidInput { attribute, reason })
1199 if attribute == "authenticator_pubkey" && reason.contains("identity")
1200 ));
1201 let sign_bit_alias = format!("0x80{}01", "0".repeat(60));
1202 assert!(matches!(
1203 validate_authenticator_pubkey(&sign_bit_alias),
1204 Err(WalletKitError::InvalidInput { attribute, reason })
1205 if attribute == "authenticator_pubkey" && reason.contains("canonical")
1206 ));
1207 }
1208
1209 #[tokio::test]
1210 async fn test_poll_status_normalizes_request_id() {
1211 use crate::storage::tests_utils::cleanup_test_storage;
1212
1213 let mut server = mockito::Server::new_async().await;
1214 let (authenticator, root) = test_authenticator(&mut server).await;
1215 let status_mock = server
1216 .mock("GET", "/status/gw_poll_test")
1217 .with_status(200)
1218 .with_header("content-type", "application/json")
1219 .with_body(
1220 serde_json::json!({
1221 "request_id": "gw_poll_test",
1222 "kind": "insert_authenticator",
1223 "status": {
1224 "state": "finalized",
1225 "tx_hash": "0x1234"
1226 }
1227 })
1228 .to_string(),
1229 )
1230 .expect(2)
1231 .create_async()
1232 .await;
1233
1234 for request_id in ["poll_test", "gw_poll_test"] {
1235 assert_eq!(
1236 authenticator
1237 .poll_status(request_id.to_string())
1238 .await
1239 .expect("status poll should succeed"),
1240 GatewayRequestStatus::Finalized {
1241 tx_hash: "0x1234".to_string()
1242 }
1243 );
1244 }
1245 status_mock.assert_async().await;
1246
1247 drop(server);
1248 cleanup_test_storage(&root);
1249 }
1250
1251 #[tokio::test]
1252 async fn test_remove_authenticator_refuses_unexpected_slot_contents() {
1253 use crate::storage::tests_utils::cleanup_test_storage;
1254
1255 let mut server = mockito::Server::new_async().await;
1256 let (authenticator, root) = test_authenticator(&mut server).await;
1257 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1258 let slot_pubkey = encoded_pubkey(&[2u8; 32]);
1259
1260 let pubkeys_mock = mock_authenticator_pubkeys(
1261 &mut server,
1262 &[
1263 Some(existing_pubkey.as_str()),
1264 None,
1265 Some(slot_pubkey.as_str()),
1266 ],
1267 2,
1268 )
1269 .await;
1270 let nonce_mock = server
1271 .mock("POST", "/signature-nonce")
1272 .expect(0)
1273 .create_async()
1274 .await;
1275 let remove_mock = server
1276 .mock("POST", "/remove-authenticator")
1277 .expect(0)
1278 .create_async()
1279 .await;
1280
1281 let mismatched = authenticator
1282 .remove_authenticator(
1283 Address::ZERO.to_string(),
1284 2,
1285 encoded_pubkey(&[3u8; 32]),
1286 )
1287 .await;
1288 assert!(matches!(
1289 mismatched,
1290 Err(WalletKitError::InvalidInput { attribute, .. })
1291 if attribute == "expected_authenticator_pubkey"
1292 ));
1293
1294 let empty_slot = authenticator
1295 .remove_authenticator(
1296 Address::ZERO.to_string(),
1297 1,
1298 encoded_pubkey(&[3u8; 32]),
1299 )
1300 .await;
1301 assert!(matches!(
1302 empty_slot,
1303 Err(WalletKitError::InvalidInput { attribute, reason })
1304 if attribute == "pubkey_id"
1305 && reason.contains("no authenticator at key set slot 1")
1306 ));
1307
1308 let out_of_range = authenticator
1309 .remove_authenticator(
1310 Address::ZERO.to_string(),
1311 7,
1312 encoded_pubkey(&[3u8; 32]),
1313 )
1314 .await;
1315 assert!(matches!(
1316 out_of_range,
1317 Err(WalletKitError::InvalidInput { attribute, reason })
1318 if attribute == "pubkey_id" && reason.contains("out of range")
1319 ));
1320
1321 pubkeys_mock.assert_async().await;
1322 nonce_mock.assert_async().await;
1323 remove_mock.assert_async().await;
1324
1325 drop(server);
1326 cleanup_test_storage(&root);
1327 }
1328
1329 #[tokio::test]
1330 async fn test_key_set_reads_return_slots_and_membership() {
1331 use crate::storage::tests_utils::cleanup_test_storage;
1332
1333 let mut server = mockito::Server::new_async().await;
1334 let (authenticator, root) = test_authenticator(&mut server).await;
1335 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1336 let other_pubkey = encoded_pubkey(&[2u8; 32]);
1337
1338 let pubkeys_mock = mock_authenticator_pubkeys(
1339 &mut server,
1340 &[
1341 Some(existing_pubkey.as_str()),
1342 None,
1343 Some(other_pubkey.as_str()),
1344 ],
1345 3,
1346 )
1347 .await;
1348
1349 assert!(authenticator
1350 .has_authenticator_pubkey(existing_pubkey.clone())
1351 .await
1352 .expect("membership read should succeed"));
1353 assert!(!authenticator
1354 .has_authenticator_pubkey(encoded_pubkey(&[3u8; 32]))
1355 .await
1356 .expect("absent key check should succeed"));
1357 assert_eq!(
1358 authenticator
1359 .get_authenticator_pubkeys()
1360 .await
1361 .expect("key set read should succeed"),
1362 vec![Some(existing_pubkey), None, Some(other_pubkey)]
1363 );
1364 pubkeys_mock.assert_async().await;
1365
1366 drop(server);
1367 cleanup_test_storage(&root);
1368 }
1369
1370 #[tokio::test]
1371 async fn test_remove_authenticator_reports_slot_emptied_during_signing() {
1372 use crate::storage::tests_utils::cleanup_test_storage;
1373 use std::sync::atomic::{AtomicUsize, Ordering};
1374
1375 let mut server = mockito::Server::new_async().await;
1376 let (authenticator, root) = test_authenticator(&mut server).await;
1377 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1378 let removed_pubkey = encoded_pubkey(&[2u8; 32]);
1379
1380 let full_body = serde_json::json!({
1386 "authenticator_pubkeys": [existing_pubkey.clone(), removed_pubkey.clone()],
1387 "offchain_signer_commitment": "0x0"
1388 })
1389 .to_string();
1390 let emptied_body = serde_json::json!({
1391 "authenticator_pubkeys": [existing_pubkey],
1392 "offchain_signer_commitment": "0x0"
1393 })
1394 .to_string();
1395 let fetches = Arc::new(AtomicUsize::new(0));
1396 let fetches_in_mock = Arc::clone(&fetches);
1397 let pubkeys_mock = server
1398 .mock("POST", "/authenticator-pubkeys")
1399 .with_status(200)
1400 .with_header("content-type", "application/json")
1401 .with_body_from_request(move |_request| {
1402 if fetches_in_mock.fetch_add(1, Ordering::SeqCst) == 0 {
1403 full_body.clone().into_bytes()
1404 } else {
1405 emptied_body.clone().into_bytes()
1406 }
1407 })
1408 .expect(2)
1409 .create_async()
1410 .await;
1411 let nonce_mock = server
1412 .mock("POST", "/signature-nonce")
1413 .with_status(200)
1414 .with_header("content-type", "application/json")
1415 .with_body(serde_json::json!({ "signature_nonce": "0x1" }).to_string())
1416 .create_async()
1417 .await;
1418 let remove_mock = server
1419 .mock("POST", "/remove-authenticator")
1420 .expect(0)
1421 .create_async()
1422 .await;
1423
1424 let raced = authenticator
1425 .remove_authenticator(Address::ZERO.to_string(), 1, removed_pubkey)
1426 .await;
1427 assert!(matches!(
1428 raced,
1429 Err(WalletKitError::InvalidInput { attribute, .. })
1430 if attribute == "pubkey_id"
1431 ));
1432
1433 pubkeys_mock.assert_async().await;
1434 nonce_mock.assert_async().await;
1435 remove_mock.assert_async().await;
1436
1437 drop(server);
1438 cleanup_test_storage(&root);
1439 }
1440
1441 #[cfg(feature = "embed-zkeys")]
1442 #[tokio::test]
1443 async fn test_init_with_config_and_materials() {
1444 use crate::{
1445 authenticator::artifacts::caching::CachingZkArtifacts,
1446 storage::tests_utils::{
1447 cleanup_test_storage, temp_root_path, InMemoryStorageProvider,
1448 },
1449 };
1450 use alloy::primitives::address;
1451 use world_id_core::primitives::{Config, ServiceEndpoint};
1452
1453 let _ = rustls::crypto::ring::default_provider().install_default();
1454
1455 let mut mock_server = mockito::Server::new_async().await;
1456 mock_server
1457 .mock("POST", "/")
1458 .with_status(200)
1459 .with_header("content-type", "application/json")
1460 .with_body(
1461 serde_json::json!({
1462 "jsonrpc": "2.0",
1463 "id": 1,
1464 "result": "0x0000000000000000000000000000000000000000000000000000000000000001"
1465 })
1466 .to_string(),
1467 )
1468 .create_async()
1469 .await;
1470
1471 let config = Config::new(
1472 Some(mock_server.url()),
1473 480,
1474 address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1475 ServiceEndpoint::direct(
1476 "https://indexer.us.id-infra.worldcoin.dev".to_string(),
1477 ),
1478 ServiceEndpoint::direct(
1479 "https://gateway.id-infra.worldcoin.dev".to_string(),
1480 ),
1481 vec![],
1482 2,
1483 )
1484 .unwrap();
1485 let config = serde_json::to_string(&config).unwrap();
1486
1487 let root = temp_root_path();
1488 let provider = InMemoryStorageProvider::new(&root);
1489 let store = CredentialStore::from_provider(&provider).expect("store");
1490 store.init(42, 100).expect("init storage");
1491
1492 let artifacts =
1493 Arc::new(CachingZkArtifacts::new(Arc::new(store.paths().unwrap())));
1494
1495 let _authenticator = Authenticator::init(
1496 [2u8; 32].to_vec(),
1497 &config,
1498 artifacts,
1499 Arc::new(store),
1500 )
1501 .await
1502 .unwrap();
1503 drop(mock_server);
1504
1505 cleanup_test_storage(&root);
1506 }
1507}