diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e90b7398 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.pre-commit-config.yaml diff --git a/26.md b/26.md new file mode 100644 index 00000000..af9b1d7b --- /dev/null +++ b/26.md @@ -0,0 +1,172 @@ +# NUT-26: Payment Request Bech32m Encoding + +`optional` `depends on: NUT-18` + +--- + +This specification defines an alternative encoding format for Payment Requests using Bech32m encoding with TLV (Tag-Length-Value) serialization. This format provides better QR code compatibility and typically 30-60% size reduction compared to the CBOR+base64 encoding defined in NUT-18. + +## Encoded Request Format + +Payment requests are serialized using TLV encoding, then encoded with Bech32m: + +`"creqb" + "1" + bech32m(TLV(PaymentRequest))` + +The human-readable part (HRP) is `"creqb"` and the version separator is `"1"`. The data payload is TLV-encoded as described below, then encoded with Bech32m (not standard Bech32). + +> [!NOTE] +> Implementations SHOULD output uppercase Bech32m strings for optimal QR code compatibility. Uppercase alphanumeric characters use QR "alphanumeric mode" which is more space-efficient than "byte mode" required for mixed-case. Decoders MUST accept both uppercase and lowercase input. + +When parsing a `creq` parameter, implementations SHOULD support both formats: + +1. If the parameter starts with `creqA` (case-insensitive), parse as NUT-18 CBOR+base64 format +2. If the parameter is valid Bech32m with HRP `creqb`, parse as NUT-26 format +3. Otherwise, return an error + +## TLV Structure + +The payment request is encoded as a sequence of TLV fields. Each TLV entry consists of: + +- **Type** (1 byte): Field identifier +- **Length** (2 bytes, big-endian): Length of value in bytes +- **Value** (variable): Field data + +### Top-Level TLV Tags + +| Tag | Field | Type | Description | +| ---- | ----------- | --------- | -------------------------------------------------------------------- | +| 0x01 | id | string | Payment identifier (corresponds to `i` in JSON) | +| 0x02 | amount | u64 | Amount in base units (corresponds to `a` in JSON) | +| 0x03 | unit | u8/string | Currency unit (corresponds to `u` in JSON) | +| 0x04 | single_use | u8 | Single-use flag: 0=false, 1=true (corresponds to `s` in JSON) | +| 0x05 | mint | string | Mint URL (repeatable for multiple mints, corresponds to `m` in JSON) | +| 0x06 | description | string | Human-readable description (corresponds to `d` in JSON) | +| 0x07 | transport | sub-TLV | Transport configuration (repeatable, corresponds to `t` in JSON) | +| 0x08 | nut10 | sub-TLV | NUT-10 spending conditions (corresponds to `nut10` in JSON) | + +All fields are optional. Unknown tags MUST be ignored to maintain forward compatibility. + +### Unit Encoding (Tag 0x03) + +The unit field uses a compact encoding: + +- **Value 0x00**: Represents `sat` (Bitcoin satoshis) +- **String value**: Any other unit is encoded as a UTF-8 string (e.g., `"msat"`, `"usd"`, `"eur"`) + +### Transport Sub-TLV (Tag 0x07) + +Transport configurations are encoded as nested TLV structures. Each transport has the following sub-tags: + +| Sub-Tag | Field | Type | Description | +| ------- | --------- | ----------- | ------------------------------------------------- | +| 0x01 | kind | u8 | Transport type: 0=nostr, 1=http_post | +| 0x02 | target | bytes | Transport target (interpretation depends on kind) | +| 0x03 | tag_tuple | sub-sub-TLV | Generic tag tuple (repeatable) | + +#### Transport Type Mapping + +The kind field (sub-tag 0x01) identifies the transport method. The following transport types are defined: + +| Kind Value | Transport Type | Description | Target Format | +| ---------- | -------------- | -------------------------------------- | ------------------------------------- | +| 0x00 | nostr | Nostr-based transport using NIP-04 DMs | 32-byte X-only public key (raw bytes) | +| 0x01 | http_post | HTTP POST to specified URL | UTF-8 encoded URL string | + +> [!NOTE] +> If no transport is specified (tag 0x07 is absent), the payment is assumed to be in-band, consistent with NUT-18 semantics. + +**JSON Representation:** + +In the NUT-18 JSON format, transports are represented with a `type` field: + +```json +{ + "t": [ + { "type": "nostr", "target": "npub1...", "tags": [["n", "17"]] }, + { "type": "post", "target": "https://callback.example.com/pay" } + ] +} +``` + +When encoding to TLV, the `type` string is converted to the corresponding numeric kind value. + +#### Transport Target Encoding (Sub-Tag 0x02) + +The target field is interpreted based on the transport kind: + +- **kind=0 (nostr)**: 32-byte X-only public key (raw bytes, not bech32-encoded) +- **kind=1 (http_post)**: UTF-8 encoded URL string + +#### Nostr Transport Details + +For Nostr transports (`kind=0`), the target field contains the raw 32-byte X-only public key (not bech32-encoded). NIPs and relay URLs are encoded using generic tag tuples (sub-tag 0x03), consistent with NUT-18's tags array. + +**Encoding (JSON to TLV):** + +1. Parse the `nprofile` or `npub` from the JSON target field using NIP-19 +2. Store the raw 32-byte X-only public key in target (sub-tag 0x02) +3. Store any relay URLs from the nprofile as tag tuples with key `"r"` +4. Store NIPs from the tags array as tag tuples with key `"n"` + +**Decoding (TLV to JSON):** + +- If no `"r"` tag tuples are present: encode public key as `npub` +- If `"r"` tag tuples are present: encode as `nprofile` using NIP-19 format + +#### Tag Tuple Encoding (Sub-Tag 0x03) + +Generic tag tuples are encoded as: + +1. Key length (1 byte) +2. Key string (UTF-8) +3. For each value: + - Value length (1 byte) + - Value string (UTF-8) + +This allows encoding arbitrary key-value pairs for extensibility. + +### NUT-10 Sub-TLV (Tag 0x08) + +NUT-10 spending conditions are encoded as nested TLV structures: + +| Sub-Tag | Field | Type | Description | +| ------- | --------- | ----------- | ------------------------------------------------------------ | +| 0x01 | kind | u8 | Secret kind (0=P2PK, 1=HTLC, etc.) | +| 0x02 | data | bytes | Kind-specific data (UTF-8 encoded) | +| 0x03 | tag_tuple | sub-sub-TLV | Tag tuple (repeatable, uses same encoding as transport tags) | + +#### NUT-10 Kind Enumeration + +The following kind values are defined for NUT-10 spending conditions: + +| Kind Value | Name | Description | +| ---------- | ---- | ---------------------------------------------------------------- | +| 0x00 | P2PK | Pay to Public Key - requires signature from specified public key | +| 0x01 | HTLC | Hash Time Locked Contract - requires preimage of hash | + +Additional kind values may be defined in future NUT specifications. Unknown kind values SHOULD be preserved when re-encoding but MAY be ignored during validation. + +## Example + +This is an example payment request expressed as JSON: + +```json +{ + "i": "demo123", + "a": 1000, + "u": "sat", + "s": true, + "m": ["https://mint.example.com"], + "d": "Coffee payment" +} +``` + +This payment request encodes to the NUT-26 format as: + +``` +CREQB1QYQQWER9D4HNZV3NQGQQSQQQQQQQQQQRAQPSQQGQQSQQZQG9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5RQQRJRDANXVET9YPCXZ7TDV4H8GXHR3TQ +``` + +[00]: 00.md +[10]: 10.md +[18]: 18.md diff --git a/README.md b/README.md index 48216bef..61a6314f 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio | [23][23] | Payment Method: BOLT11 | [Nutshell][py], [cdk-cli] | [Nutshell][py], [cdk-mintd], [nutmix] | | [24][24] | HTTP 402 Payment Required | - | - | | [25][25] | Payment Method: BOLT12 | [cdk-cli], [cashu-ts][ts] | [cdk-mintd] | +| [26][26] | Payment Request Bech32m Encoding | - | - | #### Wallets: @@ -99,3 +100,4 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio [23]: 23.md [24]: 24.md [25]: 25.md +[26]: 26.md diff --git a/tests/26-test.md b/tests/26-test.md new file mode 100644 index 00000000..e6a2dde7 --- /dev/null +++ b/tests/26-test.md @@ -0,0 +1,415 @@ +# NUT-26 Test Vectors + +## Payment Request Bech32m Encoding/Decoding + +The following are JSON-formatted payment requests and their Bech32m-encoded counterparts using TLV serialization. + +### Basic Payment Request + +A basic payment request with required fields. + +```json +{ + "i": "b7a90176", + "a": 10, + "u": "sat", + "m": ["https://8333.space:3338"], + "t": [ + { + "t": "nostr", + "a": "nprofile1qqsgm6qfa3c8dtz2fvzhvfqeacmwm0e50pe3k5tfmvpjjmn0vj7m2tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3wamnwvaz7tmjv4kxz7fw8qenxvewwdcxzcm99uqs6amnwvaz7tmwdaejumr0ds4ljh7n", + "g": [["n", "17"]] + } + ] +} +``` + +Encoded (Bech32m): + +``` +CREQB1QYQQSC3HVYUNQVFHXCPQQZQQQQQQQQQQQQ9QXQQPQQZSQ9MGW368QUE69UHNSVENXVH8XURPVDJN5VENXVUQWQREQYQQZQQZQQSGM6QFA3C8DTZ2FVZHVFQEACMWM0E50PE3K5TFMVPJJMN0VJ7M2TGRQQZSZMSZXYMSXQQHQ9EPGAMNWVAZ7TMJV4KXZ7FWV3SK6ATN9E5K7QCQRGQHY9MHWDEN5TE0WFJKCCTE9CURXVEN9EEHQCTRV5HSXQQSQ9EQ6AMNWVAZ7TMWDAEJUMR0DSRYDPGF +``` + +--- + +### Nostr Transport Payment Request + +A payment request using Nostr transport with multiple mints. + +```json +{ + "i": "f92a51b8", + "a": 100, + "u": "sat", + "m": ["https://mint1.example.com", "https://mint2.example.com"], + "t": [ + { + "t": "nostr", + "a": "nprofile1qqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq8uzqt", + "g": [ + ["n", "17"], + ["n", "9735"] + ] + } + ] +} +``` + +Encoded: + +``` +CREQB1QYQQSE3EXFSN2VTZ8QPQQZQQQQQQQQQQQPJQXQQPQQZSQXTGW368QUE69UHK66TWWSCJUETCV9KHQMR99E3K7MG9QQVKSAR5WPEN5TE0D45KUAPJ9EJHSCTDWPKX2TNRDAKSWQPEQYQQZQQZQQSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQRQQZSZMSZXYMSXQQ8Q9HQGWFHXV6SCAGZ48 +``` + +--- + +### Minimal Payment Request + +A minimal payment request with only required fields (no transport specified). + +```json +{ + "i": "7f4a2b39", + "u": "sat", + "m": ["https://mint.example.com"] +} +``` + +Encoded: + +``` +CREQB1QYQQSDMXX3SNYC3N8YPSQQGQQ5QPS6R5W3C8XW309AKKJMN59EJHSCTDWPKX2TNRDAKSYP0LHG +``` + +--- + +### Payment Request with NUT-10 Locking + +A payment request requiring P2PK-locked tokens with timeout tag. + +```json +{ + "i": "c9e45d2a", + "a": 500, + "u": "sat", + "m": ["https://mint.example.com"], + "nut10": { + "k": "P2PK", + "d": "02c3b5bb27e361457c92d93d78dd73d3d53732110b2cfe8b50fbc0abc615e9c331", + "t": [["timeout", "3600"]] + } +} +``` + +Encoded: + +``` +CREQB1QYQQSCEEV56R2EPJVYPQQZQQQQQQQQQQQ86QXQQPQQZSQXRGW368QUE69UHK66TWWSHX27RPD4CXCEFWVDHK6ZQQTYQSQQGQQGQYYVPJVVEKYDTZVGERWEFNXCCNGDFHVVUNYEPEXDJRWWRYVSMNXEPNVS6NXDENXGCNZVRZXF3KVEFCVG6NQENZVVCXZCNRXCCN2EFEVVENXVGRQQXSWARFD4JK7AT5QSENVVPS2N5FAS +``` + +--- + +### HTTP POST Transport (kind=0x01) + +A payment request using HTTP POST transport with custom tags. + +```json +{ + "i": "http_test", + "a": 250, + "u": "sat", + "m": ["https://mint.example.com"], + "t": [ + { + "t": "post", + "a": "https://api.example.com/v1/payment", + "g": [["custom", "value1", "value2"]] + } + ] +} +``` + +Encoded: + +``` +CREQB1QYQQJ6R5W3C97AR9WD6QYQQGQQQQQQQQQQQ05QCQQYQQ2QQCDP68GURN8GHJ7MTFDE6ZUETCV9KHQMR99E3K7MG8QPQSZQQPQYPQQGNGW368QUE69UHKZURF9EJHSCTDWPKX2TNRDAKJ7A339ACXZ7TDV4H8GQCQZ5RXXATNW3HK6PNKV9K82EF3QEMXZMR4V5EQ9X3SJM +``` + +--- + +### Relay Tag Extraction from nprofile + +A payment request with relays embedded in the nprofile (demonstrates "r" tag tuple encoding). + +```json +{ + "i": "relay_test", + "a": 100, + "u": "sat", + "m": ["https://mint.example.com"], + "t": [ + { + "t": "nostr", + "a": "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gprpmhxue69uhhyetvv9unztn90psk6urvv5hxxmmdqyv8wumn8ghj7un9d3shjv3wv4uxzmtsd3jjucm0d5q3samnwvaz7tmjv4kxz7fn9ejhsctdwpkx2tnrdaksxzjpjp" + } + ] +} +``` + +Encoded: + +``` +CREQB1QYQQ5UN9D3SHJHM5V4EHGQSQPQQQQQQQQQQQQEQRQQQSQPGQRP58GARSWVAZ7TMDD9H8GTN90PSK6URVV5HXXMMDQUQGZQGQQYQQYQPQ80CVV07TJDRRGPA0J7J7TMNYL2YR6YR7L8J4S3EVF6U64TH6GKWSXQQMQ9EPSAMNWVAZ7TMJV4KXZ7F39EJHSCTDWPKX2TNRDAKSXQQMQ9EPSAMNWVAZ7TMJV4KXZ7FJ9EJHSCTDWPKX2TNRDAKSXQQMQ9EPSAMNWVAZ7TMJV4KXZ7FN9EJHSCTDWPKX2TNRDAKSKRFDAR +``` + +--- + +### Description Field + +A payment request with a description field. + +```json +{ + "i": "desc_test", + "a": 100, + "u": "sat", + "m": ["https://mint.example.com"], + "d": "Test payment description" +} +``` + +Encoded: + +``` +CREQB1QYQQJER9WD347AR9WD6QYQQGQQQQQQQQQQQXGQCQQYQQ2QQCDP68GURN8GHJ7MTFDE6ZUETCV9KHQMR99E3K7MGXQQV9GETNWSS8QCTED4JKUAPQV3JHXCMJD9C8G6T0DCFLJJRX +``` + +--- + +### Single-Use Field (true) + +A payment request with single_use set to true. + +```json +{ + "i": "single_use_true", + "a": 100, + "u": "sat", + "s": true, + "m": ["https://mint.example.com"] +} +``` + +Encoded: + +``` +CREQB1QYQQ7UMFDENKCE2LW4EK2HM5WF6K2QSQPQQQQQQQQQQQQEQRQQQSQPQQQYQS2QQCDP68GURN8GHJ7MTFDE6ZUETCV9KHQMR99E3K7MGX0AYM7 +``` + +--- + +### Single-Use Field (false) + +A payment request with single_use set to false. + +```json +{ + "i": "single_use_false", + "a": 100, + "u": "sat", + "s": false, + "m": ["https://mint.example.com"] +} +``` + +Encoded: + +``` +CREQB1QYQPQUMFDENKCE2LW4EK2HMXV9K8XEGZQQYQQQQQQQQQQQRYQVQQZQQYQQQSQPGQRP58GARSWVAZ7TMDD9H8GTN90PSK6URVV5HXXMMDQ40L90 +``` + +--- + +### Non-Sat Unit (msat) + +A payment request using millisatoshi unit (string encoding, not 0x00). + +```json +{ + "i": "unit_msat", + "a": 1000, + "u": "msat", + "m": ["https://mint.example.com"] +} +``` + +Encoded: + +``` +CREQB1QYQQJATWD9697MTNV96QYQQGQQQQQQQQQQP7SQCQQ3KHXCT5Q5QPS6R5W3C8XW309AKKJMN59EJHSCTDWPKX2TNRDAKSYYMU95 +``` + +--- + +### Non-Sat Unit (usd) + +A payment request using USD unit (string encoding). + +```json +{ + "i": "unit_usd", + "a": 500, + "u": "usd", + "m": ["https://mint.example.com"] +} +``` + +Encoded: + +``` +CREQB1QYQQSATWD9697ATNVSPQQZQQQQQQQQQQQ86QXQQRW4EKGPGQRP58GARSWVAZ7TMDD9H8GTN90PSK6URVV5HXXMMDEPCJYC +``` + +--- + +### Multiple Transports + +A payment request with multiple transport options (Nostr + HTTP POST), demonstrating priority ordering. + +```json +{ + "i": "multi_transport", + "a": 500, + "u": "sat", + "m": ["https://mint.example.com"], + "d": "Payment with multiple transports", + "t": [ + { + "t": "nostr", + "a": "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8g2lcy6q", + "g": [["n", "17"]] + }, + { + "t": "post", + "a": "https://api1.example.com/payment" + }, + { + "t": "post", + "a": "https://api2.example.com/payment", + "g": [["priority", "backup"]] + } + ] +} +``` + +Encoded: + +``` +CREQB1QYQQ7MT4D36XJHM5WFSKUUMSDAE8GQSQPQQQQQQQQQQQRAQRQQQSQPGQRP58GARSWVAZ7TMDD9H8GTN90PSK6URVV5HXXMMDQCQZQ5RP09KK2MN5YPMKJARGYPKH2MR5D9CXCEFQW3EXZMNNWPHHYARNQUQZ7QGQQYQQYQPQ80CVV07TJDRRGPA0J7J7TMNYL2YR6YR7L8J4S3EVF6U64TH6GKWSXQQ9Q9HQYVFHQUQZWQGQQYQSYQPQDP68GURN8GHJ7CTSDYCJUETCV9KHQMR99E3K7MF0WPSHJMT9DE6QWQP6QYQQZQGZQQSXSAR5WPEN5TE0V9CXJV3WV4UXZMTSD3JJUCM0D5HHQCTED4JKUAQRQQGQSURJD9HHY6T50YRXYCTRDD6HQTSH7TP +``` + +--- + +### Minimal Nostr Transport (pubkey only) + +A minimal Nostr transport with just the pubkey (no relays, no tags). + +```json +{ + "i": "minimal_nostr", + "u": "sat", + "m": ["https://mint.example.com"], + "t": [ + { + "t": "nostr", + "a": "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8g2lcy6q" + } + ] +} +``` + +Encoded: + +``` +CREQB1QYQQ6MTFDE5K6CTVTAHX7UM5WGPSQQGQQ5QPS6R5W3C8XW309AKKJMN59EJHSCTDWPKX2TNRDAKSWQP8QYQQZQQZQQSRHUXX8L9EX335Q7HE0F09AEJ04ZPAZPL0NE2CGUKYAWD24MAYT8G7QNXMQ +``` + +--- + +### Minimal HTTP POST Transport (URL only) + +A minimal HTTP POST transport with just the URL (no tags). + +```json +{ + "i": "minimal_http", + "u": "sat", + "m": ["https://mint.example.com"], + "t": [ + { + "t": "post", + "a": "https://api.example.com" + } + ] +} +``` + +Encoded: + +``` +CREQB1QYQQCMTFDE5K6CTVTA58GARSQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5RSQ8SPQQQSZQSQZA58GARSWVAZ7TMPWP5JUETCV9KHQMR99E3K7MG0TWYGX +``` + +--- + +### NUT-10 HTLC Locking (kind=1) + +A payment request requiring HTLC-locked tokens with preimage tag. + +```json +{ + "i": "htlc_test", + "a": 1000, + "u": "sat", + "m": ["https://mint.example.com"], + "d": "HTLC locked payment", + "nut10": { + "k": "HTLC", + "d": "a]0e66820bfb412212cf7ab3deb0459ce282a1b04fda76ea6026a67e41ae26f3dc", + "t": [ + ["locktime", "1700000000"], + [ + "refund", + "033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e" + ] + ] + } +} +``` + +Encoded: + +``` +CREQB1QYQQJ6R5D3347AR9WD6QYQQGQQQQQQQQQQP7SQCQQYQQ2QQCDP68GURN8GHJ7MTFDE6ZUETCV9KHQMR99E3K7MGXQQF5S4ZVGVSXCMMRDDJKGGRSV9UK6ETWWSYQPTGPQQQSZQSQGFS46VR9XCMRSV3SVFNXYDP3XGERZVNRVCMKZC3NV3JKYVP5X5UKXEFJ8QEXZVTZXQ6XVERPXUMX2CFKXQERVCFKXAJNGVTPV5ERVE3NV33SXQQ5PPKX7CMTW35K6EG2XYMNQVPSXQCRQVPSQVQY5PNJV4N82MNYGGCRXVEJ8QCKXVEHXCMNWETPXGMNXETZXUCNSVMZXUURXVPKXANR2V35XSUNXVM9VCMNSEPCVVEKVVF4VGCKZDEHVD3RYDPKXQUNJCEJXEJS4EHJHC +``` + +--- + +### Custom Currency Unit + +A payment request using a custom currency unit (string encoding, not a known unit). + +```json +{ + "i": "custom_unit", + "a": 100, + "u": "btc", + "m": ["https://mint.example.com"] +} +``` + +Encoded: + +``` +CREQB1QYQQKCM4WD6X7M2LW4HXJAQZQQYQQQQQQQQQQQRYQVQQXCN5VVZSQXRGW368QUE69UHK66TWWSHX27RPD4CXCEFWVDHK6PZHCW8 +```