From 7983e24f2e445c0b57bf4678730bb0509d44f44b Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 16:12:53 +0200 Subject: [PATCH] perf(aegis): history was 473 round trips in series, and persisted all of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the Theseus-lag investigation. 0.27.1 removed the 7.4 MB store; this removes the two things that produced it and the latency that came with it. Measured on this profile's own cache: 6,981 transactions, 7.38 MB, and one consolidation with 400 inputs (median 2). - loadHistory() awaited getTx() per displayed transaction and then again per INPUT, strictly one at a time. The 400-input row alone cost 400 serial round trips. Both waves are now prefetched with bounded parallelism (getTxMany, 12 at a time). Against a simulated 10 ms link the same 473 fetches take 771 ms instead of ~4,730 ms; on a real 30-50 ms link the serial version was 15-25 seconds per refresh, per wallet. - Parent transactions were persisted forever. They exist only to compute a delta for the 25 rows on screen, and keeping every one ever seen is what grew the file. Only the displayed window is written now — 25 entries, 16.4 KB in the harness — while parents stay in a process-lifetime map bounded at 20,000, seeded from the window so a restart does not refetch what is already visible. A second refresh issues zero fetches. TX_CACHE_VERSION 4 discards v3 caches. The v3 cap of 400 was also exactly the wrong number for this data: a 400-input transaction needs 401 entries, so it would have evicted and refetched on every single refresh. --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/lib/wallet.js | 80 ++++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 32913145..9298ab4d 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.27.1", + "version": "0.28.0", "category": "plugin", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash and window.wizardconnect on any site; window.tronWeb / window.tronLink / window.ethereum / window.solana too. Every call needs your approval.", "author": "Silent Mode", diff --git a/bundled-addons/aegis/lib/wallet.js b/bundled-addons/aegis/lib/wallet.js index fb6cff8c..14b2e421 100644 --- a/bundled-addons/aegis/lib/wallet.js +++ b/bundled-addons/aegis/lib/wallet.js @@ -43,8 +43,15 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora // drives the entire browser. A 7.7 MB cache turned every storage access // into a multi-hundred-millisecond freeze of all of Theseus. Bumping the // version also discards existing oversized caches on first load. - const TX_CACHE_VERSION = 3; - const TX_CACHE_MAX = 400; + // v4: v2 was unbounded and v3 capped the PERSISTED set at 400, which is + // exactly the wrong number here — one consolidation in this wallet has 400 + // inputs, so a single history row needed 401 entries and the cap thrashed. + // The persisted set is now just the displayed window; parents live in RAM. + const TX_CACHE_VERSION = 4; + // Guard on RAM, not on correctness. Parents are re-fetchable; this only + // stops a long session from growing without limit. + const TX_MEM_MAX = 20000; + const TX_FETCH_CONCURRENCY = 12; let txCache = storage.get("txCache", {}) || {}; if (storage.get("txCacheVersion", 1) !== TX_CACHE_VERSION) { txCache = {}; @@ -55,15 +62,29 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora // the cache on every refresh, so an idle wallet rewrote the whole file on // every poll for nothing. let txDirty = false; - function pruneTxCache() { - const ids = Object.keys(txCache); - if (ids.length <= TX_CACHE_MAX) return; - // Newest first by block time. Unconfirmed entries have time 0 but are by - // definition current, so they sort as newest rather than being evicted - // first. Anything dropped is re-fetchable on demand. - ids.sort((a, b) => ((txCache[b].time || Infinity) - (txCache[a].time || Infinity))); - for (const id of ids.slice(TX_CACHE_MAX)) delete txCache[id]; - txDirty = true; + // Process-lifetime, never persisted. Seeded from the persisted window so a + // restart does not refetch what is on screen. + const memTx = new Map(); + for (const [id, t] of Object.entries(txCache)) memTx.set(id, t); + function rememberTx(id, slim) { + if (memTx.size >= TX_MEM_MAX) { + // Drop the oldest insertion; Map preserves insertion order. + const oldest = memTx.keys().next(); + if (!oldest.done) memTx.delete(oldest.value); + } + memTx.set(id, slim); + } + // Fetch many transactions with bounded parallelism. Already-known ids cost + // nothing, so passing the same list twice is cheap. + async function getTxMany(ids) { + const want = [...new Set(ids)].filter((id) => { + const c = memTx.get(id); + return !(c && c.confirmations > 0); + }); + for (let i = 0; i < want.length; i += TX_FETCH_CONCURRENCY) { + const batch = want.slice(i, i + TX_FETCH_CONCURRENCY); + await Promise.all(batch.map((id) => getTx(id).catch(() => null))); + } } let refreshTimer = null; let subscribedHeaders = false; @@ -231,7 +252,7 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora } async function getTx(txid) { - const c = txCache[txid]; + const c = memTx.get(txid) || txCache[txid]; if (c && c.confirmations > 0) return c; const raw = await client.call("blockchain.transaction.get", [txid, true]); const slim = { @@ -252,7 +273,7 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora })), size: raw.size || 0, }; - txCache[txid] = slim; + rememberTx(txid, slim); txDirty = true; return slim; } @@ -270,6 +291,20 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora return hb - ha; }).slice(0, HISTORY_LIMIT); const ours = new Set([...state.watched.values()].map((e) => e.scriptHex)); + // Fetch in two parallel waves instead of one long sequential chain. This + // loop used to await getTx() per displayed transaction and then again per + // INPUT of each, one at a time. A consolidation here has 400 inputs, so a + // single history row cost 400 round trips in series — tens of seconds, + // during which every response still had to be parsed on the main thread. + // Median input count is 2, so the common case gets faster too. + await getTxMany(ordered.map((h) => h.txid)); + const parentIds = []; + for (const h of ordered) { + const t = await getTx(h.txid); + for (const i of t.vin) if (i.txid) parentIds.push(i.txid); + } + await getTxMany(parentIds); + const out = []; for (const h of ordered) { const t = await getTx(h.txid); @@ -277,7 +312,7 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora for (const o of t.vout) { outputsTotal += o.value; if (ours.has(o.scriptHex)) received += o.value; } for (const i of t.vin) { if (!i.txid) continue; // coinbase - const p = await getTx(i.txid); + const p = await getTx(i.txid); // warm after the wave above const po = p.vout[i.vout]; if (!po) continue; inputsTotal += po.value; @@ -295,7 +330,22 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora }); } state.history = out; - if (txDirty) { pruneTxCache(); storage.set("txCache", txCache); txDirty = false; } + // Persist ONLY the displayed window. Parent transactions are needed to + // compute a delta and nothing else, and persisting every one ever seen is + // what grew this store to 7.4 MB across 6,981 entries — which the host + // then re-parsed on every storage read, on the main thread. They stay in + // the in-memory map instead, so a session never refetches them and the + // file stays a few tens of KB. + const keep = {}; + for (const h of ordered) { + const t = memTx.get(h.txid); + if (t) keep[h.txid] = t; + } + const before = JSON.stringify(txCache); + const after = JSON.stringify(keep); + txCache = keep; + if (after !== before) storage.set("txCache", keep); + txDirty = false; } function scriptToAddress(scriptHex) {