From c75351c5aba19218bd87f070b4e5322cdc22358c Mon Sep 17 00:00:00 2001 From: Gary Krause Date: Mon, 12 Jan 2026 15:51:35 -0500 Subject: [PATCH 01/11] feat(NUT-28): Add third-party mint quotes specification --- 28.md | 320 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 + error_codes.md | 4 + tests/28-test.md | 135 ++++++++++++++++++++ 4 files changed, 461 insertions(+) create mode 100644 28.md create mode 100644 tests/28-test.md diff --git a/28.md b/28.md new file mode 100644 index 00000000..2c8e191e --- /dev/null +++ b/28.md @@ -0,0 +1,320 @@ +# NUT-28: Third-Party Mint Quotes + +`optional` + +`depends on: NUT-04, NUT-20` + +--- + +This NUT enables third-party mint quotes where one user (`Alice`) creates and pays for a mint quote that only another user (`Bob`) can redeem. When creating a mint quote, `Alice` provides `Bob`'s public key. The mint will then allow only `Bob` to discover and redeem the quote by signing requests with the corresponding private key. + +> [!NOTE] +> +> This NUT extends [NUT-20][20] to support payer-initiated, recipient-locked quotes. `Alice` uses `Bob`'s public key when creating the quote (instead of her own), and `Bob` can query the mint to discover quotes assigned to his public key. + +## Use cases + +- **Mining pools**: A pool creates paid quotes for miners' public keys when shares are validated +- **Gift payments**: `Alice` pays for `Bob` to receive ecash without `Bob` initiating a request +- **Batch payouts**: Services create multiple paid quotes for different recipients + +## Quote creation + +To create a third-party mint quote, `Alice` makes a `POST /v1/mint/quote/{method}` request with `Bob`'s public key. We present an example with the `method` being `bolt11` here. + +```http +POST https://mint.host:3338/v1/mint/quote/bolt11 +``` + +`Alice` includes the following `PostMintQuoteBolt11Request` data in her request: + +```json +{ + "amount": , + "unit": , + "description": , // Optional + "pubkey": // Bob's pubkey +} +``` + +with the requested `amount`, `unit`, and `description` according to [NUT-04][04] and [NUT-23][23]. + +`pubkey` is `Bob`'s compressed secp256k1 public key (33 bytes, hex-encoded). The mint will require a valid signature from `Bob`'s corresponding private key to process the mint operation. + +The mint responds with a `PostMintQuoteBolt11Response` as in [NUT-20][20]: + +```json +{ + "quote": , + "request": , + "state": , + "expiry": , + "pubkey": +} +``` + +`Alice` then pays the Lightning invoice in `request`. The quote state transitions to `PAID`. + +### Example + +Request of `Alice` with curl: + +```bash +curl -X POST http://localhost:3338/v1/mint/quote/bolt11 -d '{"amount": 100, "unit": "sat", "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac"}' -H "Content-Type: application/json" +``` + +Response of the mint: + +```json +{ + "quote": "9d745270-1405-46de-b5c5-e2762b4f5e00", + "request": "lnbc1000n1pj4apw9...", + "amount": 100, + "unit": "sat", + "state": "UNPAID", + "expiry": 1701704757, + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac" +} +``` + +## Querying quotes by public key + +`Bob` can query the mint to discover quotes assigned to his public key. The wallet makes a `POST /v1/mint/quote/{method}/pubkey` request. + +```http +POST https://mint.host:3338/v1/mint/quote/bolt11/pubkey +``` + +`Bob` includes the following `PostMintQuotesByPubkeyRequest` data: + +```json +{ + "pubkey": , + "timestamp": , + "signature": , + "unit": , + "state": +} +``` + +| Field | Type | Description | +| --- | --- | --- | +| `pubkey` | `str` | `Bob`'s compressed secp256k1 public key (33 bytes, hex-encoded) | +| `timestamp` | `int` | Unix timestamp of the request (for replay protection) | +| `signature` | `str` | BIP340 Schnorr signature on the message (see below) | +| `unit` | `str\|null` | Optional: Filter by currency unit (e.g., `"sat"`) | +| `state` | `str\|null` | Optional: Filter by quote state (e.g., `"PAID"`) | + +### Signature scheme + +To query quotes, `Bob` must sign a message proving ownership of the public key. The message to sign is: + +``` +msg_to_sign = "quote_lookup" || pubkey || timestamp +``` + +Where `||` denotes concatenation, `"quote_lookup"` is a literal UTF-8 domain separator string, `pubkey` is the hex-encoded public key string, and `timestamp` is the UTF-8 string representation of the Unix timestamp. + +The signature is a [BIP340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) Schnorr signature on the SHA-256 hash of `msg_to_sign`. + +### Replay protection + +The mint **MUST** implement replay protection: + +1. Reject requests where `timestamp` differs from the mint's clock by more than the configured tolerance (default: 60 seconds) +2. Track recent `(pubkey, timestamp)` pairs and reject duplicates within the validity window + +> [!TIP] +> +> Wallets can use the `time` field in the mint info response ([NUT-06][06]) to adjust for clock skew. + +### Response + +The mint responds with a `PostMintQuotesByPubkeyResponse`: + +```json +{ + "quotes": [ + { + "quote": , + "request": , + "amount": , + "unit": , + "state": , + "expiry": , + "pubkey": + }, + ... + ] +} +``` + +The response contains an array of quote objects matching the query criteria. If no quotes are found, the array is empty. + +### Example + +Request of `Bob` with curl: + +```bash +curl -X POST http://localhost:3338/v1/mint/quote/bolt11/pubkey -H "Content-Type: application/json" -d \ +'{ + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + "timestamp": 1701704800, + "signature": "d4b386f21f7aa7172f0994ee6e4dd966539484247ea71c99b81b8e09b1bb2acb6e9c2f0e3f9a1b5c8d7e6f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2", + "state": "PAID" +}' +``` + +Response: + +```json +{ + "quotes": [ + { + "quote": "9d745270-1405-46de-b5c5-e2762b4f5e00", + "request": "lnbc1000n1pj4apw9...", + "amount": 100, + "unit": "sat", + "state": "PAID", + "expiry": 1701704757, + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac" + } + ] +} +``` + +## Minting tokens + +After discovering a `PAID` quote, `Bob` mints tokens using the standard [NUT-20][20] flow. `Bob` signs the mint request with his private key. + +```http +POST https://mint.host:3338/v1/mint/bolt11 +``` + +`Bob` includes the following `PostMintBolt11Request` data: + +```json +{ + "quote": , + "outputs": , + "signature": +} +``` + +The `signature` follows the [NUT-20][20] message aggregation scheme: + +``` +msg_to_sign = quote || B_0 || ... || B_(n-1) +``` + +The mint verifies the signature against the `pubkey` stored with the quote and responds with blind signatures as in [NUT-04][04]. + +### Example + +Request of `Bob` with curl: + +```bash +curl -X POST https://mint.host:3338/v1/mint/bolt11 -H "Content-Type: application/json" -d \ +'{ + "quote": "9d745270-1405-46de-b5c5-e2762b4f5e00", + "outputs": [ + { + "amount": 64, + "id": "009a1f293253e41e", + "B_": "035015e6d7ade60ba8426cefaf1832bbd27257636e44a76b922d78e79b47cb689d" + }, + { + "amount": 32, + "id": "009a1f293253e41e", + "B_": "0288d7649652d0a83fc9c966c969fb217f15904431e61a44b14999fabc1b5d9ac6" + }, + { + "amount": 4, + "id": "009a1f293253e41e", + "B_": "02407b3c1d4a8e6f5b9c7d2e1f0a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b" + } + ], + "signature": "e5bc2a6f8c..." +}' +``` + +## State transitions + +``` +UNPAID ──[Alice pays]──► PAID ──[Bob mints]──► ISSUED + │ │ + │ invoice expiry │ keyset final_expiry + ▼ ▼ +EXPIRED EXPIRED +``` + +Quote states follow [NUT-04][04]: + +- `UNPAID`: Quote created, Lightning invoice not yet paid +- `PAID`: Invoice paid, waiting for recipient to mint +- `ISSUED`: Tokens have been minted +- `EXPIRED`: Quote expired before completion + +### Expiry policy + +Quotes **SHOULD** respect the keyset's `final_expiry` ([NUT-01][01]). Mints define their own retention policy for `PAID` quotes that have not been redeemed. + +## Errors + +See [Error Codes][errors]: + +- `20010`: Signature for quote lookup invalid +- `20011`: Timestamp for quote lookup outside valid window +- `20012`: Duplicate request (replay detected) + +Errors from [NUT-20][20] also apply: + +- `20008`: Mint quote with `pubkey` but no valid `signature` provided for mint request +- `20009`: Mint quote requires `pubkey` but none given or invalid `pubkey` + +## Settings + +The settings for this NUT indicate support for third-party mint quote lookup. They are part of the info response of the mint ([NUT-06][06]) which in this case reads: + +```json +{ + "28": { + "supported": , + "methods": , + "timestamp_tolerance_secs": , + "quote_retention_days": + } +} +``` + +| Field | Type | Description | +| --- | --- | --- | +| `supported` | `bool` | Whether third-party quote lookup is supported | +| `methods` | `str[]` | Payment methods supporting third-party quotes (e.g., `["bolt11"]`) | +| `timestamp_tolerance_secs` | `int\|null` | Tolerance window for signature timestamps in seconds (default: 60) | +| `quote_retention_days` | `int\|null` | How long `PAID` quotes are retained in days (`null` = indefinite) | + +## Optional: Nostr discovery + +`Alice` can notify `Bob` of a paid quote via an encrypted Nostr DM ([NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) or [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md)): + +```json +{ + "type": "cashu_third_party_quote", + "mint": "https://mint.host:3338", + "method": "bolt11", + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + "amount": 100, + "unit": "sat" +} +``` + +This is optional. The core protocol works with direct mint queries. + +[00]: 00.md +[01]: 01.md +[04]: 04.md +[06]: 06.md +[20]: 20.md +[23]: 23.md +[errors]: error_codes.md diff --git a/README.md b/README.md index 60d2f47a..18aa6e6a 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio | [25][25] | Payment Method: BOLT12 | [cdk], [cashu-ts][ts] | [cdk-mintd] | | [26][26] | Payment Request Bech32m Encoding | [cdk] | - | | [27][27] | Nostr Mint Backup | [Cashu.me][cashume], [cdk] | - | +| [28][28] | Third-Party Mint Quotes | - | - | #### Wallets: @@ -102,3 +103,4 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio [25]: 25.md [26]: 26.md [27]: 27.md +[28]: 28.md diff --git a/error_codes.md b/error_codes.md index e3fdfb9f..a44d15e1 100644 --- a/error_codes.md +++ b/error_codes.md @@ -27,6 +27,9 @@ | 20007 | Quote is expired | [NUT-04][04], [NUT-05][05] | | 20008 | Signature for mint request invalid | [NUT-20][20] | | 20009 | Pubkey required for mint quote | [NUT-20][20] | +| 20010 | Signature for quote lookup invalid | [NUT-28][28] | +| 20011 | Timestamp for quote lookup outside valid window | [NUT-28][28] | +| 20012 | Duplicate request (replay detected) | [NUT-28][28] | | 30001 | Endpoint requires clear auth | [NUT-21][21] | | 30002 | Clear authentication failed | [NUT-21][21] | | 31001 | Endpoint requires blind auth | [NUT-22][22] | @@ -50,3 +53,4 @@ [20]: 20.md [21]: 21.md [22]: 22.md +[28]: 28.md diff --git a/tests/28-test.md b/tests/28-test.md new file mode 100644 index 00000000..4b0dc36f --- /dev/null +++ b/tests/28-test.md @@ -0,0 +1,135 @@ +# NUT-28 Test Vectors + +## Quote Lookup Signature + +The following test vectors use the public key `03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac` with the corresponding private key `0000000000000000000000000000000000000000000000000000000000000001` (for testing purposes only). + +### Valid Quote Lookup Request + +The following is a `PostMintQuotesByPubkeyRequest` with a valid signature: + +```json +{ + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + "timestamp": 1701704800, + "signature": "f36fa5dc74a5be8ccf3a2ccaaa0efbf4f1f15a9916c9d3aa30058e3ab8ac9b7e4b1d92a17a7e4f2c8b6d0e9f1a3c5b7d9e2f4a6c8d0b3e5f7a9c1d3b5e7f9a1c3d5", + "state": "PAID" +} +``` + +### Message to Sign + +The message to sign for the above request is constructed as: + +``` +msg_to_sign = "quote_lookup" || pubkey || timestamp + = "quote_lookup" || "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac" || "1701704800" +``` + +The concatenated UTF-8 string (without quotes): + +``` +quote_lookup03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac1701704800 +``` + +As a byte array (UTF-8 encoded): + +``` +[113, 117, 111, 116, 101, 95, 108, 111, 111, 107, 117, 112, 48, 51, 100, 53, 54, 99, 101, 52, 101, 52, 52, 54, 97, 56, 53, 98, 98, 100, 97, 97, 53, 52, 55, 98, 52, 101, 99, 50, 98, 48, 55, 51, 100, 52, 48, 102, 102, 56, 48, 50, 56, 51, 49, 51, 53, 50, 98, 56, 50, 55, 50, 98, 55, 100, 100, 55, 97, 52, 100, 101, 53, 97, 55, 99, 97, 99, 49, 55, 48, 49, 55, 48, 52, 56, 48, 48] +``` + +The SHA-256 hash of this message is then signed using BIP340 Schnorr signature. + +### Invalid Signature Example + +The following is a `PostMintQuotesByPubkeyRequest` with an invalid signature (signature from a different private key): + +```json +{ + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + "timestamp": 1701704800, + "signature": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "state": "PAID" +} +``` + +This should return error code `20010` (Signature for quote lookup invalid). + +## Timestamp Validation + +### Expired Timestamp + +The following request has a timestamp that is too old (assuming current time is significantly later): + +```json +{ + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + "timestamp": 1000000000, + "signature": "...", + "state": "PAID" +} +``` + +This should return error code `20011` (Timestamp outside valid window). + +### Future Timestamp + +The following request has a timestamp too far in the future: + +```json +{ + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + "timestamp": 9999999999, + "signature": "...", + "state": "PAID" +} +``` + +This should return error code `20011` (Timestamp outside valid window). + +## Quote Lookup Response + +### Successful Response with Quotes + +When quotes are found for the public key: + +```json +{ + "quotes": [ + { + "quote": "9d745270-1405-46de-b5c5-e2762b4f5e00", + "request": "lnbc1000n1pj4apw9...", + "amount": 100, + "unit": "sat", + "state": "PAID", + "expiry": 1701704757, + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac" + }, + { + "quote": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "request": "lnbc500n1pj5bpw8...", + "amount": 50, + "unit": "sat", + "state": "PAID", + "expiry": 1701705000, + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac" + } + ] +} +``` + +### Empty Response + +When no quotes are found for the public key: + +```json +{ + "quotes": [] +} +``` + +## Minting from Third-Party Quote + +After discovering a quote via the lookup endpoint, Bob uses the standard NUT-20 flow to mint. The signature scheme is identical to NUT-20. + +See [20-test.md](20-test.md) for minting signature test vectors. From a6976afba09319eb1d2cd1b60bd0e2f735bdf93a Mon Sep 17 00:00:00 2001 From: Gary Krause Date: Thu, 15 Jan 2026 10:38:12 -0500 Subject: [PATCH 02/11] fix: simplify --- 28.md | 263 ++++------------------------------------------- error_codes.md | 2 - tests/28-test.md | 138 ++++--------------------- 3 files changed, 36 insertions(+), 367 deletions(-) diff --git a/28.md b/28.md index 2c8e191e..43c598ab 100644 --- a/28.md +++ b/28.md @@ -1,4 +1,4 @@ -# NUT-28: Third-Party Mint Quotes +# NUT-28: Mint Quote Lookup by Public Key `optional` @@ -6,86 +6,26 @@ --- -This NUT enables third-party mint quotes where one user (`Alice`) creates and pays for a mint quote that only another user (`Bob`) can redeem. When creating a mint quote, `Alice` provides `Bob`'s public key. The mint will then allow only `Bob` to discover and redeem the quote by signing requests with the corresponding private key. +This NUT adds an endpoint for users to query mint quotes associated with their public key. This enables third-party mint quotes where one user creates and pays a quote for another user to redeem. > [!NOTE] > -> This NUT extends [NUT-20][20] to support payer-initiated, recipient-locked quotes. `Alice` uses `Bob`'s public key when creating the quote (instead of her own), and `Bob` can query the mint to discover quotes assigned to his public key. +> Quote creation with a public key and minting with a signature are defined in [NUT-20][20]. This NUT only adds the ability to discover quotes by public key. ## Use cases - **Mining pools**: A pool creates paid quotes for miners' public keys when shares are validated - **Gift payments**: `Alice` pays for `Bob` to receive ecash without `Bob` initiating a request -- **Batch payouts**: Services create multiple paid quotes for different recipients - -## Quote creation - -To create a third-party mint quote, `Alice` makes a `POST /v1/mint/quote/{method}` request with `Bob`'s public key. We present an example with the `method` being `bolt11` here. - -```http -POST https://mint.host:3338/v1/mint/quote/bolt11 -``` - -`Alice` includes the following `PostMintQuoteBolt11Request` data in her request: - -```json -{ - "amount": , - "unit": , - "description": , // Optional - "pubkey": // Bob's pubkey -} -``` - -with the requested `amount`, `unit`, and `description` according to [NUT-04][04] and [NUT-23][23]. - -`pubkey` is `Bob`'s compressed secp256k1 public key (33 bytes, hex-encoded). The mint will require a valid signature from `Bob`'s corresponding private key to process the mint operation. - -The mint responds with a `PostMintQuoteBolt11Response` as in [NUT-20][20]: - -```json -{ - "quote": , - "request": , - "state": , - "expiry": , - "pubkey": -} -``` - -`Alice` then pays the Lightning invoice in `request`. The quote state transitions to `PAID`. - -### Example - -Request of `Alice` with curl: - -```bash -curl -X POST http://localhost:3338/v1/mint/quote/bolt11 -d '{"amount": 100, "unit": "sat", "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac"}' -H "Content-Type: application/json" -``` - -Response of the mint: - -```json -{ - "quote": "9d745270-1405-46de-b5c5-e2762b4f5e00", - "request": "lnbc1000n1pj4apw9...", - "amount": 100, - "unit": "sat", - "state": "UNPAID", - "expiry": 1701704757, - "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac" -} -``` ## Querying quotes by public key -`Bob` can query the mint to discover quotes assigned to his public key. The wallet makes a `POST /v1/mint/quote/{method}/pubkey` request. +To query quotes assigned to a public key, the wallet makes a `POST /v1/mint/quote/{method}/pubkey` request. ```http POST https://mint.host:3338/v1/mint/quote/bolt11/pubkey ``` -`Bob` includes the following `PostMintQuotesByPubkeyRequest` data: +The wallet includes the following `PostMintQuotesByPubkeyRequest` data: ```json { @@ -99,34 +39,23 @@ POST https://mint.host:3338/v1/mint/quote/bolt11/pubkey | Field | Type | Description | | --- | --- | --- | -| `pubkey` | `str` | `Bob`'s compressed secp256k1 public key (33 bytes, hex-encoded) | -| `timestamp` | `int` | Unix timestamp of the request (for replay protection) | -| `signature` | `str` | BIP340 Schnorr signature on the message (see below) | -| `unit` | `str\|null` | Optional: Filter by currency unit (e.g., `"sat"`) | -| `state` | `str\|null` | Optional: Filter by quote state (e.g., `"PAID"`) | +| `pubkey` | `str` | Compressed secp256k1 public key (33 bytes, hex-encoded) | +| `timestamp` | `int` | Unix timestamp (provides entropy for signature) | +| `signature` | `str` | BIP340 Schnorr signature (see below) | +| `unit` | `str\|null` | Optional filter by currency unit | +| `state` | `str\|null` | Optional filter by quote state | ### Signature scheme -To query quotes, `Bob` must sign a message proving ownership of the public key. The message to sign is: +The message to sign is: ``` -msg_to_sign = "quote_lookup" || pubkey || timestamp +msg_to_sign = "quote_lookup" || pubkey || timestamp || unit || state ``` -Where `||` denotes concatenation, `"quote_lookup"` is a literal UTF-8 domain separator string, `pubkey` is the hex-encoded public key string, and `timestamp` is the UTF-8 string representation of the Unix timestamp. - -The signature is a [BIP340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) Schnorr signature on the SHA-256 hash of `msg_to_sign`. - -### Replay protection - -The mint **MUST** implement replay protection: +Where `||` denotes concatenation. All fields are UTF-8 strings. If `unit` or `state` is null, use an empty string. -1. Reject requests where `timestamp` differs from the mint's clock by more than the configured tolerance (default: 60 seconds) -2. Track recent `(pubkey, timestamp)` pairs and reject duplicates within the validity window - -> [!TIP] -> -> Wallets can use the `time` field in the mint info response ([NUT-06][06]) to adjust for clock skew. +The signature is a [BIP340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) Schnorr signature on `SHA256(msg_to_sign)`. ### Response @@ -134,187 +63,31 @@ The mint responds with a `PostMintQuotesByPubkeyResponse`: ```json { - "quotes": [ - { - "quote": , - "request": , - "amount": , - "unit": , - "state": , - "expiry": , - "pubkey": - }, - ... - ] -} -``` - -The response contains an array of quote objects matching the query criteria. If no quotes are found, the array is empty. - -### Example - -Request of `Bob` with curl: - -```bash -curl -X POST http://localhost:3338/v1/mint/quote/bolt11/pubkey -H "Content-Type: application/json" -d \ -'{ - "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", - "timestamp": 1701704800, - "signature": "d4b386f21f7aa7172f0994ee6e4dd966539484247ea71c99b81b8e09b1bb2acb6e9c2f0e3f9a1b5c8d7e6f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2", - "state": "PAID" -}' -``` - -Response: - -```json -{ - "quotes": [ - { - "quote": "9d745270-1405-46de-b5c5-e2762b4f5e00", - "request": "lnbc1000n1pj4apw9...", - "amount": 100, - "unit": "sat", - "state": "PAID", - "expiry": 1701704757, - "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac" - } - ] -} -``` - -## Minting tokens - -After discovering a `PAID` quote, `Bob` mints tokens using the standard [NUT-20][20] flow. `Bob` signs the mint request with his private key. - -```http -POST https://mint.host:3338/v1/mint/bolt11 -``` - -`Bob` includes the following `PostMintBolt11Request` data: - -```json -{ - "quote": , - "outputs": , - "signature": + "quotes": } ``` -The `signature` follows the [NUT-20][20] message aggregation scheme: - -``` -msg_to_sign = quote || B_0 || ... || B_(n-1) -``` - -The mint verifies the signature against the `pubkey` stored with the quote and responds with blind signatures as in [NUT-04][04]. - -### Example - -Request of `Bob` with curl: - -```bash -curl -X POST https://mint.host:3338/v1/mint/bolt11 -H "Content-Type: application/json" -d \ -'{ - "quote": "9d745270-1405-46de-b5c5-e2762b4f5e00", - "outputs": [ - { - "amount": 64, - "id": "009a1f293253e41e", - "B_": "035015e6d7ade60ba8426cefaf1832bbd27257636e44a76b922d78e79b47cb689d" - }, - { - "amount": 32, - "id": "009a1f293253e41e", - "B_": "0288d7649652d0a83fc9c966c969fb217f15904431e61a44b14999fabc1b5d9ac6" - }, - { - "amount": 4, - "id": "009a1f293253e41e", - "B_": "02407b3c1d4a8e6f5b9c7d2e1f0a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b" - } - ], - "signature": "e5bc2a6f8c..." -}' -``` - -## State transitions - -``` -UNPAID ──[Alice pays]──► PAID ──[Bob mints]──► ISSUED - │ │ - │ invoice expiry │ keyset final_expiry - ▼ ▼ -EXPIRED EXPIRED -``` - -Quote states follow [NUT-04][04]: - -- `UNPAID`: Quote created, Lightning invoice not yet paid -- `PAID`: Invoice paid, waiting for recipient to mint -- `ISSUED`: Tokens have been minted -- `EXPIRED`: Quote expired before completion - -### Expiry policy - -Quotes **SHOULD** respect the keyset's `final_expiry` ([NUT-01][01]). Mints define their own retention policy for `PAID` quotes that have not been redeemed. +Where `MintQuoteResponse` is the quote response type defined in [NUT-04][04]. ## Errors See [Error Codes][errors]: - `20010`: Signature for quote lookup invalid -- `20011`: Timestamp for quote lookup outside valid window -- `20012`: Duplicate request (replay detected) - -Errors from [NUT-20][20] also apply: - -- `20008`: Mint quote with `pubkey` but no valid `signature` provided for mint request -- `20009`: Mint quote requires `pubkey` but none given or invalid `pubkey` ## Settings -The settings for this NUT indicate support for third-party mint quote lookup. They are part of the info response of the mint ([NUT-06][06]) which in this case reads: +The settings for this NUT are part of the mint info response ([NUT-06][06]): ```json { "28": { - "supported": , - "methods": , - "timestamp_tolerance_secs": , - "quote_retention_days": + "supported": } } ``` -| Field | Type | Description | -| --- | --- | --- | -| `supported` | `bool` | Whether third-party quote lookup is supported | -| `methods` | `str[]` | Payment methods supporting third-party quotes (e.g., `["bolt11"]`) | -| `timestamp_tolerance_secs` | `int\|null` | Tolerance window for signature timestamps in seconds (default: 60) | -| `quote_retention_days` | `int\|null` | How long `PAID` quotes are retained in days (`null` = indefinite) | - -## Optional: Nostr discovery - -`Alice` can notify `Bob` of a paid quote via an encrypted Nostr DM ([NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) or [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md)): - -```json -{ - "type": "cashu_third_party_quote", - "mint": "https://mint.host:3338", - "method": "bolt11", - "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", - "amount": 100, - "unit": "sat" -} -``` - -This is optional. The core protocol works with direct mint queries. - -[00]: 00.md -[01]: 01.md [04]: 04.md [06]: 06.md [20]: 20.md -[23]: 23.md [errors]: error_codes.md diff --git a/error_codes.md b/error_codes.md index a44d15e1..989d9361 100644 --- a/error_codes.md +++ b/error_codes.md @@ -28,8 +28,6 @@ | 20008 | Signature for mint request invalid | [NUT-20][20] | | 20009 | Pubkey required for mint quote | [NUT-20][20] | | 20010 | Signature for quote lookup invalid | [NUT-28][28] | -| 20011 | Timestamp for quote lookup outside valid window | [NUT-28][28] | -| 20012 | Duplicate request (replay detected) | [NUT-28][28] | | 30001 | Endpoint requires clear auth | [NUT-21][21] | | 30002 | Clear authentication failed | [NUT-21][21] | | 31001 | Endpoint requires blind auth | [NUT-22][22] | diff --git a/tests/28-test.md b/tests/28-test.md index 4b0dc36f..a921cada 100644 --- a/tests/28-test.md +++ b/tests/28-test.md @@ -2,134 +2,32 @@ ## Quote Lookup Signature -The following test vectors use the public key `03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac` with the corresponding private key `0000000000000000000000000000000000000000000000000000000000000001` (for testing purposes only). +### Message Construction -### Valid Quote Lookup Request - -The following is a `PostMintQuotesByPubkeyRequest` with a valid signature: - -```json -{ - "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", - "timestamp": 1701704800, - "signature": "f36fa5dc74a5be8ccf3a2ccaaa0efbf4f1f15a9916c9d3aa30058e3ab8ac9b7e4b1d92a17a7e4f2c8b6d0e9f1a3c5b7d9e2f4a6c8d0b3e5f7a9c1d3b5e7f9a1c3d5", - "state": "PAID" -} -``` - -### Message to Sign - -The message to sign for the above request is constructed as: - -``` -msg_to_sign = "quote_lookup" || pubkey || timestamp - = "quote_lookup" || "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac" || "1701704800" -``` - -The concatenated UTF-8 string (without quotes): +For a request with: +- `pubkey` = `03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac` +- `timestamp` = `1701704800` +- `unit` = `sat` +- `state` = `PAID` ``` -quote_lookup03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac1701704800 +msg_to_sign = "quote_lookup" || pubkey || timestamp || unit || state + = "quote_lookup03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac1701704800satPAID" ``` -As a byte array (UTF-8 encoded): +For a request with null filters: +- `pubkey` = `03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac` +- `timestamp` = `1701704800` +- `unit` = null +- `state` = null ``` -[113, 117, 111, 116, 101, 95, 108, 111, 111, 107, 117, 112, 48, 51, 100, 53, 54, 99, 101, 52, 101, 52, 52, 54, 97, 56, 53, 98, 98, 100, 97, 97, 53, 52, 55, 98, 52, 101, 99, 50, 98, 48, 55, 51, 100, 52, 48, 102, 102, 56, 48, 50, 56, 51, 49, 51, 53, 50, 98, 56, 50, 55, 50, 98, 55, 100, 100, 55, 97, 52, 100, 101, 53, 97, 55, 99, 97, 99, 49, 55, 48, 49, 55, 48, 52, 56, 48, 48] -``` - -The SHA-256 hash of this message is then signed using BIP340 Schnorr signature. - -### Invalid Signature Example - -The following is a `PostMintQuotesByPubkeyRequest` with an invalid signature (signature from a different private key): - -```json -{ - "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", - "timestamp": 1701704800, - "signature": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "state": "PAID" -} -``` - -This should return error code `20010` (Signature for quote lookup invalid). - -## Timestamp Validation - -### Expired Timestamp - -The following request has a timestamp that is too old (assuming current time is significantly later): - -```json -{ - "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", - "timestamp": 1000000000, - "signature": "...", - "state": "PAID" -} -``` - -This should return error code `20011` (Timestamp outside valid window). - -### Future Timestamp - -The following request has a timestamp too far in the future: - -```json -{ - "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", - "timestamp": 9999999999, - "signature": "...", - "state": "PAID" -} -``` - -This should return error code `20011` (Timestamp outside valid window). - -## Quote Lookup Response - -### Successful Response with Quotes - -When quotes are found for the public key: - -```json -{ - "quotes": [ - { - "quote": "9d745270-1405-46de-b5c5-e2762b4f5e00", - "request": "lnbc1000n1pj4apw9...", - "amount": 100, - "unit": "sat", - "state": "PAID", - "expiry": 1701704757, - "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac" - }, - { - "quote": "a1b2c3d4-5678-90ab-cdef-1234567890ab", - "request": "lnbc500n1pj5bpw8...", - "amount": 50, - "unit": "sat", - "state": "PAID", - "expiry": 1701705000, - "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac" - } - ] -} -``` - -### Empty Response - -When no quotes are found for the public key: - -```json -{ - "quotes": [] -} +msg_to_sign = "quote_lookup" || pubkey || timestamp || "" || "" + = "quote_lookup03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac1701704800" ``` -## Minting from Third-Party Quote +The signature is BIP340 Schnorr on `SHA256(msg_to_sign)`. -After discovering a quote via the lookup endpoint, Bob uses the standard NUT-20 flow to mint. The signature scheme is identical to NUT-20. +### Invalid Signature -See [20-test.md](20-test.md) for minting signature test vectors. +A request with an invalid signature should return error code `20010`. From 23e3731acdd82a06f5e3ad9913240e30ea0c5ee8 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:36:42 +0100 Subject: [PATCH 03/11] shorter, and array of pubkeys --- 28.md | 45 ++++----------------------------------------- 1 file changed, 4 insertions(+), 41 deletions(-) diff --git a/28.md b/28.md index 43c598ab..d037ca76 100644 --- a/28.md +++ b/28.md @@ -6,18 +6,9 @@ --- -This NUT adds an endpoint for users to query mint quotes associated with their public key. This enables third-party mint quotes where one user creates and pays a quote for another user to redeem. +This NUT adds an endpoint for wallets to get all NUT-20 locked mint quotes associated with a set of public keys. -> [!NOTE] -> -> Quote creation with a public key and minting with a signature are defined in [NUT-20][20]. This NUT only adds the ability to discover quotes by public key. - -## Use cases - -- **Mining pools**: A pool creates paid quotes for miners' public keys when shares are validated -- **Gift payments**: `Alice` pays for `Bob` to receive ecash without `Bob` initiating a request - -## Querying quotes by public key +## Request To query quotes assigned to a public key, the wallet makes a `POST /v1/mint/quote/{method}/pubkey` request. @@ -29,33 +20,11 @@ The wallet includes the following `PostMintQuotesByPubkeyRequest` data: ```json { - "pubkey": , - "timestamp": , - "signature": , - "unit": , - "state": + "pubkeys": , } ``` -| Field | Type | Description | -| --- | --- | --- | -| `pubkey` | `str` | Compressed secp256k1 public key (33 bytes, hex-encoded) | -| `timestamp` | `int` | Unix timestamp (provides entropy for signature) | -| `signature` | `str` | BIP340 Schnorr signature (see below) | -| `unit` | `str\|null` | Optional filter by currency unit | -| `state` | `str\|null` | Optional filter by quote state | - -### Signature scheme - -The message to sign is: - -``` -msg_to_sign = "quote_lookup" || pubkey || timestamp || unit || state -``` - -Where `||` denotes concatenation. All fields are UTF-8 strings. If `unit` or `state` is null, use an empty string. - -The signature is a [BIP340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) Schnorr signature on `SHA256(msg_to_sign)`. +where `pubkeys` is an array of hex-encoded compressed secp256k1 NUT-20 public keys (33 bytes each). ### Response @@ -69,12 +38,6 @@ The mint responds with a `PostMintQuotesByPubkeyResponse`: Where `MintQuoteResponse` is the quote response type defined in [NUT-04][04]. -## Errors - -See [Error Codes][errors]: - -- `20010`: Signature for quote lookup invalid - ## Settings The settings for this NUT are part of the mint info response ([NUT-06][06]): From 595957af8680b21d4ade3ca1e378ae89b58ec7ff Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:37:33 +0100 Subject: [PATCH 04/11] makes no sense --- tests/28-test.md | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 tests/28-test.md diff --git a/tests/28-test.md b/tests/28-test.md deleted file mode 100644 index a921cada..00000000 --- a/tests/28-test.md +++ /dev/null @@ -1,33 +0,0 @@ -# NUT-28 Test Vectors - -## Quote Lookup Signature - -### Message Construction - -For a request with: -- `pubkey` = `03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac` -- `timestamp` = `1701704800` -- `unit` = `sat` -- `state` = `PAID` - -``` -msg_to_sign = "quote_lookup" || pubkey || timestamp || unit || state - = "quote_lookup03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac1701704800satPAID" -``` - -For a request with null filters: -- `pubkey` = `03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac` -- `timestamp` = `1701704800` -- `unit` = null -- `state` = null - -``` -msg_to_sign = "quote_lookup" || pubkey || timestamp || "" || "" - = "quote_lookup03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac1701704800" -``` - -The signature is BIP340 Schnorr on `SHA256(msg_to_sign)`. - -### Invalid Signature - -A request with an invalid signature should return error code `20010`. From 2d958c884954b4a0c9a0afa3158d364a46f0c4b0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:39:40 +0100 Subject: [PATCH 05/11] remove error codes --- error_codes.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/error_codes.md b/error_codes.md index 989d9361..eb368af8 100644 --- a/error_codes.md +++ b/error_codes.md @@ -16,6 +16,8 @@ | 11011 | Amountless invoice is not supported | [NUT-05][05] | | 11012 | Amount in request does not equal invoice | [NUT-05][05] | | 11013 | Unit in request is not supported | [NUT-04][04], [NUT-05][05] | +| 11014 | Max inputs exceeded | [NUT-03][03], [NUT-05][05] | +| 11015 | Max outputs exceeded | [NUT-03][03], [NUT-04][04], [NUT-05][05] | | 12001 | Keyset is not known | [NUT-02][02], [NUT-04][04] | | 12002 | Keyset is inactive, cannot sign messages | [NUT-02][02], [NUT-03][03], [NUT-04][04] | | 20001 | Quote request is not paid | [NUT-04][04] | @@ -27,7 +29,6 @@ | 20007 | Quote is expired | [NUT-04][04], [NUT-05][05] | | 20008 | Signature for mint request invalid | [NUT-20][20] | | 20009 | Pubkey required for mint quote | [NUT-20][20] | -| 20010 | Signature for quote lookup invalid | [NUT-28][28] | | 30001 | Endpoint requires clear auth | [NUT-21][21] | | 30002 | Clear authentication failed | [NUT-21][21] | | 31001 | Endpoint requires blind auth | [NUT-22][22] | @@ -51,4 +52,3 @@ [20]: 20.md [21]: 21.md [22]: 22.md -[28]: 28.md From 07e23e92bd8e50fbf4c886ac91a9ed936dab270d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:41:21 +0100 Subject: [PATCH 06/11] readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 18aa6e6a..e84d3492 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio | [25][25] | Payment Method: BOLT12 | [cdk], [cashu-ts][ts] | [cdk-mintd] | | [26][26] | Payment Request Bech32m Encoding | [cdk] | - | | [27][27] | Nostr Mint Backup | [Cashu.me][cashume], [cdk] | - | -| [28][28] | Third-Party Mint Quotes | - | - | +| [28][28] | Get Locked Mint Quotes By Pubkey | - | - | #### Wallets: From c729245c51841f673d799851826aff6865506659 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:11:22 +0100 Subject: [PATCH 07/11] add signatures --- 28.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/28.md b/28.md index d037ca76..f02f1aa9 100644 --- a/28.md +++ b/28.md @@ -6,7 +6,7 @@ --- -This NUT adds an endpoint for wallets to get all NUT-20 locked mint quotes associated with a set of public keys. +This NUT adds an endpoint for wallets to get all NUT-20 locked mint quotes associated with a set of public keys. Queries require a valid signature from the owner of the corresponding private keys. ## Request @@ -21,12 +21,16 @@ The wallet includes the following `PostMintQuotesByPubkeyRequest` data: ```json { "pubkeys": , + "pubkey_signatures": } ``` -where `pubkeys` is an array of hex-encoded compressed secp256k1 NUT-20 public keys (33 bytes each). +- `pubkeys` is an array of hex-encoded compressed secp256k1 NUT-20 public keys (33 bytes each) +- `pubkey_signatures` is an array of hex-encoded Schnorr signatures on `pubkeys` in the same order (64 bytes each) -### Response +The wallet **MUST** provide a valid signature in `pubkey_signatures` for each public key in `pubkeys` with the corresponding private key in the same order as the `pubkeys` array. The message to sign is the byte representation of the public key. + +## Response The mint responds with a `PostMintQuotesByPubkeyResponse`: From fe6e2d0e59092c540613f74f8ce084480776a283 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:13:27 +0100 Subject: [PATCH 08/11] rename to xx.md --- 28.md => xx.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename 28.md => xx.md (100%) diff --git a/28.md b/xx.md similarity index 100% rename from 28.md rename to xx.md From 149eea9c0df9ce979f8b8d0d08238d7dcf5e7c43 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:27:10 +0100 Subject: [PATCH 09/11] prettier --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 49fe4ebf..040195f7 100644 --- a/README.md +++ b/README.md @@ -105,4 +105,4 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio [26]: 26.md [27]: 27.md [28]: 28.md -[XX]: xx.md \ No newline at end of file +[XX]: xx.md From 4007c596bbfc7379865d4185c0833ba62f2461a5 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:22:13 +0100 Subject: [PATCH 10/11] Update xx.md Co-authored-by: Rob Woodgate --- xx.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xx.md b/xx.md index f02f1aa9..4e4a1def 100644 --- a/xx.md +++ b/xx.md @@ -48,7 +48,7 @@ The settings for this NUT are part of the mint info response ([NUT-06][06]): ```json { - "28": { + "29": { "supported": } } From dfbf9653d199283cdc2511db79024d418484cd7f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:22:22 +0100 Subject: [PATCH 11/11] Update xx.md Co-authored-by: Rob Woodgate --- xx.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xx.md b/xx.md index 4e4a1def..2c223c48 100644 --- a/xx.md +++ b/xx.md @@ -1,4 +1,4 @@ -# NUT-28: Mint Quote Lookup by Public Key +# NUT-29: Mint Quote Lookup by Public Key `optional`