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/cashu/src/nuts/auth/nut21.rs b/crates/cashu/src/nuts/auth/nut21.rs index 266fc50e4..835e924f5 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) + MintQuoteByPubkey(String), /// Mint for a specific payment method Mint(String), /// Melt Quote for a specific payment method @@ -175,7 +177,13 @@ impl std::str::FromStr for RoutePath { _ => { // 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/") { @@ -339,6 +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(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), @@ -692,6 +701,16 @@ mod tests { let json = serde_json::to_string(&RoutePath::MintQuote("paypal".to_string())).unwrap(); assert_eq!(json, "\"/v1/mint/quote/paypal\""); + 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!( + path, + RoutePath::MintQuoteByPubkey(PaymentMethod::Known(KnownMethod::Bolt11).to_string()) + ); + // Test deserialization of payment method paths let path: RoutePath = serde_json::from_str("\"/v1/mint/bolt11\"").unwrap(); assert_eq!( 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/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 new file mode 100644 index 000000000..eb07efec3 --- /dev/null +++ b/crates/cashu/src/nuts/nutxx.rs @@ -0,0 +1,156 @@ +//! NUT-XX: Mint Quote Lookup by Public Key +//! +//! + +use bitcoin::secp256k1::schnorr::Signature; +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 { + /// 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`. +/// +/// 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" + ); + } + + /// 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_sign_and_verify_is_mint_bound() { + 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 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/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/src/lib.rs b/crates/cdk-axum/src/lib.rs index 7f8409cdb..d5f50483d 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -107,7 +107,11 @@ 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)) + .route( + "/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 new file mode 100644 index 000000000..33fbac6c4 --- /dev/null +++ b/crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs @@ -0,0 +1,160 @@ +#![allow(clippy::unwrap_used)] + +//! 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. + +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(method: String, body: String) -> Request { + Request::builder() + .method("POST") + .uri(format!("/v1/mint/quote/{method}/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 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(method, 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 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(method, 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 method = "bolt11".to_string(); + let body = format!( + r#"{{"pubkeys":["{}"],"pubkey_signatures":[]}}"#, + victim.to_hex() + ); + + 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/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..5ad1d42c8 100644 --- a/crates/cdk-common/src/database/mint/test/mint.rs +++ b/crates/cdk-common/src/database/mint/test/mint.rs @@ -1086,6 +1086,86 @@ 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, +{ + let secret_key = SecretKey::generate(); + let pubkey = secret_key.public_key(); + let other_pubkey = SecretKey::generate().public_key(); + + let mint_quote = MintQuote::new( + None, + unique_string(), + 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, + 0, + vec![], + vec![], + None, + ); + + // 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_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 pub async fn delete_blinded_messages(db: DB) where 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-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-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/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-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/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/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() }; diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index ee3cc8d5b..4e987f87d 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::nutxx::{mint_quote_lookup_msg_to_sign, MAX_LOOKUP_PUBKEYS}; 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, MintQuoteOnchainResponse, MintQuoteRequest, + MintQuoteResponse, MintQuoteState, MintRequest, MintResponse, NotificationPayload, + PaymentMethod, PublicKey, }; use tracing::instrument; @@ -20,8 +23,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 { @@ -419,6 +420,74 @@ impl Mint { result } + /// 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>` - 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, + method: PaymentMethod, + pubkeys: Vec, + signatures: Vec, + ) -> Result>, Error> { + #[cfg(feature = "prometheus")] + let metrics = super::MintMetricGuard::new("mint_quotes_by_pubkeys"); + + // 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)?; + + for (pubkey, signature) in pubkeys.iter().zip(signatures.iter()) { + // `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(&msg, 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?; + + // `TryFrom` is the shared conversion every other quote path uses; hand-rolling it + // here would drift the moment a response field is added. + quotes + .into_iter() + .filter(|q| q.payment_method == method) + .map(MintQuoteResponse::try_from) + .collect::, Error>>() + } + .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 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..6e1a36abe --- /dev/null +++ b/crates/cdk/tests/nutxx_mint_quote_lookup.rs @@ -0,0 +1,236 @@ +#![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(); + 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(method, 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(); + let method = PaymentMethod::Known(KnownMethod::Bolt11); + 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(method.clone(), 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(method, 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(); + let method = PaymentMethod::Known(KnownMethod::Bolt11); + locked_quote(&mint, victim_pubkey).await; + + // No signature at all. + assert!(matches!( + mint.get_mint_quote_by_pubkey(method.clone(), 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( + method, + 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; + + let method = PaymentMethod::Known(KnownMethod::Bolt11); + + assert!(matches!( + mint.get_mint_quote_by_pubkey(method, 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(); + + let method = PaymentMethod::Known(KnownMethod::Bolt11); + + assert!(matches!( + mint.get_mint_quote_by_pubkey(method, 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 method = PaymentMethod::Known(KnownMethod::Bolt11); + + 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(method, pubkeys, vec![]).await, + Err(Error::BatchSizeExceeded { .. }) + )); +}