From 6c5c71cc284a259d857d75cf53a31faa030c8e3f Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Mon, 13 Jul 2026 18:34:49 +0100 Subject: [PATCH 1/7] feat(crypto): add mint quote lookup signature helpers --- src/crypto/NUT20.ts | 37 +++++++++++++++++++++- src/crypto/index.ts | 7 ++++- test/crypto/NUT20.test.ts | 66 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/crypto/NUT20.ts b/src/crypto/NUT20.ts index 351e02cad..af69cc2e9 100644 --- a/src/crypto/NUT20.ts +++ b/src/crypto/NUT20.ts @@ -5,7 +5,12 @@ import { hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'; import { Amount } from '../model/Amount'; import { type SerializedBlindedMessage } from '../model/types'; -import { schnorrSignDigest, schnorrVerifyDigest } from './core'; +import { + schnorrSignDigest, + schnorrSignMessage, + schnorrVerifyDigest, + schnorrVerifyMessage, +} from './core'; // Domain-separation tag. const MINT_QUOTE_SIG_DST = utf8ToBytes('Cashu_MintQuoteSig_v1'); @@ -110,3 +115,33 @@ export function verifyMintQuoteSignatureLegacy( return false; } } + +// Domain-separation tag for mint quote lookup signatures (draft NUT: get quotes by pubkeys). +const MINT_QUOTE_LOOKUP_DST = 'Cashu_MintQuoteLookup_v1'; + +// Plain UTF-8 concat per the draft spec; hex is lowercased because the message is a string. +function constructLookupMessage(mintPubkey: string, pubkey: string): string { + return MINT_QUOTE_LOOKUP_DST + mintPubkey.toLowerCase() + pubkey.toLowerCase(); +} + +/** + * Signs a mint quote lookup request for one pubkey (draft NUT: get quotes by pubkeys). + * + * @remarks + * `mintPubkey` is the mint's NUT-06 info pubkey; it binds the signature to one mint. + */ +export function signMintQuoteLookup(privkey: string, mintPubkey: string, pubkey: string): string { + return schnorrSignMessage(constructLookupMessage(mintPubkey, pubkey), privkey); +} + +/** + * Verifies a mint quote lookup signature. Malformed input returns false, never throws. + */ +export function verifyMintQuoteLookupSignature( + pubkey: string, + mintPubkey: string, + signature: string, +): boolean { + if (!isCompressedPubkey(pubkey)) return false; + return schnorrVerifyMessage(signature, constructLookupMessage(mintPubkey, pubkey), pubkey); +} diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 16e528a81..02c9a51d7 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -8,6 +8,11 @@ export * from './NUT11'; export * from './NUT12'; export * from './NUT13'; export * from './NUT14'; -export { signMintQuote, verifyMintQuoteSignature } from './NUT20'; +export { + signMintQuote, + verifyMintQuoteSignature, + signMintQuoteLookup, + verifyMintQuoteLookupSignature, +} from './NUT20'; export * from './NUT27'; export * from './NUT28'; diff --git a/test/crypto/NUT20.test.ts b/test/crypto/NUT20.test.ts index 3a57aeaed..5f7d34b61 100644 --- a/test/crypto/NUT20.test.ts +++ b/test/crypto/NUT20.test.ts @@ -1,10 +1,15 @@ -import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { schnorr, secp256k1 } from '@noble/curves/secp256k1.js'; import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; import { test, describe, expect } from 'vitest'; import { Amount, type MintRequest } from '../../src'; -import { signMintQuote, verifyMintQuoteSignature } from '../../src/crypto'; +import { + signMintQuote, + verifyMintQuoteSignature, + signMintQuoteLookup, + verifyMintQuoteLookupSignature, +} from '../../src/crypto'; import { signMintQuoteLegacy, verifyMintQuoteSignatureLegacy } from '../../src/crypto/NUT20'; /** @@ -294,3 +299,60 @@ describe('mint quote signature verification rejects malformed input (no throw)', expect(verifyMintQuoteSignatureLegacy(pubkey, quote, [], sig)).toBe(false); }); }); + +/** + * Mint quote lookup signatures (draft NUT: get quotes by pubkeys). Message is a plain UTF-8 concat, + * unlike the length-framed mint-quote transcript above. + */ +describe('mint quote lookup signatures (draft NUT)', () => { + const privkey = '0000000000000000000000000000000000000000000000000000000000000001'; + const pubkey = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const mintPubkey = '0296d0aa13b6a31cf0cd974249f28c7b7176d7274712c95a41c7d8066d3f29d679'; + + test('signature commits to the spec preimage', () => { + const signature = signMintQuoteLookup(privkey, mintPubkey, pubkey); + // Independent reconstruction: "Cashu_MintQuoteLookup_v1" || mint_pubkey || pubkey as UTF-8. + const digest = sha256( + new TextEncoder().encode('Cashu_MintQuoteLookup_v1' + mintPubkey + pubkey), + ); + expect(schnorr.verify(hexToBytes(signature), digest, hexToBytes(pubkey.slice(2)))).toBe(true); + }); + + test('sign/verify round trip', () => { + const signature = signMintQuoteLookup(privkey, mintPubkey, pubkey); + expect(verifyMintQuoteLookupSignature(pubkey, mintPubkey, signature)).toBe(true); + }); + + test('hex case does not change the message', () => { + const signature = signMintQuoteLookup(privkey, mintPubkey.toUpperCase(), pubkey.toUpperCase()); + expect(verifyMintQuoteLookupSignature(pubkey, mintPubkey, signature)).toBe(true); + }); + + test('rejects a signature bound to a different mint pubkey', () => { + const otherMint = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const signature = signMintQuoteLookup(privkey, otherMint, pubkey); + expect(verifyMintQuoteLookupSignature(pubkey, mintPubkey, signature)).toBe(false); + }); + + test('rejects a signature from a different key', () => { + const otherPriv = '0000000000000000000000000000000000000000000000000000000000000002'; + const signature = signMintQuoteLookup(otherPriv, mintPubkey, pubkey); + expect(verifyMintQuoteLookupSignature(pubkey, mintPubkey, signature)).toBe(false); + }); + + test('rejects non-compressed pubkeys', () => { + const signature = signMintQuoteLookup(privkey, mintPubkey, pubkey); + const xOnly = pubkey.slice(2); + // Uncompressed SEC1 form of the same point (G). + const uncompressed = + '0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' + + '483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8'; + expect(verifyMintQuoteLookupSignature(xOnly, mintPubkey, signature)).toBe(false); + expect(verifyMintQuoteLookupSignature(uncompressed, mintPubkey, signature)).toBe(false); + }); + + test('malformed signature returns false rather than throwing', () => { + expect(verifyMintQuoteLookupSignature(pubkey, mintPubkey, 'not-hex')).toBe(false); + expect(verifyMintQuoteLookupSignature(pubkey, mintPubkey, '')).toBe(false); + }); +}); From 17df42dd1a983c31cf77506f3857bcbea0a4f096 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Mon, 13 Jul 2026 18:41:50 +0100 Subject: [PATCH 2/7] fix(crypto): lookup verify returns false on malformed mint pubkey --- src/crypto/NUT20.ts | 7 ++++++- test/crypto/NUT20.test.ts | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/crypto/NUT20.ts b/src/crypto/NUT20.ts index af69cc2e9..eb71bd5f5 100644 --- a/src/crypto/NUT20.ts +++ b/src/crypto/NUT20.ts @@ -143,5 +143,10 @@ export function verifyMintQuoteLookupSignature( signature: string, ): boolean { if (!isCompressedPubkey(pubkey)) return false; - return schnorrVerifyMessage(signature, constructLookupMessage(mintPubkey, pubkey), pubkey); + // See verifyMintQuoteSignature: malformed input must verify as false rather than throw. + try { + return schnorrVerifyMessage(signature, constructLookupMessage(mintPubkey, pubkey), pubkey); + } catch { + return false; + } } diff --git a/test/crypto/NUT20.test.ts b/test/crypto/NUT20.test.ts index 5f7d34b61..d13043945 100644 --- a/test/crypto/NUT20.test.ts +++ b/test/crypto/NUT20.test.ts @@ -355,4 +355,11 @@ describe('mint quote lookup signatures (draft NUT)', () => { expect(verifyMintQuoteLookupSignature(pubkey, mintPubkey, 'not-hex')).toBe(false); expect(verifyMintQuoteLookupSignature(pubkey, mintPubkey, '')).toBe(false); }); + + test('non-string mint pubkey verifies as false rather than throwing', () => { + const signature = signMintQuoteLookup(privkey, mintPubkey, pubkey); + expect(verifyMintQuoteLookupSignature(pubkey, undefined as unknown as string, signature)).toBe( + false, + ); + }); }); From 080858723e155b3c6a4607cf0df1e8ecb6a22f0d Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Mon, 13 Jul 2026 18:45:26 +0100 Subject: [PATCH 3/7] feat(mint): get locked mint quotes by pubkey --- src/mint/Mint.ts | 63 ++++++++++++++++++ test/mint/Mint.node.test.ts | 124 ++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) diff --git a/src/mint/Mint.ts b/src/mint/Mint.ts index 4b865b167..3cf774f32 100644 --- a/src/mint/Mint.ts +++ b/src/mint/Mint.ts @@ -491,6 +491,69 @@ class Mint { }); } + /** + * Gets NUT-20 locked mint quotes for a set of public keys. + * + * @remarks + * Uses `/v1/mint/quote/{method}/pubkey`. Signatures must sign the lookup message for their pubkey + * (see `signMintQuoteLookup`). Returned quotes may span payment methods; each is normalized by + * its own `method` field, falling back to the path method when absent. + * @param method The payment method path segment (e.g., 'bolt11'). + * @param payload Wire payload: pubkeys and same-order signatures. + * @param options.customRequest Optional override for the request function. + * @param options.normalize Optional callback to normalize method-specific response fields. + * @returns Normalized mint quote responses. + * @experimental Implements a draft NUT; no released mint supports it yet. + */ + async getMintQuotesByPubkey( + method: string, + payload: { pubkeys: string[]; pubkey_signatures: string[] }, + options?: { customRequest?: RequestFn; normalize?: (raw: Record) => TRes }, + ): Promise { + const { pubkeys, pubkey_signatures } = payload; + failIf(!this.isValidMethodString(method), `Invalid mint quote method: ${method}`, this._logger); + failIf(pubkeys.length === 0, 'getMintQuotesByPubkey: no pubkeys provided', this._logger); + failIf( + pubkeys.length !== pubkey_signatures.length, + 'getMintQuotesByPubkey: pubkeys and signatures length mismatch', + this._logger, + ); + failIf( + pubkeys.some((pubkey) => pubkey.length !== 66), + 'getMintQuotesByPubkey: pubkeys must be compressed 33-byte hex', + this._logger, + ); + + const data = await this.requestWithAuth<{ quotes: TRes[] }>( + 'POST', + `/v1/mint/quote/${method}/pubkey`, + { requestBody: { pubkeys, pubkey_signatures } }, + options?.customRequest, + ); + + const quotes = data?.quotes; + if (!Array.isArray(quotes)) { + this._logger.error('Invalid response from mint...', { + data, + op: `getMintQuotesByPubkey.${method}`, + }); + throw new CTSError('Invalid response from mint'); + } + + return quotes.map((response) => { + const raw = response as Record; + const quoteMethod = typeof raw.method === 'string' ? raw.method : method; + if (!this.isValidMethodString(quoteMethod)) { + this._logger.error('Invalid response from mint...', { + data, + op: `getMintQuotesByPubkey.${method}`, + }); + throw new CTSError('Invalid response from mint'); + } + return this.normalizeMintQuoteResponse(quoteMethod, response, options?.normalize); + }); + } + // ----------------------------------------------------------------- // Section: Mint Proofs // ----------------------------------------------------------------- diff --git a/test/mint/Mint.node.test.ts b/test/mint/Mint.node.test.ts index 7c12483f6..362fb1fcb 100644 --- a/test/mint/Mint.node.test.ts +++ b/test/mint/Mint.node.test.ts @@ -1552,6 +1552,130 @@ afterAll(() => { mswServer.close(); }); +describe('getMintQuotesByPubkey', () => { + const PUBKEY = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const SIG = 'aa'.repeat(64); + const bolt11Quote = { + quote: 'q1', + request: 'lnbc100...', + unit: 'sat', + method: 'bolt11', + amount: 100, + amount_paid: 100, + amount_issued: 0, + updated_at: 123, + state: 'PAID', + expiry: 456, + pubkey: PUBKEY, + }; + const bolt12Quote = { + quote: 'q2', + request: 'lno1...', + unit: 'sat', + method: 'bolt12', + amount: null, + amount_paid: 42, + amount_issued: 0, + updated_at: 124, + expiry: null, + pubkey: PUBKEY, + }; + + it('posts to /v1/mint/quote/{method}/pubkey and normalizes mixed-method quotes', async () => { + const requestSpy = vi.fn(async (options: ReqArgs) => { + expect(options.endpoint).toBe(mintUrl + '/v1/mint/quote/bolt11/pubkey'); + expect(options.method).toBe('POST'); + expect(options.requestBody).toEqual({ + pubkeys: [PUBKEY], + pubkey_signatures: [SIG], + }); + return { quotes: [bolt11Quote, bolt12Quote] }; + }) as RequestFn; + const mint = new Mint(mintUrl, { customRequest: requestSpy }); + + const quotes = await mint.getMintQuotesByPubkey('bolt11', { + pubkeys: [PUBKEY], + pubkey_signatures: [SIG], + }); + + expect(quotes).toHaveLength(2); + expect(quotes[0].method).toBe('bolt11'); + expect(quotes[0].amount_paid).toBeInstanceOf(Amount); + expect(quotes[0].amount_paid.toBigInt()).toBe(100n); + // Second quote normalized as bolt12 despite the bolt11 path. + expect(quotes[1].method).toBe('bolt12'); + expect(quotes[1].amount_paid.toBigInt()).toBe(42n); + }); + + it('falls back to the path method when a quote omits method', async () => { + const { method: _omit, ...methodless } = bolt11Quote; + const mint = new Mint(mintUrl, { + customRequest: makeRequest({ quotes: [methodless] }), + }); + + const quotes = await mint.getMintQuotesByPubkey('bolt11', { + pubkeys: [PUBKEY], + pubkey_signatures: [SIG], + }); + + expect(quotes[0].method).toBe('bolt11'); + }); + + it('rejects a bare-array response', async () => { + const logger = createLogger(); + const mint = new Mint(mintUrl, { + customRequest: makeRequest([bolt11Quote]), + logger, + }); + + await expect( + mint.getMintQuotesByPubkey('bolt11', { pubkeys: [PUBKEY], pubkey_signatures: [SIG] }), + ).rejects.toThrow('Invalid response from mint'); + }); + + it('rejects a quote with a malformed method string', async () => { + const mint = new Mint(mintUrl, { + customRequest: makeRequest({ quotes: [{ ...bolt11Quote, method: 'BOLT11!' }] }), + logger: createLogger(), + }); + + await expect( + mint.getMintQuotesByPubkey('bolt11', { pubkeys: [PUBKEY], pubkey_signatures: [SIG] }), + ).rejects.toThrow('Invalid response from mint'); + }); + + it('rejects invalid client input before any request', async () => { + const requestSpy = vi.fn(async () => ({ quotes: [] })) as RequestFn; + const mint = new Mint(mintUrl, { customRequest: requestSpy, logger: createLogger() }); + + await expect( + mint.getMintQuotesByPubkey('Bolt11', { pubkeys: [PUBKEY], pubkey_signatures: [SIG] }), + ).rejects.toThrow('Invalid mint quote method'); + await expect( + mint.getMintQuotesByPubkey('bolt11', { pubkeys: [], pubkey_signatures: [] }), + ).rejects.toThrow('no pubkeys'); + await expect( + mint.getMintQuotesByPubkey('bolt11', { pubkeys: [PUBKEY], pubkey_signatures: [] }), + ).rejects.toThrow('length mismatch'); + await expect( + mint.getMintQuotesByPubkey('bolt11', { + pubkeys: [PUBKEY.slice(2)], + pubkey_signatures: [SIG], + }), + ).rejects.toThrow('compressed'); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('returns an empty array for an empty quotes envelope', async () => { + const mint = new Mint(mintUrl, { customRequest: makeRequest({ quotes: [] }) }); + const quotes = await mint.getMintQuotesByPubkey('bolt11', { + pubkeys: [PUBKEY], + pubkey_signatures: [SIG], + }); + expect(quotes).toEqual([]); + }); +}); + describe('Mint.lastResponseMetadata', () => { it('is undefined before any request', () => { const mint = new Mint(mswMintUrl); From 0706e4db2f974dd39a21bd6ce2ab2b62c18ad061 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Mon, 13 Jul 2026 18:56:48 +0100 Subject: [PATCH 4/7] feat(wallet): get locked mint quotes by pubkey --- src/wallet/Wallet.ts | 32 ++++++ test/wallet/wallet-quote-lookup.node.test.ts | 102 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 test/wallet/wallet-quote-lookup.node.test.ts diff --git a/src/wallet/Wallet.ts b/src/wallet/Wallet.ts index 9db8bf3e8..d7672c337 100644 --- a/src/wallet/Wallet.ts +++ b/src/wallet/Wallet.ts @@ -5,9 +5,13 @@ * This is the instantiation point for the Cashu-TS library. */ +import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; + import { type AuthProvider } from '../auth/AuthProvider'; import { signMintQuote, + signMintQuoteLookup, + getPubKeyFromPrivKey, findSigningKey, signP2PKProofs as cryptoSignP2PKProofs, hashToCurve, @@ -1907,6 +1911,34 @@ class Wallet { return this.mint.checkMintQuoteBolt12(quote); } + /** + * Gets the NUT-20 locked mint quotes owned by the given private key(s). + * + * @remarks + * Signs a lookup per key against the mint's info pubkey. Returned quotes may span payment + * methods; each carries its own `method` field. + * @param privkey Private key(s) whose locked quotes to fetch. + * @param method The payment method path segment. + * @returns Normalized mint quote responses. + * @experimental Implements a draft NUT; no released mint supports it yet. + */ + async getMintQuotesByPubkey( + privkey: string | string[], + method = 'bolt11', + ): Promise { + const mintPubkey = this.getMintInfo().pubkey; + this.failIf( + !mintPubkey || mintPubkey.length !== 66, + 'Mint does not publish a usable pubkey in /v1/info', + ); + const privkeys = Array.isArray(privkey) ? privkey : [privkey]; + const pubkeys = privkeys.map((key) => bytesToHex(getPubKeyFromPrivKey(hexToBytes(key)))); + const pubkey_signatures = privkeys.map((key, i) => + signMintQuoteLookup(key, mintPubkey, pubkeys[i]), + ); + return this.mint.getMintQuotesByPubkey(method, { pubkeys, pubkey_signatures }); + } + /** * Gets an existing onchain mint quote from the mint. * diff --git a/test/wallet/wallet-quote-lookup.node.test.ts b/test/wallet/wallet-quote-lookup.node.test.ts new file mode 100644 index 000000000..8b1900f52 --- /dev/null +++ b/test/wallet/wallet-quote-lookup.node.test.ts @@ -0,0 +1,102 @@ +import { HttpResponse, http } from 'msw'; +import { describe, expect, test } from 'vitest'; + +import { Wallet } from '../../src'; +import { verifyMintQuoteLookupSignature } from '../../src/crypto'; + +import { mint, mintInfoResp, mintUrl, unit, useTestServer } from './_setup'; + +const server = useTestServer(); + +const PRIVKEY = '0000000000000000000000000000000000000000000000000000000000000001'; +const PUBKEY = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; +const PRIVKEY2 = '0000000000000000000000000000000000000000000000000000000000000002'; +const PUBKEY2 = '02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5'; +const MINT_PUBKEY = mintInfoResp.pubkey as string; + +type LookupBody = { pubkeys: string[]; pubkey_signatures: string[] }; + +describe('Wallet.getMintQuotesByPubkey', () => { + test('derives the pubkey, signs with the mint info pubkey, and normalizes quotes', async () => { + let body: LookupBody | undefined; + server.use( + http.post(mintUrl + '/v1/mint/quote/bolt11/pubkey', async ({ request }) => { + body = (await request.json()) as LookupBody; + return HttpResponse.json({ + quotes: [ + { + quote: 'q1', + request: 'lnbc100...', + unit: 'sat', + method: 'bolt11', + amount: 100, + amount_paid: 100, + amount_issued: 0, + updated_at: 1, + state: 'PAID', + expiry: null, + pubkey: PUBKEY, + }, + ], + }); + }), + ); + const wallet = new Wallet(mint, { unit }); + await wallet.loadMint(); + + const quotes = await wallet.getMintQuotesByPubkey(PRIVKEY); + + expect(body?.pubkeys).toEqual([PUBKEY]); + expect(body?.pubkey_signatures).toHaveLength(1); + expect(verifyMintQuoteLookupSignature(PUBKEY, MINT_PUBKEY, body!.pubkey_signatures[0])).toBe( + true, + ); + expect(quotes).toHaveLength(1); + expect(quotes[0].quote).toBe('q1'); + expect(quotes[0].amount_paid.toBigInt()).toBe(100n); + }); + + test('accepts multiple privkeys and preserves order', async () => { + let body: LookupBody | undefined; + server.use( + http.post(mintUrl + '/v1/mint/quote/bolt11/pubkey', async ({ request }) => { + body = (await request.json()) as LookupBody; + return HttpResponse.json({ quotes: [] }); + }), + ); + const wallet = new Wallet(mint, { unit }); + await wallet.loadMint(); + + const quotes = await wallet.getMintQuotesByPubkey([PRIVKEY, PRIVKEY2]); + + expect(quotes).toEqual([]); + expect(body?.pubkeys).toEqual([PUBKEY, PUBKEY2]); + expect(verifyMintQuoteLookupSignature(PUBKEY, MINT_PUBKEY, body!.pubkey_signatures[0])).toBe( + true, + ); + expect(verifyMintQuoteLookupSignature(PUBKEY2, MINT_PUBKEY, body!.pubkey_signatures[1])).toBe( + true, + ); + }); + + test('routes the method into the request path', async () => { + server.use( + http.post(mintUrl + '/v1/mint/quote/bolt12/pubkey', () => HttpResponse.json({ quotes: [] })), + ); + const wallet = new Wallet(mint, { unit }); + await wallet.loadMint(); + + await expect(wallet.getMintQuotesByPubkey(PRIVKEY, 'bolt12')).resolves.toEqual([]); + }); + + test('rejects when the mint publishes no usable pubkey', async () => { + const { pubkey: _drop, ...noPubkeyInfo } = mintInfoResp as Record; + server.use(http.get(mintUrl + '/v1/info', () => HttpResponse.json(noPubkeyInfo))); + const wallet = new Wallet(mint, { unit }); + await wallet.loadMint(); + + await expect(wallet.getMintQuotesByPubkey(PRIVKEY)).rejects.toThrow( + 'Mint does not publish a usable pubkey', + ); + }); +}); From e4a2757ac8a9ecf8b88f34f2904db815429d9247 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Mon, 13 Jul 2026 19:06:34 +0100 Subject: [PATCH 5/7] docs(usage): mint quote lookup recipe; update api report --- docs-src/usage/derive_keys.md | 13 +++++++++++++ etc/cashu-ts.api.md | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/docs-src/usage/derive_keys.md b/docs-src/usage/derive_keys.md index 81a4aaec2..5bb69f2f7 100644 --- a/docs-src/usage/derive_keys.md +++ b/docs-src/usage/derive_keys.md @@ -48,6 +48,19 @@ const quote = await wallet.createLockedMintQuote(64, pubkey); const proofs = await wallet.ops.mint(64, quote).privkey(privkey).run(); ``` +## Find locked quotes by pubkey (experimental) + +Mints implementing the draft quote-lookup NUT can return every NUT-20 locked mint quote for +your keys, which pairs well with restore scans: + +```typescript +const quotes = await wallet.getMintQuotesByPubkey(privkey); // or [privkeyA, privkeyB] +``` + +The wallet signs the lookup with each key against the mint's info pubkey; the mint returns the +quotes locked to the corresponding pubkeys. Quotes may span payment methods, so check each +quote's `method` field. + ## Counters A counter only tells you _how many_ keys exist, never _what each was for_, so this library does diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index e7e3a10a8..9e0013751 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -1021,6 +1021,13 @@ export class Mint { getKeys(keysetId?: string, mintUrl?: string, customRequest?: RequestFn): Promise; getKeySets(customRequest?: RequestFn): Promise; getLazyMintInfo(customRequest?: RequestFn): Promise; + getMintQuotesByPubkey(method: string, payload: { + pubkeys: string[]; + pubkey_signatures: string[]; + }, options?: { + customRequest?: RequestFn; + normalize?: (raw: Record) => TRes; + }): Promise; get lastResponseMetadata(): ResponseMeta | undefined; melt = Record>(method: string, meltPayload: MeltRequest, options?: { customRequest?: RequestFn; @@ -2047,6 +2054,9 @@ export const SigFlags: { // @public (undocumented) export function signMintQuote(privkey: string, quote: string, blindedMessages: SerializedBlindedMessage[]): string; +// @public +export function signMintQuoteLookup(privkey: string, mintPubkey: string, pubkey: string): string; + // @public export function signP2PKProof(proof: Proof, privateKey: PrivKey, message?: string): Proof; @@ -2180,6 +2190,9 @@ export function verifyHTLCHash(preimage: string, hash: string): boolean; // @public export function verifyHTLCSpendingConditions(proof: Proof, logger?: Logger, message?: string): P2PKVerificationResult; +// @public +export function verifyMintQuoteLookupSignature(pubkey: string, mintPubkey: string, signature: string): boolean; + // @public (undocumented) export function verifyMintQuoteSignature(pubkey: string, quote: string, blindedMessages: SerializedBlindedMessage[], signature: string): boolean; @@ -2267,6 +2280,7 @@ export class Wallet { getFeesForProofs(proofs: Array>): Amount; getKeyset(id?: string): Keyset; getMintInfo(): MintInfo; + getMintQuotesByPubkey(privkey: string | string[], method?: string): Promise; groupProofsByState(proofs: T[]): Promise<{ unspent: T[]; pending: T[]; From 2931423afb031561a3a4bb438d1d94fe7520f5ab Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Mon, 13 Jul 2026 19:14:06 +0100 Subject: [PATCH 6/7] chore(crypto): tag lookup helpers experimental; cover verify-side hex case --- src/crypto/NUT20.ts | 3 +++ test/crypto/NUT20.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/crypto/NUT20.ts b/src/crypto/NUT20.ts index eb71bd5f5..564d45d02 100644 --- a/src/crypto/NUT20.ts +++ b/src/crypto/NUT20.ts @@ -129,6 +129,7 @@ function constructLookupMessage(mintPubkey: string, pubkey: string): string { * * @remarks * `mintPubkey` is the mint's NUT-06 info pubkey; it binds the signature to one mint. + * @experimental Implements a draft NUT; no released mint supports it yet. */ export function signMintQuoteLookup(privkey: string, mintPubkey: string, pubkey: string): string { return schnorrSignMessage(constructLookupMessage(mintPubkey, pubkey), privkey); @@ -136,6 +137,8 @@ export function signMintQuoteLookup(privkey: string, mintPubkey: string, pubkey: /** * Verifies a mint quote lookup signature. Malformed input returns false, never throws. + * + * @experimental Implements a draft NUT; no released mint supports it yet. */ export function verifyMintQuoteLookupSignature( pubkey: string, diff --git a/test/crypto/NUT20.test.ts b/test/crypto/NUT20.test.ts index d13043945..eae9163c7 100644 --- a/test/crypto/NUT20.test.ts +++ b/test/crypto/NUT20.test.ts @@ -326,6 +326,9 @@ describe('mint quote lookup signatures (draft NUT)', () => { test('hex case does not change the message', () => { const signature = signMintQuoteLookup(privkey, mintPubkey.toUpperCase(), pubkey.toUpperCase()); expect(verifyMintQuoteLookupSignature(pubkey, mintPubkey, signature)).toBe(true); + expect( + verifyMintQuoteLookupSignature(pubkey.toUpperCase(), mintPubkey.toUpperCase(), signature), + ).toBe(true); }); test('rejects a signature bound to a different mint pubkey', () => { From 5e6738a7be1c3ab37f820066c6c1a10ca3f62a23 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Mon, 13 Jul 2026 19:58:16 +0100 Subject: [PATCH 7/7] refactor(wallet): move getMintQuotesByPubkey to end of check quote section --- src/wallet/Wallet.ts | 56 ++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/wallet/Wallet.ts b/src/wallet/Wallet.ts index d7672c337..4d2f76d1a 100644 --- a/src/wallet/Wallet.ts +++ b/src/wallet/Wallet.ts @@ -1911,34 +1911,6 @@ class Wallet { return this.mint.checkMintQuoteBolt12(quote); } - /** - * Gets the NUT-20 locked mint quotes owned by the given private key(s). - * - * @remarks - * Signs a lookup per key against the mint's info pubkey. Returned quotes may span payment - * methods; each carries its own `method` field. - * @param privkey Private key(s) whose locked quotes to fetch. - * @param method The payment method path segment. - * @returns Normalized mint quote responses. - * @experimental Implements a draft NUT; no released mint supports it yet. - */ - async getMintQuotesByPubkey( - privkey: string | string[], - method = 'bolt11', - ): Promise { - const mintPubkey = this.getMintInfo().pubkey; - this.failIf( - !mintPubkey || mintPubkey.length !== 66, - 'Mint does not publish a usable pubkey in /v1/info', - ); - const privkeys = Array.isArray(privkey) ? privkey : [privkey]; - const pubkeys = privkeys.map((key) => bytesToHex(getPubKeyFromPrivKey(hexToBytes(key)))); - const pubkey_signatures = privkeys.map((key, i) => - signMintQuoteLookup(key, mintPubkey, pubkeys[i]), - ); - return this.mint.getMintQuotesByPubkey(method, { pubkeys, pubkey_signatures }); - } - /** * Gets an existing onchain mint quote from the mint. * @@ -2011,6 +1983,34 @@ class Wallet { return this.mint.checkMintQuoteBatchBolt12(quoteIds); } + /** + * Gets the NUT-20 locked mint quotes owned by the given private key(s). + * + * @remarks + * Signs a lookup per key against the mint's info pubkey. Returned quotes may span payment + * methods; each carries its own `method` field. + * @param privkey Private key(s) whose locked quotes to fetch. + * @param method The payment method path segment. + * @returns Normalized mint quote responses. + * @experimental Implements a draft NUT; no released mint supports it yet. + */ + async getMintQuotesByPubkey( + privkey: string | string[], + method = 'bolt11', + ): Promise { + const mintPubkey = this.getMintInfo().pubkey; + this.failIf( + !mintPubkey || mintPubkey.length !== 66, + 'Mint does not publish a usable pubkey in /v1/info', + ); + const privkeys = Array.isArray(privkey) ? privkey : [privkey]; + const pubkeys = privkeys.map((key) => bytesToHex(getPubKeyFromPrivKey(hexToBytes(key)))); + const pubkey_signatures = privkeys.map((key, i) => + signMintQuoteLookup(key, mintPubkey, pubkeys[i]), + ); + return this.mint.getMintQuotesByPubkey(method, { pubkeys, pubkey_signatures }); + } + // ----------------------------------------------------------------- // Section: Mint Proofs // -----------------------------------------------------------------