theseus/bundled-addons/aegis/lib/chain-bch-imported.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

361 lines
17 KiB
JavaScript

// Imported BCH wallet — single-address, key material lives in Theseus's
// wallet-imports.enc (design §3.2). This adapter mirrors chain-bch.js's
// public shape (snapshot, refresh, plan, signAndBroadcast, signMessage,
// dispose) but does NOT go through vault.derive + HKDF: derivation is
// direct from the seed+path or WIF that the user imported.
//
// 0.6.36+: spend path enabled. plan() builds a P2PKH tx off the wallet's
// single scripthash UTXO set; signAndBroadcast() pulls the signer material
// from api.vault.imports.signer(importId), decodes the WIF or derives the
// mnemonic/path into a 32-byte priv key, and signs every input in RAM.
// The private key never lands in adapter state — signAndBroadcast fetches
// it fresh per broadcast and drops it before returning.
module.exports = function makeImportedBchAdapter({
sha256, ripemd160, cashaddr, electrum, WebSocket, tx,
HDKey, secp256k1, base58check, vaultImports,
}) {
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]);
const scripthashOf = (script) => toHex(sha256(script).slice().reverse());
const hash160 = (b) => ripemd160(sha256(b));
const IMPORTED_BCH_NETWORKS = {
mainnet: {
id: "mainnet", label: "Mainnet", prefix: "bitcoincash", wifVersion: 0x80,
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",
],
},
chipnet: {
id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", wifVersion: 0xef,
// chipnet.imaginary.cash's WEB explorer has been returning 502 for
// weeks, so every history row and Explorer button on an imported
// chipnet wallet led to a dead page. chain-bch.js was moved to our own
// explorer and this adapter was missed — which is most of them, since
// a keystore bulk import produces imported wallets.
// Its Electrum endpoint on :50004 is unrelated and still in use below.
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/",
},
};
function h160OfCashaddr(addr) {
const clean = String(addr || "").replace(/^bitcoincash:|^bchtest:/, "");
const { type, hash } = cashaddr.decode(addr.includes(":") ? addr : "bitcoincash:" + clean);
if (type !== 0) throw new Error(`imported wallet must be P2PKH (got type ${type})`);
return hash;
}
// Decode a WIF-encoded private key. Accepts both mainnet (0x80) and
// testnet (0xef) version bytes and both compressed and uncompressed
// forms; returns { priv (32 bytes), compressed (bool) }.
function decodeWif(wif, versionByte) {
const bytes = base58check.decodeCheck(String(wif).trim());
if (!(bytes[0] === versionByte || bytes[0] === 0x80 || bytes[0] === 0xef)) {
throw new Error(`unexpected WIF version 0x${bytes[0].toString(16)}`);
}
const compressed = bytes.length === 34 && bytes[33] === 0x01;
const priv = bytes.slice(1, 33);
if (priv.length !== 32) throw new Error("WIF payload is not 32 bytes");
return { priv, compressed };
}
// Derive a P2PKH signer (32-byte priv + 33-byte compressed pubkey) from
// whatever vault.imports.signer returned. Two shapes today:
// { kind: "seed", seed: hex, path: "m/…" } — BIP32 derivation
// { kind: "wif", wif: base58check } — direct decode
// Anything else (or a missing signer) throws with a clear message so
// the panel can surface it rather than the broadcast returning garbage.
function signerToKey(signerBlob, net) {
if (!signerBlob) throw new Error("no signer material for this wallet");
if (signerBlob.kind === "seed") {
const seed = signerBlob.seed;
if (!/^[0-9a-f]+$/i.test(seed)) throw new Error("seed material must be hex");
const seedBytes = Uint8Array.from(seed.match(/../g).map((x) => parseInt(x, 16)));
const node = HDKey.fromMasterSeed(seedBytes).derive(signerBlob.path || "m");
return { priv: node.privateKey, pub: node.publicKey };
}
if (signerBlob.kind === "wif") {
const { priv } = decodeWif(signerBlob.wif, net.wifVersion);
const pub = secp256k1.getPublicKey(priv, true);
return { priv, pub };
}
throw new Error(`unknown signer kind: ${signerBlob.kind}`);
}
class ImportedBchWallet {
constructor({
walletId, storage, log = () => {}, onChange = () => {},
network = "mainnet", cashaddr: address, servers, importId,
} = {}) {
const net = IMPORTED_BCH_NETWORKS[network];
if (!net) throw new Error(`chain-bch-imported: unknown network ${network}`);
if (!address) throw new Error("chain-bch-imported: cashaddr required");
this.walletId = walletId;
// importId is the vault-side id used to fetch the signer at
// sign-time. Optional here so a mount without spend capability
// still works (read-only surface unaffected).
this._importId = importId || null;
this.chain = "bch";
this.network = net.id;
this._net = net;
this.log = log;
this.onChange = onChange;
this._address = address;
this._h160 = h160OfCashaddr(address);
this._script = p2pkhScript(this._h160);
this._scripthash = scripthashOf(this._script);
this._scriptHex = toHex(this._script);
this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice();
this._client = new electrum.Client(this._servers);
this._client.onServer = () => this._emit();
this._state = {
balance: { confirmed: 0, unconfirmed: 0 },
history: [],
utxos: [],
height: 0,
tokenBalances: null, // { <categoryHex>: {fungible, nfts, utxoCount} }
scanning: false,
error: null,
};
}
setServers(list) {
this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice();
this._client.setServers(this._servers);
}
schedulePoll(ms) {
clearTimeout(this._pollTimer);
this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms);
}
_emit() { try { this.onChange(); } catch {} }
snapshot() {
return {
chain: "bch",
network: this._net.id,
ticker: "BCH",
decimals: 8,
address: this._address,
addressIndex: 0,
addressPath: null,
balance: this._state.balance,
height: this._state.height,
history: this._state.history,
// Without this the panel's Assets card has nothing to read and stays
// hidden however many tokens refresh() found.
tokenBalances: this._state.tokenBalances || null,
scanning: this._state.scanning,
error: this._state.error,
server: this._client.url || null,
servers: this._servers,
imported: true,
// 0.6.36+: imported wallets can spend when the vault signer is
// reachable (i.e. Theseus is unlocked). canSpend reflects that so
// the panel can enable the Send tab without probing.
canSpend: !!this._importId,
explorerTx: this._net.explorerTx,
explorerAddr: this._net.explorerAddr,
faucet: this._net.faucet,
};
}
async refresh(full) {
this._state.scanning = true; this._emit();
try {
const bal = await this._client.call("blockchain.scripthash.get_balance", [this._scripthash]);
this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) };
// Always pull UTXOs so spend / send-max work off fresh state.
const utxos = await this._client.call("blockchain.scripthash.listunspent", [this._scripthash]);
const list = Array.isArray(utxos) ? utxos : [];
this._state.utxos = list.map((u) => ({
txid: u.tx_hash, vout: u.tx_pos, value: Number(u.value), height: Number(u.height || 0),
token: u.token_data || null,
}));
// get_balance counts the sats parked under token UTXOs, which this
// wallet never spends (see plan()). Report what is actually
// spendable so Max and the balance agree with what a send can move.
if (this._state.utxos.some((u) => u.token)) {
let confirmed = 0, unconfirmed = 0;
for (const u of this._state.utxos) {
if (u.token) continue;
if (u.height > 0) confirmed += u.value; else unconfirmed += u.value;
}
this._state.balance = { confirmed, unconfirmed };
}
// CashTokens. This adapter never looked for them, so a WIF-imported
// wallet holding tokens reported none and the panel hid its Assets
// card — a chipnet test wallet with 46 categories showed nothing.
//
// The HD path (lib/wallet.js) decodes the token prefix off each
// UTXO's scriptPubKey, which costs one transaction fetch per UTXO.
// Here we take the server's own `token_data` instead: one call for
// the whole set. That arrives only when protocol >= 1.5 was
// negotiated (see electrum.js), and when it does not, tokens are
// simply absent — the same as before this change, never wrong.
const tokenBalances = {};
for (const u of list) {
const t = u.token_data;
if (!t || !t.category) continue;
const cat = String(t.category);
if (!tokenBalances[cat]) tokenBalances[cat] = { fungible: 0n, nfts: [], utxoCount: 0 };
const b = tokenBalances[cat];
b.utxoCount++;
if (t.amount) { try { b.fungible += BigInt(t.amount); } catch (_e) {} }
if (t.nft) {
b.nfts.push({
utxoId: `${u.tx_hash}:${u.tx_pos}`,
commitmentHex: t.nft.commitment || "",
capability: t.nft.capability || "none",
capabilityLabel: t.nft.capability || "none",
});
}
}
// Same serialised shape the HD wallet emits — fungible as a decimal
// string, since JSON.stringify cannot carry a BigInt.
const serialised = {};
for (const [cat, b] of Object.entries(tokenBalances)) {
serialised[cat] = { fungible: b.fungible.toString(), nfts: b.nfts, utxoCount: b.utxoCount };
}
this._state.tokenBalances = serialised;
if (full) {
const hist = await this._client.call("blockchain.scripthash.get_history", [this._scripthash]);
this._state.history = (hist || []).slice(-50).map((h) => ({
txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0,
}));
}
this._state.error = null;
} catch (e) {
this._state.error = e?.message || String(e);
} finally {
this._state.scanning = false;
this._emit();
}
}
nextAddress() { return { address: this._address, index: 0 }; }
current() {
return {
address: this._address, index: 0, branch: 0, path: null,
h160: this._h160, script: this._script, scripthash: this._scripthash, scriptHex: this._scriptHex,
};
}
// 0.6.36 spend path. Builds an unsigned P2PKH plan against the wallet's
// own UTXO set. Signing happens in signAndBroadcast, which fetches the
// key material from Theseus's vault at broadcast time — nothing key-
// bearing lives in the plan itself, so a plan can round-trip through
// the approval overlay without leaking secrets.
plan(spec) {
if (!this._state.utxos.length) throw new Error("wallet has no unspent outputs to spend from");
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 outs = targets.map((t) => {
const a = cashaddr.parseAny(t.to, sha256, this._net.prefix);
const script = a.type === 0
? Uint8Array.from([0x76, 0xa9, 0x14, ...a.hash, 0x88, 0xac])
: Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]);
return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr };
});
if (spec?.memo) outs.push({ value: 0, script: tx.memoScript(String(spec.memo)), data: true, memo: String(spec.memo) });
const rate = Math.min(10, Math.max(1, Number(spec?.feeRate) || 1));
// Imported wallets have exactly one address, so change goes back to
// itself — no need to derive a fresh change entry from an HD tree.
const changeScript = this._script;
// Token-bearing UTXOs never enter coin selection — same rule as the HD
// wallet. tx.js builds plain P2PKH inputs with no token prefix, so a
// selected token UTXO fails at broadcast today, and would BURN the
// token the day the sighash learns about prefixes.
const spendable = this._state.utxos.filter((u) => !u.token).sort((a, b) => (b.height > 0) - (a.height > 0));
if (!spendable.length) throw new Error("every unspent output in this wallet carries a token; there is no plain BCH to spend");
const sel = tx.select(spendable, outs, rate, changeScript, { sendMax: !!spec?.sendMax });
const nonData = sel.outputs.filter((o) => !o.data);
const total = sel.outputs.reduce((a, o) => a + o.value, 0);
return {
...sel,
feeRate: rate,
recipients: nonData
.filter((_, i) => outs[i] && !outs[i].data)
.map((o, i) => ({ to: outs[i].to, value: o.value })),
memo: spec?.memo || null,
total,
};
}
async signAndBroadcast(plan) {
if (!this._importId) throw new Error("this wallet has no signer registered");
if (!vaultImports || typeof vaultImports.signer !== "function") throw new Error("vault.imports.signer unavailable");
const signerBlob = await vaultImports.signer(this._importId);
let key;
try {
key = signerToKey(signerBlob, this._net);
// Belt-and-braces: the signer must match the wallet's own address.
// Catches vault-side corruption and any accidental cross-mount.
const derivedH160 = hash160(key.pub);
const same = derivedH160.length === this._h160.length && derivedH160.every((b, i) => b === this._h160[i]);
if (!same) throw new Error("signer material does not match this wallet's address");
const inputs = plan.inputs.map((u) => ({ ...u, script: this._script }));
const t = { inputs, outputs: plan.outputs };
const signed = tx.sign(t, (inp, _i, digest) => ({
sig: secp256k1.sign(digest, key.priv, { prehash: false, lowS: true, format: "der" }),
publicKey: key.pub,
}));
const txid = await this._client.call("blockchain.transaction.broadcast", [signed.hex]);
if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid));
this.log("broadcast", txid);
setTimeout(() => this.refresh(false).catch(() => {}), 1500);
return { txid, hex: signed.hex, fee: plan.fee };
} finally {
// Wipe the private material before returning. Not perfect (JS can
// still relocate the underlying buffer during GC) but it minimises
// the window in which the raw key sits in this frame.
if (key && key.priv && key.priv.fill) { try { key.priv.fill(0); } catch {} }
}
}
// BIP-137 message signing from the imported key. Same MAGIC / double-
// sha256 payload as chain-bch.js so the resulting sig verifies through
// Electron Cash and every other BCH tool.
async signMessage(message) {
if (!this._importId) throw new Error("this wallet has no signer registered");
if (!vaultImports || typeof vaultImports.signer !== "function") throw new Error("vault.imports.signer unavailable");
const signerBlob = await vaultImports.signer(this._importId);
let key;
try {
key = signerToKey(signerBlob, this._net);
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 sig = secp256k1.sign(digest, key.priv, { prehash: false, lowS: true, format: "recovered" });
const out = new Uint8Array(65);
out[0] = 27 + sig[0] + 4;
out.set(sig.subarray(1), 1);
return { address: this._address, signature: Buffer.from(out).toString("base64") };
} finally {
if (key && key.priv && key.priv.fill) { try { key.priv.fill(0); } catch {} }
}
}
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import (Deviant keystore or wherever you got the seed/WIF from)." }; }
dispose() { clearTimeout(this._pollTimer); try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {} }
}
return { ImportedBchWallet, IMPORTED_BCH_NETWORKS };
};