theseus/bundled-addons/aegis/lib/chain-bch.js
Local Dev 884f3948b8 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

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.dispose ? this._client.dispose() : this._client.disconnect(); } catch {}
if (this._root) this._root.fill(0);
}
}
return { BchWallet, BCH_NETWORKS };
};