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