diff --git a/CHANGELOG.md b/CHANGELOG.md index b15bfa580..f767ba034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +- added: Account-level `accountCache.json` holding the account boot state plus every wallet's cached name, fiat code, enabled token IDs, last-known balances, receive addresses, and public keys. A warm login reads this one file, seeds Redux from it, and emits the account right after the currency plugins load, before the account repo syncs, with every cached wallet seeded in a single bulk dispatch. One throttled writer owns the whole account, and generations alternate between two slots, so a write interrupted part-way costs one generation of staleness rather than the warm boot. Devices holding the older per-wallet files migrate on their next login. The deferred file loads overwrite the seeded state authoritatively, and user changes made during the window win over the values those loads read. First login (no cache) boots exactly as before. +- added: Currency wallets emit their API objects as soon as the cache loads, before their engines exist, so the GUI can render the wallet list immediately at login. Engine-backed methods wait for the engine internally and reject if it fails or the wallet is deleted. First login (no cache) behaves exactly as before. +- added: Cached wallets' engine startup is staggered through a limited-concurrency queue (8 at a time) instead of all racing at login. Asking for a wallet via `waitForCurrencyWallet`, calling an engine- or storage-backed method, or un-pausing it moves it to the front of the queue. Wallets without a cache skip the queue, so first login is unaffected. +- added: A wallet serves its cached receive addresses (keyed per token) before its engine loads, so the receive scene has an address immediately on a warm login. The wallet emits `addressChanged` once the engine derives a different one, so consumers re-query and pick it up. A query naming a specific `forceIndex` still waits for the engine, since only it can derive a fresh address. +- added: A log line for each account-cache write, naming the generation it wrote, how many wallets it held, and how long the write took, so the cache's write cost is visible without a patched build. The saver's throttle bounds it to the same volume as the existing login breadcrumbs. +- 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. +- 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) - 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/src/core/account/account-api.ts b/src/core/account/account-api.ts index e1caa6ece..db756f55e 100644 --- a/src/core/account/account-api.ts +++ b/src/core/account/account-api.ts @@ -31,6 +31,10 @@ import { } from '../../types/types' import { makeEdgeResult } from '../../util/edgeResult' import { base58 } from '../../util/encoding' +import { + bumpEngineQueue, + waitForCurrencyEngine +} from '../currency/currency-selectors' import { saveWalletSettings } from '../currency/wallet/currency-wallet-files' import { getPublicWalletInfo } from '../currency/wallet/currency-wallet-pixie' import { @@ -124,7 +128,18 @@ 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 => { + 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 + } + ) function lockdown(): void { if (ai.props.state.hideKeys) { @@ -583,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') @@ -605,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') @@ -724,6 +739,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] @@ -783,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( @@ -809,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/account/account-cache-file.ts b/src/core/account/account-cache-file.ts new file mode 100644 index 000000000..78f43a051 --- /dev/null +++ b/src/core/account/account-cache-file.ts @@ -0,0 +1,83 @@ +import { Disklet } from 'disklet' + +import { makeJsonFile } from '../../util/file-helpers' +import { + AccountCacheFile, + asAccountCacheFile, + asStoredAccountCacheFile +} from './account-cleaners' + +/** + * Cached account boot state, stored on the account's local disklet. + * See `asAccountCacheFile` for the schema. + * + * The cache lives in two alternating slots. The disklet exposes no + * rename (neither its JS interface nor its iOS and Android native + * modules), so the usual write-temp-then-rename trick is unavailable, + * and Android's backend truncates the target before writing. A kill + * mid-write can therefore leave one slot torn. Writing generations + * alternately means the OTHER slot always holds the last complete + * one, and `loadAccountCache` takes the newest slot that still + * parses. (iOS writes with `NSDataWritingAtomic` and never tears, so + * this only earns its keep on Android.) + */ +export const ACCOUNT_CACHE_FILES = ['accountCache.json', 'accountCache.2.json'] +export const accountCacheFile = { + load: makeJsonFile(asStoredAccountCacheFile).load, + save: makeJsonFile(asAccountCacheFile).save +} + +/** + * Tuning for the account boot-state cache saver. + * Tests override the throttle to run quickly. + */ +export const accountCacheSaverConfig = { + throttleMs: 5000 +} + +/** + * Reads both slots and returns the newest one that parses, plus the + * slot the next write should use. Returns `undefined` cache data when + * neither slot is readable (first login, schema bump, both torn), + * which sends the caller to its cold path. + */ +export async function loadAccountCache( + disklet: Disklet +): Promise<{ cache: AccountCacheFile | undefined; nextSlot: number }> { + const slots = await Promise.all( + ACCOUNT_CACHE_FILES.map( + async path => await accountCacheFile.load(disklet, path) + ) + ) + + let best: AccountCacheFile | undefined + let bestSlot = -1 + for (let slot = 0; slot < slots.length; ++slot) { + const cache = slots[slot] + if (cache == null) continue + if (best == null || cache.sequence > best.sequence) { + best = cache + bestSlot = slot + } + } + + // Write over the slot we did NOT just read, so a torn write can + // never damage the generation we are currently relying on: + return { + cache: best, + nextSlot: bestSlot === -1 ? 0 : (bestSlot + 1) % ACCOUNT_CACHE_FILES.length + } +} + +/** + * Writes the next generation into `slot` and returns the slot the + * write after this one should use. + */ +export async function saveAccountCache( + disklet: Disklet, + slot: number, + data: AccountCacheFile +): Promise { + await accountCacheFile.save(disklet, ACCOUNT_CACHE_FILES[slot], data) + return (slot + 1) % ACCOUNT_CACHE_FILES.length +} diff --git a/src/core/account/account-cleaners.ts b/src/core/account/account-cleaners.ts index 4785f9ff5..83ab4bf59 100644 --- a/src/core/account/account-cleaners.ts +++ b/src/core/account/account-cleaners.ts @@ -1,15 +1,27 @@ import { asArray, asBoolean, + asEither, + asNull, 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 { asIntegerString } from '../currency/wallet/currency-wallet-cleaners' import { SwapSettings } from './account-types' // --------------------------------------------------------------------- @@ -92,3 +104,131 @@ 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: 2 + 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 + /** + * Each plugin's `otherMethods` names, so `CurrencyConfig` can + * expose delegating stubs even if the plugin has not loaded yet. + */ + configOtherMethodNames: EdgePluginMap + + /** + * Every active wallet's cached boot state, keyed by wallet id. + * This absorbs what used to live in each wallet's own + * `publicKey.json` + `walletCache.json` pair, so a warm boot reads + * one file for the whole account instead of two per wallet. + */ + wallets: { [walletId: string]: AccountCacheWallet } + + /** + * Increases on every write. Two slots hold alternating generations, + * so the reader can take the newest one that still parses; see + * `loadAccountCache`. + */ + sequence: number +} + +/** + * One wallet's cached boot state inside the account cache file: + * the public keys that were in `publicKey.json` plus the UI state + * that was in `walletCache.json`. + */ +export interface AccountCacheWallet { + walletInfo: { id: string; keys: object; type: string } + name: string | null + fiatCurrencyCode: string + enabledTokenIds: string[] + + /** Integer strings. The `null` tokenId is spelled '' here. */ + balances: { [tokenId: string]: string } + + /** Per tokenId, without balances (`null` tokenId spelled ''). */ + addresses: { + [tokenIdKey: string]: Array<{ addressType: string; publicAddress: string }> + } + + otherMethodNames: string[] +} + +const asEdgeWalletState = asObject({ + archived: asOptional(asBoolean), + deleted: asOptional(asBoolean), + hidden: asOptional(asBoolean), + migratedFromWalletId: asOptional(asString), + sortIndex: asOptional(asNumber) +}) + +const asCachedAddress = asObject({ + addressType: asString, + publicAddress: asString +}) + +const asAccountCacheWallet = asObject({ + walletInfo: asObject({ + id: asString, + keys: asJsonObject, + type: asString + }), + name: asEither(asString, asNull), + fiatCurrencyCode: asString, + enabledTokenIds: asArray(asString), + balances: asObject(asIntegerString), + addresses: asObject(asArray(asCachedAddress)), + otherMethodNames: asArray(asString) +}) + +export const asAccountCacheFile: Cleaner = asObject({ + version: asValue(2), + sequence: asNumber, + customTokens: asObject(asObject(asEdgeToken)), + legacyWallets: asOptional(asBoolean, false), + walletStates: asObject(asEdgeWalletState), + configOtherMethodNames: asOptional(asObject(asArray(asString)), () => ({})), + wallets: asObject(asAccountCacheWallet) +}) + +const asAccountCacheFileV1 = asObject({ + version: asValue(1), + customTokens: asObject(asObject(asEdgeToken)), + legacyWallets: asOptional(asBoolean, false), + walletStates: asObject(asEdgeWalletState), + configOtherMethodNames: asOptional(asObject(asArray(asString)), () => ({})) +}) + +/** + * Read-side cleaner: upgrades a version-1 file instead of falling + * through to a cold boot. A version-1 file predates the consolidated + * wallet table, so its wallets are still in their own per-wallet + * files, which `bulkLoadWalletCaches` reads once before this file is + * rewritten in the current schema. Writes always use + * `asAccountCacheFile`. + */ +export const asStoredAccountCacheFile: Cleaner = raw => { + try { + return asAccountCacheFile(raw) + } catch (error: unknown) { + const clean = asAccountCacheFileV1(raw) + return { ...clean, version: 2, sequence: 0, wallets: {} } + } +} diff --git a/src/core/account/account-files.ts b/src/core/account/account-files.ts index 25e65c329..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 @@ -42,6 +70,70 @@ 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 + + // 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 + }) +} + +/** + * 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, + * 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, + 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. */ @@ -148,6 +240,9 @@ export async function changeWalletStates( accountId: string, newStates: EdgeWalletStates ): Promise { + // 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) @@ -189,7 +284,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,29 +301,42 @@ export async function changePluginUserSettings( pluginId: string, userSettings: object ): Promise { - 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 waitForPluginSettings(ai, accountId) + 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 } + } + }) }) } @@ -237,25 +349,38 @@ export async function changeSwapSettings( pluginId: string, swapSettings: SwapSettings ): Promise { - 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 waitForPluginSettings(ai, accountId) + 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-pixie.ts b/src/core/account/account-pixie.ts index 0359d8ee2..9767003c8 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -13,19 +13,33 @@ import { EdgeAccount, EdgeCurrencyWallet, EdgePluginMap, - EdgeTokenMap + EdgeTokenMap, + EdgeWalletInfo, + EdgeWalletStates } from '../../types/types' import { makePeriodicTask } from '../../util/periodic-task' import { snooze } from '../../util/snooze' +import { + bulkLoadWalletCaches, + seedWalletCachesFromAccount, + walletCacheLoaderHooks +} from '../currency/wallet/wallet-cache-loader' import { syncLogin } from '../login/login' 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 { + accountCacheSaverConfig, + loadAccountCache, + saveAccountCache +} from './account-cache-file' +import { AccountCacheWallet } from './account-cleaners' import { loadAllWalletStates, reloadPluginSettings } from './account-files' import { AccountState, initialCustomTokens } from './account-reducer' import { @@ -78,7 +92,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,25 +102,128 @@ const accountPixie: TamePixie = combinePixies({ ]) } - try { - // Wait for the currency plugins (should already be loaded by now): - await waitForPlugins(ai) + 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') 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 { cache: accountCache } = await loadAccountCache( + makeLocalDisklet(ai.props.io, accountWalletInfo.id) + ) + if (accountCache != null && !accountCache.legacyWallets) { + input.props.dispatch({ + type: 'ACCOUNT_CACHE_LOADED', + payload: { + accountId, + configOtherMethodNames: accountCache.configOtherMethodNames, + 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. The current + // schema carries them all in the file just read, so the + // warm path touches no further disk. A file written by an + // older version has none, so those wallets come from + // their own files this once, and the saver then folds + // them into the consolidated file: + const cachedWallets = Object.keys(accountCache.wallets) + if (cachedWallets.length > 0) { + seedWalletCachesFromAccount(ai, accountId, accountCache.wallets) + } else { + await bulkLoadWalletCaches(ai, accountId) + } + + // The GUI already has the account, so retry transient + // failures instead of leaving the session half-loaded + // (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(attempt > 1) + 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) { + // Record the terminal failure, so the repo waiters + // (changeWalletStates, dataStore, sync, settings) + // reject instead of pending forever: + input.props.onError(error) + input.props.dispatch({ + type: 'ACCOUNT_LOAD_FAILED', + payload: { accountId, error } + }) + return await stopUpdates + } + await snooze(5000) + } + } + log.warn('Login: complete') + return await stopUpdates + } + + await loadEverything(false) // 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,19 +316,298 @@ 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) { - await saveCustomTokens(toApiInput(input), accountId).catch(error => + // 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 + + // 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 + + 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 } }, + /** + * 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 { + currencyWalletIds: string[] + customTokens: EdgePluginMap + legacyWalletInfos: EdgeWalletInfo[] + walletStates: EdgeWalletStates + + /** + * Reference identities of every active wallet's cache-relevant + * state. The Redux slices are immutable, so an unchanged + * reference means unchanged content, and this stays a cheap + * reference scan rather than a deep compare of the whole table. + */ + walletStamp: unknown[] + } + + function sameStamp(a: unknown[], b: unknown[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) return false + } + return true + } + + let failures = 0 + let lastSaved: CacheSnapshot | undefined + let timer: ReturnType | undefined + + // Which of the two slots the next write lands in, resolved from + // disk on the first save. `undefined` means "not looked up yet": + let nextSlot: number | undefined + + // Every write goes through this one chain. `doSave` is async, so a + // throttle firing while a previous write is still in flight would + // otherwise let two saves read the same `nextSlot` and both write + // it, leaving the other slot two generations stale (and, on a + // platform whose writes are not atomic, interleaving in one file): + let writeChain: Promise = Promise.resolve() + + // Monotonic across writes, so a reader can tell the two slots + // apart. Seeded from whichever generation is on disk: + let sequence = 0 + + // The wallet table as last written, so wallets that are not + // running this session keep their cached state instead of being + // dropped by a save that only sees the active ones: + let lastWallets: { [walletId: string]: AccountCacheWallet } = {} + + // The stamp computed by the `update` that armed the pending + // timer, so `doSave` records exactly what it wrote: + let pendingStamp: unknown[] = [] + + /** + * Reference identities of the state `collectWallets` reads, in a + * flat array, so `update` can tell whether anything the cache + * file actually stores has changed. + */ + function collectWalletStamp(): unknown[] { + const { accountState, state } = input.props + const stamp: unknown[] = [] + for (const walletId of accountState.activeWalletIds) { + const walletState = state.currency.wallets[walletId] + if (walletState == null) continue + stamp.push( + walletState.addresses, + walletState.balanceMap, + walletState.enabledTokenIds, + walletState.fiat, + walletState.name, + walletState.otherMethodNames, + walletState.publicWalletInfo + ) + } + return stamp + } + + /** + * Collects every active wallet's cache-relevant Redux state. + * This is the whole point of the consolidated file: one writer + * for the entire account, instead of one throttled writer per + * wallet all racing for the same bridge. + */ + function collectWallets(): { + [walletId: string]: AccountCacheWallet + } { + const { accountState, state } = input.props + + // Start from what the file already holds, so a wallet that is + // archived (or simply not running this session) keeps its cached + // boot state, the way its own file used to just sit on disk. + // Entries for wallets the account no longer has are dropped, so + // this cannot grow without bound: + const wallets: { [walletId: string]: AccountCacheWallet } = {} + for (const walletId of Object.keys(lastWallets)) { + const walletState = accountState.walletStates[walletId] + if (walletState == null || walletState.deleted === true) continue + wallets[walletId] = lastWallets[walletId] + } + + for (const walletId of accountState.activeWalletIds) { + const walletState = state.currency.wallets[walletId] + if (walletState == null) continue + + // Skip a wallet that has not finished loading its + // authoritative files, so a cold start never caches + // placeholder values (the per-wallet saver's old guard): + const { fiatLoaded, nameLoaded, publicWalletInfo, tokenFileLoaded } = + walletState + if (!fiatLoaded || !nameLoaded || !tokenFileLoaded) continue + if (publicWalletInfo == null) continue + + const balances: { [tokenId: string]: string } = {} + for (const [tokenId, balance] of walletState.balanceMap) { + balances[tokenId ?? ''] = balance + } + + wallets[walletId] = { + walletInfo: publicWalletInfo, + name: walletState.name, + fiatCurrencyCode: walletState.fiat, + enabledTokenIds: walletState.enabledTokenIds, + balances, + addresses: walletState.addresses, + otherMethodNames: walletState.otherMethodNames + } + } + return wallets + } + + async function doSave(): Promise { + const { accountId, accountState, state } = input.props + + // Never write after logout: + if (state.accounts[accountId] == null) return + + const snapshot: CacheSnapshot = { + currencyWalletIds: accountState.currencyWalletIds, + customTokens: accountState.customTokens, + legacyWalletInfos: accountState.legacyWalletInfos, + walletStates: accountState.walletStates, + walletStamp: pendingStamp + } + + // 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 legacyWallets = snapshot.legacyWalletInfos.some(info => + 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 { + const disklet = makeLocalDisklet( + input.props.io, + accountState.accountWalletInfo.id + ) + + // Resolve the starting slot once. A fresh session must not + // overwrite the generation it booted from, or a kill during + // this very first write would leave nothing intact: + if (nextSlot == null) { + const existing = await loadAccountCache(disklet) + nextSlot = existing.nextSlot + sequence = existing.cache?.sequence ?? 0 + lastWallets = existing.cache?.wallets ?? {} + } + + const wallets = collectWallets() + const startMs = Date.now() + nextSlot = await saveAccountCache(disklet, nextSlot, { + version: 2, + sequence: ++sequence, + customTokens: snapshot.customTokens, + legacyWallets, + walletStates: snapshot.walletStates, + configOtherMethodNames, + wallets + }) + + // The write is this design's whole cost, and it is invisible + // from the outside. The saver's throttle bounds this to one + // line per `throttleMs`, the same volume as the `Login:` + // breadcrumbs above: + input.props.log.warn( + `Wallet cache: wrote generation ${sequence} with ${ + Object.keys(wallets).length + } wallets in ${Date.now() - startMs}ms` + ) + lastWallets = wallets + 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 + + const walletStamp = collectWalletStamp() + if ( + lastSaved != null && + lastSaved.currencyWalletIds === accountState.currencyWalletIds && + lastSaved.customTokens === accountState.customTokens && + lastSaved.legacyWalletInfos === accountState.legacyWalletInfos && + lastSaved.walletStates === accountState.walletStates && + sameStamp(lastSaved.walletStamp, walletStamp) + ) { + return + } + pendingStamp = walletStamp + + timer = setTimeout(() => { + timer = undefined + writeChain = writeChain.then(doSave, doSave) + writeChain.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..6a38c6c5a 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: @@ -60,10 +63,16 @@ export interface AccountState { // Plugin stuff: readonly allTokens: EdgePluginMap readonly builtinTokens: EdgePluginMap + readonly configOtherMethodNames: EdgePluginMap readonly customTokens: EdgePluginMap + readonly customTokensDirtyIds: EdgePluginMap + readonly customTokensLoaded: boolean readonly alwaysEnabledTokenIds: EdgePluginMap readonly swapSettings: EdgePluginMap + readonly swapSettingsDirtyIds: string[] readonly userSettings: EdgePluginMap + readonly userSettingsDirtyIds: string[] + readonly pluginSettingsLoaded: boolean } export interface AccountNext { @@ -186,7 +195,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 +218,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,13 +358,47 @@ const accountInner = buildReducer({ customTokens( state = initialCustomTokens, - action + action, + next, + prev ): EdgePluginMap { switch (action.type) { - case 'ACCOUNT_CUSTOM_TOKENS_LOADED': { + case 'ACCOUNT_CACHE_LOADED': { const { customTokens } = action.payload return customTokens } + case 'ACCOUNT_CUSTOM_TOKENS_LOADED': { + // 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 + 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 const oldList = state[pluginId] ?? {} @@ -345,6 +423,42 @@ 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, + next, + prev + ): EdgePluginMap { + switch (action.type) { + case 'ACCOUNT_CUSTOM_TOKEN_ADDED': + case 'ACCOUNT_CUSTOM_TOKEN_REMOVED': { + // These actions might change the token list, so check for diffs: + 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_SAVED': + // The edits are on disk, so a future load will include them: + return Object.keys(state).length === 0 ? state : {} + } + 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,10 +469,21 @@ const accountInner = buildReducer({ return state }, - swapSettings(state = {}, action): EdgePluginMap { + swapSettings(state = {}, action, next, prev): EdgePluginMap { switch (action.type) { - case 'ACCOUNT_PLUGIN_SETTINGS_LOADED': - 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 @@ -370,7 +495,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 @@ -379,8 +504,78 @@ const accountInner = buildReducer({ return out } + 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 + }, + + pluginSettingsLoaded(state = false, action): boolean { + return action.type === 'ACCOUNT_PLUGIN_SETTINGS_LOADED' ? true : state + }, + + swapSettingsDirtyIds(state = [], action): string[] { + switch (action.type) { + case 'ACCOUNT_SWAP_SETTINGS_CHANGED': { + const { pluginId } = action.payload + return state.includes(pluginId) ? state : [...state, pluginId] + } + case 'ACCOUNT_PLUGIN_SETTINGS_LOADED': - return action.payload.userSettings + // 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 + }, + + 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 + + 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 } @@ -393,7 +588,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..07845d345 100644 --- a/src/core/account/plugin-api.ts +++ b/src/core/account/plugin-api.ts @@ -38,12 +38,35 @@ export class CurrencyConfig this._accountId = accountId this._pluginId = pluginId + // 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) { bridgifyObject(otherMethods) this.otherMethods = otherMethods } else { - this.otherMethods = {} + 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) + } + } + bridgifyObject(stubs) + this.otherMethods = stubs } } @@ -54,13 +77,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 dfb685e44..3af12e566 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -1,4 +1,6 @@ import { + EdgeAddress, + EdgeBalanceMap, EdgeCorePlugin, EdgeCorePluginsInit, EdgeCurrencyTools, @@ -24,6 +26,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 { @@ -50,12 +53,25 @@ 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 + configOtherMethodNames: EdgePluginMap + 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[] } } | { @@ -85,6 +101,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' @@ -271,6 +294,53 @@ 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[] + tokenId: EdgeTokenId + 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. + type: 'CURRENCY_WALLET_CACHE_LOADED' + payload: { + addresses: { [tokenIdKey: string]: EdgeAddress[] } + balanceMap: EdgeBalanceMap + enabledTokenIds: string[] + fiatCurrencyCode: string + name: string | null + otherMethodNames: string[] + 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: { @@ -286,10 +356,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 } } @@ -342,6 +414,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/currency-selectors.ts b/src/core/currency/currency-selectors.ts index 1fca70fd8..07c822941 100644 --- a/src/core/currency/currency-selectors.ts +++ b/src/core/currency/currency-selectors.ts @@ -1,9 +1,11 @@ import { + EdgeCurrencyEngine, EdgeCurrencyInfo, EdgeCurrencyWallet, EdgeTokenMap } from '../../types/types' import { ApiInput, RootProps } from '../root-pixie' +import { getEngineScheduler } from './wallet/engine-scheduler' export function getCurrencyMultiplier( currencyInfo: EdgeCurrencyInfo, @@ -28,20 +30,32 @@ 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 { + // 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 => { - // 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) { @@ -51,3 +65,33 @@ 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. + */ +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 094d8ce1a..a11c064e9 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, @@ -43,11 +44,20 @@ import { EdgeWalletInfo, JsonObject } from '../../../types/types' +import { compare } from '../../../util/compare' 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 { + bumpEngineQueue, + checkCurrencyWallet, + getCurrencyMultiplier, + waitForCurrencyEngine +} from '../currency-selectors' import { determineConfirmations, makeCurrencyWalletCallbacks, @@ -92,36 +102,201 @@ 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 - const storageWalletApi = makeStorageWalletApi(ai, walletInfo) + /** + * The wallet API object exists before the engine does, + * so engine-backed methods wait for the engine internally. + * Bail out if the wallet is deleted mid-wait, and re-throw + * `engineFailure` so a broken plugin surfaces as a rejected + * method call instead of a hang. + */ + function getEngine(): Promise { + return waitForCurrencyEngine(ai, walletId) + } + + async function getTools(): Promise { + return await getCurrencyTools(ai, pluginId) + } + + /** + * Methods that write synced-repo files need the storage wallet, + * not the engine. The repo loads well before the engine, + * so this wait is much shorter than `getEngine`. + * A ready repo always wins: an unrelated engine failure must not + * break storage-backed methods, so the failure check only matters + * while the repo is still missing (the engine pixie died before + * `addStorageWallet`, so the repo is never coming). + */ + function getStorage(): Promise { + // The repo loads inside the queued startup work, so a caller + // waiting on storage wants this wallet at the front too: + bumpEngineQueue(ai, walletId) + + return ai.waitFor((props: RootProps): true | undefined => { + if (props.state.storageWallets[walletId] != null) return true + checkCurrencyWallet(props, walletId) + }) + } + + const storageWalletApi = makeStorageWalletApi(ai, walletInfo, props => { + // 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, + // 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 + } + + // Address queries that were answered from the cache before the + // engine existed. Each one owes the caller a correction if the + // engine turns out to disagree: + const addressesServedFromCache = new Set() + + /** + * Remembers the engine's answer to the default address query, so + * the cache saver persists it and the next warm login can serve it + * pre-engine. Balances are stripped: + * they are stale by definition and `balanceMap` already owns them. + */ + function rememberAddresses( + opts: EdgeGetReceiveAddressOptions, + addresses: EdgeAddress[] + ): void { + if (opts.forceIndex != null) return + const tokenIdKey = opts.tokenId ?? '' + const stripped = addresses.map(address => ({ + addressType: address.addressType, + publicAddress: address.publicAddress + })) + + // If this query was served from the cache while the engine loaded, + // its callers hold an address the engine may have replaced. Only + // the first engine answer can settle that; later changes are + // rotations, which the engine reports through its own callback: + const owesCorrection = addressesServedFromCache.delete(tokenIdKey) + const isStale = + owesCorrection && + !compare(input.props.walletState.addresses[tokenIdKey] ?? [], stripped) + + input.props.dispatch({ + type: 'CURRENCY_WALLET_ADDRESSES_CHANGED', + payload: { + addresses: stripped, + tokenId: opts.tokenId, + walletId + } + }) + + if (isStale) fakeCallbacks.onAddressChanged() + } + + // Token queries with a reconcile already in flight. Several callers + // can be served the same cached answer before the engine lands, and + // one engine query settles the correction for all of them: + const reconcilingAddresses = new Set() + + /** + * Asks the engine for an address query that was already answered + * from the cache, so `rememberAddresses` can emit `addressChanged` + * if the two disagree. At most one runs per token query: a second + * would find the correction already settled and would silently + * overwrite the stored addresses without telling anyone. + */ + function reconcileAddresses(opts: EdgeGetReceiveAddressOptions): void { + const tokenIdKey = opts.tokenId ?? '' + if (reconcilingAddresses.has(tokenIdKey)) return + reconcilingAddresses.add(tokenIdKey) + + getEngine() + .then(async () => await out.getAddresses({ tokenId: opts.tokenId })) + .catch(error => input.props.onError(error)) + .finally(() => reconcilingAddresses.delete(tokenIdKey)) + } const fakeCallbacks = makeCurrencyWalletCallbacks(input) - 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'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 { + 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() + const withKeys = engine.otherMethodsWithKeys + if (withKeys != null && typeof withKeys[name] === 'function') { + return withKeys[name](walletInfo.keys, ...args) + } + const methods = engine.otherMethods + if (methods != null && typeof methods[name] === 'function') { + return methods[name](...args) + } + throw new Error(`The wallet engine does not implement "${name}"`) } } - 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) + } + } } + 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 + } + return otherMethodStubs } - bridgifyObject(otherMethods) const out: EdgeCurrencyWallet & InternalWalletMethods = { on: onMethod, @@ -132,6 +307,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 +319,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 +342,7 @@ export function makeCurrencyWalletApi( return input.props.walletState.name }, async renameWallet(name: string): Promise { + await getStorage() await renameCurrencyWallet(input, name) }, @@ -164,6 +351,7 @@ export function makeCurrencyWalletApi( return input.props.walletState.fiat }, async setFiatCurrencyCode(fiatCurrencyCode: string): Promise { + await getStorage() await setCurrencyWalletFiat(input, fiatCurrencyCode) }, @@ -206,6 +394,7 @@ export function makeCurrencyWalletApi( if (input.props.walletState.currencyInfo.hasWalletSettings !== true) { throw new Error('Wallet settings unsupported') } + await getStorage() await saveWalletSettingsFile(input, settings) }, @@ -232,6 +421,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 } @@ -243,14 +436,42 @@ 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] + + // 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 + // 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 + + // A terminal boot failure means the definitions never arrive: + if (accountState?.loadFailure != null) throw accountState.loadFailure + }) + + 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`) @@ -270,6 +491,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 +504,7 @@ export function makeCurrencyWalletApi( async $internalStreamTransactions( opts: EdgeStreamTransactionOptions ): Promise { + const engine = await getEngine() const { afterDate, batchSize = 10, @@ -416,8 +639,38 @@ export function makeCurrencyWalletApi( async getAddresses( opts: EdgeGetReceiveAddressOptions ): Promise { + // Serve the cached answer while the engine is still loading, so + // the receive scene works right away on a warm login. That + // answer is whatever the engine last derived, which on a + // rotating chain it may since have replaced, so the engine is + // asked in the background and `addressChanged` fires if the two + // disagree. A caller naming a specific index wants a freshly + // derived address, which only the engine can produce, so that + // query still waits. A wallet whose engine has FAILED also waits, + // and therefore rejects: its engine is never coming, so serving + // the cache forever would make a broken wallet indistinguishable + // from a healthy one on this method alone: + const cachedAddresses = + input.props.walletState.addresses[opts.tokenId ?? ''] ?? [] + if ( + opts.forceIndex == null && + cachedAddresses.length > 0 && + input.props.walletState.engineFailure == null && + input.props.walletOutput?.engine == null + ) { + // The user is on an address screen, so they want this + // wallet's engine sooner rather than later: + bumpEngineQueue(ai, walletId) + addressesServedFromCache.add(opts.tokenId ?? '') + reconcileAddresses(opts) + return cachedAddresses.map(address => ({ ...address })) + } + + 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], @@ -463,6 +716,7 @@ export function makeCurrencyWalletApi( }) } + rememberAddresses(opts, addresses) return addresses } }, @@ -515,12 +769,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 +789,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 +798,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 +893,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 +913,7 @@ export function makeCurrencyWalletApi( }, async saveTxAction(opts): Promise { + await getEngine() const { txid, tokenId, assetAction, savedAction } = opts await updateCurrencyWalletTxMetadata( input, @@ -666,6 +927,7 @@ export function makeCurrencyWalletApi( }, async saveTxMetadata(opts: EdgeSaveTxMetadataOptions): Promise { + await getEngine() const { txid, tokenId, metadata } = opts await updateCurrencyWalletTxMetadata( @@ -681,6 +943,7 @@ export function makeCurrencyWalletApi( bytes: Uint8Array, opts: EdgeSignMessageOptions = {} ): Promise { + const engine = await getEngine() const privateKeys = walletInfo.keys if (engine.signBytes != null) { @@ -706,6 +969,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 +977,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 +992,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 +1004,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 +1033,7 @@ export function makeCurrencyWalletApi( // URI handling: async encodeUri(options: EdgeEncodeUri): Promise { + const tools = await getTools() return await tools.encodeUri( options, makeMetaTokens( @@ -772,6 +1042,7 @@ export function makeCurrencyWalletApi( ) }, async parseUri(uri: string, currencyCode?: string): Promise { + const tools = await getTools() const parsedUri = await tools.parseUri( uri, currencyCode, @@ -792,7 +1063,9 @@ export function makeCurrencyWalletApi( }, // Generic: - otherMethods + get otherMethods(): EdgeOtherMethods { + return syncOtherMethodStubs() + } } return bridgifyObject(out) diff --git a/src/core/currency/wallet/currency-wallet-cleaners.ts b/src/core/currency/wallet/currency-wallet-cleaners.ts index 4175d4e96..c1c1b0db9 100644 --- a/src/core/currency/wallet/currency-wallet-cleaners.ts +++ b/src/core/currency/wallet/currency-wallet-cleaners.ts @@ -402,6 +402,74 @@ 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: 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 per tokenId to the default address + * query, without balances (the `null` tokenId is spelled '' here). + * Served pre-engine, before the engine confirms it. + */ + addresses: { + [tokenIdKey: string]: 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({ + 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: asObject(asArray(asCachedAddress)), + otherMethodNames: asOptional(asArray(asString), () => []) +}) + +const asWalletCacheFileV1 = asObject({ + version: asValue(1), + name: asEither(asString, asNull), + fiatCurrencyCode: asString, + enabledTokenIds: asArray(asString), + 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: {}, otherMethodNames: [] } + } +} + /** * 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-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-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index 1a2c62553..63461c37c 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -17,7 +17,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' @@ -38,7 +37,7 @@ import { makeCurrencyWalletCallbacks, watchCurrencyWallet } from './currency-wallet-callbacks' -import { asIntegerString, asPublicKeyFile } from './currency-wallet-cleaners' +import { asIntegerString } from './currency-wallet-cleaners' import { loadAddressFiles, loadFiatFile, @@ -55,6 +54,13 @@ import { initialWalletSettings } from './currency-wallet-reducer' import { tokenIdsToCurrencyCodes, uniqueStrings } from './enabled-tokens' +import { getEngineScheduler } from './engine-scheduler' +import { + loadWalletCacheSeed, + PUBLIC_KEY_CACHE, + publicKeyFile, + walletCacheLoaderHooks +} from './wallet-cache-loader' export interface CurrencyWalletOutput { readonly walletApi: EdgeCurrencyWallet | undefined @@ -70,151 +76,242 @@ 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) => async () => { - const { state, walletId, walletState } = input.props - const { accountId, pluginId, walletInfo } = walletState - const plugin = state.plugins.currency[pluginId] - const { currencyCode } = plugin.currencyInfo - - try { - // Start the data sync: - const ai = toApiInput(input) - await addStorageWallet(ai, walletInfo) - - // Grab the freshly-synced repos: - const { state } = input.props - const walletLocalDisklet = getStorageWalletLocalDisklet( - state, - walletInfo.id - ) - const walletLocalEncryptedDisklet = - makeStorageWalletLocalEncryptedDisklet( + 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 + + // 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. + // 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, accountId) + 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 + // 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`) + } + + // Start the data sync: + await addStorageWallet(ai, walletInfo) + + // 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. 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, + input.props.walletState.publicWalletInfo ?? undefined + ) + 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, 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), + + // 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 + }) - // 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) { + // The wallet can also be deleted (or the account logged out) + // during the awaits above, which the slot-acquisition check + // cannot see. By now `engineStarted`'s destroy has already run + // and found no engine to kill, so publishing this one would + // leave it alive with nothing left to tear it down: + if (destroyed) { + await engine.killEngine().catch(() => {}) + return + } + 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_ENGINE_CHANGED_BALANCE', - payload: { balance, tokenId: null, walletId } + type: 'CURRENCY_WALLET_OTHER_METHOD_NAMES_CHANGED', + payload: { names: otherMethodNames, 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) + + // 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: 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 @@ -492,17 +589,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) { @@ -526,7 +629,7 @@ export const walletPixie: TamePixie = combinePixies({ if ( settingsChanged && - engine?.changeWalletSettings != null && + engine.changeWalletSettings != null && hasWalletSettings ) { await engine.changeWalletSettings(walletSettings).catch(error => { @@ -537,7 +640,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) @@ -573,21 +676,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 } } @@ -602,13 +710,10 @@ export async function getPublicWalletInfo( keys: publicKeys } - // Save the cache if it's not empty: - if (Object.keys(publicKeys).length > 0) { - await publicKeyFile.save(disklet, PUBLIC_KEY_CACHE, { - walletInfo: publicWalletInfo - }) - } - + // The account cache saver persists these keys along with the rest + // of the wallet's boot state, so nothing is written per wallet + // here. Older `publicKey.json` files stay on disk as a recovery + // net for a boot that cannot read the account cache: return publicWalletInfo } diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index 1cf017ae8..c1ee0f5c0 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: { [tokenIdKey: string]: EdgeAddress[] } readonly allEnabledTokenIds: string[] readonly balanceMap: EdgeBalanceMap readonly balances: EdgeBalances @@ -74,19 +76,24 @@ export interface CurrencyWalletState { readonly currencyInfo: EdgeCurrencyInfo readonly detectedTokenIds: string[] readonly enabledTokenIds: string[] + readonly enabledTokensDirtyIds: string[] 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 otherMethodNames: string[] readonly publicWalletInfo: EdgeWalletInfo | null readonly seenTxCheckpoint: string | null readonly sortedTxidHashes: string[] @@ -122,6 +129,8 @@ export interface CurrencyWalletNext { export const initialWalletSettings: JsonObject = {} +export const initialAddresses: { [tokenIdKey: string]: EdgeAddress[] } = {} + // Used for detectedTokenIds & enabledTokenIds: export const initialTokenIds: string[] = [] @@ -192,11 +201,24 @@ const currencyWalletInner = buildReducer< ), enabledTokenIds: sortStringsReducer( - (state = initialTokenIds, action): string[] => { + (state = initialTokenIds, action, next, prev): string[] => { if (action.type === 'CURRENCY_WALLET_LOADED_TOKEN_FILE') { - 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') { + return action.payload.enabledTokenIds } else if (action.type === 'CURRENCY_ENGINE_DETECTED_TOKENS') { const { enablingTokenIds } = action.payload return uniqueStrings([...state, ...enablingTokenIds]) @@ -221,9 +243,36 @@ 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': + // 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 @@ -254,9 +303,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: @@ -264,6 +323,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 @@ -281,14 +350,34 @@ const currencyWalletInner = buildReducer< : state }, - fiat(state = '', action): string { - return action.type === 'CURRENCY_WALLET_FIAT_CHANGED' - ? 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 { - 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 { @@ -345,13 +434,67 @@ 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 + ): { [tokenIdKey: string]: EdgeAddress[] } { + switch (action.type) { + 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 (Object.keys(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 + // 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 } + 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 }, @@ -360,12 +503,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 @@ -378,14 +524,34 @@ const currencyWalletInner = buildReducer< : state }, - name(state = null, action): string | null { - return action.type === 'CURRENCY_WALLET_NAME_CHANGED' - ? 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 { - 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) { @@ -483,9 +649,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 } }) @@ -511,6 +682,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' && @@ -565,12 +747,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)) diff --git a/src/core/currency/wallet/engine-scheduler.ts b/src/core/currency/wallet/engine-scheduler.ts new file mode 100644 index 000000000..a9347bb55 --- /dev/null +++ b/src/core/currency/wallet/engine-scheduler.ts @@ -0,0 +1,166 @@ +/** + * 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(() => { + // 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): + 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 +} 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..b709b7c26 --- /dev/null +++ b/src/core/currency/wallet/wallet-cache-file.ts @@ -0,0 +1,37 @@ +import { + EdgeAddress, + EdgeBalanceMap, + EdgeWalletInfo +} from '../../../types/types' +import { makeJsonFile } from '../../../util/file-helpers' +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 = { + load: makeJsonFile(asStoredWalletCacheFile).load, + save: makeJsonFile(asWalletCacheFile).save +} + +/** + * 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 { + addresses: { [tokenIdKey: string]: EdgeAddress[] } + balanceMap: EdgeBalanceMap + 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 new file mode 100644 index 000000000..20b969d58 --- /dev/null +++ b/src/core/currency/wallet/wallet-cache-loader.ts @@ -0,0 +1,190 @@ +import { EdgeBalanceMap } from '../../../types/types' +import { makeJsonFile } from '../../../util/file-helpers' +import { loadAccountCache } from '../../account/account-cache-file' +import { AccountCacheWallet } from '../../account/account-cleaners' +import { ApiInput } from '../../root-pixie' +import { makeLocalDisklet } from '../../storage/repo' +import { asPublicKeyFile, WalletCacheFile } from './currency-wallet-cleaners' +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 `accountCacheSaverConfig`. + */ +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 +} + +/** + * Converts one wallet's entry in the account cache file into a seed. + */ +export function toWalletCacheSeed(cached: AccountCacheWallet): WalletCacheSeed { + return { + addresses: cached.addresses, + balanceMap: makeCachedBalanceMap(cached.balances), + enabledTokenIds: cached.enabledTokenIds, + fiatCurrencyCode: cached.fiatCurrencyCode, + name: cached.name, + otherMethodNames: cached.otherMethodNames, + publicWalletInfo: cached.walletInfo + } +} + +/** + * Reads one wallet's seed for a pixie that the bulk seed missed: + * a cold login, or a wallet reactivated mid-session. Prefers the + * consolidated account file, which holds every wallet the account + * still has (archived ones included), and falls back to the + * per-wallet pair a pre-consolidation device still has on disk. + */ +export async function loadWalletCacheSeed( + ai: ApiInput, + walletId: string, + accountId: string +): Promise { + const accountState = ai.props.state.accounts[accountId] + if (accountState != null) { + const { cache } = await loadAccountCache( + makeLocalDisklet(ai.props.io, accountState.accountWalletInfo.id) + ) + const cached = cache?.wallets[walletId] + if (cached != null) return toWalletCacheSeed(cached) + } + return await loadWalletFilesSeed(ai, walletId) +} + +/** + * Reads one wallet's own cache files from its local disklet. + * Returns undefined when either file is missing or invalid + * (first login, schema bump, corruption). Only a device that has not + * yet written the consolidated account file still has these. + */ +export async function loadWalletFilesSeed( + ai: ApiInput, + walletId: string +): Promise { + 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 { + addresses: walletCache.addresses, + otherMethodNames: walletCache.otherMethodNames, + balanceMap: makeCachedBalanceMap(walletCache.balances), + enabledTokenIds: walletCache.enabledTokenIds, + fiatCurrencyCode: walletCache.fiatCurrencyCode, + name: walletCache.name, + publicWalletInfo: publicKeyCache.walletInfo + } +} + +/** + * Seeds every wallet the account cache file already carried, with no + * disk reads at all: the consolidated file was read in one go by the + * account pixie. This is the warm path once a device has written the + * current schema; `bulkLoadWalletCaches` below is the migration path + * for a device whose wallets are still in their own files. + */ +export function seedWalletCachesFromAccount( + ai: ApiInput, + accountId: string, + wallets: { [walletId: string]: AccountCacheWallet } +): void { + // The file also carries wallets that are merely archived, so they + // keep their cached state; only the active ones have a pixie to + // seed. An archived wallet that is turned back on seeds itself + // through `loadWalletCacheSeed`, which reads this same file: + const activeWalletIds = + ai.props.state.accounts[accountId]?.activeWalletIds ?? [] + const seeds: { [walletId: string]: WalletCacheSeed } = {} + for (const walletId of activeWalletIds) { + const cached = wallets[walletId] + if (cached != null) seeds[walletId] = toWalletCacheSeed(cached) + } + + ai.props.dispatch({ + type: 'CURRENCY_WALLETS_CACHE_LOADED', + payload: { accountId, seeds } + }) + + if (walletCacheLoaderHooks.onBulkSeed != null) { + walletCacheLoaderHooks.onBulkSeed(Object.keys(seeds)) + } +} + +/** + * Reads every active wallet's cache files concurrently and seeds + * them all in a single `CURRENCY_WALLETS_CACHE_LOADED` dispatch, + * 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 loadWalletFilesSeed(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/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 }), 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', diff --git a/src/core/storage/storage-api.ts b/src/core/storage/storage-api.ts index a6d70e44e..cfb811d9c 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 { ApiInput } from '../root-pixie' +import { asEdgeStorageKeys } from '../login/storage-keys' +import { ApiInput, RootProps } from '../root-pixie' +import { makeLocalDisklet, makeRepoPaths } from './repo' import { syncStorageWallet } from './storage-actions' import { getStorageWalletDisklet, @@ -19,10 +22,34 @@ 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 + // 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,14 +58,28 @@ 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) }, async sync(): Promise { + // The storage wallet may not be attached yet on a cache-seeded + // 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 (props.state.storageWallets[id] != null) return true + if (checkAlive != null) checkAlive(props) + }) await syncStorageWallet(ai, id) } } diff --git a/src/docs/edge-wallet-cache-design.md b/src/docs/edge-wallet-cache-design.md new file mode 100644 index 000000000..ef30ce7a0 --- /dev/null +++ b/src/docs/edge-wallet-cache-design.md @@ -0,0 +1,640 @@ +Edge wallet cache v2 design: instant wallet UI at login (edge-core-js) + +Edge wallet cache v2 design: instant wallet UI at login (edge-core-js) + +# Wallet cache v2: instant wallet UI at login + +| | | +|---|---| +| Status | Implemented (phases 1-7 in review) | +| Author | Jon Tzeng | +| Reviewer | William Swanson | +| Last updated | 2026-08-05 | +| Repos | [EdgeApp/edge-core-js](https://github.com/EdgeApp/edge-core-js), [EdgeApp/edge-react-gui](https://github.com/EdgeApp/edge-react-gui), [EdgeApp/edge-currency-accountbased](https://github.com/EdgeApp/edge-currency-accountbased) | +| Implementation | [edge-core-js#733](https://github.com/EdgeApp/edge-core-js/pull/733), [edge-react-gui#6080](https://github.com/EdgeApp/edge-react-gui/pull/6080), [edge-currency-accountbased#1076](https://github.com/EdgeApp/edge-currency-accountbased/pull/1076) (draft, plugin opt-in) | +| Supersedes | [edge-core-js#703](https://github.com/EdgeApp/edge-core-js/pull/703) (Cache and restore wallets) | +| Related | [edge-core-js#709](https://github.com/EdgeApp/edge-core-js/pull/709) (Reduce UI lag, tabled), [architecture comparison vs #703](https://gist.github.com/j0ntz/7ea32da5dc8d23a1fd46d16fd121cda1), [write-path audit](https://gist.github.com/j0ntz/94c3e64779a92e8a1ae1896f4d7d3d6f), Asana: [Login Perf - Wallet Cache v2](https://app.asana.com/1/9976422036640/project/1213843652804305/task/1216673467164267) | + +Core file references are to edge-core-js `master` at v2.47.0 (1b2a25e7) as it stood before implementation; GUI references are to edge-react-gui `develop`. Code blocks marked "as landed" are from the implementation PRs. The architectural direction comes from the William x Jon call of 2026-07-17; decisions and their rationale are in [section 9](#9-decisions). Divergences discovered during implementation are documented inline in the sections they affected. + +## Contents + +1. [Problem](#1-problem) +2. [Why #703 is not the implementation](#2-why-703-is-not-the-implementation) +3. [Goals and non-goals](#3-goals-and-non-goals) +4. [Design overview](#4-design-overview) +5. [Detailed design: edge-core-js](#5-detailed-design-edge-core-js) +6. [Testing](#6-testing) +7. [GUI integration: edge-react-gui](#7-gui-integration-edge-react-gui) +8. [Phase history and #709 disposition](#8-phase-history-and-709-disposition) +9. [Decisions](#9-decisions) +10. [References](#10-references) + +## 1. Problem + +Measured from the last PIN digit to a usable wallet list, login is slow because the GUI cannot render balances until currency engines exist. The chain on master: + +1. The GUI gets no `EdgeAccount` until plugins load, account repos sync, and key files load (`src/core/account/account-pixie.ts:52-118`). +2. Each wallet's `walletApi` pixie refuses to emit until `engine != null && publicWalletInfo != null && nameLoaded` (`src/core/currency/wallet/currency-wallet-pixie.ts:209`). +3. In the engine pixie, `plugin.makeCurrencyEngine` sits behind repo sync, `loadTxFileNames`, and key derivation, and the name/fiat file loads are sequenced after engine creation (`currency-wallet-pixie.ts:78-201`, name/fiat at 186-187). +4. Core never persists balances. They live only in Redux (`balanceMap`), populated by engine callbacks after startup. + +So even the wallet's name waits behind engine creation, and balances wait behind engine startup plus network. QA measured the caching approach in #703 at roughly a 5x login improvement. William confirmed the finding on the 2026-07-17 call: the cost of one extra disk read per wallet is far below the cost of loading engines up front. + +Engine loading also causes visible jank after login (disk, key derivation, network on the JS thread). Deferring engine work was out of scope for phase 1; phase 2 shipped it ([section 8](#8-phase-history-and-709-disposition)). + +## 2. Why #703 is not the implementation + +#703 proved the concept and is the reason we know the win is real. It was rejected on architecture, not on results: + +- It builds a second, parallel implementation of `EdgeCurrencyWallet` and `EdgeCurrencyConfig` (543-line `cached-currency-wallet.ts` plus config, loader, saver, delegation utilities; ~3,200 lines total). Two implementations of the same interface means every future API change must be made twice, and each copy has its own bugs. Review findings bore this out: stale Redux snapshots in the loader, an initial save that wiped token definitions, non-bridgified `otherMethods`, pollers that outlive logout. +- The merged `currencyWallets` getter allocated a fresh object per read. yaob diffs by `===`, so unstable references cause superfluous or missing bridge updates (William's inline review comment, 2026-02-26). +- Cached wallets delegate to real ones through a 500 ms poll with a 60 s timeout, and four setters must remember to call `update()` by hand or the GUI wedges (the paused-state variant causes an infinite boot loop, per the PR's own design doc). + +Direction agreed on the call: keep one wallet implementation. Feed cached data into the existing Redux state and the existing `walletApi` object, and make the engine an awaitable dependency inside that object instead of a construction parameter. + +## 3. Goals and non-goals + +Goals: + +- Wallet list with names, fiat codes, enabled tokens, and last-known balances renders as soon as per-wallet cache files load, before any engine exists. +- One `EdgeCurrencyWallet` implementation. No parallel objects, no delegation layer, no polling. +- Engine-dependent methods keep working: they wait for the engine internally and reject if it fails or the wallet is deleted. +- First login (no cache) behaves exactly as today. +- The GUI changes required by the new wallet-readiness semantics ship in the same phase ([section 7](#7-gui-integration-edge-react-gui)). A core change that breaks FIO refresh or action-queue checks is not done. +- Target size: a few hundred core lines plus two small GUI patches, not 3,000 (size actuals below). + +Non-goals for phase 1: + +- Deferring or reordering engine creation. The engine pixie's imperative block runs when it runs today; only the GUI's wait for it is removed. Engines all still start; gentler scheduling was phase 2 ([section 8](#8-phase-history-and-709-disposition)). +- The #709 bridge-churn work. Tabled; see [section 8](#8-phase-history-and-709-disposition) for the one commit worth salvaging. +- yaob changes. Any diffing improvements belong in the yaob repo, not shimmed around in core. + +Size actuals, as implemented. The call's estimate was "a few hundred core lines plus two small GUI patches, not 3,000" and it covered phase 1 source only: + +| Area | Estimate | Actual | +|---|---|---| +| Core src, phase 1 | a few hundred lines | +379 / -50 across 7 files | +| Core src, phase 2 | excluded at estimate time ([section 8](#8-phase-history-and-709-disposition)) | +374 / -139 across 6 files, of which a large slice is re-indentation from the engine-pixie restructure ([section 8](#8-phase-history-and-709-disposition)) | +| Core tests | not counted | +829 lines, 22 deterministic cases plus fake-plugin harness work | +| GUI | two small patches | 3 patch sites + 1 new helper, +130 / -28 (phase 2 adds 10 of those lines) | + +Phase 1 src landed on budget. The apparent overrun is phase 2 (never in the estimate), tests (excluded by the "core lines" phrasing but more than half the diff), and diffstat noise from a re-indent. Total remains roughly 5x under the #703 anchor the estimate argued against. Phases 3 and 4 were scoped after this estimate ([section 8.1](#81-phase-3-account-startup-cache), [section 8.2](#82-followup-write-path-staleness-fixes-landed)). + +## 4. Design overview + +Three changes, all in existing files plus one new cleaner: + +1. A per-wallet cache file on the wallet's local disklet holding everything the GUI needs pre-engine: name, fiat code, enabled token IDs, balances (public wallet info stays in the existing `publicKey.json`). +2. The engine pixie reads that file first, before repo sync, and dispatches it into Redux. The `walletApi` gate drops its `engine != null` condition, so the wallet object emits as soon as cached (or freshly derived) state is in Redux. +3. `makeCurrencyWalletApi` loses its `engine` and `tools` constructor parameters. Methods that need them call internal `getEngine()` / `getTools()` waiters built on `ai.waitFor`. Methods that can serve from Redux keep doing exactly what they do today; the cache seeds Redux, so they need no changes at all. + +The wallet object is created once and never replaced. When the engine lands, the same object starts answering engine-backed calls. yaob reference stability is preserved by construction: the pixie output map and the `walletApi` object are the same ones master uses today. + +Work splits across two repos: + +| Repo | Deliverable | Scope | +|---|---|---| +| edge-core-js | [#733](https://github.com/EdgeApp/edge-core-js/pull/733) | Cache file + cleaner, hoisted load path, gate change, API waiters, cache saver ([section 5](#5-detailed-design-edge-core-js)); engine startup scheduler (phase 2, [section 8](#8-phase-history-and-709-disposition)) | +| edge-react-gui | [#6080](https://github.com/EdgeApp/edge-react-gui/pull/6080) | Engine-readiness gates for action-queue balance effects and FIO refresh, `waitForWalletOtherMethods` helper ([section 7](#7-gui-integration-edge-react-gui)) | + +How a cache-window method call resolves once both land (symbols as shipped): + +```mermaid +sequenceDiagram + box edge-react-gui (RN main thread) + participant GUI + end + box edge-core-js (WebView, across the yaob bridge) + participant W as walletApi (single object) + participant R as Redux + participant Q as engine-scheduler + participant E as engine + end + + GUI->>W: balanceMap getter + W->>R: read balanceMap (cache-seeded) + R-->>GUI: last-known balance, instantly + GUI->>W: makeSpend() + W->>Q: bumpEngineQueue(walletId) + Note over W: await getEngine() via ai.waitFor + Q->>E: makeCurrencyEngine (front of queue) + E-->>W: engine in pixie output + W->>E: engine.makeSpend() + E-->>GUI: EdgeTransaction +``` + +## 5. Detailed design: edge-core-js + +As landed, the data flow in [5.1](#51-cache-file) through [5.5](#55-save-path) shipped as specified, and cold-start byte-equivalence held under a regression-guard test ([case 1](#6-testing)). + +### 5.1 Cache file + +One file, `accountCache.json` on the account's local disklet, holding the account boot state and every wallet's cached UI state plus the public keys that used to live in each wallet's `publicKey.json`. Phases 1 through 6 wrote a `walletCache.json` per wallet alongside `publicKey.json`; phase 7 consolidated them, and the reasoning for the reversal is [decision 9.6](#96-one-account-cache-file-not-per-wallet-files). + +The file is written to two alternating slots (`accountCache.json`, `accountCache.2.json`), each carrying a monotonic `sequence`; the reader takes the newest slot that parses, which is the torn-write defense ([decision 9.10](#910-two-slot-alternation-because-the-disklet-has-no-rename)). + +Schema (`src/core/account/account-cleaners.ts`, `account-cache-file.ts`): + +```ts +export interface AccountCacheFile { + version: 2 + sequence: number + customTokens: EdgePluginMap + legacyWallets: boolean + walletStates: EdgeWalletStates + configOtherMethodNames: EdgePluginMap + wallets: { [walletId: string]: AccountCacheWallet } +} + +/** Everything the GUI needs to render one wallet before its engine exists. */ +export interface AccountCacheWallet { + walletInfo: { id: string; keys: object; type: string } + name: string | null + fiatCurrencyCode: string + enabledTokenIds: string[] + /** Integer strings. The `null` tokenId is spelled '' here. */ + balances: { [tokenId: string]: string } + /** Per tokenId, balances stripped. */ + addresses: { + [tokenIdKey: string]: Array<{ addressType: string; publicAddress: string }> + } + otherMethodNames: string[] +} +``` + +A version-1 file (no `wallets`) is upgraded on read rather than rejected, which is what sends an existing device through the migration in [section 5.2](#52-load-path). Balances are last-known values and are explicitly allowed to be stale. + +### 5.2 Load path + +In the engine pixie's async block (`currency-wallet-pixie.ts:78-201`), hoist a cache read to the top, before `addStorageWallet` and `loadTxFileNames`. Those two awaits currently sit ahead of `getPublicWalletInfo`, and on a slow disk or unsynced repo they would eat the win. + +```mermaid +flowchart TD + A[engine pixie starts] --> B[read publicKey.json + walletCache.json] + B -->|both valid| C[dispatch CURRENCY_WALLET_PUBLIC_INFO
+ CURRENCY_WALLET_CACHE_LOADED] + B -->|missing or cleaner rejects| D[cold path: no dispatch] + C --> G{walletApi gate
publicWalletInfo + nameLoaded} + G -->|opens| H[EdgeCurrencyWallet emitted
GUI renders from cache] + C --> E[addStorageWallet repo sync] + D --> E + E --> F[loadTxFileNames, keys, checkpoint, tokens file] + F --> I[makeCurrencyEngine
phase 2: queued via engine-scheduler] + I --> J[loadFiatFile / loadNameFile] + J --> K[authoritative values overwrite cached ones
same walletApi object, no replacement] + D -.gate waits for engine + name as on master.-> G +``` + +`CURRENCY_WALLET_CACHE_LOADED` (new action in `src/core/actions.ts`, payload: the wallet id plus the validated `WalletCacheFile`) seeds the wallet's Redux slice: + +- `balanceMap` from cached balances, with a dirty-wins guard: the reducer ignores a cached value for any token the engine or a user action has already touched, so cache never overwrites live data (an implementation finding; the first draft was silent on this guard). +- `name` and sets `nameLoaded = true` (`currency-wallet-reducer.ts:387`). +- `fiatCurrencyCode` and sets `fiatLoaded = true` (`currency-wallet-reducer.ts:290`). +- `enabledTokenIds`. + +The later `loadNameFile` / `loadFiatFile` / `loadTokensFile` dispatches overwrite the cached values with the authoritative synced-repo values, exactly as they overwrite initial state today. If the user renamed the wallet on another device, the cache shows the old name for a second or two, then corrects. Same class of staleness the GUI already tolerates for balances. + +If either file is missing or fails its cleaner (first login, schema bump, corruption), skip the dispatch and fall through. The gate then opens on the same conditions master uses today, so cold-start behavior is unchanged (regression-guarded by [test case 1](#6-testing)). + +### 5.3 Gate change + +`walletApi` pixie, `currency-wallet-pixie.ts:209`: + +```ts +// before +if (engine == null || publicWalletInfo == null || !nameLoaded) return +// after +if (publicWalletInfo == null || !nameLoaded) return +``` + +With the cache present, `publicWalletInfo` and `nameLoaded` are satisfied by the [load path](#52-load-path) and the wallet emits within one pixie tick of the cache read. Without the cache, `nameLoaded` flips only after `loadNameFile`, which still sits after engine creation, so the no-cache path is byte-for-byte today's behavior. + +`engineStarted` (`currency-wallet-pixie.ts:224-285`) keeps its own conditions. In phase 1 engines are created and started on the same schedule as master; phase 2 queues the creation block ([section 8](#8-phase-history-and-709-disposition)). + +### 5.4 makeCurrencyWalletApi changes + +`makeCurrencyWalletApi` (`src/core/currency/wallet/currency-wallet-api.ts:93`) drops `engine` and `tools` as parameters; the [cross-repo sequence diagram](#4-design-overview) shows how a GUI call rides these waiters across the yaob boundary. Internal waiters replace them, as landed in #733: + +```ts +/** + * Waits for a wallet's engine to exist. The wallet API object can + * exist before its engine does (a cache-seeded login), so + * engine-backed methods wait here instead of throwing. Bails out if + * the wallet is deleted mid-wait, and re-throws `engineFailure` so a + * broken plugin surfaces as a rejection instead of a hang. + */ +export function waitForCurrencyEngine( + ai: ApiInput, + walletId: string +): Promise { + // The caller needs this engine now, so skip the startup queue: + bumpEngineQueue(ai, walletId) + + return ai.waitFor((props: RootProps): EdgeCurrencyEngine | undefined => { + checkCurrencyWallet(props, walletId) + return props.output.currency.wallets[walletId]?.engine + }) +} + +/** + * The wallet API object exists before the engine does, + * so engine-backed methods wait for the engine internally. + * Bail out if the wallet is deleted mid-wait, and re-throw + * `engineFailure` so a broken plugin surfaces as a rejected + * method call instead of a hang. + */ +function getEngine(): Promise { + return waitForCurrencyEngine(ai, walletId) +} + +async function getTools(): Promise { + return await getCurrencyTools(ai, pluginId) +} + +/** + * Methods that write synced-repo files need the storage wallet, + * not the engine. The repo loads well before the engine, + * so this wait is much shorter than `getEngine`. + * A ready repo always wins: an unrelated engine failure must not + * break storage-backed methods, so the failure check only matters + * while the repo is still missing (the engine pixie died before + * `addStorageWallet`, so the repo is never coming). + */ +function getStorage(): Promise { + // The repo loads inside the queued startup work, so a caller + // waiting on storage wants this wallet at the front too: + bumpEngineQueue(ai, walletId) + + return ai.waitFor((props: RootProps): true | undefined => { + if (props.state.storageWallets[walletId] != null) return true + checkCurrencyWallet(props, walletId) + }) +} +``` + +This is the `waitForCurrencyWallet` pattern (`src/core/currency/currency-selectors.ts:31-53`) with `walletApi` swapped for `engine`, keeping both of its guards (`checkCurrencyWallet` throws for a deleted wallet or an `engineFailure`). `waitForCurrencyEngine` lives in `currency-selectors.ts` since the write-path followup, because the account-level engine methods (`getActivationAssets`, `activateWallet`, `getDisplayPrivateKey`, `getDisplayPublicKey`) now ride the same waiter instead of throwing pre-engine, reading the account's wallet list only after the wait so wallets that finish loading during it are included ([`987632ca`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/987632ca7d487b15ea71e851f14d5b6888ad209b)). The `bumpEngineQueue` calls are phase 2's priority hook ([section 8](#8-phase-history-and-709-disposition)); in phase 1 they are no-ops. + +Method classification. Everything that reads Redux state today keeps its current body and works pre-engine because the cache seeded that state. Everything that touches `engine.` gains an `await getEngine()`: + +| Serves from Redux (no code change) | Engine-gated (`await getEngine()`) | +|---|---| +| `balanceMap` / `balances` | `makeSpend`, `signTx`, `broadcastTx`, `saveTx` | +| `name`, `renameWallet`* | `sweepPrivateKeys`, `signMessage`, key export | +| `fiatCurrencyCode`, `setFiatCurrencyCode`* | `getMaxSpendable`, fee/quote paths | +| `enabledTokenIds`, `currencyConfig` accessors | `resyncBlockchain`, `dumpData`, staking / `stakingStatus` | +| `paused`, `changePaused`** | `getTransactions`, `getTransactionCount` ([decision 9.3](#93-gettransactions-stays-engine-gated)) | +| | `getAddresses`, `getReceiveAddress` (cache-assisted: served pre-engine from the per-tokenId address cache on any chain, then reconciled against the engine, [decision 9.4](#94-getaddresses-serves-the-cache-pre-engine-and-reconciles)) | +| | `otherMethods` (delegating stubs from cached names, [section 5.6](#56-yaob-and-reference-stability)) | + +\* Mutations that write synced-repo files (`renameWallet`, `setFiatCurrencyCode`, `changeWalletSettings`) gate on `getStorage()`, not the engine. They dispatch to Redux as today, so the GUI sees the change immediately through the normal update path; no manual `update()` calls, because the one wallet object flows through the existing pixie watcher. `changeEnabledTokenIds` instead waits for the builtin-token definitions (its filter would otherwise silently drop builtin ids on a warm boot) and, since the write-path followup, applies the caller's change as toggles over the current list rather than replacing it, so a call built against a stale cached list cannot erase enablement changes synced from another device ([`3bbc97fd`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/3bbc97fdde7fc8ad550c778f3a0ac921038c4e3c)). `changeWalletStates` at the account level waits for `walletStatesLoaded` for the same reason: a change diffed against cache-seeded records could no-op and then be silently reverted by the load ([`dc1cb707`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/dc1cb70763ef04ac7f030faa80983650c1abdb32)). The plugin/swap settings writers wait for `pluginSettingsLoaded`, merge into the freshly read file rather than rebuilding it from Redux, and serialize per account, so neither a racing load nor a concurrent local write can drop another plugin's settings ([`e9027da6`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/e9027da6eeff15ef4e993d621d0d169af1b98099)). + +\** `changePaused` before the engine exists reduces to updating Redux pause state that `engineStarted` already consults. The GUI's WalletLifecycle tolerates this ([section 7.1](#71-walletlifecycle-no-change-required)). + +The pre-engine serve gate is `opts.forceIndex == null && cachedAddresses.length > 0 && engine == null`. Phase 6's `allowCached` option was removed in phase 7 along with the `hasStableAddresses` gate, so every chain serves the cache and no caller opts in. Correctness moved from a gate to a reconcile: a query answered from the cache is recorded, re-asked of the engine in the background once it loads, and `rememberAddresses` emits `addressChanged` when the engine's first answer differs, so every consumer re-queries. An engine that confirms the cached answer emits nothing. Two details came out of review and are load-bearing rather than incidental: + +- At most ONE reconcile runs per token query. Several consumers can be served the same cached answer before the engine lands (the receive scene alone asks twice, since `getReceiveAddress` goes through `getAddresses`), and only the first engine reply can settle the correction. A second reply would find the correction already consumed and would then overwrite the stored addresses through the same dispatch WITHOUT emitting, which is precisely the silent staleness this mechanism exists to prevent. +- The cache is not served once `engineFailure` is set. A failed engine leaves `walletOutput.engine` null forever, so an unguarded cache serve would answer this method happily for the rest of the session and make a broken wallet indistinguishable from a healthy one on the one method that never reaches `getEngine()`. With the guard the query falls through and rejects with the engine's real error, matching every other engine-backed method. + +The rationale and its accepted tradeoff are [decision 9.4](#94-getaddresses-serves-the-cache-pre-engine-and-reconciles). + +The method bodies were mechanical as predicted; the getter surface was not (~160 lines the first draft's "mostly mechanical" claim never covered): `disklet`/`localDisklet` are backed by storage-wallet state that does not exist yet when a wallet emits from cache (fixed with lazily-built bridgified fallback disklets), `otherMethods` needed a stable bridgified `{}` plus post-engine caching for yaob reference rules, `publicWalletInfo` needed a Redux-backed getter for the [9.1](#91-new-walletcachejson-not-an-extended-publickeyjson) upgrade seam, and `getStorage`'s failure ordering (a ready repo wins over an unrelated `engineFailure`) was unspecified until review forced it. + +### 5.5 Save path + +One `cacheSaver` sub-pixie at the ACCOUNT level, the only writer of the cache (phases 1 through 6 had one per wallet; [decision 9.6](#96-one-account-cache-file-not-per-wallet-files) covers the reversal): + +- On each pixie update, compare the account slices against the last-saved snapshot, plus a stamp of every active wallet's cache-relevant Redux references (`addresses`, `balanceMap`, `enabledTokenIds`, `fiat`, `name`, `otherMethodNames`, `publicWalletInfo`). The slices are immutable, so an unchanged reference means unchanged content and this stays a reference scan. +- On change, write the next slot, throttled to at most one write per 5 s for the WHOLE ACCOUNT, trailing edge. A sync window where 194 engines all report balances costs one write, not 194. +- A wallet that has not finished loading its authoritative files is skipped, so a cold start never caches placeholder values. +- Wallets that are not running this session keep the entry they already had, so an archived wallet stays warm when it is turned back on; entries for wallets the account no longer has are dropped, so the file cannot grow without bound. +- Guard writes against post-logout, and stop after 3 consecutive failures. +- Each completed write logs its generation, the wallet count it carried, and how long it took. The write is the design's whole cost and nothing else reports it; the throttle bounds the line to the same volume as the `Login:` breadcrumbs. + +Staleness containment is structural: the cache is only ever read for wallet IDs that exist in the account's encrypted key state. A stale entry is dead data, never a resurrected wallet in the UI. + +Write-path staleness, as landed with the followup ([audit trail in section 8.2](#82-followup-write-path-staleness-fixes-landed)): every load that can race an in-window user change merges per field instead of whole-value. Enabled tokens keep a per-toggled-id dirty set that a racing token-file load merges over the loaded list, cleared once the token file write lands ([`3bbc97fd`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/3bbc97fdde7fc8ad550c778f3a0ac921038c4e3c)); account-level custom tokens keep a per-token-id dirty set with the same merge, cleared by a new `ACCOUNT_CUSTOM_TOKENS_SAVED` dispatch once the saver writes ([`7a809228`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/7a80922884fe4f8621764b56785d125efa9ee0fc)); plugin and swap settings track dirty plugin ids per map ([`e9027da6`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/e9027da6eeff15ef4e993d621d0d169af1b98099)). The account-level token saver never writes before `customTokensLoaded`, because it rebuilds the whole file from Redux and a cache-seeded map would delete tokens another device added ([`d8c48b20`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/d8c48b200a2c2b0e67331cda043c81979dab10dd)), and the account cache saver ref-compares `currencyWalletIds`, from which it derives its `legacyWallets` flag at write time ([`9b4fe78b`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/9b4fe78be5c9a8c0e219f3e4fb78650732573ef7)). + +### 5.6 yaob and reference stability + +Nothing new crosses the bridge. The GUI sees the same `EdgeCurrencyWallet` object with the same property surface; the pixie watcher's existing `update()` calls (`currency-wallet-pixie.ts:490-492`) propagate cached-then-live value changes the same way live-only changes propagate today. The account's `currencyWallets` map remains the pixie output object (`src/core/account/account-api.ts:650`), so William's `===` stability requirement holds without any merge getter. + +One visible seam: `wallet.otherMethods`. As landed with the phase-5 followup ([decision 9.8](#98-othermethods-names-are-cached-and-served-as-delegating-stubs)), it is an object of delegating stubs, one per known method NAME: each stub awaits the engine and forwards through the source object (preserving the plugin's `this`), resolving against the live engine on every call so a resync never leaves a stale capture, and rejecting cleanly when the loaded engine lacks the method. Names come from the wallet cache on a warm login (before the engine exists) and from the live engine otherwise, and they persist in `walletCache.json` on every save. The object keeps its identity as long as the known name set is unchanged, which is the common warm-boot case; when a name first appears (a cold login's engine landing, an upgraded cache with no names yet, a plugin adding methods) the getter rebuilds it as a NEW bridgified object, because yaob only serializes the properties an object had when it first crossed the bridge (verified empirically during review: `update()` cannot add properties to an existing facade). The pixie watcher's `update()` then delivers the swapped value, exactly how the old engine-swap propagated. Pre-engine with no cached names this is `{}`, the original guarantee, so property probes like `wallet.otherMethods.foo == null` stay safe. `currencyConfig.otherMethods` keeps the plugin's own object verbatim whenever the plugin is loaded (always the case today); the account cache's per-plugin name list only builds fallback stubs if plugin loading ever defers past the account emit. The GUI call sites patched in [section 7.3](#73-othermethods-one-patch-required) keep working, and on warm logins the FioActions TypeError class retires because the stub exists before the engine does. + +### 5.7 waitForCurrencyWallet semantics + +`waitForCurrencyWallet` resolves when `walletApi` exists. After this change that is pre-engine, so its meaning shifts from "wallet fully ready" to "wallet object exists," and `waitForAllWallets` shifts with it. Internal core callers (`src/core/login/keys.ts:383`, `account-api.ts:726`) want the object, not the engine, so they are unaffected. The GUI call sites were audited individually; findings and required patches are in [section 7](#7-gui-integration-edge-react-gui). Phase 2 made both this call and the internal waiters priority hooks into the engine queue ([section 8](#8-phase-history-and-709-disposition)). + +## 6. Testing + +Adopt the two determinism mechanisms from #703's test suite (the tests are the strongest part of that PR, though the harness credit was optimistic: the fake plugin still needed the engine gate, a creation-order hook, a public-key check gate, and latent fixes before these cases could exist): + +- An engine gate on the fake plugin (`createEngineGate`): block `makeCurrencyEngine` until the test releases it, so "during the cache window" is a controlled state, not a race. +- Saver throttle override (50 ms in tests). + +Cases: + +1. Cold login, no cache files: wallet emits only after engine + name load; identical to master (regression guard). +2. Warm login, engine gated: wallet emits with cached name, fiat, enabled tokens, balances; balance reads match the cache. +3. Engine released: live balances overwrite cached ones; no flicker back to cached values; the wallet object reference is unchanged. +4. `makeSpend` called during the window: pends, then completes after engine release. +5. Engine failure during the window: pending engine-gated calls reject with `engineFailure`. +6. Wallet deleted during the window: pending calls reject with the does-not-exist error; no dangling promise. +7. `renameWallet` during the window: Redux updates, GUI-visible name changes, cache file re-saves with the new name. +8. Logout during a pending throttled save: no write after logout. +9. Corrupt / old-version cache file: cleaner rejects, cold path runs, file is overwritten on next save. +10. Balance change after engine start: cache file updates within one throttle window (checks the saver actually fires). +11. `otherMethods` is `{}` pre-engine and carries the engine's methods (callable through the bridge) post-engine. + +As landed: the case list above was the right spine. All 11 exist in `test/core/currency/wallet/wallet-cache.test.ts`, plus 11 scheduler cases in `engine-scheduler.test.ts` from phase 2. + +As landed with phase 3, in `test/core/account/account-cache.test.ts` ([section 8.1](#81-phase-3-account-startup-cache)): + +12. Cache-coverage exhaustiveness: enumerate the `EdgeCurrencyWallet` properties the wallet-list scene renders pre-engine and assert each is either seeded by `WalletCacheFile` or present in the documented engine-gated set from [section 5.4](#54-makecurrencywalletapi-changes). A newly added wallet property fails the test until it is classified. This is the single-implementation replacement for the compile-time forcing function #703 got from its typed mirror object: there, TypeScript forced a caching decision per property; here, this test does. +13. Warm account login with the account cache present: `makeAccountApi` emits before `loadAllFiles` completes, wallet pixies start immediately, and the cold path (no account cache) is byte-identical to today's account boot. +14. Bulk seed dispatch count: a warm login with N wallets fires exactly two seeding dispatches (one `ACCOUNT_CACHE_LOADED`, one `CURRENCY_WALLETS_CACHE_LOADED`), every wallet gate opens from the bulk tick, and a wallet activated after login still seeds through its pixie's fallback read. + +As landed with the write-path followup, same file ([section 8.2](#82-followup-write-path-staleness-fixes-landed); each mirrors one of the audit's two-device sequences, with the repo-ahead-of-cache divergence produced deterministically by stalling the cache saver for a session): + +15. Custom token added in the boot window: the account token saver never writes the cache-seeded map, and the racing load merges per token id, so a token synced from another device and the in-window addition both reach disk and Redux. +16. Enabled-token toggle against a stale cached list: the call pends on the builtin definitions, lands as a toggle, and the racing token-file load preserves both the toggle and the other device's enablement. +17. Wallet-state change equal to the stale cache: the call waits for the wallet-state load and applies against the loaded record instead of silently no-oping and being reverted. +18. Settings write while Redux is behind the synced file: the writer merges into the freshly read file, so another device's settings survive on disk and a fresh login sees both. + +As landed with the phase-5 followup, same files: + +19. Pre-engine address serve: a warm wallet answers `getAddresses`/`getReceiveAddress` from the cache while its engine is still loading; the cached file carries the addresses per tokenId with balances stripped. +20. Rotating chains still gate: without the hint, a warm-login address query pends until the engine is released, exactly the pre-cache behavior. +21. A cached otherMethods name is callable before the engine exists; the call pends and then forwards. +22. A stale cached name rejects cleanly when the loaded engine lacks the method. +23. A version-1 cache file upgrades on read (warm boot preserved) and the stub set grows once the engine lands, through the bridge. +24. Config-level names persist in `accountCache.json` while the live plugin surface stays verbatim. +25. The cache-coverage classification gains a cache-assisted set (`getAddresses`, `getReceiveAddress`, `otherMethods`), so the exhaustiveness guard still forces a decision for new properties. + +As landed with the phase-6 followup, same file ([section 8.3](#83-phase-6-provisional-receive-address-for-rotating-chains-landed)): + +26. Address reconcile: on a warm login with the engine gated, `getAddresses({})` returns the cached address immediately and emits nothing; once the engine is released and derives a DIFFERENT address, the wallet emits `addressChanged` exactly once and the next query returns the engine's address. +27. Reconcile stays quiet on agreement: an engine that derives the same address the cache served emits no `addressChanged`. +28. `forceIndex` bypasses the cache: `getAddresses({ forceIndex: 0 })` on a gated wallet still waits for the engine, since a forced-index query wants a specific fresh address. +28. GUI affordance render (`RequestScene`): a rotating-chain wallet whose engine-confirmed `getAddresses` never resolves renders the cached address immediately and, once the grace window elapses, shows the "checking for your latest address" row. + +Verification. Performance is graded on physical hardware only; sim timings are not evidence. Measured on a Galaxy S9 (Android 10, release builds, the 194-wallet account, warm login; full method and raw tables in the [physical-device findings doc](https://gist.github.com/j0ntz/63c36e14285a638bc8874cb74e11d58e), [pinned](https://gist.github.com/j0ntz/63c36e14285a638bc8874cb74e11d58e/42ab2ddacf097d093ee8315f5fe750b7cc947d9f)): v2 seeds balances in 341 ms mean from PIN entry to the account-cache emit (range 305-411 ms over five iterations) with zero per-wallet file reads (the bulk loader), and the dispatch/store-transit epsilon is below measurement resolution; develop on the same device pays a ~57 s serial gap after account login plus ~23 s reading the 194 wallet files before balances render; #703 seeds in the same sub-second envelope as v2. Functional behavior (no timing claims) was verified in-app on the iOS sim, evidence on the PRs ([phases 1-2](https://github.com/EdgeApp/edge-core-js/pull/733#issuecomment-5015250816), [gui screenshots](https://github.com/EdgeApp/edge-react-gui/pull/6080), [phase 4](https://github.com/EdgeApp/edge-core-js/pull/733#issuecomment-5040277532), [phase 5](https://github.com/EdgeApp/edge-core-js/pull/733#issuecomment-5052051090)): the full wallet list renders from cache before any engine exists; wallets drain through the concurrency-8 startup queue with tap-to-front prioritization taking effect mid-drain; an in-app enabled-token round trip wrote through the toggle path and persisted through a cache-seeded relaunch; the receive scene's address query lands in the wallet's cache file keyed by tokenId; and the FioActions warm-boot TypeError (17 occurrences per session on phase-4 builds) dropped to zero once cached names exposed pre-engine stubs. The consolidation's own device evidence, which is write-side, is in [section 8.4](#84-phase-7-revert-the-provisional-surface-consolidate-the-cache-file-landed). + +## 7. GUI integration: edge-react-gui + +Every GUI surface that consumes wallet readiness was audited against the new semantics; the [cross-repo sequence diagram](#4-design-overview) shows what these surfaces observe from the far side of the yaob boundary during the cache window. Findings, with the required patches marked (the audit turned out incomplete: one caller surfaced only at runtime, [section 7.3](#73-othermethods-one-patch-required)): + +### 7.1 WalletLifecycle: no change required + +`src/components/services/WalletLifecycle.ts` batches wallet boot (8 concurrent on iOS, 3 on Android; line 27) by walking the sorted list and calling `wallet.changePaused(false)` on paused wallets (119-134, 180). Its only engine-dependent wait is a `watch('syncRatio')` bounded by a 5-second timeout per boot slot (190-200), so wallets that appear pre-engine degrade to "unpause batch, 5 s, next batch" in the worst case; no loop, no stall. The dedup set at line 129 prevents re-queueing while a boot is in flight. Pre-existing (not introduced by this change): a rejected `changePaused` leaks its boot slot (202-204); worth fixing opportunistically, not a blocker. + +### 7.2 waitForCurrencyWallet call sites: one patch required + +- `controllers/action-queue/runtime/checkActionEffect.ts:122-123` reads `wallet.balanceMap` immediately after awaiting the wallet. Pre-engine it now sees cached (possibly stale) balances instead of live ones, which could mis-evaluate a balance effect. **Patch: gate the effect check on engine readiness (`syncRatio` watch), matching what the loan flow already does in `waitForLoanAccountSync.ts:8-13`.** Cached data cannot satisfy the gate because sync status is excluded from the cache file ([section 5.1](#51-cache-file)). +- `controllers/action-queue/runtime/evaluateAction.ts:204-326,489` and `controllers/loan-manager/redux/actions.ts:126,229` feed the wallet into borrow engines and `broadcastTx`/`makeSpend`. Those calls now pend on `getEngine()` internally and then complete; correct behavior, just later. No change. +- `controllers/action-queue/display.ts`, `push.ts`, `CreateWalletCompletionScene.tsx:145` read only config/info or set UI status. Engine-free. No change. + +### 7.3 otherMethods: one patch required + +No wallet-list-render path touches `otherMethods`. All uses sit inside specific flows (FIO, migrate/sweep, staking adapters, WalletConnect, resync menu). The call sites that can race the cache window: + +- `Services.tsx:84-94` runs `refreshAllFioAddresses` after `await account.waitForAllWallets()`, and `FioActions.tsx:39` then calls `fioWallet.otherMethods.fetchFioAddresses(...)`. With `waitForAllWallets` now resolving pre-engine, that is a method call on `{}`. **Patch: wait for engine readiness per FIO wallet via the `waitForWalletOtherMethods` helper (added in #6080; 10-minute bound), skipping and retrying wallets that never become ready.** Implementation also caught `FioService.ts`'s periodic expired-domain check, which the original audit missed and which surfaced as a live red-alert crash on the sim; fixing it took the `waitForWalletOtherMethods` helper (42 lines, its 10-minute bound sized for a 194-wallet account), a skip-and-retry rework, and a pre-existing stuck-latch fix. "Two small GUI patches" was an audit result, and the audit was incomplete. +- `WalletConnectService.tsx:51-54` only null-probes `wallet.otherMethods.parseWalletConnectV2Payload`, which is safe against the stable `{}` guaranteed in [section 5.6](#56-yaob-and-reference-stability). No change. + +### 7.4 Sync indicators: no change required + +`WalletSyncCircle.tsx` clamps `syncRatio` below 0.05 to a minimum ring (34-36) and hides it only above 0.999 (75), so a cached-balance wallet at ratio 0 renders as a normal syncing wallet, balances visible, ring spinning. `useAccountSyncRatio.tsx:54` treats missing ratio as 0 for the account progress bar. `BuyCrypto.tsx:90` and `useIsAccountFunded.ts:21` already treat `< 1` as unsynced, which remains the right conservatism for spend paths. + +### 7.5 Receive scene: no change required + +`RequestScene.tsx` calls `wallet.getAddresses({ tokenId })` on mount and subscribes to the wallet's `addressChanged` event, re-running that query whenever it fires. That is exactly the contract [decision 9.4](#94-getaddresses-serves-the-cache-pre-engine-and-reconciles) provides: the first call is answered from the cache so the QR renders immediately on a warm login, and if the engine goes on to derive a different address the wallet emits `addressChanged` and this handler picks it up. Phase 6's provisional affordance and its `allowCached` opt-in were reverted in phase 7 ([decision 9.9](#99-the-provisional-receive-affordance-reverted)); no scene code replaced them. + +## 8. Phase history and #709 disposition + +Phase 2 candidates as designed, followed by how the shipped mechanism diverged: + +- Gentle engine scheduling: stagger or defer engine startup, and prioritize a wallet's engine when the user taps it (via the waiter hooks from [section 5.4](#54-makecurrencywalletapi-changes) and [section 5.7](#57-waitforcurrencywallet-semantics)). Every engine still starts eventually; wallets must sync, receive, and update balances without being opened. What changes is only how aggressively starts are packed into the seconds after login. +- The one #709 commit worth keeping: the `balanceMap` reducer returning the existing `Map` when the value is unchanged. William called this one correct on the call. Land it as its own small PR. +- The rest of #709 (throttle 50 to 200 ms, 300 ms debounce, `hasYaobVisibleChange` field lists) is tabled. William's assessment: it duplicates yaob's own `===` diffing, and aggressive "nothing changed" suppression has previously caused stale-balance support tickets. Re-measure after phase 1 lands; most of the churn it fights should be gone once engines are not racing the login render. +- If bridge traffic still hurts, improve diffing in yaob itself rather than shimming around it in core. + +What actually shipped diverged in mechanism, not intent: + +- The sketch said "stagger or defer `engineStarted`", but the login jank lives in engine creation (the heavy pixie block), so the shipped design queues that block instead (`engine-scheduler.ts`, concurrency 8), with a cold-bypass invariant keeping first login byte-identical. +- "waitForCurrencyWallet becomes the natural hook" assumed one hook point; `account.waitForCurrencyWallet` is a watch-based reimplementation separate from the internal selector, so the hook landed in both, and the queue interacts with [section 5.4](#54-makecurrencywalletapi-changes)'s own waiters (a rename or spend during the window would otherwise wait behind the whole queue), which forced priority bumps in `getEngine`, `getStorage`, and `changePaused(false)` as well. +- Pooling created failure classes the first draft never had to discuss: slot starvation (one wedged wallet permanently shrinking the pool; fixed with a 30s watchdog that force-releases and temporarily over-admits, degrading to master's unbounded behavior) and lost one-shot priority signals (a bump arriving before the wallet reaches the queue; fixed with sticky bumps carrying a 30s TTL, since post-startup engine calls bump constantly and would otherwise mark every wallet "asked for" by the next login). Both came out of adversarial review, not this spec. +- redux-pixies' `waitFor` evaluates against a stale `propsCache` after pixie destroy, so deletion-while-queued needed the engine pixie converted to `{update, destroy}` form with a destroy flag; most of the phase 2 diffstat is that re-indent. + +### 8.1 Phase 3: account startup cache + +Queued 2026-07-20 after auditing #703 against the shipped v2; landed 2026-07-21 on [#733](https://github.com/EdgeApp/edge-core-js/pull/733) as designed below, with the deferred loads hardened in review (bounded retry, terminal-failure plumbing for every repo waiter, and pre-storage fallbacks for the account disklets). The finding: v2 never captured #703's account-level win. On this branch the account boot is byte-identical to master, and no wallet can even read its `walletCache.json` until the whole chain completes: `waitForPlugins` then `loadBuiltinTokens` then account repo `addStorageWallet` then `loadAllFiles` (`loadAllWalletStates` + `loadCustomTokens` + `reloadPluginSettings`) then `makeAccountApi` (`account-pixie.ts:93-107`), because wallet pixies spawn from `currencyWalletIds`, which stays empty until `ACCOUNT_KEYS_LOADED` fires inside `loadAllWalletStates` (`currency-pixie.ts:36-38`, `account-reducer.ts:164-190`, `account-files.ts:104,134`). #703 proved the bypass works: its cache path awaits only `waitForPlugins` and the account repo sync, emits the account, and runs `loadBuiltinTokens` + `loadAllFiles` fire-and-forget (#703 `account-pixie.ts:178-202`). A large share of #703's measured 5x plausibly lives in this bypass, not in its wallet mirroring. + +The port, v2-style (same seed-then-overwrite pattern as [section 5.2](#52-load-path), no mirror objects): + +- One account-level cache file, per #703's one-file layout: the per-wallet files stay for wallet UI state, but the account boot inputs are cached in a single `accountCache.json` on the account's local disklet, holding what `ACCOUNT_KEYS_LOADED` and its siblings would produce: wallet states (archived/hidden/sortIndex), the wallet-info list needed to populate `currencyWalletIds`, custom token definitions, and plugin settings. Constraint carried from [decision 9.2](#92-privacy-coins-get-the-same-plaintext-cache-as-every-other-chain)'s analysis: nothing beyond what the plain disklet already exposes; private key material never enters the file. +- A new `ACCOUNT_CACHE_LOADED` action seeds that state (including `keysLoaded`) immediately after `waitForPlugins`, so `makeAccountApi` emits and wallet pixies start their cache reads without waiting for repo sync or file loads. +- `loadBuiltinTokens` and `loadAllFiles` then run deferred and overwrite the seeded state authoritatively, with the same dirty-wins guards the wallet reducer already uses, and the account cache re-saves through a throttled saver watching the relevant account slices (learning from #703's custom-token bug: the saver's dirty set must include account-level `customTokens`, the exact source its saver missed). +- Cold path (no account cache): today's boot, unchanged, regression-guarded by [test case 13](#6-testing). +- Bulk seeding, one store tick for all wallets: the per-wallet cache reads move out of the individual wallet pixies into one loader that runs as soon as the account cache seeds `currencyWalletIds`. It reads every wallet's `publicKey.json` + `walletCache.json` concurrently and dispatches a single `CURRENCY_WALLETS_CACHE_LOADED` action carrying all seeds (public info + UI state per wallet), so a warm login costs two seeding dispatches total (account, then bulk wallet) instead of two per wallet, and every pixie and watcher evaluates once against fully-seeded state instead of ~2N times. This is batching, not notification suppression: every consumer is still notified, once, with the final state, so it carries none of the missed-update risk that tabled #709 ([section 8](#8-phase-history-and-709-disposition)). The per-wallet read inside the wallet pixie stays as the fallback for wallets activated after login and for the no-account-cache cold path. Per-wallet files remain the write targets; the file-layout tradeoffs and the trigger for revisiting them are [decision 9.6](#96-one-account-cache-file-not-per-wallet-files). The one store transit that remains after bulk seeding is pinned to this architecture, not tunable: the wallet object's getters read Redux, and feeding them from a second non-Redux path would reintroduce a shadow copy. Its plausible ceiling is one reducer pass plus one watcher evaluation for a single batched action. +- Housekeeping in the same phase: drop the duplicate `publicKey.json` read inside the queued engine block (`currency-wallet-pixie.ts:752`), and add [test cases 12-14](#6-testing). +- Validation, ran 2026-07-21 on a physical Galaxy S9 ([findings doc](https://gist.github.com/j0ntz/63c36e14285a638bc8874cb74e11d58e)): the cross-build A/B against #703 and develop on the same 194-wallet account measured v2's warm seed at 341 ms mean (PIN entry to account-cache emit), the file-read window at zero (so [decision 9.6](#96-one-account-cache-file-not-per-wallet-files)'s revisit trigger did not fire), and the dispatch window below measurement resolution; v2 and #703 are within noise of each other, versus develop's ~57 s gap plus ~23 s of file reads. + +```mermaid +flowchart LR + subgraph now[v2 today] + A1[waitForPlugins] --> B1[loadBuiltinTokens] --> C1[account repo sync] --> D1[loadAllFiles] --> E1[makeAccountApi] --> F1[wallet cache reads begin] + end + subgraph p3[with phase 3, warm login] + A2[waitForPlugins] --> B2[read accountCache.json
ACCOUNT_CACHE_LOADED] --> E2[makeAccountApi] --> F2[bulk read all wallets' cache files
one CURRENCY_WALLETS_CACHE_LOADED] --> G2[every wallet gate opens
from a single store tick] + B2 -.deferred, overwrites seeded state.-> D2[repo sync + loadAllFiles + builtinTokens] + end +``` + +### 8.2 Followup: write-path staleness fixes (landed) + +Queued 2026-07-21 from the [write-path audit](https://gist.github.com/j0ntz/94c3e64779a92e8a1ae1896f4d7d3d6f)'s confirmed findings and landed the same day on [#733](https://github.com/EdgeApp/edge-core-js/pull/733). All fixes reuse existing patterns ([decision 9.7](#97-per-concern-load-gates-not-one-loadallfiles-promise)); no new surfaces: + +1. The account token saver is gated on `customTokensLoaded`, closing the missing gate that could permanently delete a cross-device custom token ([`d8c48b20`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/d8c48b200a2c2b0e67331cda043c81979dab10dd)). +2. Whole-valued dirty guards became per-field merges: custom tokens per token id, cleared once the saver writes them to disk ([`7a809228`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/7a80922884fe4f8621764b56785d125efa9ee0fc)); enabled tokens per toggled id, with `changeEnabledTokenIds` applying the caller's change as toggles over the current list ([`3bbc97fd`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/3bbc97fdde7fc8ad550c778f3a0ac921038c4e3c)); plugin/swap settings per plugin id, with the writers merging into the freshly read file instead of rebuilding it from Redux, serialized per account ([`e9027da6`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/e9027da6eeff15ef4e993d621d0d169af1b98099)). +3. `changeWalletStates` gates on `walletStatesLoaded`, so the written record bases on loaded state ([`dc1cb707`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/dc1cb70763ef04ac7f030faa80983650c1abdb32)). +4. The account-level engine methods (`getActivationAssets`, `activateWallet`, `getDisplayPrivateKey`, `getDisplayPublicKey`) wait via the shared `waitForCurrencyEngine` selector instead of throwing pre-engine, reading the wallet list after the wait ([`987632ca`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/987632ca7d487b15ea71e851f14d5b6888ad209b)). +5. `currencyWalletIds` joined the account-cache saver's ref-compare set (theoretical, one line; [`9b4fe78b`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/9b4fe78be5c9a8c0e219f3e4fb78650732573ef7)). + +The four audit scenarios are regression-guarded by [test cases 15-18](#6-testing) ([`16ff1fef`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/16ff1fefdacb79059edd74138b66dfc538284246)); reaching them deterministically needed the fake sync server to accept the hash-suffixed store routes it hands out ([`1e2d4808`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/1e2d48086311ed27db5b8aed251085ba629668e5)). + +### 8.3 Phase 6: provisional receive address for rotating chains (landed) + +Queued 2026-07-23 from review feedback that the receive/QR path always waits on the engine, felt slow on rotating chains. Landed across three repos: the core `allowCached` opt-in ([section 5.4](#54-makecurrencywalletapi-changes)), the receive-scene provisional UI (since reverted, [section 7.5](#75-receive-scene-no-change-required)), and the plugin flags in edge-currency-accountbased. It is a deliberate product change: it accepts informed address reuse for a user who acts within the pre-engine window in exchange for an instant receive screen, made visible by the affordance rather than silent ([decision 9.9](#99-the-provisional-receive-affordance-reverted)). Coverage: unit tests assert the rotating-chain `allowCached` serve, the programmatic gate (a plain query still waits), that `forceIndex` bypasses the cache even with `allowCached`, and (gui side) that the affordance row renders on a rotating chain once the grace window elapses while the engine-confirmed query stays pending ([test cases 26-28](#6-testing)). Also folded in a review finding one step removed from phase 6: a terminal deferred-boot failure left `bulkWalletSeedPending` stuck true, wedging every wallet pixie ([`c532ebee`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/c532ebee), clearing the flag on `ACCOUNT_LOAD_FAILED` alongside the existing `ACCOUNT_KEYS_LOADED` backstop). + +### 8.4 Phase 7: revert the provisional surface, consolidate the cache file (landed) + +Two ordered parts, the revert first so the storage change landed on a smaller surface. + +Reverted: the receive-scene provisional affordance, the core `allowCached` opt-in, and the `EdgeCurrencyInfo.hasStableAddresses` gate; [edge-currency-accountbased#1076](https://github.com/EdgeApp/edge-currency-accountbased/pull/1076) was closed and dropped from scope. Serving a cached address silently is now an accepted edge case ([decision 9.9](#99-the-provisional-receive-affordance-reverted)). The commits were dropped from the branches rather than reverted on top, so the history never contains the add-then-remove cycle. + +The revert exposed a gap the design had assumed away. The surviving requirement was that consumers pick up a changed address once the engine loads, and nothing did: `addressChanged` is only emitted when a running engine reports a rotation, and the cached serve returned before the engine was ever asked, so `rememberAddresses` never ran either. A consumer would have held the cached address indefinitely. Phase 7 therefore added core-side reconciliation ([section 5.4](#54-makecurrencywalletapi-changes)) rather than only deleting code, which fixes every consumer instead of the one scene the affordance covered. + +Consolidated: `accountCache.json` absorbed every wallet's cache and its `publicKey.json`, reversing [decision 9.6](#96-one-account-cache-file-not-per-wallet-files). What the measurements said: + +| | Before | After | +| --- | --- | --- | +| Boot reads (194-wallet account) | 389 files, 93.8 KiB | 1 file, 105.5 KiB | +| Throttled writers | one per wallet (194) | one per account | +| Writes per 5 s sync window | up to 194 by structure, 100 measured | 1 | +| On-disk cache | 1387 files, 330.1 KiB | 2 slots, 211.0 KiB | + +The read-side reduction is structural, not a latency win: [section 8.1](#81-phase-3-account-startup-cache)'s S9 A/B had already measured the per-wallet read window at zero, so those 389 reads were never on the critical path. The write-side consolidation is the actual benefit. The tradeoff is that every write is now the whole account rather than one wallet's ~276 B. + +Torn writes needed a different answer than the design assumed: the disklet exposes no rename on either platform, so write-temp-then-rename is not implementable. iOS already writes atomically; Android does not. Generations therefore alternate between two slots ([decision 9.10](#910-two-slot-alternation-because-the-disklet-has-no-rename)). Migration: a version-1 file sends a device to the per-wallet reads once, and the saver folds them into the consolidated file; the old per-wallet files are left in place as a recovery net and are no longer written. Coverage: [test cases 26-28](#6-testing) for the reconcile and the `forceIndex` bypass, plus a torn-write recovery case and a pre-consolidation upgrade case. + +#### Physical-device validation of the consolidation + +The consolidation's claims are write-side, so the evidence that decides them is on hardware: what a post-login sync window actually costs in writes, what one write costs at full account size, and whether a damaged slot degrades to the other one rather than to a cold boot. Four attempts were needed to produce it. The three that produced nothing failed on device access, never on the design, and are recorded here once rather than run by run: + +| Attempt | Outcome | +| --- | --- | +| 2026-08-01 | No measurement. Reported at the time as "an Android build did not fit alongside the iOS verification", which was a prediction rather than a check. The real reason is the next row. | +| 2026-08-01 | The device's Edge install carried no login stash (`files/local` absent, no `accountCache*.json`), so there was no account to warm-boot. Seeding one needs a full password login, and the account password is not available to an automated run. | +| 2026-08-03 | Seeding by QR (the account-less phone mints an edge-login lobby and a logged-in iOS sim approves it) validated everywhere except the one step that needs the phone's UI: the device sits behind a lock-screen PIN, a third credential distinct from both the account password and the account PIN. Proven on the way: `edge://edge/` routes through the GUI's deep-link parser into the approver scene's `fetchLobby`, and the QR decodes off an `adb` screenshot with Core Image alone, so the approving simulator needs no camera. | +| 2026-08-04 | PIN supplied, device seeded over that QR path, measurements below. One further obstacle worth recording: the APK on the device was a debug build with no reachable Metro, so an unlocked phone was still un-drivable until a release APK carrying this branch's core replaced it, and `run-as` needs that release build marked debuggable or the cache files cannot be read at all. | + +Measured on a Galaxy S9 (SM-G960U, Android 10) running a release build of this branch, account `edge-funds`, 146 to 156 currency wallets: + +| | | +| --- | --- | +| Consolidated file on disk | two slots, 89.6 KiB each at 146 wallets, 101.9 KiB at 156 | +| Writes across a warm login plus a 3-minute sync window | 22 | +| One full-account write under sync-window load | 1.3 to 3.5 s, median 2.1 s (n=12) | +| One full-account write once the window quiets | 76 ms | +| Warm account emit | same second as key decryption, and 12 to 14 s ahead of the deferred loads finishing | +| Boot with the newest slot truncated | still emits from cache off the older slot, no crash, and the next write targets the damaged slot | + +The result that matters is not the count but what dominates a write. Payload size does not: during the first-login window, writes carrying no wallets at all (a few hundred bytes) took up to 11.4 s, while a full 89.6 KiB write took 76 ms once engine startup quieted down. What a write costs is JS-thread and bridge contention during engine startup, not the number of bytes. So [decision 9.6](#96-one-account-cache-file-not-per-wallet-files)'s write-amplification tradeoff is real in bytes and close to irrelevant in time on this hardware, which strengthens the consolidation rather than qualifying it: collapsing N throttles into one removes work from exactly the window where each unit of work is most expensive. + +The per-wallet counterpart was then measured directly, on the same device and the same account, from a release build at `cae1f073`, the last commit before the consolidation. Its saver logs one line per write, the same instrumentation shape the consolidated numbers above came from, so the two sides are counted the same way. Both builds were driven through the same two logins: a first login with no cache on disk, then a warm login measured over its first 180 seconds. + +| Per 180 s window | Pre-consolidation (a file and a throttle per wallet) | Consolidated (one file, one throttle) | +| --- | --- | --- | +| Writes, first login with no cache | 317 | 18 | +| Writes, warm login | 363 | 22 | +| Most writes in any 5 s span, first login | 100 | 1 by construction | +| Most writes in any 5 s span, warm login | 48 | 1 by construction | +| One write, warm login | 675 ms median, 3.6 s worst (n=453) | 2.1 s median, 3.5 s worst (n=12) | +| Distinct wallets writing in the window | 196 | n/a, one writer | + +The consolidated column is the 2026-08-04 run's numbers. Re-installing the branch build over the pre-consolidation layout in the same session and repeating the warm login reproduced them: 12 writes in the window, 540 ms median, and the migration path folded the per-wallet files back into the consolidated file without a cold boot. + +So the design claim holds in direction and lands well short of its stated ceiling in magnitude: the account never had all 194 wallets dirty inside one 5 s window, and the worst span observed was 100 writes. A per-wallet write is also individually cheaper than a full-account write, which the byte counts already implied. What the consolidation removes is the count, and the count is what the JS thread and the bridge are paying for during engine startup: 363 writes became 22 across the same warm window, and the peak second-by-second pressure dropped from 48 concurrent writers to one. + +One limit remains. The torn-slot recovery was produced by truncating the newest slot by hand rather than by catching a real kill mid-write: three force-stops timed into the write window all landed between writes and left both slots intact, which is evidence about how narrow the window is, not evidence that the tear cannot happen. + +### 8.5 Followup: write-cost visibility (landed) + +Both items came out of [section 8.4](#84-phase-7-revert-the-provisional-surface-consolidate-the-cache-file-landed)'s own gaps. + +| Gap | Disposition | +| --- | --- | +| The per-wallet write count was derived from code structure, not measured | Measured, on the same S9 and account, from a release build at `cae1f073`. Numbers and what they change are in [section 8.4](#84-phase-7-revert-the-provisional-surface-consolidate-the-cache-file-landed). | +| Getting the consolidated write numbers needed a patched build, so the next reader would pay for one too | The saver now logs each write ([section 5.5](#55-save-path)). Both sides of the table above were counted off that line's shape, so reproducing the comparison needs no patch on the current build. | + +The measurement also settled a question the design left open in the other direction. [Decision 9.6](#96-one-account-cache-file-not-per-wallet-files) worried about write amplification in bytes, and the byte cost is real: a full-account write carries the whole 89.6 KiB where a per-wallet write carried ~276 B, and a single per-wallet write is correspondingly quicker (675 ms median against 2.1 s). It does not matter, because the count moves by an order of magnitude more than the per-write cost does, in the exact window where the JS thread is most contended. + +## 9. Decisions + +[Decisions 9.1 through 9.5](#91-new-walletcachejson-not-an-extended-publickeyjson) all survived implementation and three review rounds unchanged; the reviews attacked stale cached balances, the publicKey seam, and the otherMethods contract, and each rejection cited this document. + +### 9.1 New walletCache.json, not an extended publicKey.json + +Both options came out of the call. Chosen: a new sibling file. + +- `asPublicKeyFile` (`currency-wallet-cleaners.ts:397`) is tied to a re-derivation upgrade path: `getPublicWalletInfo` discards the file when `tools.checkPublicKey` rejects it (`currency-wallet-pixie.ts:585-591`). Entangling UI state with key-cache invalidation means a key-format upgrade silently drops names and balances, and any schema change to UI state risks the key cache. +- A separate versioned cleaner makes UI-state schema evolution a one-line version bump with a well-defined fall-through ([section 5.2](#52-load-path), [test case 9](#6-testing)). +- Cost of the extra file: one additional small read per wallet from the same disklet, issued in the same step. Negligible against the engine-load savings this design exists to bank. +- The alternative (one file, "let it have the wrong name") saves that read and one cleaner. It is strictly workable; nothing else in the design changes if review prefers it. + +### 9.2 Privacy coins get the same plaintext cache as every other chain + +Concern: balances for Monero, Zcash, and Pirate Chain are not derivable from public on-chain data, so writing them to the plain disklet looks like a new exposure. Investigation shows it is not: `publicKey.json` already stores each Monero wallet's private view key (`moneroViewKeyPrivate`, edge-currency-monero `MoneroTools.ts:72-88`) and each Zcash/Pirate Chain wallet's unified viewing key (edge-currency-accountbased `ZcashTools.ts:142-160`, `PiratechainTools.ts:133`) on the same unencrypted `walletLocalDisklet`. Anyone who can read `walletCache.json` can already read the viewing keys and compute the balances. Encrypting only the new file would add code and protect nothing. + +- Chosen: uniform plaintext caching, no per-chain branching in phase 1. +- Alternative worth doing separately if the exposure itself is judged unacceptable: move both files to `walletLocalEncryptedDisklet` (created alongside the plain one, `currency-wallet-pixie.ts:95-100`) for chains that declare a privacy flag. That is a hardening task about viewing keys, not about this cache, and it should not gate this work. + +### 9.3 getTransactions stays engine-gated + +Transaction history is not in the cache file. + +- The tx list state loads from per-tx disk files inside the engine pixie (`loadTxFileNames`, `currency-wallet-pixie.ts:104`), after repo sync; serving it pre-engine would mean either caching history (a large, fast-growing file that duplicates what the tx files already persist) or reordering those loads, both well beyond a few hundred lines. +- The login-path UI does not need it: the wallet list renders names and balances; history renders after tapping into a wallet, by which time the engine is loading anyway, and phase 2's tap-prioritization shrinks that wait further. +- Alternative (cache the most recent N transactions for instant detail-scene paint) is a follow-up candidate once real-device timings show whether the detail scene wait is noticeable. + +### 9.4 getAddresses serves the cache pre-engine, and reconciles + +Originally: addresses were not cached at all, because address correctness is engine policy - UTXO chains rotate fresh addresses, and a cached "last known fresh address" can silently promote address reuse. Phase 5 then cached them behind an `EdgeCurrencyInfo.hasStableAddresses` hint, so only chains that declare their addresses never rotate served the cache. Phase 7 removed the hint: every chain serves its cached address pre-engine. + +- What changed is the product decision, not the analysis. Serving a possibly-superseded address silently was reviewed and accepted as a tolerable edge case, which removes the reason for a per-chain gate and for the plugin-side flag work. +- Reuse is bounded by reconciliation rather than by a flag. Any query answered from the cache is re-asked of the engine in the background, and the wallet emits `addressChanged` when the answers differ, so consumers re-query and land on the engine's address. An engine that agrees stays silent, which is the common case. +- `forceIndex` still waits for the engine, since a caller naming an index wants a freshly derived address that only the engine can produce. +- The cache is keyed per tokenId and stores no balances, so a token's answer can never masquerade as the parent chain's. + +### 9.5 Scope stays on the wallet path; account-level latency untouched + +`makeAccountApi` still waits for plugin load, account repo sync, and `loadAllFiles` (`account-pixie.ts:52-118`). The 5x QA measurement came from the wallet path, and the account path's blockers are login cryptography and encrypted-repo sync, which this file-cache pattern does not apply to without a security design of its own. If phase 1 device timings show account setup dominating on some hardware, that becomes its own investigation rather than scope creep here. + +### 9.6 One account cache file, not per-wallet files + +The login read path could be one account-level file, N per-wallet files, or a hybrid. Originally chosen: per-wallet `walletCache.json` files as the write targets, with [section 8.1](#81-phase-3-account-startup-cache)'s bulk loader as the only login-path reader. Reversed in phase 7: one `accountCache.json` holds the account boot state and every wallet's, public keys included. + +- The read path is not what reversed it, and the original reasoning here was sound. [Section 8.1](#81-phase-3-account-startup-cache)'s S9 A/B measured the per-wallet file-read window at zero, so this decision's own revisit trigger never fired. Those reads run after the account has already emitted, so collapsing 389 of them into 1 removes work that was never on the critical path. Anyone expecting the consolidation to shorten warm login should read that measurement first. +- The write path reversed it. Per-wallet files imply per-wallet savers, so a 194-wallet account carries 194 independent 5 s throttles, and the post-login sync window (every engine reporting balances at once) has all of them writing concurrently. One file means one throttle for the whole account: at most one write per window no matter how many wallets changed. +- The three costs this decision cited against a blob are real, and were addressed rather than argued away. Write amplification: each write is now the whole account (105.5 KiB measured on the 194-wallet account) instead of ~276 B per changed wallet, traded for roughly 194x fewer bridge calls per window. Device timings later showed the byte cost is not what a write actually pays for ([section 8.4](#84-phase-7-revert-the-provisional-surface-consolidate-the-cache-file-landed)). Corruption blast radius: generations alternate between two slots and the reader takes the newest that still parses ([decision 9.10](#910-two-slot-alternation-because-the-disklet-has-no-rename)), so an interrupted write costs one generation of staleness rather than every wallet's warm boot. Dirty-set breadth, the deepest objection: the saver's change stamp is derived mechanically from the same wallet fields the writer serializes, so the enumeration cannot silently drift the way #703's hand-maintained global dirty set did. +- Alternative, the hybrid fold this decision designated: keep per-wallet write targets and periodically fold them into a read-optimized blob. Rejected now for the same reason the trigger never fired. It optimizes the read path, which measured at zero, while leaving the per-wallet write storm untouched. +- Retained from the original: staleness containment is still structural. The file only carries wallets the account still has, and entries for deleted wallets are pruned on each write, so a consolidated file cannot resurrect a wallet any more than a leftover per-wallet file could. + +### 9.10 Two-slot alternation, because the disklet has no rename + +A single boot-critical file needs a torn-write defense. The design called for the usual write-temp-then-rename. The disklet cannot do it: its interface is `delete`, `getData`, `getText`, `list`, `setData`, `setText`, and the native modules expose exactly those six (`ios/Disklet.m`, `android/.../DiskletModule.java`). There is no rename to call. + +- iOS does not need one: `writeFile` uses `[data writeToFile:options:NSDataWritingAtomic]`, which already writes to a temporary file and renames. A kill mid-write cannot tear the file there. +- Android does need one and lacks it: `writeFile` opens a plain `FileOutputStream` over the target, truncating it first. A kill part-way through leaves a short file. +- Chosen: alternate generations between `accountCache.json` and `accountCache.2.json`, each stamped with a monotonic `sequence`, and read the newest slot that parses. A torn write only ever damages the slot being written, so the other slot is a complete previous generation. This needs no rename and costs one extra file on disk. +- Alternative, add a rename to the disklet package: correct, and worth doing eventually, but it means publishing a dependency for a defense the two-slot scheme already provides. +- Alternative, accept the tear and fall back to the per-wallet files: that is the migration fallback, not a defense, and it decays as those files age (they are no longer written). + +### 9.7 Per-concern load gates, not one loadAllFiles promise + +With authoritative loads deferred behind cache seeding ([section 8.1](#81-phase-3-account-startup-cache)), writes to core-owned state must not race their own loads. Two shapes were considered: save the `loadAllFiles` promise and block every write until it resolves (proposed in the 2026-07-21 design discussion), or gate each write on exactly the load it depends on. Chosen: per-concern gates. + +- Each write awaits only its dependency: wallet-file writes await the wallet repo, token file writes await `tokenFileLoaded`, settings writes await `pluginSettingsLoaded`, wallet-state writes await the account repo. A rename never waits on custom tokens. +- Every waiter rejects terminally when the deferred boot loads fail repeatedly (`ACCOUNT_LOAD_FAILED` after 3 retries), so a broken disk produces errors, not hangs. A global promise needs the same failure plumbing while serializing unrelated concerns. +- The alternative (one global gate) is correct but strictly coarser; rejected for re-creating the old "wait for everything" model on the write path. +- The [write-path audit](https://gist.github.com/j0ntz/94c3e64779a92e8a1ae1896f4d7d3d6f) validates the shape and found that where gaps exist they are individual missing gates or wrong-granularity merges, never the per-concern structure itself; the fixes landed with the write-path followup ([section 8.2](#82-followup-write-path-staleness-fixes-landed)). + +### 9.8 otherMethods names are cached and served as delegating stubs + +Supersedes the original [section 5.6](#56-yaob-and-reference-stability) choice (a bridgified `{}` swapped for the engine's methods when it lands). Chosen with the phase-5 followup: cache the method NAMES per wallet (and per plugin at the account level) and expose one delegating stub per name ([`e1f7c428`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/e1f7c428e9d5dfc6bf4cb3de87ba0655a3a4a8ab), hardened in [`bb3a1690`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/bb3a16903a63493cff631009f100c0c5501ca3d0)). + +- The [section 5.4](#54-makecurrencywalletapi-changes) waiters made stubs nearly free: a stub is `await getEngine()` plus a property lookup, the same shape every engine-backed method already has. With names cached, a warm login exposes a callable method surface before the engine exists, and the FioService/FioActions crash class (calling a method on the pre-engine `{}`) retires on warm boots. +- Names-only is deliberate: caching method names leaks nothing (they are plugin code, not user data), needs no schema per method, and a stale name degrades to a clean rejection at call time. +- Rejected alternative, keep the swap model and patch each GUI caller: the phase-1 GUI audit already missed a caller once ([section 7.3](#73-othermethods-one-patch-required)); making the surface callable-by-name removes the whole class instead of chasing call sites. +- Rejected alternative, one truly permanent mutable object: yaob facades cannot gain properties after first crossing (verified empirically during review), so growth requires an object swap; the shipped model swaps only when the name set grows, keeping identity in the common warm case. +- Config level: the live plugin object stays exposed verbatim (identical `this` and non-function properties); cached names only feed fallback stubs if plugin loading ever defers past the account emit. +- Reopen if: yaob gains property-addition support (the growth swap can then disappear), or a plugin ships otherMethods whose names change per session (the cache would churn; none does today). + +### 9.9 The provisional receive affordance, reverted + +Phase 6 shipped a visible affordance on the receive scene: on a rotating chain the cached address rendered immediately with an inline "checking for your latest address" row and a spinner, reconciling once the engine confirmed. The reasoning was that informed reuse beats a spinner, and that the tradeoff should be visible rather than silent. + +Reverted in phase 7, along with the `allowCached` opt-in that fed it and the plugin flags in [edge-currency-accountbased#1076](https://github.com/EdgeApp/edge-currency-accountbased/pull/1076) (closed). + +- The product call went the other way: serving the cached address silently was accepted as a tolerable edge case, which removes the thing the affordance existed to disclose. +- What survives is the requirement the affordance was one way of meeting: a consumer must end up on the engine's address when it differs. That now lives in the core for every consumer rather than in one scene ([decision 9.4](#94-getaddresses-serves-the-cache-pre-engine-and-reconciles)), which is a better place for it. The receive scene already subscribes to `addressChanged` and needed no change. +- Worth recording, because the revert makes it easy to lose: without the reconcile the revert would have been a silent bug. Nothing emitted `addressChanged` when an engine finished loading and disagreed with the cache, and the cached serve returned before the engine was ever asked, so a consumer would have latched the cached address indefinitely. The check that found this is the reason phase 7 added core-side reconciliation rather than just deleting code. + +## 10. References + +- Physical-device performance findings (Galaxy S9, 3-way vs develop and #703): [findings doc](https://gist.github.com/j0ntz/63c36e14285a638bc8874cb74e11d58e). +- Prior implementation: [edge-core-js#703](https://github.com/EdgeApp/edge-core-js/pull/703), design doc at `docs/wallet-cache.md` on branch `paul/cacheWallets`. +- Tabled follow-up: [edge-core-js#709](https://github.com/EdgeApp/edge-core-js/pull/709). +- Architectural direction: William's Signal messages (screenshots on Asana subtask "Login perf - Check William's Willingness", 1214006460304787) and the William x Jon call of 2026-07-17. +- Asana: [Login Perf - Wallet Cache v2](https://app.asana.com/1/9976422036640/project/1213843652804305/task/1216673467164267), predecessor [core: wallet cache (PR)](https://app.asana.com/1/9976422036640/project/1200382638405084/task/1213598116503346), parent [Login - Get Halloumi Reviewed](https://app.asana.com/1/9976422036640/project/1213843652804305/task/1214006460304785). + + diff --git a/src/types/types.ts b/src/types/types.ts index d7fe8fec5..edc99648b 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -516,6 +516,7 @@ export interface EdgeCurrencyInfo { canAdjustFees?: boolean // Defaults to true canImportKeys?: boolean // Defaults to false canReplaceByFee?: boolean // Defaults to false + customFeeTemplate?: EdgeObjectTemplate // Indicates custom fee support customTokenTemplate?: EdgeObjectTemplate // Indicates custom token support requiredConfirmations?: number // Block confirmations required for a tx diff --git a/test/core/account/account-cache.test.ts b/test/core/account/account-cache.test.ts new file mode 100644 index 000000000..5dc4669a0 --- /dev/null +++ b/test/core/account/account-cache.test.ts @@ -0,0 +1,848 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' +import { base64 } from 'rfc4648' + +import { + ACCOUNT_CACHE_FILES, + accountCacheSaverConfig +} from '../../../src/core/account/account-cache-file' +import { walletCacheLoaderHooks } from '../../../src/core/currency/wallet/wallet-cache-loader' +import { + EdgeAccount, + EdgeContext, + EdgeFakeWorld, + 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 + /** 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. */ + 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) + } + + // Push the session's writes to the sync server, + // so a second device can see them: + await account.sync() + await account.logout() + + return { context, world, 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 + }) + + afterEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + walletCacheLoaderHooks.onAccountSeed = undefined + walletCacheLoaderHooks.onBulkSeed = undefined + walletCacheLoaderHooks.onFallbackSeed = undefined + accountCacheSaverConfig.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') + + // 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 && + 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('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() + + // 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') + // One file carries the whole account, wallets included, so this + // fixture is the entire warm-boot state: + const extraFiles: { [path: string]: string } = {} + for (const path of ACCOUNT_CACHE_FILES) { + try { + extraFiles[localPath(accountRepoInfo.id, path)] = + await account.localDisklet.getText(path) + } catch (error: unknown) {} + } + 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') + + // 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({ + currencyCode: 'FRESH', + displayName: 'Fresh Token', + denominations: [{ multiplier: '100', name: 'FRESH' }], + networkLocation: { contractAddress: '0xFRE54' } + }) + release() + releaseEngines() + await syncPromise + 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('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 + // 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) + let sawMethodName = false + for (const path of ACCOUNT_CACHE_FILES) { + try { + const text = await account.localDisklet.getText(path) + if (text.includes('fakePluginMethod')) sawMethodName = true + } catch (error: unknown) {} + } + expect(sawMethodName).equals(true) + + // The live surface stays verbatim, including in the cache-seeded + // boot window: + const result = + await account.currencyConfig.fakecoin.otherMethods.fakePluginMethod( + 'config' + ) + expect(result).equals('fakePluginMethod called with: config') + await account.logout() + }) + + it('survives a torn write by booting from the other slot', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // Put a generation in each slot: the world's own save filled one, + // and this rename dirties the cache so the next save fills the + // other: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + await wallet.renameWallet('Second Generation') + await snooze(SAVE_WAIT_MS) + + // Truncate the NEWEST slot, which is what a kill part-way through + // a non-atomic write leaves behind on Android: + accountCacheSaverConfig.throttleMs = 5000 + let newestPath = ACCOUNT_CACHE_FILES[0] + let newestSequence = -1 + let newestText = '' + for (const path of ACCOUNT_CACHE_FILES) { + try { + const text = await account.localDisklet.getText(path) + const { sequence = 0 } = JSON.parse(text) + if (sequence > newestSequence) { + newestSequence = sequence + newestPath = path + newestText = text + } + } catch (error: unknown) {} + } + expect(newestSequence).greaterThan(0) + await account.localDisklet.setText( + newestPath, + newestText.slice(0, Math.floor(newestText.length / 2)) + ) + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + + // The older slot is intact, so the login still boots warm while + // the engines are held. Its wallet name is the pre-rename one, + // which is the whole trade: one generation of staleness instead + // of a cold boot: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletIds[0]) + expect(wallet2.name).not.equals(null) + release() + await account2.logout() + }) + + it('rejects a corrupt account cache file and falls back cold', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // 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 + for (const path of ACCOUNT_CACHE_FILES) { + await account.localDisklet.setText(path, '{ "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() + }) +}) + +describe('write-path staleness', function () { + beforeEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.publicKeyCheckGate = undefined + accountCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.publicKeyCheckGate = undefined + accountCacheSaverConfig.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): + await pullOnDeviceA(context, async account => { + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + const text = await wallet.disklet.getText('Tokens.json') + return text.includes('badf00d5') + }) + + // 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}` +} + +/** 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/engine-scheduler.test.ts b/test/core/currency/wallet/engine-scheduler.test.ts new file mode 100644 index 000000000..039d4e712 --- /dev/null +++ b/test/core/currency/wallet/engine-scheduler.test.ts @@ -0,0 +1,411 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' + +import { + ACCOUNT_CACHE_FILES, + accountCacheSaverConfig +} from '../../../../src/core/account/account-cache-file' +import { + engineSchedulerConfig, + getEngineScheduler +} from '../../../../src/core/currency/wallet/engine-scheduler' +import { 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 + accountCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.onEngineCreate = undefined + accountCacheSaverConfig.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) + + // Strip every wallet out of the account cache, so the account + // still boots warm but no wallet has anything cached. Let the + // login's own cache save settle first, so nothing rewrites the + // file afterwards: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForAllWallets() + await snooze(SAVE_WAIT_MS) + accountCacheSaverConfig.throttleMs = 5000 + for (const path of ACCOUNT_CACHE_FILES) { + try { + const cache = JSON.parse(await account.localDisklet.getText(path)) + await account.localDisklet.setText( + path, + JSON.stringify({ ...cache, wallets: {} }) + ) + } catch (error: unknown) {} + } + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + 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/core/currency/wallet/wallet-cache.test.ts b/test/core/currency/wallet/wallet-cache.test.ts new file mode 100644 index 000000000..47e7eadfe --- /dev/null +++ b/test/core/currency/wallet/wallet-cache.test.ts @@ -0,0 +1,877 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' + +import { + ACCOUNT_CACHE_FILES, + accountCacheSaverConfig +} from '../../../../src/core/account/account-cache-file' +import { + EdgeAccount, + 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 } +} + +/** + * Returns the newest readable account-cache slot as raw text. The + * cache alternates between two slots, so a test that wants to inspect + * what was actually written has to pick the current generation. + */ +async function readAccountCache(account: EdgeAccount): Promise { + let best: any + for (const path of ACCOUNT_CACHE_FILES) { + try { + const parsed = JSON.parse(await account.localDisklet.getText(path)) + // A version-1 file predates `sequence` and is always older: + if (best == null || (parsed.sequence ?? 0) > (best.sequence ?? 0)) { + best = parsed + } + } catch (error: unknown) {} + } + if (best == null) throw new Error('No readable account cache') + return best +} + +describe('wallet cache', function () { + beforeEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.freshAddressPatch = undefined + fakePluginTestConfig.omitEngineOtherMethods = undefined + fakePluginTestConfig.onEngineKill = undefined + accountCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.freshAddressPatch = undefined + fakePluginTestConfig.omitEngineOtherMethods = undefined + fakePluginTestConfig.onEngineKill = undefined + accountCacheSaverConfig.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('an engine that finishes creating after deletion is killed', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Hold engine creation open so the wallet can be deleted while + // `makeCurrencyEngine` is still in flight: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const killed: string[] = [] + fakePluginTestConfig.onEngineKill = id => killed.push(id) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForCurrencyWallet(walletId) + await account.changeWalletStates({ [walletId]: { deleted: true } }) + + // `engineStarted`'s destroy has already run with no engine to + // kill, so the startup block itself has to clean this one up: + release() + await snooze(250) + expect(killed).deep.equals([walletId]) + expect(account.currencyWallets[walletId]).equals(undefined) + + 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: + accountCacheSaverConfig.throttleMs = 3000 + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.renameWallet('Ghost Name') + await account.logout() + + // The cancelled write must not have touched the cache: + accountCacheSaverConfig.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) + await account.waitForCurrencyWallet(walletId) + await snooze(SAVE_WAIT_MS) + for (const path of ACCOUNT_CACHE_FILES) { + await account.localDisklet.setText(path, '{ "version": 99 }') + } + await account.logout() + + // A corrupt file means the cold path runs: + 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('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', + 'getMaxSpendable', + 'getNumTransactions', + 'getPaymentProtocolInfo', + 'getTransactions', + 'lockReceiveAddress', + 'makeSpend', + 'resyncBlockchain', + 'saveReceiveAddress', + 'saveTx', + 'saveTxAction', + 'saveTxMetadata', + 'signBytes', + 'signMessage', + 'signTx', + 'split', + 'stakingStatus', + 'streamTransactions', + 'sweepPrivateKeys', + 'syncRatio', + 'syncStatus', + 'unactivatedTokenIds' + ] + + // Cache-assisted surfaces: engine-gated by default, but served + // from the cache pre-engine when it can answer (cached addresses; + // otherMethods stubs from cached names): + const cacheAssisted = ['getAddresses', 'getReceiveAddress', 'otherMethods'] + + // Identity, storage-backed, config, and tools surfaces, which + // never needed an engine in the first place: + const engineFree = [ + '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, + ...cacheAssisted, + ...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('serves cached addresses pre-engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache: query once with the engine running, + // then let the throttled saver persist the answer: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + const live = await wallet.getAddresses({ tokenId: null }) + expect(live[0].publicAddress).equals('fakesegwit') + await snooze(SAVE_WAIT_MS) + + // The engine's answer reached the cache file, balances stripped: + const cache = await readAccountCache(account) + const stored = cache.wallets[walletId] + expect(JSON.stringify(stored.addresses).includes('fakeaddress')).equals( + true + ) + expect(JSON.stringify(stored).includes('nativeBalance')).equals(false) + await account.logout() + + // A warm login serves the cached addresses while the engine is + // 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('emits addressChanged when the engine disagrees with the cache', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache with this engine's answer: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + const primed = await wallet.getAddresses({ tokenId: null }) + expect(primed[0].publicAddress).equals('fakesegwit') + await snooze(SAVE_WAIT_MS) + await account.logout() + + // The next session's engine derives a different address, which is + // what a rotating chain does once the cached one has been used: + fakePluginTestConfig.freshAddressPatch = { segwitAddress: 'rotatedsegwit' } + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + + let changed = 0 + wallet2.on('addressChanged', () => { + ++changed + }) + + // The pre-engine query is served from the cache, silently: + const cached = await wallet2.getAddresses({ tokenId: null }) + expect(cached[0].publicAddress).equals('fakesegwit') + expect(changed).equals(0) + + // Once the engine loads it disagrees, so the wallet tells its + // consumers to re-query, and the re-query returns the new address: + release() + await snooze(SAVE_WAIT_MS) + expect(changed).equals(1) + const confirmed = await wallet2.getAddresses({ tokenId: null }) + expect(confirmed[0].publicAddress).equals('rotatedsegwit') + await account2.logout() + }) + + it('emits addressChanged once when several callers share the cache', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + fakePluginTestConfig.freshAddressPatch = { segwitAddress: 'rotatedsegwit' } + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + + let changed = 0 + wallet2.on('addressChanged', () => { + ++changed + }) + + // Several consumers can be served the same cached answer before + // the engine lands (the receive scene alone asks twice, since + // getReceiveAddress goes through getAddresses). One engine query + // settles the correction for all of them: + const served = await Promise.all([ + wallet2.getAddresses({ tokenId: null }), + wallet2.getAddresses({ tokenId: null }), + wallet2.getReceiveAddress({ tokenId: null }) + ]) + expect(served[0][0].publicAddress).equals('fakesegwit') + expect(changed).equals(0) + + release() + await snooze(SAVE_WAIT_MS) + expect(changed).equals(1) + const confirmed = await wallet2.getAddresses({ tokenId: null }) + expect(confirmed[0].publicAddress).equals('rotatedsegwit') + await account2.logout() + }) + + it('stops serving the cached address once the engine fails', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // A warm cache must not make a wallet whose engine died look + // healthy: the engine is never coming, so the query has to reject + // rather than serve the cache forever: + const { gate, fail } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect( + (await wallet2.getAddresses({ tokenId: null }))[0].publicAddress + ).equals('fakesegwit') + + fail(new Error('Engine exploded')) + await expectRejection( + wallet2.getAddresses({ tokenId: null }), + 'Error: Engine exploded' + ) + await account2.logout() + }) + + it('stays quiet when the engine confirms the cached address', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // This engine derives exactly what the cache holds, the common + // case, so the reconcile must not wake every consumer up: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + + let changed = 0 + wallet2.on('addressChanged', () => { + ++changed + }) + + expect( + (await wallet2.getAddresses({ tokenId: null }))[0].publicAddress + ).equals('fakesegwit') + release() + await snooze(SAVE_WAIT_MS) + expect(changed).equals(0) + await account2.logout() + }) + + it('keeps the engine gate when forceIndex is set', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // The plain query is served from the cache, but a caller naming a + // specific index wants a freshly derived address, which only the + // engine can produce, so that query still waits: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + const cached = await wallet2.getAddresses({ tokenId: null }) + expect(cached[0].publicAddress).equals('fakesegwit') + + let settled = false + const forcedPromise = wallet2 + .getAddresses({ tokenId: null, forceIndex: 0 }) + .then(addresses => { + settled = true + return addresses + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + release() + const forced = await forcedPromise + expect(forced[0].publicAddress).equals('fakesegwit') + await account2.logout() + }) + + it('calls a cached otherMethods name before the engine exists', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // The engine's method names are in the cache, so a warm login + // exposes a delegating stub pre-engine. Calling it pends on the + // engine and then forwards: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.otherMethods.testMethod).not.equals(undefined) + let settled = false + const callPromise = wallet.otherMethods + .testMethod('early') + .then((result: string) => { + settled = true + return result + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + release() + expect(await callPromise).equals('testMethod called with: early') + await account.logout() + }) + + it('rejects a stale cached method name the engine lacks', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // The next session's engines are built WITHOUT otherMethods, so + // the cached `testMethod` name is stale. The stub still exists, + // and rejects cleanly once the engine loads without the method: + fakePluginTestConfig.omitEngineOtherMethods = true + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.otherMethods.testMethod).not.equals(undefined) + await expectRejection( + wallet.otherMethods.testMethod('stale'), + 'Error: The wallet engine does not implement "testMethod"' + ) + await account.logout() + }) + + it('upgrades a pre-consolidation device and grows stubs post-engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Rebuild the on-disk state an existing device is in when this + // ships: a version-1 account file that knows nothing about + // wallets, plus the per-wallet pair this version no longer + // writes. The version-1 wallet file predates addresses and method + // names, so the boot below also proves those upgrade cleanly: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await snooze(SAVE_WAIT_MS) + const cache = await readAccountCache(account) + const cached = cache.wallets[walletId] + accountCacheSaverConfig.throttleMs = 5000 + + await wallet.localDisklet.setText( + 'publicKey.json', + JSON.stringify({ walletInfo: cached.walletInfo }) + ) + await wallet.localDisklet.setText( + 'walletCache.json', + JSON.stringify({ + version: 1, + name: cached.name, + fiatCurrencyCode: cached.fiatCurrencyCode, + enabledTokenIds: cached.enabledTokenIds, + balances: cached.balances + }) + ) + for (const path of ACCOUNT_CACHE_FILES) { + await account.localDisklet.delete(path) + } + await account.localDisklet.setText( + 'accountCache.json', + JSON.stringify({ + version: 1, + customTokens: cache.customTokens, + legacyWallets: false, + walletStates: cache.walletStates, + configOtherMethodNames: cache.configOtherMethodNames + }) + ) + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + + // The old layout still warm-boots (migrated 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' + ) + + // ...and the migration folded this wallet into the consolidated + // file, so the NEXT boot needs no per-wallet reads at all: + await snooze(SAVE_WAIT_MS) + const migrated = await readAccountCache(account2) + expect(migrated.version).equals(2) + expect(migrated.wallets[walletId].name).equals('Cached Name') + await account2.logout() + }) + + 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..4fb836c77 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -21,12 +21,97 @@ import { EdgeTransaction, EdgeTransactionEvent, EdgeWalletInfo, - InsufficientFundsError + InsufficientFundsError, + JsonObject } from '../../src/index' import { upgradeCurrencyCode } from '../../src/types/type-helpers' 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, + * so "before the engine exists" is a deterministic state in tests. + */ + engineGate?: Promise + + /** + * If set, spread over the engine's `getFreshAddress` answer, so a + * test can make a freshly loaded engine disagree with the address + * its predecessor cached. + */ + freshAddressPatch?: Partial + + /** + * If set, engines are created WITHOUT their `otherMethods`, so + * tests can prove that a stale cached method name rejects cleanly. + */ + omitEngineOtherMethods?: boolean + + /** + * 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. + */ + onEngineCreate?: (walletId: string) => void + + /** + * If set, receives each wallet id as its engine is killed, so tests + * can prove an engine created after teardown is not left running. + */ + onEngineKill?: (walletId: string) => void +} + +export const fakePluginTestConfig: FakePluginTestConfig = { + builtinTokensGate: undefined, + engineGate: undefined, + freshAddressPatch: undefined, + omitEngineOtherMethods: undefined, + publicKeyCheckGate: undefined, + onEngineCreate: undefined, + onEngineKill: 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 +175,18 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { private allTokens: EdgeTokenMap = fakeTokens private readonly currencyInfo: EdgeCurrencyInfo + // 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, opts: EdgeCurrencyEngineOptions, @@ -201,6 +298,9 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { async killEngine(): Promise { this.running = false + if (fakePluginTestConfig.onEngineKill != null) { + fakePluginTestConfig.onEngineKill(this.walletId) + } } resyncBlockchain(): Promise { @@ -223,7 +323,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') } @@ -265,7 +365,8 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { publicAddress: 'fakeaddress', nativeBalance: this.state.balance.toString(), segwitAddress: 'fakesegwit', - legacyAddress: 'fakelegacy' + legacyAddress: 'fakelegacy', + ...fakePluginTestConfig.freshAddressPatch } } @@ -349,6 +450,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' } } @@ -392,19 +500,35 @@ export function makeFakeCurrencyPlugin( const currencyInfo: EdgeCurrencyInfo = { ...fakeCurrencyInfo, ...overrides } return { - currencyInfo, + get currencyInfo(): EdgeCurrencyInfo { + return currencyInfo + }, - getBuiltinTokens(): Promise { - return Promise.resolve(fakeTokens) + // Exercises the config-level otherMethods name cache in tests: + otherMethods: { + async fakePluginMethod(arg: string): Promise { + return `fakePluginMethod called with: ${arg}` + } }, - makeCurrencyEngine( + async getBuiltinTokens(): Promise { + if (fakePluginTestConfig.builtinTokensGate != null) { + await fakePluginTestConfig.builtinTokensGate + } + return fakeTokens + }, + + async makeCurrencyEngine( walletInfo: EdgeWalletInfo, opts: EdgeCurrencyEngineOptions ): Promise { - return Promise.resolve( - new FakeCurrencyEngine(walletInfo, opts, currencyInfo) - ) + if (fakePluginTestConfig.onEngineCreate != null) { + fakePluginTestConfig.onEngineCreate(walletInfo.id) + } + if (fakePluginTestConfig.engineGate != null) { + await fakePluginTestConfig.engineGate + } + return new FakeCurrencyEngine(walletInfo, opts, currencyInfo) }, makeCurrencyTools(): Promise {