Skip to main content

world_id_primitives/request/
mod.rs

1//! Module containing all the functionality to handle requests from Relying Parties (RPs) to Authenticators.
2//!
3//! Enables an RP to create a Proof request or a Session Proof request, and provides base functionality
4//! for Authenticators to handle such requests.
5mod constraints;
6pub use constraints::{ConstraintExpr, ConstraintKind, ConstraintNode, MAX_CONSTRAINT_NODES};
7
8use crate::{FieldElement, PrimitiveError, SessionNullifier, ZeroKnowledgeProof, rp::RpId};
9use serde::{Deserialize, Serialize, de::Error as _};
10use std::collections::HashSet;
11use taceo_oprf::types::OprfKeyId;
12// The uuid crate is needed for wasm compatibility
13use uuid as _;
14
15/// Protocol schema version for proof requests and responses.
16#[repr(u8)]
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum RequestVersion {
19    /// Version 1
20    V1 = 1,
21}
22
23impl serde::Serialize for RequestVersion {
24    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
25    where
26        S: serde::Serializer,
27    {
28        let v = *self as u8;
29        serializer.serialize_u8(v)
30    }
31}
32
33impl<'de> serde::Deserialize<'de> for RequestVersion {
34    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
35    where
36        D: serde::Deserializer<'de>,
37    {
38        let v = u8::deserialize(deserializer)?;
39        match v {
40            1 => Ok(Self::V1),
41            _ => Err(serde::de::Error::custom("unsupported version")),
42        }
43    }
44}
45
46/// A proof request from a Relying Party (RP) for an Authenticator.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct ProofRequest {
50    /// Unique identifier for this request.
51    pub id: String,
52    /// Version of the request.
53    pub version: RequestVersion,
54    /// Unix timestamp (seconds) when the request was created.
55    pub created_at: u64,
56    /// Unix timestamp (seconds) when the request expires.
57    pub expires_at: u64,
58    /// Registered RP identifier from the `RpRegistry`.
59    pub rp_id: RpId,
60    /// `OprfKeyId` of the RP.
61    pub oprf_key_id: OprfKeyId,
62    /// Session identifier that links proofs for the same user/RP pair across requests.
63    ///
64    /// If provided, a Session Proof will be generated instead of a Uniqueness Proof.
65    /// The proof will only be valid if the session ID is meant for this context and this
66    /// particular World ID holder.
67    pub session_id: Option<FieldElement>,
68    /// An RP-defined context that scopes what the user is proving uniqueness on.
69    ///
70    /// This parameter expects a field element. When dealing with strings or bytes,
71    /// hash with a byte-friendly hash function like keccak256 or SHA256 and reduce to the field.
72    pub action: Option<FieldElement>,
73    /// The RP's ECDSA signature over the request.
74    pub signature: alloy::signers::Signature,
75    /// Unique nonce for this request provided by the RP.
76    pub nonce: FieldElement,
77    /// Specific credential requests. This defines which credentials to ask for.
78    #[serde(rename = "proof_requests")]
79    pub requests: Vec<RequestItem>,
80    /// Constraint expression (all/any/enumerate) optional.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub constraints: Option<ConstraintExpr<'static>>,
83}
84
85/// Per-credential request payload.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct RequestItem {
89    /// An RP-defined identifier for this request item used to match against constraints and responses.
90    ///
91    /// Example: `orb`, `document`.
92    pub identifier: String,
93
94    /// Unique identifier for the credential schema and issuer pair.
95    ///
96    /// Registered in the `CredentialSchemaIssuerRegistry`.
97    pub issuer_schema_id: u64,
98
99    /// Arbitrary data provided by the RP that gets cryptographically bound into the proof.
100    ///
101    /// When present, the Authenticator hashes this via `signal_hash` and commits it into the
102    /// proof circuit so the RP can tie the proof to a particular context.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub signal: Option<String>,
105
106    /// Minimum `genesis_issued_at` timestamp that the used Credential must meet.
107    ///
108    /// If present, the proof will include a constraint that the credential's genesis issued at timestamp
109    /// is greater than or equal to this value. Can be set to 0 to skip.
110    /// This is useful for migration from previous protocol versions.
111    pub genesis_issued_at_min: Option<u64>,
112
113    /// The minimum expiration required for the Credential used in the proof.
114    ///
115    /// If the constraint is not required, it should use the current time as the minimum expiration.
116    /// The Authenticator will normally expose the effective input used in the proof.
117    ///
118    /// This is particularly useful to specify a minimum duration for a Credential proportional to the action
119    /// being performed. For example, when claiming a benefit that is once every 6 months, the minimum duration
120    /// can be set to 180 days to prevent double claiming in that period in case the Credential is set to expire earlier.
121    ///
122    /// It is an RP's responsibility to understand the issuer's policies regarding expiration to ensure the request
123    /// can be fulfilled.
124    ///
125    /// If not provided, this will default to the [`ProofRequest::created_at`] attribute.
126    pub expires_at_min: Option<u64>,
127}
128
129impl RequestItem {
130    /// Create a new request item with the given identifier, issuer schema ID and optional signal.
131    #[must_use]
132    pub const fn new(
133        identifier: String,
134        issuer_schema_id: u64,
135        signal: Option<String>,
136        genesis_issued_at_min: Option<u64>,
137        expires_at_min: Option<u64>,
138    ) -> Self {
139        Self {
140            identifier,
141            issuer_schema_id,
142            signal,
143            genesis_issued_at_min,
144            expires_at_min,
145        }
146    }
147
148    /// Get the signal hash for the request item.
149    #[must_use]
150    pub fn signal_hash(&self) -> FieldElement {
151        if let Some(signal) = &self.signal {
152            FieldElement::from_arbitrary_raw_bytes(signal.as_bytes())
153        } else {
154            FieldElement::ZERO
155        }
156    }
157
158    /// Get the effective minimum expiration timestamp for this request item.
159    ///
160    /// If `expires_at_min` is `Some`, returns that value.
161    /// Otherwise, returns the `request_created_at` value (which should be the `ProofRequest::created_at` timestamp).
162    #[must_use]
163    pub const fn effective_expires_at_min(&self, request_created_at: u64) -> u64 {
164        match self.expires_at_min {
165            Some(value) => value,
166            None => request_created_at,
167        }
168    }
169}
170
171/// Overall response from the Authenticator to the RP
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct ProofResponse {
175    /// The response id references request id
176    pub id: String,
177    /// Version corresponding to request version
178    pub version: RequestVersion,
179    /// RP session identifier that links multiple proofs for the same
180    /// user/RP pair across requests.
181    ///
182    /// When session proofs are enabled, this is the hex-encoded field element
183    /// emitted by the session circuit; otherwise it is omitted.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub session_id: Option<FieldElement>,
186    /// Error message if the entire proof request failed.
187    /// When present, the responses array will be empty.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub error: Option<String>,
190    /// Per-credential results (empty if error is present)
191    pub responses: Vec<ResponseItem>,
192}
193
194/// Per-credential response item returned by the Authenticator.
195///
196/// Each entry corresponds to one requested credential with its proof material.
197/// If any credential cannot be satisfied, the entire proof response will have
198/// an error at the `ProofResponse` level with an empty `responses` array.
199///
200/// # Nullifier Types
201///
202/// - **Uniqueness proofs**: Use `nullifier` field (a single `FieldElement`).
203///   The contract's `verify()` function takes this as a separate `uint256 nullifier` param.
204///
205/// - **Session proofs**: Use `session_nullifier` field (contains both nullifier and action).
206///   The contract's `verifySession()` function expects `uint256[2] sessionNullifier`.
207///
208/// Exactly one of `nullifier` or `session_nullifier` should be present.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct ResponseItem {
212    /// An RP-defined identifier for this request item used to match against constraints and responses.
213    ///
214    /// Example: `orb`, `document`.
215    pub identifier: String,
216
217    /// Unique identifier for the credential schema and issuer pair.
218    pub issuer_schema_id: u64,
219
220    /// Encoded World ID Proof. See [`ZeroKnowledgeProof`] for more details.
221    pub proof: ZeroKnowledgeProof,
222
223    /// Nullifier for Uniqueness proofs.
224    ///
225    /// A unique, one-time identifier derived from (user, rpId, action) that lets RPs detect
226    /// duplicate actions without learning who the user is. Used with the contract's `verify()` function.
227    ///
228    /// Present for Uniqueness proofs, absent for Session proofs.
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub nullifier: Option<FieldElement>,
231
232    /// Session nullifier for Session proofs.
233    ///
234    /// Contains both the nullifier and action values that are cryptographically bound together.
235    /// Used with the contract's `verifySession()` function which expects `uint256[2] sessionNullifier`.
236    ///
237    /// Present for Session proofs, absent for Uniqueness proofs.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub session_nullifier: Option<SessionNullifier>,
240
241    /// The minimum expiration required for the Credential used in the proof.
242    ///
243    /// This precise value must be used when verifying the proof on-chain.
244    pub expires_at_min: u64,
245}
246
247impl ProofResponse {
248    /// Determine if constraints are satisfied given a constraint expression.
249    /// Returns false if the response has an error.
250    #[must_use]
251    pub fn constraints_satisfied(&self, constraints: &ConstraintExpr<'_>) -> bool {
252        // If there's an error, constraints cannot be satisfied
253        if self.error.is_some() {
254            return false;
255        }
256
257        let provided: HashSet<&str> = self
258            .responses
259            .iter()
260            .map(|item| item.identifier.as_str())
261            .collect();
262
263        constraints.evaluate(&|t| provided.contains(t))
264    }
265}
266
267impl ResponseItem {
268    /// Create a new response item for a Uniqueness proof.
269    #[must_use]
270    pub const fn new_uniqueness(
271        identifier: String,
272        issuer_schema_id: u64,
273        proof: ZeroKnowledgeProof,
274        nullifier: FieldElement,
275        expires_at_min: u64,
276    ) -> Self {
277        Self {
278            identifier,
279            issuer_schema_id,
280            proof,
281            nullifier: Some(nullifier),
282            session_nullifier: None,
283            expires_at_min,
284        }
285    }
286
287    /// Create a new response item for a Session proof.
288    #[must_use]
289    pub const fn new_session(
290        identifier: String,
291        issuer_schema_id: u64,
292        proof: ZeroKnowledgeProof,
293        session_nullifier: SessionNullifier,
294        expires_at_min: u64,
295    ) -> Self {
296        Self {
297            identifier,
298            issuer_schema_id,
299            proof,
300            nullifier: None,
301            session_nullifier: Some(session_nullifier),
302            expires_at_min,
303        }
304    }
305
306    /// Returns true if this is a Session proof response.
307    #[must_use]
308    pub const fn is_session(&self) -> bool {
309        self.session_nullifier.is_some()
310    }
311
312    /// Returns true if this is a Uniqueness proof response.
313    #[must_use]
314    pub const fn is_uniqueness(&self) -> bool {
315        self.nullifier.is_some()
316    }
317}
318
319impl ProofRequest {
320    /// Determine which requested credentials to prove given available credentials.
321    ///
322    /// Returns `None` if constraints (or lack thereof) cannot be satisfied with the available set.
323    ///
324    /// # Panics
325    /// Panics if constraints are present but invalid according to the type invariants
326    /// (this should not occur as constraints are provided by trusted request issuer).
327    #[must_use]
328    pub fn credentials_to_prove(&self, available: &HashSet<u64>) -> Option<Vec<&RequestItem>> {
329        // Pre-compute which identifiers have an available issuer_schema_id
330        let available_identifiers: HashSet<&str> = self
331            .requests
332            .iter()
333            .filter(|r| available.contains(&r.issuer_schema_id))
334            .map(|r| r.identifier.as_str())
335            .collect();
336
337        let is_selectable = |identifier: &str| available_identifiers.contains(identifier);
338
339        // If no explicit constraints: require all requested be available
340        if self.constraints.is_none() {
341            return if self
342                .requests
343                .iter()
344                .all(|r| available.contains(&r.issuer_schema_id))
345            {
346                Some(self.requests.iter().collect())
347            } else {
348                None
349            };
350        }
351
352        // Compute selected identifiers using the constraint expression
353        let selected_identifiers = select_expr(self.constraints.as_ref().unwrap(), &is_selectable)?;
354        let selected_set: HashSet<&str> = selected_identifiers.into_iter().collect();
355
356        // Return proof_requests in original order filtered by selected identifiers
357        let result: Vec<&RequestItem> = self
358            .requests
359            .iter()
360            .filter(|r| selected_set.contains(r.identifier.as_str()))
361            .collect();
362        Some(result)
363    }
364
365    /// Find a request item by issuer schema ID if available
366    #[must_use]
367    pub fn find_request_by_issuer_schema_id(&self, issuer_schema_id: u64) -> Option<&RequestItem> {
368        self.requests
369            .iter()
370            .find(|r| r.issuer_schema_id == issuer_schema_id)
371    }
372
373    /// Returns true if the request is expired relative to now (unix timestamp in seconds)
374    #[must_use]
375    pub const fn is_expired(&self, now: u64) -> bool {
376        now > self.expires_at
377    }
378
379    /// Compute the digest hash of this request that should be signed by the RP, which right now
380    /// includes the `nonce` and the timestamp of the request.
381    ///
382    /// # Returns
383    /// A 32-byte hash that represents this request and should be signed by the RP.
384    ///
385    /// # Errors
386    /// Returns a `PrimitiveError` if `FieldElement` serialization fails (which should never occur in practice).
387    ///
388    /// The digest is computed as: `SHA256(nonce || action || created_at || expires_at)`.
389    /// This mirrors the RP signature message format from `rp::compute_rp_signature_msg`.
390    /// Note: the timestamp is encoded as big-endian to mirror the RP-side signing
391    /// performed in test fixtures and the OPRF stub.
392    pub fn digest_hash(&self) -> Result<[u8; 32], PrimitiveError> {
393        use crate::rp::compute_rp_signature_msg;
394        use k256::sha2::{Digest, Sha256};
395
396        let msg = compute_rp_signature_msg(*self.nonce, self.created_at, self.expires_at);
397        let mut hasher = Sha256::new();
398        hasher.update(&msg);
399        Ok(hasher.finalize().into())
400    }
401
402    /// Gets the action value to use in the proof.
403    ///
404    /// - When an explicit action is provided, it is returned directly.
405    /// - For session proofs (action is `None`), a random action is generated.
406    ///
407    /// Callers should cache the action during proof generation to ensure consistency across proof steps.
408    #[must_use]
409    pub fn computed_action<R: rand::CryptoRng + rand::RngCore>(&self, rng: &mut R) -> FieldElement {
410        match self.action {
411            Some(action) => action,
412            None => FieldElement::random(rng),
413        }
414    }
415
416    /// Validate that a response satisfies this request: id match and constraints semantics.
417    ///
418    /// # Errors
419    /// Returns a `ValidationError` if the response does not correspond to this request or
420    /// does not satisfy the declared constraints.
421    pub fn validate_response(&self, response: &ProofResponse) -> Result<(), ValidationError> {
422        // Validate id and version match
423        if self.id != response.id {
424            return Err(ValidationError::RequestIdMismatch);
425        }
426        if self.version != response.version {
427            return Err(ValidationError::VersionMismatch);
428        }
429
430        // If response has an error, it failed to satisfy constraints
431        if let Some(error) = &response.error {
432            return Err(ValidationError::ProofGenerationFailed(error.clone()));
433        }
434
435        // Session ID of the response doesn't match the request's session ID (if present)
436        if self.session_id.is_some() && self.session_id != response.session_id {
437            return Err(ValidationError::SessionIdMismatch);
438        }
439
440        // Validate response items correspond to request items and are unique.
441        let mut provided: HashSet<&str> = HashSet::new();
442        for response_item in &response.responses {
443            if !provided.insert(response_item.identifier.as_str()) {
444                return Err(ValidationError::DuplicateCredential(
445                    response_item.identifier.clone(),
446                ));
447            }
448
449            let request_item = self
450                .requests
451                .iter()
452                .find(|r| r.identifier == response_item.identifier)
453                .ok_or_else(|| {
454                    ValidationError::UnexpectedCredential(response_item.identifier.clone())
455                })?;
456
457            if self.session_id.is_some() {
458                // Session proof: must have session_nullifier
459                if response_item.session_nullifier.is_none() {
460                    return Err(ValidationError::MissingSessionNullifier(
461                        response_item.identifier.clone(),
462                    ));
463                }
464            } else {
465                // Uniqueness proof: must have nullifier
466                if response_item.nullifier.is_none() {
467                    return Err(ValidationError::MissingNullifier(
468                        response_item.identifier.clone(),
469                    ));
470                }
471            }
472
473            let expected_expires_at_min = request_item.effective_expires_at_min(self.created_at);
474            if response_item.expires_at_min != expected_expires_at_min {
475                return Err(ValidationError::ExpiresAtMinMismatch(
476                    response_item.identifier.clone(),
477                    expected_expires_at_min,
478                    response_item.expires_at_min,
479                ));
480            }
481        }
482
483        match &self.constraints {
484            // None => all requested credentials (via identifier) are required
485            None => {
486                for req in &self.requests {
487                    if !provided.contains(req.identifier.as_str()) {
488                        return Err(ValidationError::MissingCredential(req.identifier.clone()));
489                    }
490                }
491                Ok(())
492            }
493            Some(expr) => {
494                if !expr.validate_max_depth(2) {
495                    return Err(ValidationError::ConstraintTooDeep);
496                }
497                if !expr.validate_max_nodes(MAX_CONSTRAINT_NODES) {
498                    return Err(ValidationError::ConstraintTooLarge);
499                }
500                if expr.evaluate(&|t| provided.contains(t)) {
501                    Ok(())
502                } else {
503                    Err(ValidationError::ConstraintNotSatisfied)
504                }
505            }
506        }
507    }
508
509    /// Parse from JSON
510    ///
511    /// # Errors
512    /// Returns an error if the JSON is invalid or contains duplicate issuer schema ids.
513    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
514        let v: Self = serde_json::from_str(json)?;
515        // Enforce unique issuer schema ids within a single request
516        let mut seen: HashSet<String> = HashSet::new();
517        for r in &v.requests {
518            let t = r.issuer_schema_id.to_string();
519            if !seen.insert(t.clone()) {
520                return Err(serde_json::Error::custom(format!(
521                    "duplicate issuer schema id: {t}"
522                )));
523            }
524        }
525        Ok(v)
526    }
527
528    /// Serialize to JSON
529    ///
530    /// # Errors
531    /// Returns an error if serialization unexpectedly fails.
532    pub fn to_json(&self) -> Result<String, serde_json::Error> {
533        serde_json::to_string(self)
534    }
535
536    /// Serialize to pretty JSON
537    ///
538    /// # Errors
539    /// Returns an error if serialization unexpectedly fails.
540    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
541        serde_json::to_string_pretty(self)
542    }
543}
544
545impl ProofResponse {
546    /// Parse from JSON
547    ///
548    /// # Errors
549    /// Returns an error if the JSON does not match the expected response shape.
550    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
551        serde_json::from_str(json)
552    }
553
554    /// Serialize to pretty JSON
555    ///
556    /// # Errors
557    /// Returns an error if serialization fails.
558    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
559        serde_json::to_string_pretty(self)
560    }
561
562    /// Return the list of successful `issuer_schema_id`s in the response.
563    /// Returns an empty vec if the response has an error.
564    #[must_use]
565    pub fn successful_credentials(&self) -> Vec<u64> {
566        if self.error.is_some() {
567            return vec![];
568        }
569        self.responses.iter().map(|r| r.issuer_schema_id).collect()
570    }
571}
572
573/// Validation errors when checking a response against a request
574#[derive(Debug, thiserror::Error, PartialEq, Eq)]
575pub enum ValidationError {
576    /// The response `id` does not match the request `id`
577    #[error("Request ID mismatch")]
578    RequestIdMismatch,
579    /// The response `version` does not match the request `version`
580    #[error("Version mismatch")]
581    VersionMismatch,
582    /// The proof generation failed (response contains an error)
583    #[error("Proof generation failed: {0}")]
584    ProofGenerationFailed(String),
585    /// A required credential was not provided
586    #[error("Missing required credential: {0}")]
587    MissingCredential(String),
588    /// A credential was returned that was not requested.
589    #[error("Unexpected credential in response: {0}")]
590    UnexpectedCredential(String),
591    /// A credential identifier was returned more than once.
592    #[error("Duplicate credential in response: {0}")]
593    DuplicateCredential(String),
594    /// The provided credentials do not satisfy the request constraints
595    #[error("Constraints not satisfied")]
596    ConstraintNotSatisfied,
597    /// The constraints expression exceeds the supported nesting depth
598    #[error("Constraints nesting exceeds maximum allowed depth")]
599    ConstraintTooDeep,
600    /// The constraints expression exceeds the maximum allowed size/complexity
601    #[error("Constraints exceed maximum allowed size")]
602    ConstraintTooLarge,
603    /// The `expires_at_min` value in the response does not match the expected value from the request
604    #[error("Invalid expires_at_min for credential '{0}': expected {1}, got {2}")]
605    ExpiresAtMinMismatch(String, u64, u64),
606    /// Session ID doesn't match between request and response
607    #[error("Session ID doesn't match between request and response")]
608    SessionIdMismatch,
609    /// Session nullifier missing for credential in session proof
610    #[error("Session nullifier missing for credential: {0}")]
611    MissingSessionNullifier(String),
612    /// Nullifier missing for credential in uniqueness proof
613    #[error("Nullifier missing for credential: {0}")]
614    MissingNullifier(String),
615}
616
617// Helper selection functions for constraint evaluation
618fn select_node<'a, F>(node: &'a ConstraintNode<'a>, pred: &F) -> Option<Vec<&'a str>>
619where
620    F: Fn(&str) -> bool,
621{
622    match node {
623        ConstraintNode::Type(t) => pred(t.as_ref()).then(|| vec![t.as_ref()]),
624        ConstraintNode::Expr(e) => select_expr(e, pred),
625    }
626}
627
628fn select_expr<'a, F>(expr: &'a ConstraintExpr<'a>, pred: &F) -> Option<Vec<&'a str>>
629where
630    F: Fn(&str) -> bool,
631{
632    match expr {
633        ConstraintExpr::All { all } => {
634            let mut seen: std::collections::HashSet<&'a str> = std::collections::HashSet::new();
635            let mut out: Vec<&'a str> = Vec::new();
636            for n in all {
637                let sub = select_node(n, pred)?;
638                for s in sub {
639                    if seen.insert(s) {
640                        out.push(s);
641                    }
642                }
643            }
644            Some(out)
645        }
646        ConstraintExpr::Any { any } => any.iter().find_map(|n| select_node(n, pred)),
647        ConstraintExpr::Enumerate { enumerate } => {
648            // HashSet deduplicates identifiers, while Vec preserves first-seen output order.
649            let mut seen: std::collections::HashSet<&'a str> = std::collections::HashSet::new();
650            let mut selected: Vec<&'a str> = Vec::new();
651
652            // Enumerate semantics: collect every satisfiable child; skip unsatisfied children.
653            for child in enumerate {
654                let Some(child_selection) = select_node(child, pred) else {
655                    continue;
656                };
657
658                for identifier in child_selection {
659                    if seen.insert(identifier) {
660                        selected.push(identifier);
661                    }
662                }
663            }
664
665            if selected.is_empty() {
666                None
667            } else {
668                Some(selected)
669            }
670        }
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use crate::SessionNullifier;
678    use alloy::{
679        signers::{SignerSync, local::PrivateKeySigner},
680        uint,
681    };
682    use k256::ecdsa::SigningKey;
683
684    // Test helpers
685    fn test_signature() -> alloy::signers::Signature {
686        let signer =
687            PrivateKeySigner::from_signing_key(SigningKey::from_bytes(&[1u8; 32].into()).unwrap());
688        signer.sign_message_sync(b"test").expect("can sign")
689    }
690
691    fn test_nonce() -> FieldElement {
692        FieldElement::from(1u64)
693    }
694
695    fn test_field_element(n: u64) -> FieldElement {
696        FieldElement::from(n)
697    }
698
699    #[test]
700    fn constraints_all_any_nested() {
701        // Build a response that has test_req_1 and test_req_2 provided
702        let response = ProofResponse {
703            id: "req_123".into(),
704            version: RequestVersion::V1,
705            session_id: None,
706            error: None,
707            responses: vec![
708                ResponseItem::new_uniqueness(
709                    "test_req_1".into(),
710                    1,
711                    ZeroKnowledgeProof::default(),
712                    test_field_element(1001),
713                    1_735_689_600,
714                ),
715                ResponseItem::new_uniqueness(
716                    "test_req_2".into(),
717                    2,
718                    ZeroKnowledgeProof::default(),
719                    test_field_element(1002),
720                    1_735_689_600,
721                ),
722            ],
723        };
724
725        // all: [test_req_1, any: [test_req_2, test_req_4]]
726        let expr = ConstraintExpr::All {
727            all: vec![
728                ConstraintNode::Type("test_req_1".into()),
729                ConstraintNode::Expr(ConstraintExpr::Any {
730                    any: vec![
731                        ConstraintNode::Type("test_req_2".into()),
732                        ConstraintNode::Type("test_req_4".into()),
733                    ],
734                }),
735            ],
736        };
737
738        assert!(response.constraints_satisfied(&expr));
739
740        // all: [test_req_1, test_req_3] should fail because test_req_3 is not in response
741        let fail_expr = ConstraintExpr::All {
742            all: vec![
743                ConstraintNode::Type("test_req_1".into()),
744                ConstraintNode::Type("test_req_3".into()),
745            ],
746        };
747        assert!(!response.constraints_satisfied(&fail_expr));
748    }
749
750    #[test]
751    fn constraints_enumerate_partial_and_empty() {
752        // Build a response that has orb and passport provided
753        let response = ProofResponse {
754            id: "req_123".into(),
755            version: RequestVersion::V1,
756            session_id: None,
757            error: None,
758            responses: vec![
759                ResponseItem::new_uniqueness(
760                    "orb".into(),
761                    1,
762                    ZeroKnowledgeProof::default(),
763                    test_field_element(1001),
764                    1_735_689_600,
765                ),
766                ResponseItem::new_uniqueness(
767                    "passport".into(),
768                    2,
769                    ZeroKnowledgeProof::default(),
770                    test_field_element(1002),
771                    1_735_689_600,
772                ),
773            ],
774        };
775
776        // enumerate: [passport, national_id] should pass due to passport
777        let expr = ConstraintExpr::Enumerate {
778            enumerate: vec![
779                ConstraintNode::Type("passport".into()),
780                ConstraintNode::Type("national_id".into()),
781            ],
782        };
783        assert!(response.constraints_satisfied(&expr));
784
785        // enumerate: [national_id, document] should fail due to no matches
786        let fail_expr = ConstraintExpr::Enumerate {
787            enumerate: vec![
788                ConstraintNode::Type("national_id".into()),
789                ConstraintNode::Type("document".into()),
790            ],
791        };
792        assert!(!response.constraints_satisfied(&fail_expr));
793    }
794
795    #[test]
796    fn test_digest_hash() {
797        let request = ProofRequest {
798            id: "test_request".into(),
799            version: RequestVersion::V1,
800            created_at: 1_700_000_000,
801            expires_at: 1_700_100_000,
802            rp_id: RpId::new(1),
803            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
804            session_id: None,
805            action: Some(FieldElement::ZERO),
806            signature: test_signature(),
807            nonce: test_nonce(),
808            requests: vec![RequestItem {
809                identifier: "orb".into(),
810                issuer_schema_id: 1,
811                signal: Some("test_signal".into()),
812                genesis_issued_at_min: None,
813                expires_at_min: None,
814            }],
815            constraints: None,
816        };
817
818        let digest1 = request.digest_hash().unwrap();
819        // Verify it returns a 32-byte hash
820        assert_eq!(digest1.len(), 32);
821
822        // Verify deterministic: same request produces same hash
823        let digest2 = request.digest_hash().unwrap();
824        assert_eq!(digest1, digest2);
825
826        // Verify different request nonces produce different hashes
827        let request2 = ProofRequest {
828            nonce: test_field_element(3),
829            ..request
830        };
831        let digest3 = request2.digest_hash().unwrap();
832        assert_ne!(digest1, digest3);
833    }
834
835    #[test]
836    fn request_validate_response_none_constraints_means_all() {
837        let request = ProofRequest {
838            id: "req_1".into(),
839            version: RequestVersion::V1,
840            created_at: 1_735_689_600,
841            expires_at: 1_735_689_600, // 2025-01-01
842            rp_id: RpId::new(1),
843            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
844            session_id: None,
845            action: Some(FieldElement::ZERO),
846            signature: test_signature(),
847            nonce: test_nonce(),
848            requests: vec![
849                RequestItem {
850                    identifier: "orb".into(),
851                    issuer_schema_id: 1,
852                    signal: None,
853                    genesis_issued_at_min: None,
854                    expires_at_min: None,
855                },
856                RequestItem {
857                    identifier: "document".into(),
858                    issuer_schema_id: 2,
859                    signal: None,
860                    genesis_issued_at_min: None,
861                    expires_at_min: None,
862                },
863            ],
864            constraints: None,
865        };
866
867        let ok = ProofResponse {
868            id: "req_1".into(),
869            version: RequestVersion::V1,
870            session_id: None,
871            error: None,
872            responses: vec![
873                ResponseItem::new_uniqueness(
874                    "orb".into(),
875                    1,
876                    ZeroKnowledgeProof::default(),
877                    test_field_element(1001),
878                    1_735_689_600,
879                ),
880                ResponseItem::new_uniqueness(
881                    "document".into(),
882                    2,
883                    ZeroKnowledgeProof::default(),
884                    test_field_element(1002),
885                    1_735_689_600,
886                ),
887            ],
888        };
889        assert!(request.validate_response(&ok).is_ok());
890
891        let missing = ProofResponse {
892            id: "req_1".into(),
893            version: RequestVersion::V1,
894            session_id: None,
895            error: None,
896            responses: vec![ResponseItem::new_uniqueness(
897                "orb".into(),
898                1,
899                ZeroKnowledgeProof::default(),
900                test_field_element(1001),
901                1_735_689_600,
902            )],
903        };
904        let err = request.validate_response(&missing).unwrap_err();
905        assert!(matches!(err, ValidationError::MissingCredential(_)));
906
907        let unexpected = ProofResponse {
908            id: "req_1".into(),
909            version: RequestVersion::V1,
910            session_id: None,
911            error: None,
912            responses: vec![
913                ResponseItem::new_uniqueness(
914                    "orb".into(),
915                    1,
916                    ZeroKnowledgeProof::default(),
917                    test_field_element(1001),
918                    1_735_689_600,
919                ),
920                ResponseItem::new_uniqueness(
921                    "document".into(),
922                    2,
923                    ZeroKnowledgeProof::default(),
924                    test_field_element(1002),
925                    1_735_689_600,
926                ),
927                ResponseItem::new_uniqueness(
928                    "passport".into(),
929                    3,
930                    ZeroKnowledgeProof::default(),
931                    test_field_element(1003),
932                    1_735_689_600,
933                ),
934            ],
935        };
936        let err = request.validate_response(&unexpected).unwrap_err();
937        assert!(matches!(
938            err,
939            ValidationError::UnexpectedCredential(ref id) if id == "passport"
940        ));
941
942        let duplicate = ProofResponse {
943            id: "req_1".into(),
944            version: RequestVersion::V1,
945            session_id: None,
946            error: None,
947            responses: vec![
948                ResponseItem::new_uniqueness(
949                    "orb".into(),
950                    1,
951                    ZeroKnowledgeProof::default(),
952                    test_field_element(1001),
953                    1_735_689_600,
954                ),
955                ResponseItem::new_uniqueness(
956                    "orb".into(),
957                    1,
958                    ZeroKnowledgeProof::default(),
959                    test_field_element(1001),
960                    1_735_689_600,
961                ),
962            ],
963        };
964        let err = request.validate_response(&duplicate).unwrap_err();
965        assert!(matches!(
966            err,
967            ValidationError::DuplicateCredential(ref id) if id == "orb"
968        ));
969    }
970
971    #[test]
972    fn constraint_depth_enforced() {
973        // Root all -> nested any -> nested all (depth 3) should be rejected
974        let deep = ConstraintExpr::All {
975            all: vec![ConstraintNode::Expr(ConstraintExpr::Any {
976                any: vec![ConstraintNode::Expr(ConstraintExpr::All {
977                    all: vec![ConstraintNode::Type("orb".into())],
978                })],
979            })],
980        };
981
982        let request = ProofRequest {
983            id: "req_2".into(),
984            version: RequestVersion::V1,
985            created_at: 1_735_689_600,
986            expires_at: 1_735_689_600,
987            rp_id: RpId::new(1),
988            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
989            session_id: None,
990            action: Some(test_field_element(1)),
991            signature: test_signature(),
992            nonce: test_nonce(),
993            requests: vec![RequestItem {
994                identifier: "orb".into(),
995                issuer_schema_id: 1,
996                signal: None,
997                genesis_issued_at_min: None,
998                expires_at_min: None,
999            }],
1000            constraints: Some(deep),
1001        };
1002
1003        let response = ProofResponse {
1004            id: "req_2".into(),
1005            version: RequestVersion::V1,
1006            session_id: None,
1007            error: None,
1008            responses: vec![ResponseItem::new_uniqueness(
1009                "orb".into(),
1010                1,
1011                ZeroKnowledgeProof::default(),
1012                test_field_element(1001),
1013                1_735_689_600,
1014            )],
1015        };
1016
1017        let err = request.validate_response(&response).unwrap_err();
1018        assert!(matches!(err, ValidationError::ConstraintTooDeep));
1019    }
1020
1021    #[test]
1022    #[allow(clippy::too_many_lines)]
1023    fn constraint_node_limit_boundary_passes() {
1024        // Root All with: 1 Type + Any(4) + Any(4)
1025        // Node count = root(1) + type(1) + any(1+4) + any(1+4) = 12
1026
1027        let expr = ConstraintExpr::All {
1028            all: vec![
1029                ConstraintNode::Type("test_req_10".into()),
1030                ConstraintNode::Expr(ConstraintExpr::Any {
1031                    any: vec![
1032                        ConstraintNode::Type("test_req_11".into()),
1033                        ConstraintNode::Type("test_req_12".into()),
1034                        ConstraintNode::Type("test_req_13".into()),
1035                        ConstraintNode::Type("test_req_14".into()),
1036                    ],
1037                }),
1038                ConstraintNode::Expr(ConstraintExpr::Any {
1039                    any: vec![
1040                        ConstraintNode::Type("test_req_15".into()),
1041                        ConstraintNode::Type("test_req_16".into()),
1042                        ConstraintNode::Type("test_req_17".into()),
1043                        ConstraintNode::Type("test_req_18".into()),
1044                    ],
1045                }),
1046            ],
1047        };
1048
1049        let request = ProofRequest {
1050            id: "req_nodes_ok".into(),
1051            version: RequestVersion::V1,
1052            created_at: 1_735_689_600,
1053            expires_at: 1_735_689_600,
1054            rp_id: RpId::new(1),
1055            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1056            session_id: None,
1057            action: Some(test_field_element(5)),
1058            signature: test_signature(),
1059            nonce: test_nonce(),
1060            requests: vec![
1061                RequestItem {
1062                    identifier: "test_req_10".into(),
1063                    issuer_schema_id: 10,
1064                    signal: None,
1065                    genesis_issued_at_min: None,
1066                    expires_at_min: None,
1067                },
1068                RequestItem {
1069                    identifier: "test_req_11".into(),
1070                    issuer_schema_id: 11,
1071                    signal: None,
1072                    genesis_issued_at_min: None,
1073                    expires_at_min: None,
1074                },
1075                RequestItem {
1076                    identifier: "test_req_12".into(),
1077                    issuer_schema_id: 12,
1078                    signal: None,
1079                    genesis_issued_at_min: None,
1080                    expires_at_min: None,
1081                },
1082                RequestItem {
1083                    identifier: "test_req_13".into(),
1084                    issuer_schema_id: 13,
1085                    signal: None,
1086                    genesis_issued_at_min: None,
1087                    expires_at_min: None,
1088                },
1089                RequestItem {
1090                    identifier: "test_req_14".into(),
1091                    issuer_schema_id: 14,
1092                    signal: None,
1093                    genesis_issued_at_min: None,
1094                    expires_at_min: None,
1095                },
1096                RequestItem {
1097                    identifier: "test_req_15".into(),
1098                    issuer_schema_id: 15,
1099                    signal: None,
1100                    genesis_issued_at_min: None,
1101                    expires_at_min: None,
1102                },
1103                RequestItem {
1104                    identifier: "test_req_16".into(),
1105                    issuer_schema_id: 16,
1106                    signal: None,
1107                    genesis_issued_at_min: None,
1108                    expires_at_min: None,
1109                },
1110                RequestItem {
1111                    identifier: "test_req_17".into(),
1112                    issuer_schema_id: 17,
1113                    signal: None,
1114                    genesis_issued_at_min: None,
1115                    expires_at_min: None,
1116                },
1117                RequestItem {
1118                    identifier: "test_req_18".into(),
1119                    issuer_schema_id: 18,
1120                    signal: None,
1121                    genesis_issued_at_min: None,
1122                    expires_at_min: None,
1123                },
1124            ],
1125            constraints: Some(expr),
1126        };
1127
1128        // Provide just enough to satisfy both any-groups and the single type
1129        let response = ProofResponse {
1130            id: "req_nodes_ok".into(),
1131            version: RequestVersion::V1,
1132            session_id: None,
1133            error: None,
1134            responses: vec![
1135                ResponseItem::new_uniqueness(
1136                    "test_req_10".into(),
1137                    10,
1138                    ZeroKnowledgeProof::default(),
1139                    test_field_element(1010),
1140                    1_735_689_600,
1141                ),
1142                ResponseItem::new_uniqueness(
1143                    "test_req_11".into(),
1144                    11,
1145                    ZeroKnowledgeProof::default(),
1146                    test_field_element(1011),
1147                    1_735_689_600,
1148                ),
1149                ResponseItem::new_uniqueness(
1150                    "test_req_15".into(),
1151                    15,
1152                    ZeroKnowledgeProof::default(),
1153                    test_field_element(1015),
1154                    1_735_689_600,
1155                ),
1156            ],
1157        };
1158
1159        // Should not exceed size and should validate OK
1160        assert!(request.validate_response(&response).is_ok());
1161    }
1162
1163    #[test]
1164    #[allow(clippy::too_many_lines)]
1165    fn constraint_node_limit_exceeded_fails() {
1166        // Root All with: 1 Type + Any(4) + Any(5)
1167        // Node count = root(1) + type(1) + any(1+4) + any(1+5) = 13 (> 12)
1168        let expr = ConstraintExpr::All {
1169            all: vec![
1170                ConstraintNode::Type("t0".into()),
1171                ConstraintNode::Expr(ConstraintExpr::Any {
1172                    any: vec![
1173                        ConstraintNode::Type("t1".into()),
1174                        ConstraintNode::Type("t2".into()),
1175                        ConstraintNode::Type("t3".into()),
1176                        ConstraintNode::Type("t4".into()),
1177                    ],
1178                }),
1179                ConstraintNode::Expr(ConstraintExpr::Any {
1180                    any: vec![
1181                        ConstraintNode::Type("t5".into()),
1182                        ConstraintNode::Type("t6".into()),
1183                        ConstraintNode::Type("t7".into()),
1184                        ConstraintNode::Type("t8".into()),
1185                        ConstraintNode::Type("t9".into()),
1186                    ],
1187                }),
1188            ],
1189        };
1190
1191        let request = ProofRequest {
1192            id: "req_nodes_too_many".into(),
1193            version: RequestVersion::V1,
1194            created_at: 1_735_689_600,
1195            expires_at: 1_735_689_600,
1196            rp_id: RpId::new(1),
1197            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1198            session_id: None,
1199            action: Some(test_field_element(1)),
1200            signature: test_signature(),
1201            nonce: test_nonce(),
1202            requests: vec![
1203                RequestItem {
1204                    identifier: "test_req_20".into(),
1205                    issuer_schema_id: 20,
1206                    signal: None,
1207                    genesis_issued_at_min: None,
1208                    expires_at_min: None,
1209                },
1210                RequestItem {
1211                    identifier: "test_req_21".into(),
1212                    issuer_schema_id: 21,
1213                    signal: None,
1214                    genesis_issued_at_min: None,
1215                    expires_at_min: None,
1216                },
1217                RequestItem {
1218                    identifier: "test_req_22".into(),
1219                    issuer_schema_id: 22,
1220                    signal: None,
1221                    genesis_issued_at_min: None,
1222                    expires_at_min: None,
1223                },
1224                RequestItem {
1225                    identifier: "test_req_23".into(),
1226                    issuer_schema_id: 23,
1227                    signal: None,
1228                    genesis_issued_at_min: None,
1229                    expires_at_min: None,
1230                },
1231                RequestItem {
1232                    identifier: "test_req_24".into(),
1233                    issuer_schema_id: 24,
1234                    signal: None,
1235                    genesis_issued_at_min: None,
1236                    expires_at_min: None,
1237                },
1238                RequestItem {
1239                    identifier: "test_req_25".into(),
1240                    issuer_schema_id: 25,
1241                    signal: None,
1242                    genesis_issued_at_min: None,
1243                    expires_at_min: None,
1244                },
1245                RequestItem {
1246                    identifier: "test_req_26".into(),
1247                    issuer_schema_id: 26,
1248                    signal: None,
1249                    genesis_issued_at_min: None,
1250                    expires_at_min: None,
1251                },
1252                RequestItem {
1253                    identifier: "test_req_27".into(),
1254                    issuer_schema_id: 27,
1255                    signal: None,
1256                    genesis_issued_at_min: None,
1257                    expires_at_min: None,
1258                },
1259                RequestItem {
1260                    identifier: "test_req_28".into(),
1261                    issuer_schema_id: 28,
1262                    signal: None,
1263                    genesis_issued_at_min: None,
1264                    expires_at_min: None,
1265                },
1266                RequestItem {
1267                    identifier: "test_req_29".into(),
1268                    issuer_schema_id: 29,
1269                    signal: None,
1270                    genesis_issued_at_min: None,
1271                    expires_at_min: None,
1272                },
1273            ],
1274            constraints: Some(expr),
1275        };
1276
1277        // Response content is irrelevant; validation should fail before evaluation due to size
1278        let response = ProofResponse {
1279            id: "req_nodes_too_many".into(),
1280            version: RequestVersion::V1,
1281            session_id: None,
1282            error: None,
1283            responses: vec![ResponseItem::new_uniqueness(
1284                "test_req_20".into(),
1285                20,
1286                ZeroKnowledgeProof::default(),
1287                test_field_element(1020),
1288                1_735_689_600,
1289            )],
1290        };
1291
1292        let err = request.validate_response(&response).unwrap_err();
1293        assert!(matches!(err, ValidationError::ConstraintTooLarge));
1294    }
1295
1296    #[test]
1297    fn request_single_credential_parse_and_validate() {
1298        let req = ProofRequest {
1299            id: "req_18c0f7f03e7d".into(),
1300            version: RequestVersion::V1,
1301            created_at: 1_725_381_192,
1302            expires_at: 1_725_381_492,
1303            rp_id: RpId::new(1),
1304            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1305            session_id: Some(test_field_element(55)),
1306            action: Some(test_field_element(1)),
1307            signature: test_signature(),
1308            nonce: test_nonce(),
1309            requests: vec![RequestItem {
1310                identifier: "test_req_1".into(),
1311                issuer_schema_id: 1,
1312                signal: Some("abcd-efgh-ijkl".into()),
1313                genesis_issued_at_min: Some(1_725_381_192),
1314                expires_at_min: None,
1315            }],
1316            constraints: None,
1317        };
1318
1319        assert_eq!(req.id, "req_18c0f7f03e7d");
1320        assert_eq!(req.requests.len(), 1);
1321
1322        // Build matching successful response (session proof requires session_id and session_nullifier)
1323        let resp = ProofResponse {
1324            id: req.id.clone(),
1325            version: RequestVersion::V1,
1326            session_id: Some(test_field_element(55)),
1327            error: None,
1328            responses: vec![ResponseItem::new_session(
1329                "test_req_1".into(),
1330                1,
1331                ZeroKnowledgeProof::default(),
1332                SessionNullifier::new(test_field_element(1001), test_field_element(1)),
1333                1_725_381_192,
1334            )],
1335        };
1336        assert!(req.validate_response(&resp).is_ok());
1337    }
1338
1339    #[test]
1340    fn request_multiple_credentials_all_constraint_and_missing() {
1341        let req = ProofRequest {
1342            id: "req_18c0f7f03e7d".into(),
1343            version: RequestVersion::V1,
1344            created_at: 1_725_381_192,
1345            expires_at: 1_725_381_492,
1346            rp_id: RpId::new(1),
1347            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1348            session_id: None,
1349            action: Some(test_field_element(1)),
1350            signature: test_signature(),
1351            nonce: test_nonce(),
1352            requests: vec![
1353                RequestItem {
1354                    identifier: "test_req_1".into(),
1355                    issuer_schema_id: 1,
1356                    signal: Some("abcd-efgh-ijkl".into()),
1357                    genesis_issued_at_min: Some(1_725_381_192),
1358                    expires_at_min: None,
1359                },
1360                RequestItem {
1361                    identifier: "test_req_2".into(),
1362                    issuer_schema_id: 2,
1363                    signal: Some("abcd-efgh-ijkl".into()),
1364                    genesis_issued_at_min: Some(1_725_381_192),
1365                    expires_at_min: None,
1366                },
1367            ],
1368            constraints: Some(ConstraintExpr::All {
1369                all: vec![
1370                    ConstraintNode::Type("test_req_1".into()),
1371                    ConstraintNode::Type("test_req_2".into()),
1372                ],
1373            }),
1374        };
1375
1376        // Build response that fails constraints (test_req_1 is missing)
1377        let resp = ProofResponse {
1378            id: req.id.clone(),
1379            version: RequestVersion::V1,
1380            session_id: None,
1381            error: None,
1382            responses: vec![ResponseItem::new_uniqueness(
1383                "test_req_2".into(),
1384                2,
1385                ZeroKnowledgeProof::default(),
1386                test_field_element(1001),
1387                1_725_381_192,
1388            )],
1389        };
1390
1391        let err = req.validate_response(&resp).unwrap_err();
1392        assert!(matches!(err, ValidationError::ConstraintNotSatisfied));
1393    }
1394
1395    #[test]
1396    fn request_more_complex_constraints_nested_success() {
1397        let req = ProofRequest {
1398            id: "req_18c0f7f03e7d".into(),
1399            version: RequestVersion::V1,
1400            created_at: 1_725_381_192,
1401            expires_at: 1_725_381_492,
1402            rp_id: RpId::new(1),
1403            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1404            session_id: None,
1405            action: Some(test_field_element(1)),
1406            signature: test_signature(),
1407            nonce: test_nonce(),
1408            requests: vec![
1409                RequestItem {
1410                    identifier: "test_req_1".into(),
1411                    issuer_schema_id: 1,
1412                    signal: Some("abcd-efgh-ijkl".into()),
1413                    genesis_issued_at_min: None,
1414                    expires_at_min: None,
1415                },
1416                RequestItem {
1417                    identifier: "test_req_2".into(),
1418                    issuer_schema_id: 2,
1419                    signal: Some("mnop-qrst-uvwx".into()),
1420                    genesis_issued_at_min: None,
1421                    expires_at_min: None,
1422                },
1423                RequestItem {
1424                    identifier: "test_req_3".into(),
1425                    issuer_schema_id: 3,
1426                    signal: Some("abcd-efgh-ijkl".into()),
1427                    genesis_issued_at_min: None,
1428                    expires_at_min: None,
1429                },
1430            ],
1431            constraints: Some(ConstraintExpr::All {
1432                all: vec![
1433                    ConstraintNode::Type("test_req_3".into()),
1434                    ConstraintNode::Expr(ConstraintExpr::Any {
1435                        any: vec![
1436                            ConstraintNode::Type("test_req_1".into()),
1437                            ConstraintNode::Type("test_req_2".into()),
1438                        ],
1439                    }),
1440                ],
1441            }),
1442        };
1443
1444        // Satisfy nested any with 0x1 + 0x3
1445        let resp = ProofResponse {
1446            id: req.id.clone(),
1447            version: RequestVersion::V1,
1448            session_id: None,
1449            error: None,
1450            responses: vec![
1451                ResponseItem::new_uniqueness(
1452                    "test_req_3".into(),
1453                    3,
1454                    ZeroKnowledgeProof::default(),
1455                    test_field_element(1001),
1456                    1_725_381_192,
1457                ),
1458                ResponseItem::new_uniqueness(
1459                    "test_req_1".into(),
1460                    1,
1461                    ZeroKnowledgeProof::default(),
1462                    test_field_element(1002),
1463                    1_725_381_192,
1464                ),
1465            ],
1466        };
1467
1468        assert!(req.validate_response(&resp).is_ok());
1469    }
1470
1471    #[test]
1472    fn request_validate_response_with_enumerate() {
1473        let req = ProofRequest {
1474            id: "req_enum".into(),
1475            version: RequestVersion::V1,
1476            created_at: 1_725_381_192,
1477            expires_at: 1_725_381_492,
1478            rp_id: RpId::new(1),
1479            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1480            session_id: None,
1481            action: Some(test_field_element(1)),
1482            signature: test_signature(),
1483            nonce: test_nonce(),
1484            requests: vec![
1485                RequestItem {
1486                    identifier: "passport".into(),
1487                    issuer_schema_id: 2,
1488                    signal: None,
1489                    genesis_issued_at_min: None,
1490                    expires_at_min: None,
1491                },
1492                RequestItem {
1493                    identifier: "national_id".into(),
1494                    issuer_schema_id: 3,
1495                    signal: None,
1496                    genesis_issued_at_min: None,
1497                    expires_at_min: None,
1498                },
1499            ],
1500            constraints: Some(ConstraintExpr::Enumerate {
1501                enumerate: vec![
1502                    ConstraintNode::Type("passport".into()),
1503                    ConstraintNode::Type("national_id".into()),
1504                ],
1505            }),
1506        };
1507
1508        // Satisfies enumerate with passport
1509        let ok_resp = ProofResponse {
1510            id: req.id.clone(),
1511            version: RequestVersion::V1,
1512            session_id: None,
1513            error: None,
1514            responses: vec![ResponseItem::new_uniqueness(
1515                "passport".into(),
1516                2,
1517                ZeroKnowledgeProof::default(),
1518                test_field_element(2002),
1519                1_725_381_192,
1520            )],
1521        };
1522        assert!(req.validate_response(&ok_resp).is_ok());
1523
1524        // Fails enumerate because none of the enumerate candidates are present
1525        let fail_resp = ProofResponse {
1526            id: req.id.clone(),
1527            version: RequestVersion::V1,
1528            session_id: None,
1529            error: None,
1530            responses: vec![],
1531        };
1532        let err = req.validate_response(&fail_resp).unwrap_err();
1533        assert!(matches!(err, ValidationError::ConstraintNotSatisfied));
1534    }
1535
1536    #[test]
1537    fn response_json_parse() {
1538        // Success OK - Uniqueness nullifier (simple FieldElement)
1539        let ok_json = r#"{
1540  "id": "req_18c0f7f03e7d",
1541  "version": 1,
1542  "responses": [
1543    {
1544      "identifier": "orb",
1545      "issuer_schema_id": 100,
1546      "proof": "00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000",
1547      "nullifier": "0x00000000000000000000000000000000000000000000000000000000000003e9",
1548      "expires_at_min": 1725381192
1549    }
1550  ]
1551}"#;
1552
1553        let ok = ProofResponse::from_json(ok_json).unwrap();
1554        assert_eq!(ok.successful_credentials(), vec![100]);
1555        assert!(ok.responses[0].is_uniqueness());
1556
1557        // Canonical session nullifier representation (prefixed hex bytes).
1558        let canonical_session_nullifier = serde_json::to_string(&SessionNullifier::new(
1559            test_field_element(1001),
1560            test_field_element(42),
1561        ))
1562        .unwrap();
1563        let sess_json_canonical = format!(
1564            r#"{{
1565  "id": "req_18c0f7f03e7d",
1566  "version": 1,
1567  "session_id": "0x00000000000000000000000000000000000000000000000000000000000003ea",
1568  "responses": [
1569    {{
1570      "identifier": "orb",
1571      "issuer_schema_id": 100,
1572      "proof": "00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000",
1573      "session_nullifier": {canonical_session_nullifier},
1574      "expires_at_min": 1725381192
1575    }}
1576  ]
1577}}"#
1578        );
1579        let sess_canonical = ProofResponse::from_json(&sess_json_canonical).unwrap();
1580        assert_eq!(sess_canonical.successful_credentials(), vec![100]);
1581        assert!(sess_canonical.responses[0].is_session());
1582    }
1583    /// Test duplicate detection by creating a serialized `ProofRequest` with duplicates
1584    /// and then trying to parse it with `from_json` which should detect the duplicates
1585    #[test]
1586    fn request_rejects_duplicate_issuer_schema_ids_on_parse() {
1587        let req = ProofRequest {
1588            id: "req_dup".into(),
1589            version: RequestVersion::V1,
1590            created_at: 1_725_381_192,
1591            expires_at: 1_725_381_492,
1592            rp_id: RpId::new(1),
1593            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1594            session_id: None,
1595            action: Some(test_field_element(5)),
1596            signature: test_signature(),
1597            nonce: test_nonce(),
1598            requests: vec![
1599                RequestItem {
1600                    identifier: "test_req_1".into(),
1601                    issuer_schema_id: 1,
1602                    signal: None,
1603                    genesis_issued_at_min: None,
1604                    expires_at_min: None,
1605                },
1606                RequestItem {
1607                    identifier: "test_req_2".into(),
1608                    issuer_schema_id: 1, // Duplicate!
1609                    signal: None,
1610                    genesis_issued_at_min: None,
1611                    expires_at_min: None,
1612                },
1613            ],
1614            constraints: None,
1615        };
1616
1617        // Serialize then deserialize to trigger the duplicate check in from_json
1618        let json = req.to_json().unwrap();
1619        let err = ProofRequest::from_json(&json).unwrap_err();
1620        let msg = err.to_string();
1621        assert!(
1622            msg.contains("duplicate issuer schema id"),
1623            "Expected error message to contain 'duplicate issuer schema id', got: {msg}"
1624        );
1625    }
1626
1627    #[test]
1628    fn response_with_error_has_empty_responses_and_fails_validation() {
1629        let request = ProofRequest {
1630            id: "req_error".into(),
1631            version: RequestVersion::V1,
1632            created_at: 1_735_689_600,
1633            expires_at: 1_735_689_600,
1634            rp_id: RpId::new(1),
1635            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1636            session_id: None,
1637            action: Some(FieldElement::ZERO),
1638            signature: test_signature(),
1639            nonce: test_nonce(),
1640            requests: vec![RequestItem {
1641                identifier: "orb".into(),
1642                issuer_schema_id: 1,
1643                signal: None,
1644                genesis_issued_at_min: None,
1645                expires_at_min: None,
1646            }],
1647            constraints: None,
1648        };
1649
1650        // Response with error should have empty responses array
1651        let error_response = ProofResponse {
1652            id: "req_error".into(),
1653            version: RequestVersion::V1,
1654            session_id: None,
1655            error: Some("credential_not_available".into()),
1656            responses: vec![], // Empty when error is present
1657        };
1658
1659        // Validation should fail with ProofGenerationFailed
1660        let err = request.validate_response(&error_response).unwrap_err();
1661        assert!(matches!(err, ValidationError::ProofGenerationFailed(_)));
1662        if let ValidationError::ProofGenerationFailed(msg) = err {
1663            assert_eq!(msg, "credential_not_available");
1664        }
1665
1666        // successful_credentials should return empty vec when error is present
1667        assert_eq!(error_response.successful_credentials(), Vec::<u64>::new());
1668
1669        // constraints_satisfied should return false when error is present
1670        let expr = ConstraintExpr::All {
1671            all: vec![ConstraintNode::Type("orb".into())],
1672        };
1673        assert!(!error_response.constraints_satisfied(&expr));
1674    }
1675
1676    #[test]
1677    fn response_error_json_parse() {
1678        // Error response JSON
1679        let error_json = r#"{
1680  "id": "req_error",
1681  "version": 1,
1682  "error": "credential_not_available",
1683  "responses": []
1684}"#;
1685
1686        let error_resp = ProofResponse::from_json(error_json).unwrap();
1687        assert_eq!(error_resp.error, Some("credential_not_available".into()));
1688        assert_eq!(error_resp.responses.len(), 0);
1689        assert_eq!(error_resp.successful_credentials(), Vec::<u64>::new());
1690    }
1691
1692    #[test]
1693    fn credentials_to_prove_none_constraints_requires_all_and_drops_if_missing() {
1694        let req = ProofRequest {
1695            id: "req".into(),
1696            version: RequestVersion::V1,
1697            created_at: 1_735_689_600,
1698            expires_at: 1_735_689_600, // 2025-01-01 00:00:00 UTC
1699            rp_id: RpId::new(1),
1700            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1701            session_id: None,
1702            action: Some(test_field_element(5)),
1703            signature: test_signature(),
1704            nonce: test_nonce(),
1705            requests: vec![
1706                RequestItem {
1707                    identifier: "orb".into(),
1708                    issuer_schema_id: 100,
1709                    signal: None,
1710                    genesis_issued_at_min: None,
1711                    expires_at_min: None,
1712                },
1713                RequestItem {
1714                    identifier: "passport".into(),
1715                    issuer_schema_id: 101,
1716                    signal: None,
1717                    genesis_issued_at_min: None,
1718                    expires_at_min: None,
1719                },
1720            ],
1721            constraints: None,
1722        };
1723
1724        let available_ok: HashSet<u64> = [100, 101].into_iter().collect();
1725        let sel_ok = req.credentials_to_prove(&available_ok).unwrap();
1726        assert_eq!(sel_ok.len(), 2);
1727        assert_eq!(sel_ok[0].issuer_schema_id, 100);
1728        assert_eq!(sel_ok[1].issuer_schema_id, 101);
1729
1730        let available_missing: HashSet<u64> = std::iter::once(100).collect();
1731        assert!(req.credentials_to_prove(&available_missing).is_none());
1732    }
1733
1734    #[test]
1735    fn credentials_to_prove_with_constraints_all_and_any() {
1736        // proof_requests: orb, passport, national-id
1737        let orb_id = 100;
1738        let passport_id = 101;
1739        let national_id = 102;
1740
1741        let req = ProofRequest {
1742            id: "req".into(),
1743            version: RequestVersion::V1,
1744            created_at: 1_735_689_600,
1745            expires_at: 1_735_689_600, // 2025-01-01 00:00:00 UTC
1746            rp_id: RpId::new(1),
1747            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1748            session_id: None,
1749            action: Some(test_field_element(1)),
1750            signature: test_signature(),
1751            nonce: test_nonce(),
1752            requests: vec![
1753                RequestItem {
1754                    identifier: "orb".into(),
1755                    issuer_schema_id: orb_id,
1756                    signal: None,
1757                    genesis_issued_at_min: None,
1758                    expires_at_min: None,
1759                },
1760                RequestItem {
1761                    identifier: "passport".into(),
1762                    issuer_schema_id: passport_id,
1763                    signal: None,
1764                    genesis_issued_at_min: None,
1765                    expires_at_min: None,
1766                },
1767                RequestItem {
1768                    identifier: "national_id".into(),
1769                    issuer_schema_id: national_id,
1770                    signal: None,
1771                    genesis_issued_at_min: None,
1772                    expires_at_min: None,
1773                },
1774            ],
1775            constraints: Some(ConstraintExpr::All {
1776                all: vec![
1777                    ConstraintNode::Type("orb".into()),
1778                    ConstraintNode::Expr(ConstraintExpr::Any {
1779                        any: vec![
1780                            ConstraintNode::Type("passport".into()),
1781                            ConstraintNode::Type("national_id".into()),
1782                        ],
1783                    }),
1784                ],
1785            }),
1786        };
1787
1788        // Available has orb + passport → should pick [orb, passport]
1789        let available1: HashSet<u64> = [orb_id, passport_id].into_iter().collect();
1790        let sel1 = req.credentials_to_prove(&available1).unwrap();
1791        assert_eq!(sel1.len(), 2);
1792        assert_eq!(sel1[0].issuer_schema_id, orb_id);
1793        assert_eq!(sel1[1].issuer_schema_id, passport_id);
1794
1795        // Available has orb + national-id → should pick [orb, national-id]
1796        let available2: HashSet<u64> = [orb_id, national_id].into_iter().collect();
1797        let sel2 = req.credentials_to_prove(&available2).unwrap();
1798        assert_eq!(sel2.len(), 2);
1799        assert_eq!(sel2[0].issuer_schema_id, orb_id);
1800        assert_eq!(sel2[1].issuer_schema_id, national_id);
1801
1802        // Missing orb → cannot satisfy "all" → None
1803        let available3: HashSet<u64> = std::iter::once(passport_id).collect();
1804        assert!(req.credentials_to_prove(&available3).is_none());
1805    }
1806
1807    #[test]
1808    fn credentials_to_prove_with_constraints_enumerate() {
1809        let orb_id = 100;
1810        let passport_id = 101;
1811        let national_id = 102;
1812
1813        let req = ProofRequest {
1814            id: "req".into(),
1815            version: RequestVersion::V1,
1816            created_at: 1_735_689_600,
1817            expires_at: 1_735_689_600,
1818            rp_id: RpId::new(1),
1819            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1820            session_id: None,
1821            action: Some(test_field_element(1)),
1822            signature: test_signature(),
1823            nonce: test_nonce(),
1824            requests: vec![
1825                RequestItem {
1826                    identifier: "orb".into(),
1827                    issuer_schema_id: orb_id,
1828                    signal: None,
1829                    genesis_issued_at_min: None,
1830                    expires_at_min: None,
1831                },
1832                RequestItem {
1833                    identifier: "passport".into(),
1834                    issuer_schema_id: passport_id,
1835                    signal: None,
1836                    genesis_issued_at_min: None,
1837                    expires_at_min: None,
1838                },
1839                RequestItem {
1840                    identifier: "national_id".into(),
1841                    issuer_schema_id: national_id,
1842                    signal: None,
1843                    genesis_issued_at_min: None,
1844                    expires_at_min: None,
1845                },
1846            ],
1847            constraints: Some(ConstraintExpr::Enumerate {
1848                enumerate: vec![
1849                    ConstraintNode::Type("passport".into()),
1850                    ConstraintNode::Type("national_id".into()),
1851                ],
1852            }),
1853        };
1854
1855        // One of enumerate candidates available -> one selected
1856        let available1: HashSet<u64> = [orb_id, passport_id].into_iter().collect();
1857        let sel1 = req.credentials_to_prove(&available1).unwrap();
1858        assert_eq!(sel1.len(), 1);
1859        assert_eq!(sel1[0].issuer_schema_id, passport_id);
1860
1861        // Both enumerate candidates available -> both selected in request order
1862        let available2: HashSet<u64> = [orb_id, passport_id, national_id].into_iter().collect();
1863        let sel2 = req.credentials_to_prove(&available2).unwrap();
1864        assert_eq!(sel2.len(), 2);
1865        assert_eq!(sel2[0].issuer_schema_id, passport_id);
1866        assert_eq!(sel2[1].issuer_schema_id, national_id);
1867
1868        // None of enumerate candidates available -> cannot satisfy
1869        let available3: HashSet<u64> = std::iter::once(orb_id).collect();
1870        assert!(req.credentials_to_prove(&available3).is_none());
1871    }
1872
1873    #[test]
1874    fn credentials_to_prove_with_constraints_all_and_enumerate() {
1875        let orb_id = 100;
1876        let passport_id = 101;
1877        let national_id = 102;
1878
1879        let req = ProofRequest {
1880            id: "req".into(),
1881            version: RequestVersion::V1,
1882            created_at: 1_735_689_600,
1883            expires_at: 1_735_689_600,
1884            rp_id: RpId::new(1),
1885            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1886            session_id: None,
1887            action: Some(test_field_element(1)),
1888            signature: test_signature(),
1889            nonce: test_nonce(),
1890            requests: vec![
1891                RequestItem {
1892                    identifier: "orb".into(),
1893                    issuer_schema_id: orb_id,
1894                    signal: None,
1895                    genesis_issued_at_min: None,
1896                    expires_at_min: None,
1897                },
1898                RequestItem {
1899                    identifier: "passport".into(),
1900                    issuer_schema_id: passport_id,
1901                    signal: None,
1902                    genesis_issued_at_min: None,
1903                    expires_at_min: None,
1904                },
1905                RequestItem {
1906                    identifier: "national_id".into(),
1907                    issuer_schema_id: national_id,
1908                    signal: None,
1909                    genesis_issued_at_min: None,
1910                    expires_at_min: None,
1911                },
1912            ],
1913            constraints: Some(ConstraintExpr::All {
1914                all: vec![
1915                    ConstraintNode::Type("orb".into()),
1916                    ConstraintNode::Expr(ConstraintExpr::Enumerate {
1917                        enumerate: vec![
1918                            ConstraintNode::Type("passport".into()),
1919                            ConstraintNode::Type("national_id".into()),
1920                        ],
1921                    }),
1922                ],
1923            }),
1924        };
1925
1926        // orb + passport -> select both
1927        let available1: HashSet<u64> = [orb_id, passport_id].into_iter().collect();
1928        let sel1 = req.credentials_to_prove(&available1).unwrap();
1929        assert_eq!(sel1.len(), 2);
1930        assert_eq!(sel1[0].issuer_schema_id, orb_id);
1931        assert_eq!(sel1[1].issuer_schema_id, passport_id);
1932
1933        // orb + passport + national_id -> select all three
1934        let available2: HashSet<u64> = [orb_id, passport_id, national_id].into_iter().collect();
1935        let sel2 = req.credentials_to_prove(&available2).unwrap();
1936        assert_eq!(sel2.len(), 3);
1937        assert_eq!(sel2[0].issuer_schema_id, orb_id);
1938        assert_eq!(sel2[1].issuer_schema_id, passport_id);
1939        assert_eq!(sel2[2].issuer_schema_id, national_id);
1940
1941        // orb alone -> enumerate branch unsatisfied
1942        let available3: HashSet<u64> = std::iter::once(orb_id).collect();
1943        assert!(req.credentials_to_prove(&available3).is_none());
1944    }
1945
1946    #[test]
1947    fn request_item_effective_expires_at_min_defaults_to_created_at() {
1948        let request_created_at = 1_735_689_600; // 2025-01-01 00:00:00 UTC
1949        let custom_expires_at = 1_735_862_400; // 2025-01-03 00:00:00 UTC
1950
1951        // When expires_at_min is None, should use request_created_at
1952        let item_with_none = RequestItem {
1953            identifier: "test".into(),
1954            issuer_schema_id: 100,
1955            signal: None,
1956            genesis_issued_at_min: None,
1957            expires_at_min: None,
1958        };
1959        assert_eq!(
1960            item_with_none.effective_expires_at_min(request_created_at),
1961            request_created_at,
1962            "When expires_at_min is None, should default to request created_at"
1963        );
1964
1965        // When expires_at_min is Some, should use that value
1966        let item_with_custom = RequestItem {
1967            identifier: "test".into(),
1968            issuer_schema_id: 100,
1969            signal: None,
1970            genesis_issued_at_min: None,
1971            expires_at_min: Some(custom_expires_at),
1972        };
1973        assert_eq!(
1974            item_with_custom.effective_expires_at_min(request_created_at),
1975            custom_expires_at,
1976            "When expires_at_min is Some, should use that explicit value"
1977        );
1978    }
1979
1980    #[test]
1981    fn validate_response_checks_expires_at_min_matches() {
1982        let request_created_at = 1_735_689_600; // 2025-01-01 00:00:00 UTC
1983        let custom_expires_at = 1_735_862_400; // 2025-01-03 00:00:00 UTC
1984
1985        // Request with one item that has no explicit expires_at_min (defaults to created_at)
1986        // and one with an explicit expires_at_min
1987        let request = ProofRequest {
1988            id: "req_expires_test".into(),
1989            version: RequestVersion::V1,
1990            created_at: request_created_at,
1991            expires_at: request_created_at + 300,
1992            rp_id: RpId::new(1),
1993            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1994            session_id: None,
1995            action: Some(test_field_element(1)),
1996            signature: test_signature(),
1997            nonce: test_nonce(),
1998            requests: vec![
1999                RequestItem {
2000                    identifier: "orb".into(),
2001                    issuer_schema_id: 100,
2002                    signal: None,
2003                    genesis_issued_at_min: None,
2004                    expires_at_min: None, // Should default to request_created_at
2005                },
2006                RequestItem {
2007                    identifier: "document".into(),
2008                    issuer_schema_id: 101,
2009                    signal: None,
2010                    genesis_issued_at_min: None,
2011                    expires_at_min: Some(custom_expires_at), // Explicit value
2012                },
2013            ],
2014            constraints: None,
2015        };
2016
2017        // Valid response with matching expires_at_min values
2018        let valid_response = ProofResponse {
2019            id: "req_expires_test".into(),
2020            version: RequestVersion::V1,
2021            session_id: None,
2022            error: None,
2023            responses: vec![
2024                ResponseItem::new_uniqueness(
2025                    "orb".into(),
2026                    100,
2027                    ZeroKnowledgeProof::default(),
2028                    test_field_element(1001),
2029                    request_created_at, // Matches default
2030                ),
2031                ResponseItem::new_uniqueness(
2032                    "document".into(),
2033                    101,
2034                    ZeroKnowledgeProof::default(),
2035                    test_field_element(1002),
2036                    custom_expires_at, // Matches explicit value
2037                ),
2038            ],
2039        };
2040        assert!(request.validate_response(&valid_response).is_ok());
2041
2042        // Invalid response with mismatched expires_at_min for first item
2043        let invalid_response_1 = ProofResponse {
2044            id: "req_expires_test".into(),
2045            version: RequestVersion::V1,
2046            session_id: None,
2047            error: None,
2048            responses: vec![
2049                ResponseItem::new_uniqueness(
2050                    "orb".into(),
2051                    100,
2052                    ZeroKnowledgeProof::default(),
2053                    test_field_element(1001),
2054                    custom_expires_at, // Wrong! Should be request_created_at
2055                ),
2056                ResponseItem::new_uniqueness(
2057                    "document".into(),
2058                    101,
2059                    ZeroKnowledgeProof::default(),
2060                    test_field_element(1002),
2061                    custom_expires_at,
2062                ),
2063            ],
2064        };
2065        let err1 = request.validate_response(&invalid_response_1).unwrap_err();
2066        assert!(matches!(
2067            err1,
2068            ValidationError::ExpiresAtMinMismatch(_, _, _)
2069        ));
2070        if let ValidationError::ExpiresAtMinMismatch(identifier, expected, got) = err1 {
2071            assert_eq!(identifier, "orb");
2072            assert_eq!(expected, request_created_at);
2073            assert_eq!(got, custom_expires_at);
2074        }
2075
2076        // Invalid response with mismatched expires_at_min for second item
2077        let invalid_response_2 = ProofResponse {
2078            id: "req_expires_test".into(),
2079            version: RequestVersion::V1,
2080            session_id: None,
2081            error: None,
2082            responses: vec![
2083                ResponseItem::new_uniqueness(
2084                    "orb".into(),
2085                    100,
2086                    ZeroKnowledgeProof::default(),
2087                    test_field_element(1001),
2088                    request_created_at,
2089                ),
2090                ResponseItem::new_uniqueness(
2091                    "document".into(),
2092                    101,
2093                    ZeroKnowledgeProof::default(),
2094                    test_field_element(1002),
2095                    request_created_at, // Wrong! Should be custom_expires_at
2096                ),
2097            ],
2098        };
2099        let err2 = request.validate_response(&invalid_response_2).unwrap_err();
2100        assert!(matches!(
2101            err2,
2102            ValidationError::ExpiresAtMinMismatch(_, _, _)
2103        ));
2104        if let ValidationError::ExpiresAtMinMismatch(identifier, expected, got) = err2 {
2105            assert_eq!(identifier, "document");
2106            assert_eq!(expected, custom_expires_at);
2107            assert_eq!(got, request_created_at);
2108        }
2109    }
2110
2111    #[test]
2112    fn computed_action_returns_explicit_action() {
2113        let action = test_field_element(42);
2114        let request = ProofRequest {
2115            id: "req".into(),
2116            version: RequestVersion::V1,
2117            created_at: 1_700_000_000,
2118            expires_at: 1_700_100_000,
2119            rp_id: RpId::new(1),
2120            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2121            session_id: None,
2122            action: Some(action),
2123            signature: test_signature(),
2124            nonce: test_nonce(),
2125            requests: vec![],
2126            constraints: None,
2127        };
2128        assert_eq!(request.computed_action(&mut rand::rngs::OsRng), action);
2129    }
2130
2131    #[test]
2132    fn computed_action_generates_random_when_none() {
2133        let request = ProofRequest {
2134            id: "req".into(),
2135            version: RequestVersion::V1,
2136            created_at: 1_700_000_000,
2137            expires_at: 1_700_100_000,
2138            rp_id: RpId::new(1),
2139            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2140            session_id: Some(test_field_element(99)),
2141            action: None,
2142            signature: test_signature(),
2143            nonce: test_nonce(),
2144            requests: vec![],
2145            constraints: None,
2146        };
2147
2148        let action1 = request.computed_action(&mut rand::rngs::OsRng);
2149        let action2 = request.computed_action(&mut rand::rngs::OsRng);
2150        // Each call generates a different random action
2151        assert_ne!(action1, action2);
2152    }
2153
2154    #[test]
2155    fn test_validate_response_requires_session_id_in_response() {
2156        // Request with session_id should require response to also have session_id
2157        let request = ProofRequest {
2158            id: "req_session".into(),
2159            version: RequestVersion::V1,
2160            created_at: 1_735_689_600,
2161            expires_at: 1_735_689_900,
2162            rp_id: RpId::new(1),
2163            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2164            session_id: Some(test_field_element(123)), // Session proof
2165            action: Some(test_field_element(42)),
2166            signature: test_signature(),
2167            nonce: test_nonce(),
2168            requests: vec![RequestItem {
2169                identifier: "orb".into(),
2170                issuer_schema_id: 1,
2171                signal: None,
2172                genesis_issued_at_min: None,
2173                expires_at_min: None,
2174            }],
2175            constraints: None,
2176        };
2177
2178        // Response without session_id should fail validation
2179        let response_missing_session_id = ProofResponse {
2180            id: "req_session".into(),
2181            version: RequestVersion::V1,
2182            session_id: None, // Missing!
2183            error: None,
2184            responses: vec![ResponseItem::new_session(
2185                "orb".into(),
2186                1,
2187                ZeroKnowledgeProof::default(),
2188                SessionNullifier::new(test_field_element(1001), test_field_element(42)),
2189                1_735_689_600,
2190            )],
2191        };
2192
2193        let err = request
2194            .validate_response(&response_missing_session_id)
2195            .unwrap_err();
2196        assert!(matches!(err, ValidationError::SessionIdMismatch));
2197    }
2198
2199    #[test]
2200    fn test_validate_response_requires_session_nullifier_for_session_proof() {
2201        // Request with session_id requires session_nullifier in each response item
2202        let request = ProofRequest {
2203            id: "req_session".into(),
2204            version: RequestVersion::V1,
2205            created_at: 1_735_689_600,
2206            expires_at: 1_735_689_900,
2207            rp_id: RpId::new(1),
2208            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2209            session_id: Some(test_field_element(123)), // Session proof
2210            action: Some(test_field_element(42)),
2211            signature: test_signature(),
2212            nonce: test_nonce(),
2213            requests: vec![RequestItem {
2214                identifier: "orb".into(),
2215                issuer_schema_id: 1,
2216                signal: None,
2217                genesis_issued_at_min: None,
2218                expires_at_min: None,
2219            }],
2220            constraints: None,
2221        };
2222
2223        // Response with uniqueness nullifier instead of session nullifier should fail
2224        let response_wrong_nullifier_type = ProofResponse {
2225            id: "req_session".into(),
2226            version: RequestVersion::V1,
2227            session_id: Some(test_field_element(123)),
2228            error: None,
2229            responses: vec![ResponseItem::new_uniqueness(
2230                "orb".into(),
2231                1,
2232                ZeroKnowledgeProof::default(),
2233                test_field_element(1001), // Using uniqueness nullifier instead of session!
2234                1_735_689_600,
2235            )],
2236        };
2237
2238        let err = request
2239            .validate_response(&response_wrong_nullifier_type)
2240            .unwrap_err();
2241        assert!(matches!(
2242            err,
2243            ValidationError::MissingSessionNullifier(ref id) if id == "orb"
2244        ));
2245    }
2246
2247    #[test]
2248    fn test_validate_response_requires_nullifier_for_uniqueness_proof() {
2249        // Request without session_id requires nullifier in each response item
2250        let request = ProofRequest {
2251            id: "req_uniqueness".into(),
2252            version: RequestVersion::V1,
2253            created_at: 1_735_689_600,
2254            expires_at: 1_735_689_900,
2255            rp_id: RpId::new(1),
2256            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2257            session_id: None, // Uniqueness proof
2258            action: Some(test_field_element(42)),
2259            signature: test_signature(),
2260            nonce: test_nonce(),
2261            requests: vec![RequestItem {
2262                identifier: "orb".into(),
2263                issuer_schema_id: 1,
2264                signal: None,
2265                genesis_issued_at_min: None,
2266                expires_at_min: None,
2267            }],
2268            constraints: None,
2269        };
2270
2271        // Response with session nullifier instead of uniqueness nullifier should fail
2272        let response_wrong_nullifier_type = ProofResponse {
2273            id: "req_uniqueness".into(),
2274            version: RequestVersion::V1,
2275            session_id: None,
2276            error: None,
2277            responses: vec![ResponseItem::new_session(
2278                "orb".into(),
2279                1,
2280                ZeroKnowledgeProof::default(),
2281                SessionNullifier::new(test_field_element(1001), test_field_element(42)), // Using session nullifier instead of uniqueness!
2282                1_735_689_600,
2283            )],
2284        };
2285
2286        let err = request
2287            .validate_response(&response_wrong_nullifier_type)
2288            .unwrap_err();
2289        assert!(matches!(
2290            err,
2291            ValidationError::MissingNullifier(ref id) if id == "orb"
2292        ));
2293    }
2294}