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",