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