diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 0153d390..e71bfa7e 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.19.0", + "version": "0.20.0", "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.", "author": "Silent Mode", diff --git a/bundled-addons/aegis/lib/electrum.js b/bundled-addons/aegis/lib/electrum.js index 3a8141d6..1cba3281 100644 --- a/bundled-addons/aegis/lib/electrum.js +++ b/bundled-addons/aegis/lib/electrum.js @@ -99,6 +99,19 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) { this.disconnect(); } 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() { if (this.conn && !this.conn.closed) return this.conn; if (this.connecting) return this.connecting; diff --git a/bundled-addons/aegis/lib/wallet.js b/bundled-addons/aegis/lib/wallet.js index faa62975..c28bc313 100644 --- a/bundled-addons/aegis/lib/wallet.js +++ b/bundled-addons/aegis/lib/wallet.js @@ -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 // 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 subscribedHeaders = false; @@ -82,45 +94,94 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora const u = await client.call("blockchain.scripthash.listunspent", [e.scripthash]); return (Array.isArray(u) ? u : []).map((x) => ({ 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(); - // Enrich each UTXO with its scriptPubKey so cashtokens.decodePrefixedScript - // 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 - // tolerated — an un-classifiable UTXO is treated as bare BCH, which - // is the conservative choice (worst case: user sees BCH value in - // balance but the coin selector still won't pick it if its token - // status matters — it just won't participate in a token send either). + // Classify each UTXO as bare BCH or CashToken. + // + // Two routes. When the server negotiated protocol >= 1.5 it reports + // `token_data` on listunspent itself, and — this is the part that + // matters — a UTXO WITHOUT token_data at that protocol is definitively + // not a token UTXO. So the whole set is classified from the one + // 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 = {}; - await Promise.all(utxos.map(async (u) => { - try { - const t = await getTx(u.txid); - const out = t.vout[u.vout]; - if (!out || !out.scriptHex) return; - const scriptBytes = tx.fromHex(out.scriptHex); - const { token, lockingScript } = cashtokens.decodePrefixedScript(scriptBytes); - u.scriptHex = out.scriptHex; - u.lockingScriptHex = Array.from(lockingScript, (x) => x.toString(16).padStart(2, "0")).join(""); - if (token) { - u.token = token; - const cat = token.categoryHex; - if (!tokenBalances[cat]) tokenBalances[cat] = { fungible: 0n, nfts: [], utxoIds: [] }; - if (token.hasAmount) tokenBalances[cat].fungible += token.amount; - if (token.hasNft) { - tokenBalances[cat].nfts.push({ - utxoId: `${u.txid}:${u.vout}`, - commitmentHex: token.commitmentHex, - capability: token.capability, - capabilityLabel: token.capabilityLabel, - }); - } - tokenBalances[cat].utxoIds.push(`${u.txid}:${u.vout}`); - } - } catch (e) { - log("utxo classify failed:", u.txid + ":" + u.vout, e?.message || e); + // Electrum's token shape -> the shape cashtokens.decodePrefixedScript + // returns, so everything downstream is identical whichever route found + // it. The two speak different dialects and must be reconciled here or an + // identical UTXO would describe itself differently depending on which + // server answered: the decoder yields a NUMERIC capability (0/1/2) with + // the labels immutable/mutable/minting, while Electrum sends a STRING + // and calls 0 "none". The decoder's vocabulary wins — it is the one + // already established here and in the CHIP. + const CAP_CODE = { none: 0, immutable: 0, mutable: 1, minting: 2 }; + 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; + const cat = token.categoryHex; + if (!tokenBalances[cat]) tokenBalances[cat] = { fungible: 0n, nfts: [], utxoIds: [] }; + if (token.hasAmount) tokenBalances[cat].fungible += token.amount; + if (token.hasNft) { + tokenBalances[cat].nfts.push({ + utxoId: `${u.txid}:${u.vout}`, + commitmentHex: token.commitmentHex, + capability: token.capability, + capabilityLabel: token.capabilityLabel, + }); } - })); + 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) { + log("utxo classify failed:", u.txid + ":" + u.vout, e?.message || e); + } + })); + } state.utxos = utxos; // Serialize BigInt fungible amounts as decimal strings for the snapshot // (JSON.stringify chokes on BigInt otherwise). @@ -155,7 +216,17 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora confirmations: raw.confirmations || 0, time: raw.blocktime || raw.time || 0, 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, }; txCache[txid] = slim; diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 0a7fe177..4097e9b5 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -3440,9 +3440,14 @@ function renderCerts(balances, metaMap) { const meta = metaMap?.[r.cat] || null; const named = meta?.name || meta?.symbol || null; const title = named || (r.cat.slice(0, 10) + "…" + r.cat.slice(-6)); - const cap = String(r.capabilityLabel || r.capability || "none"); - const capTag = cap && cap !== "none" - ? ` ${esc(cap.toUpperCase())}` + // "immutable" is the quiet default — it describes most certificates and + // tagging every row with it would say nothing. Only the capabilities + // 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" + ? ` ${esc(cap.toUpperCase())}` : ""; // A commitment is arbitrary bytes; it is the only thing distinguishing // two certificates of the same category, so show it rather than hide it.