Skip to main content

world_id_primitives/
session.rs

1use crate::{FieldElement, PrimitiveError};
2use embed_doc_image::embed_doc_image;
3use ruint::aliases::U256;
4use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
5
6#[expect(unused_imports, reason = "used in doc comments")]
7use crate::circuit_inputs::QueryProofCircuitInput;
8
9/// Type of field elements for Session Proofs
10#[repr(u8)]
11pub enum SessionFeType {
12    /// The [`SessionId::oprf_seed`]
13    OprfSeed = 0x01,
14    /// The action used to compute the inner nullifier (in a [`SessionNullifier`]) for a Session Proof.
15    Action = 0x02,
16}
17
18/// Allows field element generation for Session Proofs
19pub trait SessionFieldElement {
20    /// Generate a randomized field element with a specific prefix used
21    /// only for Session Proofs. See [`SessionFeType`] for details.
22    fn random_for_session<R: rand::CryptoRng + rand::RngCore>(
23        rng: &mut R,
24        element_type: SessionFeType,
25    ) -> FieldElement;
26    /// Returns whether a Field Element is valid for Session Proof use, i.e. it has
27    /// the right prefix
28    fn is_valid_for_session(&self, element_type: SessionFeType) -> bool;
29}
30
31impl SessionFieldElement for FieldElement {
32    fn random_for_session<R: rand::CryptoRng + rand::RngCore>(
33        rng: &mut R,
34        element_type: SessionFeType,
35    ) -> FieldElement {
36        let mut bytes = [0u8; 32];
37        rng.fill_bytes(&mut bytes);
38        bytes[0] = element_type as u8;
39        let seed = U256::from_be_bytes(bytes);
40        Self::try_from(seed).expect(
41            "should always fit in the field because with 0x01 as the MSB, the field element < babyjubjub modulus",
42        )
43    }
44
45    fn is_valid_for_session(&self, element_type: SessionFeType) -> bool {
46        self.to_be_bytes()[0] == element_type as u8
47    }
48}
49
50/// An identifier for a session (can be re-used).
51///
52/// A session allows RPs to ensure that it's still the same World ID
53/// interacting with them across multiple interactions.
54///
55/// A `SessionId` is obtained after creating an initial session.
56///
57/// See the diagram below on how Session Proofs work, the [`SessionId`] and the `r` seed
58/// ![Session Proofs Diagram][session-proofs.png]
59///
60/// Note that the `action` stored here is unrelated to the randomized action used
61/// internally by [`SessionNullifier`]s — that randomized action exists only to ensure
62/// the circuit's nullifier output is unique per Session Proof.
63#[embed_doc_image("session-proofs.png", "assets/session-proofs.png")]
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub struct SessionId {
66    /// The actual commitment being verified in the ZK-circuit.
67    ///
68    /// It is computed as H(DS_C || leaf_index || session_id_r_seed), see
69    /// `signal computed_id_commitment` in `oprf_nullifier.circom`.
70    pub commitment: FieldElement,
71    /// A random seed generated by the authenticator in the initial Uniqueness Proof.
72    ///
73    /// This seed is the input to the OPRF Query to derive `session_id_r_seed` (`r`). It
74    /// is part of the `session_id` so the RP can provide it when requesting a Session Proof.
75    ///
76    /// # Important: Prefix
77    /// To ensure there are no collisions between the generated `r`s and the nullifiers
78    /// for Uniqueness Proofs (as they use the same OPRF Key and query structure), the
79    /// `oprf_seed`s, which are plugged as `action` in the Query Proof (see [`QueryProofCircuitInput`]),
80    /// MUST be prefixed with an explicit byte of `0x01`. All other actions have a `0x00` byte prefix. This
81    /// collision avoidance is important because it ensures that any requests for nullifiers meant
82    /// for Uniqueness Proofs are always signed by the RP (otherwise, an RP signature for a Session Proof
83    /// could be used for requesting computation of _any_ nullifier).
84    ///
85    /// # Re-derivation
86    ///
87    /// The Authenticator can deterministically re-derive `r` from the OPRF nodes without
88    /// needing to cache `r` locally as:
89    /// ```text
90    /// r = OPRF(pk_rpId, DS_C || leafIndex || oprf_seed)
91    /// ```
92    pub oprf_seed: FieldElement,
93}
94
95impl SessionId {
96    const JSON_PREFIX: &str = "session_";
97    /// Domain separator for session id.
98    ///
99    /// TODO: Change DS to not use the same DS as the base Query Proof
100    const DS_C: &[u8] = b"H(id, r)";
101
102    /// Creates a new session id. Most uses should default to `from_r_seed` instead.
103    ///
104    /// # Errors
105    /// If the provided `oprf_seed` is not prefixed properly.
106    pub fn new(commitment: FieldElement, oprf_seed: FieldElement) -> Result<Self, PrimitiveError> {
107        // OPRF Seeds must always start with a byte of `0x01`. See [`Self::oprf_seed`]
108        // for details. Panic is acceptable as `oprf_seed` generation should
109        // generally be done with `Self::from_r_seed`
110        if !oprf_seed.is_valid_for_session(SessionFeType::OprfSeed) {
111            return Err(PrimitiveError::InvalidInput {
112                attribute: "session_id".to_string(),
113                reason: "inner oprf_seed is not valid".to_string(),
114            });
115        }
116        Ok(Self {
117            commitment,
118            oprf_seed,
119        })
120    }
121
122    /// Initializes a `SessionId` from the OPRF-output seed (`r`), and the `oprf_seed`
123    /// used as input for the OPRF computation.
124    ///
125    /// This matches the logic in `oprf_nullifier.circom` for computing the `commitment` from the OPRF seed.
126    ///
127    /// # Seed (`session_id_r_seed`)
128    /// - The seed MUST be computationally indistinguishable from random,
129    ///   i.e. uniformly distributed because it uses OPRF.
130    /// - When computed, the OPRF nodes will use the same `oprfKeyId` for the RP, with a different domain separator.
131    /// - Requesting this seed requires a properly signed request from the RP and a complete query proof.
132    /// - The seed generation is based on a randomly generated seed used as an "action" in a Query Proof. Note
133    ///   this `action` is different than the randomized action used internally by [`SessionNullifier`]s.
134    pub fn from_r_seed(
135        leaf_index: u64,
136        session_id_r_seed: FieldElement,
137        oprf_seed: FieldElement,
138    ) -> Result<Self, PrimitiveError> {
139        let sub_ds = FieldElement::from_be_bytes_mod_order(Self::DS_C);
140
141        if !oprf_seed.is_valid_for_session(SessionFeType::OprfSeed) {
142            return Err(PrimitiveError::InvalidInput {
143                attribute: "session_id".to_string(),
144                reason: "inner oprf_seed is not valid".to_string(),
145            });
146        }
147
148        let mut input = [*sub_ds, leaf_index.into(), *session_id_r_seed];
149        poseidon2::bn254::t3::permutation_in_place(&mut input);
150        let commitment = input[1].into();
151        Ok(Self {
152            commitment,
153            oprf_seed,
154        })
155    }
156
157    /// Generates a new [`Self::oprf_seed`] to initialize a new Session.
158    pub fn generate_oprf_seed<R: rand::CryptoRng + rand::RngCore>(rng: &mut R) -> FieldElement {
159        FieldElement::random_for_session(rng, SessionFeType::OprfSeed)
160    }
161
162    /// Returns the 64-byte big-endian representation (2 x 32-byte field elements).
163    #[must_use]
164    pub fn to_compressed_bytes(&self) -> [u8; 64] {
165        let mut bytes = [0u8; 64];
166        bytes[..32].copy_from_slice(&self.commitment.to_be_bytes());
167        bytes[32..].copy_from_slice(&self.oprf_seed.to_be_bytes());
168        bytes
169    }
170
171    /// Constructs from compressed bytes (must be exactly 64 bytes).
172    ///
173    /// # Errors
174    /// Returns an error if the input is not exactly 64 bytes or if values are not valid field elements.
175    pub fn from_compressed_bytes(bytes: &[u8]) -> Result<Self, String> {
176        if bytes.len() != 64 {
177            return Err(format!(
178                "Invalid length: expected 64 bytes, got {}",
179                bytes.len()
180            ));
181        }
182
183        let commitment = FieldElement::from_be_bytes(bytes[..32].try_into().unwrap())
184            .map_err(|e| format!("invalid commitment: {e}"))?;
185        let oprf_seed = FieldElement::from_be_bytes(bytes[32..].try_into().unwrap())
186            .map_err(|e| format!("invalid oprf_seed: {e}"))?;
187
188        if bytes[32] != SessionFeType::OprfSeed as u8 {
189            return Err("invalid prefix for oprf_seed".to_string());
190        }
191
192        Ok(Self {
193            commitment,
194            oprf_seed,
195        })
196    }
197}
198
199impl Default for SessionId {
200    fn default() -> Self {
201        let mut oprf_seed = [0u8; 32];
202        oprf_seed[0] = SessionFeType::OprfSeed as u8;
203        let oprf_seed = U256::from_be_bytes(oprf_seed)
204            .try_into()
205            .expect("always fits in the field");
206        Self {
207            commitment: FieldElement::ZERO,
208            oprf_seed,
209        }
210    }
211}
212
213impl Serialize for SessionId {
214    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
215    where
216        S: Serializer,
217    {
218        let bytes = self.to_compressed_bytes();
219        if serializer.is_human_readable() {
220            // JSON: prefixed hex-encoded compressed bytes for explicit typing.
221            serializer.serialize_str(&format!("{}{}", Self::JSON_PREFIX, hex::encode(bytes)))
222        } else {
223            // Binary: compressed bytes
224            serializer.serialize_bytes(&bytes)
225        }
226    }
227}
228
229impl<'de> Deserialize<'de> for SessionId {
230    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
231    where
232        D: Deserializer<'de>,
233    {
234        let bytes = if deserializer.is_human_readable() {
235            let value = String::deserialize(deserializer)?;
236            let hex_str = value.strip_prefix(Self::JSON_PREFIX).ok_or_else(|| {
237                D::Error::custom(format!(
238                    "session id must start with '{}'",
239                    Self::JSON_PREFIX
240                ))
241            })?;
242            hex::decode(hex_str).map_err(D::Error::custom)?
243        } else {
244            Vec::deserialize(deserializer)?
245        };
246
247        Self::from_compressed_bytes(&bytes).map_err(D::Error::custom)
248    }
249}
250
251/// A session nullifier for World ID Session proofs. It is analogous to a request nonce,
252/// it **does NOT guarantee uniqueness of a World ID** as a `Nullifier` does.
253///
254/// This type is intended to be opaque for RPs. For an RP context, they should only
255/// be concerned of this needing to be passthrough to the `verifySession()` contract function.
256///
257/// This type exists as an adaptation to be able to use the same ZK-circuit for
258/// both Uniqueness Proofs and Session Proofs, and it encompasses:
259/// - the nullifier used as the proof output.
260/// - a random action bound to the same proof.
261///
262/// The `WorldIDVerifier.sol` contract expects this as a `uint256[2]` array
263/// use `as_ethereum_representation()` for conversion.
264///
265/// # Future
266///
267/// Note the session nullifier exists **only** to support the same ZK-circuit than for Uniqueness Proofs; as
268/// World ID evolves to a different proving system which won't require circuit precompiles, a new circuit MUST
269/// be created which does not generate a nullifier at all, and the input randomized action will not be required either.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
271pub struct SessionNullifier {
272    /// The nullifier value for this proof.
273    nullifier: FieldElement,
274    /// The random action value bound to this session proof.
275    action: FieldElement,
276}
277
278impl SessionNullifier {
279    const JSON_PREFIX: &str = "snil_";
280
281    /// Creates a new session nullifier.
282    pub fn new(nullifier: FieldElement, action: FieldElement) -> Result<Self, PrimitiveError> {
283        if !action.is_valid_for_session(SessionFeType::Action) {
284            return Err(PrimitiveError::InvalidInput {
285                attribute: "session_nullifier".to_string(),
286                reason: "inner action is not valid".to_string(),
287            });
288        }
289        Ok(Self { nullifier, action })
290    }
291
292    /// Returns the nullifier value.
293    #[must_use]
294    pub const fn nullifier(&self) -> FieldElement {
295        self.nullifier
296    }
297
298    /// Returns the action value.
299    #[must_use]
300    pub const fn action(&self) -> FieldElement {
301        self.action
302    }
303
304    /// Returns the session nullifier as an Ethereum-compatible array for `verifySession()`.
305    ///
306    /// Format: `[nullifier, action]` matching the contract's `uint256[2] sessionNullifier`.
307    #[must_use]
308    pub fn as_ethereum_representation(&self) -> [U256; 2] {
309        [self.nullifier.into(), self.action.into()]
310    }
311
312    /// Creates a session nullifier from an Ethereum representation.
313    ///
314    /// # Errors
315    /// Returns an error if the U256 values are not valid field elements.
316    pub fn from_ethereum_representation(value: [U256; 2]) -> Result<Self, String> {
317        let nullifier =
318            FieldElement::try_from(value[0]).map_err(|e| format!("invalid nullifier: {e}"))?;
319        let action =
320            FieldElement::try_from(value[1]).map_err(|e| format!("invalid action: {e}"))?;
321
322        if !action.is_valid_for_session(SessionFeType::Action) {
323            return Err("inner action is not valid".to_string());
324        }
325        Ok(Self { nullifier, action })
326    }
327
328    /// Returns the 64-byte big-endian representation (2 x 32-byte field elements).
329    #[must_use]
330    pub fn to_compressed_bytes(&self) -> [u8; 64] {
331        let mut bytes = [0u8; 64];
332        bytes[..32].copy_from_slice(&self.nullifier.to_be_bytes());
333        bytes[32..].copy_from_slice(&self.action.to_be_bytes());
334        bytes
335    }
336
337    /// Constructs from compressed bytes (must be exactly 64 bytes).
338    ///
339    /// # Errors
340    /// Returns an error if the input is not exactly 64 bytes or if values are not valid field elements.
341    pub fn from_compressed_bytes(bytes: &[u8]) -> Result<Self, String> {
342        if bytes.len() != 64 {
343            return Err(format!(
344                "Invalid length: expected 64 bytes, got {}",
345                bytes.len()
346            ));
347        }
348
349        let nullifier = FieldElement::from_be_bytes(bytes[..32].try_into().unwrap())
350            .map_err(|e| format!("invalid nullifier: {e}"))?;
351        let action = FieldElement::from_be_bytes(bytes[32..].try_into().unwrap())
352            .map_err(|e| format!("invalid action: {e}"))?;
353
354        if bytes[32] != SessionFeType::Action as u8 {
355            return Err("invalid action. missing expected prefix.".to_string());
356        }
357
358        Ok(Self { nullifier, action })
359    }
360}
361
362impl Default for SessionNullifier {
363    fn default() -> Self {
364        let mut action = [0u8; 32];
365        action[0] = SessionFeType::Action as u8;
366        let action = U256::from_be_bytes(action)
367            .try_into()
368            .expect("always fits in the field");
369        Self {
370            nullifier: FieldElement::ZERO,
371            action,
372        }
373    }
374}
375
376impl Serialize for SessionNullifier {
377    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
378    where
379        S: Serializer,
380    {
381        let bytes = self.to_compressed_bytes();
382        if serializer.is_human_readable() {
383            // JSON: prefixed hex-encoded compressed bytes for explicit typing.
384            serializer.serialize_str(&format!("{}{}", Self::JSON_PREFIX, hex::encode(bytes)))
385        } else {
386            // Binary: compressed bytes
387            serializer.serialize_bytes(&bytes)
388        }
389    }
390}
391
392impl<'de> Deserialize<'de> for SessionNullifier {
393    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
394    where
395        D: Deserializer<'de>,
396    {
397        let bytes = if deserializer.is_human_readable() {
398            let value = String::deserialize(deserializer)?;
399            let hex_str = value.strip_prefix(Self::JSON_PREFIX).ok_or_else(|| {
400                D::Error::custom(format!(
401                    "session nullifier must start with '{}'",
402                    Self::JSON_PREFIX
403                ))
404            })?;
405            hex::decode(hex_str).map_err(D::Error::custom)?
406        } else {
407            Vec::deserialize(deserializer)?
408        };
409
410        Self::from_compressed_bytes(&bytes).map_err(D::Error::custom)
411    }
412}
413
414impl From<SessionNullifier> for [U256; 2] {
415    fn from(value: SessionNullifier) -> Self {
416        value.as_ethereum_representation()
417    }
418}
419
420#[cfg(test)]
421mod session_id_tests {
422    use super::*;
423    use ruint::uint;
424
425    fn test_field_element(value: u64) -> FieldElement {
426        FieldElement::from(value)
427    }
428
429    /// Creates an oprf_seed with the right prefix
430    fn test_oprf_seed(value: u64) -> FieldElement {
431        // set the first byte to 0x01; no need to clear the first bits as the input is u64
432        let n = U256::from(value)
433            | uint!(0x0100000000000000000000000000000000000000000000000000000000000000_U256);
434        FieldElement::try_from(n).expect("test value fits in field")
435    }
436
437    #[test]
438    fn test_new_and_accessors() {
439        let commitment = test_field_element(1001);
440        let seed = test_oprf_seed(42);
441        let id = SessionId::new(commitment, seed).unwrap();
442
443        assert_eq!(id.commitment, commitment);
444        assert_eq!(id.oprf_seed, seed);
445    }
446
447    #[test]
448    fn test_default() {
449        let id = SessionId::default();
450        assert_eq!(id.commitment, FieldElement::ZERO);
451        assert_eq!(
452            id.oprf_seed,
453            uint!(0x0100000000000000000000000000000000000000000000000000000000000000_U256)
454                .try_into()
455                .unwrap()
456        );
457    }
458
459    #[test]
460    fn test_bytes_roundtrip() {
461        let id = SessionId::new(test_field_element(1001), test_oprf_seed(42)).unwrap();
462        let bytes = id.to_compressed_bytes();
463
464        assert_eq!(bytes.len(), 64);
465
466        let decoded = SessionId::from_compressed_bytes(&bytes).unwrap();
467        assert_eq!(id, decoded);
468    }
469
470    #[test]
471    fn test_bytes_use_field_element_encoding() {
472        let id = SessionId::new(test_field_element(1001), test_oprf_seed(42)).unwrap();
473        let bytes = id.to_compressed_bytes();
474
475        let mut expected = [0u8; 64];
476        expected[..32].copy_from_slice(&id.commitment.to_be_bytes());
477        expected[32..].copy_from_slice(&id.oprf_seed.to_be_bytes());
478        assert_eq!(bytes, expected);
479    }
480
481    #[test]
482    fn test_invalid_bytes_length() {
483        let too_short = vec![0u8; 63];
484        let result = SessionId::from_compressed_bytes(&too_short);
485        assert!(result.is_err());
486        assert!(result.unwrap_err().contains("Invalid length"));
487
488        let too_long = vec![0u8; 65];
489        let result = SessionId::from_compressed_bytes(&too_long);
490        assert!(result.is_err());
491        assert!(result.unwrap_err().contains("Invalid length"));
492    }
493
494    #[test]
495    fn test_from_compressed_bytes_rejects_wrong_oprf_seed_prefix() {
496        let mut bytes = [0u8; 64];
497        // Valid commitment (zero is a valid field element)
498        // oprf_seed with wrong prefix: 0x00 instead of 0x01
499        bytes[32] = 0x00;
500        let result = SessionId::from_compressed_bytes(&bytes);
501        assert!(result.is_err());
502        assert!(
503            result.unwrap_err().contains("invalid prefix"),
504            "should reject oprf_seed without 0x01 prefix"
505        );
506    }
507
508    #[test]
509    fn test_json_roundtrip() {
510        let id = SessionId::new(test_field_element(1001), test_oprf_seed(42)).unwrap();
511        let json = serde_json::to_string(&id).unwrap();
512
513        assert!(json.starts_with("\"session_"));
514        assert!(json.ends_with('"'));
515
516        let decoded: SessionId = serde_json::from_str(&json).unwrap();
517        assert_eq!(id, decoded);
518    }
519
520    #[test]
521    fn test_json_format() {
522        let id = SessionId::new(test_field_element(1), test_oprf_seed(2)).unwrap();
523        let json = serde_json::to_string(&id).unwrap();
524
525        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
526        assert!(parsed.is_string());
527        let value = parsed.as_str().unwrap();
528        assert!(value.starts_with("session_"));
529    }
530
531    #[test]
532    fn test_json_wrong_prefix_rejected() {
533        let result = serde_json::from_str::<SessionId>("\"snil_00\"");
534        assert!(result.is_err());
535    }
536
537    #[test]
538    fn test_generates_random_oprf_seed() {
539        let mut rng = rand::rngs::OsRng;
540
541        let seed_1 = SessionId::generate_oprf_seed(&mut rng);
542        let seed_2 = SessionId::generate_oprf_seed(&mut rng);
543
544        assert_ne!(seed_1, seed_2);
545    }
546
547    #[test]
548    fn test_from_r_seed_generated_seed_has_session_prefix() {
549        let mut rng = rand::rngs::OsRng;
550
551        for _ in 0..50 {
552            let seed = SessionId::generate_oprf_seed(&mut rng);
553            // Top byte must be exactly 0x01: bit 248 set, bits 249-255 clear
554            assert_eq!(seed.to_u256() >> 248, U256::from(1));
555        }
556    }
557
558    #[test]
559    fn test_from_r_seed_commitment_snapshot() {
560        let leaf_index = 42u64;
561        let r_seed = test_field_element(123);
562        let oprf_seed = test_oprf_seed(456);
563
564        let session_id = SessionId::from_r_seed(leaf_index, r_seed, oprf_seed).unwrap();
565
566        let expected = "0x1e7853ebd4fc9d9f0232fdcfae116023610bdf66a22e2700445d7a2e0e7e6152"
567            .parse::<U256>()
568            .unwrap();
569        assert_eq!(
570            session_id.commitment.to_u256(),
571            expected,
572            "commitment snapashot for session commitment changed"
573        );
574    }
575}
576
577#[cfg(test)]
578mod session_nullifier_tests {
579    use super::*;
580    use ruint::uint;
581
582    fn test_field_element(value: u64) -> FieldElement {
583        FieldElement::from(value)
584    }
585
586    /// Creates an action with the required `0x02` prefix
587    fn test_action(value: u64) -> FieldElement {
588        let n = U256::from(value)
589            | uint!(0x0200000000000000000000000000000000000000000000000000000000000000_U256);
590        FieldElement::try_from(n).expect("test value fits in field")
591    }
592
593    #[test]
594    fn test_new_and_accessors() {
595        let nullifier = test_field_element(1001);
596        let action = test_action(42);
597        let session = SessionNullifier::new(nullifier, action).unwrap();
598
599        assert_eq!(session.nullifier(), nullifier);
600        assert_eq!(session.action(), action);
601    }
602
603    #[test]
604    fn test_as_ethereum_representation() {
605        let nullifier = test_field_element(100);
606        let action = test_action(200);
607        let session = SessionNullifier::new(nullifier, action).unwrap();
608
609        let repr = session.as_ethereum_representation();
610        assert_eq!(repr[0], U256::from(100));
611        assert_eq!(repr[1], action.to_u256());
612    }
613
614    #[test]
615    fn test_from_ethereum_representation() {
616        let action = test_action(200);
617        let repr = [U256::from(100), action.to_u256()];
618        let session = SessionNullifier::from_ethereum_representation(repr).unwrap();
619
620        assert_eq!(session.nullifier(), test_field_element(100));
621        assert_eq!(session.action(), action);
622    }
623
624    #[test]
625    fn test_json_roundtrip() {
626        let session = SessionNullifier::new(test_field_element(1001), test_action(42)).unwrap();
627        let json = serde_json::to_string(&session).unwrap();
628
629        // Verify JSON uses the prefixed compact representation
630        assert!(json.starts_with("\"snil_"));
631        assert!(json.ends_with('"'));
632
633        // Verify roundtrip
634        let decoded: SessionNullifier = serde_json::from_str(&json).unwrap();
635        assert_eq!(session, decoded);
636    }
637
638    #[test]
639    fn test_json_format() {
640        let session = SessionNullifier::new(test_field_element(1), test_action(2)).unwrap();
641        let json = serde_json::to_string(&session).unwrap();
642
643        // Should be a prefixed compact string
644        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
645        assert!(parsed.is_string());
646        let value = parsed.as_str().unwrap();
647        assert!(value.starts_with("snil_"));
648    }
649
650    #[test]
651    fn test_bytes_roundtrip() {
652        let session = SessionNullifier::new(test_field_element(1001), test_action(42)).unwrap();
653        let bytes = session.to_compressed_bytes();
654
655        assert_eq!(bytes.len(), 64); // 32 + 32 bytes
656
657        let decoded = SessionNullifier::from_compressed_bytes(&bytes).unwrap();
658        assert_eq!(session, decoded);
659    }
660
661    #[test]
662    fn test_bytes_use_field_element_encoding() {
663        let session = SessionNullifier::new(test_field_element(1001), test_action(42)).unwrap();
664        let bytes = session.to_compressed_bytes();
665
666        let mut expected = [0u8; 64];
667        expected[..32].copy_from_slice(&session.nullifier().to_be_bytes());
668        expected[32..].copy_from_slice(&session.action().to_be_bytes());
669        assert_eq!(bytes, expected);
670    }
671
672    #[test]
673    fn test_invalid_bytes_length() {
674        let too_short = vec![0u8; 63];
675        let result = SessionNullifier::from_compressed_bytes(&too_short);
676        assert!(result.is_err());
677        assert!(result.unwrap_err().contains("Invalid length"));
678
679        let too_long = vec![0u8; 65];
680        let result = SessionNullifier::from_compressed_bytes(&too_long);
681        assert!(result.is_err());
682        assert!(result.unwrap_err().contains("Invalid length"));
683    }
684
685    #[test]
686    fn test_default() {
687        let session = SessionNullifier::default();
688        assert_eq!(session.nullifier(), FieldElement::ZERO);
689        let expected_action: FieldElement =
690            uint!(0x0200000000000000000000000000000000000000000000000000000000000000_U256)
691                .try_into()
692                .unwrap();
693        assert_eq!(session.action(), expected_action);
694    }
695
696    #[test]
697    fn test_into_u256_array() {
698        let action = test_action(200);
699        let session = SessionNullifier::new(test_field_element(100), action).unwrap();
700        let arr: [U256; 2] = session.into();
701
702        assert_eq!(arr[0], U256::from(100));
703        assert_eq!(arr[1], action.to_u256());
704    }
705
706    #[test]
707    fn test_new_rejects_invalid_action_prefix() {
708        let nullifier = test_field_element(1);
709        let bad_action = test_field_element(42); // no 0x02 prefix
710        let result = SessionNullifier::new(nullifier, bad_action);
711        assert!(result.is_err());
712
713        let err = result.unwrap_err();
714        assert!(
715            matches!(err, PrimitiveError::InvalidInput { .. }),
716            "expected InvalidInput, got {err:?}"
717        );
718    }
719
720    #[test]
721    fn test_new_rejects_oprf_seed_prefix_as_action() {
722        let nullifier = test_field_element(1);
723        // 0x01 prefix (OprfSeed) is not valid for Action
724        let oprf_prefixed = U256::from(42u64)
725            | uint!(0x0100000000000000000000000000000000000000000000000000000000000000_U256);
726        let bad_action = FieldElement::try_from(oprf_prefixed).unwrap();
727        assert!(SessionNullifier::new(nullifier, bad_action).is_err());
728    }
729
730    #[test]
731    fn test_from_ethereum_representation_rejects_invalid_action() {
732        let repr = [U256::from(100), U256::from(200)]; // action has 0x00 prefix
733        let result = SessionNullifier::from_ethereum_representation(repr);
734        assert!(result.is_err());
735        assert!(
736            result.unwrap_err().contains("action"),
737            "error should mention the action"
738        );
739    }
740
741    #[test]
742    fn test_from_compressed_bytes_rejects_invalid_action_prefix() {
743        let mut bytes = [0u8; 64];
744        // Valid nullifier (zero), but action with 0x00 prefix
745        bytes[32] = 0x00;
746        let result = SessionNullifier::from_compressed_bytes(&bytes);
747        assert!(result.is_err());
748        assert!(
749            result.unwrap_err().contains("action"),
750            "error should mention the action"
751        );
752    }
753
754    #[test]
755    fn test_json_rejects_invalid_action_prefix() {
756        // Build JSON with a valid nullifier but an action lacking the 0x02 prefix
757        let nullifier = test_field_element(1);
758        let bad_action = test_field_element(2); // 0x00 prefix
759        let mut bytes = [0u8; 64];
760        bytes[..32].copy_from_slice(&nullifier.to_be_bytes());
761        bytes[32..].copy_from_slice(&bad_action.to_be_bytes());
762        let json = format!("\"snil_{}\"", hex::encode(bytes));
763
764        let result = serde_json::from_str::<SessionNullifier>(&json);
765        assert!(result.is_err());
766    }
767}