Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dfef35d
feat: mint strict flag in payment request
d4rp4t May 26, 2026
965794f
feat: nut-18 mint preferences (ms, fr, sm)
robwoodgate May 28, 2026
e51c5a9
fix(payment-request): coerce mintsStrict to boolean to prevent type c…
robwoodgate Jun 23, 2026
73705ad
refactor(payment-request)!: flip ms->mp (mint_preferred), make single…
robwoodgate Jun 26, 2026
7ecf99d
feat(payment-request): per-method fee (mn/mf) on supported_methods
robwoodgate Jul 1, 2026
24147b0
feat(payment-request): add feesFor() for amountless fee pricing
robwoodgate Jul 1, 2026
8fb2fba
docs(payment-request): add NUT-18/NUT-26 payment requests usage guide
robwoodgate Jul 1, 2026
37068f5
Merge branch 'main' into feat/nut18-nut26-mint-strict-flag
robwoodgate Jul 1, 2026
d3e7e72
docs(payment-request): fix feesFor @link and refresh API report
robwoodgate Jul 1, 2026
2575e5d
test(tlv): cover supported_method sub-TLV error branches
robwoodgate Jul 1, 2026
8174517
refactor(payment-request): replace fr with nf (net_fees), rescope mf
robwoodgate Jul 6, 2026
95a76b3
Merge remote-tracking branch 'origin/main' into feat/nut18-nut26-mint…
robwoodgate Jul 6, 2026
f395c0d
refactor(payment-request)!: constructor takes an options object
robwoodgate Jul 6, 2026
5d18b17
refactor(payment-request)!: drop nf, requested amount is always net o…
robwoodgate Jul 8, 2026
74478f6
Merge remote-tracking branch 'origin/main' into feat/nut18-nut26-mint…
robwoodgate Jul 8, 2026
4144898
fix(payment-request)!: enforce the NUT-18 unit rule (u required with …
robwoodgate Jul 14, 2026
397e6d5
Merge branch 'main' into feat/nut18-nut26-mint-strict-flag
robwoodgate Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions docs-src/usage/payment_requests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# <a href="/">Documents</a> › [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
1 change: 1 addition & 0 deletions docs-src/usage/usage_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
54 changes: 46 additions & 8 deletions etc/cashu-ts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2096,6 +2128,12 @@ export type SubscriptionCanceller = () => void;
// @public
export function sumProofs(proofs: Array<Pick<ProofLike, 'amount'>>): Amount;

// @public
export type SupportedMethod = {
method: string;
fee?: Amount;
};

// @public
export type SwapMethod = {
method: string;
Expand Down
36 changes: 36 additions & 0 deletions migration-5.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading