Skip to main content

world_id_primitives/
nullifier.rs

1use ruint::aliases::U256;
2use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
3
4use crate::FieldElement;
5
6/// A session nullifier for World ID Session proofs.
7///
8/// They are an adaptation that reuses the same proof system inputs for session flows:
9/// - the nullifier component lets RPs detect replayed submissions for the same proof context
10/// - the action component is randomized for session verification semantics
11///
12/// Together they include:
13/// - the nullifier used as the proof output
14/// - a random action bound to the same proof
15///
16/// The `WorldIDVerifier.sol` contract expects this as a `uint256[2]` array
17/// use `as_ethereum_representation()` for conversion.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct SessionNullifier {
20    /// The nullifier value for this proof.
21    nullifier: FieldElement,
22    /// The random action value bound to this session proof.
23    action: FieldElement,
24}
25
26impl SessionNullifier {
27    const JSON_PREFIX: &str = "snil_";
28
29    /// Creates a new session nullifier.
30    #[must_use]
31    pub const fn new(nullifier: FieldElement, action: FieldElement) -> Self {
32        Self { nullifier, action }
33    }
34
35    /// Returns the nullifier value.
36    #[must_use]
37    pub const fn nullifier(&self) -> FieldElement {
38        self.nullifier
39    }
40
41    /// Returns the action value.
42    #[must_use]
43    pub const fn action(&self) -> FieldElement {
44        self.action
45    }
46
47    /// Returns the session nullifier as an Ethereum-compatible array for `verifySession()`.
48    ///
49    /// Format: `[nullifier, action]` matching the contract's `uint256[2] sessionNullifier`.
50    #[must_use]
51    pub fn as_ethereum_representation(&self) -> [U256; 2] {
52        [self.nullifier.into(), self.action.into()]
53    }
54
55    /// Creates a session nullifier from an Ethereum representation.
56    ///
57    /// # Errors
58    /// Returns an error if the U256 values are not valid field elements.
59    pub fn from_ethereum_representation(value: [U256; 2]) -> Result<Self, String> {
60        let nullifier =
61            FieldElement::try_from(value[0]).map_err(|e| format!("invalid nullifier: {e}"))?;
62        let action =
63            FieldElement::try_from(value[1]).map_err(|e| format!("invalid action: {e}"))?;
64        Ok(Self { nullifier, action })
65    }
66
67    /// Returns the 64-byte big-endian representation (2 x 32-byte field elements).
68    #[must_use]
69    pub fn to_compressed_bytes(&self) -> [u8; 64] {
70        let mut bytes = [0u8; 64];
71        bytes[..32].copy_from_slice(&self.nullifier.to_be_bytes());
72        bytes[32..].copy_from_slice(&self.action.to_be_bytes());
73        bytes
74    }
75
76    /// Constructs from compressed bytes (must be exactly 64 bytes).
77    ///
78    /// # Errors
79    /// Returns an error if the input is not exactly 64 bytes or if values are not valid field elements.
80    pub fn from_compressed_bytes(bytes: &[u8]) -> Result<Self, String> {
81        if bytes.len() != 64 {
82            return Err(format!(
83                "Invalid length: expected 64 bytes, got {}",
84                bytes.len()
85            ));
86        }
87
88        let nullifier = FieldElement::from_be_bytes(bytes[..32].try_into().unwrap())
89            .map_err(|e| format!("invalid nullifier: {e}"))?;
90        let action = FieldElement::from_be_bytes(bytes[32..].try_into().unwrap())
91            .map_err(|e| format!("invalid action: {e}"))?;
92
93        Ok(Self { nullifier, action })
94    }
95}
96
97impl Default for SessionNullifier {
98    fn default() -> Self {
99        Self {
100            nullifier: FieldElement::ZERO,
101            action: FieldElement::ZERO,
102        }
103    }
104}
105
106impl Serialize for SessionNullifier {
107    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
108    where
109        S: Serializer,
110    {
111        let bytes = self.to_compressed_bytes();
112        if serializer.is_human_readable() {
113            // JSON: prefixed hex-encoded compressed bytes for explicit typing.
114            serializer.serialize_str(&format!("{}{}", Self::JSON_PREFIX, hex::encode(bytes)))
115        } else {
116            // Binary: compressed bytes
117            serializer.serialize_bytes(&bytes)
118        }
119    }
120}
121
122impl<'de> Deserialize<'de> for SessionNullifier {
123    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
124    where
125        D: Deserializer<'de>,
126    {
127        let bytes = if deserializer.is_human_readable() {
128            let value = String::deserialize(deserializer)?;
129            let hex_str = value.strip_prefix(Self::JSON_PREFIX).ok_or_else(|| {
130                D::Error::custom(format!(
131                    "session nullifier must start with '{}'",
132                    Self::JSON_PREFIX
133                ))
134            })?;
135            hex::decode(hex_str).map_err(D::Error::custom)?
136        } else {
137            Vec::deserialize(deserializer)?
138        };
139
140        Self::from_compressed_bytes(&bytes).map_err(D::Error::custom)
141    }
142}
143
144impl From<SessionNullifier> for [U256; 2] {
145    fn from(value: SessionNullifier) -> Self {
146        value.as_ethereum_representation()
147    }
148}
149
150impl From<(FieldElement, FieldElement)> for SessionNullifier {
151    fn from((nullifier, action): (FieldElement, FieldElement)) -> Self {
152        Self::new(nullifier, action)
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn test_field_element(value: u64) -> FieldElement {
161        FieldElement::from(value)
162    }
163
164    #[test]
165    fn test_new_and_accessors() {
166        let nullifier = test_field_element(1001);
167        let action = test_field_element(42);
168        let session = SessionNullifier::new(nullifier, action);
169
170        assert_eq!(session.nullifier(), nullifier);
171        assert_eq!(session.action(), action);
172    }
173
174    #[test]
175    fn test_as_ethereum_representation() {
176        let nullifier = test_field_element(100);
177        let action = test_field_element(200);
178        let session = SessionNullifier::new(nullifier, action);
179
180        let repr = session.as_ethereum_representation();
181        assert_eq!(repr[0], U256::from(100));
182        assert_eq!(repr[1], U256::from(200));
183    }
184
185    #[test]
186    fn test_from_ethereum_representation() {
187        let repr = [U256::from(100), U256::from(200)];
188        let session = SessionNullifier::from_ethereum_representation(repr).unwrap();
189
190        assert_eq!(session.nullifier(), test_field_element(100));
191        assert_eq!(session.action(), test_field_element(200));
192    }
193
194    #[test]
195    fn test_json_roundtrip() {
196        let session = SessionNullifier::new(test_field_element(1001), test_field_element(42));
197        let json = serde_json::to_string(&session).unwrap();
198
199        // Verify JSON uses the prefixed compact representation
200        assert!(json.starts_with("\"snil_"));
201        assert!(json.ends_with('"'));
202
203        // Verify roundtrip
204        let decoded: SessionNullifier = serde_json::from_str(&json).unwrap();
205        assert_eq!(session, decoded);
206    }
207
208    #[test]
209    fn test_json_format() {
210        let session = SessionNullifier::new(test_field_element(1), test_field_element(2));
211        let json = serde_json::to_string(&session).unwrap();
212
213        // Should be a prefixed compact string
214        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
215        assert!(parsed.is_string());
216        let value = parsed.as_str().unwrap();
217        assert!(value.starts_with("snil_"));
218    }
219
220    #[test]
221    fn test_bytes_roundtrip() {
222        let session = SessionNullifier::new(test_field_element(1001), test_field_element(42));
223        let bytes = session.to_compressed_bytes();
224
225        assert_eq!(bytes.len(), 64); // 32 + 32 bytes
226
227        let decoded = SessionNullifier::from_compressed_bytes(&bytes).unwrap();
228        assert_eq!(session, decoded);
229    }
230
231    #[test]
232    fn test_bytes_use_field_element_encoding() {
233        let session = SessionNullifier::new(test_field_element(1001), test_field_element(42));
234        let bytes = session.to_compressed_bytes();
235
236        let mut expected = [0u8; 64];
237        expected[..32].copy_from_slice(&session.nullifier().to_be_bytes());
238        expected[32..].copy_from_slice(&session.action().to_be_bytes());
239        assert_eq!(bytes, expected);
240    }
241
242    #[test]
243    fn test_invalid_bytes_length() {
244        let too_short = vec![0u8; 63];
245        let result = SessionNullifier::from_compressed_bytes(&too_short);
246        assert!(result.is_err());
247        assert!(result.unwrap_err().contains("Invalid length"));
248
249        let too_long = vec![0u8; 65];
250        let result = SessionNullifier::from_compressed_bytes(&too_long);
251        assert!(result.is_err());
252        assert!(result.unwrap_err().contains("Invalid length"));
253    }
254
255    #[test]
256    fn test_default() {
257        let session = SessionNullifier::default();
258        assert_eq!(session.nullifier(), FieldElement::ZERO);
259        assert_eq!(session.action(), FieldElement::ZERO);
260    }
261
262    #[test]
263    fn test_from_tuple() {
264        let nullifier = test_field_element(100);
265        let action = test_field_element(200);
266        let session: SessionNullifier = (nullifier, action).into();
267
268        assert_eq!(session.nullifier(), nullifier);
269        assert_eq!(session.action(), action);
270    }
271
272    #[test]
273    fn test_into_u256_array() {
274        let session = SessionNullifier::new(test_field_element(100), test_field_element(200));
275        let arr: [U256; 2] = session.into();
276
277        assert_eq!(arr[0], U256::from(100));
278        assert_eq!(arr[1], U256::from(200));
279    }
280}