Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 25 additions & 2 deletions docs-src/usage/payment_requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ If `supportedMethods` (`sm`) is set, the sending mint must be able to **melt the

## 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.
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 melt 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.

Expand All @@ -46,7 +46,7 @@ pr.amountToSend('https://in-list.mint', ['bolt12']); // listed mint, no fee →
pr.amountToSend('https://other.mint', ['bolt11', 'bolt12']); // lowest = 0 → 100
pr.amountToSend('https://other.mint', ['bolt12']); // + mf → 105

const total = pr.amountToSend(myMint, myMintMethods);
const total = pr.amountToSend(myMint, myMeltMethods);
await wallet.ops.send(total, proofs).run(); // Amount passed straight through
```

Expand Down Expand Up @@ -100,6 +100,29 @@ request.toEncodedCreqA(); // 'creqA…' (CBOR)
request.toEncodedCreqB(); // 'CREQB1…' (TLV + Bech32m, best for QR)
```

### The builder

`PaymentRequest.builder()` offers a fluent alternative that also handles the fiddly parts: transport tag formats, NUT-10 lock serialization, and cross-field validation. Setters can be called in any order; `build()` validates (eg `mintsPreferred` without mints throws) and returns the `PaymentRequest`.

```typescript
import { PaymentRequest, P2PKBuilder } from '@cashu/cashu-ts';

const request = PaymentRequest.builder()
.id('inv-123')
.amount(100, 'sat') // unit is required with amount (NUT-18)
.description('Coffee')
.addMint('https://my.mint')
.mintsPreferred() // advisory list
.addNostrTransport(nprofile) // NIP-17 tags applied for you
.addHttpPostTransport('https://pay.example.com')
.addSupportedMethod('bolt11')
.addSupportedMethod('bolt12', 5) // with a per-method fee
.lock(new P2PKBuilder().addLockPubkey(receiverPk).toOptions()) // nut10 from a P2PK/HTLC lock
.build();
```

`lock()` takes a complete `P2PKOptions` (eg from `P2PKBuilder`, as with `asP2PK()`) and serializes it into the request's `nut10` option (the exact condition `toP2PKOptions()` reconstructs on the sender side). For NUT-10 kinds beyond P2PK/HTLC, pass a raw option with `nut10()`.

## 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):
Expand Down
27 changes: 25 additions & 2 deletions etc/cashu-ts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1597,6 +1597,9 @@ export type P2PKOptions = SpendingConditionsBase & LockConditions & {
kind: 'P2PK' | 'HTLC';
};

// @public
export function p2pkOptionsToPRNut10(p2pk: P2PKOptions): NUT10Option;

// @public
export interface P2PKPathInfo {
pubkeys: string[];
Expand Down Expand Up @@ -1642,10 +1645,11 @@ class PaymentRequest_2 {
constructor(options?: PaymentRequestOptions);
// (undocumented)
amount?: Amount;
amountToSend(mint: string, mintMethods?: string[]): Amount;
amountToSend(mint: string, meltMethods?: string[]): Amount;
static builder(): PaymentRequestBuilder;
// (undocumented)
description?: string;
feesFor(mint: string, mintMethods?: string[]): Amount;
feesFor(mint: string, meltMethods?: string[]): Amount;
// (undocumented)
static fromEncodedRequest(encodedRequest: string): PaymentRequest_2;
static fromRawRequest(rawPaymentRequest: RawPaymentRequest): PaymentRequest_2;
Expand Down Expand Up @@ -1678,6 +1682,25 @@ class PaymentRequest_2 {
}
export { PaymentRequest_2 as PaymentRequest }

// @public
export class PaymentRequestBuilder {
addHttpPostTransport(url: string): this;
addMint(mint: string | string[]): this;
addNostrTransport(nprofile: string, nips?: string[]): this;
addSupportedMethod(method: string, fee?: AmountLike): this;
addTransport(transport: PaymentRequestTransport): this;
amount(amount: AmountLike, unit: string): this;
build(): PaymentRequest_2;
description(description: string): this;
id(id: string): this;
lock(p2pk: P2PKOptions): this;
mintsPreferred(preferred?: boolean): this;
nut10(option: NUT10Option): this;
// (undocumented)
singleUse(single?: boolean): this;
unit(unit: string): this;
}

// @public
export type PaymentRequestOptions = {
id?: string;
Expand Down
90 changes: 89 additions & 1 deletion src/crypto/NUT11.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { type Logger, NULL_LOGGER } from '../logger';
import { CTSError } from '../model/Errors';
import { type OutputDataLike } from '../model/OutputData';
import { type HTLCWitness, type P2PKWitness, type Proof } from '../model/types';
import { type NUT10Option } from '../wallet/types/payment-requests';

import { getValidSigners, schnorrSignMessage, schnorrVerifyMessage, type PrivKey } from './core';
import { pointFromHex } from './curve_secp';
Expand Down Expand Up @@ -130,7 +131,7 @@ type WitnessData = {

/**
* NUT-11 tag keys that map onto structured {@link LockConditions} fields, rather than being carried
* as free-form `additionalTags`.
* as free-form `additionalTags`, and are therefore reserved (not settable as additional tags).
*
* @internal
*/
Expand Down Expand Up @@ -333,6 +334,93 @@ export function normalizeP2PKOptions(p2pk: P2PKOptions): P2PKOptions {
};
}

// ------------------------------
// Lock Tag Serialization
// ------------------------------

/**
* Asserts P2PK Tag key is valid.
*
* @param key Tag Key.
* @throws If not a string, or is a reserved string.
* @internal
*/
export function assertValidTagKey(key: string) {
if (!key || typeof key !== 'string') throw new CTSError('tag key must be a non empty string');
if (P2PK_KNOWN_TAG_KEYS.has(key)) {
throw new CTSError(`additionalTags must not use reserved key "${key}"`);
}
}

/**
* Serializes NUT-11 lock fields into secret tags.
*
* @remarks
* Expects {@link normalizeP2PKOptions}-canonical input (deduped keys, redundant thresholds dropped).
* Thresholds are only emitted alongside their key tag.
* @throws If an additional tag uses a reserved or invalid key.
* @internal
*/
export function buildP2PKTags(lock: LockConditions): string[][] {
const tags: string[][] = [];
const pubkeys = lock.pubkeys ?? [];
const refund = lock.refundKeys ?? [];

const ts = lock.locktime ?? NaN;
if (Number.isSafeInteger(ts) && ts >= 0) {
tags.push(['locktime', String(ts)]);
}

if (pubkeys.length > 0) {
tags.push(['pubkeys', ...pubkeys]);
if ((lock.requiredSignatures ?? 1) > 1) {
tags.push(['n_sigs', String(lock.requiredSignatures)]);
}
}

if (refund.length > 0) {
tags.push(['refund', ...refund]);
if ((lock.requiredRefundSignatures ?? 1) > 1) {
tags.push(['n_sigs_refund', String(lock.requiredRefundSignatures)]);
}
}

if (lock.sigFlag == 'SIG_ALL') {
tags.push(['sigflag', 'SIG_ALL']);
}

if (lock.additionalTags?.length) {
const extraTags = lock.additionalTags.map(([k, ...vals]) => {
assertValidTagKey(k); // Validate key
return [k, ...vals.map(String)]; // all to strings
});
tags.push(...extraTags);
}

return tags;
}

/**
* Converts a {@link P2PKOptions} into the NUT-18 payment request `nut10` option.
*
* @remarks
* Validates and canonicalises the lock (deduped keys, redundant thresholds dropped). `blindKeys`
* throws: P2BK blinding is applied per output at send time, so a static request cannot carry it.
*/
export function p2pkOptionsToPRNut10(p2pk: P2PKOptions): NUT10Option {
const normalized = normalizeP2PKOptions(p2pk);
if (normalized.blindKeys) {
throw new CTSError(
'blindKeys is not expressible in a payment request; the sender applies P2BK blinding per output',
);
}
return {
kind: normalized.kind,
data: normalized.data,
tags: buildP2PKTags(normalized),
};
}

// ------------------------------
// Public Getters
// ------------------------------
Expand Down
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ export * from './utils/core';
export { JSONInt, type JSONIntApi } from './utils/JSONInt';

// Payment request facade (tests rely on these at top level)
export { PaymentRequest, type PaymentRequestOptions } from './model/PaymentRequest';
export {
PaymentRequest,
PaymentRequestBuilder,
type PaymentRequestOptions,
} from './model/PaymentRequest';
export { PaymentRequestTransportType } from './wallet/types';
export type {
PaymentRequestPayload,
Expand Down
72 changes: 11 additions & 61 deletions src/model/OutputData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
asSecpPoint,
blindMessage,
blindMessageBls,
buildP2PKTags,
constructUnblindedSignatureBls,
createSecretAndBlindingFactorDeriver,
constructUnblindedSignature,
Expand Down Expand Up @@ -90,33 +91,6 @@ export type SerializedOutputData = {
ephemeralE?: string;
};

/**
* Core P2PK tags that must not be settable in additional tags.
*
* @internal
*/
export const RESERVED_P2PK_TAGS = new Set([
'locktime',
'pubkeys',
'n_sigs',
'refund',
'n_sigs_refund',
'sigflag',
]);

/**
* Asserts P2PK Tag key is valid.
*
* @param key Tag Key.
* @throws If not a string, or is a reserved string.
*/
export function assertValidTagKey(key: string) {
if (!key || typeof key !== 'string') throw new CTSError('tag key must be a non empty string');
if (RESERVED_P2PK_TAGS.has(key)) {
throw new CTSError(`additionalTags must not use reserved key "${key}"`);
}
}

export function isOutputDataFactory(
value: OutputData[] | OutputDataFactory,
): value is OutputDataFactory {
Expand Down Expand Up @@ -300,40 +274,16 @@ export class OutputData implements OutputDataLike {
Ehex = _E;
}

// build P2PK Tags (NUT-11)
const tags: string[][] = [];

const ts = normalized.locktime ?? NaN;
if (Number.isSafeInteger(ts) && ts >= 0) {
tags.push(['locktime', String(ts)]);
}

if (pubkeys.length > 0) {
tags.push(['pubkeys', ...pubkeys]);
if (reqLock > 1) {
tags.push(['n_sigs', String(reqLock)]);
}
}

if (refund.length > 0) {
tags.push(['refund', ...refund]);
if (reqRefund > 1) {
tags.push(['n_sigs_refund', String(reqRefund)]);
}
}

if (normalized.sigFlag == 'SIG_ALL') {
tags.push(['sigflag', 'SIG_ALL']);
}

// Append additional tags if any
if (normalized.additionalTags?.length) {
const extraTags = normalized.additionalTags.map(([k, ...vals]) => {
assertValidTagKey(k); // Validate key
return [k, ...vals.map(String)]; // all to strings
});
tags.push(...extraTags);
}
// build P2PK Tags (NUT-11), from the post-blinding key layout
const tags = buildP2PKTags({
locktime: normalized.locktime,
pubkeys,
refundKeys: refund,
requiredSignatures: reqLock,
requiredRefundSignatures: reqRefund,
sigFlag: normalized.sigFlag,
additionalTags: normalized.additionalTags,
});

// Construct secret
const kind = isHTLC ? 'HTLC' : 'P2PK';
Expand Down
Loading
Loading