diff --git a/CHANGELOG.md b/CHANGELOG.md index b15bfa580..c4cd5fc00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +- added: Account-level `accountCache.json` with the wallet states and custom tokens the account boot needs. A warm login seeds Redux from it and emits the account right after the currency plugins load, before the account repo syncs, and every cached wallet then seeds from a single bulk dispatch. The deferred file loads overwrite the seeded state authoritatively, and user changes made during the window win over the values those loads read. First login (no cache) boots exactly as before. +- added: Per-wallet `walletCache.json` with each wallet's name, fiat code, enabled token IDs, and last-known balances. Currency wallets now emit their API objects as soon as this cache loads, before their engines exist, so the GUI can render the wallet list immediately at login. Engine-backed methods wait for the engine internally and reject if it fails or the wallet is deleted. First login (no cache) behaves exactly as before. +- added: Cached wallets' engine startup is staggered through a limited-concurrency queue (8 at a time) instead of all racing at login. Asking for a wallet via `waitForCurrencyWallet`, calling an engine- or storage-backed method, or un-pausing it moves it to the front of the queue. Wallets without a cache skip the queue, so first login is unaffected. +- added: An optional `EdgeCurrencyInfo.hasStableAddresses` hint for chains whose receive addresses never rotate. When set, a wallet serves its cached addresses (keyed per token) before the engine loads; rotating and unflagged chains keep the engine wait, exactly as before. +- added: An `allowCached` option on `getAddresses` / `getReceiveAddress` that lets a caller opt in to a provisional cached address before the engine loads, even on a rotating-address chain. Programmatic callers leave it unset and stay engine-gated, so only the receive scene serves a provisional address that it reconciles once the engine confirms the fresh one. +- changed: `waitForCurrencyWallet` and `waitForAllWallets` now resolve when the wallet object exists, which can be before its engine loads. +- changed: `wallet.otherMethods` exposes delegating stubs: a method whose name is in the wallet's cache can be called before the engine exists (the call waits for the engine internally), the object keeps its identity when the cache already names every engine method, and a cached name the engine no longer implements rejects cleanly at call time. Pre-engine with no cache it is `{}`, as before. +- changed: `EdgeCurrencyWallet.balanceMap` keeps its object identity when an engine re-reports an unchanged balance. +- 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..6eed47e68 --- /dev/null +++ b/src/core/account/account-cache-file.ts @@ -0,0 +1,17 @@ +import { makeJsonFile } from '../../util/file-helpers' +import { asAccountCacheFile } from './account-cleaners' + +/** + * Cached account boot state, stored on the account's local disklet. + * See `asAccountCacheFile` for the schema. + */ +export const ACCOUNT_CACHE_FILE = 'accountCache.json' +export const accountCacheFile = makeJsonFile(asAccountCacheFile) + +/** + * Tuning for the account boot-state cache saver. + * Tests override the throttle to run quickly. + */ +export const accountCacheSaverConfig = { + throttleMs: 5000 +} diff --git a/src/core/account/account-cleaners.ts b/src/core/account/account-cleaners.ts index 4785f9ff5..68bc4be7c 100644 --- a/src/core/account/account-cleaners.ts +++ b/src/core/account/account-cleaners.ts @@ -4,11 +4,20 @@ import { asNumber, asObject, asOptional, - asString + asString, + asValue, + Cleaner } from 'cleaners' import { asBase16 } from '../../types/server-cleaners' -import { EdgeDenomination, EdgeToken } from '../../types/types' +import { + EdgeDenomination, + EdgePluginMap, + EdgeToken, + EdgeTokenMap, + EdgeWalletState, + EdgeWalletStates +} from '../../types/types' import { asJsonObject } from '../../util/file-helpers' import { SwapSettings } from './account-types' @@ -92,3 +101,48 @@ export const asGuiSettingsFile = asObject({ export const asCustomTokensFile = asObject({ customTokens: asObject(asObject(asEdgeToken)) }) + +/** + * Cached account boot state, stored on the account's local disklet. + * This is what the deferred account file loads would produce, + * so wallet pixies can start before the account repo syncs. + * Values are last-known and explicitly allowed to be stale; + * the authoritative loads overwrite them within seconds. + * Never contains private key material: wallet keys stay in the + * encrypted login stash, which is already in memory at login. + * Plugin settings are deliberately excluded: unlike wallet states + * and token definitions, they can hold credentials (custom node + * auth, API keys), which must never leave the encrypted repo. + */ +export interface AccountCacheFile { + version: 1 + customTokens: EdgePluginMap + /** + * True when the account has legacy Airbitz-repo wallets. Their + * wallet infos cannot be cached (they contain private keys), so + * such accounts boot cold rather than briefly hiding wallets. + */ + legacyWallets: boolean + walletStates: EdgeWalletStates + /** + * Each plugin's `otherMethods` names, so `CurrencyConfig` can + * expose delegating stubs even if the plugin has not loaded yet. + */ + configOtherMethodNames: EdgePluginMap +} + +const asEdgeWalletState = asObject({ + archived: asOptional(asBoolean), + deleted: asOptional(asBoolean), + hidden: asOptional(asBoolean), + migratedFromWalletId: asOptional(asString), + sortIndex: asOptional(asNumber) +}) + +export const asAccountCacheFile: Cleaner = asObject({ + version: asValue(1), + customTokens: asObject(asObject(asEdgeToken)), + legacyWallets: asOptional(asBoolean, false), + walletStates: asObject(asEdgeWalletState), + configOtherMethodNames: asOptional(asObject(asArray(asString)), () => ({})) +}) 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..c8bff28ca 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -13,19 +13,31 @@ import { EdgeAccount, EdgeCurrencyWallet, EdgePluginMap, - EdgeTokenMap + EdgeTokenMap, + EdgeWalletInfo, + EdgeWalletStates } from '../../types/types' import { makePeriodicTask } from '../../util/periodic-task' import { snooze } from '../../util/snooze' +import { + bulkLoadWalletCaches, + walletCacheLoaderHooks +} from '../currency/wallet/wallet-cache-loader' import { syncLogin } from '../login/login' import { waitForPlugins } from '../plugins/plugins-selectors' import { RootProps, toApiInput } from '../root-pixie' +import { makeLocalDisklet } from '../storage/repo' import { addStorageWallet, SYNC_INTERVAL, syncStorageWallet } from '../storage/storage-actions' import { makeAccountApi } from './account-api' +import { + ACCOUNT_CACHE_FILE, + accountCacheFile, + accountCacheSaverConfig +} from './account-cache-file' import { loadAllWalletStates, reloadPluginSettings } from './account-files' import { AccountState, initialCustomTokens } from './account-reducer' import { @@ -78,7 +90,7 @@ const accountPixie: TamePixie = combinePixies({ async update() { const ai = toApiInput(input) const { accountId, accountState, log } = input.props - const { accountWalletInfos } = accountState + const { accountWalletInfo, accountWalletInfos } = accountState async function loadAllFiles(): Promise { await Promise.all([ @@ -88,25 +100,119 @@ 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 accountCache = await accountCacheFile.load( + makeLocalDisklet(ai.props.io, accountWalletInfo.id), + ACCOUNT_CACHE_FILE + ) + if (accountCache != null && !accountCache.legacyWallets) { + input.props.dispatch({ + type: 'ACCOUNT_CACHE_LOADED', + payload: { + accountId, + 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: + 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 +305,150 @@ 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 + } + + let failures = 0 + let lastSaved: CacheSnapshot | undefined + let timer: ReturnType | undefined + + async function doSave(): Promise { + timer = undefined + const { accountId, accountState, state } = input.props + + // Never write after logout: + if (state.accounts[accountId] == null) return + + const snapshot: CacheSnapshot = { + currencyWalletIds: accountState.currencyWalletIds, + customTokens: accountState.customTokens, + legacyWalletInfos: accountState.legacyWalletInfos, + walletStates: accountState.walletStates + } + + // Only legacy wallets that actually surface as currency wallets + // force a cold boot; a legacy repo whose wallet type has no + // loaded plugin was never visible in the first place: + const 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 { + await accountCacheFile.save( + makeLocalDisklet(input.props.io, accountState.accountWalletInfo.id), + ACCOUNT_CACHE_FILE, + { + version: 1, + customTokens: snapshot.customTokens, + legacyWallets, + walletStates: snapshot.walletStates, + configOtherMethodNames + } + ) + failures = 0 + lastSaved = snapshot + } catch (error: unknown) { + if (++failures >= 3) { + input.props.log.error( + `Account cache saver giving up after ${failures} failures: ${String( + error + )}` + ) + } + } + } + + return { + update() { + const { accountState } = input.props + if (accountState == null) return + if (failures >= 3 || timer != null) return + + // Wait until the authoritative files have loaded, + // so a cold start never caches placeholder values: + const { customTokensLoaded, walletStatesLoaded } = accountState + if (!customTokensLoaded || !walletStatesLoaded) return + + if ( + lastSaved != null && + lastSaved.currencyWalletIds === accountState.currencyWalletIds && + lastSaved.customTokens === accountState.customTokens && + lastSaved.legacyWalletInfos === accountState.legacyWalletInfos && + lastSaved.walletStates === accountState.walletStates + ) { + return + } + + timer = setTimeout(() => { + doSave().catch(error => input.props.onError(error)) + }, accountCacheSaverConfig.throttleMs) + }, + + destroy() { + if (timer != null) clearTimeout(timer) + } + } + }, + watcher(input: AccountInput) { let lastState: AccountState | undefined // let lastWallets diff --git a/src/core/account/account-reducer.ts b/src/core/account/account-reducer.ts index 86f37d714..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..3d2033f4a 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -25,6 +25,7 @@ import { EdgeEncodeUri, EdgeGetReceiveAddressOptions, EdgeGetTransactionsOptions, + EdgeOtherMethods, EdgeParsedUri, EdgePaymentProtocolInfo, EdgeReceiveAddress, @@ -45,9 +46,17 @@ import { } from '../../../types/types' import { makeMetaTokens } from '../../account/custom-tokens' import { splitWalletInfo } from '../../login/splitting' -import { toApiInput } from '../../root-pixie' +import { asEdgeStorageKeys } from '../../login/storage-keys' +import { getCurrencyTools } from '../../plugins/plugins-selectors' +import { RootProps, toApiInput } from '../../root-pixie' +import { makeLocalDisklet, makeRepoPaths } from '../../storage/repo' import { makeStorageWalletApi } from '../../storage/storage-api' -import { getCurrencyMultiplier } from '../currency-selectors' +import { + bumpEngineQueue, + checkCurrencyWallet, + getCurrencyMultiplier, + waitForCurrencyEngine +} from '../currency-selectors' import { determineConfirmations, makeCurrencyWalletCallbacks, @@ -92,36 +101,159 @@ 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 + } + + /** + * Remembers the engine's answer to the default address query, so + * the cache saver persists it and the next warm login can serve it + * pre-engine on stable-address chains. Balances are stripped: + * they are stale by definition and `balanceMap` already owns them. + */ + function rememberAddresses( + opts: EdgeGetReceiveAddressOptions, + addresses: EdgeAddress[] + ): void { + if (opts.forceIndex != null) return + input.props.dispatch({ + type: 'CURRENCY_WALLET_ADDRESSES_CHANGED', + payload: { + addresses: addresses.map(address => ({ + addressType: address.addressType, + publicAddress: address.publicAddress + })), + tokenId: opts.tokenId, + walletId + } + }) + } 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 +264,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 +276,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 +299,7 @@ export function makeCurrencyWalletApi( return input.props.walletState.name }, async renameWallet(name: string): Promise { + await getStorage() await renameCurrencyWallet(input, name) }, @@ -164,6 +308,7 @@ export function makeCurrencyWalletApi( return input.props.walletState.fiat }, async setFiatCurrencyCode(fiatCurrencyCode: string): Promise { + await getStorage() await setCurrencyWalletFiat(input, fiatCurrencyCode) }, @@ -206,6 +351,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 +378,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 +393,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 +448,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 +461,7 @@ export function makeCurrencyWalletApi( async $internalStreamTransactions( opts: EdgeStreamTransactionOptions ): Promise { + const engine = await getEngine() const { afterDate, batchSize = 10, @@ -416,8 +596,38 @@ export function makeCurrencyWalletApi( async getAddresses( opts: EdgeGetReceiveAddressOptions ): Promise { + // On chains whose addresses never rotate, serve the cached + // answer while the engine is still loading, so the receive + // scene works right away on a warm login. A rotating chain + // waits for the engine by default, to avoid address reuse, + // unless the caller passes `allowCached`: the receive scene + // opts in to a provisional answer and reconciles once the + // engine returns the fresh address, while programmatic + // callers stay gated and never latch a reused address: + const { hasStableAddresses = false } = plugin.currencyInfo + const cachedAddresses = + input.props.walletState.addresses[opts.tokenId ?? ''] ?? [] + if ( + (hasStableAddresses || opts.allowCached === true) && + opts.forceIndex == null && + cachedAddresses.length > 0 && + input.props.walletOutput?.engine == null + ) { + // The user is on an address screen, so they want this + // wallet's engine sooner rather than later: + bumpEngineQueue(ai, walletId) + return cachedAddresses.map(address => ({ ...address })) + } + + const engine = await getEngine() if (engine.getAddresses != null) { - return await engine.getAddresses(opts) + // `allowCached` is a UI-layer hint consumed above, so keep it + // out of the engine call (the `getFreshAddress` branch already + // never forwards it): + const { allowCached, ...engineOpts } = opts + const addresses = await engine.getAddresses(engineOpts) + rememberAddresses(opts, addresses) + return addresses } else { const upgradedCurrency = upgradeCurrencyCode({ allTokens: input.props.state.accounts[accountId].allTokens[pluginId], @@ -463,6 +673,7 @@ export function makeCurrencyWalletApi( }) } + rememberAddresses(opts, addresses) return addresses } }, @@ -515,12 +726,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 +746,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 +755,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 +850,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 +870,7 @@ export function makeCurrencyWalletApi( }, async saveTxAction(opts): Promise { + await getEngine() const { txid, tokenId, assetAction, savedAction } = opts await updateCurrencyWalletTxMetadata( input, @@ -666,6 +884,7 @@ export function makeCurrencyWalletApi( }, async saveTxMetadata(opts: EdgeSaveTxMetadataOptions): Promise { + await getEngine() const { txid, tokenId, metadata } = opts await updateCurrencyWalletTxMetadata( @@ -681,6 +900,7 @@ export function makeCurrencyWalletApi( bytes: Uint8Array, opts: EdgeSignMessageOptions = {} ): Promise { + const engine = await getEngine() const privateKeys = walletInfo.keys if (engine.signBytes != null) { @@ -706,6 +926,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 +934,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 +949,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 +961,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 +990,7 @@ export function makeCurrencyWalletApi( // URI handling: async encodeUri(options: EdgeEncodeUri): Promise { + const tools = await getTools() return await tools.encodeUri( options, makeMetaTokens( @@ -772,6 +999,7 @@ export function makeCurrencyWalletApi( ) }, async parseUri(uri: string, currencyCode?: string): Promise { + const tools = await getTools() const parsedUri = await tools.parseUri( uri, currencyCode, @@ -792,7 +1020,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..469615b81 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 only on stable-address chains. + */ + 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..fae9d570e 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -10,6 +10,8 @@ import { import { update } from 'yaob' import { + EdgeAddress, + EdgeBalanceMap, EdgeCurrencyEngine, EdgeCurrencyTools, EdgeCurrencyWallet, @@ -17,13 +19,13 @@ 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' import { makeLog } from '../../log/log' import { getCurrencyTools } from '../../plugins/plugins-selectors' import { RootProps, toApiInput } from '../../root-pixie' +import { makeLocalDisklet } from '../../storage/repo' import { addStorageWallet, SYNC_INTERVAL, @@ -38,7 +40,7 @@ import { makeCurrencyWalletCallbacks, watchCurrencyWallet } from './currency-wallet-callbacks' -import { asIntegerString, asPublicKeyFile } from './currency-wallet-cleaners' +import { asIntegerString, WalletCacheFile } from './currency-wallet-cleaners' import { loadAddressFiles, loadFiatFile, @@ -55,6 +57,18 @@ import { initialWalletSettings } from './currency-wallet-reducer' import { tokenIdsToCurrencyCodes, uniqueStrings } from './enabled-tokens' +import { getEngineScheduler } from './engine-scheduler' +import { + WALLET_CACHE_FILE, + walletCacheFile, + walletCacheSaverConfig +} from './wallet-cache-file' +import { + loadWalletCacheSeed, + PUBLIC_KEY_CACHE, + publicKeyFile, + walletCacheLoaderHooks +} from './wallet-cache-loader' export interface CurrencyWalletOutput { readonly walletApi: EdgeCurrencyWallet | undefined @@ -70,151 +84,232 @@ 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) + 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) - - // 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 } - }) + // 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 } + }) - // Load the last seen transaction checkpoint into memory. - // This also loads subscribed addresses from the same file. - const { checkpoint: seenTxCheckpoint, subscribedAddresses } = - await loadSeenTxCheckpointFile(input) + // Load the last seen transaction checkpoint into memory. + // This also loads subscribed addresses from the same file. + const { checkpoint: seenTxCheckpoint, subscribedAddresses } = + await loadSeenTxCheckpointFile(input) - // We need to know which tokens are enabled, - // so the engine can start in the right state: - await loadTokensFile(input) + // We need to know which tokens are enabled, + // so the engine can start in the right state: + await loadTokensFile(input) - const { hasWalletSettings = false } = walletState.currencyInfo - if (hasWalletSettings) { - await loadWalletSettingsFile(input) - } + const { hasWalletSettings = false } = walletState.currencyInfo + if (hasWalletSettings) { + await loadWalletSettingsFile(input) + } - // Start the engine: - const accountState = state.accounts[accountId] - const engine = await plugin.makeCurrencyEngine(publicWalletInfo, { - callbacks: makeCurrencyWalletCallbacks(input), - - // Engine state kept by the core: - seenTxCheckpoint, - subscribedAddresses, - - // Wallet-scoped IO objects: - log: makeLog( - input.props.logBackend, - `${pluginId}-${walletInfo.id.slice(0, 2)}` - ), - walletLocalDisklet, - walletLocalEncryptedDisklet, - - // User settings: - customTokens: accountState.customTokens[pluginId] ?? {}, - enabledTokenIds: input.props.walletState.allEnabledTokenIds, - userSettings: accountState.userSettings[pluginId] ?? {}, - walletSettings: input.props.walletState.walletSettings - }) - input.onOutput(engine) - - // Grab initial state: - const parentCurrency = { currencyCode, tokenId: null } - const balance = asMaybe(asIntegerString)( - engine.getBalance(parentCurrency) - ) - if (balance != null) { + // 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 + }) + 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 @@ -472,6 +567,108 @@ export const walletPixie: TamePixie = combinePixies({ } }, + /** + * Watches the wallet's cache-relevant Redux state and persists it to + * `walletCache.json`, so the next login can render this wallet before + * its engine exists. Writes are throttled to at most one per wallet per + * `walletCacheSaverConfig.throttleMs` (trailing edge), never happen + * after logout, and stop after 3 consecutive failures to avoid log spam. + */ + cacheSaver(input: CurrencyWalletInput) { + interface CacheSnapshot { + addresses: { [tokenIdKey: string]: EdgeAddress[] } + balanceMap: EdgeBalanceMap + enabledTokenIds: string[] + fiat: string + name: string | null + otherMethodNames: string[] + } + + let failures = 0 + let lastSaved: CacheSnapshot | undefined + let timer: ReturnType | undefined + + async function doSave(): Promise { + timer = undefined + const { state, walletId, walletState } = input.props + + // Never write after logout: + if (state.accounts[walletState.accountId] == null) return + + const snapshot: CacheSnapshot = { + addresses: walletState.addresses, + balanceMap: walletState.balanceMap, + enabledTokenIds: walletState.enabledTokenIds, + fiat: walletState.fiat, + name: walletState.name, + otherMethodNames: walletState.otherMethodNames + } + const balances: WalletCacheFile['balances'] = {} + for (const [tokenId, balance] of snapshot.balanceMap) { + balances[tokenId ?? ''] = balance + } + + try { + await walletCacheFile.save( + makeLocalDisklet(input.props.io, walletId), + WALLET_CACHE_FILE, + { + version: 2, + name: snapshot.name, + fiatCurrencyCode: snapshot.fiat, + enabledTokenIds: snapshot.enabledTokenIds, + balances, + addresses: snapshot.addresses, + otherMethodNames: snapshot.otherMethodNames + } + ) + failures = 0 + lastSaved = snapshot + } catch (error: unknown) { + if (++failures >= 3) { + input.props.log.error( + `Wallet cache saver giving up after ${failures} failures: ${String( + error + )}` + ) + } + } + } + + return { + update() { + const { walletState } = input.props + if (walletState == null) return + if (failures >= 3 || timer != null) return + + // Wait until the authoritative files have loaded, + // so a cold start never caches placeholder values: + const { fiatLoaded, nameLoaded, tokenFileLoaded } = walletState + if (!fiatLoaded || !nameLoaded || !tokenFileLoaded) return + + if ( + lastSaved != null && + lastSaved.addresses === walletState.addresses && + lastSaved.balanceMap === walletState.balanceMap && + lastSaved.otherMethodNames === walletState.otherMethodNames && + lastSaved.enabledTokenIds === walletState.enabledTokenIds && + lastSaved.fiat === walletState.fiat && + lastSaved.name === walletState.name + ) { + return + } + + timer = setTimeout(() => { + doSave().catch(error => input.props.onError(error)) + }, walletCacheSaverConfig.throttleMs) + }, + + destroy() { + if (timer != null) clearTimeout(timer) + } + } + }, + watcher(input: CurrencyWalletInput) { let lastState: CurrencyWalletState | undefined let lastUserSettings: object = {} @@ -492,17 +689,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 +729,7 @@ export const walletPixie: TamePixie = combinePixies({ if ( settingsChanged && - engine?.changeWalletSettings != null && + engine.changeWalletSettings != null && hasWalletSettings ) { await engine.changeWalletSettings(walletSettings).catch(error => { @@ -537,7 +740,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 +776,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 } } 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..d8d998c49 --- /dev/null +++ b/src/core/currency/wallet/wallet-cache-file.ts @@ -0,0 +1,45 @@ +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 +} + +/** + * Tuning for the wallet UI-state cache saver. + * Tests override the throttle to run quickly. + */ +export const walletCacheSaverConfig = { + throttleMs: 5000 +} diff --git a/src/core/currency/wallet/wallet-cache-loader.ts b/src/core/currency/wallet/wallet-cache-loader.ts new file mode 100644 index 000000000..30afbfd16 --- /dev/null +++ b/src/core/currency/wallet/wallet-cache-loader.ts @@ -0,0 +1,115 @@ +import { EdgeBalanceMap } from '../../../types/types' +import { makeJsonFile } from '../../../util/file-helpers' +import { ApiInput } from '../../root-pixie' +import { makeLocalDisklet } from '../../storage/repo' +import { asPublicKeyFile, WalletCacheFile } from './currency-wallet-cleaners' +import { + WALLET_CACHE_FILE, + walletCacheFile, + WalletCacheSeed +} from './wallet-cache-file' + +export const PUBLIC_KEY_CACHE = 'publicKey.json' +export const publicKeyFile = makeJsonFile(asPublicKeyFile) + +/** + * Test hooks for observing cache seeding, following the same + * mutable-config pattern as `walletCacheSaverConfig`. + */ +export const walletCacheLoaderHooks: { + /** Receives each account id seeded by `ACCOUNT_CACHE_LOADED`. */ + onAccountSeed?: (accountId: string) => void + /** Receives the seeded wallet ids of each bulk dispatch. */ + onBulkSeed?: (walletIds: string[]) => void + /** Receives each wallet id seeded by a pixie's fallback read. */ + onFallbackSeed?: (walletId: string) => void +} = {} + +/** + * Upgrades a validated `walletCache.json` balance table + * to the `EdgeBalanceMap` shape the Redux slice uses. + */ +export function makeCachedBalanceMap( + balances: WalletCacheFile['balances'] +): EdgeBalanceMap { + const balanceMap: EdgeBalanceMap = new Map() + for (const tokenId of Object.keys(balances)) { + balanceMap.set(tokenId === '' ? null : tokenId, balances[tokenId]) + } + return balanceMap +} + +/** + * Reads one wallet's cache files from its local disklet. + * Returns undefined when either file is missing or invalid + * (first login, schema bump, corruption). + */ +export async function loadWalletCacheSeed( + ai: ApiInput, + walletId: string +): Promise { + const cacheDisklet = makeLocalDisklet(ai.props.io, walletId) + const [publicKeyCache, walletCache] = await Promise.all([ + publicKeyFile.load(cacheDisklet, PUBLIC_KEY_CACHE), + walletCacheFile.load(cacheDisklet, WALLET_CACHE_FILE) + ]) + if (publicKeyCache == null || walletCache == null) return + + return { + addresses: walletCache.addresses, + otherMethodNames: walletCache.otherMethodNames, + balanceMap: makeCachedBalanceMap(walletCache.balances), + enabledTokenIds: walletCache.enabledTokenIds, + fiatCurrencyCode: walletCache.fiatCurrencyCode, + name: walletCache.name, + publicWalletInfo: publicKeyCache.walletInfo + } +} + +/** + * Reads every active wallet's cache files concurrently and seeds + * them all in a single `CURRENCY_WALLETS_CACHE_LOADED` dispatch, + * so a warm login costs one store tick for the whole wallet list + * instead of two dispatches per wallet. Wallets without valid cache + * files are simply absent from the payload; their pixies fall back + * to their own reads. Always dispatches, even with zero seeds, since + * the wallet pixies are holding for `bulkWalletSeedPending` to clear. + */ +export async function bulkLoadWalletCaches( + ai: ApiInput, + accountId: string +): Promise { + const seeds: { [walletId: string]: WalletCacheSeed } = {} + try { + const accountState = ai.props.state.accounts[accountId] + if (accountState == null) return + const { activeWalletIds } = accountState + + await Promise.all( + activeWalletIds.map(async walletId => { + const seed = await loadWalletCacheSeed(ai, walletId).catch(() => { + // A broken read just means this wallet boots cold: + return undefined + }) + if (seed != null) seeds[walletId] = seed + }) + ) + } catch (error: unknown) { + // Never skip the dispatch below: wallet pixies are holding for + // `bulkWalletSeedPending` to clear, and an empty seed table just + // sends them to their own fallback reads: + ai.props.log.warn(`Bulk wallet-cache load failed: ${String(error)}`) + } + + // The account may have logged out while we read the disk: + if (ai.props.state.accounts[accountId] == null) return + + ai.props.dispatch({ + type: 'CURRENCY_WALLETS_CACHE_LOADED', + payload: { accountId, seeds } + }) + + if (walletCacheLoaderHooks.onBulkSeed != null) { + walletCacheLoaderHooks.onBulkSeed(Object.keys(seeds)) + } +} diff --git a/src/core/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/types/types.ts b/src/types/types.ts index d7fe8fec5..c30d6d8e0 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -516,6 +516,16 @@ export interface EdgeCurrencyInfo { canAdjustFees?: boolean // Defaults to true canImportKeys?: boolean // Defaults to false canReplaceByFee?: boolean // Defaults to false + + /** + * True if the chain's receive addresses never rotate, so a + * previously returned address remains valid and reuse-safe. + * When set, a wallet can serve its cached addresses before the + * engine loads. Defaults to false: address queries wait for the + * engine, exactly the pre-cache behavior, which is what rotating + * (UTXO-style) chains need to avoid address reuse. + */ + hasStableAddresses?: boolean customFeeTemplate?: EdgeObjectTemplate // Indicates custom fee support customTokenTemplate?: EdgeObjectTemplate // Indicates custom token support requiredConfirmations?: number // Block confirmations required for a tx @@ -926,6 +936,18 @@ export interface EdgeStreamTransactionOptions { export type EdgeGetReceiveAddressOptions = EdgeTokenIdOptions & { forceIndex?: number + + /** + * Opt in to a provisional cached answer before the engine loads, + * even on a chain whose addresses rotate. The receive scene sets + * this so a warm login shows an address right away, marking it + * provisional and reconciling once the engine returns the fresh + * one. Programmatic callers (payments, action queue) leave it + * unset and stay engine-gated, so they never latch a reused + * address. Chains with `hasStableAddresses` serve cached + * regardless of this flag. + */ + allowCached?: boolean } export interface EdgeEngineActivationOptions { diff --git a/test/core/account/account-cache.test.ts b/test/core/account/account-cache.test.ts new file mode 100644 index 000000000..07a3e2501 --- /dev/null +++ b/test/core/account/account-cache.test.ts @@ -0,0 +1,795 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' +import { base64 } from 'rfc4648' + +import { accountCacheSaverConfig } from '../../../src/core/account/account-cache-file' +import { walletCacheSaverConfig } from '../../../src/core/currency/wallet/wallet-cache-file' +import { walletCacheLoaderHooks } from '../../../src/core/currency/wallet/wallet-cache-loader' +import { + 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 + walletCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + walletCacheLoaderHooks.onAccountSeed = undefined + walletCacheLoaderHooks.onBulkSeed = undefined + walletCacheLoaderHooks.onFallbackSeed = undefined + accountCacheSaverConfig.throttleMs = 5000 + walletCacheSaverConfig.throttleMs = 5000 + }) + + it('cold login without an account cache boots as on master', async function () { + this.timeout(15000) + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // A cold boot awaits loadBuiltinTokens before anything else, + // so a gated first login must not resolve, exactly as on master: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + let settled = false + const loginPromise = context + .loginWithPIN(fakeUser.username, fakeUser.pin) + .then(account => { + settled = true + return account + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + + // Releasing the gate lets the whole boot chain finish: + release() + const account = await loginPromise + const walletInfo = account.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('Broken test account') + const wallet = await account.waitForCurrencyWallet(walletInfo.id) + expect(wallet.name).equals('Fake Wallet') + + // The saver persists an account cache for the next login: + await snooze(SAVE_WAIT_MS) + await account.logout() + + // The next gated login resolves from that cache instead of blocking: + const { gate: gate2, release: release2 } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate2 + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account2.activeWalletIds.length).equals(2) + release2() + await account2.logout() + }) + + it('warm login emits the account before the deferred loads land', async function () { + this.timeout(15000) + const { context, walletIds, customTokenId } = await makeAccountCachedWorld({ + archiveSecond: true + }) + + // Hold the deferred loads (builtin tokens run at their head), + // so everything observable here comes from the account cache: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Wallet states seeded: the archived wallet stays out of the list: + expect([...account.activeWalletIds]).deep.equals([walletIds[0]]) + expect([...account.archivedWalletIds]).deep.equals([walletIds[1]]) + + // Custom tokens seeded (the class of data #703's saver lost): + const { customTokens } = account.currencyConfig.fakecoin + expect(customTokens[customTokenId]?.currencyCode).equals('CACHED') + + // The wallet emits from its own cache, engine-independent: + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + expect(wallet.name).equals('Cached Name') + + // 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') + const extraFiles: { [path: string]: string } = {} + extraFiles[localPath(accountRepoInfo.id, 'accountCache.json')] = + await account.localDisklet.getText('accountCache.json') + for (const walletId of walletIds) { + const cachedWallet = account.currencyWallets[walletId] + extraFiles[localPath(walletId, 'walletCache.json')] = + await cachedWallet.localDisklet.getText('walletCache.json') + extraFiles[localPath(walletId, 'publicKey.json')] = + await cachedWallet.localDisklet.getText('publicKey.json') + } + await account.logout() + + // A brand-new context is a fresh app process: a fresh Redux store + // with no storage wallets, plus the files captured above. The + // gated login must still resolve from the cache (regression guard: + // an eager storage-wallet read here crashes the whole login): + const context2 = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true }, + extraFiles + }) + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const { gate: engineGate, release: releaseEngines } = createEngineGate() + fakePluginTestConfig.engineGate = engineGate + const account2 = await context2.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + const wallet2 = await account2.waitForCurrencyWallet(walletIds[0]) + expect(wallet2.name).equals('Cached Name') + + // 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) + const text = await account.localDisklet.getText('accountCache.json') + expect(text.includes('fakePluginMethod')).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('rejects a corrupt account cache file and falls back cold', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // Corrupt the account cache after the saver has settled: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await snooze(SAVE_WAIT_MS) + accountCacheSaverConfig.throttleMs = 5000 + await account.localDisklet.setText('accountCache.json', '{ "version": 99 }') + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + + // A corrupt file means the cold path runs: a gated login blocks, + // exactly as with no cache at all: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + let settled = false + const loginPromise = context + .loginWithPIN(fakeUser.username, fakeUser.pin) + .then(account2 => { + settled = true + return account2 + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + + // Releasing the gate completes the boot and re-saves the cache: + release() + const account2 = await loginPromise + expect(sorted([...account2.activeWalletIds])).deep.equals(sorted(walletIds)) + await snooze(SAVE_WAIT_MS) + await account2.logout() + + // The rewritten file feeds the next gated login: + const { gate: gate3, release: release3 } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate3 + const account3 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account3.activeWalletIds.length).equals(2) + release3() + await account3.logout() + }) +}) + +describe('write-path staleness', function () { + beforeEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.publicKeyCheckGate = undefined + accountCacheSaverConfig.throttleMs = 50 + walletCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.publicKeyCheckGate = undefined + accountCacheSaverConfig.throttleMs = 5000 + walletCacheSaverConfig.throttleMs = 5000 + }) + + /** + * Logs device A in and out once, so the background repo sync pulls + * device B's pushed changes into A's local repo copy. The account + * cache saver is stalled for the session, so A's account cache + * stays as it was: the repo is now ahead of the cache, exactly the + * state a warm boot walks into. + */ + async function pullOnDeviceA( + context: EdgeContext, + pulled: (account: EdgeAccount) => Promise + ): Promise { + accountCacheSaverConfig.throttleMs = 5000 + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.sync() + await pollUntilAsync(async () => await pulled(account)) + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + } + + it('keeps custom tokens from another device across a boot-window edit', async function () { + this.timeout(15000) + const { context, customTokenId } = await makeAccountCachedWorld() + + // Put a second token in the synced repo but not in the account + // cache, by stalling the cache saver for a session. This is the + // divergence a second device's addCustomToken leaves behind once + // sync delivers it: the repo is ahead of this device's cache: + accountCacheSaverConfig.throttleMs = 5000 + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const remoteTokenId = await account1.currencyConfig.fakecoin.addCustomToken( + { + currencyCode: 'REMOTE', + displayName: 'Remote Token', + denominations: [{ multiplier: '100', name: 'REMOTE' }], + networkLocation: { contractAddress: '0xREM07E' } + } + ) + await pollUntilAsync(async () => { + const text = await account1.disklet.getText('CustomTokens.json') + return text.includes(remoteTokenId) + }) + // Push the write out of the repo's pending-changes folder, so the + // warm boot's background sync has nothing in flight: + await account1.sync() + await account1.logout() + accountCacheSaverConfig.throttleMs = 50 + + // Device A warm-boots on a cache that has never seen the remote + // token, and adds its own token in the boot window: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const localTokenId = await account.currencyConfig.fakecoin.addCustomToken({ + currencyCode: 'LOCAL', + displayName: 'Local Token', + denominations: [{ multiplier: '100', name: 'LOCAL' }], + networkLocation: { contractAddress: '0xL0CA1' } + }) + + // The saver must not write the cache-seeded map to disk + // (that write is what would delete the remote token): + await snooze(RACE_WAIT_MS) + const preLoad = await account.disklet.getText('CustomTokens.json') + expect(preLoad.includes(localTokenId)).equals(false) + expect(preLoad.includes(remoteTokenId)).equals(true) + + // The load lands, merges, and the saver writes all three tokens: + release() + await pollUntilAsync(async () => { + const text = await account.disklet.getText('CustomTokens.json') + return ( + text.includes(customTokenId) && + text.includes(remoteTokenId) && + text.includes(localTokenId) + ) + }) + const { customTokens } = account.currencyConfig.fakecoin + expect(customTokens[customTokenId]?.currencyCode).equals('CACHED') + expect(customTokens[remoteTokenId]?.currencyCode).equals('REMOTE') + expect(customTokens[localTokenId]?.currencyCode).equals('LOCAL') + await account.logout() + + // Wait for the cache savers to settle before the world closes, + // so nothing writes into a destroyed context: + await snooze(SAVE_WAIT_MS) + }) + + it('keeps enabled tokens from another device across a boot-window toggle', async function () { + this.timeout(15000) + const { context, world, walletIds, customTokenId } = + await makeAccountCachedWorld() + + // Device B enables the builtin token and syncs it to the server: + const contextB = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + const accountB = await contextB.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + const walletB = await accountB.waitForCurrencyWallet(walletIds[0]) + await walletB.changeEnabledTokenIds(['badf00d5']) + await pollUntilAsync(async () => { + const text = await walletB.disklet.getText('Tokens.json') + return text.includes('badf00d5') + }) + await walletB.sync() + await accountB.logout() + + // Device A pulls the wallet repo. The wallet reloads its token + // file mid-session, so stall the wallet cache saver to keep the + // cache on the old empty list (the divergence under test): + walletCacheSaverConfig.throttleMs = 5000 + await pullOnDeviceA(context, async account => { + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + const text = await wallet.disklet.getText('Tokens.json') + return text.includes('badf00d5') + }) + walletCacheSaverConfig.throttleMs = 50 + + // Device A warm-boots on a cache with an empty enabled list. The + // public-key gate holds the wallet's file loads, so the list the + // user acts on is deterministically the stale cached one: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const { gate: pkGate, release: releasePk } = createEngineGate() + fakePluginTestConfig.publicKeyCheckGate = pkGate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + expect([...wallet.enabledTokenIds]).deep.equals([]) + + // Toggling the custom token pends on the builtin definitions: + let toggleSettled = false + const togglePromise = wallet + .changeEnabledTokenIds([customTokenId]) + .then(() => { + toggleSettled = true + }) + await snooze(RACE_WAIT_MS) + expect(toggleSettled).equals(false) + + // The toggle lands against the stale list... + release() + await togglePromise + expect([...wallet.enabledTokenIds]).deep.equals([customTokenId]) + + // ...and the racing load preserves it instead of erasing it, + // while the toggle preserves the other device's enablement: + releasePk() + await pollUntil( + () => + wallet.enabledTokenIds.includes('badf00d5') && + wallet.enabledTokenIds.includes(customTokenId) + ) + await account.logout() + await snooze(SAVE_WAIT_MS) + }) + + it('keeps a wallet-state change made against a stale cache', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld({ + archiveSecond: true + }) + + // Put an "unarchived" record in the synced repo while the cache + // still says "archived", by stalling the cache saver for a + // session. This is the divergence a second device's unarchive + // leaves behind once sync delivers it: + accountCacheSaverConfig.throttleMs = 5000 + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account1.changeWalletStates({ [walletIds[1]]: { archived: false } }) + // Push the write out of the repo's pending-changes folder, so the + // warm boot's background sync has nothing in flight: + await account1.sync() + await account1.logout() + accountCacheSaverConfig.throttleMs = 50 + + // Device A warm-boots on a cache that still says "archived", and + // the user re-affirms that. Against the stale cache this change + // looks like a no-op, so it must wait for the load instead: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account.archivedWalletIds.includes(walletIds[1])).equals(true) + let changeSettled = false + const changePromise = account + .changeWalletStates({ [walletIds[1]]: { archived: true } }) + .then(() => { + changeSettled = true + }) + await snooze(RACE_WAIT_MS) + expect(changeSettled).equals(false) + + // The load lands with device B's "unarchived" record, and the + // change applies on top of it instead of silently reverting: + release() + await changePromise + expect(account.archivedWalletIds.includes(walletIds[1])).equals(true) + expect(account.activeWalletIds.includes(walletIds[1])).equals(false) + await account.logout() + await snooze(SAVE_WAIT_MS) + }) + + it('keeps plugin settings from another device across a local change', async function () { + this.timeout(15000) + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const plugins = { fakecoin: true, fakeswap: true } + const context = await world.makeEdgeContext({ ...contextOptions, plugins }) + const contextB = await world.makeEdgeContext({ ...contextOptions, plugins }) + + // Device A logs in first, so its Redux settings predate B's write: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Device B changes the currency plugin's settings and syncs: + const accountB = await contextB.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + await accountB.currencyConfig.fakecoin.changeUserSettings({ + remoteFlag: true + }) + await accountB.sync() + await accountB.logout() + + // Device A pulls the repo (no settings reload runs), then changes + // a different plugin's settings. The writer must merge into the + // freshly read file, not rebuild it from stale Redux: + await account.sync() + await account.swapConfig.fakeswap.changeUserSettings({ localFlag: true }) + const text = await account.disklet.getText('PluginSettings.json') + expect(text.includes('remoteFlag')).equals(true) + expect(text.includes('localFlag')).equals(true) + await account.logout() + + // A fresh login sees both settings: + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account2.currencyConfig.fakecoin.userSettings).deep.equals({ + remoteFlag: true + }) + expect(account2.swapConfig.fakeswap.userSettings).deep.equals({ + localFlag: true + }) + await account2.logout() + await snooze(SAVE_WAIT_MS) + }) +}) + +/** The disklet path of a file on a wallet's or account's local storage. */ +function localPath(id: string, file: string): string { + return `local/${base58.stringify(base64.parse(id))}/${file}` +} + +/** 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..2710aa677 --- /dev/null +++ b/test/core/currency/wallet/engine-scheduler.test.ts @@ -0,0 +1,400 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' + +import { + engineSchedulerConfig, + getEngineScheduler +} from '../../../../src/core/currency/wallet/engine-scheduler' +import { walletCacheSaverConfig } from '../../../../src/core/currency/wallet/wallet-cache-file' +import { EdgeContext, makeFakeEdgeWorld } from '../../../../src/index' +import { snooze } from '../../../../src/util/snooze' +import { + createEngineGate, + fakePluginTestConfig +} from '../../../fake/fake-currency-plugin' +import { fakeUser } from '../../../fake/fake-user' + +const contextOptions = { apiKey: '', appId: '', deviceDescription: 'iphone12' } +const quiet = { onLog() {} } + +// Generous wait for the throttled cache saver (50ms in tests) to write: +const SAVE_WAIT_MS = 300 + +// Short wait to prove something has *not* happened: +const RACE_WAIT_MS = 150 + +interface MultiWalletWorld { + context: EdgeContext + walletIds: string[] +} + +/** + * Logs in once, pads the account out to at least `count` fakecoin + * wallets, waits for their cache files to persist, and logs out. + * The next login on the returned context is a warm start where every + * wallet's engine startup enters the scheduler queue. The returned + * ids cover EVERY wallet in the account (the fake user starts with + * two), so length comparisons against engine-creation counts hold. + */ +async function makeMultiWalletWorld(count: number): Promise { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletIds = account.activeWalletIds.filter( + walletId => account.getWalletInfo(walletId)?.type === 'wallet:fakecoin' + ) + if (walletIds.length !== account.activeWalletIds.length) { + throw new Error('Broken test account: unexpected non-fakecoin wallets') + } + while (walletIds.length < count) { + const wallet = await account.createCurrencyWallet('wallet:fakecoin', { + fiatCurrencyCode: 'iso:USD', + name: `Wallet ${walletIds.length}` + }) + walletIds.push(wallet.id) + } + + // Let the throttled saver persist each wallet's cache file: + await snooze(SAVE_WAIT_MS) + await account.logout() + + return { context, walletIds } +} + +/** Returns a sorted copy, so order-insensitive comparisons read cleanly. */ +function sorted(list: string[]): string[] { + return [...list].sort((a, b) => a.localeCompare(b)) +} + +/** Polls until `condition` holds, or fails the test after ~5s. */ +async function pollUntil(condition: () => boolean): Promise { + for (let i = 0; i < 100; ++i) { + if (condition()) return + await snooze(50) + } + throw new Error('Timed out waiting for a test condition') +} + +describe('engine scheduler', function () { + beforeEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.onEngineCreate = undefined + walletCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.onEngineCreate = undefined + walletCacheSaverConfig.throttleMs = 5000 + engineSchedulerConfig.concurrency = 8 + engineSchedulerConfig.slotTimeoutMs = 30000 + engineSchedulerConfig.stickyBumpTtlMs = 30000 + }) + + it('runs cached startup work at the configured concurrency', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(3) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Every wallet emits from its cache while the queue is stuck: + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + + // The gate blocks the slot holder inside `makeCurrencyEngine`, + // so with one slot, exactly one creation can begin. Wait for it + // (a fixed sleep would flake on slow machines), then hold long + // enough to prove no second creation sneaks through: + await pollUntil(() => created.length === 1) + await snooze(RACE_WAIT_MS) + expect(created.length).equals(1) + + // Releasing the gate drains the queue one wallet at a time, + // and every engine still starts: + release() + await pollUntil(() => created.length === walletIds.length) + expect(sorted(created)).deep.equals(sorted(walletIds)) + await account.logout() + }) + + it('waitForCurrencyWallet moves a queued wallet to the front', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(4) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + // Pick the wallet at the back of the line and ask for it: + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const target = queued[queued.length - 1] + await account.waitForCurrencyWallet(target) + + // Once the slot frees up, the bumped wallet goes next: + release() + await pollUntil(() => created.length === walletIds.length) + expect(created[1]).equals(target) + await account.logout() + }) + + it('cold wallets skip the queue entirely', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(3) + + // Erase the cache files, making the next login a cold start. + // Let the login's own cache save settle first, so nothing + // rewrites the files after the deletes: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForAllWallets() + await snooze(SAVE_WAIT_MS) + for (const walletId of walletIds) { + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.localDisklet.delete('walletCache.json') + } + await account.logout() + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + // Cold wallets cannot emit until their startup work runs, + // so they bypass the queue: every creation begins even though + // the single queue slot never opens: + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => created.length === walletIds.length) + + release() + await account2.waitForAllWallets() + await account2.logout() + }) + + it('a wallet deleted while queued gives up its place in line', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(3) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + // Delete a wallet that is still waiting in the queue: + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const deleted = queued[0] + await account.changeWalletStates({ [deleted]: { deleted: true } }) + + // The queue keeps moving: the deleted wallet never creates an + // engine, and the remaining wallets still get theirs: + release() + const survivors = walletIds.filter(walletId => walletId !== deleted) + await pollUntil(() => created.length === survivors.length) + expect(sorted(created)).deep.equals(sorted(survivors)) + await snooze(RACE_WAIT_MS) + expect(created.includes(deleted)).equals(false) + await account.logout() + }) + + it('admits wallets up to the configured concurrency', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(3) + + engineSchedulerConfig.concurrency = 2 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // With two slots and three wallets, exactly two creations begin: + await pollUntil(() => created.length === 2) + await snooze(RACE_WAIT_MS) + expect(created.length).equals(2) + + release() + await pollUntil(() => created.length === walletIds.length) + await account.logout() + }) + + it('an engine-backed method call bumps a queued wallet', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(4) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + // makeSpend waits on the engine internally, which bumps the queue: + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const target = queued[queued.length - 1] + const spendPromise = account.currencyWallets[target].makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }) + + release() + await pollUntil(() => created.length === walletIds.length) + expect(created[1]).equals(target) + await spendPromise + await account.logout() + }) + + it('a storage-backed method call bumps a queued wallet', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(4) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + // The repo loads inside the queued startup work, so a rename + // pends on it and bumps this wallet to the front: + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const target = queued[queued.length - 1] + const renamePromise = + account.currencyWallets[target].renameWallet('Bumped Name') + + release() + await pollUntil(() => created.length === walletIds.length) + expect(created[1]).equals(target) + await renamePromise + expect(account.currencyWallets[target].name).equals('Bumped Name') + await account.logout() + }) + + it('changePaused(false) bumps a queued wallet', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(4) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const target = queued[queued.length - 1] + await account.currencyWallets[target].changePaused(false) + + release() + await pollUntil(() => created.length === walletIds.length) + expect(created[1]).equals(target) + await account.logout() + }) + + it('a bump before the wallet reaches the queue still counts', async function () { + engineSchedulerConfig.concurrency = 1 + const scheduler = getEngineScheduler({}) + + const releaseFirst = await scheduler.acquire('first') + + // Ask for a wallet that has not reached the queue yet: + scheduler.bump('wanted') + + const order: string[] = [] + const otherPromise = scheduler.acquire('other').then(release => { + order.push('other') + return release + }) + const wantedPromise = scheduler.acquire('wanted').then(release => { + order.push('wanted') + return release + }) + + // "other" queued first, but the sticky bump front-loads "wanted": + releaseFirst() + const releaseWanted = await wantedPromise + expect(order).deep.equals(['wanted']) + releaseWanted() + const releaseOther = await otherPromise + expect(order).deep.equals(['wanted', 'other']) + releaseOther() + }) + + it('the watchdog frees a slot held too long', async function () { + engineSchedulerConfig.concurrency = 1 + engineSchedulerConfig.slotTimeoutMs = 50 + const scheduler = getEngineScheduler({}) + + let timedOut = false + await scheduler.acquire('wedged', () => { + timedOut = true + }) + + // Never release "wedged"; the watchdog frees the slot anyway: + const release = await scheduler.acquire('next') + expect(timedOut).equals(true) + release() + }) + + it('unchanged balances keep the same balanceMap object', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(1) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 20 }) + await pollUntil(() => wallet.balanceMap.get(null) === '20') + + // Re-reporting the same balance must not produce a new map: + const before = wallet.balanceMap + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 20 }) + await snooze(RACE_WAIT_MS) + expect(wallet.balanceMap).equals(before) + + // A real change still lands: + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 21 }) + await pollUntil(() => wallet.balanceMap.get(null) === '21') + expect(wallet.balanceMap).not.equals(before) + await account.logout() + }) +}) diff --git a/test/core/currency/wallet/wallet-cache.test.ts b/test/core/currency/wallet/wallet-cache.test.ts new file mode 100644 index 000000000..2266a4167 --- /dev/null +++ b/test/core/currency/wallet/wallet-cache.test.ts @@ -0,0 +1,702 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' + +import { walletCacheSaverConfig } from '../../../../src/core/currency/wallet/wallet-cache-file' +import { + EdgeContext, + EdgeCurrencyWallet, + makeFakeEdgeWorld +} from '../../../../src/index' +import { snooze } from '../../../../src/util/snooze' +import { expectRejection } from '../../../expect-rejection' +import { + createEngineGate, + fakePluginTestConfig +} from '../../../fake/fake-currency-plugin' +import { fakeUser } from '../../../fake/fake-user' + +const contextOptions = { apiKey: '', appId: '', deviceDescription: 'iphone12' } +const quiet = { onLog() {} } + +// Generous wait for the throttled cache saver (50ms in tests) to write: +const SAVE_WAIT_MS = 300 + +// Short wait to prove something has *not* happened: +const RACE_WAIT_MS = 150 + +interface CachedWorld { + context: EdgeContext + walletId: string +} + +/** + * Logs in once without any engine gate, decorates the fakecoin wallet + * with recognizable values, waits for the cache saver to persist them, + * and logs out. The returned context has a warm cache on disk. + */ +async function makeCachedWorld(): Promise { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('Broken test account') + const wallet = await account.waitForCurrencyWallet(walletInfo.id) + + await wallet.renameWallet('Cached Name') + await wallet.changeEnabledTokenIds(['badf00d5']) + await account.currencyConfig.fakecoin.changeUserSettings({ + balance: 12345, + tokenBalance: 45 + }) + + // Let the callbacks propagate and the throttled saver write: + await snooze(SAVE_WAIT_MS) + await account.logout() + + return { context, walletId: walletInfo.id } +} + +describe('wallet cache', function () { + beforeEach(function () { + fakePluginTestConfig.currencyInfoPatch = undefined + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.omitEngineOtherMethods = undefined + walletCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.currencyInfoPatch = undefined + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.omitEngineOtherMethods = undefined + walletCacheSaverConfig.throttleMs = 5000 + }) + + it('cold login without cache files matches master behavior', async function () { + this.timeout(15000) + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First-ever login on this device, with engine creation blocked: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('Broken test account') + + // With no cache, the wallet must not emit while the engine is blocked: + await snooze(RACE_WAIT_MS) + expect(account.currencyWallets[walletInfo.id]).equals(undefined) + + // Releasing the engine lets the wallet finish loading as on master: + release() + const wallet = await account.waitForCurrencyWallet(walletInfo.id) + expect(wallet.name).equals('Fake Wallet') + await account.logout() + }) + + it('warm login emits cached wallet before the engine exists', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // The wallet emits from the cache while the engine is still blocked: + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.name).equals('Cached Name') + expect(wallet.fiatCurrencyCode).equals('iso:USD') + expect(wallet.enabledTokenIds).deep.equals(['badf00d5']) + expect(wallet.balanceMap.get(null)).equals('12345') + expect(wallet.balances.FAKE).equals('12345') + expect(wallet.balances.TOKEN).equals('45') + + release() + await account.logout() + }) + + it('live engine data overwrites cached values on the same object', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.balances.FAKE).equals('12345') + + release() + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 777 }) + await snooze(SAVE_WAIT_MS) + + // Live data lands on the very same wallet object: + expect(wallet.balances.FAKE).equals('777') + expect(account.currencyWallets[walletId]).equals(wallet) + await account.logout() + }) + + it('makeSpend pends during the cache window and then completes', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + let settled = false + const spendPromise = wallet + .makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }) + .then(tx => { + settled = true + return tx + }) + + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + + release() + const tx = await spendPromise + expect(tx.txid).equals('spend') + await account.logout() + }) + + it('engine failure rejects calls pending on the engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, fail } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + const spendPromise = wallet.makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }) + + fail(new Error('Engine exploded')) + await expectRejection(spendPromise, 'Error: Engine exploded') + await account.logout() + }) + + it('storage-backed methods survive an engine failure', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, fail } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + fail(new Error('Engine exploded')) + + // Engine-backed methods reject, but the repo is healthy, + // so storage-backed methods keep working: + await expectRejection( + wallet.makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }), + 'Error: Engine exploded' + ) + await wallet.renameWallet('Renamed After Failure') + expect(wallet.name).equals('Renamed After Failure') + await account.logout() + }) + + it('deleting the wallet rejects calls pending on the engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + const spendPromise = wallet.makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }) + + await account.changeWalletStates({ [walletId]: { deleted: true } }) + + // The pixie tree tears down, so the pending call must reject, + // not dangle forever: + await expectRejection(spendPromise) + release() + await account.logout() + }) + + it('renameWallet during the cache window updates Redux and the cache file', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + // The repo loads before the engine, so renames work in the window: + await wallet.renameWallet('Renamed In Window') + expect(wallet.name).equals('Renamed In Window') + + // The saver picks up the change: + await snooze(SAVE_WAIT_MS) + release() + await account.logout() + + // The next gated login sees the new name from the cache: + const { gate: gate2, release: release2 } = createEngineGate() + fakePluginTestConfig.engineGate = gate2 + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.name).equals('Renamed In Window') + release2() + await account2.logout() + }) + + it('logout during a pending throttled save cancels the write', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Slow the saver down so its write is still pending at logout: + walletCacheSaverConfig.throttleMs = 3000 + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.renameWallet('Ghost Name') + await account.logout() + + // The cancelled write must not have touched the cache: + walletCacheSaverConfig.throttleMs = 50 + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.name).equals('Cached Name') + release() + await account2.logout() + }) + + it('rejects a corrupt cache file, falls back cold, and re-saves', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Corrupt the cache file after the saver has settled: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await snooze(SAVE_WAIT_MS) + await wallet.localDisklet.setText('walletCache.json', '{ "version": 99 }') + await account.logout() + + // A corrupt file means the cold path runs: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await snooze(RACE_WAIT_MS) + expect(account2.currencyWallets[walletId]).equals(undefined) + + // Releasing the engine loads the wallet, and the saver rewrites the file: + release() + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.name).equals('Cached Name') + await snooze(SAVE_WAIT_MS) + await account2.logout() + + // The rewritten file feeds the next gated login: + const { gate: gate3, release: release3 } = createEngineGate() + fakePluginTestConfig.engineGate = gate3 + const account3 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet3 = await account3.waitForCurrencyWallet(walletId) + expect(wallet3.name).equals('Cached Name') + release3() + await account3.logout() + }) + + it('balance changes reach the cache file within a throttle window', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForCurrencyWallet(walletId) + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 999 }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.balances.FAKE).equals('999') + release() + await account2.logout() + }) + + it('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 (addresses only on + // stable-address chains; otherMethods stubs from cached names): + const cacheAssisted = ['getAddresses', 'getReceiveAddress', 'otherMethods'] + + // Identity, storage-backed, config, and tools surfaces, which + // never needed an engine in the first place: + const engineFree = [ + '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 on a stable-address chain', async function () { + this.timeout(15000) + fakePluginTestConfig.currencyInfoPatch = { hasStableAddresses: true } + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache: query once with the engine running, + // then let the throttled saver persist the answer: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + const live = await wallet.getAddresses({ tokenId: null }) + expect(live[0].publicAddress).equals('fakesegwit') + await snooze(SAVE_WAIT_MS) + + // The engine's answer reached the cache file, balances stripped: + const text = await wallet.localDisklet.getText('walletCache.json') + expect(text.includes('fakeaddress')).equals(true) + expect(text.includes('nativeBalance')).equals(false) + await account.logout() + + // A warm login serves the cached addresses while the engine is + // still blocked, and getReceiveAddress derives from them: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + const cached = await wallet2.getAddresses({ tokenId: null }) + expect(cached.map(address => address.publicAddress)).deep.equals( + live.map(address => address.publicAddress) + ) + const receive = await wallet2.getReceiveAddress({ tokenId: null }) + expect(receive.publicAddress).equals('fakeaddress') + release() + await account2.logout() + }) + + it('keeps the engine gate for rotating-address chains', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache, exactly as on the stable chain: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // Without the stability hint, a warm-login address query still + // waits for the engine, exactly the pre-cache behavior: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + let settled = false + const addressPromise = wallet2 + .getAddresses({ tokenId: null }) + .then(addresses => { + settled = true + return addresses + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + release() + const addresses = await addressPromise + expect(addresses[0].publicAddress).equals('fakesegwit') + await account2.logout() + }) + + it('serves a rotating chain pre-engine only when allowCached is set', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache with the engine running: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // On a warm login with the engine still blocked, the receive + // scene's opt-in serves the cached answer immediately, while a + // plain (programmatic) query on the same rotating chain waits: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + + const provisional = await wallet2.getAddresses({ + tokenId: null, + allowCached: true + }) + expect(provisional[0].publicAddress).equals('fakesegwit') + + // A forced-index query never serves the cache, even with + // allowCached, since the caller wants a specific fresh address: + let forcedSettled = false + const forcedPromise = wallet2 + .getAddresses({ tokenId: null, allowCached: true, forceIndex: 0 }) + .then(addresses => { + forcedSettled = true + return addresses + }) + await snooze(RACE_WAIT_MS) + expect(forcedSettled).equals(false) + + let gatedSettled = false + const gatedPromise = wallet2 + .getAddresses({ tokenId: null }) + .then(addresses => { + gatedSettled = true + return addresses + }) + await snooze(RACE_WAIT_MS) + expect(gatedSettled).equals(false) + release() + const gated = await gatedPromise + expect(gated[0].publicAddress).equals('fakesegwit') + const forced = await forcedPromise + expect(forced[0].publicAddress).equals('fakesegwit') + await account2.logout() + }) + + it('calls a cached otherMethods name before the engine exists', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // 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 version-1 cache file and grows stubs post-engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Rewrite the cache as a version-1 file (no addresses, no method + // names), the state every real device is in when this ships: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + const file = JSON.parse( + await wallet.localDisklet.getText('walletCache.json') + ) + walletCacheSaverConfig.throttleMs = 5000 + await wallet.localDisklet.setText( + 'walletCache.json', + JSON.stringify({ + version: 1, + name: file.name, + fiatCurrencyCode: file.fiatCurrencyCode, + enabledTokenIds: file.enabledTokenIds, + balances: file.balances + }) + ) + await account.logout() + walletCacheSaverConfig.throttleMs = 50 + + // The old file still warm-boots (upgraded on read, not cold), + // with no method names yet: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.name).equals('Cached Name') + expect(wallet2.otherMethods.testMethod).equals(undefined) + + // Once the engine lands, its methods appear and work, even + // through a bridge that saw the empty object first: + release() + await waitForOtherMethods(wallet2) + expect(await wallet2.otherMethods.testMethod('grown')).equals( + 'testMethod called with: grown' + ) + await account2.logout() + }) + + it('otherMethods is {} pre-engine and carries engine methods post-engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + 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..5cf3c3afc 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -21,12 +21,90 @@ 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 plugin's `currencyInfo` (identity-stable: + * the patched object is rebuilt only when this reference changes). + * Set it BEFORE creating the context, so every reader agrees. + */ + currencyInfoPatch?: Partial + + /** + * If set, engines are created WITHOUT their `otherMethods`, so + * tests can prove that a stale cached method name rejects cleanly. + */ + omitEngineOtherMethods?: boolean + + /** + * If set, `checkPublicKey` will wait for this promise to resolve. + * The wallet pixie validates its cached public key between the + * 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 +} + +export const fakePluginTestConfig: FakePluginTestConfig = { + builtinTokensGate: undefined, + currencyInfoPatch: undefined, + engineGate: undefined, + omitEngineOtherMethods: undefined, + publicKeyCheckGate: undefined, + onEngineCreate: 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 +168,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, @@ -223,7 +313,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') } @@ -349,6 +439,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' } } @@ -391,20 +488,49 @@ export function makeFakeCurrencyPlugin( ): EdgeCurrencyPlugin { const currencyInfo: EdgeCurrencyInfo = { ...fakeCurrencyInfo, ...overrides } + // Identity-stable view of `currencyInfoPatch`, so memoized reducers + // never see a fresh object unless the patch itself changed: + let patchedInfo = currencyInfo + let lastPatch: Partial | undefined + function getCurrencyInfo(): EdgeCurrencyInfo { + const patch = fakePluginTestConfig.currencyInfoPatch + if (patch !== lastPatch) { + lastPatch = patch + patchedInfo = patch == null ? currencyInfo : { ...currencyInfo, ...patch } + } + return patchedInfo + } + return { - currencyInfo, + get currencyInfo(): EdgeCurrencyInfo { + return getCurrencyInfo() + }, - 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}` + } + }, + + async getBuiltinTokens(): Promise { + if (fakePluginTestConfig.builtinTokensGate != null) { + await fakePluginTestConfig.builtinTokensGate + } + return fakeTokens }, - makeCurrencyEngine( + 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 {