Skip to content
56 changes: 56 additions & 0 deletions crates/cashu/src/nuts/nutxx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ pub struct MintQuoteByPubkeyRequest {
pub pubkeys: Vec<PublicKey>,
/// Schnorr signatures, in the same order as `pubkeys`
pub pubkey_signatures: Vec<Signature>,
/// 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]
Expand Down Expand Up @@ -130,6 +137,7 @@ mod tests {
let json = serde_json::to_string(&MintQuoteByPubkeyRequest {
pubkeys: vec![pubkey],
pubkey_signatures: vec![signature],
only_mintable: false,
})
.unwrap();

Expand All @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion crates/cdk-axum/src/custom_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;

Expand Down
5 changes: 5 additions & 0 deletions crates/cdk-common/src/database/mint/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,14 @@ pub trait QuotesDatabase {
/// Get Mint Quotes
async fn get_mint_quotes(&self) -> Result<Vec<MintMintQuote>, 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<Vec<MintMintQuote>, Self::Err>;
/// Get [`mint::MeltQuote`]
async fn get_melt_quote(
Expand Down
17 changes: 11 additions & 6 deletions crates/cdk-common/src/database/mint/test/mint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1080,8 +1080,6 @@ pub async fn get_mint_quote_by_public_key<DB>(db: DB)
where
DB: Database<Error> + KeysDatabase<Err = Error>,
{
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();
Expand Down Expand Up @@ -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);
Expand All @@ -1140,21 +1141,25 @@ 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);

// 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
Expand Down
44 changes: 8 additions & 36 deletions crates/cdk-integration-tests/src/init_pure_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -140,44 +138,18 @@ impl MintConnector for DirectMintConnection {

async fn post_mint_quote_by_pubkey(
&self,
method: PaymentMethod,
request: MintQuoteByPubkeyRequest,
) -> Result<Vec<MintQuoteResponse<String>>, Error> {
let pubkeys: Vec<PublicKey> = request
.pubkeys
.iter()
.map(|pk| PublicKey::from_hex(pk).map_err(|_| Error::PubkeyRequired))
.collect::<Result<Vec<_>, _>>()?;

let signatures: Vec<Signature> = request
.pubkeys_signatures
.iter()
.map(|sig| {
Signature::from_slice(sig.as_bytes()).map_err(|_| Error::SignatureMissingOrInvalid)
})
.collect::<Result<Vec<_>, _>>()?;

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::<String>::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(
Expand Down
72 changes: 71 additions & 1 deletion crates/cdk-integration-tests/tests/integration_tests_pure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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;
Expand Down Expand Up @@ -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(&quotes[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());
}
15 changes: 12 additions & 3 deletions crates/cdk-sql-common/src/mint/quotes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1299,17 +1299,26 @@ where
async fn get_mint_quotes_by_pubkey(
&self,
pubkeys: &[PublicKey],
only_mintable: bool,
) -> Result<Vec<MintQuote>, 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,
Expand All @@ -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?
Expand Down
8 changes: 6 additions & 2 deletions crates/cdk-sql-common/src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading