Files
minibits_wallet/__tests__/onchainQuotes.test.ts
T
minibits-cashandClaude Opus 4.8 73610e4db8 Run the db tests against the real code, not copies of it
The db suites hand-copied both the schema and each repo's SQL, then asserted
against the copy. That proves nothing about the code the app runs, and it drifted
six times across this branch while staying green — counters.test.ts was still
asserting the OLD (mintUrl, keysetId) key long after it was gone, and
transactionsMintUrl.test.ts kept passing while testing a function that had been
deleted.

Rather than make the copies track production, this removes them.

The op-sqlite jest mock now backs connection.ts's driver seam with node:sqlite.
connection.ts documents itself as "the single seam between the rest of the app
and the native SQLite library", so mocking exactly there means tests run the
production path end to end: real connection.ts (param sanitizing, result
adaptation, BEGIN/COMMIT batch emulation), real instance.ts (schema creation AND
the real migration runner), real repos. Net -418 lines, and no production code
changed.

- counters, proofReservation, onchainQuotes, meltRecovery, inFlightRequests and
  nut20's counter half now call Database.* directly. No copied DDL, no copied SQL.
- The migration suites import their SQL from the MIGRATIONS registry. Their
  PRE-migration shapes stay hand-written ON PURPOSE — those are frozen history and
  must never track today's schema, which is the whole reason v26/v28/v29/v31 froze
  their column lists. That distinction is now stated in each file so the next
  person does not "helpfully" point them at schema.ts and reintroduce the replay
  bug.

It found a bug on contact: walletCountersRepo allocates an index with a single
`INSERT … ON CONFLICT DO UPDATE … RETURNING`. The mock routed statements by
leading keyword, sent it down .run(), and the allocation failed — exactly the
point, since the mirror had RE-IMPLEMENTED that statement rather than running it
and so could never exercise RETURNING.

Two smaller gains from using the real path: counters' rollback test now trips the
real sanitizeParams guard instead of a hand-rolled NOT NULL violation, and
proofReservation locks real MST Proof nodes, because the repo calls isAlive() —
which only answers for an actual node, so plain objects never took production's
path.

Limits, recorded in the mock: Node's SQLite is not op-sqlite's build (version and
compile flags may differ), so this proves our SQL and our logic, not the exact
native binary — device testing still owns that. And each test FILE shares one
in-memory database (instance.ts caches its connection), so suites clear tables in
beforeEach rather than rebuilding.

Tests: 441 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:13:36 +02:00

212 lines
7.9 KiB
TypeScript

/**
* Onchain (NUT-30) mint quotes, against the REAL repo and a real database.
*
* The watch rule is what these tests are really about. Two facts make it subtle:
*
* - the mint returns `expiry: null`, so a deposit address never dies and nothing
* server-side bounds how long we poll a quote nobody paid;
* - a quote can be paid MORE than once, so "have we minted yet" is the wrong
* question — "is there money credited that we have not minted" is the right one.
*
* Getting this wrong loses money in one direction (abandon a quote holding funds)
* or polls forever in the other.
*
* Calls the production `Database.*` functions rather than mirroring their SQL: the
* op-sqlite jest mock backs the real driver seam with node:sqlite. Dates are
* anchored to REAL now, because the real watch query compares against `new Date()`.
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {Database} from '../src/services/db'
const MINT = 'https://mint.test'
const MINT_ID = 'mint1111'
const daysFromNow = (n: number) => new Date(Date.now() + n * 86400000).toISOString()
const addQuote = (q: {
quote: string
counterIndex: number
amountRequested?: number | null
amountPaid?: number
amountIssued?: number
watchUntil?: string
mintId?: string
}) =>
Database.addOnchainMintQuote({
quote: q.quote,
mintId: q.mintId ?? MINT_ID,
mintUrl: MINT,
unit: 'sat',
address: 'bc1q' + q.quote,
counterIndex: q.counterIndex,
pubkey: '02' + 'a'.repeat(64),
amountRequested: q.amountRequested ?? null,
amountPaid: q.amountPaid,
amountIssued: q.amountIssued,
expiry: null, // the mint returns null — the address never dies
watchUntil: q.watchUntil,
})
const watchedIds = () =>
Database.getWatchedOnchainMintQuotes()
.map(r => r.quote)
.sort()
beforeEach(() => {
Database.getInstance().executeBatch([['DELETE FROM onchain_mint_quotes']])
})
describe('onchain mint quotes', () => {
describe('the watch rule', () => {
it('watches a fresh unpaid quote (window still open)', () => {
addQuote({quote: 'q1', counterIndex: 0})
expect(watchedIds()).toEqual(['q1'])
})
it('stops watching an unpaid quote once the window closes', () => {
// nothing ever arrived, and the mint will never expire the address for us —
// so the wallet has to draw the line itself
addQuote({quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)})
expect(watchedIds()).toEqual([])
})
it('watches a quote with credited-but-unminted funds', () => {
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 0})
expect(watchedIds()).toEqual(['q1'])
})
it('stops watching once the quote is fully drained (the "stop after first mint" case)', () => {
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
expect(watchedIds()).toEqual([])
})
it('KEEPS watching a PARTIALLY drained quote — money is still credited', () => {
// The reason the rule keys on the unminted BALANCE and not on "has ever
// minted". Keying on amountIssued > 0 would archive this and abandon 20k sat
// the mint is holding for us.
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 70000, amountIssued: 50000})
expect(watchedIds()).toEqual(['q1'])
})
it('watches credited funds even after the window has closed', () => {
// the deadline bounds quotes nobody paid; it must never override money that
// is actually sitting at the mint
addQuote({
quote: 'q1',
counterIndex: 0,
amountPaid: 50000,
amountIssued: 0,
watchUntil: daysFromNow(-30),
})
expect(watchedIds()).toEqual(['q1'])
})
it('separates watched from archived across a realistic mix', () => {
addQuote({quote: 'fresh', counterIndex: 0})
addQuote({quote: 'drained', counterIndex: 1, amountPaid: 10000, amountIssued: 10000})
addQuote({quote: 'unpaid-expired', counterIndex: 2, watchUntil: daysFromNow(-1)})
addQuote({quote: 'has-funds', counterIndex: 3, amountPaid: 30000, amountIssued: 0})
addQuote({quote: 'partly-drained', counterIndex: 4, amountPaid: 30000, amountIssued: 10000})
expect(watchedIds()).toEqual(['fresh', 'has-funds', 'partly-drained'])
})
})
describe('amount updates are monotonic', () => {
it('applies a normal forward update', () => {
addQuote({quote: 'q1', counterIndex: 0})
Database.updateOnchainMintQuoteAmounts('q1', 50000, 0)
expect(Database.getOnchainMintQuote('q1')).toMatchObject({amountPaid: 50000, amountIssued: 0})
})
it('ignores a stale response that would walk amounts BACKWARDS', () => {
// A slow reply landing after a fresh one must not resurrect an old view. If
// amountIssued regressed, the wallet would see unminted money that is not
// there — and mint it a second time.
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
Database.updateOnchainMintQuoteAmounts('q1', 50000, 0) // stale: "nothing issued yet"
expect(Database.getOnchainMintQuote('q1')).toMatchObject({
amountPaid: 50000,
amountIssued: 50000,
})
expect(watchedIds()).toEqual([]) // still archived, not resurrected
})
it('records a second deposit arriving on the same address', () => {
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
expect(watchedIds()).toEqual([])
Database.updateOnchainMintQuoteAmounts('q1', 70000, 50000) // paid again
expect(watchedIds()).toEqual(['q1']) // unminted balance -> back in the watch set
})
})
describe('archived quotes stay recoverable', () => {
it('keeps the row, and with it the NUT-20 counterIndex', () => {
// counterIndex is the only way to re-derive the key that signs a mint request
// for this quote. Delete the row and a late deposit becomes permanently
// unspendable.
addQuote({quote: 'q1', counterIndex: 42, amountPaid: 10000, amountIssued: 10000})
expect(watchedIds()).toEqual([]) // archived
expect(Database.getOnchainMintQuote('q1')).toMatchObject({
counterIndex: 42,
address: 'bc1qq1',
})
})
it('re-opens the watch window on user request', () => {
addQuote({quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)})
expect(watchedIds()).toEqual([])
Database.extendOnchainMintQuoteWatch('q1', 7) // "check for deposits"
expect(watchedIds()).toEqual(['q1'])
})
})
describe('the requested amount is only a hint', () => {
it('keeps amountRequested separate from what was actually paid', () => {
// The BIP21 amount is a suggestion the sender can ignore. Under- and
// overpayment are normal, so the two must never be conflated.
addQuote({quote: 'q1', counterIndex: 0, amountRequested: 50000})
Database.updateOnchainMintQuoteAmounts('q1', 42000, 0) // sender underpaid
expect(Database.getOnchainMintQuote('q1')).toMatchObject({
amountRequested: 50000,
amountPaid: 42000,
})
})
it('allows a null requested amount', () => {
addQuote({quote: 'q1', counterIndex: 0, amountRequested: null})
expect(Database.getOnchainMintQuote('q1')!.amountRequested).toBeNull()
})
})
describe('the mint reference', () => {
it('finds a mint\'s quotes by id, not by the url they were created at', () => {
addQuote({quote: 'q1', counterIndex: 0})
addQuote({quote: 'q2', counterIndex: 1, mintId: 'other-mint'})
expect(Database.getOnchainMintQuotesByMintId(MINT_ID).map(r => r.quote)).toEqual(['q1'])
})
it('keeps mintUrl as the record of where the quote was created', () => {
addQuote({quote: 'q1', counterIndex: 0})
const row = Database.getOnchainMintQuote('q1')!
expect(row.mintId).toBe(MINT_ID)
expect(row.mintUrl).toBe(MINT)
})
})
})