Skip to content

Wallet cache v2 phase 1: emit currency wallets before their engines exist - #733

Draft
j0ntz wants to merge 42 commits into
masterfrom
jon/wallet-cache-v2
Draft

Wallet cache v2 phase 1: emit currency wallets before their engines exist#733
j0ntz wants to merge 42 commits into
masterfrom
jon/wallet-cache-v2

Conversation

@j0ntz

@j0ntz j0ntz commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Description

Implements the wallet cache v2 TDD, which lives on this branch at src/docs/edge-wallet-cache-design.md. Phase 1 below, with phases 2 through 7 described in the sections that follow: the wallet list can render names, fiat codes, enabled tokens, and last-known balances as soon as per-wallet cache files load, before any currency engine exists. Supersedes #703, which proved the ~5x login win but was rejected on architecture (parallel EdgeCurrencyWallet implementation, delegation/polling layer).

One wallet implementation, three changes:

  • Cache file: new walletCache.json on the wallet's local disklet (name, fiat code, enabled token IDs, last-known balances), validated by a versioned cleaner. Missing or invalid file (first login, schema bump, corruption) falls through to the exact cold path. Balances are allowed to be stale; the engine overwrites them within seconds of starting. Token definitions, tx history, and addresses are intentionally not cached (TDD decisions 9.3, 9.4). Privacy coins cache uniformly: publicKey.json already stores viewing keys on the same plain disklet, so encrypting only this file would protect nothing (TDD 9.2).
  • Load path + gate: the engine pixie reads publicKey.json + walletCache.json first, ahead of the storage-wallet sync, and seeds Redux via a new CURRENCY_WALLET_CACHE_LOADED action (cached balances never overwrite live engine data). The walletApi gate drops its engine != null condition, so the wallet object emits within one pixie tick of the cache read. Without a cache, the gate opens on the same conditions as master (name loads after engine creation), so cold-start behavior is unchanged. Engine creation and start scheduling are untouched.
  • Engine as an awaitable dependency: makeCurrencyWalletApi drops its engine/tools constructor parameters. Engine-backed methods await getEngine() internally (the waitForCurrencyWallet pattern, keeping the deleted-wallet bailout and the engineFailure rethrow); repo-writing mutations (renameWallet, setFiatCurrencyCode, changeWalletSettings, sync) gate on the storage wallet instead, which loads well before the engine. otherMethods is guaranteed {} pre-engine and switches to the engine's bridgified methods when it lands. publicWalletInfo is served from Redux so a later key-cache upgrade propagates.

A per-wallet cacheSaver sub-pixie persists the cache-relevant Redux slice: trailing-edge throttled to one write per 5s per wallet, guarded against post-logout writes, stops after 3 consecutive failures. It only writes once the authoritative name/fiat/token files have loaded, so a cold start never caches placeholder values. Stale cache files for deleted wallets are dead data on disk, never resurrected wallets: the cache is only read for wallet IDs in the account's encrypted key state.

Semantic shift: waitForCurrencyWallet / waitForAllWallets now mean "wallet object exists," which can be pre-engine. Internal core callers want the object, not the engine, and are unaffected; the two GUI call sites that consumed engine state at resolve-time are patched in the companion PR (EdgeApp/edge-react-gui).

Tests adopt #703's determinism mechanisms (a test-controlled engine gate on the fake plugin plus a 50ms saver throttle) and cover the 11 TDD section-6 cases: cold-start equivalence, cached emission, live overwrite on the same object, pending engine-gated calls (completion / engine failure / wallet deletion), renames inside the window, cancelled post-logout writes, corrupt cache files, saver timing, and the otherMethods pre-engine guarantee. One deviation from the TDD's test 6 wording: when a wallet is deleted mid-wait its pixie tree is destroyed, so a pending call rejects with redux-pixies' shutdown error rather than the does-not-exist message; the guard for the does-not-exist path is still in the waiter, and the test asserts rejection (no dangling promise) either way.

Phase 2: gentle engine scheduling (TDD section 8)

Cached wallets no longer race all of their heavy startup work (repo sync, key derivation, makeCurrencyEngine) in the seconds after login. A per-context scheduler runs that work for at most 8 wallets at a time; wallets without a cache bypass the queue entirely because they cannot emit until the work runs, keeping first login byte-identical to the cold path above. A wallet moves to the front of the queue when the app asks for it: waitForCurrencyWallet (both the account method and the internal selector), the internal engine/storage waiters behind every engine- and repo-backed method, and changePaused(false). A wallet deleted or logged out while queued gives up its slot without creating an engine.

This PR also carries the one #709 commit William called correct: the balanceMap reducer keeps the existing Map when an engine re-reports an unchanged balance, so memoized reducers, the cache saver, and yaob's === diffing see no phantom update.

New deterministic tests (engine-scheduler.test.ts) cover concurrency-limited draining, front-of-queue bumping, cold-wallet bypass, deleted-while-queued, and balanceMap identity.

Phase 3: account startup cache (TDD section 8.1)

The account boot itself now bypasses the repo sync and file loads on a warm login. A new accountCache.json on the account's local disklet holds what the deferred loads would produce (wallet states, custom token definitions, plus a legacyWallets flag); right after waitForPlugins, a new ACCOUNT_CACHE_LOADED action seeds that state (including keysLoaded) and makeAccountApi emits immediately. loadBuiltinTokens, addStorageWallet, and the file loads still run and overwrite the seeded state authoritatively, with dirty-wins guards (per-id for wallet states, whole-map for custom tokens and plugin settings) so user changes made during the window survive the overwrite. The deferred chain retries transient failures up to 3 times, since the GUI already holds the account. The cold path (no cache file) boots exactly as before, regression-guarded by a gated test.

  • Bulk wallet seeding, one store tick: once the account cache seeds currencyWalletIds, one loader reads every active wallet's publicKey.json + walletCache.json concurrently and seeds them all in a single CURRENCY_WALLETS_CACHE_LOADED dispatch (the wallet reducer's filter hands each wallet its own seed), so a warm login costs two seeding dispatches total instead of two per wallet. The per-wallet read inside the wallet pixie stays as the fallback for cold logins, bulk misses, and wallets activated after login, and now costs one dispatch instead of two.
  • Pre-storage window handling: the account API emits before its storage wallet exists, so EdgeDataStore resolves its disklet lazily, the account/wallet disklet/localDisklet getters fall back to disklets built directly from the keys (same files, same encryption), and repo-backed calls pend instead of throwing: changeWalletStates waits for the repo, sync waits for addStorageWallet (rejecting on logout/deletion or a terminal boot failure), plugin-settings writes wait for the settings load (writing earlier would rebuild the on-disk map from an incomplete Redux map), and changeEnabledTokenIds waits for the plugin's builtin token definitions. The account tokenSaver defers (never drops) a custom-token write made before the repo exists, the pixie watcher never adopts account state it could not deliver to a not-yet-created engine, and user changes made while a boot-time file load is in flight win over the value the load read (name, fiat, wallet settings, wallet states, custom tokens, plugin settings).
  • Privacy: plugin userSettings/swapSettings are deliberately NOT cached: unlike wallet states and token definitions, they can hold credentials (custom node auth, API keys) and previously never left the encrypted repo. Cached wallet states are unauthenticated local data; a tampered file can only transiently hide or reorder wallets until the authoritative load lands (same attacker model as the existing plaintext publicKey.json/walletCache.json).
  • Legacy Airbitz accounts: legacy wallet infos contain private keys and are never cached. When an account has legacy wallets that surface as currency wallets, the cache saver sets legacyWallets: true and the next login boots cold rather than briefly hiding those wallets.
  • Housekeeping: the queued engine block no longer reads publicKey.json a second time; the seeded public info (or the fallback read) is passed into getPublicWalletInfo.

New tests cover TDD cases 12-14: the cache-coverage exhaustiveness test (every EdgeCurrencyWallet property must be classified cache-seeded, engine-gated, or engine-free, so new properties force a caching decision), warm account login with the cold path blocking exactly as on master, the two-dispatch bulk seed count with fallback seeding for wallets activated after login, plus fresh-process warm login (a brand-new context with only the cache files on disk), stale-cache overwrite, corrupt-file fallback, and cancelled post-logout saves.

Phase 4: write-path staleness fixes (TDD section 8.2)

A write-path audit of the boot window confirmed four gaps where a load racing an in-window user change could lose data; all reuse existing patterns, no new surfaces:

  • Account token saver gated on the load (audit 5.1.1): the saver rebuilds CustomTokens.json wholesale from Redux, so a write before customTokensLoaded would delete tokens another device had synced. It now returns without adopting the diff until the load lands (d8c48b20).
  • Per-field merges replace whole-valued dirty guards (audit 5.1.2, 5.1.4): custom tokens merge per token id, cleared by a new ACCOUNT_CUSTOM_TOKENS_SAVED once the saver writes (7a809228); enabled tokens merge per toggled id, and changeEnabledTokenIds applies the caller's change as toggles over the current list, so a call built against a stale cached list cannot erase another device's enablement (3bbc97fd); plugin/swap settings track dirty plugin ids per map, and the writers merge into the freshly read file instead of rebuilding it from Redux, serialized per account so concurrent local writes cannot clobber each other (e9027da6).
  • changeWalletStates waits for walletStatesLoaded (audit 5.1.3): a change diffed against cache-seeded records could no-op and then be silently reverted by the load; it now bases the written record on loaded state (dc1cb707).
  • Account-level engine methods use the standard waiter (audit 5.2): getActivationAssets, activateWallet, getDisplayPrivateKey, and getDisplayPublicKey wait via a shared waitForCurrencyEngine selector (also backing the wallet's own getEngine) instead of throwing pre-engine, and read the account's wallet list after the wait so wallets that loaded during it are included (987632ca).
  • currencyWalletIds joined the account-cache saver's ref-compare set (audit 5.3, theoretical; 9b4fe78b).

New tests cover the audit's four two-device scenarios (TDD cases 15-18), producing the repo-ahead-of-cache divergence deterministically by stalling the cache saver for a session (16ff1fef). Reaching them needed the fake sync server to accept the hash-suffixed store routes it hands out, which previously 404'd every repo's second sync inside a fake world (1e2d4808).

Phase 5: receive-address cache + otherMethods name cache (TDD sections 5.1/5.6, decisions 9.4/9.8)

Superseded in part by phase 7: the hasStableAddresses gate described below was removed, and the address cache is now served on every chain with a reconcile behind it.

Review feedback flagged that the receive/QR path always waits on the engine, and neither implementation ever cached addresses. Both additions follow the established observe-dispatch-persist-seed pattern:

  • Address cache (2ea1c016, hardened in bb3a1690): the engine's answer to the default getAddresses query dispatches into Redux (keyed per tokenId, balances stripped), persists in walletCache.json (schema v2; version-1 files upgrade on read so no device loses its warm boot), and seeds on login. A new optional EdgeCurrencyInfo.hasStableAddresses hint gates the pre-engine serve; it defaults to false, so rotating (UTXO-style) and unflagged chains keep exactly today's engine wait. No plugin sets the hint yet: this lands the core mechanism only, and flagging account-based chains is a follow-up in their own repo with its own in-app proof.
  • otherMethods delegating stubs (e1f7c428, hardened in bb3a1690): the engine's method names persist in the wallet cache, and wallet.otherMethods exposes one delegating stub per known name: each awaits the engine, resolves against the live engine on every call (a resync never leaves a stale capture), forwards through the source object (preserving this), and rejects cleanly if the loaded engine lacks the method. The object keeps its identity when the cache already names every method (the common warm boot); a newly discovered name rebuilds it once, because yaob facades cannot gain properties after first crossing (verified empirically in review). On warm logins the FioActions "fetchFioAddresses is not a function" class retires: the stub exists before the engine does.
  • Config level (f5f0e64c, eeabe437): each plugin's otherMethods names persist in accountCache.json; the live plugin's object stays exposed verbatim (identical this and non-function properties), with the cached names only building fallback stubs if plugin loading ever defers past the account emit.

New tests (b0346238) cover the stable-flagged serve, the rotating-chain gate, the pre-engine stub call, the stale-name rejection, the version-1 upgrade + post-engine stub growth through the bridge, config-level persistence, and the cache-coverage classification's new cache-assisted set.

Phase 6: provisional receive address for rotating chains (TDD sections 5.4/7.5, decision 9.9)

Reverted in phase 7. Kept here for review history; none of the code below is on the branch.

Review feedback flagged the receive/QR path as slow on rotating chains, which wait on the engine for a fresh address. Phase 6 lets the receive scene show the cached address immediately and reconcile.

  • Core allowCached opt-in: getAddresses/getReceiveAddress take an allowCached option that serves the cached address pre-engine on any chain, not just hasStableAddresses ones. The flag is stripped before the engine call and only the receive scene passes it, so programmatic callers (payments, action queue, loans) stay engine-gated and never latch a reused address. Paired with a fix for a terminal-boot-failure wedge (c532ebee: clear bulkWalletSeedPending on ACCOUNT_LOAD_FAILED so wallet pixies fall back to their own reads instead of never starting).
  • GUI (edge-react-gui#6080): the receive scene renders the cached address at once; on a rotating chain it marks it provisional with a static "checking for your latest address" affordance and reconciles to the engine's address on confirm, with token/wallet/unmount guards against stale cross-wallet reconciles.
  • Plugin opt-in (draft edge-currency-accountbased#1076): hasStableAddresses on every account-based chain; UTXO chains stay unflagged and use the provisional path. It is a deliberate product change accepting informed reuse in the short pre-engine window for an instant receive screen (decision 9.9).

New tests cover the rotating-chain allowCached serve, the programmatic gate, and forceIndex bypass (TDD cases 26-27).

TDD (pinned, live): implementation divergences and the decisions are documented inline in the affected sections.

Asana: https://app.asana.com/1/9976422036640/project/1213843652804305/task/1216673467164267

Phase 7: revert the provisional surface, consolidate the cache file (TDD sections 5.1/5.5, decisions 9.4/9.6/9.9/9.10)

Two ordered parts. The revert landed first so the storage change had a smaller surface.

Reverted: the allowCached opt-in, the EdgeCurrencyInfo.hasStableAddresses hint, and (in the GUI PR) the provisional receive affordance. edge-currency-accountbased#1076 is closed and out of scope. Serving a cached address silently is now an accepted edge case, so the per-chain gate and the plugin flag work are both unnecessary. The commits were dropped from the branch rather than reverted on top, so this history never contains the add-then-remove cycle.

The revert exposed a gap: the surviving requirement is that consumers pick up a changed address once the engine loads, and nothing did that. addressChanged only fires when a running engine reports a rotation, and the cached serve returned before the engine was ever asked, so rememberAddresses never ran either. A consumer would have held the cached address indefinitely. Every query answered from the cache is now re-asked of the engine in the background, and the wallet emits addressChanged when the engine's first answer differs. An engine that agrees stays silent. forceIndex still waits for the engine.

Consolidated: accountCache.json absorbed every wallet's cache and its publicKey.json. One file for the whole account, written by one throttled writer.

Before After
Boot reads (194-wallet account) 389 files, 93.8 KiB 1 file, 105.5 KiB
Throttled writers one per wallet (194) one per account
Writes per 5 s sync window up to 194 1

The read-side reduction is structural, not a latency win: the phase-3 Galaxy S9 A/B had already measured the per-wallet read window at zero, so those reads were never on the critical path. The write-side consolidation is the actual benefit, and the tradeoff is that each write is now the whole account rather than one wallet's ~276 B.

Torn writes: the disklet exposes no rename, on either platform (its JS interface is delete/getData/getText/list/setData/setText, and the iOS and Android native modules expose exactly those), so write-temp-then-rename is not implementable. iOS already writes atomically (NSDataWritingAtomic); Android truncates in place and can tear. Generations therefore alternate between accountCache.json and accountCache.2.json, each carrying a monotonic sequence, and the reader takes the newest slot that parses. An interrupted write costs one generation of staleness, not the account's warm boot.

Migration: a version-1 file sends a device through the per-wallet reads once, and the saver folds them into the consolidated file. The old per-wallet files are left on disk as a recovery net and are no longer written. Verified on the sim: a real container holding a version-1 accountCache.json migrated to a version-2 file with all 195 wallets, alternating slots correctly across relaunches.

Measured on hardware (Galaxy S9, release build of this branch, edge-funds at 146-156 wallets; TDD section 8.4 has the method and the caveats): a warm login plus a 3-minute sync window costs 22 writes; one full-account write costs 1.3-3.5 s under sync-window load and 76 ms once the window quiets. Payload size is not what a write pays for, since writes carrying no wallets at all took up to 11.4 s during the busy first-login window, so the write-amplification tradeoff this design accepted is real in bytes and close to irrelevant in time. Booting with the newest slot truncated still emits from cache off the older slot with no crash, and the next write targets the damaged slot.

The per-wallet counterpart, measured: a second release build at cae1f073, the last commit before the consolidation, run on the same phone and account. Its warm login costs 363 per-wallet writes across the same 3-minute window against 22, with 48 writes in the worst 5 s span against 1; a first login with no cache costs 317 writes with 100 in the worst 5 s span. The design's "up to 194 per window" was a structural ceiling the account never reached, so the claim holds in direction and is smaller in magnitude than stated. A per-wallet write is individually cheaper (675 ms median against 2.1 s), which the byte counts already implied; the count is what the JS thread and the bridge pay for during engine startup.

Write-cost logging: the saver logs each completed write with its generation, the wallet count it carried, and its duration (Wallet cache: wrote generation 172 with 125 wallets in 426ms). The write is the whole cost of this design and nothing else reported it; the existing throttle bounds the line to the same volume as the Login: breadcrumbs. Both sides of the comparison above were counted off that line.

The design doc now lives on this branch at src/docs/edge-wallet-cache-design.md instead of in a gist, so it ships and is reviewed with the code.


Note

High Risk
Changes core login sequencing, wallet API timing (pre-engine behavior), and on-disk account state with multi-device merge logic—bugs could cause stale UI, hung waits, or lost synced settings/tokens.

Overview
Warm boot reads a single consolidated accountCache.json (alternating accountCache.2.json slots with monotonic sequence for torn-write safety on Android) and seeds Redux, then emits the account API and currency wallet APIs before the account repo sync and per-wallet engines finish. Per-wallet boot data (names, fiat, balances, addresses, public keys, otherMethods names) lives in that file instead of separate per-wallet caches; legacy devices migrate once via bulk or per-wallet reads.

Engines start behind an 8-wallet concurrency queue with priority bumps from waitForCurrencyWallet, engine-backed calls, and un-pausing. Wallet methods that need an engine await internally; waitForCurrencyWallet / waitForAllWallets resolve when the wallet object exists. Cached receive addresses are served pre-engine with background engine reconcile and addressChanged if they differ; otherMethods uses delegating stubs from cached names until the live engine is available.

Boot-window / multi-device fixes: custom tokens are not written before first load; plugin settings and wallet states merge per field with dirty tracking; serialized plugin-settings writes and storage sync queues; changeEnabledTokenIds applies toggles against the current list. Fake sync server accepts hash-suffixed store routes.

Reviewed by Cursor Bugbot for commit 093c043. Bugbot is set up for automated code reviews on this repo. Configure here.

Add walletCache.json (name, fiat code, enabled token IDs, last-known
balances) with a versioned cleaner, plus a CURRENCY_WALLET_CACHE_LOADED
action that seeds the wallet's Redux slice. Cached balances never
overwrite live engine data, and the later authoritative file loads
replace the cached values exactly as they replace initial state today.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread src/core/currency/wallet/currency-wallet-api.ts
@j0ntz
j0ntz force-pushed the jon/wallet-cache-v2 branch from fd70e56 to 0fc4524 Compare July 18, 2026 01:08
Comment thread src/core/currency/wallet/currency-wallet-api.ts
Comment thread src/core/currency/wallet/currency-wallet-pixie.ts
j0ntz added 2 commits July 17, 2026 18:29
Hoist a read of publicKey.json + walletCache.json to the top of the
engine pixie, ahead of the storage-wallet sync, and seed Redux from it,
so the walletApi gate (which drops its engine condition) opens within
one pixie tick on a warm login. Without the cache the gate opens on the
same conditions as before, keeping cold-start behavior unchanged.

makeCurrencyWalletApi loses its engine and tools constructor parameters.
Engine-backed methods wait internally via getEngine(), which bails out
if the wallet is deleted mid-wait and rethrows engineFailure so a broken
plugin surfaces as a rejected call instead of a hang. Mutations that
write synced-repo files gate on the storage wallet instead, which loads
well before the engine. otherMethods is guaranteed to be {} pre-engine
and switches to the engine's bridgified methods once it lands.

A cacheSaver sub-pixie watches the cache-relevant Redux slice and
persists it per wallet, throttled to one trailing-edge write per 5s,
guarded against post-logout writes, and giving up after 3 consecutive
failures. Engine creation and start scheduling are unchanged.
The fake currency plugin gains a test-controlled engine gate, so
"before the engine exists" is a controlled state instead of a race,
and the cache saver throttle drops to 50ms under test. The suite covers
cold-start equivalence, cached emission, live-data overwrite, pending
engine-gated calls (completion, engine failure, wallet deletion),
renames inside the cache window, cancelled post-logout writes, corrupt
cache files, saver behavior, and the otherMethods pre-engine guarantee.
@j0ntz
j0ntz force-pushed the jon/wallet-cache-v2 branch from 0fc4524 to ed985f4 Compare July 18, 2026 01:29
Comment thread src/core/currency/wallet/currency-wallet-pixie.ts Outdated
Comment thread src/core/currency/wallet/currency-wallet-api.ts
Comment thread src/core/currency/wallet/currency-wallet-api.ts
j0ntz added 3 commits July 19, 2026 02:15
…queue

Wallets that emit from walletCache.json no longer race every other
wallet through repo sync, key derivation, and engine creation in the
seconds after login. Their heavy startup work now waits in a per-context
queue (8 at a time), while wallets without a cache bypass the queue
because they cannot emit at all until that work runs, keeping first
login identical to before.

Asking for a wallet moves it to the front of the line: the account's
waitForCurrencyWallet, the internal engine/storage waiters, and
changePaused(false) all bump the wallet's queued startup.
Engines re-report balances they already reported, and each report used
to allocate a fresh Map. An unchanged balance now keeps the existing
Map, so memoized reducers, the wallet cache saver, and yaob's ===
diffing see no phantom update.
The fake plugin reports each makeCurrencyEngine call through an
onEngineCreate hook, so tests can observe creation order. Cases cover
concurrency-limited draining, waitForCurrencyWallet bumping a queued
wallet to the front, cold wallets bypassing the queue, a deleted queued
wallet giving up its place, and balanceMap identity across unchanged
balance reports.
@j0ntz
j0ntz force-pushed the jon/wallet-cache-v2 branch from f615690 to 4783de3 Compare July 19, 2026 09:16
Comment thread src/core/currency/wallet/currency-wallet-api.ts
@j0ntz

j0ntz commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Phase 2 test evidence: live in-app verification (iOS sim, edge-funds, 194 wallets, warm login)

Captured the core's log stream during a warm login with verbose logging on. Three behaviors verified live:

  1. Staggered engine startup: 187 cached wallets acquired startup slots over ~12s at concurrency 8, instead of all racing at once. The remaining wallets have no cache (their engines fail on sim, so the saver never persisted one) and correctly bypassed the queue.
  2. Tap-prioritization: opening "My Sepolia" mid-drain moved it to the front of the queue; slot acquired 91ms after the bump, startEngine 145ms later, live balance on the detail scene.
  3. Organic bump via waitForCurrencyWallet: pending action-queue balance effects asked for their two wallets at login and both jumped the queue.
== Tap-prioritization: user opened My Sepolia during the drain ==
09:42:43 edge-core: X2yC3vU... engine startup bumped to front of queue
09:42:43 edge-core: X2yC3vU... engine startup slot acquired
09:42:44 edge-core: X2yC3vU... startEngine
09:42:44 sepolia-X2: X2yC3vU... syncRatio of: 0.5

== Organic bump: action-queue balance effects at login ==
09:40:47 edge-core: 2k9Bly5... engine startup bumped to front of queue
09:40:47 edge-core: jwjTT2k... engine startup bumped to front of queue
09:40:47 edge-core: 2k9Bly5... engine startup slot acquired
09:40:47 edge-core: jwjTT2k... engine startup slot acquired

Phase 1 re-verified on the same run: the wallet list rendered names and balances from walletCache.json seconds after PIN entry, before engines existed (screenshots on EdgeApp/edge-react-gui#6080).

j0ntz added 3 commits July 20, 2026 18:40
A warm login reads accountCache.json (wallet states, custom tokens,
plugin settings) from the account's local disklet right after
waitForPlugins, seeds Redux through a new ACCOUNT_CACHE_LOADED action,
and emits the account API object immediately. The repo sync and file
loads still run and overwrite the seeded state authoritatively, with
dirty-wins guards so user changes made during the window survive.

Once the account cache seeds currencyWalletIds, one bulk loader reads
every active wallet's cache files concurrently and seeds them all in a
single CURRENCY_WALLETS_CACHE_LOADED dispatch, so a warm login costs
two seeding dispatches total instead of two per wallet. The per-wallet
read inside the wallet pixie remains as the fallback for cold logins
and wallets activated after login.

A throttled account cache saver persists the state after the
authoritative loads land; its dirty set includes account-level custom
tokens. The cold path (no cache file) boots exactly as before.
The cache seeding path already reads publicKey.json before the wallet
enters the startup queue, and the seeded public info lands in Redux.
Pass it into getPublicWalletInfo so the queued block does not read the
same file a second time. Cold wallets still read the file as before.
Covers the design's test cases 12-14: the cache-coverage
exhaustiveness test classifying every EdgeCurrencyWallet property,
warm account login emitting before the deferred loads land (with the
cold path blocking exactly as on master), and the bulk seed dispatch
count with the pixie fallback for wallets activated after login.
The fake plugin gains a builtinTokensGate, which blocks the deferred
account loads at their head, making both states deterministic.
@j0ntz

j0ntz commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

📸 Phase 3 test evidence (in-app, edge-funds, 194 wallets)

agent proof 1216673467164267 p3 03 warm fixed

agent proof 1216673467164267 p3 03 warm fixed

agent proof 1216673467164267 p3 04 warm pin list

agent proof 1216673467164267 p3 04 warm pin list

Captured by the agent's in-app test run (build-and-test).

@j0ntz

j0ntz commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

=== Phase 3 in-app evidence: edge-funds (194 wallets), iOS sim, phase-3 core bundle ===

--- 1. Cold boot (fresh install, no accountCache.json): master-identical sequence ---
[info] 07-21 01:34:01 edge-core: Login: decrypted keys for user uU6v6oNbuWDWphBVTz60M8qm8gnVTnvnYf7U6JyGf7o=
[info] 07-21 01:34:01 edge-core: Login: account exists for appId
[info] 07-21 01:34:01 edge-core: Login: currency plugins exist
[info] 07-21 01:34:01 edge-core: Login: synced account repos
[info] 07-21 01:34:01 edge-core: Login: loaded files
[info] 07-21 01:34:01 edge-core: Login: complete

--- 2. Fresh-process warm relaunch (accountCache.json present): account emits from cache before the deferred loads ---
[info] 07-21 01:40:22 edge-core: Login: decrypted keys for user uU6v6oNbuWDWphBVTz60M8qm8gnVTnvnYf7U6JyGf7o=
[info] 07-21 01:40:22 edge-core: Login: account exists for appId
[info] 07-21 01:40:22 edge-core: Login: emitted account from cache
[info] 07-21 01:40:22 edge-core: Login: currency plugins exist
[info] 07-21 01:40:22 edge-core: Login: synced account repos
[info] 07-21 01:40:22 edge-core: Login: loaded files
[info] 07-21 01:40:22 edge-core: Login: complete

--- 3. Warm PIN login with verbose logging: bulk-seeded wallets enter the startup queue before the loads land ---
[info] 07-21 01:43:23 edge-core: Login: decrypted keys for user uU6v6oNbuWDWphBVTz60M8qm8gnVTnvnYf7U6JyGf7o=
[info] 07-21 01:43:23 edge-core: Login: account exists for appId
[info] 07-21 01:43:23 edge-core: Login: emitted account from cache
[info] 07-21 01:43:23 edge-core: Y222zJlNNxqcy/i3xnGnWruGEBz4tjrSI/xxu0Mf9xg= engine startup slot acquired
[info] 07-21 01:43:23 edge-core: tIz3zg4KVh38F1lKBP4Ft/+QnLcLVrdLlc1rEVyGEkk= engine startup slot acquired
[info] 07-21 01:43:23 edge-core: PFpJNIrWnirbVqJU5wiRdnApSaH2MfyAA+MwfFhpF6k= engine startup slot acquired
[info] 07-21 01:43:23 edge-core: //j3WFpSPs14g6rNn6q0QI/ioh7m0AGfmDqDfst7rlY= engine startup slot acquired
[info] 07-21 01:43:23 edge-core: V5b+2aOIsWj6748Hme5EGuuWV26CKLBgWbBGb8v4p2Q= engine startup slot acquired
[info] 07-21 01:43:23 edge-core: dDMrVTRMMgWRmQ4M2FChJeRN2H43dPRwblK9R7cEaSk= engine startup slot acquired
[info] 07-21 01:43:23 edge-core: 2SDRqv1GBntaqRdNk0RQDJ7fxYv1oUIB48jiM3ydyag= engine startup slot acquired
[info] 07-21 01:43:23 edge-core: DRWdI0mngV/CNEzw0UxVFJzIGdUIMK+iQ1/dLoqwV5E= engine startup slot acquired
[info] 07-21 01:43:23 edge-core: Login: currency plugins exist
[info] 07-21 01:43:23 edge-core: Login: synced account repos
[info] 07-21 01:43:23 edge-core: Login: loaded files
...

--- 4. Queue drain: 174 cached wallets at concurrency 8 (cold monero/zano-family wallets bypass) ---
8 07-21 01:43:23
16 07-21 01:43:24
15 07-21 01:43:25
28 07-21 01:43:26
25 07-21 01:43:27
26 07-21 01:43:28
24 07-21 01:43:29
25 07-21 01:43:30
7 07-21 01:43:31

--- 5. accountCache.json shape on the sim (no plugin settings; privacy fix) ---
keys: customTokens, legacyWallets, version, walletStates | walletStates: 132 | customTokens plugins: 6 | legacyWallets: false | userSettings present: False

Comment thread src/core/currency/wallet/currency-wallet-reducer.ts Outdated
A wallet emits from its cache before its name, fiat, and settings
files load, so a rename (or fiat/settings change) made during that
window could be overwritten when the in-flight load dispatched its
stale value. The mutation writes the file before dispatching, so the
change was already on disk; only Redux regressed. File loads now tag
their dispatches, and a dirty flag lets the user's value win over one
racing load, mirroring the enabledTokenIds and account-level guards.
Comment thread src/core/currency/wallet/currency-wallet-pixie.ts
On a warm login, engine startup runs in parallel with the deferred
account file loads. The pixie watcher could observe freshly-loaded
plugin settings or custom tokens while the engine was still null,
adopt them, and then never deliver them once the engine appeared,
leaving it on empty settings for the whole session. The watcher now
never adopts a value it could not deliver, and engine creation reads
the account state fresh instead of from an earlier snapshot.
Comment thread src/core/storage/storage-api.ts
A cache-seeded login emits the account and wallet API objects before
addStorageWallet attaches their repos, so sync() now waits for the
repo instead of throwing during that window, matching the disklet
fallbacks and the pending repo-backed mutations.
Comment thread src/core/currency/wallet/currency-wallet-api.ts
Comment thread CHANGELOG.md
changeEnabledTokenIds filters the requested ids against the plugin's
known tokens, but on a warm login the builtin definitions load after
the wallet exists, so a toggle made in that window silently dropped
enabled builtin tokens. The method now waits for the plugin's builtin
tokens (still working when the engine has failed, bailing only on
wallet deletion).
j0ntz added 7 commits July 31, 2026 17:56
CurrencyConfig.otherMethods now mirrors the wallet's model: one
permanent object of delegating stubs whose names come from the live
plugin, with the account cache as a fallback. Each plugin's names
persist in accountCache.json, so the config surface stays complete
even if plugin loading ever defers past the account emit.
Cover the phase-5 scope: a stable-flagged chain serves its cached
addresses pre-engine (and getReceiveAddress derives from them), a
rotating chain still waits for the engine, the engine's address
answer reaches the cache file with balances stripped, a cached
otherMethods name is callable before the engine exists, a stale
cached name rejects cleanly when the loaded engine lacks the method,
config-level names persist and delegate, and the cache-coverage
classification gains a cache-assisted set for the conditional
surfaces. The fake plugin gains an identity-stable currencyInfo
patch hook and an omit-otherMethods switch to stage these states.
Review findings on the new caches. The address cache was one flat
array per wallet, so a token query overwrote the parent chain's
answer and a warm boot could serve the wrong asset's address; it is
now keyed by tokenId end to end (Redux, file schema, the pre-engine
serve), with an identity guard so a repeated identical answer causes
no phantom update. The otherMethods object cannot gain properties
after it first crosses the yaob bridge (update() only re-serializes
properties that existed then, verified empirically), so instead of
mutating one permanent object, the getter rebuilds it as a new
bridgified object whenever a name first appears; identity still holds
for the common warm case where the cache already names everything.
Stubs also resolve against the live engine on every call, so an
engine rebuilt by a resync never leaves a stale capture, and calls go
through the source object to preserve the plugin's this binding.
Review findings: routing every config otherMethods call through a
stub dropped the plugin's this binding and any non-function property,
on every login. When the plugin is loaded (always the case today) the
config exposes the plugin's own otherMethods object verbatim, exactly
as before; the cached names only build delegating stubs in the
so-far-unreachable case where plugin loading defers past the account
emit, and those stubs call through the object to preserve this.
Bugbot findings. A warm-boot retry re-ran addStorageWallet for repos
a prior attempt had already attached; STORAGE_WALLET_ADDED replaces
the whole entry (wiping lastChanges) and starts a second sync that
can race the one still in flight, so retries now attach only the
repos that are still missing. The engine scheduler's watchdog called
its logging callback before freeing the slot, so a throw from stale
props after a pixie destroy could permanently shrink the pool; the
slot is now released first and the callback is contained.
Bugbot finding: storageWallets entries survive logout, so on a
same-context re-login the repo waiters resolve against the prior
session's entry and a user-facing sync can run concurrently with the
boot's own addStorageWallet sync. Concurrent syncRepo calls on one
repo are unsafe (the changes-folder snapshot is deleted after the
round trip, so a racing write can be dropped or double-uploaded), and
the same overlap already existed between the periodic timer and a
user sync. All callers funnel through syncStorageWallet, which now
queues per repo, and a sync that dequeues after deletion or logout
rejects cleanly.
ACCOUNT_CACHE_LOADED sets bulkWalletSeedPending so wallet pixies hold
their own cache reads until the bulk seed dispatches. It cleared only
on CURRENCY_WALLETS_CACHE_LOADED or ACCOUNT_KEYS_LOADED, so a terminal
deferred-load failure (ACCOUNT_LOAD_FAILED after the account already
emitted) left the flag stuck true and every wallet pixie returning
early forever, never starting an engine or emitting an API object.
Clear the flag on ACCOUNT_LOAD_FAILED too, matching the existing
ACCOUNT_KEYS_LOADED backstop, so the wallets fall back to their own
reads.
@j0ntz
j0ntz marked this pull request as draft August 1, 2026 01:02
@j0ntz
j0ntz force-pushed the jon/wallet-cache-v2 branch from c532ebe to 733c172 Compare August 1, 2026 01:02
@j0ntz

j0ntz commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence: warm login from the consolidated cache

warm login wallet list

warm login wallet list

receive address no affordance

receive address no affordance

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz marked this pull request as ready for review August 1, 2026 02:20
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@j0ntz
j0ntz force-pushed the jon/wallet-cache-v2 branch from 4b81ab9 to 0f8f4a0 Compare August 1, 2026 02:24
@cursor

cursor Bot commented Aug 1, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

A cached address is served before the engine exists, so the engine can
derive a different one, which a rotating chain does as soon as the
cached address is used. Every query answered from the cache now asks
the engine in the background, and the wallet emits addressChanged when
the two disagree, so the receive scene and every other consumer
re-query and pick up the new address. An engine that confirms the
cached answer stays silent.
@j0ntz
j0ntz force-pushed the jon/wallet-cache-v2 branch from 0f8f4a0 to 9dc46e6 Compare August 1, 2026 02:27
Comment thread src/core/currency/wallet/currency-wallet-pixie.ts
@j0ntz
j0ntz force-pushed the jon/wallet-cache-v2 branch 2 times, most recently from 683a5bd to b589454 Compare August 4, 2026 10:45

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b589454. Configure here.

Comment thread src/core/account/account-cleaners.ts Outdated
@j0ntz
j0ntz force-pushed the jon/wallet-cache-v2 branch from b589454 to 6438c41 Compare August 4, 2026 10:53
j0ntz added 3 commits August 4, 2026 03:53
A warm boot read two files per wallet, so an account with 200 wallets
paid 400 bridge round trips before it could render, and each wallet
ran its own throttled saver, so a busy sync window had them all
writing at once.

The account cache file now carries every wallet's boot state, public
keys included, so a warm boot reads ONE file and a single serialized
writer with a single throttle owns every update. Wallets that are
merely archived keep their entry, so turning one back on stays warm;
entries for wallets the account no longer has are dropped, so the file
cannot grow without bound.

The disklet exposes no rename on either platform, so the usual
write-temp-then-rename trick is unavailable and Android's backend
truncates the target in place. Generations therefore alternate between
two slots and the reader takes the newest one that still parses, so a
kill part-way through a write costs one generation of staleness rather
than the account's warm boot. A device on the old layout migrates on
its next login: the version-1 file sends it to the per-wallet reads
once, and the saver folds them into the consolidated file. The old
per-wallet files are left alone as a recovery net.
The design doc lived as a gist while the work had no PR. It now ships
with the code it describes, so it is reviewable in the same diff and
cannot drift from the branch. Body updated to the current design: one
consolidated account cache file, the pre-engine address serve with its
reconcile, and the reverted provisional affordance.
Folds the physical-run history into one entry under the phase-7 section:
the three attempts that produced no numbers, and the S9 measurements that
the device PIN finally unblocked. Also repoints two stale decision-9.6
anchors left by the phase-7 rename.
@j0ntz
j0ntz force-pushed the jon/wallet-cache-v2 branch from 6438c41 to 093c043 Compare August 4, 2026 10:53
The cache write is this design's entire cost and it is invisible from
the outside: measuring it needed a patched build, so the next person
who asks what a write costs pays for a build to find out.

The saver now names the generation it wrote, the wallet count that
went into it, and how long it took. The existing throttle bounds the
volume to one line per window, the same rate as the login breadcrumbs
it sits alongside.
@j0ntz
j0ntz marked this pull request as draft August 5, 2026 00:26
@j0ntz

j0ntz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Back in draft for this turn on purpose. The push is a log line and a doc update, and Bugbot skips draft PRs, so neither change spends a review credit.

What landed:

  • aa60a85f logs each account-cache write with its generation, wallet count, and elapsed ms. The saver's existing 5 s throttle bounds it to the same volume as the Login: breadcrumbs it sits next to.
  • dbfebb3e fills in the one derived number left in the design doc. Section 8.4 claimed "up to 194 writes per 5 s window becomes 1" from reading the code. A release build at cae1f073, the last pre-consolidation commit, measured on the same Galaxy S9 and the same account: 363 per-wallet writes across a warm login's first 3 minutes against 22 for the consolidated file, and 100 writes in the worst 5 s span against 1.

Section 8.4 claimed the consolidation turns up to 194 writes per 5 s
window into 1, and the 194 came from reading the code rather than from
a device. A release build at the last pre-consolidation commit,
measured on the same phone and account as the consolidated numbers,
puts the real figures in: 363 per-wallet writes across a warm login's
first 3 minutes against 22, and 100 writes in the worst 5 s span
against 1.

Section 8.5 records the two gaps this closes and what the numbers say
about the byte-amplification tradeoff decision 9.6 worried about.
@j0ntz
j0ntz force-pushed the jon/wallet-cache-v2 branch from dbfebb3 to e07528d Compare August 5, 2026 00:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant