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:
minibits-cash
2026-06-07 22:24:03 +02:00
co-authored by Claude Opus 4.8
51 changed files with 2257 additions and 795 deletions
+340
View File
@@ -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()
})
})
})
+142
View File
@@ -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()
})
})
+152
View File
@@ -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()
})
})
+6 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+3 -3
View File
@@ -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))
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 },
+1 -1
View File
@@ -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.
*/
+1 -1
View File
@@ -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
View File
@@ -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.'
+155 -23
View File
@@ -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,
)
)
}
}
+13 -4
View File
@@ -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})
+10 -4
View File
@@ -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)) {
+16 -6
View File
@@ -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 = {
+3 -3
View File
@@ -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={
<>
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -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 {
+39 -32
View File
@@ -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),
},
})
}
}
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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)
+9 -1
View File
@@ -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)
}
+8
View File
@@ -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,
}
+164
View File
@@ -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)
}
}
+120
View File
@@ -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)
}
}
+39
View File
@@ -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,
}
+6
View File
@@ -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 {
+111
View File
@@ -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)
}
}
+26 -2
View File
@@ -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)]],
},
]
/**
+24 -3
View File
@@ -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)
}
}
+24 -2
View File
@@ -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)
+69
View File
@@ -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)],
]
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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 ===
+2 -2
View File
@@ -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
+57 -12
View File
@@ -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)
}
+6 -4
View File
@@ -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)
+6
View File
@@ -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
View File
@@ -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) => {