fix(aegis): 0.15.0 — ask Electrum for protocol 1.5, which is where the tokens were

Aegis negotiated a flat protocol "1.4". Measured against Fulcrum 2.1.0 on
chipnet, for a wallet holding CashTokens:

  asked "1.4"            -> negotiated 1.4    ->  39 utxos,  0 with token_data
  asked ["1.4","1.5.3"]  -> negotiated 1.5.3  -> 130 utxos, 91 with token_data

So 1.4 did not merely omit the `token_data` field — Fulcrum left the
token-bearing outputs out of listunspent altogether. Ninety-one UTXOs were
invisible to the wallet, along with the BCH sitting in them. That is a
balance-correctness bug, not only a missing Assets card, and it applied to
the HD path too, since lib/wallet.js reads the same listunspent.

Now a [min, max] range: a modern server picks 1.5.3, an older one still
settles on 1.4, so nothing that worked before stops working. The negotiated
version is recorded on the client for diagnosis.

Second half: the imported BCH adapter had no CashToken code at all — it never
set tokenBalances and snapshot() never exposed it, so the panel's Assets card
was hidden for every WIF import however many tokens the address held. A
chipnet test wallet with 46 categories showed nothing. It now aggregates from
the server's own token_data, which costs one call for the whole set rather
than the per-UTXO transaction fetch the HD path uses, and emits the same
serialised shape the panel already reads. Verified against that wallet: 130
UTXOs, 46 categories, 17 fungible, 32 with NFTs, JSON-clean.

Found because the user said their asset "uses a different asset category" and
suggested checking with the explorer. It is ordinary CashTokens; the wallet
simply could not see them. My earlier conclusion that the empty Assets card
was correct came from probing a single address that genuinely holds no tokens
and generalising from it.
This commit is contained in:
Local Dev 2026-09-28 22:52:15 +02:00
parent 6d844b03cf
commit 74224cd7dd
3 changed files with 55 additions and 3 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.14.0", "version": "0.15.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

@ -120,6 +120,7 @@ module.exports = function makeImportedBchAdapter({
history: [], history: [],
utxos: [], utxos: [],
height: 0, height: 0,
tokenBalances: null, // { <categoryHex>: {fungible, nfts, utxoCount} }
scanning: false, scanning: false,
error: null, error: null,
}; };
@ -148,6 +149,9 @@ module.exports = function makeImportedBchAdapter({
balance: this._state.balance, balance: this._state.balance,
height: this._state.height, height: this._state.height,
history: this._state.history, history: this._state.history,
// Without this the panel's Assets card has nothing to read and stays
// hidden however many tokens refresh() found.
tokenBalances: this._state.tokenBalances || null,
scanning: this._state.scanning, scanning: this._state.scanning,
error: this._state.error, error: this._state.error,
server: this._client.url || null, server: this._client.url || null,
@ -170,9 +174,46 @@ module.exports = function makeImportedBchAdapter({
this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) }; this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) };
// Always pull UTXOs so spend / send-max work off fresh state. // Always pull UTXOs so spend / send-max work off fresh state.
const utxos = await this._client.call("blockchain.scripthash.listunspent", [this._scripthash]); const utxos = await this._client.call("blockchain.scripthash.listunspent", [this._scripthash]);
this._state.utxos = (Array.isArray(utxos) ? utxos : []).map((u) => ({ const list = Array.isArray(utxos) ? utxos : [];
this._state.utxos = list.map((u) => ({
txid: u.tx_hash, vout: u.tx_pos, value: Number(u.value), height: Number(u.height || 0), txid: u.tx_hash, vout: u.tx_pos, value: Number(u.value), height: Number(u.height || 0),
token: u.token_data || null,
})); }));
// CashTokens. This adapter never looked for them, so a WIF-imported
// wallet holding tokens reported none and the panel hid its Assets
// card — a chipnet test wallet with 46 categories showed nothing.
//
// The HD path (lib/wallet.js) decodes the token prefix off each
// UTXO's scriptPubKey, which costs one transaction fetch per UTXO.
// Here we take the server's own `token_data` instead: one call for
// the whole set. That arrives only when protocol >= 1.5 was
// negotiated (see electrum.js), and when it does not, tokens are
// simply absent — the same as before this change, never wrong.
const tokenBalances = {};
for (const u of list) {
const t = u.token_data;
if (!t || !t.category) continue;
const cat = String(t.category);
if (!tokenBalances[cat]) tokenBalances[cat] = { fungible: 0n, nfts: [], utxoCount: 0 };
const b = tokenBalances[cat];
b.utxoCount++;
if (t.amount) { try { b.fungible += BigInt(t.amount); } catch (_e) {} }
if (t.nft) {
b.nfts.push({
utxoId: `${u.tx_hash}:${u.tx_pos}`,
commitmentHex: t.nft.commitment || "",
capability: t.nft.capability || "none",
capabilityLabel: t.nft.capability || "none",
});
}
}
// Same serialised shape the HD wallet emits — fungible as a decimal
// string, since JSON.stringify cannot carry a BigInt.
const serialised = {};
for (const [cat, b] of Object.entries(tokenBalances)) {
serialised[cat] = { fungible: b.fungible.toString(), nfts: b.nfts, utxoCount: b.utxoCount };
}
this._state.tokenBalances = serialised;
if (full) { if (full) {
const hist = await this._client.call("blockchain.scripthash.get_history", [this._scripthash]); const hist = await this._client.call("blockchain.scripthash.get_history", [this._scripthash]);
this._state.history = (hist || []).slice(-50).map((h) => ({ this._state.history = (hist || []).slice(-50).map((h) => ({

View file

@ -22,7 +22,18 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
this.ws = ws; this.ws = ws;
const fail = (e) => { if (!this.closed) { this.closed = true; reject(e instanceof Error ? e : new Error("electrum ws error: " + this.url)); } }; const fail = (e) => { if (!this.closed) { this.closed = true; reject(e instanceof Error ? e : new Error("electrum ws error: " + this.url)); } };
ws.on("open", async () => { ws.on("open", async () => {
try { await this.call("server.version", ["theseus-bchwallet", "1.4"]); resolve(this); } // A RANGE, not a flat "1.4". Fulcrum only attaches `token_data` to
// listunspent results once protocol >= 1.5 is negotiated, and with
// a flat 1.4 it silently omits it — which is why imported BCH
// wallets showed no CashTokens at all. A [min, max] pair lets a
// modern server pick 1.5.3 while an older one still settles on 1.4,
// so nothing that worked before stops working.
try {
const v = await this.call("server.version", ["theseus-bchwallet", ["1.4", "1.5.3"]]);
this.serverVersion = Array.isArray(v) ? v[0] : null;
this.protocolVersion = Array.isArray(v) ? v[1] : null;
resolve(this);
}
catch (e) { fail(e); this.close(); } catch (e) { fail(e); this.close(); }
}); });
ws.on("error", fail); ws.on("error", fail);