perf(aegis): history was 473 round trips in series, and persisted all of them

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.
This commit is contained in:
Local Dev 2026-10-03 16:12:53 +02:00
parent 469ff482ea
commit 7983e24f2e
2 changed files with 66 additions and 16 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.27.1", "version": "0.28.0",
"category": "plugin", "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.", "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", "author": "Silent Mode",

View file

@ -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 // 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 // into a multi-hundred-millisecond freeze of all of Theseus. Bumping the
// version also discards existing oversized caches on first load. // version also discards existing oversized caches on first load.
const TX_CACHE_VERSION = 3; // v4: v2 was unbounded and v3 capped the PERSISTED set at 400, which is
const TX_CACHE_MAX = 400; // 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", {}) || {}; let txCache = storage.get("txCache", {}) || {};
if (storage.get("txCacheVersion", 1) !== TX_CACHE_VERSION) { if (storage.get("txCacheVersion", 1) !== TX_CACHE_VERSION) {
txCache = {}; 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 // the cache on every refresh, so an idle wallet rewrote the whole file on
// every poll for nothing. // every poll for nothing.
let txDirty = false; let txDirty = false;
function pruneTxCache() { // Process-lifetime, never persisted. Seeded from the persisted window so a
const ids = Object.keys(txCache); // restart does not refetch what is on screen.
if (ids.length <= TX_CACHE_MAX) return; const memTx = new Map();
// Newest first by block time. Unconfirmed entries have time 0 but are by for (const [id, t] of Object.entries(txCache)) memTx.set(id, t);
// definition current, so they sort as newest rather than being evicted function rememberTx(id, slim) {
// first. Anything dropped is re-fetchable on demand. if (memTx.size >= TX_MEM_MAX) {
ids.sort((a, b) => ((txCache[b].time || Infinity) - (txCache[a].time || Infinity))); // Drop the oldest insertion; Map preserves insertion order.
for (const id of ids.slice(TX_CACHE_MAX)) delete txCache[id]; const oldest = memTx.keys().next();
txDirty = true; 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 refreshTimer = null;
let subscribedHeaders = false; let subscribedHeaders = false;
@ -231,7 +252,7 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
} }
async function getTx(txid) { async function getTx(txid) {
const c = txCache[txid]; const c = memTx.get(txid) || txCache[txid];
if (c && c.confirmations > 0) return c; if (c && c.confirmations > 0) return c;
const raw = await client.call("blockchain.transaction.get", [txid, true]); const raw = await client.call("blockchain.transaction.get", [txid, true]);
const slim = { const slim = {
@ -252,7 +273,7 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
})), })),
size: raw.size || 0, size: raw.size || 0,
}; };
txCache[txid] = slim; rememberTx(txid, slim);
txDirty = true; txDirty = true;
return slim; return slim;
} }
@ -270,6 +291,20 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
return hb - ha; return hb - ha;
}).slice(0, HISTORY_LIMIT); }).slice(0, HISTORY_LIMIT);
const ours = new Set([...state.watched.values()].map((e) => e.scriptHex)); 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 = []; const out = [];
for (const h of ordered) { for (const h of ordered) {
const t = await getTx(h.txid); 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 o of t.vout) { outputsTotal += o.value; if (ours.has(o.scriptHex)) received += o.value; }
for (const i of t.vin) { for (const i of t.vin) {
if (!i.txid) continue; // coinbase 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]; const po = p.vout[i.vout];
if (!po) continue; if (!po) continue;
inputsTotal += po.value; inputsTotal += po.value;
@ -295,7 +330,22 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
}); });
} }
state.history = out; 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) { function scriptToAddress(scriptHex) {