diff --git a/docs-src/usage/payment_requests.md b/docs-src/usage/payment_requests.md new file mode 100644 index 000000000..80536f139 --- /dev/null +++ b/docs-src/usage/payment_requests.md @@ -0,0 +1,122 @@ +# 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 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? + +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 +// 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, 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, 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: + +```typescript +const total = chosenAmount.add(pr.feesFor(myMint, ['bolt12'])); // mf, or 0 if none applies +``` + +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 +await wallet.ops.send(total, proofs).includeFees(true).run(); // sender covers the receiver's input fee +``` + +## 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 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({ + 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 + supportedMethods: [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], +}); + +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 154f2d748..92952b694 100644 --- a/docs-src/usage/usage_index.md +++ b/docs-src/usage/usage_index.md @@ -31,6 +31,7 @@ If you are building a wallet integration from scratch, read these in order: | [Derive Keys](./derive_keys.md) | Derive recoverable P2PK / NUT-20 keys deterministically from the wallet seed. | | [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. | | [Restore Proofs](./restore_proofs.md) | Recover deterministic proofs from the wallet seed across keysets. | | [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. | diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index 2007cdcb9..d2ad51379 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -1639,24 +1639,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(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; static fromRawRequest(rawPaymentRequest: RawPaymentRequest): PaymentRequest_2; // (undocumented) getTransport(type: PaymentRequestTransportType): PaymentRequestTransport | undefined; // (undocumented) - id?: string | undefined; + id?: string; + get isMintListStrict(): boolean | undefined; + // (undocumented) + mints?: string[]; // (undocumented) - mints?: string[] | undefined; + mintsPreferred?: boolean; // (undocumented) - nut10?: NUT10Option | undefined; + nut10?: NUT10Option; // (undocumented) - singleUse: boolean; + singleUse?: boolean; + // (undocumented) + supportedMethods?: SupportedMethod[]; toEncodedCreqA(): string; toEncodedCreqB(): string; // (undocumented) @@ -1665,12 +1672,29 @@ 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; + supportedMethods?: Array<{ + method: string; + fee?: AmountLike; + }>; +}; + // @public (undocumented) export type PaymentRequestPayload = { id?: string; @@ -1788,11 +1812,19 @@ export type RawPaymentRequest = { u?: string; s?: boolean; m?: string[]; + mp?: boolean; + 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; @@ -2096,6 +2128,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/migration-5.0.0.md b/migration-5.0.0.md index 65a46b98c..a8537d2c0 100644 --- a/migration-5.0.0.md +++ b/migration-5.0.0.md @@ -360,3 +360,39 @@ Two escape hatches keep stored-quote flows working: - Quotes reporting `0/0` defer to the mint — a zero snapshot may simply have been fetched before the payment was made, so the create → pay externally → mint flow is unaffected. The practical change from v4: attempting to re-mint a quote object whose snapshot shows it fully issued (`amount_paid === amount_issued > 0`) now fails fast client-side instead of round-tripping to the mint for a rejection. + +--- + +## `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 4b10ea17c..d1a49554e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,12 +77,14 @@ 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, PaymentRequestTransport, RawPaymentRequest, + RawSupportedMethod, + SupportedMethod, RawTransport, NUT10Option, RawNUT10Option, diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index 676ae9042..bf4958868 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -11,28 +11,141 @@ import type { NUT10Option, PaymentRequestTransport, PaymentRequestTransportType, + SupportedMethod, } from '../wallet/types'; import { Amount, type AmountLike } from './Amount'; import { CTSError } from './Errors'; +/** + * 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; + amount?: AmountLike; + unit?: string; + mints?: string[]; + description?: string; + transport?: PaymentRequestTransport[]; + singleUse?: boolean; + nut10?: NUT10Option; + mintsPreferred?: boolean; + supportedMethods?: Array<{ method: string; fee?: AmountLike }>; +}; + export class PaymentRequest { + public id?: string; public amount?: Amount; + public unit?: string; + public mints?: string[]; + public description?: string; + public transport?: PaymentRequestTransport[]; + public singleUse?: boolean; + public nut10?: NUT10Option; + public mintsPreferred?: boolean; + public supportedMethods?: SupportedMethod[]; - constructor( - public transport?: PaymentRequestTransport[], - public id?: string, - amount?: AmountLike, - public unit?: string, - public mints?: string[], - public description?: string, - public singleUse: boolean = false, - public nut10?: NUT10Option, - ) { - this.amount = amount !== undefined ? Amount.from(amount) : undefined; + 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 = options.singleUse === undefined ? undefined : Boolean(options.singleUse); + this.mintsPreferred = + options.mintsPreferred === undefined ? undefined : Boolean(options.mintsPreferred); + } + + /** + * Resolves the NUT-18 mint list strictness per spec. + * + * - `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`) + */ + get isMintListStrict(): boolean | undefined { + if (!this.mints?.length) { + return undefined; + } + 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 + * supports (NUT-18). + * + * Use this for amountless requests (where the payer chooses the amount): add the result to the + * 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 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(); + } + const applicable = this.supportedMethods + .filter((m) => mintMethods?.includes(m.method)) + .map((m) => m.fee ?? Amount.zero()); + if (!applicable.length) { + return Amount.zero(); + } + return applicable.reduce((min, fee) => Amount.min(min, fee)); + } + + /** + * 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 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) { + 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, mintMethods)); } toRawRequest() { + this.assertUnitRule(); const rawRequest: RawPaymentRequest = {}; if (this.transport) { rawRequest.t = this.transport.map((t: PaymentRequestTransport) => ({ @@ -53,10 +166,18 @@ export class PaymentRequest { if (this.mints) { rawRequest.m = this.mints; } + if (this.mintsPreferred !== undefined) { + rawRequest.mp = this.mintsPreferred; + } + 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 }, + ); + } if (this.description) { rawRequest.d = this.description; } - if (this.singleUse) { + if (this.singleUse !== undefined) { rawRequest.s = this.singleUse; } if (this.nut10) { @@ -92,12 +213,18 @@ export class PaymentRequest { * @experimental */ toEncodedCreqB(): string { + this.assertUnitRule(); const tlvRequest: DecodedTLVPaymentRequest = { id: this.id, amount: this.amount !== undefined ? this.amount.toBigInt() : undefined, unit: this.unit, singleUse: this.singleUse, mints: this.mints, + mintsPreferred: this.mintsPreferred, + 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 @@ -197,16 +324,19 @@ export class PaymentRequest { tags: rawPaymentRequest.nut10.t, } : undefined; - return new PaymentRequest( - transports, - rawPaymentRequest.i, - rawPaymentRequest.a, - rawPaymentRequest.u, - rawPaymentRequest.m, - rawPaymentRequest.d, - rawPaymentRequest.s, + const supportedMethods = rawPaymentRequest.sm?.map((m) => ({ method: m.mn, fee: m.mf })); + return new PaymentRequest({ + transport: transports, + id: rawPaymentRequest.i, + amount: rawPaymentRequest.a, + unit: rawPaymentRequest.u, + mints: rawPaymentRequest.m, + description: rawPaymentRequest.d, + singleUse: rawPaymentRequest.s, nut10, - ); + mintsPreferred: rawPaymentRequest.mp, + supportedMethods, + }); } static fromEncodedRequest(encodedRequest: string): PaymentRequest { @@ -223,16 +353,18 @@ 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 ?? false, + 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, - ); + mintsPreferred: decoded.mintsPreferred, + supportedMethods: decoded.supportedMethods, + }); } // Version A: CBOR encoding (creqA...) diff --git a/src/utils/tlv.ts b/src/utils/tlv.ts index 1d26096c3..94276ce16 100644 --- a/src/utils/tlv.ts +++ b/src/utils/tlv.ts @@ -22,6 +22,8 @@ export type DecodedTLVPaymentRequest = { unit?: string; singleUse?: boolean; mints?: string[]; + mintsPreferred?: boolean; + supportedMethods?: Array<{ method: string; fee?: bigint }>; description?: string; transports?: PaymentRequestTransport[]; nut10?: Nut10SpendingCondition; @@ -30,16 +32,18 @@ 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) | + * | 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 | supported_method | sub-TLV | Supported payment method with an optional per-method fee (repeatable) | */ const TAG_ID = 0x01; const TAG_AMOUNT = 0x02; @@ -49,6 +53,8 @@ const TAG_MINT = 0x05; const TAG_DESCRIPTION = 0x06; const TAG_TRANSPORT = 0x07; const TAG_NUT10 = 0x08; +const TAG_MINT_PREFERRED = 0x09; +const TAG_SUPPORTED_METHODS = 0x0a; /** * Transport Sub-TLV Tag definitions. @@ -82,6 +88,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 0x0a). + * + * | 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; @@ -139,6 +156,15 @@ export function decodeTLV(data: Uint8Array): DecodedTLVPaymentRequest { } result.nut10 = parseNut10(part.value); break; + case TAG_MINT_PREFERRED: + result.mintsPreferred = parseU8(part.value) === 1; + break; + case TAG_SUPPORTED_METHODS: + if (!result.supportedMethods) { + result.supportedMethods = []; + } + result.supportedMethods.push(parseSupportedMethod(part.value)); + break; default: // Ignore unknown tags for forward compatibility break; @@ -340,6 +366,44 @@ function parseNut10(value: Uint8Array): Nut10SpendingCondition { }; } +/** + * 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. + */ +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. * @@ -430,6 +494,17 @@ export function encodeTLV(request: DecodedTLVPaymentRequest): Uint8Array { parts.push(encodeTLVPart(TAG_NUT10, encodeNut10(request.nut10))); } + if (request.mintsPreferred !== undefined) { + parts.push(encodeTLVPart(TAG_MINT_PREFERRED, encodeU8(request.mintsPreferred ? 1 : 0))); + } + + // Repeatable: supported_method + if (request.supportedMethods && request.supportedMethods.length > 0) { + for (const method of request.supportedMethods) { + parts.push(encodeTLVPart(TAG_SUPPORTED_METHODS, encodeSupportedMethod(method))); + } + } + // Concatenate all parts const totalLength = parts.reduce((sum, part) => sum + part.length, 0); const result = new Uint8Array(totalLength); @@ -587,6 +662,31 @@ function encodeNut10(nut10: Nut10SpendingCondition): Uint8Array { return result; } +/** + * 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. + */ +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 72b2b0af3..266b02ef0 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,17 +13,34 @@ 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 u?: string; // unit s?: boolean; // single use m?: string[]; // mints + mp?: boolean; // mints preferred: strict list when absent or false, advisory list when true + 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 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; + 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 1216ffd74..cd13f335c 100644 --- a/test/utils/tlv-roundtrip.test.ts +++ b/test/utils/tlv-roundtrip.test.ts @@ -176,6 +176,82 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { }); }); + describe('Supported Method sub-TLV (malformed)', () => { + // 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([ + 0x0a, + 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([ + 0x0a, + 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([ + 0x0a, + 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'; @@ -358,6 +434,42 @@ describe('TLV Encoding/Decoding Roundtrip Tests', () => { }); }); + describe('Preferred Mint List with Supported Methods', () => { + // NUT-26 spec test vector — payment request with mp=true (preferred/advisory + // mint list) and supported methods where bolt12 carries a per-method fee + // (mf=5) for non-preferred mints. + const encoded = + 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQJQGQQE3X7MR5XYCS5QQ5QYQQVCN0D36RZVSZQQYQQQQQQQQQQQQ9FJ2568'; + + test('roundtrip preferred mint list with supported methods', () => { + testRoundtrip(encoded, 'NUT-26 spec vector: mp=true, sm=[bolt11, bolt12(mf=5)]'); + }); + + test('verify mp, 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.mintsPreferred).toBe(true); + 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.supportedMethods).toEqual([ + { method: 'bolt11' }, + { method: 'bolt12', fee: BigInt(5) }, + ]); + }); + }); + describe('Custom Currency Unit', () => { const encoded = 'CREQB1QYQQKCM4WD6X7M2LW4HXJAQZQQYQQQQQQQQQQQRYQVQQXCN5VVZSQXRGW368QUE69UHK66TWWSHX27RPD4CXCEFWVDHK6PZHCW8'; diff --git a/test/wallet/paymentRequests.test.ts b/test/wallet/paymentRequests.test.ts index 44bcf4a91..b6cc2d266 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); @@ -113,30 +113,240 @@ describe('payment requests', () => { expect(() => decodePaymentRequest(prWithInvalidVersion)).toThrow('unsupported pr version'); }); + 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 = + 'creqApmFpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWJtcPVic22CoWJtbmZib2x0MTGiYm1uZmJvbHQxMmJtZgU='; + const SPEC_CREQB = + 'CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQJQGQQE3X7MR5XYCS5QQ5QYQQVCN0D36RZVSZQQYQQQQQQQQQQQQ9FJ2568'; + + 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 + supportedMethods: [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], + }); + + const pr = request.toEncodedRequest(); + expect(pr).toBe(SPEC_CREQA); + + const decoded = decodePaymentRequest(pr); + expect(decoded.mintsPreferred).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 supported methods (creqB)', () => { + const request = new PaymentRequest({ + id: 'preferred_fee_methods', + amount: 100, + unit: 'sat', + mints: ['https://mint.example.com'], + mintsPreferred: true, + supportedMethods: [{ method: 'bolt11' }, { method: 'bolt12', fee: 5 }], + }); + + const encoded = request.toEncodedCreqB(); + expect(encoded).toBe(SPEC_CREQB); + + const decoded = PaymentRequest.fromEncodedRequest(encoded); + expect(decoded.mintsPreferred).toBe(true); + expect(decoded.supportedMethods?.map((m) => m.method)).toEqual(['bolt11', 'bolt12']); + expect(decoded.supportedMethods?.[1].fee?.equals(5)).toBeTruthy(); + }); + + 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({ + 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(); + // 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({ + 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). + 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. + 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({ + 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('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(); + + 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({ + 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({ + 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 + const fromWire = decodePaymentRequest(mintsOnly.toEncodedRequest()); + expect(fromWire.mintsPreferred).toBeUndefined(); + expect(fromWire.isMintListStrict).toBe(true); + }); + + 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'], + mp: 1 as unknown as boolean, + }); + expect(fromOne.mintsPreferred).toBe(true); + expect(fromOne.isMintListStrict).toBe(false); + // Round-trips through both formats without flipping strictness. + expect(decodePaymentRequest(fromOne.toEncodedCreqA()).isMintListStrict).toBe(false); + expect(decodePaymentRequest(fromOne.toEncodedCreqB()).isMintListStrict).toBe(false); + + const fromZero = PaymentRequest.fromRawRequest({ + i: 'zero', + a: 100, + u: 'sat', + m: ['https://mint.example.com'], + mp: 0 as unknown as boolean, + }); + expect(fromZero.mintsPreferred).toBe(false); + expect(fromZero.isMintListStrict).toBe(true); + }); + + test('mp/sm absent by default (no serialization, no defaults injected)', () => { + 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.sm).toBeUndefined(); + + const decoded = decodePaymentRequest(request.toEncodedRequest()); + expect(decoded.mintsPreferred).toBeUndefined(); + expect(decoded.supportedMethods).toBeUndefined(); + }); + }); + describe('toRawRequest', () => { - test('omits every optional field and defaults singleUse to false', () => { + test('omits every optional field, including the tri-state singleUse', () => { // A request built with no arguments carries no optional fields; toStrictEqual // distinguishes an absent key from one explicitly set to undefined, so this - // pins each `if (this.field)` guard as well as the singleUse default. + // pins each `if (this.field)` guard as well as the singleUse tri-state. const request = new PaymentRequest(); - expect(request.singleUse).toBe(false); + expect(request.singleUse).toBeUndefined(); expect(request.toRawRequest()).toStrictEqual({}); }); 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()); @@ -149,15 +359,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', @@ -167,21 +377,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(); @@ -201,8 +411,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', @@ -212,13 +422,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); @@ -234,9 +443,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); @@ -249,15 +460,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: [ @@ -265,7 +475,7 @@ describe('payment requests', () => { ['refund', '03abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890cd'], ], }, - ); + }); const encoded = pr.toEncodedCreqB(); const decoded = PaymentRequest.fromEncodedRequest(encoded); @@ -286,20 +496,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()); @@ -308,15 +515,15 @@ describe('payment requests', () => { expect(decoded.nut10?.tags).toStrictEqual([]); }); - test('a creqB without a single_use tag defaults singleUse to false', () => { - // Our encoder always writes the single_use tag, so craft a TLV that omits it - // (singleUse undefined => tag skipped) to exercise the decode-side default. + test('a creqB without a single_use tag decodes singleUse as undefined (tri-state)', () => { + // Craft a TLV that omits the single_use tag to exercise the decode side: + // the absent/false/true distinction must survive, so no default is injected. const tlv = encodeTLV({ id: 'noflag', unit: 'sat', mints: ['https://mint.example.com'] }); const encoded = encodeBech32m('creqb', tlv).toUpperCase(); const decoded = PaymentRequest.fromEncodedRequest(encoded); expect(decoded.id).toBe('noflag'); - expect(decoded.singleUse).toBe(false); + expect(decoded.singleUse).toBeUndefined(); }); test('roundtrip from creqB test vector', () => { @@ -346,7 +553,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(); 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",