Setup 5d15508bba929f1f074c052ac933863eadf6eb8e56984ebd5a1af75e80626643 Portable a5d346b97f5a13d85fa3bd301a72075ddb82fe636d7b1a51840ffd5a16d879f4 Bundled since 0.3.27: 32d4b75 - Aegis (bchwallet) gains its own update card in Settings > General beside Ariadne. Check for updates hits the same signed OTA endpoint the boot timer uses; Restart to apply appears when a signed newer version is staged. Uses the existing addons-check-updates + a new app-restart IPC. New Aegis versions ship without a Theseus release. 32d4b75 (same commit) - DevTools (F12 / Ctrl+Shift+I) opens docked to the right of the tab (mode: 'right') instead of a detached window. Matches stock Chrome. Users who prefer detached can drag out via the DevTools own toolbar. b71c925 - Search-engine favicons in Settings > Search now use Google's /s2/favicons service — DuckDuckGo's ip3 source returned 404 for enough hosts (Brave, Bing, Yandex, etc.) that half the list was falling through to the emoji placeholder. Deployed. Verified LIVE 0.3.28.
531 lines
23 KiB
JavaScript
531 lines
23 KiB
JavaScript
// 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/",
|
|
},
|
|
signet: {
|
|
// Signet (BIP-325) shares testnet's address format and SLIP-44 coin
|
|
// type (1), so bitcoinjs-lib's `networks.testnet` handles address
|
|
// derivation unchanged. The chain itself is a separate, permissioned
|
|
// testnet with its own genesis + signer-signed blocks; from a wallet's
|
|
// point of view, the only differences are the electrum pool serving
|
|
// it and the explorer URL for tx lookups.
|
|
id: "signet", label: "Signet",
|
|
hrp: "tb", coinType: 1,
|
|
defaultAccountPath: "m/84'/1'/0'",
|
|
explorerTx: "https://mempool.space/signet/tx/",
|
|
explorerAddr: "https://mempool.space/signet/address/",
|
|
defaultServers: [
|
|
"wss://signet.aranguren.org:51102",
|
|
"wss://signet-electrumx.wakiyamap.dev:50003",
|
|
],
|
|
faucet: "https://signetfaucet.com/",
|
|
},
|
|
};
|
|
|
|
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);
|
|
// Taproot (p2tr) address derivation needs bitcoinjs-lib's schnorr backend
|
|
// wired to a curve implementation — @bitcoinerlab/secp256k1 provides both
|
|
// ECDSA and schnorr, so initEccLib once at load makes p2tr resolve.
|
|
try { bitcoinjs.initEccLib && bitcoinjs.initEccLib(ecc); } catch {}
|
|
|
|
// Map our network id → bitcoinjs-lib Network object. Signet shares
|
|
// testnet's address prefixes + magic (BIP-325 defines only new consensus
|
|
// rules; the p2p / address layer stays testnet-compatible).
|
|
function bjsNetworkFor(id) {
|
|
if (id === "mainnet") return bjsNetworks.bitcoin;
|
|
if (id === "testnet" || id === "signet") 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),
|
|
};
|
|
}
|
|
|
|
// Address-family shape from the derivation-path purpose. Every field the
|
|
// PSBT layer might need for signing an input funded by this family is
|
|
// captured here so signAndBroadcast has one code path per family.
|
|
function paymentFor(purpose, node, network) {
|
|
const pubkey = Buffer.from(node.publicKey);
|
|
if (purpose === 44) {
|
|
// Legacy P2PKH. Signing needs the full previous transaction
|
|
// (nonWitnessUtxo) — one extra electrum call per input at send time.
|
|
const p = payments.p2pkh({ pubkey, network });
|
|
return { family: "bip44", address: p.address, output: Buffer.from(p.output), send: "p2pkh", needsPrevTx: true };
|
|
}
|
|
if (purpose === 49) {
|
|
// P2SH-wrapped SegWit. PSBT needs the redeem script (the inner p2wpkh
|
|
// output) alongside the witnessUtxo.
|
|
const redeem = payments.p2wpkh({ pubkey, network });
|
|
const p = payments.p2sh({ redeem, network });
|
|
return { family: "bip49", address: p.address, output: Buffer.from(p.output), redeem: Buffer.from(redeem.output), send: "p2sh-p2wpkh" };
|
|
}
|
|
if (purpose === 86) {
|
|
// BIP86 Taproot key-path. Signing goes through a tap-tweaked ECPair
|
|
// (see signerFor + signAndBroadcast); the internal 32-byte x-only
|
|
// pubkey is captured here so the PSBT input can carry it.
|
|
const internalPubkey = Buffer.from(pubkey.subarray(1, 33));
|
|
const p = payments.p2tr({ internalPubkey, network });
|
|
return { family: "bip86", address: p.address, output: Buffer.from(p.output), internalPubkey, send: "p2tr" };
|
|
}
|
|
// Default: BIP84 native SegWit.
|
|
const p = payments.p2wpkh({ pubkey, network });
|
|
return { family: "bip84", address: p.address, output: Buffer.from(p.output), send: "p2wpkh" };
|
|
}
|
|
function purposeOfPath(accountPath) {
|
|
const m = /^m\/(\d+)'\//.exec(String(accountPath || ""));
|
|
return m ? Number(m[1]) : 84;
|
|
}
|
|
|
|
class WalletKeys {
|
|
constructor(root32, accountPath, bjsNetwork) {
|
|
this._accountPath = /^m(\/\d+'?)+$/.test(accountPath) ? accountPath : "m/84'/0'/0'";
|
|
this._purpose = purposeOfPath(this._accountPath);
|
|
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; }
|
|
get purpose() { return this._purpose; }
|
|
entry(branch, index) {
|
|
const k = branch + "/" + index;
|
|
let e = this._cache.get(k);
|
|
if (!e) {
|
|
const node = this._branch[branch].derive(index);
|
|
const pay = paymentFor(this._purpose, node, this._network);
|
|
e = {
|
|
branch, index, path: this._accountPath + "/" + branch + "/" + index,
|
|
publicKey: Buffer.from(node.publicKey),
|
|
family: pay.family, sendKind: pay.send,
|
|
script: pay.output, scriptHex: pay.output.toString("hex"),
|
|
scripthash: scripthashOf(pay.output),
|
|
address: pay.address,
|
|
redeemScript: pay.redeem || null,
|
|
tapInternalKey: pay.internalPubkey || null,
|
|
_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 per family. Values are rounded vsize contributions from
|
|
// standard tx-size tables; the estimator is pessimistic enough to cover a
|
|
// real broadcast without underpaying.
|
|
const OVERHEAD_VB = 10.5;
|
|
const OUTPUT_VB = 31; // P2WPKH / P2SH / P2PKH outputs are all ~31 vB give or take
|
|
const INPUT_VB = {
|
|
p2pkh: 148, // (32+4)+1+107+4 legacy input
|
|
"p2sh-p2wpkh": 91, // 40 base + ~205/4 witness
|
|
p2wpkh: 68, // 41 base + 108/4 witness
|
|
p2tr: 58, // 41 base + 66/4 witness (key-path)
|
|
};
|
|
const feeVb = (kind, nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + nOut * 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 cur = this.current();
|
|
if (!cur.sendKind) throw new Error(`no sender for ${cur.family} — registry bug`);
|
|
const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
|
|
const change = this._changeEntry();
|
|
const kind = cur.sendKind;
|
|
if (sendMax) {
|
|
const chosen = spendable;
|
|
const sum = chosen.reduce((a, u) => a + u.value, 0);
|
|
const fee = feeVb(kind, 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, _kind: kind,
|
|
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(kind, 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, _kind: kind,
|
|
recipients: [{ to: dest, value }],
|
|
fee, feeRate: rate,
|
|
change: changeVal > 546 ? changeVal : 0,
|
|
total: value + fee,
|
|
};
|
|
}
|
|
}
|
|
throw new Error("insufficient funds");
|
|
}
|
|
|
|
async signAndBroadcast(plan) {
|
|
// BIP44 inputs need the whole previous transaction (nonWitnessUtxo)
|
|
// so PSBT can compute a legacy sighash; fetch each one in parallel
|
|
// before assembling the PSBT.
|
|
const needsPrev = plan._chosen.filter((u) => u.entry.family === "bip44");
|
|
const prevHex = new Map();
|
|
if (needsPrev.length) {
|
|
const results = await Promise.all(needsPrev.map((u) =>
|
|
this._client.call("blockchain.transaction.get", [u.txid, false])
|
|
));
|
|
needsPrev.forEach((u, i) => prevHex.set(u.txid, String(results[i])));
|
|
}
|
|
const psbt = new Psbt({ network: this._bjsNet });
|
|
for (const u of plan._chosen) {
|
|
const inp = { hash: u.txid, index: u.vout };
|
|
const fam = u.entry.family;
|
|
if (fam === "bip44") {
|
|
inp.nonWitnessUtxo = Buffer.from(prevHex.get(u.txid), "hex");
|
|
} else {
|
|
inp.witnessUtxo = { script: u.entry.script, value: u.value };
|
|
if (fam === "bip49" && u.entry.redeemScript) inp.redeemScript = u.entry.redeemScript;
|
|
if (fam === "bip86" && u.entry.tapInternalKey) inp.tapInternalKey = u.entry.tapInternalKey;
|
|
}
|
|
psbt.addInput(inp);
|
|
}
|
|
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 entry = plan._chosen[i].entry;
|
|
// Taproot key-path: bitcoinjs-lib matches the signer's publicKey
|
|
// against the tweaked output key, so the signer has to be the
|
|
// internal ECPair tweaked with sha256("TapTweak" || internalPubkey).
|
|
// ECPair.tweak() from the ecpair package does exactly that (its
|
|
// internal state becomes the tap-tweaked keypair) and its
|
|
// signSchnorr is what PSBT calls for a key-path spend.
|
|
if (entry.family === "bip86") {
|
|
const raw = ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: this._bjsNet });
|
|
const tweak = bitcoinjs.crypto.taggedHash("TapTweak", entry.tapInternalKey);
|
|
const tweaked = raw.tweak(tweak);
|
|
psbt.signInput(i, tweaked);
|
|
} else {
|
|
psbt.signInput(i, this._keys.signerFor(entry));
|
|
}
|
|
}
|
|
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 };
|
|
};
|