theseus/bundled-addons/aegis/lib/chain-bch.js
Local Dev 72b086fa7e feat(aegis): 0.13.1 — chipnet explorer links point at ours; explorer gains a tx view
chipnet.imaginary.cash's web explorer is returning 502, so every "Explorer"
button on a chipnet wallet led to a Bad Gateway. Its Electrum endpoint on
:50004 is a separate service and is fine — balances, history and the send
path were never affected, which is worth stating because a dead explorer
link looks like a dead wallet.

The explorer now resolves transactions as well as addresses, so it can back
both links rather than half of them. One search box: 64 hex characters is
treated as a transaction id, anything else goes to the cashaddr decoder so
it can complain precisely. A transaction shows confirmations, total out,
size and timestamp, its inputs as links to the parent transactions, and its
outputs as links to the receiving addresses — in-page, without a reload.
Inputs deliberately show no amount: Electrum does not resolve it, and
fetching every parent transaction to display one number is not worth the
round-trips.

Aegis's chipnet explorerTx/explorerAddr now point there. Same chain data,
read over the same Electrum connection the wallet already trusts, and a page
we can fix ourselves the next time one breaks. Mainnet is untouched.
2026-09-28 00:12:42 +02:00

164 lines
6.5 KiB
JavaScript

// BCH chain adapter — wraps the existing keys.js / wallet.js / tx.js /
// electrum.js / cashaddr.js code with the common adapter shape that the
// multi-wallet manager talks to. Every BCH wallet is one BIP32 account
// derived from its own 32-byte root (from api.vault.derive).
//
// Deps are handed in so index.js loads @noble/* once and shares them across
// every wallet, rather than each adapter dynamic-importing on its own.
const BCH_NETWORKS = {
mainnet: {
id: "mainnet",
label: "Mainnet",
prefix: "bitcoincash",
defaultAccountPath: "m/44'/145'/0'",
explorerTx: "https://bchexplorer.cash/tx/",
explorerAddr: "https://bchexplorer.cash/address/",
defaultServers: [
"wss://bch.imaginary.cash:50004",
"wss://cashnode.bch.ninja:50004",
"wss://electroncash.dk:50004",
"wss://fulcrum.jettscythe.xyz:50004",
],
faucet: null,
},
chipnet: {
id: "chipnet",
label: "Chipnet testnet",
prefix: "bchtest",
// BIP44 testnet coin type is 1 across every chain; the account is still 0.
defaultAccountPath: "m/44'/1'/0'",
// chipnet.imaginary.cash's WEB explorer has been returning 502 (its
// Electrum endpoint on :50004 is unaffected and still serves balances).
// Point at our own instead — same chain data, read over the same
// Electrum connection, and a page we can fix when it breaks.
explorerTx: "https://aegis.x/explorer/chipnet/?tx=",
explorerAddr: "https://aegis.x/explorer/chipnet/?addr=",
defaultServers: [
"wss://chipnet.imaginary.cash:50004",
"wss://chipnet.bch.ninja:50004",
],
faucet: "https://tbch.googol.cash/",
},
};
module.exports = function makeBchAdapter({
HDKey, secp256k1, sha256, ripemd160, cashaddr,
keysLib, tx, electrum, WebSocket,
}) {
// storage is the FULL api.storage. keyPrefix scopes every read/write under
// "wallets/<walletId>/…" so multiple BCH wallets don't stomp each other.
function scopedStorage(storage, keyPrefix) {
const k = (key) => keyPrefix + key;
return {
get: (key, fallback = null) => storage.get(k(key), fallback),
set: (key, value) => storage.set(k(key), value),
};
}
class BchWallet {
constructor(root32, {
walletId, storage, log = () => {}, onChange = () => {}, servers,
network = "mainnet", accountPath,
} = {}) {
if (!walletId) throw new Error("chain-bch: walletId required");
const net = BCH_NETWORKS[network];
if (!net) throw new Error(`chain-bch: unknown network ${network}`);
this.walletId = walletId;
this.chain = "bch";
this.network = net.id;
this._net = net;
this.log = log;
this.onChange = onChange;
this.storage = scopedStorage(storage, `wallets/${walletId}/`);
// Servers: caller-provided override → user-set custom list (handled by
// index.js already, this is a fallback path) → the network's built-in
// defaults so a wallet always has somewhere to connect.
this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice();
const wantPath = accountPath || net.defaultAccountPath;
this._accountPath = /^m(\/\d+'?)+$/.test(wantPath) ? wantPath : net.defaultAccountPath;
this._client = new electrum.Client(this._servers);
this._client.onServer = () => this._emit();
this._keys = new keysLib.WalletKeys(root32, this._accountPath, net.prefix);
this._root = new Uint8Array(root32);
const walletFactory = require("./wallet.js");
this._wallet = walletFactory({
client: this._client, keys: this._keys, tx, cashaddr, sha256,
storage: this.storage,
log: (...a) => this.log(...a),
onChange: () => this._emit(),
});
}
setServers(list) {
this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice();
this._client.setServers(this._servers);
}
_emit() { try { this.onChange(); } catch {} }
snapshot() {
const w = this._wallet.snapshot();
return {
chain: "bch",
network: this._net.id,
ticker: "BCH",
decimals: 8,
address: w.address,
addressIndex: w.addressIndex,
addressPath: w.addressPath,
balance: w.balance,
// CashTokens balances (0.7.0+). Categories → { fungible: str,
// nfts: [...], utxoCount }. Empty object when no tokens held.
tokenBalances: w.tokenBalances || {},
height: w.height,
history: w.history,
scanning: w.scanning,
error: w.error,
server: this._client.url || null,
servers: this._servers,
accountPath: this._accountPath,
xpub: this._keys.xpub,
explorerTx: this._net.explorerTx,
explorerAddr: this._net.explorerAddr,
faucet: this._net.faucet,
};
}
async refresh(full) { return this._wallet.refresh(!!full); }
nextAddress() { return this._wallet.nextUnusedAddress(); }
current() { return this._wallet.current(); }
plan(spec) {
const targets = Array.isArray(spec.outputs) && spec.outputs.length
? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value }))
: [{ to: spec.to, value: spec.amount ?? spec.value }];
const memo = typeof spec.memo === "string" ? spec.memo : "";
return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax, memo });
}
async signAndBroadcast(plan) { return this._wallet.signAndBroadcast(plan); }
// 65-byte BIP-137 recoverable signature — the format Electron Cash and
// most BCH tooling verify against.
signMessage(message) {
const enc = new TextEncoder();
const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("too long"); return Uint8Array.from([b.length, ...b]); };
const MAGIC = "Bitcoin Signed Message:\n";
const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]);
const digest = sha256(sha256(payload));
const entry = this.current();
const sig = this._keys.signRecoverable(entry, digest);
return { address: entry.address, signature: Buffer.from(sig).toString("base64") };
}
recovery() {
return { accountPath: this._accountPath, xpub: this._keys.xpub, xprv: this._keys.xprv };
}
dispose() {
try { this._wallet.dispose(); } catch {}
try { this._keys.wipe(); } catch {}
try { this._client.disconnect(); } catch {}
if (this._root) this._root.fill(0);
}
}
return { BchWallet, BCH_NETWORKS };
};