-
Notifications
You must be signed in to change notification settings - Fork 111
Batch Minting Implementation (NUT-XX) #1414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- 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
P2PK, HTLC, and combined
…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
dc17e90 to
55e2281
Compare
crates/cdk-common/src/mint.rs
Outdated
| /// 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
crates/cashu/src/nuts/nut06.rs
Outdated
| /// 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() | ||
| } | ||
| } |
There was a problem hiding this comment.
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!(), |
There was a problem hiding this comment.
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.
| // 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)); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
|
|
||
| // 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, | ||
| )); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| /// Add mint quote to storage | ||
| async fn add_mint_quote(&self, quote: WalletMintQuote) -> Result<(), Self::Err>; | ||
|
|
There was a problem hiding this comment.
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
| (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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
crates/cdk/src/mint/issue/mod.rs
Outdated
|
|
||
| per_quote | ||
| } | ||
| _ => unreachable!(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use a real error.
crates/cdk/src/mint/issue/mod.rs
Outdated
| let associated_quote = if origin == MintRequestOrigin::Single { | ||
| quote_ids.first().cloned() | ||
| } else { | ||
| None | ||
| }; |
There was a problem hiding this comment.
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.
crates/cdk/src/mint/issue/mod.rs
Outdated
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yep. good idea
- add batch_check_mint_quotes method in cdk for batch check endpoint - remove duplicate validation from post_batch_mint axum handler
use From impls instead
- 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
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:
POST /v1/mint/{method}/batchandPOST /v1/mint/{method}/checkImplementation Details
Protocol Extensions (NUT-XX)
New Types:
BatchMintSettingsin NUT-06 for capability advertisementBatchMintRequest/BatchQuoteStatusRequest/BatchQuoteStatusResponsefor batch operationsMintBolt11BatchandMintBolt12Batchto NUT-19 path enumerationError Codes:
BatchEmpty- empty quote arrayBatchSizeExceeded- exceeds 100 quote limitDuplicateQuoteIds- duplicate quotes in single requestPaymentMethodMismatch- quotes use different payment methodsEndpointMethodMismatch- quote method doesn't match URL pathSignatureInvalid/SignatureMissing/SignatureUnexpected- NUT-20 validation errorsBatchUnitMismatch- quotes use different currency unitsAPI Endpoints
Batch Mint:
MintResponsewith all blind signaturesBatch Quote Status:
Validation Rules
Request Validation:
{method})Quote Lifecycle Validation:
PAIDstateSpending Condition Support (NUT-20):
Bolt12 Specifics
The bolt12 implementation includes:
/mint/bolt12/batch)Database Changes
Wallet Database:
add_mint_quote()method for persisting quotesMint Database:
get_mint_quote())Architecture
The implementation follows a unified pattern:
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:
Test Coverage:
Migration Guide
For Mint Operators:
BatchMintSettings.methodsFor Wallet Developers:
POST /v1/mint/{method}/batchinstead of multiple single-mint callsBreaking Changes
None. This is a purely additive feature behind new endpoints.
Related