diff --git a/crates/pops-core-verify/src/cashu_credential.rs b/crates/pops-core-verify/src/cashu_credential.rs index 4708252..ce29c8c 100644 --- a/crates/pops-core-verify/src/cashu_credential.rs +++ b/crates/pops-core-verify/src/cashu_credential.rs @@ -16,7 +16,7 @@ use std::str::FromStr; use cashu::nuts::nut00::ProofsMethods; use cashu::{Amount, CurrencyUnit, MintUrl, Proofs, Token}; -use crate::charge::{ChargeError, DleqLocation, RedeemedProofs}; +use crate::charge::{ChargeError, RedeemedProofs}; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -415,12 +415,10 @@ fn map_validation_error(e: ValidationError, mint_url: &str) -> ChargeError { // needs the mint's NUT-03 error-body parse, which is not yet done. ValidationError::MintRejectedSwap(_) => ChargeError::DoubleSpend, // Money-safety: a missing/invalid swap-output DLEQ is verification- - // failed at the SwapOutput location — a 402 (gateway serves nothing), - // distinct from a double-spend so the operator sees the mint-trust - // signal. NEVER serve the resource on this path. - ValidationError::SwapOutputDleqInvalid(_) => ChargeError::DleqInvalid { - location: DleqLocation::SwapOutput, - }, + // failed — a 402 (gateway serves nothing), distinct from a double-spend + // so the operator sees the mint-trust signal. NEVER serve the resource + // on this path. + ValidationError::SwapOutputDleqInvalid(_) => ChargeError::DleqInvalid, } } @@ -1417,10 +1415,9 @@ mod tests { } #[tokio::test] - async fn verify_and_redeem_maps_swap_output_dleq_to_dleq_invalid_swap_output() { - // Money-safety: a swap-output DLEQ failure maps to DleqInvalid{SwapOutput}, - // NOT DoubleSpend — the gateway serves nothing and no proofs are produced. - use crate::charge::DleqLocation; + async fn verify_and_redeem_maps_swap_output_dleq_to_dleq_invalid() { + // Money-safety: a swap-output DLEQ failure maps to DleqInvalid, NOT + // DoubleSpend — the gateway serves nothing and no proofs are produced. let presented = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]).to_string(); let req = charge_req("pop_1700000000", vec![mint_a()], 10); @@ -1431,16 +1428,10 @@ mod tests { .verify_and_redeem(&presented, &req) .await .expect_err("swap-output DLEQ failure must map to DleqInvalid"); - match err { - ChargeError::DleqInvalid { location } => { - assert_eq!( - location, - DleqLocation::SwapOutput, - "swap-output DLEQ failure must carry the SwapOutput location" - ); - } - other => panic!("expected DleqInvalid {{ SwapOutput }}, got {other:?}"), - } + assert!( + matches!(err, ChargeError::DleqInvalid), + "swap-output DLEQ failure must map to DleqInvalid, got {err:?}" + ); } #[tokio::test] diff --git a/crates/pops-core-verify/src/charge.rs b/crates/pops-core-verify/src/charge.rs index 9e9c495..dc44589 100644 --- a/crates/pops-core-verify/src/charge.rs +++ b/crates/pops-core-verify/src/charge.rs @@ -1,6 +1,6 @@ -//! The charge contract: [`ChargeError`], [`DleqLocation`], and [`RedeemedProofs`] -//! — the committed shape the verifier produces and its hosts (the gateway, the -//! wasm/serverless SDK) map off. +//! The charge contract: [`ChargeError`] and [`RedeemedProofs`] — the committed +//! shape the verifier produces and its hosts (the gateway, the wasm/serverless +//! SDK) map off. //! //! Plain data only (`RedeemedProofs.fresh_proofs` is a serialized `cashuB…` //! string, not `cashu::Proofs`), so it stays wasm-clean and is the canonical @@ -89,17 +89,13 @@ pub enum ChargeError { #[error("token carries a NUT-10 spending condition (locked); bearer proofs only")] LockedToken, - /// A DLEQ proof (NUT-12) is INVALID — on a presented input proof, or - /// (security-critical) on a blind signature the swap RETURNED. Absence of an - /// input-proof DLEQ is NOT this error; a mint that OMITS output DLEQ IS. + /// A blind signature the swap RETURNED failed NUT-12 DLEQ — invalid or + /// omitted by the mint (a malicious mint reporting outputs it never validly + /// signed, which the server then could not spend). Security-critical. /// /// HTTP 402 · `verification-failed` · terminal. - #[error("DLEQ verification failed ({location})")] - DleqInvalid { - /// Distinguishes the lenient input case (present-but-invalid) from the - /// strict swap-output case (invalid or omitted — a mint-trust signal). - location: DleqLocation, - }, + #[error("swap-output DLEQ verification failed")] + DleqInvalid, /// A proof's short (v1) keyset id does not resolve, or resolves ambiguously, /// against the mint's published keysets. @@ -171,27 +167,6 @@ pub enum ChargeError { }, } -/// Where a DLEQ check failed — payload of `ChargeError::DleqInvalid`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DleqLocation { - /// On a presented INPUT proof (present-but-invalid). Lenient elsewhere: - /// ABSENCE of input DLEQ never produces an error. - InputProof, - /// On a blind signature the SWAP RETURNED — invalid OR omitted by the mint - /// (security-critical; a malicious mint reporting unsigned outputs). - SwapOutput, -} - -impl std::fmt::Display for DleqLocation { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - DleqLocation::InputProof => "input proof", - DleqLocation::SwapOutput => "swap output", - }; - f.write_str(s) - } -} - /// The value the operator holds after a successful verify+redeem, plus what the /// SDK needs to emit a Payment-Receipt. /// @@ -224,17 +199,9 @@ mod tests { use super::*; #[test] - fn dleq_location_display() { - assert_eq!(DleqLocation::InputProof.to_string(), "input proof"); - assert_eq!(DleqLocation::SwapOutput.to_string(), "swap output"); - } - - #[test] - fn dleq_invalid_display_interpolates_location() { - let err = ChargeError::DleqInvalid { - location: DleqLocation::SwapOutput, - }; - assert_eq!(err.to_string(), "DLEQ verification failed (swap output)"); + fn dleq_invalid_displays() { + let err = ChargeError::DleqInvalid; + assert_eq!(err.to_string(), "swap-output DLEQ verification failed"); } #[test] diff --git a/crates/pops-core-verify/src/lib.rs b/crates/pops-core-verify/src/lib.rs index 416bd69..f1f4608 100644 --- a/crates/pops-core-verify/src/lib.rs +++ b/crates/pops-core-verify/src/lib.rs @@ -23,8 +23,8 @@ pub mod cashu_credential; pub mod challenge; -// The committed charge contract (`ChargeError` / `RedeemedProofs` / `DleqLocation`) -// and the `pop_` unit grammar. Plain data, thiserror only — wasm-clean. +// The committed charge contract (`ChargeError` / `RedeemedProofs`) and the +// `pop_` unit grammar. Plain data, thiserror only — wasm-clean. pub mod charge; pub mod envelope; pub mod error; diff --git a/crates/pops-core-verify/src/middleware.rs b/crates/pops-core-verify/src/middleware.rs index b8ea971..e90d770 100644 --- a/crates/pops-core-verify/src/middleware.rs +++ b/crates/pops-core-verify/src/middleware.rs @@ -243,7 +243,7 @@ fn problem_parts(e: &ChargeError) -> (&'static str, &'static str) { | ChargeError::MintNotAllowed { .. } | ChargeError::MultiMintOrUnit | ChargeError::LockedToken - | ChargeError::DleqInvalid { .. } + | ChargeError::DleqInvalid | ChargeError::ShortKeysetIdUnresolved { .. } | ChargeError::DoubleSpend => ("verification-failed", "Verification failed"), ChargeError::Expired | ChargeError::ChallengeExpired => { diff --git a/crates/pops-core-verify/src/mint_client.rs b/crates/pops-core-verify/src/mint_client.rs index fe3de0d..61c2165 100644 --- a/crates/pops-core-verify/src/mint_client.rs +++ b/crates/pops-core-verify/src/mint_client.rs @@ -84,7 +84,7 @@ pub enum MintClientError { /// SECURITY-CRITICAL, deliberately distinct from [`Self::RejectedSwap`]: the /// mint did NOT prove it signed the outputs with the advertised key, so the /// proofs are not provably valid bearer value and MUST NOT be redeemed (no - /// redeemed value without a verified DLEQ). Maps to `DleqInvalid { SwapOutput }` + /// redeemed value without a verified DLEQ). Maps to `DleqInvalid` /// (402, resource not served), NOT a double-spend. #[error("swap-output DLEQ verification failed: {0}")] SwapOutputDleqInvalid(String), diff --git a/crates/pops-core-verify/src/wasm.rs b/crates/pops-core-verify/src/wasm.rs index 08c4683..bdf92aa 100644 --- a/crates/pops-core-verify/src/wasm.rs +++ b/crates/pops-core-verify/src/wasm.rs @@ -106,7 +106,7 @@ fn charge_error_code(e: &ChargeError) -> &'static str { ChargeError::MintNotAllowed { .. } => "mint-not-allowed", ChargeError::MultiMintOrUnit => "multi-mint-or-unit", ChargeError::LockedToken => "locked-token", - ChargeError::DleqInvalid { .. } => "dleq-invalid", + ChargeError::DleqInvalid => "dleq-invalid", ChargeError::ShortKeysetIdUnresolved { .. } => "short-keyset-id-unresolved", ChargeError::DoubleSpend => "double-spend", ChargeError::Expired => "expired", diff --git a/crates/pops-core-verify/tests/charge_conformance.rs b/crates/pops-core-verify/tests/charge_conformance.rs index 8487808..ac1edd0 100644 --- a/crates/pops-core-verify/tests/charge_conformance.rs +++ b/crates/pops-core-verify/tests/charge_conformance.rs @@ -25,7 +25,7 @@ use cashu::{Amount, CurrencyUnit, MintUrl}; use http::{header::AUTHORIZATION, Request, StatusCode}; use tower::ServiceExt; -use pops_core_verify::charge::{ChargeError, DleqLocation, RedeemedProofs}; +use pops_core_verify::charge::{ChargeError, RedeemedProofs}; use pops_core_verify::challenge::{ decode_charge_request, encode_challenge, encode_charge_request, CashuRequirement, }; @@ -426,9 +426,7 @@ async fn charge_errors_map_to_spec_problem_types_and_statuses() { (|| ChargeError::MultiMintOrUnit, "cashu/verification-failed", 402), (|| ChargeError::LockedToken, "cashu/verification-failed", 402), ( - || ChargeError::DleqInvalid { - location: DleqLocation::SwapOutput, - }, + || ChargeError::DleqInvalid, "cashu/verification-failed", 402, ),