feat(theseus/bchwallet): receive + history — vault-derived keys, cashaddr, QR, electrum
Wallet core on mainnet:
- keys from api.vault.derive("bchwallet/mainnet/0") -> BIP32 m/44'/145'/0'
(@scure/bip32), never persisted; wiped on deactivate.
- lib/cashaddr.js (encode/decode + legacy Base58Check, spec vectors pass),
lib/keys.js (hash160, p2pkh, electrum scripthash, ECDSA DER + BIP-137
recoverable signing), lib/tx.js (serialization, SIGHASH_ALL|FORKID
digest, coin selection, fee estimate), lib/electrum.js (Fulcrum WSS
client with failover + subscriptions), lib/wallet.js (gap-limit scan,
balance, UTXOs, 25-tx history with per-tx deltas, cached public txs).
- qr.js: dependency-free QR encoder (byte mode, v1-10, EC M/L; verified
against jsQR).
- panel: balance header, Receive (QR, copy, next unused address, explorer),
History (deltas, confirmations, explorer links), Settings (derivation
path, electrum server list, xpub / approval-gated xprv reveal). Locked
and not-set-up vault states explained in-panel.
- host: api.import for ESM-only deps, api.openTab for explorer links; an
add-on whose activate() throws is no longer listed twice.
2026-09-06 02:46:41 +02:00
|
|
|
// Electrum (Fulcrum) JSON-RPC over WebSocket for the wallet. One live
|
|
|
|
|
// connection at a time, chosen by walking the server list in order; the
|
|
|
|
|
// caller gets a stable `call()` that reconnects transparently on the next
|
|
|
|
|
// request after a drop. Notifications (headers / scripthash subscriptions)
|
|
|
|
|
// fan out to `onNotify`.
|
|
|
|
|
module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
|
|
|
|
|
const CALL_TIMEOUT_MS = 20000;
|
|
|
|
|
|
|
|
|
|
class Connection {
|
|
|
|
|
constructor(url) {
|
|
|
|
|
this.url = url;
|
|
|
|
|
this.id = 0;
|
|
|
|
|
this.pending = new Map();
|
|
|
|
|
this.buf = "";
|
|
|
|
|
this.closed = false;
|
|
|
|
|
this.onNotify = null;
|
|
|
|
|
this.onClose = null;
|
|
|
|
|
}
|
|
|
|
|
connect() {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const ws = new WebSocket(this.url);
|
|
|
|
|
this.ws = ws;
|
|
|
|
|
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 () => {
|
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.
2026-09-28 22:52:15 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
feat(theseus/bchwallet): receive + history — vault-derived keys, cashaddr, QR, electrum
Wallet core on mainnet:
- keys from api.vault.derive("bchwallet/mainnet/0") -> BIP32 m/44'/145'/0'
(@scure/bip32), never persisted; wiped on deactivate.
- lib/cashaddr.js (encode/decode + legacy Base58Check, spec vectors pass),
lib/keys.js (hash160, p2pkh, electrum scripthash, ECDSA DER + BIP-137
recoverable signing), lib/tx.js (serialization, SIGHASH_ALL|FORKID
digest, coin selection, fee estimate), lib/electrum.js (Fulcrum WSS
client with failover + subscriptions), lib/wallet.js (gap-limit scan,
balance, UTXOs, 25-tx history with per-tx deltas, cached public txs).
- qr.js: dependency-free QR encoder (byte mode, v1-10, EC M/L; verified
against jsQR).
- panel: balance header, Receive (QR, copy, next unused address, explorer),
History (deltas, confirmations, explorer links), Settings (derivation
path, electrum server list, xpub / approval-gated xprv reveal). Locked
and not-set-up vault states explained in-panel.
- host: api.import for ESM-only deps, api.openTab for explorer links; an
add-on whose activate() throws is no longer listed twice.
2026-09-06 02:46:41 +02:00
|
|
|
catch (e) { fail(e); this.close(); }
|
|
|
|
|
});
|
|
|
|
|
ws.on("error", fail);
|
|
|
|
|
ws.on("message", (d) => this._onData(String(d)));
|
|
|
|
|
ws.on("close", () => {
|
|
|
|
|
this.closed = true;
|
|
|
|
|
for (const p of this.pending.values()) p.reject(new Error("electrum connection closed"));
|
|
|
|
|
this.pending.clear();
|
|
|
|
|
if (this.onClose) this.onClose();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
_onData(chunk) {
|
|
|
|
|
this.buf += chunk;
|
|
|
|
|
let nl;
|
|
|
|
|
while ((nl = this.buf.indexOf("\n")) >= 0) {
|
|
|
|
|
const line = this.buf.slice(0, nl).trim();
|
|
|
|
|
this.buf = this.buf.slice(nl + 1);
|
|
|
|
|
if (line) this._handleLine(line);
|
|
|
|
|
}
|
|
|
|
|
const rest = this.buf.trim();
|
|
|
|
|
if (rest) { try { JSON.parse(rest); this._handleLine(rest); this.buf = ""; } catch {} }
|
|
|
|
|
}
|
|
|
|
|
_handleLine(line) {
|
|
|
|
|
let msg;
|
|
|
|
|
try { msg = JSON.parse(line); } catch { return; }
|
|
|
|
|
if (msg.id != null && this.pending.has(msg.id)) {
|
|
|
|
|
const p = this.pending.get(msg.id);
|
|
|
|
|
this.pending.delete(msg.id);
|
|
|
|
|
clearTimeout(p.timer);
|
|
|
|
|
if (msg.error) p.reject(new Error(typeof msg.error === "object" ? (msg.error.message || JSON.stringify(msg.error)) : String(msg.error)));
|
|
|
|
|
else p.resolve(msg.result);
|
|
|
|
|
} else if (msg.method && this.onNotify) {
|
|
|
|
|
this.onNotify(msg.method, msg.params || []);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
call(method, params = []) {
|
|
|
|
|
if (this.closed) return Promise.reject(new Error("electrum connection closed"));
|
|
|
|
|
const id = ++this.id;
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`electrum timeout: ${method}`)); }
|
|
|
|
|
}, CALL_TIMEOUT_MS);
|
|
|
|
|
this.pending.set(id, { resolve, reject, timer });
|
|
|
|
|
try { this.ws.send(JSON.stringify({ id, method, params }) + "\n"); }
|
|
|
|
|
catch (e) { clearTimeout(timer); this.pending.delete(id); reject(e); }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
close() { this.closed = true; try { this.ws.close(); } catch {} }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class Client {
|
|
|
|
|
constructor(servers) {
|
|
|
|
|
this.servers = servers.slice();
|
|
|
|
|
this.conn = null;
|
|
|
|
|
this.connecting = null;
|
|
|
|
|
this.subscriptions = new Map(); // method+key -> params (replayed on reconnect)
|
|
|
|
|
this.onNotify = null;
|
|
|
|
|
this.onServer = null; // (url|null) connection state for the UI
|
|
|
|
|
}
|
|
|
|
|
setServers(servers) {
|
|
|
|
|
this.servers = servers.slice();
|
|
|
|
|
this.disconnect();
|
|
|
|
|
}
|
|
|
|
|
get url() { return this.conn && !this.conn.closed ? this.conn.url : null; }
|
|
|
|
|
async _ensure() {
|
|
|
|
|
if (this.conn && !this.conn.closed) return this.conn;
|
|
|
|
|
if (this.connecting) return this.connecting;
|
|
|
|
|
this.connecting = (async () => {
|
|
|
|
|
let lastErr;
|
|
|
|
|
for (const url of this.servers) {
|
|
|
|
|
try {
|
|
|
|
|
const c = await new Connection(url).connect();
|
|
|
|
|
c.onNotify = (m, p) => { if (this.onNotify) this.onNotify(m, p); };
|
|
|
|
|
c.onClose = () => { if (this.conn === c) { this.conn = null; if (this.onServer) this.onServer(null); } };
|
|
|
|
|
this.conn = c;
|
|
|
|
|
log("connected", url);
|
|
|
|
|
if (this.onServer) this.onServer(url);
|
|
|
|
|
// Re-arm subscriptions so a reconnect keeps the live feed.
|
|
|
|
|
for (const params of this.subscriptions.values()) c.call(params[0], params[1]).catch(() => {});
|
|
|
|
|
return c;
|
|
|
|
|
} catch (e) { lastErr = e; log("failed", url, e?.message); }
|
|
|
|
|
}
|
|
|
|
|
throw lastErr || new Error("no electrum server reachable");
|
|
|
|
|
})();
|
|
|
|
|
try { return await this.connecting; }
|
|
|
|
|
finally { this.connecting = null; }
|
|
|
|
|
}
|
|
|
|
|
async call(method, params = []) {
|
|
|
|
|
const c = await this._ensure();
|
|
|
|
|
return c.call(method, params);
|
|
|
|
|
}
|
|
|
|
|
// Remember a subscription so it survives reconnects.
|
|
|
|
|
async subscribe(method, params = []) {
|
|
|
|
|
this.subscriptions.set(method + ":" + JSON.stringify(params), [method, params]);
|
|
|
|
|
return this.call(method, params);
|
|
|
|
|
}
|
|
|
|
|
clearSubscriptions() { this.subscriptions.clear(); }
|
|
|
|
|
disconnect() {
|
|
|
|
|
if (this.conn) { const c = this.conn; this.conn = null; c.close(); }
|
|
|
|
|
if (this.onServer) this.onServer(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { Client };
|
|
|
|
|
};
|