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); } ``` diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index 979c288a3..166f4bd37 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -845,12 +845,16 @@ export type MeltProofsResponse; + // @public export type MeltQuoteOnchainFeeOption = { fee_index: number; @@ -902,7 +908,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 +940,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 +955,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 +1208,8 @@ export type MintProofsConfig = { // @public export type MintQuoteBaseRequest = { unit: string; + amount?: AmountLike; + description?: string; pubkey?: string; }; @@ -1211,6 +1218,11 @@ export type MintQuoteBaseResponse = { quote: string; request: string; unit: string; + method: string; + amount_paid: Amount; + amount_issued: Amount; + updated_at: number | null; + expiry: number | null; pubkey?: string; }; @@ -1224,7 +1236,6 @@ export type MintQuoteBolt11Request = MintQuoteBaseRequest & { export type MintQuoteBolt11Response = MintQuoteBaseResponse & { amount: Amount; state: MintQuoteState; - expiry: number | null; }; // @public @@ -1236,15 +1247,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 +1263,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 +2152,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 +2172,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; diff --git a/migration-5.0.0.md b/migration-5.0.0.md index 8ccf9d793..643ab8899 100644 --- a/migration-5.0.0.md +++ b/migration-5.0.0.md @@ -198,3 +198,74 @@ 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 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). + +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, + method: 'bolt11', + 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` 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`. + +`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`. + +--- + +## 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 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/mint/Mint.ts b/src/mint/Mint.ts index c613a4f98..783cd6515 100644 --- a/src/mint/Mint.ts +++ b/src/mint/Mint.ts @@ -13,12 +13,15 @@ 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, + MintQuoteState, type MintResponse, type GetInfoResponse, type MeltRequest, @@ -237,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 }, @@ -331,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 }, @@ -568,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 }, @@ -664,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 }, @@ -1144,38 +1147,123 @@ 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, 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, method, op); if (method === 'bolt11') { this.normalizeMintQuoteBolt11Fields(data); } else if (method === 'bolt12') { this.normalizeMintQuoteBolt12Fields(data); - } else if (method === 'onchain') { - this.normalizeMintQuoteOnchainFields(data); } 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, method: string, op: string): void { + this.normalizeQuoteMethod(data, method, op); + // nullish: absent (legacy bolt11) or off-spec null → derive + if (data.amount_paid != null && data.amount_issued != null) { + 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, + ); + data.expiry = normalizeSafeIntegerMetadata(data.expiry as number, 'mintQuote.expiry', 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'); + } + } + + /** + * 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. + */ + 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. */ 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) + ) { + data.state = this.deriveMintQuoteState( + data.amount_paid as Amount, + data.amount_issued as Amount, + ); + } } /** @@ -1187,26 +1275,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, - ); - data.amount_paid = Amount.from(data.amount_paid as AmountLike); - data.amount_issued = Amount.from(data.amount_issued as AmountLike); - } - - /** - * Mutates `data` in place, normalizing onchain mint-quote fields. - */ - private normalizeMintQuoteOnchainFields(data: Record): void { - data.expiry = normalizeSafeIntegerMetadata( - data.expiry as number, - 'mintQuoteOnchain.expiry', - null, - ); - data.amount_paid = Amount.from(data.amount_paid as Amount); - data.amount_issued = Amount.from(data.amount_issued as Amount); } /** @@ -1221,7 +1289,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') { @@ -1233,19 +1301,25 @@ 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, 'meltQuote.expiry', undefined, ); + if (data.fee_reserve != null) { + // nullish + 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' || @@ -1261,8 +1335,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'); } @@ -1296,7 +1370,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 db3377d23..50eea4b40 100644 --- a/src/model/types/NUT04.ts +++ b/src/model/types/NUT04.ts @@ -1,3 +1,5 @@ +import type { Amount, AmountLike } from '../Amount'; + import { type SerializedBlindedMessage, type SerializedBlindedSignature } from './blinded'; export const MintQuoteState = { @@ -16,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). */ @@ -39,12 +49,40 @@ 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. + */ + 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; + /** + * 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..31a26f5b1 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,23 @@ 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; + /** + * 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. + */ + fee_reserve?: Amount; /** * Unit of the melt quote. */ @@ -55,6 +72,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 5fff3dbf6..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. * @@ -37,15 +33,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..feffeb9e4 100644 --- a/src/model/types/NUT30.ts +++ b/src/model/types/NUT30.ts @@ -17,22 +17,10 @@ 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. */ 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; }; /** @@ -71,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 0fc7a41f5..455aa8bc3 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 }, @@ -1953,14 +1955,16 @@ class Wallet { quote: Pick, requestedAmount: Amount, ): void { - if (method !== 'bolt12' && method !== 'onchain') { - return; - } if (!('amount_paid' in quote) || !('amount_issued' in quote)) { return; } const amountPaid = Amount.from(quote.amount_paid as AmountLike); const amountIssued = Amount.from(quote.amount_issued as AmountLike); + // 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; + } const availableAmount = amountPaid.subtract(amountIssued); this.failIf( requestedAmount.greaterThan(availableAmount), @@ -2432,7 +2436,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 }, @@ -2599,7 +2603,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 09ce663a9..5b5c705e4 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; @@ -478,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, @@ -512,6 +517,281 @@ 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', + ); + }); + }); + + 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 = { + 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 23ca6307f..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 () => { @@ -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..03cf60c2b 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,39 @@ 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 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 may simply predate the + // payment 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(); @@ -1219,6 +1254,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, @@ -1265,6 +1301,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, @@ -1289,6 +1326,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, @@ -1343,6 +1381,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, @@ -1426,6 +1465,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, @@ -1780,6 +1820,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,