2026-09-08 18:17:25 +02:00
|
|
|
// 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'",
|
2026-09-22 20:44:44 +02:00
|
|
|
explorerTx: "https://bchexplorer.cash/tx/",
|
|
|
|
|
explorerAddr: "https://bchexplorer.cash/address/",
|
2026-09-08 18:17:25 +02:00
|
|
|
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'",
|
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
|
|
|
// 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=",
|
2026-09-08 18:17:25 +02:00
|
|
|
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,
|
chore(aegis): 0.8.2 — sectioned Settings tab
Settings grew tall enough that the user had to scroll past a dozen
cards to reach Prices, Sites or About. Split it into six named
sections (Security, Session, Wallet, Prices, Sites, About) with a
chip nav row at the top; only one section is visible at a time and
the choice persists across restarts.
Adds an About card that names the wallet, the aegis.x front-door
site and the silentmode.st umbrella, so support triage has a
one-click way to reach either from within the panel.
Includes the accumulated 0.7.x-0.8.1 wallet work that was already
shipping on OTA (CashTokens/BCMR, imported-wallet spend, siascan
integration, consolidate, currency picker, footer update chip).
2026-09-22 20:37:28 +02:00
|
|
|
// CashTokens balances (0.7.0+). Categories → { fungible: str,
|
|
|
|
|
// nfts: [...], utxoCount }. Empty object when no tokens held.
|
|
|
|
|
tokenBalances: w.tokenBalances || {},
|
2026-09-08 18:17:25 +02:00
|
|
|
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 }];
|
chore(aegis): 0.8.2 — sectioned Settings tab
Settings grew tall enough that the user had to scroll past a dozen
cards to reach Prices, Sites or About. Split it into six named
sections (Security, Session, Wallet, Prices, Sites, About) with a
chip nav row at the top; only one section is visible at a time and
the choice persists across restarts.
Adds an About card that names the wallet, the aegis.x front-door
site and the silentmode.st umbrella, so support triage has a
one-click way to reach either from within the panel.
Includes the accumulated 0.7.x-0.8.1 wallet work that was already
shipping on OTA (CashTokens/BCMR, imported-wallet spend, siascan
integration, consolidate, currency picker, footer update chip).
2026-09-22 20:37:28 +02:00
|
|
|
const memo = typeof spec.memo === "string" ? spec.memo : "";
|
|
|
|
|
return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax, memo });
|
2026-09-08 18:17:25 +02:00
|
|
|
}
|
|
|
|
|
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 {}
|
Aegis: fees that follow the network, connections that come back, its own name on Solana
Still 0.31.0 (unpublished batch).
- ETH nonce. The pending count from a load-balanced RPC often misses a
transaction this wallet sent seconds ago, so two sends in a row shared a
nonce and the second failed or replaced the first. The nonce is taken at
signing, from the RPC or from what the wallet itself last broadcast,
whichever is higher, and broadcasts are serialised per wallet.
- Chains without EIP-1559 (no baseFeePerGas) get a legacy EIP-155
transaction; they rejected the type-2 envelope, so a network added by a
dapp could receive but never send. The tip is clamped to the fee cap.
- Bitcoin and DigiByte take their fee rate from the Electrum server's
estimate instead of a constant, size each output from its real script
(a taproot destination was undercounted), and round the size up before
pricing. Bitcoin inputs signal replace-by-fee. DigiByte keeps its 20
sat/vB floor and does not signal RBF, which it does not have.
- Electrum: a wallet with live subscriptions went quiet for good when its
server dropped. The client reconnects with backoff, pings to catch dead
sockets, times out a silent connect, and hands the replayed subscription
answers on as notifications so the wallet refreshes. dispose() ends it.
- Max with an SPL token selected did nothing; it now fills the exact token
balance.
- Removing a wallet retired its derivation index for good. Add wallet now
takes the lowest free index, so the same wallet comes back.
- Solana: registered through the Wallet Standard as "Aegis" instead of
setting isPhantom, with silent connect for already-connected sites.
- "Only show the wallet to sites I enable": Aegis keeps the host's
page-inject allow-list in step with enabled and connected sites.
2026-10-04 01:55:38 +02:00
|
|
|
try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {}
|
2026-09-08 18:17:25 +02:00
|
|
|
if (this._root) this._root.fill(0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { BchWallet, BCH_NETWORKS };
|
|
|
|
|
};
|