Start of the next batch; 0.30.0 is published.
- Tron panel sends signed whatever /wallet/createtransaction returned while
the approval showed the local request. The returned bytes are now decoded
and must be one transfer from this wallet, to that address, for that
amount, with a matching txID - checked at plan time and again at signing.
- Importing a Solana wallet from a seed phrase threw on every attempt (a
mis-parenthesised `new require("crypto").createHmac` plus a bare require
of an ESM-only subpath). SLIP-0010 now uses Node's HMAC, as chain-sol does.
- Imported BCH wallets put token-bearing UTXOs into coin selection. They are
excluded, as in the HD wallet, and the balance counts what can be spent.
- WizardConnect dropped the token from each spent output before signing, so
under SIGHASH_UTXOS every signature of a token transaction was invalid.
- A wallet disposed while a refresh was in flight re-armed its poll timer.
- BCMR registry content is bounded before it reaches the panel: control and
bidi characters stripped, lengths capped, decimals 0-18, icons https/ipfs
only, registries https only.
361 lines
17 KiB
JavaScript
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.disconnect(); } catch {} }
|
|
}
|
|
|
|
return { ImportedBchWallet, IMPORTED_BCH_NETWORKS };
|
|
};
|