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, PrimitiveError, SessionNullifier, ZeroKnowledgeProof, nullifier::Nullifier,
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<FieldElement>,
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    /// When session proofs are enabled, this is the hex-encoded field element
195    /// emitted by the session circuit; otherwise it is omitted.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub session_id: Option<FieldElement>,
198    /// Error message if the entire proof request failed.
199    /// When present, the responses array will be empty.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub error: Option<String>,
202    /// Per-credential results (empty if error is present)
203    pub responses: Vec<ResponseItem>,
204}
205
206/// Per-credential response item returned by the Authenticator.
207///
208/// Each entry corresponds to one requested credential with its proof material.
209/// If any credential cannot be satisfied, the entire proof response will have
210/// an error at the `ProofResponse` level with an empty `responses` array.
211///
212/// # Nullifier Types
213///
214/// - **Uniqueness proofs**: Use `nullifier` field (a single `FieldElement`).
215///   The contract's `verify()` function takes this as a separate `uint256 nullifier` param.
216///
217/// - **Session proofs**: Use `session_nullifier` field (contains both nullifier and action).
218///   The contract's `verifySession()` function expects `uint256[2] sessionNullifier`.
219///
220/// Exactly one of `nullifier` or `session_nullifier` should be present.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct ResponseItem {
224    /// An RP-defined identifier for this request item used to match against constraints and responses.
225    ///
226    /// Example: `orb`, `document`.
227    pub identifier: String,
228
229    /// Unique identifier for the credential schema and issuer pair.
230    pub issuer_schema_id: u64,
231
232    /// Encoded World ID Proof. See [`ZeroKnowledgeProof`] for more details.
233    pub proof: ZeroKnowledgeProof,
234
235    /// A [`Nullifier`] for Uniqueness proofs.
236    ///
237    /// Present for Uniqueness proofs, absent for Session proofs.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub nullifier: Option<Nullifier>,
240
241    /// A [`SessionNullifier`] for Session proofs.
242    ///
243    /// Present for Session proofs, absent for Uniqueness proofs.
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub session_nullifier: Option<SessionNullifier>,
246
247    /// The minimum expiration required for the Credential used in the proof.
248    ///
249    /// This precise value must be used when verifying the proof on-chain.
250    pub expires_at_min: u64,
251}
252
253impl ProofResponse {
254    /// Determine if constraints are satisfied given a constraint expression.
255    /// Returns false if the response has an error.
256    #[must_use]
257    pub fn constraints_satisfied(&self, constraints: &ConstraintExpr<'_>) -> bool {
258        // If there's an error, constraints cannot be satisfied
259        if self.error.is_some() {
260            return false;
261        }
262
263        let provided: HashSet<&str> = self
264            .responses
265            .iter()
266            .map(|item| item.identifier.as_str())
267            .collect();
268
269        constraints.evaluate(&|t| provided.contains(t))
270    }
271}
272
273impl ResponseItem {
274    /// Create a new response item for a Uniqueness proof.
275    #[must_use]
276    pub const fn new_uniqueness(
277        identifier: String,
278        issuer_schema_id: u64,
279        proof: ZeroKnowledgeProof,
280        nullifier: Nullifier,
281        expires_at_min: u64,
282    ) -> Self {
283        Self {
284            identifier,
285            issuer_schema_id,
286            proof,
287            nullifier: Some(nullifier),
288            session_nullifier: None,
289            expires_at_min,
290        }
291    }
292
293    /// Create a new response item for a Session proof.
294    #[must_use]
295    pub const fn new_session(
296        identifier: String,
297        issuer_schema_id: u64,
298        proof: ZeroKnowledgeProof,
299        session_nullifier: SessionNullifier,
300        expires_at_min: u64,
301    ) -> Self {
302        Self {
303            identifier,
304            issuer_schema_id,
305            proof,
306            nullifier: None,
307            session_nullifier: Some(session_nullifier),
308            expires_at_min,
309        }
310    }
311
312    /// Returns true if this is a Session proof response.
313    #[must_use]
314    pub const fn is_session(&self) -> bool {
315        self.session_nullifier.is_some()
316    }
317
318    /// Returns true if this is a Uniqueness proof response.
319    #[must_use]
320    pub const fn is_uniqueness(&self) -> bool {
321        self.nullifier.is_some()
322    }
323}
324
325impl ProofRequest {
326    /// Determine which requested credentials to prove given available credentials.
327    ///
328    /// Returns `None` if constraints (or lack thereof) cannot be satisfied with the available set.
329    ///
330    /// # Panics
331    /// Panics if constraints are present but invalid according to the type invariants
332    /// (this should not occur as constraints are provided by trusted request issuer).
333    #[must_use]
334    pub fn credentials_to_prove(&self, available: &HashSet<u64>) -> Option<Vec<&RequestItem>> {
335        // Pre-compute which identifiers have an available issuer_schema_id
336        let available_identifiers: HashSet<&str> = self
337            .requests
338            .iter()
339            .filter(|r| available.contains(&r.issuer_schema_id))
340            .map(|r| r.identifier.as_str())
341            .collect();
342
343        let is_selectable = |identifier: &str| available_identifiers.contains(identifier);
344
345        // If no explicit constraints: require all requested be available
346        if self.constraints.is_none() {
347            return if self
348                .requests
349                .iter()
350                .all(|r| available.contains(&r.issuer_schema_id))
351            {
352                Some(self.requests.iter().collect())
353            } else {
354                None
355            };
356        }
357
358        // Compute selected identifiers using the constraint expression
359        let selected_identifiers = select_expr(self.constraints.as_ref().unwrap(), &is_selectable)?;
360        let selected_set: HashSet<&str> = selected_identifiers.into_iter().collect();
361
362        // Return proof_requests in original order filtered by selected identifiers
363        let result: Vec<&RequestItem> = self
364            .requests
365            .iter()
366            .filter(|r| selected_set.contains(r.identifier.as_str()))
367            .collect();
368        Some(result)
369    }
370
371    /// Find a request item by issuer schema ID if available
372    #[must_use]
373    pub fn find_request_by_issuer_schema_id(&self, issuer_schema_id: u64) -> Option<&RequestItem> {
374        self.requests
375            .iter()
376            .find(|r| r.issuer_schema_id == issuer_schema_id)
377    }
378
379    /// Returns true if the request is expired relative to now (unix timestamp in seconds)
380    #[must_use]
381    pub const fn is_expired(&self, now: u64) -> bool {
382        now > self.expires_at
383    }
384
385    /// Compute the digest hash of this request that should be signed by the RP, which right now
386    /// includes the `nonce` and the timestamp of the request.
387    ///
388    /// # Returns
389    /// A 32-byte hash that represents this request and should be signed by the RP.
390    ///
391    /// # Errors
392    /// Returns a `PrimitiveError` if `FieldElement` serialization fails (which should never occur in practice).
393    ///
394    /// The digest is computed as: `SHA256(version || nonce || created_at || expires_at)`.
395    /// This mirrors the RP signature message format from `rp::compute_rp_signature_msg`.
396    /// Note: the timestamp is encoded as big-endian to mirror the RP-side signing
397    /// performed in test fixtures and the OPRF stub.
398    pub fn digest_hash(&self) -> Result<[u8; 32], PrimitiveError> {
399        use crate::rp::compute_rp_signature_msg;
400        use k256::sha2::{Digest, Sha256};
401
402        let msg = compute_rp_signature_msg(*self.nonce, self.created_at, self.expires_at);
403        let mut hasher = Sha256::new();
404        hasher.update(&msg);
405        Ok(hasher.finalize().into())
406    }
407
408    /// Gets the action value to use in the proof.
409    ///
410    /// - When an explicit action is provided, it is returned directly.
411    /// - For session proofs (action is `None`), a random action is generated.
412    ///
413    /// Callers should cache the action during proof generation to ensure consistency across proof steps.
414    #[must_use]
415    pub fn computed_action<R: rand::CryptoRng + rand::RngCore>(&self, rng: &mut R) -> FieldElement {
416        match self.action {
417            Some(action) => action,
418            None => FieldElement::random(rng),
419        }
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    #[test]
706    fn constraints_all_any_nested() {
707        // Build a response that has test_req_1 and test_req_2 provided
708        let response = ProofResponse {
709            id: "req_123".into(),
710            version: RequestVersion::V1,
711            session_id: None,
712            error: None,
713            responses: vec![
714                ResponseItem::new_uniqueness(
715                    "test_req_1".into(),
716                    1,
717                    ZeroKnowledgeProof::default(),
718                    test_field_element(1001).into(),
719                    1_735_689_600,
720                ),
721                ResponseItem::new_uniqueness(
722                    "test_req_2".into(),
723                    2,
724                    ZeroKnowledgeProof::default(),
725                    test_field_element(1002).into(),
726                    1_735_689_600,
727                ),
728            ],
729        };
730
731        // all: [test_req_1, any: [test_req_2, test_req_4]]
732        let expr = ConstraintExpr::All {
733            all: vec![
734                ConstraintNode::Type("test_req_1".into()),
735                ConstraintNode::Expr(ConstraintExpr::Any {
736                    any: vec![
737                        ConstraintNode::Type("test_req_2".into()),
738                        ConstraintNode::Type("test_req_4".into()),
739                    ],
740                }),
741            ],
742        };
743
744        assert!(response.constraints_satisfied(&expr));
745
746        // all: [test_req_1, test_req_3] should fail because test_req_3 is not in response
747        let fail_expr = ConstraintExpr::All {
748            all: vec![
749                ConstraintNode::Type("test_req_1".into()),
750                ConstraintNode::Type("test_req_3".into()),
751            ],
752        };
753        assert!(!response.constraints_satisfied(&fail_expr));
754    }
755
756    #[test]
757    fn constraints_enumerate_partial_and_empty() {
758        // Build a response that has orb and passport provided
759        let response = ProofResponse {
760            id: "req_123".into(),
761            version: RequestVersion::V1,
762            session_id: None,
763            error: None,
764            responses: vec![
765                ResponseItem::new_uniqueness(
766                    "orb".into(),
767                    1,
768                    ZeroKnowledgeProof::default(),
769                    Nullifier::from(test_field_element(1001)),
770                    1_735_689_600,
771                ),
772                ResponseItem::new_uniqueness(
773                    "passport".into(),
774                    2,
775                    ZeroKnowledgeProof::default(),
776                    Nullifier::from(test_field_element(1002)),
777                    1_735_689_600,
778                ),
779            ],
780        };
781
782        // enumerate: [passport, national_id] should pass due to passport
783        let expr = ConstraintExpr::Enumerate {
784            enumerate: vec![
785                ConstraintNode::Type("passport".into()),
786                ConstraintNode::Type("national_id".into()),
787            ],
788        };
789        assert!(response.constraints_satisfied(&expr));
790
791        // enumerate: [national_id, document] should fail due to no matches
792        let fail_expr = ConstraintExpr::Enumerate {
793            enumerate: vec![
794                ConstraintNode::Type("national_id".into()),
795                ConstraintNode::Type("document".into()),
796            ],
797        };
798        assert!(!response.constraints_satisfied(&fail_expr));
799    }
800
801    #[test]
802    fn test_digest_hash() {
803        let request = ProofRequest {
804            id: "test_request".into(),
805            version: RequestVersion::V1,
806            created_at: 1_700_000_000,
807            expires_at: 1_700_100_000,
808            rp_id: RpId::new(1),
809            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
810            session_id: None,
811            action: Some(FieldElement::ZERO),
812            signature: test_signature(),
813            nonce: test_nonce(),
814            requests: vec![RequestItem {
815                identifier: "orb".into(),
816                issuer_schema_id: 1,
817                signal: Some("test_signal".into()),
818                genesis_issued_at_min: None,
819                expires_at_min: None,
820            }],
821            constraints: None,
822        };
823
824        let digest1 = request.digest_hash().unwrap();
825        // Verify it returns a 32-byte hash
826        assert_eq!(digest1.len(), 32);
827
828        // Verify deterministic: same request produces same hash
829        let digest2 = request.digest_hash().unwrap();
830        assert_eq!(digest1, digest2);
831
832        // Verify different request nonces produce different hashes
833        let request2 = ProofRequest {
834            nonce: test_field_element(3),
835            ..request
836        };
837        let digest3 = request2.digest_hash().unwrap();
838        assert_ne!(digest1, digest3);
839    }
840
841    #[test]
842    fn proof_request_signature_serializes_as_hex_string() {
843        let request = ProofRequest {
844            id: "test".into(),
845            version: RequestVersion::V1,
846            created_at: 1_700_000_000,
847            expires_at: 1_700_100_000,
848            rp_id: RpId::new(1),
849            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
850            session_id: None,
851            action: None,
852            signature: test_signature(),
853            nonce: test_nonce(),
854            requests: vec![RequestItem {
855                identifier: "orb".into(),
856                issuer_schema_id: 1,
857                signal: None,
858                genesis_issued_at_min: None,
859                expires_at_min: None,
860            }],
861            constraints: None,
862        };
863
864        let json = request.to_json().unwrap();
865        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
866        let sig = value["signature"]
867            .as_str()
868            .expect("signature should be a string");
869        assert!(sig.starts_with("0x"));
870        assert_eq!(sig.len(), 132);
871
872        let roundtripped = ProofRequest::from_json(&json).unwrap();
873        assert_eq!(roundtripped.signature, request.signature);
874    }
875
876    #[test]
877    fn request_validate_response_none_constraints_means_all() {
878        let request = ProofRequest {
879            id: "req_1".into(),
880            version: RequestVersion::V1,
881            created_at: 1_735_689_600,
882            expires_at: 1_735_689_600, // 2025-01-01
883            rp_id: RpId::new(1),
884            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
885            session_id: None,
886            action: Some(FieldElement::ZERO),
887            signature: test_signature(),
888            nonce: test_nonce(),
889            requests: vec![
890                RequestItem {
891                    identifier: "orb".into(),
892                    issuer_schema_id: 1,
893                    signal: None,
894                    genesis_issued_at_min: None,
895                    expires_at_min: None,
896                },
897                RequestItem {
898                    identifier: "document".into(),
899                    issuer_schema_id: 2,
900                    signal: None,
901                    genesis_issued_at_min: None,
902                    expires_at_min: None,
903                },
904            ],
905            constraints: None,
906        };
907
908        let ok = ProofResponse {
909            id: "req_1".into(),
910            version: RequestVersion::V1,
911            session_id: None,
912            error: None,
913            responses: vec![
914                ResponseItem::new_uniqueness(
915                    "orb".into(),
916                    1,
917                    ZeroKnowledgeProof::default(),
918                    Nullifier::from(test_field_element(1001)),
919                    1_735_689_600,
920                ),
921                ResponseItem::new_uniqueness(
922                    "document".into(),
923                    2,
924                    ZeroKnowledgeProof::default(),
925                    Nullifier::from(test_field_element(1002)),
926                    1_735_689_600,
927                ),
928            ],
929        };
930        assert!(request.validate_response(&ok).is_ok());
931
932        let missing = ProofResponse {
933            id: "req_1".into(),
934            version: RequestVersion::V1,
935            session_id: None,
936            error: None,
937            responses: vec![ResponseItem::new_uniqueness(
938                "orb".into(),
939                1,
940                ZeroKnowledgeProof::default(),
941                Nullifier::from(test_field_element(1001)),
942                1_735_689_600,
943            )],
944        };
945        let err = request.validate_response(&missing).unwrap_err();
946        assert!(matches!(err, ValidationError::MissingCredential(_)));
947
948        let unexpected = ProofResponse {
949            id: "req_1".into(),
950            version: RequestVersion::V1,
951            session_id: None,
952            error: None,
953            responses: vec![
954                ResponseItem::new_uniqueness(
955                    "orb".into(),
956                    1,
957                    ZeroKnowledgeProof::default(),
958                    Nullifier::from(test_field_element(1001)),
959                    1_735_689_600,
960                ),
961                ResponseItem::new_uniqueness(
962                    "document".into(),
963                    2,
964                    ZeroKnowledgeProof::default(),
965                    Nullifier::from(test_field_element(1002)),
966                    1_735_689_600,
967                ),
968                ResponseItem::new_uniqueness(
969                    "passport".into(),
970                    3,
971                    ZeroKnowledgeProof::default(),
972                    Nullifier::from(test_field_element(1003)),
973                    1_735_689_600,
974                ),
975            ],
976        };
977        let err = request.validate_response(&unexpected).unwrap_err();
978        assert!(matches!(
979            err,
980            ValidationError::UnexpectedCredential(ref id) if id == "passport"
981        ));
982
983        let duplicate = ProofResponse {
984            id: "req_1".into(),
985            version: RequestVersion::V1,
986            session_id: None,
987            error: None,
988            responses: vec![
989                ResponseItem::new_uniqueness(
990                    "orb".into(),
991                    1,
992                    ZeroKnowledgeProof::default(),
993                    Nullifier::from(test_field_element(1001)),
994                    1_735_689_600,
995                ),
996                ResponseItem::new_uniqueness(
997                    "orb".into(),
998                    1,
999                    ZeroKnowledgeProof::default(),
1000                    Nullifier::from(test_field_element(1001)),
1001                    1_735_689_600,
1002                ),
1003            ],
1004        };
1005        let err = request.validate_response(&duplicate).unwrap_err();
1006        assert!(matches!(
1007            err,
1008            ValidationError::DuplicateCredential(ref id) if id == "orb"
1009        ));
1010    }
1011
1012    #[test]
1013    fn constraint_depth_enforced() {
1014        // Root all -> nested any -> nested all (depth 3) should be rejected
1015        let deep = ConstraintExpr::All {
1016            all: vec![ConstraintNode::Expr(ConstraintExpr::Any {
1017                any: vec![ConstraintNode::Expr(ConstraintExpr::All {
1018                    all: vec![ConstraintNode::Type("orb".into())],
1019                })],
1020            })],
1021        };
1022
1023        let request = ProofRequest {
1024            id: "req_2".into(),
1025            version: RequestVersion::V1,
1026            created_at: 1_735_689_600,
1027            expires_at: 1_735_689_600,
1028            rp_id: RpId::new(1),
1029            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1030            session_id: None,
1031            action: Some(test_field_element(1)),
1032            signature: test_signature(),
1033            nonce: test_nonce(),
1034            requests: vec![RequestItem {
1035                identifier: "orb".into(),
1036                issuer_schema_id: 1,
1037                signal: None,
1038                genesis_issued_at_min: None,
1039                expires_at_min: None,
1040            }],
1041            constraints: Some(deep),
1042        };
1043
1044        let response = ProofResponse {
1045            id: "req_2".into(),
1046            version: RequestVersion::V1,
1047            session_id: None,
1048            error: None,
1049            responses: vec![ResponseItem::new_uniqueness(
1050                "orb".into(),
1051                1,
1052                ZeroKnowledgeProof::default(),
1053                Nullifier::from(test_field_element(1001)),
1054                1_735_689_600,
1055            )],
1056        };
1057
1058        let err = request.validate_response(&response).unwrap_err();
1059        assert!(matches!(err, ValidationError::ConstraintTooDeep));
1060    }
1061
1062    #[test]
1063    #[allow(clippy::too_many_lines)]
1064    fn constraint_node_limit_boundary_passes() {
1065        // Root All with: 1 Type + Any(4) + Any(4)
1066        // Node count = root(1) + type(1) + any(1+4) + any(1+4) = 12
1067
1068        let expr = ConstraintExpr::All {
1069            all: vec![
1070                ConstraintNode::Type("test_req_10".into()),
1071                ConstraintNode::Expr(ConstraintExpr::Any {
1072                    any: vec![
1073                        ConstraintNode::Type("test_req_11".into()),
1074                        ConstraintNode::Type("test_req_12".into()),
1075                        ConstraintNode::Type("test_req_13".into()),
1076                        ConstraintNode::Type("test_req_14".into()),
1077                    ],
1078                }),
1079                ConstraintNode::Expr(ConstraintExpr::Any {
1080                    any: vec![
1081                        ConstraintNode::Type("test_req_15".into()),
1082                        ConstraintNode::Type("test_req_16".into()),
1083                        ConstraintNode::Type("test_req_17".into()),
1084                        ConstraintNode::Type("test_req_18".into()),
1085                    ],
1086                }),
1087            ],
1088        };
1089
1090        let request = ProofRequest {
1091            id: "req_nodes_ok".into(),
1092            version: RequestVersion::V1,
1093            created_at: 1_735_689_600,
1094            expires_at: 1_735_689_600,
1095            rp_id: RpId::new(1),
1096            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1097            session_id: None,
1098            action: Some(test_field_element(5)),
1099            signature: test_signature(),
1100            nonce: test_nonce(),
1101            requests: vec![
1102                RequestItem {
1103                    identifier: "test_req_10".into(),
1104                    issuer_schema_id: 10,
1105                    signal: None,
1106                    genesis_issued_at_min: None,
1107                    expires_at_min: None,
1108                },
1109                RequestItem {
1110                    identifier: "test_req_11".into(),
1111                    issuer_schema_id: 11,
1112                    signal: None,
1113                    genesis_issued_at_min: None,
1114                    expires_at_min: None,
1115                },
1116                RequestItem {
1117                    identifier: "test_req_12".into(),
1118                    issuer_schema_id: 12,
1119                    signal: None,
1120                    genesis_issued_at_min: None,
1121                    expires_at_min: None,
1122                },
1123                RequestItem {
1124                    identifier: "test_req_13".into(),
1125                    issuer_schema_id: 13,
1126                    signal: None,
1127                    genesis_issued_at_min: None,
1128                    expires_at_min: None,
1129                },
1130                RequestItem {
1131                    identifier: "test_req_14".into(),
1132                    issuer_schema_id: 14,
1133                    signal: None,
1134                    genesis_issued_at_min: None,
1135                    expires_at_min: None,
1136                },
1137                RequestItem {
1138                    identifier: "test_req_15".into(),
1139                    issuer_schema_id: 15,
1140                    signal: None,
1141                    genesis_issued_at_min: None,
1142                    expires_at_min: None,
1143                },
1144                RequestItem {
1145                    identifier: "test_req_16".into(),
1146                    issuer_schema_id: 16,
1147                    signal: None,
1148                    genesis_issued_at_min: None,
1149                    expires_at_min: None,
1150                },
1151                RequestItem {
1152                    identifier: "test_req_17".into(),
1153                    issuer_schema_id: 17,
1154                    signal: None,
1155                    genesis_issued_at_min: None,
1156                    expires_at_min: None,
1157                },
1158                RequestItem {
1159                    identifier: "test_req_18".into(),
1160                    issuer_schema_id: 18,
1161                    signal: None,
1162                    genesis_issued_at_min: None,
1163                    expires_at_min: None,
1164                },
1165            ],
1166            constraints: Some(expr),
1167        };
1168
1169        // Provide just enough to satisfy both any-groups and the single type
1170        let response = ProofResponse {
1171            id: "req_nodes_ok".into(),
1172            version: RequestVersion::V1,
1173            session_id: None,
1174            error: None,
1175            responses: vec![
1176                ResponseItem::new_uniqueness(
1177                    "test_req_10".into(),
1178                    10,
1179                    ZeroKnowledgeProof::default(),
1180                    Nullifier::from(test_field_element(1010)),
1181                    1_735_689_600,
1182                ),
1183                ResponseItem::new_uniqueness(
1184                    "test_req_11".into(),
1185                    11,
1186                    ZeroKnowledgeProof::default(),
1187                    Nullifier::from(test_field_element(1011)),
1188                    1_735_689_600,
1189                ),
1190                ResponseItem::new_uniqueness(
1191                    "test_req_15".into(),
1192                    15,
1193                    ZeroKnowledgeProof::default(),
1194                    Nullifier::from(test_field_element(1015)),
1195                    1_735_689_600,
1196                ),
1197            ],
1198        };
1199
1200        // Should not exceed size and should validate OK
1201        assert!(request.validate_response(&response).is_ok());
1202    }
1203
1204    #[test]
1205    #[allow(clippy::too_many_lines)]
1206    fn constraint_node_limit_exceeded_fails() {
1207        // Root All with: 1 Type + Any(4) + Any(5)
1208        // Node count = root(1) + type(1) + any(1+4) + any(1+5) = 13 (> 12)
1209        let expr = ConstraintExpr::All {
1210            all: vec![
1211                ConstraintNode::Type("t0".into()),
1212                ConstraintNode::Expr(ConstraintExpr::Any {
1213                    any: vec![
1214                        ConstraintNode::Type("t1".into()),
1215                        ConstraintNode::Type("t2".into()),
1216                        ConstraintNode::Type("t3".into()),
1217                        ConstraintNode::Type("t4".into()),
1218                    ],
1219                }),
1220                ConstraintNode::Expr(ConstraintExpr::Any {
1221                    any: vec![
1222                        ConstraintNode::Type("t5".into()),
1223                        ConstraintNode::Type("t6".into()),
1224                        ConstraintNode::Type("t7".into()),
1225                        ConstraintNode::Type("t8".into()),
1226                        ConstraintNode::Type("t9".into()),
1227                    ],
1228                }),
1229            ],
1230        };
1231
1232        let request = ProofRequest {
1233            id: "req_nodes_too_many".into(),
1234            version: RequestVersion::V1,
1235            created_at: 1_735_689_600,
1236            expires_at: 1_735_689_600,
1237            rp_id: RpId::new(1),
1238            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1239            session_id: None,
1240            action: Some(test_field_element(1)),
1241            signature: test_signature(),
1242            nonce: test_nonce(),
1243            requests: vec![
1244                RequestItem {
1245                    identifier: "test_req_20".into(),
1246                    issuer_schema_id: 20,
1247                    signal: None,
1248                    genesis_issued_at_min: None,
1249                    expires_at_min: None,
1250                },
1251                RequestItem {
1252                    identifier: "test_req_21".into(),
1253                    issuer_schema_id: 21,
1254                    signal: None,
1255                    genesis_issued_at_min: None,
1256                    expires_at_min: None,
1257                },
1258                RequestItem {
1259                    identifier: "test_req_22".into(),
1260                    issuer_schema_id: 22,
1261                    signal: None,
1262                    genesis_issued_at_min: None,
1263                    expires_at_min: None,
1264                },
1265                RequestItem {
1266                    identifier: "test_req_23".into(),
1267                    issuer_schema_id: 23,
1268                    signal: None,
1269                    genesis_issued_at_min: None,
1270                    expires_at_min: None,
1271                },
1272                RequestItem {
1273                    identifier: "test_req_24".into(),
1274                    issuer_schema_id: 24,
1275                    signal: None,
1276                    genesis_issued_at_min: None,
1277                    expires_at_min: None,
1278                },
1279                RequestItem {
1280                    identifier: "test_req_25".into(),
1281                    issuer_schema_id: 25,
1282                    signal: None,
1283                    genesis_issued_at_min: None,
1284                    expires_at_min: None,
1285                },
1286                RequestItem {
1287                    identifier: "test_req_26".into(),
1288                    issuer_schema_id: 26,
1289                    signal: None,
1290                    genesis_issued_at_min: None,
1291                    expires_at_min: None,
1292                },
1293                RequestItem {
1294                    identifier: "test_req_27".into(),
1295                    issuer_schema_id: 27,
1296                    signal: None,
1297                    genesis_issued_at_min: None,
1298                    expires_at_min: None,
1299                },
1300                RequestItem {
1301                    identifier: "test_req_28".into(),
1302                    issuer_schema_id: 28,
1303                    signal: None,
1304                    genesis_issued_at_min: None,
1305                    expires_at_min: None,
1306                },
1307                RequestItem {
1308                    identifier: "test_req_29".into(),
1309                    issuer_schema_id: 29,
1310                    signal: None,
1311                    genesis_issued_at_min: None,
1312                    expires_at_min: None,
1313                },
1314            ],
1315            constraints: Some(expr),
1316        };
1317
1318        // Response content is irrelevant; validation should fail before evaluation due to size
1319        let response = ProofResponse {
1320            id: "req_nodes_too_many".into(),
1321            version: RequestVersion::V1,
1322            session_id: None,
1323            error: None,
1324            responses: vec![ResponseItem::new_uniqueness(
1325                "test_req_20".into(),
1326                20,
1327                ZeroKnowledgeProof::default(),
1328                Nullifier::from(test_field_element(1020)),
1329                1_735_689_600,
1330            )],
1331        };
1332
1333        let err = request.validate_response(&response).unwrap_err();
1334        assert!(matches!(err, ValidationError::ConstraintTooLarge));
1335    }
1336
1337    #[test]
1338    fn request_single_credential_parse_and_validate() {
1339        let req = ProofRequest {
1340            id: "req_18c0f7f03e7d".into(),
1341            version: RequestVersion::V1,
1342            created_at: 1_725_381_192,
1343            expires_at: 1_725_381_492,
1344            rp_id: RpId::new(1),
1345            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1346            session_id: Some(test_field_element(55)),
1347            action: Some(test_field_element(1)),
1348            signature: test_signature(),
1349            nonce: test_nonce(),
1350            requests: vec![RequestItem {
1351                identifier: "test_req_1".into(),
1352                issuer_schema_id: 1,
1353                signal: Some("abcd-efgh-ijkl".into()),
1354                genesis_issued_at_min: Some(1_725_381_192),
1355                expires_at_min: None,
1356            }],
1357            constraints: None,
1358        };
1359
1360        assert_eq!(req.id, "req_18c0f7f03e7d");
1361        assert_eq!(req.requests.len(), 1);
1362
1363        // Build matching successful response (session proof requires session_id and session_nullifier)
1364        let resp = ProofResponse {
1365            id: req.id.clone(),
1366            version: RequestVersion::V1,
1367            session_id: Some(test_field_element(55)),
1368            error: None,
1369            responses: vec![ResponseItem::new_session(
1370                "test_req_1".into(),
1371                1,
1372                ZeroKnowledgeProof::default(),
1373                SessionNullifier::new(test_field_element(1001), test_field_element(1)),
1374                1_725_381_192,
1375            )],
1376        };
1377        assert!(req.validate_response(&resp).is_ok());
1378    }
1379
1380    #[test]
1381    fn request_multiple_credentials_all_constraint_and_missing() {
1382        let req = ProofRequest {
1383            id: "req_18c0f7f03e7d".into(),
1384            version: RequestVersion::V1,
1385            created_at: 1_725_381_192,
1386            expires_at: 1_725_381_492,
1387            rp_id: RpId::new(1),
1388            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1389            session_id: None,
1390            action: Some(test_field_element(1)),
1391            signature: test_signature(),
1392            nonce: test_nonce(),
1393            requests: vec![
1394                RequestItem {
1395                    identifier: "test_req_1".into(),
1396                    issuer_schema_id: 1,
1397                    signal: Some("abcd-efgh-ijkl".into()),
1398                    genesis_issued_at_min: Some(1_725_381_192),
1399                    expires_at_min: None,
1400                },
1401                RequestItem {
1402                    identifier: "test_req_2".into(),
1403                    issuer_schema_id: 2,
1404                    signal: Some("abcd-efgh-ijkl".into()),
1405                    genesis_issued_at_min: Some(1_725_381_192),
1406                    expires_at_min: None,
1407                },
1408            ],
1409            constraints: Some(ConstraintExpr::All {
1410                all: vec![
1411                    ConstraintNode::Type("test_req_1".into()),
1412                    ConstraintNode::Type("test_req_2".into()),
1413                ],
1414            }),
1415        };
1416
1417        // Build response that fails constraints (test_req_1 is missing)
1418        let resp = ProofResponse {
1419            id: req.id.clone(),
1420            version: RequestVersion::V1,
1421            session_id: None,
1422            error: None,
1423            responses: vec![ResponseItem::new_uniqueness(
1424                "test_req_2".into(),
1425                2,
1426                ZeroKnowledgeProof::default(),
1427                Nullifier::from(test_field_element(1001)),
1428                1_725_381_192,
1429            )],
1430        };
1431
1432        let err = req.validate_response(&resp).unwrap_err();
1433        assert!(matches!(err, ValidationError::ConstraintNotSatisfied));
1434    }
1435
1436    #[test]
1437    fn request_more_complex_constraints_nested_success() {
1438        let req = ProofRequest {
1439            id: "req_18c0f7f03e7d".into(),
1440            version: RequestVersion::V1,
1441            created_at: 1_725_381_192,
1442            expires_at: 1_725_381_492,
1443            rp_id: RpId::new(1),
1444            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1445            session_id: None,
1446            action: Some(test_field_element(1)),
1447            signature: test_signature(),
1448            nonce: test_nonce(),
1449            requests: vec![
1450                RequestItem {
1451                    identifier: "test_req_1".into(),
1452                    issuer_schema_id: 1,
1453                    signal: Some("abcd-efgh-ijkl".into()),
1454                    genesis_issued_at_min: None,
1455                    expires_at_min: None,
1456                },
1457                RequestItem {
1458                    identifier: "test_req_2".into(),
1459                    issuer_schema_id: 2,
1460                    signal: Some("mnop-qrst-uvwx".into()),
1461                    genesis_issued_at_min: None,
1462                    expires_at_min: None,
1463                },
1464                RequestItem {
1465                    identifier: "test_req_3".into(),
1466                    issuer_schema_id: 3,
1467                    signal: Some("abcd-efgh-ijkl".into()),
1468                    genesis_issued_at_min: None,
1469                    expires_at_min: None,
1470                },
1471            ],
1472            constraints: Some(ConstraintExpr::All {
1473                all: vec![
1474                    ConstraintNode::Type("test_req_3".into()),
1475                    ConstraintNode::Expr(ConstraintExpr::Any {
1476                        any: vec![
1477                            ConstraintNode::Type("test_req_1".into()),
1478                            ConstraintNode::Type("test_req_2".into()),
1479                        ],
1480                    }),
1481                ],
1482            }),
1483        };
1484
1485        // Satisfy nested any with 0x1 + 0x3
1486        let resp = ProofResponse {
1487            id: req.id.clone(),
1488            version: RequestVersion::V1,
1489            session_id: None,
1490            error: None,
1491            responses: vec![
1492                ResponseItem::new_uniqueness(
1493                    "test_req_3".into(),
1494                    3,
1495                    ZeroKnowledgeProof::default(),
1496                    Nullifier::from(test_field_element(1001)),
1497                    1_725_381_192,
1498                ),
1499                ResponseItem::new_uniqueness(
1500                    "test_req_1".into(),
1501                    1,
1502                    ZeroKnowledgeProof::default(),
1503                    Nullifier::from(test_field_element(1002)),
1504                    1_725_381_192,
1505                ),
1506            ],
1507        };
1508
1509        assert!(req.validate_response(&resp).is_ok());
1510    }
1511
1512    #[test]
1513    fn request_validate_response_with_enumerate() {
1514        let req = ProofRequest {
1515            id: "req_enum".into(),
1516            version: RequestVersion::V1,
1517            created_at: 1_725_381_192,
1518            expires_at: 1_725_381_492,
1519            rp_id: RpId::new(1),
1520            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1521            session_id: None,
1522            action: Some(test_field_element(1)),
1523            signature: test_signature(),
1524            nonce: test_nonce(),
1525            requests: vec![
1526                RequestItem {
1527                    identifier: "passport".into(),
1528                    issuer_schema_id: 2,
1529                    signal: None,
1530                    genesis_issued_at_min: None,
1531                    expires_at_min: None,
1532                },
1533                RequestItem {
1534                    identifier: "national_id".into(),
1535                    issuer_schema_id: 3,
1536                    signal: None,
1537                    genesis_issued_at_min: None,
1538                    expires_at_min: None,
1539                },
1540            ],
1541            constraints: Some(ConstraintExpr::Enumerate {
1542                enumerate: vec![
1543                    ConstraintNode::Type("passport".into()),
1544                    ConstraintNode::Type("national_id".into()),
1545                ],
1546            }),
1547        };
1548
1549        // Satisfies enumerate with passport
1550        let ok_resp = ProofResponse {
1551            id: req.id.clone(),
1552            version: RequestVersion::V1,
1553            session_id: None,
1554            error: None,
1555            responses: vec![ResponseItem::new_uniqueness(
1556                "passport".into(),
1557                2,
1558                ZeroKnowledgeProof::default(),
1559                Nullifier::from(test_field_element(2002)),
1560                1_725_381_192,
1561            )],
1562        };
1563        assert!(req.validate_response(&ok_resp).is_ok());
1564
1565        // Fails enumerate because none of the enumerate candidates are present
1566        let fail_resp = ProofResponse {
1567            id: req.id.clone(),
1568            version: RequestVersion::V1,
1569            session_id: None,
1570            error: None,
1571            responses: vec![],
1572        };
1573        let err = req.validate_response(&fail_resp).unwrap_err();
1574        assert!(matches!(err, ValidationError::ConstraintNotSatisfied));
1575    }
1576
1577    #[test]
1578    fn request_json_parse() {
1579        // Happy path with signal present
1580        let with_signal = r#"{
1581  "id": "req_abc123",
1582  "version": 1,
1583  "created_at": 1725381192,
1584  "expires_at": 1725381492,
1585  "rp_id": "rp_0000000000000001",
1586  "oprf_key_id": "0x1",
1587  "session_id": null,
1588  "action": "0x000000000000000000000000000000000000000000000000000000000000002a",
1589  "signature": "0xa1fd06f0d8ceb541f6096fe2e865063eac1ff085c9d2bac2eedcc9ed03804bfc18d956b38c5ac3a8f7e71fde43deff3bda254d369c699f3c7a3f8e6b8477a5f51c",
1590  "nonce": "0x0000000000000000000000000000000000000000000000000000000000000001",
1591  "proof_requests": [
1592    {
1593      "identifier": "orb",
1594      "issuer_schema_id": 1,
1595      "signal": "0xdeadbeef",
1596      "genesis_issued_at_min": 1725381192,
1597      "expires_at_min": 1725381492
1598    }
1599  ]
1600}"#;
1601
1602        let req = ProofRequest::from_json(with_signal).expect("parse with signal");
1603        assert_eq!(req.id, "req_abc123");
1604        assert_eq!(req.requests.len(), 1);
1605        assert_eq!(req.requests[0].signal, Some(b"\xde\xad\xbe\xef".to_vec()));
1606        assert_eq!(req.requests[0].genesis_issued_at_min, Some(1_725_381_192));
1607        assert_eq!(req.requests[0].expires_at_min, Some(1_725_381_492));
1608
1609        let without_signal = r#"{
1610  "id": "req_abc123",
1611  "version": 1,
1612  "created_at": 1725381192,
1613  "expires_at": 1725381492,
1614  "rp_id": "rp_0000000000000001",
1615  "oprf_key_id": "0x1",
1616  "session_id": null,
1617  "action": "0x000000000000000000000000000000000000000000000000000000000000002a",
1618  "signature": "0xa1fd06f0d8ceb541f6096fe2e865063eac1ff085c9d2bac2eedcc9ed03804bfc18d956b38c5ac3a8f7e71fde43deff3bda254d369c699f3c7a3f8e6b8477a5f51c",
1619  "nonce": "0x0000000000000000000000000000000000000000000000000000000000000001",
1620  "proof_requests": [
1621    {
1622      "identifier": "orb",
1623      "issuer_schema_id": 1
1624    }
1625  ]
1626}"#;
1627
1628        let req = ProofRequest::from_json(without_signal).expect("parse without signal");
1629        assert!(req.requests[0].signal.is_none());
1630        assert_eq!(req.requests[0].signal_hash(), FieldElement::ZERO);
1631    }
1632
1633    #[test]
1634    fn response_json_parse() {
1635        // Success OK - Uniqueness nullifier (simple FieldElement)
1636        let ok_json = r#"{
1637  "id": "req_18c0f7f03e7d",
1638  "version": 1,
1639  "responses": [
1640    {
1641      "identifier": "orb",
1642      "issuer_schema_id": 100,
1643      "proof": "00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000",
1644      "nullifier": "nil_00000000000000000000000000000000000000000000000000000000000003e9",
1645      "expires_at_min": 1725381192
1646    }
1647  ]
1648}"#;
1649
1650        let ok = ProofResponse::from_json(ok_json).unwrap();
1651        assert_eq!(ok.successful_credentials(), vec![100]);
1652        assert!(ok.responses[0].is_uniqueness());
1653
1654        // Canonical session nullifier representation (prefixed hex bytes).
1655        let canonical_session_nullifier = serde_json::to_string(&SessionNullifier::new(
1656            test_field_element(1001),
1657            test_field_element(42),
1658        ))
1659        .unwrap();
1660        let sess_json_canonical = format!(
1661            r#"{{
1662  "id": "req_18c0f7f03e7d",
1663  "version": 1,
1664  "session_id": "0x00000000000000000000000000000000000000000000000000000000000003ea",
1665  "responses": [
1666    {{
1667      "identifier": "orb",
1668      "issuer_schema_id": 100,
1669      "proof": "00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000",
1670      "session_nullifier": {canonical_session_nullifier},
1671      "expires_at_min": 1725381192
1672    }}
1673  ]
1674}}"#
1675        );
1676        let sess_canonical = ProofResponse::from_json(&sess_json_canonical).unwrap();
1677        assert_eq!(sess_canonical.successful_credentials(), vec![100]);
1678        assert!(sess_canonical.responses[0].is_session());
1679    }
1680    /// Test duplicate detection by creating a serialized `ProofRequest` with duplicates
1681    /// and then trying to parse it with `from_json` which should detect the duplicates
1682    #[test]
1683    fn request_rejects_duplicate_issuer_schema_ids_on_parse() {
1684        let req = ProofRequest {
1685            id: "req_dup".into(),
1686            version: RequestVersion::V1,
1687            created_at: 1_725_381_192,
1688            expires_at: 1_725_381_492,
1689            rp_id: RpId::new(1),
1690            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1691            session_id: None,
1692            action: Some(test_field_element(5)),
1693            signature: test_signature(),
1694            nonce: test_nonce(),
1695            requests: vec![
1696                RequestItem {
1697                    identifier: "test_req_1".into(),
1698                    issuer_schema_id: 1,
1699                    signal: None,
1700                    genesis_issued_at_min: None,
1701                    expires_at_min: None,
1702                },
1703                RequestItem {
1704                    identifier: "test_req_2".into(),
1705                    issuer_schema_id: 1, // Duplicate!
1706                    signal: None,
1707                    genesis_issued_at_min: None,
1708                    expires_at_min: None,
1709                },
1710            ],
1711            constraints: None,
1712        };
1713
1714        // Serialize then deserialize to trigger the duplicate check in from_json
1715        let json = req.to_json().unwrap();
1716        let err = ProofRequest::from_json(&json).unwrap_err();
1717        let msg = err.to_string();
1718        assert!(
1719            msg.contains("duplicate issuer schema id"),
1720            "Expected error message to contain 'duplicate issuer schema id', got: {msg}"
1721        );
1722    }
1723
1724    #[test]
1725    fn response_with_error_has_empty_responses_and_fails_validation() {
1726        let request = ProofRequest {
1727            id: "req_error".into(),
1728            version: RequestVersion::V1,
1729            created_at: 1_735_689_600,
1730            expires_at: 1_735_689_600,
1731            rp_id: RpId::new(1),
1732            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1733            session_id: None,
1734            action: Some(FieldElement::ZERO),
1735            signature: test_signature(),
1736            nonce: test_nonce(),
1737            requests: vec![RequestItem {
1738                identifier: "orb".into(),
1739                issuer_schema_id: 1,
1740                signal: None,
1741                genesis_issued_at_min: None,
1742                expires_at_min: None,
1743            }],
1744            constraints: None,
1745        };
1746
1747        // Response with error should have empty responses array
1748        let error_response = ProofResponse {
1749            id: "req_error".into(),
1750            version: RequestVersion::V1,
1751            session_id: None,
1752            error: Some("credential_not_available".into()),
1753            responses: vec![], // Empty when error is present
1754        };
1755
1756        // Validation should fail with ProofGenerationFailed
1757        let err = request.validate_response(&error_response).unwrap_err();
1758        assert!(matches!(err, ValidationError::ProofGenerationFailed(_)));
1759        if let ValidationError::ProofGenerationFailed(msg) = err {
1760            assert_eq!(msg, "credential_not_available");
1761        }
1762
1763        // successful_credentials should return empty vec when error is present
1764        assert_eq!(error_response.successful_credentials(), Vec::<u64>::new());
1765
1766        // constraints_satisfied should return false when error is present
1767        let expr = ConstraintExpr::All {
1768            all: vec![ConstraintNode::Type("orb".into())],
1769        };
1770        assert!(!error_response.constraints_satisfied(&expr));
1771    }
1772
1773    #[test]
1774    fn response_error_json_parse() {
1775        // Error response JSON
1776        let error_json = r#"{
1777  "id": "req_error",
1778  "version": 1,
1779  "error": "credential_not_available",
1780  "responses": []
1781}"#;
1782
1783        let error_resp = ProofResponse::from_json(error_json).unwrap();
1784        assert_eq!(error_resp.error, Some("credential_not_available".into()));
1785        assert_eq!(error_resp.responses.len(), 0);
1786        assert_eq!(error_resp.successful_credentials(), Vec::<u64>::new());
1787    }
1788
1789    #[test]
1790    fn credentials_to_prove_none_constraints_requires_all_and_drops_if_missing() {
1791        let req = ProofRequest {
1792            id: "req".into(),
1793            version: RequestVersion::V1,
1794            created_at: 1_735_689_600,
1795            expires_at: 1_735_689_600, // 2025-01-01 00:00:00 UTC
1796            rp_id: RpId::new(1),
1797            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1798            session_id: None,
1799            action: Some(test_field_element(5)),
1800            signature: test_signature(),
1801            nonce: test_nonce(),
1802            requests: vec![
1803                RequestItem {
1804                    identifier: "orb".into(),
1805                    issuer_schema_id: 100,
1806                    signal: None,
1807                    genesis_issued_at_min: None,
1808                    expires_at_min: None,
1809                },
1810                RequestItem {
1811                    identifier: "passport".into(),
1812                    issuer_schema_id: 101,
1813                    signal: None,
1814                    genesis_issued_at_min: None,
1815                    expires_at_min: None,
1816                },
1817            ],
1818            constraints: None,
1819        };
1820
1821        let available_ok: HashSet<u64> = [100, 101].into_iter().collect();
1822        let sel_ok = req.credentials_to_prove(&available_ok).unwrap();
1823        assert_eq!(sel_ok.len(), 2);
1824        assert_eq!(sel_ok[0].issuer_schema_id, 100);
1825        assert_eq!(sel_ok[1].issuer_schema_id, 101);
1826
1827        let available_missing: HashSet<u64> = std::iter::once(100).collect();
1828        assert!(req.credentials_to_prove(&available_missing).is_none());
1829    }
1830
1831    #[test]
1832    fn credentials_to_prove_with_constraints_all_and_any() {
1833        // proof_requests: orb, passport, national-id
1834        let orb_id = 100;
1835        let passport_id = 101;
1836        let national_id = 102;
1837
1838        let req = ProofRequest {
1839            id: "req".into(),
1840            version: RequestVersion::V1,
1841            created_at: 1_735_689_600,
1842            expires_at: 1_735_689_600, // 2025-01-01 00:00:00 UTC
1843            rp_id: RpId::new(1),
1844            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1845            session_id: None,
1846            action: Some(test_field_element(1)),
1847            signature: test_signature(),
1848            nonce: test_nonce(),
1849            requests: vec![
1850                RequestItem {
1851                    identifier: "orb".into(),
1852                    issuer_schema_id: orb_id,
1853                    signal: None,
1854                    genesis_issued_at_min: None,
1855                    expires_at_min: None,
1856                },
1857                RequestItem {
1858                    identifier: "passport".into(),
1859                    issuer_schema_id: passport_id,
1860                    signal: None,
1861                    genesis_issued_at_min: None,
1862                    expires_at_min: None,
1863                },
1864                RequestItem {
1865                    identifier: "national_id".into(),
1866                    issuer_schema_id: national_id,
1867                    signal: None,
1868                    genesis_issued_at_min: None,
1869                    expires_at_min: None,
1870                },
1871            ],
1872            constraints: Some(ConstraintExpr::All {
1873                all: vec![
1874                    ConstraintNode::Type("orb".into()),
1875                    ConstraintNode::Expr(ConstraintExpr::Any {
1876                        any: vec![
1877                            ConstraintNode::Type("passport".into()),
1878                            ConstraintNode::Type("national_id".into()),
1879                        ],
1880                    }),
1881                ],
1882            }),
1883        };
1884
1885        // Available has orb + passport → should pick [orb, passport]
1886        let available1: HashSet<u64> = [orb_id, passport_id].into_iter().collect();
1887        let sel1 = req.credentials_to_prove(&available1).unwrap();
1888        assert_eq!(sel1.len(), 2);
1889        assert_eq!(sel1[0].issuer_schema_id, orb_id);
1890        assert_eq!(sel1[1].issuer_schema_id, passport_id);
1891
1892        // Available has orb + national-id → should pick [orb, national-id]
1893        let available2: HashSet<u64> = [orb_id, national_id].into_iter().collect();
1894        let sel2 = req.credentials_to_prove(&available2).unwrap();
1895        assert_eq!(sel2.len(), 2);
1896        assert_eq!(sel2[0].issuer_schema_id, orb_id);
1897        assert_eq!(sel2[1].issuer_schema_id, national_id);
1898
1899        // Missing orb → cannot satisfy "all" → None
1900        let available3: HashSet<u64> = std::iter::once(passport_id).collect();
1901        assert!(req.credentials_to_prove(&available3).is_none());
1902    }
1903
1904    #[test]
1905    fn credentials_to_prove_with_constraints_enumerate() {
1906        let orb_id = 100;
1907        let passport_id = 101;
1908        let national_id = 102;
1909
1910        let req = ProofRequest {
1911            id: "req".into(),
1912            version: RequestVersion::V1,
1913            created_at: 1_735_689_600,
1914            expires_at: 1_735_689_600,
1915            rp_id: RpId::new(1),
1916            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1917            session_id: None,
1918            action: Some(test_field_element(1)),
1919            signature: test_signature(),
1920            nonce: test_nonce(),
1921            requests: vec![
1922                RequestItem {
1923                    identifier: "orb".into(),
1924                    issuer_schema_id: orb_id,
1925                    signal: None,
1926                    genesis_issued_at_min: None,
1927                    expires_at_min: None,
1928                },
1929                RequestItem {
1930                    identifier: "passport".into(),
1931                    issuer_schema_id: passport_id,
1932                    signal: None,
1933                    genesis_issued_at_min: None,
1934                    expires_at_min: None,
1935                },
1936                RequestItem {
1937                    identifier: "national_id".into(),
1938                    issuer_schema_id: national_id,
1939                    signal: None,
1940                    genesis_issued_at_min: None,
1941                    expires_at_min: None,
1942                },
1943            ],
1944            constraints: Some(ConstraintExpr::Enumerate {
1945                enumerate: vec![
1946                    ConstraintNode::Type("passport".into()),
1947                    ConstraintNode::Type("national_id".into()),
1948                ],
1949            }),
1950        };
1951
1952        // One of enumerate candidates available -> one selected
1953        let available1: HashSet<u64> = [orb_id, passport_id].into_iter().collect();
1954        let sel1 = req.credentials_to_prove(&available1).unwrap();
1955        assert_eq!(sel1.len(), 1);
1956        assert_eq!(sel1[0].issuer_schema_id, passport_id);
1957
1958        // Both enumerate candidates available -> both selected in request order
1959        let available2: HashSet<u64> = [orb_id, passport_id, national_id].into_iter().collect();
1960        let sel2 = req.credentials_to_prove(&available2).unwrap();
1961        assert_eq!(sel2.len(), 2);
1962        assert_eq!(sel2[0].issuer_schema_id, passport_id);
1963        assert_eq!(sel2[1].issuer_schema_id, national_id);
1964
1965        // None of enumerate candidates available -> cannot satisfy
1966        let available3: HashSet<u64> = std::iter::once(orb_id).collect();
1967        assert!(req.credentials_to_prove(&available3).is_none());
1968    }
1969
1970    #[test]
1971    fn credentials_to_prove_with_constraints_all_and_enumerate() {
1972        let orb_id = 100;
1973        let passport_id = 101;
1974        let national_id = 102;
1975
1976        let req = ProofRequest {
1977            id: "req".into(),
1978            version: RequestVersion::V1,
1979            created_at: 1_735_689_600,
1980            expires_at: 1_735_689_600,
1981            rp_id: RpId::new(1),
1982            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1983            session_id: None,
1984            action: Some(test_field_element(1)),
1985            signature: test_signature(),
1986            nonce: test_nonce(),
1987            requests: vec![
1988                RequestItem {
1989                    identifier: "orb".into(),
1990                    issuer_schema_id: orb_id,
1991                    signal: None,
1992                    genesis_issued_at_min: None,
1993                    expires_at_min: None,
1994                },
1995                RequestItem {
1996                    identifier: "passport".into(),
1997                    issuer_schema_id: passport_id,
1998                    signal: None,
1999                    genesis_issued_at_min: None,
2000                    expires_at_min: None,
2001                },
2002                RequestItem {
2003                    identifier: "national_id".into(),
2004                    issuer_schema_id: national_id,
2005                    signal: None,
2006                    genesis_issued_at_min: None,
2007                    expires_at_min: None,
2008                },
2009            ],
2010            constraints: Some(ConstraintExpr::All {
2011                all: vec![
2012                    ConstraintNode::Type("orb".into()),
2013                    ConstraintNode::Expr(ConstraintExpr::Enumerate {
2014                        enumerate: vec![
2015                            ConstraintNode::Type("passport".into()),
2016                            ConstraintNode::Type("national_id".into()),
2017                        ],
2018                    }),
2019                ],
2020            }),
2021        };
2022
2023        // orb + passport -> select both
2024        let available1: HashSet<u64> = [orb_id, passport_id].into_iter().collect();
2025        let sel1 = req.credentials_to_prove(&available1).unwrap();
2026        assert_eq!(sel1.len(), 2);
2027        assert_eq!(sel1[0].issuer_schema_id, orb_id);
2028        assert_eq!(sel1[1].issuer_schema_id, passport_id);
2029
2030        // orb + passport + national_id -> select all three
2031        let available2: HashSet<u64> = [orb_id, passport_id, national_id].into_iter().collect();
2032        let sel2 = req.credentials_to_prove(&available2).unwrap();
2033        assert_eq!(sel2.len(), 3);
2034        assert_eq!(sel2[0].issuer_schema_id, orb_id);
2035        assert_eq!(sel2[1].issuer_schema_id, passport_id);
2036        assert_eq!(sel2[2].issuer_schema_id, national_id);
2037
2038        // orb alone -> enumerate branch unsatisfied
2039        let available3: HashSet<u64> = std::iter::once(orb_id).collect();
2040        assert!(req.credentials_to_prove(&available3).is_none());
2041    }
2042
2043    #[test]
2044    fn request_item_effective_expires_at_min_defaults_to_created_at() {
2045        let request_created_at = 1_735_689_600; // 2025-01-01 00:00:00 UTC
2046        let custom_expires_at = 1_735_862_400; // 2025-01-03 00:00:00 UTC
2047
2048        // When expires_at_min is None, should use request_created_at
2049        let item_with_none = RequestItem {
2050            identifier: "test".into(),
2051            issuer_schema_id: 100,
2052            signal: None,
2053            genesis_issued_at_min: None,
2054            expires_at_min: None,
2055        };
2056        assert_eq!(
2057            item_with_none.effective_expires_at_min(request_created_at),
2058            request_created_at,
2059            "When expires_at_min is None, should default to request created_at"
2060        );
2061
2062        // When expires_at_min is Some, should use that value
2063        let item_with_custom = RequestItem {
2064            identifier: "test".into(),
2065            issuer_schema_id: 100,
2066            signal: None,
2067            genesis_issued_at_min: None,
2068            expires_at_min: Some(custom_expires_at),
2069        };
2070        assert_eq!(
2071            item_with_custom.effective_expires_at_min(request_created_at),
2072            custom_expires_at,
2073            "When expires_at_min is Some, should use that explicit value"
2074        );
2075    }
2076
2077    #[test]
2078    fn validate_response_checks_expires_at_min_matches() {
2079        let request_created_at = 1_735_689_600; // 2025-01-01 00:00:00 UTC
2080        let custom_expires_at = 1_735_862_400; // 2025-01-03 00:00:00 UTC
2081
2082        // Request with one item that has no explicit expires_at_min (defaults to created_at)
2083        // and one with an explicit expires_at_min
2084        let request = ProofRequest {
2085            id: "req_expires_test".into(),
2086            version: RequestVersion::V1,
2087            created_at: request_created_at,
2088            expires_at: request_created_at + 300,
2089            rp_id: RpId::new(1),
2090            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2091            session_id: None,
2092            action: Some(test_field_element(1)),
2093            signature: test_signature(),
2094            nonce: test_nonce(),
2095            requests: vec![
2096                RequestItem {
2097                    identifier: "orb".into(),
2098                    issuer_schema_id: 100,
2099                    signal: None,
2100                    genesis_issued_at_min: None,
2101                    expires_at_min: None, // Should default to request_created_at
2102                },
2103                RequestItem {
2104                    identifier: "document".into(),
2105                    issuer_schema_id: 101,
2106                    signal: None,
2107                    genesis_issued_at_min: None,
2108                    expires_at_min: Some(custom_expires_at), // Explicit value
2109                },
2110            ],
2111            constraints: None,
2112        };
2113
2114        // Valid response with matching expires_at_min values
2115        let valid_response = ProofResponse {
2116            id: "req_expires_test".into(),
2117            version: RequestVersion::V1,
2118            session_id: None,
2119            error: None,
2120            responses: vec![
2121                ResponseItem::new_uniqueness(
2122                    "orb".into(),
2123                    100,
2124                    ZeroKnowledgeProof::default(),
2125                    Nullifier::from(test_field_element(1001)),
2126                    request_created_at, // Matches default
2127                ),
2128                ResponseItem::new_uniqueness(
2129                    "document".into(),
2130                    101,
2131                    ZeroKnowledgeProof::default(),
2132                    Nullifier::from(test_field_element(1002)),
2133                    custom_expires_at, // Matches explicit value
2134                ),
2135            ],
2136        };
2137        assert!(request.validate_response(&valid_response).is_ok());
2138
2139        // Invalid response with mismatched expires_at_min for first item
2140        let invalid_response_1 = ProofResponse {
2141            id: "req_expires_test".into(),
2142            version: RequestVersion::V1,
2143            session_id: None,
2144            error: None,
2145            responses: vec![
2146                ResponseItem::new_uniqueness(
2147                    "orb".into(),
2148                    100,
2149                    ZeroKnowledgeProof::default(),
2150                    Nullifier::from(test_field_element(1001)),
2151                    custom_expires_at, // Wrong! Should be request_created_at
2152                ),
2153                ResponseItem::new_uniqueness(
2154                    "document".into(),
2155                    101,
2156                    ZeroKnowledgeProof::default(),
2157                    Nullifier::from(test_field_element(1002)),
2158                    custom_expires_at,
2159                ),
2160            ],
2161        };
2162        let err1 = request.validate_response(&invalid_response_1).unwrap_err();
2163        assert!(matches!(
2164            err1,
2165            ValidationError::ExpiresAtMinMismatch(_, _, _)
2166        ));
2167        if let ValidationError::ExpiresAtMinMismatch(identifier, expected, got) = err1 {
2168            assert_eq!(identifier, "orb");
2169            assert_eq!(expected, request_created_at);
2170            assert_eq!(got, custom_expires_at);
2171        }
2172
2173        // Invalid response with mismatched expires_at_min for second item
2174        let invalid_response_2 = ProofResponse {
2175            id: "req_expires_test".into(),
2176            version: RequestVersion::V1,
2177            session_id: None,
2178            error: None,
2179            responses: vec![
2180                ResponseItem::new_uniqueness(
2181                    "orb".into(),
2182                    100,
2183                    ZeroKnowledgeProof::default(),
2184                    Nullifier::from(test_field_element(1001)),
2185                    request_created_at,
2186                ),
2187                ResponseItem::new_uniqueness(
2188                    "document".into(),
2189                    101,
2190                    ZeroKnowledgeProof::default(),
2191                    Nullifier::from(test_field_element(1002)),
2192                    request_created_at, // Wrong! Should be custom_expires_at
2193                ),
2194            ],
2195        };
2196        let err2 = request.validate_response(&invalid_response_2).unwrap_err();
2197        assert!(matches!(
2198            err2,
2199            ValidationError::ExpiresAtMinMismatch(_, _, _)
2200        ));
2201        if let ValidationError::ExpiresAtMinMismatch(identifier, expected, got) = err2 {
2202            assert_eq!(identifier, "document");
2203            assert_eq!(expected, custom_expires_at);
2204            assert_eq!(got, request_created_at);
2205        }
2206    }
2207
2208    #[test]
2209    fn computed_action_returns_explicit_action() {
2210        let action = test_field_element(42);
2211        let request = ProofRequest {
2212            id: "req".into(),
2213            version: RequestVersion::V1,
2214            created_at: 1_700_000_000,
2215            expires_at: 1_700_100_000,
2216            rp_id: RpId::new(1),
2217            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2218            session_id: None,
2219            action: Some(action),
2220            signature: test_signature(),
2221            nonce: test_nonce(),
2222            requests: vec![],
2223            constraints: None,
2224        };
2225        assert_eq!(request.computed_action(&mut rand::rngs::OsRng), action);
2226    }
2227
2228    #[test]
2229    fn computed_action_generates_random_when_none() {
2230        let request = ProofRequest {
2231            id: "req".into(),
2232            version: RequestVersion::V1,
2233            created_at: 1_700_000_000,
2234            expires_at: 1_700_100_000,
2235            rp_id: RpId::new(1),
2236            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2237            session_id: Some(test_field_element(99)),
2238            action: None,
2239            signature: test_signature(),
2240            nonce: test_nonce(),
2241            requests: vec![],
2242            constraints: None,
2243        };
2244
2245        let action1 = request.computed_action(&mut rand::rngs::OsRng);
2246        let action2 = request.computed_action(&mut rand::rngs::OsRng);
2247        // Each call generates a different random action
2248        assert_ne!(action1, action2);
2249    }
2250
2251    #[test]
2252    fn test_validate_response_requires_session_id_in_response() {
2253        // Request with session_id should require response to also have session_id
2254        let request = ProofRequest {
2255            id: "req_session".into(),
2256            version: RequestVersion::V1,
2257            created_at: 1_735_689_600,
2258            expires_at: 1_735_689_900,
2259            rp_id: RpId::new(1),
2260            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2261            session_id: Some(test_field_element(123)), // Session proof
2262            action: Some(test_field_element(42)),
2263            signature: test_signature(),
2264            nonce: test_nonce(),
2265            requests: vec![RequestItem {
2266                identifier: "orb".into(),
2267                issuer_schema_id: 1,
2268                signal: None,
2269                genesis_issued_at_min: None,
2270                expires_at_min: None,
2271            }],
2272            constraints: None,
2273        };
2274
2275        // Response without session_id should fail validation
2276        let response_missing_session_id = ProofResponse {
2277            id: "req_session".into(),
2278            version: RequestVersion::V1,
2279            session_id: None, // Missing!
2280            error: None,
2281            responses: vec![ResponseItem::new_session(
2282                "orb".into(),
2283                1,
2284                ZeroKnowledgeProof::default(),
2285                SessionNullifier::new(test_field_element(1001), test_field_element(42)),
2286                1_735_689_600,
2287            )],
2288        };
2289
2290        let err = request
2291            .validate_response(&response_missing_session_id)
2292            .unwrap_err();
2293        assert!(matches!(err, ValidationError::SessionIdMismatch));
2294    }
2295
2296    #[test]
2297    fn test_validate_response_requires_session_nullifier_for_session_proof() {
2298        // Request with session_id requires session_nullifier in each response item
2299        let request = ProofRequest {
2300            id: "req_session".into(),
2301            version: RequestVersion::V1,
2302            created_at: 1_735_689_600,
2303            expires_at: 1_735_689_900,
2304            rp_id: RpId::new(1),
2305            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2306            session_id: Some(test_field_element(123)), // Session proof
2307            action: Some(test_field_element(42)),
2308            signature: test_signature(),
2309            nonce: test_nonce(),
2310            requests: vec![RequestItem {
2311                identifier: "orb".into(),
2312                issuer_schema_id: 1,
2313                signal: None,
2314                genesis_issued_at_min: None,
2315                expires_at_min: None,
2316            }],
2317            constraints: None,
2318        };
2319
2320        // Response with uniqueness nullifier instead of session nullifier should fail
2321        let response_wrong_nullifier_type = ProofResponse {
2322            id: "req_session".into(),
2323            version: RequestVersion::V1,
2324            session_id: Some(test_field_element(123)),
2325            error: None,
2326            responses: vec![ResponseItem::new_uniqueness(
2327                "orb".into(),
2328                1,
2329                ZeroKnowledgeProof::default(),
2330                Nullifier::from(test_field_element(1001)), // Using uniqueness nullifier instead of session!
2331                1_735_689_600,
2332            )],
2333        };
2334
2335        let err = request
2336            .validate_response(&response_wrong_nullifier_type)
2337            .unwrap_err();
2338        assert!(matches!(
2339            err,
2340            ValidationError::MissingSessionNullifier(ref id) if id == "orb"
2341        ));
2342    }
2343
2344    #[test]
2345    fn test_validate_response_requires_nullifier_for_uniqueness_proof() {
2346        // Request without session_id requires nullifier in each response item
2347        let request = ProofRequest {
2348            id: "req_uniqueness".into(),
2349            version: RequestVersion::V1,
2350            created_at: 1_735_689_600,
2351            expires_at: 1_735_689_900,
2352            rp_id: RpId::new(1),
2353            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2354            session_id: None, // Uniqueness proof
2355            action: Some(test_field_element(42)),
2356            signature: test_signature(),
2357            nonce: test_nonce(),
2358            requests: vec![RequestItem {
2359                identifier: "orb".into(),
2360                issuer_schema_id: 1,
2361                signal: None,
2362                genesis_issued_at_min: None,
2363                expires_at_min: None,
2364            }],
2365            constraints: None,
2366        };
2367
2368        // Response with session nullifier instead of uniqueness nullifier should fail
2369        let response_wrong_nullifier_type = ProofResponse {
2370            id: "req_uniqueness".into(),
2371            version: RequestVersion::V1,
2372            session_id: None,
2373            error: None,
2374            responses: vec![ResponseItem::new_session(
2375                "orb".into(),
2376                1,
2377                ZeroKnowledgeProof::default(),
2378                SessionNullifier::new(test_field_element(1001), test_field_element(42)), // Using session nullifier instead of uniqueness!
2379                1_735_689_600,
2380            )],
2381        };
2382
2383        let err = request
2384            .validate_response(&response_wrong_nullifier_type)
2385            .unwrap_err();
2386        assert!(matches!(
2387            err,
2388            ValidationError::MissingNullifier(ref id) if id == "orb"
2389        ));
2390    }
2391}