theseus/bundled-addons/aegis/lib/chain-utxo-imported.js
Local Dev 884f3948b8 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

140 lines
5.8 KiB
JavaScript

// 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." }; }
dispose() { clearTimeout(this._pollTimer); try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {} }
}
return { UtxoImportedWallet, NETWORKS };
};