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).
155 lines
6.5 KiB
JavaScript
155 lines
6.5 KiB
JavaScript
// 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 () => {
|
|
// 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(); }
|
|
});
|
|
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; }
|
|
// 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;
|
|
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 };
|
|
};
|