fix(aegis): 0.20.0 — the UTXO scan was 28k requests that always found nothing

Went looking for a performance fix and found a correctness bug underneath it.

getTx() slims each transaction on the way into the cache, and the slim shape
kept only { value, scriptHex } — dropping vout.tokenData. That field is where
the server reports CashTokens. It is NOT in scriptPubKey.hex, which Fulcrum
returns with the token prefix already stripped. So the classify pass fetched
one transaction per UTXO, looked for a prefix that was never there, and
concluded "no token" every single time.

Measured against a real chipnet wallet (130 UTXOs, 91 of them token-bearing):
the old pass cost 54 requests for that address and found 0 tokens; sampling
12 of those transactions, exactly 0 had a scriptPubKey starting with the
token prefix. On the faucet-fed address with 28,289 UTXOs it was ~28k
requests, still finding nothing — which is what made the wallet look hung.

So HD BCH wallets have never shown CashTokens. 0.15.0 fixed the imported
adapter, which reads token_data off listunspent, and I took the HD path's
silence for an empty wallet.

Now: when the server negotiated protocol >= 1.5, listunspent carries
token_data and a UTXO WITHOUT it is definitively not token-bearing, so the
whole set is classified from the one call we already make — 1 request instead
of 28,289. Below 1.5 the fallback fetches parents as before but reads
vout.tokenData (present even at 1.4) and only decodes a prefix as a last
resort, so it is correct now too.

Both routes are reconciled onto one shape. They speak different dialects:
the decoder yields a numeric capability (0/1/2) labelled
immutable/mutable/minting, while Electrum sends a string and calls 0 "none".
Left alone, an identical UTXO would have described itself differently
depending on which server answered. The decoder's vocabulary wins, and the
Certificates pane treats immutable as the quiet default so only capabilities
that change what the holder can do get a tag.

txCache is versioned and dropped once: entries written by the old shape carry
no token information, and an absent field cannot be told apart from "no
token", so a warm cache on a pre-1.5 server would have reported a token
wallet as empty.

Verified against the live wallet: one request, 91 token UTXOs, 46 categories,
17 assets, 51 certificates — matching what the panel reports — with
capability labels normalised (5 immutable, 28 mutable, 18 minting).
This commit is contained in:
Local Dev 2026-09-29 00:56:06 +02:00
parent 9bb9086ff5
commit 3ce36a0184
4 changed files with 129 additions and 40 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.19.0", "version": "0.20.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 on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.",
"author": "Silent Mode", "author": "Silent Mode",

View file

@ -99,6 +99,19 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
this.disconnect(); this.disconnect();
} }
get url() { return this.conn && !this.conn.closed ? this.conn.url : null; } get url() { return this.conn && !this.conn.closed ? this.conn.url : null; }
// The protocol the live connection settled on. Callers use it to decide
// whether listunspent will carry `token_data` (>= 1.5) or whether they
// have to classify tokens the expensive way, by fetching each UTXO's
// parent transaction.
get protocolVersion() { return this.conn && !this.conn.closed ? (this.conn.protocolVersion || null) : null; }
// True when the server will report CashTokens on listunspent itself.
get hasTokenData() {
const v = String(this.protocolVersion || "");
const m = /^(\d+)\.(\d+)/.exec(v);
if (!m) return false;
const major = Number(m[1]), minor = Number(m[2]);
return major > 1 || (major === 1 && minor >= 5);
}
async _ensure() { async _ensure() {
if (this.conn && !this.conn.closed) return this.conn; if (this.conn && !this.conn.closed) return this.conn;
if (this.connecting) return this.connecting; if (this.connecting) return this.connecting;

View file

@ -29,7 +29,19 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
}; };
// Verbose transactions are public chain data; caching them on disk saves a // Verbose transactions are public chain data; caching them on disk saves a
// round of fetches on every launch. // round of fetches on every launch.
const txCache = storage.get("txCache", {}) || {}; // Cached transactions are slimmed on the way in, so a schema change to
// that slim shape has to invalidate them. v2 adds vout.tokenData; entries
// written by v1 carry no token information at all and an absent field is
// indistinguishable from "no token", so they are dropped once rather than
// trusted. Only the pre-1.5 fallback path reads this for classification,
// but a warm v1 cache there would silently report a token wallet as empty.
const TX_CACHE_VERSION = 2;
let txCache = storage.get("txCache", {}) || {};
if (storage.get("txCacheVersion", 1) !== TX_CACHE_VERSION) {
txCache = {};
storage.set("txCache", txCache);
storage.set("txCacheVersion", TX_CACHE_VERSION);
}
let refreshTimer = null; let refreshTimer = null;
let subscribedHeaders = false; let subscribedHeaders = false;
@ -82,27 +94,57 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
const u = await client.call("blockchain.scripthash.listunspent", [e.scripthash]); const u = await client.call("blockchain.scripthash.listunspent", [e.scripthash]);
return (Array.isArray(u) ? u : []).map((x) => ({ return (Array.isArray(u) ? u : []).map((x) => ({
txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e, txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e,
tokenData: x.token_data || null,
})); }));
})); }));
const utxos = lists.flat(); const utxos = lists.flat();
// Enrich each UTXO with its scriptPubKey so cashtokens.decodePrefixedScript // Classify each UTXO as bare BCH or CashToken.
// can classify it. getTx() already caches to disk, so a re-scan on a //
// wallet with hundreds of UTXOs only fetches new ones. Failures are // Two routes. When the server negotiated protocol >= 1.5 it reports
// tolerated — an un-classifiable UTXO is treated as bare BCH, which // `token_data` on listunspent itself, and — this is the part that
// is the conservative choice (worst case: user sees BCH value in // matters — a UTXO WITHOUT token_data at that protocol is definitively
// balance but the coin selector still won't pick it if its token // not a token UTXO. So the whole set is classified from the one
// status matters — it just won't participate in a token send either). // listunspent call, with zero further round-trips.
//
// Below 1.5 the server says nothing, so we fall back to fetching each
// UTXO's parent transaction and decoding the token prefix off its
// scriptPubKey. That is one request per UTXO: correct, cached to disk,
// and completely impractical on a faucet-fed chipnet address — a real
// one here holds 28,289 UTXOs, so a first scan meant ~28k requests and
// read as a hung wallet rather than as work in progress.
//
// Failures on the fallback path are tolerated: an unclassifiable UTXO is
// treated as bare BCH, which is the conservative choice — the coin
// selector may spend it as plain value, but it will never be pulled
// into a token send.
const tokenBalances = {}; const tokenBalances = {};
await Promise.all(utxos.map(async (u) => { // Electrum's token shape -> the shape cashtokens.decodePrefixedScript
try { // returns, so everything downstream is identical whichever route found
const t = await getTx(u.txid); // it. The two speak different dialects and must be reconciled here or an
const out = t.vout[u.vout]; // identical UTXO would describe itself differently depending on which
if (!out || !out.scriptHex) return; // server answered: the decoder yields a NUMERIC capability (0/1/2) with
const scriptBytes = tx.fromHex(out.scriptHex); // the labels immutable/mutable/minting, while Electrum sends a STRING
const { token, lockingScript } = cashtokens.decodePrefixedScript(scriptBytes); // and calls 0 "none". The decoder's vocabulary wins — it is the one
u.scriptHex = out.scriptHex; // already established here and in the CHIP.
u.lockingScriptHex = Array.from(lockingScript, (x) => x.toString(16).padStart(2, "0")).join(""); const CAP_CODE = { none: 0, immutable: 0, mutable: 1, minting: 2 };
if (token) { const CAP_LABEL = ["immutable", "mutable", "minting"];
const tokenFromElectrum = (td) => {
if (!td || !td.category) return null;
let amount = 0n;
try { amount = BigInt(td.amount || 0); } catch (_e) { amount = 0n; }
const nft = td.nft || null;
const code = nft ? (CAP_CODE[String(nft.capability || "none").toLowerCase()] ?? 0) : 0;
return {
categoryHex: String(td.category),
hasAmount: amount > 0n,
amount,
hasNft: !!nft,
commitmentHex: nft ? String(nft.commitment || "") : null,
capability: nft ? code : 0,
capabilityLabel: nft ? CAP_LABEL[code] : null,
};
};
const addToken = (u, token) => {
u.token = token; u.token = token;
const cat = token.categoryHex; const cat = token.categoryHex;
if (!tokenBalances[cat]) tokenBalances[cat] = { fungible: 0n, nfts: [], utxoIds: [] }; if (!tokenBalances[cat]) tokenBalances[cat] = { fungible: 0n, nfts: [], utxoIds: [] };
@ -116,11 +158,30 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
}); });
} }
tokenBalances[cat].utxoIds.push(`${u.txid}:${u.vout}`); tokenBalances[cat].utxoIds.push(`${u.txid}:${u.vout}`);
};
if (client.hasTokenData) {
for (const u of utxos) {
const token = tokenFromElectrum(u.tokenData);
if (token) addToken(u, token);
} }
} else {
await Promise.all(utxos.map(async (u) => {
try {
const t = await getTx(u.txid);
const out = t.vout[u.vout];
if (!out) return;
u.scriptHex = out.scriptHex;
// Prefer the server's own tokenData; fall back to decoding a
// prefix out of the script for a server that embeds it there.
const token = tokenFromElectrum(out.tokenData)
|| (out.scriptHex ? cashtokens.decodePrefixedScript(tx.fromHex(out.scriptHex)).token : null);
if (token) addToken(u, token);
} catch (e) { } catch (e) {
log("utxo classify failed:", u.txid + ":" + u.vout, e?.message || e); log("utxo classify failed:", u.txid + ":" + u.vout, e?.message || e);
} }
})); }));
}
state.utxos = utxos; state.utxos = utxos;
// Serialize BigInt fungible amounts as decimal strings for the snapshot // Serialize BigInt fungible amounts as decimal strings for the snapshot
// (JSON.stringify chokes on BigInt otherwise). // (JSON.stringify chokes on BigInt otherwise).
@ -155,7 +216,17 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
confirmations: raw.confirmations || 0, confirmations: raw.confirmations || 0,
time: raw.blocktime || raw.time || 0, time: raw.blocktime || raw.time || 0,
vin: (raw.vin || []).map((i) => ({ txid: i.txid, vout: i.vout })), vin: (raw.vin || []).map((i) => ({ txid: i.txid, vout: i.vout })),
vout: (raw.vout || []).map((o) => ({ value: sats(o.value), scriptHex: o.scriptPubKey && o.scriptPubKey.hex })), // tokenData was being dropped here, and that was the whole bug: the
// server reports CashTokens in this field, NOT inside
// scriptPubKey.hex, which Fulcrum returns with the token prefix
// already stripped. So the old classify pass fetched a transaction per
// UTXO, looked for a prefix that was never there, and concluded "no
// token" every single time. Keep it.
vout: (raw.vout || []).map((o) => ({
value: sats(o.value),
scriptHex: o.scriptPubKey && o.scriptPubKey.hex,
tokenData: o.tokenData || o.token_data || null,
})),
size: raw.size || 0, size: raw.size || 0,
}; };
txCache[txid] = slim; txCache[txid] = slim;

View file

@ -3440,9 +3440,14 @@ function renderCerts(balances, metaMap) {
const meta = metaMap?.[r.cat] || null; const meta = metaMap?.[r.cat] || null;
const named = meta?.name || meta?.symbol || null; const named = meta?.name || meta?.symbol || null;
const title = named || (r.cat.slice(0, 10) + "…" + r.cat.slice(-6)); const title = named || (r.cat.slice(0, 10) + "…" + r.cat.slice(-6));
const cap = String(r.capabilityLabel || r.capability || "none"); // "immutable" is the quiet default — it describes most certificates and
const capTag = cap && cap !== "none" // tagging every row with it would say nothing. Only the capabilities
? ` <span class="ttag" title="This certificate can ${cap === "minting" ? "issue more" : "be altered"}">${esc(cap.toUpperCase())}</span>` // that change what the holder can DO get a tag. ("none" is Electrum's
// word for the same thing; lib/wallet.js normalises it to immutable, but
// accept both so an older cached snapshot still reads correctly.)
const cap = String(r.capabilityLabel || "").toLowerCase();
const capTag = cap && cap !== "none" && cap !== "immutable"
? ` <span class="ttag" title="This certificate can ${cap === "minting" ? "issue more of its category" : "be altered by its holder"}">${esc(cap.toUpperCase())}</span>`
: ""; : "";
// A commitment is arbitrary bytes; it is the only thing distinguishing // A commitment is arbitrary bytes; it is the only thing distinguishing
// two certificates of the same category, so show it rather than hide it. // two certificates of the same category, so show it rather than hide it.