From be887e5a16562ce81a0122cd3c213ecd7f506379 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Sun, 8 Feb 2026 21:46:30 -0800 Subject: [PATCH 1/8] Fix missing return and improve error logging Add missing return in fake plugin getBalance for token balance. Improve @ts-expect-error comment and log swap quote close errors. --- src/core/account/account-api.ts | 6 ++++-- test/fake/fake-currency-plugin.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/account/account-api.ts b/src/core/account/account-api.ts index 496bb3d4..eb74e10e 100644 --- a/src/core/account/account-api.ts +++ b/src/core/account/account-api.ts @@ -804,7 +804,7 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { : undefined, // Added for backward compatibility for plugins using core 1.x - // @ts-expect-error + // @ts-expect-error - paymentTokenId/paymentWallet are deprecated but still used by old plugins paymentTokenId: paymentInfo?.tokenId, paymentWallet: wallet }) @@ -832,7 +832,9 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { // Close unused quotes: for (const otherQuote of otherQuotes) { - otherQuote.close().catch(() => undefined) + otherQuote.close().catch((error: unknown) => { + ai.props.log.warn('Failed to close unused swap quote:', error) + }) } // Return the front quote: diff --git a/test/fake/fake-currency-plugin.ts b/test/fake/fake-currency-plugin.ts index bffa847e..704a6510 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -223,7 +223,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') } From 56ab96f0378c0862326021ca537f921040f084cd Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Tue, 3 Feb 2026 14:58:59 -0800 Subject: [PATCH 2/8] 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. --- docs/wallet-cache.md | 73 ++ src/core/account/account-api.ts | 58 +- src/core/account/account-cleaners.ts | 2 +- src/core/account/account-pixie.ts | 228 +++- src/core/cache/cache-utils.ts | 160 +++ src/core/cache/cache-wallet-cleaners.ts | 68 + src/core/cache/cache-wallet-loader.ts | 148 +++ src/core/cache/cache-wallet-saver.ts | 250 ++++ src/core/cache/cached-currency-config.ts | 169 +++ src/core/cache/cached-currency-wallet.ts | 543 ++++++++ test/core/cache/wallet-cache.test.ts | 1470 ++++++++++++++++++++++ test/fake/fake-currency-plugin.ts | 55 +- 12 files changed, 3206 insertions(+), 18 deletions(-) create mode 100644 docs/wallet-cache.md create mode 100644 src/core/cache/cache-utils.ts create mode 100644 src/core/cache/cache-wallet-cleaners.ts create mode 100644 src/core/cache/cache-wallet-loader.ts create mode 100644 src/core/cache/cache-wallet-saver.ts create mode 100644 src/core/cache/cached-currency-config.ts create mode 100644 src/core/cache/cached-currency-wallet.ts create mode 100644 test/core/cache/wallet-cache.test.ts diff --git a/docs/wallet-cache.md b/docs/wallet-cache.md new file mode 100644 index 00000000..5301f0e2 --- /dev/null +++ b/docs/wallet-cache.md @@ -0,0 +1,73 @@ +# Wallet Cache Architecture + +Wallet caching provides instant UI on login by saving wallet state to an unencrypted JSON file and restoring it before currency engines load. + +## Overview + +On login, the account pixie checks for a cache file at `accountCache//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. + +The cache file contains: + +- Token definitions (only tokens enabled by at least one wallet) +- Wallet state: id, type, name, pluginId, fiatCurrencyCode, balances, enabledTokenIds, otherMethodNames, created date, publicWalletInfo +- Config otherMethods names per plugin + +## Cached Wallet Delegation + +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. + +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: + +- `changePaused` / `paused` +- `renameWallet` / `name` +- `setFiatCurrencyCode` / `fiatCurrencyCode` +- `changeEnabledTokenIds` / `enabledTokenIds` + +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. + +## Shared Polling (`makeRealObjectPoller`) + +Both cached wallets and cached configs use `makeRealObjectPoller` 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. + +## otherMethods Delegation + +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. + +## Disklet Delegation + +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. + +## Cache Saving + +`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: + +- When `markDirty()` is called and >= throttleMs has elapsed since the last save, the save happens immediately. +- When `markDirty()` is called within the throttle window, the save is scheduled for when the window expires. +- Only one pending save is scheduled at a time; additional `markDirty()` calls during the window are coalesced. +- If changes arrive during an active save, another save is scheduled after completion. + +Other features: + +- Max 3 consecutive failures before giving up (prevents infinite log spam) +- Uses `account.loggedIn` to guard against writing after logout +- Only caches tokens enabled by at least one wallet (avoids caching thousands of Ethereum tokens) +- `walletCacheSaverConfig.throttleMs` can be overridden to 50ms in tests + +## Cache Loading + +`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. + +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. + +## paused State and WalletLifecycle + +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. + +## Testing + +Tests use two mechanisms for deterministic control: + +- **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. +- **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). + +The fake currency plugin supports `fakePluginTestConfig.noOtherMethods = true` to test the empty-otherMethods code path. diff --git a/src/core/account/account-api.ts b/src/core/account/account-api.ts index eb74e10e..dbb5eda5 100644 --- a/src/core/account/account-api.ts +++ b/src/core/account/account-api.ts @@ -30,6 +30,7 @@ import { } from '../../types/types' import { makeEdgeResult } from '../../util/edgeResult' import { base58 } from '../../util/encoding' +import { WalletCacheSetup } from '../cache/cache-wallet-loader' import { getPublicWalletInfo } from '../currency/wallet/currency-wallet-pixie' import { finishWalletCreation, @@ -72,10 +73,21 @@ import { makeLobbyApi } from './lobby-api' import { makeMemoryWalletInner } from './memory-wallet' import { CurrencyConfig, SwapConfig } from './plugin-api' +export interface AccountApiOptions { + /** Optional cached wallet data for instant UI on login */ + cacheSetup?: WalletCacheSetup +} + /** * Creates an unwrapped account API object around an account state object. + * If cacheSetup is provided, cached wallets are used until real wallets load. */ -export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { +export function makeAccountApi( + ai: ApiInput, + accountId: string, + opts: AccountApiOptions = {} +): EdgeAccount { + const { cacheSetup } = opts // We don't want accountState to be undefined when we log out, // so preserve a snapshot of our last state: let lastState = ai.props.state.accounts[accountId] @@ -613,23 +625,57 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { // ---------------------------------------------------------------- get activeWalletIds(): string[] { - return ai.props.state.accounts[accountId].activeWalletIds + const accountState = ai.props.state.accounts[accountId] + // Use cached IDs until keys are loaded: + if ( + accountState != null && + !accountState.keysLoaded && + cacheSetup != null + ) { + return cacheSetup.activeWalletIds + } + return accountState?.activeWalletIds ?? [] }, get archivedWalletIds(): string[] { - return ai.props.state.accounts[accountId].archivedWalletIds + return ai.props.state.accounts[accountId]?.archivedWalletIds ?? [] }, get hiddenWalletIds(): string[] { - return ai.props.state.accounts[accountId].hiddenWalletIds + return ai.props.state.accounts[accountId]?.hiddenWalletIds ?? [] }, get currencyWallets(): { [walletId: string]: EdgeCurrencyWallet } { - return ai.props.output.accounts[accountId].currencyWallets + // Get real wallets from pixie output + const pixieWallets = + ai.props.output.accounts[accountId]?.currencyWallets ?? {} + + // If no cache, just return pixie wallets (stable reference) + if (cacheSetup == null) { + return pixieWallets + } + + // Once all active wallets have loaded, return the stable pixie + // reference to avoid allocating a new object on every access: + const activeIds = this.activeWalletIds + if (activeIds.every(id => pixieWallets[id] != null)) { + return pixieWallets + } + + // Merge: real wallets take priority, cached fill gaps + const result: { [walletId: string]: EdgeCurrencyWallet } = {} + for (const walletId of activeIds) { + const wallet = + pixieWallets[walletId] ?? cacheSetup.currencyWallets[walletId] + if (wallet != null) { + result[walletId] = wallet + } + } + return result }, get currencyWalletErrors(): { [walletId: string]: Error } { - return ai.props.state.accounts[accountId].currencyWalletErrors + return ai.props.state.accounts[accountId]?.currencyWalletErrors ?? {} }, async createCurrencyWallet( diff --git a/src/core/account/account-cleaners.ts b/src/core/account/account-cleaners.ts index 4785f9ff..0ca12e04 100644 --- a/src/core/account/account-cleaners.ts +++ b/src/core/account/account-cleaners.ts @@ -22,7 +22,7 @@ const asEdgeDenomination = asObject({ symbol: asOptional(asString) }) -const asEdgeToken = asObject({ +export const asEdgeToken = asObject({ currencyCode: asString, denominations: asArray(asEdgeDenomination), displayName: asString, diff --git a/src/core/account/account-pixie.ts b/src/core/account/account-pixie.ts index 0359d8ee..75c7d552 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -11,12 +11,18 @@ import { close, update } from 'yaob' import { asMaybeOtpError, EdgeAccount, + EdgeCurrencyInfo, EdgeCurrencyWallet, EdgePluginMap, EdgeTokenMap } from '../../types/types' import { makePeriodicTask } from '../../util/periodic-task' import { snooze } from '../../util/snooze' +import { loadWalletCache, WalletCacheSetup } from '../cache/cache-wallet-loader' +import { + makeWalletCacheSaver, + WalletCacheSaver +} from '../cache/cache-wallet-saver' import { syncLogin } from '../login/login' import { waitForPlugins } from '../plugins/plugins-selectors' import { RootProps, toApiInput } from '../root-pixie' @@ -36,6 +42,26 @@ import { export const EXPEDITED_SYNC_INTERVAL = 5000 +/** + * Checks whether an error represents a missing file. + * Handles disklet ("Cannot load", "Cannot read file") and Node.js (ENOENT). + * Note: Coupled to disklet's error message format — update if disklet changes. + */ +function isFileNotFoundError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const msg = error.message + if (msg.startsWith('Cannot load') || msg.startsWith('Cannot read file')) { + return true + } + const errorWithCode = error as Error & { code?: string } + return errorWithCode.code === 'ENOENT' +} + +/** Returns the disklet path for the account's wallet cache file. */ +function getWalletCachePath(storageWalletId: string): string { + return `accountCache/${storageWalletId}/walletCache.json` +} + export interface AccountOutput { readonly accountApi: EdgeAccount readonly currencyWallets: { [walletId: string]: EdgeCurrencyWallet } @@ -51,8 +77,16 @@ export type AccountInput = PixieInput const accountPixie: TamePixie = combinePixies({ accountApi(input: AccountInput) { + let cacheCleanup: (() => void) | undefined + return { destroy() { + // Cancel any active cache pollers to prevent unnecessary resource usage + if (cacheCleanup != null) { + cacheCleanup() + cacheCleanup = undefined + } + // The Pixie library stops updating props after destruction, // so we are stuck seeing the logged-in state. Fix that: const hack: any = input.props @@ -78,7 +112,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,18 +122,101 @@ const accountPixie: TamePixie = combinePixies({ ]) } + // Try to load wallet cache for instant UI. + // Returns the cache setup if successful, undefined otherwise. + // Assumes storage wallets are already initialized. + async function tryLoadCache(): Promise { + try { + const cachePath = getWalletCachePath(accountWalletInfo.id) + const cacheJson = await ai.props.io.disklet.getText(cachePath) + + // Build currency info map from loaded plugins. + // Read live state (not the captured `state` snapshot from update()) + // since plugins may have finished loading during earlier awaits: + const currencyPlugins = ai.props.state.plugins.currency + const currencyInfos: { + [pluginId: string]: EdgeCurrencyInfo + } = {} + for (const pluginId of Object.keys(currencyPlugins)) { + currencyInfos[pluginId] = currencyPlugins[pluginId].currencyInfo + } + + // Create cached wallets with real wallet lookup for delegation: + const cacheSetup = loadWalletCache(cacheJson, currencyInfos, { + // Provide a callback to look up real wallets when available + getRealWallet: (walletId: string) => { + const accountOutput = ai.props.output.accounts[accountId] + const pixieWallets = accountOutput?.currencyWallets + return pixieWallets?.[walletId] + }, + // Provide a callback to look up real configs when available + getRealConfig: (pluginId: string) => { + const accountOutput = ai.props.output.accounts[accountId] + const accountApi = accountOutput?.accountApi + return accountApi?.currencyConfig[pluginId] + }, + // Pass through the login's pauseWallets option so cached + // wallets match the initial paused state of real wallets: + pauseWallets: accountState.pauseWallets + }) + + return cacheSetup + } catch (error: unknown) { + // Cache doesn't exist or failed to load, continue with normal flow. + // File-not-found is expected on first login; anything else is logged: + if (!isFileNotFoundError(error)) { + log.warn( + 'Login: cache loading failed with unexpected error:', + error + ) + } + return undefined + } + } + try { - // Wait for the currency plugins (should already be loaded by now): await waitForPlugins(ai) - await loadBuiltinTokens(ai, accountId) - log.warn('Login: currency plugins exist') - // Start the repo: + // Initialize storage wallets (cheap file reads, needed for both paths): await Promise.all( accountWalletInfos.map(info => addStorageWallet(ai, info)) ) log.warn('Login: synced account repos') + // Try cache-first login for instant UI: + const cacheSetup = await tryLoadCache() + if (cacheSetup != null) { + // Store cleanup function for use in destroy() + cacheCleanup = cacheSetup.cleanup + + // Create the API object with cached wallets: + input.onOutput(makeAccountApi(ai, accountId, { cacheSetup })) + + // Continue loading files in background to enable real engines. + // This dispatches ACCOUNT_KEYS_LOADED which sets keysLoaded=true, + // which populates activeWalletIds and triggers walletPixie to + // create real currency engines: + loadBuiltinTokens(ai, accountId) + .then(async () => { + if (ai.props.state.accounts[accountId] == null) return + await loadAllFiles() + }) + .catch((error: unknown) => { + // Check if account was logged out during async operation: + if (ai.props.state.accounts[accountId] == null) return + log.error('Login: background loading failed:', error) + input.props.dispatch({ + type: 'ACCOUNT_LOAD_FAILED', + payload: { accountId, error } + }) + }) + + return await stopUpdates + } + + // Normal login flow (no cache available): + await loadBuiltinTokens(ai, accountId) + await loadAllFiles() log.warn('Login: loaded files') @@ -212,6 +329,100 @@ const accountPixie: TamePixie = combinePixies({ } }, + /** + * Auto-saves the wallet cache with throttling (every 5 seconds at most). + * Change detection is reactive: the pixie's update() runs whenever Redux + * state changes, and marks the saver dirty. The saver has a single periodic + * task that writes to disk at most once every 5 seconds. + */ + cacheSaver: filterPixie( + (input: AccountInput) => { + let cacheSaver: WalletCacheSaver | undefined + const lastWalletStates: { [walletId: string]: unknown } = {} + let lastActiveWalletIds: string[] | undefined + let initialSaveDone = false + + return { + update() { + const ai = toApiInput(input) + const { accountId, accountOutput, accountState, state } = input.props + const accountApi = accountOutput?.accountApi + + // Guard: ensure account still exists + if (state.accounts[accountId] == null) return + + // Initialize cache saver once account API exists + if (accountApi != null && cacheSaver == null) { + const cachePath = getWalletCachePath( + accountState.accountWalletInfo.id + ) + + cacheSaver = makeWalletCacheSaver( + accountApi, + ai.props.io.disklet, + cachePath, + input.props.log.warn.bind(input.props.log) + ) + input.props.log('[WalletCache] Cache saver initialized') + } + + if (cacheSaver == null) return + + // Trigger initial save to persist cached wallet data from login. + // Wait until keysLoaded to ensure builtinTokens are populated, + // avoiding overwriting cached tokens with empty data: + if (!initialSaveDone && accountState.keysLoaded) { + initialSaveDone = true + cacheSaver.markDirty() + return + } + + // Reactively check for wallet state changes on each Redux update: + const accountReduxState = state.accounts[accountId] + if (accountReduxState == null) return + + let hasChanges = false + + // Check if the active wallet list itself changed (e.g., wallet archived) + if (lastActiveWalletIds !== accountReduxState.activeWalletIds) { + hasChanges = true + lastActiveWalletIds = accountReduxState.activeWalletIds + + for (const walletId of Object.keys(lastWalletStates)) { + if (!accountReduxState.activeWalletIds.includes(walletId)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete lastWalletStates[walletId] + } + } + } + + for (const walletId of accountReduxState.activeWalletIds) { + const walletState = state.currency.wallets[walletId] + if ( + walletState != null && + lastWalletStates[walletId] !== walletState + ) { + hasChanges = true + lastWalletStates[walletId] = walletState + } + } + + if (hasChanges) { + cacheSaver.markDirty() + } + }, + + destroy() { + if (cacheSaver != null) { + cacheSaver.stop() + cacheSaver = undefined + } + } + } + }, + props => (props.state.paused ? undefined : props) + ), + watcher(input: AccountInput) { let lastState: AccountState | undefined // let lastWallets @@ -249,12 +460,16 @@ const accountPixie: TamePixie = combinePixies({ } }, + // Outputs real wallets from the currency pixie. + // The account API's currencyWallets getter merges these with cached wallets. currencyWallets(input: AccountInput) { let lastActiveWalletIds: string[] return () => { const { accountOutput, accountState } = input.props const { activeWalletIds } = accountState + const { wallets: currencyWallets } = input.props.output.currency + let dirty = lastActiveWalletIds !== activeWalletIds lastActiveWalletIds = activeWalletIds @@ -264,9 +479,8 @@ const accountPixie: TamePixie = combinePixies({ } const out: { [walletId: string]: EdgeCurrencyWallet } = {} - const { wallets } = input.props.output.currency for (const walletId of activeWalletIds) { - const api = wallets[walletId]?.walletApi + const api = currencyWallets[walletId]?.walletApi if (api !== lastOut[walletId]) dirty = true if (api != null) out[walletId] = api } diff --git a/src/core/cache/cache-utils.ts b/src/core/cache/cache-utils.ts new file mode 100644 index 00000000..72782e41 --- /dev/null +++ b/src/core/cache/cache-utils.ts @@ -0,0 +1,160 @@ +import { bridgifyObject } from 'yaob' + +import { EdgeOtherMethods } from '../../types/types' + +/** How often to poll for a real object (ms) */ +const POLL_INTERVAL_MS = 500 + +/** Maximum time to wait for a real object before timing out (ms) */ +const MAX_WAIT_MS = 60000 + +/** + * Creates a shared poller that waits for a real object to become available. + * Uses a single shared promise so multiple callers don't spawn independent + * polling loops. Returns both `tryGet` (synchronous check) and `waitFor` + * (async wait with timeout). + */ +export function makeRealObjectPoller( + getter: () => T | undefined, + label: string +): { + tryGet: () => T | undefined + waitFor: () => Promise + cancel: () => void +} { + let sharedPromise: Promise | undefined + let activeTimeoutId: ReturnType | undefined + let activeReject: ((error: Error) => void) | undefined + + function tryGet(): T | undefined { + return getter() + } + + function cancel(): void { + if (activeTimeoutId != null) { + clearTimeout(activeTimeoutId) + activeTimeoutId = undefined + } + if (activeReject != null) { + activeReject(new Error(`Poller for ${label} was cancelled`)) + activeReject = undefined + } + sharedPromise = undefined + } + + function waitFor(): Promise { + // Fast path: already available + const immediate = tryGet() + if (immediate != null) return Promise.resolve(immediate) + + // Reuse shared promise so all callers share one poll loop + if (sharedPromise != null) return sharedPromise + + sharedPromise = new Promise((resolve, reject) => { + const startTime = Date.now() + activeReject = reject + + const cleanup = (): void => { + if (activeTimeoutId != null) { + clearTimeout(activeTimeoutId) + activeTimeoutId = undefined + } + activeReject = undefined + } + + const check = (): void => { + if (activeReject == null) return // Cancelled — stop polling + try { + const real = tryGet() + if (real != null) { + cleanup() + sharedPromise = undefined + resolve(real) + return + } + + if (Date.now() - startTime > MAX_WAIT_MS) { + cleanup() + sharedPromise = undefined + reject( + new Error(`Timed out waiting for ${label} after ${MAX_WAIT_MS}ms`) + ) + return + } + + activeTimeoutId = setTimeout(check, POLL_INTERVAL_MS) + } catch (error: unknown) { + cleanup() + sharedPromise = undefined + reject(error) + } + } + + activeTimeoutId = setTimeout(check, POLL_INTERVAL_MS) + }) + + return sharedPromise + } + + return { tryGet, waitFor, cancel } +} + +/** + * Helper to create delegating otherMethods that wait for the real object. + * Creates explicit functions for known method names only. + * Methods not in the cache will be undefined until the real object loads. + * + * @param methodNames - Array of method names to create delegating stubs for + * @param getRealOtherMethods - Callback to get real otherMethods if available + * @param waitForReal - Async function that waits for and returns the real object + * @param bridgify - If true, calls bridgifyObject on the result (needed for wallets) + */ +export function createDelegatingOtherMethods< + T extends { otherMethods: EdgeOtherMethods } +>( + methodNames: string[], + getRealOtherMethods: () => EdgeOtherMethods | undefined, + waitForReal: () => Promise, + bridgify: boolean = false +): EdgeOtherMethods { + // If no method names cached, check if real methods are immediately available + // If not, return empty object (GUI should check method existence) + if (methodNames.length === 0) { + const immediate = getRealOtherMethods() + if (immediate != null) { + if (bridgify) { + bridgifyObject(immediate) + } + return immediate + } + return {} + } + + const otherMethods: { [name: string]: unknown } = {} + + // Create explicit methods for all cached method names + for (const methodName of methodNames) { + otherMethods[methodName] = async (...args: unknown[]) => { + // First check if real methods are already available + const immediate = getRealOtherMethods() + if (immediate != null && typeof immediate[methodName] === 'function') { + return immediate[methodName](...args) + } + + // Wait for real wallet/config to load + const real = await waitForReal() + if (typeof real.otherMethods[methodName] !== 'function') { + throw new Error(`Method ${methodName} not available on real object`) + } + return real.otherMethods[methodName](...args) + } + } + + // Mark the otherMethods object itself as bridgeable (like real wallets do) + // This prevents yaob from trying to serialize individual function properties + if (bridgify) { + bridgifyObject(otherMethods) + } + + return otherMethods as EdgeOtherMethods +} diff --git a/src/core/cache/cache-wallet-cleaners.ts b/src/core/cache/cache-wallet-cleaners.ts new file mode 100644 index 00000000..5f8fef71 --- /dev/null +++ b/src/core/cache/cache-wallet-cleaners.ts @@ -0,0 +1,68 @@ +import { + asArray, + asObject, + asOptional, + asString, + asUnknown, + asValue +} from 'cleaners' + +import { asEdgeToken } from '../account/account-cleaners' + +// ---------------------------------------------------------------- +// Shared constants used across cache modules +// ---------------------------------------------------------------- + +/** Key used in balances map for the parent currency (null tokenId) */ +export const PARENT_CURRENCY_KEY = 'null' + +// ---------------------------------------------------------------- +// Cleaners +// ---------------------------------------------------------------- + +/** + * Cleaner for validating cached wallet data from disk. + * Each cached wallet contains essential state for instant UI display. + */ +export const asCachedWallet = asObject({ + id: asString, + type: asString, + name: asOptional(asString), + pluginId: asString, + fiatCurrencyCode: asString, + // tokenId (or "null" for parent currency) -> nativeAmount + balances: asObject(asString), + enabledTokenIds: asArray(asString), + // Method names from otherMethods for delegation + otherMethodNames: asArray(asString), + // Creation date (ISO string) + created: asString, + // Public wallet info (safe - no private keys). + // keys uses asUnknown to match JsonObject (plugins may store non-string values) + publicWalletInfo: asObject({ + id: asString, + type: asString, + keys: asObject(asUnknown) + }) +}) + +/** + * Cleaner for validating the wallet cache file structure from disk. + * The file contains token definitions and wallet state for all cached wallets. + */ +export const asWalletCacheFile = asObject({ + version: asValue(1), + // pluginId -> tokenId -> token + tokens: asObject(asObject(asEdgeToken)), + // pluginId -> tokenId -> token (user-added custom tokens) + customTokens: asOptional(asObject(asObject(asEdgeToken)), {}), + wallets: asArray(asCachedWallet), + // Config otherMethods names per plugin + configOtherMethodNames: asObject(asArray(asString)) +}) + +/** Cached wallet data structure, validated by asCachedWallet cleaner */ +export type CachedWallet = ReturnType + +/** Wallet cache file structure, validated by asWalletCacheFile cleaner */ +export type WalletCacheFile = ReturnType diff --git a/src/core/cache/cache-wallet-loader.ts b/src/core/cache/cache-wallet-loader.ts new file mode 100644 index 00000000..d050be52 --- /dev/null +++ b/src/core/cache/cache-wallet-loader.ts @@ -0,0 +1,148 @@ +import { asJSON } from 'cleaners' + +import { + EdgeCurrencyConfig, + EdgeCurrencyInfo, + EdgeCurrencyWallet, + EdgePluginMap +} from '../../types/types' +import { asWalletCacheFile, WalletCacheFile } from './cache-wallet-cleaners' +import { + CachedCurrencyConfigOptions, + makeCachedCurrencyConfig +} from './cached-currency-config' +import { + CachedWalletOptions, + makeCachedCurrencyWallet +} from './cached-currency-wallet' + +/** + * Result of loading wallet cache, containing all data needed to + * display cached wallets before real engines are loaded. + */ +export interface WalletCacheSetup { + /** Map of wallet ID to cached wallet object */ + currencyWallets: { [walletId: string]: EdgeCurrencyWallet } + /** List of active wallet IDs in display order */ + activeWalletIds: string[] + /** Cleanup function to stop all active pollers */ + cleanup: () => void +} + +/** + * Callback to get a real wallet by ID for delegation. + * Returns undefined if the real wallet is not yet available. + */ +type RealWalletLookup = (walletId: string) => EdgeCurrencyWallet | undefined + +/** + * Callback to get a real config by pluginId for delegation. + * Returns undefined if the real config is not yet available. + */ +type RealConfigLookup = (pluginId: string) => EdgeCurrencyConfig | undefined + +/** + * Options for loading wallet cache. + */ +export interface LoadWalletCacheOptions { + /** Map of walletId to options for cached wallet creation */ + walletOptions?: { [walletId: string]: CachedWalletOptions } + /** Callback to look up real wallets for delegation */ + getRealWallet?: RealWalletLookup + /** Callback to look up real configs for delegation */ + getRealConfig?: RealConfigLookup + /** If true, cached wallets start paused (matching the login option) */ + pauseWallets?: boolean +} + +/** + * Loads wallet cache data from a JSON string and creates cached wallet objects. + * @param jsonData The raw JSON string containing wallet cache data + * @param currencyInfos Map of pluginId to EdgeCurrencyInfo for available plugins + * @param options Optional configuration for cached wallet creation + * @returns Setup data for cached wallets including wallets and active IDs + */ +export function loadWalletCache( + jsonData: string, + currencyInfos: EdgePluginMap, + options: LoadWalletCacheOptions = {} +): WalletCacheSetup { + const { + walletOptions = {}, + getRealWallet, + getRealConfig, + pauseWallets + } = options + + // Parse and validate the wallet cache file. + // asJSON wraps SyntaxError with context and avoids untyped `any` from JSON.parse: + const asWalletCacheJson = asJSON(asWalletCacheFile) + const cacheFile: WalletCacheFile = asWalletCacheJson(jsonData) + + // Create currency configs for each plugin that has wallets + const currencyConfigs: EdgePluginMap = {} + const pluginIds = new Set(cacheFile.wallets.map(w => w.pluginId)) + const cleanupFunctions: Array<() => void> = [] + + for (const pluginId of pluginIds) { + const currencyInfo = currencyInfos[pluginId] + if (currencyInfo == null) { + continue + } + + // Create the cached config with real config lookup for delegation + const cachedConfigOptions: CachedCurrencyConfigOptions = { + getRealConfig: + getRealConfig != null ? () => getRealConfig(pluginId) : undefined + } + const { config, cleanup } = makeCachedCurrencyConfig( + pluginId, + currencyInfo, + cacheFile, + cachedConfigOptions + ) + currencyConfigs[pluginId] = config + cleanupFunctions.push(cleanup) + } + + // Create cached wallets + const currencyWallets: { [walletId: string]: EdgeCurrencyWallet } = {} + const activeWalletIds: string[] = [] + + for (const cachedWallet of cacheFile.wallets) { + const { pluginId, id: walletId } = cachedWallet + const currencyConfig = currencyConfigs[pluginId] + const currencyInfo = currencyInfos[pluginId] + + if (currencyConfig == null || currencyInfo == null) { + continue + } + + // Create the cached wallet with real wallet lookup for delegation + const cachedWalletOptions: CachedWalletOptions = { + ...walletOptions[walletId], + getRealWallet: + getRealWallet != null ? () => getRealWallet(walletId) : undefined, + pauseWallets + } + const { wallet, cleanup } = makeCachedCurrencyWallet( + cachedWallet, + currencyInfo, + currencyConfig, + cachedWalletOptions + ) + currencyWallets[walletId] = wallet + cleanupFunctions.push(cleanup) + activeWalletIds.push(walletId) + } + + return { + currencyWallets, + activeWalletIds, + cleanup: () => { + for (const cleanupFn of cleanupFunctions) { + cleanupFn() + } + } + } +} diff --git a/src/core/cache/cache-wallet-saver.ts b/src/core/cache/cache-wallet-saver.ts new file mode 100644 index 00000000..247909d2 --- /dev/null +++ b/src/core/cache/cache-wallet-saver.ts @@ -0,0 +1,250 @@ +import { Disklet } from 'disklet' + +import { EdgeAccount, EdgeLog } from '../../types/types' +import { + asWalletCacheFile, + PARENT_CURRENCY_KEY, + WalletCacheFile +} from './cache-wallet-cleaners' + +/** Default minimum interval between saves: 5 seconds */ +const DEFAULT_THROTTLE_MS = 5000 +const MAX_CONSECUTIVE_FAILURES = 3 +const LOG_PREFIX = '[WalletCache]' + +/** + * Test-only configuration for the wallet cache saver. + * Set throttleMs to a low value (e.g. 50) in test setup to avoid long snoozes. + */ +export const walletCacheSaverConfig = { + throttleMs: undefined as number | undefined +} + +// Type for the log function +type LogFn = EdgeLog['warn'] + +/** + * Interface for controlling the wallet cache saver. + * Call markDirty() when wallet state changes to trigger a save. + * Call stop() when the account is destroyed to clean up resources. + */ +export interface WalletCacheSaver { + /** Mark the cache as dirty, triggering a save as soon as the throttle allows */ + markDirty: () => void + /** Stop the saver and clean up resources */ + stop: () => void +} + +export interface WalletCacheSaverOptions { + /** Minimum interval between saves in ms (default 5000). */ + throttleMs?: number +} + +/** + * Creates a dirty-triggered throttled cache saver. + * + * When markDirty() is called: + * - If enough time has passed since the last save (>= throttleMs), + * the save happens immediately. + * - If a save happened recently, the save is scheduled for when the + * throttle window expires. + * + * This ensures changes are persisted as quickly as possible while + * limiting disk writes to at most once per throttle interval. + */ +export function makeWalletCacheSaver( + account: EdgeAccount, + disklet: Disklet, + cachePath: string, + log: LogFn, + opts: WalletCacheSaverOptions = {} +): WalletCacheSaver { + const { + throttleMs = walletCacheSaverConfig.throttleMs ?? DEFAULT_THROTTLE_MS + } = opts + let isDirty = false + let isSaving = false + let isStopped = false + let gaveUp = false + let consecutiveFailures = 0 + let lastSaveTime = 0 + let pendingTimeout: ReturnType | undefined + + async function doSave(): Promise { + if (isStopped || isSaving || !isDirty) return + isSaving = true + isDirty = false + + try { + // Guard: verify account is still valid before accessing its properties. + if (!account.loggedIn) { + log(`${LOG_PREFIX} Skipping save: account no longer valid`) + return + } + + // Collect enabled token IDs per plugin from all active wallets. + // Only cache tokens enabled by at least one wallet to minimize size. + const enabledTokensByPlugin: { [pluginId: string]: Set } = {} + for (const walletId of account.activeWalletIds) { + const wallet = account.currencyWallets[walletId] + if (wallet == null) continue + + const pluginId = wallet.currencyInfo.pluginId + if (enabledTokensByPlugin[pluginId] == null) { + enabledTokensByPlugin[pluginId] = new Set() + } + for (const tokenId of wallet.enabledTokenIds) { + enabledTokensByPlugin[pluginId].add(tokenId) + } + } + + // Build token map from only enabled tokens: + const tokens: WalletCacheFile['tokens'] = {} + for (const [pluginId, config] of Object.entries(account.currencyConfig)) { + const enabledTokenIds = enabledTokensByPlugin[pluginId] + if (enabledTokenIds == null || enabledTokenIds.size === 0) continue + + const pluginTokens: WalletCacheFile['tokens'][string] = {} + for (const tokenId of enabledTokenIds) { + const token = config.allTokens[tokenId] + if (token != null) { + pluginTokens[tokenId] = token + } + } + if (Object.keys(pluginTokens).length > 0) { + tokens[pluginId] = pluginTokens + } + } + + // Save custom tokens per plugin: + const customTokens: WalletCacheFile['customTokens'] = {} + for (const [pluginId, config] of Object.entries(account.currencyConfig)) { + const pluginCustomTokens = config.customTokens + if (Object.keys(pluginCustomTokens).length > 0) { + customTokens[pluginId] = pluginCustomTokens + } + } + + // Save config otherMethods names per plugin: + const configOtherMethodNames: WalletCacheFile['configOtherMethodNames'] = + {} + for (const [pluginId, config] of Object.entries(account.currencyConfig)) { + const methodNames = Object.keys(config.otherMethods) + if (methodNames.length > 0) { + configOtherMethodNames[pluginId] = methodNames + } + } + + // Build wallet array from active wallets: + const wallets: WalletCacheFile['wallets'] = [] + for (const walletId of account.activeWalletIds) { + const wallet = account.currencyWallets[walletId] + if (wallet == null) continue + + const balances: { [tokenId: string]: string } = {} + for (const [tokenId, balance] of wallet.balanceMap) { + const key = tokenId ?? PARENT_CURRENCY_KEY + balances[key] = balance + } + + const otherMethodNames = Object.keys(wallet.otherMethods) + + wallets.push({ + id: wallet.id, + type: wallet.type, + name: wallet.name ?? undefined, + pluginId: wallet.currencyInfo.pluginId, + fiatCurrencyCode: wallet.fiatCurrencyCode, + balances, + enabledTokenIds: wallet.enabledTokenIds, + otherMethodNames, + created: (wallet.created ?? new Date()).toISOString(), + publicWalletInfo: wallet.publicWalletInfo + }) + } + + // Validate at write time so malformed data is caught immediately + // rather than producing an unusable cache on next login: + const cacheFile: WalletCacheFile = asWalletCacheFile({ + version: 1, + tokens, + customTokens, + wallets, + configOtherMethodNames + }) + + const cacheJson = JSON.stringify(cacheFile, null, 2) + await disklet.setText(cachePath, cacheJson) + lastSaveTime = Date.now() + log( + `${LOG_PREFIX} Saved cache: ${wallets.length} wallets, ${ + Object.keys(tokens).length + } plugins` + ) + consecutiveFailures = 0 + } catch (error: unknown) { + consecutiveFailures++ + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + gaveUp = true + log( + `${LOG_PREFIX} Failed to save cache ${consecutiveFailures} times, giving up:`, + error + ) + } else { + log(`${LOG_PREFIX} Failed to save cache:`, error) + // Re-mark dirty so the finally block schedules a retry: + if (!isStopped) isDirty = true + } + } finally { + isSaving = false + + // If more changes arrived while saving, schedule another save: + if (isDirty && !isStopped) { + scheduleSave() + } + } + } + + /** + * Schedules a save based on how long ago the last save was: + * - If >= throttleMs has passed, save immediately. + * - Otherwise, schedule for when the throttle window expires. + * Only one pending timeout exists at a time. + */ + function scheduleSave(): void { + if (pendingTimeout != null) return // Already scheduled + if (isStopped) return + + const elapsed = Date.now() - lastSaveTime + if (elapsed >= throttleMs) { + // Enough time has passed, save immediately (async). + doSave().catch((error: unknown) => { + if (!isStopped) log(`${LOG_PREFIX} Unexpected save rejection:`, error) + }) + } else { + // Schedule save for when the throttle window expires: + const delay = throttleMs - elapsed + pendingTimeout = setTimeout(() => { + pendingTimeout = undefined + doSave().catch((error: unknown) => { + if (!isStopped) log(`${LOG_PREFIX} Unexpected save rejection:`, error) + }) + }, delay) + } + } + + return { + markDirty(): void { + if (isStopped || gaveUp) return + isDirty = true + scheduleSave() + }, + stop(): void { + isStopped = true + if (pendingTimeout != null) { + clearTimeout(pendingTimeout) + pendingTimeout = undefined + } + } + } +} diff --git a/src/core/cache/cached-currency-config.ts b/src/core/cache/cached-currency-config.ts new file mode 100644 index 00000000..131605c8 --- /dev/null +++ b/src/core/cache/cached-currency-config.ts @@ -0,0 +1,169 @@ +import { bridgifyObject, watchMethod } from 'yaob' + +import { + EdgeCurrencyConfig, + EdgeCurrencyInfo, + EdgeGetTokenDetailsFilter, + EdgeToken, + EdgeTokenMap +} from '../../types/types' +import { + createDelegatingOtherMethods, + makeRealObjectPoller +} from './cache-utils' +import { WalletCacheFile } from './cache-wallet-cleaners' + +/** + * Options for creating a cached currency config. + */ +export interface CachedCurrencyConfigOptions { + /** Callback to get the real config for delegation */ + getRealConfig?: () => EdgeCurrencyConfig | undefined +} + +/** + * Result of creating a cached currency config. + */ +export interface CachedCurrencyConfigResult { + config: EdgeCurrencyConfig + cleanup: () => void +} + +/** + * Creates a cached EdgeCurrencyConfig for a plugin. + * Used for cache-first login without real plugin instantiation. + */ +export function makeCachedCurrencyConfig( + pluginId: string, + currencyInfo: EdgeCurrencyInfo, + cacheFile: WalletCacheFile, + options: CachedCurrencyConfigOptions = {} +): CachedCurrencyConfigResult { + const { getRealConfig } = options + + // Shared poller: single poll loop for all callers + const poller = makeRealObjectPoller(() => { + if (getRealConfig == null) return undefined + const realConfig = getRealConfig() + // Don't delegate to self + if (realConfig != null && realConfig !== config) { + return realConfig + } + return undefined + }, `config ${pluginId}`) + + const { + tryGet: tryGetRealConfig, + waitFor: waitForRealConfig, + cancel: cancelPoller + } = poller + + /** + * Delegates an async method call to the real config. + */ + async function delegate( + fn: (c: EdgeCurrencyConfig) => Promise + ): Promise { + const immediate = tryGetRealConfig() + if (immediate != null) return await fn(immediate) + return await fn(await waitForRealConfig()) + } + + // Build token maps from cached data (cached tokens are EdgeTokens) + const allTokens: EdgeTokenMap = cacheFile.tokens[pluginId] ?? {} + const customTokens: EdgeTokenMap = cacheFile.customTokens[pluginId] ?? {} + // Compute builtinTokens by excluding custom tokens from allTokens + const builtinTokens: EdgeTokenMap = Object.fromEntries( + Object.entries(allTokens).filter( + ([tokenId]) => customTokens[tokenId] == null + ) + ) + + // Get otherMethods names for this plugin + const otherMethodNames = cacheFile.configOtherMethodNames[pluginId] ?? [] + + const config: EdgeCurrencyConfig = { + watch: watchMethod, + + currencyInfo, + + // Tokens (delegate to real config when available, fall back to cache): + get allTokens(): EdgeTokenMap { + const realConfig = tryGetRealConfig() + return realConfig != null ? realConfig.allTokens : allTokens + }, + get builtinTokens(): EdgeTokenMap { + const realConfig = tryGetRealConfig() + return realConfig != null ? realConfig.builtinTokens : builtinTokens + }, + get customTokens(): EdgeTokenMap { + const realConfig = tryGetRealConfig() + return realConfig != null ? realConfig.customTokens : customTokens + }, + + // Token methods (need real config, delegate): + async getTokenDetails( + filter: EdgeGetTokenDetailsFilter + ): Promise { + return await delegate(async c => await c.getTokenDetails(filter)) + }, + + async getTokenId(token: EdgeToken): Promise { + return await delegate(async c => await c.getTokenId(token)) + }, + + async addCustomToken(token: EdgeToken): Promise { + return await delegate(async c => await c.addCustomToken(token)) + }, + + async changeCustomToken(tokenId: string, token: EdgeToken): Promise { + return await delegate( + async c => await c.changeCustomToken(tokenId, token) + ) + }, + + async removeCustomToken(tokenId: string): Promise { + return await delegate(async c => await c.removeCustomToken(tokenId)) + }, + + // Always-enabled tokens (delegate when available, write delegates): + get alwaysEnabledTokenIds(): string[] { + const realConfig = tryGetRealConfig() + return realConfig != null ? realConfig.alwaysEnabledTokenIds : [] + }, + + async changeAlwaysEnabledTokenIds(tokenIds: string[]): Promise { + return await delegate( + async c => await c.changeAlwaysEnabledTokenIds(tokenIds) + ) + }, + + // User settings (delegate when available, write delegates): + get userSettings(): object | undefined { + const realConfig = tryGetRealConfig() + return realConfig != null ? realConfig.userSettings : {} + }, + + async changeUserSettings(settings: object): Promise { + return await delegate(async c => await c.changeUserSettings(settings)) + }, + + // Utility methods (need real config, delegate): + async importKey( + userInput: string, + opts?: { keyOptions?: object } + ): Promise { + return await delegate(async c => await c.importKey(userInput, opts)) + }, + + // Generic - create delegating stubs for otherMethods + otherMethods: createDelegatingOtherMethods( + otherMethodNames, + () => tryGetRealConfig()?.otherMethods, + waitForRealConfig, + true // bridgify for config otherMethods + ) + } + + return { config: bridgifyObject(config), cleanup: cancelPoller } +} diff --git a/src/core/cache/cached-currency-wallet.ts b/src/core/cache/cached-currency-wallet.ts new file mode 100644 index 00000000..72ce4940 --- /dev/null +++ b/src/core/cache/cached-currency-wallet.ts @@ -0,0 +1,543 @@ +import { Disklet, DiskletListing } from 'disklet' +import { bridgifyObject, onMethod, update, watchMethod } from 'yaob' + +import { InternalWalletMethods, streamTransactions } from '../../client-side' +import { + EdgeAddress, + EdgeBalanceMap, + EdgeBalances, + EdgeCurrencyConfig, + EdgeCurrencyInfo, + EdgeCurrencyWallet, + EdgeDataDump, + EdgeEncodeUri, + EdgeGetReceiveAddressOptions, + EdgeGetTransactionsOptions, + EdgeParsedUri, + EdgePaymentProtocolInfo, + EdgeReceiveAddress, + EdgeResult, + EdgeSaveTxActionOptions, + EdgeSaveTxMetadataOptions, + EdgeSignMessageOptions, + EdgeSpendInfo, + EdgeSplitCurrencyWallet, + EdgeStakingStatus, + EdgeStreamTransactionOptions, + EdgeSyncStatus, + EdgeTokenIdOptions, + EdgeTransaction, + EdgeWalletInfo +} from '../../types/types' +import { + createDelegatingOtherMethods, + makeRealObjectPoller +} from './cache-utils' +import { CachedWallet, PARENT_CURRENCY_KEY } from './cache-wallet-cleaners' + +/** + * Sync ratio returned by cached wallets to indicate "partially loaded" state. + * Using 0.05 shows visual progress in the UI (not 0% or 100%) while engines sync. + */ +const CACHE_MODE_SYNC_RATIO = 0.05 + +/** Number of characters to show when logging wallet IDs */ +const WALLET_ID_DISPLAY_LENGTH = 8 + +/** Default batch size for streaming transactions */ +const DEFAULT_BATCH_SIZE = 10 + +/** + * Options for creating a cached wallet. + */ +export interface CachedWalletOptions { + /** Callback to get the real wallet for delegation */ + getRealWallet?: () => EdgeCurrencyWallet | undefined + /** If true, cached wallets start paused (matching real wallet behavior) */ + pauseWallets?: boolean +} + +/** + * Result of creating a cached wallet. + */ +export interface CachedWalletResult { + wallet: EdgeCurrencyWallet + cleanup: () => void +} + +/** + * Creates a delegating disklet that forwards all operations to the real + * wallet's disklet when available. This ensures cached wallets don't use + * memory disklets that would lose data. + * + * The returned disklet is bridged for yaob serialization. + */ +function makeDelegatingDisklet( + tryGetRealWallet: () => EdgeCurrencyWallet | undefined, + waitForRealWallet: () => Promise, + diskletKey: 'disklet' | 'localDisklet' +): Disklet { + const disklet: Disklet = { + async delete(path: string): Promise { + const immediate = tryGetRealWallet() + if (immediate != null) { + return await immediate[diskletKey].delete(path) + } + const realWallet = await waitForRealWallet() + return await realWallet[diskletKey].delete(path) + }, + async getData(path: string): Promise { + const immediate = tryGetRealWallet() + if (immediate != null) { + return await immediate[diskletKey].getData(path) + } + const realWallet = await waitForRealWallet() + return await realWallet[diskletKey].getData(path) + }, + async getText(path: string): Promise { + const immediate = tryGetRealWallet() + if (immediate != null) { + return await immediate[diskletKey].getText(path) + } + const realWallet = await waitForRealWallet() + return await realWallet[diskletKey].getText(path) + }, + async list(path?: string): Promise { + const immediate = tryGetRealWallet() + if (immediate != null) { + return await immediate[diskletKey].list(path) + } + const realWallet = await waitForRealWallet() + return await realWallet[diskletKey].list(path) + }, + async setData(path: string, data: ArrayLike): Promise { + const immediate = tryGetRealWallet() + if (immediate != null) { + return await immediate[diskletKey].setData(path, data) + } + const realWallet = await waitForRealWallet() + return await realWallet[diskletKey].setData(path, data) + }, + async setText(path: string, text: string): Promise { + const immediate = tryGetRealWallet() + if (immediate != null) { + return await immediate[diskletKey].setText(path, text) + } + const realWallet = await waitForRealWallet() + return await realWallet[diskletKey].setText(path, text) + } + } + return bridgifyObject(disklet) +} + +/** + * Creates a cached EdgeCurrencyWallet that provides instant read-only data. + * Methods that require the real wallet will delegate if available, or wait + * for the real wallet to load via a shared polling promise. + */ +export function makeCachedCurrencyWallet( + cacheData: CachedWallet, + currencyInfo: EdgeCurrencyInfo, + currencyConfig: EdgeCurrencyConfig, + options: CachedWalletOptions = {} +): CachedWalletResult { + const { getRealWallet, pauseWallets = false } = options + const { + id: walletId, + type, + name, + fiatCurrencyCode, + balances, + enabledTokenIds, + otherMethodNames, + created: createdString, + publicWalletInfo: cachedPublicWalletInfo + } = cacheData + + const shortId = walletId.slice(0, WALLET_ID_DISPLAY_LENGTH) + const createdDate = new Date(createdString) + + // Track mutable state locally. When the GUI calls a setter, we update + // the local value immediately and call update(wallet) to push it + // through yaob to the GUI side. Without this, yaob's client-side proxy + // would cache the old getter value indefinitely since no pixie calls + // update() on cached wallet objects. + let localPaused = pauseWallets + let localName: string | undefined = name + let localFiatCurrencyCode = fiatCurrencyCode + let localEnabledTokenIds = enabledTokenIds + + // Shared poller: single poll loop for all callers, reuses the same promise + const poller = makeRealObjectPoller(() => { + if (getRealWallet == null) return undefined + const realWallet = getRealWallet() + // Don't delegate to self + if (realWallet != null && realWallet !== wallet) { + return realWallet + } + return undefined + }, `wallet ${shortId}`) + + const { + tryGet: tryGetRealWallet, + waitFor: waitForRealWallet, + cancel: cancelPoller + } = poller + + /** + * Delegates an async method call to the real wallet. + * Checks synchronously first, then waits if needed. + */ + async function delegate( + fn: (w: EdgeCurrencyWallet) => Promise + ): Promise { + const immediate = tryGetRealWallet() + if (immediate != null) return await fn(immediate) + return await fn(await waitForRealWallet()) + } + + // Build balance map from cached data + const cachedBalanceMap: EdgeBalanceMap = new Map() + const cachedBalancesObj: EdgeBalances = {} + for (const [tokenIdStr, amount] of Object.entries(balances)) { + const tokenId = tokenIdStr === PARENT_CURRENCY_KEY ? null : tokenIdStr + cachedBalanceMap.set(tokenId, amount) + + // Get currency code for the balances object + if (tokenId === null) { + cachedBalancesObj[currencyInfo.currencyCode] = amount + } else { + const token = currencyConfig.allTokens[tokenId] + if (token != null) { + cachedBalancesObj[token.currencyCode] = amount + } + } + } + + // Create delegating disklets that forward to the real wallet's disklets + // when available. This prevents data loss from using memory disklets. + const disklet = makeDelegatingDisklet( + tryGetRealWallet, + waitForRealWallet, + 'disklet' + ) + const localDisklet = makeDelegatingDisklet( + tryGetRealWallet, + waitForRealWallet, + 'localDisklet' + ) + + // The wallet object includes internal methods for yaob compatibility + // ($internalStreamTransactions is called by client-side streamTransactions) + const wallet: EdgeCurrencyWallet & InternalWalletMethods = { + // Note: watch/on callbacks registered on this cached wallet will not fire + // from the pixie system (which only calls update() on real wallets). + // Setters like renameWallet/setFiatCurrencyCode call update(wallet) to + // push local changes through yaob, but watch/on won't fire reactively. + // This is acceptable because: + // - All getters delegate to the real wallet, so reads return live data. + // - The GUI re-grabs wallets from `account.currencyWallets` on re-render, + // which swaps in the real wallet and triggers re-subscription. + on: onMethod, + watch: watchMethod, + + // Data store: + get created(): Date | undefined { + return createdDate + }, + get disklet() { + return disklet + }, + get id(): string { + return walletId + }, + get localDisklet() { + return localDisklet + }, + publicWalletInfo: cachedPublicWalletInfo as EdgeWalletInfo, + async sync(): Promise { + return await delegate(async w => await w.sync()) + }, + get type(): string { + return type + }, + + // Wallet name: + get name(): string | null { + const realWallet = tryGetRealWallet() + return realWallet != null ? realWallet.name : localName ?? null + }, + async renameWallet(newName: string): Promise { + await delegate(async w => await w.renameWallet(newName)) + localName = newName + update(wallet) + }, + + // Fiat currency option: + get fiatCurrencyCode(): string { + const realWallet = tryGetRealWallet() + return realWallet != null + ? realWallet.fiatCurrencyCode + : localFiatCurrencyCode + }, + async setFiatCurrencyCode(code: string): Promise { + await delegate(async w => await w.setFiatCurrencyCode(code)) + localFiatCurrencyCode = code + update(wallet) + }, + + // Currency info: + get currencyConfig(): EdgeCurrencyConfig { + const realWallet = tryGetRealWallet() + return realWallet != null ? realWallet.currencyConfig : currencyConfig + }, + get currencyInfo(): EdgeCurrencyInfo { + return currencyInfo + }, + + // Chain state (delegate to real wallet when available, otherwise use cached): + get balanceMap(): EdgeBalanceMap { + const realWallet = tryGetRealWallet() + return realWallet != null ? realWallet.balanceMap : cachedBalanceMap + }, + get balances(): EdgeBalances { + const realWallet = tryGetRealWallet() + return realWallet != null ? realWallet.balances : cachedBalancesObj + }, + get blockHeight(): number { + const realWallet = tryGetRealWallet() + return realWallet != null ? realWallet.blockHeight : 0 + }, + get syncStatus(): EdgeSyncStatus { + const realWallet = tryGetRealWallet() + return realWallet != null + ? realWallet.syncStatus + : { totalRatio: CACHE_MODE_SYNC_RATIO } + }, + get syncRatio(): number { + const realWallet = tryGetRealWallet() + return realWallet != null ? realWallet.syncRatio : CACHE_MODE_SYNC_RATIO + }, + get unactivatedTokenIds(): string[] { + const realWallet = tryGetRealWallet() + return realWallet != null ? realWallet.unactivatedTokenIds : [] + }, + + // Running state: + // Paused starts from the login's pauseWallets option. When the GUI + // calls changePaused, we update localPaused immediately and call + // update(wallet) to propagate through yaob to the client side. + // This ensures the GUI sees the paused change without needing a + // pixie-driven update cycle. + async changePaused(paused: boolean): Promise { + await delegate(async w => await w.changePaused(paused)) + localPaused = paused + update(wallet) + }, + get paused(): boolean { + const realWallet = tryGetRealWallet() + return realWallet != null ? realWallet.paused : localPaused + }, + + // Token management: + async changeEnabledTokenIds(tokenIds: string[]): Promise { + await delegate(async w => await w.changeEnabledTokenIds(tokenIds)) + localEnabledTokenIds = tokenIds + update(wallet) + }, + get enabledTokenIds(): string[] { + const realWallet = tryGetRealWallet() + return realWallet != null + ? realWallet.enabledTokenIds + : localEnabledTokenIds + }, + get detectedTokenIds(): string[] { + const realWallet = tryGetRealWallet() + return realWallet != null ? realWallet.detectedTokenIds : [] + }, + + // Transaction history (delegates to real wallet): + async getNumTransactions(opts: EdgeTokenIdOptions): Promise { + return await delegate(async w => await w.getNumTransactions(opts)) + }, + async getTransactions( + opts: EdgeGetTransactionsOptions + ): Promise { + return await delegate(async w => await w.getTransactions(opts)) + }, + // Use the shared streamTransactions function from client-side.ts + // This function calls $internalStreamTransactions on the bridged wallet + streamTransactions, + + // Internal method used by yaob's client-side streamTransactions wrapper. + // This follows the InternalWalletStream pattern from client-side.ts. + async $internalStreamTransactions( + opts: EdgeStreamTransactionOptions + ): Promise<{ + next: () => Promise<{ done: boolean; value: EdgeTransaction[] }> + }> { + const realWallet = await waitForRealWallet() + + // Double cast needed: $internalStreamTransactions is an internal + // bridge method not on the public EdgeCurrencyWallet type. + const internalMethod = ( + realWallet as unknown as Partial + ).$internalStreamTransactions + + if (internalMethod != null) { + return await internalMethod.call(realWallet, opts) + } + + // Fallback: create a stream from getTransactions + const transactions = await realWallet.getTransactions(opts) + let index = 0 + return { + next: async () => { + if (index >= transactions.length) { + return { done: true, value: [] } + } + const batch = transactions.slice( + index, + index + (opts.batchSize ?? DEFAULT_BATCH_SIZE) + ) + index += batch.length + return { done: false, value: batch } + } + } + }, + + // Addresses (delegates to real wallet): + async getAddresses( + opts: EdgeGetReceiveAddressOptions + ): Promise { + return await delegate(async w => await w.getAddresses(opts)) + }, + + // Sending (delegates to real wallet): + async broadcastTx(tx: EdgeTransaction): Promise { + return await delegate(async w => await w.broadcastTx(tx)) + }, + async getMaxSpendable(spendInfo: EdgeSpendInfo): Promise { + return await delegate(async w => await w.getMaxSpendable(spendInfo)) + }, + async getPaymentProtocolInfo( + url: string + ): Promise { + return await delegate(async w => await w.getPaymentProtocolInfo(url)) + }, + async makeSpend(spendInfo: EdgeSpendInfo): Promise { + return await delegate(async w => await w.makeSpend(spendInfo)) + }, + async saveTx(tx: EdgeTransaction): Promise { + return await delegate(async w => await w.saveTx(tx)) + }, + async saveTxAction(opts: EdgeSaveTxActionOptions): Promise { + return await delegate(async w => await w.saveTxAction(opts)) + }, + async saveTxMetadata(opts: EdgeSaveTxMetadataOptions): Promise { + return await delegate(async w => await w.saveTxMetadata(opts)) + }, + async signTx(tx: EdgeTransaction): Promise { + return await delegate(async w => await w.signTx(tx)) + }, + async sweepPrivateKeys(spendInfo: EdgeSpendInfo): Promise { + return await delegate(async w => await w.sweepPrivateKeys(spendInfo)) + }, + + // Signing (delegates to real wallet): + async signBytes( + bytes: Uint8Array, + opts?: EdgeSignMessageOptions + ): Promise { + return await delegate(async w => await w.signBytes(bytes, opts)) + }, + + // Accelerating (delegates to real wallet): + async accelerate(tx: EdgeTransaction): Promise { + return await delegate(async w => await w.accelerate(tx)) + }, + + // Staking (delegate to real wallet when available): + get stakingStatus(): EdgeStakingStatus { + const realWallet = tryGetRealWallet() + return realWallet != null + ? realWallet.stakingStatus + : { stakedAmounts: [] } + }, + + // Wallet management (delegates to real wallet): + async dumpData(): Promise { + return await delegate(async w => await w.dumpData()) + }, + async resyncBlockchain(): Promise { + return await delegate(async w => await w.resyncBlockchain()) + }, + async split( + splitWallets: EdgeSplitCurrencyWallet[] + ): Promise>> { + return await delegate(async w => await w.split(splitWallets)) + }, + + // URI handling (delegates to real wallet for proper implementation): + async encodeUri(obj: EdgeEncodeUri): Promise { + return await delegate(async w => await w.encodeUri(obj)) + }, + async parseUri(uri: string, currencyCode?: string): Promise { + return await delegate(async w => await w.parseUri(uri, currencyCode)) + }, + + // Generic - create delegating stubs for otherMethods + // These are bridged by yaob and callable by the GUI + otherMethods: createDelegatingOtherMethods( + otherMethodNames, + () => tryGetRealWallet()?.otherMethods, + waitForRealWallet, + true // bridgify for wallet otherMethods + ), + + // Deprecated methods (delegate to real wallet): + async denominationToNative( + amount: string, + currencyCode: string + ): Promise { + return await delegate( + async w => await w.denominationToNative(amount, currencyCode) + ) + }, + async nativeToDenomination( + amount: string, + currencyCode: string + ): Promise { + return await delegate( + async w => await w.nativeToDenomination(amount, currencyCode) + ) + }, + async getReceiveAddress( + opts: EdgeGetReceiveAddressOptions + ): Promise { + return await delegate(async w => await w.getReceiveAddress(opts)) + }, + async lockReceiveAddress( + receiveAddress: EdgeReceiveAddress + ): Promise { + return await delegate( + async w => await w.lockReceiveAddress(receiveAddress) + ) + }, + async saveReceiveAddress( + receiveAddress: EdgeReceiveAddress + ): Promise { + return await delegate( + async w => await w.saveReceiveAddress(receiveAddress) + ) + }, + async signMessage( + message: string, + opts?: EdgeSignMessageOptions + ): Promise { + return await delegate(async w => await w.signMessage(message, opts)) + } + } + + return { wallet: bridgifyObject(wallet), cleanup: cancelPoller } +} diff --git a/test/core/cache/wallet-cache.test.ts b/test/core/cache/wallet-cache.test.ts new file mode 100644 index 00000000..19fd7dd0 --- /dev/null +++ b/test/core/cache/wallet-cache.test.ts @@ -0,0 +1,1470 @@ +/** + * End-to-end tests for wallet caching (cache-first login). + * + * Tests the complete flow: + * 1. Cache saving when balances/wallets change + * 2. Cache loading on subsequent login (instant wallet display) + * 3. Delegation from cached wallet to real wallet + * 4. waitForCurrencyWallet returning cached wallet immediately + */ + +import '../../fake/fake-plugins' + +import { makeAssertLog } from 'assert-log' +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { walletCacheSaverConfig } from '../../../src/core/cache/cache-wallet-saver' +import { EdgeToken } from '../../../src/index' +import { 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: '' } +const quiet = { onLog() {} } + +const CACHE_SAVE_WAIT_MS = 100 +const BRIDGE_SETTLE_MS = 50 + +describe('wallet cache', function () { + // Reset test config before and after each test + beforeEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.noOtherMethods = false + walletCacheSaverConfig.throttleMs = 50 + }) + + afterEach(function () { + fakePluginTestConfig.engineGate = undefined + fakePluginTestConfig.noOtherMethods = false + walletCacheSaverConfig.throttleMs = undefined + }) + + it('provides cached wallets before engine loads', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache normally (no delay) + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + const wallet1 = await account1.waitForCurrencyWallet(walletInfo.id) + await wallet1.renameWallet('Cached Wallet') + await account1.currencyConfig.fakecoin.changeUserSettings({ + balance: 12345 + }) + + // Wait for async balance callback to propagate through yaob bridge + await snooze(CACHE_SAVE_WAIT_MS) + + // Verify balance is set + expect(wallet1.balances.FAKE).equals('12345') + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + + await account1.logout() + + // Second login - use gate to block engine creation and verify cache is used + const { gate } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Check IMMEDIATELY - wallet must be from cache since engine is blocked by gate + const cachedWallet = account2.currencyWallets[walletInfo.id] + expect(cachedWallet).not.equals(undefined, 'Cached wallet should exist') + expect(cachedWallet.name).equals('Cached Wallet') + expect(cachedWallet.balances.FAKE).equals('12345') + + // Cached wallet should show partial sync ratio (0.05) to indicate cache-loaded state + expect(cachedWallet.syncRatio).equals(0.05) + + await account2.logout() + }) + + it('waitForCurrencyWallet returns cached wallet immediately', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + await account1.currencyConfig.fakecoin.changeUserSettings({ balance: 9999 }) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to block engine + const { gate } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // waitForCurrencyWallet should return immediately (from cache), not wait for gate + const startTime = Date.now() + const wallet = await account2.waitForCurrencyWallet(walletInfo.id) + const elapsed = Date.now() - startTime + + expect(elapsed).lessThan( + 1000, + 'waitForCurrencyWallet should return instantly from cache' + ) + expect(wallet.balances.FAKE).equals('9999') + + await account2.logout() + }) + + it('delegates to real wallet after engine loads', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + await account1.currencyConfig.fakecoin.changeUserSettings({ balance: 5000 }) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate, release immediately to let engine load + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = account2.currencyWallets[walletInfo.id] + + // Verify we have cached wallet immediately + expect(wallet.balances.FAKE).equals('5000') + + // Release the gate to allow engine to load + release() + + // Now methods that require the engine should work via delegation + // Set a new balance through the real engine + await account2.currencyConfig.fakecoin.changeUserSettings({ balance: 7777 }) + + // getTransactions requires the real engine (will wait for it) + const txs = await wallet.getTransactions({ tokenId: null }) + expect(txs).to.be.an('array') + + await account2.logout() + }) + + it('methods wait for real wallet instead of throwing', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + await account1.currencyConfig.fakecoin.changeUserSettings({ balance: 1000 }) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to control engine loading + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = account2.currencyWallets[walletInfo.id] + + // Call a method immediately (before engine loads) + // It should wait for the engine, not throw an error + let methodCompleted = false + const txPromise = wallet.getTransactions({ tokenId: null }).then(txs => { + methodCompleted = true + return txs + }) + + // Wait enough for bridge hops to settle, verify it hasn't completed + await snooze(BRIDGE_SETTLE_MS) + expect(methodCompleted).equals(false, 'Method should wait for engine') + + // Release the gate to allow engine to load + release() + + const txs = await txPromise + expect(methodCompleted).equals(true) + expect(txs).to.be.an('array') + + await account2.logout() + }) + + it('caches token balances and enabled tokens', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - set up tokens + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + const wallet1 = await account1.waitForCurrencyWallet(walletInfo.id) + + // Enable token and set balances + await wallet1.changeEnabledTokenIds(['badf00d5']) + await account1.currencyConfig.fakecoin.changeUserSettings({ + balance: 1000, + tokenBalance: 5000 + }) + + // Wait for async balance callback to propagate + await snooze(CACHE_SAVE_WAIT_MS) + + expect(wallet1.balances.FAKE).equals('1000') + expect(wallet1.balances.TOKEN).equals('5000') + expect(wallet1.enabledTokenIds).deep.equals(['badf00d5']) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to block engine, verify cache includes tokens + const { gate } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedWallet = account2.currencyWallets[walletInfo.id] + + expect(cachedWallet.balances.FAKE).equals('1000') + expect(cachedWallet.balances.TOKEN).equals('5000') + expect(cachedWallet.enabledTokenIds).deep.equals(['badf00d5']) + + await account2.logout() + }) + + it('handles logout during pending operations gracefully', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + await account1.currencyConfig.fakecoin.changeUserSettings({ balance: 100 }) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login with gate to block engine + const { gate } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // Start a cache save cycle and logout quickly + // (Intentionally not awaiting - testing graceful logout during pending ops) + const config = account2.currencyConfig.fakecoin + // eslint-disable-next-line @typescript-eslint/no-floating-promises + config.changeUserSettings({ balance: 200 }) + // eslint-disable-next-line @typescript-eslint/no-floating-promises + config.changeUserSettings({ balance: 300 }) + + // Logout while operations might be pending + await account2.logout() + + // Should complete without errors + expect(account2.loggedIn).equals(false) + }) + + it('caches otherMethods and they are callable from cached wallet', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - ensure otherMethods are saved to cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + const wallet1 = await account1.waitForCurrencyWallet(walletInfo.id) + + // Verify otherMethods exist on real wallet + expect(wallet1.otherMethods).to.be.an('object') + expect(wallet1.otherMethods.testMethod).to.be.a('function') + + // Call otherMethod to verify it works + const result1 = await wallet1.otherMethods.testMethod('hello') + expect(result1).equals('testMethod called with: hello') + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to block engine, verify otherMethods are in cached wallet + const { gate } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedWallet = account2.currencyWallets[walletInfo.id] + + // Cached wallet should have otherMethods + expect(cachedWallet.otherMethods).to.be.an('object') + expect(cachedWallet.otherMethods.testMethod).to.be.a('function') + + await account2.logout() + }) + + it('otherMethods delegate to real wallet after engine loads', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache with otherMethods + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate, release immediately to let engine load + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = account2.currencyWallets[walletInfo.id] + + // Verify we have cached wallet with otherMethods immediately + expect(wallet.otherMethods).to.be.an('object') + expect(wallet.otherMethods.testMethod).to.be.a('function') + + // Release the gate to allow engine to load + release() + + // Now otherMethods should delegate to real engine (will wait if needed) + const result = await wallet.otherMethods.testMethod('delegated') + expect(result).equals('testMethod called with: delegated') + + await account2.logout() + }) + + it('otherMethods wait for real wallet if called immediately', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to control engine loading + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = account2.currencyWallets[walletInfo.id] + + // Call otherMethod immediately (before engine loads) + // It should wait for the engine, not throw an error + let methodCompleted = false + const resultPromise = wallet.otherMethods + .testMethod('waiting') + .then((r: string) => { + methodCompleted = true + return r + }) + + // Wait enough for bridge hops to settle, verify it hasn't completed + await snooze(BRIDGE_SETTLE_MS) + expect(methodCompleted).equals(false, 'Method should wait for engine') + + // Release the gate to allow engine to load + release() + + const result = await resultPromise + expect(methodCompleted).equals(true) + expect(result).equals('testMethod called with: waiting') + + await account2.logout() + }) + + it('otherMethods are bridgeable (no serialization errors)', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate, release immediately for this test + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = account2.currencyWallets[walletInfo.id] + + // Access otherMethods - should not throw serialization errors + // This tests that bridgifyObject was called on otherMethods + expect(wallet.otherMethods).to.be.an('object') + expect(typeof wallet.otherMethods.testMethod).equals('function') + + // Release gate before calling method + release() + + // Calling the method should work without errors + const result = await wallet.otherMethods.testMethod('bridged') + expect(result).to.include('testMethod called with') + + await account2.logout() + }) + + it('handles wallets with no otherMethods gracefully', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login with otherMethods suppressed on the engine + fakePluginTestConfig.noOtherMethods = true + + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + const wallet1 = await account1.waitForCurrencyWallet(walletInfo.id) + + // Verify otherMethods are empty on real wallet + expect(Object.keys(wallet1.otherMethods).length).equals(0) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to block engine, should handle empty otherMethods + const { gate } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedWallet = account2.currencyWallets[walletInfo.id] + + // Should still work, with empty otherMethods + expect(cachedWallet).not.equals(undefined) + expect(cachedWallet.otherMethods).to.be.an('object') + expect(Object.keys(cachedWallet.otherMethods).length).equals(0) + + await account2.logout() + }) + + // =========================================================================== + // Disklet delegation tests (using gate-based control, no timing) + // =========================================================================== + + it('disklet.setText waits for real wallet during cache phase', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to control when engine loads + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedWallet = account2.currencyWallets[walletInfo.id] + + // Start a write operation - it should wait (not reject immediately) + let writeCompleted = false + const writePromise = cachedWallet.disklet + .setText('test-file.txt', 'test content') + .then(() => { + writeCompleted = true + }) + + // Wait enough for bridge hops to settle + await snooze(BRIDGE_SETTLE_MS) + expect(writeCompleted).equals( + false, + 'Write should not complete before gate' + ) + + // Release the gate to allow engine to load + release() + + // Wait for write to complete + await writePromise + expect(writeCompleted).equals(true, 'Write should complete after gate') + + // Verify data was written to the real disklet + const content = await cachedWallet.disklet.getText('test-file.txt') + expect(content).equals('test content') + + await account2.logout() + }) + + it('disklet.getText waits for real wallet during cache phase', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache and write a file + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + const wallet1 = await account1.waitForCurrencyWallet(walletInfo.id) + await wallet1.disklet.setText('existing-file.txt', 'persisted content') + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to control when engine loads + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedWallet = account2.currencyWallets[walletInfo.id] + + // Start a read operation - it should wait (not reject immediately) + let readResult: string | undefined + const readPromise = cachedWallet.disklet + .getText('existing-file.txt') + .then(content => { + readResult = content + }) + + // Give a tick for any immediate rejection + await snooze(BRIDGE_SETTLE_MS) + expect(readResult).equals(undefined, 'Read should not complete before gate') + + // Release the gate to allow engine to load + release() + + // Wait for read to complete + await readPromise + expect(readResult).equals('persisted content') + + await account2.logout() + }) + + it('disklet.setData waits for real wallet during cache phase', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to control when engine loads + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedWallet = account2.currencyWallets[walletInfo.id] + + // Start a binary write operation - it should wait + let writeCompleted = false + const testData = new Uint8Array([1, 2, 3, 4, 5]) + const writePromise = cachedWallet.disklet + .setData('binary-file.dat', testData) + .then(() => { + writeCompleted = true + }) + + // Give a tick for any immediate rejection + await snooze(BRIDGE_SETTLE_MS) + expect(writeCompleted).equals( + false, + 'Write should not complete before gate' + ) + + // Release the gate + release() + + await writePromise + expect(writeCompleted).equals(true) + + // Verify data was written + const readData = await cachedWallet.disklet.getData('binary-file.dat') + expect(Array.from(readData)).deep.equals([1, 2, 3, 4, 5]) + + await account2.logout() + }) + + it('disklet.getData waits for real wallet during cache phase', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache and write binary data + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + const wallet1 = await account1.waitForCurrencyWallet(walletInfo.id) + await wallet1.disklet.setData( + 'existing-binary.dat', + new Uint8Array([10, 20, 30]) + ) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to control when engine loads + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedWallet = account2.currencyWallets[walletInfo.id] + + // Start a binary read operation - it should wait + let readResult: Uint8Array | undefined + const readPromise = cachedWallet.disklet + .getData('existing-binary.dat') + .then(data => { + readResult = data + }) + + // Give a tick for any immediate rejection + await snooze(BRIDGE_SETTLE_MS) + expect(readResult).equals(undefined, 'Read should not complete before gate') + + // Release the gate + release() + + await readPromise + if (readResult == null) throw new Error('Read result should not be null') + expect(Array.from(readResult)).deep.equals([10, 20, 30]) + + await account2.logout() + }) + + it('disklet.list waits for real wallet during cache phase', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache and create some files + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + const wallet1 = await account1.waitForCurrencyWallet(walletInfo.id) + await wallet1.disklet.setText('file-a.txt', 'a') + await wallet1.disklet.setText('file-b.txt', 'b') + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to control when engine loads + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedWallet = account2.currencyWallets[walletInfo.id] + + // Start a list operation - it should wait + let listResult: { [name: string]: 'file' | 'folder' } | undefined + const listPromise = cachedWallet.disklet.list().then(result => { + listResult = result + }) + + // Give a tick for any immediate rejection + await snooze(BRIDGE_SETTLE_MS) + expect(listResult).equals(undefined, 'List should not complete before gate') + + // Release the gate + release() + + await listPromise + expect(listResult).to.have.property('file-a.txt', 'file') + expect(listResult).to.have.property('file-b.txt', 'file') + + await account2.logout() + }) + + it('disklet.delete waits for real wallet during cache phase', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache and create a file + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + const wallet1 = await account1.waitForCurrencyWallet(walletInfo.id) + await wallet1.disklet.setText('to-delete.txt', 'delete me') + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to control when engine loads + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedWallet = account2.currencyWallets[walletInfo.id] + + // Start a delete operation - it should wait + let deleteCompleted = false + const deletePromise = cachedWallet.disklet + .delete('to-delete.txt') + .then(() => { + deleteCompleted = true + }) + + // Give a tick for any immediate rejection + await snooze(BRIDGE_SETTLE_MS) + expect(deleteCompleted).equals( + false, + 'Delete should not complete before gate' + ) + + // Release the gate + release() + + await deletePromise + expect(deleteCompleted).equals(true) + + // Verify file is deleted: + try { + await cachedWallet.disklet.getText('to-delete.txt') + expect.fail('File should have been deleted') + } catch (e) { + expect(e).to.be.instanceOf(Error) + expect((e as Error).message).to.match( + /cannot load|cannot read|not found|ENOENT/i + ) + } + + await account2.logout() + }) + + it('localDisklet operations wait for real wallet during cache phase', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + const wallet1 = await account1.waitForCurrencyWallet(walletInfo.id) + await wallet1.localDisklet.setText('local-file.txt', 'local content') + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to control when engine loads + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedWallet = account2.currencyWallets[walletInfo.id] + + // Start operations on localDisklet - they should wait + let readCompleted = false + let writeCompleted = false + + const readPromise = cachedWallet.localDisklet + .getText('local-file.txt') + .then(() => { + readCompleted = true + }) + + const writePromise = cachedWallet.localDisklet + .setText('new-local-file.txt', 'new local') + .then(() => { + writeCompleted = true + }) + + // Give a tick for any immediate rejection + await snooze(BRIDGE_SETTLE_MS) + expect(readCompleted).equals(false, 'Read should not complete before gate') + expect(writeCompleted).equals( + false, + 'Write should not complete before gate' + ) + + // Release the gate + release() + + await Promise.all([readPromise, writePromise]) + expect(readCompleted).equals(true) + expect(writeCompleted).equals(true) + + // Verify data persisted + const content = + await cachedWallet.localDisklet.getText('new-local-file.txt') + expect(content).equals('new local') + + await account2.logout() + }) + + it('multiple disklet operations queue correctly during cache phase', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to control when engine loads + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedWallet = account2.currencyWallets[walletInfo.id] + + // Start multiple operations before engine loads + const operations: Array> = [] + const completionOrder: number[] = [] + + for (let i = 0; i < 5; i++) { + const index = i + operations.push( + cachedWallet.disklet + .setText(`file-${i}.txt`, `content-${i}`) + .then(() => { + completionOrder.push(index) + }) + ) + } + + // None should complete yet + await snooze(BRIDGE_SETTLE_MS) + expect(completionOrder.length).equals( + 0, + 'No operations should complete before gate' + ) + + // Release the gate + release() + + // All operations should complete + await Promise.all(operations) + expect(completionOrder.length).equals(5, 'All operations should complete') + + // Verify all files were written + for (let i = 0; i < 5; i++) { + const content = await cachedWallet.disklet.getText(`file-${i}.txt`) + expect(content).equals(`content-${i}`) + } + + await account2.logout() + }) + + it('disklet delegator works correctly after real wallet loads', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - release gate immediately to test post-load behavior + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = account2.currencyWallets[walletInfo.id] + + // Release immediately + release() + + // Wait for wallet to fully load (use a method that requires real wallet) + await wallet.getTransactions({ tokenId: null }) + + // Now operations should go directly to the real disklet + await wallet.disklet.setText('post-load-file.txt', 'post load content') + const content = await wallet.disklet.getText('post-load-file.txt') + expect(content).equals('post load content') + + // Test folder operations + await wallet.disklet.setText('subfolder/nested.txt', 'nested content') + const list = await wallet.disklet.list('subfolder') + // Disklet returns keys with full relative paths from query root + expect(list).to.have.property('subfolder/nested.txt', 'file') + + await account2.logout() + }) + + // Note: Testing that disklet operations properly reject when the wallet + // never loads is covered implicitly by the MAX_WAIT_MS + // timeout in the implementation. Testing this directly would require + // waiting 60 seconds which is too long for unit tests. + + // =========================================================================== + // Multiple wallet tests + // =========================================================================== + + it('caches and restores multiple wallets correctly', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - create a second wallet + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo1 = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo1 == null) throw new Error('No wallet') + + const wallet1 = await account1.waitForCurrencyWallet(walletInfo1.id) + await wallet1.renameWallet('Wallet One') + await account1.currencyConfig.fakecoin.changeUserSettings({ + balance: 1111 + }) + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + + // Create a second wallet + const wallet2Api = await account1.createCurrencyWallet('wallet:fakecoin', { + name: 'Wallet Two' + }) + const walletId2 = wallet2Api.id + + // Wait for cache saver to write both wallets + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + + // Verify both wallets exist before logout + expect(account1.currencyWallets[walletInfo1.id]).not.equals(undefined) + expect(account1.currencyWallets[walletId2]).not.equals(undefined) + + await account1.logout() + + // Second login - use gate to block engine, verify both wallets cached + const { gate } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + const cached1 = account2.currencyWallets[walletInfo1.id] + const cached2 = account2.currencyWallets[walletId2] + + // Both wallets should exist from cache + expect(cached1).not.equals(undefined, 'First wallet should be cached') + expect(cached2).not.equals(undefined, 'Second wallet should be cached') + + // Verify they have distinct data + expect(cached1.name).equals('Wallet One') + expect(cached2.name).equals('Wallet Two') + expect(cached1.id).not.equals(cached2.id) + + // Verify activeWalletIds contains both + expect(account2.activeWalletIds).to.include(walletInfo1.id) + expect(account2.activeWalletIds).to.include(walletId2) + + await account2.logout() + }) + + it('excludes archived wallets from cache on next login', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - create two wallets + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo1 = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo1 == null) throw new Error('No wallet') + + const wallet1 = await account1.waitForCurrencyWallet(walletInfo1.id) + await wallet1.renameWallet('Keep Me') + + const wallet2Api = await account1.createCurrencyWallet('wallet:fakecoin', { + name: 'Archive Me' + }) + const walletId2 = wallet2Api.id + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + + // Verify both are active + expect(account1.activeWalletIds).to.include(walletInfo1.id) + expect(account1.activeWalletIds).to.include(walletId2) + + // Archive the second wallet + await account1.changeWalletStates({ [walletId2]: { archived: true } }) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + + expect(account1.activeWalletIds).to.not.include(walletId2) + + await account1.logout() + + // Second login - use gate to block engine, verify only active wallet cached + const { gate } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + const cached1 = account2.currencyWallets[walletInfo1.id] + const cached2 = account2.currencyWallets[walletId2] + + expect(cached1).not.equals(undefined, 'Active wallet should be cached') + expect(cached1.name).equals('Keep Me') + + expect(cached2).equals( + undefined, + 'Archived wallet should not appear in currencyWallets' + ) + + expect(account2.activeWalletIds).to.include(walletInfo1.id) + expect(account2.activeWalletIds).to.not.include(walletId2) + + await account2.logout() + }) + + // =========================================================================== + // yaob bridge propagation tests + // =========================================================================== + + it('watch fires for changePaused on cached wallet', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + await account1.waitForCurrencyWallet(walletInfo.id) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - gate blocks engine, wallet comes from cache + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = account2.currencyWallets[walletInfo.id] + expect(wallet).not.equals(undefined, 'Cached wallet should exist') + + // Wallet should start paused (pauseWallets is false in tests) + expect(wallet.paused).equals(false) + + // Set up watch listener + const log = makeAssertLog() + wallet.watch('paused', paused => log('paused', paused)) + + // Release gate so changePaused can delegate to real wallet + release() + + // Call changePaused - should delegate and update through yaob + await wallet.changePaused(true) + log.assert('paused true') + + await wallet.changePaused(false) + log.assert('paused false') + + await account2.logout() + }) + + it('watch fires for renameWallet on cached wallet', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + await account1.waitForCurrencyWallet(walletInfo.id) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - gate blocks engine + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = account2.currencyWallets[walletInfo.id] + + // Set up watch listener + const log = makeAssertLog() + wallet.watch('name', name => log('name', name)) + + // Release gate so renameWallet can delegate + release() + + await wallet.renameWallet('New Cache Name') + log.assert('name New Cache Name') + + await account2.logout() + }) + + it('watch fires for setFiatCurrencyCode on cached wallet', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + await account1.waitForCurrencyWallet(walletInfo.id) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - gate blocks engine + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = account2.currencyWallets[walletInfo.id] + + // Set up watch listener + const log = makeAssertLog() + wallet.watch('fiatCurrencyCode', code => log('fiat', code)) + + // Release gate so setFiatCurrencyCode can delegate + release() + + await wallet.setFiatCurrencyCode('iso:EUR') + log.assert('fiat iso:EUR') + + await account2.logout() + }) + + it('watch fires for changeEnabledTokenIds on cached wallet', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - populate cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + await account1.waitForCurrencyWallet(walletInfo.id) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - gate blocks engine + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const wallet = account2.currencyWallets[walletInfo.id] + + // Set up watch listener + const log = makeAssertLog() + wallet.watch('enabledTokenIds', tokenIds => + log('tokens', tokenIds.join(',')) + ) + + // Release gate so changeEnabledTokenIds can delegate + release() + + await wallet.changeEnabledTokenIds(['badf00d5']) + log.assert('tokens badf00d5') + + await account2.logout() + }) + + // =========================================================================== + // Config caching tests + // =========================================================================== + + it('caches custom tokens on config', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - add a custom token + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + + const customToken: EdgeToken = { + currencyCode: 'CUSTOM', + displayName: 'Custom Token', + denominations: [{ multiplier: '1000', name: 'CUSTOM' }], + networkLocation: { + contractAddress: + '0X7CD5885327FD60E825D67D32F9D22B018227A208AA3C4819DA15B36B5D5869D3' + } + } + await account1.currencyConfig.fakecoin.addCustomToken(customToken) + + const customTokenId = + '7cd5885327fd60e825d67d32f9d22b018227a208aa3c4819da15b36b5d5869d3' + expect(account1.currencyConfig.fakecoin.customTokens).to.have.property( + customTokenId + ) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - use gate to block engine, verify custom tokens from cache + const { gate } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedConfig = account2.currencyConfig.fakecoin + + expect(cachedConfig.customTokens).to.have.property(customTokenId) + expect(cachedConfig.customTokens[customTokenId].currencyCode).equals( + 'CUSTOM' + ) + + // Custom tokens should also appear in allTokens + expect(cachedConfig.allTokens).to.have.property(customTokenId) + + await account2.logout() + }) + + it('config getters delegate to real config after engine loads', async function () { + this.timeout(10000) + + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { fakecoin: true } + }) + + // First login - add a custom token to populate the cache + const account1 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const walletInfo = account1.getFirstWalletInfo('wallet:fakecoin') + if (walletInfo == null) throw new Error('No wallet') + + await account1.waitForCurrencyWallet(walletInfo.id) + + const customToken: EdgeToken = { + currencyCode: 'CUSTOM', + displayName: 'Custom Token', + denominations: [{ multiplier: '1000', name: 'CUSTOM' }], + networkLocation: { + contractAddress: + '0X7CD5885327FD60E825D67D32F9D22B018227A208AA3C4819DA15B36B5D5869D3' + } + } + await account1.currencyConfig.fakecoin.addCustomToken(customToken) + + // Wait for cache saver to write (throttled to 50ms in tests): + await snooze(CACHE_SAVE_WAIT_MS) + await account1.logout() + + // Second login - gate blocks engine + const { gate, release } = createEngineGate() + fakePluginTestConfig.engineGate = gate + + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + const cachedConfig = account2.currencyConfig.fakecoin + + // In cache mode: builtinTokens should have cached data + expect(cachedConfig.builtinTokens).to.have.property('badf00d5') + + // In cache mode: customTokens should have cached data + const customTokenId = + '7cd5885327fd60e825d67d32f9d22b018227a208aa3c4819da15b36b5d5869d3' + expect(cachedConfig.customTokens).to.have.property(customTokenId) + + // Release the gate to allow real config to load + release() + + // Use a delegating write method to confirm the real config is loaded, + // then verify getters delegate to the real config: + await cachedConfig.changeAlwaysEnabledTokenIds(['badf00d5']) + + expect(cachedConfig.alwaysEnabledTokenIds).deep.equals(['badf00d5']) + expect(cachedConfig.builtinTokens).to.have.property('badf00d5') + expect(cachedConfig.customTokens).to.have.property(customTokenId) + + await account2.logout() + }) +}) diff --git a/test/fake/fake-currency-plugin.ts b/test/fake/fake-currency-plugin.ts index 704a6510..4f1f4320 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -27,6 +27,39 @@ import { upgradeCurrencyCode } from '../../src/types/type-helpers' const GENESIS_BLOCK = 1231006505 +/** + * Test configuration for controlling fake plugin behavior. + */ +export interface FakePluginTestConfig { + /** + * If set, engine creation will wait for this promise to resolve. + * Use createEngineGate() to create a controllable gate for deterministic tests. + */ + engineGate?: Promise + /** If true, engines will not expose otherMethods */ + noOtherMethods?: boolean +} + +/** + * Creates a gate that can be used to halt engine loading. + * Call release() when ready to allow engines to load. + */ +export function createEngineGate(): { + gate: Promise + release: () => void +} { + let release: () => void = () => {} + const gate = new Promise(resolve => { + release = resolve + }) + return { gate, release } +} + +export const fakePluginTestConfig: FakePluginTestConfig = { + engineGate: undefined, + noOtherMethods: false +} + const fakeTokens: EdgeTokenMap = { badf00d5: { currencyCode: 'TOKEN', @@ -90,11 +123,24 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { private allTokens: EdgeTokenMap = fakeTokens private readonly currencyInfo: EdgeCurrencyInfo + // otherMethods for testing cached wallet otherMethods delegation. + // When fakePluginTestConfig.noOtherMethods is true, this is set to empty. + otherMethods: { [method: string]: (...args: any[]) => any } + constructor( walletInfo: EdgeWalletInfo, opts: EdgeCurrencyEngineOptions, currencyInfo: EdgeCurrencyInfo ) { + // Set otherMethods based on test config + this.otherMethods = + fakePluginTestConfig.noOtherMethods === true + ? {} + : { + testMethod: async (arg: string): Promise => { + return `testMethod called with: ${arg}` + } + } this.walletId = walletInfo.id this.callbacks = opts.callbacks this.running = false @@ -398,13 +444,14 @@ export function makeFakeCurrencyPlugin( return Promise.resolve(fakeTokens) }, - makeCurrencyEngine( + async makeCurrencyEngine( walletInfo: EdgeWalletInfo, opts: EdgeCurrencyEngineOptions ): Promise { - return Promise.resolve( - new FakeCurrencyEngine(walletInfo, opts, currencyInfo) - ) + if (fakePluginTestConfig.engineGate != null) { + await fakePluginTestConfig.engineGate + } + return new FakeCurrencyEngine(walletInfo, opts, currencyInfo) }, makeCurrencyTools(): Promise { From 62801fdd85c37fe43e7a4bf2a83f313dee412f17 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Wed, 25 Feb 2026 08:27:43 -0800 Subject: [PATCH 3/8] Seed balanceMap from wallet cache + early-return guard During login, 45% of balance-related YAOB bridge crossings were redundant. The Redux balanceMap started empty despite cached balances being available, treating every cached balance as "new". (A) Initialize balanceMap directly from the wallet cache in the reducer via a new ACCOUNT_CACHED_BALANCES_LOADED action dispatched before wallet creation, so YAOB never observes an empty map. (B) Early-return guard in onTokenBalanceChanged drops callbacks when the balance matches the current Redux state, catching any duplicates the cache initialization missed. Removes the now-redundant initial parent-balance dispatch from the wallet pixie since balanceMap is pre-populated. --- src/core/account/account-pixie.ts | 11 +++++++++++ src/core/account/account-reducer.ts | 13 +++++++++++++ src/core/actions.ts | 10 ++++++++++ src/core/cache/cache-wallet-loader.ts | 11 +++++++++++ .../wallet/currency-wallet-callbacks.ts | 3 +++ .../currency/wallet/currency-wallet-pixie.ts | 17 +++-------------- .../currency/wallet/currency-wallet-reducer.ts | 16 +++++++++++++++- 7 files changed, 66 insertions(+), 15 deletions(-) diff --git a/src/core/account/account-pixie.ts b/src/core/account/account-pixie.ts index 75c7d552..db66c681 100644 --- a/src/core/account/account-pixie.ts +++ b/src/core/account/account-pixie.ts @@ -189,6 +189,17 @@ const accountPixie: TamePixie = combinePixies({ // Store cleanup function for use in destroy() cacheCleanup = cacheSetup.cleanup + // Pre-populate account state with cached balances so wallet + // reducers can initialize balanceMap from cache (no dispatch + // per-balance needed). Must fire before ACCOUNT_KEYS_LOADED: + ai.props.dispatch({ + type: 'ACCOUNT_CACHED_BALANCES_LOADED', + payload: { + accountId, + cachedBalances: cacheSetup.cachedBalances + } + }) + // Create the API object with cached wallets: input.onOutput(makeAccountApi(ai, accountId, { cacheSetup })) diff --git a/src/core/account/account-reducer.ts b/src/core/account/account-reducer.ts index 86f37d71..f74a6d40 100644 --- a/src/core/account/account-reducer.ts +++ b/src/core/account/account-reducer.ts @@ -33,6 +33,9 @@ export interface AccountState { readonly accountWalletInfos: EdgeWalletInfo[] readonly allWalletInfosFull: EdgeWalletInfoFull[] readonly allWalletInfosClean: EdgeWalletInfoFull[] + readonly cachedBalances: { + [walletId: string]: { [tokenId: string]: string } + } readonly currencyWalletErrors: { [walletId: string]: Error } readonly currencyWalletIds: string[] readonly activeWalletIds: string[] @@ -130,6 +133,16 @@ const accountInner = buildReducer({ walletInfos.map(info => ({ ...info, keys: {} })) ), + cachedBalances( + state: { [walletId: string]: { [tokenId: string]: string } } = {}, + action + ): { [walletId: string]: { [tokenId: string]: string } } { + if (action.type === 'ACCOUNT_CACHED_BALANCES_LOADED') { + return action.payload.cachedBalances + } + return state + }, + currencyWalletErrors(state = {}, action, next, prev) { const { activeWalletIds } = next.self const walletStates = next.root.currency.wallets diff --git a/src/core/actions.ts b/src/core/actions.ts index 59bdf34c..c959802e 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -84,6 +84,16 @@ export type RootAction = customTokens: EdgePluginMap } } + | { + // Cached wallet balances loaded from the wallet cache file. + // Dispatched before ACCOUNT_KEYS_LOADED so wallet reducers can + // initialize balanceMap from cache instead of an empty Map. + type: 'ACCOUNT_CACHED_BALANCES_LOADED' + payload: { + accountId: string + cachedBalances: { [walletId: string]: { [tokenId: string]: string } } + } + } | { // The account fires this when it loads its keys from disk. type: 'ACCOUNT_KEYS_LOADED' diff --git a/src/core/cache/cache-wallet-loader.ts b/src/core/cache/cache-wallet-loader.ts index d050be52..63ee8649 100644 --- a/src/core/cache/cache-wallet-loader.ts +++ b/src/core/cache/cache-wallet-loader.ts @@ -25,6 +25,8 @@ export interface WalletCacheSetup { currencyWallets: { [walletId: string]: EdgeCurrencyWallet } /** List of active wallet IDs in display order */ activeWalletIds: string[] + /** Cached balances per wallet for reducer initialization (tokenId string → nativeAmount) */ + cachedBalances: { [walletId: string]: { [tokenId: string]: string } } /** Cleanup function to stop all active pollers */ cleanup: () => void } @@ -105,6 +107,14 @@ export function loadWalletCache( cleanupFunctions.push(cleanup) } + // Extract cached balances for reducer initialization + const cachedBalances: { + [walletId: string]: { [tokenId: string]: string } + } = {} + for (const cachedWallet of cacheFile.wallets) { + cachedBalances[cachedWallet.id] = cachedWallet.balances + } + // Create cached wallets const currencyWallets: { [walletId: string]: EdgeCurrencyWallet } = {} const activeWalletIds: string[] = [] @@ -139,6 +149,7 @@ export function loadWalletCache( return { currencyWallets, activeWalletIds, + cachedBalances, cleanup: () => { for (const cleanupFn of cleanupFunctions) { cleanupFn() diff --git a/src/core/currency/wallet/currency-wallet-callbacks.ts b/src/core/currency/wallet/currency-wallet-callbacks.ts index ca0bb0dc..8db2af75 100644 --- a/src/core/currency/wallet/currency-wallet-callbacks.ts +++ b/src/core/currency/wallet/currency-wallet-callbacks.ts @@ -210,6 +210,9 @@ export function makeCurrencyWalletCallbacks( ) return } + if (input.props.walletState.balanceMap.get(tokenId) === clean) { + return + } pushUpdate({ id: `${walletId}==${String(tokenId)}`, action: 'onTokenBalanceChanged', diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index 2b310532..a184086a 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -1,4 +1,3 @@ -import { asMaybe } from 'cleaners' import { Disklet } from 'disklet' import { combinePixies, @@ -37,7 +36,7 @@ import { makeCurrencyWalletCallbacks, watchCurrencyWallet } from './currency-wallet-callbacks' -import { asIntegerString, asPublicKeyFile } from './currency-wallet-cleaners' +import { asPublicKeyFile } from './currency-wallet-cleaners' import { loadAddressFiles, loadFiatFile, @@ -73,7 +72,6 @@ export const walletPixie: TamePixie = combinePixies({ 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: @@ -142,17 +140,8 @@ export const walletPixie: TamePixie = combinePixies({ }) 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 } - }) - } + // Grab initial state (balanceMap is pre-populated from cache + // in the reducer, so we skip the parent balance dispatch here): const height = engine.getBlockHeight() input.props.dispatch({ type: 'CURRENCY_ENGINE_CHANGED_HEIGHT', diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index b9912277..a162faca 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -17,6 +17,7 @@ import { } from '../../../types/types' import { compare } from '../../../util/compare' import { RootAction } from '../../actions' +import { PARENT_CURRENCY_KEY } from '../../cache/cache-wallet-cleaners' import { findCurrencyPluginId } from '../../plugins/plugins-selectors' import { RootState } from '../../root-reducer' import { TransactionFile } from './currency-wallet-cleaners' @@ -331,7 +332,20 @@ const currencyWalletInner = buildReducer< return state }, - balanceMap(state = new Map(), action): Map { + balanceMap(state, action, next): Map { + if (state == null) { + const { accountId } = next.self + const cached = next.root.accounts[accountId]?.cachedBalances?.[next.id] + if (cached != null) { + const map: EdgeBalanceMap = new Map() + for (const [key, amount] of Object.entries(cached)) { + const tokenId = key === PARENT_CURRENCY_KEY ? null : key + map.set(tokenId, amount) + } + return map + } + return new Map() + } if (action.type === 'CURRENCY_ENGINE_CHANGED_BALANCE') { const { balance, tokenId } = action.payload const out = new Map(state) From 1bc00d3f0a8a1bf5898d9a53e64d110b8c04d63e Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Wed, 25 Feb 2026 08:28:02 -0800 Subject: [PATCH 4/8] Increase YAOB throttle from 50ms to 200ms At 50ms, YAOB sent up to 20 bridge messages per second per wallet. With 168 wallets, the RN JS thread was overwhelmed with JSON parsing and proxy updates. Increasing to 200ms batches more updates per message and reduces interrupt frequency by 4x. --- src/io/react-native/react-native-types.ts | 2 +- src/io/react-native/react-native-worker.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/io/react-native/react-native-types.ts b/src/io/react-native/react-native-types.ts index b71ffe2e..1595e229 100644 --- a/src/io/react-native/react-native-types.ts +++ b/src/io/react-native/react-native-types.ts @@ -44,4 +44,4 @@ export type EdgeCoreWebView = React.ComponentClass export type EdgeCoreWebViewRef = React.Component // Throttle YAOB updates -export const YAOB_THROTTLE_MS = 50 +export const YAOB_THROTTLE_MS = 200 diff --git a/src/io/react-native/react-native-worker.ts b/src/io/react-native/react-native-worker.ts index a71b31a1..bc97e4cf 100644 --- a/src/io/react-native/react-native-worker.ts +++ b/src/io/react-native/react-native-worker.ts @@ -257,6 +257,7 @@ export function normalizePath(path: string): string { return parts.slice(0, j).join('/') } + // Send the root object: const workerApi: WorkerApi = bridgifyObject({ async makeEdgeContext(nativeIo, logBackend, pluginUris, opts) { From cb9984ae3ef8db689732da6e55e834ea9c943151 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Wed, 25 Feb 2026 08:28:41 -0800 Subject: [PATCH 5/8] Debounce update(walletApi) to 300ms per wallet Every Redux state change triggered the pixie watcher to call update(walletApi), sending a bridge message even when multiple changes arrived within milliseconds. Per-wallet debouncing collapses rapid-fire state mutations into a single bridge crossing. Also adds hasYaobVisibleChange to skip no-op bridge messages caused by Redux reference changes to internal-only fields. --- .../currency/wallet/currency-wallet-pixie.ts | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index a184086a..3d61596a 100644 --- a/src/core/currency/wallet/currency-wallet-pixie.ts +++ b/src/core/currency/wallet/currency-wallet-pixie.ts @@ -451,9 +451,12 @@ export const walletPixie: TamePixie = combinePixies({ watcher(input: CurrencyWalletInput) { let lastState: CurrencyWalletState | undefined + let lastUpdatedState: CurrencyWalletState | undefined let lastSettings: object = {} let lastTokens: EdgeTokenMap = {} let lastEnabledTokenIds: string[] = initialTokenIds + let updateTimer: ReturnType | undefined + let updatePending = false return async () => { const { state, walletState, walletOutput } = input.props @@ -462,9 +465,21 @@ export const walletPixie: TamePixie = combinePixies({ const { accountId, pluginId } = walletState const accountState = state.accounts[accountId] - // Update API object: + // Update API object (debounced to 300ms): if (lastState !== walletState && walletApi != null) { - update(walletApi) + updatePending = true + if (updateTimer == null) { + updateTimer = setTimeout(() => { + updateTimer = undefined + if (updatePending) { + updatePending = false + if (hasYaobVisibleChange(walletState, lastUpdatedState)) { + lastUpdatedState = walletState + update(walletApi) + } + } + }, 300) + } } lastState = walletState @@ -578,3 +593,30 @@ export function whatsNew(after: string[], before: string[]): string { const beforeSet = new Set(before) return after.filter(s => !beforeSet.has(s)).join(', ') } + +/** + * Returns true if any YAOB-visible wallet fields have changed since the + * last time update(walletApi) was called. Skips no-op bridge messages + * caused by Redux reference changes to internal-only fields. + */ +function hasYaobVisibleChange( + current: CurrencyWalletState, + previous: CurrencyWalletState | undefined +): boolean { + if (previous == null) return true + return ( + current.name !== previous.name || + current.fiat !== previous.fiat || + current.balanceMap !== previous.balanceMap || + current.balances !== previous.balances || + current.height !== previous.height || + current.syncStatus !== previous.syncStatus || + current.unactivatedTokenIds !== previous.unactivatedTokenIds || + current.paused !== previous.paused || + current.detectedTokenIds !== previous.detectedTokenIds || + current.enabledTokenIds !== previous.enabledTokenIds || + current.txs !== previous.txs || + current.stakingStatus !== previous.stakingStatus || + current.sortedTxidHashes !== previous.sortedTxidHashes + ) +} From 388f2ca58f7a588484aaf5b4606e94b45cfcaff2 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Wed, 25 Feb 2026 08:29:00 -0800 Subject: [PATCH 6/8] Slow updateQueue processing to 1 item per 1000ms The update queue processed 3 items per 500ms (6/sec), but with 168 wallets filling it, the queue peaked at 227 items and never drained. Slowing individual processing while relying on deduplication keeps the queue shallow. --- src/util/updateQueue.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/util/updateQueue.ts b/src/util/updateQueue.ts index fa95a82a..0fc2489d 100644 --- a/src/util/updateQueue.ts +++ b/src/util/updateQueue.ts @@ -1,5 +1,5 @@ // How often to run jobs from the queue -let QUEUE_RUN_DELAY = 500 +let QUEUE_RUN_DELAY = 1000 // How many jobs to run from the queue on each cycle let QUEUE_JOBS_PER_RUN = 3 @@ -48,6 +48,7 @@ export function removeIdFromQueue(id: string): void { } } + function startQueue(): void { timeout = setTimeout(() => { const numJobs = Math.min(QUEUE_JOBS_PER_RUN, updateQueue.length) From 0bfa519f1fcdb084ab563aeba2b7bc555e5a234a Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Wed, 25 Feb 2026 08:29:23 -0800 Subject: [PATCH 7/8] Gate CURRENCY_ENGINE_CHANGED_TXS dispatch on actual changes The onTransactions callback dispatched CURRENCY_ENGINE_CHANGED_TXS unconditionally, even when compare() filtered out all transactions as unchanged. Each dispatch mutated wallet state, triggered the pixie watcher, and sent a YAOB bridge message. Gating the dispatch eliminates no-op bridge crossings. Also guards onNewTokens against empty arrays. --- .../wallet/currency-wallet-callbacks.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/core/currency/wallet/currency-wallet-callbacks.ts b/src/core/currency/wallet/currency-wallet-callbacks.ts index 8db2af75..d14f8691 100644 --- a/src/core/currency/wallet/currency-wallet-callbacks.ts +++ b/src/core/currency/wallet/currency-wallet-callbacks.ts @@ -130,6 +130,7 @@ export function makeCurrencyWalletCallbacks( }, onNewTokens(tokenIds: string[]) { + if (tokenIds.length === 0) return pushUpdate({ id: walletId, action: 'onNewTokens', @@ -420,11 +421,17 @@ export function makeCurrencyWalletCallbacks( txidHashes[txidHash] = { date: combinedTx.date, txid } } - // Tell everyone who cares: - input.props.dispatch({ - type: 'CURRENCY_ENGINE_CHANGED_TXS', - payload: { txs: allTxs, walletId, txidHashes } - }) + // Only dispatch if there are actually changed/created transactions or txidHashes + if ( + changed.length > 0 || + created.length > 0 || + Object.keys(txidHashes).length > 0 + ) { + input.props.dispatch({ + type: 'CURRENCY_ENGINE_CHANGED_TXS', + payload: { txs: allTxs, walletId, txidHashes } + }) + } if (changed.length > 0) throttledOnTxChanged(changed) if (created.length > 0) throttledOnNewTx(created) }, From 19b037e9a1949d39d9db66f7b6f9d027ae902192 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Wed, 25 Feb 2026 08:29:36 -0800 Subject: [PATCH 8/8] Dedup balanceMap: skip new Map() when balance unchanged The CURRENCY_ENGINE_CHANGED_BALANCE reducer always created a new Map reference, marking the wallet as dirty and triggering a YAOB bridge message even when the balance was identical. A same-value guard keeps the existing reference, letting the watcher debounce skip the update. --- src/core/currency/wallet/currency-wallet-reducer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index a162faca..e79bb490 100644 --- a/src/core/currency/wallet/currency-wallet-reducer.ts +++ b/src/core/currency/wallet/currency-wallet-reducer.ts @@ -348,6 +348,7 @@ const currencyWalletInner = buildReducer< } if (action.type === 'CURRENCY_ENGINE_CHANGED_BALANCE') { const { balance, tokenId } = action.payload + if (state.get(tokenId) === balance) return state const out = new Map(state) out.set(tokenId, balance) return out