Skip to content

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

Open
j0ntz wants to merge 37 commits into
masterfrom
jon/wallet-cache-v2
Open

Wallet cache v2 phase 1: emit currency wallets before their engines exist#733
j0ntz wants to merge 37 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 phase 1 of the wallet cache v2 TDD (immutable snapshot, live): 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)

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)

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


Note

High Risk
Large login/boot refactor touches wallet lifecycle, multi-device merge semantics, and address serving before engines load; regressions could affect funds display, receive QR, or synced settings.

Overview
Warm boot seeds Redux from accountCache.json and per-wallet walletCache.json, emits the account and wallet APIs before repo sync and engines finish, then lets authoritative file loads overwrite cache while dirty-field merges preserve in-window user edits and cross-device sync.

Engines start behind an 8-wide queue for cache-hit wallets only; waitForCurrencyWallet, engine/repo-backed methods, and un-pause bump priority. Wallet methods await the engine or storage repo internally instead of throwing pre-load; waitForCurrencyWallet means “API exists,” not “engine ready.”

Also adds hasStableAddresses / allowCached for provisional receive addresses, delegating otherMethods stubs from cached method names, throttled cache savers, serialized plugin-settings and storage sync writes, and fixes (custom-token write gating, fake sync hash routes, stable balanceMap identity).

Reviewed by Cursor Bugbot for commit c532ebe. 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
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).
changeEnabledTokenIds waits for the plugin's builtin token
definitions, which never arrive if the deferred boot loads failed
terminally; re-throw the recorded failure like the other repo
waiters.
@j0ntz

j0ntz commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (after review fixes, final HEAD d7a0ed0)

agent proof 1216673467164267 p3 05 final warm

agent proof 1216673467164267 p3 05 final warm

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

j0ntz added 9 commits July 21, 2026 13:30
A cache-seeded login holds possibly-stale wallet states, so a change
made in that window could no-op against a stale record and then be
silently reverted when the authoritative load lands. Gating on
walletStatesLoaded also bases the written record on loaded state.
The saver rebuilds the whole CustomTokens.json file from Redux, so a
write in the cache-seeded window would wipe tokens another device
added to the file. Return without adopting the diff, so the write
retries once the authoritative load has merged.
The dirty guard for custom tokens was a whole-map boolean, so a load
landing after an in-window edit kept the entire stale map, dropping
tokens another device had synced to the file. Track the edited token
ids instead, and have the load merge everything else from the file.
The ids clear once the saver writes them to disk.
changeEnabledTokenIds replaced the whole enabled list, so a call made
against a cache-seeded list erased enablement changes another device
had synced to the file. Diff the request against the list the caller
saw, apply those toggles to the current list after the builtin-token
wait, and have a racing file load preserve exactly the toggled ids.
One boolean covered both settings maps, so a load landing after an
in-window change discarded the entire loaded payload, dropping
settings another device had synced. Track the changed plugin ids per
map instead, and merge the rest from the loaded file. The writers now
also merge into the freshly read file rather than rebuilding the
whole on-disk map from Redux.
getActivationAssets, activateWallet, getDisplayPrivateKey, and
getDisplayPublicKey read the engine output directly and threw when it
was missing, unlike every wallet-level engine method. Route them
through a shared engine waiter, which also bumps the wallet to the
front of the startup queue and rejects on deletion or engine failure.
The saver derives the legacyWallets flag from currencyWalletIds at
write time but never re-compared it, so a late plugin load could
leave a stale flag in the cache file.
The fake server handed out a sync hash on every update but rejected
the hash-suffixed routes clients build from it, so any repo's second
sync inside a fake world failed with a 404. Reads ignore the hash and
return the whole repo, which clients treat as a superset of the
changes they asked for.
Cover the four confirmed audit scenarios: a boot-window custom-token
add no longer deletes a token synced from another device, a toggle
against a stale cached enabled list preserves the loaded list, a
wallet-state change that matches the stale cache waits for the load
instead of silently no-oping, and a settings write merges into the
freshly read file instead of wiping another plugin's synced settings.
The fake plugin gains a public-key check gate, so tests can hold a
wallet's file loads while its cache-seeded API stays usable.
@j0ntz

j0ntz commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

📸 Phase 4 test evidence (write-path staleness fixes)

agent proof 1216673467164267 p4 01 cold boot

agent proof 1216673467164267 p4 01 cold boot

agent proof 1216673467164267 p4 02 warm wallets

agent proof 1216673467164267 p4 02 warm wallets

agent proof 1216673467164267 p4 03 warm final

agent proof 1216673467164267 p4 03 warm final

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 4 in-app evidence (edge-funds, 194 wallets, iOS sim, core webview bundle @ phase-4 HEAD) ===

--- Cold boot (fresh app data restored from pool image; master-identical ordering, no cache emit) ---
2026-07-21T23:36:26 edge-core: Login: decrypted keys for user uU6v...
2026-07-21T23:36:26 edge-core: Login: account exists for appId
2026-07-21T23:36:26 edge-core: Login: currency plugins exist
2026-07-21T23:36:26 edge-core: Login: synced account repos
2026-07-21T23:36:26 edge-core: Login: loaded files
2026-07-21T23:36:26 edge-core: Login: complete
(no "emitted account from cache" line: cold path, byte-identical ordering to master)
accountCache.json + 194 walletCache.json written after boot.

--- Warm relaunch (account emits from cache before repo sync/file loads) ---
2026-07-21T23:44:11.277 edge-core: Login: decrypted keys for user uU6v...
2026-07-21T23:44:11.334 edge-core: Login: emitted account from cache
2026-07-21T23:44:11.573 edge-core: Login: currency plugins exist
2026-07-21T23:44:11.589 edge-core: Login: synced account repos
2026-07-21T23:44:12.616 edge-core: Login: loaded files
2026-07-21T23:44:12.616 edge-core: Login: complete

--- Enabled-token toggle round trip through the new set-diff path (L3USD on My Fantom) ---
2026-07-21T23:51:25 edge-core: enabledTokenIds: zi changeEnabledTokenIds
2026-07-21T23:51:25 edge-core: enabledTokenIds: zi write to disk, add [], remove [5f0456f728e2d59028b4f5b8ad8c604100724c6a]
2026-07-21T23:53:28 edge-core: enabledTokenIds: zi write to disk, add [5f0456f728e2d59028b4f5b8ad8c604100724c6a], remove []

--- Final warm relaunch (token persisted through a cache-seeded boot) ---
2026-07-21T23:54:16 edge-core: Login: emitted account from cache
2026-07-21T23:54:17 edge-core: Login: complete
L3USD row present in the seeded wallet list with cached balance (screenshot p4-03).

j0ntz added 6 commits July 22, 2026 13:16
The receive path always waited on the engine, even for chains whose
addresses never rotate. The engine's answer to the default address
query is now remembered in Redux (balances stripped), persisted into
walletCache.json (schema version 2, with old files upgraded on read
so nobody loses a warm boot), and seeded on the next login. A new
optional EdgeCurrencyInfo.hasStableAddresses hint gates the
pre-engine serve; it defaults to false, so rotating (UTXO-style)
chains and unflagged plugins keep exactly today's engine wait.
The wallet's otherMethods object was {} pre-engine and swapped to the
engine's bridgified methods when it landed, so a method was invisible
until then even though its name never changes. The engine's method
names now persist in walletCache.json, and the wallet exposes one
permanent object of delegating stubs: each waits for the engine and
forwards, resolving the real method once. A warm login can therefore
call a known method before the engine exists, a stale cached name
rejects cleanly at call time, and the object-swap plus update() dance
is gone. Cold logins with no cache keep the {} guarantee.
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.
@j0ntz

j0ntz commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

📸 Phase 5 test evidence (address cache + otherMethods stubs)

agent proof 1216673467164267 p5 01 warm list

agent proof 1216673467164267 p5 01 warm list

agent proof 1216673467164267 p5 02 receive

agent proof 1216673467164267 p5 02 receive

agent proof 1216673467164267 p5 03 warm final

agent proof 1216673467164267 p5 03 warm final

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

@j0ntz

j0ntz commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

=== Phase 5 in-app evidence (edge-funds, 194 wallets, iOS sim, core webview bundle @ phase-5 HEAD) ===

--- Warm boot on phase-5 core (account emits from cache; wallet list renders pre-engine) ---
2026-07-22T21:51:15.185 edge-core: Login: decrypted keys for user uU6v...
2026-07-22T21:51:15.245 edge-core: Login: emitted account from cache
2026-07-22T21:51:15.530 edge-core: Login: currency plugins exist
2026-07-22T21:51:15.778 edge-core: Login: synced account repos
All 194 walletCache.json files rewritten as schema v2 with otherMethodNames within the first minute.

--- Receive/QR scene (My Fantom / L3USD): address renders, and the engine's answer lands in the cache ---
walletCache.json (Fantom wallet) after the receive query:
"addresses": {"": [{"addressType": "publicAddress", "publicAddress": "0x15aDA9aB27d7fCdE5c1806cd48683977656E3af0"}]}
"otherMethodNames": ["parseWalletConnectV2Payload", "txRpcParamsToSpendInfo"]
(hasStableAddresses ships defaulted OFF, so the scene still waits for the engine exactly as before; screenshot p5-02.)

--- otherMethods stub retirement: the FioActions warm-boot TypeError is gone ---
Phase-4 evidence logs: "TypeError: fioWallet.otherMethods.fetchFioAddresses is not a function" on every warm boot (17 occurrences in one session).
Phase-5 warm boots #1 and #2: 0 occurrences (the cached name exposes a delegating stub pre-engine; the call now waits for the engine instead of exploding).

--- Final warm relaunch (cache emit, clean boot) ---
2026-07-22T21:56:19.151 edge-core: Login: emitted account from cache
2026-07-22T21:56:19.166 edge-core: Login: complete
fetchFioAddresses TypeErrors: 0

Comment thread src/core/account/account-pixie.ts
Comment thread src/core/currency/wallet/engine-scheduler.ts
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.
Comment thread src/core/account/account-files.ts
j0ntz added 2 commits July 22, 2026 15:13
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.
The receive scene wants to show an address the instant a warm login
finishes, even on a chain whose addresses rotate. getAddresses and
getReceiveAddress now accept allowCached: passing it serves the cached
answer before the engine loads on any chain, not just stable-address
ones. Programmatic callers leave it unset and keep waiting for the
engine, so only the receive scene shows a provisional address (which
it reconciles once the engine returns the fresh one) and no payment
path ever latches a reused address.
Comment thread src/core/account/account-reducer.ts
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.

@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 default 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 c532ebe. Configure here.

Comment thread src/core/account/account-pixie.ts
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