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