From c00ea576009ed23cd5bd1349dbeaa69b935c2872 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Mon, 13 Jul 2026 14:10:12 +0100 Subject: [PATCH 1/2] feat(nut18): add PaymentRequestBuilder Fluent authoring API for NUT-18 payment requests: transport helpers (nostr NIP tags, HTTP POST), mint URL normalization with dedupe, and cross-field validation at build() (mp without mints, duplicate sm methods, amount without unit). lock() serializes a P2PKOptions into the request's nut10 option via the new p2pkOptionsToPRNut10(); the NUT-11 tag serialization moves out of OutputData into a shared buildP2PKTags() so authored requests and sender-side secrets cannot drift. RESERVED_P2PK_TAGS is consolidated into P2PK_KNOWN_TAG_KEYS. --- docs-src/usage/payment_requests.md | 23 +++ etc/cashu-ts.api.md | 23 +++ src/crypto/NUT11.ts | 90 +++++++++- src/index.ts | 6 +- src/model/OutputData.ts | 72 ++------ src/model/PaymentRequest.ts | 204 +++++++++++++++++++++- src/wallet/P2PKBuilder.ts | 3 +- test/model/OutputData.test.ts | 11 +- test/wallet/paymentRequestBuilder.test.ts | 184 +++++++++++++++++++ 9 files changed, 542 insertions(+), 74 deletions(-) create mode 100644 test/wallet/paymentRequestBuilder.test.ts diff --git a/docs-src/usage/payment_requests.md b/docs-src/usage/payment_requests.md index 80536f139..896d5e847 100644 --- a/docs-src/usage/payment_requests.md +++ b/docs-src/usage/payment_requests.md @@ -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): diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index d2ad51379..8a237891a 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -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[]; @@ -1643,6 +1646,7 @@ class PaymentRequest_2 { // (undocumented) amount?: Amount; amountToSend(mint: string, mintMethods?: string[]): Amount; + static builder(): PaymentRequestBuilder; // (undocumented) description?: string; feesFor(mint: string, mintMethods?: string[]): Amount; @@ -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; diff --git a/src/crypto/NUT11.ts b/src/crypto/NUT11.ts index 2b3f16283..f6a7d5a1b 100644 --- a/src/crypto/NUT11.ts +++ b/src/crypto/NUT11.ts @@ -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'; @@ -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 */ @@ -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 // ------------------------------ diff --git a/src/index.ts b/src/index.ts index d1a49554e..ae14e1ce4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/model/OutputData.ts b/src/model/OutputData.ts index b070bebae..9b9302b92 100644 --- a/src/model/OutputData.ts +++ b/src/model/OutputData.ts @@ -6,6 +6,7 @@ import { asSecpPoint, blindMessage, blindMessageBls, + buildP2PKTags, constructUnblindedSignatureBls, createSecretAndBlindingFactorDeriver, constructUnblindedSignature, @@ -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 { @@ -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'; diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index bf4958868..70e8cbdf8 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -1,16 +1,21 @@ import { getTag, getTagInt, getTagScalar } from '../crypto/NUT10'; import type { P2PKOptions, P2PKTag } from '../crypto/NUT11'; -import { P2PK_KNOWN_TAG_KEYS, normalizePubkey, parseP2PKSecret } from '../crypto/NUT11'; -import { encodeBase64toUint8, decodeCBOR, encodeCBOR, Bytes } from '../utils'; +import { + P2PK_KNOWN_TAG_KEYS, + normalizePubkey, + p2pkOptionsToPRNut10, + parseP2PKSecret, +} from '../crypto/NUT11'; +import { encodeBase64toUint8, decodeCBOR, encodeCBOR, Bytes, normalizeUrl } from '../utils'; import { decodeBech32mToBytes, encodeBech32m } from '../utils/bech32m'; import { decodeTLV, encodeTLV } from '../utils/tlv'; import type { DecodedTLVPaymentRequest } from '../utils/tlv'; +import { PaymentRequestTransportType } from '../wallet/types'; import type { RawPaymentRequest, RawTransport, NUT10Option, PaymentRequestTransport, - PaymentRequestTransportType, SupportedMethod, } from '../wallet/types'; @@ -244,6 +249,13 @@ export class PaymentRequest { return this.transport?.find((t: PaymentRequestTransport) => t.type === type); } + /** + * A fresh {@link PaymentRequestBuilder}. + */ + static builder(): PaymentRequestBuilder { + return new PaymentRequestBuilder(); + } + /** * Converts this request's `nut10` locking option into a {@link P2PKOptions} for the wallet's * `.asP2PK()` gate, so a payer can lock proofs to exactly the condition the payee requested. @@ -381,3 +393,189 @@ export class PaymentRequest { return this.fromRawRequest(decoded); } } + +/** + * Fluent builder for authoring a {@link PaymentRequest} (NUT-18). + * + * @remarks + * Setters collect state in any order and never throw on cross-field state; `build()` is the single + * validation point. The {@link PaymentRequest} class itself stays lenient because it is also the + * decode type for foreign requests. + */ +export class PaymentRequestBuilder { + private _id?: string; + private _amount?: AmountLike; + private _unit?: string; + private _description?: string; + private _mints: string[] = []; + private _mintsPreferred?: boolean; + private _singleUse?: boolean; + private _transports: PaymentRequestTransport[] = []; + private _nut10?: NUT10Option; + private _methods: Array<{ method: string; fee?: AmountLike }> = []; + + /** + * Sets the optional payment ID reference. + */ + id(id: string): this { + this._id = id; + return this; + } + + /** + * Sets the requested amount and its unit together (NUT-18: `u` MUST be set when `a` is set). + * + * @throws If the unit is empty. + */ + amount(amount: AmountLike, unit: string): this { + if (!unit) { + throw new CTSError('amount requires a unit (NUT-18: `u` MUST be set when `a` is set)'); + } + this._amount = amount; + this._unit = unit; + return this; + } + + /** + * Sets the unit for an amountless request. The last write here or via `amount()` wins. + */ + unit(unit: string): this { + this._unit = unit; + return this; + } + + /** + * A human readable description for the payment request. + */ + description(description: string): this { + this._description = description; + return this; + } + + /** + * Appends to the mint list; URLs are normalized (as `Mint` does) and deduplicated, first-seen + * order preserved. + * + * @throws If a URL is not a valid mint URL. + */ + addMint(mint: string | string[]): this { + const arr = Array.isArray(mint) ? mint : [mint]; + for (const m of arr) { + const normalized = normalizeUrl(m); + if (!this._mints.includes(normalized)) this._mints.push(normalized); + } + return this; + } + + /** + * Marks the mint list advisory (`mp`) rather than strict; requires mints at `build()`. + */ + mintsPreferred(preferred = true): this { + this._mintsPreferred = preferred; + return this; + } + + singleUse(single = true): this { + this._singleUse = single; + return this; + } + + /** + * Appends a transport; order is preference order (NUT-18). + */ + addTransport(transport: PaymentRequestTransport): this { + this._transports.push(transport); + return this; + } + + /** + * Appends a nostr transport for the given NIPs (default NIP-17 direct messages). + * + * @throws If the target is not an nprofile, or `nips` is empty (the `n` tag MUST carry at least + * one value). + */ + addNostrTransport(nprofile: string, nips: string[] = ['17']): this { + if (!nprofile.startsWith('nprofile1')) { + throw new CTSError('nostr transport target must be an nprofile'); + } + if (nips.length === 0) { + throw new CTSError('nostr transport requires at least one NIP (`n` tag value)'); + } + return this.addTransport({ + type: PaymentRequestTransportType.NOSTR, + target: nprofile, + tags: [['n', ...nips.map(String)]], + }); + } + + /** + * Appends an HTTP POST transport; the sender POSTs the payment payload to `url`. + */ + addHttpPostTransport(url: string): this { + return this.addTransport({ type: PaymentRequestTransportType.POST, target: url }); + } + + /** + * Appends a NUT-05 melting method the payee accepts (`sm`), with an optional per-method fee. + * + * @throws If the method name is empty. + */ + addSupportedMethod(method: string, fee?: AmountLike): this { + if (!method) { + throw new CTSError('supported method name must be a non-empty string'); + } + this._methods.push({ method, fee }); + return this; + } + + /** + * Sets the `nut10` locking condition from a complete P2PK/HTLC {@link P2PKOptions} (e.g. from + * `P2PKBuilder.toOptions()`). Last call here or via `nut10()` wins. + * + * @throws If the lock is invalid or uses `blindKeys` (not expressible in a request). + */ + lock(p2pk: P2PKOptions): this { + this._nut10 = p2pkOptionsToPRNut10(p2pk); + return this; + } + + /** + * Sets the `nut10` locking condition verbatim, for kinds `lock()` cannot express. + */ + nut10(option: NUT10Option): this { + this._nut10 = option; + return this; + } + + /** + * Validates cross-field state and constructs the {@link PaymentRequest}. + * + * @throws If `mintsPreferred` is set without mints (NUT-18 ignores `mp` without `m`), or a + * supported method is listed twice. + */ + build(): PaymentRequest { + if (this._mintsPreferred !== undefined && this._mints.length === 0) { + throw new CTSError('mintsPreferred (mp) requires a mint list; add mints or drop the flag'); + } + const seen = new Set(); + for (const m of this._methods) { + if (seen.has(m.method)) { + throw new CTSError(`duplicate supported method "${m.method}"`); + } + seen.add(m.method); + } + // Copy the collected arrays so reusing the builder cannot mutate the built request. + return new PaymentRequest({ + id: this._id, + amount: this._amount, + unit: this._unit, + mints: this._mints.length ? [...this._mints] : undefined, + description: this._description, + transport: this._transports.length ? [...this._transports] : undefined, + singleUse: this._singleUse, + nut10: this._nut10, + mintsPreferred: this._mintsPreferred, + supportedMethods: this._methods.length ? this._methods : undefined, + }); + } +} diff --git a/src/wallet/P2PKBuilder.ts b/src/wallet/P2PKBuilder.ts index bcc5aae06..b2b62f981 100644 --- a/src/wallet/P2PKBuilder.ts +++ b/src/wallet/P2PKBuilder.ts @@ -1,4 +1,5 @@ import { + assertValidTagKey, dedupeP2PKPubkeys, normalizeHashlock, type LockConditions, @@ -7,7 +8,7 @@ import { type P2PKOptions, } from '../crypto'; import { CTSError } from '../model/Errors'; -import { assertValidTagKey, OutputData } from '../model/OutputData'; +import { OutputData } from '../model/OutputData'; function toUnixSeconds(input: Date | number): number { if (input instanceof Date) return Math.floor(input.getTime() / 1000); diff --git a/test/model/OutputData.test.ts b/test/model/OutputData.test.ts index fb6d98a6f..3a6f1a125 100644 --- a/test/model/OutputData.test.ts +++ b/test/model/OutputData.test.ts @@ -4,21 +4,18 @@ import { sha256 } from '@noble/hashes/sha2.js'; import { describe, expect, test } from 'vitest'; import { + assertValidTagKey, createBlindSignature, createDLEQProof, getPubKeyFromPrivKey, pointFromHex, P2BK_DST, + P2PK_KNOWN_TAG_KEYS, } from '../../src/crypto'; import { verifyUnblindedSignature } from '../../src/crypto/NUT01'; import { Amount } from '../../src/model/Amount'; import { CTSError } from '../../src/model/Errors'; -import { - MAX_SECRET_LENGTH, - OutputData, - assertValidTagKey, - RESERVED_P2PK_TAGS, -} from '../../src/model/OutputData'; +import { MAX_SECRET_LENGTH, OutputData } from '../../src/model/OutputData'; import type { HasKeysetKeys, SerializedBlindedSignature } from '../../src/model/types'; import { Bytes, deriveKeysetId, numberToHexPadded64 } from '../../src/utils'; @@ -167,7 +164,7 @@ describe('OutputData secp round-trip (secp256k1 + NUT-12 DLEQ)', () => { describe('OutputData.assertValidTagKey and reserved tags', () => { test('rejects every reserved P2PK tag key', () => { - for (const key of RESERVED_P2PK_TAGS) { + for (const key of P2PK_KNOWN_TAG_KEYS) { expect(() => assertValidTagKey(key)).toThrowError(/reserved key/); } // Explicit check for the last reserved entry, guarding against a dropped set member. diff --git a/test/wallet/paymentRequestBuilder.test.ts b/test/wallet/paymentRequestBuilder.test.ts new file mode 100644 index 000000000..a1254a351 --- /dev/null +++ b/test/wallet/paymentRequestBuilder.test.ts @@ -0,0 +1,184 @@ +import { test, describe, expect } from 'vitest'; + +import { + P2PKBuilder, + PaymentRequest, + PaymentRequestBuilder, + PaymentRequestTransportType, + p2pkOptionsToPRNut10, + type P2PKOptions, +} from '../../src/index'; + +const PUBKEY = '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2'; +const PUBKEY2 = '03e7a51b73e5f2f6b5a6f0d63c6a5e1a3b2c4d5e6f708192a3b4c5d6e7f8091a2b'; +const NPROFILE = + 'nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5'; + +describe('PaymentRequestBuilder', () => { + test('builds a full request that round-trips through creqA', () => { + const pr = PaymentRequest.builder() + .id('4840f51e') + .amount(1000, 'sat') + .description('test') + .addMint('https://mint.com') + .mintsPreferred() + .addNostrTransport(NPROFILE) + .addHttpPostTransport('https://pay.example/cb') + .addSupportedMethod('bolt11') + .addSupportedMethod('onchain', 50) + .singleUse() + .build(); + + const decoded = PaymentRequest.fromEncodedRequest(pr.toEncodedRequest()); + expect(decoded.id).toBe('4840f51e'); + expect(decoded.amount?.equals(1000)).toBeTruthy(); + expect(decoded.unit).toBe('sat'); + expect(decoded.description).toBe('test'); + expect(decoded.mints).toEqual(['https://mint.com']); + expect(decoded.mintsPreferred).toBe(true); + expect(decoded.singleUse).toBe(true); + expect(decoded.transport).toEqual([ + { type: PaymentRequestTransportType.NOSTR, target: NPROFILE, tags: [['n', '17']] }, + { type: PaymentRequestTransportType.POST, target: 'https://pay.example/cb' }, + ]); + expect(decoded.supportedMethods?.[0].method).toBe('bolt11'); + expect(decoded.supportedMethods?.[0].fee).toBeUndefined(); + expect(decoded.supportedMethods?.[1].method).toBe('onchain'); + expect(decoded.supportedMethods?.[1].fee?.equals(50)).toBeTruthy(); + }); + + test('empty builder produces an empty request', () => { + const pr = new PaymentRequestBuilder().build(); + expect(pr.toRawRequest()).toEqual({}); + }); + + test('amount() sets amount and unit together; unit() alone works for amountless', () => { + const withAmount = new PaymentRequestBuilder().amount(21, 'usd').build(); + expect(withAmount.amount?.equals(21)).toBeTruthy(); + expect(withAmount.unit).toBe('usd'); + + const amountless = new PaymentRequestBuilder().unit('sat').build(); + expect(amountless.amount).toBeUndefined(); + expect(amountless.unit).toBe('sat'); + }); + + test('addMint normalizes URLs and dedupes with first-seen order', () => { + const pr = new PaymentRequestBuilder() + .addMint(['https://a.mint', 'https://b.mint']) + .addMint('https://a.mint/') // normalizes to the same URL + .build(); + expect(pr.mints).toEqual(['https://a.mint', 'https://b.mint']); + + expect(() => new PaymentRequestBuilder().addMint('not a url')).toThrowError(/mint URL/i); + }); + + test('mintsPreferred without mints throws at build(), in any call order', () => { + expect(() => new PaymentRequestBuilder().mintsPreferred().build()).toThrowError(/mint list/); + expect(() => new PaymentRequestBuilder().mintsPreferred(false).build()).toThrowError( + /mint list/, + ); + // setter order is free; only build() validates + const pr = new PaymentRequestBuilder().mintsPreferred().addMint('https://a.mint').build(); + expect(pr.mintsPreferred).toBe(true); + }); + + test('amount() rejects an empty unit; addSupportedMethod() rejects an empty method', () => { + expect(() => new PaymentRequestBuilder().amount(100, '')).toThrowError(/requires a unit/); + expect(() => new PaymentRequestBuilder().addSupportedMethod('')).toThrowError(/non-empty/); + }); + + test('reusing the builder does not mutate an already-built request', () => { + const builder = new PaymentRequestBuilder() + .addMint('https://a.mint') + .addHttpPostTransport('https://pay.example/cb'); + const first = builder.build(); + builder.addMint('https://b.mint').addHttpPostTransport('https://other.example'); + expect(first.mints).toEqual(['https://a.mint']); + expect(first.transport).toHaveLength(1); + }); + + test('duplicate supported methods throw at build()', () => { + expect(() => + new PaymentRequestBuilder() + .addSupportedMethod('bolt11') + .addSupportedMethod('bolt11', 2) + .build(), + ).toThrowError(/duplicate supported method/); + }); + + test('addNostrTransport validates target and nips', () => { + expect(() => new PaymentRequestBuilder().addNostrTransport('npub1notaprofile')).toThrowError( + /nprofile/, + ); + expect(() => new PaymentRequestBuilder().addNostrTransport(NPROFILE, [])).toThrowError( + /at least one NIP/, + ); + const pr = new PaymentRequestBuilder().addNostrTransport(NPROFILE, ['17', '04']).build(); + expect(pr.getTransport(PaymentRequestTransportType.NOSTR)?.tags).toEqual([['n', '17', '04']]); + }); + + test('lock() accepts P2PKBuilder output and round-trips via toP2PKOptions()', () => { + const builder = new P2PKBuilder() + .addLockPubkey([PUBKEY, PUBKEY2]) + .addRefundPubkey(PUBKEY) + .lockUntil(2085000000) + .requireLockSignatures(2); + const pr = new PaymentRequestBuilder().lock(builder.toOptions()).build(); + + expect(pr.nut10?.kind).toBe('P2PK'); + expect(pr.nut10?.data).toBe(PUBKEY); + expect(pr.nut10?.tags).toContainEqual(['pubkeys', PUBKEY2]); + expect(pr.nut10?.tags).toContainEqual(['n_sigs', '2']); + expect(pr.nut10?.tags).toContainEqual(['locktime', '2085000000']); + expect(pr.nut10?.tags).toContainEqual(['refund', PUBKEY]); + + // the payer-side parser reconstructs the same lock + const roundTripped = pr.toP2PKOptions(); + expect(roundTripped).toEqual(builder.toOptions()); + }); + + test('lock() accepts raw P2PKOptions and validates them', () => { + const pr = new PaymentRequestBuilder().lock({ kind: 'P2PK', data: PUBKEY }).build(); + expect(pr.nut10).toEqual({ kind: 'P2PK', data: PUBKEY, tags: [] }); + + expect(() => + new PaymentRequestBuilder().lock({ kind: 'P2PK', data: 'garbage' }), + ).toThrowError(); + }); + + test('lock() rejects blindKeys', () => { + const blind: P2PKOptions = { kind: 'P2PK', data: PUBKEY, blindKeys: true }; + expect(() => new PaymentRequestBuilder().lock(blind)).toThrowError(/blindKeys/); + }); + + test('nut10() passes arbitrary kinds through verbatim; last lock write wins', () => { + const custom = { kind: 'DLC', data: 'deadbeef', tags: [['x', 'y']] }; + const pr = new PaymentRequestBuilder() + .lock({ kind: 'P2PK', data: PUBKEY }) + .nut10(custom) + .build(); + expect(pr.nut10).toEqual(custom); + }); +}); + +describe('p2pkOptionsToPRNut10', () => { + test('HTLC options serialize with the hashlock in data', () => { + const hashlock = 'ab'.repeat(32); + const nut10 = p2pkOptionsToPRNut10({ kind: 'HTLC', data: hashlock, pubkeys: [PUBKEY] }); + expect(nut10.kind).toBe('HTLC'); + expect(nut10.data).toBe(hashlock); + expect(nut10.tags).toEqual([['pubkeys', PUBKEY]]); + }); + + test('additional tags are validated and appended', () => { + const nut10 = p2pkOptionsToPRNut10({ + kind: 'P2PK', + data: PUBKEY, + additionalTags: [['memo', 'hi']], + }); + expect(nut10.tags).toContainEqual(['memo', 'hi']); + expect(() => + p2pkOptionsToPRNut10({ kind: 'P2PK', data: PUBKEY, additionalTags: [['pubkeys', 'x']] }), + ).toThrowError(/reserved key/); + }); +}); From 9cb626a39cbf638d6fc3783fad4021975bebdf4a Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Tue, 14 Jul 2026 14:06:28 +0100 Subject: [PATCH 2/2] refactor(payment-request): meltMethods param and builder unit-rule validation Renames the feesFor/amountToSend mintMethods param to meltMethods: the sm check is against the mint's NUT-05 melt methods, and the old name invited the mint-vs-melt mixup the spec wording now rules out. PaymentRequestBuilder.build() rejects supported methods without a unit and unit() rejects an empty string, so a request the encoder would refuse cannot be authored via the builder. --- docs-src/usage/payment_requests.md | 4 ++-- etc/cashu-ts.api.md | 4 ++-- src/model/PaymentRequest.ts | 29 ++++++++++++++++------- test/wallet/paymentRequestBuilder.test.ts | 16 ++++++++++++- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/docs-src/usage/payment_requests.md b/docs-src/usage/payment_requests.md index 896d5e847..48afad1c5 100644 --- a/docs-src/usage/payment_requests.md +++ b/docs-src/usage/payment_requests.md @@ -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. @@ -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 ``` diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index 8a237891a..936f37418 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -1645,11 +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; diff --git a/src/model/PaymentRequest.ts b/src/model/PaymentRequest.ts index 70e8cbdf8..e546572d6 100644 --- a/src/model/PaymentRequest.ts +++ b/src/model/PaymentRequest.ts @@ -100,7 +100,7 @@ export class PaymentRequest { /** * 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 + * mint list, otherwise the lowest fee among the `sm` methods that `meltMethods` says the mint * supports (NUT-18). * * Use this for amountless requests (where the payer chooses the amount): add the result to the @@ -109,19 +109,19 @@ export class PaymentRequest { * 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 + * @param meltMethods - 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 { + feesFor(mint: string, meltMethods?: 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)) + .filter((m) => meltMethods?.includes(m.method)) .map((m) => m.fee ?? Amount.zero()); if (!applicable.length) { return Amount.zero(); @@ -134,19 +134,19 @@ export class PaymentRequest { * {@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 + * @param meltMethods - 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 { + amountToSend(mint: string, meltMethods?: 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)); + return this.amount.add(this.feesFor(mint, meltMethods)); } toRawRequest() { @@ -438,8 +438,13 @@ export class PaymentRequestBuilder { /** * Sets the unit for an amountless request. The last write here or via `amount()` wins. + * + * @throws If the unit is empty. */ unit(unit: string): this { + if (!unit) { + throw new CTSError('unit must be a non-empty string'); + } this._unit = unit; return this; } @@ -550,13 +555,19 @@ export class PaymentRequestBuilder { /** * Validates cross-field state and constructs the {@link PaymentRequest}. * - * @throws If `mintsPreferred` is set without mints (NUT-18 ignores `mp` without `m`), or a - * supported method is listed twice. + * @throws If `mintsPreferred` is set without mints (NUT-18 ignores `mp` without `m`), a supported + * method is listed twice, or supported methods are set without a unit (NUT-18: `u` MUST be set + * when `sm` is set). */ build(): PaymentRequest { if (this._mintsPreferred !== undefined && this._mints.length === 0) { throw new CTSError('mintsPreferred (mp) requires a mint list; add mints or drop the flag'); } + if (this._methods.length > 0 && !this._unit) { + throw new CTSError( + 'supported methods (sm) require a unit; set it via amount(value, unit) or unit()', + ); + } const seen = new Set(); for (const m of this._methods) { if (seen.has(m.method)) { diff --git a/test/wallet/paymentRequestBuilder.test.ts b/test/wallet/paymentRequestBuilder.test.ts index a1254a351..15aa7dad8 100644 --- a/test/wallet/paymentRequestBuilder.test.ts +++ b/test/wallet/paymentRequestBuilder.test.ts @@ -10,7 +10,8 @@ import { } from '../../src/index'; const PUBKEY = '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2'; -const PUBKEY2 = '03e7a51b73e5f2f6b5a6f0d63c6a5e1a3b2c4d5e6f708192a3b4c5d6e7f8091a2b'; +// A real on-curve point (secp256k1 G): main now rejects non-point pubkeys in P2PK locks. +const PUBKEY2 = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; const NPROFILE = 'nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5'; @@ -85,6 +86,7 @@ describe('PaymentRequestBuilder', () => { test('amount() rejects an empty unit; addSupportedMethod() rejects an empty method', () => { expect(() => new PaymentRequestBuilder().amount(100, '')).toThrowError(/requires a unit/); expect(() => new PaymentRequestBuilder().addSupportedMethod('')).toThrowError(/non-empty/); + expect(() => new PaymentRequestBuilder().unit('')).toThrowError(/non-empty/); }); test('reusing the builder does not mutate an already-built request', () => { @@ -100,12 +102,24 @@ describe('PaymentRequestBuilder', () => { test('duplicate supported methods throw at build()', () => { expect(() => new PaymentRequestBuilder() + .unit('sat') .addSupportedMethod('bolt11') .addSupportedMethod('bolt11', 2) .build(), ).toThrowError(/duplicate supported method/); }); + test('supported methods without a unit throw at build(), in any call order', () => { + // NUT-18: u MUST be set if sm is set (mf and the melt check are denominated in it). + expect(() => new PaymentRequestBuilder().addSupportedMethod('bolt11').build()).toThrowError( + /require a unit/, + ); + // amount() supplies the unit, satisfying the rule. + expect(() => + new PaymentRequestBuilder().amount(100, 'sat').addSupportedMethod('bolt11').build(), + ).not.toThrow(); + }); + test('addNostrTransport validates target and nips', () => { expect(() => new PaymentRequestBuilder().addNostrTransport('npub1notaprofile')).toThrowError( /nprofile/,