Reference mints by stable id, not by url

Completes the mint-identity work. A mint url is a network locator and mints
move, but the url had become the de facto foreign key for nearly every
persisted row, so a url edit had to fan out across the schema and mostly did
not. Each table now references the mint by an identity that a move cannot
disturb, and setMintUrl shrinks to the two things that genuinely hold a url:
Mint.mintUrl and the proofs.mintUrl cache.

Which identity, per table

mintId (Mint.id) where the row needs the mint itself:

- onchain_mint_quotes. The critical one: a quote's address stays creditable for
  as long as the mint exists (rows are never deleted), so the reference has to
  outlive a move. It followed row.mintUrl, and the watcher swallows errors by
  design — a renamed mint stranded deposits permanently and silently.
- reservations. A url edit racing an open send did not merely misfile the
  proofs; commitReservation resolved the mint by url, so it threw "Mint not
  found" and aborted the commit of an operation the mint had already performed.
- transactions. `mint` was two things at once, switched by status: a historical
  record of where a finished payment happened, AND a live pointer dialled for an
  open one. That conflation is why a rename had to rewrite in-flight rows —
  rewriting the very column that records the past. mintId takes the identity
  job, so `mint` is frozen as history and the status-scoped rewrite is retired.

No reference at all where the row already has a parent:

- inflight_requests, melt_recovery are CHILD rows of a transaction (their
  primary key IS transactionId), so the parent owns "which mint". Their mintUrl
  and keysetId copies had ZERO readers — the keyset that is used comes from
  inside meltPreview. Both dropped; the one mint-scoped query joins through
  transactions.mintId. Nothing left to go stale.

Mint.id gets referential authority only, never identity authority: findById
answers "which mint is this row about?" and must never answer "are these the
same mint?" — it is random and unrelated to the keys, so that question stays
with the keysets. The backfills are IS NULL-guarded so a resolved row can never
be re-pointed at whichever mint now answers an old url.

Backfills run from JS (v38 seed), not SQL: mints live in the MST/MMKV snapshot,
so nothing in SQL can map url -> id. Matching on url is trustworthy at exactly
that moment and no other — until now a url could not change without these rows
being rewritten to match. The join is spent once, at rest, instead of on every
rename.

Migration-system fixes found along the way

- _dbVersion is now DERIVED from the migration list. It was a hand-maintained
  literal, and it was already wrong: it said 33 while migration 34 existed, so
  34 would never have run. The failure is silent and asymmetric — fresh installs
  build from schema.ts and are fine, while upgrading devices land on a schema
  the code does not have. dbMigrationRegistry.test.ts pins this and the ordering
  invariants.
- Migrations 26/28/29/31 built their tables from the LIVE schema constants. A
  device replaying them would get today's shape, and the later ALTER adding the
  column would fail with "duplicate column name" — breaking upgrades from
  exactly the versions those migrations serve. Historical shapes are frozen
  locally now, and the schema.ts header no longer recommends the sharing.
- rootStoreModelVersion was left at 37 while the seed guarded on < 38, so the
  backfill would have re-run on every launch forever.

Tests: 421 pass. Six hand-mirrored suites had drifted from the schema they
claim to mirror; transactionsMintUrl.test.ts was worse — still passing while
testing a function this commit deletes, so it is removed. The mirroring pattern
is worth revisiting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
minibits-cash
2026-07-16 21:41:00 +02:00
co-authored by Claude Opus 4.8
parent 9d759f83ab
commit 2f7a2763f2
41 changed files with 1468 additions and 486 deletions
+47
View File
@@ -0,0 +1,47 @@
/**
* Invariants of the migration registry itself (services/db/migrations.ts).
*
* These guard the wiring rather than any one migration's SQL. The failure they
* exist for is silent and asymmetric: a migration whose version is not below
* `_dbVersion` never runs (the runner applies only `currentVersion <
* migration.version`, then stores `_dbVersion`), so upgrading devices end up on a
* schema the code does not have and fail later with a missing column — while a
* FRESH install is perfectly fine, because it builds from schema.ts and skips
* migrations entirely. That asymmetry is what makes it easy to ship.
*
* migrations.ts imports only a type from ./connection (elided at runtime), so it
* loads without the native op-sqlite module.
*
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {_dbVersion, MIGRATIONS} from '../src/services/db/migrations'
describe('migration registry', () => {
test('_dbVersion equals the newest migration version', () => {
const newest = Math.max(...MIGRATIONS.map(m => m.version))
expect(_dbVersion).toBe(newest)
})
test('every migration version is unique', () => {
const versions = MIGRATIONS.map(m => m.version)
expect(new Set(versions).size).toBe(versions.length)
})
test('migrations are in ascending order', () => {
// The runner concatenates matching migrations in array order and runs them as
// one batch, so the array order IS the execution order — a later-versioned
// entry placed earlier would apply out of sequence.
const versions = MIGRATIONS.map(m => m.version)
expect(versions).toEqual([...versions].sort((a, b) => a - b))
})
test('no migration is empty', () => {
for (const m of MIGRATIONS) {
expect(m.queries.length).toBeGreaterThan(0)
}
})
})
+98 -42
View File
@@ -17,41 +17,62 @@ const NOW = '2026-06-05T00:00:00.000Z'
const CREATE_INFLIGHT = `CREATE TABLE inflight_requests (
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
request TEXT NOT NULL,
createdAt TEXT
)`
/**
* The parent. inflight_requests is a CHILD of a transaction (its primary key IS
* transactionId) and holds no mint reference of its own — the mint-scoped query
* joins through here, so the fixture needs it.
*/
const CREATE_TRANSACTIONS = `CREATE TABLE transactions (
id INTEGER PRIMARY KEY NOT NULL,
mintId TEXT,
mint TEXT,
status TEXT
)`
const MINT = 'https://mint.test'
const MINT_ID = 'mint1111'
const OTHER_MINT_ID = 'mint9999'
// ── Mirrored repo primitives (exact production SQL) ─────────────────────────
function addInFlightRequest(
db: DatabaseSync,
transactionId: number,
mintUrl: string | null,
keysetId: string | null,
request: object,
) {
function addTransaction(db: DatabaseSync, id: number, mintId: string | null) {
db.prepare(`INSERT OR REPLACE INTO transactions (id, mintId, mint, status) VALUES (?, ?, ?, 'PENDING')`)
.run(id, mintId, MINT)
}
function addInFlightRequest(db: DatabaseSync, transactionId: number, request: object) {
db.prepare(
`INSERT OR REPLACE INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
VALUES (?, ?, ?, ?, ?)`,
).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW)
`INSERT OR REPLACE INTO inflight_requests (transactionId, request, createdAt)
VALUES (?, ?, ?)`,
).run(transactionId, JSON.stringify(request), NOW)
}
function getInFlightRequest(db: DatabaseSync, transactionId: number) {
const row = db
.prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE transactionId = ?`)
.get(transactionId) as {transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null} | undefined
.prepare(`SELECT transactionId, request, createdAt FROM inflight_requests WHERE transactionId = ?`)
.get(transactionId) as {transactionId: number; request: string; createdAt: string | null} | undefined
if (!row) return undefined
return {...row, request: JSON.parse(row.request)}
}
function getInFlightRequestsByMint(db: DatabaseSync, mintUrl: string) {
/**
* The mint-scoped sweep. Joins through the owning transaction: this table keeps no
* mint reference of its own, so `transactions.mintId` is the single owner of that
* fact — and being an id, it survives the mint changing url.
*/
function getInFlightRequestsByMintId(db: DatabaseSync, mintId: string) {
const rows = db
.prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE mintUrl = ?`)
.all(mintUrl) as Array<{transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null}>
.prepare(
`SELECT r.transactionId, r.request, r.createdAt
FROM inflight_requests r
JOIN transactions t ON t.id = r.transactionId
WHERE t.mintId = ?`,
)
.all(mintId) as Array<{transactionId: number; request: string; createdAt: string | null}>
return rows.map(r => ({...r, request: JSON.parse(r.request)}))
}
@@ -59,23 +80,18 @@ function removeInFlightRequest(db: DatabaseSync, transactionId: number) {
db.prepare(`DELETE FROM inflight_requests WHERE transactionId = ?`).run(transactionId)
}
function seedInFlightRequest(
db: DatabaseSync,
transactionId: number,
mintUrl: string | null,
keysetId: string | null,
request: object,
) {
function seedInFlightRequest(db: DatabaseSync, transactionId: number, request: object) {
db.prepare(
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
VALUES (?, ?, ?, ?, ?)
`INSERT INTO inflight_requests (transactionId, request, createdAt)
VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`,
).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW)
).run(transactionId, JSON.stringify(request), NOW)
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_INFLIGHT)
db.exec(CREATE_TRANSACTIONS)
return db
}
@@ -86,12 +102,11 @@ describe('In-flight requests (inflight_requests)', () => {
const db = freshDb()
const request = {token: 'cashuA...', options: {keysetId: 'k1'}}
addInFlightRequest(db, 101, MINT, 'k1', request)
addTransaction(db, 101, MINT_ID)
addInFlightRequest(db, 101, request)
const rec = getInFlightRequest(db, 101)!
expect(rec.transactionId).toBe(101)
expect(rec.mintUrl).toBe(MINT)
expect(rec.keysetId).toBe('k1')
expect(rec.request).toEqual(request)
db.close()
})
@@ -104,38 +119,79 @@ describe('In-flight requests (inflight_requests)', () => {
test('add OVERWRITES an existing entry (set semantics)', () => {
const db = freshDb()
addInFlightRequest(db, 101, MINT, 'k1', {v: 'first'})
addInFlightRequest(db, 101, MINT, 'k1', {v: 'second'})
addTransaction(db, 101, MINT_ID)
addInFlightRequest(db, 101, {v: 'first'})
addInFlightRequest(db, 101, {v: 'second'})
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'second'})
db.close()
})
test('getInFlightRequestsByMint returns all rows for a mint', () => {
test('getInFlightRequestsByMintId returns all rows of a mint', () => {
const db = freshDb()
addInFlightRequest(db, 101, MINT, 'k1', {v: 1})
addInFlightRequest(db, 102, MINT, 'k1', {v: 2})
addInFlightRequest(db, 103, 'https://other.test', 'k9', {v: 3})
addTransaction(db, 101, MINT_ID)
addTransaction(db, 102, MINT_ID)
addTransaction(db, 103, OTHER_MINT_ID)
addInFlightRequest(db, 101, {v: 1})
addInFlightRequest(db, 102, {v: 2})
addInFlightRequest(db, 103, {v: 3})
const forMint = getInFlightRequestsByMint(db, MINT)
const forMint = getInFlightRequestsByMintId(db, MINT_ID)
expect(forMint.map(r => r.transactionId).sort()).toEqual([101, 102])
expect(getInFlightRequestsByMint(db, 'https://other.test')).toHaveLength(1)
expect(getInFlightRequestsByMintId(db, OTHER_MINT_ID)).toHaveLength(1)
db.close()
})
// The point of joining on mintId rather than a url copy: the mint moving must
// not hide its own in-flight work.
test('still finds a mint\'s requests after its url changes', () => {
const db = freshDb()
addTransaction(db, 101, MINT_ID)
addInFlightRequest(db, 101, {v: 1})
// transactions.mint is frozen history and never rewritten; only the mint's
// live url moves. The id is unaffected, so the join is too.
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(1)
db.close()
})
// Correct, not a regression: the retry settles proofs onto its transaction and
// the handler branches on tx.type, so without the parent there is nothing to
// apply the result to.
test('a request whose transaction is gone is not returned', () => {
const db = freshDb()
addTransaction(db, 101, MINT_ID)
addInFlightRequest(db, 101, {v: 1})
db.prepare('DELETE FROM transactions WHERE id = ?').run(101)
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0)
db.close()
})
test('a transaction with no mintId is not returned', () => {
const db = freshDb()
addTransaction(db, 101, null)
addInFlightRequest(db, 101, {v: 1})
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0)
db.close()
})
test('remove deletes the entry', () => {
const db = freshDb()
addInFlightRequest(db, 101, MINT, 'k1', {v: 1})
addTransaction(db, 101, MINT_ID)
addInFlightRequest(db, 101, {v: 1})
removeInFlightRequest(db, 101)
expect(getInFlightRequest(db, 101)).toBeUndefined()
expect(getInFlightRequestsByMint(db, MINT)).toHaveLength(0)
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0)
db.close()
})
test('seed is idempotent — does not overwrite an existing entry', () => {
const db = freshDb()
addInFlightRequest(db, 101, MINT, 'k1', {v: 'live'})
seedInFlightRequest(db, 101, MINT, 'k1', {v: 'snapshot'})
addTransaction(db, 101, MINT_ID)
addInFlightRequest(db, 101, {v: 'live'})
seedInFlightRequest(db, 101, {v: 'snapshot'})
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'live'})
db.close()
})
+18 -22
View File
@@ -18,8 +18,6 @@ const NOW = '2026-06-05T00:00:00.000Z'
const CREATE_MELT_RECOVERY = `CREATE TABLE melt_recovery (
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
meltPreview TEXT NOT NULL,
createdAt TEXT
)`
@@ -43,22 +41,20 @@ const previewFor = (keysetId: string, secret = 'aa') => ({
function addMeltRecovery(
db: DatabaseSync,
transactionId: number,
mintUrl: string | null,
keysetId: string | null,
meltPreview: object,
) {
db.prepare(
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
VALUES (?, ?, ?, ?, ?)
`INSERT INTO melt_recovery (transactionId, meltPreview, createdAt)
VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`,
).run(transactionId, mintUrl, keysetId, JSON.stringify(meltPreview), NOW)
).run(transactionId, JSON.stringify(meltPreview), NOW)
}
function getMeltRecovery(db: DatabaseSync, transactionId: number) {
const row = db
.prepare(`SELECT transactionId, mintUrl, keysetId, meltPreview, createdAt FROM melt_recovery WHERE transactionId = ?`)
.prepare(`SELECT transactionId, meltPreview, createdAt FROM melt_recovery WHERE transactionId = ?`)
.get(transactionId) as
| {transactionId: number; mintUrl: string | null; keysetId: string | null; meltPreview: string; createdAt: string | null}
| {transactionId: number; meltPreview: string; createdAt: string | null}
| undefined
if (!row) return undefined
return {...row, meltPreview: JSON.parse(row.meltPreview)}
@@ -86,13 +82,13 @@ describe('Melt recovery (melt_recovery)', () => {
const db = freshDb()
const preview = previewFor('k1')
addMeltRecovery(db, 101, MINT, 'k1', preview)
addMeltRecovery(db, 101, preview)
const rec = getMeltRecovery(db, 101)!
expect(rec.transactionId).toBe(101)
expect(rec.mintUrl).toBe(MINT)
expect(rec.keysetId).toBe('k1')
expect(rec.meltPreview).toEqual(preview)
// The keyset lives INSIDE the preview — the row never duplicated it.
expect(rec.meltPreview.keysetId).toBe('k1')
db.close()
})
@@ -104,9 +100,9 @@ describe('Melt recovery (melt_recovery)', () => {
test('the FIRST stored preview wins (ON CONFLICT DO NOTHING)', () => {
const db = freshDb()
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'first'))
addMeltRecovery(db, 101, previewFor('k1', 'first'))
// A second attempt for the same tx must not overwrite.
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'second'))
addMeltRecovery(db, 101, previewFor('k1', 'second'))
const rec = getMeltRecovery(db, 101)!
expect(rec.meltPreview.outputData[0].secret).toBe('first')
@@ -116,7 +112,7 @@ describe('Melt recovery (melt_recovery)', () => {
test('remove deletes the entry (terminal success/failure)', () => {
const db = freshDb()
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1'))
addMeltRecovery(db, 101, previewFor('k1'))
expect(rowCount(db)).toBe(1)
removeMeltRecovery(db, 101)
@@ -127,24 +123,24 @@ describe('Melt recovery (melt_recovery)', () => {
test('entries for different transactions are independent', () => {
const db = freshDb()
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1'))
addMeltRecovery(db, 102, MINT, 'k2', previewFor('k2'))
addMeltRecovery(db, 101, previewFor('k1'))
addMeltRecovery(db, 102, previewFor('k2'))
expect(getMeltRecovery(db, 101)!.keysetId).toBe('k1')
expect(getMeltRecovery(db, 102)!.keysetId).toBe('k2')
expect(getMeltRecovery(db, 101)!.meltPreview.keysetId).toBe('k1')
expect(getMeltRecovery(db, 102)!.meltPreview.keysetId).toBe('k2')
removeMeltRecovery(db, 101)
expect(getMeltRecovery(db, 101)).toBeUndefined()
expect(getMeltRecovery(db, 102)!.keysetId).toBe('k2') // unaffected
expect(getMeltRecovery(db, 102)!.meltPreview.keysetId).toBe('k2') // unaffected
db.close()
})
test('seed is idempotent — does not overwrite an existing entry', () => {
const db = freshDb()
// Live entry already advanced/stored.
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'live'))
addMeltRecovery(db, 101, previewFor('k1', 'live'))
// Upgrade seed re-runs with the snapshot copy.
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'snapshot'))
addMeltRecovery(db, 101, previewFor('k1', 'snapshot'))
expect(getMeltRecovery(db, 101)!.meltPreview.outputData[0].secret).toBe('live')
db.close()
+7 -2
View File
@@ -21,6 +21,7 @@ import {DatabaseSync} from 'node:sqlite'
const CREATE_ONCHAIN_MINT_QUOTES = `CREATE TABLE onchain_mint_quotes (
quote TEXT PRIMARY KEY NOT NULL,
mintId TEXT,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
address TEXT NOT NULL,
@@ -42,9 +43,12 @@ const daysFromNow = (n: number) => iso(new Date(NOW.getTime() + n * 86400000))
// ── Mirrored repo primitives (exact production SQL) ─────────────────────────
const COLS = `quote, mintUrl, unit, address, counterIndex, pubkey, amountRequested,
const COLS = `quote, mintId, mintUrl, unit, address, counterIndex, pubkey, amountRequested,
amountPaid, amountIssued, expiry, watchUntil, createdAt, updatedAt`
/** Mint.id of the owning mint — the reference that survives a mint-url edit. */
const MINT_ID = 'mint1234'
function addQuote(
db: DatabaseSync,
q: {
@@ -58,9 +62,10 @@ function addQuote(
) {
db.prepare(
`INSERT OR REPLACE INTO onchain_mint_quotes (${COLS})
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
q.quote,
MINT_ID,
MINT,
'sat',
`bc1q${q.quote}`,
+1
View File
@@ -32,6 +32,7 @@ const CREATE_PROOFS = `CREATE TABLE proofs (
const CREATE_RESERVATIONS = `CREATE TABLE reservations (
id TEXT PRIMARY KEY NOT NULL,
transactionId INTEGER NOT NULL,
mintId TEXT,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
operationType TEXT NOT NULL,
+286
View File
@@ -0,0 +1,286 @@
/**
* Repeatable migration tests for SQLite migration 33.
*
* Migration 33 adds `mintId` to `onchain_mint_quotes` and `reservations`, so those
* rows reference the owning mint by its stable id instead of by url.
*
* WHY: a mint url is a network LOCATOR and mints move. An onchain quote's address
* stays creditable for as long as the mint exists (rows are never deleted), so the
* reference has to outlive the move; following a stale url just polls a dead host
* forever, and silently, because the watcher swallows errors by design.
*
* The column is added EMPTY and backfilled from JS (the v38 seed), because mints
* live in the MST/MMKV snapshot rather than SQLite — no SQL statement can map
* url -> id. These tests cover the migration shape, the backfill semantics, and the
* replay hazard that made v26/v31 freeze their historical column lists.
*
* Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node
*/
import {DatabaseSync} from 'node:sqlite'
// ── Historical shapes, as v26/v31 create them (frozen in migrations.ts) ───────
const CREATE_ONCHAIN_V31 = `CREATE TABLE onchain_mint_quotes (
quote TEXT PRIMARY KEY NOT NULL,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
address TEXT NOT NULL,
counterIndex INTEGER NOT NULL,
pubkey TEXT NOT NULL,
amountRequested INTEGER,
amountPaid INTEGER NOT NULL DEFAULT 0,
amountIssued INTEGER NOT NULL DEFAULT 0,
expiry INTEGER,
watchUntil TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT
)`
const CREATE_RESERVATIONS_V26 = `CREATE TABLE reservations (
id TEXT PRIMARY KEY NOT NULL,
transactionId INTEGER NOT NULL,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
operationType TEXT NOT NULL,
lockedProofs TEXT NOT NULL,
createdAt TEXT NOT NULL
)`
// ── SQL copied verbatim from migrations.ts migration 33 ──────────────────────
const MIGRATION_33 = [
`ALTER TABLE onchain_mint_quotes ADD COLUMN mintId TEXT`,
`ALTER TABLE reservations ADD COLUMN mintId TEXT`,
]
// ── Backfill, mirroring onchainQuotesRepo / reservationsRepo ─────────────────
function backfillOnchainMintQuoteMintIds(
db: DatabaseSync,
mints: Array<{id: string; mintUrl: string}>,
): number {
let updated = 0
for (const mint of mints) {
const {changes} = db
.prepare(`UPDATE onchain_mint_quotes SET mintId = ? WHERE mintUrl = ? AND mintId IS NULL`)
.run(mint.id, mint.mintUrl)
updated += Number(changes)
}
return updated
}
function backfillReservationMintIds(
db: DatabaseSync,
mints: Array<{id: string; mintUrl: string}>,
): number {
let updated = 0
for (const mint of mints) {
const {changes} = db
.prepare(`UPDATE reservations SET mintId = ? WHERE mintUrl = ? AND mintId IS NULL`)
.run(mint.id, mint.mintUrl)
updated += Number(changes)
}
return updated
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const MINT_A = {id: 'aaaa1111', mintUrl: 'https://a.mint.test'}
const MINT_B = {id: 'bbbb2222', mintUrl: 'https://b.mint.test'}
function runMigration33(db: DatabaseSync) {
db.exec('BEGIN')
try {
for (const sql of MIGRATION_33) db.exec(sql)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
function insertQuote(db: DatabaseSync, quote: string, mintUrl: string) {
db.prepare(
`INSERT INTO onchain_mint_quotes
(quote, mintUrl, unit, address, counterIndex, pubkey, amountPaid, amountIssued, watchUntil, createdAt)
VALUES (?, ?, 'sat', 'bc1qaddr', 7, 'pub', 0, 0, '2026-12-01', '2026-01-01')`,
).run(quote, mintUrl)
}
function insertReservation(db: DatabaseSync, id: string, mintUrl: string) {
db.prepare(
`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt)
VALUES (?, 1, ?, 'sat', 'send', '[]', '2026-01-01')`,
).run(id, mintUrl)
}
function quoteMintId(db: DatabaseSync, quote: string): string | null {
const row = db.prepare('SELECT mintId FROM onchain_mint_quotes WHERE quote = ?').get(quote) as
| {mintId: string | null}
| undefined
return row?.mintId ?? null
}
function reservationMintId(db: DatabaseSync, id: string): string | null {
const row = db.prepare('SELECT mintId FROM reservations WHERE id = ?').get(id) as
| {mintId: string | null}
| undefined
return row?.mintId ?? null
}
function columns(db: DatabaseSync, table: string): string[] {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{name: string}>
return rows.map(r => r.name)
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_ONCHAIN_V31)
db.exec(CREATE_RESERVATIONS_V26)
return db
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('SQLite migration 33 — reference the mint by id', () => {
describe('shape', () => {
test('adds mintId to both tables', () => {
const db = freshDb()
expect(columns(db, 'onchain_mint_quotes')).not.toContain('mintId')
expect(columns(db, 'reservations')).not.toContain('mintId')
runMigration33(db)
expect(columns(db, 'onchain_mint_quotes')).toContain('mintId')
expect(columns(db, 'reservations')).toContain('mintId')
db.close()
})
test('keeps mintUrl — it stays as the historical record', () => {
const db = freshDb()
runMigration33(db)
expect(columns(db, 'onchain_mint_quotes')).toContain('mintUrl')
expect(columns(db, 'reservations')).toContain('mintUrl')
db.close()
})
test('preserves existing rows, with mintId NULL until backfilled', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
const row = db.prepare('SELECT * FROM onchain_mint_quotes WHERE quote = ?').get('q1') as any
expect(row.mintId).toBeNull()
// The load-bearing column: without counterIndex the NUT-20 key cannot be
// re-derived and a deposit is unmintable.
expect(row.counterIndex).toBe(7)
expect(row.address).toBe('bc1qaddr')
expect(row.mintUrl).toBe(MINT_A.mintUrl)
db.close()
})
// The reason v26/v31 freeze their column lists instead of reading schema.ts.
// If a replayed old migration created today's shape, this ALTER would throw
// "duplicate column name" and the upgrade would fail outright.
test('re-running the ALTER on an already-migrated table throws', () => {
const db = freshDb()
runMigration33(db)
expect(() => db.exec(`ALTER TABLE onchain_mint_quotes ADD COLUMN mintId TEXT`)).toThrow()
db.close()
})
})
describe('backfill', () => {
test('resolves each row to its mint id', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
insertQuote(db, 'q2', MINT_B.mintUrl)
insertReservation(db, 'r1', MINT_A.mintUrl)
runMigration33(db)
expect(backfillOnchainMintQuoteMintIds(db, [MINT_A, MINT_B])).toBe(2)
expect(backfillReservationMintIds(db, [MINT_A, MINT_B])).toBe(1)
expect(quoteMintId(db, 'q1')).toBe(MINT_A.id)
expect(quoteMintId(db, 'q2')).toBe(MINT_B.id)
expect(reservationMintId(db, 'r1')).toBe(MINT_A.id)
db.close()
})
test('is idempotent — a second run updates nothing', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
expect(backfillOnchainMintQuoteMintIds(db, [MINT_A])).toBe(1)
expect(backfillOnchainMintQuoteMintIds(db, [MINT_A])).toBe(0)
expect(quoteMintId(db, 'q1')).toBe(MINT_A.id)
db.close()
})
// The IS NULL guard: once a row is resolved, a later url match must never
// re-point it. After a rename the row's mintUrl is stale by design, so a
// re-run must not "correct" it toward whichever mint now answers that url.
test('never overwrites an id already resolved', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
backfillOnchainMintQuoteMintIds(db, [MINT_A])
// A different mint has since taken over that url.
const impostor = {id: 'cccc3333', mintUrl: MINT_A.mintUrl}
expect(backfillOnchainMintQuoteMintIds(db, [impostor])).toBe(0)
expect(quoteMintId(db, 'q1')).toBe(MINT_A.id)
db.close()
})
test('leaves a row whose mint is gone from the wallet NULL', () => {
const db = freshDb()
insertQuote(db, 'orphan', 'https://removed.mint.test')
runMigration33(db)
expect(backfillOnchainMintQuoteMintIds(db, [MINT_A, MINT_B])).toBe(0)
// Null means "no mint to talk to" — the quote is dead either way, and the
// resolver throws rather than guessing from the stale url.
expect(quoteMintId(db, 'orphan')).toBeNull()
db.close()
})
test('is a no-op with no mints', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
expect(backfillOnchainMintQuoteMintIds(db, [])).toBe(0)
expect(quoteMintId(db, 'q1')).toBeNull()
db.close()
})
})
describe('the point of the whole change', () => {
test('a quote still resolves after its mint moves url', () => {
const db = freshDb()
insertQuote(db, 'q1', MINT_A.mintUrl)
runMigration33(db)
backfillOnchainMintQuoteMintIds(db, [MINT_A])
// The mint moves. Nothing rewrites the quote row — that is the design.
const movedMint = {id: MINT_A.id, mintUrl: 'https://moved.mint.test'}
// Resolution is by id, so it still finds the mint and gets its LIVE url.
expect(quoteMintId(db, 'q1')).toBe(movedMint.id)
// Whereas the old url-keyed lookup now finds nothing — this is precisely the
// query that stranded deposits permanently.
const byOldUrl = db
.prepare('SELECT quote FROM onchain_mint_quotes WHERE mintUrl = ?')
.all(movedMint.mintUrl)
expect(byOldUrl).toHaveLength(0)
const byId = db.prepare('SELECT quote FROM onchain_mint_quotes WHERE mintId = ?').all(movedMint.id)
expect(byId).toHaveLength(1)
db.close()
})
})
})
+273
View File
@@ -0,0 +1,273 @@
/**
* Repeatable migration tests for SQLite migration 34.
*
* Migration 34 finishes the mint-identity work:
*
* 1. `transactions.mintId` — splitting the two jobs `transactions.mint` was doing
* at once, switched by status: a historical record of where a finished payment
* happened, AND a live pointer the wallet dialled for an open one. That is why
* a mint-url edit used to rewrite in-flight rows — rewriting the very column
* that records the past. With mintId carrying identity, `mint` is frozen.
*
* 2. inflight_requests / melt_recovery rebuilt WITHOUT mintUrl and keysetId. Both
* are CHILD rows of a transaction (their primary key IS transactionId), so the
* parent already owns "which mint"; their copies had no readers at all. The one
* mint-scoped query joins through the parent instead.
*
* Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node
*/
import {DatabaseSync} from 'node:sqlite'
// ── Pre-34 shapes (as v28/v29 create them; frozen in migrations.ts) ──────────
const CREATE_TRANSACTIONS_PRE34 = `CREATE TABLE transactions (
id INTEGER PRIMARY KEY NOT NULL,
type TEXT,
amount INTEGER,
unit TEXT,
data TEXT,
mint TEXT,
status TEXT,
createdAt TEXT
)`
const CREATE_INFLIGHT_V29 = `CREATE TABLE inflight_requests (
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
request TEXT NOT NULL,
createdAt TEXT
)`
const CREATE_MELT_RECOVERY_V28 = `CREATE TABLE melt_recovery (
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
meltPreview TEXT NOT NULL,
createdAt TEXT
)`
// ── SQL copied verbatim from migrations.ts migration 34 ─────────────────────
const MIGRATION_34 = [
`ALTER TABLE transactions ADD COLUMN mintId TEXT`,
`CREATE TABLE inflight_requests_v34 (
transactionId INTEGER PRIMARY KEY NOT NULL,
request TEXT NOT NULL,
createdAt TEXT
)`,
`INSERT INTO inflight_requests_v34 (transactionId, request, createdAt)
SELECT transactionId, request, createdAt FROM inflight_requests`,
`DROP TABLE inflight_requests`,
`ALTER TABLE inflight_requests_v34 RENAME TO inflight_requests`,
`CREATE TABLE melt_recovery_v34 (
transactionId INTEGER PRIMARY KEY NOT NULL,
meltPreview TEXT NOT NULL,
createdAt TEXT
)`,
`INSERT INTO melt_recovery_v34 (transactionId, meltPreview, createdAt)
SELECT transactionId, meltPreview, createdAt FROM melt_recovery`,
`DROP TABLE melt_recovery`,
`ALTER TABLE melt_recovery_v34 RENAME TO melt_recovery`,
]
/** Mirrors transactionsRepo.backfillTransactionMintIds (the v38 seed). */
function backfillTransactionMintIds(
db: DatabaseSync,
mints: Array<{id: string; mintUrl: string}>,
): number {
let updated = 0
for (const mint of mints) {
const {changes} = db
.prepare(`UPDATE transactions SET mintId = ? WHERE mint = ? AND mintId IS NULL`)
.run(mint.id, mint.mintUrl)
updated += Number(changes)
}
return updated
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const MINT_A = {id: 'aaaa1111', mintUrl: 'https://a.mint.test'}
const MINT_B = {id: 'bbbb2222', mintUrl: 'https://b.mint.test'}
function runMigration34(db: DatabaseSync) {
db.exec('BEGIN')
try {
for (const sql of MIGRATION_34) db.exec(sql)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
function insertTx(db: DatabaseSync, id: number, mint: string, status = 'COMPLETED') {
db.prepare(
`INSERT INTO transactions (id, type, amount, unit, data, mint, status, createdAt)
VALUES (?, 'TOPUP', 100, 'sat', '{}', ?, ?, '2026-01-01')`,
).run(id, mint, status)
}
function columns(db: DatabaseSync, table: string): string[] {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{name: string}>
return rows.map(r => r.name)
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_TRANSACTIONS_PRE34)
db.exec(CREATE_INFLIGHT_V29)
db.exec(CREATE_MELT_RECOVERY_V28)
return db
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('SQLite migration 34 — transactions.mintId, and child tables normalized', () => {
describe('transactions.mintId', () => {
test('adds mintId while keeping mint', () => {
const db = freshDb()
runMigration34(db)
expect(columns(db, 'transactions')).toContain('mintId')
// `mint` stays: it is where the payment happened, and history needs it.
expect(columns(db, 'transactions')).toContain('mint')
db.close()
})
test('backfills mintId from the url, leaving mint untouched', () => {
const db = freshDb()
insertTx(db, 1, MINT_A.mintUrl)
insertTx(db, 2, MINT_B.mintUrl)
runMigration34(db)
expect(backfillTransactionMintIds(db, [MINT_A, MINT_B])).toBe(2)
const rows = db.prepare('SELECT id, mint, mintId FROM transactions ORDER BY id').all() as any[]
expect(rows[0]).toMatchObject({mint: MINT_A.mintUrl, mintId: MINT_A.id})
expect(rows[1]).toMatchObject({mint: MINT_B.mintUrl, mintId: MINT_B.id})
db.close()
})
test('leaves history of a removed mint with a null mintId', () => {
const db = freshDb()
insertTx(db, 1, 'https://removed.mint.test')
runMigration34(db)
expect(backfillTransactionMintIds(db, [MINT_A])).toBe(0)
const row = db.prepare('SELECT mint, mintId FROM transactions WHERE id = 1').get() as any
// Legitimate history: no mint to act on, but the url still records where it
// happened.
expect(row.mintId).toBeNull()
expect(row.mint).toBe('https://removed.mint.test')
db.close()
})
test('backfill is idempotent and never re-points a resolved row', () => {
const db = freshDb()
insertTx(db, 1, MINT_A.mintUrl)
runMigration34(db)
backfillTransactionMintIds(db, [MINT_A])
// A different mint has since taken over that url.
const impostor = {id: 'cccc3333', mintUrl: MINT_A.mintUrl}
expect(backfillTransactionMintIds(db, [impostor])).toBe(0)
const row = db.prepare('SELECT mintId FROM transactions WHERE id = 1').get() as any
expect(row.mintId).toBe(MINT_A.id)
db.close()
})
test('backfills in-flight and terminal rows alike', () => {
const db = freshDb()
insertTx(db, 1, MINT_A.mintUrl, 'PENDING')
insertTx(db, 2, MINT_A.mintUrl, 'COMPLETED')
runMigration34(db)
// Unlike the rewrite this replaces, the backfill is status-blind: mintId is
// identity, which a finished transaction has just as much as an open one.
expect(backfillTransactionMintIds(db, [MINT_A])).toBe(2)
db.close()
})
})
describe('child tables lose their duplicated mint reference', () => {
test('inflight_requests keeps only transactionId, request, createdAt', () => {
const db = freshDb()
runMigration34(db)
expect(columns(db, 'inflight_requests').sort()).toEqual(['createdAt', 'request', 'transactionId'])
db.close()
})
test('melt_recovery keeps only transactionId, meltPreview, createdAt', () => {
const db = freshDb()
runMigration34(db)
expect(columns(db, 'melt_recovery').sort()).toEqual(['createdAt', 'meltPreview', 'transactionId'])
db.close()
})
test('carries the surviving data across the rebuild', () => {
const db = freshDb()
db.prepare(
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
VALUES (1, 'https://a.mint.test', 'k1', '{"v":1}', '2026-01-01')`,
).run()
db.prepare(
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
VALUES (2, 'https://a.mint.test', 'k1', '{"keysetId":"k1"}', '2026-01-02')`,
).run()
runMigration34(db)
const ifr = db.prepare('SELECT * FROM inflight_requests WHERE transactionId = 1').get() as any
expect(ifr.request).toBe('{"v":1}')
expect(ifr.createdAt).toBe('2026-01-01')
const melt = db.prepare('SELECT * FROM melt_recovery WHERE transactionId = 2').get() as any
// The keyset was never lost — it lives inside the preview, which is why the
// column duplicating it had no readers.
expect(JSON.parse(melt.meltPreview).keysetId).toBe('k1')
expect(melt.createdAt).toBe('2026-01-02')
db.close()
})
test('an empty child table rebuilds cleanly', () => {
const db = freshDb()
runMigration34(db)
expect(db.prepare('SELECT * FROM inflight_requests').all()).toEqual([])
expect(db.prepare('SELECT * FROM melt_recovery').all()).toEqual([])
db.close()
})
})
describe('the mint-scoped sweep, after the change', () => {
test('finds a mint\'s in-flight requests through the parent transaction', () => {
const db = freshDb()
insertTx(db, 1, MINT_A.mintUrl, 'PENDING')
insertTx(db, 2, MINT_B.mintUrl, 'PENDING')
db.prepare(
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
VALUES (1, 'https://a.mint.test', 'k1', '{"v":1}', '2026-01-01'),
(2, 'https://b.mint.test', 'k2', '{"v":2}', '2026-01-01')`,
).run()
runMigration34(db)
backfillTransactionMintIds(db, [MINT_A, MINT_B])
const rows = db
.prepare(
`SELECT r.transactionId FROM inflight_requests r
JOIN transactions t ON t.id = r.transactionId
WHERE t.mintId = ?`,
)
.all(MINT_A.id) as any[]
expect(rows).toHaveLength(1)
expect(rows[0].transactionId).toBe(1)
db.close()
})
})
})
-203
View File
@@ -1,203 +0,0 @@
/**
* Mint-url rewrite scoping for transactions
* (Database.updateInFlightTransactionsMintUrl).
*
* `transactions.mint` carries two meanings, switched by status:
*
* - TERMINAL: a historical record of where the payment actually happened.
* Rewriting it would falsify history, so a mint-url edit must leave it alone.
* - IN-FLIGHT: a LIVE pointer the wallet still calls —
* checkLightningMintQuote(tx.mint, tx.quote) in topupOperationApi,
* checkLightningMeltQuote / checkOnchainMeltQuote in transferOperationApi,
* findByUrl(tx.mint) on the revert/receive paths. Left stale after an edit, an
* open transaction is stranded at a dead url — a paid topup whose ecash the
* wallet can never mint.
*
* Mirrors the production SQL with node:sqlite, as the native driver needs a
* device — but takes the status list from the REAL IN_FLIGHT_STATUSES rather
* than a copy, so the mirror cannot drift from what production actually runs.
*
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
jest.mock('../src/services', () => ({
Database: {},
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {DatabaseSync} from 'node:sqlite'
import {IN_FLIGHT_STATUSES} from '../src/models/TransactionStates'
import {TransactionStatus} from '../src/models/Transaction'
const OLD_URL = 'https://old.mint.test'
const NEW_URL = 'https://new.mint.test'
const OTHER_URL = 'https://other.mint.test'
/** The exact set the production UPDATE binds. */
const IN_FLIGHT = [...IN_FLIGHT_STATUSES] as string[]
/** Everything else — by construction, so a new enum member lands here loudly. */
const TERMINAL = Object.values(TransactionStatus).filter(s => !IN_FLIGHT.includes(s)) as string[]
const CREATE_TRANSACTIONS = `CREATE TABLE transactions (
id INTEGER PRIMARY KEY NOT NULL,
type TEXT,
amount INTEGER,
unit TEXT,
data TEXT,
mint TEXT,
status TEXT,
createdAt TEXT
)`
/** Mirrors transactionsRepo.updateInFlightTransactionsMintUrl. */
function updateInFlightTransactionsMintUrl(
db: DatabaseSync,
currentMintUrl: string,
updatedMintUrl: string,
): number {
const placeholders = IN_FLIGHT.map(() => '?').join(', ')
const {changes} = db
.prepare(
`UPDATE transactions
SET mint = ?
WHERE mint = ? AND status IN (${placeholders})`,
)
.run(updatedMintUrl, currentMintUrl, ...IN_FLIGHT)
return Number(changes)
}
let nextId = 1
function insertTx(db: DatabaseSync, mint: string, status: string): number {
const id = nextId++
db.prepare(
`INSERT INTO transactions (id, type, amount, unit, data, mint, status, createdAt)
VALUES (?, 'TOPUP', 100, 'sat', '{}', ?, ?, '2026-01-01')`,
).run(id, mint, status)
return id
}
function mintOf(db: DatabaseSync, id: number): string {
const row = db.prepare('SELECT mint FROM transactions WHERE id = ?').get(id) as {mint: string}
return row.mint
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_TRANSACTIONS)
nextId = 1
return db
}
describe('updateInFlightTransactionsMintUrl', () => {
describe('in-flight transactions are repointed', () => {
test.each(IN_FLIGHT)('%s is repointed to the new url', status => {
const db = freshDb()
const id = insertTx(db, OLD_URL, status)
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(1)
expect(mintOf(db, id)).toBe(NEW_URL)
db.close()
})
})
describe('terminal transactions keep their historical url', () => {
test.each(TERMINAL)('%s is left untouched', status => {
const db = freshDb()
const id = insertTx(db, OLD_URL, status)
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(0)
expect(mintOf(db, id)).toBe(OLD_URL)
db.close()
})
})
test('splits a mixed history: open rows move, closed rows stay', () => {
const db = freshDb()
const pending = insertTx(db, OLD_URL, 'PENDING')
const executing = insertTx(db, OLD_URL, 'EXECUTING')
const completed = insertTx(db, OLD_URL, 'COMPLETED')
const reverted = insertTx(db, OLD_URL, 'REVERTED')
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(2)
expect(mintOf(db, pending)).toBe(NEW_URL)
expect(mintOf(db, executing)).toBe(NEW_URL)
expect(mintOf(db, completed)).toBe(OLD_URL)
expect(mintOf(db, reverted)).toBe(OLD_URL)
db.close()
})
test('does not touch another mint\'s in-flight transactions', () => {
const db = freshDb()
const mine = insertTx(db, OLD_URL, 'PENDING')
const theirs = insertTx(db, OTHER_URL, 'PENDING')
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(1)
expect(mintOf(db, mine)).toBe(NEW_URL)
expect(mintOf(db, theirs)).toBe(OTHER_URL)
db.close()
})
test('is a no-op when the mint has no transactions', () => {
const db = freshDb()
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(0)
db.close()
})
test('is idempotent — re-running finds nothing left at the old url', () => {
const db = freshDb()
const pending = insertTx(db, OLD_URL, 'PENDING')
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(1)
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(0)
expect(mintOf(db, pending)).toBe(NEW_URL)
db.close()
})
test('a second rename chains correctly (A -> B -> C)', () => {
const db = freshDb()
const pending = insertTx(db, OLD_URL, 'PENDING')
const completed = insertTx(db, OLD_URL, 'COMPLETED')
updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)
updateInFlightTransactionsMintUrl(db, NEW_URL, OTHER_URL)
expect(mintOf(db, pending)).toBe(OTHER_URL)
// Still frozen at the url its payment actually used, two renames later.
expect(mintOf(db, completed)).toBe(OLD_URL)
db.close()
})
test('the in-flight set matches TransactionStates exactly', () => {
// Pins the classification itself: a status moved between the sets, or a new
// one added to neither, changes whether a rename repoints that transaction.
expect([...IN_FLIGHT].sort()).toEqual([
'DRAFT',
'EXECUTING',
'PENDING',
'PREPARED',
'PREPARED_OFFLINE',
'ROLLING_BACK',
])
})
test('every status is classified as either live or historical', () => {
// TERMINAL is derived as the complement, so this asserts the enum has not
// grown a member that silently falls into "historical" without anyone
// deciding that is right for it.
expect([...IN_FLIGHT, ...TERMINAL].sort()).toEqual(Object.values(TransactionStatus).sort())
expect(TERMINAL.sort()).toEqual([
'BLOCKED',
'COMPLETED',
'ERROR',
'EXPIRED',
'RECOVERED',
'REVERTED',
])
})
})
+10 -13
View File
@@ -580,19 +580,17 @@ export const MintModel = types
* somewhere else now. So everything addressed by the old location has to
* follow. What follows, and what deliberately does not:
*
* - proofs — repointed. `proofs.mintUrl` is how a balance is found.
* - in-flight transactions — repointed. Their `mint` is a live pointer the
* wallet still calls; stale, it strands an open payment at a dead url.
* - terminal transactions — NOT repointed. There the url is a historical
* record of where the payment happened. See
* Database.updateInFlightTransactionsMintUrl.
* - proofs — repointed. `proofs.mintUrl` is how a balance is found, and it
* is the last denormalized copy of the url left in the schema.
* - transactions — nothing to do. `mint` is the url the payment happened at,
* a historical fact that must NOT move; `mintId` carries the identity.
* - derivation counters — nothing to do: keyed by keysetId, which no url
* edit can disturb (see MINT_COUNTERS_COLUMNS).
*
* NOT yet carried over — each still keyed by url, and tracked as step 3 of
* the mint-identity work: onchain mint quotes, in-flight requests, melt
* recovery rows, and open reservations. Renaming a mint with any of those
* outstanding still strands them.
* - onchain mint quotes, open reservations — nothing to do: they reference
* the mint by `mintId` and resolve the live url through it at use time.
* - in-flight requests, melt recovery — nothing to do: they are child rows
* of a transaction and hold no mint reference at all; the one mint-scoped
* query joins through the parent's mintId.
*
* Validation runs through the same normalizer as `addMint`, so a rename can
* no longer install a url that adding the same mint would have rejected.
@@ -609,7 +607,7 @@ export const MintModel = types
return true
}
const {mintsStore, proofsStore, transactionsStore} = getRootStore(self)
const {mintsStore, proofsStore} = getRootStore(self)
// mintExists (normalized), not alreadyExists (literal string compare):
// the latter misses a twin differing only by a trailing slash, which
@@ -619,7 +617,6 @@ export const MintModel = types
}
proofsStore.updateMintUrl(currentMintUrl, normalized)
transactionsStore.updateMintUrl(currentMintUrl, normalized)
self.mintUrl = normalized
self.setHostname() // derived from mintUrl — stale until recomputed
+35
View File
@@ -53,10 +53,45 @@ export const MintsStoreModel = types
const mint = self.mints.find(m => m.mintUrl === mintUrl)
return mint ? mint : undefined
},
/**
* Resolve a mint by its stable local id — the lookup for stored rows that
* must survive a mint-url edit.
*
* `Mint.id` is an arbitrary local surrogate, and that is precisely why it
* works as a foreign key: it carries no meaning that can go stale, unlike a
* url, which is a network locator mints do change. It answers exactly one
* question — "which mint is this row about?".
*
* It must NEVER answer "are these two the same mint?". It is random, with no
* relation to the mint's keys, so it cannot recognise one mint reached by two
* urls. That question belongs to the keysets — see
* CashuUtils.isCollidingKeysetId.
*/
findById: (mintId: string) => {
return self.mints.find(m => m.id === mintId)
},
get allKeysetIds() {
return self.mints.flatMap(m => m.keysetIds)
},
}))
.views(self => ({
/**
* The mint a transaction belongs to.
*
* Use this rather than findByUrl(tx.mint). `tx.mint` records where the
* payment happened and is deliberately never rewritten, so once a mint moves
* url it stops matching — every one of that mint's transactions would look
* like it belonged to a mint that no longer exists.
*
* There is deliberately NO fallback to the url. A null mintId means the mint
* is not in this wallet, and that IS the answer; guessing from the url would
* resolve to whichever mint now answers it, which is the precise confusion
* mintId exists to prevent.
*/
findByTransaction: (tx: {mintId?: string | null}) => {
return tx.mintId ? self.findById(tx.mintId) : undefined
},
}))
.actions(withSetPropAction)
.actions(self => ({
mintExists: (mintUrl: string | URL) => {
+26 -4
View File
@@ -289,11 +289,17 @@ import {
originalTId: p.tId ?? null,
}))
// Resolved here rather than asked of every caller: they all identify the
// mint by url, but the reservation must survive that url changing while
// the operation is open (see ProofReservation.mintId).
const mintId = getRootStore(self).mintsStore.findByUrl(opts.mintUrl)?.id ?? null
// ATOMIC: write reservation row + lock proofs to PENDING in one batch.
Database.openReservation(
{
id: reservationId,
transactionId: opts.transactionId,
mintId: mintId ?? undefined,
mintUrl: opts.mintUrl,
unit: opts.unit,
operationType: opts.operationType,
@@ -316,6 +322,7 @@ import {
return {
id: reservationId,
transactionId: opts.transactionId,
mintId,
mintUrl: opts.mintUrl,
unit: opts.unit,
operationType: opts.operationType,
@@ -347,14 +354,29 @@ import {
} = {},
): { added: Proof[] } {
const mintsStore = getRootStore(self).mintsStore
const mintInstance = mintsStore.findByUrl(reservation.mintUrl)
// Resolve by stable id, not by the url captured when the reservation
// opened: a mint-url edit may have landed while this operation was in
// flight, and a url lookup would then find nothing and abort the commit
// of an operation the mint has already performed. Falls back to the url
// for a pre-v33 reservation, which carries no mintId.
const mintInstance =
(reservation.mintId ? mintsStore.findById(reservation.mintId) : undefined) ??
mintsStore.findByUrl(reservation.mintUrl)
if (!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint not found for reservation', {
mintId: reservation.mintId,
mintUrl: reservation.mintUrl,
reservationId: reservation.id,
})
}
// The mint's url NOW, which is not necessarily reservation.mintUrl. Every
// write below — SQLite and the MST mirror alike — uses this: filing the
// new proofs under a url no mint owns makes the balance simply vanish.
const commitMintUrl = mintInstance.mintUrl
// Snapshot the current derivation counter for every keyset the new
// proofs were derived under (a cashu proof's `id` IS its keyset id).
// WalletStore already advanced the model counter to
@@ -388,7 +410,7 @@ import {
newProofs: changes.newProofs?.map(group => ({
proofs: group.proofs,
state: group.state,
mintUrl: reservation.mintUrl,
mintUrl: commitMintUrl,
unit: reservation.unit,
tId: group.tId,
})),
@@ -420,7 +442,7 @@ import {
if (existing) {
if (existing.state === 'SPENT') continue
if (isAlive(existing)) {
existing.setProp('mintUrl', reservation.mintUrl)
existing.setProp('mintUrl', commitMintUrl)
existing.setProp('tId', group.tId)
existing.setProp('unit', reservation.unit)
existing.setProp('state', group.state)
@@ -430,7 +452,7 @@ import {
const node = ProofModel.create({
...proof,
amount: Number(proof.amount),
mintUrl: reservation.mintUrl,
mintUrl: commitMintUrl,
tId: group.tId,
unit: reservation.unit,
state: group.state,
+1 -1
View File
@@ -11,7 +11,7 @@ import {NwcStoreModel} from './NwcStore'
import {AuthStoreModel} from './AuthStore'
import { log } from '../services'
export const rootStoreModelVersion = 37 // Update this if model changes require migrations defined in setupRootStore.ts
export const rootStoreModelVersion = 38 // Update this if model changes require migrations defined in setupRootStore.ts
/**
* A RootStore model.
*/
+15
View File
@@ -81,7 +81,22 @@ export const TransactionModel = types
paymentId: types.maybe(types.maybeNull(types.string)),
quote: types.maybe(types.maybeNull(types.string)),
memo: types.maybe(types.maybeNull(types.string)),
/**
* The url this payment happened at — a HISTORICAL fact, frozen once written.
* Display this; never dial it. A mint may since have moved, and rewriting it
* would misreport where the money actually went.
*/
mint: types.string,
/**
* Stable id (Mint.id) of the mint — the identity, and the only reference to
* resolve when acting: reaching the mint, or asking whether its mint is still
* in the wallet. Survives a mint-url edit, which is why `mint` no longer has
* to.
*
* Null for a transaction of a mint that has been removed, and for pre-v34
* rows the v38 backfill could not match.
*/
mintId: types.maybe(types.maybeNull(types.string)),
paymentRequest: types.maybe(types.maybeNull(types.string)),
zapRequest: types.maybe(types.maybeNull(types.string)),
inputToken: types.maybe(types.maybeNull(types.string)),
+2 -8
View File
@@ -204,14 +204,8 @@ const TERMINAL_STATUSES: ReadonlySet<TransactionStatus> = new Set([
TransactionStatus.RECOVERED,
])
/**
* Statuses in which a transaction is still open — the complement of
* TERMINAL_STATUSES. Exported because it is not only a type-narrowing concern:
* an open transaction's `mint` url is a LIVE pointer the wallet still calls,
* whereas a terminal one's is a historical record (see
* Database.updateInFlightTransactionsMintUrl).
*/
export const IN_FLIGHT_STATUSES: ReadonlySet<TransactionStatus> = new Set([
/** Statuses in which a transaction is still open — the complement of TERMINAL_STATUSES. */
const IN_FLIGHT_STATUSES: ReadonlySet<TransactionStatus> = new Set([
TransactionStatus.DRAFT,
TransactionStatus.PREPARED,
TransactionStatus.PREPARED_OFFLINE,
+27 -24
View File
@@ -12,7 +12,6 @@ import {
TransactionStatus,
TransactionType,
} from './Transaction'
import { isInFlight } from './TransactionStates'
import { Database } from '../services'
import { log } from '../services/logService'
import { getRootStore } from './helpers/getRootStore'
@@ -156,32 +155,23 @@ export const TransactionsStoreModel = types
},
/**
* Mirror a mint-url edit onto in-flight transactions only — see
* Database.updateInFlightTransactionsMintUrl for why terminal rows keep
* the url their payment actually used.
* Drop recent-list entries whose mint is no longer in the wallet.
*
* SQLite first: if the write throws, the exception propagates with the MST
* tree still matching the database. Updating memory first would leave the
* UI reading a url the database does not have, which the next restart
* would silently revert.
* Tests mintId, not the url. `tx.mint` is where the payment happened and is
* never rewritten, so a mint that has since moved would fail a url test and
* its whole history would vanish from the list as though the mint had been
* removed. The id answers the question actually being asked: does this
* transaction's mint still exist?
*
* A null mintId means exactly that it does not (removed mint, or history
* older than the v38 backfill), so those are dropped too — which is what
* this prune is for.
*/
updateMintUrl(currentMintUrl: string, updatedMintUrl: string) {
Database.updateInFlightTransactionsMintUrl(currentMintUrl, updatedMintUrl)
for (const tx of self.transactionsMap.values()) {
if (tx.mint === currentMintUrl && isInFlight(tx)) {
tx.setProp('mint', updatedMintUrl)
}
}
log.trace('[updateMintUrl] Repointed in-flight transactions', {currentMintUrl, updatedMintUrl})
},
pruneRecentWithoutCurrentMint() {
const { mintsStore } = getRootStore(self)
const validUrls = new Set(mintsStore.allMints.map(m => m.mintUrl))
const validIds = new Set(mintsStore.allMints.map((m: Mint) => m.id))
const newList = self.recentByUnit.filter(tx => validUrls.has(tx.mint))
const newList = self.recentByUnit.filter(tx => tx.mintId && validIds.has(tx.mintId))
if (newList.length !== self.recentByUnit.length) {
self.recentByUnit.replace(newList)
log.trace('[pruneRecentWithoutCurrentMint]', `${self.recentByUnit.length - newList.length} removed`)
@@ -199,8 +189,21 @@ export const TransactionsStoreModel = types
.actions(self => ({
// ── Main add (push + safe prune) ──
addTransaction: flow(function* addTransaction(newTxData: Partial<Transaction>) {
const dbTx: Transaction = yield Database.addTransactionAsync(newTxData)
// Return type is annotated because the body reaches the root store, and an
// inferred type would loop: RootStore -> TransactionsStore -> RootStore.
addTransaction: flow(function* addTransaction(
newTxData: Partial<Transaction>,
): Generator<any, Transaction, any> {
// Resolved here rather than asked of every call site: they all identify
// the mint by url, but the row needs the id that survives a url edit
// (see Transaction.mintId). Callers may still pass mintId explicitly.
const mintId: string | null =
newTxData.mintId ??
(newTxData.mint
? (getRootStore(self).mintsStore.findByUrl(newTxData.mint)?.id as string | undefined) ?? null
: null)
const dbTx: Transaction = yield Database.addTransactionAsync({...newTxData, mintId})
const tx = TransactionModel.create(dbTx)
self.transactionsMap.set(String(dbTx.id), tx)
+4 -13
View File
@@ -550,7 +550,7 @@ export const WalletStoreModel = types
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, receiveParams)
Database.addInFlightRequest(transactionId, receiveParams)
}
let reservedCounters: OperationCounters | undefined
@@ -665,7 +665,7 @@ export const WalletStoreModel = types
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, sendParams)
Database.addInFlightRequest(transactionId, sendParams)
}
let reservedCounters: OperationCounters | undefined
@@ -975,12 +975,7 @@ export const WalletStoreModel = types
// onCountersReserved never fired, so we never wrote it back.
// @ts-ignore
if (cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
Database.addInFlightRequest(
transactionId,
mintUrl,
cashuWallet.keysetId,
mintParams,
)
Database.addInFlightRequest(transactionId, mintParams)
}
let reservedCounters: OperationCounters | undefined
@@ -1082,7 +1077,7 @@ export const WalletStoreModel = types
// @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, mintParams)
Database.addInFlightRequest(transactionId, mintParams)
}
let reservedCounters: OperationCounters | undefined
@@ -1230,8 +1225,6 @@ export const WalletStoreModel = types
// even if the app dies right after the payment is submitted.
Database.addMeltRecovery(
transactionId,
mintUrl,
cashuWallet.keysetId,
CashuUtils.serializeMeltPreview(meltPreview),
)
@@ -1425,8 +1418,6 @@ export const WalletStoreModel = types
// recoverable even if the app dies the moment after.
Database.addMeltRecovery(
transactionId,
mintUrl,
cashuWallet.keysetId,
CashuUtils.serializeMeltPreview(meltPreview),
)
+26 -4
View File
@@ -18,6 +18,7 @@ import * as Sentry from '@sentry/react-native'
import type { RootStore } from '../RootStore'
import { Database, MMKVStorage } from '../../services'
import type { MeltRecoverySeed, InFlightRequestSeed, CounterSeed } from '../../services/db'
import type { Mint } from '../Mint'
import { log } from '../../services/logService'
import { rootStoreModelVersion } from '../RootStore'
import AppError, { Err } from '../../utils/AppError'
@@ -294,8 +295,6 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
if (entry?.meltPreview && typeof entry.transactionId === 'number') {
seeds.push({
transactionId: entry.transactionId,
mintUrl: mint.mintUrl,
keysetId: counter.keyset,
meltPreview: entry.meltPreview,
})
}
@@ -320,8 +319,6 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
if (entry?.request && typeof entry.transactionId === 'number') {
seeds.push({
transactionId: entry.transactionId,
mintUrl: mint.mintUrl,
keysetId: counter.keyset,
request: entry.request,
})
}
@@ -356,6 +353,31 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
userSettingsStore.setIsOnboarded(false)
}
if(currentVersion < 38) {
// db v33 added onchain_mint_quotes.mintId and reservations.mintId so
// those rows reference the mint by its stable id rather than by url.
// The column had to be added empty: mints live in this MST snapshot,
// NOT in SQLite, so no SQL statement can map url -> id. Hence the
// backfill here, where the store is hydrated and the mapping exists.
//
// Matching on url is trustworthy at exactly this moment and no other:
// until now a mint's url could not change without these rows being
// rewritten too, so row.mintUrl still agrees with the mint. This spends
// that join ONCE, at rest, instead of on every rename.
//
// Rows left NULL belong to a mint no longer in the wallet and are dead
// regardless. The backfill only fills NULLs, so it is safe to re-run.
const mintRefs = rootStore.mintsStore.allMints.map((m: Mint) => ({
id: m.id as string,
mintUrl: m.mintUrl,
}))
if (mintRefs.length > 0) {
Database.backfillTransactionMintIds(mintRefs)
Database.backfillOnchainMintQuoteMintIds(mintRefs)
Database.backfillReservationMintIds(mintRefs)
}
}
// Set once, after all steps succeed: if any step throws, the version is
// NOT bumped and the whole migration retries on the next launch.
rootStore.setVersion(rootStoreModelVersion)
+1 -1
View File
@@ -489,7 +489,7 @@ return (
label="cashuPaymentRequest_to"
isFirst={true}
value={
mintsStore.findByUrl(transaction.mint)
mintsStore.findByTransaction(transaction)
?.shortname as string
}
/>
+1 -1
View File
@@ -1260,7 +1260,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
label="tranDetailScreen_trasferredTo"
isFirst={true}
value={
mintsStore.findByUrl(transaction.mint)
mintsStore.findByTransaction(transaction)
?.shortname as string
}
/>
+1 -1
View File
@@ -462,7 +462,7 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
<TranItem
label="transactionCommon_receivedTo"
isFirst={true}
value={mintsStore.findByUrl(transaction.mint)?.shortname as string}
value={mintsStore.findByTransaction(transaction)?.shortname as string}
/>
{transaction?.memo && (
<TranItem
+1 -1
View File
@@ -1583,7 +1583,7 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
<TranItem
label="tranDetailScreen_sentTo"
isFirst={true}
value={mintsStore.findByUrl(transaction.mint)?.shortname as string}
value={mintsStore.findByTransaction(transaction)?.shortname as string}
/>
{transaction?.memo && (
<TranItem
+1 -1
View File
@@ -1107,7 +1107,7 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
label="topup_to"
isFirst={true}
value={
mintsStore.findByUrl(transaction.mint)
mintsStore.findByTransaction(transaction)
?.shortname as string
}
/>
+13 -8
View File
@@ -131,7 +131,7 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
setIsDataParsable(false)
}
const mintInstance = mintsStore.findByUrl(tx.mint)
const mintInstance = mintsStore.findByTransaction(tx)
if (mintInstance) {
setMint(mintInstance)
}
@@ -703,7 +703,7 @@ const ReceiveInfoBlock = function (props: {
increaseProofsCounter(transaction.unit)
}
const mintInstance = mintsStore.findByUrl(transaction.mint)
const mintInstance = mintsStore.findByTransaction(transaction)
if(!mintInstance) {
throw new AppError(Err.NOTFOUND_ERROR, 'Mint not found in the wallet state, cannot retry receive', {
@@ -2024,7 +2024,7 @@ const TransferInfoBlock = function (props: {
colorScheme: 'dark' | 'light'
}) {
const {transaction, mint, isDataParsable} = props
const {proofsStore, transactionsStore} = useStores()
const {proofsStore, transactionsStore, mintsStore} = useStores()
const navigation = useNavigation()
@@ -2044,9 +2044,14 @@ const TransferInfoBlock = function (props: {
const {paymentRequest, unit, mint} = transaction
if(!paymentRequest || !unit || !mint) {
log.error('[onPayDraftTransfer] Missing params', {paymentRequest, unit, mint})
// The mint's live url: this is handed to the Transfer screen to actually pay,
// so it must be where the mint answers NOW, not the url the draft was created
// at (transaction.mint, which is frozen as history).
const mintInstance = mintsStore.findByTransaction(transaction)
if(!paymentRequest || !unit || !mint || !mintInstance) {
log.error('[onPayDraftTransfer] Missing params', {paymentRequest, unit, mint, mintId: transaction.mintId})
setResultModalInfo({
status: TransactionStatus.ERROR,
message: 'This transaction is missing data needed to be paid.'
@@ -2063,10 +2068,10 @@ const TransferInfoBlock = function (props: {
encodedInvoice: transaction.paymentRequest,
paymentOption: TransferOption.PASTE_OR_SCAN_INVOICE,
unit: transaction.unit,
mintUrl: transaction.mint,
mintUrl: mintInstance.mintUrl,
draftTransactionId: transaction.id
}
})
})
}
const onRevertPreparedTransfer = async function () {
+1 -1
View File
@@ -1073,7 +1073,7 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
label="tranDetailScreen_trasferredTo"
isFirst={true}
value={
mintsStore.findByUrl(transaction.mint)
mintsStore.findByTransaction(transaction)
?.shortname as string
}
/>
+31 -23
View File
@@ -13,12 +13,16 @@ import {log} from '../logService'
//
// A row exists only while a request is in-flight; it is deleted on success or
// terminal failure. Keyed by transactionId.
//
// A CHILD of the transaction: the primary key IS the parent's id, so the parent
// owns the mint reference and this table stores none. It used to duplicate
// `mintUrl` and `keysetId` — keysetId had no reader at all, and mintUrl served one
// query, which now joins through the parent. One owner of the fact means nothing
// here can go stale when a mint moves.
// ─────────────────────────────────────────────────────────────────────────────
export type InFlightRequestRecord = {
transactionId: number
mintUrl: string | null
keysetId: string | null
request: any
createdAt: string | null
}
@@ -26,15 +30,11 @@ export type InFlightRequestRecord = {
/** A single in-flight entry for the one-time seed from the MST/MMKV snapshot. */
export type InFlightRequestSeed = {
transactionId: number
mintUrl?: string
keysetId?: string
request: any
}
const rowToRecord = (row: any): InFlightRequestRecord => ({
transactionId: row.transactionId,
mintUrl: row.mintUrl,
keysetId: row.keysetId,
request: JSON.parse(row.request),
createdAt: row.createdAt,
})
@@ -43,17 +43,12 @@ const rowToRecord = (row: any): InFlightRequestRecord => ({
* Store (or replace) the in-flight request for a transaction. Overwrites an
* existing row — matching the previous addInFlightRequest set() semantics.
*/
export const addInFlightRequest = function (
transactionId: number,
mintUrl: string | undefined,
keysetId: string | undefined,
request: any,
): void {
export const addInFlightRequest = function (transactionId: number, request: any): void {
try {
getInstance().execute(
`INSERT OR REPLACE INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
VALUES (?, ?, ?, ?, ?)`,
[transactionId, mintUrl ?? null, keysetId ?? null, JSON.stringify(request), new Date().toISOString()],
`INSERT OR REPLACE INTO inflight_requests (transactionId, request, createdAt)
VALUES (?, ?, ?)`,
[transactionId, JSON.stringify(request), new Date().toISOString()],
)
} catch (e: any) {
throw dbError('In-flight request could not be saved to the database', e)
@@ -64,7 +59,7 @@ export const addInFlightRequest = function (
export const getInFlightRequest = function (transactionId: number): InFlightRequestRecord | undefined {
try {
const {rows} = getInstance().execute(
`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE transactionId = ?`,
`SELECT transactionId, request, createdAt FROM inflight_requests WHERE transactionId = ?`,
[transactionId],
)
const row = rows?.item(0)
@@ -74,12 +69,25 @@ export const getInFlightRequest = function (transactionId: number): InFlightRequ
}
}
/** All in-flight requests for a mint (drives the per-mint recovery sweep). */
export const getInFlightRequestsByMint = function (mintUrl: string): InFlightRequestRecord[] {
/**
* All in-flight requests of a mint (drives the per-mint recovery sweep).
*
* Joins through the owning transaction rather than storing a mint reference here:
* `transactions.mintId` is the single owner of that fact, and being an id it
* survives a mint-url edit — the url copy this table used to keep did not.
*
* A request whose transaction has been deleted is correctly invisible: the retry
* exists to settle proofs onto that transaction, so without it there is nothing to
* apply the result to.
*/
export const getInFlightRequestsByMintId = function (mintId: string): InFlightRequestRecord[] {
try {
const {rows} = getInstance().execute(
`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE mintUrl = ?`,
[mintUrl],
`SELECT r.transactionId, r.request, r.createdAt
FROM inflight_requests r
JOIN transactions t ON t.id = r.transactionId
WHERE t.mintId = ?`,
[mintId],
)
return (rows?._array ?? []).map(rowToRecord)
} catch (e: any) {
@@ -106,10 +114,10 @@ export const seedInFlightRequests = function (seeds: InFlightRequestSeed[]): {se
const now = new Date().toISOString()
getInstance().executeBatch(
seeds.map(s => [
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
VALUES (?, ?, ?, ?, ?)
`INSERT INTO inflight_requests (transactionId, request, createdAt)
VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`,
[s.transactionId, s.mintUrl ?? null, s.keysetId ?? null, JSON.stringify(s.request), now],
[s.transactionId, JSON.stringify(s.request), now],
]),
)
log.info('[seedInFlightRequests]', 'Seeded in-flight requests into SQLite', {count: seeds.length})
+10 -6
View File
@@ -22,7 +22,7 @@ import {
getPendingOnchainTransfers,
addTransactionAsync,
updateTransaction,
updateInFlightTransactionsMintUrl,
backfillTransactionMintIds,
expireAllAfterRecovery,
updateStatusesAsync,
deleteTransactionsByStatus,
@@ -46,6 +46,7 @@ import {
commitReservation,
rollbackReservation,
getOpenReservations,
backfillReservationMintIds,
} from './reservationsRepo'
import {
getCounters,
@@ -63,7 +64,7 @@ import {
import {
addInFlightRequest,
getInFlightRequest,
getInFlightRequestsByMint,
getInFlightRequestsByMintId,
removeInFlightRequest,
seedInFlightRequests,
} from './inFlightRepo'
@@ -75,7 +76,8 @@ import {
import {
addOnchainMintQuote,
getOnchainMintQuote,
getOnchainMintQuotesByMint,
getOnchainMintQuotesByMintId,
backfillOnchainMintQuoteMintIds,
getWatchedOnchainMintQuotes,
updateOnchainMintQuoteAmounts,
extendOnchainMintQuoteWatch,
@@ -112,7 +114,7 @@ export const Database = {
getPendingOnchainTransfers,
addTransactionAsync,
updateTransaction,
updateInFlightTransactionsMintUrl,
backfillTransactionMintIds,
expireAllAfterRecovery,
updateStatusesAsync,
deleteTransactionsByStatus,
@@ -132,6 +134,7 @@ export const Database = {
commitReservation,
rollbackReservation,
getOpenReservations,
backfillReservationMintIds,
getCounters,
getCounter,
setCounter,
@@ -143,7 +146,7 @@ export const Database = {
seedMeltRecoveries,
addInFlightRequest,
getInFlightRequest,
getInFlightRequestsByMint,
getInFlightRequestsByMintId,
removeInFlightRequest,
seedInFlightRequests,
allocateNextCounter,
@@ -151,7 +154,8 @@ export const Database = {
setWalletCounter,
addOnchainMintQuote,
getOnchainMintQuote,
getOnchainMintQuotesByMint,
getOnchainMintQuotesByMintId,
backfillOnchainMintQuoteMintIds,
getWatchedOnchainMintQuotes,
updateOnchainMintQuoteAmounts,
extendOnchainMintQuoteWatch,
+14 -15
View File
@@ -15,12 +15,17 @@ import {StoredMeltPreview} from '../cashu/cashuUtils'
//
// A row exists only while a melt is in-flight; it is deleted on terminal
// success/failure. Keyed by transactionId.
//
// A CHILD of the transaction: the primary key IS the parent's id, so the parent
// owns the mint reference and this table stores none. It used to duplicate
// `mintUrl` and `keysetId`; neither had a single reader. The keyset that IS used
// comes from inside `meltPreview`, and every caller arrives here already holding
// the transaction. One owner of each fact, so nothing here goes stale on a
// mint-url edit.
// ─────────────────────────────────────────────────────────────────────────────
export type MeltRecoveryRecord = {
transactionId: number
mintUrl: string | null
keysetId: string | null
meltPreview: StoredMeltPreview
createdAt: string | null
}
@@ -28,8 +33,6 @@ export type MeltRecoveryRecord = {
/** A single melt-recovery entry for the one-time seed from the MST/MMKV snapshot. */
export type MeltRecoverySeed = {
transactionId: number
mintUrl?: string
keysetId?: string
meltPreview: StoredMeltPreview
}
@@ -40,16 +43,14 @@ export type MeltRecoverySeed = {
*/
export const addMeltRecovery = function (
transactionId: number,
mintUrl: string | undefined,
keysetId: string | undefined,
meltPreview: StoredMeltPreview,
): void {
try {
getInstance().execute(
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
VALUES (?, ?, ?, ?, ?)
`INSERT INTO melt_recovery (transactionId, meltPreview, createdAt)
VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`,
[transactionId, mintUrl ?? null, keysetId ?? null, JSON.stringify(meltPreview), new Date().toISOString()],
[transactionId, JSON.stringify(meltPreview), new Date().toISOString()],
)
} catch (e: any) {
throw dbError('Melt recovery could not be saved to the database', e)
@@ -60,15 +61,13 @@ export const addMeltRecovery = function (
export const getMeltRecovery = function (transactionId: number): MeltRecoveryRecord | undefined {
try {
const {rows} = getInstance().execute(
`SELECT transactionId, mintUrl, keysetId, meltPreview, createdAt FROM melt_recovery WHERE transactionId = ?`,
`SELECT transactionId, meltPreview, createdAt FROM melt_recovery WHERE transactionId = ?`,
[transactionId],
)
const row = rows?.item(0)
if (!row) return undefined
return {
transactionId: row.transactionId,
mintUrl: row.mintUrl,
keysetId: row.keysetId,
meltPreview: JSON.parse(row.meltPreview) as StoredMeltPreview,
createdAt: row.createdAt,
}
@@ -97,10 +96,10 @@ export const seedMeltRecoveries = function (seeds: MeltRecoverySeed[]): {seeded:
const db = getInstance()
db.executeBatch(
seeds.map(s => [
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
VALUES (?, ?, ?, ?, ?)
`INSERT INTO melt_recovery (transactionId, meltPreview, createdAt)
VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`,
[s.transactionId, s.mintUrl ?? null, s.keysetId ?? null, JSON.stringify(s.meltPreview), now],
[s.transactionId, JSON.stringify(s.meltPreview), now],
]),
)
log.info('[seedMeltRecoveries]', 'Seeded melt recovery entries into SQLite', {count: seeds.length})
+138 -9
View File
@@ -1,13 +1,70 @@
import {DbConnection, SQLBatchTuple} from './connection'
import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS, MINT_COUNTERS_COLUMN_NAMES, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS, WALLET_COUNTERS_COLUMNS, ONCHAIN_MINT_QUOTES_COLUMNS} from './schema'
// RESERVATIONS_COLUMNS / ONCHAIN_MINT_QUOTES_COLUMNS are deliberately NOT imported:
// v26 and v31 build those tables from the frozen historical shapes below, not from
// the live schema. See the note there.
import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, MINT_COUNTERS_COLUMNS, MINT_COUNTERS_COLUMN_NAMES, 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 = 32
type Migration = {version: number; queries: SQLBatchTuple[]}
// ─────────────────────────────────────────────────────────────────────────────
// Historical column shapes — FROZEN. Do not edit to match schema.ts.
//
// A migration must create a table as it existed AT ITS OWN VERSION. These used to
// read the live constants from schema.ts, which is unsound the moment a column is
// added: a device replaying the old migration would get TODAY's shape, and the
// later ALTER that adds that column would fail with "duplicate column name" —
// breaking upgrades from exactly the older versions the migration exists to serve.
// (v33 adds mintId to both of these, so that is no longer hypothetical.)
// ─────────────────────────────────────────────────────────────────────────────
/** `reservations` as created by v26, before v33 added mintId. */
const RESERVATIONS_COLUMNS_V26 = `
id TEXT PRIMARY KEY NOT NULL,
transactionId INTEGER NOT NULL,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
operationType TEXT NOT NULL,
lockedProofs TEXT NOT NULL,
createdAt TEXT NOT NULL
`
/** `melt_recovery` as created by v28, before v34 dropped mintUrl/keysetId. */
const MELT_RECOVERY_COLUMNS_V28 = `
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
meltPreview TEXT NOT NULL,
createdAt TEXT
`
/** `inflight_requests` as created by v29, before v34 dropped mintUrl/keysetId. */
const INFLIGHT_REQUESTS_COLUMNS_V29 = `
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
request TEXT NOT NULL,
createdAt TEXT
`
/** `onchain_mint_quotes` as created by v31, before v33 added mintId. */
const ONCHAIN_MINT_QUOTES_COLUMNS_V31 = `
quote TEXT PRIMARY KEY NOT NULL,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
address TEXT NOT NULL,
counterIndex INTEGER NOT NULL,
pubkey TEXT NOT NULL,
amountRequested INTEGER,
amountPaid INTEGER NOT NULL DEFAULT 0,
amountIssued INTEGER NOT NULL DEFAULT 0,
expiry INTEGER,
watchUntil TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT
`
/**
* Ordered migration registry. On startup every migration whose `version` is
* greater than the device's current version is applied, in order, inside a
@@ -16,7 +73,7 @@ type Migration = {version: number; queries: SQLBatchTuple[]}
* To add a migration: append an entry with the next version number. No runner
* logic changes are needed.
*/
const MIGRATIONS: Migration[] = [
export const MIGRATIONS: Migration[] = [
// IF EXISTS: on devices that never had a usersettings table this DROP used to
// error (and the error was swallowed, leaving the migration stuck). Making it
// defensive lets these devices migrate forward cleanly and lets us treat any
@@ -67,7 +124,7 @@ const MIGRATIONS: Migration[] = [
{
// Add reservations table for atomic proof reservations (Phase 5).
version: 26,
queries: [[createTable('reservations', RESERVATIONS_COLUMNS)]],
queries: [[createTable('reservations', RESERVATIONS_COLUMNS_V26)]],
},
{
// Add per-keyset derivation counters table. The table is created empty here;
@@ -84,14 +141,14 @@ const MIGRATIONS: Migration[] = [
// meltCounterValues from the MST/MMKV snapshot are copied by a one-time JS
// seed (see setupRootStore._runMigrations).
version: 28,
queries: [[createTable('melt_recovery', MELT_RECOVERY_COLUMNS)]],
queries: [[createTable('melt_recovery', MELT_RECOVERY_COLUMNS_V28)]],
},
{
// Add per-transaction in-flight request table. Empty on creation; any
// in-flight requests from the MST/MMKV snapshot are copied by a one-time JS
// seed (see setupRootStore._runMigrations).
version: 29,
queries: [[createTable('inflight_requests', INFLIGHT_REQUESTS_COLUMNS)]],
queries: [[createTable('inflight_requests', INFLIGHT_REQUESTS_COLUMNS_V29)]],
},
{
// Add wallet-global derivation counters (NUT-20 quote-locking keys).
@@ -106,7 +163,7 @@ const MIGRATIONS: Migration[] = [
// Both empty on creation — no onchain transaction can predate this.
version: 31,
queries: [
[createTable('onchain_mint_quotes', ONCHAIN_MINT_QUOTES_COLUMNS)],
[createTable('onchain_mint_quotes', ONCHAIN_MINT_QUOTES_COLUMNS_V31)],
[`ALTER TABLE transactions ADD COLUMN outpoint TEXT`],
],
},
@@ -139,8 +196,80 @@ const MIGRATIONS: Migration[] = [
[`ALTER TABLE mint_counters_v32 RENAME TO mint_counters`],
],
},
{
// Reference the owning mint by its stable id rather than its url.
//
// A mint url is a network locator; mints move. Rows that outlive the move —
// an onchain quote's address stays creditable for as long as the mint exists —
// cannot be addressed by one, and following a stale url just polls a dead host
// forever, silently. Mint.id never changes, so it survives.
//
// Added nullable and left NULL here: mints live in the MST/MMKV snapshot, not
// SQLite, so nothing in SQL can map url -> id. The v38 seed in setupRootStore
// backfills it once the store is hydrated. A row still NULL after that belongs
// to a mint no longer in the wallet, and is dead either way.
//
// `mintUrl` stays on both tables as the historical record of where the row was
// created; it is no longer followed.
version: 33,
queries: [
[`ALTER TABLE onchain_mint_quotes ADD COLUMN mintId TEXT`],
[`ALTER TABLE reservations ADD COLUMN mintId TEXT`],
],
},
{
// Split the two jobs `transactions.mint` was doing, and stop the child tables
// duplicating the answer.
//
// `transactions.mint` meant two things at once, switched by status: a
// historical record of where a finished payment happened, AND a live pointer
// the wallet dialled for an open one. A mint-url edit therefore had to rewrite
// the in-flight rows — rewriting the same column that records the past. `mintId`
// takes over the identity job, so `mint` can be frozen as pure history.
//
// inflight_requests and melt_recovery are CHILD rows of a transaction (their
// primary key IS transactionId), so the parent already owns "which mint". Their
// own mintUrl/keysetId copies had no readers at all — dead denormalization of
// exactly the kind that goes stale. They are rebuilt without them; the one
// mint-scoped query joins through the parent instead.
//
// No DROP COLUMN (unsupported on older SQLite), hence the rebuilds. Both tables
// hold at most a handful of rows, and only while an operation is in flight.
version: 34,
queries: [
[`ALTER TABLE transactions ADD COLUMN mintId TEXT`],
[createTable('inflight_requests_v34', INFLIGHT_REQUESTS_COLUMNS, false)],
[
`INSERT INTO inflight_requests_v34 (transactionId, request, createdAt)
SELECT transactionId, request, createdAt FROM inflight_requests`,
],
[`DROP TABLE inflight_requests`],
[`ALTER TABLE inflight_requests_v34 RENAME TO inflight_requests`],
[createTable('melt_recovery_v34', MELT_RECOVERY_COLUMNS, false)],
[
`INSERT INTO melt_recovery_v34 (transactionId, meltPreview, createdAt)
SELECT transactionId, meltPreview, createdAt FROM melt_recovery`,
],
[`DROP TABLE melt_recovery`],
[`ALTER TABLE melt_recovery_v34 RENAME TO melt_recovery`],
],
},
]
/**
* The schema version this build expects: whatever the newest migration produces.
*
* DERIVED, never hand-set. It used to be a literal that had to be bumped in step
* with the list, and forgetting was silent in the worst way: a migration below
* `_dbVersion` simply never runs (the runner only applies `currentVersion <
* migration.version` and then stores `_dbVersion`), so devices upgrade to a schema
* the code does not have, and the failure surfaces later as a missing column. A
* fresh install would be fine, which is exactly what makes it easy to miss.
*/
export const _dbVersion = MIGRATIONS.reduce((max, m) => Math.max(max, m.version), 0)
/**
* Pure read of the stored schema version. Returns null when the version row has
* not been seeded yet (a fresh database). Never mutates.
+62 -8
View File
@@ -41,6 +41,16 @@ export const ONCHAIN_QUOTE_WATCH_DAYS = 7
export type OnchainMintQuoteRecord = {
quote: string
/**
* Stable id (Mint.id) of the owning mint — the ONLY reference to resolve for
* processing. Null when the mint is no longer in the wallet (or on a pre-v38 row
* the backfill has not reached yet), in which case the quote is unusable.
*/
mintId: string | null
/**
* Where the quote was created. A historical record — do NOT follow it: after a
* mint-url edit it points at a dead host, and these rows outlive the move.
*/
mintUrl: string
unit: string
address: string
@@ -59,7 +69,7 @@ export type OnchainMintQuoteRecord = {
updatedAt: string | null
}
const COLS = `quote, mintUrl, unit, address, counterIndex, pubkey, amountRequested,
const COLS = `quote, mintId, mintUrl, unit, address, counterIndex, pubkey, amountRequested,
amountPaid, amountIssued, expiry, watchUntil, createdAt, updatedAt`
/**
@@ -70,7 +80,11 @@ const COLS = `quote, mintUrl, unit, address, counterIndex, pubkey, amountRequest
* NUT-20 index) is already safe, so a deposit to that address remains mintable.
*/
export const addOnchainMintQuote = function (
q: Omit<OnchainMintQuoteRecord, 'amountPaid' | 'amountIssued' | 'createdAt' | 'updatedAt' | 'watchUntil'> & {
q: Omit<
OnchainMintQuoteRecord,
'mintId' | 'amountPaid' | 'amountIssued' | 'createdAt' | 'updatedAt' | 'watchUntil'
> & {
mintId?: string | null
amountPaid?: number
amountIssued?: number
watchUntil?: string
@@ -84,9 +98,10 @@ export const addOnchainMintQuote = function (
getInstance().execute(
`INSERT OR REPLACE INTO onchain_mint_quotes (${COLS})
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
q.quote,
q.mintId ?? null,
q.mintUrl,
q.unit,
q.address,
@@ -102,7 +117,7 @@ export const addOnchainMintQuote = function (
],
)
log.debug('[addOnchainMintQuote]', {quote: q.quote, mintUrl: q.mintUrl})
log.debug('[addOnchainMintQuote]', {quote: q.quote, mintId: q.mintId, mintUrl: q.mintUrl})
} catch (e: any) {
throw dbError('Onchain mint quote could not be saved to the database', e)
}
@@ -182,12 +197,17 @@ export const getWatchedOnchainMintQuotes = function (): OnchainMintQuoteRecord[]
}
}
/** Every quote for a mint, newest first. Drives the manual re-check. */
export const getOnchainMintQuotesByMint = function (mintUrl: string): OnchainMintQuoteRecord[] {
/**
* Every quote of a mint, newest first.
*
* Keyed by mintId, not url: the mint may have moved since the quote was created,
* and a url match would then silently return nothing.
*/
export const getOnchainMintQuotesByMintId = function (mintId: string): OnchainMintQuoteRecord[] {
try {
const {rows} = getInstance().execute(
`SELECT ${COLS} FROM onchain_mint_quotes WHERE mintUrl = ? ORDER BY createdAt DESC`,
[mintUrl],
`SELECT ${COLS} FROM onchain_mint_quotes WHERE mintId = ? ORDER BY createdAt DESC`,
[mintId],
)
return (rows?._array ?? []) as OnchainMintQuoteRecord[]
} catch (e: any) {
@@ -195,6 +215,40 @@ export const getOnchainMintQuotesByMint = function (mintUrl: string): OnchainMin
}
}
/**
* One-time backfill of `mintId` from the url a row was created at (the v38 seed).
*
* Only touches rows that have no mintId yet, so it is idempotent and can never
* overwrite a resolved id with a guess from a url that has since moved on.
*
* Matching on url is sound ONLY here, at the moment of upgrade: before this
* migration nothing could edit a mint's url without also rewriting these rows'
* mintUrl, so the two still agree. That is exactly why the join is spent once, at
* rest, rather than on every rename.
*/
export const backfillOnchainMintQuoteMintIds = function (
mints: Array<{id: string; mintUrl: string}>,
): {updated: number} {
if (mints.length === 0) return {updated: 0}
try {
const db = getInstance()
let updated = 0
for (const mint of mints) {
const {rowsAffected} = db.execute(
`UPDATE onchain_mint_quotes SET mintId = ? WHERE mintUrl = ? AND mintId IS NULL`,
[mint.id, mint.mintUrl],
)
updated += rowsAffected ?? 0
}
log.info('[backfillOnchainMintQuoteMintIds]', 'Backfilled onchain quote mint ids', {updated})
return {updated}
} catch (e: any) {
throw dbError('Onchain mint quote mintIds could not be backfilled', e)
}
}
/**
* Reopen the watch window on a quote — the user asking "did anything else arrive?"
* from the transaction detail, after the quote had been archived.
+44 -3
View File
@@ -37,8 +37,14 @@ export type LockedProofSnapshot = {
export type ReservationRow = {
id: string
transactionId: number
/**
* Stable id (Mint.id) of the owning mint — resolve this, not `mintUrl`, to reach
* the mint. Null only on a row opened before v33.
*/
mintId: string | null
/** Where the operation started. Historical/debug; not followed. */
mintUrl: string
transactionId: number
unit: string
operationType: string
lockedProofs: LockedProofSnapshot[]
@@ -56,6 +62,7 @@ export const openReservation = function (
reservation: {
id: string
transactionId: number
mintId?: string
mintUrl: string
unit: string
operationType: string
@@ -68,11 +75,12 @@ export const openReservation = function (
const batch: SQLBatchTuple[] = []
batch.push([
`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO reservations (id, transactionId, mintId, mintUrl, unit, operationType, lockedProofs, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
reservation.id,
reservation.transactionId,
reservation.mintId ?? null,
reservation.mintUrl,
reservation.unit,
reservation.operationType,
@@ -320,6 +328,38 @@ export const rollbackReservation = function (
* Return all reservations currently in the DB. Used at startup to roll back
* orphans (operations whose process died before they could commit or rollback).
*/
/**
* One-time backfill of `mintId` from the url a reservation was opened at (the v38
* seed). See backfillOnchainMintQuoteMintIds — same reasoning, and only fills
* NULLs, so it is idempotent.
*
* Almost always a no-op in practice: a reservation lives only for the duration of
* an operation, so an upgrade rarely catches one open. It exists for the upgrade
* that lands right after a crash left an orphan behind.
*/
export const backfillReservationMintIds = function (
mints: Array<{id: string; mintUrl: string}>,
): {updated: number} {
if (mints.length === 0) return {updated: 0}
try {
const db = getInstance()
let updated = 0
for (const mint of mints) {
const {rowsAffected} = db.execute(
`UPDATE reservations SET mintId = ? WHERE mintUrl = ? AND mintId IS NULL`,
[mint.id, mint.mintUrl],
)
updated += rowsAffected ?? 0
}
log.info('[backfillReservationMintIds]', 'Backfilled reservation mint ids', {updated})
return {updated}
} catch (e: any) {
throw dbError('Reservation mintIds could not be backfilled', e)
}
}
export const getOpenReservations = function (): ReservationRow[] {
try {
const db = getInstance()
@@ -338,6 +378,7 @@ export const getOpenReservations = function (): ReservationRow[] {
result.push({
id: row.id,
transactionId: row.transactionId,
mintId: row.mintId ?? null,
mintUrl: row.mintUrl,
unit: row.unit,
operationType: row.operationType,
+82 -9
View File
@@ -1,14 +1,44 @@
import {SQLBatchTuple} from './connection'
/**
* Schema definitions — the single source of truth for table shapes.
* Schema definitions — the source of truth for the CURRENT table shapes, used to
* build a fresh database (see createSchemaQueries).
*
* The `proofs` and `reservations` column lists are referenced from more than
* one place (first-run creation here, plus the v25 proofs rebuild and the v26
* reservations add in migrations.ts). Defining the columns once and generating
* every `CREATE TABLE` from them guarantees the definitions can never drift.
* These constants track the latest shape and therefore CHANGE over time. A
* migration must never build a table from one: a migration has to create the table
* as it existed AT ITS OWN VERSION, or a device replaying it gets today's shape and
* the later ALTER that adds the column fails with "duplicate column name". Old
* migrations freeze their own copy of the shape locally — see the historical
* constants at the top of migrations.ts.
*
* A table rebuild in a migration may reference a live constant only when the
* migration IS the change that produced that shape (as with the v25 proofs rebuild
* and the v32 mint_counters re-key), and even then it stops being true the next
* time the shape moves.
*/
/**
* Wallet transactions.
*
* `mint` and `mintId` are BOTH mint references, and the split is deliberate:
*
* - `mint` is the url the payment actually happened at — a historical fact, frozen
* once written. It is what history displays, and rewriting it would be a lie
* about the past.
* - `mintId` (Mint.id) is the identity, and the ONLY reference to resolve when
* doing something: reaching the mint over the network, or asking "is this
* transaction's mint still in the wallet?". It survives a mint-url edit.
*
* They used to be one column doing both jobs, switched by `status` — live pointer
* while open, history once terminal. That conflation is why a mint-url edit had to
* rewrite in-flight rows: the same column that recorded the past was also being
* dialled. With mintId carrying the identity, `mint` can simply stop moving.
*
* `mintId` is nullable: it cannot be backfilled in SQL (mints live in the MST/MMKV
* snapshot — see the v38 seed in setupRootStore), and history legitimately contains
* transactions of mints since removed, which have no id to point at. A null means
* "no mint in this wallet"; `mint` still records where it happened.
*/
export const TRANSACTIONS_COLUMNS = `
id INTEGER PRIMARY KEY NOT NULL,
paymentId TEXT,
@@ -22,6 +52,7 @@ export const TRANSACTIONS_COLUMNS = `
sentTo TEXT,
profile TEXT,
memo TEXT,
mintId TEXT,
mint TEXT,
quote TEXT,
paymentRequest TEXT,
@@ -59,9 +90,23 @@ export const DBVERSION_COLUMNS = `
createdAt TEXT
`
/**
* Open outgoing-operation reservations (a row exists only between reserve() and
* commit()/rollback()).
*
* `mintId` (Mint.id) is the owning-mint reference; `mintUrl` is a debug record of
* where the operation started. Short-lived as these rows are, the url still cannot
* be trusted at commit time: a mint-url edit RACING an open send would commit the
* new proofs under the pre-edit url, stranding them at a url no mint owns —
* an invisible balance. Resolving mintId at commit always yields the live url.
*
* Nullable for the same reason as onchain_mint_quotes: ALTER TABLE cannot backfill
* from MST/MMKV. See the v38 seed in setupRootStore.
*/
export const RESERVATIONS_COLUMNS = `
id TEXT PRIMARY KEY NOT NULL,
transactionId INTEGER NOT NULL,
mintId TEXT,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
operationType TEXT NOT NULL,
@@ -111,11 +156,16 @@ export const MINT_COUNTERS_COLUMNS = `
*
* Keyed by transactionId (globally unique). A row exists only while a melt is
* in-flight; it is deleted on terminal success/failure.
*
* A CHILD of the transaction — the primary key IS the parent's id — so it carries
* no mint reference of its own: the transaction already owns that fact, and every
* reader arrives here holding the transaction. It used to duplicate `mintUrl` and
* `keysetId`; neither had a single reader (the keyset that IS used comes from
* inside `meltPreview`), so they were dead denormalization of precisely the kind
* that goes stale on a mint-url edit.
*/
export const MELT_RECOVERY_COLUMNS = `
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
meltPreview TEXT NOT NULL,
createdAt TEXT
`
@@ -130,11 +180,21 @@ export const MELT_RECOVERY_COLUMNS = `
*
* Keyed by transactionId. A row exists only while a request is in-flight; it is
* deleted on success or terminal failure.
*
* A CHILD of the transaction — the primary key IS the parent's id — so it carries
* no mint reference of its own. It used to duplicate `mintUrl` and `keysetId`;
* `keysetId` had no reader at all, and `mintUrl` served exactly one query ("every
* request of this mint"), which now joins through the parent's `mintId` instead.
* That keeps ONE owner of the fact, so there is no second copy to go stale when a
* mint moves.
*
* Reaching the mint through the parent is not merely equivalent, it is more
* correct: a request whose transaction is gone cannot be applied anyway (the retry
* settles proofs onto that transaction, and the handler branches on `tx.type`), so
* a join finding nothing is the right answer rather than a lost row.
*/
export const INFLIGHT_REQUESTS_COLUMNS = `
transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
request TEXT NOT NULL,
createdAt TEXT
`
@@ -178,11 +238,24 @@ export const WALLET_COUNTERS_COLUMNS = `
* there is nothing to bound polling of a quote nobody ever paid. It mirrors the
* 24h fallback bolt11 topup applies when an invoice carries no expiry tag.
*
* The owning mint is referenced by `mintId` (Mint.id), NOT by `mintUrl`. Because a
* row here outlives everything — the address stays creditable for as long as the
* mint exists — it has to survive the mint moving to a new url. `mintUrl` is kept
* as the historical record of where the quote was created and is never followed;
* following it after a url edit polls a dead host forever, silently, and the
* deposit can never be minted.
*
* `mintId` is nullable only because ALTER TABLE cannot backfill it: mints live in
* the MST/MMKV snapshot, not SQLite, so the url->id mapping is resolvable only from
* JS (see the v38 seed in setupRootStore). A null after that seed means the owning
* mint is no longer in the wallet, and the quote is dead regardless.
*
* MELT quotes get no table: they are one-shot and terminal, so their durable state
* (quote id, outpoint, fee) fits on the transaction row.
*/
export const ONCHAIN_MINT_QUOTES_COLUMNS = `
quote TEXT PRIMARY KEY NOT NULL,
mintId TEXT,
mintUrl TEXT NOT NULL,
unit TEXT NOT NULL,
address TEXT NOT NULL,
+27 -40
View File
@@ -1,5 +1,4 @@
import {Transaction, TransactionStatus} from '../../models/Transaction'
import {IN_FLIGHT_STATUSES} from '../../models/TransactionStates'
import AppError, {Err} from '../../utils/AppError'
import {log} from '../logService'
import {getInstance} from './instance'
@@ -50,50 +49,38 @@ export const updateTransaction = function (id: number, fields: Partial<Transacti
}
/**
* Repoint a mint's IN-FLIGHT transactions at its new url, leaving terminal ones
* untouched.
* One-time backfill of `mintId` from the url a transaction happened at (the v38
* seed).
*
* `transactions.mint` carries two meanings, switched by status:
* Only fills NULLs, so it is idempotent and can never re-point a resolved row at
* whichever mint now answers a url. Rows left NULL belong to mints no longer in the
* wallet — legitimate history, which keeps its `mint` url and simply has no mint to
* act on.
*
* - TERMINAL (completed, reverted, …): a historical record of where the payment
* actually happened. Rewriting it would falsify history, so it is frozen.
* - IN-FLIGHT: a LIVE pointer the wallet still calls — checkLightningMintQuote
* (topupOperationApi), checkLightningMeltQuote / checkOnchainMeltQuote
* (transferOperationApi), and findByUrl(tx.mint) on the revert/receive paths.
* Leaving it stale after a mint-url edit strands an open transaction at a dead
* url: a paid topup whose ecash the wallet can never mint.
*
* One statement, so the status test and the write cannot straddle a concurrent
* status transition — a transaction that goes terminal mid-rename is either fully
* in or fully out.
* Matching on url is sound ONLY here, at the moment of upgrade: until now a mint's
* url could not change without in-flight rows being rewritten to match, so the two
* still agree. This spends that join once, at rest, instead of on every rename.
*/
export const updateInFlightTransactionsMintUrl = function (
currentMintUrl: string,
updatedMintUrl: string,
export const backfillTransactionMintIds = function (
mints: Array<{id: string; mintUrl: string}>,
): {updated: number} {
if (mints.length === 0) return {updated: 0}
try {
const statuses = [...IN_FLIGHT_STATUSES]
const placeholders = statuses.map(() => '?').join(', ')
const db = getInstance()
const {rowsAffected} = db.execute(
`UPDATE transactions
SET mint = ?
WHERE mint = ? AND status IN (${placeholders})`,
[updatedMintUrl, currentMintUrl, ...statuses],
)
let updated = 0
const updated = rowsAffected ?? 0
log.debug('[updateInFlightTransactionsMintUrl]', 'Repointed in-flight transactions', {
currentMintUrl,
updatedMintUrl,
updated,
})
for (const mint of mints) {
const {rowsAffected} = db.execute(
`UPDATE transactions SET mintId = ? WHERE mint = ? AND mintId IS NULL`,
[mint.id, mint.mintUrl],
)
updated += rowsAffected ?? 0
}
log.info('[backfillTransactionMintIds]', 'Backfilled transaction mint ids', {updated})
return {updated}
} catch (e: any) {
throw dbError('Could not update transactions mintUrl in database', e)
throw dbError('Transaction mintIds could not be backfilled', e)
}
}
@@ -538,19 +525,19 @@ export const getLastTransactionBy = function (
export const addTransactionAsync = async function (tx: Partial<Transaction>): Promise<Transaction> {
try {
const {type, amount, fee, unit, data, memo, mint, status} = tx
const {type, amount, fee, unit, data, memo, mint, mintId, status} = tx
const now = new Date()
const query = `
INSERT INTO transactions (type, amount, fee, unit, data, memo, mint, status, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO transactions (type, amount, fee, unit, data, memo, mint, mintId, status, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
const params = [type, amount, fee, unit, data, memo, mint, status, now.toISOString()]
const params = [type, amount, fee, unit, data, memo, mint, mintId ?? null, status, now.toISOString()]
const db = getInstance()
const result = await db.executeAsync(query, params)
log.debug('[addTransactionAsync] New transaction added to the database', {id: result.insertId, type, mint, status})
log.debug('[addTransactionAsync] New transaction added to the database', {id: result.insertId, type, mint, mintId, status})
return getTransactionById(result.insertId as number) // already normalized
@@ -31,7 +31,9 @@ const {
*/
const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> => {
const mintUrl = mint.mintUrl
const inFlightRequests = Database.getInFlightRequestsByMint(mintUrl)
// By id, not url: the requests are found through their transaction's mintId, so
// a mint that has changed url still finds its own in-flight work.
const inFlightRequests = Database.getInFlightRequestsByMintId(mint.id!)
const totalRequests = inFlightRequests.length
log.trace('[handleInFlightByMintTask] start', {mintUrl, totalRequests})
@@ -370,7 +372,7 @@ const handleInFlightQueue = async function (): Promise<void> {
for (const mint of mintsStore.allMints) {
if (Database.getInFlightRequestsByMint(mint.mintUrl).length === 0) {
if (Database.getInFlightRequestsByMintId(mint.id!).length === 0) {
log.trace('No inFlight requests for mint, skipping...')
continue
}
@@ -61,6 +61,7 @@ import {
} from '../../../models/Transaction'
import {Database} from '../../../services'
import {OnchainMintQuoteRecord} from '../../db/onchainQuotesRepo'
import type {Mint} from '../../../models/Mint'
import {allocateQuoteKeypair, deriveQuoteKeypair} from '../../cashu/nut20'
import {capMintAmount, mintableAmount} from './onchainAmounts'
import {CashuProof} from '../../cashu/cashuUtils'
@@ -154,6 +155,10 @@ async function createQuote(input: {
// the row (with its counterIndex) is already safe, so a deposit to that address
// stays mintable. Losing it would make the money unspendable.
Database.addOnchainMintQuote({
// The stable reference. mintUrl is stored alongside it purely as the
// record of where this quote was created — the mint may move, and this
// row outlives the move.
mintId: mintsStore.findByUrl(mintUrl)?.id,
quote: quoteResponse.quote,
mintUrl,
unit,
@@ -274,7 +279,8 @@ async function refreshQuote(quoteId: string): Promise<RefreshOnchainQuoteResult>
throw new ValidationError('Unknown onchain mint quote', {quote: quoteId})
}
const quoteResponse = await walletStore.checkOnchainMintQuote(row.mintUrl, quoteId)
const mint = _resolveQuoteMint(row)
const quoteResponse = await walletStore.checkOnchainMintQuote(mint.mintUrl, quoteId)
const amountPaid = Number(quoteResponse.amount_paid ?? 0)
const amountIssued = Number(quoteResponse.amount_issued ?? 0)
@@ -294,7 +300,7 @@ async function refreshQuote(quoteId: string): Promise<RefreshOnchainQuoteResult>
return {quote: quoteId, amountPaid, amountIssued, minted: 0}
}
const transactionId = await _mintAvailable(row, quoteResponse, mintable)
const transactionId = await _mintAvailable(row, mint, quoteResponse, mintable)
return {
quote: quoteId,
@@ -309,6 +315,33 @@ async function refreshQuote(quoteId: string): Promise<RefreshOnchainQuoteResult>
// Private
// ─────────────────────────────────────────────────────────────────────────────
/**
* Resolve a quote's owning mint through its stable id.
*
* `row.mintUrl` is deliberately NOT consulted. It records where the quote was
* created, and a mint that has since moved would leave it pointing at a host that
* no longer answers — while this quote's address stays creditable for as long as
* the mint exists (rows here are never deleted), so a late deposit would be
* unmintable forever, and silently, because the watcher swallows errors by design.
*
* A missing mint means it was removed from the wallet (or, on a row from before
* v33 that the v38 backfill could not match, that its url had already moved on).
* Either way there is nothing to talk to, so this throws rather than guessing.
*/
function _resolveQuoteMint(row: OnchainMintQuoteRecord): Mint {
const mint = row.mintId ? mintsStore.findById(row.mintId) : undefined
if (!mint) {
throw new ValidationError('Onchain quote has no mint in this wallet', {
quote: row.quote,
mintId: row.mintId,
createdAtUrl: row.mintUrl,
})
}
return mint
}
/**
* Mint the available balance on a quote and settle it onto a transaction.
*
@@ -319,24 +352,28 @@ async function refreshQuote(quoteId: string): Promise<RefreshOnchainQuoteResult>
*/
async function _mintAvailable(
row: OnchainMintQuoteRecord,
mintInstance: Mint,
quoteResponse: any,
mintable: number,
): Promise<number> {
const {quote, mintUrl, counterIndex} = row
const {quote, counterIndex} = row
const unit = row.unit as MintUnit
// The mint resolved from row.mintId by the caller — NOT row.mintUrl, which is
// only where the quote was created and may since have moved.
const mintUrl = mintInstance.mintUrl
// The mint may cap a single mint operation; never ask for more than it allows.
// Any remainder stays credited on the quote, where the watch rule keeps it
// visible and the next sweep takes the rest.
const mintInstance = mintsStore.findByUrl(mintUrl)
const maxAmount = mintInstance?.mintMethodSetting!('onchain', unit)?.max_amount
const maxAmount = mintInstance.mintMethodSetting!('onchain', unit)?.max_amount
const amount = capMintAmount(mintable, maxAmount ? Number(maxAmount) : null)
// Re-derive the NUT-20 signing key from the seed. Only the index was persisted.
const seed: Uint8Array = await walletStore.getCachedSeed()
const {privkey} = deriveQuoteKeypair(seed, counterIndex)
const tx = await _findOrCreateTransaction(row, amount, unit)
const tx = await _findOrCreateTransaction(row, mintUrl, amount, unit)
const transactionId = tx.id
let proofs: CashuProof[] = []
@@ -433,6 +470,8 @@ async function _mintAvailable(
*/
async function _findOrCreateTransaction(
row: OnchainMintQuoteRecord,
/** The mint's url NOW (resolved from row.mintId), not row.mintUrl. */
mintUrl: string,
amount: number,
unit: MintUnit,
): Promise<Transaction> {
@@ -470,7 +509,10 @@ async function _findOrCreateTransaction(
fee: 0,
unit,
data: JSON.stringify(transactionData),
mint: row.mintUrl,
// Where this deposit is being settled NOW. A second deposit onto an old
// address is still a payment to the mint at its current url, and this row is
// brand new — it has no history to preserve.
mint: mintUrl,
status: TransactionStatus.PENDING,
})
@@ -490,15 +490,25 @@ function _reloadPrepared(transactionId: number): PreparedReceiveData {
if (!tx.inputToken) {
throw new ValidationError('Transaction is missing input token', {transactionId})
}
const mintInstance = mintsStore.findByUrl(tx.mint)
const token = getDecodedToken(tx.inputToken, mintInstance?.keysetIds ?? [])
// By identity: tx.mint is where the receive was prepared and is frozen, so it
// stops finding the mint once it moves.
const mintInstance = mintsStore.findByTransaction(tx)
if (!mintInstance) {
throw new ValidationError('Transaction mint is no longer in this wallet', {
transactionId,
mintId: tx.mintId,
preparedAtUrl: tx.mint,
})
}
const token = getDecodedToken(tx.inputToken, mintInstance.keysetIds ?? [])
const isOffline = tx.status === TransactionStatus.PREPARED_OFFLINE
return {
transactionId,
tx,
mintUrl: tx.mint,
// The mint's url NOW — this is dialled to complete the receive.
mintUrl: mintInstance.mintUrl,
unit: tx.unit,
amountToReceive: tx.amount,
memo: tx.memo ?? '',
@@ -57,6 +57,26 @@ import AppError, {Err} from '../../../utils/AppError'
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance
/**
* The live url of a transaction's mint.
*
* Resolved through `tx.mintId`, never `tx.mint`: the latter records where the send
* happened and is deliberately frozen, so after a mint-url edit it points at a host
* that no longer answers.
*/
function _txMintUrl(tx: Transaction): string {
const mint = mintsStore.findByTransaction(tx)
if (!mint) {
throw new ValidationError('Transaction mint is no longer in this wallet', {
transactionId: tx.id,
mintId: tx.mintId,
happenedAtUrl: tx.mint,
})
}
return mint.mintUrl
}
// ─────────────────────────────────────────────────────────────────────────────
// Public types
// ─────────────────────────────────────────────────────────────────────────────
@@ -598,7 +618,7 @@ async function finalize(transactionId: number): Promise<CompletedTransaction> {
const result = await WalletTask.syncStateWithMintQueueAwaitable({
proofsToSync: sendProofs,
mintUrl: tx.mint,
mintUrl: _txMintUrl(tx),
proofState: 'PENDING',
})
if (result.completedTransactionIds.includes(transactionId)) {
@@ -631,7 +651,7 @@ async function refresh(transactionId: number): Promise<Transaction> {
}
await WalletTask.syncStateWithMintQueueAwaitable({
proofsToSync: sendProofs,
mintUrl: tx.mint,
mintUrl: _txMintUrl(tx),
proofState: 'PENDING',
})
return transactionsStore.findById(transactionId) ?? tx
@@ -657,6 +677,7 @@ function _rowToReservation(row: ReservationRow): ProofReservation {
return {
id: row.id,
transactionId: row.transactionId,
mintId: row.mintId,
mintUrl: row.mintUrl,
unit: row.unit as MintUnit,
operationType: row.operationType,
@@ -69,6 +69,27 @@ import {sendTopupNotification} from '../notifications'
const {mintsStore, proofsStore, transactionsStore, walletStore, walletProfileStore} =
rootStoreInstance
/**
* The live url of a transaction's mint.
*
* Resolved through `tx.mintId`, never `tx.mint`: the latter records where the
* payment happened and is deliberately frozen, so once the mint moves it points at
* a host that no longer answers — and an open topup would poll it forever while the
* mint holds money the wallet could have claimed.
*/
function _txMintUrl(tx: Transaction): string {
const mint = mintsStore.findByTransaction(tx)
if (!mint) {
throw new ValidationError('Transaction mint is no longer in this wallet', {
transactionId: tx.id,
mintId: tx.mintId,
happenedAtUrl: tx.mint,
})
}
return mint.mintUrl
}
// ─────────────────────────────────────────────────────────────────────────────
// Public types
// ─────────────────────────────────────────────────────────────────────────────
@@ -359,7 +380,7 @@ async function finalize(transactionId: number): Promise<CompletedTransaction> {
throw new ValidationError('Topup has no quote id; cannot finalize.', {transactionId})
}
const {state} = await walletStore.checkLightningMintQuote(tx.mint, tx.quote)
const {state} = await walletStore.checkLightningMintQuote(_txMintUrl(tx), tx.quote)
if (state !== MintQuoteState.PAID) {
throw new MintError(
`Cannot finalize topup; mint reports quote state ${state}.`,
@@ -397,7 +418,7 @@ async function refresh(transactionId: number): Promise<Transaction> {
return tx
}
const {state} = await walletStore.checkLightningMintQuote(tx.mint, tx.quote)
const {state} = await walletStore.checkLightningMintQuote(_txMintUrl(tx), tx.quote)
const isExpired = !!tx.expiresAt && isBefore(tx.expiresAt, new Date())
const paymentHash = tx.paymentId
@@ -470,7 +491,7 @@ async function refresh(transactionId: number): Promise<Transaction> {
*/
async function _finalizePaid(tx: Transaction): Promise<CompletedTransaction> {
const transactionId = tx.id
const mintUrl = tx.mint
const mintUrl = _txMintUrl(tx)
const unit = tx.unit
const amount = tx.amount
const mintQuote = tx.quote!
@@ -100,6 +100,27 @@ type AnyMeltQuote = MeltQuoteBolt11Response | MeltQuoteOnchainResponse
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance
/**
* The live url of a transaction's mint.
*
* Resolved through `tx.mintId`, never `tx.mint`: the latter records where the
* payment happened and is deliberately frozen, so after a mint-url edit it points
* at a host that no longer answers — and an open melt would never learn whether the
* mint had settled it.
*/
function _txMintUrl(tx: Transaction): string {
const mint = mintsStore.findByTransaction(tx)
if (!mint) {
throw new ValidationError('Transaction mint is no longer in this wallet', {
transactionId: tx.id,
mintId: tx.mintId,
happenedAtUrl: tx.mint,
})
}
return mint.mintUrl
}
// ─────────────────────────────────────────────────────────────────────────────
// Public types
// ─────────────────────────────────────────────────────────────────────────────
@@ -961,8 +982,8 @@ function _isOnchainTransfer(tx: Transaction): boolean {
*/
async function _checkQuote(tx: Transaction, quoteId: string): Promise<AnyMeltQuote> {
return _isOnchainTransfer(tx)
? await walletStore.checkOnchainMeltQuote(tx.mint, quoteId)
: await walletStore.checkLightningMeltQuote(tx.mint, quoteId)
? await walletStore.checkOnchainMeltQuote(_txMintUrl(tx), quoteId)
: await walletStore.checkLightningMeltQuote(_txMintUrl(tx), quoteId)
}
/**
@@ -1103,7 +1124,7 @@ async function _finalizePaid(
quote: AnyMeltQuote,
): Promise<CompletedTransaction> {
const transactionId = tx.id
const mintUrl = tx.mint
const mintUrl = _txMintUrl(tx)
const unit = tx.unit
const method: TransferMethod = _isOnchainTransfer(tx) ? 'onchain' : 'bolt11'
@@ -1340,6 +1361,7 @@ function _rowToReservation(row: ReservationRow): ProofReservation {
return {
id: row.id,
transactionId: row.transactionId,
mintId: row.mintId,
mintUrl: row.mintUrl,
unit: row.unit as MintUnit,
operationType: row.operationType,
+15 -1
View File
@@ -24,7 +24,21 @@ export type ProofReservation = {
/** Wallet transaction this reservation belongs to. */
transactionId: number
/** Mint the reserved proofs belong to. */
/**
* Stable id (Mint.id) of the mint the reserved proofs belong to.
*
* Resolve THIS at commit, not `mintUrl`: a mint-url edit racing an open
* operation would otherwise commit the new proofs under the pre-edit url,
* leaving them owned by no mint and invisible in the balance.
*
* Null only for a reservation row opened before v33.
*/
mintId: string | null
/**
* The mint's url when the reservation was opened. A snapshot for logging — see
* mintId for the reference that survives a move.
*/
mintUrl: string
/** Currency unit (sat, msat, usd, …) — used when committing new proofs. */
+6
View File
@@ -189,6 +189,12 @@ export const receiveOfflineCompleteTask = async function (
const prepared = loadPreparedForOfflineComplete(transactionId)
// Prefer the mint's live url over tx.mint (where the receive was prepared):
// this value is reported back and named in the "mint is blocked" message, so
// a stale url would send the user looking for a mint that is not in Settings
// under that name.
mintToReceive = prepared.mintUrl
if (prepared.blocked) {
return {
taskFunction: RECEIVE_OFFLINE_COMPLETE_TASK,
+6 -2
View File
@@ -35,7 +35,9 @@ const unit = transaction.unit as MintUnit
const allProofs = proofsStore.getByTransactionId(transaction.id)
const pendingProofs = allProofs.filter(p => p.state === 'PENDING')
const mintInstance = mintsStore.findByUrl(transaction.mint)
// By identity, not by transaction.mint — that url records where the payment
// happened and no longer finds the mint once it has moved.
const mintInstance = mintsStore.findByTransaction(transaction)
// Reservation is declared at the outer scope so the catch block can resolve it
// (commit or rollback) if a failure happens after it's opened. Errors thrown
@@ -75,7 +77,9 @@ try {
})
const receivedResult = await walletStore.receive(
transaction.mint,
// The mint's live url — transaction.mint is where the original send
// happened and would no longer reach a mint that has moved.
mintInstance.mintUrl,
unit as MintUnit,
encodedToken,
transaction.id