Skip to content

Commit e82fdcd

Browse files
committed
Cache and restore wallets
Improve login performance by caching wallet state on a per account level in unencrypted JSON file and rehydrate before wallets load.
1 parent ab74d24 commit e82fdcd

12 files changed

Lines changed: 3067 additions & 17 deletions

docs/wallet-cache.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Wallet Cache Architecture
2+
3+
Wallet caching provides instant UI on login by saving wallet state to an unencrypted JSON file and restoring it before currency engines load.
4+
5+
## Overview
6+
7+
On login, the account pixie checks for a cache file at `accountCache/<storageWalletId>/walletCache.json`. If found, it creates lightweight cached wallet objects that the GUI can display immediately. Real wallets load in the background and replace/supplement the cached ones.
8+
9+
The cache file contains:
10+
11+
- Token definitions (only tokens enabled by at least one wallet)
12+
- Wallet state: id, type, name, pluginId, fiatCurrencyCode, balances, enabledTokenIds, otherMethodNames, created date, publicWalletInfo
13+
- Config otherMethods names per plugin
14+
15+
## Cached Wallet Delegation
16+
17+
Cached wallets implement the full `EdgeCurrencyWallet` interface. Property getters return cached values as defaults, delegating to the real wallet when available via `tryGetRealWallet()`. Async methods delegate via a shared `delegate()` helper that checks for the real wallet synchronously first, then waits via a shared polling promise.
18+
19+
Key design constraint: the cached wallet runs inside the WebView (edge-core-js), while the GUI reads properties through the yaob bridge. yaob caches getter values on the client side and only refreshes them when `update(object)` is called. Since no pixie calls `update()` on cached wallets, **any setter that changes a value the GUI reads back must call `update(wallet)` after mutation** to propagate through yaob. Four setters require this:
20+
21+
- `changePaused` / `paused`
22+
- `renameWallet` / `name`
23+
- `setFiatCurrencyCode` / `fiatCurrencyCode`
24+
- `changeEnabledTokenIds` / `enabledTokenIds`
25+
26+
Each setter: (1) awaits the delegate to the real wallet, (2) updates a local variable, (3) calls `update(wallet)`. If the delegate throws, no local state changes.
27+
28+
## Shared Polling (`makeRealObjectPoller`)
29+
30+
Both cached wallets and cached configs use `makeRealObjectPoller<T>` from `cache-utils.ts`. This creates a single shared promise per object -- all callers that need the real wallet share the same 500ms poll loop. This avoids N concurrent polling loops when N methods are called simultaneously. The poller times out after 60 seconds.
31+
32+
## otherMethods Delegation
33+
34+
Plugin `otherMethods` are cached by name in the cache file. `createDelegatingOtherMethods` creates stub functions for each cached name. When called, each stub checks if the real otherMethods are available synchronously, otherwise waits for the real wallet/config. Method names not in the cache return `undefined` until the real object loads. Wallet otherMethods are bridgified for yaob serialization.
35+
36+
## Disklet Delegation
37+
38+
Cached wallets expose delegating disklets that forward all operations (`getText`, `setText`, `getData`, `setData`, `list`, `delete`) to the real wallet's disklet. During the cache phase, operations wait for the real wallet. The GUI does not access wallet-level disklets during the cache window -- account-level disklets (for settings, referrals) come from the account's own storage wallet, not currency wallets.
39+
40+
## Cache Saving
41+
42+
`makeWalletCacheSaver` implements a dirty-triggered throttle. The account pixie's `cacheSaver` sub-pixie detects wallet state changes reactively in its `update()` method (triggered by Redux state changes) and calls `markDirty()`. The saver responds immediately or schedules a delayed save:
43+
44+
- When `markDirty()` is called and >= throttleMs has elapsed since the last save, the save happens immediately.
45+
- When `markDirty()` is called within the throttle window, the save is scheduled for when the window expires.
46+
- Only one pending save is scheduled at a time; additional `markDirty()` calls during the window are coalesced.
47+
- If changes arrive during an active save, another save is scheduled after completion.
48+
49+
Other features:
50+
51+
- Max 3 consecutive failures before giving up (prevents infinite log spam)
52+
- Uses `account.loggedIn` to guard against writing after logout
53+
- Only caches tokens enabled by at least one wallet (avoids caching thousands of Ethereum tokens)
54+
- `walletCacheSaverConfig.throttleMs` can be overridden to 50ms in tests
55+
56+
## Cache Loading
57+
58+
`loadWalletCache` parses the JSON, validates through cleaners (`asWalletCacheFile`), creates one `EdgeCurrencyConfig` per plugin and one `EdgeCurrencyWallet` per cached wallet. Each gets a real-object lookup callback that reads from the pixie output. The loader also accepts `pauseWallets` from the login options so cached wallets match the real wallet's initial paused state.
59+
60+
Cache loading happens before `loadAllFiles` / `ACCOUNT_KEYS_LOADED`. If the cache file doesn't exist or fails validation (expected on first login or after schema changes), login falls through to the normal flow.
61+
62+
## paused State and WalletLifecycle
63+
64+
The GUI's `WalletLifecycle` boots wallets in batches by checking `wallet.paused`. Cached wallets start with `paused = pauseWallets` (true when the GUI passes `pauseWallets: true`). When WalletLifecycle calls `changePaused(false)`, the cached wallet delegates to the real wallet and calls `update(wallet)` to propagate the change through yaob. Without the `update()` call, yaob's client-side proxy would cache the old `paused = true` indefinitely, causing WalletLifecycle to re-boot the same wallets in an infinite loop.
65+
66+
## Testing
67+
68+
Tests use two mechanisms for deterministic control:
69+
70+
- **Engine gate**: `createEngineGate()` returns `{ gate, release }`. Setting `fakePluginTestConfig.engineGate = gate` blocks engine creation. Call `release()` to allow engines to load. This replaces timing-based delays with explicit control.
71+
- **Cache saver throttle**: `walletCacheSaverConfig.throttleMs = 50` reduces the save interval from 5 seconds to 50ms in tests. Cache save waits use `await snooze(100)` (2x the throttle).
72+
73+
The fake currency plugin supports `fakePluginTestConfig.noOtherMethods = true` to test the empty-otherMethods code path.

src/core/account/account-api.ts

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
} from '../../types/types'
3131
import { makeEdgeResult } from '../../util/edgeResult'
3232
import { base58 } from '../../util/encoding'
33+
import { WalletCacheSetup } from '../cache/cache-wallet-loader'
3334
import { getPublicWalletInfo } from '../currency/wallet/currency-wallet-pixie'
3435
import {
3536
finishWalletCreation,
@@ -72,10 +73,21 @@ import { makeLobbyApi } from './lobby-api'
7273
import { makeMemoryWalletInner } from './memory-wallet'
7374
import { CurrencyConfig, SwapConfig } from './plugin-api'
7475

76+
export interface AccountApiOptions {
77+
/** Optional cached wallet data for instant UI on login */
78+
cacheSetup?: WalletCacheSetup
79+
}
80+
7581
/**
7682
* Creates an unwrapped account API object around an account state object.
83+
* If cacheSetup is provided, cached wallets are used until real wallets load.
7784
*/
78-
export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount {
85+
export function makeAccountApi(
86+
ai: ApiInput,
87+
accountId: string,
88+
opts: AccountApiOptions = {}
89+
): EdgeAccount {
90+
const { cacheSetup } = opts
7991
// We don't want accountState to be undefined when we log out,
8092
// so preserve a snapshot of our last state:
8193
let lastState = ai.props.state.accounts[accountId]
@@ -613,23 +625,57 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount {
613625
// ----------------------------------------------------------------
614626

615627
get activeWalletIds(): string[] {
616-
return ai.props.state.accounts[accountId].activeWalletIds
628+
const accountState = ai.props.state.accounts[accountId]
629+
// Use cached IDs until keys are loaded:
630+
if (
631+
accountState != null &&
632+
!accountState.keysLoaded &&
633+
cacheSetup != null
634+
) {
635+
return cacheSetup.activeWalletIds
636+
}
637+
return accountState?.activeWalletIds ?? []
617638
},
618639

619640
get archivedWalletIds(): string[] {
620-
return ai.props.state.accounts[accountId].archivedWalletIds
641+
return ai.props.state.accounts[accountId]?.archivedWalletIds ?? []
621642
},
622643

623644
get hiddenWalletIds(): string[] {
624-
return ai.props.state.accounts[accountId].hiddenWalletIds
645+
return ai.props.state.accounts[accountId]?.hiddenWalletIds ?? []
625646
},
626647

627648
get currencyWallets(): { [walletId: string]: EdgeCurrencyWallet } {
628-
return ai.props.output.accounts[accountId].currencyWallets
649+
// Get real wallets from pixie output
650+
const pixieWallets =
651+
ai.props.output.accounts[accountId]?.currencyWallets ?? {}
652+
653+
// If no cache, just return pixie wallets (stable reference)
654+
if (cacheSetup == null) {
655+
return pixieWallets
656+
}
657+
658+
// Once all active wallets have loaded, return the stable pixie
659+
// reference to avoid allocating a new object on every access:
660+
const activeIds = this.activeWalletIds
661+
if (activeIds.every(id => pixieWallets[id] != null)) {
662+
return pixieWallets
663+
}
664+
665+
// Merge: real wallets take priority, cached fill gaps
666+
const result: { [walletId: string]: EdgeCurrencyWallet } = {}
667+
for (const walletId of activeIds) {
668+
const wallet =
669+
pixieWallets[walletId] ?? cacheSetup.currencyWallets[walletId]
670+
if (wallet != null) {
671+
result[walletId] = wallet
672+
}
673+
}
674+
return result
629675
},
630676

631677
get currencyWalletErrors(): { [walletId: string]: Error } {
632-
return ai.props.state.accounts[accountId].currencyWalletErrors
678+
return ai.props.state.accounts[accountId]?.currencyWalletErrors ?? {}
633679
},
634680

635681
async createCurrencyWallet(

src/core/account/account-cleaners.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const asEdgeDenomination = asObject<EdgeDenomination>({
2222
symbol: asOptional(asString)
2323
})
2424

25-
const asEdgeToken = asObject<EdgeToken>({
25+
export const asEdgeToken = asObject<EdgeToken>({
2626
currencyCode: asString,
2727
denominations: asArray(asEdgeDenomination),
2828
displayName: asString,

0 commit comments

Comments
 (0)