walletkit_core/
proof.rs

1use crate::error::WalletKitError;
2
3use alloy_core::sol_types::SolValue;
4use semaphore_rs::{
5    hash_to_field, identity,
6    packed_proof::PackedProof,
7    protocol::{generate_nullifier_hash, generate_proof, Proof},
8};
9
10use semaphore_rs::MODULUS;
11
12use serde::Serialize;
13
14use crate::{
15    credential_type::CredentialType, merkle_tree::MerkleTreeProof, u256::U256Wrapper,
16};
17
18/// A `ProofContext` contains the basic information on the verifier and the specific action a user will be proving.
19///
20/// It is required to generate a `Proof` and will generally be initialized from an `app_id` and `action`.
21///
22/// Note on naming: `ProofContext` is used to make it clear in FFIs which may not respect the module structure.
23#[derive(Clone, PartialEq, Eq, Debug)]
24#[cfg_attr(feature = "ffi", derive(uniffi::Object))]
25pub struct ProofContext {
26    /// The `external_nullifier` is the computed result of a specific context for which a World ID Proof is generated.
27    /// It is used in the Sempahore ZK circuit and in the computation of the `nullifier_hash` to guarantee uniqueness in a privacy-preserving way.
28    pub external_nullifier: U256Wrapper,
29    /// Represents the specific credential to be used for a World ID Proof.
30    pub credential_type: CredentialType,
31    /// The hashed signal which is included in the ZKP and committed to in the proof.
32    /// When verifying the proof, the same signal must be provided.
33    pub signal_hash: U256Wrapper,
34}
35
36#[cfg_attr(feature = "ffi", uniffi::export)]
37impl ProofContext {
38    /// Initializes a `ProofContext`.
39    ///
40    /// Will compute the relevant external nullifier from the provided `app_id` and `action` as defined by the
41    /// World ID Protocol. The external nullifier generation matches the logic in the
42    /// [Developer Portal](https://github.com/worldcoin/developer-portal/blob/main/web/lib/hashing.ts).
43    ///
44    /// # Arguments
45    ///
46    /// * `app_id` - The ID of the application requesting proofs.  This can be obtained from the [Developer Portal](https://developer.world.org).
47    /// * `action` - Optional. Custom incognito action being requested.
48    /// * `signal` - Optional. The signal is included in the ZKP and is committed to in the proof. When verifying the proof, the
49    ///   same signal must be provided to ensure the proof is valid. The signal can be used to prevent replay attacks, MITM or other cases.
50    ///   More details available in the [docs](https://docs.world.org/world-id/further-reading/zero-knowledge-proofs).
51    /// * `credential_type` - The type of credential being requested.
52    ///
53    #[must_use]
54    #[cfg_attr(feature = "ffi", uniffi::constructor)]
55    pub fn new(
56        app_id: &str,
57        action: Option<String>,
58        signal: Option<String>,
59        credential_type: CredentialType,
60    ) -> Self {
61        Self::new_from_bytes(
62            app_id,
63            action.map(std::string::String::into_bytes),
64            signal.map(std::string::String::into_bytes),
65            credential_type,
66        )
67    }
68
69    /// Initializes a `Proof::ProofContext` where the `action` is provided as raw bytes. This is useful for advanced cases
70    /// where the `action` is an already ABI encoded value for on-chain usage.
71    /// See _walletkit-core/tests/solidity.rs_ for an example.
72    ///
73    /// Will compute the relevant external nullifier from the provided `app_id` and `action`.
74    ///
75    /// # Arguments
76    ///
77    /// See `ProofContext::new` for reference. The `action` and `signal` need to be provided as raw bytes.
78    ///
79    #[must_use]
80    #[cfg_attr(feature = "ffi", uniffi::constructor)]
81    #[allow(clippy::needless_pass_by_value)]
82    pub fn new_from_bytes(
83        app_id: &str,
84        action: Option<Vec<u8>>,
85        signal: Option<Vec<u8>>,
86        credential_type: CredentialType,
87    ) -> Self {
88        let signal_hash =
89            U256Wrapper::from(hash_to_field(signal.unwrap_or_default().as_slice()));
90
91        Self::new_from_signal_hash_unchecked(
92            app_id,
93            action,
94            credential_type,
95            &signal_hash,
96        )
97    }
98
99    /// Initializes a `ProofContext` from an already hashed signal.
100    ///
101    /// Please note it is imperative to hash into the Semaphore field. Not all U256 are part of the field.
102    /// Use the `hash_to_field` function to hash into the field.
103    ///
104    /// # Usage
105    /// - This may be used when the hash of the signal is computed externally.
106    /// - For example, this is used for support of legacy `MiniKit` v1 commands in World App where `minikit-js` hashed the signal.
107    ///
108    /// # Arguments
109    ///
110    /// * `app_id` - The ID of the application requesting proofs.  This can be obtained from the [Developer Portal](https://developer.world.org).
111    /// * `action` - Optional. Custom incognito action being requested as bytes.
112    /// * `credential_type` - The type of credential being requested.
113    /// * `signal` - The already hashed signal as a field element.
114    ///
115    /// # Errors
116    ///
117    /// - Returns an error if the signal is not a valid number in the field.
118    #[cfg_attr(feature = "ffi", uniffi::constructor)]
119    pub fn new_from_signal_hash(
120        app_id: &str,
121        action: Option<Vec<u8>>,
122        credential_type: CredentialType,
123        signal_hash: &U256Wrapper,
124    ) -> Result<Self, WalletKitError> {
125        if signal_hash.0 >= MODULUS {
126            return Err(WalletKitError::InvalidNumber);
127        }
128
129        Ok(Self::new_from_signal_hash_unchecked(
130            app_id,
131            action,
132            credential_type,
133            signal_hash,
134        ))
135    }
136}
137
138// This impl block is not exported to foreign bindings.
139impl ProofContext {
140    fn new_from_signal_hash_unchecked(
141        app_id: &str,
142        action: Option<Vec<u8>>,
143        credential_type: CredentialType,
144        signal_hash: &U256Wrapper,
145    ) -> Self {
146        let mut pre_image = hash_to_field(app_id.as_bytes()).abi_encode_packed();
147
148        if let Some(action) = action {
149            pre_image.extend_from_slice(&action);
150        }
151
152        let external_nullifier = hash_to_field(&pre_image).into();
153
154        Self {
155            external_nullifier,
156            credential_type,
157            signal_hash: *signal_hash,
158        }
159    }
160}
161
162#[cfg_attr(feature = "ffi", uniffi::export)]
163#[cfg(feature = "legacy-nullifiers")]
164impl ProofContext {
165    /// LEGACY AND ADVANCED USE ONLY.
166    ///
167    /// Initializes a `ProofContext` from an arbitrary pre-image for an external nullifier.
168    ///
169    /// This is used for legacy nullifiers which were constructed from arbitrary bytes which don't follow
170    /// the `app_id` and `action` standard.
171    ///
172    /// # Usage (Non-exhaustive)
173    ///
174    /// - This is used for the World ID Address Book.
175    ///
176    /// # Arguments
177    ///
178    /// * `external_nullifier` - An arbitrary array of bytes that will be hashed to produce the external nullifier.
179    /// * `credential_type` - The type of credential being requested.
180    /// * `signal` - Optional. The signal is included in the ZKP and is committed to in the proof.
181    #[must_use]
182    #[cfg_attr(feature = "ffi", uniffi::constructor)]
183    pub fn legacy_new_from_pre_image_external_nullifier(
184        external_nullifier: &[u8],
185        credential_type: CredentialType,
186        signal: Option<Vec<u8>>,
187    ) -> Self {
188        let external_nullifier: U256Wrapper = hash_to_field(external_nullifier).into();
189        Self {
190            external_nullifier,
191            credential_type,
192            signal_hash: hash_to_field(signal.unwrap_or_default().as_slice()).into(),
193        }
194    }
195
196    /// LEGACY AND ADVANCED USE ONLY.
197    ///
198    /// Initializes a `ProofContext` from a raw external nullifier.
199    ///
200    /// This is used for legacy nullifiers which were constructed from raw field elements.
201    ///
202    /// # Usage (Non-exhaustive)
203    ///
204    /// - This is used for Recurring Grant Claims (Worldcoin Airdrop).
205    /// - This is used to verify a World App account.
206    ///
207    /// # Arguments
208    ///
209    /// * `external_nullifier` - The raw external nullifier. Must already be a number in the field. No additional hashing is performed.
210    /// * `credential_type` - The type of credential being requested.
211    /// * `signal` - Optional. The signal is included in the ZKP and is committed to in the proof.
212    ///
213    /// # Errors
214    ///
215    /// - Returns an error if the external nullifier is not a valid number in the field.
216    #[cfg_attr(feature = "ffi", uniffi::constructor)]
217    pub fn legacy_new_from_raw_external_nullifier(
218        external_nullifier: &U256Wrapper,
219        credential_type: CredentialType,
220        signal: Option<Vec<u8>>,
221    ) -> Result<Self, WalletKitError> {
222        if external_nullifier.0 >= MODULUS {
223            return Err(WalletKitError::InvalidNumber);
224        }
225
226        Ok(Self {
227            external_nullifier: *external_nullifier,
228            credential_type,
229            signal_hash: hash_to_field(signal.unwrap_or_default().as_slice()).into(),
230        })
231    }
232}
233
234/// Represents the complete output of a World ID Proof (i.e. a credential persentation). This output
235/// can be serialized to JSON and can be verified easily with the Developer Portal or Sign up Sequencer.
236///
237/// For on-chain verification, the `proof` (which is packed) should generally be deserialized into `uint256[8]`.
238///
239/// More information on: [On-Chain Verification](https://docs.world.org/world-id/id/on-chain)
240#[derive(Clone, PartialEq, Eq, Debug, Serialize)]
241#[cfg_attr(feature = "ffi", derive(uniffi::Object))]
242#[allow(clippy::module_name_repetitions)]
243pub struct ProofOutput {
244    /// The root hash of the Merkle tree used to prove membership. This root hash should match published hashes in the World ID
245    ///     protocol contract in Ethereum mainnet. See [address book](https://docs.world.org/world-id/reference/address-book).
246    pub merkle_root: U256Wrapper,
247    /// Represents the unique identifier for a specific context (app & action) and World ID. A World ID holder will always generate
248    /// the same `nullifier_hash` for the same context.
249    pub nullifier_hash: U256Wrapper,
250    /// The raw zero-knowledge proof.
251    #[serde(skip_serializing)]
252    pub raw_proof: Proof,
253    /// The ABI-encoded zero-knowledge proof represented as a string. This is the format generally used with other libraries and
254    /// can be directly used with the Developer Portal for verification.
255    pub proof: PackedProof,
256    /// The credential type used to generate the proof.
257    pub credential_type: CredentialType,
258}
259
260#[cfg_attr(feature = "ffi", uniffi::export)]
261impl ProofOutput {
262    /// Converts the entire proof output to a JSON string with standard attribute names.
263    ///
264    /// # Errors
265    /// Will error if serialization fails.
266    pub fn to_json(&self) -> Result<String, WalletKitError> {
267        serde_json::to_string(self).map_err(|_| WalletKitError::SerializationError)
268    }
269
270    /// Exposes the nullifier hash to foreign code. Struct fields are not directly exposed to foreign code.
271    #[must_use]
272    pub const fn get_nullifier_hash(&self) -> U256Wrapper {
273        self.nullifier_hash
274    }
275
276    /// Exposes the merkle root to foreign code. Struct fields are not directly exposed to foreign code.
277    #[must_use]
278    pub const fn get_merkle_root(&self) -> U256Wrapper {
279        self.merkle_root
280    }
281
282    /// Exposes the proof as a string to foreign code. Struct fields are not directly exposed to foreign code.
283    #[must_use]
284    pub fn get_proof_as_string(&self) -> String {
285        self.proof.to_string()
286    }
287
288    /// Exposes the credential type to foreign code. Struct fields are not directly exposed to foreign code.
289    #[must_use]
290    pub const fn get_credential_type(&self) -> CredentialType {
291        self.credential_type
292    }
293}
294
295/// Generates a Semaphore ZKP for a specific Semaphore identity using the relevant provided context.
296///
297/// **Requires the `semaphore` feature flag.**
298///
299/// # Errors
300/// Returns an error if proof generation fails
301pub fn generate_proof_with_semaphore_identity(
302    identity: &identity::Identity,
303    merkle_tree_proof: &MerkleTreeProof,
304    context: &ProofContext,
305) -> Result<ProofOutput, WalletKitError> {
306    #[cfg(not(feature = "semaphore"))]
307    return Err(WalletKitError::SemaphoreNotEnabled);
308
309    let merkle_root = merkle_tree_proof.merkle_root; // clone the value
310
311    let external_nullifier_hash = context.external_nullifier.into();
312    let nullifier_hash =
313        generate_nullifier_hash(identity, external_nullifier_hash).into();
314
315    let proof = generate_proof(
316        identity,
317        merkle_tree_proof.as_poseidon_proof(),
318        external_nullifier_hash,
319        context.signal_hash.into(),
320    )?;
321
322    Ok(ProofOutput {
323        merkle_root,
324        nullifier_hash,
325        raw_proof: proof,
326        proof: PackedProof::from(proof),
327        credential_type: context.credential_type,
328    })
329}
330
331#[cfg(test)]
332mod external_nullifier_tests {
333    use alloy_core::primitives::address;
334    use ruint::{aliases::U256, uint};
335
336    use super::*;
337
338    #[test]
339    fn test_context_and_external_nullifier_hash_generation() {
340        let context = ProofContext::new(
341            "app_369183bd38f1641b6964ab51d7a20434",
342            None,
343            None,
344            CredentialType::Orb,
345        );
346        assert_eq!(
347            context.external_nullifier.to_hex_string(),
348            "0x0073e4a6b670e81dc619b1f8703aa7491dc5aaadf75409aba0ac2414014c0227"
349        );
350
351        // note the same external nullifier hash is generated for an empty string action
352        let context = ProofContext::new(
353            "app_369183bd38f1641b6964ab51d7a20434",
354            Some(String::new()),
355            None,
356            CredentialType::Orb,
357        );
358        assert_eq!(
359            context.external_nullifier.to_hex_string(),
360            "0x0073e4a6b670e81dc619b1f8703aa7491dc5aaadf75409aba0ac2414014c0227"
361        );
362    }
363
364    /// This test case comes from the real example in the docs.
365    /// Reference: <https://github.com/worldcoin/world-id-docs/blob/main/src/pages/world-id/try.tsx>
366    #[test]
367    fn test_external_nullifier_hash_generation_string_action_staging() {
368        let context = ProofContext::new(
369            "app_staging_45068dca85829d2fd90e2dd6f0bff997",
370            Some("test-action-qli8g".to_string()),
371            None,
372            CredentialType::Orb,
373        );
374        assert_eq!(
375            context.external_nullifier.to_hex_string(),
376            "0x00d8b157e767dc59faa533120ed0ce34fc51a71937292ea8baed6ee6f4fda866"
377        );
378    }
379
380    #[test]
381    fn test_external_nullifier_hash_generation_string_action() {
382        let context = ProofContext::new(
383            "app_10eb12bd96d8f7202892ff25f094c803",
384            Some("test-123123".to_string()),
385            None,
386            CredentialType::Orb,
387        );
388        assert_eq!(
389            context.external_nullifier.0,
390            uint!(
391                // cspell:disable-next-line
392                0x0065ebab05692ff2e0816cc4c3b83216c33eaa4d906c6495add6323fe0e2dc89_U256
393            )
394        );
395    }
396
397    #[test]
398    fn test_external_nullifier_hash_generation_with_advanced_abi_encoded_values() {
399        let custom_action = [
400            address!("541f3cc5772a64f2ba0a47e83236CcE2F089b188").abi_encode_packed(),
401            U256::from(1).abi_encode_packed(),
402            "hello".abi_encode_packed(),
403        ]
404        .concat();
405
406        let context = ProofContext::new_from_bytes(
407            "app_10eb12bd96d8f7202892ff25f094c803",
408            Some(custom_action),
409            None,
410            CredentialType::Orb,
411        );
412        assert_eq!(
413            context.external_nullifier.to_hex_string(),
414            // expected output obtained from Solidity
415            "0x00f974ff06219e8ca992073d8bbe05084f81250dbd8f37cae733f24fcc0c5ffd"
416        );
417    }
418
419    #[test]
420    fn test_external_nullifier_hash_generation_with_advanced_abi_encoded_values_staging(
421    ) {
422        let custom_action = [
423            "world".abi_encode_packed(),
424            U256::from(1).abi_encode_packed(),
425            "hello".abi_encode_packed(),
426        ]
427        .concat();
428
429        let context = ProofContext::new_from_bytes(
430            "app_staging_45068dca85829d2fd90e2dd6f0bff997",
431            Some(custom_action),
432            None,
433            CredentialType::Orb,
434        );
435        assert_eq!(
436            context.external_nullifier.to_hex_string(),
437            // expected output obtained from Solidity
438            "0x005b49f95e822c7c37f4f043421689b11f880e617faa5cd0391803bc9bcc63c0"
439        );
440    }
441
442    #[cfg(feature = "legacy-nullifiers")]
443    #[test]
444    fn test_proof_generation_with_legacy_nullifier_address_book() {
445        let context = ProofContext::legacy_new_from_pre_image_external_nullifier(
446            b"internal_addressbook",
447            CredentialType::Device,
448            None,
449        );
450
451        // the expected nullifier hash from the contract
452        // reference: <https://worldchain-mainnet.explorer.alchemy.com/tx/0x974e70f125abe3b6abaa0b3fb9cb067c09cee359b08fa847487d6623377308fd>
453        let expected = uint!(377593556987874043165400752883455722895901692332643678318174569531027326541_U256);
454        assert_eq!(
455            context.external_nullifier.to_hex_string(),
456            format!("{expected:#066x}")
457        );
458    }
459
460    #[cfg(feature = "legacy-nullifiers")]
461    #[test]
462    fn test_proof_generation_with_legacy_nullifier_recurring_grant_drop() {
463        let grant_id = 48;
464
465        // The constant which is added to all the Grant IDs in World Chain.
466        // Reference: <https://github.com/worldcoin/worldcoin-grants-contracts/blob/main/src/RecurringGrantDrop.sol#L22>
467        let worldchain_nullifier_hash_constant = uint!(
468            0x1E00000000000000000000000000000000000000000000000000000000000000_U256
469        );
470        let external_nullifier_hash =
471            worldchain_nullifier_hash_constant + U256::from(grant_id);
472
473        let context = ProofContext::legacy_new_from_raw_external_nullifier(
474            &external_nullifier_hash.into(),
475            CredentialType::Device,
476            None,
477        )
478        .unwrap();
479
480        // the expected nullifier hash from the contract
481        // transaction example for RGD 48: <https://worldscan.org/tx/0xbad696a88c5425a22af18ea6d00efca78ae0f5c5cceade21597adf60126a5fc4>
482        let expected = uint!(13569385457497991651199724805705614201555076328004753598373935625927319879728_U256);
483        assert_eq!(
484            context.external_nullifier.to_hex_string(),
485            format!("{expected:#066x}")
486        );
487    }
488
489    #[cfg(feature = "legacy-nullifiers")]
490    #[test]
491    fn test_ensure_raw_external_nullifier_is_in_the_field() {
492        let invalid_external_nullifiers = [MODULUS, MODULUS + U256::from(1)];
493        for external_nullifier in invalid_external_nullifiers {
494            let context = ProofContext::legacy_new_from_raw_external_nullifier(
495                &external_nullifier.into(),
496                CredentialType::Device,
497                None,
498            );
499            assert!(context.is_err());
500        }
501    }
502}
503
504#[cfg(test)]
505mod signal_tests {
506    use ruint::aliases::U256;
507
508    use super::*;
509
510    #[test]
511    fn test_ensure_raw_signal_hash_is_in_the_field() {
512        let invalid_signals = [MODULUS, MODULUS + U256::from(1)];
513        for signal_hash in invalid_signals {
514            let context = ProofContext::new_from_signal_hash(
515                "my_app_id",
516                None,
517                CredentialType::Device,
518                &signal_hash.into(),
519            );
520            assert!(context.is_err());
521        }
522    }
523}
524
525#[cfg(test)]
526mod proof_tests {
527
528    use regex::Regex;
529    use semaphore_rs::protocol::verify_proof;
530    use serde_json::Value;
531
532    use super::*;
533
534    fn helper_load_merkle_proof() -> MerkleTreeProof {
535        let json_merkle: Value = serde_json::from_str(include_str!(
536            "../tests/fixtures/inclusion_proof.json"
537        ))
538        .unwrap();
539        MerkleTreeProof::from_json_proof(
540            &serde_json::to_string(&json_merkle["proof"]).unwrap(),
541            json_merkle["root"].as_str().unwrap(),
542        )
543        .unwrap()
544    }
545
546    #[test]
547    fn test_proof_generation() {
548        let context = ProofContext::new(
549            "app_staging_45068dca85829d2fd90e2dd6f0bff997",
550            Some("test-action-89tcf".to_string()),
551            None,
552            CredentialType::Device,
553        );
554
555        let mut secret = b"not_a_real_secret".to_vec();
556
557        let identity = semaphore_rs::identity::Identity::from_secret(
558            &mut secret,
559            Some(context.credential_type.as_identity_trapdoor()),
560        );
561
562        assert_eq!(
563            U256Wrapper::from(identity.commitment()).to_hex_string(),
564            "0x1a060ef75540e13711f074b779a419c126ab5a89d2c2e7d01e64dfd121e44671"
565        );
566
567        // Compute ZKP
568        let zkp = generate_proof_with_semaphore_identity(
569            &identity,
570            &helper_load_merkle_proof(),
571            &context,
572        )
573        .unwrap();
574
575        assert_eq!(
576            zkp.merkle_root.to_hex_string(),
577            "0x2f3a95b6df9074a19bf46e2308d7f5696e9dca49e0d64ef49a1425bbf40e0c02"
578        );
579
580        assert_eq!(
581            zkp.nullifier_hash.to_hex_string(),
582            "0x11d194ff98df5c8e239e6b6e33cce7fb1b419344cb13e064350a917970c8fea4"
583        );
584
585        // assert proof verifies locally
586        assert!(verify_proof(
587            *zkp.merkle_root,
588            *zkp.nullifier_hash,
589            hash_to_field(&[]),
590            *context.external_nullifier,
591            &zkp.raw_proof,
592            30
593        )
594        .unwrap());
595    }
596
597    #[test]
598    fn test_proof_json_encoding() {
599        let context = ProofContext::new(
600            "app_staging_45068dca85829d2fd90e2dd6f0bff997",
601            Some("test-action-89tcf".to_string()),
602            None,
603            CredentialType::Device,
604        );
605
606        let mut secret = b"not_a_real_secret".to_vec();
607        let identity = semaphore_rs::identity::Identity::from_secret(
608            &mut secret,
609            Some(context.credential_type.as_identity_trapdoor()),
610        );
611
612        // Compute ZKP
613        let zkp = generate_proof_with_semaphore_identity(
614            &identity,
615            &helper_load_merkle_proof(),
616            &context,
617        )
618        .unwrap();
619
620        let parsed_json: Value = serde_json::from_str(&zkp.to_json().unwrap()).unwrap();
621
622        assert_eq!(
623            parsed_json["nullifier_hash"].as_str().unwrap(),
624            "0x11d194ff98df5c8e239e6b6e33cce7fb1b419344cb13e064350a917970c8fea4"
625        );
626        assert_eq!(
627            parsed_json["merkle_root"].as_str().unwrap(),
628            "0x2f3a95b6df9074a19bf46e2308d7f5696e9dca49e0d64ef49a1425bbf40e0c02"
629        );
630
631        assert_eq!(parsed_json["credential_type"].as_str().unwrap(), "device");
632
633        // ensure the proof is automatically encoded as packed
634        let packed_proof_pattern = r"^0x[a-f0-9]{400,600}$";
635        let re = Regex::new(packed_proof_pattern).unwrap();
636        assert!(re.is_match(parsed_json["proof"].as_str().unwrap()));
637
638        assert_eq!(
639            zkp.get_nullifier_hash().to_hex_string(),
640            parsed_json["nullifier_hash"].as_str().unwrap()
641        );
642        assert_eq!(
643            zkp.get_merkle_root().to_hex_string(),
644            parsed_json["merkle_root"].as_str().unwrap()
645        );
646        assert_eq!(
647            zkp.get_proof_as_string(),
648            parsed_json["proof"].as_str().unwrap()
649        );
650    }
651}