diff --git a/crates/cashu/src/nuts/nutxx.rs b/crates/cashu/src/nuts/nutxx.rs index eb07efec3..a66284333 100644 --- a/crates/cashu/src/nuts/nutxx.rs +++ b/crates/cashu/src/nuts/nutxx.rs @@ -23,6 +23,13 @@ pub struct MintQuoteByPubkeyRequest { pub pubkeys: Vec, /// Schnorr signatures, in the same order as `pubkeys` pub pubkey_signatures: Vec, + /// Bound the response to quotes that are still mintable (`amount_issued < amount_paid`) + /// + /// Opt-in and additive: absent from the wire when `false`, so a mint that predates this + /// field sees the request an old client would send, and a client that predates it + /// deserializes an incoming request to `false` - unfiltered either way. + #[serde(default, skip_serializing_if = "core::ops::Not::not")] + pub only_mintable: bool, } /// Mint quote by pubkey response [NUT-XX] @@ -130,6 +137,7 @@ mod tests { let json = serde_json::to_string(&MintQuoteByPubkeyRequest { pubkeys: vec![pubkey], pubkey_signatures: vec![signature], + only_mintable: false, }) .unwrap(); @@ -139,6 +147,54 @@ mod tests { let request: MintQuoteByPubkeyRequest = serde_json::from_str(&json).unwrap(); assert_eq!(request.pubkeys, vec![pubkey]); assert_eq!(request.pubkey_signatures, vec![signature]); + assert!(!request.only_mintable); + } + + /// A request from a client built before `only_mintable` existed (the field absent from the + /// wire entirely) must still deserialize, defaulting to `false` - the old, unfiltered + /// behavior - rather than failing to parse. + #[test] + fn test_only_mintable_absent_defaults_to_false() { + 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::json!({ + "pubkeys": [pubkey.to_hex()], + "pubkey_signatures": [signature.to_string()], + }); + + let request: MintQuoteByPubkeyRequest = serde_json::from_value(json).unwrap(); + assert!(!request.only_mintable); + } + + /// `false` is the common case and must be left off the wire entirely, so a mint that + /// predates this field parses the request exactly as it did before. + #[test] + fn test_only_mintable_false_is_not_serialized() { + let request = MintQuoteByPubkeyRequest { + pubkeys: vec![SecretKey::generate().public_key()], + pubkey_signatures: vec![], + only_mintable: false, + }; + + let value = serde_json::to_value(&request).unwrap(); + assert!(value.get("only_mintable").is_none()); + } + + /// `true` must be sent explicitly so a conforming mint can apply the filter. + #[test] + fn test_only_mintable_true_is_serialized() { + let request = MintQuoteByPubkeyRequest { + pubkeys: vec![SecretKey::generate().public_key()], + pubkey_signatures: vec![], + only_mintable: true, + }; + + let value = serde_json::to_value(&request).unwrap(); + assert_eq!(value.get("only_mintable"), Some(&serde_json::json!(true))); } /// The response envelope is an object with a `quotes` array, not a bare array. diff --git a/crates/cdk-axum/src/custom_handlers.rs b/crates/cdk-axum/src/custom_handlers.rs index 888fd78fe..6e6e7d54f 100644 --- a/crates/cdk-axum/src/custom_handlers.rs +++ b/crates/cdk-axum/src/custom_handlers.rs @@ -765,7 +765,11 @@ pub async fn post_mint_quote_by_pubkey( let quotes = state .mint - .get_mint_quote_by_pubkey(request.pubkeys, request.pubkey_signatures) + .get_mint_quote_by_pubkey( + request.pubkeys, + request.pubkey_signatures, + request.only_mintable, + ) .await .map_err(into_response)?; diff --git a/crates/cdk-common/src/database/mint/mod.rs b/crates/cdk-common/src/database/mint/mod.rs index da5dd15b1..0c3dab9ad 100644 --- a/crates/cdk-common/src/database/mint/mod.rs +++ b/crates/cdk-common/src/database/mint/mod.rs @@ -361,9 +361,14 @@ pub trait QuotesDatabase { /// Get Mint Quotes async fn get_mint_quotes(&self) -> Result, Self::Err>; /// Get Mint Quotes By Pubkey + /// + /// When `only_mintable` is `true`, the result is bounded to quotes that are still mintable + /// (`amount_paid > amount_issued`); when `false`, every quote for `pubkeys` is returned + /// regardless of accounting state. async fn get_mint_quotes_by_pubkey( &self, pubkeys: &[PublicKey], + only_mintable: bool, ) -> Result, Self::Err>; /// Get [`mint::MeltQuote`] async fn get_melt_quote( diff --git a/crates/cdk-common/src/database/mint/test/mint.rs b/crates/cdk-common/src/database/mint/test/mint.rs index 3cb4dd884..8bbef5f42 100644 --- a/crates/cdk-common/src/database/mint/test/mint.rs +++ b/crates/cdk-common/src/database/mint/test/mint.rs @@ -1080,8 +1080,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(); @@ -1129,7 +1127,10 @@ where tx.add_mint_quote(other_quote.clone()).await.unwrap(); tx.commit().await.unwrap(); - let retrieved = db.get_mint_quotes_by_pubkey(&[pubkey]).await.unwrap(); + let retrieved = db + .get_mint_quotes_by_pubkey(&[pubkey], false) + .await + .unwrap(); assert_eq!(retrieved.len(), 1); let quote = retrieved.first().unwrap(); assert_eq!(quote.id, mint_quote.id); @@ -1140,7 +1141,7 @@ where // Both pubkeys at once returns both quotes. let both = db - .get_mint_quotes_by_pubkey(&[pubkey, other_pubkey]) + .get_mint_quotes_by_pubkey(&[pubkey, other_pubkey], false) .await .unwrap(); assert_eq!(both.len(), 2); @@ -1148,13 +1149,17 @@ where // An unknown pubkey returns nothing rather than erroring. let unknown = SecretKey::generate().public_key(); assert!(db - .get_mint_quotes_by_pubkey(&[unknown]) + .get_mint_quotes_by_pubkey(&[unknown], false) .await .unwrap() .is_empty()); // An empty request is not an error. - assert!(db.get_mint_quotes_by_pubkey(&[]).await.unwrap().is_empty()); + assert!(db + .get_mint_quotes_by_pubkey(&[], false) + .await + .unwrap() + .is_empty()); } /// Test deleting blinded messages diff --git a/crates/cdk-integration-tests/src/init_pure_tests.rs b/crates/cdk-integration-tests/src/init_pure_tests.rs index 13379ea87..8177cd0ba 100644 --- a/crates/cdk-integration-tests/src/init_pure_tests.rs +++ b/crates/cdk-integration-tests/src/init_pure_tests.rs @@ -8,11 +8,9 @@ use std::{env, fs}; use anyhow::{anyhow, bail, Result}; use async_trait::async_trait; use bip39::Mnemonic; -use bitcoin::secp256k1::schnorr::Signature; use cashu::nut00::KnownMethod; use cashu::nutxx::MintQuoteByPubkeyRequest; use cashu::quote_id::QuoteId; -use cashu::PublicKey; use cdk::amount::SplitTarget; use cdk::cdk_database::{self, WalletDatabase}; use cdk::mint::{MintBuilder, MintMeltLimits}; @@ -140,44 +138,18 @@ impl MintConnector for DirectMintConnection { async fn post_mint_quote_by_pubkey( &self, - method: PaymentMethod, request: MintQuoteByPubkeyRequest, ) -> Result>, Error> { - let pubkeys: Vec = request - .pubkeys - .iter() - .map(|pk| PublicKey::from_hex(pk).map_err(|_| Error::PubkeyRequired)) - .collect::, _>>()?; - - let signatures: Vec = request - .pubkeys_signatures - .iter() - .map(|sig| { - Signature::from_slice(sig.as_bytes()).map_err(|_| Error::SignatureMissingOrInvalid) - }) - .collect::, _>>()?; - - let response = self + let responses = self .mint - .get_mint_quote_by_pubkey(pubkeys, signatures, request.nonce, request.timestamp) - .await? - .into_iter() - .map(|r| match r { - cdk::mint::MintQuoteResponse::Bolt11(x) => { - MintQuoteResponse::::Bolt11(x.into()) - } - cdk::mint::MintQuoteResponse::Bolt12(x) => MintQuoteResponse::Bolt12(x.into()), - cdk::mint::MintQuoteResponse::Onchain(x) => MintQuoteResponse::Onchain(x.into()), - cdk::mint::MintQuoteResponse::Custom { response: x, .. } => { - MintQuoteResponse::Custom { - method: method.clone(), - response: x.into(), - } - } - }) - .collect(); + .get_mint_quote_by_pubkey( + request.pubkeys, + request.pubkey_signatures, + request.only_mintable, + ) + .await?; - Ok(response) + Ok(responses.into_iter().map(Into::into).collect()) } async fn get_mint_quote_status( diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index 14f310aee..588596516 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -21,6 +21,7 @@ use bip39::Mnemonic; use cashu::amount::SplitTarget; use cashu::dhke::construct_proofs; use cashu::mint_url::MintUrl; +use cashu::nuts::nut00::KnownMethod; use cashu::nuts::nut10::Conditions; use cashu::nuts::SigFlag; use cashu::{ @@ -39,7 +40,10 @@ use cdk_common::payment::{ MintPayment, OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, }; use cdk_common::wallet::ProofInfo; -use cdk_common::{MeltQuoteCreateResponse, MeltQuoteRequest, MeltQuoteResponse}; +use cdk_common::{ + MeltQuoteCreateResponse, MeltQuoteRequest, MeltQuoteResponse, MintQuoteBolt11Request, + MintQuoteRequest, +}; use cdk_fake_wallet::create_fake_invoice; use cdk_integration_tests::init_pure_tests::*; use futures::Stream; @@ -3699,3 +3703,69 @@ async fn test_p2pk_signing_keys_mixed_locked_and_unlocked_proofs() { "Bob should receive exactly the send amount" ); } + +/// The wallet signs its own NUT-XX lookup challenge, the mint verifies it, and the wallet gets +/// back the quote it locked to its own key - then persists it with the signing key stamped, +/// since callers use this method to populate the wallet database, not just to report results. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_fetch_mint_quotes_by_pubkey_round_trip() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let secret_key = SecretKey::generate(); + mint.get_mint_quote(MintQuoteRequest::Bolt11(MintQuoteBolt11Request { + amount: Amount::from(100).into(), + unit: CurrencyUnit::Sat, + description: None, + pubkey: Some(secret_key.public_key()), + })) + .await + .expect("Failed to create locked quote"); + + let wallet = create_test_wallet_for_mint(mint) + .await + .expect("Failed to create test wallet"); + + let quotes = wallet + .fetch_mint_quotes_by_pubkey(std::slice::from_ref(&secret_key), false) + .await + .expect("lookup should succeed"); + + assert_eq!(quotes.len(), 1); + assert_eq!( + quotes[0].payment_method, + PaymentMethod::Known(KnownMethod::Bolt11) + ); + assert_eq!(quotes[0].secret_key, Some(secret_key)); + + // The lookup must have persisted the quote, not just returned it in memory. + let stored = wallet + .localstore + .get_mint_quote("es[0].id) + .await + .expect("localstore read") + .expect("quote should be stored locally after lookup"); + assert_eq!(stored, quotes[0]); +} + +/// A key with no locked quotes gets back an empty list, not an error - the mint's signature +/// check passes (the wallet signed correctly) and simply finds nothing for that key. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_fetch_mint_quotes_by_pubkey_empty_for_unused_key() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint) + .await + .expect("Failed to create test wallet"); + + let unrelated_key = SecretKey::generate(); + let quotes = wallet + .fetch_mint_quotes_by_pubkey(&[unrelated_key], false) + .await + .expect("lookup should succeed"); + + assert!(quotes.is_empty()); +} diff --git a/crates/cdk-sql-common/src/mint/quotes.rs b/crates/cdk-sql-common/src/mint/quotes.rs index 00b7fd62b..b3409dfb5 100644 --- a/crates/cdk-sql-common/src/mint/quotes.rs +++ b/crates/cdk-sql-common/src/mint/quotes.rs @@ -1299,17 +1299,26 @@ where async fn get_mint_quotes_by_pubkey( &self, pubkeys: &[PublicKey], + only_mintable: bool, ) -> Result, Self::Err> { if pubkeys.is_empty() { return Ok(vec![]); } + // Filtering here, rather than after loading, also skips the per-quote payments/issuance + // follow-up queries below for every excluded row. + let mintable_clause = if only_mintable { + "AND amount_paid > amount_issued" + } else { + "" + }; + let conn = self .pool .get() .await .map_err(|e| Error::Database(Box::new(e)))?; - let mut mint_quotes = query( + let mut mint_quotes = query(&format!( r#" SELECT id, @@ -1328,9 +1337,9 @@ where extra_json FROM mint_quote - WHERE pubkey IN (:pubkeys) + WHERE pubkey IN (:pubkeys) {mintable_clause} "#, - )? + ))? .bind_vec("pubkeys", pubkeys.iter().map(|pk| pk.to_hex()).collect())? .fetch_all(&*conn) .await? diff --git a/crates/cdk-sql-common/src/wallet/mod.rs b/crates/cdk-sql-common/src/wallet/mod.rs index 1b9c77c1e..9c669c342 100644 --- a/crates/cdk-sql-common/src/wallet/mod.rs +++ b/crates/cdk-sql-common/src/wallet/mod.rs @@ -1184,12 +1184,15 @@ where let expected_version = quote.version; let new_version = expected_version.wrapping_add(1); + // `created_time` is bound on insert only and deliberately absent from the conflict + // clause: the row keeps the time it was first stored, while every other field tracks + // the latest state. let rows_affected = query( r#" INSERT INTO mint_quote - (id, mint_url, amount, unit, request, state, expiry, secret_key, payment_method, amount_issued, amount_paid, updated_at, estimated_blocks, version, used_by_operation) + (id, mint_url, amount, unit, request, state, expiry, secret_key, payment_method, amount_issued, amount_paid, updated_at, estimated_blocks, version, used_by_operation, created_time) VALUES - (:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key, :payment_method, :amount_issued, :amount_paid, :updated_at, :estimated_blocks, :version, :used_by_operation) + (:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key, :payment_method, :amount_issued, :amount_paid, :updated_at, :estimated_blocks, :version, :used_by_operation, :created_time) ON CONFLICT(id) DO UPDATE SET mint_url = excluded.mint_url, amount = excluded.amount, @@ -1226,6 +1229,7 @@ where .bind("new_version", new_version as i64) .bind("expected_version", expected_version as i64) .bind("used_by_operation", quote.used_by_operation) + .bind("created_time", unix_time() as i64) .execute(&*conn).await?; if rows_affected == 0 { diff --git a/crates/cdk-sqlite/src/wallet/mod.rs b/crates/cdk-sqlite/src/wallet/mod.rs index 72da4a86a..a22c43222 100644 --- a/crates/cdk-sqlite/src/wallet/mod.rs +++ b/crates/cdk-sqlite/src/wallet/mod.rs @@ -194,6 +194,82 @@ mod tests { } } + /// `created_time` is stamped with the current time when a mint quote row is first stored, + /// and later stores of the same quote preserve it. The column is not part of the public + /// `MintQuote` struct, so it is read back with a raw connection (which is also why this + /// test is skipped under sqlcipher). + #[cfg(not(feature = "sqlcipher"))] + #[tokio::test] + async fn test_mint_quote_created_time_stamped_on_first_store() { + use cdk_common::mint_url::MintUrl; + use cdk_common::nuts::{CurrencyUnit, PaymentMethod}; + use cdk_common::util::unix_time; + use cdk_common::wallet::MintQuote; + use cdk_common::Amount; + + let path = std::env::temp_dir().to_path_buf().join(format!( + "cdk-test-created-time-{}.sqlite", + uuid::Uuid::new_v4() + )); + + let db = WalletSqliteDatabase::new(path.clone()).await.unwrap(); + + let quote = MintQuote::new( + "created-time-quote".to_string(), + MintUrl::from_str("https://example.com").unwrap(), + PaymentMethod::Known(KnownMethod::Bolt11), + Some(Amount::from(100)), + CurrencyUnit::Sat, + "test_request".to_string(), + 1000000000, + None, + ); + + let before = unix_time(); + db.add_mint_quote(quote).await.unwrap(); + + let conn = rusqlite::Connection::open(&path).unwrap(); + let read_created_time = |conn: &rusqlite::Connection| -> u64 { + conn.query_row( + "SELECT created_time FROM mint_quote WHERE id = ?1", + ["created-time-quote"], + |row| row.get(0), + ) + .unwrap() + }; + + let created_time = read_created_time(&conn); + assert!( + created_time >= before, + "first store must stamp created_time with the current time, got {created_time}" + ); + + // Plant a sentinel before re-storing, so an (incorrect) restamp with the current time + // cannot be mistaken for preservation. + conn.execute( + "UPDATE mint_quote SET created_time = 1 WHERE id = ?1", + ["created-time-quote"], + ) + .unwrap(); + + let mut updated = db + .get_mint_quote("created-time-quote") + .await + .unwrap() + .unwrap(); + updated.amount_paid = Amount::from(100); + db.add_mint_quote(updated).await.unwrap(); + + assert_eq!( + read_created_time(&conn), + 1, + "storing an existing quote must not restamp created_time" + ); + + drop(conn); + let _ = std::fs::remove_file(&path); + } + #[tokio::test] async fn test_get_proofs_by_ys_empty_errors() { use cdk_common::database::Error; diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index c0b9e4813..4cdbc52a5 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -422,6 +422,11 @@ impl Mint { /// 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. /// + /// `only_mintable` bounds the response to quotes that are still mintable + /// (`amount_issued < amount_paid`) when `true`. It is a response-side convenience only: it + /// is not part of the signed message, carries no security weight, and does not affect which + /// signatures are required or how they're checked. + /// /// # Returns /// * `Vec>` - quotes locked to the requested pubkeys /// * `Error` if any signature is missing or invalid, or database access fails @@ -430,6 +435,7 @@ impl Mint { &self, pubkeys: Vec, signatures: Vec, + only_mintable: bool, ) -> Result>, Error> { #[cfg(feature = "prometheus")] let metrics = super::MintMetricGuard::new("mint_quotes_by_pubkeys"); @@ -464,7 +470,17 @@ impl Mint { } let result: Result>, Error> = async { - let quotes = self.localstore.get_mint_quotes_by_pubkey(&pubkeys).await?; + let quotes = self + .localstore + .get_mint_quotes_by_pubkey(&pubkeys, only_mintable) + .await?; + + tracing::debug!( + "Pubkey quote lookup for {} pubkey(s) returned {} quote(s) (only_mintable: {})", + pubkeys.len(), + quotes.len(), + only_mintable + ); // `TryFrom` is the shared conversion every other quote path uses; hand-rolling it // here would drift the moment a response field is added. diff --git a/crates/cdk/src/wallet/issue/mod.rs b/crates/cdk/src/wallet/issue/mod.rs index b876634ab..47b1189b7 100644 --- a/crates/cdk/src/wallet/issue/mod.rs +++ b/crates/cdk/src/wallet/issue/mod.rs @@ -4,18 +4,23 @@ pub(crate) mod saga; +use std::collections::HashMap; + use cdk_common::nut00::KnownMethod; use cdk_common::nut04::MintMethodOptions; +use cdk_common::nutxx::{ + mint_quote_lookup_msg_to_sign, MintQuoteByPubkeyRequest, MAX_LOOKUP_PUBKEYS, +}; use cdk_common::{MintQuoteRequest, MintQuoteResponse, PaymentMethod}; pub(crate) use saga::MintSaga; use tracing::instrument; use crate::amount::SplitTarget; -use crate::nuts::{BatchCheckMintQuoteRequest, Proofs, SecretKey, SpendingConditions}; +use crate::nuts::{BatchCheckMintQuoteRequest, Proofs, PublicKey, SecretKey, SpendingConditions}; use crate::util::unix_time; use crate::wallet::recovery::RecoveryAction; use crate::wallet::{MintQuote, MintQuoteState}; -use crate::{Amount, Error, Wallet}; +use crate::{ensure_cdk, Amount, Error, Wallet}; pub(crate) fn apply_mint_quote_response( quote: &mut MintQuote, @@ -148,6 +153,55 @@ fn mint_quote_response_amount(response: &MintQuoteResponse) -> Option) -> Option { + match response { + MintQuoteResponse::Bolt11(r) => r.pubkey, + MintQuoteResponse::Bolt12(r) => Some(r.pubkey), + MintQuoteResponse::Onchain(r) => Some(r.pubkey), + MintQuoteResponse::Custom { response: r, .. } => r.pubkey, + } +} + +/// Build the signed [NUT-XX] request that proves control of `secret_keys` to `mint_pubkey`. +/// +/// One signature is produced per key, over `mint_quote_lookup_msg_to_sign(mint_pubkey, pubkey)`. +/// `only_mintable` rides along unsigned - it bounds the response, not the proof of ownership - +/// and is set verbatim on the returned request. +fn build_mint_quote_by_pubkey_request( + mint_pubkey: &PublicKey, + secret_keys: &[SecretKey], + only_mintable: bool, +) -> Result { + ensure_cdk!( + secret_keys.len() <= MAX_LOOKUP_PUBKEYS, + Error::BatchSizeExceeded { + actual: secret_keys.len(), + max: MAX_LOOKUP_PUBKEYS, + } + ); + + let mut pubkeys = Vec::with_capacity(secret_keys.len()); + let mut pubkey_signatures = Vec::with_capacity(secret_keys.len()); + + for secret_key in secret_keys { + let pubkey = secret_key.public_key(); + let msg = mint_quote_lookup_msg_to_sign(mint_pubkey, &pubkey); + let signature = secret_key.sign(&msg)?; + + pubkeys.push(pubkey); + pubkey_signatures.push(signature); + } + + Ok(MintQuoteByPubkeyRequest { + pubkeys, + pubkey_signatures, + only_mintable, + }) +} + impl Wallet { /// Resolve the NUT-20 signing key for a mint quote /// @@ -652,6 +706,158 @@ impl Wallet { Ok(quotes) } + /// Look up this wallet's mint quotes locked to the given NUT-20 keys ([NUT-XX]), storing + /// the results in the local database. + /// + /// Fetches the mint's NUT-06 pubkey, signs the per-key lookup challenge for each of + /// `secret_keys` (deduplicated by public key first, so repeated keys don't burn slots of + /// the `MAX_LOOKUP_PUBKEYS` request budget), and queries the mint. The mint answers with + /// every quote it holds for any of the keys regardless of payment method, so the result + /// may mix Bolt11/Bolt12/Onchain/Custom quotes. + /// + /// Each accepted quote is reconciled into local storage with the same merge/accounting + /// logic as [`Wallet::fetch_mint_quote`]: an existing record is updated in place, an + /// unseen quote is inserted fresh, and the signing key is stamped on the stored record. + /// Unlike `fetch_mint_quote`, which writes back unconditionally, writes here are skipped + /// when nothing changed, so a caller polling on an interval does not rewrite + /// already-current quotes on every pass. + /// + /// Every returned quote is validated against the keys that were actually requested: a + /// quote whose `pubkey` is missing or was not asked for is logged and dropped rather than + /// stored, since this method writes mint responses to the wallet's database and cannot + /// take the mint's word for which pubkeys they belong to. + /// + /// `only_mintable` is an opt-in, response-bounding filter: when `true`, the mint is asked + /// to return only quotes that are still mintable (`amount_issued < amount_paid`), keeping + /// the response small for a caller that only cares about quotes it can act on right now. + /// It plays no part in proving ownership of `secret_keys`; a mint that predates the + /// filter ignores it and returns everything, same as passing `false`. + /// + /// # Errors + /// Returns `Ok(vec![])` without contacting the mint if `secret_keys` is empty. Returns + /// `Error::BatchSizeExceeded` if the deduplicated key set is longer than + /// `MAX_LOOKUP_PUBKEYS`; splitting a larger set into chunks and issuing one call per + /// chunk is the caller's responsibility. Returns `Error::MissingPubkey` if the mint does + /// not advertise a NUT-06 pubkey. + #[instrument(skip(self, secret_keys))] + pub async fn fetch_mint_quotes_by_pubkey( + &self, + secret_keys: &[SecretKey], + only_mintable: bool, + ) -> Result, Error> { + if secret_keys.is_empty() { + return Ok(Vec::new()); + } + + // Dedupe by pubkey before building the request: duplicate keys would otherwise burn + // slots of the `MAX_LOOKUP_PUBKEYS` request budget for no benefit. + let mut requested: HashMap = + HashMap::with_capacity(secret_keys.len()); + for secret_key in secret_keys { + requested + .entry(secret_key.public_key()) + .or_insert_with(|| secret_key.clone()); + } + let deduped_keys: Vec = requested.values().cloned().collect(); + + let mint_pubkey = match self.load_mint_info().await?.pubkey { + Some(pubkey) => pubkey, + None => { + // A cached mint record can lack a pubkey while still counting as fresh: + // it may predate the mint advertising one, or hold it in a legacy format + // the row parser silently drops. Refresh from the mint once before + // concluding the lookup is unsupported. + self.fetch_mint_info() + .await? + .and_then(|info| info.pubkey) + .ok_or(Error::MissingPubkey)? + } + }; + + let request = + build_mint_quote_by_pubkey_request(&mint_pubkey, &deduped_keys, only_mintable)?; + let responses = self.client.post_mint_quote_by_pubkey(request).await?; + + let mut quotes = Vec::with_capacity(responses.len()); + for response in responses { + let quote_id = response.quote().to_string(); + + let matching_key = match mint_quote_response_pubkey(&response) + .and_then(|pubkey| requested.get(&pubkey)) + { + Some(key) => key.clone(), + None => { + tracing::warn!( + "Dropping mint quote {quote_id} returned for a pubkey that was not requested" + ); + continue; + } + }; + + let existing_quote = self.localstore.get_mint_quote("e_id).await?; + + let (mut quote, mut changed) = match existing_quote { + Some(mut existing) => { + // `apply_mint_quote_response`'s bool return means "not stale", not + // "changed": it also reports true for a response that repeats exactly + // what is already stored. Compare the fields it can touch before and + // after instead, so an identical repeat - the steady-state case for a + // poller - skips the write. + let before = ( + existing.state, + existing.amount_paid, + existing.amount_issued, + existing.updated_at, + ); + apply_mint_quote_response(&mut existing, &response); + let changed = before + != ( + existing.state, + existing.amount_paid, + existing.amount_issued, + existing.updated_at, + ); + (existing, changed) + } + None => { + let amount = mint_quote_response_amount(&response); + let unit = match &response { + MintQuoteResponse::Bolt11(r) => r.unit.clone(), + MintQuoteResponse::Bolt12(r) => Some(r.unit.clone()), + MintQuoteResponse::Custom { response: r, .. } => r.unit.clone(), + MintQuoteResponse::Onchain(r) => Some(r.unit.clone()), + }; + let mut quote = MintQuote::new( + quote_id, + self.mint_url.clone(), + response.method(), + amount, + unit.unwrap_or(self.unit.clone()), + response.request().to_string(), + response.expiry().unwrap_or(0), + None, + ); + apply_mint_quote_response(&mut quote, &response); + // A freshly constructed record is always new to the store. + (quote, true) + } + }; + + if quote.secret_key.as_ref() != Some(&matching_key) { + quote.secret_key = Some(matching_key); + changed = true; + } + + if changed { + self.localstore.add_mint_quote(quote.clone()).await?; + } + + quotes.push(quote); + } + + Ok(quotes) + } + /// Mint tokens for multiple quotes in a single batch operation. /// /// Calls `POST /v1/mint/{method}/batch` per NUT-29. @@ -693,11 +899,458 @@ impl Wallet { #[cfg(test)] mod tests { use std::str::FromStr; + use std::sync::Arc; use cdk_common::mint_url::MintUrl; use cdk_common::nuts::CurrencyUnit; use super::*; + use crate::wallet::test_utils::{ + create_test_db, create_test_wallet_with_mock, test_keyset, test_mint_info, test_mint_url, + MockMintConnector, + }; + + #[test] + fn mint_quote_by_pubkey_request_signs_and_verifies() { + let mint_pubkey = SecretKey::generate().public_key(); + let secret_keys = vec![SecretKey::generate(), SecretKey::generate()]; + + let request = build_mint_quote_by_pubkey_request(&mint_pubkey, &secret_keys, false) + .expect("request should build for a small key set"); + + assert_eq!(request.pubkeys.len(), secret_keys.len()); + assert_eq!(request.pubkey_signatures.len(), secret_keys.len()); + assert!(!request.only_mintable); + + let filtered_request = build_mint_quote_by_pubkey_request(&mint_pubkey, &secret_keys, true) + .expect("request should build with the filter set too"); + assert!(filtered_request.only_mintable); + + for ((secret_key, pubkey), signature) in secret_keys + .iter() + .zip(request.pubkeys.iter()) + .zip(request.pubkey_signatures.iter()) + { + assert_eq!(*pubkey, secret_key.public_key()); + + // Round-trip: the signature must verify against the same preimage the mint checks. + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, pubkey); + assert!(pubkey.verify(&msg, signature).is_ok()); + + // And it must not verify against a different mint's preimage (mint-bound). + let other_mint_pubkey = SecretKey::generate().public_key(); + let other_msg = mint_quote_lookup_msg_to_sign(&other_mint_pubkey, pubkey); + assert!(pubkey.verify(&other_msg, signature).is_err()); + } + } + + #[test] + fn mint_quote_by_pubkey_request_rejects_oversized_batch() { + let mint_pubkey = SecretKey::generate().public_key(); + let secret_keys: Vec = (0..=MAX_LOOKUP_PUBKEYS) + .map(|_| SecretKey::generate()) + .collect(); + + let result = build_mint_quote_by_pubkey_request(&mint_pubkey, &secret_keys, false); + assert!(matches!( + result, + Err(Error::BatchSizeExceeded { actual, max }) + if actual == secret_keys.len() && max == MAX_LOOKUP_PUBKEYS + )); + } + + /// `Wallet::fetch_mint_quotes_by_pubkey` against a mock connector: the mint pubkey comes + /// from mint info, the request the connector receives carries a valid mint-bound signature + /// over the wallet's own pubkey, and the mocked response is stored and returned as a + /// `MintQuote` record with the signing key stamped. + #[tokio::test] + async fn fetch_mint_quotes_by_pubkey_signs_stores_and_returns_mock_response() { + let db = create_test_db().await; + let mock = Arc::new(MockMintConnector::new()); + let wallet = create_test_wallet_with_mock(db, mock.clone()).await; + + let mint_pubkey = wallet + .load_mint_info() + .await + .expect("mock mint info") + .pubkey + .expect("mock mint info has a pubkey"); + + let secret_key = SecretKey::generate(); + let canned_response = vec![MintQuoteResponse::Bolt11( + cdk_common::nut23::MintQuoteBolt11Response { + quote: "quote-id".to_string(), + request: "lnbc1...".to_string(), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::Known(KnownMethod::Bolt11), + amount_paid: Amount::ZERO, + amount_issued: Amount::ZERO, + updated_at: 0, + state: MintQuoteState::Unpaid, + expiry: None, + pubkey: Some(secret_key.public_key()), + }, + )]; + mock.set_mint_quote_by_pubkey_response(Ok(canned_response)); + + let quotes = wallet + .fetch_mint_quotes_by_pubkey(std::slice::from_ref(&secret_key), false) + .await + .expect("lookup should succeed"); + + assert_eq!(quotes.len(), 1); + assert_eq!(quotes[0].id, "quote-id"); + assert_eq!(quotes[0].secret_key, Some(secret_key.clone())); + + let stored = wallet + .localstore + .get_mint_quote("quote-id") + .await + .expect("localstore read") + .expect("quote should be stored locally after lookup"); + assert_eq!(stored, quotes[0]); + + let captured = mock.captured_mint_quote_by_pubkey_requests.lock().unwrap(); + assert_eq!(captured.len(), 1); + let sent = &captured[0]; + assert_eq!(sent.pubkeys, vec![secret_key.public_key()]); + assert_eq!(sent.pubkey_signatures.len(), 1); + + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &secret_key.public_key()); + assert!(secret_key + .public_key() + .verify(&msg, &sent.pubkey_signatures[0]) + .is_ok()); + } + + /// The `only_mintable` argument must land on the wire request unchanged - this is the + /// wallet-side half of the filter; `crates/cdk/tests/nutxx_mint_quote_lookup.rs` covers the + /// mint actually honoring it end to end. + #[tokio::test] + async fn fetch_mint_quotes_by_pubkey_sets_only_mintable_flag_on_request() { + let db = create_test_db().await; + let mock = Arc::new(MockMintConnector::new()); + let wallet = create_test_wallet_with_mock(db, mock.clone()).await; + + let secret_key = SecretKey::generate(); + + mock.set_mint_quote_by_pubkey_response(Ok(Vec::new())); + wallet + .fetch_mint_quotes_by_pubkey(std::slice::from_ref(&secret_key), false) + .await + .expect("lookup should succeed"); + + mock.set_mint_quote_by_pubkey_response(Ok(Vec::new())); + wallet + .fetch_mint_quotes_by_pubkey(std::slice::from_ref(&secret_key), true) + .await + .expect("lookup should succeed"); + + let captured = mock.captured_mint_quote_by_pubkey_requests.lock().unwrap(); + assert_eq!(captured.len(), 2); + assert!( + !captured[0].only_mintable, + "only_mintable: false must be sent as false" + ); + assert!( + captured[1].only_mintable, + "only_mintable: true must reach the request" + ); + } + + /// Seed the localstore like a wallet whose cached mint record predates the mint's + /// pubkey (or holds it in a format the row parser drops): info without a pubkey plus + /// a keyset, which is what marks the database cache populated. + async fn seed_stale_mint_record( + db: &std::sync::Arc< + dyn cdk_common::database::WalletDatabase + Send + Sync, + >, + ) { + let mut stale_info = test_mint_info(); + stale_info.pubkey = None; + db.add_mint(test_mint_url(), Some(stale_info)) + .await + .expect("seed mint info"); + let ks = test_keyset(); + db.add_mint_keysets( + test_mint_url(), + vec![crate::nuts::KeySetInfo { + id: ks.id, + unit: ks.unit.clone(), + active: true, + input_fee_ppk: 0, + final_expiry: None, + }], + ) + .await + .expect("seed keysets"); + } + + /// A stored mint record without a pubkey must trigger one forced refresh from the + /// mint instead of failing the lookup outright: such a record otherwise satisfies + /// the metadata cache, so without the refresh the lookup would starve forever even + /// though the live mint advertises a pubkey. + #[tokio::test] + async fn fetch_mint_quotes_by_pubkey_refreshes_cached_mint_info_without_pubkey() { + let db = create_test_db().await; + seed_stale_mint_record(&db).await; + + let mock = Arc::new(MockMintConnector::new()); + // The refresh goes through `fetch_mint_info`, which enforces mint-clock tolerance; + // clear the canned info's fixed `time` so the mock isn't rejected as skewed. + mock.mint_info.lock().unwrap().time = None; + let wallet = create_test_wallet_with_mock(db, mock.clone()).await; + + let secret_key = SecretKey::generate(); + mock.set_mint_quote_by_pubkey_response(Ok(Vec::new())); + wallet + .fetch_mint_quotes_by_pubkey(std::slice::from_ref(&secret_key), false) + .await + .expect("a stale cached mint record must not starve the lookup"); + + assert!( + *mock.get_mint_info_calls.lock().unwrap() >= 1, + "the wallet must refresh mint info from the mint when the cached pubkey is absent" + ); + } + + /// The refresh is a single attempt, not a mask: when the mint genuinely advertises + /// no pubkey, the lookup still fails with `MissingPubkey` after refreshing. + #[tokio::test] + async fn fetch_mint_quotes_by_pubkey_still_fails_when_mint_has_no_pubkey() { + let db = create_test_db().await; + seed_stale_mint_record(&db).await; + + let mock = Arc::new(MockMintConnector::new()); + let mut no_pubkey_info = test_mint_info(); + no_pubkey_info.pubkey = None; + no_pubkey_info.time = None; + *mock.mint_info.lock().unwrap() = no_pubkey_info; + let wallet = create_test_wallet_with_mock(db, mock.clone()).await; + + let secret_key = SecretKey::generate(); + let err = wallet + .fetch_mint_quotes_by_pubkey(std::slice::from_ref(&secret_key), false) + .await + .expect_err("a mint without a pubkey cannot serve the lookup"); + + assert!(matches!(err, Error::MissingPubkey)); + assert!( + *mock.get_mint_info_calls.lock().unwrap() >= 1, + "the refresh must have been attempted before giving up" + ); + } + + /// A quote returned for a pubkey the wallet did not request must be dropped, not + /// stored: the response pubkey selects which local secret key gets stamped onto the + /// stored quote (and later signs the NUT-20 mint request), so an entry matching no + /// requested key has no key to bind and would persist as an unmintable row. The mint + /// stays authoritative for quote state - this check only correlates entries back to + /// the request. + #[tokio::test] + async fn fetch_mint_quotes_by_pubkey_drops_quote_for_unrequested_pubkey() { + let db = create_test_db().await; + let mock = Arc::new(MockMintConnector::new()); + let wallet = create_test_wallet_with_mock(db, mock.clone()).await; + + let requested_key = SecretKey::generate(); + let unrequested_pubkey = SecretKey::generate().public_key(); + + let canned_response = vec![MintQuoteResponse::Bolt11( + cdk_common::nut23::MintQuoteBolt11Response { + quote: "unrequested-quote-id".to_string(), + request: "lnbc1...".to_string(), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::Known(KnownMethod::Bolt11), + amount_paid: Amount::from(100), + amount_issued: Amount::ZERO, + updated_at: 0, + state: MintQuoteState::Paid, + expiry: None, + pubkey: Some(unrequested_pubkey), + }, + )]; + mock.set_mint_quote_by_pubkey_response(Ok(canned_response)); + + let quotes = wallet + .fetch_mint_quotes_by_pubkey(std::slice::from_ref(&requested_key), false) + .await + .expect("lookup should succeed even though the returned quote is dropped"); + + assert!( + quotes.is_empty(), + "a quote for an unrequested pubkey must be dropped" + ); + assert!( + wallet + .localstore + .get_mint_quote("unrequested-quote-id") + .await + .expect("localstore read") + .is_none(), + "dropped quote must not be written to the local store" + ); + } + + /// A mint with no NUT-06 pubkey cannot be asked to prove quote ownership against, so the + /// lookup must fail fast with `Error::MissingPubkey` rather than send a request. + #[tokio::test] + async fn fetch_mint_quotes_by_pubkey_errors_without_mint_pubkey() { + let db = create_test_db().await; + let mock = Arc::new(MockMintConnector::new()); + mock.set_mint_info_response(Ok(cdk_common::nuts::MintInfo::new())); + let wallet = create_test_wallet_with_mock(db, mock.clone()).await; + + let secret_key = SecretKey::generate(); + let result = wallet + .fetch_mint_quotes_by_pubkey(std::slice::from_ref(&secret_key), false) + .await; + + assert!(matches!(result, Err(Error::MissingPubkey))); + assert!( + mock.captured_mint_quote_by_pubkey_requests + .lock() + .unwrap() + .is_empty(), + "no lookup request should be sent when the mint has no pubkey" + ); + } + + /// Empty `secret_keys` must short-circuit locally before any network call: no mint-info + /// fetch, no lookup request. Both mock responses are left unconfigured on purpose, so + /// reaching either would panic rather than silently pass. + #[tokio::test] + async fn fetch_mint_quotes_by_pubkey_empty_keys_makes_no_connector_calls() { + let db = create_test_db().await; + let mock = Arc::new(MockMintConnector::new()); + let wallet = create_test_wallet_with_mock(db, mock.clone()).await; + + let quotes = wallet + .fetch_mint_quotes_by_pubkey(&[], false) + .await + .expect("an empty lookup should succeed without contacting the mint"); + + assert!(quotes.is_empty()); + assert_eq!( + *mock.get_mint_info_calls.lock().unwrap(), + 0, + "empty secret_keys must not fetch mint info" + ); + assert!( + mock.captured_mint_quote_by_pubkey_requests + .lock() + .unwrap() + .is_empty(), + "empty secret_keys must not reach the connector" + ); + } + + /// Calling the lookup twice with an identical mint response must not rewrite the stored + /// record: the accounting fields are unchanged and the secret key is already stamped, so + /// the guard that gates `add_mint_quote` stays false. A caller polling on an interval + /// must not rewrite unchanged history to disk on every pass. + /// + /// The witness is the quote row's `version`: `add_mint_quote` is an + /// optimistic-concurrency write that bumps it on every store, so a skipped write shows up + /// as an unchanged version on a fresh read. A positive control - a changed response + /// between calls - must still produce a second write, proving the guard is conditional + /// rather than a latch. + #[tokio::test] + async fn fetch_mint_quotes_by_pubkey_is_idempotent_for_an_unchanged_response() { + let db = create_test_db().await; + let mock = Arc::new(MockMintConnector::new()); + let wallet = create_test_wallet_with_mock(db, mock.clone()).await; + + let secret_key = SecretKey::generate(); + let canned_response = |amount_paid: u64, updated_at: u64| { + vec![MintQuoteResponse::Bolt11( + cdk_common::nut23::MintQuoteBolt11Response { + quote: "repeat-quote-id".to_string(), + request: "lnbc1...".to_string(), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::Known(KnownMethod::Bolt11), + amount_paid: Amount::from(amount_paid), + amount_issued: Amount::ZERO, + updated_at, + state: MintQuoteState::Paid, + expiry: None, + pubkey: Some(secret_key.public_key()), + }, + )] + }; + + async fn stored_version(wallet: &Wallet) -> u32 { + wallet + .localstore + .get_mint_quote("repeat-quote-id") + .await + .expect("localstore read") + .expect("quote should be stored") + .version + } + + mock.set_mint_quote_by_pubkey_response(Ok(canned_response(100, 10))); + let first = wallet + .fetch_mint_quotes_by_pubkey(std::slice::from_ref(&secret_key), false) + .await + .expect("first lookup should succeed"); + let version_after_first = stored_version(&wallet).await; + + mock.set_mint_quote_by_pubkey_response(Ok(canned_response(100, 10))); + let second = wallet + .fetch_mint_quotes_by_pubkey(std::slice::from_ref(&secret_key), false) + .await + .expect("second lookup should succeed"); + + assert_eq!( + first, second, + "an unchanged mint response must not perturb the stored record" + ); + assert_eq!( + stored_version(&wallet).await, + version_after_first, + "an unchanged response must not trigger a second write" + ); + assert_eq!( + mock.captured_mint_quote_by_pubkey_requests + .lock() + .unwrap() + .len(), + 2, + "the connector should still be called on every poll; only the write is guarded" + ); + + // Positive control: a genuinely changed response must still write through. + mock.set_mint_quote_by_pubkey_response(Ok(canned_response(150, 11))); + let third = wallet + .fetch_mint_quotes_by_pubkey(std::slice::from_ref(&secret_key), false) + .await + .expect("third lookup should succeed"); + assert_eq!(third[0].amount_paid, Amount::from(150)); + assert_eq!( + stored_version(&wallet).await, + version_after_first.wrapping_add(1), + "a genuinely changed response must trigger exactly one more write" + ); + + // `add_mint_quote` bumps the stored `version` server-side without reflecting it back + // into the caller's struct, so compare the fields the response can touch rather than + // the full struct. + let stored = wallet + .localstore + .get_mint_quote("repeat-quote-id") + .await + .expect("localstore read") + .expect("quote should be stored"); + assert_eq!(stored.amount_paid, third[0].amount_paid); + assert_eq!(stored.amount_issued, third[0].amount_issued); + assert_eq!(stored.updated_at, third[0].updated_at); + assert_eq!(stored.state, third[0].state); + assert_eq!(stored.secret_key, third[0].secret_key); + } #[test] fn local_onchain_mint_quote_amount_is_not_stored() { diff --git a/crates/cdk/src/wallet/mint_connector/http_client.rs b/crates/cdk/src/wallet/mint_connector/http_client.rs index fa7f80a8c..122e8cfef 100644 --- a/crates/cdk/src/wallet/mint_connector/http_client.rs +++ b/crates/cdk/src/wallet/mint_connector/http_client.rs @@ -3,11 +3,8 @@ use std::collections::HashSet; use std::sync::{Arc, RwLock as StdRwLock}; use async_trait::async_trait; -<<<<<<< HEAD use cdk_common::auth::oidc::{OidcHttpResponse, OidcHttpTransport}; -======= -use cdk_common::nutxx::MintQuoteByPubkeyRequest; ->>>>>>> 7eb36e35 (feat(wallet): create mint_connector http request for mint quote lookup by public key) +use cdk_common::nutxx::{MintQuoteByPubkeyRequest, MintQuoteByPubkeyResponse}; use cdk_common::{ nut19, MeltQuoteCreateResponse, MeltQuoteRequest, MeltQuoteResponse, Method, MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteCustomResponse, @@ -84,6 +81,54 @@ where serde_json::from_value(value).map_err(|e| Error::Custom(e.to_string())) } +fn deserialize_quote_value(value: serde_json::Value) -> Result { + Ok(serde_json::from_value(value)?) +} + +/// Reconstruct a [`MintQuoteResponse`] from one element of a +/// [`MintQuoteByPubkeyResponse`] `quotes` array. +/// +/// The mint flattens each quote to its bare NUT-04 response object (see +/// `mint_quote_response_to_value` in `cdk-axum`) rather than `MintQuoteResponse`'s externally +/// tagged form, so the enum's derived `Deserialize` cannot parse it. Every concrete response +/// type carries its own `method` field, so peeking at it here is enough to pick the variant to +/// deserialize into. +/// +/// A missing or non-string `method` is rejected rather than guessed at: the concrete types' +/// own serde defaults for `method` only apply once a variant has been chosen, and defaulting +/// to Bolt11 here would reinterpret a paid bolt12/onchain/custom quote's accounting fields as +/// an unpaid Bolt11 response. +fn mint_quote_value_to_response( + value: serde_json::Value, +) -> Result, Error> { + let method_value = value.get("method").cloned().ok_or_else(|| { + Error::Custom(format!( + "mint quote {} response is missing a \"method\" field", + value + .get("quote") + .and_then(|q| q.as_str()) + .unwrap_or("") + )) + })?; + let method: PaymentMethod = serde_json::from_value(method_value)?; + + match method { + PaymentMethod::Known(KnownMethod::Bolt11) => { + Ok(MintQuoteResponse::Bolt11(deserialize_quote_value(value)?)) + } + PaymentMethod::Known(KnownMethod::Bolt12) => { + Ok(MintQuoteResponse::Bolt12(deserialize_quote_value(value)?)) + } + PaymentMethod::Known(KnownMethod::Onchain) => { + Ok(MintQuoteResponse::Onchain(deserialize_quote_value(value)?)) + } + PaymentMethod::Custom(name) => Ok(MintQuoteResponse::Custom { + method: PaymentMethod::Custom(name), + response: deserialize_quote_value(value)?, + }), + } +} + /// Http Client #[derive(Debug, Clone)] pub struct HttpClient @@ -599,97 +644,66 @@ where } } - /// NUT-XX: Mint Quote Lookup by Public Key - #[instrument(skip(self), fields(mint_url = %self.mint_url))] + /// Look up mint quotes locked to a set of NUT-20 public keys [NUT-XX] + /// + /// A malformed entry in the response - currently, a quote object missing its `method` + /// field - is skipped rather than failing the whole lookup: this call can answer for many + /// pubkeys at once, and one bad entry should not hide every other, perfectly valid quote + /// from the caller. Each skipped entry is logged via `tracing::warn!` with its quote id, + /// when extractable, and the parse error, plus one summary `tracing::warn!` with the + /// skipped/total counts for the call. + /// + /// As a consequence, a response whose entries are all malformed yields `Ok(vec![])`, + /// indistinguishable from a pubkey that genuinely has no quotes except by those warnings. + #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] async fn post_mint_quote_by_pubkey( &self, - method: PaymentMethod, request: MintQuoteByPubkeyRequest, ) -> Result>, Error> { - match &method { - PaymentMethod::Known(KnownMethod::Bolt11) => { - let url = self - .mint_url - .join_paths(&["v1", "mint", "quote", "bolt11", "pubkey"])?; - - let auth_token = self - .get_auth_token( - Method::Post, - RoutePath::MintQuote(PaymentMethod::Known(KnownMethod::Bolt11).to_string()), - ) - .await?; - - let response: Vec> = - self.transport.http_post(url, auth_token, &request).await?; - - Ok(response - .iter() - .map(|r| MintQuoteResponse::Bolt11(r.clone())) - .collect()) - } - PaymentMethod::Known(KnownMethod::Bolt12) => { - let url = self - .mint_url - .join_paths(&["v1", "mint", "quote", "bolt12", "pubkey"])?; - - let auth_token = self - .get_auth_token( - Method::Post, - RoutePath::MintQuote(PaymentMethod::Known(KnownMethod::Bolt12).to_string()), - ) - .await?; - - let response: Vec> = - self.transport.http_post(url, auth_token, &request).await?; - - Ok(response - .iter() - .map(|r| MintQuoteResponse::Bolt12(r.clone())) - .collect()) - } - PaymentMethod::Known(KnownMethod::Onchain) => { - let url = self - .mint_url - .join_paths(&["v1", "mint", "quote", "onchain", "pubkey"])?; - - let auth_token = self - .get_auth_token( - Method::Post, - RoutePath::MintQuote( - PaymentMethod::Known(KnownMethod::Onchain).to_string(), - ), - ) - .await?; - - let response: Vec> = - self.transport.http_post(url, auth_token, &request).await?; + let url = self + .mint_url + .join_paths(&["v1", "mint", "quote", "pubkey"])?; + let auth_token = self + .get_auth_token(Method::Post, RoutePath::MintQuoteByPubkey) + .await?; - Ok(response - .iter() - .map(|r| MintQuoteResponse::Onchain(r.clone())) - .collect()) + let response: MintQuoteByPubkeyResponse = + self.transport_http_post(url, auth_token, &request).await?; + + let total = response.quotes.len(); + let mut quotes = Vec::with_capacity(total); + for value in response.quotes { + // Peek the id before `value` is consumed below, so a parse failure can still be + // attributed to a specific quote in the warning. + let quote_id = value + .get("quote") + .and_then(|q| q.as_str()) + .map(str::to_string); + + match mint_quote_value_to_response(value) { + Ok(response) => quotes.push(response), + Err(e) => { + tracing::warn!( + "Skipping malformed mint quote {} in pubkey lookup response: {}", + quote_id.as_deref().unwrap_or(""), + e + ); + } } - PaymentMethod::Custom(method_name) => { - let url = - self.mint_url - .join_paths(&["v1", "mint", "quote", method_name, "pubkey"])?; - - let auth_token = self - .get_auth_token(Method::Post, RoutePath::MintQuote(method_name.clone())) - .await?; - - let response: Vec> = - self.transport.http_post(url, auth_token, &request).await?; + } - Ok(response - .iter() - .map(|r| MintQuoteResponse::Custom { - method: method.clone(), - response: r.clone(), - }) - .collect()) - } + let skipped = total - quotes.len(); + if skipped > 0 { + // One summary line a caller can alert on; without it, an all-malformed response + // degrades to an Ok(empty) that reads exactly like "no quotes outstanding". + tracing::warn!( + "Pubkey quote lookup skipped {} of {} response entries as malformed", + skipped, + total + ); } + + Ok(quotes) } /// Mint Tokens [NUT-04] @@ -1587,6 +1601,334 @@ mod tests { } } + /// The mint answers `/v1/mint/quote/pubkey` with quotes flattened to their bare NUT-04 + /// object (see `mint_quote_response_to_value` in `cdk-axum`), not wrapped in + /// `MintQuoteResponse`'s own externally tagged envelope. A response mixing a known method + /// (bolt11) and a custom method (paypal) must still reconstruct both correctly, and the + /// request must go to the method-agnostic path with no `{method}` segment in it. + #[tokio::test] + async fn test_post_mint_quote_by_pubkey_reconstructs_mixed_methods() { + let canned_json = serde_json::json!({ + "quotes": [ + { + "quote": "bolt11-quote-id", + "request": "lnbc1...", + "amount": 1000, + "unit": "sat", + "method": "bolt11", + "amount_paid": 1000, + "amount_issued": 0, + "updated_at": 42, + "state": "PAID", + "expiry": 9999999999_u64 + }, + { + "quote": "custom-quote-id", + "request": "paypal://pay?id=123", + "method": "paypal", + "amount": 500, + "amount_paid": 0, + "amount_issued": 0, + "updated_at": 7, + "unit": "sat", + "expiry": 9999999999_u64 + } + ] + }) + .to_string(); + + let transport = MockTransport { + post_response: Arc::new(Mutex::new(Some(canned_json))), + ..Default::default() + }; + let post_urls = transport.post_urls.clone(); + let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url"); + let client = HttpClient::with_transport(mint_url, transport, None); + + let secret_key = crate::nuts::SecretKey::generate(); + let request = MintQuoteByPubkeyRequest { + pubkeys: vec![secret_key.public_key()], + pubkey_signatures: vec![secret_key.sign(b"test-message").expect("sign")], + only_mintable: false, + }; + + let responses = client + .post_mint_quote_by_pubkey(request) + .await + .expect("post_mint_quote_by_pubkey should succeed"); + + assert_eq!(responses.len(), 2); + match &responses[0] { + MintQuoteResponse::Bolt11(r) => { + assert_eq!(r.quote, "bolt11-quote-id"); + assert_eq!(r.state, MintQuoteState::Paid); + } + other => panic!("expected bolt11 response, got {other:?}"), + } + match &responses[1] { + MintQuoteResponse::Custom { method, response } => { + assert_eq!(method, &PaymentMethod::Custom("paypal".to_string())); + assert_eq!(response.quote, "custom-quote-id"); + } + other => panic!("expected custom response, got {other:?}"), + } + + // No `{method}` path segment: this endpoint is method-agnostic. + assert_eq!( + post_urls.lock().expect("lock").as_slice(), + ["https://mint.example.com/v1/mint/quote/pubkey"] + ); + } + + /// Unit contract of `mint_quote_value_to_response` itself: a quote with no `"method"` field + /// must be rejected, not defaulted to bolt11 - that would parse a paid + /// bolt12/onchain/custom quote as an *unpaid* `MintQuoteResponse::Bolt11`, type confusion + /// the caller has no way to detect. This contract holds regardless of what + /// `post_mint_quote_by_pubkey` does with the error (see + /// `test_post_mint_quote_by_pubkey_skips_malformed_quotes` for that, batch-level behavior). + #[test] + fn test_mint_quote_value_to_response_rejects_missing_method() { + let value = serde_json::json!({ + "quote": "no-method-quote-id", + "request": "lnbc1...", + "amount": 1000, + "amount_paid": 1000, + "amount_issued": 0, + "updated_at": 42, + "unit": "sat", + "expiry": 9999999999_u64 + }); + + let result = mint_quote_value_to_response(value); + + match result { + Err(Error::Custom(msg)) => { + assert!( + msg.contains("no-method-quote-id"), + "error should name the offending quote id, got: {msg}" + ); + } + other => panic!( + "a method-less quote must be rejected, not silently treated as bolt11: {other:?}" + ), + } + } + + /// A malformed entry (here, missing `"method"`) must not discard the rest of the batch: a + /// caller reconciling many quotes at once would otherwise see nothing at all for a pubkey + /// because of one bad quote, while that bad quote persists forever. The malformed entry is + /// dropped and the well-formed ones are still returned. + #[tokio::test] + async fn test_post_mint_quote_by_pubkey_skips_malformed_quotes() { + let canned_json = serde_json::json!({ + "quotes": [ + { + "quote": "good-bolt11-quote-id", + "request": "lnbc1...", + "amount": 1000, + "unit": "sat", + "method": "bolt11", + "amount_paid": 1000, + "amount_issued": 0, + "updated_at": 42, + "state": "PAID", + "expiry": 9999999999_u64 + }, + { + "quote": "no-method-quote-id", + "request": "lnbc1...", + "amount": 1000, + "amount_paid": 1000, + "amount_issued": 0, + "updated_at": 42, + "unit": "sat", + "expiry": 9999999999_u64 + } + ] + }) + .to_string(); + + let transport = MockTransport { + post_response: Arc::new(Mutex::new(Some(canned_json))), + ..Default::default() + }; + let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url"); + let client = HttpClient::with_transport(mint_url, transport, None); + + let secret_key = crate::nuts::SecretKey::generate(); + let request = MintQuoteByPubkeyRequest { + pubkeys: vec![secret_key.public_key()], + pubkey_signatures: vec![secret_key.sign(b"test-message").expect("sign")], + only_mintable: false, + }; + + let responses = client + .post_mint_quote_by_pubkey(request) + .await + .expect("a malformed entry must not fail the whole lookup"); + + assert_eq!(responses.len(), 1); + match &responses[0] { + MintQuoteResponse::Bolt11(r) => assert_eq!(r.quote, "good-bolt11-quote-id"), + other => panic!("expected the good bolt11 response, got {other:?}"), + } + } + + /// Pins the documented edge case: a response whose entries are ALL malformed collapses to + /// `Ok(vec![])` - the same value a quote-less pubkey returns. Callers can only tell the two + /// apart by the emitted warnings, which is why the doc comment tells them to watch for those. + #[tokio::test] + async fn test_post_mint_quote_by_pubkey_all_malformed_yields_empty_ok() { + let canned_json = serde_json::json!({ + "quotes": [ + { + "quote": "no-method-a", + "request": "lnbc1...", + "amount": 1000, + "amount_paid": 1000, + "amount_issued": 0, + "updated_at": 42, + "unit": "sat", + "expiry": 9999999999_u64 + }, + { + "quote": "no-method-b", + "request": "lnbc2...", + "amount": 500, + "amount_paid": 500, + "amount_issued": 0, + "updated_at": 43, + "unit": "sat", + "expiry": 9999999999_u64 + } + ] + }) + .to_string(); + + let transport = MockTransport { + post_response: Arc::new(Mutex::new(Some(canned_json))), + ..Default::default() + }; + let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url"); + let client = HttpClient::with_transport(mint_url, transport, None); + + let secret_key = crate::nuts::SecretKey::generate(); + let request = MintQuoteByPubkeyRequest { + pubkeys: vec![secret_key.public_key()], + pubkey_signatures: vec![secret_key.sign(b"test-message").expect("sign")], + only_mintable: false, + }; + + let responses = client + .post_mint_quote_by_pubkey(request) + .await + .expect("an all-malformed batch degrades to empty, it does not error"); + + assert!(responses.is_empty()); + } + + /// A `"bolt12"` object must reconstruct as `MintQuoteResponse::Bolt12`, not be defaulted + /// to Bolt11. + #[tokio::test] + async fn test_post_mint_quote_by_pubkey_reconstructs_bolt12() { + let pubkey = crate::nuts::SecretKey::generate().public_key(); + let canned_json = serde_json::json!({ + "quotes": [ + { + "quote": "bolt12-quote-id", + "request": "lno1...", + "method": "bolt12", + "amount": 500, + "unit": "sat", + "expiry": 9999999999_u64, + "pubkey": pubkey.to_hex(), + "amount_paid": 500, + "amount_issued": 0, + "updated_at": 5 + } + ] + }) + .to_string(); + + let transport = MockTransport { + post_response: Arc::new(Mutex::new(Some(canned_json))), + ..Default::default() + }; + let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url"); + let client = HttpClient::with_transport(mint_url, transport, None); + + let secret_key = crate::nuts::SecretKey::generate(); + let request = MintQuoteByPubkeyRequest { + pubkeys: vec![secret_key.public_key()], + pubkey_signatures: vec![secret_key.sign(b"test-message").expect("sign")], + only_mintable: false, + }; + + let responses = client + .post_mint_quote_by_pubkey(request) + .await + .expect("post_mint_quote_by_pubkey should succeed"); + + assert_eq!(responses.len(), 1); + match &responses[0] { + MintQuoteResponse::Bolt12(r) => { + assert_eq!(r.quote, "bolt12-quote-id"); + assert_eq!(r.amount_paid, cdk_common::Amount::from(500)); + } + other => panic!("expected bolt12 response, got {other:?}"), + } + } + + /// An `"onchain"` object must reconstruct as `MintQuoteResponse::Onchain`, not be + /// defaulted to Bolt11. + #[tokio::test] + async fn test_post_mint_quote_by_pubkey_reconstructs_onchain() { + let pubkey = crate::nuts::SecretKey::generate().public_key(); + let canned_json = serde_json::json!({ + "quotes": [ + { + "quote": "onchain-quote-id", + "request": "bc1qexample", + "method": "onchain", + "unit": "sat", + "pubkey": pubkey.to_hex(), + "amount_paid": 750, + "amount_issued": 0, + "updated_at": 3 + } + ] + }) + .to_string(); + + let transport = MockTransport { + post_response: Arc::new(Mutex::new(Some(canned_json))), + ..Default::default() + }; + let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url"); + let client = HttpClient::with_transport(mint_url, transport, None); + + let secret_key = crate::nuts::SecretKey::generate(); + let request = MintQuoteByPubkeyRequest { + pubkeys: vec![secret_key.public_key()], + pubkey_signatures: vec![secret_key.sign(b"test-message").expect("sign")], + only_mintable: false, + }; + + let responses = client + .post_mint_quote_by_pubkey(request) + .await + .expect("post_mint_quote_by_pubkey should succeed"); + + assert_eq!(responses.len(), 1); + match &responses[0] { + MintQuoteResponse::Onchain(r) => { + assert_eq!(r.quote, "onchain-quote-id"); + assert_eq!(r.amount_paid, cdk_common::Amount::from(750)); + } + other => panic!("expected onchain response, got {other:?}"), + } + } + #[tokio::test] async fn test_post_melt_quote_custom_derives_missing_method_from_route() { let canned_json = serde_json::json!({ diff --git a/crates/cdk/src/wallet/mint_connector/mod.rs b/crates/cdk/src/wallet/mint_connector/mod.rs index e365f7d04..f84fde01e 100644 --- a/crates/cdk/src/wallet/mint_connector/mod.rs +++ b/crates/cdk/src/wallet/mint_connector/mod.rs @@ -133,10 +133,21 @@ pub trait MintConnector: Debug { quote_id: &str, ) -> Result, Error>; - /// NUT-XX: Mint Quote Lookup by Public Key + /// Look up mint quotes locked to a set of NUT-20 public keys [NUT-XX] + /// + /// Method-agnostic: quotes for any payment method locked to any of the requested pubkeys + /// are returned together. The caller must already have signed `request.pubkey_signatures` + /// (one signature per pubkey, over `nutxx::mint_quote_lookup_msg_to_sign`) — this is the + /// low-level transport call; + /// [`Wallet::fetch_mint_quotes_by_pubkey`](crate::Wallet::fetch_mint_quotes_by_pubkey) is the + /// signing, storing entry point most callers want. + /// + /// A malformed entry in the response (currently, a quote missing its `method` field) is + /// skipped with a `tracing::warn!` rather than failing the whole lookup. As a consequence, + /// a response whose entries are all malformed yields `Ok(vec![])`, indistinguishable from + /// a pubkey with no quotes except by the emitted warnings (per-entry plus a summary count). async fn post_mint_quote_by_pubkey( &self, - method: PaymentMethod, request: MintQuoteByPubkeyRequest, ) -> Result>, Error>; diff --git a/crates/cdk/src/wallet/test_utils.rs b/crates/cdk/src/wallet/test_utils.rs index f39a2e2d9..68f55330b 100644 --- a/crates/cdk/src/wallet/test_utils.rs +++ b/crates/cdk/src/wallet/test_utils.rs @@ -471,6 +471,11 @@ pub struct MockMintConnector { pub post_batch_mint_responses: Mutex>>, /// Captured post_batch_mint requests. pub post_batch_mint_requests: Mutex)>>, + /// Response for post_mint_quote_by_pubkey calls + pub post_mint_quote_by_pubkey_response: + Mutex>, Error>>>, + /// Captured post_mint_quote_by_pubkey requests for test verification. + pub captured_mint_quote_by_pubkey_requests: Mutex>, /// Response for post_swap calls pub post_swap_response: Mutex>>, /// Queue of responses for successive post_swap calls. @@ -493,6 +498,8 @@ pub struct MockMintConnector { /// Response for DNS TXT resolution calls #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] pub dns_txt_response: Mutex, Error>>>, + /// Number of times `get_mint_info` has been called. + pub get_mint_info_calls: Mutex, } impl Default for MockMintConnector { @@ -518,6 +525,8 @@ impl MockMintConnector { post_mint_requests: Mutex::new(Vec::new()), post_batch_mint_responses: Mutex::new(std::collections::VecDeque::new()), post_batch_mint_requests: Mutex::new(Vec::new()), + post_mint_quote_by_pubkey_response: Mutex::new(None), + captured_mint_quote_by_pubkey_requests: Mutex::new(Vec::new()), post_swap_response: Mutex::new(None), post_swap_responses: Mutex::new(std::collections::VecDeque::new()), captured_swap_requests: Mutex::new(Vec::new()), @@ -527,6 +536,7 @@ impl MockMintConnector { lnurl_invoice_response: Mutex::new(None), #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] dns_txt_response: Mutex::new(None), + get_mint_info_calls: Mutex::new(0), } } @@ -581,6 +591,13 @@ impl MockMintConnector { } } + pub fn set_mint_quote_by_pubkey_response( + &self, + response: Result>, Error>, + ) { + *self.post_mint_quote_by_pubkey_response.lock().unwrap() = Some(response); + } + pub fn set_active_keyset(&self, keyset: KeySet) { *self.keysets.lock().unwrap() = vec![keyset]; } @@ -615,13 +632,6 @@ impl MockMintConnector { .push_back(response); } - pub fn set_post_mint_quote_by_pubkeys_response( - &self, - response: Result>, Error>, - ) { - *self.post_mint_quote_by_pubkey_response.lock().unwrap() = Some(response); - } - pub fn set_post_mint_response(&self, response: Result) { *self.post_mint_response.lock().unwrap() = Some(response); } @@ -765,9 +775,13 @@ impl MintConnector for MockMintConnector { async fn post_mint_quote_by_pubkey( &self, - _method: PaymentMethod, - _request: MintQuoteByPubkeyRequest, + request: MintQuoteByPubkeyRequest, ) -> Result>, Error> { + self.captured_mint_quote_by_pubkey_requests + .lock() + .unwrap() + .push(request); + self.post_mint_quote_by_pubkey_response .lock() .unwrap() @@ -849,6 +863,7 @@ impl MintConnector for MockMintConnector { } async fn get_mint_info(&self) -> Result { + *self.get_mint_info_calls.lock().unwrap() += 1; Ok(self.mint_info.lock().unwrap().clone()) } diff --git a/crates/cdk/tests/nutxx_mint_quote_lookup.rs b/crates/cdk/tests/nutxx_mint_quote_lookup.rs index 9106fa19c..482c21915 100644 --- a/crates/cdk/tests/nutxx_mint_quote_lookup.rs +++ b/crates/cdk/tests/nutxx_mint_quote_lookup.rs @@ -6,16 +6,21 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::time::Duration; use bip39::Mnemonic; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; -use cdk::mint::{Mint, MintBuilder, MintMeltLimits}; +use cdk::amount::SplitTarget; +use cdk::mint::{Mint, MintBuilder, MintInput, MintMeltLimits, QuoteId}; 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::nuts::{ + CurrencyUnit, MintQuoteBolt11Request, MintQuoteState, MintRequest, PaymentMethod, + PreMintSecrets, PublicKey, SecretKey, +}; use cdk::types::{FeeReserve, QuoteTTL}; -use cdk::{Amount, Error, MintQuoteRequest}; +use cdk::{Amount, Error, MintQuoteRequest, MintQuoteResponse}; use cdk_fake_wallet::FakeWallet; async fn test_mint() -> Mint { @@ -59,8 +64,8 @@ async fn test_mint() -> Mint { mint } -/// Create a NUT-20 locked bolt11 mint quote owned by `pubkey`. -async fn locked_quote(mint: &Mint, pubkey: PublicKey) { +/// Create a NUT-20 locked bolt11 mint quote owned by `pubkey`, for a fixed amount of 100 sat. +async fn locked_quote(mint: &Mint, pubkey: PublicKey) -> MintQuoteResponse { mint.get_mint_quote(MintQuoteRequest::Bolt11(MintQuoteBolt11Request { amount: Amount::new(100, CurrencyUnit::Sat).into(), unit: CurrencyUnit::Sat, @@ -68,7 +73,7 @@ async fn locked_quote(mint: &Mint, pubkey: PublicKey) { pubkey: Some(pubkey), })) .await - .unwrap(); + .unwrap() } /// Sign the lookup message the way a spec-conformant wallet does. @@ -81,6 +86,65 @@ async fn sign_lookup( secret_key.sign(&msg).unwrap() } +/// Wait for the fake Lightning backend's scheduled payment to land and the quote to be marked +/// paid, polling `check_mint_quotes` with a short sleep - `test_mint()`'s `FakeWallet` settles +/// its payment on a short delay rather than synchronously. Mirrors the poll loop in +/// `cdk::test_helpers::mint::mint_test_proofs`, which is `#[cfg(test)]`-only and so not +/// available to this integration test. +async fn wait_until_paid(mint: &Mint, quote_id: &QuoteId) { + loop { + let quotes = mint + .check_mint_quotes(std::slice::from_ref(quote_id)) + .await + .unwrap(); + if quotes[0].state() == Some(MintQuoteState::Paid) { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +/// Mint the full 100 sat against a NUT-20 locked quote, driving the real blind-signing path so +/// `amount_issued` catches up to `amount_paid` server-side - this is what "fully issued" means +/// for the mintable filter. The resulting proofs aren't needed, only the accounting effect. +async fn mint_full_amount(mint: &Mint, quote_id: &QuoteId, owner: &SecretKey) { + let keyset_id = *mint + .get_active_keysets() + .get(&CurrencyUnit::Sat) + .expect("mint has an active sat keyset"); + + let keys = mint + .keyset_pubkeys(&keyset_id) + .unwrap() + .keysets + .first() + .unwrap() + .keys + .clone(); + + let fee_and_amounts: cdk::amount::FeeAndAmounts = + (0, keys.iter().map(|a| a.0.to_u64()).collect::>()).into(); + + let premint_secrets = PreMintSecrets::random( + keyset_id, + Amount::from(100), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); + + let mut request = MintRequest { + quote: quote_id.clone(), + outputs: premint_secrets.blinded_messages(), + signature: None, + }; + request.sign(owner).unwrap(); + + mint.process_mint_request(MintInput::Single(request)) + .await + .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] @@ -104,7 +168,7 @@ async fn signed_lookup_returns_own_quotes() { let signature = sign_lookup(&mint, &owner).await; let quotes = mint - .get_mint_quote_by_pubkey(vec![pubkey], vec![signature]) + .get_mint_quote_by_pubkey(vec![pubkey], vec![signature], false) .await .unwrap(); @@ -131,7 +195,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(vec![pubkey], vec![signature], false) .await .is_ok()); @@ -139,7 +203,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(vec![pubkey], vec![double_hashed], false) .await, Err(Error::SignatureMissingOrInvalid) )); @@ -156,7 +220,7 @@ async fn missing_signatures_are_rejected() { // No signature at all. assert!(matches!( - mint.get_mint_quote_by_pubkey(vec![victim_pubkey], vec![]) + mint.get_mint_quote_by_pubkey(vec![victim_pubkey], vec![], false) .await, Err(Error::SignatureMissingOrInvalid) )); @@ -167,7 +231,8 @@ async fn missing_signatures_are_rejected() { assert!(matches!( mint.get_mint_quote_by_pubkey( vec![attacker.public_key(), victim_pubkey], - vec![attacker_signature] + vec![attacker_signature], + false ) .await, Err(Error::SignatureMissingOrInvalid) @@ -185,7 +250,7 @@ async fn signature_from_another_key_is_rejected() { let attacker_signature = sign_lookup(&mint, &attacker).await; assert!(matches!( - mint.get_mint_quote_by_pubkey(vec![victim_pubkey], vec![attacker_signature]) + mint.get_mint_quote_by_pubkey(vec![victim_pubkey], vec![attacker_signature], false) .await, Err(Error::SignatureMissingOrInvalid) )); @@ -204,7 +269,7 @@ async fn signature_for_another_mint_is_rejected() { let signature = owner.sign(&msg).unwrap(); assert!(matches!( - mint.get_mint_quote_by_pubkey(vec![pubkey], vec![signature]) + mint.get_mint_quote_by_pubkey(vec![pubkey], vec![signature], false) .await, Err(Error::SignatureMissingOrInvalid) )); @@ -220,7 +285,62 @@ async fn oversized_request_is_rejected() { .collect(); assert!(matches!( - mint.get_mint_quote_by_pubkey(pubkeys, vec![]).await, + mint.get_mint_quote_by_pubkey(pubkeys, vec![], false).await, Err(Error::BatchSizeExceeded { .. }) )); } + +/// `only_mintable` narrows the lookup to quotes that are actually mintable right now +/// (`amount_paid > amount_issued`): unpaid and fully-issued quotes are both excluded when set, +/// and nothing is excluded when it's left off. +#[tokio::test] +async fn only_mintable_filter_narrows_to_paid_unissued_quotes() { + let mint = test_mint().await; + let owner = SecretKey::generate(); + let pubkey = owner.public_key(); + + // Unpaid: never touched after creation. + let unpaid = locked_quote(&mint, pubkey).await; + + // Paid, not yet issued: wait for the fake backend's payment to land and be recorded. + let paid_unissued = locked_quote(&mint, pubkey).await; + wait_until_paid(&mint, paid_unissued.quote()).await; + + // Fully issued: paid, then minted in full so amount_issued catches up to amount_paid. + let fully_issued = locked_quote(&mint, pubkey).await; + wait_until_paid(&mint, fully_issued.quote()).await; + mint_full_amount(&mint, fully_issued.quote(), &owner).await; + + let signature = sign_lookup(&mint, &owner).await; + let mintable_only = mint + .get_mint_quote_by_pubkey(vec![pubkey], vec![signature], true) + .await + .unwrap(); + assert_eq!( + mintable_only + .iter() + .map(|q| q.quote().clone()) + .collect::>(), + vec![paid_unissued.quote().clone()], + "only_mintable=true must return exactly the paid-but-unissued quote" + ); + + // A fresh signature: the one above was consumed by the previous call's request. + let signature = sign_lookup(&mint, &owner).await; + let everything = mint + .get_mint_quote_by_pubkey(vec![pubkey], vec![signature], false) + .await + .unwrap(); + let mut everything_ids: Vec = everything.iter().map(|q| q.quote().clone()).collect(); + everything_ids.sort(); + let mut expected_ids = vec![ + unpaid.quote().clone(), + paid_unissued.quote().clone(), + fully_issued.quote().clone(), + ]; + expected_ids.sort(); + assert_eq!( + everything_ids, expected_ids, + "only_mintable=false must return all three quotes regardless of accounting state" + ); +}