From ac2feb1093c4de02d67fadae86030c3d4f60d534 Mon Sep 17 00:00:00 2001 From: TheMhv Date: Fri, 3 Apr 2026 11:48:34 -0300 Subject: [PATCH 01/12] feat(nutxx): create request struct for nutxx --- crates/cashu/src/nuts/mod.rs | 1 + crates/cashu/src/nuts/nutxx.rs | 14 ++++++++++++++ crates/cdk/src/mint/issue/mod.rs | 2 -- 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 crates/cashu/src/nuts/nutxx.rs diff --git a/crates/cashu/src/nuts/mod.rs b/crates/cashu/src/nuts/mod.rs index 50bb3258d..d555bcf67 100644 --- a/crates/cashu/src/nuts/mod.rs +++ b/crates/cashu/src/nuts/mod.rs @@ -33,6 +33,7 @@ pub mod nut27; pub mod nut28; pub mod nut29; pub mod nut30; +pub mod nutxx; mod auth; diff --git a/crates/cashu/src/nuts/nutxx.rs b/crates/cashu/src/nuts/nutxx.rs new file mode 100644 index 000000000..d389106b6 --- /dev/null +++ b/crates/cashu/src/nuts/nutxx.rs @@ -0,0 +1,14 @@ +//! NUT-XX: Mint Quote Lookup by Public Key +//! +//! + +use serde::{Deserialize, Serialize}; + +/// Mint quote by pubkey request [NUT-XX] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MintQuoteByPubkeyRequest { + /// Pubkeys + pub pubkeys: Vec, + /// Signatures + pub pubkey_signatures: Vec, +} diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index ee3cc8d5b..fdda5702c 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -20,8 +20,6 @@ use crate::Mint; mod auth; -use cdk_common::mint_quote::{MintQuoteRequest, MintQuoteResponse}; - /// Input enum to handle both single and batch mint formats (internal to CDK, not spec) #[derive(Debug, Clone)] pub enum MintInput { From ef21ed4149d0229792fad75010a2b3b322fe0479 Mon Sep 17 00:00:00 2001 From: TheMhv Date: Fri, 3 Apr 2026 18:49:02 -0300 Subject: [PATCH 02/12] feat(nutxx): create function to get mint quotes from database for nutxx --- crates/cdk-common/src/database/mint/mod.rs | 5 ++ .../cdk-common/src/database/mint/test/mint.rs | 40 +++++++++ ...4221137_add_pubkey_index_to_mint_quote.sql | 1 + ...4221137_add_pubkey_index_to_mint_quote.sql | 1 + crates/cdk-sql-common/src/mint/quotes.rs | 53 ++++++++++++ crates/cdk/src/mint/issue/mod.rs | 85 +++++++++++++++++++ 6 files changed, 185 insertions(+) create mode 100644 crates/cdk-sql-common/src/mint/migrations/postgres/20260404221137_add_pubkey_index_to_mint_quote.sql create mode 100644 crates/cdk-sql-common/src/mint/migrations/sqlite/20260404221137_add_pubkey_index_to_mint_quote.sql diff --git a/crates/cdk-common/src/database/mint/mod.rs b/crates/cdk-common/src/database/mint/mod.rs index 5e9871d20..440d2f1cf 100644 --- a/crates/cdk-common/src/database/mint/mod.rs +++ b/crates/cdk-common/src/database/mint/mod.rs @@ -418,6 +418,11 @@ pub trait QuotesDatabase { ) -> Result, Self::Err>; /// Get Mint Quotes async fn get_mint_quotes(&self) -> Result, Self::Err>; + /// Get Mint Quotes By Pubkey + async fn get_mint_quotes_by_pubkey( + &self, + pubkeys: &[PublicKey], + ) -> Result, Self::Err>; /// Get [`mint::MeltQuote`] async fn get_melt_quote( &self, diff --git a/crates/cdk-common/src/database/mint/test/mint.rs b/crates/cdk-common/src/database/mint/test/mint.rs index c55cc592f..d0c3e025d 100644 --- a/crates/cdk-common/src/database/mint/test/mint.rs +++ b/crates/cdk-common/src/database/mint/test/mint.rs @@ -1086,6 +1086,46 @@ where assert_eq!(retrieved.request_lookup_id, lookup_id); } +/// Test getting mint quote by public key +pub async fn get_mint_quote_by_public_key(db: DB) +where + DB: Database + KeysDatabase, +{ + use crate::database::mint::test::unique_string; + + let secret_key = SecretKey::generate(); + let pubkey = secret_key.public_key(); + + let mint_quote = MintQuote::new( + None, + "".to_owned(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + Some(pubkey), + Amount::new(100, cashu::CurrencyUnit::Sat), + Amount::new(0, cashu::CurrencyUnit::Sat), + cashu::PaymentMethod::Known(KnownMethod::Bolt11), + 0, + vec![], + vec![], + None, + ); + + // Add quote + let mut tx = Database::begin_transaction(&db).await.unwrap(); + tx.add_mint_quote(mint_quote.clone()).await.unwrap(); + tx.commit().await.unwrap(); + + let retrieved = db.get_mint_quotes_by_pubkey(&[pubkey]).await.unwrap(); + assert!(!retrieved.is_empty()); + let retrieved = retrieved.first().unwrap(); + assert_eq!(retrieved.id, mint_quote.id); + assert!(retrieved.pubkey.is_some()); + assert_eq!(retrieved.pubkey, Some(pubkey)); +} + /// Test deleting blinded messages pub async fn delete_blinded_messages(db: DB) where diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20260404221137_add_pubkey_index_to_mint_quote.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20260404221137_add_pubkey_index_to_mint_quote.sql new file mode 100644 index 000000000..4980b0e40 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20260404221137_add_pubkey_index_to_mint_quote.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_pubkey ON mint_quote(pubkey); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20260404221137_add_pubkey_index_to_mint_quote.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20260404221137_add_pubkey_index_to_mint_quote.sql new file mode 100644 index 000000000..4980b0e40 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20260404221137_add_pubkey_index_to_mint_quote.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_pubkey ON mint_quote(pubkey); diff --git a/crates/cdk-sql-common/src/mint/quotes.rs b/crates/cdk-sql-common/src/mint/quotes.rs index df427e7ab..47e01ba7d 100644 --- a/crates/cdk-sql-common/src/mint/quotes.rs +++ b/crates/cdk-sql-common/src/mint/quotes.rs @@ -1381,6 +1381,59 @@ where Ok(mint_quotes) } + async fn get_mint_quotes_by_pubkey( + &self, + pubkeys: &[PublicKey], + ) -> Result, Self::Err> { + if pubkeys.is_empty() { + return Ok(vec![]); + } + + let conn = self + .pool + .get() + .await + .map_err(|e| Error::Database(Box::new(e)))?; + let mut mint_quotes = query( + r#" + SELECT + id, + amount, + unit, + request, + expiry, + request_lookup_id, + pubkey, + created_time, + amount_paid, + amount_issued, + updated_at, + last_checked, + payment_method, + request_lookup_id_kind, + extra_json + FROM + mint_quote + WHERE pubkey IN (:pubkeys) + "#, + )? + .bind_vec("pubkeys", pubkeys.iter().map(|pk| pk.to_hex()).collect())? + .fetch_all(&*conn) + .await? + .into_iter() + .map(|row| sql_row_to_mint_quote(row, vec![], vec![])) + .collect::, _>>()?; + + for quote in mint_quotes.as_mut_slice() { + let payments = get_mint_quote_payments(&*conn, "e.id).await?; + let issuance = get_mint_quote_issuance(&*conn, "e.id).await?; + quote.issuance = issuance; + quote.payments = payments; + } + + Ok(mint_quotes) + } + async fn get_melt_quote( &self, quote_id: &QuoteId, diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index fdda5702c..ddb5b16ad 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -417,6 +417,91 @@ impl Mint { result } + /// Retrieves mint quotes with pubkey from the database + /// + /// # Returns + /// * `Vec` - List of mint quotes filtered by pubkeys + /// * `Error` if database access fails + #[instrument(skip_all)] + pub async fn get_mint_quote_by_pubkey( + &self, + pubkeys: Vec, + signatures: Vec, + ) -> Result>, Error> { + #[cfg(feature = "prometheus")] + let metrics = super::MintMetricGuard::new("mint_quotes_by_pubkeys"); + + pubkeys.len().ne(&signatures.len()).then(|| { + tracing::error!("Signatures must be the same length of publickeys"); + Error::SignatureMissingOrInvalid + }); + + for (pubkey, signature) in pubkeys.iter().zip(signatures.iter()) { + pubkey.verify(&pubkey.serialize(), signature).map_err(|e| { + tracing::error!("Failed to validate signature: {}", e); + Error::SignatureMissingOrInvalid + })?; + } + + let result: Result>, Error> = async { + let quotes = self.localstore.get_mint_quotes_by_pubkey(&pubkeys).await?; + + quotes + .iter() + .map(|q| match q.payment_method { + PaymentMethod::Known(KnownMethod::Bolt11) => { + Ok(MintQuoteResponse::Bolt11(MintQuoteBolt11Response::< + QuoteId, + > { + quote: q.id.clone(), + request: q.request.clone(), + amount: q.amount.clone().map(|a| a.into()), + unit: Some(q.unit.clone()), + state: q.state(), + expiry: Some(q.expiry), + pubkey: q.pubkey, + })) + } + PaymentMethod::Known(KnownMethod::Bolt12) => { + Ok(MintQuoteResponse::Bolt12(MintQuoteBolt12Response::< + QuoteId, + > { + quote: q.id.clone(), + request: q.request.clone(), + amount: q.amount.clone().map(|a| a.into()), + unit: q.unit.clone(), + expiry: Some(q.expiry), + pubkey: q.pubkey.ok_or(Error::PubkeyRequired)?, + amount_paid: q.amount_paid().into(), + amount_issued: q.amount_issued().into(), + })) + } + _ => Ok(MintQuoteResponse::Custom { + method: q.payment_method.clone(), + response: MintQuoteCustomResponse { + quote: q.id.clone(), + request: q.request.clone(), + amount: q.amount.clone().map(|a| a.into()), + unit: Some(q.unit.clone()), + state: q.state(), + expiry: Some(q.expiry), + pubkey: q.pubkey, + extra: q.extra_json.clone().unwrap_or_default(), + }, + }), + }) + .collect() + } + .await; + + #[cfg(feature = "prometheus")] + { + metrics.record(result.is_ok()); + } + + result + } + /// Marks a mint quote as paid based on the payment request ID /// /// Looks up the mint quote by the payment request ID and marks it as paid From fed0c6542751ad5892929adda66aeebc12e9398c Mon Sep 17 00:00:00 2001 From: TheMhv Date: Fri, 3 Apr 2026 17:14:42 -0300 Subject: [PATCH 03/12] feat(nutxx): create get mint quote by pubkey route for nutxx --- crates/cdk-axum/src/custom_handlers.rs | 52 ++++++++++++++++++++++++++ crates/cdk-axum/src/custom_router.rs | 7 +++- crates/cdk/src/mint/issue/mod.rs | 23 ++++++++++-- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/crates/cdk-axum/src/custom_handlers.rs b/crates/cdk-axum/src/custom_handlers.rs index 3e81e4e23..83f04ce9a 100644 --- a/crates/cdk-axum/src/custom_handlers.rs +++ b/crates/cdk-axum/src/custom_handlers.rs @@ -13,6 +13,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use cdk::mint::QuoteId; use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath}; +use cdk::nuts::nutxx::MintQuoteByPubkeyRequest; use cdk::nuts::{ BatchCheckMintQuoteRequest, BatchMintRequest, MeltOnchainRequest, MeltQuoteBolt11Request, MeltQuoteBolt12Request, MeltQuoteCustomRequest, MeltQuoteOnchainRequest, @@ -727,6 +728,57 @@ pub async fn cache_post_batch_mint( Ok(result) } +/// Generic handler for get mint quotes by pubkey +#[instrument(skip_all, fields(method = ?method))] +pub async fn post_mint_quote_by_pubkey( + auth: AuthHeader, + State(state): State, + Path(method): Path, + Json(payload): Json, +) -> Result { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuote(method.clone())), + ) + .await + .map_err(into_response)?; + + let request: MintQuoteByPubkeyRequest = serde_json::from_value(payload).map_err(|e| { + tracing::error!("Failed to parse request: {}", e); + into_response(cdk::Error::InvalidPaymentRequest) + })?; + + let pubkeys = request + .pubkeys + .iter() + .map(|s| s.parse()) + .collect::>() + .map_err(|e| { + tracing::error!("Invalid Public Key: {}", e); + into_response(cdk::Error::InvalidPaymentRequest) + })?; + + let signatures = request + .pubkeys_signatures + .iter() + .map(|s| s.parse()) + .collect::>() + .map_err(|e| { + tracing::error!("Invalid Signature: {}", e); + into_response(cdk::Error::SignatureMissingOrInvalid) + })?; + + let response = state + .mint + .get_mint_quote_by_pubkey(pubkeys, signatures) + .await + .map_err(into_response)?; + + Ok(Json(response).into_response()) +} + #[cfg(test)] mod tests { use std::collections::{HashMap, HashSet}; diff --git a/crates/cdk-axum/src/custom_router.rs b/crates/cdk-axum/src/custom_router.rs index 7c827f2ec..f77174d54 100644 --- a/crates/cdk-axum/src/custom_router.rs +++ b/crates/cdk-axum/src/custom_router.rs @@ -10,7 +10,7 @@ use cdk::nuts::PaymentMethod; use crate::custom_handlers::{ cache_post_batch_mint, cache_post_melt_custom, cache_post_mint_custom, get_check_melt_custom_quote, get_check_mint_custom_quote, post_batch_check_mint_quote, - post_melt_custom_quote, post_mint_custom_quote, + post_melt_custom_quote, post_mint_custom_quote, post_mint_quote_by_pubkey, }; use crate::MintState; @@ -20,6 +20,7 @@ use crate::MintState; /// - `/mint/quote/{method}` - POST: Create mint quote /// - `/mint/quote/{method}/{quote_id}` - GET: Check mint quote status /// - `/mint/quote/{method}/check` - POST: Batch check mint quote status (NUT-29) +/// - `/mint/quote/{method}/pubkey` - POST: Mint quotes lookup by pubkey /// - `/mint/{method}` - POST: Mint tokens /// - `/mint/{method}/batch` - POST: Batch mint tokens (NUT-29) /// - `/melt/quote/{method}` - POST: Create melt quote @@ -46,6 +47,10 @@ pub fn create_custom_routers(state: MintState, custom_methods: Vec) -> R "/mint/quote/{method}/check", post(post_batch_check_mint_quote), ) + .route( + "/mint/quote/{method}/pubkey", + post(post_mint_quote_by_pubkey), + ) .route("/mint/{method}", post(cache_post_mint_custom)) .route("/mint/{method}/batch", post(cache_post_batch_mint)) .route("/melt/quote/{method}", post(post_melt_custom_quote)) diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index ddb5b16ad..c9b70d2a5 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -1,7 +1,9 @@ use std::sync::Arc; +use bitcoin::secp256k1::schnorr::Signature; use cdk_common::database::mint::Acquired; use cdk_common::mint::{MintQuote, Operation}; +use cdk_common::nut00::KnownMethod; use cdk_common::payment::{ Bolt11IncomingPaymentOptions, Bolt12IncomingPaymentOptions, CustomIncomingPaymentOptions, IncomingPaymentOptions, OnchainIncomingPaymentOptions, WaitPaymentResponse, @@ -10,8 +12,9 @@ use cdk_common::quote_id::QuoteId; use cdk_common::util::unix_time; use cdk_common::{ database, ensure_cdk, Amount, BatchMintRequest, BlindedMessage, CurrencyUnit, Error, - MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteOnchainResponse, MintQuoteState, - MintRequest, MintResponse, NotificationPayload, PaymentMethod, PublicKey, + MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteCustomResponse, + MintQuoteOnchainResponse, MintQuoteRequest, MintQuoteResponse, MintQuoteState, MintRequest, + MintResponse, NotificationPayload, PaymentMethod, PublicKey, }; use tracing::instrument; @@ -476,6 +479,19 @@ impl Mint { amount_issued: q.amount_issued().into(), })) } + PaymentMethod::Known(KnownMethod::Onchain) => { + Ok(MintQuoteResponse::Onchain(MintQuoteOnchainResponse::< + QuoteId, + > { + quote: q.id.clone(), + request: q.request.clone(), + unit: q.unit.clone(), + expiry: Some(q.expiry), + pubkey: q.pubkey.ok_or(Error::PubkeyRequired)?, + amount_paid: q.amount_paid().into(), + amount_issued: q.amount_issued().into(), + })) + } _ => Ok(MintQuoteResponse::Custom { method: q.payment_method.clone(), response: MintQuoteCustomResponse { @@ -483,10 +499,11 @@ impl Mint { request: q.request.clone(), amount: q.amount.clone().map(|a| a.into()), unit: Some(q.unit.clone()), - state: q.state(), expiry: Some(q.expiry), pubkey: q.pubkey, extra: q.extra_json.clone().unwrap_or_default(), + amount_paid: q.amount_paid().into(), + amount_issued: q.amount_issued().into(), }, }), }) From e3c55ba152ff30351a3f0138aecc8adc9cb5c8a5 Mon Sep 17 00:00:00 2001 From: TheMhv Date: Fri, 19 Jun 2026 13:02:25 -0300 Subject: [PATCH 04/12] feat(nutxx): Add replay-protect pubkey quote lookup signatures --- crates/cdk/src/mint/issue/mod.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index c9b70d2a5..423d7cab8 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use bitcoin::hashes::sha256::Hash as Sha256Hash; +use bitcoin::hashes::Hash; use bitcoin::secp256k1::schnorr::Signature; use cdk_common::database::mint::Acquired; use cdk_common::mint::{MintQuote, Operation}; @@ -439,8 +441,24 @@ impl Mint { Error::SignatureMissingOrInvalid }); + let mint_pubkey = self + .mint_info() + .await? + .pubkey + .ok_or(Error::MissingPubkey)? + .to_hex(); + for (pubkey, signature) in pubkeys.iter().zip(signatures.iter()) { - pubkey.verify(&pubkey.serialize(), signature).map_err(|e| { + let pubkey_hex = pubkey.to_hex(); + + let mut preimage = Vec::with_capacity(24 + mint_pubkey.len() + pubkey_hex.len()); + preimage.extend_from_slice(b"Cashu_MintQuoteLookup_v1"); + preimage.extend_from_slice(mint_pubkey.as_bytes()); + preimage.extend_from_slice(pubkey_hex.as_bytes()); + + let hash = Sha256Hash::hash(&preimage).to_byte_array(); + + pubkey.verify(&hash, signature).map_err(|e| { tracing::error!("Failed to validate signature: {}", e); Error::SignatureMissingOrInvalid })?; From 51c48dc7f148dbe20a903b59c194a63d2687aabe Mon Sep 17 00:00:00 2001 From: vnprc Date: Thu, 6 Aug 2026 21:15:15 +0000 Subject: [PATCH 05/12] fix(nutxx): compile against current main The branch was rebased onto a main that already had 04cbe81c ("align mint quote accounting with NUT-04"), which added method/amount_paid/ amount_issued/updated_at to the quote response types and a 15th `updated_at` argument to `MintQuote::new`. Nothing conflicted, because the new code is all in new blocks, so three sites broke silently. - `get_mint_quote_by_pubkey` built the four response types by hand. Replaced with the existing `TryFrom for MintQuoteResponse` in cdk-common, which is the conversion every other quote path already uses and stays current on its own. Fixes four E0063s and removes 60 lines. - The axum handler read `pubkeys_signatures`; the struct declares `pubkey_signatures` (E0609). - The database fixture passed 14 of 15 arguments to `MintQuote::new` (E0061). --- crates/cdk-axum/src/custom_handlers.rs | 2 +- .../cdk-common/src/database/mint/test/mint.rs | 1 + crates/cdk/src/mint/issue/mod.rs | 71 +++---------------- 3 files changed, 11 insertions(+), 63 deletions(-) diff --git a/crates/cdk-axum/src/custom_handlers.rs b/crates/cdk-axum/src/custom_handlers.rs index 83f04ce9a..af34c5d75 100644 --- a/crates/cdk-axum/src/custom_handlers.rs +++ b/crates/cdk-axum/src/custom_handlers.rs @@ -761,7 +761,7 @@ pub async fn post_mint_quote_by_pubkey( })?; let signatures = request - .pubkeys_signatures + .pubkey_signatures .iter() .map(|s| s.parse()) .collect::>() diff --git a/crates/cdk-common/src/database/mint/test/mint.rs b/crates/cdk-common/src/database/mint/test/mint.rs index d0c3e025d..4398080bc 100644 --- a/crates/cdk-common/src/database/mint/test/mint.rs +++ b/crates/cdk-common/src/database/mint/test/mint.rs @@ -1108,6 +1108,7 @@ where Amount::new(0, cashu::CurrencyUnit::Sat), cashu::PaymentMethod::Known(KnownMethod::Bolt11), 0, + 0, vec![], vec![], None, diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index 423d7cab8..78be7e090 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -5,7 +5,6 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::schnorr::Signature; use cdk_common::database::mint::Acquired; use cdk_common::mint::{MintQuote, Operation}; -use cdk_common::nut00::KnownMethod; use cdk_common::payment::{ Bolt11IncomingPaymentOptions, Bolt12IncomingPaymentOptions, CustomIncomingPaymentOptions, IncomingPaymentOptions, OnchainIncomingPaymentOptions, WaitPaymentResponse, @@ -14,9 +13,9 @@ use cdk_common::quote_id::QuoteId; use cdk_common::util::unix_time; use cdk_common::{ database, ensure_cdk, Amount, BatchMintRequest, BlindedMessage, CurrencyUnit, Error, - MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteCustomResponse, - MintQuoteOnchainResponse, MintQuoteRequest, MintQuoteResponse, MintQuoteState, MintRequest, - MintResponse, NotificationPayload, PaymentMethod, PublicKey, + MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteOnchainResponse, MintQuoteRequest, + MintQuoteResponse, MintQuoteState, MintRequest, MintResponse, NotificationPayload, + PaymentMethod, PublicKey, }; use tracing::instrument; @@ -467,65 +466,13 @@ impl Mint { let result: Result>, Error> = async { let quotes = self.localstore.get_mint_quotes_by_pubkey(&pubkeys).await?; + // `TryFrom` is the shared conversion every other quote path uses; hand-rolling it + // here would drift the moment a response field is added. quotes - .iter() - .map(|q| match q.payment_method { - PaymentMethod::Known(KnownMethod::Bolt11) => { - Ok(MintQuoteResponse::Bolt11(MintQuoteBolt11Response::< - QuoteId, - > { - quote: q.id.clone(), - request: q.request.clone(), - amount: q.amount.clone().map(|a| a.into()), - unit: Some(q.unit.clone()), - state: q.state(), - expiry: Some(q.expiry), - pubkey: q.pubkey, - })) - } - PaymentMethod::Known(KnownMethod::Bolt12) => { - Ok(MintQuoteResponse::Bolt12(MintQuoteBolt12Response::< - QuoteId, - > { - quote: q.id.clone(), - request: q.request.clone(), - amount: q.amount.clone().map(|a| a.into()), - unit: q.unit.clone(), - expiry: Some(q.expiry), - pubkey: q.pubkey.ok_or(Error::PubkeyRequired)?, - amount_paid: q.amount_paid().into(), - amount_issued: q.amount_issued().into(), - })) - } - PaymentMethod::Known(KnownMethod::Onchain) => { - Ok(MintQuoteResponse::Onchain(MintQuoteOnchainResponse::< - QuoteId, - > { - quote: q.id.clone(), - request: q.request.clone(), - unit: q.unit.clone(), - expiry: Some(q.expiry), - pubkey: q.pubkey.ok_or(Error::PubkeyRequired)?, - amount_paid: q.amount_paid().into(), - amount_issued: q.amount_issued().into(), - })) - } - _ => Ok(MintQuoteResponse::Custom { - method: q.payment_method.clone(), - response: MintQuoteCustomResponse { - quote: q.id.clone(), - request: q.request.clone(), - amount: q.amount.clone().map(|a| a.into()), - unit: Some(q.unit.clone()), - expiry: Some(q.expiry), - pubkey: q.pubkey, - extra: q.extra_json.clone().unwrap_or_default(), - amount_paid: q.amount_paid().into(), - amount_issued: q.amount_issued().into(), - }, - }), - }) - .collect() + .into_iter() + .map(MintQuoteResponse::try_from) + .collect::, _>>() + .map_err(Error::from) } .await; From fd6f6c89313fb1e5637b6a8055e088b713d722cb Mon Sep 17 00:00:00 2001 From: vnprc Date: Thu, 6 Aug 2026 21:16:11 +0000 Subject: [PATCH 06/12] fix(nutxx): select updated_at in the by-pubkey query `get_mint_quotes_by_pubkey` copied its SELECT list from `get_mint_quotes` forty lines above but dropped `updated_at`. `sql_row_to_mint_quote` reads fourteen columns and got thirteen, so every call to the endpoint failed: Database(Conversion(MissingColumn(14, 13))) This is the second bug in the feature caused by copying something that then moved; the column list is a candidate for a shared const. --- crates/cdk-sql-common/src/mint/quotes.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/cdk-sql-common/src/mint/quotes.rs b/crates/cdk-sql-common/src/mint/quotes.rs index 47e01ba7d..bff45c7d4 100644 --- a/crates/cdk-sql-common/src/mint/quotes.rs +++ b/crates/cdk-sql-common/src/mint/quotes.rs @@ -1408,7 +1408,6 @@ where amount_paid, amount_issued, updated_at, - last_checked, payment_method, request_lookup_id_kind, extra_json From c403be949ae47719e9ac0ac70c73651673922e80 Mon Sep 17 00:00:00 2001 From: vnprc Date: Thu, 6 Aug 2026 21:18:22 +0000 Subject: [PATCH 07/12] fix(nutxx): enforce the signature contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NUT-XX: "The mint MUST reject the request unless every signature is valid." Two problems stopped that from holding. The length guard did nothing. `pubkeys.len().ne(&signatures.len()).then(..)` builds an `Option` and drops it, so nothing enforced the equality. `pubkeys.iter().zip(signatures.iter())` then stops at the shorter iterator, which means an empty `pubkey_signatures` array skipped verification entirely: anyone who knew a pubkey could read that user's quotes, and pubkeys travel in mint quote requests. Now `ensure_cdk!`. Also bound the request length. The endpoint is unauthenticated until the signatures verify, so without a cap an anonymous caller can ask the mint for an unbounded number of Schnorr verifications plus an `IN (...)` query. `MAX_LOOKUP_PUBKEYS` is a plain constant; happy to make it a NUT-06 setting like NUT-29's `max_batch_size` if that reads better. The signature was verified over a double hash. The NUT signs SHA256("Cashu_MintQuoteLookup_v1" || mint_pubkey || pubkey), but `PublicKey::verify` already hashes its argument before BIP-340 verification — which is why NUT-20 passes it the raw message. This hashed first and passed the digest, so the mint checked SHA256(SHA256(preimage)) and rejected conformant wallets while accepting only double-hashed ones. Message construction now lives in `mint_quote_lookup_msg_to_sign` next to the request type, with a byte-level vector so it cannot drift from the NUT silently, and a test that the digest does not verify. --- crates/cashu/src/nuts/nutxx.rs | 81 ++++++++++++++++++++++++++++++++ crates/cdk/src/mint/issue/mod.rs | 49 ++++++++++--------- 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/crates/cashu/src/nuts/nutxx.rs b/crates/cashu/src/nuts/nutxx.rs index d389106b6..36a20d684 100644 --- a/crates/cashu/src/nuts/nutxx.rs +++ b/crates/cashu/src/nuts/nutxx.rs @@ -4,6 +4,17 @@ use serde::{Deserialize, Serialize}; +use super::PublicKey; + +/// Domain separator for mint quote lookup signatures [NUT-XX] +pub const MINT_QUOTE_LOOKUP_DOMAIN: &[u8] = b"Cashu_MintQuoteLookup_v1"; + +/// Maximum number of pubkeys accepted in a single lookup request. +/// +/// The endpoint is unauthenticated until the signatures are checked, so the request length +/// bounds how much signature verification an anonymous caller can ask the mint to perform. +pub const MAX_LOOKUP_PUBKEYS: usize = 50; + /// Mint quote by pubkey request [NUT-XX] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MintQuoteByPubkeyRequest { @@ -12,3 +23,73 @@ pub struct MintQuoteByPubkeyRequest { /// Signatures pub pubkey_signatures: Vec, } + +/// Build the message a wallet signs to prove control of `pubkey`. +/// +/// Per [NUT-XX] the signature covers the SHA-256 hash of +/// `"Cashu_MintQuoteLookup_v1" || mint_pubkey || pubkey`, with the pubkeys concatenated as +/// their UTF-8 hex string representations. +/// +/// The returned value is the *pre-image*, not the digest — pass it straight to +/// [`PublicKey::verify`] / [`crate::SecretKey::sign`], both of which hash their argument. +pub fn mint_quote_lookup_msg_to_sign(mint_pubkey: &PublicKey, pubkey: &PublicKey) -> Vec { + let mint_pubkey = mint_pubkey.to_hex(); + let pubkey = pubkey.to_hex(); + + let mut msg = + Vec::with_capacity(MINT_QUOTE_LOOKUP_DOMAIN.len() + mint_pubkey.len() + pubkey.len()); + msg.extend_from_slice(MINT_QUOTE_LOOKUP_DOMAIN); + msg.extend_from_slice(mint_pubkey.as_bytes()); + msg.extend_from_slice(pubkey.as_bytes()); + msg +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SecretKey; + + /// Fixed keys so the vector below is reproducible. + const MINT_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + const WALLET_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000002"; + + fn fixed_keys() -> (PublicKey, PublicKey) { + ( + SecretKey::from_hex(MINT_SECRET).unwrap().public_key(), + SecretKey::from_hex(WALLET_SECRET).unwrap().public_key(), + ) + } + + /// Pins the pre-image so the construction cannot drift from the NUT without this failing. + #[test] + fn test_msg_to_sign_vector() { + let (mint_pubkey, pubkey) = fixed_keys(); + + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey); + + assert_eq!( + String::from_utf8(msg).unwrap(), + "Cashu_MintQuoteLookup_v1\ + 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\ + 02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" + ); + } + + /// The signed message is the pre-image, not its digest: `sign`/`verify` hash internally. + #[test] + fn test_signature_covers_a_single_hash() { + use bitcoin::hashes::sha256::Hash as Sha256Hash; + use bitcoin::hashes::Hash; + + let (mint_pubkey, _) = fixed_keys(); + let secret_key = SecretKey::generate(); + let pubkey = secret_key.public_key(); + + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey); + let signature = secret_key.sign(&msg).unwrap(); + assert!(pubkey.verify(&msg, &signature).is_ok()); + + let digest = Sha256Hash::hash(&msg).to_byte_array(); + assert!(pubkey.verify(&digest, &signature).is_err()); + } +} diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index 78be7e090..3d6b06898 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -1,10 +1,9 @@ use std::sync::Arc; -use bitcoin::hashes::sha256::Hash as Sha256Hash; -use bitcoin::hashes::Hash; use bitcoin::secp256k1::schnorr::Signature; use cdk_common::database::mint::Acquired; use cdk_common::mint::{MintQuote, Operation}; +use cdk_common::nutxx::{mint_quote_lookup_msg_to_sign, MAX_LOOKUP_PUBKEYS}; use cdk_common::payment::{ Bolt11IncomingPaymentOptions, Bolt12IncomingPaymentOptions, CustomIncomingPaymentOptions, IncomingPaymentOptions, OnchainIncomingPaymentOptions, WaitPaymentResponse, @@ -421,11 +420,14 @@ impl Mint { result } - /// Retrieves mint quotes with pubkey from the database + /// Retrieves the mint quotes locked to a set of NUT-20 public keys [NUT-XX] + /// + /// Every pubkey must be accompanied by a signature proving control of the corresponding + /// private key; the request is rejected outright unless all of them verify. /// /// # Returns - /// * `Vec` - List of mint quotes filtered by pubkeys - /// * `Error` if database access fails + /// * `Vec>` - quotes locked to the requested pubkeys + /// * `Error` if any signature is missing or invalid, or database access fails #[instrument(skip_all)] pub async fn get_mint_quote_by_pubkey( &self, @@ -435,29 +437,30 @@ impl Mint { #[cfg(feature = "prometheus")] let metrics = super::MintMetricGuard::new("mint_quotes_by_pubkeys"); - pubkeys.len().ne(&signatures.len()).then(|| { - tracing::error!("Signatures must be the same length of publickeys"); + // Anonymous callers reach this before any signature is checked, so bound the work a + // single request can ask for. + ensure_cdk!( + pubkeys.len() <= MAX_LOOKUP_PUBKEYS, + Error::BatchSizeExceeded { + actual: pubkeys.len(), + max: MAX_LOOKUP_PUBKEYS, + } + ); + + // NUT-XX: "The mint MUST reject the request unless every signature is valid." Checking + // the lengths match is what makes the zip below cover every pubkey. + ensure_cdk!( + pubkeys.len() == signatures.len(), Error::SignatureMissingOrInvalid - }); + ); - let mint_pubkey = self - .mint_info() - .await? - .pubkey - .ok_or(Error::MissingPubkey)? - .to_hex(); + let mint_pubkey = self.mint_info().await?.pubkey.ok_or(Error::MissingPubkey)?; for (pubkey, signature) in pubkeys.iter().zip(signatures.iter()) { - let pubkey_hex = pubkey.to_hex(); - - let mut preimage = Vec::with_capacity(24 + mint_pubkey.len() + pubkey_hex.len()); - preimage.extend_from_slice(b"Cashu_MintQuoteLookup_v1"); - preimage.extend_from_slice(mint_pubkey.as_bytes()); - preimage.extend_from_slice(pubkey_hex.as_bytes()); - - let hash = Sha256Hash::hash(&preimage).to_byte_array(); + // `verify` hashes its argument, so it takes the pre-image rather than the digest. + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, pubkey); - pubkey.verify(&hash, signature).map_err(|e| { + pubkey.verify(&msg, signature).map_err(|e| { tracing::error!("Failed to validate signature: {}", e); Error::SignatureMissingOrInvalid })?; From d8d75220cfbfc175135191f88bfd596fef1287bb Mon Sep 17 00:00:00 2001 From: vnprc Date: Thu, 6 Aug 2026 21:19:50 +0000 Subject: [PATCH 08/12] fix(nutxx): serve the lookup from the v1 router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint could not be reached on any mint. The route was registered inside `create_custom_routers`, which `create_mint_router` only nests when `custom_methods` is non-empty. An ordinary bolt11/bolt12 mint therefore answered 404. The lookup is method-agnostic, so it belongs next to the other v1 routes rather than in the per-method custom router. Where the custom router *was* mounted, every request failed instead: the last commit removed `{method}` from the path but the handler still declared `Path(method): Path`, so axum rejected before the body ran with "Wrong number of path arguments for `Path`. Expected 1 but got 0." Dropping the path parameter left `verify_auth` with nothing to key on, so NUT-21 gains a `RoutePath::MintQuoteByPubkey` variant rather than borrowing `MintQuote(method)`. Its `FromStr` arm has to precede the `/v1/mint/quote/` prefix branch, which would otherwise read this path as a payment method literally named "pubkey" — there is a round-trip assertion in `test_route_path_serialization` so a reordering cannot pass silently. --- crates/cashu/src/nuts/auth/nut21.rs | 15 +++++++++++++++ crates/cdk-axum/src/custom_handlers.rs | 10 ++++++---- crates/cdk-axum/src/custom_router.rs | 2 +- crates/cdk-axum/src/lib.rs | 8 +++++++- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/cashu/src/nuts/auth/nut21.rs b/crates/cashu/src/nuts/auth/nut21.rs index 266fc50e4..51442d279 100644 --- a/crates/cashu/src/nuts/auth/nut21.rs +++ b/crates/cashu/src/nuts/auth/nut21.rs @@ -130,6 +130,8 @@ pub enum RoutePath { Wildcard(String), /// Mint Quote for a specific payment method MintQuote(String), + /// Mint Quote lookup by public key (NUT-XX), method-agnostic + MintQuoteByPubkey, /// Mint for a specific payment method Mint(String), /// Melt Quote for a specific payment method @@ -172,6 +174,9 @@ impl std::str::FromStr for RoutePath { "/v1/restore" => Ok(RoutePath::Restore), "/v1/auth/blind/mint" => Ok(RoutePath::MintBlindAuth), "/v1/ws" => Ok(RoutePath::Ws), + // Must precede the `/v1/mint/quote/` prefix branch below, which would otherwise + // read this as a payment method literally named "pubkey". + "/v1/mint/quote/pubkey" => Ok(RoutePath::MintQuoteByPubkey), _ => { // Try to parse as a payment method route if let Some(method) = s.strip_prefix("/v1/mint/quote/") { @@ -339,6 +344,7 @@ impl std::fmt::Display for RoutePath { match self { RoutePath::Wildcard(prefix) => write!(f, "{}*", prefix), RoutePath::MintQuote(method) => write!(f, "/v1/mint/quote/{}", method), + RoutePath::MintQuoteByPubkey => write!(f, "/v1/mint/quote/pubkey"), RoutePath::Mint(method) => write!(f, "/v1/mint/{}", method), RoutePath::MeltQuote(method) => write!(f, "/v1/melt/quote/{}", method), RoutePath::Melt(method) => write!(f, "/v1/melt/{}", method), @@ -692,6 +698,15 @@ mod tests { let json = serde_json::to_string(&RoutePath::MintQuote("paypal".to_string())).unwrap(); assert_eq!(json, "\"/v1/mint/quote/paypal\""); + // NUT-XX lookup is a static path. It must not be read as a payment method named + // "pubkey" by the `/v1/mint/quote/` prefix branch in `FromStr`. + let json = serde_json::to_string(&RoutePath::MintQuoteByPubkey).unwrap(); + assert_eq!(json, "\"/v1/mint/quote/pubkey\""); + assert_eq!( + RoutePath::from_str("/v1/mint/quote/pubkey").unwrap(), + RoutePath::MintQuoteByPubkey + ); + // Test deserialization of payment method paths let path: RoutePath = serde_json::from_str("\"/v1/mint/bolt11\"").unwrap(); assert_eq!( diff --git a/crates/cdk-axum/src/custom_handlers.rs b/crates/cdk-axum/src/custom_handlers.rs index af34c5d75..1ca5dd074 100644 --- a/crates/cdk-axum/src/custom_handlers.rs +++ b/crates/cdk-axum/src/custom_handlers.rs @@ -728,19 +728,21 @@ pub async fn cache_post_batch_mint( Ok(result) } -/// Generic handler for get mint quotes by pubkey -#[instrument(skip_all, fields(method = ?method))] +/// Handler for mint quote lookup by public key (NUT-XX) +/// +/// Method-agnostic: a pubkey may hold quotes across several payment methods and they are all +/// returned together. +#[instrument(skip_all)] pub async fn post_mint_quote_by_pubkey( auth: AuthHeader, State(state): State, - Path(method): Path, Json(payload): Json, ) -> Result { state .mint .verify_auth( auth.into(), - &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuote(method.clone())), + &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteByPubkey), ) .await .map_err(into_response)?; diff --git a/crates/cdk-axum/src/custom_router.rs b/crates/cdk-axum/src/custom_router.rs index f77174d54..061bb596b 100644 --- a/crates/cdk-axum/src/custom_router.rs +++ b/crates/cdk-axum/src/custom_router.rs @@ -10,7 +10,7 @@ use cdk::nuts::PaymentMethod; use crate::custom_handlers::{ cache_post_batch_mint, cache_post_melt_custom, cache_post_mint_custom, get_check_melt_custom_quote, get_check_mint_custom_quote, post_batch_check_mint_quote, - post_melt_custom_quote, post_mint_custom_quote, post_mint_quote_by_pubkey, + post_melt_custom_quote, post_mint_custom_quote, }; use crate::MintState; diff --git a/crates/cdk-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index 7f8409cdb..d6d14bfcf 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -107,7 +107,13 @@ pub async fn create_mint_router_with_custom_cache( .route("/ws", get(ws_handler)) .route("/checkstate", post(post_check)) .route("/info", get(get_mint_info)) - .route("/restore", post(post_restore)); + .route("/restore", post(post_restore)) + // NUT-XX quote lookup is method-agnostic, so it belongs here rather than in the + // per-method custom router, which is only mounted when custom methods are configured. + .route( + "/mint/quote/pubkey", + post(custom_handlers::post_mint_quote_by_pubkey), + ); let mut mint_router = Router::new().nest("/v1", v1_router); From ae2bff99494f496aa8ddda1ec7771ed534f2d140 Mon Sep 17 00:00:00 2001 From: vnprc Date: Thu, 6 Aug 2026 21:21:36 +0000 Subject: [PATCH 09/12] feat(nutxx): match the spec wire contract Response. The NUT defines `PostMintQuotesByPubkeyResponse` as `{"quotes": [, ...]}` with the NUT-04 quote objects. The handler returned `Json(Vec>)`, and that enum is externally tagged, so the body was a bare array whose elements were each wrapped in a `{"Bolt11": ...}` envelope no NUT describes. The envelope type now lives in nutxx.rs, and the handler flattens each quote to its inner response the way `melt_quote_response_to_json` already does in this file. Each quote carries its own `method` field, so nothing is lost by flattening. Request. `pubkeys` and `pubkey_signatures` are now `Vec` and `Vec`. Both already serialise as hex, so the wire format is unchanged, but the two manual `.parse()` blocks in the handler go away and malformed input is reported by serde instead of as `Error::InvalidPaymentRequest`, which read oddly on a lookup endpoint. Settings. A mint that serves the endpoint has to say so, or wallets cannot discover it: `"XX": {"supported": }` in the NUT-06 info response, set by `MintBuilder` alongside the other supported NUTs. Note this is keyed "XX" until the NUT is assigned a number, and it reaches the FFI `Nuts` record as `nutxx_supported` so the round trip stays lossless. Caveat for existing deployments: `Mint::new` prefers stored mint info and only merges pubkey/nut21/nut22 from code defaults, so a mint upgrading to this build keeps its stored settings and will not advertise support until something rewrites its mint info. --- crates/cashu/src/nuts/nut06.rs | 14 +++++ crates/cashu/src/nuts/nutxx.rs | 83 ++++++++++++++++++++++---- crates/cdk-axum/src/custom_handlers.rs | 62 +++++++++---------- crates/cdk-ffi/src/types/mint.rs | 10 ++++ crates/cdk/src/mint/builder.rs | 1 + 5 files changed, 128 insertions(+), 42 deletions(-) diff --git a/crates/cashu/src/nuts/nut06.rs b/crates/cashu/src/nuts/nut06.rs index 908fbe627..9b7f0e06f 100644 --- a/crates/cashu/src/nuts/nut06.rs +++ b/crates/cashu/src/nuts/nut06.rs @@ -348,6 +348,12 @@ pub struct Nuts { #[serde(rename = "29")] #[serde(skip_serializing_if = "nut29::Settings::is_empty")] pub nut29: nut29::Settings, + /// NUTXX Settings (mint quote lookup by pubkey) + /// + /// Keyed `XX` until the NUT is assigned a number. + #[serde(default)] + #[serde(rename = "XX")] + pub nutxx: SupportedSettings, } impl Nuts { @@ -473,6 +479,14 @@ impl Nuts { } } + /// NutXX settings (mint quote lookup by pubkey) + pub fn nutxx(self, supported: bool) -> Self { + Self { + nutxx: SupportedSettings { supported }, + ..self + } + } + /// Units where minting is supported pub fn supported_mint_units(&self) -> Vec<&CurrencyUnit> { self.nut04 diff --git a/crates/cashu/src/nuts/nutxx.rs b/crates/cashu/src/nuts/nutxx.rs index 36a20d684..eb07efec3 100644 --- a/crates/cashu/src/nuts/nutxx.rs +++ b/crates/cashu/src/nuts/nutxx.rs @@ -2,6 +2,7 @@ //! //! +use bitcoin::secp256k1::schnorr::Signature; use serde::{Deserialize, Serialize}; use super::PublicKey; @@ -18,10 +19,20 @@ pub const MAX_LOOKUP_PUBKEYS: usize = 50; /// Mint quote by pubkey request [NUT-XX] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MintQuoteByPubkeyRequest { - /// Pubkeys - pub pubkeys: Vec, - /// Signatures - pub pubkey_signatures: Vec, + /// NUT-20 public keys to look up quotes for + pub pubkeys: Vec, + /// Schnorr signatures, in the same order as `pubkeys` + pub pubkey_signatures: Vec, +} + +/// Mint quote by pubkey response [NUT-XX] +/// +/// Generic over the quote representation so the mint can answer with its own response type +/// without this crate depending on the unified `MintQuoteResponse` enum in `cdk-common`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MintQuoteByPubkeyResponse { + /// Quotes locked to the requested pubkeys, in [NUT-04] response format + pub quotes: Vec, } /// Build the message a wallet signs to prove control of `pubkey`. @@ -75,12 +86,10 @@ mod tests { ); } - /// The signed message is the pre-image, not its digest: `sign`/`verify` hash internally. + /// A signature produced over the pre-image verifies, and one bound to a different mint + /// does not — this is what stops a signature being replayed at another mint. #[test] - fn test_signature_covers_a_single_hash() { - use bitcoin::hashes::sha256::Hash as Sha256Hash; - use bitcoin::hashes::Hash; - + fn test_sign_and_verify_is_mint_bound() { let (mint_pubkey, _) = fixed_keys(); let secret_key = SecretKey::generate(); let pubkey = secret_key.public_key(); @@ -89,7 +98,59 @@ mod tests { let signature = secret_key.sign(&msg).unwrap(); assert!(pubkey.verify(&msg, &signature).is_ok()); - let digest = Sha256Hash::hash(&msg).to_byte_array(); - assert!(pubkey.verify(&digest, &signature).is_err()); + let other_mint = SecretKey::generate().public_key(); + let other_msg = mint_quote_lookup_msg_to_sign(&other_mint, &pubkey); + assert!(pubkey.verify(&other_msg, &signature).is_err()); + } + + /// A signature is bound to the pubkey it authorises, so one pubkey's signature cannot be + /// used to read another pubkey's quotes. + #[test] + fn test_signature_is_bound_to_pubkey() { + let (mint_pubkey, _) = fixed_keys(); + let secret_key = SecretKey::generate(); + let victim = SecretKey::generate().public_key(); + + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &secret_key.public_key()); + let signature = secret_key.sign(&msg).unwrap(); + + let victim_msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &victim); + assert!(victim.verify(&victim_msg, &signature).is_err()); + } + + /// The wire format is hex strings, per the NUT. + #[test] + fn test_request_wire_format() { + let (mint_pubkey, _) = fixed_keys(); + let secret_key = SecretKey::generate(); + let pubkey = secret_key.public_key(); + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey); + let signature = secret_key.sign(&msg).unwrap(); + + let json = serde_json::to_string(&MintQuoteByPubkeyRequest { + pubkeys: vec![pubkey], + pubkey_signatures: vec![signature], + }) + .unwrap(); + + assert!(json.contains(&pubkey.to_hex())); + assert!(json.contains(&signature.to_string())); + + let request: MintQuoteByPubkeyRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(request.pubkeys, vec![pubkey]); + assert_eq!(request.pubkey_signatures, vec![signature]); + } + + /// The response envelope is an object with a `quotes` array, not a bare array. + #[test] + fn test_response_wire_format() { + let response = MintQuoteByPubkeyResponse { + quotes: vec![serde_json::json!({"quote": "abc", "method": "bolt11"})], + }; + + assert_eq!( + serde_json::to_value(&response).unwrap(), + serde_json::json!({"quotes": [{"quote": "abc", "method": "bolt11"}]}) + ); } } diff --git a/crates/cdk-axum/src/custom_handlers.rs b/crates/cdk-axum/src/custom_handlers.rs index 1ca5dd074..888fd78fe 100644 --- a/crates/cdk-axum/src/custom_handlers.rs +++ b/crates/cdk-axum/src/custom_handlers.rs @@ -13,7 +13,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use cdk::mint::QuoteId; use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath}; -use cdk::nuts::nutxx::MintQuoteByPubkeyRequest; +use cdk::nuts::nutxx::{MintQuoteByPubkeyRequest, MintQuoteByPubkeyResponse}; use cdk::nuts::{ BatchCheckMintQuoteRequest, BatchMintRequest, MeltOnchainRequest, MeltQuoteBolt11Request, MeltQuoteBolt12Request, MeltQuoteCustomRequest, MeltQuoteOnchainRequest, @@ -21,6 +21,7 @@ use cdk::nuts::{ MintQuoteBolt12Response, MintQuoteCustomRequest, MintQuoteOnchainRequest, MintQuoteOnchainResponse, MintRequest, MintResponse, PaymentMethod, }; +use cdk::MintQuoteResponse; use cdk::{MeltQuoteCreateResponse, MeltQuoteResponse}; use serde_json::Value; use tracing::instrument; @@ -728,15 +729,30 @@ pub async fn cache_post_batch_mint( Ok(result) } +/// Flatten a `MintQuoteResponse` to the NUT-04 quote object that goes on the wire. +/// +/// `MintQuoteResponse` is an externally tagged enum, so serialising it directly would wrap +/// each quote in a `{"Bolt11": …}` envelope that no NUT describes. +fn mint_quote_response_to_value( + response: MintQuoteResponse, +) -> Result { + match response { + MintQuoteResponse::Bolt11(r) => serde_json::to_value(r), + MintQuoteResponse::Bolt12(r) => serde_json::to_value(r), + MintQuoteResponse::Onchain(r) => serde_json::to_value(r), + MintQuoteResponse::Custom { response, .. } => serde_json::to_value(response), + } +} + /// Handler for mint quote lookup by public key (NUT-XX) /// /// Method-agnostic: a pubkey may hold quotes across several payment methods and they are all -/// returned together. +/// returned together, each in its own NUT-04 response format. #[instrument(skip_all)] pub async fn post_mint_quote_by_pubkey( auth: AuthHeader, State(state): State, - Json(payload): Json, + Json(request): Json, ) -> Result { state .mint @@ -747,38 +763,22 @@ pub async fn post_mint_quote_by_pubkey( .await .map_err(into_response)?; - let request: MintQuoteByPubkeyRequest = serde_json::from_value(payload).map_err(|e| { - tracing::error!("Failed to parse request: {}", e); - into_response(cdk::Error::InvalidPaymentRequest) - })?; - - let pubkeys = request - .pubkeys - .iter() - .map(|s| s.parse()) - .collect::>() - .map_err(|e| { - tracing::error!("Invalid Public Key: {}", e); - into_response(cdk::Error::InvalidPaymentRequest) - })?; - - let signatures = request - .pubkey_signatures - .iter() - .map(|s| s.parse()) - .collect::>() - .map_err(|e| { - tracing::error!("Invalid Signature: {}", e); - into_response(cdk::Error::SignatureMissingOrInvalid) - })?; - - let response = state + let quotes = state .mint - .get_mint_quote_by_pubkey(pubkeys, signatures) + .get_mint_quote_by_pubkey(request.pubkeys, request.pubkey_signatures) .await .map_err(into_response)?; - Ok(Json(response).into_response()) + let quotes = quotes + .into_iter() + .map(mint_quote_response_to_value) + .collect::, _>>() + .map_err(|e| { + tracing::error!("Failed to serialize mint quotes: {}", e); + into_response(cdk::Error::Internal) + })?; + + Ok(Json(MintQuoteByPubkeyResponse { quotes }).into_response()) } #[cfg(test)] diff --git a/crates/cdk-ffi/src/types/mint.rs b/crates/cdk-ffi/src/types/mint.rs index a4fb1311b..1faa4850c 100644 --- a/crates/cdk-ffi/src/types/mint.rs +++ b/crates/cdk-ffi/src/types/mint.rs @@ -511,6 +511,8 @@ pub struct Nuts { pub nut22: Option, /// NUT29 Settings - Batch minting pub nut29: Nut29Settings, + /// NUTXX Settings - Mint quote lookup by pubkey + pub nutxx_supported: bool, /// Supported currency units for minting pub mint_units: Vec, /// Supported currency units for melting @@ -544,6 +546,7 @@ impl From for Nuts { nut21: nuts.nut21.map(Into::into), nut22: nuts.nut22.map(Into::into), nut29: nuts.nut29.into(), + nutxx_supported: nuts.nutxx.supported, mint_units, melt_units, } @@ -587,6 +590,9 @@ impl TryFrom for cdk::nuts::Nuts { nut21: n.nut21.map(|s| s.try_into()).transpose()?, nut22: n.nut22.map(|s| s.try_into()).transpose()?, nut29: n.nut29.into(), + nutxx: cdk::nuts::nut06::SupportedSettings { + supported: n.nutxx_supported, + }, }) } } @@ -768,6 +774,7 @@ mod tests { )], }), nut29: Default::default(), + nutxx: Default::default(), } } @@ -908,6 +915,7 @@ mod tests { nut21: None, nut22: None, nut29: Default::default(), + nutxx: Default::default(), }; let ffi_nuts: Nuts = cdk_nuts.into(); @@ -940,6 +948,7 @@ mod tests { nut21: None, nut22: None, nut29: Default::default(), + nutxx_supported: false, mint_units: vec![], melt_units: vec![], }; @@ -1103,6 +1112,7 @@ mod tests { }], }), nut29: Nut29Settings::default(), + nutxx_supported: false, mint_units: vec![], melt_units: vec![], }, diff --git a/crates/cdk/src/mint/builder.rs b/crates/cdk/src/mint/builder.rs index 11a021d1f..6a5d9652a 100644 --- a/crates/cdk/src/mint/builder.rs +++ b/crates/cdk/src/mint/builder.rs @@ -104,6 +104,7 @@ impl MintBuilder { .nut12(true) .nut14(true) .nut20(true) + .nutxx(true) .nut29(cdk_common::nut29::Settings::default()), ..Default::default() }; From f89b2fd30d22285086d95da5922216e40b3b6a47 Mon Sep 17 00:00:00 2001 From: vnprc Date: Thu, 6 Aug 2026 21:23:53 +0000 Subject: [PATCH 10/12] test(nutxx): run the database test, and name the index after its table `get_mint_quote_by_public_key` was defined but never added to the `mint_db_test!` list, so no backend ran it. That is how the missing `updated_at` column reached this branch: registering the test makes it fail immediately on the unfixed query. It now runs against both sqlite and postgres, and also covers quotes owned by another pubkey, several pubkeys at once, an unknown pubkey, and an empty request. New coverage for the rest of the feature: - crates/cdk/tests: signed lookup succeeds; a digest-signed signature is refused while a preimage-signed one is accepted; missing or short signature arrays are refused; another key's and another mint's signatures are refused; oversized requests are refused; support is advertised in NUT-06. - crates/cdk-axum/tests: the route answers with and without custom payment methods configured, the body is `{"quotes": [...]}` with flat NUT-04 quote objects, and an unsigned request does not return 200. Index naming: `idx_pubkey` was schema-global in both SQLite and Postgres and did not match the other indexes on this table (`idx_mint_quote_expiry`, `idx_mint_quote_created_time`, ...). Renamed to `idx_mint_quote_pubkey`. The migration was also re-dated, having sorted ahead of eleven migrations already in the tree. --- Cargo.lock | 1 + crates/cdk-axum/Cargo.toml | 2 + .../tests/nutxx_mint_quote_lookup_route.rs | 157 ++++++++++++ .../cdk-common/src/database/mint/test/mint.rs | 55 ++++- .../cdk-common/src/database/mint/test/mod.rs | 1 + ...4221137_add_pubkey_index_to_mint_quote.sql | 1 - ...3000000_add_pubkey_index_to_mint_quote.sql | 1 + ...4221137_add_pubkey_index_to_mint_quote.sql | 1 - ...3000000_add_pubkey_index_to_mint_quote.sql | 1 + crates/cdk/Cargo.toml | 2 + crates/cdk/tests/nutxx_mint_quote_lookup.rs | 226 ++++++++++++++++++ 11 files changed, 439 insertions(+), 9 deletions(-) create mode 100644 crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs delete mode 100644 crates/cdk-sql-common/src/mint/migrations/postgres/20260404221137_add_pubkey_index_to_mint_quote.sql create mode 100644 crates/cdk-sql-common/src/mint/migrations/postgres/20260723000000_add_pubkey_index_to_mint_quote.sql delete mode 100644 crates/cdk-sql-common/src/mint/migrations/sqlite/20260404221137_add_pubkey_index_to_mint_quote.sql create mode 100644 crates/cdk-sql-common/src/mint/migrations/sqlite/20260723000000_add_pubkey_index_to_mint_quote.sql create mode 100644 crates/cdk/tests/nutxx_mint_quote_lookup.rs diff --git a/Cargo.lock b/Cargo.lock index 582ad3eab..06709f9d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1314,6 +1314,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "tokio", + "tower", "tracing", ] diff --git a/crates/cdk-axum/Cargo.toml b/crates/cdk-axum/Cargo.toml index 0a33f6265..2883c1b78 100644 --- a/crates/cdk-axum/Cargo.toml +++ b/crates/cdk-axum/Cargo.toml @@ -46,6 +46,8 @@ cdk-fake-wallet = { workspace = true } cdk-sqlite = { workspace = true, features = ["mint"] } cdk-signatory = { workspace = true } bip39 = { workspace = true } +tower = { workspace = true, features = ["util"] } +tokio = { workspace = true, features = ["full"] } [lints] workspace = true diff --git a/crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs b/crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs new file mode 100644 index 000000000..a9f524ff9 --- /dev/null +++ b/crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs @@ -0,0 +1,157 @@ +#![allow(clippy::unwrap_used)] + +//! NUT-XX route: `POST /v1/mint/quote/pubkey` +//! +//! The lookup is method-agnostic, so it must be served by the main v1 router rather than the +//! per-method custom router, which is only mounted when custom methods are configured. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use bip39::Mnemonic; +use cdk::mint::{Mint, MintBuilder, MintMeltLimits}; +use cdk::nuts::nut00::KnownMethod; +use cdk::nuts::nutxx::mint_quote_lookup_msg_to_sign; +use cdk::nuts::{CurrencyUnit, MintQuoteBolt11Request, PaymentMethod, SecretKey}; +use cdk::types::{FeeReserve, QuoteTTL}; +use cdk::{Amount, MintQuoteRequest}; +use cdk_fake_wallet::FakeWallet; +use tower::ServiceExt; + +async fn test_mint() -> Arc { + let db = Arc::new(cdk_sqlite::mint::memory::empty().await.unwrap()); + let mut builder = MintBuilder::new(db.clone()); + + let backend = FakeWallet::new( + FeeReserve { + min_fee_reserve: 1.into(), + percent_fee_reserve: 1.0, + }, + HashMap::default(), + HashSet::default(), + 2, + CurrencyUnit::Sat, + ); + + builder + .add_payment_processor( + CurrencyUnit::Sat, + PaymentMethod::Known(KnownMethod::Bolt11), + MintMeltLimits::new(1, 10_000), + Arc::new(backend), + ) + .await + .unwrap(); + + let mnemonic = Mnemonic::generate(12).unwrap(); + builder = builder + .with_name("nutxx route test".to_string()) + .with_description("nutxx route test".to_string()) + .with_urls(vec!["https://test-mint".to_string()]); + + let mint = builder + .build_with_seed(db.clone(), &mnemonic.to_seed_normalized("")) + .await + .unwrap(); + mint.set_quote_ttl(QuoteTTL::new(10_000, 10_000)) + .await + .unwrap(); + Arc::new(mint) +} + +async fn signed_request_body(mint: &Mint, owner: &SecretKey) -> String { + let mint_pubkey = mint.mint_info().await.unwrap().pubkey.unwrap(); + let pubkey = owner.public_key(); + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey); + let signature = owner.sign(&msg).unwrap(); + + format!( + r#"{{"pubkeys":["{}"],"pubkey_signatures":["{}"]}}"#, + pubkey.to_hex(), + signature + ) +} + +fn lookup_request(body: String) -> Request { + Request::builder() + .method("POST") + .uri("/v1/mint/quote/pubkey") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap() +} + +/// The endpoint answers on a plain bolt11 mint with no custom payment methods configured, +/// and the body matches `PostMintQuotesByPubkeyResponse`: an object with a `quotes` array +/// whose elements are flat NUT-04 quote objects. +#[tokio::test] +async fn lookup_route_is_served_without_custom_methods() { + let mint = test_mint().await; + let owner = SecretKey::generate(); + + mint.get_mint_quote(MintQuoteRequest::Bolt11(MintQuoteBolt11Request { + amount: Amount::new(100, CurrencyUnit::Sat).into(), + unit: CurrencyUnit::Sat, + description: None, + pubkey: Some(owner.public_key()), + })) + .await + .unwrap(); + + let body = signed_request_body(&mint, &owner).await; + let router = cdk_axum::create_mint_router(mint, vec![]).await.unwrap(); + + let response = router.oneshot(lookup_request(body)).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + let quotes = json + .get("quotes") + .expect("response is an object with a `quotes` field") + .as_array() + .expect("`quotes` is an array"); + assert_eq!(quotes.len(), 1); + + // Flat NUT-04 quote object, not an externally tagged `{"Bolt11": …}` envelope. + assert!(quotes[0].get("Bolt11").is_none()); + assert!(quotes[0].get("quote").is_some()); + assert_eq!(quotes[0]["method"], "bolt11"); + assert_eq!(quotes[0]["pubkey"], owner.public_key().to_hex()); +} + +/// Configuring custom payment methods must not shadow or break the route. +#[tokio::test] +async fn lookup_route_is_served_with_custom_methods() { + let mint = test_mint().await; + let owner = SecretKey::generate(); + let body = signed_request_body(&mint, &owner).await; + + let router = cdk_axum::create_mint_router(mint, vec!["paypal".to_string()]) + .await + .unwrap(); + + let response = router.oneshot(lookup_request(body)).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} + +/// A request with no signatures is refused at the HTTP layer. +#[tokio::test] +async fn unsigned_request_is_refused() { + let mint = test_mint().await; + let victim = SecretKey::generate().public_key(); + + let router = cdk_axum::create_mint_router(mint, vec![]).await.unwrap(); + let body = format!( + r#"{{"pubkeys":["{}"],"pubkey_signatures":[]}}"#, + victim.to_hex() + ); + + let response = router.oneshot(lookup_request(body)).await.unwrap(); + assert_ne!(response.status(), StatusCode::OK); +} diff --git a/crates/cdk-common/src/database/mint/test/mint.rs b/crates/cdk-common/src/database/mint/test/mint.rs index 4398080bc..8fa60e244 100644 --- a/crates/cdk-common/src/database/mint/test/mint.rs +++ b/crates/cdk-common/src/database/mint/test/mint.rs @@ -1095,10 +1095,11 @@ where let secret_key = SecretKey::generate(); let pubkey = secret_key.public_key(); + let other_pubkey = SecretKey::generate().public_key(); let mint_quote = MintQuote::new( None, - "".to_owned(), + unique_string(), cashu::CurrencyUnit::Sat, None, 0, @@ -1114,17 +1115,57 @@ where None, ); - // Add quote + // A second quote locked to a different pubkey, which must never be returned below. + let other_quote = MintQuote::new( + None, + unique_string(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + Some(other_pubkey), + Amount::new(100, cashu::CurrencyUnit::Sat), + Amount::new(0, cashu::CurrencyUnit::Sat), + cashu::PaymentMethod::Known(KnownMethod::Bolt11), + 0, + 0, + vec![], + vec![], + None, + ); + + // Add quotes let mut tx = Database::begin_transaction(&db).await.unwrap(); tx.add_mint_quote(mint_quote.clone()).await.unwrap(); + tx.add_mint_quote(other_quote.clone()).await.unwrap(); tx.commit().await.unwrap(); let retrieved = db.get_mint_quotes_by_pubkey(&[pubkey]).await.unwrap(); - assert!(!retrieved.is_empty()); - let retrieved = retrieved.first().unwrap(); - assert_eq!(retrieved.id, mint_quote.id); - assert!(retrieved.pubkey.is_some()); - assert_eq!(retrieved.pubkey, Some(pubkey)); + assert_eq!(retrieved.len(), 1); + let quote = retrieved.first().unwrap(); + assert_eq!(quote.id, mint_quote.id); + assert_eq!(quote.pubkey, Some(pubkey)); + + // Only the requested pubkey's quotes come back. + assert!(!retrieved.iter().any(|q| q.id == other_quote.id)); + + // Both pubkeys at once returns both quotes. + let both = db + .get_mint_quotes_by_pubkey(&[pubkey, other_pubkey]) + .await + .unwrap(); + assert_eq!(both.len(), 2); + + // An unknown pubkey returns nothing rather than erroring. + let unknown = SecretKey::generate().public_key(); + assert!(db + .get_mint_quotes_by_pubkey(&[unknown]) + .await + .unwrap() + .is_empty()); + + // An empty request is not an error. + assert!(db.get_mint_quotes_by_pubkey(&[]).await.unwrap().is_empty()); } /// Test deleting blinded messages diff --git a/crates/cdk-common/src/database/mint/test/mod.rs b/crates/cdk-common/src/database/mint/test/mod.rs index f2e63b067..9bf376222 100644 --- a/crates/cdk-common/src/database/mint/test/mod.rs +++ b/crates/cdk-common/src/database/mint/test/mod.rs @@ -428,6 +428,7 @@ macro_rules! mint_db_test { get_mint_quotes_by_ids, get_melt_quotes_by_request_lookup_id, lock_melt_quote_and_related, + get_mint_quote_by_public_key, ); }; ($make_db_fn:ident, $($name:ident),+ $(,)?) => { diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20260404221137_add_pubkey_index_to_mint_quote.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20260404221137_add_pubkey_index_to_mint_quote.sql deleted file mode 100644 index 4980b0e40..000000000 --- a/crates/cdk-sql-common/src/mint/migrations/postgres/20260404221137_add_pubkey_index_to_mint_quote.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_pubkey ON mint_quote(pubkey); diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20260723000000_add_pubkey_index_to_mint_quote.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20260723000000_add_pubkey_index_to_mint_quote.sql new file mode 100644 index 000000000..61a3df711 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20260723000000_add_pubkey_index_to_mint_quote.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_mint_quote_pubkey ON mint_quote(pubkey); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20260404221137_add_pubkey_index_to_mint_quote.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20260404221137_add_pubkey_index_to_mint_quote.sql deleted file mode 100644 index 4980b0e40..000000000 --- a/crates/cdk-sql-common/src/mint/migrations/sqlite/20260404221137_add_pubkey_index_to_mint_quote.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_pubkey ON mint_quote(pubkey); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20260723000000_add_pubkey_index_to_mint_quote.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20260723000000_add_pubkey_index_to_mint_quote.sql new file mode 100644 index 000000000..61a3df711 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20260723000000_add_pubkey_index_to_mint_quote.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_mint_quote_pubkey ON mint_quote(pubkey); diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index 3997a2dd8..c82f06c10 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -178,6 +178,8 @@ required-features = ["wallet"] [dev-dependencies] rand.workspace = true +bitcoin.workspace = true +serde_json.workspace = true cdk-sqlite.workspace = true cdk-postgres.workspace = true cdk-fake-wallet.workspace = true diff --git a/crates/cdk/tests/nutxx_mint_quote_lookup.rs b/crates/cdk/tests/nutxx_mint_quote_lookup.rs new file mode 100644 index 000000000..9106fa19c --- /dev/null +++ b/crates/cdk/tests/nutxx_mint_quote_lookup.rs @@ -0,0 +1,226 @@ +#![allow(clippy::unwrap_used)] + +//! NUT-XX: mint quote lookup by public key +//! +//! + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use bip39::Mnemonic; +use bitcoin::hashes::sha256::Hash as Sha256Hash; +use bitcoin::hashes::Hash; +use cdk::mint::{Mint, MintBuilder, MintMeltLimits}; +use cdk::nuts::nut00::KnownMethod; +use cdk::nuts::nutxx::{mint_quote_lookup_msg_to_sign, MAX_LOOKUP_PUBKEYS}; +use cdk::nuts::{CurrencyUnit, MintQuoteBolt11Request, PaymentMethod, PublicKey, SecretKey}; +use cdk::types::{FeeReserve, QuoteTTL}; +use cdk::{Amount, Error, MintQuoteRequest}; +use cdk_fake_wallet::FakeWallet; + +async fn test_mint() -> Mint { + let db = Arc::new(cdk_sqlite::mint::memory::empty().await.unwrap()); + let mut builder = MintBuilder::new(db.clone()); + + let backend = FakeWallet::new( + FeeReserve { + min_fee_reserve: 1.into(), + percent_fee_reserve: 1.0, + }, + HashMap::default(), + HashSet::default(), + 2, + CurrencyUnit::Sat, + ); + + builder + .add_payment_processor( + CurrencyUnit::Sat, + PaymentMethod::Known(KnownMethod::Bolt11), + MintMeltLimits::new(1, 10_000), + Arc::new(backend), + ) + .await + .unwrap(); + + let mnemonic = Mnemonic::generate(12).unwrap(); + builder = builder + .with_name("nutxx test mint".to_string()) + .with_description("nutxx test mint".to_string()) + .with_urls(vec!["https://test-mint".to_string()]); + + let mint = builder + .build_with_seed(db.clone(), &mnemonic.to_seed_normalized("")) + .await + .unwrap(); + mint.set_quote_ttl(QuoteTTL::new(10_000, 10_000)) + .await + .unwrap(); + mint +} + +/// Create a NUT-20 locked bolt11 mint quote owned by `pubkey`. +async fn locked_quote(mint: &Mint, pubkey: PublicKey) { + mint.get_mint_quote(MintQuoteRequest::Bolt11(MintQuoteBolt11Request { + amount: Amount::new(100, CurrencyUnit::Sat).into(), + unit: CurrencyUnit::Sat, + description: None, + pubkey: Some(pubkey), + })) + .await + .unwrap(); +} + +/// Sign the lookup message the way a spec-conformant wallet does. +async fn sign_lookup( + mint: &Mint, + secret_key: &SecretKey, +) -> bitcoin::secp256k1::schnorr::Signature { + let mint_pubkey = mint.mint_info().await.unwrap().pubkey.unwrap(); + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &secret_key.public_key()); + secret_key.sign(&msg).unwrap() +} + +/// A mint that serves the endpoint must say so in its NUT-06 info response, otherwise wallets +/// have no way to discover it. +#[tokio::test] +async fn support_is_advertised_in_mint_info() { + let mint = test_mint().await; + let mint_info = mint.mint_info().await.unwrap(); + + assert!(mint_info.nuts.nutxx.supported); + + let json = serde_json::to_value(&mint_info).unwrap(); + assert_eq!(json["nuts"]["XX"]["supported"], true); +} + +/// A valid signature returns the quotes locked to that pubkey. +#[tokio::test] +async fn signed_lookup_returns_own_quotes() { + let mint = test_mint().await; + let owner = SecretKey::generate(); + let pubkey = owner.public_key(); + locked_quote(&mint, pubkey).await; + + let signature = sign_lookup(&mint, &owner).await; + let quotes = mint + .get_mint_quote_by_pubkey(vec![pubkey], vec![signature]) + .await + .unwrap(); + + assert_eq!(quotes.len(), 1); + assert_eq!( + quotes[0].method(), + PaymentMethod::Known(KnownMethod::Bolt11) + ); +} + +/// The signature covers SHA256(preimage), matching the NUT. `PublicKey::verify` hashes its +/// argument, so passing a digest instead of the preimage would verify a double hash and reject +/// conformant wallets. +#[tokio::test] +async fn signature_is_over_a_single_hash_of_the_preimage() { + let mint = test_mint().await; + let owner = SecretKey::generate(); + let pubkey = owner.public_key(); + locked_quote(&mint, pubkey).await; + + let mint_pubkey = mint.mint_info().await.unwrap().pubkey.unwrap(); + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey); + + // Signing the preimage is accepted... + let signature = owner.sign(&msg).unwrap(); + assert!(mint + .get_mint_quote_by_pubkey(vec![pubkey], vec![signature]) + .await + .is_ok()); + + // ...and signing the digest of the preimage is not. + let digest = Sha256Hash::hash(&msg).to_byte_array(); + let double_hashed = owner.sign(&digest).unwrap(); + assert!(matches!( + mint.get_mint_quote_by_pubkey(vec![pubkey], vec![double_hashed]) + .await, + Err(Error::SignatureMissingOrInvalid) + )); +} + +/// "The mint MUST reject the request unless every signature is valid" — an empty or short +/// signature array must not silently skip verification. +#[tokio::test] +async fn missing_signatures_are_rejected() { + let mint = test_mint().await; + let victim = SecretKey::generate(); + let victim_pubkey = victim.public_key(); + locked_quote(&mint, victim_pubkey).await; + + // No signature at all. + assert!(matches!( + mint.get_mint_quote_by_pubkey(vec![victim_pubkey], vec![]) + .await, + Err(Error::SignatureMissingOrInvalid) + )); + + // Fewer signatures than pubkeys: one valid signature must not authorise a second pubkey. + let attacker = SecretKey::generate(); + let attacker_signature = sign_lookup(&mint, &attacker).await; + assert!(matches!( + mint.get_mint_quote_by_pubkey( + vec![attacker.public_key(), victim_pubkey], + vec![attacker_signature] + ) + .await, + Err(Error::SignatureMissingOrInvalid) + )); +} + +/// A signature from one key must not unlock a different key's quotes. +#[tokio::test] +async fn signature_from_another_key_is_rejected() { + let mint = test_mint().await; + let victim_pubkey = SecretKey::generate().public_key(); + locked_quote(&mint, victim_pubkey).await; + + let attacker = SecretKey::generate(); + let attacker_signature = sign_lookup(&mint, &attacker).await; + + assert!(matches!( + mint.get_mint_quote_by_pubkey(vec![victim_pubkey], vec![attacker_signature]) + .await, + Err(Error::SignatureMissingOrInvalid) + )); +} + +/// A signature bound to a different mint must not be replayable here. +#[tokio::test] +async fn signature_for_another_mint_is_rejected() { + let mint = test_mint().await; + let owner = SecretKey::generate(); + let pubkey = owner.public_key(); + locked_quote(&mint, pubkey).await; + + let other_mint_pubkey = SecretKey::generate().public_key(); + let msg = mint_quote_lookup_msg_to_sign(&other_mint_pubkey, &pubkey); + let signature = owner.sign(&msg).unwrap(); + + assert!(matches!( + mint.get_mint_quote_by_pubkey(vec![pubkey], vec![signature]) + .await, + Err(Error::SignatureMissingOrInvalid) + )); +} + +/// An anonymous caller cannot ask the mint for unbounded signature verification. +#[tokio::test] +async fn oversized_request_is_rejected() { + let mint = test_mint().await; + + let pubkeys: Vec = (0..MAX_LOOKUP_PUBKEYS + 1) + .map(|_| SecretKey::generate().public_key()) + .collect(); + + assert!(matches!( + mint.get_mint_quote_by_pubkey(pubkeys, vec![]).await, + Err(Error::BatchSizeExceeded { .. }) + )); +} From eb8b04abfc595a6e620a846193c30bb670e7b9ea Mon Sep 17 00:00:00 2001 From: TheMhv Date: Mon, 24 Aug 2026 15:36:27 -0300 Subject: [PATCH 11/12] fix(nutxx): add method to mint quotes by pubkey route --- crates/cashu/src/nuts/auth/nut21.rs | 30 +++++---- crates/cdk-axum/src/custom_handlers.rs | 54 ---------------- crates/cdk-axum/src/custom_router.rs | 5 -- crates/cdk-axum/src/lib.rs | 6 +- crates/cdk-axum/src/router_handlers.rs | 61 +++++++++++++++++++ .../tests/nutxx_mint_quote_lookup_route.rs | 15 +++-- .../cdk-common/src/database/mint/test/mint.rs | 2 - crates/cdk/src/mint/issue/mod.rs | 5 +- crates/cdk/tests/nutxx_mint_quote_lookup.rs | 24 +++++--- 9 files changed, 109 insertions(+), 93 deletions(-) diff --git a/crates/cashu/src/nuts/auth/nut21.rs b/crates/cashu/src/nuts/auth/nut21.rs index 51442d279..835e924f5 100644 --- a/crates/cashu/src/nuts/auth/nut21.rs +++ b/crates/cashu/src/nuts/auth/nut21.rs @@ -130,8 +130,8 @@ pub enum RoutePath { Wildcard(String), /// Mint Quote for a specific payment method MintQuote(String), - /// Mint Quote lookup by public key (NUT-XX), method-agnostic - MintQuoteByPubkey, + /// Mint Quote lookup by public key (NUT-XX) + MintQuoteByPubkey(String), /// Mint for a specific payment method Mint(String), /// Melt Quote for a specific payment method @@ -174,13 +174,16 @@ impl std::str::FromStr for RoutePath { "/v1/restore" => Ok(RoutePath::Restore), "/v1/auth/blind/mint" => Ok(RoutePath::MintBlindAuth), "/v1/ws" => Ok(RoutePath::Ws), - // Must precede the `/v1/mint/quote/` prefix branch below, which would otherwise - // read this as a payment method literally named "pubkey". - "/v1/mint/quote/pubkey" => Ok(RoutePath::MintQuoteByPubkey), _ => { // Try to parse as a payment method route if let Some(method) = s.strip_prefix("/v1/mint/quote/") { - Ok(RoutePath::MintQuote(normalize_payment_method(method))) + if let Some(method) = method.strip_suffix("/pubkey") { + Ok(RoutePath::MintQuoteByPubkey(normalize_payment_method( + method, + ))) + } else { + Ok(RoutePath::MintQuote(normalize_payment_method(method))) + } } else if let Some(method) = s.strip_prefix("/v1/mint/") { Ok(RoutePath::Mint(normalize_payment_method(method))) } else if let Some(method) = s.strip_prefix("/v1/melt/quote/") { @@ -344,7 +347,7 @@ impl std::fmt::Display for RoutePath { match self { RoutePath::Wildcard(prefix) => write!(f, "{}*", prefix), RoutePath::MintQuote(method) => write!(f, "/v1/mint/quote/{}", method), - RoutePath::MintQuoteByPubkey => write!(f, "/v1/mint/quote/pubkey"), + RoutePath::MintQuoteByPubkey(method) => write!(f, "/v1/mint/quote/{}/pubkey", method), RoutePath::Mint(method) => write!(f, "/v1/mint/{}", method), RoutePath::MeltQuote(method) => write!(f, "/v1/melt/quote/{}", method), RoutePath::Melt(method) => write!(f, "/v1/melt/{}", method), @@ -698,13 +701,14 @@ mod tests { let json = serde_json::to_string(&RoutePath::MintQuote("paypal".to_string())).unwrap(); assert_eq!(json, "\"/v1/mint/quote/paypal\""); - // NUT-XX lookup is a static path. It must not be read as a payment method named - // "pubkey" by the `/v1/mint/quote/` prefix branch in `FromStr`. - let json = serde_json::to_string(&RoutePath::MintQuoteByPubkey).unwrap(); - assert_eq!(json, "\"/v1/mint/quote/pubkey\""); + let json = + serde_json::to_string(&RoutePath::MintQuoteByPubkey("bolt11".to_string())).unwrap(); + assert_eq!(json, "\"/v1/mint/quote/bolt11/pubkey\""); + + let path: RoutePath = serde_json::from_str("\"/v1/mint/quote/bolt11/pubkey\"").unwrap(); assert_eq!( - RoutePath::from_str("/v1/mint/quote/pubkey").unwrap(), - RoutePath::MintQuoteByPubkey + path, + RoutePath::MintQuoteByPubkey(PaymentMethod::Known(KnownMethod::Bolt11).to_string()) ); // Test deserialization of payment method paths diff --git a/crates/cdk-axum/src/custom_handlers.rs b/crates/cdk-axum/src/custom_handlers.rs index 888fd78fe..3e81e4e23 100644 --- a/crates/cdk-axum/src/custom_handlers.rs +++ b/crates/cdk-axum/src/custom_handlers.rs @@ -13,7 +13,6 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use cdk::mint::QuoteId; use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath}; -use cdk::nuts::nutxx::{MintQuoteByPubkeyRequest, MintQuoteByPubkeyResponse}; use cdk::nuts::{ BatchCheckMintQuoteRequest, BatchMintRequest, MeltOnchainRequest, MeltQuoteBolt11Request, MeltQuoteBolt12Request, MeltQuoteCustomRequest, MeltQuoteOnchainRequest, @@ -21,7 +20,6 @@ use cdk::nuts::{ MintQuoteBolt12Response, MintQuoteCustomRequest, MintQuoteOnchainRequest, MintQuoteOnchainResponse, MintRequest, MintResponse, PaymentMethod, }; -use cdk::MintQuoteResponse; use cdk::{MeltQuoteCreateResponse, MeltQuoteResponse}; use serde_json::Value; use tracing::instrument; @@ -729,58 +727,6 @@ pub async fn cache_post_batch_mint( Ok(result) } -/// Flatten a `MintQuoteResponse` to the NUT-04 quote object that goes on the wire. -/// -/// `MintQuoteResponse` is an externally tagged enum, so serialising it directly would wrap -/// each quote in a `{"Bolt11": …}` envelope that no NUT describes. -fn mint_quote_response_to_value( - response: MintQuoteResponse, -) -> Result { - match response { - MintQuoteResponse::Bolt11(r) => serde_json::to_value(r), - MintQuoteResponse::Bolt12(r) => serde_json::to_value(r), - MintQuoteResponse::Onchain(r) => serde_json::to_value(r), - MintQuoteResponse::Custom { response, .. } => serde_json::to_value(response), - } -} - -/// Handler for mint quote lookup by public key (NUT-XX) -/// -/// Method-agnostic: a pubkey may hold quotes across several payment methods and they are all -/// returned together, each in its own NUT-04 response format. -#[instrument(skip_all)] -pub async fn post_mint_quote_by_pubkey( - auth: AuthHeader, - State(state): State, - Json(request): Json, -) -> Result { - state - .mint - .verify_auth( - auth.into(), - &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteByPubkey), - ) - .await - .map_err(into_response)?; - - let quotes = state - .mint - .get_mint_quote_by_pubkey(request.pubkeys, request.pubkey_signatures) - .await - .map_err(into_response)?; - - let quotes = quotes - .into_iter() - .map(mint_quote_response_to_value) - .collect::, _>>() - .map_err(|e| { - tracing::error!("Failed to serialize mint quotes: {}", e); - into_response(cdk::Error::Internal) - })?; - - Ok(Json(MintQuoteByPubkeyResponse { quotes }).into_response()) -} - #[cfg(test)] mod tests { use std::collections::{HashMap, HashSet}; diff --git a/crates/cdk-axum/src/custom_router.rs b/crates/cdk-axum/src/custom_router.rs index 061bb596b..7c827f2ec 100644 --- a/crates/cdk-axum/src/custom_router.rs +++ b/crates/cdk-axum/src/custom_router.rs @@ -20,7 +20,6 @@ use crate::MintState; /// - `/mint/quote/{method}` - POST: Create mint quote /// - `/mint/quote/{method}/{quote_id}` - GET: Check mint quote status /// - `/mint/quote/{method}/check` - POST: Batch check mint quote status (NUT-29) -/// - `/mint/quote/{method}/pubkey` - POST: Mint quotes lookup by pubkey /// - `/mint/{method}` - POST: Mint tokens /// - `/mint/{method}/batch` - POST: Batch mint tokens (NUT-29) /// - `/melt/quote/{method}` - POST: Create melt quote @@ -47,10 +46,6 @@ pub fn create_custom_routers(state: MintState, custom_methods: Vec) -> R "/mint/quote/{method}/check", post(post_batch_check_mint_quote), ) - .route( - "/mint/quote/{method}/pubkey", - post(post_mint_quote_by_pubkey), - ) .route("/mint/{method}", post(cache_post_mint_custom)) .route("/mint/{method}/batch", post(cache_post_batch_mint)) .route("/melt/quote/{method}", post(post_melt_custom_quote)) diff --git a/crates/cdk-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index d6d14bfcf..d5f50483d 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -108,11 +108,9 @@ pub async fn create_mint_router_with_custom_cache( .route("/checkstate", post(post_check)) .route("/info", get(get_mint_info)) .route("/restore", post(post_restore)) - // NUT-XX quote lookup is method-agnostic, so it belongs here rather than in the - // per-method custom router, which is only mounted when custom methods are configured. .route( - "/mint/quote/pubkey", - post(custom_handlers::post_mint_quote_by_pubkey), + "/mint/quote/{method}/pubkey", + post(post_mint_quote_by_pubkey), ); let mut mint_router = Router::new().nest("/v1", v1_router); diff --git a/crates/cdk-axum/src/router_handlers.rs b/crates/cdk-axum/src/router_handlers.rs index c5837393f..0c813b3de 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -10,7 +10,10 @@ use cdk::nuts::{ RestoreRequest, RestoreResponse, SwapRequest, SwapResponse, }; use cdk::util::unix_time; +use cdk_common::nutxx::{MintQuoteByPubkeyRequest, MintQuoteByPubkeyResponse}; +use cdk_common::{MintQuoteResponse, PaymentMethod, QuoteId}; use paste::paste; +use serde_json::Value; use tracing::instrument; use crate::auth::AuthHeader; @@ -242,6 +245,64 @@ pub(crate) async fn post_restore( Ok(Json(restore_response)) } +/// Handler for mint quote lookup by public key (NUT-XX) +/// +/// Method-agnostic: a pubkey may hold quotes across several payment methods and they are all +/// returned together, each in its own NUT-04 response format. +#[instrument(skip_all)] +pub async fn post_mint_quote_by_pubkey( + auth: AuthHeader, + Path(method): Path, + State(state): State, + Json(request): Json, +) -> Result { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new( + Method::Post, + RoutePath::MintQuoteByPubkey(method.clone().to_lowercase()), + ), + ) + .await + .map_err(into_response)?; + + let method = PaymentMethod::from(method); + + let quotes = state + .mint + .get_mint_quote_by_pubkey(method, request.pubkeys, request.pubkey_signatures) + .await + .map_err(into_response)?; + + let quotes = quotes + .into_iter() + .map(mint_quote_response_to_value) + .collect::, _>>() + .map_err(|e| { + tracing::error!("Failed to serialize mint quotes: {}", e); + into_response(cdk::Error::Internal) + })?; + + Ok(Json(MintQuoteByPubkeyResponse { quotes }).into_response()) +} + +/// Flatten a `MintQuoteResponse` to the NUT-04 quote object that goes on the wire. +/// +/// `MintQuoteResponse` is an externally tagged enum, so serialising it directly would wrap +/// each quote in a `{"Bolt11": …}` envelope that no NUT describes. +fn mint_quote_response_to_value( + response: MintQuoteResponse, +) -> Result { + match response { + MintQuoteResponse::Bolt11(r) => serde_json::to_value(r), + MintQuoteResponse::Bolt12(r) => serde_json::to_value(r), + MintQuoteResponse::Onchain(r) => serde_json::to_value(r), + MintQuoteResponse::Custom { response, .. } => serde_json::to_value(response), + } +} + #[cfg(feature = "info-page")] const CSS: &str = r#" :root { diff --git a/crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs b/crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs index a9f524ff9..33fbac6c4 100644 --- a/crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs +++ b/crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs @@ -1,6 +1,6 @@ #![allow(clippy::unwrap_used)] -//! NUT-XX route: `POST /v1/mint/quote/pubkey` +//! NUT-XX route: `POST /v1/mint/quote/{method}/pubkey` //! //! The lookup is method-agnostic, so it must be served by the main v1 router rather than the //! per-method custom router, which is only mounted when custom methods are configured. @@ -74,10 +74,10 @@ async fn signed_request_body(mint: &Mint, owner: &SecretKey) -> String { ) } -fn lookup_request(body: String) -> Request { +fn lookup_request(method: String, body: String) -> Request { Request::builder() .method("POST") - .uri("/v1/mint/quote/pubkey") + .uri(format!("/v1/mint/quote/{method}/pubkey")) .header("content-type", "application/json") .body(Body::from(body)) .unwrap() @@ -100,10 +100,11 @@ async fn lookup_route_is_served_without_custom_methods() { .await .unwrap(); + let method = "bolt11".to_string(); let body = signed_request_body(&mint, &owner).await; let router = cdk_axum::create_mint_router(mint, vec![]).await.unwrap(); - let response = router.oneshot(lookup_request(body)).await.unwrap(); + let response = router.oneshot(lookup_request(method, body)).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) @@ -130,13 +131,14 @@ async fn lookup_route_is_served_without_custom_methods() { async fn lookup_route_is_served_with_custom_methods() { let mint = test_mint().await; let owner = SecretKey::generate(); + let method = "bolt11".to_string(); let body = signed_request_body(&mint, &owner).await; let router = cdk_axum::create_mint_router(mint, vec!["paypal".to_string()]) .await .unwrap(); - let response = router.oneshot(lookup_request(body)).await.unwrap(); + let response = router.oneshot(lookup_request(method, body)).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -147,11 +149,12 @@ async fn unsigned_request_is_refused() { let victim = SecretKey::generate().public_key(); let router = cdk_axum::create_mint_router(mint, vec![]).await.unwrap(); + let method = "bolt11".to_string(); let body = format!( r#"{{"pubkeys":["{}"],"pubkey_signatures":[]}}"#, victim.to_hex() ); - let response = router.oneshot(lookup_request(body)).await.unwrap(); + let response = router.oneshot(lookup_request(method, body)).await.unwrap(); assert_ne!(response.status(), StatusCode::OK); } diff --git a/crates/cdk-common/src/database/mint/test/mint.rs b/crates/cdk-common/src/database/mint/test/mint.rs index 8fa60e244..5ad1d42c8 100644 --- a/crates/cdk-common/src/database/mint/test/mint.rs +++ b/crates/cdk-common/src/database/mint/test/mint.rs @@ -1091,8 +1091,6 @@ pub async fn get_mint_quote_by_public_key(db: DB) where DB: Database + KeysDatabase, { - use crate::database::mint::test::unique_string; - let secret_key = SecretKey::generate(); let pubkey = secret_key.public_key(); let other_pubkey = SecretKey::generate().public_key(); diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index 3d6b06898..4e987f87d 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -431,6 +431,7 @@ impl Mint { #[instrument(skip_all)] pub async fn get_mint_quote_by_pubkey( &self, + method: PaymentMethod, pubkeys: Vec, signatures: Vec, ) -> Result>, Error> { @@ -473,9 +474,9 @@ impl Mint { // here would drift the moment a response field is added. quotes .into_iter() + .filter(|q| q.payment_method == method) .map(MintQuoteResponse::try_from) - .collect::, _>>() - .map_err(Error::from) + .collect::, Error>>() } .await; diff --git a/crates/cdk/tests/nutxx_mint_quote_lookup.rs b/crates/cdk/tests/nutxx_mint_quote_lookup.rs index 9106fa19c..6e1a36abe 100644 --- a/crates/cdk/tests/nutxx_mint_quote_lookup.rs +++ b/crates/cdk/tests/nutxx_mint_quote_lookup.rs @@ -100,11 +100,12 @@ async fn signed_lookup_returns_own_quotes() { let mint = test_mint().await; let owner = SecretKey::generate(); let pubkey = owner.public_key(); + let method = PaymentMethod::Known(KnownMethod::Bolt11); locked_quote(&mint, pubkey).await; let signature = sign_lookup(&mint, &owner).await; let quotes = mint - .get_mint_quote_by_pubkey(vec![pubkey], vec![signature]) + .get_mint_quote_by_pubkey(method, vec![pubkey], vec![signature]) .await .unwrap(); @@ -123,6 +124,7 @@ async fn signature_is_over_a_single_hash_of_the_preimage() { let mint = test_mint().await; let owner = SecretKey::generate(); let pubkey = owner.public_key(); + let method = PaymentMethod::Known(KnownMethod::Bolt11); locked_quote(&mint, pubkey).await; let mint_pubkey = mint.mint_info().await.unwrap().pubkey.unwrap(); @@ -131,7 +133,7 @@ async fn signature_is_over_a_single_hash_of_the_preimage() { // Signing the preimage is accepted... let signature = owner.sign(&msg).unwrap(); assert!(mint - .get_mint_quote_by_pubkey(vec![pubkey], vec![signature]) + .get_mint_quote_by_pubkey(method.clone(), vec![pubkey], vec![signature]) .await .is_ok()); @@ -139,7 +141,7 @@ async fn signature_is_over_a_single_hash_of_the_preimage() { let digest = Sha256Hash::hash(&msg).to_byte_array(); let double_hashed = owner.sign(&digest).unwrap(); assert!(matches!( - mint.get_mint_quote_by_pubkey(vec![pubkey], vec![double_hashed]) + mint.get_mint_quote_by_pubkey(method, vec![pubkey], vec![double_hashed]) .await, Err(Error::SignatureMissingOrInvalid) )); @@ -152,11 +154,12 @@ async fn missing_signatures_are_rejected() { let mint = test_mint().await; let victim = SecretKey::generate(); let victim_pubkey = victim.public_key(); + let method = PaymentMethod::Known(KnownMethod::Bolt11); locked_quote(&mint, victim_pubkey).await; // No signature at all. assert!(matches!( - mint.get_mint_quote_by_pubkey(vec![victim_pubkey], vec![]) + mint.get_mint_quote_by_pubkey(method.clone(), vec![victim_pubkey], vec![]) .await, Err(Error::SignatureMissingOrInvalid) )); @@ -166,6 +169,7 @@ async fn missing_signatures_are_rejected() { let attacker_signature = sign_lookup(&mint, &attacker).await; assert!(matches!( mint.get_mint_quote_by_pubkey( + method, vec![attacker.public_key(), victim_pubkey], vec![attacker_signature] ) @@ -184,8 +188,10 @@ async fn signature_from_another_key_is_rejected() { let attacker = SecretKey::generate(); let attacker_signature = sign_lookup(&mint, &attacker).await; + let method = PaymentMethod::Known(KnownMethod::Bolt11); + assert!(matches!( - mint.get_mint_quote_by_pubkey(vec![victim_pubkey], vec![attacker_signature]) + mint.get_mint_quote_by_pubkey(method, vec![victim_pubkey], vec![attacker_signature]) .await, Err(Error::SignatureMissingOrInvalid) )); @@ -203,8 +209,10 @@ async fn signature_for_another_mint_is_rejected() { let msg = mint_quote_lookup_msg_to_sign(&other_mint_pubkey, &pubkey); let signature = owner.sign(&msg).unwrap(); + let method = PaymentMethod::Known(KnownMethod::Bolt11); + assert!(matches!( - mint.get_mint_quote_by_pubkey(vec![pubkey], vec![signature]) + mint.get_mint_quote_by_pubkey(method, vec![pubkey], vec![signature]) .await, Err(Error::SignatureMissingOrInvalid) )); @@ -213,6 +221,8 @@ async fn signature_for_another_mint_is_rejected() { /// An anonymous caller cannot ask the mint for unbounded signature verification. #[tokio::test] async fn oversized_request_is_rejected() { + let method = PaymentMethod::Known(KnownMethod::Bolt11); + let mint = test_mint().await; let pubkeys: Vec = (0..MAX_LOOKUP_PUBKEYS + 1) @@ -220,7 +230,7 @@ async fn oversized_request_is_rejected() { .collect(); assert!(matches!( - mint.get_mint_quote_by_pubkey(pubkeys, vec![]).await, + mint.get_mint_quote_by_pubkey(method, pubkeys, vec![]).await, Err(Error::BatchSizeExceeded { .. }) )); } From 1c5cf35bf7d9237197230a738ca431eec9b13385 Mon Sep 17 00:00:00 2001 From: TheMhv Date: Tue, 25 Aug 2026 10:23:25 -0300 Subject: [PATCH 12/12] fix(nutxx): add last_checked field for db mint quote query --- crates/cdk-sql-common/src/mint/quotes.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cdk-sql-common/src/mint/quotes.rs b/crates/cdk-sql-common/src/mint/quotes.rs index bff45c7d4..47e01ba7d 100644 --- a/crates/cdk-sql-common/src/mint/quotes.rs +++ b/crates/cdk-sql-common/src/mint/quotes.rs @@ -1408,6 +1408,7 @@ where amount_paid, amount_issued, updated_at, + last_checked, payment_method, request_lookup_id_kind, extra_json