Skip to content

Commit 520c5ad

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 66e9b1d commit 520c5ad

12 files changed

Lines changed: 2919 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 300ms 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: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
EdgeWalletStates
3030
} from '../../types/types'
3131
import { base58 } from '../../util/encoding'
32+
import { WalletCacheSetup } from '../cache/cache-wallet-loader'
3233
import { getPublicWalletInfo } from '../currency/wallet/currency-wallet-pixie'
3334
import {
3435
finishWalletCreation,
@@ -71,10 +72,21 @@ import { makeLobbyApi } from './lobby-api'
7172
import { makeMemoryWalletInner } from './memory-wallet'
7273
import { CurrencyConfig, SwapConfig } from './plugin-api'
7374

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

579591
get activeWalletIds(): string[] {
580-
return ai.props.state.accounts[accountId].activeWalletIds
592+
const accountState = ai.props.state.accounts[accountId]
593+
// Use cached IDs until keys are loaded:
594+
if (
595+
accountState != null &&
596+
!accountState.keysLoaded &&
597+
cacheSetup != null
598+
) {
599+
return cacheSetup.activeWalletIds
600+
}
601+
return accountState?.activeWalletIds ?? []
581602
},
582603

583604
get archivedWalletIds(): string[] {
584-
return ai.props.state.accounts[accountId].archivedWalletIds
605+
const accountState = ai.props.state.accounts[accountId]
606+
// Return empty until keys are loaded (cache doesn't track archived):
607+
if (
608+
accountState != null &&
609+
!accountState.keysLoaded &&
610+
cacheSetup != null
611+
) {
612+
return []
613+
}
614+
return accountState?.archivedWalletIds ?? []
585615
},
586616

587617
get hiddenWalletIds(): string[] {
588-
return ai.props.state.accounts[accountId].hiddenWalletIds
618+
const accountState = ai.props.state.accounts[accountId]
619+
// Return empty until keys are loaded (cache doesn't track hidden):
620+
if (
621+
accountState != null &&
622+
!accountState.keysLoaded &&
623+
cacheSetup != null
624+
) {
625+
return []
626+
}
627+
return accountState?.hiddenWalletIds ?? []
589628
},
590629

591630
get currencyWallets(): { [walletId: string]: EdgeCurrencyWallet } {
592-
return ai.props.output.accounts[accountId].currencyWallets
631+
// Get real wallets from pixie output
632+
const pixieWallets =
633+
ai.props.output.accounts[accountId]?.currencyWallets ?? {}
634+
635+
// If no cache, just return pixie wallets
636+
if (cacheSetup == null) {
637+
return pixieWallets
638+
}
639+
640+
// Merge: real wallets take priority, cached fill gaps
641+
const activeIds = this.activeWalletIds
642+
const result: { [walletId: string]: EdgeCurrencyWallet } = {}
643+
for (const walletId of activeIds) {
644+
// Prefer real wallet, fall back to cached
645+
const wallet =
646+
pixieWallets[walletId] ?? cacheSetup.currencyWallets[walletId]
647+
// Skip wallets that don't exist in either source
648+
// (e.g., FIO wallets excluded from cache but in activeWalletIds)
649+
if (wallet != null) {
650+
result[walletId] = wallet
651+
}
652+
}
653+
return result
593654
},
594655

595656
get currencyWalletErrors(): { [walletId: string]: Error } {
596-
return ai.props.state.accounts[accountId].currencyWalletErrors
657+
const accountState = ai.props.state.accounts[accountId]
658+
// Return empty until keys are loaded:
659+
if (
660+
accountState != null &&
661+
!accountState.keysLoaded &&
662+
cacheSetup != null
663+
) {
664+
return {}
665+
}
666+
return accountState?.currencyWalletErrors ?? {}
597667
},
598668

599669
async createCurrencyWallet(
@@ -768,7 +838,7 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount {
768838
: undefined,
769839

770840
// Added for backward compatibility for plugins using core 1.x
771-
// @ts-expect-error
841+
// @ts-expect-error - paymentTokenId/paymentWallet are deprecated but still used by old plugins
772842
paymentTokenId: paymentInfo?.tokenId,
773843
paymentWallet: wallet
774844
})
@@ -796,7 +866,9 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount {
796866

797867
// Close unused quotes:
798868
for (const otherQuote of otherQuotes) {
799-
otherQuote.close().catch(() => undefined)
869+
otherQuote.close().catch((error: unknown) => {
870+
ai.props.log.warn('Failed to close unused swap quote:', error)
871+
})
800872
}
801873

802874
// Return the front quote:

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)