Skip to content

Conversation

@vnprc
Copy link
Contributor

@vnprc vnprc commented Dec 13, 2025

Summary

This PR implements batch minting functionality as specified in NUT-XX, allowing clients to mint multiple quotes in a single request. The implementation supports both bolt11 and bolt12 payment methods with comprehensive validation and spending condition support.

Key Changes:

  • Added batch minting endpoints: POST /v1/mint/{method}/batch and POST /v1/mint/{method}/check
  • Implemented payment method validation ensuring all quotes in a batch use the same payment method
  • Added NUT-20 spending condition support for batch minting with signature validation
  • Extended protocol types with new batch-specific structs and error codes
  • Implemented bolt12 batch minting with signature validation and lifecycle validation

Implementation Details

Protocol Extensions (NUT-XX)

New Types:

  • BatchMintSettings in NUT-06 for capability advertisement
  • BatchMintRequest / BatchQuoteStatusRequest / BatchQuoteStatusResponse for batch operations
  • Added MintBolt11Batch and MintBolt12Batch to NUT-19 path enumeration

Error Codes:

  • BatchEmpty - empty quote array
  • BatchSizeExceeded - exceeds 100 quote limit
  • DuplicateQuoteIds - duplicate quotes in single request
  • PaymentMethodMismatch - quotes use different payment methods
  • EndpointMethodMismatch - quote method doesn't match URL path
  • SignatureInvalid / SignatureMissing / SignatureUnexpected - NUT-20 validation errors
  • BatchUnitMismatch - quotes use different currency units

API Endpoints

Batch Mint:

POST /v1/mint/{method}/batch
  • Mints multiple quotes atomically
  • Validates all quotes use same payment method as URL path
  • Supports bolt11 and bolt12 with spending conditions
  • Returns unified MintResponse with all blind signatures

Batch Quote Status:

POST /v1/mint/{method}/check
  • Checks status of multiple quotes in one call
  • Omits unknown quotes from response (per spec)
  • Returns heterogeneous response array supporting both bolt11 and bolt12

Validation Rules

Request Validation:

  1. Quotes array must not be empty
  2. Maximum 100 quotes per batch
  3. No duplicate quote IDs within a batch
  4. All quotes must use same payment method
  5. All quotes must use same currency unit
  6. Payment method must match URL path ({method})
  7. Signature array (if present) must match quote count

Quote Lifecycle Validation:

  • All quotes must be in PAID state
  • Quotes must not be expired
  • Quotes must not already be issued
  • For bolt12: validates pubkey ownership through signature verification

Spending Condition Support (NUT-20):

  • Validates signatures when quotes have pubkey locks
  • Rejects signatures on unlocked quotes
  • Requires signatures for all locked quotes in batch
  • Verifies each signature against corresponding quote's pubkey

Bolt12 Specifics

The bolt12 implementation includes:

  • Signature validation for quotes with pubkey locks (NUT-20)
  • Support for multiple quotes locked to different pubkeys in a single batch
  • Proper route validation (quotes with pubkeys must come through /mint/bolt12/batch)

Database Changes

Wallet Database:

  • Added add_mint_quote() method for persisting quotes

Mint Database:

  • Quote retrieval by quote ID (standard get_mint_quote())
  • Batch operations retrieve multiple quotes sequentially within a transaction

Architecture

The implementation follows a unified pattern:

  1. Request validation (size, duplicates, payment method)
  2. Quote retrieval and lifecycle validation
  3. Signature verification (if NUT-20 conditions present)
  4. Atomic minting through existing single-mint infrastructure
  5. Unified response construction

Core logic is shared between bolt11 and bolt12 through process_batch_mint_request(), with payment-method-specific validation extracted into helpers.

Testing

All tests passing:

# Integration tests (pure)
OPENSSL_LIB_DIR=$(pkg-config --variable=libdir openssl) \
  RUSTFLAGS="-C link-arg=-Wl,-rpath,$OPENSSL_LIB_DIR" \
  CDK_TEST_DB_TYPE=memory \
  cargo test -p cdk-integration-tests --test integration_tests_pure -- --test-threads 1 batch_mint

# Wallet batch mint tests
cargo test -p cdk --tests wallet_batch_mint

Test Coverage:

  • Basic batch minting with bolt11 quotes
  • Batch minting with spending conditions (NUT-20)
  • Multiple spending conditions in single batch
  • Quote status checking
  • Error cases: empty batch, oversized batch, duplicate quotes, payment method mismatches
  • Bolt12 signature validation for locked quotes
  • Double-spend prevention (quote reuse)

Migration Guide

For Mint Operators:

  • Batch minting is opt-in via NUT-XX capability advertisement
  • Maximum batch size is currently hardcoded to 100 quotes (TODO: make configurable)
  • Specify supported payment methods in BatchMintSettings.methods
  • No database migrations required

For Wallet Developers:

  • Use POST /v1/mint/{method}/batch instead of multiple single-mint calls
  • Maximum 100 quotes per batch request (currently hardcoded)
  • Handle new error codes for batch validation failures
  • For bolt12: include signatures for all quotes with pubkeys

Breaking Changes

None. This is a purely additive feature behind new endpoints.

Related

  • Implements NUT-XX batch minting specification
  • Extends NUT-20 spending conditions to batch context
  • Bolt12 integration follows NUT-25 patterns

vnprc added 15 commits December 8, 2025 19:47
- change batch mint endpoint from `/mint/bolt11/batch` to `/mint/{method}/batch`
- add `post_cache_wrapper_with_path` macro to support path params in cached handlers
- update `post_batch_mint` handler to validate payment method from URL path
- add validation that batch quotes match the endpoint's payment method
- add batch-specific error types
- update test suite with handler validation and protocol parsing tests
- refactor helper function to creat configurable number of quotes
- update all batch mint test calls to work with new signature
- add integration test: try to batch mint a quote twice
…lper

- normalize single mint requests into batch shape and delegate through shared helper
- collapse process_batch_mint_request body into common engine while keeping metrics wrappers
- add process_mint_workload with origin flag plus regression test for the single path
…ocally

- reuse batch status polling for bolt12 to reject unpaid quotes
- update bolt12 quote bookkeeping to persist issued state without warnings
- add unit tests for bolt12 finalization and mint connector routing paths
- extend cdk-common batch status types to cover bolt11 and bolt12 states
- wire cdk-axum handler for /v1/mint/{method}/check with auth and swagger
- update wallet connectors to send payment method when polling quote status
- change route path parameter syntax from :method to {method}
- remove incorrect outputs count validation in batch mint handler
- add requires_signature logic for bolt12 batch minting
- enforce signature requirement for bolt12 in batch origin mode
- add batch error codes and optional quote field, map to 400 responses
- enable bolt12 batch endpoint with spec validations and swagger tweaks
- enforce nut20 signature semantics and bolt12 partial issuance in mint logic
- advertise batch support via nuts.xx and update builder/ffi conversions
- adjust wallet bolt12 batch finalization to apportion minted amounts per quote
@vnprc vnprc force-pushed the batched-minting-pr-branch branch from dc17e90 to 55e2281 Compare December 13, 2025 21:51
Comment on lines 750 to 807
/// 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 quote: Vec<String>,
/// Blinded messages
pub outputs: Vec<cashu::nuts::nut00::BlindedMessage>,
/// Signatures for NUT-20 locked quotes (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<Vec<Option<String>>>,
}

impl BatchMintRequest {
/// Total amount of outputs
pub fn total_amount(&self) -> Result<Amount, cashu::nuts::nut04::Error> {
Amount::try_sum(self.outputs.iter().map(|msg| msg.amount))
.map_err(|_| cashu::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 quote: Vec<String>,
}

/// 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)
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requests and responses defined in the nuts should be in the cashu crate in a mod for the nut, nutxx for now.

Comment on lines 274 to 300
/// 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()
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this to its own mod nutxx.

let route = match payment_method {
PaymentMethod::Bolt11 => RoutePath::MintBolt11,
PaymentMethod::Bolt12 => RoutePath::MintBolt12,
PaymentMethod::Custom(_) => unreachable!(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would rather just return an error here then use unreachable.

Comment on lines 740 to 756
// Validation: empty quotes
if payload.quote.is_empty() {
return Err(into_response(cdk::error::Error::BatchEmpty));
}

// Validation: max 100 quotes per batch
if payload.quote.len() > MAX_BATCH_SIZE {
return Err(into_response(cdk::error::Error::BatchSizeExceeded));
}

// Validation: no duplicate quotes
let mut unique_quotes = std::collections::HashSet::new();
for quote_id in &payload.quote {
if !unique_quotes.insert(quote_id.clone()) {
return Err(into_response(cdk::error::Error::DuplicateQuoteIdInBatch));
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not do this verification here in axum. We should let cdk handle it.

Comment on lines 848 to 874

// Validation: empty quotes
if payload.quote.is_empty() {
return Err(into_response(cdk::error::Error::BatchEmpty));
}

// Validation: max 100 quotes per batch
if payload.quote.len() > MAX_BATCH_SIZE {
return Err(into_response(cdk::error::Error::BatchSizeExceeded));
}

// Validation: no duplicate quotes
let mut unique_quotes = std::collections::HashSet::new();
for quote_id in &payload.quote {
if !unique_quotes.insert(quote_id.clone()) {
return Err(into_response(cdk::error::Error::DuplicateQuoteIdInBatch));
}
}

// Validation: signature array (if present) matches quotes length
if let Some(ref signatures) = payload.signature {
if signatures.len() != payload.quote.len() {
return Err(into_response(
cdk::error::Error::BatchSignatureCountMismatch,
));
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The axum layer should be very thin just passing the request to cdk where it is validated.

Comment on lines 142 to 144
/// Add mint quote to storage
async fn add_mint_quote(&self, quote: WalletMintQuote) -> Result<(), Self::Err>;

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be added here we already have it on the tx. Line 59

Comment on lines 234 to 236
(id, mint_url, amount, unit, request, state, expiry, secret_key, payment_method, amount_issued, amount_paid, spending_condition)
VALUES
(:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key, :payment_method, :amount_issued, :amount_paid)
(:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key, :payment_method, :amount_issued, :amount_paid, :spending_condition)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to store the spending condition? The existing method it gets passed at runtime as an argument to the mint fn.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, i was not suspicious enough of the llm output


per_quote
}
_ => unreachable!(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a real error.

Comment on lines 856 to 860
let associated_quote = if origin == MintRequestOrigin::Single {
quote_ids.first().cloned()
} else {
None
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should still add the quote, we know how much each quote is for so we can split the message up per quote.

Comment on lines 837 to 847
PaymentMethod::Bolt12 => {
let mut remaining = outputs_amount;
let mut per_quote = Vec::with_capacity(mintable_per_quote.len());

for available in &mintable_per_quote {
let minted = std::cmp::min(*available, remaining);
per_quote.push(minted);
remaining = remaining.checked_sub(minted).ok_or(Error::AmountOverflow)?;
}

per_quote
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could be problematic if a large payment came in while the wallet is making the request it did not account for and that is the first quote in the list then all the outputs would be allocated minting for that quote and leaving none for the rest of the quotes. Worse the wallet has no way of knowing this even happened without further checks of mint quotes.

Should the wallet state in the request how much it expects for each quote.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep. good idea

vnprc added 13 commits December 15, 2025 15:58
- add batch_check_mint_quotes method in cdk for batch check endpoint
- remove duplicate validation from post_batch_mint axum handler
- drop add_mint_quote from wallet database trait and backends
- update ffi bridge and adapters to rely on transactional add_mint_quote
- refactor wallet batch and integration tests to insert quotes via transactions
- partition blinded messages/signatures per quote and persist with quote ids
- replace unreachable payment-method arm with a real error path
- add inline comments clarifying process_mint_workload flow
- extend BatchMintRequest with quote_amounts and populate in wallet/mint paths
- enforce bolt12 batch allocations against provided per-quote amounts server-side
- adjust tests/requests to include expected per-quote totals
- rename batch payload fields to quotes/signatures
  - propagate through wallet, client, handlers, and tests
- enforce per-quote bolt11/bolt12 amount validation with flexible output grouping
- emit spec string error codes and accept spec-format requests only
- update batch mint fixtures and remove unused serde aliases
- add signature validation tests (reordered outputs, missing msgs)
- add amount validation tests (mismatch, length errors)
- add currency unit mismatch tests
- add bolt12 edge case tests (missing amounts, over-request)
- test invalid quote id filtering in batch status
- fix batch status to skip unparseable quote ids
- refactor helpers to use transaction payment tracking
update batch quote amounts to demonstrate that amounts
are summed together to produce the output vector
- add test for atomic batch failure with unknown quote (vector 3)
- add test for valid nut-20 signature using spec test vector (vector 4)
- use exact test vector values for nut-20 signature validation
- add tests for empty outputs and amount validation
- add bolt11 quote amount mismatch validation tests
- add test for unlocked quotes with explicit null signatures
- add bolt12 incremental minting test for partial withdrawals
- add batch check endpoint tests for quote status queries
- add tests for unknown, empty, and duplicate quote handling
- add mixed paid/unpaid state and size limit tests
- simplify happy path test to use single output instead of split
- reorganize test coverage documentation by category
@ye0man ye0man added this to CDK Jan 15, 2026
@github-project-automation github-project-automation bot moved this to Backlog in CDK Jan 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants