From 24a9a85b9f8b10418fda912d3b0f1b4a3fbc4bd7 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Sat, 6 Jun 2026 17:09:29 +0200 Subject: [PATCH] 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 386475e8..bdf09421 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() {