Wallet cache v2 phase 1: emit currency wallets before their engines exist - #733
Wallet cache v2 phase 1: emit currency wallets before their engines exist#733j0ntz wants to merge 42 commits into
Conversation
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.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
fd70e56 to
0fc4524
Compare
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.
0fc4524 to
ed985f4
Compare
…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.
f615690 to
4783de3
Compare
|
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:
Phase 1 re-verified on the same run: the wallet list rendered names and balances from |
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.
|
=== 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 --- --- 2. Fresh-process warm relaunch (accountCache.json present): account emits from cache before the deferred loads --- --- 3. Warm PIN login with verbose logging: bulk-seeded wallets enter the startup queue before the loads land --- --- 4. Queue drain: 174 cached wallets at concurrency 8 (cold monero/zano-family wallets bypass) --- --- 5. accountCache.json shape on the sim (no plugin settings; privacy fix) --- |
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.
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.
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.
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).
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.
c532ebe to
733c172
Compare
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
4b81ab9 to
0f8f4a0
Compare
Bugbot is paused — on-demand spend limit reachedBugbot 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.
0f8f4a0 to
9dc46e6
Compare
683a5bd to
b589454
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ 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.
b589454 to
6438c41
Compare
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.
6438c41 to
093c043
Compare
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.
|
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:
|
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.
dbfebb3 to
e07528d
Compare





CHANGELOG
Does this branch warrant an entry to the CHANGELOG?
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 (parallelEdgeCurrencyWalletimplementation, delegation/polling layer).One wallet implementation, three changes:
walletCache.jsonon 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.jsonalready stores viewing keys on the same plain disklet, so encrypting only this file would protect nothing (TDD 9.2).publicKey.json+walletCache.jsonfirst, ahead of the storage-wallet sync, and seeds Redux via a newCURRENCY_WALLET_CACHE_LOADEDaction (cached balances never overwrite live engine data). ThewalletApigate drops itsengine != nullcondition, 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.makeCurrencyWalletApidrops itsengine/toolsconstructor parameters. Engine-backed methodsawait getEngine()internally (thewaitForCurrencyWalletpattern, keeping the deleted-wallet bailout and theengineFailurerethrow); repo-writing mutations (renameWallet,setFiatCurrencyCode,changeWalletSettings,sync) gate on the storage wallet instead, which loads well before the engine.otherMethodsis guaranteed{}pre-engine and switches to the engine's bridgified methods when it lands.publicWalletInfois served from Redux so a later key-cache upgrade propagates.A per-wallet
cacheSaversub-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/waitForAllWalletsnow 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
otherMethodspre-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, andchangePaused(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
balanceMapreducer keeps the existingMapwhen 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, andbalanceMapidentity.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.jsonon the account's local disklet holds what the deferred loads would produce (wallet states, custom token definitions, plus alegacyWalletsflag); right afterwaitForPlugins, a newACCOUNT_CACHE_LOADEDaction seeds that state (includingkeysLoaded) andmakeAccountApiemits 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.currencyWalletIds, one loader reads every active wallet'spublicKey.json+walletCache.jsonconcurrently and seeds them all in a singleCURRENCY_WALLETS_CACHE_LOADEDdispatch (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.EdgeDataStoreresolves its disklet lazily, the account/walletdisklet/localDiskletgetters fall back to disklets built directly from the keys (same files, same encryption), and repo-backed calls pend instead of throwing:changeWalletStateswaits for the repo,syncwaits foraddStorageWallet(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), andchangeEnabledTokenIdswaits for the plugin's builtin token definitions. The accounttokenSaverdefers (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).userSettings/swapSettingsare 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 plaintextpublicKey.json/walletCache.json).legacyWallets: trueand the next login boots cold rather than briefly hiding those wallets.publicKey.jsona second time; the seeded public info (or the fallback read) is passed intogetPublicWalletInfo.New tests cover TDD cases 12-14: the cache-coverage exhaustiveness test (every
EdgeCurrencyWalletproperty 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:
CustomTokens.jsonwholesale from Redux, so a write beforecustomTokensLoadedwould delete tokens another device had synced. It now returns without adopting the diff until the load lands (d8c48b20).ACCOUNT_CUSTOM_TOKENS_SAVEDonce the saver writes (7a809228); enabled tokens merge per toggled id, andchangeEnabledTokenIdsapplies 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).changeWalletStateswaits forwalletStatesLoaded(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).getActivationAssets,activateWallet,getDisplayPrivateKey, andgetDisplayPublicKeywait via a sharedwaitForCurrencyEngineselector (also backing the wallet's owngetEngine) instead of throwing pre-engine, and read the account's wallet list after the wait so wallets that loaded during it are included (987632ca).currencyWalletIdsjoined 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:
2ea1c016, hardened inbb3a1690): the engine's answer to the defaultgetAddressesquery dispatches into Redux (keyed per tokenId, balances stripped), persists inwalletCache.json(schema v2; version-1 files upgrade on read so no device loses its warm boot), and seeds on login. A new optionalEdgeCurrencyInfo.hasStableAddresseshint 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.e1f7c428, hardened inbb3a1690): the engine's method names persist in the wallet cache, andwallet.otherMethodsexposes 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 (preservingthis), 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.f5f0e64c,eeabe437): each plugin's otherMethods names persist inaccountCache.json; the live plugin's object stays exposed verbatim (identicalthisand 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.
allowCachedopt-in:getAddresses/getReceiveAddresstake anallowCachedoption that serves the cached address pre-engine on any chain, not justhasStableAddressesones. 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: clearbulkWalletSeedPendingonACCOUNT_LOAD_FAILEDso wallet pixies fall back to their own reads instead of never starting).hasStableAddresseson 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
allowCachedserve, the programmatic gate, andforceIndexbypass (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
allowCachedopt-in, theEdgeCurrencyInfo.hasStableAddresseshint, 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.
addressChangedonly fires when a running engine reports a rotation, and the cached serve returned before the engine was ever asked, sorememberAddressesnever 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 emitsaddressChangedwhen the engine's first answer differs. An engine that agrees stays silent.forceIndexstill waits for the engine.Consolidated:
accountCache.jsonabsorbed every wallet's cache and itspublicKey.json. One file for the whole account, written by one throttled writer.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 betweenaccountCache.jsonandaccountCache.2.json, each carrying a monotonicsequence, 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.jsonmigrated 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-fundsat 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 theLogin: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.mdinstead 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(alternatingaccountCache.2.jsonslots with monotonicsequencefor 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,otherMethodsnames) 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/waitForAllWalletsresolve when the wallet object exists. Cached receive addresses are served pre-engine with background engine reconcile andaddressChangedif they differ;otherMethodsuses 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;
changeEnabledTokenIdsapplies 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.