Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs-src/usage/derive_keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions etc/cashu-ts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,13 @@ export class Mint {
getKeys(keysetId?: string, mintUrl?: string, customRequest?: RequestFn): Promise<GetKeysResponse>;
getKeySets(customRequest?: RequestFn): Promise<GetKeysetsResponse>;
getLazyMintInfo(customRequest?: RequestFn): Promise<MintInfo>;
getMintQuotesByPubkey<TRes extends MintQuoteBaseResponse = MintQuoteBaseResponse>(method: string, payload: {
pubkeys: string[];
pubkey_signatures: string[];
}, options?: {
customRequest?: RequestFn;
normalize?: (raw: Record<string, unknown>) => TRes;
}): Promise<TRes[]>;
get lastResponseMetadata(): ResponseMeta | undefined;
melt<TRes extends Record<string, unknown> = Record<string, unknown>>(method: string, meltPayload: MeltRequest, options?: {
customRequest?: RequestFn;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -2267,6 +2280,7 @@ export class Wallet {
getFeesForProofs(proofs: Array<Pick<Proof, 'id'>>): Amount;
getKeyset(id?: string): Keyset;
getMintInfo(): MintInfo;
getMintQuotesByPubkey(privkey: string | string[], method?: string): Promise<MintQuoteBaseResponse[]>;
groupProofsByState<T extends ProofLike = Proof>(proofs: T[]): Promise<{
unspent: T[];
pending: T[];
Expand Down
45 changes: 44 additions & 1 deletion src/crypto/NUT20.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -110,3 +115,41 @@ 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.
* @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);
}

/**
* 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,
mintPubkey: string,
signature: string,
): boolean {
if (!isCompressedPubkey(pubkey)) return false;
// See verifyMintQuoteSignature: malformed input must verify as false rather than throw.
try {
return schnorrVerifyMessage(signature, constructLookupMessage(mintPubkey, pubkey), pubkey);
} catch {
return false;
}
}
7 changes: 6 additions & 1 deletion src/crypto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
63 changes: 63 additions & 0 deletions src/mint/Mint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TRes extends MintQuoteBaseResponse = MintQuoteBaseResponse>(
method: string,
payload: { pubkeys: string[]; pubkey_signatures: string[] },
options?: { customRequest?: RequestFn; normalize?: (raw: Record<string, unknown>) => TRes },
): Promise<TRes[]> {
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<string, unknown>;
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
// -----------------------------------------------------------------
Expand Down
32 changes: 32 additions & 0 deletions src/wallet/Wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1979,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<MintQuoteBaseResponse[]> {
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
// -----------------------------------------------------------------
Expand Down
76 changes: 74 additions & 2 deletions test/crypto/NUT20.test.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -294,3 +299,70 @@ 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);
expect(
verifyMintQuoteLookupSignature(pubkey.toUpperCase(), mintPubkey.toUpperCase(), 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);
});

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,
);
});
});
Loading
Loading