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.