From 884f3948b87a9750b99e1ad401af2bc336140f5e Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 01:55:38 +0200 Subject: [PATCH] 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. --- bundled-addons/aegis/index.js | 89 ++++++++++++++- .../aegis/lib/chain-bch-imported.js | 2 +- bundled-addons/aegis/lib/chain-bch.js | 2 +- bundled-addons/aegis/lib/chain-btc.js | 47 ++++++-- bundled-addons/aegis/lib/chain-dgb.js | 39 ++++++- bundled-addons/aegis/lib/chain-eth.js | 91 ++++++++++++--- .../aegis/lib/chain-utxo-imported.js | 2 +- bundled-addons/aegis/lib/electrum.js | 76 ++++++++++++- bundled-addons/aegis/panel.html | 15 +++ bundled-addons/aegis/panel.js | 52 +++++++++ bundled-addons/aegis/wallet-inject.js | 104 +++++++++++++++++- 11 files changed, 479 insertions(+), 40 deletions(-) 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 @@
Sites allowed to see your address, allowances for silent BCH payments, and Tron dapps you've connected. Message signing always asks.
+
+
Who can see the wallet
+
A site can tell a wallet is installed before you connect to it. Limit that to the sites you choose and every other page sees a browser with no wallet at all.
+ + + +