mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-08-10 16:43:19 +00:00
Merge branch 'feat/counters-sqlite-migration'
Counters → SQLite migration: move mint counters, melt counter values, and in-flight requests off MMKV/MST into SQLite with atomic write-through, startup hydration, seeding, and tests; remove the old counterBackups. NWC background optimization: process pushed commands directly (no WS re-fetch), adaptive listener lifetime, lean cold-wake hydration, async melt with lifetime-bound preimage wait, and coalesced keychain reads. Release: bump to 0.4.3-beta.8 and upgrade HotUpdater 0.23.0 → 0.32.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Derivation-counter tests (mint_counters migration).
|
||||
*
|
||||
* Verifies the SQL-level semantics that `Database.setCounter`, `bumpCounter`,
|
||||
* `seedCounters`, and the `counterUpdate` folded into `commitReservation`
|
||||
* implement on top of `executeBatch`. The counter is the BIP32 derivation
|
||||
* high-water mark; the single most important invariant is that it is MONOTONIC
|
||||
* — a stored counter can never move backward — because a regression would let
|
||||
* the next derivation reuse a blinded secret.
|
||||
*
|
||||
* As with proofReservation.test.ts we mirror the exact production SQL using
|
||||
* node:sqlite + explicit BEGIN/COMMIT, since the native driver needs a device.
|
||||
*
|
||||
* @jest-environment node
|
||||
*/
|
||||
import {DatabaseSync} from 'node:sqlite'
|
||||
|
||||
const NOW = '2026-06-04T00:00:00.000Z'
|
||||
|
||||
// ── Schema (mirrors schema.ts) ──────────────────────────────────────────────
|
||||
|
||||
const CREATE_MINT_COUNTERS = `CREATE TABLE mint_counters (
|
||||
mintUrl TEXT NOT NULL,
|
||||
keysetId TEXT NOT NULL,
|
||||
unit TEXT,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
updatedAt TEXT,
|
||||
PRIMARY KEY (mintUrl, keysetId)
|
||||
)`
|
||||
|
||||
const CREATE_PROOFS = `CREATE TABLE proofs (
|
||||
id TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
secret TEXT PRIMARY KEY NOT NULL,
|
||||
C TEXT NOT NULL,
|
||||
unit TEXT,
|
||||
tId INTEGER,
|
||||
mintUrl TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'UNSPENT',
|
||||
updatedAt TEXT
|
||||
)`
|
||||
|
||||
const CREATE_RESERVATIONS = `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
|
||||
)`
|
||||
|
||||
const MINT = 'https://mint.test'
|
||||
|
||||
// ── Mirrored Database primitives (exact production SQL) ─────────────────────
|
||||
|
||||
/** countersRepo.buildCounterUpsert / setCounter — monotonic absolute write. */
|
||||
function setCounter(db: DatabaseSync, mintUrl: string, keysetId: string, unit: string | null, value: number) {
|
||||
db.prepare(
|
||||
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET
|
||||
counter = MAX(counter, excluded.counter),
|
||||
unit = excluded.unit,
|
||||
updatedAt = excluded.updatedAt`,
|
||||
).run(mintUrl, keysetId, unit, value, NOW)
|
||||
}
|
||||
|
||||
/** countersRepo.bumpCounter — relative advance (no-op for delta <= 0). */
|
||||
function bumpCounter(db: DatabaseSync, mintUrl: string, keysetId: string, unit: string | null, delta: number) {
|
||||
if (delta <= 0) return
|
||||
db.prepare(
|
||||
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET
|
||||
counter = counter + ?,
|
||||
updatedAt = excluded.updatedAt`,
|
||||
).run(mintUrl, keysetId, unit, delta, NOW, delta)
|
||||
}
|
||||
|
||||
function getCounter(db: DatabaseSync, mintUrl: string, keysetId: string): number | undefined {
|
||||
const row = db
|
||||
.prepare('SELECT counter FROM mint_counters WHERE mintUrl = ? AND keysetId = ?')
|
||||
.get(mintUrl, keysetId) as {counter: number} | undefined
|
||||
return row?.counter
|
||||
}
|
||||
|
||||
function counterRowCount(db: DatabaseSync): number {
|
||||
const {n} = db.prepare('SELECT COUNT(*) AS n FROM mint_counters').get() as {n: number}
|
||||
return n
|
||||
}
|
||||
|
||||
/** countersRepo.seedCounters — idempotent monotonic batch. */
|
||||
function seedCounters(
|
||||
db: DatabaseSync,
|
||||
seeds: Array<{mintUrl: string; keysetId: string; unit?: string; counter: number}>,
|
||||
) {
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
for (const s of seeds) setCounter(db, s.mintUrl, s.keysetId, s.unit ?? null, s.counter)
|
||||
db.exec('COMMIT')
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function insertProof(db: DatabaseSync, secret: string, amount: number, state = 'UNSPENT') {
|
||||
db.prepare(
|
||||
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
|
||||
VALUES ('keyset1', ?, ?, 'C', '${MINT}', 'sat', 1, ?, '2026-01-01')`,
|
||||
).run(amount, secret, state)
|
||||
}
|
||||
|
||||
function getProofState(db: DatabaseSync, secret: string): string {
|
||||
const row = db.prepare('SELECT state FROM proofs WHERE secret = ?').get(secret) as
|
||||
| {state: string}
|
||||
| undefined
|
||||
return row?.state ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* commitReservation with a folded counterUpdate (the step-4 atomic commit):
|
||||
* new proofs + a monotonic counter upsert + reservation delete, all in one txn.
|
||||
*/
|
||||
function commitWithCounter(
|
||||
db: DatabaseSync,
|
||||
reservationId: string,
|
||||
changes: {
|
||||
newProofs?: Array<{secret: string; amount: number; state: string}>
|
||||
counterUpdate?: Array<{mintUrl: string; keysetId: string; unit?: string; counter: number}>
|
||||
},
|
||||
) {
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
const insertNew = db.prepare(
|
||||
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
|
||||
VALUES ('keyset1', ?, ?, 'C', '${MINT}', 'sat', 1, ?, ?)`,
|
||||
)
|
||||
for (const p of changes.newProofs ?? []) insertNew.run(p.amount, p.secret, p.state, NOW)
|
||||
|
||||
for (const cu of changes.counterUpdate ?? []) {
|
||||
setCounter(db, cu.mintUrl, cu.keysetId, cu.unit ?? null, cu.counter)
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
|
||||
db.exec('COMMIT')
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function freshDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
db.exec(CREATE_MINT_COUNTERS)
|
||||
db.exec(CREATE_PROOFS)
|
||||
db.exec(CREATE_RESERVATIONS)
|
||||
return db
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Derivation counters (mint_counters)', () => {
|
||||
describe('setCounter — monotonic', () => {
|
||||
test('inserts a new row when none exists', () => {
|
||||
const db = freshDb()
|
||||
setCounter(db, MINT, 'k1', 'sat', 42)
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(42)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('raises to a higher value', () => {
|
||||
const db = freshDb()
|
||||
setCounter(db, MINT, 'k1', 'sat', 100)
|
||||
setCounter(db, MINT, 'k1', 'sat', 150)
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(150)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => {
|
||||
const db = freshDb()
|
||||
setCounter(db, MINT, 'k1', 'sat', 100)
|
||||
setCounter(db, MINT, 'k1', 'sat', 50) // stale / replayed writer
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(100)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('an equal value is a no-op', () => {
|
||||
const db = freshDb()
|
||||
setCounter(db, MINT, 'k1', 'sat', 100)
|
||||
setCounter(db, MINT, 'k1', 'sat', 100)
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(100)
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bumpCounter — relative advance', () => {
|
||||
test('inserts from 0 when no row exists', () => {
|
||||
const db = freshDb()
|
||||
bumpCounter(db, MINT, 'k1', 'sat', 10)
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(10)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('adds to the existing value', () => {
|
||||
const db = freshDb()
|
||||
setCounter(db, MINT, 'k1', 'sat', 100)
|
||||
bumpCounter(db, MINT, 'k1', 'sat', 10)
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(110)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('a non-positive delta is a no-op', () => {
|
||||
const db = freshDb()
|
||||
setCounter(db, MINT, 'k1', 'sat', 100)
|
||||
bumpCounter(db, MINT, 'k1', 'sat', 0)
|
||||
bumpCounter(db, MINT, 'k1', 'sat', -5)
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(100)
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('primary key isolation', () => {
|
||||
test('different keysets on the same mint are independent', () => {
|
||||
const db = freshDb()
|
||||
setCounter(db, MINT, 'k1', 'sat', 100)
|
||||
setCounter(db, MINT, 'k2', 'sat', 7)
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(100)
|
||||
expect(getCounter(db, MINT, 'k2')).toBe(7)
|
||||
expect(counterRowCount(db)).toBe(2)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('the same keyset id on different mints is independent', () => {
|
||||
const db = freshDb()
|
||||
setCounter(db, MINT, 'k1', 'sat', 100)
|
||||
setCounter(db, 'https://other.test', 'k1', 'sat', 5)
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(100)
|
||||
expect(getCounter(db, 'https://other.test', 'k1')).toBe(5)
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('seedCounters — one-time MMKV→SQLite copy', () => {
|
||||
test('seeds every supplied counter', () => {
|
||||
const db = freshDb()
|
||||
seedCounters(db, [
|
||||
{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100},
|
||||
{mintUrl: MINT, keysetId: 'k2', unit: 'sat', counter: 50},
|
||||
])
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(100)
|
||||
expect(getCounter(db, MINT, 'k2')).toBe(50)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('is idempotent — re-running never lowers an advanced counter', () => {
|
||||
const db = freshDb()
|
||||
// First upgrade seed copies the (then current) MMKV values.
|
||||
seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100}])
|
||||
// Wallet advances past it during normal use.
|
||||
setCounter(db, MINT, 'k1', 'sat', 175)
|
||||
// A later launch re-runs the seed with the now-stale snapshot value.
|
||||
seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100}])
|
||||
// The advanced SQLite value wins — the seed cannot regress it.
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(175)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('a too-high seed is kept (conservative-safe: skips indices, never reuses)', () => {
|
||||
const db = freshDb()
|
||||
setCounter(db, MINT, 'k1', 'sat', 100)
|
||||
seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 9999}])
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(9999)
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('atomic commit (counterUpdate folded into commitReservation)', () => {
|
||||
test('persists the counter in the SAME txn as the new proofs', () => {
|
||||
const db = freshDb()
|
||||
setCounter(db, MINT, 'k1', 'sat', 100)
|
||||
|
||||
commitWithCounter(db, 'res-1', {
|
||||
newProofs: [{secret: 'new1', amount: 50, state: 'UNSPENT'}],
|
||||
counterUpdate: [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 110}],
|
||||
})
|
||||
|
||||
expect(getProofState(db, 'new1')).toBe('UNSPENT')
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(110)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('a failed commit batch rolls back BOTH the proofs and the counter', () => {
|
||||
const db = freshDb()
|
||||
setCounter(db, MINT, 'k1', 'sat', 100)
|
||||
|
||||
// Force a failure mid-batch (NOT NULL violation on amount) AFTER the
|
||||
// proof insert and counter upsert have run in the same transaction.
|
||||
expect(() => {
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
|
||||
VALUES ('keyset1', 50, 'new1', 'C', '${MINT}', 'sat', 1, 'UNSPENT', '${NOW}')`,
|
||||
).run()
|
||||
setCounter(db, MINT, 'k1', 'sat', 110)
|
||||
// Violates NOT NULL on amount → aborts the whole batch.
|
||||
db.prepare(
|
||||
`INSERT INTO proofs (id, amount, secret, C, state) VALUES ('keyset1', NULL, 'bad', 'C', 'UNSPENT')`,
|
||||
).run()
|
||||
db.exec('COMMIT')
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK')
|
||||
throw e
|
||||
}
|
||||
}).toThrow()
|
||||
|
||||
// Neither the proof nor the counter advance survived.
|
||||
expect(getProofState(db, 'new1')).toBe('')
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(100)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('counterUpdate stays monotonic inside the commit batch', () => {
|
||||
const db = freshDb()
|
||||
setCounter(db, MINT, 'k1', 'sat', 200)
|
||||
|
||||
// A commit carrying a stale (lower) counter must not regress it.
|
||||
commitWithCounter(db, 'res-2', {
|
||||
newProofs: [{secret: 'new2', amount: 10, state: 'UNSPENT'}],
|
||||
counterUpdate: [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 150}],
|
||||
})
|
||||
|
||||
expect(getProofState(db, 'new2')).toBe('UNSPENT')
|
||||
expect(getCounter(db, MINT, 'k1')).toBe(200)
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* In-flight request tests (inFlightRequests → SQLite migration).
|
||||
*
|
||||
* Per-transaction request params stored so an op whose mint response was lost
|
||||
* can be retried against the mint's idempotent endpoint. add() overwrites
|
||||
* (set semantics), the per-mint query drives the recovery sweep, the row is
|
||||
* deleted on success/terminal failure, and the upgrade seed is idempotent.
|
||||
*
|
||||
* Mirrors the production SQL against node:sqlite (the native driver needs a
|
||||
* device), like meltRecovery.test.ts.
|
||||
*
|
||||
* @jest-environment node
|
||||
*/
|
||||
import {DatabaseSync} from 'node:sqlite'
|
||||
|
||||
const NOW = '2026-06-05T00:00:00.000Z'
|
||||
|
||||
const CREATE_INFLIGHT = `CREATE TABLE inflight_requests (
|
||||
transactionId INTEGER PRIMARY KEY NOT NULL,
|
||||
mintUrl TEXT,
|
||||
keysetId TEXT,
|
||||
request TEXT NOT NULL,
|
||||
createdAt TEXT
|
||||
)`
|
||||
|
||||
const MINT = 'https://mint.test'
|
||||
|
||||
// ── Mirrored repo primitives (exact production SQL) ─────────────────────────
|
||||
|
||||
function addInFlightRequest(
|
||||
db: DatabaseSync,
|
||||
transactionId: number,
|
||||
mintUrl: string | null,
|
||||
keysetId: string | null,
|
||||
request: object,
|
||||
) {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW)
|
||||
}
|
||||
|
||||
function getInFlightRequest(db: DatabaseSync, transactionId: number) {
|
||||
const row = db
|
||||
.prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE transactionId = ?`)
|
||||
.get(transactionId) as {transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null} | undefined
|
||||
if (!row) return undefined
|
||||
return {...row, request: JSON.parse(row.request)}
|
||||
}
|
||||
|
||||
function getInFlightRequestsByMint(db: DatabaseSync, mintUrl: string) {
|
||||
const rows = db
|
||||
.prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE mintUrl = ?`)
|
||||
.all(mintUrl) as Array<{transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null}>
|
||||
return rows.map(r => ({...r, request: JSON.parse(r.request)}))
|
||||
}
|
||||
|
||||
function removeInFlightRequest(db: DatabaseSync, transactionId: number) {
|
||||
db.prepare(`DELETE FROM inflight_requests WHERE transactionId = ?`).run(transactionId)
|
||||
}
|
||||
|
||||
function seedInFlightRequest(
|
||||
db: DatabaseSync,
|
||||
transactionId: number,
|
||||
mintUrl: string | null,
|
||||
keysetId: string | null,
|
||||
request: object,
|
||||
) {
|
||||
db.prepare(
|
||||
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(transactionId) DO NOTHING`,
|
||||
).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW)
|
||||
}
|
||||
|
||||
function freshDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
db.exec(CREATE_INFLIGHT)
|
||||
return db
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('In-flight requests (inflight_requests)', () => {
|
||||
test('stores and reads back a request (JSON round-trip)', () => {
|
||||
const db = freshDb()
|
||||
const request = {token: 'cashuA...', options: {keysetId: 'k1'}}
|
||||
|
||||
addInFlightRequest(db, 101, MINT, 'k1', request)
|
||||
const rec = getInFlightRequest(db, 101)!
|
||||
|
||||
expect(rec.transactionId).toBe(101)
|
||||
expect(rec.mintUrl).toBe(MINT)
|
||||
expect(rec.keysetId).toBe('k1')
|
||||
expect(rec.request).toEqual(request)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('returns undefined when no entry exists', () => {
|
||||
const db = freshDb()
|
||||
expect(getInFlightRequest(db, 999)).toBeUndefined()
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('add OVERWRITES an existing entry (set semantics)', () => {
|
||||
const db = freshDb()
|
||||
addInFlightRequest(db, 101, MINT, 'k1', {v: 'first'})
|
||||
addInFlightRequest(db, 101, MINT, 'k1', {v: 'second'})
|
||||
|
||||
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'second'})
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('getInFlightRequestsByMint returns all rows for a mint', () => {
|
||||
const db = freshDb()
|
||||
addInFlightRequest(db, 101, MINT, 'k1', {v: 1})
|
||||
addInFlightRequest(db, 102, MINT, 'k1', {v: 2})
|
||||
addInFlightRequest(db, 103, 'https://other.test', 'k9', {v: 3})
|
||||
|
||||
const forMint = getInFlightRequestsByMint(db, MINT)
|
||||
expect(forMint.map(r => r.transactionId).sort()).toEqual([101, 102])
|
||||
expect(getInFlightRequestsByMint(db, 'https://other.test')).toHaveLength(1)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('remove deletes the entry', () => {
|
||||
const db = freshDb()
|
||||
addInFlightRequest(db, 101, MINT, 'k1', {v: 1})
|
||||
removeInFlightRequest(db, 101)
|
||||
expect(getInFlightRequest(db, 101)).toBeUndefined()
|
||||
expect(getInFlightRequestsByMint(db, MINT)).toHaveLength(0)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('seed is idempotent — does not overwrite an existing entry', () => {
|
||||
const db = freshDb()
|
||||
addInFlightRequest(db, 101, MINT, 'k1', {v: 'live'})
|
||||
seedInFlightRequest(db, 101, MINT, 'k1', {v: 'snapshot'})
|
||||
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'live'})
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Melt recovery tests (meltCounterValues → SQLite migration).
|
||||
*
|
||||
* Verifies the SQL-level semantics of meltRecoveryRepo: a per-transaction
|
||||
* serialized meltPreview is stored before a melt is submitted so a paid-but-
|
||||
* unconfirmed melt can be recovered and its change unblinded. The first stored
|
||||
* preview for a transaction wins (idempotent), and the row is removed on
|
||||
* terminal success/failure.
|
||||
*
|
||||
* Mirrors the production SQL against node:sqlite, like proofReservation.test.ts
|
||||
* and counters.test.ts (the native driver needs a device).
|
||||
*
|
||||
* @jest-environment node
|
||||
*/
|
||||
import {DatabaseSync} from 'node:sqlite'
|
||||
|
||||
const NOW = '2026-06-05T00:00:00.000Z'
|
||||
|
||||
const CREATE_MELT_RECOVERY = `CREATE TABLE melt_recovery (
|
||||
transactionId INTEGER PRIMARY KEY NOT NULL,
|
||||
mintUrl TEXT,
|
||||
keysetId TEXT,
|
||||
meltPreview TEXT NOT NULL,
|
||||
createdAt TEXT
|
||||
)`
|
||||
|
||||
const MINT = 'https://mint.test'
|
||||
|
||||
// A representative StoredMeltPreview (shape from cashuUtils).
|
||||
const previewFor = (keysetId: string, secret = 'aa') => ({
|
||||
keysetId,
|
||||
outputData: [
|
||||
{
|
||||
blindedMessage: {amount: '2', id: keysetId, B_: 'B_' + secret},
|
||||
blindingFactor: 'deadbeef',
|
||||
secret,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// ── Mirrored repo primitives (exact production SQL) ─────────────────────────
|
||||
|
||||
function addMeltRecovery(
|
||||
db: DatabaseSync,
|
||||
transactionId: number,
|
||||
mintUrl: string | null,
|
||||
keysetId: string | null,
|
||||
meltPreview: object,
|
||||
) {
|
||||
db.prepare(
|
||||
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(transactionId) DO NOTHING`,
|
||||
).run(transactionId, mintUrl, keysetId, JSON.stringify(meltPreview), NOW)
|
||||
}
|
||||
|
||||
function getMeltRecovery(db: DatabaseSync, transactionId: number) {
|
||||
const row = db
|
||||
.prepare(`SELECT transactionId, mintUrl, keysetId, meltPreview, createdAt FROM melt_recovery WHERE transactionId = ?`)
|
||||
.get(transactionId) as
|
||||
| {transactionId: number; mintUrl: string | null; keysetId: string | null; meltPreview: string; createdAt: string | null}
|
||||
| undefined
|
||||
if (!row) return undefined
|
||||
return {...row, meltPreview: JSON.parse(row.meltPreview)}
|
||||
}
|
||||
|
||||
function removeMeltRecovery(db: DatabaseSync, transactionId: number) {
|
||||
db.prepare(`DELETE FROM melt_recovery WHERE transactionId = ?`).run(transactionId)
|
||||
}
|
||||
|
||||
function rowCount(db: DatabaseSync): number {
|
||||
const {n} = db.prepare('SELECT COUNT(*) AS n FROM melt_recovery').get() as {n: number}
|
||||
return n
|
||||
}
|
||||
|
||||
function freshDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
db.exec(CREATE_MELT_RECOVERY)
|
||||
return db
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Melt recovery (melt_recovery)', () => {
|
||||
test('stores and reads back a meltPreview (JSON round-trip)', () => {
|
||||
const db = freshDb()
|
||||
const preview = previewFor('k1')
|
||||
|
||||
addMeltRecovery(db, 101, MINT, 'k1', preview)
|
||||
const rec = getMeltRecovery(db, 101)!
|
||||
|
||||
expect(rec.transactionId).toBe(101)
|
||||
expect(rec.mintUrl).toBe(MINT)
|
||||
expect(rec.keysetId).toBe('k1')
|
||||
expect(rec.meltPreview).toEqual(preview)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('returns undefined when no entry exists', () => {
|
||||
const db = freshDb()
|
||||
expect(getMeltRecovery(db, 999)).toBeUndefined()
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('the FIRST stored preview wins (ON CONFLICT DO NOTHING)', () => {
|
||||
const db = freshDb()
|
||||
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'first'))
|
||||
// A second attempt for the same tx must not overwrite.
|
||||
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'second'))
|
||||
|
||||
const rec = getMeltRecovery(db, 101)!
|
||||
expect(rec.meltPreview.outputData[0].secret).toBe('first')
|
||||
expect(rowCount(db)).toBe(1)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('remove deletes the entry (terminal success/failure)', () => {
|
||||
const db = freshDb()
|
||||
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1'))
|
||||
expect(rowCount(db)).toBe(1)
|
||||
|
||||
removeMeltRecovery(db, 101)
|
||||
expect(getMeltRecovery(db, 101)).toBeUndefined()
|
||||
expect(rowCount(db)).toBe(0)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('entries for different transactions are independent', () => {
|
||||
const db = freshDb()
|
||||
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1'))
|
||||
addMeltRecovery(db, 102, MINT, 'k2', previewFor('k2'))
|
||||
|
||||
expect(getMeltRecovery(db, 101)!.keysetId).toBe('k1')
|
||||
expect(getMeltRecovery(db, 102)!.keysetId).toBe('k2')
|
||||
|
||||
removeMeltRecovery(db, 101)
|
||||
expect(getMeltRecovery(db, 101)).toBeUndefined()
|
||||
expect(getMeltRecovery(db, 102)!.keysetId).toBe('k2') // unaffected
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('seed is idempotent — does not overwrite an existing entry', () => {
|
||||
const db = freshDb()
|
||||
// Live entry already advanced/stored.
|
||||
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'live'))
|
||||
// Upgrade seed re-runs with the snapshot copy.
|
||||
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'snapshot'))
|
||||
|
||||
expect(getMeltRecovery(db, 101)!.meltPreview.outputData[0].secret).toBe('live')
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import 'message-port-polyfill' // nostr-tools
|
||||
import notifee from '@notifee/react-native'
|
||||
import messaging from '@react-native-firebase/messaging'
|
||||
import {AppRegistry} from 'react-native'
|
||||
import { rootStoreInstance, setupRootStore } from './src/models'
|
||||
import { rootStoreInstance } from './src/models'
|
||||
import {
|
||||
WalletTask,
|
||||
NotificationService,
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
SWAP_DENOMINATION_TASK,
|
||||
TEST_TASK,
|
||||
SYNC_STATE_WITH_ALL_MINTS_TASK,
|
||||
HANDLE_NWC_REQUEST_TASK
|
||||
} from './src/services'
|
||||
import {
|
||||
LISTEN_FOR_NWC_EVENTS
|
||||
@@ -33,25 +32,14 @@ notifee.registerForegroundService(async (notification) => {
|
||||
return new Promise(async (resolve) => {
|
||||
log.trace('[registerForegroundService] Foreground service starting for task:', notification.data.task)
|
||||
|
||||
try {
|
||||
try {
|
||||
|
||||
// NWC command received by push notification
|
||||
if(notification.data.task === HANDLE_NWC_REQUEST_TASK) {
|
||||
log.info(`[registerForegroundService] Submitting task ${HANDLE_NWC_REQUEST_TASK} to the queue.`)
|
||||
|
||||
const {nwcStore} = rootStoreInstance
|
||||
// if an app is in killed state, state is not loaded
|
||||
if(nwcStore.all.length === 0) {
|
||||
await setupRootStore(rootStoreInstance)
|
||||
}
|
||||
|
||||
WalletTask.handleNwcRequestQueue({requestEvent: notification.data.data})
|
||||
}
|
||||
|
||||
// Listen for NWC commands from minibits relay over ws if push notifications are not enabled
|
||||
// Listen for NWC commands over ws (started by the push handler AFTER it has
|
||||
// processed the pushed command directly; this catches any follow-ups that
|
||||
// arrive in the window without their own push).
|
||||
if(notification.data.task === LISTEN_FOR_NWC_EVENTS) {
|
||||
const {nwcStore} = rootStoreInstance
|
||||
nwcStore.listenForNwcEvents()
|
||||
nwcStore.listenForNwcEvents()
|
||||
}
|
||||
|
||||
if(notification.data.task === SYNC_STATE_WITH_ALL_MINTS_TASK) {
|
||||
|
||||
+5
-52
@@ -1,5 +1,4 @@
|
||||
PODS:
|
||||
- BitByteData (2.0.4)
|
||||
- boost (1.84.0)
|
||||
- DoubleConversion (1.1.6)
|
||||
- fast_float (8.0.0)
|
||||
@@ -63,7 +62,7 @@ PODS:
|
||||
- hermes-engine (250829098.0.7):
|
||||
- hermes-engine/Pre-built (= 250829098.0.7)
|
||||
- hermes-engine/Pre-built (250829098.0.7)
|
||||
- HotUpdater (0.23.0):
|
||||
- HotUpdater (0.32.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -83,7 +82,6 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- SWCompression (~> 4.8.0)
|
||||
- Yoga
|
||||
- libwebp (1.5.0):
|
||||
- libwebp/demux (= 1.5.0)
|
||||
@@ -123,7 +121,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- OpenSSL-Universal (3.3.3001)
|
||||
- OpenSSL-Universal (3.6.2000)
|
||||
- PromisesObjC (2.4.0)
|
||||
- RCT-Folly (2024.11.18.00):
|
||||
- boost
|
||||
@@ -2377,47 +2375,6 @@ PODS:
|
||||
- libwebp (~> 1.0)
|
||||
- SDWebImage/Core (~> 5.10)
|
||||
- Sentry/HybridSDK (8.57.3)
|
||||
- SWCompression (4.8.6):
|
||||
- BitByteData (~> 2.0)
|
||||
- SWCompression/BZip2 (= 4.8.6)
|
||||
- SWCompression/Deflate (= 4.8.6)
|
||||
- SWCompression/GZip (= 4.8.6)
|
||||
- SWCompression/LZ4 (= 4.8.6)
|
||||
- SWCompression/LZMA (= 4.8.6)
|
||||
- SWCompression/LZMA2 (= 4.8.6)
|
||||
- SWCompression/SevenZip (= 4.8.6)
|
||||
- SWCompression/TAR (= 4.8.6)
|
||||
- SWCompression/XZ (= 4.8.6)
|
||||
- SWCompression/ZIP (= 4.8.6)
|
||||
- SWCompression/Zlib (= 4.8.6)
|
||||
- SWCompression/BZip2 (4.8.6):
|
||||
- BitByteData (~> 2.0)
|
||||
- SWCompression/Deflate (4.8.6):
|
||||
- BitByteData (~> 2.0)
|
||||
- SWCompression/GZip (4.8.6):
|
||||
- BitByteData (~> 2.0)
|
||||
- SWCompression/Deflate
|
||||
- SWCompression/LZ4 (4.8.6):
|
||||
- BitByteData (~> 2.0)
|
||||
- SWCompression/LZMA (4.8.6):
|
||||
- BitByteData (~> 2.0)
|
||||
- SWCompression/LZMA2 (4.8.6):
|
||||
- BitByteData (~> 2.0)
|
||||
- SWCompression/LZMA
|
||||
- SWCompression/SevenZip (4.8.6):
|
||||
- BitByteData (~> 2.0)
|
||||
- SWCompression/LZMA2
|
||||
- SWCompression/TAR (4.8.6):
|
||||
- BitByteData (~> 2.0)
|
||||
- SWCompression/XZ (4.8.6):
|
||||
- BitByteData (~> 2.0)
|
||||
- SWCompression/LZMA2
|
||||
- SWCompression/ZIP (4.8.6):
|
||||
- BitByteData (~> 2.0)
|
||||
- SWCompression/Deflate
|
||||
- SWCompression/Zlib (4.8.6):
|
||||
- BitByteData (~> 2.0)
|
||||
- SWCompression/Deflate
|
||||
- SwiftUIIntrospect (1.3.0)
|
||||
- Yoga (0.0.0)
|
||||
|
||||
@@ -2529,7 +2486,6 @@ DEPENDENCIES:
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- BitByteData
|
||||
- Firebase
|
||||
- FirebaseCore
|
||||
- FirebaseCoreExtension
|
||||
@@ -2546,7 +2502,6 @@ SPEC REPOS:
|
||||
- SDWebImage
|
||||
- SDWebImageWebPCoder
|
||||
- Sentry
|
||||
- SWCompression
|
||||
- SwiftUIIntrospect
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
@@ -2759,7 +2714,6 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
BitByteData: 2422ed269fcddc44976d97f171a183d25ef535ce
|
||||
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
||||
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
||||
fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6
|
||||
@@ -2771,15 +2725,15 @@ SPEC CHECKSUMS:
|
||||
FirebaseInstallations: ae9f4902cb5bf1d0c5eaa31ec1f4e5495a0714e2
|
||||
FirebaseMessaging: d33971b7bb252745ea6cd31ab190d1a1df4b8ed5
|
||||
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
|
||||
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
|
||||
glog: e56ede4028c4b7418e6b1195a36b1656bb35e225
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
hermes-engine: 58df1e0c617059234b43c78bc3c7a9c99c8533b5
|
||||
HotUpdater: 9f8e5c8a20f82a7579e23d2015586aac6848f666
|
||||
HotUpdater: aff72491bb3d00fe199c429170549cee1b56ef9f
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
op-sqlite: 6426d38c0642bfcd4a35c9db7430ee774ca4ff9e
|
||||
OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2
|
||||
OpenSSL-Universal: ecee7b138fa75a74ecf00d7ffd248fb584739b9e
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
RCT-Folly: 7449e39d08a22c7cfc6bbfc9f140168c61c5869d
|
||||
RCTDeprecation: 8ae59687fd548d481aa5ce8014ac7cd9b47e7316
|
||||
@@ -2878,7 +2832,6 @@ SPEC CHECKSUMS:
|
||||
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
|
||||
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
|
||||
Sentry: c643eb180df401dd8c734c5036ddd9dd9218daa6
|
||||
SWCompression: 9379873ad5e9f25b31db88c441649b59a4c2bab7
|
||||
SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d
|
||||
Yoga: e72e7f14bfa5278868f138a38b3186ce2e063bbf
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -31,7 +31,7 @@
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minibits_wallet",
|
||||
"version": "0.4.3-beta.6",
|
||||
"version": "0.4.3-beta.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android:clean": "cd android && ./gradlew clean",
|
||||
|
||||
@@ -138,7 +138,7 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
|
||||
|
||||
// keep internal state in sync with external `value`
|
||||
useEffect(() => {
|
||||
log.trace(`[useEffect] setTopValue call`, value)
|
||||
log.trace(`[AmountInput.useEffect:mount] setTopValue`, value)
|
||||
setTopValue(value)
|
||||
|
||||
// only show if
|
||||
@@ -153,7 +153,7 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
|
||||
setIsConvertedValueVisible(!!canShow)
|
||||
|
||||
if (canShow) {
|
||||
log.trace(`[useEffect] recalcBottom call`, value)
|
||||
log.trace(`[AmountInput.useEffect:mount] recalcBottom`, value)
|
||||
setBottomValue(recalcBottom(value)) // ✅ always compute bottom from current top
|
||||
} else {
|
||||
setBottomValue("0")
|
||||
@@ -161,7 +161,7 @@ export const AmountInput = forwardRef<TextInput, AmountInputProps>(
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
log.trace(`[useEffect] setTopValue call`, value)
|
||||
log.trace(`[AmountInput.useEffect:value] setTopValue`, value)
|
||||
setTopValue(value)
|
||||
if(!hasBeenFirstTimeConverted && value && toNumber(value) > 0) {
|
||||
setBottomValue(recalcBottom(value))
|
||||
|
||||
@@ -144,7 +144,7 @@ export const AuthStoreModel = types
|
||||
|
||||
log.trace('[loadTokensFromKeyChain] Tokens loaded successfully')
|
||||
} else {
|
||||
log.warn('[loadTokensFromKeyChain] No tokens found in the KeyChain')
|
||||
log.debug('[loadTokensFromKeyChain] No tokens found in the KeyChain')
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error(`Failed to load tokens: ${e.message}`, {caller: 'loadTokensFromKeyChain'})
|
||||
|
||||
+78
-205
@@ -5,24 +5,16 @@ import {
|
||||
type MintKeys as CashuMintKeys,
|
||||
type MintKeyset as CashuMintKeyset,
|
||||
Mint as CashuMint,
|
||||
type MeltPreview,
|
||||
} from '@cashu/cashu-ts'
|
||||
import {colors, getRandomIconColor} from '../theme'
|
||||
import { log } from '../services'
|
||||
import { log, Database } from '../services'
|
||||
|
||||
import AppError, { Err } from '../utils/AppError'
|
||||
import { MintUnit, MintUnits } from '../services/wallet/currency'
|
||||
import { getRootStore } from './helpers/getRootStore'
|
||||
import { generateId } from '../utils/utils'
|
||||
import { Proof } from './Proof'
|
||||
import { CashuProof, CashuUtils, StoredMeltPreview } from '../services/cashu/cashuUtils'
|
||||
|
||||
function serializeMeltPreview(meltPreview: MeltPreview): StoredMeltPreview {
|
||||
return {
|
||||
keysetId: meltPreview.keysetId,
|
||||
outputData: CashuUtils.serializeOutputData(meltPreview.outputData),
|
||||
}
|
||||
}
|
||||
import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils'
|
||||
|
||||
export type MintBalance = {
|
||||
mintUrl: string
|
||||
@@ -51,205 +43,113 @@ export type InFlightRequest<TRequest = any> = {
|
||||
request: TRequest
|
||||
}
|
||||
|
||||
const InFlightRequestModel = types.model('InFlightRequest', {
|
||||
transactionId: types.number,
|
||||
request: types.frozen<any>(), // or replace `any` with your actual request type
|
||||
})
|
||||
|
||||
// Sub-model for melt previews (v3.x uses MeltPreview instead of just counter)
|
||||
const MeltCounterValueModel = types.model('MeltCounterValue', {
|
||||
transactionId: types.number,
|
||||
counterAtMelt: types.number, // the counter value when melt started (kept for backward compatibility)
|
||||
meltPreview: types.maybe(types.frozen<StoredMeltPreview>()),
|
||||
createdAt: types.optional(types.Date, () => new Date()), // optional: when it was added
|
||||
})
|
||||
|
||||
// === Migration function ===
|
||||
// inFlightRequests and meltCounterValues moved to SQLite (inflight_requests /
|
||||
// melt_recovery tables). Strip both from any old snapshot so applySnapshot does
|
||||
// not choke on the removed fields. A MintProofsCounter snapshot is now just
|
||||
// {keyset, unit, counter}.
|
||||
const migrateSnapshot = (snapshot: any): any => {
|
||||
if (!snapshot) return snapshot
|
||||
|
||||
// 1. Convert old inFlightRequests array → map (if needed)
|
||||
if (Array.isArray(snapshot.inFlightRequests)) {
|
||||
const oldArray = snapshot.inFlightRequests as Array<{ transactionId: number; request: any }>
|
||||
const newMap: Record<string, any> = {}
|
||||
|
||||
oldArray.forEach(item => {
|
||||
if (item && typeof item.transactionId === 'number') {
|
||||
newMap[item.transactionId.toString()] = {
|
||||
transactionId: item.transactionId,
|
||||
request: item.request ?? null,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
inFlightRequests: newMap,
|
||||
}
|
||||
} else if (snapshot.inFlightRequests == null) {
|
||||
// Ensure it's an object (empty map)
|
||||
snapshot = { ...snapshot, inFlightRequests: {} }
|
||||
}
|
||||
|
||||
// 2. Add missing meltCounterValues map (new in v2+)
|
||||
if (snapshot.meltCounterValues === undefined) {
|
||||
snapshot = { ...snapshot, meltCounterValues: {} }
|
||||
}
|
||||
|
||||
return snapshot
|
||||
const {inFlightRequests, meltCounterValues, ...rest} = snapshot
|
||||
return rest
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Write a counter mutation through to the SQLite authority — the PRIMARY
|
||||
* counter persistence ("W1").
|
||||
*
|
||||
* `mode: 'set'` persists an absolute value monotonically (never lowers);
|
||||
* `mode: 'bump'` advances by a relative delta. The (mint, keyset) identity is
|
||||
* read from the parent Mint node — a counter is always nested two levels up
|
||||
* (counter -> proofsCounters array -> Mint).
|
||||
*
|
||||
* This fires the instant cashu derives (right after onCountersReserved, BEFORE
|
||||
* the reservation commit), so the advance is durable the moment the mint could
|
||||
* have seen those outputs — covering a crash before commit AND an explicit
|
||||
* rollback. Those indices are consumed at the mint and must never be reused even
|
||||
* if the operation aborts, which is exactly why rollback does NOT rewind the
|
||||
* counter.
|
||||
*
|
||||
* Errors are logged (→ Sentry in prod) but never rethrown: a counter write must
|
||||
* not break a wallet flow, and there is a complementary safety net —
|
||||
* commitReservation re-persists this same value ATOMICALLY with the proofs
|
||||
* ("W2", see reservationsRepo), so even if this write is dropped a successful
|
||||
* commit cannot leave the counter behind its proofs. A detached instance (a
|
||||
* counter freshly created by createProofsCounter, not yet pushed onto a Mint)
|
||||
* has no parent and is a no-op here.
|
||||
*/
|
||||
const persistCounter = (self: any, mode: 'set' | 'bump', value: number): void => {
|
||||
let mintUrl: string | undefined
|
||||
try {
|
||||
mintUrl = getParent<any>(self, 2)?.mintUrl
|
||||
} catch {
|
||||
return // not attached to a Mint (freshly created counter) — nothing to persist
|
||||
}
|
||||
if (!mintUrl) return
|
||||
|
||||
try {
|
||||
if (mode === 'set') {
|
||||
Database.setCounter(mintUrl, self.keyset, self.unit, value)
|
||||
} else {
|
||||
Database.bumpCounter(mintUrl, self.keyset, self.unit, value)
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error('[persistCounter]', 'Counter write-through failed', {
|
||||
error: e?.message,
|
||||
mintUrl,
|
||||
keyset: self.keyset,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const MintProofsCounterModel = types
|
||||
.model('MintProofsCounter', {
|
||||
keyset: types.string,
|
||||
unit: types.optional(types.frozen<MintUnit>(), 'sat'),
|
||||
counter: types.optional(types.number, 0),
|
||||
|
||||
// In-flight mint requests
|
||||
inFlightRequests: types.map(InFlightRequestModel),
|
||||
|
||||
// Melt transactions that have started (counter value frozen at start)
|
||||
meltCounterValues: types.map(MeltCounterValueModel),
|
||||
})
|
||||
.preProcessSnapshot(migrateSnapshot)
|
||||
.actions(self => ({
|
||||
// === In-flight mint requests (unchanged) ===
|
||||
addInFlightRequest(transactionId: number, request: any) {
|
||||
self.inFlightRequests.set(transactionId.toString(), {
|
||||
transactionId,
|
||||
request,
|
||||
})
|
||||
log.trace('[addInFlightRequest]', { transactionId, request })
|
||||
},
|
||||
|
||||
removeInFlightRequest(transactionId: number) {
|
||||
if (!isAlive(self)) {
|
||||
log.error('[removeInFlightRequest]', 'ProofsCounter is not alive')
|
||||
return
|
||||
}
|
||||
const key = transactionId.toString()
|
||||
if (self.inFlightRequests.has(key)) {
|
||||
self.inFlightRequests.delete(key)
|
||||
log.trace('[removeInFlightRequest]', { transactionId })
|
||||
}
|
||||
},
|
||||
|
||||
clearAllInFlightRequests() {
|
||||
if (!isAlive(self)) {
|
||||
log.error('[clearAllInFlightRequests]', 'ProofsCounter is not alive')
|
||||
return
|
||||
}
|
||||
const count = self.inFlightRequests.size
|
||||
if (count > 0) {
|
||||
self.inFlightRequests.clear()
|
||||
log.info('[clearAllInFlightRequests]', `Cleared ${count} in-flight request(s)`)
|
||||
}
|
||||
},
|
||||
|
||||
// === Melt counter tracking ===
|
||||
addMeltCounterValue(transactionId: number, meltPreview?: MeltPreview): number {
|
||||
const key = transactionId.toString()
|
||||
if (self.meltCounterValues.has(key)) {
|
||||
log.warn('[addMeltCounterValue]', 'Melt already tracked', { transactionId })
|
||||
return self.meltCounterValues.get(key)!.counterAtMelt
|
||||
}
|
||||
|
||||
self.meltCounterValues.set(key, {
|
||||
transactionId,
|
||||
counterAtMelt: self.counter,
|
||||
meltPreview: meltPreview ? serializeMeltPreview(meltPreview) : undefined,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
log.trace('[addMeltCounterValue]', {
|
||||
transactionId,
|
||||
counterAtMelt: self.counter,
|
||||
hasMeltPreview: !!meltPreview,
|
||||
})
|
||||
|
||||
return self.counter
|
||||
},
|
||||
|
||||
removeMeltCounterValue(transactionId: number) {
|
||||
if (!isAlive(self)) {
|
||||
log.error('[removeMeltCounterValue]', 'ProofsCounter is not alive')
|
||||
return
|
||||
}
|
||||
|
||||
const key = transactionId.toString()
|
||||
if (self.meltCounterValues.has(key)) {
|
||||
self.meltCounterValues.delete(key)
|
||||
log.trace('[removeMeltCounterValue]', { transactionId })
|
||||
}
|
||||
},
|
||||
|
||||
clearAllMeltCounterValues() {
|
||||
if (!isAlive(self)) {
|
||||
log.error('[clearAllMeltCounterValues]', 'ProofsCounter is not alive')
|
||||
return
|
||||
}
|
||||
|
||||
const count = self.meltCounterValues.size
|
||||
if (count > 0) {
|
||||
self.meltCounterValues.clear()
|
||||
log.info('[clearAllMeltCounterValues]', `Cleared ${count} melt tracking entries`)
|
||||
}
|
||||
},
|
||||
|
||||
// === Counter mutations (unchanged) ===
|
||||
// === Counter mutations (write through to the SQLite authority) ===
|
||||
increaseProofsCounter(numberOfProofs: number) {
|
||||
self.counter += numberOfProofs
|
||||
persistCounter(self, 'bump', numberOfProofs)
|
||||
log.info('[increaseProofsCounter]', 'Increased proofsCounter', {
|
||||
numberOfProofs,
|
||||
counter: self.counter,
|
||||
})
|
||||
},
|
||||
|
||||
decreaseProofsCounter(numberOfProofs: number) {
|
||||
self.counter = Math.max(0, self.counter - numberOfProofs)
|
||||
log.trace('[decreaseProofsCounter]', 'Decreased proofsCounter', {
|
||||
numberOfProofs,
|
||||
counter: self.counter,
|
||||
})
|
||||
},
|
||||
|
||||
setProofsCounter(newCounter: number) {
|
||||
self.counter = newCounter
|
||||
persistCounter(self, 'set', newCounter)
|
||||
log.debug('[setProofsCounter]', 'Set proofsCounter', {
|
||||
counter: self.counter,
|
||||
})
|
||||
},
|
||||
}))
|
||||
.views(self => ({
|
||||
// === In-flight requests ===
|
||||
inFlightRequestExists(transactionId: number): boolean {
|
||||
return self.inFlightRequests.has(transactionId.toString())
|
||||
},
|
||||
getInFlightRequest(transactionId: number): InFlightRequest | undefined {
|
||||
return self.inFlightRequests.get(transactionId.toString())
|
||||
},
|
||||
get inFlightRequestCount(): number {
|
||||
return self.inFlightRequests.size
|
||||
},
|
||||
get allInFlightRequests(): Instance<typeof InFlightRequestModel>[] {
|
||||
return Array.from(self.inFlightRequests.values())
|
||||
},
|
||||
|
||||
// === Melt counter values ===
|
||||
meltCounterValueExists(transactionId: number): boolean {
|
||||
return self.meltCounterValues.has(transactionId.toString())
|
||||
},
|
||||
getMeltCounterValue(transactionId: number): Instance<typeof MeltCounterValueModel> | undefined {
|
||||
return self.meltCounterValues.get(transactionId.toString())
|
||||
},
|
||||
get meltCounterValueCount(): number {
|
||||
return self.meltCounterValues.size
|
||||
},
|
||||
get allMeltCounterValues(): Instance<typeof MeltCounterValueModel>[] {
|
||||
return Array.from(self.meltCounterValues.values())
|
||||
/**
|
||||
* Load the authoritative value from SQLite into the in-memory cache on
|
||||
* startup/resume. Monotonic (only raises) and does NOT write back, so it
|
||||
* can't loop with the write-through above.
|
||||
*/
|
||||
hydrateCounterFromDb(value: number) {
|
||||
if (value > self.counter) {
|
||||
self.counter = value
|
||||
}
|
||||
},
|
||||
}))
|
||||
// The derivation counter is mastered in SQLite (mint_counters), hydrated
|
||||
// into this model as an in-memory cache on startup/resume. Strip it from
|
||||
// every persisted snapshot so the MMKV whole-tree save can never write a
|
||||
// stale value back over the SQLite authority — exactly as ProofsStore strips
|
||||
// `proofs`. Consumers that legitimately need the value (backup export,
|
||||
// counter backups) re-inject it from the live model / SQLite.
|
||||
.postProcessSnapshot(snapshot => ({
|
||||
...snapshot,
|
||||
counter: 0,
|
||||
}))
|
||||
|
||||
export type MintProofsCounter = Instance<typeof MintProofsCounterModel>
|
||||
|
||||
@@ -608,35 +508,8 @@ export const MintModel = types
|
||||
log.trace('[getMintFeeReserve]', {feeReserve})
|
||||
return feeReserve
|
||||
},
|
||||
removeAllInFlightRequests() {
|
||||
log.trace('[removeAllInFlightRequests] Removing all inFlight requests', {mintUrl: self.mintUrl})
|
||||
for(const counter of self.proofsCounters) {
|
||||
counter.clearAllInFlightRequests()
|
||||
}
|
||||
},
|
||||
}))
|
||||
.views(self => ({
|
||||
findInFlightRequestByTId: (transactionId: number) => {
|
||||
const inFlightCounters = self.proofsCounters.filter(c => c.inFlightRequests && c.inFlightRequests.size > 0)
|
||||
let inFlightRequest: InFlightRequest | undefined = undefined
|
||||
|
||||
for (const counter of inFlightCounters) {
|
||||
const request = counter.getInFlightRequest(transactionId)
|
||||
if(request) {
|
||||
inFlightRequest = request
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return inFlightRequest
|
||||
},
|
||||
get proofsCountersWithInFlightRequests() {
|
||||
const counters = self.proofsCounters.filter(c => c.inFlightRequests && c.inFlightRequests.size > 0)
|
||||
return counters || []
|
||||
},
|
||||
get allInFlightRequests() {
|
||||
return self.proofsCounters.flatMap((counter) => counter.allInFlightRequests)
|
||||
},
|
||||
get balances(): MintBalance | undefined {
|
||||
const mintBalance: MintBalance | undefined = getRootStore(self).proofsStore.getMintBalance(self.mintUrl)
|
||||
return mintBalance
|
||||
|
||||
+65
-59
@@ -5,12 +5,13 @@ import {
|
||||
destroy,
|
||||
isStateTreeNode,
|
||||
detach,
|
||||
flow,
|
||||
getSnapshot,
|
||||
flow,
|
||||
} from 'mobx-state-tree'
|
||||
import {withSetPropAction} from './helpers/withSetPropAction'
|
||||
import {MintModel, Mint, MintProofsCounter, MintProofsCounterModel} from './Mint'
|
||||
import {log} from '../services/logService'
|
||||
import {MintModel, Mint} from './Mint'
|
||||
import {log} from '../services/logService'
|
||||
import {Database} from '../services'
|
||||
import type {CounterSeed} from '../services/db'
|
||||
import AppError, { Err } from '../utils/AppError'
|
||||
import {
|
||||
Mint as CashuMint,
|
||||
@@ -32,22 +33,20 @@ export type MintsByUnit = {
|
||||
mints: Mint[]
|
||||
}
|
||||
|
||||
export type CounterBackup = {
|
||||
mintUrl: string
|
||||
proofCounters: MintProofsCounter
|
||||
}
|
||||
|
||||
// Define the CounterBackup model
|
||||
const CounterBackupModel = types.model('CounterBackup', {
|
||||
mintUrl: types.string,
|
||||
counters: types.array(MintProofsCounterModel)
|
||||
})
|
||||
|
||||
export const MintsStoreModel = types
|
||||
.model('MintsStore', {
|
||||
mints: types.array(MintModel),
|
||||
blockedMintUrls: types.array(types.string),
|
||||
counterBackups: types.array(CounterBackupModel)
|
||||
})
|
||||
// counterBackups removed: a removed mint's counters are retained in SQLite
|
||||
// (mint_counters rows are never deleted) and restored on re-add via
|
||||
// hydrateCountersFromDatabase. Strip the field from any old snapshot so
|
||||
// applySnapshot tolerates it; pre-upgrade backup values are migrated into
|
||||
// SQLite by a one-time seed in setupRootStore._runMigrations.
|
||||
.preProcessSnapshot((s: any) => {
|
||||
if (!s) return s
|
||||
const {counterBackups, ...rest} = s
|
||||
return rest
|
||||
})
|
||||
.views(self => ({
|
||||
findByUrl: (mintUrl: string | URL) => {
|
||||
@@ -65,43 +64,47 @@ export const MintsStoreModel = types
|
||||
const mint = self.mints.find(m => m.mintUrl.replace(/\/$/, '') === normalized)
|
||||
if(mint) {return true} else {return false}
|
||||
},
|
||||
addOrUpdateCounterBackup(mintToRemove: Mint) {
|
||||
try {
|
||||
const existingIndex = self.counterBackups.findIndex(
|
||||
(backup) => backup.mintUrl === mintToRemove.mintUrl
|
||||
)
|
||||
|
||||
const newCounterBackup = CounterBackupModel.create({
|
||||
mintUrl: mintToRemove.mintUrl,
|
||||
counters: getSnapshot(mintToRemove.proofsCounters!)
|
||||
})
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Replace existing backup
|
||||
self.counterBackups[existingIndex] = newCounterBackup
|
||||
} else {
|
||||
// Add new backup
|
||||
self.counterBackups.push(newCounterBackup)
|
||||
/**
|
||||
* One-time copy of the in-memory (MMKV-loaded) derivation counters into
|
||||
* SQLite. Run from _runMigrations on upgrade, and on backup import.
|
||||
*
|
||||
* Two safeguards make this safe even on a device that has ALREADY
|
||||
* migrated (SQLite populated, MMKV stripped to 0, model not yet
|
||||
* re-hydrated this launch):
|
||||
* 1. only counters that have actually advanced (> 0) are seeded, so a
|
||||
* stripped/zero value is never written; and
|
||||
* 2. the repo upsert is monotonic (MAX), so a seed can never lower an
|
||||
* existing SQLite counter.
|
||||
*/
|
||||
seedCountersToDatabase() {
|
||||
const seeds: CounterSeed[] = []
|
||||
|
||||
for (const mint of self.mints) {
|
||||
for (const c of mint.proofsCounters) {
|
||||
if (c.counter > 0) {
|
||||
seeds.push({mintUrl: mint.mintUrl, keysetId: c.keyset, unit: c.unit, counter: c.counter})
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.STORAGE_ERROR, e.message)
|
||||
}
|
||||
|
||||
if (seeds.length > 0) {
|
||||
Database.seedCounters(seeds)
|
||||
}
|
||||
},
|
||||
updateMintCountersFromBackup(newMint: Mint) {
|
||||
const backup = self.counterBackups.find(
|
||||
(backup) => backup.mintUrl === newMint.mintUrl
|
||||
)
|
||||
|
||||
if (backup) {
|
||||
newMint.proofsCounters!.forEach((proofsCounter) => {
|
||||
const backupCounter = backup.counters.find(
|
||||
(counter) => counter.keyset === proofsCounter.keyset
|
||||
)
|
||||
|
||||
if (backupCounter) {
|
||||
proofsCounter.increaseProofsCounter(backupCounter.counter)
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Load the authoritative counter values from SQLite into the in-memory
|
||||
* cache (startup / foreground resume). Monotonic per counter, so a value
|
||||
* already advanced in memory is never lowered.
|
||||
*/
|
||||
hydrateCountersFromDatabase() {
|
||||
const rows = Database.getCounters()
|
||||
|
||||
for (const row of rows) {
|
||||
const mint = self.mints.find(m => m.mintUrl === row.mintUrl)
|
||||
const counter = mint?.proofsCounters.find(c => c.keyset === row.keysetId)
|
||||
if (counter) {
|
||||
counter.hydrateCounterFromDb(row.counter)
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -161,18 +164,20 @@ export const MintsStoreModel = types
|
||||
continue
|
||||
}
|
||||
|
||||
mintInstance.initKeys(key)
|
||||
mintInstance.initKeys(key)
|
||||
}
|
||||
|
||||
log.trace('[addMint] updateMintCountersFromBackup')
|
||||
|
||||
self.updateMintCountersFromBackup(mintInstance)
|
||||
mintInstance.setHostname()
|
||||
yield mintInstance.setShortname()
|
||||
|
||||
mintInstance.setHostname()
|
||||
yield mintInstance.setShortname()
|
||||
|
||||
self.mints.push(mintInstance)
|
||||
|
||||
|
||||
// SQLite retains derivation counters by (mintUrl, keysetId) across
|
||||
// mint removal (rows are never deleted), so a re-added mint recovers
|
||||
// its real counter from the authority here. Monotonic, so a genuinely
|
||||
// new mint (no row) simply stays at 0.
|
||||
self.hydrateCountersFromDatabase()
|
||||
|
||||
return mintInstance
|
||||
}),
|
||||
updateMint: flow(function* updateMint(mintUrl: string) {
|
||||
@@ -236,7 +241,8 @@ export const MintsStoreModel = types
|
||||
}
|
||||
|
||||
if (mintInstance) {
|
||||
self.addOrUpdateCounterBackup(mintInstance)
|
||||
// No counter backup needed: the mint's mint_counters rows are
|
||||
// retained in SQLite and restored on re-add via hydrate.
|
||||
detach(mintInstance)
|
||||
destroy(mintInstance)
|
||||
log.info('[removeMint]', 'Mint removed from MintsStore')
|
||||
|
||||
+180
-34
@@ -11,19 +11,21 @@ import { NWCWalletResponse, NWCWalletInfo, NWCWalletRequest } from 'nostr-tools/
|
||||
import {withSetPropAction} from './helpers/withSetPropAction'
|
||||
import {log} from '../services/logService'
|
||||
import { getRootStore } from './helpers/getRootStore'
|
||||
import {
|
||||
import {
|
||||
Database,
|
||||
HANDLE_NWC_REQUEST_TASK,
|
||||
KeyChain,
|
||||
NostrClient,
|
||||
NostrEvent,
|
||||
NostrKeyPair,
|
||||
NostrUnsignedEvent,
|
||||
SyncQueue,
|
||||
TransactionTaskResult,
|
||||
KeyChain,
|
||||
NostrClient,
|
||||
NostrEvent,
|
||||
NostrKeyPair,
|
||||
NostrUnsignedEvent,
|
||||
SyncQueue,
|
||||
TransactionTaskResult,
|
||||
WalletTaskResult
|
||||
} from '../services'
|
||||
import AppError, { Err } from '../utils/AppError'
|
||||
import { LightningUtils } from '../services/lightning/lightningUtils'
|
||||
import EventEmitter from '../utils/eventEmitter'
|
||||
import { addSeconds } from 'date-fns/addSeconds'
|
||||
import { Transaction, TransactionStatus, TransactionType } from './Transaction'
|
||||
import { MeltQuoteBolt11Response } from '@cashu/cashu-ts'
|
||||
@@ -59,6 +61,20 @@ type NwcResponse = {
|
||||
result: any
|
||||
}
|
||||
|
||||
// Internal-only outcome: an NWC command resolved to "no reply" — an async melt
|
||||
// still pending when the background (NWC listener / foreground service) tore
|
||||
// down. NIP-47 has no pending wire response, so this is NEVER sent; it just
|
||||
// tells the dispatcher to skip replying for this command (the payment finalizes
|
||||
// later; zaps confirm via the NIP-57 receipt).
|
||||
type NwcPending = {
|
||||
result_type: string,
|
||||
pending: true
|
||||
}
|
||||
|
||||
const isNwcPending = (
|
||||
r: NwcResponse | NwcError | NwcPending,
|
||||
): r is NwcPending => (r as NwcPending).pending === true
|
||||
|
||||
type NwcTransaction = {
|
||||
type: string,
|
||||
invoice: string,
|
||||
@@ -87,6 +103,47 @@ export const MIN_LIGHTNING_FEE = 2 // sats
|
||||
export const LIGHTNING_FEE_PERCENT = 1
|
||||
const MAX_MULTI_PAY_INVOICES = 5
|
||||
|
||||
// Safety cap so an in-flight NWC pay_invoice can never hang the SyncQueue forever
|
||||
// if the listener-closing signal is missed. Normally the wait ends earlier — the
|
||||
// async melt settles (ev_asyncMeltResult) or the NWC listener / foreground
|
||||
// service tears down (ev_nwcListenerClosing). Kept just above the 30s listener
|
||||
// hard cap so it only ever acts as a backstop.
|
||||
const NWC_ASYNC_MELT_SAFETY_MS = 35 * 1000
|
||||
|
||||
/**
|
||||
* Wait for the async melt of `transactionId` to settle, OR until the NWC listener
|
||||
* / foreground service is torn down — whichever comes first. This binds the wait
|
||||
* to the background lifetime instead of a separate timeout: the common fast case
|
||||
* resolves on settlement (→ preimage reply); on teardown it resolves undefined
|
||||
* (payment still in flight, finalized later; zaps confirm via the NIP-57 receipt).
|
||||
*/
|
||||
const waitForAsyncMeltResult = (
|
||||
transactionId: number,
|
||||
): Promise<{transactionId: number; status: TransactionStatus; message: string} | undefined> =>
|
||||
new Promise((resolve) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
EventEmitter.off('ev_asyncMeltResult', onResult)
|
||||
EventEmitter.off('ev_nwcListenerClosing', onClosing)
|
||||
}
|
||||
const onResult = (result: {transactionId: number; status: TransactionStatus; message: string}) => {
|
||||
if (result?.transactionId === transactionId) {
|
||||
cleanup()
|
||||
resolve(result)
|
||||
}
|
||||
}
|
||||
const onClosing = () => {
|
||||
cleanup()
|
||||
resolve(undefined)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve(undefined)
|
||||
}, NWC_ASYNC_MELT_SAFETY_MS)
|
||||
EventEmitter.on('ev_asyncMeltResult', onResult)
|
||||
EventEmitter.on('ev_nwcListenerClosing', onClosing)
|
||||
})
|
||||
|
||||
const getSupportedMethods = function () {
|
||||
return [
|
||||
'pay_invoice',
|
||||
@@ -297,40 +354,77 @@ export const NwcConnectionModel = types.model('NwcConnection', {
|
||||
|
||||
self.setLastMeltQuoteId(result.meltQuote?.quote)
|
||||
|
||||
let txStatus: TransactionStatus | undefined = result.transaction?.status
|
||||
let completedAmount: number = result.transaction?.amount ?? 0
|
||||
let completedFee: number = result.transaction?.fee ?? 0
|
||||
let preimage: string | undefined = result.preimage
|
||||
|
||||
// Async melt: the mint ACKed and the payment is in flight. Wait for
|
||||
// settlement only as long as the background (NWC listener / foreground
|
||||
// service) is alive. Fast common case → reply with preimage; if the
|
||||
// background tears down first → no reply (payment finalizes later; zaps
|
||||
// confirm via the NIP-57 receipt, non-zap clients retry + mint dedups).
|
||||
if(txStatus === TransactionStatus.PENDING) {
|
||||
const settled = yield waitForAsyncMeltResult(result.transaction.id)
|
||||
|
||||
if(!settled) {
|
||||
// Still in flight at teardown. Reserve the daily limit
|
||||
// (conservative — never lets the cap be exceeded) and resolve
|
||||
// to a typed 'pending' outcome (no wire reply).
|
||||
self.setRemainingDailyLimit(self.remainingDailyLimit - totalAmountToPay)
|
||||
log.info('[Nwc.payInvoice] Async melt still pending at teardown; limit reserved, no NWC reply', {tId: result.transaction.id})
|
||||
return { result_type: nwcRequest.method, pending: true } as NwcPending
|
||||
}
|
||||
|
||||
if(settled.status !== TransactionStatus.COMPLETED) {
|
||||
return {
|
||||
result_type: nwcRequest.method,
|
||||
error: { code: 'INTERNAL', message: settled.message || 'Lightning payment failed.'}
|
||||
} as NwcError
|
||||
}
|
||||
|
||||
// Settled within the window — read the finalized tx for the
|
||||
// preimage and actual amount/fee.
|
||||
const finalTx = Database.getTransactionById(result.transaction.id)
|
||||
completedAmount = finalTx?.amount ?? completedAmount
|
||||
completedFee = finalTx?.fee ?? completedFee
|
||||
preimage = finalTx?.proof ?? preimage
|
||||
txStatus = TransactionStatus.COMPLETED
|
||||
}
|
||||
|
||||
let nwcResponse: NwcResponse | NwcError
|
||||
|
||||
if(result.transaction?.status === TransactionStatus.COMPLETED) {
|
||||
const updatedLimit = self.remainingDailyLimit -
|
||||
(result.transaction.amount + result.transaction.fee)
|
||||
|
||||
|
||||
if(txStatus === TransactionStatus.COMPLETED) {
|
||||
const updatedLimit = self.remainingDailyLimit - (completedAmount + completedFee)
|
||||
|
||||
nwcResponse = {
|
||||
result_type: nwcRequest.method,
|
||||
result: {
|
||||
preimage: result.preimage || 'not-provided',
|
||||
preimage: preimage || 'not-provided',
|
||||
}
|
||||
} as NwcResponse
|
||||
|
||||
|
||||
log.trace('[handleTransferTaskResult] Updating remainingLimit', {
|
||||
connection: self.name,
|
||||
beforeUpdate: self.remainingDailyLimit,
|
||||
afterUpdate: updatedLimit
|
||||
})
|
||||
|
||||
self.setRemainingDailyLimit(updatedLimit)
|
||||
|
||||
|
||||
self.setRemainingDailyLimit(updatedLimit)
|
||||
|
||||
yield NotificationService.createLocalNotification(
|
||||
Platform.OS === 'android' ? `<b>${self.name}</b> - Nostr Wallet Connect` : `${self.name} - Nostr Wallet Connect`,
|
||||
`Paid ${result.transaction.amount} SAT${result.transaction.fee > 0 ? ', fee ' + result.transaction.fee + ' SAT' : ''}. Remaining today's limit is ${self.remainingDailyLimit} SAT`,
|
||||
`Paid ${completedAmount} SAT${completedFee > 0 ? ', fee ' + completedFee + ' SAT' : ''}. Remaining today's limit is ${self.remainingDailyLimit} SAT`,
|
||||
nwcPngUrl
|
||||
)
|
||||
|
||||
|
||||
} else {
|
||||
nwcResponse = {
|
||||
result_type: nwcRequest.method,
|
||||
error: { code: 'INTERNAL', message: result.message}
|
||||
} as NwcError
|
||||
}
|
||||
|
||||
|
||||
return nwcResponse
|
||||
|
||||
} catch (e: any) {
|
||||
@@ -395,7 +489,9 @@ export const NwcConnectionModel = types.model('NwcConnection', {
|
||||
return nwcResponse
|
||||
},
|
||||
handleGetBalance(nwcRequest: NwcRequest) {
|
||||
const balance = self.getProofsStore().getMintBalanceWithMaxBalance('sat')?.balances.sat
|
||||
// Read from SQLite so this works on a lean NWC wake (proof map not
|
||||
// hydrated). Mirrors proofsStore.getMintBalanceWithMaxBalance('sat').
|
||||
const balance = Database.getMintBalanceWithMaxBalance('sat')
|
||||
const limit = self.remainingDailyLimit
|
||||
let resultBalanceMsat = 0
|
||||
|
||||
@@ -527,7 +623,7 @@ export const NwcConnectionModel = types.model('NwcConnection', {
|
||||
}
|
||||
|
||||
const nwcResponse = yield self.payInvoice(nwcRequest, nwcRequest.params.invoice, requestEvent)
|
||||
return nwcResponse as NwcResponse | NwcError
|
||||
return nwcResponse as NwcResponse | NwcError | NwcPending
|
||||
}),
|
||||
handleMultiPayInvoice: flow(function* handleMultiPayInvoice(nwcRequest: NwcRequest, requestEvent: NostrEvent) {
|
||||
log.debug('[Nwc.handleMultiPayInvoice] start')
|
||||
@@ -549,10 +645,12 @@ export const NwcConnectionModel = types.model('NwcConnection', {
|
||||
self.setCurrentDay()
|
||||
}
|
||||
|
||||
const nwcResponses: (NwcResponse | NwcError)[] = []
|
||||
const nwcResponses: (NwcResponse | NwcError | NwcPending)[] = []
|
||||
|
||||
for (const invoice of encodedInvoices) {
|
||||
const nwcResponse = yield self.payInvoice(nwcRequest, invoice, requestEvent)
|
||||
// May be a typed 'pending' outcome (async melt unresolved at teardown);
|
||||
// the dispatcher skips sending those.
|
||||
nwcResponses.push(nwcResponse)
|
||||
}
|
||||
|
||||
@@ -577,12 +675,19 @@ export const NwcConnectionModel = types.model('NwcConnection', {
|
||||
nwcRequest = decryptedNwcRequest
|
||||
}
|
||||
|
||||
let nwcResponse: NwcResponse | NwcError | undefined = undefined
|
||||
let nwcResponses: (NwcResponse | NwcError)[] = []
|
||||
let nwcResponse: NwcResponse | NwcError | NwcPending | undefined = undefined
|
||||
let nwcResponses: (NwcResponse | NwcError | NwcPending)[] = []
|
||||
|
||||
log.trace('[Nwc.handleRequest] request event', {requestEvent})
|
||||
log.trace('[Nwc.handleRequest] decrypted nwc command', {nwcRequest})
|
||||
|
||||
// A lean background NWC wake skips bulk proof loading. Mutating commands
|
||||
// select/derive against the in-memory proof map, so load it on demand
|
||||
// here (no-op when already hydrated — warm session or full setup).
|
||||
if (['pay_invoice', 'multi_pay_invoice', 'make_invoice'].includes(nwcRequest.method)) {
|
||||
yield self.getProofsStore().ensureProofsLoaded()
|
||||
}
|
||||
|
||||
switch (nwcRequest.method) {
|
||||
case 'get_info':
|
||||
nwcResponse = self.handleGetInfo(nwcRequest)
|
||||
@@ -622,8 +727,10 @@ export const NwcConnectionModel = types.model('NwcConnection', {
|
||||
}
|
||||
|
||||
for (const response of nwcResponses) {
|
||||
// 'pending' outcomes have no NIP-47 wire response — skip them.
|
||||
if (isNwcPending(response)) continue
|
||||
yield self.sendResponse(response, requestEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return nwcResponses[0]
|
||||
}),
|
||||
@@ -749,8 +856,49 @@ export const NwcStoreModel = types
|
||||
log.debug('[remove]', 'Connection removed from NwcStore')
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Process an NWC request event delivered IN the FCM push payload, without
|
||||
* waiting for the WebSocket listener to re-fetch it. Dedup-marks the event
|
||||
* synchronously (so the follow-up listener, started right after, skips it),
|
||||
* sets the listener window to start from this event, and dispatches it on
|
||||
* the SyncQueue — exactly the same handler the WS path uses.
|
||||
*
|
||||
* MUST be called BEFORE the follow-up listener is opened, so the dedup mark
|
||||
* is in place and the same event can never be processed twice (a double
|
||||
* pay_invoice would be a double payment).
|
||||
*/
|
||||
receivePushedEvent (event: NostrEvent) {
|
||||
if (!event || !event.id) {
|
||||
log.warn('[receivePushedEvent] No event in push payload, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
const relaysStore = getRootStore(self).relaysStore
|
||||
if (relaysStore.eventAlreadyReceived(event.id)) {
|
||||
log.trace('[receivePushedEvent] Event already processed, skipping', {id: event.id})
|
||||
return
|
||||
}
|
||||
relaysStore.addReceivedEventId(event.id)
|
||||
|
||||
// Follow-up listener should start from this event (it is now deduped).
|
||||
self.setRetrieveEventsSince(event.created_at)
|
||||
|
||||
const targetConnection = self.nwcConnections.find(c =>
|
||||
c.connectionPubkey === event.pubkey
|
||||
)
|
||||
if (!targetConnection) {
|
||||
log.error('[receivePushedEvent] No NWC connection for pushed event', {pubkey: event.pubkey})
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().getTime()
|
||||
SyncQueue.addTask(
|
||||
`handleNwcRequestTask-${now}`,
|
||||
async () => await targetConnection.handleNwcRequestTask(event)
|
||||
)
|
||||
},
|
||||
listenForNwcEvents () {
|
||||
log.debug('[listenForNwcEvents] got request to start nwcListener', {
|
||||
log.debug('[listenForNwcEvents] got request to start nwcListener', {
|
||||
walletPubkey: self.walletPubkey,
|
||||
isNwcListenerActive: self.isNwcListenerActive,
|
||||
relays: self.connectionRelays
|
||||
@@ -790,19 +938,17 @@ export const NwcStoreModel = types
|
||||
log.trace('[listenForNwcEvents]', `onEvent`)
|
||||
if (event.kind != NWCWalletRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
eventsBatch.push(event)
|
||||
}
|
||||
|
||||
if(relaysStore.eventAlreadyReceived(event.id)) {
|
||||
log.warn(
|
||||
Err.ALREADY_EXISTS_ERROR,
|
||||
'[listenForNwcEvents] Event has been processed in the past, skipping...',
|
||||
Err.ALREADY_EXISTS_ERROR,
|
||||
'[listenForNwcEvents] Event has been processed in the past, skipping...',
|
||||
{id: event.id, created_at: event.created_at}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
eventsBatch.push(event)
|
||||
relaysStore.addReceivedEventId(event.id)
|
||||
|
||||
|
||||
+86
-129
@@ -105,120 +105,46 @@ import {
|
||||
}))
|
||||
|
||||
// ───────────────────── ACTIONS ─────────────────────
|
||||
.actions(self => ({
|
||||
loadProofsFromDatabase: flow(function* loadProofsFromDatabase(includeSpent: boolean = false) {
|
||||
const proofRecords: ProofRecord[] = yield Database.getProofs(
|
||||
true, // includeUnspent
|
||||
true, // includePending
|
||||
includeSpent
|
||||
)
|
||||
.actions(self => ({
|
||||
loadProofsFromDatabase: flow(function* loadProofsFromDatabase(includeSpent: boolean = false) {
|
||||
const proofRecords: ProofRecord[] = yield Database.getProofs(
|
||||
true, // includeUnspent
|
||||
true, // includePending
|
||||
includeSpent
|
||||
)
|
||||
|
||||
self.proofs.clear()
|
||||
self.proofs.clear()
|
||||
|
||||
for (const record of proofRecords) {
|
||||
const {
|
||||
state,
|
||||
dleq_e,
|
||||
dleq_r,
|
||||
dleq_s,
|
||||
updatedAt,
|
||||
...coreProof
|
||||
} = record
|
||||
for (const record of proofRecords) {
|
||||
const {
|
||||
state,
|
||||
dleq_e,
|
||||
dleq_r,
|
||||
dleq_s,
|
||||
updatedAt,
|
||||
...coreProof
|
||||
} = record
|
||||
|
||||
const dleq = dleq_e && dleq_s
|
||||
? { e: dleq_e as string, r: dleq_r as string, s: dleq_s as string }
|
||||
: undefined
|
||||
const dleq = dleq_e && dleq_s
|
||||
? { e: dleq_e as string, r: dleq_r as string, s: dleq_s as string }
|
||||
: undefined
|
||||
|
||||
self.proofs.put(
|
||||
ProofModel.create({
|
||||
...coreProof,
|
||||
state: state ?? 'UNSPENT',
|
||||
dleq,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
log.trace('[loadProofsFromDatabase]', {
|
||||
loaded: self.proofs.size,
|
||||
unspent: Array.from(self.proofs.values()).filter(p => p.state === 'UNSPENT').length,
|
||||
pending: Array.from(self.proofs.values()).filter(p => p.state === 'PENDING').length,
|
||||
spent: Array.from(self.proofs.values()).filter(p => p.state === 'SPENT').length,
|
||||
self.proofs.put(
|
||||
ProofModel.create({
|
||||
...coreProof,
|
||||
state: state ?? 'UNSPENT',
|
||||
dleq,
|
||||
})
|
||||
}),
|
||||
|
||||
addOrUpdate(
|
||||
proofs: CashuProof[] | Proof[],
|
||||
update: {
|
||||
mintUrl: string,
|
||||
tId: number,
|
||||
unit: MintUnit
|
||||
state: ProofState,
|
||||
}): { updatedAmount: number; updatedProofs: Proof[] } {
|
||||
|
||||
if (proofs.length === 0) return { updatedAmount: 0, updatedProofs: [] }
|
||||
|
||||
let updatedAmount = 0
|
||||
const updatedProofs: Proof[] = []
|
||||
const { state, tId, unit, mintUrl } = update
|
||||
|
||||
const mintsStore = getRootStore(self).mintsStore
|
||||
const mintInstance = mintsStore.findByUrl(mintUrl)
|
||||
|
||||
if (!mintInstance) {
|
||||
throw new AppError(Err.VALIDATION_ERROR, 'Mint not found in the wallet', { mintUrl })
|
||||
)
|
||||
}
|
||||
|
||||
const proofsByKeyset = new Map<string, Proof[]>()
|
||||
|
||||
for (const proof of proofs) {
|
||||
|
||||
let proofNode = self.getBySecret(proof.secret)
|
||||
|
||||
if (proofNode) {
|
||||
if (proofNode.state === 'SPENT') continue // never move a spent proof backward
|
||||
|
||||
if (!isAlive(proofNode)) {
|
||||
log.error('[addOrUpdate]', 'Proof instance is not alive, aborting state update', { secret: proofNode.secret })
|
||||
continue
|
||||
}
|
||||
|
||||
proofNode?.setProp('mintUrl', mintUrl)
|
||||
proofNode?.setProp('tId', tId)
|
||||
proofNode?.setProp('unit', unit)
|
||||
proofNode?.setProp('state', state)
|
||||
} else {
|
||||
proofNode = ProofModel.create({
|
||||
...proof,
|
||||
amount: Number(proof.amount),
|
||||
mintUrl,
|
||||
tId,
|
||||
unit,
|
||||
state,
|
||||
})
|
||||
self.proofs.put(proofNode)
|
||||
}
|
||||
|
||||
updatedAmount += proofNode.amount
|
||||
updatedProofs.push(proofNode)
|
||||
|
||||
proofsByKeyset.set(proof.id, (proofsByKeyset.get(proof.id) || []).concat(proofNode))
|
||||
}
|
||||
|
||||
// Increment counters only when proofs become freshly spendable
|
||||
if (state === 'UNSPENT') {
|
||||
for (const [keysetId, proofs] of proofsByKeyset) {
|
||||
const counter = mintInstance.getProofsCounterByKeysetId(keysetId)
|
||||
counter?.increaseProofsCounter(proofs.length)
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedProofs.length > 0) {
|
||||
Database.addOrUpdateProofs(updatedProofs, state)
|
||||
}
|
||||
|
||||
log.trace('[addOrUpdate]', `Added or updated ${updatedProofs.length} ${state} proofs`)
|
||||
return { updatedAmount, updatedProofs }
|
||||
},
|
||||
log.trace('[loadProofsFromDatabase]', {
|
||||
loaded: self.proofs.size,
|
||||
unspent: Array.from(self.proofs.values()).filter(p => p.state === 'UNSPENT').length,
|
||||
pending: Array.from(self.proofs.values()).filter(p => p.state === 'PENDING').length,
|
||||
spent: Array.from(self.proofs.values()).filter(p => p.state === 'SPENT').length,
|
||||
})
|
||||
}),
|
||||
|
||||
// Lock proofs locally during an outgoing operation (send, melt prepare, etc.)
|
||||
// Does NOT touch pendingByMintSecrets — that is mint-reported pending.
|
||||
@@ -420,8 +346,34 @@ import {
|
||||
})
|
||||
}
|
||||
|
||||
// Snapshot the current derivation counter for every keyset the new
|
||||
// proofs were derived under (a cashu proof's `id` IS its keyset id).
|
||||
// WalletStore already advanced the model counter to
|
||||
// `reservedCounters.next` and wrote it through to SQLite (W1); this
|
||||
// folds the same value into the proof-commit batch (W2) as an atomic
|
||||
// backstop, so a committed proof can never outlive its counter even if
|
||||
// the W1 write-through was dropped. Monotonic, so the normal-path
|
||||
// double write is a harmless no-op.
|
||||
const counterUpdate: Array<{mintUrl: string; keysetId: string; unit?: string; counter: number}> = []
|
||||
const seenKeysets = new Set<string>()
|
||||
for (const group of changes.newProofs ?? []) {
|
||||
for (const proof of group.proofs) {
|
||||
if (seenKeysets.has(proof.id)) continue
|
||||
seenKeysets.add(proof.id)
|
||||
const counter = mintInstance.getProofsCounter(proof.id)
|
||||
if (counter) {
|
||||
counterUpdate.push({
|
||||
mintUrl: reservation.mintUrl,
|
||||
keysetId: proof.id,
|
||||
unit: counter.unit,
|
||||
counter: counter.counter,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ATOMIC SQLite write of every state transition + (optional)
|
||||
// transaction-row update + reservation deletion.
|
||||
// transaction-row update + counter advance + reservation deletion.
|
||||
Database.commitReservation(reservation.id, {
|
||||
toSpent: changes.toSpent,
|
||||
toUnspent: changes.toUnspent,
|
||||
@@ -433,6 +385,7 @@ import {
|
||||
tId: group.tId,
|
||||
})),
|
||||
transactionUpdate: changes.transactionUpdate,
|
||||
counterUpdate,
|
||||
})
|
||||
|
||||
// Mirror to MST now that SQLite is durable.
|
||||
@@ -453,8 +406,6 @@ import {
|
||||
)
|
||||
}
|
||||
|
||||
const proofsByKeyset = new Map<string, Proof[]>()
|
||||
|
||||
for (const group of changes.newProofs ?? []) {
|
||||
for (const proof of group.proofs) {
|
||||
const existing = self.getBySecret(proof.secret)
|
||||
@@ -466,12 +417,6 @@ import {
|
||||
existing.setProp('unit', reservation.unit)
|
||||
existing.setProp('state', group.state)
|
||||
added.push(existing)
|
||||
if (group.state === 'UNSPENT') {
|
||||
proofsByKeyset.set(
|
||||
proof.id,
|
||||
(proofsByKeyset.get(proof.id) || []).concat(existing),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const node = ProofModel.create({
|
||||
@@ -484,21 +429,17 @@ import {
|
||||
})
|
||||
self.proofs.put(node)
|
||||
added.push(node)
|
||||
if (group.state === 'UNSPENT') {
|
||||
proofsByKeyset.set(
|
||||
proof.id,
|
||||
(proofsByKeyset.get(proof.id) || []).concat(node),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Increment keyset counters for proofs that became freshly spendable.
|
||||
for (const [keysetId, addedProofs] of proofsByKeyset) {
|
||||
const counter = mintInstance.getProofsCounterByKeysetId(keysetId)
|
||||
counter?.increaseProofsCounter(addedProofs.length)
|
||||
}
|
||||
// The keyset counter is NOT advanced here. Under cashu-ts v3.x the
|
||||
// operation already advanced it to `reservedCounters.next` (via
|
||||
// WalletStore.setProofsCounter), covering every index these proofs
|
||||
// consumed — and that value was persisted atomically with the proofs
|
||||
// in the commit batch above (counterUpdate). The old post-commit
|
||||
// `increaseProofsCounter(addedProofs.length)` here double-advanced the
|
||||
// counter (a pre-v3.x leftover) and was removed.
|
||||
|
||||
// Mirror the (already-durable) transaction update to MST so the
|
||||
// in-memory model reflects the new tx state immediately. Uses
|
||||
@@ -524,8 +465,8 @@ import {
|
||||
id: reservation.id,
|
||||
toSpent: changes.toSpent?.length ?? 0,
|
||||
toUnspent: changes.toUnspent?.length ?? 0,
|
||||
addedCount: added.length,
|
||||
txUpdate: changes.transactionUpdate?.id,
|
||||
addedProofsCount: added.length,
|
||||
txId: changes.transactionUpdate?.id,
|
||||
})
|
||||
|
||||
return { added }
|
||||
@@ -599,6 +540,22 @@ import {
|
||||
},
|
||||
}))
|
||||
|
||||
.actions(self => ({
|
||||
/**
|
||||
* Lazily initialize the proof subsystem when it was NOT hydrated at
|
||||
* startup — i.e. a lean background NWC wake (setupRootStore skipProofs).
|
||||
* Loads proofs from SQLite and rolls back orphan reservations. No-op once
|
||||
* proofs are already in memory (warm session, or a full foreground setup).
|
||||
* Mutating NWC commands call this before selecting proofs.
|
||||
*/
|
||||
ensureProofsLoaded: flow(function* ensureProofsLoaded() {
|
||||
if (self.proofs.size > 0) return
|
||||
log.trace('[ensureProofsLoaded] Lean wake — loading proofs on demand')
|
||||
yield self.loadProofsFromDatabase()
|
||||
self.recoverOrphanReservations()
|
||||
}),
|
||||
}))
|
||||
|
||||
// ───────────────────── DERIVED VIEWS ─────────────────────
|
||||
.views(self => ({
|
||||
get proofsCount() { return self.unspentProofs.length },
|
||||
|
||||
@@ -11,7 +11,7 @@ import {NwcStoreModel} from './NwcStore'
|
||||
import {AuthStoreModel} from './AuthStore'
|
||||
import { log } from '../services'
|
||||
|
||||
export const rootStoreModelVersion = 32 // Update this if model changes require migrations defined in setupRootStore.ts
|
||||
export const rootStoreModelVersion = 36 // Update this if model changes require migrations defined in setupRootStore.ts
|
||||
/**
|
||||
* A RootStore model.
|
||||
*/
|
||||
|
||||
@@ -198,7 +198,7 @@ export const TransactionsStoreModel = types
|
||||
inputToken: dbTx.inputToken?.slice(0, 40) || '',
|
||||
outputToken: dbTx.outputToken?.slice(0, 40) || '',
|
||||
})
|
||||
log.trace('[addToHistory]', `${id} added to transactionsMap`)
|
||||
//log.trace('[addToHistory]', `${id} added to transactionsMap`)
|
||||
}
|
||||
|
||||
const ref = self.transactionsMap.get(id)
|
||||
|
||||
+70
-38
@@ -20,7 +20,7 @@ import {
|
||||
MeltQuoteState,
|
||||
} from '@cashu/cashu-ts'
|
||||
import { JS_BUNDLE_VERSION } from '@env'
|
||||
import {KeyChain, MinibitsClient, WalletKeys} from '../services'
|
||||
import {Database, KeyChain, MinibitsClient, WalletKeys} from '../services'
|
||||
import {log} from '../services/logService'
|
||||
import AppError, { Err, MintError, NetworkError } from '../utils/AppError'
|
||||
import { Currencies, CurrencyCode, MintUnit } from '../services/wallet/currency'
|
||||
@@ -169,29 +169,54 @@ export const WalletStoreModel = types
|
||||
resetExchangeRate () {
|
||||
self.exchangeRate = undefined
|
||||
}
|
||||
}))
|
||||
}))
|
||||
.volatile(() => ({
|
||||
// In-flight KeyChain read shared by concurrent getCachedWalletKeys callers.
|
||||
// Reading secure storage is slow (~2s cold on Android), and several NWC
|
||||
// pushes can wake the app at once — without this, each push would trigger
|
||||
// its own KeyChain read. Volatile (never persisted), so it resets on a
|
||||
// fresh cold start, which is exactly when we want a single fresh read.
|
||||
walletKeysInFlight: null as Promise<WalletKeys> | null,
|
||||
}))
|
||||
.actions(self => ({
|
||||
getCachedWalletKeys: flow(function* getWalletKeys() {
|
||||
if (self.walletKeys) {
|
||||
getCachedWalletKeys: flow(function* getWalletKeys() {
|
||||
if (self.walletKeys) {
|
||||
log.trace('[getCachedWalletKeys]', 'Returning cached walletKeys')
|
||||
return self.walletKeys
|
||||
}
|
||||
|
||||
const keys: WalletKeys | undefined = yield KeyChain.getWalletKeys()
|
||||
|
||||
if (!keys) {
|
||||
return self.walletKeys
|
||||
}
|
||||
|
||||
// Coalesce concurrent cold reads onto a single KeyChain fetch.
|
||||
if (self.walletKeysInFlight) {
|
||||
log.trace('[getCachedWalletKeys]', 'Awaiting in-flight KeyChain read')
|
||||
return yield self.walletKeysInFlight
|
||||
}
|
||||
|
||||
// The shared promise resolves to validated keys so that both this
|
||||
// originator and any concurrent awaiters get identical success/error.
|
||||
const fetch = (async (): Promise<WalletKeys> => {
|
||||
const keys: WalletKeys | undefined = await KeyChain.getWalletKeys()
|
||||
if (!keys) {
|
||||
throw new AppError(
|
||||
Err.NOTFOUND_ERROR,
|
||||
Err.NOTFOUND_ERROR,
|
||||
'Device secure storage could not return wallet keys, please reinstall and use your seed phrase to recover wallet.'
|
||||
)
|
||||
}
|
||||
return keys
|
||||
})()
|
||||
|
||||
self.walletKeysInFlight = fetch
|
||||
|
||||
try {
|
||||
const keys: WalletKeys = yield fetch
|
||||
self.walletKeys = keys
|
||||
return keys
|
||||
} finally {
|
||||
self.walletKeysInFlight = null
|
||||
}
|
||||
|
||||
self.walletKeys = keys
|
||||
return keys
|
||||
}),
|
||||
cleanCachedWalletKeys() {
|
||||
cleanCachedWalletKeys() {
|
||||
self.walletKeys = undefined
|
||||
},
|
||||
},
|
||||
}))
|
||||
.actions(self => ({
|
||||
getCachedSeed: flow(function* getCachedSeed() {
|
||||
@@ -476,7 +501,7 @@ export const WalletStoreModel = types
|
||||
|
||||
// @ts-ignore
|
||||
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
|
||||
currentCounter.addInFlightRequest(transactionId, receiveParams)
|
||||
Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, receiveParams)
|
||||
}
|
||||
|
||||
let reservedCounters: OperationCounters | undefined
|
||||
@@ -488,19 +513,19 @@ export const WalletStoreModel = types
|
||||
...receiveParams.options,
|
||||
onCountersReserved: (info: OperationCounters) => {
|
||||
reservedCounters = info
|
||||
log.debug('[receive] Counters reserved', info)
|
||||
log.debug('[WalletStore.receive] Counters reserved', info)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
log.trace('[WalletStore.receive]', {proofs})
|
||||
|
||||
currentCounter.removeInFlightRequest(transactionId)
|
||||
Database.removeInFlightRequest(transactionId)
|
||||
|
||||
// Update our counter to match what the wallet used (v3.x)
|
||||
if (reservedCounters) {
|
||||
currentCounter.setProofsCounter(reservedCounters.next)
|
||||
log.debug('[receive] Updated counter', {
|
||||
log.debug('[WalletStore.receive] Updated counter', {
|
||||
keysetId: reservedCounters.keysetId,
|
||||
start: reservedCounters.start,
|
||||
count: reservedCounters.count,
|
||||
@@ -524,7 +549,7 @@ export const WalletStoreModel = types
|
||||
if(!e.message.toLowerCase().includes('timeout') &&
|
||||
!e.message.toLowerCase().includes('network request failed')) {
|
||||
// remove in-flight request only if it was not a timeout or network error
|
||||
currentCounter.removeInFlightRequest(transactionId)
|
||||
Database.removeInFlightRequest(transactionId)
|
||||
}
|
||||
throw new AppError(
|
||||
Err.MINT_ERROR,
|
||||
@@ -591,7 +616,7 @@ export const WalletStoreModel = types
|
||||
|
||||
// @ts-ignore
|
||||
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
|
||||
currentCounter.addInFlightRequest(transactionId, sendParams)
|
||||
Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, sendParams)
|
||||
}
|
||||
|
||||
let reservedCounters: OperationCounters | undefined
|
||||
@@ -605,17 +630,17 @@ export const WalletStoreModel = types
|
||||
...sendParams.options,
|
||||
onCountersReserved: (info: OperationCounters) => {
|
||||
reservedCounters = info
|
||||
log.debug('[send] Counters reserved', info)
|
||||
log.debug('[WalletStore.send] Counters reserved', info)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
currentCounter.removeInFlightRequest(transactionId)
|
||||
Database.removeInFlightRequest(transactionId)
|
||||
|
||||
// Update our counter to match what the wallet used (v3.x)
|
||||
if (reservedCounters) {
|
||||
currentCounter.setProofsCounter(reservedCounters.next)
|
||||
log.debug('[send] Updated counter', {
|
||||
log.debug('[WalletStore.send] Updated counter', {
|
||||
keysetId: reservedCounters.keysetId,
|
||||
start: reservedCounters.start,
|
||||
count: reservedCounters.count,
|
||||
@@ -642,7 +667,7 @@ export const WalletStoreModel = types
|
||||
if(!e.message.toLowerCase().includes('timeout') &&
|
||||
!e.message.toLowerCase().includes('network request failed')) {
|
||||
// remove in-flight request only if it was not a timeout or network error
|
||||
currentCounter.removeInFlightRequest(transactionId)
|
||||
Database.removeInFlightRequest(transactionId)
|
||||
}
|
||||
|
||||
let message = 'Swap to prepare ecash to send has failed.'
|
||||
@@ -729,7 +754,7 @@ export const WalletStoreModel = types
|
||||
description
|
||||
})
|
||||
|
||||
log.info('[createLightningMintQuote]', {mintQuoteResponse})
|
||||
log.info('[WalletStore.createLightningMintQuote]', {mintQuoteResponse})
|
||||
|
||||
return {
|
||||
encodedInvoice: mintQuoteResponse.request,
|
||||
@@ -759,7 +784,7 @@ export const WalletStoreModel = types
|
||||
quote
|
||||
)
|
||||
|
||||
log.info('[checkLightningMintQuote]', {quoteResponse})
|
||||
log.info('[WalletStore.checkLightningMintQuote]', {quoteResponse})
|
||||
|
||||
return {
|
||||
encodedInvoice: quoteResponse.request,
|
||||
@@ -829,7 +854,7 @@ export const WalletStoreModel = types
|
||||
|
||||
// @ts-ignore
|
||||
if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) {
|
||||
currentCounter.addInFlightRequest(transactionId, mintParams)
|
||||
Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, mintParams)
|
||||
}
|
||||
|
||||
let reservedCounters: OperationCounters | undefined
|
||||
@@ -843,17 +868,17 @@ export const WalletStoreModel = types
|
||||
keysetId: mintParams.options?.keysetId,
|
||||
onCountersReserved: (info: OperationCounters) => {
|
||||
reservedCounters = info
|
||||
log.debug('[mintProofsBolt11] Counters reserved', info)
|
||||
log.debug('[cashuWallet.mintProofsBolt11] Counters reserved', info)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
currentCounter.removeInFlightRequest(transactionId)
|
||||
Database.removeInFlightRequest(transactionId)
|
||||
|
||||
// Update our counter to match what the wallet used (v3.x)
|
||||
if (reservedCounters) {
|
||||
currentCounter.setProofsCounter(reservedCounters.next)
|
||||
log.debug('[mintProofs] Updated counter', {
|
||||
log.debug('[WalletStore.mintProofs] Updated counter', {
|
||||
keysetId: reservedCounters.keysetId,
|
||||
start: reservedCounters.start,
|
||||
count: reservedCounters.count,
|
||||
@@ -861,7 +886,7 @@ export const WalletStoreModel = types
|
||||
})
|
||||
}
|
||||
|
||||
log.debug('[mintProofs]', {amount: mintParams.amount, quote: mintParams.quote, proofs})
|
||||
log.debug('[WalletStore.mintProofs]', {amount: mintParams.amount, quote: mintParams.quote, proofs})
|
||||
|
||||
return proofs
|
||||
|
||||
@@ -869,7 +894,7 @@ export const WalletStoreModel = types
|
||||
if(!e.message.toLowerCase().includes('timeout') &&
|
||||
!e.message.toLowerCase().includes('network request failed')) {
|
||||
// remove in-flight request only if it was not a timeout or network error
|
||||
currentCounter.removeInFlightRequest(transactionId)
|
||||
Database.removeInFlightRequest(transactionId)
|
||||
}
|
||||
|
||||
let message = 'Error on request to mint new ecash.'
|
||||
@@ -972,8 +997,15 @@ export const WalletStoreModel = types
|
||||
}
|
||||
)
|
||||
|
||||
// Store the MeltPreview for potential recovery
|
||||
currentCounter.addMeltCounterValue(transactionId, meltPreview)
|
||||
// Store the MeltPreview for potential recovery. Synchronous SQLite
|
||||
// write BEFORE completeMelt, so the change can always be recovered
|
||||
// even if the app dies right after the payment is submitted.
|
||||
Database.addMeltRecovery(
|
||||
transactionId,
|
||||
mintUrl,
|
||||
cashuWallet.keysetId,
|
||||
CashuUtils.serializeMeltPreview(meltPreview),
|
||||
)
|
||||
|
||||
// Update our counter to match what the wallet used (v3.x)
|
||||
if (reservedCounters) {
|
||||
@@ -992,7 +1024,7 @@ export const WalletStoreModel = types
|
||||
|
||||
// Keep the preview for PENDING async melts — handlePendingMeltTask needs it to unbind change later
|
||||
if (meltResponse.quote.state !== MeltQuoteState.PENDING) {
|
||||
currentCounter.removeMeltCounterValue(transactionId)
|
||||
Database.removeMeltRecovery(transactionId)
|
||||
}
|
||||
|
||||
log.trace('[payLightningMelt]', {meltResponse})
|
||||
@@ -1002,7 +1034,7 @@ export const WalletStoreModel = types
|
||||
if(!e.message.toLowerCase().includes('timeout') &&
|
||||
!e.message.toLowerCase().includes('network request failed')) {
|
||||
// remove only if it was not a timeout or network error
|
||||
currentCounter.removeMeltCounterValue(transactionId)
|
||||
Database.removeMeltRecovery(transactionId)
|
||||
}
|
||||
|
||||
let message = 'Lightning payment failed.'
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
} from 'mobx-state-tree'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
import type { RootStore } from '../RootStore'
|
||||
import { MMKVStorage } from '../../services'
|
||||
import { Database, MMKVStorage } from '../../services'
|
||||
import type { MeltRecoverySeed, InFlightRequestSeed, CounterSeed } from '../../services/db'
|
||||
import { log } from '../../services/logService'
|
||||
import { rootStoreModelVersion } from '../RootStore'
|
||||
import AppError, { Err } from '../../utils/AppError'
|
||||
@@ -32,7 +33,22 @@ export const ROOT_STORAGE_KEY = 'minibits-root-storage'
|
||||
* Setup the root state.
|
||||
*/
|
||||
|
||||
export async function setupRootStore(rootStore: RootStore) {
|
||||
/**
|
||||
* Lean-hydration options for the background NWC cold wake. The foreground always
|
||||
* runs a FULL setup (App mount → useInitialRootStore), and SQLite is the
|
||||
* authority, so anything skipped here is reconciled when the app is opened.
|
||||
*
|
||||
* - skipTokens: don't load the minibits JWT from keychain (NWC never uses it).
|
||||
* - skipProofs: don't bulk-load proofs into MST (nor run orphan recovery).
|
||||
* Read-only NWC commands read SQLite directly; mutating commands call
|
||||
* proofsStore.ensureProofsLoaded() on demand before selecting proofs.
|
||||
*/
|
||||
export type SetupRootStoreOptions = {
|
||||
skipTokens?: boolean
|
||||
skipProofs?: boolean
|
||||
}
|
||||
|
||||
export async function setupRootStore(rootStore: RootStore, opts: SetupRootStoreOptions = {}) {
|
||||
let restoredState: any
|
||||
let _disposer: IDisposer | undefined
|
||||
// let latestSnapshot: any
|
||||
@@ -63,35 +79,67 @@ export async function setupRootStore(rootStore: RootStore) {
|
||||
const stateHydrated = performance.now()
|
||||
log.trace(`Hydrating rooStoreModel took ${stateHydrated - mmkvLoaded} ms.`, {caller: 'setupRootStore'})
|
||||
|
||||
const {proofsStore, walletProfileStore, authStore, userSettingsStore, transactionsStore} = rootStore
|
||||
const {proofsStore, walletProfileStore, authStore, userSettingsStore, transactionsStore, mintsStore} = rootStore
|
||||
|
||||
if(walletProfileStore.walletId) {
|
||||
Sentry.setUser({ id: walletProfileStore.walletId })
|
||||
}
|
||||
|
||||
if(userSettingsStore.isOnboarded) {
|
||||
if(userSettingsStore.isOnboarded && !opts.skipTokens) {
|
||||
// hydrate auth tokens to model from keychain
|
||||
await authStore.loadTokensFromKeyChain()
|
||||
}
|
||||
const tokensLoaded = performance.now()
|
||||
|
||||
// hydrate unspent and pending ecash proofs to model from database
|
||||
await proofsStore.loadProofsFromDatabase()
|
||||
if(!opts.skipProofs) {
|
||||
await proofsStore.loadProofsFromDatabase()
|
||||
}
|
||||
const proofsHydrated = performance.now()
|
||||
|
||||
// Hydrate the in-memory derivation-counter cache from SQLite (the
|
||||
// authority) on every launch. The MMKV snapshot stores counter:0 (it is
|
||||
// stripped on save), so the real value lives only in mint_counters. The
|
||||
// one-time MMKV→SQLite copy of pre-existing counters is a migration —
|
||||
// see _runMigrations.
|
||||
mintsStore.hydrateCountersFromDatabase()
|
||||
const countersHydrated = performance.now()
|
||||
|
||||
// Roll back any orphan proof reservations from the last session.
|
||||
// An orphan is a reservation row whose owning operation died before
|
||||
// it could commit or rollback (process crash, force-quit, etc.).
|
||||
// Each orphan restores its locked proofs to their original state.
|
||||
const { recoveredCount } = proofsStore.recoverOrphanReservations()
|
||||
if (recoveredCount > 0) {
|
||||
log.warn(`[setupRootStore] Rolled back ${recoveredCount} orphan proof reservations`)
|
||||
// Bundled with proof loading: when proofs are skipped (lean NWC wake),
|
||||
// this runs on demand via proofsStore.ensureProofsLoaded() before the
|
||||
// first mutating command, and fully on the next foreground app open.
|
||||
if(!opts.skipProofs) {
|
||||
const { recoveredCount } = proofsStore.recoverOrphanReservations()
|
||||
if (recoveredCount > 0) {
|
||||
log.warn(`[setupRootStore] Rolled back ${recoveredCount} orphan proof reservations`)
|
||||
}
|
||||
}
|
||||
const orphansRecovered = performance.now()
|
||||
|
||||
// hydrate last transactions from database
|
||||
await transactionsStore.loadRecentFromDatabase()
|
||||
const txHydrated = performance.now()
|
||||
|
||||
const proofsLoaded = performance.now()
|
||||
log.trace(`Loading proofs and transactions from DB and hydrating took ${proofsLoaded - stateHydrated} ms.`, {
|
||||
caller: 'setupRootStore'
|
||||
// Cold-hydration phase breakdown. Read this off a background NWC wake to
|
||||
// see where the time goes — it drives the Stage 4 lean-hydration decision
|
||||
// (hypothesis: applySnapshot + loadProofs dominate). Emitted at info so it
|
||||
// shows without trace logging.
|
||||
log.info('[setupRootStore] cold hydration phase timings (ms)', {
|
||||
mmkvLoad: Math.round(mmkvLoaded - start),
|
||||
applySnapshot: Math.round(stateHydrated - mmkvLoaded),
|
||||
loadTokens: Math.round(tokensLoaded - stateHydrated),
|
||||
loadProofs: Math.round(proofsHydrated - tokensLoaded),
|
||||
hydrateCounters: Math.round(countersHydrated - proofsHydrated),
|
||||
recoverOrphans: Math.round(orphansRecovered - countersHydrated),
|
||||
loadRecentTx: Math.round(txHydrated - orphansRecovered),
|
||||
total: Math.round(txHydrated - start),
|
||||
proofCount: proofsStore.proofs.size,
|
||||
stateBytes: dataSize,
|
||||
caller: 'setupRootStore',
|
||||
})
|
||||
|
||||
} catch (e: any) {
|
||||
@@ -117,7 +165,7 @@ export async function setupRootStore(rootStore: RootStore) {
|
||||
log.info(`RootStore loaded from MMKV, version is: ${rootStore.version}`, {caller: 'setupRootStore'})
|
||||
|
||||
if(rootStore.version < rootStoreModelVersion) {
|
||||
await _runMigrations(rootStore)
|
||||
await _runMigrations(rootStore, restoredState)
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error(Err.STORAGE_ERROR, e.message)
|
||||
@@ -136,30 +184,114 @@ export async function setupRootStore(rootStore: RootStore) {
|
||||
* Migrations code to execute based on code and on device model version.
|
||||
*/
|
||||
|
||||
async function _runMigrations(rootStore: RootStore) {
|
||||
const {
|
||||
userSettingsStore,
|
||||
async function _runMigrations(rootStore: RootStore, restoredState: any) {
|
||||
const {
|
||||
mintsStore,
|
||||
transactionsStore,
|
||||
} = rootStore
|
||||
|
||||
let currentVersion = rootStore.version
|
||||
|
||||
const currentVersion = rootStore.version
|
||||
|
||||
try {
|
||||
log.trace(`Starting rootStore migrations from v${currentVersion} -> v${rootStoreModelVersion}`)
|
||||
|
||||
if(currentVersion < 29) {
|
||||
log.trace(`Starting rootStore migrations from version v${currentVersion} -> v29`)
|
||||
|
||||
transactionsStore.addRecentByUnit()
|
||||
|
||||
rootStore.setVersion(rootStoreModelVersion)
|
||||
log.info(`Completed rootStore migrations to the version v${rootStoreModelVersion}`, {caller: '_runMigrations'} )
|
||||
}
|
||||
|
||||
if(currentVersion < 33) {
|
||||
// One-time copy of the MMKV-resident derivation counters into SQLite
|
||||
// (the new authority for counters). Reads the LIVE MST counters,
|
||||
// which still hold the real values loaded from the pre-upgrade MMKV
|
||||
// snapshot — postProcessSnapshot strips `counter` only from saves, not
|
||||
// from the in-memory model — and persists them via a monotonic,
|
||||
// idempotent upsert (incl. counterBackups). After this, mint_counters
|
||||
// is authoritative and the every-launch hydrate in setupRootStore
|
||||
// fills the in-memory cache from it.
|
||||
mintsStore.seedCountersToDatabase()
|
||||
}
|
||||
|
||||
if(currentVersion < 34) {
|
||||
// meltCounterValues moved to SQLite (melt_recovery). The model no
|
||||
// longer holds them and applySnapshot strips them, so read straight
|
||||
// from the RAW pre-upgrade snapshot to carry over any melt that was
|
||||
// in-flight at upgrade time (usually none). Idempotent.
|
||||
const seeds: MeltRecoverySeed[] = []
|
||||
for (const mint of restoredState?.mintsStore?.mints ?? []) {
|
||||
for (const counter of mint?.proofsCounters ?? []) {
|
||||
const mcv = counter?.meltCounterValues ?? {}
|
||||
for (const key of Object.keys(mcv)) {
|
||||
const entry = mcv[key]
|
||||
if (entry?.meltPreview && typeof entry.transactionId === 'number') {
|
||||
seeds.push({
|
||||
transactionId: entry.transactionId,
|
||||
mintUrl: mint.mintUrl,
|
||||
keysetId: counter.keyset,
|
||||
meltPreview: entry.meltPreview,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (seeds.length > 0) {
|
||||
Database.seedMeltRecoveries(seeds)
|
||||
}
|
||||
}
|
||||
|
||||
if(currentVersion < 35) {
|
||||
// inFlightRequests moved to SQLite (inflight_requests). Same as the
|
||||
// melt seed above: read from the RAW pre-upgrade snapshot to carry
|
||||
// over any request in-flight at upgrade time (usually none). Idempotent.
|
||||
const seeds: InFlightRequestSeed[] = []
|
||||
for (const mint of restoredState?.mintsStore?.mints ?? []) {
|
||||
for (const counter of mint?.proofsCounters ?? []) {
|
||||
const ifr = counter?.inFlightRequests ?? {}
|
||||
for (const key of Object.keys(ifr)) {
|
||||
const entry = ifr[key]
|
||||
if (entry?.request && typeof entry.transactionId === 'number') {
|
||||
seeds.push({
|
||||
transactionId: entry.transactionId,
|
||||
mintUrl: mint.mintUrl,
|
||||
keysetId: counter.keyset,
|
||||
request: entry.request,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (seeds.length > 0) {
|
||||
Database.seedInFlightRequests(seeds)
|
||||
}
|
||||
}
|
||||
|
||||
if(currentVersion < 36) {
|
||||
// counterBackups removed: counters are now retained in SQLite across
|
||||
// mint removal. Carry over any removed-mint counters that were only
|
||||
// held in the (now-removed) counterBackups of the RAW pre-upgrade
|
||||
// snapshot, so re-adding such a mint restores its counter. Monotonic.
|
||||
const seeds: CounterSeed[] = []
|
||||
for (const backup of restoredState?.mintsStore?.counterBackups ?? []) {
|
||||
for (const c of backup?.counters ?? []) {
|
||||
if (c?.keyset && typeof c.counter === 'number' && c.counter > 0) {
|
||||
seeds.push({mintUrl: backup.mintUrl, keysetId: c.keyset, unit: c.unit, counter: c.counter})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (seeds.length > 0) {
|
||||
Database.seedCounters(seeds)
|
||||
}
|
||||
}
|
||||
|
||||
// Set once, after all steps succeed: if any step throws, the version is
|
||||
// NOT bumped and the whole migration retries on the next launch.
|
||||
rootStore.setVersion(rootStoreModelVersion)
|
||||
log.info(`Completed rootStore migrations to v${rootStoreModelVersion}`, {caller: '_runMigrations'})
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.STORAGE_ERROR,
|
||||
'Error when executing rootStore migrations',
|
||||
e.message,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -121,9 +121,8 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) {
|
||||
}
|
||||
|
||||
let exportedMintsStore: MintsStoreSnapshot = {
|
||||
mints: [],
|
||||
blockedMintUrls: [],
|
||||
counterBackups: []
|
||||
mints: [],
|
||||
blockedMintUrls: [],
|
||||
}
|
||||
|
||||
let exportedContactsStore: ContactsStoreSnapshot = {
|
||||
@@ -147,7 +146,17 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) {
|
||||
exportedMintsStore = JSON.parse(JSON.stringify(getSnapshot(mintsStore)))
|
||||
|
||||
exportedMintsStore.mints.forEach((mint: any) => {
|
||||
mint.keys = [];
|
||||
mint.keys = [];
|
||||
|
||||
// `counter` is stripped from snapshots (mastered in SQLite), so
|
||||
// re-inject the live derivation index per keyset. Exporting a
|
||||
// backup with counter 0 would risk blinded-secret reuse on the
|
||||
// recovered wallet.
|
||||
const liveMint = mintsStore.findByUrl(mint.mintUrl)
|
||||
mint.proofsCounters?.forEach((pc: any) => {
|
||||
const live = liveMint?.proofsCounters.find(c => c.keyset === pc.keyset)
|
||||
if (live) pc.counter = live.counter
|
||||
})
|
||||
})
|
||||
|
||||
//log.trace({exportedMintsStore})
|
||||
|
||||
@@ -201,17 +201,23 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
|
||||
proofsStore.pendingByMintSecrets.push(secret)
|
||||
}
|
||||
applySnapshot(mintsStore, walletSnapshot.mintsStore)
|
||||
applySnapshot(contactsStore, walletSnapshot.contactsStore)
|
||||
applySnapshot(contactsStore, walletSnapshot.contactsStore)
|
||||
|
||||
// The backup carries real derivation counters in the MST snapshot; the
|
||||
// counter is mastered in SQLite, so copy the just-imported values into
|
||||
// the mint_counters table immediately (monotonic, never lowers). Without
|
||||
// this they would only reach SQLite on the next startup seed.
|
||||
mintsStore.seedCountersToDatabase()
|
||||
|
||||
log.trace('After import and mint keys hydration', {mintsStore})
|
||||
|
||||
// import proofs into the db
|
||||
if(proofsStore.proofsCount > 0) {
|
||||
Database.addOrUpdateProofs(proofsStore.allProofs, false, false)
|
||||
Database.addOrUpdateProofs(proofsStore.allProofs, 'UNSPENT')
|
||||
}
|
||||
|
||||
|
||||
if(proofsStore.pendingProofsCount > 0) {
|
||||
Database.addOrUpdateProofs(proofsStore.allPendingProofs, true, false)
|
||||
Database.addOrUpdateProofs(proofsStore.allPendingProofs, 'PENDING')
|
||||
}
|
||||
|
||||
if(!mintsStore.mintExists(MINIBITS_MINT_URL)) {
|
||||
|
||||
@@ -97,7 +97,7 @@ export const MintInfoScreen = observer(function MintInfoScreen({ route }: Props)
|
||||
throw new AppError(Err.VALIDATION_ERROR, 'Missing mintUrl')
|
||||
}
|
||||
|
||||
log.trace('useEffect', { mintUrl: route.params.mintUrl })
|
||||
log.trace('[MintInfoScreen] useEffect', { mintUrl: route.params.mintUrl })
|
||||
|
||||
//setIsLoading(true)
|
||||
const mint = mintsStore.findByUrl(route.params.mintUrl)
|
||||
@@ -258,9 +258,20 @@ export const MintInfoScreen = observer(function MintInfoScreen({ route }: Props)
|
||||
{isLocalInfoVisible && (
|
||||
<JSONTree
|
||||
hideRoot
|
||||
data={getSnapshot(
|
||||
mintsStore.findByUrl(route.params?.mintUrl) as Mint,
|
||||
) as any}
|
||||
data={(() => {
|
||||
const m = mintsStore.findByUrl(route.params?.mintUrl) as Mint
|
||||
const snap = getSnapshot(m) as any
|
||||
// `counter` is stripped from every snapshot (it is mastered in
|
||||
// SQLite, not MMKV). Re-inject the live cache value per keyset
|
||||
// so this debug tree shows the real derivation index, not 0.
|
||||
return {
|
||||
...snap,
|
||||
proofsCounters: snap.proofsCounters?.map((c: any) => ({
|
||||
...c,
|
||||
counter: m?.proofsCounters?.find(pc => pc.keyset === c.keyset)?.counter ?? c.counter,
|
||||
})),
|
||||
}
|
||||
})() as any}
|
||||
theme={{
|
||||
scheme: 'default',
|
||||
base00: '#eee',
|
||||
@@ -310,7 +321,7 @@ function MOTDCard(props: {info: GetInfoResponse}) {
|
||||
|
||||
function MintLimitsCard(props: { info: GetInfoResponse, limitInfo: ReturnType<typeof getMintLimits> }) {
|
||||
if (props.limitInfo.mintSats === false && props.limitInfo.mintSats === false) return;
|
||||
log.trace('MintLimtsCard', props.limitInfo)
|
||||
log.trace('[MintLimitsCard]', props.limitInfo)
|
||||
|
||||
const limitText = (m: MethodLimit) => {
|
||||
const min = `${formatCurrency(m.min as number, CurrencyCode.SAT)}`
|
||||
@@ -564,7 +575,6 @@ function getMintLimits(info: GetInfoResponse) {
|
||||
// later this can be adjusted to show USD/other units as well. for now only shows limits if they are in sats
|
||||
let mintSats: false | MethodLimit = false
|
||||
let meltSats: false | MethodLimit = false
|
||||
console.log('runs')
|
||||
for (const method of info.nuts['4'].methods) {
|
||||
if ((typeof method.min_amount !== 'undefined' || typeof method.max_amount !== 'undefined') && method.unit === 'sat') {
|
||||
mintSats = {
|
||||
|
||||
@@ -205,18 +205,18 @@ export const OptimizeEcashScreen = function OptimizeEcash(_props: Props) {
|
||||
mintBalances={mintBalances}
|
||||
selectedMintBalance={selectedMintBalance}
|
||||
unit={unit}
|
||||
title="Select mint to optimize"
|
||||
title=""
|
||||
onMintBalanceSelect={onMintBalanceSelect}
|
||||
/>
|
||||
) : (
|
||||
<Card content="No mints with balance to optimize." style={$card} />
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
{/*<Text
|
||||
text={`Only denominations with more than ${OPTIMIZE_DENOMINATION_THRESHOLD} proofs can be optimized - by swapping them with the mint for lower number of ecash notes with higher amounts.`}
|
||||
preset="formHelper"
|
||||
style={[$hintText, {color: hint}]}
|
||||
/>
|
||||
/>*/}
|
||||
<Card
|
||||
ContentComponent={
|
||||
<>
|
||||
|
||||
@@ -84,7 +84,7 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!route.params?.encodedToken) {
|
||||
log.trace('nothing scanned')
|
||||
log.trace('[ReceiveScreen.useFocusEffect] No token in route params, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
|
||||
isLockedToWallet = lockedToPK === '02' + keys.NOSTR.publicKey
|
||||
}
|
||||
|
||||
log.trace('decoded tokenMetadata', {
|
||||
log.trace('[ReceiveScreen.onEncodedToken] Decoded token metadata', {
|
||||
mint: tokenInfo.mint,
|
||||
unit: tokenInfo.unit,
|
||||
amount: tokenInfo.amount.toString(),
|
||||
@@ -169,7 +169,7 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
|
||||
isLocked,
|
||||
isLockedToWallet,
|
||||
})
|
||||
log.trace('tokenAmount', {amount, unit})
|
||||
log.trace('[ReceiveScreen.onEncodedToken] Token amount', {amount, unit})
|
||||
|
||||
const currency = getCurrency(unit as MintUnit)
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
|
||||
|
||||
const decodedData = Buffer.from(decodedBuffer).toString('utf8')
|
||||
|
||||
log.trace('Scanned animated', {scanned: decodedData})
|
||||
log.trace('[ScanScreen] Scanned animated', {scanned: decodedData})
|
||||
|
||||
return onIncomingData(decodedData)
|
||||
} else {
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useStores } from '../models'
|
||||
import { MintListItem } from './Mints/MintListItem'
|
||||
import { Mint } from '../models/Mint'
|
||||
import { MintKeyset } from '@cashu/cashu-ts'
|
||||
import { CashuUtils } from '../services/cashu/cashuUtils'
|
||||
import { CashuUtils, CashuProof } from '../services/cashu/cashuUtils'
|
||||
import { Proof } from '../models/Proof'
|
||||
import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../models/Transaction'
|
||||
import { ResultModalInfo } from './Wallet/ResultModalInfo'
|
||||
@@ -289,32 +289,36 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
|
||||
|
||||
transaction = await transactionsStore.addTransaction(newTransaction)
|
||||
|
||||
const { updatedAmount: addedAmount } = proofsStore.addOrUpdate(proofStates.UNSPENT, {
|
||||
mintUrl: recoveredMint.mintUrl,
|
||||
unit: selectedKeyset.unit as MintUnit,
|
||||
tId: transaction!.id,
|
||||
state: 'UNSPENT',
|
||||
})
|
||||
recoveredAmount = amount
|
||||
|
||||
if (amount !== addedAmount) {
|
||||
transaction!.update({amount: addedAmount})
|
||||
recoveredAmount = addedAmount
|
||||
}
|
||||
const currentSpendable = proofsStore.getUnitBalance(selectedKeyset.unit as MintUnit)?.unitBalance ?? 0
|
||||
const balanceAfter = currentSpendable + amount
|
||||
|
||||
// Finally, update completed transaction
|
||||
transactionData.push({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
recoveredAmount,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
transaction!.update({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
data: JSON.stringify(transactionData)
|
||||
// Add recovered proofs + finalize the tx atomically (one
|
||||
// SQLite txn, incl. the keyset counter). No inputs locked.
|
||||
const reservation = proofsStore.reserve([], {
|
||||
transactionId: transaction!.id,
|
||||
mintUrl: recoveredMint.mintUrl,
|
||||
unit: selectedKeyset.unit as MintUnit,
|
||||
operationType: 'seed-recovery',
|
||||
rollbackTo: 'UNSPENT',
|
||||
})
|
||||
proofsStore.commitReservation(reservation, {
|
||||
newProofs: [{proofs: proofStates.UNSPENT as CashuProof[], state: 'UNSPENT', tId: transaction!.id}],
|
||||
transactionUpdate: {
|
||||
id: transaction!.id,
|
||||
status: TransactionStatus.COMPLETED,
|
||||
amount,
|
||||
balanceAfter,
|
||||
data: JSON.stringify(transactionData),
|
||||
},
|
||||
})
|
||||
|
||||
const balanceAfter = proofsStore.getUnitBalance(selectedKeyset.unit as MintUnit)?.unitBalance
|
||||
transaction!.update({balanceAfter})
|
||||
}
|
||||
|
||||
if(proofStates.PENDING.length > 0) {
|
||||
@@ -344,26 +348,29 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
|
||||
|
||||
pendingTransaction = await transactionsStore.addTransaction(newTransaction)
|
||||
|
||||
const { updatedAmount: addedAmount } = proofsStore.addOrUpdate(proofStates.PENDING, {
|
||||
mintUrl: recoveredMint.mintUrl,
|
||||
unit: selectedKeyset.unit as MintUnit,
|
||||
tId: pendingTransaction!.id,
|
||||
state: 'PENDING',
|
||||
})
|
||||
|
||||
if (pendingAmount !== addedAmount) {
|
||||
pendingTransaction!.update({amount: addedAmount})
|
||||
}
|
||||
|
||||
// Finally, update pending transaction
|
||||
pendingTransactionData.push({
|
||||
status: TransactionStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
pendingTransaction!.update({
|
||||
status: TransactionStatus.PENDING,
|
||||
data: JSON.stringify(pendingTransactionData)
|
||||
// Add recovered pending proofs + finalize the tx atomically
|
||||
// (one SQLite txn, incl. the keyset counter). No inputs locked.
|
||||
const reservation = proofsStore.reserve([], {
|
||||
transactionId: pendingTransaction!.id,
|
||||
mintUrl: recoveredMint.mintUrl,
|
||||
unit: selectedKeyset.unit as MintUnit,
|
||||
operationType: 'seed-recovery-pending',
|
||||
rollbackTo: 'PENDING',
|
||||
})
|
||||
proofsStore.commitReservation(reservation, {
|
||||
newProofs: [{proofs: proofStates.PENDING as CashuProof[], state: 'PENDING', tId: pendingTransaction!.id}],
|
||||
transactionUpdate: {
|
||||
id: pendingTransaction!.id,
|
||||
status: TransactionStatus.PENDING,
|
||||
amount: pendingAmount,
|
||||
data: JSON.stringify(pendingTransactionData),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
|
||||
useFocusEffect(useCallback(() => {
|
||||
try {
|
||||
const tx = transactionsStore.findById(id, true) // load full tokens
|
||||
log.trace('Transaction loaded', {tx})
|
||||
log.trace('[TranDetailScreen] Transaction loaded', {tx})
|
||||
|
||||
if (!tx) {
|
||||
throw new AppError(
|
||||
|
||||
@@ -104,7 +104,7 @@ export const TranHistoryScreen = observer(function TranHistoryScreen({ route }:
|
||||
setIsLoading(true)
|
||||
const countByStatus = Database.getTransactionsCount()
|
||||
|
||||
log.trace('Database transaction counts', {countByStatus})
|
||||
log.trace('[TranHistoryScreen] Database transaction counts', {countByStatus})
|
||||
|
||||
setExpiredDbCount(countByStatus[TransactionStatus.EXPIRED] || 0)
|
||||
setErroredDbCount(countByStatus[TransactionStatus.ERROR] || 0)
|
||||
|
||||
@@ -394,6 +394,14 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
|
||||
|
||||
// Real foreground – run your normal logic (e.g., unlock check, etc.)
|
||||
log.trace('[handleAppStateChange] WalletScreen active again')
|
||||
|
||||
// Reconcile derivation counters from SQLite (the authority) before
|
||||
// any foreground operation can derive proofs. A background path
|
||||
// (e.g. an NWC payment processed while backgrounded) may have
|
||||
// advanced a counter in the DB; hydration is monotonic so it only
|
||||
// ever raises the in-memory value, never lowers a live one.
|
||||
mintsStore.hydrateCountersFromDatabase()
|
||||
|
||||
performChecks()
|
||||
NostrClient.reconnectToRelays({
|
||||
hasDeviceId: !!walletProfileStore.device,
|
||||
@@ -430,7 +438,7 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
|
||||
|
||||
|
||||
const toggleSendModal = () => {
|
||||
log.trace('toggleSendModal')
|
||||
log.trace('[WalletScreen.toggleSendModal]')
|
||||
setIsSendModalVisible(previousState => !previousState)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
PaymentRequestPayload,
|
||||
TokenMetadata,
|
||||
OutputDataLike,
|
||||
MeltPreview,
|
||||
} from '@cashu/cashu-ts'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import AppError, {Err} from '../../utils/AppError'
|
||||
@@ -521,6 +522,12 @@ const deserializeOutputData = (serialized: SerializedOutputData[]): OutputData[]
|
||||
od.ephemeralE,
|
||||
))
|
||||
|
||||
/** Serialize a cashu-ts MeltPreview into the JSON-safe shape stored for recovery. */
|
||||
const serializeMeltPreview = (meltPreview: MeltPreview): StoredMeltPreview => ({
|
||||
keysetId: meltPreview.keysetId,
|
||||
outputData: serializeOutputData(meltPreview.outputData),
|
||||
})
|
||||
|
||||
export const CashuUtils = {
|
||||
findEncodedCashuToken,
|
||||
findEncodedCashuPaymentRequest,
|
||||
@@ -545,6 +552,7 @@ export const CashuUtils = {
|
||||
sumProofs,
|
||||
serializeOutputData,
|
||||
deserializeOutputData,
|
||||
serializeMeltPreview,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import {SQLBatchTuple} from './connection'
|
||||
import {getInstance} from './instance'
|
||||
import {dbError} from './errors'
|
||||
import {log} from '../logService'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Per-keyset deterministic-derivation counters.
|
||||
//
|
||||
// The `counter` is the BIP32 derivation high-water mark for a (mint, keyset)
|
||||
// pair. It was previously held only in the MST `MintProofsCounter` model and
|
||||
// persisted to MMKV via the whole-tree snapshot — a separate persistence engine
|
||||
// from the proofs the counter derives, committed at a different moment. That
|
||||
// cross-engine gap meant a crash between "counter advanced" (MMKV) and "proofs
|
||||
// written" (SQLite) could desync them and risk blinded-secret reuse.
|
||||
//
|
||||
// This repo makes SQLite the authority for the counter so the advance can later
|
||||
// be folded into the SAME transaction as the proof writes. Every write here is
|
||||
// MONOTONIC: a counter can never move backward. That single invariant is what
|
||||
// makes the MMKV→SQLite migration safe — a stale or racing writer can only ever
|
||||
// be a no-op, never a regression.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CounterRecord = {
|
||||
mintUrl: string
|
||||
keysetId: string
|
||||
unit: string | null
|
||||
counter: number
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
/** A single (mint, keyset, value) tuple for the one-time seed from MST/MMKV. */
|
||||
export type CounterSeed = {
|
||||
mintUrl: string
|
||||
keysetId: string
|
||||
unit?: string
|
||||
counter: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the monotonic counter upsert as a batch tuple, so the exact same write
|
||||
* can be used standalone (setCounter / seedCounters) or folded into another
|
||||
* transaction (the proof-commit batch in reservationsRepo). The stored value
|
||||
* only ever rises to MAX(existing, value); a lower value is a no-op.
|
||||
*/
|
||||
export const buildCounterUpsert = function (
|
||||
mintUrl: string,
|
||||
keysetId: string,
|
||||
unit: string | undefined,
|
||||
value: number,
|
||||
now: string = new Date().toISOString(),
|
||||
): SQLBatchTuple {
|
||||
return [
|
||||
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET
|
||||
counter = MAX(counter, excluded.counter),
|
||||
unit = excluded.unit,
|
||||
updatedAt = excluded.updatedAt`,
|
||||
[mintUrl, keysetId, unit ?? null, value, now],
|
||||
]
|
||||
}
|
||||
|
||||
/** Read every counter row. Used to hydrate the in-memory MST cache on startup. */
|
||||
export const getCounters = function (): CounterRecord[] {
|
||||
try {
|
||||
const db = getInstance()
|
||||
const {rows} = db.execute(`SELECT mintUrl, keysetId, unit, counter, updatedAt FROM mint_counters`)
|
||||
return (rows?._array ?? []) as CounterRecord[]
|
||||
} catch (e: any) {
|
||||
throw dbError('Counters could not be retrieved from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a single counter, or undefined when no row exists yet. */
|
||||
export const getCounter = function (
|
||||
mintUrl: string,
|
||||
keysetId: string,
|
||||
): CounterRecord | undefined {
|
||||
try {
|
||||
const db = getInstance()
|
||||
const {rows} = db.execute(
|
||||
`SELECT mintUrl, keysetId, unit, counter, updatedAt FROM mint_counters WHERE mintUrl = ? AND keysetId = ?`,
|
||||
[mintUrl, keysetId],
|
||||
)
|
||||
return rows?.item(0) as CounterRecord | undefined
|
||||
} catch (e: any) {
|
||||
throw dbError('Counter could not be retrieved from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a counter to an absolute value, MONOTONICALLY: the stored value only ever
|
||||
* rises to `MAX(existing, value)`. This is the write-back used after a cashu
|
||||
* operation reports its `next` counter, and the primitive the idempotent seed is
|
||||
* built on. A lower `value` (stale cache, replayed op) is silently ignored.
|
||||
*/
|
||||
export const setCounter = function (
|
||||
mintUrl: string,
|
||||
keysetId: string,
|
||||
unit: string | undefined,
|
||||
value: number,
|
||||
): void {
|
||||
try {
|
||||
const [sql, params] = buildCounterUpsert(mintUrl, keysetId, unit, value)
|
||||
getInstance().execute(sql, params)
|
||||
} catch (e: any) {
|
||||
throw dbError('Counter could not be saved to the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a counter by `delta` (the error-healing / increaseCounterBy path).
|
||||
* Relative, so it always moves forward by construction; an absent row starts
|
||||
* from 0 and becomes `delta`.
|
||||
*/
|
||||
export const bumpCounter = function (
|
||||
mintUrl: string,
|
||||
keysetId: string,
|
||||
unit: string | undefined,
|
||||
delta: number,
|
||||
): void {
|
||||
if (delta <= 0) return
|
||||
try {
|
||||
const db = getInstance()
|
||||
db.execute(
|
||||
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET
|
||||
counter = counter + ?,
|
||||
updatedAt = excluded.updatedAt`,
|
||||
[mintUrl, keysetId, unit ?? null, delta, new Date().toISOString(), delta],
|
||||
)
|
||||
} catch (e: any) {
|
||||
throw dbError('Counter could not be advanced in the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time, idempotent copy of the MST/MMKV counters into SQLite. Each entry is
|
||||
* applied through the same monotonic upsert as `setCounter`, so:
|
||||
* - re-running it can never lower a value (safe to call on every startup),
|
||||
* - a seed value lower than what SQLite already holds is ignored (the wallet
|
||||
* has since advanced past it),
|
||||
* - the conservative-safe direction (a too-high seed) only ever skips indices,
|
||||
* never reuses them.
|
||||
* Done in a single batch transaction.
|
||||
*/
|
||||
export const seedCounters = function (seeds: CounterSeed[]): {seeded: number} {
|
||||
if (!seeds || seeds.length === 0) return {seeded: 0}
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
const batch: SQLBatchTuple[] = seeds.map(s =>
|
||||
buildCounterUpsert(s.mintUrl, s.keysetId, s.unit, s.counter, now),
|
||||
)
|
||||
|
||||
const db = getInstance()
|
||||
db.executeBatch(batch)
|
||||
|
||||
log.info('[seedCounters]', 'Seeded derivation counters into SQLite', {count: seeds.length})
|
||||
return {seeded: seeds.length}
|
||||
} catch (e: any) {
|
||||
throw dbError('Counters could not be seeded into the database', e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {getInstance} from './instance'
|
||||
import {dbError} from './errors'
|
||||
import {log} from '../logService'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// In-flight mint/swap requests.
|
||||
//
|
||||
// Per-transaction request params for an operation that has hit the mint but
|
||||
// whose response may be lost (network failure). Written before the network call
|
||||
// so the op can be safely retried against the mint's idempotent (NUT-19 cached)
|
||||
// endpoint. Previously held on the MST MintProofsCounter; moved here so retries
|
||||
// work with no MST loaded (off-MST background).
|
||||
//
|
||||
// A row exists only while a request is in-flight; it is deleted on success or
|
||||
// terminal failure. Keyed by transactionId.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type InFlightRequestRecord = {
|
||||
transactionId: number
|
||||
mintUrl: string | null
|
||||
keysetId: string | null
|
||||
request: any
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
/** A single in-flight entry for the one-time seed from the MST/MMKV snapshot. */
|
||||
export type InFlightRequestSeed = {
|
||||
transactionId: number
|
||||
mintUrl?: string
|
||||
keysetId?: string
|
||||
request: any
|
||||
}
|
||||
|
||||
const rowToRecord = (row: any): InFlightRequestRecord => ({
|
||||
transactionId: row.transactionId,
|
||||
mintUrl: row.mintUrl,
|
||||
keysetId: row.keysetId,
|
||||
request: JSON.parse(row.request),
|
||||
createdAt: row.createdAt,
|
||||
})
|
||||
|
||||
/**
|
||||
* Store (or replace) the in-flight request for a transaction. Overwrites an
|
||||
* existing row — matching the previous addInFlightRequest set() semantics.
|
||||
*/
|
||||
export const addInFlightRequest = function (
|
||||
transactionId: number,
|
||||
mintUrl: string | undefined,
|
||||
keysetId: string | undefined,
|
||||
request: any,
|
||||
): void {
|
||||
try {
|
||||
getInstance().execute(
|
||||
`INSERT OR REPLACE INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[transactionId, mintUrl ?? null, keysetId ?? null, JSON.stringify(request), new Date().toISOString()],
|
||||
)
|
||||
} catch (e: any) {
|
||||
throw dbError('In-flight request could not be saved to the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the in-flight request for a transaction, or undefined. */
|
||||
export const getInFlightRequest = function (transactionId: number): InFlightRequestRecord | undefined {
|
||||
try {
|
||||
const {rows} = getInstance().execute(
|
||||
`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE transactionId = ?`,
|
||||
[transactionId],
|
||||
)
|
||||
const row = rows?.item(0)
|
||||
return row ? rowToRecord(row) : undefined
|
||||
} catch (e: any) {
|
||||
throw dbError('In-flight request could not be retrieved from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** All in-flight requests for a mint (drives the per-mint recovery sweep). */
|
||||
export const getInFlightRequestsByMint = function (mintUrl: string): InFlightRequestRecord[] {
|
||||
try {
|
||||
const {rows} = getInstance().execute(
|
||||
`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE mintUrl = ?`,
|
||||
[mintUrl],
|
||||
)
|
||||
return (rows?._array ?? []).map(rowToRecord)
|
||||
} catch (e: any) {
|
||||
throw dbError('In-flight requests could not be retrieved from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete the in-flight request for a transaction (success/terminal failure). */
|
||||
export const removeInFlightRequest = function (transactionId: number): void {
|
||||
try {
|
||||
getInstance().execute(`DELETE FROM inflight_requests WHERE transactionId = ?`, [transactionId])
|
||||
} catch (e: any) {
|
||||
throw dbError('In-flight request could not be removed from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time, idempotent copy of MST/MMKV-resident in-flight requests into SQLite.
|
||||
* Used by the upgrade migration to carry over a request in-flight at upgrade.
|
||||
*/
|
||||
export const seedInFlightRequests = function (seeds: InFlightRequestSeed[]): {seeded: number} {
|
||||
if (!seeds || seeds.length === 0) return {seeded: 0}
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
getInstance().executeBatch(
|
||||
seeds.map(s => [
|
||||
`INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(transactionId) DO NOTHING`,
|
||||
[s.transactionId, s.mintUrl ?? null, s.keysetId ?? null, JSON.stringify(s.request), now],
|
||||
]),
|
||||
)
|
||||
log.info('[seedInFlightRequests]', 'Seeded in-flight requests into SQLite', {count: seeds.length})
|
||||
return {seeded: seeds.length}
|
||||
} catch (e: any) {
|
||||
throw dbError('In-flight requests could not be seeded into the database', e)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
getProofById,
|
||||
getProofs,
|
||||
getProofsByTransaction,
|
||||
getMintBalanceWithMaxBalance,
|
||||
} from './proofsRepo'
|
||||
import {
|
||||
openReservation,
|
||||
@@ -44,6 +45,26 @@ import {
|
||||
rollbackReservation,
|
||||
getOpenReservations,
|
||||
} from './reservationsRepo'
|
||||
import {
|
||||
getCounters,
|
||||
getCounter,
|
||||
setCounter,
|
||||
bumpCounter,
|
||||
seedCounters,
|
||||
} from './countersRepo'
|
||||
import {
|
||||
addMeltRecovery,
|
||||
getMeltRecovery,
|
||||
removeMeltRecovery,
|
||||
seedMeltRecoveries,
|
||||
} from './meltRecoveryRepo'
|
||||
import {
|
||||
addInFlightRequest,
|
||||
getInFlightRequest,
|
||||
getInFlightRequestsByMint,
|
||||
removeInFlightRequest,
|
||||
seedInFlightRequests,
|
||||
} from './inFlightRepo'
|
||||
|
||||
export type {TransactionSearchFilters} from './transactionsRepo'
|
||||
export type {
|
||||
@@ -51,6 +72,9 @@ export type {
|
||||
ReservationRow,
|
||||
ReservationTransactionUpdate,
|
||||
} from './reservationsRepo'
|
||||
export type {CounterRecord, CounterSeed} from './countersRepo'
|
||||
export type {MeltRecoveryRecord, MeltRecoverySeed} from './meltRecoveryRepo'
|
||||
export type {InFlightRequestRecord, InFlightRequestSeed} from './inFlightRepo'
|
||||
|
||||
export const Database = {
|
||||
getInstance,
|
||||
@@ -83,8 +107,23 @@ export const Database = {
|
||||
getProofById,
|
||||
getProofs,
|
||||
getProofsByTransaction,
|
||||
getMintBalanceWithMaxBalance,
|
||||
openReservation,
|
||||
commitReservation,
|
||||
rollbackReservation,
|
||||
getOpenReservations,
|
||||
getCounters,
|
||||
getCounter,
|
||||
setCounter,
|
||||
bumpCounter,
|
||||
seedCounters,
|
||||
addMeltRecovery,
|
||||
getMeltRecovery,
|
||||
removeMeltRecovery,
|
||||
seedMeltRecoveries,
|
||||
addInFlightRequest,
|
||||
getInFlightRequest,
|
||||
getInFlightRequestsByMint,
|
||||
removeInFlightRequest,
|
||||
seedInFlightRequests,
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ export const cleanAll = function () {
|
||||
['DROP TABLE transactions'],
|
||||
['DROP TABLE proofs'],
|
||||
['DROP TABLE dbversion'],
|
||||
// IF EXISTS: these tables were added by later migrations, so a very old DB
|
||||
// may lack them; without the guard a missing table aborts the atomic batch.
|
||||
['DROP TABLE IF EXISTS reservations'],
|
||||
['DROP TABLE IF EXISTS mint_counters'],
|
||||
['DROP TABLE IF EXISTS melt_recovery'],
|
||||
['DROP TABLE IF EXISTS inflight_requests'],
|
||||
] as SQLBatchTuple[]
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {getInstance} from './instance'
|
||||
import {dbError} from './errors'
|
||||
import {log} from '../logService'
|
||||
import {StoredMeltPreview} from '../cashu/cashuUtils'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Melt recovery data.
|
||||
//
|
||||
// Per-transaction serialized `meltPreview` (the blinded change outputData) for
|
||||
// outgoing lightning payments. Written SYNCHRONOUSLY before the melt is
|
||||
// submitted so a paid-but-unconfirmed melt can always be recovered and its
|
||||
// change ecash unblinded — previously held on the MST MintProofsCounter and
|
||||
// persisted only via the debounced whole-tree MMKV snapshot, which risked
|
||||
// losing the preview (and the change) on a crash right after submission.
|
||||
//
|
||||
// A row exists only while a melt is in-flight; it is deleted on terminal
|
||||
// success/failure. Keyed by transactionId.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type MeltRecoveryRecord = {
|
||||
transactionId: number
|
||||
mintUrl: string | null
|
||||
keysetId: string | null
|
||||
meltPreview: StoredMeltPreview
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
/** A single melt-recovery entry for the one-time seed from the MST/MMKV snapshot. */
|
||||
export type MeltRecoverySeed = {
|
||||
transactionId: number
|
||||
mintUrl?: string
|
||||
keysetId?: string
|
||||
meltPreview: StoredMeltPreview
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the meltPreview for a transaction. Idempotent: the FIRST stored preview
|
||||
* for a transaction wins (ON CONFLICT DO NOTHING), matching the previous
|
||||
* addMeltCounterValue "already tracked" guard. Synchronous.
|
||||
*/
|
||||
export const addMeltRecovery = function (
|
||||
transactionId: number,
|
||||
mintUrl: string | undefined,
|
||||
keysetId: string | undefined,
|
||||
meltPreview: StoredMeltPreview,
|
||||
): void {
|
||||
try {
|
||||
getInstance().execute(
|
||||
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(transactionId) DO NOTHING`,
|
||||
[transactionId, mintUrl ?? null, keysetId ?? null, JSON.stringify(meltPreview), new Date().toISOString()],
|
||||
)
|
||||
} catch (e: any) {
|
||||
throw dbError('Melt recovery could not be saved to the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the melt-recovery entry for a transaction, or undefined. */
|
||||
export const getMeltRecovery = function (transactionId: number): MeltRecoveryRecord | undefined {
|
||||
try {
|
||||
const {rows} = getInstance().execute(
|
||||
`SELECT transactionId, mintUrl, keysetId, meltPreview, createdAt FROM melt_recovery WHERE transactionId = ?`,
|
||||
[transactionId],
|
||||
)
|
||||
const row = rows?.item(0)
|
||||
if (!row) return undefined
|
||||
return {
|
||||
transactionId: row.transactionId,
|
||||
mintUrl: row.mintUrl,
|
||||
keysetId: row.keysetId,
|
||||
meltPreview: JSON.parse(row.meltPreview) as StoredMeltPreview,
|
||||
createdAt: row.createdAt,
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw dbError('Melt recovery could not be retrieved from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete the melt-recovery entry for a transaction (terminal success/failure). */
|
||||
export const removeMeltRecovery = function (transactionId: number): void {
|
||||
try {
|
||||
getInstance().execute(`DELETE FROM melt_recovery WHERE transactionId = ?`, [transactionId])
|
||||
} catch (e: any) {
|
||||
throw dbError('Melt recovery could not be removed from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time, idempotent copy of MST/MMKV-resident melt previews into SQLite. Used
|
||||
* by the upgrade migration to carry over a melt that was in-flight at upgrade.
|
||||
*/
|
||||
export const seedMeltRecoveries = function (seeds: MeltRecoverySeed[]): {seeded: number} {
|
||||
if (!seeds || seeds.length === 0) return {seeded: 0}
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
const db = getInstance()
|
||||
db.executeBatch(
|
||||
seeds.map(s => [
|
||||
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(transactionId) DO NOTHING`,
|
||||
[s.transactionId, s.mintUrl ?? null, s.keysetId ?? null, JSON.stringify(s.meltPreview), now],
|
||||
]),
|
||||
)
|
||||
log.info('[seedMeltRecoveries]', 'Seeded melt recovery entries into SQLite', {count: seeds.length})
|
||||
return {seeded: seeds.length}
|
||||
} catch (e: any) {
|
||||
throw dbError('Melt recovery entries could not be seeded into the database', e)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import {DbConnection, SQLBatchTuple} from './connection'
|
||||
import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS} from './schema'
|
||||
import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS} from './schema'
|
||||
import {dbError} from './errors'
|
||||
import {log} from '../logService'
|
||||
|
||||
/** Bump this when a schema change requires a migration, then add an entry below. */
|
||||
export const _dbVersion = 26
|
||||
export const _dbVersion = 29
|
||||
|
||||
type Migration = {version: number; queries: SQLBatchTuple[]}
|
||||
|
||||
@@ -69,6 +69,30 @@ const MIGRATIONS: Migration[] = [
|
||||
version: 26,
|
||||
queries: [[createTable('reservations', RESERVATIONS_COLUMNS)]],
|
||||
},
|
||||
{
|
||||
// Add per-keyset derivation counters table. The table is created empty here;
|
||||
// existing counter values are copied from the MST/MMKV snapshot by a one-time
|
||||
// idempotent JS seed after rootStore hydration (see countersRepo.seedCounters).
|
||||
// The seed is monotonic (never lowers a value), so running this before the
|
||||
// seed leaves the wallet correct — counters simply read as not-yet-known and
|
||||
// are populated on first hydration.
|
||||
version: 27,
|
||||
queries: [[createTable('mint_counters', MINT_COUNTERS_COLUMNS)]],
|
||||
},
|
||||
{
|
||||
// Add per-transaction melt recovery table. Empty on creation; any in-flight
|
||||
// meltCounterValues from the MST/MMKV snapshot are copied by a one-time JS
|
||||
// seed (see setupRootStore._runMigrations).
|
||||
version: 28,
|
||||
queries: [[createTable('melt_recovery', MELT_RECOVERY_COLUMNS)]],
|
||||
},
|
||||
{
|
||||
// Add per-transaction in-flight request table. Empty on creation; any
|
||||
// in-flight requests from the MST/MMKV snapshot are copied by a one-time JS
|
||||
// seed (see setupRootStore._runMigrations).
|
||||
version: 29,
|
||||
queries: [[createTable('inflight_requests', INFLIGHT_REQUESTS_COLUMNS)]],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,9 +89,7 @@ export const addOrUpdateProofs = function (
|
||||
const {rowsAffected} = db.executeBatch(insertQueries)
|
||||
|
||||
// DO NOT log proof secrets to Sentry
|
||||
log.info('[addOrUpdateProofs]',
|
||||
`${rowsAffected} ${state} proofs were added or updated in the database`,
|
||||
)
|
||||
log.debug(`[addOrUpdateProofs] ${rowsAffected} ${state} proofs were added or updated in the database`)
|
||||
|
||||
return rowsAffected
|
||||
} catch (e: any) {
|
||||
@@ -199,3 +197,26 @@ export const getProofsByTransaction = function (transactionId: number): ProofRec
|
||||
throw dbError('Proofs could not be retrieved from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest spendable (UNSPENT) balance for a unit across mints, computed in SQL.
|
||||
* SQLite-only counterpart of the MST proofsStore.getMintBalanceWithMaxBalance —
|
||||
* the single highest-balance mint (an NWC payment is made from one mint) — so
|
||||
* read-only NWC commands (get_balance) can answer without loading the proof map.
|
||||
* Returns the balance amount (0 when there are no unspent proofs for the unit).
|
||||
*/
|
||||
export const getMintBalanceWithMaxBalance = function (unit: string): number {
|
||||
try {
|
||||
const {rows} = getInstance().execute(
|
||||
`SELECT SUM(amount) AS bal FROM proofs
|
||||
WHERE state = 'UNSPENT' AND unit = ?
|
||||
GROUP BY mintUrl
|
||||
ORDER BY bal DESC
|
||||
LIMIT 1`,
|
||||
[unit],
|
||||
)
|
||||
return (rows?.item(0)?.bal as number) ?? 0
|
||||
} catch (e: any) {
|
||||
throw dbError('Could not compute max mint balance', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {log} from '../logService'
|
||||
import {SQLBatchTuple} from './connection'
|
||||
import {getInstance} from './instance'
|
||||
import {dbError} from './errors'
|
||||
import {buildCounterUpsert} from './countersRepo'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Proof reservations (Phase 5 of refactoring).
|
||||
@@ -108,7 +109,7 @@ export const openReservation = function (
|
||||
const db = getInstance()
|
||||
db.executeBatch(batch)
|
||||
|
||||
log.info('[openReservation]', 'Reservation opened', {
|
||||
log.debug('[openReservation] Reservation opened', {
|
||||
id: reservation.id,
|
||||
transactionId: reservation.transactionId,
|
||||
lockedCount: proofsToLock.length,
|
||||
@@ -161,6 +162,22 @@ export const commitReservation = function (
|
||||
tId: number
|
||||
}>
|
||||
transactionUpdate?: ReservationTransactionUpdate
|
||||
/**
|
||||
* Per-keyset derivation counters to persist atomically with the proof
|
||||
* writes — the "W2" backstop to the write-through in Mint.persistCounter
|
||||
* ("W1"). W1 already persists this value the instant cashu derives, BEFORE
|
||||
* this commit, so on the normal path this upsert is a monotonic no-op. Its
|
||||
* job is the failure case: if W1's write was dropped (logged, not thrown),
|
||||
* folding the counter into the SAME transaction as the proofs guarantees a
|
||||
* committed proof can never outlive its counter advance — which would let the
|
||||
* next derivation reuse a blinded secret. Each upsert is monotonic.
|
||||
*/
|
||||
counterUpdate?: Array<{
|
||||
mintUrl: string
|
||||
keysetId: string
|
||||
unit?: string
|
||||
counter: number
|
||||
}>
|
||||
},
|
||||
): void {
|
||||
try {
|
||||
@@ -244,17 +261,22 @@ export const commitReservation = function (
|
||||
}
|
||||
}
|
||||
|
||||
for (const cu of changes.counterUpdate ?? []) {
|
||||
batch.push(buildCounterUpsert(cu.mintUrl, cu.keysetId, cu.unit, cu.counter, now))
|
||||
}
|
||||
|
||||
batch.push([`DELETE FROM reservations WHERE id = ?`, [reservationId]])
|
||||
|
||||
const db = getInstance()
|
||||
db.executeBatch(batch)
|
||||
|
||||
log.info('[commitReservation] ', 'Reservation committed to DB', {
|
||||
log.debug('[commitReservation] Reservation committed to DB', {
|
||||
id: reservationId,
|
||||
toSpent: changes.toSpent?.length ?? 0,
|
||||
toUnspent: changes.toUnspent?.length ?? 0,
|
||||
newGroups: changes.newProofs?.length ?? 0,
|
||||
txUpdate: changes.transactionUpdate ? changes.transactionUpdate.id : undefined,
|
||||
counterUpdates: changes.counterUpdate?.length ?? 0,
|
||||
})
|
||||
} catch (e: any) {
|
||||
throw dbError('Could not commit proof reservation', e)
|
||||
|
||||
@@ -68,6 +68,66 @@ export const RESERVATIONS_COLUMNS = `
|
||||
createdAt TEXT NOT NULL
|
||||
`
|
||||
|
||||
/**
|
||||
* Per-keyset deterministic-derivation counter (the BIP32 high-water mark).
|
||||
*
|
||||
* Authoritative store for the counter previously held only in the MST
|
||||
* `MintProofsCounter` model and persisted to MMKV via the whole-tree snapshot.
|
||||
* Moving it here lets a counter advance commit ATOMICALLY with the proofs it
|
||||
* derives (same SQLite transaction) and makes SQLite the single source of truth,
|
||||
* closing the cross-engine non-atomicity that risked blinded-secret reuse.
|
||||
*
|
||||
* Keyed by (mintUrl, keysetId): a keyset id is mint-scoped, and keying on the
|
||||
* url keeps the row addressable across mint-url edits.
|
||||
*/
|
||||
export const MINT_COUNTERS_COLUMNS = `
|
||||
mintUrl TEXT NOT NULL,
|
||||
keysetId TEXT NOT NULL,
|
||||
unit TEXT,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
updatedAt TEXT,
|
||||
PRIMARY KEY (mintUrl, keysetId)
|
||||
`
|
||||
|
||||
/**
|
||||
* Recovery data for outgoing lightning payments (melt).
|
||||
*
|
||||
* Holds the serialized `meltPreview` (the blinded change outputData) per
|
||||
* transaction, written synchronously BEFORE the melt is submitted so a paid-
|
||||
* but-unconfirmed melt can always be recovered and its change ecash unblinded —
|
||||
* previously kept on the MST MintProofsCounter (debounced MMKV), which risked
|
||||
* losing the preview (and the change) on a crash right after submission.
|
||||
*
|
||||
* Keyed by transactionId (globally unique). A row exists only while a melt is
|
||||
* in-flight; it is deleted on terminal success/failure.
|
||||
*/
|
||||
export const MELT_RECOVERY_COLUMNS = `
|
||||
transactionId INTEGER PRIMARY KEY NOT NULL,
|
||||
mintUrl TEXT,
|
||||
keysetId TEXT,
|
||||
meltPreview TEXT NOT NULL,
|
||||
createdAt TEXT
|
||||
`
|
||||
|
||||
/**
|
||||
* In-flight mint/swap request data for idempotent retry (NUT-19).
|
||||
*
|
||||
* Holds the op's request params per transaction, written before the network
|
||||
* call so a lost response can be safely retried against the mint's cached
|
||||
* (idempotent) endpoint. Previously kept on the MST MintProofsCounter; moved
|
||||
* here so retries work with no MST loaded (off-MST background).
|
||||
*
|
||||
* Keyed by transactionId. A row exists only while a request is in-flight; it is
|
||||
* deleted on success or terminal failure.
|
||||
*/
|
||||
export const INFLIGHT_REQUESTS_COLUMNS = `
|
||||
transactionId INTEGER PRIMARY KEY NOT NULL,
|
||||
mintUrl TEXT,
|
||||
keysetId TEXT,
|
||||
request TEXT NOT NULL,
|
||||
createdAt TEXT
|
||||
`
|
||||
|
||||
/** Build a CREATE TABLE statement from a column block. */
|
||||
export const createTable = (
|
||||
name: string,
|
||||
@@ -88,4 +148,13 @@ export const createSchemaQueries: SQLBatchTuple[] = [
|
||||
// is in-flight (between reserve() and commit()/rollback()). Orphans (process
|
||||
// died mid-operation) are detected and rolled back at startup.
|
||||
[createTable('reservations', RESERVATIONS_COLUMNS)],
|
||||
// Per-keyset deterministic-derivation counters. Seeded from the MST/MMKV
|
||||
// counters on first run after this migration (see countersRepo).
|
||||
[createTable('mint_counters', MINT_COUNTERS_COLUMNS)],
|
||||
// Per-transaction melt recovery data (serialized meltPreview). A row exists
|
||||
// only while an outgoing lightning payment is in-flight (see meltRecoveryRepo).
|
||||
[createTable('melt_recovery', MELT_RECOVERY_COLUMNS)],
|
||||
// Per-transaction in-flight mint/swap request data for idempotent retry
|
||||
// (see inFlightRepo). A row exists only while a request is in-flight.
|
||||
[createTable('inflight_requests', INFLIGHT_REQUESTS_COLUMNS)],
|
||||
]
|
||||
|
||||
@@ -469,7 +469,7 @@ export const addTransactionAsync = async function (tx: Partial<Transaction>): Pr
|
||||
const db = getInstance()
|
||||
const result = await db.executeAsync(query, params)
|
||||
|
||||
log.info('[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, status})
|
||||
|
||||
return getTransactionById(result.insertId as number) // already normalized
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ const saveWalletKeys = async function (
|
||||
if (result) {
|
||||
const keys: WalletKeys = JSON.parse(result.password)
|
||||
|
||||
log.trace('[getWalletKeys]', 'Returning walletKeys from KeyChain', {keys})
|
||||
log.trace('[getWalletKeys] Returning walletKeys from KeyChain', {walletId: keys.walletId})
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
+54
-14
@@ -59,23 +59,59 @@ const levelToSentry: Record<LogLevel, Sentry.SeverityLevel> = {
|
||||
[LogLevel.ERROR]: 'error',
|
||||
}
|
||||
|
||||
const safeStringify = (msg: any): string => {
|
||||
try {
|
||||
if (msg === null || msg === undefined) return 'null'
|
||||
if (typeof msg === 'string') return msg
|
||||
if (msg instanceof Error) return `${msg.name}: ${msg.message}\n${msg.stack || ''}`
|
||||
return JSON.stringify(msg, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2)
|
||||
} catch {
|
||||
return '[Unserializable]'
|
||||
}
|
||||
}
|
||||
|
||||
const redactSensitive = (text: string): string => {
|
||||
if (!text) return text
|
||||
return text
|
||||
.replace(/lnurl\w{50,}/gi, 'lnurl[redacted]')
|
||||
.replace(/nsec1[ac-hj-np-z02-9]{58,}/g, 'nsec1[redacted]')
|
||||
.replace(/cashuB[ac-hj-np-z02-9]{58,}/g, 'cashuB[redacted]')
|
||||
.replace(/lnurl1?[ac-hj-np-z02-9]{30,}/gi, 'lnurl[redacted]')
|
||||
.replace(/nsec1[ac-hj-np-z02-9]{20,}/gi, 'nsec1[redacted]')
|
||||
// cashu tokens are base64url (v4 CBOR / v3 JSON), not bech32
|
||||
.replace(/cashu[AB][A-Za-z0-9_-]{40,}/g, 'cashu[redacted]')
|
||||
// JWTs (header.payload.signature)
|
||||
.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[jwt redacted]')
|
||||
}
|
||||
|
||||
// Object keys whose values must never be logged in cleartext (case-insensitive).
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
'seed', 'seedhash', 'mnemonic',
|
||||
'privatekey', 'privkey', 'nsec', 'password',
|
||||
'accesstoken', 'refreshtoken', 'token', 'tokens', 'inputtoken', 'outputtoken',
|
||||
'secret', 'sig', 'signature', 'dleq', 'c',
|
||||
])
|
||||
|
||||
const REDACT_MAX_DEPTH = 6
|
||||
|
||||
// Recursively redact sensitive keys and string patterns from a params object.
|
||||
const redactParams = (value: any, depth = 0, seen = new WeakSet<object>()): any => {
|
||||
if (value === null || value === undefined) return value
|
||||
if (typeof value === 'bigint') return value.toString()
|
||||
if (typeof value === 'string') return redactSensitive(value)
|
||||
if (typeof value !== 'object') return value
|
||||
if (seen.has(value)) return '[Circular]'
|
||||
if (depth >= REDACT_MAX_DEPTH) return '[Truncated]'
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) return value.map((v) => redactParams(v, depth + 1, seen))
|
||||
|
||||
const out: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[redacted]' : redactParams(v, depth + 1, seen)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Insert a space after a leading "[Tag]" when the author forgot one, e.g. "[getInstance]MMKV…".
|
||||
const fixTagSpacing = (s: string): string => s.replace(/^(\[[^\]]+\])(?=\S)/, '$1 ')
|
||||
|
||||
const safeStringify = (msg: any): string => {
|
||||
try {
|
||||
if (msg === null || msg === undefined) return 'null'
|
||||
if (typeof msg === 'string') return redactSensitive(fixTagSpacing(msg))
|
||||
if (msg instanceof Error) return `${msg.name}: ${redactSensitive(msg.message)}\n${msg.stack || ''}`
|
||||
// Leading space guarantees separation from the preceding message arg.
|
||||
return ' ' + JSON.stringify(redactParams(msg), null, 2)
|
||||
} catch {
|
||||
return '[Unserializable]'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +167,10 @@ const customSentryTransport: transportFunctionType<TransportOptions> = async (pr
|
||||
params = rawMessage
|
||||
}
|
||||
|
||||
// Normalize readability + strip secrets before anything leaves the device.
|
||||
message = fixTagSpacing(message)
|
||||
params = redactParams(params)
|
||||
|
||||
//console.log(`[${level}] ${message}`, params)
|
||||
|
||||
// === ERROR LEVEL: Send real exception with full context ===
|
||||
|
||||
@@ -362,14 +362,14 @@ const getExchangeRate = async function (currency: CurrencyCode) {
|
||||
jwtAuthRequired: true
|
||||
}) as {currency: CurrencyCode, rate: number}
|
||||
|
||||
log.info(`[getExchangeRate] Got response`, rate)
|
||||
log.debug(`[getExchangeRate] Got response`, rate)
|
||||
|
||||
return rate
|
||||
}
|
||||
|
||||
|
||||
const fetchApi = async (url: string, options: MinibitsRequestOptions, timeout = 15000) => { //ms
|
||||
log.info('Start', {url, caller: 'fetchApi'})
|
||||
log.debug('[fetchApi] HTTP request', {url})
|
||||
|
||||
const controller = new AbortController()
|
||||
const body = options.body ? JSON.stringify(options.body) : undefined
|
||||
|
||||
@@ -13,6 +13,7 @@ import { rootStoreInstance, setupRootStore } from '../models'
|
||||
import { LISTEN_FOR_NWC_EVENTS, NwcRequest, nwcPngUrl } from '../models/NwcStore';
|
||||
import { HANDLE_NWC_REQUEST_TASK, WalletTask, WalletTaskResult } from './walletService'
|
||||
import { SyncQueue } from './syncQueueService'
|
||||
import EventEmitter from '../utils/eventEmitter'
|
||||
import { delay } from '../utils/delay'
|
||||
import TaskQueue, { Task, TaskId, TaskStatus } from 'taskon'
|
||||
import { minibitsPngIcon } from '../components/MinibitsIcon'
|
||||
@@ -259,16 +260,30 @@ const _receiveToLnurlHandler = async function(remoteData: NotifyReceiveToLnurlDa
|
||||
}
|
||||
|
||||
|
||||
const _nwcRequestHandler = async function(remoteData: NotifyNwcRequestData) {
|
||||
log.trace('[_nwcRequestHandler] start')
|
||||
const _nwcRequestHandler = async function(remoteData: NotifyNwcRequestData) {
|
||||
log.trace('[_nwcRequestHandler] start')
|
||||
|
||||
const {requestEvent} = remoteData.data
|
||||
const {nwcStore} = rootStoreInstance
|
||||
|
||||
if(nwcStore.all.length === 0) {
|
||||
await setupRootStore(rootStoreInstance)
|
||||
|
||||
if(nwcStore.all.length === 0) {
|
||||
// Lean cold-wake hydration: skip the keychain JWT (NWC never uses it) and
|
||||
// the bulk proof load. Read-only commands (get_balance) read SQLite;
|
||||
// mutating commands call proofsStore.ensureProofsLoaded() on demand. The
|
||||
// foreground app-open runs a FULL setup and SQLite is authoritative, so
|
||||
// anything skipped here is reconciled when the user opens the app.
|
||||
await setupRootStore(rootStoreInstance, { skipTokens: true, skipProofs: true })
|
||||
}
|
||||
|
||||
// Process the command carried IN the push payload directly — no need to wait
|
||||
// for the WebSocket listener to re-fetch it from the relay. This dedup-marks
|
||||
// the event, so the follow-up listener started below skips it. Must run
|
||||
// BEFORE createNwcListenerNotification so the same event can't be processed
|
||||
// twice.
|
||||
nwcStore.receivePushedEvent(requestEvent)
|
||||
|
||||
// Keep a short-lived listener open for any follow-up commands that arrive in
|
||||
// the next window without their own push (faster than push round-trips).
|
||||
await createNwcListenerNotification()
|
||||
|
||||
}
|
||||
@@ -397,19 +412,49 @@ const createNwcListenerNotification = async function () {
|
||||
if(Platform.OS === 'ios') {
|
||||
nwcStore.listenForNwcEvents()
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
|
||||
// Adaptive lifetime: close ~8s after the SyncQueue goes idle (no NWC command
|
||||
// queued or running), capped at 30s. An in-flight task keeps the window open
|
||||
// (never closes mid-pay_invoice) and each follow-up command extends it. This
|
||||
// trims the idle tail versus flatly holding the foreground service / WS
|
||||
// subscription for the full 30s after a single quick command.
|
||||
const startedAt = Date.now()
|
||||
const HARD_CAP_MS = 30 * 1000
|
||||
const IDLE_GRACE_MS = 10 * 1000
|
||||
let lastBusyAt = Date.now()
|
||||
|
||||
const closeListener = async () => {
|
||||
// Tell any in-flight NWC pay_invoice that the background context is going
|
||||
// away, so it can stop waiting for its async melt result (no separate
|
||||
// timeout needed — the wait is bound to this listener's lifetime).
|
||||
EventEmitter.emit('ev_nwcListenerClosing', undefined)
|
||||
|
||||
if(Platform.OS === 'android') {
|
||||
log.trace('[createNwcListenerNotification] Terminating Android foreground service after timeout')
|
||||
// this should close the sub
|
||||
log.trace('[createNwcListenerNotification] Terminating Android foreground service')
|
||||
await stopForegroundService()
|
||||
} else {
|
||||
log.trace('[createNwcListenerNotification] Closing iOS nwcSubscription after timeout')
|
||||
log.trace('[createNwcListenerNotification] Closing iOS nwcSubscription')
|
||||
nwcStore.resetSubscription()
|
||||
cancelNotification(listenerNotification)
|
||||
}
|
||||
}, 30 * 1000)
|
||||
|
||||
}
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
const inFlight = SyncQueue.getSyncQueue().getAllTasksDetails(['idle', 'running']).length
|
||||
const now = Date.now()
|
||||
if (inFlight > 0) {
|
||||
lastBusyAt = now
|
||||
}
|
||||
const idleFor = now - lastBusyAt
|
||||
const elapsed = now - startedAt
|
||||
|
||||
if (elapsed >= HARD_CAP_MS || (inFlight === 0 && idleFor >= IDLE_GRACE_MS)) {
|
||||
clearInterval(poll)
|
||||
log.trace('[createNwcListenerNotification] Closing NWC listener', {elapsed, idleFor, inFlight})
|
||||
await closeListener()
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const addPrioritizedTask = function <T>(
|
||||
): Promise<T> {
|
||||
const queue = getSyncQueue()
|
||||
|
||||
log.info(`Adding new high priority task ${taskId} to the queue`)
|
||||
log.debug(`[addPrioritizedTask] Queued high-priority task ${taskId}`)
|
||||
|
||||
const promise = queue.addPrioritizedTask(
|
||||
task,
|
||||
@@ -67,9 +67,11 @@ const addPrioritizedTask = function <T>(
|
||||
|
||||
// retrieve result of wallet transaction by listening to ev_taskFuncion event
|
||||
const _handleTaskResult = async (taskId: TaskId, result: WalletTaskResult | TransactionTaskResult) => {
|
||||
log.info(
|
||||
`[_handleTaskResult] The result of task ${taskId}`, result
|
||||
)
|
||||
log.info(`[_handleTaskResult] Task ${taskId} finished`, {
|
||||
taskFunction: result.taskFunction,
|
||||
message: result.message,
|
||||
})
|
||||
log.trace('[_handleTaskResult] Full result', result)
|
||||
|
||||
EventEmitter.emit(`ev_${result.taskFunction}_result`, result)
|
||||
|
||||
|
||||
@@ -53,6 +53,12 @@ export type WalletEvents = {
|
||||
status: TransactionStatus
|
||||
message: string
|
||||
}
|
||||
|
||||
// Emitted by NotificationService when the NWC listener / foreground service
|
||||
// is torn down (adaptive idle close or 30s hard cap). Lets an in-flight NWC
|
||||
// pay_invoice stop waiting for its async melt exactly when the background
|
||||
// context is going away, instead of using a separate timeout.
|
||||
ev_nwcListenerClosing: void
|
||||
}
|
||||
|
||||
declare module '../../utils/eventEmitter' {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {isAlive} from 'mobx-state-tree'
|
||||
import {getEncodedToken, normalizeProofAmounts} from '@cashu/cashu-ts'
|
||||
import {log} from '../../logService'
|
||||
import {Database} from '../../sqlite'
|
||||
import {CashuUtils} from '../../cashu/cashuUtils'
|
||||
import {rootStoreInstance} from '../../../models'
|
||||
import {Mint} from '../../../models/Mint'
|
||||
@@ -30,15 +31,12 @@ const {
|
||||
*/
|
||||
const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> => {
|
||||
const mintUrl = mint.mintUrl
|
||||
const countersWithInFlight = mint.proofsCountersWithInFlightRequests || []
|
||||
const inFlightRequests = Database.getInFlightRequestsByMint(mintUrl)
|
||||
const totalRequests = inFlightRequests.length
|
||||
|
||||
log.trace('[handleInFlightByMintTask] start', {
|
||||
mintUrl,
|
||||
counters: countersWithInFlight?.length,
|
||||
totalRequests: mint.allInFlightRequests?.length ?? 0,
|
||||
})
|
||||
log.trace('[handleInFlightByMintTask] start', {mintUrl, totalRequests})
|
||||
|
||||
if (countersWithInFlight.length === 0) {
|
||||
if (totalRequests === 0) {
|
||||
return {
|
||||
taskFunction: HANDLE_INFLIGHT_BY_MINT_TASK,
|
||||
mintUrl,
|
||||
@@ -48,17 +46,11 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
|
||||
|
||||
const errors: string[] = []
|
||||
|
||||
for (const counter of countersWithInFlight) {
|
||||
for (const inFlight of counter.allInFlightRequests) {
|
||||
|
||||
if (!isAlive(inFlight)) {
|
||||
log.error('[handleInFlightByMintTask]', 'InFlightRequest is not alive', {mintUrl})
|
||||
continue
|
||||
}
|
||||
for (const inFlight of inFlightRequests) {
|
||||
|
||||
const tx = transactionsStore.findById(inFlight.transactionId)
|
||||
if (!tx) {
|
||||
counter.removeInFlightRequest(inFlight.transactionId)
|
||||
Database.removeInFlightRequest(inFlight.transactionId)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -66,7 +58,7 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
|
||||
// the tx): nothing to recover. Drop the lingering in-flight request so it isn't
|
||||
// retried on every sweep.
|
||||
if (tx.status === TransactionStatus.COMPLETED || tx.status === TransactionStatus.REVERTED) {
|
||||
counter.removeInFlightRequest(inFlight.transactionId)
|
||||
Database.removeInFlightRequest(inFlight.transactionId)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -90,25 +82,33 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
|
||||
{inFlightRequest: inFlight},
|
||||
)
|
||||
|
||||
const {updatedAmount: receivedAmount} = proofsStore.addOrUpdate(proofs, {
|
||||
mintUrl,
|
||||
tId: tx.id,
|
||||
unit,
|
||||
state: 'UNSPENT',
|
||||
})
|
||||
|
||||
const receivedAmount = CashuUtils.getProofsAmount(proofs)
|
||||
const outputToken = getEncodedToken({mint: mintUrl, proofs: normalizeProofAmounts(proofs), unit})
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
|
||||
const balanceAfter = currentSpendable + receivedAmount
|
||||
|
||||
txData.push({status: TransactionStatus.COMPLETED, receivedAmount, swapFeePaid, createdAt: new Date()})
|
||||
|
||||
tx.update({
|
||||
amount: receivedAmount,
|
||||
status: TransactionStatus.COMPLETED,
|
||||
data: JSON.stringify(txData),
|
||||
outputToken,
|
||||
balanceAfter,
|
||||
fee: swapFeePaid > 0 ? swapFeePaid : tx.fee,
|
||||
// Add received proofs + complete the tx atomically (one
|
||||
// SQLite txn, incl. the keyset counter). No inputs locked.
|
||||
const reservation = proofsStore.reserve([], {
|
||||
transactionId: tx.id,
|
||||
mintUrl,
|
||||
unit,
|
||||
operationType: 'receive-retry',
|
||||
rollbackTo: 'UNSPENT',
|
||||
})
|
||||
proofsStore.commitReservation(reservation, {
|
||||
newProofs: [{proofs, state: 'UNSPENT', tId: tx.id}],
|
||||
transactionUpdate: {
|
||||
id: tx.id,
|
||||
amount: receivedAmount,
|
||||
status: TransactionStatus.COMPLETED,
|
||||
data: JSON.stringify(txData),
|
||||
outputToken,
|
||||
balanceAfter,
|
||||
fee: swapFeePaid > 0 ? swapFeePaid : tx.fee,
|
||||
},
|
||||
})
|
||||
|
||||
break
|
||||
@@ -214,23 +214,31 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
|
||||
{inFlightRequest: inFlight},
|
||||
)
|
||||
|
||||
proofsStore.addOrUpdate(proofs, {
|
||||
mintUrl,
|
||||
tId: tx.id,
|
||||
unit,
|
||||
state: 'UNSPENT',
|
||||
})
|
||||
const recoveredAmount = CashuUtils.getProofsAmount(proofs)
|
||||
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
|
||||
const balanceAfter = currentSpendable + recoveredAmount
|
||||
|
||||
stopPolling(`handlePendingTopupPoller-${tx.paymentId}`)
|
||||
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
|
||||
txData.push({status: TransactionStatus.COMPLETED, createdAt: new Date()})
|
||||
|
||||
tx.update({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
data: JSON.stringify(txData),
|
||||
balanceAfter,
|
||||
// Add minted proofs + complete the tx atomically (one
|
||||
// SQLite txn, incl. the keyset counter). No inputs locked.
|
||||
const reservation = proofsStore.reserve([], {
|
||||
transactionId: tx.id,
|
||||
mintUrl,
|
||||
unit,
|
||||
operationType: 'topup-retry',
|
||||
rollbackTo: 'UNSPENT',
|
||||
})
|
||||
proofsStore.commitReservation(reservation, {
|
||||
newProofs: [{proofs, state: 'UNSPENT', tId: tx.id}],
|
||||
transactionUpdate: {
|
||||
id: tx.id,
|
||||
status: TransactionStatus.COMPLETED,
|
||||
data: JSON.stringify(txData),
|
||||
balanceAfter,
|
||||
},
|
||||
})
|
||||
|
||||
break
|
||||
@@ -248,7 +256,7 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
|
||||
log.error('[handleInFlightByMintTask] Unknown tx type', {type: tx.type, tId: tx.id})
|
||||
}
|
||||
|
||||
counter.removeInFlightRequest(inFlight.transactionId)
|
||||
Database.removeInFlightRequest(inFlight.transactionId)
|
||||
|
||||
} catch (e: any) {
|
||||
log.error(`[handleInFlightByMintTask] ${tx.type} failed`, {
|
||||
@@ -258,10 +266,9 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
|
||||
})
|
||||
errors.push(`${tx.type} tId=${tx.id}: ${e.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalProcessed = mint.allInFlightRequests?.length ?? 0
|
||||
const totalProcessed = totalRequests
|
||||
|
||||
return {
|
||||
taskFunction: HANDLE_INFLIGHT_BY_MINT_TASK,
|
||||
@@ -279,8 +286,8 @@ const handleInFlightQueue = async function (): Promise<void> {
|
||||
|
||||
for (const mint of mintsStore.allMints) {
|
||||
|
||||
if (mint.proofsCountersWithInFlightRequests.length === 0) {
|
||||
log.trace('No proofCounters with inFlight requests, skipping...')
|
||||
if (Database.getInFlightRequestsByMint(mint.mintUrl).length === 0) {
|
||||
log.trace('No inFlight requests for mint, skipping...')
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getEncodedToken,
|
||||
} from '@cashu/cashu-ts'
|
||||
import {log} from '../../logService'
|
||||
import {Database} from '../../sqlite'
|
||||
import {translate} from '../../../i18n'
|
||||
import {MintError, ValidationError} from '../../../utils/AppError'
|
||||
import EventEmitter from '../../../utils/eventEmitter'
|
||||
@@ -93,10 +94,7 @@ const recoverMeltQuoteChange = async (
|
||||
|
||||
switch (state) {
|
||||
case MeltQuoteState.UNPAID:
|
||||
if (tx.keysetId) {
|
||||
const currentCounter = mintInstance.getProofsCounterByKeysetId!(tx.keysetId)
|
||||
currentCounter.removeMeltCounterValue(tx.id)
|
||||
}
|
||||
Database.removeMeltRecovery(tx.id)
|
||||
|
||||
throw new ValidationError(`Melt quote ${meltQuote} was not paid`)
|
||||
|
||||
@@ -115,20 +113,19 @@ const recoverMeltQuoteChange = async (
|
||||
throw new ValidationError('Missing keysetId on transaction', {meltQuote})
|
||||
}
|
||||
|
||||
const currentCounter = mintInstance.getProofsCounterByKeysetId!(tx.keysetId)
|
||||
const meltCounterValue = currentCounter?.getMeltCounterValue(tx.id)
|
||||
const meltRecovery = Database.getMeltRecovery(tx.id)
|
||||
|
||||
if (!meltCounterValue?.meltPreview) {
|
||||
if (!meltRecovery?.meltPreview) {
|
||||
throw new ValidationError('MeltPreview not found – this transaction may be from an older version', {meltQuote})
|
||||
}
|
||||
|
||||
const meltPreview = meltCounterValue.meltPreview
|
||||
const meltPreview = meltRecovery.meltPreview
|
||||
const cashuWallet = await walletStore.getWallet(mintUrl, unit, {withSeed: true, keysetId: meltPreview.keysetId})
|
||||
const keyset = cashuWallet.getKeyset(meltPreview.keysetId)
|
||||
|
||||
const reconstructedOutputData = CashuUtils.deserializeOutputData(meltPreview.outputData)
|
||||
const recoveredChange = change.map((sig, i) => reconstructedOutputData[i].toProof(sig, keyset))
|
||||
currentCounter.removeMeltCounterValue(tx.id)
|
||||
Database.removeMeltRecovery(tx.id)
|
||||
|
||||
const newChange = recoveredChange.filter(proof => !proofsStore.alreadyExists(proof))
|
||||
|
||||
@@ -136,14 +133,9 @@ const recoverMeltQuoteChange = async (
|
||||
throw new MintError(`No new ecash proofs to recover from melt quote ${meltQuoteResponse.quote}, ${recoveredChange.length} proofs already in wallet.`)
|
||||
}
|
||||
|
||||
const {updatedAmount: recoveredAmount} = proofsStore.addOrUpdate(newChange, {
|
||||
mintUrl,
|
||||
unit,
|
||||
tId: tx.id,
|
||||
state: 'UNSPENT',
|
||||
})
|
||||
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
const recoveredAmount = CashuUtils.getProofsAmount(newChange)
|
||||
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
|
||||
const balanceAfter = currentSpendable + recoveredAmount
|
||||
const outputToken = getEncodedToken({mint: mintUrl, proofs: newChange, unit})
|
||||
|
||||
txData.push({
|
||||
@@ -152,12 +144,26 @@ const recoverMeltQuoteChange = async (
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
tx.update({
|
||||
status: TransactionStatus.RECOVERED,
|
||||
amount: recoveredAmount,
|
||||
balanceAfter,
|
||||
outputToken,
|
||||
data: JSON.stringify(txData),
|
||||
// Add the recovered change + finalize the tx atomically (one
|
||||
// SQLite txn, incl. the keyset counter). No inputs are locked
|
||||
// here (the melt already spent them), so rollback is a no-op.
|
||||
const reservation = proofsStore.reserve([], {
|
||||
transactionId: tx.id,
|
||||
mintUrl,
|
||||
unit,
|
||||
operationType: 'melt-change-recover',
|
||||
rollbackTo: 'UNSPENT',
|
||||
})
|
||||
proofsStore.commitReservation(reservation, {
|
||||
newProofs: [{proofs: newChange, state: 'UNSPENT', tId: tx.id}],
|
||||
transactionUpdate: {
|
||||
id: tx.id,
|
||||
status: TransactionStatus.RECOVERED,
|
||||
amount: recoveredAmount,
|
||||
balanceAfter,
|
||||
outputToken,
|
||||
data: JSON.stringify(txData),
|
||||
},
|
||||
})
|
||||
|
||||
log.debug('[recoverMeltQuoteChange] Success', {meltQuote, recoveredAmount})
|
||||
@@ -212,10 +218,9 @@ const unblindPendingMeltChange = async function (params: {
|
||||
const mintInstance = mintsStore.findByUrl(mintUrl)
|
||||
if (!mintInstance || !transaction.keysetId) return {change: []}
|
||||
|
||||
const currentCounter = mintInstance.getProofsCounterByKeysetId!(transaction.keysetId)
|
||||
const meltCounterValue = currentCounter?.getMeltCounterValue(transaction.id)
|
||||
const meltRecovery = Database.getMeltRecovery(transaction.id)
|
||||
|
||||
if (!meltCounterValue?.meltPreview || !quoteChange?.length) {
|
||||
if (!meltRecovery?.meltPreview || !quoteChange?.length) {
|
||||
return {change: []}
|
||||
}
|
||||
|
||||
@@ -225,7 +230,7 @@ const unblindPendingMeltChange = async function (params: {
|
||||
changeCount: quoteChange.length,
|
||||
})
|
||||
|
||||
const {meltPreview} = meltCounterValue
|
||||
const {meltPreview} = meltRecovery
|
||||
const cashuWallet = await walletStore.getWallet(mintUrl, unit, {
|
||||
withSeed: true,
|
||||
keysetId: meltPreview.keysetId,
|
||||
@@ -237,7 +242,7 @@ const unblindPendingMeltChange = async function (params: {
|
||||
|
||||
log.trace('[handlePendingMelt] Change unblinded', {transactionId: transaction.id, quoteId, change})
|
||||
|
||||
currentCounter.removeMeltCounterValue(transaction.id)
|
||||
Database.removeMeltRecovery(transaction.id)
|
||||
|
||||
return {change}
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -222,23 +222,32 @@ const recoverMintQuote = async (
|
||||
throw new MintError('Mint returned no proofs to recover')
|
||||
}
|
||||
|
||||
const {updatedAmount: recoveredAmount} = proofsStore.addOrUpdate(proofs, {
|
||||
mintUrl,
|
||||
unit,
|
||||
tId: tx.id,
|
||||
state: 'UNSPENT',
|
||||
})
|
||||
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
const recoveredAmount = CashuUtils.getProofsAmount(proofs)
|
||||
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
|
||||
const balanceAfter = currentSpendable + recoveredAmount
|
||||
|
||||
txData.push({status: TransactionStatus.RECOVERED, recoveredAmount, createdAt: new Date()})
|
||||
|
||||
tx.update({
|
||||
status: TransactionStatus.RECOVERED,
|
||||
amount: recoveredAmount,
|
||||
keysetId: proofs[0].id,
|
||||
balanceAfter,
|
||||
data: JSON.stringify(txData),
|
||||
// Add the recovered proofs + finalize the tx atomically (one SQLite
|
||||
// txn, incl. the keyset counter). No input proofs are locked, so the
|
||||
// empty reservation's rollback is a no-op.
|
||||
const reservation = proofsStore.reserve([], {
|
||||
transactionId: tx.id,
|
||||
mintUrl,
|
||||
unit,
|
||||
operationType: 'topup-recover',
|
||||
rollbackTo: 'UNSPENT',
|
||||
})
|
||||
proofsStore.commitReservation(reservation, {
|
||||
newProofs: [{proofs, state: 'UNSPENT', tId: tx.id}],
|
||||
transactionUpdate: {
|
||||
id: tx.id,
|
||||
status: TransactionStatus.RECOVERED,
|
||||
amount: recoveredAmount,
|
||||
keysetId: proofs[0].id,
|
||||
balanceAfter,
|
||||
data: JSON.stringify(txData),
|
||||
},
|
||||
})
|
||||
|
||||
log.debug('[recoverMintQuote] Success', {mintUrl, mintQuote, recoveredAmount})
|
||||
|
||||
@@ -64,7 +64,7 @@ const extractZapSenderData = function (str: string) {
|
||||
}
|
||||
|
||||
const handleClaimQueue = async function (): Promise<void> {
|
||||
log.info('[handleClaimQueue] start')
|
||||
log.debug('[handleClaimQueue] start')
|
||||
const {isOwnProfile} = walletProfileStore
|
||||
|
||||
if (isOwnProfile) {
|
||||
|
||||
@@ -416,7 +416,6 @@ async function execute(
|
||||
proofsToMeltFrom,
|
||||
proofsToMeltFromAmount,
|
||||
meltFeeReserve,
|
||||
nwcEvent,
|
||||
} = prepared
|
||||
|
||||
tx.update({status: TransactionStatus.EXECUTING})
|
||||
@@ -429,7 +428,12 @@ async function execute(
|
||||
meltQuote,
|
||||
proofsToMeltFrom,
|
||||
tx.id,
|
||||
{preferAsync: nwcEvent ? false : true},
|
||||
// Always async — including NWC. The mint ACKs immediately and the
|
||||
// monitor finalizes on settlement, so the background isn't held for
|
||||
// the lightning round-trip. NWC pay_invoice waits a bounded time for
|
||||
// the preimage (see NwcStore.payInvoice); zaps confirm via the NIP-57
|
||||
// receipt regardless.
|
||||
{preferAsync: true},
|
||||
)
|
||||
} catch (e: any) {
|
||||
if (WalletUtils.shouldHealOutputsError(e)) {
|
||||
@@ -443,7 +447,7 @@ async function execute(
|
||||
meltQuote,
|
||||
proofsToMeltFrom,
|
||||
tx.id,
|
||||
{increaseCounterBy: 10, preferAsync: nwcEvent ? false : true},
|
||||
{increaseCounterBy: 10, preferAsync: true},
|
||||
)
|
||||
} catch (e2: any) {
|
||||
return _handleExecuteError(e2, {
|
||||
@@ -994,14 +998,13 @@ async function _unblindMeltChange(params: {
|
||||
const mintInstance = mintsStore.findByUrl(mintUrl)
|
||||
if (!mintInstance || !transaction.keysetId) return {change: []}
|
||||
|
||||
const currentCounter = mintInstance.getProofsCounterByKeysetId!(transaction.keysetId)
|
||||
const meltCounterValue = currentCounter?.getMeltCounterValue(transaction.id)
|
||||
const meltRecovery = Database.getMeltRecovery(transaction.id)
|
||||
|
||||
if (!meltCounterValue?.meltPreview || !quoteChange?.length) {
|
||||
if (!meltRecovery?.meltPreview || !quoteChange?.length) {
|
||||
return {change: []}
|
||||
}
|
||||
|
||||
const {meltPreview} = meltCounterValue
|
||||
const {meltPreview} = meltRecovery
|
||||
const cashuWallet = await walletStore.getWallet(mintUrl, unit, {
|
||||
withSeed: true,
|
||||
keysetId: meltPreview.keysetId,
|
||||
@@ -1019,7 +1022,7 @@ async function _unblindMeltChange(params: {
|
||||
change,
|
||||
})
|
||||
|
||||
currentCounter.removeMeltCounterValue(transaction.id)
|
||||
Database.removeMeltRecovery(transaction.id)
|
||||
return {change}
|
||||
} catch (e: any) {
|
||||
log.error(
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ export const poller = async (
|
||||
const {interval, maxPolls, maxErrors} = config
|
||||
|
||||
pollers.set(name, true); // Add poller to the Map
|
||||
log.info('Starting new poller', {name, numOfPollers: pollers.size})
|
||||
log.debug('[startPolling] Starting new poller', {name, numOfPollers: pollers.size})
|
||||
|
||||
while (pollers.get(name) && pollCount < maxPolls && errorCount < maxErrors) {
|
||||
try {
|
||||
@@ -38,7 +38,7 @@ export const poller = async (
|
||||
|
||||
export const stopPolling = (name: string) => {
|
||||
pollers.delete(name) // Remove poller from the Map
|
||||
log.info('Removing poller', {name, numOfPollers: pollers.size})
|
||||
log.debug('[stopPolling] Removing poller', {name, numOfPollers: pollers.size})
|
||||
}
|
||||
|
||||
export const pollerExists = (name: string) => {
|
||||
|
||||
Reference in New Issue
Block a user