mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
13d09ebcef
commit
c7fd763eca
+13
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)]],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)],
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user