Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
529d1f7
feat: implement batched quote minting
vnprc Nov 18, 2025
0716b1b
docs: second code review
vnprc Nov 20, 2025
c7c3254
refactor: add payment method path param to batch mint endpoint
vnprc Nov 21, 2025
edd724e
fix: batch mint integration tests
vnprc Nov 21, 2025
dec03ed
test: add integration test to batch mint a quote twice
vnprc Nov 21, 2025
8665656
docs: clean up docs
vnprc Nov 21, 2025
c6005e9
docs: batch minting bolt12 development plan
vnprc Nov 21, 2025
c051703
test: add three spending condition batch mint integration tests
vnprc Nov 21, 2025
6d88a23
feat: unify bolt11 single and batch mint handling through a shared he…
vnprc Dec 3, 2025
a66a54d
docs: update bolt12 batch minting dev plan
vnprc Dec 4, 2025
0a650a5
feat: bolt12 batch mint now validates status, lifecycle, and routes l…
vnprc Dec 4, 2025
42872c4
feat: add bolt12-aware batch quote status support across mint and wallet
vnprc Dec 6, 2025
5531396
fix: batch minting route syntax and bolt12 sig validation
vnprc Dec 12, 2025
afcaf86
docs: remove llm markdown docs
vnprc Dec 12, 2025
55e2281
fix: align batch minting with spec and partial bolt12 rules
vnprc Dec 13, 2025
bd60504
refactor: move batch mint structs and impls to nutxx.rs
vnprc Dec 15, 2025
2df18c1
refactor: move BatchMintSettings struct and impls to nutxx.rs
vnprc Dec 15, 2025
22fe290
fix: replace unreachable! with Error::UnsupportedPaymentMethod
vnprc Dec 15, 2025
1fb1c65
refactor: move batch mint validation from axum to cdk layer
vnprc Dec 15, 2025
bde3f9b
refactor: remove duplicate batch mint errors
vnprc Dec 15, 2025
ddc422f
refactor: remove MintQuoteBolt11Response.from_* fns
vnprc Dec 15, 2025
8a13f44
refactor: remove redundant create_test_mint function
vnprc Dec 16, 2025
ffc2118
refactor: remove duplicated logic in redb wallet add_mint_quote fn
vnprc Dec 16, 2025
b04ea07
refactor: remove non-transactional add_mint_quote API
vnprc Dec 16, 2025
c5594cf
fix: remove spending_condition field from mint quotes
vnprc Dec 16, 2025
b7bb6a5
fix: replace unreachable! with a proper error
vnprc Dec 16, 2025
9055adb
refactor: associate batch mint outputs with specific quotes
vnprc Dec 16, 2025
4fe1704
refactor: require per-quote amounts in batch mint requests
vnprc Dec 16, 2025
9e17053
fix: algn batch mint implementation with nut-xx spec
vnprc Dec 17, 2025
7aca388
test: add batch mint edge cases and quote id fix
vnprc Dec 31, 2025
202f880
fix: add openssl dependency
vnprc Jan 5, 2026
b035b7c
test: add happy path batched mint test
vnprc Jan 5, 2026
e50aea1
test: improve happy path test vector
vnprc Jan 5, 2026
f233ec6
test: add test vectors for atomic failure and nut-20 happy path
vnprc Jan 6, 2026
4e5a7ad
test: expand batch mint test coverage
vnprc Jan 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/cashu/src/nuts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pub mod nut19;
pub mod nut20;
pub mod nut23;
pub mod nut25;
pub mod nutxx;

#[cfg(feature = "auth")]
mod auth;
Expand Down Expand Up @@ -69,3 +70,7 @@ pub use nut23::{
MintQuoteBolt11Response, QuoteState as MintQuoteState,
};
pub use nut25::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response};
pub use nutxx::{
BatchMintRequest, BatchMintSettings, BatchQuoteStatusItem, BatchQuoteStatusRequest,
BatchQuoteStatusResponse, MintQuoteBolt12BatchStatusResponse,
};
6 changes: 6 additions & 0 deletions crates/cashu/src/nuts/nut06.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::nut01::PublicKey;
use super::nut17::SupportedMethods;
use super::nut19::CachedEndpoint;
use super::nutxx::BatchMintSettings;
use super::{nut04, nut05, nut15, nut19, MppMethodSettings};
#[cfg(feature = "auth")]
use super::{AuthRequired, BlindAuthSettings, ClearAuthSettings, ProtectedEndpoint};
Expand Down Expand Up @@ -327,6 +328,11 @@ pub struct Nuts {
#[serde(default)]
#[serde(rename = "20")]
pub nut20: SupportedSettings,
/// NUTXX Batch mint settings
#[serde(default)]
#[serde(rename = "XX")]
#[serde(skip_serializing_if = "BatchMintSettings::is_empty")]
pub nutxx: BatchMintSettings,
/// NUT21 Settings
#[serde(rename = "21")]
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down
6 changes: 6 additions & 0 deletions crates/cashu/src/nuts/nut19.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ pub enum Path {
/// Bolt11 Mint
#[serde(rename = "/v1/mint/bolt11")]
MintBolt11,
/// Bolt11 Batch Mint
#[serde(rename = "/v1/mint/bolt11/batch")]
MintBolt11Batch,
/// Bolt11 Melt
#[serde(rename = "/v1/melt/bolt11")]
MeltBolt11,
Expand All @@ -58,6 +61,9 @@ pub enum Path {
/// Bolt12 Mint
#[serde(rename = "/v1/mint/bolt12")]
MintBolt12,
/// Bolt12 Batch Mint
#[serde(rename = "/v1/mint/bolt12/batch")]
MintBolt12Batch,
/// Bolt12 Melt
#[serde(rename = "/v1/melt/bolt12")]
MeltBolt12,
Expand Down
249 changes: 249 additions & 0 deletions crates/cashu/src/nuts/nutxx.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
//! NUT-XX: Batch Minting
//!
//! Batch minting support for Cashu

use serde::{Deserialize, Serialize};

use super::nut00::{BlindedMessage, PaymentMethod};
use super::nut23::MintQuoteBolt11Response;
use super::nut25::MintQuoteBolt12Response;
use super::MintQuoteState;
use crate::Amount;

/// Batch Mint Request [NUT-XX]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub struct BatchMintRequest {
/// Quote IDs
pub quotes: Vec<String>,
/// Expected amount to mint per quote, in the same order as `quote`
#[serde(skip_serializing_if = "Option::is_none", default)]
pub quote_amounts: Option<Vec<Amount>>,
/// Blinded messages
pub outputs: Vec<BlindedMessage>,
/// Signatures for NUT-20 locked quotes (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub signatures: Option<Vec<Option<String>>>,
}

impl BatchMintRequest {
/// Total amount of outputs
pub fn total_amount(&self) -> Result<Amount, crate::nuts::nut04::Error> {
Amount::try_sum(self.outputs.iter().map(|msg| msg.amount))
.map_err(|_| crate::nuts::nut04::Error::AmountOverflow)
}

/// Total amount the client expects to mint per quote, if provided
pub fn total_quote_amounts(&self) -> Option<Result<Amount, crate::nuts::nut04::Error>> {
self.quote_amounts.as_ref().map(|amounts| {
Amount::try_sum(amounts.iter().cloned())
.map_err(|_| crate::nuts::nut04::Error::AmountOverflow)
})
}
}

/// Batch Quote Status Request [NUT-XX]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub struct BatchQuoteStatusRequest {
/// Quote IDs
pub quotes: Vec<String>,
}

/// Batch minting settings (NUT-XX)
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub struct BatchMintSettings {
/// Maximum quotes allowed in a batch request
#[serde(skip_serializing_if = "Option::is_none")]
pub max_batch_size: Option<u16>,
/// Supported payment methods for batch minting
#[serde(default)]
pub methods: Vec<PaymentMethod>,
}

impl Default for BatchMintSettings {
fn default() -> Self {
Self {
max_batch_size: Some(100),
methods: Vec::new(),
}
}
}

impl BatchMintSettings {
/// Returns true when no batch capabilities should be advertised
pub fn is_empty(&self) -> bool {
self.methods.is_empty()
}
}

/// Bolt12 batch status payload extends the standard Bolt12 quote with a derived state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
#[serde(bound = "Q: Serialize + for<'a> Deserialize<'a>")]
pub struct MintQuoteBolt12BatchStatusResponse<Q> {
/// Underlying Bolt12 quote payload
#[serde(flatten)]
pub quote: MintQuoteBolt12Response<Q>,
/// Derived quote state (UNPAID, PAID, ISSUED)
pub state: MintQuoteState,
}

impl<Q> MintQuoteBolt12BatchStatusResponse<Q> {
fn from_quote(quote: MintQuoteBolt12Response<Q>) -> Self {
let state = derive_quote_state(quote.amount_paid, quote.amount_issued);
Self { quote, state }
}

/// Current quote state
pub fn state(&self) -> MintQuoteState {
self.state
}
}

impl<Q> From<MintQuoteBolt12Response<Q>> for MintQuoteBolt12BatchStatusResponse<Q> {
fn from(value: MintQuoteBolt12Response<Q>) -> Self {
Self::from_quote(value)
}
}

/// Batch quote status entry supporting both Bolt11 and Bolt12 payloads.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
#[serde(untagged)]
pub enum BatchQuoteStatusItem {
/// Bolt11 quote payload
Bolt11(MintQuoteBolt11Response<String>),
/// Bolt12 quote payload
Bolt12(MintQuoteBolt12BatchStatusResponse<String>),
}

impl BatchQuoteStatusItem {
/// Quote state (UNPAID, PAID, ISSUED)
pub fn state(&self) -> MintQuoteState {
match self {
Self::Bolt11(response) => response.state,
Self::Bolt12(response) => response.state(),
}
}
}

impl From<MintQuoteBolt11Response<String>> for BatchQuoteStatusItem {
fn from(value: MintQuoteBolt11Response<String>) -> Self {
Self::Bolt11(value)
}
}

impl From<MintQuoteBolt12Response<String>> for BatchQuoteStatusItem {
fn from(value: MintQuoteBolt12Response<String>) -> Self {
let batch_response = MintQuoteBolt12BatchStatusResponse::from(value);
Self::Bolt12(batch_response)
}
}

impl From<MintQuoteBolt12BatchStatusResponse<String>> for BatchQuoteStatusItem {
fn from(value: MintQuoteBolt12BatchStatusResponse<String>) -> Self {
Self::Bolt12(value)
}
}

/// Batch Quote Status Response [NUT-XX]
/// Returns a Vec that should be serialized as a JSON array
#[derive(Debug, Clone)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(transparent))]
pub struct BatchQuoteStatusResponse(
/// Vector of quote status responses as JSON
pub Vec<BatchQuoteStatusItem>,
);

impl Serialize for BatchQuoteStatusResponse {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.0.serialize(serializer)
}
}

impl<'de> Deserialize<'de> for BatchQuoteStatusResponse {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Vec::<BatchQuoteStatusItem>::deserialize(deserializer).map(BatchQuoteStatusResponse)
}
}

fn derive_quote_state(amount_paid: Amount, amount_issued: Amount) -> MintQuoteState {
if amount_paid == Amount::ZERO && amount_issued == Amount::ZERO {
return MintQuoteState::Unpaid;
}

match amount_paid.cmp(&amount_issued) {
std::cmp::Ordering::Less => {
tracing::error!("Bolt12 quote has issued more than paid");
MintQuoteState::Issued
}
std::cmp::Ordering::Equal => MintQuoteState::Issued,
std::cmp::Ordering::Greater => MintQuoteState::Paid,
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{CurrencyUnit, PublicKey};
use std::str::FromStr;

#[test]
fn batch_quote_status_item_returns_bolt11_state() {
let response = MintQuoteBolt11Response {
quote: "quote-1".to_string(),
request: "bolt11".to_string(),
amount: Some(Amount::from(100u64)),
unit: Some(CurrencyUnit::Sat),
state: MintQuoteState::Paid,
expiry: Some(42),
pubkey: None,
};

let item: BatchQuoteStatusItem = response.clone().into();
assert_eq!(item.state(), MintQuoteState::Paid);

match item {
BatchQuoteStatusItem::Bolt11(inner) => assert_eq!(inner.quote, response.quote),
_ => panic!("Expected bolt11 variant"),
}
}

#[test]
fn batch_quote_status_item_derives_bolt12_state() {
let pubkey = PublicKey::from_str(
"0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798",
)
.expect("valid pubkey");

let response = MintQuoteBolt12Response {
quote: "quote-2".to_string(),
request: "bolt12".to_string(),
amount: Some(Amount::from(200u64)),
unit: CurrencyUnit::Sat,
expiry: Some(100),
pubkey,
amount_paid: Amount::from(200u64),
amount_issued: Amount::from(50u64),
};

let item: BatchQuoteStatusItem = response.into();
assert_eq!(item.state(), MintQuoteState::Paid);

match item {
BatchQuoteStatusItem::Bolt12(inner) => {
assert_eq!(inner.quote.quote, "quote-2");
assert_eq!(inner.state(), MintQuoteState::Paid);
}
_ => panic!("Expected bolt12 variant"),
}
}
}
19 changes: 19 additions & 0 deletions crates/cdk-axum/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,14 @@ mod swagger_imports {
MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintQuoteBolt11Request,
MintQuoteBolt11Response,
};
pub use cdk::nuts::nut25::{MintQuoteBolt12Request, MintQuoteBolt12Response};
#[cfg(feature = "auth")]
pub use cdk::nuts::MintAuthRequest;
pub use cdk::nuts::{nut04, nut05, nut15, MeltQuoteState, MintQuoteState};
pub use cdk::{
BatchQuoteStatusItem, BatchQuoteStatusRequest, BatchQuoteStatusResponse,
MintQuoteBolt12BatchStatusResponse,
};
}

#[cfg(feature = "swagger")]
Expand Down Expand Up @@ -115,6 +120,9 @@ define_api_doc! {
BlindedMessage,
BlindSignature,
BlindSignatureDleq,
BatchQuoteStatusItem,
BatchQuoteStatusRequest,
BatchQuoteStatusResponse,
CheckStateRequest,
CheckStateResponse,
ContactInfo,
Expand All @@ -137,6 +145,9 @@ define_api_doc! {
MintInfo,
MintQuoteBolt11Request,
MintQuoteBolt11Response<String>,
MintQuoteBolt12Request,
MintQuoteBolt12Response<String>,
MintQuoteBolt12BatchStatusResponse<String>,
MintQuoteState,
MintMethodSettings,
MintVersion,
Expand Down Expand Up @@ -171,6 +182,9 @@ define_api_doc! {
BlindedMessage,
BlindSignature,
BlindSignatureDleq,
BatchQuoteStatusItem,
BatchQuoteStatusRequest,
BatchQuoteStatusResponse,
CheckStateRequest,
CheckStateResponse,
ContactInfo,
Expand All @@ -193,6 +207,9 @@ define_api_doc! {
MintInfo,
MintQuoteBolt11Request,
MintQuoteBolt11Response<String>,
MintQuoteBolt12Request,
MintQuoteBolt12Response<String>,
MintQuoteBolt12BatchStatusResponse<String>,
MintQuoteState,
MintMethodSettings,
MintVersion,
Expand Down Expand Up @@ -297,6 +314,8 @@ pub async fn create_mint_router_with_custom_cache(
get(get_check_mint_bolt11_quote),
)
.route("/mint/bolt11", post(cache_post_mint_bolt11))
.route("/mint/{method}/check", post(cache_post_batch_check_mint))
.route("/mint/{method}/batch", post(cache_post_batch_mint))
.route("/melt/quote/bolt11", post(post_melt_bolt11_quote))
.route("/ws", get(ws_handler))
.route(
Expand Down
Loading