From dfef35d35962b4e4bba92070130e4f28d97a1fae Mon Sep 17 00:00:00 2001 From: d4rp4t Date: Tue, 26 May 2026 19:54:27 +0200 Subject: [PATCH 01/13] feat: mint strict flag in payment request --- src/model/PaymentRequest.ts | 7 +++++++ src/utils/tlv.ts | 10 ++++++++++ src/wallet/types/payment-requests.ts | 1 + 3 files changed, 18 insertions(+) diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index 511905452..fd9649138 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -28,6 +28,7 @@ export class PaymentRequest { public description?: string, public singleUse: boolean = false, public nut10?: NUT10Option, + public mintsStrict?: boolean, ) { this.amount = amount !== undefined ? Amount.from(amount) : undefined; } @@ -53,6 +54,9 @@ export class PaymentRequest { if (this.mints) { rawRequest.m = this.mints; } + if (this.mintsStrict !== undefined) { + rawRequest.ms = this.mintsStrict; + } if (this.description) { rawRequest.d = this.description; } @@ -98,6 +102,7 @@ export class PaymentRequest { unit: this.unit, singleUse: this.singleUse, mints: this.mints, + mintsStrict: this.mintsStrict, description: this.description, transports: this.transport, nut10: this.nut10 @@ -203,6 +208,7 @@ export class PaymentRequest { rawPaymentRequest.d, rawPaymentRequest.s, nut10, + rawPaymentRequest.ms, ); } @@ -229,6 +235,7 @@ export class PaymentRequest { decoded.description, decoded.singleUse ?? false, nut10, + decoded.mintsStrict, ); } diff --git a/src/utils/tlv.ts b/src/utils/tlv.ts index 1d26096c3..d5aaf0652 100644 --- a/src/utils/tlv.ts +++ b/src/utils/tlv.ts @@ -22,6 +22,7 @@ export type DecodedTLVPaymentRequest = { unit?: string; singleUse?: boolean; mints?: string[]; + mintsStrict?: boolean; description?: string; transports?: PaymentRequestTransport[]; nut10?: Nut10SpendingCondition; @@ -40,6 +41,7 @@ export type DecodedTLVPaymentRequest = { * | 0x06 | description | string | Human-readable description | * | 0x07 | transport | sub-TLV | Transport configuration (repeatable) | * | 0x08 | nut10 | sub-TLV | NUT-10 spending conditions (not yet implemented) | + * | 0x09 | mint_strict | u8 | Mint list strict flag: 0=false, 1=true; if absent, defaults to 1 | */ const TAG_ID = 0x01; const TAG_AMOUNT = 0x02; @@ -49,6 +51,7 @@ const TAG_MINT = 0x05; const TAG_DESCRIPTION = 0x06; const TAG_TRANSPORT = 0x07; const TAG_NUT10 = 0x08; +const TAG_MINT_STRICT = 0x09; /** * Transport Sub-TLV Tag definitions. @@ -139,6 +142,9 @@ export function decodeTLV(data: Uint8Array): DecodedTLVPaymentRequest { } result.nut10 = parseNut10(part.value); break; + case TAG_MINT_STRICT: + result.mintsStrict = parseU8(part.value) === 1; + break; default: // Ignore unknown tags for forward compatibility break; @@ -430,6 +436,10 @@ export function encodeTLV(request: DecodedTLVPaymentRequest): Uint8Array { parts.push(encodeTLVPart(TAG_NUT10, encodeNut10(request.nut10))); } + if (request.mintsStrict !== undefined) { + parts.push(encodeTLVPart(TAG_MINT_STRICT, encodeU8(request.mintsStrict ? 1 : 0))); + } + // Concatenate all parts const totalLength = parts.reduce((sum, part) => sum + part.length, 0); const result = new Uint8Array(totalLength); diff --git a/src/wallet/types/payment-requests.ts b/src/wallet/types/payment-requests.ts index 72b2b0af3..cffcf8cc7 100644 --- a/src/wallet/types/payment-requests.ts +++ b/src/wallet/types/payment-requests.ts @@ -18,6 +18,7 @@ export type RawPaymentRequest = { u?: string; // unit s?: boolean; // single use m?: string[]; // mints + ms?: boolean; // mints strict d?: string; // description t?: RawTransport[]; // transports nut10?: RawNUT10Option; From 965794f314b80afea9b3d1fff1dd59320ce5c090 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Thu, 28 May 2026 16:36:37 +0100 Subject: [PATCH 02/13] feat: nut-18 mint preferences (ms, fr, sm) Spec moved from cashubtc/nuts#380 to cashubtc/nuts#381, adding fee_reserve (fr) and supported_methods (sm) alongside the existing mint-strict flag. Adds an isMintListStrict resolver for the spec default-to-true semantic. --- etc/cashu-ts.api.md | 12 +++- src/model/PaymentRequest.ts | 30 ++++++++ src/utils/tlv.ts | 48 ++++++++++--- src/wallet/types/payment-requests.ts | 2 + test/utils/tlv-roundtrip.test.ts | 31 ++++++++ test/wallet/paymentRequests.test.ts | 104 +++++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 12 deletions(-) diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index aabb1d015..e32453a27 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -1563,24 +1563,31 @@ export function parseSecret(secret: string | Secret): Secret; // @public (undocumented) class PaymentRequest_2 { - constructor(transport?: PaymentRequestTransport[] | undefined, id?: string | undefined, amount?: AmountLike, unit?: string | undefined, mints?: string[] | undefined, description?: string | undefined, singleUse?: boolean, nut10?: NUT10Option | undefined); + constructor(transport?: PaymentRequestTransport[] | undefined, id?: string | undefined, amount?: AmountLike, unit?: string | undefined, mints?: string[] | undefined, description?: string | undefined, singleUse?: boolean, nut10?: NUT10Option | undefined, mintsStrict?: boolean | undefined, feeReserve?: AmountLike, supportedMethods?: string[] | undefined); // (undocumented) amount?: Amount; // (undocumented) description?: string | undefined; // (undocumented) + feeReserve?: Amount; + // (undocumented) static fromEncodedRequest(encodedRequest: string): PaymentRequest_2; static fromRawRequest(rawPaymentRequest: RawPaymentRequest): PaymentRequest_2; // (undocumented) getTransport(type: PaymentRequestTransportType): PaymentRequestTransport | undefined; // (undocumented) id?: string | undefined; + get isMintListStrict(): boolean | undefined; // (undocumented) mints?: string[] | undefined; // (undocumented) + mintsStrict?: boolean | undefined; + // (undocumented) nut10?: NUT10Option | undefined; // (undocumented) singleUse: boolean; + // (undocumented) + supportedMethods?: string[] | undefined; toEncodedCreqA(): string; toEncodedCreqB(): string; // (undocumented) @@ -1712,6 +1719,9 @@ export type RawPaymentRequest = { u?: string; s?: boolean; m?: string[]; + ms?: boolean; + fr?: number | bigint; + sm?: string[]; d?: string; t?: RawTransport[]; nut10?: RawNUT10Option; diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index fd9649138..ee2a1bcc6 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -18,6 +18,7 @@ import { CTSError } from './Errors'; export class PaymentRequest { public amount?: Amount; + public feeReserve?: Amount; constructor( public transport?: PaymentRequestTransport[], @@ -29,8 +30,25 @@ export class PaymentRequest { public singleUse: boolean = false, public nut10?: NUT10Option, public mintsStrict?: boolean, + feeReserve?: AmountLike, + public supportedMethods?: string[], ) { this.amount = amount !== undefined ? Amount.from(amount) : undefined; + this.feeReserve = feeReserve !== undefined ? Amount.from(feeReserve) : undefined; + } + + /** + * Resolves the NUT-18 mint list strictness per spec. + * + * - `undefined` if no mint list is set (`ms` and `fr` SHOULD be ignored) + * - `true` if the list is strict (`ms` absent or `true`) + * - `false` if the list is preferred (`ms === false`) + */ + get isMintListStrict(): boolean | undefined { + if (!this.mints?.length) { + return undefined; + } + return this.mintsStrict !== false; } toRawRequest() { @@ -57,6 +75,12 @@ export class PaymentRequest { if (this.mintsStrict !== undefined) { rawRequest.ms = this.mintsStrict; } + if (this.feeReserve) { + rawRequest.fr = this.feeReserve.toBigInt(); + } + if (this.supportedMethods && this.supportedMethods.length > 0) { + rawRequest.sm = this.supportedMethods; + } if (this.description) { rawRequest.d = this.description; } @@ -103,6 +127,8 @@ export class PaymentRequest { singleUse: this.singleUse, mints: this.mints, mintsStrict: this.mintsStrict, + feeReserve: this.feeReserve !== undefined ? this.feeReserve.toBigInt() : undefined, + supportedMethods: this.supportedMethods, description: this.description, transports: this.transport, nut10: this.nut10 @@ -209,6 +235,8 @@ export class PaymentRequest { rawPaymentRequest.s, nut10, rawPaymentRequest.ms, + rawPaymentRequest.fr, + rawPaymentRequest.sm, ); } @@ -236,6 +264,8 @@ export class PaymentRequest { decoded.singleUse ?? false, nut10, decoded.mintsStrict, + decoded.feeReserve, + decoded.supportedMethods, ); } diff --git a/src/utils/tlv.ts b/src/utils/tlv.ts index d5aaf0652..8336c9e99 100644 --- a/src/utils/tlv.ts +++ b/src/utils/tlv.ts @@ -23,6 +23,8 @@ export type DecodedTLVPaymentRequest = { singleUse?: boolean; mints?: string[]; mintsStrict?: boolean; + feeReserve?: bigint; + supportedMethods?: string[]; description?: string; transports?: PaymentRequestTransport[]; nut10?: Nut10SpendingCondition; @@ -31,17 +33,19 @@ export type DecodedTLVPaymentRequest = { /** * TLV Tag definitions for Payment Request (NUT-18 version B). * - * | Tag | Field | Type | Description | - * | ---- | ----------- | --------- | ------------------------------------------------ | - * | 0x01 | id | string | Payment identifier | - * | 0x02 | amount | u64 | Amount in base units | - * | 0x03 | unit | u8/string | Currency unit (0x00 = 'sat') | - * | 0x04 | single_use | u8 | Single-use flag: 0=false, 1=true | - * | 0x05 | mint | string | Mint URL (repeatable) | - * | 0x06 | description | string | Human-readable description | - * | 0x07 | transport | sub-TLV | Transport configuration (repeatable) | - * | 0x08 | nut10 | sub-TLV | NUT-10 spending conditions (not yet implemented) | - * | 0x09 | mint_strict | u8 | Mint list strict flag: 0=false, 1=true; if absent, defaults to 1 | + * | Tag | Field | Type | Description | + * | ---- | ----------------- | --------- | --------------------------------------------------------------------------------------------- | + * | 0x01 | id | string | Payment identifier | + * | 0x02 | amount | u64 | Amount in base units | + * | 0x03 | unit | u8/string | Currency unit (0x00 = 'sat') | + * | 0x04 | single_use | u8 | Single-use flag: 0=false, 1=true | + * | 0x05 | mint | string | Mint URL (repeatable) | + * | 0x06 | description | string | Human-readable description | + * | 0x07 | transport | sub-TLV | Transport configuration (repeatable) | + * | 0x08 | nut10 | sub-TLV | NUT-10 spending conditions (not yet implemented) | + * | 0x09 | mint_strict | u8 | Mint list strict flag: 0=false, 1=true; if absent, defaults to 1 | + * | 0x0a | fee_reserve | u64 | Additional fee reserve, in the request unit, when paying from a mint outside `mint` list | + * | 0x0b | supported_methods | string | Payment method the sending mint must support, e.g. "bolt11", "bolt12", "onchain" (repeatable) | */ const TAG_ID = 0x01; const TAG_AMOUNT = 0x02; @@ -52,6 +56,8 @@ const TAG_DESCRIPTION = 0x06; const TAG_TRANSPORT = 0x07; const TAG_NUT10 = 0x08; const TAG_MINT_STRICT = 0x09; +const TAG_FEE_RESERVE = 0x0a; +const TAG_SUPPORTED_METHODS = 0x0b; /** * Transport Sub-TLV Tag definitions. @@ -145,6 +151,15 @@ export function decodeTLV(data: Uint8Array): DecodedTLVPaymentRequest { case TAG_MINT_STRICT: result.mintsStrict = parseU8(part.value) === 1; break; + case TAG_FEE_RESERVE: + result.feeReserve = parseU64(part.value); + break; + case TAG_SUPPORTED_METHODS: + if (!result.supportedMethods) { + result.supportedMethods = []; + } + result.supportedMethods.push(parseString(part.value)); + break; default: // Ignore unknown tags for forward compatibility break; @@ -440,6 +455,17 @@ export function encodeTLV(request: DecodedTLVPaymentRequest): Uint8Array { parts.push(encodeTLVPart(TAG_MINT_STRICT, encodeU8(request.mintsStrict ? 1 : 0))); } + if (request.feeReserve !== undefined) { + parts.push(encodeTLVPart(TAG_FEE_RESERVE, encodeU64(request.feeReserve))); + } + + // Repeatable: supported_methods + if (request.supportedMethods && request.supportedMethods.length > 0) { + for (const method of request.supportedMethods) { + parts.push(encodeTLVPart(TAG_SUPPORTED_METHODS, encodeString(method))); + } + } + // Concatenate all parts const totalLength = parts.reduce((sum, part) => sum + part.length, 0); const result = new Uint8Array(totalLength); diff --git a/src/wallet/types/payment-requests.ts b/src/wallet/types/payment-requests.ts index cffcf8cc7..7f1d09a9f 100644 --- a/src/wallet/types/payment-requests.ts +++ b/src/wallet/types/payment-requests.ts @@ -19,6 +19,8 @@ export type RawPaymentRequest = { s?: boolean; // single use m?: string[]; // mints ms?: boolean; // mints strict + fr?: number | bigint; // fee reserve (additional, in request unit, when paying from a non-strict-list mint) + sm?: string[]; // supported payment methods the sending mint must support (e.g. "bolt11", "bolt12", "onchain") d?: string; // description t?: RawTransport[]; // transports nut10?: RawNUT10Option; diff --git a/test/utils/tlv-roundtrip.test.ts b/test/utils/tlv-roundtrip.test.ts index 1979ad68d..d86cccb4a 100644 --- a/test/utils/tlv-roundtrip.test.ts +++ b/test/utils/tlv-roundtrip.test.ts @@ -357,6 +357,37 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { }); }); + describe('Preferred Mint List with Fee Reserve and Supported Methods', () => { + // NUT-26 spec test vector — payment request with ms=false (preferred mint list), + // a fee reserve for non-preferred mints, and required mint payment methods. + const encoded = + 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGQPGQQSQQQQQQQQQQQQG9SQPNZDAK8GVF3PVQQVCN0D36RZVSUP24PH'; + + test('roundtrip preferred mint list with fee reserve and supported methods', () => { + testRoundtrip(encoded, 'NUT-26 spec vector: ms=false, fr=2, sm=[bolt11, bolt12]'); + }); + + test('verify ms, fr, sm fields', () => { + const bytes = decodeBech32mToBytes(encoded.toLowerCase()); + const decoded = decodeTLV(bytes); + + expect(decoded.id).toBe('preferred_fee_methods'); + expect(decoded.amount).toBe(BigInt(100)); + expect(decoded.unit).toBe('sat'); + expect(decoded.mints).toEqual(['https://mint.example.com']); + expect(decoded.mintsStrict).toBe(false); + expect(decoded.feeReserve).toBe(BigInt(2)); + expect(decoded.supportedMethods).toEqual(['bolt11', 'bolt12']); + + const reEncoded = encodeTLV(decoded); + const finalDecoded = decodeTLV(reEncoded); + + expect(finalDecoded.mintsStrict).toBe(false); + expect(finalDecoded.feeReserve).toBe(BigInt(2)); + expect(finalDecoded.supportedMethods).toEqual(['bolt11', 'bolt12']); + }); + }); + describe('Custom Currency Unit', () => { const encoded = 'CREQB1QYQQKCM4WD6X7M2LW4HXJAQZQQYQQQQQQQQQQQRYQVQQXCN5VVZSQXRGW368QUE69UHK66TWWSHX27RPD4CXCEFWVDHK6PZHCW8'; diff --git a/test/wallet/paymentRequests.test.ts b/test/wallet/paymentRequests.test.ts index c7f956a8c..793345115 100644 --- a/test/wallet/paymentRequests.test.ts +++ b/test/wallet/paymentRequests.test.ts @@ -111,6 +111,110 @@ describe('payment requests', () => { expect(() => decodePaymentRequest(prWithInvalidVersion)).toThrow('unsupported pr version'); }); + describe('mint preferences (ms, fr, sm)', () => { + test('encode/decode preferred mint list with fee reserve and supported methods (creqA)', () => { + const request = new PaymentRequest( + undefined, + 'preferred_fee_methods', + 100, + 'sat', + ['https://mint.example.com'], + undefined, + false, + undefined, + false, // mintsStrict + 2, // feeReserve + ['bolt11', 'bolt12'], // supportedMethods + ); + + const pr = request.toEncodedRequest(); + const decoded = decodePaymentRequest(pr); + + expect(decoded.mintsStrict).toBe(false); + expect(decoded.feeReserve?.equals(2)).toBeTruthy(); + expect(decoded.supportedMethods).toEqual(['bolt11', 'bolt12']); + }); + + test('encode/decode preferred mint list with fee reserve and supported methods (creqB)', () => { + const request = new PaymentRequest( + undefined, + 'preferred_fee_methods', + 100, + 'sat', + ['https://mint.example.com'], + undefined, + false, + undefined, + false, + 2, + ['bolt11', 'bolt12'], + ); + + const encoded = request.toEncodedCreqB(); + const decoded = PaymentRequest.fromEncodedRequest(encoded); + + expect(decoded.mintsStrict).toBe(false); + expect(decoded.feeReserve?.equals(2)).toBeTruthy(); + expect(decoded.supportedMethods).toEqual(['bolt11', 'bolt12']); + }); + + test('isMintListStrict resolves NUT-18 default-to-true semantic', () => { + const noMints = new PaymentRequest(undefined, 'no_mints', 100, 'sat'); + expect(noMints.isMintListStrict).toBeUndefined(); + + const mintsOnly = new PaymentRequest(undefined, 'mints_only', 100, 'sat', [ + 'https://mint.example.com', + ]); + expect(mintsOnly.isMintListStrict).toBe(true); + + const explicitStrict = new PaymentRequest( + undefined, + 'explicit_strict', + 100, + 'sat', + ['https://mint.example.com'], + undefined, + false, + undefined, + true, + ); + expect(explicitStrict.isMintListStrict).toBe(true); + + const preferred = new PaymentRequest( + undefined, + 'preferred', + 100, + 'sat', + ['https://mint.example.com'], + undefined, + false, + undefined, + false, + ); + expect(preferred.isMintListStrict).toBe(false); + + // Decoded request with mints set and ms absent — should resolve to strict + const fromWire = decodePaymentRequest(mintsOnly.toEncodedRequest()); + expect(fromWire.mintsStrict).toBeUndefined(); + expect(fromWire.isMintListStrict).toBe(true); + }); + + test('ms/fr/sm absent by default (no serialization, no defaults injected)', () => { + const request = new PaymentRequest(undefined, 'no_prefs', 100, 'sat', [ + 'https://mint.example.com', + ]); + const raw = request.toRawRequest(); + expect(raw.ms).toBeUndefined(); + expect(raw.fr).toBeUndefined(); + expect(raw.sm).toBeUndefined(); + + const decoded = decodePaymentRequest(request.toEncodedRequest()); + expect(decoded.mintsStrict).toBeUndefined(); + expect(decoded.feeReserve).toBeUndefined(); + expect(decoded.supportedMethods).toBeUndefined(); + }); + }); + describe('toEncodedCreqB - creqB format (TLV + bech32m)', () => { test('encode and decode basic payment request with nostr transport', () => { const pr = new PaymentRequest( From e51c5a93b47324627e7c7e5d9ef5ab7a325d57e8 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Tue, 23 Jun 2026 23:12:41 +0100 Subject: [PATCH 03/13] fix(payment-request): coerce mintsStrict to boolean to prevent type confusion An untyped falsy CBOR value (ms: 0 or ms: null) read as strict via the isMintListStrict getter's `!== false` check while serializing to false over TLV, diverging across creqA/creqB. Coerce to a real boolean at construction so every representation agrees. --- etc/cashu-ts.api.md | 4 ++-- src/model/PaymentRequest.ts | 6 +++++- test/wallet/paymentRequests.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index e32453a27..d0e34fa36 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -1563,7 +1563,7 @@ export function parseSecret(secret: string | Secret): Secret; // @public (undocumented) class PaymentRequest_2 { - constructor(transport?: PaymentRequestTransport[] | undefined, id?: string | undefined, amount?: AmountLike, unit?: string | undefined, mints?: string[] | undefined, description?: string | undefined, singleUse?: boolean, nut10?: NUT10Option | undefined, mintsStrict?: boolean | undefined, feeReserve?: AmountLike, supportedMethods?: string[] | undefined); + constructor(transport?: PaymentRequestTransport[] | undefined, id?: string | undefined, amount?: AmountLike, unit?: string | undefined, mints?: string[] | undefined, description?: string | undefined, singleUse?: boolean, nut10?: NUT10Option | undefined, mintsStrict?: boolean, feeReserve?: AmountLike, supportedMethods?: string[] | undefined); // (undocumented) amount?: Amount; // (undocumented) @@ -1581,7 +1581,7 @@ class PaymentRequest_2 { // (undocumented) mints?: string[] | undefined; // (undocumented) - mintsStrict?: boolean | undefined; + mintsStrict?: boolean; // (undocumented) nut10?: NUT10Option | undefined; // (undocumented) diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index ee2a1bcc6..f94495cf0 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -19,6 +19,7 @@ import { CTSError } from './Errors'; export class PaymentRequest { public amount?: Amount; public feeReserve?: Amount; + public mintsStrict?: boolean; constructor( public transport?: PaymentRequestTransport[], @@ -29,12 +30,15 @@ export class PaymentRequest { public description?: string, public singleUse: boolean = false, public nut10?: NUT10Option, - public mintsStrict?: boolean, + mintsStrict?: boolean, feeReserve?: AmountLike, public supportedMethods?: string[], ) { this.amount = amount !== undefined ? Amount.from(amount) : undefined; this.feeReserve = feeReserve !== undefined ? Amount.from(feeReserve) : undefined; + // Coerce to a real boolean so an untyped falsy CBOR value (`0`/`null`) + // can't read strict via the getter yet serialize false over TLV. + this.mintsStrict = mintsStrict === undefined ? undefined : Boolean(mintsStrict); } /** diff --git a/test/wallet/paymentRequests.test.ts b/test/wallet/paymentRequests.test.ts index 793345115..8603e0581 100644 --- a/test/wallet/paymentRequests.test.ts +++ b/test/wallet/paymentRequests.test.ts @@ -199,6 +199,34 @@ describe('payment requests', () => { expect(fromWire.isMintListStrict).toBe(true); }); + test('non-boolean falsy ms is coerced (no cross-format type confusion)', () => { + // An untyped CBOR producer might emit `ms: 0`/`ms: null` to mean + // "preferred". Coercion must normalize it to a genuine boolean so the + // getter and TLV serialization agree rather than diverging. + const fromZero = PaymentRequest.fromRawRequest({ + i: 'zero', + a: 100, + u: 'sat', + m: ['https://mint.example.com'], + ms: 0 as unknown as boolean, + }); + expect(fromZero.mintsStrict).toBe(false); + expect(fromZero.isMintListStrict).toBe(false); + // Round-trips through both formats without flipping strictness. + expect(decodePaymentRequest(fromZero.toEncodedCreqA()).isMintListStrict).toBe(false); + expect(decodePaymentRequest(fromZero.toEncodedCreqB()).isMintListStrict).toBe(false); + + const fromNull = PaymentRequest.fromRawRequest({ + i: 'null', + a: 100, + u: 'sat', + m: ['https://mint.example.com'], + ms: null as unknown as boolean, + }); + expect(fromNull.mintsStrict).toBe(false); + expect(fromNull.isMintListStrict).toBe(false); + }); + test('ms/fr/sm absent by default (no serialization, no defaults injected)', () => { const request = new PaymentRequest(undefined, 'no_prefs', 100, 'sat', [ 'https://mint.example.com', From 73705adc654f30a715809cde02f86779239900a5 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Sat, 27 Jun 2026 00:05:19 +0100 Subject: [PATCH 04/13] refactor(payment-request)!: flip ms->mp (mint_preferred), make single_use tri-state Spec NUT-18/NUT-26 replaced `ms` (mint_strict) with `mp` (mint_preferred), inverting the default: a strict mint list is the omitted default, an advisory list is the explicit flag. Rename the field, TLV tag 0x09, and the `isMintListStrict` getter to match; strict now resolves from `mp` absent or false. Also make `single_use` tri-state optional (absent/false/true) like `mp`, defensively coerce both flags to real booleans so an untyped CBOR value can't leak a non-boolean, and pin the creqA/creqB spec vectors byte-for-byte. --- etc/cashu-ts.api.md | 8 +-- migration-5.0.0.md | 6 +++ src/model/PaymentRequest.ts | 37 +++++++------ src/utils/tlv.ts | 14 ++--- src/wallet/types/payment-requests.ts | 4 +- test/utils/tlv-roundtrip.test.ts | 14 ++--- test/wallet/paymentRequests.test.ts | 79 ++++++++++++++++------------ 7 files changed, 92 insertions(+), 70 deletions(-) diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index d0e34fa36..47fbb2798 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -1563,7 +1563,7 @@ export function parseSecret(secret: string | Secret): Secret; // @public (undocumented) class PaymentRequest_2 { - constructor(transport?: PaymentRequestTransport[] | undefined, id?: string | undefined, amount?: AmountLike, unit?: string | undefined, mints?: string[] | undefined, description?: string | undefined, singleUse?: boolean, nut10?: NUT10Option | undefined, mintsStrict?: boolean, feeReserve?: AmountLike, supportedMethods?: string[] | undefined); + constructor(transport?: PaymentRequestTransport[] | undefined, id?: string | undefined, amount?: AmountLike, unit?: string | undefined, mints?: string[] | undefined, description?: string | undefined, singleUse?: boolean, nut10?: NUT10Option | undefined, mintsPreferred?: boolean, feeReserve?: AmountLike, supportedMethods?: string[] | undefined); // (undocumented) amount?: Amount; // (undocumented) @@ -1581,11 +1581,11 @@ class PaymentRequest_2 { // (undocumented) mints?: string[] | undefined; // (undocumented) - mintsStrict?: boolean; + mintsPreferred?: boolean; // (undocumented) nut10?: NUT10Option | undefined; // (undocumented) - singleUse: boolean; + singleUse?: boolean; // (undocumented) supportedMethods?: string[] | undefined; toEncodedCreqA(): string; @@ -1719,7 +1719,7 @@ export type RawPaymentRequest = { u?: string; s?: boolean; m?: string[]; - ms?: boolean; + mp?: boolean; fr?: number | bigint; sm?: string[]; d?: string; diff --git a/migration-5.0.0.md b/migration-5.0.0.md index 8ccf9d793..573697daf 100644 --- a/migration-5.0.0.md +++ b/migration-5.0.0.md @@ -198,3 +198,9 @@ await wallet.completeMelt(meltPreview, privkey, { preferAsync: true }); ``` Calls that already pass a `CompleteMeltOptions` object (or omit the third argument) need no change. + +--- + +## `PaymentRequest.singleUse` is now optional (tri-state) + +`PaymentRequest.singleUse` is now `boolean | undefined` (was a required `boolean` defaulting to `false`), so the flag can round-trip the absent/`false`/`true` distinction instead of always serializing `single_use=0`. Setting `false` or `true` is unchanged; only decoding shifts — a request that omits the flag now yields `singleUse: undefined` instead of `false`. Replace any `pr.singleUse === false` check with `!pr.singleUse` (true for both absent and explicit `false`). diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index f94495cf0..bd4f08719 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -19,7 +19,8 @@ import { CTSError } from './Errors'; export class PaymentRequest { public amount?: Amount; public feeReserve?: Amount; - public mintsStrict?: boolean; + public singleUse?: boolean; + public mintsPreferred?: boolean; constructor( public transport?: PaymentRequestTransport[], @@ -28,31 +29,33 @@ export class PaymentRequest { public unit?: string, public mints?: string[], public description?: string, - public singleUse: boolean = false, + singleUse?: boolean, public nut10?: NUT10Option, - mintsStrict?: boolean, + mintsPreferred?: boolean, feeReserve?: AmountLike, public supportedMethods?: string[], ) { this.amount = amount !== undefined ? Amount.from(amount) : undefined; this.feeReserve = feeReserve !== undefined ? Amount.from(feeReserve) : undefined; - // Coerce to a real boolean so an untyped falsy CBOR value (`0`/`null`) - // can't read strict via the getter yet serialize false over TLV. - this.mintsStrict = mintsStrict === undefined ? undefined : Boolean(mintsStrict); + // Coerce the optional flags to real booleans (preserving `undefined` for the + // absent/tri-state case) so an untyped CBOR value (`0`/`1`/`null`) can't leak a + // non-boolean into the getter or get re-serialized verbatim over the wire. + this.singleUse = singleUse === undefined ? undefined : Boolean(singleUse); + this.mintsPreferred = mintsPreferred === undefined ? undefined : Boolean(mintsPreferred); } /** * Resolves the NUT-18 mint list strictness per spec. * - * - `undefined` if no mint list is set (`ms` and `fr` SHOULD be ignored) - * - `true` if the list is strict (`ms` absent or `true`) - * - `false` if the list is preferred (`ms === false`) + * - `undefined` if no mint list is set (`mp` and `fr` SHOULD be ignored) + * - `true` if the list is strict (`mp` absent or `false`) + * - `false` if the list is preferred/advisory (`mp === true`) */ get isMintListStrict(): boolean | undefined { if (!this.mints?.length) { return undefined; } - return this.mintsStrict !== false; + return this.mintsPreferred !== true; } toRawRequest() { @@ -76,8 +79,8 @@ export class PaymentRequest { if (this.mints) { rawRequest.m = this.mints; } - if (this.mintsStrict !== undefined) { - rawRequest.ms = this.mintsStrict; + if (this.mintsPreferred !== undefined) { + rawRequest.mp = this.mintsPreferred; } if (this.feeReserve) { rawRequest.fr = this.feeReserve.toBigInt(); @@ -88,7 +91,7 @@ export class PaymentRequest { if (this.description) { rawRequest.d = this.description; } - if (this.singleUse) { + if (this.singleUse !== undefined) { rawRequest.s = this.singleUse; } if (this.nut10) { @@ -130,7 +133,7 @@ export class PaymentRequest { unit: this.unit, singleUse: this.singleUse, mints: this.mints, - mintsStrict: this.mintsStrict, + mintsPreferred: this.mintsPreferred, feeReserve: this.feeReserve !== undefined ? this.feeReserve.toBigInt() : undefined, supportedMethods: this.supportedMethods, description: this.description, @@ -238,7 +241,7 @@ export class PaymentRequest { rawPaymentRequest.d, rawPaymentRequest.s, nut10, - rawPaymentRequest.ms, + rawPaymentRequest.mp, rawPaymentRequest.fr, rawPaymentRequest.sm, ); @@ -265,9 +268,9 @@ export class PaymentRequest { decoded.unit, decoded.mints, decoded.description, - decoded.singleUse ?? false, + decoded.singleUse, nut10, - decoded.mintsStrict, + decoded.mintsPreferred, decoded.feeReserve, decoded.supportedMethods, ); diff --git a/src/utils/tlv.ts b/src/utils/tlv.ts index 8336c9e99..81792cbce 100644 --- a/src/utils/tlv.ts +++ b/src/utils/tlv.ts @@ -22,7 +22,7 @@ export type DecodedTLVPaymentRequest = { unit?: string; singleUse?: boolean; mints?: string[]; - mintsStrict?: boolean; + mintsPreferred?: boolean; feeReserve?: bigint; supportedMethods?: string[]; description?: string; @@ -43,7 +43,7 @@ export type DecodedTLVPaymentRequest = { * | 0x06 | description | string | Human-readable description | * | 0x07 | transport | sub-TLV | Transport configuration (repeatable) | * | 0x08 | nut10 | sub-TLV | NUT-10 spending conditions (not yet implemented) | - * | 0x09 | mint_strict | u8 | Mint list strict flag: 0=false, 1=true; if absent, defaults to 1 | + * | 0x09 | mint_preferred | u8 | Mint list strictness flag: 0=false, 1=true; if absent, defaults to 0 (strict) | * | 0x0a | fee_reserve | u64 | Additional fee reserve, in the request unit, when paying from a mint outside `mint` list | * | 0x0b | supported_methods | string | Payment method the sending mint must support, e.g. "bolt11", "bolt12", "onchain" (repeatable) | */ @@ -55,7 +55,7 @@ const TAG_MINT = 0x05; const TAG_DESCRIPTION = 0x06; const TAG_TRANSPORT = 0x07; const TAG_NUT10 = 0x08; -const TAG_MINT_STRICT = 0x09; +const TAG_MINT_PREFERRED = 0x09; const TAG_FEE_RESERVE = 0x0a; const TAG_SUPPORTED_METHODS = 0x0b; @@ -148,8 +148,8 @@ export function decodeTLV(data: Uint8Array): DecodedTLVPaymentRequest { } result.nut10 = parseNut10(part.value); break; - case TAG_MINT_STRICT: - result.mintsStrict = parseU8(part.value) === 1; + case TAG_MINT_PREFERRED: + result.mintsPreferred = parseU8(part.value) === 1; break; case TAG_FEE_RESERVE: result.feeReserve = parseU64(part.value); @@ -451,8 +451,8 @@ export function encodeTLV(request: DecodedTLVPaymentRequest): Uint8Array { parts.push(encodeTLVPart(TAG_NUT10, encodeNut10(request.nut10))); } - if (request.mintsStrict !== undefined) { - parts.push(encodeTLVPart(TAG_MINT_STRICT, encodeU8(request.mintsStrict ? 1 : 0))); + if (request.mintsPreferred !== undefined) { + parts.push(encodeTLVPart(TAG_MINT_PREFERRED, encodeU8(request.mintsPreferred ? 1 : 0))); } if (request.feeReserve !== undefined) { diff --git a/src/wallet/types/payment-requests.ts b/src/wallet/types/payment-requests.ts index 7f1d09a9f..1a1b349d7 100644 --- a/src/wallet/types/payment-requests.ts +++ b/src/wallet/types/payment-requests.ts @@ -18,8 +18,8 @@ export type RawPaymentRequest = { u?: string; // unit s?: boolean; // single use m?: string[]; // mints - ms?: boolean; // mints strict - fr?: number | bigint; // fee reserve (additional, in request unit, when paying from a non-strict-list mint) + mp?: boolean; // mints preferred: strict list when absent or false, advisory list when true + fr?: number | bigint; // fee reserve (additional, in request unit, when paying from a mint outside a preferred list) sm?: string[]; // supported payment methods the sending mint must support (e.g. "bolt11", "bolt12", "onchain") d?: string; // description t?: RawTransport[]; // transports diff --git a/test/utils/tlv-roundtrip.test.ts b/test/utils/tlv-roundtrip.test.ts index d86cccb4a..be618208a 100644 --- a/test/utils/tlv-roundtrip.test.ts +++ b/test/utils/tlv-roundtrip.test.ts @@ -358,16 +358,16 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { }); describe('Preferred Mint List with Fee Reserve and Supported Methods', () => { - // NUT-26 spec test vector — payment request with ms=false (preferred mint list), - // a fee reserve for non-preferred mints, and required mint payment methods. + // NUT-26 spec test vector — payment request with mp=true (preferred/advisory + // mint list), a fee reserve for non-preferred mints, and required mint methods. const encoded = - 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGQPGQQSQQQQQQQQQQQQG9SQPNZDAK8GVF3PVQQVCN0D36RZVSUP24PH'; + 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQSQQQQQQQQQQQQG9SQPNZDAK8GVF3PVQQVCN0D36RZVSDWZJ5Y'; test('roundtrip preferred mint list with fee reserve and supported methods', () => { - testRoundtrip(encoded, 'NUT-26 spec vector: ms=false, fr=2, sm=[bolt11, bolt12]'); + testRoundtrip(encoded, 'NUT-26 spec vector: mp=true, fr=2, sm=[bolt11, bolt12]'); }); - test('verify ms, fr, sm fields', () => { + test('verify mp, fr, sm fields', () => { const bytes = decodeBech32mToBytes(encoded.toLowerCase()); const decoded = decodeTLV(bytes); @@ -375,14 +375,14 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { expect(decoded.amount).toBe(BigInt(100)); expect(decoded.unit).toBe('sat'); expect(decoded.mints).toEqual(['https://mint.example.com']); - expect(decoded.mintsStrict).toBe(false); + expect(decoded.mintsPreferred).toBe(true); expect(decoded.feeReserve).toBe(BigInt(2)); expect(decoded.supportedMethods).toEqual(['bolt11', 'bolt12']); const reEncoded = encodeTLV(decoded); const finalDecoded = decodeTLV(reEncoded); - expect(finalDecoded.mintsStrict).toBe(false); + expect(finalDecoded.mintsPreferred).toBe(true); expect(finalDecoded.feeReserve).toBe(BigInt(2)); expect(finalDecoded.supportedMethods).toEqual(['bolt11', 'bolt12']); }); diff --git a/test/wallet/paymentRequests.test.ts b/test/wallet/paymentRequests.test.ts index 8603e0581..21e9620fc 100644 --- a/test/wallet/paymentRequests.test.ts +++ b/test/wallet/paymentRequests.test.ts @@ -111,7 +111,16 @@ describe('payment requests', () => { expect(() => decodePaymentRequest(prWithInvalidVersion)).toThrow('unsupported pr version'); }); - describe('mint preferences (ms, fr, sm)', () => { + describe('mint preferences (mp, fr, sm)', () => { + // NUT-18/NUT-26 spec vector: preferred mint list (mp=true) with fee reserve and + // supported methods. single_use is absent, so neither encoding emits it. Both strings + // are pinned to lock canonical output: minimal CBOR (creqA, `a7` not `b9 0007`) and + // minimal TLV with no redundant single_use=0 (creqB). + const SPEC_CREQA = + 'creqAp2FpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWJtcPViZnICYnNtgmZib2x0MTFmYm9sdDEy'; + const SPEC_CREQB = + 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQSQQQQQQQQQQQQG9SQPNZDAK8GVF3PVQQVCN0D36RZVSDWZJ5Y'; + test('encode/decode preferred mint list with fee reserve and supported methods (creqA)', () => { const request = new PaymentRequest( undefined, @@ -120,17 +129,18 @@ describe('payment requests', () => { 'sat', ['https://mint.example.com'], undefined, - false, + undefined, // singleUse absent undefined, - false, // mintsStrict + true, // mintsPreferred (advisory list) 2, // feeReserve ['bolt11', 'bolt12'], // supportedMethods ); const pr = request.toEncodedRequest(); - const decoded = decodePaymentRequest(pr); + expect(pr).toBe(SPEC_CREQA); - expect(decoded.mintsStrict).toBe(false); + const decoded = decodePaymentRequest(pr); + expect(decoded.mintsPreferred).toBe(true); expect(decoded.feeReserve?.equals(2)).toBeTruthy(); expect(decoded.supportedMethods).toEqual(['bolt11', 'bolt12']); }); @@ -143,22 +153,23 @@ describe('payment requests', () => { 'sat', ['https://mint.example.com'], undefined, - false, + undefined, // singleUse absent undefined, - false, + true, 2, ['bolt11', 'bolt12'], ); const encoded = request.toEncodedCreqB(); - const decoded = PaymentRequest.fromEncodedRequest(encoded); + expect(encoded).toBe(SPEC_CREQB); - expect(decoded.mintsStrict).toBe(false); + const decoded = PaymentRequest.fromEncodedRequest(encoded); + expect(decoded.mintsPreferred).toBe(true); expect(decoded.feeReserve?.equals(2)).toBeTruthy(); expect(decoded.supportedMethods).toEqual(['bolt11', 'bolt12']); }); - test('isMintListStrict resolves NUT-18 default-to-true semantic', () => { + test('isMintListStrict resolves NUT-18 default-to-strict semantic', () => { const noMints = new PaymentRequest(undefined, 'no_mints', 100, 'sat'); expect(noMints.isMintListStrict).toBeUndefined(); @@ -176,7 +187,7 @@ describe('payment requests', () => { undefined, false, undefined, - true, + false, // mintsPreferred === false is strict ); expect(explicitStrict.isMintListStrict).toBe(true); @@ -189,55 +200,57 @@ describe('payment requests', () => { undefined, false, undefined, - false, + true, // mintsPreferred === true is advisory ); expect(preferred.isMintListStrict).toBe(false); - // Decoded request with mints set and ms absent — should resolve to strict + // Decoded request with mints set and mp absent — should resolve to strict const fromWire = decodePaymentRequest(mintsOnly.toEncodedRequest()); - expect(fromWire.mintsStrict).toBeUndefined(); + expect(fromWire.mintsPreferred).toBeUndefined(); expect(fromWire.isMintListStrict).toBe(true); }); - test('non-boolean falsy ms is coerced (no cross-format type confusion)', () => { - // An untyped CBOR producer might emit `ms: 0`/`ms: null` to mean - // "preferred". Coercion must normalize it to a genuine boolean so the - // getter and TLV serialization agree rather than diverging. - const fromZero = PaymentRequest.fromRawRequest({ - i: 'zero', + test('non-boolean truthy mp is coerced (no cross-format type confusion)', () => { + // An untyped CBOR producer might emit `mp: 1` to mean "preferred". + // Coercion must normalize it to a genuine boolean so the getter + // (`mintsPreferred !== true`) and TLV serialization agree rather than + // diverging — a raw `1` would read strict via the getter yet serialize + // preferred over TLV. + const fromOne = PaymentRequest.fromRawRequest({ + i: 'one', a: 100, u: 'sat', m: ['https://mint.example.com'], - ms: 0 as unknown as boolean, + mp: 1 as unknown as boolean, }); - expect(fromZero.mintsStrict).toBe(false); - expect(fromZero.isMintListStrict).toBe(false); + expect(fromOne.mintsPreferred).toBe(true); + expect(fromOne.isMintListStrict).toBe(false); // Round-trips through both formats without flipping strictness. - expect(decodePaymentRequest(fromZero.toEncodedCreqA()).isMintListStrict).toBe(false); - expect(decodePaymentRequest(fromZero.toEncodedCreqB()).isMintListStrict).toBe(false); + expect(decodePaymentRequest(fromOne.toEncodedCreqA()).isMintListStrict).toBe(false); + expect(decodePaymentRequest(fromOne.toEncodedCreqB()).isMintListStrict).toBe(false); - const fromNull = PaymentRequest.fromRawRequest({ - i: 'null', + const fromZero = PaymentRequest.fromRawRequest({ + i: 'zero', a: 100, u: 'sat', m: ['https://mint.example.com'], - ms: null as unknown as boolean, + mp: 0 as unknown as boolean, }); - expect(fromNull.mintsStrict).toBe(false); - expect(fromNull.isMintListStrict).toBe(false); + expect(fromZero.mintsPreferred).toBe(false); + expect(fromZero.isMintListStrict).toBe(true); }); - test('ms/fr/sm absent by default (no serialization, no defaults injected)', () => { + test('mp/fr/sm absent by default (no serialization, no defaults injected)', () => { const request = new PaymentRequest(undefined, 'no_prefs', 100, 'sat', [ 'https://mint.example.com', ]); const raw = request.toRawRequest(); - expect(raw.ms).toBeUndefined(); + expect(raw.mp).toBeUndefined(); expect(raw.fr).toBeUndefined(); expect(raw.sm).toBeUndefined(); const decoded = decodePaymentRequest(request.toEncodedRequest()); - expect(decoded.mintsStrict).toBeUndefined(); + expect(decoded.mintsPreferred).toBeUndefined(); expect(decoded.feeReserve).toBeUndefined(); expect(decoded.supportedMethods).toBeUndefined(); }); From 7ecf99ddbb035fc5a4d3e9e5445d26cab02f6b60 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Wed, 1 Jul 2026 21:12:02 +0100 Subject: [PATCH 05/13] feat(payment-request): per-method fee (mn/mf) on supported_methods Track the NUT-18/NUT-26 update: sm entries become {mn, mf?} objects carrying an optional per-method fee that stacks additively with the top-level fr (non-preferred-mint fee). - RawSupportedMethod {mn, mf} wire type; model SupportedMethod {method, fee?: Amount} mirroring feeReserve's Amount treatment - NUT-26 tag 0x0b promoted to sub-TLV (0x01 method, 0x02 fee u64) with encode/parseSupportedMethod; duplicate sub-tags rejected - PaymentRequest.amountToSend(mint, method?) sums the applicable fr + mf fees; does not validate admissibility (caller's concern) - update pinned creqA/creqB spec vectors; both byte-match the NUT vectors --- src/index.ts | 2 + src/model/PaymentRequest.ts | 58 +++++++++++++- src/utils/tlv.ts | 108 ++++++++++++++++++++++----- src/wallet/types/payment-requests.ts | 17 ++++- test/utils/tlv-roundtrip.test.ts | 17 +++-- test/wallet/paymentRequests.test.ts | 59 +++++++++++++-- 6 files changed, 228 insertions(+), 33 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0fb372fb0..bf855a804 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,6 +83,8 @@ export type { PaymentRequestPayload, PaymentRequestTransport, RawPaymentRequest, + RawSupportedMethod, + SupportedMethod, RawTransport, NUT10Option, RawNUT10Option, diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index bd4f08719..750af66d8 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -11,6 +11,7 @@ import type { NUT10Option, PaymentRequestTransport, PaymentRequestTransportType, + SupportedMethod, } from '../wallet/types'; import { Amount, type AmountLike } from './Amount'; @@ -21,6 +22,7 @@ export class PaymentRequest { public feeReserve?: Amount; public singleUse?: boolean; public mintsPreferred?: boolean; + public supportedMethods?: SupportedMethod[]; constructor( public transport?: PaymentRequestTransport[], @@ -33,10 +35,14 @@ export class PaymentRequest { public nut10?: NUT10Option, mintsPreferred?: boolean, feeReserve?: AmountLike, - public supportedMethods?: string[], + supportedMethods?: Array<{ method: string; fee?: AmountLike }>, ) { this.amount = amount !== undefined ? Amount.from(amount) : undefined; this.feeReserve = feeReserve !== undefined ? Amount.from(feeReserve) : undefined; + this.supportedMethods = supportedMethods?.map((m) => ({ + method: m.method, + fee: m.fee !== undefined ? Amount.from(m.fee) : undefined, + })); // Coerce the optional flags to real booleans (preserving `undefined` for the // absent/tri-state case) so an untyped CBOR value (`0`/`1`/`null`) can't leak a // non-boolean into the getter or get re-serialized verbatim over the wire. @@ -58,6 +64,44 @@ export class PaymentRequest { return this.mintsPreferred !== true; } + /** + * Computes the total amount the payer must send from `mint` using `method`, including the + * additional fees the request requires: the non-preferred-mint fee (`fr`) when `mint` is outside + * a preferred (`mp = true`) mint list, plus the per-method fee (`mf`) of the chosen `sm` method. + * The two fees stack additively (NUT-18). + * + * This sums only the fees that apply; it does NOT validate admissibility (e.g. a strict mint + * list, or a `method` absent from `sm`). Callers that must reject disallowed mints/methods check + * that separately. + * + * @param mint - The mint URL the payer will send from. + * @param method - The payment method the payer relies on (matched against `sm`); omit if none. + * @throws If the request has no amount (there is no base to add fees to). + */ + amountToSend(mint: string, method?: string): Amount { + if (!this.amount) { + throw new CTSError('cannot compute amount to send: request has no amount'); + } + let total = this.amount; + // fr applies only to a preferred list (mp = true) when paying from a mint outside it. + if ( + this.feeReserve && + this.mintsPreferred === true && + this.mints?.length && + !this.mints.includes(mint) + ) { + total = total.add(this.feeReserve); + } + // mf applies for the chosen method if that sm entry carries a fee. + if (method) { + const fee = this.supportedMethods?.find((m) => m.method === method)?.fee; + if (fee) { + total = total.add(fee); + } + } + return total; + } + toRawRequest() { const rawRequest: RawPaymentRequest = {}; if (this.transport) { @@ -86,7 +130,9 @@ export class PaymentRequest { rawRequest.fr = this.feeReserve.toBigInt(); } if (this.supportedMethods && this.supportedMethods.length > 0) { - rawRequest.sm = this.supportedMethods; + rawRequest.sm = this.supportedMethods.map((m) => + m.fee !== undefined ? { mn: m.method, mf: m.fee.toBigInt() } : { mn: m.method }, + ); } if (this.description) { rawRequest.d = this.description; @@ -135,7 +181,10 @@ export class PaymentRequest { mints: this.mints, mintsPreferred: this.mintsPreferred, feeReserve: this.feeReserve !== undefined ? this.feeReserve.toBigInt() : undefined, - supportedMethods: this.supportedMethods, + supportedMethods: this.supportedMethods?.map((m) => ({ + method: m.method, + fee: m.fee !== undefined ? m.fee.toBigInt() : undefined, + })), description: this.description, transports: this.transport, nut10: this.nut10 @@ -232,6 +281,7 @@ export class PaymentRequest { tags: rawPaymentRequest.nut10.t, } : undefined; + const supportedMethods = rawPaymentRequest.sm?.map((m) => ({ method: m.mn, fee: m.mf })); return new PaymentRequest( transports, rawPaymentRequest.i, @@ -243,7 +293,7 @@ export class PaymentRequest { nut10, rawPaymentRequest.mp, rawPaymentRequest.fr, - rawPaymentRequest.sm, + supportedMethods, ); } diff --git a/src/utils/tlv.ts b/src/utils/tlv.ts index 81792cbce..7f2e3ba8e 100644 --- a/src/utils/tlv.ts +++ b/src/utils/tlv.ts @@ -24,7 +24,7 @@ export type DecodedTLVPaymentRequest = { mints?: string[]; mintsPreferred?: boolean; feeReserve?: bigint; - supportedMethods?: string[]; + supportedMethods?: Array<{ method: string; fee?: bigint }>; description?: string; transports?: PaymentRequestTransport[]; nut10?: Nut10SpendingCondition; @@ -33,19 +33,19 @@ export type DecodedTLVPaymentRequest = { /** * TLV Tag definitions for Payment Request (NUT-18 version B). * - * | Tag | Field | Type | Description | - * | ---- | ----------------- | --------- | --------------------------------------------------------------------------------------------- | - * | 0x01 | id | string | Payment identifier | - * | 0x02 | amount | u64 | Amount in base units | - * | 0x03 | unit | u8/string | Currency unit (0x00 = 'sat') | - * | 0x04 | single_use | u8 | Single-use flag: 0=false, 1=true | - * | 0x05 | mint | string | Mint URL (repeatable) | - * | 0x06 | description | string | Human-readable description | - * | 0x07 | transport | sub-TLV | Transport configuration (repeatable) | - * | 0x08 | nut10 | sub-TLV | NUT-10 spending conditions (not yet implemented) | - * | 0x09 | mint_preferred | u8 | Mint list strictness flag: 0=false, 1=true; if absent, defaults to 0 (strict) | - * | 0x0a | fee_reserve | u64 | Additional fee reserve, in the request unit, when paying from a mint outside `mint` list | - * | 0x0b | supported_methods | string | Payment method the sending mint must support, e.g. "bolt11", "bolt12", "onchain" (repeatable) | + * | Tag | Field | Type | Description | + * | ---- | ---------------- | --------- | ---------------------------------------------------------------------------------------- | + * | 0x01 | id | string | Payment identifier | + * | 0x02 | amount | u64 | Amount in base units | + * | 0x03 | unit | u8/string | Currency unit (0x00 = 'sat') | + * | 0x04 | single_use | u8 | Single-use flag: 0=false, 1=true | + * | 0x05 | mint | string | Mint URL (repeatable) | + * | 0x06 | description | string | Human-readable description | + * | 0x07 | transport | sub-TLV | Transport configuration (repeatable) | + * | 0x08 | nut10 | sub-TLV | NUT-10 spending conditions (not yet implemented) | + * | 0x09 | mint_preferred | u8 | Mint list strictness flag: 0=false, 1=true; if absent, defaults to 0 (strict) | + * | 0x0a | fee_reserve | u64 | Additional fee reserve, in the request unit, when paying from a mint outside `mint` list | + * | 0x0b | supported_method | sub-TLV | Supported payment method with an optional per-method fee (repeatable) | */ const TAG_ID = 0x01; const TAG_AMOUNT = 0x02; @@ -91,6 +91,17 @@ const NUT10_TAG_TAG_TUPLE = 0x03; const NUT10_KIND_P2PK = 0; const NUT10_KIND_HTLC = 1; +/** + * Supported Method Sub-TLV Tag definitions (NUT-26 tag 0x0b). + * + * | Sub-Tag | Field | Type | Description | + * | ------- | ------ | ------ | --------------------------------- | + * | 0x01 | method | string | Method name, e.g. "bolt11" | + * | 0x02 | fee | u64 | Optional per-method fee; absent=0 | + */ +const SUPPORTED_METHOD_TAG_METHOD = 0x01; +const SUPPORTED_METHOD_TAG_FEE = 0x02; + type TLVPart = { tag: number; length: number; @@ -158,7 +169,7 @@ export function decodeTLV(data: Uint8Array): DecodedTLVPaymentRequest { if (!result.supportedMethods) { result.supportedMethods = []; } - result.supportedMethods.push(parseString(part.value)); + result.supportedMethods.push(parseSupportedMethod(part.value)); break; default: // Ignore unknown tags for forward compatibility @@ -361,6 +372,44 @@ function parseNut10(value: Uint8Array): Nut10SpendingCondition { }; } +/** + * Parses a supported method (NUT-26 tag 0x0b) from its sub-TLV value. + * + * @param value - The supported_method sub-TLV value bytes. + * @returns Parsed method with an optional per-method fee. + */ +function parseSupportedMethod(value: Uint8Array): { method: string; fee?: bigint } { + const parts = decodeAllParts(value); + + let method: string | undefined; + let fee: bigint | undefined; + + for (const part of parts) { + switch (part.tag) { + case SUPPORTED_METHOD_TAG_METHOD: + // Singular: a duplicate makes the method ambiguous (last value silently + // won before), so reject rather than guess. + if (method !== undefined) { + throw new CTSError('invalid pr: multiple supported_method method fields'); + } + method = parseString(part.value); + break; + case SUPPORTED_METHOD_TAG_FEE: + if (fee !== undefined) { + throw new CTSError('invalid pr: multiple supported_method fee fields'); + } + fee = parseU64(part.value); + break; + } + } + + if (method === undefined) { + throw new CTSError('supported_method missing required method field'); + } + + return fee !== undefined ? { method, fee } : { method }; +} + /** * Parses a tag tuple from its TLV value. * @@ -459,10 +508,10 @@ export function encodeTLV(request: DecodedTLVPaymentRequest): Uint8Array { parts.push(encodeTLVPart(TAG_FEE_RESERVE, encodeU64(request.feeReserve))); } - // Repeatable: supported_methods + // Repeatable: supported_method if (request.supportedMethods && request.supportedMethods.length > 0) { for (const method of request.supportedMethods) { - parts.push(encodeTLVPart(TAG_SUPPORTED_METHODS, encodeString(method))); + parts.push(encodeTLVPart(TAG_SUPPORTED_METHODS, encodeSupportedMethod(method))); } } @@ -623,6 +672,31 @@ function encodeNut10(nut10: Nut10SpendingCondition): Uint8Array { return result; } +/** + * Encodes a supported method into its TLV sub-structure (NUT-26 tag 0x0b). + * + * @param method - The method name with an optional per-method fee. + * @returns Encoded supported_method sub-TLV. + */ +function encodeSupportedMethod(method: { method: string; fee?: bigint }): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeTLVPart(SUPPORTED_METHOD_TAG_METHOD, encodeString(method.method))); + if (method.fee !== undefined) { + parts.push(encodeTLVPart(SUPPORTED_METHOD_TAG_FEE, encodeU64(method.fee))); + } + + // Concatenate all sub-parts + const totalLength = parts.reduce((sum, part) => sum + part.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + + return result; +} + /** * Encodes a tag tuple into its TLV value format. * diff --git a/src/wallet/types/payment-requests.ts b/src/wallet/types/payment-requests.ts index 1a1b349d7..24239493b 100644 --- a/src/wallet/types/payment-requests.ts +++ b/src/wallet/types/payment-requests.ts @@ -1,3 +1,4 @@ +import type { Amount } from '../../model/Amount'; import { type Proof } from '../../model/types/proof'; export type RawTransport = { @@ -12,6 +13,11 @@ export type RawNUT10Option = { t: string[][]; // tags }; +export type RawSupportedMethod = { + mn: string; // method name (e.g. "bolt11", "bolt12", "onchain") + mf?: number | bigint; // per-method fee, in request unit; omitted = 0 +}; + export type RawPaymentRequest = { i?: string; // id a?: number | bigint; // amount @@ -20,12 +26,21 @@ export type RawPaymentRequest = { m?: string[]; // mints mp?: boolean; // mints preferred: strict list when absent or false, advisory list when true fr?: number | bigint; // fee reserve (additional, in request unit, when paying from a mint outside a preferred list) - sm?: string[]; // supported payment methods the sending mint must support (e.g. "bolt11", "bolt12", "onchain") + sm?: RawSupportedMethod[]; // supported methods the payee accepts, each with an optional per-method fee d?: string; // description t?: RawTransport[]; // transports nut10?: RawNUT10Option; }; +/** + * A payment method the payee accepts, with an optional per-method fee the payer must add when using + * it. The fee stacks additively with the top-level {@link PaymentRequest.feeReserve} (`fr`). + */ +export type SupportedMethod = { + method: string; + fee?: Amount; +}; + export type PaymentRequestTransport = { type: PaymentRequestTransportType; target: string; diff --git a/test/utils/tlv-roundtrip.test.ts b/test/utils/tlv-roundtrip.test.ts index be618208a..0c5b903c9 100644 --- a/test/utils/tlv-roundtrip.test.ts +++ b/test/utils/tlv-roundtrip.test.ts @@ -359,12 +359,13 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { describe('Preferred Mint List with Fee Reserve and Supported Methods', () => { // NUT-26 spec test vector — payment request with mp=true (preferred/advisory - // mint list), a fee reserve for non-preferred mints, and required mint methods. + // mint list), a fee required for non-preferred mints, and supported methods + // where bolt12 carries a per-method fee (mf=5) that stacks with fr. const encoded = - 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQSQQQQQQQQQQQQG9SQPNZDAK8GVF3PVQQVCN0D36RZVSDWZJ5Y'; + 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQSQQQQQQQQQQQQG9SQZGPQQRXYMMVWSCNZZCQZSQSQPNZDAK8GVFJQGQQSQQQQQQQQQQQQ5SX95HX'; test('roundtrip preferred mint list with fee reserve and supported methods', () => { - testRoundtrip(encoded, 'NUT-26 spec vector: mp=true, fr=2, sm=[bolt11, bolt12]'); + testRoundtrip(encoded, 'NUT-26 spec vector: mp=true, fr=2, sm=[bolt11, bolt12(mf=5)]'); }); test('verify mp, fr, sm fields', () => { @@ -377,14 +378,20 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { expect(decoded.mints).toEqual(['https://mint.example.com']); expect(decoded.mintsPreferred).toBe(true); expect(decoded.feeReserve).toBe(BigInt(2)); - expect(decoded.supportedMethods).toEqual(['bolt11', 'bolt12']); + expect(decoded.supportedMethods).toEqual([ + { method: 'bolt11' }, + { method: 'bolt12', fee: BigInt(5) }, + ]); const reEncoded = encodeTLV(decoded); const finalDecoded = decodeTLV(reEncoded); expect(finalDecoded.mintsPreferred).toBe(true); expect(finalDecoded.feeReserve).toBe(BigInt(2)); - expect(finalDecoded.supportedMethods).toEqual(['bolt11', 'bolt12']); + expect(finalDecoded.supportedMethods).toEqual([ + { method: 'bolt11' }, + { method: 'bolt12', fee: BigInt(5) }, + ]); }); }); diff --git a/test/wallet/paymentRequests.test.ts b/test/wallet/paymentRequests.test.ts index 21e9620fc..7f8d7a715 100644 --- a/test/wallet/paymentRequests.test.ts +++ b/test/wallet/paymentRequests.test.ts @@ -117,9 +117,9 @@ describe('payment requests', () => { // are pinned to lock canonical output: minimal CBOR (creqA, `a7` not `b9 0007`) and // minimal TLV with no redundant single_use=0 (creqB). const SPEC_CREQA = - 'creqAp2FpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWJtcPViZnICYnNtgmZib2x0MTFmYm9sdDEy'; + 'creqAp2FpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWJtcPViZnICYnNtgqFibW5mYm9sdDExomJtbmZib2x0MTJibWYF'; const SPEC_CREQB = - 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQSQQQQQQQQQQQQG9SQPNZDAK8GVF3PVQQVCN0D36RZVSDWZJ5Y'; + 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQSQQQQQQQQQQQQG9SQZGPQQRXYMMVWSCNZZCQZSQSQPNZDAK8GVFJQGQQSQQQQQQQQQQQQ5SX95HX'; test('encode/decode preferred mint list with fee reserve and supported methods (creqA)', () => { const request = new PaymentRequest( @@ -133,7 +133,7 @@ describe('payment requests', () => { undefined, true, // mintsPreferred (advisory list) 2, // feeReserve - ['bolt11', 'bolt12'], // supportedMethods + [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], // supportedMethods ); const pr = request.toEncodedRequest(); @@ -142,7 +142,8 @@ describe('payment requests', () => { const decoded = decodePaymentRequest(pr); expect(decoded.mintsPreferred).toBe(true); expect(decoded.feeReserve?.equals(2)).toBeTruthy(); - expect(decoded.supportedMethods).toEqual(['bolt11', 'bolt12']); + expect(decoded.supportedMethods?.map((m) => m.method)).toEqual(['bolt11', 'bolt12']); + expect(decoded.supportedMethods?.[1].fee?.equals(5)).toBeTruthy(); }); test('encode/decode preferred mint list with fee reserve and supported methods (creqB)', () => { @@ -157,7 +158,7 @@ describe('payment requests', () => { undefined, true, 2, - ['bolt11', 'bolt12'], + [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], ); const encoded = request.toEncodedCreqB(); @@ -166,7 +167,53 @@ describe('payment requests', () => { const decoded = PaymentRequest.fromEncodedRequest(encoded); expect(decoded.mintsPreferred).toBe(true); expect(decoded.feeReserve?.equals(2)).toBeTruthy(); - expect(decoded.supportedMethods).toEqual(['bolt11', 'bolt12']); + expect(decoded.supportedMethods?.map((m) => m.method)).toEqual(['bolt11', 'bolt12']); + expect(decoded.supportedMethods?.[1].fee?.equals(5)).toBeTruthy(); + }); + + test('amountToSend stacks fr (non-preferred mint) and mf (per-method) fees', () => { + // Preferred list (mp=true), fr=2, bolt12 carries mf=5. + const pr = new PaymentRequest( + undefined, + 'fees', + 100, + 'sat', + ['https://in.example.com'], + undefined, + undefined, + undefined, + true, + 2, + [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], + ); + + // In-list mint, no fee-bearing method: base only. + expect(pr.amountToSend('https://in.example.com').equals(100)).toBeTruthy(); + // In-list mint + bolt12: base + mf. + expect(pr.amountToSend('https://in.example.com', 'bolt12').equals(105)).toBeTruthy(); + // Outside mint + bolt11 (no mf): base + fr. + expect(pr.amountToSend('https://out.example.com', 'bolt11').equals(102)).toBeTruthy(); + // Outside mint + bolt12: base + fr + mf. + expect(pr.amountToSend('https://out.example.com', 'bolt12').equals(107)).toBeTruthy(); + + // Strict list (mp absent): fr never applies even from an outside mint. + const strict = new PaymentRequest( + undefined, + 'strict', + 100, + 'sat', + ['https://in.example.com'], + undefined, + undefined, + undefined, + undefined, + 2, + ); + expect(strict.amountToSend('https://out.example.com').equals(100)).toBeTruthy(); + + // No amount: cannot compute. + const noAmount = new PaymentRequest(undefined, 'noamt', undefined, 'sat'); + expect(() => noAmount.amountToSend('https://x.example.com')).toThrow(); }); test('isMintListStrict resolves NUT-18 default-to-strict semantic', () => { From 24147b08edfd79ef8a97ab3ea23ff0a39d3ae476 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Wed, 1 Jul 2026 22:13:01 +0100 Subject: [PATCH 06/13] feat(payment-request): add feesFor() for amountless fee pricing Split the fr + mf surcharge calc out of amountToSend into feesFor(mint, method?), which never throws and returns 0 when nothing applies. Lets callers price the fees on an amountless request (where amountToSend has no base to add to) and add them to the payer-chosen amount. amountToSend now delegates to feesFor and still throws on a missing amount, pointing callers at feesFor. --- src/model/PaymentRequest.ts | 46 +++++++++++++++++++---------- test/wallet/paymentRequests.test.ts | 25 ++++++++++++++-- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index 750af66d8..c7e85be22 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -65,24 +65,21 @@ export class PaymentRequest { } /** - * Computes the total amount the payer must send from `mint` using `method`, including the - * additional fees the request requires: the non-preferred-mint fee (`fr`) when `mint` is outside - * a preferred (`mp = true`) mint list, plus the per-method fee (`mf`) of the chosen `sm` method. - * The two fees stack additively (NUT-18). + * The additional fees the payer must add when paying from `mint` using `method`: the + * non-preferred-mint fee (`fr`) when `mint` is outside a preferred (`mp = true`) mint list, plus + * the per-method fee (`mf`) of the chosen `sm` method. The two stack additively (NUT-18); returns + * `0` when none apply. * - * This sums only the fees that apply; it does NOT validate admissibility (e.g. a strict mint - * list, or a `method` absent from `sm`). Callers that must reject disallowed mints/methods check - * that separately. + * Use this for amountless requests (where the payer chooses the amount): add the result to the + * chosen amount. This sums only the fees that apply; it does NOT validate admissibility (e.g. a + * strict mint list, or a `method` absent from `sm`) — callers that must reject disallowed + * mints/methods check that separately. * * @param mint - The mint URL the payer will send from. * @param method - The payment method the payer relies on (matched against `sm`); omit if none. - * @throws If the request has no amount (there is no base to add fees to). */ - amountToSend(mint: string, method?: string): Amount { - if (!this.amount) { - throw new CTSError('cannot compute amount to send: request has no amount'); - } - let total = this.amount; + feesFor(mint: string, method?: string): Amount { + let fees = Amount.from(0); // fr applies only to a preferred list (mp = true) when paying from a mint outside it. if ( this.feeReserve && @@ -90,16 +87,33 @@ export class PaymentRequest { this.mints?.length && !this.mints.includes(mint) ) { - total = total.add(this.feeReserve); + fees = fees.add(this.feeReserve); } // mf applies for the chosen method if that sm entry carries a fee. if (method) { const fee = this.supportedMethods?.find((m) => m.method === method)?.fee; if (fee) { - total = total.add(fee); + fees = fees.add(fee); } } - return total; + return fees; + } + + /** + * The total amount to send from `mint` using `method`: the requested amount plus {@link feesFor}. + * + * @param mint - The mint URL the payer will send from. + * @param method - The payment method the payer relies on (matched against `sm`); omit if none. + * @throws If the request has no amount. Amountless requests have no base to add fees to; use + * {@link feesFor} and add it to the amount the payer chooses. + */ + amountToSend(mint: string, method?: string): Amount { + if (!this.amount) { + throw new CTSError( + 'cannot compute amount to send: request has no amount; use feesFor() and add the payer-chosen amount', + ); + } + return this.amount.add(this.feesFor(mint, method)); } toRawRequest() { diff --git a/test/wallet/paymentRequests.test.ts b/test/wallet/paymentRequests.test.ts index 7f8d7a715..7871d311a 100644 --- a/test/wallet/paymentRequests.test.ts +++ b/test/wallet/paymentRequests.test.ts @@ -211,9 +211,30 @@ describe('payment requests', () => { ); expect(strict.amountToSend('https://out.example.com').equals(100)).toBeTruthy(); - // No amount: cannot compute. - const noAmount = new PaymentRequest(undefined, 'noamt', undefined, 'sat'); + // feesFor returns the surcharge alone (0 when none applies). + expect(pr.feesFor('https://in.example.com').equals(0)).toBeTruthy(); + expect(pr.feesFor('https://out.example.com', 'bolt12').equals(7)).toBeTruthy(); + + // Amountless request: amountToSend throws, but feesFor still prices the surcharge so the + // payer can add it to their chosen amount. + const noAmount = new PaymentRequest(undefined, 'noamt', undefined, 'sat', [ + 'https://in.example.com', + ]); expect(() => noAmount.amountToSend('https://x.example.com')).toThrow(); + const mp = new PaymentRequest( + undefined, + 'noamt_mp', + undefined, + 'sat', + ['https://in.example.com'], + undefined, + undefined, + undefined, + true, + 2, + [{ method: 'bolt12', fee: 5 }], + ); + expect(mp.feesFor('https://out.example.com', 'bolt12').equals(7)).toBeTruthy(); }); test('isMintListStrict resolves NUT-18 default-to-strict semantic', () => { From 8fb2fbad25e450b4ae564081a6543e03ad23dc0d Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Wed, 1 Jul 2026 22:13:10 +0100 Subject: [PATCH 07/13] docs(payment-request): add NUT-18/NUT-26 payment requests usage guide Cover decoding, mint-list admissibility (isMintListStrict), fee-aware amountToSend / feesFor (fr + mf stacking, amountless case), locked requests via toP2PKOptions, creating/encoding, and the delivery payload. Register in typedoc projectDocuments and link from the usage index. --- docs-src/usage/payment_requests.md | 120 +++++++++++++++++++++++++++++ docs-src/usage/usage_index.md | 1 + typedoc.json | 1 + 3 files changed, 122 insertions(+) create mode 100644 docs-src/usage/payment_requests.md diff --git a/docs-src/usage/payment_requests.md b/docs-src/usage/payment_requests.md new file mode 100644 index 000000000..2dddcead4 --- /dev/null +++ b/docs-src/usage/payment_requests.md @@ -0,0 +1,120 @@ +# Documents › [Usage Examples](../usage/usage_index.md) › **Payment Requests** + +# Payment Requests (NUT-18 / NUT-26) + +A **payment request** lets a receiver describe a payment they want to be paid, encode it (as a string or QR), and hand it to a sender. The sender decodes it, builds a matching token, and delivers it over the transport the request specifies. See [NUT-18][nut18] and its Bech32m encoding [NUT-26][nut26]. + +Two encodings are supported and both decode through the same API: + +- `creqA…`: CBOR + base64url (NUT-18) +- `CREQB1…`: TLV + Bech32m, more compact and QR-friendly (NUT-26) + +## Decode an incoming request (sender side) + +```typescript +import { decodePaymentRequest } from '@cashu/cashu-ts'; + +const pr = decodePaymentRequest(scanned); // accepts creqA… or CREQB1… + +pr.amount; // requested Amount (undefined = payer chooses the amount) +pr.unit; // e.g. 'sat' +pr.description; // human-readable, show to the user +pr.mints; // mints the receiver accepts (string[] | undefined) +pr.getTransport('nostr'); // the transport of a given type, if present +``` + +## Which mint may I pay from? + +A request may carry a mint list that is either **strict** (send only from these mints) or **preferred** (prefer these, but others are allowed). `isMintListStrict` resolves the NUT-18 default-to-strict semantic so you do not have to: + +```typescript +// undefined = no list (any mint); true = strict; false = preferred/advisory +const allowed = !pr.isMintListStrict || pr.mints?.includes(myMint); +``` + +If `supportedMethods` (`sm`) is set, the sending mint must also support at least one of those methods (`bolt11`, `bolt12`, `onchain`, …). Checking that requires the sending mint's capabilities. See [Inspect Mint Capabilities](./mint_capabilities.md). + +## How much do I send, including fees? + +A preferred mint list can attach an extra fee (`fr`) for paying from a mint outside it, and each supported method can carry its own fee (`mf`). The two **stack additively**. `amountToSend` computes the total for you. + +`amountToSend` returns an `Amount`, so it flows straight into `wallet.ops.send` (which accepts any `AmountLike`). Convert only at the edge, for display or serialization. + +```typescript +// preferred list (mp=true), fr=2, and bolt12 carries mf=5 +pr.amountToSend('https://in-list.mint'); // Amount, base only → 100 +pr.amountToSend('https://in-list.mint', 'bolt12'); // + mf → 105 +pr.amountToSend('https://other.mint', 'bolt11'); // + fr → 102 +pr.amountToSend('https://other.mint', 'bolt12'); // + fr + mf → 107 + +const total = pr.amountToSend(myMint, 'bolt11'); +await wallet.ops.send(total, proofs).run(); // Amount passed straight through +``` + +`amountToSend` only sums the fees that apply; it does not reject a mint or method that is not allowed (that is the caller's decision, see above). It throws if the request has no amount. + +For an **amountless** request (the payer chooses the amount), use `feesFor` to price the surcharge alone and add it to the chosen amount: + +```typescript +const total = chosenAmount.add(pr.feesFor(myMint, 'bolt12')); // fr + mf, or 0 if none apply +``` + +## Locked requests + +A request may require the token be locked to a spending condition (P2PK / HTLC). `toP2PKOptions()` converts that condition into the options accepted by the P2PK builder, so you can produce proofs locked exactly as the receiver asked: + +```typescript +const opts = pr.toP2PKOptions(); // undefined = no lockable nut10 condition +const builder = wallet.ops.send(pr.amountToSend(myMint), proofs); +// Lock only when the request asks for it; otherwise send unlocked. +if (opts) builder.asP2PK(opts); +const { keep, send } = await builder.run(); +``` + +See [Create P2PK](./create_p2pk.md) for the builder. + +## Create and encode a request (receiver side) + +The `PaymentRequest` constructor is positional. `feeReserve` and each method `fee` accept any `AmountLike` (number, bigint, string, or `Amount`). + +```typescript +import { PaymentRequest, PaymentRequestTransportType } from '@cashu/cashu-ts'; + +const request = new PaymentRequest( + [{ type: PaymentRequestTransportType.POST, target: 'https://pay.example.com' }], // transport + 'inv-123', // id + 100, // amount + 'sat', // unit + ['https://my.mint'], // mints + 'Coffee', // description + undefined, // singleUse (absent / true / false) + undefined, // nut10 locking condition + true, // mintsPreferred → advisory list + 2, // feeReserve (fr) + [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], // supportedMethods (sm) +); + +request.toEncodedCreqA(); // 'creqA…' (CBOR) +request.toEncodedCreqB(); // 'CREQB1…' (TLV + Bech32m, best for QR) +``` + +## Delivering the payment (sender side) + +Send the receiver a `PaymentRequestPayload` over the request's transport (HTTP POST body, Nostr DM, or in-band if no transport is given): + +```typescript +import type { PaymentRequestPayload } from '@cashu/cashu-ts'; + +const payload: PaymentRequestPayload = { + id: pr.id, + mint: myMint, + unit: pr.unit ?? myUnit, // the requested unit, or the unit of what you send + proofs: send, // the locked/selected proofs +}; +``` + +> [!IMPORTANT] +> The receiver validates the incoming proofs themselves (DLEQ, and that any timelock is long enough) before accepting. Building a payload does not settle the payment. + +[nut18]: https://github.com/cashubtc/nuts/blob/main/18.md +[nut26]: https://github.com/cashubtc/nuts/blob/main/26.md diff --git a/docs-src/usage/usage_index.md b/docs-src/usage/usage_index.md index 6c12fe238..800e80bc3 100644 --- a/docs-src/usage/usage_index.md +++ b/docs-src/usage/usage_index.md @@ -29,6 +29,7 @@ If you are building a wallet integration from scratch, read these in order: | [Create P2PK](./create_p2pk.md) | Send tokens locked to a public key. | | [Get Token](./get_token.md) | Inspect token metadata before wallet creation or decode it after load. | | [Melt Token](./melt_token.md) | Pay BOLT11 invoices or other payment methods with wallet proofs. | +| [Payment Requests](./payment_requests.md) | Decode, price (fees), fulfil, and create NUT-18 / NUT-26 payment requests. | | [Bolt12](./bolt12.md) | Work with reusable BOLT12 offers for minting and melting. | | [NUT-19 Cached Responses](./nut19.md) | Understand cached endpoint retries and timeout behavior. | | [Logging](./logging.md) | Enable and route library logs while debugging wallet or mint behavior. | diff --git a/typedoc.json b/typedoc.json index c4cabd14e..9cdc27df4 100644 --- a/typedoc.json +++ b/typedoc.json @@ -20,6 +20,7 @@ "docs-src/usage/create_wallet.md", "docs-src/usage/get_token.md", "docs-src/usage/bolt12.md", + "docs-src/usage/payment_requests.md", "docs-src/usage/nut19.md", "docs-src/usage/logging.md", "docs-src/wallet_ops/wallet_ops.md", From d3e7e72bfe879c43a7857404b7dcb2ec5ca0a0e9 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Wed, 1 Jul 2026 22:23:43 +0100 Subject: [PATCH 08/13] docs(payment-request): fix feesFor @link and refresh API report - qualify {@link PaymentRequest.feesFor} so api-extractor resolves it as a class member, not a missing package export (ae-unresolved-link warning) - regenerate etc/cashu-ts.api.md for the new PaymentRequest surface (amountToSend, feesFor, SupportedMethod, RawSupportedMethod, sm retype) --- etc/cashu-ts.api.md | 23 ++++++++++++++++++++--- src/model/PaymentRequest.ts | 5 +++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index 9dd3346eb..39d416f60 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -1593,13 +1593,18 @@ export function parseSecret(secret: string | Secret): Secret; // @public (undocumented) class PaymentRequest_2 { - constructor(transport?: PaymentRequestTransport[] | undefined, id?: string | undefined, amount?: AmountLike, unit?: string | undefined, mints?: string[] | undefined, description?: string | undefined, singleUse?: boolean, nut10?: NUT10Option | undefined, mintsPreferred?: boolean, feeReserve?: AmountLike, supportedMethods?: string[] | undefined); + constructor(transport?: PaymentRequestTransport[] | undefined, id?: string | undefined, amount?: AmountLike, unit?: string | undefined, mints?: string[] | undefined, description?: string | undefined, singleUse?: boolean, nut10?: NUT10Option | undefined, mintsPreferred?: boolean, feeReserve?: AmountLike, supportedMethods?: Array<{ + method: string; + fee?: AmountLike; + }>); // (undocumented) amount?: Amount; + amountToSend(mint: string, method?: string): Amount; // (undocumented) description?: string | undefined; // (undocumented) feeReserve?: Amount; + feesFor(mint: string, method?: string): Amount; // (undocumented) static fromEncodedRequest(encodedRequest: string): PaymentRequest_2; static fromRawRequest(rawPaymentRequest: RawPaymentRequest): PaymentRequest_2; @@ -1617,7 +1622,7 @@ class PaymentRequest_2 { // (undocumented) singleUse?: boolean; // (undocumented) - supportedMethods?: string[] | undefined; + supportedMethods?: SupportedMethod[]; toEncodedCreqA(): string; toEncodedCreqB(): string; // (undocumented) @@ -1751,12 +1756,18 @@ export type RawPaymentRequest = { m?: string[]; mp?: boolean; fr?: number | bigint; - sm?: string[]; + sm?: RawSupportedMethod[]; d?: string; t?: RawTransport[]; nut10?: RawNUT10Option; }; +// @public (undocumented) +export type RawSupportedMethod = { + mn: string; + mf?: number | bigint; +}; + // @public (undocumented) export type RawTransport = { t: PaymentRequestTransportType; @@ -2050,6 +2061,12 @@ export type SubscriptionCanceller = () => void; // @public export function sumProofs(proofs: Array>): Amount; +// @public +export type SupportedMethod = { + method: string; + fee?: Amount; +}; + // @public export type SwapMethod = { method: string; diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index 1fef308f8..efd4973e4 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -100,12 +100,13 @@ export class PaymentRequest { } /** - * The total amount to send from `mint` using `method`: the requested amount plus {@link feesFor}. + * The total amount to send from `mint` using `method`: the requested amount plus + * {@link PaymentRequest.feesFor | feesFor}. * * @param mint - The mint URL the payer will send from. * @param method - The payment method the payer relies on (matched against `sm`); omit if none. * @throws If the request has no amount. Amountless requests have no base to add fees to; use - * {@link feesFor} and add it to the amount the payer chooses. + * {@link PaymentRequest.feesFor | feesFor} and add it to the amount the payer chooses. */ amountToSend(mint: string, method?: string): Amount { if (!this.amount) { From 2575e5d60ef209ba4552942a1dbc93185fc0abb6 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Wed, 1 Jul 2026 22:28:54 +0100 Subject: [PATCH 09/13] test(tlv): cover supported_method sub-TLV error branches Add malformed-payload tests for the new tag 0x0b codec: duplicate method sub-tag, duplicate fee sub-tag, and a supported_method missing its method field. Covers the reject-not-last-wins paths introduced with per-method fees (tlv.ts:393/399/407), which had no coverage. --- test/utils/tlv-roundtrip.test.ts | 76 ++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/test/utils/tlv-roundtrip.test.ts b/test/utils/tlv-roundtrip.test.ts index 0c5b903c9..d7aeb6a24 100644 --- a/test/utils/tlv-roundtrip.test.ts +++ b/test/utils/tlv-roundtrip.test.ts @@ -175,6 +175,82 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { }); }); + describe('Supported Method sub-TLV (malformed)', () => { + // supported_method (tag 0x0b) is a sub-TLV: 0x01 method (string), 0x02 fee (u64). + // Duplicate singular sub-tags and a missing method must be rejected, not last-wins. + test('rejects duplicate method sub-tag', () => { + const malformed = new Uint8Array([ + 0x0b, + 0x00, + 0x08, // TAG_SUPPORTED_METHODS, length 8 + 0x01, + 0x00, + 0x01, + 0x61, // method = "a" + 0x01, + 0x00, + 0x01, + 0x62, // method = "b" (duplicate 0x01) + ]); + expect(() => decodeTLV(malformed)).toThrow(/multiple supported_method method/); + }); + + test('rejects duplicate fee sub-tag', () => { + const malformed = new Uint8Array([ + 0x0b, + 0x00, + 26, // TAG_SUPPORTED_METHODS, length 26 + 0x01, + 0x00, + 0x01, + 0x61, // method = "a" + 0x02, + 0x00, + 0x08, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 5, // fee = 5 (u64) + 0x02, + 0x00, + 0x08, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, // fee = 6 (duplicate 0x02) + ]); + expect(() => decodeTLV(malformed)).toThrow(/multiple supported_method fee/); + }); + + test('rejects supported_method missing its method field', () => { + const malformed = new Uint8Array([ + 0x0b, + 0x00, + 0x0b, // TAG_SUPPORTED_METHODS, length 11 + 0x02, + 0x00, + 0x08, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 5, // fee only, no method sub-tag + ]); + expect(() => decodeTLV(malformed)).toThrow(/supported_method missing required method/); + }); + }); + describe('HTTP POST Transport (kind=0x01)', () => { const encoded = 'CREQB1QYQQJ6R5W3C97AR9WD6QYQQGQQQQQQQQQQQ05QCQQYQQ2QQCDP68GURN8GHJ7MTFDE6ZUETCV9KHQMR99E3K7MG8QPQSZQQPQYPQQGNGW368QUE69UHKZURF9EJHSCTDWPKX2TNRDAKJ7A339ACXZ7TDV4H8GQCQZ5RXXATNW3HK6PNKV9K82EF3QEMXZMR4V5EQ9X3SJM'; From 8174517f2e20a60a64de40e73022551056b8afd0 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Mon, 6 Jul 2026 15:19:16 +0100 Subject: [PATCH 10/13] refactor(payment-request): replace fr with nf (net_fees), rescope mf Track the latest NUT-18/NUT-26 candidate: the top-level fee (fr) is dropped in favor of a net-of-input-fees flag (nf, TLV tag 0x0a as u8), and the per-method fee (mf) now applies only when paying from a mint outside the request's list, with the payer owing the lowest fee among the listed methods their mint supports. feesFor/amountToSend now take the mint's supported methods; spec vectors repinned. --- docs-src/usage/payment_requests.md | 29 ++++++---- etc/cashu-ts.api.md | 12 ++--- src/model/PaymentRequest.ts | 68 +++++++++++------------- src/utils/tlv.ts | 38 ++++++------- src/wallet/types/payment-requests.ts | 7 +-- test/utils/tlv-roundtrip.test.ts | 18 +++---- test/wallet/paymentRequests.test.ts | 79 +++++++++++++++------------- 7 files changed, 128 insertions(+), 123 deletions(-) diff --git a/docs-src/usage/payment_requests.md b/docs-src/usage/payment_requests.md index 2dddcead4..48550b49d 100644 --- a/docs-src/usage/payment_requests.md +++ b/docs-src/usage/payment_requests.md @@ -36,27 +36,34 @@ If `supportedMethods` (`sm`) is set, the sending mint must also support at least ## How much do I send, including fees? -A preferred mint list can attach an extra fee (`fr`) for paying from a mint outside it, and each supported method can carry its own fee (`mf`). The two **stack additively**. `amountToSend` computes the total for you. +Each supported method can carry a fee (`mf`) that compensates the receiver for melting out via it. The fee applies only when paying from a mint outside the request's mint list (or from any mint if no list is set); a payment from a listed mint carries none. When one applies, the payer owes the **lowest** `mf` among the listed methods their mint supports. `amountToSend` computes the total for you: pass the methods your mint supports as the second argument. `amountToSend` returns an `Amount`, so it flows straight into `wallet.ops.send` (which accepts any `AmountLike`). Convert only at the edge, for display or serialization. ```typescript -// preferred list (mp=true), fr=2, and bolt12 carries mf=5 -pr.amountToSend('https://in-list.mint'); // Amount, base only → 100 -pr.amountToSend('https://in-list.mint', 'bolt12'); // + mf → 105 -pr.amountToSend('https://other.mint', 'bolt11'); // + fr → 102 -pr.amountToSend('https://other.mint', 'bolt12'); // + fr + mf → 107 +// list = [in-list.mint], bolt11 carries no fee, bolt12 carries mf=5 +pr.amountToSend('https://in-list.mint', ['bolt12']); // listed mint, no fee → 100 +pr.amountToSend('https://other.mint', ['bolt11', 'bolt12']); // lowest = 0 → 100 +pr.amountToSend('https://other.mint', ['bolt12']); // + mf → 105 -const total = pr.amountToSend(myMint, 'bolt11'); +const total = pr.amountToSend(myMint, myMintMethods); await wallet.ops.send(total, proofs).run(); // Amount passed straight through ``` -`amountToSend` only sums the fees that apply; it does not reject a mint or method that is not allowed (that is the caller's decision, see above). It throws if the request has no amount. +`amountToSend` only prices the fee that applies; it does not reject a mint or method that is not allowed (that is the caller's decision, see above). It throws if the request has no amount. For an **amountless** request (the payer chooses the amount), use `feesFor` to price the surcharge alone and add it to the chosen amount: ```typescript -const total = chosenAmount.add(pr.feesFor(myMint, 'bolt12')); // fr + mf, or 0 if none apply +const total = chosenAmount.add(pr.feesFor(myMint, ['bolt12'])); // mf, or 0 if none applies +``` + +If the request sets `netFees` (`nf`), the requested amount is **net of input fees**: the receiver must be able to swap or melt the proofs without dipping below it. Select proofs with fees included: + +```typescript +const builder = wallet.ops.send(total, proofs); +if (pr.netFees) builder.includeFees(true); // sender covers the receiver's input fee +await builder.run(); ``` ## Locked requests @@ -75,7 +82,7 @@ See [Create P2PK](./create_p2pk.md) for the builder. ## Create and encode a request (receiver side) -The `PaymentRequest` constructor is positional. `feeReserve` and each method `fee` accept any `AmountLike` (number, bigint, string, or `Amount`). +The `PaymentRequest` constructor is positional. Each method `fee` accepts any `AmountLike` (number, bigint, string, or `Amount`). ```typescript import { PaymentRequest, PaymentRequestTransportType } from '@cashu/cashu-ts'; @@ -90,7 +97,7 @@ const request = new PaymentRequest( undefined, // singleUse (absent / true / false) undefined, // nut10 locking condition true, // mintsPreferred → advisory list - 2, // feeReserve (fr) + true, // netFees (nf) → amount is net of input fees [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], // supportedMethods (sm) ); diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index 39d416f60..3bb8941b3 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -1593,18 +1593,16 @@ export function parseSecret(secret: string | Secret): Secret; // @public (undocumented) class PaymentRequest_2 { - constructor(transport?: PaymentRequestTransport[] | undefined, id?: string | undefined, amount?: AmountLike, unit?: string | undefined, mints?: string[] | undefined, description?: string | undefined, singleUse?: boolean, nut10?: NUT10Option | undefined, mintsPreferred?: boolean, feeReserve?: AmountLike, supportedMethods?: Array<{ + constructor(transport?: PaymentRequestTransport[] | undefined, id?: string | undefined, amount?: AmountLike, unit?: string | undefined, mints?: string[] | undefined, description?: string | undefined, singleUse?: boolean, nut10?: NUT10Option | undefined, mintsPreferred?: boolean, netFees?: boolean, supportedMethods?: Array<{ method: string; fee?: AmountLike; }>); // (undocumented) amount?: Amount; - amountToSend(mint: string, method?: string): Amount; + amountToSend(mint: string, mintMethods?: string[]): Amount; // (undocumented) description?: string | undefined; - // (undocumented) - feeReserve?: Amount; - feesFor(mint: string, method?: string): Amount; + feesFor(mint: string, mintMethods?: string[]): Amount; // (undocumented) static fromEncodedRequest(encodedRequest: string): PaymentRequest_2; static fromRawRequest(rawPaymentRequest: RawPaymentRequest): PaymentRequest_2; @@ -1618,6 +1616,8 @@ class PaymentRequest_2 { // (undocumented) mintsPreferred?: boolean; // (undocumented) + netFees?: boolean; + // (undocumented) nut10?: NUT10Option | undefined; // (undocumented) singleUse?: boolean; @@ -1755,7 +1755,7 @@ export type RawPaymentRequest = { s?: boolean; m?: string[]; mp?: boolean; - fr?: number | bigint; + nf?: boolean; sm?: RawSupportedMethod[]; d?: string; t?: RawTransport[]; diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index efd4973e4..fdda44823 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -19,7 +19,7 @@ import { CTSError } from './Errors'; export class PaymentRequest { public amount?: Amount; - public feeReserve?: Amount; + public netFees?: boolean; public singleUse?: boolean; public mintsPreferred?: boolean; public supportedMethods?: SupportedMethod[]; @@ -34,11 +34,10 @@ export class PaymentRequest { singleUse?: boolean, public nut10?: NUT10Option, mintsPreferred?: boolean, - feeReserve?: AmountLike, + netFees?: boolean, supportedMethods?: Array<{ method: string; fee?: AmountLike }>, ) { this.amount = amount !== undefined ? Amount.from(amount) : undefined; - this.feeReserve = feeReserve !== undefined ? Amount.from(feeReserve) : undefined; this.supportedMethods = supportedMethods?.map((m) => ({ method: m.method, fee: m.fee !== undefined ? Amount.from(m.fee) : undefined, @@ -48,12 +47,13 @@ export class PaymentRequest { // non-boolean into the getter or get re-serialized verbatim over the wire. this.singleUse = singleUse === undefined ? undefined : Boolean(singleUse); this.mintsPreferred = mintsPreferred === undefined ? undefined : Boolean(mintsPreferred); + this.netFees = netFees === undefined ? undefined : Boolean(netFees); } /** * Resolves the NUT-18 mint list strictness per spec. * - * - `undefined` if no mint list is set (`mp` and `fr` SHOULD be ignored) + * - `undefined` if no mint list is set (`mp` SHOULD be ignored) * - `true` if the list is strict (`mp` absent or `false`) * - `false` if the list is preferred/advisory (`mp === true`) */ @@ -65,56 +65,50 @@ export class PaymentRequest { } /** - * The additional fees the payer must add when paying from `mint` using `method`: the - * non-preferred-mint fee (`fr`) when `mint` is outside a preferred (`mp = true`) mint list, plus - * the per-method fee (`mf`) of the chosen `sm` method. The two stack additively (NUT-18); returns - * `0` when none apply. + * The per-method fee (`mf`) the payer must add when paying from `mint`: `0` if `mint` is in the + * mint list, otherwise the lowest fee among the `sm` methods that `mintMethods` says the mint + * supports (NUT-18). * * Use this for amountless requests (where the payer chooses the amount): add the result to the - * chosen amount. This sums only the fees that apply; it does NOT validate admissibility (e.g. a - * strict mint list, or a `method` absent from `sm`) — callers that must reject disallowed + * chosen amount. This prices only the fee that applies; it does NOT validate admissibility (e.g. + * a strict mint list, or a mint supporting none of `sm`) — callers that must reject disallowed * mints/methods check that separately. * * @param mint - The mint URL the payer will send from. - * @param method - The payment method the payer relies on (matched against `sm`); omit if none. + * @param mintMethods - The payment methods that mint supports (matched against `sm`); omit if + * unknown (prices as `0`). */ - feesFor(mint: string, method?: string): Amount { - let fees = Amount.from(0); - // fr applies only to a preferred list (mp = true) when paying from a mint outside it. - if ( - this.feeReserve && - this.mintsPreferred === true && - this.mints?.length && - !this.mints.includes(mint) - ) { - fees = fees.add(this.feeReserve); + feesFor(mint: string, mintMethods?: string[]): Amount { + // Fees compensate the receiver for melting out: payments from a listed mint carry none. + if (!this.supportedMethods?.length || this.mints?.includes(mint)) { + return Amount.zero(); } - // mf applies for the chosen method if that sm entry carries a fee. - if (method) { - const fee = this.supportedMethods?.find((m) => m.method === method)?.fee; - if (fee) { - fees = fees.add(fee); - } + const applicable = this.supportedMethods + .filter((m) => mintMethods?.includes(m.method)) + .map((m) => m.fee ?? Amount.zero()); + if (!applicable.length) { + return Amount.zero(); } - return fees; + return applicable.reduce((min, fee) => Amount.min(min, fee)); } /** - * The total amount to send from `mint` using `method`: the requested amount plus + * The total amount to send from `mint`: the requested amount plus * {@link PaymentRequest.feesFor | feesFor}. * * @param mint - The mint URL the payer will send from. - * @param method - The payment method the payer relies on (matched against `sm`); omit if none. + * @param mintMethods - The payment methods that mint supports (matched against `sm`); omit if + * unknown. * @throws If the request has no amount. Amountless requests have no base to add fees to; use * {@link PaymentRequest.feesFor | feesFor} and add it to the amount the payer chooses. */ - amountToSend(mint: string, method?: string): Amount { + amountToSend(mint: string, mintMethods?: string[]): Amount { if (!this.amount) { throw new CTSError( 'cannot compute amount to send: request has no amount; use feesFor() and add the payer-chosen amount', ); } - return this.amount.add(this.feesFor(mint, method)); + return this.amount.add(this.feesFor(mint, mintMethods)); } toRawRequest() { @@ -141,8 +135,8 @@ export class PaymentRequest { if (this.mintsPreferred !== undefined) { rawRequest.mp = this.mintsPreferred; } - if (this.feeReserve) { - rawRequest.fr = this.feeReserve.toBigInt(); + if (this.netFees !== undefined) { + rawRequest.nf = this.netFees; } if (this.supportedMethods && this.supportedMethods.length > 0) { rawRequest.sm = this.supportedMethods.map((m) => @@ -195,7 +189,7 @@ export class PaymentRequest { singleUse: this.singleUse, mints: this.mints, mintsPreferred: this.mintsPreferred, - feeReserve: this.feeReserve !== undefined ? this.feeReserve.toBigInt() : undefined, + netFees: this.netFees, supportedMethods: this.supportedMethods?.map((m) => ({ method: m.method, fee: m.fee !== undefined ? m.fee.toBigInt() : undefined, @@ -309,7 +303,7 @@ export class PaymentRequest { rawPaymentRequest.s, nut10, rawPaymentRequest.mp, - rawPaymentRequest.fr, + rawPaymentRequest.nf, supportedMethods, ); } @@ -338,7 +332,7 @@ export class PaymentRequest { decoded.singleUse, nut10, decoded.mintsPreferred, - decoded.feeReserve, + decoded.netFees, decoded.supportedMethods, ); } diff --git a/src/utils/tlv.ts b/src/utils/tlv.ts index 7f2e3ba8e..aab6ddfc4 100644 --- a/src/utils/tlv.ts +++ b/src/utils/tlv.ts @@ -23,7 +23,7 @@ export type DecodedTLVPaymentRequest = { singleUse?: boolean; mints?: string[]; mintsPreferred?: boolean; - feeReserve?: bigint; + netFees?: boolean; supportedMethods?: Array<{ method: string; fee?: bigint }>; description?: string; transports?: PaymentRequestTransport[]; @@ -33,19 +33,19 @@ export type DecodedTLVPaymentRequest = { /** * TLV Tag definitions for Payment Request (NUT-18 version B). * - * | Tag | Field | Type | Description | - * | ---- | ---------------- | --------- | ---------------------------------------------------------------------------------------- | - * | 0x01 | id | string | Payment identifier | - * | 0x02 | amount | u64 | Amount in base units | - * | 0x03 | unit | u8/string | Currency unit (0x00 = 'sat') | - * | 0x04 | single_use | u8 | Single-use flag: 0=false, 1=true | - * | 0x05 | mint | string | Mint URL (repeatable) | - * | 0x06 | description | string | Human-readable description | - * | 0x07 | transport | sub-TLV | Transport configuration (repeatable) | - * | 0x08 | nut10 | sub-TLV | NUT-10 spending conditions (not yet implemented) | - * | 0x09 | mint_preferred | u8 | Mint list strictness flag: 0=false, 1=true; if absent, defaults to 0 (strict) | - * | 0x0a | fee_reserve | u64 | Additional fee reserve, in the request unit, when paying from a mint outside `mint` list | - * | 0x0b | supported_method | sub-TLV | Supported payment method with an optional per-method fee (repeatable) | + * | Tag | Field | Type | Description | + * | ---- | ---------------- | --------- | ----------------------------------------------------------------------------- | + * | 0x01 | id | string | Payment identifier | + * | 0x02 | amount | u64 | Amount in base units | + * | 0x03 | unit | u8/string | Currency unit (0x00 = 'sat') | + * | 0x04 | single_use | u8 | Single-use flag: 0=false, 1=true | + * | 0x05 | mint | string | Mint URL (repeatable) | + * | 0x06 | description | string | Human-readable description | + * | 0x07 | transport | sub-TLV | Transport configuration (repeatable) | + * | 0x08 | nut10 | sub-TLV | NUT-10 spending conditions (not yet implemented) | + * | 0x09 | mint_preferred | u8 | Mint list strictness flag: 0=false, 1=true; if absent, defaults to 0 (strict) | + * | 0x0a | net_fees | u8 | Net-of-input-fees flag: 0=false, 1=true; if absent, defaults to 0 | + * | 0x0b | supported_method | sub-TLV | Supported payment method with an optional per-method fee (repeatable) | */ const TAG_ID = 0x01; const TAG_AMOUNT = 0x02; @@ -56,7 +56,7 @@ const TAG_DESCRIPTION = 0x06; const TAG_TRANSPORT = 0x07; const TAG_NUT10 = 0x08; const TAG_MINT_PREFERRED = 0x09; -const TAG_FEE_RESERVE = 0x0a; +const TAG_NET_FEES = 0x0a; const TAG_SUPPORTED_METHODS = 0x0b; /** @@ -162,8 +162,8 @@ export function decodeTLV(data: Uint8Array): DecodedTLVPaymentRequest { case TAG_MINT_PREFERRED: result.mintsPreferred = parseU8(part.value) === 1; break; - case TAG_FEE_RESERVE: - result.feeReserve = parseU64(part.value); + case TAG_NET_FEES: + result.netFees = parseU8(part.value) === 1; break; case TAG_SUPPORTED_METHODS: if (!result.supportedMethods) { @@ -504,8 +504,8 @@ export function encodeTLV(request: DecodedTLVPaymentRequest): Uint8Array { parts.push(encodeTLVPart(TAG_MINT_PREFERRED, encodeU8(request.mintsPreferred ? 1 : 0))); } - if (request.feeReserve !== undefined) { - parts.push(encodeTLVPart(TAG_FEE_RESERVE, encodeU64(request.feeReserve))); + if (request.netFees !== undefined) { + parts.push(encodeTLVPart(TAG_NET_FEES, encodeU8(request.netFees ? 1 : 0))); } // Repeatable: supported_method diff --git a/src/wallet/types/payment-requests.ts b/src/wallet/types/payment-requests.ts index 24239493b..167c74be7 100644 --- a/src/wallet/types/payment-requests.ts +++ b/src/wallet/types/payment-requests.ts @@ -25,7 +25,7 @@ export type RawPaymentRequest = { s?: boolean; // single use m?: string[]; // mints mp?: boolean; // mints preferred: strict list when absent or false, advisory list when true - fr?: number | bigint; // fee reserve (additional, in request unit, when paying from a mint outside a preferred list) + nf?: boolean; // net fees: requested amount is net of input fees when true sm?: RawSupportedMethod[]; // supported methods the payee accepts, each with an optional per-method fee d?: string; // description t?: RawTransport[]; // transports @@ -33,8 +33,9 @@ export type RawPaymentRequest = { }; /** - * A payment method the payee accepts, with an optional per-method fee the payer must add when using - * it. The fee stacks additively with the top-level {@link PaymentRequest.feeReserve} (`fr`). + * A payment method the payee accepts, with an optional per-method fee. The fee applies only when + * paying from a mint outside the request's mint list (or from any mint if no list is set); the + * payer owes the lowest fee among the listed methods their mint supports (NUT-18). */ export type SupportedMethod = { method: string; diff --git a/test/utils/tlv-roundtrip.test.ts b/test/utils/tlv-roundtrip.test.ts index d7aeb6a24..e22740c06 100644 --- a/test/utils/tlv-roundtrip.test.ts +++ b/test/utils/tlv-roundtrip.test.ts @@ -433,18 +433,18 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { }); }); - describe('Preferred Mint List with Fee Reserve and Supported Methods', () => { + describe('Preferred Mint List with Supported Methods and Net Fees', () => { // NUT-26 spec test vector — payment request with mp=true (preferred/advisory - // mint list), a fee required for non-preferred mints, and supported methods - // where bolt12 carries a per-method fee (mf=5) that stacks with fr. + // mint list), an amount net of input fees (nf=true), and supported methods + // where bolt12 carries a per-method fee (mf=5) for non-preferred mints. const encoded = - 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQSQQQQQQQQQQQQG9SQZGPQQRXYMMVWSCNZZCQZSQSQPNZDAK8GVFJQGQQSQQQQQQQQQQQQ5SX95HX'; + 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQZQGTQQYSZQQXVFHKCAP3XY9SQ9QPQQRXYMMVWSCNYQSQPQQQQQQQQQQQQPGZ0CGYS'; - test('roundtrip preferred mint list with fee reserve and supported methods', () => { - testRoundtrip(encoded, 'NUT-26 spec vector: mp=true, fr=2, sm=[bolt11, bolt12(mf=5)]'); + test('roundtrip preferred mint list with supported methods and net fees', () => { + testRoundtrip(encoded, 'NUT-26 spec vector: mp=true, nf=true, sm=[bolt11, bolt12(mf=5)]'); }); - test('verify mp, fr, sm fields', () => { + test('verify mp, nf, sm fields', () => { const bytes = decodeBech32mToBytes(encoded.toLowerCase()); const decoded = decodeTLV(bytes); @@ -453,7 +453,7 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { expect(decoded.unit).toBe('sat'); expect(decoded.mints).toEqual(['https://mint.example.com']); expect(decoded.mintsPreferred).toBe(true); - expect(decoded.feeReserve).toBe(BigInt(2)); + expect(decoded.netFees).toBe(true); expect(decoded.supportedMethods).toEqual([ { method: 'bolt11' }, { method: 'bolt12', fee: BigInt(5) }, @@ -463,7 +463,7 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { const finalDecoded = decodeTLV(reEncoded); expect(finalDecoded.mintsPreferred).toBe(true); - expect(finalDecoded.feeReserve).toBe(BigInt(2)); + expect(finalDecoded.netFees).toBe(true); expect(finalDecoded.supportedMethods).toEqual([ { method: 'bolt11' }, { method: 'bolt12', fee: BigInt(5) }, diff --git a/test/wallet/paymentRequests.test.ts b/test/wallet/paymentRequests.test.ts index 3c14ac1df..1c109fbd9 100644 --- a/test/wallet/paymentRequests.test.ts +++ b/test/wallet/paymentRequests.test.ts @@ -111,17 +111,17 @@ describe('payment requests', () => { expect(() => decodePaymentRequest(prWithInvalidVersion)).toThrow('unsupported pr version'); }); - describe('mint preferences (mp, fr, sm)', () => { - // NUT-18/NUT-26 spec vector: preferred mint list (mp=true) with fee reserve and - // supported methods. single_use is absent, so neither encoding emits it. Both strings - // are pinned to lock canonical output: minimal CBOR (creqA, `a7` not `b9 0007`) and - // minimal TLV with no redundant single_use=0 (creqB). + describe('mint preferences (mp, nf, sm)', () => { + // NUT-18/NUT-26 spec vector: preferred mint list (mp=true), amount net of input + // fees (nf=true) and supported methods. single_use is absent, so neither encoding + // emits it. Both strings are pinned to lock canonical output: minimal CBOR (creqA, + // `a7` not `b9 0007`) and minimal TLV with no redundant single_use=0 (creqB). const SPEC_CREQA = - 'creqAp2FpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWJtcPViZnICYnNtgqFibW5mYm9sdDExomJtbmZib2x0MTJibWYF'; + 'creqAp2FpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWJtcPVibmb1YnNtgqFibW5mYm9sdDExomJtbmZib2x0MTJibWYF'; const SPEC_CREQB = - 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQSQQQQQQQQQQQQG9SQZGPQQRXYMMVWSCNZZCQZSQSQPNZDAK8GVFJQGQQSQQQQQQQQQQQQ5SX95HX'; + 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQZQGTQQYSZQQXVFHKCAP3XY9SQ9QPQQRXYMMVWSCNYQSQPQQQQQQQQQQQQPGZ0CGYS'; - test('encode/decode preferred mint list with fee reserve and supported methods (creqA)', () => { + test('encode/decode preferred mint list with net fees and supported methods (creqA)', () => { const request = new PaymentRequest( undefined, 'preferred_fee_methods', @@ -132,7 +132,7 @@ describe('payment requests', () => { undefined, // singleUse absent undefined, true, // mintsPreferred (advisory list) - 2, // feeReserve + true, // netFees [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], // supportedMethods ); @@ -141,12 +141,12 @@ describe('payment requests', () => { const decoded = decodePaymentRequest(pr); expect(decoded.mintsPreferred).toBe(true); - expect(decoded.feeReserve?.equals(2)).toBeTruthy(); + expect(decoded.netFees).toBe(true); expect(decoded.supportedMethods?.map((m) => m.method)).toEqual(['bolt11', 'bolt12']); expect(decoded.supportedMethods?.[1].fee?.equals(5)).toBeTruthy(); }); - test('encode/decode preferred mint list with fee reserve and supported methods (creqB)', () => { + test('encode/decode preferred mint list with net fees and supported methods (creqB)', () => { const request = new PaymentRequest( undefined, 'preferred_fee_methods', @@ -157,7 +157,7 @@ describe('payment requests', () => { undefined, // singleUse absent undefined, true, - 2, + true, [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], ); @@ -166,13 +166,13 @@ describe('payment requests', () => { const decoded = PaymentRequest.fromEncodedRequest(encoded); expect(decoded.mintsPreferred).toBe(true); - expect(decoded.feeReserve?.equals(2)).toBeTruthy(); + expect(decoded.netFees).toBe(true); expect(decoded.supportedMethods?.map((m) => m.method)).toEqual(['bolt11', 'bolt12']); expect(decoded.supportedMethods?.[1].fee?.equals(5)).toBeTruthy(); }); - test('amountToSend stacks fr (non-preferred mint) and mf (per-method) fees', () => { - // Preferred list (mp=true), fr=2, bolt12 carries mf=5. + test('feesFor prices the lowest applicable per-method (mf) fee', () => { + // Preferred list (mp=true), bolt11 carries no fee, bolt12 carries mf=5. const pr = new PaymentRequest( undefined, 'fees', @@ -183,37 +183,40 @@ describe('payment requests', () => { undefined, undefined, true, - 2, + undefined, [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], ); - // In-list mint, no fee-bearing method: base only. - expect(pr.amountToSend('https://in.example.com').equals(100)).toBeTruthy(); - // In-list mint + bolt12: base + mf. - expect(pr.amountToSend('https://in.example.com', 'bolt12').equals(105)).toBeTruthy(); - // Outside mint + bolt11 (no mf): base + fr. - expect(pr.amountToSend('https://out.example.com', 'bolt11').equals(102)).toBeTruthy(); - // Outside mint + bolt12: base + fr + mf. - expect(pr.amountToSend('https://out.example.com', 'bolt12').equals(107)).toBeTruthy(); - - // Strict list (mp absent): fr never applies even from an outside mint. - const strict = new PaymentRequest( + // In-list mint: no per-method fee, whatever the mint supports. + expect(pr.amountToSend('https://in.example.com', ['bolt12']).equals(100)).toBeTruthy(); + // Outside mint supporting both methods: owes the lowest fee (bolt11 = 0). + expect( + pr.amountToSend('https://out.example.com', ['bolt11', 'bolt12']).equals(100), + ).toBeTruthy(); + // Outside mint supporting only the fee-bearing method: owes its mf. + expect(pr.amountToSend('https://out.example.com', ['bolt12']).equals(105)).toBeTruthy(); + // Mint methods unknown/unsupported: prices as 0 (admissibility is the caller's check). + expect(pr.amountToSend('https://out.example.com').equals(100)).toBeTruthy(); + + // No mint list: the fee applies from any mint. + const noList = new PaymentRequest( undefined, - 'strict', + 'nolist', 100, 'sat', - ['https://in.example.com'], undefined, undefined, undefined, undefined, - 2, + undefined, + undefined, + [{ method: 'bolt12', fee: 5 }], ); - expect(strict.amountToSend('https://out.example.com').equals(100)).toBeTruthy(); + expect(noList.amountToSend('https://any.example.com', ['bolt12']).equals(105)).toBeTruthy(); // feesFor returns the surcharge alone (0 when none applies). - expect(pr.feesFor('https://in.example.com').equals(0)).toBeTruthy(); - expect(pr.feesFor('https://out.example.com', 'bolt12').equals(7)).toBeTruthy(); + expect(pr.feesFor('https://in.example.com', ['bolt12']).equals(0)).toBeTruthy(); + expect(pr.feesFor('https://out.example.com', ['bolt12']).equals(5)).toBeTruthy(); // Amountless request: amountToSend throws, but feesFor still prices the surcharge so the // payer can add it to their chosen amount. @@ -231,10 +234,10 @@ describe('payment requests', () => { undefined, undefined, true, - 2, + undefined, [{ method: 'bolt12', fee: 5 }], ); - expect(mp.feesFor('https://out.example.com', 'bolt12').equals(7)).toBeTruthy(); + expect(mp.feesFor('https://out.example.com', ['bolt12']).equals(5)).toBeTruthy(); }); test('isMintListStrict resolves NUT-18 default-to-strict semantic', () => { @@ -308,18 +311,18 @@ describe('payment requests', () => { expect(fromZero.isMintListStrict).toBe(true); }); - test('mp/fr/sm absent by default (no serialization, no defaults injected)', () => { + test('mp/nf/sm absent by default (no serialization, no defaults injected)', () => { const request = new PaymentRequest(undefined, 'no_prefs', 100, 'sat', [ 'https://mint.example.com', ]); const raw = request.toRawRequest(); expect(raw.mp).toBeUndefined(); - expect(raw.fr).toBeUndefined(); + expect(raw.nf).toBeUndefined(); expect(raw.sm).toBeUndefined(); const decoded = decodePaymentRequest(request.toEncodedRequest()); expect(decoded.mintsPreferred).toBeUndefined(); - expect(decoded.feeReserve).toBeUndefined(); + expect(decoded.netFees).toBeUndefined(); expect(decoded.supportedMethods).toBeUndefined(); }); }); From f395c0d1743b26f0b44fcadb6592518260631034 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Mon, 6 Jul 2026 15:41:00 +0100 Subject: [PATCH 11/13] refactor(payment-request)!: constructor takes an options object Replace the 11-slot positional constructor with a single PaymentRequestOptions object whose keys mirror the class properties. Adjacent same-typed slots (three booleans) and undefined padding made positional calls unreadable and swap-prone, and every spec revision appended another slot. Decode paths are unaffected; a v4-style call fails to type-check. Documented in migration-5.0.0.md. --- docs-src/usage/payment_requests.md | 26 ++- etc/cashu-ts.api.md | 35 +++- migration-5.0.0.md | 30 +++ src/index.ts | 2 +- src/model/PaymentRequest.ts | 103 ++++++---- test/wallet/paymentRequests.test.ts | 309 +++++++++++++--------------- 6 files changed, 270 insertions(+), 235 deletions(-) diff --git a/docs-src/usage/payment_requests.md b/docs-src/usage/payment_requests.md index 48550b49d..4021a2b07 100644 --- a/docs-src/usage/payment_requests.md +++ b/docs-src/usage/payment_requests.md @@ -82,24 +82,22 @@ See [Create P2PK](./create_p2pk.md) for the builder. ## Create and encode a request (receiver side) -The `PaymentRequest` constructor is positional. Each method `fee` accepts any `AmountLike` (number, bigint, string, or `Amount`). +The `PaymentRequest` constructor takes an options object whose keys mirror the class properties; set only what you need. `amount` and each method `fee` accept any `AmountLike` (number, bigint, string, or `Amount`). ```typescript import { PaymentRequest, PaymentRequestTransportType } from '@cashu/cashu-ts'; -const request = new PaymentRequest( - [{ type: PaymentRequestTransportType.POST, target: 'https://pay.example.com' }], // transport - 'inv-123', // id - 100, // amount - 'sat', // unit - ['https://my.mint'], // mints - 'Coffee', // description - undefined, // singleUse (absent / true / false) - undefined, // nut10 locking condition - true, // mintsPreferred → advisory list - true, // netFees (nf) → amount is net of input fees - [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], // supportedMethods (sm) -); +const request = new PaymentRequest({ + transport: [{ type: PaymentRequestTransportType.POST, target: 'https://pay.example.com' }], + id: 'inv-123', + amount: 100, + unit: 'sat', + mints: ['https://my.mint'], + description: 'Coffee', + mintsPreferred: true, // advisory list + netFees: true, // amount is net of input fees + supportedMethods: [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], +}); request.toEncodedCreqA(); // 'creqA…' (CBOR) request.toEncodedCreqB(); // 'CREQB1…' (TLV + Bech32m, best for QR) diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index 3bb8941b3..144237727 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -1593,15 +1593,12 @@ export function parseSecret(secret: string | Secret): Secret; // @public (undocumented) class PaymentRequest_2 { - constructor(transport?: PaymentRequestTransport[] | undefined, id?: string | undefined, amount?: AmountLike, unit?: string | undefined, mints?: string[] | undefined, description?: string | undefined, singleUse?: boolean, nut10?: NUT10Option | undefined, mintsPreferred?: boolean, netFees?: boolean, supportedMethods?: Array<{ - method: string; - fee?: AmountLike; - }>); + constructor(options?: PaymentRequestOptions); // (undocumented) amount?: Amount; amountToSend(mint: string, mintMethods?: string[]): Amount; // (undocumented) - description?: string | undefined; + description?: string; feesFor(mint: string, mintMethods?: string[]): Amount; // (undocumented) static fromEncodedRequest(encodedRequest: string): PaymentRequest_2; @@ -1609,16 +1606,16 @@ class PaymentRequest_2 { // (undocumented) getTransport(type: PaymentRequestTransportType): PaymentRequestTransport | undefined; // (undocumented) - id?: string | undefined; + id?: string; get isMintListStrict(): boolean | undefined; // (undocumented) - mints?: string[] | undefined; + mints?: string[]; // (undocumented) mintsPreferred?: boolean; // (undocumented) netFees?: boolean; // (undocumented) - nut10?: NUT10Option | undefined; + nut10?: NUT10Option; // (undocumented) singleUse?: boolean; // (undocumented) @@ -1631,12 +1628,30 @@ class PaymentRequest_2 { // (undocumented) toRawRequest(): RawPaymentRequest; // (undocumented) - transport?: PaymentRequestTransport[] | undefined; + transport?: PaymentRequestTransport[]; // (undocumented) - unit?: string | undefined; + unit?: string; } export { PaymentRequest_2 as PaymentRequest } +// @public +export type PaymentRequestOptions = { + id?: string; + amount?: AmountLike; + unit?: string; + mints?: string[]; + description?: string; + transport?: PaymentRequestTransport[]; + singleUse?: boolean; + nut10?: NUT10Option; + mintsPreferred?: boolean; + netFees?: boolean; + supportedMethods?: Array<{ + method: string; + fee?: AmountLike; + }>; +}; + // @public (undocumented) export type PaymentRequestPayload = { id?: string; diff --git a/migration-5.0.0.md b/migration-5.0.0.md index 52c15af94..a128dbd51 100644 --- a/migration-5.0.0.md +++ b/migration-5.0.0.md @@ -234,3 +234,33 @@ asP2PK({ kind: 'HTLC', data: h, pubkeys: [a] }); ## `PaymentRequest.singleUse` is now optional (tri-state) `PaymentRequest.singleUse` is now `boolean | undefined` (was a required `boolean` defaulting to `false`), so the flag can round-trip the absent/`false`/`true` distinction instead of always serializing `single_use=0`. Setting `false` or `true` is unchanged; only decoding shifts — a request that omits the flag now yields `singleUse: undefined` instead of `false`. Replace any `pr.singleUse === false` check with `!pr.singleUse` (true for both absent and explicit `false`). + +--- + +## `PaymentRequest` constructor takes an options object + +The v4 constructor was positional (`new PaymentRequest(transport, id, amount, unit, mints, description, singleUse, nut10)`); it now takes a single `PaymentRequestOptions` object whose keys mirror the class properties, so only the fields you set need naming: + +```ts +// v4 — unused optional slots need explicit fillers +new PaymentRequest( + undefined, // transport + 'inv-123', + 100, + 'sat', + ['https://my.mint'], + undefined, // description + true, // singleUse +); + +// v5 — name only the fields you set +new PaymentRequest({ + id: 'inv-123', + amount: 100, + unit: 'sat', + mints: ['https://my.mint'], + singleUse: true, +}); +``` + +Decoding (`decodePaymentRequest`, `PaymentRequest.fromEncodedRequest`, `fromRawRequest`) is unaffected. A positional call fails to type-check. diff --git a/src/index.ts b/src/index.ts index bf855a804..35ebfc8dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,7 +77,7 @@ export * from './utils/core'; export { JSONInt, type JSONIntApi } from './utils/JSONInt'; // Payment request facade (tests rely on these at top level) -export { PaymentRequest } from './model/PaymentRequest'; +export { PaymentRequest, type PaymentRequestOptions } from './model/PaymentRequest'; export { PaymentRequestTransportType } from './wallet/types'; export type { PaymentRequestPayload, diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index fdda44823..788221919 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -17,37 +17,56 @@ import type { import { Amount, type AmountLike } from './Amount'; import { CTSError } from './Errors'; +/** + * Constructor options for {@link PaymentRequest}. Keys mirror the class properties; `amount`, + * `netFees` and method `fee` values accept flexible input and are normalized on construction. + */ +export type PaymentRequestOptions = { + id?: string; + amount?: AmountLike; + unit?: string; + mints?: string[]; + description?: string; + transport?: PaymentRequestTransport[]; + singleUse?: boolean; + nut10?: NUT10Option; + mintsPreferred?: boolean; + netFees?: boolean; + supportedMethods?: Array<{ method: string; fee?: AmountLike }>; +}; + export class PaymentRequest { + public id?: string; public amount?: Amount; - public netFees?: boolean; + public unit?: string; + public mints?: string[]; + public description?: string; + public transport?: PaymentRequestTransport[]; public singleUse?: boolean; + public nut10?: NUT10Option; public mintsPreferred?: boolean; + public netFees?: boolean; public supportedMethods?: SupportedMethod[]; - constructor( - public transport?: PaymentRequestTransport[], - public id?: string, - amount?: AmountLike, - public unit?: string, - public mints?: string[], - public description?: string, - singleUse?: boolean, - public nut10?: NUT10Option, - mintsPreferred?: boolean, - netFees?: boolean, - supportedMethods?: Array<{ method: string; fee?: AmountLike }>, - ) { - this.amount = amount !== undefined ? Amount.from(amount) : undefined; - this.supportedMethods = supportedMethods?.map((m) => ({ + constructor(options: PaymentRequestOptions = {}) { + this.id = options.id; + this.unit = options.unit; + this.mints = options.mints; + this.description = options.description; + this.transport = options.transport; + this.nut10 = options.nut10; + this.amount = options.amount !== undefined ? Amount.from(options.amount) : undefined; + this.supportedMethods = options.supportedMethods?.map((m) => ({ method: m.method, fee: m.fee !== undefined ? Amount.from(m.fee) : undefined, })); // Coerce the optional flags to real booleans (preserving `undefined` for the // absent/tri-state case) so an untyped CBOR value (`0`/`1`/`null`) can't leak a // non-boolean into the getter or get re-serialized verbatim over the wire. - this.singleUse = singleUse === undefined ? undefined : Boolean(singleUse); - this.mintsPreferred = mintsPreferred === undefined ? undefined : Boolean(mintsPreferred); - this.netFees = netFees === undefined ? undefined : Boolean(netFees); + this.singleUse = options.singleUse === undefined ? undefined : Boolean(options.singleUse); + this.mintsPreferred = + options.mintsPreferred === undefined ? undefined : Boolean(options.mintsPreferred); + this.netFees = options.netFees === undefined ? undefined : Boolean(options.netFees); } /** @@ -293,19 +312,19 @@ export class PaymentRequest { } : undefined; const supportedMethods = rawPaymentRequest.sm?.map((m) => ({ method: m.mn, fee: m.mf })); - return new PaymentRequest( - transports, - rawPaymentRequest.i, - rawPaymentRequest.a, - rawPaymentRequest.u, - rawPaymentRequest.m, - rawPaymentRequest.d, - rawPaymentRequest.s, + return new PaymentRequest({ + transport: transports, + id: rawPaymentRequest.i, + amount: rawPaymentRequest.a, + unit: rawPaymentRequest.u, + mints: rawPaymentRequest.m, + description: rawPaymentRequest.d, + singleUse: rawPaymentRequest.s, nut10, - rawPaymentRequest.mp, - rawPaymentRequest.nf, + mintsPreferred: rawPaymentRequest.mp, + netFees: rawPaymentRequest.nf, supportedMethods, - ); + }); } static fromEncodedRequest(encodedRequest: string): PaymentRequest { @@ -322,19 +341,19 @@ export class PaymentRequest { tags: decoded.nut10.tags ?? [], } : undefined; - return new PaymentRequest( - decoded.transports, - decoded.id, - decoded.amount, - decoded.unit, - decoded.mints, - decoded.description, - decoded.singleUse, + return new PaymentRequest({ + transport: decoded.transports, + id: decoded.id, + amount: decoded.amount, + unit: decoded.unit, + mints: decoded.mints, + description: decoded.description, + singleUse: decoded.singleUse, nut10, - decoded.mintsPreferred, - decoded.netFees, - decoded.supportedMethods, - ); + mintsPreferred: decoded.mintsPreferred, + netFees: decoded.netFees, + supportedMethods: decoded.supportedMethods, + }); } // Version A: CBOR encoding (creqA...) diff --git a/test/wallet/paymentRequests.test.ts b/test/wallet/paymentRequests.test.ts index 144c6a948..67b028d37 100644 --- a/test/wallet/paymentRequests.test.ts +++ b/test/wallet/paymentRequests.test.ts @@ -12,26 +12,26 @@ import { encodeTLV } from '../../src/utils/tlv'; describe('payment requests', () => { test('encode payment requests', async () => { - const request = new PaymentRequest( - [ + const request = new PaymentRequest({ + transport: [ { type: PaymentRequestTransportType.NOSTR, target: 'asd', tags: [['n', '17']], }, ], - '4840f51e', - 1000, - 'sat', - ['https://mint.com'], - 'test', - true, // single use - { + id: '4840f51e', + amount: 1000, + unit: 'sat', + mints: ['https://mint.com'], + description: 'test', + singleUse: true, + nut10: { kind: 'P2PK', data: 'pubkey', tags: [['tag', 'tag-value']], }, - ); + }); const pr = request.toEncodedRequest(); expect(pr).toBeDefined(); const decodedRequest = decodePaymentRequest(pr); @@ -83,18 +83,18 @@ describe('payment requests', () => { }); test('encode and decode payment request with bigint amount (uint64)', async () => { const largeAmount = 2n ** 53n + 1n; // exceeds Number.MAX_SAFE_INTEGER - const request = new PaymentRequest( - [ + const request = new PaymentRequest({ + transport: [ { type: PaymentRequestTransportType.POST, target: 'https://example.com/pay', }, ], - 'bigint_test', - largeAmount, - 'sat', - ['https://mint.com'], - ); + id: 'bigint_test', + amount: largeAmount, + unit: 'sat', + mints: ['https://mint.com'], + }); const pr = request.toEncodedRequest(); expect(pr).toBeDefined(); const decoded = decodePaymentRequest(pr); @@ -124,19 +124,15 @@ describe('payment requests', () => { 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQZQGTQQYSZQQXVFHKCAP3XY9SQ9QPQQRXYMMVWSCNYQSQPQQQQQQQQQQQQPGZ0CGYS'; test('encode/decode preferred mint list with net fees and supported methods (creqA)', () => { - const request = new PaymentRequest( - undefined, - 'preferred_fee_methods', - 100, - 'sat', - ['https://mint.example.com'], - undefined, - undefined, // singleUse absent - undefined, - true, // mintsPreferred (advisory list) - true, // netFees - [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], // supportedMethods - ); + const request = new PaymentRequest({ + id: 'preferred_fee_methods', + amount: 100, + unit: 'sat', + mints: ['https://mint.example.com'], + mintsPreferred: true, // advisory list + netFees: true, + supportedMethods: [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], + }); const pr = request.toEncodedRequest(); expect(pr).toBe(SPEC_CREQA); @@ -149,19 +145,15 @@ describe('payment requests', () => { }); test('encode/decode preferred mint list with net fees and supported methods (creqB)', () => { - const request = new PaymentRequest( - undefined, - 'preferred_fee_methods', - 100, - 'sat', - ['https://mint.example.com'], - undefined, - undefined, // singleUse absent - undefined, - true, - true, - [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], - ); + const request = new PaymentRequest({ + id: 'preferred_fee_methods', + amount: 100, + unit: 'sat', + mints: ['https://mint.example.com'], + mintsPreferred: true, + netFees: true, + supportedMethods: [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], + }); const encoded = request.toEncodedCreqB(); expect(encoded).toBe(SPEC_CREQB); @@ -175,19 +167,14 @@ describe('payment requests', () => { test('feesFor prices the lowest applicable per-method (mf) fee', () => { // Preferred list (mp=true), bolt11 carries no fee, bolt12 carries mf=5. - const pr = new PaymentRequest( - undefined, - 'fees', - 100, - 'sat', - ['https://in.example.com'], - undefined, - undefined, - undefined, - true, - undefined, - [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], - ); + const pr = new PaymentRequest({ + id: 'fees', + amount: 100, + unit: 'sat', + mints: ['https://in.example.com'], + mintsPreferred: true, + supportedMethods: [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], + }); // In-list mint: no per-method fee, whatever the mint supports. expect(pr.amountToSend('https://in.example.com', ['bolt12']).equals(100)).toBeTruthy(); @@ -201,19 +188,12 @@ describe('payment requests', () => { expect(pr.amountToSend('https://out.example.com').equals(100)).toBeTruthy(); // No mint list: the fee applies from any mint. - const noList = new PaymentRequest( - undefined, - 'nolist', - 100, - 'sat', - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - [{ method: 'bolt12', fee: 5 }], - ); + const noList = new PaymentRequest({ + id: 'nolist', + amount: 100, + unit: 'sat', + supportedMethods: [{ method: 'bolt12', fee: 5 }], + }); expect(noList.amountToSend('https://any.example.com', ['bolt12']).equals(105)).toBeTruthy(); // feesFor returns the surcharge alone (0 when none applies). @@ -222,59 +202,52 @@ describe('payment requests', () => { // Amountless request: amountToSend throws, but feesFor still prices the surcharge so the // payer can add it to their chosen amount. - const noAmount = new PaymentRequest(undefined, 'noamt', undefined, 'sat', [ - 'https://in.example.com', - ]); + const noAmount = new PaymentRequest({ + id: 'noamt', + unit: 'sat', + mints: ['https://in.example.com'], + }); expect(() => noAmount.amountToSend('https://x.example.com')).toThrow(); - const mp = new PaymentRequest( - undefined, - 'noamt_mp', - undefined, - 'sat', - ['https://in.example.com'], - undefined, - undefined, - undefined, - true, - undefined, - [{ method: 'bolt12', fee: 5 }], - ); + const mp = new PaymentRequest({ + id: 'noamt_mp', + unit: 'sat', + mints: ['https://in.example.com'], + mintsPreferred: true, + supportedMethods: [{ method: 'bolt12', fee: 5 }], + }); expect(mp.feesFor('https://out.example.com', ['bolt12']).equals(5)).toBeTruthy(); }); test('isMintListStrict resolves NUT-18 default-to-strict semantic', () => { - const noMints = new PaymentRequest(undefined, 'no_mints', 100, 'sat'); + const noMints = new PaymentRequest({ id: 'no_mints', amount: 100, unit: 'sat' }); expect(noMints.isMintListStrict).toBeUndefined(); - const mintsOnly = new PaymentRequest(undefined, 'mints_only', 100, 'sat', [ - 'https://mint.example.com', - ]); + const mintsOnly = new PaymentRequest({ + id: 'mints_only', + amount: 100, + unit: 'sat', + mints: ['https://mint.example.com'], + }); expect(mintsOnly.isMintListStrict).toBe(true); - const explicitStrict = new PaymentRequest( - undefined, - 'explicit_strict', - 100, - 'sat', - ['https://mint.example.com'], - undefined, - false, - undefined, - false, // mintsPreferred === false is strict - ); + const explicitStrict = new PaymentRequest({ + id: 'explicit_strict', + amount: 100, + unit: 'sat', + mints: ['https://mint.example.com'], + singleUse: false, + mintsPreferred: false, // explicit false is strict + }); expect(explicitStrict.isMintListStrict).toBe(true); - const preferred = new PaymentRequest( - undefined, - 'preferred', - 100, - 'sat', - ['https://mint.example.com'], - undefined, - false, - undefined, - true, // mintsPreferred === true is advisory - ); + const preferred = new PaymentRequest({ + id: 'preferred', + amount: 100, + unit: 'sat', + mints: ['https://mint.example.com'], + singleUse: false, + mintsPreferred: true, // true is advisory + }); expect(preferred.isMintListStrict).toBe(false); // Decoded request with mints set and mp absent — should resolve to strict @@ -314,9 +287,12 @@ describe('payment requests', () => { }); test('mp/nf/sm absent by default (no serialization, no defaults injected)', () => { - const request = new PaymentRequest(undefined, 'no_prefs', 100, 'sat', [ - 'https://mint.example.com', - ]); + const request = new PaymentRequest({ + id: 'no_prefs', + amount: 100, + unit: 'sat', + mints: ['https://mint.example.com'], + }); const raw = request.toRawRequest(); expect(raw.mp).toBeUndefined(); expect(raw.nf).toBeUndefined(); @@ -340,19 +316,19 @@ describe('payment requests', () => { }); test('emits only the fields that are set', () => { - const request = new PaymentRequest(undefined, 'the-id', 1000, 'sat', undefined, undefined); + const request = new PaymentRequest({ id: 'the-id', amount: 1000, unit: 'sat' }); expect(request.toRawRequest()).toStrictEqual({ i: 'the-id', a: 1000n, u: 'sat' }); }); }); describe('toEncodedCreqA', () => { test('produces the creqA (CBOR) encoding, identical to toEncodedRequest', () => { - const request = new PaymentRequest( - [{ type: PaymentRequestTransportType.POST, target: 'https://pay.example' }], - 'creqa-id', - 1000, - 'sat', - ); + const request = new PaymentRequest({ + transport: [{ type: PaymentRequestTransportType.POST, target: 'https://pay.example' }], + id: 'creqa-id', + amount: 1000, + unit: 'sat', + }); const encoded = request.toEncodedCreqA(); expect(encoded.startsWith('creqA')).toBe(true); expect(encoded).toBe(request.toEncodedRequest()); @@ -365,15 +341,15 @@ describe('payment requests', () => { describe('getTransport', () => { test('returns undefined when the request has no transports', () => { - const request = new PaymentRequest(undefined, 'id'); + const request = new PaymentRequest({ id: 'id' }); expect(request.getTransport(PaymentRequestTransportType.NOSTR)).toBeUndefined(); }); test('matches on transport type and returns undefined for an absent type', () => { - const request = new PaymentRequest( - [{ type: PaymentRequestTransportType.POST, target: 'https://pay.example' }], - 'id', - ); + const request = new PaymentRequest({ + transport: [{ type: PaymentRequestTransportType.POST, target: 'https://pay.example' }], + id: 'id', + }); expect(request.getTransport(PaymentRequestTransportType.NOSTR)).toBeUndefined(); expect(request.getTransport(PaymentRequestTransportType.POST)?.target).toBe( 'https://pay.example', @@ -383,21 +359,21 @@ describe('payment requests', () => { describe('toEncodedCreqB - creqB format (TLV + bech32m)', () => { test('encode and decode basic payment request with nostr transport', () => { - const pr = new PaymentRequest( - [ + const pr = new PaymentRequest({ + transport: [ { type: PaymentRequestTransportType.NOSTR, target: 'nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8g2lcy6q', tags: [['n', '17']], }, ], - 'test_id_123', - 500, - 'sat', - ['https://mint.example.com'], - 'Test payment request', - true, - ); + id: 'test_id_123', + amount: 500, + unit: 'sat', + mints: ['https://mint.example.com'], + description: 'Test payment request', + singleUse: true, + }); const encoded = pr.toEncodedCreqB(); @@ -417,8 +393,8 @@ describe('payment requests', () => { }); test('encode and decode payment request with POST transport', () => { - const pr = new PaymentRequest( - [ + const pr = new PaymentRequest({ + transport: [ { type: PaymentRequestTransportType.POST, target: 'https://api.example.com/payment', @@ -428,13 +404,12 @@ describe('payment requests', () => { ], }, ], - 'http_test', - 250, - 'sat', - ['https://mint.example.com'], - undefined, - false, - ); + id: 'http_test', + amount: 250, + unit: 'sat', + mints: ['https://mint.example.com'], + singleUse: false, + }); const encoded = pr.toEncodedCreqB(); const decoded = PaymentRequest.fromEncodedRequest(encoded); @@ -450,9 +425,11 @@ describe('payment requests', () => { }); test('encode and decode minimal payment request', () => { - const pr = new PaymentRequest(undefined, 'minimal_id', undefined, 'sat', [ - 'https://mint.example.com', - ]); + const pr = new PaymentRequest({ + id: 'minimal_id', + unit: 'sat', + mints: ['https://mint.example.com'], + }); const encoded = pr.toEncodedCreqB(); const decoded = PaymentRequest.fromEncodedRequest(encoded); @@ -465,15 +442,14 @@ describe('payment requests', () => { }); test('encode and decode payment request with NUT-10', () => { - const pr = new PaymentRequest( - undefined, - 'p2pk_test', - 1000, - 'sat', - ['https://mint.example.com'], - 'Locked payment', - false, - { + const pr = new PaymentRequest({ + id: 'p2pk_test', + amount: 1000, + unit: 'sat', + mints: ['https://mint.example.com'], + description: 'Locked payment', + singleUse: false, + nut10: { kind: 'P2PK', data: '02abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab', tags: [ @@ -481,7 +457,7 @@ describe('payment requests', () => { ['refund', '03abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890cd'], ], }, - ); + }); const encoded = pr.toEncodedCreqB(); const decoded = PaymentRequest.fromEncodedRequest(encoded); @@ -502,20 +478,17 @@ describe('payment requests', () => { }); test('encode and decode payment request with tagless NUT-10', () => { - const pr = new PaymentRequest( - undefined, - 'p2pk_test', - 1000, - 'sat', - undefined, - undefined, - false, - { + const pr = new PaymentRequest({ + id: 'p2pk_test', + amount: 1000, + unit: 'sat', + singleUse: false, + nut10: { kind: 'P2PK', data: '02abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab', tags: [], }, - ); + }); const decoded = PaymentRequest.fromEncodedRequest(pr.toEncodedCreqB()); @@ -561,7 +534,7 @@ describe('payment requests', () => { const HASH = '5d3f2c1b0a99887766554433221100ffeeddccbbaa99887766554433221100ff'; const prWithNut10 = (nut10?: NUT10Option) => - new PaymentRequest(undefined, 'id', 1, 'sat', undefined, undefined, false, nut10); + new PaymentRequest({ id: 'id', amount: 1, unit: 'sat', singleUse: false, nut10 }); test('returns undefined when there is no nut10 option', () => { expect(prWithNut10(undefined).toP2PKOptions()).toBeUndefined(); From 5d18b173b95f506a2f899566ec14b694ff427e44 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Wed, 8 Jul 2026 14:03:05 +0100 Subject: [PATCH 12/13] refactor(payment-request)!: drop nf, requested amount is always net of input fees NUT-18 dropped the nf flag: the requested amount is always net of input fees, so payers should select proofs with includeFees. The supported_method TLV moves to tag 0x0a and the pinned spec vectors are updated to match. --- docs-src/usage/payment_requests.md | 7 ++----- etc/cashu-ts.api.md | 4 ---- src/model/PaymentRequest.ts | 13 ++----------- src/utils/tlv.ts | 20 +++++--------------- src/wallet/types/payment-requests.ts | 1 - test/utils/tlv-roundtrip.test.ts | 24 +++++++++++------------- test/wallet/paymentRequests.test.ts | 26 ++++++++++---------------- 7 files changed, 30 insertions(+), 65 deletions(-) diff --git a/docs-src/usage/payment_requests.md b/docs-src/usage/payment_requests.md index 4021a2b07..b85db282c 100644 --- a/docs-src/usage/payment_requests.md +++ b/docs-src/usage/payment_requests.md @@ -58,12 +58,10 @@ For an **amountless** request (the payer chooses the amount), use `feesFor` to p const total = chosenAmount.add(pr.feesFor(myMint, ['bolt12'])); // mf, or 0 if none applies ``` -If the request sets `netFees` (`nf`), the requested amount is **net of input fees**: the receiver must be able to swap or melt the proofs without dipping below it. Select proofs with fees included: +The requested amount is **net of input fees** (NUT-18): the receiver must be able to swap or melt the proofs without dipping below it. Select proofs with fees included: ```typescript -const builder = wallet.ops.send(total, proofs); -if (pr.netFees) builder.includeFees(true); // sender covers the receiver's input fee -await builder.run(); +await wallet.ops.send(total, proofs).includeFees(true).run(); // sender covers the receiver's input fee ``` ## Locked requests @@ -95,7 +93,6 @@ const request = new PaymentRequest({ mints: ['https://my.mint'], description: 'Coffee', mintsPreferred: true, // advisory list - netFees: true, // amount is net of input fees supportedMethods: [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], }); diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index 144237727..25d4d1335 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -1613,8 +1613,6 @@ class PaymentRequest_2 { // (undocumented) mintsPreferred?: boolean; // (undocumented) - netFees?: boolean; - // (undocumented) nut10?: NUT10Option; // (undocumented) singleUse?: boolean; @@ -1645,7 +1643,6 @@ export type PaymentRequestOptions = { singleUse?: boolean; nut10?: NUT10Option; mintsPreferred?: boolean; - netFees?: boolean; supportedMethods?: Array<{ method: string; fee?: AmountLike; @@ -1770,7 +1767,6 @@ export type RawPaymentRequest = { s?: boolean; m?: string[]; mp?: boolean; - nf?: boolean; sm?: RawSupportedMethod[]; d?: string; t?: RawTransport[]; diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index 788221919..babdd6d1c 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -18,8 +18,8 @@ import { Amount, type AmountLike } from './Amount'; import { CTSError } from './Errors'; /** - * Constructor options for {@link PaymentRequest}. Keys mirror the class properties; `amount`, - * `netFees` and method `fee` values accept flexible input and are normalized on construction. + * Constructor options for {@link PaymentRequest}. Keys mirror the class properties; `amount` and + * method `fee` values accept flexible input and are normalized on construction. */ export type PaymentRequestOptions = { id?: string; @@ -31,7 +31,6 @@ export type PaymentRequestOptions = { singleUse?: boolean; nut10?: NUT10Option; mintsPreferred?: boolean; - netFees?: boolean; supportedMethods?: Array<{ method: string; fee?: AmountLike }>; }; @@ -45,7 +44,6 @@ export class PaymentRequest { public singleUse?: boolean; public nut10?: NUT10Option; public mintsPreferred?: boolean; - public netFees?: boolean; public supportedMethods?: SupportedMethod[]; constructor(options: PaymentRequestOptions = {}) { @@ -66,7 +64,6 @@ export class PaymentRequest { this.singleUse = options.singleUse === undefined ? undefined : Boolean(options.singleUse); this.mintsPreferred = options.mintsPreferred === undefined ? undefined : Boolean(options.mintsPreferred); - this.netFees = options.netFees === undefined ? undefined : Boolean(options.netFees); } /** @@ -154,9 +151,6 @@ export class PaymentRequest { if (this.mintsPreferred !== undefined) { rawRequest.mp = this.mintsPreferred; } - if (this.netFees !== undefined) { - rawRequest.nf = this.netFees; - } if (this.supportedMethods && this.supportedMethods.length > 0) { rawRequest.sm = this.supportedMethods.map((m) => m.fee !== undefined ? { mn: m.method, mf: m.fee.toBigInt() } : { mn: m.method }, @@ -208,7 +202,6 @@ export class PaymentRequest { singleUse: this.singleUse, mints: this.mints, mintsPreferred: this.mintsPreferred, - netFees: this.netFees, supportedMethods: this.supportedMethods?.map((m) => ({ method: m.method, fee: m.fee !== undefined ? m.fee.toBigInt() : undefined, @@ -322,7 +315,6 @@ export class PaymentRequest { singleUse: rawPaymentRequest.s, nut10, mintsPreferred: rawPaymentRequest.mp, - netFees: rawPaymentRequest.nf, supportedMethods, }); } @@ -351,7 +343,6 @@ export class PaymentRequest { singleUse: decoded.singleUse, nut10, mintsPreferred: decoded.mintsPreferred, - netFees: decoded.netFees, supportedMethods: decoded.supportedMethods, }); } diff --git a/src/utils/tlv.ts b/src/utils/tlv.ts index aab6ddfc4..94276ce16 100644 --- a/src/utils/tlv.ts +++ b/src/utils/tlv.ts @@ -23,7 +23,6 @@ export type DecodedTLVPaymentRequest = { singleUse?: boolean; mints?: string[]; mintsPreferred?: boolean; - netFees?: boolean; supportedMethods?: Array<{ method: string; fee?: bigint }>; description?: string; transports?: PaymentRequestTransport[]; @@ -44,8 +43,7 @@ export type DecodedTLVPaymentRequest = { * | 0x07 | transport | sub-TLV | Transport configuration (repeatable) | * | 0x08 | nut10 | sub-TLV | NUT-10 spending conditions (not yet implemented) | * | 0x09 | mint_preferred | u8 | Mint list strictness flag: 0=false, 1=true; if absent, defaults to 0 (strict) | - * | 0x0a | net_fees | u8 | Net-of-input-fees flag: 0=false, 1=true; if absent, defaults to 0 | - * | 0x0b | supported_method | sub-TLV | Supported payment method with an optional per-method fee (repeatable) | + * | 0x0a | supported_method | sub-TLV | Supported payment method with an optional per-method fee (repeatable) | */ const TAG_ID = 0x01; const TAG_AMOUNT = 0x02; @@ -56,8 +54,7 @@ const TAG_DESCRIPTION = 0x06; const TAG_TRANSPORT = 0x07; const TAG_NUT10 = 0x08; const TAG_MINT_PREFERRED = 0x09; -const TAG_NET_FEES = 0x0a; -const TAG_SUPPORTED_METHODS = 0x0b; +const TAG_SUPPORTED_METHODS = 0x0a; /** * Transport Sub-TLV Tag definitions. @@ -92,7 +89,7 @@ const NUT10_KIND_P2PK = 0; const NUT10_KIND_HTLC = 1; /** - * Supported Method Sub-TLV Tag definitions (NUT-26 tag 0x0b). + * Supported Method Sub-TLV Tag definitions (NUT-26 tag 0x0a). * * | Sub-Tag | Field | Type | Description | * | ------- | ------ | ------ | --------------------------------- | @@ -162,9 +159,6 @@ export function decodeTLV(data: Uint8Array): DecodedTLVPaymentRequest { case TAG_MINT_PREFERRED: result.mintsPreferred = parseU8(part.value) === 1; break; - case TAG_NET_FEES: - result.netFees = parseU8(part.value) === 1; - break; case TAG_SUPPORTED_METHODS: if (!result.supportedMethods) { result.supportedMethods = []; @@ -373,7 +367,7 @@ function parseNut10(value: Uint8Array): Nut10SpendingCondition { } /** - * Parses a supported method (NUT-26 tag 0x0b) from its sub-TLV value. + * Parses a supported method (NUT-26 tag 0x0a) from its sub-TLV value. * * @param value - The supported_method sub-TLV value bytes. * @returns Parsed method with an optional per-method fee. @@ -504,10 +498,6 @@ export function encodeTLV(request: DecodedTLVPaymentRequest): Uint8Array { parts.push(encodeTLVPart(TAG_MINT_PREFERRED, encodeU8(request.mintsPreferred ? 1 : 0))); } - if (request.netFees !== undefined) { - parts.push(encodeTLVPart(TAG_NET_FEES, encodeU8(request.netFees ? 1 : 0))); - } - // Repeatable: supported_method if (request.supportedMethods && request.supportedMethods.length > 0) { for (const method of request.supportedMethods) { @@ -673,7 +663,7 @@ function encodeNut10(nut10: Nut10SpendingCondition): Uint8Array { } /** - * Encodes a supported method into its TLV sub-structure (NUT-26 tag 0x0b). + * Encodes a supported method into its TLV sub-structure (NUT-26 tag 0x0a). * * @param method - The method name with an optional per-method fee. * @returns Encoded supported_method sub-TLV. diff --git a/src/wallet/types/payment-requests.ts b/src/wallet/types/payment-requests.ts index 167c74be7..266b02ef0 100644 --- a/src/wallet/types/payment-requests.ts +++ b/src/wallet/types/payment-requests.ts @@ -25,7 +25,6 @@ export type RawPaymentRequest = { s?: boolean; // single use m?: string[]; // mints mp?: boolean; // mints preferred: strict list when absent or false, advisory list when true - nf?: boolean; // net fees: requested amount is net of input fees when true sm?: RawSupportedMethod[]; // supported methods the payee accepts, each with an optional per-method fee d?: string; // description t?: RawTransport[]; // transports diff --git a/test/utils/tlv-roundtrip.test.ts b/test/utils/tlv-roundtrip.test.ts index 96db01257..cd13f335c 100644 --- a/test/utils/tlv-roundtrip.test.ts +++ b/test/utils/tlv-roundtrip.test.ts @@ -177,11 +177,11 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { }); describe('Supported Method sub-TLV (malformed)', () => { - // supported_method (tag 0x0b) is a sub-TLV: 0x01 method (string), 0x02 fee (u64). + // supported_method (tag 0x0a) is a sub-TLV: 0x01 method (string), 0x02 fee (u64). // Duplicate singular sub-tags and a missing method must be rejected, not last-wins. test('rejects duplicate method sub-tag', () => { const malformed = new Uint8Array([ - 0x0b, + 0x0a, 0x00, 0x08, // TAG_SUPPORTED_METHODS, length 8 0x01, @@ -198,7 +198,7 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { test('rejects duplicate fee sub-tag', () => { const malformed = new Uint8Array([ - 0x0b, + 0x0a, 0x00, 26, // TAG_SUPPORTED_METHODS, length 26 0x01, @@ -233,7 +233,7 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { test('rejects supported_method missing its method field', () => { const malformed = new Uint8Array([ - 0x0b, + 0x0a, 0x00, 0x0b, // TAG_SUPPORTED_METHODS, length 11 0x02, @@ -434,18 +434,18 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { }); }); - describe('Preferred Mint List with Supported Methods and Net Fees', () => { + describe('Preferred Mint List with Supported Methods', () => { // NUT-26 spec test vector — payment request with mp=true (preferred/advisory - // mint list), an amount net of input fees (nf=true), and supported methods - // where bolt12 carries a per-method fee (mf=5) for non-preferred mints. + // mint list) and supported methods where bolt12 carries a per-method fee + // (mf=5) for non-preferred mints. const encoded = - 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQZQGTQQYSZQQXVFHKCAP3XY9SQ9QPQQRXYMMVWSCNYQSQPQQQQQQQQQQQQPGZ0CGYS'; + 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQJQGQQE3X7MR5XYCS5QQ5QYQQVCN0D36RZVSZQQYQQQQQQQQQQQQ9FJ2568'; - test('roundtrip preferred mint list with supported methods and net fees', () => { - testRoundtrip(encoded, 'NUT-26 spec vector: mp=true, nf=true, sm=[bolt11, bolt12(mf=5)]'); + test('roundtrip preferred mint list with supported methods', () => { + testRoundtrip(encoded, 'NUT-26 spec vector: mp=true, sm=[bolt11, bolt12(mf=5)]'); }); - test('verify mp, nf, sm fields', () => { + test('verify mp, sm fields', () => { const bytes = decodeBech32mToBytes(encoded.toLowerCase()); const decoded = decodeTLV(bytes); @@ -454,7 +454,6 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { expect(decoded.unit).toBe('sat'); expect(decoded.mints).toEqual(['https://mint.example.com']); expect(decoded.mintsPreferred).toBe(true); - expect(decoded.netFees).toBe(true); expect(decoded.supportedMethods).toEqual([ { method: 'bolt11' }, { method: 'bolt12', fee: BigInt(5) }, @@ -464,7 +463,6 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { const finalDecoded = decodeTLV(reEncoded); expect(finalDecoded.mintsPreferred).toBe(true); - expect(finalDecoded.netFees).toBe(true); expect(finalDecoded.supportedMethods).toEqual([ { method: 'bolt11' }, { method: 'bolt12', fee: BigInt(5) }, diff --git a/test/wallet/paymentRequests.test.ts b/test/wallet/paymentRequests.test.ts index 67b028d37..a22ba8d93 100644 --- a/test/wallet/paymentRequests.test.ts +++ b/test/wallet/paymentRequests.test.ts @@ -113,24 +113,23 @@ describe('payment requests', () => { expect(() => decodePaymentRequest(prWithInvalidVersion)).toThrow('unsupported pr version'); }); - describe('mint preferences (mp, nf, sm)', () => { - // NUT-18/NUT-26 spec vector: preferred mint list (mp=true), amount net of input - // fees (nf=true) and supported methods. single_use is absent, so neither encoding - // emits it. Both strings are pinned to lock canonical output: minimal CBOR (creqA, - // `a7` not `b9 0007`) and minimal TLV with no redundant single_use=0 (creqB). + describe('mint preferences (mp, sm)', () => { + // NUT-18/NUT-26 spec vector: preferred mint list (mp=true) and supported + // methods. single_use is absent, so neither encoding emits it. Both strings + // are pinned to lock canonical output: minimal CBOR (creqA, `a6` not + // `b9 0006`) and minimal TLV with no redundant single_use=0 (creqB). const SPEC_CREQA = - 'creqAp2FpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWJtcPVibmb1YnNtgqFibW5mYm9sdDExomJtbmZib2x0MTJibWYF'; + 'creqApmFpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWJtcPVic22CoWJtbmZib2x0MTGiYm1uZmJvbHQxMmJtZgU='; const SPEC_CREQB = - 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQZQGTQQYSZQQXVFHKCAP3XY9SQ9QPQQRXYMMVWSCNYQSQPQQQQQQQQQQQQPGZ0CGYS'; + 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQJQGQQE3X7MR5XYCS5QQ5QYQQVCN0D36RZVSZQQYQQQQQQQQQQQQ9FJ2568'; - test('encode/decode preferred mint list with net fees and supported methods (creqA)', () => { + test('encode/decode preferred mint list with supported methods (creqA)', () => { const request = new PaymentRequest({ id: 'preferred_fee_methods', amount: 100, unit: 'sat', mints: ['https://mint.example.com'], mintsPreferred: true, // advisory list - netFees: true, supportedMethods: [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], }); @@ -139,19 +138,17 @@ describe('payment requests', () => { const decoded = decodePaymentRequest(pr); expect(decoded.mintsPreferred).toBe(true); - expect(decoded.netFees).toBe(true); expect(decoded.supportedMethods?.map((m) => m.method)).toEqual(['bolt11', 'bolt12']); expect(decoded.supportedMethods?.[1].fee?.equals(5)).toBeTruthy(); }); - test('encode/decode preferred mint list with net fees and supported methods (creqB)', () => { + test('encode/decode preferred mint list with supported methods (creqB)', () => { const request = new PaymentRequest({ id: 'preferred_fee_methods', amount: 100, unit: 'sat', mints: ['https://mint.example.com'], mintsPreferred: true, - netFees: true, supportedMethods: [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], }); @@ -160,7 +157,6 @@ describe('payment requests', () => { const decoded = PaymentRequest.fromEncodedRequest(encoded); expect(decoded.mintsPreferred).toBe(true); - expect(decoded.netFees).toBe(true); expect(decoded.supportedMethods?.map((m) => m.method)).toEqual(['bolt11', 'bolt12']); expect(decoded.supportedMethods?.[1].fee?.equals(5)).toBeTruthy(); }); @@ -286,7 +282,7 @@ describe('payment requests', () => { expect(fromZero.isMintListStrict).toBe(true); }); - test('mp/nf/sm absent by default (no serialization, no defaults injected)', () => { + test('mp/sm absent by default (no serialization, no defaults injected)', () => { const request = new PaymentRequest({ id: 'no_prefs', amount: 100, @@ -295,12 +291,10 @@ describe('payment requests', () => { }); const raw = request.toRawRequest(); expect(raw.mp).toBeUndefined(); - expect(raw.nf).toBeUndefined(); expect(raw.sm).toBeUndefined(); const decoded = decodePaymentRequest(request.toEncodedRequest()); expect(decoded.mintsPreferred).toBeUndefined(); - expect(decoded.netFees).toBeUndefined(); expect(decoded.supportedMethods).toBeUndefined(); }); }); From 41448982369f8505b73144c2674ddbba32ab41b8 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Tue, 14 Jul 2026 13:53:53 +0100 Subject: [PATCH 13/13] fix(payment-request)!: enforce the NUT-18 unit rule (u required with a or sm) Per the amended spec, a request that sets an amount or supported methods must carry a unit: mf and the melt-method check are denominated in it. Encoding (creqA and creqB) and fee pricing now throw on such requests; decoding stays lenient so foreign requests can still be inspected. Docs now state the sm check is against the mint's NUT-05 melt methods for the request unit. --- docs-src/usage/payment_requests.md | 4 ++-- src/model/PaymentRequest.ts | 31 +++++++++++++++++++++++------ test/wallet/paymentRequests.test.ts | 24 ++++++++++++++++++++++ 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/docs-src/usage/payment_requests.md b/docs-src/usage/payment_requests.md index b85db282c..80536f139 100644 --- a/docs-src/usage/payment_requests.md +++ b/docs-src/usage/payment_requests.md @@ -32,7 +32,7 @@ A request may carry a mint list that is either **strict** (send only from these const allowed = !pr.isMintListStrict || pr.mints?.includes(myMint); ``` -If `supportedMethods` (`sm`) is set, the sending mint must also support at least one of those methods (`bolt11`, `bolt12`, `onchain`, …). Checking that requires the sending mint's capabilities. See [Inspect Mint Capabilities](./mint_capabilities.md). +If `supportedMethods` (`sm`) is set, the sending mint must be able to **melt the request's `unit`** via at least one of those methods (`bolt11`, `bolt12`, `onchain`, etc): the check is against the mint's NUT-05 melt methods for that unit, not its NUT-04 mint methods. Checking that requires the sending mint's capabilities. See [Inspect Mint Capabilities](./mint_capabilities.md). ## How much do I send, including fees? @@ -50,7 +50,7 @@ const total = pr.amountToSend(myMint, myMintMethods); await wallet.ops.send(total, proofs).run(); // Amount passed straight through ``` -`amountToSend` only prices the fee that applies; it does not reject a mint or method that is not allowed (that is the caller's decision, see above). It throws if the request has no amount. +`amountToSend` only prices the fee that applies; it does not reject a mint or method that is not allowed (that is the caller's decision, see above). It throws if the request has no amount, or no unit: NUT-18 requires `unit` whenever `amount` or `supportedMethods` is set (`mf` is denominated in the request unit), so encoding or pricing such a request fails, while plain decoding stays lenient for inspection. For an **amountless** request (the payer chooses the amount), use `feesFor` to price the surcharge alone and add it to the chosen amount: diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index babdd6d1c..b7afa20e9 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -80,6 +80,19 @@ export class PaymentRequest { return this.mintsPreferred !== true; } + /** + * NUT-18: `u` MUST be set if `a` or `sm` is set: `mf` and the melt-method check are denominated + * in the request unit. Enforced when encoding or pricing; parsing stays lenient so foreign + * requests can still be inspected. + */ + private assertUnitRule(): void { + if (!this.unit && (this.amount !== undefined || this.supportedMethods?.length)) { + throw new CTSError( + 'invalid payment request: unit (u) is required when an amount (a) or supported methods (sm) are set', + ); + } + } + /** * The per-method fee (`mf`) the payer must add when paying from `mint`: `0` if `mint` is in the * mint list, otherwise the lowest fee among the `sm` methods that `mintMethods` says the mint @@ -91,10 +104,13 @@ export class PaymentRequest { * mints/methods check that separately. * * @param mint - The mint URL the payer will send from. - * @param mintMethods - The payment methods that mint supports (matched against `sm`); omit if - * unknown (prices as `0`). + * @param mintMethods - The methods the mint can melt the request unit via (its NUT-05 melt + * methods, matched against `sm`); omit if unknown (prices as `0`). + * @throws If the request sets `a` or `sm` without `u` (invalid per NUT-18; `mf` is denominated in + * the request unit). */ feesFor(mint: string, mintMethods?: string[]): Amount { + this.assertUnitRule(); // Fees compensate the receiver for melting out: payments from a listed mint carry none. if (!this.supportedMethods?.length || this.mints?.includes(mint)) { return Amount.zero(); @@ -113,10 +129,11 @@ export class PaymentRequest { * {@link PaymentRequest.feesFor | feesFor}. * * @param mint - The mint URL the payer will send from. - * @param mintMethods - The payment methods that mint supports (matched against `sm`); omit if - * unknown. - * @throws If the request has no amount. Amountless requests have no base to add fees to; use - * {@link PaymentRequest.feesFor | feesFor} and add it to the amount the payer chooses. + * @param mintMethods - The methods the mint can melt the request unit via (its NUT-05 melt + * methods, matched against `sm`); omit if unknown. + * @throws If the request has no amount (amountless requests have no base to add fees to; use + * {@link PaymentRequest.feesFor | feesFor} and add it to the amount the payer chooses), or no + * unit (invalid per NUT-18). */ amountToSend(mint: string, mintMethods?: string[]): Amount { if (!this.amount) { @@ -128,6 +145,7 @@ export class PaymentRequest { } toRawRequest() { + this.assertUnitRule(); const rawRequest: RawPaymentRequest = {}; if (this.transport) { rawRequest.t = this.transport.map((t: PaymentRequestTransport) => ({ @@ -195,6 +213,7 @@ export class PaymentRequest { * @experimental */ toEncodedCreqB(): string { + this.assertUnitRule(); const tlvRequest: DecodedTLVPaymentRequest = { id: this.id, amount: this.amount !== undefined ? this.amount.toBigInt() : undefined, diff --git a/test/wallet/paymentRequests.test.ts b/test/wallet/paymentRequests.test.ts index a22ba8d93..d92bcf97f 100644 --- a/test/wallet/paymentRequests.test.ts +++ b/test/wallet/paymentRequests.test.ts @@ -214,6 +214,30 @@ describe('payment requests', () => { expect(mp.feesFor('https://out.example.com', ['bolt12']).equals(5)).toBeTruthy(); }); + test('unit rule: a or sm without u fails on encode and pricing, decode stays lenient', () => { + // NUT-18: u MUST be set if a or sm is set (mf is denominated in the request unit). + const smNoUnit = new PaymentRequest({ + id: 'sm_no_unit', + mints: ['https://in.example.com'], + supportedMethods: [{ method: 'bolt12', fee: 5 }], + }); + expect(() => smNoUnit.toEncodedRequest()).toThrow(/unit/); + expect(() => smNoUnit.toEncodedCreqB()).toThrow(/unit/); + expect(() => smNoUnit.feesFor('https://out.example.com', ['bolt12'])).toThrow(/unit/); + + const amountNoUnit = new PaymentRequest({ id: 'a_no_unit', amount: 100 }); + expect(() => amountNoUnit.toEncodedRequest()).toThrow(/unit/); + expect(() => amountNoUnit.amountToSend('https://any.example.com')).toThrow(/unit/); + + // Foreign requests stay decodable for inspection; only encoding/pricing rejects. + const foreign = PaymentRequest.fromRawRequest({ + i: 'foreign', + sm: [{ mn: 'bolt12', mf: 5 }], + }); + expect(foreign.supportedMethods?.[0].fee?.equals(5)).toBeTruthy(); + expect(() => foreign.feesFor('https://any.example.com', ['bolt12'])).toThrow(/unit/); + }); + test('isMintListStrict resolves NUT-18 default-to-strict semantic', () => { const noMints = new PaymentRequest({ id: 'no_mints', amount: 100, unit: 'sat' }); expect(noMints.isMintListStrict).toBeUndefined();