From 2f08dfd1d7280846544605eef2a4de9ab907c599 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Fri, 12 Jun 2026 14:53:26 +0100 Subject: [PATCH 01/11] feat(nut04)!: add quote accounting to mint quote base response Implements cashubtc/nuts#377: amount_paid, amount_issued and updated_at become base mint quote response fields for every payment method. For mints that predate quote accounting, the amounts are derived from the legacy single-use state; conversely, a missing bolt11 state is derived from the accounting fields. updated_at is null when not reported. Generic mint quote responses now get base normalization (quote, request, unit, accounting) like melt quotes already did, and the wallet enforces the mintable amount (paid minus issued) for all methods, not just bolt12/onchain. BREAKING CHANGE: MintQuoteBaseResponse requires amount_paid, amount_issued and updated_at; non-conformant mint quote responses that previously passed through unvalidated now throw. --- src/mint/Mint.ts | 84 ++++++++++++++-- src/model/types/NUT04.ts | 16 +++ src/model/types/NUT25.ts | 9 -- src/model/types/NUT30.ts | 8 -- src/wallet/Wallet.ts | 3 - test/mint/Mint.node.test.ts | 140 ++++++++++++++++++++++++++- test/wallet/bolt12.test.ts | 3 +- test/wallet/wallet-mint.node.test.ts | 17 ++++ 8 files changed, 250 insertions(+), 30 deletions(-) diff --git a/src/mint/Mint.ts b/src/mint/Mint.ts index c613a4f98..4c6187b13 100644 --- a/src/mint/Mint.ts +++ b/src/mint/Mint.ts @@ -19,6 +19,7 @@ import { type MeltQuoteBolt11Response, type MeltQuoteBolt12Response, MeltQuoteState, + MintQuoteState, type MintResponse, type GetInfoResponse, type MeltRequest, @@ -1153,9 +1154,9 @@ class Mint { response: TRes, normalize?: (raw: Record) => TRes, ): TRes { - // MintQuoteBaseResponse has no Amount fields to normalize at the base level. - // Stack first-class normalization for known methods. + const op = `${method} mint quote`; const data: Record = { ...response }; + this.normalizeMintBaseFields(data, op); if (method === 'bolt11') { this.normalizeMintQuoteBolt11Fields(data); } else if (method === 'bolt12') { @@ -1166,6 +1167,72 @@ class Mint { return normalize ? normalize(data) : (data as TRes); } + /** + * Mutates `data` in place, normalizing protocol-mandatory mint quote base fields. + * + * NUT-04 requires `amount_paid`/`amount_issued` on every mint quote response; for mints that + * predate quote accounting they are derived from the legacy single-use `state` and `amount`. + */ + private normalizeMintBaseFields(data: Record, op: string): void { + if (data.amount_paid !== undefined && data.amount_issued !== undefined) { + data.amount_paid = Amount.from(data.amount_paid as AmountLike); + data.amount_issued = Amount.from(data.amount_issued as AmountLike); + } else { + const derived = this.deriveMintQuoteAccounting(data); + if (!derived) { + this._logger.error('Invalid response from mint...', { data, op }); + throw new CTSError('Invalid response from mint'); + } + [data.amount_paid, data.amount_issued] = derived; + } + data.updated_at = normalizeSafeIntegerMetadata( + data.updated_at as number | undefined, + 'mintQuote.updated_at', + null, + ); + if ( + typeof data.quote !== 'string' || + typeof data.request !== 'string' || + typeof data.unit !== 'string' + ) { + this._logger.error('Invalid response from mint...', { data, op }); + throw new CTSError('Invalid response from mint'); + } + } + + /** + * Derives `[amount_paid, amount_issued]` from the legacy single-use `state` and quote `amount`, + * or returns null when underivable. + */ + private deriveMintQuoteAccounting(data: Record): [Amount, Amount] | null { + if ( + typeof data.state !== 'string' || + !Object.values(MintQuoteState).includes(data.state as MintQuoteState) + ) { + return null; + } + if (data.state === MintQuoteState.UNPAID) { + return [Amount.from(0), Amount.from(0)]; + } + let amount: Amount; + try { + amount = Amount.from(data.amount as AmountLike); + } catch { + return null; + } + return data.state === MintQuoteState.PAID ? [amount, Amount.from(0)] : [amount, amount]; + } + + /** + * Derives the deprecated single-use `state` from the accounting fields (NUT-04). + */ + private deriveMintQuoteState(paid: Amount, issued: Amount): MintQuoteState { + if (paid.isZero() && issued.isZero()) { + return MintQuoteState.UNPAID; + } + return paid.greaterThan(issued) ? MintQuoteState.PAID : MintQuoteState.ISSUED; + } + /** * Mutates `data` in place, normalizing bolt11 mint-quote fields. */ @@ -1176,6 +1243,15 @@ class Mint { 'mintQuoteBolt11.expiry', null, ); + if ( + typeof data.state !== 'string' || + !Object.values(MintQuoteState).includes(data.state as MintQuoteState) + ) { + data.state = this.deriveMintQuoteState( + data.amount_paid as Amount, + data.amount_issued as Amount, + ); + } } /** @@ -1192,8 +1268,6 @@ class Mint { 'mintQuoteBolt12.expiry', null, ); - data.amount_paid = Amount.from(data.amount_paid as AmountLike); - data.amount_issued = Amount.from(data.amount_issued as AmountLike); } /** @@ -1205,8 +1279,6 @@ class Mint { 'mintQuoteOnchain.expiry', null, ); - data.amount_paid = Amount.from(data.amount_paid as Amount); - data.amount_issued = Amount.from(data.amount_issued as Amount); } /** diff --git a/src/model/types/NUT04.ts b/src/model/types/NUT04.ts index db3377d23..18b3e69ee 100644 --- a/src/model/types/NUT04.ts +++ b/src/model/types/NUT04.ts @@ -1,3 +1,5 @@ +import type { Amount } from '../Amount'; + import { type SerializedBlindedMessage, type SerializedBlindedSignature } from './blinded'; export const MintQuoteState = { @@ -39,6 +41,20 @@ export type MintQuoteBaseResponse = { * Unit of the melt quote. */ unit: string; + /** + * Total amount paid to the mint for this quote, in `unit`. Derived from the legacy `state` for + * mints that do not report accounting fields. + */ + amount_paid: Amount; + /** + * Total amount of ecash issued for this quote, in `unit`. The difference between `amount_paid` + * and `amount_issued` can be minted. + */ + amount_issued: Amount; + /** + * Unix timestamp of the last quote update. `null` when the mint does not report it. + */ + updated_at: number | null; /** * Optional. Public key the quote is locked to (NUT-20) */ diff --git a/src/model/types/NUT25.ts b/src/model/types/NUT25.ts index 5fff3dbf6..d91c4381c 100644 --- a/src/model/types/NUT25.ts +++ b/src/model/types/NUT25.ts @@ -37,15 +37,6 @@ export type MintQuoteBolt12Response = MintQuoteBaseResponse & { * Required for bolt12. */ pubkey: string; - /** - * The amount that has been paid to the mint via the bolt12 offer. The difference between this and - * `amount_issued` can be minted. - */ - amount_paid: Amount; - /** - * The amount of ecash that has been issued for the given mint quote. - */ - amount_issued: Amount; }; /** diff --git a/src/model/types/NUT30.ts b/src/model/types/NUT30.ts index 95762fa6f..5ead02ac9 100644 --- a/src/model/types/NUT30.ts +++ b/src/model/types/NUT30.ts @@ -25,14 +25,6 @@ export type MintQuoteOnchainResponse = MintQuoteBaseResponse & { * Public key the quote is locked to. */ pubkey: string; - /** - * The amount that has been paid to the mint via the onchain transaction. - */ - amount_paid: Amount; - /** - * The amount of ecash that has been issued for the given mint quote. - */ - amount_issued: Amount; }; /** diff --git a/src/wallet/Wallet.ts b/src/wallet/Wallet.ts index 0fc7a41f5..d365f7b33 100644 --- a/src/wallet/Wallet.ts +++ b/src/wallet/Wallet.ts @@ -1953,9 +1953,6 @@ class Wallet { quote: Pick, requestedAmount: Amount, ): void { - if (method !== 'bolt12' && method !== 'onchain') { - return; - } if (!('amount_paid' in quote) || !('amount_issued' in quote)) { return; } diff --git a/test/mint/Mint.node.test.ts b/test/mint/Mint.node.test.ts index 09ce663a9..11e0091b6 100644 --- a/test/mint/Mint.node.test.ts +++ b/test/mint/Mint.node.test.ts @@ -352,7 +352,8 @@ describe('Mint normalization', () => { return { quote: 'q1', request: 'lnbc1...', - paid: false, + unit: 'sat', + state: 'UNPAID', expiry: null, amount: 21, }; @@ -377,7 +378,7 @@ describe('Mint normalization', () => { return { quote: 'q1', request: 'lno1...', - paid: true, + unit: 'sat', expiry: 123, amount_paid: 5, amount_issued: 4, @@ -461,7 +462,10 @@ describe('Mint normalization', () => { it('supports custom quote methods with a normalize callback', async () => { const requestSpy = vi.fn(async () => ({ quote: 'q1', - paid: false, + request: 'acct:12345', + unit: 'sat', + amount_paid: 0, + amount_issued: 0, expiry: 123, note: 'custom', })) as RequestFn; @@ -512,6 +516,136 @@ describe('Mint normalization', () => { ); }); + describe('mint quote accounting (NUT-04 amount_paid/amount_issued/updated_at)', () => { + const baseBolt11 = { + quote: 'q1', + request: 'lnbc1...', + unit: 'sat', + amount: 21, + expiry: 123, + }; + + it.each([ + ['UNPAID', 0n, 0n], + ['PAID', 21n, 0n], + ['ISSUED', 21n, 21n], + ])('derives accounting from legacy bolt11 state %s', async (state, paid, issued) => { + const mint = new Mint(mintUrl, { customRequest: makeRequest({ ...baseBolt11, state }) }); + + const response = await mint.checkMintQuoteBolt11('q1'); + + expect(response.amount_paid.toBigInt()).toBe(paid); + expect(response.amount_issued.toBigInt()).toBe(issued); + expect(response.updated_at).toBeNull(); + expect(response.state).toBe(state); + }); + + it.each([ + [21, 0, 'PAID'], + [21, 10, 'PAID'], + [21, 21, 'ISSUED'], + [0, 0, 'UNPAID'], + ])( + 'derives bolt11 state from accounting fields paid=%d issued=%d -> %s', + async (paid, issued, state) => { + const mint = new Mint(mintUrl, { + customRequest: makeRequest({ + ...baseBolt11, + amount_paid: paid, + amount_issued: issued, + updated_at: 1750000000, + }), + }); + + const response = await mint.checkMintQuoteBolt11('q1'); + + expect(response.state).toBe(state); + expect(response.amount_paid.toBigInt()).toBe(BigInt(paid)); + expect(response.amount_issued.toBigInt()).toBe(BigInt(issued)); + expect(response.updated_at).toBe(1750000000); + }, + ); + + it('keeps the mint-provided state when both state and accounting fields are present', async () => { + const mint = new Mint(mintUrl, { + customRequest: makeRequest({ + ...baseBolt11, + state: 'PAID', + amount_paid: 21, + amount_issued: 0, + }), + }); + + const response = await mint.checkMintQuoteBolt11('q1'); + + expect(response.state).toBe('PAID'); + }); + + it('normalizes updated_at on bolt12 responses and defaults it to null', async () => { + const bolt12 = { + quote: 'q1', + request: 'lno1...', + unit: 'sat', + amount: null, + expiry: 123, + pubkey: '02abcd', + amount_paid: 5, + amount_issued: 4, + }; + const withTimestamp = new Mint(mintUrl, { + customRequest: makeRequest({ ...bolt12, updated_at: 1750000001 }), + }); + const without = new Mint(mintUrl, { customRequest: makeRequest(bolt12) }); + + expect((await withTimestamp.checkMintQuoteBolt12('q1')).updated_at).toBe(1750000001); + expect((await without.checkMintQuoteBolt12('q1')).updated_at).toBeNull(); + }); + + it('normalizes base accounting for custom methods and preserves unknown fields', async () => { + const mint = new Mint(mintUrl, { + customRequest: makeRequest({ + quote: 'q1', + request: 'acct:12345', + unit: 'usd', + amount_paid: 100, + amount_issued: 40, + updated_at: 1750000002, + processor_ref: 'px-77', + }), + }); + + const response = await mint.checkMintQuote('paypal', 'q1'); + + expect(response.amount_paid.toBigInt()).toBe(100n); + expect(response.amount_issued.toBigInt()).toBe(40n); + expect(response.updated_at).toBe(1750000002); + expect((response as Record).processor_ref).toBe('px-77'); + }); + + it('throws when accounting fields are missing and underivable', async () => { + const logger = createLogger(); + const mint = new Mint(mintUrl, { + customRequest: makeRequest({ quote: 'q1', request: 'acct:12345', unit: 'usd' }), + logger, + }); + + await expect(mint.checkMintQuote('paypal', 'q1')).rejects.toThrow( + 'Invalid response from mint', + ); + expect(logger.error).toHaveBeenCalled(); + }); + + it('throws when a mint quote response lacks base fields', async () => { + const mint = new Mint(mintUrl, { + customRequest: makeRequest({ quote: 'q1', amount_paid: 1, amount_issued: 0 }), + }); + + await expect(mint.checkMintQuote('paypal', 'q1')).rejects.toThrow( + 'Invalid response from mint', + ); + }); + }); + it('throws on invalid swap responses', async () => { const logger = createLogger(); const mint = new Mint(mintUrl, { diff --git a/test/wallet/bolt12.test.ts b/test/wallet/bolt12.test.ts index 23ca6307f..08b5a8109 100644 --- a/test/wallet/bolt12.test.ts +++ b/test/wallet/bolt12.test.ts @@ -417,7 +417,8 @@ describe('Wallet (BOLT12) – wrappers', () => { it('wallet.checkMintQuoteBolt12 delegates to mint', async () => { const response = { quote: 'q1', - state: 'PAID', + request: 'lno1offer...', + unit: 'sat', expiry: null, amount_paid: 21, amount_issued: 21, diff --git a/test/wallet/wallet-mint.node.test.ts b/test/wallet/wallet-mint.node.test.ts index ffa7350b8..dd62b8421 100644 --- a/test/wallet/wallet-mint.node.test.ts +++ b/test/wallet/wallet-mint.node.test.ts @@ -808,6 +808,8 @@ describe('generic mint/melt methods', () => { quote: 'bacs-quote-unit', request: 'CASHU-REF-UNIT', unit: body.unit, + amount_paid: 0, + amount_issued: 0, }); }), ); @@ -1131,6 +1133,21 @@ describe('generic mint/melt methods', () => { ).rejects.toThrow('Mint quote bolt12-partial has only 2 available to mint; requested 3'); }); + test('prepareMint rejects amounts above paid minus issued for custom methods', async () => { + const wallet = new Wallet(mint, { unit: 'sat' }); + await wallet.loadMint(); + + await expect( + wallet.prepareMint('bacs', 3, { + quote: 'bacs-partial', + request: 'CASHU-REF', + unit: 'sat', + amount_paid: Amount.from(5), + amount_issued: Amount.from(3), + }), + ).rejects.toThrow('Mint quote bacs-partial has only 2 available to mint; requested 3'); + }); + test('prepareMint keeps string-only quote support without available amount fields', async () => { const wallet = new Wallet(mint, { unit: 'sat' }); await wallet.loadMint(); From ea0c9c5fb684bf14b6285b5841cc91998ce1d1ef Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Fri, 12 Jun 2026 14:59:41 +0100 Subject: [PATCH 02/11] feat(nut04/05)!: widen quote base structs for custom payment methods Implements the base-shape harmonization proposed on cashubtc/nuts#382: - MintQuoteBaseRequest gains optional amount and description; method NUTs tighten as needed (bolt11 requires amount). - MintQuoteBaseResponse gains expiry (normalized to null when unset), subsuming the per-method expiry fields. - MeltQuoteBaseRequest gains optional amount (onchain requires it). - MeltQuoteBaseResponse gains request and optional fee_reserve; bolt methods keep fee_reserve required. - Melt base validation now requires the payment request for every method, and normalizes fee_reserve when present. - New MintQuoteGenericResponse/MeltQuoteGenericResponse passthrough types are the defaults on the generic quote methods, so custom-method fields are reachable without casting. BREAKING CHANGE: melt quote responses without a request field now throw; generic quote methods default to the Generic response types. --- src/mint/Mint.ts | 50 ++++++------------ src/model/types/NUT04.ts | 20 +++++++- src/model/types/NUT05.ts | 21 +++++++- src/model/types/NUT23.ts | 11 +--- src/model/types/NUT25.ts | 4 -- src/model/types/NUT30.ts | 8 --- src/wallet/Wallet.ts | 10 ++-- test/mint/Mint.node.test.ts | 77 ++++++++++++++++++++++++++++ test/wallet/bolt12.test.ts | 2 +- test/wallet/wallet-mint.node.test.ts | 6 +++ 10 files changed, 148 insertions(+), 61 deletions(-) diff --git a/src/mint/Mint.ts b/src/mint/Mint.ts index 4c6187b13..c74e6c6dd 100644 --- a/src/mint/Mint.ts +++ b/src/mint/Mint.ts @@ -13,9 +13,11 @@ import { CTSError } from '../model/Errors'; import { MintInfo } from '../model/MintInfo'; import { type MintQuoteBaseResponse, + type MintQuoteGenericResponse, type MintQuoteBolt11Response, type MintQuoteBolt12Response, type MeltQuoteBaseResponse, + type MeltQuoteGenericResponse, type MeltQuoteBolt11Response, type MeltQuoteBolt12Response, MeltQuoteState, @@ -238,7 +240,7 @@ class Mint { * @param options.normalize Optional callback to normalize method-specific response fields. * @returns The mint quote response. */ - async createMintQuote( + async createMintQuote( method: string, payload: Record, options?: { customRequest?: RequestFn; normalize?: (raw: Record) => TRes }, @@ -332,7 +334,7 @@ class Mint { * @param options.normalize Optional callback to normalize method-specific response fields. * @returns The mint quote response. */ - async checkMintQuote( + async checkMintQuote( method: string, quote: string, options?: { customRequest?: RequestFn; normalize?: (raw: Record) => TRes }, @@ -569,7 +571,7 @@ class Mint { * @param options.normalize Optional callback to normalize method-specific response fields. * @returns The melt quote response. */ - async createMeltQuote( + async createMeltQuote( method: string, payload: Record, options?: { customRequest?: RequestFn; normalize?: (raw: Record) => TRes }, @@ -665,7 +667,7 @@ class Mint { * @param options.normalize Optional callback to normalize method-specific response fields. * @returns The melt quote response. */ - async checkMeltQuote( + async checkMeltQuote( method: string, quote: string, options?: { customRequest?: RequestFn; normalize?: (raw: Record) => TRes }, @@ -1145,9 +1147,10 @@ class Mint { } /** - * Stacks normalizers for mint quote responses: first-class (bolt11/bolt12) normalization is - * applied for known methods, then any custom normalize callback. Works on untyped wire data - * internally — the caller casts the result to the desired TRes. + * Stacks normalizers for mint quote responses: base normalization (accounting, expiry, + * updated_at) is always applied, then first-class normalization for known methods, then any + * custom normalize callback. Works on untyped wire data internally — the caller casts the result + * to the desired TRes. */ private normalizeMintQuoteResponse( method: string, @@ -1161,8 +1164,6 @@ class Mint { this.normalizeMintQuoteBolt11Fields(data); } else if (method === 'bolt12') { this.normalizeMintQuoteBolt12Fields(data); - } else if (method === 'onchain') { - this.normalizeMintQuoteOnchainFields(data); } return normalize ? normalize(data) : (data as TRes); } @@ -1190,6 +1191,7 @@ class Mint { 'mintQuote.updated_at', null, ); + data.expiry = normalizeSafeIntegerMetadata(data.expiry as number, 'mintQuote.expiry', null); if ( typeof data.quote !== 'string' || typeof data.request !== 'string' || @@ -1238,11 +1240,6 @@ class Mint { */ private normalizeMintQuoteBolt11Fields(data: Record): void { data.amount = Amount.from(data.amount as AmountLike); - data.expiry = normalizeSafeIntegerMetadata( - data.expiry as number, - 'mintQuoteBolt11.expiry', - null, - ); if ( typeof data.state !== 'string' || !Object.values(MintQuoteState).includes(data.state as MintQuoteState) @@ -1263,22 +1260,6 @@ class Mint { // values are coerced to Amount. nullIfUndefined(data, 'amount'); data.amount = data.amount === null ? null : Amount.from(data.amount as Amount); - data.expiry = normalizeSafeIntegerMetadata( - data.expiry as number, - 'mintQuoteBolt12.expiry', - null, - ); - } - - /** - * Mutates `data` in place, normalizing onchain mint-quote fields. - */ - private normalizeMintQuoteOnchainFields(data: Record): void { - data.expiry = normalizeSafeIntegerMetadata( - data.expiry as number, - 'mintQuoteOnchain.expiry', - null, - ); } /** @@ -1312,12 +1293,16 @@ class Mint { 'meltQuote.expiry', undefined, ); + if (data.fee_reserve !== undefined) { + data.fee_reserve = Amount.from(data.fee_reserve as AmountLike); + } if (data.change) { data.change = this.normalizeSignatureAmounts(data.change as SerializedBlindedSignature[]); } if ( !isObj(data) || typeof data.quote !== 'string' || + typeof data.request !== 'string' || !(data.amount instanceof Amount) || typeof data.unit !== 'string' || typeof data.state !== 'string' || @@ -1333,8 +1318,8 @@ class Mint { * Mutates `data` in place, normalizing bolt11/bolt12-specific melt fields. */ private normalizeMeltBoltFields(data: Record, op: string): void { - data.fee_reserve = Amount.from(data.fee_reserve as AmountLike); - if (typeof data.request !== 'string' || !(data.fee_reserve instanceof Amount)) { + // Base normalization already coerced fee_reserve when present; bolt methods require it. + if (!(data.fee_reserve instanceof Amount)) { this._logger.error('Invalid response from mint...', { data, op }); throw new CTSError('Invalid response from mint'); } @@ -1368,7 +1353,6 @@ class Mint { }); nullIfUndefined(data, 'selected_fee_index', 'outpoint'); if ( - typeof data.request !== 'string' || (data.selected_fee_index !== null && !Number.isSafeInteger(data.selected_fee_index)) || (data.outpoint !== null && typeof data.outpoint !== 'string') ) { diff --git a/src/model/types/NUT04.ts b/src/model/types/NUT04.ts index 18b3e69ee..f9c03aca3 100644 --- a/src/model/types/NUT04.ts +++ b/src/model/types/NUT04.ts @@ -1,4 +1,4 @@ -import type { Amount } from '../Amount'; +import type { Amount, AmountLike } from '../Amount'; import { type SerializedBlindedMessage, type SerializedBlindedSignature } from './blinded'; @@ -18,6 +18,14 @@ export type MintQuoteBaseRequest = { * Unit to be minted. */ unit: string; + /** + * Optional. Amount to be minted. Method-specific NUTs may require it (e.g. bolt11). + */ + amount?: AmountLike; + /** + * Optional. Description for the payment request. + */ + description?: string; /** * Optional. Public key to lock the quote to (NUT-20). */ @@ -55,12 +63,22 @@ export type MintQuoteBaseResponse = { * Unix timestamp of the last quote update. `null` when the mint does not report it. */ updated_at: number | null; + /** + * Timestamp of when the quote expires. `null` when the mint does not set an expiry. + */ + expiry: number | null; /** * Optional. Public key the quote is locked to (NUT-20) */ pubkey?: string; }; +/** + * Mint quote response for methods without first-class types. Base fields are normalized and + * validated; method-specific fields pass through unchanged. + */ +export type MintQuoteGenericResponse = MintQuoteBaseResponse & Record; + /** * Payload that needs to be sent to the mint when requesting a mint. */ diff --git a/src/model/types/NUT05.ts b/src/model/types/NUT05.ts index ebee5db29..260885f73 100644 --- a/src/model/types/NUT05.ts +++ b/src/model/types/NUT05.ts @@ -1,4 +1,4 @@ -import type { Amount } from '../Amount'; +import type { Amount, AmountLike } from '../Amount'; import { type SerializedBlindedMessage, type SerializedBlindedSignature } from './blinded'; import { type Proof } from './proof'; @@ -22,6 +22,10 @@ export type MeltQuoteBaseRequest = { * Request to be melted to. */ request: string; + /** + * Optional. Amount to be melted. Method-specific NUTs may require it (e.g. onchain). + */ + amount?: AmountLike; }; /** @@ -32,10 +36,19 @@ export type MeltQuoteBaseResponse = { * Quote ID. */ quote: string; + /** + * Method-specific payment routing instructions (e.g. bolt11 invoice, onchain address, bank + * account reference). + */ + request: string; /** * Amount to be melted. */ amount: Amount; + /** + * Optional. Additional fee reserve for using the method. + */ + fee_reserve?: Amount; /** * Unit of the melt quote. */ @@ -55,6 +68,12 @@ export type MeltQuoteBaseResponse = { change?: SerializedBlindedSignature[]; }; +/** + * Melt quote response for methods without first-class types. Base fields are normalized and + * validated; method-specific fields pass through unchanged. + */ +export type MeltQuoteGenericResponse = MeltQuoteBaseResponse & Record; + /** * Generic Melt request payload. * diff --git a/src/model/types/NUT23.ts b/src/model/types/NUT23.ts index 870fb98e5..f6fc95eab 100644 --- a/src/model/types/NUT23.ts +++ b/src/model/types/NUT23.ts @@ -30,13 +30,10 @@ export type MintQuoteBolt11Response = MintQuoteBaseResponse & { */ amount: Amount; /** - * State of the mint quote. + * State of the mint quote. Deprecated in NUT-04 in favour of the accounting fields; cashu-ts + * always populates it for bolt11. */ state: MintQuoteState; - /** - * Timestamp of when the quote expires. `null` when the mint does not set an expiry. - */ - expiry: number | null; }; /** @@ -63,10 +60,6 @@ export type MeltQuoteBolt11Request = MeltQuoteBaseRequest & { * for paying Lightning Network offers. */ export type MeltQuoteBolt11Response = MeltQuoteBaseResponse & { - /** - * Payment request for the melt quote. - */ - request: string; // LN invoice /** * Fee reserve to be added to the amount. */ diff --git a/src/model/types/NUT25.ts b/src/model/types/NUT25.ts index d91c4381c..b430f7e2b 100644 --- a/src/model/types/NUT25.ts +++ b/src/model/types/NUT25.ts @@ -26,10 +26,6 @@ export type MintQuoteBolt12Response = MintQuoteBaseResponse & { * Amount requested for mint quote. `null` for amountless offers (per NUT-25). */ amount: Amount | null; - /** - * Timestamp of when the quote expires. `null` when the mint does not set an expiry. - */ - expiry: number | null; /** * Public key the quote is locked to. * diff --git a/src/model/types/NUT30.ts b/src/model/types/NUT30.ts index 5ead02ac9..feffeb9e4 100644 --- a/src/model/types/NUT30.ts +++ b/src/model/types/NUT30.ts @@ -17,10 +17,6 @@ export type MintQuoteOnchainRequest = MintQuoteBaseRequest & { * Response from the mint after requesting an onchain mint quote. */ export type MintQuoteOnchainResponse = MintQuoteBaseResponse & { - /** - * Timestamp of when the quote expires. `null` when the mint does not set an expiry. - */ - expiry: number | null; /** * Public key the quote is locked to. */ @@ -63,10 +59,6 @@ export type MeltQuoteOnchainFeeOption = { * contains NUT-08 change signatures when the mint returns onchain melt change. */ export type MeltQuoteOnchainResponse = MeltQuoteBaseResponse & { - /** - * Bitcoin address or destination. - */ - request: string; /** * Available fee and confirmation estimates for this quote. */ diff --git a/src/wallet/Wallet.ts b/src/wallet/Wallet.ts index d365f7b33..83eb9bfa9 100644 --- a/src/wallet/Wallet.ts +++ b/src/wallet/Wallet.ts @@ -30,12 +30,14 @@ import type { GetInfoResponse, MeltRequest, MeltQuoteBaseResponse, + MeltQuoteGenericResponse, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltQuoteBolt12Response, MeltQuoteOnchainResponse, MintRequest, MintQuoteBaseResponse, + MintQuoteGenericResponse, MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteOnchainResponse, @@ -1711,7 +1713,7 @@ class Wallet { * @param options.normalize Optional callback to normalize method-specific response fields. * @returns The mint quote response. */ - async createMintQuote( + async createMintQuote( method: string, payload: Record, options?: { normalize?: (raw: Record) => TRes }, @@ -1865,7 +1867,7 @@ class Wallet { * @param options.normalize Optional callback to normalize method-specific response fields. * @returns The mint quote response. */ - async checkMintQuote( + async checkMintQuote( method: string, quote: string | Pick, options?: { normalize?: (raw: Record) => TRes }, @@ -2429,7 +2431,7 @@ class Wallet { * @param options.normalize Optional callback to normalize method-specific response fields. * @returns The melt quote response. */ - async createMeltQuote( + async createMeltQuote( method: string, payload: Record, options?: { normalize?: (raw: Record) => TRes }, @@ -2596,7 +2598,7 @@ class Wallet { * @param options.normalize Optional callback to normalize method-specific response fields. * @returns The melt quote response. */ - async checkMeltQuote( + async checkMeltQuote( method: string, quote: string | Pick, options?: { normalize?: (raw: Record) => TRes }, diff --git a/test/mint/Mint.node.test.ts b/test/mint/Mint.node.test.ts index 11e0091b6..44cdd06b0 100644 --- a/test/mint/Mint.node.test.ts +++ b/test/mint/Mint.node.test.ts @@ -482,6 +482,7 @@ describe('Mint normalization', () => { normalize: (raw) => ({ ...raw, tag: 'melt' }) as any, customRequest: (async () => ({ quote: 'q1', + request: 'acct:12345', amount: 1, unit: 'sat', state: MeltQuoteState.UNPAID, @@ -646,6 +647,82 @@ describe('Mint normalization', () => { }); }); + describe('custom payment method base normalization (NUT-04/05 common formats)', () => { + it('normalizes expiry on custom mint quotes and defaults it to null', async () => { + const base = { + quote: 'q1', + request: 'acct:12345', + unit: 'usd', + amount_paid: 0, + amount_issued: 0, + }; + const withExpiry = new Mint(mintUrl, { + customRequest: makeRequest({ ...base, expiry: 456 }), + }); + const without = new Mint(mintUrl, { customRequest: makeRequest(base) }); + + expect((await withExpiry.checkMintQuote('paypal', 'q1')).expiry).toBe(456); + expect((await without.checkMintQuote('paypal', 'q1')).expiry).toBeNull(); + }); + + it('normalizes melt base fields for custom methods and preserves unknown fields', async () => { + const mint = new Mint(mintUrl, { + customRequest: makeRequest({ + quote: 'm1', + request: 'acct:12345', + amount: 9, + unit: 'usd', + state: 'UNPAID', + expiry: 123, + fee_reserve: 2, + processor_ref: 'px-77', + }), + }); + + const response = await mint.checkMeltQuote('paypal', 'm1'); + + expect(response.request).toBe('acct:12345'); + expect(response.fee_reserve?.toBigInt()).toBe(2n); + expect((response as Record).processor_ref).toBe('px-77'); + }); + + it('leaves fee_reserve undefined for custom melt quotes when not provided', async () => { + const mint = new Mint(mintUrl, { + customRequest: makeRequest({ + quote: 'm1', + request: 'acct:12345', + amount: 9, + unit: 'usd', + state: 'UNPAID', + expiry: 123, + }), + }); + + const response = await mint.checkMeltQuote('paypal', 'm1'); + + expect(response.fee_reserve).toBeUndefined(); + }); + + it('throws when a custom melt quote lacks the payment request', async () => { + const logger = createLogger(); + const mint = new Mint(mintUrl, { + customRequest: makeRequest({ + quote: 'm1', + amount: 9, + unit: 'usd', + state: 'UNPAID', + expiry: 123, + }), + logger, + }); + + await expect(mint.checkMeltQuote('paypal', 'm1')).rejects.toThrow( + 'Invalid response from mint', + ); + expect(logger.error).toHaveBeenCalled(); + }); + }); + it('throws on invalid swap responses', async () => { const logger = createLogger(); const mint = new Mint(mintUrl, { diff --git a/test/wallet/bolt12.test.ts b/test/wallet/bolt12.test.ts index 08b5a8109..1880e1114 100644 --- a/test/wallet/bolt12.test.ts +++ b/test/wallet/bolt12.test.ts @@ -152,7 +152,7 @@ describe('Mint (BOLT12) – instance methods via customRequest', () => { unit: 'sat', pubkey: '02abcd', }), - ).rejects.toThrow('mintQuoteBolt12.expiry'); + ).rejects.toThrow('mintQuote.expiry'); }); it('checkMintQuoteBolt12 requests /v1/mint/quote/bolt12/{quote}', async () => { diff --git a/test/wallet/wallet-mint.node.test.ts b/test/wallet/wallet-mint.node.test.ts index dd62b8421..f6cebb85e 100644 --- a/test/wallet/wallet-mint.node.test.ts +++ b/test/wallet/wallet-mint.node.test.ts @@ -1236,6 +1236,7 @@ describe('generic mint/melt methods', () => { http.post(mintUrl + '/v1/melt/quote/bacs', () => HttpResponse.json({ quote: 'bacs-melt-1', + request: 'GB29NWBK60161331926819', amount: 5000, unit: 'gbp', state: MeltQuoteState.UNPAID, @@ -1282,6 +1283,7 @@ describe('generic mint/melt methods', () => { const body = (await request.json()) as { unit: string }; return HttpResponse.json({ quote: 'bacs-melt-unit', + request: 'GB29NWBK60161331926819', amount: 5000, unit: body.unit, state: MeltQuoteState.UNPAID, @@ -1306,6 +1308,7 @@ describe('generic mint/melt methods', () => { http.get(mintUrl + '/v1/melt/quote/bacs/bacs-melt-1', () => HttpResponse.json({ quote: 'bacs-melt-1', + request: 'GB29NWBK60161331926819', amount: 5000, unit: 'gbp', state: MeltQuoteState.PAID, @@ -1360,6 +1363,7 @@ describe('generic mint/melt methods', () => { http.get(mintUrl + '/v1/melt/quote/bacs/bacs-melt-2', () => HttpResponse.json({ quote: 'bacs-melt-2', + request: 'GB29NWBK60161331926819', amount: 100, unit: 'gbp', state: MeltQuoteState.UNPAID, @@ -1443,6 +1447,7 @@ describe('generic mint/melt methods', () => { http.post(mintUrl + '/v1/melt/bacs', () => { return HttpResponse.json({ quote: 'bacs-melt-1', + request: 'GB29NWBK60161331926819', amount: 10, unit: 'sat', state: MeltQuoteState.PAID, @@ -1797,6 +1802,7 @@ describe('generic mint/melt methods', () => { http.post(mintUrl + '/v1/melt/quote/swift', () => HttpResponse.json({ quote: 'swift-1', + request: 'SWIFT-REF', amount: 200, unit: 'usd', state: MeltQuoteState.UNPAID, From 1cdf3095dd120b95d9bcba681fbd7fb32f604d67 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Fri, 12 Jun 2026 15:00:29 +0100 Subject: [PATCH 03/11] chore: update api report for quote base struct widening --- etc/cashu-ts.api.md | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index 979c288a3..456f073db 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -845,12 +845,15 @@ export type MeltProofsResponse; + // @public export type MeltQuoteOnchainFeeOption = { fee_index: number; @@ -902,7 +907,6 @@ export type MeltQuoteOnchainRequest = MeltQuoteBaseRequest & { // @public export type MeltQuoteOnchainResponse = MeltQuoteBaseResponse & { - request: string; fee_options: MeltQuoteOnchainFeeOption[]; selected_fee_index: number | null; outpoint: string | null; @@ -935,14 +939,14 @@ export class Mint { logger?: Logger; }); check(checkPayload: CheckStatePayload, customRequest?: RequestFn): Promise; - checkMeltQuote(method: string, quote: string, options?: { + checkMeltQuote(method: string, quote: string, options?: { customRequest?: RequestFn; normalize?: (raw: Record) => TRes; }): Promise; checkMeltQuoteBolt11(quote: string, customRequest?: RequestFn): Promise; checkMeltQuoteBolt12(quote: string, customRequest?: RequestFn): Promise; checkMeltQuoteOnchain(quote: string, customRequest?: RequestFn): Promise; - checkMintQuote(method: string, quote: string, options?: { + checkMintQuote(method: string, quote: string, options?: { customRequest?: RequestFn; normalize?: (raw: Record) => TRes; }): Promise; @@ -950,14 +954,14 @@ export class Mint { checkMintQuoteBolt12(quote: string, customRequest?: RequestFn): Promise; checkMintQuoteOnchain(quote: string, customRequest?: RequestFn): Promise; connectWebSocket(): Promise; - createMeltQuote(method: string, payload: Record, options?: { + createMeltQuote(method: string, payload: Record, options?: { customRequest?: RequestFn; normalize?: (raw: Record) => TRes; }): Promise; createMeltQuoteBolt11(meltQuotePayload: MeltQuoteBolt11Request, customRequest?: RequestFn): Promise; createMeltQuoteBolt12(meltQuotePayload: MeltQuoteBolt12Request, customRequest?: RequestFn): Promise; createMeltQuoteOnchain(meltQuotePayload: MeltQuoteOnchainRequest, customRequest?: RequestFn): Promise; - createMintQuote(method: string, payload: Record, options?: { + createMintQuote(method: string, payload: Record, options?: { customRequest?: RequestFn; normalize?: (raw: Record) => TRes; }): Promise; @@ -1203,6 +1207,8 @@ export type MintProofsConfig = { // @public export type MintQuoteBaseRequest = { unit: string; + amount?: AmountLike; + description?: string; pubkey?: string; }; @@ -1211,6 +1217,10 @@ export type MintQuoteBaseResponse = { quote: string; request: string; unit: string; + amount_paid: Amount; + amount_issued: Amount; + updated_at: number | null; + expiry: number | null; pubkey?: string; }; @@ -1224,7 +1234,6 @@ export type MintQuoteBolt11Request = MintQuoteBaseRequest & { export type MintQuoteBolt11Response = MintQuoteBaseResponse & { amount: Amount; state: MintQuoteState; - expiry: number | null; }; // @public @@ -1236,15 +1245,15 @@ export type MintQuoteBolt12Request = MintQuoteBaseRequest & { // @public export type MintQuoteBolt12Response = MintQuoteBaseResponse & { amount: Amount | null; - expiry: number | null; pubkey: string; - amount_paid: Amount; - amount_issued: Amount; }; // @public (undocumented) export type MintQuoteFor = M extends 'bolt11' ? string | MintQuoteBolt11Response : M extends 'bolt12' ? MintQuoteBolt12Response : MintQuoteOnchainResponse; +// @public +export type MintQuoteGenericResponse = MintQuoteBaseResponse & Record; + // @public export type MintQuoteOnchainRequest = MintQuoteBaseRequest & { pubkey: string; @@ -1252,10 +1261,7 @@ export type MintQuoteOnchainRequest = MintQuoteBaseRequest & { // @public export type MintQuoteOnchainResponse = MintQuoteBaseResponse & { - expiry: number | null; pubkey: string; - amount_paid: Amount; - amount_issued: Amount; }; // @public (undocumented) @@ -2144,13 +2150,13 @@ export class Wallet { lastCounterWithSignature?: number; }>; bindKeyset(id: string): void; - checkMeltQuote(method: string, quote: string | Pick, options?: { + checkMeltQuote(method: string, quote: string | Pick, options?: { normalize?: (raw: Record) => TRes; }): Promise; checkMeltQuoteBolt11(quote: string | MeltQuoteBolt11Response): Promise; checkMeltQuoteBolt12(quote: string): Promise; checkMeltQuoteOnchain(quote: string): Promise; - checkMintQuote(method: string, quote: string | Pick, options?: { + checkMintQuote(method: string, quote: string | Pick, options?: { normalize?: (raw: Record) => TRes; }): Promise; checkMintQuoteBolt11(quote: string | MintQuoteBolt11Response): Promise; @@ -2164,13 +2170,13 @@ export class Wallet { readonly counters: WalletCounters; createLockedMintQuote(amount: AmountLike, pubkey: string, description?: string): Promise; createMeltChangeProofs(outputData: OutputDataLike[], changeSigs: SerializedBlindedSignature[]): Proof[]; - createMeltQuote(method: string, payload: Record, options?: { + createMeltQuote(method: string, payload: Record, options?: { normalize?: (raw: Record) => TRes; }): Promise; createMeltQuoteBolt11(invoice: string, amountMsat?: AmountLike): Promise; createMeltQuoteBolt12(offer: string, amountMsat?: AmountLike): Promise; createMeltQuoteOnchain(address: string, amount: AmountLike): Promise; - createMintQuote(method: string, payload: Record, options?: { + createMintQuote(method: string, payload: Record, options?: { normalize?: (raw: Record) => TRes; }): Promise; createMintQuoteBolt11(amount: AmountLike, description?: string): Promise; From d090cb681084ae639015f081b1d3f2edb19b7b32 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Fri, 12 Jun 2026 16:58:00 +0100 Subject: [PATCH 04/11] fix(wallet): defer to mint when quote accounting reports no payment activity The canonical bolt11 flow is create quote -> pay externally -> mint with the original quote object, so a 0/0 accounting snapshot is indistinguishable from a stale pre-payment quote. Skip the client-side mintable-amount check in that case and let the mint decide; once amount_paid is non-zero the snapshot proves a payment event and the check applies as before. --- src/wallet/Wallet.ts | 5 +++++ test/wallet/wallet-mint.node.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/wallet/Wallet.ts b/src/wallet/Wallet.ts index 83eb9bfa9..76c07c6a3 100644 --- a/src/wallet/Wallet.ts +++ b/src/wallet/Wallet.ts @@ -1960,6 +1960,11 @@ class Wallet { } const amountPaid = Amount.from(quote.amount_paid as AmountLike); const amountIssued = Amount.from(quote.amount_issued as AmountLike); + // A 0/0 snapshot is indistinguishable from a stale pre-payment quote (create -> pay + // externally -> mint with the original object); the mint is the source of truth. + if (amountPaid.isZero() && amountIssued.isZero()) { + return; + } const availableAmount = amountPaid.subtract(amountIssued); this.failIf( requestedAmount.greaterThan(availableAmount), diff --git a/test/wallet/wallet-mint.node.test.ts b/test/wallet/wallet-mint.node.test.ts index f6cebb85e..a10077825 100644 --- a/test/wallet/wallet-mint.node.test.ts +++ b/test/wallet/wallet-mint.node.test.ts @@ -1148,6 +1148,24 @@ describe('generic mint/melt methods', () => { ).rejects.toThrow('Mint quote bacs-partial has only 2 available to mint; requested 3'); }); + test('prepareMint defers to the mint when the quote reports no payment activity', async () => { + // create -> pay externally -> mint with the original quote object is the + // canonical bolt11 flow; a 0/0 accounting snapshot is indistinguishable + // from a stale pre-payment quote and must not fail fast. + const wallet = new Wallet(mint, { unit: 'sat' }); + await wallet.loadMint(); + + const preview = await wallet.prepareMint('bolt11', 3, { + quote: 'stale-unpaid', + request: 'lnbc1...', + unit: 'sat', + amount_paid: Amount.from(0), + amount_issued: Amount.from(0), + }); + + expect(preview.payload.quote).toBe('stale-unpaid'); + }); + test('prepareMint keeps string-only quote support without available amount fields', async () => { const wallet = new Wallet(mint, { unit: 'sat' }); await wallet.loadMint(); From 75ca0c4f0e0a11663ab0fe30151d1346e6086ced Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Fri, 12 Jun 2026 17:00:40 +0100 Subject: [PATCH 05/11] docs: add v4 migration notes for NUT-04/05 quote payload changes --- migration-5.0.0.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/migration-5.0.0.md b/migration-5.0.0.md index 8ccf9d793..188abdb4e 100644 --- a/migration-5.0.0.md +++ b/migration-5.0.0.md @@ -198,3 +198,70 @@ await wallet.completeMelt(meltPreview, privkey, { preferAsync: true }); ``` Calls that already pass a `CompleteMeltOptions` object (or omit the third argument) need no change. + +--- + +## Mint quote responses now carry NUT-04 accounting fields + +`MintQuoteBaseResponse` (and every method-specific mint quote response) gains four required fields: + +- `amount_paid: Amount` — total paid to the mint for this quote +- `amount_issued: Amount` — total ecash issued for this quote +- `updated_at: number | null` — Unix timestamp of the last quote update (`null` when the mint does not report it) +- `expiry: number | null` — moved here from the method-specific response types + +The difference `amount_paid − amount_issued` is the amount available to mint. The single-use `state` field is deprecated in NUT-04 in favour of the accounting fields, but cashu-ts always populates it on `MintQuoteBolt11Response` (derived from the accounting fields when the mint omits it). + +Quotes returned by `Wallet`/`Mint` methods need no change: normalization fills the fields in, deriving them from the legacy `state` and `amount` for mints that predate quote accounting. + +### Migration + +Code that constructs mint quote objects (test fixtures, hydrating stored quotes) must supply the new fields: + +```ts +// Before +const quote: MintQuoteBolt11Response = { + quote: 'q1', + request: 'lnbc…', + unit: 'sat', + amount: Amount.from(21), + state: 'UNPAID', + expiry: null, +}; + +// After +const quote: MintQuoteBolt11Response = { + quote: 'q1', + request: 'lnbc…', + unit: 'sat', + amount: Amount.from(21), + state: 'UNPAID', + expiry: null, + amount_paid: Amount.from(0), + amount_issued: Amount.from(0), + updated_at: null, +}; +``` + +Generic mint quote responses (custom payment methods) are now base-validated like melt quotes always were: a response missing `quote`, `request` or `unit`, or whose accounting fields are absent and underivable, throws `Invalid response from mint` instead of passing through silently. + +--- + +## Melt quote responses require `request` + +`request` (the method-specific payment routing instructions) moved from the bolt11/onchain response types into `MeltQuoteBaseResponse`, and the base type gains an optional `fee_reserve`. Melt quote responses from any method — including custom ones — that lack a `request` string now throw `Invalid response from mint`. + +bolt11, bolt12 and onchain flows are unaffected: those responses already required `request`. Code constructing a plain `MeltQuoteBaseResponse` must include it. + +--- + +## Mintable amount is enforced for every payment method + +`prepareMint`/`mintProofs` previously rejected requests above `amount_paid − amount_issued` only for bolt12 and onchain quotes. v5 applies the check to any quote object that carries accounting fields, regardless of method. + +Two escape hatches keep stored-quote flows working: + +- Quote objects without accounting fields (e.g. minimal `{ quote: '…' }` references) skip the check, as before. +- Quotes reporting `0/0` defer to the mint — a zero snapshot is indistinguishable from a stale pre-payment quote, 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. From c27b2e2f61a1fd0270daf3f164446115f8bb979c Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Fri, 12 Jun 2026 17:06:25 +0100 Subject: [PATCH 06/11] docs: clarify stale-snapshot wording in 0/0 quote comments --- migration-5.0.0.md | 2 +- src/wallet/Wallet.ts | 2 +- test/wallet/wallet-mint.node.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/migration-5.0.0.md b/migration-5.0.0.md index 188abdb4e..e15398dd5 100644 --- a/migration-5.0.0.md +++ b/migration-5.0.0.md @@ -262,6 +262,6 @@ bolt11, bolt12 and onchain flows are unaffected: those responses already require Two escape hatches keep stored-quote flows working: - Quote objects without accounting fields (e.g. minimal `{ quote: '…' }` references) skip the check, as before. -- Quotes reporting `0/0` defer to the mint — a zero snapshot is indistinguishable from a stale pre-payment quote, so the create → pay externally → mint flow is unaffected. +- 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. diff --git a/src/wallet/Wallet.ts b/src/wallet/Wallet.ts index 76c07c6a3..455aa8bc3 100644 --- a/src/wallet/Wallet.ts +++ b/src/wallet/Wallet.ts @@ -1960,7 +1960,7 @@ class Wallet { } const amountPaid = Amount.from(quote.amount_paid as AmountLike); const amountIssued = Amount.from(quote.amount_issued as AmountLike); - // A 0/0 snapshot is indistinguishable from a stale pre-payment quote (create -> pay + // A 0/0 snapshot may simply have been fetched before the payment was made (create -> pay // externally -> mint with the original object); the mint is the source of truth. if (amountPaid.isZero() && amountIssued.isZero()) { return; diff --git a/test/wallet/wallet-mint.node.test.ts b/test/wallet/wallet-mint.node.test.ts index a10077825..03cf60c2b 100644 --- a/test/wallet/wallet-mint.node.test.ts +++ b/test/wallet/wallet-mint.node.test.ts @@ -1150,8 +1150,8 @@ describe('generic mint/melt methods', () => { test('prepareMint defers to the mint when the quote reports no payment activity', async () => { // create -> pay externally -> mint with the original quote object is the - // canonical bolt11 flow; a 0/0 accounting snapshot is indistinguishable - // from a stale pre-payment quote and must not fail fast. + // canonical bolt11 flow; a 0/0 accounting snapshot may simply predate the + // payment and must not fail fast. const wallet = new Wallet(mint, { unit: 'sat' }); await wallet.loadMint(); From 451d4740afc4784188b0032cedeb249e17352216 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Fri, 12 Jun 2026 17:36:42 +0100 Subject: [PATCH 07/11] feat(nut04/05): add method to quote response base structs Implements cashubtc/nuts#387: every mint and melt quote response carries a method field. The wallet always knows the method from the request endpoint, so normalization populates it when the mint omits it (no mint ships the field yet) and throws when a reported method disagrees with the endpoint. Once mints implement it, NUT-17 generic mint_quote/melt_quote notifications become routable by method. --- etc/cashu-ts.api.md | 2 ++ migration-5.0.0.md | 10 ++++-- src/mint/Mint.ts | 23 ++++++++++--- src/model/types/NUT04.ts | 4 +++ src/model/types/NUT05.ts | 4 +++ test/mint/Mint.node.test.ts | 69 +++++++++++++++++++++++++++++++++++++ 6 files changed, 105 insertions(+), 7 deletions(-) diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index 456f073db..166f4bd37 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -853,6 +853,7 @@ export type MeltQuoteBaseResponse = { quote: string; request: string; amount: Amount; + method: string; fee_reserve?: Amount; unit: string; state: MeltQuoteState; @@ -1217,6 +1218,7 @@ export type MintQuoteBaseResponse = { quote: string; request: string; unit: string; + method: string; amount_paid: Amount; amount_issued: Amount; updated_at: number | null; diff --git a/migration-5.0.0.md b/migration-5.0.0.md index e15398dd5..643ab8899 100644 --- a/migration-5.0.0.md +++ b/migration-5.0.0.md @@ -203,12 +203,13 @@ Calls that already pass a `CompleteMeltOptions` object (or omit the third argume ## Mint quote responses now carry NUT-04 accounting fields -`MintQuoteBaseResponse` (and every method-specific mint quote response) gains four required fields: +`MintQuoteBaseResponse` (and every method-specific mint quote response) gains five required fields: - `amount_paid: Amount` — total paid to the mint for this quote - `amount_issued: Amount` — total ecash issued for this quote - `updated_at: number | null` — Unix timestamp of the last quote update (`null` when the mint does not report it) - `expiry: number | null` — moved here from the method-specific response types +- `method: string` — the payment method, populated from the request endpoint when the mint omits it; a reported method that disagrees with the endpoint throws `Invalid response from mint` The difference `amount_paid − amount_issued` is the amount available to mint. The single-use `state` field is deprecated in NUT-04 in favour of the accounting fields, but cashu-ts always populates it on `MintQuoteBolt11Response` (derived from the accounting fields when the mint omits it). @@ -237,6 +238,7 @@ const quote: MintQuoteBolt11Response = { amount: Amount.from(21), state: 'UNPAID', expiry: null, + method: 'bolt11', amount_paid: Amount.from(0), amount_issued: Amount.from(0), updated_at: null, @@ -247,11 +249,13 @@ Generic mint quote responses (custom payment methods) are now base-validated lik --- -## Melt quote responses require `request` +## Melt quote responses require `request` and carry `method` `request` (the method-specific payment routing instructions) moved from the bolt11/onchain response types into `MeltQuoteBaseResponse`, and the base type gains an optional `fee_reserve`. Melt quote responses from any method — including custom ones — that lack a `request` string now throw `Invalid response from mint`. -bolt11, bolt12 and onchain flows are unaffected: those responses already required `request`. Code constructing a plain `MeltQuoteBaseResponse` must include it. +`MeltQuoteBaseResponse` also gains a required `method: string` with the same semantics as on mint quotes: populated from the request endpoint when the mint omits it, throwing on a mismatch. + +bolt11, bolt12 and onchain flows are unaffected: those responses already required `request`. Code constructing a plain `MeltQuoteBaseResponse` must include `request` and `method`. --- diff --git a/src/mint/Mint.ts b/src/mint/Mint.ts index c74e6c6dd..44bc7b6de 100644 --- a/src/mint/Mint.ts +++ b/src/mint/Mint.ts @@ -1159,7 +1159,7 @@ class Mint { ): TRes { const op = `${method} mint quote`; const data: Record = { ...response }; - this.normalizeMintBaseFields(data, op); + this.normalizeMintBaseFields(data, method, op); if (method === 'bolt11') { this.normalizeMintQuoteBolt11Fields(data); } else if (method === 'bolt12') { @@ -1174,7 +1174,8 @@ class Mint { * NUT-04 requires `amount_paid`/`amount_issued` on every mint quote response; for mints that * predate quote accounting they are derived from the legacy single-use `state` and `amount`. */ - private normalizeMintBaseFields(data: Record, op: string): void { + private normalizeMintBaseFields(data: Record, method: string, op: string): void { + this.normalizeQuoteMethod(data, method, op); if (data.amount_paid !== undefined && data.amount_issued !== undefined) { data.amount_paid = Amount.from(data.amount_paid as AmountLike); data.amount_issued = Amount.from(data.amount_issued as AmountLike); @@ -1202,6 +1203,19 @@ class Mint { } } + /** + * Mutates `data` in place, reconciling the quote's `method` with the request endpoint: absent is + * populated from the endpoint; a disagreeing value is a protocol violation. + */ + private normalizeQuoteMethod(data: Record, method: string, op: string): void { + if (data.method === undefined) { + data.method = method; + } else if (data.method !== method) { + this._logger.error('Invalid response from mint...', { data, op }); + throw new CTSError('Invalid response from mint'); + } + } + /** * Derives `[amount_paid, amount_issued]` from the legacy single-use `state` and quote `amount`, * or returns null when underivable. @@ -1274,7 +1288,7 @@ class Mint { ): TRes { const op = `${method} melt quote`; const data: Record = { ...response }; - this.normalizeMeltBaseFields(data, op); + this.normalizeMeltBaseFields(data, method, op); if (method === 'bolt11' || method === 'bolt12') { this.normalizeMeltBoltFields(data, op); } else if (method === 'onchain') { @@ -1286,7 +1300,8 @@ class Mint { /** * Mutates `data` in place, normalizing protocol-mandatory melt base fields. */ - private normalizeMeltBaseFields(data: Record, op: string): void { + private normalizeMeltBaseFields(data: Record, method: string, op: string): void { + this.normalizeQuoteMethod(data, method, op); data.amount = Amount.from(data.amount as AmountLike); data.expiry = normalizeSafeIntegerMetadata( data.expiry as number, diff --git a/src/model/types/NUT04.ts b/src/model/types/NUT04.ts index f9c03aca3..50eea4b40 100644 --- a/src/model/types/NUT04.ts +++ b/src/model/types/NUT04.ts @@ -49,6 +49,10 @@ export type MintQuoteBaseResponse = { * Unit of the melt quote. */ unit: string; + /** + * Payment method of the quote. Populated from the request endpoint when the mint omits it. + */ + method: string; /** * Total amount paid to the mint for this quote, in `unit`. Derived from the legacy `state` for * mints that do not report accounting fields. diff --git a/src/model/types/NUT05.ts b/src/model/types/NUT05.ts index 260885f73..31a26f5b1 100644 --- a/src/model/types/NUT05.ts +++ b/src/model/types/NUT05.ts @@ -45,6 +45,10 @@ export type MeltQuoteBaseResponse = { * Amount to be melted. */ amount: Amount; + /** + * Payment method of the quote. Populated from the request endpoint when the mint omits it. + */ + method: string; /** * Optional. Additional fee reserve for using the method. */ diff --git a/test/mint/Mint.node.test.ts b/test/mint/Mint.node.test.ts index 44cdd06b0..5b5c705e4 100644 --- a/test/mint/Mint.node.test.ts +++ b/test/mint/Mint.node.test.ts @@ -647,6 +647,75 @@ describe('Mint normalization', () => { }); }); + describe('method field on quote responses', () => { + const mintQuote = { + quote: 'q1', + request: 'lnbc1...', + unit: 'sat', + state: 'UNPAID', + expiry: 123, + amount: 21, + }; + const meltQuote = { + quote: 'm1', + request: 'lnbc1...', + amount: 9, + unit: 'sat', + state: 'UNPAID', + expiry: 123, + fee_reserve: 1, + }; + + it('injects method from the endpoint when the mint omits it', async () => { + const mint = new Mint(mintUrl, { customRequest: makeRequest(mintQuote) }); + + expect((await mint.checkMintQuoteBolt11('q1')).method).toBe('bolt11'); + }); + + it('injects method on custom quotes and passes a matching method through', async () => { + const custom = { + quote: 'q1', + request: 'acct:12345', + unit: 'usd', + amount_paid: 0, + amount_issued: 0, + }; + const omitted = new Mint(mintUrl, { customRequest: makeRequest(custom) }); + const matching = new Mint(mintUrl, { + customRequest: makeRequest({ ...custom, method: 'paypal' }), + }); + + expect((await omitted.checkMintQuote('paypal', 'q1')).method).toBe('paypal'); + expect((await matching.checkMintQuote('paypal', 'q1')).method).toBe('paypal'); + }); + + it('injects method on melt quotes when the mint omits it', async () => { + const mint = new Mint(mintUrl, { customRequest: makeRequest(meltQuote) }); + + expect((await mint.checkMeltQuoteBolt11('m1')).method).toBe('bolt11'); + }); + + it('throws when the reported method disagrees with the endpoint', async () => { + const logger = createLogger(); + const wrongMint = new Mint(mintUrl, { + customRequest: makeRequest({ ...mintQuote, method: 'bolt12' }), + logger, + }); + const wrongMelt = new Mint(mintUrl, { + customRequest: makeRequest({ ...meltQuote, method: 'bolt12' }), + logger, + }); + + await expect(wrongMint.checkMintQuoteBolt11('q1')).rejects.toThrow( + 'Invalid response from mint', + ); + await expect(wrongMelt.checkMeltQuoteBolt11('m1')).rejects.toThrow( + 'Invalid response from mint', + ); + expect(logger.error).toHaveBeenCalledTimes(2); + }); + }); + describe('custom payment method base normalization (NUT-04/05 common formats)', () => { it('normalizes expiry on custom mint quotes and defaults it to null', async () => { const base = { From bbd6bc4c3158bececf7312a21adce9f4abe7ec44 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Fri, 12 Jun 2026 18:13:32 +0100 Subject: [PATCH 08/11] docs: align custom payment method examples with quote base widening Custom mint example reads quote progress via the accounting fields instead of state, and both generic examples note the automatic base normalization and the Generic response type defaults. --- docs-src/usage/melt_token.md | 6 ++++++ docs-src/usage/mint_token.md | 17 +++++++---------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs-src/usage/melt_token.md b/docs-src/usage/melt_token.md index f3145659e..a1cf64496 100644 --- a/docs-src/usage/melt_token.md +++ b/docs-src/usage/melt_token.md @@ -91,6 +91,12 @@ requiring first-class library support. The mint must advertise the method at `/v1/melt/quote/{method}`. +The NUT-05 base fields (`quote`, `request`, `amount`, `unit`, `method`, `state`, `expiry`, optional +`fee_reserve`) are normalized and validated automatically for every method — `state` always uses +the standard `UNPAID`/`PENDING`/`PAID` values. The optional `normalize` callback is only needed for +method-specific fields. Without a type parameter, the generic methods return +`MeltQuoteGenericResponse`, which exposes method-specific fields as `unknown`. + ```ts import { Wallet, Amount, type MeltQuoteBaseResponse, type AmountLike } from '@cashu/cashu-ts'; diff --git a/docs-src/usage/mint_token.md b/docs-src/usage/mint_token.md index 6c0ee52dd..6391b2ac3 100644 --- a/docs-src/usage/mint_token.md +++ b/docs-src/usage/mint_token.md @@ -86,19 +86,14 @@ The generic `createMintQuote()` / `mintProofs()` methods support arbitrary payme The mint must advertise the method at `/v1/mint/quote/{method}`. +The NUT-04 base fields (`quote`, `request`, `unit`, `method`, `amount_paid`, `amount_issued`, `updated_at`, `expiry`) are normalized and validated automatically for every method — the optional `normalize` callback is only needed for method-specific fields. Without a type parameter, the generic methods return `MintQuoteGenericResponse`, which exposes method-specific fields as `unknown`. + ```ts -import { - Wallet, - Amount, - MintQuoteState, - type MintQuoteBaseResponse, - type AmountLike, -} from '@cashu/cashu-ts'; +import { Wallet, Amount, type MintQuoteBaseResponse, type AmountLike } from '@cashu/cashu-ts'; // Define your custom quote response type type BacsMintQuoteResponse = MintQuoteBaseResponse & { amount: Amount; - state: MintQuoteState; reference: string; // bank transfer reference }; @@ -132,8 +127,10 @@ const updated = await wallet.checkMintQuote('bacs', mintQ }), }); -// Mint once the bank transfer is confirmed -if (updated.state === MintQuoteState.PAID) { +// Mint once the bank transfer is confirmed. The accounting fields are the +// method-independent way to read quote progress: paid minus issued is mintable. +const available = updated.amount_paid.subtract(updated.amount_issued); +if (available.greaterThanOrEqual(5000)) { const proofs = await wallet.mintProofs('bacs', 5000, updated); } ``` From 8af05b040ce2a9544a1a2357710b94df5e7b7fcc Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Thu, 18 Jun 2026 21:00:33 +0100 Subject: [PATCH 09/11] feat(nut17)!: method-neutral quote subscriptions Subscribe to mint/melt quote updates independent of payment method: prefer the generic mint_quote/melt_quote kinds when the mint advertises them, else fan out across the per-method kinds it advertises (bolt11, bolt12, custom) and merge them. Fanned-out kinds isolate errors, so one kind's subscribe failure no longer tears down the siblings that work. Quote event helpers are now generic over the response type (default generic), and "paid" is accounting-based (amount_paid > amount_issued) with a legacy state fallback. Builds on #698. --- etc/cashu-ts.api.md | 24 +-- src/model/types/jsonrpc.ts | 16 +- src/wallet/WalletEvents.ts | 271 +++++++++++++++------------ test/consts.ts | 2 +- test/transport/WSConnection.test.ts | 74 ++++---- test/wallet/WalletEvents.test.ts | 219 +++++++++++++++++++--- test/wallet/_setup.ts | 2 +- test/wallet/wallet-auth.node.test.ts | 2 +- test/wallet/wallet-init.node.test.ts | 50 +++-- 9 files changed, 436 insertions(+), 224 deletions(-) diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index 166f4bd37..464f66c34 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -1793,8 +1793,8 @@ export type RestoreConfig = { keysetId?: string; }; -// @public (undocumented) -export type RpcSubKinds = 'bolt11_mint_quote' | 'bolt11_melt_quote' | 'proof_state'; +// @public +export type RpcSubKinds = 'mint_quote' | 'melt_quote' | 'proof_state' | 'bolt11_mint_quote' | 'bolt11_melt_quote' | 'bolt12_mint_quote' | 'bolt12_melt_quote'; // @public export const schnorrSignDigest: (digest: DigestInput, privateKey: PrivKey) => string; @@ -2261,26 +2261,26 @@ export class WalletEvents { add: (c: CancellerLike) => CancellerLike; cancelled: boolean; }; - meltQuotePaid(id: string, cb: (p: MeltQuoteBolt11Response) => void, err: (e: Error) => void, opts?: SubscribeOpts): Promise; - meltQuoteUpdates(ids: string[], cb: (p: MeltQuoteBolt11Response) => void, err: (e: Error) => void, opts?: SubscribeOpts): Promise; - mintQuotePaid(id: string, cb: (p: MintQuoteBolt11Response) => void, err: (e: Error) => void, opts?: SubscribeOpts): Promise; - mintQuoteUpdates(ids: string[], cb: (p: MintQuoteBolt11Response) => void, err: (e: Error) => void, opts?: SubscribeOpts): Promise; - onceAnyMintPaid(ids: string[], opts?: { + meltQuotePaid(id: string, cb: (p: T) => void, err: (e: Error) => void, opts?: SubscribeOpts): Promise; + meltQuoteUpdates(ids: string[], cb: (p: T) => void, err: (e: Error) => void, opts?: SubscribeOpts): Promise; + mintQuotePaid(id: string, cb: (p: T) => void, err: (e: Error) => void, opts?: SubscribeOpts): Promise; + mintQuoteUpdates(ids: string[], cb: (p: T) => void, err: (e: Error) => void, opts?: SubscribeOpts): Promise; + onceAnyMintPaid(ids: string[], opts?: { signal?: AbortSignal; timeoutMs?: number; failOnError?: boolean; }): Promise<{ id: string; - quote: MintQuoteBolt11Response; + quote: T; }>; - onceMeltPaid(id: string, opts?: { + onceMeltPaid(id: string, opts?: { signal?: AbortSignal; timeoutMs?: number; - }): Promise; - onceMintPaid(id: string, opts?: { + }): Promise; + onceMintPaid(id: string, opts?: { signal?: AbortSignal; timeoutMs?: number; - }): Promise; + }): Promise; proofStatesStream

(proofs: P[], opts?: { signal?: AbortSignal; maxBuffer?: number; diff --git a/src/model/types/jsonrpc.ts b/src/model/types/jsonrpc.ts index f1817c437..0f82584fd 100644 --- a/src/model/types/jsonrpc.ts +++ b/src/model/types/jsonrpc.ts @@ -1,4 +1,18 @@ -export type RpcSubKinds = 'bolt11_mint_quote' | 'bolt11_melt_quote' | 'proof_state'; +/** + * NUT-17 subscription kinds. + * + * `mint_quote` / `melt_quote` are the method-agnostic kinds; the per-method forms are what mints + * advertise today. Custom payment methods may use further `_mint_quote` / + * `_melt_quote` kinds that the wallet forwards but does not enumerate here. + */ +export type RpcSubKinds = + | 'mint_quote' + | 'melt_quote' + | 'proof_state' + | 'bolt11_mint_quote' + | 'bolt11_melt_quote' + | 'bolt12_mint_quote' + | 'bolt12_melt_quote'; export type RpcSubId = string | number | null; export type JsonRpcParams = { diff --git a/src/wallet/WalletEvents.ts b/src/wallet/WalletEvents.ts index e7e41b962..62bdc3635 100644 --- a/src/wallet/WalletEvents.ts +++ b/src/wallet/WalletEvents.ts @@ -1,13 +1,15 @@ import { hashToCurve, hashToCurveBls, isBlsKeyset } from '../crypto'; import { safeCallback } from '../logger'; +import { Amount, type AmountLike } from '../model/Amount'; import { CTSError } from '../model/Errors'; import { MintQuoteState, MeltQuoteState } from '../model/types'; import type { Proof, ProofLike, ProofState, - MeltQuoteBolt11Response, - MintQuoteBolt11Response, + MintQuoteGenericResponse, + MeltQuoteGenericResponse, + RpcSubKinds, } from '../model/types'; import { type OperationCounters } from './CounterSource'; @@ -62,12 +64,59 @@ function cancelSafely(c: CancellerLike | null | undefined): void { }); } +// Mint quote has funds available to mint: `amount_paid` > `amount_issued`, falling back to legacy +// `state` for pre-accounting mints. Payloads are raw JSON (not `Amount`), hence `Amount.from` and +// `!= null` (a real 0 is not absent). +function isMintQuotePaid(p: MintQuoteGenericResponse): boolean { + const paid = (p as { amount_paid?: AmountLike }).amount_paid; + const issued = (p as { amount_issued?: AmountLike }).amount_issued; + if (paid != null && issued != null) { + return Amount.from(paid).greaterThan(Amount.from(issued)); + } + return (p as { state?: MintQuoteState }).state === MintQuoteState.PAID; +} + +// Melt quote payment completed. `state` is a base field on every melt quote, so no accounting fallback. +function isMeltQuotePaid(p: MeltQuoteGenericResponse): boolean { + return p.state === MeltQuoteState.PAID; +} + export class WalletEvents { constructor(private wallet: Wallet) {} // Callbacks registered for Counters Reserved events private countersReservedHandlers = new Set<(payload: OperationCounters) => void>(); + // NUT-17 kind(s) to open for a quote subscription, independent of payment method: the generic + // kind if the mint advertises it, else every per-method kind it advertises for this unit (fanned + // out and merged). Falls back to the generic kind when mint info is unavailable. + private quoteSubKinds(type: 'mint' | 'melt'): RpcSubKinds[] { + const generic: RpcSubKinds = type === 'mint' ? 'mint_quote' : 'melt_quote'; + const suffix = type === 'mint' ? '_mint_quote' : '_melt_quote'; + + try { + const { supported, params } = this.wallet.getMintInfo().isSupported(17); + if (!supported) return [generic]; + + const commands = Array.from( + new Set( + params?.filter((p) => p.unit === this.wallet.unit).flatMap((p) => p.commands) ?? [], + ), + ); + + if (commands.includes(generic)) return [generic]; + + // `endsWith(suffix)` excludes the bare generic kind (no leading method prefix). The cast + // forwards any advertised per-method kind, including custom ones not enumerated by RpcSubKinds. + const perMethod = commands.filter((c) => c.endsWith(suffix)) as RpcSubKinds[]; + if (perMethod.length > 0) return perMethod; + } catch { + // WalletEvents is also used in tests with a minimal mocked Wallet surface. + } + + return [generic]; + } + // Binds an abort signal to each subscription canceller private withAbort( signal: AbortSignal | undefined, @@ -191,17 +240,12 @@ export class WalletEvents { } } - /** - * Register a callback to be called whenever a mint quote's state changes. - * - * @param quoteIds List of mint quote IDs that should be subscribed to. - * @param callback Callback function that will be called whenever a mint quote state changes. - * @param errorCallback - * @returns - */ - async mintQuoteUpdates( + // Core quote-subscription primitive: opens one sub per kind from `quoteSubKinds` (usually one), + // routes each to `cb`, and returns a canceller that tears all of them down. + private async quoteUpdates( + type: 'mint' | 'melt', ids: string[], - cb: (p: MintQuoteBolt11Response) => void, + cb: (p: T) => void, err: (e: Error) => void, opts?: SubscribeOpts, ): Promise { @@ -210,29 +254,65 @@ export class WalletEvents { if (!ws) throw new CTSError('Failed to establish WebSocket connection.'); const uniq = Array.from(new Set(ids)); - const subId = ws.createSubscription({ kind: 'bolt11_mint_quote', filters: uniq }, cb, err); - const cancel = () => ws.cancelSubscription(subId, cb); + const kinds = this.quoteSubKinds(type); + // Fanned-out kinds share one stream but isolate errors. A quote id belongs to a single method, + // so only one kind ever delivers; don't let another kind's error (e.g. a mint that advertises a + // kind but rejects the subscribe) tear down the sibling that works. Surface `err` only once + // every kind has failed. + let alive = kinds.length; + const onErr = (e: Error) => { + if (--alive <= 0) err(e); + }; + const subIds = kinds.map((kind) => + ws.createSubscription({ kind, filters: uniq }, cb, onErr), + ); + const cancel = () => { + for (const subId of subIds) ws.cancelSubscription(subId, cb); + }; return this.withAbort(opts?.signal, cancel); } /** - * Register a callback to be called when a single mint quote gets paid. + * Subscribe to mint quote state changes for any payment method. * - * @param quoteId Mint quote id that should be subscribed to. - * @param callback Callback function that will be called when this mint quote gets paid. - * @param errorCallback - * @returns + * @remarks + * Payload defaults to {@link MintQuoteGenericResponse}; narrow via `T` (e.g. + * `MintQuoteBolt12Response`) when you know the quotes' method. + * @param ids Mint quote ids to subscribe to. + * @param cb Called whenever a subscribed mint quote changes. + * @param err Called if the subscription errors. + * @returns A canceller that unsubscribes. */ - async mintQuotePaid( + async mintQuoteUpdates( + ids: string[], + cb: (p: T) => void, + err: (e: Error) => void, + opts?: SubscribeOpts, + ): Promise { + return this.quoteUpdates('mint', ids, cb, err, opts); + } + + /** + * Subscribe to a single mint quote and fire once it is mintable. + * + * @remarks + * "Mintable" means `amount_paid` > `amount_issued`, falling back to legacy `state` PAID for + * pre-accounting mints. + * @param id Mint quote id to subscribe to. + * @param cb Called once the quote becomes mintable. + * @param err Called if the subscription errors. + * @returns A canceller that unsubscribes. + */ + async mintQuotePaid( id: string, - cb: (p: MintQuoteBolt11Response) => void, + cb: (p: T) => void, err: (e: Error) => void, opts?: SubscribeOpts, ): Promise { - return this.mintQuoteUpdates( + return this.mintQuoteUpdates( [id], (p) => { - if (p.state === MintQuoteState.PAID) cb(p); + if (isMintQuotePaid(p)) cb(p); }, err, opts, @@ -240,47 +320,43 @@ export class WalletEvents { } /** - * Register a callback to be called whenever a melt quote’s state changes. + * Subscribe to melt quote state changes for any payment method. * - * @param quoteId Melt quote id that should be subscribed to. - * @param callback Callback function that will be called when this melt quote gets paid. - * @param errorCallback - * @returns + * @remarks + * Payload defaults to {@link MeltQuoteGenericResponse}; narrow via `T` when you know the quotes' + * method. + * @param ids Melt quote ids to subscribe to. + * @param cb Called whenever a subscribed melt quote changes. + * @param err Called if the subscription errors. + * @returns A canceller that unsubscribes. */ - async meltQuoteUpdates( + async meltQuoteUpdates( ids: string[], - cb: (p: MeltQuoteBolt11Response) => void, + cb: (p: T) => void, err: (e: Error) => void, opts?: SubscribeOpts, ): Promise { - await this.wallet.mint.connectWebSocket(); - const ws = this.wallet.mint.webSocketConnection; - if (!ws) throw new CTSError('Failed to establish WebSocket connection.'); - - const uniq = Array.from(new Set(ids)); - const subId = ws.createSubscription({ kind: 'bolt11_melt_quote', filters: uniq }, cb, err); - const cancel = () => ws.cancelSubscription(subId, cb); - return this.withAbort(opts?.signal, cancel); + return this.quoteUpdates('melt', ids, cb, err, opts); } /** - * Register a callback to be called when a single melt quote gets paid. + * Subscribe to a single melt quote and fire once it reaches PAID. * - * @param quoteIds List of melt quote IDs that should be subscribed to. - * @param callback Callback function that will be called whenever a melt quote state changes. - * @param errorCallback - * @returns + * @param id Melt quote id to subscribe to. + * @param cb Called once the quote is PAID. + * @param err Called if the subscription errors. + * @returns A canceller that unsubscribes. */ - async meltQuotePaid( + async meltQuotePaid( id: string, - cb: (p: MeltQuoteBolt11Response) => void, + cb: (p: T) => void, err: (e: Error) => void, opts?: SubscribeOpts, ): Promise { - return this.meltQuoteUpdates( + return this.meltQuoteUpdates( [id], (p) => { - if (p.state === MeltQuoteState.PAID) cb(p); + if (isMeltQuotePaid(p)) cb(p); }, err, opts, @@ -338,46 +414,23 @@ export class WalletEvents { } /** - * Resolve once a mint quote transitions to PAID, with automatic unsubscription, optional abort - * signal, and optional timeout. - * - * The underlying subscription is always cancelled after resolution or rejection, including on - * timeout or abort. - * - * @example - * - * ```ts - * const ac = new AbortController(); - * // Cancel if the user navigates away - * window.addEventListener('beforeunload', () => ac.abort(), { once: true }); - * - * try { - * const paid = await wallet.on.onceMintPaid(quoteId, { - * signal: ac.signal, - * timeoutMs: 60_000, - * }); - * console.log('Mint paid, amount', paid.amount); - * } catch (e) { - * if ((e as Error).name === 'AbortError') { - * console.log('User aborted'); - * } else { - * console.error('Mint not paid', e); - * } - * } - * ``` + * Resolve once a mint quote becomes mintable, then auto-unsubscribe. * + * @remarks + * The subscription is always cancelled after resolution, rejection, timeout, or abort. "Mintable" + * is defined as in `mintQuotePaid`. * @param id Mint quote id to watch. * @param opts Optional controls. * @param opts.signal AbortSignal to cancel the wait early. * @param opts.timeoutMs Milliseconds to wait before rejecting with a timeout error. - * @returns A promise that resolves with the latest `MintQuoteBolt11Response` once PAID. + * @returns A promise that resolves with the mint quote once it is mintable. */ - onceMintPaid( + onceMintPaid( id: string, opts?: { signal?: AbortSignal; timeoutMs?: number }, - ): Promise { - return this.waitUntilPaid( - this.mintQuotePaid.bind(this), + ): Promise { + return this.waitUntilPaid( + (qid, cb, err, o) => this.mintQuotePaid(qid, cb, err, o), id, opts, 'Timeout waiting for mint paid', @@ -385,36 +438,23 @@ export class WalletEvents { } /** - * Resolve when ANY of several mint quotes is PAID, cancelling the rest. - * - * Subscribes to all distinct ids, resolves with `{ id, quote }` for the first PAID, and cancels - * all remaining subscriptions. - * - * Errors from individual subscriptions are ignored by default so a single noisy stream does not - * abort the whole race. Set `failOnError: true` to reject on the first error instead. If all - * subscriptions error and none paid, the promise rejects with the last seen error. - * - * @example - * - * ```ts - * // Race multiple quotes obtained from splitting a large top up - * const { id, quote } = await wallet.on.onceAnyMintPaid(batchQuoteIds, { - * timeoutMs: 120_000, - * }); - * console.log('First top up paid', id, quote.preimage?.length); - * ``` + * Resolve when ANY of several mint quotes becomes mintable, cancelling the rest. * + * @remarks + * Resolves with `{ id, quote }` for the first mintable quote. Per-subscription errors are ignored + * by default (set `failOnError` to reject on the first); if all error and none resolve, rejects + * with the last error. * @param ids Array of mint quote ids (duplicates are ignored). * @param opts Optional controls. * @param opts.signal AbortSignal to cancel the wait early. * @param opts.timeoutMs Milliseconds to wait before rejecting with a timeout error. * @param opts.failOnError When true, reject on first error. Default false. - * @returns A promise resolving to the id that won and its `MintQuoteBolt11Response`. + * @returns A promise resolving to the id that won and its mint quote. */ - onceAnyMintPaid( + onceAnyMintPaid( ids: string[], opts?: { signal?: AbortSignal; timeoutMs?: number; failOnError?: boolean }, - ): Promise<{ id: string; quote: MintQuoteBolt11Response }> { + ): Promise<{ id: string; quote: T }> { return new Promise((resolve, reject) => { const unique = Array.from(new Set(ids)); const cancels: Map = new Map(); @@ -455,7 +495,7 @@ export class WalletEvents { if (unique.length === 0) return cleanup(new CTSError('No quote ids provided')); for (const quoteId of unique) { - const c = this.mintQuotePaid( + const c = this.mintQuotePaid( quoteId, (p) => { cleanup(); @@ -508,34 +548,23 @@ export class WalletEvents { } /** - * Resolve once a melt quote transitions to PAID, with automatic unsubscription, optional abort - * signal, and optional timeout. - * - * Mirrors onceMintPaid, but for melts. - * - * @example - * - * ```ts - * try { - * const paid = await wallet.on.onceMeltPaid(meltId, { timeoutMs: 45_000 }); - * console.log('Invoice paid by mint, paid msat', paid.paid ?? 0); - * } catch (e) { - * console.error('Payment did not complete in time', e); - * } - * ``` + * Resolve once a melt quote reaches PAID, then auto-unsubscribe. * + * @remarks + * Mirrors `onceMintPaid`, but for melts. The subscription is always cancelled after resolution, + * rejection, timeout, or abort. * @param id Melt quote id to watch. * @param opts Optional controls. * @param opts.signal AbortSignal to cancel the wait early. * @param opts.timeoutMs Milliseconds to wait before rejecting with a timeout error. - * @returns A promise that resolves with the `MeltQuoteBolt11Response` once PAID. + * @returns A promise that resolves with the melt quote once PAID. */ - onceMeltPaid( + onceMeltPaid( id: string, opts?: { signal?: AbortSignal; timeoutMs?: number }, - ): Promise { - return this.waitUntilPaid( - this.meltQuotePaid.bind(this), + ): Promise { + return this.waitUntilPaid( + (qid, cb, err, o) => this.meltQuotePaid(qid, cb, err, o), id, opts, 'Timeout waiting for melt paid', diff --git a/test/consts.ts b/test/consts.ts index 8e42ac95f..d86ee1799 100644 --- a/test/consts.ts +++ b/test/consts.ts @@ -1,7 +1,7 @@ import { KeyChain, KeyChainCache, type MintKeys, type MintKeyset } from '../src'; export const MINTINFORESP = JSON.parse( - '{"name":"Testnut mint","pubkey":"0296d0aa13b6a31cf0cd974249f28c7b7176d7274712c95a41c7d8066d3f29d679","version":"Nutshell/0.16.3","description":"Mint for testing Cashu wallets","description_long":"This mint usually runs the latest main branch of the nutshell repository. It uses a FakeWallet, all your Lightning invoices will always be marked paid so that you can test minting and melting ecash via Lightning.","contact":[{"method":"email","info":"contact@me.com"},{"method":"twitter","info":"@me"},{"method":"nostr","info":"npub1337"}],"motd":"This is a message of the day field. You should display this field to your users if the content changes!","icon_url":"https://image.nostr.build/46ee47763c345d2cfa3317f042d332003f498ee281fb42808d47a7d3b9585911.png","time":1731684933,"nuts":{"4":{"methods":[{"method":"bolt11","unit":"sat","options":{"description":true}},{"method":"bolt11","unit":"usd","options":{"description":true}},{"method":"bolt11","unit":"eur","options":{"description":true}},{"method":"bolt12","unit":"sat","options":{"description":true}},{"method":"bolt12","unit":"usd","options":{"description":true}},{"method":"bolt12","unit":"eur","options":{"description":true}}],"disabled":false},"5":{"methods":[{"method":"bolt11","unit":"sat"},{"method":"bolt11","unit":"usd"},{"method":"bolt11","unit":"eur"},{"method":"bolt12","unit":"sat"},{"method":"bolt12","unit":"usd"},{"method":"bolt12","unit":"eur"}],"disabled":false},"7":{"supported":true},"8":{"supported":true},"9":{"supported":true},"10":{"supported":true},"11":{"supported":true},"12":{"supported":true},"14":{"supported":true},"17":{"supported":[{"method":"bolt11","unit":"sat","commands":["bolt11_melt_quote","proof_state","bolt11_mint_quote"]},{"method":"bolt11","unit":"usd","commands":["bolt11_melt_quote","proof_state","bolt11_mint_quote"]},{"method":"bolt11","unit":"eur","commands":["bolt11_melt_quote","proof_state","bolt11_mint_quote"]},{"method":"bolt12","unit":"sat","commands":["bolt12_melt_quote","proof_state","bolt12_mint_quote"]},{"method":"bolt12","unit":"usd","commands":["bolt12_melt_quote","proof_state","bolt12_mint_quote"]},{"method":"bolt12","unit":"eur","commands":["bolt12_melt_quote","proof_state","bolt12_mint_quote"]}]}}}', + '{"name":"Testnut mint","pubkey":"0296d0aa13b6a31cf0cd974249f28c7b7176d7274712c95a41c7d8066d3f29d679","version":"Nutshell/0.16.3","description":"Mint for testing Cashu wallets","description_long":"This mint usually runs the latest main branch of the nutshell repository. It uses a FakeWallet, all your Lightning invoices will always be marked paid so that you can test minting and melting ecash via Lightning.","contact":[{"method":"email","info":"contact@me.com"},{"method":"twitter","info":"@me"},{"method":"nostr","info":"npub1337"}],"motd":"This is a message of the day field. You should display this field to your users if the content changes!","icon_url":"https://image.nostr.build/46ee47763c345d2cfa3317f042d332003f498ee281fb42808d47a7d3b9585911.png","time":1731684933,"nuts":{"4":{"methods":[{"method":"bolt11","unit":"sat","options":{"description":true}},{"method":"bolt11","unit":"usd","options":{"description":true}},{"method":"bolt11","unit":"eur","options":{"description":true}},{"method":"bolt12","unit":"sat","options":{"description":true}},{"method":"bolt12","unit":"usd","options":{"description":true}},{"method":"bolt12","unit":"eur","options":{"description":true}}],"disabled":false},"5":{"methods":[{"method":"bolt11","unit":"sat"},{"method":"bolt11","unit":"usd"},{"method":"bolt11","unit":"eur"},{"method":"bolt12","unit":"sat"},{"method":"bolt12","unit":"usd"},{"method":"bolt12","unit":"eur"}],"disabled":false},"7":{"supported":true},"8":{"supported":true},"9":{"supported":true},"10":{"supported":true},"11":{"supported":true},"12":{"supported":true},"14":{"supported":true},"17":{"supported":[{"method":"bolt11","unit":"sat","commands":["melt_quote","proof_state","mint_quote"]},{"method":"bolt11","unit":"usd","commands":["melt_quote","proof_state","mint_quote"]},{"method":"bolt11","unit":"eur","commands":["melt_quote","proof_state","mint_quote"]},{"method":"bolt12","unit":"sat","commands":["melt_quote","proof_state","mint_quote"]},{"method":"bolt12","unit":"usd","commands":["melt_quote","proof_state","mint_quote"]},{"method":"bolt12","unit":"eur","commands":["melt_quote","proof_state","mint_quote"]}]}}}', ); /** diff --git a/test/transport/WSConnection.test.ts b/test/transport/WSConnection.test.ts index ef78af16a..a8576e3d6 100644 --- a/test/transport/WSConnection.test.ts +++ b/test/transport/WSConnection.test.ts @@ -48,6 +48,26 @@ describe('testing WSConnection', () => { expect(connectionSpy).toHaveBeenCalled(); }); test('requesting subscription', async () => { + const message = (await new Promise(async (res) => { + server.on('connection', (socket) => { + socket.on('message', (m) => { + res(m.toString()); + }); + }); + const conn = new WSConnection(fakeUrl); + await conn.connect(); + + const callback = vi.fn(); + const errorCallback = vi.fn(); + conn.createSubscription({ kind: 'mint_quote', filters: ['12345'] }, callback, errorCallback); + })) as string; + expect(JSON.parse(message)).toMatchObject({ + jsonrpc: '2.0', + method: 'subscribe', + params: { kind: 'mint_quote', filters: ['12345'] }, + }); + }); + test('keeps deprecated bolt11 subscription kinds working', async () => { const message = (await new Promise(async (res) => { server.on('connection', (socket) => { socket.on('message', (m) => { @@ -76,11 +96,7 @@ describe('testing WSConnection', () => { const callback = vi.fn(); const errorCallback = vi.fn(); expect(() => { - conn.createSubscription( - { kind: 'bolt11_mint_quote', filters: ['123'] }, - callback, - errorCallback, - ); + conn.createSubscription({ kind: 'mint_quote', filters: ['123'] }, callback, errorCallback); }).toThrow('Socket is not open'); expect(errorCallback).not.toHaveBeenCalled(); // No soft callback invocation }); @@ -98,7 +114,7 @@ describe('testing WSConnection', () => { const callback = vi.fn(); const errorCallback = vi.fn(); subId = conn.createSubscription( - { kind: 'bolt11_mint_quote', filters: ['123'] }, + { kind: 'mint_quote', filters: ['123'] }, callback, errorCallback, ); @@ -149,11 +165,7 @@ describe('testing WSConnection', () => { res(p); }); const errorCallback = vi.fn(); - conn.createSubscription( - { kind: 'bolt11_mint_quote', filters: ['123'] }, - callback, - errorCallback, - ); + conn.createSubscription({ kind: 'mint_quote', filters: ['123'] }, callback, errorCallback); }); expect(payload).toMatchObject({ quote: '123', request: '456', paid: true, expiry: 123 }); }); @@ -168,7 +180,7 @@ describe('WSConnection – socket-not-open paths', () => { test('sendRequest subscribe throws when socket not open', () => { const conn = new WSConnection(fakeUrl); expect(() => - conn.sendRequest('subscribe', { subId: 'test', kind: 'bolt11_mint_quote', filters: [] }), + conn.sendRequest('subscribe', { subId: 'test', kind: 'mint_quote', filters: [] }), ).toThrow('Socket not open'); }); }); @@ -180,7 +192,7 @@ describe('WSConnection – close and lifecycle', () => { conn.setLogger(logger); expect(() => - conn.sendRequest('subscribe', { subId: 'test', kind: 'bolt11_mint_quote', filters: [] }), + conn.sendRequest('subscribe', { subId: 'test', kind: 'mint_quote', filters: [] }), ).toThrow('Socket not open'); expect(logger.error).toHaveBeenCalledWith('Attempted sendRequest, but socket was not open'); }); @@ -364,7 +376,7 @@ describe('WSConnection – close and lifecycle', () => { }); const errorCb = vi.fn(); - conn.createSubscription({ kind: 'bolt11_mint_quote', filters: [] }, vi.fn(), errorCb); + conn.createSubscription({ kind: 'mint_quote', filters: [] }, vi.fn(), errorCb); await new Promise((res) => { conn.onClose(() => setTimeout(res, 0)); @@ -391,7 +403,7 @@ describe('WSConnection – close and lifecycle', () => { }); const errorCb = vi.fn(); - conn.createSubscription({ kind: 'bolt11_mint_quote', filters: [] }, vi.fn(), errorCb); + conn.createSubscription({ kind: 'mint_quote', filters: [] }, vi.fn(), errorCb); await new Promise((res) => { conn.onClose(() => setTimeout(res, 0)); @@ -453,7 +465,7 @@ describe('WSConnection – message handling', () => { const errorCb = vi.fn(); await new Promise((res) => { - conn.createSubscription({ kind: 'bolt11_mint_quote', filters: [] }, vi.fn(), (e) => { + conn.createSubscription({ kind: 'mint_quote', filters: [] }, vi.fn(), (e) => { errorCb(e); res(); }); @@ -495,7 +507,7 @@ describe('WSConnection – message handling', () => { const callback = vi.fn(); await new Promise((res) => { - conn.createSubscription({ kind: 'bolt11_mint_quote', filters: [] }, callback, vi.fn()); + conn.createSubscription({ kind: 'mint_quote', filters: [] }, callback, vi.fn()); setTimeout(res, 150); }); @@ -548,7 +560,7 @@ describe('WSConnection – message handling', () => { const callback = vi.fn(); await new Promise((res) => { - conn.createSubscription({ kind: 'bolt11_mint_quote', filters: [] }, callback, vi.fn()); + conn.createSubscription({ kind: 'mint_quote', filters: [] }, callback, vi.fn()); setTimeout(res, 150); }); @@ -590,7 +602,7 @@ describe('WSConnection – message handling', () => { await new Promise((res) => { conn.createSubscription( - { kind: 'bolt11_mint_quote', filters: [] }, + { kind: 'mint_quote', filters: [] }, () => { throw new Error('listener boom'); }, @@ -688,8 +700,8 @@ describe('WSConnection – listener management', () => { }); const errorCb2 = vi.fn(); - conn.createSubscription({ kind: 'bolt11_mint_quote', filters: [] }, vi.fn(), errorCb1); - conn.createSubscription({ kind: 'bolt11_mint_quote', filters: [] }, vi.fn(), errorCb2); + conn.createSubscription({ kind: 'mint_quote', filters: [] }, vi.fn(), errorCb1); + conn.createSubscription({ kind: 'mint_quote', filters: [] }, vi.fn(), errorCb2); await new Promise((res) => { conn.onClose(() => setTimeout(res, 0)); @@ -730,11 +742,7 @@ describe('WSConnection – listener management', () => { await conn.connect(); const callback = vi.fn(); - const subId = conn.createSubscription( - { kind: 'bolt11_mint_quote', filters: [] }, - callback, - vi.fn(), - ); + const subId = conn.createSubscription({ kind: 'mint_quote', filters: [] }, callback, vi.fn()); await waitForSubscription(conn, subId); await new Promise((res) => { @@ -780,11 +788,7 @@ describe('WSConnection – listener management', () => { await conn.connect(); const callback = vi.fn(); - const subId = conn.createSubscription( - { kind: 'bolt11_mint_quote', filters: [] }, - callback, - vi.fn(), - ); + const subId = conn.createSubscription({ kind: 'mint_quote', filters: [] }, callback, vi.fn()); await waitForSubscription(conn, subId); const errorCallback = vi.fn(); @@ -836,11 +840,7 @@ describe('WSConnection – listener management', () => { await conn.connect(); const callback = vi.fn(); - const subId = conn.createSubscription( - { kind: 'bolt11_mint_quote', filters: [] }, - callback, - vi.fn(), - ); + const subId = conn.createSubscription({ kind: 'mint_quote', filters: [] }, callback, vi.fn()); await waitForSubscription(conn, subId); await new Promise((res) => { @@ -888,7 +888,7 @@ describe('WSConnection – listener management', () => { await conn.connect(); expect(() => - conn.createSubscription({ kind: 'bolt11_mint_quote', filters: [] }, vi.fn(), vi.fn()), + conn.createSubscription({ kind: 'mint_quote', filters: [] }, vi.fn(), vi.fn()), ).toThrow('send boom'); expect( Object.keys((conn as unknown as { rpcListeners: Record }).rpcListeners), diff --git a/test/wallet/WalletEvents.test.ts b/test/wallet/WalletEvents.test.ts index 36085c04f..d10343952 100644 --- a/test/wallet/WalletEvents.test.ts +++ b/test/wallet/WalletEvents.test.ts @@ -34,10 +34,7 @@ class MockWS { /** * Deliver an update payload to matching subscribers for a kind. */ - emit( - kind: 'bolt11_mint_quote' | 'bolt11_melt_quote', - payload: { quote: string; [k: string]: any }, - ) { + emit(kind: string, payload: { quote: string; [k: string]: any }) { for (const { kind: k, filters, cb } of this.subs.values()) { if (k !== kind) continue; if (filters.includes(payload.quote)) cb(payload); @@ -114,7 +111,24 @@ class MockMint { * Only what WalletEvents touches. */ class MockWallet { + public unit = 'sat'; public mint = new MockMint(); + + constructor( + private commands: string[] | undefined = ['mint_quote', 'melt_quote', 'proof_state'], + private supportsNut17 = true, + ) {} + + getMintInfo() { + return { + isSupported: () => ({ + supported: this.supportsNut17, + params: this.commands + ? [{ method: 'bolt11', unit: this.unit, commands: this.commands }] + : undefined, + }), + }; + } } describe('WalletEvents', () => { @@ -140,7 +154,7 @@ describe('WalletEvents', () => { const canceller = await events.mintQuoteUpdates(['a', 'b'], cb, err); const ws = mock.mint.webSocketConnection!; - ws.emit('bolt11_mint_quote', { quote: 'a', state: 'PAID' }); + ws.emit('mint_quote', { quote: 'a', state: 'PAID' }); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ quote: 'a' })); expect(typeof canceller).toBe('function'); @@ -152,10 +166,10 @@ describe('WalletEvents', () => { await events.mintQuotePaid('x', cb, err); const ws = mock.mint.webSocketConnection!; - ws.emit('bolt11_mint_quote', { quote: 'x', state: 'UNPAID' }); + ws.emit('mint_quote', { quote: 'x', state: 'UNPAID' }); expect(cb).not.toHaveBeenCalled(); - ws.emit('bolt11_mint_quote', { quote: 'x', state: 'PAID' }); + ws.emit('mint_quote', { quote: 'x', state: 'PAID' }); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ quote: 'x' })); }); @@ -165,7 +179,7 @@ describe('WalletEvents', () => { await events.meltQuoteUpdates(['m1'], cb, err); const ws = mock.mint.webSocketConnection!; - ws.emit('bolt11_melt_quote', { quote: 'm1', state: 'PAID' }); + ws.emit('melt_quote', { quote: 'm1', state: 'PAID' }); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ quote: 'm1' })); }); @@ -175,13 +189,172 @@ describe('WalletEvents', () => { await events.meltQuotePaid('m2', cb, err); const ws = mock.mint.webSocketConnection!; - ws.emit('bolt11_melt_quote', { quote: 'm2', state: 'UNPAID' }); + ws.emit('melt_quote', { quote: 'm2', state: 'UNPAID' }); expect(cb).not.toHaveBeenCalled(); - ws.emit('bolt11_melt_quote', { quote: 'm2', state: 'PAID' }); + ws.emit('melt_quote', { quote: 'm2', state: 'PAID' }); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ quote: 'm2' })); }); + it('falls back to deprecated bolt11 quote kinds when mint info advertises them', async () => { + mock = new MockWallet(['bolt11_mint_quote', 'bolt11_melt_quote', 'proof_state']); + // @ts-expect-error only the mocked subset is injected + events = new WalletEvents(mock); + + await events.mintQuoteUpdates(['a'], vi.fn(), vi.fn()); + await events.meltQuoteUpdates(['m1'], vi.fn(), vi.fn()); + + const ws = mock.mint.webSocketConnection!; + expect(ws.count('bolt11_mint_quote')).toBe(1); + expect(ws.count('bolt11_melt_quote')).toBe(1); + }); + + it('uses generic quote kinds when no quote command matches the wallet unit', async () => { + mock = new MockWallet(['proof_state']); + // @ts-expect-error only the mocked subset is injected + events = new WalletEvents(mock); + + await events.mintQuoteUpdates(['a'], vi.fn(), vi.fn()); + + expect(mock.mint.webSocketConnection!.count('mint_quote')).toBe(1); + }); + + it('uses generic quote kinds when NUT-17 params are absent or unsupported', async () => { + // null (not undefined) is required to skip MockWallet's default-param and make `params` undefined + for (const fallbackMock of [new MockWallet(null as any), new MockWallet([], false)]) { + // @ts-expect-error only the mocked subset is injected + const fallbackEvents = new WalletEvents(fallbackMock); + await fallbackEvents.mintQuoteUpdates(['a'], vi.fn(), vi.fn()); + expect(fallbackMock.mint.webSocketConnection!.count('mint_quote')).toBe(1); + } + }); + + it('fans out across every advertised per-method quote kind', async () => { + mock = new MockWallet(['bolt11_mint_quote', 'bolt12_mint_quote', 'proof_state']); + // @ts-expect-error only the mocked subset is injected + events = new WalletEvents(mock); + + const cb = vi.fn(); + await events.mintQuoteUpdates(['a'], cb, vi.fn()); + + const ws = mock.mint.webSocketConnection!; + expect(ws.count('bolt11_mint_quote')).toBe(1); + expect(ws.count('bolt12_mint_quote')).toBe(1); + + // An update on either kind reaches the single merged callback. + ws.emit('bolt12_mint_quote', { quote: 'a', amount_paid: 5, amount_issued: 0 }); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ quote: 'a' })); + }); + + it('subscribes to only the generic kind when both generic and per-method kinds are advertised', async () => { + mock = new MockWallet([ + 'mint_quote', + 'bolt11_mint_quote', + 'bolt12_mint_quote', + 'proof_state', + ]); + // @ts-expect-error only the mocked subset is injected + events = new WalletEvents(mock); + + await events.mintQuoteUpdates(['a'], vi.fn(), vi.fn()); + + const ws = mock.mint.webSocketConnection!; + expect(ws.count('mint_quote')).toBe(1); + expect(ws.count('bolt11_mint_quote')).toBe(0); + expect(ws.count('bolt12_mint_quote')).toBe(0); + }); + + it('subscribes to custom per-method quote kinds the mint advertises', async () => { + mock = new MockWallet(['bacs_melt_quote', 'proof_state']); + // @ts-expect-error only the mocked subset is injected + events = new WalletEvents(mock); + + await events.meltQuoteUpdates(['m'], vi.fn(), vi.fn()); + expect(mock.mint.webSocketConnection!.count('bacs_melt_quote')).toBe(1); + }); + + it('cancelling a fanned-out subscription tears down every underlying kind', async () => { + mock = new MockWallet(['bolt11_mint_quote', 'bolt12_mint_quote', 'proof_state']); + // @ts-expect-error only the mocked subset is injected + events = new WalletEvents(mock); + + const cancel = await events.mintQuoteUpdates(['a'], vi.fn(), vi.fn()); + const ws = mock.mint.webSocketConnection!; + cancel(); + expect(ws.count('bolt11_mint_quote')).toBe(0); + expect(ws.count('bolt12_mint_quote')).toBe(0); + }); + + it("a fanned-out kind's error does not tear down the sibling that delivers", async () => { + mock = new MockWallet(['bolt11_mint_quote', 'bolt12_mint_quote', 'proof_state']); + // @ts-expect-error only the mocked subset is injected + events = new WalletEvents(mock); + + const paid = events.onceMintPaid('q', { timeoutMs: 1000 }); + await flushMicrotasks(); + const ws = mock.mint.webSocketConnection!; + // A mint that advertises bolt12 but rejects the subscribe must not kill the working bolt11 sub. + ws.fail('bolt12_mint_quote', new Error('bolt12 broken')); + ws.emit('bolt11_mint_quote', { quote: 'q', amount_paid: 10, amount_issued: 0 }); + + await expect(paid).resolves.toMatchObject({ quote: 'q' }); + }); + + it('rejects a fanned-out wait only when every kind has errored', async () => { + mock = new MockWallet(['bolt11_mint_quote', 'bolt12_mint_quote', 'proof_state']); + // @ts-expect-error only the mocked subset is injected + events = new WalletEvents(mock); + + const paid = events.onceMintPaid('q', { timeoutMs: 1000 }); + await flushMicrotasks(); + const ws = mock.mint.webSocketConnection!; + ws.fail('bolt12_mint_quote', new Error('bolt12 broken')); + ws.fail('bolt11_mint_quote', new Error('bolt11 broken')); + + await expect(paid).rejects.toThrow('bolt11 broken'); + }); + + it('mintQuotePaid treats a mintable accounting balance as paid (no state field)', async () => { + const cb = vi.fn(); + await events.mintQuotePaid('x', cb, vi.fn()); + const ws = mock.mint.webSocketConnection!; + + // amount_paid == amount_issued: nothing left to mint, not paid. + ws.emit('mint_quote', { quote: 'x', amount_paid: 5, amount_issued: 5 }); + expect(cb).not.toHaveBeenCalled(); + + // amount_paid > amount_issued: mintable balance, paid. + ws.emit('mint_quote', { quote: 'x', amount_paid: 7, amount_issued: 5 }); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ quote: 'x' })); + }); + + it('mintQuotePaid prefers accounting over legacy state when both are present', async () => { + const cb = vi.fn(); + await events.mintQuotePaid('x', cb, vi.fn()); + const ws = mock.mint.webSocketConnection!; + + // Legacy state says PAID, but everything paid has already been issued: nothing mintable. + ws.emit('mint_quote', { quote: 'x', state: 'PAID', amount_paid: 5, amount_issued: 5 }); + expect(cb).not.toHaveBeenCalled(); + }); + + it('throws when connectWebSocket fails to establish a connection', async () => { + mock.mint.connectWebSocket = vi.fn(async () => {}); + mock.mint.webSocketConnection = undefined; + const proofs: Proof[] = [ + { amount: Amount.from(1), id: '00bd033559de27d0', secret: 's', C: 'c' }, + ]; + await expect(events.mintQuoteUpdates(['a'], vi.fn(), vi.fn())).rejects.toThrow( + /Failed to establish WebSocket connection/, + ); + await expect(events.meltQuoteUpdates(['m'], vi.fn(), vi.fn())).rejects.toThrow( + /Failed to establish WebSocket connection/, + ); + await expect(events.proofStateUpdates(proofs, vi.fn(), vi.fn())).rejects.toThrow( + /Failed to establish WebSocket connection/, + ); + }); + it('proofStateUpdates subscribes and forwards payloads with proof attached', async () => { const cb = vi.fn(); const err = vi.fn(); @@ -275,7 +448,7 @@ describe('WalletEvents', () => { const p = events.onceMintPaid('q1'); await flushMicrotasks(); // wait for subscription to be created const ws = mock.mint.webSocketConnection!; - ws.emit('bolt11_mint_quote', { quote: 'q1', state: 'PAID', amount: 123 }); + ws.emit('mint_quote', { quote: 'q1', state: 'PAID', amount: 123 }); const res = await p; expect(res).toMatchObject({ quote: 'q1', amount: 123 }); await flushMicrotasks(); @@ -307,7 +480,7 @@ describe('WalletEvents', () => { const p = events.onceMintPaid('q4'); await flushMicrotasks(); // wait for subscription const ws = mock.mint.webSocketConnection!; - ws.fail('bolt11_mint_quote', new Error('boom')); + ws.fail('mint_quote', new Error('boom')); await expect(p).rejects.toThrow('boom'); await flushMicrotasks(); expect(ws.cancelSubscription).toHaveBeenCalled(); @@ -319,7 +492,7 @@ describe('WalletEvents', () => { const p = events.onceAnyMintPaid(['a', 'b', 'c']); await flushMicrotasks(); // subs ready const ws = mock.mint.webSocketConnection!; - ws.emit('bolt11_mint_quote', { quote: 'b', state: 'PAID', amount: 42 }); + ws.emit('mint_quote', { quote: 'b', state: 'PAID', amount: 42 }); const res = await p; expect(res).toMatchObject({ id: 'b', quote: expect.objectContaining({ amount: 42 }) }); await flushMicrotasks(); @@ -355,7 +528,7 @@ describe('WalletEvents', () => { const p = events.onceAnyMintPaid(['f1', 'f2', 'f3'], { failOnError: true }); await flushMicrotasks(); // subs ready const ws = mock.mint.webSocketConnection!; - ws.fail('bolt11_mint_quote', new Error('bad')); + ws.fail('mint_quote', new Error('bad')); await expect(p).rejects.toThrow(/bad/); await flushMicrotasks(); expect(ws.cancelSubscription.mock.calls.length).toBeGreaterThanOrEqual(3); @@ -365,8 +538,8 @@ describe('WalletEvents', () => { const p = events.onceAnyMintPaid(['dup', 'dup', 'other']); await flushMicrotasks(); // subs ready const ws = mock.mint.webSocketConnection!; - expect(ws.count('bolt11_mint_quote')).toBe(2); // dup + other - ws.emit('bolt11_mint_quote', { quote: 'dup', state: 'PAID' }); + expect(ws.count('mint_quote')).toBe(2); // dup + other + ws.emit('mint_quote', { quote: 'dup', state: 'PAID' }); const res = await p; expect(res.id).toBe('dup'); }); @@ -378,8 +551,8 @@ describe('WalletEvents', () => { const ws = mock.mint.webSocketConnection!; // Our WS mock broadcasts the same error to all subs per call; the first call // already empties the set. So assert we get *a* JSON-stringified object. - ws.fail('bolt11_mint_quote', { code: 1, msg: 'x' } as any); - ws.fail('bolt11_mint_quote', { code: 2, msg: 'y' } as any); + ws.fail('mint_quote', { code: 1, msg: 'x' } as any); + ws.fail('mint_quote', { code: 2, msg: 'y' } as any); await expect(p).rejects.toThrow(/"code":\s*\d/); }); @@ -388,7 +561,7 @@ describe('WalletEvents', () => { const p = events.onceMintPaid('bigobj'); await flushMicrotasks(); // sub ready const ws = mock.mint.webSocketConnection!; - ws.fail('bolt11_mint_quote', { n: 10n } as any); + ws.fail('mint_quote', { n: 10n } as any); await expect(p).rejects.toThrow(/\[object Object\]/); }); @@ -397,7 +570,7 @@ describe('WalletEvents', () => { await flushMicrotasks(); // sub ready const ws = mock.mint.webSocketConnection!; - ws.fail('bolt11_mint_quote', 10n); + ws.fail('mint_quote', 10n); // With current normalizeError/safeStringify, primitives fall back to // Object.prototype.toString => "[object BigInt]". @@ -410,7 +583,7 @@ describe('WalletEvents', () => { const p = events.onceMeltPaid('m1'); await flushMicrotasks(); // sub ready const ws = mock.mint.webSocketConnection!; - ws.emit('bolt11_melt_quote', { quote: 'm1', state: 'PAID', amount: 7 }); + ws.emit('melt_quote', { quote: 'm1', state: 'PAID', amount: 7 }); const res = await p; expect(res).toMatchObject({ quote: 'm1', amount: 7 }); await flushMicrotasks(); @@ -446,7 +619,7 @@ describe('WalletEvents', () => { const p = events.onceMeltPaid('m-error'); await flushMicrotasks(); // sub ready const ws = mock.mint.webSocketConnection!; - ws.fail('bolt11_melt_quote', new Error('melt-boom')); + ws.fail('melt_quote', new Error('melt-boom')); await expect(p).rejects.toThrow('melt-boom'); await flushMicrotasks(); expect(ws.cancelSubscription).toHaveBeenCalled(); @@ -458,7 +631,7 @@ describe('WalletEvents', () => { const p = events.onceMeltPaid('m-ok', { signal: ac.signal }); await flushMicrotasks(); // sub ready const ws = mock.mint.webSocketConnection!; - ws.emit('bolt11_melt_quote', { quote: 'm-ok', state: 'PAID', amount: 1 }); + ws.emit('melt_quote', { quote: 'm-ok', state: 'PAID', amount: 1 }); await expect(p).resolves.toMatchObject({ quote: 'm-ok' }); await flushMicrotasks(); expect(ws.cancelSubscription).toHaveBeenCalled(); diff --git a/test/wallet/_setup.ts b/test/wallet/_setup.ts index 331ffa5df..edb7c077f 100644 --- a/test/wallet/_setup.ts +++ b/test/wallet/_setup.ts @@ -8,7 +8,7 @@ import { DUMMY_TEST_KEYS, DUMMY_TEST_KEYSET } from '../consts'; injectWebSocketImpl(WebSocket); export const mintInfoResp = JSON.parse( - '{"name":"Testnut mint","pubkey":"0296d0aa13b6a31cf0cd974249f28c7b7176d7274712c95a41c7d8066d3f29d679","version":"Nutshell/0.16.3","description":"Mint for testing Cashu wallets","description_long":"This mint usually runs the latest main branch of the nutshell repository. It uses a FakeWallet, all your Lightning invoices will always be marked paid so that you can test minting and melting ecash via Lightning.","contact":[{"method":"email","info":"contact@me.com"},{"method":"twitter","info":"@me"},{"method":"nostr","info":"npub1337"}],"motd":"This is a message of the day field. You should display this field to your users if the content changes!","icon_url":"https://image.nostr.build/46ee47763c345d2cfa3317f042d332003f498ee281fb42808d47a7d3b9585911.png","time":1731684933,"nuts":{"4":{"methods":[{"method":"bolt11","unit":"sat","description":true},{"method":"bolt11","unit":"usd","description":true},{"method":"bolt11","unit":"eur","description":true},{"method":"bolt12","unit":"sat"},{"method":"onchain","unit":"sat"}],"disabled":false},"5":{"methods":[{"method":"bolt11","unit":"sat"},{"method":"bolt11","unit":"usd"},{"method":"bolt11","unit":"eur"},{"method":"bolt12","unit":"sat"},{"method":"onchain","unit":"sat"}],"disabled":false},"7":{"supported":true},"8":{"supported":true},"9":{"supported":true},"10":{"supported":true},"11":{"supported":true},"12":{"supported":true},"14":{"supported":true},"17":{"supported":[{"method":"bolt11","unit":"sat","commands":["bolt11_melt_quote","proof_state","bolt11_mint_quote"]},{"method":"bolt11","unit":"usd","commands":["bolt11_melt_quote","proof_state","bolt11_mint_quote"]},{"method":"bolt11","unit":"eur","commands":["bolt11_melt_quote","proof_state","bolt11_mint_quote"]}]}}}', + '{"name":"Testnut mint","pubkey":"0296d0aa13b6a31cf0cd974249f28c7b7176d7274712c95a41c7d8066d3f29d679","version":"Nutshell/0.16.3","description":"Mint for testing Cashu wallets","description_long":"This mint usually runs the latest main branch of the nutshell repository. It uses a FakeWallet, all your Lightning invoices will always be marked paid so that you can test minting and melting ecash via Lightning.","contact":[{"method":"email","info":"contact@me.com"},{"method":"twitter","info":"@me"},{"method":"nostr","info":"npub1337"}],"motd":"This is a message of the day field. You should display this field to your users if the content changes!","icon_url":"https://image.nostr.build/46ee47763c345d2cfa3317f042d332003f498ee281fb42808d47a7d3b9585911.png","time":1731684933,"nuts":{"4":{"methods":[{"method":"bolt11","unit":"sat","description":true},{"method":"bolt11","unit":"usd","description":true},{"method":"bolt11","unit":"eur","description":true},{"method":"bolt12","unit":"sat"},{"method":"onchain","unit":"sat"}],"disabled":false},"5":{"methods":[{"method":"bolt11","unit":"sat"},{"method":"bolt11","unit":"usd"},{"method":"bolt11","unit":"eur"},{"method":"bolt12","unit":"sat"},{"method":"onchain","unit":"sat"}],"disabled":false},"7":{"supported":true},"8":{"supported":true},"9":{"supported":true},"10":{"supported":true},"11":{"supported":true},"12":{"supported":true},"14":{"supported":true},"17":{"supported":[{"method":"bolt11","unit":"sat","commands":["melt_quote","proof_state","mint_quote"]},{"method":"bolt11","unit":"usd","commands":["melt_quote","proof_state","mint_quote"]},{"method":"bolt11","unit":"eur","commands":["melt_quote","proof_state","mint_quote"]}]}}}', ); export const dummyKeysResp = { diff --git a/test/wallet/wallet-auth.node.test.ts b/test/wallet/wallet-auth.node.test.ts index 5551cab5e..f841912b6 100644 --- a/test/wallet/wallet-auth.node.test.ts +++ b/test/wallet/wallet-auth.node.test.ts @@ -8,7 +8,7 @@ const server = useTestServer(); describe('Blind Authentication', () => { test('Mint Info', async () => { const mintInfo = JSON.parse( - '{"name":"Testnut auth mint","pubkey":"020fbbac41bcbd8d9b5353ee137baf45e0b21ccf33c0721a09bc7cbec495b156a2","version":"Nutshell/0.16.4","description":"","description_long":"","contact":[{"method":"email","info":"contact@me.com"},{"method":"twitter","info":"@me"},{"method":"nostr","info":"npub1337"}],"motd":"","icon_url":"","time":1738594208,"nuts":{"4":{"methods":[{"method":"bolt11","unit":"sat","description":true},{"method":"bolt11","unit":"usd","description":true},{"method":"bolt11","unit":"eur","description":true}],"disabled":false},"5":{"methods":[{"method":"bolt11","unit":"sat"},{"method":"bolt11","unit":"usd"},{"method":"bolt11","unit":"eur"}],"disabled":false},"7":{"supported":true},"8":{"supported":true},"9":{"supported":true},"10":{"supported":true},"11":{"supported":true},"12":{"supported":true},"14":{"supported":true},"20":{"supported":true},"17":{"supported":[{"method":"bolt11","unit":"sat","commands":["bolt11_melt_quote","proof_state","bolt11_mint_quote"]},{"method":"bolt11","unit":"usd","commands":["bolt11_melt_quote","proof_state","bolt11_mint_quote"]},{"method":"bolt11","unit":"eur","commands":["bolt11_melt_quote","proof_state","bolt11_mint_quote"]}]},"21":{"openid_discovery":"https://oicd.8333.space/realms/nutshell/.well-known/openid-configuration","client_id":"cashu-client","protected_endpoints":[{"method":"POST","path":"/v1/auth/blind/mint"}]},"22":{"bat_max_mint":100,"protected_endpoints":[{"method":"POST","path":"/v1/swap"},{"method":"POST","path":"/v1/mint/quote/bolt11"},{"method":"POST","path":"/v1/mint/bolt11"},{"method":"POST","path":"/v1/melt/bolt11"}]}}}', + '{"name":"Testnut auth mint","pubkey":"020fbbac41bcbd8d9b5353ee137baf45e0b21ccf33c0721a09bc7cbec495b156a2","version":"Nutshell/0.16.4","description":"","description_long":"","contact":[{"method":"email","info":"contact@me.com"},{"method":"twitter","info":"@me"},{"method":"nostr","info":"npub1337"}],"motd":"","icon_url":"","time":1738594208,"nuts":{"4":{"methods":[{"method":"bolt11","unit":"sat","description":true},{"method":"bolt11","unit":"usd","description":true},{"method":"bolt11","unit":"eur","description":true}],"disabled":false},"5":{"methods":[{"method":"bolt11","unit":"sat"},{"method":"bolt11","unit":"usd"},{"method":"bolt11","unit":"eur"}],"disabled":false},"7":{"supported":true},"8":{"supported":true},"9":{"supported":true},"10":{"supported":true},"11":{"supported":true},"12":{"supported":true},"14":{"supported":true},"20":{"supported":true},"17":{"supported":[{"method":"bolt11","unit":"sat","commands":["melt_quote","proof_state","mint_quote"]},{"method":"bolt11","unit":"usd","commands":["melt_quote","proof_state","mint_quote"]},{"method":"bolt11","unit":"eur","commands":["melt_quote","proof_state","mint_quote"]}]},"21":{"openid_discovery":"https://oicd.8333.space/realms/nutshell/.well-known/openid-configuration","client_id":"cashu-client","protected_endpoints":[{"method":"POST","path":"/v1/auth/blind/mint"}]},"22":{"bat_max_mint":100,"protected_endpoints":[{"method":"POST","path":"/v1/swap"},{"method":"POST","path":"/v1/mint/quote/bolt11"},{"method":"POST","path":"/v1/mint/bolt11"},{"method":"POST","path":"/v1/melt/bolt11"}]}}}', ); server.use( http.get(mintUrl + '/v1/info', () => { diff --git a/test/wallet/wallet-init.node.test.ts b/test/wallet/wallet-init.node.test.ts index 337ae8bee..c4a04ee64 100644 --- a/test/wallet/wallet-init.node.test.ts +++ b/test/wallet/wallet-init.node.test.ts @@ -388,17 +388,17 @@ describe('test info', () => { { method: 'bolt11', unit: 'sat', - commands: ['bolt11_melt_quote', 'proof_state', 'bolt11_mint_quote'], + commands: ['melt_quote', 'proof_state', 'mint_quote'], }, { method: 'bolt11', unit: 'usd', - commands: ['bolt11_melt_quote', 'proof_state', 'bolt11_mint_quote'], + commands: ['melt_quote', 'proof_state', 'mint_quote'], }, { method: 'bolt11', unit: 'eur', - commands: ['bolt11_melt_quote', 'proof_state', 'bolt11_mint_quote'], + commands: ['melt_quote', 'proof_state', 'mint_quote'], }, ], }); @@ -425,7 +425,7 @@ describe('test info', () => { // mintInfoRespDeprecated uses the old array-of-arrays contact format // v4 no longer normalizes this — it is passed through from the wire const mintInfoRespDeprecated = JSON.parse( - '{"name":"Testnut mint","pubkey":"0296d0aa13b6a31cf0cd974249f28c7b7176d7274712c95a41c7d8066d3f29d679","version":"Nutshell/0.16.3","description":"Mint for testing Cashu wallets","description_long":"This mint usually runs the latest main branch of the nutshell repository. All your Lightning invoices will always be marked paid so that you can test minting and melting ecash via Lightning.","contact":[["email","contact@me.com"],["twitter","@me"],["nostr","npub1337"]],"motd":"This is a message of the day field. You should display this field to your users if the content changes!","nuts":{"4":{"methods":[{"method":"bolt11","unit":"sat"},{"method":"bolt11","unit":"usd"}],"disabled":false},"5":{"methods":[{"method":"bolt11","unit":"sat"},{"method":"bolt11","unit":"usd"}],"disabled":false},"7":{"supported":true},"8":{"supported":true},"9":{"supported":true},"10":{"supported":true},"11":{"supported":true},"12":{"supported":true},"17":[{"method":"bolt11","unit":"sat","commands":["bolt11_melt_quote","proof_state","bolt11_mint_quote"]},{"method":"bolt11","unit":"usd","commands":["bolt11_melt_quote","proof_state","bolt11_mint_quote"]}]}}', + '{"name":"Testnut mint","pubkey":"0296d0aa13b6a31cf0cd974249f28c7b7176d7274712c95a41c7d8066d3f29d679","version":"Nutshell/0.16.3","description":"Mint for testing Cashu wallets","description_long":"This mint usually runs the latest main branch of the nutshell repository. All your Lightning invoices will always be marked paid so that you can test minting and melting ecash via Lightning.","contact":[["email","contact@me.com"],["twitter","@me"],["nostr","npub1337"]],"motd":"This is a message of the day field. You should display this field to your users if the content changes!","nuts":{"4":{"methods":[{"method":"bolt11","unit":"sat"},{"method":"bolt11","unit":"usd"}],"disabled":false},"5":{"methods":[{"method":"bolt11","unit":"sat"},{"method":"bolt11","unit":"usd"}],"disabled":false},"7":{"supported":true},"8":{"supported":true},"9":{"supported":true},"10":{"supported":true},"11":{"supported":true},"12":{"supported":true},"17":[{"method":"bolt11","unit":"sat","commands":["melt_quote","proof_state","mint_quote"]},{"method":"bolt11","unit":"usd","commands":["melt_quote","proof_state","mint_quote"]}]}}', ); server.use( http.get(mintUrl + '/v1/info', () => { @@ -658,7 +658,7 @@ describe('bindKeyset & withKeyset', () => { describe('multi mint', async () => { const mintInfo = JSON.parse( - '{"name":"Cashu mint","pubkey":"023ef9a3cda9945d5e784e478d3bd0c8d39726bcb3ca11098fe685a95d3f889d28","version":"Nutshell/0.16.4","contact":[],"time":1737973290,"nuts":{"4":{"methods":[{"method":"bolt11","unit":"sat","description":true}],"disabled":false},"5":{"methods":[{"method":"bolt11","unit":"sat"}],"disabled":false},"7":{"supported":true},"8":{"supported":true},"9":{"supported":true},"10":{"supported":true},"11":{"supported":true},"12":{"supported":true},"14":{"supported":true},"20":{"supported":true},"15":{"methods":[{"method":"bolt11","unit":"sat"}]},"17":{"supported":[{"method":"bolt11","unit":"sat","commands":["bolt11_melt_quote","proof_state","bolt11_mint_quote"]}]}}}', + '{"name":"Cashu mint","pubkey":"023ef9a3cda9945d5e784e478d3bd0c8d39726bcb3ca11098fe685a95d3f889d28","version":"Nutshell/0.16.4","contact":[],"time":1737973290,"nuts":{"4":{"methods":[{"method":"bolt11","unit":"sat","description":true}],"disabled":false},"5":{"methods":[{"method":"bolt11","unit":"sat"}],"disabled":false},"7":{"supported":true},"8":{"supported":true},"9":{"supported":true},"10":{"supported":true},"11":{"supported":true},"12":{"supported":true},"14":{"supported":true},"20":{"supported":true},"15":{"methods":[{"method":"bolt11","unit":"sat"}]},"17":{"supported":[{"method":"bolt11","unit":"sat","commands":["melt_quote","proof_state","mint_quote"]}]}}}', ); test('multi path melt quotes', async () => { server.use( @@ -666,28 +666,24 @@ describe('multi mint', async () => { return HttpResponse.json(mintInfo); }), ); + let seenMppAmount: unknown; server.use( - http.post( - mintUrl + '/v1/melt/quote/bolt11', - async ({ request }) => { - const body = await request.json(); - if (!body?.options.mpp) { - return new HttpResponse('No MPP', { status: 400 }); - } - return HttpResponse.json({ - quote: 'K-80Mo7xrtQRgaA1ifrxDKGQGZEGlo7zNDwTtf-D', - amount: 1, - fee_reserve: 2, - unit: 'sat', - request: invoice, - paid: false, - state: 'UNPAID', - expiry: 1673972705, - payment_preimage: null, - change: null, - }); - }, - ), + http.post(mintUrl + '/v1/melt/quote/bolt11', async ({ request }) => { + const body = await request.json(); + seenMppAmount = body?.options?.mpp?.amount; + return HttpResponse.json({ + quote: 'K-80Mo7xrtQRgaA1ifrxDKGQGZEGlo7zNDwTtf-D', + amount: 1, + fee_reserve: 2, + unit: 'sat', + request: invoice, + paid: false, + state: 'UNPAID', + expiry: 1673972705, + payment_preimage: null, + change: null, + }); + }), ); const mint = new Mint(mintUrl); const wallet = new Wallet(mint); @@ -696,6 +692,6 @@ describe('multi mint', async () => { const meltQuote = await wallet.createMultiPathMeltQuote(invoice, 1000); expect(meltQuote.amount).toEqual(Amount.from(1)); expect(meltQuote.quote).toBe('K-80Mo7xrtQRgaA1ifrxDKGQGZEGlo7zNDwTtf-D'); - await expect(wallet.createMeltQuoteBolt11(invoice)).rejects.toThrow(); + expect(seenMppAmount).toBe(1000); }); }); From 5d5c702b7a36393787e1af39116b78fe572b16aa Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Thu, 18 Jun 2026 22:35:46 +0100 Subject: [PATCH 10/11] docs: document generic quote-subscription payload in v5 migration guide --- migration-5.0.0.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/migration-5.0.0.md b/migration-5.0.0.md index 643ab8899..4e6e6da76 100644 --- a/migration-5.0.0.md +++ b/migration-5.0.0.md @@ -269,3 +269,21 @@ 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. + +--- + +## Quote subscriptions default to a generic payload type + +The `wallet.on` quote helpers (`mintQuoteUpdates`, `mintQuotePaid`, `meltQuoteUpdates`, `meltQuotePaid`, `onceMintPaid`, `onceMeltPaid`, `onceAnyMintPaid`) are now generic over the payload type, defaulting to `MintQuoteGenericResponse` / `MeltQuoteGenericResponse` (previously `MintQuoteBolt11Response` / `MeltQuoteBolt11Response`). Runtime behaviour is unchanged — only the static type widened, so method-specific fields are now `unknown` unless you narrow. + +### Migration + +Pass the type argument to restore typed fields: + +```ts +// Before — payload typed as MintQuoteBolt11Response +await wallet.on.onceMintPaid(quoteId); + +// After — narrow to keep bolt11 fields typed +await wallet.on.onceMintPaid(quoteId); +``` From 00ad101888f7fc1747b8e6c4ed7a7b9af1bb52e7 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Fri, 19 Jun 2026 10:54:53 +0100 Subject: [PATCH 11/11] test(nut17): pin blind quote-subscription fan-out error behavior Document the fan-out fallback's intentional tradeoff: with only a quote id the wallet can't map a quote to the kind that owns it, so a subscribe rejection on the relevant kind is masked while an idle sibling keeps the alive count above zero. Add a test for that masking and one proving the once* timeoutMs escape still terminates the wait. --- test/wallet/WalletEvents.test.ts | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/wallet/WalletEvents.test.ts b/test/wallet/WalletEvents.test.ts index d10343952..d76b80c21 100644 --- a/test/wallet/WalletEvents.test.ts +++ b/test/wallet/WalletEvents.test.ts @@ -314,6 +314,45 @@ describe('WalletEvents', () => { await expect(paid).rejects.toThrow('bolt11 broken'); }); + // Documents the blind-fan-out tradeoff: with only an id, the wallet can't map a quote to the + // kind that owns it, so a subscribe rejection on the *relevant* kind looks identical to one on + // an idle sibling. The `alive` policy waits for every kind before surfacing `err` (so it never + // false-alarms when the working kind still delivers, see the sibling test above); the cost is + // that a lone relevant-kind rejection is masked while an idle sibling keeps the count above 0. + it('masks a relevant-kind rejection while an idle sibling stays subscribed', async () => { + mock = new MockWallet(['bolt11_mint_quote', 'bolt12_mint_quote', 'proof_state']); + // @ts-expect-error only the mocked subset is injected + events = new WalletEvents(mock); + + const cb = vi.fn(); + const err = vi.fn(); + await events.mintQuoteUpdates(['q'], cb, err); + const ws = mock.mint.webSocketConnection!; + + // 'q' is a bolt11 quote; the mint rejects its subscribe but accepts (then idles) bolt12. + ws.fail('bolt11_mint_quote', new Error('bolt11 broken')); + await flushMicrotasks(); + + // Masked: one kind remains alive, so the rejection is not surfaced. + expect(err).not.toHaveBeenCalled(); + expect(cb).not.toHaveBeenCalled(); + }); + + it('a relevant-kind rejection still terminates a once* wait via timeoutMs', async () => { + mock = new MockWallet(['bolt11_mint_quote', 'bolt12_mint_quote', 'proof_state']); + // @ts-expect-error only the mocked subset is injected + events = new WalletEvents(mock); + + const paid = events.onceMintPaid('q', { timeoutMs: 50 }); + await flushMicrotasks(); + const ws = mock.mint.webSocketConnection!; + // Relevant kind rejected; sibling idles. The wait is not surfaced as an error, but the + // bounded one-shot escape hatch still resolves the hang. + ws.fail('bolt11_mint_quote', new Error('bolt11 broken')); + + await expect(paid).rejects.toThrow(/Timeout/); + }); + it('mintQuotePaid treats a mintable accounting balance as paid (no state field)', async () => { const cb = vi.fn(); await events.mintQuotePaid('x', cb, vi.fn());