Files
minibits_wallet/src/services/cashu/nut20.ts
T
minibits-cashandClaude Opus 4.8 c7fd763eca Add NUT-20 quote-signing foundation (Stage 1 of onchain)
NUT-30 onchain mint quotes MUST be locked to a pubkey (the mint rejects
them with 20009 otherwise), so onchain needs NUT-20 quote signing. This
is net-new: bolt11 topup does not sign quotes today.

Adds the two pieces, with no production caller yet — Stage 5 (onchain
receive) is the first consumer:

  wallet_counters (new table, migration v30)
    NUT-20's path m/129373'/20'/0'/0'/{counter} has no mint or keyset
    component, so unlike mint_counters this counter is wallet-global.
    Keyed by purpose name so future wallet-global counters need no
    migration. No seed: no NUT-20 quote has ever existed, so an absent
    row (index 0) is correct for new and upgrading wallets alike.

  walletCountersRepo
    Allocation is BURN-FORWARD: one atomic INSERT..ON CONFLICT..RETURNING
    commits the index before the caller uses it, so a failed quote request
    or a crash can only SKIP an index, never hand the same one out twice.
    Reuse is what we cannot allow — two quotes sharing a pubkey lets the
    mint link them (NUT-20 asks for a unique key per quote precisely to
    prevent that) and makes the signature ambiguous. Being atomic, it is
    also safe against concurrent foreground/NWC-background allocation.

  services/cashu/nut20.ts
    deriveQuoteKeypair() is pure (seed in, keypair out) so it works from
    the off-MST background paths that will need to sign. Only the integer
    index is ever persisted; the privkey is re-derived from the keychain
    seed at mint time, which may be days later once an onchain deposit
    confirms. No key material lands in SQLite.

Also fixes the react-native-quick-crypto jest mock, which claimed to make
bip32 work but did not: it required the bare 'crypto' specifier (an empty
shim under the RN preset) and lacked __esModule, so Babel's interop double-
wrapped it and every quickCrypto.createHmac/.pbkdf2Sync call resolved to
undefined. Our patches to @scure/bip32 and @scure/bip39 route through
quick-crypto, so nothing that derives keys could be tested until now.

Tests pin the derivation against a vector computed independently of this
codebase (a clean @scure/bip32 install), so it cross-checks the path rather
than enshrining our own output. Note the exported signMintQuote /
verifyMintQuoteSignature in cashu-ts are the LEGACY aggregation (no domain
tag, no amounts); the library's own mint path signs with the spec's
Cashu_MintQuoteSig_v1, so what we transmit is spec-correct. The wire format
is proven when a real mint accepts a signed request, in Stage 5.

209/209 tests pass; typecheck introduces no new errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:32:29 +02:00

104 lines
4.1 KiB
TypeScript

/**
* NUT-20 quote-locking keys.
*
* A NUT-20 mint quote is locked to a public key: the mint will only issue ecash
* against it after seeing a signature from the matching private key. Onchain
* (NUT-30) makes this MANDATORY — the mint refuses to issue an onchain mint quote
* without a `pubkey` (error 20009).
*
* Keys are derived deterministically from the wallet seed, so the key needed to
* sign for a quote can always be re-derived — including days later, after an
* onchain deposit finally confirms, and (in future) after a seed restore.
*
* NUT-20 derivation path:
*
* m/129373'/20'/0'/0'/{counter}
*
* where 129373' is the Cashu namespace, 20' the NUT-20 index, and {counter} an
* incrementing NON-hardened child index. The path has no mint or keyset
* component, so the counter is wallet-global — see walletCountersRepo, which owns
* it (mint_counters is for the mint-scoped NUT-13 counters and is unrelated).
*
* The spec asks for a UNIQUE key per quote, so the mint cannot link a wallet's
* quotes to each other. `allocateQuoteKeypair` is the only thing call sites
* should use: it burns the index before handing it back, so an index can never
* be issued twice.
*
* Signing itself is cashu-ts's job (`signMintQuote`), which implements the
* `Cashu_MintQuoteSig_v1` message aggregation and BIP340 Schnorr signature. We
* only supply the key.
*/
import {HDKey} from '@scure/bip32'
// `.js` suffix: the bare '@noble/hashes/utils' specifier used elsewhere in this
// codebase does not resolve under tsc (the package's exports map only names the
// suffixed path). Both forms work at runtime; this one also typechecks.
import {bytesToHex} from '@noble/hashes/utils.js'
import {allocateNextCounter, NUT20_COUNTER} from '../db/walletCountersRepo'
import AppError, {Err} from '../../utils/AppError'
export type QuoteKeypair = {
/** 32-byte private key, hex. Never persisted — re-derived from the seed. */
privkey: string
/** 33-byte compressed secp256k1 public key, hex. Sent to the mint. */
pubkey: string
}
/** BIP32 path for the NUT-20 quote-locking key at `index`. */
export const quoteKeyDerivationPath = (index: number): string =>
`m/129373'/20'/0'/0'/${index}`
/**
* Derive the NUT-20 quote-locking keypair at `index` from the wallet seed.
*
* Pure: no database, no MST, no keychain — the caller supplies the seed. That
* keeps it trivially testable and usable from the off-MST background paths that
* will need to sign mint requests.
*/
export const deriveQuoteKeypair = function (
seed: Uint8Array,
index: number,
): QuoteKeypair {
if (!Number.isInteger(index) || index < 0) {
throw new AppError(
Err.VALIDATION_ERROR,
'NUT-20 quote key index must be a non-negative integer',
{index, caller: 'deriveQuoteKeypair'},
)
}
const derived = HDKey.fromMasterSeed(seed).derive(quoteKeyDerivationPath(index))
if (!derived.privateKey || !derived.publicKey) {
throw new AppError(
Err.VALIDATION_ERROR,
'NUT-20 quote key derivation produced no key material',
{index, caller: 'deriveQuoteKeypair'},
)
}
return {
privkey: bytesToHex(derived.privateKey),
pubkey: bytesToHex(derived.publicKey),
}
}
/**
* Allocate a fresh index and derive its keypair — the entry point call sites use.
*
* The index is COMMITTED to the database before this returns, so if the caller's
* mint-quote request then fails (or the app dies mid-request), the index is
* simply skipped and never handed out again. Reuse is the thing we cannot allow:
* two quotes sharing a pubkey would let the mint link them and would make the
* NUT-20 signature ambiguous. Skipped indices are harmless.
*
* The returned `index` MUST be persisted alongside the quote — it is the only
* thing that lets the wallet re-derive the private key to sign the mint request
* later.
*/
export const allocateQuoteKeypair = function (
seed: Uint8Array,
): QuoteKeypair & {index: number} {
const index = allocateNextCounter(NUT20_COUNTER)
return {index, ...deriveQuoteKeypair(seed, index)}
}