From 2f08dfd1d7280846544605eef2a4de9ab907c599 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Fri, 12 Jun 2026 14:53:26 +0100 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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); } ```