Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Unreleased

- added: Warm logins render the account and wallet list from an account-level cache before any engine starts
- added: Cached wallets start their engines through a limited-concurrency queue, with an opened wallet moved to the front
- added: Cached receive addresses are served before the engine loads, with `addressChanged` emitted on a later mismatch
- changed: `waitForCurrencyWallet` and `waitForAllWallets` resolve once the wallet object exists, possibly before its engine
- changed: `wallet.otherMethods` exposes delegating stubs for cached method names before the engine exists
- changed: `EdgeCurrencyWallet.balanceMap` keeps its identity when an unchanged balance is re-reported
- changed: Account-level engine methods wait for the wallet's engine instead of throwing before it loads
- fixed: Boot-window edits to custom tokens, enabled tokens, plugin settings and wallet states merge per field
- fixed: Plugin-settings writes merge into the on-disk file instead of rebuilding it from memory

## 2.48.1 (2026-08-31)

- fixed: Stop rebuilding the NYM mixFetch client on every request while its gateway is failing. Each attempt spawns a web worker holding megabytes of WASM that the library gives no way to terminate, so a poll loop retrying every few seconds exhausted the host's memory and killed the JS context, which on iOS reads to the user as being logged out. A failed setup now starts a cooldown that doubles up to five minutes.
Expand Down
51 changes: 34 additions & 17 deletions src/core/account/account-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import {
} from '../../types/types'
import { makeEdgeResult } from '../../util/edgeResult'
import { base58 } from '../../util/encoding'
import {
bumpEngineQueue,
waitForCurrencyEngine
} from '../currency/currency-selectors'
import { saveWalletSettings } from '../currency/wallet/currency-wallet-files'
import { getPublicWalletInfo } from '../currency/wallet/currency-wallet-pixie'
import {
Expand Down Expand Up @@ -124,7 +128,18 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount {

// Specialty API's:
const dataStore = makeDataStoreApi(ai, accountId)
const storageWalletApi = makeStorageWalletApi(ai, accountWalletInfo)
const storageWalletApi = makeStorageWalletApi(
ai,
accountWalletInfo,
props => {
const accountState = props.state.accounts[accountId]
if (accountState == null) {
throw new Error('The account was logged out')
}
// A terminal boot failure means the repo is never coming:
if (accountState.loadFailure != null) throw accountState.loadFailure
}
)

function lockdown(): void {
if (ai.props.state.hideKeys) {
Expand Down Expand Up @@ -583,9 +598,9 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount {
return await tools.getDisplayPrivateKey(info)
}

const { engine } = ai.props.output.currency.wallets[walletId]
if (engine == null || engine.getDisplayPrivateSeed == null) {
throw new Error('Wallet has not yet loaded')
const engine = await waitForCurrencyEngine(ai, walletId)
if (engine.getDisplayPrivateSeed == null) {
throw new Error(`getDisplayPrivateKey unsupported by ${info.type}`)
}
const out = await engine.getDisplayPrivateSeed(info.keys)
if (out == null) throw new Error('The engine failed to return a key')
Expand All @@ -605,9 +620,9 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount {
return await tools.getDisplayPublicKey(publicInfo)
}

const { engine } = ai.props.output.currency.wallets[walletId]
if (engine == null || engine.getDisplayPublicSeed == null) {
throw new Error('Wallet has not yet loaded')
const engine = await waitForCurrencyEngine(ai, walletId)
if (engine.getDisplayPublicSeed == null) {
throw new Error(`getDisplayPublicKey unsupported by ${info.type}`)
}
const out = await engine.getDisplayPublicSeed()
if (out == null) throw new Error('The engine failed to return a key')
Expand Down Expand Up @@ -724,6 +739,10 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount {
},

async waitForCurrencyWallet(walletId: string): Promise<EdgeCurrencyWallet> {
// Asking for a wallet is the "the user wants this one" signal,
// so move its engine startup to the front of the queue:
bumpEngineQueue(ai, walletId)

return await new Promise((resolve, reject) => {
const check = (): void => {
const wallet = this.currencyWallets[walletId]
Expand Down Expand Up @@ -783,12 +802,11 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount {
activateWalletId,
activateTokenIds
}: EdgeGetActivationAssetsOptions): Promise<EdgeGetActivationAssetsResults> {
const { currencyWallets } = ai.props.output.accounts[accountId]
const walletOutput = ai.props.output.currency.wallets[activateWalletId]
const { engine } = walletOutput
const engine = await waitForCurrencyEngine(ai, activateWalletId)

if (engine == null)
throw new Error(`Invalid wallet: ${activateWalletId} not found`)
// Read the wallet list after the wait, so wallets that
// finished loading while the engine started are included:
const { currencyWallets } = ai.props.output.accounts[accountId]

if (engine.engineGetActivationAssets == null)
throw new Error(
Expand All @@ -809,12 +827,11 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount {
opts: EdgeActivationOptions
): Promise<EdgeActivationQuote> {
const { activateWalletId, activateTokenIds, paymentInfo } = opts
const { currencyWallets } = ai.props.output.accounts[accountId]
const walletOutput = ai.props.output.currency.wallets[activateWalletId]
const { engine } = walletOutput
const engine = await waitForCurrencyEngine(ai, activateWalletId)

if (engine == null)
throw new Error(`Invalid wallet: ${activateWalletId} not found`)
// Read the wallet list after the wait, so wallets that
// finished loading while the engine started are included:
const { currencyWallets } = ai.props.output.accounts[accountId]

if (engine.engineActivateWallet == null)
throw new Error(
Expand Down
85 changes: 85 additions & 0 deletions src/core/account/account-cache-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Disklet } from 'disklet'

import { makeJsonFile } from '../../util/file-helpers'
import {
AccountCacheFile,
asAccountCacheFile,
asStoredAccountCacheFile
} from './account-cleaners'

/**
* Cached account boot state, stored on the account's local disklet.
* See `asAccountCacheFile` for the schema.
*
* The cache lives in two alternating slots. The disklet exposes no
* rename (neither its JS interface nor its iOS and Android native
* modules), so the usual write-temp-then-rename trick is unavailable,
* and Android's backend truncates the target before writing. A kill
* mid-write can therefore leave one slot torn. Writing generations
* alternately means the OTHER slot always holds the last complete
* one, and `loadAccountCache` takes the newest slot that still
* parses. (iOS writes with `NSDataWritingAtomic` and never tears, so
* this only earns its keep on Android.)
*/
export const ACCOUNT_CACHE_FILES = ['accountCache.json', 'accountCache.2.json']
export const accountCacheFile = {
load: makeJsonFile(asStoredAccountCacheFile).load,
save: makeJsonFile(asAccountCacheFile).save
}

/**
* Tuning for the account boot-state cache saver.
* Tests override the throttle to run quickly.
*/
export const accountCacheSaverConfig = {
throttleMs: 5000,
/** A write slower than this logs at `warn` instead of `info`. */
slowWriteMs: 5000
}

/**
* Reads both slots and returns the newest one that parses, plus the
* slot the next write should use. Returns `undefined` cache data when
* neither slot is readable (first login, schema bump, both torn),
* which sends the caller to its cold path.
*/
export async function loadAccountCache(
disklet: Disklet
): Promise<{ cache: AccountCacheFile | undefined; nextSlot: number }> {
const slots = await Promise.all(
ACCOUNT_CACHE_FILES.map(
async path => await accountCacheFile.load(disklet, path)
)
)

let best: AccountCacheFile | undefined
let bestSlot = -1
for (let slot = 0; slot < slots.length; ++slot) {
const cache = slots[slot]
if (cache == null) continue
if (best == null || cache.sequence > best.sequence) {
best = cache
bestSlot = slot
}
}

// Write over the slot we did NOT just read, so a torn write can
// never damage the generation we are currently relying on:
return {
cache: best,
nextSlot: bestSlot === -1 ? 0 : (bestSlot + 1) % ACCOUNT_CACHE_FILES.length
}
}

/**
* Writes the next generation into `slot` and returns the slot the
* write after this one should use.
*/
export async function saveAccountCache(
disklet: Disklet,
slot: number,
data: AccountCacheFile
): Promise<number> {
await accountCacheFile.save(disklet, ACCOUNT_CACHE_FILES[slot], data)
return (slot + 1) % ACCOUNT_CACHE_FILES.length
}
165 changes: 163 additions & 2 deletions src/core/account/account-cleaners.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
import {
asArray,
asBoolean,
asDate,
asEither,
asNull,
asNumber,
asObject,
asOptional,
asString
asString,
asValue,
Cleaner
} from 'cleaners'

import { asBase16 } from '../../types/server-cleaners'
import { EdgeDenomination, EdgeToken } from '../../types/types'
import {
EdgeDenomination,
EdgePluginMap,
EdgeStakingStatus,
EdgeToken,
EdgeTokenMap,
EdgeWalletState,
EdgeWalletStates
} from '../../types/types'
import { asJsonObject } from '../../util/file-helpers'
import { asIntegerString } from '../currency/wallet/currency-wallet-cleaners'
import { SwapSettings } from './account-types'

// ---------------------------------------------------------------------
Expand Down Expand Up @@ -92,3 +106,150 @@ export const asGuiSettingsFile = asObject({
export const asCustomTokensFile = asObject({
customTokens: asObject(asObject(asEdgeToken))
})

/**
* Cached account boot state, stored on the account's local disklet.
* This is what the deferred account file loads would produce,
* so wallet pixies can start before the account repo syncs.
* Values are last-known and explicitly allowed to be stale;
* the authoritative loads overwrite them within seconds.
* Never contains private key material: wallet keys stay in the
* encrypted login stash, which is already in memory at login.
* Plugin settings are deliberately excluded: unlike wallet states
* and token definitions, they can hold credentials (custom node
* auth, API keys), which must never leave the encrypted repo.
*/
export interface AccountCacheFile {
version: 2
customTokens: EdgePluginMap<EdgeTokenMap>
/**
* True when the account has legacy Airbitz-repo wallets. Their
* wallet infos cannot be cached (they contain private keys), so
* such accounts boot cold rather than briefly hiding wallets.
*/
legacyWallets: boolean
walletStates: EdgeWalletStates
/**
* Each plugin's `otherMethods` names, so `CurrencyConfig` can
* expose delegating stubs even if the plugin has not loaded yet.
*/
configOtherMethodNames: EdgePluginMap<string[]>

/**
* Every active wallet's cached boot state, keyed by wallet id.
* This absorbs what used to live in each wallet's own
* `publicKey.json` + `walletCache.json` pair, so a warm boot reads
* one file for the whole account instead of two per wallet.
*/
wallets: { [walletId: string]: AccountCacheWallet }

/**
* Increases on every write. Two slots hold alternating generations,
* so the reader can take the newest one that still parses; see
* `loadAccountCache`.
*/
sequence: number
}

/**
* One wallet's cached boot state inside the account cache file:
* the public keys that were in `publicKey.json` plus the UI state
* that was in `walletCache.json`.
*/
export interface AccountCacheWallet {
walletInfo: { id: string; keys: object; type: string }
name: string | null
fiatCurrencyCode: string
enabledTokenIds: string[]

/** Integer strings. The `null` tokenId is spelled '' here. */
balances: { [tokenId: string]: string }

/** Per tokenId, without balances (`null` tokenId spelled ''). */
addresses: {
[tokenIdKey: string]: Array<{ addressType: string; publicAddress: string }>
}

otherMethodNames: string[]

/**
* Last-known locked-stake rows. Cached for the same reason balances
* are: the GUI subtracts locked stake from the balance to show what
* is spendable, so serving a cached balance with an empty stake
* reports more available than the wallet has until the engine loads.
*/
stakingStatus?: EdgeStakingStatus
}

const asEdgeWalletState = asObject<EdgeWalletState>({
archived: asOptional(asBoolean),
deleted: asOptional(asBoolean),
hidden: asOptional(asBoolean),
migratedFromWalletId: asOptional(asString),
sortIndex: asOptional(asNumber)
})

const asCachedAddress = asObject({
addressType: asString,
publicAddress: asString
})

const asCachedStakingStatus = asObject<EdgeStakingStatus>({
stakedAmounts: asArray(
asObject({
nativeAmount: asString,
unlockDate: asOptional(asDate),
otherParams: asOptional(asJsonObject)
})
)
})

const asAccountCacheWallet = asObject<AccountCacheWallet>({
Comment thread
j0ntz marked this conversation as resolved.
walletInfo: asObject({
id: asString,
keys: asJsonObject,
type: asString
}),
name: asEither(asString, asNull),
fiatCurrencyCode: asString,
enabledTokenIds: asArray(asString),
balances: asObject(asIntegerString),
addresses: asObject(asArray(asCachedAddress)),
otherMethodNames: asArray(asString),
stakingStatus: asOptional(asCachedStakingStatus)
})

export const asAccountCacheFile: Cleaner<AccountCacheFile> = asObject({
version: asValue(2),
sequence: asNumber,
customTokens: asObject(asObject(asEdgeToken)),
legacyWallets: asOptional(asBoolean, false),
walletStates: asObject(asEdgeWalletState),
configOtherMethodNames: asOptional(asObject(asArray(asString)), () => ({})),
wallets: asObject(asAccountCacheWallet)
})

const asAccountCacheFileV1 = asObject({
version: asValue(1),
customTokens: asObject(asObject(asEdgeToken)),
legacyWallets: asOptional(asBoolean, false),
walletStates: asObject(asEdgeWalletState),
configOtherMethodNames: asOptional(asObject(asArray(asString)), () => ({}))
})

/**
* Read-side cleaner: upgrades a version-1 file instead of falling
* through to a cold boot. A version-1 file predates the consolidated
* wallet table, so its wallets are still in their own per-wallet
* files, which `bulkLoadWalletCaches` reads once before this file is
* rewritten in the current schema. Writes always use
* `asAccountCacheFile`.
*/
export const asStoredAccountCacheFile: Cleaner<AccountCacheFile> = raw => {
try {
return asAccountCacheFile(raw)
} catch (error: unknown) {
const clean = asAccountCacheFileV1(raw)
return { ...clean, version: 2, sequence: 0, wallets: {} }
}
}
Loading
Loading