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