diff --git a/20.md b/20.md index 5c945057..4bf1b598 100644 --- a/20.md +++ b/20.md @@ -26,8 +26,8 @@ The wallet of `Alice` includes the following `PostMintQuoteBolt11Request` data i { "amount": , "unit": , - "description": , // Optional - "pubkey": // Optional <-- New + "description": , // Optional + "pubkey": // Optional <-- New } ``` @@ -47,7 +47,7 @@ The mint `Bob` then responds with a `PostMintQuoteBolt11Response`: "request": , "state": , "expiry": , - "pubkey": // Optional <-- New + "pubkey": // Optional <-- New } ``` diff --git a/29.md b/29.md new file mode 100644 index 00000000..36e8da53 --- /dev/null +++ b/29.md @@ -0,0 +1,278 @@ +# NUT-29: Batched Minting + +`optional` + +`depends on: NUT-04` + +`uses: NUT-20` + +This spec describes how a wallet can mint multiple quotes in one batched operation by requesting blind signatures for multiple multiple quotes in a single atomic request. + +--- + +## 1. Batch Checking Quote State + +Before minting, the wallet SHOULD verify that each mint quote has been paid. It does this by sending: + +```http +POST https://mint.host:3338/v1/mint/quote/{method}/check +``` + +The wallet includes the following body in its request: + +```json +{ + "quotes": +} +``` + +where `quotes` is an array of _unique_ mint quote IDs. + +The mint returns a JSON array of mint quotes objects as defined by the payment method's NUT specification. The quotes in this array MUST be in the same order as in the request. + +#### Example + +Below is an example for checking two bolt11 mint quotes. + +##### Request + +```http +POST https://mint.host:3338/v1/mint/quote/bolt11/check +Content-Type: application/json + +{ + "quotes": [ "quote_id_1", "quote_id_2" ] +} +``` + +##### Response + +```json +[ + { + "quote": "quote_id_1", + "request": "lnbc...", + "state": "PAID", + "unit": "sat", + "amount": 100, + "expiry": 1234567890 + }, + { + "quote": "quote_id_2", + "request": "lnbc...", + "state": "UNPAID", + "unit": "sat", + "amount": 50, + "expiry": 1234567890 + } +] +``` + +#### Error Handling + +This is a query endpoint that uses all-or-nothing error handling, matching the behavior of the batch mint endpoint: + +- If any `quote_id` is not known by the mint, the mint MUST reject the entire request and return an appropriate error +- If any `quote_id` cannot be parsed (invalid format), the mint MUST reject the entire request and return an appropriate error + +--- + +## 2. Executing the Batched Mint + +#### Request by wallet + +Once all quoted payments are confirmed, the wallet mints the proofs by calling: + +```http +POST https://mint.host:3338/v1/mint/{method}/batch +``` + +The wallet includes the following body in its request: + +```json +{ + "quotes": , + "quote_amounts": , // Optional + "outputs": , + "signatures": // Optional +} +``` + +- `quotes`: array of _unique_ quote IDs. +- `quote_amounts`: array of expected amounts to mint per quote, in the same order as `quotes`. + - Required for payment methods that demand an amount like bolt12; Optional for other methods like bolt11. +- `outputs`: array of blinded messages (see [NUT-00][00]). +- `signatures`: array of signatures for NUT-20 locked quotes. See [NUT-20 Support][nut-20-support] + +#### Response by mint + +The mint responds with: + +```json +{ + "signatures": +} +``` + +- `signatures`: an array of blind signatures, one for each provided blinded message, in the same order as the `outputs` array. + +### Example + +Below is an example for minting two NUT-20 locked bolt11 mint quotes. + +##### Request + +```http +POST https://mint.host:3338/v1/mint/bolt12/batch +Content-Type: application/json + +{ + "quotes": [ + "quote_id_1", + "quote_id_2" + ], + "quote_amounts": [ + 100, + 50 + ], + "signatures": [ + "d9be080b33179387e504bb6991ea41ae0dd715e28b01ce9f63d57198a095bccc776874914288e6989e97ac9d255ac667c205fa8d90a211184b417b4ffdd24092", + "f2d97118390195cf5bef21d84c94e505dcdc2760154519536f74ba5e27f886f313b82296610df14db1d91d346e988ed384070bad084aaf06d14ccd7686157f24" + ], + "outputs": [ + { + "amount": 128, + "id": "009a1f293253e41e", + "B_": "035015e6d7ade60ba8426cefaf1832bbd27257636e44a76b922d78e79b47cb689d" + }, + { + "amount": 16, + "id": "009a1f293253e41e", + "B_": "0288d7649652d0a83fc9c966c969fb217f15904431e61a44b14999fabc1b5d9ac6" + }, + { + "amount": 4, + "id": "009a1f293253e41e", + "B_": "03b85be0c0a9f51056375b632a2f2c8149831b9827fad677be9807455e7d84b584" + }, + { + "amount": 2, + "id": "009a1f293253e41e", + "B_": "0276bbcf69e1b1238e6d39fc14c84e7cdfd519fee07f3369d2b2b23045390e2efc" + } + ] +} +``` + +##### Response + +```json +{ + "signatures": [ + "0208657b2917f469f275226cc931e5389451f6eed515d586ad16fa7a700eed4fb6", + "037dc0b5e712a39ef22f5bba1d49bc65a323ab9e0b771f7281d8c33280e2e58dbc" + ] +} +``` + +### Request Validation + +The mint MUST validate the following before processing a batch mint request: + +1. **Non-empty batch**: The `quotes` array MUST NOT be empty +2. **Unique quotes**: All quote IDs in the `quotes` array MUST be unique (no duplicates) +3. **Valid quote IDs**: All quote IDs MUST exist in the mint's database +4. **Payment method consistency**: All quotes MUST have the same payment method, matching `{method}` in the URL path +5. **Currency unit consistency**: All quotes MUST use the same currency unit +6. **Quote state**: All quotes MUST be in PAID state (or have a mintable amount for payment methods that allow multiple mint operations like bolt12) +7. **Amount balance**: The sum of amounts contained in the `outputs` MUST equal the sum of `quote_amounts` (bolt11) or MUST NOT exceed it (bolt12) +8. **Signature validation (NUT-20)**: The `signature` array length MUST match the `quotes` array length; locked quotes MUST include a valid signature; unlocked quotes MUST NOT include one + +Implementations MAY impose additional constraints such as maximum batch size based on their resource limitations. If any validation fails, the mint MUST reject the entire batch and return an appropriate error without minting any quotes. + +### NUT-20 support + +Per [NUT-20][20], quotes can require authentication via signatures. When using batch minting with NUT-20 locked quotes: + +#### Signature Array Structure + +**Array structure:** + +- The `signature` field is an array with length equal to `quote.length` (one entry per quote) +- `signatures[i]` corresponds to `quotes[i]` + +**Per-quote signatures:** + +- **Locked quotes** (with `pubkey`): `signature[i]` contains the signature string +- **Unlocked quotes**: `signatures[i]` is `null` + +**Field requirement:** + +- **Required**: If ANY quote is locked +- **Optional**: May be omitted entirely if all quotes are unlocked + +#### Signature Message + +Following the [NUT-20 message aggregation][20-msg-agg] pattern, the signature for `quotes[i]` is computed as: + +``` +msg_to_sign = quote_id[i] || B_0 || B_1 || ... || B_(n-1) +``` + +Where: + +- `quote_id[i]` is the UTF-8 encoded quote ID at index `i` +- `B_0 ... B_(n-1)` are **all blinded messages** from the `outputs` array (regardless of amount splitting) +- `||` denotes concatenation + +The signature is a BIP340 Schnorr signature on the SHA-256 hash of `msg_to_sign`. + +### Signature Validation Failure + +If **any signature in the batch is invalid**, the mint MUST reject the **entire batch** and return an error. This maintains atomicity: all quotes must be successfully authenticated and minted together, or none at all. + +### Example + +```json +{ + "quotes": ["locked_quote_id_1", "unlocked_quote_id_2", "locked_quote_id_3"], + "outputs": [ + { "amount": 64, "id": "keyset_1", "B_": "..." }, + { "amount": 64, "id": "keyset_1", "B_": "..." }, + { "amount": 22, "id": "keyset_1", "B_": "..." } + ], + "signatures": [ + "d9be080b...", // Signature for quote[0], covers ALL 3 outputs + null, // Quote[1] is unlocked + "a1c5f7e2..." // Signature for quote[2], covers ALL 3 outputs + ] +} +``` + +## Implementation Notes + +### Batch Size Limits + +Mints MAY advertise a maximum batch size through the [NUT-06][06] mint info endpoint. The batch size limit is included in the `nuts` object under the `29` key: + +```json +{ + "nuts": { + "29": { + "max_batch_size": 100, + "methods": ["bolt11", "bolt12"] + } + } +} +``` + +Fields: + +- `max_batch_size` (optional): Maximum number of quotes allowed in a single batch request. If omitted, the batch size limit is implementation-defined and clients MUST handle `BATCH_SIZE_EXCEEDED` errors gracefully. +- `methods` (optional): Array of payment methods supported for batch minting. If omitted, all methods supported by the mint (per NUT-04) are available for batching. + +[00]: 00.md +[06]: 06.md +[20]: 20.md +[20-msg-agg]: 20.md#message-aggregation +[nut-20-support]: #nut-20-support diff --git a/README.md b/README.md index 8c0b5798..eb653ccb 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,9 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio | [26][26] | Payment Request Bech32m Encoding | [cdk], [cashu-ts][ts] | - | | [27][27] | Nostr Mint Backup | [Cashu.me][cashume], [cdk] | - | | [28][28] | Pay to Blinded Key (P2BK) | [cdk], [cashu-ts][ts] | - | +| [29][29] | Batched Mint | - | - | -#### Wallets: +#### Wallets - [Nutshell][py] - [cdk] @@ -56,21 +57,18 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio - [Cashu.me][cashume] - [Boardwalk][bwc] -#### Mints: +#### Mints - [Nutshell][py] - [cdk-mintd][cdk-mintd] - [Nutmix][nutmix] [py]: https://github.com/cashubtc/nutshell -[lnbits]: https://github.com/lnbits/cashu [cashume]: https://cashu.me [ns]: https://nutstash.app/ [ts]: https://github.com/cashubtc/cashu-ts -[enuts]: https://github.com/cashubtc/eNuts [macadamia]: https://github.com/zeugmaster/macadamia [minibits]: https://github.com/minibits-cash/minibits_wallet -[moksha]: https://github.com/ngutech21/moksha [cdk]: https://github.com/cashubtc/cdk [cdk-mintd]: https://github.com/cashubtc/cdk/tree/main/crates/cdk-mintd [nutmix]: https://github.com/lescuer97/nutmix @@ -104,3 +102,4 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio [26]: 26.md [27]: 27.md [28]: 28.md +[29]: 29.md diff --git a/error_codes.md b/error_codes.md index eb368af8..4c677a07 100644 --- a/error_codes.md +++ b/error_codes.md @@ -24,7 +24,7 @@ | 20002 | Quote has already been issued | [NUT-04][04] | | 20003 | Minting is disabled | [NUT-04][04] | | 20004 | Lightning payment failed | [NUT-05][05] | -| 20005 | Quote is pending | [NUT-04][04], [NUT-05][05] | +| 20005 | Quote is pending | [NUT-04][04], [NUT-05][05], [NUT-29][29] | | 20006 | Invoice already paid | [NUT-05][05] | | 20007 | Quote is expired | [NUT-04][04], [NUT-05][05] | | 20008 | Signature for mint request invalid | [NUT-20][20] | @@ -52,3 +52,4 @@ [20]: 20.md [21]: 21.md [22]: 22.md +[29]: 29.md diff --git a/tests/29-tests.md b/tests/29-tests.md new file mode 100644 index 00000000..c697e184 --- /dev/null +++ b/tests/29-tests.md @@ -0,0 +1,157 @@ +# NUT-29 Test Vectors + +## Successful batch mint + +The following is a valid batch mint request combining two bolt11 quotes (`quote_id_a` for 5 sats and `quote_id_b` for 3 sats) into a single 8 sat output. + +```json +{ + "quotes": ["quote_id_a", "quote_id_b"], + "quote_amounts": [5, 3], + "outputs": [{ "amount": 8, "id": "keyset_1", "B_": "" }] +} +``` + +The following is the corresponding response with a blind signature. + +```json +{ + "signatures": [{ "amount": 8, "id": "keyset_1", "C_": "" }] +} +``` + +## Check endpoint with unknown quotes + +The following is an invalid check request containing an unknown quote ID. + +```json +{ "quotes": ["known-1", "bogus", "unknown-2"] } +``` + +Per NUT-29, quote check uses all-or-nothing error handling. If any quote is unknown, the entire request must be rejected. + +```json +{ + "code": "UNKNOWN_QUOTE", + "error": "one or more quote IDs are unknown" +} +``` + +## Batch mint atomic failure + +The following is an invalid batch mint request containing one unknown quote ID, causing the entire batch to fail atomically with no partial minting. + +```json +{ + "quotes": ["valid_quote_id", "unknown_quote_id"], + ... +} +``` + +Expected behavior: + +- The mint rejects the whole request with an error. +- No outputs are signed. +- No quote state is consumed/changed by partial processing. + +## Batch mint rejects empty quotes array + +The following is an invalid batch mint request with an empty `quotes` array. + +```json +{ + "quotes": [], + "outputs": [{ "amount": 1, "id": "keyset_1", "B_": "" }] +} +``` + +Expected behavior: + +- The mint rejects the request because `quotes` must be non-empty. +- No outputs are signed. + +## Batch mint rejects duplicate quote IDs + +The following is an invalid batch mint request with duplicate quote IDs. + +```json +{ + "quotes": ["quote_id_dup", "quote_id_dup"], + "outputs": [{ "amount": 2, "id": "keyset_1", "B_": "" }] +} +``` + +Expected behavior: + +- The mint rejects the request because quote IDs must be unique. +- No outputs are signed. + +## Batch mint rejects mixed payment methods + +The following is an invalid request to `/v1/mint/bolt11/batch` where one quote is bolt11 and one quote is bolt12. + +```json +{ + "quotes": ["bolt11_quote_id", "bolt12_quote_id"], + "quote_amounts": [5, 3], + "outputs": [{ "amount": 8, "id": "keyset_1", "B_": "" }] +} +``` + +Expected behavior: + +- The mint rejects the request because all quotes must share the same payment method and match `{method}` in the URL. +- No outputs are signed. + +## Batch mint rejects NUT-20 signature length mismatch + +The following is an invalid batch mint request where `signatures` length does not match `quotes` length. + +```json +{ + "quotes": ["locked_quote_id_1", "locked_quote_id_2"], + "outputs": [ + { "amount": 1, "id": "keyset_1", "B_": "" }, + { "amount": 1, "id": "keyset_1", "B_": "" } + ], + "signatures": [""] +} +``` + +Expected behavior: + +- The mint rejects the request because `signatures[i]` must exist for each `quotes[i]` when signatures are required. +- No outputs are signed. + +## NUT-20 signature with valid ordering + +The following is a valid NUT-20 batch mint request where the signature correctly covers all outputs in order. The quote has pubkey `0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798` (sk = 1). + +```shell +quote: "locked-quote" +pubkey: 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 +msg_to_sign_bytes: utf8("locked-quote") || B0 || B1 +msg_hash: 5ac550d5416e81c613b58e3f1fb095390fb828b55e8991fd9de231ca8e31e859 +signature[0]: 9408920d0b94cee5eb6df20f14d2a655e7ce2ce309dc1f1aeb69b219efe76716933b2206eba3a54f9a953c92edaa922ab3e6912e02383dda42a193409567a0dc +``` + +```json +{ + "quotes": ["locked-quote"], + "outputs": [ + { + "amount": 1, + "id": "010000000000000000000000000000000000000000000000000000000000000000", + "B_": "036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2" + }, + { + "amount": 1, + "id": "010000000000000000000000000000000000000000000000000000000000000000", + "B_": "021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59" + } + ], + "signatures": [ + "9408920d0b94cee5eb6df20f14d2a655e7ce2ce309dc1f1aeb69b219efe76716933b2206eba3a54f9a953c92edaa922ab3e6912e02383dda42a193409567a0dc" + ] +} +```