From c7fd763eca5f1ba602495c104925cc364be1d0a9 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Mon, 13 Jul 2026 11:32:29 +0200 Subject: [PATCH] Add NUT-20 quote-signing foundation (Stage 1 of onchain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- __mocks__/react-native-quick-crypto.js | 14 +- __tests__/nut20.test.ts | 335 +++++++++++++++++++++++++ src/services/cashu/nut20.ts | 103 ++++++++ src/services/db/index.ts | 9 + src/services/db/migrations.ts | 11 +- src/services/db/schema.ts | 22 ++ src/services/db/walletCountersRepo.ts | 94 +++++++ 7 files changed, 585 insertions(+), 3 deletions(-) create mode 100644 __tests__/nut20.test.ts create mode 100644 src/services/cashu/nut20.ts create mode 100644 src/services/db/walletCountersRepo.ts diff --git a/__mocks__/react-native-quick-crypto.js b/__mocks__/react-native-quick-crypto.js index 11af160e..f5903bc5 100644 --- a/__mocks__/react-native-quick-crypto.js +++ b/__mocks__/react-native-quick-crypto.js @@ -5,8 +5,20 @@ * (Node environment, no native module) we simply delegate to Node's crypto. * This lets dependencies that load it at import time — e.g. @scure/bip32 via * @cashu/cashu-ts — be required without the native `QuickCrypto` module. + * + * `__esModule` is load-bearing. Our patches to @scure/bip32 and @scure/bip39 + * (see patches/) rewire them onto quick-crypto for native speed, using a DEFAULT + * import: `import quickCrypto from 'react-native-quick-crypto'` and then calling + * `quickCrypto.createHmac(...)` / `.pbkdf2Sync(...)`. Without the `__esModule` + * marker, Babel's interop wraps this CJS module again — the default import then + * resolves to `{default: crypto}` instead of `crypto`, and every call lands on + * `undefined`. With it, interop hands back `default` directly. */ -const crypto = require('crypto') +// `node:` prefix is deliberate: the bare `crypto` specifier resolves to an empty +// shim under the react-native jest preset, which silently yields a module with no +// createHmac/pbkdf2Sync on it. +const crypto = require('node:crypto') module.exports = crypto module.exports.default = crypto +module.exports.__esModule = true diff --git a/__tests__/nut20.test.ts b/__tests__/nut20.test.ts new file mode 100644 index 00000000..632e161b --- /dev/null +++ b/__tests__/nut20.test.ts @@ -0,0 +1,335 @@ +/** + * NUT-20 quote-locking key tests. + * + * Two halves: + * + * 1. DERIVATION (src/services/cashu/nut20.ts) — determinism, key format, a + * pinned regression vector, and a sign/verify round-trip through cashu-ts's + * own NUT-20 primitives. + * + * 2. THE COUNTER (src/services/db/walletCountersRepo.ts) — the burn-forward + * allocation and monotonic set, mirrored against node:sqlite because the + * native driver needs a device (same approach as inFlightRequests.test.ts + * and meltRecovery.test.ts). + * + * The counter is the load-bearing part: handing the same index out twice would + * give two quotes the same pubkey, which lets the mint link them (NUT-20 asks + * for a unique key per quote precisely to prevent that) and makes the signature + * ambiguous. Skipping an index is harmless; reusing one is not. + * + * @jest-environment node + */ +import {DatabaseSync} from 'node:sqlite' +import {Amount, signMintQuote, verifyMintQuoteSignature} from '@cashu/cashu-ts' +import type {SerializedBlindedMessage} from '@cashu/cashu-ts' +import {hexToBytes} from '@noble/curves/utils.js' + +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) + +// nut20.ts pulls in walletCountersRepo -> db/instance -> op-sqlite (native, and +// unavailable here). Stub the repo so the derivation code is importable; the +// real SQL is exercised against node:sqlite further down. +const mockAllocateNextCounter = jest.fn() +jest.mock('../src/services/db/walletCountersRepo', () => ({ + NUT20_COUNTER: 'nut20', + allocateNextCounter: (name: string) => mockAllocateNextCounter(name), +})) + +import { + allocateQuoteKeypair, + deriveQuoteKeypair, + quoteKeyDerivationPath, +} from '../src/services/cashu/nut20' + +/** + * Seed for the standard BIP39 test mnemonic + * ("abandon" x11 + "about", empty passphrase) — the canonical value from the + * BIP39 spec's own test vectors, so it can be checked against the spec rather + * than against us. Pinned as bytes because NUT-20 derives from the seed, not the + * mnemonic; that keeps BIP39 (and its PBKDF2 native dep) out of this test. + */ +const SEED = hexToBytes( + '5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1' + + '9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4', +) + +/** + * Pinned NUT-20 vector for m/129373'/20'/0'/0'/{0,1}. + * + * Computed INDEPENDENTLY (a clean @scure/bip32 install outside this codebase), + * not captured from our own output — so it cross-checks the implementation + * rather than enshrining whatever it happened to produce. + * + * Stage 1 validates derivation by round-trip only; a real mint accepting these + * signatures is not proven until the first onchain mint (Stage 5). Until then + * this vector is what stops the path from silently drifting. If it ever fails, + * the derivation path changed — that would orphan every existing quote, so do + * not "fix" it by updating the constant. + */ +const VECTOR = [ + { + index: 0, + privkey: '26392b1fd4bd70c14f27c30368a896412ce5a44f22a3035ce820f91324c7d49b', + pubkey: '025c820f30db9b7d9479a0337aeed771162e291e9cd711ce8f42b1a188a39d3c9e', + }, + { + index: 1, + privkey: '13dbe6792e239fd6a9b0d96263076e67507aef397453a314a6e68d5fc390cc22', + pubkey: '032acf3ccf5f01638ec9110a997d75797805c1e37f050de814263beceb7d8996a6', + }, +] + +const BLINDED_MESSAGES: SerializedBlindedMessage[] = [ + { + amount: Amount.from(8), + id: '009a1f293253e41e', + B_: '035015e6d7ade60ba8426cefaf1832bbd27257636e44a76b922d78e79b47cb689d', + }, + { + amount: Amount.from(2), + id: '009a1f293253e41e', + B_: '0288d7649652d0a83fc9c966c969fb217f15904431e61a44b14999fabc1b5d9ac6', + }, +] + +const QUOTE_ID = '019e6d5a-2347-7000-8850-39c85ed1b5d3' + +describe('NUT-20 quote key derivation', () => { + beforeEach(() => mockAllocateNextCounter.mockReset()) + + it('uses the NUT-20 path, with a non-hardened counter', () => { + // 129373' = cashu namespace, 20' = NUT-20; the counter is NOT hardened. + expect(quoteKeyDerivationPath(0)).toBe("m/129373'/20'/0'/0'/0") + expect(quoteKeyDerivationPath(42)).toBe("m/129373'/20'/0'/0'/42") + }) + + it('matches the pinned vector (independently computed)', () => { + for (const {index, privkey, pubkey} of VECTOR) { + expect(deriveQuoteKeypair(SEED, index)).toEqual({privkey, pubkey}) + } + }) + + it('is deterministic for a given seed and index', () => { + expect(deriveQuoteKeypair(SEED, 7)).toEqual(deriveQuoteKeypair(SEED, 7)) + }) + + it('gives a distinct key per index', () => { + const keys = [0, 1, 2, 3, 4].map(i => deriveQuoteKeypair(SEED, i).pubkey) + expect(new Set(keys).size).toBe(keys.length) + }) + + it('produces a 32-byte privkey and a 33-byte compressed pubkey', () => { + const {privkey, pubkey} = deriveQuoteKeypair(SEED, 3) + expect(privkey).toMatch(/^[0-9a-f]{64}$/) + expect(pubkey).toMatch(/^0[23][0-9a-f]{64}$/) // compressed: 02/03 prefix + }) + + it('rejects a negative or non-integer index', () => { + expect(() => deriveQuoteKeypair(SEED, -1)).toThrow() + expect(() => deriveQuoteKeypair(SEED, 1.5)).toThrow() + }) +}) + +/** + * Round-trip through cashu-ts's NUT-20 primitives. + * + * These prove the DERIVED KEY is a usable NUT-20 signing key — they do not pin + * the wire format, and deliberately so. cashu-ts 4.7.0 carries TWO mint-quote + * message aggregations: + * + * - the spec's `Cashu_MintQuoteSig_v1` (domain tag, len32-prefixed quote, and + * per-output amount + B_), and + * - a legacy one: sha256(quote || B_0 || B_1 ...), with no domain tag and no + * amounts. + * + * The EXPORTED `signMintQuote` / `verifyMintQuoteSignature` are the LEGACY pair. + * The library's own mint path (prepareMint, and so mintProofsOnchain) signs with + * the spec-v1 aggregation and puts THAT in the request's `signature` field — so + * what Minibits actually sends is spec-correct. The wire format is proven when a + * real mint accepts a signed mint request (Stage 5); here we only need to know + * the key itself signs and verifies. + * + * Consequence: assertions below stick to what the legacy message commits to + * (quote id and B_). It does NOT commit to output amounts, so a tampered amount + * would still verify through this pair — which says nothing about the v1 format + * we actually transmit. + */ +describe('NUT-20 signing round-trip (via cashu-ts)', () => { + it('signs a mint request the matching pubkey verifies', () => { + const {privkey, pubkey} = deriveQuoteKeypair(SEED, 0) + + const signature = signMintQuote(privkey, QUOTE_ID, BLINDED_MESSAGES) + + expect(verifyMintQuoteSignature(pubkey, QUOTE_ID, BLINDED_MESSAGES, signature)).toBe(true) + }) + + it('does not verify under a different quote key', () => { + const {privkey} = deriveQuoteKeypair(SEED, 0) + const {pubkey: otherPubkey} = deriveQuoteKeypair(SEED, 1) + + const signature = signMintQuote(privkey, QUOTE_ID, BLINDED_MESSAGES) + + expect(verifyMintQuoteSignature(otherPubkey, QUOTE_ID, BLINDED_MESSAGES, signature)).toBe( + false, + ) + }) + + it('does not verify against a different quote id', () => { + const {privkey, pubkey} = deriveQuoteKeypair(SEED, 0) + + const signature = signMintQuote(privkey, QUOTE_ID, BLINDED_MESSAGES) + + expect( + verifyMintQuoteSignature(pubkey, 'some-other-quote-id', BLINDED_MESSAGES, signature), + ).toBe(false) + }) + + it('does not verify against tampered outputs (B_ is committed)', () => { + const {privkey, pubkey} = deriveQuoteKeypair(SEED, 0) + + const signature = signMintQuote(privkey, QUOTE_ID, BLINDED_MESSAGES) + const tampered = [ + { + ...BLINDED_MESSAGES[0], + B_: '0288d7649652d0a83fc9c966c969fb217f15904431e61a44b14999fabc1b5d9ac6', + }, + BLINDED_MESSAGES[1], + ] + + expect(verifyMintQuoteSignature(pubkey, QUOTE_ID, tampered, signature)).toBe(false) + }) +}) + +describe('allocateQuoteKeypair', () => { + beforeEach(() => mockAllocateNextCounter.mockReset()) + + it('allocates from the nut20 counter and derives that index', () => { + mockAllocateNextCounter.mockReturnValue(5) + + const result = allocateQuoteKeypair(SEED) + + expect(mockAllocateNextCounter).toHaveBeenCalledWith('nut20') + expect(result).toEqual({index: 5, ...deriveQuoteKeypair(SEED, 5)}) + }) + + it('never repeats a keypair across calls', () => { + mockAllocateNextCounter.mockReturnValueOnce(0).mockReturnValueOnce(1) + + const first = allocateQuoteKeypair(SEED) + const second = allocateQuoteKeypair(SEED) + + expect(first.pubkey).not.toBe(second.pubkey) + }) +}) + +// ── Counter SQL, mirrored against node:sqlite ─────────────────────────────── +// +// Exact production statements from walletCountersRepo. Kept in sync by hand, +// as with the other repo tests. + +const CREATE_WALLET_COUNTERS = `CREATE TABLE wallet_counters ( + name TEXT PRIMARY KEY NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + updatedAt TEXT +)` + +const NOW = '2026-07-13T00:00:00.000Z' + +const allocateNextCounter = (db: DatabaseSync, name: string): number => + ( + db + .prepare( + `INSERT INTO wallet_counters (name, counter, updatedAt) + VALUES (?, 1, ?) + ON CONFLICT(name) DO UPDATE SET + counter = counter + 1, + updatedAt = excluded.updatedAt + RETURNING counter - 1 AS allocated`, + ) + .get(name, NOW) as {allocated: number} + ).allocated + +const getWalletCounter = (db: DatabaseSync, name: string): number => + ((db.prepare(`SELECT counter FROM wallet_counters WHERE name = ?`).get(name) as + | {counter: number} + | undefined)?.counter ?? 0) + +const setWalletCounter = (db: DatabaseSync, name: string, value: number): void => { + db.prepare( + `INSERT INTO wallet_counters (name, counter, updatedAt) + VALUES (?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + counter = MAX(counter, excluded.counter), + updatedAt = excluded.updatedAt`, + ).run(name, value, NOW) +} + +describe('wallet_counters (burn-forward allocation)', () => { + let db: DatabaseSync + + beforeEach(() => { + db = new DatabaseSync(':memory:') + db.exec(CREATE_WALLET_COUNTERS) + }) + + it('starts at index 0 when no row exists', () => { + expect(getWalletCounter(db, 'nut20')).toBe(0) + expect(allocateNextCounter(db, 'nut20')).toBe(0) + }) + + it('hands out consecutive indices and stores the next free one', () => { + const allocated = [0, 1, 2, 3].map(() => allocateNextCounter(db, 'nut20')) + + expect(allocated).toEqual([0, 1, 2, 3]) + expect(getWalletCounter(db, 'nut20')).toBe(4) // next free, not last used + }) + + it('never hands out the same index twice', () => { + const allocated = Array.from({length: 50}, () => allocateNextCounter(db, 'nut20')) + + expect(new Set(allocated).size).toBe(allocated.length) + }) + + it('burns the index when the caller fails: a retry gets a NEW one', () => { + // The whole point of committing before the network call. Quote request + // dies -> index 0 is spent, never recycled. + const burned = allocateNextCounter(db, 'nut20') + const afterRetry = allocateNextCounter(db, 'nut20') + + expect(burned).toBe(0) + expect(afterRetry).toBe(1) + expect(afterRetry).not.toBe(burned) + }) + + it('keeps counters independent per purpose name', () => { + expect(allocateNextCounter(db, 'nut20')).toBe(0) + expect(allocateNextCounter(db, 'other')).toBe(0) + expect(allocateNextCounter(db, 'nut20')).toBe(1) + + expect(getWalletCounter(db, 'nut20')).toBe(2) + expect(getWalletCounter(db, 'other')).toBe(1) + }) + + it('setWalletCounter is monotonic: a lower value is a no-op', () => { + setWalletCounter(db, 'nut20', 10) + expect(getWalletCounter(db, 'nut20')).toBe(10) + + setWalletCounter(db, 'nut20', 3) // stale writer + expect(getWalletCounter(db, 'nut20')).toBe(10) + + setWalletCounter(db, 'nut20', 12) + expect(getWalletCounter(db, 'nut20')).toBe(12) + }) + + it('cannot walk back onto an index already handed out', () => { + const first = allocateNextCounter(db, 'nut20') // 0 + allocateNextCounter(db, 'nut20') // 1 + + setWalletCounter(db, 'nut20', 0) // stale/replayed write + + expect(allocateNextCounter(db, 'nut20')).toBe(2) + expect(allocateNextCounter(db, 'nut20')).not.toBe(first) + }) +}) diff --git a/src/services/cashu/nut20.ts b/src/services/cashu/nut20.ts new file mode 100644 index 00000000..f1c65b7c --- /dev/null +++ b/src/services/cashu/nut20.ts @@ -0,0 +1,103 @@ +/** + * 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)} +} diff --git a/src/services/db/index.ts b/src/services/db/index.ts index 448c597e..565c9ba2 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -65,6 +65,11 @@ import { removeInFlightRequest, seedInFlightRequests, } from './inFlightRepo' +import { + allocateNextCounter, + getWalletCounter, + setWalletCounter, +} from './walletCountersRepo' export type {TransactionSearchFilters} from './transactionsRepo' export type { @@ -73,6 +78,7 @@ export type { ReservationTransactionUpdate, } from './reservationsRepo' export type {CounterRecord, CounterSeed} from './countersRepo' +export {NUT20_COUNTER} from './walletCountersRepo' export type {MeltRecoveryRecord, MeltRecoverySeed} from './meltRecoveryRepo' export type {InFlightRequestRecord, InFlightRequestSeed} from './inFlightRepo' @@ -126,4 +132,7 @@ export const Database = { getInFlightRequestsByMint, removeInFlightRequest, seedInFlightRequests, + allocateNextCounter, + getWalletCounter, + setWalletCounter, } diff --git a/src/services/db/migrations.ts b/src/services/db/migrations.ts index 66613f6d..3d4b5de9 100644 --- a/src/services/db/migrations.ts +++ b/src/services/db/migrations.ts @@ -1,10 +1,10 @@ import {DbConnection, SQLBatchTuple} from './connection' -import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS} from './schema' +import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS, WALLET_COUNTERS_COLUMNS} from './schema' import {dbError} from './errors' import {log} from '../logService' /** Bump this when a schema change requires a migration, then add an entry below. */ -export const _dbVersion = 29 +export const _dbVersion = 30 type Migration = {version: number; queries: SQLBatchTuple[]} @@ -93,6 +93,13 @@ const MIGRATIONS: Migration[] = [ version: 29, queries: [[createTable('inflight_requests', INFLIGHT_REQUESTS_COLUMNS)]], }, + { + // Add wallet-global derivation counters (NUT-20 quote-locking keys). + // No seed: no NUT-20 quote has ever been created, so an absent row (== 0, + // the first free index) is correct for both new and upgrading wallets. + version: 30, + queries: [[createTable('wallet_counters', WALLET_COUNTERS_COLUMNS)]], + }, ] /** diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index e9a0fb76..32c32d56 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -128,6 +128,25 @@ export const INFLIGHT_REQUESTS_COLUMNS = ` createdAt TEXT ` +/** + * Wallet-global deterministic-derivation counters, keyed by purpose name. + * + * Distinct from `mint_counters`, which is keyed by (mintUrl, keysetId) because + * NUT-13 keyset counters are mint-scoped. The counters here belong to derivation + * paths that have NO mint or keyset component, so a single value serves the whole + * wallet. The first is NUT-20 quote-locking (`m/129373'/20'/0'/0'/{counter}`); + * the table is keyed by name so future wallet-global counters need no migration. + * + * `counter` is the NEXT FREE index (a high-water mark), matching the semantics of + * `mint_counters` (which stores cashu-ts's `next`). Allocation increments and + * returns the previous value; see walletCountersRepo. + */ +export const WALLET_COUNTERS_COLUMNS = ` + name TEXT PRIMARY KEY NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + updatedAt TEXT +` + /** Build a CREATE TABLE statement from a column block. */ export const createTable = ( name: string, @@ -157,4 +176,7 @@ export const createSchemaQueries: SQLBatchTuple[] = [ // Per-transaction in-flight mint/swap request data for idempotent retry // (see inFlightRepo). A row exists only while a request is in-flight. [createTable('inflight_requests', INFLIGHT_REQUESTS_COLUMNS)], + // Wallet-global derivation counters keyed by purpose (see walletCountersRepo). + // First user: NUT-20 quote-locking keys. + [createTable('wallet_counters', WALLET_COUNTERS_COLUMNS)], ] diff --git a/src/services/db/walletCountersRepo.ts b/src/services/db/walletCountersRepo.ts new file mode 100644 index 00000000..197f038b --- /dev/null +++ b/src/services/db/walletCountersRepo.ts @@ -0,0 +1,94 @@ +import {getInstance} from './instance' +import {dbError} from './errors' + +// ───────────────────────────────────────────────────────────────────────────── +// Wallet-global deterministic-derivation counters, keyed by purpose name. +// +// Sibling of countersRepo, which owns the per-(mint, keyset) NUT-13 counters. +// The counters here belong to derivation paths with NO mint or keyset component, +// so one value serves the whole wallet. First user: NUT-20 quote-locking keys +// (`m/129373'/20'/0'/0'/{counter}`). +// +// The stored `counter` is the NEXT FREE index. Allocation is BURN-FORWARD: the +// increment is committed BEFORE the caller uses the index, so a failed mint call +// or a crash can only ever SKIP an index, never hand the same one out twice. +// That direction matters — two NUT-20 quotes sharing a pubkey would let the mint +// link them (defeating the point of a per-quote key) and make signatures +// ambiguous. Gaps are harmless; a future recovery scan handles them with a gap +// limit. +// ───────────────────────────────────────────────────────────────────────────── + +/** Purpose name for the NUT-20 quote-locking counter. */ +export const NUT20_COUNTER = 'nut20' + +/** + * Allocate the next free index for `name` and COMMIT it before returning. + * + * One atomic statement, so concurrent allocators (foreground and the NWC + * background task) can never receive the same index. `RETURNING` needs SQLite + * >= 3.35; op-sqlite 16.x bundles 3.51. + * + * The row starts at counter=1 on first insert and the statement returns + * `counter - 1`, so the first index handed out is 0 and the stored value is + * always the next free one. + */ +export const allocateNextCounter = function (name: string): number { + try { + const db = getInstance() + const {rows} = db.execute( + `INSERT INTO wallet_counters (name, counter, updatedAt) + VALUES (?, 1, ?) + ON CONFLICT(name) DO UPDATE SET + counter = counter + 1, + updatedAt = excluded.updatedAt + RETURNING counter - 1 AS allocated`, + [name, new Date().toISOString()], + ) + + const allocated = (rows?.item(0) as {allocated: number} | undefined)?.allocated + + if (typeof allocated !== 'number') { + throw new Error('Allocation returned no index') + } + + return allocated + } catch (e: any) { + throw dbError('Derivation counter could not be allocated in the database', e) + } +} + +/** Next free index for `name`; 0 when no row exists yet. */ +export const getWalletCounter = function (name: string): number { + try { + const db = getInstance() + const {rows} = db.execute(`SELECT counter FROM wallet_counters WHERE name = ?`, [name]) + return (rows?.item(0) as {counter: number} | undefined)?.counter ?? 0 + } catch (e: any) { + throw dbError('Derivation counter could not be retrieved from the database', e) + } +} + +/** + * Set a counter to an absolute value, MONOTONICALLY: the stored value only ever + * rises to `MAX(existing, value)`, mirroring countersRepo.setCounter. A lower + * value (stale writer, replayed op) is silently ignored, so this can never walk + * the wallet back onto an index it has already handed out. + * + * For healing and for a future recovery scan that discovers used indices beyond + * the local high-water mark. + */ +export const setWalletCounter = function (name: string, value: number): void { + try { + const db = getInstance() + db.execute( + `INSERT INTO wallet_counters (name, counter, updatedAt) + VALUES (?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + counter = MAX(counter, excluded.counter), + updatedAt = excluded.updatedAt`, + [name, value, new Date().toISOString()], + ) + } catch (e: any) { + throw dbError('Derivation counter could not be saved to the database', e) + } +}