From 2b422ee569bfcc5d3a38c0c41246f096c1dc3ddc Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Wed, 3 Jun 2026 23:13:48 +0200 Subject: [PATCH 01/25] feat(db): add mint_counters table + countersRepo (step 1) Introduce a SQLite-authoritative store for per-keyset deterministic derivation counters, previously held only in the MST MintProofsCounter model and persisted to MMKV via the whole-tree snapshot. - schema: new mint_counters table, PK (mintUrl, keysetId) - migration v27: creates the table (empty; JS seed comes in step 2) - countersRepo: getCounters/getCounter + monotonic setCounter, relative bumpCounter, and idempotent batched seedCounters - facade: expose on Database.* with CounterRecord/CounterSeed types Every write is monotonic (counter = MAX(existing, new)), the invariant that makes the upcoming MMKV->SQLite seed and atomic write-back safe. No existing code reads/writes the table yet; behavior is unchanged. Co-Authored-By: Claude Opus 4.8 --- src/services/db/countersRepo.ts | 154 ++++++++++++++++++++++++++++++++ src/services/db/index.ts | 13 +++ src/services/db/migrations.ts | 14 ++- src/services/db/schema.ts | 24 +++++ 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 src/services/db/countersRepo.ts diff --git a/src/services/db/countersRepo.ts b/src/services/db/countersRepo.ts new file mode 100644 index 0000000..621c12f --- /dev/null +++ b/src/services/db/countersRepo.ts @@ -0,0 +1,154 @@ +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 +} + +/** 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 db = getInstance() + db.execute( + `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, new Date().toISOString()], + ) + } 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 => [ + `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`, + [s.mintUrl, s.keysetId, s.unit ?? null, 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/index.ts b/src/services/db/index.ts index 607473a..4623239 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -44,6 +44,13 @@ import { rollbackReservation, getOpenReservations, } from './reservationsRepo' +import { + getCounters, + getCounter, + setCounter, + bumpCounter, + seedCounters, +} from './countersRepo' export type {TransactionSearchFilters} from './transactionsRepo' export type { @@ -51,6 +58,7 @@ export type { ReservationRow, ReservationTransactionUpdate, } from './reservationsRepo' +export type {CounterRecord, CounterSeed} from './countersRepo' export const Database = { getInstance, @@ -87,4 +95,9 @@ export const Database = { commitReservation, rollbackReservation, getOpenReservations, + getCounters, + getCounter, + setCounter, + bumpCounter, + seedCounters, } diff --git a/src/services/db/migrations.ts b/src/services/db/migrations.ts index 4b43a84..23e7540 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} 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 = 27 type Migration = {version: number; queries: SQLBatchTuple[]} @@ -69,6 +69,16 @@ 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)]], + }, ] /** diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index b407f4b..0c17d5d 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -68,6 +68,27 @@ 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) +` + /** Build a CREATE TABLE statement from a column block. */ export const createTable = ( name: string, @@ -88,4 +109,7 @@ 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)], ] From a8caa2a21fa7f21a9b9e6e43000fef5324c11fd3 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Wed, 3 Jun 2026 23:22:05 +0200 Subject: [PATCH 02/25] feat(counters): write-through + seed + startup hydrate (step 2) Make SQLite the runtime authority for derivation counters while keeping the MST counter as the in-memory cache every call site already reads. - Mint: increaseProofsCounter/setProofsCounter now write through to the countersRepo (relative bump / monotonic set). A new hydrateCounterFromDb loads the authoritative value into the cache without writing back. decreaseProofsCounter stays MST-only (no callers; lowering is the safe direction the monotonic store ignores anyway). - MintsStore: seedCountersToDatabase (idempotent MMKV->SQLite copy, incl. counterBackups) and hydrateCountersFromDatabase (SQLite -> cache). - setupRootStore: seed then hydrate on startup, after proofs load. Both directions monotonic, so order-safe and a no-op after the first copy. Counter still persists in the MMKV snapshot for now (kept identical via write-through); stripping it is deferred to the counterBackups migration to avoid breaking getSnapshot(proofsCounters). No behavior change for the existing MST-driven flows. Co-Authored-By: Claude Opus 4.8 --- src/models/Mint.ts | 57 +++++++++++++++++++++++++++- src/models/MintsStore.ts | 48 +++++++++++++++++++++-- src/models/helpers/setupRootStore.ts | 9 ++++- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index ae69996..2e92b65 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -8,7 +8,7 @@ import { 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' @@ -100,6 +100,44 @@ const migrateSnapshot = (snapshot: any): any => { } +/** + * Write a counter mutation through to the SQLite authority. + * + * `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). + * + * Failures are swallowed deliberately: a detached instance (e.g. a counter + * created for a CounterBackup, not yet attached to a Mint) has no parent, and a + * transient DB error must never break a wallet flow. The value stays in the MST + * cache and startup hydration reconciles from SQLite. Step 4 folds this write + * into the proof-commit transaction to make it atomic. + */ +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 (e.g. CounterBackup) — 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, @@ -197,9 +235,10 @@ export const MintProofsCounterModel = types } }, - // === 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, @@ -207,6 +246,8 @@ export const MintProofsCounterModel = types }, decreaseProofsCounter(numberOfProofs: number) { + // Lowering is deliberately NOT written through: the SQLite counter is + // monotonic by design. No external caller uses this (recovery only). self.counter = Math.max(0, self.counter - numberOfProofs) log.trace('[decreaseProofsCounter]', 'Decreased proofsCounter', { numberOfProofs, @@ -216,10 +257,22 @@ export const MintProofsCounterModel = types setProofsCounter(newCounter: number) { self.counter = newCounter + persistCounter(self, 'set', newCounter) log.debug('[setProofsCounter]', 'Set proofsCounter', { counter: self.counter, }) }, + + /** + * 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 + } + }, })) .views(self => ({ // === In-flight requests === diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index 5f839c8..b850b4e 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -10,7 +10,9 @@ import { } from 'mobx-state-tree' import {withSetPropAction} from './helpers/withSetPropAction' import {MintModel, Mint, MintProofsCounter, MintProofsCounterModel} from './Mint' - import {log} from '../services/logService' + 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, @@ -91,19 +93,59 @@ export const MintsStoreModel = types 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) } }) } }, + /** + * One-time, idempotent copy of the in-memory (MMKV-backed) counters into + * SQLite. Includes counterBackups so a removed mint's counter isn't lost. + * The repo applies each value monotonically, so this is safe to run on + * every launch — after the first copy it is a no-op. + */ + seedCountersToDatabase() { + const seeds: CounterSeed[] = [] + + for (const mint of self.mints) { + for (const c of mint.proofsCounters) { + seeds.push({mintUrl: mint.mintUrl, keysetId: c.keyset, unit: c.unit, counter: c.counter}) + } + } + for (const backup of self.counterBackups) { + for (const c of backup.counters) { + seeds.push({mintUrl: backup.mintUrl, keysetId: c.keyset, unit: c.unit, counter: c.counter}) + } + } + + if (seeds.length > 0) { + Database.seedCounters(seeds) + } + }, + /** + * 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) + } + } + }, })) .actions(self => ({ addMint: flow(function* addMint(mintUrl: string) { diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index 5aa9dea..a34ba05 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -63,7 +63,7 @@ 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 }) @@ -77,6 +77,13 @@ export async function setupRootStore(rootStore: RootStore) { // hydrate unspent and pending ecash proofs to model from database await proofsStore.loadProofsFromDatabase() + // Derivation counters: one-time idempotent copy of the MMKV-backed + // counters into SQLite (the new authority), then hydrate the in-memory + // cache back from SQLite. Both directions are monotonic, so the order is + // safe and re-running every launch is a no-op once copied. + mintsStore.seedCountersToDatabase() + mintsStore.hydrateCountersFromDatabase() + // 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.). From 24b37ff2b5da09f201592953c15abbd1179b577d Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Wed, 3 Jun 2026 23:25:28 +0200 Subject: [PATCH 03/25] feat(counters): hydrate from SQLite on foreground resume (step 3) Hook counter reconciliation into the existing WalletScreen AppState 'active' handler. A background path may advance a counter in SQLite while the app is backgrounded-but-alive; hydrating on resume (before performChecks can derive proofs) keeps the in-memory cache from going stale. Monotonic, so it only ever raises a live value. Co-Authored-By: Claude Opus 4.8 --- src/screens/WalletScreen.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/screens/WalletScreen.tsx b/src/screens/WalletScreen.tsx index 1964e3c..d95021d 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, From fd19807e1897f4f44f5f7dd79b7804b860ebf2b0 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Wed, 3 Jun 2026 23:35:38 +0200 Subject: [PATCH 04/25] feat(counters): strip counter from MMKV snapshot + keep backups correct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make SQLite the sole persisted store for the derivation counter by stripping `counter` from every model snapshot (postProcessSnapshot on MintProofsCounter, mirroring how ProofsStore strips `proofs`). This stops the foreground whole-tree MMKV save from ever writing a stale counter back over the SQLite authority. Because getSnapshot is shared by persistence AND the legitimate counter consumers, re-inject the live value where it must survive: - Backup export: re-inject live counters per keyset into the exported mints snapshot (exporting counter 0 would risk secret reuse on restore). - Backup import: seed the imported counters into SQLite right after applySnapshot (monotonic). - counterBackups: re-inject live counters when capturing a backup at mint removal, and hydrate from SQLite after a mint is (re-)added — SQLite retains counters by (mintUrl, keysetId) across removal, covering the re-add-after-restart case where the persisted backup reloaded as zero. Co-Authored-By: Claude Opus 4.8 --- src/models/Mint.ts | 10 ++++++++++ src/models/MintsStore.ts | 27 +++++++++++++++++++++------ src/screens/ExportBackupScreen.tsx | 12 +++++++++++- src/screens/ImportBackupScreen.tsx | 8 +++++++- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 2e92b65..3835f10 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -303,6 +303,16 @@ export const MintProofsCounterModel = types return Array.from(self.meltCounterValues.values()) }, })) + // 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 diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index b850b4e..ba2f39b 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -73,9 +73,17 @@ export const MintsStoreModel = types (backup) => backup.mintUrl === mintToRemove.mintUrl ) + // `counter` is stripped from snapshots (mastered in SQLite), so + // re-inject the live cache value per keyset — otherwise the backup + // taken at removal time would capture zeros. + const counters = getSnapshot(mintToRemove.proofsCounters!).map((c: any) => ({ + ...c, + counter: mintToRemove.proofsCounters!.find(pc => pc.keyset === c.keyset)?.counter ?? c.counter, + })) + const newCounterBackup = CounterBackupModel.create({ mintUrl: mintToRemove.mintUrl, - counters: getSnapshot(mintToRemove.proofsCounters!) + counters }) if (existingIndex !== -1) { @@ -208,13 +216,20 @@ export const MintsStoreModel = types log.trace('[addMint] updateMintCountersFromBackup') - self.updateMintCountersFromBackup(mintInstance) + 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, so a re-added mint recovers its real counter from the + // authority — even when the snapshot-stripped counterBackup reloaded + // as zero after a restart. Monotonic, so a genuinely new mint (no row) + // simply stays at 0. + self.hydrateCountersFromDatabase() + return mintInstance }), updateMint: flow(function* updateMint(mintUrl: string) { diff --git a/src/screens/ExportBackupScreen.tsx b/src/screens/ExportBackupScreen.tsx index 18c1a64..0b45c71 100644 --- a/src/screens/ExportBackupScreen.tsx +++ b/src/screens/ExportBackupScreen.tsx @@ -147,7 +147,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..40caf57 100644 --- a/src/screens/ImportBackupScreen.tsx +++ b/src/screens/ImportBackupScreen.tsx @@ -201,7 +201,13 @@ 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}) From 2965f6df7ae389d62ddae0c93e2f6b7ead45fc10 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Wed, 3 Jun 2026 23:41:06 +0200 Subject: [PATCH 05/25] fix(import): write imported proofs with correct state column ImportBackupScreen still called addOrUpdateProofs with the pre-v25 boolean signature (proofs, isPending, isSpent). After the v25 migration replaced the isPending/isSpent columns with a single `state` TEXT column, that 2nd boolean was written verbatim into `state` (stored as '0'/'1'), so imported ecash never matched the `state IN ('UNSPENT','PENDING', 'SPENT')` filter in getProofs and silently disappeared from the balance after the next app restart. Pass the proper ProofState instead: 'UNSPENT' for the unspent set and 'PENDING' for the pending set. Co-Authored-By: Claude Opus 4.8 --- src/screens/ImportBackupScreen.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/screens/ImportBackupScreen.tsx b/src/screens/ImportBackupScreen.tsx index 40caf57..6729a3e 100644 --- a/src/screens/ImportBackupScreen.tsx +++ b/src/screens/ImportBackupScreen.tsx @@ -213,11 +213,11 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } // 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)) { From 72de0a4c76c7883fdc83ba29d096754144d7c819 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Thu, 4 Jun 2026 00:11:27 +0200 Subject: [PATCH 06/25] feat(counters): commit counter atomically with proofs (step 4) Fold the derivation-counter advance into the same SQLite transaction as the proof-commit batch, closing the proofs-table <-> mint_counters atomicity window. A crash that committed new proofs but not the advanced counter could otherwise let the next derivation reuse a blinded secret. - countersRepo: extract buildCounterUpsert (monotonic upsert tuple) and reuse it in setCounter/seedCounters so the SQL lives in one place. - reservationsRepo.commitReservation: accept counterUpdate[] and batch a monotonic upsert per keyset alongside the proof writes + tx update. - ProofsStore.commitReservation: derive the per-keyset counter snapshot from the new proofs (a cashu proof.id IS its keyset id) and pass it to the atomic commit. Centralized in the single wrapper; no operation-API call sites change. Value-preserving: persists the same counter the model already holds, monotonically. The redundant in-method increments are removed separately. Co-Authored-By: Claude Opus 4.8 --- src/models/ProofsStore.ts | 28 ++++++++++++++++- src/services/db/countersRepo.ts | 48 +++++++++++++++++------------ src/services/db/reservationsRepo.ts | 19 ++++++++++++ 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 8607f6f..799485d 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -420,8 +420,33 @@ import { }) } + // Snapshot the current derivation counter for every keyset that the + // new proofs were derived under (a cashu proof's `id` IS its keyset + // id). At this point WalletStore has already advanced the model + // counter to `reservedCounters.next`, so persisting it in the SAME + // batch as the proofs guarantees the stored counter can never lag the + // proofs that consumed those indices. Monotonic, so a redundant write + // is harmless. + 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 +458,7 @@ import { tId: group.tId, })), transactionUpdate: changes.transactionUpdate, + counterUpdate, }) // Mirror to MST now that SQLite is durable. diff --git a/src/services/db/countersRepo.ts b/src/services/db/countersRepo.ts index 621c12f..b2c698b 100644 --- a/src/services/db/countersRepo.ts +++ b/src/services/db/countersRepo.ts @@ -36,6 +36,30 @@ export type CounterSeed = { 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 { @@ -77,16 +101,8 @@ export const setCounter = function ( value: number, ): void { try { - const db = getInstance() - db.execute( - `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, new Date().toISOString()], - ) + 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) } @@ -133,15 +149,9 @@ 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 => [ - `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`, - [s.mintUrl, s.keysetId, s.unit ?? null, s.counter, now], - ]) + const batch: SQLBatchTuple[] = seeds.map(s => + buildCounterUpsert(s.mintUrl, s.keysetId, s.unit, s.counter, now), + ) const db = getInstance() db.executeBatch(batch) diff --git a/src/services/db/reservationsRepo.ts b/src/services/db/reservationsRepo.ts index 524e53f..c45b0a4 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). @@ -161,6 +162,19 @@ export const commitReservation = function ( tId: number }> transactionUpdate?: ReservationTransactionUpdate + /** + * Per-keyset derivation counters to persist atomically with the proof + * writes. Folding the counter into this same transaction closes the + * proofs-table ↔ mint_counters atomicity window: a crash that committed + * the new proofs but not the advanced counter could otherwise 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,6 +258,10 @@ 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() @@ -255,6 +273,7 @@ export const commitReservation = function ( 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) From 3cce38a2bd5a615e1fd4b8d715e92207e3d19dc9 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Thu, 4 Jun 2026 00:14:43 +0200 Subject: [PATCH 07/25] fix(counters): remove redundant in-method counter advances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both proofsStore.commitReservation and proofsStore.addOrUpdate advanced the keyset counter by the number of new spendable proofs. Under cashu-ts v3.x this double-counts: the authoritative advance to reservedCounters.next has already happened on the path that produced the proofs, and it covers every index those proofs consumed. The extra +length only over-advanced the counter (safe — never reuses — but wasteful, and it inflated the range seed recovery must scan). Audited every caller of both sites; each has an independent, sufficient advance before reaching these methods: - commitReservation: send/receive/mint/melt/revert/in-flight all call a WalletStore op -> setProofsCounter(reservedCounters.next). - addOrUpdate: inflight/mint (setProofsCounter), melt-recovery (indices pre-reserved at prepare via meltPreview), seed-recovery (whole-interval advance in SeedRecoveryScreen). The counter is now advanced in exactly one authoritative place per path, and (for the reservation path) persisted atomically with the proofs. Co-Authored-By: Claude Opus 4.8 --- src/models/ProofsStore.ts | 44 +++++++++++++-------------------------- 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 799485d..8c5b8a1 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -168,8 +168,6 @@ import { 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) @@ -200,17 +198,15 @@ import { 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) - } - } + // The keyset counter is NOT advanced here. Every caller already + // advanced it via the authoritative v3.x path before reaching this + // method: WalletStore.setProofsCounter(reservedCounters.next) for the + // inflight/mint/melt-recovery callers, and the whole-interval advance + // in SeedRecoveryScreen for seed recovery. The old + // `increaseProofsCounter(proofs.length)` here double-advanced the + // counter (a pre-v3.x leftover) and was removed. if (updatedProofs.length > 0) { Database.addOrUpdateProofs(updatedProofs, state) @@ -479,8 +475,6 @@ import { ) } - const proofsByKeyset = new Map() - for (const group of changes.newProofs ?? []) { for (const proof of group.proofs) { const existing = self.getBySecret(proof.secret) @@ -492,12 +486,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({ @@ -510,21 +498,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 From 276cece26a0df15ec3af80177b0b73cbe6dc0d61 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Thu, 4 Jun 2026 10:53:05 +0200 Subject: [PATCH 08/25] refactor(proofs): phase out addOrUpdate; single atomic write path (step 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProofsStore.addOrUpdate was a second proof-creation path parallel to commitReservation, with its own MST mirroring loop and its own non-atomic Database.addOrUpdateProofs write + separate tx.update. That left counter/proofs/tx as three separate writes on the recovery paths. Convert all six add-only callers to the reservation pattern (reserve([]) + commitReservation{newProofs, transactionUpdate}), so proofs + tx + keyset counter land in ONE SQLite transaction — same idiom topup finalize already uses: - mintOperations.recoverMintQuote - meltOperations change-recovery - inFlightOperations receive-retry + topup-retry - SeedRecoveryScreen UNSPENT + PENDING Then remove addOrUpdate entirely. Proof creation now flows through a single, atomic, well-tested code path, shrinking the critical surface. Multi-step seed-recovery tx.update() sequences collapse into one atomic transactionUpdate. Co-Authored-By: Claude Opus 4.8 --- src/models/ProofsStore.ts | 136 +++++------------- src/screens/SeedRecoveryScreen.tsx | 71 ++++----- .../wallet/operations/inFlightOperations.ts | 70 +++++---- .../wallet/operations/meltOperations.ts | 37 +++-- .../wallet/operations/mintOperations.ts | 37 +++-- 5 files changed, 161 insertions(+), 190 deletions(-) diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 8c5b8a1..079407b 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -105,116 +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 }) + ) } - 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) - } - - // The keyset counter is NOT advanced here. Every caller already - // advanced it via the authoritative v3.x path before reaching this - // method: WalletStore.setProofsCounter(reservedCounters.next) for the - // inflight/mint/melt-recovery callers, and the whole-interval advance - // in SeedRecoveryScreen for seed recovery. The old - // `increaseProofsCounter(proofs.length)` here double-advanced the - // counter (a pre-v3.x leftover) and was removed. - - 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. 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/services/wallet/operations/inFlightOperations.ts b/src/services/wallet/operations/inFlightOperations.ts index 9d54f3d..0de8cfb 100644 --- a/src/services/wallet/operations/inFlightOperations.ts +++ b/src/services/wallet/operations/inFlightOperations.ts @@ -90,25 +90,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 +222,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 diff --git a/src/services/wallet/operations/meltOperations.ts b/src/services/wallet/operations/meltOperations.ts index 0a1a941..053d2cf 100644 --- a/src/services/wallet/operations/meltOperations.ts +++ b/src/services/wallet/operations/meltOperations.ts @@ -136,14 +136,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 +147,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}) 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}) From de85703b4dab5b33ab3d2d3154f6138b54ec5d6f Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Thu, 4 Jun 2026 11:21:30 +0200 Subject: [PATCH 09/25] fix(mint-info): show live counter in debug JSON tree MintInfoScreen renders getSnapshot(mint), and postProcessSnapshot zeros `counter` in every snapshot (it is mastered in SQLite, not MMKV). The debug tree therefore showed counter: 0 even though the live in-memory counter is correct. Re-inject the live per-keyset value into the snapshot before display, the same way the backup export does. Display-only; no effect on derivation or persistence. Co-Authored-By: Claude Opus 4.8 --- src/screens/MintInfoScreen.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/screens/MintInfoScreen.tsx b/src/screens/MintInfoScreen.tsx index 32f559f..036e9d1 100644 --- a/src/screens/MintInfoScreen.tsx +++ b/src/screens/MintInfoScreen.tsx @@ -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', From 196caca42a4d940b0ec40e2f4b79033756cc8565 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Thu, 4 Jun 2026 15:17:50 +0200 Subject: [PATCH 10/25] fix(db): drop mint_counters + reservations in cleanAll cleanAll only dropped transactions/proofs/dbversion, leaving the mint_counters and reservations tables (added by later migrations) behind. A wallet wipe would keep stale derivation counters, which the next launch would hydrate. Drop them too (IF EXISTS, so an old DB lacking them doesn't abort the atomic batch). Tables are recreated on next launch via the IF-NOT-EXISTS schema bootstrap. Co-Authored-By: Claude Opus 4.8 --- src/services/db/instance.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/services/db/instance.ts b/src/services/db/instance.ts index 105a47e..49dd823 100644 --- a/src/services/db/instance.ts +++ b/src/services/db/instance.ts @@ -59,6 +59,10 @@ 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'], ] as SQLBatchTuple[] try { From 8ffce9e46a9b660c9a5991e57ff1be6467f2d14c Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Thu, 4 Jun 2026 15:23:23 +0200 Subject: [PATCH 11/25] test(counters): cover mint_counters invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unit coverage for the derivation-counter migration (mirrors the production SQL against node:sqlite, like proofReservation.test.ts): - setCounter is monotonic — raises, ignores a lower value, no-ops on equal - bumpCounter advances relatively; non-positive delta is a no-op - (mintUrl, keysetId) primary key isolates keysets and mints - seedCounters is idempotent and never regresses an advanced counter; a too-high seed is kept (conservative-safe) - the folded counterUpdate persists the counter atomically with new proofs, rolls back with them on a failed batch, and stays monotonic in-batch Locks in the no-secret-reuse safety property of the migration. Co-Authored-By: Claude Opus 4.8 --- __tests__/counters.test.ts | 340 +++++++++++++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 __tests__/counters.test.ts 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() + }) + }) +}) From 19bd77eddcdbfe624c5af0aee7afe8ec984d904c Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Thu, 4 Jun 2026 15:45:28 +0200 Subject: [PATCH 12/25] refactor(counters): run one-time seed in _runMigrations; guard re-seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the MMKV->SQLite counter copy out of the setupRootStore hot path into _runMigrations (gated by version bump 32->33). The core path now only does the every-launch hydrate, alongside loadProofsFromDatabase. Restructure _runMigrations so each step is independent and the version is set once at the end (a throwing step retries next launch; also fixes a latent quirk where v29-31 users never had their version bumped). The seed reads the LIVE MST counters, which still hold real values after the snapshot strip (postProcessSnapshot strips counter from saves, not the in-memory model). Hydrate stays every-launch — it is not a migration. Safety for devices that ALREADY migrated to SQLite while still on model v32 (SQLite populated, MMKV stripped to 0): the re-run of the migration cannot reset their counters, guarded two ways — 1. seedCountersToDatabase only seeds counters > 0 (never writes a stripped/zero value), and 2. the repo upsert is monotonic (MAX), so a seed can never lower an existing SQLite counter. Hydrate also restores the real values into the model before the seed runs. Co-Authored-By: Claude Opus 4.8 --- src/models/MintsStore.ts | 23 +++++++++++---- src/models/RootStore.ts | 2 +- src/models/helpers/setupRootStore.ts | 43 ++++++++++++++++++---------- 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index ba2f39b..2aee673 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -115,22 +115,33 @@ export const MintsStoreModel = types } }, /** - * One-time, idempotent copy of the in-memory (MMKV-backed) counters into - * SQLite. Includes counterBackups so a removed mint's counter isn't lost. - * The repo applies each value monotonically, so this is safe to run on - * every launch — after the first copy it is a no-op. + * One-time copy of the in-memory (MMKV-loaded) derivation counters into + * SQLite. Run from _runMigrations on upgrade, and on backup import. + * Includes counterBackups so a removed mint's counter isn't lost. + * + * 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) { - seeds.push({mintUrl: mint.mintUrl, keysetId: c.keyset, unit: c.unit, counter: c.counter}) + if (c.counter > 0) { + seeds.push({mintUrl: mint.mintUrl, keysetId: c.keyset, unit: c.unit, counter: c.counter}) + } } } for (const backup of self.counterBackups) { for (const c of backup.counters) { - seeds.push({mintUrl: backup.mintUrl, keysetId: c.keyset, unit: c.unit, counter: c.counter}) + if (c.counter > 0) { + seeds.push({mintUrl: backup.mintUrl, keysetId: c.keyset, unit: c.unit, counter: c.counter}) + } } } diff --git a/src/models/RootStore.ts b/src/models/RootStore.ts index cdfe650..43ef2af 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 = 33 // Update this if model changes require migrations defined in setupRootStore.ts /** * A RootStore model. */ diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index a34ba05..aa6d59e 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -77,11 +77,11 @@ export async function setupRootStore(rootStore: RootStore) { // hydrate unspent and pending ecash proofs to model from database await proofsStore.loadProofsFromDatabase() - // Derivation counters: one-time idempotent copy of the MMKV-backed - // counters into SQLite (the new authority), then hydrate the in-memory - // cache back from SQLite. Both directions are monotonic, so the order is - // safe and re-running every launch is a no-op once copied. - mintsStore.seedCountersToDatabase() + // 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() // Roll back any orphan proof reservations from the last session. @@ -144,29 +144,42 @@ export async function setupRootStore(rootStore: RootStore) { */ async function _runMigrations(rootStore: RootStore) { - const { - userSettingsStore, + 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() + } + + // 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, - ) + ) } } From b9ee74e63134c9295d3b5d894f201d5c937c6516 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Thu, 4 Jun 2026 15:46:22 +0200 Subject: [PATCH 13/25] Logs tuning --- src/components/AmountInput.tsx | 6 +- src/models/AuthStore.ts | 2 +- src/models/Mint.ts | 10 --- src/models/ProofsStore.ts | 4 +- src/models/TransactionsStore.ts | 2 +- src/screens/MintInfoScreen.tsx | 5 +- src/screens/OptimizeEcashScreen.tsx | 6 +- src/screens/ReceiveScreen.tsx | 6 +- src/screens/ScanScreen.tsx | 2 +- src/screens/TranDetailScreen.tsx | 2 +- src/screens/TranHistoryScreen.tsx | 2 +- src/screens/WalletScreen.tsx | 2 +- src/services/db/proofsRepo.ts | 4 +- src/services/db/reservationsRepo.ts | 4 +- src/services/db/transactionsRepo.ts | 2 +- src/services/keyChain.ts | 2 +- src/services/logService.ts | 68 +++++++++++++++---- src/services/minibitsService.ts | 4 +- src/services/syncQueueService.ts | 10 +-- .../wallet/operations/nostrOperations.ts | 2 +- src/utils/poller.ts | 4 +- 21 files changed, 89 insertions(+), 60 deletions(-) diff --git a/src/components/AmountInput.tsx b/src/components/AmountInput.tsx index 964af9d..f57b68e 100644 --- a/src/components/AmountInput.tsx +++ b/src/components/AmountInput.tsx @@ -138,7 +138,7 @@ export const AmountInput = forwardRef( // 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 3835f10..1ec62ce 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -245,16 +245,6 @@ export const MintProofsCounterModel = types }) }, - decreaseProofsCounter(numberOfProofs: number) { - // Lowering is deliberately NOT written through: the SQLite counter is - // monotonic by design. No external caller uses this (recovery only). - 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) diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 079407b..670eeee 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -464,8 +464,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 } 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/screens/MintInfoScreen.tsx b/src/screens/MintInfoScreen.tsx index 036e9d1..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) @@ -321,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)}` @@ -575,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/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 d95021d..9b07e4b 100644 --- a/src/screens/WalletScreen.tsx +++ b/src/screens/WalletScreen.tsx @@ -438,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/db/proofsRepo.ts b/src/services/db/proofsRepo.ts index 4738411..4cac18a 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) { diff --git a/src/services/db/reservationsRepo.ts b/src/services/db/reservationsRepo.ts index c45b0a4..516c607 100644 --- a/src/services/db/reservationsRepo.ts +++ b/src/services/db/reservationsRepo.ts @@ -109,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, @@ -267,7 +267,7 @@ export const commitReservation = function ( 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, 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/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/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/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) => { From 2d294c220007bf399e15dbf82134486b7a390565 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 5 Jun 2026 08:45:04 +0200 Subject: [PATCH 14/25] docs(counters): clarify the complementary W1/W2 counter writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counter is persisted twice per reservation op, and the old comments mis-described the roles (write-through as "best-effort", the commit upsert as "the keystone"). Correct them to reflect the actual complementary design: - W1 (Mint.persistCounter write-through): the PRIMARY persistence — fires the instant cashu derives, before the commit, so the advance is durable the moment the mint could have seen the outputs. Covers crash-before-commit and rollback (indices are consumed at the mint; rollback must not rewind). Errors are logged (→ Sentry in prod), not thrown. - W2 (commitReservation counterUpdate): the atomic BACKSTOP — a monotonic no-op on the normal path (W1 already persisted), but if W1's write was dropped, folding the counter into the proof batch guarantees a committed proof can never outlive its counter advance. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 --- src/models/Mint.ts | 22 ++++++++++++++++------ src/models/ProofsStore.ts | 15 ++++++++------- src/services/db/reservationsRepo.ts | 11 +++++++---- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 1ec62ce..897c2bd 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -101,18 +101,28 @@ const migrateSnapshot = (snapshot: any): any => { /** - * Write a counter mutation through to the SQLite authority. + * 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). * - * Failures are swallowed deliberately: a detached instance (e.g. a counter - * created for a CounterBackup, not yet attached to a Mint) has no parent, and a - * transient DB error must never break a wallet flow. The value stays in the MST - * cache and startup hydration reconciles from SQLite. Step 4 folds this write - * into the proof-commit transaction to make it atomic. + * 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 (e.g. a + * counter created for a CounterBackup, not yet attached to 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 diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 670eeee..66841ff 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -346,13 +346,14 @@ import { }) } - // Snapshot the current derivation counter for every keyset that the - // new proofs were derived under (a cashu proof's `id` IS its keyset - // id). At this point WalletStore has already advanced the model - // counter to `reservedCounters.next`, so persisting it in the SAME - // batch as the proofs guarantees the stored counter can never lag the - // proofs that consumed those indices. Monotonic, so a redundant write - // is harmless. + // 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 ?? []) { diff --git a/src/services/db/reservationsRepo.ts b/src/services/db/reservationsRepo.ts index 516c607..0ffd611 100644 --- a/src/services/db/reservationsRepo.ts +++ b/src/services/db/reservationsRepo.ts @@ -164,10 +164,13 @@ export const commitReservation = function ( transactionUpdate?: ReservationTransactionUpdate /** * Per-keyset derivation counters to persist atomically with the proof - * writes. Folding the counter into this same transaction closes the - * proofs-table ↔ mint_counters atomicity window: a crash that committed - * the new proofs but not the advanced counter could otherwise let the next - * derivation reuse a blinded secret. Each upsert is monotonic. + * 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 From 8d91fad254945e7fb2fa4d7e8c0e5153ee27b682 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 5 Jun 2026 09:36:53 +0200 Subject: [PATCH 15/25] feat(melt): move meltCounterValues to SQLite (off-MST, durable) [M1] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate per-transaction melt recovery data (the serialized meltPreview) from the MST MintProofsCounter (debounced MMKV) to a dedicated SQLite table, so it can be written synchronously and read with no MST loaded. Why: meltPreview is recovery-critical — it unblinds the change of a paid- but-unconfirmed melt. It was persisted only via the batched whole-tree MMKV snapshot, so a crash right after the payment was submitted could lose it and the change ecash. It's also a prerequisite for off-MST background melt (NWC pay_invoice). - schema/migration v28: new melt_recovery table (txId PK, mintUrl, keysetId, meltPreview JSON); added to cleanAll. - meltRecoveryRepo: add (ON CONFLICT DO NOTHING — first preview wins, matching the old "already tracked" guard) / get / remove / seed. - WalletStore: write the preview synchronously via Database.addMeltRecovery BEFORE completeMelt; remove on terminal success/failure. - meltOperations / transferOperationApi: read/remove via Database instead of the counter model; drop the now-needless counter fetch in those blocks. - Mint model: remove meltCounterValues map, MeltCounterValueModel, the melt actions/views, the dead counterAtMelt field, and serializeMeltPreview (moved to cashuUtils). migrateSnapshot now STRIPS meltCounterValues from old snapshots so applySnapshot tolerates the removed field. - one-time seed (rootStoreModelVersion 33->34): _runMigrations reads the RAW pre-upgrade snapshot (the model no longer holds it) and copies any in-flight meltPreview into SQLite. Idempotent. - tests: __tests__/meltRecovery.test.ts (JSON round-trip, first-wins, remove, isolation, idempotent seed). Full suite green (14 suites / 157 tests). Co-Authored-By: Claude Opus 4.8 --- __tests__/meltRecovery.test.ts | 152 ++++++++++++++++++ src/models/Mint.ts | 93 +---------- src/models/RootStore.ts | 2 +- src/models/WalletStore.ts | 35 ++-- src/models/helpers/setupRootStore.ts | 34 +++- src/services/cashu/cashuUtils.ts | 8 + src/services/db/index.ts | 11 ++ src/services/db/instance.ts | 1 + src/services/db/meltRecoveryRepo.ts | 111 +++++++++++++ src/services/db/migrations.ts | 11 +- src/services/db/schema.ts | 23 +++ .../wallet/operations/meltOperations.ts | 24 ++- .../wallet/operations/transferOperationApi.ts | 9 +- 13 files changed, 388 insertions(+), 126 deletions(-) create mode 100644 __tests__/meltRecovery.test.ts create mode 100644 src/services/db/meltRecoveryRepo.ts 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/src/models/Mint.ts b/src/models/Mint.ts index 897c2bd..877e7e5 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -5,7 +5,6 @@ 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, Database } from '../services' @@ -15,14 +14,7 @@ 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 @@ -56,14 +48,6 @@ const InFlightRequestModel = types.model('InFlightRequest', { 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 === const migrateSnapshot = (snapshot: any): any => { if (!snapshot) return snapshot @@ -91,9 +75,11 @@ const migrateSnapshot = (snapshot: any): any => { snapshot = { ...snapshot, inFlightRequests: {} } } - // 2. Add missing meltCounterValues map (new in v2+) - if (snapshot.meltCounterValues === undefined) { - snapshot = { ...snapshot, meltCounterValues: {} } + // 2. meltCounterValues moved to SQLite (melt_recovery table). Strip it from + // any old snapshot so applySnapshot doesn't choke on the removed field. + if (snapshot.meltCounterValues !== undefined) { + const {meltCounterValues, ...rest} = snapshot + snapshot = rest } return snapshot @@ -156,9 +142,6 @@ export const MintProofsCounterModel = types // 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 => ({ @@ -195,56 +178,6 @@ export const MintProofsCounterModel = types } }, - // === 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 (write through to the SQLite authority) === increaseProofsCounter(numberOfProofs: number) { self.counter += numberOfProofs @@ -288,20 +221,6 @@ export const MintProofsCounterModel = types 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()) - }, })) // The derivation counter is mastered in SQLite (mint_counters), hydrated // into this model as an in-memory cache on startup/resume. Strip it from diff --git a/src/models/RootStore.ts b/src/models/RootStore.ts index 43ef2af..d72d8b0 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 = 33 // Update this if model changes require migrations defined in setupRootStore.ts +export const rootStoreModelVersion = 34 // Update this if model changes require migrations defined in setupRootStore.ts /** * A RootStore model. */ diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 958964e..3e51d1f 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' @@ -488,7 +488,7 @@ export const WalletStoreModel = types ...receiveParams.options, onCountersReserved: (info: OperationCounters) => { reservedCounters = info - log.debug('[receive] Counters reserved', info) + log.debug('[WalletStore.receive] Counters reserved', info) } } ) @@ -500,7 +500,7 @@ export const WalletStoreModel = types // 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, @@ -605,7 +605,7 @@ export const WalletStoreModel = types ...sendParams.options, onCountersReserved: (info: OperationCounters) => { reservedCounters = info - log.debug('[send] Counters reserved', info) + log.debug('[WalletStore.send] Counters reserved', info) } } ) @@ -615,7 +615,7 @@ export const WalletStoreModel = types // 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, @@ -729,7 +729,7 @@ export const WalletStoreModel = types description }) - log.info('[createLightningMintQuote]', {mintQuoteResponse}) + log.info('[WalletStore.createLightningMintQuote]', {mintQuoteResponse}) return { encodedInvoice: mintQuoteResponse.request, @@ -759,7 +759,7 @@ export const WalletStoreModel = types quote ) - log.info('[checkLightningMintQuote]', {quoteResponse}) + log.info('[WalletStore.checkLightningMintQuote]', {quoteResponse}) return { encodedInvoice: quoteResponse.request, @@ -843,7 +843,7 @@ 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) } } ) @@ -853,7 +853,7 @@ export const WalletStoreModel = types // 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 +861,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 @@ -972,8 +972,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 +999,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 +1009,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 aa6d59e..461e1fa 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 } from '../../services/db' import { log } from '../../services/logService' import { rootStoreModelVersion } from '../RootStore' import AppError, { Err } from '../../utils/AppError' @@ -124,7 +125,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) @@ -143,7 +144,7 @@ export async function setupRootStore(rootStore: RootStore) { * Migrations code to execute based on code and on device model version. */ -async function _runMigrations(rootStore: RootStore) { +async function _runMigrations(rootStore: RootStore, restoredState: any) { const { mintsStore, transactionsStore, @@ -170,6 +171,33 @@ async function _runMigrations(rootStore: RootStore) { 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) + } + } + // 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) 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/index.ts b/src/services/db/index.ts index 4623239..8b0c729 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -51,6 +51,12 @@ import { bumpCounter, seedCounters, } from './countersRepo' +import { + addMeltRecovery, + getMeltRecovery, + removeMeltRecovery, + seedMeltRecoveries, +} from './meltRecoveryRepo' export type {TransactionSearchFilters} from './transactionsRepo' export type { @@ -59,6 +65,7 @@ export type { ReservationTransactionUpdate, } from './reservationsRepo' export type {CounterRecord, CounterSeed} from './countersRepo' +export type {MeltRecoveryRecord, MeltRecoverySeed} from './meltRecoveryRepo' export const Database = { getInstance, @@ -100,4 +107,8 @@ export const Database = { setCounter, bumpCounter, seedCounters, + addMeltRecovery, + getMeltRecovery, + removeMeltRecovery, + seedMeltRecoveries, } diff --git a/src/services/db/instance.ts b/src/services/db/instance.ts index 49dd823..5c99dca 100644 --- a/src/services/db/instance.ts +++ b/src/services/db/instance.ts @@ -63,6 +63,7 @@ export const cleanAll = function () { // 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'], ] 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 23e7540..41463be 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, MINT_COUNTERS_COLUMNS} from './schema' +import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS, MELT_RECOVERY_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 = 27 +export const _dbVersion = 28 type Migration = {version: number; queries: SQLBatchTuple[]} @@ -79,6 +79,13 @@ const MIGRATIONS: Migration[] = [ 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)]], + }, ] /** diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index 0c17d5d..926ddb0 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -89,6 +89,26 @@ export const MINT_COUNTERS_COLUMNS = ` 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 +` + /** Build a CREATE TABLE statement from a column block. */ export const createTable = ( name: string, @@ -112,4 +132,7 @@ export const createSchemaQueries: SQLBatchTuple[] = [ // 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)], ] diff --git a/src/services/wallet/operations/meltOperations.ts b/src/services/wallet/operations/meltOperations.ts index 053d2cf..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)) @@ -221,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: []} } @@ -234,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, @@ -246,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/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index eeb4263..8a30eb0 100644 --- a/src/services/wallet/operations/transferOperationApi.ts +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -994,14 +994,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 +1018,7 @@ async function _unblindMeltChange(params: { change, }) - currentCounter.removeMeltCounterValue(transaction.id) + Database.removeMeltRecovery(transaction.id) return {change} } catch (e: any) { log.error( From 14f8d1de8cc2b8fbf7758caf1b5a67485b3910ab Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 5 Jun 2026 09:50:56 +0200 Subject: [PATCH 16/25] feat(inflight): move inFlightRequests to SQLite (off-MST) [M2] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate per-transaction in-flight request data (params for NUT-19 idempotent retry) from the MST MintProofsCounter to a dedicated SQLite table, so retries work with no MST loaded — completing the off-MST set needed for background NWC. - schema/migration v29: inflight_requests (txId PK, mintUrl, keysetId, request JSON); added to cleanAll. - inFlightRepo: add (INSERT OR REPLACE = set semantics) / get / getInFlightRequestsByMint / remove / seed (ON CONFLICT DO NOTHING). - WalletStore: write/remove via Database (receive/send/mint paths). - inFlightOperations: enumerate via a flat Database.getInFlightRequestsByMint query instead of the mint.proofsCountersWithInFlightRequests nested loop; removeInFlightRequest via Database; queue guard uses the DB count. - Mint model: remove inFlightRequests map, InFlightRequestModel, all the in-flight actions/views (counter + mint level). The InFlightRequest TYPE is kept (WalletStore option signatures). MintProofsCounter is now just {keyset, unit, counter}. migrateSnapshot strips inFlightRequests AND meltCounterValues from old snapshots. - one-time seed (rootStoreModelVersion 34->35): _runMigrations reads the raw pre-upgrade snapshot for any in-flight requests; idempotent. - tests: __tests__/inFlightRequests.test.ts. Full suite green (15 suites / 163 tests). With M1+M2, the MintProofsCounter sub-model now carries only the counter (itself SQLite-authoritative) — a candidate to collapse later. Co-Authored-By: Claude Opus 4.8 --- __tests__/inFlightRequests.test.ts | 142 ++++++++++++++++++ src/models/Mint.ts | 121 +-------------- src/models/RootStore.ts | 2 +- src/models/WalletStore.ts | 18 +-- src/models/helpers/setupRootStore.ts | 28 +++- src/services/db/inFlightRepo.ts | 120 +++++++++++++++ src/services/db/index.ts | 13 ++ src/services/db/instance.ts | 1 + src/services/db/migrations.ts | 11 +- src/services/db/schema.ts | 22 +++ .../wallet/operations/inFlightOperations.ts | 33 ++-- 11 files changed, 362 insertions(+), 149 deletions(-) create mode 100644 __tests__/inFlightRequests.test.ts create mode 100644 src/services/db/inFlightRepo.ts 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/src/models/Mint.ts b/src/models/Mint.ts index 877e7e5..0817d06 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -43,46 +43,15 @@ export type InFlightRequest = { request: TRequest } -const InFlightRequestModel = types.model('InFlightRequest', { - transactionId: types.number, - request: types.frozen(), // or replace `any` with your actual request type -}) - // === 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. meltCounterValues moved to SQLite (melt_recovery table). Strip it from - // any old snapshot so applySnapshot doesn't choke on the removed field. - if (snapshot.meltCounterValues !== undefined) { - const {meltCounterValues, ...rest} = snapshot - snapshot = rest - } - - return snapshot + const {inFlightRequests, meltCounterValues, ...rest} = snapshot + return rest } @@ -139,45 +108,9 @@ export const MintProofsCounterModel = types keyset: types.string, unit: types.optional(types.frozen(), 'sat'), counter: types.optional(types.number, 0), - - // In-flight mint requests - inFlightRequests: types.map(InFlightRequestModel), }) .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)`) - } - }, - // === Counter mutations (write through to the SQLite authority) === increaseProofsCounter(numberOfProofs: number) { self.counter += numberOfProofs @@ -207,21 +140,6 @@ export const MintProofsCounterModel = types } }, })) - .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()) - }, - })) // 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 @@ -590,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/RootStore.ts b/src/models/RootStore.ts index d72d8b0..6f0ab7c 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 = 34 // Update this if model changes require migrations defined in setupRootStore.ts +export const rootStoreModelVersion = 35 // Update this if model changes require migrations defined in setupRootStore.ts /** * A RootStore model. */ diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 3e51d1f..386475e 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -476,7 +476,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 @@ -495,7 +495,7 @@ export const WalletStoreModel = types log.trace('[WalletStore.receive]', {proofs}) - currentCounter.removeInFlightRequest(transactionId) + Database.removeInFlightRequest(transactionId) // Update our counter to match what the wallet used (v3.x) if (reservedCounters) { @@ -524,7 +524,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 +591,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 @@ -610,7 +610,7 @@ export const WalletStoreModel = types } ) - currentCounter.removeInFlightRequest(transactionId) + Database.removeInFlightRequest(transactionId) // Update our counter to match what the wallet used (v3.x) if (reservedCounters) { @@ -642,7 +642,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.' @@ -829,7 +829,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 @@ -848,7 +848,7 @@ export const WalletStoreModel = types } ) - currentCounter.removeInFlightRequest(transactionId) + Database.removeInFlightRequest(transactionId) // Update our counter to match what the wallet used (v3.x) if (reservedCounters) { @@ -869,7 +869,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.' diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index 461e1fa..06122ee 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -17,7 +17,7 @@ import { import * as Sentry from '@sentry/react-native' import type { RootStore } from '../RootStore' import { Database, MMKVStorage } from '../../services' -import type { MeltRecoverySeed } from '../../services/db' +import type { MeltRecoverySeed, InFlightRequestSeed } from '../../services/db' import { log } from '../../services/logService' import { rootStoreModelVersion } from '../RootStore' import AppError, { Err } from '../../utils/AppError' @@ -198,6 +198,32 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) { } } + 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) + } + } + // 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) 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 8b0c729..fd8961d 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -57,6 +57,13 @@ import { removeMeltRecovery, seedMeltRecoveries, } from './meltRecoveryRepo' +import { + addInFlightRequest, + getInFlightRequest, + getInFlightRequestsByMint, + removeInFlightRequest, + seedInFlightRequests, +} from './inFlightRepo' export type {TransactionSearchFilters} from './transactionsRepo' export type { @@ -66,6 +73,7 @@ export type { } from './reservationsRepo' export type {CounterRecord, CounterSeed} from './countersRepo' export type {MeltRecoveryRecord, MeltRecoverySeed} from './meltRecoveryRepo' +export type {InFlightRequestRecord, InFlightRequestSeed} from './inFlightRepo' export const Database = { getInstance, @@ -111,4 +119,9 @@ export const Database = { getMeltRecovery, removeMeltRecovery, seedMeltRecoveries, + addInFlightRequest, + getInFlightRequest, + getInFlightRequestsByMint, + removeInFlightRequest, + seedInFlightRequests, } diff --git a/src/services/db/instance.ts b/src/services/db/instance.ts index 5c99dca..b134f07 100644 --- a/src/services/db/instance.ts +++ b/src/services/db/instance.ts @@ -64,6 +64,7 @@ export const cleanAll = function () { ['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/migrations.ts b/src/services/db/migrations.ts index 41463be..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, MINT_COUNTERS_COLUMNS, MELT_RECOVERY_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 = 28 +export const _dbVersion = 29 type Migration = {version: number; queries: SQLBatchTuple[]} @@ -86,6 +86,13 @@ const MIGRATIONS: Migration[] = [ 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/schema.ts b/src/services/db/schema.ts index 926ddb0..e9a0fb7 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -109,6 +109,25 @@ export const MELT_RECOVERY_COLUMNS = ` 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, @@ -135,4 +154,7 @@ export const createSchemaQueries: SQLBatchTuple[] = [ // 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/wallet/operations/inFlightOperations.ts b/src/services/wallet/operations/inFlightOperations.ts index 0de8cfb..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 } @@ -264,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`, { @@ -274,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, @@ -295,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 } From 3321096fe278ac10a23cf94d4ffc0386f70645f5 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 5 Jun 2026 10:02:04 +0200 Subject: [PATCH 17/25] refactor(counters): remove counterBackups (SQLite retention supersedes it) counterBackups preserved a removed mint's counters in MMKV for restore on re-add. That's now redundant: mint_counters rows are never deleted on mint removal and addMint restores via hydrateCountersFromDatabase. Keeping both also caused a latent double-advance on re-add (updateMintCountersFromBackup bumped SQLite on top of the retained row). - MintsStore: remove counterBackups field, CounterBackupModel/CounterBackup type, addOrUpdateCounterBackup + updateMintCountersFromBackup, the call in removeMint/addMint, and the counterBackups loop in seedCountersToDatabase. removeMint now just detaches; addMint relies on hydrate. - snapshot compat: MintsStore.preProcessSnapshot strips counterBackups from old snapshots so applySnapshot tolerates the removed field. - backup export: drop the counterBackups field (old backups that contain it are stripped on import via the same preProcessSnapshot). - one-time seed (rootStoreModelVersion 35->36): _runMigrations copies any removed-mint counters from the RAW pre-upgrade snapshot's counterBackups into SQLite, so a later re-add still restores them. Monotonic. Full suite green (15 suites / 163 tests). counterBackups was the last counter-adjacent field still in MMKV. Co-Authored-By: Claude Opus 4.8 --- src/models/Mint.ts | 8 +-- src/models/MintsStore.ts | 98 +++++----------------------- src/models/RootStore.ts | 2 +- src/models/helpers/setupRootStore.ts | 20 +++++- src/screens/ExportBackupScreen.tsx | 5 +- 5 files changed, 44 insertions(+), 89 deletions(-) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 0817d06..09dfd21 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -75,16 +75,16 @@ const migrateSnapshot = (snapshot: any): any => { * 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 (e.g. a - * counter created for a CounterBackup, not yet attached to a Mint) has no parent - * and is a no-op here. + * 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 (e.g. CounterBackup) — nothing to persist + return // not attached to a Mint (freshly created counter) — nothing to persist } if (!mintUrl) return diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index 2aee673..1aef2ea 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -5,11 +5,10 @@ import { destroy, isStateTreeNode, detach, - flow, - getSnapshot, + flow, } from 'mobx-state-tree' import {withSetPropAction} from './helpers/withSetPropAction' - import {MintModel, Mint, MintProofsCounter, MintProofsCounterModel} from './Mint' + import {MintModel, Mint} from './Mint' import {log} from '../services/logService' import {Database} from '../services' import type {CounterSeed} from '../services/db' @@ -34,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) => { @@ -67,57 +64,9 @@ 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 - ) - - // `counter` is stripped from snapshots (mastered in SQLite), so - // re-inject the live cache value per keyset — otherwise the backup - // taken at removal time would capture zeros. - const counters = getSnapshot(mintToRemove.proofsCounters!).map((c: any) => ({ - ...c, - counter: mintToRemove.proofsCounters!.find(pc => pc.keyset === c.keyset)?.counter ?? c.counter, - })) - - const newCounterBackup = CounterBackupModel.create({ - mintUrl: mintToRemove.mintUrl, - counters - }) - - if (existingIndex !== -1) { - // Replace existing backup - self.counterBackups[existingIndex] = newCounterBackup - } else { - // Add new backup - self.counterBackups.push(newCounterBackup) - } - } catch (e: any) { - throw new AppError(Err.STORAGE_ERROR, e.message) - } - }, - 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) - } - }) - } - }, /** * One-time copy of the in-memory (MMKV-loaded) derivation counters into * SQLite. Run from _runMigrations on upgrade, and on backup import. - * Includes counterBackups so a removed mint's counter isn't lost. * * Two safeguards make this safe even on a device that has ALREADY * migrated (SQLite populated, MMKV stripped to 0, model not yet @@ -137,13 +86,6 @@ export const MintsStoreModel = types } } } - for (const backup of self.counterBackups) { - for (const c of backup.counters) { - if (c.counter > 0) { - seeds.push({mintUrl: backup.mintUrl, keysetId: c.keyset, unit: c.unit, counter: c.counter}) - } - } - } if (seeds.length > 0) { Database.seedCounters(seeds) @@ -222,23 +164,18 @@ export const MintsStoreModel = types continue } - mintInstance.initKeys(key) + mintInstance.initKeys(key) } - log.trace('[addMint] updateMintCountersFromBackup') - - self.updateMintCountersFromBackup(mintInstance) - mintInstance.setHostname() yield mintInstance.setShortname() self.mints.push(mintInstance) // SQLite retains derivation counters by (mintUrl, keysetId) across - // mint removal, so a re-added mint recovers its real counter from the - // authority — even when the snapshot-stripped counterBackup reloaded - // as zero after a restart. Monotonic, so a genuinely new mint (no row) - // simply stays at 0. + // 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 @@ -304,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/RootStore.ts b/src/models/RootStore.ts index 6f0ab7c..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 = 35 // 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/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index 06122ee..01c4ec9 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -17,7 +17,7 @@ import { import * as Sentry from '@sentry/react-native' import type { RootStore } from '../RootStore' import { Database, MMKVStorage } from '../../services' -import type { MeltRecoverySeed, InFlightRequestSeed } from '../../services/db' +import type { MeltRecoverySeed, InFlightRequestSeed, CounterSeed } from '../../services/db' import { log } from '../../services/logService' import { rootStoreModelVersion } from '../RootStore' import AppError, { Err } from '../../utils/AppError' @@ -224,6 +224,24 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) { } } + 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) diff --git a/src/screens/ExportBackupScreen.tsx b/src/screens/ExportBackupScreen.tsx index 0b45c71..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 = { From 2f350a346c41541970a2d63794167482d15f94ff Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 5 Jun 2026 17:02:52 +0200 Subject: [PATCH 18/25] perf(nwc): process pushed command directly; drop WS re-fetch [Stage 1] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FCM push payload already carries the NWC request event, but the background handler discarded it and spun up a WebSocket subscription to re-fetch the same event before processing — adding a relay round-trip to the first (often only) command. - NwcStore.receivePushedEvent: dedup-mark the pushed event, set the follow-up listener window from it, and dispatch it on the SyncQueue (connection-level handler, same as the WS path). Must run before the listener opens so the event can never be processed twice. - notificationService._nwcRequestHandler: process the pushed event directly, THEN open the listener purely for follow-ups. - index.js: remove the dead HANDLE_NWC_REQUEST_TASK foreground-service branch (no notification ever triggered it) + now-unused imports. - fix a double eventsBatch.push in listenForNwcEvents.onevent. First command no longer waits on a WS connect+subscribe+round-trip; the listener still catches follow-ups. No state-model changes (still runs after setupRootStore — that's Stage 4). tsc clean, suite 15/163 green. Co-Authored-By: Claude Opus 4.8 --- index.js | 24 ++++--------- src/models/NwcStore.ts | 53 +++++++++++++++++++++++++---- src/services/notificationService.ts | 19 ++++++++--- 3 files changed, 66 insertions(+), 30 deletions(-) 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/src/models/NwcStore.ts b/src/models/NwcStore.ts index 30426db..4b8274b 100644 --- a/src/models/NwcStore.ts +++ b/src/models/NwcStore.ts @@ -749,8 +749,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 +831,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/services/notificationService.ts b/src/services/notificationService.ts index 4f864ed..762a035 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -259,16 +259,25 @@ 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) { + await setupRootStore(rootStoreInstance) } + // 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() } From 489c44e4f4cf7ecd243c291857dbd13ae7b88cee Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 5 Jun 2026 17:08:42 +0200 Subject: [PATCH 19/25] perf(nwc): adaptive listener lifetime instead of flat 30s [Stage 2] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NWC listener / Android foreground service was held for a fixed 30s after every wake, even when a single quick command finished in ~2s. Replace the flat setTimeout with an idle-aware poller that closes ~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 via queue activity — so bursts still get the fast-follow-up benefit while the idle tail is trimmed, cutting foreground-service / websocket hold time per wake. Uses the existing SyncQueue.getAllTasksDetails(['idle','running']) count. tsc clean, suite 15/163 green. Co-Authored-By: Claude Opus 4.8 --- src/services/notificationService.ts | 39 +++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 762a035..81d8d8b 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -406,19 +406,44 @@ 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 = 8 * 1000 + let lastBusyAt = Date.now() + + const closeListener = async () => { 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) + } From cc9f012c236264377657bb950a584099a750dc07 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 5 Jun 2026 17:13:00 +0200 Subject: [PATCH 20/25] chore(nwc): instrument setupRootStore cold-hydration phases [Stage 0] Isolate the per-phase timings (mmkvLoad, applySnapshot, loadProofs, hydrateCounters, recoverOrphans, loadRecentTx, total) plus proofCount and stateBytes into a single info-level summary, so a background NWC wake shows exactly where cold-hydration time goes. Drives the Stage 4 lean-hydration decision (hypothesis: applySnapshot + loadProofs dominate). Measurement only; no behavior change. Co-Authored-By: Claude Opus 4.8 --- src/models/helpers/setupRootStore.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index 01c4ec9..1ce6483 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -77,6 +77,7 @@ export async function setupRootStore(rootStore: RootStore) { // hydrate unspent and pending ecash proofs to model from database 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 @@ -84,6 +85,7 @@ export async function setupRootStore(rootStore: RootStore) { // 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 @@ -93,13 +95,27 @@ export async function setupRootStore(rootStore: RootStore) { 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), + loadProofs: Math.round(proofsHydrated - stateHydrated), + 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) { From c515ffcc7387fefaf1949bf81d36fcb524b345d8 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 5 Jun 2026 17:22:22 +0200 Subject: [PATCH 21/25] chore(nwc): separate keychain-token load from loadProofs timing [Stage 0] The stateHydrated marker sat before loadTokensFromKeyChain, so the JWT keychain load was misattributed into the loadProofs bucket. Add a tokensLoaded marker and a distinct loadTokens phase so the breakdown is accurate (matters for the Stage 4 decision and on heavier wallets). Co-Authored-By: Claude Opus 4.8 --- src/models/helpers/setupRootStore.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index 1ce6483..76cd778 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -74,6 +74,7 @@ export async function setupRootStore(rootStore: RootStore) { // 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() @@ -108,7 +109,8 @@ export async function setupRootStore(rootStore: RootStore) { log.info('[setupRootStore] cold hydration phase timings (ms)', { mmkvLoad: Math.round(mmkvLoaded - start), applySnapshot: Math.round(stateHydrated - mmkvLoaded), - loadProofs: Math.round(proofsHydrated - stateHydrated), + 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), From 3b992723ff3230ec53b48b713f5115868abb28f3 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 5 Jun 2026 18:01:27 +0200 Subject: [PATCH 22/25] perf(nwc): lean cold-wake hydration (skip JWT + proofs) [Stage 4a] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measurement showed cold-wake setupRootStore is dominated by the keychain JWT load (~63ms fixed, NWC never uses it) and loadProofs (scales with wallet size, builds an MST node per proof synchronously). Make the background NWC wake skip both. - setupRootStore: {skipTokens, skipProofs} options. skipProofs also skips orphan-reservation recovery (bundled with proof loading). Foreground app-open still runs a FULL setup and SQLite is authoritative, so a lean wake is reconciled when the user opens the app. - notificationService._nwcRequestHandler: lean wake (skipTokens+skipProofs). - ProofsStore.ensureProofsLoaded(): load proofs + orphan recovery on demand (no-op once hydrated). Mutating NWC commands (pay/multi_pay/make_invoice) call it before selecting proofs — covers follow-up commands too, so the worst case of a bug is a gracefully-failed pay, never a wrong spend. - get_balance reads SQLite (proofsRepo.getMintBalanceWithMaxBalance) instead of the MST proof map; the per-connection daily-limit cap is unchanged. - loadRecentTx kept (list_transactions still reads MST history; skipping it would force a rewrite for only ~20ms). Read-only commands now answer with no proof/JWT load; pay loads proofs lazily (its cost is intrinsic anyway). tsc clean, suite 15/163 green. Co-Authored-By: Claude Opus 4.8 --- src/models/NwcStore.ts | 28 +++++++++++++++-------- src/models/ProofsStore.ts | 16 +++++++++++++ src/models/helpers/setupRootStore.ts | 34 +++++++++++++++++++++++----- src/services/db/index.ts | 2 ++ src/services/db/proofsRepo.ts | 23 +++++++++++++++++++ src/services/notificationService.ts | 7 +++++- 6 files changed, 94 insertions(+), 16 deletions(-) diff --git a/src/models/NwcStore.ts b/src/models/NwcStore.ts index 4b8274b..46f1f1d 100644 --- a/src/models/NwcStore.ts +++ b/src/models/NwcStore.ts @@ -11,15 +11,16 @@ 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' @@ -395,7 +396,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 @@ -583,6 +586,13 @@ export const NwcConnectionModel = types.model('NwcConnection', { 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) diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 66841ff..cbb3665 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -540,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/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index 76cd778..ae89412 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -33,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 @@ -70,14 +85,16 @@ export async function setupRootStore(rootStore: RootStore) { 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 @@ -92,9 +109,14 @@ export async function setupRootStore(rootStore: RootStore) { // 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() diff --git a/src/services/db/index.ts b/src/services/db/index.ts index fd8961d..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, @@ -106,6 +107,7 @@ export const Database = { getProofById, getProofs, getProofsByTransaction, + getMintBalanceWithMaxBalance, openReservation, commitReservation, rollbackReservation, diff --git a/src/services/db/proofsRepo.ts b/src/services/db/proofsRepo.ts index 4cac18a..0b6cfd7 100644 --- a/src/services/db/proofsRepo.ts +++ b/src/services/db/proofsRepo.ts @@ -197,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/notificationService.ts b/src/services/notificationService.ts index 81d8d8b..fdbe2cb 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -266,7 +266,12 @@ const _nwcRequestHandler = async function(remoteData: NotifyNwcRequestData) { const {nwcStore} = rootStoreInstance if(nwcStore.all.length === 0) { - await setupRootStore(rootStoreInstance) + // 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 From 0d43095108d99c883f70866cea50f48b79fd6f00 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Sat, 6 Jun 2026 00:12:18 +0200 Subject: [PATCH 23/25] perf(nwc): async melt with lifetime-bound preimage wait [Stage 6] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NWC pay_invoice forced SYNCHRONOUS melt, holding the background context (JS, foreground service, SyncQueue) for the entire lightning settlement — the worst offender for the OS background budget, especially on iOS and for slow/stuck payments. Switch NWC melt to async (preferAsync: true, matching all other flows): the mint ACKs immediately and the existing monitor (ws/poll) finalizes on settlement. payInvoice then waits for the preimage only as long as the background is alive — resolves on ev_asyncMeltResult (fast common case → reply with preimage) or ev_nwcListenerClosing (Stage 2 teardown), bound by a safety cap. No separate per-pay timeout. If still pending at teardown: resolve to a typed 'pending' outcome (no NIP-47 wire reply — never fabricate a false error/success). The payment finalizes later via the monitor / next foreground; zaps confirm via the NIP-57 receipt (recipient's zapper publishes 9735 at settlement, independent of our reply); non-zap clients retry and the mint dedups by payment hash. Daily limit reserved conservatively on the pending path. - transferOperationApi: drop the nwcEvent sync/async distinction. - events.ts: ev_nwcListenerClosing; notificationService emits it on close. - NwcStore: waitForAsyncMeltResult, typed NwcPending outcome, dispatcher skips sending pending, settled path re-reads preimage/amounts from SQLite. tsc clean, suite 15/163 green. Co-Authored-By: Claude Opus 4.8 --- src/models/NwcStore.ts | 133 +++++++++++++++--- src/services/notificationService.ts | 8 +- src/services/wallet/events.ts | 6 + .../wallet/operations/transferOperationApi.ts | 10 +- 4 files changed, 135 insertions(+), 22 deletions(-) diff --git a/src/models/NwcStore.ts b/src/models/NwcStore.ts index 46f1f1d..86ea346 100644 --- a/src/models/NwcStore.ts +++ b/src/models/NwcStore.ts @@ -25,6 +25,7 @@ import { } 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' @@ -60,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, @@ -88,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', @@ -298,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) { @@ -530,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') @@ -552,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) } @@ -580,8 +675,8 @@ 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}) @@ -632,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] }), diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index fdbe2cb..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' @@ -419,10 +420,15 @@ const createNwcListenerNotification = async function () { // subscription for the full 30s after a single quick command. const startedAt = Date.now() const HARD_CAP_MS = 30 * 1000 - const IDLE_GRACE_MS = 8 * 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') await stopForegroundService() 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/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index 8a30eb0..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, { From 24a9a85b9f8b10418fda912d3b0f1b4a3fbc4bd7 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Sat, 6 Jun 2026 17:09:29 +0200 Subject: [PATCH 24/25] perf(nwc): coalesce concurrent KeyChain reads in getCachedWalletKeys [Stage 4a] Several NWC pushes can wake the app simultaneously (e.g. a wallet connecting fires get_balance + list_transactions together, or rapid zaps). Each caller previously raced past the walletKeys cache check before any populated it, so each triggered its own ~2s cold KeyChain read. Park the in-flight read in a volatile promise; concurrent callers await the same fetch instead of hitting secure storage independently. The shared promise resolves to validated keys so the originator and all awaiters get identical success/error. Volatile (never persisted) so a fresh cold start still does exactly one fresh read. Co-Authored-By: Claude Opus 4.8 --- src/models/WalletStore.ts | 55 ++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 386475e..bdf0942 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -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() { From d774fde9cefcbdcbc4eebdc2473a36ff71fa8f54 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Sun, 7 Jun 2026 22:23:37 +0200 Subject: [PATCH 25/25] =?UTF-8?q?chore(release):=20bump=20to=200.4.3-beta.?= =?UTF-8?q?8;=20upgrade=20HotUpdater=200.23.0=E2=86=920.32.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade react-native HotUpdater (0.23.0 → 0.32.0): drops SWCompression / BitByteData pods and bumps OpenSSL-Universal to 3.6.2000. Set the iOS launch scheme to Release. Co-Authored-By: Claude Opus 4.8 --- ios/Podfile.lock | 57 ++--------------- ios/minibits_wallet.xcodeproj/project.pbxproj | 62 +++++++++---------- .../xcschemes/minibits_wallet.xcscheme | 2 +- package.json | 2 +- 4 files changed, 38 insertions(+), 85 deletions(-) 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">