diff --git a/__tests__/counters.test.ts b/__tests__/counters.test.ts new file mode 100644 index 0000000..0e8ce40 --- /dev/null +++ b/__tests__/counters.test.ts @@ -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() + }) + }) +}) diff --git a/__tests__/inFlightRequests.test.ts b/__tests__/inFlightRequests.test.ts new file mode 100644 index 0000000..7cd32a9 --- /dev/null +++ b/__tests__/inFlightRequests.test.ts @@ -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() + }) +}) diff --git a/__tests__/meltRecovery.test.ts b/__tests__/meltRecovery.test.ts new file mode 100644 index 0000000..3609565 --- /dev/null +++ b/__tests__/meltRecovery.test.ts @@ -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() + }) +}) diff --git a/index.js b/index.js index 0a7a13c..e8d24e4 100644 --- a/index.js +++ b/index.js @@ -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) { diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c8ed394..c7abcdb 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 diff --git a/ios/minibits_wallet.xcodeproj/project.pbxproj b/ios/minibits_wallet.xcodeproj/project.pbxproj index 711af65..28f9a45 100644 --- a/ios/minibits_wallet.xcodeproj/project.pbxproj +++ b/ios/minibits_wallet.xcodeproj/project.pbxproj @@ -8,13 +8,13 @@ /* Begin PBXBuildFile section */ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 1CB4C58DE9411DB18C458776 /* Pods_minibits_wallet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6D6043B9F78BD43EB0653482 /* Pods_minibits_wallet.framework */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; 902F38392D92C792005DB998 /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 902F38382D92C792005DB998 /* OpenSSL.xcframework */; }; 9044DC252D95937D00F918A6 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9044DC242D95937D00F918A6 /* GoogleService-Info.plist */; }; A1B9A6F7058C4F12AB677FD4 /* nostr.png in Resources */ = {isa = PBXBuildFile; fileRef = AB8A46AD84194C2583FF9193 /* nostr.png */; }; A2E4F8C1D3B547A890123456 /* HammersmithOne-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = B3D7A2E5F1C648B9A0234567 /* HammersmithOne-Regular.ttf */; }; - CD7DC745CFA9F47C2D6FFA1D /* Pods_minibits_wallet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4ACC08249CA13DFDBBBD7A8B /* Pods_minibits_wallet.framework */; }; D610B97F2318E8AB9D0962D2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; /* End PBXBuildFile section */ @@ -23,14 +23,14 @@ 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = minibits_wallet/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = minibits_wallet/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = minibits_wallet/PrivacyInfo.xcprivacy; sourceTree = ""; }; - 3B4392A12AC88292D35C810B /* Pods-minibits_wallet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-minibits_wallet.debug.xcconfig"; path = "Target Support Files/Pods-minibits_wallet/Pods-minibits_wallet.debug.xcconfig"; sourceTree = ""; }; - 4ACC08249CA13DFDBBBD7A8B /* Pods_minibits_wallet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_minibits_wallet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 5709B34CF0A7D63546082F79 /* Pods-minibits_wallet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-minibits_wallet.release.xcconfig"; path = "Target Support Files/Pods-minibits_wallet/Pods-minibits_wallet.release.xcconfig"; sourceTree = ""; }; + 6D6043B9F78BD43EB0653482 /* Pods_minibits_wallet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_minibits_wallet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = minibits_wallet/AppDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = minibits_wallet/LaunchScreen.storyboard; sourceTree = ""; }; - 902F38382D92C792005DB998 /* OpenSSL.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:67RAULRX93:Marcin Krzyzanowski"; lastKnownFileType = wrapper.xcframework; name = OpenSSL.xcframework; path = "Pods/OpenSSL-Universal/OpenSSL.xcframework"; sourceTree = ""; }; + 902F38382D92C792005DB998 /* OpenSSL.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:C88F57F4TJ:Goodnotes Limited"; lastKnownFileType = wrapper.xcframework; name = OpenSSL.xcframework; path = "Pods/OpenSSL-Universal/OpenSSL.xcframework"; sourceTree = ""; }; 9044DC232D9556F600F918A6 /* minibits_wallet.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = minibits_wallet.entitlements; path = minibits_wallet/minibits_wallet.entitlements; sourceTree = ""; }; 9044DC242D95937D00F918A6 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "minibits_wallet/GoogleService-Info.plist"; sourceTree = ""; }; + 9F9CE019CFD105099F4B725F /* Pods-minibits_wallet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-minibits_wallet.debug.xcconfig"; path = "Target Support Files/Pods-minibits_wallet/Pods-minibits_wallet.debug.xcconfig"; sourceTree = ""; }; + A1CE6181922C80EE57A17389 /* Pods-minibits_wallet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-minibits_wallet.release.xcconfig"; path = "Target Support Files/Pods-minibits_wallet/Pods-minibits_wallet.release.xcconfig"; sourceTree = ""; }; AB8A46AD84194C2583FF9193 /* nostr.png */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = nostr.png; path = ../assets/icons/nostr.png; sourceTree = ""; }; B3D7A2E5F1C648B9A0234567 /* HammersmithOne-Regular.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "HammersmithOne-Regular.ttf"; path = "../assets/fonts/HammersmithOne-Regular.ttf"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; @@ -42,7 +42,7 @@ buildActionMask = 2147483647; files = ( 902F38392D92C792005DB998 /* OpenSSL.xcframework in Frameworks */, - CD7DC745CFA9F47C2D6FFA1D /* Pods_minibits_wallet.framework in Frameworks */, + 1CB4C58DE9411DB18C458776 /* Pods_minibits_wallet.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -67,7 +67,7 @@ children = ( 902F38382D92C792005DB998 /* OpenSSL.xcframework */, ED297162215061F000B7C4FE /* JavaScriptCore.framework */, - 4ACC08249CA13DFDBBBD7A8B /* Pods_minibits_wallet.framework */, + 6D6043B9F78BD43EB0653482 /* Pods_minibits_wallet.framework */, ); name = Frameworks; sourceTree = ""; @@ -115,8 +115,8 @@ BBD78D7AC51CEA395F1C20DB /* Pods */ = { isa = PBXGroup; children = ( - 3B4392A12AC88292D35C810B /* Pods-minibits_wallet.debug.xcconfig */, - 5709B34CF0A7D63546082F79 /* Pods-minibits_wallet.release.xcconfig */, + 9F9CE019CFD105099F4B725F /* Pods-minibits_wallet.debug.xcconfig */, + A1CE6181922C80EE57A17389 /* Pods-minibits_wallet.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -128,14 +128,14 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "minibits_wallet" */; buildPhases = ( - C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */, + 548DF4FA70E097B337BFA942 /* [CP] Check Pods Manifest.lock */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, - 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */, - E235C05ADACE081382539298 /* [CP] Copy Pods Resources */, - CAF33BFCFEA2C4036AACB2C1 /* [CP-User] [RNFB] Core Configuration */, + 261FFE96B80048E02E7CB975 /* [CP] Embed Pods Frameworks */, + AA2E61D171EAFB1E9F1F2C89 /* [CP] Copy Pods Resources */, + 49D0FC1683601FED48985DFD /* [CP-User] [RNFB] Core Configuration */, ); buildRules = ( ); @@ -211,7 +211,7 @@ shellPath = /bin/sh; shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; }; - 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */ = { + 261FFE96B80048E02E7CB975 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -228,7 +228,20 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-minibits_wallet/Pods-minibits_wallet-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = { + 49D0FC1683601FED48985DFD /* [CP-User] [RNFB] Core Configuration */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", + ); + name = "[CP-User] [RNFB] Core Configuration"; + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"note: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"note: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"note: -> RNFB build script started\"\necho \"note: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"note: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"note: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n if ! _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\"); then\n echo \"error: Failed to parse firebase.json, check for syntax errors.\"\n exit 1\n fi\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"error: python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_analytics_storage\n _ANALYTICS_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_analytics_storage\")\n if [[ $_ANALYTICS_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_ANALYTICS_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_storage\n _ANALYTICS_AD_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_storage\")\n if [[ $_ANALYTICS_AD_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_user_data\n _ANALYTICS_AD_USER_DATA=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_user_data\")\n if [[ $_ANALYTICS_AD_USER_DATA ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_USER_DATA\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_USER_DATA\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.analytics_registration_with_ad_network_enabled\n _ANALYTICS_REGISTRATION_WITH_AD_NETWORK=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_registration_with_ad_network_enabled\")\n if [[ $_ANALYTICS_REGISTRATION_WITH_AD_NETWORK ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_REGISTRATION_WITH_AD_NETWORK_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_REGISTRATION_WITH_AD_NETWORK\")\")\n fi\n\n # config.google_analytics_automatic_screen_reporting_enabled\n _ANALYTICS_AUTO_SCREEN_REPORTING=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_automatic_screen_reporting_enabled\")\n if [[ $_ANALYTICS_AUTO_SCREEN_REPORTING ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAutomaticScreenReportingEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_SCREEN_REPORTING\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"note: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"note: <- RNFB build script finished\"\n"; + }; + 548DF4FA70E097B337BFA942 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -250,20 +263,7 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - CAF33BFCFEA2C4036AACB2C1 /* [CP-User] [RNFB] Core Configuration */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", - ); - name = "[CP-User] [RNFB] Core Configuration"; - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"note: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"note: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"note: -> RNFB build script started\"\necho \"note: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"note: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"note: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n if ! _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\"); then\n echo \"error: Failed to parse firebase.json, check for syntax errors.\"\n exit 1\n fi\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"error: python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_analytics_storage\n _ANALYTICS_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_analytics_storage\")\n if [[ $_ANALYTICS_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_ANALYTICS_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_storage\n _ANALYTICS_AD_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_storage\")\n if [[ $_ANALYTICS_AD_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_user_data\n _ANALYTICS_AD_USER_DATA=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_user_data\")\n if [[ $_ANALYTICS_AD_USER_DATA ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_USER_DATA\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_USER_DATA\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.analytics_registration_with_ad_network_enabled\n _ANALYTICS_REGISTRATION_WITH_AD_NETWORK=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_registration_with_ad_network_enabled\")\n if [[ $_ANALYTICS_REGISTRATION_WITH_AD_NETWORK ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_REGISTRATION_WITH_AD_NETWORK_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_REGISTRATION_WITH_AD_NETWORK\")\")\n fi\n\n # config.google_analytics_automatic_screen_reporting_enabled\n _ANALYTICS_AUTO_SCREEN_REPORTING=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_automatic_screen_reporting_enabled\")\n if [[ $_ANALYTICS_AUTO_SCREEN_REPORTING ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAutomaticScreenReportingEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_SCREEN_REPORTING\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"note: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"note: <- RNFB build script finished\"\n"; - }; - E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = { + AA2E61D171EAFB1E9F1F2C89 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -296,7 +296,7 @@ /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-minibits_wallet.debug.xcconfig */; + baseConfigurationReference = 9F9CE019CFD105099F4B725F /* Pods-minibits_wallet.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIconGolden; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -332,7 +332,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-minibits_wallet.release.xcconfig */; + baseConfigurationReference = A1CE6181922C80EE57A17389 /* Pods-minibits_wallet.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIconGolden; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; diff --git a/ios/minibits_wallet.xcodeproj/xcshareddata/xcschemes/minibits_wallet.xcscheme b/ios/minibits_wallet.xcodeproj/xcshareddata/xcschemes/minibits_wallet.xcscheme index 3be76ef..797020b 100644 --- a/ios/minibits_wallet.xcodeproj/xcshareddata/xcschemes/minibits_wallet.xcscheme +++ b/ios/minibits_wallet.xcodeproj/xcshareddata/xcschemes/minibits_wallet.xcscheme @@ -31,7 +31,7 @@ shouldAutocreateTestPlan = "YES"> ( // 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( 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( }, []) 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)) diff --git a/src/models/AuthStore.ts b/src/models/AuthStore.ts index 8d94002..467da49 100644 --- a/src/models/AuthStore.ts +++ b/src/models/AuthStore.ts @@ -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'}) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index ae69996..09dfd21 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -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 = { request: TRequest } -const InFlightRequestModel = types.model('InFlightRequest', { - transactionId: types.number, - request: types.frozen(), // 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()), - 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 = {} - - 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(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(), '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[] { - return Array.from(self.inFlightRequests.values()) - }, - // === Melt counter values === - meltCounterValueExists(transactionId: number): boolean { - return self.meltCounterValues.has(transactionId.toString()) - }, - getMeltCounterValue(transactionId: number): Instance | undefined { - return self.meltCounterValues.get(transactionId.toString()) - }, - get meltCounterValueCount(): number { - return self.meltCounterValues.size - }, - get allMeltCounterValues(): Instance[] { - 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 @@ -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 diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index 5f839c8..1aef2ea 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -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') diff --git a/src/models/NwcStore.ts b/src/models/NwcStore.ts index 30426db..86ea346 100644 --- a/src/models/NwcStore.ts +++ b/src/models/NwcStore.ts @@ -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' ? `${self.name} - 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) diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 8607f6f..cbb3665 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -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() - - 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() + 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() - 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 }, diff --git a/src/models/RootStore.ts b/src/models/RootStore.ts index cdfe650..7fe172a 100644 --- a/src/models/RootStore.ts +++ b/src/models/RootStore.ts @@ -11,7 +11,7 @@ import {NwcStoreModel} from './NwcStore' import {AuthStoreModel} from './AuthStore' import { log } from '../services' -export const rootStoreModelVersion = 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. */ diff --git a/src/models/TransactionsStore.ts b/src/models/TransactionsStore.ts index 336ba27..462f7cc 100644 --- a/src/models/TransactionsStore.ts +++ b/src/models/TransactionsStore.ts @@ -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) diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 958964e..bdf0942 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -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 | 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 => { + 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.' diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index 5aa9dea..ae89412 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -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, - ) + ) } } diff --git a/src/screens/ExportBackupScreen.tsx b/src/screens/ExportBackupScreen.tsx index 18c1a64..2107d4e 100644 --- a/src/screens/ExportBackupScreen.tsx +++ b/src/screens/ExportBackupScreen.tsx @@ -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}) diff --git a/src/screens/ImportBackupScreen.tsx b/src/screens/ImportBackupScreen.tsx index 7044341..6729a3e 100644 --- a/src/screens/ImportBackupScreen.tsx +++ b/src/screens/ImportBackupScreen.tsx @@ -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)) { diff --git a/src/screens/MintInfoScreen.tsx b/src/screens/MintInfoScreen.tsx index 32f559f..46b58a9 100644 --- a/src/screens/MintInfoScreen.tsx +++ b/src/screens/MintInfoScreen.tsx @@ -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 && ( { + 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 }) { 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 = { diff --git a/src/screens/OptimizeEcashScreen.tsx b/src/screens/OptimizeEcashScreen.tsx index 0264e68..4b14745 100644 --- a/src/screens/OptimizeEcashScreen.tsx +++ b/src/screens/OptimizeEcashScreen.tsx @@ -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} /> ) : ( )} - + />*/} diff --git a/src/screens/ReceiveScreen.tsx b/src/screens/ReceiveScreen.tsx index de7d334..a943d75 100644 --- a/src/screens/ReceiveScreen.tsx +++ b/src/screens/ReceiveScreen.tsx @@ -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) diff --git a/src/screens/ScanScreen.tsx b/src/screens/ScanScreen.tsx index c743765..3d8fa6f 100644 --- a/src/screens/ScanScreen.tsx +++ b/src/screens/ScanScreen.tsx @@ -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 { diff --git a/src/screens/SeedRecoveryScreen.tsx b/src/screens/SeedRecoveryScreen.tsx index 2fd0998..fd9be4d 100644 --- a/src/screens/SeedRecoveryScreen.tsx +++ b/src/screens/SeedRecoveryScreen.tsx @@ -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), + }, }) } } diff --git a/src/screens/TranDetailScreen.tsx b/src/screens/TranDetailScreen.tsx index a4c4a3c..e23eaac 100644 --- a/src/screens/TranDetailScreen.tsx +++ b/src/screens/TranDetailScreen.tsx @@ -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( diff --git a/src/screens/TranHistoryScreen.tsx b/src/screens/TranHistoryScreen.tsx index efee322..34e12f0 100644 --- a/src/screens/TranHistoryScreen.tsx +++ b/src/screens/TranHistoryScreen.tsx @@ -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) diff --git a/src/screens/WalletScreen.tsx b/src/screens/WalletScreen.tsx index 1964e3c..9b07e4b 100644 --- a/src/screens/WalletScreen.tsx +++ b/src/screens/WalletScreen.tsx @@ -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) } diff --git a/src/services/cashu/cashuUtils.ts b/src/services/cashu/cashuUtils.ts index e7b3ad5..aa1c304 100644 --- a/src/services/cashu/cashuUtils.ts +++ b/src/services/cashu/cashuUtils.ts @@ -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, } diff --git a/src/services/db/countersRepo.ts b/src/services/db/countersRepo.ts new file mode 100644 index 0000000..b2c698b --- /dev/null +++ b/src/services/db/countersRepo.ts @@ -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) + } +} diff --git a/src/services/db/inFlightRepo.ts b/src/services/db/inFlightRepo.ts new file mode 100644 index 0000000..db1003b --- /dev/null +++ b/src/services/db/inFlightRepo.ts @@ -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) + } +} diff --git a/src/services/db/index.ts b/src/services/db/index.ts index 607473a..448c597 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -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, } diff --git a/src/services/db/instance.ts b/src/services/db/instance.ts index 105a47e..b134f07 100644 --- a/src/services/db/instance.ts +++ b/src/services/db/instance.ts @@ -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 { diff --git a/src/services/db/meltRecoveryRepo.ts b/src/services/db/meltRecoveryRepo.ts new file mode 100644 index 0000000..9c2f928 --- /dev/null +++ b/src/services/db/meltRecoveryRepo.ts @@ -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) + } +} diff --git a/src/services/db/migrations.ts b/src/services/db/migrations.ts index 4b43a84..66613f6 100644 --- a/src/services/db/migrations.ts +++ b/src/services/db/migrations.ts @@ -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)]], + }, ] /** diff --git a/src/services/db/proofsRepo.ts b/src/services/db/proofsRepo.ts index 4738411..0b6cfd7 100644 --- a/src/services/db/proofsRepo.ts +++ b/src/services/db/proofsRepo.ts @@ -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) + } +} diff --git a/src/services/db/reservationsRepo.ts b/src/services/db/reservationsRepo.ts index 524e53f..0ffd611 100644 --- a/src/services/db/reservationsRepo.ts +++ b/src/services/db/reservationsRepo.ts @@ -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) diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index b407f4b..e9a0fb7 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -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)], ] diff --git a/src/services/db/transactionsRepo.ts b/src/services/db/transactionsRepo.ts index 5dfe294..9b0d4a8 100644 --- a/src/services/db/transactionsRepo.ts +++ b/src/services/db/transactionsRepo.ts @@ -469,7 +469,7 @@ export const addTransactionAsync = async function (tx: Partial): 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 diff --git a/src/services/keyChain.ts b/src/services/keyChain.ts index 6a2af5d..c2b03f3 100644 --- a/src/services/keyChain.ts +++ b/src/services/keyChain.ts @@ -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 } diff --git a/src/services/logService.ts b/src/services/logService.ts index bb844a6..58453f1 100644 --- a/src/services/logService.ts +++ b/src/services/logService.ts @@ -59,23 +59,59 @@ const levelToSentry: Record = { [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()): 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 = {} + 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 = 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 === diff --git a/src/services/minibitsService.ts b/src/services/minibitsService.ts index cba94de..489422e 100644 --- a/src/services/minibitsService.ts +++ b/src/services/minibitsService.ts @@ -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 diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 4f864ed..7b1ae0e 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -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) + } diff --git a/src/services/syncQueueService.ts b/src/services/syncQueueService.ts index cd23ddd..2403262 100644 --- a/src/services/syncQueueService.ts +++ b/src/services/syncQueueService.ts @@ -50,7 +50,7 @@ const addPrioritizedTask = function ( ): Promise { 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 ( // 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) diff --git a/src/services/wallet/events.ts b/src/services/wallet/events.ts index 63b052d..1600787 100644 --- a/src/services/wallet/events.ts +++ b/src/services/wallet/events.ts @@ -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' { diff --git a/src/services/wallet/operations/inFlightOperations.ts b/src/services/wallet/operations/inFlightOperations.ts index 9d54f3d..8bd2d00 100644 --- a/src/services/wallet/operations/inFlightOperations.ts +++ b/src/services/wallet/operations/inFlightOperations.ts @@ -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 => { 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 = 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 = // 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 = {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 = {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 = 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 = }) 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 { 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 } diff --git a/src/services/wallet/operations/meltOperations.ts b/src/services/wallet/operations/meltOperations.ts index 0a1a941..063492c 100644 --- a/src/services/wallet/operations/meltOperations.ts +++ b/src/services/wallet/operations/meltOperations.ts @@ -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) { diff --git a/src/services/wallet/operations/mintOperations.ts b/src/services/wallet/operations/mintOperations.ts index b2e66de..6ac54e6 100644 --- a/src/services/wallet/operations/mintOperations.ts +++ b/src/services/wallet/operations/mintOperations.ts @@ -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}) diff --git a/src/services/wallet/operations/nostrOperations.ts b/src/services/wallet/operations/nostrOperations.ts index 28a6d0a..744b9d7 100644 --- a/src/services/wallet/operations/nostrOperations.ts +++ b/src/services/wallet/operations/nostrOperations.ts @@ -64,7 +64,7 @@ const extractZapSenderData = function (str: string) { } const handleClaimQueue = async function (): Promise { - log.info('[handleClaimQueue] start') + log.debug('[handleClaimQueue] start') const {isOwnProfile} = walletProfileStore if (isOwnProfile) { diff --git a/src/services/wallet/operations/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index eeb4263..406fd64 100644 --- a/src/services/wallet/operations/transferOperationApi.ts +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -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( diff --git a/src/utils/poller.ts b/src/utils/poller.ts index bf857ca..abe9b7a 100644 --- a/src/utils/poller.ts +++ b/src/utils/poller.ts @@ -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) => {