diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 8d1becf3..40815483 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -529,16 +529,23 @@ function migrateLegacyStorage(api) { api.log("migrated legacy BCH wallet into multi-wallet layout"); } +// The lowest index not in use. It used to be highest + 1, so removing a +// wallet retired its index for good: the keys were still derivable from the +// vault, but nothing in the UI could ever produce that wallet again, and +// whatever was on it was out of reach. Now "Add wallet" after a removal +// brings the same wallet (same keys, same address) back. function nextIndex(wallets, meta) { - let max = meta.startIndex - 1; + const used = new Set(); for (const w of wallets) { if (chainKey(w.chain, w.network) !== chainKey(meta.chain, meta.network)) continue; if (w.isLegacy) continue; const m = /\/(\d+)$/.exec(w.purpose || ""); const n = m ? Number(m[1]) : NaN; - if (Number.isFinite(n) && n > max) max = n; + if (Number.isFinite(n)) used.add(n); } - return max + 1; + let i = meta.startIndex; + while (used.has(i)) i++; + return i; } function makeWalletId(meta, index) { @@ -2487,6 +2494,44 @@ function registerPanelMessages(api) { }; }); + // ---- who can see the wallet -------------------------------------------- + const injectState = () => ({ + supported: !!(api.features && api.features.pageInjectPolicy), + mode: injectMode(api), + origins: injectAllowList(api), + }); + api.onMessage("injectPolicyGet", (_p, m) => { fromPanel(m); return injectState(); }); + api.onMessage("injectPolicySet", (p, m) => { + fromPanel(m); + if (!(api.features && api.features.pageInjectPolicy)) throw new Error("this Theseus cannot limit which sites see the wallet — update Theseus"); + api.storage.set(INJECT_MODE_KEY, p && p.mode === "allowed" ? "allowed" : "all"); + syncInjectPolicy(api); + return injectState(); + }); + // Enable one site. With no origin given it is the site in the active tab, + // which the host reports — the panel never has to type or guess a URL. + api.onMessage("injectAllowSite", async (p, m) => { + fromPanel(m); + let origin = normalizeOrigin(p && p.origin); + if (!origin && typeof api.scanActiveTabForUris === "function") { + try { origin = normalizeOrigin((await api.scanActiveTabForUris({ scheme: "wiz", limit: 1 })).origin); } catch {} + } + if (!origin) throw new Error("Open the site in a tab first — there is no web page in the active tab."); + const list = injectAllowList(api); + if (!list.includes(origin)) list.push(origin); + api.storage.set(INJECT_ALLOW_KEY, list); + syncInjectPolicy(api); + return { origin, ...injectState() }; + }); + api.onMessage("injectRemoveSite", (p, m) => { + fromPanel(m); + const origin = String(p && p.origin || ""); + api.storage.set(INJECT_ALLOW_KEY, injectAllowList(api).filter((o) => o !== origin)); + syncInjectPolicy(api); + return injectState(); + }); + try { syncInjectPolicy(api); } catch {} + api.onMessage("permissions", (_p, m) => { fromPanel(m); return grantsForPanel(api); }); api.onMessage("revoke", (p, m) => { fromPanel(m); @@ -2844,6 +2889,7 @@ function setGrant(api, origin, scope, value, persist) { const perms = permissions(api); perms[origin] = { ...(perms[origin] || {}), ...wrap }; api.storage.set("permissions", perms); + syncInjectPolicy(api); } else { sessionGrants.set(origin, { ...(sessionGrants.get(origin) || {}), ...wrap }); } @@ -2869,6 +2915,34 @@ function revokeOrigin(api, origin) { delete perms[origin]; api.storage.set("permissions", perms); sessionGrants.delete(origin); + syncInjectPolicy(api); +} + +// ---- who can see the wallet ------------------------------------------------ +// The bridges used to go into every https page, which tells every page that +// this browser carries a wallet (and which one) before the user has done +// anything. In "allowed" mode Theseus injects only into origins listed in +// the reserved storage key "__pageInjectPolicy" — it reads that key itself, +// at document start, even while Aegis has not been started yet. The list is +// the sites the user enabled plus every site with a saved connection, so +// turning the mode on never breaks a dapp already in use. +const INJECT_MODE_KEY = "aegis/inject/mode"; +const INJECT_ALLOW_KEY = "aegis/inject/allow"; +function injectAllowList(api) { + const v = api.storage.get(INJECT_ALLOW_KEY, []); + return Array.isArray(v) ? v.filter((o) => typeof o === "string") : []; +} +function injectMode(api) { return api.storage.get(INJECT_MODE_KEY, "all") === "allowed" ? "allowed" : "all"; } +function syncInjectPolicy(api) { + const origins = Array.from(new Set([...injectAllowList(api), ...Object.keys(permissions(api))])); + api.storage.set("__pageInjectPolicy", { mode: injectMode(api), origins }); +} +function normalizeOrigin(raw) { + try { + const u = new URL(String(raw || "").replace(/^bns:\/\//i, "https://")); + if (u.protocol !== "https:" && u.protocol !== "http:") return null; + return `${u.protocol}//${u.host}`; + } catch { return null; } } // Panel view: persisted grants plus session-only ones. An origin with no // persisted entry is flagged `session`; one with both gets the session @@ -3748,6 +3822,15 @@ function registerPageMessages(api) { throw new Error("no Solana wallet available — add one in the Aegis sidebar"); } function solConnectedFor(origin) { return !!grantFor(api, origin, "sol"); } + // Is this site already connected? Lets a Wallet Standard "silent" connect + // (what adapters do on page load to restore a session) succeed without a + // prompt for a connected site and stay quiet for everyone else. + api.onMessage("sol.state", (_p, m) => { + const origin = fromPage(m); + if (!solConnectedFor(origin)) return { address: null }; + try { const snap = activeSolRuntime().adapter.snapshot(); return { address: snap.address, network: snap.network }; } + catch { return { address: null }; } + }); api.onMessage("sol.connect", async (_p, m) => { const origin = fromPage(m); const rt = activeSolRuntime(); diff --git a/bundled-addons/aegis/lib/chain-bch-imported.js b/bundled-addons/aegis/lib/chain-bch-imported.js index a48cedc6..70865566 100644 --- a/bundled-addons/aegis/lib/chain-bch-imported.js +++ b/bundled-addons/aegis/lib/chain-bch-imported.js @@ -354,7 +354,7 @@ module.exports = function makeImportedBchAdapter({ 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 {} } + dispose() { clearTimeout(this._pollTimer); try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {} } } return { ImportedBchWallet, IMPORTED_BCH_NETWORKS }; diff --git a/bundled-addons/aegis/lib/chain-bch.js b/bundled-addons/aegis/lib/chain-bch.js index 99362024..6a0a0e55 100644 --- a/bundled-addons/aegis/lib/chain-bch.js +++ b/bundled-addons/aegis/lib/chain-bch.js @@ -155,7 +155,7 @@ module.exports = function makeBchAdapter({ dispose() { try { this._wallet.dispose(); } catch {} try { this._keys.wipe(); } catch {} - try { this._client.disconnect(); } catch {} + try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {} if (this._root) this._root.fill(0); } } diff --git a/bundled-addons/aegis/lib/chain-btc.js b/bundled-addons/aegis/lib/chain-btc.js index a21525bf..38bf3f42 100644 --- a/bundled-addons/aegis/lib/chain-btc.js +++ b/bundled-addons/aegis/lib/chain-btc.js @@ -184,6 +184,15 @@ module.exports = function makeBtcAdapter({ p2tr: 58, // 41 base + 66/4 witness (key-path) }; const feeVb = (kind, nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + nOut * OUTPUT_VB) * feePerVb); + // Same, with each output sized from its real script (8 value + 1 length + + // script). A flat 31 vB undercounts P2TR / P2WSH destinations (43 vB), so + // a send to a bc1p… address paid below the rate it asked for. + const feeFor = (kind, nIn, outScripts, feePerVb) => + // Round the size up before pricing it: a real transaction is a whole + // number of vbytes, and paying for 152.5 of 153 lands under the rate. + Math.ceil(Math.ceil(OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + outScripts.reduce((a, s) => a + 9 + s.length, 0)) * feePerVb); + const DEFAULT_RATE = 5; // sat/vB when the server has no estimate + const RBF_SEQUENCE = 0xfffffffd; // BIP125: opt in to replace-by-fee class BtcWallet { constructor(root32, networkId, { @@ -377,10 +386,32 @@ module.exports = function makeBtcAdapter({ this._client.setServers(this._servers); } - plan({ to, amount, feeRate = 5, sendMax = false }) { - const rate = Math.min(500, Math.max(1, Number(feeRate) || 5)); + // sat/vB for confirmation within ~6 blocks, asked of the Electrum server + // and remembered for a minute. The rate used to be a constant 5, which + // overpays on a quiet day and strands the payment on a busy one. + async _estimateRate() { + if (this._feeEst && Date.now() - this._feeEstAt < 60_000) return this._feeEst; + try { + const perKb = Number(await Promise.race([ + this._client.call("blockchain.estimatefee", [6]), + new Promise((_, rej) => setTimeout(() => rej(new Error("estimatefee timeout")), 4000)), + ])); + if (Number.isFinite(perKb) && perKb > 0) { // coin per kB; -1 = no estimate + this._feeEst = Math.min(500, Math.max(1, Math.ceil(perKb * 1e5))); + this._feeEstAt = Date.now(); + return this._feeEst; + } + } catch {} + return DEFAULT_RATE; + } + + async plan({ to, amount, feeRate, sendMax = false }) { + const rate = feeRate != null && Number(feeRate) > 0 + ? Math.min(500, Math.max(1, Number(feeRate))) + : await this._estimateRate(); const dest = String(to || ""); - try { bitcoinjs.address.toOutputScript(dest, this._bjsNet); } + let destScript; + try { destScript = bitcoinjs.address.toOutputScript(dest, this._bjsNet); } catch (e) { throw new Error(`bad Bitcoin address: ${e?.message || dest}`); } const cur = this.current(); if (!cur.sendKind) throw new Error(`no sender for ${cur.family} — registry bug`); @@ -390,7 +421,7 @@ module.exports = function makeBtcAdapter({ if (sendMax) { const chosen = spendable; const sum = chosen.reduce((a, u) => a + u.value, 0); - const fee = feeVb(kind, chosen.length, 1, rate); + const fee = feeFor(kind, chosen.length, [destScript], rate); if (sum <= fee) throw new Error("balance does not cover the fee"); return { _chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change, _kind: kind, @@ -404,7 +435,7 @@ module.exports = function makeBtcAdapter({ let sum = 0; const chosen = []; for (const u of spendable) { chosen.push(u); sum += u.value; - const withChange = feeVb(kind, chosen.length, 2, rate); + const withChange = feeFor(kind, chosen.length, [destScript, change.script], rate); if (sum >= value + withChange) { const changeVal = sum - value - withChange; const fee = changeVal > 546 ? withChange : sum - value; @@ -435,7 +466,9 @@ module.exports = function makeBtcAdapter({ } const psbt = new Psbt({ network: this._bjsNet }); for (const u of plan._chosen) { - const inp = { hash: u.txid, index: u.vout }; + // Signal replace-by-fee, so a payment sent at too low a rate can be + // re-issued at a higher one instead of sitting unconfirmed for days. + const inp = { hash: u.txid, index: u.vout, sequence: RBF_SEQUENCE }; const fam = u.entry.family; if (fam === "bip44") { inp.nonWitnessUtxo = Buffer.from(prevHex.get(u.txid), "hex"); @@ -522,7 +555,7 @@ module.exports = function makeBtcAdapter({ dispose() { clearTimeout(this._refreshTimer); try { this._keys.wipe(); } catch {} - try { this._client.disconnect(); } catch {} + try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {} if (this._root) this._root.fill(0); } } diff --git a/bundled-addons/aegis/lib/chain-dgb.js b/bundled-addons/aegis/lib/chain-dgb.js index aa6609bf..5a3d1621 100644 --- a/bundled-addons/aegis/lib/chain-dgb.js +++ b/bundled-addons/aegis/lib/chain-dgb.js @@ -153,6 +153,13 @@ module.exports = function makeDgbAdapter({ const OUTPUT_VB = 31; const INPUT_VB = { p2pkh: 148, "p2sh-p2wpkh": 91, p2wpkh: 68, p2tr: 58 }; const feeVb = (kind, nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + nOut * OUTPUT_VB) * feePerVb); + // Each output sized from its real script (8 value + 1 length + script); a + // flat 31 vB undercounts a dgb1p… destination. + const feeFor = (kind, nIn, outScripts, feePerVb) => + // Round the size up before pricing it: a real transaction is a whole + // number of vbytes, and paying for 152.5 of 153 lands under the rate. + Math.ceil(Math.ceil(OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + outScripts.reduce((a, s) => a + 9 + s.length, 0)) * feePerVb); + const DEFAULT_RATE = 20; // sat/vB floor, and the fallback function scopedStorage(storage, keyPrefix) { const k = (key) => keyPrefix + key; @@ -355,8 +362,29 @@ module.exports = function makeDgbAdapter({ } // ---- plan + sign (via @dgb-wallet/psbt) ----------------------------- - plan({ to, amount, feeRate = 20, sendMax = false }) { - const rate = Math.min(500, Math.max(1, Number(feeRate) || 20)); + // sat/vB from the Electrum server, never below the 20 this wallet has + // always paid (DigiByte's relay floor is far above Bitcoin's, and there + // is no replace-by-fee on DGB to rescue an underpaid transaction). + async _estimateRate() { + if (this._feeEst && Date.now() - this._feeEstAt < 60_000) return this._feeEst; + try { + const perKb = Number(await Promise.race([ + this._client.call("blockchain.estimatefee", [6]), + new Promise((_, rej) => setTimeout(() => rej(new Error("estimatefee timeout")), 4000)), + ])); + if (Number.isFinite(perKb) && perKb > 0) { // coin per kB; -1 = no estimate + this._feeEst = Math.min(500, Math.max(DEFAULT_RATE, Math.ceil(perKb * 1e5))); + this._feeEstAt = Date.now(); + return this._feeEst; + } + } catch {} + return DEFAULT_RATE; + } + + async plan({ to, amount, feeRate, sendMax = false }) { + const rate = feeRate != null && Number(feeRate) > 0 + ? Math.min(500, Math.max(1, Number(feeRate))) + : await this._estimateRate(); const dest = String(to || ""); // The `payments` decoder will reject anything that isn't a valid // DGB address; catch and re-raise as a sane error. @@ -367,12 +395,13 @@ module.exports = function makeDgbAdapter({ try { bitcoinjs.address.toOutputScript(dest, digibyte); } catch (e) { throw new Error(`bad DGB address: ${e?.message || dest}`); } } + const destScript = bitcoinjs.address.toOutputScript(dest, digibyte); const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); const change = this._changeEntry(); if (sendMax) { const chosen = spendable; const sum = chosen.reduce((a, u) => a + u.value, 0); - const fee = feeVb(change.send, chosen.length, 1, rate); + const fee = feeFor(change.send, chosen.length, [destScript], rate); if (sum <= fee) throw new Error("balance does not cover the fee"); return { _chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change, @@ -386,7 +415,7 @@ module.exports = function makeDgbAdapter({ let sum = 0; const chosen = []; for (const u of spendable) { chosen.push(u); sum += u.value; - const withChange = feeVb(change.send, chosen.length, 2, rate); + const withChange = feeFor(change.send, chosen.length, [destScript, change.script], rate); if (sum >= value + withChange) { const changeVal = sum - value - withChange; const fee = changeVal > 546 ? withChange : sum - value; @@ -502,7 +531,7 @@ module.exports = function makeDgbAdapter({ dispose() { clearTimeout(this._refreshTimer); try { this._keys.wipe(); } catch {} - try { this._client.disconnect(); } catch {} + try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {} if (this._root) this._root.fill(0); } } diff --git a/bundled-addons/aegis/lib/chain-eth.js b/bundled-addons/aegis/lib/chain-eth.js index 60cc3ea4..1bc0a78f 100644 --- a/bundled-addons/aegis/lib/chain-eth.js +++ b/bundled-addons/aegis/lib/chain-eth.js @@ -141,6 +141,19 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { const signedRlp = rlpEncodeList(signedFields); return "0x" + toHex(concat(Uint8Array.from([0x02]), signedRlp)); } + // Legacy (type 0) transaction with EIP-155 replay protection, for chains + // that never adopted EIP-1559 (no baseFeePerGas in their blocks). Such a + // chain rejects a type-2 envelope outright, so a network added through + // wallet_addEthereumChain could receive but never send. + // sign: keccak256(RLP([nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0])) + // signed: RLP([nonce, gasPrice, gasLimit, to, value, data, v, r, s]), v = recid + 35 + 2·chainId + function signTxLegacy({ chainId, nonce, gasPrice, gasLimit, to, value, data }, privKey) { + const base = [nonce, gasPrice, gasLimit, to, value, data]; + const hash = keccak_256(rlpEncodeList([...base, chainId, new Uint8Array(0), new Uint8Array(0)])); + const sig = secp256k1.sign(hash, privKey, { prehash: false, lowS: true, format: "recovered" }); + const v = BigInt(sig[0]) + 35n + 2n * BigInt(chainId); + return "0x" + toHex(rlpEncodeList([...base, v, stripLeadingZeros(sig.subarray(1, 33)), stripLeadingZeros(sig.subarray(33, 65))])); + } function stripLeadingZeros(bytes) { let i = 0; while (i < bytes.length - 1 && bytes[i] === 0) i++; @@ -321,20 +334,26 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { const dataHex = normalizeData(data); const dataBytes = fromHex(dataHex.slice(2)); const from = this.address.toLowerCase(); - const [nonceHex, priorityHex, gasPriceHex] = await Promise.all([ - nonce != null ? Promise.resolve(bigToHex(toBig(nonce))) : this._client.call("eth_getTransactionCount", [from, "pending"]), + const nonceFixed = nonce != null; // the dapp chose it (e.g. to replace a pending tx) + const [nonceN, legacy, priorityHex, gasPriceHex] = await Promise.all([ + nonceFixed ? Promise.resolve(Number(toBig(nonce))) : this._nextNonce(), + this._isLegacyChain(), this._client.call("eth_maxPriorityFeePerGas", []).catch(() => "0x59682f00"), // fallback: 1.5 gwei this._client.call("eth_gasPrice", []), ]); - const nonceN = Number(hexToBig(nonceHex)); - const tip = maxPriorityFeePerGas != null ? toBig(maxPriorityFeePerGas) : hexToBig(priorityHex); // eth_gasPrice on a 1559 chain already includes a tip, so it is the // best single-number estimate of what a block will actually charge. const gasPriceNow = hexToBig(gasPriceHex); // Cap: dapp-supplied maxFeePerGas (or legacy gasPrice) wins; otherwise - // 2 × current price + tip so the tx survives a base-fee spike. - const maxFee = maxFeePerGas != null ? toBig(maxFeePerGas) - : (gasPrice != null ? toBig(gasPrice) : gasPriceNow * 2n + tip); + // 2 × current price + tip so the tx survives a base-fee spike. A chain + // without EIP-1559 has one price and no spike to survive: 10% headroom. + let tip = maxPriorityFeePerGas != null ? toBig(maxPriorityFeePerGas) : hexToBig(priorityHex); + const maxFee = legacy + ? (gasPrice != null ? toBig(gasPrice) : (maxFeePerGas != null ? toBig(maxFeePerGas) : (gasPriceNow * 11n) / 10n)) + : (maxFeePerGas != null ? toBig(maxFeePerGas) : (gasPrice != null ? toBig(gasPrice) : gasPriceNow * 2n + tip)); + // A tip above the cap is invalid ("max priority fee higher than max + // fee"). It happened whenever a dapp sent only a low gasPrice. + if (tip > maxFee) tip = maxFee; const bal = BigInt(this._state.balance.confirmed || "0"); let value = sendMax ? 0n : toBig(amount); if (value < 0n) throw new Error("amount must be >= 0 wei"); @@ -364,7 +383,9 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { _draft: { chainId: this._net.chainId, nonce: nonceN, maxPriorityFeePerGas: tip, maxFeePerGas: maxFee, gasLimit: gas, to: dest, value, data: dataHex, accessList: [], + legacy, nonceFixed, }, + txType: legacy ? 0 : 2, recipients: [{ to: dest, value: value.toString() }], fee: feeMax.toString(), // worst case — what the balance check uses feeEstimate: feeEstimate.toString(), // what a block will most likely charge @@ -377,19 +398,55 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { }; } + // The next nonce to use. "pending" from a load-balanced public RPC often + // does not know about a transaction this wallet broadcast seconds ago + // (it went to a different backend), so two sends in a row got the same + // nonce and the second either failed or silently replaced the first. + // What this wallet itself broadcast in the last few minutes counts too. + async _nextNonce() { + const rpc = Number(hexToBig(await this._client.call("eth_getTransactionCount", [this.address.toLowerCase(), "pending"]))); + const recent = this._lastNonce != null && (Date.now() - this._lastNonceAt) < 180_000; + return recent ? Math.max(rpc, this._lastNonce + 1) : rpc; + } + // Does this chain lack EIP-1559? Decided from the latest block and + // remembered for ten minutes. Unknown (RPC error, no block) keeps the + // type-2 default that Ethereum and every major L2 accept. + async _isLegacyChain() { + if (this._legacyAt && Date.now() - this._legacyAt < 600_000) return this._legacy; + let legacy = false; + try { + const block = await this._client.call("eth_getBlockByNumber", ["latest", false]); + if (block && typeof block === "object") legacy = block.baseFeePerGas == null; + } catch { return false; } + this._legacy = legacy; this._legacyAt = Date.now(); + return legacy; + } + async signAndBroadcast(plan) { const d = plan && plan._draft; if (!d) throw new Error("bad plan"); - const unsignedFields = [ - d.chainId, d.nonce, d.maxPriorityFeePerGas, d.maxFeePerGas, d.gasLimit, - fromHex(d.to.slice(2)), d.value, fromHex(normalizeData(d.data).slice(2)), [], - ]; - const rawTxHex = signTxEip1559(unsignedFields, this._priv); - const txid = await this._client.call("eth_sendRawTransaction", [rawTxHex]); - if (typeof txid !== "string" || !/^0x[0-9a-f]{64}$/i.test(txid)) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); - this.log("broadcast", txid); - setTimeout(() => this.refresh(), 3000); - return { txid }; + // One broadcast at a time per wallet, and the nonce is taken at the + // moment of signing: two approvals answered close together (or a plan + // drawn before an earlier send went out) must not share one. + const run = async () => { + const nonce = d.nonceFixed ? d.nonce : Math.max(d.nonce, await this._nextNonce()); + const to = fromHex(d.to.slice(2)); + const data = fromHex(normalizeData(d.data).slice(2)); + const rawTxHex = d.legacy + ? signTxLegacy({ chainId: d.chainId, nonce, gasPrice: d.maxFeePerGas, gasLimit: d.gasLimit, to, value: d.value, data }, this._priv) + : signTxEip1559([d.chainId, nonce, d.maxPriorityFeePerGas, d.maxFeePerGas, d.gasLimit, to, d.value, data, []], this._priv); + const txid = await this._client.call("eth_sendRawTransaction", [rawTxHex]); + if (typeof txid !== "string" || !/^0x[0-9a-f]{64}$/i.test(txid)) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); + if (this._lastNonce == null || nonce >= this._lastNonce || Date.now() - this._lastNonceAt >= 180_000) { + this._lastNonce = nonce; this._lastNonceAt = Date.now(); + } + this.log("broadcast", txid); + setTimeout(() => { if (!this._disposed) this.refresh(); }, 3000); + return { txid, nonce }; + }; + const next = (this._sendChain || Promise.resolve()).then(run, run); + this._sendChain = next.catch(() => {}); + return next; } // EIP-712: sign a pre-computed typed-data digest with r||s||v (v = 27+recid). diff --git a/bundled-addons/aegis/lib/chain-utxo-imported.js b/bundled-addons/aegis/lib/chain-utxo-imported.js index bde67708..f9e45248 100644 --- a/bundled-addons/aegis/lib/chain-utxo-imported.js +++ b/bundled-addons/aegis/lib/chain-utxo-imported.js @@ -133,7 +133,7 @@ module.exports = function makeUtxoImportedAdapter({ sha256, bitcoinjs, dgbCore, 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 {} } + dispose() { clearTimeout(this._pollTimer); try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {} } } return { UtxoImportedWallet, NETWORKS }; diff --git a/bundled-addons/aegis/lib/electrum.js b/bundled-addons/aegis/lib/electrum.js index 1cba3281..e5e3c6d5 100644 --- a/bundled-addons/aegis/lib/electrum.js +++ b/bundled-addons/aegis/lib/electrum.js @@ -5,6 +5,10 @@ // fan out to `onNotify`. module.exports = function makeElectrum({ WebSocket, log = () => {} }) { const CALL_TIMEOUT_MS = 20000; + const CONNECT_TIMEOUT_MS = 10000; + const PING_EVERY_MS = 60000; + const RECONNECT_MIN_MS = 2000; + const RECONNECT_MAX_MS = 60000; class Connection { constructor(url) { @@ -21,6 +25,11 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) { const ws = new WebSocket(this.url); this.ws = ws; const fail = (e) => { if (!this.closed) { this.closed = true; reject(e instanceof Error ? e : new Error("electrum ws error: " + this.url)); } }; + // A server that accepts the TCP connection and then says nothing + // used to hold the whole server walk hostage; give up and move on. + const connectTimer = setTimeout(() => { fail(new Error("electrum connect timeout: " + this.url)); try { ws.close(); } catch {} }, CONNECT_TIMEOUT_MS); + ws.on("open", () => clearTimeout(connectTimer)); + ws.on("close", () => clearTimeout(connectTimer)); ws.on("open", async () => { // A RANGE, not a flat "1.4". Fulcrum only attaches `token_data` to // listunspent results once protocol >= 1.5 is negotiated, and with @@ -93,10 +102,52 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) { this.subscriptions = new Map(); // method+key -> params (replayed on reconnect) this.onNotify = null; this.onServer = null; // (url|null) connection state for the UI + this.disposed = false; + this._reconnectTimer = null; + this._reconnectDelay = RECONNECT_MIN_MS; + this._pingTimer = null; } setServers(servers) { this.servers = servers.slice(); this.disconnect(); + this._scheduleReconnect(250); // keep the live feed going on the new list + } + // A wallet with live subscriptions used to go quiet for good when its + // server dropped: the connection was only re-opened by the next call(), + // and nothing calls while the user is just looking at a balance. Come + // back on our own, with backoff, for as long as anything is subscribed. + _scheduleReconnect(delay) { + if (this.disposed || !this.subscriptions.size || this._reconnectTimer) return; + const wait = delay != null ? delay : this._reconnectDelay; + this._reconnectTimer = setTimeout(() => { + this._reconnectTimer = null; + if (this.disposed || (this.conn && !this.conn.closed)) return; + this._ensure().then( + () => { this._reconnectDelay = RECONNECT_MIN_MS; }, + () => { this._reconnectDelay = Math.min(RECONNECT_MAX_MS, this._reconnectDelay * 2); this._scheduleReconnect(); }, + ); + }, wait); + if (this._reconnectTimer.unref) this._reconnectTimer.unref(); + } + // A half-dead socket (laptop slept, NAT dropped the flow) never fires + // "close". Ping it; a ping that fails or times out closes it, which + // lands in the reconnect path above. + _armPing(c) { + clearInterval(this._pingTimer); + this._pingTimer = setInterval(() => { + if (this.disposed || this.conn !== c || c.closed) { clearInterval(this._pingTimer); return; } + c.call("server.ping", []).catch(() => { try { c.close(); } catch {} }); + }, PING_EVERY_MS); + if (this._pingTimer.unref) this._pingTimer.unref(); + } + // Stop for good: no reconnects, no pings, and any later call() fails + // instead of quietly resurrecting the connection of a removed wallet. + dispose() { + this.disposed = true; + clearTimeout(this._reconnectTimer); this._reconnectTimer = null; + clearInterval(this._pingTimer); this._pingTimer = null; + this.subscriptions.clear(); + this.disconnect(); } get url() { return this.conn && !this.conn.closed ? this.conn.url : null; } // The protocol the live connection settled on. Callers use it to decide @@ -113,6 +164,7 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) { return major > 1 || (major === 1 && minor >= 5); } async _ensure() { + if (this.disposed) throw new Error("electrum client disposed"); if (this.conn && !this.conn.closed) return this.conn; if (this.connecting) return this.connecting; this.connecting = (async () => { @@ -120,15 +172,30 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) { for (const url of this.servers) { try { const c = await new Connection(url).connect(); + if (this.disposed) { c.close(); throw new Error("electrum client disposed"); } c.onNotify = (m, p) => { if (this.onNotify) this.onNotify(m, p); }; - c.onClose = () => { if (this.conn === c) { this.conn = null; if (this.onServer) this.onServer(null); } }; + c.onClose = () => { + if (this.conn !== c) return; + this.conn = null; + if (this.onServer) this.onServer(null); + this._scheduleReconnect(); + }; this.conn = c; log("connected", url); if (this.onServer) this.onServer(url); - // Re-arm subscriptions so a reconnect keeps the live feed. - for (const params of this.subscriptions.values()) c.call(params[0], params[1]).catch(() => {}); + this._armPing(c); + // Re-arm subscriptions so a reconnect keeps the live feed, and + // hand each answer on as if it were a notification: whatever + // changed while we were away (a new tip, a payment) is in that + // answer, and the wallet only refreshes when it is told. + for (const [method, params] of this.subscriptions.values()) { + c.call(method, params).then((result) => { + if (!this.onNotify || this.conn !== c) return; + this.onNotify(method, method === "blockchain.headers.subscribe" ? [result] : [...params, result]); + }, () => {}); + } return c; - } catch (e) { lastErr = e; log("failed", url, e?.message); } + } catch (e) { lastErr = e; log("failed", url, e?.message); if (this.disposed) break; } } throw lastErr || new Error("no electrum server reachable"); })(); @@ -146,6 +213,7 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) { } clearSubscriptions() { this.subscriptions.clear(); } disconnect() { + clearInterval(this._pingTimer); this._pingTimer = null; if (this.conn) { const c = this.conn; this.conn = null; c.close(); } if (this.onServer) this.onServer(null); } diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html index 117fb6bf..fe030cb5 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -1330,6 +1330,21 @@