theseus/bundled-addons/aegis/lib/chain-utxo-imported.js

141 lines
5.8 KiB
JavaScript
Raw Normal View History

// Generic single-address read-only imported adapter for UTXO chains
// (BTC + DGB). Uses electrum for balance + history, bitcoinjs to convert
// the address back into a locking script for the scripthash.
//
// M.1b will add spending; for now these wallets show as read-only,
// matching chain-bch-imported.js's stance.
module.exports = function makeUtxoImportedAdapter({ sha256, bitcoinjs, dgbCore, electrum, WebSocket }) {
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const scripthashOf = (script) => toHex(sha256(script).slice().reverse());
const NETWORKS = {
btc: {
ticker: "BTC", decimals: 8,
networks: {
mainnet: {
id: "mainnet", label: "Mainnet",
bitcoinjsNet: bitcoinjs.networks.bitcoin,
servers: ["wss://electrum.blockstream.info:50004", "wss://fulcrum.sethforprivacy.com:50002"],
explorerAddr: "https://mempool.space/address/", explorerTx: "https://mempool.space/tx/",
},
testnet3: {
id: "testnet3", label: "Testnet3",
bitcoinjsNet: bitcoinjs.networks.testnet,
servers: ["wss://electrumx.tomasi.name:50004"],
explorerAddr: "https://mempool.space/testnet/address/", explorerTx: "https://mempool.space/testnet/tx/",
testnet: true, faucet: "https://coinfaucet.eu/en/btc-testnet/",
},
signet: {
id: "signet", label: "Signet",
bitcoinjsNet: bitcoinjs.networks.testnet,
servers: ["wss://signet-electrumx.wakiyamap.dev:50004"],
explorerAddr: "https://mempool.space/signet/address/", explorerTx: "https://mempool.space/signet/tx/",
testnet: true, faucet: "https://signet.bc-2.jp/",
},
},
},
dgb: {
ticker: "DGB", decimals: 8,
networks: {
mainnet: {
id: "mainnet", label: "Mainnet",
bitcoinjsNet: dgbCore ? dgbCore.digibyte : null,
servers: ["wss://electrum1.cipig.net:20063", "wss://electrum2.cipig.net:20063"],
explorerAddr: "https://chainz.cryptoid.info/dgb/address.dws?", explorerTx: "https://chainz.cryptoid.info/dgb/tx.dws?",
},
},
},
};
class UtxoImportedWallet {
constructor({ chain, network, address, log = () => {}, onChange = () => {} } = {}) {
const cfg = NETWORKS[chain]; if (!cfg) throw new Error(`chain-utxo-imported: unknown chain ${chain}`);
const net = cfg.networks[network]; if (!net) throw new Error(`chain-utxo-imported: ${chain} has no network ${network}`);
if (!address) throw new Error("address required");
if (!net.bitcoinjsNet) throw new Error(`chain-utxo-imported: ${chain}/${network} missing bitcoinjs network params`);
this.chain = chain;
this.network = network;
this._cfg = cfg;
this._net = net;
this.log = log;
this.onChange = onChange;
this._address = address;
try {
this._script = bitcoinjs.address.toOutputScript(address, net.bitcoinjsNet);
} catch (e) {
throw new Error(`invalid ${chain} address for ${network}: ${e?.message || e}`);
}
this._scripthash = scripthashOf(this._script);
this._client = new electrum.Client(net.servers.slice());
this._client.onServer = () => this._emit();
this._state = {
balance: { confirmed: 0, unconfirmed: 0 },
history: [],
height: 0,
scanning: false,
error: null,
};
}
setServers(list) { this._client.setServers(list && list.length ? list : this._net.servers.slice()); }
schedulePoll(ms) {
clearTimeout(this._pollTimer);
this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms);
}
_emit() { try { this.onChange(); } catch {} }
snapshot() {
return {
chain: this.chain, network: this.network,
ticker: this._cfg.ticker, decimals: this._cfg.decimals,
address: this._address,
addressIndex: 0,
addressPath: null,
balance: this._state.balance,
history: this._state.history,
height: this._state.height,
scanning: this._state.scanning,
error: this._state.error,
server: this._client.url || null,
imported: true,
explorerAddr: this._net.explorerAddr,
explorerTx: this._net.explorerTx,
faucet: this._net.faucet || null,
};
}
async refresh(full) {
this._state.scanning = true; this._emit();
try {
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 }; }
plan() { throw new Error(`Imported ${this.chain.toUpperCase()} wallets are read-only in this build.`); }
signAndBroadcast() { throw new Error("read-only"); }
signMessage() { throw new Error("read-only"); }
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import." }; }
dispose() { clearTimeout(this._pollTimer); try { this._client.disconnect(); } catch {} }
}
return { UtxoImportedWallet, NETWORKS };
};