From 600301ae65b82f9ca0486d83d90b3d0e18957d6d Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 17 Jul 2026 17:17:23 -0700 Subject: [PATCH 01/39] Add a versioned wallet UI-state cache file and Redux seeding action Add walletCache.json (name, fiat code, enabled token IDs, last-known balances) with a versioned cleaner, plus a CURRENCY_WALLET_CACHE_LOADED action that seeds the wallet's Redux slice. Cached balances never overwrite live engine data, and the later authoritative file loads replace the cached values exactly as they replace initial state today. --- src/core/actions.ts | 13 ++++++++++ .../wallet/currency-wallet-cleaners.ts | 24 +++++++++++++++++ .../wallet/currency-wallet-reducer.ts | 26 ++++++++++++++++--- src/core/currency/wallet/wallet-cache-file.ts | 17 ++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 src/core/currency/wallet/wallet-cache-file.ts diff --git a/src/core/actions.ts b/src/core/actions.ts index dfb685e44..c0091911a 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -1,4 +1,5 @@ import { + EdgeBalanceMap, EdgeCorePlugin, EdgeCorePluginsInit, EdgeCurrencyTools, @@ -271,6 +272,18 @@ export type RootAction = tools: Promise } } + | { + // Called when the wallet's UI-state cache file loads from disk, + // seeding Redux before the engine exists. + type: 'CURRENCY_WALLET_CACHE_LOADED' + payload: { + balanceMap: EdgeBalanceMap + enabledTokenIds: string[] + fiatCurrencyCode: string + name: string | null + walletId: string + } + } | { type: 'CURRENCY_WALLET_CHANGED_PAUSED' payload: { diff --git a/src/core/currency/wallet/currency-wallet-cleaners.ts b/src/core/currency/wallet/currency-wallet-cleaners.ts index 4175d4e96..d57252d59 100644 --- a/src/core/currency/wallet/currency-wallet-cleaners.ts +++ b/src/core/currency/wallet/currency-wallet-cleaners.ts @@ -402,6 +402,30 @@ export const asPublicKeyFile = asObject({ }) }) +/** + * Cached wallet UI state, stored in the wallet's local storage. + * This is everything the GUI needs to render a wallet in the wallet list + * before its engine exists. Balances are last-known values, + * and are explicitly allowed to be stale. + */ +export interface WalletCacheFile { + version: 1 + name: string | null + fiatCurrencyCode: string + enabledTokenIds: string[] + + /** Integer strings. The `null` tokenId is spelled '' here. */ + balances: { [tokenId: string]: string } +} + +export const asWalletCacheFile: Cleaner = asObject({ + version: asValue(1), + name: asEither(asString, asNull), + fiatCurrencyCode: asString, + enabledTokenIds: asArray(asString), + balances: asObject(asIntegerString) +}) + /** * The wallet's local storage file for the last seen "checkpoint". The core * does not know the contents of the checkpoint, so it just as an arbitrary diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index 1cf017ae8..2f9cabb9b 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -197,6 +197,8 @@ const currencyWalletInner = buildReducer< return action.payload.enabledTokenIds } else if (action.type === 'CURRENCY_WALLET_ENABLED_TOKENS_CHANGED') { return action.payload.enabledTokenIds + } else if (action.type === 'CURRENCY_WALLET_CACHE_LOADED') { + return action.payload.enabledTokenIds } else if (action.type === 'CURRENCY_ENGINE_DETECTED_TOKENS') { const { enablingTokenIds } = action.payload return uniqueStrings([...state, ...enablingTokenIds]) @@ -282,13 +284,17 @@ const currencyWalletInner = buildReducer< }, fiat(state = '', action): string { - return action.type === 'CURRENCY_WALLET_FIAT_CHANGED' + return action.type === 'CURRENCY_WALLET_FIAT_CHANGED' || + action.type === 'CURRENCY_WALLET_CACHE_LOADED' ? action.payload.fiatCurrencyCode : state }, fiatLoaded(state = false, action): boolean { - return action.type === 'CURRENCY_WALLET_FIAT_CHANGED' ? true : state + return action.type === 'CURRENCY_WALLET_FIAT_CHANGED' || + action.type === 'CURRENCY_WALLET_CACHE_LOADED' + ? true + : state }, files(state = {}, action): TxFileJsons { @@ -352,6 +358,14 @@ const currencyWalletInner = buildReducer< out.set(tokenId, balance) return out } + if (action.type === 'CURRENCY_WALLET_CACHE_LOADED') { + // Seed cached balances, but never overwrite live engine data: + const out = new Map(state) + for (const [tokenId, balance] of action.payload.balanceMap) { + if (!out.has(tokenId)) out.set(tokenId, balance) + } + return out + } return state }, @@ -379,13 +393,17 @@ const currencyWalletInner = buildReducer< }, name(state = null, action): string | null { - return action.type === 'CURRENCY_WALLET_NAME_CHANGED' + return action.type === 'CURRENCY_WALLET_NAME_CHANGED' || + action.type === 'CURRENCY_WALLET_CACHE_LOADED' ? action.payload.name : state }, nameLoaded(state = false, action): boolean { - return action.type === 'CURRENCY_WALLET_NAME_CHANGED' ? true : state + return action.type === 'CURRENCY_WALLET_NAME_CHANGED' || + action.type === 'CURRENCY_WALLET_CACHE_LOADED' + ? true + : state }, seenTxCheckpoint(state = null, action) { diff --git a/src/core/currency/wallet/wallet-cache-file.ts b/src/core/currency/wallet/wallet-cache-file.ts new file mode 100644 index 000000000..1effa3bcc --- /dev/null +++ b/src/core/currency/wallet/wallet-cache-file.ts @@ -0,0 +1,17 @@ +import { makeJsonFile } from '../../../util/file-helpers' +import { asWalletCacheFile } from './currency-wallet-cleaners' + +/** + * Cached wallet UI state, stored on the wallet's local disklet + * alongside `publicKey.json`. See `asWalletCacheFile` for the schema. + */ +export const WALLET_CACHE_FILE = 'walletCache.json' +export const walletCacheFile = makeJsonFile(asWalletCacheFile) + +/** + * Tuning for the wallet UI-state cache saver. + * Tests override the throttle to run quickly. + */ +export const walletCacheSaverConfig = { + throttleMs: 5000 +} From 5105dcc9ca542f8e93a0091b1e872f4093715c43 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 17 Jul 2026 17:17:43 -0700 Subject: [PATCH 02/39] Emit currency wallets before their engines exist Hoist a read of publicKey.json + walletCache.json to the top of the engine pixie, ahead of the storage-wallet sync, and seed Redux from it, so the walletApi gate (which drops its engine condition) opens within one pixie tick on a warm login. Without the cache the gate opens on the same conditions as before, keeping cold-start behavior unchanged. makeCurrencyWalletApi loses its engine and tools constructor parameters. Engine-backed methods wait internally via getEngine(), which bails out if the wallet is deleted mid-wait and rethrows engineFailure so a broken plugin surfaces as a rejected call instead of a hang. Mutations that write synced-repo files gate on the storage wallet instead, which loads well before the engine. otherMethods is guaranteed to be {} pre-engine and switches to the engine's bridgified methods once it lands. A cacheSaver sub-pixie watches the cache-relevant Redux slice and persists it per wallet, throttled to one trailing-edge write per 5s, guarded against post-logout writes, and giving up after 3 consecutive failures. Engine creation and start scheduling are unchanged. --- src/core/currency/currency-selectors.ts | 24 ++- .../currency/wallet/currency-wallet-api.ts | 147 +++++++++++++--- .../currency/wallet/currency-wallet-pixie.ts | 161 ++++++++++++++++-- .../wallet/currency-wallet-reducer.ts | 17 +- 4 files changed, 303 insertions(+), 46 deletions(-) diff --git a/src/core/currency/currency-selectors.ts b/src/core/currency/currency-selectors.ts index 1fca70fd8..df9ac2269 100644 --- a/src/core/currency/currency-selectors.ts +++ b/src/core/currency/currency-selectors.ts @@ -28,20 +28,28 @@ export function getCurrencyMultiplier( return '1' } +/** + * Throws if a wallet has been deleted mid-wait or its engine has failed, + * so `waitFor` conditions surface those as rejections instead of hangs. + */ +export function checkCurrencyWallet(props: RootProps, walletId: string): void { + // If the wallet id doesn't even exist, bail out: + if (props.state.currency.wallets[walletId] == null) { + throw new Error(`Wallet id ${walletId} does not exist in this account`) + } + + // Throw the error if one exists: + const { engineFailure } = props.state.currency.wallets[walletId] + if (engineFailure != null) throw engineFailure +} + export function waitForCurrencyWallet( ai: ApiInput, walletId: string ): Promise { const out: Promise = ai.waitFor( (props: RootProps): EdgeCurrencyWallet | undefined => { - // If the wallet id doesn't even exist, bail out: - if (props.state.currency.wallets[walletId] == null) { - throw new Error(`Wallet id ${walletId} does not exist in this account`) - } - - // Return the error if one exists: - const { engineFailure } = props.state.currency.wallets[walletId] - if (engineFailure != null) throw engineFailure + checkCurrencyWallet(props, walletId) // Return the API if that exists: if (props.output.currency.wallets[walletId] != null) { diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index 094d8ce1a..f8591e721 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -25,6 +25,7 @@ import { EdgeEncodeUri, EdgeGetReceiveAddressOptions, EdgeGetTransactionsOptions, + EdgeOtherMethods, EdgeParsedUri, EdgePaymentProtocolInfo, EdgeReceiveAddress, @@ -45,9 +46,15 @@ import { } from '../../../types/types' import { makeMetaTokens } from '../../account/custom-tokens' import { splitWalletInfo } from '../../login/splitting' -import { toApiInput } from '../../root-pixie' +import { asEdgeStorageKeys } from '../../login/storage-keys' +import { getCurrencyTools } from '../../plugins/plugins-selectors' +import { RootProps, toApiInput } from '../../root-pixie' +import { makeLocalDisklet, makeRepoPaths } from '../../storage/repo' import { makeStorageWalletApi } from '../../storage/storage-api' -import { getCurrencyMultiplier } from '../currency-selectors' +import { + checkCurrencyWallet, + getCurrencyMultiplier +} from '../currency-selectors' import { determineConfirmations, makeCurrencyWalletCallbacks, @@ -92,36 +99,97 @@ type SavedSpendTargets = EdgeTransaction['spendTargets'] */ export function makeCurrencyWalletApi( input: CurrencyWalletInput, - engine: EdgeCurrencyEngine, - tools: EdgeCurrencyTools, publicWalletInfo: EdgeWalletInfo ): EdgeCurrencyWallet { const ai = toApiInput(input) + const { walletId } = input.props const { accountId, pluginId, walletInfo } = input.props.walletState const plugin = input.props.state.plugins.currency[pluginId] const { unsafeBroadcastTx = false, unsafeMakeSpend = false } = plugin.currencyInfo + /** + * The wallet API object exists before the engine does, + * so engine-backed methods wait for the engine internally. + * Bail out if the wallet is deleted mid-wait, and re-throw + * `engineFailure` so a broken plugin surfaces as a rejected + * method call instead of a hang. + */ + function getEngine(): Promise { + return ai.waitFor((props: RootProps): EdgeCurrencyEngine | undefined => { + checkCurrencyWallet(props, walletId) + return props.output.currency.wallets[walletId]?.engine + }) + } + + async function getTools(): Promise { + return await getCurrencyTools(ai, pluginId) + } + + /** + * Methods that write synced-repo files need the storage wallet, + * not the engine. The repo loads well before the engine, + * so this wait is much shorter than `getEngine`. + * A ready repo always wins: an unrelated engine failure must not + * break storage-backed methods, so the failure check only matters + * while the repo is still missing (the engine pixie died before + * `addStorageWallet`, so the repo is never coming). + */ + function getStorage(): Promise { + return ai.waitFor((props: RootProps): true | undefined => { + if (props.state.storageWallets[walletId] != null) return true + checkCurrencyWallet(props, walletId) + }) + } + const storageWalletApi = makeStorageWalletApi(ai, walletInfo) + // The storage-wallet state provides the disklets once the repo loads, + // but the wallet API can emit slightly earlier from the UI-state cache, + // so lazily build the identical disklets as a synchronous fallback: + let fallbackDisklets: { disklet: Disklet; localDisklet: Disklet } | undefined + function getFallbackDisklets(): { disklet: Disklet; localDisklet: Disklet } { + if (fallbackDisklets == null) { + const { io } = ai.props + const localDisklet = makeLocalDisklet(io, walletId) + bridgifyObject(localDisklet) + fallbackDisklets = { + disklet: makeRepoPaths(io, asEdgeStorageKeys(walletInfo.keys)).disklet, + localDisklet + } + } + return fallbackDisklets + } + const fakeCallbacks = makeCurrencyWalletCallbacks(input) - const otherMethods: { [name: string]: (...args: any[]) => any } = {} - if (engine.otherMethods != null) { - for (const name of Object.keys(engine.otherMethods)) { - const method = engine.otherMethods[name] - if (typeof method !== 'function') continue - otherMethods[name] = method + // The core guarantees `otherMethods` is `{}` (never `undefined`) before + // the engine exists, so property probes stay safe on the GUI side. + // Once the engine lands, the pixie watcher's `update` propagates the + // engine's bridgified methods through the same getter: + const emptyOtherMethods = bridgifyObject({}) + let engineOtherMethods: EdgeOtherMethods | undefined + + function makeEngineOtherMethods( + engine: EdgeCurrencyEngine + ): EdgeOtherMethods { + const otherMethods: { [name: string]: (...args: any[]) => any } = {} + if (engine.otherMethods != null) { + for (const name of Object.keys(engine.otherMethods)) { + const method = engine.otherMethods[name] + if (typeof method !== 'function') continue + otherMethods[name] = method + } } - } - if (engine.otherMethodsWithKeys != null) { - for (const name of Object.keys(engine.otherMethodsWithKeys)) { - const method = engine.otherMethodsWithKeys[name] - if (typeof method !== 'function') continue - otherMethods[name] = (...args) => method(walletInfo.keys, ...args) + if (engine.otherMethodsWithKeys != null) { + for (const name of Object.keys(engine.otherMethodsWithKeys)) { + const method = engine.otherMethodsWithKeys[name] + if (typeof method !== 'function') continue + otherMethods[name] = (...args) => method(walletInfo.keys, ...args) + } } + return bridgifyObject(otherMethods) } - bridgifyObject(otherMethods) const out: EdgeCurrencyWallet & InternalWalletMethods = { on: onMethod, @@ -132,6 +200,9 @@ export function makeCurrencyWalletApi( return walletInfo.created }, get disklet(): Disklet { + if (input.props.state.storageWallets[walletId] == null) { + return getFallbackDisklets().disklet + } return storageWalletApi.disklet }, get id(): string { @@ -141,10 +212,18 @@ export function makeCurrencyWalletApi( return walletInfo.imported === true }, get localDisklet(): Disklet { + if (input.props.state.storageWallets[walletId] == null) { + return getFallbackDisklets().localDisklet + } return storageWalletApi.localDisklet }, - publicWalletInfo, + get publicWalletInfo(): EdgeWalletInfo { + // The cache-loaded value may be upgraded (re-derived) later, + // so always serve the latest one from Redux: + return input.props.walletState.publicWalletInfo ?? publicWalletInfo + }, async sync(): Promise { + await getStorage() await storageWalletApi.sync() }, get type(): string { @@ -156,6 +235,7 @@ export function makeCurrencyWalletApi( return input.props.walletState.name }, async renameWallet(name: string): Promise { + await getStorage() await renameCurrencyWallet(input, name) }, @@ -164,6 +244,7 @@ export function makeCurrencyWalletApi( return input.props.walletState.fiat }, async setFiatCurrencyCode(fiatCurrencyCode: string): Promise { + await getStorage() await setCurrencyWalletFiat(input, fiatCurrencyCode) }, @@ -206,6 +287,7 @@ export function makeCurrencyWalletApi( if (input.props.walletState.currencyInfo.hasWalletSettings !== true) { throw new Error('Wallet settings unsupported') } + await getStorage() await saveWalletSettingsFile(input, settings) }, @@ -270,6 +352,7 @@ export function makeCurrencyWalletApi( // Transactions history: async getNumTransactions(opts: EdgeTokenIdOptions): Promise { + const engine = await getEngine() const upgradedCurrency = upgradeCurrencyCode({ allTokens: input.props.state.accounts[accountId].allTokens[pluginId], currencyInfo: plugin.currencyInfo, @@ -282,6 +365,7 @@ export function makeCurrencyWalletApi( async $internalStreamTransactions( opts: EdgeStreamTransactionOptions ): Promise { + const engine = await getEngine() const { afterDate, batchSize = 10, @@ -416,6 +500,7 @@ export function makeCurrencyWalletApi( async getAddresses( opts: EdgeGetReceiveAddressOptions ): Promise { + const engine = await getEngine() if (engine.getAddresses != null) { return await engine.getAddresses(opts) } else { @@ -515,12 +600,15 @@ export function makeCurrencyWalletApi( // Sending: async broadcastTx(tx: EdgeTransaction): Promise { + const engine = await getEngine() + // Only provide wallet info if currency requires it: const privateKeys = unsafeBroadcastTx ? walletInfo.keys : undefined return await engine.broadcastTx(tx, { privateKeys }) }, async getMaxSpendable(spendInfo: EdgeSpendInfo): Promise { + const engine = await getEngine() return await getMaxSpendableInner( spendInfo, plugin, @@ -532,6 +620,7 @@ export function makeCurrencyWalletApi( async getPaymentProtocolInfo( paymentProtocolUrl: string ): Promise { + const engine = await getEngine() if (engine.getPaymentProtocolInfo == null) { throw new Error( "'getPaymentProtocolInfo' is not implemented on wallets of this type" @@ -540,6 +629,7 @@ export function makeCurrencyWalletApi( return await engine.getPaymentProtocolInfo(paymentProtocolUrl) }, async makeSpend(spendInfo: EdgeSpendInfo): Promise { + const engine = await getEngine() spendInfo = upgradeMemos(spendInfo, plugin.currencyInfo) const { assetAction, @@ -634,6 +724,7 @@ export function makeCurrencyWalletApi( return tx }, async saveTx(transaction: EdgeTransaction): Promise { + const engine = await getEngine() if (input.props.walletState.txs[transaction.txid] == null) { const { fileName, txFile } = await setupNewTxMetadata( input, @@ -653,6 +744,7 @@ export function makeCurrencyWalletApi( }, async saveTxAction(opts): Promise { + await getEngine() const { txid, tokenId, assetAction, savedAction } = opts await updateCurrencyWalletTxMetadata( input, @@ -666,6 +758,7 @@ export function makeCurrencyWalletApi( }, async saveTxMetadata(opts: EdgeSaveTxMetadataOptions): Promise { + await getEngine() const { txid, tokenId, metadata } = opts await updateCurrencyWalletTxMetadata( @@ -681,6 +774,7 @@ export function makeCurrencyWalletApi( bytes: Uint8Array, opts: EdgeSignMessageOptions = {} ): Promise { + const engine = await getEngine() const privateKeys = walletInfo.keys if (engine.signBytes != null) { @@ -706,6 +800,7 @@ export function makeCurrencyWalletApi( message: string, opts: EdgeSignMessageOptions = {} ): Promise { + const engine = await getEngine() if (engine.signMessage == null) { throw new Error(`${pluginId} doesn't support signing messages`) } @@ -713,11 +808,13 @@ export function makeCurrencyWalletApi( return await engine.signMessage(message, privateKeys, opts) }, async signTx(tx: EdgeTransaction): Promise { + const engine = await getEngine() const privateKeys = walletInfo.keys return await engine.signTx(tx, privateKeys) }, async sweepPrivateKeys(spendInfo: EdgeSpendInfo): Promise { + const engine = await getEngine() if (engine.sweepPrivateKeys == null) { throw new Error('Sweeping this currency is not supported.') } @@ -726,6 +823,7 @@ export function makeCurrencyWalletApi( // Accelerating: async accelerate(tx: EdgeTransaction): Promise { + const engine = await getEngine() if (engine.accelerate == null) return null return await engine.accelerate(tx) }, @@ -737,9 +835,11 @@ export function makeCurrencyWalletApi( // Wallet management: async dumpData(): Promise { + const engine = await getEngine() return await engine.dumpData() }, async resyncBlockchain(): Promise { + const engine = await getEngine() const shortId = input.props.walletId.slice(0, 2) input.props.log.warn(`enabledTokenIds: ${shortId} resyncBlockchain`) ai.props.dispatch({ @@ -764,6 +864,7 @@ export function makeCurrencyWalletApi( // URI handling: async encodeUri(options: EdgeEncodeUri): Promise { + const tools = await getTools() return await tools.encodeUri( options, makeMetaTokens( @@ -772,6 +873,7 @@ export function makeCurrencyWalletApi( ) }, async parseUri(uri: string, currencyCode?: string): Promise { + const tools = await getTools() const parsedUri = await tools.parseUri( uri, currencyCode, @@ -792,7 +894,14 @@ export function makeCurrencyWalletApi( }, // Generic: - otherMethods + get otherMethods(): EdgeOtherMethods { + const engine = input.props.walletOutput?.engine + if (engine == null) return emptyOtherMethods + if (engineOtherMethods == null) { + engineOtherMethods = makeEngineOtherMethods(engine) + } + return engineOtherMethods + } } return bridgifyObject(out) diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index 1a2c62553..bd0233f50 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -10,6 +10,7 @@ import { import { update } from 'yaob' import { + EdgeBalanceMap, EdgeCurrencyEngine, EdgeCurrencyTools, EdgeCurrencyWallet, @@ -24,6 +25,7 @@ import { makeTokenInfo } from '../../account/custom-tokens' import { makeLog } from '../../log/log' import { getCurrencyTools } from '../../plugins/plugins-selectors' import { RootProps, toApiInput } from '../../root-pixie' +import { makeLocalDisklet } from '../../storage/repo' import { addStorageWallet, SYNC_INTERVAL, @@ -38,7 +40,11 @@ import { makeCurrencyWalletCallbacks, watchCurrencyWallet } from './currency-wallet-callbacks' -import { asIntegerString, asPublicKeyFile } from './currency-wallet-cleaners' +import { + asIntegerString, + asPublicKeyFile, + WalletCacheFile +} from './currency-wallet-cleaners' import { loadAddressFiles, loadFiatFile, @@ -55,6 +61,11 @@ import { initialWalletSettings } from './currency-wallet-reducer' import { tokenIdsToCurrencyCodes, uniqueStrings } from './enabled-tokens' +import { + WALLET_CACHE_FILE, + walletCacheFile, + walletCacheSaverConfig +} from './wallet-cache-file' export interface CurrencyWalletOutput { readonly walletApi: EdgeCurrencyWallet | undefined @@ -82,8 +93,42 @@ export const walletPixie: TamePixie = combinePixies({ const { currencyCode } = plugin.currencyInfo try { - // Start the data sync: const ai = toApiInput(input) + + // Load the UI-state cache before the storage-wallet sync, + // so a previously-seen wallet can emit its API object right away. + // If either file is missing or invalid (first login, schema bump, + // corruption), skip the dispatch and fall through to the cold path: + const cacheDisklet = makeLocalDisklet(input.props.io, walletInfo.id) + const [publicKeyCache, walletCache] = await Promise.all([ + publicKeyFile.load(cacheDisklet, PUBLIC_KEY_CACHE), + walletCacheFile.load(cacheDisklet, WALLET_CACHE_FILE) + ]) + if (publicKeyCache != null && walletCache != null) { + const balanceMap: EdgeBalanceMap = new Map() + for (const tokenId of Object.keys(walletCache.balances)) { + balanceMap.set( + tokenId === '' ? null : tokenId, + walletCache.balances[tokenId] + ) + } + input.props.dispatch({ + type: 'CURRENCY_WALLET_PUBLIC_INFO', + payload: { walletInfo: publicKeyCache.walletInfo, walletId } + }) + input.props.dispatch({ + type: 'CURRENCY_WALLET_CACHE_LOADED', + payload: { + balanceMap, + enabledTokenIds: walletCache.enabledTokenIds, + fiatCurrencyCode: walletCache.fiatCurrencyCode, + name: walletCache.name, + walletId + } + }) + } + + // Start the data sync: await addStorageWallet(ai, walletInfo) // Grab the freshly-synced repos: @@ -202,19 +247,11 @@ export const walletPixie: TamePixie = combinePixies({ // Creates the API object: walletApi: (input: CurrencyWalletInput) => async () => { - const { walletOutput, walletState } = input.props - if (walletOutput == null) return - const { engine } = walletOutput - const { nameLoaded, pluginId, publicWalletInfo } = walletState - if (engine == null || publicWalletInfo == null || !nameLoaded) return - const tools = await getCurrencyTools(toApiInput(input), pluginId) - - const currencyWalletApi = makeCurrencyWalletApi( - input, - engine, - tools, - publicWalletInfo - ) + const { walletState } = input.props + const { nameLoaded, publicWalletInfo } = walletState + if (publicWalletInfo == null || !nameLoaded) return + + const currencyWalletApi = makeCurrencyWalletApi(input, publicWalletInfo) input.onOutput(currencyWalletApi) return await stopUpdates @@ -472,6 +509,100 @@ export const walletPixie: TamePixie = combinePixies({ } }, + /** + * Watches the wallet's cache-relevant Redux state and persists it to + * `walletCache.json`, so the next login can render this wallet before + * its engine exists. Writes are throttled to at most one per wallet per + * `walletCacheSaverConfig.throttleMs` (trailing edge), never happen + * after logout, and stop after 3 consecutive failures to avoid log spam. + */ + cacheSaver(input: CurrencyWalletInput) { + interface CacheSnapshot { + balanceMap: EdgeBalanceMap + enabledTokenIds: string[] + fiat: string + name: string | null + } + + let failures = 0 + let lastSaved: CacheSnapshot | undefined + let timer: ReturnType | undefined + + async function doSave(): Promise { + timer = undefined + const { state, walletId, walletState } = input.props + + // Never write after logout: + if (state.accounts[walletState.accountId] == null) return + + const snapshot: CacheSnapshot = { + balanceMap: walletState.balanceMap, + enabledTokenIds: walletState.enabledTokenIds, + fiat: walletState.fiat, + name: walletState.name + } + const balances: WalletCacheFile['balances'] = {} + for (const [tokenId, balance] of snapshot.balanceMap) { + balances[tokenId ?? ''] = balance + } + + try { + await walletCacheFile.save( + makeLocalDisklet(input.props.io, walletId), + WALLET_CACHE_FILE, + { + version: 1, + name: snapshot.name, + fiatCurrencyCode: snapshot.fiat, + enabledTokenIds: snapshot.enabledTokenIds, + balances + } + ) + failures = 0 + lastSaved = snapshot + } catch (error: unknown) { + if (++failures >= 3) { + input.props.log.error( + `Wallet cache saver giving up after ${failures} failures: ${String( + error + )}` + ) + } + } + } + + return { + update() { + const { walletState } = input.props + if (walletState == null) return + if (failures >= 3 || timer != null) return + + // Wait until the authoritative files have loaded, + // so a cold start never caches placeholder values: + const { fiatLoaded, nameLoaded, tokenFileLoaded } = walletState + if (!fiatLoaded || !nameLoaded || !tokenFileLoaded) return + + if ( + lastSaved != null && + lastSaved.balanceMap === walletState.balanceMap && + lastSaved.enabledTokenIds === walletState.enabledTokenIds && + lastSaved.fiat === walletState.fiat && + lastSaved.name === walletState.name + ) { + return + } + + timer = setTimeout(() => { + doSave().catch(error => input.props.onError(error)) + }, walletCacheSaverConfig.throttleMs) + }, + + destroy() { + if (timer != null) clearTimeout(timer) + } + } + }, + watcher(input: CurrencyWalletInput) { let lastState: CurrencyWalletState | undefined let lastUserSettings: object = {} diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index 2f9cabb9b..69881f179 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -192,8 +192,11 @@ const currencyWalletInner = buildReducer< ), enabledTokenIds: sortStringsReducer( - (state = initialTokenIds, action): string[] => { + (state = initialTokenIds, action, next, prev): string[] => { if (action.type === 'CURRENCY_WALLET_LOADED_TOKEN_FILE') { + // Unsaved user changes win over the file we just loaded, + // and stay dirty, so the tokenSaver writes them back out: + if (prev?.self.tokenFileDirty === true) return state return action.payload.enabledTokenIds } else if (action.type === 'CURRENCY_WALLET_ENABLED_TOKENS_CHANGED') { return action.payload.enabledTokenIds @@ -226,6 +229,10 @@ const currencyWalletInner = buildReducer< tokenFileDirty(state = false, action, next, prev): boolean { switch (action.type) { case 'CURRENCY_WALLET_LOADED_TOKEN_FILE': + // Stay dirty if the user changed tokens before the file loaded, + // so the tokenSaver writes those changes back out: + return state + case 'CURRENCY_WALLET_SAVED_TOKEN_FILE': // The file has been synced to disk, so it's not dirty: return false @@ -583,12 +590,14 @@ export function mergeTx( type StringsReducer = ( state: string[] | undefined, - action: RootAction + action: RootAction, + next?: CurrencyWalletNext, + prev?: CurrencyWalletNext ) => string[] function sortStringsReducer(reducer: StringsReducer): StringsReducer { - return (state, action) => { - const out = reducer(state, action) + return (state, action, next, prev) => { + const out = reducer(state, action, next, prev) if (out === state) return state out.sort((a, b) => (a === b ? 0 : a > b ? 1 : -1)) From ed985f46d156bca42a6c4f1816f8e895bcaa99a9 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 17 Jul 2026 17:18:05 -0700 Subject: [PATCH 03/39] Add deterministic tests for the wallet UI-state cache The fake currency plugin gains a test-controlled engine gate, so "before the engine exists" is a controlled state instead of a race, and the cache saver throttle drops to 50ms under test. The suite covers cold-start equivalence, cached emission, live-data overwrite, pending engine-gated calls (completion, engine failure, wallet deletion), renames inside the cache window, cancelled post-logout writes, corrupt cache files, saver behavior, and the otherMethods pre-engine guarantee. --- CHANGELOG.md | 4 + .../core/currency/wallet/wallet-cache.test.ts | 370 ++++++++++++++++++ test/fake/fake-currency-plugin.ts | 53 ++- 3 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 test/core/currency/wallet/wallet-cache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b15bfa580..abc50ea7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- added: Per-wallet `walletCache.json` with each wallet's name, fiat code, enabled token IDs, and last-known balances. Currency wallets now emit their API objects as soon as this cache loads, before their engines exist, so the GUI can render the wallet list immediately at login. Engine-backed methods wait for the engine internally and reject if it fails or the wallet is deleted. First login (no cache) behaves exactly as before. +- changed: `waitForCurrencyWallet` and `waitForAllWallets` now resolve when the wallet object exists, which can be before its engine loads. +- changed: `wallet.otherMethods` is `{}` until the wallet's engine loads, then switches to the engine's methods. + ## 2.47.1 (2026-07-17) - fixed: Revert `@nymproject/mix-fetch` to v1 (1.4.4), restoring the pinned gateway and network requester. The v2 stack shipped in 2.47.0 fails to complete small HTTPS JSON-RPC requests through most exit nodes and its exit-node auto-discovery rarely converges, which left wallets with NYM privacy enabled unable to sync or send. diff --git a/test/core/currency/wallet/wallet-cache.test.ts b/test/core/currency/wallet/wallet-cache.test.ts new file mode 100644 index 000000000..70ee1f3e5 --- /dev/null +++ b/test/core/currency/wallet/wallet-cache.test.ts @@ -0,0 +1,370 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' + +import { walletCacheSaverConfig } from '../../../../src/core/currency/wallet/wallet-cache-file' +import { + EdgeContext, + EdgeCurrencyWallet, + makeFakeEdgeWorld +} from '../../../../src/index' +import { snooze } from '../../../../src/util/snooze' +import { expectRejection } from '../../../expect-rejection' +import { + createEngineGate, + fakePluginTestConfig +} from '../../../fake/fake-currency-plugin' +import { fakeUser } from '../../../fake/fake-user' + +const contextOptions = { apiKey: '', appId: '', deviceDescription: 'iphone12' } +const quiet = { onLog() {} } + +// Generous wait for the throttled cache saver (50ms in tests) to write: +const SAVE_WAIT_MS = 300 + +// Short wait to prove something has *not* happened: +const RACE_WAIT_MS = 150 + +interface CachedWorld { + context: EdgeContext + walletId: string +} + +/** + * Logs in once without any engine gate, decorates the fakecoin wallet + * with recognizable values, waits for the cache saver to persist them, + * and logs out. The returned context has a warm cache on disk. + */ +async function makeCachedWorld(): Promise { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('Broken test account') + const wallet = await account.waitForCurrencyWallet(walletInfo.id) + + await wallet.renameWallet('Cached Name') + await wallet.changeEnabledTokenIds(['badf00d5']) + await account.currencyConfig.fakecoin.changeUserSettings({ + balance: 12345, + tokenBalance: 45 + }) + + // Let the callbacks propagate and the throttled saver write: + await snooze(SAVE_WAIT_MS) + await account.logout() + + return { context, walletId: walletInfo.id } +} + +describe('wallet cache', function () { + beforeEach(function () { + fakePluginTestConfig.engineGate = undefined + walletCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.engineGate = undefined + walletCacheSaverConfig.throttleMs = 5000 + }) + + it('cold login without cache files matches master behavior', async function () { + this.timeout(15000) + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First-ever login on this device, with engine creation blocked: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('Broken test account') + + // With no cache, the wallet must not emit while the engine is blocked: + await snooze(RACE_WAIT_MS) + expect(account.currencyWallets[walletInfo.id]).equals(undefined) + + // Releasing the engine lets the wallet finish loading as on master: + release() + const wallet = await account.waitForCurrencyWallet(walletInfo.id) + expect(wallet.name).equals('Fake Wallet') + await account.logout() + }) + + it('warm login emits cached wallet before the engine exists', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // The wallet emits from the cache while the engine is still blocked: + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.name).equals('Cached Name') + expect(wallet.fiatCurrencyCode).equals('iso:USD') + expect(wallet.enabledTokenIds).deep.equals(['badf00d5']) + expect(wallet.balanceMap.get(null)).equals('12345') + expect(wallet.balances.FAKE).equals('12345') + expect(wallet.balances.TOKEN).equals('45') + + release() + await account.logout() + }) + + it('live engine data overwrites cached values on the same object', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.balances.FAKE).equals('12345') + + release() + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 777 }) + await snooze(SAVE_WAIT_MS) + + // Live data lands on the very same wallet object: + expect(wallet.balances.FAKE).equals('777') + expect(account.currencyWallets[walletId]).equals(wallet) + await account.logout() + }) + + it('makeSpend pends during the cache window and then completes', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + let settled = false + const spendPromise = wallet + .makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }) + .then(tx => { + settled = true + return tx + }) + + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + + release() + const tx = await spendPromise + expect(tx.txid).equals('spend') + await account.logout() + }) + + it('engine failure rejects calls pending on the engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, fail } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + const spendPromise = wallet.makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }) + + fail(new Error('Engine exploded')) + await expectRejection(spendPromise, 'Error: Engine exploded') + await account.logout() + }) + + it('storage-backed methods survive an engine failure', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, fail } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + fail(new Error('Engine exploded')) + + // Engine-backed methods reject, but the repo is healthy, + // so storage-backed methods keep working: + await expectRejection( + wallet.makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }), + 'Error: Engine exploded' + ) + await wallet.renameWallet('Renamed After Failure') + expect(wallet.name).equals('Renamed After Failure') + await account.logout() + }) + + it('deleting the wallet rejects calls pending on the engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + const spendPromise = wallet.makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }) + + await account.changeWalletStates({ [walletId]: { deleted: true } }) + + // The pixie tree tears down, so the pending call must reject, + // not dangle forever: + await expectRejection(spendPromise) + release() + await account.logout() + }) + + it('renameWallet during the cache window updates Redux and the cache file', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + // The repo loads before the engine, so renames work in the window: + await wallet.renameWallet('Renamed In Window') + expect(wallet.name).equals('Renamed In Window') + + // The saver picks up the change: + await snooze(SAVE_WAIT_MS) + release() + await account.logout() + + // The next gated login sees the new name from the cache: + const { gate: gate2, release: release2 } = createEngineGate() + fakePluginTestConfig.engineGate = gate2 + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.name).equals('Renamed In Window') + release2() + await account2.logout() + }) + + it('logout during a pending throttled save cancels the write', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Slow the saver down so its write is still pending at logout: + walletCacheSaverConfig.throttleMs = 3000 + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.renameWallet('Ghost Name') + await account.logout() + + // The cancelled write must not have touched the cache: + walletCacheSaverConfig.throttleMs = 50 + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.name).equals('Cached Name') + release() + await account2.logout() + }) + + it('rejects a corrupt cache file, falls back cold, and re-saves', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Corrupt the cache file after the saver has settled: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await snooze(SAVE_WAIT_MS) + await wallet.localDisklet.setText('walletCache.json', '{ "version": 99 }') + await account.logout() + + // A corrupt file means the cold path runs: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await snooze(RACE_WAIT_MS) + expect(account2.currencyWallets[walletId]).equals(undefined) + + // Releasing the engine loads the wallet, and the saver rewrites the file: + release() + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.name).equals('Cached Name') + await snooze(SAVE_WAIT_MS) + await account2.logout() + + // The rewritten file feeds the next gated login: + const { gate: gate3, release: release3 } = createEngineGate() + fakePluginTestConfig.engineGate = gate3 + const account3 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet3 = await account3.waitForCurrencyWallet(walletId) + expect(wallet3.name).equals('Cached Name') + release3() + await account3.logout() + }) + + it('balance changes reach the cache file within a throttle window', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForCurrencyWallet(walletId) + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 999 }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.balances.FAKE).equals('999') + release() + await account2.logout() + }) + + it('otherMethods is {} pre-engine and carries engine methods post-engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + // Pre-engine, otherMethods is a safe empty object: + expect(wallet.otherMethods).not.equals(undefined) + expect(Object.keys(wallet.otherMethods)).deep.equals([]) + + release() + await waitForOtherMethods(wallet) + expect(await wallet.otherMethods.testMethod('hello')).equals( + 'testMethod called with: hello' + ) + await account.logout() + }) +}) + +/** Polls until the engine's otherMethods replace the empty pre-engine ones. */ +async function waitForOtherMethods(wallet: EdgeCurrencyWallet): Promise { + for (let i = 0; i < 100; ++i) { + if (wallet.otherMethods.testMethod != null) return + await snooze(50) + } + throw new Error('otherMethods never arrived') +} diff --git a/test/fake/fake-currency-plugin.ts b/test/fake/fake-currency-plugin.ts index bffa847ec..aef98cd86 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -27,6 +27,41 @@ import { upgradeCurrencyCode } from '../../src/types/type-helpers' const GENESIS_BLOCK = 1231006505 +/** + * Test configuration for controlling fake plugin behavior. + */ +export interface FakePluginTestConfig { + /** + * If set, engine creation will wait for this promise to resolve. + * Use `createEngineGate` to make a controllable gate, + * so "before the engine exists" is a deterministic state in tests. + */ + engineGate?: Promise +} + +export const fakePluginTestConfig: FakePluginTestConfig = { + engineGate: undefined +} + +/** + * Creates a gate that can halt engine creation. + * Call `release` to allow engines to load, + * or `fail` to make engine creation reject. + */ +export function createEngineGate(): { + gate: Promise + release: () => void + fail: (error: Error) => void +} { + let release: () => void = () => {} + let fail: (error: Error) => void = () => {} + const gate = new Promise((resolve, reject) => { + release = resolve + fail = reject + }) + return { gate, release, fail } +} + const fakeTokens: EdgeTokenMap = { badf00d5: { currencyCode: 'TOKEN', @@ -90,6 +125,13 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { private allTokens: EdgeTokenMap = fakeTokens private readonly currencyInfo: EdgeCurrencyInfo + // Exercises the wallet's pre-engine `otherMethods` guarantee in tests: + readonly otherMethods = { + async testMethod(arg: string): Promise { + return `testMethod called with: ${arg}` + } + } + constructor( walletInfo: EdgeWalletInfo, opts: EdgeCurrencyEngineOptions, @@ -223,7 +265,7 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { getBalance(opts: EdgeTokenIdOptions): string { const { tokenId = null } = opts if (tokenId == null) return this.state.balance.toString() - if (tokenId === 'badf00d5') this.state.tokenBalance.toString() + if (tokenId === 'badf00d5') return this.state.tokenBalance.toString() if (this.allTokens[tokenId] != null) return '0' throw new Error('Unknown currency') } @@ -398,13 +440,14 @@ export function makeFakeCurrencyPlugin( return Promise.resolve(fakeTokens) }, - makeCurrencyEngine( + async makeCurrencyEngine( walletInfo: EdgeWalletInfo, opts: EdgeCurrencyEngineOptions ): Promise { - return Promise.resolve( - new FakeCurrencyEngine(walletInfo, opts, currencyInfo) - ) + if (fakePluginTestConfig.engineGate != null) { + await fakePluginTestConfig.engineGate + } + return new FakeCurrencyEngine(walletInfo, opts, currencyInfo) }, makeCurrencyTools(): Promise { From c09ac2cbff423c7eb890fc66eb69d22138588e80 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Sun, 19 Jul 2026 01:47:16 -0700 Subject: [PATCH 04/39] Schedule cached wallets' engine startup behind a limited-concurrency queue Wallets that emit from walletCache.json no longer race every other wallet through repo sync, key derivation, and engine creation in the seconds after login. Their heavy startup work now waits in a per-context queue (8 at a time), while wallets without a cache bypass the queue because they cannot emit at all until that work runs, keeping first login identical to before. Asking for a wallet moves it to the front of the line: the account's waitForCurrencyWallet, the internal engine/storage waiters, and changePaused(false) all bump the wallet's queued startup. --- src/core/account/account-api.ts | 5 + src/core/currency/currency-selectors.ts | 15 + .../currency/wallet/currency-wallet-api.ts | 12 + .../currency/wallet/currency-wallet-pixie.ts | 316 ++++++++++-------- src/core/currency/wallet/engine-scheduler.ts | 161 +++++++++ 5 files changed, 370 insertions(+), 139 deletions(-) create mode 100644 src/core/currency/wallet/engine-scheduler.ts diff --git a/src/core/account/account-api.ts b/src/core/account/account-api.ts index e1caa6ece..af0438101 100644 --- a/src/core/account/account-api.ts +++ b/src/core/account/account-api.ts @@ -31,6 +31,7 @@ import { } from '../../types/types' import { makeEdgeResult } from '../../util/edgeResult' import { base58 } from '../../util/encoding' +import { bumpEngineQueue } from '../currency/currency-selectors' import { saveWalletSettings } from '../currency/wallet/currency-wallet-files' import { getPublicWalletInfo } from '../currency/wallet/currency-wallet-pixie' import { @@ -724,6 +725,10 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { }, async waitForCurrencyWallet(walletId: string): Promise { + // Asking for a wallet is the "the user wants this one" signal, + // so move its engine startup to the front of the queue: + bumpEngineQueue(ai, walletId) + return await new Promise((resolve, reject) => { const check = (): void => { const wallet = this.currencyWallets[walletId] diff --git a/src/core/currency/currency-selectors.ts b/src/core/currency/currency-selectors.ts index df9ac2269..01ee5d613 100644 --- a/src/core/currency/currency-selectors.ts +++ b/src/core/currency/currency-selectors.ts @@ -4,6 +4,7 @@ import { EdgeTokenMap } from '../../types/types' import { ApiInput, RootProps } from '../root-pixie' +import { getEngineScheduler } from './wallet/engine-scheduler' export function getCurrencyMultiplier( currencyInfo: EdgeCurrencyInfo, @@ -47,6 +48,10 @@ export function waitForCurrencyWallet( ai: ApiInput, walletId: string ): Promise { + // Asking for a wallet is the "the user wants this one" signal, + // so move its engine startup to the front of the queue: + bumpEngineQueue(ai, walletId) + const out: Promise = ai.waitFor( (props: RootProps): EdgeCurrencyWallet | undefined => { checkCurrencyWallet(props, walletId) @@ -59,3 +64,13 @@ export function waitForCurrencyWallet( ) return out } + +/** + * Prioritizes a wallet's engine startup when its startup work is still + * waiting in the limited-concurrency queue. Harmless otherwise. + */ +export function bumpEngineQueue(ai: ApiInput, walletId: string): void { + if (getEngineScheduler(ai.props.io).bump(walletId)) { + ai.props.log(`${walletId} engine startup bumped to front of queue`) + } +} diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index f8591e721..ce69715e1 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -52,6 +52,7 @@ import { RootProps, toApiInput } from '../../root-pixie' import { makeLocalDisklet, makeRepoPaths } from '../../storage/repo' import { makeStorageWalletApi } from '../../storage/storage-api' import { + bumpEngineQueue, checkCurrencyWallet, getCurrencyMultiplier } from '../currency-selectors' @@ -116,6 +117,9 @@ export function makeCurrencyWalletApi( * method call instead of a hang. */ function getEngine(): Promise { + // The caller needs this engine now, so skip the startup queue: + bumpEngineQueue(ai, walletId) + return ai.waitFor((props: RootProps): EdgeCurrencyEngine | undefined => { checkCurrencyWallet(props, walletId) return props.output.currency.wallets[walletId]?.engine @@ -136,6 +140,10 @@ export function makeCurrencyWalletApi( * `addStorageWallet`, so the repo is never coming). */ function getStorage(): Promise { + // The repo loads inside the queued startup work, so a caller + // waiting on storage wants this wallet at the front too: + bumpEngineQueue(ai, walletId) + return ai.waitFor((props: RootProps): true | undefined => { if (props.state.storageWallets[walletId] != null) return true checkCurrencyWallet(props, walletId) @@ -314,6 +322,10 @@ export function makeCurrencyWalletApi( // Running state: async changePaused(paused: boolean): Promise { + // Un-pausing means the app wants this wallet running, + // so align the startup queue with the caller's boot order: + if (!paused) bumpEngineQueue(ai, walletId) + input.props.dispatch({ type: 'CURRENCY_WALLET_CHANGED_PAUSED', payload: { walletId: input.props.walletId, paused } diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index bd0233f50..82f9609c8 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -61,6 +61,7 @@ import { initialWalletSettings } from './currency-wallet-reducer' import { tokenIdsToCurrencyCodes, uniqueStrings } from './enabled-tokens' +import { getEngineScheduler } from './engine-scheduler' import { WALLET_CACHE_FILE, walletCacheFile, @@ -86,163 +87,200 @@ const publicKeyFile = makeJsonFile(asPublicKeyFile) export const walletPixie: TamePixie = combinePixies({ // Creates the engine for this wallet: - engine: (input: CurrencyWalletInput) => async () => { - const { state, walletId, walletState } = input.props - const { accountId, pluginId, walletInfo } = walletState - const plugin = state.plugins.currency[pluginId] - const { currencyCode } = plugin.currencyInfo - - try { - const ai = toApiInput(input) - - // Load the UI-state cache before the storage-wallet sync, - // so a previously-seen wallet can emit its API object right away. - // If either file is missing or invalid (first login, schema bump, - // corruption), skip the dispatch and fall through to the cold path: - const cacheDisklet = makeLocalDisklet(input.props.io, walletInfo.id) - const [publicKeyCache, walletCache] = await Promise.all([ - publicKeyFile.load(cacheDisklet, PUBLIC_KEY_CACHE), - walletCacheFile.load(cacheDisklet, WALLET_CACHE_FILE) - ]) - if (publicKeyCache != null && walletCache != null) { - const balanceMap: EdgeBalanceMap = new Map() - for (const tokenId of Object.keys(walletCache.balances)) { - balanceMap.set( - tokenId === '' ? null : tokenId, - walletCache.balances[tokenId] + engine(input: CurrencyWalletInput) { + // Set when the wallet is deleted or the user logs out, so startup + // work still waiting in the scheduler queue knows to give up + // (`input.props` goes stale at destroy, so it cannot tell us): + let destroyed = false + + async function update(): Promise { + const { state, walletId, walletState } = input.props + const { accountId, pluginId, walletInfo } = walletState + const plugin = state.plugins.currency[pluginId] + const { currencyCode } = plugin.currencyInfo + + let releaseSlot: (() => void) | undefined + try { + const ai = toApiInput(input) + + // Load the UI-state cache before the storage-wallet sync, + // so a previously-seen wallet can emit its API object right away. + // If either file is missing or invalid (first login, schema bump, + // corruption), skip the dispatch and fall through to the cold path: + const cacheDisklet = makeLocalDisklet(input.props.io, walletInfo.id) + const [publicKeyCache, walletCache] = await Promise.all([ + publicKeyFile.load(cacheDisklet, PUBLIC_KEY_CACHE), + walletCacheFile.load(cacheDisklet, WALLET_CACHE_FILE) + ]) + if (publicKeyCache != null && walletCache != null) { + const balanceMap: EdgeBalanceMap = new Map() + for (const tokenId of Object.keys(walletCache.balances)) { + balanceMap.set( + tokenId === '' ? null : tokenId, + walletCache.balances[tokenId] + ) + } + input.props.dispatch({ + type: 'CURRENCY_WALLET_PUBLIC_INFO', + payload: { walletInfo: publicKeyCache.walletInfo, walletId } + }) + input.props.dispatch({ + type: 'CURRENCY_WALLET_CACHE_LOADED', + payload: { + balanceMap, + enabledTokenIds: walletCache.enabledTokenIds, + fiatCurrencyCode: walletCache.fiatCurrencyCode, + name: walletCache.name, + walletId + } + }) + + // This wallet is already usable from its cache, so its heavy + // startup work (repo sync, key derivation, engine creation) + // waits its turn in a limited-concurrency queue instead of + // racing every other wallet in the seconds after login. + // Wallets without a cache skip the queue: they cannot emit at + // all until this work runs, so they behave exactly as before. + releaseSlot = await getEngineScheduler(input.props.io).acquire( + walletId, + () => { + input.props.log.warn( + `${walletId} engine startup exceeded its slot time; freeing the slot for the next wallet` + ) + } ) + + // The wallet may have been deleted (or the user logged out) + // while it waited in line; the finally releases the slot: + if (destroyed) return + input.props.log(`${walletId} engine startup slot acquired`) } - input.props.dispatch({ - type: 'CURRENCY_WALLET_PUBLIC_INFO', - payload: { walletInfo: publicKeyCache.walletInfo, walletId } - }) - input.props.dispatch({ - type: 'CURRENCY_WALLET_CACHE_LOADED', - payload: { - balanceMap, - enabledTokenIds: walletCache.enabledTokenIds, - fiatCurrencyCode: walletCache.fiatCurrencyCode, - name: walletCache.name, - walletId - } - }) - } - // Start the data sync: - await addStorageWallet(ai, walletInfo) + // Start the data sync: + await addStorageWallet(ai, walletInfo) - // Grab the freshly-synced repos: - const { state } = input.props - const walletLocalDisklet = getStorageWalletLocalDisklet( - state, - walletInfo.id - ) - const walletLocalEncryptedDisklet = - makeStorageWalletLocalEncryptedDisklet( + // Grab the freshly-synced repos: + const { state } = input.props + const walletLocalDisklet = getStorageWalletLocalDisklet( state, - walletInfo.id, - input.props.io + walletInfo.id ) + const walletLocalEncryptedDisklet = + makeStorageWalletLocalEncryptedDisklet( + state, + walletInfo.id, + input.props.io + ) - // We need to know which transactions exist, - // since new transactions may come in from the network: - await loadTxFileNames(input) + // We need to know which transactions exist, + // since new transactions may come in from the network: + await loadTxFileNames(input) - // Derive the public keys: - const tools = await getCurrencyTools(ai, pluginId) - const publicWalletInfo = await getPublicWalletInfo( - walletInfo, - walletLocalDisklet, - tools - ) - input.props.dispatch({ - type: 'CURRENCY_WALLET_PUBLIC_INFO', - payload: { walletInfo: publicWalletInfo, walletId } - }) + // Derive the public keys: + const tools = await getCurrencyTools(ai, pluginId) + const publicWalletInfo = await getPublicWalletInfo( + walletInfo, + walletLocalDisklet, + tools + ) + input.props.dispatch({ + type: 'CURRENCY_WALLET_PUBLIC_INFO', + payload: { walletInfo: publicWalletInfo, walletId } + }) - // Load the last seen transaction checkpoint into memory. - // This also loads subscribed addresses from the same file. - const { checkpoint: seenTxCheckpoint, subscribedAddresses } = - await loadSeenTxCheckpointFile(input) + // Load the last seen transaction checkpoint into memory. + // This also loads subscribed addresses from the same file. + const { checkpoint: seenTxCheckpoint, subscribedAddresses } = + await loadSeenTxCheckpointFile(input) - // We need to know which tokens are enabled, - // so the engine can start in the right state: - await loadTokensFile(input) + // We need to know which tokens are enabled, + // so the engine can start in the right state: + await loadTokensFile(input) - const { hasWalletSettings = false } = walletState.currencyInfo - if (hasWalletSettings) { - await loadWalletSettingsFile(input) - } + const { hasWalletSettings = false } = walletState.currencyInfo + if (hasWalletSettings) { + await loadWalletSettingsFile(input) + } - // Start the engine: - const accountState = state.accounts[accountId] - const engine = await plugin.makeCurrencyEngine(publicWalletInfo, { - callbacks: makeCurrencyWalletCallbacks(input), - - // Engine state kept by the core: - seenTxCheckpoint, - subscribedAddresses, - - // Wallet-scoped IO objects: - log: makeLog( - input.props.logBackend, - `${pluginId}-${walletInfo.id.slice(0, 2)}` - ), - walletLocalDisklet, - walletLocalEncryptedDisklet, - - // User settings: - customTokens: accountState.customTokens[pluginId] ?? {}, - enabledTokenIds: input.props.walletState.allEnabledTokenIds, - userSettings: accountState.userSettings[pluginId] ?? {}, - walletSettings: input.props.walletState.walletSettings - }) - input.onOutput(engine) - - // Grab initial state: - const parentCurrency = { currencyCode, tokenId: null } - const balance = asMaybe(asIntegerString)( - engine.getBalance(parentCurrency) - ) - if (balance != null) { - input.props.dispatch({ - type: 'CURRENCY_ENGINE_CHANGED_BALANCE', - payload: { balance, tokenId: null, walletId } + // Start the engine: + const accountState = state.accounts[accountId] + const engine = await plugin.makeCurrencyEngine(publicWalletInfo, { + callbacks: makeCurrencyWalletCallbacks(input), + + // Engine state kept by the core: + seenTxCheckpoint, + subscribedAddresses, + + // Wallet-scoped IO objects: + log: makeLog( + input.props.logBackend, + `${pluginId}-${walletInfo.id.slice(0, 2)}` + ), + walletLocalDisklet, + walletLocalEncryptedDisklet, + + // User settings: + customTokens: accountState.customTokens[pluginId] ?? {}, + enabledTokenIds: input.props.walletState.allEnabledTokenIds, + userSettings: accountState.userSettings[pluginId] ?? {}, + walletSettings: input.props.walletState.walletSettings }) - } - const height = engine.getBlockHeight() - input.props.dispatch({ - type: 'CURRENCY_ENGINE_CHANGED_HEIGHT', - payload: { height, walletId } - }) - if (engine.getStakingStatus != null) { - await engine.getStakingStatus().then( - stakingStatus => { - input.props.dispatch({ - type: 'CURRENCY_ENGINE_CHANGED_STAKING', - payload: { stakingStatus, walletId } - }) - }, - error => input.props.onError(error) + input.onOutput(engine) + + // Grab initial state: + const parentCurrency = { currencyCode, tokenId: null } + const balance = asMaybe(asIntegerString)( + engine.getBalance(parentCurrency) ) + if (balance != null) { + input.props.dispatch({ + type: 'CURRENCY_ENGINE_CHANGED_BALANCE', + payload: { balance, tokenId: null, walletId } + }) + } + const height = engine.getBlockHeight() + input.props.dispatch({ + type: 'CURRENCY_ENGINE_CHANGED_HEIGHT', + payload: { height, walletId } + }) + if (engine.getStakingStatus != null) { + await engine.getStakingStatus().then( + stakingStatus => { + input.props.dispatch({ + type: 'CURRENCY_ENGINE_CHANGED_STAKING', + payload: { stakingStatus, walletId } + }) + }, + error => input.props.onError(error) + ) + } + + // Load remaining data from disk: + await loadFiatFile(input) + await loadNameFile(input) + await loadAddressFiles(input) + } catch (error: unknown) { + input.props.onError(error) + input.props.dispatch({ + type: 'CURRENCY_ENGINE_FAILED', + payload: { error, walletId } + }) + } finally { + if (releaseSlot != null) releaseSlot() } - // Load remaining data from disk: - await loadFiatFile(input) - await loadNameFile(input) - await loadAddressFiles(input) - } catch (error: unknown) { - input.props.onError(error) - input.props.dispatch({ - type: 'CURRENCY_ENGINE_FAILED', - payload: { error, walletId } - }) - } + // Fire callbacks when our state changes: + watchCurrencyWallet(input) - // Fire callbacks when our state changes: - watchCurrencyWallet(input) + return await stopUpdates + } - return await stopUpdates + return { + update, + destroy() { + destroyed = true + } + } }, // Creates the API object: diff --git a/src/core/currency/wallet/engine-scheduler.ts b/src/core/currency/wallet/engine-scheduler.ts new file mode 100644 index 000000000..2aa95d259 --- /dev/null +++ b/src/core/currency/wallet/engine-scheduler.ts @@ -0,0 +1,161 @@ +/** + * Limits how many wallets may run their heavy engine-startup work + * (repo sync, key derivation, `makeCurrencyEngine`) at once. + * + * Only wallets that already emitted their API object from the UI-state + * cache enter this queue - a wallet with no cache needs its startup work + * before it can emit at all, so it bypasses the queue and first-login + * behavior stays identical to the pre-cache flow. + * + * This module has no dependencies, so anything in the core can import it + * without creating require cycles. + */ + +/** Tests override these to make queue behavior observable. */ +export const engineSchedulerConfig = { + /** How many queued wallets may run their startup work at once: */ + concurrency: 8, + + /** + * A watchdog force-releases any slot held longer than this, + * so one wedged wallet (a hung repo sync or plugin) cannot + * permanently shrink the pool and starve the queue. The wedged + * wallet's work keeps running; the queue just stops waiting for + * it, temporarily admitting one extra wallet - the same unbounded + * behavior every wallet had before this queue existed. + */ + slotTimeoutMs: 30000, + + /** + * How long a bump for a not-yet-queued wallet stays valid. Engine- + * backed method calls keep bumping wallets long after startup, so + * without an expiry every wallet would look "asked for" by the next + * login and the priority signal would mean nothing. + */ + stickyBumpTtlMs: 30000 +} + +export interface EngineScheduler { + /** + * Waits for a free startup slot, then resolves with a `release` + * callback. Call `release` (idempotent) once the startup work + * settles. `onTimeout` fires if the watchdog reclaims the slot + * before `release` is called. + */ + readonly acquire: ( + walletId: string, + onTimeout?: () => void + ) => Promise<() => void> + + /** + * Moves a queued wallet to the front of the line, such as when the + * user opens the wallet or calls one of its engine-backed methods. + * A wallet that has not reached the queue yet is remembered, and + * enters at the front when it arrives. Returns true if the wallet + * actually moved, and false otherwise. + */ + readonly bump: (walletId: string) => boolean +} + +interface QueueEntry { + walletId: string + start: () => void +} + +function makeEngineScheduler(): EngineScheduler { + let running = 0 + const queue: QueueEntry[] = [] + + // Wallets asked-for before their startup work reached the queue, + // stamped with the time of the ask. Consumed when the wallet + // acquires; bounded by the account's wallet count: + const stickyBumps = new Map() + + function startNext(): void { + while (running < engineSchedulerConfig.concurrency && queue.length > 0) { + const entry = queue.shift() + if (entry == null) return + running++ + entry.start() + } + } + + return { + async acquire(walletId, onTimeout) { + let released = false + let watchdog: ReturnType | undefined + const release = (): void => { + if (released) return + released = true + if (watchdog != null) clearTimeout(watchdog) + running-- + startNext() + } + const takeSlot = (): (() => void) => { + stickyBumps.delete(walletId) + watchdog = setTimeout(() => { + if (onTimeout != null) onTimeout() + release() + }, engineSchedulerConfig.slotTimeoutMs) + // Do not hold the process open just for the watchdog (the + // unref method exists on Node timers, not React Native's): + const unrefable = watchdog as { unref?: () => void } + if (unrefable.unref != null) unrefable.unref() + return release + } + + if (running < engineSchedulerConfig.concurrency && queue.length === 0) { + running++ + return takeSlot() + } + + await new Promise(resolve => { + const entry = { walletId, start: resolve } + // A recent bump for this wallet means "the user wants it", + // so it enters at the front of the line: + const bumpedAt = stickyBumps.get(walletId) + stickyBumps.delete(walletId) + if ( + bumpedAt != null && + Date.now() - bumpedAt < engineSchedulerConfig.stickyBumpTtlMs + ) { + queue.unshift(entry) + } else { + queue.push(entry) + } + }) + return takeSlot() + }, + + bump(walletId) { + const index = queue.findIndex(entry => entry.walletId === walletId) + if (index < 0) { + // Not queued (yet): remember the request, so a wallet whose + // startup work is still reading its cache files gets its + // priority when it does join the queue: + stickyBumps.set(walletId, Date.now()) + return false + } + if (index === 0) return false + const [entry] = queue.splice(index, 1) + queue.unshift(entry) + return true + } + } +} + +/** + * One scheduler per core context. The `io` object is created once per + * context and threads through every pixie's props, so it works as the + * context identity without new plumbing: + */ +const schedulers = new WeakMap() + +export function getEngineScheduler(io: object): EngineScheduler { + let scheduler = schedulers.get(io) + if (scheduler == null) { + scheduler = makeEngineScheduler() + schedulers.set(io, scheduler) + } + return scheduler +} From 0ad9cff1307ff9324b5cc1a0e43e7b1095f5c368 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Sun, 19 Jul 2026 01:47:28 -0700 Subject: [PATCH 05/39] Keep the existing balanceMap when a balance report is unchanged Engines re-report balances they already reported, and each report used to allocate a fresh Map. An unchanged balance now keeps the existing Map, so memoized reducers, the wallet cache saver, and yaob's === diffing see no phantom update. --- src/core/currency/wallet/currency-wallet-reducer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index 69881f179..360a276ba 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -361,6 +361,10 @@ const currencyWalletInner = buildReducer< balanceMap(state = new Map(), action): Map { if (action.type === 'CURRENCY_ENGINE_CHANGED_BALANCE') { const { balance, tokenId } = action.payload + // Keep the existing Map when nothing changed, so downstream + // reference checks (memoized reducers, the cache saver, yaob + // diffing) see no phantom update: + if (state.get(tokenId) === balance) return state const out = new Map(state) out.set(tokenId, balance) return out From 4783de3a9e2355f3559dddbe52a72cad3e4670bc Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Sun, 19 Jul 2026 01:47:48 -0700 Subject: [PATCH 06/39] Add deterministic engine-scheduler tests The fake plugin reports each makeCurrencyEngine call through an onEngineCreate hook, so tests can observe creation order. Cases cover concurrency-limited draining, waitForCurrencyWallet bumping a queued wallet to the front, cold wallets bypassing the queue, a deleted queued wallet giving up its place, and balanceMap identity across unchanged balance reports. --- CHANGELOG.md | 2 + .../currency/wallet/engine-scheduler.test.ts | 400 ++++++++++++++++++ test/fake/fake-currency-plugin.ts | 12 +- 3 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 test/core/currency/wallet/engine-scheduler.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index abc50ea7d..f1ed34eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,10 @@ ## Unreleased - added: Per-wallet `walletCache.json` with each wallet's name, fiat code, enabled token IDs, and last-known balances. Currency wallets now emit their API objects as soon as this cache loads, before their engines exist, so the GUI can render the wallet list immediately at login. Engine-backed methods wait for the engine internally and reject if it fails or the wallet is deleted. First login (no cache) behaves exactly as before. +- added: Cached wallets' engine startup is staggered through a limited-concurrency queue (8 at a time) instead of all racing at login. Asking for a wallet via `waitForCurrencyWallet`, calling an engine- or storage-backed method, or un-pausing it moves it to the front of the queue. Wallets without a cache skip the queue, so first login is unaffected. - changed: `waitForCurrencyWallet` and `waitForAllWallets` now resolve when the wallet object exists, which can be before its engine loads. - changed: `wallet.otherMethods` is `{}` until the wallet's engine loads, then switches to the engine's methods. +- changed: `EdgeCurrencyWallet.balanceMap` keeps its object identity when an engine re-reports an unchanged balance. ## 2.47.1 (2026-07-17) diff --git a/test/core/currency/wallet/engine-scheduler.test.ts b/test/core/currency/wallet/engine-scheduler.test.ts new file mode 100644 index 000000000..2710aa677 --- /dev/null +++ b/test/core/currency/wallet/engine-scheduler.test.ts @@ -0,0 +1,400 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' + +import { + engineSchedulerConfig, + getEngineScheduler +} from '../../../../src/core/currency/wallet/engine-scheduler' +import { walletCacheSaverConfig } from '../../../../src/core/currency/wallet/wallet-cache-file' +import { EdgeContext, makeFakeEdgeWorld } from '../../../../src/index' +import { snooze } from '../../../../src/util/snooze' +import { + createEngineGate, + fakePluginTestConfig +} from '../../../fake/fake-currency-plugin' +import { fakeUser } from '../../../fake/fake-user' + +const contextOptions = { apiKey: '', appId: '', deviceDescription: 'iphone12' } +const quiet = { onLog() {} } + +// Generous wait for the throttled cache saver (50ms in tests) to write: +const SAVE_WAIT_MS = 300 + +// Short wait to prove something has *not* happened: +const RACE_WAIT_MS = 150 + +interface MultiWalletWorld { + context: EdgeContext + walletIds: string[] +} + +/** + * Logs in once, pads the account out to at least `count` fakecoin + * wallets, waits for their cache files to persist, and logs out. + * The next login on the returned context is a warm start where every + * wallet's engine startup enters the scheduler queue. The returned + * ids cover EVERY wallet in the account (the fake user starts with + * two), so length comparisons against engine-creation counts hold. + */ +async function makeMultiWalletWorld(count: number): Promise { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletIds = account.activeWalletIds.filter( + walletId => account.getWalletInfo(walletId)?.type === 'wallet:fakecoin' + ) + if (walletIds.length !== account.activeWalletIds.length) { + throw new Error('Broken test account: unexpected non-fakecoin wallets') + } + while (walletIds.length < count) { + const wallet = await account.createCurrencyWallet('wallet:fakecoin', { + fiatCurrencyCode: 'iso:USD', + name: `Wallet ${walletIds.length}` + }) + walletIds.push(wallet.id) + } + + // Let the throttled saver persist each wallet's cache file: + await snooze(SAVE_WAIT_MS) + await account.logout() + + return { context, walletIds } +} + +/** Returns a sorted copy, so order-insensitive comparisons read cleanly. */ +function sorted(list: string[]): string[] { + return [...list].sort((a, b) => a.localeCompare(b)) +} + +/** Polls until `condition` holds, or fails the test after ~5s. */ +async function pollUntil(condition: () => boolean): Promise { + for (let i = 0; i < 100; ++i) { + if (condition()) return + await snooze(50) + } + throw new Error('Timed out waiting for a test condition') +} + +describe('engine scheduler', function () { + beforeEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.onEngineCreate = undefined + walletCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.onEngineCreate = undefined + walletCacheSaverConfig.throttleMs = 5000 + engineSchedulerConfig.concurrency = 8 + engineSchedulerConfig.slotTimeoutMs = 30000 + engineSchedulerConfig.stickyBumpTtlMs = 30000 + }) + + it('runs cached startup work at the configured concurrency', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(3) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Every wallet emits from its cache while the queue is stuck: + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + + // The gate blocks the slot holder inside `makeCurrencyEngine`, + // so with one slot, exactly one creation can begin. Wait for it + // (a fixed sleep would flake on slow machines), then hold long + // enough to prove no second creation sneaks through: + await pollUntil(() => created.length === 1) + await snooze(RACE_WAIT_MS) + expect(created.length).equals(1) + + // Releasing the gate drains the queue one wallet at a time, + // and every engine still starts: + release() + await pollUntil(() => created.length === walletIds.length) + expect(sorted(created)).deep.equals(sorted(walletIds)) + await account.logout() + }) + + it('waitForCurrencyWallet moves a queued wallet to the front', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(4) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + // Pick the wallet at the back of the line and ask for it: + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const target = queued[queued.length - 1] + await account.waitForCurrencyWallet(target) + + // Once the slot frees up, the bumped wallet goes next: + release() + await pollUntil(() => created.length === walletIds.length) + expect(created[1]).equals(target) + await account.logout() + }) + + it('cold wallets skip the queue entirely', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(3) + + // Erase the cache files, making the next login a cold start. + // Let the login's own cache save settle first, so nothing + // rewrites the files after the deletes: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForAllWallets() + await snooze(SAVE_WAIT_MS) + for (const walletId of walletIds) { + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.localDisklet.delete('walletCache.json') + } + await account.logout() + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + // Cold wallets cannot emit until their startup work runs, + // so they bypass the queue: every creation begins even though + // the single queue slot never opens: + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => created.length === walletIds.length) + + release() + await account2.waitForAllWallets() + await account2.logout() + }) + + it('a wallet deleted while queued gives up its place in line', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(3) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + // Delete a wallet that is still waiting in the queue: + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const deleted = queued[0] + await account.changeWalletStates({ [deleted]: { deleted: true } }) + + // The queue keeps moving: the deleted wallet never creates an + // engine, and the remaining wallets still get theirs: + release() + const survivors = walletIds.filter(walletId => walletId !== deleted) + await pollUntil(() => created.length === survivors.length) + expect(sorted(created)).deep.equals(sorted(survivors)) + await snooze(RACE_WAIT_MS) + expect(created.includes(deleted)).equals(false) + await account.logout() + }) + + it('admits wallets up to the configured concurrency', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(3) + + engineSchedulerConfig.concurrency = 2 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // With two slots and three wallets, exactly two creations begin: + await pollUntil(() => created.length === 2) + await snooze(RACE_WAIT_MS) + expect(created.length).equals(2) + + release() + await pollUntil(() => created.length === walletIds.length) + await account.logout() + }) + + it('an engine-backed method call bumps a queued wallet', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(4) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + // makeSpend waits on the engine internally, which bumps the queue: + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const target = queued[queued.length - 1] + const spendPromise = account.currencyWallets[target].makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }) + + release() + await pollUntil(() => created.length === walletIds.length) + expect(created[1]).equals(target) + await spendPromise + await account.logout() + }) + + it('a storage-backed method call bumps a queued wallet', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(4) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + // The repo loads inside the queued startup work, so a rename + // pends on it and bumps this wallet to the front: + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const target = queued[queued.length - 1] + const renamePromise = + account.currencyWallets[target].renameWallet('Bumped Name') + + release() + await pollUntil(() => created.length === walletIds.length) + expect(created[1]).equals(target) + await renamePromise + expect(account.currencyWallets[target].name).equals('Bumped Name') + await account.logout() + }) + + it('changePaused(false) bumps a queued wallet', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(4) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const target = queued[queued.length - 1] + await account.currencyWallets[target].changePaused(false) + + release() + await pollUntil(() => created.length === walletIds.length) + expect(created[1]).equals(target) + await account.logout() + }) + + it('a bump before the wallet reaches the queue still counts', async function () { + engineSchedulerConfig.concurrency = 1 + const scheduler = getEngineScheduler({}) + + const releaseFirst = await scheduler.acquire('first') + + // Ask for a wallet that has not reached the queue yet: + scheduler.bump('wanted') + + const order: string[] = [] + const otherPromise = scheduler.acquire('other').then(release => { + order.push('other') + return release + }) + const wantedPromise = scheduler.acquire('wanted').then(release => { + order.push('wanted') + return release + }) + + // "other" queued first, but the sticky bump front-loads "wanted": + releaseFirst() + const releaseWanted = await wantedPromise + expect(order).deep.equals(['wanted']) + releaseWanted() + const releaseOther = await otherPromise + expect(order).deep.equals(['wanted', 'other']) + releaseOther() + }) + + it('the watchdog frees a slot held too long', async function () { + engineSchedulerConfig.concurrency = 1 + engineSchedulerConfig.slotTimeoutMs = 50 + const scheduler = getEngineScheduler({}) + + let timedOut = false + await scheduler.acquire('wedged', () => { + timedOut = true + }) + + // Never release "wedged"; the watchdog frees the slot anyway: + const release = await scheduler.acquire('next') + expect(timedOut).equals(true) + release() + }) + + it('unchanged balances keep the same balanceMap object', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(1) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 20 }) + await pollUntil(() => wallet.balanceMap.get(null) === '20') + + // Re-reporting the same balance must not produce a new map: + const before = wallet.balanceMap + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 20 }) + await snooze(RACE_WAIT_MS) + expect(wallet.balanceMap).equals(before) + + // A real change still lands: + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 21 }) + await pollUntil(() => wallet.balanceMap.get(null) === '21') + expect(wallet.balanceMap).not.equals(before) + await account.logout() + }) +}) diff --git a/test/fake/fake-currency-plugin.ts b/test/fake/fake-currency-plugin.ts index aef98cd86..da34a95cf 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -37,10 +37,17 @@ export interface FakePluginTestConfig { * so "before the engine exists" is a deterministic state in tests. */ engineGate?: Promise + + /** + * If set, receives each wallet id as its `makeCurrencyEngine` call + * begins (before any gate), so tests can observe creation order. + */ + onEngineCreate?: (walletId: string) => void } export const fakePluginTestConfig: FakePluginTestConfig = { - engineGate: undefined + engineGate: undefined, + onEngineCreate: undefined } /** @@ -444,6 +451,9 @@ export function makeFakeCurrencyPlugin( walletInfo: EdgeWalletInfo, opts: EdgeCurrencyEngineOptions ): Promise { + if (fakePluginTestConfig.onEngineCreate != null) { + fakePluginTestConfig.onEngineCreate(walletInfo.id) + } if (fakePluginTestConfig.engineGate != null) { await fakePluginTestConfig.engineGate } From ff930d22ebf372387b12319c8fa8dbb476403976 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 17:21:04 -0700 Subject: [PATCH 07/39] Seed the account boot state from a local cache A warm login reads accountCache.json (wallet states, custom tokens, plugin settings) from the account's local disklet right after waitForPlugins, seeds Redux through a new ACCOUNT_CACHE_LOADED action, and emits the account API object immediately. The repo sync and file loads still run and overwrite the seeded state authoritatively, with dirty-wins guards so user changes made during the window survive. Once the account cache seeds currencyWalletIds, one bulk loader reads every active wallet's cache files concurrently and seeds them all in a single CURRENCY_WALLETS_CACHE_LOADED dispatch, so a warm login costs two seeding dispatches total instead of two per wallet. The per-wallet read inside the wallet pixie remains as the fallback for cold logins and wallets activated after login. A throttled account cache saver persists the state after the authoritative loads land; its dirty set includes account-level custom tokens. The cold path (no cache file) boots exactly as before. --- src/core/account/account-cache-file.ts | 17 ++ src/core/account/account-cleaners.ts | 52 ++++- src/core/account/account-files.ts | 29 ++- src/core/account/account-pixie.ts | 201 +++++++++++++++++- src/core/account/account-reducer.ts | 129 ++++++++++- src/core/account/data-store-api.ts | 21 +- src/core/account/plugin-api.ts | 6 +- src/core/actions.ts | 25 +++ .../currency/wallet/currency-wallet-pixie.ts | 70 +++--- .../wallet/currency-wallet-reducer.ts | 31 ++- src/core/currency/wallet/wallet-cache-file.ts | 14 ++ .../currency/wallet/wallet-cache-loader.ts | 113 ++++++++++ src/core/storage/storage-api.ts | 28 +++ 13 files changed, 668 insertions(+), 68 deletions(-) create mode 100644 src/core/account/account-cache-file.ts create mode 100644 src/core/currency/wallet/wallet-cache-loader.ts diff --git a/src/core/account/account-cache-file.ts b/src/core/account/account-cache-file.ts new file mode 100644 index 000000000..6eed47e68 --- /dev/null +++ b/src/core/account/account-cache-file.ts @@ -0,0 +1,17 @@ +import { makeJsonFile } from '../../util/file-helpers' +import { asAccountCacheFile } from './account-cleaners' + +/** + * Cached account boot state, stored on the account's local disklet. + * See `asAccountCacheFile` for the schema. + */ +export const ACCOUNT_CACHE_FILE = 'accountCache.json' +export const accountCacheFile = makeJsonFile(asAccountCacheFile) + +/** + * Tuning for the account boot-state cache saver. + * Tests override the throttle to run quickly. + */ +export const accountCacheSaverConfig = { + throttleMs: 5000 +} diff --git a/src/core/account/account-cleaners.ts b/src/core/account/account-cleaners.ts index 4785f9ff5..833d416be 100644 --- a/src/core/account/account-cleaners.ts +++ b/src/core/account/account-cleaners.ts @@ -4,11 +4,20 @@ import { asNumber, asObject, asOptional, - asString + asString, + asValue, + Cleaner } from 'cleaners' import { asBase16 } from '../../types/server-cleaners' -import { EdgeDenomination, EdgeToken } from '../../types/types' +import { + EdgeDenomination, + EdgePluginMap, + EdgeToken, + EdgeTokenMap, + EdgeWalletState, + EdgeWalletStates +} from '../../types/types' import { asJsonObject } from '../../util/file-helpers' import { SwapSettings } from './account-types' @@ -92,3 +101,42 @@ export const asGuiSettingsFile = asObject({ export const asCustomTokensFile = asObject({ customTokens: asObject(asObject(asEdgeToken)) }) + +/** + * Cached account boot state, stored on the account's local disklet. + * This is what the deferred account file loads would produce, + * so wallet pixies can start before the account repo syncs. + * Values are last-known and explicitly allowed to be stale; + * the authoritative loads overwrite them within seconds. + * Never contains private key material: wallet keys stay in the + * encrypted login stash, which is already in memory at login. + * Plugin settings are deliberately excluded: unlike wallet states + * and token definitions, they can hold credentials (custom node + * auth, API keys), which must never leave the encrypted repo. + */ +export interface AccountCacheFile { + version: 1 + customTokens: EdgePluginMap + /** + * True when the account has legacy Airbitz-repo wallets. Their + * wallet infos cannot be cached (they contain private keys), so + * such accounts boot cold rather than briefly hiding wallets. + */ + legacyWallets: boolean + walletStates: EdgeWalletStates +} + +const asEdgeWalletState = asObject({ + archived: asOptional(asBoolean), + deleted: asOptional(asBoolean), + hidden: asOptional(asBoolean), + migratedFromWalletId: asOptional(asString), + sortIndex: asOptional(asNumber) +}) + +export const asAccountCacheFile: Cleaner = asObject({ + version: asValue(1), + customTokens: asObject(asObject(asEdgeToken)), + legacyWallets: asOptional(asBoolean, false), + walletStates: asObject(asEdgeWalletState) +}) diff --git a/src/core/account/account-files.ts b/src/core/account/account-files.ts index 25e65c329..da481a07e 100644 --- a/src/core/account/account-files.ts +++ b/src/core/account/account-files.ts @@ -42,6 +42,26 @@ function different(a: any, b: any): boolean { return false } +/** + * Waits until the account's storage wallet exists. A cache-seeded + * login emits the account API object before `addStorageWallet` runs, + * so methods that touch the synced repo pend briefly instead of + * throwing during that window. Rejects if the account logs out. + */ +export function waitForAccountRepo( + ai: ApiInput, + accountId: string +): Promise { + return ai.waitFor(props => { + const accountState = props.state.accounts[accountId] + if (accountState == null) { + throw new Error('The account was logged out') + } + const { accountWalletInfo } = accountState + if (props.state.storageWallets[accountWalletInfo.id] != null) return true + }) +} + /** * Loads the legacy wallet list from the account folder. */ @@ -148,6 +168,7 @@ export async function changeWalletStates( accountId: string, newStates: EdgeWalletStates ): Promise { + await waitForAccountRepo(ai, accountId) const { accountWalletInfo, walletStates } = ai.props.state.accounts[accountId] const disklet = getStorageWalletDisklet(ai.props.state, accountWalletInfo.id) @@ -189,7 +210,11 @@ export async function changeWalletStates( ai.props.dispatch({ type: 'ACCOUNT_CHANGED_WALLET_STATES', - payload: { accountId, walletStates: { ...walletStates, ...toWrite } } + payload: { + accountId, + walletStates: { ...walletStates, ...toWrite }, + changedIds: walletIds + } }) } @@ -202,6 +227,7 @@ export async function changePluginUserSettings( pluginId: string, userSettings: object ): Promise { + await waitForAccountRepo(ai, accountId) const { accountWalletInfo } = ai.props.state.accounts[accountId] const disklet = getStorageWalletDisklet(ai.props.state, accountWalletInfo.id) @@ -237,6 +263,7 @@ export async function changeSwapSettings( pluginId: string, swapSettings: SwapSettings ): Promise { + await waitForAccountRepo(ai, accountId) const { accountWalletInfo } = ai.props.state.accounts[accountId] const disklet = getStorageWalletDisklet(ai.props.state, accountWalletInfo.id) diff --git a/src/core/account/account-pixie.ts b/src/core/account/account-pixie.ts index 0359d8ee2..748811951 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -13,19 +13,31 @@ import { EdgeAccount, EdgeCurrencyWallet, EdgePluginMap, - EdgeTokenMap + EdgeTokenMap, + EdgeWalletInfo, + EdgeWalletStates } from '../../types/types' import { makePeriodicTask } from '../../util/periodic-task' import { snooze } from '../../util/snooze' +import { + bulkLoadWalletCaches, + walletCacheLoaderHooks +} from '../currency/wallet/wallet-cache-loader' import { syncLogin } from '../login/login' import { waitForPlugins } from '../plugins/plugins-selectors' import { RootProps, toApiInput } from '../root-pixie' +import { makeLocalDisklet } from '../storage/repo' import { addStorageWallet, SYNC_INTERVAL, syncStorageWallet } from '../storage/storage-actions' import { makeAccountApi } from './account-api' +import { + ACCOUNT_CACHE_FILE, + accountCacheFile, + accountCacheSaverConfig +} from './account-cache-file' import { loadAllWalletStates, reloadPluginSettings } from './account-files' import { AccountState, initialCustomTokens } from './account-reducer' import { @@ -78,7 +90,7 @@ const accountPixie: TamePixie = combinePixies({ async update() { const ai = toApiInput(input) const { accountId, accountState, log } = input.props - const { accountWalletInfos } = accountState + const { accountWalletInfo, accountWalletInfos } = accountState async function loadAllFiles(): Promise { await Promise.all([ @@ -88,9 +100,7 @@ const accountPixie: TamePixie = combinePixies({ ]) } - try { - // Wait for the currency plugins (should already be loaded by now): - await waitForPlugins(ai) + async function loadEverything(): Promise { await loadBuiltinTokens(ai, accountId) log.warn('Login: currency plugins exist') @@ -102,11 +112,87 @@ const accountPixie: TamePixie = combinePixies({ await loadAllFiles() log.warn('Login: loaded files') + } + + let emitted = false + try { + // Wait for the currency plugins (should already be loaded by now): + await waitForPlugins(ai) + + // Try the account boot cache. On a hit, seed Redux and emit + // the API object right away, so wallets can start from their + // own caches without waiting for the repo sync or file loads. + // The loads below then overwrite the seeded state + // authoritatively. On a miss (first login, schema bump, + // corruption) or an account with legacy Airbitz wallets + // (their infos cannot be cached), this is today's boot, + // unchanged: + const accountCache = await accountCacheFile.load( + makeLocalDisklet(ai.props.io, accountWalletInfo.id), + ACCOUNT_CACHE_FILE + ) + if (accountCache != null && !accountCache.legacyWallets) { + input.props.dispatch({ + type: 'ACCOUNT_CACHE_LOADED', + payload: { + accountId, + customTokens: accountCache.customTokens, + walletStates: accountCache.walletStates + } + }) + input.onOutput(makeAccountApi(ai, accountId)) + emitted = true + log.warn('Login: emitted account from cache') + if (walletCacheLoaderHooks.onAccountSeed != null) { + walletCacheLoaderHooks.onAccountSeed(accountId) + } + + // Seed every wallet's cache in one dispatch: + await bulkLoadWalletCaches(ai, accountId) + + // The GUI already has the account, so retry transient + // failures instead of leaving the session half-loaded + // (a stuck `*Loaded` flag would disable the cache saver): + for (let attempt = 1; ; ++attempt) { + try { + await loadEverything() + break + } catch (error: unknown) { + if (ai.props.state.accounts[accountId] == null) { + return await stopUpdates + } + log.error( + `Login: deferred account load failed (attempt ${attempt}): ${String( + error + )}` + ) + if (attempt >= 3) { + input.props.onError(error) + break + } + await snooze(5000) + } + } + log.warn('Login: complete') + return await stopUpdates + } + + await loadEverything() // Create the API object: input.onOutput(makeAccountApi(ai, accountId)) log.warn('Login: complete') } catch (error: unknown) { + // The account may have logged out while we were loading: + if (ai.props.state.accounts[accountId] == null) { + return await stopUpdates + } + if (emitted) { + // The GUI already has the account, so surface the failure + // instead of wedging the login: + log.error(`Login: cache-seeded boot failed: ${String(error)}`) + input.props.onError(error) + } input.props.dispatch({ type: 'ACCOUNT_LOAD_FAILED', payload: { accountId, error } @@ -199,10 +285,15 @@ const accountPixie: TamePixie = combinePixies({ let lastTokens: EdgePluginMap = initialCustomTokens return async function update() { - const { accountId, accountState } = input.props + const { accountId, accountState, state } = input.props - const { customTokens } = accountState + const { accountWalletInfo, customTokens } = accountState if (customTokens !== lastTokens && lastTokens !== initialCustomTokens) { + // The synced repo may not exist yet (cache-seeded boot); + // return without adopting `customTokens`, so this same diff + // triggers the write once `addStorageWallet` finishes: + if (state.storageWallets[accountWalletInfo.id] == null) return + await saveCustomTokens(toApiInput(input), accountId).catch(error => input.props.onError(error) ) @@ -212,6 +303,102 @@ const accountPixie: TamePixie = combinePixies({ } }, + /** + * Watches the account's cache-relevant Redux state and persists it + * to `accountCache.json`, so the next login can start its wallets + * before the account repo loads. Writes are throttled (trailing + * edge), never happen after logout, and stop after 3 consecutive + * failures to avoid log spam. The dirty set deliberately includes + * account-level `customTokens`: a saver that misses those wipes + * custom tokens on the next warm login. + */ + cacheSaver(input: AccountInput) { + interface CacheSnapshot { + customTokens: EdgePluginMap + legacyWalletInfos: EdgeWalletInfo[] + walletStates: EdgeWalletStates + } + + let failures = 0 + let lastSaved: CacheSnapshot | undefined + let timer: ReturnType | undefined + + async function doSave(): Promise { + timer = undefined + const { accountId, accountState, state } = input.props + + // Never write after logout: + if (state.accounts[accountId] == null) return + + const snapshot: CacheSnapshot = { + customTokens: accountState.customTokens, + legacyWalletInfos: accountState.legacyWalletInfos, + walletStates: accountState.walletStates + } + + // Only legacy wallets that actually surface as currency wallets + // force a cold boot; a legacy repo whose wallet type has no + // loaded plugin was never visible in the first place: + const { currencyWalletIds } = accountState + const legacyWallets = snapshot.legacyWalletInfos.some(info => + currencyWalletIds.includes(info.id) + ) + + try { + await accountCacheFile.save( + makeLocalDisklet(input.props.io, accountState.accountWalletInfo.id), + ACCOUNT_CACHE_FILE, + { + version: 1, + customTokens: snapshot.customTokens, + legacyWallets, + walletStates: snapshot.walletStates + } + ) + failures = 0 + lastSaved = snapshot + } catch (error: unknown) { + if (++failures >= 3) { + input.props.log.error( + `Account cache saver giving up after ${failures} failures: ${String( + error + )}` + ) + } + } + } + + return { + update() { + const { accountState } = input.props + if (accountState == null) return + if (failures >= 3 || timer != null) return + + // Wait until the authoritative files have loaded, + // so a cold start never caches placeholder values: + const { customTokensLoaded, walletStatesLoaded } = accountState + if (!customTokensLoaded || !walletStatesLoaded) return + + if ( + lastSaved != null && + lastSaved.customTokens === accountState.customTokens && + lastSaved.legacyWalletInfos === accountState.legacyWalletInfos && + lastSaved.walletStates === accountState.walletStates + ) { + return + } + + timer = setTimeout(() => { + doSave().catch(error => input.props.onError(error)) + }, accountCacheSaverConfig.throttleMs) + }, + + destroy() { + if (timer != null) clearTimeout(timer) + } + } + }, + watcher(input: AccountInput) { let lastState: AccountState | undefined // let lastWallets diff --git a/src/core/account/account-reducer.ts b/src/core/account/account-reducer.ts index 86f37d714..50e8c5d52 100644 --- a/src/core/account/account-reducer.ts +++ b/src/core/account/account-reducer.ts @@ -38,10 +38,13 @@ export interface AccountState { readonly activeWalletIds: string[] readonly archivedWalletIds: string[] readonly hiddenWalletIds: string[] + readonly bulkWalletSeedPending: boolean readonly keysLoaded: boolean readonly legacyWalletInfos: EdgeWalletInfo[] readonly walletInfos: WalletInfoFullMap readonly walletStates: EdgeWalletStates + readonly walletStatesDirtyIds: string[] + readonly walletStatesLoaded: boolean readonly pauseWallets: boolean // Login stuff: @@ -61,9 +64,12 @@ export interface AccountState { readonly allTokens: EdgePluginMap readonly builtinTokens: EdgePluginMap readonly customTokens: EdgePluginMap + readonly customTokensDirty: boolean + readonly customTokensLoaded: boolean readonly alwaysEnabledTokenIds: EdgePluginMap readonly swapSettings: EdgePluginMap readonly userSettings: EdgePluginMap + readonly pluginSettingsDirty: boolean } export interface AccountNext { @@ -186,7 +192,10 @@ const accountInner = buildReducer({ ), keysLoaded(state = false, action): boolean { - return action.type === 'ACCOUNT_KEYS_LOADED' ? true : state + return action.type === 'ACCOUNT_KEYS_LOADED' || + action.type === 'ACCOUNT_CACHE_LOADED' + ? true + : state }, legacyWalletInfos(state = [], action): EdgeWalletInfo[] { @@ -206,11 +215,43 @@ const accountInner = buildReducer({ } ), - walletStates(state = {}, action): EdgeWalletStates { - return action.type === 'ACCOUNT_CHANGED_WALLET_STATES' || - action.type === 'ACCOUNT_KEYS_LOADED' - ? action.payload.walletStates - : state + walletStates(state = {}, action, next, prev): EdgeWalletStates { + switch (action.type) { + case 'ACCOUNT_CACHE_LOADED': + case 'ACCOUNT_CHANGED_WALLET_STATES': + return action.payload.walletStates + + case 'ACCOUNT_KEYS_LOADED': { + // User changes made while this load was reading the disk win + // over the values the load saw (the disk already has them, + // since `changeWalletStates` writes before it dispatches): + const dirtyIds = prev.self?.walletStatesDirtyIds ?? [] + if (dirtyIds.length === 0) return action.payload.walletStates + + const out = { ...action.payload.walletStates } + for (const id of dirtyIds) { + if (state[id] != null) out[id] = state[id] + } + return out + } + } + return state + }, + + walletStatesDirtyIds(state = [], action): string[] { + switch (action.type) { + case 'ACCOUNT_CHANGED_WALLET_STATES': + return [...state, ...action.payload.changedIds] + + case 'ACCOUNT_KEYS_LOADED': + // The load has landed, and `walletStates` merged these ids: + return state.length === 0 ? state : [] + } + return state + }, + + walletStatesLoaded(state = false, action): boolean { + return action.type === 'ACCOUNT_KEYS_LOADED' ? true : state }, pauseWallets(state = false, action): boolean { @@ -314,10 +355,19 @@ const accountInner = buildReducer({ customTokens( state = initialCustomTokens, - action + action, + next, + prev ): EdgePluginMap { switch (action.type) { + case 'ACCOUNT_CACHE_LOADED': { + const { customTokens } = action.payload + return customTokens + } case 'ACCOUNT_CUSTOM_TOKENS_LOADED': { + // Unsaved user changes win over the file we just loaded, + // and stay dirty, so the tokenSaver writes them back out: + if (prev.self?.customTokensDirty) return state const { customTokens } = action.payload return customTokens } @@ -345,6 +395,25 @@ const accountInner = buildReducer({ return state }, + customTokensDirty(state = false, action, next, prev): boolean { + switch (action.type) { + case 'ACCOUNT_CUSTOM_TOKEN_ADDED': + case 'ACCOUNT_CUSTOM_TOKEN_REMOVED': + // These actions might change the token list, so check for diffs: + return state || next.self.customTokens !== prev.self?.customTokens + + case 'ACCOUNT_CUSTOM_TOKENS_LOADED': + // The load has landed; `customTokens` kept any dirty changes, + // and the tokenSaver will write those back out: + return false + } + return state + }, + + customTokensLoaded(state = false, action): boolean { + return action.type === 'ACCOUNT_CUSTOM_TOKENS_LOADED' ? true : state + }, + alwaysEnabledTokenIds(state = {}, action): EdgePluginMap { switch (action.type) { case 'ACCOUNT_ALWAYS_ENABLED_TOKENS_CHANGED': { @@ -355,9 +424,12 @@ const accountInner = buildReducer({ return state }, - swapSettings(state = {}, action): EdgePluginMap { + swapSettings(state = {}, action, next, prev): EdgePluginMap { switch (action.type) { case 'ACCOUNT_PLUGIN_SETTINGS_LOADED': + // Unsaved user changes win over the file we just loaded + // (the change already wrote the file before dispatching): + if (prev.self?.pluginSettingsDirty) return state return action.payload.swapSettings case 'ACCOUNT_SWAP_SETTINGS_CHANGED': { @@ -370,7 +442,7 @@ const accountInner = buildReducer({ return state }, - userSettings(state = {}, action): EdgePluginMap { + userSettings(state = {}, action, next, prev): EdgePluginMap { switch (action.type) { case 'ACCOUNT_PLUGIN_SETTINGS_CHANGED': { const { pluginId, userSettings } = action.payload @@ -380,9 +452,45 @@ const accountInner = buildReducer({ } case 'ACCOUNT_PLUGIN_SETTINGS_LOADED': + // Unsaved user changes win over the file we just loaded + // (the change already wrote the file before dispatching): + if (prev.self?.pluginSettingsDirty) return state return action.payload.userSettings } return state + }, + + pluginSettingsDirty(state = false, action): boolean { + switch (action.type) { + case 'ACCOUNT_PLUGIN_SETTINGS_CHANGED': + case 'ACCOUNT_SWAP_SETTINGS_CHANGED': + return true + + case 'ACCOUNT_PLUGIN_SETTINGS_LOADED': + // The load has landed; the settings reducers kept dirty state: + return false + } + return state + }, + + bulkWalletSeedPending(state = false, action): boolean { + switch (action.type) { + case 'ACCOUNT_CACHE_LOADED': + // The account pixie's bulk loader is about to read every + // wallet's cache files and seed them in a single dispatch; + // wallet pixies hold their own fallback reads until then: + return true + + case 'CURRENCY_WALLETS_CACHE_LOADED': + return false + + case 'ACCOUNT_KEYS_LOADED': + // Backstop: if the bulk loader somehow died, the authoritative + // load unwedges the wallet pixies (they fall back to their own + // reads): + return false + } + return state } }) @@ -393,7 +501,8 @@ export const accountReducer = filterReducer< RootAction >(accountInner, (action, next) => { if ( - /^ACCOUNT_/.test(action.type) && + (/^ACCOUNT_/.test(action.type) || + action.type === 'CURRENCY_WALLETS_CACHE_LOADED') && 'payload' in action && typeof action.payload === 'object' && 'accountId' in action.payload && diff --git a/src/core/account/data-store-api.ts b/src/core/account/data-store-api.ts index cf5e22ae5..9014d463c 100644 --- a/src/core/account/data-store-api.ts +++ b/src/core/account/data-store-api.ts @@ -1,5 +1,5 @@ import { asObject, asString } from 'cleaners' -import { justFiles, justFolders } from 'disklet' +import { Disklet, justFiles, justFolders } from 'disklet' import { bridgifyObject } from 'yaob' import { EdgeDataStore } from '../../types/types' @@ -9,6 +9,7 @@ import { getStorageWalletDisklet, hashStorageWalletFilename } from '../storage/storage-selectors' +import { waitForAccountRepo } from './account-files' /** * Each data store folder has a "Name.json" file with this format. @@ -34,9 +35,16 @@ export function makeDataStoreApi( accountId: string ): EdgeDataStore { const { accountWalletInfo } = ai.props.state.accounts[accountId] - const disklet = getStorageWalletDisklet(ai.props.state, accountWalletInfo.id) - // Path manipulation: + // A cache-seeded login emits the account API object before the + // account's storage wallet exists, so resolve the disklet on + // demand instead of at construction time: + async function getDisklet(): Promise { + await waitForAccountRepo(ai, accountId) + return getStorageWalletDisklet(ai.props.state, accountWalletInfo.id) + } + + // Path manipulation (only call once the storage wallet exists): const hashName = (data: string): string => hashStorageWalletFilename(ai.props.state, accountWalletInfo.id, data) const getStorePath = (storeId: string): string => @@ -46,14 +54,17 @@ export function makeDataStoreApi( const out: EdgeDataStore = { async deleteItem(storeId: string, itemId: string): Promise { + const disklet = await getDisklet() await disklet.delete(getItemPath(storeId, itemId)) }, async deleteStore(storeId: string): Promise { + const disklet = await getDisklet() await disklet.delete(getStorePath(storeId)) }, async listItemIds(storeId: string): Promise { + const disklet = await getDisklet() const itemIds: string[] = [] const paths = justFiles(await disklet.list(getStorePath(storeId))) await Promise.all( @@ -66,6 +77,7 @@ export function makeDataStoreApi( }, async listStoreIds(): Promise { + const disklet = await getDisklet() const storeIds: string[] = [] const paths = justFolders(await disklet.list('Plugins')) await Promise.all( @@ -78,6 +90,7 @@ export function makeDataStoreApi( }, async getItem(storeId: string, itemId: string): Promise { + const disklet = await getDisklet() const clean = await storeItemFile.load( disklet, getItemPath(storeId, itemId) @@ -91,6 +104,8 @@ export function makeDataStoreApi( itemId: string, value: string ): Promise { + const disklet = await getDisklet() + // Set up the plugin folder, if needed: const namePath = `${getStorePath(storeId)}/Name.json` const clean = await storeIdFile.load(disklet, namePath) diff --git a/src/core/account/plugin-api.ts b/src/core/account/plugin-api.ts index 7bff1070b..2e23b85a5 100644 --- a/src/core/account/plugin-api.ts +++ b/src/core/account/plugin-api.ts @@ -54,13 +54,15 @@ export class CurrencyConfig get allTokens(): EdgeTokenMap { const { state } = this._ai.props const { _accountId: accountId, _pluginId: pluginId } = this - return state.accounts[accountId].allTokens[pluginId] + // On a cache-seeded login these can be briefly absent, + // so honor the declared type instead of returning undefined: + return state.accounts[accountId].allTokens[pluginId] ?? emptyTokens } get builtinTokens(): EdgeTokenMap { const { state } = this._ai.props const { _accountId: accountId, _pluginId: pluginId } = this - return state.accounts[accountId].builtinTokens[pluginId] + return state.accounts[accountId].builtinTokens[pluginId] ?? emptyTokens } get customTokens(): EdgeTokenMap { diff --git a/src/core/actions.ts b/src/core/actions.ts index c0091911a..19a1fb013 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -25,6 +25,7 @@ import { TxFileNames, TxidHashes } from './currency/wallet/currency-wallet-reducer' +import { WalletCacheSeed } from './currency/wallet/wallet-cache-file' import { LoginStash } from './login/login-stash' import { LoginType, SessionKey } from './login/login-types' import { @@ -51,12 +52,24 @@ export type RootAction = tokens: EdgeTokenMap } } + | { + // We just read the account's local boot cache from disk, + // so wallets can start before the account repo loads. + type: 'ACCOUNT_CACHE_LOADED' + payload: { + accountId: string + customTokens: EdgePluginMap + walletStates: EdgeWalletStates + } + } | { // The account fires this when the user sorts or archives wallets. type: 'ACCOUNT_CHANGED_WALLET_STATES' payload: { accountId: string walletStates: EdgeWalletStates + /** The ids whose states this change actually touched. */ + changedIds: string[] } } | { @@ -281,9 +294,21 @@ export type RootAction = enabledTokenIds: string[] fiatCurrencyCode: string name: string | null + publicWalletInfo?: EdgeWalletInfo walletId: string } } + | { + // Called when the bulk loader finishes reading every active + // wallet's cache files, seeding all of them in one dispatch. + // The wallet reducer's filter hands each wallet its own seed, + // and the account reducer uses it to clear `bulkWalletSeedPending`. + type: 'CURRENCY_WALLETS_CACHE_LOADED' + payload: { + accountId: string + seeds: { [walletId: string]: WalletCacheSeed } + } + } | { type: 'CURRENCY_WALLET_CHANGED_PAUSED' payload: { diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index 82f9609c8..29fc95a9b 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -18,7 +18,6 @@ import { EdgeWalletInfo, JsonObject } from '../../../types/types' -import { makeJsonFile } from '../../../util/file-helpers' import { makePeriodicTask, PeriodicTask } from '../../../util/periodic-task' import { snooze } from '../../../util/snooze' import { makeTokenInfo } from '../../account/custom-tokens' @@ -40,11 +39,7 @@ import { makeCurrencyWalletCallbacks, watchCurrencyWallet } from './currency-wallet-callbacks' -import { - asIntegerString, - asPublicKeyFile, - WalletCacheFile -} from './currency-wallet-cleaners' +import { asIntegerString, WalletCacheFile } from './currency-wallet-cleaners' import { loadAddressFiles, loadFiatFile, @@ -67,6 +62,12 @@ import { walletCacheFile, walletCacheSaverConfig } from './wallet-cache-file' +import { + loadWalletCacheSeed, + PUBLIC_KEY_CACHE, + publicKeyFile, + walletCacheLoaderHooks +} from './wallet-cache-loader' export interface CurrencyWalletOutput { readonly walletApi: EdgeCurrencyWallet | undefined @@ -82,9 +83,6 @@ export type CurrencyWalletProps = RootProps & { export type CurrencyWalletInput = PixieInput -const PUBLIC_KEY_CACHE = 'publicKey.json' -const publicKeyFile = makeJsonFile(asPublicKeyFile) - export const walletPixie: TamePixie = combinePixies({ // Creates the engine for this wallet: engine(input: CurrencyWalletInput) { @@ -99,42 +97,40 @@ export const walletPixie: TamePixie = combinePixies({ const plugin = state.plugins.currency[pluginId] const { currencyCode } = plugin.currencyInfo + // On a warm account login, one bulk loader reads every wallet's + // cache files and seeds them in a single dispatch. Hold our own + // read until then (this update re-runs when the dispatch lands): + if (state.accounts[accountId]?.bulkWalletSeedPending) { + return + } + let releaseSlot: (() => void) | undefined try { const ai = toApiInput(input) // Load the UI-state cache before the storage-wallet sync, // so a previously-seen wallet can emit its API object right away. - // If either file is missing or invalid (first login, schema bump, - // corruption), skip the dispatch and fall through to the cold path: - const cacheDisklet = makeLocalDisklet(input.props.io, walletInfo.id) - const [publicKeyCache, walletCache] = await Promise.all([ - publicKeyFile.load(cacheDisklet, PUBLIC_KEY_CACHE), - walletCacheFile.load(cacheDisklet, WALLET_CACHE_FILE) - ]) - if (publicKeyCache != null && walletCache != null) { - const balanceMap: EdgeBalanceMap = new Map() - for (const tokenId of Object.keys(walletCache.balances)) { - balanceMap.set( - tokenId === '' ? null : tokenId, - walletCache.balances[tokenId] - ) - } - input.props.dispatch({ - type: 'CURRENCY_WALLET_PUBLIC_INFO', - payload: { walletInfo: publicKeyCache.walletInfo, walletId } - }) - input.props.dispatch({ - type: 'CURRENCY_WALLET_CACHE_LOADED', - payload: { - balanceMap, - enabledTokenIds: walletCache.enabledTokenIds, - fiatCurrencyCode: walletCache.fiatCurrencyCode, - name: walletCache.name, - walletId + // The bulk loader may have already seeded us; otherwise read our + // own files (cold logins, wallets activated after login, bulk + // misses). If either file is missing or invalid (first login, + // schema bump, corruption), fall through to the cold path: + let cacheSeeded = + walletState.publicWalletInfo != null && walletState.nameLoaded + if (!cacheSeeded) { + const seed = await loadWalletCacheSeed(ai, walletId) + if (seed != null) { + input.props.dispatch({ + type: 'CURRENCY_WALLET_CACHE_LOADED', + payload: { ...seed, walletId } + }) + if (walletCacheLoaderHooks.onFallbackSeed != null) { + walletCacheLoaderHooks.onFallbackSeed(walletId) } - }) + cacheSeeded = true + } + } + if (cacheSeeded) { // This wallet is already usable from its cache, so its heavy // startup work (repo sync, key derivation, engine creation) // waits its turn in a limited-concurrency queue instead of diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index 360a276ba..4a37f8e45 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -385,12 +385,15 @@ const currencyWalletInner = buildReducer< next => next.self.currencyInfo, next => next.root.accounts[next.self.accountId].allTokens[next.self.pluginId], - (balanceMap, currencyInfo, allTokens) => { + (balanceMap, currencyInfo, allTokens = {}) => { const out: EdgeBalances = {} for (const tokenId of balanceMap.keys()) { const balance = balanceMap.get(tokenId) - const { currencyCode } = - tokenId == null ? currencyInfo : allTokens[tokenId] + // A cached token balance can arrive before the deferred + // builtin-token load defines its token; skip it until then: + const tokenInfo = tokenId == null ? currencyInfo : allTokens[tokenId] + if (tokenInfo == null) continue + const { currencyCode } = tokenInfo if (balance != null) out[currencyCode] = balance } return out @@ -512,9 +515,14 @@ const currencyWalletInner = buildReducer< }, publicWalletInfo(state = null, action): EdgeWalletInfo | null { - return action.type === 'CURRENCY_WALLET_PUBLIC_INFO' - ? action.payload.walletInfo - : state + switch (action.type) { + case 'CURRENCY_WALLET_PUBLIC_INFO': + return action.payload.walletInfo + + case 'CURRENCY_WALLET_CACHE_LOADED': + return action.payload.publicWalletInfo ?? state + } + return state } }) @@ -540,6 +548,17 @@ export const currencyWalletReducer = filterReducer< CurrencyWalletNext, RootAction >(currencyWalletInner, (action, next) => { + // The bulk loader seeds every wallet in one dispatch; + // hand each wallet its own seed as the per-wallet action: + if (action.type === 'CURRENCY_WALLETS_CACHE_LOADED') { + const seed = action.payload.seeds[next.id] + if (seed == null) return { type: 'UPDATE_NEXT' } + return { + type: 'CURRENCY_WALLET_CACHE_LOADED', + payload: { ...seed, walletId: next.id } + } + } + return /^CURRENCY_/.test(action.type) && 'payload' in action && typeof action.payload === 'object' && diff --git a/src/core/currency/wallet/wallet-cache-file.ts b/src/core/currency/wallet/wallet-cache-file.ts index 1effa3bcc..b7324731b 100644 --- a/src/core/currency/wallet/wallet-cache-file.ts +++ b/src/core/currency/wallet/wallet-cache-file.ts @@ -1,3 +1,4 @@ +import { EdgeBalanceMap, EdgeWalletInfo } from '../../../types/types' import { makeJsonFile } from '../../../util/file-helpers' import { asWalletCacheFile } from './currency-wallet-cleaners' @@ -8,6 +9,19 @@ import { asWalletCacheFile } from './currency-wallet-cleaners' export const WALLET_CACHE_FILE = 'walletCache.json' export const walletCacheFile = makeJsonFile(asWalletCacheFile) +/** + * One wallet's cache files, validated and ready to seed Redux: + * the public keys from `publicKey.json` plus the UI state from + * `walletCache.json`, with balances upgraded to an `EdgeBalanceMap`. + */ +export interface WalletCacheSeed { + balanceMap: EdgeBalanceMap + enabledTokenIds: string[] + fiatCurrencyCode: string + name: string | null + publicWalletInfo: EdgeWalletInfo +} + /** * Tuning for the wallet UI-state cache saver. * Tests override the throttle to run quickly. diff --git a/src/core/currency/wallet/wallet-cache-loader.ts b/src/core/currency/wallet/wallet-cache-loader.ts new file mode 100644 index 000000000..08ef0457c --- /dev/null +++ b/src/core/currency/wallet/wallet-cache-loader.ts @@ -0,0 +1,113 @@ +import { EdgeBalanceMap } from '../../../types/types' +import { makeJsonFile } from '../../../util/file-helpers' +import { ApiInput } from '../../root-pixie' +import { makeLocalDisklet } from '../../storage/repo' +import { asPublicKeyFile, WalletCacheFile } from './currency-wallet-cleaners' +import { + WALLET_CACHE_FILE, + walletCacheFile, + WalletCacheSeed +} from './wallet-cache-file' + +export const PUBLIC_KEY_CACHE = 'publicKey.json' +export const publicKeyFile = makeJsonFile(asPublicKeyFile) + +/** + * Test hooks for observing cache seeding, following the same + * mutable-config pattern as `walletCacheSaverConfig`. + */ +export const walletCacheLoaderHooks: { + /** Receives each account id seeded by `ACCOUNT_CACHE_LOADED`. */ + onAccountSeed?: (accountId: string) => void + /** Receives the seeded wallet ids of each bulk dispatch. */ + onBulkSeed?: (walletIds: string[]) => void + /** Receives each wallet id seeded by a pixie's fallback read. */ + onFallbackSeed?: (walletId: string) => void +} = {} + +/** + * Upgrades a validated `walletCache.json` balance table + * to the `EdgeBalanceMap` shape the Redux slice uses. + */ +export function makeCachedBalanceMap( + balances: WalletCacheFile['balances'] +): EdgeBalanceMap { + const balanceMap: EdgeBalanceMap = new Map() + for (const tokenId of Object.keys(balances)) { + balanceMap.set(tokenId === '' ? null : tokenId, balances[tokenId]) + } + return balanceMap +} + +/** + * Reads one wallet's cache files from its local disklet. + * Returns undefined when either file is missing or invalid + * (first login, schema bump, corruption). + */ +export async function loadWalletCacheSeed( + ai: ApiInput, + walletId: string +): Promise { + const cacheDisklet = makeLocalDisklet(ai.props.io, walletId) + const [publicKeyCache, walletCache] = await Promise.all([ + publicKeyFile.load(cacheDisklet, PUBLIC_KEY_CACHE), + walletCacheFile.load(cacheDisklet, WALLET_CACHE_FILE) + ]) + if (publicKeyCache == null || walletCache == null) return + + return { + balanceMap: makeCachedBalanceMap(walletCache.balances), + enabledTokenIds: walletCache.enabledTokenIds, + fiatCurrencyCode: walletCache.fiatCurrencyCode, + name: walletCache.name, + publicWalletInfo: publicKeyCache.walletInfo + } +} + +/** + * Reads every active wallet's cache files concurrently and seeds + * them all in a single `CURRENCY_WALLETS_CACHE_LOADED` dispatch, + * so a warm login costs one store tick for the whole wallet list + * instead of two dispatches per wallet. Wallets without valid cache + * files are simply absent from the payload; their pixies fall back + * to their own reads. Always dispatches, even with zero seeds, since + * the wallet pixies are holding for `bulkWalletSeedPending` to clear. + */ +export async function bulkLoadWalletCaches( + ai: ApiInput, + accountId: string +): Promise { + const seeds: { [walletId: string]: WalletCacheSeed } = {} + try { + const accountState = ai.props.state.accounts[accountId] + if (accountState == null) return + const { activeWalletIds } = accountState + + await Promise.all( + activeWalletIds.map(async walletId => { + const seed = await loadWalletCacheSeed(ai, walletId).catch(() => { + // A broken read just means this wallet boots cold: + return undefined + }) + if (seed != null) seeds[walletId] = seed + }) + ) + } catch (error: unknown) { + // Never skip the dispatch below: wallet pixies are holding for + // `bulkWalletSeedPending` to clear, and an empty seed table just + // sends them to their own fallback reads: + ai.props.log.warn(`Bulk wallet-cache load failed: ${String(error)}`) + } + + // The account may have logged out while we read the disk: + if (ai.props.state.accounts[accountId] == null) return + + ai.props.dispatch({ + type: 'CURRENCY_WALLETS_CACHE_LOADED', + payload: { accountId, seeds } + }) + + if (walletCacheLoaderHooks.onBulkSeed != null) { + walletCacheLoaderHooks.onBulkSeed(Object.keys(seeds)) + } +} diff --git a/src/core/storage/storage-api.ts b/src/core/storage/storage-api.ts index a6d70e44e..c00663580 100644 --- a/src/core/storage/storage-api.ts +++ b/src/core/storage/storage-api.ts @@ -1,7 +1,10 @@ import { Disklet } from 'disklet' +import { bridgifyObject } from 'yaob' import { EdgeWalletInfo } from '../../types/types' +import { asEdgeStorageKeys } from '../login/storage-keys' import { ApiInput } from '../root-pixie' +import { makeLocalDisklet, makeRepoPaths } from './repo' import { syncStorageWallet } from './storage-actions' import { getStorageWalletDisklet, @@ -23,6 +26,25 @@ export function makeStorageWalletApi( ): EdgeStorageWallet { const { id, type, keys } = walletInfo + // The storage wallet may not be attached to Redux yet (a + // cache-seeded login emits API objects before `addStorageWallet` + // runs), so fall back to disklets built directly from the keys. + // They point at the same files, with the same encryption, as the + // attached versions: + let fallbackDisklets: { disklet: Disklet; localDisklet: Disklet } | undefined + function getFallbackDisklets(): { disklet: Disklet; localDisklet: Disklet } { + if (fallbackDisklets == null) { + const { io } = ai.props + const localDisklet = makeLocalDisklet(io, id) + bridgifyObject(localDisklet) + fallbackDisklets = { + disklet: makeRepoPaths(io, asEdgeStorageKeys(keys)).disklet, + localDisklet + } + } + return fallbackDisklets + } + return { // Broken-out key info: id, @@ -31,10 +53,16 @@ export function makeStorageWalletApi( // Folders: get disklet(): Disklet { + if (ai.props.state.storageWallets[id] == null) { + return getFallbackDisklets().disklet + } return getStorageWalletDisklet(ai.props.state, id) }, get localDisklet(): Disklet { + if (ai.props.state.storageWallets[id] == null) { + return getFallbackDisklets().localDisklet + } return getStorageWalletLocalDisklet(ai.props.state, id) }, From 9d7718f4b64c453d2ad0038c5f4cdba239384be2 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 17:21:54 -0700 Subject: [PATCH 08/39] Drop the duplicate publicKey.json read in the queued engine block The cache seeding path already reads publicKey.json before the wallet enters the startup queue, and the seeded public info lands in Redux. Pass it into getPublicWalletInfo so the queued block does not read the same file a second time. Cold wallets still read the file as before. --- .../currency/wallet/currency-wallet-pixie.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index 29fc95a9b..6ae0e9292 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -172,12 +172,14 @@ export const walletPixie: TamePixie = combinePixies({ // since new transactions may come in from the network: await loadTxFileNames(input) - // Derive the public keys: + // Derive the public keys. The cache seeding path already read + // publicKey.json, so reuse that instead of a second disk read: const tools = await getCurrencyTools(ai, pluginId) const publicWalletInfo = await getPublicWalletInfo( walletInfo, walletLocalDisklet, - tools + tools, + input.props.walletState.publicWalletInfo ?? undefined ) input.props.dispatch({ type: 'CURRENCY_WALLET_PUBLIC_INFO', @@ -738,21 +740,26 @@ export const walletPixie: TamePixie = combinePixies({ /** * Attempts to load/derive the wallet public keys. + * Pass `cachedWalletInfo` when `publicKey.json` was already read + * (the cache seeding path), so it is not read a second time. */ export async function getPublicWalletInfo( walletInfo: EdgeWalletInfo, disklet: Disklet, - tools: EdgeCurrencyTools + tools: EdgeCurrencyTools, + cachedWalletInfo?: EdgeWalletInfo ): Promise { // Try to load the cache: - const publicKeyCache = await publicKeyFile.load(disklet, PUBLIC_KEY_CACHE) - if (publicKeyCache != null) { + const cached = + cachedWalletInfo ?? + (await publicKeyFile.load(disklet, PUBLIC_KEY_CACHE))?.walletInfo + if (cached != null) { // Return it if it needs not to be upgraded (re-derived): if ( tools.checkPublicKey == null || - (await tools.checkPublicKey(publicKeyCache.walletInfo.keys)) + (await tools.checkPublicKey(cached.keys)) ) { - return publicKeyCache.walletInfo + return cached } } From e8490631e437a8115a5492a0d53e0376c0a884e6 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 17:30:59 -0700 Subject: [PATCH 09/39] Add account-cache and bulk-seeding tests Covers the design's test cases 12-14: the cache-coverage exhaustiveness test classifying every EdgeCurrencyWallet property, warm account login emitting before the deferred loads land (with the cold path blocking exactly as on master), and the bulk seed dispatch count with the pixie fallback for wallets activated after login. The fake plugin gains a builtinTokensGate, which blocks the deferred account loads at their head, making both states deterministic. --- CHANGELOG.md | 1 + test/core/account/account-cache.test.ts | 439 ++++++++++++++++++ .../core/currency/wallet/wallet-cache.test.ts | 112 +++++ test/fake/fake-currency-plugin.ts | 17 +- 4 files changed, 567 insertions(+), 2 deletions(-) create mode 100644 test/core/account/account-cache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ed34eeb..91040e3d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- added: Account-level `accountCache.json` with the wallet states, custom tokens, and plugin settings the account boot needs. A warm login seeds Redux from it and emits the account right after the currency plugins load, before the account repo syncs, and every cached wallet then seeds from a single bulk dispatch. The deferred file loads overwrite the seeded state authoritatively, and user changes made during the window win over the values those loads read. First login (no cache) boots exactly as before. - added: Per-wallet `walletCache.json` with each wallet's name, fiat code, enabled token IDs, and last-known balances. Currency wallets now emit their API objects as soon as this cache loads, before their engines exist, so the GUI can render the wallet list immediately at login. Engine-backed methods wait for the engine internally and reject if it fails or the wallet is deleted. First login (no cache) behaves exactly as before. - added: Cached wallets' engine startup is staggered through a limited-concurrency queue (8 at a time) instead of all racing at login. Asking for a wallet via `waitForCurrencyWallet`, calling an engine- or storage-backed method, or un-pausing it moves it to the front of the queue. Wallets without a cache skip the queue, so first login is unaffected. - changed: `waitForCurrencyWallet` and `waitForAllWallets` now resolve when the wallet object exists, which can be before its engine loads. diff --git a/test/core/account/account-cache.test.ts b/test/core/account/account-cache.test.ts new file mode 100644 index 000000000..0e386f429 --- /dev/null +++ b/test/core/account/account-cache.test.ts @@ -0,0 +1,439 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' +import { base64 } from 'rfc4648' + +import { accountCacheSaverConfig } from '../../../src/core/account/account-cache-file' +import { walletCacheSaverConfig } from '../../../src/core/currency/wallet/wallet-cache-file' +import { walletCacheLoaderHooks } from '../../../src/core/currency/wallet/wallet-cache-loader' +import { EdgeContext, makeFakeEdgeWorld } from '../../../src/index' +import { base58 } from '../../../src/util/encoding' +import { snooze } from '../../../src/util/snooze' +import { + createEngineGate, + fakePluginTestConfig +} from '../../fake/fake-currency-plugin' +import { fakeUser } from '../../fake/fake-user' + +const contextOptions = { apiKey: '', appId: '', deviceDescription: 'iphone12' } +const quiet = { onLog() {} } + +// Generous wait for the throttled cache savers (50ms in tests) to write: +const SAVE_WAIT_MS = 300 + +// Short wait to prove something has *not* happened: +const RACE_WAIT_MS = 150 + +interface AccountCachedWorld { + context: EdgeContext + /** Every fakecoin wallet id, in active order at creation time. */ + walletIds: string[] + /** The custom token added during the first session. */ + customTokenId: string +} + +/** + * Logs in once (a cold start), decorates the account with + * recognizable values, waits for the account and wallet cache savers + * to persist them, and logs out. The returned context has a warm + * account cache on disk, so the next login seeds from it. + * The fake user starts with two fakecoin wallets; pass `archiveSecond` + * to archive the second one, so cached wallet states are observable. + */ +async function makeAccountCachedWorld( + opts: { archiveSecond?: boolean } = {} +): Promise { + const { archiveSecond = false } = opts + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletIds = [...account.activeWalletIds] + if (walletIds.length !== 2) throw new Error('Broken test account') + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + await account.waitForCurrencyWallet(walletIds[1]) + + await wallet.renameWallet('Cached Name') + const customTokenId = await account.currencyConfig.fakecoin.addCustomToken({ + currencyCode: 'CACHED', + displayName: 'Cached Token', + denominations: [{ multiplier: '100', name: 'CACHED' }], + networkLocation: { contractAddress: '0xCACHED' } + }) + + // Let both wallets' cache files persist before any archiving, + // so archived wallets still have a cache to seed from later: + await snooze(SAVE_WAIT_MS) + + if (archiveSecond) { + await account.changeWalletStates({ [walletIds[1]]: { archived: true } }) + await snooze(SAVE_WAIT_MS) + } + await account.logout() + + return { context, walletIds, customTokenId } +} + +describe('account cache', function () { + beforeEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + walletCacheLoaderHooks.onAccountSeed = undefined + walletCacheLoaderHooks.onBulkSeed = undefined + walletCacheLoaderHooks.onFallbackSeed = undefined + accountCacheSaverConfig.throttleMs = 50 + walletCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + walletCacheLoaderHooks.onAccountSeed = undefined + walletCacheLoaderHooks.onBulkSeed = undefined + walletCacheLoaderHooks.onFallbackSeed = undefined + accountCacheSaverConfig.throttleMs = 5000 + walletCacheSaverConfig.throttleMs = 5000 + }) + + it('cold login without an account cache boots as on master', async function () { + this.timeout(15000) + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // A cold boot awaits loadBuiltinTokens before anything else, + // so a gated first login must not resolve, exactly as on master: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + let settled = false + const loginPromise = context + .loginWithPIN(fakeUser.username, fakeUser.pin) + .then(account => { + settled = true + return account + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + + // Releasing the gate lets the whole boot chain finish: + release() + const account = await loginPromise + const walletInfo = account.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('Broken test account') + const wallet = await account.waitForCurrencyWallet(walletInfo.id) + expect(wallet.name).equals('Fake Wallet') + + // The saver persists an account cache for the next login: + await snooze(SAVE_WAIT_MS) + await account.logout() + + // The next gated login resolves from that cache instead of blocking: + const { gate: gate2, release: release2 } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate2 + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account2.activeWalletIds.length).equals(2) + release2() + await account2.logout() + }) + + it('warm login emits the account before the deferred loads land', async function () { + this.timeout(15000) + const { context, walletIds, customTokenId } = await makeAccountCachedWorld({ + archiveSecond: true + }) + + // Hold the deferred loads (builtin tokens run at their head), + // so everything observable here comes from the account cache: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Wallet states seeded: the archived wallet stays out of the list: + expect([...account.activeWalletIds]).deep.equals([walletIds[0]]) + expect([...account.archivedWalletIds]).deep.equals([walletIds[1]]) + + // Custom tokens seeded (the class of data #703's saver lost): + const { customTokens } = account.currencyConfig.fakecoin + expect(customTokens[customTokenId]?.currencyCode).equals('CACHED') + + // The wallet emits from its own cache, engine-independent: + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + expect(wallet.name).equals('Cached Name') + + // The deferred loads land and overwrite authoritatively, and the + // cached token balance gains its currency code once the builtin + // token definitions arrive: + release() + await pollUntil( + () => + account.currencyConfig.fakecoin.builtinTokens.badf00d5 != null && + wallet.balances.TOKEN != null + ) + await account.logout() + }) + + it('deferred loads overwrite stale cached wallet states', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // Make the cache disagree with the authoritative files: archive the + // second wallet, then unarchive it with the account saver slowed + // down, so the cache still says "archived" while the disk says not: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.changeWalletStates({ [walletIds[1]]: { archived: true } }) + await snooze(SAVE_WAIT_MS) + accountCacheSaverConfig.throttleMs = 5000 + await account.changeWalletStates({ [walletIds[1]]: { archived: false } }) + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + + // The warm login first shows the stale cached state: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account2.activeWalletIds.includes(walletIds[1])).equals(false) + + // ...then corrects once the authoritative load lands: + release() + await pollUntil(() => account2.activeWalletIds.includes(walletIds[1])) + await account2.logout() + }) + + it('warm login seeds every wallet in one bulk dispatch', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + const accountSeeds: string[] = [] + const bulkSeeds: string[][] = [] + const fallbackSeeds: string[] = [] + walletCacheLoaderHooks.onAccountSeed = id => accountSeeds.push(id) + walletCacheLoaderHooks.onBulkSeed = ids => bulkSeeds.push(ids) + walletCacheLoaderHooks.onFallbackSeed = id => fallbackSeeds.push(id) + + // Hold the engines, so everything observable is cache-seeded: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Every wallet gate opens from the single bulk tick, pre-engine, + // and the whole warm login costs exactly two seeding dispatches: + await pollUntil(() => + walletIds.every(id => account.currencyWallets[id] != null) + ) + expect(accountSeeds.length).equals(1) + expect(bulkSeeds.length).equals(1) + expect(sorted(bulkSeeds[0])).deep.equals(sorted(walletIds)) + expect(fallbackSeeds).deep.equals([]) + expect(account.currencyWallets[walletIds[0]].name).equals('Cached Name') + + release() + await account.logout() + }) + + it('a wallet activated after login seeds through the fallback read', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld({ + archiveSecond: true + }) + + const bulkSeeds: string[][] = [] + const fallbackSeeds: string[] = [] + walletCacheLoaderHooks.onBulkSeed = ids => bulkSeeds.push(ids) + walletCacheLoaderHooks.onFallbackSeed = id => fallbackSeeds.push(id) + + // Hold the engines the whole time; both wallets must emit from + // their caches alone: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Only the active wallet rides the bulk dispatch: + await pollUntil(() => account.currencyWallets[walletIds[0]] != null) + expect(bulkSeeds.length).equals(1) + expect(bulkSeeds[0]).deep.equals([walletIds[0]]) + + // The reactivated wallet seeds itself through its pixie's own read + // (changeWalletStates pends until the deferred loads add the repo): + await account.changeWalletStates({ [walletIds[1]]: { archived: false } }) + await pollUntil(() => account.currencyWallets[walletIds[1]] != null) + expect(fallbackSeeds).deep.equals([walletIds[1]]) + expect(bulkSeeds.length).equals(1) + + release() + await account.logout() + }) + + it('logout during a pending account cache save cancels the write', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // Slow the saver down so its write is still pending at logout: + accountCacheSaverConfig.throttleMs = 3000 + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.changeWalletStates({ [walletIds[1]]: { archived: true } }) + await account.logout() + + // The cancelled write must not have touched the cache: a gated + // warm login still shows both wallets active: + accountCacheSaverConfig.throttleMs = 50 + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(sorted([...account2.activeWalletIds])).deep.equals(sorted(walletIds)) + release() + await account2.logout() + }) + + it('fresh-process warm login boots from the cache alone', async function () { + this.timeout(15000) + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First session: decorate, then capture the local cache files a + // real device would still have on disk after the app closes: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletIds = [...account.activeWalletIds] + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + await account.waitForCurrencyWallet(walletIds[1]) + await wallet.renameWallet('Cached Name') + await snooze(SAVE_WAIT_MS) + + const accountRepoInfo = account.getFirstWalletInfo( + 'account-repo:co.airbitz.wallet' + ) + if (accountRepoInfo == null) throw new Error('Broken test account') + const extraFiles: { [path: string]: string } = {} + extraFiles[localPath(accountRepoInfo.id, 'accountCache.json')] = + await account.localDisklet.getText('accountCache.json') + for (const walletId of walletIds) { + const cachedWallet = account.currencyWallets[walletId] + extraFiles[localPath(walletId, 'walletCache.json')] = + await cachedWallet.localDisklet.getText('walletCache.json') + extraFiles[localPath(walletId, 'publicKey.json')] = + await cachedWallet.localDisklet.getText('publicKey.json') + } + await account.logout() + + // A brand-new context is a fresh app process: a fresh Redux store + // with no storage wallets, plus the files captured above. The + // gated login must still resolve from the cache (regression guard: + // an eager storage-wallet read here crashes the whole login): + const context2 = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true }, + extraFiles + }) + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const { gate: engineGate, release: releaseEngines } = createEngineGate() + fakePluginTestConfig.engineGate = engineGate + const account2 = await context2.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + const wallet2 = await account2.waitForCurrencyWallet(walletIds[0]) + expect(wallet2.name).equals('Cached Name') + + // A custom token added during the boot window must reach the + // synced repo once the deferred loads land, not just Redux: + const tokenId = await account2.currencyConfig.fakecoin.addCustomToken({ + currencyCode: 'FRESH', + displayName: 'Fresh Token', + denominations: [{ multiplier: '100', name: 'FRESH' }], + networkLocation: { contractAddress: '0xFRE54' } + }) + release() + releaseEngines() + await pollUntilAsync(async () => { + try { + const text = await account2.disklet.getText('CustomTokens.json') + return text.includes(tokenId) + } catch (error: unknown) { + return false + } + }) + expect( + account2.currencyConfig.fakecoin.customTokens[tokenId]?.currencyCode + ).equals('FRESH') + await account2.logout() + }) + + it('rejects a corrupt account cache file and falls back cold', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // Corrupt the account cache after the saver has settled: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await snooze(SAVE_WAIT_MS) + accountCacheSaverConfig.throttleMs = 5000 + await account.localDisklet.setText('accountCache.json', '{ "version": 99 }') + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + + // A corrupt file means the cold path runs: a gated login blocks, + // exactly as with no cache at all: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + let settled = false + const loginPromise = context + .loginWithPIN(fakeUser.username, fakeUser.pin) + .then(account2 => { + settled = true + return account2 + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + + // Releasing the gate completes the boot and re-saves the cache: + release() + const account2 = await loginPromise + expect(sorted([...account2.activeWalletIds])).deep.equals(sorted(walletIds)) + await snooze(SAVE_WAIT_MS) + await account2.logout() + + // The rewritten file feeds the next gated login: + const { gate: gate3, release: release3 } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate3 + const account3 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account3.activeWalletIds.length).equals(2) + release3() + await account3.logout() + }) +}) + +/** The disklet path of a file on a wallet's or account's local storage. */ +function localPath(id: string, file: string): string { + return `local/${base58.stringify(base64.parse(id))}/${file}` +} + +/** Returns a sorted copy, so order-insensitive comparisons read cleanly. */ +function sorted(list: string[]): string[] { + return [...list].sort((a, b) => a.localeCompare(b)) +} + +/** Polls until `condition` holds, or fails the test after ~5s. */ +async function pollUntil(condition: () => boolean): Promise { + for (let i = 0; i < 100; ++i) { + if (condition()) return + await snooze(50) + } + throw new Error('Timed out waiting for a test condition') +} + +/** Polls an async condition, or fails the test after ~5s. */ +async function pollUntilAsync( + condition: () => Promise +): Promise { + for (let i = 0; i < 100; ++i) { + if (await condition()) return + await snooze(50) + } + throw new Error('Timed out waiting for a test condition') +} diff --git a/test/core/currency/wallet/wallet-cache.test.ts b/test/core/currency/wallet/wallet-cache.test.ts index 70ee1f3e5..3e8ae9bd5 100644 --- a/test/core/currency/wallet/wallet-cache.test.ts +++ b/test/core/currency/wallet/wallet-cache.test.ts @@ -338,6 +338,118 @@ describe('wallet cache', function () { await account2.logout() }) + it('classifies every wallet property as cache-seeded or engine-gated', async function () { + this.timeout(15000) + + // Everything the wallet-list scene renders pre-engine must come + // from the cache file. If this list changes, walletCache.json + // (and its saver and seed paths) must change with it: + const cacheSeeded = [ + 'balanceMap', + 'balances', + 'enabledTokenIds', + 'fiatCurrencyCode', + 'name' + ] + + // The documented engine-gated set from the design's section 5.4: + // methods that internally await the engine, plus engine-sourced + // getters with safe pre-engine defaults: + const engineGated = [ + '$internalStreamTransactions', + 'accelerate', + 'blockHeight', + 'broadcastTx', + 'detectedTokenIds', + 'dumpData', + 'getAddresses', + 'getMaxSpendable', + 'getNumTransactions', + 'getPaymentProtocolInfo', + 'getReceiveAddress', + 'getTransactions', + 'lockReceiveAddress', + 'makeSpend', + 'otherMethods', + 'resyncBlockchain', + 'saveReceiveAddress', + 'saveTx', + 'saveTxAction', + 'saveTxMetadata', + 'signBytes', + 'signMessage', + 'signTx', + 'split', + 'stakingStatus', + 'streamTransactions', + 'sweepPrivateKeys', + 'syncRatio', + 'syncStatus', + 'unactivatedTokenIds' + ] + + // Identity, storage-backed, config, and tools surfaces, which + // never needed an engine in the first place: + const engineFree = [ + 'changeEnabledTokenIds', + 'changePaused', + 'changeWalletSettings', + 'created', + 'currencyConfig', + 'currencyInfo', + 'denominationToNative', + 'disklet', + 'encodeUri', + 'id', + 'imported', + 'localDisklet', + 'nativeToDenomination', + 'on', + 'parseUri', + 'paused', + 'publicWalletInfo', + 'renameWallet', + 'setFiatCurrencyCode', + 'sync', + 'type', + 'walletSettings', + 'watch' + ] + + const { context, walletId } = await makeCachedWorld() + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + // Every property on the live wallet object must be classified. + // A newly added EdgeCurrencyWallet property fails here until + // someone decides whether the cache must seed it: + const classified = new Set([...cacheSeeded, ...engineGated, ...engineFree]) + const unclassified = Object.getOwnPropertyNames(wallet).filter( + // The yaob bridge adds its own bookkeeping property: + key => key !== '_yaob' && !classified.has(key) + ) + expect(unclassified).deep.equals([]) + + // And the classification must not name properties that no longer + // exist, so removals also force a decision: + const surface = new Set(Object.getOwnPropertyNames(wallet)) + const stale = [...classified].filter(key => !surface.has(key)) + expect(stale).deep.equals([]) + + // The cache-seeded properties actually carry cached values while + // the engine is still blocked: + expect(wallet.name).equals('Cached Name') + expect(wallet.fiatCurrencyCode).equals('iso:USD') + expect(wallet.enabledTokenIds).deep.equals(['badf00d5']) + expect(wallet.balanceMap.get(null)).equals('12345') + expect(wallet.balances.FAKE).equals('12345') + + release() + await account.logout() + }) + it('otherMethods is {} pre-engine and carries engine methods post-engine', async function () { this.timeout(15000) const { context, walletId } = await makeCachedWorld() diff --git a/test/fake/fake-currency-plugin.ts b/test/fake/fake-currency-plugin.ts index da34a95cf..1871e49a8 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -31,6 +31,15 @@ const GENESIS_BLOCK = 1231006505 * Test configuration for controlling fake plugin behavior. */ export interface FakePluginTestConfig { + /** + * If set, `getBuiltinTokens` will wait for this promise to resolve. + * The account pixie awaits builtin tokens at the head of its file + * loads, so this gate makes "the deferred account loads have not + * landed yet" a deterministic state in tests (and blocks a cold + * login entirely, exactly as on master). + */ + builtinTokensGate?: Promise + /** * If set, engine creation will wait for this promise to resolve. * Use `createEngineGate` to make a controllable gate, @@ -46,6 +55,7 @@ export interface FakePluginTestConfig { } export const fakePluginTestConfig: FakePluginTestConfig = { + builtinTokensGate: undefined, engineGate: undefined, onEngineCreate: undefined } @@ -443,8 +453,11 @@ export function makeFakeCurrencyPlugin( return { currencyInfo, - getBuiltinTokens(): Promise { - return Promise.resolve(fakeTokens) + async getBuiltinTokens(): Promise { + if (fakePluginTestConfig.builtinTokensGate != null) { + await fakePluginTestConfig.builtinTokensGate + } + return fakeTokens }, async makeCurrencyEngine( From b89bb6ca7097fd96725547da8c911f77b240cf2c Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 18:53:26 -0700 Subject: [PATCH 10/39] Keep user changes over racing wallet file loads A wallet emits from its cache before its name, fiat, and settings files load, so a rename (or fiat/settings change) made during that window could be overwritten when the in-flight load dispatched its stale value. The mutation writes the file before dispatching, so the change was already on disk; only Redux regressed. File loads now tag their dispatches, and a dirty flag lets the user's value win over one racing load, mirroring the enabledTokenIds and account-level guards. --- src/core/actions.ts | 6 +- .../currency/wallet/currency-wallet-files.ts | 3 +- .../wallet/currency-wallet-reducer.ts | 77 ++++++++++++++++--- 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/core/actions.ts b/src/core/actions.ts index 19a1fb013..8b93db1f3 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -324,10 +324,12 @@ export type RootAction = } } | { - // Called when a currency wallet receives a new name. + // Called when a currency wallet receives a new fiat code. type: 'CURRENCY_WALLET_FIAT_CHANGED' payload: { fiatCurrencyCode: string + /** True when the value was read from disk, not set by a user. */ + fromFile?: boolean walletId: string } } @@ -380,6 +382,8 @@ export type RootAction = type: 'CURRENCY_WALLET_NAME_CHANGED' payload: { name: string | null + /** True when the value was read from disk, not set by a user. */ + fromFile?: boolean walletId: string } } diff --git a/src/core/currency/wallet/currency-wallet-files.ts b/src/core/currency/wallet/currency-wallet-files.ts index 3c548787f..5df38d0b6 100644 --- a/src/core/currency/wallet/currency-wallet-files.ts +++ b/src/core/currency/wallet/currency-wallet-files.ts @@ -193,7 +193,7 @@ export async function loadFiatFile(input: CurrencyWalletInput): Promise { dispatch({ type: 'CURRENCY_WALLET_FIAT_CHANGED', - payload: { fiatCurrencyCode, walletId } + payload: { fiatCurrencyCode, fromFile: true, walletId } }) } @@ -223,6 +223,7 @@ export async function loadNameFile(input: CurrencyWalletInput): Promise { type: 'CURRENCY_WALLET_NAME_CHANGED', payload: { name: typeof name === 'string' ? name : null, + fromFile: true, walletId } }) diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index 4a37f8e45..75d4ab841 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -77,15 +77,18 @@ export interface CurrencyWalletState { readonly tokenFileDirty: boolean readonly tokenFileLoaded: boolean readonly walletSettings: JsonObject + readonly walletSettingsDirty: boolean readonly engineFailure: Error | null readonly engineStarted: boolean readonly fiat: string + readonly fiatDirty: boolean readonly fiatLoaded: boolean readonly fileNames: TxFileNames readonly files: TxFileJsons readonly gotTxs: Set readonly height: number readonly name: string | null + readonly nameDirty: boolean readonly nameLoaded: boolean readonly publicWalletInfo: EdgeWalletInfo | null readonly seenTxCheckpoint: string | null @@ -263,9 +266,19 @@ const currencyWalletInner = buildReducer< } }, - walletSettings(state = initialWalletSettings, action): JsonObject { + walletSettings( + state = initialWalletSettings, + action, + next, + prev + ): JsonObject { switch (action.type) { case 'CURRENCY_WALLET_LOADED_WALLET_SETTINGS_FILE': + // A user change made while the file load was reading the disk + // wins over the value the load saw (the change already wrote + // the file before dispatching): + if (prev.self?.walletSettingsDirty) return state + return action.payload.walletSettings case 'CURRENCY_WALLET_CHANGED_WALLET_SETTINGS': return action.payload.walletSettings default: @@ -273,6 +286,16 @@ const currencyWalletInner = buildReducer< } }, + walletSettingsDirty(state = false, action): boolean { + switch (action.type) { + case 'CURRENCY_WALLET_CHANGED_WALLET_SETTINGS': + return true + case 'CURRENCY_WALLET_LOADED_WALLET_SETTINGS_FILE': + return false + } + return state + }, + engineFailure(state = null, action): Error | null { if (action.type === 'CURRENCY_ENGINE_FAILED') { const { error } = action.payload @@ -290,11 +313,27 @@ const currencyWalletInner = buildReducer< : state }, - fiat(state = '', action): string { - return action.type === 'CURRENCY_WALLET_FIAT_CHANGED' || - action.type === 'CURRENCY_WALLET_CACHE_LOADED' - ? action.payload.fiatCurrencyCode - : state + fiat(state = '', action, next, prev): string { + switch (action.type) { + case 'CURRENCY_WALLET_CACHE_LOADED': + return action.payload.fiatCurrencyCode + + case 'CURRENCY_WALLET_FIAT_CHANGED': + // A user change made while the file load was reading the disk + // wins over the value the load saw (the change already wrote + // the file before dispatching): + if (action.payload.fromFile === true && prev.self?.fiatDirty) + return state + return action.payload.fiatCurrencyCode + } + return state + }, + + fiatDirty(state = false, action): boolean { + if (action.type === 'CURRENCY_WALLET_FIAT_CHANGED') { + return action.payload.fromFile !== true + } + return state }, fiatLoaded(state = false, action): boolean { @@ -406,11 +445,27 @@ const currencyWalletInner = buildReducer< : state }, - name(state = null, action): string | null { - return action.type === 'CURRENCY_WALLET_NAME_CHANGED' || - action.type === 'CURRENCY_WALLET_CACHE_LOADED' - ? action.payload.name - : state + name(state = null, action, next, prev): string | null { + switch (action.type) { + case 'CURRENCY_WALLET_CACHE_LOADED': + return action.payload.name + + case 'CURRENCY_WALLET_NAME_CHANGED': + // A user rename made while the file load was reading the disk + // wins over the value the load saw (the rename already wrote + // the file before dispatching): + if (action.payload.fromFile === true && prev.self?.nameDirty) + return state + return action.payload.name + } + return state + }, + + nameDirty(state = false, action): boolean { + if (action.type === 'CURRENCY_WALLET_NAME_CHANGED') { + return action.payload.fromFile !== true + } + return state }, nameLoaded(state = false, action): boolean { From a857b19a69d2a8d37c29be794d42b957ebcdd419 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 19:01:22 -0700 Subject: [PATCH 11/39] Deliver account state to engines that start before it loads On a warm login, engine startup runs in parallel with the deferred account file loads. The pixie watcher could observe freshly-loaded plugin settings or custom tokens while the engine was still null, adopt them, and then never deliver them once the engine appeared, leaving it on empty settings for the whole session. The watcher now never adopts a value it could not deliver, and engine creation reads the account state fresh instead of from an earlier snapshot. --- .../currency/wallet/currency-wallet-pixie.ts | 21 +++++++---- test/core/account/account-cache.test.ts | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index 6ae0e9292..737e45ba3 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -200,8 +200,11 @@ export const walletPixie: TamePixie = combinePixies({ await loadWalletSettingsFile(input) } - // Start the engine: - const accountState = state.accounts[accountId] + // Start the engine, reading the account state fresh: the + // deferred account file loads run in parallel with this block + // on a warm login, so an earlier snapshot could hand the + // engine stale settings or tokens: + const accountState = input.props.state.accounts[accountId] const engine = await plugin.makeCurrencyEngine(publicWalletInfo, { callbacks: makeCurrencyWalletCallbacks(input), @@ -659,17 +662,23 @@ export const walletPixie: TamePixie = combinePixies({ } lastState = walletState + // On a warm login the engine starts in parallel with the + // deferred account file loads, so account state can change + // while `engine` is still null. Never adopt a value we could + // not deliver, or the engine would miss it forever: + if (engine == null) return + // Update engine settings: const userSettings = accountState.userSettings[pluginId] ?? lastUserSettings - if (lastUserSettings !== userSettings && engine != null) { + if (lastUserSettings !== userSettings) { await engine.changeUserSettings(userSettings) } lastUserSettings = userSettings // Update the custom tokens: const customTokens = accountState.customTokens[pluginId] ?? lastTokens - if (lastTokens !== customTokens && engine != null) { + if (lastTokens !== customTokens) { if (engine.changeCustomTokens != null) { await engine.changeCustomTokens(customTokens) } else if (engine.addCustomToken != null) { @@ -693,7 +702,7 @@ export const walletPixie: TamePixie = combinePixies({ if ( settingsChanged && - engine?.changeWalletSettings != null && + engine.changeWalletSettings != null && hasWalletSettings ) { await engine.changeWalletSettings(walletSettings).catch(error => { @@ -704,7 +713,7 @@ export const walletPixie: TamePixie = combinePixies({ // Update enabled tokens: const { allEnabledTokenIds } = walletState - if (lastEnabledTokenIds !== allEnabledTokenIds && engine != null) { + if (lastEnabledTokenIds !== allEnabledTokenIds) { if (engine.changeEnabledTokenIds != null) { await engine .changeEnabledTokenIds(allEnabledTokenIds) diff --git a/test/core/account/account-cache.test.ts b/test/core/account/account-cache.test.ts index 0e386f429..09dd513fd 100644 --- a/test/core/account/account-cache.test.ts +++ b/test/core/account/account-cache.test.ts @@ -267,6 +267,41 @@ describe('account cache', function () { await account.logout() }) + it('plugin settings loaded after an engine starts still reach it', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // Persist plugin settings the next warm login must deliver: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 4321 }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // Warm login with both the deferred loads and the engines gated: + const { gate: builtinGate, release: releaseBuiltin } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = builtinGate + const { gate: engineGate, release: releaseEngine } = createEngineGate() + fakePluginTestConfig.engineGate = engineGate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletIds[0]) + + // Let the deferred loads land while the engine is still gated, so + // the watcher sees the loaded settings with no engine to apply + // them to (plugin settings are deliberately not cached): + releaseBuiltin() + await pollUntil( + () => account2.currencyConfig.fakecoin.builtinTokens.badf00d5 != null + ) + await snooze(RACE_WAIT_MS) + + // The engine arrives late and must still receive those settings + // (the fake engine reports its configured balance only when + // changeUserSettings delivers it): + releaseEngine() + await pollUntil(() => wallet2.balanceMap.get(null) === '4321') + await account2.logout() + }) + it('logout during a pending account cache save cancels the write', async function () { this.timeout(15000) const { context, walletIds } = await makeAccountCachedWorld() From f4e14c57e59261b63fc3106a2860bec0cf8e33c1 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 19:09:15 -0700 Subject: [PATCH 12/39] Make storage sync wait for the storage wallet A cache-seeded login emits the account and wallet API objects before addStorageWallet attaches their repos, so sync() now waits for the repo instead of throwing during that window, matching the disklet fallbacks and the pending repo-backed mutations. --- src/core/storage/storage-api.ts | 5 +++++ test/core/account/account-cache.test.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/core/storage/storage-api.ts b/src/core/storage/storage-api.ts index c00663580..4e1128dcf 100644 --- a/src/core/storage/storage-api.ts +++ b/src/core/storage/storage-api.ts @@ -67,6 +67,11 @@ export function makeStorageWalletApi( }, async sync(): Promise { + // The storage wallet may not be attached yet on a cache-seeded + // login; wait for `addStorageWallet` instead of throwing: + await ai.waitFor(props => + props.state.storageWallets[id] != null ? true : undefined + ) await syncStorageWallet(ai, id) } } diff --git a/test/core/account/account-cache.test.ts b/test/core/account/account-cache.test.ts index 09dd513fd..21a735bc3 100644 --- a/test/core/account/account-cache.test.ts +++ b/test/core/account/account-cache.test.ts @@ -376,6 +376,15 @@ describe('account cache', function () { const wallet2 = await account2.waitForCurrencyWallet(walletIds[0]) expect(wallet2.name).equals('Cached Name') + // Repo-backed calls made in the window pend instead of throwing + // (this store has never seen addStorageWallet run): + let syncSettled = false + const syncPromise = account2.sync().then(() => { + syncSettled = true + }) + await snooze(RACE_WAIT_MS) + expect(syncSettled).equals(false) + // A custom token added during the boot window must reach the // synced repo once the deferred loads land, not just Redux: const tokenId = await account2.currencyConfig.fakecoin.addCustomToken({ @@ -386,6 +395,7 @@ describe('account cache', function () { }) release() releaseEngines() + await syncPromise await pollUntilAsync(async () => { try { const text = await account2.disklet.getText('CustomTokens.json') From 6e27cf51d72347633b71191e40c6c0e44817e366 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 19:15:39 -0700 Subject: [PATCH 13/39] Wait for builtin tokens before filtering enabled token ids changeEnabledTokenIds filters the requested ids against the plugin's known tokens, but on a warm login the builtin definitions load after the wallet exists, so a toggle made in that window silently dropped enabled builtin tokens. The method now waits for the plugin's builtin tokens (still working when the engine has failed, bailing only on wallet deletion). --- .../currency/wallet/currency-wallet-api.ts | 19 +++++++++++++++++-- test/core/account/account-cache.test.ts | 13 +++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index ce69715e1..916bfa000 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -337,9 +337,24 @@ export function makeCurrencyWalletApi( // Tokens: async changeEnabledTokenIds(tokenIds: string[]): Promise { - const { dispatch, walletId, walletState } = input.props + const { walletId, walletState } = input.props const { accountId, pluginId } = walletState - const accountState = input.props.state.accounts[accountId] + + // On a warm login the builtin token definitions load after the + // wallet exists; wait for them, or the filter below would + // silently drop enabled builtin tokens. This must keep working + // when the engine has failed, so only bail on deletion: + const accountState = await ai.waitFor(props => { + if (props.state.currency.wallets[walletId] == null) { + throw new Error( + `Wallet id ${walletId} does not exist in this account` + ) + } + const accountState = props.state.accounts[accountId] + if (accountState?.builtinTokens[pluginId] != null) return accountState + }) + + const { dispatch } = input.props const allTokens = accountState.allTokens[pluginId] ?? {} const enabledTokenIds = uniqueStrings(tokenIds).filter( diff --git a/test/core/account/account-cache.test.ts b/test/core/account/account-cache.test.ts index 21a735bc3..47325cd4c 100644 --- a/test/core/account/account-cache.test.ts +++ b/test/core/account/account-cache.test.ts @@ -164,10 +164,23 @@ describe('account cache', function () { const wallet = await account.waitForCurrencyWallet(walletIds[0]) expect(wallet.name).equals('Cached Name') + // Enabling a builtin token in the window pends until the builtin + // definitions load, instead of silently filtering the id away: + let tokensSettled = false + const tokensPromise = wallet + .changeEnabledTokenIds(['badf00d5']) + .then(() => { + tokensSettled = true + }) + await snooze(RACE_WAIT_MS) + expect(tokensSettled).equals(false) + // The deferred loads land and overwrite authoritatively, and the // cached token balance gains its currency code once the builtin // token definitions arrive: release() + await tokensPromise + expect([...wallet.enabledTokenIds]).deep.equals(['badf00d5']) await pollUntil( () => account.currencyConfig.fakecoin.builtinTokens.badf00d5 != null && From 5f3b86093915a9db10d98bcc036131284d069be1 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 19:22:24 -0700 Subject: [PATCH 14/39] Stop the deferred boot loads after a logout The warm login path kept running (and retrying) the deferred account loads after the user logged out mid-boot; check for the account at each attempt, matching the guards elsewhere in the chain. --- src/core/account/account-pixie.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/account/account-pixie.ts b/src/core/account/account-pixie.ts index 748811951..baf2d0627 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -154,6 +154,10 @@ const accountPixie: TamePixie = combinePixies({ // failures instead of leaving the session half-loaded // (a stuck `*Loaded` flag would disable the cache saver): for (let attempt = 1; ; ++attempt) { + // The user may have logged out while we were seeding: + if (ai.props.state.accounts[accountId] == null) { + return await stopUpdates + } try { await loadEverything() break From b4f6389f94bfbadf48286d97ebe555694ab10f4b Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 19:28:08 -0700 Subject: [PATCH 15/39] Reject a pending storage sync on logout or wallet deletion The sync wait added for the cache-seeded boot window could hang forever if the account logged out (or the wallet was deleted) before addStorageWallet ran. Callers now supply a liveness check that the waiter runs on every state change, so the pending sync rejects instead. --- src/core/account/account-api.ts | 10 +++++++++- src/core/currency/wallet/currency-wallet-api.ts | 6 +++++- src/core/storage/storage-api.ts | 16 +++++++++++----- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/core/account/account-api.ts b/src/core/account/account-api.ts index af0438101..96913f08a 100644 --- a/src/core/account/account-api.ts +++ b/src/core/account/account-api.ts @@ -125,7 +125,15 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { // Specialty API's: const dataStore = makeDataStoreApi(ai, accountId) - const storageWalletApi = makeStorageWalletApi(ai, accountWalletInfo) + const storageWalletApi = makeStorageWalletApi( + ai, + accountWalletInfo, + props => { + if (props.state.accounts[accountId] == null) { + throw new Error('The account was logged out') + } + } + ) function lockdown(): void { if (ai.props.state.hideKeys) { diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index 916bfa000..a1fecdcf3 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -150,7 +150,11 @@ export function makeCurrencyWalletApi( }) } - const storageWalletApi = makeStorageWalletApi(ai, walletInfo) + const storageWalletApi = makeStorageWalletApi(ai, walletInfo, props => { + if (props.state.currency.wallets[walletId] == null) { + throw new Error(`Wallet id ${walletId} does not exist in this account`) + } + }) // The storage-wallet state provides the disklets once the repo loads, // but the wallet API can emit slightly earlier from the UI-state cache, diff --git a/src/core/storage/storage-api.ts b/src/core/storage/storage-api.ts index 4e1128dcf..6a46aaf79 100644 --- a/src/core/storage/storage-api.ts +++ b/src/core/storage/storage-api.ts @@ -3,7 +3,7 @@ import { bridgifyObject } from 'yaob' import { EdgeWalletInfo } from '../../types/types' import { asEdgeStorageKeys } from '../login/storage-keys' -import { ApiInput } from '../root-pixie' +import { ApiInput, RootProps } from '../root-pixie' import { makeLocalDisklet, makeRepoPaths } from './repo' import { syncStorageWallet } from './storage-actions' import { @@ -22,7 +22,12 @@ export interface EdgeStorageWallet { export function makeStorageWalletApi( ai: ApiInput, - walletInfo: EdgeWalletInfo + walletInfo: EdgeWalletInfo, + /** + * Throws when the owning account or wallet is gone, so a pending + * `sync` rejects on logout or deletion instead of hanging forever. + */ + checkAlive?: (props: RootProps) => void ): EdgeStorageWallet { const { id, type, keys } = walletInfo @@ -69,9 +74,10 @@ export function makeStorageWalletApi( async sync(): Promise { // The storage wallet may not be attached yet on a cache-seeded // login; wait for `addStorageWallet` instead of throwing: - await ai.waitFor(props => - props.state.storageWallets[id] != null ? true : undefined - ) + await ai.waitFor(props => { + if (checkAlive != null) checkAlive(props) + if (props.state.storageWallets[id] != null) return true + }) await syncStorageWallet(ai, id) } } From 71beb97d16227655ae9376bc4575d5a6b388f7f4 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 19:34:30 -0700 Subject: [PATCH 16/39] Reject repo waiters after a terminal boot-load failure If the deferred loads fail all their retries on a warm login, the account repo is never coming. Record the failure through ACCOUNT_LOAD_FAILED (and stop logging a misleading 'Login: complete'), and make the repo waiters (changeWalletStates, dataStore, sync, plugin-settings writes) re-throw it instead of pending forever. The checks only run while the repo is missing, so partial failures cannot break already-working calls. --- src/core/account/account-api.ts | 5 ++++- src/core/account/account-files.ts | 4 ++++ src/core/account/account-pixie.ts | 9 ++++++++- src/core/currency/wallet/currency-wallet-api.ts | 6 +++--- src/core/storage/storage-api.ts | 6 ++++-- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/core/account/account-api.ts b/src/core/account/account-api.ts index 96913f08a..49b52a1b6 100644 --- a/src/core/account/account-api.ts +++ b/src/core/account/account-api.ts @@ -129,9 +129,12 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { ai, accountWalletInfo, props => { - if (props.state.accounts[accountId] == null) { + const accountState = props.state.accounts[accountId] + if (accountState == null) { throw new Error('The account was logged out') } + // A terminal boot failure means the repo is never coming: + if (accountState.loadFailure != null) throw accountState.loadFailure } ) diff --git a/src/core/account/account-files.ts b/src/core/account/account-files.ts index da481a07e..0368795bf 100644 --- a/src/core/account/account-files.ts +++ b/src/core/account/account-files.ts @@ -59,6 +59,10 @@ export function waitForAccountRepo( } const { accountWalletInfo } = accountState if (props.state.storageWallets[accountWalletInfo.id] != null) return true + + // The repo is still missing. If the boot loads failed terminally, + // it is never coming, so reject instead of pending forever: + if (accountState.loadFailure != null) throw accountState.loadFailure }) } diff --git a/src/core/account/account-pixie.ts b/src/core/account/account-pixie.ts index baf2d0627..bc90c1457 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -171,8 +171,15 @@ const accountPixie: TamePixie = combinePixies({ )}` ) if (attempt >= 3) { + // Record the terminal failure, so the repo waiters + // (changeWalletStates, dataStore, sync, settings) + // reject instead of pending forever: input.props.onError(error) - break + input.props.dispatch({ + type: 'ACCOUNT_LOAD_FAILED', + payload: { accountId, error } + }) + return await stopUpdates } await snooze(5000) } diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index a1fecdcf3..ff52a45c0 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -151,9 +151,9 @@ export function makeCurrencyWalletApi( } const storageWalletApi = makeStorageWalletApi(ai, walletInfo, props => { - if (props.state.currency.wallets[walletId] == null) { - throw new Error(`Wallet id ${walletId} does not exist in this account`) - } + // Bails on deletion, and re-throws `engineFailure`: while the + // repo is missing, a dead engine pixie means it is never coming. + checkCurrencyWallet(props, walletId) }) // The storage-wallet state provides the disklets once the repo loads, diff --git a/src/core/storage/storage-api.ts b/src/core/storage/storage-api.ts index 6a46aaf79..cfb811d9c 100644 --- a/src/core/storage/storage-api.ts +++ b/src/core/storage/storage-api.ts @@ -73,10 +73,12 @@ export function makeStorageWalletApi( async sync(): Promise { // The storage wallet may not be attached yet on a cache-seeded - // login; wait for `addStorageWallet` instead of throwing: + // login; wait for `addStorageWallet` instead of throwing. + // The liveness check only matters while the repo is missing, + // so an unrelated later failure cannot break a working sync: await ai.waitFor(props => { - if (checkAlive != null) checkAlive(props) if (props.state.storageWallets[id] != null) return true + if (checkAlive != null) checkAlive(props) }) await syncStorageWallet(ai, id) } From 93ceb0c45fe900a6dd1cb1b32dd0c4268603b571 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 19:42:48 -0700 Subject: [PATCH 17/39] Wait for plugin settings before writing them The plugin-settings writers rebuild the whole on-disk map from Redux, so a changeUserSettings or changeSwapSettings call made during the cache-seeded boot window (before reloadPluginSettings ran) would wipe every other plugin's settings, on disk and in memory. Both writers now wait for the settings load, which also guarantees the Redux map they merge into is complete. --- src/core/account/account-files.ts | 24 ++++++++++++++++++++++-- src/core/account/account-reducer.ts | 5 +++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/core/account/account-files.ts b/src/core/account/account-files.ts index 0368795bf..330bba210 100644 --- a/src/core/account/account-files.ts +++ b/src/core/account/account-files.ts @@ -66,6 +66,26 @@ export function waitForAccountRepo( }) } +/** + * Waits until the account's plugin settings have loaded from disk. + * The settings writers rebuild the whole on-disk map from Redux, so + * writing before the load would wipe other plugins' settings. Rejects + * if the account logs out or the boot loads fail terminally. + */ +export function waitForPluginSettings( + ai: ApiInput, + accountId: string +): Promise { + return ai.waitFor(props => { + const accountState = props.state.accounts[accountId] + if (accountState == null) { + throw new Error('The account was logged out') + } + if (accountState.pluginSettingsLoaded) return true + if (accountState.loadFailure != null) throw accountState.loadFailure + }) +} + /** * Loads the legacy wallet list from the account folder. */ @@ -231,7 +251,7 @@ export async function changePluginUserSettings( pluginId: string, userSettings: object ): Promise { - await waitForAccountRepo(ai, accountId) + await waitForPluginSettings(ai, accountId) const { accountWalletInfo } = ai.props.state.accounts[accountId] const disklet = getStorageWalletDisklet(ai.props.state, accountWalletInfo.id) @@ -267,7 +287,7 @@ export async function changeSwapSettings( pluginId: string, swapSettings: SwapSettings ): Promise { - await waitForAccountRepo(ai, accountId) + await waitForPluginSettings(ai, accountId) const { accountWalletInfo } = ai.props.state.accounts[accountId] const disklet = getStorageWalletDisklet(ai.props.state, accountWalletInfo.id) diff --git a/src/core/account/account-reducer.ts b/src/core/account/account-reducer.ts index 50e8c5d52..544f9677e 100644 --- a/src/core/account/account-reducer.ts +++ b/src/core/account/account-reducer.ts @@ -70,6 +70,7 @@ export interface AccountState { readonly swapSettings: EdgePluginMap readonly userSettings: EdgePluginMap readonly pluginSettingsDirty: boolean + readonly pluginSettingsLoaded: boolean } export interface AccountNext { @@ -460,6 +461,10 @@ const accountInner = buildReducer({ return state }, + pluginSettingsLoaded(state = false, action): boolean { + return action.type === 'ACCOUNT_PLUGIN_SETTINGS_LOADED' ? true : state + }, + pluginSettingsDirty(state = false, action): boolean { switch (action.type) { case 'ACCOUNT_PLUGIN_SETTINGS_CHANGED': From d7a0ed0ab522bfb98d1ee755f953180940a62b05 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 19:47:33 -0700 Subject: [PATCH 18/39] Reject a pending token toggle after a terminal boot failure changeEnabledTokenIds waits for the plugin's builtin token definitions, which never arrive if the deferred boot loads failed terminally; re-throw the recorded failure like the other repo waiters. --- src/core/currency/wallet/currency-wallet-api.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index ff52a45c0..2fa9755fb 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -356,6 +356,9 @@ export function makeCurrencyWalletApi( } const accountState = props.state.accounts[accountId] if (accountState?.builtinTokens[pluginId] != null) return accountState + + // A terminal boot failure means the definitions never arrive: + if (accountState?.loadFailure != null) throw accountState.loadFailure }) const { dispatch } = input.props From dc1cb70763ef04ac7f030faa80983650c1abdb32 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 21 Jul 2026 13:30:02 -0700 Subject: [PATCH 19/39] Wait for the wallet-state load before changing wallet states A cache-seeded login holds possibly-stale wallet states, so a change made in that window could no-op against a stale record and then be silently reverted when the authoritative load lands. Gating on walletStatesLoaded also bases the written record on loaded state. --- src/core/account/account-files.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/core/account/account-files.ts b/src/core/account/account-files.ts index 330bba210..dcf5fb23c 100644 --- a/src/core/account/account-files.ts +++ b/src/core/account/account-files.ts @@ -66,6 +66,27 @@ export function waitForAccountRepo( }) } +/** + * Waits until the account's wallet states have loaded from disk. + * A cache-seeded login holds possibly-stale wallet states, so a + * change based on those could no-op against a value the load is + * about to overwrite. Rejects if the account logs out or the boot + * loads fail terminally. + */ +export function waitForWalletStates( + ai: ApiInput, + accountId: string +): Promise { + return ai.waitFor(props => { + const accountState = props.state.accounts[accountId] + if (accountState == null) { + throw new Error('The account was logged out') + } + if (accountState.walletStatesLoaded) return true + if (accountState.loadFailure != null) throw accountState.loadFailure + }) +} + /** * Waits until the account's plugin settings have loaded from disk. * The settings writers rebuild the whole on-disk map from Redux, so @@ -192,7 +213,9 @@ export async function changeWalletStates( accountId: string, newStates: EdgeWalletStates ): Promise { - await waitForAccountRepo(ai, accountId) + // The load implies the repo exists, and it makes the diff below + // compare against authoritative records instead of cached ones: + await waitForWalletStates(ai, accountId) const { accountWalletInfo, walletStates } = ai.props.state.accounts[accountId] const disklet = getStorageWalletDisklet(ai.props.state, accountWalletInfo.id) From d8c48b200a2c2b0e67331cda043c81979dab10dd Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 21 Jul 2026 13:30:25 -0700 Subject: [PATCH 20/39] Gate the account token saver on the custom-token load The saver rebuilds the whole CustomTokens.json file from Redux, so a write in the cache-seeded window would wipe tokens another device added to the file. Return without adopting the diff, so the write retries once the authoritative load has merged. --- src/core/account/account-pixie.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/account/account-pixie.ts b/src/core/account/account-pixie.ts index bc90c1457..801573970 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -305,6 +305,12 @@ const accountPixie: TamePixie = combinePixies({ // triggers the write once `addStorageWallet` finishes: if (state.storageWallets[accountWalletInfo.id] == null) return + // Never write before the authoritative load lands: the write + // rebuilds the whole file from Redux, so a cache-seeded map + // would wipe tokens another device added. Return without + // adopting, so the load's merge triggers the write: + if (!accountState.customTokensLoaded) return + await saveCustomTokens(toApiInput(input), accountId).catch(error => input.props.onError(error) ) From 7a80922884fe4f8621764b56785d125efa9ee0fc Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 21 Jul 2026 13:31:59 -0700 Subject: [PATCH 21/39] Keep user token edits per token id when a load races them The dirty guard for custom tokens was a whole-map boolean, so a load landing after an in-window edit kept the entire stale map, dropping tokens another device had synced to the file. Track the edited token ids instead, and have the load merge everything else from the file. The ids clear once the saver writes them to disk. --- src/core/account/account-pixie.ts | 12 +++++- src/core/account/account-reducer.ts | 58 +++++++++++++++++++++++------ src/core/actions.ts | 7 ++++ 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/core/account/account-pixie.ts b/src/core/account/account-pixie.ts index 801573970..6a1a5fa5d 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -311,9 +311,17 @@ const accountPixie: TamePixie = combinePixies({ // adopting, so the load's merge triggers the write: if (!accountState.customTokensLoaded) return - await saveCustomTokens(toApiInput(input), accountId).catch(error => + try { + await saveCustomTokens(toApiInput(input), accountId) + // Any dirty edits are on disk now, so a racing load no + // longer needs to preserve them: + input.props.dispatch({ + type: 'ACCOUNT_CUSTOM_TOKENS_SAVED', + payload: { accountId } + }) + } catch (error: unknown) { input.props.onError(error) - ) + } await snooze(100) // Rate limiting } lastTokens = customTokens diff --git a/src/core/account/account-reducer.ts b/src/core/account/account-reducer.ts index 544f9677e..00db52c23 100644 --- a/src/core/account/account-reducer.ts +++ b/src/core/account/account-reducer.ts @@ -64,7 +64,7 @@ export interface AccountState { readonly allTokens: EdgePluginMap readonly builtinTokens: EdgePluginMap readonly customTokens: EdgePluginMap - readonly customTokensDirty: boolean + readonly customTokensDirtyIds: EdgePluginMap readonly customTokensLoaded: boolean readonly alwaysEnabledTokenIds: EdgePluginMap readonly swapSettings: EdgePluginMap @@ -366,11 +366,36 @@ const accountInner = buildReducer({ return customTokens } case 'ACCOUNT_CUSTOM_TOKENS_LOADED': { - // Unsaved user changes win over the file we just loaded, - // and stay dirty, so the tokenSaver writes them back out: - if (prev.self?.customTokensDirty) return state + // Unsaved user edits win over the file we just loaded, but + // only for the token ids the user actually touched - the + // rest of the file may hold changes from another device. + // The edits stay dirty, so the tokenSaver writes them out: const { customTokens } = action.payload - return customTokens + const dirtyIds = prev.self?.customTokensDirtyIds ?? {} + const dirtyPluginIds = Object.keys(dirtyIds).filter( + pluginId => dirtyIds[pluginId].length > 0 + ) + if (dirtyPluginIds.length === 0) return customTokens + + const out = { ...customTokens } + for (const pluginId of dirtyPluginIds) { + const dirty = dirtyIds[pluginId] + const loaded = out[pluginId] ?? {} + const list: EdgeTokenMap = {} + for (const tokenId of Object.keys(loaded)) { + // A dirty id missing from our state was removed here: + if (dirty.includes(tokenId) && state[pluginId]?.[tokenId] == null) { + continue + } + list[tokenId] = loaded[tokenId] + } + for (const tokenId of dirty) { + const token = state[pluginId]?.[tokenId] + if (token != null) list[tokenId] = token + } + out[pluginId] = list + } + return out } case 'ACCOUNT_CUSTOM_TOKEN_ADDED': { const { pluginId, tokenId, token } = action.payload @@ -396,17 +421,26 @@ const accountInner = buildReducer({ return state }, - customTokensDirty(state = false, action, next, prev): boolean { + customTokensDirtyIds( + state = {}, + action, + next, + prev + ): EdgePluginMap { switch (action.type) { case 'ACCOUNT_CUSTOM_TOKEN_ADDED': - case 'ACCOUNT_CUSTOM_TOKEN_REMOVED': + case 'ACCOUNT_CUSTOM_TOKEN_REMOVED': { // These actions might change the token list, so check for diffs: - return state || next.self.customTokens !== prev.self?.customTokens + if (next.self.customTokens === prev.self?.customTokens) return state + const { pluginId, tokenId } = action.payload + const ids = state[pluginId] ?? [] + if (ids.includes(tokenId)) return state + return { ...state, [pluginId]: [...ids, tokenId] } + } - case 'ACCOUNT_CUSTOM_TOKENS_LOADED': - // The load has landed; `customTokens` kept any dirty changes, - // and the tokenSaver will write those back out: - return false + case 'ACCOUNT_CUSTOM_TOKENS_SAVED': + // The edits are on disk, so a future load will include them: + return Object.keys(state).length === 0 ? state : {} } return state }, diff --git a/src/core/actions.ts b/src/core/actions.ts index 8b93db1f3..cbbaf5c73 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -99,6 +99,13 @@ export type RootAction = customTokens: EdgePluginMap } } + | { + // The token saver has written the custom tokens to disk. + type: 'ACCOUNT_CUSTOM_TOKENS_SAVED' + payload: { + accountId: string + } + } | { // The account fires this when it loads its keys from disk. type: 'ACCOUNT_KEYS_LOADED' From 3bbc97fdde7fc8ad550c778f3a0ac921038c4e3c Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 21 Jul 2026 13:33:23 -0700 Subject: [PATCH 22/39] Apply enabled-token changes as toggles over the loaded list changeEnabledTokenIds replaced the whole enabled list, so a call made against a cache-seeded list erased enablement changes another device had synced to the file. Diff the request against the list the caller saw, apply those toggles to the current list after the builtin-token wait, and have a racing file load preserve exactly the toggled ids. --- .../currency/wallet/currency-wallet-api.ts | 16 ++++++-- .../wallet/currency-wallet-reducer.ts | 40 +++++++++++++++++-- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index 2fa9755fb..dce489b42 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -344,6 +344,15 @@ export function makeCurrencyWalletApi( const { walletId, walletState } = input.props const { accountId, pluginId } = walletState + // The caller built this list against the enabled list they + // could see, so capture that baseline now. If an authoritative + // load lands during the wait below, we re-apply the caller's + // toggles to the fresh list instead of erasing it with a list + // built from a stale one: + const baseline = walletState.enabledTokenIds + const added = uniqueStrings(tokenIds, baseline) + const removed = baseline.filter(id => !tokenIds.includes(id)) + // On a warm login the builtin token definitions load after the // wallet exists; wait for them, or the filter below would // silently drop enabled builtin tokens. This must keep working @@ -364,9 +373,10 @@ export function makeCurrencyWalletApi( const { dispatch } = input.props const allTokens = accountState.allTokens[pluginId] ?? {} - const enabledTokenIds = uniqueStrings(tokenIds).filter( - tokenId => allTokens[tokenId] != null - ) + const enabledTokenIds = uniqueStrings( + [...input.props.walletState.enabledTokenIds, ...added], + removed + ).filter(tokenId => allTokens[tokenId] != null) const shortId = walletId.slice(0, 2) input.props.log.warn(`enabledTokenIds: ${shortId} changeEnabledTokenIds`) diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index 75d4ab841..cd33438c1 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -74,6 +74,7 @@ export interface CurrencyWalletState { readonly currencyInfo: EdgeCurrencyInfo readonly detectedTokenIds: string[] readonly enabledTokenIds: string[] + readonly enabledTokensDirtyIds: string[] readonly tokenFileDirty: boolean readonly tokenFileLoaded: boolean readonly walletSettings: JsonObject @@ -197,10 +198,18 @@ const currencyWalletInner = buildReducer< enabledTokenIds: sortStringsReducer( (state = initialTokenIds, action, next, prev): string[] => { if (action.type === 'CURRENCY_WALLET_LOADED_TOKEN_FILE') { - // Unsaved user changes win over the file we just loaded, - // and stay dirty, so the tokenSaver writes them back out: - if (prev?.self.tokenFileDirty === true) return state - return action.payload.enabledTokenIds + // Unsaved user toggles win over the file we just loaded, but + // only for the token ids the user actually touched - the + // rest of the file may hold changes from another device. + // The toggles stay dirty, so the tokenSaver writes them out: + const dirtyIds = prev?.self.enabledTokensDirtyIds ?? [] + if (dirtyIds.length === 0) return action.payload.enabledTokenIds + const enabled = dirtyIds.filter(id => state.includes(id)) + const disabled = dirtyIds.filter(id => !state.includes(id)) + return uniqueStrings( + [...action.payload.enabledTokenIds, ...enabled], + disabled + ) } else if (action.type === 'CURRENCY_WALLET_ENABLED_TOKENS_CHANGED') { return action.payload.enabledTokenIds } else if (action.type === 'CURRENCY_WALLET_CACHE_LOADED') { @@ -229,6 +238,29 @@ const currencyWalletInner = buildReducer< return state }, + enabledTokensDirtyIds(state = initialTokenIds, action, next, prev): string[] { + switch (action.type) { + case 'CURRENCY_WALLET_ENABLED_TOKENS_CHANGED': + case 'CURRENCY_ENGINE_DETECTED_TOKENS': { + // Remember which ids these actions actually toggled, so a + // racing load can preserve exactly those: + const prevIds = prev?.self.enabledTokenIds ?? initialTokenIds + const nextIds = next.self.enabledTokenIds + if (nextIds === prevIds) return state + const toggled = [ + ...nextIds.filter(id => !prevIds.includes(id)), + ...prevIds.filter(id => !nextIds.includes(id)) + ].filter(id => !state.includes(id)) + return toggled.length === 0 ? state : [...state, ...toggled] + } + + case 'CURRENCY_WALLET_SAVED_TOKEN_FILE': + // The toggles are on disk, so a future load will include them: + return state.length === 0 ? state : [] + } + return state + }, + tokenFileDirty(state = false, action, next, prev): boolean { switch (action.type) { case 'CURRENCY_WALLET_LOADED_TOKEN_FILE': From e9027da6eeff15ef4e993d621d0d169af1b98099 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 21 Jul 2026 13:34:38 -0700 Subject: [PATCH 23/39] Track dirty plugin settings per plugin id One boolean covered both settings maps, so a load landing after an in-window change discarded the entire loaded payload, dropping settings another device had synced. Track the changed plugin ids per map instead, and merge the rest from the loaded file. The writers now also merge into the freshly read file rather than rebuilding the whole on-disk map from Redux. --- src/core/account/account-files.ts | 135 +++++++++++++++++++--------- src/core/account/account-reducer.ts | 66 ++++++++++---- 2 files changed, 142 insertions(+), 59 deletions(-) diff --git a/src/core/account/account-files.ts b/src/core/account/account-files.ts index dcf5fb23c..5d5f211d0 100644 --- a/src/core/account/account-files.ts +++ b/src/core/account/account-files.ts @@ -25,6 +25,34 @@ const emptySettings = asPluginSettingsFile({}) const PLUGIN_SETTINGS_FILE = 'PluginSettings.json' +/** + * The settings writers read, modify, and re-write one shared file, + * so two concurrent writes would silently drop one plugin's change. + * Serialize them per account: + */ +const settingsWriteQueues = new Map>() + +async function withSettingsFileQueue( + accountId: string, + task: () => Promise +): Promise { + const prev = settingsWriteQueues.get(accountId) ?? Promise.resolve() + const out = prev.then(task) + const tail = out.then( + () => undefined, + () => undefined + ) + settingsWriteQueues.set(accountId, tail) + tail + .then(() => { + if (settingsWriteQueues.get(accountId) === tail) { + settingsWriteQueues.delete(accountId) + } + }) + .catch(() => undefined) + return await out +} + interface LoadedWalletList { walletInfos: EdgeWalletInfo[] walletStates: EdgeWalletStates @@ -88,10 +116,9 @@ export function waitForWalletStates( } /** - * Waits until the account's plugin settings have loaded from disk. - * The settings writers rebuild the whole on-disk map from Redux, so - * writing before the load would wipe other plugins' settings. Rejects - * if the account logs out or the boot loads fail terminally. + * Waits until the account's plugin settings have loaded from disk, + * so a settings change never runs against pre-load Redux state. + * Rejects if the account logs out or the boot loads fail terminally. */ export function waitForPluginSettings( ai: ApiInput, @@ -275,29 +302,41 @@ export async function changePluginUserSettings( userSettings: object ): Promise { await waitForPluginSettings(ai, accountId) - const { accountWalletInfo } = ai.props.state.accounts[accountId] - const disklet = getStorageWalletDisklet(ai.props.state, accountWalletInfo.id) - - // Write the new state to disk: - const clean = - (await pluginSettingsFile.load(disklet, PLUGIN_SETTINGS_FILE)) ?? - emptySettings - await pluginSettingsFile.save(disklet, PLUGIN_SETTINGS_FILE, { - ...clean, - userSettings: { - ...ai.props.state.accounts[accountId].userSettings, - [pluginId]: userSettings + await withSettingsFileQueue(accountId, async () => { + // The account may have logged out while this write was queued: + const accountState = ai.props.state.accounts[accountId] + if (accountState == null) { + throw new Error('The account was logged out') } - }) + const { accountWalletInfo } = accountState + const disklet = getStorageWalletDisklet( + ai.props.state, + accountWalletInfo.id + ) - // Update Redux: - ai.props.dispatch({ - type: 'ACCOUNT_PLUGIN_SETTINGS_CHANGED', - payload: { - accountId, - pluginId, - userSettings: { ...userSettings } - } + // Write the new state to disk. Merge into the freshly read file + // rather than rebuilding the map from Redux, so settings another + // device synced to disk survive even if Redux hasn't seen them yet: + const clean = + (await pluginSettingsFile.load(disklet, PLUGIN_SETTINGS_FILE)) ?? + emptySettings + await pluginSettingsFile.save(disklet, PLUGIN_SETTINGS_FILE, { + ...clean, + userSettings: { + ...clean.userSettings, + [pluginId]: userSettings + } + }) + + // Update Redux: + ai.props.dispatch({ + type: 'ACCOUNT_PLUGIN_SETTINGS_CHANGED', + payload: { + accountId, + pluginId, + userSettings: { ...userSettings } + } + }) }) } @@ -311,25 +350,37 @@ export async function changeSwapSettings( swapSettings: SwapSettings ): Promise { await waitForPluginSettings(ai, accountId) - const { accountWalletInfo } = ai.props.state.accounts[accountId] - const disklet = getStorageWalletDisklet(ai.props.state, accountWalletInfo.id) - - // Write the new state to disk: - const clean = - (await pluginSettingsFile.load(disklet, PLUGIN_SETTINGS_FILE)) ?? - emptySettings - await pluginSettingsFile.save(disklet, PLUGIN_SETTINGS_FILE, { - ...clean, - swapSettings: { - ...ai.props.state.accounts[accountId].swapSettings, - [pluginId]: swapSettings + await withSettingsFileQueue(accountId, async () => { + // The account may have logged out while this write was queued: + const accountState = ai.props.state.accounts[accountId] + if (accountState == null) { + throw new Error('The account was logged out') } - }) + const { accountWalletInfo } = accountState + const disklet = getStorageWalletDisklet( + ai.props.state, + accountWalletInfo.id + ) - // Update Redux: - ai.props.dispatch({ - type: 'ACCOUNT_SWAP_SETTINGS_CHANGED', - payload: { accountId, pluginId, swapSettings } + // Write the new state to disk. Merge into the freshly read file + // rather than rebuilding the map from Redux, so settings another + // device synced to disk survive even if Redux hasn't seen them yet: + const clean = + (await pluginSettingsFile.load(disklet, PLUGIN_SETTINGS_FILE)) ?? + emptySettings + await pluginSettingsFile.save(disklet, PLUGIN_SETTINGS_FILE, { + ...clean, + swapSettings: { + ...clean.swapSettings, + [pluginId]: swapSettings + } + }) + + // Update Redux: + ai.props.dispatch({ + type: 'ACCOUNT_SWAP_SETTINGS_CHANGED', + payload: { accountId, pluginId, swapSettings } + }) }) } diff --git a/src/core/account/account-reducer.ts b/src/core/account/account-reducer.ts index 00db52c23..d311e7dae 100644 --- a/src/core/account/account-reducer.ts +++ b/src/core/account/account-reducer.ts @@ -68,8 +68,9 @@ export interface AccountState { readonly customTokensLoaded: boolean readonly alwaysEnabledTokenIds: EdgePluginMap readonly swapSettings: EdgePluginMap + readonly swapSettingsDirtyIds: string[] readonly userSettings: EdgePluginMap - readonly pluginSettingsDirty: boolean + readonly userSettingsDirtyIds: string[] readonly pluginSettingsLoaded: boolean } @@ -461,11 +462,19 @@ const accountInner = buildReducer({ swapSettings(state = {}, action, next, prev): EdgePluginMap { switch (action.type) { - case 'ACCOUNT_PLUGIN_SETTINGS_LOADED': - // Unsaved user changes win over the file we just loaded - // (the change already wrote the file before dispatching): - if (prev.self?.pluginSettingsDirty) return state - return action.payload.swapSettings + case 'ACCOUNT_PLUGIN_SETTINGS_LOADED': { + // User changes win over the file we just loaded, but only + // for the plugin ids the user actually touched - the rest of + // the file may hold changes from another device. The changes + // wrote the file before dispatching, so nothing is unsaved: + const dirtyIds = prev.self?.swapSettingsDirtyIds ?? [] + if (dirtyIds.length === 0) return action.payload.swapSettings + const out = { ...action.payload.swapSettings } + for (const pluginId of dirtyIds) { + if (state[pluginId] != null) out[pluginId] = state[pluginId] + } + return out + } case 'ACCOUNT_SWAP_SETTINGS_CHANGED': { const { pluginId, swapSettings } = action.payload @@ -486,11 +495,19 @@ const accountInner = buildReducer({ return out } - case 'ACCOUNT_PLUGIN_SETTINGS_LOADED': - // Unsaved user changes win over the file we just loaded - // (the change already wrote the file before dispatching): - if (prev.self?.pluginSettingsDirty) return state - return action.payload.userSettings + case 'ACCOUNT_PLUGIN_SETTINGS_LOADED': { + // User changes win over the file we just loaded, but only + // for the plugin ids the user actually touched - the rest of + // the file may hold changes from another device. The changes + // wrote the file before dispatching, so nothing is unsaved: + const dirtyIds = prev.self?.userSettingsDirtyIds ?? [] + if (dirtyIds.length === 0) return action.payload.userSettings + const out = { ...action.payload.userSettings } + for (const pluginId of dirtyIds) { + if (state[pluginId] != null) out[pluginId] = state[pluginId] + } + return out + } } return state }, @@ -499,15 +516,30 @@ const accountInner = buildReducer({ return action.type === 'ACCOUNT_PLUGIN_SETTINGS_LOADED' ? true : state }, - pluginSettingsDirty(state = false, action): boolean { + swapSettingsDirtyIds(state = [], action): string[] { switch (action.type) { - case 'ACCOUNT_PLUGIN_SETTINGS_CHANGED': - case 'ACCOUNT_SWAP_SETTINGS_CHANGED': - return true + case 'ACCOUNT_SWAP_SETTINGS_CHANGED': { + const { pluginId } = action.payload + return state.includes(pluginId) ? state : [...state, pluginId] + } case 'ACCOUNT_PLUGIN_SETTINGS_LOADED': - // The load has landed; the settings reducers kept dirty state: - return false + // The load has landed, and `swapSettings` merged these ids: + return state.length === 0 ? state : [] + } + return state + }, + + userSettingsDirtyIds(state = [], action): string[] { + switch (action.type) { + case 'ACCOUNT_PLUGIN_SETTINGS_CHANGED': { + const { pluginId } = action.payload + return state.includes(pluginId) ? state : [...state, pluginId] + } + + case 'ACCOUNT_PLUGIN_SETTINGS_LOADED': + // The load has landed, and `userSettings` merged these ids: + return state.length === 0 ? state : [] } return state }, From 987632ca7d487b15ea71e851f14d5b6888ad209b Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 21 Jul 2026 13:36:23 -0700 Subject: [PATCH 24/39] Wait for the engine in the account-level engine methods getActivationAssets, activateWallet, getDisplayPrivateKey, and getDisplayPublicKey read the engine output directly and threw when it was missing, unlike every wallet-level engine method. Route them through a shared engine waiter, which also bumps the wallet to the front of the startup queue and rejects on deletion or engine failure. --- src/core/account/account-api.ts | 35 ++++++++++--------- src/core/currency/currency-selectors.ts | 21 +++++++++++ .../currency/wallet/currency-wallet-api.ts | 11 ++---- 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/src/core/account/account-api.ts b/src/core/account/account-api.ts index 49b52a1b6..db756f55e 100644 --- a/src/core/account/account-api.ts +++ b/src/core/account/account-api.ts @@ -31,7 +31,10 @@ import { } from '../../types/types' import { makeEdgeResult } from '../../util/edgeResult' import { base58 } from '../../util/encoding' -import { bumpEngineQueue } from '../currency/currency-selectors' +import { + bumpEngineQueue, + waitForCurrencyEngine +} from '../currency/currency-selectors' import { saveWalletSettings } from '../currency/wallet/currency-wallet-files' import { getPublicWalletInfo } from '../currency/wallet/currency-wallet-pixie' import { @@ -595,9 +598,9 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { return await tools.getDisplayPrivateKey(info) } - const { engine } = ai.props.output.currency.wallets[walletId] - if (engine == null || engine.getDisplayPrivateSeed == null) { - throw new Error('Wallet has not yet loaded') + const engine = await waitForCurrencyEngine(ai, walletId) + if (engine.getDisplayPrivateSeed == null) { + throw new Error(`getDisplayPrivateKey unsupported by ${info.type}`) } const out = await engine.getDisplayPrivateSeed(info.keys) if (out == null) throw new Error('The engine failed to return a key') @@ -617,9 +620,9 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { return await tools.getDisplayPublicKey(publicInfo) } - const { engine } = ai.props.output.currency.wallets[walletId] - if (engine == null || engine.getDisplayPublicSeed == null) { - throw new Error('Wallet has not yet loaded') + const engine = await waitForCurrencyEngine(ai, walletId) + if (engine.getDisplayPublicSeed == null) { + throw new Error(`getDisplayPublicKey unsupported by ${info.type}`) } const out = await engine.getDisplayPublicSeed() if (out == null) throw new Error('The engine failed to return a key') @@ -799,12 +802,11 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { activateWalletId, activateTokenIds }: EdgeGetActivationAssetsOptions): Promise { - const { currencyWallets } = ai.props.output.accounts[accountId] - const walletOutput = ai.props.output.currency.wallets[activateWalletId] - const { engine } = walletOutput + const engine = await waitForCurrencyEngine(ai, activateWalletId) - if (engine == null) - throw new Error(`Invalid wallet: ${activateWalletId} not found`) + // Read the wallet list after the wait, so wallets that + // finished loading while the engine started are included: + const { currencyWallets } = ai.props.output.accounts[accountId] if (engine.engineGetActivationAssets == null) throw new Error( @@ -825,12 +827,11 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { opts: EdgeActivationOptions ): Promise { const { activateWalletId, activateTokenIds, paymentInfo } = opts - const { currencyWallets } = ai.props.output.accounts[accountId] - const walletOutput = ai.props.output.currency.wallets[activateWalletId] - const { engine } = walletOutput + const engine = await waitForCurrencyEngine(ai, activateWalletId) - if (engine == null) - throw new Error(`Invalid wallet: ${activateWalletId} not found`) + // Read the wallet list after the wait, so wallets that + // finished loading while the engine started are included: + const { currencyWallets } = ai.props.output.accounts[accountId] if (engine.engineActivateWallet == null) throw new Error( diff --git a/src/core/currency/currency-selectors.ts b/src/core/currency/currency-selectors.ts index 01ee5d613..07c822941 100644 --- a/src/core/currency/currency-selectors.ts +++ b/src/core/currency/currency-selectors.ts @@ -1,4 +1,5 @@ import { + EdgeCurrencyEngine, EdgeCurrencyInfo, EdgeCurrencyWallet, EdgeTokenMap @@ -65,6 +66,26 @@ export function waitForCurrencyWallet( return out } +/** + * Waits for a wallet's engine to exist. The wallet API object can + * exist before its engine does (a cache-seeded login), so + * engine-backed methods wait here instead of throwing. Bails out if + * the wallet is deleted mid-wait, and re-throws `engineFailure` so a + * broken plugin surfaces as a rejection instead of a hang. + */ +export function waitForCurrencyEngine( + ai: ApiInput, + walletId: string +): Promise { + // The caller needs this engine now, so skip the startup queue: + bumpEngineQueue(ai, walletId) + + return ai.waitFor((props: RootProps): EdgeCurrencyEngine | undefined => { + checkCurrencyWallet(props, walletId) + return props.output.currency.wallets[walletId]?.engine + }) +} + /** * Prioritizes a wallet's engine startup when its startup work is still * waiting in the limited-concurrency queue. Harmless otherwise. diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index dce489b42..bbb784a2e 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -54,7 +54,8 @@ import { makeStorageWalletApi } from '../../storage/storage-api' import { bumpEngineQueue, checkCurrencyWallet, - getCurrencyMultiplier + getCurrencyMultiplier, + waitForCurrencyEngine } from '../currency-selectors' import { determineConfirmations, @@ -117,13 +118,7 @@ export function makeCurrencyWalletApi( * method call instead of a hang. */ function getEngine(): Promise { - // The caller needs this engine now, so skip the startup queue: - bumpEngineQueue(ai, walletId) - - return ai.waitFor((props: RootProps): EdgeCurrencyEngine | undefined => { - checkCurrencyWallet(props, walletId) - return props.output.currency.wallets[walletId]?.engine - }) + return waitForCurrencyEngine(ai, walletId) } async function getTools(): Promise { From 9b4fe78be5c9a8c0e219f3e4fb78650732573ef7 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 21 Jul 2026 13:36:59 -0700 Subject: [PATCH 25/39] Watch currencyWalletIds in the account cache saver The saver derives the legacyWallets flag from currencyWalletIds at write time but never re-compared it, so a late plugin load could leave a stale flag in the cache file. --- src/core/account/account-pixie.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/account/account-pixie.ts b/src/core/account/account-pixie.ts index 6a1a5fa5d..e99a65a9d 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -339,6 +339,7 @@ const accountPixie: TamePixie = combinePixies({ */ cacheSaver(input: AccountInput) { interface CacheSnapshot { + currencyWalletIds: string[] customTokens: EdgePluginMap legacyWalletInfos: EdgeWalletInfo[] walletStates: EdgeWalletStates @@ -356,6 +357,7 @@ const accountPixie: TamePixie = combinePixies({ if (state.accounts[accountId] == null) return const snapshot: CacheSnapshot = { + currencyWalletIds: accountState.currencyWalletIds, customTokens: accountState.customTokens, legacyWalletInfos: accountState.legacyWalletInfos, walletStates: accountState.walletStates @@ -364,9 +366,8 @@ const accountPixie: TamePixie = combinePixies({ // Only legacy wallets that actually surface as currency wallets // force a cold boot; a legacy repo whose wallet type has no // loaded plugin was never visible in the first place: - const { currencyWalletIds } = accountState const legacyWallets = snapshot.legacyWalletInfos.some(info => - currencyWalletIds.includes(info.id) + snapshot.currencyWalletIds.includes(info.id) ) try { @@ -406,6 +407,7 @@ const accountPixie: TamePixie = combinePixies({ if ( lastSaved != null && + lastSaved.currencyWalletIds === accountState.currencyWalletIds && lastSaved.customTokens === accountState.customTokens && lastSaved.legacyWalletInfos === accountState.legacyWalletInfos && lastSaved.walletStates === accountState.walletStates From 1e2d48086311ed27db5b8aed251085ba629668e5 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 21 Jul 2026 14:12:19 -0700 Subject: [PATCH 26/39] Accept hash-suffixed store routes in the fake sync server The fake server handed out a sync hash on every update but rejected the hash-suffixed routes clients build from it, so any repo's second sync inside a fake world failed with a 404. Reads ignore the hash and return the whole repo, which clients treat as a superset of the changes they asked for. --- src/core/fake/fake-server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/fake/fake-server.ts b/src/core/fake/fake-server.ts index b3e5de2ad..f4e275145 100644 --- a/src/core/fake/fake-server.ts +++ b/src/core/fake/fake-server.ts @@ -789,7 +789,10 @@ const urls: Serverlet = pickPath( }), // Sync server endpoints: - '/api/v2/store/[^/]+/?': pickMethod({ + // The trailing hash segment is "changes since this point", which + // we ignore: reads return the whole repo, which the client treats + // as a superset of the changes it asked for: + '/api/v2/store/[^/]+(/[^/]+)?/?': pickMethod({ GET: storeReadRoute, POST: storeUpdateRoute }), From 16ff1fefdacb79059edd74138b66dfc538284246 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 21 Jul 2026 14:12:49 -0700 Subject: [PATCH 27/39] Add write-path staleness tests Cover the four confirmed audit scenarios: a boot-window custom-token add no longer deletes a token synced from another device, a toggle against a stale cached enabled list preserves the loaded list, a wallet-state change that matches the stale cache waits for the load instead of silently no-oping, and a settings write merges into the freshly read file instead of wiping another plugin's synced settings. The fake plugin gains a public-key check gate, so tests can hold a wallet's file loads while its cache-seeded API stays usable. --- CHANGELOG.md | 8 +- test/core/account/account-cache.test.ts | 280 +++++++++++++++++++++++- test/fake/fake-currency-plugin.ts | 20 +- 3 files changed, 304 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91040e3d8..03c3b58bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,18 @@ ## Unreleased -- added: Account-level `accountCache.json` with the wallet states, custom tokens, and plugin settings the account boot needs. A warm login seeds Redux from it and emits the account right after the currency plugins load, before the account repo syncs, and every cached wallet then seeds from a single bulk dispatch. The deferred file loads overwrite the seeded state authoritatively, and user changes made during the window win over the values those loads read. First login (no cache) boots exactly as before. +- added: Account-level `accountCache.json` with the wallet states and custom tokens the account boot needs. A warm login seeds Redux from it and emits the account right after the currency plugins load, before the account repo syncs, and every cached wallet then seeds from a single bulk dispatch. The deferred file loads overwrite the seeded state authoritatively, and user changes made during the window win over the values those loads read. First login (no cache) boots exactly as before. - added: Per-wallet `walletCache.json` with each wallet's name, fiat code, enabled token IDs, and last-known balances. Currency wallets now emit their API objects as soon as this cache loads, before their engines exist, so the GUI can render the wallet list immediately at login. Engine-backed methods wait for the engine internally and reject if it fails or the wallet is deleted. First login (no cache) behaves exactly as before. - added: Cached wallets' engine startup is staggered through a limited-concurrency queue (8 at a time) instead of all racing at login. Asking for a wallet via `waitForCurrencyWallet`, calling an engine- or storage-backed method, or un-pausing it moves it to the front of the queue. Wallets without a cache skip the queue, so first login is unaffected. - changed: `waitForCurrencyWallet` and `waitForAllWallets` now resolve when the wallet object exists, which can be before its engine loads. - changed: `wallet.otherMethods` is `{}` until the wallet's engine loads, then switches to the engine's methods. - changed: `EdgeCurrencyWallet.balanceMap` keeps its object identity when an engine re-reports an unchanged balance. +- changed: `getActivationAssets`, `activateWallet`, `getDisplayPrivateKey`, and `getDisplayPublicKey` wait for the wallet's engine instead of throwing when it has not loaded yet. +- fixed: The account's custom-token file is never written before its first load, which could permanently delete a custom token another device had synced to the account. +- fixed: File loads that race a user change during the boot window now merge per field (custom tokens per token id, enabled tokens per toggle, plugin settings per plugin id, wallet states per wallet id) instead of keeping or discarding whole maps, so changes synced from another device survive the window. +- fixed: `changeEnabledTokenIds` applies the caller's toggles to the current list, so a call built against a stale list no longer erases enablement changes synced from another device. +- fixed: The plugin-settings writers merge into the on-disk file instead of rebuilding it from memory, so settings another device synced to disk survive a local settings change. +- fixed: The fake sync server accepts the hash-suffixed store routes it hands out, so a repo's second sync inside `makeFakeEdgeWorld` no longer fails with a 404. ## 2.47.1 (2026-07-17) diff --git a/test/core/account/account-cache.test.ts b/test/core/account/account-cache.test.ts index 47325cd4c..95227e040 100644 --- a/test/core/account/account-cache.test.ts +++ b/test/core/account/account-cache.test.ts @@ -5,7 +5,12 @@ import { base64 } from 'rfc4648' import { accountCacheSaverConfig } from '../../../src/core/account/account-cache-file' import { walletCacheSaverConfig } from '../../../src/core/currency/wallet/wallet-cache-file' import { walletCacheLoaderHooks } from '../../../src/core/currency/wallet/wallet-cache-loader' -import { EdgeContext, makeFakeEdgeWorld } from '../../../src/index' +import { + EdgeAccount, + EdgeContext, + EdgeFakeWorld, + makeFakeEdgeWorld +} from '../../../src/index' import { base58 } from '../../../src/util/encoding' import { snooze } from '../../../src/util/snooze' import { @@ -25,6 +30,8 @@ const RACE_WAIT_MS = 150 interface AccountCachedWorld { context: EdgeContext + /** The world, for making second-device contexts. */ + world: EdgeFakeWorld /** Every fakecoin wallet id, in active order at creation time. */ walletIds: string[] /** The custom token added during the first session. */ @@ -71,9 +78,13 @@ async function makeAccountCachedWorld( await account.changeWalletStates({ [walletIds[1]]: { archived: true } }) await snooze(SAVE_WAIT_MS) } + + // Push the session's writes to the sync server, + // so a second device can see them: + await account.sync() await account.logout() - return { context, walletIds, customTokenId } + return { context, world, walletIds, customTokenId } } describe('account cache', function () { @@ -466,6 +477,271 @@ describe('account cache', function () { }) }) +describe('write-path staleness', function () { + beforeEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.publicKeyCheckGate = undefined + accountCacheSaverConfig.throttleMs = 50 + walletCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.publicKeyCheckGate = undefined + accountCacheSaverConfig.throttleMs = 5000 + walletCacheSaverConfig.throttleMs = 5000 + }) + + /** + * Logs device A in and out once, so the background repo sync pulls + * device B's pushed changes into A's local repo copy. The account + * cache saver is stalled for the session, so A's account cache + * stays as it was: the repo is now ahead of the cache, exactly the + * state a warm boot walks into. + */ + async function pullOnDeviceA( + context: EdgeContext, + pulled: (account: EdgeAccount) => Promise + ): Promise { + accountCacheSaverConfig.throttleMs = 5000 + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.sync() + await pollUntilAsync(async () => await pulled(account)) + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + } + + it('keeps custom tokens from another device across a boot-window edit', async function () { + this.timeout(15000) + const { context, customTokenId } = await makeAccountCachedWorld() + + // Put a second token in the synced repo but not in the account + // cache, by stalling the cache saver for a session. This is the + // divergence a second device's addCustomToken leaves behind once + // sync delivers it: the repo is ahead of this device's cache: + accountCacheSaverConfig.throttleMs = 5000 + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const remoteTokenId = await account1.currencyConfig.fakecoin.addCustomToken( + { + currencyCode: 'REMOTE', + displayName: 'Remote Token', + denominations: [{ multiplier: '100', name: 'REMOTE' }], + networkLocation: { contractAddress: '0xREM07E' } + } + ) + await pollUntilAsync(async () => { + const text = await account1.disklet.getText('CustomTokens.json') + return text.includes(remoteTokenId) + }) + // Push the write out of the repo's pending-changes folder, so the + // warm boot's background sync has nothing in flight: + await account1.sync() + await account1.logout() + accountCacheSaverConfig.throttleMs = 50 + + // Device A warm-boots on a cache that has never seen the remote + // token, and adds its own token in the boot window: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const localTokenId = await account.currencyConfig.fakecoin.addCustomToken({ + currencyCode: 'LOCAL', + displayName: 'Local Token', + denominations: [{ multiplier: '100', name: 'LOCAL' }], + networkLocation: { contractAddress: '0xL0CA1' } + }) + + // The saver must not write the cache-seeded map to disk + // (that write is what would delete the remote token): + await snooze(RACE_WAIT_MS) + const preLoad = await account.disklet.getText('CustomTokens.json') + expect(preLoad.includes(localTokenId)).equals(false) + expect(preLoad.includes(remoteTokenId)).equals(true) + + // The load lands, merges, and the saver writes all three tokens: + release() + await pollUntilAsync(async () => { + const text = await account.disklet.getText('CustomTokens.json') + return ( + text.includes(customTokenId) && + text.includes(remoteTokenId) && + text.includes(localTokenId) + ) + }) + const { customTokens } = account.currencyConfig.fakecoin + expect(customTokens[customTokenId]?.currencyCode).equals('CACHED') + expect(customTokens[remoteTokenId]?.currencyCode).equals('REMOTE') + expect(customTokens[localTokenId]?.currencyCode).equals('LOCAL') + await account.logout() + + // Wait for the cache savers to settle before the world closes, + // so nothing writes into a destroyed context: + await snooze(SAVE_WAIT_MS) + }) + + it('keeps enabled tokens from another device across a boot-window toggle', async function () { + this.timeout(15000) + const { context, world, walletIds, customTokenId } = + await makeAccountCachedWorld() + + // Device B enables the builtin token and syncs it to the server: + const contextB = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + const accountB = await contextB.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + const walletB = await accountB.waitForCurrencyWallet(walletIds[0]) + await walletB.changeEnabledTokenIds(['badf00d5']) + await pollUntilAsync(async () => { + const text = await walletB.disklet.getText('Tokens.json') + return text.includes('badf00d5') + }) + await walletB.sync() + await accountB.logout() + + // Device A pulls the wallet repo. The wallet reloads its token + // file mid-session, so stall the wallet cache saver to keep the + // cache on the old empty list (the divergence under test): + walletCacheSaverConfig.throttleMs = 5000 + await pullOnDeviceA(context, async account => { + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + const text = await wallet.disklet.getText('Tokens.json') + return text.includes('badf00d5') + }) + walletCacheSaverConfig.throttleMs = 50 + + // Device A warm-boots on a cache with an empty enabled list. The + // public-key gate holds the wallet's file loads, so the list the + // user acts on is deterministically the stale cached one: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const { gate: pkGate, release: releasePk } = createEngineGate() + fakePluginTestConfig.publicKeyCheckGate = pkGate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + expect([...wallet.enabledTokenIds]).deep.equals([]) + + // Toggling the custom token pends on the builtin definitions: + let toggleSettled = false + const togglePromise = wallet + .changeEnabledTokenIds([customTokenId]) + .then(() => { + toggleSettled = true + }) + await snooze(RACE_WAIT_MS) + expect(toggleSettled).equals(false) + + // The toggle lands against the stale list... + release() + await togglePromise + expect([...wallet.enabledTokenIds]).deep.equals([customTokenId]) + + // ...and the racing load preserves it instead of erasing it, + // while the toggle preserves the other device's enablement: + releasePk() + await pollUntil( + () => + wallet.enabledTokenIds.includes('badf00d5') && + wallet.enabledTokenIds.includes(customTokenId) + ) + await account.logout() + await snooze(SAVE_WAIT_MS) + }) + + it('keeps a wallet-state change made against a stale cache', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld({ + archiveSecond: true + }) + + // Put an "unarchived" record in the synced repo while the cache + // still says "archived", by stalling the cache saver for a + // session. This is the divergence a second device's unarchive + // leaves behind once sync delivers it: + accountCacheSaverConfig.throttleMs = 5000 + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account1.changeWalletStates({ [walletIds[1]]: { archived: false } }) + // Push the write out of the repo's pending-changes folder, so the + // warm boot's background sync has nothing in flight: + await account1.sync() + await account1.logout() + accountCacheSaverConfig.throttleMs = 50 + + // Device A warm-boots on a cache that still says "archived", and + // the user re-affirms that. Against the stale cache this change + // looks like a no-op, so it must wait for the load instead: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account.archivedWalletIds.includes(walletIds[1])).equals(true) + let changeSettled = false + const changePromise = account + .changeWalletStates({ [walletIds[1]]: { archived: true } }) + .then(() => { + changeSettled = true + }) + await snooze(RACE_WAIT_MS) + expect(changeSettled).equals(false) + + // The load lands with device B's "unarchived" record, and the + // change applies on top of it instead of silently reverting: + release() + await changePromise + expect(account.archivedWalletIds.includes(walletIds[1])).equals(true) + expect(account.activeWalletIds.includes(walletIds[1])).equals(false) + await account.logout() + await snooze(SAVE_WAIT_MS) + }) + + it('keeps plugin settings from another device across a local change', async function () { + this.timeout(15000) + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const plugins = { fakecoin: true, fakeswap: true } + const context = await world.makeEdgeContext({ ...contextOptions, plugins }) + const contextB = await world.makeEdgeContext({ ...contextOptions, plugins }) + + // Device A logs in first, so its Redux settings predate B's write: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Device B changes the currency plugin's settings and syncs: + const accountB = await contextB.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + await accountB.currencyConfig.fakecoin.changeUserSettings({ + remoteFlag: true + }) + await accountB.sync() + await accountB.logout() + + // Device A pulls the repo (no settings reload runs), then changes + // a different plugin's settings. The writer must merge into the + // freshly read file, not rebuild it from stale Redux: + await account.sync() + await account.swapConfig.fakeswap.changeUserSettings({ localFlag: true }) + const text = await account.disklet.getText('PluginSettings.json') + expect(text.includes('remoteFlag')).equals(true) + expect(text.includes('localFlag')).equals(true) + await account.logout() + + // A fresh login sees both settings: + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account2.currencyConfig.fakecoin.userSettings).deep.equals({ + remoteFlag: true + }) + expect(account2.swapConfig.fakeswap.userSettings).deep.equals({ + localFlag: true + }) + await account2.logout() + await snooze(SAVE_WAIT_MS) + }) +}) + /** The disklet path of a file on a wallet's or account's local storage. */ function localPath(id: string, file: string): string { return `local/${base58.stringify(base64.parse(id))}/${file}` diff --git a/test/fake/fake-currency-plugin.ts b/test/fake/fake-currency-plugin.ts index 1871e49a8..048da2c6c 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -21,7 +21,8 @@ import { EdgeTransaction, EdgeTransactionEvent, EdgeWalletInfo, - InsufficientFundsError + InsufficientFundsError, + JsonObject } from '../../src/index' import { upgradeCurrencyCode } from '../../src/types/type-helpers' @@ -47,6 +48,15 @@ export interface FakePluginTestConfig { */ engineGate?: Promise + /** + * If set, `checkPublicKey` will wait for this promise to resolve. + * The wallet pixie validates its cached public key between the + * repo sync and the wallet file loads, so this gate makes "the + * wallet's file loads have not landed yet" a deterministic state + * on a warm login, while the cache-seeded wallet API stays usable. + */ + publicKeyCheckGate?: Promise + /** * If set, receives each wallet id as its `makeCurrencyEngine` call * begins (before any gate), so tests can observe creation order. @@ -57,6 +67,7 @@ export interface FakePluginTestConfig { export const fakePluginTestConfig: FakePluginTestConfig = { builtinTokensGate: undefined, engineGate: undefined, + publicKeyCheckGate: undefined, onEngineCreate: undefined } @@ -408,6 +419,13 @@ class FakeCurrencyTools implements EdgeCurrencyTools { return Promise.resolve({ fakeKey: 'FakePrivateKey' }) } + async checkPublicKey(publicKey: JsonObject): Promise { + if (fakePluginTestConfig.publicKeyCheckGate != null) { + await fakePluginTestConfig.publicKeyCheckGate + } + return true + } + async derivePublicKey(privateWalletInfo: EdgeWalletInfo): Promise { return { fakeAddress: 'FakePublicAddress' } } From 51549dd8afceae49b224fcbffa94d3dbf28d8ec2 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 13:16:37 -0700 Subject: [PATCH 28/39] Serve cached addresses before the engine The receive path always waited on the engine, so a warm login showed a spinner until the wallet finished loading. The engine's answer to the default address query is now remembered in Redux (balances stripped), persisted into walletCache.json (schema version 2, with old files upgraded on read so nobody loses a warm boot), and seeded on the next login, where it is served while the engine is still loading. A caller naming a specific index still waits, since only the engine can derive a fresh address. --- src/core/actions.ts | 12 +++++ .../currency/wallet/currency-wallet-api.ts | 44 ++++++++++++++++++- .../wallet/currency-wallet-cleaners.ts | 36 ++++++++++++++- .../currency/wallet/currency-wallet-pixie.ts | 12 ++++- .../wallet/currency-wallet-reducer.ts | 19 ++++++++ src/core/currency/wallet/wallet-cache-file.ts | 19 ++++++-- .../currency/wallet/wallet-cache-loader.ts | 1 + src/types/types.ts | 1 + 8 files changed, 137 insertions(+), 7 deletions(-) diff --git a/src/core/actions.ts b/src/core/actions.ts index cbbaf5c73..c5ab665ff 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -1,4 +1,5 @@ import { + EdgeAddress, EdgeBalanceMap, EdgeCorePlugin, EdgeCorePluginsInit, @@ -292,11 +293,22 @@ export type RootAction = tools: Promise } } + | { + // The engine answered an address query, so remember the result + // for the cache (and, on stable-address chains, for pre-engine + // serving on the next login). Balances are stripped. + type: 'CURRENCY_WALLET_ADDRESSES_CHANGED' + payload: { + addresses: EdgeAddress[] + walletId: string + } + } | { // Called when the wallet's UI-state cache file loads from disk, // seeding Redux before the engine exists. type: 'CURRENCY_WALLET_CACHE_LOADED' payload: { + addresses: EdgeAddress[] balanceMap: EdgeBalanceMap enabledTokenIds: string[] fiatCurrencyCode: string diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index bbb784a2e..64ef6442c 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -168,6 +168,29 @@ export function makeCurrencyWalletApi( return fallbackDisklets } + /** + * Remembers the engine's answer to the default address query, so + * the cache saver persists it and the next warm login can serve it + * pre-engine. Balances are stripped: + * they are stale by definition and `balanceMap` already owns them. + */ + function rememberAddresses( + opts: EdgeGetReceiveAddressOptions, + addresses: EdgeAddress[] + ): void { + if (opts.forceIndex != null) return + input.props.dispatch({ + type: 'CURRENCY_WALLET_ADDRESSES_CHANGED', + payload: { + addresses: addresses.map(address => ({ + addressType: address.addressType, + publicAddress: address.publicAddress + })), + walletId + } + }) + } + const fakeCallbacks = makeCurrencyWalletCallbacks(input) // The core guarantees `otherMethods` is `{}` (never `undefined`) before @@ -539,9 +562,27 @@ export function makeCurrencyWalletApi( async getAddresses( opts: EdgeGetReceiveAddressOptions ): Promise { + // Serve the cached answer while the engine is still loading, so + // the receive scene works right away on a warm login. A caller + // naming a specific index wants a freshly derived address, which + // only the engine can produce, so that query still waits: + const cachedAddresses = input.props.walletState.addresses + if ( + opts.forceIndex == null && + cachedAddresses.length > 0 && + input.props.walletOutput?.engine == null + ) { + // The user is on an address screen, so they want this + // wallet's engine sooner rather than later: + bumpEngineQueue(ai, walletId) + return cachedAddresses.map(address => ({ ...address })) + } + const engine = await getEngine() if (engine.getAddresses != null) { - return await engine.getAddresses(opts) + const addresses = await engine.getAddresses(opts) + rememberAddresses(opts, addresses) + return addresses } else { const upgradedCurrency = upgradeCurrencyCode({ allTokens: input.props.state.accounts[accountId].allTokens[pluginId], @@ -587,6 +628,7 @@ export function makeCurrencyWalletApi( }) } + rememberAddresses(opts, addresses) return addresses } }, diff --git a/src/core/currency/wallet/currency-wallet-cleaners.ts b/src/core/currency/wallet/currency-wallet-cleaners.ts index d57252d59..1d7a63fb8 100644 --- a/src/core/currency/wallet/currency-wallet-cleaners.ts +++ b/src/core/currency/wallet/currency-wallet-cleaners.ts @@ -409,16 +409,36 @@ export const asPublicKeyFile = asObject({ * and are explicitly allowed to be stale. */ export interface WalletCacheFile { - version: 1 + version: 2 name: string | null fiatCurrencyCode: string enabledTokenIds: string[] /** Integer strings. The `null` tokenId is spelled '' here. */ balances: { [tokenId: string]: string } + + /** + * The engine's last answer to the default address query, without + * balances. Served pre-engine, before the engine confirms them. + */ + addresses: Array<{ addressType: string; publicAddress: string }> } +const asCachedAddress = asObject({ + addressType: asString, + publicAddress: asString +}) + export const asWalletCacheFile: Cleaner = asObject({ + version: asValue(2), + name: asEither(asString, asNull), + fiatCurrencyCode: asString, + enabledTokenIds: asArray(asString), + balances: asObject(asIntegerString), + addresses: asArray(asCachedAddress) +}) + +const asWalletCacheFileV1 = asObject({ version: asValue(1), name: asEither(asString, asNull), fiatCurrencyCode: asString, @@ -426,6 +446,20 @@ export const asWalletCacheFile: Cleaner = asObject({ balances: asObject(asIntegerString) }) +/** + * Read-side cleaner: upgrades an old file instead of falling through + * to a cold boot; the fields it lacks simply have nothing cached yet. + * Writes always use the current `asWalletCacheFile` schema. + */ +export const asStoredWalletCacheFile: Cleaner = raw => { + try { + return asWalletCacheFile(raw) + } catch (error: unknown) { + const clean = asWalletCacheFileV1(raw) + return { ...clean, version: 2, addresses: [] } + } +} + /** * The wallet's local storage file for the last seen "checkpoint". The core * does not know the contents of the checkpoint, so it just as an arbitrary diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index 737e45ba3..8d77549e3 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -10,6 +10,7 @@ import { import { update } from 'yaob' import { + EdgeAddress, EdgeBalanceMap, EdgeCurrencyEngine, EdgeCurrencyTools, @@ -557,6 +558,7 @@ export const walletPixie: TamePixie = combinePixies({ */ cacheSaver(input: CurrencyWalletInput) { interface CacheSnapshot { + addresses: EdgeAddress[] balanceMap: EdgeBalanceMap enabledTokenIds: string[] fiat: string @@ -575,6 +577,7 @@ export const walletPixie: TamePixie = combinePixies({ if (state.accounts[walletState.accountId] == null) return const snapshot: CacheSnapshot = { + addresses: walletState.addresses, balanceMap: walletState.balanceMap, enabledTokenIds: walletState.enabledTokenIds, fiat: walletState.fiat, @@ -590,11 +593,15 @@ export const walletPixie: TamePixie = combinePixies({ makeLocalDisklet(input.props.io, walletId), WALLET_CACHE_FILE, { - version: 1, + version: 2, name: snapshot.name, fiatCurrencyCode: snapshot.fiat, enabledTokenIds: snapshot.enabledTokenIds, - balances + balances, + addresses: snapshot.addresses.map(address => ({ + addressType: address.addressType, + publicAddress: address.publicAddress + })) } ) failures = 0 @@ -623,6 +630,7 @@ export const walletPixie: TamePixie = combinePixies({ if ( lastSaved != null && + lastSaved.addresses === walletState.addresses && lastSaved.balanceMap === walletState.balanceMap && lastSaved.enabledTokenIds === walletState.enabledTokenIds && lastSaved.fiat === walletState.fiat && diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index cd33438c1..9eb996f18 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -2,6 +2,7 @@ import { lt } from 'biggystring' import { buildReducer, filterReducer, memoizeReducer } from 'redux-keto' import { + EdgeAddress, EdgeAssetAction, EdgeBalanceMap, EdgeBalances, @@ -67,6 +68,7 @@ export interface CurrencyWalletState { readonly paused: boolean + readonly addresses: EdgeAddress[] readonly allEnabledTokenIds: string[] readonly balanceMap: EdgeBalanceMap readonly balances: EdgeBalances @@ -126,6 +128,8 @@ export interface CurrencyWalletNext { export const initialWalletSettings: JsonObject = {} +export const initialAddresses: EdgeAddress[] = [] + // Used for detectedTokenIds & enabledTokenIds: export const initialTokenIds: string[] = [] @@ -429,6 +433,21 @@ const currencyWalletInner = buildReducer< return state }, + addresses(state = initialAddresses, action): EdgeAddress[] { + switch (action.type) { + case 'CURRENCY_WALLET_ADDRESSES_CHANGED': + // The engine's answer is authoritative: + return action.payload.addresses + + case 'CURRENCY_WALLET_CACHE_LOADED': + // Seed cached addresses, but never overwrite an engine answer + // (the seed only ever fires before the engine exists): + if (state.length > 0) return state + return action.payload.addresses + } + return state + }, + balanceMap(state = new Map(), action): Map { if (action.type === 'CURRENCY_ENGINE_CHANGED_BALANCE') { const { balance, tokenId } = action.payload diff --git a/src/core/currency/wallet/wallet-cache-file.ts b/src/core/currency/wallet/wallet-cache-file.ts index b7324731b..3551fbcf3 100644 --- a/src/core/currency/wallet/wallet-cache-file.ts +++ b/src/core/currency/wallet/wallet-cache-file.ts @@ -1,13 +1,25 @@ -import { EdgeBalanceMap, EdgeWalletInfo } from '../../../types/types' +import { + EdgeAddress, + EdgeBalanceMap, + EdgeWalletInfo +} from '../../../types/types' import { makeJsonFile } from '../../../util/file-helpers' -import { asWalletCacheFile } from './currency-wallet-cleaners' +import { + asStoredWalletCacheFile, + asWalletCacheFile +} from './currency-wallet-cleaners' /** * Cached wallet UI state, stored on the wallet's local disklet * alongside `publicKey.json`. See `asWalletCacheFile` for the schema. + * Reads accept older schema versions by upgrading them in place, + * so a version bump never costs an existing device its warm boot. */ export const WALLET_CACHE_FILE = 'walletCache.json' -export const walletCacheFile = makeJsonFile(asWalletCacheFile) +export const walletCacheFile = { + load: makeJsonFile(asStoredWalletCacheFile).load, + save: makeJsonFile(asWalletCacheFile).save +} /** * One wallet's cache files, validated and ready to seed Redux: @@ -15,6 +27,7 @@ export const walletCacheFile = makeJsonFile(asWalletCacheFile) * `walletCache.json`, with balances upgraded to an `EdgeBalanceMap`. */ export interface WalletCacheSeed { + addresses: EdgeAddress[] balanceMap: EdgeBalanceMap enabledTokenIds: string[] fiatCurrencyCode: string diff --git a/src/core/currency/wallet/wallet-cache-loader.ts b/src/core/currency/wallet/wallet-cache-loader.ts index 08ef0457c..c3e0695f6 100644 --- a/src/core/currency/wallet/wallet-cache-loader.ts +++ b/src/core/currency/wallet/wallet-cache-loader.ts @@ -56,6 +56,7 @@ export async function loadWalletCacheSeed( if (publicKeyCache == null || walletCache == null) return return { + addresses: walletCache.addresses, balanceMap: makeCachedBalanceMap(walletCache.balances), enabledTokenIds: walletCache.enabledTokenIds, fiatCurrencyCode: walletCache.fiatCurrencyCode, diff --git a/src/types/types.ts b/src/types/types.ts index d7fe8fec5..edc99648b 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -516,6 +516,7 @@ export interface EdgeCurrencyInfo { canAdjustFees?: boolean // Defaults to true canImportKeys?: boolean // Defaults to false canReplaceByFee?: boolean // Defaults to false + customFeeTemplate?: EdgeObjectTemplate // Indicates custom fee support customTokenTemplate?: EdgeObjectTemplate // Indicates custom token support requiredConfirmations?: number // Block confirmations required for a tx From a31909e25a7f9072a76379a11eabe2e0332d0fd0 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 13:20:49 -0700 Subject: [PATCH 29/39] Expose otherMethods as permanent delegating stubs The wallet's otherMethods object was {} pre-engine and swapped to the engine's bridgified methods when it landed, so a method was invisible until then even though its name never changes. The engine's method names now persist in walletCache.json, and the wallet exposes one permanent object of delegating stubs: each waits for the engine and forwards, resolving the real method once. A warm login can therefore call a known method before the engine exists, a stale cached name rejects cleanly at call time, and the object-swap plus update() dance is gone. Cold logins with no cache keep the {} guarantee. --- src/core/actions.ts | 11 +++ .../currency/wallet/currency-wallet-api.ts | 80 ++++++++++++------- .../wallet/currency-wallet-cleaners.ts | 11 ++- .../currency/wallet/currency-wallet-pixie.ts | 26 +++++- .../wallet/currency-wallet-reducer.ts | 18 +++++ src/core/currency/wallet/wallet-cache-file.ts | 1 + .../currency/wallet/wallet-cache-loader.ts | 1 + 7 files changed, 115 insertions(+), 33 deletions(-) diff --git a/src/core/actions.ts b/src/core/actions.ts index c5ab665ff..534d7a7df 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -303,6 +303,16 @@ export type RootAction = walletId: string } } + | { + // The engine exists, so remember its otherMethods names for + // the cache; the next login builds pre-engine delegating stubs + // from them. + type: 'CURRENCY_WALLET_OTHER_METHOD_NAMES_CHANGED' + payload: { + names: string[] + walletId: string + } + } | { // Called when the wallet's UI-state cache file loads from disk, // seeding Redux before the engine exists. @@ -313,6 +323,7 @@ export type RootAction = enabledTokenIds: string[] fiatCurrencyCode: string name: string | null + otherMethodNames: string[] publicWalletInfo?: EdgeWalletInfo walletId: string } diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index 64ef6442c..aa3592e1d 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -1,7 +1,7 @@ import { abs, div, lt, mul } from 'biggystring' import { Disklet } from 'disklet' import { base64 } from 'rfc4648' -import { bridgifyObject, emit, onMethod, watchMethod } from 'yaob' +import { bridgifyObject, emit, onMethod, update, watchMethod } from 'yaob' import { InternalWalletMethods, @@ -193,32 +193,59 @@ export function makeCurrencyWalletApi( const fakeCallbacks = makeCurrencyWalletCallbacks(input) - // The core guarantees `otherMethods` is `{}` (never `undefined`) before - // the engine exists, so property probes stay safe on the GUI side. - // Once the engine lands, the pixie watcher's `update` propagates the - // engine's bridgified methods through the same getter: - const emptyOtherMethods = bridgifyObject({}) - let engineOtherMethods: EdgeOtherMethods | undefined - - function makeEngineOtherMethods( - engine: EdgeCurrencyEngine - ): EdgeOtherMethods { - const otherMethods: { [name: string]: (...args: any[]) => any } = {} - if (engine.otherMethods != null) { - for (const name of Object.keys(engine.otherMethods)) { - const method = engine.otherMethods[name] - if (typeof method !== 'function') continue - otherMethods[name] = method + // The wallet exposes ONE permanent `otherMethods` object for its + // whole life; it is never swapped when the engine lands. Each + // property is a delegating stub that waits for the engine and then + // forwards, so a method can be called the moment its NAME is known: + // from the cache on a warm login (before the engine exists), or + // from the live engine otherwise. Stubs are added when a name first + // appears and never removed; a name the loaded engine turns out to + // lack rejects cleanly at call time. Pre-engine with no cache this + // is `{}`, exactly the old guarantee, so property probes stay safe. + const otherMethodStubs: { [name: string]: (...args: any[]) => any } = {} + bridgifyObject(otherMethodStubs) + + function makeOtherMethodStub(name: string): (...args: any[]) => any { + // Resolve the engine's method once and remember it: + let resolved: ((...args: any[]) => any) | undefined + return async (...args: any[]): Promise => { + const engine = await getEngine() + if (resolved == null) { + const withKeys = engine.otherMethodsWithKeys?.[name] + const method = engine.otherMethods?.[name] + if (typeof withKeys === 'function') { + resolved = (...inner: any[]) => withKeys(walletInfo.keys, ...inner) + } else if (typeof method === 'function') { + resolved = method + } } + if (resolved == null) { + throw new Error(`The wallet engine does not implement "${name}"`) + } + return resolved(...args) } - if (engine.otherMethodsWithKeys != null) { - for (const name of Object.keys(engine.otherMethodsWithKeys)) { - const method = engine.otherMethodsWithKeys[name] - if (typeof method !== 'function') continue - otherMethods[name] = (...args) => method(walletInfo.keys, ...args) + } + + function syncOtherMethodStubs(): EdgeOtherMethods { + const names = [...input.props.walletState.otherMethodNames] + const engine = input.props.walletOutput?.engine + if (engine != null) { + for (const source of [engine.otherMethods, engine.otherMethodsWithKeys]) { + if (source == null) continue + for (const name of Object.keys(source)) { + if (typeof source[name] === 'function') names.push(name) + } } } - return bridgifyObject(otherMethods) + let added = false + for (const name of names) { + if (otherMethodStubs[name] != null) continue + otherMethodStubs[name] = makeOtherMethodStub(name) + added = true + } + // New names must reach the far side of the bridge too: + if (added) update(otherMethodStubs) + return otherMethodStubs } const out: EdgeCurrencyWallet & InternalWalletMethods = { @@ -976,12 +1003,7 @@ export function makeCurrencyWalletApi( // Generic: get otherMethods(): EdgeOtherMethods { - const engine = input.props.walletOutput?.engine - if (engine == null) return emptyOtherMethods - if (engineOtherMethods == null) { - engineOtherMethods = makeEngineOtherMethods(engine) - } - return engineOtherMethods + return syncOtherMethodStubs() } } diff --git a/src/core/currency/wallet/currency-wallet-cleaners.ts b/src/core/currency/wallet/currency-wallet-cleaners.ts index 1d7a63fb8..4ccd758b9 100644 --- a/src/core/currency/wallet/currency-wallet-cleaners.ts +++ b/src/core/currency/wallet/currency-wallet-cleaners.ts @@ -422,6 +422,12 @@ export interface WalletCacheFile { * balances. Served pre-engine, before the engine confirms them. */ addresses: Array<{ addressType: string; publicAddress: string }> + + /** + * The names of the engine's `otherMethods`, so the next login can + * expose a delegating stub per method before the engine exists. + */ + otherMethodNames: string[] } const asCachedAddress = asObject({ @@ -435,7 +441,8 @@ export const asWalletCacheFile: Cleaner = asObject({ fiatCurrencyCode: asString, enabledTokenIds: asArray(asString), balances: asObject(asIntegerString), - addresses: asArray(asCachedAddress) + addresses: asArray(asCachedAddress), + otherMethodNames: asOptional(asArray(asString), () => []) }) const asWalletCacheFileV1 = asObject({ @@ -456,7 +463,7 @@ export const asStoredWalletCacheFile: Cleaner = raw => { return asWalletCacheFile(raw) } catch (error: unknown) { const clean = asWalletCacheFileV1(raw) - return { ...clean, version: 2, addresses: [] } + return { ...clean, version: 2, addresses: [], otherMethodNames: [] } } } diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index 8d77549e3..bf948d39f 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -229,6 +229,24 @@ export const walletPixie: TamePixie = combinePixies({ }) input.onOutput(engine) + // Remember the engine's otherMethods names, so the cache can + // expose pre-engine delegating stubs on the next login: + const otherMethodNames: string[] = [] + for (const source of [ + engine.otherMethods, + engine.otherMethodsWithKeys + ]) { + if (source == null) continue + for (const name of Object.keys(source)) { + if (typeof source[name] !== 'function') continue + if (!otherMethodNames.includes(name)) otherMethodNames.push(name) + } + } + input.props.dispatch({ + type: 'CURRENCY_WALLET_OTHER_METHOD_NAMES_CHANGED', + payload: { names: otherMethodNames, walletId } + }) + // Grab initial state: const parentCurrency = { currencyCode, tokenId: null } const balance = asMaybe(asIntegerString)( @@ -563,6 +581,7 @@ export const walletPixie: TamePixie = combinePixies({ enabledTokenIds: string[] fiat: string name: string | null + otherMethodNames: string[] } let failures = 0 @@ -581,7 +600,8 @@ export const walletPixie: TamePixie = combinePixies({ balanceMap: walletState.balanceMap, enabledTokenIds: walletState.enabledTokenIds, fiat: walletState.fiat, - name: walletState.name + name: walletState.name, + otherMethodNames: walletState.otherMethodNames } const balances: WalletCacheFile['balances'] = {} for (const [tokenId, balance] of snapshot.balanceMap) { @@ -601,7 +621,8 @@ export const walletPixie: TamePixie = combinePixies({ addresses: snapshot.addresses.map(address => ({ addressType: address.addressType, publicAddress: address.publicAddress - })) + })), + otherMethodNames: snapshot.otherMethodNames } ) failures = 0 @@ -632,6 +653,7 @@ export const walletPixie: TamePixie = combinePixies({ lastSaved != null && lastSaved.addresses === walletState.addresses && lastSaved.balanceMap === walletState.balanceMap && + lastSaved.otherMethodNames === walletState.otherMethodNames && lastSaved.enabledTokenIds === walletState.enabledTokenIds && lastSaved.fiat === walletState.fiat && lastSaved.name === walletState.name diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index 9eb996f18..96d268c23 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -93,6 +93,7 @@ export interface CurrencyWalletState { readonly name: string | null readonly nameDirty: boolean readonly nameLoaded: boolean + readonly otherMethodNames: string[] readonly publicWalletInfo: EdgeWalletInfo | null readonly seenTxCheckpoint: string | null readonly sortedTxidHashes: string[] @@ -433,6 +434,23 @@ const currencyWalletInner = buildReducer< return state }, + otherMethodNames: sortStringsReducer( + (state = initialTokenIds, action): string[] => { + switch (action.type) { + case 'CURRENCY_WALLET_OTHER_METHOD_NAMES_CHANGED': + // The engine's method list is authoritative: + return action.payload.names + + case 'CURRENCY_WALLET_CACHE_LOADED': + // Seed cached names, but never overwrite an engine answer + // (the seed only ever fires before the engine exists): + if (state.length > 0) return state + return action.payload.otherMethodNames + } + return state + } + ), + addresses(state = initialAddresses, action): EdgeAddress[] { switch (action.type) { case 'CURRENCY_WALLET_ADDRESSES_CHANGED': diff --git a/src/core/currency/wallet/wallet-cache-file.ts b/src/core/currency/wallet/wallet-cache-file.ts index 3551fbcf3..0684e219d 100644 --- a/src/core/currency/wallet/wallet-cache-file.ts +++ b/src/core/currency/wallet/wallet-cache-file.ts @@ -32,6 +32,7 @@ export interface WalletCacheSeed { enabledTokenIds: string[] fiatCurrencyCode: string name: string | null + otherMethodNames: string[] publicWalletInfo: EdgeWalletInfo } diff --git a/src/core/currency/wallet/wallet-cache-loader.ts b/src/core/currency/wallet/wallet-cache-loader.ts index c3e0695f6..30afbfd16 100644 --- a/src/core/currency/wallet/wallet-cache-loader.ts +++ b/src/core/currency/wallet/wallet-cache-loader.ts @@ -57,6 +57,7 @@ export async function loadWalletCacheSeed( return { addresses: walletCache.addresses, + otherMethodNames: walletCache.otherMethodNames, balanceMap: makeCachedBalanceMap(walletCache.balances), enabledTokenIds: walletCache.enabledTokenIds, fiatCurrencyCode: walletCache.fiatCurrencyCode, From 6925d0ade675f9e3533b9cd3fee771bde5866612 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 13:23:29 -0700 Subject: [PATCH 30/39] Cache config otherMethod names CurrencyConfig.otherMethods now mirrors the wallet's model: one permanent object of delegating stubs whose names come from the live plugin, with the account cache as a fallback. Each plugin's names persist in accountCache.json, so the config surface stays complete even if plugin loading ever defers past the account emit. --- src/core/account/account-cleaners.ts | 8 ++++++- src/core/account/account-pixie.ts | 17 +++++++++++++- src/core/account/account-reducer.ts | 9 ++++++++ src/core/account/plugin-api.ts | 34 ++++++++++++++++++++++++---- src/core/actions.ts | 1 + 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/core/account/account-cleaners.ts b/src/core/account/account-cleaners.ts index 833d416be..68bc4be7c 100644 --- a/src/core/account/account-cleaners.ts +++ b/src/core/account/account-cleaners.ts @@ -124,6 +124,11 @@ export interface AccountCacheFile { */ legacyWallets: boolean walletStates: EdgeWalletStates + /** + * Each plugin's `otherMethods` names, so `CurrencyConfig` can + * expose delegating stubs even if the plugin has not loaded yet. + */ + configOtherMethodNames: EdgePluginMap } const asEdgeWalletState = asObject({ @@ -138,5 +143,6 @@ export const asAccountCacheFile: Cleaner = asObject({ version: asValue(1), customTokens: asObject(asObject(asEdgeToken)), legacyWallets: asOptional(asBoolean, false), - walletStates: asObject(asEdgeWalletState) + walletStates: asObject(asEdgeWalletState), + configOtherMethodNames: asOptional(asObject(asArray(asString)), () => ({})) }) diff --git a/src/core/account/account-pixie.ts b/src/core/account/account-pixie.ts index e99a65a9d..b179d0384 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -136,6 +136,7 @@ const accountPixie: TamePixie = combinePixies({ type: 'ACCOUNT_CACHE_LOADED', payload: { accountId, + configOtherMethodNames: accountCache.configOtherMethodNames, customTokens: accountCache.customTokens, walletStates: accountCache.walletStates } @@ -370,6 +371,19 @@ const accountPixie: TamePixie = combinePixies({ snapshot.currencyWalletIds.includes(info.id) ) + // Remember each plugin's otherMethods names. The plugin list is + // static for the whole session, so this needs no dirty tracking: + const configOtherMethodNames: EdgePluginMap = {} + const { currency } = input.props.state.plugins + for (const pluginId of Object.keys(currency)) { + const { otherMethods } = currency[pluginId] + if (otherMethods == null) continue + const names = Object.keys(otherMethods).filter( + name => typeof (otherMethods as any)[name] === 'function' + ) + if (names.length > 0) configOtherMethodNames[pluginId] = names + } + try { await accountCacheFile.save( makeLocalDisklet(input.props.io, accountState.accountWalletInfo.id), @@ -378,7 +392,8 @@ const accountPixie: TamePixie = combinePixies({ version: 1, customTokens: snapshot.customTokens, legacyWallets, - walletStates: snapshot.walletStates + walletStates: snapshot.walletStates, + configOtherMethodNames } ) failures = 0 diff --git a/src/core/account/account-reducer.ts b/src/core/account/account-reducer.ts index d311e7dae..bc394ea43 100644 --- a/src/core/account/account-reducer.ts +++ b/src/core/account/account-reducer.ts @@ -63,6 +63,7 @@ export interface AccountState { // Plugin stuff: readonly allTokens: EdgePluginMap readonly builtinTokens: EdgePluginMap + readonly configOtherMethodNames: EdgePluginMap readonly customTokens: EdgePluginMap readonly customTokensDirtyIds: EdgePluginMap readonly customTokensLoaded: boolean @@ -422,6 +423,14 @@ const accountInner = buildReducer({ return state }, + configOtherMethodNames(state = {}, action): EdgePluginMap { + // Cached plugin method names; the live plugin list wins whenever + // the plugins are loaded, so this only fills gaps: + return action.type === 'ACCOUNT_CACHE_LOADED' + ? action.payload.configOtherMethodNames + : state + }, + customTokensDirtyIds( state = {}, action, diff --git a/src/core/account/plugin-api.ts b/src/core/account/plugin-api.ts index 2e23b85a5..d7c111f3b 100644 --- a/src/core/account/plugin-api.ts +++ b/src/core/account/plugin-api.ts @@ -38,13 +38,39 @@ export class CurrencyConfig this._accountId = accountId this._pluginId = pluginId + // One permanent object of delegating stubs, mirroring the wallet's + // otherMethods model. Names come from the live plugin (loaded + // before any account emits today) with the account cache as a + // fallback; a cached name the plugin turns out to lack rejects + // cleanly at call time: + const names: string[] = [] const { otherMethods } = ai.props.state.plugins.currency[pluginId] if (otherMethods != null) { - bridgifyObject(otherMethods) - this.otherMethods = otherMethods - } else { - this.otherMethods = {} + for (const name of Object.keys(otherMethods)) { + if (typeof (otherMethods as any)[name] === 'function') { + names.push(name) + } + } + } + const cachedNames = + ai.props.state.accounts[accountId].configOtherMethodNames[pluginId] ?? [] + for (const name of cachedNames) { + if (!names.includes(name)) names.push(name) + } + + const stubs: { [name: string]: (...args: any[]) => any } = {} + for (const name of names) { + stubs[name] = async (...args: any[]): Promise => { + const plugin = ai.props.state.plugins.currency[pluginId] + const method = plugin?.otherMethods?.[name] + if (typeof method !== 'function') { + throw new Error(`The currency plugin does not implement "${name}"`) + } + return method(...args) + } } + bridgifyObject(stubs) + this.otherMethods = stubs } get currencyInfo(): EdgeCurrencyInfo { diff --git a/src/core/actions.ts b/src/core/actions.ts index 534d7a7df..88d3d1a49 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -59,6 +59,7 @@ export type RootAction = type: 'ACCOUNT_CACHE_LOADED' payload: { accountId: string + configOtherMethodNames: EdgePluginMap customTokens: EdgePluginMap walletStates: EdgeWalletStates } From b6b4f8fee58e31cbd998f8420d8d61309d303b26 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 13:32:14 -0700 Subject: [PATCH 31/39] Add address and otherMethods cache tests Cover the phase-5 scope: a stable-flagged chain serves its cached addresses pre-engine (and getReceiveAddress derives from them), a rotating chain still waits for the engine, the engine's address answer reaches the cache file with balances stripped, a cached otherMethods name is callable before the engine exists, a stale cached name rejects cleanly when the loaded engine lacks the method, config-level names persist and delegate, and the cache-coverage classification gains a cache-assisted set for the conditional surfaces. The fake plugin gains an identity-stable currencyInfo patch hook and an omit-otherMethods switch to stage these states. --- CHANGELOG.md | 3 +- test/core/account/account-cache.test.ts | 20 +++ .../core/currency/wallet/wallet-cache.test.ts | 131 +++++++++++++++++- test/fake/fake-currency-plugin.ts | 35 ++++- 4 files changed, 177 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03c3b58bd..b24687c57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,9 @@ - added: Account-level `accountCache.json` with the wallet states and custom tokens the account boot needs. A warm login seeds Redux from it and emits the account right after the currency plugins load, before the account repo syncs, and every cached wallet then seeds from a single bulk dispatch. The deferred file loads overwrite the seeded state authoritatively, and user changes made during the window win over the values those loads read. First login (no cache) boots exactly as before. - added: Per-wallet `walletCache.json` with each wallet's name, fiat code, enabled token IDs, and last-known balances. Currency wallets now emit their API objects as soon as this cache loads, before their engines exist, so the GUI can render the wallet list immediately at login. Engine-backed methods wait for the engine internally and reject if it fails or the wallet is deleted. First login (no cache) behaves exactly as before. - added: Cached wallets' engine startup is staggered through a limited-concurrency queue (8 at a time) instead of all racing at login. Asking for a wallet via `waitForCurrencyWallet`, calling an engine- or storage-backed method, or un-pausing it moves it to the front of the queue. Wallets without a cache skip the queue, so first login is unaffected. +- added: An optional `EdgeCurrencyInfo.hasStableAddresses` hint for chains whose receive addresses never rotate. When set, a wallet serves its cached addresses (keyed per token) before the engine loads; rotating and unflagged chains keep the engine wait, exactly as before. - changed: `waitForCurrencyWallet` and `waitForAllWallets` now resolve when the wallet object exists, which can be before its engine loads. -- changed: `wallet.otherMethods` is `{}` until the wallet's engine loads, then switches to the engine's methods. +- changed: `wallet.otherMethods` exposes delegating stubs: a method whose name is in the wallet's cache can be called before the engine exists (the call waits for the engine internally), the object keeps its identity when the cache already names every engine method, and a cached name the engine no longer implements rejects cleanly at call time. Pre-engine with no cache it is `{}`, as before. - changed: `EdgeCurrencyWallet.balanceMap` keeps its object identity when an engine re-reports an unchanged balance. - changed: `getActivationAssets`, `activateWallet`, `getDisplayPrivateKey`, and `getDisplayPublicKey` wait for the wallet's engine instead of throwing when it has not loaded yet. - fixed: The account's custom-token file is never written before its first load, which could permanently delete a custom token another device had synced to the account. diff --git a/test/core/account/account-cache.test.ts b/test/core/account/account-cache.test.ts index 95227e040..59b4a54cc 100644 --- a/test/core/account/account-cache.test.ts +++ b/test/core/account/account-cache.test.ts @@ -434,6 +434,26 @@ describe('account cache', function () { await account2.logout() }) + it('caches config otherMethods names and delegates through stubs', async function () { + this.timeout(15000) + const { context } = await makeAccountCachedWorld() + + // The plugin's method names reached the account cache file: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await snooze(SAVE_WAIT_MS) + const text = await account.localDisklet.getText('accountCache.json') + expect(text.includes('fakePluginMethod')).equals(true) + + // The config surface is a delegating stub that reaches the live + // plugin method, even in the cache-seeded boot window: + const result = + await account.currencyConfig.fakecoin.otherMethods.fakePluginMethod( + 'config' + ) + expect(result).equals('fakePluginMethod called with: config') + await account.logout() + }) + it('rejects a corrupt account cache file and falls back cold', async function () { this.timeout(15000) const { context, walletIds } = await makeAccountCachedWorld() diff --git a/test/core/currency/wallet/wallet-cache.test.ts b/test/core/currency/wallet/wallet-cache.test.ts index 3e8ae9bd5..292d76ad2 100644 --- a/test/core/currency/wallet/wallet-cache.test.ts +++ b/test/core/currency/wallet/wallet-cache.test.ts @@ -63,11 +63,13 @@ async function makeCachedWorld(): Promise { describe('wallet cache', function () { beforeEach(function () { fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.omitEngineOtherMethods = undefined walletCacheSaverConfig.throttleMs = 50 }) afterEach(function () { fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.omitEngineOtherMethods = undefined walletCacheSaverConfig.throttleMs = 5000 }) @@ -362,15 +364,12 @@ describe('wallet cache', function () { 'broadcastTx', 'detectedTokenIds', 'dumpData', - 'getAddresses', 'getMaxSpendable', 'getNumTransactions', 'getPaymentProtocolInfo', - 'getReceiveAddress', 'getTransactions', 'lockReceiveAddress', 'makeSpend', - 'otherMethods', 'resyncBlockchain', 'saveReceiveAddress', 'saveTx', @@ -388,6 +387,11 @@ describe('wallet cache', function () { 'unactivatedTokenIds' ] + // Cache-assisted surfaces: engine-gated by default, but served + // from the cache pre-engine when it can answer (cached addresses; + // otherMethods stubs from cached names): + const cacheAssisted = ['getAddresses', 'getReceiveAddress', 'otherMethods'] + // Identity, storage-backed, config, and tools surfaces, which // never needed an engine in the first place: const engineFree = [ @@ -425,7 +429,12 @@ describe('wallet cache', function () { // Every property on the live wallet object must be classified. // A newly added EdgeCurrencyWallet property fails here until // someone decides whether the cache must seed it: - const classified = new Set([...cacheSeeded, ...engineGated, ...engineFree]) + const classified = new Set([ + ...cacheSeeded, + ...cacheAssisted, + ...engineGated, + ...engineFree + ]) const unclassified = Object.getOwnPropertyNames(wallet).filter( // The yaob bridge adds its own bookkeeping property: key => key !== '_yaob' && !classified.has(key) @@ -450,6 +459,120 @@ describe('wallet cache', function () { await account.logout() }) + it('serves cached addresses pre-engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache: query once with the engine running, + // then let the throttled saver persist the answer: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + const live = await wallet.getAddresses({ tokenId: null }) + expect(live[0].publicAddress).equals('fakesegwit') + await snooze(SAVE_WAIT_MS) + + // The engine's answer reached the cache file, balances stripped: + const text = await wallet.localDisklet.getText('walletCache.json') + expect(text.includes('fakeaddress')).equals(true) + expect(text.includes('nativeBalance')).equals(false) + await account.logout() + + // A warm login serves the cached addresses while the engine is + // still blocked, and getReceiveAddress derives from them: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + const cached = await wallet2.getAddresses({ tokenId: null }) + expect(cached.map(address => address.publicAddress)).deep.equals( + live.map(address => address.publicAddress) + ) + const receive = await wallet2.getReceiveAddress({ tokenId: null }) + expect(receive.publicAddress).equals('fakeaddress') + release() + await account2.logout() + }) + + it('keeps the engine gate when forceIndex is set', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // The plain query is served from the cache, but a caller naming a + // specific index wants a freshly derived address, which only the + // engine can produce, so that query still waits: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + const cached = await wallet2.getAddresses({ tokenId: null }) + expect(cached[0].publicAddress).equals('fakesegwit') + + let settled = false + const forcedPromise = wallet2 + .getAddresses({ tokenId: null, forceIndex: 0 }) + .then(addresses => { + settled = true + return addresses + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + release() + const forced = await forcedPromise + expect(forced[0].publicAddress).equals('fakesegwit') + await account2.logout() + }) + + it('calls a cached otherMethods name before the engine exists', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // The engine's method names are in the cache, so a warm login + // exposes a delegating stub pre-engine. Calling it pends on the + // engine and then forwards: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.otherMethods.testMethod).not.equals(undefined) + let settled = false + const callPromise = wallet.otherMethods + .testMethod('early') + .then((result: string) => { + settled = true + return result + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + release() + expect(await callPromise).equals('testMethod called with: early') + await account.logout() + }) + + it('rejects a stale cached method name the engine lacks', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // The next session's engines are built WITHOUT otherMethods, so + // the cached `testMethod` name is stale. The stub still exists, + // and rejects cleanly once the engine loads without the method: + fakePluginTestConfig.omitEngineOtherMethods = true + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.otherMethods.testMethod).not.equals(undefined) + await expectRejection( + wallet.otherMethods.testMethod('stale'), + 'Error: The wallet engine does not implement "testMethod"' + ) + await account.logout() + }) + it('otherMethods is {} pre-engine and carries engine methods post-engine', async function () { this.timeout(15000) const { context, walletId } = await makeCachedWorld() diff --git a/test/fake/fake-currency-plugin.ts b/test/fake/fake-currency-plugin.ts index 048da2c6c..b716363b6 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -48,6 +48,12 @@ export interface FakePluginTestConfig { */ engineGate?: Promise + /** + * If set, engines are created WITHOUT their `otherMethods`, so + * tests can prove that a stale cached method name rejects cleanly. + */ + omitEngineOtherMethods?: boolean + /** * If set, `checkPublicKey` will wait for this promise to resolve. * The wallet pixie validates its cached public key between the @@ -67,6 +73,7 @@ export interface FakePluginTestConfig { export const fakePluginTestConfig: FakePluginTestConfig = { builtinTokensGate: undefined, engineGate: undefined, + omitEngineOtherMethods: undefined, publicKeyCheckGate: undefined, onEngineCreate: undefined } @@ -153,12 +160,17 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { private allTokens: EdgeTokenMap = fakeTokens private readonly currencyInfo: EdgeCurrencyInfo - // Exercises the wallet's pre-engine `otherMethods` guarantee in tests: - readonly otherMethods = { - async testMethod(arg: string): Promise { - return `testMethod called with: ${arg}` - } - } + // Exercises the wallet's pre-engine `otherMethods` guarantee in tests. + // `omitEngineOtherMethods` simulates an engine that dropped a method + // some cache still names: + readonly otherMethods = + fakePluginTestConfig.omitEngineOtherMethods === true + ? undefined + : { + async testMethod(arg: string): Promise { + return `testMethod called with: ${arg}` + } + } constructor( walletInfo: EdgeWalletInfo, @@ -469,7 +481,16 @@ export function makeFakeCurrencyPlugin( const currencyInfo: EdgeCurrencyInfo = { ...fakeCurrencyInfo, ...overrides } return { - currencyInfo, + get currencyInfo(): EdgeCurrencyInfo { + return currencyInfo + }, + + // Exercises the config-level otherMethods name cache in tests: + otherMethods: { + async fakePluginMethod(arg: string): Promise { + return `fakePluginMethod called with: ${arg}` + } + }, async getBuiltinTokens(): Promise { if (fakePluginTestConfig.builtinTokensGate != null) { From 56d4c69b1002a0e39943825c2b1b382bc29f552f Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 14:19:12 -0700 Subject: [PATCH 32/39] Key the address cache by tokenId and rebuild stubs on growth Review findings on the new caches. The address cache was one flat array per wallet, so a token query overwrote the parent chain's answer and a warm boot could serve the wrong asset's address; it is now keyed by tokenId end to end (Redux, file schema, the pre-engine serve), with an identity guard so a repeated identical answer causes no phantom update. The otherMethods object cannot gain properties after it first crosses the yaob bridge (update() only re-serializes properties that existed then, verified empirically), so instead of mutating one permanent object, the getter rebuilds it as a new bridgified object whenever a name first appears; identity still holds for the common warm case where the cache already names everything. Stubs also resolve against the live engine on every call, so an engine rebuilt by a resync never leaves a stale capture, and calls go through the source object to preserve the plugin's this binding. --- src/core/actions.ts | 3 +- .../currency/wallet/currency-wallet-api.ts | 72 ++++++++++--------- .../wallet/currency-wallet-cleaners.ts | 13 ++-- .../currency/wallet/currency-wallet-pixie.ts | 7 +- .../wallet/currency-wallet-reducer.ts | 24 +++++-- src/core/currency/wallet/wallet-cache-file.ts | 2 +- .../core/currency/wallet/wallet-cache.test.ts | 44 ++++++++++++ 7 files changed, 114 insertions(+), 51 deletions(-) diff --git a/src/core/actions.ts b/src/core/actions.ts index 88d3d1a49..3af12e566 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -301,6 +301,7 @@ export type RootAction = type: 'CURRENCY_WALLET_ADDRESSES_CHANGED' payload: { addresses: EdgeAddress[] + tokenId: EdgeTokenId walletId: string } } @@ -319,7 +320,7 @@ export type RootAction = // seeding Redux before the engine exists. type: 'CURRENCY_WALLET_CACHE_LOADED' payload: { - addresses: EdgeAddress[] + addresses: { [tokenIdKey: string]: EdgeAddress[] } balanceMap: EdgeBalanceMap enabledTokenIds: string[] fiatCurrencyCode: string diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index aa3592e1d..d76f32233 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -1,7 +1,7 @@ import { abs, div, lt, mul } from 'biggystring' import { Disklet } from 'disklet' import { base64 } from 'rfc4648' -import { bridgifyObject, emit, onMethod, update, watchMethod } from 'yaob' +import { bridgifyObject, emit, onMethod, watchMethod } from 'yaob' import { InternalWalletMethods, @@ -186,6 +186,7 @@ export function makeCurrencyWalletApi( addressType: address.addressType, publicAddress: address.publicAddress })), + tokenId: opts.tokenId, walletId } }) @@ -193,36 +194,39 @@ export function makeCurrencyWalletApi( const fakeCallbacks = makeCurrencyWalletCallbacks(input) - // The wallet exposes ONE permanent `otherMethods` object for its - // whole life; it is never swapped when the engine lands. Each - // property is a delegating stub that waits for the engine and then - // forwards, so a method can be called the moment its NAME is known: - // from the cache on a warm login (before the engine exists), or - // from the live engine otherwise. Stubs are added when a name first - // appears and never removed; a name the loaded engine turns out to - // lack rejects cleanly at call time. Pre-engine with no cache this - // is `{}`, exactly the old guarantee, so property probes stay safe. - const otherMethodStubs: { [name: string]: (...args: any[]) => any } = {} + // The wallet's `otherMethods` is an object of delegating stubs: + // each waits for the engine and then forwards, so a method can be + // called the moment its NAME is known - from the cache on a warm + // login (before the engine exists), or from the live engine + // otherwise. The object keeps its identity as long as the known + // name set is unchanged (the common warm-boot case, where the + // cache already names every engine method); when a name first + // appears it is REBUILT as a new bridgified object, because yaob + // only serializes the properties an object had when it first + // crossed the bridge - the pixie watcher's update() then delivers + // the new object, exactly how the old engine-swap propagated. + // Existing stubs carry over a rebuild, a name the loaded engine + // turns out to lack rejects cleanly at call time, and pre-engine + // with no cache this is `{}`, exactly the old guarantee, so + // property probes stay safe. + let otherMethodStubs: { [name: string]: (...args: any[]) => any } = {} bridgifyObject(otherMethodStubs) function makeOtherMethodStub(name: string): (...args: any[]) => any { - // Resolve the engine's method once and remember it: - let resolved: ((...args: any[]) => any) | undefined return async (...args: any[]): Promise => { + // Resolve against the live engine on every call, so an engine + // rebuilt by a resync never leaves a stale capture behind. + // Calls go through the source object, preserving `this`: const engine = await getEngine() - if (resolved == null) { - const withKeys = engine.otherMethodsWithKeys?.[name] - const method = engine.otherMethods?.[name] - if (typeof withKeys === 'function') { - resolved = (...inner: any[]) => withKeys(walletInfo.keys, ...inner) - } else if (typeof method === 'function') { - resolved = method - } + const withKeys = engine.otherMethodsWithKeys + if (withKeys != null && typeof withKeys[name] === 'function') { + return withKeys[name](walletInfo.keys, ...args) } - if (resolved == null) { - throw new Error(`The wallet engine does not implement "${name}"`) + const methods = engine.otherMethods + if (methods != null && typeof methods[name] === 'function') { + return methods[name](...args) } - return resolved(...args) + throw new Error(`The wallet engine does not implement "${name}"`) } } @@ -237,14 +241,17 @@ export function makeCurrencyWalletApi( } } } - let added = false - for (const name of names) { - if (otherMethodStubs[name] != null) continue - otherMethodStubs[name] = makeOtherMethodStub(name) - added = true + const missing = names.filter(name => otherMethodStubs[name] == null) + if (missing.length > 0) { + const next: { [name: string]: (...args: any[]) => any } = { + ...otherMethodStubs + } + for (const name of missing) { + if (next[name] == null) next[name] = makeOtherMethodStub(name) + } + bridgifyObject(next) + otherMethodStubs = next } - // New names must reach the far side of the bridge too: - if (added) update(otherMethodStubs) return otherMethodStubs } @@ -593,7 +600,8 @@ export function makeCurrencyWalletApi( // the receive scene works right away on a warm login. A caller // naming a specific index wants a freshly derived address, which // only the engine can produce, so that query still waits: - const cachedAddresses = input.props.walletState.addresses + const cachedAddresses = + input.props.walletState.addresses[opts.tokenId ?? ''] ?? [] if ( opts.forceIndex == null && cachedAddresses.length > 0 && diff --git a/src/core/currency/wallet/currency-wallet-cleaners.ts b/src/core/currency/wallet/currency-wallet-cleaners.ts index 4ccd758b9..c1c1b0db9 100644 --- a/src/core/currency/wallet/currency-wallet-cleaners.ts +++ b/src/core/currency/wallet/currency-wallet-cleaners.ts @@ -418,10 +418,13 @@ export interface WalletCacheFile { balances: { [tokenId: string]: string } /** - * The engine's last answer to the default address query, without - * balances. Served pre-engine, before the engine confirms them. + * The engine's last answer per tokenId to the default address + * query, without balances (the `null` tokenId is spelled '' here). + * Served pre-engine, before the engine confirms it. */ - addresses: Array<{ addressType: string; publicAddress: string }> + addresses: { + [tokenIdKey: string]: Array<{ addressType: string; publicAddress: string }> + } /** * The names of the engine's `otherMethods`, so the next login can @@ -441,7 +444,7 @@ export const asWalletCacheFile: Cleaner = asObject({ fiatCurrencyCode: asString, enabledTokenIds: asArray(asString), balances: asObject(asIntegerString), - addresses: asArray(asCachedAddress), + addresses: asObject(asArray(asCachedAddress)), otherMethodNames: asOptional(asArray(asString), () => []) }) @@ -463,7 +466,7 @@ export const asStoredWalletCacheFile: Cleaner = raw => { return asWalletCacheFile(raw) } catch (error: unknown) { const clean = asWalletCacheFileV1(raw) - return { ...clean, version: 2, addresses: [], otherMethodNames: [] } + return { ...clean, version: 2, addresses: {}, otherMethodNames: [] } } } diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index bf948d39f..fae9d570e 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -576,7 +576,7 @@ export const walletPixie: TamePixie = combinePixies({ */ cacheSaver(input: CurrencyWalletInput) { interface CacheSnapshot { - addresses: EdgeAddress[] + addresses: { [tokenIdKey: string]: EdgeAddress[] } balanceMap: EdgeBalanceMap enabledTokenIds: string[] fiat: string @@ -618,10 +618,7 @@ export const walletPixie: TamePixie = combinePixies({ fiatCurrencyCode: snapshot.fiat, enabledTokenIds: snapshot.enabledTokenIds, balances, - addresses: snapshot.addresses.map(address => ({ - addressType: address.addressType, - publicAddress: address.publicAddress - })), + addresses: snapshot.addresses, otherMethodNames: snapshot.otherMethodNames } ) diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index 96d268c23..c1ee0f5c0 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -68,7 +68,7 @@ export interface CurrencyWalletState { readonly paused: boolean - readonly addresses: EdgeAddress[] + readonly addresses: { [tokenIdKey: string]: EdgeAddress[] } readonly allEnabledTokenIds: string[] readonly balanceMap: EdgeBalanceMap readonly balances: EdgeBalances @@ -129,7 +129,7 @@ export interface CurrencyWalletNext { export const initialWalletSettings: JsonObject = {} -export const initialAddresses: EdgeAddress[] = [] +export const initialAddresses: { [tokenIdKey: string]: EdgeAddress[] } = {} // Used for detectedTokenIds & enabledTokenIds: export const initialTokenIds: string[] = [] @@ -451,16 +451,26 @@ const currencyWalletInner = buildReducer< } ), - addresses(state = initialAddresses, action): EdgeAddress[] { + addresses( + state = initialAddresses, + action + ): { [tokenIdKey: string]: EdgeAddress[] } { switch (action.type) { - case 'CURRENCY_WALLET_ADDRESSES_CHANGED': - // The engine's answer is authoritative: - return action.payload.addresses + case 'CURRENCY_WALLET_ADDRESSES_CHANGED': { + // The engine's answer is authoritative for its own tokenId. + // Keep the existing state when nothing changed, so downstream + // reference checks (the cache saver, yaob diffing) see no + // phantom update: + const { addresses, tokenId } = action.payload + const key = tokenId ?? '' + if (compare(state[key], addresses)) return state + return { ...state, [key]: addresses } + } case 'CURRENCY_WALLET_CACHE_LOADED': // Seed cached addresses, but never overwrite an engine answer // (the seed only ever fires before the engine exists): - if (state.length > 0) return state + if (Object.keys(state).length > 0) return state return action.payload.addresses } return state diff --git a/src/core/currency/wallet/wallet-cache-file.ts b/src/core/currency/wallet/wallet-cache-file.ts index 0684e219d..d8d998c49 100644 --- a/src/core/currency/wallet/wallet-cache-file.ts +++ b/src/core/currency/wallet/wallet-cache-file.ts @@ -27,7 +27,7 @@ export const walletCacheFile = { * `walletCache.json`, with balances upgraded to an `EdgeBalanceMap`. */ export interface WalletCacheSeed { - addresses: EdgeAddress[] + addresses: { [tokenIdKey: string]: EdgeAddress[] } balanceMap: EdgeBalanceMap enabledTokenIds: string[] fiatCurrencyCode: string diff --git a/test/core/currency/wallet/wallet-cache.test.ts b/test/core/currency/wallet/wallet-cache.test.ts index 292d76ad2..bd2efe025 100644 --- a/test/core/currency/wallet/wallet-cache.test.ts +++ b/test/core/currency/wallet/wallet-cache.test.ts @@ -573,6 +573,50 @@ describe('wallet cache', function () { await account.logout() }) + it('upgrades a version-1 cache file and grows stubs post-engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Rewrite the cache as a version-1 file (no addresses, no method + // names), the state every real device is in when this ships: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + const file = JSON.parse( + await wallet.localDisklet.getText('walletCache.json') + ) + walletCacheSaverConfig.throttleMs = 5000 + await wallet.localDisklet.setText( + 'walletCache.json', + JSON.stringify({ + version: 1, + name: file.name, + fiatCurrencyCode: file.fiatCurrencyCode, + enabledTokenIds: file.enabledTokenIds, + balances: file.balances + }) + ) + await account.logout() + walletCacheSaverConfig.throttleMs = 50 + + // The old file still warm-boots (upgraded on read, not cold), + // with no method names yet: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.name).equals('Cached Name') + expect(wallet2.otherMethods.testMethod).equals(undefined) + + // Once the engine lands, its methods appear and work, even + // through a bridge that saw the empty object first: + release() + await waitForOtherMethods(wallet2) + expect(await wallet2.otherMethods.testMethod('grown')).equals( + 'testMethod called with: grown' + ) + await account2.logout() + }) + it('otherMethods is {} pre-engine and carries engine methods post-engine', async function () { this.timeout(15000) const { context, walletId } = await makeCachedWorld() From 4bf45cac207bd92ccf79ed10b4f9cbaa307f43d7 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 14:19:26 -0700 Subject: [PATCH 33/39] Keep the live plugin surface for config otherMethods Review findings: routing every config otherMethods call through a stub dropped the plugin's this binding and any non-function property, on every login. When the plugin is loaded (always the case today) the config exposes the plugin's own otherMethods object verbatim, exactly as before; the cached names only build delegating stubs in the so-far-unreachable case where plugin loading defers past the account emit, and those stubs call through the object to preserve this. --- src/core/account/plugin-api.ts | 53 ++++++++++++------------- test/core/account/account-cache.test.ts | 10 +++-- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/core/account/plugin-api.ts b/src/core/account/plugin-api.ts index d7c111f3b..07845d345 100644 --- a/src/core/account/plugin-api.ts +++ b/src/core/account/plugin-api.ts @@ -38,39 +38,36 @@ export class CurrencyConfig this._accountId = accountId this._pluginId = pluginId - // One permanent object of delegating stubs, mirroring the wallet's - // otherMethods model. Names come from the live plugin (loaded - // before any account emits today) with the account cache as a - // fallback; a cached name the plugin turns out to lack rejects - // cleanly at call time: - const names: string[] = [] + // When the plugin is loaded (always the case today: plugins load + // before any account emits), expose its otherMethods verbatim, + // exactly as before. The cached names below only matter if plugin + // loading ever defers past the account emit: then each name gets + // a delegating stub that reaches the plugin's method at call time + // (through the object, preserving `this`), rejecting cleanly if + // the loaded plugin turns out to lack it: const { otherMethods } = ai.props.state.plugins.currency[pluginId] if (otherMethods != null) { - for (const name of Object.keys(otherMethods)) { - if (typeof (otherMethods as any)[name] === 'function') { - names.push(name) - } - } - } - const cachedNames = - ai.props.state.accounts[accountId].configOtherMethodNames[pluginId] ?? [] - for (const name of cachedNames) { - if (!names.includes(name)) names.push(name) - } - - const stubs: { [name: string]: (...args: any[]) => any } = {} - for (const name of names) { - stubs[name] = async (...args: any[]): Promise => { - const plugin = ai.props.state.plugins.currency[pluginId] - const method = plugin?.otherMethods?.[name] - if (typeof method !== 'function') { - throw new Error(`The currency plugin does not implement "${name}"`) + bridgifyObject(otherMethods) + this.otherMethods = otherMethods + } else { + const cachedNames = + ai.props.state.accounts[accountId].configOtherMethodNames[pluginId] ?? + [] + const stubs: { [name: string]: (...args: any[]) => any } = {} + for (const name of cachedNames) { + stubs[name] = async (...args: any[]): Promise => { + const methods: any = + ai.props.state.plugins.currency[pluginId]?.otherMethods + if (methods == null || typeof methods[name] !== 'function') { + throw new Error(`The currency plugin does not implement "${name}"`) + } + // A member call, so the plugin method keeps its `this`: + return methods[name](...args) } - return method(...args) } + bridgifyObject(stubs) + this.otherMethods = stubs } - bridgifyObject(stubs) - this.otherMethods = stubs } get currencyInfo(): EdgeCurrencyInfo { diff --git a/test/core/account/account-cache.test.ts b/test/core/account/account-cache.test.ts index 59b4a54cc..07a3e2501 100644 --- a/test/core/account/account-cache.test.ts +++ b/test/core/account/account-cache.test.ts @@ -434,18 +434,20 @@ describe('account cache', function () { await account2.logout() }) - it('caches config otherMethods names and delegates through stubs', async function () { + it('caches config otherMethods names and keeps the live surface', async function () { this.timeout(15000) const { context } = await makeAccountCachedWorld() - // The plugin's method names reached the account cache file: + // The plugin's method names reached the account cache file (the + // stub fallback consumes them if plugin loading ever defers past + // the account emit; today the live plugin always wins): const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) await snooze(SAVE_WAIT_MS) const text = await account.localDisklet.getText('accountCache.json') expect(text.includes('fakePluginMethod')).equals(true) - // The config surface is a delegating stub that reaches the live - // plugin method, even in the cache-seeded boot window: + // The live surface stays verbatim, including in the cache-seeded + // boot window: const result = await account.currencyConfig.fakecoin.otherMethods.fakePluginMethod( 'config' From 8abf6912db6ce0e92f236b998d9de2a5e3a75aba Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 15:04:59 -0700 Subject: [PATCH 34/39] Harden the boot-retry and scheduler-timeout edge paths Bugbot findings. A warm-boot retry re-ran addStorageWallet for repos a prior attempt had already attached; STORAGE_WALLET_ADDED replaces the whole entry (wiping lastChanges) and starts a second sync that can race the one still in flight, so retries now attach only the repos that are still missing. The engine scheduler's watchdog called its logging callback before freeing the slot, so a throw from stale props after a pixie destroy could permanently shrink the pool; the slot is now released first and the callback is contained. --- src/core/account/account-pixie.ts | 18 +++++++++++++----- src/core/currency/wallet/engine-scheduler.ts | 7 ++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/core/account/account-pixie.ts b/src/core/account/account-pixie.ts index b179d0384..c8bff28ca 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -100,13 +100,21 @@ const accountPixie: TamePixie = combinePixies({ ]) } - async function loadEverything(): Promise { + async function loadEverything(isRetry: boolean): Promise { await loadBuiltinTokens(ai, accountId) log.warn('Login: currency plugins exist') - // Start the repo: + // Start the repo. A retry must not re-attach repos a prior + // attempt already attached: STORAGE_WALLET_ADDED replaces + // the whole entry (wiping lastChanges) and starts another + // sync that can race the one still in flight: + const missingInfos = isRetry + ? accountWalletInfos.filter( + info => ai.props.state.storageWallets[info.id] == null + ) + : accountWalletInfos await Promise.all( - accountWalletInfos.map(info => addStorageWallet(ai, info)) + missingInfos.map(info => addStorageWallet(ai, info)) ) log.warn('Login: synced account repos') @@ -160,7 +168,7 @@ const accountPixie: TamePixie = combinePixies({ return await stopUpdates } try { - await loadEverything() + await loadEverything(attempt > 1) break } catch (error: unknown) { if (ai.props.state.accounts[accountId] == null) { @@ -189,7 +197,7 @@ const accountPixie: TamePixie = combinePixies({ return await stopUpdates } - await loadEverything() + await loadEverything(false) // Create the API object: input.onOutput(makeAccountApi(ai, accountId)) diff --git a/src/core/currency/wallet/engine-scheduler.ts b/src/core/currency/wallet/engine-scheduler.ts index 2aa95d259..a9347bb55 100644 --- a/src/core/currency/wallet/engine-scheduler.ts +++ b/src/core/currency/wallet/engine-scheduler.ts @@ -94,8 +94,13 @@ function makeEngineScheduler(): EngineScheduler { const takeSlot = (): (() => void) => { stickyBumps.delete(walletId) watchdog = setTimeout(() => { - if (onTimeout != null) onTimeout() + // Free the slot first: the callback is for logging only, + // and a throw from it (stale props after a pixie destroy) + // must never shrink the pool: release() + try { + if (onTimeout != null) onTimeout() + } catch (error: unknown) {} }, engineSchedulerConfig.slotTimeoutMs) // Do not hold the process open just for the watchdog (the // unref method exists on Node timers, not React Native's): From 4d5474d256a1397f8e5073c91ab9b9cc3a77149a Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 15:13:23 -0700 Subject: [PATCH 35/39] Serialize repo syncs per storage wallet Bugbot finding: storageWallets entries survive logout, so on a same-context re-login the repo waiters resolve against the prior session's entry and a user-facing sync can run concurrently with the boot's own addStorageWallet sync. Concurrent syncRepo calls on one repo are unsafe (the changes-folder snapshot is deleted after the round trip, so a racing write can be dropped or double-uploaded), and the same overlap already existed between the periodic timer and a user sync. All callers funnel through syncStorageWallet, which now queues per repo, and a sync that dequeues after deletion or logout rejects cleanly. --- src/core/storage/storage-actions.ts | 43 +++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/core/storage/storage-actions.ts b/src/core/storage/storage-actions.ts index f63266990..dc9d88fb6 100644 --- a/src/core/storage/storage-actions.ts +++ b/src/core/storage/storage-actions.ts @@ -54,14 +54,53 @@ export async function addStorageWallet( } else await syncPromise } +/** + * Syncs are serialized per repo: `syncRepo` snapshots the changes + * folder before its network round trip and deletes those paths after + * it, so two in-flight syncs on one repo could double-upload or drop + * a write that landed between them. Every caller funnels through + * here (the boot's `addStorageWallet`, the periodic timers, and the + * user-facing `sync()` methods), so overlapping requests simply run + * one after the other. + */ +const storageSyncQueues = new Map>() + export function syncStorageWallet( ai: ApiInput, walletId: string +): Promise { + const prev = storageSyncQueues.get(walletId) ?? Promise.resolve() + const out = prev.then(async () => await doSyncStorageWallet(ai, walletId)) + const tail = out.then( + () => undefined, + () => undefined + ) + storageSyncQueues.set(walletId, tail) + tail + .then(() => { + if (storageSyncQueues.get(walletId) === tail) { + storageSyncQueues.delete(walletId) + } + }) + .catch(() => undefined) + return out +} + +async function doSyncStorageWallet( + ai: ApiInput, + walletId: string ): Promise { const { dispatch, syncClient, state } = ai.props - const { paths, status } = state.storageWallets[walletId] - return syncRepo(syncClient, paths, { ...status }).then( + // The wallet may have been deleted (or the user logged out) + // while this sync waited in line: + const storageWallet = state.storageWallets[walletId] + if (storageWallet == null) { + throw new Error('This storage wallet is no longer attached') + } + const { paths, status } = storageWallet + + return await syncRepo(syncClient, paths, { ...status }).then( ({ changes, status }) => { dispatch({ type: 'STORAGE_WALLET_SYNCED', From 389cf3c8f8eae22206627defb4d9b5763e1f8cc4 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 23 Jul 2026 04:11:17 -0700 Subject: [PATCH 36/39] Unwedge wallet pixies when the deferred account load fails ACCOUNT_CACHE_LOADED sets bulkWalletSeedPending so wallet pixies hold their own cache reads until the bulk seed dispatches. It cleared only on CURRENCY_WALLETS_CACHE_LOADED or ACCOUNT_KEYS_LOADED, so a terminal deferred-load failure (ACCOUNT_LOAD_FAILED after the account already emitted) left the flag stuck true and every wallet pixie returning early forever, never starting an engine or emitting an API object. Clear the flag on ACCOUNT_LOAD_FAILED too, matching the existing ACCOUNT_KEYS_LOADED backstop, so the wallets fall back to their own reads. --- src/core/account/account-reducer.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/account/account-reducer.ts b/src/core/account/account-reducer.ts index bc394ea43..6a38c6c5a 100644 --- a/src/core/account/account-reducer.ts +++ b/src/core/account/account-reducer.ts @@ -569,6 +569,13 @@ const accountInner = buildReducer({ // load unwedges the wallet pixies (they fall back to their own // reads): return false + + case 'ACCOUNT_LOAD_FAILED': + // The deferred load gave up, so the bulk seed is never coming. + // Clear the hold so the wallet pixies fall back to their own + // cache reads instead of wedging (never starting an engine or + // emitting an API object) for the life of the session: + return false } return state } From cae1f073508900376782adb2c64d1d81f0038d1a Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 31 Jul 2026 18:01:35 -0700 Subject: [PATCH 37/39] Reconcile a cached address once the engine loads A cached address is served before the engine exists, so the engine can derive a different one, which a rotating chain does as soon as the cached address is used. Every query answered from the cache now asks the engine in the background, and the wallet emits addressChanged when the two disagree, so the receive scene and every other consumer re-query and pick up the new address. An engine that confirms the cached answer stays silent. --- .../currency/wallet/currency-wallet-api.ts | 67 +++++++- .../core/currency/wallet/wallet-cache.test.ts | 144 ++++++++++++++++++ test/fake/fake-currency-plugin.ts | 11 +- 3 files changed, 214 insertions(+), 8 deletions(-) diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index d76f32233..a11c064e9 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -44,6 +44,7 @@ import { EdgeWalletInfo, JsonObject } from '../../../types/types' +import { compare } from '../../../util/compare' import { makeMetaTokens } from '../../account/custom-tokens' import { splitWalletInfo } from '../../login/splitting' import { asEdgeStorageKeys } from '../../login/storage-keys' @@ -168,6 +169,11 @@ export function makeCurrencyWalletApi( return fallbackDisklets } + // Address queries that were answered from the cache before the + // engine existed. Each one owes the caller a correction if the + // engine turns out to disagree: + const addressesServedFromCache = new Set() + /** * Remembers the engine's answer to the default address query, so * the cache saver persists it and the next warm login can serve it @@ -179,17 +185,54 @@ export function makeCurrencyWalletApi( addresses: EdgeAddress[] ): void { if (opts.forceIndex != null) return + const tokenIdKey = opts.tokenId ?? '' + const stripped = addresses.map(address => ({ + addressType: address.addressType, + publicAddress: address.publicAddress + })) + + // If this query was served from the cache while the engine loaded, + // its callers hold an address the engine may have replaced. Only + // the first engine answer can settle that; later changes are + // rotations, which the engine reports through its own callback: + const owesCorrection = addressesServedFromCache.delete(tokenIdKey) + const isStale = + owesCorrection && + !compare(input.props.walletState.addresses[tokenIdKey] ?? [], stripped) + input.props.dispatch({ type: 'CURRENCY_WALLET_ADDRESSES_CHANGED', payload: { - addresses: addresses.map(address => ({ - addressType: address.addressType, - publicAddress: address.publicAddress - })), + addresses: stripped, tokenId: opts.tokenId, walletId } }) + + if (isStale) fakeCallbacks.onAddressChanged() + } + + // Token queries with a reconcile already in flight. Several callers + // can be served the same cached answer before the engine lands, and + // one engine query settles the correction for all of them: + const reconcilingAddresses = new Set() + + /** + * Asks the engine for an address query that was already answered + * from the cache, so `rememberAddresses` can emit `addressChanged` + * if the two disagree. At most one runs per token query: a second + * would find the correction already settled and would silently + * overwrite the stored addresses without telling anyone. + */ + function reconcileAddresses(opts: EdgeGetReceiveAddressOptions): void { + const tokenIdKey = opts.tokenId ?? '' + if (reconcilingAddresses.has(tokenIdKey)) return + reconcilingAddresses.add(tokenIdKey) + + getEngine() + .then(async () => await out.getAddresses({ tokenId: opts.tokenId })) + .catch(error => input.props.onError(error)) + .finally(() => reconcilingAddresses.delete(tokenIdKey)) } const fakeCallbacks = makeCurrencyWalletCallbacks(input) @@ -597,19 +640,29 @@ export function makeCurrencyWalletApi( opts: EdgeGetReceiveAddressOptions ): Promise { // Serve the cached answer while the engine is still loading, so - // the receive scene works right away on a warm login. A caller - // naming a specific index wants a freshly derived address, which - // only the engine can produce, so that query still waits: + // the receive scene works right away on a warm login. That + // answer is whatever the engine last derived, which on a + // rotating chain it may since have replaced, so the engine is + // asked in the background and `addressChanged` fires if the two + // disagree. A caller naming a specific index wants a freshly + // derived address, which only the engine can produce, so that + // query still waits. A wallet whose engine has FAILED also waits, + // and therefore rejects: its engine is never coming, so serving + // the cache forever would make a broken wallet indistinguishable + // from a healthy one on this method alone: const cachedAddresses = input.props.walletState.addresses[opts.tokenId ?? ''] ?? [] if ( opts.forceIndex == null && cachedAddresses.length > 0 && + input.props.walletState.engineFailure == null && input.props.walletOutput?.engine == null ) { // The user is on an address screen, so they want this // wallet's engine sooner rather than later: bumpEngineQueue(ai, walletId) + addressesServedFromCache.add(opts.tokenId ?? '') + reconcileAddresses(opts) return cachedAddresses.map(address => ({ ...address })) } diff --git a/test/core/currency/wallet/wallet-cache.test.ts b/test/core/currency/wallet/wallet-cache.test.ts index bd2efe025..64928411f 100644 --- a/test/core/currency/wallet/wallet-cache.test.ts +++ b/test/core/currency/wallet/wallet-cache.test.ts @@ -63,12 +63,14 @@ async function makeCachedWorld(): Promise { describe('wallet cache', function () { beforeEach(function () { fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.freshAddressPatch = undefined fakePluginTestConfig.omitEngineOtherMethods = undefined walletCacheSaverConfig.throttleMs = 50 }) afterEach(function () { fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.freshAddressPatch = undefined fakePluginTestConfig.omitEngineOtherMethods = undefined walletCacheSaverConfig.throttleMs = 5000 }) @@ -493,6 +495,148 @@ describe('wallet cache', function () { await account2.logout() }) + it('emits addressChanged when the engine disagrees with the cache', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache with this engine's answer: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + const primed = await wallet.getAddresses({ tokenId: null }) + expect(primed[0].publicAddress).equals('fakesegwit') + await snooze(SAVE_WAIT_MS) + await account.logout() + + // The next session's engine derives a different address, which is + // what a rotating chain does once the cached one has been used: + fakePluginTestConfig.freshAddressPatch = { segwitAddress: 'rotatedsegwit' } + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + + let changed = 0 + wallet2.on('addressChanged', () => { + ++changed + }) + + // The pre-engine query is served from the cache, silently: + const cached = await wallet2.getAddresses({ tokenId: null }) + expect(cached[0].publicAddress).equals('fakesegwit') + expect(changed).equals(0) + + // Once the engine loads it disagrees, so the wallet tells its + // consumers to re-query, and the re-query returns the new address: + release() + await snooze(SAVE_WAIT_MS) + expect(changed).equals(1) + const confirmed = await wallet2.getAddresses({ tokenId: null }) + expect(confirmed[0].publicAddress).equals('rotatedsegwit') + await account2.logout() + }) + + it('emits addressChanged once when several callers share the cache', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + fakePluginTestConfig.freshAddressPatch = { segwitAddress: 'rotatedsegwit' } + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + + let changed = 0 + wallet2.on('addressChanged', () => { + ++changed + }) + + // Several consumers can be served the same cached answer before + // the engine lands (the receive scene alone asks twice, since + // getReceiveAddress goes through getAddresses). One engine query + // settles the correction for all of them: + const served = await Promise.all([ + wallet2.getAddresses({ tokenId: null }), + wallet2.getAddresses({ tokenId: null }), + wallet2.getReceiveAddress({ tokenId: null }) + ]) + expect(served[0][0].publicAddress).equals('fakesegwit') + expect(changed).equals(0) + + release() + await snooze(SAVE_WAIT_MS) + expect(changed).equals(1) + const confirmed = await wallet2.getAddresses({ tokenId: null }) + expect(confirmed[0].publicAddress).equals('rotatedsegwit') + await account2.logout() + }) + + it('stops serving the cached address once the engine fails', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // A warm cache must not make a wallet whose engine died look + // healthy: the engine is never coming, so the query has to reject + // rather than serve the cache forever: + const { gate, fail } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect( + (await wallet2.getAddresses({ tokenId: null }))[0].publicAddress + ).equals('fakesegwit') + + fail(new Error('Engine exploded')) + await expectRejection( + wallet2.getAddresses({ tokenId: null }), + 'Error: Engine exploded' + ) + await account2.logout() + }) + + it('stays quiet when the engine confirms the cached address', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // This engine derives exactly what the cache holds, the common + // case, so the reconcile must not wake every consumer up: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + + let changed = 0 + wallet2.on('addressChanged', () => { + ++changed + }) + + expect( + (await wallet2.getAddresses({ tokenId: null }))[0].publicAddress + ).equals('fakesegwit') + release() + await snooze(SAVE_WAIT_MS) + expect(changed).equals(0) + await account2.logout() + }) + it('keeps the engine gate when forceIndex is set', async function () { this.timeout(15000) const { context, walletId } = await makeCachedWorld() diff --git a/test/fake/fake-currency-plugin.ts b/test/fake/fake-currency-plugin.ts index b716363b6..d399e8881 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -48,6 +48,13 @@ export interface FakePluginTestConfig { */ engineGate?: Promise + /** + * If set, spread over the engine's `getFreshAddress` answer, so a + * test can make a freshly loaded engine disagree with the address + * its predecessor cached. + */ + freshAddressPatch?: Partial + /** * If set, engines are created WITHOUT their `otherMethods`, so * tests can prove that a stale cached method name rejects cleanly. @@ -73,6 +80,7 @@ export interface FakePluginTestConfig { export const fakePluginTestConfig: FakePluginTestConfig = { builtinTokensGate: undefined, engineGate: undefined, + freshAddressPatch: undefined, omitEngineOtherMethods: undefined, publicKeyCheckGate: undefined, onEngineCreate: undefined @@ -347,7 +355,8 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { publicAddress: 'fakeaddress', nativeBalance: this.state.balance.toString(), segwitAddress: 'fakesegwit', - legacyAddress: 'fakelegacy' + legacyAddress: 'fakelegacy', + ...fakePluginTestConfig.freshAddressPatch } } From ec0f67c057a769507b7c3dfb2f03838f261a1583 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 31 Jul 2026 18:31:22 -0700 Subject: [PATCH 38/39] Consolidate every wallet's cache into one account file A warm boot read two files per wallet, so an account with 200 wallets paid 400 bridge round trips before it could render, and each wallet ran its own throttled saver, so a busy sync window had them all writing at once. The account cache file now carries every wallet's boot state, public keys included, so a warm boot reads ONE file and a single serialized writer with a single throttle owns every update. Wallets that are merely archived keep their entry, so turning one back on stays warm; entries for wallets the account no longer has are dropped, so the file cannot grow without bound. The disklet exposes no rename on either platform, so the usual write-temp-then-rename trick is unavailable and Android's backend truncates the target in place. Generations therefore alternate between two slots and the reader takes the newest one that still parses, so a kill part-way through a write costs one generation of staleness rather than the account's warm boot. A device on the old layout migrates on its next login: the version-1 file sends it to the per-wallet reads once, and the saver folds them into the consolidated file. The old per-wallet files are left alone as a recovery net. --- CHANGELOG.md | 6 +- src/core/account/account-cache-file.ts | 72 ++++++- src/core/account/account-cleaners.ts | 87 +++++++- src/core/account/account-pixie.ts | 192 ++++++++++++++++-- .../currency/wallet/currency-wallet-pixie.ts | 125 +----------- src/core/currency/wallet/wallet-cache-file.ts | 8 - .../currency/wallet/wallet-cache-loader.ts | 85 +++++++- test/core/account/account-cache.test.ts | 91 +++++++-- .../currency/wallet/engine-scheduler.test.ts | 29 ++- .../core/currency/wallet/wallet-cache.test.ts | 101 +++++++-- 10 files changed, 585 insertions(+), 211 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24687c57..5a5f833cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ ## Unreleased -- added: Account-level `accountCache.json` with the wallet states and custom tokens the account boot needs. A warm login seeds Redux from it and emits the account right after the currency plugins load, before the account repo syncs, and every cached wallet then seeds from a single bulk dispatch. The deferred file loads overwrite the seeded state authoritatively, and user changes made during the window win over the values those loads read. First login (no cache) boots exactly as before. -- added: Per-wallet `walletCache.json` with each wallet's name, fiat code, enabled token IDs, and last-known balances. Currency wallets now emit their API objects as soon as this cache loads, before their engines exist, so the GUI can render the wallet list immediately at login. Engine-backed methods wait for the engine internally and reject if it fails or the wallet is deleted. First login (no cache) behaves exactly as before. +- added: Account-level `accountCache.json` holding the account boot state plus every wallet's cached name, fiat code, enabled token IDs, last-known balances, receive addresses, and public keys. A warm login reads this one file, seeds Redux from it, and emits the account right after the currency plugins load, before the account repo syncs, with every cached wallet seeded in a single bulk dispatch. One throttled writer owns the whole account, and generations alternate between two slots, so a write interrupted part-way costs one generation of staleness rather than the warm boot. Devices holding the older per-wallet files migrate on their next login. The deferred file loads overwrite the seeded state authoritatively, and user changes made during the window win over the values those loads read. First login (no cache) boots exactly as before. +- added: Currency wallets emit their API objects as soon as the cache loads, before their engines exist, so the GUI can render the wallet list immediately at login. Engine-backed methods wait for the engine internally and reject if it fails or the wallet is deleted. First login (no cache) behaves exactly as before. - added: Cached wallets' engine startup is staggered through a limited-concurrency queue (8 at a time) instead of all racing at login. Asking for a wallet via `waitForCurrencyWallet`, calling an engine- or storage-backed method, or un-pausing it moves it to the front of the queue. Wallets without a cache skip the queue, so first login is unaffected. -- added: An optional `EdgeCurrencyInfo.hasStableAddresses` hint for chains whose receive addresses never rotate. When set, a wallet serves its cached addresses (keyed per token) before the engine loads; rotating and unflagged chains keep the engine wait, exactly as before. +- added: A wallet serves its cached receive addresses (keyed per token) before its engine loads, so the receive scene has an address immediately on a warm login. The wallet emits `addressChanged` once the engine derives a different one, so consumers re-query and pick it up. A query naming a specific `forceIndex` still waits for the engine, since only it can derive a fresh address. - changed: `waitForCurrencyWallet` and `waitForAllWallets` now resolve when the wallet object exists, which can be before its engine loads. - changed: `wallet.otherMethods` exposes delegating stubs: a method whose name is in the wallet's cache can be called before the engine exists (the call waits for the engine internally), the object keeps its identity when the cache already names every engine method, and a cached name the engine no longer implements rejects cleanly at call time. Pre-engine with no cache it is `{}`, as before. - changed: `EdgeCurrencyWallet.balanceMap` keeps its object identity when an engine re-reports an unchanged balance. diff --git a/src/core/account/account-cache-file.ts b/src/core/account/account-cache-file.ts index 6eed47e68..78f43a051 100644 --- a/src/core/account/account-cache-file.ts +++ b/src/core/account/account-cache-file.ts @@ -1,12 +1,31 @@ +import { Disklet } from 'disklet' + import { makeJsonFile } from '../../util/file-helpers' -import { asAccountCacheFile } from './account-cleaners' +import { + AccountCacheFile, + asAccountCacheFile, + asStoredAccountCacheFile +} from './account-cleaners' /** * Cached account boot state, stored on the account's local disklet. * See `asAccountCacheFile` for the schema. + * + * The cache lives in two alternating slots. The disklet exposes no + * rename (neither its JS interface nor its iOS and Android native + * modules), so the usual write-temp-then-rename trick is unavailable, + * and Android's backend truncates the target before writing. A kill + * mid-write can therefore leave one slot torn. Writing generations + * alternately means the OTHER slot always holds the last complete + * one, and `loadAccountCache` takes the newest slot that still + * parses. (iOS writes with `NSDataWritingAtomic` and never tears, so + * this only earns its keep on Android.) */ -export const ACCOUNT_CACHE_FILE = 'accountCache.json' -export const accountCacheFile = makeJsonFile(asAccountCacheFile) +export const ACCOUNT_CACHE_FILES = ['accountCache.json', 'accountCache.2.json'] +export const accountCacheFile = { + load: makeJsonFile(asStoredAccountCacheFile).load, + save: makeJsonFile(asAccountCacheFile).save +} /** * Tuning for the account boot-state cache saver. @@ -15,3 +34,50 @@ export const accountCacheFile = makeJsonFile(asAccountCacheFile) export const accountCacheSaverConfig = { throttleMs: 5000 } + +/** + * Reads both slots and returns the newest one that parses, plus the + * slot the next write should use. Returns `undefined` cache data when + * neither slot is readable (first login, schema bump, both torn), + * which sends the caller to its cold path. + */ +export async function loadAccountCache( + disklet: Disklet +): Promise<{ cache: AccountCacheFile | undefined; nextSlot: number }> { + const slots = await Promise.all( + ACCOUNT_CACHE_FILES.map( + async path => await accountCacheFile.load(disklet, path) + ) + ) + + let best: AccountCacheFile | undefined + let bestSlot = -1 + for (let slot = 0; slot < slots.length; ++slot) { + const cache = slots[slot] + if (cache == null) continue + if (best == null || cache.sequence > best.sequence) { + best = cache + bestSlot = slot + } + } + + // Write over the slot we did NOT just read, so a torn write can + // never damage the generation we are currently relying on: + return { + cache: best, + nextSlot: bestSlot === -1 ? 0 : (bestSlot + 1) % ACCOUNT_CACHE_FILES.length + } +} + +/** + * Writes the next generation into `slot` and returns the slot the + * write after this one should use. + */ +export async function saveAccountCache( + disklet: Disklet, + slot: number, + data: AccountCacheFile +): Promise { + await accountCacheFile.save(disklet, ACCOUNT_CACHE_FILES[slot], data) + return (slot + 1) % ACCOUNT_CACHE_FILES.length +} diff --git a/src/core/account/account-cleaners.ts b/src/core/account/account-cleaners.ts index 68bc4be7c..8380f9787 100644 --- a/src/core/account/account-cleaners.ts +++ b/src/core/account/account-cleaners.ts @@ -1,6 +1,8 @@ import { asArray, asBoolean, + asEither, + asNull, asNumber, asObject, asOptional, @@ -115,7 +117,7 @@ export const asCustomTokensFile = asObject({ * auth, API keys), which must never leave the encrypted repo. */ export interface AccountCacheFile { - version: 1 + version: 2 customTokens: EdgePluginMap /** * True when the account has legacy Airbitz-repo wallets. Their @@ -129,6 +131,43 @@ export interface AccountCacheFile { * expose delegating stubs even if the plugin has not loaded yet. */ configOtherMethodNames: EdgePluginMap + + /** + * Every active wallet's cached boot state, keyed by wallet id. + * This absorbs what used to live in each wallet's own + * `publicKey.json` + `walletCache.json` pair, so a warm boot reads + * one file for the whole account instead of two per wallet. + */ + wallets: { [walletId: string]: AccountCacheWallet } + + /** + * Increases on every write. Two slots hold alternating generations, + * so the reader can take the newest one that still parses; see + * `loadAccountCache`. + */ + sequence: number +} + +/** + * One wallet's cached boot state inside the account cache file: + * the public keys that were in `publicKey.json` plus the UI state + * that was in `walletCache.json`. + */ +export interface AccountCacheWallet { + walletInfo: { id: string; keys: object; type: string } + name: string | null + fiatCurrencyCode: string + enabledTokenIds: string[] + + /** Integer strings. The `null` tokenId is spelled '' here. */ + balances: { [tokenId: string]: string } + + /** Per tokenId, without balances (`null` tokenId spelled ''). */ + addresses: { + [tokenIdKey: string]: Array<{ addressType: string; publicAddress: string }> + } + + otherMethodNames: string[] } const asEdgeWalletState = asObject({ @@ -139,10 +178,56 @@ const asEdgeWalletState = asObject({ sortIndex: asOptional(asNumber) }) +const asCachedAddress = asObject({ + addressType: asString, + publicAddress: asString +}) + +const asAccountCacheWallet = asObject({ + walletInfo: asObject({ + id: asString, + keys: asJsonObject, + type: asString + }), + name: asEither(asString, asNull), + fiatCurrencyCode: asString, + enabledTokenIds: asArray(asString), + balances: asObject(asString), + addresses: asObject(asArray(asCachedAddress)), + otherMethodNames: asArray(asString) +}) + export const asAccountCacheFile: Cleaner = asObject({ + version: asValue(2), + sequence: asNumber, + customTokens: asObject(asObject(asEdgeToken)), + legacyWallets: asOptional(asBoolean, false), + walletStates: asObject(asEdgeWalletState), + configOtherMethodNames: asOptional(asObject(asArray(asString)), () => ({})), + wallets: asObject(asAccountCacheWallet) +}) + +const asAccountCacheFileV1 = asObject({ version: asValue(1), customTokens: asObject(asObject(asEdgeToken)), legacyWallets: asOptional(asBoolean, false), walletStates: asObject(asEdgeWalletState), configOtherMethodNames: asOptional(asObject(asArray(asString)), () => ({})) }) + +/** + * Read-side cleaner: upgrades a version-1 file instead of falling + * through to a cold boot. A version-1 file predates the consolidated + * wallet table, so its wallets are still in their own per-wallet + * files, which `bulkLoadWalletCaches` reads once before this file is + * rewritten in the current schema. Writes always use + * `asAccountCacheFile`. + */ +export const asStoredAccountCacheFile: Cleaner = raw => { + try { + return asAccountCacheFile(raw) + } catch (error: unknown) { + const clean = asAccountCacheFileV1(raw) + return { ...clean, version: 2, sequence: 0, wallets: {} } + } +} diff --git a/src/core/account/account-pixie.ts b/src/core/account/account-pixie.ts index c8bff28ca..c202e7983 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -21,6 +21,7 @@ import { makePeriodicTask } from '../../util/periodic-task' import { snooze } from '../../util/snooze' import { bulkLoadWalletCaches, + seedWalletCachesFromAccount, walletCacheLoaderHooks } from '../currency/wallet/wallet-cache-loader' import { syncLogin } from '../login/login' @@ -34,10 +35,11 @@ import { } from '../storage/storage-actions' import { makeAccountApi } from './account-api' import { - ACCOUNT_CACHE_FILE, - accountCacheFile, - accountCacheSaverConfig + accountCacheSaverConfig, + loadAccountCache, + saveAccountCache } from './account-cache-file' +import { AccountCacheWallet } from './account-cleaners' import { loadAllWalletStates, reloadPluginSettings } from './account-files' import { AccountState, initialCustomTokens } from './account-reducer' import { @@ -135,9 +137,8 @@ const accountPixie: TamePixie = combinePixies({ // corruption) or an account with legacy Airbitz wallets // (their infos cannot be cached), this is today's boot, // unchanged: - const accountCache = await accountCacheFile.load( - makeLocalDisklet(ai.props.io, accountWalletInfo.id), - ACCOUNT_CACHE_FILE + const { cache: accountCache } = await loadAccountCache( + makeLocalDisklet(ai.props.io, accountWalletInfo.id) ) if (accountCache != null && !accountCache.legacyWallets) { input.props.dispatch({ @@ -156,8 +157,18 @@ const accountPixie: TamePixie = combinePixies({ walletCacheLoaderHooks.onAccountSeed(accountId) } - // Seed every wallet's cache in one dispatch: - await bulkLoadWalletCaches(ai, accountId) + // Seed every wallet's cache in one dispatch. The current + // schema carries them all in the file just read, so the + // warm path touches no further disk. A file written by an + // older version has none, so those wallets come from + // their own files this once, and the saver then folds + // them into the consolidated file: + const cachedWallets = Object.keys(accountCache.wallets) + if (cachedWallets.length > 0) { + seedWalletCachesFromAccount(ai, accountId, accountCache.wallets) + } else { + await bulkLoadWalletCaches(ai, accountId) + } // The GUI already has the account, so retry transient // failures instead of leaving the session half-loaded @@ -352,14 +363,130 @@ const accountPixie: TamePixie = combinePixies({ customTokens: EdgePluginMap legacyWalletInfos: EdgeWalletInfo[] walletStates: EdgeWalletStates + + /** + * Reference identities of every active wallet's cache-relevant + * state. The Redux slices are immutable, so an unchanged + * reference means unchanged content, and this stays a cheap + * reference scan rather than a deep compare of the whole table. + */ + walletStamp: unknown[] + } + + function sameStamp(a: unknown[], b: unknown[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) return false + } + return true } let failures = 0 let lastSaved: CacheSnapshot | undefined let timer: ReturnType | undefined + // Which of the two slots the next write lands in, resolved from + // disk on the first save. `undefined` means "not looked up yet": + let nextSlot: number | undefined + + // Every write goes through this one chain. `doSave` is async, so a + // throttle firing while a previous write is still in flight would + // otherwise let two saves read the same `nextSlot` and both write + // it, leaving the other slot two generations stale (and, on a + // platform whose writes are not atomic, interleaving in one file): + let writeChain: Promise = Promise.resolve() + + // Monotonic across writes, so a reader can tell the two slots + // apart. Seeded from whichever generation is on disk: + let sequence = 0 + + // The wallet table as last written, so wallets that are not + // running this session keep their cached state instead of being + // dropped by a save that only sees the active ones: + let lastWallets: { [walletId: string]: AccountCacheWallet } = {} + + // The stamp computed by the `update` that armed the pending + // timer, so `doSave` records exactly what it wrote: + let pendingStamp: unknown[] = [] + + /** + * Reference identities of the state `collectWallets` reads, in a + * flat array, so `update` can tell whether anything the cache + * file actually stores has changed. + */ + function collectWalletStamp(): unknown[] { + const { accountState, state } = input.props + const stamp: unknown[] = [] + for (const walletId of accountState.activeWalletIds) { + const walletState = state.currency.wallets[walletId] + if (walletState == null) continue + stamp.push( + walletState.addresses, + walletState.balanceMap, + walletState.enabledTokenIds, + walletState.fiat, + walletState.name, + walletState.otherMethodNames, + walletState.publicWalletInfo + ) + } + return stamp + } + + /** + * Collects every active wallet's cache-relevant Redux state. + * This is the whole point of the consolidated file: one writer + * for the entire account, instead of one throttled writer per + * wallet all racing for the same bridge. + */ + function collectWallets(): { + [walletId: string]: AccountCacheWallet + } { + const { accountState, state } = input.props + + // Start from what the file already holds, so a wallet that is + // archived (or simply not running this session) keeps its cached + // boot state, the way its own file used to just sit on disk. + // Entries for wallets the account no longer has are dropped, so + // this cannot grow without bound: + const wallets: { [walletId: string]: AccountCacheWallet } = {} + for (const walletId of Object.keys(lastWallets)) { + const walletState = accountState.walletStates[walletId] + if (walletState == null || walletState.deleted === true) continue + wallets[walletId] = lastWallets[walletId] + } + + for (const walletId of accountState.activeWalletIds) { + const walletState = state.currency.wallets[walletId] + if (walletState == null) continue + + // Skip a wallet that has not finished loading its + // authoritative files, so a cold start never caches + // placeholder values (the per-wallet saver's old guard): + const { fiatLoaded, nameLoaded, publicWalletInfo, tokenFileLoaded } = + walletState + if (!fiatLoaded || !nameLoaded || !tokenFileLoaded) continue + if (publicWalletInfo == null) continue + + const balances: { [tokenId: string]: string } = {} + for (const [tokenId, balance] of walletState.balanceMap) { + balances[tokenId ?? ''] = balance + } + + wallets[walletId] = { + walletInfo: publicWalletInfo, + name: walletState.name, + fiatCurrencyCode: walletState.fiat, + enabledTokenIds: walletState.enabledTokenIds, + balances, + addresses: walletState.addresses, + otherMethodNames: walletState.otherMethodNames + } + } + return wallets + } + async function doSave(): Promise { - timer = undefined const { accountId, accountState, state } = input.props // Never write after logout: @@ -369,7 +496,8 @@ const accountPixie: TamePixie = combinePixies({ currencyWalletIds: accountState.currencyWalletIds, customTokens: accountState.customTokens, legacyWalletInfos: accountState.legacyWalletInfos, - walletStates: accountState.walletStates + walletStates: accountState.walletStates, + walletStamp: pendingStamp } // Only legacy wallets that actually surface as currency wallets @@ -393,17 +521,32 @@ const accountPixie: TamePixie = combinePixies({ } try { - await accountCacheFile.save( - makeLocalDisklet(input.props.io, accountState.accountWalletInfo.id), - ACCOUNT_CACHE_FILE, - { - version: 1, - customTokens: snapshot.customTokens, - legacyWallets, - walletStates: snapshot.walletStates, - configOtherMethodNames - } + const disklet = makeLocalDisklet( + input.props.io, + accountState.accountWalletInfo.id ) + + // Resolve the starting slot once. A fresh session must not + // overwrite the generation it booted from, or a kill during + // this very first write would leave nothing intact: + if (nextSlot == null) { + const existing = await loadAccountCache(disklet) + nextSlot = existing.nextSlot + sequence = existing.cache?.sequence ?? 0 + lastWallets = existing.cache?.wallets ?? {} + } + + const wallets = collectWallets() + nextSlot = await saveAccountCache(disklet, nextSlot, { + version: 2, + sequence: ++sequence, + customTokens: snapshot.customTokens, + legacyWallets, + walletStates: snapshot.walletStates, + configOtherMethodNames, + wallets + }) + lastWallets = wallets failures = 0 lastSaved = snapshot } catch (error: unknown) { @@ -428,18 +571,23 @@ const accountPixie: TamePixie = combinePixies({ const { customTokensLoaded, walletStatesLoaded } = accountState if (!customTokensLoaded || !walletStatesLoaded) return + const walletStamp = collectWalletStamp() if ( lastSaved != null && lastSaved.currencyWalletIds === accountState.currencyWalletIds && lastSaved.customTokens === accountState.customTokens && lastSaved.legacyWalletInfos === accountState.legacyWalletInfos && - lastSaved.walletStates === accountState.walletStates + lastSaved.walletStates === accountState.walletStates && + sameStamp(lastSaved.walletStamp, walletStamp) ) { return } + pendingStamp = walletStamp timer = setTimeout(() => { - doSave().catch(error => input.props.onError(error)) + timer = undefined + writeChain = writeChain.then(doSave, doSave) + writeChain.catch(error => input.props.onError(error)) }, accountCacheSaverConfig.throttleMs) }, diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index fae9d570e..5b30197c9 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -10,8 +10,6 @@ import { import { update } from 'yaob' import { - EdgeAddress, - EdgeBalanceMap, EdgeCurrencyEngine, EdgeCurrencyTools, EdgeCurrencyWallet, @@ -25,7 +23,6 @@ import { makeTokenInfo } from '../../account/custom-tokens' import { makeLog } from '../../log/log' import { getCurrencyTools } from '../../plugins/plugins-selectors' import { RootProps, toApiInput } from '../../root-pixie' -import { makeLocalDisklet } from '../../storage/repo' import { addStorageWallet, SYNC_INTERVAL, @@ -40,7 +37,7 @@ import { makeCurrencyWalletCallbacks, watchCurrencyWallet } from './currency-wallet-callbacks' -import { asIntegerString, WalletCacheFile } from './currency-wallet-cleaners' +import { asIntegerString } from './currency-wallet-cleaners' import { loadAddressFiles, loadFiatFile, @@ -58,11 +55,6 @@ import { } from './currency-wallet-reducer' import { tokenIdsToCurrencyCodes, uniqueStrings } from './enabled-tokens' import { getEngineScheduler } from './engine-scheduler' -import { - WALLET_CACHE_FILE, - walletCacheFile, - walletCacheSaverConfig -} from './wallet-cache-file' import { loadWalletCacheSeed, PUBLIC_KEY_CACHE, @@ -118,7 +110,7 @@ export const walletPixie: TamePixie = combinePixies({ let cacheSeeded = walletState.publicWalletInfo != null && walletState.nameLoaded if (!cacheSeeded) { - const seed = await loadWalletCacheSeed(ai, walletId) + const seed = await loadWalletCacheSeed(ai, walletId, accountId) if (seed != null) { input.props.dispatch({ type: 'CURRENCY_WALLET_CACHE_LOADED', @@ -567,108 +559,6 @@ export const walletPixie: TamePixie = combinePixies({ } }, - /** - * Watches the wallet's cache-relevant Redux state and persists it to - * `walletCache.json`, so the next login can render this wallet before - * its engine exists. Writes are throttled to at most one per wallet per - * `walletCacheSaverConfig.throttleMs` (trailing edge), never happen - * after logout, and stop after 3 consecutive failures to avoid log spam. - */ - cacheSaver(input: CurrencyWalletInput) { - interface CacheSnapshot { - addresses: { [tokenIdKey: string]: EdgeAddress[] } - balanceMap: EdgeBalanceMap - enabledTokenIds: string[] - fiat: string - name: string | null - otherMethodNames: string[] - } - - let failures = 0 - let lastSaved: CacheSnapshot | undefined - let timer: ReturnType | undefined - - async function doSave(): Promise { - timer = undefined - const { state, walletId, walletState } = input.props - - // Never write after logout: - if (state.accounts[walletState.accountId] == null) return - - const snapshot: CacheSnapshot = { - addresses: walletState.addresses, - balanceMap: walletState.balanceMap, - enabledTokenIds: walletState.enabledTokenIds, - fiat: walletState.fiat, - name: walletState.name, - otherMethodNames: walletState.otherMethodNames - } - const balances: WalletCacheFile['balances'] = {} - for (const [tokenId, balance] of snapshot.balanceMap) { - balances[tokenId ?? ''] = balance - } - - try { - await walletCacheFile.save( - makeLocalDisklet(input.props.io, walletId), - WALLET_CACHE_FILE, - { - version: 2, - name: snapshot.name, - fiatCurrencyCode: snapshot.fiat, - enabledTokenIds: snapshot.enabledTokenIds, - balances, - addresses: snapshot.addresses, - otherMethodNames: snapshot.otherMethodNames - } - ) - failures = 0 - lastSaved = snapshot - } catch (error: unknown) { - if (++failures >= 3) { - input.props.log.error( - `Wallet cache saver giving up after ${failures} failures: ${String( - error - )}` - ) - } - } - } - - return { - update() { - const { walletState } = input.props - if (walletState == null) return - if (failures >= 3 || timer != null) return - - // Wait until the authoritative files have loaded, - // so a cold start never caches placeholder values: - const { fiatLoaded, nameLoaded, tokenFileLoaded } = walletState - if (!fiatLoaded || !nameLoaded || !tokenFileLoaded) return - - if ( - lastSaved != null && - lastSaved.addresses === walletState.addresses && - lastSaved.balanceMap === walletState.balanceMap && - lastSaved.otherMethodNames === walletState.otherMethodNames && - lastSaved.enabledTokenIds === walletState.enabledTokenIds && - lastSaved.fiat === walletState.fiat && - lastSaved.name === walletState.name - ) { - return - } - - timer = setTimeout(() => { - doSave().catch(error => input.props.onError(error)) - }, walletCacheSaverConfig.throttleMs) - }, - - destroy() { - if (timer != null) clearTimeout(timer) - } - } - }, - watcher(input: CurrencyWalletInput) { let lastState: CurrencyWalletState | undefined let lastUserSettings: object = {} @@ -810,13 +700,10 @@ export async function getPublicWalletInfo( keys: publicKeys } - // Save the cache if it's not empty: - if (Object.keys(publicKeys).length > 0) { - await publicKeyFile.save(disklet, PUBLIC_KEY_CACHE, { - walletInfo: publicWalletInfo - }) - } - + // The account cache saver persists these keys along with the rest + // of the wallet's boot state, so nothing is written per wallet + // here. Older `publicKey.json` files stay on disk as a recovery + // net for a boot that cannot read the account cache: return publicWalletInfo } diff --git a/src/core/currency/wallet/wallet-cache-file.ts b/src/core/currency/wallet/wallet-cache-file.ts index d8d998c49..b709b7c26 100644 --- a/src/core/currency/wallet/wallet-cache-file.ts +++ b/src/core/currency/wallet/wallet-cache-file.ts @@ -35,11 +35,3 @@ export interface WalletCacheSeed { otherMethodNames: string[] publicWalletInfo: EdgeWalletInfo } - -/** - * Tuning for the wallet UI-state cache saver. - * Tests override the throttle to run quickly. - */ -export const walletCacheSaverConfig = { - throttleMs: 5000 -} diff --git a/src/core/currency/wallet/wallet-cache-loader.ts b/src/core/currency/wallet/wallet-cache-loader.ts index 30afbfd16..20b969d58 100644 --- a/src/core/currency/wallet/wallet-cache-loader.ts +++ b/src/core/currency/wallet/wallet-cache-loader.ts @@ -1,5 +1,7 @@ import { EdgeBalanceMap } from '../../../types/types' import { makeJsonFile } from '../../../util/file-helpers' +import { loadAccountCache } from '../../account/account-cache-file' +import { AccountCacheWallet } from '../../account/account-cleaners' import { ApiInput } from '../../root-pixie' import { makeLocalDisklet } from '../../storage/repo' import { asPublicKeyFile, WalletCacheFile } from './currency-wallet-cleaners' @@ -14,7 +16,7 @@ export const publicKeyFile = makeJsonFile(asPublicKeyFile) /** * Test hooks for observing cache seeding, following the same - * mutable-config pattern as `walletCacheSaverConfig`. + * mutable-config pattern as `accountCacheSaverConfig`. */ export const walletCacheLoaderHooks: { /** Receives each account id seeded by `ACCOUNT_CACHE_LOADED`. */ @@ -40,11 +42,50 @@ export function makeCachedBalanceMap( } /** - * Reads one wallet's cache files from its local disklet. - * Returns undefined when either file is missing or invalid - * (first login, schema bump, corruption). + * Converts one wallet's entry in the account cache file into a seed. + */ +export function toWalletCacheSeed(cached: AccountCacheWallet): WalletCacheSeed { + return { + addresses: cached.addresses, + balanceMap: makeCachedBalanceMap(cached.balances), + enabledTokenIds: cached.enabledTokenIds, + fiatCurrencyCode: cached.fiatCurrencyCode, + name: cached.name, + otherMethodNames: cached.otherMethodNames, + publicWalletInfo: cached.walletInfo + } +} + +/** + * Reads one wallet's seed for a pixie that the bulk seed missed: + * a cold login, or a wallet reactivated mid-session. Prefers the + * consolidated account file, which holds every wallet the account + * still has (archived ones included), and falls back to the + * per-wallet pair a pre-consolidation device still has on disk. */ export async function loadWalletCacheSeed( + ai: ApiInput, + walletId: string, + accountId: string +): Promise { + const accountState = ai.props.state.accounts[accountId] + if (accountState != null) { + const { cache } = await loadAccountCache( + makeLocalDisklet(ai.props.io, accountState.accountWalletInfo.id) + ) + const cached = cache?.wallets[walletId] + if (cached != null) return toWalletCacheSeed(cached) + } + return await loadWalletFilesSeed(ai, walletId) +} + +/** + * Reads one wallet's own cache files from its local disklet. + * Returns undefined when either file is missing or invalid + * (first login, schema bump, corruption). Only a device that has not + * yet written the consolidated account file still has these. + */ +export async function loadWalletFilesSeed( ai: ApiInput, walletId: string ): Promise { @@ -66,6 +107,40 @@ export async function loadWalletCacheSeed( } } +/** + * Seeds every wallet the account cache file already carried, with no + * disk reads at all: the consolidated file was read in one go by the + * account pixie. This is the warm path once a device has written the + * current schema; `bulkLoadWalletCaches` below is the migration path + * for a device whose wallets are still in their own files. + */ +export function seedWalletCachesFromAccount( + ai: ApiInput, + accountId: string, + wallets: { [walletId: string]: AccountCacheWallet } +): void { + // The file also carries wallets that are merely archived, so they + // keep their cached state; only the active ones have a pixie to + // seed. An archived wallet that is turned back on seeds itself + // through `loadWalletCacheSeed`, which reads this same file: + const activeWalletIds = + ai.props.state.accounts[accountId]?.activeWalletIds ?? [] + const seeds: { [walletId: string]: WalletCacheSeed } = {} + for (const walletId of activeWalletIds) { + const cached = wallets[walletId] + if (cached != null) seeds[walletId] = toWalletCacheSeed(cached) + } + + ai.props.dispatch({ + type: 'CURRENCY_WALLETS_CACHE_LOADED', + payload: { accountId, seeds } + }) + + if (walletCacheLoaderHooks.onBulkSeed != null) { + walletCacheLoaderHooks.onBulkSeed(Object.keys(seeds)) + } +} + /** * Reads every active wallet's cache files concurrently and seeds * them all in a single `CURRENCY_WALLETS_CACHE_LOADED` dispatch, @@ -87,7 +162,7 @@ export async function bulkLoadWalletCaches( await Promise.all( activeWalletIds.map(async walletId => { - const seed = await loadWalletCacheSeed(ai, walletId).catch(() => { + const seed = await loadWalletFilesSeed(ai, walletId).catch(() => { // A broken read just means this wallet boots cold: return undefined }) diff --git a/test/core/account/account-cache.test.ts b/test/core/account/account-cache.test.ts index 07a3e2501..5dc4669a0 100644 --- a/test/core/account/account-cache.test.ts +++ b/test/core/account/account-cache.test.ts @@ -2,8 +2,10 @@ import { expect } from 'chai' import { afterEach, beforeEach, describe, it } from 'mocha' import { base64 } from 'rfc4648' -import { accountCacheSaverConfig } from '../../../src/core/account/account-cache-file' -import { walletCacheSaverConfig } from '../../../src/core/currency/wallet/wallet-cache-file' +import { + ACCOUNT_CACHE_FILES, + accountCacheSaverConfig +} from '../../../src/core/account/account-cache-file' import { walletCacheLoaderHooks } from '../../../src/core/currency/wallet/wallet-cache-loader' import { EdgeAccount, @@ -95,7 +97,6 @@ describe('account cache', function () { walletCacheLoaderHooks.onBulkSeed = undefined walletCacheLoaderHooks.onFallbackSeed = undefined accountCacheSaverConfig.throttleMs = 50 - walletCacheSaverConfig.throttleMs = 50 }) afterEach(function () { @@ -105,7 +106,6 @@ describe('account cache', function () { walletCacheLoaderHooks.onBulkSeed = undefined walletCacheLoaderHooks.onFallbackSeed = undefined accountCacheSaverConfig.throttleMs = 5000 - walletCacheSaverConfig.throttleMs = 5000 }) it('cold login without an account cache boots as on master', async function () { @@ -368,15 +368,14 @@ describe('account cache', function () { 'account-repo:co.airbitz.wallet' ) if (accountRepoInfo == null) throw new Error('Broken test account') + // One file carries the whole account, wallets included, so this + // fixture is the entire warm-boot state: const extraFiles: { [path: string]: string } = {} - extraFiles[localPath(accountRepoInfo.id, 'accountCache.json')] = - await account.localDisklet.getText('accountCache.json') - for (const walletId of walletIds) { - const cachedWallet = account.currencyWallets[walletId] - extraFiles[localPath(walletId, 'walletCache.json')] = - await cachedWallet.localDisklet.getText('walletCache.json') - extraFiles[localPath(walletId, 'publicKey.json')] = - await cachedWallet.localDisklet.getText('publicKey.json') + for (const path of ACCOUNT_CACHE_FILES) { + try { + extraFiles[localPath(accountRepoInfo.id, path)] = + await account.localDisklet.getText(path) + } catch (error: unknown) {} } await account.logout() @@ -443,8 +442,14 @@ describe('account cache', function () { // the account emit; today the live plugin always wins): const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) await snooze(SAVE_WAIT_MS) - const text = await account.localDisklet.getText('accountCache.json') - expect(text.includes('fakePluginMethod')).equals(true) + let sawMethodName = false + for (const path of ACCOUNT_CACHE_FILES) { + try { + const text = await account.localDisklet.getText(path) + if (text.includes('fakePluginMethod')) sawMethodName = true + } catch (error: unknown) {} + } + expect(sawMethodName).equals(true) // The live surface stays verbatim, including in the cache-seeded // boot window: @@ -456,6 +461,56 @@ describe('account cache', function () { await account.logout() }) + it('survives a torn write by booting from the other slot', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // Put a generation in each slot: the world's own save filled one, + // and this rename dirties the cache so the next save fills the + // other: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + await wallet.renameWallet('Second Generation') + await snooze(SAVE_WAIT_MS) + + // Truncate the NEWEST slot, which is what a kill part-way through + // a non-atomic write leaves behind on Android: + accountCacheSaverConfig.throttleMs = 5000 + let newestPath = ACCOUNT_CACHE_FILES[0] + let newestSequence = -1 + let newestText = '' + for (const path of ACCOUNT_CACHE_FILES) { + try { + const text = await account.localDisklet.getText(path) + const { sequence = 0 } = JSON.parse(text) + if (sequence > newestSequence) { + newestSequence = sequence + newestPath = path + newestText = text + } + } catch (error: unknown) {} + } + expect(newestSequence).greaterThan(0) + await account.localDisklet.setText( + newestPath, + newestText.slice(0, Math.floor(newestText.length / 2)) + ) + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + + // The older slot is intact, so the login still boots warm while + // the engines are held. Its wallet name is the pre-rename one, + // which is the whole trade: one generation of staleness instead + // of a cold boot: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletIds[0]) + expect(wallet2.name).not.equals(null) + release() + await account2.logout() + }) + it('rejects a corrupt account cache file and falls back cold', async function () { this.timeout(15000) const { context, walletIds } = await makeAccountCachedWorld() @@ -464,7 +519,9 @@ describe('account cache', function () { const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) await snooze(SAVE_WAIT_MS) accountCacheSaverConfig.throttleMs = 5000 - await account.localDisklet.setText('accountCache.json', '{ "version": 99 }') + for (const path of ACCOUNT_CACHE_FILES) { + await account.localDisklet.setText(path, '{ "version": 99 }') + } await account.logout() accountCacheSaverConfig.throttleMs = 50 @@ -505,7 +562,6 @@ describe('write-path staleness', function () { fakePluginTestConfig.engineGate = undefined fakePluginTestConfig.publicKeyCheckGate = undefined accountCacheSaverConfig.throttleMs = 50 - walletCacheSaverConfig.throttleMs = 50 }) afterEach(function () { @@ -513,7 +569,6 @@ describe('write-path staleness', function () { fakePluginTestConfig.engineGate = undefined fakePluginTestConfig.publicKeyCheckGate = undefined accountCacheSaverConfig.throttleMs = 5000 - walletCacheSaverConfig.throttleMs = 5000 }) /** @@ -629,13 +684,11 @@ describe('write-path staleness', function () { // Device A pulls the wallet repo. The wallet reloads its token // file mid-session, so stall the wallet cache saver to keep the // cache on the old empty list (the divergence under test): - walletCacheSaverConfig.throttleMs = 5000 await pullOnDeviceA(context, async account => { const wallet = await account.waitForCurrencyWallet(walletIds[0]) const text = await wallet.disklet.getText('Tokens.json') return text.includes('badf00d5') }) - walletCacheSaverConfig.throttleMs = 50 // Device A warm-boots on a cache with an empty enabled list. The // public-key gate holds the wallet's file loads, so the list the diff --git a/test/core/currency/wallet/engine-scheduler.test.ts b/test/core/currency/wallet/engine-scheduler.test.ts index 2710aa677..039d4e712 100644 --- a/test/core/currency/wallet/engine-scheduler.test.ts +++ b/test/core/currency/wallet/engine-scheduler.test.ts @@ -1,11 +1,14 @@ import { expect } from 'chai' import { afterEach, beforeEach, describe, it } from 'mocha' +import { + ACCOUNT_CACHE_FILES, + accountCacheSaverConfig +} from '../../../../src/core/account/account-cache-file' import { engineSchedulerConfig, getEngineScheduler } from '../../../../src/core/currency/wallet/engine-scheduler' -import { walletCacheSaverConfig } from '../../../../src/core/currency/wallet/wallet-cache-file' import { EdgeContext, makeFakeEdgeWorld } from '../../../../src/index' import { snooze } from '../../../../src/util/snooze' import { @@ -83,13 +86,13 @@ describe('engine scheduler', function () { beforeEach(function () { fakePluginTestConfig.engineGate = undefined fakePluginTestConfig.onEngineCreate = undefined - walletCacheSaverConfig.throttleMs = 50 + accountCacheSaverConfig.throttleMs = 50 }) afterEach(function () { fakePluginTestConfig.engineGate = undefined fakePluginTestConfig.onEngineCreate = undefined - walletCacheSaverConfig.throttleMs = 5000 + accountCacheSaverConfig.throttleMs = 5000 engineSchedulerConfig.concurrency = 8 engineSchedulerConfig.slotTimeoutMs = 30000 engineSchedulerConfig.stickyBumpTtlMs = 30000 @@ -160,17 +163,25 @@ describe('engine scheduler', function () { this.timeout(15000) const { context, walletIds } = await makeMultiWalletWorld(3) - // Erase the cache files, making the next login a cold start. - // Let the login's own cache save settle first, so nothing - // rewrites the files after the deletes: + // Strip every wallet out of the account cache, so the account + // still boots warm but no wallet has anything cached. Let the + // login's own cache save settle first, so nothing rewrites the + // file afterwards: const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) await account.waitForAllWallets() await snooze(SAVE_WAIT_MS) - for (const walletId of walletIds) { - const wallet = await account.waitForCurrencyWallet(walletId) - await wallet.localDisklet.delete('walletCache.json') + accountCacheSaverConfig.throttleMs = 5000 + for (const path of ACCOUNT_CACHE_FILES) { + try { + const cache = JSON.parse(await account.localDisklet.getText(path)) + await account.localDisklet.setText( + path, + JSON.stringify({ ...cache, wallets: {} }) + ) + } catch (error: unknown) {} } await account.logout() + accountCacheSaverConfig.throttleMs = 50 engineSchedulerConfig.concurrency = 1 const created: string[] = [] diff --git a/test/core/currency/wallet/wallet-cache.test.ts b/test/core/currency/wallet/wallet-cache.test.ts index 64928411f..ffc45cb95 100644 --- a/test/core/currency/wallet/wallet-cache.test.ts +++ b/test/core/currency/wallet/wallet-cache.test.ts @@ -1,8 +1,12 @@ import { expect } from 'chai' import { afterEach, beforeEach, describe, it } from 'mocha' -import { walletCacheSaverConfig } from '../../../../src/core/currency/wallet/wallet-cache-file' import { + ACCOUNT_CACHE_FILES, + accountCacheSaverConfig +} from '../../../../src/core/account/account-cache-file' +import { + EdgeAccount, EdgeContext, EdgeCurrencyWallet, makeFakeEdgeWorld @@ -60,19 +64,39 @@ async function makeCachedWorld(): Promise { return { context, walletId: walletInfo.id } } +/** + * Returns the newest readable account-cache slot as raw text. The + * cache alternates between two slots, so a test that wants to inspect + * what was actually written has to pick the current generation. + */ +async function readAccountCache(account: EdgeAccount): Promise { + let best: any + for (const path of ACCOUNT_CACHE_FILES) { + try { + const parsed = JSON.parse(await account.localDisklet.getText(path)) + // A version-1 file predates `sequence` and is always older: + if (best == null || (parsed.sequence ?? 0) > (best.sequence ?? 0)) { + best = parsed + } + } catch (error: unknown) {} + } + if (best == null) throw new Error('No readable account cache') + return best +} + describe('wallet cache', function () { beforeEach(function () { fakePluginTestConfig.engineGate = undefined fakePluginTestConfig.freshAddressPatch = undefined fakePluginTestConfig.omitEngineOtherMethods = undefined - walletCacheSaverConfig.throttleMs = 50 + accountCacheSaverConfig.throttleMs = 50 }) afterEach(function () { fakePluginTestConfig.engineGate = undefined fakePluginTestConfig.freshAddressPatch = undefined fakePluginTestConfig.omitEngineOtherMethods = undefined - walletCacheSaverConfig.throttleMs = 5000 + accountCacheSaverConfig.throttleMs = 5000 }) it('cold login without cache files matches master behavior', async function () { @@ -271,14 +295,14 @@ describe('wallet cache', function () { const { context, walletId } = await makeCachedWorld() // Slow the saver down so its write is still pending at logout: - walletCacheSaverConfig.throttleMs = 3000 + accountCacheSaverConfig.throttleMs = 3000 const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) const wallet = await account.waitForCurrencyWallet(walletId) await wallet.renameWallet('Ghost Name') await account.logout() // The cancelled write must not have touched the cache: - walletCacheSaverConfig.throttleMs = 50 + accountCacheSaverConfig.throttleMs = 50 const { gate, release } = createEngineGate() fakePluginTestConfig.engineGate = gate const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) @@ -294,9 +318,11 @@ describe('wallet cache', function () { // Corrupt the cache file after the saver has settled: const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) - const wallet = await account.waitForCurrencyWallet(walletId) + await account.waitForCurrencyWallet(walletId) await snooze(SAVE_WAIT_MS) - await wallet.localDisklet.setText('walletCache.json', '{ "version": 99 }') + for (const path of ACCOUNT_CACHE_FILES) { + await account.localDisklet.setText(path, '{ "version": 99 }') + } await account.logout() // A corrupt file means the cold path runs: @@ -474,9 +500,12 @@ describe('wallet cache', function () { await snooze(SAVE_WAIT_MS) // The engine's answer reached the cache file, balances stripped: - const text = await wallet.localDisklet.getText('walletCache.json') - expect(text.includes('fakeaddress')).equals(true) - expect(text.includes('nativeBalance')).equals(false) + const cache = await readAccountCache(account) + const stored = cache.wallets[walletId] + expect(JSON.stringify(stored.addresses).includes('fakeaddress')).equals( + true + ) + expect(JSON.stringify(stored).includes('nativeBalance')).equals(false) await account.logout() // A warm login serves the cached addresses while the engine is @@ -717,32 +746,53 @@ describe('wallet cache', function () { await account.logout() }) - it('upgrades a version-1 cache file and grows stubs post-engine', async function () { + it('upgrades a pre-consolidation device and grows stubs post-engine', async function () { this.timeout(15000) const { context, walletId } = await makeCachedWorld() - // Rewrite the cache as a version-1 file (no addresses, no method - // names), the state every real device is in when this ships: + // Rebuild the on-disk state an existing device is in when this + // ships: a version-1 account file that knows nothing about + // wallets, plus the per-wallet pair this version no longer + // writes. The version-1 wallet file predates addresses and method + // names, so the boot below also proves those upgrade cleanly: const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) const wallet = await account.waitForCurrencyWallet(walletId) - const file = JSON.parse( - await wallet.localDisklet.getText('walletCache.json') + await snooze(SAVE_WAIT_MS) + const cache = await readAccountCache(account) + const cached = cache.wallets[walletId] + accountCacheSaverConfig.throttleMs = 5000 + + await wallet.localDisklet.setText( + 'publicKey.json', + JSON.stringify({ walletInfo: cached.walletInfo }) ) - walletCacheSaverConfig.throttleMs = 5000 await wallet.localDisklet.setText( 'walletCache.json', JSON.stringify({ version: 1, - name: file.name, - fiatCurrencyCode: file.fiatCurrencyCode, - enabledTokenIds: file.enabledTokenIds, - balances: file.balances + name: cached.name, + fiatCurrencyCode: cached.fiatCurrencyCode, + enabledTokenIds: cached.enabledTokenIds, + balances: cached.balances + }) + ) + for (const path of ACCOUNT_CACHE_FILES) { + await account.localDisklet.delete(path) + } + await account.localDisklet.setText( + 'accountCache.json', + JSON.stringify({ + version: 1, + customTokens: cache.customTokens, + legacyWallets: false, + walletStates: cache.walletStates, + configOtherMethodNames: cache.configOtherMethodNames }) ) await account.logout() - walletCacheSaverConfig.throttleMs = 50 + accountCacheSaverConfig.throttleMs = 50 - // The old file still warm-boots (upgraded on read, not cold), + // The old layout still warm-boots (migrated on read, not cold), // with no method names yet: const { gate, release } = createEngineGate() fakePluginTestConfig.engineGate = gate @@ -758,6 +808,13 @@ describe('wallet cache', function () { expect(await wallet2.otherMethods.testMethod('grown')).equals( 'testMethod called with: grown' ) + + // ...and the migration folded this wallet into the consolidated + // file, so the NEXT boot needs no per-wallet reads at all: + await snooze(SAVE_WAIT_MS) + const migrated = await readAccountCache(account2) + expect(migrated.version).equals(2) + expect(migrated.wallets[walletId].name).equals('Cached Name') await account2.logout() }) From 9dc46e6981b28429a6a2c2ffd7405ff61a320950 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 31 Jul 2026 19:10:02 -0700 Subject: [PATCH 39/39] Commit the wallet cache TDD onto the branch The design doc lived as a gist while the work had no PR. It now ships with the code it describes, so it is reviewable in the same diff and cannot drift from the branch. Body updated to the current design: one consolidated account cache file, the pre-engine address serve with its reconcile, and the reverted provisional affordance. --- src/docs/edge-wallet-cache-design.md | 587 +++++++++++++++++++++++++++ 1 file changed, 587 insertions(+) create mode 100644 src/docs/edge-wallet-cache-design.md diff --git a/src/docs/edge-wallet-cache-design.md b/src/docs/edge-wallet-cache-design.md new file mode 100644 index 000000000..df6ec85f7 --- /dev/null +++ b/src/docs/edge-wallet-cache-design.md @@ -0,0 +1,587 @@ +Edge wallet cache v2 design: instant wallet UI at login (edge-core-js) + +Edge wallet cache v2 design: instant wallet UI at login (edge-core-js) + +# Wallet cache v2: instant wallet UI at login + +| | | +|---|---| +| Status | Implemented (phases 1-6 in review) | +| Author | Jon Tzeng | +| Reviewer | William Swanson | +| Last updated | 2026-07-23 | +| Repos | [EdgeApp/edge-core-js](https://github.com/EdgeApp/edge-core-js), [EdgeApp/edge-react-gui](https://github.com/EdgeApp/edge-react-gui), [EdgeApp/edge-currency-accountbased](https://github.com/EdgeApp/edge-currency-accountbased) | +| Implementation | [edge-core-js#733](https://github.com/EdgeApp/edge-core-js/pull/733), [edge-react-gui#6080](https://github.com/EdgeApp/edge-react-gui/pull/6080), [edge-currency-accountbased#1076](https://github.com/EdgeApp/edge-currency-accountbased/pull/1076) (draft, plugin opt-in) | +| Supersedes | [edge-core-js#703](https://github.com/EdgeApp/edge-core-js/pull/703) (Cache and restore wallets) | +| Related | [edge-core-js#709](https://github.com/EdgeApp/edge-core-js/pull/709) (Reduce UI lag, tabled), [architecture comparison vs #703](https://gist.github.com/j0ntz/7ea32da5dc8d23a1fd46d16fd121cda1), [write-path audit](https://gist.github.com/j0ntz/94c3e64779a92e8a1ae1896f4d7d3d6f), Asana: [Login Perf - Wallet Cache v2](https://app.asana.com/1/9976422036640/project/1213843652804305/task/1216673467164267) | + +Core file references are to edge-core-js `master` at v2.47.0 (1b2a25e7) as it stood before implementation; GUI references are to edge-react-gui `develop`. Code blocks marked "as landed" are from the implementation PRs. The architectural direction comes from the William x Jon call of 2026-07-17; decisions and their rationale are in [section 9](#9-decisions). Divergences discovered during implementation are documented inline in the sections they affected. + +## Contents + +1. [Problem](#1-problem) +2. [Why #703 is not the implementation](#2-why-703-is-not-the-implementation) +3. [Goals and non-goals](#3-goals-and-non-goals) +4. [Design overview](#4-design-overview) +5. [Detailed design: edge-core-js](#5-detailed-design-edge-core-js) +6. [Testing](#6-testing) +7. [GUI integration: edge-react-gui](#7-gui-integration-edge-react-gui) +8. [Phase history and #709 disposition](#8-phase-history-and-709-disposition) +9. [Decisions](#9-decisions) +10. [References](#10-references) + +## 1. Problem + +Measured from the last PIN digit to a usable wallet list, login is slow because the GUI cannot render balances until currency engines exist. The chain on master: + +1. The GUI gets no `EdgeAccount` until plugins load, account repos sync, and key files load (`src/core/account/account-pixie.ts:52-118`). +2. Each wallet's `walletApi` pixie refuses to emit until `engine != null && publicWalletInfo != null && nameLoaded` (`src/core/currency/wallet/currency-wallet-pixie.ts:209`). +3. In the engine pixie, `plugin.makeCurrencyEngine` sits behind repo sync, `loadTxFileNames`, and key derivation, and the name/fiat file loads are sequenced after engine creation (`currency-wallet-pixie.ts:78-201`, name/fiat at 186-187). +4. Core never persists balances. They live only in Redux (`balanceMap`), populated by engine callbacks after startup. + +So even the wallet's name waits behind engine creation, and balances wait behind engine startup plus network. QA measured the caching approach in #703 at roughly a 5x login improvement. William confirmed the finding on the 2026-07-17 call: the cost of one extra disk read per wallet is far below the cost of loading engines up front. + +Engine loading also causes visible jank after login (disk, key derivation, network on the JS thread). Deferring engine work was out of scope for phase 1; phase 2 shipped it ([section 8](#8-phase-history-and-709-disposition)). + +## 2. Why #703 is not the implementation + +#703 proved the concept and is the reason we know the win is real. It was rejected on architecture, not on results: + +- It builds a second, parallel implementation of `EdgeCurrencyWallet` and `EdgeCurrencyConfig` (543-line `cached-currency-wallet.ts` plus config, loader, saver, delegation utilities; ~3,200 lines total). Two implementations of the same interface means every future API change must be made twice, and each copy has its own bugs. Review findings bore this out: stale Redux snapshots in the loader, an initial save that wiped token definitions, non-bridgified `otherMethods`, pollers that outlive logout. +- The merged `currencyWallets` getter allocated a fresh object per read. yaob diffs by `===`, so unstable references cause superfluous or missing bridge updates (William's inline review comment, 2026-02-26). +- Cached wallets delegate to real ones through a 500 ms poll with a 60 s timeout, and four setters must remember to call `update()` by hand or the GUI wedges (the paused-state variant causes an infinite boot loop, per the PR's own design doc). + +Direction agreed on the call: keep one wallet implementation. Feed cached data into the existing Redux state and the existing `walletApi` object, and make the engine an awaitable dependency inside that object instead of a construction parameter. + +## 3. Goals and non-goals + +Goals: + +- Wallet list with names, fiat codes, enabled tokens, and last-known balances renders as soon as per-wallet cache files load, before any engine exists. +- One `EdgeCurrencyWallet` implementation. No parallel objects, no delegation layer, no polling. +- Engine-dependent methods keep working: they wait for the engine internally and reject if it fails or the wallet is deleted. +- First login (no cache) behaves exactly as today. +- The GUI changes required by the new wallet-readiness semantics ship in the same phase ([section 7](#7-gui-integration-edge-react-gui)). A core change that breaks FIO refresh or action-queue checks is not done. +- Target size: a few hundred core lines plus two small GUI patches, not 3,000 (size actuals below). + +Non-goals for phase 1: + +- Deferring or reordering engine creation. The engine pixie's imperative block runs when it runs today; only the GUI's wait for it is removed. Engines all still start; gentler scheduling was phase 2 ([section 8](#8-phase-history-and-709-disposition)). +- The #709 bridge-churn work. Tabled; see [section 8](#8-phase-history-and-709-disposition) for the one commit worth salvaging. +- yaob changes. Any diffing improvements belong in the yaob repo, not shimmed around in core. + +Size actuals, as implemented. The call's estimate was "a few hundred core lines plus two small GUI patches, not 3,000" and it covered phase 1 source only: + +| Area | Estimate | Actual | +|---|---|---| +| Core src, phase 1 | a few hundred lines | +379 / -50 across 7 files | +| Core src, phase 2 | excluded at estimate time ([section 8](#8-phase-history-and-709-disposition)) | +374 / -139 across 6 files, of which a large slice is re-indentation from the engine-pixie restructure ([section 8](#8-phase-history-and-709-disposition)) | +| Core tests | not counted | +829 lines, 22 deterministic cases plus fake-plugin harness work | +| GUI | two small patches | 3 patch sites + 1 new helper, +130 / -28 (phase 2 adds 10 of those lines) | + +Phase 1 src landed on budget. The apparent overrun is phase 2 (never in the estimate), tests (excluded by the "core lines" phrasing but more than half the diff), and diffstat noise from a re-indent. Total remains roughly 5x under the #703 anchor the estimate argued against. Phases 3 and 4 were scoped after this estimate ([section 8.1](#81-phase-3-account-startup-cache), [section 8.2](#82-followup-write-path-staleness-fixes-landed)). + +## 4. Design overview + +Three changes, all in existing files plus one new cleaner: + +1. A per-wallet cache file on the wallet's local disklet holding everything the GUI needs pre-engine: name, fiat code, enabled token IDs, balances (public wallet info stays in the existing `publicKey.json`). +2. The engine pixie reads that file first, before repo sync, and dispatches it into Redux. The `walletApi` gate drops its `engine != null` condition, so the wallet object emits as soon as cached (or freshly derived) state is in Redux. +3. `makeCurrencyWalletApi` loses its `engine` and `tools` constructor parameters. Methods that need them call internal `getEngine()` / `getTools()` waiters built on `ai.waitFor`. Methods that can serve from Redux keep doing exactly what they do today; the cache seeds Redux, so they need no changes at all. + +The wallet object is created once and never replaced. When the engine lands, the same object starts answering engine-backed calls. yaob reference stability is preserved by construction: the pixie output map and the `walletApi` object are the same ones master uses today. + +Work splits across two repos: + +| Repo | Deliverable | Scope | +|---|---|---| +| edge-core-js | [#733](https://github.com/EdgeApp/edge-core-js/pull/733) | Cache file + cleaner, hoisted load path, gate change, API waiters, cache saver ([section 5](#5-detailed-design-edge-core-js)); engine startup scheduler (phase 2, [section 8](#8-phase-history-and-709-disposition)) | +| edge-react-gui | [#6080](https://github.com/EdgeApp/edge-react-gui/pull/6080) | Engine-readiness gates for action-queue balance effects and FIO refresh, `waitForWalletOtherMethods` helper ([section 7](#7-gui-integration-edge-react-gui)) | + +How a cache-window method call resolves once both land (symbols as shipped): + +```mermaid +sequenceDiagram + box edge-react-gui (RN main thread) + participant GUI + end + box edge-core-js (WebView, across the yaob bridge) + participant W as walletApi (single object) + participant R as Redux + participant Q as engine-scheduler + participant E as engine + end + + GUI->>W: balanceMap getter + W->>R: read balanceMap (cache-seeded) + R-->>GUI: last-known balance, instantly + GUI->>W: makeSpend() + W->>Q: bumpEngineQueue(walletId) + Note over W: await getEngine() via ai.waitFor + Q->>E: makeCurrencyEngine (front of queue) + E-->>W: engine in pixie output + W->>E: engine.makeSpend() + E-->>GUI: EdgeTransaction +``` + +## 5. Detailed design: edge-core-js + +As landed, the data flow in [5.1](#51-cache-file) through [5.5](#55-save-path) shipped as specified, and cold-start byte-equivalence held under a regression-guard test ([case 1](#6-testing)). + +### 5.1 Cache file + +One file, `accountCache.json` on the account's local disklet, holding the account boot state and every wallet's cached UI state plus the public keys that used to live in each wallet's `publicKey.json`. Phases 1 through 6 wrote a `walletCache.json` per wallet alongside `publicKey.json`; phase 7 consolidated them, and the reasoning for the reversal is [decision 9.6](#96-one-account-cache-file-not-per-wallet-files). + +The file is written to two alternating slots (`accountCache.json`, `accountCache.2.json`), each carrying a monotonic `sequence`; the reader takes the newest slot that parses, which is the torn-write defense ([decision 9.10](#910-two-slot-alternation-because-the-disklet-has-no-rename)). + +Schema (`src/core/account/account-cleaners.ts`, `account-cache-file.ts`): + +```ts +export interface AccountCacheFile { + version: 2 + sequence: number + customTokens: EdgePluginMap + legacyWallets: boolean + walletStates: EdgeWalletStates + configOtherMethodNames: EdgePluginMap + wallets: { [walletId: string]: AccountCacheWallet } +} + +/** Everything the GUI needs to render one wallet before its engine exists. */ +export interface AccountCacheWallet { + walletInfo: { id: string; keys: object; type: string } + name: string | null + fiatCurrencyCode: string + enabledTokenIds: string[] + /** Integer strings. The `null` tokenId is spelled '' here. */ + balances: { [tokenId: string]: string } + /** Per tokenId, balances stripped. */ + addresses: { + [tokenIdKey: string]: Array<{ addressType: string; publicAddress: string }> + } + otherMethodNames: string[] +} +``` + +A version-1 file (no `wallets`) is upgraded on read rather than rejected, which is what sends an existing device through the migration in [section 5.2](#52-load-path). Balances are last-known values and are explicitly allowed to be stale. + +### 5.2 Load path + +In the engine pixie's async block (`currency-wallet-pixie.ts:78-201`), hoist a cache read to the top, before `addStorageWallet` and `loadTxFileNames`. Those two awaits currently sit ahead of `getPublicWalletInfo`, and on a slow disk or unsynced repo they would eat the win. + +```mermaid +flowchart TD + A[engine pixie starts] --> B[read publicKey.json + walletCache.json] + B -->|both valid| C[dispatch CURRENCY_WALLET_PUBLIC_INFO
+ CURRENCY_WALLET_CACHE_LOADED] + B -->|missing or cleaner rejects| D[cold path: no dispatch] + C --> G{walletApi gate
publicWalletInfo + nameLoaded} + G -->|opens| H[EdgeCurrencyWallet emitted
GUI renders from cache] + C --> E[addStorageWallet repo sync] + D --> E + E --> F[loadTxFileNames, keys, checkpoint, tokens file] + F --> I[makeCurrencyEngine
phase 2: queued via engine-scheduler] + I --> J[loadFiatFile / loadNameFile] + J --> K[authoritative values overwrite cached ones
same walletApi object, no replacement] + D -.gate waits for engine + name as on master.-> G +``` + +`CURRENCY_WALLET_CACHE_LOADED` (new action in `src/core/actions.ts`, payload: the wallet id plus the validated `WalletCacheFile`) seeds the wallet's Redux slice: + +- `balanceMap` from cached balances, with a dirty-wins guard: the reducer ignores a cached value for any token the engine or a user action has already touched, so cache never overwrites live data (an implementation finding; the first draft was silent on this guard). +- `name` and sets `nameLoaded = true` (`currency-wallet-reducer.ts:387`). +- `fiatCurrencyCode` and sets `fiatLoaded = true` (`currency-wallet-reducer.ts:290`). +- `enabledTokenIds`. + +The later `loadNameFile` / `loadFiatFile` / `loadTokensFile` dispatches overwrite the cached values with the authoritative synced-repo values, exactly as they overwrite initial state today. If the user renamed the wallet on another device, the cache shows the old name for a second or two, then corrects. Same class of staleness the GUI already tolerates for balances. + +If either file is missing or fails its cleaner (first login, schema bump, corruption), skip the dispatch and fall through. The gate then opens on the same conditions master uses today, so cold-start behavior is unchanged (regression-guarded by [test case 1](#6-testing)). + +### 5.3 Gate change + +`walletApi` pixie, `currency-wallet-pixie.ts:209`: + +```ts +// before +if (engine == null || publicWalletInfo == null || !nameLoaded) return +// after +if (publicWalletInfo == null || !nameLoaded) return +``` + +With the cache present, `publicWalletInfo` and `nameLoaded` are satisfied by the [load path](#52-load-path) and the wallet emits within one pixie tick of the cache read. Without the cache, `nameLoaded` flips only after `loadNameFile`, which still sits after engine creation, so the no-cache path is byte-for-byte today's behavior. + +`engineStarted` (`currency-wallet-pixie.ts:224-285`) keeps its own conditions. In phase 1 engines are created and started on the same schedule as master; phase 2 queues the creation block ([section 8](#8-phase-history-and-709-disposition)). + +### 5.4 makeCurrencyWalletApi changes + +`makeCurrencyWalletApi` (`src/core/currency/wallet/currency-wallet-api.ts:93`) drops `engine` and `tools` as parameters; the [cross-repo sequence diagram](#4-design-overview) shows how a GUI call rides these waiters across the yaob boundary. Internal waiters replace them, as landed in #733: + +```ts +/** + * Waits for a wallet's engine to exist. The wallet API object can + * exist before its engine does (a cache-seeded login), so + * engine-backed methods wait here instead of throwing. Bails out if + * the wallet is deleted mid-wait, and re-throws `engineFailure` so a + * broken plugin surfaces as a rejection instead of a hang. + */ +export function waitForCurrencyEngine( + ai: ApiInput, + walletId: string +): Promise { + // The caller needs this engine now, so skip the startup queue: + bumpEngineQueue(ai, walletId) + + return ai.waitFor((props: RootProps): EdgeCurrencyEngine | undefined => { + checkCurrencyWallet(props, walletId) + return props.output.currency.wallets[walletId]?.engine + }) +} + +/** + * The wallet API object exists before the engine does, + * so engine-backed methods wait for the engine internally. + * Bail out if the wallet is deleted mid-wait, and re-throw + * `engineFailure` so a broken plugin surfaces as a rejected + * method call instead of a hang. + */ +function getEngine(): Promise { + return waitForCurrencyEngine(ai, walletId) +} + +async function getTools(): Promise { + return await getCurrencyTools(ai, pluginId) +} + +/** + * Methods that write synced-repo files need the storage wallet, + * not the engine. The repo loads well before the engine, + * so this wait is much shorter than `getEngine`. + * A ready repo always wins: an unrelated engine failure must not + * break storage-backed methods, so the failure check only matters + * while the repo is still missing (the engine pixie died before + * `addStorageWallet`, so the repo is never coming). + */ +function getStorage(): Promise { + // The repo loads inside the queued startup work, so a caller + // waiting on storage wants this wallet at the front too: + bumpEngineQueue(ai, walletId) + + return ai.waitFor((props: RootProps): true | undefined => { + if (props.state.storageWallets[walletId] != null) return true + checkCurrencyWallet(props, walletId) + }) +} +``` + +This is the `waitForCurrencyWallet` pattern (`src/core/currency/currency-selectors.ts:31-53`) with `walletApi` swapped for `engine`, keeping both of its guards (`checkCurrencyWallet` throws for a deleted wallet or an `engineFailure`). `waitForCurrencyEngine` lives in `currency-selectors.ts` since the write-path followup, because the account-level engine methods (`getActivationAssets`, `activateWallet`, `getDisplayPrivateKey`, `getDisplayPublicKey`) now ride the same waiter instead of throwing pre-engine, reading the account's wallet list only after the wait so wallets that finish loading during it are included ([`987632ca`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/987632ca7d487b15ea71e851f14d5b6888ad209b)). The `bumpEngineQueue` calls are phase 2's priority hook ([section 8](#8-phase-history-and-709-disposition)); in phase 1 they are no-ops. + +Method classification. Everything that reads Redux state today keeps its current body and works pre-engine because the cache seeded that state. Everything that touches `engine.` gains an `await getEngine()`: + +| Serves from Redux (no code change) | Engine-gated (`await getEngine()`) | +|---|---| +| `balanceMap` / `balances` | `makeSpend`, `signTx`, `broadcastTx`, `saveTx` | +| `name`, `renameWallet`* | `sweepPrivateKeys`, `signMessage`, key export | +| `fiatCurrencyCode`, `setFiatCurrencyCode`* | `getMaxSpendable`, fee/quote paths | +| `enabledTokenIds`, `currencyConfig` accessors | `resyncBlockchain`, `dumpData`, staking / `stakingStatus` | +| `paused`, `changePaused`** | `getTransactions`, `getTransactionCount` ([decision 9.3](#93-gettransactions-stays-engine-gated)) | +| | `getAddresses`, `getReceiveAddress` (cache-assisted: served pre-engine from the per-tokenId address cache on any chain, then reconciled against the engine, [decision 9.4](#94-getaddresses-serves-the-cache-pre-engine-and-reconciles)) | +| | `otherMethods` (delegating stubs from cached names, [section 5.6](#56-yaob-and-reference-stability)) | + +\* Mutations that write synced-repo files (`renameWallet`, `setFiatCurrencyCode`, `changeWalletSettings`) gate on `getStorage()`, not the engine. They dispatch to Redux as today, so the GUI sees the change immediately through the normal update path; no manual `update()` calls, because the one wallet object flows through the existing pixie watcher. `changeEnabledTokenIds` instead waits for the builtin-token definitions (its filter would otherwise silently drop builtin ids on a warm boot) and, since the write-path followup, applies the caller's change as toggles over the current list rather than replacing it, so a call built against a stale cached list cannot erase enablement changes synced from another device ([`3bbc97fd`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/3bbc97fdde7fc8ad550c778f3a0ac921038c4e3c)). `changeWalletStates` at the account level waits for `walletStatesLoaded` for the same reason: a change diffed against cache-seeded records could no-op and then be silently reverted by the load ([`dc1cb707`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/dc1cb70763ef04ac7f030faa80983650c1abdb32)). The plugin/swap settings writers wait for `pluginSettingsLoaded`, merge into the freshly read file rather than rebuilding it from Redux, and serialize per account, so neither a racing load nor a concurrent local write can drop another plugin's settings ([`e9027da6`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/e9027da6eeff15ef4e993d621d0d169af1b98099)). + +\** `changePaused` before the engine exists reduces to updating Redux pause state that `engineStarted` already consults. The GUI's WalletLifecycle tolerates this ([section 7.1](#71-walletlifecycle-no-change-required)). + +The pre-engine serve gate is `opts.forceIndex == null && cachedAddresses.length > 0 && engine == null`. Phase 6's `allowCached` option was removed in phase 7 along with the `hasStableAddresses` gate, so every chain serves the cache and no caller opts in. Correctness moved from a gate to a reconcile: a query answered from the cache is recorded, re-asked of the engine in the background once it loads, and `rememberAddresses` emits `addressChanged` when the engine's first answer differs, so every consumer re-queries. An engine that confirms the cached answer emits nothing. Two details came out of review and are load-bearing rather than incidental: + +- At most ONE reconcile runs per token query. Several consumers can be served the same cached answer before the engine lands (the receive scene alone asks twice, since `getReceiveAddress` goes through `getAddresses`), and only the first engine reply can settle the correction. A second reply would find the correction already consumed and would then overwrite the stored addresses through the same dispatch WITHOUT emitting, which is precisely the silent staleness this mechanism exists to prevent. +- The cache is not served once `engineFailure` is set. A failed engine leaves `walletOutput.engine` null forever, so an unguarded cache serve would answer this method happily for the rest of the session and make a broken wallet indistinguishable from a healthy one on the one method that never reaches `getEngine()`. With the guard the query falls through and rejects with the engine's real error, matching every other engine-backed method. + +The rationale and its accepted tradeoff are [decision 9.4](#94-getaddresses-serves-the-cache-pre-engine-and-reconciles). + +The method bodies were mechanical as predicted; the getter surface was not (~160 lines the first draft's "mostly mechanical" claim never covered): `disklet`/`localDisklet` are backed by storage-wallet state that does not exist yet when a wallet emits from cache (fixed with lazily-built bridgified fallback disklets), `otherMethods` needed a stable bridgified `{}` plus post-engine caching for yaob reference rules, `publicWalletInfo` needed a Redux-backed getter for the [9.1](#91-new-walletcachejson-not-an-extended-publickeyjson) upgrade seam, and `getStorage`'s failure ordering (a ready repo wins over an unrelated `engineFailure`) was unspecified until review forced it. + +### 5.5 Save path + +One `cacheSaver` sub-pixie at the ACCOUNT level, the only writer of the cache (phases 1 through 6 had one per wallet; [decision 9.6](#96-one-account-cache-file-not-per-wallet-files) covers the reversal): + +- On each pixie update, compare the account slices against the last-saved snapshot, plus a stamp of every active wallet's cache-relevant Redux references (`addresses`, `balanceMap`, `enabledTokenIds`, `fiat`, `name`, `otherMethodNames`, `publicWalletInfo`). The slices are immutable, so an unchanged reference means unchanged content and this stays a reference scan. +- On change, write the next slot, throttled to at most one write per 5 s for the WHOLE ACCOUNT, trailing edge. A sync window where 194 engines all report balances costs one write, not 194. +- A wallet that has not finished loading its authoritative files is skipped, so a cold start never caches placeholder values. +- Wallets that are not running this session keep the entry they already had, so an archived wallet stays warm when it is turned back on; entries for wallets the account no longer has are dropped, so the file cannot grow without bound. +- Guard writes against post-logout, and stop after 3 consecutive failures. + +Staleness containment is structural: the cache is only ever read for wallet IDs that exist in the account's encrypted key state. A stale entry is dead data, never a resurrected wallet in the UI. + +Write-path staleness, as landed with the followup ([audit trail in section 8.2](#82-followup-write-path-staleness-fixes-landed)): every load that can race an in-window user change merges per field instead of whole-value. Enabled tokens keep a per-toggled-id dirty set that a racing token-file load merges over the loaded list, cleared once the token file write lands ([`3bbc97fd`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/3bbc97fdde7fc8ad550c778f3a0ac921038c4e3c)); account-level custom tokens keep a per-token-id dirty set with the same merge, cleared by a new `ACCOUNT_CUSTOM_TOKENS_SAVED` dispatch once the saver writes ([`7a809228`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/7a80922884fe4f8621764b56785d125efa9ee0fc)); plugin and swap settings track dirty plugin ids per map ([`e9027da6`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/e9027da6eeff15ef4e993d621d0d169af1b98099)). The account-level token saver never writes before `customTokensLoaded`, because it rebuilds the whole file from Redux and a cache-seeded map would delete tokens another device added ([`d8c48b20`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/d8c48b200a2c2b0e67331cda043c81979dab10dd)), and the account cache saver ref-compares `currencyWalletIds`, from which it derives its `legacyWallets` flag at write time ([`9b4fe78b`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/9b4fe78be5c9a8c0e219f3e4fb78650732573ef7)). + +### 5.6 yaob and reference stability + +Nothing new crosses the bridge. The GUI sees the same `EdgeCurrencyWallet` object with the same property surface; the pixie watcher's existing `update()` calls (`currency-wallet-pixie.ts:490-492`) propagate cached-then-live value changes the same way live-only changes propagate today. The account's `currencyWallets` map remains the pixie output object (`src/core/account/account-api.ts:650`), so William's `===` stability requirement holds without any merge getter. + +One visible seam: `wallet.otherMethods`. As landed with the phase-5 followup ([decision 9.8](#98-othermethods-names-are-cached-and-served-as-delegating-stubs)), it is an object of delegating stubs, one per known method NAME: each stub awaits the engine and forwards through the source object (preserving the plugin's `this`), resolving against the live engine on every call so a resync never leaves a stale capture, and rejecting cleanly when the loaded engine lacks the method. Names come from the wallet cache on a warm login (before the engine exists) and from the live engine otherwise, and they persist in `walletCache.json` on every save. The object keeps its identity as long as the known name set is unchanged, which is the common warm-boot case; when a name first appears (a cold login's engine landing, an upgraded cache with no names yet, a plugin adding methods) the getter rebuilds it as a NEW bridgified object, because yaob only serializes the properties an object had when it first crossed the bridge (verified empirically during review: `update()` cannot add properties to an existing facade). The pixie watcher's `update()` then delivers the swapped value, exactly how the old engine-swap propagated. Pre-engine with no cached names this is `{}`, the original guarantee, so property probes like `wallet.otherMethods.foo == null` stay safe. `currencyConfig.otherMethods` keeps the plugin's own object verbatim whenever the plugin is loaded (always the case today); the account cache's per-plugin name list only builds fallback stubs if plugin loading ever defers past the account emit. The GUI call sites patched in [section 7.3](#73-othermethods-one-patch-required) keep working, and on warm logins the FioActions TypeError class retires because the stub exists before the engine does. + +### 5.7 waitForCurrencyWallet semantics + +`waitForCurrencyWallet` resolves when `walletApi` exists. After this change that is pre-engine, so its meaning shifts from "wallet fully ready" to "wallet object exists," and `waitForAllWallets` shifts with it. Internal core callers (`src/core/login/keys.ts:383`, `account-api.ts:726`) want the object, not the engine, so they are unaffected. The GUI call sites were audited individually; findings and required patches are in [section 7](#7-gui-integration-edge-react-gui). Phase 2 made both this call and the internal waiters priority hooks into the engine queue ([section 8](#8-phase-history-and-709-disposition)). + +## 6. Testing + +Adopt the two determinism mechanisms from #703's test suite (the tests are the strongest part of that PR, though the harness credit was optimistic: the fake plugin still needed the engine gate, a creation-order hook, a public-key check gate, and latent fixes before these cases could exist): + +- An engine gate on the fake plugin (`createEngineGate`): block `makeCurrencyEngine` until the test releases it, so "during the cache window" is a controlled state, not a race. +- Saver throttle override (50 ms in tests). + +Cases: + +1. Cold login, no cache files: wallet emits only after engine + name load; identical to master (regression guard). +2. Warm login, engine gated: wallet emits with cached name, fiat, enabled tokens, balances; balance reads match the cache. +3. Engine released: live balances overwrite cached ones; no flicker back to cached values; the wallet object reference is unchanged. +4. `makeSpend` called during the window: pends, then completes after engine release. +5. Engine failure during the window: pending engine-gated calls reject with `engineFailure`. +6. Wallet deleted during the window: pending calls reject with the does-not-exist error; no dangling promise. +7. `renameWallet` during the window: Redux updates, GUI-visible name changes, cache file re-saves with the new name. +8. Logout during a pending throttled save: no write after logout. +9. Corrupt / old-version cache file: cleaner rejects, cold path runs, file is overwritten on next save. +10. Balance change after engine start: cache file updates within one throttle window (checks the saver actually fires). +11. `otherMethods` is `{}` pre-engine and carries the engine's methods (callable through the bridge) post-engine. + +As landed: the case list above was the right spine. All 11 exist in `test/core/currency/wallet/wallet-cache.test.ts`, plus 11 scheduler cases in `engine-scheduler.test.ts` from phase 2. + +As landed with phase 3, in `test/core/account/account-cache.test.ts` ([section 8.1](#81-phase-3-account-startup-cache)): + +12. Cache-coverage exhaustiveness: enumerate the `EdgeCurrencyWallet` properties the wallet-list scene renders pre-engine and assert each is either seeded by `WalletCacheFile` or present in the documented engine-gated set from [section 5.4](#54-makecurrencywalletapi-changes). A newly added wallet property fails the test until it is classified. This is the single-implementation replacement for the compile-time forcing function #703 got from its typed mirror object: there, TypeScript forced a caching decision per property; here, this test does. +13. Warm account login with the account cache present: `makeAccountApi` emits before `loadAllFiles` completes, wallet pixies start immediately, and the cold path (no account cache) is byte-identical to today's account boot. +14. Bulk seed dispatch count: a warm login with N wallets fires exactly two seeding dispatches (one `ACCOUNT_CACHE_LOADED`, one `CURRENCY_WALLETS_CACHE_LOADED`), every wallet gate opens from the bulk tick, and a wallet activated after login still seeds through its pixie's fallback read. + +As landed with the write-path followup, same file ([section 8.2](#82-followup-write-path-staleness-fixes-landed); each mirrors one of the audit's two-device sequences, with the repo-ahead-of-cache divergence produced deterministically by stalling the cache saver for a session): + +15. Custom token added in the boot window: the account token saver never writes the cache-seeded map, and the racing load merges per token id, so a token synced from another device and the in-window addition both reach disk and Redux. +16. Enabled-token toggle against a stale cached list: the call pends on the builtin definitions, lands as a toggle, and the racing token-file load preserves both the toggle and the other device's enablement. +17. Wallet-state change equal to the stale cache: the call waits for the wallet-state load and applies against the loaded record instead of silently no-oping and being reverted. +18. Settings write while Redux is behind the synced file: the writer merges into the freshly read file, so another device's settings survive on disk and a fresh login sees both. + +As landed with the phase-5 followup, same files: + +19. Pre-engine address serve: a warm wallet answers `getAddresses`/`getReceiveAddress` from the cache while its engine is still loading; the cached file carries the addresses per tokenId with balances stripped. +20. Rotating chains still gate: without the hint, a warm-login address query pends until the engine is released, exactly the pre-cache behavior. +21. A cached otherMethods name is callable before the engine exists; the call pends and then forwards. +22. A stale cached name rejects cleanly when the loaded engine lacks the method. +23. A version-1 cache file upgrades on read (warm boot preserved) and the stub set grows once the engine lands, through the bridge. +24. Config-level names persist in `accountCache.json` while the live plugin surface stays verbatim. +25. The cache-coverage classification gains a cache-assisted set (`getAddresses`, `getReceiveAddress`, `otherMethods`), so the exhaustiveness guard still forces a decision for new properties. + +As landed with the phase-6 followup, same file ([section 8.3](#83-phase-6-provisional-receive-address-for-rotating-chains-landed)): + +26. Address reconcile: on a warm login with the engine gated, `getAddresses({})` returns the cached address immediately and emits nothing; once the engine is released and derives a DIFFERENT address, the wallet emits `addressChanged` exactly once and the next query returns the engine's address. +27. Reconcile stays quiet on agreement: an engine that derives the same address the cache served emits no `addressChanged`. +28. `forceIndex` bypasses the cache: `getAddresses({ forceIndex: 0 })` on a gated wallet still waits for the engine, since a forced-index query wants a specific fresh address. +28. GUI affordance render (`RequestScene`): a rotating-chain wallet whose engine-confirmed `getAddresses` never resolves renders the cached address immediately and, once the grace window elapses, shows the "checking for your latest address" row. + +Verification. Performance is graded on physical hardware only; sim timings are not evidence. Measured on a Galaxy S9 (Android 10, release builds, the 194-wallet account, warm login; full method and raw tables in the [physical-device findings doc](https://gist.github.com/j0ntz/63c36e14285a638bc8874cb74e11d58e), [pinned](https://gist.github.com/j0ntz/63c36e14285a638bc8874cb74e11d58e/42ab2ddacf097d093ee8315f5fe750b7cc947d9f)): v2 seeds balances in 341 ms mean from PIN entry to the account-cache emit (range 305-411 ms over five iterations) with zero per-wallet file reads (the bulk loader), and the dispatch/store-transit epsilon is below measurement resolution; develop on the same device pays a ~57 s serial gap after account login plus ~23 s reading the 194 wallet files before balances render; #703 seeds in the same sub-second envelope as v2. Functional behavior (no timing claims) was verified in-app on the iOS sim, evidence on the PRs ([phases 1-2](https://github.com/EdgeApp/edge-core-js/pull/733#issuecomment-5015250816), [gui screenshots](https://github.com/EdgeApp/edge-react-gui/pull/6080), [phase 4](https://github.com/EdgeApp/edge-core-js/pull/733#issuecomment-5040277532), [phase 5](https://github.com/EdgeApp/edge-core-js/pull/733#issuecomment-5052051090)): the full wallet list renders from cache before any engine exists; wallets drain through the concurrency-8 startup queue with tap-to-front prioritization taking effect mid-drain; an in-app enabled-token round trip wrote through the toggle path and persisted through a cache-seeded relaunch; the receive scene's address query lands in the wallet's cache file keyed by tokenId; and the FioActions warm-boot TypeError (17 occurrences per session on phase-4 builds) dropped to zero once cached names exposed pre-engine stubs. + +## 7. GUI integration: edge-react-gui + +Every GUI surface that consumes wallet readiness was audited against the new semantics; the [cross-repo sequence diagram](#4-design-overview) shows what these surfaces observe from the far side of the yaob boundary during the cache window. Findings, with the required patches marked (the audit turned out incomplete: one caller surfaced only at runtime, [section 7.3](#73-othermethods-one-patch-required)): + +### 7.1 WalletLifecycle: no change required + +`src/components/services/WalletLifecycle.ts` batches wallet boot (8 concurrent on iOS, 3 on Android; line 27) by walking the sorted list and calling `wallet.changePaused(false)` on paused wallets (119-134, 180). Its only engine-dependent wait is a `watch('syncRatio')` bounded by a 5-second timeout per boot slot (190-200), so wallets that appear pre-engine degrade to "unpause batch, 5 s, next batch" in the worst case; no loop, no stall. The dedup set at line 129 prevents re-queueing while a boot is in flight. Pre-existing (not introduced by this change): a rejected `changePaused` leaks its boot slot (202-204); worth fixing opportunistically, not a blocker. + +### 7.2 waitForCurrencyWallet call sites: one patch required + +- `controllers/action-queue/runtime/checkActionEffect.ts:122-123` reads `wallet.balanceMap` immediately after awaiting the wallet. Pre-engine it now sees cached (possibly stale) balances instead of live ones, which could mis-evaluate a balance effect. **Patch: gate the effect check on engine readiness (`syncRatio` watch), matching what the loan flow already does in `waitForLoanAccountSync.ts:8-13`.** Cached data cannot satisfy the gate because sync status is excluded from the cache file ([section 5.1](#51-cache-file)). +- `controllers/action-queue/runtime/evaluateAction.ts:204-326,489` and `controllers/loan-manager/redux/actions.ts:126,229` feed the wallet into borrow engines and `broadcastTx`/`makeSpend`. Those calls now pend on `getEngine()` internally and then complete; correct behavior, just later. No change. +- `controllers/action-queue/display.ts`, `push.ts`, `CreateWalletCompletionScene.tsx:145` read only config/info or set UI status. Engine-free. No change. + +### 7.3 otherMethods: one patch required + +No wallet-list-render path touches `otherMethods`. All uses sit inside specific flows (FIO, migrate/sweep, staking adapters, WalletConnect, resync menu). The call sites that can race the cache window: + +- `Services.tsx:84-94` runs `refreshAllFioAddresses` after `await account.waitForAllWallets()`, and `FioActions.tsx:39` then calls `fioWallet.otherMethods.fetchFioAddresses(...)`. With `waitForAllWallets` now resolving pre-engine, that is a method call on `{}`. **Patch: wait for engine readiness per FIO wallet via the `waitForWalletOtherMethods` helper (added in #6080; 10-minute bound), skipping and retrying wallets that never become ready.** Implementation also caught `FioService.ts`'s periodic expired-domain check, which the original audit missed and which surfaced as a live red-alert crash on the sim; fixing it took the `waitForWalletOtherMethods` helper (42 lines, its 10-minute bound sized for a 194-wallet account), a skip-and-retry rework, and a pre-existing stuck-latch fix. "Two small GUI patches" was an audit result, and the audit was incomplete. +- `WalletConnectService.tsx:51-54` only null-probes `wallet.otherMethods.parseWalletConnectV2Payload`, which is safe against the stable `{}` guaranteed in [section 5.6](#56-yaob-and-reference-stability). No change. + +### 7.4 Sync indicators: no change required + +`WalletSyncCircle.tsx` clamps `syncRatio` below 0.05 to a minimum ring (34-36) and hides it only above 0.999 (75), so a cached-balance wallet at ratio 0 renders as a normal syncing wallet, balances visible, ring spinning. `useAccountSyncRatio.tsx:54` treats missing ratio as 0 for the account progress bar. `BuyCrypto.tsx:90` and `useIsAccountFunded.ts:21` already treat `< 1` as unsynced, which remains the right conservatism for spend paths. + +### 7.5 Receive scene: no change required + +`RequestScene.tsx` calls `wallet.getAddresses({ tokenId })` on mount and subscribes to the wallet's `addressChanged` event, re-running that query whenever it fires. That is exactly the contract [decision 9.4](#94-getaddresses-serves-the-cache-pre-engine-and-reconciles) provides: the first call is answered from the cache so the QR renders immediately on a warm login, and if the engine goes on to derive a different address the wallet emits `addressChanged` and this handler picks it up. Phase 6's provisional affordance and its `allowCached` opt-in were reverted in phase 7 ([decision 9.9](#99-the-provisional-receive-affordance-reverted)); no scene code replaced them. + +## 8. Phase history and #709 disposition + +Phase 2 candidates as designed, followed by how the shipped mechanism diverged: + +- Gentle engine scheduling: stagger or defer engine startup, and prioritize a wallet's engine when the user taps it (via the waiter hooks from [section 5.4](#54-makecurrencywalletapi-changes) and [section 5.7](#57-waitforcurrencywallet-semantics)). Every engine still starts eventually; wallets must sync, receive, and update balances without being opened. What changes is only how aggressively starts are packed into the seconds after login. +- The one #709 commit worth keeping: the `balanceMap` reducer returning the existing `Map` when the value is unchanged. William called this one correct on the call. Land it as its own small PR. +- The rest of #709 (throttle 50 to 200 ms, 300 ms debounce, `hasYaobVisibleChange` field lists) is tabled. William's assessment: it duplicates yaob's own `===` diffing, and aggressive "nothing changed" suppression has previously caused stale-balance support tickets. Re-measure after phase 1 lands; most of the churn it fights should be gone once engines are not racing the login render. +- If bridge traffic still hurts, improve diffing in yaob itself rather than shimming around it in core. + +What actually shipped diverged in mechanism, not intent: + +- The sketch said "stagger or defer `engineStarted`", but the login jank lives in engine creation (the heavy pixie block), so the shipped design queues that block instead (`engine-scheduler.ts`, concurrency 8), with a cold-bypass invariant keeping first login byte-identical. +- "waitForCurrencyWallet becomes the natural hook" assumed one hook point; `account.waitForCurrencyWallet` is a watch-based reimplementation separate from the internal selector, so the hook landed in both, and the queue interacts with [section 5.4](#54-makecurrencywalletapi-changes)'s own waiters (a rename or spend during the window would otherwise wait behind the whole queue), which forced priority bumps in `getEngine`, `getStorage`, and `changePaused(false)` as well. +- Pooling created failure classes the first draft never had to discuss: slot starvation (one wedged wallet permanently shrinking the pool; fixed with a 30s watchdog that force-releases and temporarily over-admits, degrading to master's unbounded behavior) and lost one-shot priority signals (a bump arriving before the wallet reaches the queue; fixed with sticky bumps carrying a 30s TTL, since post-startup engine calls bump constantly and would otherwise mark every wallet "asked for" by the next login). Both came out of adversarial review, not this spec. +- redux-pixies' `waitFor` evaluates against a stale `propsCache` after pixie destroy, so deletion-while-queued needed the engine pixie converted to `{update, destroy}` form with a destroy flag; most of the phase 2 diffstat is that re-indent. + +### 8.1 Phase 3: account startup cache + +Queued 2026-07-20 after auditing #703 against the shipped v2; landed 2026-07-21 on [#733](https://github.com/EdgeApp/edge-core-js/pull/733) as designed below, with the deferred loads hardened in review (bounded retry, terminal-failure plumbing for every repo waiter, and pre-storage fallbacks for the account disklets). The finding: v2 never captured #703's account-level win. On this branch the account boot is byte-identical to master, and no wallet can even read its `walletCache.json` until the whole chain completes: `waitForPlugins` then `loadBuiltinTokens` then account repo `addStorageWallet` then `loadAllFiles` (`loadAllWalletStates` + `loadCustomTokens` + `reloadPluginSettings`) then `makeAccountApi` (`account-pixie.ts:93-107`), because wallet pixies spawn from `currencyWalletIds`, which stays empty until `ACCOUNT_KEYS_LOADED` fires inside `loadAllWalletStates` (`currency-pixie.ts:36-38`, `account-reducer.ts:164-190`, `account-files.ts:104,134`). #703 proved the bypass works: its cache path awaits only `waitForPlugins` and the account repo sync, emits the account, and runs `loadBuiltinTokens` + `loadAllFiles` fire-and-forget (#703 `account-pixie.ts:178-202`). A large share of #703's measured 5x plausibly lives in this bypass, not in its wallet mirroring. + +The port, v2-style (same seed-then-overwrite pattern as [section 5.2](#52-load-path), no mirror objects): + +- One account-level cache file, per #703's one-file layout: the per-wallet files stay for wallet UI state, but the account boot inputs are cached in a single `accountCache.json` on the account's local disklet, holding what `ACCOUNT_KEYS_LOADED` and its siblings would produce: wallet states (archived/hidden/sortIndex), the wallet-info list needed to populate `currencyWalletIds`, custom token definitions, and plugin settings. Constraint carried from [decision 9.2](#92-privacy-coins-get-the-same-plaintext-cache-as-every-other-chain)'s analysis: nothing beyond what the plain disklet already exposes; private key material never enters the file. +- A new `ACCOUNT_CACHE_LOADED` action seeds that state (including `keysLoaded`) immediately after `waitForPlugins`, so `makeAccountApi` emits and wallet pixies start their cache reads without waiting for repo sync or file loads. +- `loadBuiltinTokens` and `loadAllFiles` then run deferred and overwrite the seeded state authoritatively, with the same dirty-wins guards the wallet reducer already uses, and the account cache re-saves through a throttled saver watching the relevant account slices (learning from #703's custom-token bug: the saver's dirty set must include account-level `customTokens`, the exact source its saver missed). +- Cold path (no account cache): today's boot, unchanged, regression-guarded by [test case 13](#6-testing). +- Bulk seeding, one store tick for all wallets: the per-wallet cache reads move out of the individual wallet pixies into one loader that runs as soon as the account cache seeds `currencyWalletIds`. It reads every wallet's `publicKey.json` + `walletCache.json` concurrently and dispatches a single `CURRENCY_WALLETS_CACHE_LOADED` action carrying all seeds (public info + UI state per wallet), so a warm login costs two seeding dispatches total (account, then bulk wallet) instead of two per wallet, and every pixie and watcher evaluates once against fully-seeded state instead of ~2N times. This is batching, not notification suppression: every consumer is still notified, once, with the final state, so it carries none of the missed-update risk that tabled #709 ([section 8](#8-phase-history-and-709-disposition)). The per-wallet read inside the wallet pixie stays as the fallback for wallets activated after login and for the no-account-cache cold path. Per-wallet files remain the write targets; the file-layout tradeoffs and the trigger for revisiting them are [decision 9.6](#96-per-wallet-cache-files-with-a-bulk-loader-not-one-account-blob). The one store transit that remains after bulk seeding is pinned to this architecture, not tunable: the wallet object's getters read Redux, and feeding them from a second non-Redux path would reintroduce a shadow copy. Its plausible ceiling is one reducer pass plus one watcher evaluation for a single batched action. +- Housekeeping in the same phase: drop the duplicate `publicKey.json` read inside the queued engine block (`currency-wallet-pixie.ts:752`), and add [test cases 12-14](#6-testing). +- Validation, ran 2026-07-21 on a physical Galaxy S9 ([findings doc](https://gist.github.com/j0ntz/63c36e14285a638bc8874cb74e11d58e)): the cross-build A/B against #703 and develop on the same 194-wallet account measured v2's warm seed at 341 ms mean (PIN entry to account-cache emit), the file-read window at zero (so [decision 9.6](#96-per-wallet-cache-files-with-a-bulk-loader-not-one-account-blob)'s revisit trigger did not fire), and the dispatch window below measurement resolution; v2 and #703 are within noise of each other, versus develop's ~57 s gap plus ~23 s of file reads. + +```mermaid +flowchart LR + subgraph now[v2 today] + A1[waitForPlugins] --> B1[loadBuiltinTokens] --> C1[account repo sync] --> D1[loadAllFiles] --> E1[makeAccountApi] --> F1[wallet cache reads begin] + end + subgraph p3[with phase 3, warm login] + A2[waitForPlugins] --> B2[read accountCache.json
ACCOUNT_CACHE_LOADED] --> E2[makeAccountApi] --> F2[bulk read all wallets' cache files
one CURRENCY_WALLETS_CACHE_LOADED] --> G2[every wallet gate opens
from a single store tick] + B2 -.deferred, overwrites seeded state.-> D2[repo sync + loadAllFiles + builtinTokens] + end +``` + +### 8.2 Followup: write-path staleness fixes (landed) + +Queued 2026-07-21 from the [write-path audit](https://gist.github.com/j0ntz/94c3e64779a92e8a1ae1896f4d7d3d6f)'s confirmed findings and landed the same day on [#733](https://github.com/EdgeApp/edge-core-js/pull/733). All fixes reuse existing patterns ([decision 9.7](#97-per-concern-load-gates-not-one-loadallfiles-promise)); no new surfaces: + +1. The account token saver is gated on `customTokensLoaded`, closing the missing gate that could permanently delete a cross-device custom token ([`d8c48b20`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/d8c48b200a2c2b0e67331cda043c81979dab10dd)). +2. Whole-valued dirty guards became per-field merges: custom tokens per token id, cleared once the saver writes them to disk ([`7a809228`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/7a80922884fe4f8621764b56785d125efa9ee0fc)); enabled tokens per toggled id, with `changeEnabledTokenIds` applying the caller's change as toggles over the current list ([`3bbc97fd`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/3bbc97fdde7fc8ad550c778f3a0ac921038c4e3c)); plugin/swap settings per plugin id, with the writers merging into the freshly read file instead of rebuilding it from Redux, serialized per account ([`e9027da6`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/e9027da6eeff15ef4e993d621d0d169af1b98099)). +3. `changeWalletStates` gates on `walletStatesLoaded`, so the written record bases on loaded state ([`dc1cb707`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/dc1cb70763ef04ac7f030faa80983650c1abdb32)). +4. The account-level engine methods (`getActivationAssets`, `activateWallet`, `getDisplayPrivateKey`, `getDisplayPublicKey`) wait via the shared `waitForCurrencyEngine` selector instead of throwing pre-engine, reading the wallet list after the wait ([`987632ca`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/987632ca7d487b15ea71e851f14d5b6888ad209b)). +5. `currencyWalletIds` joined the account-cache saver's ref-compare set (theoretical, one line; [`9b4fe78b`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/9b4fe78be5c9a8c0e219f3e4fb78650732573ef7)). + +The four audit scenarios are regression-guarded by [test cases 15-18](#6-testing) ([`16ff1fef`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/16ff1fefdacb79059edd74138b66dfc538284246)); reaching them deterministically needed the fake sync server to accept the hash-suffixed store routes it hands out ([`1e2d4808`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/1e2d48086311ed27db5b8aed251085ba629668e5)). + +### 8.3 Phase 6: provisional receive address for rotating chains (landed) + +Queued 2026-07-23 from review feedback that the receive/QR path always waits on the engine, felt slow on rotating chains. Landed across three repos: the core `allowCached` opt-in ([section 5.4](#54-makecurrencywalletapi-changes)), the receive-scene provisional UI (since reverted, [section 7.5](#75-receive-scene-no-change-required)), and the plugin flags in edge-currency-accountbased. It is a deliberate product change: it accepts informed address reuse for a user who acts within the pre-engine window in exchange for an instant receive screen, made visible by the affordance rather than silent ([decision 9.9](#99-the-provisional-receive-affordance-reverted)). Coverage: unit tests assert the rotating-chain `allowCached` serve, the programmatic gate (a plain query still waits), that `forceIndex` bypasses the cache even with `allowCached`, and (gui side) that the affordance row renders on a rotating chain once the grace window elapses while the engine-confirmed query stays pending ([test cases 26-28](#6-testing)). Also folded in a review finding one step removed from phase 6: a terminal deferred-boot failure left `bulkWalletSeedPending` stuck true, wedging every wallet pixie ([`c532ebee`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/c532ebee), clearing the flag on `ACCOUNT_LOAD_FAILED` alongside the existing `ACCOUNT_KEYS_LOADED` backstop). + +### 8.4 Phase 7: revert the provisional surface, consolidate the cache file (landed) + +Two ordered parts, the revert first so the storage change landed on a smaller surface. + +Reverted: the receive-scene provisional affordance, the core `allowCached` opt-in, and the `EdgeCurrencyInfo.hasStableAddresses` gate; [edge-currency-accountbased#1076](https://github.com/EdgeApp/edge-currency-accountbased/pull/1076) was closed and dropped from scope. Serving a cached address silently is now an accepted edge case ([decision 9.9](#99-the-provisional-receive-affordance-reverted)). The commits were dropped from the branches rather than reverted on top, so the history never contains the add-then-remove cycle. + +The revert exposed a gap the design had assumed away. The surviving requirement was that consumers pick up a changed address once the engine loads, and nothing did: `addressChanged` is only emitted when a running engine reports a rotation, and the cached serve returned before the engine was ever asked, so `rememberAddresses` never ran either. A consumer would have held the cached address indefinitely. Phase 7 therefore added core-side reconciliation ([section 5.4](#54-makecurrencywalletapi-changes)) rather than only deleting code, which fixes every consumer instead of the one scene the affordance covered. + +Consolidated: `accountCache.json` absorbed every wallet's cache and its `publicKey.json`, reversing [decision 9.6](#96-one-account-cache-file-not-per-wallet-files). What the measurements said: + +| | Before | After | +| --- | --- | --- | +| Boot reads (194-wallet account) | 389 files, 93.8 KiB | 1 file, 105.5 KiB | +| Throttled writers | one per wallet (194) | one per account | +| Writes per 5 s sync window | up to 194 | 1 | +| On-disk cache | 1387 files, 330.1 KiB | 2 slots, 211.0 KiB | + +The read-side reduction is structural, not a latency win: [section 8.1](#81-phase-3-account-startup-cache)'s S9 A/B had already measured the per-wallet read window at zero, so those 389 reads were never on the critical path. The write-side consolidation is the actual benefit. The tradeoff is that every write is now the whole account rather than one wallet's ~276 B. + +Torn writes needed a different answer than the design assumed: the disklet exposes no rename on either platform, so write-temp-then-rename is not implementable. iOS already writes atomically; Android does not. Generations therefore alternate between two slots ([decision 9.10](#910-two-slot-alternation-because-the-disklet-has-no-rename)). Migration: a version-1 file sends a device to the per-wallet reads once, and the saver folds them into the consolidated file; the old per-wallet files are left in place as a recovery net and are no longer written. Coverage: [test cases 26-28](#6-testing) for the reconcile and the `forceIndex` bypass, plus a torn-write recovery case and a pre-consolidation upgrade case. + +## 9. Decisions + +[Decisions 9.1 through 9.5](#91-new-walletcachejson-not-an-extended-publickeyjson) all survived implementation and three review rounds unchanged; the reviews attacked stale cached balances, the publicKey seam, and the otherMethods contract, and each rejection cited this document. + +### 9.1 New walletCache.json, not an extended publicKey.json + +Both options came out of the call. Chosen: a new sibling file. + +- `asPublicKeyFile` (`currency-wallet-cleaners.ts:397`) is tied to a re-derivation upgrade path: `getPublicWalletInfo` discards the file when `tools.checkPublicKey` rejects it (`currency-wallet-pixie.ts:585-591`). Entangling UI state with key-cache invalidation means a key-format upgrade silently drops names and balances, and any schema change to UI state risks the key cache. +- A separate versioned cleaner makes UI-state schema evolution a one-line version bump with a well-defined fall-through ([section 5.2](#52-load-path), [test case 9](#6-testing)). +- Cost of the extra file: one additional small read per wallet from the same disklet, issued in the same step. Negligible against the engine-load savings this design exists to bank. +- The alternative (one file, "let it have the wrong name") saves that read and one cleaner. It is strictly workable; nothing else in the design changes if review prefers it. + +### 9.2 Privacy coins get the same plaintext cache as every other chain + +Concern: balances for Monero, Zcash, and Pirate Chain are not derivable from public on-chain data, so writing them to the plain disklet looks like a new exposure. Investigation shows it is not: `publicKey.json` already stores each Monero wallet's private view key (`moneroViewKeyPrivate`, edge-currency-monero `MoneroTools.ts:72-88`) and each Zcash/Pirate Chain wallet's unified viewing key (edge-currency-accountbased `ZcashTools.ts:142-160`, `PiratechainTools.ts:133`) on the same unencrypted `walletLocalDisklet`. Anyone who can read `walletCache.json` can already read the viewing keys and compute the balances. Encrypting only the new file would add code and protect nothing. + +- Chosen: uniform plaintext caching, no per-chain branching in phase 1. +- Alternative worth doing separately if the exposure itself is judged unacceptable: move both files to `walletLocalEncryptedDisklet` (created alongside the plain one, `currency-wallet-pixie.ts:95-100`) for chains that declare a privacy flag. That is a hardening task about viewing keys, not about this cache, and it should not gate this work. + +### 9.3 getTransactions stays engine-gated + +Transaction history is not in the cache file. + +- The tx list state loads from per-tx disk files inside the engine pixie (`loadTxFileNames`, `currency-wallet-pixie.ts:104`), after repo sync; serving it pre-engine would mean either caching history (a large, fast-growing file that duplicates what the tx files already persist) or reordering those loads, both well beyond a few hundred lines. +- The login-path UI does not need it: the wallet list renders names and balances; history renders after tapping into a wallet, by which time the engine is loading anyway, and phase 2's tap-prioritization shrinks that wait further. +- Alternative (cache the most recent N transactions for instant detail-scene paint) is a follow-up candidate once real-device timings show whether the detail scene wait is noticeable. + +### 9.4 getAddresses serves the cache pre-engine, and reconciles + +Originally: addresses were not cached at all, because address correctness is engine policy - UTXO chains rotate fresh addresses, and a cached "last known fresh address" can silently promote address reuse. Phase 5 then cached them behind an `EdgeCurrencyInfo.hasStableAddresses` hint, so only chains that declare their addresses never rotate served the cache. Phase 7 removed the hint: every chain serves its cached address pre-engine. + +- What changed is the product decision, not the analysis. Serving a possibly-superseded address silently was reviewed and accepted as a tolerable edge case, which removes the reason for a per-chain gate and for the plugin-side flag work. +- Reuse is bounded by reconciliation rather than by a flag. Any query answered from the cache is re-asked of the engine in the background, and the wallet emits `addressChanged` when the answers differ, so consumers re-query and land on the engine's address. An engine that agrees stays silent, which is the common case. +- `forceIndex` still waits for the engine, since a caller naming an index wants a freshly derived address that only the engine can produce. +- The cache is keyed per tokenId and stores no balances, so a token's answer can never masquerade as the parent chain's. + +### 9.5 Scope stays on the wallet path; account-level latency untouched + +`makeAccountApi` still waits for plugin load, account repo sync, and `loadAllFiles` (`account-pixie.ts:52-118`). The 5x QA measurement came from the wallet path, and the account path's blockers are login cryptography and encrypted-repo sync, which this file-cache pattern does not apply to without a security design of its own. If phase 1 device timings show account setup dominating on some hardware, that becomes its own investigation rather than scope creep here. + +### 9.6 One account cache file, not per-wallet files + +The login read path could be one account-level file, N per-wallet files, or a hybrid. Originally chosen: per-wallet `walletCache.json` files as the write targets, with [section 8.1](#81-phase-3-account-startup-cache)'s bulk loader as the only login-path reader. Reversed in phase 7: one `accountCache.json` holds the account boot state and every wallet's, public keys included. + +- The read path is not what reversed it, and the original reasoning here was sound. [Section 8.1](#81-phase-3-account-startup-cache)'s S9 A/B measured the per-wallet file-read window at zero, so this decision's own revisit trigger never fired. Those reads run after the account has already emitted, so collapsing 389 of them into 1 removes work that was never on the critical path. Anyone expecting the consolidation to shorten warm login should read that measurement first. +- The write path reversed it. Per-wallet files imply per-wallet savers, so a 194-wallet account carries 194 independent 5 s throttles, and the post-login sync window (every engine reporting balances at once) has all of them writing concurrently. One file means one throttle for the whole account: at most one write per window no matter how many wallets changed. +- The three costs this decision cited against a blob are real, and were addressed rather than argued away. Write amplification: each write is now the whole account (105.5 KiB measured on the 194-wallet account) instead of ~276 B per changed wallet, traded for roughly 194x fewer bridge calls per window. Corruption blast radius: generations alternate between two slots and the reader takes the newest that still parses ([decision 9.10](#910-two-slot-alternation-because-the-disklet-has-no-rename)), so an interrupted write costs one generation of staleness rather than every wallet's warm boot. Dirty-set breadth, the deepest objection: the saver's change stamp is derived mechanically from the same wallet fields the writer serializes, so the enumeration cannot silently drift the way #703's hand-maintained global dirty set did. +- Alternative, the hybrid fold this decision designated: keep per-wallet write targets and periodically fold them into a read-optimized blob. Rejected now for the same reason the trigger never fired. It optimizes the read path, which measured at zero, while leaving the per-wallet write storm untouched. +- Retained from the original: staleness containment is still structural. The file only carries wallets the account still has, and entries for deleted wallets are pruned on each write, so a consolidated file cannot resurrect a wallet any more than a leftover per-wallet file could. + +### 9.10 Two-slot alternation, because the disklet has no rename + +A single boot-critical file needs a torn-write defense. The design called for the usual write-temp-then-rename. The disklet cannot do it: its interface is `delete`, `getData`, `getText`, `list`, `setData`, `setText`, and the native modules expose exactly those six (`ios/Disklet.m`, `android/.../DiskletModule.java`). There is no rename to call. + +- iOS does not need one: `writeFile` uses `[data writeToFile:options:NSDataWritingAtomic]`, which already writes to a temporary file and renames. A kill mid-write cannot tear the file there. +- Android does need one and lacks it: `writeFile` opens a plain `FileOutputStream` over the target, truncating it first. A kill part-way through leaves a short file. +- Chosen: alternate generations between `accountCache.json` and `accountCache.2.json`, each stamped with a monotonic `sequence`, and read the newest slot that parses. A torn write only ever damages the slot being written, so the other slot is a complete previous generation. This needs no rename and costs one extra file on disk. +- Alternative, add a rename to the disklet package: correct, and worth doing eventually, but it means publishing a dependency for a defense the two-slot scheme already provides. +- Alternative, accept the tear and fall back to the per-wallet files: that is the migration fallback, not a defense, and it decays as those files age (they are no longer written). + +### 9.7 Per-concern load gates, not one loadAllFiles promise + +With authoritative loads deferred behind cache seeding ([section 8.1](#81-phase-3-account-startup-cache)), writes to core-owned state must not race their own loads. Two shapes were considered: save the `loadAllFiles` promise and block every write until it resolves (proposed in the 2026-07-21 design discussion), or gate each write on exactly the load it depends on. Chosen: per-concern gates. + +- Each write awaits only its dependency: wallet-file writes await the wallet repo, token file writes await `tokenFileLoaded`, settings writes await `pluginSettingsLoaded`, wallet-state writes await the account repo. A rename never waits on custom tokens. +- Every waiter rejects terminally when the deferred boot loads fail repeatedly (`ACCOUNT_LOAD_FAILED` after 3 retries), so a broken disk produces errors, not hangs. A global promise needs the same failure plumbing while serializing unrelated concerns. +- The alternative (one global gate) is correct but strictly coarser; rejected for re-creating the old "wait for everything" model on the write path. +- The [write-path audit](https://gist.github.com/j0ntz/94c3e64779a92e8a1ae1896f4d7d3d6f) validates the shape and found that where gaps exist they are individual missing gates or wrong-granularity merges, never the per-concern structure itself; the fixes landed with the write-path followup ([section 8.2](#82-followup-write-path-staleness-fixes-landed)). + +### 9.8 otherMethods names are cached and served as delegating stubs + +Supersedes the original [section 5.6](#56-yaob-and-reference-stability) choice (a bridgified `{}` swapped for the engine's methods when it lands). Chosen with the phase-5 followup: cache the method NAMES per wallet (and per plugin at the account level) and expose one delegating stub per name ([`e1f7c428`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/e1f7c428e9d5dfc6bf4cb3de87ba0655a3a4a8ab), hardened in [`bb3a1690`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/bb3a16903a63493cff631009f100c0c5501ca3d0)). + +- The [section 5.4](#54-makecurrencywalletapi-changes) waiters made stubs nearly free: a stub is `await getEngine()` plus a property lookup, the same shape every engine-backed method already has. With names cached, a warm login exposes a callable method surface before the engine exists, and the FioService/FioActions crash class (calling a method on the pre-engine `{}`) retires on warm boots. +- Names-only is deliberate: caching method names leaks nothing (they are plugin code, not user data), needs no schema per method, and a stale name degrades to a clean rejection at call time. +- Rejected alternative, keep the swap model and patch each GUI caller: the phase-1 GUI audit already missed a caller once ([section 7.3](#73-othermethods-one-patch-required)); making the surface callable-by-name removes the whole class instead of chasing call sites. +- Rejected alternative, one truly permanent mutable object: yaob facades cannot gain properties after first crossing (verified empirically during review), so growth requires an object swap; the shipped model swaps only when the name set grows, keeping identity in the common warm case. +- Config level: the live plugin object stays exposed verbatim (identical `this` and non-function properties); cached names only feed fallback stubs if plugin loading ever defers past the account emit. +- Reopen if: yaob gains property-addition support (the growth swap can then disappear), or a plugin ships otherMethods whose names change per session (the cache would churn; none does today). + +### 9.9 The provisional receive affordance, reverted + +Phase 6 shipped a visible affordance on the receive scene: on a rotating chain the cached address rendered immediately with an inline "checking for your latest address" row and a spinner, reconciling once the engine confirmed. The reasoning was that informed reuse beats a spinner, and that the tradeoff should be visible rather than silent. + +Reverted in phase 7, along with the `allowCached` opt-in that fed it and the plugin flags in [edge-currency-accountbased#1076](https://github.com/EdgeApp/edge-currency-accountbased/pull/1076) (closed). + +- The product call went the other way: serving the cached address silently was accepted as a tolerable edge case, which removes the thing the affordance existed to disclose. +- What survives is the requirement the affordance was one way of meeting: a consumer must end up on the engine's address when it differs. That now lives in the core for every consumer rather than in one scene ([decision 9.4](#94-getaddresses-serves-the-cache-pre-engine-and-reconciles)), which is a better place for it. The receive scene already subscribes to `addressChanged` and needed no change. +- Worth recording, because the revert makes it easy to lose: without the reconcile the revert would have been a silent bug. Nothing emitted `addressChanged` when an engine finished loading and disagreed with the cache, and the cached serve returned before the engine was ever asked, so a consumer would have latched the cached address indefinitely. The check that found this is the reason phase 7 added core-side reconciliation rather than just deleting code. + +## 10. References + +- Physical-device performance findings (Galaxy S9, 3-way vs develop and #703): [findings doc](https://gist.github.com/j0ntz/63c36e14285a638bc8874cb74e11d58e). +- Prior implementation: [edge-core-js#703](https://github.com/EdgeApp/edge-core-js/pull/703), design doc at `docs/wallet-cache.md` on branch `paul/cacheWallets`. +- Tabled follow-up: [edge-core-js#709](https://github.com/EdgeApp/edge-core-js/pull/709). +- Architectural direction: William's Signal messages (screenshots on Asana subtask "Login perf - Check William's Willingness", 1214006460304787) and the William x Jon call of 2026-07-17. +- Asana: [Login Perf - Wallet Cache v2](https://app.asana.com/1/9976422036640/project/1213843652804305/task/1216673467164267), predecessor [core: wallet cache (PR)](https://app.asana.com/1/9976422036640/project/1200382638405084/task/1213598116503346), parent [Login - Get Halloumi Reviewed](https://app.asana.com/1/9976422036640/project/1213843652804305/task/1214006460304785). + +