From 7edcff6689ec8d22287ac9c043d8bfedc4a441c3 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:14:24 -0700 Subject: [PATCH 01/10] Document the wallet cache v2 design The design spans two repos, seven phases, and a file-layout reversal, and the reasoning behind each is not recoverable from the diff. The doc ships with the code so it is reviewed alongside it and stays current instead of drifting in a gist. --- src/docs/edge-wallet-cache-design.md | 845 +++++++++++++++++++++++++++ 1 file changed, 845 insertions(+) create mode 100644 src/docs/edge-wallet-cache-design.md diff --git a/src/docs/edge-wallet-cache-design.md b/src/docs/edge-wallet-cache-design.md new file mode 100644 index 000000000..284bf36ba --- /dev/null +++ b/src/docs/edge-wallet-cache-design.md @@ -0,0 +1,845 @@ +# Wallet cache v2: instant wallet UI at login + +| | | +|---|---| +| Status | Implemented (phases 1-7 in review) | +| Author | Jon Tzeng | +| Reviewer | William Swanson | +| Last updated | 2026-09-03 | +| Repos | [EdgeApp/edge-core-js](https://github.com/EdgeApp/edge-core-js), [EdgeApp/edge-react-gui](https://github.com/EdgeApp/edge-react-gui) | +| Implementation | [edge-core-js#733](https://github.com/EdgeApp/edge-core-js/pull/733), [edge-react-gui#6080](https://github.com/EdgeApp/edge-react-gui/pull/6080) | +| Supersedes | [edge-core-js#703](https://github.com/EdgeApp/edge-core-js/pull/703) (Cache and restore wallets) | +| Related | [edge-core-js#709](https://github.com/EdgeApp/edge-core-js/pull/709) (Reduce UI lag, tabled), [architecture comparison vs #703](https://gist.github.com/j0ntz/7ea32da5dc8d23a1fd46d16fd121cda1), [write-path audit](https://gist.github.com/j0ntz/94c3e64779a92e8a1ae1896f4d7d3d6f), Asana: [Login Perf - Wallet Cache v2](https://app.asana.com/1/9976422036640/project/1213843652804305/task/1216673467164267) | + + + +[Section 1](#1-problem) through [section 5](#5-testing) describe the implementation as it stands on the branch; every code block is captioned with the file and commit it was pulled from. Line references to unchanged code are to edge-core-js `master` at v2.47.0 (1b2a25e7) and to edge-react-gui `develop`. The architectural direction comes from the William x Jon call of 2026-07-17. How the design moved between that call and the current shape is in [section 6](#6-phase-history-and-709-disposition), the rationale behind each contested choice is in [section 7](#7-decisions), and what the document got wrong is in [section 10](#10-post-implementation-retrospective). + +## Contents + +1. [Problem](#1-problem) +2. [Why #703 is not the implementation](#2-why-703-is-not-the-implementation) +3. [Goals and non-goals](#3-goals-and-non-goals) +4. [Design overview](#4-design-overview) + 1. [Detailed design: edge-core-js](#41-detailed-design-edge-core-js) + 2. [GUI integration: edge-react-gui](#42-gui-integration-edge-react-gui) +5. [Testing](#5-testing) +6. [Phase history and #709 disposition](#6-phase-history-and-709-disposition) +7. [Decisions](#7-decisions) +8. [Glossary](#8-glossary) +9. [References](#9-references) +10. [Post-implementation retrospective](#10-post-implementation-retrospective) + +## 1. Problem + +Measured from the last [PIN](#pin) digit to a usable wallet list, login is slow because the GUI cannot render balances until currency engines exist. The chain on master: + +1. The GUI gets no `EdgeAccount` until plugins load, account repos sync, and key files load (`src/core/account/account-pixie.ts:52-118`). +2. Each wallet's `walletApi` [pixie](#pixie) refuses to emit until `engine != null && publicWalletInfo != null && nameLoaded` (`src/core/currency/wallet/currency-wallet-pixie.ts:209`). +3. In the engine pixie, `plugin.makeCurrencyEngine` sits behind repo sync, `loadTxFileNames`, and key derivation, and the name/fiat file loads are sequenced after engine creation (`currency-wallet-pixie.ts:78-201`, name/fiat at 186-187). +4. Core never persists balances. They live only in Redux (`balanceMap`), populated by engine callbacks after startup. + +So even the wallet's name waits behind engine creation, and balances wait behind engine startup plus network. QA measured the caching approach in #703 at roughly a 5x login improvement. William confirmed the finding on the 2026-07-17 call: the cost of one extra disk read per wallet is far below the cost of loading engines up front. + +Engine loading also causes visible jank after login (disk, key derivation, network on the JS thread). The startup queue in [section 6](#6-phase-history-and-709-disposition) is what addresses that. + +## 2. Why #703 is not the implementation + +#703 proved the concept and is the reason we know the win is real. It was rejected on architecture, not on results: + +- It builds a second, parallel implementation of `EdgeCurrencyWallet` and `EdgeCurrencyConfig` (543-line `cached-currency-wallet.ts` plus config, loader, saver, delegation utilities; ~3,200 lines total). Two implementations of the same interface means every future API change must be made twice, and each copy has its own bugs. Review findings bore this out: stale Redux snapshots in the loader, an initial save that wiped token definitions, non-bridgified `otherMethods`, pollers that outlive logout. +- The merged `currencyWallets` getter allocated a fresh object per read. [yaob](#yaob) diffs by `===`, so unstable references cause superfluous or missing bridge updates (William's inline review comment, 2026-02-26). +- Cached wallets delegate to real ones through a 500 ms poll with a 60 s timeout, and four setters must remember to call `update()` by hand or the GUI wedges (the paused-state variant causes an infinite boot loop, per the PR's own design doc). + +Direction agreed on the call: keep one wallet implementation. Feed cached data into the existing Redux state and the existing `walletApi` object, and make the engine an awaitable dependency inside that object instead of a construction parameter. + +## 3. Goals and non-goals + +Goals: + +- Wallet list with names, fiat codes, enabled tokens, and last-known balances renders as soon as per-wallet cache files load, before any engine exists. +- One `EdgeCurrencyWallet` implementation. No parallel objects, no delegation layer, no polling. +- Engine-dependent methods keep working: they wait for the engine internally and reject if it fails or the wallet is deleted. +- First login (no cache) behaves exactly as it does on master. +- The GUI changes required by the new wallet-readiness semantics ship in the same phase ([section 4.2](#42-gui-integration-edge-react-gui)). A core change that breaks [FIO](#fio) refresh or action-queue checks is not done. +- Target size: a few hundred core lines plus two small GUI patches, not 3,000 (size actuals below). + +Non-goals, as scoped on the call: + +- Deferring or reordering engine creation. The engine [pixie](#pixie)'s imperative block was to run when it runs on master, with only the GUI's wait for it removed. This one was taken back up and shipped as the startup queue ([section 6](#6-phase-history-and-709-disposition)). +- The #709 bridge-churn work. Tabled; see [section 6](#6-phase-history-and-709-disposition) for the one commit worth salvaging. +- [yaob](#yaob) changes. Any diffing improvements belong in the yaob repo, not shimmed around in core. + +The call's size target was "a few hundred core lines plus two small GUI patches, not 3,000", set against [#703](https://github.com/EdgeApp/edge-core-js/pull/703)'s ~3,200. The branch is larger than that target and still well under the anchor it argued against; the estimate covered one phase of source and excluded tests, which are more than half the diff. Numbers are in the [retrospective](#10-post-implementation-retrospective). + +## 4. Design overview + +The login path caches what the GUI renders and moves everything else behind it: + +- **One cache file.** `accountCache.json` on the account's local [disklet](#disklet) holds the account boot state and, per wallet, its name, fiat code, enabled tokens, last-known balances, receive addresses, `otherMethods` names, and public keys ([section 4.1.1](#411-cache-file)). +- **One read, one dispatch pair.** A bulk loader reads that file right after the plugins load and seeds Redux with two actions, one for the account and one carrying every wallet ([section 4.1.2](#412-load-path)). The account repo sync and the authoritative file loads run deferred behind the emit. +- **A gate that no longer waits for engines.** The `walletApi` gate drops its `engine != null` condition. A wallet object emits as soon as its state is in Redux ([section 4.1.3](#413-gate-change)). +- **The engine as an awaitable dependency.** `makeCurrencyWalletApi` loses its `engine` and `tools` constructor parameters; methods that need them await internal waiters, and methods that read Redux are unchanged because the cache seeded that Redux ([section 4.1.4](#414-makecurrencywalletapi-changes)). +- **One writer.** A single throttled account-level saver owns the file. One sync window costs one write, however many wallets changed ([section 4.1.5](#415-save-path)). +- **A queue in front of engine startup.** Engine creation drains 8 wallets at a time, with the wallet the user opened moved to the front ([section 6](#6-phase-history-and-709-disposition)). + +The wallet object is created once and never replaced. When the engine lands, the same object starts answering engine-backed calls. [yaob](#yaob) reference stability is preserved by construction: the [pixie](#pixie) output map and the `walletApi` object are the same ones master already uses. + +Work splits across two repos: + +| Repo | Deliverable | Scope | +|---|---|---| +| edge-core-js | [#733](https://github.com/EdgeApp/edge-core-js/pull/733) | Cache file and cleaners, bulk load path, gate change, API waiters, account cache saver ([section 4.1](#41-detailed-design-edge-core-js)), engine startup scheduler ([section 6](#6-phase-history-and-709-disposition)) | +| edge-react-gui | [#6080](https://github.com/EdgeApp/edge-react-gui/pull/6080) | Engine-readiness gates for action-queue balance effects and [FIO](#fio) refresh, `waitForWalletOtherMethods` helper ([section 4.2](#42-gui-integration-edge-react-gui)) | + +How a cache-window method call resolves once both land (symbols as shipped): + +```mermaid +sequenceDiagram + box edge-react-gui (RN main thread) + participant GUI + end + box edge-core-js (WebView, across the yaob bridge) + participant W as walletApi (single object) + participant R as Redux + participant Q as engine-scheduler + participant E as engine + end + + GUI->>W: balanceMap getter + W->>R: read balanceMap (cache-seeded) + R-->>GUI: last-known balance, instantly + GUI->>W: makeSpend() + W->>Q: bumpEngineQueue(walletId) + Note over W: await getEngine() via ai.waitFor + Q->>E: makeCurrencyEngine (front of queue) + E-->>W: engine in pixie output + W->>E: engine.makeSpend() + E-->>GUI: EdgeTransaction +``` + +### 4.1 Detailed design: edge-core-js + +A first login with no cache on disk must run exactly the code it ran before this design, which is a standing requirement of this section and is guarded by [test case 1](#5-testing). + +#### 4.1.1 Cache file + +One file, `accountCache.json` on the account's local [disklet](#disklet), holds the account boot state and every wallet's cached UI state, public keys included. Storing per-wallet data in one account-level file rather than a file per wallet is [decision 7.6](#76-one-account-cache-file-not-per-wallet-files). + +The file is written to two alternating slots (`accountCache.json`, `accountCache.2.json`), each carrying a monotonic `sequence`; the reader takes the newest slot that parses, which is the torn-write defense ([decision 7.10](#710-two-slot-alternation-because-the-disklet-has-no-rename)). + +Schema: + +[`src/core/account/account-cleaners.ts`](https://github.com/EdgeApp/edge-core-js/blob/9f28c9ffcbd7f647e0832ca482d480dc5203274c/src/core/account/account-cleaners.ts) +```ts +export interface AccountCacheFile { + version: 2 + customTokens: EdgePluginMap + /** + * True when the account has legacy Airbitz-repo wallets. Their + * wallet infos cannot be cached (they contain private keys), so + * such accounts boot cold rather than briefly hiding wallets. + */ + legacyWallets: boolean + walletStates: EdgeWalletStates + /** + * Each plugin's `otherMethods` names, so `CurrencyConfig` can + * expose delegating stubs even if the plugin has not loaded yet. + */ + configOtherMethodNames: EdgePluginMap + + /** + * Every active wallet's cached boot state, keyed by wallet id. + * This absorbs what used to live in each wallet's own + * `publicKey.json` + `walletCache.json` pair, so a warm boot reads + * one file for the whole account instead of two per wallet. + */ + wallets: { [walletId: string]: AccountCacheWallet } + + /** + * Increases on every write. Two slots hold alternating generations, + * so the reader can take the newest one that still parses; see + * `loadAccountCache`. + */ + sequence: number +} + +export interface AccountCacheWallet { + walletInfo: { id: string; keys: object; type: string } + name: string | null + fiatCurrencyCode: string + enabledTokenIds: string[] + + /** Integer strings. The `null` tokenId is spelled '' here. */ + balances: { [tokenId: string]: string } + + /** Per tokenId, without balances (`null` tokenId spelled ''). */ + addresses: { + [tokenIdKey: string]: Array<{ addressType: string; publicAddress: string }> + } + + otherMethodNames: string[] +} +``` + +A version-1 file (no `wallets`) is upgraded on read rather than rejected, which is what sends an existing device through the migration in [section 4.1.2](#412-load-path). Balances are last-known values and are explicitly allowed to be stale. + +#### 4.1.2 Load path + +The read happens once, at the account level, in the account [pixie](#pixie)'s boot block. Right after `waitForPlugins`, the boot reads `accountCache.json`; on a hit it seeds the account slice, emits the account API, and seeds every wallet from the same file it just read. The account repo sync and the authoritative file loads then run deferred behind that emit, with bounded retries because the GUI already holds the account. + +```mermaid +flowchart TD + A[account pixie boot] --> B[waitForPlugins] + B --> C[read accountCache.json
newest parsing slot] + C -->|hit, no legacy wallets| D[dispatch ACCOUNT_CACHE_LOADED
emit the account API] + C -->|miss, corrupt, or legacy wallets| E[cold path: repo sync, then loadAllFiles] + D --> F{file carries wallets?} + F -->|version 2| G[seedWalletCachesFromAccount
one CURRENCY_WALLETS_CACHE_LOADED] + F -->|version 1| H[bulkLoadWalletCaches
read each publicKey.json + walletCache.json once] + G --> I{walletApi gate
publicWalletInfo + nameLoaded} + H --> I + I -->|opens| J[every EdgeCurrencyWallet emitted
GUI renders from cache] + D -. deferred, overwrites the seeded state .-> K[repo sync, loadAllFiles, builtin tokens] + K --> L[engine startup drains through engine-scheduler] + E --> I +``` + +The whole boot rides on two seeding actions. `ACCOUNT_CACHE_LOADED` seeds wallet states, custom tokens, and the per-plugin `otherMethods` names, and sets `keysLoaded` so wallet pixies spawn. `CURRENCY_WALLETS_CACHE_LOADED` carries one seed per wallet, and the wallet reducer's filter hands each wallet its own; a warm login therefore costs two seeding dispatches rather than two per wallet, and every pixie and watcher evaluates once against fully-seeded state. This is batching, not notification suppression: every consumer is still notified, once, with the final state, so it carries none of the missed-update risk that tabled [#709](https://github.com/EdgeApp/edge-core-js/pull/709) ([section 6](#6-phase-history-and-709-disposition)). + +Each wallet's seed populates its Redux slice: + +- `balanceMap` from cached balances, with a dirty-wins guard: the reducer ignores a cached value for any token the engine or a user action has already touched, so the cache never overwrites live data. +- `name`, setting `nameLoaded`, and `fiatCurrencyCode`, setting `fiatLoaded`, which is what opens the gate in [section 4.1.3](#413-gate-change). +- `enabledTokenIds`, the per-[tokenId](#tokenid) receive addresses, the `otherMethods` names, and `publicWalletInfo`. + +The later `loadNameFile` / `loadFiatFile` / `loadTokensFile` dispatches overwrite the cached values with the authoritative synced-repo values, exactly as they overwrite initial state on master. One of those loads needs a gate of its own: `loadTokensFile`'s legacy branch names currency codes rather than [tokenIds](#tokenid), and converting them against the builtin definitions that are still loading in parallel would map every code to nothing, so it waits for those definitions before converting. If the user renamed the wallet on another device, the cache shows the old name for a second or two, then corrects. Same class of staleness the GUI already tolerates for balances. + +Two paths keep the per-wallet read alive. A wallet activated after login misses the bulk seed, so its own pixie reads its seed from the account cache, falling back to `publicKey.json` plus `walletCache.json`; and a device still on the version-1 layout takes that same per-wallet read once, after which the saver folds those wallets into the consolidated file. The old per-wallet files are left on disk as a recovery net and are never written again. + +If the file is missing, fails its [cleaner](#cleaner), or the account holds legacy Airbitz wallets whose infos cannot be cached, the boot skips the seed entirely and runs the cold path: repo sync, then `loadAllFiles`, then the gate opens on the conditions master uses. Cold-start behavior is unchanged, guarded by [test case 1](#5-testing). + +#### 4.1.3 Gate change + +The `walletApi` pixie's gate, before and after: + +```ts +// before +if (engine == null || publicWalletInfo == null || !nameLoaded) return +// after +if (publicWalletInfo == null || !nameLoaded) return +``` + +With the cache present, `publicWalletInfo` and `nameLoaded` are satisfied by the [load path](#412-load-path) and the wallet emits within one pixie tick of the cache read. Without the cache, `nameLoaded` flips only after `loadNameFile`, which still sits after engine creation, so the no-cache path runs exactly master's code. + +`engineStarted` (`currency-wallet-pixie.ts:224-285`) keeps its own conditions; the creation block it guards runs through the startup queue ([section 6](#6-phase-history-and-709-disposition)). + +#### 4.1.4 makeCurrencyWalletApi changes + +`makeCurrencyWalletApi` (`src/core/currency/wallet/currency-wallet-api.ts:93`) drops `engine` and `tools` as parameters; the [cross-repo sequence diagram](#4-design-overview) shows how a GUI call rides these waiters across the [yaob](#yaob) boundary. Internal waiters replace them: + +[`src/core/currency/currency-selectors.ts`](https://github.com/EdgeApp/edge-core-js/blob/9f28c9ffcbd7f647e0832ca482d480dc5203274c/src/core/currency/currency-selectors.ts) +```ts +/** + * Waits for a wallet's engine to exist. The wallet API object can + * exist before its engine does (a cache-seeded login), so + * engine-backed methods wait here instead of throwing. Bails out if + * the wallet is deleted mid-wait, and re-throws `engineFailure` so a + * broken plugin surfaces as a rejection instead of a hang. + */ +export function waitForCurrencyEngine( + ai: ApiInput, + walletId: string +): Promise { + // The caller needs this engine now, so skip the startup queue: + bumpEngineQueue(ai, walletId) + + return ai.waitFor((props: RootProps): EdgeCurrencyEngine | undefined => { + checkCurrencyWallet(props, walletId) + return props.output.currency.wallets[walletId]?.engine + }) +} +``` + +The wallet API holds one waiter per dependency class: the engine, the plugin tools, and the storage repo. + +[`src/core/currency/wallet/currency-wallet-api.ts`](https://github.com/EdgeApp/edge-core-js/blob/9f28c9ffcbd7f647e0832ca482d480dc5203274c/src/core/currency/wallet/currency-wallet-api.ts) +```ts + function getEngine(): Promise { + return waitForCurrencyEngine(ai, walletId) + } + + async function getTools(): Promise { + return await getCurrencyTools(ai, pluginId) + } + + /** + * Methods that write synced-repo files need the storage wallet, + * not the engine. The repo loads well before the engine, + * so this wait is much shorter than `getEngine`. + * A ready repo always wins: an unrelated engine failure must not + * break storage-backed methods, so the failure check only matters + * while the repo is still missing (the engine pixie died before + * `addStorageWallet`, so the repo is never coming). + */ + function getStorage(): Promise { + // The repo loads inside the queued startup work, so a caller + // waiting on storage wants this wallet at the front too: + bumpEngineQueue(ai, walletId) + + return ai.waitFor((props: RootProps): true | undefined => { + if (props.state.storageWallets[walletId] != null) return true + checkCurrencyWallet(props, walletId) + }) + } +``` + +This is the `waitForCurrencyWallet` pattern (`src/core/currency/currency-selectors.ts:31-53`) with `walletApi` swapped for `engine`, keeping both of its guards (`checkCurrencyWallet` throws for a deleted wallet or an `engineFailure`). `waitForCurrencyEngine` lives in `currency-selectors.ts` because the account-level engine methods (`getActivationAssets`, `activateWallet`, `getDisplayPrivateKey`, `getDisplayPublicKey`) now ride the same waiter instead of throwing pre-engine, reading the account's wallet list only after the wait so wallets that finish loading during it are included ([`987632ca`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/987632ca7d487b15ea71e851f14d5b6888ad209b)). The `bumpEngineQueue` calls are the startup queue's priority hook ([section 6](#6-phase-history-and-709-disposition)). + +Method classification. Everything that reads Redux state on master keeps its current body and works pre-engine because the cache seeded that state. Everything that touches `engine.` gains an `await getEngine()`: + +| Serves from Redux (no code change) | Engine-gated (`await getEngine()`) | +|---|---| +| `balanceMap` / `balances` | `makeSpend`, `signTx`, `broadcastTx`, `saveTx` | +| `name`, `renameWallet`* | `sweepPrivateKeys`, `signMessage`, key export | +| `fiatCurrencyCode`, `setFiatCurrencyCode`* | `getMaxSpendable`, fee/quote paths | +| `enabledTokenIds`, `currencyConfig` accessors | `resyncBlockchain`, `dumpData`, staking actions | +| `stakingStatus`, served from the cache because the GUI subtracts locked stake from the balance, so an empty stake over a cached balance reports more spendable than the wallet holds | | +| `paused`, `changePaused`** | `getTransactions`, `getTransactionCount` ([decision 7.3](#73-gettransactions-stays-engine-gated)) | +| | `getAddresses`, `getReceiveAddress` (cache-assisted: served pre-engine from the per-tokenId address cache on any chain, then reconciled against the engine, [decision 7.4](#74-getaddresses-serves-the-cache-pre-engine-and-reconciles)) | +| | `otherMethods` (delegating stubs from cached names, [section 4.1.6](#416-yaob-and-reference-stability)) | + +\* Mutations that write synced-repo files (`renameWallet`, `setFiatCurrencyCode`, `changeWalletSettings`) gate on `getStorage()`, not the engine. They dispatch to Redux the way master does, so the GUI sees the change immediately through the normal update path; no manual `update()` calls, because the one wallet object flows through the existing pixie watcher. `changeEnabledTokenIds` instead waits for the builtin-token definitions (its filter would otherwise silently drop builtin ids on a warm boot), bumps the wallet up the startup queue (the token file writer gates on `tokenFileLoaded`, which the queued startup block sets, so an unbumped toggle could sit in Redux unwritten for as long as the queue takes) and, since the write-path followup, applies the caller's change as toggles over the current list rather than replacing it, so a call built against a stale cached list cannot erase enablement changes synced from another device ([`3bbc97fd`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/3bbc97fdde7fc8ad550c778f3a0ac921038c4e3c)). `changeWalletStates` at the account level waits for `walletStatesLoaded` for the same reason: a change diffed against cache-seeded records could no-op and then be silently reverted by the load ([`dc1cb707`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/dc1cb70763ef04ac7f030faa80983650c1abdb32)). The plugin/swap settings writers wait for `pluginSettingsLoaded`, merge into the freshly read file rather than rebuilding it from Redux, and serialize per account, so neither a racing load nor a concurrent local write can drop another plugin's settings ([`e9027da6`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/e9027da6eeff15ef4e993d621d0d169af1b98099)). + +\** `changePaused` before the engine exists reduces to updating Redux pause state that `engineStarted` already consults. The GUI's WalletLifecycle tolerates this ([section 4.2.1](#421-walletlifecycle-no-change-required)). + +The pre-engine serve gate is `opts.forceIndex == null && cachedAddresses.length > 0 && engineFailure == null && engine == null`, and a serve also bumps the wallet to the front of the startup queue, since a caller on an address screen wants this engine early. Every chain serves the cache and no caller opts in; the `allowCached` option and the `hasStableAddresses` hint that once gated this are gone ([decision 7.9](#79-the-provisional-receive-affordance-reverted)). Correctness moved from a gate to a reconcile: a query answered from the cache is recorded, re-asked of the engine in the background once it loads, and `rememberAddresses` emits `addressChanged` when the engine's first answer differs, so every consumer re-queries. An engine that confirms the cached answer emits nothing. Review turned up three details the mechanism depends on: + +- At most one reconcile runs per token query. Several consumers can be served the same cached answer before the engine lands (the receive scene alone asks twice, since `getReceiveAddress` goes through `getAddresses`), and only the first engine reply can settle the correction. A second reply would find the correction already consumed and would then overwrite the stored addresses through the same dispatch WITHOUT emitting, which is precisely the silent staleness this mechanism exists to prevent. +- The cache is not served once `engineFailure` is set. A failed engine leaves `walletOutput.engine` null forever, so an unguarded cache serve would answer this method happily for the rest of the session and make a broken wallet indistinguishable from a healthy one on the one method that never reaches `getEngine()`. With the guard the query falls through and rejects with the engine's real error, matching every other engine-backed method. +- The reconcile's engine wait is not an app-visible error at teardown. A logout destroys the pixie under the pending wait and a wallet deletion drops it from the state, so an unguarded rejection would reach `onError` and surface a shutdown or wallet-missing error after the user has left the account. Both are swallowed, because the correction they were going to deliver is moot once the wallet is gone (test case 44). + +The rationale and its accepted tradeoff are [decision 7.4](#74-getaddresses-serves-the-cache-pre-engine-and-reconciles). + +The method bodies were mechanical as predicted; the getter surface was not (~160 lines the first draft's "mostly mechanical" claim never covered): `disklet`/`localDisklet` are backed by storage-wallet state that does not exist yet when a wallet emits from cache (fixed with lazily-built bridgified fallback disklets), `otherMethods` needed a stable bridgified `{}` plus post-engine caching for yaob reference rules, `publicWalletInfo` needed a Redux-backed getter for the [decision 7.1](#71-ui-state-stays-out-of-the-public-key-cache) upgrade seam, and `getStorage`'s failure ordering (a ready repo wins over an unrelated `engineFailure`) was unspecified until review forced it. + +#### 4.1.5 Save path + +One `cacheSaver` sub-pixie at the ACCOUNT level is the only writer of the cache ([decision 7.6](#76-one-account-cache-file-not-per-wallet-files)): + +- On each pixie update, compare the account slices against the last-saved snapshot, plus a stamp of every active wallet's cache-relevant Redux references (`addresses`, `balanceMap`, `enabledTokenIds`, `fiat`, `name`, `otherMethodNames`, `publicWalletInfo`). The slices are immutable, so an unchanged reference means unchanged content and this stays a reference scan. +- On change, write the next slot, throttled to at most one write per 5 s for the whole account, trailing edge. A sync window where 194 engines all report balances costs one write, not 194. +- A cold start never caches placeholder values: a wallet that has not finished loading its authoritative files is skipped. +- Wallets that are not running this session keep the entry they already had, so an archived wallet stays warm when it is turned back on. Entries for wallets the account no longer has are dropped, which is what bounds the file's size. +- Guard writes against post-logout, and stop after 3 consecutive failures. The logout guard is a destroy flag on the saver, not the account's presence in Redux: redux-pixies serves a destroyed pixie its last props, so a state read still reports the account as present and would let a write already on the chain through. +- Each completed write logs its generation, the wallet count it carried, and how long it took, at `info`; a write slower than 5 s (`accountCacheSaverConfig.slowWriteMs`, above every measured value except the one 11.4 s outlier in [section 6.5](#65-followup-write-cost-visibility-landed)) logs at `warn` so it reaches the log server. The write is the design's whole cost and nothing else reports it, but unlike the one-shot `Login:` breadcrumbs the throttle bounds this line per window for the whole session, so the default level cannot carry every write. + +Staleness containment is structural: the cache is only ever read for wallet IDs that exist in the account's encrypted key state. A stale entry is dead data, never a resurrected wallet in the UI. + +Write-path staleness ([audit trail in section 6.2](#62-followup-write-path-staleness-fixes-landed)): every load that can race an in-window user change merges per field instead of whole-value. Enabled tokens keep a per-toggled-id dirty set that a racing token-file load merges over the loaded list, cleared once the token file write lands ([`3bbc97fd`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/3bbc97fdde7fc8ad550c778f3a0ac921038c4e3c)); account-level custom tokens keep a per-token-id dirty set with the same merge, cleared by a new `ACCOUNT_CUSTOM_TOKENS_SAVED` dispatch once the saver writes ([`7a809228`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/7a80922884fe4f8621764b56785d125efa9ee0fc)); plugin and swap settings track dirty plugin ids per map ([`e9027da6`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/e9027da6eeff15ef4e993d621d0d169af1b98099)). The account-level token saver never writes before `customTokensLoaded`, because it rebuilds the whole file from Redux and a cache-seeded map would delete tokens another device added ([`d8c48b20`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/d8c48b200a2c2b0e67331cda043c81979dab10dd)), and the account cache saver ref-compares `currencyWalletIds`, from which it derives its `legacyWallets` flag at write time ([`9b4fe78b`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/9b4fe78be5c9a8c0e219f3e4fb78650732573ef7)). The whole-value wallet files (name, fiat, wallet settings) use write generations instead of a dirty flag: each user change bumps the wallet's generation for that file, each loader captures the generation before its disk read and dispatches it as `loadGen`, and the reducer applies a loaded value only when the generations match. A load that began before the change read the disk too early and is dropped; a load that began after it saw the write and applies, so a rename pulled from another device lands without waiting for the sync server to echo the local write. + +#### 4.1.6 yaob and reference stability + +Nothing new crosses the bridge. The GUI sees the same `EdgeCurrencyWallet` object with the same property surface; the pixie watcher's existing `update()` calls (`currency-wallet-pixie.ts:490-492`) propagate cached-then-live value changes the same way live-only changes propagate on master. The account's `currencyWallets` map remains the pixie output object (`src/core/account/account-api.ts:650`), so William's `===` stability requirement holds without any merge getter. + +One visible seam: `wallet.otherMethods` ([decision 7.8](#78-othermethods-names-are-cached-and-served-as-delegating-stubs)). It is an object of delegating stubs, one per known method name: each stub awaits the engine and forwards through the source object (preserving the plugin's `this`), resolving against the live engine on every call so a resync never leaves a stale capture, and rejecting cleanly when the loaded engine lacks the method. Names come from the cache on a warm login (before the engine exists) and from the live engine otherwise, and they persist in `accountCache.json` on every save. The object keeps its identity as long as the known name set is unchanged, which is the common warm-boot case; when a name first appears (a cold login's engine landing, an upgraded cache with no names yet, a plugin adding methods) the getter rebuilds it as a new bridgified object, because yaob only serializes the properties an object had when it first crossed the bridge (verified empirically during review: `update()` cannot add properties to an existing facade). The pixie watcher's `update()` then delivers the swapped value, exactly how the old engine-swap propagated. Pre-engine with no cached names this is `{}`, the original guarantee, so property probes like `wallet.otherMethods.foo == null` stay safe. `currencyConfig.otherMethods` keeps the plugin's own object verbatim whenever the plugin is loaded (always the case in the shipped app); the account cache's per-plugin name list only builds fallback stubs if plugin loading ever defers past the account emit. The GUI call sites patched in [section 4.2.3](#423-othermethods-one-patch-required) keep working, and on warm logins the FioActions TypeError class retires because the stub exists before the engine does. + +#### 4.1.7 waitForCurrencyWallet semantics + +`waitForCurrencyWallet` resolves when `walletApi` exists. After this change that is pre-engine, so its meaning shifts from "wallet fully ready" to "wallet object exists," and `waitForAllWallets` shifts with it. Internal core callers (`src/core/login/keys.ts:383`, `account-api.ts:726`) want the object, not the engine, so they are unaffected. The GUI call sites were audited individually; findings and required patches are in [section 4.2](#42-gui-integration-edge-react-gui). This call and the internal waiters are both priority hooks into the engine queue ([section 6](#6-phase-history-and-709-disposition)). + +#### 4.1.8 Boot outcomes + +What a login does is the product of what is on disk and what the account holds, and the individual rules are in [4.1.1](#411-cache-file), [4.1.2](#412-load-path), and [4.1.5](#415-save-path). Those sections are the source of truth; this table is an index into them and loses on any disagreement. Rows are the configurations a real device can reach. + +| On disk | Account emits | Wallet state comes from | Boot disk reads | Next write | +|---|---|---|---|---| +| No `accountCache.json` (first login) | after repo sync and `loadAllFiles`, as on master | the authoritative files | today's reads | a version-2 file, once the loads land | +| Version-2 file, no legacy wallets | right after `waitForPlugins` | the same file, one dispatch for every wallet | one | version 2, next slot | +| Version-1 file (no `wallets`), no legacy wallets | right after `waitForPlugins` | each wallet's own `publicKey.json` and `walletCache.json`, read once concurrently | one plus two per wallet | version 2, folding every wallet in | +| Any file, but the account has legacy Airbitz wallets | after repo sync and `loadAllFiles` | the authoritative files | today's reads | a version-2 file with `legacyWallets: true`, which keeps the next boot cold | +| Newest slot torn, older slot parses | right after `waitForPlugins`, from the older generation | the older generation | one, plus the failed parse | the damaged slot, which repairs it | +| Neither slot parses | after repo sync and `loadAllFiles` | the authoritative files | two failed parses | a fresh version-2 file | +| A wallet activated after the seed | already emitted | that wallet's own pixie read, account cache first and its per-wallet files as fallback | two for that wallet | the whole account, next throttle window | + +The archived case cuts across every row: a wallet that is not running keeps whatever entry it already had, so turning it back on is warm, and entries for wallets the account no longer has are dropped so the file cannot grow without bound ([section 4.1.5](#415-save-path)). + +### 4.2 GUI integration: edge-react-gui + +Every GUI surface that consumes wallet readiness was audited against the new semantics; the [cross-repo sequence diagram](#4-design-overview) shows what these surfaces observe from the far side of the [yaob](#yaob) boundary during the cache window. Findings, with the required patches marked (the audit turned out incomplete: one caller surfaced only at runtime, [section 4.2.3](#423-othermethods-one-patch-required)): + +#### 4.2.1 WalletLifecycle: no change required + +`src/components/services/WalletLifecycle.ts` batches wallet boot (8 concurrent on iOS, 3 on Android; line 27) by walking the sorted list and calling `wallet.changePaused(false)` on paused wallets (119-134, 180). Its only engine-dependent wait is a `watch('syncRatio')` bounded by a 5-second timeout per boot slot (190-200), so wallets that appear pre-engine degrade to "unpause batch, 5 s, next batch" in the worst case; no loop, no stall. The dedup set at line 129 prevents re-queueing while a boot is in flight. Pre-existing (not introduced by this change): a rejected `changePaused` leaks its boot slot (202-204); worth fixing opportunistically, not a blocker. + +#### 4.2.2 waitForCurrencyWallet call sites: one patch required + +- `controllers/action-queue/runtime/checkActionEffect.ts:122-123` reads `wallet.balanceMap` immediately after awaiting the wallet. Pre-engine it now sees cached (possibly stale) balances instead of live ones, which could mis-evaluate a balance effect. **Patch: while `wallet.syncStatus.totalRatio` is under `DONE_THRESHOLD` (0.999), the effect reports itself not-yet-effective with a 15 s retry delay, so the balance comparison only ever runs against a synced engine.** A retry suits the action queue, which already re-checks effects on a delay; the loan flow's [`syncRatio`](#syncratio) watch (`waitForLoanAccountSync.ts:8-13`) is the same gate written as a wait. Cached data cannot satisfy it because sync status is excluded from the cache file ([section 4.1.1](#411-cache-file)). +- `controllers/action-queue/runtime/evaluateAction.ts:204-326,489` and `controllers/loan-manager/redux/actions.ts:126,229` feed the wallet into borrow engines and `broadcastTx`/`makeSpend`. Those calls now pend on `getEngine()` internally and then complete; correct behavior, just later. No change. +- `controllers/action-queue/display.ts`, `push.ts`, `CreateWalletCompletionScene.tsx:145` read only config/info or set UI status. Engine-free. No change. + +#### 4.2.3 otherMethods: one patch required + +No wallet-list-render path touches `otherMethods`. All uses sit inside specific flows ([FIO](#fio), migrate/sweep, staking adapters, WalletConnect, resync menu). The call sites that can race the cache window: + +- `Services.tsx:84-94` runs `refreshAllFioAddresses` after `await account.waitForAllWallets()`, and `FioActions.tsx:39` then calls `fioWallet.otherMethods.fetchFioAddresses(...)`. With `waitForAllWallets` now resolving pre-engine, that is a method call on `{}`. **Patch: await `waitForWalletOtherMethods` on each FIO wallet before those refreshes.** The helper (47 lines, added in #6080) resolves as soon as the wallet's `otherMethods` carries names, which on a warm login is immediate, and rejects after a 10-minute bound sized for a 194-wallet account. Each wait is caught per wallet so one wallet that never loads cannot reject the whole gate, and the block returns without refreshing if the account logged out while it waited. +- `FioService.ts`'s periodic expired-domain check was not in the original audit and surfaced as a live red-alert crash on the sim. **Patch: skip FIO wallets whose `otherMethods.getFioAddresses` is still absent and let the next cycle retry them.** The check already runs on a timer, so skipping costs one cycle where a wait would hold a promise open. The fix also exposed a pre-existing stuck latch: `expiredChecking` was cleared only on the path that found names, so any other outcome disabled the check for the rest of the session. It now clears in a `finally`. "Two small GUI patches" was an audit result, and the audit was incomplete. +- `WalletConnectService.tsx:51-54` only null-probes `wallet.otherMethods.parseWalletConnectV2Payload`, which is safe against the stable `{}` guaranteed in [section 4.1.6](#416-yaob-and-reference-stability). No change. + +#### 4.2.4 Sync indicators: no change required + +`WalletSyncCircle.tsx` clamps `syncRatio` below 0.05 to a minimum ring (34-36) and hides it only above 0.999 (75), so a cached-balance wallet at ratio 0 renders as a normal syncing wallet, balances visible, ring spinning. `useAccountSyncRatio.tsx:54` treats missing ratio as 0 for the account progress bar. `BuyCrypto.tsx:90` and `useIsAccountFunded.ts:21` already treat `< 1` as unsynced, which remains the right conservatism for spend paths. + +#### 4.2.5 Receive scene: no change required + +`RequestScene.tsx` calls `wallet.getAddresses({ tokenId })` on mount and subscribes to the wallet's `addressChanged` event, re-running that query whenever it fires. That is exactly the contract [decision 7.4](#74-getaddresses-serves-the-cache-pre-engine-and-reconciles) provides: the first call is answered from the cache so the QR renders immediately on a warm login, and if the engine goes on to derive a different address the wallet emits `addressChanged` and this handler picks it up. A provisional affordance and an `allowCached` opt-in were tried here and reverted ([decision 7.9](#79-the-provisional-receive-affordance-reverted)); no scene code replaced them. + +#### 4.2.6 Wallet-scoped scenes: one patch required + +Opening a wallet-scoped scene is the clearest "the user wants this wallet" signal the GUI has, and the queue in [section 6](#6-phase-history-and-709-disposition) can only act on a signal it is given. `withWallet.tsx` therefore calls `account.waitForCurrencyWallet(walletId)` on mount purely for its side effect: the call moves that wallet's engine startup to the front of the core's post-login queue, and its result is discarded because the existing effect below it already handles a deleted or broken wallet. The null check keeps the patch working against a core that predates the method. + +## 5. Testing + +Adopt the two determinism mechanisms from #703's test suite (the tests are the strongest part of that PR, though the harness credit was optimistic: the fake plugin still needed the engine gate, a creation-order hook, a public-key check gate, and latent fixes before these cases could exist): + +- An engine gate on the fake plugin (`createEngineGate`): block `makeCurrencyEngine` until the test releases it, so "during the cache window" is a controlled state, not a race. +- Saver throttle override (50 ms in tests). + +Cases: + +1. Cold login, no cache files: wallet emits only after engine + name load; identical to master (regression guard). +2. Warm login, engine gated: wallet emits with cached name, fiat, enabled tokens, balances; balance reads match the cache. +3. Engine released: live balances overwrite cached ones; no flicker back to cached values; the wallet object reference is unchanged. +4. `makeSpend` called during the window: pends, then completes after engine release. +5. Engine failure during the window: pending engine-gated calls reject with `engineFailure`. +6. Wallet deleted during the window: pending calls reject with the does-not-exist error; no dangling promise. +7. `renameWallet` during the window: Redux updates, GUI-visible name changes, cache file re-saves with the new name. +8. Logout during a pending throttled save: no write after logout. +9. Corrupt / old-version cache file: [cleaner](#cleaner) rejects, cold path runs, file is overwritten on next save. +10. Balance change after engine start: cache file updates within one throttle window (checks the saver actually fires). +11. `otherMethods` is `{}` pre-engine and carries the engine's methods (callable through the bridge) post-engine. + +All 11 live in `test/core/currency/wallet/wallet-cache.test.ts`, alongside 11 scheduler cases in `engine-scheduler.test.ts`. The numbered cases here are design scenarios, and several take more than one assertion apiece: the three suites hold 50 `it()` blocks in total (24 in `wallet-cache.test.ts`, 15 in `account-cache.test.ts`, 11 in `engine-scheduler.test.ts`). + +In `test/core/account/account-cache.test.ts` ([section 6.1](#61-phase-3-account-startup-cache)): + +12. Cache-coverage exhaustiveness: enumerate the `EdgeCurrencyWallet` properties the wallet-list scene renders pre-engine and assert each is either seeded by `WalletCacheFile` or present in the documented engine-gated set from [section 4.1.4](#414-makecurrencywalletapi-changes). A newly added wallet property fails the test until it is classified. This is the single-implementation replacement for the compile-time forcing function #703 got from its typed mirror object: there, TypeScript forced a caching decision per property; here, this test does. +13. Warm account login with the account cache present: `makeAccountApi` emits before `loadAllFiles` completes, wallet pixies start immediately, and the cold path (no account cache) runs master's account boot unchanged. +14. Bulk seed dispatch count: a warm login with N wallets fires exactly two seeding dispatches (one `ACCOUNT_CACHE_LOADED`, one `CURRENCY_WALLETS_CACHE_LOADED`), every wallet gate opens from the bulk tick, and a wallet activated after login still seeds through its [pixie](#pixie)'s fallback read. + +Same file, one case per two-device sequence the write-path audit found ([section 6.2](#62-followup-write-path-staleness-fixes-landed)), with the repo-ahead-of-cache divergence produced deterministically by stalling the cache saver for a session: + +15. Custom token added in the boot window: the account token saver never writes the cache-seeded map, and the racing load merges per token id, so a token synced from another device and the in-window addition both reach disk and Redux. +16. Enabled-token toggle against a stale cached list: the call pends on the builtin definitions, lands as a toggle, and the racing token-file load preserves both the toggle and the other device's enablement. +17. Wallet-state change equal to the stale cache: the call waits for the wallet-state load and applies against the loaded record instead of silently no-oping and being reverted. +18. Settings write while Redux is behind the synced file: the writer merges into the freshly read file, so another device's settings survive on disk and a fresh login sees both. + +Same files, for the address and `otherMethods` caches: + +19. Pre-engine address serve: a warm wallet answers `getAddresses`/`getReceiveAddress` from the cache while its engine is still loading; the cached file carries the addresses per [tokenId](#tokenid) with balances stripped. +20. Rotating chains still gate: without the hint, a warm-login address query pends until the engine is released, exactly the pre-cache behavior. +21. A cached otherMethods name is callable before the engine exists; the call pends and then forwards. +22. A stale cached name rejects cleanly when the loaded engine lacks the method. +23. A version-1 cache file upgrades on read (warm boot preserved) and the stub set grows once the engine lands, through the bridge. +24. Config-level names persist in `accountCache.json` while the live plugin surface stays verbatim. +25. The cache-coverage classification gains a cache-assisted set (`getAddresses`, `getReceiveAddress`, `otherMethods`), so the exhaustiveness guard still forces a decision for new properties. + +Same file, for the address reconcile ([section 6.3](#63-phase-6-provisional-receive-address-for-rotating-chains-landed)): + +26. Address reconcile: on a warm login with the engine gated, `getAddresses({})` returns the cached address immediately and emits nothing; once the engine is released and derives a DIFFERENT address, the wallet emits `addressChanged` exactly once and the next query returns the engine's address. +27. Reconcile stays quiet on agreement: an engine that derives the same address the cache served emits no `addressChanged`. +28. `forceIndex` bypasses the cache: `getAddresses({ forceIndex: 0 })` on a gated wallet still waits for the engine, since a forced-index query wants a specific fresh address. +29. Torn-slot recovery: a boot whose newest slot is truncated part-way reads the older generation, emits from it, and targets the damaged slot on the next write. + +Same file, for the startup window's own teardown: + +30. Logout during engine startup: a wallet parked mid-startup (inside the public-key check, after the account API has emitted) surfaces no error when the account logs out and the block then resumes. The engine-startup block reads the account's tokens and settings after several awaits, and what makes that read safe is not a guard but redux-pixies handing a destroyed pixie its last cached props, so the account it reads is still there. The engine it goes on to build is killed by the post-creation `destroyed` check ([test case 6](#5-testing) covers the wallet-deletion path into the same kill). + +In `account-cache.test.ts`, for the write generations ([section 6.7](#67-followup-the-post-review-followups-landed)): + +31. A rename from another device applies after a local rename, because the load that the sync starts began after the local write. + +In `engine-scheduler.test.ts` and `wallet-cache.test.ts`, for the reviewer-bot findings in the same section: + +32. Logout releases in-flight startup slots for the next login: with one slot held open inside engine creation, a logout followed by a new login reaches engine creation while the old session's gate is still held. +33. Logout during engine startup swallows a late startup failure: an engine creation that fails after the account logged out emits no error. +34. A token toggle made before the token file loads reaches the cache once the load lands, even though the merged list is unchanged. +35. A name load that began before a local rename is dropped: with the fake disk's read gate parking the load on the value it already read, a rename in that window wins and the stale load does not apply. +36. A cache write left in flight by a logout lands before the next login writes: with the fake disk's write gate parking session A's write, session B's first write waits behind it and the two slots hold consecutive generations. +37. A wallet whose files have not loaded keeps its cache entry across a write: the saver's retention is keyed on the account's wallet list, not on the sparse `walletStates`. +38. A wallet keeps reaching the cache after a resync: the engine clear resets `tokenFileLoaded`, but the saver gates on a sticky `tokenFileEverLoaded`, so a rename made after the resync is written. +39. Adding a custom token to a legacy plugin (tools without `getTokenId`) while the engines are still queued waits for one of that plugin's engines instead of failing with "a wallet must exist". +40. That wait skips a wallet whose engine already failed: with the plugin's first wallet failed and its second still queued, the add waits for the second instead of rejecting with the first one's error. +41. A fallback read that outlives its logout does not seed the next session: with the loader's gate parking session A's pixie on the seed it read for an unarchived wallet, session B renames that wallet, and the parked read resumes into a destroyed pixie without touching B's name. +42. A wallet created after boot seeds from the cache when unarchived: the saver's write put it in the memo, so with the engine gated the unarchive emits the wallet from its entry, the same as a wallet the boot seeded. +43. Startup loads that outlive a logout do not touch the next session: with session A's fiat-file load parked past its read and the account logged out, a fiat code pushed from another device survives the parked load's release in the next session, because the startup block's dispatches go quiet at destroy. +44. A reconcile that outlives its logout stays silent: with the engine gated, a cached-address query parks its background reconcile on the engine wait, and logging out rejects that wait without reaching the context's `error` event. +45. An engine callback fired after its logout leaves the next session alone: the first session's callbacks are kept, and the balance one is called once the next login's wallet is up under the same id; that wallet keeps its own balance. +46. A staking query that outlives its logout reports no error: the engine's `getStakingStatus` is parked past the logout, and its rejection reaches nobody. +47. A token list survives an unreadable token file: with both token files corrupted on disk, the startup load keeps the cache-seeded list, and the next cache write still carries it. + +Verification. Performance is graded on physical hardware only; sim timings are not evidence. Measured on a Galaxy S9 (Android 10, release builds, the 194-wallet account, warm login; full method and raw tables in the [physical-device findings doc](https://gist.github.com/j0ntz/63c36e14285a638bc8874cb74e11d58e), [pinned](https://gist.github.com/j0ntz/63c36e14285a638bc8874cb74e11d58e/42ab2ddacf097d093ee8315f5fe750b7cc947d9f)): v2 seeds balances in 341 ms mean from [PIN](#pin) entry to the account-cache emit (range 305-411 ms over five iterations) with zero per-wallet file reads (the bulk loader), and the dispatch/store-transit epsilon is below measurement resolution; develop on the same device pays a ~57 s serial gap after account login plus ~23 s reading the 194 wallet files before balances render; #703 seeds in the same sub-second envelope as v2. Functional behavior (no timing claims) was verified in-app on the iOS sim, evidence on the PRs ([phases 1-2](https://github.com/EdgeApp/edge-core-js/pull/733#issuecomment-5015250816), [gui screenshots](https://github.com/EdgeApp/edge-react-gui/pull/6080), [phase 4](https://github.com/EdgeApp/edge-core-js/pull/733#issuecomment-5040277532), [phase 5](https://github.com/EdgeApp/edge-core-js/pull/733#issuecomment-5052051090)): the full wallet list renders from cache before any engine exists; wallets drain through the concurrency-8 startup queue with tap-to-front prioritization taking effect mid-drain; an in-app enabled-token round trip wrote through the toggle path and persisted through a cache-seeded relaunch; the receive scene's address query lands in the wallet's cache file keyed by tokenId; and the FioActions warm-boot TypeError (17 occurrences per session on phase-4 builds) dropped to zero once cached names exposed pre-engine stubs. The consolidation's own device evidence, which is write-side, is in [section 6.4](#64-phase-7-revert-the-provisional-surface-consolidate-the-cache-file-landed). + +## 6. Phase history and #709 disposition + +Phase 2 candidates as designed, followed by how the shipped mechanism diverged: + +- Gentle engine scheduling: stagger or defer engine startup, and prioritize a wallet's engine when the user taps it (via the waiter hooks from [section 4.1.4](#414-makecurrencywalletapi-changes) and [section 4.1.7](#417-waitforcurrencywallet-semantics)). Every engine still starts eventually; wallets must sync, receive, and update balances without being opened. What changes is only how aggressively starts are packed into the seconds after login. +- The one #709 commit worth keeping: the `balanceMap` reducer returning the existing `Map` when the value is unchanged. William called this one correct on the call. Land it as its own small PR. +- The rest of #709 (throttle 50 to 200 ms, 300 ms debounce, `hasYaobVisibleChange` field lists) is tabled. William's assessment: it duplicates [yaob](#yaob)'s own `===` diffing, and aggressive "nothing changed" suppression has previously caused stale-balance support tickets. Re-measure after phase 1 lands; most of the churn it fights should be gone once engines are not racing the login render. +- If bridge traffic still hurts, improve diffing in yaob itself rather than shimming around it in core. + +What actually shipped diverged in mechanism, not intent: + +- The sketch said "stagger or defer `engineStarted`", but the login jank lives in engine creation (the heavy [pixie](#pixie) block), so the shipped design queues that block instead (`engine-scheduler.ts`, concurrency 8), with a cold-bypass invariant keeping first login unchanged. +- "waitForCurrencyWallet becomes the natural hook" assumed one hook point; `account.waitForCurrencyWallet` is a watch-based reimplementation separate from the internal selector, so the hook landed in both, and the queue interacts with [section 4.1.4](#414-makecurrencywalletapi-changes)'s own waiters (a rename or spend during the window would otherwise wait behind the whole queue), which forced priority bumps in `getEngine`, `getStorage`, and `changePaused(false)` as well. +- Pooling created failure classes the first draft never had to discuss: slot starvation (one wedged wallet permanently shrinking the pool; fixed with a 30s watchdog that force-releases and temporarily over-admits, degrading to master's unbounded behavior) and lost one-shot priority signals (a bump arriving before the wallet reaches the queue; fixed with sticky bumps carrying a 30s [TTL](#ttl), since post-startup engine calls bump constantly and would otherwise mark every wallet "asked for" by the next login). Both came out of adversarial review, not this spec. +- redux-pixies' `waitFor` evaluates against a stale `propsCache` after pixie destroy, so deletion-while-queued needed the engine pixie converted to `{update, destroy}` form with a destroy flag; most of the phase 2 diffstat is that re-indent. + +### 6.1 Phase 3: account startup cache + +Shipped as designed below, with the deferred loads hardened in review (bounded retry, terminal-failure plumbing for every repo waiter, and pre-storage fallbacks for the account disklets). The finding: v2 never captured #703's account-level win. On this branch the account boot matches master exactly, and no wallet can even read its `walletCache.json` until the whole chain completes: `waitForPlugins` then `loadBuiltinTokens` then account repo `addStorageWallet` then `loadAllFiles` (`loadAllWalletStates` + `loadCustomTokens` + `reloadPluginSettings`) then `makeAccountApi` (`account-pixie.ts:93-107`), because wallet pixies spawn from `currencyWalletIds`, which stays empty until `ACCOUNT_KEYS_LOADED` fires inside `loadAllWalletStates` (`currency-pixie.ts:36-38`, `account-reducer.ts:164-190`, `account-files.ts:104,134`). #703 proved the bypass works: its cache path awaits only `waitForPlugins` and the account repo sync, emits the account, and runs `loadBuiltinTokens` + `loadAllFiles` fire-and-forget (#703 `account-pixie.ts:178-202`). A large share of #703's measured 5x plausibly lives in this bypass, not in its wallet mirroring. + +The port, v2-style (same seed-then-overwrite pattern as [section 4.1.2](#412-load-path), no mirror objects): + +- One account-level cache file, per #703's one-file layout: the per-wallet files stay for wallet UI state, but the account boot inputs are cached in a single `accountCache.json` on the account's local [disklet](#disklet), holding what `ACCOUNT_KEYS_LOADED` and its siblings would produce: wallet states (archived/hidden/sortIndex), the wallet-info list needed to populate `currencyWalletIds`, custom token definitions, and plugin settings. Constraint carried from [decision 7.2](#72-privacy-coins-get-the-same-plaintext-cache-as-every-other-chain)'s analysis: nothing beyond what the plain disklet already exposes; private key material never enters the file. +- A new `ACCOUNT_CACHE_LOADED` action seeds that state (including `keysLoaded`) immediately after `waitForPlugins`, so `makeAccountApi` emits and wallet pixies start their cache reads without waiting for repo sync or file loads. +- `loadBuiltinTokens` and `loadAllFiles` then run deferred and overwrite the seeded state authoritatively, with the same dirty-wins guards the wallet reducer already uses, and the account cache re-saves through a throttled saver watching the relevant account slices (learning from #703's custom-token bug: the saver's dirty set must include account-level `customTokens`, the exact source its saver missed). +- Cold path (no account cache): master's boot, unchanged, regression-guarded by [test case 13](#5-testing). +- Bulk seeding, one store tick for all wallets: the per-wallet cache reads move out of the individual wallet pixies into one loader that runs as soon as the account cache seeds `currencyWalletIds`. It reads every wallet's `publicKey.json` + `walletCache.json` concurrently and dispatches a single `CURRENCY_WALLETS_CACHE_LOADED` action carrying all seeds (public info + UI state per wallet), so a warm login costs two seeding dispatches total (account, then bulk wallet) instead of two per wallet, and every pixie and watcher evaluates once against fully-seeded state instead of ~2N times. This is batching, not notification suppression: every consumer is still notified, once, with the final state, so it carries none of the missed-update risk that tabled #709 ([section 6](#6-phase-history-and-709-disposition)). The per-wallet read inside the wallet pixie stays as the fallback for wallets activated after login and for the no-account-cache cold path. Per-wallet files remain the write targets; the file-layout tradeoffs and the trigger for revisiting them are [decision 7.6](#76-one-account-cache-file-not-per-wallet-files). The one store transit that remains after bulk seeding is pinned to this architecture, not tunable: the wallet object's getters read Redux, and feeding them from a second non-Redux path would reintroduce a shadow copy. Its plausible ceiling is one reducer pass plus one watcher evaluation for a single batched action. +- Housekeeping in the same phase: drop the duplicate `publicKey.json` read inside the queued engine block (`currency-wallet-pixie.ts:752`), and add [test cases 12-14](#5-testing). +- Validation, ran 2026-07-21 on a physical Galaxy S9 ([findings doc](https://gist.github.com/j0ntz/63c36e14285a638bc8874cb74e11d58e)): the cross-build A/B against #703 and develop on the same 194-wallet account measured v2's warm seed at 341 ms mean ([PIN](#pin) entry to account-cache emit), the file-read window at zero (so [decision 7.6](#76-one-account-cache-file-not-per-wallet-files)'s revisit trigger did not fire), and the dispatch window below measurement resolution; v2 and #703 are within noise of each other, versus develop's ~57 s gap plus ~23 s of file reads. + +```mermaid +flowchart LR + subgraph now[v2 today] + A1[waitForPlugins] --> B1[loadBuiltinTokens] --> C1[account repo sync] --> D1[loadAllFiles] --> E1[makeAccountApi] --> F1[wallet cache reads begin] + end + subgraph p3[with phase 3, warm login] + A2[waitForPlugins] --> B2[read accountCache.json
ACCOUNT_CACHE_LOADED] --> E2[makeAccountApi] --> F2[bulk read all wallets' cache files
one CURRENCY_WALLETS_CACHE_LOADED] --> G2[every wallet gate opens
from a single store tick] + B2 -. deferred, overwrites seeded state .-> D2[repo sync + loadAllFiles + builtinTokens] + end +``` + +### 6.2 Followup: write-path staleness fixes (landed) + +The [write-path audit](https://gist.github.com/j0ntz/94c3e64779a92e8a1ae1896f4d7d3d6f) confirmed four gaps where a load racing an in-window user change could lose data. All fixes reuse existing patterns ([decision 7.7](#77-per-concern-load-gates-not-one-loadallfiles-promise)); no new surfaces: + +1. The account token saver is gated on `customTokensLoaded`, closing the missing gate that could permanently delete a cross-device custom token ([`d8c48b20`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/d8c48b200a2c2b0e67331cda043c81979dab10dd)). +2. Whole-valued dirty guards became per-field merges: custom tokens per token id, cleared once the saver writes them to disk ([`7a809228`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/7a80922884fe4f8621764b56785d125efa9ee0fc)); enabled tokens per toggled id, with `changeEnabledTokenIds` applying the caller's change as toggles over the current list ([`3bbc97fd`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/3bbc97fdde7fc8ad550c778f3a0ac921038c4e3c)); plugin/swap settings per plugin id, with the writers merging into the freshly read file instead of rebuilding it from Redux, serialized per account ([`e9027da6`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/e9027da6eeff15ef4e993d621d0d169af1b98099)). +3. `changeWalletStates` gates on `walletStatesLoaded`, so the written record bases on loaded state ([`dc1cb707`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/dc1cb70763ef04ac7f030faa80983650c1abdb32)). +4. The account-level engine methods (`getActivationAssets`, `activateWallet`, `getDisplayPrivateKey`, `getDisplayPublicKey`) wait via the shared `waitForCurrencyEngine` selector instead of throwing pre-engine, reading the wallet list after the wait ([`987632ca`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/987632ca7d487b15ea71e851f14d5b6888ad209b)). +5. `currencyWalletIds` joined the account-cache saver's ref-compare set (theoretical, one line; [`9b4fe78b`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/9b4fe78be5c9a8c0e219f3e4fb78650732573ef7)). + +The four audit scenarios are regression-guarded by [test cases 15-18](#5-testing) ([`16ff1fef`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/16ff1fefdacb79059edd74138b66dfc538284246)); reaching them deterministically needed the fake sync server to accept the hash-suffixed store routes it hands out ([`1e2d4808`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/1e2d48086311ed27db5b8aed251085ba629668e5)). + +### 6.3 Phase 6: provisional receive address for rotating chains (landed) + +The receive path always waited on the engine, which is slow on rotating chains. This landed across three repos: the core `allowCached` opt-in ([section 4.1.4](#414-makecurrencywalletapi-changes)), the receive-scene provisional UI (since reverted, [section 4.2.5](#425-receive-scene-no-change-required)), and the plugin flags in edge-currency-accountbased. It is a deliberate product change: it accepts informed address reuse for a user who acts within the pre-engine window in exchange for an instant receive screen, made visible by the affordance rather than silent ([decision 7.9](#79-the-provisional-receive-affordance-reverted)). Coverage: unit tests assert the rotating-chain `allowCached` serve, the programmatic gate (a plain query still waits), that `forceIndex` bypasses the cache even with `allowCached`, and (gui side) that the affordance row renders on a rotating chain once the grace window elapses while the engine-confirmed query stays pending ([test cases 26-28](#5-testing)). Also folded in a review finding one step removed from phase 6: a terminal deferred-boot failure left `bulkWalletSeedPending` stuck true, wedging every wallet pixie ([`c532ebee`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/c532ebee), clearing the flag on `ACCOUNT_LOAD_FAILED` alongside the existing `ACCOUNT_KEYS_LOADED` backstop). + +### 6.4 Phase 7: revert the provisional surface, consolidate the cache file (landed) + +Two ordered parts, the revert first so the storage change landed on a smaller surface. + +Reverted: the receive-scene provisional affordance, the core `allowCached` opt-in, and the `EdgeCurrencyInfo.hasStableAddresses` gate; [edge-currency-accountbased#1076](https://github.com/EdgeApp/edge-currency-accountbased/pull/1076) was closed and dropped from scope. Serving a cached address silently is now an accepted edge case ([decision 7.9](#79-the-provisional-receive-affordance-reverted)). The commits were dropped from the branches rather than reverted on top, so the history never contains the add-then-remove cycle. + +The revert exposed a gap the design had assumed away. The surviving requirement was that consumers pick up a changed address once the engine loads, and nothing did: `addressChanged` is only emitted when a running engine reports a rotation, and the cached serve returned before the engine was ever asked, so `rememberAddresses` never ran either. A consumer would have held the cached address indefinitely. Phase 7 therefore added core-side reconciliation ([section 4.1.4](#414-makecurrencywalletapi-changes)) rather than only deleting code, which fixes every consumer instead of the one scene the affordance covered. + +Consolidated: `accountCache.json` absorbed every wallet's cache and its `publicKey.json`, reversing [decision 7.6](#76-one-account-cache-file-not-per-wallet-files). What the measurements said: + +| | Before | After | +| --- | --- | --- | +| Boot reads (194-wallet account) | 389 files, 93.8 KiB | 1 file, 105.5 KiB | +| Throttled writers | one per wallet (194) | one per account | +| Writes per 5 s sync window | up to 194 by structure, 100 measured | 1 | +| On-disk cache | 1387 files, 330.1 KiB | 2 slots, 211.0 KiB | + +The read-side reduction is structural, not a latency win: [section 6.1](#61-phase-3-account-startup-cache)'s S9 A/B had already measured the per-wallet read window at zero, so those 389 reads were never on the critical path. The write-side consolidation is the actual benefit. The tradeoff is that every write is now the whole account rather than one wallet's ~276 B. + +Torn writes needed a different answer than the design assumed: the disklet exposes no rename on either platform, so write-temp-then-rename is not implementable. iOS already writes atomically; Android does not. Generations therefore alternate between two slots ([decision 7.10](#710-two-slot-alternation-because-the-disklet-has-no-rename)). Migration: a version-1 file sends a device to the per-wallet reads once, and the saver folds them into the consolidated file; the old per-wallet files are left in place as a recovery net and are no longer written. Coverage: [test cases 26-28](#5-testing) for the reconcile and the `forceIndex` bypass, plus a torn-write recovery case and a pre-consolidation upgrade case. + +#### Physical-device validation of the consolidation + +The consolidation's claims are write-side, so the evidence that decides them is on hardware: what a post-login sync window actually costs in writes, what one write costs at full account size, and whether a damaged slot degrades to the other one rather than to a cold boot. Four attempts were needed to produce it. The three that produced nothing failed on device access, never on the design, and are recorded here once rather than run by run: + +| Attempt | Outcome | +| --- | --- | +| 2026-08-01 | No measurement. Reported at the time as "an Android build did not fit alongside the iOS verification", which was a prediction rather than a check. The real reason is the next row. | +| 2026-08-01 | The device's Edge install carried no login stash (`files/local` absent, no `accountCache*.json`), so there was no account to warm-boot. Seeding one needs a full password login, and the account password is not available to an automated run. | +| 2026-08-03 | Seeding by QR (the account-less phone mints an edge-login lobby and a logged-in iOS sim approves it) validated everywhere except the one step that needs the phone's UI: the device sits behind a lock-screen PIN, a third credential distinct from both the account password and the account PIN. Proven on the way: `edge://edge/` routes through the GUI's deep-link parser into the approver scene's `fetchLobby`, and the QR decodes off an `adb` screenshot with Core Image alone, so the approving simulator needs no camera. | +| 2026-08-04 | PIN supplied, device seeded over that QR path, measurements below. One further obstacle worth recording: the APK on the device was a debug build with no reachable Metro, so an unlocked phone was still un-drivable until a release APK carrying this branch's core replaced it, and `run-as` needs that release build marked debuggable or the cache files cannot be read at all. | + +Measured on a Galaxy S9 running Android 10 and a release build of this branch, account `edge-funds`, 146 to 156 currency wallets: + +| | | +| --- | --- | +| Consolidated file on disk | two slots, 89.6 KiB each at 146 wallets, 101.9 KiB at 156 | +| Writes across a warm login plus a 3-minute sync window | 22 | +| One full-account write under sync-window load | 1.3 to 3.5 s, median 2.1 s (n=12) | +| One full-account write once the window quiets | 76 ms | +| Warm account emit | same second as key decryption, and 12 to 14 s ahead of the deferred loads finishing | +| Boot with the newest slot truncated | still emits from cache off the older slot, no crash, and the next write targets the damaged slot | + +The result that matters is not the count but what dominates a write. Payload size does not: during the first-login window, writes carrying no wallets at all (a few hundred bytes) took up to 11.4 s, while a full 89.6 KiB write took 76 ms once engine startup quieted down. What a write costs is JS-thread and bridge contention during engine startup, not the number of bytes. So [decision 7.6](#76-one-account-cache-file-not-per-wallet-files)'s write-amplification tradeoff is real in bytes and close to irrelevant in time on this hardware, which strengthens the consolidation rather than qualifying it: collapsing N throttles into one removes work from exactly the window where each unit of work is most expensive. + +The per-wallet counterpart was then measured directly, on the same device and the same account, from a release build at `cae1f073`, the last commit before the consolidation. Its saver logs one line per write, the same instrumentation shape the consolidated numbers above came from, so the two sides are counted the same way. Both builds were driven through the same two logins: a first login with no cache on disk, then a warm login measured over its first 180 seconds. + +| Per 180 s window | Pre-consolidation (a file and a throttle per wallet) | Consolidated (one file, one throttle) | +| --- | --- | --- | +| Writes, first login with no cache | 317 | 18 | +| Writes, warm login | 363 | 22 | +| Most writes in any 5 s span, first login | 100 | 1 by construction | +| Most writes in any 5 s span, warm login | 48 | 1 by construction | +| One write, warm login | 675 ms median, 3.6 s worst (n=453) | 2.1 s median, 3.5 s worst (n=12) | +| Distinct wallets writing in the window | 196 | n/a, one writer | + +The consolidated column is the 2026-08-04 run's numbers. Re-installing the branch build over the pre-consolidation layout in the same session and repeating the warm login reproduced them: 12 writes in the window, 540 ms median, and the migration path folded the per-wallet files back into the consolidated file without a cold boot. + +So the design claim holds in direction and lands well short of its stated ceiling in magnitude: the account never had all 194 wallets dirty inside one 5 s window, and the worst span observed was 100 writes. A per-wallet write is also individually cheaper than a full-account write, which the byte counts already implied. What the consolidation removes is the count, and the count is what the JS thread and the bridge are paying for during engine startup: 363 writes became 22 across the same warm window, and the peak second-by-second pressure dropped from 48 concurrent writers to one. + +One limit remains. The torn-slot recovery was produced by truncating the newest slot by hand rather than by catching a real kill mid-write: three force-stops timed into the write window all landed between writes and left both slots intact, which is evidence about how narrow the window is, not evidence that the tear cannot happen. + +### 6.5 Followup: write-cost visibility (landed) + +Both items came out of [section 6.4](#64-phase-7-revert-the-provisional-surface-consolidate-the-cache-file-landed)'s own gaps. + +| Gap | Disposition | +| --- | --- | +| The per-wallet write count was derived from code structure, not measured | Measured, on the same S9 and account, from a release build at `cae1f073`. Numbers and what they change are in [section 6.4](#64-phase-7-revert-the-provisional-surface-consolidate-the-cache-file-landed). | +| Getting the consolidated write numbers needed a patched build, so the next reader would pay for one too | The saver now logs each write ([section 4.1.5](#415-save-path)). Both sides of the table above were counted off that line's shape, so reproducing the comparison needs no patch on the current build. | + +The measurement also settled a question the design left open in the other direction. [Decision 7.6](#76-one-account-cache-file-not-per-wallet-files) worried about write amplification in bytes, and the byte cost is real: a full-account write carries the whole 89.6 KiB where a per-wallet write carried ~276 B, and a single per-wallet write is correspondingly quicker (675 ms median against 2.1 s). It does not matter, because the count moves by an order of magnitude more than the per-write cost does, in the exact window where the JS thread is most contended. + +### 6.6 Followup: first human review (landed) + +The first full human review of both branches produced six core findings and one gui finding. Four core findings and the gui finding changed the design; two were answered and left as they stood. + +| Finding | Shipped as | +| --- | --- | +| Engine created with empty `userSettings`, because nothing ordered the deferred `reloadPluginSettings` ahead of it | Engine startup waits for the whole deferred account load (custom tokens, plugin settings, builtin tokens) before `makeCurrencyEngine`, rejecting on `ACCOUNT_LOAD_FAILED` ([decision 7.7](#77-per-concern-load-gates-not-one-loadallfiles-promise)) | +| `getFallbackDisklets` in the wallet API duplicated `makeStorageWalletApi`'s own pre-repo fallback | Deleted; both getters delegate to `storageWalletApi` | +| A wallet outside the bulk seed re-read both cache slots, ~2 x 105 KiB each | The account pixie keeps the parsed boot file for the session; a miss goes straight to the per-wallet files ([section 4.1.2](#412-load-path)) | +| `stakingStatus` was uncached while the classification table called it engine-gated | Cached, and the table corrected ([section 4.1.4](#414-makecurrencywalletapi-changes)) | +| A cached wallet whose engine failed rendered as a healthy row, since the loading row that carried the error is unreachable once the wallet object exists | The shared wallet row shows the engine error in the overlay slot that paused and disabled wallets already use, so the row and its balances stay | + +Two findings were answered rather than changed, both because the proposed fix costs more than the behavior it corrects: + +- **A batch of bumps runs in reverse of the order it was asked for.** Making the insert preserve call order requires walking past entries already marked bumped, and those marks accumulate from incidental startup bumps, so a live user bump lands behind them. At batch sizes within the concurrency of 8 the ordering is unobservable. +- **`nameDirty` and its siblings clear only on a `fromFile` load.** The writers dispatch after the file write is durable, so the flag exists to beat a load that started before that write. Clearing it in the save path would hand that in-flight load the win. Removing the dependency on the sync server's echo needs a per-file load generation, which is deferred. + +The review also moved one invariant. Engine startup is now a reader that gates on the deferred account load, where the per-concern gates previously covered only writers. + +### 6.7 Followup: the post-review followups (landed) + +The three follow-ups the post-review runs recorded, each shipped or dispositioned. + +| Follow-up | Shipped as | +| --- | --- | +| Whole-value wallet files kept a dirty flag that cleared only on a `fromFile` load, so correctness rested on the sync server echoing the local write | Write generations ([section 4.1.5](#415-save-path)): a load applies only when the generation it began under is still current. Test case 31 | +| `saveTxAction` and `saveTxMetadata` wait for the engine although they write repo files | Left engine-gated on purpose: the write also needs the transaction in Redux (`walletState.txs[txid]`), and every reducer case that fills that map is an engine action, so a file-scan gate would turn a pending call into a "missing tx" error rather than let the write through | +| The per-write log line shipped at `warn` for the whole session | `info`, with `warn` only above 5 s. A product call, made here rather than deferred again: the line exists to make the write cost visible, and a warm login's normal writes are the baseline, not the signal | +| Reviewer-bot findings on the pushed heads | A wallet pixie's `destroy` releases its engine-startup slot, so a logout mid-startup no longer makes the next login queue behind the old session's awaits (scheduler test `logout releases in-flight startup slots for the next login`); the startup catch returns when the pixie is destroyed, so a failure that lands after logout is teardown rather than a wallet error; the per-session boot-file memo remembers a miss too, so a cold login goes straight to the per-wallet files instead of re-reading both empty slots per wallet; and the saver's change stamp leaves out wallets still loading their files, so a token toggle made in that window reaches the cache once the load lands (test case 34). Accepted risk, with its trigger: a toggle made on a cache-seeded wallet and followed by a logout before that wallet's queue slot is lost, because the repo write must wait for the authoritative load; reopen if support reports token toggles vanishing right after login. And the saver's write chain is keyed per account at module level, so a write a logout leaves in flight is awaited by the next login's saver before it resolves its starting slot, rather than the two racing for one slot (test case 36). The fake world's disk gained read and write gates for these cases (test cases 35 and 36). Found by this segment's own tests and fixed: the saver kept a not-yet-loaded wallet's entry only when `walletStates` listed the wallet, and that map only lists wallets with an explicit state, so a plain wallet that was merely not loaded lost its entry; retention is now keyed on the account's wallet list (test case 37). A later bot finding: a resync clears `tokenFileLoaded` for the rest of the session, which froze the wallet's cache entry; the saver now gates on a sticky `tokenFileEverLoaded` and the token saver keeps master's flag (test case 38). Another: a legacy token plugin validates tokens through a running engine, and with wallets emitted before their engines `getTokenId` threw during the queue window; it now waits until any of that plugin's wallets has an engine, bumping a healthy one to the front, and rejects only once none of them can still start (test cases 39 and 40). A later Bugbot round on the pushed head: a pixie destroyed by a logout during its fallback cache read still dispatched its seed, and the next login's pixie under the same wallet id took that as its name, fiat and token list; the read now returns on a destroyed pixie before seeding or re-adding the [storage wallet](#storage-wallet) (test case 41). And the per-session memo of the account cache file held only the boot snapshot, so a wallet created after boot and later unarchived missed its entry and fell to per-wallet files this version no longer writes; the saver now replaces the memo with each generation it writes (test case 42). A Bugbot round on the re-split head: the claim that a warm login now starts the engine before `loadAddressFiles` feeds it the legacy `Addresses/` rows was answered without a change, because master already queued `startEngine` from the name-file dispatch before those files were read, and the [UTXO](#utxo) engine's `addGapLimitAddresses` saves the rows as used and re-runs its processor whenever they arrive. Accepted risk, with its trigger: a wallet created after boot and archived before the throttled saver's next write is in neither the file nor the active set, so its first unarchive boots on the cold path and its entry lands with the next write; reopen if support reports a just-created wallet showing no cached balances after an archive and restore. A second round on that head: file loads in the wallet startup block kept dispatching after a logout, and the next login's pixie under the same wallet id took those reads as its own, so a fiat code or name synced from another device in between was reverted and the saver would have cached it; every dispatch of the startup block now goes through a wrapper that goes quiet at destroy (test case 43). The account-level bulk loader needs no change: the account pixie's destroy blanks its stale props' account map, so its presence check already fails after a logout. A later round on the same head: the background address reconcile forwarded its own teardown rejection to the app, so a logout during the pre-engine window emitted a shutdown error from a correction nobody was waiting for; it now returns on a destroyed pixie or a missing wallet (test case 44). A further round on that head covered the rest of the teardown surface: the engine's callbacks were built from the raw pixie input rather than the guarded one, so a balance, token or sync report that arrived between `destroy` and the end of `killEngine` wrote into the next login's wallet under the same id (test case 45), and the engine startup's staking query forwarded its own rejection to the app after a logout the same way the reconcile did (test case 46). The same round's claim that the account cache saver's memo can overwrite the next login's was answered without that consequence, because account ids never repeat (`login${n}`), so no later session can read a memo an earlier one left; the saver skips the memo write when it is destroyed anyway, which keeps a logged-out session's file from sitting in the map for an id that can never log in again. A round on the autosquashed head read the two `waitForCurrencyWallet` implementations as disagreeing, since the internal one in `currency-selectors.ts` throws `engineFailure` even once `walletApi` exists while the account-level one resolves the object first. Answered without a change: both orderings are master's, `checkCurrencyWallet` is a pure extraction of the guards master already ran in that order, and the two waiters have different callers by design. The internal one is reached only from `splitting.ts` and `keys.ts`, which have just created or split a wallet and need one whose engine came up, while the public waiter is how the GUI gets the wallet API object for a row it may have to render an engine error on. What this work changes is how wide the window is in which the difference shows, since a cache-emitted wallet has a `walletApi` long before its engine, and neither internal caller runs inside that window. A round on the land-ready head: when neither token file could be read, the startup load replaced the cache-seeded enabled list with an empty one and the saver cached the loss, the same hazard the legacy-file branch already waits out; that load now leaves the list as it is, still marking the file loaded (test case 47) | + +## 7. Decisions + +Each entry carries what was chosen, the evidence behind it, the alternatives and why they lost, and the trigger that would reopen it. Three review rounds attacked stale cached balances, the `publicKey.json` seam, and the `otherMethods` contract, and every rejection cited this section. Three entries have moved since they were written: [decision 7.1](#71-ui-state-stays-out-of-the-public-key-cache)'s file layout was folded into the phase-7 consolidation, [decision 7.4](#74-getaddresses-serves-the-cache-pre-engine-and-reconciles) dropped its per-chain gate, and [decision 7.5](#75-the-account-path-was-scoped-out-then-taken-up-in-phase-3) was reversed outright. The rest stand as written. + +### 7.1 UI state stays out of the public-key cache + +The call weighed extending `publicKey.json` to carry the cached UI state against giving that state a file of its own. + +- **Chosen: a file of its own.** `asPublicKeyFile` (`currency-wallet-cleaners.ts:397`) is tied to a re-derivation upgrade path, where `getPublicWalletInfo` ignores the cached value once `tools.checkPublicKey` rejects it, re-derives, and overwrites the record. UI state sharing that record would go with it on every key-format upgrade, and any UI-state schema change would put the key cache at risk. +- **What the separation buys.** A versioned [cleaner](#cleaner) of its own turns UI-state schema evolution into a one-line version bump with a defined fall-through ([section 4.1.2](#412-load-path), [test case 9](#5-testing)). +- **Where it stands after phase 7.** `accountCache.json` holds the cached `walletInfo` and the UI state in one file ([decision 7.6](#76-one-account-cache-file-not-per-wallet-files)), and the property that mattered survives the merge: the file carries its own version and cleaner, and a rejected `checkPublicKey` replaces one wallet's `walletInfo` value rather than discarding the file, so an upgrade never costs the cached names and balances. +- **Rejected, extend `publicKey.json`.** It saves one read and one cleaner, and ties UI-state schema evolution to key-cache invalidation. The read it saves later measured at zero on device ([decision 7.6](#76-one-account-cache-file-not-per-wallet-files)), so that saving was never the deciding term. +- **Reopen if** a `checkPublicKey` rejection becomes a file-level discard rather than a value-level one, which would put the cached UI state back inside the key cache's blast radius. + +### 7.2 Privacy coins get the same plaintext cache as every other chain + +Balances for Monero, Zcash, and Pirate Chain are not derivable from public on-chain data, so writing them to the plain [disklet](#disklet) looks like a new exposure. + +- **Chosen: uniform plaintext caching, no per-chain branching.** The exposure already exists. `publicKey.json` stores each Monero wallet's private view key (`moneroViewKeyPrivate`, edge-currency-monero `MoneroTools.ts:72-88`) and each Zcash/Pirate Chain wallet's unified viewing key (edge-currency-accountbased `ZcashTools.ts:142-160`, `PiratechainTools.ts:133`) on the same unencrypted `walletLocalDisklet`. Anyone who can read the cache can already read those viewing keys and compute the balances, so encrypting only the new file would add code and protect nothing. +- **Rejected, encrypt for chains that declare a privacy flag** (move both files to `walletLocalEncryptedDisklet`, created alongside the plain one, `currency-wallet-pixie.ts:95-100`). Sound as hardening, but it is a task about the viewing keys, not about this cache, and pairing them would gate this work on a security review it does not need. +- **Reopen if** the viewing-key exposure is itself judged unacceptable. That makes the encrypted disklet a task in its own right, and this cache moves with it. + +### 7.3 getTransactions stays engine-gated + +Transaction history is not in the cache file. + +- **Chosen: leave history behind the engine.** The tx list state loads from per-tx disk files inside the engine [pixie](#pixie) (`loadTxFileNames`, `currency-wallet-pixie.ts:104`), after repo sync. Serving it pre-engine means either caching history, a large and fast-growing file duplicating what the tx files already persist, or reordering those loads. Both are well beyond a few hundred lines. +- **Why the login path can afford it.** The wallet list renders names and balances. History renders after tapping into a wallet, by which time the engine is loading anyway, and the queue's tap-prioritization shrinks that wait further ([section 6](#6-phase-history-and-709-disposition)). +- **Deferred, not rejected, caching the most recent N transactions** for an instant detail-scene paint. +- **Reopen if** device timings show the detail-scene wait is noticeable after a tap. That measurement is what would justify the extra file. + +### 7.4 getAddresses serves the cache pre-engine, and reconciles + +Addresses started uncached, because address correctness is engine policy: [UTXO](#utxo) chains rotate fresh addresses, and a cached "last known fresh address" can silently promote address reuse. Phase 5 cached them behind an `EdgeCurrencyInfo.hasStableAddresses` hint, so only chains declaring that their addresses never rotate served the cache. Phase 7 removed the hint. + +- **Chosen: every chain serves its cached address pre-engine.** What changed is the product decision, not the analysis. Serving a possibly-superseded address silently was reviewed and accepted as a tolerable edge case, which removes the reason for both the per-chain gate and the plugin-side flag work. +- **What bounds the reuse.** Reconciliation rather than a flag. Any query answered from the cache is re-asked of the engine in the background, and the wallet emits `addressChanged` when the answers differ, so consumers re-query and land on the engine's address. An engine that agrees stays silent, which is the common case. +- **What still waits for the engine.** `forceIndex` queries, since a caller naming an index wants a freshly derived address only the engine can produce, and wallets whose engine has already failed ([section 4.1.4](#414-makecurrencywalletapi-changes)). +- **Blast radius.** The cache is keyed per [tokenId](#tokenid) and stores no balances, so a token's answer can never masquerade as the parent chain's. +- **Rejected, the `hasStableAddresses` per-chain gate.** It needed a flag on every plugin ([edge-currency-accountbased#1076](https://github.com/EdgeApp/edge-currency-accountbased/pull/1076), closed) to cover a case the reconcile handles for every chain at once. +- **Reopen if** address reuse from the pre-engine window turns up in support tickets, or a chain appears whose rotation the reconcile cannot correct fast enough to matter. + +### 7.5 The account path was scoped out, then taken up in phase 3 + +- **Chosen at design time: leave the account path alone.** The 5x QA measurement came from the wallet path, and the account path's blockers looked like login cryptography and encrypted-repo sync, neither of which a file cache speeds up without a security design of its own. +- **Reversed in phase 3.** The blocker was neither. No wallet could read its cache until `waitForPlugins`, `loadBuiltinTokens`, the account repo's `addStorageWallet`, and `loadAllFiles` had all finished, because wallet pixies spawn from `currencyWalletIds`, which stays empty until `ACCOUNT_KEYS_LOADED` fires inside `loadAllWalletStates`. That is file loading and repo sync, not cryptography, so the same seed-then-overwrite pattern applied ([section 6.1](#61-phase-3-account-startup-cache)). A warm boot now emits the account API immediately after `waitForPlugins`; a cold boot still runs master's ordering. +- **What remains out of scope.** Login cryptography and the encrypted-repo sync themselves. Nothing here makes key decryption or the sync faster; the win is in no longer waiting on the file loads behind them. +- **Reopen if** device timings show key decryption or repo sync dominating a warm login. That is a security-design investigation rather than a caching one. + +### 7.6 One account cache file, not per-wallet files + +The login read path could be one account-level file, N per-wallet files, or a hybrid. Originally chosen: per-wallet `walletCache.json` files as the write targets, with [section 6.1](#61-phase-3-account-startup-cache)'s bulk loader as the only login-path reader. Reversed in phase 7: one `accountCache.json` holds the account boot state and every wallet's, public keys included. + +- **The read path is not what reversed it, and the original reasoning here was sound.** [Section 6.1](#61-phase-3-account-startup-cache)'s S9 A/B measured the per-wallet file-read window at zero, so this decision's own revisit trigger never fired. Those reads run after the account has already emitted. Collapsing 389 of them into 1 removes work that was never on the critical path, and anyone expecting the consolidation to shorten warm login should read that measurement first. +- **The write path reversed it.** Per-wallet files imply per-wallet savers, so a 194-wallet account carries 194 independent 5 s throttles, and the post-login sync window (every engine reporting balances at once) has all of them writing concurrently. One file means one throttle for the whole account: at most one write per window, however many wallets changed. +- **The three costs this decision cited against a blob are real, and were addressed rather than argued away.** Write amplification: each write is now the whole account (105.5 KiB measured on the 194-wallet account) instead of ~276 B per changed wallet, traded for roughly 194x fewer bridge calls per window, and device timings later showed the byte cost is not what a write actually pays for ([section 6.4](#64-phase-7-revert-the-provisional-surface-consolidate-the-cache-file-landed)). Corruption blast radius: generations alternate between two slots and the reader takes the newest that still parses ([decision 7.10](#710-two-slot-alternation-because-the-disklet-has-no-rename)), so an interrupted write costs one generation of staleness rather than every wallet's warm boot. Dirty-set breadth, the deepest objection: the saver's change stamp is derived mechanically from the same wallet fields the writer serializes, so the enumeration cannot silently drift the way #703's hand-maintained global dirty set did. +- **Retained from the original: staleness containment is still structural.** The file only carries wallets the account still has, and entries for deleted wallets are pruned on each write, so a consolidated file cannot resurrect a wallet any more than a leftover per-wallet file could. +- **Rejected, the hybrid fold this decision designated** (keep per-wallet write targets, periodically folding them into a read-optimized blob). It optimizes the read path, which measured at zero, and leaves the per-wallet write storm untouched. +- **Reopen if** a full-account write becomes the dominant cost rather than the write count: a much larger account, or a platform where write time tracks bytes rather than JS-thread contention. The hybrid fold is the answer that fits that shape. + +### 7.7 Per-concern load gates, not one loadAllFiles promise + +With authoritative loads deferred behind cache seeding ([section 6.1](#61-phase-3-account-startup-cache)), writes to core-owned state must not race their own loads. Two shapes were considered: save the `loadAllFiles` promise and block every write until it resolves (proposed in the 2026-07-21 design discussion), or gate each write on exactly the load it depends on. + +- **Chosen: per-concern gates.** Engine startup is the one reader that gates too: it waits for the deferred account load (custom tokens, plugin settings, builtin tokens) before `makeCurrencyEngine`, because an engine built with empty `userSettings` opens connections before a `networkPrivacy` setting can apply. Each write awaits only its dependency. Wallet-file writes await the wallet repo, token file writes await `tokenFileLoaded`, settings writes await `pluginSettingsLoaded`, wallet-state writes await the account repo. A rename never waits on custom tokens. +- **Failure behavior.** Every waiter rejects terminally when the deferred boot loads fail repeatedly (`ACCOUNT_LOAD_FAILED` after 3 retries), so a broken disk produces errors rather than hangs. +- **Rejected, one global gate.** Correct but strictly coarser, and it re-creates the old "wait for everything" model on the write path. It also needs the same terminal-failure plumbing, so it does not even buy simplicity there. +- **Evidence after the fact.** The [write-path audit](https://gist.github.com/j0ntz/94c3e64779a92e8a1ae1896f4d7d3d6f) found that where gaps existed they were individual missing gates or wrong-granularity merges, never the per-concern structure itself; the fixes landed with the write-path followup ([section 6.2](#62-followup-write-path-staleness-fixes-landed)). +- **Reopen if** a write appears that genuinely depends on two or more loads at once, since one gate per concern stops expressing its precondition. + +### 7.8 otherMethods names are cached and served as delegating stubs + +Supersedes the original [section 4.1.6](#416-yaob-and-reference-stability) choice, a bridgified `{}` swapped for the engine's methods when it lands. + +- **Chosen with the phase-5 followup: cache the method names** per wallet (and per plugin at the account level) and expose one delegating stub per name ([`e1f7c428`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/e1f7c428e9d5dfc6bf4cb3de87ba0655a3a4a8ab), hardened in [`bb3a1690`](https://github.com/EdgeApp/edge-core-js/pull/733/commits/bb3a16903a63493cff631009f100c0c5501ca3d0)). The [section 4.1.4](#414-makecurrencywalletapi-changes) waiters made stubs nearly free: a stub is `await getEngine()` plus a property lookup, the same shape every engine-backed method already has. A warm login therefore exposes a callable method surface before the engine exists, and the FioService/FioActions crash class (calling a method on the pre-engine `{}`) retires on warm boots. +- **Names-only is deliberate.** Caching method names leaks nothing (they are plugin code, not user data), needs no schema per method, and a stale name degrades to a clean rejection at call time. +- **Config level.** The live plugin object stays exposed verbatim (identical `this` and non-function properties); cached names only feed fallback stubs if plugin loading ever defers past the account emit. +- **Rejected, keep the swap model and patch each GUI caller.** The phase-1 GUI audit already missed a caller once ([section 4.2.3](#423-othermethods-one-patch-required)). Making the surface callable-by-name removes the whole class instead of chasing call sites. +- **Rejected, one truly permanent mutable object.** [yaob](#yaob) facades cannot gain properties after first crossing (verified empirically during review), so growth requires an object swap. The shipped model swaps only when the name set grows, which keeps identity in the common warm case. +- **Reopen if** yaob gains property-addition support, which retires the growth swap, or a plugin ships `otherMethods` whose names change per session, which would make the cache churn. No plugin does. + +### 7.9 The provisional receive affordance, reverted + +Phase 6 shipped a visible affordance on the receive scene: on a rotating chain the cached address rendered immediately with an inline "checking for your latest address" row and a spinner, reconciling once the engine confirmed. The reasoning was that informed reuse beats a spinner, and that the tradeoff should be visible rather than silent. + +- **Reverted in phase 7**, along with the `allowCached` opt-in that fed it and the plugin flags in [edge-currency-accountbased#1076](https://github.com/EdgeApp/edge-currency-accountbased/pull/1076) (closed). The product call went the other way: serving the cached address silently was accepted as a tolerable edge case, which removes the thing the affordance existed to disclose. +- **What survives is the requirement the affordance was one way of meeting**, that a consumer must end up on the engine's address when it differs. That now lives in the core for every consumer rather than in one scene ([decision 7.4](#74-getaddresses-serves-the-cache-pre-engine-and-reconciles)). The receive scene already subscribes to `addressChanged` and needed no change. +- **Worth recording, because the revert makes it easy to lose.** Without the reconcile the revert would have been a silent bug. Nothing emitted `addressChanged` when an engine finished loading and disagreed with the cache, and the cached serve returned before the engine was ever asked, so a consumer would have latched the cached address indefinitely. The check that found this is why phase 7 added core-side reconciliation rather than only deleting code. +- **Reopen if** the silent serve proves to cost users real address reuse, which puts disclosure back on the table. The core-side reconcile stays either way; the affordance would return as a scene surface on top of it. + +### 7.10 Two-slot alternation, because the disklet has no rename + +A single boot-critical file needs a torn-write defense. The design called for the usual write-temp-then-rename. The [disklet](#disklet) cannot do it: its interface is `delete`, `getData`, `getText`, `list`, `setData`, `setText`, and the native modules expose exactly those six (`ios/Disklet.m`, `android/.../DiskletModule.java`). There is no rename to call. + +- **Only Android needs the defense.** iOS `writeFile` uses `[data writeToFile:options:NSDataWritingAtomic]`, which already writes to a temporary file and renames, so a kill mid-write cannot tear the file there. Android's `writeFile` opens a plain `FileOutputStream` over the target, truncating it first, and a kill part-way through leaves a short file. +- **Chosen: alternate generations** between `accountCache.json` and `accountCache.2.json`, each stamped with a monotonic `sequence`, reading the newest slot that parses. A torn write only ever damages the slot being written, so the other slot is a complete previous generation. This needs no rename and costs one extra file on disk. +- **Rejected, add a rename to the disklet package.** Correct, and worth doing eventually, but it means publishing a dependency for a defense the two-slot scheme already provides. +- **Rejected, accept the tear and fall back to the per-wallet files.** That is the migration fallback, not a defense, and it decays as those files age, since they are no longer written. +- **Reopen if** the disklet package gains a rename, which lets the cache collapse back to one slot. + +## 8. Glossary + +### Bridgify + +Marking a JavaScript object so yaob will expose it across the bridge, capturing its property surface at that moment. It matters here because `otherMethods` is rebuilt as a new bridgified object whenever its known name set grows: yaob only serializes the properties an object had when it first crossed, so a name added later would otherwise be invisible to the GUI. See the [yaob README](https://github.com/EdgeApp/yaob#readme). + +### Cleaner + +A small validating function that takes unknown JSON and returns a typed value or throws. Every cache file read here goes through one, which is what lets a corrupt or older-schema file fall back to the cold path instead of seeding garbage. See [cleaners](https://cleaners.js.org/). + +### Disklet + +Edge's minimal file-system abstraction, with native implementations on iOS and Android. Its whole interface is `delete`, `getData`, `getText`, `list`, `setData`, and `setText`, and the absence of a rename is why the cache alternates between two slots rather than writing to a temporary file ([decision 7.10](#710-two-slot-alternation-because-the-disklet-has-no-rename)). See [disklet](https://github.com/EdgeApp/disklet). + +### FIO + +The Foundation for Interwallet Operability protocol, which gives a wallet a human-readable address and a request/approve flow on top of it. It appears here only as a consumer: FIO's refresh runs off `waitForAllWallets` and reaches for `otherMethods`, both of which this design changed the timing of ([section 4.2.3](#423-othermethods-one-patch-required)). See [FIO](https://fio.net/). + +### Legacy Airbitz wallet + +A wallet from the pre-Edge Airbitz app, whose wallet info is reconstructed from a legacy repo rather than the account's key list. Their infos contain private keys, so they are never cached, and an account holding one boots cold ([section 4.1.8](#418-boot-outcomes)). Defined in [`src/core/login/keys.ts`](https://github.com/EdgeApp/edge-core-js/blob/master/src/core/login/keys.ts). + +### Pixie + +A unit in [redux-pixies](https://github.com/swansontec/redux-pixies), the effect layer this codebase uses instead of thunks or sagas: a pixie watches Redux state and produces output the rest of the system consumes. The account and each wallet are pixies, and "the wallet emits" throughout this document means its pixie published a `walletApi` object. + +### PIN + +The short numeric code that unlocks an Edge account on a device that has already logged in once with the full password. Every login timing in this document is measured from the last digit of that code, because that is the moment the user expects the wallet list. Defined by the login stash written on first password login, in [`src/core/login/login-stash.ts`](https://github.com/EdgeApp/edge-core-js/blob/master/src/core/login/login-stash.ts). + +### QR code + +The two-dimensional barcode the receive scene renders for a wallet's deposit address. It is what makes the cached-address serve visible to a user: the code has to be drawn from some address the moment the scene opens, and before this design that meant waiting for the engine ([decision 7.4](#74-getaddresses-serves-the-cache-pre-engine-and-reconciles)). See [ISO/IEC 18004](https://www.iso.org/standard/83389.html). + +### Storage wallet + +The synced, encrypted repo behind an account or a wallet, holding the files the user's other devices also see (names, fiat codes, token settings, transaction metadata). It exists well before a currency engine does, which is why repo-writing methods wait on it rather than on the engine ([section 4.1.4](#414-makecurrencywalletapi-changes)). Defined in [`src/core/storage/storage-actions.ts`](https://github.com/EdgeApp/edge-core-js/blob/master/src/core/storage/storage-actions.ts). + +### syncRatio + +A per-wallet number from 0 to 1 reporting how far its engine has progressed through its initial blockchain sync. Cached wallets report 0 until their engine starts, which is why the GUI's spend-path checks treat anything below 1 as unsynced and why the cache deliberately does not store it. Reported through [`EdgeCurrencyWallet.syncRatio`](https://github.com/EdgeApp/edge-core-js/blob/master/src/types/types.ts). + +### tokenId + +The identifier for one asset inside a wallet, `null` for the chain's own currency and a contract-derived string for a token. The cache file spells the `null` id as `''` because JSON object keys must be strings, and the address map is keyed the same way. Defined as `EdgeTokenId` in [`src/types/types.ts`](https://github.com/EdgeApp/edge-core-js/blob/master/src/types/types.ts). + +### TTL + +Time to live, the age after which a cached value is discarded. The engine queue stamps a priority bump with one, because post-startup engine calls bump constantly and a permanent mark would leave every wallet flagged as asked-for by the next login ([section 6](#6-phase-history-and-709-disposition)). The queue's own implementation is [`src/core/currency/wallet/engine-scheduler.ts`](https://github.com/EdgeApp/edge-core-js/blob/jon/wallet-cache-v2/src/core/currency/wallet/engine-scheduler.ts). + +### UTXO + +Unspent transaction output, the accounting model Bitcoin-style chains use: a wallet's balance is the sum of the outputs it can spend rather than a stored number. It matters to [decision 7.3](#73-gettransactions-stays-engine-gated) because deriving anything spendable on such a chain needs the engine's own index, which is not cacheable state. See [Bitcoin developer documentation](https://developer.bitcoin.org/devguide/transactions.html). + +### yaob + +"Yet another object bridge", the RPC layer that carries objects between the React Native app and the core's WebView. It diffs by reference identity, so keeping the same `walletApi` object across the cache-to-engine transition is what stops this design from flooding the bridge ([section 4.1.6](#416-yaob-and-reference-stability)). See [yaob](https://github.com/EdgeApp/yaob). + +## 9. References + +- Physical-device performance findings (Galaxy S9, 3-way vs develop and #703): [findings doc](https://gist.github.com/j0ntz/63c36e14285a638bc8874cb74e11d58e). +- Prior implementation: [edge-core-js#703](https://github.com/EdgeApp/edge-core-js/pull/703), design doc at `docs/wallet-cache.md` on branch `paul/cacheWallets`. +- Tabled follow-up: [edge-core-js#709](https://github.com/EdgeApp/edge-core-js/pull/709). +- Architectural direction: William's Signal messages (screenshots on Asana subtask "Login perf - Check William's Willingness", 1214006460304787) and the William x Jon call of 2026-07-17. +- Asana: [Login Perf - Wallet Cache v2](https://app.asana.com/1/9976422036640/project/1213843652804305/task/1216673467164267), predecessor [core: wallet cache (PR)](https://app.asana.com/1/9976422036640/project/1200382638405084/task/1213598116503346), parent [Login - Get Halloumi Reviewed](https://app.asana.com/1/9976422036640/project/1213843652804305/task/1214006460304785). + + + +## 10. Post-implementation retrospective + +### Estimate vs. actuals + +| | Estimated on the call | Actual | +|---|---|---| +| Core source | a few hundred lines | +2,515 / -293 across 21 files | +| Core tests | not estimated | +2,298 / -10 across 4 files, 50 deterministic cases | +| GUI | two small patches | 4 patched files plus a 47-line helper, +137 / -29 | +| Phases | one | seven, the last one reversing a file-layout decision from the first | +| Repos | two | two, after a third was opened and closed ([decision 7.9](#79-the-provisional-receive-affordance-reverted)) | + +### Where this document was wrong or silent + +1. **The file layout.** [Decision 7.6](#76-one-account-cache-file-not-per-wallet-files) chose a file per wallet and named the trigger that would reopen it. The trigger never fired: the read window measured at zero on device, so the reason to consolidate turned out to be the write side, which the original decision treated as the cheaper half. The reversal is in [section 6.4](#64-phase-7-revert-the-provisional-surface-consolidate-the-cache-file-landed). +2. **"Mostly mechanical" method bodies.** [Section 4.1.4](#414-makecurrencywalletapi-changes) predicted the API changes would be a mechanical `await getEngine()` insertion. The method bodies were; the getter surface was not, and cost roughly 160 lines the estimate never covered (fallback disklets, a stable bridgified `otherMethods`, a Redux-backed `publicWalletInfo`). +3. **The GUI audit was incomplete.** [Section 4.2](#42-gui-integration-edge-react-gui) enumerated the `waitForCurrencyWallet` and `otherMethods` call sites and concluded two small patches. `FioService.ts`'s periodic expired-domain check was not in the list and surfaced as a live crash on the simulator ([section 4.2.3](#423-othermethods-one-patch-required)). +4. **Torn writes were assumed solvable by rename.** The design reached for write-temp-then-rename without checking that the [disklet](#disklet) exposes one. It does not, on either platform, and Android truncates in place ([decision 7.10](#710-two-slot-alternation-because-the-disklet-has-no-rename)). +5. **The dirty-wins guard was unstated.** [Section 4.1.2](#412-load-path) described the cache seeding Redux and the loads overwriting it, and said nothing about a cached value racing a live engine value. The reducers need that guard. Every write-path fix in [section 6.2](#62-followup-write-path-staleness-fixes-landed) is the same omission found in another field. +6. **Test harness credit was optimistic.** [Section 5](#5-testing) proposed adopting two determinism mechanisms from #703. The fake plugin also needed a creation-order hook, a public-key check gate, an engine-kill hook, and several latent fixes before the cases could be written at all. +7. **The write cost was invisible.** Nothing reported what a cache write cost until [section 6.5](#65-followup-write-cost-visibility-landed) added the log line, which is why the first three attempts at device measurement produced numbers only for the read side. + +### What held + +- One `EdgeCurrencyWallet` implementation, no mirror object and no delegation layer, which is the whole reason this design was chosen over [#703](https://github.com/EdgeApp/edge-core-js/pull/703). Three review rounds attacked stale balances, the `publicKey.json` seam, and the `otherMethods` contract, and each rejection cited this document rather than reopening the architecture. +- Cold-start equivalence. Every phase kept the no-cache path identical to master, and [test case 1](#5-testing) has guarded it from the first commit. +- [yaob](#yaob) reference stability by construction ([section 4.1.6](#416-yaob-and-reference-stability)). The [pixie](#pixie) output map and the `walletApi` object stayed the ones master already used, so no merge getter or diffing shim was ever needed. +- Staleness containment. The cache is only read for wallet ids in the account's encrypted key state, so no stale entry has ever been able to resurrect a wallet in the UI. + +### Verification highlights + +- Warm login on a physical Galaxy S9, 194-wallet account: balances seeded 341 ms after the last [PIN](#pin) digit (305-411 ms over five iterations), against develop's ~57 s serial gap plus ~23 s of per-wallet file reads ([section 5](#5-testing)). +- Write-side, same device and account, against a build at the last pre-consolidation commit: 363 per-wallet writes across a warm login's first 3 minutes became 22, and the worst 5-second span went from 100 writes to 1 ([section 6.4](#64-phase-7-revert-the-provisional-surface-consolidate-the-cache-file-landed)). +- Torn-slot recovery on device: the newest slot truncated by hand, the boot still emitting from the older generation with no crash, and the next write repairing the damaged slot. +- 50 deterministic cases across three suites, all driving ordering through a test-controlled gate rather than sleeping ([section 5](#5-testing)). From 4070317afaff2af39eda6c8cc5fe7e0bffa16389 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:14:29 -0700 Subject: [PATCH 02/10] Add the account cache file and its cleaners A warm login needs the account's boot state and every wallet's UI state on disk in a form that survives a schema bump and a kill part-way through a write. The file carries wallet states, custom tokens, and one entry per wallet holding its name, fiat code, enabled tokens, balances, addresses, public keys, and otherMethods names. Reads accept older versions by upgrading in place. The disklet exposes no rename on either platform and Android truncates the target, so generations alternate between two slots and the reader takes the newest that still parses. The per-wallet walletCache.json reader stays for devices on the old layout. --- src/core/account/account-cache-file.ts | 85 +++++++++ src/core/account/account-cleaners.ts | 165 +++++++++++++++++- .../wallet/currency-wallet-cleaners.ts | 68 ++++++++ src/core/currency/wallet/wallet-cache-file.ts | 39 +++++ 4 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 src/core/account/account-cache-file.ts create mode 100644 src/core/currency/wallet/wallet-cache-file.ts diff --git a/src/core/account/account-cache-file.ts b/src/core/account/account-cache-file.ts new file mode 100644 index 000000000..41dd62322 --- /dev/null +++ b/src/core/account/account-cache-file.ts @@ -0,0 +1,85 @@ +import { Disklet } from 'disklet' + +import { makeJsonFile } from '../../util/file-helpers' +import { + AccountCacheFile, + asAccountCacheFile, + asStoredAccountCacheFile +} from './account-cleaners' + +/** + * Cached account boot state, stored on the account's local disklet. + * See `asAccountCacheFile` for the schema. + * + * The cache lives in two alternating slots. The disklet exposes no + * rename (neither its JS interface nor its iOS and Android native + * modules), so the usual write-temp-then-rename trick is unavailable, + * and Android's backend truncates the target before writing. A kill + * mid-write can therefore leave one slot torn. Writing generations + * alternately means the OTHER slot always holds the last complete + * one, and `loadAccountCache` takes the newest slot that still + * parses. (iOS writes with `NSDataWritingAtomic` and never tears, so + * this only earns its keep on Android.) + */ +export const ACCOUNT_CACHE_FILES = ['accountCache.json', 'accountCache.2.json'] +export const accountCacheFile = { + load: makeJsonFile(asStoredAccountCacheFile).load, + save: makeJsonFile(asAccountCacheFile).save +} + +/** + * Tuning for the account boot-state cache saver. + * Tests override the throttle to run quickly. + */ +export const accountCacheSaverConfig = { + throttleMs: 5000, + /** A write slower than this logs at `warn` instead of `info`. */ + slowWriteMs: 5000 +} + +/** + * Reads both slots and returns the newest one that parses, plus the + * slot the next write should use. Returns `undefined` cache data when + * neither slot is readable (first login, schema bump, both torn), + * which sends the caller to its cold path. + */ +export async function loadAccountCache( + disklet: Disklet +): Promise<{ cache: AccountCacheFile | undefined; nextSlot: number }> { + const slots = await Promise.all( + ACCOUNT_CACHE_FILES.map( + async path => await accountCacheFile.load(disklet, path) + ) + ) + + let best: AccountCacheFile | undefined + let bestSlot = -1 + for (let slot = 0; slot < slots.length; ++slot) { + const cache = slots[slot] + if (cache == null) continue + if (best == null || cache.sequence > best.sequence) { + best = cache + bestSlot = slot + } + } + + // Write over the slot we did NOT just read, so a torn write can + // never damage the generation we are currently relying on: + return { + cache: best, + nextSlot: bestSlot === -1 ? 0 : (bestSlot + 1) % ACCOUNT_CACHE_FILES.length + } +} + +/** + * Writes the next generation into `slot` and returns the slot the + * write after this one should use. + */ +export async function saveAccountCache( + disklet: Disklet, + slot: number, + data: AccountCacheFile +): Promise { + await accountCacheFile.save(disklet, ACCOUNT_CACHE_FILES[slot], data) + return (slot + 1) % ACCOUNT_CACHE_FILES.length +} diff --git a/src/core/account/account-cleaners.ts b/src/core/account/account-cleaners.ts index 4785f9ff5..6a1375de8 100644 --- a/src/core/account/account-cleaners.ts +++ b/src/core/account/account-cleaners.ts @@ -1,15 +1,29 @@ import { asArray, asBoolean, + asDate, + asEither, + asNull, asNumber, asObject, asOptional, - asString + asString, + asValue, + Cleaner } from 'cleaners' import { asBase16 } from '../../types/server-cleaners' -import { EdgeDenomination, EdgeToken } from '../../types/types' +import { + EdgeDenomination, + EdgePluginMap, + EdgeStakingStatus, + EdgeToken, + EdgeTokenMap, + EdgeWalletState, + EdgeWalletStates +} from '../../types/types' import { asJsonObject } from '../../util/file-helpers' +import { asIntegerString } from '../currency/wallet/currency-wallet-cleaners' import { SwapSettings } from './account-types' // --------------------------------------------------------------------- @@ -92,3 +106,150 @@ export const asGuiSettingsFile = asObject({ export const asCustomTokensFile = asObject({ customTokens: asObject(asObject(asEdgeToken)) }) + +/** + * Cached account boot state, stored on the account's local disklet. + * This is what the deferred account file loads would produce, + * so wallet pixies can start before the account repo syncs. + * Values are last-known and explicitly allowed to be stale; + * the authoritative loads overwrite them within seconds. + * Never contains private key material: wallet keys stay in the + * encrypted login stash, which is already in memory at login. + * Plugin settings are deliberately excluded: unlike wallet states + * and token definitions, they can hold credentials (custom node + * auth, API keys), which must never leave the encrypted repo. + */ +export interface AccountCacheFile { + version: 2 + customTokens: EdgePluginMap + /** + * True when the account has legacy Airbitz-repo wallets. Their + * wallet infos cannot be cached (they contain private keys), so + * such accounts boot cold rather than briefly hiding wallets. + */ + legacyWallets: boolean + walletStates: EdgeWalletStates + /** + * Each plugin's `otherMethods` names, so `CurrencyConfig` can + * expose delegating stubs even if the plugin has not loaded yet. + */ + configOtherMethodNames: EdgePluginMap + + /** + * Every active wallet's cached boot state, keyed by wallet id. + * This absorbs what used to live in each wallet's own + * `publicKey.json` + `walletCache.json` pair, so a warm boot reads + * one file for the whole account instead of two per wallet. + */ + wallets: { [walletId: string]: AccountCacheWallet } + + /** + * Increases on every write. Two slots hold alternating generations, + * so the reader can take the newest one that still parses; see + * `loadAccountCache`. + */ + sequence: number +} + +/** + * One wallet's cached boot state inside the account cache file: + * the public keys that were in `publicKey.json` plus the UI state + * that was in `walletCache.json`. + */ +export interface AccountCacheWallet { + walletInfo: { id: string; keys: object; type: string } + name: string | null + fiatCurrencyCode: string + enabledTokenIds: string[] + + /** Integer strings. The `null` tokenId is spelled '' here. */ + balances: { [tokenId: string]: string } + + /** Per tokenId, without balances (`null` tokenId spelled ''). */ + addresses: { + [tokenIdKey: string]: Array<{ addressType: string; publicAddress: string }> + } + + otherMethodNames: string[] + + /** + * Last-known locked-stake rows. Cached for the same reason balances + * are: the GUI subtracts locked stake from the balance to show what + * is spendable, so serving a cached balance with an empty stake + * reports more available than the wallet has until the engine loads. + */ + stakingStatus?: EdgeStakingStatus +} + +const asEdgeWalletState = asObject({ + archived: asOptional(asBoolean), + deleted: asOptional(asBoolean), + hidden: asOptional(asBoolean), + migratedFromWalletId: asOptional(asString), + sortIndex: asOptional(asNumber) +}) + +const asCachedAddress = asObject({ + addressType: asString, + publicAddress: asString +}) + +const asCachedStakingStatus = asObject({ + stakedAmounts: asArray( + asObject({ + nativeAmount: asString, + unlockDate: asOptional(asDate), + otherParams: asOptional(asJsonObject) + }) + ) +}) + +const asAccountCacheWallet = asObject({ + walletInfo: asObject({ + id: asString, + keys: asJsonObject, + type: asString + }), + name: asEither(asString, asNull), + fiatCurrencyCode: asString, + enabledTokenIds: asArray(asString), + balances: asObject(asIntegerString), + addresses: asObject(asArray(asCachedAddress)), + otherMethodNames: asArray(asString), + stakingStatus: asOptional(asCachedStakingStatus) +}) + +export const asAccountCacheFile: Cleaner = asObject({ + version: asValue(2), + sequence: asNumber, + customTokens: asObject(asObject(asEdgeToken)), + legacyWallets: asOptional(asBoolean, false), + walletStates: asObject(asEdgeWalletState), + configOtherMethodNames: asOptional(asObject(asArray(asString)), () => ({})), + wallets: asObject(asAccountCacheWallet) +}) + +const asAccountCacheFileV1 = asObject({ + version: asValue(1), + customTokens: asObject(asObject(asEdgeToken)), + legacyWallets: asOptional(asBoolean, false), + walletStates: asObject(asEdgeWalletState), + configOtherMethodNames: asOptional(asObject(asArray(asString)), () => ({})) +}) + +/** + * Read-side cleaner: upgrades a version-1 file instead of falling + * through to a cold boot. A version-1 file predates the consolidated + * wallet table, so its wallets are still in their own per-wallet + * files, which `bulkLoadWalletCaches` reads once before this file is + * rewritten in the current schema. Writes always use + * `asAccountCacheFile`. + */ +export const asStoredAccountCacheFile: Cleaner = raw => { + try { + return asAccountCacheFile(raw) + } catch (error: unknown) { + const clean = asAccountCacheFileV1(raw) + return { ...clean, version: 2, sequence: 0, wallets: {} } + } +} diff --git a/src/core/currency/wallet/currency-wallet-cleaners.ts b/src/core/currency/wallet/currency-wallet-cleaners.ts index 4175d4e96..c1c1b0db9 100644 --- a/src/core/currency/wallet/currency-wallet-cleaners.ts +++ b/src/core/currency/wallet/currency-wallet-cleaners.ts @@ -402,6 +402,74 @@ export const asPublicKeyFile = asObject({ }) }) +/** + * Cached wallet UI state, stored in the wallet's local storage. + * This is everything the GUI needs to render a wallet in the wallet list + * before its engine exists. Balances are last-known values, + * and are explicitly allowed to be stale. + */ +export interface WalletCacheFile { + version: 2 + name: string | null + fiatCurrencyCode: string + enabledTokenIds: string[] + + /** Integer strings. The `null` tokenId is spelled '' here. */ + balances: { [tokenId: string]: string } + + /** + * The engine's last answer per tokenId to the default address + * query, without balances (the `null` tokenId is spelled '' here). + * Served pre-engine, before the engine confirms it. + */ + addresses: { + [tokenIdKey: string]: Array<{ addressType: string; publicAddress: string }> + } + + /** + * The names of the engine's `otherMethods`, so the next login can + * expose a delegating stub per method before the engine exists. + */ + otherMethodNames: string[] +} + +const asCachedAddress = asObject({ + addressType: asString, + publicAddress: asString +}) + +export const asWalletCacheFile: Cleaner = asObject({ + version: asValue(2), + name: asEither(asString, asNull), + fiatCurrencyCode: asString, + enabledTokenIds: asArray(asString), + balances: asObject(asIntegerString), + addresses: asObject(asArray(asCachedAddress)), + otherMethodNames: asOptional(asArray(asString), () => []) +}) + +const asWalletCacheFileV1 = asObject({ + version: asValue(1), + name: asEither(asString, asNull), + fiatCurrencyCode: asString, + enabledTokenIds: asArray(asString), + balances: asObject(asIntegerString) +}) + +/** + * Read-side cleaner: upgrades an old file instead of falling through + * to a cold boot; the fields it lacks simply have nothing cached yet. + * Writes always use the current `asWalletCacheFile` schema. + */ +export const asStoredWalletCacheFile: Cleaner = raw => { + try { + return asWalletCacheFile(raw) + } catch (error: unknown) { + const clean = asWalletCacheFileV1(raw) + return { ...clean, version: 2, addresses: {}, otherMethodNames: [] } + } +} + /** * The wallet's local storage file for the last seen "checkpoint". The core * does not know the contents of the checkpoint, so it just as an arbitrary diff --git a/src/core/currency/wallet/wallet-cache-file.ts b/src/core/currency/wallet/wallet-cache-file.ts new file mode 100644 index 000000000..a2881471e --- /dev/null +++ b/src/core/currency/wallet/wallet-cache-file.ts @@ -0,0 +1,39 @@ +import { + EdgeAddress, + EdgeBalanceMap, + EdgeStakingStatus, + 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 + stakingStatus?: EdgeStakingStatus +} From 325e8c693234f2f3c8663ed5a34b445232056cdd Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:14:32 -0700 Subject: [PATCH 03/10] Add a limited-concurrency queue for engine startup Once the wallet list renders from cache, every wallet's engine work (repo sync, key derivation, makeCurrencyEngine) still races in the seconds after login, which is where the post-login jank lives. The scheduler admits 8 wallets at a time and lets a caller move one to the front, so a wallet the user actually opened does not wait behind the whole queue. A wedged wallet cannot starve the pool: a watchdog force-releases its slot and temporarily over-admits, which degrades to the unbounded behavior this replaces. Priority bumps carry a TTL, since post-startup engine calls bump constantly and would otherwise mark every wallet as asked-for by the next login. --- src/core/currency/wallet/engine-scheduler.ts | 177 +++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 src/core/currency/wallet/engine-scheduler.ts diff --git a/src/core/currency/wallet/engine-scheduler.ts b/src/core/currency/wallet/engine-scheduler.ts new file mode 100644 index 000000000..d27c6042a --- /dev/null +++ b/src/core/currency/wallet/engine-scheduler.ts @@ -0,0 +1,177 @@ +/** + * 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) { + // Search from the back. A wallet destroyed while queued leaves + // its entry behind (nothing rejects the promise it is waiting + // on), so if that id is recreated its live entry is the newer + // one and promoting the dead one would leave the live pixie at + // the back of the line: + let index = -1 + for (let i = queue.length - 1; i >= 0; --i) { + if (queue[i].walletId === walletId) { + index = i + break + } + } + if (index < 0) { + // Not queued (yet): remember the request, so a wallet whose + // startup work is still reading its cache files gets its + // priority when it does join the queue: + stickyBumps.set(walletId, Date.now()) + return false + } + if (index === 0) return false + const [entry] = queue.splice(index, 1) + queue.unshift(entry) + return true + } + } +} + +/** + * One scheduler per core context. The `io` object is created once per + * context and threads through every pixie's props, so it works as the + * context identity without new plumbing: + */ +const schedulers = new WeakMap() + +export function getEngineScheduler(io: object): EngineScheduler { + let scheduler = schedulers.get(io) + if (scheduler == null) { + scheduler = makeEngineScheduler() + schedulers.set(io, scheduler) + } + return scheduler +} From 616d6bd5a00e44bafc20ba64a97ae9eedb33daea Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:14:47 -0700 Subject: [PATCH 04/10] Seed Redux from cache, defer the real loads The seeded state is a guess that the file loads overwrite moments later, and a user change made inside that window would otherwise be reverted by a load that read the file before it. Two seeding actions, one for the account and one carrying every wallet, populate the same reducers the file loads populate, so there is no shadow copy of wallet state. The loads then run deferred, marked fromFile so the reducers can tell an authoritative value from a user change, and each racing field merges rather than replacing: custom tokens per token id, enabled tokens per toggled id, plugin settings per plugin id, wallet states per wallet id. A terminal load failure rejects the waiters instead of leaving them pending forever, and balanceMap keeps its identity when an engine re-reports an unchanged balance, so yaob sees no phantom update. --- src/core/account/account-files.ts | 203 +++++++++++--- src/core/account/account-reducer.ts | 224 ++++++++++++++- src/core/actions.ts | 90 +++++- src/core/currency/currency-selectors.ts | 60 +++- .../currency/wallet/currency-wallet-files.ts | 25 +- .../wallet/currency-wallet-reducer.ts | 258 ++++++++++++++++-- 6 files changed, 768 insertions(+), 92 deletions(-) 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-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/actions.ts b/src/core/actions.ts index 38f36c23f..84d501340 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,54 @@ 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 + stakingStatus?: EdgeStakingStatus + 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 +357,16 @@ 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 + /** + * Set when the value was read from disk: the wallet's fiat + * write generation when that read began. Absent for a user + * change, which starts a new generation. + */ + loadGen?: number walletId: string } } @@ -325,7 +402,8 @@ export type RootAction = type: 'CURRENCY_WALLET_LOADED_TOKEN_FILE' payload: { detectedTokenIds: string[] - enabledTokenIds: string[] + // Undefined when neither token file could be read: + enabledTokenIds: string[] | undefined walletId: string } } @@ -342,6 +420,12 @@ export type RootAction = type: 'CURRENCY_WALLET_NAME_CHANGED' payload: { name: string | null + /** + * Set when the value was read from disk: the wallet's name + * write generation when that read began. Absent for a user + * change, which starts a new generation. + */ + loadGen?: number walletId: string } } @@ -370,6 +454,8 @@ export type RootAction = | { type: 'CURRENCY_WALLET_LOADED_WALLET_SETTINGS_FILE' payload: { + /** The wallet's settings write generation when the read began. */ + loadGen: number walletId: string walletSettings: JsonObject } 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-files.ts b/src/core/currency/wallet/currency-wallet-files.ts index 3c548787f..a8f7541d9 100644 --- a/src/core/currency/wallet/currency-wallet-files.ts +++ b/src/core/currency/wallet/currency-wallet-files.ts @@ -177,9 +177,12 @@ export async function setCurrencyWalletFiat( * Loads the wallet fiat currency file. */ export async function loadFiatFile(input: CurrencyWalletInput): Promise { - const { dispatch, state, walletId } = input.props + const { dispatch, state, walletId, walletState } = input.props const disklet = getStorageWalletDisklet(state, walletId) + // Taken before the read, so the reducer can tell whether a user + // change wrote the file after this load had already read it: + const loadGen = walletState.fiatGen const clean = await walletFiatFile.load(disklet, CURRENCY_FILE) let fiatCurrencyCode = 'iso:USD' if (clean != null) { @@ -193,7 +196,7 @@ export async function loadFiatFile(input: CurrencyWalletInput): Promise { dispatch({ type: 'CURRENCY_WALLET_FIAT_CHANGED', - payload: { fiatCurrencyCode, walletId } + payload: { fiatCurrencyCode, loadGen, walletId } }) } @@ -201,9 +204,12 @@ export async function loadFiatFile(input: CurrencyWalletInput): Promise { * Loads the wallet name file. */ export async function loadNameFile(input: CurrencyWalletInput): Promise { - const { dispatch, state, walletId } = input.props + const { dispatch, state, walletId, walletState } = input.props const disklet = getStorageWalletDisklet(state, walletId) + // Taken before the read, so the reducer can tell whether a rename + // wrote the file after this load had already read it: + const loadGen = walletState.nameGen const clean = await walletNameFile.load(disklet, WALLET_NAME_FILE) let name: string | null = null if (clean == null || clean.walletName == null) { @@ -223,6 +229,7 @@ export async function loadNameFile(input: CurrencyWalletInput): Promise { type: 'CURRENCY_WALLET_NAME_CHANGED', payload: { name: typeof name === 'string' ? name : null, + loadGen, walletId } }) @@ -275,7 +282,9 @@ export async function loadTokensFile( return } - // Both the new and old files are missing: + // Both the new and old files are missing or unreadable, so the disk + // has no list to offer. Leave the enabled list as it is, since it may + // hold the cache-seeded tokens: const shortId = walletId.slice(0, 2) input.props.log.warn(`enabledTokenIds: ${shortId} loaded neither file`) dispatch({ @@ -283,7 +292,7 @@ export async function loadTokensFile( payload: { walletId: input.props.walletId, detectedTokenIds: [], - enabledTokenIds: [] + enabledTokenIds: undefined } }) } @@ -294,13 +303,17 @@ export async function loadTokensFile( export async function loadWalletSettingsFile( input: CurrencyWalletInput ): Promise { - const { dispatch, state, walletId } = input.props + const { dispatch, state, walletId, walletState } = input.props const disklet = getStorageWalletDisklet(state, walletId) + // Taken before the read, so the reducer can tell whether a user + // change wrote the file after this load had already read it: + const loadGen = walletState.walletSettingsGen const clean = await walletSettingsFile.load(disklet, WALLET_SETTINGS_FILE) dispatch({ type: 'CURRENCY_WALLET_LOADED_WALLET_SETTINGS_FILE', payload: { + loadGen, walletId, walletSettings: clean?.walletSettings ?? {} } diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index 1cf017ae8..046ad5a26 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,25 @@ export interface CurrencyWalletState { readonly currencyInfo: EdgeCurrencyInfo readonly detectedTokenIds: string[] readonly enabledTokenIds: string[] + readonly enabledTokensDirtyIds: string[] readonly tokenFileDirty: boolean readonly tokenFileLoaded: boolean + readonly tokenFileEverLoaded: boolean readonly walletSettings: JsonObject + readonly walletSettingsGen: number readonly engineFailure: Error | null readonly engineStarted: boolean readonly fiat: string + readonly fiatGen: number readonly fiatLoaded: boolean readonly fileNames: TxFileNames readonly files: TxFileJsons readonly gotTxs: Set readonly height: number readonly name: string | null + readonly nameGen: number readonly nameLoaded: boolean + readonly otherMethodNames: string[] readonly publicWalletInfo: EdgeWalletInfo | null readonly seenTxCheckpoint: string | null readonly sortedTxidHashes: string[] @@ -122,6 +130,8 @@ export interface CurrencyWalletNext { export const initialWalletSettings: JsonObject = {} +export const initialAddresses: { [tokenIdKey: string]: EdgeAddress[] } = {} + // Used for detectedTokenIds & enabledTokenIds: export const initialTokenIds: string[] = [] @@ -192,11 +202,26 @@ 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 + // No readable file, so keep the cache-seeded list and any + // unsaved toggles already applied to it: + const { enabledTokenIds } = action.payload + if (enabledTokenIds == null) return state + + // 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 enabledTokenIds + const enabled = dirtyIds.filter(id => state.includes(id)) + const disabled = dirtyIds.filter(id => !state.includes(id)) + return uniqueStrings([...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 +246,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 +306,28 @@ const currencyWalletInner = buildReducer< } }, - walletSettings(state = initialWalletSettings, action): JsonObject { + tokenFileEverLoaded(state = false, action): boolean { + // Unlike `tokenFileLoaded`, a resync does not clear this: the + // enabled-token list survives the engine clear, so the account + // cache saver can keep writing the wallet afterwards: + return action.type === 'CURRENCY_WALLET_LOADED_TOKEN_FILE' ? true : state + }, + + walletSettings( + state = initialWalletSettings, + action, + next, + prev + ): JsonObject { switch (action.type) { case 'CURRENCY_WALLET_LOADED_WALLET_SETTINGS_FILE': + // A load that began before a user change read the disk too + // early, so the change wins (it wrote the file before + // dispatching). A load that began after it saw the write: + if (action.payload.loadGen !== (prev.self?.walletSettingsGen ?? 0)) { + return state + } + return action.payload.walletSettings case 'CURRENCY_WALLET_CHANGED_WALLET_SETTINGS': return action.payload.walletSettings default: @@ -264,6 +335,14 @@ const currencyWalletInner = buildReducer< } }, + walletSettingsGen(state = 0, action): number { + // Each user change starts a new generation, which is what tells + // a file load whether it began before or after the change: + return action.type === 'CURRENCY_WALLET_CHANGED_WALLET_SETTINGS' + ? state + 1 + : state + }, + engineFailure(state = null, action): Error | null { if (action.type === 'CURRENCY_ENGINE_FAILED') { const { error } = action.payload @@ -281,14 +360,39 @@ const currencyWalletInner = buildReducer< : state }, - fiat(state = '', action): string { - return action.type === 'CURRENCY_WALLET_FIAT_CHANGED' - ? action.payload.fiatCurrencyCode + fiat(state = '', action, next, prev): string { + switch (action.type) { + case 'CURRENCY_WALLET_CACHE_LOADED': + return action.payload.fiatCurrencyCode + + case 'CURRENCY_WALLET_FIAT_CHANGED': { + // A load that began before a user change read the disk too + // early, so the change wins (it wrote the file before + // dispatching). A load that began after it saw the write: + const { loadGen } = action.payload + if (loadGen != null && loadGen !== (prev.self?.fiatGen ?? 0)) { + return state + } + return action.payload.fiatCurrencyCode + } + } + return state + }, + + fiatGen(state = 0, action): number { + // Each user change starts a new generation, which is what tells + // a file load whether it began before or after the change: + return action.type === 'CURRENCY_WALLET_FIAT_CHANGED' && + action.payload.loadGen == null + ? state + 1 : 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 +449,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 +518,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 +539,39 @@ const currencyWalletInner = buildReducer< : state }, - name(state = null, action): string | null { - return action.type === 'CURRENCY_WALLET_NAME_CHANGED' - ? action.payload.name + 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 load that began before a user rename read the disk too + // early, so the rename wins (it wrote the file before + // dispatching). A load that began after it saw the write: + const { loadGen } = action.payload + if (loadGen != null && loadGen !== (prev.self?.nameGen ?? 0)) { + return state + } + return action.payload.name + } + } + return state + }, + + nameGen(state = 0, action): number { + // Each user rename starts a new generation, which is what tells + // a file load whether it began before or after the rename: + return action.type === 'CURRENCY_WALLET_NAME_CHANGED' && + action.payload.loadGen == null + ? state + 1 : 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) { @@ -405,9 +591,17 @@ const currencyWalletInner = buildReducer< ), stakingStatus(state = { stakedAmounts: [] }, action): EdgeStakingStatus { - return action.type === 'CURRENCY_ENGINE_CHANGED_STAKING' - ? action.payload.stakingStatus - : state + switch (action.type) { + case 'CURRENCY_ENGINE_CHANGED_STAKING': + return action.payload.stakingStatus + + case 'CURRENCY_WALLET_CACHE_LOADED': + // Last-known locked rows. Without them a cache-seeded wallet + // reports its whole cached balance as spendable until the + // engine reports the real stake: + return action.payload.stakingStatus ?? state + } + return state }, txidHashes(state = {}, action) { @@ -483,9 +677,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 +710,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 +775,14 @@ export function mergeTx( type StringsReducer = ( state: string[] | undefined, - action: RootAction + action: RootAction, + next?: CurrencyWalletNext, + prev?: CurrencyWalletNext ) => string[] function sortStringsReducer(reducer: StringsReducer): StringsReducer { - return (state, action) => { - const out = reducer(state, action) + return (state, action, next, prev) => { + const out = reducer(state, action, next, prev) if (out === state) return state out.sort((a, b) => (a === b ? 0 : a > b ? 1 : -1)) From 7f73f580da64a43f3d0bcbedf59052b76a4df062 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:14:51 -0700 Subject: [PATCH 05/10] Read every wallet's cached state in one bulk load Reading each wallet's cache from its own pixie costs two dispatches per wallet, so a 194-wallet account paid ~400 store transits before the list could render, and every pixie and watcher re-evaluated against each one. The loader reads the consolidated file once and, for a device still on the per-wallet layout, falls back to reading those files concurrently. Either way it produces one seed per wallet for a single batched dispatch. This is batching, not notification suppression: every consumer is still notified once, with the final state. --- .../currency/wallet/wallet-cache-loader.ts | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 src/core/currency/wallet/wallet-cache-loader.ts 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..99395314f --- /dev/null +++ b/src/core/currency/wallet/wallet-cache-loader.ts @@ -0,0 +1,224 @@ +import { EdgeBalanceMap } from '../../../types/types' +import { makeJsonFile } from '../../../util/file-helpers' +import { loadAccountCache } from '../../account/account-cache-file' +import { + AccountCacheFile, + AccountCacheWallet +} from '../../account/account-cleaners' +import { ApiInput } from '../../root-pixie' +import { makeLocalDisklet } from '../../storage/repo' +import { asPublicKeyFile, WalletCacheFile } from './currency-wallet-cleaners' +import { + WALLET_CACHE_FILE, + walletCacheFile, + WalletCacheSeed +} from './wallet-cache-file' + +export const PUBLIC_KEY_CACHE = 'publicKey.json' +export const publicKeyFile = makeJsonFile(asPublicKeyFile) + +/** + * Test hooks for observing cache seeding, following the same + * mutable-config pattern as `accountCacheSaverConfig`. + */ +export const walletCacheLoaderHooks: { + /** Receives each account id seeded by `ACCOUNT_CACHE_LOADED`. */ + onAccountSeed?: (accountId: string) => void + /** Receives the seeded wallet ids of each bulk dispatch. */ + onBulkSeed?: (walletIds: string[]) => void + /** Receives each wallet id seeded by a pixie's fallback read. */ + onFallbackSeed?: (walletId: string) => void +} = {} + +/** + * The consolidated cache file each account booted from, kept for the + * session. The account pixie reads that file once at boot, so a wallet + * the bulk seed missed can be looked up here instead of re-reading both + * slots (~2 x 105 KiB on a large account) per wallet. A wallet absent + * from the booted file is absent from disk too: the saver only adds + * entries for wallets that are already running, which never take the + * fallback path. A miss is remembered as well (`undefined`), so a cold + * login goes straight to the per-wallet files instead of re-reading + * both missing slots per wallet. + */ +const bootAccountCaches = new Map() + +export function rememberAccountCache( + accountId: string, + cache: AccountCacheFile | undefined +): void { + bootAccountCaches.set(accountId, cache) +} + +export function forgetAccountCache(accountId: string): void { + bootAccountCaches.delete(accountId) +} + +/** + * Upgrades a validated `walletCache.json` balance table + * to the `EdgeBalanceMap` shape the Redux slice uses. + */ +export function makeCachedBalanceMap( + balances: WalletCacheFile['balances'] +): EdgeBalanceMap { + const balanceMap: EdgeBalanceMap = new Map() + for (const tokenId of Object.keys(balances)) { + balanceMap.set(tokenId === '' ? null : tokenId, balances[tokenId]) + } + return balanceMap +} + +/** + * Converts one wallet's entry in the account cache file into a seed. + */ +export function toWalletCacheSeed(cached: AccountCacheWallet): WalletCacheSeed { + return { + addresses: cached.addresses, + balanceMap: makeCachedBalanceMap(cached.balances), + enabledTokenIds: cached.enabledTokenIds, + fiatCurrencyCode: cached.fiatCurrencyCode, + name: cached.name, + otherMethodNames: cached.otherMethodNames, + publicWalletInfo: cached.walletInfo, + stakingStatus: cached.stakingStatus + } +} + +/** + * Reads one wallet's seed for a pixie that the bulk seed missed: + * a cold login, or a wallet reactivated mid-session. Prefers the + * consolidated account file, which holds every wallet the account + * still has (archived ones included), and falls back to the + * per-wallet pair a pre-consolidation device still has on disk. + */ +export async function loadWalletCacheSeed( + ai: ApiInput, + walletId: string, + accountId: string +): Promise { + if (bootAccountCaches.has(accountId)) { + const cached = bootAccountCaches.get(accountId)?.wallets[walletId] + if (cached != null) return toWalletCacheSeed(cached) + return await loadWalletFilesSeed(ai, walletId) + } + + const accountState = ai.props.state.accounts[accountId] + if (accountState != null) { + const { cache } = await loadAccountCache( + makeLocalDisklet(ai.props.io, accountState.accountWalletInfo.id) + ) + const cached = cache?.wallets[walletId] + if (cached != null) return toWalletCacheSeed(cached) + } + return await loadWalletFilesSeed(ai, walletId) +} + +/** + * Reads one wallet's own cache files from its local disklet. + * Returns undefined when either file is missing or invalid + * (first login, schema bump, corruption). Only a device that has not + * yet written the consolidated account file still has these. + */ +export async function loadWalletFilesSeed( + ai: ApiInput, + walletId: string +): Promise { + const cacheDisklet = makeLocalDisklet(ai.props.io, walletId) + const [publicKeyCache, walletCache] = await Promise.all([ + publicKeyFile.load(cacheDisklet, PUBLIC_KEY_CACHE), + walletCacheFile.load(cacheDisklet, WALLET_CACHE_FILE) + ]) + if (publicKeyCache == null || walletCache == null) return + + return { + addresses: walletCache.addresses, + otherMethodNames: walletCache.otherMethodNames, + balanceMap: makeCachedBalanceMap(walletCache.balances), + enabledTokenIds: walletCache.enabledTokenIds, + fiatCurrencyCode: walletCache.fiatCurrencyCode, + name: walletCache.name, + publicWalletInfo: publicKeyCache.walletInfo + } +} + +/** + * Seeds every wallet the account cache file already carried, with no + * disk reads at all: the consolidated file was read in one go by the + * account pixie. This is the warm path once a device has written the + * current schema; `bulkLoadWalletCaches` below is the migration path + * for a device whose wallets are still in their own files. + */ +export function seedWalletCachesFromAccount( + ai: ApiInput, + accountId: string, + wallets: { [walletId: string]: AccountCacheWallet } +): void { + // The file also carries wallets that are merely archived, so they + // keep their cached state; only the active ones have a pixie to + // seed. An archived wallet that is turned back on seeds itself + // through `loadWalletCacheSeed`, which reads this same file: + const activeWalletIds = + ai.props.state.accounts[accountId]?.activeWalletIds ?? [] + const seeds: { [walletId: string]: WalletCacheSeed } = {} + for (const walletId of activeWalletIds) { + const cached = wallets[walletId] + if (cached != null) seeds[walletId] = toWalletCacheSeed(cached) + } + + ai.props.dispatch({ + type: 'CURRENCY_WALLETS_CACHE_LOADED', + payload: { accountId, seeds } + }) + + if (walletCacheLoaderHooks.onBulkSeed != null) { + walletCacheLoaderHooks.onBulkSeed(Object.keys(seeds)) + } +} + +/** + * Reads every active wallet's cache files concurrently and seeds + * them all in a single `CURRENCY_WALLETS_CACHE_LOADED` dispatch, + * so a warm login costs one store tick for the whole wallet list + * instead of two dispatches per wallet. Wallets without valid cache + * files are simply absent from the payload; their pixies fall back + * to their own reads. Always dispatches, even with zero seeds, since + * the wallet pixies are holding for `bulkWalletSeedPending` to clear. + */ +export async function bulkLoadWalletCaches( + ai: ApiInput, + accountId: string +): Promise { + const seeds: { [walletId: string]: WalletCacheSeed } = {} + try { + const accountState = ai.props.state.accounts[accountId] + if (accountState == null) return + const { activeWalletIds } = accountState + + await Promise.all( + activeWalletIds.map(async walletId => { + const seed = await loadWalletFilesSeed(ai, walletId).catch(() => { + // A broken read just means this wallet boots cold: + return undefined + }) + if (seed != null) seeds[walletId] = seed + }) + ) + } catch (error: unknown) { + // Never skip the dispatch below: wallet pixies are holding for + // `bulkWalletSeedPending` to clear, and an empty seed table just + // sends them to their own fallback reads: + ai.props.log.warn(`Bulk wallet-cache load failed: ${String(error)}`) + } + + // The account may have logged out while we read the disk: + if (ai.props.state.accounts[accountId] == null) return + + ai.props.dispatch({ + type: 'CURRENCY_WALLETS_CACHE_LOADED', + payload: { accountId, seeds } + }) + + if (walletCacheLoaderHooks.onBulkSeed != null) { + walletCacheLoaderHooks.onBulkSeed(Object.keys(seeds)) + } +} From 66d1b436d111f04b630283558c00fa4305aa80da Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:15:14 -0700 Subject: [PATCH 06/10] Serve the account API before its repo exists The account now emits before its repo is created, so every surface that assumed a live repo could throw during the window the cache exists to make usable. Disklets resolve lazily or fall back to disklets built straight from the keys, which are the same files under the same encryption. Repo- backed calls pend rather than throw: changeWalletStates waits for the repo, sync waits for the storage wallet, plugin-settings writes wait for their load, and changeEnabledTokenIds waits for the plugin's builtin definitions. The account-level engine methods wait through the shared selector and read the wallet list after the wait, so wallets that arrived during it are included. Repo syncs serialize per storage wallet, so two concurrent syncs cannot interleave. --- src/core/account/account-api.ts | 51 +++++++++++++++++++---------- src/core/account/data-store-api.ts | 21 ++++++++++-- src/core/account/plugin-api.ts | 31 ++++++++++++++++-- src/core/storage/storage-actions.ts | 43 ++++++++++++++++++++++-- src/core/storage/storage-api.ts | 45 +++++++++++++++++++++++-- 5 files changed, 164 insertions(+), 27 deletions(-) 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/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/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) } } From b9b4e705373e8759e03c549665f31d4f5f31112b Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:15:07 -0700 Subject: [PATCH 07/10] Emit currency wallets before their engines exist The wallet API refused to emit until its engine existed, so the GUI could not render a name, a fiat code, or a balance until every wallet's engine had loaded, which is the whole of the login delay. The gate now opens on the cached state, and the engine becomes a dependency the API awaits internally: engine-backed methods wait and reject if the engine fails or the wallet is deleted, while repo- writing methods gate on the storage wallet instead, which lands much earlier. otherMethods is a permanent object of delegating stubs built from the cached names, so its identity survives the engine landing. A rotating chain serves its cached receive address immediately and re-asks the engine in the background, emitting addressChanged when the two differ. Without a cache the gate opens on the same conditions as before, so first login is unchanged. --- src/core/account/custom-tokens.ts | 56 ++- .../currency/wallet/currency-wallet-api.ts | 314 +++++++++++- .../currency/wallet/currency-wallet-pixie.ts | 457 ++++++++++++------ .../currency/wallet/wallet-cache-loader.ts | 34 +- 4 files changed, 681 insertions(+), 180 deletions(-) diff --git a/src/core/account/custom-tokens.ts b/src/core/account/custom-tokens.ts index 38b3cfbda..effc5b3d2 100644 --- a/src/core/account/custom-tokens.ts +++ b/src/core/account/custom-tokens.ts @@ -9,11 +9,12 @@ import { EdgeTokenMap } from '../../types/types' import { makeJsonFile } from '../../util/file-helpers' +import { bumpEngineQueue } from '../currency/currency-selectors' import { getCurrencyTools, maybeFindCurrencyPluginId } from '../plugins/plugins-selectors' -import { ApiInput } from '../root-pixie' +import { ApiInput, RootProps } from '../root-pixie' import { getStorageWalletDisklet } from '../storage/storage-selectors' import { asCustomTokensFile, asGuiSettingsFile } from './account-cleaners' @@ -51,8 +52,12 @@ export async function getTokenId( return await tools.getTokenId(token) } - // Find an engine (any engine) to validate our token: - const engine = findEngine(ai, pluginId) + // Find an engine (any engine) to validate our token. A wallet can + // exist before its engine does (a cache-seeded login), so wait for + // one of this plugin's wallets to start its engine rather than + // failing while the engines are still queued: + const engine = + findEngine(ai, pluginId) ?? (await waitForPluginEngine(ai, pluginId)) if (engine == null) { throw new Error( 'A wallet must exist before adding tokens to a legacy currency plugin' @@ -163,6 +168,51 @@ export function findEngine( } } +/** + * Waits for any of this plugin's wallets to have a running engine, + * moving one healthy wallet to the front of the startup queue. + * Returns undefined when the account has no wallet of this plugin, and + * rejects only once none of them can still start: a single failed + * engine must not fail the wait while another is still queued. + */ +async function waitForPluginEngine( + ai: ApiInput, + pluginId: string +): Promise { + const pluginWalletIds = (props: RootProps): string[] => { + const { wallets } = props.state.currency + return Object.keys(wallets).filter(id => wallets[id].pluginId === pluginId) + } + + const walletIds = pluginWalletIds(ai.props) + if (walletIds.length === 0) return + const healthyId = walletIds.find( + id => ai.props.state.currency.wallets[id].engineFailure == null + ) + if (healthyId != null) bumpEngineQueue(ai, healthyId) + + return await ai.waitFor( + (props: RootProps): EdgeCurrencyEngine | undefined => { + let failure: Error | undefined + let pending = 0 + for (const walletId of pluginWalletIds(props)) { + const engine = props.output.currency.wallets[walletId]?.engine + if (engine != null) return engine + const { engineFailure } = props.state.currency.wallets[walletId] + if (engineFailure != null) failure = engineFailure + else ++pending + } + if (pending > 0) return + throw ( + failure ?? + new Error( + 'A wallet must exist before adding tokens to a legacy currency plugin' + ) + ) + } + ) +} + async function loadGuiTokens( ai: ApiInput, accountId: string diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index 094d8ce1a..fb9b1a26b 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -1,5 +1,6 @@ import { abs, div, lt, mul } from 'biggystring' import { Disklet } from 'disklet' +import { isPixieShutdownError } from 'redux-pixies' import { base64 } from 'rfc4648' import { bridgifyObject, emit, onMethod, watchMethod } from 'yaob' @@ -25,6 +26,7 @@ import { EdgeEncodeUri, EdgeGetReceiveAddressOptions, EdgeGetTransactionsOptions, + EdgeOtherMethods, EdgeParsedUri, EdgePaymentProtocolInfo, EdgeReceiveAddress, @@ -43,11 +45,18 @@ import { EdgeWalletInfo, JsonObject } from '../../../types/types' +import { compare } from '../../../util/compare' import { makeMetaTokens } from '../../account/custom-tokens' import { splitWalletInfo } from '../../login/splitting' -import { toApiInput } from '../../root-pixie' +import { getCurrencyTools } from '../../plugins/plugins-selectors' +import { RootProps, toApiInput } from '../../root-pixie' 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,193 @@ 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) + }) + + // Address queries that were answered from the cache before the + // engine existed. Each one owes the caller a correction if the + // engine turns out to disagree: + const addressesServedFromCache = new Set() + + /** + * Remembers the engine's answer to the default address query, so + * the cache saver persists it and the next warm login can serve it + * pre-engine. Balances are stripped: + * they are stale by definition and `balanceMap` already owns them. + */ + function rememberAddresses( + opts: EdgeGetReceiveAddressOptions, + addresses: EdgeAddress[] + ): void { + if (opts.forceIndex != null) return + const tokenIdKey = opts.tokenId ?? '' + const stripped = addresses.map(address => ({ + addressType: address.addressType, + publicAddress: address.publicAddress + })) + + // If this query was served from the cache while the engine loaded, + // its callers hold an address the engine may have replaced. Only + // the first engine answer can settle that; later changes are + // rotations, which the engine reports through its own callback: + const owesCorrection = addressesServedFromCache.delete(tokenIdKey) + const isStale = + owesCorrection && + !compare(input.props.walletState.addresses[tokenIdKey] ?? [], stripped) + + input.props.dispatch({ + type: 'CURRENCY_WALLET_ADDRESSES_CHANGED', + payload: { + addresses: stripped, + tokenId: opts.tokenId, + walletId + } + }) + + if (isStale) fakeCallbacks.onAddressChanged() + } + + // Token queries with a reconcile already in flight. Several callers + // can be served the same cached answer before the engine lands, and + // one engine query settles the correction for all of them: + const reconcilingAddresses = new Set() + + /** + * Asks the engine for an address query that was already answered + * from the cache, so `rememberAddresses` can emit `addressChanged` + * if the two disagree. At most one runs per token query: a second + * would find the correction already settled and would silently + * overwrite the stored addresses without telling anyone. + */ + function reconcileAddresses(opts: EdgeGetReceiveAddressOptions): void { + const tokenIdKey = opts.tokenId ?? '' + if (reconcilingAddresses.has(tokenIdKey)) return + reconcilingAddresses.add(tokenIdKey) + + getEngine() + .then(async () => await out.getAddresses({ tokenId: opts.tokenId })) + .catch(error => { + // A logout destroys the pixie under the pending engine wait, + // and a wallet deletion removes it from the state. Either is + // teardown, not a failed reconcile, and the correction is + // moot once the wallet is gone, so the app must not see a + // leftover shutdown or wallet-missing error: + if (isPixieShutdownError(error)) return + if (ai.props.state.currency.wallets[walletId] == null) return + input.props.onError(error) + }) + .finally(() => reconcilingAddresses.delete(tokenIdKey)) + } const fakeCallbacks = makeCurrencyWalletCallbacks(input) - const otherMethods: { [name: string]: (...args: any[]) => any } = {} - if (engine.otherMethods != null) { - for (const name of Object.keys(engine.otherMethods)) { - const method = engine.otherMethods[name] - if (typeof method !== 'function') continue - otherMethods[name] = method + // The wallet's `otherMethods` is an object of delegating stubs: + // each waits for the engine and then forwards, so a method can be + // called the moment its NAME is known - from the cache on a warm + // login (before the engine exists), or from the live engine + // otherwise. The object keeps its identity as long as the known + // name set is unchanged (the common warm-boot case, where the + // cache already names every engine method); when a name first + // appears it is REBUILT as a new bridgified object, because yaob + // only serializes the properties an object had when it first + // crossed the bridge - the pixie watcher's update() then delivers + // the new object, exactly how the old engine-swap propagated. + // Existing stubs carry over a rebuild, a name the loaded engine + // turns out to lack rejects cleanly at call time, and pre-engine + // with no cache this is `{}`, exactly the old guarantee, so + // property probes stay safe. + let otherMethodStubs: { [name: string]: (...args: any[]) => any } = {} + bridgifyObject(otherMethodStubs) + + function makeOtherMethodStub(name: string): (...args: any[]) => any { + return async (...args: any[]): Promise => { + // Resolve against the live engine on every call, so an engine + // rebuilt by a resync never leaves a stale capture behind. + // Calls go through the source object, preserving `this`: + const engine = await getEngine() + const withKeys = engine.otherMethodsWithKeys + if (withKeys != null && typeof withKeys[name] === 'function') { + return withKeys[name](walletInfo.keys, ...args) + } + const methods = engine.otherMethods + if (methods != null && typeof methods[name] === 'function') { + return methods[name](...args) + } + throw new Error(`The wallet engine does not implement "${name}"`) } } - if (engine.otherMethodsWithKeys != null) { - for (const name of Object.keys(engine.otherMethodsWithKeys)) { - const method = engine.otherMethodsWithKeys[name] - if (typeof method !== 'function') continue - otherMethods[name] = (...args) => method(walletInfo.keys, ...args) + + function syncOtherMethodStubs(): EdgeOtherMethods { + const names = [...input.props.walletState.otherMethodNames] + const engine = input.props.walletOutput?.engine + if (engine != null) { + for (const source of [engine.otherMethods, engine.otherMethodsWithKeys]) { + if (source == null) continue + for (const name of Object.keys(source)) { + if (typeof source[name] === 'function') names.push(name) + } + } } + const missing = names.filter(name => otherMethodStubs[name] == null) + if (missing.length > 0) { + const next: { [name: string]: (...args: any[]) => any } = { + ...otherMethodStubs + } + for (const name of missing) { + if (next[name] == null) next[name] = makeOtherMethodStub(name) + } + bridgifyObject(next) + otherMethodStubs = next + } + return otherMethodStubs } - bridgifyObject(otherMethods) const out: EdgeCurrencyWallet & InternalWalletMethods = { on: onMethod, @@ -143,8 +309,13 @@ export function makeCurrencyWalletApi( get localDisklet(): Disklet { 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 +327,7 @@ export function makeCurrencyWalletApi( return input.props.walletState.name }, async renameWallet(name: string): Promise { + await getStorage() await renameCurrencyWallet(input, name) }, @@ -164,6 +336,7 @@ export function makeCurrencyWalletApi( return input.props.walletState.fiat }, async setFiatCurrencyCode(fiatCurrencyCode: string): Promise { + await getStorage() await setCurrencyWalletFiat(input, fiatCurrencyCode) }, @@ -206,6 +379,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 +406,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 +421,48 @@ 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)) + + // The token file writer waits on `tokenFileLoaded`, which the + // queued startup block sets, so a toggle on a cache-emitted + // wallet would otherwise sit in Redux unwritten until this + // wallet's turn comes up and be lost if the process dies first: + bumpEngineQueue(ai, walletId) + + // 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 +482,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 +495,7 @@ export function makeCurrencyWalletApi( async $internalStreamTransactions( opts: EdgeStreamTransactionOptions ): Promise { + const engine = await getEngine() const { afterDate, batchSize = 10, @@ -416,8 +630,38 @@ export function makeCurrencyWalletApi( async getAddresses( opts: EdgeGetReceiveAddressOptions ): Promise { + // Serve the cached answer while the engine is still loading, so + // the receive scene works right away on a warm login. That + // answer is whatever the engine last derived, which on a + // rotating chain it may since have replaced, so the engine is + // asked in the background and `addressChanged` fires if the two + // disagree. A caller naming a specific index wants a freshly + // derived address, which only the engine can produce, so that + // query still waits. A wallet whose engine has FAILED also waits, + // and therefore rejects: its engine is never coming, so serving + // the cache forever would make a broken wallet indistinguishable + // from a healthy one on this method alone: + const cachedAddresses = + input.props.walletState.addresses[opts.tokenId ?? ''] ?? [] + if ( + opts.forceIndex == null && + cachedAddresses.length > 0 && + input.props.walletState.engineFailure == null && + input.props.walletOutput?.engine == null + ) { + // The user is on an address screen, so they want this + // wallet's engine sooner rather than later: + bumpEngineQueue(ai, walletId) + addressesServedFromCache.add(opts.tokenId ?? '') + reconcileAddresses(opts) + return cachedAddresses.map(address => ({ ...address })) + } + + const engine = await getEngine() if (engine.getAddresses != null) { - return await engine.getAddresses(opts) + const addresses = await engine.getAddresses(opts) + rememberAddresses(opts, addresses) + return addresses } else { const upgradedCurrency = upgradeCurrencyCode({ allTokens: input.props.state.accounts[accountId].allTokens[pluginId], @@ -463,6 +707,7 @@ export function makeCurrencyWalletApi( }) } + rememberAddresses(opts, addresses) return addresses } }, @@ -515,12 +760,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 +780,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 +789,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 +884,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 +904,7 @@ export function makeCurrencyWalletApi( }, async saveTxAction(opts): Promise { + await getEngine() const { txid, tokenId, assetAction, savedAction } = opts await updateCurrencyWalletTxMetadata( input, @@ -666,6 +918,7 @@ export function makeCurrencyWalletApi( }, async saveTxMetadata(opts: EdgeSaveTxMetadataOptions): Promise { + await getEngine() const { txid, tokenId, metadata } = opts await updateCurrencyWalletTxMetadata( @@ -681,6 +934,7 @@ export function makeCurrencyWalletApi( bytes: Uint8Array, opts: EdgeSignMessageOptions = {} ): Promise { + const engine = await getEngine() const privateKeys = walletInfo.keys if (engine.signBytes != null) { @@ -706,6 +960,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 +968,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 +983,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 +995,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 +1024,7 @@ export function makeCurrencyWalletApi( // URI handling: async encodeUri(options: EdgeEncodeUri): Promise { + const tools = await getTools() return await tools.encodeUri( options, makeMetaTokens( @@ -772,6 +1033,7 @@ export function makeCurrencyWalletApi( ) }, async parseUri(uri: string, currencyCode?: string): Promise { + const tools = await getTools() const parsedUri = await tools.parseUri( uri, currencyCode, @@ -792,7 +1054,9 @@ export function makeCurrencyWalletApi( }, // Generic: - otherMethods + get otherMethods(): EdgeOtherMethods { + return syncOtherMethodStubs() + } } return bridgifyObject(out) diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index 1a2c62553..78cfe58a0 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -17,10 +17,10 @@ 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 { Dispatch } from '../../actions' import { makeLog } from '../../log/log' import { getCurrencyTools } from '../../plugins/plugins-selectors' import { RootProps, toApiInput } from '../../root-pixie' @@ -38,7 +38,7 @@ import { makeCurrencyWalletCallbacks, watchCurrencyWallet } from './currency-wallet-callbacks' -import { asIntegerString, asPublicKeyFile } from './currency-wallet-cleaners' +import { asIntegerString } from './currency-wallet-cleaners' import { loadAddressFiles, loadFiatFile, @@ -55,6 +55,13 @@ import { initialWalletSettings } from './currency-wallet-reducer' import { tokenIdsToCurrencyCodes, uniqueStrings } from './enabled-tokens' +import { getEngineScheduler } from './engine-scheduler' +import { + loadWalletCacheSeed, + PUBLIC_KEY_CACHE, + publicKeyFile, + walletCacheLoaderHooks +} from './wallet-cache-loader' export interface CurrencyWalletOutput { readonly walletApi: EdgeCurrencyWallet | undefined @@ -70,151 +77,307 @@ 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 + + // The engine-startup slot this wallet holds, if any. Kept at the + // pixie level so `destroy` can free it: the scheduler outlives a + // login, and a slot held through the old session's remaining + // awaits would make the next login queue behind dead work: + let releaseSlot: (() => void) | undefined + + // Work that outlives a logout must not touch the next session: + // its pixie under the same wallet id would take this one's stale + // reads as its own, and the cache saver would then persist them. + // Every dispatch of the startup block and of the engine callbacks + // goes through this, so anything that lands after `destroy` is + // dropped: + const dispatch: Dispatch = action => + destroyed ? action : input.props.dispatch(action) + const startupInput: CurrencyWalletInput = { + ...input, + get props() { + return { ...input.props, dispatch } + } + } + + 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 + } + try { + const ai = toApiInput(startupInput) + + // Load the UI-state cache before the storage-wallet sync, + // so a previously-seen wallet can emit its API object right away. + // The bulk loader may have already seeded us; otherwise read our + // own files (cold logins, wallets activated after login, bulk + // misses). If either file is missing or invalid (first login, + // schema bump, corruption), fall through to the cold path: + let cacheSeeded = + walletState.publicWalletInfo != null && walletState.nameLoaded + if (!cacheSeeded) { + const seed = await loadWalletCacheSeed(ai, walletId, accountId) + + // A logout during that read destroys this pixie, and the next + // login runs a fresh one under the same wallet id. Seeding + // now would overwrite that session's name, fiat and token + // list with this one's stale read, and the repo sync below + // would re-add the storage wallet under it: + if (destroyed) return + if (seed != null) { + dispatch({ + type: 'CURRENCY_WALLET_CACHE_LOADED', + payload: { ...seed, walletId } + }) + if (walletCacheLoaderHooks.onFallbackSeed != null) { + walletCacheLoaderHooks.onFallbackSeed(walletId) + } + cacheSeeded = true + } + } + + if (cacheSeeded) { + // This wallet is already usable from its cache, so its heavy + // startup work (repo sync, key derivation, engine creation) + // waits its turn in a limited-concurrency queue instead of + // racing every other wallet in the seconds after login. + // Wallets without a cache skip the queue: they cannot emit at + // all until this work runs, so they behave exactly as before. + releaseSlot = await getEngineScheduler(input.props.io).acquire( + walletId, + () => { + input.props.log.warn( + `${walletId} engine startup exceeded its slot time; freeing the slot for the next wallet` + ) + } + ) + + // The wallet may have been deleted (or the user logged out) + // while it waited in line; the finally releases the slot: + if (destroyed) return + input.props.log(`${walletId} engine startup slot acquired`) + } + + // Start the data sync: + await addStorageWallet(ai, walletInfo) + + // Grab the freshly-synced repos: + const { state } = input.props + const walletLocalDisklet = getStorageWalletLocalDisklet( state, - walletInfo.id, - input.props.io + walletInfo.id ) + const walletLocalEncryptedDisklet = + makeStorageWalletLocalEncryptedDisklet( + state, + walletInfo.id, + input.props.io + ) - // We need to know which transactions exist, - // since new transactions may come in from the network: - await loadTxFileNames(input) + // We need to know which transactions exist, + // since new transactions may come in from the network: + await loadTxFileNames(startupInput) + + // 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 + ) + dispatch({ + type: 'CURRENCY_WALLET_PUBLIC_INFO', + payload: { walletInfo: publicWalletInfo, walletId } + }) - // Derive the public keys: - const tools = await getCurrencyTools(ai, pluginId) - const publicWalletInfo = await getPublicWalletInfo( - walletInfo, - walletLocalDisklet, - tools - ) - input.props.dispatch({ - type: 'CURRENCY_WALLET_PUBLIC_INFO', - payload: { walletInfo: publicWalletInfo, walletId } - }) + // Load the last seen transaction checkpoint into memory. + // This also loads subscribed addresses from the same file. + const { checkpoint: seenTxCheckpoint, subscribedAddresses } = + await loadSeenTxCheckpointFile(startupInput) - // 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(startupInput) - // 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(startupInput) + } - const { hasWalletSettings = false } = walletState.currencyInfo - if (hasWalletSettings) { - await loadWalletSettingsFile(input) - } + // Master never started an engine before the account files were + // read. The warm path runs that load in parallel with this + // block, so wait for it here rather than reading whatever has + // landed: an engine created with `userSettings: {}` connects to + // its default servers before a `networkPrivacy` setting can + // apply, which is the leak that setting exists to prevent. + // The wait is local reads for a returning login, since + // `addStorageWallet` returns after a status read and syncs in + // the background, and the wallet list is already on screen. + const accountState = await ai.waitFor(props => { + const accountState = props.state.accounts[accountId] + if (accountState == null) { + throw new Error('The account was logged out') + } + if ( + accountState.customTokensLoaded && + accountState.pluginSettingsLoaded && + accountState.builtinTokens[pluginId] != null + ) { + return accountState + } - // Start the engine: - const accountState = state.accounts[accountId] - const engine = await plugin.makeCurrencyEngine(publicWalletInfo, { - callbacks: makeCurrencyWalletCallbacks(input), - - // Engine state kept by the core: - seenTxCheckpoint, - subscribedAddresses, - - // Wallet-scoped IO objects: - log: makeLog( - input.props.logBackend, - `${pluginId}-${walletInfo.id.slice(0, 2)}` - ), - walletLocalDisklet, - walletLocalEncryptedDisklet, - - // User settings: - customTokens: accountState.customTokens[pluginId] ?? {}, - enabledTokenIds: input.props.walletState.allEnabledTokenIds, - userSettings: accountState.userSettings[pluginId] ?? {}, - walletSettings: input.props.walletState.walletSettings - }) - input.onOutput(engine) - - // Grab initial state: - const parentCurrency = { currencyCode, tokenId: null } - const balance = asMaybe(asIntegerString)( - engine.getBalance(parentCurrency) - ) - if (balance != null) { - input.props.dispatch({ - type: 'CURRENCY_ENGINE_CHANGED_BALANCE', - payload: { balance, tokenId: null, walletId } + // A terminal boot failure means the files never arrive. + // Failing the engine beats starting it with unknown + // privacy settings: + if (accountState.loadFailure != null) throw accountState.loadFailure }) - } - 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) + + const engine = await plugin.makeCurrencyEngine(publicWalletInfo, { + // The engine keeps firing between `destroy` and the moment + // `killEngine` finishes, and the next login already has a + // wallet under this same id, so these dispatches go through + // the guarded input as well: + callbacks: makeCurrencyWalletCallbacks(startupInput), + + // 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 + }) + + // The wallet can also be deleted (or the account logged out) + // during the awaits above, which the slot-acquisition check + // cannot see. By now `engineStarted`'s destroy has already run + // and found no engine to kill, so publishing this one would + // leave it alive with nothing left to tear it down: + if (destroyed) { + await engine.killEngine().catch(() => {}) + return + } + input.onOutput(engine) + + // Remember the engine's otherMethods names, so the cache can + // expose pre-engine delegating stubs on the next login: + const otherMethodNames: string[] = [] + for (const source of [ + engine.otherMethods, + engine.otherMethodsWithKeys + ]) { + if (source == null) continue + for (const name of Object.keys(source)) { + if (typeof source[name] !== 'function') continue + if (!otherMethodNames.includes(name)) otherMethodNames.push(name) + } + } + dispatch({ + type: 'CURRENCY_WALLET_OTHER_METHOD_NAMES_CHANGED', + payload: { names: otherMethodNames, walletId } + }) + + // Grab initial state: + const parentCurrency = { currencyCode, tokenId: null } + const balance = asMaybe(asIntegerString)( + engine.getBalance(parentCurrency) ) + if (balance != null) { + dispatch({ + type: 'CURRENCY_ENGINE_CHANGED_BALANCE', + payload: { balance, tokenId: null, walletId } + }) + } + const height = engine.getBlockHeight() + dispatch({ + type: 'CURRENCY_ENGINE_CHANGED_HEIGHT', + payload: { height, walletId } + }) + if (engine.getStakingStatus != null) { + await engine.getStakingStatus().then( + stakingStatus => { + dispatch({ + type: 'CURRENCY_ENGINE_CHANGED_STAKING', + payload: { stakingStatus, walletId } + }) + }, + // A logout rejects this query for a wallet the user has + // already left, which is teardown, not an error the app + // can act on (the catch below drops the same case): + error => { + if (destroyed) return + input.props.onError(error) + } + ) + } + + // Load remaining data from disk: + await loadFiatFile(startupInput) + await loadNameFile(startupInput) + await loadAddressFiles(startupInput) + } catch (error: unknown) { + // A logout mid-startup makes the remaining awaits fail for a + // wallet that no longer exists; that is teardown, not an error: + if (destroyed) return + input.props.onError(error) + 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 + // Idempotent, so the startup block's own release stays the + // normal path and this only matters for a logout mid-startup: + if (releaseSlot != null) releaseSlot() + } + } }, // Creates the API object: walletApi: (input: CurrencyWalletInput) => async () => { - const { walletOutput, walletState } = input.props - if (walletOutput == null) return - const { engine } = walletOutput - const { nameLoaded, pluginId, publicWalletInfo } = walletState - if (engine == null || publicWalletInfo == null || !nameLoaded) return - const tools = await getCurrencyTools(toApiInput(input), pluginId) - - const currencyWalletApi = makeCurrencyWalletApi( - input, - engine, - tools, - publicWalletInfo - ) + const { walletState } = input.props + const { nameLoaded, publicWalletInfo } = walletState + if (publicWalletInfo == null || !nameLoaded) return + + const currencyWalletApi = makeCurrencyWalletApi(input, publicWalletInfo) input.onOutput(currencyWalletApi) return await stopUpdates @@ -492,17 +655,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 +695,7 @@ export const walletPixie: TamePixie = combinePixies({ if ( settingsChanged && - engine?.changeWalletSettings != null && + engine.changeWalletSettings != null && hasWalletSettings ) { await engine.changeWalletSettings(walletSettings).catch(error => { @@ -537,7 +706,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 +742,26 @@ export const walletPixie: TamePixie = combinePixies({ /** * Attempts to load/derive the wallet public keys. + * Pass `cachedWalletInfo` when `publicKey.json` was already read + * (the cache seeding path), so it is not read a second time. */ export async function getPublicWalletInfo( walletInfo: EdgeWalletInfo, disklet: Disklet, - tools: EdgeCurrencyTools + tools: EdgeCurrencyTools, + cachedWalletInfo?: EdgeWalletInfo ): Promise { // Try to load the cache: - const publicKeyCache = await publicKeyFile.load(disklet, PUBLIC_KEY_CACHE) - if (publicKeyCache != null) { + const cached = + cachedWalletInfo ?? + (await publicKeyFile.load(disklet, PUBLIC_KEY_CACHE))?.walletInfo + if (cached != null) { // Return it if it needs not to be upgraded (re-derived): if ( tools.checkPublicKey == null || - (await tools.checkPublicKey(publicKeyCache.walletInfo.keys)) + (await tools.checkPublicKey(cached.keys)) ) { - return publicKeyCache.walletInfo + return cached } } @@ -602,13 +776,10 @@ export async function getPublicWalletInfo( keys: publicKeys } - // Save the cache if it's not empty: - if (Object.keys(publicKeys).length > 0) { - await publicKeyFile.save(disklet, PUBLIC_KEY_CACHE, { - walletInfo: publicWalletInfo - }) - } - + // The account cache saver persists these keys along with the rest + // of the wallet's boot state, so nothing is written per wallet + // here. Older `publicKey.json` files stay on disk as a recovery + // net for a boot that cannot read the account cache: return publicWalletInfo } diff --git a/src/core/currency/wallet/wallet-cache-loader.ts b/src/core/currency/wallet/wallet-cache-loader.ts index 99395314f..256382b57 100644 --- a/src/core/currency/wallet/wallet-cache-loader.ts +++ b/src/core/currency/wallet/wallet-cache-loader.ts @@ -28,18 +28,22 @@ export const walletCacheLoaderHooks: { onBulkSeed?: (walletIds: string[]) => void /** Receives each wallet id seeded by a pixie's fallback read. */ onFallbackSeed?: (walletId: string) => void + /** Parks a pixie's fallback read on the seed it already found. */ + fallbackSeedGate?: Promise } = {} /** - * The consolidated cache file each account booted from, kept for the - * session. The account pixie reads that file once at boot, so a wallet - * the bulk seed missed can be looked up here instead of re-reading both - * slots (~2 x 105 KiB on a large account) per wallet. A wallet absent - * from the booted file is absent from disk too: the saver only adds - * entries for wallets that are already running, which never take the - * fallback path. A miss is remembered as well (`undefined`), so a cold - * login goes straight to the per-wallet files instead of re-reading - * both missing slots per wallet. + * Each account's consolidated cache file, kept for the session. The + * account pixie reads that file once at boot, and the saver replaces + * the entry with each generation it writes, so a wallet the bulk seed + * missed looks its entry up here instead of re-reading both slots + * (~2 x 105 KiB on a large account) per wallet. Following the writes + * matters for a wallet created after boot and then archived: its entry + * exists only in the generations written since, and the per-wallet + * files the fallback reads next are no longer written at all. A miss + * is remembered as well (`undefined`), so a cold login goes straight to + * the per-wallet files instead of re-reading both missing slots per + * wallet, until the saver's first write fills the entry in. */ const bootAccountCaches = new Map() @@ -95,6 +99,18 @@ export async function loadWalletCacheSeed( ai: ApiInput, walletId: string, accountId: string +): Promise { + const seed = await readWalletCacheSeed(ai, walletId, accountId) + if (walletCacheLoaderHooks.fallbackSeedGate != null) { + await walletCacheLoaderHooks.fallbackSeedGate + } + return seed +} + +async function readWalletCacheSeed( + ai: ApiInput, + walletId: string, + accountId: string ): Promise { if (bootAccountCaches.has(accountId)) { const cached = bootAccountCaches.get(accountId)?.wallets[walletId] From a4815f69bd79c81e5185d68f06fa7da29235e74a Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:15:10 -0700 Subject: [PATCH 08/10] Emit the account from cache, write from one saver The account itself was the other half of the delay: nothing emitted until plugins loaded, the account repos synced, and every key file was read, and wallet pixies could not even start until that chain finished. The boot now seeds from the cache right after the plugins load and emits, running the repo sync and file loads deferred behind it. Writes go the other way: one serialized throttled saver owns the whole account, so a sync window where 194 engines all report balances costs one write instead of 194, and generations alternate between the file's two slots so an interrupted write costs one generation of staleness. Each write logs its generation, wallet count, and duration, since the write is this design's whole cost and nothing else reports it. --- src/core/account/account-pixie.ts | 485 +++++++++++++++++- .../currency/wallet/currency-wallet-files.ts | 19 +- 2 files changed, 492 insertions(+), 12 deletions(-) diff --git a/src/core/account/account-pixie.ts b/src/core/account/account-pixie.ts index 0359d8ee2..154774bd0 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -13,19 +13,35 @@ import { EdgeAccount, EdgeCurrencyWallet, EdgePluginMap, - EdgeTokenMap + EdgeTokenMap, + EdgeWalletInfo, + EdgeWalletStates } from '../../types/types' import { makePeriodicTask } from '../../util/periodic-task' import { snooze } from '../../util/snooze' +import { + bulkLoadWalletCaches, + forgetAccountCache, + rememberAccountCache, + seedWalletCachesFromAccount, + walletCacheLoaderHooks +} from '../currency/wallet/wallet-cache-loader' import { syncLogin } from '../login/login' import { waitForPlugins } from '../plugins/plugins-selectors' import { RootProps, toApiInput } from '../root-pixie' +import { makeLocalDisklet } from '../storage/repo' import { addStorageWallet, SYNC_INTERVAL, syncStorageWallet } from '../storage/storage-actions' import { makeAccountApi } from './account-api' +import { + accountCacheSaverConfig, + loadAccountCache, + saveAccountCache +} from './account-cache-file' +import { AccountCacheFile, AccountCacheWallet } from './account-cleaners' import { loadAllWalletStates, reloadPluginSettings } from './account-files' import { AccountState, initialCustomTokens } from './account-reducer' import { @@ -49,6 +65,15 @@ export type AccountProps = RootProps & { export type AccountInput = PixieInput +/** + * The account cache saver's write chains, one per account (keyed by + * the account's local disklet id). Module-level so a write left in + * flight by a logout is still ahead of the next login's first save: + * that save reads the newest slot only after the old write has + * landed, and so never targets the slot the old write is filling. + */ +const cacheWriteChains = new Map>() + const accountPixie: TamePixie = combinePixies({ accountApi(input: AccountInput) { return { @@ -78,7 +103,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 +113,134 @@ const accountPixie: TamePixie = combinePixies({ ]) } - try { - // Wait for the currency plugins (should already be loaded by now): - await waitForPlugins(ai) + async function loadEverything(isRetry: boolean): Promise { await loadBuiltinTokens(ai, accountId) log.warn('Login: currency plugins exist') - // Start the repo: + // Start the repo. A retry must not re-attach repos a prior + // attempt already attached: STORAGE_WALLET_ADDED replaces + // the whole entry (wiping lastChanges) and starts another + // sync that can race the one still in flight: + const missingInfos = isRetry + ? accountWalletInfos.filter( + info => ai.props.state.storageWallets[info.id] == null + ) + : accountWalletInfos await Promise.all( - accountWalletInfos.map(info => addStorageWallet(ai, info)) + missingInfos.map(info => addStorageWallet(ai, info)) ) log.warn('Login: synced account repos') await loadAllFiles() log.warn('Login: loaded files') + } + + let emitted = false + try { + // Wait for the currency plugins (should already be loaded by now): + await waitForPlugins(ai) + + // Try the account boot cache. On a hit, seed Redux and emit + // the API object right away, so wallets can start from their + // own caches without waiting for the repo sync or file loads. + // The loads below then overwrite the seeded state + // authoritatively. On a miss (first login, schema bump, + // corruption) or an account with legacy Airbitz wallets + // (their infos cannot be cached), this is today's boot, + // unchanged: + const { cache: accountCache } = await loadAccountCache( + makeLocalDisklet(ai.props.io, accountWalletInfo.id) + ) + + // Keep the parsed file for the session, so a wallet the bulk + // seed misses looks its entry up here instead of re-reading + // both slots: + rememberAccountCache(accountId, accountCache) + + if (accountCache != null && !accountCache.legacyWallets) { + input.props.dispatch({ + type: 'ACCOUNT_CACHE_LOADED', + payload: { + accountId, + configOtherMethodNames: accountCache.configOtherMethodNames, + customTokens: accountCache.customTokens, + walletStates: accountCache.walletStates + } + }) + input.onOutput(makeAccountApi(ai, accountId)) + emitted = true + log.warn('Login: emitted account from cache') + if (walletCacheLoaderHooks.onAccountSeed != null) { + walletCacheLoaderHooks.onAccountSeed(accountId) + } + + // Seed every wallet's cache in one dispatch. The current + // schema carries them all in the file just read, so the + // warm path touches no further disk. A file written by an + // older version has none, so those wallets come from + // their own files this once, and the saver then folds + // them into the consolidated file: + const cachedWallets = Object.keys(accountCache.wallets) + if (cachedWallets.length > 0) { + seedWalletCachesFromAccount(ai, accountId, accountCache.wallets) + } else { + await bulkLoadWalletCaches(ai, accountId) + } + + // The GUI already has the account, so retry transient + // failures instead of leaving the session half-loaded + // (a stuck `*Loaded` flag would disable the cache saver): + for (let attempt = 1; ; ++attempt) { + // The user may have logged out while we were seeding: + if (ai.props.state.accounts[accountId] == null) { + return await stopUpdates + } + try { + await loadEverything(attempt > 1) + break + } catch (error: unknown) { + if (ai.props.state.accounts[accountId] == null) { + return await stopUpdates + } + log.error( + `Login: deferred account load failed (attempt ${attempt}): ${String( + error + )}` + ) + if (attempt >= 3) { + // Record the terminal failure, so the repo waiters + // (changeWalletStates, dataStore, sync, settings) + // reject instead of pending forever: + input.props.onError(error) + input.props.dispatch({ + type: 'ACCOUNT_LOAD_FAILED', + payload: { accountId, error } + }) + return await stopUpdates + } + await snooze(5000) + } + } + log.warn('Login: complete') + return await stopUpdates + } + + await loadEverything(false) // Create the API object: input.onOutput(makeAccountApi(ai, accountId)) log.warn('Login: complete') } catch (error: unknown) { + // The account may have logged out while we were loading: + if (ai.props.state.accounts[accountId] == null) { + return await stopUpdates + } + if (emitted) { + // The GUI already has the account, so surface the failure + // instead of wedging the login: + log.error(`Login: cache-seeded boot failed: ${String(error)}`) + input.props.onError(error) + } input.props.dispatch({ type: 'ACCOUNT_LOAD_FAILED', payload: { accountId, error } @@ -199,19 +333,348 @@ const accountPixie: TamePixie = combinePixies({ let lastTokens: EdgePluginMap = initialCustomTokens return async function update() { - const { accountId, accountState } = input.props + const { accountId, accountState, state } = input.props - const { customTokens } = accountState + const { accountWalletInfo, customTokens } = accountState if (customTokens !== lastTokens && lastTokens !== initialCustomTokens) { - await saveCustomTokens(toApiInput(input), accountId).catch(error => + // The synced repo may not exist yet (cache-seeded boot); + // return without adopting `customTokens`, so this same diff + // triggers the write once `addStorageWallet` finishes: + if (state.storageWallets[accountWalletInfo.id] == null) return + + // Never write before the authoritative load lands: the write + // rebuilds the whole file from Redux, so a cache-seeded map + // would wipe tokens another device added. Return without + // adopting, so the load's merge triggers the write: + if (!accountState.customTokensLoaded) return + + try { + await saveCustomTokens(toApiInput(input), accountId) + // Any dirty edits are on disk now, so a racing load no + // longer needs to preserve them: + input.props.dispatch({ + type: 'ACCOUNT_CUSTOM_TOKENS_SAVED', + payload: { accountId } + }) + } catch (error: unknown) { input.props.onError(error) - ) + } await snooze(100) // Rate limiting } lastTokens = customTokens } }, + /** + * Watches the account's cache-relevant Redux state and persists it + * to `accountCache.json`, so the next login can start its wallets + * before the account repo loads. Writes are throttled (trailing + * edge), never happen after logout, and stop after 3 consecutive + * failures to avoid log spam. The dirty set deliberately includes + * account-level `customTokens`: a saver that misses those wipes + * custom tokens on the next warm login. + */ + cacheSaver(input: AccountInput) { + interface CacheSnapshot { + currencyWalletIds: string[] + customTokens: EdgePluginMap + legacyWalletInfos: EdgeWalletInfo[] + walletStates: EdgeWalletStates + + /** + * Reference identities of every active wallet's cache-relevant + * state. The Redux slices are immutable, so an unchanged + * reference means unchanged content, and this stays a cheap + * reference scan rather than a deep compare of the whole table. + */ + walletStamp: unknown[] + } + + function sameStamp(a: unknown[], b: unknown[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) return false + } + return true + } + + let destroyed = false + let failures = 0 + let lastSaved: CacheSnapshot | undefined + let timer: ReturnType | undefined + + // Which of the two slots the next write lands in, resolved from + // disk on the first save. `undefined` means "not looked up yet": + let nextSlot: number | undefined + + // Every write goes through one chain per account (keyed below by + // the account's local disklet id, in `cacheWriteChains`). `doSave` + // is async, so a throttle firing while a previous write is still + // in flight would otherwise let two saves read the same `nextSlot` + // and both write it, leaving the other slot two generations stale + // (and, on a platform whose writes are not atomic, interleaving in + // one file). The chain outlives this saver on purpose: a logout + // during a write cannot cancel the write, so the next login's + // saver waits behind it before resolving its own starting slot. + + // Monotonic across writes, so a reader can tell the two slots + // apart. Seeded from whichever generation is on disk: + let sequence = 0 + + // The wallet table as last written, so wallets that are not + // running this session keep their cached state instead of being + // dropped by a save that only sees the active ones: + let lastWallets: { [walletId: string]: AccountCacheWallet } = {} + + // The stamp computed by the `update` that armed the pending + // timer, so `doSave` records exactly what it wrote: + let pendingStamp: unknown[] = [] + + /** + * Reference identities of the state `collectWallets` reads, in a + * flat array, so `update` can tell whether anything the cache + * file actually stores has changed. + */ + function collectWalletStamp(): unknown[] { + const { accountState, state } = input.props + const stamp: unknown[] = [] + for (const walletId of accountState.activeWalletIds) { + const walletState = state.currency.wallets[walletId] + if (walletState == null) continue + + // A wallet still loading its files is not written (see + // `collectWallets`), so its fields must not enter the stamp + // yet: a toggle made in that window would otherwise be + // stamped without being written, and a load that merges to + // the same list would never trigger the write that carries it: + const { fiatLoaded, nameLoaded, tokenFileEverLoaded } = walletState + if (!fiatLoaded || !nameLoaded || !tokenFileEverLoaded) { + stamp.push(null) + continue + } + stamp.push( + walletState.addresses, + walletState.balanceMap, + walletState.enabledTokenIds, + walletState.fiat, + walletState.name, + walletState.otherMethodNames, + walletState.publicWalletInfo, + walletState.stakingStatus + ) + } + return stamp + } + + /** + * Collects every active wallet's cache-relevant Redux state. + * This is the whole point of the consolidated file: one writer + * for the entire account, instead of one throttled writer per + * wallet all racing for the same bridge. + */ + function collectWallets(): { + [walletId: string]: AccountCacheWallet + } { + const { accountState, state } = input.props + + // Start from what the file already holds, so a wallet that is + // archived (or simply not running this session) keeps its cached + // boot state, the way its own file used to just sit on disk. + // Entries for wallets the account no longer has are dropped, so + // this cannot grow without bound. The wallet list is the test, + // not `walletStates`, which only lists wallets with an explicit + // state and would drop a plain wallet that is merely not loaded: + const wallets: { [walletId: string]: AccountCacheWallet } = {} + for (const walletId of Object.keys(lastWallets)) { + if (accountState.walletInfos[walletId] == null) continue + if (accountState.walletStates[walletId]?.deleted === true) continue + wallets[walletId] = lastWallets[walletId] + } + + for (const walletId of accountState.activeWalletIds) { + const walletState = state.currency.wallets[walletId] + if (walletState == null) continue + + // Skip a wallet that has not finished loading its + // authoritative files, so a cold start never caches + // placeholder values (the per-wallet saver's old guard). The + // token-file flag is the sticky one, so a resync, which clears + // `tokenFileLoaded` but keeps the enabled list, does not + // freeze this wallet's entry for the rest of the session: + const { + fiatLoaded, + nameLoaded, + publicWalletInfo, + tokenFileEverLoaded + } = walletState + if (!fiatLoaded || !nameLoaded || !tokenFileEverLoaded) continue + if (publicWalletInfo == null) continue + + const balances: { [tokenId: string]: string } = {} + for (const [tokenId, balance] of walletState.balanceMap) { + balances[tokenId ?? ''] = balance + } + + wallets[walletId] = { + walletInfo: publicWalletInfo, + name: walletState.name, + fiatCurrencyCode: walletState.fiat, + enabledTokenIds: walletState.enabledTokenIds, + balances, + addresses: walletState.addresses, + otherMethodNames: walletState.otherMethodNames, + stakingStatus: walletState.stakingStatus + } + } + return wallets + } + + async function doSave(): Promise { + const { accountId, accountState, state } = input.props + + // Never write after logout. The destroy flag is what makes this + // reliable: redux-pixies serves a destroyed pixie its last props, + // so the state read below still reports the account as present + // and would let a write already on the chain through: + if (destroyed) return + if (state.accounts[accountId] == null) return + + const snapshot: CacheSnapshot = { + currencyWalletIds: accountState.currencyWalletIds, + customTokens: accountState.customTokens, + legacyWalletInfos: accountState.legacyWalletInfos, + walletStates: accountState.walletStates, + walletStamp: pendingStamp + } + + // Only legacy wallets that actually surface as currency wallets + // force a cold boot; a legacy repo whose wallet type has no + // loaded plugin was never visible in the first place: + const legacyWallets = snapshot.legacyWalletInfos.some(info => + snapshot.currencyWalletIds.includes(info.id) + ) + + // Remember each plugin's otherMethods names. The plugin list is + // static for the whole session, so this needs no dirty tracking: + const configOtherMethodNames: EdgePluginMap = {} + const { currency } = input.props.state.plugins + for (const pluginId of Object.keys(currency)) { + const { otherMethods } = currency[pluginId] + if (otherMethods == null) continue + const names = Object.keys(otherMethods).filter( + name => typeof (otherMethods as any)[name] === 'function' + ) + if (names.length > 0) configOtherMethodNames[pluginId] = names + } + + try { + const disklet = makeLocalDisklet( + input.props.io, + accountState.accountWalletInfo.id + ) + + // Resolve the starting slot once. A fresh session must not + // overwrite the generation it booted from, or a kill during + // this very first write would leave nothing intact: + if (nextSlot == null) { + const existing = await loadAccountCache(disklet) + nextSlot = existing.nextSlot + sequence = existing.cache?.sequence ?? 0 + lastWallets = existing.cache?.wallets ?? {} + } + + const wallets = collectWallets() + const file: AccountCacheFile = { + version: 2, + sequence: ++sequence, + customTokens: snapshot.customTokens, + legacyWallets, + walletStates: snapshot.walletStates, + configOtherMethodNames, + wallets + } + const startMs = Date.now() + nextSlot = await saveAccountCache(disklet, nextSlot, file) + + // A wallet activated later this session seeds from the memo, + // so it has to follow the disk. A wallet created after boot + // has no entry anywhere else once it is archived. A logout + // during the write above already ran `forgetAccountCache`, + // and account ids never repeat, so re-memoing here would + // strand this file under an id that can never log in again: + if (!destroyed) rememberAccountCache(accountId, file) + + // The write is this design's whole cost, and it is invisible + // from the outside. The saver's throttle bounds this to one + // line per `throttleMs`, but that runs for the whole session, + // so it ships at `info` and only a slow write earns `warn`: + const elapsedMs = Date.now() - startMs + const line = `Wallet cache: wrote generation ${sequence} with ${ + Object.keys(wallets).length + } wallets in ${elapsedMs}ms` + if (elapsedMs > accountCacheSaverConfig.slowWriteMs) { + input.props.log.warn(line) + } else { + input.props.log(line) + } + lastWallets = wallets + failures = 0 + lastSaved = snapshot + } catch (error: unknown) { + if (++failures >= 3) { + input.props.log.error( + `Account cache saver giving up after ${failures} failures: ${String( + error + )}` + ) + } + } + } + + return { + update() { + const { accountState } = input.props + if (accountState == null) return + if (failures >= 3 || timer != null) return + + // Wait until the authoritative files have loaded, + // so a cold start never caches placeholder values: + const { customTokensLoaded, walletStatesLoaded } = accountState + if (!customTokensLoaded || !walletStatesLoaded) return + + const walletStamp = collectWalletStamp() + if ( + lastSaved != null && + lastSaved.currencyWalletIds === accountState.currencyWalletIds && + lastSaved.customTokens === accountState.customTokens && + lastSaved.legacyWalletInfos === accountState.legacyWalletInfos && + lastSaved.walletStates === accountState.walletStates && + sameStamp(lastSaved.walletStamp, walletStamp) + ) { + return + } + pendingStamp = walletStamp + + timer = setTimeout(() => { + timer = undefined + const key = accountState.accountWalletInfo.id + const chain = (cacheWriteChains.get(key) ?? Promise.resolve()).then( + doSave, + doSave + ) + cacheWriteChains.set(key, chain) + chain.catch(error => input.props.onError(error)) + }, accountCacheSaverConfig.throttleMs) + }, + + destroy() { + destroyed = true + forgetAccountCache(input.props.accountId) + if (timer != null) clearTimeout(timer) + } + } + }, + watcher(input: AccountInput) { let lastState: AccountState | undefined // let lastWallets diff --git a/src/core/currency/wallet/currency-wallet-files.ts b/src/core/currency/wallet/currency-wallet-files.ts index a8f7541d9..5ea86c3de 100644 --- a/src/core/currency/wallet/currency-wallet-files.ts +++ b/src/core/currency/wallet/currency-wallet-files.ts @@ -261,7 +261,24 @@ export async function loadTokensFile( ) if (legacyCurrencyCodes != null) { const { accountId, currencyInfo, pluginId } = input.props.walletState - const accountState = input.props.state.accounts[accountId] + + // This file names currency codes, so converting it needs the token + // definitions. On a warm login the builtin definitions load in + // parallel with this block, and an empty map converts every code to + // nothing: the dispatch below would then replace the cache-seeded + // list with an empty one and the saver would persist the loss. + // Wait, exactly as `changeEnabledTokenIds` does: + const accountState = await toApiInput(input).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 tokenIds = currencyCodesToTokenIds( accountState.builtinTokens[pluginId], accountState.customTokens[pluginId], From 6fc602033ca51caeabfbc8c9e7961f4fa95ec6bc Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:15:27 -0700 Subject: [PATCH 09/10] Accept hash-suffixed routes in the fake server The fake server hands out hash-suffixed store URLs but only routed the bare form, so a repo's second sync inside makeFakeEdgeWorld 404'd. Two-device tests need that second sync to diverge the repo from the cache at all. --- src/core/fake/fake-server.ts | 5 ++++- src/core/fake/fake-world.ts | 37 ++++++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) 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/fake/fake-world.ts b/src/core/fake/fake-world.ts index 0f3eafa56..5961fea13 100644 --- a/src/core/fake/fake-world.ts +++ b/src/core/fake/fake-world.ts @@ -1,4 +1,4 @@ -import { makeMemoryDisklet } from 'disklet' +import { Disklet, makeMemoryDisklet } from 'disklet' import { base16, base64 } from 'rfc4648' import { makeFetchFunction } from 'serverlet' import { bridgifyObject, close } from 'yaob' @@ -91,6 +91,39 @@ async function saveRepo( /** * Creates a fake Edge server for unit testing. */ +/** + * Test hooks for the fake world's disk, following the same + * mutable-config pattern as `accountCacheSaverConfig`. Each gate holds + * the matching disklet call AFTER it has done its work and before it + * returns, so a test can park a loader on the value it already read, + * or a writer on the file it already wrote, and interleave something + * else in that window. + */ +export const fakeWorldTestConfig: { + readGate?: Promise + writeGate?: Promise +} = {} + +function makeGatedDisklet(disklet: Disklet): Disklet { + return { + ...disklet, + async getText(path: string): Promise { + const out = await disklet.getText(path) + if (fakeWorldTestConfig.readGate != null) { + await fakeWorldTestConfig.readGate + } + return out + }, + async setText(path: string, text: string): Promise { + const out = await disklet.setText(path, text) + if (fakeWorldTestConfig.writeGate != null) { + await fakeWorldTestConfig.writeGate + } + return out + } + } +} + export function makeFakeWorld( ios: PluginIos, logBackend: LogBackend, @@ -135,7 +168,7 @@ export function makeFakeWorld( const fakeIo = { ...io, - disklet: makeMemoryDisklet(), + disklet: makeGatedDisklet(makeMemoryDisklet()), fetch } From 1c2e1c29b2ff79d28847e20dfa84ccd0a6829d6f Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:15:31 -0700 Subject: [PATCH 10/10] Add deterministic cache and boot-window tests Every claim here is a timing claim, so the suites drive the ordering directly rather than sleeping: the fake plugin gets a test-controlled engine gate, the savers get a 50 ms throttle, and the fake world is relaunched from disk to prove a warm boot reads only what is on it. Coverage runs from cold-start equivalence and cached emission through engine-gated calls completing, failing, and being deleted mid-wait, the concurrency queue draining with front-of-queue bumps, the four two-device races the write-path audit found, the address and otherMethods caches, and a torn slot degrading to the older generation. A cache-coverage test asserts every EdgeCurrencyWallet property is classified, so a new property forces a caching decision. --- CHANGELOG.md | 10 + test/core/account/account-cache.test.ts | 1001 ++++++++++++ .../currency/wallet/engine-scheduler.test.ts | 437 ++++++ .../core/currency/wallet/wallet-cache.test.ts | 1347 +++++++++++++++++ test/fake/fake-currency-plugin.ts | 201 ++- 5 files changed, 2983 insertions(+), 13 deletions(-) create mode 100644 test/core/account/account-cache.test.ts create mode 100644 test/core/currency/wallet/engine-scheduler.test.ts create mode 100644 test/core/currency/wallet/wallet-cache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cbf4c5cd..78ad2821d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- added: Warm logins render the account and wallet list from an account-level cache before any engine starts +- added: Cached wallets start their engines through a limited-concurrency queue, with an opened wallet moved to the front +- added: Cached receive addresses are served before the engine loads, with `addressChanged` emitted on a later mismatch +- changed: `waitForCurrencyWallet` and `waitForAllWallets` resolve once the wallet object exists, possibly before its engine +- changed: `wallet.otherMethods` exposes delegating stubs for cached method names before the engine exists +- changed: `EdgeCurrencyWallet.balanceMap` keeps its identity when an unchanged balance is re-reported +- changed: Account-level engine methods wait for the wallet's engine instead of throwing before it loads +- fixed: Boot-window edits to custom tokens, enabled tokens, plugin settings and wallet states merge per field +- fixed: Plugin-settings writes merge into the on-disk file instead of rebuilding it from memory + ## 2.48.1 (2026-08-31) - fixed: Stop rebuilding the NYM mixFetch client on every request while its gateway is failing. Each attempt spawns a web worker holding megabytes of WASM that the library gives no way to terminate, so a poll loop retrying every few seconds exhausted the host's memory and killed the JS context, which on iOS reads to the user as being logged out. A failed setup now starts a cooldown that doubles up to five minutes. diff --git a/test/core/account/account-cache.test.ts b/test/core/account/account-cache.test.ts new file mode 100644 index 000000000..e2ccd7dde --- /dev/null +++ b/test/core/account/account-cache.test.ts @@ -0,0 +1,1001 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' +import { base64 } from 'rfc4648' + +import { + ACCOUNT_CACHE_FILES, + accountCacheSaverConfig +} from '../../../src/core/account/account-cache-file' +import { walletCacheLoaderHooks } from '../../../src/core/currency/wallet/wallet-cache-loader' +import { fakeWorldTestConfig } from '../../../src/core/fake/fake-world' +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 } +} + +/** + * Returns the newest readable account-cache slot as parsed JSON. The + * cache alternates between two slots, so a test that wants to inspect + * what was actually written has to pick the current generation. + */ +async function readAccountCache(account: EdgeAccount): Promise { + let best: any + for (const path of ACCOUNT_CACHE_FILES) { + try { + const parsed = JSON.parse(await account.localDisklet.getText(path)) + if (best == null || (parsed.sequence ?? 0) > (best.sequence ?? 0)) { + best = parsed + } + } catch (error: unknown) {} + } + if (best == null) throw new Error('No readable account cache') + return best +} + +describe('account cache', function () { + beforeEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + walletCacheLoaderHooks.onAccountSeed = undefined + walletCacheLoaderHooks.onBulkSeed = undefined + walletCacheLoaderHooks.onFallbackSeed = undefined + fakeWorldTestConfig.readGate = undefined + fakeWorldTestConfig.writeGate = undefined + accountCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + walletCacheLoaderHooks.onAccountSeed = undefined + walletCacheLoaderHooks.onBulkSeed = undefined + walletCacheLoaderHooks.onFallbackSeed = undefined + fakeWorldTestConfig.readGate = undefined + fakeWorldTestConfig.writeGate = undefined + accountCacheSaverConfig.throttleMs = 5000 + }) + + it('cold login without an account cache boots as on master', async function () { + this.timeout(15000) + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // A cold boot awaits loadBuiltinTokens before anything else, + // so a gated first login must not resolve, exactly as on master: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + let settled = false + const loginPromise = context + .loginWithPIN(fakeUser.username, fakeUser.pin) + .then(account => { + settled = true + return account + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + + // Releasing the gate lets the whole boot chain finish: + release() + const account = await loginPromise + const walletInfo = account.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('Broken test account') + const wallet = await account.waitForCurrencyWallet(walletInfo.id) + expect(wallet.name).equals('Fake Wallet') + + // The saver persists an account cache for the next login: + await snooze(SAVE_WAIT_MS) + await account.logout() + + // The next gated login resolves from that cache instead of blocking: + const { gate: gate2, release: release2 } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate2 + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account2.activeWalletIds.length).equals(2) + release2() + await account2.logout() + }) + + it('warm login emits the account before the deferred loads land', async function () { + this.timeout(15000) + const { context, walletIds, customTokenId } = await makeAccountCachedWorld({ + archiveSecond: true + }) + + // Hold the deferred loads (builtin tokens run at their head), + // so everything observable here comes from the account cache: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Wallet states seeded: the archived wallet stays out of the list: + expect([...account.activeWalletIds]).deep.equals([walletIds[0]]) + expect([...account.archivedWalletIds]).deep.equals([walletIds[1]]) + + // Custom tokens seeded (the class of data #703's saver lost): + const { customTokens } = account.currencyConfig.fakecoin + expect(customTokens[customTokenId]?.currencyCode).equals('CACHED') + + // The wallet emits from its own cache, engine-independent: + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + expect(wallet.name).equals('Cached Name') + + // Enabling a builtin token in the window pends until the builtin + // definitions load, instead of silently filtering the id away: + let tokensSettled = false + const tokensPromise = wallet + .changeEnabledTokenIds(['badf00d5']) + .then(() => { + tokensSettled = true + }) + await snooze(RACE_WAIT_MS) + expect(tokensSettled).equals(false) + + // The deferred loads land and overwrite authoritatively, and the + // cached token balance gains its currency code once the builtin + // token definitions arrive: + release() + await tokensPromise + expect([...wallet.enabledTokenIds]).deep.equals(['badf00d5']) + await pollUntil( + () => + account.currencyConfig.fakecoin.builtinTokens.badf00d5 != null && + wallet.balances.TOKEN != null + ) + await account.logout() + }) + + it('deferred loads overwrite stale cached wallet states', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // Make the cache disagree with the authoritative files: archive the + // second wallet, then unarchive it with the account saver slowed + // down, so the cache still says "archived" while the disk says not: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.changeWalletStates({ [walletIds[1]]: { archived: true } }) + await snooze(SAVE_WAIT_MS) + accountCacheSaverConfig.throttleMs = 5000 + await account.changeWalletStates({ [walletIds[1]]: { archived: false } }) + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + + // The warm login first shows the stale cached state: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account2.activeWalletIds.includes(walletIds[1])).equals(false) + + // ...then corrects once the authoritative load lands: + release() + await pollUntil(() => account2.activeWalletIds.includes(walletIds[1])) + await account2.logout() + }) + + it('warm login seeds every wallet in one bulk dispatch', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + const accountSeeds: string[] = [] + const bulkSeeds: string[][] = [] + const fallbackSeeds: string[] = [] + walletCacheLoaderHooks.onAccountSeed = id => accountSeeds.push(id) + walletCacheLoaderHooks.onBulkSeed = ids => bulkSeeds.push(ids) + walletCacheLoaderHooks.onFallbackSeed = id => fallbackSeeds.push(id) + + // Hold the engines, so everything observable is cache-seeded: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Every wallet gate opens from the single bulk tick, pre-engine, + // and the whole warm login costs exactly two seeding dispatches: + await pollUntil(() => + walletIds.every(id => account.currencyWallets[id] != null) + ) + expect(accountSeeds.length).equals(1) + expect(bulkSeeds.length).equals(1) + expect(sorted(bulkSeeds[0])).deep.equals(sorted(walletIds)) + expect(fallbackSeeds).deep.equals([]) + expect(account.currencyWallets[walletIds[0]].name).equals('Cached Name') + + release() + await account.logout() + }) + + it('a wallet activated after login seeds through the fallback read', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld({ + archiveSecond: true + }) + + const bulkSeeds: string[][] = [] + const fallbackSeeds: string[] = [] + walletCacheLoaderHooks.onBulkSeed = ids => bulkSeeds.push(ids) + walletCacheLoaderHooks.onFallbackSeed = id => fallbackSeeds.push(id) + + // Hold the engines the whole time; both wallets must emit from + // their caches alone: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Only the active wallet rides the bulk dispatch: + await pollUntil(() => account.currencyWallets[walletIds[0]] != null) + expect(bulkSeeds.length).equals(1) + expect(bulkSeeds[0]).deep.equals([walletIds[0]]) + + // The reactivated wallet seeds itself through its pixie's own read + // (changeWalletStates pends until the deferred loads add the repo): + await account.changeWalletStates({ [walletIds[1]]: { archived: false } }) + await pollUntil(() => account.currencyWallets[walletIds[1]] != null) + expect(fallbackSeeds).deep.equals([walletIds[1]]) + expect(bulkSeeds.length).equals(1) + + release() + await account.logout() + }) + + it('plugin settings loaded after an engine starts still reach it', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // Persist plugin settings the next warm login must deliver: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 4321 }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // Warm login with both the deferred loads and the engines gated: + const { gate: builtinGate, release: releaseBuiltin } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = builtinGate + const { gate: engineGate, release: releaseEngine } = createEngineGate() + fakePluginTestConfig.engineGate = engineGate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletIds[0]) + + // Let the deferred loads land while the engine is still gated, so + // the watcher sees the loaded settings with no engine to apply + // them to (plugin settings are deliberately not cached): + releaseBuiltin() + await pollUntil( + () => account2.currencyConfig.fakecoin.builtinTokens.badf00d5 != null + ) + await snooze(RACE_WAIT_MS) + + // The engine arrives late and must still receive those settings + // (the fake engine reports its configured balance only when + // changeUserSettings delivers it): + releaseEngine() + await pollUntil(() => wallet2.balanceMap.get(null) === '4321') + await account2.logout() + }) + + it('logout during a pending account cache save cancels the write', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // Slow the saver down so its write is still pending at logout: + accountCacheSaverConfig.throttleMs = 3000 + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.changeWalletStates({ [walletIds[1]]: { archived: true } }) + await account.logout() + + // The cancelled write must not have touched the cache: a gated + // warm login still shows both wallets active: + accountCacheSaverConfig.throttleMs = 50 + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(sorted([...account2.activeWalletIds])).deep.equals(sorted(walletIds)) + release() + await account2.logout() + }) + + it('fresh-process warm login boots from the cache alone', async function () { + this.timeout(15000) + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First session: decorate, then capture the local cache files a + // real device would still have on disk after the app closes: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletIds = [...account.activeWalletIds] + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + await account.waitForCurrencyWallet(walletIds[1]) + await wallet.renameWallet('Cached Name') + await snooze(SAVE_WAIT_MS) + + const accountRepoInfo = account.getFirstWalletInfo( + 'account-repo:co.airbitz.wallet' + ) + if (accountRepoInfo == null) throw new Error('Broken test account') + // One file carries the whole account, wallets included, so this + // fixture is the entire warm-boot state: + const extraFiles: { [path: string]: string } = {} + for (const path of ACCOUNT_CACHE_FILES) { + try { + extraFiles[localPath(accountRepoInfo.id, path)] = + await account.localDisklet.getText(path) + } catch (error: unknown) {} + } + await account.logout() + + // A brand-new context is a fresh app process: a fresh Redux store + // with no storage wallets, plus the files captured above. The + // gated login must still resolve from the cache (regression guard: + // an eager storage-wallet read here crashes the whole login): + const context2 = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true }, + extraFiles + }) + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const { gate: engineGate, release: releaseEngines } = createEngineGate() + fakePluginTestConfig.engineGate = engineGate + const account2 = await context2.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + const wallet2 = await account2.waitForCurrencyWallet(walletIds[0]) + expect(wallet2.name).equals('Cached Name') + + // Repo-backed calls made in the window pend instead of throwing + // (this store has never seen addStorageWallet run): + let syncSettled = false + const syncPromise = account2.sync().then(() => { + syncSettled = true + }) + await snooze(RACE_WAIT_MS) + expect(syncSettled).equals(false) + + // A custom token added during the boot window must reach the + // synced repo once the deferred loads land, not just Redux: + const tokenId = await account2.currencyConfig.fakecoin.addCustomToken({ + currencyCode: 'FRESH', + displayName: 'Fresh Token', + denominations: [{ multiplier: '100', name: 'FRESH' }], + networkLocation: { contractAddress: '0xFRE54' } + }) + release() + releaseEngines() + await syncPromise + await pollUntilAsync(async () => { + try { + const text = await account2.disklet.getText('CustomTokens.json') + return text.includes(tokenId) + } catch (error: unknown) { + return false + } + }) + expect( + account2.currencyConfig.fakecoin.customTokens[tokenId]?.currencyCode + ).equals('FRESH') + await account2.logout() + }) + + it('caches config otherMethods names and keeps the live surface', async function () { + this.timeout(15000) + const { context } = await makeAccountCachedWorld() + + // The plugin's method names reached the account cache file (the + // stub fallback consumes them if plugin loading ever defers past + // the account emit; today the live plugin always wins): + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await snooze(SAVE_WAIT_MS) + let sawMethodName = false + for (const path of ACCOUNT_CACHE_FILES) { + try { + const text = await account.localDisklet.getText(path) + if (text.includes('fakePluginMethod')) sawMethodName = true + } catch (error: unknown) {} + } + expect(sawMethodName).equals(true) + + // The live surface stays verbatim, including in the cache-seeded + // boot window: + const result = + await account.currencyConfig.fakecoin.otherMethods.fakePluginMethod( + 'config' + ) + expect(result).equals('fakePluginMethod called with: config') + await account.logout() + }) + + it('survives a torn write by booting from the other slot', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // Put a generation in each slot: the world's own save filled one, + // and this rename dirties the cache so the next save fills the + // other: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + await wallet.renameWallet('Second Generation') + await snooze(SAVE_WAIT_MS) + + // Truncate the NEWEST slot, which is what a kill part-way through + // a non-atomic write leaves behind on Android: + accountCacheSaverConfig.throttleMs = 5000 + let newestPath = ACCOUNT_CACHE_FILES[0] + let newestSequence = -1 + let newestText = '' + for (const path of ACCOUNT_CACHE_FILES) { + try { + const text = await account.localDisklet.getText(path) + const { sequence = 0 } = JSON.parse(text) + if (sequence > newestSequence) { + newestSequence = sequence + newestPath = path + newestText = text + } + } catch (error: unknown) {} + } + expect(newestSequence).greaterThan(0) + await account.localDisklet.setText( + newestPath, + newestText.slice(0, Math.floor(newestText.length / 2)) + ) + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + + // The older slot is intact, so the login still boots warm while + // the engines are held. Its wallet name is the pre-rename one, + // which is the whole trade: one generation of staleness instead + // of a cold boot: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletIds[0]) + expect(wallet2.name).not.equals(null) + release() + await account2.logout() + }) + + it('rejects a corrupt account cache file and falls back cold', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld() + + // Corrupt the account cache after the saver has settled: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await snooze(SAVE_WAIT_MS) + accountCacheSaverConfig.throttleMs = 5000 + for (const path of ACCOUNT_CACHE_FILES) { + await account.localDisklet.setText(path, '{ "version": 99 }') + } + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + + // A corrupt file means the cold path runs: a gated login blocks, + // exactly as with no cache at all: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + let settled = false + const loginPromise = context + .loginWithPIN(fakeUser.username, fakeUser.pin) + .then(account2 => { + settled = true + return account2 + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + + // Releasing the gate completes the boot and re-saves the cache: + release() + const account2 = await loginPromise + expect(sorted([...account2.activeWalletIds])).deep.equals(sorted(walletIds)) + await snooze(SAVE_WAIT_MS) + await account2.logout() + + // The rewritten file feeds the next gated login: + const { gate: gate3, release: release3 } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate3 + const account3 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account3.activeWalletIds.length).equals(2) + release3() + await account3.logout() + }) +}) + +describe('write-path staleness', function () { + beforeEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.publicKeyCheckGate = undefined + accountCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.publicKeyCheckGate = undefined + accountCacheSaverConfig.throttleMs = 5000 + }) + + /** + * Logs device A in and out once, so the background repo sync pulls + * device B's pushed changes into A's local repo copy. The account + * cache saver is stalled for the session, so A's account cache + * stays as it was: the repo is now ahead of the cache, exactly the + * state a warm boot walks into. + */ + async function pullOnDeviceA( + context: EdgeContext, + pulled: (account: EdgeAccount) => Promise + ): Promise { + accountCacheSaverConfig.throttleMs = 5000 + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.sync() + await pollUntilAsync(async () => await pulled(account)) + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + } + + it('a rename from another device applies after a local rename', async function () { + this.timeout(15000) + const { context, world, walletIds } = await makeAccountCachedWorld() + const [walletId] = walletIds + + // Device A renames and pushes. The rename starts a new write + // generation, so only a load that begins after it can apply: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.renameWallet('Local Name') + await wallet.sync() + + // Device B pulls that name, renames again, and pushes: + const contextB = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + const accountB = await contextB.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + const walletB = await accountB.waitForCurrencyWallet(walletId) + await walletB.sync() + await pollUntilAsync(async () => walletB.name === 'Local Name') + await walletB.renameWallet('Remote Name') + await walletB.sync() + await accountB.logout() + + // Device A's sync pulls the remote name. That load began after + // A's own write, so it applies instead of being held back as a + // stale read of the local rename: + await wallet.sync() + await pollUntilAsync(async () => wallet.name === 'Remote Name') + await account.logout() + await snooze(SAVE_WAIT_MS) + }) + + it('a name load that began before a local rename is dropped', async function () { + this.timeout(15000) + const { context, world, walletIds } = await makeAccountCachedWorld() + const [walletId] = walletIds + + // Device B renames and pushes, so device A's next sync reloads + // the name file: + const contextB = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + const accountB = await contextB.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + const walletB = await accountB.waitForCurrencyWallet(walletId) + await walletB.renameWallet('Remote Name') + await walletB.sync() + await accountB.logout() + + // Device A pulls the change, but its name load is parked on the + // value it already read. A rename lands in that window: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + const { gate, release } = createEngineGate() + fakeWorldTestConfig.readGate = gate + await wallet.sync() + await snooze(RACE_WAIT_MS) + fakeWorldTestConfig.readGate = undefined + await wallet.renameWallet('Local Name') + expect(wallet.name).equals('Local Name') + + // The parked load resolves with the remote name it read before + // the rename, and its generation is stale, so it does not apply: + release() + await snooze(RACE_WAIT_MS) + expect(wallet.name).equals('Local Name') + await account.logout() + await snooze(SAVE_WAIT_MS) + }) + + it('a cache write left in flight by a logout lands before the next login writes', async function () { + this.timeout(15000) + const { context } = await makeAccountCachedWorld() + + // Session A's write is parked after the disk took it. Session B + // logs in and makes its own change while it is parked: + const accountA = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletA = await accountA.waitForCurrencyWallet( + accountA.activeWalletIds[0] + ) + await snooze(SAVE_WAIT_MS) + const before = await readAccountCache(accountA) + await walletA.renameWallet('Session A Name') + + // Park the cache write the rename triggers, once the throttle + // fires and the disk has taken it: + const { gate, release } = createEngineGate() + fakeWorldTestConfig.writeGate = gate + await snooze(RACE_WAIT_MS) + fakeWorldTestConfig.writeGate = undefined + await accountA.logout() + + const accountB = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletB = await accountB.waitForCurrencyWallet( + accountB.activeWalletIds[0] + ) + await walletB.renameWallet('Session B Name') + await snooze(RACE_WAIT_MS) + + // B's first write waited behind A's, so the two generations land + // in different slots with consecutive sequence numbers, both + // newer than what A had written before the parked write: + release() + await snooze(SAVE_WAIT_MS) + const sequences: number[] = [] + for (const path of ACCOUNT_CACHE_FILES) { + try { + const cache = JSON.parse(await accountB.localDisklet.getText(path)) + sequences.push(cache.sequence) + } catch (error: unknown) {} + } + sequences.sort((a, b) => a - b) + expect(sequences.length).equals(2) + expect(sequences[0]).greaterThan(before.sequence) + expect(sequences[1]).equals(sequences[0] + 1) + const latest = await readAccountCache(accountB) + expect(latest.wallets[walletB.id].name).equals('Session B Name') + await accountB.logout() + await snooze(SAVE_WAIT_MS) + }) + + it('keeps custom tokens from another device across a boot-window edit', async function () { + this.timeout(15000) + const { context, customTokenId } = await makeAccountCachedWorld() + + // Put a second token in the synced repo but not in the account + // cache, by stalling the cache saver for a session. This is the + // divergence a second device's addCustomToken leaves behind once + // sync delivers it: the repo is ahead of this device's cache: + accountCacheSaverConfig.throttleMs = 5000 + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const remoteTokenId = await account1.currencyConfig.fakecoin.addCustomToken( + { + currencyCode: 'REMOTE', + displayName: 'Remote Token', + denominations: [{ multiplier: '100', name: 'REMOTE' }], + networkLocation: { contractAddress: '0xREM07E' } + } + ) + await pollUntilAsync(async () => { + const text = await account1.disklet.getText('CustomTokens.json') + return text.includes(remoteTokenId) + }) + // Push the write out of the repo's pending-changes folder, so the + // warm boot's background sync has nothing in flight: + await account1.sync() + await account1.logout() + accountCacheSaverConfig.throttleMs = 50 + + // Device A warm-boots on a cache that has never seen the remote + // token, and adds its own token in the boot window: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const localTokenId = await account.currencyConfig.fakecoin.addCustomToken({ + currencyCode: 'LOCAL', + displayName: 'Local Token', + denominations: [{ multiplier: '100', name: 'LOCAL' }], + networkLocation: { contractAddress: '0xL0CA1' } + }) + + // The saver must not write the cache-seeded map to disk + // (that write is what would delete the remote token): + await snooze(RACE_WAIT_MS) + const preLoad = await account.disklet.getText('CustomTokens.json') + expect(preLoad.includes(localTokenId)).equals(false) + expect(preLoad.includes(remoteTokenId)).equals(true) + + // The load lands, merges, and the saver writes all three tokens: + release() + await pollUntilAsync(async () => { + const text = await account.disklet.getText('CustomTokens.json') + return ( + text.includes(customTokenId) && + text.includes(remoteTokenId) && + text.includes(localTokenId) + ) + }) + const { customTokens } = account.currencyConfig.fakecoin + expect(customTokens[customTokenId]?.currencyCode).equals('CACHED') + expect(customTokens[remoteTokenId]?.currencyCode).equals('REMOTE') + expect(customTokens[localTokenId]?.currencyCode).equals('LOCAL') + await account.logout() + + // Wait for the cache savers to settle before the world closes, + // so nothing writes into a destroyed context: + await snooze(SAVE_WAIT_MS) + }) + + it('keeps enabled tokens from another device across a boot-window toggle', async function () { + this.timeout(15000) + const { context, world, walletIds, customTokenId } = + await makeAccountCachedWorld() + + // Device B enables the builtin token and syncs it to the server: + const contextB = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + const accountB = await contextB.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + const walletB = await accountB.waitForCurrencyWallet(walletIds[0]) + await walletB.changeEnabledTokenIds(['badf00d5']) + await pollUntilAsync(async () => { + const text = await walletB.disklet.getText('Tokens.json') + return text.includes('badf00d5') + }) + await walletB.sync() + await accountB.logout() + + // Device A pulls the wallet repo. The wallet reloads its token + // file mid-session, so stall the wallet cache saver to keep the + // cache on the old empty list (the divergence under test): + await pullOnDeviceA(context, async account => { + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + const text = await wallet.disklet.getText('Tokens.json') + return text.includes('badf00d5') + }) + + // Device A warm-boots on a cache with an empty enabled list. The + // public-key gate holds the wallet's file loads, so the list the + // user acts on is deterministically the stale cached one: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const { gate: pkGate, release: releasePk } = createEngineGate() + fakePluginTestConfig.publicKeyCheckGate = pkGate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + expect([...wallet.enabledTokenIds]).deep.equals([]) + + // Toggling the custom token pends on the builtin definitions: + let toggleSettled = false + const togglePromise = wallet + .changeEnabledTokenIds([customTokenId]) + .then(() => { + toggleSettled = true + }) + await snooze(RACE_WAIT_MS) + expect(toggleSettled).equals(false) + + // The toggle lands against the stale list... + release() + await togglePromise + expect([...wallet.enabledTokenIds]).deep.equals([customTokenId]) + + // ...and the racing load preserves it instead of erasing it, + // while the toggle preserves the other device's enablement: + releasePk() + await pollUntil( + () => + wallet.enabledTokenIds.includes('badf00d5') && + wallet.enabledTokenIds.includes(customTokenId) + ) + await account.logout() + await snooze(SAVE_WAIT_MS) + }) + + it('keeps a wallet-state change made against a stale cache', async function () { + this.timeout(15000) + const { context, walletIds } = await makeAccountCachedWorld({ + archiveSecond: true + }) + + // Put an "unarchived" record in the synced repo while the cache + // still says "archived", by stalling the cache saver for a + // session. This is the divergence a second device's unarchive + // leaves behind once sync delivers it: + accountCacheSaverConfig.throttleMs = 5000 + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account1.changeWalletStates({ [walletIds[1]]: { archived: false } }) + // Push the write out of the repo's pending-changes folder, so the + // warm boot's background sync has nothing in flight: + await account1.sync() + await account1.logout() + accountCacheSaverConfig.throttleMs = 50 + + // Device A warm-boots on a cache that still says "archived", and + // the user re-affirms that. Against the stale cache this change + // looks like a no-op, so it must wait for the load instead: + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account.archivedWalletIds.includes(walletIds[1])).equals(true) + let changeSettled = false + const changePromise = account + .changeWalletStates({ [walletIds[1]]: { archived: true } }) + .then(() => { + changeSettled = true + }) + await snooze(RACE_WAIT_MS) + expect(changeSettled).equals(false) + + // The load lands with device B's "unarchived" record, and the + // change applies on top of it instead of silently reverting: + release() + await changePromise + expect(account.archivedWalletIds.includes(walletIds[1])).equals(true) + expect(account.activeWalletIds.includes(walletIds[1])).equals(false) + await account.logout() + await snooze(SAVE_WAIT_MS) + }) + + it('keeps plugin settings from another device across a local change', async function () { + this.timeout(15000) + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const plugins = { fakecoin: true, fakeswap: true } + const context = await world.makeEdgeContext({ ...contextOptions, plugins }) + const contextB = await world.makeEdgeContext({ ...contextOptions, plugins }) + + // Device A logs in first, so its Redux settings predate B's write: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Device B changes the currency plugin's settings and syncs: + const accountB = await contextB.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + await accountB.currencyConfig.fakecoin.changeUserSettings({ + remoteFlag: true + }) + await accountB.sync() + await accountB.logout() + + // Device A pulls the repo (no settings reload runs), then changes + // a different plugin's settings. The writer must merge into the + // freshly read file, not rebuild it from stale Redux: + await account.sync() + await account.swapConfig.fakeswap.changeUserSettings({ localFlag: true }) + const text = await account.disklet.getText('PluginSettings.json') + expect(text.includes('remoteFlag')).equals(true) + expect(text.includes('localFlag')).equals(true) + await account.logout() + + // A fresh login sees both settings: + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + expect(account2.currencyConfig.fakecoin.userSettings).deep.equals({ + remoteFlag: true + }) + expect(account2.swapConfig.fakeswap.userSettings).deep.equals({ + localFlag: true + }) + await account2.logout() + await snooze(SAVE_WAIT_MS) + }) +}) + +/** The disklet path of a file on a wallet's or account's local storage. */ +function localPath(id: string, file: string): string { + return `local/${base58.stringify(base64.parse(id))}/${file}` +} + +/** Returns a sorted copy, so order-insensitive comparisons read cleanly. */ +function sorted(list: string[]): string[] { + return [...list].sort((a, b) => a.localeCompare(b)) +} + +/** Polls until `condition` holds, or fails the test after ~5s. */ +async function pollUntil(condition: () => boolean): Promise { + for (let i = 0; i < 100; ++i) { + if (condition()) return + await snooze(50) + } + throw new Error('Timed out waiting for a test condition') +} + +/** Polls an async condition, or fails the test after ~5s. */ +async function pollUntilAsync( + condition: () => Promise +): Promise { + for (let i = 0; i < 100; ++i) { + if (await condition()) return + await snooze(50) + } + throw new Error('Timed out waiting for a test condition') +} diff --git a/test/core/currency/wallet/engine-scheduler.test.ts b/test/core/currency/wallet/engine-scheduler.test.ts new file mode 100644 index 000000000..e1de42eed --- /dev/null +++ b/test/core/currency/wallet/engine-scheduler.test.ts @@ -0,0 +1,437 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' + +import { + ACCOUNT_CACHE_FILES, + accountCacheSaverConfig +} from '../../../../src/core/account/account-cache-file' +import { + engineSchedulerConfig, + getEngineScheduler +} from '../../../../src/core/currency/wallet/engine-scheduler' +import { EdgeContext, makeFakeEdgeWorld } from '../../../../src/index' +import { snooze } from '../../../../src/util/snooze' +import { + createEngineGate, + fakePluginTestConfig +} from '../../../fake/fake-currency-plugin' +import { fakeUser } from '../../../fake/fake-user' + +const contextOptions = { apiKey: '', appId: '', deviceDescription: 'iphone12' } +const quiet = { onLog() {} } + +// Generous wait for the throttled cache saver (50ms in tests) to write: +const SAVE_WAIT_MS = 300 + +// Short wait to prove something has *not* happened: +const RACE_WAIT_MS = 150 + +interface MultiWalletWorld { + context: EdgeContext + walletIds: string[] +} + +/** + * Logs in once, pads the account out to at least `count` fakecoin + * wallets, waits for their cache files to persist, and logs out. + * The next login on the returned context is a warm start where every + * wallet's engine startup enters the scheduler queue. The returned + * ids cover EVERY wallet in the account (the fake user starts with + * two), so length comparisons against engine-creation counts hold. + */ +async function makeMultiWalletWorld(count: number): Promise { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletIds = account.activeWalletIds.filter( + walletId => account.getWalletInfo(walletId)?.type === 'wallet:fakecoin' + ) + if (walletIds.length !== account.activeWalletIds.length) { + throw new Error('Broken test account: unexpected non-fakecoin wallets') + } + while (walletIds.length < count) { + const wallet = await account.createCurrencyWallet('wallet:fakecoin', { + fiatCurrencyCode: 'iso:USD', + name: `Wallet ${walletIds.length}` + }) + walletIds.push(wallet.id) + } + + // Let the throttled saver persist each wallet's cache file: + await snooze(SAVE_WAIT_MS) + await account.logout() + + return { context, walletIds } +} + +/** Returns a sorted copy, so order-insensitive comparisons read cleanly. */ +function sorted(list: string[]): string[] { + return [...list].sort((a, b) => a.localeCompare(b)) +} + +/** Polls until `condition` holds, or fails the test after ~5s. */ +async function pollUntil(condition: () => boolean): Promise { + for (let i = 0; i < 100; ++i) { + if (condition()) return + await snooze(50) + } + throw new Error('Timed out waiting for a test condition') +} + +describe('engine scheduler', function () { + beforeEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.onEngineCreate = undefined + accountCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.onEngineCreate = undefined + accountCacheSaverConfig.throttleMs = 5000 + engineSchedulerConfig.concurrency = 8 + engineSchedulerConfig.slotTimeoutMs = 30000 + engineSchedulerConfig.stickyBumpTtlMs = 30000 + }) + + it('runs cached startup work at the configured concurrency', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(3) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Every wallet emits from its cache while the queue is stuck: + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + + // The gate blocks the slot holder inside `makeCurrencyEngine`, + // so with one slot, exactly one creation can begin. Wait for it + // (a fixed sleep would flake on slow machines), then hold long + // enough to prove no second creation sneaks through: + await pollUntil(() => created.length === 1) + await snooze(RACE_WAIT_MS) + expect(created.length).equals(1) + + // Releasing the gate drains the queue one wallet at a time, + // and every engine still starts: + release() + await pollUntil(() => created.length === walletIds.length) + expect(sorted(created)).deep.equals(sorted(walletIds)) + await account.logout() + }) + + it('waitForCurrencyWallet moves a queued wallet to the front', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(4) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + // Pick the wallet at the back of the line and ask for it: + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const target = queued[queued.length - 1] + await account.waitForCurrencyWallet(target) + + // Once the slot frees up, the bumped wallet goes next: + release() + await pollUntil(() => created.length === walletIds.length) + expect(created[1]).equals(target) + await account.logout() + }) + + it('logout releases in-flight startup slots for the next login', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(2) + + // One slot, held open inside the first wallet's engine creation: + 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(() => created.length === 1) + await account.logout() + + // The old session's startup is still parked on the gate. The next + // login must not wait behind it, so its first wallet reaches + // engine creation while the gate is still held: + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => created.length >= 2) + expect(walletIds).contains(created[1]) + + release() + await account2.logout() + }) + + it('cold wallets skip the queue entirely', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(3) + + // Strip every wallet out of the account cache, so the account + // still boots warm but no wallet has anything cached. Let the + // login's own cache save settle first, so nothing rewrites the + // file afterwards: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForAllWallets() + await snooze(SAVE_WAIT_MS) + accountCacheSaverConfig.throttleMs = 5000 + for (const path of ACCOUNT_CACHE_FILES) { + try { + const cache = JSON.parse(await account.localDisklet.getText(path)) + await account.localDisklet.setText( + path, + JSON.stringify({ ...cache, wallets: {} }) + ) + } catch (error: unknown) {} + } + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + // Cold wallets cannot emit until their startup work runs, + // so they bypass the queue: every creation begins even though + // the single queue slot never opens: + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => created.length === walletIds.length) + + release() + await account2.waitForAllWallets() + await account2.logout() + }) + + it('a wallet deleted while queued gives up its place in line', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(3) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + // Delete a wallet that is still waiting in the queue: + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const deleted = queued[0] + await account.changeWalletStates({ [deleted]: { deleted: true } }) + + // The queue keeps moving: the deleted wallet never creates an + // engine, and the remaining wallets still get theirs: + release() + const survivors = walletIds.filter(walletId => walletId !== deleted) + await pollUntil(() => created.length === survivors.length) + expect(sorted(created)).deep.equals(sorted(survivors)) + await snooze(RACE_WAIT_MS) + expect(created.includes(deleted)).equals(false) + await account.logout() + }) + + it('admits wallets up to the configured concurrency', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(3) + + engineSchedulerConfig.concurrency = 2 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // With two slots and three wallets, exactly two creations begin: + await pollUntil(() => created.length === 2) + await snooze(RACE_WAIT_MS) + expect(created.length).equals(2) + + release() + await pollUntil(() => created.length === walletIds.length) + await account.logout() + }) + + it('an engine-backed method call bumps a queued wallet', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(4) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + // makeSpend waits on the engine internally, which bumps the queue: + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const target = queued[queued.length - 1] + const spendPromise = account.currencyWallets[target].makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }) + + release() + await pollUntil(() => created.length === walletIds.length) + expect(created[1]).equals(target) + await spendPromise + await account.logout() + }) + + it('a storage-backed method call bumps a queued wallet', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(4) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + // The repo loads inside the queued startup work, so a rename + // pends on it and bumps this wallet to the front: + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const target = queued[queued.length - 1] + const renamePromise = + account.currencyWallets[target].renameWallet('Bumped Name') + + release() + await pollUntil(() => created.length === walletIds.length) + expect(created[1]).equals(target) + await renamePromise + expect(account.currencyWallets[target].name).equals('Bumped Name') + await account.logout() + }) + + it('changePaused(false) bumps a queued wallet', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(4) + + engineSchedulerConfig.concurrency = 1 + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await pollUntil(() => + walletIds.every(walletId => account.currencyWallets[walletId] != null) + ) + await pollUntil(() => created.length === 1) + + const queued = walletIds.filter(walletId => !created.includes(walletId)) + const target = queued[queued.length - 1] + await account.currencyWallets[target].changePaused(false) + + release() + await pollUntil(() => created.length === walletIds.length) + expect(created[1]).equals(target) + await account.logout() + }) + + it('a bump before the wallet reaches the queue still counts', async function () { + engineSchedulerConfig.concurrency = 1 + const scheduler = getEngineScheduler({}) + + const releaseFirst = await scheduler.acquire('first') + + // Ask for a wallet that has not reached the queue yet: + scheduler.bump('wanted') + + const order: string[] = [] + const otherPromise = scheduler.acquire('other').then(release => { + order.push('other') + return release + }) + const wantedPromise = scheduler.acquire('wanted').then(release => { + order.push('wanted') + return release + }) + + // "other" queued first, but the sticky bump front-loads "wanted": + releaseFirst() + const releaseWanted = await wantedPromise + expect(order).deep.equals(['wanted']) + releaseWanted() + const releaseOther = await otherPromise + expect(order).deep.equals(['wanted', 'other']) + releaseOther() + }) + + it('the watchdog frees a slot held too long', async function () { + engineSchedulerConfig.concurrency = 1 + engineSchedulerConfig.slotTimeoutMs = 50 + const scheduler = getEngineScheduler({}) + + let timedOut = false + await scheduler.acquire('wedged', () => { + timedOut = true + }) + + // Never release "wedged"; the watchdog frees the slot anyway: + const release = await scheduler.acquire('next') + expect(timedOut).equals(true) + release() + }) + + it('unchanged balances keep the same balanceMap object', async function () { + this.timeout(15000) + const { context, walletIds } = await makeMultiWalletWorld(1) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletIds[0]) + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 20 }) + await pollUntil(() => wallet.balanceMap.get(null) === '20') + + // Re-reporting the same balance must not produce a new map: + const before = wallet.balanceMap + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 20 }) + await snooze(RACE_WAIT_MS) + expect(wallet.balanceMap).equals(before) + + // A real change still lands: + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 21 }) + await pollUntil(() => wallet.balanceMap.get(null) === '21') + expect(wallet.balanceMap).not.equals(before) + await account.logout() + }) +}) diff --git a/test/core/currency/wallet/wallet-cache.test.ts b/test/core/currency/wallet/wallet-cache.test.ts new file mode 100644 index 000000000..a77457609 --- /dev/null +++ b/test/core/currency/wallet/wallet-cache.test.ts @@ -0,0 +1,1347 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' + +import { + ACCOUNT_CACHE_FILES, + accountCacheSaverConfig +} from '../../../../src/core/account/account-cache-file' +import { walletCacheLoaderHooks } from '../../../../src/core/currency/wallet/wallet-cache-loader' +import { fakeWorldTestConfig } from '../../../../src/core/fake/fake-world' +import { + EdgeAccount, + EdgeContext, + EdgeCurrencyEngineCallbacks, + EdgeCurrencyWallet, + EdgeFakeWorld, + EdgeStakingStatus, + 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 + world: EdgeFakeWorld +} + +/** + * 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.setFiatCurrencyCode('iso:USD') + 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, world } +} + +/** + * Returns the newest readable account-cache slot as raw text. The + * cache alternates between two slots, so a test that wants to inspect + * what was actually written has to pick the current generation. + */ +async function readAccountCache(account: EdgeAccount): Promise { + let best: any + for (const path of ACCOUNT_CACHE_FILES) { + try { + const parsed = JSON.parse(await account.localDisklet.getText(path)) + // A version-1 file predates `sequence` and is always older: + if (best == null || (parsed.sequence ?? 0) > (best.sequence ?? 0)) { + best = parsed + } + } catch (error: unknown) {} + } + if (best == null) throw new Error('No readable account cache') + return best +} + +describe('wallet cache', function () { + beforeEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.freshAddressPatch = undefined + fakePluginTestConfig.omitEngineOtherMethods = undefined + fakePluginTestConfig.onEngineKill = undefined + fakePluginTestConfig.onEngineCreate = undefined + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.publicKeyCheckGate = undefined + fakePluginTestConfig.legacyTokenPlugin = undefined + fakePluginTestConfig.failEngineFor = undefined + fakePluginTestConfig.onEngineCallbacks = undefined + fakePluginTestConfig.stakingStatusHook = undefined + fakeWorldTestConfig.readGate = undefined + walletCacheLoaderHooks.fallbackSeedGate = undefined + walletCacheLoaderHooks.onFallbackSeed = undefined + accountCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.freshAddressPatch = undefined + fakePluginTestConfig.omitEngineOtherMethods = undefined + fakePluginTestConfig.onEngineKill = undefined + fakePluginTestConfig.onEngineCreate = undefined + fakePluginTestConfig.builtinTokensGate = undefined + fakePluginTestConfig.publicKeyCheckGate = undefined + fakePluginTestConfig.legacyTokenPlugin = undefined + fakePluginTestConfig.failEngineFor = undefined + fakePluginTestConfig.onEngineCallbacks = undefined + fakePluginTestConfig.stakingStatusHook = undefined + fakeWorldTestConfig.readGate = undefined + walletCacheLoaderHooks.fallbackSeedGate = undefined + walletCacheLoaderHooks.onFallbackSeed = undefined + accountCacheSaverConfig.throttleMs = 5000 + }) + + it('cold login without cache files matches master behavior', async function () { + this.timeout(15000) + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First-ever login on this device, with engine creation blocked: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('Broken test account') + + // With no cache, the wallet must not emit while the engine is blocked: + await snooze(RACE_WAIT_MS) + expect(account.currencyWallets[walletInfo.id]).equals(undefined) + + // Releasing the engine lets the wallet finish loading as on master: + release() + const wallet = await account.waitForCurrencyWallet(walletInfo.id) + expect(wallet.name).equals('Fake Wallet') + await account.logout() + }) + + it('warm login emits cached wallet before the engine exists', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // The wallet emits from the cache while the engine is still blocked: + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.name).equals('Cached Name') + expect(wallet.fiatCurrencyCode).equals('iso:USD') + expect(wallet.enabledTokenIds).deep.equals(['badf00d5']) + expect(wallet.balanceMap.get(null)).equals('12345') + expect(wallet.balances.FAKE).equals('12345') + expect(wallet.balances.TOKEN).equals('45') + + release() + await account.logout() + }) + + it('live engine data overwrites cached values on the same object', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.balances.FAKE).equals('12345') + + release() + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 777 }) + await snooze(SAVE_WAIT_MS) + + // Live data lands on the very same wallet object: + expect(wallet.balances.FAKE).equals('777') + expect(account.currencyWallets[walletId]).equals(wallet) + await account.logout() + }) + + it('makeSpend pends during the cache window and then completes', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + let settled = false + const spendPromise = wallet + .makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }) + .then(tx => { + settled = true + return tx + }) + + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + + release() + const tx = await spendPromise + expect(tx.txid).equals('spend') + await account.logout() + }) + + it('engine failure rejects calls pending on the engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, fail } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + const spendPromise = wallet.makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }) + + fail(new Error('Engine exploded')) + await expectRejection(spendPromise, 'Error: Engine exploded') + await account.logout() + }) + + it('storage-backed methods survive an engine failure', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, fail } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + fail(new Error('Engine exploded')) + + // Engine-backed methods reject, but the repo is healthy, + // so storage-backed methods keep working: + await expectRejection( + wallet.makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }), + 'Error: Engine exploded' + ) + await wallet.renameWallet('Renamed After Failure') + expect(wallet.name).equals('Renamed After Failure') + await account.logout() + }) + + it('deleting the wallet rejects calls pending on the engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + const spendPromise = wallet.makeSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }] + }) + + await account.changeWalletStates({ [walletId]: { deleted: true } }) + + // The pixie tree tears down, so the pending call must reject, + // not dangle forever: + await expectRejection(spendPromise) + release() + await account.logout() + }) + + it('an engine that finishes creating after deletion is killed', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Hold engine creation open so the wallet can be deleted while + // `makeCurrencyEngine` is still in flight: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const killed: string[] = [] + fakePluginTestConfig.onEngineKill = id => killed.push(id) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForCurrencyWallet(walletId) + await account.changeWalletStates({ [walletId]: { deleted: true } }) + + // `engineStarted`'s destroy has already run with no engine to + // kill, so the startup block itself has to clean this one up: + release() + await snooze(250) + expect(killed).deep.equals([walletId]) + expect(account.currencyWallets[walletId]).equals(undefined) + + await account.logout() + }) + + it('logout during engine startup does not error on account state', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Park the startup block inside `getPublicWalletInfo`, which sits + // after the account API has emitted and before the block reads the + // account's tokens and settings: + const { gate, release } = createEngineGate() + fakePluginTestConfig.publicKeyCheckGate = gate + + const errors: unknown[] = [] + const unsubscribe = context.on('error', error => errors.push(error)) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForCurrencyWallet(walletId) + + // Log out while the block is parked, so the account is gone from + // Redux by the time it resumes and reaches for its settings: + await account.logout() + release() + await snooze(250) + + unsubscribe() + expect(errors).deep.equals([]) + }) + + it('logout during engine startup swallows a late startup failure', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Park the startup block inside engine creation, log out, and + // then fail the creation: the wallet is gone, so the failure is + // teardown and must not surface as an error: + const { gate, fail } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const errors: unknown[] = [] + const unsubscribe = context.on('error', error => errors.push(error)) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForCurrencyWallet(walletId) + await account.logout() + fail(new Error('Engine exploded after logout')) + await snooze(250) + + unsubscribe() + expect(errors).deep.equals([]) + }) + + it('holds engine startup until the deferred account load lands', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Hold the deferred account load open. The wallet still emits from + // cache, but its engine must not be built against account state + // that has not loaded: an engine created with empty `userSettings` + // opens connections before a privacy setting can apply. + const { gate, release } = createEngineGate() + fakePluginTestConfig.builtinTokensGate = gate + const created: string[] = [] + fakePluginTestConfig.onEngineCreate = id => created.push(id) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.name).equals('Cached Name') + + await snooze(RACE_WAIT_MS) + expect(created).deep.equals([]) + + release() + await snooze(SAVE_WAIT_MS) + expect(created).contains(walletId) + + await account.logout() + }) + + it('renameWallet during the cache window updates Redux and the cache file', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + // The repo loads before the engine, so renames work in the window: + await wallet.renameWallet('Renamed In Window') + expect(wallet.name).equals('Renamed In Window') + + // The saver picks up the change: + await snooze(SAVE_WAIT_MS) + release() + await account.logout() + + // The next gated login sees the new name from the cache: + const { gate: gate2, release: release2 } = createEngineGate() + fakePluginTestConfig.engineGate = gate2 + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.name).equals('Renamed In Window') + release2() + await account2.logout() + }) + + it('logout during a pending throttled save cancels the write', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Slow the saver down so its write is still pending at logout: + accountCacheSaverConfig.throttleMs = 3000 + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.renameWallet('Ghost Name') + await account.logout() + + // The cancelled write must not have touched the cache: + accountCacheSaverConfig.throttleMs = 50 + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.name).equals('Cached Name') + release() + await account2.logout() + }) + + it('caches a token toggle made before the token file loads', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Park the startup block before its file loads, toggle a token + // on the cache-seeded wallet, and let the saver write once: + const { gate, release } = createEngineGate() + fakePluginTestConfig.publicKeyCheckGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.changeEnabledTokenIds([]) + await snooze(SAVE_WAIT_MS) + + // The load lands and merges to the same list. That is a new write + // for the cache, which carried the pre-toggle entry until now: + release() + await snooze(SAVE_WAIT_MS) + const cache = await readAccountCache(account) + expect(cache.wallets[walletId].enabledTokenIds).deep.equals([]) + await account.logout() + }) + + it('keeps the cache entry of a wallet whose files have not loaded', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Park this wallet's startup before its file loads, so the saver + // skips it on every write of this session; the other wallet's + // loads still land and trigger writes: + const { gate, release } = createEngineGate() + fakePluginTestConfig.publicKeyCheckGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.name).equals('Cached Name') + await snooze(SAVE_WAIT_MS) + + // A skipped wallet keeps the entry it already had, even though it + // has no explicit entry in `walletStates`: + const cache = await readAccountCache(account) + expect(cache.wallets[walletId]?.name).equals('Cached Name') + release() + await account.logout() + }) + + it('keeps writing a wallet to the cache after a resync', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await snooze(SAVE_WAIT_MS) + + // A resync clears the engine state, including `tokenFileLoaded`, + // but the wallet's enabled list and files are still loaded. A + // change made afterwards must still reach the cache: + await wallet.resyncBlockchain() + await wallet.renameWallet('After Resync') + await snooze(SAVE_WAIT_MS) + const cache = await readAccountCache(account) + expect(cache.wallets[walletId].name).equals('After Resync') + await account.logout() + }) + + it('keeps the cached token list when no token file is readable', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Break both token files, so the next startup reads neither: + const accountA = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletA = await accountA.waitForCurrencyWallet(walletId) + await walletA.disklet.setText('Tokens.json', 'not json') + await walletA.disklet.setText('EnabledTokens.json', 'not json') + await accountA.logout() + + // The cache still names the token, and an unreadable file is no + // reason to drop it from the wallet or from the next cache write: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.enabledTokenIds).deep.equals(['badf00d5']) + await wallet.renameWallet('After Load') + await snooze(SAVE_WAIT_MS) + expect(wallet.enabledTokenIds).deep.equals(['badf00d5']) + const cache = await readAccountCache(account) + expect(cache.wallets[walletId].name).equals('After Load') + expect(cache.wallets[walletId].enabledTokenIds).deep.equals(['badf00d5']) + await account.logout() + }) + + it('adds a legacy-plugin token while the engines are still queued', async function () { + this.timeout(15000) + const { context } = await makeCachedWorld() + + // A legacy plugin validates tokens through a running engine. On a + // warm login the wallets exist before their engines, so the add + // must wait for one instead of failing: + fakePluginTestConfig.legacyTokenPlugin = true + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForAllWallets() + + let settled = false + const pending = account.currencyConfig.fakecoin.addCustomToken({ + currencyCode: 'LEGACY', + displayName: 'Legacy Token', + denominations: [{ multiplier: '100', name: 'LEGACY' }], + networkLocation: { contractAddress: '0x1e6ac7' } + }) + pending.then( + () => (settled = true), + () => (settled = true) + ) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + + release() + expect(await pending).equals('1e6ac7') + await account.logout() + }) + + it('a legacy-plugin token add skips a failed wallet for a queued one', async function () { + this.timeout(15000) + const { context } = await makeCachedWorld() + + // The wait used to pick the plugin's first wallet. Learn which one + // that is, then fail its engine while the other wallet stays + // queued behind the gate: + const probe = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const [firstId] = probe.activeWalletIds + await probe.logout() + + fakePluginTestConfig.legacyTokenPlugin = true + fakePluginTestConfig.failEngineFor = firstId + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForAllWallets() + await snooze(RACE_WAIT_MS) + + let settled = false + const pending = account.currencyConfig.fakecoin.addCustomToken({ + currencyCode: 'LEGACY', + displayName: 'Legacy Token', + denominations: [{ multiplier: '100', name: 'LEGACY' }], + networkLocation: { contractAddress: '0x1e6ac7' } + }) + pending.then( + () => (settled = true), + () => (settled = true) + ) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + + release() + expect(await pending).equals('1e6ac7') + await account.logout() + }) + + it('a fallback read that outlives its logout does not seed the next session', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // A wallet created after boot and archived seeds through the + // fallback read when it comes back. Park session A's pixie on the + // seed it read, and log out with it parked: + const accountA = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await accountA.waitForCurrencyWallet(walletId) + const created = await accountA.createCurrencyWallet('wallet:fakecoin', { + fiatCurrencyCode: 'iso:USD', + name: 'Session A Name' + }) + await snooze(SAVE_WAIT_MS) + await accountA.changeWalletStates({ [created.id]: { archived: true } }) + await snooze(RACE_WAIT_MS) + const seeded: string[] = [] + walletCacheLoaderHooks.onFallbackSeed = id => seeded.push(id) + const { gate, release } = createEngineGate() + walletCacheLoaderHooks.fallbackSeedGate = gate + await accountA.changeWalletStates({ [created.id]: { archived: false } }) + await snooze(RACE_WAIT_MS) + walletCacheLoaderHooks.fallbackSeedGate = undefined + expect(seeded).deep.equals([]) + await accountA.logout() + + // Session B boots the wallet from the bulk seed and renames it. + // Session A's read then resumes into a destroyed pixie: + const accountB = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletB = await accountB.waitForCurrencyWallet(created.id) + await walletB.renameWallet('Session B Name') + release() + await snooze(RACE_WAIT_MS) + expect(walletB.name).equals('Session B Name') + expect(seeded).deep.equals([]) + await accountB.logout() + }) + + it('startup loads that outlive a logout do not touch the next session', async function () { + this.timeout(15000) + const { context, walletId, world } = await makeCachedWorld() + + // Session A boots from cache with its engine held, then parks its + // fiat-file load on the value it already read, and logs out: + const engine = createEngineGate() + fakePluginTestConfig.engineGate = engine.gate + const accountA = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletA = await accountA.waitForCurrencyWallet(walletId) + expect(walletA.fiatCurrencyCode).equals('iso:USD') + const disk = createEngineGate() + fakeWorldTestConfig.readGate = disk.gate + engine.release() + await snooze(RACE_WAIT_MS) + fakeWorldTestConfig.readGate = undefined + fakePluginTestConfig.engineGate = undefined + await accountA.logout() + + // Another device changes the fiat code and pushes it: + const contextB = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + const accountB = await contextB.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + const walletB = await accountB.waitForCurrencyWallet(walletId) + await walletB.setFiatCurrencyCode('iso:EUR') + await walletB.sync() + await accountB.logout() + + // The next session on this device pulls that change. Session A's + // parked load then resumes with the code it read before, under the + // same wallet id, and must not apply to this session: + const accountC = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletC = await accountC.waitForCurrencyWallet(walletId) + await walletC.sync() + for (let i = 0; i < 20 && walletC.fiatCurrencyCode !== 'iso:EUR'; ++i) { + await snooze(RACE_WAIT_MS) + } + expect(walletC.fiatCurrencyCode).equals('iso:EUR') + disk.release() + await snooze(RACE_WAIT_MS) + expect(walletC.fiatCurrencyCode).equals('iso:EUR') + await accountC.logout() + }) + + it('a wallet created after boot seeds from the cache when unarchived', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Boot warm, then create a wallet the booted file never carried + // and let the saver write it into the next generation: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForCurrencyWallet(walletId) + const created = await account.createCurrencyWallet('wallet:fakecoin', { + fiatCurrencyCode: 'iso:USD', + name: 'Born After Boot' + }) + await snooze(SAVE_WAIT_MS) + await account.changeWalletStates({ [created.id]: { archived: true } }) + await snooze(RACE_WAIT_MS) + + // Unarchiving it with the engine held back has to emit the wallet + // from its cache entry, the same as a wallet the boot seeded: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + await account.changeWalletStates({ [created.id]: { archived: false } }) + await snooze(RACE_WAIT_MS) + expect(account.currencyWallets[created.id]?.name).equals('Born After Boot') + + release() + await account.logout() + }) + + it('rejects a corrupt cache file, falls back cold, and re-saves', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Corrupt the cache file after the saver has settled: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForCurrencyWallet(walletId) + await snooze(SAVE_WAIT_MS) + for (const path of ACCOUNT_CACHE_FILES) { + await account.localDisklet.setText(path, '{ "version": 99 }') + } + await account.logout() + + // A corrupt file means the cold path runs: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await snooze(RACE_WAIT_MS) + expect(account2.currencyWallets[walletId]).equals(undefined) + + // Releasing the engine loads the wallet, and the saver rewrites the file: + release() + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.name).equals('Cached Name') + await snooze(SAVE_WAIT_MS) + await account2.logout() + + // The rewritten file feeds the next gated login: + const { gate: gate3, release: release3 } = createEngineGate() + fakePluginTestConfig.engineGate = gate3 + const account3 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet3 = await account3.waitForCurrencyWallet(walletId) + expect(wallet3.name).equals('Cached Name') + release3() + await account3.logout() + }) + + it('balance changes reach the cache file within a throttle window', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForCurrencyWallet(walletId) + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 999 }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.balances.FAKE).equals('999') + release() + await account2.logout() + }) + + it('classifies every wallet property as cache-seeded or engine-gated', async function () { + this.timeout(15000) + + // Everything the wallet-list scene renders pre-engine must come + // from the cache file. If this list changes, walletCache.json + // (and its saver and seed paths) must change with it: + const cacheSeeded = [ + 'balanceMap', + 'balances', + 'enabledTokenIds', + 'fiatCurrencyCode', + 'name' + ] + + // The documented engine-gated set from the design's section 5.4: + // methods that internally await the engine, plus engine-sourced + // getters with safe pre-engine defaults: + const engineGated = [ + '$internalStreamTransactions', + 'accelerate', + 'blockHeight', + 'broadcastTx', + 'detectedTokenIds', + 'dumpData', + 'getMaxSpendable', + 'getNumTransactions', + 'getPaymentProtocolInfo', + 'getTransactions', + 'lockReceiveAddress', + 'makeSpend', + 'resyncBlockchain', + 'saveReceiveAddress', + 'saveTx', + 'saveTxAction', + 'saveTxMetadata', + 'signBytes', + 'signMessage', + 'signTx', + 'split', + 'stakingStatus', + 'streamTransactions', + 'sweepPrivateKeys', + 'syncRatio', + 'syncStatus', + 'unactivatedTokenIds' + ] + + // Cache-assisted surfaces: engine-gated by default, but served + // from the cache pre-engine when it can answer (cached addresses; + // otherMethods stubs from cached names): + const cacheAssisted = ['getAddresses', 'getReceiveAddress', 'otherMethods'] + + // Identity, storage-backed, config, and tools surfaces, which + // never needed an engine in the first place: + const engineFree = [ + 'changeEnabledTokenIds', + 'changePaused', + 'changeWalletSettings', + 'created', + 'currencyConfig', + 'currencyInfo', + 'denominationToNative', + 'disklet', + 'encodeUri', + 'id', + 'imported', + 'localDisklet', + 'nativeToDenomination', + 'on', + 'parseUri', + 'paused', + 'publicWalletInfo', + 'renameWallet', + 'setFiatCurrencyCode', + 'sync', + 'type', + 'walletSettings', + 'watch' + ] + + const { context, walletId } = await makeCachedWorld() + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + // Every property on the live wallet object must be classified. + // A newly added EdgeCurrencyWallet property fails here until + // someone decides whether the cache must seed it: + const classified = new Set([ + ...cacheSeeded, + ...cacheAssisted, + ...engineGated, + ...engineFree + ]) + const unclassified = Object.getOwnPropertyNames(wallet).filter( + // The yaob bridge adds its own bookkeeping property: + key => key !== '_yaob' && !classified.has(key) + ) + expect(unclassified).deep.equals([]) + + // And the classification must not name properties that no longer + // exist, so removals also force a decision: + const surface = new Set(Object.getOwnPropertyNames(wallet)) + const stale = [...classified].filter(key => !surface.has(key)) + expect(stale).deep.equals([]) + + // The cache-seeded properties actually carry cached values while + // the engine is still blocked: + expect(wallet.name).equals('Cached Name') + expect(wallet.fiatCurrencyCode).equals('iso:USD') + expect(wallet.enabledTokenIds).deep.equals(['badf00d5']) + expect(wallet.balanceMap.get(null)).equals('12345') + expect(wallet.balances.FAKE).equals('12345') + + release() + await account.logout() + }) + + it('serves cached addresses pre-engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache: query once with the engine running, + // then let the throttled saver persist the answer: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + const live = await wallet.getAddresses({ tokenId: null }) + expect(live[0].publicAddress).equals('fakesegwit') + await snooze(SAVE_WAIT_MS) + + // The engine's answer reached the cache file, balances stripped: + const cache = await readAccountCache(account) + const stored = cache.wallets[walletId] + expect(JSON.stringify(stored.addresses).includes('fakeaddress')).equals( + true + ) + expect(JSON.stringify(stored).includes('nativeBalance')).equals(false) + await account.logout() + + // A warm login serves the cached addresses while the engine is + // still blocked, and getReceiveAddress derives from them: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + const cached = await wallet2.getAddresses({ tokenId: null }) + expect(cached.map(address => address.publicAddress)).deep.equals( + live.map(address => address.publicAddress) + ) + const receive = await wallet2.getReceiveAddress({ tokenId: null }) + expect(receive.publicAddress).equals('fakeaddress') + release() + await account2.logout() + }) + + it('a logout while a cached-address reconcile waits reports no error', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache, so the next login can serve it: + 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() + + // Warm login with the engine held: the query is answered from the + // cache, and its background reconcile parks on the engine wait: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const errors: unknown[] = [] + const unsubscribe = context.on('error', error => errors.push(error)) + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + await wallet2.getAddresses({ tokenId: null }) + + // The logout rejects that parked wait. The correction is moot once + // the wallet is gone, so nothing reaches the app's error handler: + await account2.logout() + release() + fakePluginTestConfig.engineGate = undefined + await snooze(250) + + unsubscribe() + expect(errors).deep.equals([]) + }) + + it('emits addressChanged when the engine disagrees with the cache', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache with this engine's answer: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + const primed = await wallet.getAddresses({ tokenId: null }) + expect(primed[0].publicAddress).equals('fakesegwit') + await snooze(SAVE_WAIT_MS) + await account.logout() + + // The next session's engine derives a different address, which is + // what a rotating chain does once the cached one has been used: + fakePluginTestConfig.freshAddressPatch = { segwitAddress: 'rotatedsegwit' } + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + + let changed = 0 + wallet2.on('addressChanged', () => { + ++changed + }) + + // The pre-engine query is served from the cache, silently: + const cached = await wallet2.getAddresses({ tokenId: null }) + expect(cached[0].publicAddress).equals('fakesegwit') + expect(changed).equals(0) + + // Once the engine loads it disagrees, so the wallet tells its + // consumers to re-query, and the re-query returns the new address: + release() + await snooze(SAVE_WAIT_MS) + expect(changed).equals(1) + const confirmed = await wallet2.getAddresses({ tokenId: null }) + expect(confirmed[0].publicAddress).equals('rotatedsegwit') + await account2.logout() + }) + + it('emits addressChanged once when several callers share the cache', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + fakePluginTestConfig.freshAddressPatch = { segwitAddress: 'rotatedsegwit' } + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + + let changed = 0 + wallet2.on('addressChanged', () => { + ++changed + }) + + // Several consumers can be served the same cached answer before + // the engine lands (the receive scene alone asks twice, since + // getReceiveAddress goes through getAddresses). One engine query + // settles the correction for all of them: + const served = await Promise.all([ + wallet2.getAddresses({ tokenId: null }), + wallet2.getAddresses({ tokenId: null }), + wallet2.getReceiveAddress({ tokenId: null }) + ]) + expect(served[0][0].publicAddress).equals('fakesegwit') + expect(changed).equals(0) + + release() + await snooze(SAVE_WAIT_MS) + expect(changed).equals(1) + const confirmed = await wallet2.getAddresses({ tokenId: null }) + expect(confirmed[0].publicAddress).equals('rotatedsegwit') + await account2.logout() + }) + + it('stops serving the cached address once the engine fails', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // A warm cache must not make a wallet whose engine died look + // healthy: the engine is never coming, so the query has to reject + // rather than serve the cache forever: + const { gate, fail } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect( + (await wallet2.getAddresses({ tokenId: null }))[0].publicAddress + ).equals('fakesegwit') + + fail(new Error('Engine exploded')) + await expectRejection( + wallet2.getAddresses({ tokenId: null }), + 'Error: Engine exploded' + ) + await account2.logout() + }) + + it('stays quiet when the engine confirms the cached address', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // This engine derives exactly what the cache holds, the common + // case, so the reconcile must not wake every consumer up: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + + let changed = 0 + wallet2.on('addressChanged', () => { + ++changed + }) + + expect( + (await wallet2.getAddresses({ tokenId: null }))[0].publicAddress + ).equals('fakesegwit') + release() + await snooze(SAVE_WAIT_MS) + expect(changed).equals(0) + await account2.logout() + }) + + it('keeps the engine gate when forceIndex is set', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Prime the address cache: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await wallet.getAddresses({ tokenId: null }) + await snooze(SAVE_WAIT_MS) + await account.logout() + + // The plain query is served from the cache, but a caller naming a + // specific index wants a freshly derived address, which only the + // engine can produce, so that query still waits: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + const cached = await wallet2.getAddresses({ tokenId: null }) + expect(cached[0].publicAddress).equals('fakesegwit') + + let settled = false + const forcedPromise = wallet2 + .getAddresses({ tokenId: null, forceIndex: 0 }) + .then(addresses => { + settled = true + return addresses + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + release() + const forced = await forcedPromise + expect(forced[0].publicAddress).equals('fakesegwit') + await account2.logout() + }) + + it('calls a cached otherMethods name before the engine exists', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // The engine's method names are in the cache, so a warm login + // exposes a delegating stub pre-engine. Calling it pends on the + // engine and then forwards: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.otherMethods.testMethod).not.equals(undefined) + let settled = false + const callPromise = wallet.otherMethods + .testMethod('early') + .then((result: string) => { + settled = true + return result + }) + await snooze(RACE_WAIT_MS) + expect(settled).equals(false) + release() + expect(await callPromise).equals('testMethod called with: early') + await account.logout() + }) + + it('rejects a stale cached method name the engine lacks', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // The next session's engines are built WITHOUT otherMethods, so + // the cached `testMethod` name is stale. The stub still exists, + // and rejects cleanly once the engine loads without the method: + fakePluginTestConfig.omitEngineOtherMethods = true + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + expect(wallet.otherMethods.testMethod).not.equals(undefined) + await expectRejection( + wallet.otherMethods.testMethod('stale'), + 'Error: The wallet engine does not implement "testMethod"' + ) + await account.logout() + }) + + it('upgrades a pre-consolidation device and grows stubs post-engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Rebuild the on-disk state an existing device is in when this + // ships: a version-1 account file that knows nothing about + // wallets, plus the per-wallet pair this version no longer + // writes. The version-1 wallet file predates addresses and method + // names, so the boot below also proves those upgrade cleanly: + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await snooze(SAVE_WAIT_MS) + const cache = await readAccountCache(account) + const cached = cache.wallets[walletId] + accountCacheSaverConfig.throttleMs = 5000 + + await wallet.localDisklet.setText( + 'publicKey.json', + JSON.stringify({ walletInfo: cached.walletInfo }) + ) + await wallet.localDisklet.setText( + 'walletCache.json', + JSON.stringify({ + version: 1, + name: cached.name, + fiatCurrencyCode: cached.fiatCurrencyCode, + enabledTokenIds: cached.enabledTokenIds, + balances: cached.balances + }) + ) + for (const path of ACCOUNT_CACHE_FILES) { + await account.localDisklet.delete(path) + } + await account.localDisklet.setText( + 'accountCache.json', + JSON.stringify({ + version: 1, + customTokens: cache.customTokens, + legacyWallets: false, + walletStates: cache.walletStates, + configOtherMethodNames: cache.configOtherMethodNames + }) + ) + await account.logout() + accountCacheSaverConfig.throttleMs = 50 + + // The old layout still warm-boots (migrated on read, not cold), + // with no method names yet: + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + expect(wallet2.name).equals('Cached Name') + expect(wallet2.otherMethods.testMethod).equals(undefined) + + // Once the engine lands, its methods appear and work, even + // through a bridge that saw the empty object first: + release() + await waitForOtherMethods(wallet2) + expect(await wallet2.otherMethods.testMethod('grown')).equals( + 'testMethod called with: grown' + ) + + // ...and the migration folded this wallet into the consolidated + // file, so the NEXT boot needs no per-wallet reads at all: + await snooze(SAVE_WAIT_MS) + const migrated = await readAccountCache(account2) + expect(migrated.version).equals(2) + expect(migrated.wallets[walletId].name).equals('Cached Name') + await account2.logout() + }) + + it('otherMethods is {} pre-engine and carries engine methods post-engine', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + + // Pre-engine, otherMethods is a safe empty object: + expect(wallet.otherMethods).not.equals(undefined) + expect(Object.keys(wallet.otherMethods)).deep.equals([]) + + release() + await waitForOtherMethods(wallet) + expect(await wallet.otherMethods.testMethod('hello')).equals( + 'testMethod called with: hello' + ) + await account.logout() + }) + + it('an engine callback after logout leaves the next session alone', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Keep the callbacks the first session's engine was handed: + let stale: EdgeCurrencyEngineCallbacks | undefined + fakePluginTestConfig.onEngineCallbacks = (id, callbacks) => { + if (id === walletId && stale == null) stale = callbacks + } + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = await account.waitForCurrencyWallet(walletId) + await snooze(SAVE_WAIT_MS) + expect(wallet.balanceMap.get(null)).equals('12345') + await account.logout() + + // The next login runs a fresh pixie under the same wallet id: + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet2 = await account2.waitForCurrencyWallet(walletId) + if (stale == null) throw new Error('No engine callbacks captured') + + // The dead session's engine reports one last balance. It belongs + // to nobody, so it must not land on this session's wallet: + stale.onTokenBalanceChanged(null, '999') + await snooze(RACE_WAIT_MS) + expect(wallet2.balanceMap.get(null)).equals('12345') + await account2.logout() + }) + + it('a staking query that outlives its logout reports no error', async function () { + this.timeout(15000) + const { context, walletId } = await makeCachedWorld() + + // Park the staking query the engine startup awaits: + let called = false + let failStaking: (error: Error) => void = () => {} + fakePluginTestConfig.stakingStatusHook = async () => { + called = true + return await new Promise((resolve, reject) => { + failStaking = reject + }) + } + + const errors: unknown[] = [] + const unsubscribe = context.on('error', error => errors.push(error)) + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.waitForCurrencyWallet(walletId) + await snooze(SAVE_WAIT_MS) + expect(called).equals(true) + + // The logout leaves that query pending, and its rejection lands + // for a wallet the user has already left: + await account.logout() + failStaking(new Error('Staking query failed')) + await snooze(RACE_WAIT_MS) + + unsubscribe() + expect(errors).deep.equals([]) + }) +}) + +/** 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..a4889f7d4 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -21,12 +21,129 @@ import { EdgeTransaction, EdgeTransactionEvent, EdgeWalletInfo, - InsufficientFundsError + InsufficientFundsError, + JsonObject } from '../../src/index' import { upgradeCurrencyCode } from '../../src/types/type-helpers' const GENESIS_BLOCK = 1231006505 +/** + * Test configuration for controlling fake plugin behavior. + */ +export interface FakePluginTestConfig { + /** + * If set, `getBuiltinTokens` will wait for this promise to resolve. + * The account pixie awaits builtin tokens at the head of its file + * loads, so this gate makes "the deferred account loads have not + * landed yet" a deterministic state in tests (and blocks a cold + * login entirely, exactly as on master). + */ + builtinTokensGate?: Promise + + /** + * If set, engine creation will wait for this promise to resolve. + * Use `createEngineGate` to make a controllable gate, + * so "before the engine exists" is a deterministic state in tests. + */ + engineGate?: Promise + + /** + * If set, spread over the engine's `getFreshAddress` answer, so a + * test can make a freshly loaded engine disagree with the address + * its predecessor cached. + */ + freshAddressPatch?: Partial + + /** + * If set, engines are created WITHOUT their `otherMethods`, so + * tests can prove that a stale cached method name rejects cleanly. + */ + omitEngineOtherMethods?: boolean + + /** + * If set, the plugin behaves like a legacy token plugin: its tools + * lack `getTokenId` and its engines take `addCustomToken`, so token + * validation has to go through a running engine. + */ + legacyTokenPlugin?: boolean + + /** + * If set, `checkPublicKey` will wait for this promise to resolve. + * The wallet pixie validates its cached public key between the + * repo sync and the wallet file loads, so this gate makes "the + * wallet's file loads have not landed yet" a deterministic state + * on a warm login, while the cache-seeded wallet API stays usable. + */ + publicKeyCheckGate?: Promise + + /** + * If set, receives each wallet id as its `makeCurrencyEngine` call + * begins (before any gate), so tests can observe creation order. + */ + onEngineCreate?: (walletId: string) => void + + /** + * If set, engine creation for this one wallet id fails before any + * gate, so tests can hold one wallet failed while another is queued. + */ + failEngineFor?: string + + /** + * If set, receives each wallet id as its engine is killed, so tests + * can prove an engine created after teardown is not left running. + */ + onEngineKill?: (walletId: string) => void + + /** + * If set, receives the callbacks handed to each engine, so tests can + * fire one after its session is gone. + */ + onEngineCallbacks?: ( + walletId: string, + callbacks: EdgeCurrencyEngineCallbacks + ) => void + + /** + * If set, `getStakingStatus` returns this promise instead of the + * engine's own status, so a test can hold or reject that query. + */ + stakingStatusHook?: () => Promise +} + +export const fakePluginTestConfig: FakePluginTestConfig = { + builtinTokensGate: undefined, + engineGate: undefined, + freshAddressPatch: undefined, + omitEngineOtherMethods: undefined, + legacyTokenPlugin: undefined, + publicKeyCheckGate: undefined, + onEngineCreate: undefined, + failEngineFor: undefined, + onEngineKill: undefined, + onEngineCallbacks: undefined, + stakingStatusHook: 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 +207,24 @@ 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}` + } + } + + // Present only when a test models a legacy token plugin: + get addCustomToken(): ((token: unknown) => Promise) | undefined { + if (fakePluginTestConfig.legacyTokenPlugin !== true) return + return async () => {} + } + constructor( walletInfo: EdgeWalletInfo, opts: EdgeCurrencyEngineOptions, @@ -201,6 +336,9 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { async killEngine(): Promise { this.running = false + if (fakePluginTestConfig.onEngineKill != null) { + fakePluginTestConfig.onEngineKill(this.walletId) + } } resyncBlockchain(): Promise { @@ -223,7 +361,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') } @@ -252,6 +390,9 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { // Staking: async getStakingStatus(): Promise { + if (fakePluginTestConfig.stakingStatusHook != null) { + return await fakePluginTestConfig.stakingStatusHook() + } return { stakedAmounts: [{ nativeAmount: String(this.state.stakedBalance) }] } @@ -265,7 +406,8 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { publicAddress: 'fakeaddress', nativeBalance: this.state.balance.toString(), segwitAddress: 'fakesegwit', - legacyAddress: 'fakelegacy' + legacyAddress: 'fakelegacy', + ...fakePluginTestConfig.freshAddressPatch } } @@ -349,13 +491,24 @@ 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' } } - async getTokenId(token: EdgeToken): Promise { - const { contractAddress } = asNetworkLocation(token.networkLocation) - return contractAddress.toLowerCase().replace(/^0x/, '') + // Absent when a test models a legacy token plugin: + get getTokenId(): ((token: EdgeToken) => Promise) | undefined { + if (fakePluginTestConfig.legacyTokenPlugin === true) return + return async (token: EdgeToken): Promise => { + const { contractAddress } = asNetworkLocation(token.networkLocation) + return contractAddress.toLowerCase().replace(/^0x/, '') + } } async getDisplayPrivateKey( @@ -392,19 +545,41 @@ export function makeFakeCurrencyPlugin( const currencyInfo: EdgeCurrencyInfo = { ...fakeCurrencyInfo, ...overrides } return { - currencyInfo, + get currencyInfo(): EdgeCurrencyInfo { + return currencyInfo + }, + + // Exercises the config-level otherMethods name cache in tests: + otherMethods: { + async fakePluginMethod(arg: string): Promise { + return `fakePluginMethod called with: ${arg}` + } + }, - getBuiltinTokens(): Promise { - return Promise.resolve(fakeTokens) + 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.onEngineCallbacks != null) { + fakePluginTestConfig.onEngineCallbacks(walletInfo.id, opts.callbacks) + } + if (fakePluginTestConfig.failEngineFor === walletInfo.id) { + throw new Error('Engine exploded') + } + if (fakePluginTestConfig.engineGate != null) { + await fakePluginTestConfig.engineGate + } + return new FakeCurrencyEngine(walletInfo, opts, currencyInfo) }, makeCurrencyTools(): Promise {