From 2c7cec94698cd8e25c6129323fa660b34bc39321 Mon Sep 17 00:00:00 2001 From: Vladimir Burdukov Date: Mon, 18 May 2026 18:41:40 +0300 Subject: [PATCH 1/2] refactor: credential check as ProofRequest method - move check_credentials_against_proof_request into ProofRequest::check_credentials under requests/credential_check.rs - replace manual constraint depth/node checks with validate_constraints() - drop tests that duplicate world-id-primitives coverage --- ...of_request_credential_constraints_check.rs | 701 +---------------- .../src/requests/credential_check.rs | 719 ++++++++++++++++++ .../src/{requests.rs => requests/mod.rs} | 3 + 3 files changed, 729 insertions(+), 694 deletions(-) create mode 100644 crates/walletkit-core/src/requests/credential_check.rs rename crates/walletkit-core/src/{requests.rs => requests/mod.rs} (98%) diff --git a/crates/walletkit-core/src/proof_request_credential_constraints_check.rs b/crates/walletkit-core/src/proof_request_credential_constraints_check.rs index 8667f6112..85b77a1e8 100644 --- a/crates/walletkit-core/src/proof_request_credential_constraints_check.rs +++ b/crates/walletkit-core/src/proof_request_credential_constraints_check.rs @@ -1,696 +1,9 @@ -//! Pre-flight check of whether the credential store can satisfy a proof request. +//! Compatibility entry point for credential preflight checks. //! -//! # Overview -//! -//! [`crate::proof_request_credential_constraints_check::check_credentials_against_proof_request`] -//! evaluates every request item in a proof request against the contents of the local -//! [`crate::storage::CredentialStore`] and returns a -//! [`crate::proof_request_credential_constraints_check::CredentialConstraintsCheckResult`] describing: -//! -//! - **`is_satisfied`** — whether the overall request (including any constraint -//! expression) can be fulfilled with the credentials currently in the store. -//! - **`check_results`** — one -//! [`crate::proof_request_credential_constraints_check::CredentialConstraintsCheckItem`] per request item, -//! always populated regardless of `is_satisfied`, so the caller can identify -//! exactly which credentials are present or missing. -//! -//! # Per-item evaluation -//! -//! For each request item the check verifies that the store contains at least one -//! credential that is: -//! -//! 1. **Not expired** — `expires_at > now`. -//! 2. **Fresh enough** — `genesis_issued_at >= genesis_issued_at_min` (defaults to 0 -//! when the request item omits the field, meaning any issuance time is accepted). -//! 3. **Long-lived enough** — `expires_at > expires_at_min` (defaults to the proof -//! request's `created_at` when the request item omits the field). -//! -//! Multiple credentials with the same `issuer_schema_id` can exist in the store. -//! The item is considered satisfied if **any** of them passes all three checks, -//! which matches proof-generation behaviour (it selects the most recently updated -//! qualifying credential). -//! -//! # Constraint expressions -//! -//! When the proof request carries a constraint expression (`Any`, `All`, or -//! `Enumerate`), `is_satisfied` reflects whether the expression evaluates to `true` -//! given the per-item results. The expression is validated for structural limits -//! (max depth 2, max `MAX_CONSTRAINT_NODES` nodes) before evaluation; violations -//! are returned as errors rather than `is_satisfied = false`. -//! -//! When there is no constraint expression every request item must be satisfied. -//! -//! # UI usage -//! -//! `check_results` is intended for the UI layer. When `is_satisfied` is `false`, -//! iterate `check_results` and surface items where `has_credential` is `false` to -//! tell the user which credentials are missing or do not meet the request's time -//! constraints. -//! -//! # Examples -//! -//! The table below shows how constraints, available credentials, and per-item -//! results combine. ✓ = `has_credential: true`, ✗ = `has_credential: false`. -//! -//! | Request items | Constraints | Credentials in store | `is_satisfied` | `check_results` | -//! |-----------------------|----------------------------------|-----------------------|----------------|------------------------------------| -//! | orb, mnc | _(none)_ | orb, mnc | `true` | orb ✓, mnc ✓ | -//! | orb, mnc | _(none)_ | orb only | `false` | orb ✓, mnc ✗ | -//! | orb, mnc | _(none)_ | mnc only | `false` | orb ✗, mnc ✓ | -//! | orb, mnc | `Any(orb, mnc)` | orb only | `true` | orb ✓, mnc ✗ | -//! | orb, mnc | `Any(orb, mnc)` | mnc only | `true` | orb ✗, mnc ✓ | -//! | orb, mnc | `Any(orb, mnc)` | _(none)_ | `false` | orb ✗, mnc ✗ | -//! | orb, mnc | `All(orb, mnc)` | orb only | `false` | orb ✓, mnc ✗ | -//! | orb, mnc | `All(orb, mnc)` | mnc only | `false` | orb ✗, mnc ✓ | -//! | orb, mnc, passport | `All(orb, Any(mnc, passport))` | orb, mnc | `true` | orb ✓, mnc ✓, passport ✗ | -//! | orb, mnc, passport | `All(orb, Any(mnc, passport))` | orb, passport | `true` | orb ✓, mnc ✗, passport ✓ | -//! | orb, mnc, passport | `All(orb, Any(mnc, passport))` | mnc, passport | `false` | orb ✗, mnc ✓, passport ✓ | -//! | orb, mnc, passport | `All(passport, Any(orb, mnc))` | passport, mnc | `true` | orb ✗, mnc ✓, passport ✓ | -//! | orb, mnc, passport | `All(passport, Any(orb, mnc))` | orb only | `false` | orb ✓, mnc ✗, passport ✗ | - -use std::collections::HashMap; - -use world_id_core::requests::MAX_CONSTRAINT_NODES; - -use crate::requests::ProofRequest; -use crate::storage::{CredentialRecord, CredentialStore, StorageError}; - -/// Error returned by [`check_credentials_against_proof_request`]. -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum CredentialConstraintsCheckError { - /// Credential store query failed. - #[error(transparent)] - Storage(#[from] StorageError), - /// The constraint expression exceeds the maximum nesting depth of 2. - #[error("constraint nesting exceeds maximum allowed depth")] - ConstraintTooDeep, - /// The constraint expression exceeds the maximum node count. - #[error("constraints exceed maximum allowed size")] - ConstraintTooLarge, -} - -/// Check result for a single request item. -#[derive(Debug, Clone, uniffi::Record)] -pub struct CredentialConstraintsCheckItem { - /// The RP-defined identifier for this request item (e.g. `"orb"`, `"document"`). - pub identifier: String, - /// Issuer schema ID required by this item. - pub issuer_schema_id: u64, - /// `true` when the store contains at least one non-expired credential that meets - /// all time constraints (`genesis_issued_at_min`, `expires_at_min`) for this item. - pub has_credential: bool, -} - -/// Result of [`check_credentials_against_proof_request`]. -#[derive(Debug, Clone, uniffi::Record)] -pub struct CredentialConstraintsCheckResult { - /// `true` when the constraint tree (or all items, if no constraints) is satisfied. - pub is_satisfied: bool, - /// One entry per request item in the proof request, in the same order. - /// - /// Always populated regardless of `is_satisfied`. When `is_satisfied` is `false`, - /// items with `has_credential = false` identify what is missing or does not meet - /// the request's time constraints. - pub check_results: Vec, -} - -/// Checks whether `store` holds the credentials required to fulfill `request`. -/// -/// See the [module-level documentation](self) for a full description of the -/// evaluation logic and intended usage. -/// -/// # Errors -/// -/// - [`CredentialConstraintsCheckError::Storage`] if the credential store query fails. -/// - [`CredentialConstraintsCheckError::ConstraintTooDeep`] if the constraint tree exceeds depth 2. -/// - [`CredentialConstraintsCheckError::ConstraintTooLarge`] if the constraint tree exceeds the node limit. -#[uniffi::export] -pub fn check_credentials_against_proof_request( - request: &ProofRequest, - store: &CredentialStore, - now: u64, -) -> Result { - let records = store.list_credentials(None, now)?; - - let mut by_schema: HashMap> = HashMap::new(); - for r in records.iter().filter(|r| !r.is_expired) { - by_schema.entry(r.issuer_schema_id).or_default().push(r); - } - - let mut check_results: Vec = Vec::new(); - for item in &request.0.requests { - let expires_min = item.expires_at_min.unwrap_or(request.0.created_at); - let genesis_min = item.genesis_issued_at_min.unwrap_or(0); - - let has_credential = - by_schema.get(&item.issuer_schema_id).is_some_and(|creds| { - creds.iter().any(|r| { - r.expires_at > expires_min && r.genesis_issued_at >= genesis_min - }) - }); - - check_results.push(CredentialConstraintsCheckItem { - identifier: item.identifier.clone(), - issuer_schema_id: item.issuer_schema_id, - has_credential, - }); - } - - let is_satisfied = match &request.0.constraints { - None => check_results.iter().all(|i| i.has_credential), - Some(expr) => { - // TODO: replace with `request.0.validate_constraints()?` once - // walletkit bumps world-id-core to 0.11.x. - if !expr.validate_max_depth(2) { - return Err(CredentialConstraintsCheckError::ConstraintTooDeep); - } - if !expr.validate_max_nodes(MAX_CONSTRAINT_NODES) { - return Err(CredentialConstraintsCheckError::ConstraintTooLarge); - } - expr.evaluate(&|id: &str| { - check_results - .iter() - .any(|i| i.identifier == id && i.has_credential) - }) - } - }; - - Ok(CredentialConstraintsCheckResult { - is_satisfied, - check_results, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - use alloy_core::primitives::{Signature, U160}; - use world_id_core::{ - primitives::{rp::RpId, OprfKeyId, SessionRef}, - requests::{ - ConstraintExpr, ConstraintNode, ProofRequest as CoreProofRequest, - ProofType, RequestItem, RequestVersion, - }, - FieldElement as CoreFieldElement, - }; - - use crate::{ - storage::tests_utils::{ - cleanup_test_storage, temp_root_path, InMemoryStorageProvider, - }, - Credential, FieldElement, - }; - use world_id_core::Credential as CoreCredential; - - fn dummy_request( - items: Vec, - constraints: Option>, - ) -> ProofRequest { - let core = CoreProofRequest { - id: "test".to_string(), - version: RequestVersion::V1, - proof_type: ProofType::Uniqueness, - created_at: 0, - expires_at: u64::MAX, - rp_id: RpId::new(1), - oprf_key_id: OprfKeyId::new(U160::from(1u64)), - session_id: SessionRef::None, - action: None, - signature: Signature::test_signature(), - nonce: CoreFieldElement::ZERO, - requests: items, - constraints, - }; - ProofRequest(core) - } - - fn store_with_credentials( - issuer_ids: &[u64], - now: u64, - ) -> (CredentialStore, std::path::PathBuf) { - let root = temp_root_path(); - let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); - store.init(42, now).expect("init"); - - for &id in issuer_ids { - let cred: Credential = CoreCredential::new() - .issuer_schema_id(id) - .genesis_issued_at(now) - .into(); - store - .store_credential( - &cred, - &FieldElement::from(1u64), - now + 9999, - None, - now, - ) - .expect("store credential"); - } - (store, root) - } - - #[test] - fn no_constraints_all_satisfied() { - let now = 1000; - let (store, root) = store_with_credentials(&[100, 200], now); - let request = dummy_request( - vec![ - RequestItem::new("a".into(), 100, None, None, None), - RequestItem::new("b".into(), 200, None, None, None), - ], - None, - ); - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(result.is_satisfied); - assert!(result.check_results.iter().all(|i| i.has_credential)); - cleanup_test_storage(&root); - } - - #[test] - fn no_constraints_one_missing() { - let now = 1000; - let (store, root) = store_with_credentials(&[100], now); - let request = dummy_request( - vec![ - RequestItem::new("a".into(), 100, None, None, None), - RequestItem::new("b".into(), 999, None, None, None), - ], - None, - ); - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(!result.is_satisfied); - assert!(result.check_results[0].has_credential); - assert!(!result.check_results[1].has_credential); - assert_eq!(result.check_results[1].identifier, "b"); - assert_eq!(result.check_results[1].issuer_schema_id, 999); - cleanup_test_storage(&root); - } - - #[test] - fn expired_credential_not_counted() { - let now = 5000; - let root = temp_root_path(); - let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); - store.init(42, 1000).expect("init"); - - let cred: Credential = CoreCredential::new() - .issuer_schema_id(100) - .genesis_issued_at(1000) - .into(); - store - .store_credential(&cred, &FieldElement::from(1u64), 2000, None, 1000) - .expect("store"); - - let request = dummy_request( - vec![RequestItem::new("a".into(), 100, None, None, None)], - None, - ); - - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(!result.is_satisfied); - assert!(!result.check_results[0].has_credential); - cleanup_test_storage(&root); - } - - #[test] - fn any_constraint_one_branch_satisfied() { - let now = 1000; - let (store, root) = store_with_credentials(&[100], now); - let request = dummy_request( - vec![ - RequestItem::new("a".into(), 100, None, None, None), - RequestItem::new("b".into(), 999, None, None, None), - ], - Some(ConstraintExpr::Any { - any: vec![ - ConstraintNode::Type("a".into()), - ConstraintNode::Type("b".into()), - ], - }), - ); - assert!( - check_credentials_against_proof_request(&request, &store, now) - .unwrap() - .is_satisfied - ); - cleanup_test_storage(&root); - } - - #[test] - fn all_constraint_one_branch_missing() { - let now = 1000; - let (store, root) = store_with_credentials(&[100], now); - let request = dummy_request( - vec![ - RequestItem::new("a".into(), 100, None, None, None), - RequestItem::new("b".into(), 999, None, None, None), - ], - Some(ConstraintExpr::All { - all: vec![ - ConstraintNode::Type("a".into()), - ConstraintNode::Type("b".into()), - ], - }), - ); - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(!result.is_satisfied); - assert!(result.check_results[0].has_credential); - assert!(!result.check_results[1].has_credential); - cleanup_test_storage(&root); - } - - #[test] - fn enumerate_constraint_any_branch_satisfies() { - let now = 1000; - let (store, root) = store_with_credentials(&[200], now); - let request = dummy_request( - vec![ - RequestItem::new("a".into(), 999, None, None, None), - RequestItem::new("b".into(), 200, None, None, None), - ], - Some(ConstraintExpr::Enumerate { - enumerate: vec![ - ConstraintNode::Type("a".into()), - ConstraintNode::Type("b".into()), - ], - }), - ); - assert!( - check_credentials_against_proof_request(&request, &store, now) - .unwrap() - .is_satisfied - ); - cleanup_test_storage(&root); - } - - #[test] - fn enumerate_constraint_none_available() { - let now = 1000; - let (store, root) = store_with_credentials(&[], now); - let request = dummy_request( - vec![ - RequestItem::new("a".into(), 100, None, None, None), - RequestItem::new("b".into(), 200, None, None, None), - ], - Some(ConstraintExpr::Enumerate { - enumerate: vec![ - ConstraintNode::Type("a".into()), - ConstraintNode::Type("b".into()), - ], - }), - ); - assert!( - !check_credentials_against_proof_request(&request, &store, now) - .unwrap() - .is_satisfied - ); - cleanup_test_storage(&root); - } - - // ----------------------------------------------------------------------- - // Table cases: A or B or C / A and (B or C) - // ----------------------------------------------------------------------- - - fn three_item_request(constraints: ConstraintExpr<'static>) -> ProofRequest { - dummy_request( - vec![ - RequestItem::new("a".into(), 100, None, None, None), - RequestItem::new("b".into(), 200, None, None, None), - RequestItem::new("c".into(), 300, None, None, None), - ], - Some(constraints), - ) - } - - fn any_a_or_b_or_c() -> ConstraintExpr<'static> { - ConstraintExpr::Any { - any: vec![ - ConstraintNode::Type("a".into()), - ConstraintNode::Type("b".into()), - ConstraintNode::Type("c".into()), - ], - } - } - - fn all_a_and_b_or_c() -> ConstraintExpr<'static> { - ConstraintExpr::All { - all: vec![ - ConstraintNode::Type("a".into()), - ConstraintNode::Expr(ConstraintExpr::Any { - any: vec![ - ConstraintNode::Type("b".into()), - ConstraintNode::Type("c".into()), - ], - }), - ], - } - } - - // A or B or C — only A present → True - #[test] - fn any_abc_only_a_satisfies() { - let now = 1000; - let (store, root) = store_with_credentials(&[100], now); - let result = check_credentials_against_proof_request( - &three_item_request(any_a_or_b_or_c()), - &store, - now, - ) - .unwrap(); - assert!(result.is_satisfied); - assert!( - result - .check_results - .iter() - .find(|i| i.identifier == "a") - .unwrap() - .has_credential - ); - assert!( - !result - .check_results - .iter() - .find(|i| i.identifier == "b") - .unwrap() - .has_credential - ); - assert!( - !result - .check_results - .iter() - .find(|i| i.identifier == "c") - .unwrap() - .has_credential - ); - cleanup_test_storage(&root); - } - - // A or B or C — only B present → True - #[test] - fn any_abc_only_b_satisfies() { - let now = 1000; - let (store, root) = store_with_credentials(&[200], now); - let result = check_credentials_against_proof_request( - &three_item_request(any_a_or_b_or_c()), - &store, - now, - ) - .unwrap(); - assert!(result.is_satisfied); - assert!( - !result - .check_results - .iter() - .find(|i| i.identifier == "a") - .unwrap() - .has_credential - ); - assert!( - result - .check_results - .iter() - .find(|i| i.identifier == "b") - .unwrap() - .has_credential - ); - assert!( - !result - .check_results - .iter() - .find(|i| i.identifier == "c") - .unwrap() - .has_credential - ); - cleanup_test_storage(&root); - } - - // A or B or C — none present → False - #[test] - fn any_abc_none_present() { - let now = 1000; - let (store, root) = store_with_credentials(&[], now); - let result = check_credentials_against_proof_request( - &three_item_request(any_a_or_b_or_c()), - &store, - now, - ) - .unwrap(); - assert!(!result.is_satisfied); - assert!(result.check_results.iter().all(|i| !i.has_credential)); - cleanup_test_storage(&root); - } - - // A and (B or C) — none present → False - #[test] - fn all_a_any_bc_none_present() { - let now = 1000; - let (store, root) = store_with_credentials(&[], now); - let result = check_credentials_against_proof_request( - &three_item_request(all_a_and_b_or_c()), - &store, - now, - ) - .unwrap(); - assert!(!result.is_satisfied); - cleanup_test_storage(&root); - } - - // A and (B or C) — A, B, C all present → True (A satisfies A; B satisfies B or C) - #[test] - fn all_a_any_bc_all_present() { - let now = 1000; - let (store, root) = store_with_credentials(&[100, 200, 300], now); - let result = check_credentials_against_proof_request( - &three_item_request(all_a_and_b_or_c()), - &store, - now, - ) - .unwrap(); - assert!(result.is_satisfied); - assert!(result.check_results.iter().all(|i| i.has_credential)); - cleanup_test_storage(&root); - } - - #[test] - fn constraint_too_deep_returns_error() { - let now = 1000; - let (store, root) = store_with_credentials(&[100], now); - let deep = ConstraintExpr::All { - all: vec![ConstraintNode::Expr(ConstraintExpr::Any { - any: vec![ConstraintNode::Expr(ConstraintExpr::All { - all: vec![ConstraintNode::Type("a".into())], - })], - })], - }; - let request = dummy_request( - vec![RequestItem::new("a".into(), 100, None, None, None)], - Some(deep), - ); - let err = - check_credentials_against_proof_request(&request, &store, now).unwrap_err(); - assert!(matches!( - err, - CredentialConstraintsCheckError::ConstraintTooDeep - )); - cleanup_test_storage(&root); - } - - #[test] - fn constraint_too_large_returns_error() { - use world_id_core::requests::MAX_CONSTRAINT_NODES; - let now = 1000; - let (store, root) = store_with_credentials(&[], now); - // Build a flat Any with MAX_CONSTRAINT_NODES + 1 leaves to exceed the limit. - let nodes: Vec> = (0..=MAX_CONSTRAINT_NODES) - .map(|i| ConstraintNode::Type(format!("t{i}").into())) - .collect(); - let expr = ConstraintExpr::Any { any: nodes }; - let items: Vec = (0..=MAX_CONSTRAINT_NODES) - .map(|i| RequestItem::new(format!("t{i}"), i as u64, None, None, None)) - .collect(); - let request = dummy_request(items, Some(expr)); - let err = - check_credentials_against_proof_request(&request, &store, now).unwrap_err(); - assert!(matches!( - err, - CredentialConstraintsCheckError::ConstraintTooLarge - )); - cleanup_test_storage(&root); - } - - fn store_with_credential_times( - issuer_id: u64, - genesis_issued_at: u64, - expires_at: u64, - now: u64, - ) -> (CredentialStore, std::path::PathBuf) { - let root = temp_root_path(); - let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); - store.init(42, now).expect("init"); - let cred: Credential = CoreCredential::new() - .issuer_schema_id(issuer_id) - .genesis_issued_at(genesis_issued_at) - .into(); - store - .store_credential(&cred, &FieldElement::from(1u64), expires_at, None, now) - .expect("store credential"); - (store, root) - } - - #[test] - fn genesis_issued_at_min_not_met_returns_unsatisfied() { - let now = 1000; - // Credential was issued at t=500; request requires t>=600. - let (store, root) = store_with_credential_times(100, 500, now + 9999, now); - let request = dummy_request( - vec![RequestItem::new("a".into(), 100, None, Some(600), None)], - None, - ); - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(!result.is_satisfied); - assert!(!result.check_results[0].has_credential); - cleanup_test_storage(&root); - } - - #[test] - fn expires_at_min_not_met_returns_unsatisfied() { - let now = 1000; - // Credential expires at t=2000; request requires expires_at >= 5000. - let (store, root) = store_with_credential_times(100, now, 2000, now); - let request = dummy_request( - vec![RequestItem::new("a".into(), 100, None, None, Some(5000))], - None, - ); - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(!result.is_satisfied); - assert!(!result.check_results[0].has_credential); - cleanup_test_storage(&root); - } +//! New callers can use [`crate::requests::ProofRequest::check_credentials`]. +//! The free function and result types remain available for existing consumers. - #[test] - fn expires_at_min_equal_to_expires_at_returns_unsatisfied() { - let now = 1000; - // Boundary: expires_at == expires_at_min is rejected by the circuit (strict >). - let (store, root) = store_with_credential_times(100, now, 5000, now); - let request = dummy_request( - vec![RequestItem::new("a".into(), 100, None, None, Some(5000))], - None, - ); - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(!result.is_satisfied); - assert!(!result.check_results[0].has_credential); - cleanup_test_storage(&root); - } -} +pub use crate::requests::credential_check::{ + check_credentials_against_proof_request, CredentialConstraintsCheckError, + CredentialConstraintsCheckItem, CredentialConstraintsCheckResult, +}; diff --git a/crates/walletkit-core/src/requests/credential_check.rs b/crates/walletkit-core/src/requests/credential_check.rs new file mode 100644 index 000000000..525112bcd --- /dev/null +++ b/crates/walletkit-core/src/requests/credential_check.rs @@ -0,0 +1,719 @@ +//! Pre-flight check of whether the credential store can satisfy a proof request. +//! +//! # Overview +//! +//! [`crate::proof_request_credential_constraints_check::check_credentials_against_proof_request`] +//! evaluates every request item in a proof request against the contents of the local +//! [`crate::storage::CredentialStore`] and returns a +//! [`crate::proof_request_credential_constraints_check::CredentialConstraintsCheckResult`] describing: +//! +//! - **`is_satisfied`** — whether the overall request (including any constraint +//! expression) can be fulfilled with the credentials currently in the store. +//! - **`check_results`** — one +//! [`crate::proof_request_credential_constraints_check::CredentialConstraintsCheckItem`] per request item, +//! always populated regardless of `is_satisfied`, so the caller can identify +//! exactly which credentials are present or missing. +//! +//! # Per-item evaluation +//! +//! For each request item the check verifies that the store contains at least one +//! credential that is: +//! +//! 1. **Not expired** — `expires_at > now`. +//! 2. **Fresh enough** — `genesis_issued_at >= genesis_issued_at_min` (defaults to 0 +//! when the request item omits the field, meaning any issuance time is accepted). +//! 3. **Long-lived enough** — `expires_at > expires_at_min` (defaults to the proof +//! request's `created_at` when the request item omits the field). +//! +//! Multiple credentials with the same `issuer_schema_id` can exist in the store. +//! The item is considered satisfied if **any** of them passes all three checks, +//! which matches proof-generation behaviour (it selects the most recently updated +//! qualifying credential). +//! +//! # Constraint expressions +//! +//! When the proof request carries a constraint expression (`Any`, `All`, or +//! `Enumerate`), `is_satisfied` reflects whether the expression evaluates to `true` +//! given the per-item results. The expression is validated for structural limits +//! (max depth 2, max `MAX_CONSTRAINT_NODES` nodes) before evaluation; violations +//! are returned as errors rather than `is_satisfied = false`. +//! +//! When there is no constraint expression every request item must be satisfied. +//! +//! # UI usage +//! +//! `check_results` is intended for the UI layer. When `is_satisfied` is `false`, +//! iterate `check_results` and surface items where `has_credential` is `false` to +//! tell the user which credentials are missing or do not meet the request's time +//! constraints. +//! +//! # Examples +//! +//! The table below shows how constraints, available credentials, and per-item +//! results combine. ✓ = `has_credential: true`, ✗ = `has_credential: false`. +//! +//! | Request items | Constraints | Credentials in store | `is_satisfied` | `check_results` | +//! |-----------------------|----------------------------------|-----------------------|----------------|------------------------------------| +//! | orb, mnc | _(none)_ | orb, mnc | `true` | orb ✓, mnc ✓ | +//! | orb, mnc | _(none)_ | orb only | `false` | orb ✓, mnc ✗ | +//! | orb, mnc | _(none)_ | mnc only | `false` | orb ✗, mnc ✓ | +//! | orb, mnc | `Any(orb, mnc)` | orb only | `true` | orb ✓, mnc ✗ | +//! | orb, mnc | `Any(orb, mnc)` | mnc only | `true` | orb ✗, mnc ✓ | +//! | orb, mnc | `Any(orb, mnc)` | _(none)_ | `false` | orb ✗, mnc ✗ | +//! | orb, mnc | `All(orb, mnc)` | orb only | `false` | orb ✓, mnc ✗ | +//! | orb, mnc | `All(orb, mnc)` | mnc only | `false` | orb ✗, mnc ✓ | +//! | orb, mnc, passport | `All(orb, Any(mnc, passport))` | orb, mnc | `true` | orb ✓, mnc ✓, passport ✗ | +//! | orb, mnc, passport | `All(orb, Any(mnc, passport))` | orb, passport | `true` | orb ✓, mnc ✗, passport ✓ | +//! | orb, mnc, passport | `All(orb, Any(mnc, passport))` | mnc, passport | `false` | orb ✗, mnc ✓, passport ✓ | +//! | orb, mnc, passport | `All(passport, Any(orb, mnc))` | passport, mnc | `true` | orb ✗, mnc ✓, passport ✓ | +//! | orb, mnc, passport | `All(passport, Any(orb, mnc))` | orb only | `false` | orb ✓, mnc ✗, passport ✗ | + +use std::collections::HashMap; + +use world_id_core::requests::ValidationError; + +use crate::requests::ProofRequest; +use crate::storage::{CredentialRecord, CredentialStore, StorageError}; + +/// Error returned by [`check_credentials_against_proof_request`]. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum CredentialConstraintsCheckError { + /// Credential store query failed. + #[error(transparent)] + Storage(#[from] StorageError), + /// The constraint expression exceeds the maximum nesting depth of 2. + #[error("constraint nesting exceeds maximum allowed depth")] + ConstraintTooDeep, + /// The constraint expression exceeds the maximum node count. + #[error("constraints exceed maximum allowed size")] + ConstraintTooLarge, + /// An unexpected structural validation error from the protocol. + #[error("unexpected constraint validation error: {0}")] + Unknown(String), +} + +impl From for CredentialConstraintsCheckError { + fn from(error: ValidationError) -> Self { + match error { + ValidationError::ConstraintTooDeep => Self::ConstraintTooDeep, + ValidationError::ConstraintTooLarge => Self::ConstraintTooLarge, + other => Self::Unknown(other.to_string()), + } + } +} + +/// Check result for a single request item. +#[derive(Debug, Clone, uniffi::Record)] +pub struct CredentialConstraintsCheckItem { + /// The RP-defined identifier for this request item (e.g. `"orb"`, `"document"`). + pub identifier: String, + /// Issuer schema ID required by this item. + pub issuer_schema_id: u64, + /// `true` when the store contains at least one non-expired credential that meets + /// all time constraints (`genesis_issued_at_min`, `expires_at_min`) for this item. + pub has_credential: bool, +} + +/// Result of [`check_credentials_against_proof_request`]. +#[derive(Debug, Clone, uniffi::Record)] +pub struct CredentialConstraintsCheckResult { + /// `true` when the constraint tree (or all items, if no constraints) is satisfied. + pub is_satisfied: bool, + /// One entry per request item in the proof request, in the same order. + /// + /// Always populated regardless of `is_satisfied`. When `is_satisfied` is `false`, + /// items with `has_credential = false` identify what is missing or does not meet + /// the request's time constraints. + pub check_results: Vec, +} + +/// Checks whether `store` holds the credentials required to fulfill `request`. +/// +/// See the [module-level documentation](self) for a full description of the +/// evaluation logic and intended usage. +/// +/// # Errors +/// +/// - [`CredentialConstraintsCheckError::Storage`] if the credential store query fails. +/// - [`CredentialConstraintsCheckError::ConstraintTooDeep`] if the constraint tree exceeds depth 2. +/// - [`CredentialConstraintsCheckError::ConstraintTooLarge`] if the constraint tree exceeds the node limit. +#[uniffi::export] +pub fn check_credentials_against_proof_request( + request: &ProofRequest, + store: &CredentialStore, + now: u64, +) -> Result { + request.check_credentials(store, now) +} + +#[uniffi::export] +impl ProofRequest { + /// Checks local credential availability and time requirements for each request + /// item, then evaluates the request's constraints without generating a proof. + /// + /// # Errors + /// Returns an error if constraint validation or reading the credential store fails. + pub fn check_credentials( + &self, + store: &CredentialStore, + now: u64, + ) -> Result { + self.0.validate_constraints()?; + let records = store.list_credentials(None, now)?; + + let mut by_schema: HashMap> = HashMap::new(); + for r in records.iter().filter(|r| !r.is_expired) { + by_schema.entry(r.issuer_schema_id).or_default().push(r); + } + + let mut check_results: Vec = Vec::new(); + for item in &self.0.requests { + let expires_min = item.expires_at_min.unwrap_or(self.0.created_at); + let genesis_min = item.genesis_issued_at_min.unwrap_or(0); + + let has_credential = + by_schema.get(&item.issuer_schema_id).is_some_and(|creds| { + creds.iter().any(|r| { + r.expires_at > expires_min && r.genesis_issued_at >= genesis_min + }) + }); + + check_results.push(CredentialConstraintsCheckItem { + identifier: item.identifier.clone(), + issuer_schema_id: item.issuer_schema_id, + has_credential, + }); + } + + let is_satisfied = match &self.0.constraints { + None => check_results.iter().all(|i| i.has_credential), + Some(expr) => { + expr.evaluate(&|id: &str| { + check_results + .iter() + .any(|i| i.identifier == id && i.has_credential) + }) + } + }; + + Ok(CredentialConstraintsCheckResult { + is_satisfied, + check_results, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use world_id_core::requests::MAX_CONSTRAINT_NODES; + + use alloy_core::primitives::{Signature, U160}; + use world_id_core::{ + primitives::{rp::RpId, OprfKeyId, SessionRef}, + requests::{ + ConstraintExpr, ConstraintNode, ProofRequest as CoreProofRequest, + ProofType, RequestItem, RequestVersion, + }, + FieldElement as CoreFieldElement, + }; + + use crate::{ + storage::tests_utils::{ + cleanup_test_storage, temp_root_path, InMemoryStorageProvider, + }, + Credential, FieldElement, + }; + use world_id_core::Credential as CoreCredential; + + fn dummy_request( + items: Vec, + constraints: Option>, + ) -> ProofRequest { + let core = CoreProofRequest { + id: "test".to_string(), + version: RequestVersion::V1, + proof_type: ProofType::Uniqueness, + created_at: 0, + expires_at: u64::MAX, + rp_id: RpId::new(1), + oprf_key_id: OprfKeyId::new(U160::from(1u64)), + session_id: SessionRef::None, + action: None, + signature: Signature::test_signature(), + nonce: CoreFieldElement::ZERO, + requests: items, + constraints, + }; + ProofRequest(core) + } + + fn store_with_credentials( + issuer_ids: &[u64], + now: u64, + ) -> (CredentialStore, std::path::PathBuf) { + let root = temp_root_path(); + let provider = InMemoryStorageProvider::new(&root); + let store = CredentialStore::from_provider(&provider).expect("create store"); + store.init(42, now).expect("init"); + + for &id in issuer_ids { + let cred: Credential = CoreCredential::new() + .issuer_schema_id(id) + .genesis_issued_at(now) + .into(); + store + .store_credential( + &cred, + &FieldElement::from(1u64), + now + 9999, + None, + now, + ) + .expect("store credential"); + } + (store, root) + } + + #[test] + fn no_constraints_all_satisfied() { + let now = 1000; + let (store, root) = store_with_credentials(&[100, 200], now); + let request = dummy_request( + vec![ + RequestItem::new("a".into(), 100, None, None, None), + RequestItem::new("b".into(), 200, None, None, None), + ], + None, + ); + let result = + check_credentials_against_proof_request(&request, &store, now).unwrap(); + assert!(result.is_satisfied); + assert!(result.check_results.iter().all(|i| i.has_credential)); + cleanup_test_storage(&root); + } + + #[test] + fn no_constraints_one_missing() { + let now = 1000; + let (store, root) = store_with_credentials(&[100], now); + let request = dummy_request( + vec![ + RequestItem::new("a".into(), 100, None, None, None), + RequestItem::new("b".into(), 999, None, None, None), + ], + None, + ); + let result = + check_credentials_against_proof_request(&request, &store, now).unwrap(); + assert!(!result.is_satisfied); + assert!(result.check_results[0].has_credential); + assert!(!result.check_results[1].has_credential); + assert_eq!(result.check_results[1].identifier, "b"); + assert_eq!(result.check_results[1].issuer_schema_id, 999); + cleanup_test_storage(&root); + } + + #[test] + fn expired_credential_not_counted() { + let now = 5000; + let root = temp_root_path(); + let provider = InMemoryStorageProvider::new(&root); + let store = CredentialStore::from_provider(&provider).expect("create store"); + store.init(42, 1000).expect("init"); + + let cred: Credential = CoreCredential::new() + .issuer_schema_id(100) + .genesis_issued_at(1000) + .into(); + store + .store_credential(&cred, &FieldElement::from(1u64), 2000, None, 1000) + .expect("store"); + + let request = dummy_request( + vec![RequestItem::new("a".into(), 100, None, None, None)], + None, + ); + + let result = + check_credentials_against_proof_request(&request, &store, now).unwrap(); + assert!(!result.is_satisfied); + assert!(!result.check_results[0].has_credential); + cleanup_test_storage(&root); + } + + #[test] + fn any_constraint_one_branch_satisfied() { + let now = 1000; + let (store, root) = store_with_credentials(&[100], now); + let request = dummy_request( + vec![ + RequestItem::new("a".into(), 100, None, None, None), + RequestItem::new("b".into(), 999, None, None, None), + ], + Some(ConstraintExpr::Any { + any: vec![ + ConstraintNode::Type("a".into()), + ConstraintNode::Type("b".into()), + ], + }), + ); + assert!( + check_credentials_against_proof_request(&request, &store, now) + .unwrap() + .is_satisfied + ); + cleanup_test_storage(&root); + } + + #[test] + fn all_constraint_one_branch_missing() { + let now = 1000; + let (store, root) = store_with_credentials(&[100], now); + let request = dummy_request( + vec![ + RequestItem::new("a".into(), 100, None, None, None), + RequestItem::new("b".into(), 999, None, None, None), + ], + Some(ConstraintExpr::All { + all: vec![ + ConstraintNode::Type("a".into()), + ConstraintNode::Type("b".into()), + ], + }), + ); + let result = + check_credentials_against_proof_request(&request, &store, now).unwrap(); + assert!(!result.is_satisfied); + assert!(result.check_results[0].has_credential); + assert!(!result.check_results[1].has_credential); + cleanup_test_storage(&root); + } + + #[test] + fn enumerate_constraint_any_branch_satisfies() { + let now = 1000; + let (store, root) = store_with_credentials(&[200], now); + let request = dummy_request( + vec![ + RequestItem::new("a".into(), 999, None, None, None), + RequestItem::new("b".into(), 200, None, None, None), + ], + Some(ConstraintExpr::Enumerate { + enumerate: vec![ + ConstraintNode::Type("a".into()), + ConstraintNode::Type("b".into()), + ], + }), + ); + assert!( + check_credentials_against_proof_request(&request, &store, now) + .unwrap() + .is_satisfied + ); + cleanup_test_storage(&root); + } + + #[test] + fn enumerate_constraint_none_available() { + let now = 1000; + let (store, root) = store_with_credentials(&[], now); + let request = dummy_request( + vec![ + RequestItem::new("a".into(), 100, None, None, None), + RequestItem::new("b".into(), 200, None, None, None), + ], + Some(ConstraintExpr::Enumerate { + enumerate: vec![ + ConstraintNode::Type("a".into()), + ConstraintNode::Type("b".into()), + ], + }), + ); + assert!( + !check_credentials_against_proof_request(&request, &store, now) + .unwrap() + .is_satisfied + ); + cleanup_test_storage(&root); + } + + // ----------------------------------------------------------------------- + // Table cases: A or B or C / A and (B or C) + // ----------------------------------------------------------------------- + + fn three_item_request(constraints: ConstraintExpr<'static>) -> ProofRequest { + dummy_request( + vec![ + RequestItem::new("a".into(), 100, None, None, None), + RequestItem::new("b".into(), 200, None, None, None), + RequestItem::new("c".into(), 300, None, None, None), + ], + Some(constraints), + ) + } + + fn any_a_or_b_or_c() -> ConstraintExpr<'static> { + ConstraintExpr::Any { + any: vec![ + ConstraintNode::Type("a".into()), + ConstraintNode::Type("b".into()), + ConstraintNode::Type("c".into()), + ], + } + } + + fn all_a_and_b_or_c() -> ConstraintExpr<'static> { + ConstraintExpr::All { + all: vec![ + ConstraintNode::Type("a".into()), + ConstraintNode::Expr(ConstraintExpr::Any { + any: vec![ + ConstraintNode::Type("b".into()), + ConstraintNode::Type("c".into()), + ], + }), + ], + } + } + + // A or B or C — only A present → True + #[test] + fn any_abc_only_a_satisfies() { + let now = 1000; + let (store, root) = store_with_credentials(&[100], now); + let result = check_credentials_against_proof_request( + &three_item_request(any_a_or_b_or_c()), + &store, + now, + ) + .unwrap(); + assert!(result.is_satisfied); + assert!( + result + .check_results + .iter() + .find(|i| i.identifier == "a") + .unwrap() + .has_credential + ); + assert!( + !result + .check_results + .iter() + .find(|i| i.identifier == "b") + .unwrap() + .has_credential + ); + assert!( + !result + .check_results + .iter() + .find(|i| i.identifier == "c") + .unwrap() + .has_credential + ); + cleanup_test_storage(&root); + } + + // A or B or C — only B present → True + #[test] + fn any_abc_only_b_satisfies() { + let now = 1000; + let (store, root) = store_with_credentials(&[200], now); + let result = check_credentials_against_proof_request( + &three_item_request(any_a_or_b_or_c()), + &store, + now, + ) + .unwrap(); + assert!(result.is_satisfied); + assert!( + !result + .check_results + .iter() + .find(|i| i.identifier == "a") + .unwrap() + .has_credential + ); + assert!( + result + .check_results + .iter() + .find(|i| i.identifier == "b") + .unwrap() + .has_credential + ); + assert!( + !result + .check_results + .iter() + .find(|i| i.identifier == "c") + .unwrap() + .has_credential + ); + cleanup_test_storage(&root); + } + + // A or B or C — none present → False + #[test] + fn any_abc_none_present() { + let now = 1000; + let (store, root) = store_with_credentials(&[], now); + let result = check_credentials_against_proof_request( + &three_item_request(any_a_or_b_or_c()), + &store, + now, + ) + .unwrap(); + assert!(!result.is_satisfied); + assert!(result.check_results.iter().all(|i| !i.has_credential)); + cleanup_test_storage(&root); + } + + // A and (B or C) — none present → False + #[test] + fn all_a_any_bc_none_present() { + let now = 1000; + let (store, root) = store_with_credentials(&[], now); + let result = check_credentials_against_proof_request( + &three_item_request(all_a_and_b_or_c()), + &store, + now, + ) + .unwrap(); + assert!(!result.is_satisfied); + cleanup_test_storage(&root); + } + + // A and (B or C) — A, B, C all present → True (A satisfies A; B satisfies B or C) + #[test] + fn all_a_any_bc_all_present() { + let now = 1000; + let (store, root) = store_with_credentials(&[100, 200, 300], now); + let result = check_credentials_against_proof_request( + &three_item_request(all_a_and_b_or_c()), + &store, + now, + ) + .unwrap(); + assert!(result.is_satisfied); + assert!(result.check_results.iter().all(|i| i.has_credential)); + cleanup_test_storage(&root); + } + + #[test] + fn constraint_too_deep_returns_error() { + let now = 1000; + let (store, root) = store_with_credentials(&[100], now); + let deep = ConstraintExpr::All { + all: vec![ConstraintNode::Expr(ConstraintExpr::Any { + any: vec![ConstraintNode::Expr(ConstraintExpr::All { + all: vec![ConstraintNode::Type("a".into())], + })], + })], + }; + let request = dummy_request( + vec![RequestItem::new("a".into(), 100, None, None, None)], + Some(deep), + ); + let err = + check_credentials_against_proof_request(&request, &store, now).unwrap_err(); + assert!(matches!( + err, + CredentialConstraintsCheckError::ConstraintTooDeep + )); + cleanup_test_storage(&root); + } + + #[test] + fn constraint_too_large_returns_error() { + use world_id_core::requests::ValidationError; + let now = 1000; + let (store, root) = store_with_credentials(&[], now); + // Build a flat Any with MAX_CONSTRAINT_NODES + 1 leaves to exceed the limit. + let nodes: Vec> = (0..=MAX_CONSTRAINT_NODES) + .map(|i| ConstraintNode::Type(format!("t{i}").into())) + .collect(); + let expr = ConstraintExpr::Any { any: nodes }; + let items: Vec = (0..=MAX_CONSTRAINT_NODES) + .map(|i| RequestItem::new(format!("t{i}"), i as u64, None, None, None)) + .collect(); + let request = dummy_request(items, Some(expr)); + let err = + check_credentials_against_proof_request(&request, &store, now).unwrap_err(); + assert!(matches!( + err, + CredentialConstraintsCheckError::ConstraintTooLarge + )); + cleanup_test_storage(&root); + } + + fn store_with_credential_times( + issuer_id: u64, + genesis_issued_at: u64, + expires_at: u64, + now: u64, + ) -> (CredentialStore, std::path::PathBuf) { + let root = temp_root_path(); + let provider = InMemoryStorageProvider::new(&root); + let store = CredentialStore::from_provider(&provider).expect("create store"); + store.init(42, now).expect("init"); + let cred: Credential = CoreCredential::new() + .issuer_schema_id(issuer_id) + .genesis_issued_at(genesis_issued_at) + .into(); + store + .store_credential(&cred, &FieldElement::from(1u64), expires_at, None, now) + .expect("store credential"); + (store, root) + } + + #[test] + fn genesis_issued_at_min_not_met_returns_unsatisfied() { + let now = 1000; + // Credential was issued at t=500; request requires t>=600. + let (store, root) = store_with_credential_times(100, 500, now + 9999, now); + let request = dummy_request( + vec![RequestItem::new("a".into(), 100, None, Some(600), None)], + None, + ); + let result = + check_credentials_against_proof_request(&request, &store, now).unwrap(); + assert!(!result.is_satisfied); + assert!(!result.check_results[0].has_credential); + cleanup_test_storage(&root); + } + + #[test] + fn expires_at_min_not_met_returns_unsatisfied() { + let now = 1000; + // Credential expires at t=2000; request requires expires_at >= 5000. + let (store, root) = store_with_credential_times(100, now, 2000, now); + let request = dummy_request( + vec![RequestItem::new("a".into(), 100, None, None, Some(5000))], + None, + ); + let result = + check_credentials_against_proof_request(&request, &store, now).unwrap(); + assert!(!result.is_satisfied); + assert!(!result.check_results[0].has_credential); + cleanup_test_storage(&root); + } + + #[test] + fn expires_at_min_equal_to_expires_at_returns_unsatisfied() { + let now = 1000; + // Boundary: expires_at == expires_at_min is rejected by the circuit (strict >). + let (store, root) = store_with_credential_times(100, now, 5000, now); + let request = dummy_request( + vec![RequestItem::new("a".into(), 100, None, None, Some(5000))], + None, + ); + let result = + check_credentials_against_proof_request(&request, &store, now).unwrap(); + assert!(!result.is_satisfied); + assert!(!result.check_results[0].has_credential); + cleanup_test_storage(&root); + } +} diff --git a/crates/walletkit-core/src/requests.rs b/crates/walletkit-core/src/requests/mod.rs similarity index 98% rename from crates/walletkit-core/src/requests.rs rename to crates/walletkit-core/src/requests/mod.rs index 2b3794f77..45cd2e398 100644 --- a/crates/walletkit-core/src/requests.rs +++ b/crates/walletkit-core/src/requests/mod.rs @@ -1,3 +1,6 @@ +/// Local credential preflight checks for proof requests. +pub mod credential_check; + use world_id_core::requests::{ ProofRequest as CoreProofRequest, ProofResponse as CoreProofResponse, }; From 6b48ddf27b4c33d8bcbf3d37adbc977710036f3b Mon Sep 17 00:00:00 2001 From: dzejkop Date: Wed, 9 Sep 2026 12:52:56 +0200 Subject: [PATCH 2/2] refactor: retain preflight compatibility and current test coverage --- .../src/requests/credential_check.rs | 83 ++++++++++++------- 1 file changed, 54 insertions(+), 29 deletions(-) diff --git a/crates/walletkit-core/src/requests/credential_check.rs b/crates/walletkit-core/src/requests/credential_check.rs index 525112bcd..574f4fa22 100644 --- a/crates/walletkit-core/src/requests/credential_check.rs +++ b/crates/walletkit-core/src/requests/credential_check.rs @@ -160,42 +160,45 @@ impl ProofRequest { ) -> Result { self.0.validate_constraints()?; let records = store.list_credentials(None, now)?; - + let mut by_schema: HashMap> = HashMap::new(); for r in records.iter().filter(|r| !r.is_expired) { by_schema.entry(r.issuer_schema_id).or_default().push(r); } - - let mut check_results: Vec = Vec::new(); - for item in &self.0.requests { - let expires_min = item.expires_at_min.unwrap_or(self.0.created_at); - let genesis_min = item.genesis_issued_at_min.unwrap_or(0); - - let has_credential = - by_schema.get(&item.issuer_schema_id).is_some_and(|creds| { - creds.iter().any(|r| { - r.expires_at > expires_min && r.genesis_issued_at >= genesis_min - }) - }); - - check_results.push(CredentialConstraintsCheckItem { - identifier: item.identifier.clone(), - issuer_schema_id: item.issuer_schema_id, - has_credential, - }); - } - - let is_satisfied = match &self.0.constraints { - None => check_results.iter().all(|i| i.has_credential), - Some(expr) => { + + let check_results: Vec<_> = self + .0 + .requests + .iter() + .map(|item| { + let expires_min = item.expires_at_min.unwrap_or(self.0.created_at); + let genesis_min = item.genesis_issued_at_min.unwrap_or(0); + let has_credential = + by_schema.get(&item.issuer_schema_id).is_some_and(|creds| { + creds.iter().any(|record| { + record.expires_at > expires_min + && record.genesis_issued_at >= genesis_min + }) + }); + CredentialConstraintsCheckItem { + identifier: item.identifier.clone(), + issuer_schema_id: item.issuer_schema_id, + has_credential, + } + }) + .collect(); + + let is_satisfied = self.0.constraints.as_ref().map_or_else( + || check_results.iter().all(|item| item.has_credential), + |expr| { expr.evaluate(&|id: &str| { check_results .iter() - .any(|i| i.identifier == id && i.has_credential) + .any(|item| item.identifier == id && item.has_credential) }) - } - }; - + }, + ); + Ok(CredentialConstraintsCheckResult { is_satisfied, check_results, @@ -275,6 +278,29 @@ mod tests { (store, root) } + #[test] + fn method_and_legacy_api_agree() { + let now = 1000; + let (store, root) = store_with_credentials(&[100], now); + let request = dummy_request( + vec![ + RequestItem::new("present".into(), 100, None, None, None), + RequestItem::new("missing".into(), 200, None, None, None), + ], + None, + ); + let direct = request.check_credentials(&store, now).unwrap(); + let legacy = crate::proof_request_credential_constraints_check::check_credentials_against_proof_request(&request, &store, now).unwrap(); + assert_eq!(direct.is_satisfied, legacy.is_satisfied); + assert_eq!(direct.check_results.len(), legacy.check_results.len()); + for (a, b) in direct.check_results.iter().zip(&legacy.check_results) { + assert_eq!(a.identifier, b.identifier); + assert_eq!(a.issuer_schema_id, b.issuer_schema_id); + assert_eq!(a.has_credential, b.has_credential); + } + cleanup_test_storage(&root); + } + #[test] fn no_constraints_all_satisfied() { let now = 1000; @@ -628,7 +654,6 @@ mod tests { #[test] fn constraint_too_large_returns_error() { - use world_id_core::requests::ValidationError; let now = 1000; let (store, root) = store_with_credentials(&[], now); // Build a flat Any with MAX_CONSTRAINT_NODES + 1 leaves to exceed the limit.