From 600301ae65b82f9ca0486d83d90b3d0e18957d6d Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 17 Jul 2026 17:17:23 -0700 Subject: [PATCH 01/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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 2ea1c0162952325ff9279703ab41f4d82330fe80 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 13:16:37 -0700 Subject: [PATCH 28/37] Serve cached addresses before the engine on stable-address chains The receive path always waited on the engine, even for chains whose addresses never rotate. 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. A new optional EdgeCurrencyInfo.hasStableAddresses hint gates the pre-engine serve; it defaults to false, so rotating (UTXO-style) chains and unflagged plugins keep exactly today's engine wait. --- src/core/actions.ts | 12 +++++ .../currency/wallet/currency-wallet-api.ts | 47 ++++++++++++++++++- .../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 | 10 ++++ 8 files changed, 149 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..d94dd9b06 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 on stable-address chains. 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,30 @@ export function makeCurrencyWalletApi( async getAddresses( opts: EdgeGetReceiveAddressOptions ): Promise { + // On chains whose addresses never rotate, serve the cached + // answer while the engine is still loading, so the receive + // scene works right away on a warm login. Rotating chains + // (and chains without the hint) wait for the engine, exactly + // as before, to avoid address reuse: + const { hasStableAddresses = false } = plugin.currencyInfo + const cachedAddresses = input.props.walletState.addresses + if ( + hasStableAddresses && + 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 +631,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..2656e8c44 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 only on stable-address chains. + */ + 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..d215e0d92 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -516,6 +516,16 @@ export interface EdgeCurrencyInfo { canAdjustFees?: boolean // Defaults to true canImportKeys?: boolean // Defaults to false canReplaceByFee?: boolean // Defaults to false + + /** + * True if the chain's receive addresses never rotate, so a + * previously returned address remains valid and reuse-safe. + * When set, a wallet can serve its cached addresses before the + * engine loads. Defaults to false: address queries wait for the + * engine, exactly the pre-cache behavior, which is what rotating + * (UTXO-style) chains need to avoid address reuse. + */ + hasStableAddresses?: boolean customFeeTemplate?: EdgeObjectTemplate // Indicates custom fee support customTokenTemplate?: EdgeObjectTemplate // Indicates custom token support requiredConfirmations?: number // Block confirmations required for a tx From e1f7c428e9d5dfc6bf4cb3de87ba0655a3a4a8ab Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 13:20:49 -0700 Subject: [PATCH 29/37] 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 d94dd9b06..be48817e3 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 = { @@ -979,12 +1006,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 2656e8c44..048de2daf 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 only on stable-address chains. */ 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 f5f0e64cd799788c8d9bfbe703486230c3e3bb05 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 13:23:29 -0700 Subject: [PATCH 30/37] 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 b0346238f13d04c6eeea09976510890d1e7d3397 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 13:32:14 -0700 Subject: [PATCH 31/37] 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 | 130 +++++++++++++++++- test/fake/fake-currency-plugin.ts | 56 +++++++- 4 files changed, 197 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..36c9bce9e 100644 --- a/test/core/currency/wallet/wallet-cache.test.ts +++ b/test/core/currency/wallet/wallet-cache.test.ts @@ -62,12 +62,16 @@ async function makeCachedWorld(): Promise { describe('wallet cache', function () { beforeEach(function () { + fakePluginTestConfig.currencyInfoPatch = undefined fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.omitEngineOtherMethods = undefined walletCacheSaverConfig.throttleMs = 50 }) afterEach(function () { + fakePluginTestConfig.currencyInfoPatch = undefined fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.omitEngineOtherMethods = undefined walletCacheSaverConfig.throttleMs = 5000 }) @@ -362,15 +366,12 @@ describe('wallet cache', function () { 'broadcastTx', 'detectedTokenIds', 'dumpData', - 'getAddresses', 'getMaxSpendable', 'getNumTransactions', 'getPaymentProtocolInfo', - 'getReceiveAddress', 'getTransactions', 'lockReceiveAddress', 'makeSpend', - 'otherMethods', 'resyncBlockchain', 'saveReceiveAddress', 'saveTx', @@ -388,6 +389,11 @@ describe('wallet cache', function () { 'unactivatedTokenIds' ] + // Cache-assisted surfaces: engine-gated by default, but served + // from the cache pre-engine when it can answer (addresses only on + // stable-address chains; 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 +431,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 +461,117 @@ describe('wallet cache', function () { await account.logout() }) + it('serves cached addresses pre-engine on a stable-address chain', async function () { + this.timeout(15000) + fakePluginTestConfig.currencyInfoPatch = { hasStableAddresses: true } + 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 for rotating-address chains', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache, exactly as on the stable chain: + 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() + + // Without the stability hint, a warm-login address query still + // waits for the engine, exactly the pre-cache behavior: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + let settled = false + const addressPromise = wallet2 + .getAddresses({ tokenId: null }) + .then(addresses => { + settled = true + return addresses + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + release() + const addresses = await addressPromise + expect(addresses[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..5cf3c3afc 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -48,6 +48,19 @@ export interface FakePluginTestConfig { */ engineGate?: Promise + /** + * If set, spread over the plugin's `currencyInfo` (identity-stable: + * the patched object is rebuilt only when this reference changes). + * Set it BEFORE creating the context, so every reader agrees. + */ + currencyInfoPatch?: Partial + + /** + * 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 @@ -66,7 +79,9 @@ export interface FakePluginTestConfig { export const fakePluginTestConfig: FakePluginTestConfig = { builtinTokensGate: undefined, + currencyInfoPatch: undefined, engineGate: undefined, + omitEngineOtherMethods: undefined, publicKeyCheckGate: undefined, onEngineCreate: undefined } @@ -153,12 +168,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, @@ -468,8 +488,30 @@ export function makeFakeCurrencyPlugin( ): EdgeCurrencyPlugin { const currencyInfo: EdgeCurrencyInfo = { ...fakeCurrencyInfo, ...overrides } + // Identity-stable view of `currencyInfoPatch`, so memoized reducers + // never see a fresh object unless the patch itself changed: + let patchedInfo = currencyInfo + let lastPatch: Partial | undefined + function getCurrencyInfo(): EdgeCurrencyInfo { + const patch = fakePluginTestConfig.currencyInfoPatch + if (patch !== lastPatch) { + lastPatch = patch + patchedInfo = patch == null ? currencyInfo : { ...currencyInfo, ...patch } + } + return patchedInfo + } + return { - currencyInfo, + get currencyInfo(): EdgeCurrencyInfo { + return getCurrencyInfo() + }, + + // 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 bb3a16903a63493cff631009f100c0c5501ca3d0 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 14:19:12 -0700 Subject: [PATCH 32/37] 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 be48817e3..350b25dd6 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 } @@ -595,7 +602,8 @@ export function makeCurrencyWalletApi( // (and chains without the hint) wait for the engine, exactly // as before, to avoid address reuse: const { hasStableAddresses = false } = plugin.currencyInfo - const cachedAddresses = input.props.walletState.addresses + const cachedAddresses = + input.props.walletState.addresses[opts.tokenId ?? ''] ?? [] if ( hasStableAddresses && opts.forceIndex == null && diff --git a/src/core/currency/wallet/currency-wallet-cleaners.ts b/src/core/currency/wallet/currency-wallet-cleaners.ts index 048de2daf..469615b81 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 only on stable-address chains. + * The engine's last answer per tokenId to the default address + * query, without balances (the `null` tokenId is spelled '' here). + * Served pre-engine only on stable-address chains. */ - 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 36c9bce9e..263f44eb4 100644 --- a/test/core/currency/wallet/wallet-cache.test.ts +++ b/test/core/currency/wallet/wallet-cache.test.ts @@ -572,6 +572,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 eeabe4370b0752100b1b781242ef992e9df6386c Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 14:19:26 -0700 Subject: [PATCH 33/37] 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 bf2bf4bdc49de2ec8060f73e6cb0a26d065fa77d Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 15:04:59 -0700 Subject: [PATCH 34/37] 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 e3fab42c9292ac217751d7cc467021ec41f5c88b Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 22 Jul 2026 15:13:23 -0700 Subject: [PATCH 35/37] 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 6f7c6333bd804e5d599bd7e5dbf58c99b8f8ee00 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 23 Jul 2026 03:04:08 -0700 Subject: [PATCH 36/37] Add allowCached opt-in for provisional receive addresses The receive scene wants to show an address the instant a warm login finishes, even on a chain whose addresses rotate. getAddresses and getReceiveAddress now accept allowCached: passing it serves the cached answer before the engine loads on any chain, not just stable-address ones. Programmatic callers leave it unset and keep waiting for the engine, so only the receive scene shows a provisional address (which it reconciles once the engine returns the fresh one) and no payment path ever latches a reused address. --- CHANGELOG.md | 1 + .../currency/wallet/currency-wallet-api.ts | 17 ++++-- src/types/types.ts | 12 +++++ .../core/currency/wallet/wallet-cache.test.ts | 54 +++++++++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24687c57..c4cd5fc00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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. +- added: An `allowCached` option on `getAddresses` / `getReceiveAddress` that lets a caller opt in to a provisional cached address before the engine loads, even on a rotating-address chain. Programmatic callers leave it unset and stay engine-gated, so only the receive scene serves a provisional address that it reconciles once the engine confirms the fresh one. - 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/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index 350b25dd6..3d2033f4a 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -598,14 +598,17 @@ export function makeCurrencyWalletApi( ): Promise { // On chains whose addresses never rotate, serve the cached // answer while the engine is still loading, so the receive - // scene works right away on a warm login. Rotating chains - // (and chains without the hint) wait for the engine, exactly - // as before, to avoid address reuse: + // scene works right away on a warm login. A rotating chain + // waits for the engine by default, to avoid address reuse, + // unless the caller passes `allowCached`: the receive scene + // opts in to a provisional answer and reconciles once the + // engine returns the fresh address, while programmatic + // callers stay gated and never latch a reused address: const { hasStableAddresses = false } = plugin.currencyInfo const cachedAddresses = input.props.walletState.addresses[opts.tokenId ?? ''] ?? [] if ( - hasStableAddresses && + (hasStableAddresses || opts.allowCached === true) && opts.forceIndex == null && cachedAddresses.length > 0 && input.props.walletOutput?.engine == null @@ -618,7 +621,11 @@ export function makeCurrencyWalletApi( const engine = await getEngine() if (engine.getAddresses != null) { - const addresses = await engine.getAddresses(opts) + // `allowCached` is a UI-layer hint consumed above, so keep it + // out of the engine call (the `getFreshAddress` branch already + // never forwards it): + const { allowCached, ...engineOpts } = opts + const addresses = await engine.getAddresses(engineOpts) rememberAddresses(opts, addresses) return addresses } else { diff --git a/src/types/types.ts b/src/types/types.ts index d215e0d92..c30d6d8e0 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -936,6 +936,18 @@ export interface EdgeStreamTransactionOptions { export type EdgeGetReceiveAddressOptions = EdgeTokenIdOptions & { forceIndex?: number + + /** + * Opt in to a provisional cached answer before the engine loads, + * even on a chain whose addresses rotate. The receive scene sets + * this so a warm login shows an address right away, marking it + * provisional and reconciling once the engine returns the fresh + * one. Programmatic callers (payments, action queue) leave it + * unset and stay engine-gated, so they never latch a reused + * address. Chains with `hasStableAddresses` serve cached + * regardless of this flag. + */ + allowCached?: boolean } export interface EdgeEngineActivationOptions { diff --git a/test/core/currency/wallet/wallet-cache.test.ts b/test/core/currency/wallet/wallet-cache.test.ts index 263f44eb4..2266a4167 100644 --- a/test/core/currency/wallet/wallet-cache.test.ts +++ b/test/core/currency/wallet/wallet-cache.test.ts @@ -528,6 +528,60 @@ describe('wallet cache', function () { await account2.logout() }) + it('serves a rotating chain pre-engine only when allowCached is set', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache with the engine running: + 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() + + // On a warm login with the engine still blocked, the receive + // scene's opt-in serves the cached answer immediately, while a + // plain (programmatic) query on the same rotating chain waits: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + + const provisional = await wallet2.getAddresses({ + tokenId: null, + allowCached: true + }) + expect(provisional[0].publicAddress).equals('fakesegwit') + + // A forced-index query never serves the cache, even with + // allowCached, since the caller wants a specific fresh address: + let forcedSettled = false + const forcedPromise = wallet2 + .getAddresses({ tokenId: null, allowCached: true, forceIndex: 0 }) + .then(addresses => { + forcedSettled = true + return addresses + }) + await snooze(RACE_WAIT_MS) + expect(forcedSettled).equals(false) + + let gatedSettled = false + const gatedPromise = wallet2 + .getAddresses({ tokenId: null }) + .then(addresses => { + gatedSettled = true + return addresses + }) + await snooze(RACE_WAIT_MS) + expect(gatedSettled).equals(false) + release() + const gated = await gatedPromise + expect(gated[0].publicAddress).equals('fakesegwit') + 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() From c532ebee41e836dafbf302f0595281db9c65910a Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 23 Jul 2026 04:11:17 -0700 Subject: [PATCH 37/37] 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 }