diff --git a/docs/wallet-cache.md b/docs/wallet-cache.md new file mode 100644 index 000000000..5301f0e28 --- /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 496bb3d4e..dbb5eda59 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( @@ -804,7 +850,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 +878,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/src/core/account/account-cleaners.ts b/src/core/account/account-cleaners.ts index 4785f9ff5..0ca12e04e 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 0359d8ee2..db66c6810 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,112 @@ 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 + + // 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 })) + + // 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 +340,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 +471,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 +490,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/account/account-reducer.ts b/src/core/account/account-reducer.ts index 86f37d714..f74a6d405 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 59bdf34ca..c959802e8 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-utils.ts b/src/core/cache/cache-utils.ts new file mode 100644 index 000000000..72782e418 --- /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 000000000..5f8fef71c --- /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 000000000..63ee86496 --- /dev/null +++ b/src/core/cache/cache-wallet-loader.ts @@ -0,0 +1,159 @@ +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[] + /** 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 +} + +/** + * 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) + } + + // 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[] = [] + + 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, + cachedBalances, + 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 000000000..247909d2e --- /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 000000000..131605c85 --- /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 000000000..72ce4940e --- /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/src/core/currency/wallet/currency-wallet-callbacks.ts b/src/core/currency/wallet/currency-wallet-callbacks.ts index ca0bb0dc5..d14f86910 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', @@ -210,6 +211,9 @@ export function makeCurrencyWalletCallbacks( ) return } + if (input.props.walletState.balanceMap.get(tokenId) === clean) { + return + } pushUpdate({ id: `${walletId}==${String(tokenId)}`, action: 'onTokenBalanceChanged', @@ -417,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) }, diff --git a/src/core/currency/wallet/currency-wallet-pixie.ts b/src/core/currency/wallet/currency-wallet-pixie.ts index 2b310532b..3d61596a6 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', @@ -462,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 @@ -473,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 @@ -589,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 + ) +} diff --git a/src/core/currency/wallet/currency-wallet-reducer.ts b/src/core/currency/wallet/currency-wallet-reducer.ts index b9912277a..e79bb4909 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,9 +332,23 @@ 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 + if (state.get(tokenId) === balance) return state const out = new Map(state) out.set(tokenId, balance) return out diff --git a/src/io/react-native/react-native-types.ts b/src/io/react-native/react-native-types.ts index b71ffe2ee..1595e229d 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 a71b31a18..bc97e4cf7 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) { diff --git a/src/util/updateQueue.ts b/src/util/updateQueue.ts index fa95a82a1..0fc2489d5 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) diff --git a/test/core/cache/wallet-cache.test.ts b/test/core/cache/wallet-cache.test.ts new file mode 100644 index 000000000..19fd7dd0a --- /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 bffa847ec..4f1f43209 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 @@ -223,7 +269,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') } @@ -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 {