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