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