Skip to main content

world_id_primitives/request/
mod.rs

1//! Module containing all the functionality to handle requests from Relying Parties (RPs) to Authenticators.
2//!
3//! Enables an RP to create a Proof request or a Session Proof request, and provides base functionality
4//! for Authenticators to handle such requests.
5mod constraints;
6pub use constraints::{ConstraintExpr, ConstraintKind, ConstraintNode, MAX_CONSTRAINT_NODES};
7
8use crate::{
9    FieldElement, Nullifier, PrimitiveError, SessionId, SessionNullifier, ZeroKnowledgeProof,
10    rp::RpId,
11};
12use serde::{Deserialize, Serialize, de::Error as _};
13use std::collections::HashSet;
14use taceo_oprf::types::OprfKeyId;
15// The uuid crate is needed for wasm compatibility
16use uuid as _;
17
18/// Protocol schema version for proof requests and responses.
19#[repr(u8)]
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum RequestVersion {
22    /// Version 1
23    V1 = 1,
24}
25
26impl serde::Serialize for RequestVersion {
27    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
28    where
29        S: serde::Serializer,
30    {
31        let v = *self as u8;
32        serializer.serialize_u8(v)
33    }
34}
35
36impl<'de> serde::Deserialize<'de> for RequestVersion {
37    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
38    where
39        D: serde::Deserializer<'de>,
40    {
41        let v = u8::deserialize(deserializer)?;
42        match v {
43            1 => Ok(Self::V1),
44            _ => Err(serde::de::Error::custom("unsupported version")),
45        }
46    }
47}
48
49/// A proof request from a Relying Party (RP) for an Authenticator.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct ProofRequest {
53    /// Unique identifier for this request.
54    pub id: String,
55    /// Version of the request.
56    pub version: RequestVersion,
57    /// Unix timestamp (seconds) when the request was created.
58    pub created_at: u64,
59    /// Unix timestamp (seconds) when the request expires.
60    pub expires_at: u64,
61    /// Registered RP identifier from the `RpRegistry`.
62    pub rp_id: RpId,
63    /// `OprfKeyId` of the RP.
64    pub oprf_key_id: OprfKeyId,
65    /// Session identifier that links proofs for the same user/RP pair across requests.
66    ///
67    /// If provided, a Session Proof will be generated instead of a Uniqueness Proof.
68    /// The proof will only be valid if the session ID is meant for this context and this
69    /// particular World ID holder.
70    pub session_id: Option<SessionId>,
71    /// An RP-defined context that scopes what the user is proving uniqueness on.
72    ///
73    /// This parameter expects a field element. When dealing with strings or bytes,
74    /// hash with a byte-friendly hash function like keccak256 or SHA256 and reduce to the field.
75    pub action: Option<FieldElement>,
76    /// The RP's ECDSA signature over the request.
77    #[serde(with = "crate::serde_utils::hex_signature")]
78    pub signature: alloy::signers::Signature,
79    /// Unique nonce for this request provided by the RP.
80    pub nonce: FieldElement,
81    /// Specific credential requests. This defines which credentials to ask for.
82    #[serde(rename = "proof_requests")]
83    pub requests: Vec<RequestItem>,
84    /// Constraint expression (all/any/enumerate) optional.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub constraints: Option<ConstraintExpr<'static>>,
87}
88
89/// Per-credential request payload.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct RequestItem {
93    /// An RP-defined identifier for this request item used to match against constraints and responses.
94    ///
95    /// Example: `orb`, `document`.
96    pub identifier: String,
97
98    /// Unique identifier for the credential schema and issuer pair.
99    ///
100    /// Registered in the `CredentialSchemaIssuerRegistry`.
101    pub issuer_schema_id: u64,
102
103    /// Arbitrary data provided by the RP that gets cryptographically bound into the proof.
104    ///
105    /// When present, the Authenticator hashes this via `signal_hash` and commits it into the
106    /// proof circuit so the RP can tie the proof to a particular context.
107    ///
108    /// The reason why the signal is expected as raw bytes and hashed by the Authenticator instead
109    /// of directly as a field element is so that in the future it can be displayed to the user in
110    /// a human-readable way.
111    ///
112    /// Raw bytes provides maximum flexibility because for on-chain use cases any arbitrary set of
113    /// inputs can be ABI-encoded to be verified on-chain.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    #[serde(with = "crate::serde_utils::hex_bytes_opt")]
116    pub signal: Option<Vec<u8>>,
117
118    /// Minimum `genesis_issued_at` timestamp that the used Credential must meet.
119    ///
120    /// If present, the proof will include a constraint that the credential's genesis issued at timestamp
121    /// is greater than or equal to this value. Can be set to 0 to skip.
122    /// This is useful for migration from previous protocol versions.
123    pub genesis_issued_at_min: Option<u64>,
124
125    /// The minimum expiration required for the Credential used in the proof.
126    ///
127    /// If the constraint is not required, it should use the current time as the minimum expiration.
128    /// The Authenticator will normally expose the effective input used in the proof.
129    ///
130    /// This is particularly useful to specify a minimum duration for a Credential proportional to the action
131    /// being performed. For example, when claiming a benefit that is once every 6 months, the minimum duration
132    /// can be set to 180 days to prevent double claiming in that period in case the Credential is set to expire earlier.
133    ///
134    /// It is an RP's responsibility to understand the issuer's policies regarding expiration to ensure the request
135    /// can be fulfilled.
136    ///
137    /// If not provided, this will default to the [`ProofRequest::created_at`] attribute.
138    pub expires_at_min: Option<u64>,
139}
140
141impl RequestItem {
142    /// Create a new request item with the given identifier, issuer schema ID and optional signal.
143    #[must_use]
144    pub const fn new(
145        identifier: String,
146        issuer_schema_id: u64,
147        signal: Option<Vec<u8>>,
148        genesis_issued_at_min: Option<u64>,
149        expires_at_min: Option<u64>,
150    ) -> Self {
151        Self {
152            identifier,
153            issuer_schema_id,
154            signal,
155            genesis_issued_at_min,
156            expires_at_min,
157        }
158    }
159
160    /// Get the signal hash for the request item.
161    #[must_use]
162    pub fn signal_hash(&self) -> FieldElement {
163        if let Some(signal) = &self.signal {
164            FieldElement::from_arbitrary_raw_bytes(signal)
165        } else {
166            FieldElement::ZERO
167        }
168    }
169
170    /// Get the effective minimum expiration timestamp for this request item.
171    ///
172    /// If `expires_at_min` is `Some`, returns that value.
173    /// Otherwise, returns the `request_created_at` value (which should be the `ProofRequest::created_at` timestamp).
174    #[must_use]
175    pub const fn effective_expires_at_min(&self, request_created_at: u64) -> u64 {
176        match self.expires_at_min {
177            Some(value) => value,
178            None => request_created_at,
179        }
180    }
181}
182
183/// Overall response from the Authenticator to the RP
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct ProofResponse {
187    /// The response id references request id
188    pub id: String,
189    /// Version corresponding to request version
190    pub version: RequestVersion,
191    /// RP session identifier that links multiple proofs for the same
192    /// user/RP pair across requests.
193    ///
194    /// For an initial request which creates a session, this contains
195    /// the newly generated `SessionId`. For subsequent Session Proofs, this
196    /// echoes back the `SessionId` from the request for convenience.
197    ///
198    /// This is optional as it's not provided in Uniqueness Proofs.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub session_id: Option<SessionId>,
201    /// Error message if the entire proof request failed.
202    /// When present, the responses array will be empty.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub error: Option<String>,
205    /// Per-credential results (empty if error is present)
206    pub responses: Vec<ResponseItem>,
207}
208
209/// Per-credential response item returned by the Authenticator.
210///
211/// Each entry corresponds to one requested credential with its proof material.
212/// If any credential cannot be satisfied, the entire proof response will have
213/// an error at the `ProofResponse` level with an empty `responses` array.
214///
215/// # Nullifier Types
216///
217/// - **Uniqueness proofs**: Use `nullifier` field (a single `FieldElement`).
218///   The contract's `verify()` function takes this as a separate `uint256 nullifier` param.
219///
220/// - **Session proofs**: Use `session_nullifier` field (contains both nullifier and action).
221///   The contract's `verifySession()` function expects `uint256[2] sessionNullifier`.
222///
223/// Exactly one of `nullifier` or `session_nullifier` should be present.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(deny_unknown_fields)]
226pub struct ResponseItem {
227    /// An RP-defined identifier for this request item used to match against constraints and responses.
228    ///
229    /// Example: `orb`, `document`.
230    pub identifier: String,
231
232    /// Unique identifier for the credential schema and issuer pair.
233    pub issuer_schema_id: u64,
234
235    /// Encoded World ID Proof. See [`ZeroKnowledgeProof`] for more details.
236    pub proof: ZeroKnowledgeProof,
237
238    /// A [`Nullifier`] for Uniqueness proofs.
239    ///
240    /// Present for Uniqueness proofs, absent for Session proofs.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub nullifier: Option<Nullifier>,
243
244    /// A [`SessionNullifier`] for Session proofs.
245    ///
246    /// Present for Session proofs, absent for Uniqueness proofs.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub session_nullifier: Option<SessionNullifier>,
249
250    /// The minimum expiration required for the Credential used in the proof.
251    ///
252    /// This precise value must be used when verifying the proof on-chain.
253    pub expires_at_min: u64,
254}
255
256impl ProofResponse {
257    /// Determine if constraints are satisfied given a constraint expression.
258    /// Returns false if the response has an error.
259    #[must_use]
260    pub fn constraints_satisfied(&self, constraints: &ConstraintExpr<'_>) -> bool {
261        // If there's an error, constraints cannot be satisfied
262        if self.error.is_some() {
263            return false;
264        }
265
266        let provided: HashSet<&str> = self
267            .responses
268            .iter()
269            .map(|item| item.identifier.as_str())
270            .collect();
271
272        constraints.evaluate(&|t| provided.contains(t))
273    }
274}
275
276impl ResponseItem {
277    /// Create a new response item for a Uniqueness proof.
278    #[must_use]
279    pub const fn new_uniqueness(
280        identifier: String,
281        issuer_schema_id: u64,
282        proof: ZeroKnowledgeProof,
283        nullifier: Nullifier,
284        expires_at_min: u64,
285    ) -> Self {
286        Self {
287            identifier,
288            issuer_schema_id,
289            proof,
290            nullifier: Some(nullifier),
291            session_nullifier: None,
292            expires_at_min,
293        }
294    }
295
296    /// Create a new response item for a Session proof.
297    #[must_use]
298    pub const fn new_session(
299        identifier: String,
300        issuer_schema_id: u64,
301        proof: ZeroKnowledgeProof,
302        session_nullifier: SessionNullifier,
303        expires_at_min: u64,
304    ) -> Self {
305        Self {
306            identifier,
307            issuer_schema_id,
308            proof,
309            nullifier: None,
310            session_nullifier: Some(session_nullifier),
311            expires_at_min,
312        }
313    }
314
315    /// Returns true if this is a Session proof response.
316    #[must_use]
317    pub const fn is_session(&self) -> bool {
318        self.session_nullifier.is_some()
319    }
320
321    /// Returns true if this is a Uniqueness proof response.
322    #[must_use]
323    pub const fn is_uniqueness(&self) -> bool {
324        self.nullifier.is_some()
325    }
326}
327
328impl ProofRequest {
329    /// Determine which requested credentials to prove given available credentials.
330    ///
331    /// Returns `None` if constraints (or lack thereof) cannot be satisfied with the available set.
332    ///
333    /// # Panics
334    /// Panics if constraints are present but invalid according to the type invariants
335    /// (this should not occur as constraints are provided by trusted request issuer).
336    #[must_use]
337    pub fn credentials_to_prove(&self, available: &HashSet<u64>) -> Option<Vec<&RequestItem>> {
338        // Pre-compute which identifiers have an available issuer_schema_id
339        let available_identifiers: HashSet<&str> = self
340            .requests
341            .iter()
342            .filter(|r| available.contains(&r.issuer_schema_id))
343            .map(|r| r.identifier.as_str())
344            .collect();
345
346        let is_selectable = |identifier: &str| available_identifiers.contains(identifier);
347
348        // If no explicit constraints: require all requested be available
349        if self.constraints.is_none() {
350            return if self
351                .requests
352                .iter()
353                .all(|r| available.contains(&r.issuer_schema_id))
354            {
355                Some(self.requests.iter().collect())
356            } else {
357                None
358            };
359        }
360
361        // Compute selected identifiers using the constraint expression
362        let selected_identifiers = select_expr(self.constraints.as_ref().unwrap(), &is_selectable)?;
363        let selected_set: HashSet<&str> = selected_identifiers.into_iter().collect();
364
365        // Return proof_requests in original order filtered by selected identifiers
366        let result: Vec<&RequestItem> = self
367            .requests
368            .iter()
369            .filter(|r| selected_set.contains(r.identifier.as_str()))
370            .collect();
371        Some(result)
372    }
373
374    /// Find a request item by issuer schema ID if available
375    #[must_use]
376    pub fn find_request_by_issuer_schema_id(&self, issuer_schema_id: u64) -> Option<&RequestItem> {
377        self.requests
378            .iter()
379            .find(|r| r.issuer_schema_id == issuer_schema_id)
380    }
381
382    /// Returns true if the request is expired relative to now (unix timestamp in seconds)
383    #[must_use]
384    pub const fn is_expired(&self, now: u64) -> bool {
385        now > self.expires_at
386    }
387
388    /// Compute the digest hash of this request that should be signed by the RP, which right now
389    /// includes the `nonce` and the timestamp of the request.
390    ///
391    /// # Returns
392    /// A 32-byte hash that represents this request and should be signed by the RP.
393    ///
394    /// # Errors
395    /// Returns a `PrimitiveError` if `FieldElement` serialization fails (which should never occur in practice).
396    ///
397    /// The digest is computed as: `SHA256(version || nonce || created_at || expires_at || action)`.
398    /// This mirrors the RP signature message format from `rp::compute_rp_signature_msg`.
399    /// Note: the timestamp is encoded as big-endian to mirror the RP-side signing
400    /// performed in test fixtures and the OPRF stub.
401    pub fn digest_hash(&self) -> Result<[u8; 32], PrimitiveError> {
402        use crate::rp::compute_rp_signature_msg;
403        use k256::sha2::{Digest, Sha256};
404
405        let msg = compute_rp_signature_msg(
406            *self.nonce,
407            self.created_at,
408            self.expires_at,
409            self.action.map(|v| *v),
410        );
411        let mut hasher = Sha256::new();
412        hasher.update(&msg);
413        Ok(hasher.finalize().into())
414    }
415
416    /// Returns true if this request is for a Session proof (i.e., has a session ID).
417    #[must_use]
418    pub const fn is_session_proof(&self) -> bool {
419        self.session_id.is_some()
420    }
421
422    /// Validate that a response satisfies this request: id match and constraints semantics.
423    ///
424    /// # Errors
425    /// Returns a `ValidationError` if the response does not correspond to this request or
426    /// does not satisfy the declared constraints.
427    pub fn validate_response(&self, response: &ProofResponse) -> Result<(), ValidationError> {
428        // Validate id and version match
429        if self.id != response.id {
430            return Err(ValidationError::RequestIdMismatch);
431        }
432        if self.version != response.version {
433            return Err(ValidationError::VersionMismatch);
434        }
435
436        // If response has an error, it failed to satisfy constraints
437        if let Some(error) = &response.error {
438            return Err(ValidationError::ProofGenerationFailed(error.clone()));
439        }
440
441        // Session ID of the response doesn't match the request's session ID (if present)
442        if self.session_id.is_some() && self.session_id != response.session_id {
443            return Err(ValidationError::SessionIdMismatch);
444        }
445
446        // Validate response items correspond to request items and are unique.
447        let mut provided: HashSet<&str> = HashSet::new();
448        for response_item in &response.responses {
449            if !provided.insert(response_item.identifier.as_str()) {
450                return Err(ValidationError::DuplicateCredential(
451                    response_item.identifier.clone(),
452                ));
453            }
454
455            let request_item = self
456                .requests
457                .iter()
458                .find(|r| r.identifier == response_item.identifier)
459                .ok_or_else(|| {
460                    ValidationError::UnexpectedCredential(response_item.identifier.clone())
461                })?;
462
463            if self.session_id.is_some() {
464                // Session proof: must have session_nullifier
465                if response_item.session_nullifier.is_none() {
466                    return Err(ValidationError::MissingSessionNullifier(
467                        response_item.identifier.clone(),
468                    ));
469                }
470            } else {
471                // Uniqueness proof: must have nullifier
472                if response_item.nullifier.is_none() {
473                    return Err(ValidationError::MissingNullifier(
474                        response_item.identifier.clone(),
475                    ));
476                }
477            }
478
479            let expected_expires_at_min = request_item.effective_expires_at_min(self.created_at);
480            if response_item.expires_at_min != expected_expires_at_min {
481                return Err(ValidationError::ExpiresAtMinMismatch(
482                    response_item.identifier.clone(),
483                    expected_expires_at_min,
484                    response_item.expires_at_min,
485                ));
486            }
487        }
488
489        match &self.constraints {
490            // None => all requested credentials (via identifier) are required
491            None => {
492                for req in &self.requests {
493                    if !provided.contains(req.identifier.as_str()) {
494                        return Err(ValidationError::MissingCredential(req.identifier.clone()));
495                    }
496                }
497                Ok(())
498            }
499            Some(expr) => {
500                if !expr.validate_max_depth(2) {
501                    return Err(ValidationError::ConstraintTooDeep);
502                }
503                if !expr.validate_max_nodes(MAX_CONSTRAINT_NODES) {
504                    return Err(ValidationError::ConstraintTooLarge);
505                }
506                if expr.evaluate(&|t| provided.contains(t)) {
507                    Ok(())
508                } else {
509                    Err(ValidationError::ConstraintNotSatisfied)
510                }
511            }
512        }
513    }
514
515    /// Parse from JSON
516    ///
517    /// # Errors
518    /// Returns an error if the JSON is invalid or contains duplicate issuer schema ids.
519    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
520        let v: Self = serde_json::from_str(json)?;
521        // Enforce unique issuer schema ids within a single request
522        let mut seen: HashSet<String> = HashSet::new();
523        for r in &v.requests {
524            let t = r.issuer_schema_id.to_string();
525            if !seen.insert(t.clone()) {
526                return Err(serde_json::Error::custom(format!(
527                    "duplicate issuer schema id: {t}"
528                )));
529            }
530        }
531        Ok(v)
532    }
533
534    /// Serialize to JSON
535    ///
536    /// # Errors
537    /// Returns an error if serialization unexpectedly fails.
538    pub fn to_json(&self) -> Result<String, serde_json::Error> {
539        serde_json::to_string(self)
540    }
541
542    /// Serialize to pretty JSON
543    ///
544    /// # Errors
545    /// Returns an error if serialization unexpectedly fails.
546    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
547        serde_json::to_string_pretty(self)
548    }
549}
550
551impl ProofResponse {
552    /// Parse from JSON
553    ///
554    /// # Errors
555    /// Returns an error if the JSON does not match the expected response shape.
556    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
557        serde_json::from_str(json)
558    }
559
560    /// Serialize to pretty JSON
561    ///
562    /// # Errors
563    /// Returns an error if serialization fails.
564    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
565        serde_json::to_string_pretty(self)
566    }
567
568    /// Return the list of successful `issuer_schema_id`s in the response.
569    /// Returns an empty vec if the response has an error.
570    #[must_use]
571    pub fn successful_credentials(&self) -> Vec<u64> {
572        if self.error.is_some() {
573            return vec![];
574        }
575        self.responses.iter().map(|r| r.issuer_schema_id).collect()
576    }
577}
578
579/// Validation errors when checking a response against a request
580#[derive(Debug, thiserror::Error, PartialEq, Eq)]
581pub enum ValidationError {
582    /// The response `id` does not match the request `id`
583    #[error("Request ID mismatch")]
584    RequestIdMismatch,
585    /// The response `version` does not match the request `version`
586    #[error("Version mismatch")]
587    VersionMismatch,
588    /// The proof generation failed (response contains an error)
589    #[error("Proof generation failed: {0}")]
590    ProofGenerationFailed(String),
591    /// A required credential was not provided
592    #[error("Missing required credential: {0}")]
593    MissingCredential(String),
594    /// A credential was returned that was not requested.
595    #[error("Unexpected credential in response: {0}")]
596    UnexpectedCredential(String),
597    /// A credential identifier was returned more than once.
598    #[error("Duplicate credential in response: {0}")]
599    DuplicateCredential(String),
600    /// The provided credentials do not satisfy the request constraints
601    #[error("Constraints not satisfied")]
602    ConstraintNotSatisfied,
603    /// The constraints expression exceeds the supported nesting depth
604    #[error("Constraints nesting exceeds maximum allowed depth")]
605    ConstraintTooDeep,
606    /// The constraints expression exceeds the maximum allowed size/complexity
607    #[error("Constraints exceed maximum allowed size")]
608    ConstraintTooLarge,
609    /// The `expires_at_min` value in the response does not match the expected value from the request
610    #[error("Invalid expires_at_min for credential '{0}': expected {1}, got {2}")]
611    ExpiresAtMinMismatch(String, u64, u64),
612    /// Session ID doesn't match between request and response
613    #[error("Session ID doesn't match between request and response")]
614    SessionIdMismatch,
615    /// Session nullifier missing for credential in session proof
616    #[error("Session nullifier missing for credential: {0}")]
617    MissingSessionNullifier(String),
618    /// Nullifier missing for credential in uniqueness proof
619    #[error("Nullifier missing for credential: {0}")]
620    MissingNullifier(String),
621}
622
623// Helper selection functions for constraint evaluation
624fn select_node<'a, F>(node: &'a ConstraintNode<'a>, pred: &F) -> Option<Vec<&'a str>>
625where
626    F: Fn(&str) -> bool,
627{
628    match node {
629        ConstraintNode::Type(t) => pred(t.as_ref()).then(|| vec![t.as_ref()]),
630        ConstraintNode::Expr(e) => select_expr(e, pred),
631    }
632}
633
634fn select_expr<'a, F>(expr: &'a ConstraintExpr<'a>, pred: &F) -> Option<Vec<&'a str>>
635where
636    F: Fn(&str) -> bool,
637{
638    match expr {
639        ConstraintExpr::All { all } => {
640            let mut seen: std::collections::HashSet<&'a str> = std::collections::HashSet::new();
641            let mut out: Vec<&'a str> = Vec::new();
642            for n in all {
643                let sub = select_node(n, pred)?;
644                for s in sub {
645                    if seen.insert(s) {
646                        out.push(s);
647                    }
648                }
649            }
650            Some(out)
651        }
652        ConstraintExpr::Any { any } => any.iter().find_map(|n| select_node(n, pred)),
653        ConstraintExpr::Enumerate { enumerate } => {
654            // HashSet deduplicates identifiers, while Vec preserves first-seen output order.
655            let mut seen: std::collections::HashSet<&'a str> = std::collections::HashSet::new();
656            let mut selected: Vec<&'a str> = Vec::new();
657
658            // Enumerate semantics: collect every satisfiable child; skip unsatisfied children.
659            for child in enumerate {
660                let Some(child_selection) = select_node(child, pred) else {
661                    continue;
662                };
663
664                for identifier in child_selection {
665                    if seen.insert(identifier) {
666                        selected.push(identifier);
667                    }
668                }
669            }
670
671            if selected.is_empty() {
672                None
673            } else {
674                Some(selected)
675            }
676        }
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683    use crate::SessionNullifier;
684    use alloy::{
685        signers::{SignerSync, local::PrivateKeySigner},
686        uint,
687    };
688    use k256::ecdsa::SigningKey;
689
690    // Test helpers
691    fn test_signature() -> alloy::signers::Signature {
692        let signer =
693            PrivateKeySigner::from_signing_key(SigningKey::from_bytes(&[1u8; 32].into()).unwrap());
694        signer.sign_message_sync(b"test").expect("can sign")
695    }
696
697    fn test_nonce() -> FieldElement {
698        FieldElement::from(1u64)
699    }
700
701    fn test_field_element(n: u64) -> FieldElement {
702        FieldElement::from(n)
703    }
704
705    #[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(SessionId::default()),
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(SessionId::default()),
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": "session_00000000000000000000000000000000000000000000000000000000000003ea0100000000000000000000000000000000000000000000000000000000000001",
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        assert_eq!(
1680            sess_canonical.session_id.unwrap().oprf_seed().to_u256(),
1681            uint!(0x0100000000000000000000000000000000000000000000000000000000000001_U256)
1682        );
1683    }
1684    /// Test duplicate detection by creating a serialized `ProofRequest` with duplicates
1685    /// and then trying to parse it with `from_json` which should detect the duplicates
1686    #[test]
1687    fn request_rejects_duplicate_issuer_schema_ids_on_parse() {
1688        let req = ProofRequest {
1689            id: "req_dup".into(),
1690            version: RequestVersion::V1,
1691            created_at: 1_725_381_192,
1692            expires_at: 1_725_381_492,
1693            rp_id: RpId::new(1),
1694            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1695            session_id: None,
1696            action: Some(test_field_element(5)),
1697            signature: test_signature(),
1698            nonce: test_nonce(),
1699            requests: vec![
1700                RequestItem {
1701                    identifier: "test_req_1".into(),
1702                    issuer_schema_id: 1,
1703                    signal: None,
1704                    genesis_issued_at_min: None,
1705                    expires_at_min: None,
1706                },
1707                RequestItem {
1708                    identifier: "test_req_2".into(),
1709                    issuer_schema_id: 1, // Duplicate!
1710                    signal: None,
1711                    genesis_issued_at_min: None,
1712                    expires_at_min: None,
1713                },
1714            ],
1715            constraints: None,
1716        };
1717
1718        // Serialize then deserialize to trigger the duplicate check in from_json
1719        let json = req.to_json().unwrap();
1720        let err = ProofRequest::from_json(&json).unwrap_err();
1721        let msg = err.to_string();
1722        assert!(
1723            msg.contains("duplicate issuer schema id"),
1724            "Expected error message to contain 'duplicate issuer schema id', got: {msg}"
1725        );
1726    }
1727
1728    #[test]
1729    fn response_with_error_has_empty_responses_and_fails_validation() {
1730        let request = ProofRequest {
1731            id: "req_error".into(),
1732            version: RequestVersion::V1,
1733            created_at: 1_735_689_600,
1734            expires_at: 1_735_689_600,
1735            rp_id: RpId::new(1),
1736            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1737            session_id: None,
1738            action: Some(FieldElement::ZERO),
1739            signature: test_signature(),
1740            nonce: test_nonce(),
1741            requests: vec![RequestItem {
1742                identifier: "orb".into(),
1743                issuer_schema_id: 1,
1744                signal: None,
1745                genesis_issued_at_min: None,
1746                expires_at_min: None,
1747            }],
1748            constraints: None,
1749        };
1750
1751        // Response with error should have empty responses array
1752        let error_response = ProofResponse {
1753            id: "req_error".into(),
1754            version: RequestVersion::V1,
1755            session_id: None,
1756            error: Some("credential_not_available".into()),
1757            responses: vec![], // Empty when error is present
1758        };
1759
1760        // Validation should fail with ProofGenerationFailed
1761        let err = request.validate_response(&error_response).unwrap_err();
1762        assert!(matches!(err, ValidationError::ProofGenerationFailed(_)));
1763        if let ValidationError::ProofGenerationFailed(msg) = err {
1764            assert_eq!(msg, "credential_not_available");
1765        }
1766
1767        // successful_credentials should return empty vec when error is present
1768        assert_eq!(error_response.successful_credentials(), Vec::<u64>::new());
1769
1770        // constraints_satisfied should return false when error is present
1771        let expr = ConstraintExpr::All {
1772            all: vec![ConstraintNode::Type("orb".into())],
1773        };
1774        assert!(!error_response.constraints_satisfied(&expr));
1775    }
1776
1777    #[test]
1778    fn response_error_json_parse() {
1779        // Error response JSON
1780        let error_json = r#"{
1781  "id": "req_error",
1782  "version": 1,
1783  "error": "credential_not_available",
1784  "responses": []
1785}"#;
1786
1787        let error_resp = ProofResponse::from_json(error_json).unwrap();
1788        assert_eq!(error_resp.error, Some("credential_not_available".into()));
1789        assert_eq!(error_resp.responses.len(), 0);
1790        assert_eq!(error_resp.successful_credentials(), Vec::<u64>::new());
1791    }
1792
1793    #[test]
1794    fn credentials_to_prove_none_constraints_requires_all_and_drops_if_missing() {
1795        let req = ProofRequest {
1796            id: "req".into(),
1797            version: RequestVersion::V1,
1798            created_at: 1_735_689_600,
1799            expires_at: 1_735_689_600, // 2025-01-01 00:00:00 UTC
1800            rp_id: RpId::new(1),
1801            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1802            session_id: None,
1803            action: Some(test_field_element(5)),
1804            signature: test_signature(),
1805            nonce: test_nonce(),
1806            requests: vec![
1807                RequestItem {
1808                    identifier: "orb".into(),
1809                    issuer_schema_id: 100,
1810                    signal: None,
1811                    genesis_issued_at_min: None,
1812                    expires_at_min: None,
1813                },
1814                RequestItem {
1815                    identifier: "passport".into(),
1816                    issuer_schema_id: 101,
1817                    signal: None,
1818                    genesis_issued_at_min: None,
1819                    expires_at_min: None,
1820                },
1821            ],
1822            constraints: None,
1823        };
1824
1825        let available_ok: HashSet<u64> = [100, 101].into_iter().collect();
1826        let sel_ok = req.credentials_to_prove(&available_ok).unwrap();
1827        assert_eq!(sel_ok.len(), 2);
1828        assert_eq!(sel_ok[0].issuer_schema_id, 100);
1829        assert_eq!(sel_ok[1].issuer_schema_id, 101);
1830
1831        let available_missing: HashSet<u64> = std::iter::once(100).collect();
1832        assert!(req.credentials_to_prove(&available_missing).is_none());
1833    }
1834
1835    #[test]
1836    fn credentials_to_prove_with_constraints_all_and_any() {
1837        // proof_requests: orb, passport, national-id
1838        let orb_id = 100;
1839        let passport_id = 101;
1840        let national_id = 102;
1841
1842        let req = ProofRequest {
1843            id: "req".into(),
1844            version: RequestVersion::V1,
1845            created_at: 1_735_689_600,
1846            expires_at: 1_735_689_600, // 2025-01-01 00:00:00 UTC
1847            rp_id: RpId::new(1),
1848            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1849            session_id: None,
1850            action: Some(test_field_element(1)),
1851            signature: test_signature(),
1852            nonce: test_nonce(),
1853            requests: vec![
1854                RequestItem {
1855                    identifier: "orb".into(),
1856                    issuer_schema_id: orb_id,
1857                    signal: None,
1858                    genesis_issued_at_min: None,
1859                    expires_at_min: None,
1860                },
1861                RequestItem {
1862                    identifier: "passport".into(),
1863                    issuer_schema_id: passport_id,
1864                    signal: None,
1865                    genesis_issued_at_min: None,
1866                    expires_at_min: None,
1867                },
1868                RequestItem {
1869                    identifier: "national_id".into(),
1870                    issuer_schema_id: national_id,
1871                    signal: None,
1872                    genesis_issued_at_min: None,
1873                    expires_at_min: None,
1874                },
1875            ],
1876            constraints: Some(ConstraintExpr::All {
1877                all: vec![
1878                    ConstraintNode::Type("orb".into()),
1879                    ConstraintNode::Expr(ConstraintExpr::Any {
1880                        any: vec![
1881                            ConstraintNode::Type("passport".into()),
1882                            ConstraintNode::Type("national_id".into()),
1883                        ],
1884                    }),
1885                ],
1886            }),
1887        };
1888
1889        // Available has orb + passport → should pick [orb, passport]
1890        let available1: HashSet<u64> = [orb_id, passport_id].into_iter().collect();
1891        let sel1 = req.credentials_to_prove(&available1).unwrap();
1892        assert_eq!(sel1.len(), 2);
1893        assert_eq!(sel1[0].issuer_schema_id, orb_id);
1894        assert_eq!(sel1[1].issuer_schema_id, passport_id);
1895
1896        // Available has orb + national-id → should pick [orb, national-id]
1897        let available2: HashSet<u64> = [orb_id, national_id].into_iter().collect();
1898        let sel2 = req.credentials_to_prove(&available2).unwrap();
1899        assert_eq!(sel2.len(), 2);
1900        assert_eq!(sel2[0].issuer_schema_id, orb_id);
1901        assert_eq!(sel2[1].issuer_schema_id, national_id);
1902
1903        // Missing orb → cannot satisfy "all" → None
1904        let available3: HashSet<u64> = std::iter::once(passport_id).collect();
1905        assert!(req.credentials_to_prove(&available3).is_none());
1906    }
1907
1908    #[test]
1909    fn credentials_to_prove_with_constraints_enumerate() {
1910        let orb_id = 100;
1911        let passport_id = 101;
1912        let national_id = 102;
1913
1914        let req = ProofRequest {
1915            id: "req".into(),
1916            version: RequestVersion::V1,
1917            created_at: 1_735_689_600,
1918            expires_at: 1_735_689_600,
1919            rp_id: RpId::new(1),
1920            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1921            session_id: None,
1922            action: Some(test_field_element(1)),
1923            signature: test_signature(),
1924            nonce: test_nonce(),
1925            requests: vec![
1926                RequestItem {
1927                    identifier: "orb".into(),
1928                    issuer_schema_id: orb_id,
1929                    signal: None,
1930                    genesis_issued_at_min: None,
1931                    expires_at_min: None,
1932                },
1933                RequestItem {
1934                    identifier: "passport".into(),
1935                    issuer_schema_id: passport_id,
1936                    signal: None,
1937                    genesis_issued_at_min: None,
1938                    expires_at_min: None,
1939                },
1940                RequestItem {
1941                    identifier: "national_id".into(),
1942                    issuer_schema_id: national_id,
1943                    signal: None,
1944                    genesis_issued_at_min: None,
1945                    expires_at_min: None,
1946                },
1947            ],
1948            constraints: Some(ConstraintExpr::Enumerate {
1949                enumerate: vec![
1950                    ConstraintNode::Type("passport".into()),
1951                    ConstraintNode::Type("national_id".into()),
1952                ],
1953            }),
1954        };
1955
1956        // One of enumerate candidates available -> one selected
1957        let available1: HashSet<u64> = [orb_id, passport_id].into_iter().collect();
1958        let sel1 = req.credentials_to_prove(&available1).unwrap();
1959        assert_eq!(sel1.len(), 1);
1960        assert_eq!(sel1[0].issuer_schema_id, passport_id);
1961
1962        // Both enumerate candidates available -> both selected in request order
1963        let available2: HashSet<u64> = [orb_id, passport_id, national_id].into_iter().collect();
1964        let sel2 = req.credentials_to_prove(&available2).unwrap();
1965        assert_eq!(sel2.len(), 2);
1966        assert_eq!(sel2[0].issuer_schema_id, passport_id);
1967        assert_eq!(sel2[1].issuer_schema_id, national_id);
1968
1969        // None of enumerate candidates available -> cannot satisfy
1970        let available3: HashSet<u64> = std::iter::once(orb_id).collect();
1971        assert!(req.credentials_to_prove(&available3).is_none());
1972    }
1973
1974    #[test]
1975    fn credentials_to_prove_with_constraints_all_and_enumerate() {
1976        let orb_id = 100;
1977        let passport_id = 101;
1978        let national_id = 102;
1979
1980        let req = ProofRequest {
1981            id: "req".into(),
1982            version: RequestVersion::V1,
1983            created_at: 1_735_689_600,
1984            expires_at: 1_735_689_600,
1985            rp_id: RpId::new(1),
1986            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
1987            session_id: None,
1988            action: Some(test_field_element(1)),
1989            signature: test_signature(),
1990            nonce: test_nonce(),
1991            requests: vec![
1992                RequestItem {
1993                    identifier: "orb".into(),
1994                    issuer_schema_id: orb_id,
1995                    signal: None,
1996                    genesis_issued_at_min: None,
1997                    expires_at_min: None,
1998                },
1999                RequestItem {
2000                    identifier: "passport".into(),
2001                    issuer_schema_id: passport_id,
2002                    signal: None,
2003                    genesis_issued_at_min: None,
2004                    expires_at_min: None,
2005                },
2006                RequestItem {
2007                    identifier: "national_id".into(),
2008                    issuer_schema_id: national_id,
2009                    signal: None,
2010                    genesis_issued_at_min: None,
2011                    expires_at_min: None,
2012                },
2013            ],
2014            constraints: Some(ConstraintExpr::All {
2015                all: vec![
2016                    ConstraintNode::Type("orb".into()),
2017                    ConstraintNode::Expr(ConstraintExpr::Enumerate {
2018                        enumerate: vec![
2019                            ConstraintNode::Type("passport".into()),
2020                            ConstraintNode::Type("national_id".into()),
2021                        ],
2022                    }),
2023                ],
2024            }),
2025        };
2026
2027        // orb + passport -> select both
2028        let available1: HashSet<u64> = [orb_id, passport_id].into_iter().collect();
2029        let sel1 = req.credentials_to_prove(&available1).unwrap();
2030        assert_eq!(sel1.len(), 2);
2031        assert_eq!(sel1[0].issuer_schema_id, orb_id);
2032        assert_eq!(sel1[1].issuer_schema_id, passport_id);
2033
2034        // orb + passport + national_id -> select all three
2035        let available2: HashSet<u64> = [orb_id, passport_id, national_id].into_iter().collect();
2036        let sel2 = req.credentials_to_prove(&available2).unwrap();
2037        assert_eq!(sel2.len(), 3);
2038        assert_eq!(sel2[0].issuer_schema_id, orb_id);
2039        assert_eq!(sel2[1].issuer_schema_id, passport_id);
2040        assert_eq!(sel2[2].issuer_schema_id, national_id);
2041
2042        // orb alone -> enumerate branch unsatisfied
2043        let available3: HashSet<u64> = std::iter::once(orb_id).collect();
2044        assert!(req.credentials_to_prove(&available3).is_none());
2045    }
2046
2047    #[test]
2048    fn request_item_effective_expires_at_min_defaults_to_created_at() {
2049        let request_created_at = 1_735_689_600; // 2025-01-01 00:00:00 UTC
2050        let custom_expires_at = 1_735_862_400; // 2025-01-03 00:00:00 UTC
2051
2052        // When expires_at_min is None, should use request_created_at
2053        let item_with_none = RequestItem {
2054            identifier: "test".into(),
2055            issuer_schema_id: 100,
2056            signal: None,
2057            genesis_issued_at_min: None,
2058            expires_at_min: None,
2059        };
2060        assert_eq!(
2061            item_with_none.effective_expires_at_min(request_created_at),
2062            request_created_at,
2063            "When expires_at_min is None, should default to request created_at"
2064        );
2065
2066        // When expires_at_min is Some, should use that value
2067        let item_with_custom = RequestItem {
2068            identifier: "test".into(),
2069            issuer_schema_id: 100,
2070            signal: None,
2071            genesis_issued_at_min: None,
2072            expires_at_min: Some(custom_expires_at),
2073        };
2074        assert_eq!(
2075            item_with_custom.effective_expires_at_min(request_created_at),
2076            custom_expires_at,
2077            "When expires_at_min is Some, should use that explicit value"
2078        );
2079    }
2080
2081    #[test]
2082    fn validate_response_checks_expires_at_min_matches() {
2083        let request_created_at = 1_735_689_600; // 2025-01-01 00:00:00 UTC
2084        let custom_expires_at = 1_735_862_400; // 2025-01-03 00:00:00 UTC
2085
2086        // Request with one item that has no explicit expires_at_min (defaults to created_at)
2087        // and one with an explicit expires_at_min
2088        let request = ProofRequest {
2089            id: "req_expires_test".into(),
2090            version: RequestVersion::V1,
2091            created_at: request_created_at,
2092            expires_at: request_created_at + 300,
2093            rp_id: RpId::new(1),
2094            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2095            session_id: None,
2096            action: Some(test_field_element(1)),
2097            signature: test_signature(),
2098            nonce: test_nonce(),
2099            requests: vec![
2100                RequestItem {
2101                    identifier: "orb".into(),
2102                    issuer_schema_id: 100,
2103                    signal: None,
2104                    genesis_issued_at_min: None,
2105                    expires_at_min: None, // Should default to request_created_at
2106                },
2107                RequestItem {
2108                    identifier: "document".into(),
2109                    issuer_schema_id: 101,
2110                    signal: None,
2111                    genesis_issued_at_min: None,
2112                    expires_at_min: Some(custom_expires_at), // Explicit value
2113                },
2114            ],
2115            constraints: None,
2116        };
2117
2118        // Valid response with matching expires_at_min values
2119        let valid_response = ProofResponse {
2120            id: "req_expires_test".into(),
2121            version: RequestVersion::V1,
2122            session_id: None,
2123            error: None,
2124            responses: vec![
2125                ResponseItem::new_uniqueness(
2126                    "orb".into(),
2127                    100,
2128                    ZeroKnowledgeProof::default(),
2129                    Nullifier::from(test_field_element(1001)),
2130                    request_created_at, // Matches default
2131                ),
2132                ResponseItem::new_uniqueness(
2133                    "document".into(),
2134                    101,
2135                    ZeroKnowledgeProof::default(),
2136                    Nullifier::from(test_field_element(1002)),
2137                    custom_expires_at, // Matches explicit value
2138                ),
2139            ],
2140        };
2141        assert!(request.validate_response(&valid_response).is_ok());
2142
2143        // Invalid response with mismatched expires_at_min for first item
2144        let invalid_response_1 = ProofResponse {
2145            id: "req_expires_test".into(),
2146            version: RequestVersion::V1,
2147            session_id: None,
2148            error: None,
2149            responses: vec![
2150                ResponseItem::new_uniqueness(
2151                    "orb".into(),
2152                    100,
2153                    ZeroKnowledgeProof::default(),
2154                    Nullifier::from(test_field_element(1001)),
2155                    custom_expires_at, // Wrong! Should be request_created_at
2156                ),
2157                ResponseItem::new_uniqueness(
2158                    "document".into(),
2159                    101,
2160                    ZeroKnowledgeProof::default(),
2161                    Nullifier::from(test_field_element(1002)),
2162                    custom_expires_at,
2163                ),
2164            ],
2165        };
2166        let err1 = request.validate_response(&invalid_response_1).unwrap_err();
2167        assert!(matches!(
2168            err1,
2169            ValidationError::ExpiresAtMinMismatch(_, _, _)
2170        ));
2171        if let ValidationError::ExpiresAtMinMismatch(identifier, expected, got) = err1 {
2172            assert_eq!(identifier, "orb");
2173            assert_eq!(expected, request_created_at);
2174            assert_eq!(got, custom_expires_at);
2175        }
2176
2177        // Invalid response with mismatched expires_at_min for second item
2178        let invalid_response_2 = ProofResponse {
2179            id: "req_expires_test".into(),
2180            version: RequestVersion::V1,
2181            session_id: None,
2182            error: None,
2183            responses: vec![
2184                ResponseItem::new_uniqueness(
2185                    "orb".into(),
2186                    100,
2187                    ZeroKnowledgeProof::default(),
2188                    Nullifier::from(test_field_element(1001)),
2189                    request_created_at,
2190                ),
2191                ResponseItem::new_uniqueness(
2192                    "document".into(),
2193                    101,
2194                    ZeroKnowledgeProof::default(),
2195                    Nullifier::from(test_field_element(1002)),
2196                    request_created_at, // Wrong! Should be custom_expires_at
2197                ),
2198            ],
2199        };
2200        let err2 = request.validate_response(&invalid_response_2).unwrap_err();
2201        assert!(matches!(
2202            err2,
2203            ValidationError::ExpiresAtMinMismatch(_, _, _)
2204        ));
2205        if let ValidationError::ExpiresAtMinMismatch(identifier, expected, got) = err2 {
2206            assert_eq!(identifier, "document");
2207            assert_eq!(expected, custom_expires_at);
2208            assert_eq!(got, request_created_at);
2209        }
2210    }
2211
2212    #[test]
2213    fn test_validate_response_requires_session_id_in_response() {
2214        // Request with session_id should require response to also have session_id
2215        let request = ProofRequest {
2216            id: "req_session".into(),
2217            version: RequestVersion::V1,
2218            created_at: 1_735_689_600,
2219            expires_at: 1_735_689_900,
2220            rp_id: RpId::new(1),
2221            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2222            session_id: Some(SessionId::default()), // Session proof
2223            action: Some(test_field_element(42)),
2224            signature: test_signature(),
2225            nonce: test_nonce(),
2226            requests: vec![RequestItem {
2227                identifier: "orb".into(),
2228                issuer_schema_id: 1,
2229                signal: None,
2230                genesis_issued_at_min: None,
2231                expires_at_min: None,
2232            }],
2233            constraints: None,
2234        };
2235
2236        // Response without session_id should fail validation
2237        let response_missing_session_id = ProofResponse {
2238            id: "req_session".into(),
2239            version: RequestVersion::V1,
2240            session_id: None, // Missing!
2241            error: None,
2242            responses: vec![ResponseItem::new_session(
2243                "orb".into(),
2244                1,
2245                ZeroKnowledgeProof::default(),
2246                SessionNullifier::new(test_field_element(1001), test_field_element(42)),
2247                1_735_689_600,
2248            )],
2249        };
2250
2251        let err = request
2252            .validate_response(&response_missing_session_id)
2253            .unwrap_err();
2254        assert!(matches!(err, ValidationError::SessionIdMismatch));
2255    }
2256
2257    #[test]
2258    fn test_validate_response_requires_session_nullifier_for_session_proof() {
2259        // Request with session_id requires session_nullifier in each response item
2260        let request = ProofRequest {
2261            id: "req_session".into(),
2262            version: RequestVersion::V1,
2263            created_at: 1_735_689_600,
2264            expires_at: 1_735_689_900,
2265            rp_id: RpId::new(1),
2266            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2267            session_id: Some(SessionId::default()), // Session proof
2268            action: Some(test_field_element(42)),
2269            signature: test_signature(),
2270            nonce: test_nonce(),
2271            requests: vec![RequestItem {
2272                identifier: "orb".into(),
2273                issuer_schema_id: 1,
2274                signal: None,
2275                genesis_issued_at_min: None,
2276                expires_at_min: None,
2277            }],
2278            constraints: None,
2279        };
2280
2281        // Response with uniqueness nullifier instead of session nullifier should fail
2282        let response_wrong_nullifier_type = ProofResponse {
2283            id: "req_session".into(),
2284            version: RequestVersion::V1,
2285            session_id: Some(SessionId::default()),
2286            error: None,
2287            responses: vec![ResponseItem::new_uniqueness(
2288                "orb".into(),
2289                1,
2290                ZeroKnowledgeProof::default(),
2291                Nullifier::from(test_field_element(1001)), // Using uniqueness nullifier instead of session!
2292                1_735_689_600,
2293            )],
2294        };
2295
2296        let err = request
2297            .validate_response(&response_wrong_nullifier_type)
2298            .unwrap_err();
2299        assert!(matches!(
2300            err,
2301            ValidationError::MissingSessionNullifier(ref id) if id == "orb"
2302        ));
2303    }
2304
2305    #[test]
2306    fn test_validate_response_requires_nullifier_for_uniqueness_proof() {
2307        // Request without session_id requires nullifier in each response item
2308        let request = ProofRequest {
2309            id: "req_uniqueness".into(),
2310            version: RequestVersion::V1,
2311            created_at: 1_735_689_600,
2312            expires_at: 1_735_689_900,
2313            rp_id: RpId::new(1),
2314            oprf_key_id: OprfKeyId::new(uint!(1_U160)),
2315            session_id: None, // Uniqueness proof
2316            action: Some(test_field_element(42)),
2317            signature: test_signature(),
2318            nonce: test_nonce(),
2319            requests: vec![RequestItem {
2320                identifier: "orb".into(),
2321                issuer_schema_id: 1,
2322                signal: None,
2323                genesis_issued_at_min: None,
2324                expires_at_min: None,
2325            }],
2326            constraints: None,
2327        };
2328
2329        // Response with session nullifier instead of uniqueness nullifier should fail
2330        let response_wrong_nullifier_type = ProofResponse {
2331            id: "req_uniqueness".into(),
2332            version: RequestVersion::V1,
2333            session_id: None,
2334            error: None,
2335            responses: vec![ResponseItem::new_session(
2336                "orb".into(),
2337                1,
2338                ZeroKnowledgeProof::default(),
2339                SessionNullifier::new(test_field_element(1001), test_field_element(42)), // Using session nullifier instead of uniqueness!
2340                1_735_689_600,
2341            )],
2342        };
2343
2344        let err = request
2345            .validate_response(&response_wrong_nullifier_type)
2346            .unwrap_err();
2347        assert!(matches!(
2348            err,
2349            ValidationError::MissingNullifier(ref id) if id == "orb"
2350        ));
2351    }
2352}