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

## Unreleased

- added: Account-level `accountCache.json` holding the account boot state plus every wallet's cached name, fiat code, enabled token IDs, last-known balances, receive addresses, and public keys. A warm login reads this one file, seeds Redux from it, and emits the account right after the currency plugins load, before the account repo syncs, with every cached wallet seeded in a single bulk dispatch. One throttled writer owns the whole account, and generations alternate between two slots, so a write interrupted part-way costs one generation of staleness rather than the warm boot. Devices holding the older per-wallet files migrate on their next login. The deferred file loads overwrite the seeded state authoritatively, and user changes made during the window win over the values those loads read. First login (no cache) boots exactly as before.
- added: Currency wallets emit their API objects as soon as the cache loads, before their engines exist, so the GUI can render the wallet list immediately at login. Engine-backed methods wait for the engine internally and reject if it fails or the wallet is deleted. First login (no cache) behaves exactly as before.
- added: Cached wallets' engine startup is staggered through a limited-concurrency queue (8 at a time) instead of all racing at login. Asking for a wallet via `waitForCurrencyWallet`, calling an engine- or storage-backed method, or un-pausing it moves it to the front of the queue. Wallets without a cache skip the queue, so first login is unaffected.
- added: A wallet serves its cached receive addresses (keyed per token) before its engine loads, so the receive scene has an address immediately on a warm login. The wallet emits `addressChanged` once the engine derives a different one, so consumers re-query and pick it up. A query naming a specific `forceIndex` still waits for the engine, since only it can derive a fresh address.
- added: A log line for each account-cache write, naming the generation it wrote, how many wallets it held, and how long the write took, so the cache's write cost is visible without a patched build. The saver's throttle bounds it to the same volume as the existing login breadcrumbs.
- changed: `waitForCurrencyWallet` and `waitForAllWallets` now resolve when the wallet object exists, which can be before its engine loads.
- changed: `wallet.otherMethods` exposes delegating stubs: a method whose name is in the wallet's cache can be called before the engine exists (the call waits for the engine internally), the object keeps its identity when the cache already names every engine method, and a cached name the engine no longer implements rejects cleanly at call time. Pre-engine with no cache it is `{}`, as before.
- changed: `EdgeCurrencyWallet.balanceMap` keeps its object identity when an engine re-reports an unchanged balance.
Comment thread
j0ntz marked this conversation as resolved.
- changed: `getActivationAssets`, `activateWallet`, `getDisplayPrivateKey`, and `getDisplayPublicKey` wait for the wallet's engine instead of throwing when it has not loaded yet.
- fixed: The account's custom-token file is never written before its first load, which could permanently delete a custom token another device had synced to the account.
- fixed: File loads that race a user change during the boot window now merge per field (custom tokens per token id, enabled tokens per toggle, plugin settings per plugin id, wallet states per wallet id) instead of keeping or discarding whole maps, so changes synced from another device survive the window.
- fixed: `changeEnabledTokenIds` applies the caller's toggles to the current list, so a call built against a stale list no longer erases enablement changes synced from another device.
- fixed: The plugin-settings writers merge into the on-disk file instead of rebuilding it from memory, so settings another device synced to disk survive a local settings change.
- fixed: The fake sync server accepts the hash-suffixed store routes it hands out, so a repo's second sync inside `makeFakeEdgeWorld` no longer fails with a 404.

## 2.47.1 (2026-07-17)

- fixed: Revert `@nymproject/mix-fetch` to v1 (1.4.4), restoring the pinned gateway and network requester. The v2 stack shipped in 2.47.0 fails to complete small HTTPS JSON-RPC requests through most exit nodes and its exit-node auto-discovery rarely converges, which left wallets with NYM privacy enabled unable to sync or send.
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
83 changes: 83 additions & 0 deletions src/core/account/account-cache-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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
}

/**
* 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
}
144 changes: 142 additions & 2 deletions src/core/account/account-cleaners.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import {
asArray,
asBoolean,
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,
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 +104,131 @@ 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[]
}

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 asAccountCacheWallet = asObject<AccountCacheWallet>({
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)
})

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