theseus/bundled-addons/bchwallet/lib/chain-btc.js

429 lines
17 KiB
JavaScript
Raw Normal View History

feat(theseus/aegis): Bitcoin adapter (mainnet + testnet3, BIP84 native SegWit) Seven coins across twelve networks now — BTC joins the shipping roster. - lib/chain-btc.js: BIP84 native SegWit — m/84'/0'/0'/0/x → bc1q… (mainnet), m/84'/1'/0'/0/x → tb1q… (testnet3). Reuses the exact same stack the DGB adapter already pulls in: bitcoinjs-lib for network params + payments.p2wpkh + PSBT, bip32 for HD derivation, ecpair for the Signer interface, ecc (@bitcoinerlab/secp256k1) for message-sign recoverable sigs. No new npm deps. - Backend: same lib/electrum.js Aegis uses for BCH and DGB — plugged into a public Bitcoin ElectrumX pool (blockstream.info, lu.ke, grey.pw) for mainnet and aranguren.org / blockstream.info:993 for testnet3. Send flow: PSBT build + per-input signInput + finalizeAllInputs + broadcast. BIP-137 recoverable message signing. - Registered as btc:mainnet + btc:testnet in COINS with the orange Bitcoin disc SVG logo. Mount case mirrors DGB (accountPath honored, so switching to m/44'/0'/0' or m/49'/0'/0' via the setAccountPath message gives legacy 1… or wrapped-segwit 3… — same one-line UI plumb as the DGB address-family selector, deferred to a follow-up). - Panel: sat as the small-unit label, bitcoin: BIP21 QR payload, chain-aware send placeholder ("bc1q…" mainnet / "tb1q…" testnet). - Verified: BIP84 spec test vector — abandon×11 mnemonic derives bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu at m/84'/0'/0'/0/0 (byte-identical to the vector in the BIP text). Testnet variant produces tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl at m/84'/1'/0'/0/0 (cross-checkable on iancoleman.io/bip39 with coin BTC Testnet).
2026-09-07 21:15:45 +02:00
// Bitcoin (BTC) chain adapter — mainnet + testnet3. BIP84 native SegWit,
// bitcoinjs-lib for the tx/PSBT primitives, Aegis's own ElectrumX transport
// for the network side. Very close in shape to chain-dgb.js; the two could
// share a "bip84-electrum" helper later, but for now a distinct file keeps
// the chain-specific tuning (electrum pool, network object) visible.
//
// Address family: BIP84 only in this rev — bc1q… (mainnet) / tb1q… (testnet).
// BIP44 (1…) and BIP49 (3…) are trivially reachable by editing accountPath
// to m/44'/0'/0' or m/49'/0'/0' respectively; the PSBT layer already
// supports the resulting scripts because bitcoinjs-lib does. An explicit
// address-family picker like DGB's is a follow-up.
const NETWORKS = {
mainnet: {
id: "mainnet", label: "Mainnet",
hrp: "bc", coinType: 0,
defaultAccountPath: "m/84'/0'/0'",
explorerTx: "https://mempool.space/tx/",
explorerAddr: "https://mempool.space/address/",
defaultServers: [
"wss://electrum.blockstream.info:50004",
"wss://bitcoin.lu.ke:50004",
"wss://fulcrum.grey.pw:50004",
],
faucet: null,
},
testnet: {
id: "testnet", label: "Testnet3",
hrp: "tb", coinType: 1,
defaultAccountPath: "m/84'/1'/0'",
explorerTx: "https://mempool.space/testnet/tx/",
explorerAddr: "https://mempool.space/testnet/address/",
defaultServers: [
"wss://testnet.aranguren.org:51004",
"wss://blockstream.info:993",
],
faucet: "https://coinfaucet.eu/en/btc-testnet/",
},
};
module.exports = function makeBtcAdapter({
bitcoinjs, bip32Factory, ecpairFactory, ecc, sha256, electrum,
}) {
if (!bitcoinjs || !bip32Factory || !ecpairFactory || !ecc || !electrum) {
throw new Error("chain-btc: missing dep");
}
const { payments, Psbt, networks: bjsNetworks } = bitcoinjs;
const bip32 = bip32Factory(ecc);
const ECPair = ecpairFactory(ecc);
// Map our network id → bitcoinjs-lib Network object.
function bjsNetworkFor(id) {
if (id === "mainnet") return bjsNetworks.bitcoin;
if (id === "testnet") return bjsNetworks.testnet;
throw new Error("chain-btc: unknown network " + id);
}
const toHex = (b) => Buffer.from(b).toString("hex");
const scripthashOf = (scriptBuf) => Buffer.from(sha256(scriptBuf)).reverse().toString("hex");
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 WalletKeys {
constructor(root32, accountPath, bjsNetwork) {
this._accountPath = /^m(\/\d+'?)+$/.test(accountPath) ? accountPath : "m/84'/0'/0'";
this._network = bjsNetwork;
this._root = bip32.fromSeed(Buffer.from(root32), bjsNetwork);
this._account = this._root.derivePath(this._accountPath);
this._branch = [this._account.derive(0), this._account.derive(1)];
this._cache = new Map();
}
get xpub() { return this._account.neutered().toBase58(); }
get xprv() { return this._account.toBase58(); }
get accountPath() { return this._accountPath; }
entry(branch, index) {
const k = branch + "/" + index;
let e = this._cache.get(k);
if (!e) {
const node = this._branch[branch].derive(index);
const pubkey = Buffer.from(node.publicKey);
const p2wpkh = payments.p2wpkh({ pubkey, network: this._network });
const script = Buffer.from(p2wpkh.output);
e = {
branch, index, path: this._accountPath + "/" + branch + "/" + index,
publicKey: pubkey, script, scriptHex: script.toString("hex"),
scripthash: scripthashOf(script),
address: p2wpkh.address,
_node: node,
};
this._cache.set(k, e);
}
return e;
}
signerFor(entry) {
return ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: this._network });
}
wipe() {
for (const e of this._cache.values()) e._node = null;
this._cache.clear();
this._branch = null;
this._account = null;
this._root = null;
}
}
// Fee vsize model — same P2WPKH numbers as DGB (identical script shapes).
const OVERHEAD_VB = 10.5;
const P2WPKH_INPUT_VB = 68;
const P2WPKH_OUTPUT_VB = 31;
const feeVb = (nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * P2WPKH_INPUT_VB + nOut * P2WPKH_OUTPUT_VB) * feePerVb);
class BtcWallet {
constructor(root32, networkId, {
walletId, storage, log = () => {}, onChange = () => {}, servers,
accountPath,
} = {}) {
if (!walletId) throw new Error("chain-btc: walletId required");
const net = NETWORKS[networkId];
if (!net) throw new Error("chain-btc: unknown network " + networkId);
this.walletId = walletId;
this.chain = "btc";
this.network = net.id;
this._net = net;
this._bjsNet = bjsNetworkFor(net.id);
this.log = log;
this.onChange = onChange;
this.storage = scopedStorage(storage, `wallets/${walletId}/`);
this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice();
const wantPath = accountPath || net.defaultAccountPath;
this._keys = new WalletKeys(root32, wantPath, this._bjsNet);
this._root = new Uint8Array(root32);
this._client = new electrum.Client(this._servers);
this._client.onServer = () => this._emit();
this._state = {
used: new Set(),
watched: new Map(),
height: 0,
balance: { confirmed: 0, unconfirmed: 0 },
utxos: [],
history: [],
receiveIndex: 0,
scanning: false,
error: null,
};
this._refreshTimer = null;
this._subscribedHeaders = false;
this._client.onNotify = (method, params) => {
if (method === "blockchain.headers.subscribe") {
const h = params && params[0] && params[0].height;
if (h) { this._state.height = h; this._scheduleRefresh(1500); }
} else if (method === "blockchain.scripthash.subscribe") {
this._scheduleRefresh(800);
}
};
}
_emit() { try { this.onChange(); } catch {} }
async _historyOf(entry) {
const h = await this._client.call("blockchain.scripthash.get_history", [entry.scripthash]);
return Array.isArray(h) ? h : [];
}
async _scan() {
const cursor = Number(this.storage.get("receiveCursor", 0)) || 0;
const GAP = 20;
for (const branch of [0, 1]) {
let gap = 0, i = 0;
const minIndex = branch === 0 ? cursor + 1 : 0;
while (gap < GAP || i < minIndex + GAP) {
const batch = [];
for (let k = 0; k < 10; k++) batch.push(this._keys.entry(branch, i + k));
const results = await Promise.all(batch.map((e) => this._historyOf(e)));
for (let k = 0; k < batch.length; k++) {
const e = batch[k];
this._state.watched.set(e.scripthash, e);
if (results[k].length) { this._state.used.add(branch + "/" + e.index); gap = 0; } else gap++;
i++;
if (gap >= GAP && i >= minIndex + GAP) break;
}
}
}
let r = cursor;
while (this._state.used.has("0/" + r)) r++;
this._state.receiveIndex = r;
this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r));
}
async _subscribeAll() {
if (!this._subscribedHeaders) {
this._subscribedHeaders = true;
const tip = await this._client.subscribe("blockchain.headers.subscribe", []);
if (tip && tip.height) this._state.height = tip.height;
}
await Promise.all([...this._state.watched.values()].map((e) =>
this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {})));
}
async _loadUtxos() {
const lists = await Promise.all([...this._state.watched.values()].map(async (e) => {
const u = await this._client.call("blockchain.scripthash.listunspent", [e.scripthash]);
return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e }));
}));
this._state.utxos = lists.flat();
let confirmed = 0, unconfirmed = 0;
for (const u of this._state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; }
this._state.balance = { confirmed, unconfirmed };
}
async _loadHistory() {
const entries = [...this._state.watched.values()].filter((e) => this._state.used.has(e.branch + "/" + e.index));
const merged = new Map();
const lists = await Promise.all(entries.map((e) => this._historyOf(e)));
for (const list of lists) for (const h of list) {
const prev = merged.get(h.tx_hash);
if (!prev || (h.height > 0 && prev.height <= 0)) merged.set(h.tx_hash, { txid: h.tx_hash, height: h.height });
}
const ordered = [...merged.values()].sort((a, b) => {
const ha = a.height > 0 ? a.height : Infinity, hb = b.height > 0 ? b.height : Infinity;
return hb - ha;
}).slice(0, 25);
const ours = new Set([...this._state.watched.values()].map((e) => e.scriptHex));
const out = [];
for (const h of ordered) {
let received = 0, spent = 0;
try {
const t = await this._client.call("blockchain.transaction.get", [h.txid, true]);
for (const o of t.vout || []) {
const hex = o.scriptPubKey && o.scriptPubKey.hex;
if (hex && ours.has(hex)) received += Math.round(Number(o.value || 0) * 1e8);
}
for (const i of t.vin || []) {
if (!i.txid) continue;
try {
const p = await this._client.call("blockchain.transaction.get", [i.txid, true]);
const po = p.vout && p.vout[i.vout];
const hex = po && po.scriptPubKey && po.scriptPubKey.hex;
if (hex && ours.has(hex)) spent += Math.round(Number(po.value || 0) * 1e8);
} catch {}
}
out.push({
txid: h.txid, height: h.height, confirmations: t.confirmations || 0,
time: t.blocktime || t.time || 0,
delta: received - spent, fee: null, to: null,
status: (t.confirmations || 0) > 0 ? "confirmed" : "pending",
kind: "transfer",
});
} catch {
out.push({ txid: h.txid, height: h.height, confirmations: 0, time: 0, delta: 0, fee: null, to: null, status: "pending", kind: "transfer" });
}
}
this._state.history = out;
}
async refresh(full = false) {
if (this._state.scanning) return;
this._state.scanning = true; this._state.error = null; this._emit();
try {
if (full || !this._state.watched.size) await this._scan();
else {
let r = Number(this.storage.get("receiveCursor", 0)) || 0;
while (this._state.used.has("0/" + r)) r++;
this._state.receiveIndex = r;
this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r));
}
await this._loadUtxos();
await this._loadHistory();
await this._subscribeAll();
for (const u of this._state.utxos) this._state.used.add(u.entry.branch + "/" + u.entry.index);
let r = Number(this.storage.get("receiveCursor", 0)) || 0;
while (this._state.used.has("0/" + r)) r++;
this._state.receiveIndex = r;
} catch (e) {
this._state.error = e?.message || String(e);
this.log("refresh failed:", this._state.error);
} finally {
this._state.scanning = false;
this._emit();
}
}
_scheduleRefresh(ms = 800) {
clearTimeout(this._refreshTimer);
this._refreshTimer = setTimeout(() => this.refresh(false), ms);
}
current() { return this._keys.entry(0, this._state.receiveIndex); }
nextAddress() {
let r = this._state.receiveIndex + 1;
while (this._state.used.has("0/" + r)) r++;
this.storage.set("receiveCursor", r);
this._state.receiveIndex = r;
const e = this._keys.entry(0, r);
this._state.watched.set(e.scripthash, e);
this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {});
this._emit();
return this.current();
}
_changeEntry() {
let i = 0;
while (this._state.used.has("1/" + i)) i++;
return this._keys.entry(1, i);
}
setServers(list) {
this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice();
this._client.setServers(this._servers);
}
plan({ to, amount, feeRate = 5, sendMax = false }) {
const rate = Math.min(500, Math.max(1, Number(feeRate) || 5));
const dest = String(to || "");
try { bitcoinjs.address.toOutputScript(dest, this._bjsNet); }
catch (e) { throw new Error(`bad Bitcoin address: ${e?.message || dest}`); }
const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
const change = this._changeEntry();
if (sendMax) {
const chosen = spendable;
const sum = chosen.reduce((a, u) => a + u.value, 0);
const fee = feeVb(chosen.length, 1, rate);
if (sum <= fee) throw new Error("balance does not cover the fee");
return {
_chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change,
recipients: [{ to: dest, value: sum - fee }],
fee, feeRate: rate, change: 0,
total: sum,
};
}
const value = Math.round(Number(amount) || 0);
if (!(value > 0)) throw new Error("amount must be > 0");
let sum = 0; const chosen = [];
for (const u of spendable) {
chosen.push(u); sum += u.value;
const withChange = feeVb(chosen.length, 2, rate);
if (sum >= value + withChange) {
const changeVal = sum - value - withChange;
const fee = changeVal > 546 ? withChange : sum - value;
return {
_chosen: chosen, _rate: rate, _to: dest, _value: value,
_change: change, _changeVal: changeVal > 546 ? changeVal : 0,
recipients: [{ to: dest, value }],
fee, feeRate: rate,
change: changeVal > 546 ? changeVal : 0,
total: value + fee,
};
}
}
throw new Error("insufficient funds");
}
async signAndBroadcast(plan) {
const psbt = new Psbt({ network: this._bjsNet });
for (const u of plan._chosen) {
psbt.addInput({
hash: u.txid, index: u.vout,
witnessUtxo: { script: u.entry.script, value: u.value },
});
}
const outputs = [{ address: plan._to, value: plan._sendMax ? plan.recipients[0].value : plan._value }];
if (!plan._sendMax && plan._changeVal > 0) {
outputs.push({ address: plan._change.address, value: plan._changeVal });
}
for (const o of outputs) psbt.addOutput(o);
for (let i = 0; i < plan._chosen.length; i++) {
const signer = this._keys.signerFor(plan._chosen[i].entry);
psbt.signInput(i, signer);
}
psbt.finalizeAllInputs();
const tx = psbt.extractTransaction();
const hex = tx.toHex();
const txid = await this._client.call("blockchain.transaction.broadcast", [hex]);
if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid));
this.log("broadcast", txid);
this._scheduleRefresh(1200);
return { txid, hex, fee: plan.fee };
}
// BIP-137 recoverable over sha256d("Bitcoin Signed Message:\n" || msg).
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 signer = this._keys.signerFor(entry);
const sig = ecc.signRecoverable(Buffer.from(digest), signer.privateKey);
const out = Buffer.alloc(65);
out[0] = 27 + sig.recoveryId + 4; // +4 = compressed
Buffer.from(sig.signature).copy(out, 1);
return { address: entry.address, signature: out.toString("base64") };
}
recovery() {
return { accountPath: this._keys.accountPath, xpub: this._keys.xpub, xprv: this._keys.xprv };
}
snapshot() {
const cur = this.current();
return {
chain: "btc", network: this._net.id, ticker: "BTC", decimals: 8,
address: cur.address, addressIndex: this._state.receiveIndex,
addressPath: cur.path,
balance: this._state.balance,
height: this._state.height,
history: this._state.history,
scanning: this._state.scanning,
error: this._state.error,
server: this._client.url || null,
servers: this._servers,
accountPath: this._keys.accountPath,
xpub: this._keys.xpub,
explorerTx: this._net.explorerTx,
explorerAddr: this._net.explorerAddr,
faucet: this._net.faucet,
};
}
dispose() {
clearTimeout(this._refreshTimer);
try { this._keys.wipe(); } catch {}
try { this._client.disconnect(); } catch {}
if (this._root) this._root.fill(0);
}
}
return { BtcWallet, NETWORKS };
};