mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-08-02 04:56:15 +00:00
Reference mints by stable id, not by url
Completes the mint-identity work. A mint url is a network locator and mints move, but the url had become the de facto foreign key for nearly every persisted row, so a url edit had to fan out across the schema and mostly did not. Each table now references the mint by an identity that a move cannot disturb, and setMintUrl shrinks to the two things that genuinely hold a url: Mint.mintUrl and the proofs.mintUrl cache. Which identity, per table mintId (Mint.id) where the row needs the mint itself: - onchain_mint_quotes. The critical one: a quote's address stays creditable for as long as the mint exists (rows are never deleted), so the reference has to outlive a move. It followed row.mintUrl, and the watcher swallows errors by design — a renamed mint stranded deposits permanently and silently. - reservations. A url edit racing an open send did not merely misfile the proofs; commitReservation resolved the mint by url, so it threw "Mint not found" and aborted the commit of an operation the mint had already performed. - transactions. `mint` was two things at once, switched by status: a historical record of where a finished payment happened, AND a live pointer dialled for an open one. That conflation is why a rename had to rewrite in-flight rows — rewriting the very column that records the past. mintId takes the identity job, so `mint` is frozen as history and the status-scoped rewrite is retired. No reference at all where the row already has a parent: - inflight_requests, melt_recovery are CHILD rows of a transaction (their primary key IS transactionId), so the parent owns "which mint". Their mintUrl and keysetId copies had ZERO readers — the keyset that is used comes from inside meltPreview. Both dropped; the one mint-scoped query joins through transactions.mintId. Nothing left to go stale. Mint.id gets referential authority only, never identity authority: findById answers "which mint is this row about?" and must never answer "are these the same mint?" — it is random and unrelated to the keys, so that question stays with the keysets. The backfills are IS NULL-guarded so a resolved row can never be re-pointed at whichever mint now answers an old url. Backfills run from JS (v38 seed), not SQL: mints live in the MST/MMKV snapshot, so nothing in SQL can map url -> id. Matching on url is trustworthy at exactly that moment and no other — until now a url could not change without these rows being rewritten to match. The join is spent once, at rest, instead of on every rename. Migration-system fixes found along the way - _dbVersion is now DERIVED from the migration list. It was a hand-maintained literal, and it was already wrong: it said 33 while migration 34 existed, so 34 would never have run. The failure is silent and asymmetric — fresh installs build from schema.ts and are fine, while upgrading devices land on a schema the code does not have. dbMigrationRegistry.test.ts pins this and the ordering invariants. - Migrations 26/28/29/31 built their tables from the LIVE schema constants. A device replaying them would get today's shape, and the later ALTER adding the column would fail with "duplicate column name" — breaking upgrades from exactly the versions those migrations serve. Historical shapes are frozen locally now, and the schema.ts header no longer recommends the sharing. - rootStoreModelVersion was left at 37 while the seed guarded on < 38, so the backfill would have re-run on every launch forever. Tests: 421 pass. Six hand-mirrored suites had drifted from the schema they claim to mirror; transactionsMintUrl.test.ts was worse — still passing while testing a function this commit deletes, so it is removed. The mirroring pattern is worth revisiting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9d759f83ab
commit
2f7a2763f2
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -17,41 +17,62 @@ const NOW = '2026-06-05T00:00:00.000Z'
|
||||
|
||||
const CREATE_INFLIGHT = `CREATE TABLE inflight_requests (
|
||||
transactionId INTEGER PRIMARY KEY NOT NULL,
|
||||
mintUrl TEXT,
|
||||
keysetId TEXT,
|
||||
request TEXT NOT NULL,
|
||||
createdAt TEXT
|
||||
)`
|
||||
|
||||
/**
|
||||
* The parent. inflight_requests is a CHILD of a transaction (its primary key IS
|
||||
* transactionId) and holds no mint reference of its own — the mint-scoped query
|
||||
* joins through here, so the fixture needs it.
|
||||
*/
|
||||
const CREATE_TRANSACTIONS = `CREATE TABLE transactions (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
mintId TEXT,
|
||||
mint TEXT,
|
||||
status TEXT
|
||||
)`
|
||||
|
||||
const MINT = 'https://mint.test'
|
||||
const MINT_ID = 'mint1111'
|
||||
const OTHER_MINT_ID = 'mint9999'
|
||||
|
||||
// ── Mirrored repo primitives (exact production SQL) ─────────────────────────
|
||||
|
||||
function addInFlightRequest(
|
||||
db: DatabaseSync,
|
||||
transactionId: number,
|
||||
mintUrl: string | null,
|
||||
keysetId: string | null,
|
||||
request: object,
|
||||
) {
|
||||
function addTransaction(db: DatabaseSync, id: number, mintId: string | null) {
|
||||
db.prepare(`INSERT OR REPLACE INTO transactions (id, mintId, mint, status) VALUES (?, ?, ?, 'PENDING')`)
|
||||
.run(id, mintId, MINT)
|
||||
}
|
||||
|
||||
function addInFlightRequest(db: DatabaseSync, transactionId: number, request: object) {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW)
|
||||
`INSERT OR REPLACE INTO inflight_requests (transactionId, request, createdAt)
|
||||
VALUES (?, ?, ?)`,
|
||||
).run(transactionId, JSON.stringify(request), NOW)
|
||||
}
|
||||
|
||||
function getInFlightRequest(db: DatabaseSync, transactionId: number) {
|
||||
const row = db
|
||||
.prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE transactionId = ?`)
|
||||
.get(transactionId) as {transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null} | undefined
|
||||
.prepare(`SELECT transactionId, request, createdAt FROM inflight_requests WHERE transactionId = ?`)
|
||||
.get(transactionId) as {transactionId: number; request: string; createdAt: string | null} | undefined
|
||||
if (!row) return undefined
|
||||
return {...row, request: JSON.parse(row.request)}
|
||||
}
|
||||
|
||||
function getInFlightRequestsByMint(db: DatabaseSync, mintUrl: string) {
|
||||
/**
|
||||
* The mint-scoped sweep. Joins through the owning transaction: this table keeps no
|
||||
* mint reference of its own, so `transactions.mintId` is the single owner of that
|
||||
* fact — and being an id, it survives the mint changing url.
|
||||
*/
|
||||
function getInFlightRequestsByMintId(db: DatabaseSync, mintId: string) {
|
||||
const rows = db
|
||||
.prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE mintUrl = ?`)
|
||||
.all(mintUrl) as Array<{transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null}>
|
||||
.prepare(
|
||||
`SELECT r.transactionId, r.request, r.createdAt
|
||||
FROM inflight_requests r
|
||||
JOIN transactions t ON t.id = r.transactionId
|
||||
WHERE t.mintId = ?`,
|
||||
)
|
||||
.all(mintId) as Array<{transactionId: number; request: string; createdAt: string | null}>
|
||||
return rows.map(r => ({...r, request: JSON.parse(r.request)}))
|
||||
}
|
||||
|
||||
@@ -59,23 +80,18 @@ function removeInFlightRequest(db: DatabaseSync, transactionId: number) {
|
||||
db.prepare(`DELETE FROM inflight_requests WHERE transactionId = ?`).run(transactionId)
|
||||
}
|
||||
|
||||
function seedInFlightRequest(
|
||||
db: DatabaseSync,
|
||||
transactionId: number,
|
||||
mintUrl: string | null,
|
||||
keysetId: string | null,
|
||||
request: object,
|
||||
) {
|
||||
function seedInFlightRequest(db: DatabaseSync, transactionId: number, request: object) {
|
||||
db.prepare(
|
||||
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`INSERT INTO inflight_requests (transactionId, request, createdAt)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(transactionId) DO NOTHING`,
|
||||
).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW)
|
||||
).run(transactionId, JSON.stringify(request), NOW)
|
||||
}
|
||||
|
||||
function freshDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
db.exec(CREATE_INFLIGHT)
|
||||
db.exec(CREATE_TRANSACTIONS)
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -86,12 +102,11 @@ describe('In-flight requests (inflight_requests)', () => {
|
||||
const db = freshDb()
|
||||
const request = {token: 'cashuA...', options: {keysetId: 'k1'}}
|
||||
|
||||
addInFlightRequest(db, 101, MINT, 'k1', request)
|
||||
addTransaction(db, 101, MINT_ID)
|
||||
addInFlightRequest(db, 101, request)
|
||||
const rec = getInFlightRequest(db, 101)!
|
||||
|
||||
expect(rec.transactionId).toBe(101)
|
||||
expect(rec.mintUrl).toBe(MINT)
|
||||
expect(rec.keysetId).toBe('k1')
|
||||
expect(rec.request).toEqual(request)
|
||||
db.close()
|
||||
})
|
||||
@@ -104,38 +119,79 @@ describe('In-flight requests (inflight_requests)', () => {
|
||||
|
||||
test('add OVERWRITES an existing entry (set semantics)', () => {
|
||||
const db = freshDb()
|
||||
addInFlightRequest(db, 101, MINT, 'k1', {v: 'first'})
|
||||
addInFlightRequest(db, 101, MINT, 'k1', {v: 'second'})
|
||||
addTransaction(db, 101, MINT_ID)
|
||||
addInFlightRequest(db, 101, {v: 'first'})
|
||||
addInFlightRequest(db, 101, {v: 'second'})
|
||||
|
||||
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'second'})
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('getInFlightRequestsByMint returns all rows for a mint', () => {
|
||||
test('getInFlightRequestsByMintId returns all rows of a mint', () => {
|
||||
const db = freshDb()
|
||||
addInFlightRequest(db, 101, MINT, 'k1', {v: 1})
|
||||
addInFlightRequest(db, 102, MINT, 'k1', {v: 2})
|
||||
addInFlightRequest(db, 103, 'https://other.test', 'k9', {v: 3})
|
||||
addTransaction(db, 101, MINT_ID)
|
||||
addTransaction(db, 102, MINT_ID)
|
||||
addTransaction(db, 103, OTHER_MINT_ID)
|
||||
addInFlightRequest(db, 101, {v: 1})
|
||||
addInFlightRequest(db, 102, {v: 2})
|
||||
addInFlightRequest(db, 103, {v: 3})
|
||||
|
||||
const forMint = getInFlightRequestsByMint(db, MINT)
|
||||
const forMint = getInFlightRequestsByMintId(db, MINT_ID)
|
||||
expect(forMint.map(r => r.transactionId).sort()).toEqual([101, 102])
|
||||
expect(getInFlightRequestsByMint(db, 'https://other.test')).toHaveLength(1)
|
||||
expect(getInFlightRequestsByMintId(db, OTHER_MINT_ID)).toHaveLength(1)
|
||||
db.close()
|
||||
})
|
||||
|
||||
// The point of joining on mintId rather than a url copy: the mint moving must
|
||||
// not hide its own in-flight work.
|
||||
test('still finds a mint\'s requests after its url changes', () => {
|
||||
const db = freshDb()
|
||||
addTransaction(db, 101, MINT_ID)
|
||||
addInFlightRequest(db, 101, {v: 1})
|
||||
|
||||
// transactions.mint is frozen history and never rewritten; only the mint's
|
||||
// live url moves. The id is unaffected, so the join is too.
|
||||
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(1)
|
||||
db.close()
|
||||
})
|
||||
|
||||
// Correct, not a regression: the retry settles proofs onto its transaction and
|
||||
// the handler branches on tx.type, so without the parent there is nothing to
|
||||
// apply the result to.
|
||||
test('a request whose transaction is gone is not returned', () => {
|
||||
const db = freshDb()
|
||||
addTransaction(db, 101, MINT_ID)
|
||||
addInFlightRequest(db, 101, {v: 1})
|
||||
db.prepare('DELETE FROM transactions WHERE id = ?').run(101)
|
||||
|
||||
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('a transaction with no mintId is not returned', () => {
|
||||
const db = freshDb()
|
||||
addTransaction(db, 101, null)
|
||||
addInFlightRequest(db, 101, {v: 1})
|
||||
|
||||
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('remove deletes the entry', () => {
|
||||
const db = freshDb()
|
||||
addInFlightRequest(db, 101, MINT, 'k1', {v: 1})
|
||||
addTransaction(db, 101, MINT_ID)
|
||||
addInFlightRequest(db, 101, {v: 1})
|
||||
removeInFlightRequest(db, 101)
|
||||
expect(getInFlightRequest(db, 101)).toBeUndefined()
|
||||
expect(getInFlightRequestsByMint(db, MINT)).toHaveLength(0)
|
||||
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('seed is idempotent — does not overwrite an existing entry', () => {
|
||||
const db = freshDb()
|
||||
addInFlightRequest(db, 101, MINT, 'k1', {v: 'live'})
|
||||
seedInFlightRequest(db, 101, MINT, 'k1', {v: 'snapshot'})
|
||||
addTransaction(db, 101, MINT_ID)
|
||||
addInFlightRequest(db, 101, {v: 'live'})
|
||||
seedInFlightRequest(db, 101, {v: 'snapshot'})
|
||||
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'live'})
|
||||
db.close()
|
||||
})
|
||||
|
||||
@@ -18,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()
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* Mint-url rewrite scoping for transactions
|
||||
* (Database.updateInFlightTransactionsMintUrl).
|
||||
*
|
||||
* `transactions.mint` carries two meanings, switched by status:
|
||||
*
|
||||
* - TERMINAL: a historical record of where the payment actually happened.
|
||||
* Rewriting it would falsify history, so a mint-url edit must leave it alone.
|
||||
* - IN-FLIGHT: a LIVE pointer the wallet still calls —
|
||||
* checkLightningMintQuote(tx.mint, tx.quote) in topupOperationApi,
|
||||
* checkLightningMeltQuote / checkOnchainMeltQuote in transferOperationApi,
|
||||
* findByUrl(tx.mint) on the revert/receive paths. Left stale after an edit, an
|
||||
* open transaction is stranded at a dead url — a paid topup whose ecash the
|
||||
* wallet can never mint.
|
||||
*
|
||||
* Mirrors the production SQL with node:sqlite, as the native driver needs a
|
||||
* device — but takes the status list from the REAL IN_FLIGHT_STATUSES rather
|
||||
* than a copy, so the mirror cannot drift from what production actually runs.
|
||||
*
|
||||
* @jest-environment node
|
||||
*/
|
||||
jest.mock('../src/services/logService', () => ({
|
||||
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
|
||||
}))
|
||||
jest.mock('../src/services', () => ({
|
||||
Database: {},
|
||||
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
|
||||
}))
|
||||
|
||||
import {DatabaseSync} from 'node:sqlite'
|
||||
import {IN_FLIGHT_STATUSES} from '../src/models/TransactionStates'
|
||||
import {TransactionStatus} from '../src/models/Transaction'
|
||||
|
||||
const OLD_URL = 'https://old.mint.test'
|
||||
const NEW_URL = 'https://new.mint.test'
|
||||
const OTHER_URL = 'https://other.mint.test'
|
||||
|
||||
/** The exact set the production UPDATE binds. */
|
||||
const IN_FLIGHT = [...IN_FLIGHT_STATUSES] as string[]
|
||||
|
||||
/** Everything else — by construction, so a new enum member lands here loudly. */
|
||||
const TERMINAL = Object.values(TransactionStatus).filter(s => !IN_FLIGHT.includes(s)) as string[]
|
||||
|
||||
const CREATE_TRANSACTIONS = `CREATE TABLE transactions (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
type TEXT,
|
||||
amount INTEGER,
|
||||
unit TEXT,
|
||||
data TEXT,
|
||||
mint TEXT,
|
||||
status TEXT,
|
||||
createdAt TEXT
|
||||
)`
|
||||
|
||||
/** Mirrors transactionsRepo.updateInFlightTransactionsMintUrl. */
|
||||
function updateInFlightTransactionsMintUrl(
|
||||
db: DatabaseSync,
|
||||
currentMintUrl: string,
|
||||
updatedMintUrl: string,
|
||||
): number {
|
||||
const placeholders = IN_FLIGHT.map(() => '?').join(', ')
|
||||
const {changes} = db
|
||||
.prepare(
|
||||
`UPDATE transactions
|
||||
SET mint = ?
|
||||
WHERE mint = ? AND status IN (${placeholders})`,
|
||||
)
|
||||
.run(updatedMintUrl, currentMintUrl, ...IN_FLIGHT)
|
||||
return Number(changes)
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
function insertTx(db: DatabaseSync, mint: string, status: string): number {
|
||||
const id = nextId++
|
||||
db.prepare(
|
||||
`INSERT INTO transactions (id, type, amount, unit, data, mint, status, createdAt)
|
||||
VALUES (?, 'TOPUP', 100, 'sat', '{}', ?, ?, '2026-01-01')`,
|
||||
).run(id, mint, status)
|
||||
return id
|
||||
}
|
||||
|
||||
function mintOf(db: DatabaseSync, id: number): string {
|
||||
const row = db.prepare('SELECT mint FROM transactions WHERE id = ?').get(id) as {mint: string}
|
||||
return row.mint
|
||||
}
|
||||
|
||||
function freshDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
db.exec(CREATE_TRANSACTIONS)
|
||||
nextId = 1
|
||||
return db
|
||||
}
|
||||
|
||||
describe('updateInFlightTransactionsMintUrl', () => {
|
||||
describe('in-flight transactions are repointed', () => {
|
||||
test.each(IN_FLIGHT)('%s is repointed to the new url', status => {
|
||||
const db = freshDb()
|
||||
const id = insertTx(db, OLD_URL, status)
|
||||
|
||||
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(1)
|
||||
expect(mintOf(db, id)).toBe(NEW_URL)
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal transactions keep their historical url', () => {
|
||||
test.each(TERMINAL)('%s is left untouched', status => {
|
||||
const db = freshDb()
|
||||
const id = insertTx(db, OLD_URL, status)
|
||||
|
||||
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(0)
|
||||
expect(mintOf(db, id)).toBe(OLD_URL)
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
test('splits a mixed history: open rows move, closed rows stay', () => {
|
||||
const db = freshDb()
|
||||
const pending = insertTx(db, OLD_URL, 'PENDING')
|
||||
const executing = insertTx(db, OLD_URL, 'EXECUTING')
|
||||
const completed = insertTx(db, OLD_URL, 'COMPLETED')
|
||||
const reverted = insertTx(db, OLD_URL, 'REVERTED')
|
||||
|
||||
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(2)
|
||||
|
||||
expect(mintOf(db, pending)).toBe(NEW_URL)
|
||||
expect(mintOf(db, executing)).toBe(NEW_URL)
|
||||
expect(mintOf(db, completed)).toBe(OLD_URL)
|
||||
expect(mintOf(db, reverted)).toBe(OLD_URL)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('does not touch another mint\'s in-flight transactions', () => {
|
||||
const db = freshDb()
|
||||
const mine = insertTx(db, OLD_URL, 'PENDING')
|
||||
const theirs = insertTx(db, OTHER_URL, 'PENDING')
|
||||
|
||||
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(1)
|
||||
|
||||
expect(mintOf(db, mine)).toBe(NEW_URL)
|
||||
expect(mintOf(db, theirs)).toBe(OTHER_URL)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('is a no-op when the mint has no transactions', () => {
|
||||
const db = freshDb()
|
||||
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(0)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('is idempotent — re-running finds nothing left at the old url', () => {
|
||||
const db = freshDb()
|
||||
const pending = insertTx(db, OLD_URL, 'PENDING')
|
||||
|
||||
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(1)
|
||||
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(0)
|
||||
expect(mintOf(db, pending)).toBe(NEW_URL)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('a second rename chains correctly (A -> B -> C)', () => {
|
||||
const db = freshDb()
|
||||
const pending = insertTx(db, OLD_URL, 'PENDING')
|
||||
const completed = insertTx(db, OLD_URL, 'COMPLETED')
|
||||
|
||||
updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)
|
||||
updateInFlightTransactionsMintUrl(db, NEW_URL, OTHER_URL)
|
||||
|
||||
expect(mintOf(db, pending)).toBe(OTHER_URL)
|
||||
// Still frozen at the url its payment actually used, two renames later.
|
||||
expect(mintOf(db, completed)).toBe(OLD_URL)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('the in-flight set matches TransactionStates exactly', () => {
|
||||
// Pins the classification itself: a status moved between the sets, or a new
|
||||
// one added to neither, changes whether a rename repoints that transaction.
|
||||
expect([...IN_FLIGHT].sort()).toEqual([
|
||||
'DRAFT',
|
||||
'EXECUTING',
|
||||
'PENDING',
|
||||
'PREPARED',
|
||||
'PREPARED_OFFLINE',
|
||||
'ROLLING_BACK',
|
||||
])
|
||||
})
|
||||
|
||||
test('every status is classified as either live or historical', () => {
|
||||
// TERMINAL is derived as the complement, so this asserts the enum has not
|
||||
// grown a member that silently falls into "historical" without anyone
|
||||
// deciding that is right for it.
|
||||
expect([...IN_FLIGHT, ...TERMINAL].sort()).toEqual(Object.values(TransactionStatus).sort())
|
||||
expect(TERMINAL.sort()).toEqual([
|
||||
'BLOCKED',
|
||||
'COMPLETED',
|
||||
'ERROR',
|
||||
'EXPIRED',
|
||||
'RECOVERED',
|
||||
'REVERTED',
|
||||
])
|
||||
})
|
||||
})
|
||||
+10
-13
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -204,14 +204,8 @@ const TERMINAL_STATUSES: ReadonlySet<TransactionStatus> = new Set([
|
||||
TransactionStatus.RECOVERED,
|
||||
])
|
||||
|
||||
/**
|
||||
* Statuses in which a transaction is still open — the complement of
|
||||
* TERMINAL_STATUSES. Exported because it is not only a type-narrowing concern:
|
||||
* an open transaction's `mint` url is a LIVE pointer the wallet still calls,
|
||||
* whereas a terminal one's is a historical record (see
|
||||
* Database.updateInFlightTransactionsMintUrl).
|
||||
*/
|
||||
export const IN_FLIGHT_STATUSES: ReadonlySet<TransactionStatus> = new Set([
|
||||
/** Statuses in which a transaction is still open — the complement of TERMINAL_STATUSES. */
|
||||
const IN_FLIGHT_STATUSES: ReadonlySet<TransactionStatus> = new Set([
|
||||
TransactionStatus.DRAFT,
|
||||
TransactionStatus.PREPARED,
|
||||
TransactionStatus.PREPARED_OFFLINE,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
TransactionStatus,
|
||||
TransactionType,
|
||||
} from './Transaction'
|
||||
import { isInFlight } from './TransactionStates'
|
||||
import { Database } from '../services'
|
||||
import { log } from '../services/logService'
|
||||
import { getRootStore } from './helpers/getRootStore'
|
||||
@@ -156,32 +155,23 @@ export const TransactionsStoreModel = types
|
||||
},
|
||||
|
||||
/**
|
||||
* Mirror a mint-url edit onto in-flight transactions only — see
|
||||
* Database.updateInFlightTransactionsMintUrl for why terminal rows keep
|
||||
* the url their payment actually used.
|
||||
* Drop recent-list entries whose mint is no longer in the wallet.
|
||||
*
|
||||
* SQLite first: if the write throws, the exception propagates with the MST
|
||||
* tree still matching the database. Updating memory first would leave the
|
||||
* UI reading a url the database does not have, which the next restart
|
||||
* would silently revert.
|
||||
* Tests mintId, not the url. `tx.mint` is where the payment happened and is
|
||||
* never rewritten, so a mint that has since moved would fail a url test and
|
||||
* its whole history would vanish from the list as though the mint had been
|
||||
* removed. The id answers the question actually being asked: does this
|
||||
* transaction's mint still exist?
|
||||
*
|
||||
* A null mintId means exactly that it does not (removed mint, or history
|
||||
* older than the v38 backfill), so those are dropped too — which is what
|
||||
* this prune is for.
|
||||
*/
|
||||
updateMintUrl(currentMintUrl: string, updatedMintUrl: string) {
|
||||
Database.updateInFlightTransactionsMintUrl(currentMintUrl, updatedMintUrl)
|
||||
|
||||
for (const tx of self.transactionsMap.values()) {
|
||||
if (tx.mint === currentMintUrl && isInFlight(tx)) {
|
||||
tx.setProp('mint', updatedMintUrl)
|
||||
}
|
||||
}
|
||||
|
||||
log.trace('[updateMintUrl] Repointed in-flight transactions', {currentMintUrl, updatedMintUrl})
|
||||
},
|
||||
|
||||
pruneRecentWithoutCurrentMint() {
|
||||
const { mintsStore } = getRootStore(self)
|
||||
const validUrls = new Set(mintsStore.allMints.map(m => m.mintUrl))
|
||||
const validIds = new Set(mintsStore.allMints.map((m: Mint) => m.id))
|
||||
|
||||
const newList = self.recentByUnit.filter(tx => validUrls.has(tx.mint))
|
||||
const newList = self.recentByUnit.filter(tx => tx.mintId && validIds.has(tx.mintId))
|
||||
if (newList.length !== self.recentByUnit.length) {
|
||||
self.recentByUnit.replace(newList)
|
||||
log.trace('[pruneRecentWithoutCurrentMint]', `${self.recentByUnit.length - newList.length} removed`)
|
||||
@@ -199,8 +189,21 @@ export const TransactionsStoreModel = types
|
||||
|
||||
.actions(self => ({
|
||||
// ── Main add (push + safe prune) ──
|
||||
addTransaction: flow(function* addTransaction(newTxData: Partial<Transaction>) {
|
||||
const dbTx: Transaction = yield Database.addTransactionAsync(newTxData)
|
||||
// Return type is annotated because the body reaches the root store, and an
|
||||
// inferred type would loop: RootStore -> TransactionsStore -> RootStore.
|
||||
addTransaction: flow(function* addTransaction(
|
||||
newTxData: Partial<Transaction>,
|
||||
): Generator<any, Transaction, any> {
|
||||
// Resolved here rather than asked of every call site: they all identify
|
||||
// the mint by url, but the row needs the id that survives a url edit
|
||||
// (see Transaction.mintId). Callers may still pass mintId explicitly.
|
||||
const mintId: string | null =
|
||||
newTxData.mintId ??
|
||||
(newTxData.mint
|
||||
? (getRootStore(self).mintsStore.findByUrl(newTxData.mint)?.id as string | undefined) ?? null
|
||||
: null)
|
||||
|
||||
const dbTx: Transaction = yield Database.addTransactionAsync({...newTxData, mintId})
|
||||
const tx = TransactionModel.create(dbTx)
|
||||
|
||||
self.transactionsMap.set(String(dbTx.id), tx)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -489,7 +489,7 @@ return (
|
||||
label="cashuPaymentRequest_to"
|
||||
isFirst={true}
|
||||
value={
|
||||
mintsStore.findByUrl(transaction.mint)
|
||||
mintsStore.findByTransaction(transaction)
|
||||
?.shortname as string
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -462,7 +462,7 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
|
||||
<TranItem
|
||||
label="transactionCommon_receivedTo"
|
||||
isFirst={true}
|
||||
value={mintsStore.findByUrl(transaction.mint)?.shortname as string}
|
||||
value={mintsStore.findByTransaction(transaction)?.shortname as string}
|
||||
/>
|
||||
{transaction?.memo && (
|
||||
<TranItem
|
||||
|
||||
@@ -1583,7 +1583,7 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
|
||||
<TranItem
|
||||
label="tranDetailScreen_sentTo"
|
||||
isFirst={true}
|
||||
value={mintsStore.findByUrl(transaction.mint)?.shortname as string}
|
||||
value={mintsStore.findByTransaction(transaction)?.shortname as string}
|
||||
/>
|
||||
{transaction?.memo && (
|
||||
<TranItem
|
||||
|
||||
@@ -1107,7 +1107,7 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
|
||||
label="topup_to"
|
||||
isFirst={true}
|
||||
value={
|
||||
mintsStore.findByUrl(transaction.mint)
|
||||
mintsStore.findByTransaction(transaction)
|
||||
?.shortname as string
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -41,6 +41,16 @@ export const ONCHAIN_QUOTE_WATCH_DAYS = 7
|
||||
|
||||
export type OnchainMintQuoteRecord = {
|
||||
quote: string
|
||||
/**
|
||||
* Stable id (Mint.id) of the owning mint — the ONLY reference to resolve for
|
||||
* processing. Null when the mint is no longer in the wallet (or on a pre-v38 row
|
||||
* the backfill has not reached yet), in which case the quote is unusable.
|
||||
*/
|
||||
mintId: string | null
|
||||
/**
|
||||
* Where the quote was created. A historical record — do NOT follow it: after a
|
||||
* mint-url edit it points at a dead host, and these rows outlive the move.
|
||||
*/
|
||||
mintUrl: string
|
||||
unit: string
|
||||
address: string
|
||||
@@ -59,7 +69,7 @@ export type OnchainMintQuoteRecord = {
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
const COLS = `quote, mintUrl, unit, address, counterIndex, pubkey, amountRequested,
|
||||
const COLS = `quote, mintId, mintUrl, unit, address, counterIndex, pubkey, amountRequested,
|
||||
amountPaid, amountIssued, expiry, watchUntil, createdAt, updatedAt`
|
||||
|
||||
/**
|
||||
@@ -70,7 +80,11 @@ const COLS = `quote, mintUrl, unit, address, counterIndex, pubkey, amountRequest
|
||||
* NUT-20 index) is already safe, so a deposit to that address remains mintable.
|
||||
*/
|
||||
export const addOnchainMintQuote = function (
|
||||
q: Omit<OnchainMintQuoteRecord, 'amountPaid' | 'amountIssued' | 'createdAt' | 'updatedAt' | 'watchUntil'> & {
|
||||
q: Omit<
|
||||
OnchainMintQuoteRecord,
|
||||
'mintId' | 'amountPaid' | 'amountIssued' | 'createdAt' | 'updatedAt' | 'watchUntil'
|
||||
> & {
|
||||
mintId?: string | null
|
||||
amountPaid?: number
|
||||
amountIssued?: number
|
||||
watchUntil?: string
|
||||
@@ -84,9 +98,10 @@ export const addOnchainMintQuote = function (
|
||||
|
||||
getInstance().execute(
|
||||
`INSERT OR REPLACE INTO onchain_mint_quotes (${COLS})
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
q.quote,
|
||||
q.mintId ?? null,
|
||||
q.mintUrl,
|
||||
q.unit,
|
||||
q.address,
|
||||
@@ -102,7 +117,7 @@ export const addOnchainMintQuote = function (
|
||||
],
|
||||
)
|
||||
|
||||
log.debug('[addOnchainMintQuote]', {quote: q.quote, mintUrl: q.mintUrl})
|
||||
log.debug('[addOnchainMintQuote]', {quote: q.quote, mintId: q.mintId, mintUrl: q.mintUrl})
|
||||
} catch (e: any) {
|
||||
throw dbError('Onchain mint quote could not be saved to the database', e)
|
||||
}
|
||||
@@ -182,12 +197,17 @@ export const getWatchedOnchainMintQuotes = function (): OnchainMintQuoteRecord[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Every quote for a mint, newest first. Drives the manual re-check. */
|
||||
export const getOnchainMintQuotesByMint = function (mintUrl: string): OnchainMintQuoteRecord[] {
|
||||
/**
|
||||
* Every quote of a mint, newest first.
|
||||
*
|
||||
* Keyed by mintId, not url: the mint may have moved since the quote was created,
|
||||
* and a url match would then silently return nothing.
|
||||
*/
|
||||
export const getOnchainMintQuotesByMintId = function (mintId: string): OnchainMintQuoteRecord[] {
|
||||
try {
|
||||
const {rows} = getInstance().execute(
|
||||
`SELECT ${COLS} FROM onchain_mint_quotes WHERE mintUrl = ? ORDER BY createdAt DESC`,
|
||||
[mintUrl],
|
||||
`SELECT ${COLS} FROM onchain_mint_quotes WHERE mintId = ? ORDER BY createdAt DESC`,
|
||||
[mintId],
|
||||
)
|
||||
return (rows?._array ?? []) as OnchainMintQuoteRecord[]
|
||||
} catch (e: any) {
|
||||
@@ -195,6 +215,40 @@ export const getOnchainMintQuotesByMint = function (mintUrl: string): OnchainMin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time backfill of `mintId` from the url a row was created at (the v38 seed).
|
||||
*
|
||||
* Only touches rows that have no mintId yet, so it is idempotent and can never
|
||||
* overwrite a resolved id with a guess from a url that has since moved on.
|
||||
*
|
||||
* Matching on url is sound ONLY here, at the moment of upgrade: before this
|
||||
* migration nothing could edit a mint's url without also rewriting these rows'
|
||||
* mintUrl, so the two still agree. That is exactly why the join is spent once, at
|
||||
* rest, rather than on every rename.
|
||||
*/
|
||||
export const backfillOnchainMintQuoteMintIds = function (
|
||||
mints: Array<{id: string; mintUrl: string}>,
|
||||
): {updated: number} {
|
||||
if (mints.length === 0) return {updated: 0}
|
||||
try {
|
||||
const db = getInstance()
|
||||
let updated = 0
|
||||
|
||||
for (const mint of mints) {
|
||||
const {rowsAffected} = db.execute(
|
||||
`UPDATE onchain_mint_quotes SET mintId = ? WHERE mintUrl = ? AND mintId IS NULL`,
|
||||
[mint.id, mint.mintUrl],
|
||||
)
|
||||
updated += rowsAffected ?? 0
|
||||
}
|
||||
|
||||
log.info('[backfillOnchainMintQuoteMintIds]', 'Backfilled onchain quote mint ids', {updated})
|
||||
return {updated}
|
||||
} catch (e: any) {
|
||||
throw dbError('Onchain mint quote mintIds could not be backfilled', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen the watch window on a quote — the user asking "did anything else arrive?"
|
||||
* from the transaction detail, after the quote had been archived.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {Transaction, TransactionStatus} from '../../models/Transaction'
|
||||
import {IN_FLIGHT_STATUSES} from '../../models/TransactionStates'
|
||||
import AppError, {Err} from '../../utils/AppError'
|
||||
import {log} from '../logService'
|
||||
import {getInstance} from './instance'
|
||||
@@ -50,50 +49,38 @@ export const updateTransaction = function (id: number, fields: Partial<Transacti
|
||||
}
|
||||
|
||||
/**
|
||||
* Repoint a mint's IN-FLIGHT transactions at its new url, leaving terminal ones
|
||||
* untouched.
|
||||
* One-time backfill of `mintId` from the url a transaction happened at (the v38
|
||||
* seed).
|
||||
*
|
||||
* `transactions.mint` carries two meanings, switched by status:
|
||||
* Only fills NULLs, so it is idempotent and can never re-point a resolved row at
|
||||
* whichever mint now answers a url. Rows left NULL belong to mints no longer in the
|
||||
* wallet — legitimate history, which keeps its `mint` url and simply has no mint to
|
||||
* act on.
|
||||
*
|
||||
* - TERMINAL (completed, reverted, …): a historical record of where the payment
|
||||
* actually happened. Rewriting it would falsify history, so it is frozen.
|
||||
* - IN-FLIGHT: a LIVE pointer the wallet still calls — checkLightningMintQuote
|
||||
* (topupOperationApi), checkLightningMeltQuote / checkOnchainMeltQuote
|
||||
* (transferOperationApi), and findByUrl(tx.mint) on the revert/receive paths.
|
||||
* Leaving it stale after a mint-url edit strands an open transaction at a dead
|
||||
* url: a paid topup whose ecash the wallet can never mint.
|
||||
*
|
||||
* One statement, so the status test and the write cannot straddle a concurrent
|
||||
* status transition — a transaction that goes terminal mid-rename is either fully
|
||||
* in or fully out.
|
||||
* Matching on url is sound ONLY here, at the moment of upgrade: until now a mint's
|
||||
* url could not change without in-flight rows being rewritten to match, so the two
|
||||
* still agree. This spends that join once, at rest, instead of on every rename.
|
||||
*/
|
||||
export const updateInFlightTransactionsMintUrl = function (
|
||||
currentMintUrl: string,
|
||||
updatedMintUrl: string,
|
||||
export const backfillTransactionMintIds = function (
|
||||
mints: Array<{id: string; mintUrl: string}>,
|
||||
): {updated: number} {
|
||||
if (mints.length === 0) return {updated: 0}
|
||||
try {
|
||||
const statuses = [...IN_FLIGHT_STATUSES]
|
||||
const placeholders = statuses.map(() => '?').join(', ')
|
||||
|
||||
const db = getInstance()
|
||||
const {rowsAffected} = db.execute(
|
||||
`UPDATE transactions
|
||||
SET mint = ?
|
||||
WHERE mint = ? AND status IN (${placeholders})`,
|
||||
[updatedMintUrl, currentMintUrl, ...statuses],
|
||||
)
|
||||
let updated = 0
|
||||
|
||||
const updated = rowsAffected ?? 0
|
||||
|
||||
log.debug('[updateInFlightTransactionsMintUrl]', 'Repointed in-flight transactions', {
|
||||
currentMintUrl,
|
||||
updatedMintUrl,
|
||||
updated,
|
||||
})
|
||||
for (const mint of mints) {
|
||||
const {rowsAffected} = db.execute(
|
||||
`UPDATE transactions SET mintId = ? WHERE mint = ? AND mintId IS NULL`,
|
||||
[mint.id, mint.mintUrl],
|
||||
)
|
||||
updated += rowsAffected ?? 0
|
||||
}
|
||||
|
||||
log.info('[backfillTransactionMintIds]', 'Backfilled transaction mint ids', {updated})
|
||||
return {updated}
|
||||
} catch (e: any) {
|
||||
throw dbError('Could not update transactions mintUrl in database', e)
|
||||
throw dbError('Transaction mintIds could not be backfilled', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,19 +525,19 @@ export const getLastTransactionBy = function (
|
||||
|
||||
export const addTransactionAsync = async function (tx: Partial<Transaction>): Promise<Transaction> {
|
||||
try {
|
||||
const {type, amount, fee, unit, data, memo, mint, status} = tx
|
||||
const {type, amount, fee, unit, data, memo, mint, mintId, status} = tx
|
||||
const now = new Date()
|
||||
|
||||
const query = `
|
||||
INSERT INTO transactions (type, amount, fee, unit, data, memo, mint, status, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO transactions (type, amount, fee, unit, data, memo, mint, mintId, status, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
const params = [type, amount, fee, unit, data, memo, mint, status, now.toISOString()]
|
||||
const params = [type, amount, fee, unit, data, memo, mint, mintId ?? null, status, now.toISOString()]
|
||||
|
||||
const db = getInstance()
|
||||
const result = await db.executeAsync(query, params)
|
||||
|
||||
log.debug('[addTransactionAsync] New transaction added to the database', {id: result.insertId, type, mint, status})
|
||||
log.debug('[addTransactionAsync] New transaction added to the database', {id: result.insertId, type, mint, mintId, status})
|
||||
|
||||
return getTransactionById(result.insertId as number) // already normalized
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ const {
|
||||
*/
|
||||
const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> => {
|
||||
const mintUrl = mint.mintUrl
|
||||
const inFlightRequests = Database.getInFlightRequestsByMint(mintUrl)
|
||||
// By id, not url: the requests are found through their transaction's mintId, so
|
||||
// a mint that has changed url still finds its own in-flight work.
|
||||
const inFlightRequests = Database.getInFlightRequestsByMintId(mint.id!)
|
||||
const totalRequests = inFlightRequests.length
|
||||
|
||||
log.trace('[handleInFlightByMintTask] start', {mintUrl, totalRequests})
|
||||
@@ -370,7 +372,7 @@ const handleInFlightQueue = async function (): Promise<void> {
|
||||
|
||||
for (const mint of mintsStore.allMints) {
|
||||
|
||||
if (Database.getInFlightRequestsByMint(mint.mintUrl).length === 0) {
|
||||
if (Database.getInFlightRequestsByMintId(mint.id!).length === 0) {
|
||||
log.trace('No inFlight requests for mint, skipping...')
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
} from '../../../models/Transaction'
|
||||
import {Database} from '../../../services'
|
||||
import {OnchainMintQuoteRecord} from '../../db/onchainQuotesRepo'
|
||||
import type {Mint} from '../../../models/Mint'
|
||||
import {allocateQuoteKeypair, deriveQuoteKeypair} from '../../cashu/nut20'
|
||||
import {capMintAmount, mintableAmount} from './onchainAmounts'
|
||||
import {CashuProof} from '../../cashu/cashuUtils'
|
||||
@@ -154,6 +155,10 @@ async function createQuote(input: {
|
||||
// the row (with its counterIndex) is already safe, so a deposit to that address
|
||||
// stays mintable. Losing it would make the money unspendable.
|
||||
Database.addOnchainMintQuote({
|
||||
// The stable reference. mintUrl is stored alongside it purely as the
|
||||
// record of where this quote was created — the mint may move, and this
|
||||
// row outlives the move.
|
||||
mintId: mintsStore.findByUrl(mintUrl)?.id,
|
||||
quote: quoteResponse.quote,
|
||||
mintUrl,
|
||||
unit,
|
||||
@@ -274,7 +279,8 @@ async function refreshQuote(quoteId: string): Promise<RefreshOnchainQuoteResult>
|
||||
throw new ValidationError('Unknown onchain mint quote', {quote: quoteId})
|
||||
}
|
||||
|
||||
const quoteResponse = await walletStore.checkOnchainMintQuote(row.mintUrl, quoteId)
|
||||
const mint = _resolveQuoteMint(row)
|
||||
const quoteResponse = await walletStore.checkOnchainMintQuote(mint.mintUrl, quoteId)
|
||||
|
||||
const amountPaid = Number(quoteResponse.amount_paid ?? 0)
|
||||
const amountIssued = Number(quoteResponse.amount_issued ?? 0)
|
||||
@@ -294,7 +300,7 @@ async function refreshQuote(quoteId: string): Promise<RefreshOnchainQuoteResult>
|
||||
return {quote: quoteId, amountPaid, amountIssued, minted: 0}
|
||||
}
|
||||
|
||||
const transactionId = await _mintAvailable(row, quoteResponse, mintable)
|
||||
const transactionId = await _mintAvailable(row, mint, quoteResponse, mintable)
|
||||
|
||||
return {
|
||||
quote: quoteId,
|
||||
@@ -309,6 +315,33 @@ async function refreshQuote(quoteId: string): Promise<RefreshOnchainQuoteResult>
|
||||
// Private
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a quote's owning mint through its stable id.
|
||||
*
|
||||
* `row.mintUrl` is deliberately NOT consulted. It records where the quote was
|
||||
* created, and a mint that has since moved would leave it pointing at a host that
|
||||
* no longer answers — while this quote's address stays creditable for as long as
|
||||
* the mint exists (rows here are never deleted), so a late deposit would be
|
||||
* unmintable forever, and silently, because the watcher swallows errors by design.
|
||||
*
|
||||
* A missing mint means it was removed from the wallet (or, on a row from before
|
||||
* v33 that the v38 backfill could not match, that its url had already moved on).
|
||||
* Either way there is nothing to talk to, so this throws rather than guessing.
|
||||
*/
|
||||
function _resolveQuoteMint(row: OnchainMintQuoteRecord): Mint {
|
||||
const mint = row.mintId ? mintsStore.findById(row.mintId) : undefined
|
||||
|
||||
if (!mint) {
|
||||
throw new ValidationError('Onchain quote has no mint in this wallet', {
|
||||
quote: row.quote,
|
||||
mintId: row.mintId,
|
||||
createdAtUrl: row.mintUrl,
|
||||
})
|
||||
}
|
||||
|
||||
return mint
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the available balance on a quote and settle it onto a transaction.
|
||||
*
|
||||
@@ -319,24 +352,28 @@ async function refreshQuote(quoteId: string): Promise<RefreshOnchainQuoteResult>
|
||||
*/
|
||||
async function _mintAvailable(
|
||||
row: OnchainMintQuoteRecord,
|
||||
mintInstance: Mint,
|
||||
quoteResponse: any,
|
||||
mintable: number,
|
||||
): Promise<number> {
|
||||
const {quote, mintUrl, counterIndex} = row
|
||||
const {quote, counterIndex} = row
|
||||
const unit = row.unit as MintUnit
|
||||
|
||||
// The mint resolved from row.mintId by the caller — NOT row.mintUrl, which is
|
||||
// only where the quote was created and may since have moved.
|
||||
const mintUrl = mintInstance.mintUrl
|
||||
|
||||
// The mint may cap a single mint operation; never ask for more than it allows.
|
||||
// Any remainder stays credited on the quote, where the watch rule keeps it
|
||||
// visible and the next sweep takes the rest.
|
||||
const mintInstance = mintsStore.findByUrl(mintUrl)
|
||||
const maxAmount = mintInstance?.mintMethodSetting!('onchain', unit)?.max_amount
|
||||
const maxAmount = mintInstance.mintMethodSetting!('onchain', unit)?.max_amount
|
||||
const amount = capMintAmount(mintable, maxAmount ? Number(maxAmount) : null)
|
||||
|
||||
// Re-derive the NUT-20 signing key from the seed. Only the index was persisted.
|
||||
const seed: Uint8Array = await walletStore.getCachedSeed()
|
||||
const {privkey} = deriveQuoteKeypair(seed, counterIndex)
|
||||
|
||||
const tx = await _findOrCreateTransaction(row, amount, unit)
|
||||
const tx = await _findOrCreateTransaction(row, mintUrl, amount, unit)
|
||||
const transactionId = tx.id
|
||||
|
||||
let proofs: CashuProof[] = []
|
||||
@@ -433,6 +470,8 @@ async function _mintAvailable(
|
||||
*/
|
||||
async function _findOrCreateTransaction(
|
||||
row: OnchainMintQuoteRecord,
|
||||
/** The mint's url NOW (resolved from row.mintId), not row.mintUrl. */
|
||||
mintUrl: string,
|
||||
amount: number,
|
||||
unit: MintUnit,
|
||||
): Promise<Transaction> {
|
||||
@@ -470,7 +509,10 @@ async function _findOrCreateTransaction(
|
||||
fee: 0,
|
||||
unit,
|
||||
data: JSON.stringify(transactionData),
|
||||
mint: row.mintUrl,
|
||||
// Where this deposit is being settled NOW. A second deposit onto an old
|
||||
// address is still a payment to the mint at its current url, and this row is
|
||||
// brand new — it has no history to preserve.
|
||||
mint: mintUrl,
|
||||
status: TransactionStatus.PENDING,
|
||||
})
|
||||
|
||||
|
||||
@@ -490,15 +490,25 @@ function _reloadPrepared(transactionId: number): PreparedReceiveData {
|
||||
if (!tx.inputToken) {
|
||||
throw new ValidationError('Transaction is missing input token', {transactionId})
|
||||
}
|
||||
const mintInstance = mintsStore.findByUrl(tx.mint)
|
||||
const token = getDecodedToken(tx.inputToken, mintInstance?.keysetIds ?? [])
|
||||
// By identity: tx.mint is where the receive was prepared and is frozen, so it
|
||||
// stops finding the mint once it moves.
|
||||
const mintInstance = mintsStore.findByTransaction(tx)
|
||||
if (!mintInstance) {
|
||||
throw new ValidationError('Transaction mint is no longer in this wallet', {
|
||||
transactionId,
|
||||
mintId: tx.mintId,
|
||||
preparedAtUrl: tx.mint,
|
||||
})
|
||||
}
|
||||
const token = getDecodedToken(tx.inputToken, mintInstance.keysetIds ?? [])
|
||||
|
||||
const isOffline = tx.status === TransactionStatus.PREPARED_OFFLINE
|
||||
|
||||
return {
|
||||
transactionId,
|
||||
tx,
|
||||
mintUrl: tx.mint,
|
||||
// The mint's url NOW — this is dialled to complete the receive.
|
||||
mintUrl: mintInstance.mintUrl,
|
||||
unit: tx.unit,
|
||||
amountToReceive: tx.amount,
|
||||
memo: tx.memo ?? '',
|
||||
|
||||
@@ -57,6 +57,26 @@ import AppError, {Err} from '../../../utils/AppError'
|
||||
|
||||
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance
|
||||
|
||||
/**
|
||||
* The live url of a transaction's mint.
|
||||
*
|
||||
* Resolved through `tx.mintId`, never `tx.mint`: the latter records where the send
|
||||
* happened and is deliberately frozen, so after a mint-url edit it points at a host
|
||||
* that no longer answers.
|
||||
*/
|
||||
function _txMintUrl(tx: Transaction): string {
|
||||
const mint = mintsStore.findByTransaction(tx)
|
||||
if (!mint) {
|
||||
throw new ValidationError('Transaction mint is no longer in this wallet', {
|
||||
transactionId: tx.id,
|
||||
mintId: tx.mintId,
|
||||
happenedAtUrl: tx.mint,
|
||||
})
|
||||
}
|
||||
return mint.mintUrl
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -598,7 +618,7 @@ async function finalize(transactionId: number): Promise<CompletedTransaction> {
|
||||
|
||||
const result = await WalletTask.syncStateWithMintQueueAwaitable({
|
||||
proofsToSync: sendProofs,
|
||||
mintUrl: tx.mint,
|
||||
mintUrl: _txMintUrl(tx),
|
||||
proofState: 'PENDING',
|
||||
})
|
||||
if (result.completedTransactionIds.includes(transactionId)) {
|
||||
@@ -631,7 +651,7 @@ async function refresh(transactionId: number): Promise<Transaction> {
|
||||
}
|
||||
await WalletTask.syncStateWithMintQueueAwaitable({
|
||||
proofsToSync: sendProofs,
|
||||
mintUrl: tx.mint,
|
||||
mintUrl: _txMintUrl(tx),
|
||||
proofState: 'PENDING',
|
||||
})
|
||||
return transactionsStore.findById(transactionId) ?? tx
|
||||
@@ -657,6 +677,7 @@ function _rowToReservation(row: ReservationRow): ProofReservation {
|
||||
return {
|
||||
id: row.id,
|
||||
transactionId: row.transactionId,
|
||||
mintId: row.mintId,
|
||||
mintUrl: row.mintUrl,
|
||||
unit: row.unit as MintUnit,
|
||||
operationType: row.operationType,
|
||||
|
||||
@@ -69,6 +69,27 @@ import {sendTopupNotification} from '../notifications'
|
||||
const {mintsStore, proofsStore, transactionsStore, walletStore, walletProfileStore} =
|
||||
rootStoreInstance
|
||||
|
||||
/**
|
||||
* The live url of a transaction's mint.
|
||||
*
|
||||
* Resolved through `tx.mintId`, never `tx.mint`: the latter records where the
|
||||
* payment happened and is deliberately frozen, so once the mint moves it points at
|
||||
* a host that no longer answers — and an open topup would poll it forever while the
|
||||
* mint holds money the wallet could have claimed.
|
||||
*/
|
||||
function _txMintUrl(tx: Transaction): string {
|
||||
const mint = mintsStore.findByTransaction(tx)
|
||||
if (!mint) {
|
||||
throw new ValidationError('Transaction mint is no longer in this wallet', {
|
||||
transactionId: tx.id,
|
||||
mintId: tx.mintId,
|
||||
happenedAtUrl: tx.mint,
|
||||
})
|
||||
}
|
||||
return mint.mintUrl
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -359,7 +380,7 @@ async function finalize(transactionId: number): Promise<CompletedTransaction> {
|
||||
throw new ValidationError('Topup has no quote id; cannot finalize.', {transactionId})
|
||||
}
|
||||
|
||||
const {state} = await walletStore.checkLightningMintQuote(tx.mint, tx.quote)
|
||||
const {state} = await walletStore.checkLightningMintQuote(_txMintUrl(tx), tx.quote)
|
||||
if (state !== MintQuoteState.PAID) {
|
||||
throw new MintError(
|
||||
`Cannot finalize topup; mint reports quote state ${state}.`,
|
||||
@@ -397,7 +418,7 @@ async function refresh(transactionId: number): Promise<Transaction> {
|
||||
return tx
|
||||
}
|
||||
|
||||
const {state} = await walletStore.checkLightningMintQuote(tx.mint, tx.quote)
|
||||
const {state} = await walletStore.checkLightningMintQuote(_txMintUrl(tx), tx.quote)
|
||||
const isExpired = !!tx.expiresAt && isBefore(tx.expiresAt, new Date())
|
||||
const paymentHash = tx.paymentId
|
||||
|
||||
@@ -470,7 +491,7 @@ async function refresh(transactionId: number): Promise<Transaction> {
|
||||
*/
|
||||
async function _finalizePaid(tx: Transaction): Promise<CompletedTransaction> {
|
||||
const transactionId = tx.id
|
||||
const mintUrl = tx.mint
|
||||
const mintUrl = _txMintUrl(tx)
|
||||
const unit = tx.unit
|
||||
const amount = tx.amount
|
||||
const mintQuote = tx.quote!
|
||||
|
||||
@@ -100,6 +100,27 @@ type AnyMeltQuote = MeltQuoteBolt11Response | MeltQuoteOnchainResponse
|
||||
|
||||
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance
|
||||
|
||||
/**
|
||||
* The live url of a transaction's mint.
|
||||
*
|
||||
* Resolved through `tx.mintId`, never `tx.mint`: the latter records where the
|
||||
* payment happened and is deliberately frozen, so after a mint-url edit it points
|
||||
* at a host that no longer answers — and an open melt would never learn whether the
|
||||
* mint had settled it.
|
||||
*/
|
||||
function _txMintUrl(tx: Transaction): string {
|
||||
const mint = mintsStore.findByTransaction(tx)
|
||||
if (!mint) {
|
||||
throw new ValidationError('Transaction mint is no longer in this wallet', {
|
||||
transactionId: tx.id,
|
||||
mintId: tx.mintId,
|
||||
happenedAtUrl: tx.mint,
|
||||
})
|
||||
}
|
||||
return mint.mintUrl
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -961,8 +982,8 @@ function _isOnchainTransfer(tx: Transaction): boolean {
|
||||
*/
|
||||
async function _checkQuote(tx: Transaction, quoteId: string): Promise<AnyMeltQuote> {
|
||||
return _isOnchainTransfer(tx)
|
||||
? await walletStore.checkOnchainMeltQuote(tx.mint, quoteId)
|
||||
: await walletStore.checkLightningMeltQuote(tx.mint, quoteId)
|
||||
? await walletStore.checkOnchainMeltQuote(_txMintUrl(tx), quoteId)
|
||||
: await walletStore.checkLightningMeltQuote(_txMintUrl(tx), quoteId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1103,7 +1124,7 @@ async function _finalizePaid(
|
||||
quote: AnyMeltQuote,
|
||||
): Promise<CompletedTransaction> {
|
||||
const transactionId = tx.id
|
||||
const mintUrl = tx.mint
|
||||
const mintUrl = _txMintUrl(tx)
|
||||
const unit = tx.unit
|
||||
const method: TransferMethod = _isOnchainTransfer(tx) ? 'onchain' : 'bolt11'
|
||||
|
||||
@@ -1340,6 +1361,7 @@ function _rowToReservation(row: ReservationRow): ProofReservation {
|
||||
return {
|
||||
id: row.id,
|
||||
transactionId: row.transactionId,
|
||||
mintId: row.mintId,
|
||||
mintUrl: row.mintUrl,
|
||||
unit: row.unit as MintUnit,
|
||||
operationType: row.operationType,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user