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 ( const CREATE_INFLIGHT = `CREATE TABLE inflight_requests (
transactionId INTEGER PRIMARY KEY NOT NULL, transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
request TEXT NOT NULL, request TEXT NOT NULL,
createdAt TEXT 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 = 'https://mint.test'
const MINT_ID = 'mint1111'
const OTHER_MINT_ID = 'mint9999'
// ── Mirrored repo primitives (exact production SQL) ───────────────────────── // ── Mirrored repo primitives (exact production SQL) ─────────────────────────
function addInFlightRequest( function addTransaction(db: DatabaseSync, id: number, mintId: string | null) {
db: DatabaseSync, db.prepare(`INSERT OR REPLACE INTO transactions (id, mintId, mint, status) VALUES (?, ?, ?, 'PENDING')`)
transactionId: number, .run(id, mintId, MINT)
mintUrl: string | null, }
keysetId: string | null,
request: object, function addInFlightRequest(db: DatabaseSync, transactionId: number, request: object) {
) {
db.prepare( db.prepare(
`INSERT OR REPLACE INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt) `INSERT OR REPLACE INTO inflight_requests (transactionId, request, createdAt)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?)`,
).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW) ).run(transactionId, JSON.stringify(request), NOW)
} }
function getInFlightRequest(db: DatabaseSync, transactionId: number) { function getInFlightRequest(db: DatabaseSync, transactionId: number) {
const row = db const row = db
.prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE transactionId = ?`) .prepare(`SELECT transactionId, request, createdAt FROM inflight_requests WHERE transactionId = ?`)
.get(transactionId) as {transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null} | undefined .get(transactionId) as {transactionId: number; request: string; createdAt: string | null} | undefined
if (!row) return undefined if (!row) return undefined
return {...row, request: JSON.parse(row.request)} 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 const rows = db
.prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE mintUrl = ?`) .prepare(
.all(mintUrl) as Array<{transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null}> `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)})) 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) db.prepare(`DELETE FROM inflight_requests WHERE transactionId = ?`).run(transactionId)
} }
function seedInFlightRequest( function seedInFlightRequest(db: DatabaseSync, transactionId: number, request: object) {
db: DatabaseSync,
transactionId: number,
mintUrl: string | null,
keysetId: string | null,
request: object,
) {
db.prepare( db.prepare(
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt) `INSERT INTO inflight_requests (transactionId, request, createdAt)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`, ON CONFLICT(transactionId) DO NOTHING`,
).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW) ).run(transactionId, JSON.stringify(request), NOW)
} }
function freshDb(): DatabaseSync { function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:') const db = new DatabaseSync(':memory:')
db.exec(CREATE_INFLIGHT) db.exec(CREATE_INFLIGHT)
db.exec(CREATE_TRANSACTIONS)
return db return db
} }
@@ -86,12 +102,11 @@ describe('In-flight requests (inflight_requests)', () => {
const db = freshDb() const db = freshDb()
const request = {token: 'cashuA...', options: {keysetId: 'k1'}} 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)! const rec = getInFlightRequest(db, 101)!
expect(rec.transactionId).toBe(101) expect(rec.transactionId).toBe(101)
expect(rec.mintUrl).toBe(MINT)
expect(rec.keysetId).toBe('k1')
expect(rec.request).toEqual(request) expect(rec.request).toEqual(request)
db.close() db.close()
}) })
@@ -104,38 +119,79 @@ describe('In-flight requests (inflight_requests)', () => {
test('add OVERWRITES an existing entry (set semantics)', () => { test('add OVERWRITES an existing entry (set semantics)', () => {
const db = freshDb() const db = freshDb()
addInFlightRequest(db, 101, MINT, 'k1', {v: 'first'}) addTransaction(db, 101, MINT_ID)
addInFlightRequest(db, 101, MINT, 'k1', {v: 'second'}) addInFlightRequest(db, 101, {v: 'first'})
addInFlightRequest(db, 101, {v: 'second'})
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'second'}) expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'second'})
db.close() db.close()
}) })
test('getInFlightRequestsByMint returns all rows for a mint', () => { test('getInFlightRequestsByMintId returns all rows of a mint', () => {
const db = freshDb() const db = freshDb()
addInFlightRequest(db, 101, MINT, 'k1', {v: 1}) addTransaction(db, 101, MINT_ID)
addInFlightRequest(db, 102, MINT, 'k1', {v: 2}) addTransaction(db, 102, MINT_ID)
addInFlightRequest(db, 103, 'https://other.test', 'k9', {v: 3}) 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(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() db.close()
}) })
test('remove deletes the entry', () => { test('remove deletes the entry', () => {
const db = freshDb() const db = freshDb()
addInFlightRequest(db, 101, MINT, 'k1', {v: 1}) addTransaction(db, 101, MINT_ID)
addInFlightRequest(db, 101, {v: 1})
removeInFlightRequest(db, 101) removeInFlightRequest(db, 101)
expect(getInFlightRequest(db, 101)).toBeUndefined() expect(getInFlightRequest(db, 101)).toBeUndefined()
expect(getInFlightRequestsByMint(db, MINT)).toHaveLength(0) expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0)
db.close() db.close()
}) })
test('seed is idempotent — does not overwrite an existing entry', () => { test('seed is idempotent — does not overwrite an existing entry', () => {
const db = freshDb() const db = freshDb()
addInFlightRequest(db, 101, MINT, 'k1', {v: 'live'}) addTransaction(db, 101, MINT_ID)
seedInFlightRequest(db, 101, MINT, 'k1', {v: 'snapshot'}) addInFlightRequest(db, 101, {v: 'live'})
seedInFlightRequest(db, 101, {v: 'snapshot'})
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'live'}) expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'live'})
db.close() 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 ( const CREATE_MELT_RECOVERY = `CREATE TABLE melt_recovery (
transactionId INTEGER PRIMARY KEY NOT NULL, transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
meltPreview TEXT NOT NULL, meltPreview TEXT NOT NULL,
createdAt TEXT createdAt TEXT
)` )`
@@ -43,22 +41,20 @@ const previewFor = (keysetId: string, secret = 'aa') => ({
function addMeltRecovery( function addMeltRecovery(
db: DatabaseSync, db: DatabaseSync,
transactionId: number, transactionId: number,
mintUrl: string | null,
keysetId: string | null,
meltPreview: object, meltPreview: object,
) { ) {
db.prepare( db.prepare(
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt) `INSERT INTO melt_recovery (transactionId, meltPreview, createdAt)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`, 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) { function getMeltRecovery(db: DatabaseSync, transactionId: number) {
const row = db 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 .get(transactionId) as
| {transactionId: number; mintUrl: string | null; keysetId: string | null; meltPreview: string; createdAt: string | null} | {transactionId: number; meltPreview: string; createdAt: string | null}
| undefined | undefined
if (!row) return undefined if (!row) return undefined
return {...row, meltPreview: JSON.parse(row.meltPreview)} return {...row, meltPreview: JSON.parse(row.meltPreview)}
@@ -86,13 +82,13 @@ describe('Melt recovery (melt_recovery)', () => {
const db = freshDb() const db = freshDb()
const preview = previewFor('k1') const preview = previewFor('k1')
addMeltRecovery(db, 101, MINT, 'k1', preview) addMeltRecovery(db, 101, preview)
const rec = getMeltRecovery(db, 101)! const rec = getMeltRecovery(db, 101)!
expect(rec.transactionId).toBe(101) expect(rec.transactionId).toBe(101)
expect(rec.mintUrl).toBe(MINT)
expect(rec.keysetId).toBe('k1')
expect(rec.meltPreview).toEqual(preview) expect(rec.meltPreview).toEqual(preview)
// The keyset lives INSIDE the preview — the row never duplicated it.
expect(rec.meltPreview.keysetId).toBe('k1')
db.close() db.close()
}) })
@@ -104,9 +100,9 @@ describe('Melt recovery (melt_recovery)', () => {
test('the FIRST stored preview wins (ON CONFLICT DO NOTHING)', () => { test('the FIRST stored preview wins (ON CONFLICT DO NOTHING)', () => {
const db = freshDb() 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. // 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)! const rec = getMeltRecovery(db, 101)!
expect(rec.meltPreview.outputData[0].secret).toBe('first') 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)', () => { test('remove deletes the entry (terminal success/failure)', () => {
const db = freshDb() const db = freshDb()
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1')) addMeltRecovery(db, 101, previewFor('k1'))
expect(rowCount(db)).toBe(1) expect(rowCount(db)).toBe(1)
removeMeltRecovery(db, 101) removeMeltRecovery(db, 101)
@@ -127,24 +123,24 @@ describe('Melt recovery (melt_recovery)', () => {
test('entries for different transactions are independent', () => { test('entries for different transactions are independent', () => {
const db = freshDb() const db = freshDb()
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1')) addMeltRecovery(db, 101, previewFor('k1'))
addMeltRecovery(db, 102, MINT, 'k2', previewFor('k2')) addMeltRecovery(db, 102, previewFor('k2'))
expect(getMeltRecovery(db, 101)!.keysetId).toBe('k1') expect(getMeltRecovery(db, 101)!.meltPreview.keysetId).toBe('k1')
expect(getMeltRecovery(db, 102)!.keysetId).toBe('k2') expect(getMeltRecovery(db, 102)!.meltPreview.keysetId).toBe('k2')
removeMeltRecovery(db, 101) removeMeltRecovery(db, 101)
expect(getMeltRecovery(db, 101)).toBeUndefined() expect(getMeltRecovery(db, 101)).toBeUndefined()
expect(getMeltRecovery(db, 102)!.keysetId).toBe('k2') // unaffected expect(getMeltRecovery(db, 102)!.meltPreview.keysetId).toBe('k2') // unaffected
db.close() db.close()
}) })
test('seed is idempotent — does not overwrite an existing entry', () => { test('seed is idempotent — does not overwrite an existing entry', () => {
const db = freshDb() const db = freshDb()
// Live entry already advanced/stored. // 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. // 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') expect(getMeltRecovery(db, 101)!.meltPreview.outputData[0].secret).toBe('live')
db.close() 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 ( const CREATE_ONCHAIN_MINT_QUOTES = `CREATE TABLE onchain_mint_quotes (
quote TEXT PRIMARY KEY NOT NULL, quote TEXT PRIMARY KEY NOT NULL,
mintId TEXT,
mintUrl TEXT NOT NULL, mintUrl TEXT NOT NULL,
unit TEXT NOT NULL, unit TEXT NOT NULL,
address 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) ───────────────────────── // ── 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` 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( function addQuote(
db: DatabaseSync, db: DatabaseSync,
q: { q: {
@@ -58,9 +62,10 @@ function addQuote(
) { ) {
db.prepare( db.prepare(
`INSERT OR REPLACE INTO onchain_mint_quotes (${COLS}) `INSERT OR REPLACE INTO onchain_mint_quotes (${COLS})
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
q.quote, q.quote,
MINT_ID,
MINT, MINT,
'sat', 'sat',
`bc1q${q.quote}`, `bc1q${q.quote}`,
+1
View File
@@ -32,6 +32,7 @@ const CREATE_PROOFS = `CREATE TABLE proofs (
const CREATE_RESERVATIONS = `CREATE TABLE reservations ( const CREATE_RESERVATIONS = `CREATE TABLE reservations (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
transactionId INTEGER NOT NULL, transactionId INTEGER NOT NULL,
mintId TEXT,
mintUrl TEXT NOT NULL, mintUrl TEXT NOT NULL,
unit TEXT NOT NULL, unit TEXT NOT NULL,
operationType 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 * somewhere else now. So everything addressed by the old location has to
* follow. What follows, and what deliberately does not: * follow. What follows, and what deliberately does not:
* *
* - proofs — repointed. `proofs.mintUrl` is how a balance is found. * - proofs — repointed. `proofs.mintUrl` is how a balance is found, and it
* - in-flight transactions — repointed. Their `mint` is a live pointer the * is the last denormalized copy of the url left in the schema.
* wallet still calls; stale, it strands an open payment at a dead url. * - transactions — nothing to do. `mint` is the url the payment happened at,
* - terminal transactions — NOT repointed. There the url is a historical * a historical fact that must NOT move; `mintId` carries the identity.
* record of where the payment happened. See
* Database.updateInFlightTransactionsMintUrl.
* - derivation counters — nothing to do: keyed by keysetId, which no url * - derivation counters — nothing to do: keyed by keysetId, which no url
* edit can disturb (see MINT_COUNTERS_COLUMNS). * edit can disturb (see MINT_COUNTERS_COLUMNS).
* * - onchain mint quotes, open reservations — nothing to do: they reference
* NOT yet carried over — each still keyed by url, and tracked as step 3 of * the mint by `mintId` and resolve the live url through it at use time.
* the mint-identity work: onchain mint quotes, in-flight requests, melt * - in-flight requests, melt recovery — nothing to do: they are child rows
* recovery rows, and open reservations. Renaming a mint with any of those * of a transaction and hold no mint reference at all; the one mint-scoped
* outstanding still strands them. * query joins through the parent's mintId.
* *
* Validation runs through the same normalizer as `addMint`, so a rename can * 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. * no longer install a url that adding the same mint would have rejected.
@@ -609,7 +607,7 @@ export const MintModel = types
return true return true
} }
const {mintsStore, proofsStore, transactionsStore} = getRootStore(self) const {mintsStore, proofsStore} = getRootStore(self)
// mintExists (normalized), not alreadyExists (literal string compare): // mintExists (normalized), not alreadyExists (literal string compare):
// the latter misses a twin differing only by a trailing slash, which // the latter misses a twin differing only by a trailing slash, which
@@ -619,7 +617,6 @@ export const MintModel = types
} }
proofsStore.updateMintUrl(currentMintUrl, normalized) proofsStore.updateMintUrl(currentMintUrl, normalized)
transactionsStore.updateMintUrl(currentMintUrl, normalized)
self.mintUrl = normalized self.mintUrl = normalized
self.setHostname() // derived from mintUrl — stale until recomputed 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) const mint = self.mints.find(m => m.mintUrl === mintUrl)
return mint ? mint : undefined 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() { get allKeysetIds() {
return self.mints.flatMap(m => m.keysetIds) 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(withSetPropAction)
.actions(self => ({ .actions(self => ({
mintExists: (mintUrl: string | URL) => { mintExists: (mintUrl: string | URL) => {
+26 -4
View File
@@ -289,11 +289,17 @@ import {
originalTId: p.tId ?? null, 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. // ATOMIC: write reservation row + lock proofs to PENDING in one batch.
Database.openReservation( Database.openReservation(
{ {
id: reservationId, id: reservationId,
transactionId: opts.transactionId, transactionId: opts.transactionId,
mintId: mintId ?? undefined,
mintUrl: opts.mintUrl, mintUrl: opts.mintUrl,
unit: opts.unit, unit: opts.unit,
operationType: opts.operationType, operationType: opts.operationType,
@@ -316,6 +322,7 @@ import {
return { return {
id: reservationId, id: reservationId,
transactionId: opts.transactionId, transactionId: opts.transactionId,
mintId,
mintUrl: opts.mintUrl, mintUrl: opts.mintUrl,
unit: opts.unit, unit: opts.unit,
operationType: opts.operationType, operationType: opts.operationType,
@@ -347,14 +354,29 @@ import {
} = {}, } = {},
): { added: Proof[] } { ): { added: Proof[] } {
const mintsStore = getRootStore(self).mintsStore 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) { if (!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint not found for reservation', { throw new AppError(Err.VALIDATION_ERROR, 'Mint not found for reservation', {
mintId: reservation.mintId,
mintUrl: reservation.mintUrl, mintUrl: reservation.mintUrl,
reservationId: reservation.id, 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 // Snapshot the current derivation counter for every keyset the new
// proofs were derived under (a cashu proof's `id` IS its keyset id). // proofs were derived under (a cashu proof's `id` IS its keyset id).
// WalletStore already advanced the model counter to // WalletStore already advanced the model counter to
@@ -388,7 +410,7 @@ import {
newProofs: changes.newProofs?.map(group => ({ newProofs: changes.newProofs?.map(group => ({
proofs: group.proofs, proofs: group.proofs,
state: group.state, state: group.state,
mintUrl: reservation.mintUrl, mintUrl: commitMintUrl,
unit: reservation.unit, unit: reservation.unit,
tId: group.tId, tId: group.tId,
})), })),
@@ -420,7 +442,7 @@ import {
if (existing) { if (existing) {
if (existing.state === 'SPENT') continue if (existing.state === 'SPENT') continue
if (isAlive(existing)) { if (isAlive(existing)) {
existing.setProp('mintUrl', reservation.mintUrl) existing.setProp('mintUrl', commitMintUrl)
existing.setProp('tId', group.tId) existing.setProp('tId', group.tId)
existing.setProp('unit', reservation.unit) existing.setProp('unit', reservation.unit)
existing.setProp('state', group.state) existing.setProp('state', group.state)
@@ -430,7 +452,7 @@ import {
const node = ProofModel.create({ const node = ProofModel.create({
...proof, ...proof,
amount: Number(proof.amount), amount: Number(proof.amount),
mintUrl: reservation.mintUrl, mintUrl: commitMintUrl,
tId: group.tId, tId: group.tId,
unit: reservation.unit, unit: reservation.unit,
state: group.state, state: group.state,
+1 -1
View File
@@ -11,7 +11,7 @@ import {NwcStoreModel} from './NwcStore'
import {AuthStoreModel} from './AuthStore' import {AuthStoreModel} from './AuthStore'
import { log } from '../services' 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. * A RootStore model.
*/ */
+15
View File
@@ -81,7 +81,22 @@ export const TransactionModel = types
paymentId: types.maybe(types.maybeNull(types.string)), paymentId: types.maybe(types.maybeNull(types.string)),
quote: types.maybe(types.maybeNull(types.string)), quote: types.maybe(types.maybeNull(types.string)),
memo: 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, 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)), paymentRequest: types.maybe(types.maybeNull(types.string)),
zapRequest: types.maybe(types.maybeNull(types.string)), zapRequest: types.maybe(types.maybeNull(types.string)),
inputToken: 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, TransactionStatus.RECOVERED,
]) ])
/** /** Statuses in which a transaction is still open — the complement of TERMINAL_STATUSES. */
* Statuses in which a transaction is still open — the complement of const IN_FLIGHT_STATUSES: ReadonlySet<TransactionStatus> = new Set([
* 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([
TransactionStatus.DRAFT, TransactionStatus.DRAFT,
TransactionStatus.PREPARED, TransactionStatus.PREPARED,
TransactionStatus.PREPARED_OFFLINE, TransactionStatus.PREPARED_OFFLINE,
+27 -24
View File
@@ -12,7 +12,6 @@ import {
TransactionStatus, TransactionStatus,
TransactionType, TransactionType,
} from './Transaction' } from './Transaction'
import { isInFlight } from './TransactionStates'
import { Database } from '../services' import { Database } from '../services'
import { log } from '../services/logService' import { log } from '../services/logService'
import { getRootStore } from './helpers/getRootStore' import { getRootStore } from './helpers/getRootStore'
@@ -156,32 +155,23 @@ export const TransactionsStoreModel = types
}, },
/** /**
* Mirror a mint-url edit onto in-flight transactions only — see * Drop recent-list entries whose mint is no longer in the wallet.
* Database.updateInFlightTransactionsMintUrl for why terminal rows keep
* the url their payment actually used.
* *
* SQLite first: if the write throws, the exception propagates with the MST * Tests mintId, not the url. `tx.mint` is where the payment happened and is
* tree still matching the database. Updating memory first would leave the * never rewritten, so a mint that has since moved would fail a url test and
* UI reading a url the database does not have, which the next restart * its whole history would vanish from the list as though the mint had been
* would silently revert. * 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() { pruneRecentWithoutCurrentMint() {
const { mintsStore } = getRootStore(self) 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) { if (newList.length !== self.recentByUnit.length) {
self.recentByUnit.replace(newList) self.recentByUnit.replace(newList)
log.trace('[pruneRecentWithoutCurrentMint]', `${self.recentByUnit.length - newList.length} removed`) log.trace('[pruneRecentWithoutCurrentMint]', `${self.recentByUnit.length - newList.length} removed`)
@@ -199,8 +189,21 @@ export const TransactionsStoreModel = types
.actions(self => ({ .actions(self => ({
// ── Main add (push + safe prune) ── // ── Main add (push + safe prune) ──
addTransaction: flow(function* addTransaction(newTxData: Partial<Transaction>) { // Return type is annotated because the body reaches the root store, and an
const dbTx: Transaction = yield Database.addTransactionAsync(newTxData) // 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) const tx = TransactionModel.create(dbTx)
self.transactionsMap.set(String(dbTx.id), tx) self.transactionsMap.set(String(dbTx.id), tx)
+4 -13
View File
@@ -550,7 +550,7 @@ export const WalletStoreModel = types
// @ts-ignore // @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, receiveParams) Database.addInFlightRequest(transactionId, receiveParams)
} }
let reservedCounters: OperationCounters | undefined let reservedCounters: OperationCounters | undefined
@@ -665,7 +665,7 @@ export const WalletStoreModel = types
// @ts-ignore // @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, sendParams) Database.addInFlightRequest(transactionId, sendParams)
} }
let reservedCounters: OperationCounters | undefined let reservedCounters: OperationCounters | undefined
@@ -975,12 +975,7 @@ export const WalletStoreModel = types
// onCountersReserved never fired, so we never wrote it back. // onCountersReserved never fired, so we never wrote it back.
// @ts-ignore // @ts-ignore
if (cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { if (cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
Database.addInFlightRequest( Database.addInFlightRequest(transactionId, mintParams)
transactionId,
mintUrl,
cashuWallet.keysetId,
mintParams,
)
} }
let reservedCounters: OperationCounters | undefined let reservedCounters: OperationCounters | undefined
@@ -1082,7 +1077,7 @@ export const WalletStoreModel = types
// @ts-ignore // @ts-ignore
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, mintParams) Database.addInFlightRequest(transactionId, mintParams)
} }
let reservedCounters: OperationCounters | undefined let reservedCounters: OperationCounters | undefined
@@ -1230,8 +1225,6 @@ export const WalletStoreModel = types
// even if the app dies right after the payment is submitted. // even if the app dies right after the payment is submitted.
Database.addMeltRecovery( Database.addMeltRecovery(
transactionId, transactionId,
mintUrl,
cashuWallet.keysetId,
CashuUtils.serializeMeltPreview(meltPreview), CashuUtils.serializeMeltPreview(meltPreview),
) )
@@ -1425,8 +1418,6 @@ export const WalletStoreModel = types
// recoverable even if the app dies the moment after. // recoverable even if the app dies the moment after.
Database.addMeltRecovery( Database.addMeltRecovery(
transactionId, transactionId,
mintUrl,
cashuWallet.keysetId,
CashuUtils.serializeMeltPreview(meltPreview), CashuUtils.serializeMeltPreview(meltPreview),
) )
+26 -4
View File
@@ -18,6 +18,7 @@ import * as Sentry from '@sentry/react-native'
import type { RootStore } from '../RootStore' import type { RootStore } from '../RootStore'
import { Database, MMKVStorage } from '../../services' import { Database, MMKVStorage } from '../../services'
import type { MeltRecoverySeed, InFlightRequestSeed, CounterSeed } from '../../services/db' import type { MeltRecoverySeed, InFlightRequestSeed, CounterSeed } from '../../services/db'
import type { Mint } from '../Mint'
import { log } from '../../services/logService' import { log } from '../../services/logService'
import { rootStoreModelVersion } from '../RootStore' import { rootStoreModelVersion } from '../RootStore'
import AppError, { Err } from '../../utils/AppError' import AppError, { Err } from '../../utils/AppError'
@@ -294,8 +295,6 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
if (entry?.meltPreview && typeof entry.transactionId === 'number') { if (entry?.meltPreview && typeof entry.transactionId === 'number') {
seeds.push({ seeds.push({
transactionId: entry.transactionId, transactionId: entry.transactionId,
mintUrl: mint.mintUrl,
keysetId: counter.keyset,
meltPreview: entry.meltPreview, meltPreview: entry.meltPreview,
}) })
} }
@@ -320,8 +319,6 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
if (entry?.request && typeof entry.transactionId === 'number') { if (entry?.request && typeof entry.transactionId === 'number') {
seeds.push({ seeds.push({
transactionId: entry.transactionId, transactionId: entry.transactionId,
mintUrl: mint.mintUrl,
keysetId: counter.keyset,
request: entry.request, request: entry.request,
}) })
} }
@@ -356,6 +353,31 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
userSettingsStore.setIsOnboarded(false) 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 // Set once, after all steps succeed: if any step throws, the version is
// NOT bumped and the whole migration retries on the next launch. // NOT bumped and the whole migration retries on the next launch.
rootStore.setVersion(rootStoreModelVersion) rootStore.setVersion(rootStoreModelVersion)
+1 -1
View File
@@ -489,7 +489,7 @@ return (
label="cashuPaymentRequest_to" label="cashuPaymentRequest_to"
isFirst={true} isFirst={true}
value={ value={
mintsStore.findByUrl(transaction.mint) mintsStore.findByTransaction(transaction)
?.shortname as string ?.shortname as string
} }
/> />
+1 -1
View File
@@ -1260,7 +1260,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
label="tranDetailScreen_trasferredTo" label="tranDetailScreen_trasferredTo"
isFirst={true} isFirst={true}
value={ value={
mintsStore.findByUrl(transaction.mint) mintsStore.findByTransaction(transaction)
?.shortname as string ?.shortname as string
} }
/> />
+1 -1
View File
@@ -462,7 +462,7 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
<TranItem <TranItem
label="transactionCommon_receivedTo" label="transactionCommon_receivedTo"
isFirst={true} isFirst={true}
value={mintsStore.findByUrl(transaction.mint)?.shortname as string} value={mintsStore.findByTransaction(transaction)?.shortname as string}
/> />
{transaction?.memo && ( {transaction?.memo && (
<TranItem <TranItem
+1 -1
View File
@@ -1583,7 +1583,7 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
<TranItem <TranItem
label="tranDetailScreen_sentTo" label="tranDetailScreen_sentTo"
isFirst={true} isFirst={true}
value={mintsStore.findByUrl(transaction.mint)?.shortname as string} value={mintsStore.findByTransaction(transaction)?.shortname as string}
/> />
{transaction?.memo && ( {transaction?.memo && (
<TranItem <TranItem
+1 -1
View File
@@ -1107,7 +1107,7 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
label="topup_to" label="topup_to"
isFirst={true} isFirst={true}
value={ value={
mintsStore.findByUrl(transaction.mint) mintsStore.findByTransaction(transaction)
?.shortname as string ?.shortname as string
} }
/> />
+11 -6
View File
@@ -131,7 +131,7 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
setIsDataParsable(false) setIsDataParsable(false)
} }
const mintInstance = mintsStore.findByUrl(tx.mint) const mintInstance = mintsStore.findByTransaction(tx)
if (mintInstance) { if (mintInstance) {
setMint(mintInstance) setMint(mintInstance)
} }
@@ -703,7 +703,7 @@ const ReceiveInfoBlock = function (props: {
increaseProofsCounter(transaction.unit) increaseProofsCounter(transaction.unit)
} }
const mintInstance = mintsStore.findByUrl(transaction.mint) const mintInstance = mintsStore.findByTransaction(transaction)
if(!mintInstance) { if(!mintInstance) {
throw new AppError(Err.NOTFOUND_ERROR, 'Mint not found in the wallet state, cannot retry receive', { 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' colorScheme: 'dark' | 'light'
}) { }) {
const {transaction, mint, isDataParsable} = props const {transaction, mint, isDataParsable} = props
const {proofsStore, transactionsStore} = useStores() const {proofsStore, transactionsStore, mintsStore} = useStores()
const navigation = useNavigation() const navigation = useNavigation()
@@ -2044,8 +2044,13 @@ const TransferInfoBlock = function (props: {
const {paymentRequest, unit, mint} = transaction const {paymentRequest, unit, mint} = transaction
if(!paymentRequest || !unit || !mint) { // The mint's live url: this is handed to the Transfer screen to actually pay,
log.error('[onPayDraftTransfer] Missing params', {paymentRequest, unit, mint}) // 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({ setResultModalInfo({
status: TransactionStatus.ERROR, status: TransactionStatus.ERROR,
@@ -2063,7 +2068,7 @@ const TransferInfoBlock = function (props: {
encodedInvoice: transaction.paymentRequest, encodedInvoice: transaction.paymentRequest,
paymentOption: TransferOption.PASTE_OR_SCAN_INVOICE, paymentOption: TransferOption.PASTE_OR_SCAN_INVOICE,
unit: transaction.unit, unit: transaction.unit,
mintUrl: transaction.mint, mintUrl: mintInstance.mintUrl,
draftTransactionId: transaction.id draftTransactionId: transaction.id
} }
}) })
+1 -1
View File
@@ -1073,7 +1073,7 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
label="tranDetailScreen_trasferredTo" label="tranDetailScreen_trasferredTo"
isFirst={true} isFirst={true}
value={ value={
mintsStore.findByUrl(transaction.mint) mintsStore.findByTransaction(transaction)
?.shortname as string ?.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 // A row exists only while a request is in-flight; it is deleted on success or
// terminal failure. Keyed by transactionId. // 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 = { export type InFlightRequestRecord = {
transactionId: number transactionId: number
mintUrl: string | null
keysetId: string | null
request: any request: any
createdAt: string | null 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. */ /** A single in-flight entry for the one-time seed from the MST/MMKV snapshot. */
export type InFlightRequestSeed = { export type InFlightRequestSeed = {
transactionId: number transactionId: number
mintUrl?: string
keysetId?: string
request: any request: any
} }
const rowToRecord = (row: any): InFlightRequestRecord => ({ const rowToRecord = (row: any): InFlightRequestRecord => ({
transactionId: row.transactionId, transactionId: row.transactionId,
mintUrl: row.mintUrl,
keysetId: row.keysetId,
request: JSON.parse(row.request), request: JSON.parse(row.request),
createdAt: row.createdAt, createdAt: row.createdAt,
}) })
@@ -43,17 +43,12 @@ const rowToRecord = (row: any): InFlightRequestRecord => ({
* Store (or replace) the in-flight request for a transaction. Overwrites an * Store (or replace) the in-flight request for a transaction. Overwrites an
* existing row — matching the previous addInFlightRequest set() semantics. * existing row — matching the previous addInFlightRequest set() semantics.
*/ */
export const addInFlightRequest = function ( export const addInFlightRequest = function (transactionId: number, request: any): void {
transactionId: number,
mintUrl: string | undefined,
keysetId: string | undefined,
request: any,
): void {
try { try {
getInstance().execute( getInstance().execute(
`INSERT OR REPLACE INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt) `INSERT OR REPLACE INTO inflight_requests (transactionId, request, createdAt)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?)`,
[transactionId, mintUrl ?? null, keysetId ?? null, JSON.stringify(request), new Date().toISOString()], [transactionId, JSON.stringify(request), new Date().toISOString()],
) )
} catch (e: any) { } catch (e: any) {
throw dbError('In-flight request could not be saved to the database', e) 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 { export const getInFlightRequest = function (transactionId: number): InFlightRequestRecord | undefined {
try { try {
const {rows} = getInstance().execute( 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], [transactionId],
) )
const row = rows?.item(0) 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 { try {
const {rows} = getInstance().execute( const {rows} = getInstance().execute(
`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE mintUrl = ?`, `SELECT r.transactionId, r.request, r.createdAt
[mintUrl], FROM inflight_requests r
JOIN transactions t ON t.id = r.transactionId
WHERE t.mintId = ?`,
[mintId],
) )
return (rows?._array ?? []).map(rowToRecord) return (rows?._array ?? []).map(rowToRecord)
} catch (e: any) { } catch (e: any) {
@@ -106,10 +114,10 @@ export const seedInFlightRequests = function (seeds: InFlightRequestSeed[]): {se
const now = new Date().toISOString() const now = new Date().toISOString()
getInstance().executeBatch( getInstance().executeBatch(
seeds.map(s => [ seeds.map(s => [
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt) `INSERT INTO inflight_requests (transactionId, request, createdAt)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`, 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}) log.info('[seedInFlightRequests]', 'Seeded in-flight requests into SQLite', {count: seeds.length})
+10 -6
View File
@@ -22,7 +22,7 @@ import {
getPendingOnchainTransfers, getPendingOnchainTransfers,
addTransactionAsync, addTransactionAsync,
updateTransaction, updateTransaction,
updateInFlightTransactionsMintUrl, backfillTransactionMintIds,
expireAllAfterRecovery, expireAllAfterRecovery,
updateStatusesAsync, updateStatusesAsync,
deleteTransactionsByStatus, deleteTransactionsByStatus,
@@ -46,6 +46,7 @@ import {
commitReservation, commitReservation,
rollbackReservation, rollbackReservation,
getOpenReservations, getOpenReservations,
backfillReservationMintIds,
} from './reservationsRepo' } from './reservationsRepo'
import { import {
getCounters, getCounters,
@@ -63,7 +64,7 @@ import {
import { import {
addInFlightRequest, addInFlightRequest,
getInFlightRequest, getInFlightRequest,
getInFlightRequestsByMint, getInFlightRequestsByMintId,
removeInFlightRequest, removeInFlightRequest,
seedInFlightRequests, seedInFlightRequests,
} from './inFlightRepo' } from './inFlightRepo'
@@ -75,7 +76,8 @@ import {
import { import {
addOnchainMintQuote, addOnchainMintQuote,
getOnchainMintQuote, getOnchainMintQuote,
getOnchainMintQuotesByMint, getOnchainMintQuotesByMintId,
backfillOnchainMintQuoteMintIds,
getWatchedOnchainMintQuotes, getWatchedOnchainMintQuotes,
updateOnchainMintQuoteAmounts, updateOnchainMintQuoteAmounts,
extendOnchainMintQuoteWatch, extendOnchainMintQuoteWatch,
@@ -112,7 +114,7 @@ export const Database = {
getPendingOnchainTransfers, getPendingOnchainTransfers,
addTransactionAsync, addTransactionAsync,
updateTransaction, updateTransaction,
updateInFlightTransactionsMintUrl, backfillTransactionMintIds,
expireAllAfterRecovery, expireAllAfterRecovery,
updateStatusesAsync, updateStatusesAsync,
deleteTransactionsByStatus, deleteTransactionsByStatus,
@@ -132,6 +134,7 @@ export const Database = {
commitReservation, commitReservation,
rollbackReservation, rollbackReservation,
getOpenReservations, getOpenReservations,
backfillReservationMintIds,
getCounters, getCounters,
getCounter, getCounter,
setCounter, setCounter,
@@ -143,7 +146,7 @@ export const Database = {
seedMeltRecoveries, seedMeltRecoveries,
addInFlightRequest, addInFlightRequest,
getInFlightRequest, getInFlightRequest,
getInFlightRequestsByMint, getInFlightRequestsByMintId,
removeInFlightRequest, removeInFlightRequest,
seedInFlightRequests, seedInFlightRequests,
allocateNextCounter, allocateNextCounter,
@@ -151,7 +154,8 @@ export const Database = {
setWalletCounter, setWalletCounter,
addOnchainMintQuote, addOnchainMintQuote,
getOnchainMintQuote, getOnchainMintQuote,
getOnchainMintQuotesByMint, getOnchainMintQuotesByMintId,
backfillOnchainMintQuoteMintIds,
getWatchedOnchainMintQuotes, getWatchedOnchainMintQuotes,
updateOnchainMintQuoteAmounts, updateOnchainMintQuoteAmounts,
extendOnchainMintQuoteWatch, 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 // A row exists only while a melt is in-flight; it is deleted on terminal
// success/failure. Keyed by transactionId. // 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 = { export type MeltRecoveryRecord = {
transactionId: number transactionId: number
mintUrl: string | null
keysetId: string | null
meltPreview: StoredMeltPreview meltPreview: StoredMeltPreview
createdAt: string | null 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. */ /** A single melt-recovery entry for the one-time seed from the MST/MMKV snapshot. */
export type MeltRecoverySeed = { export type MeltRecoverySeed = {
transactionId: number transactionId: number
mintUrl?: string
keysetId?: string
meltPreview: StoredMeltPreview meltPreview: StoredMeltPreview
} }
@@ -40,16 +43,14 @@ export type MeltRecoverySeed = {
*/ */
export const addMeltRecovery = function ( export const addMeltRecovery = function (
transactionId: number, transactionId: number,
mintUrl: string | undefined,
keysetId: string | undefined,
meltPreview: StoredMeltPreview, meltPreview: StoredMeltPreview,
): void { ): void {
try { try {
getInstance().execute( getInstance().execute(
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt) `INSERT INTO melt_recovery (transactionId, meltPreview, createdAt)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`, 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) { } catch (e: any) {
throw dbError('Melt recovery could not be saved to the database', e) 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 { export const getMeltRecovery = function (transactionId: number): MeltRecoveryRecord | undefined {
try { try {
const {rows} = getInstance().execute( 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], [transactionId],
) )
const row = rows?.item(0) const row = rows?.item(0)
if (!row) return undefined if (!row) return undefined
return { return {
transactionId: row.transactionId, transactionId: row.transactionId,
mintUrl: row.mintUrl,
keysetId: row.keysetId,
meltPreview: JSON.parse(row.meltPreview) as StoredMeltPreview, meltPreview: JSON.parse(row.meltPreview) as StoredMeltPreview,
createdAt: row.createdAt, createdAt: row.createdAt,
} }
@@ -97,10 +96,10 @@ export const seedMeltRecoveries = function (seeds: MeltRecoverySeed[]): {seeded:
const db = getInstance() const db = getInstance()
db.executeBatch( db.executeBatch(
seeds.map(s => [ seeds.map(s => [
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt) `INSERT INTO melt_recovery (transactionId, meltPreview, createdAt)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(transactionId) DO NOTHING`, 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}) 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 {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 {dbError} from './errors'
import {log} from '../logService' 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[]} 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 * Ordered migration registry. On startup every migration whose `version` is
* greater than the device's current version is applied, in order, inside a * 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 * To add a migration: append an entry with the next version number. No runner
* logic changes are needed. * 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 // 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 // error (and the error was swallowed, leaving the migration stuck). Making it
// defensive lets these devices migrate forward cleanly and lets us treat any // 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). // Add reservations table for atomic proof reservations (Phase 5).
version: 26, 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; // 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 // meltCounterValues from the MST/MMKV snapshot are copied by a one-time JS
// seed (see setupRootStore._runMigrations). // seed (see setupRootStore._runMigrations).
version: 28, 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 // 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 // in-flight requests from the MST/MMKV snapshot are copied by a one-time JS
// seed (see setupRootStore._runMigrations). // seed (see setupRootStore._runMigrations).
version: 29, 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). // 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. // Both empty on creation — no onchain transaction can predate this.
version: 31, version: 31,
queries: [ 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`], [`ALTER TABLE transactions ADD COLUMN outpoint TEXT`],
], ],
}, },
@@ -139,8 +196,80 @@ const MIGRATIONS: Migration[] = [
[`ALTER TABLE mint_counters_v32 RENAME TO mint_counters`], [`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 * Pure read of the stored schema version. Returns null when the version row has
* not been seeded yet (a fresh database). Never mutates. * 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 = { export type OnchainMintQuoteRecord = {
quote: string 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 mintUrl: string
unit: string unit: string
address: string address: string
@@ -59,7 +69,7 @@ export type OnchainMintQuoteRecord = {
updatedAt: string | null 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` 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. * NUT-20 index) is already safe, so a deposit to that address remains mintable.
*/ */
export const addOnchainMintQuote = function ( 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 amountPaid?: number
amountIssued?: number amountIssued?: number
watchUntil?: string watchUntil?: string
@@ -84,9 +98,10 @@ export const addOnchainMintQuote = function (
getInstance().execute( getInstance().execute(
`INSERT OR REPLACE INTO onchain_mint_quotes (${COLS}) `INSERT OR REPLACE INTO onchain_mint_quotes (${COLS})
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
q.quote, q.quote,
q.mintId ?? null,
q.mintUrl, q.mintUrl,
q.unit, q.unit,
q.address, 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) { } catch (e: any) {
throw dbError('Onchain mint quote could not be saved to the database', e) 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 { try {
const {rows} = getInstance().execute( const {rows} = getInstance().execute(
`SELECT ${COLS} FROM onchain_mint_quotes WHERE mintUrl = ? ORDER BY createdAt DESC`, `SELECT ${COLS} FROM onchain_mint_quotes WHERE mintId = ? ORDER BY createdAt DESC`,
[mintUrl], [mintId],
) )
return (rows?._array ?? []) as OnchainMintQuoteRecord[] return (rows?._array ?? []) as OnchainMintQuoteRecord[]
} catch (e: any) { } 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?" * Reopen the watch window on a quote — the user asking "did anything else arrive?"
* from the transaction detail, after the quote had been archived. * from the transaction detail, after the quote had been archived.
+44 -3
View File
@@ -37,8 +37,14 @@ export type LockedProofSnapshot = {
export type ReservationRow = { export type ReservationRow = {
id: string 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 mintUrl: string
transactionId: number
unit: string unit: string
operationType: string operationType: string
lockedProofs: LockedProofSnapshot[] lockedProofs: LockedProofSnapshot[]
@@ -56,6 +62,7 @@ export const openReservation = function (
reservation: { reservation: {
id: string id: string
transactionId: number transactionId: number
mintId?: string
mintUrl: string mintUrl: string
unit: string unit: string
operationType: string operationType: string
@@ -68,11 +75,12 @@ export const openReservation = function (
const batch: SQLBatchTuple[] = [] const batch: SQLBatchTuple[] = []
batch.push([ batch.push([
`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt) `INSERT INTO reservations (id, transactionId, mintId, mintUrl, unit, operationType, lockedProofs, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
reservation.id, reservation.id,
reservation.transactionId, reservation.transactionId,
reservation.mintId ?? null,
reservation.mintUrl, reservation.mintUrl,
reservation.unit, reservation.unit,
reservation.operationType, reservation.operationType,
@@ -320,6 +328,38 @@ export const rollbackReservation = function (
* Return all reservations currently in the DB. Used at startup to roll back * Return all reservations currently in the DB. Used at startup to roll back
* orphans (operations whose process died before they could commit or rollback). * 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[] { export const getOpenReservations = function (): ReservationRow[] {
try { try {
const db = getInstance() const db = getInstance()
@@ -338,6 +378,7 @@ export const getOpenReservations = function (): ReservationRow[] {
result.push({ result.push({
id: row.id, id: row.id,
transactionId: row.transactionId, transactionId: row.transactionId,
mintId: row.mintId ?? null,
mintUrl: row.mintUrl, mintUrl: row.mintUrl,
unit: row.unit, unit: row.unit,
operationType: row.operationType, operationType: row.operationType,
+82 -9
View File
@@ -1,14 +1,44 @@
import {SQLBatchTuple} from './connection' 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 * These constants track the latest shape and therefore CHANGE over time. A
* one place (first-run creation here, plus the v25 proofs rebuild and the v26 * migration must never build a table from one: a migration has to create the table
* reservations add in migrations.ts). Defining the columns once and generating * as it existed AT ITS OWN VERSION, or a device replaying it gets today's shape and
* every `CREATE TABLE` from them guarantees the definitions can never drift. * 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 = ` export const TRANSACTIONS_COLUMNS = `
id INTEGER PRIMARY KEY NOT NULL, id INTEGER PRIMARY KEY NOT NULL,
paymentId TEXT, paymentId TEXT,
@@ -22,6 +52,7 @@ export const TRANSACTIONS_COLUMNS = `
sentTo TEXT, sentTo TEXT,
profile TEXT, profile TEXT,
memo TEXT, memo TEXT,
mintId TEXT,
mint TEXT, mint TEXT,
quote TEXT, quote TEXT,
paymentRequest TEXT, paymentRequest TEXT,
@@ -59,9 +90,23 @@ export const DBVERSION_COLUMNS = `
createdAt TEXT 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 = ` export const RESERVATIONS_COLUMNS = `
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
transactionId INTEGER NOT NULL, transactionId INTEGER NOT NULL,
mintId TEXT,
mintUrl TEXT NOT NULL, mintUrl TEXT NOT NULL,
unit TEXT NOT NULL, unit TEXT NOT NULL,
operationType 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 * Keyed by transactionId (globally unique). A row exists only while a melt is
* in-flight; it is deleted on terminal success/failure. * 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 = ` export const MELT_RECOVERY_COLUMNS = `
transactionId INTEGER PRIMARY KEY NOT NULL, transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
meltPreview TEXT NOT NULL, meltPreview TEXT NOT NULL,
createdAt TEXT 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 * Keyed by transactionId. A row exists only while a request is in-flight; it is
* deleted on success or terminal failure. * 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 = ` export const INFLIGHT_REQUESTS_COLUMNS = `
transactionId INTEGER PRIMARY KEY NOT NULL, transactionId INTEGER PRIMARY KEY NOT NULL,
mintUrl TEXT,
keysetId TEXT,
request TEXT NOT NULL, request TEXT NOT NULL,
createdAt TEXT 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 * 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. * 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 * MELT quotes get no table: they are one-shot and terminal, so their durable state
* (quote id, outpoint, fee) fits on the transaction row. * (quote id, outpoint, fee) fits on the transaction row.
*/ */
export const ONCHAIN_MINT_QUOTES_COLUMNS = ` export const ONCHAIN_MINT_QUOTES_COLUMNS = `
quote TEXT PRIMARY KEY NOT NULL, quote TEXT PRIMARY KEY NOT NULL,
mintId TEXT,
mintUrl TEXT NOT NULL, mintUrl TEXT NOT NULL,
unit TEXT NOT NULL, unit TEXT NOT NULL,
address TEXT NOT NULL, address TEXT NOT NULL,
+26 -39
View File
@@ -1,5 +1,4 @@
import {Transaction, TransactionStatus} from '../../models/Transaction' import {Transaction, TransactionStatus} from '../../models/Transaction'
import {IN_FLIGHT_STATUSES} from '../../models/TransactionStates'
import AppError, {Err} from '../../utils/AppError' import AppError, {Err} from '../../utils/AppError'
import {log} from '../logService' import {log} from '../logService'
import {getInstance} from './instance' 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 * One-time backfill of `mintId` from the url a transaction happened at (the v38
* untouched. * 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 * Matching on url is sound ONLY here, at the moment of upgrade: until now a mint's
* actually happened. Rewriting it would falsify history, so it is frozen. * url could not change without in-flight rows being rewritten to match, so the two
* - IN-FLIGHT: a LIVE pointer the wallet still calls — checkLightningMintQuote * still agree. This spends that join once, at rest, instead of on every rename.
* (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.
*/ */
export const updateInFlightTransactionsMintUrl = function ( export const backfillTransactionMintIds = function (
currentMintUrl: string, mints: Array<{id: string; mintUrl: string}>,
updatedMintUrl: string,
): {updated: number} { ): {updated: number} {
if (mints.length === 0) return {updated: 0}
try { try {
const statuses = [...IN_FLIGHT_STATUSES]
const placeholders = statuses.map(() => '?').join(', ')
const db = getInstance() const db = getInstance()
let updated = 0
for (const mint of mints) {
const {rowsAffected} = db.execute( const {rowsAffected} = db.execute(
`UPDATE transactions `UPDATE transactions SET mintId = ? WHERE mint = ? AND mintId IS NULL`,
SET mint = ? [mint.id, mint.mintUrl],
WHERE mint = ? AND status IN (${placeholders})`,
[updatedMintUrl, currentMintUrl, ...statuses],
) )
updated += rowsAffected ?? 0
}
const updated = rowsAffected ?? 0 log.info('[backfillTransactionMintIds]', 'Backfilled transaction mint ids', {updated})
log.debug('[updateInFlightTransactionsMintUrl]', 'Repointed in-flight transactions', {
currentMintUrl,
updatedMintUrl,
updated,
})
return {updated} return {updated}
} catch (e: any) { } 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> { export const addTransactionAsync = async function (tx: Partial<Transaction>): Promise<Transaction> {
try { 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 now = new Date()
const query = ` const query = `
INSERT INTO transactions (type, amount, fee, unit, data, memo, mint, status, createdAt) INSERT INTO transactions (type, amount, fee, unit, data, memo, mint, mintId, status, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 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 db = getInstance()
const result = await db.executeAsync(query, params) 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 return getTransactionById(result.insertId as number) // already normalized
@@ -31,7 +31,9 @@ const {
*/ */
const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> => { const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> => {
const mintUrl = mint.mintUrl 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 const totalRequests = inFlightRequests.length
log.trace('[handleInFlightByMintTask] start', {mintUrl, totalRequests}) log.trace('[handleInFlightByMintTask] start', {mintUrl, totalRequests})
@@ -370,7 +372,7 @@ const handleInFlightQueue = async function (): Promise<void> {
for (const mint of mintsStore.allMints) { 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...') log.trace('No inFlight requests for mint, skipping...')
continue continue
} }
@@ -61,6 +61,7 @@ import {
} from '../../../models/Transaction' } from '../../../models/Transaction'
import {Database} from '../../../services' import {Database} from '../../../services'
import {OnchainMintQuoteRecord} from '../../db/onchainQuotesRepo' import {OnchainMintQuoteRecord} from '../../db/onchainQuotesRepo'
import type {Mint} from '../../../models/Mint'
import {allocateQuoteKeypair, deriveQuoteKeypair} from '../../cashu/nut20' import {allocateQuoteKeypair, deriveQuoteKeypair} from '../../cashu/nut20'
import {capMintAmount, mintableAmount} from './onchainAmounts' import {capMintAmount, mintableAmount} from './onchainAmounts'
import {CashuProof} from '../../cashu/cashuUtils' 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 // the row (with its counterIndex) is already safe, so a deposit to that address
// stays mintable. Losing it would make the money unspendable. // stays mintable. Losing it would make the money unspendable.
Database.addOnchainMintQuote({ 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, quote: quoteResponse.quote,
mintUrl, mintUrl,
unit, unit,
@@ -274,7 +279,8 @@ async function refreshQuote(quoteId: string): Promise<RefreshOnchainQuoteResult>
throw new ValidationError('Unknown onchain mint quote', {quote: quoteId}) 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 amountPaid = Number(quoteResponse.amount_paid ?? 0)
const amountIssued = Number(quoteResponse.amount_issued ?? 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} return {quote: quoteId, amountPaid, amountIssued, minted: 0}
} }
const transactionId = await _mintAvailable(row, quoteResponse, mintable) const transactionId = await _mintAvailable(row, mint, quoteResponse, mintable)
return { return {
quote: quoteId, quote: quoteId,
@@ -309,6 +315,33 @@ async function refreshQuote(quoteId: string): Promise<RefreshOnchainQuoteResult>
// Private // 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. * 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( async function _mintAvailable(
row: OnchainMintQuoteRecord, row: OnchainMintQuoteRecord,
mintInstance: Mint,
quoteResponse: any, quoteResponse: any,
mintable: number, mintable: number,
): Promise<number> { ): Promise<number> {
const {quote, mintUrl, counterIndex} = row const {quote, counterIndex} = row
const unit = row.unit as MintUnit 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. // 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 // Any remainder stays credited on the quote, where the watch rule keeps it
// visible and the next sweep takes the rest. // 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) const amount = capMintAmount(mintable, maxAmount ? Number(maxAmount) : null)
// Re-derive the NUT-20 signing key from the seed. Only the index was persisted. // Re-derive the NUT-20 signing key from the seed. Only the index was persisted.
const seed: Uint8Array = await walletStore.getCachedSeed() const seed: Uint8Array = await walletStore.getCachedSeed()
const {privkey} = deriveQuoteKeypair(seed, counterIndex) const {privkey} = deriveQuoteKeypair(seed, counterIndex)
const tx = await _findOrCreateTransaction(row, amount, unit) const tx = await _findOrCreateTransaction(row, mintUrl, amount, unit)
const transactionId = tx.id const transactionId = tx.id
let proofs: CashuProof[] = [] let proofs: CashuProof[] = []
@@ -433,6 +470,8 @@ async function _mintAvailable(
*/ */
async function _findOrCreateTransaction( async function _findOrCreateTransaction(
row: OnchainMintQuoteRecord, row: OnchainMintQuoteRecord,
/** The mint's url NOW (resolved from row.mintId), not row.mintUrl. */
mintUrl: string,
amount: number, amount: number,
unit: MintUnit, unit: MintUnit,
): Promise<Transaction> { ): Promise<Transaction> {
@@ -470,7 +509,10 @@ async function _findOrCreateTransaction(
fee: 0, fee: 0,
unit, unit,
data: JSON.stringify(transactionData), 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, status: TransactionStatus.PENDING,
}) })
@@ -490,15 +490,25 @@ function _reloadPrepared(transactionId: number): PreparedReceiveData {
if (!tx.inputToken) { if (!tx.inputToken) {
throw new ValidationError('Transaction is missing input token', {transactionId}) throw new ValidationError('Transaction is missing input token', {transactionId})
} }
const mintInstance = mintsStore.findByUrl(tx.mint) // By identity: tx.mint is where the receive was prepared and is frozen, so it
const token = getDecodedToken(tx.inputToken, mintInstance?.keysetIds ?? []) // 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 const isOffline = tx.status === TransactionStatus.PREPARED_OFFLINE
return { return {
transactionId, transactionId,
tx, tx,
mintUrl: tx.mint, // The mint's url NOW — this is dialled to complete the receive.
mintUrl: mintInstance.mintUrl,
unit: tx.unit, unit: tx.unit,
amountToReceive: tx.amount, amountToReceive: tx.amount,
memo: tx.memo ?? '', memo: tx.memo ?? '',
@@ -57,6 +57,26 @@ import AppError, {Err} from '../../../utils/AppError'
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance 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 // Public types
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -598,7 +618,7 @@ async function finalize(transactionId: number): Promise<CompletedTransaction> {
const result = await WalletTask.syncStateWithMintQueueAwaitable({ const result = await WalletTask.syncStateWithMintQueueAwaitable({
proofsToSync: sendProofs, proofsToSync: sendProofs,
mintUrl: tx.mint, mintUrl: _txMintUrl(tx),
proofState: 'PENDING', proofState: 'PENDING',
}) })
if (result.completedTransactionIds.includes(transactionId)) { if (result.completedTransactionIds.includes(transactionId)) {
@@ -631,7 +651,7 @@ async function refresh(transactionId: number): Promise<Transaction> {
} }
await WalletTask.syncStateWithMintQueueAwaitable({ await WalletTask.syncStateWithMintQueueAwaitable({
proofsToSync: sendProofs, proofsToSync: sendProofs,
mintUrl: tx.mint, mintUrl: _txMintUrl(tx),
proofState: 'PENDING', proofState: 'PENDING',
}) })
return transactionsStore.findById(transactionId) ?? tx return transactionsStore.findById(transactionId) ?? tx
@@ -657,6 +677,7 @@ function _rowToReservation(row: ReservationRow): ProofReservation {
return { return {
id: row.id, id: row.id,
transactionId: row.transactionId, transactionId: row.transactionId,
mintId: row.mintId,
mintUrl: row.mintUrl, mintUrl: row.mintUrl,
unit: row.unit as MintUnit, unit: row.unit as MintUnit,
operationType: row.operationType, operationType: row.operationType,
@@ -69,6 +69,27 @@ import {sendTopupNotification} from '../notifications'
const {mintsStore, proofsStore, transactionsStore, walletStore, walletProfileStore} = const {mintsStore, proofsStore, transactionsStore, walletStore, walletProfileStore} =
rootStoreInstance 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 // Public types
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -359,7 +380,7 @@ async function finalize(transactionId: number): Promise<CompletedTransaction> {
throw new ValidationError('Topup has no quote id; cannot finalize.', {transactionId}) 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) { if (state !== MintQuoteState.PAID) {
throw new MintError( throw new MintError(
`Cannot finalize topup; mint reports quote state ${state}.`, `Cannot finalize topup; mint reports quote state ${state}.`,
@@ -397,7 +418,7 @@ async function refresh(transactionId: number): Promise<Transaction> {
return tx 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 isExpired = !!tx.expiresAt && isBefore(tx.expiresAt, new Date())
const paymentHash = tx.paymentId const paymentHash = tx.paymentId
@@ -470,7 +491,7 @@ async function refresh(transactionId: number): Promise<Transaction> {
*/ */
async function _finalizePaid(tx: Transaction): Promise<CompletedTransaction> { async function _finalizePaid(tx: Transaction): Promise<CompletedTransaction> {
const transactionId = tx.id const transactionId = tx.id
const mintUrl = tx.mint const mintUrl = _txMintUrl(tx)
const unit = tx.unit const unit = tx.unit
const amount = tx.amount const amount = tx.amount
const mintQuote = tx.quote! const mintQuote = tx.quote!
@@ -100,6 +100,27 @@ type AnyMeltQuote = MeltQuoteBolt11Response | MeltQuoteOnchainResponse
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance 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 // Public types
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -961,8 +982,8 @@ function _isOnchainTransfer(tx: Transaction): boolean {
*/ */
async function _checkQuote(tx: Transaction, quoteId: string): Promise<AnyMeltQuote> { async function _checkQuote(tx: Transaction, quoteId: string): Promise<AnyMeltQuote> {
return _isOnchainTransfer(tx) return _isOnchainTransfer(tx)
? await walletStore.checkOnchainMeltQuote(tx.mint, quoteId) ? await walletStore.checkOnchainMeltQuote(_txMintUrl(tx), quoteId)
: await walletStore.checkLightningMeltQuote(tx.mint, quoteId) : await walletStore.checkLightningMeltQuote(_txMintUrl(tx), quoteId)
} }
/** /**
@@ -1103,7 +1124,7 @@ async function _finalizePaid(
quote: AnyMeltQuote, quote: AnyMeltQuote,
): Promise<CompletedTransaction> { ): Promise<CompletedTransaction> {
const transactionId = tx.id const transactionId = tx.id
const mintUrl = tx.mint const mintUrl = _txMintUrl(tx)
const unit = tx.unit const unit = tx.unit
const method: TransferMethod = _isOnchainTransfer(tx) ? 'onchain' : 'bolt11' const method: TransferMethod = _isOnchainTransfer(tx) ? 'onchain' : 'bolt11'
@@ -1340,6 +1361,7 @@ function _rowToReservation(row: ReservationRow): ProofReservation {
return { return {
id: row.id, id: row.id,
transactionId: row.transactionId, transactionId: row.transactionId,
mintId: row.mintId,
mintUrl: row.mintUrl, mintUrl: row.mintUrl,
unit: row.unit as MintUnit, unit: row.unit as MintUnit,
operationType: row.operationType, operationType: row.operationType,
+15 -1
View File
@@ -24,7 +24,21 @@ export type ProofReservation = {
/** Wallet transaction this reservation belongs to. */ /** Wallet transaction this reservation belongs to. */
transactionId: number 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 mintUrl: string
/** Currency unit (sat, msat, usd, …) — used when committing new proofs. */ /** 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) 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) { if (prepared.blocked) {
return { return {
taskFunction: RECEIVE_OFFLINE_COMPLETE_TASK, 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 allProofs = proofsStore.getByTransactionId(transaction.id)
const pendingProofs = allProofs.filter(p => p.state === 'PENDING') 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 // 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 // (commit or rollback) if a failure happens after it's opened. Errors thrown
@@ -75,7 +77,9 @@ try {
}) })
const receivedResult = await walletStore.receive( 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, unit as MintUnit,
encodedToken, encodedToken,
transaction.id transaction.id