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.call("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.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 }; }
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." }; }
Aegis: fees that follow the network, connections that come back, its own name on Solana Still 0.31.0 (unpublished batch). - ETH nonce. The pending count from a load-balanced RPC often misses a transaction this wallet sent seconds ago, so two sends in a row shared a nonce and the second failed or replaced the first. The nonce is taken at signing, from the RPC or from what the wallet itself last broadcast, whichever is higher, and broadcasts are serialised per wallet. - Chains without EIP-1559 (no baseFeePerGas) get a legacy EIP-155 transaction; they rejected the type-2 envelope, so a network added by a dapp could receive but never send. The tip is clamped to the fee cap. - Bitcoin and DigiByte take their fee rate from the Electrum server's estimate instead of a constant, size each output from its real script (a taproot destination was undercounted), and round the size up before pricing. Bitcoin inputs signal replace-by-fee. DigiByte keeps its 20 sat/vB floor and does not signal RBF, which it does not have. - Electrum: a wallet with live subscriptions went quiet for good when its server dropped. The client reconnects with backoff, pings to catch dead sockets, times out a silent connect, and hands the replayed subscription answers on as notifications so the wallet refreshes. dispose() ends it. - Max with an SPL token selected did nothing; it now fills the exact token balance. - Removing a wallet retired its derivation index for good. Add wallet now takes the lowest free index, so the same wallet comes back. - Solana: registered through the Wallet Standard as "Aegis" instead of setting isPhantom, with silent connect for already-connected sites. - "Only show the wallet to sites I enable": Aegis keeps the host's page-inject allow-list in step with enabled and connected sites.
2026-10-04 01:55:38 +02:00
dispose() { clearTimeout(this._pollTimer); try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {} }
}
return { UtxoImportedWallet, NETWORKS };
};