147 lines
6.1 KiB
JavaScript
147 lines
6.1 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, dispose) but
|
||
|
|
// does NOT go through vault.derive + HKDF: derivation is direct from the
|
||
|
|
// seed+path or WIF that the user imported.
|
||
|
|
//
|
||
|
|
// M.1a scope: read-only (balance + history over Electrum). planSend/send
|
||
|
|
// throw with a clear message until M.1b lands the sign path.
|
||
|
|
|
||
|
|
module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, electrum, WebSocket, tx }) {
|
||
|
|
|
||
|
|
// Same electrum scripthash convention chain-bch uses: sha256(script), byte-
|
||
|
|
// reversed, hex. P2PKH-only for imports today — that's what every entry in
|
||
|
|
// Deviant's keystore is.
|
||
|
|
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",
|
||
|
|
explorerTx: "https://blockchair.com/bitcoin-cash/transaction/",
|
||
|
|
explorerAddr: "https://blockchair.com/bitcoin-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",
|
||
|
|
explorerTx: "https://chipnet.imaginary.cash/tx/",
|
||
|
|
explorerAddr: "https://chipnet.imaginary.cash/address/",
|
||
|
|
defaultServers: [
|
||
|
|
"wss://chipnet.imaginary.cash:50004",
|
||
|
|
"wss://chipnet.bch.ninja:50004",
|
||
|
|
],
|
||
|
|
faucet: "https://tbch.googol.cash/",
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// Decode a cashaddr → 20-byte hash160 payload. We stored cashaddr at import
|
||
|
|
// time and use it here to compute the scripthash for Electrum without ever
|
||
|
|
// asking main for the signer material — that only happens at sign time.
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
class ImportedBchWallet {
|
||
|
|
constructor({ walletId, storage, log = () => {}, onChange = () => {}, network = "mainnet", cashaddr: address, servers } = {}) {
|
||
|
|
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;
|
||
|
|
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: [],
|
||
|
|
height: 0,
|
||
|
|
scanning: false,
|
||
|
|
error: null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
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() {
|
||
|
|
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,
|
||
|
|
scanning: this._state.scanning,
|
||
|
|
error: this._state.error,
|
||
|
|
server: this._client.url || null,
|
||
|
|
servers: this._servers,
|
||
|
|
imported: true,
|
||
|
|
explorerTx: this._net.explorerTx,
|
||
|
|
explorerAddr: this._net.explorerAddr,
|
||
|
|
faucet: this._net.faucet,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async refresh(full) {
|
||
|
|
this._state.scanning = true; this._emit();
|
||
|
|
try {
|
||
|
|
// Balance for this single scripthash.
|
||
|
|
const bal = await this._client.request("blockchain.scripthash.get_balance", [this._scripthash]);
|
||
|
|
this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) };
|
||
|
|
if (full) {
|
||
|
|
const hist = await this._client.request("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 }; }
|
||
|
|
|
||
|
|
plan() { throw new Error("Imported wallets are read-only in this build. Spending support ships in the next Aegis update."); }
|
||
|
|
signAndBroadcast() { throw new Error("Imported wallets are read-only in this build."); }
|
||
|
|
signMessage() { throw new Error("Imported wallets are read-only in this build."); }
|
||
|
|
|
||
|
|
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() { try { this._client.disconnect(); } catch {} }
|
||
|
|
}
|
||
|
|
|
||
|
|
return { ImportedBchWallet, IMPORTED_BCH_NETWORKS };
|
||
|
|
};
|