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