From 5c2f0b78fcfbf933353bdd50b98594ebf73ac34c Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 7 Sep 2026 22:26:27 +0200 Subject: [PATCH] feat(theseus/aegis): EIP-3085 wallet_addEthereumChain + EIP-3326 switchChain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aegis now handles the standard MetaMask try-switch-then-add flow. A dapp that wants to route through Polygon (or Base, or Arbitrum, or any other EVM the Silent Mode user hasn't added yet) calls the pair the industry already wrote for it — Aegis registers the chain, provisions a wallet on it under the same vault seed, auto-connects the origin, fires chainChanged, and hands the dapp back a provider pointed at the new chain. No sidebar detour, no Custom RPC copy-paste. Users still see every chain in the picker post-add and can revoke sites in Settings. - lib/chain-eth.js: EthWallet accepts a customNetwork override ({id, label, chainId, defaultRpc, explorerTx, explorerAddr, ticker}). When present it replaces the NETWORKS lookup so mainnet+Sepolia ship built-in and every EIP-3085 chain is a runtime override the addon persists. The ticker flows into snapshot() so the send approval reads MATIC / BNB / whatever the chain's native currency is, not a hardcoded ETH. - index.js customEthChains storage: `{[chainId]: {chainName, rpcUrl, explorerTx, explorerAddr, ticker, addedAt, addedByOrigin}}`. Persisted under api.storage.customEthChains, so an added chain survives Theseus restarts. chainMeta("eth", "custom-") synthesizes the meta from storage so the panel renders custom chains without needing them in COINS at module-load time. - eth.addChain handler (EIP-3085): approval overlay shows chain name, decimal + hex chain id, native ticker, RPC and explorer URLs (the phishing-signal quartet). On approval, persist config + create wallet with a custom- network + auto-grant the origin readAddress on this chain. No-op success if the chain is already added. - eth.switchChain rewritten to be EIP-3326 correct: look up any ready ETH wallet whose adapter reports the requested chainId, make it the selected wallet, fire chainChanged. When no wallet matches, throw with .code = 4902 (the standard 'chain not added' code) so wagmi / RainbowKit / any 3326-aware dapp does the fallback wallet_addEthereumChain call in the same click. - eth.state handler: cheap {address, chainIdHex, networkVersion} peek for the origin's currently-connected wallet (no approval, no key access). The main-world bridge calls it after every switch/add to emit chainChanged + accountsChanged locally — the events MetaMask fires and RainbowKit listens for. - wallet-inject.js: routes wallet_addEthereumChain via eth.addChain, preserves the 4902 code across the postMessage boundary on switch failures, calls pullEthStateAndEmit() to fire the post-switch/add events. --- bundled-addons/bchwallet/index.js | 168 +++++++++++++++++++++- bundled-addons/bchwallet/lib/chain-eth.js | 10 +- bundled-addons/bchwallet/wallet-inject.js | 37 ++++- 3 files changed, 205 insertions(+), 10 deletions(-) diff --git a/bundled-addons/bchwallet/index.js b/bundled-addons/bchwallet/index.js index 89da745a..ce4fc865 100644 --- a/bundled-addons/bchwallet/index.js +++ b/bundled-addons/bchwallet/index.js @@ -296,7 +296,53 @@ function defaultAccountPathFor(c, network) { if (ct == null || c.defaultPurpose == null) return null; return `m/${c.defaultPurpose}'/${ct}'/0'`; } +// Custom EVM chains (EIP-3085) live in api.storage under `customEthChains`. +// Structured as { [chainId]: {chainId, chainName, rpcUrl, explorerTx, +// explorerAddr, ticker, addedAt, addedByOrigin} }. They're not in COINS at +// module-load time — we synthesize a chainMeta / mount entry from storage +// so wallet_addEthereumChain can register new networks at runtime without +// a Theseus restart. +const CUSTOM_ETH_PREFIX = "custom-"; +function customEthChains(api) { + const raw = api.storage.get("customEthChains", {}); + return raw && typeof raw === "object" ? raw : {}; +} +function customEthNetworkEntry(api, network) { + if (!network || !network.startsWith(CUSTOM_ETH_PREFIX)) return null; + const chainId = Number(network.slice(CUSTOM_ETH_PREFIX.length)); + if (!Number.isFinite(chainId)) return null; + const all = customEthChains(api); + const cfg = all[String(chainId)]; + if (!cfg) return null; + return { + id: network, + label: cfg.chainName || `EVM #${chainId}`, + chainId, + defaultRpc: cfg.rpcUrl, + explorerTx: cfg.explorerTx, + explorerAddr: cfg.explorerAddr, + ticker: cfg.ticker || "ETH", + faucet: null, + }; +} function chainMeta(chain, network) { + // ETH custom-network fallback for EIP-3085 chains. + if (chain === "eth" && String(network || "").startsWith(CUSTOM_ETH_PREFIX)) { + const cfg = customEthNetworkEntry(ctx?.api, network); + if (!cfg) return null; + return { + chain: "eth", network: cfg.id, + label: "Ethereum · " + cfg.label, + short: cfg.ticker, ticker: cfg.ticker, decimals: 18, + color: COINS.eth.color, logo: COINS.eth.logo, + coinLabel: cfg.label, networkLabel: `chainId ${cfg.chainId}`, + testnet: false, + purposePrefix: `bchwallet/eth/${cfg.id}/`, startIndex: 0, + supportsMessageSign: true, supportsPageInject: false, + addressFamilies: null, defaultAccountPath: null, + isCustom: true, chainId: cfg.chainId, + }; + } const c = COINS[chain]; const n = c && c.networks[network]; if (!c || !n) return null; return { @@ -453,12 +499,16 @@ async function mountWallet(entry) { }); } else if (entry.chain === "eth") { const rpcUrl = String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || ""); + // Custom EIP-3085 chains resolve their config from storage rather than + // the built-in NETWORKS map. + const customNetwork = customEthNetworkEntry(c.api, entry.network); adapter = new c.d.ethAdapter.EthWallet(root, entry.network, { walletId: entry.id, storage: c.api.storage, log: (...a) => c.api.log(`[${entry.id}]`, ...a), onChange: () => emitStateForWallet(entry.id), rpcUrl, + customNetwork, }); adapter.schedulePoll(20_000); } else if (entry.chain === "sol") { @@ -1237,11 +1287,121 @@ function registerPageMessages(api) { }); api.onMessage("eth.switchChain", async (p, m) => { fromPage(m); - const want = String(p && p.chainId || "").toLowerCase(); + const wantHex = String(p && p.chainId || "").toLowerCase(); + const wantId = Number(wantHex); + if (!Number.isFinite(wantId) || wantId <= 0) throw new Error("bad chainId"); + // Find any wallet already on that chain and select it. + for (const rt of ctx.runtimes.values()) { + if (rt.entry.chain !== "eth" || rt.phase !== "ready") continue; + const snap = rt.adapter.snapshot(); + if (Number(snap.chainId) === wantId) { + api.storage.set("selectedWalletId", rt.entry.id); + emitState(); + return null; + } + } + // EIP-3326: throw the well-known "chain not added" code so dapps fall + // back to wallet_addEthereumChain. + const err = new Error(`Aegis: chainId ${wantHex} is not added. Ask via wallet_addEthereumChain.`); + err.code = 4902; + throw err; + }); + // EIP-3085: dapp asks Aegis to add a new EVM chain. On approval, we + // persist the chain config and create a wallet on it under the same + // vault-derived key. Existing addresses on that chain remain visible on + // whatever wallet they were funded on — a chain add doesn't move any + // key material, just registers the network. + api.onMessage("eth.addChain", async (p, m) => { + const origin = fromPage(m); + const spec = (p && p.params) || {}; + const chainIdHex = String(spec.chainId || "").toLowerCase(); + const chainId = Number(chainIdHex); + if (!chainIdHex.startsWith("0x") || !Number.isFinite(chainId) || chainId <= 0) { + throw new Error("wallet_addEthereumChain: chainId must be a positive hex integer (e.g. '0x89')"); + } + const chainName = String(spec.chainName || "").trim() || `EVM #${chainId}`; + const rpcUrls = Array.isArray(spec.rpcUrls) ? spec.rpcUrls.filter((u) => /^https?:\/\//i.test(u)) : []; + const rpcUrl = rpcUrls[0]; + if (!rpcUrl) throw new Error("wallet_addEthereumChain: at least one https rpcUrls entry is required"); + const explorerBase = Array.isArray(spec.blockExplorerUrls) && spec.blockExplorerUrls[0] + ? String(spec.blockExplorerUrls[0]).replace(/\/+$/, "") + : null; + const nc = spec.nativeCurrency || {}; + const ticker = String(nc.symbol || "ETH").slice(0, 6).toUpperCase(); + // Reject if a wallet on this chain already exists — no-op success per + // EIP-3085 conventions. + const existing = walletEntries().find((w) => w.chain === "eth" + && (w.network === CUSTOM_ETH_PREFIX + chainId + || (chainMeta("eth", w.network)?.chainId === chainId))); + if (existing) { + // Auto-connect the origin to this wallet — dapps expect the returned + // provider to be pointed at the added chain immediately. + const perms = permissions(api); + perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId } }; + api.storage.set("permissions", perms); + api.storage.set("selectedWalletId", existing.id); + emitState(); + return null; + } + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Add an Ethereum chain?", + origin, + body: "The site is asking to add a new EVM network to Aegis. Verify the RPC and chain ID — a malicious 'chain add' can point you at a fraudulent RPC that intercepts your reads or signs.", + rows: [ + { label: "Chain name", value: chainName }, + { label: "Chain ID", value: `${chainId} (${chainIdHex})` }, + { label: "Native ticker", value: ticker }, + { label: "RPC", value: rpcUrl, mono: true }, + { label: "Explorer", value: explorerBase || "(none)", mono: true }, + ], + actions: [{ id: "add", label: "Add chain", primary: true }], + }); + if (pick !== "add") throw new Error("user rejected"); + // Persist chain config + create a wallet on it. + const chains = customEthChains(api); + chains[String(chainId)] = { + chainId, chainName, rpcUrl, + explorerTx: explorerBase ? explorerBase + "/tx/" : "", + explorerAddr: explorerBase ? explorerBase + "/address/" : "", + ticker, addedAt: Date.now(), addedByOrigin: origin, + }; + api.storage.set("customEthChains", chains); + const network = CUSTOM_ETH_PREFIX + chainId; + const meta = chainMeta("eth", network); + const list = walletEntries().slice(); + const index = nextIndex(list, meta); + const purpose = meta.purposePrefix + index; + const id = makeWalletId(meta, index); + const label = `${chainName} — ${meta.short}`; + const entry = { id, label, chain: "eth", network, purpose, createdAt: Date.now() }; + list.push(entry); + writeWallets(api, list); + api.storage.set("selectedWalletId", id); + // Grant the origin read access on this chain by default (they just + // approved adding it — implicit consent to also see the address). + const perms = permissions(api); + perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId } }; + api.storage.set("permissions", perms); + ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null }); + emitState(); + await mountWallet(entry); + return null; + }); + }); + // Cheap state peek — used by the main-world bridge right after a switch + // or add to emit accountsChanged / chainChanged without needing another + // approval overlay. Only returns the wallet the origin already sees. + api.onMessage("eth.state", (_p, m) => { + const origin = fromPage(m); + if (!ethConnectedFor(origin)) return { address: null, chainIdHex: "0x0", networkVersion: "0" }; const rt = activeEthRuntime(); - const cur = "0x" + Number(rt.adapter.snapshot().chainId).toString(16); - if (want === cur) return null; - throw new Error(`Aegis: switch chains via the Aegis sidebar picker. This wallet is on ${cur}; site asked for ${want}.`); + const snap = rt.adapter.snapshot(); + return { + address: snap.address, + chainIdHex: "0x" + Number(snap.chainId).toString(16), + networkVersion: String(snap.chainId), + }; }); // Read passthrough: forward eth_getBalance / eth_call / etc. to the // wallet's own configured RPC. Nothing here reveals the private key. diff --git a/bundled-addons/bchwallet/lib/chain-eth.js b/bundled-addons/bchwallet/lib/chain-eth.js index 64626247..a5c9bfd0 100644 --- a/bundled-addons/bchwallet/lib/chain-eth.js +++ b/bundled-addons/bchwallet/lib/chain-eth.js @@ -176,14 +176,16 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { class EthWallet { constructor(root32, networkId, { walletId, storage, log = () => {}, onChange = () => {}, rpcUrl, + customNetwork, // { id, label, chainId, defaultRpc, explorerTx, explorerAddr, ticker } for EIP-3085 chains } = {}) { if (!walletId) throw new Error("chain-eth: walletId required"); - const net = NETWORKS[networkId]; + const net = customNetwork || NETWORKS[networkId]; if (!net) throw new Error(`chain-eth: unknown network ${networkId}`); this.walletId = walletId; this.chain = "eth"; this.network = net.id; this._net = net; + this._ticker = net.ticker || "ETH"; this.log = log; this.onChange = onChange; this.storage = scopedStorage(storage, `wallets/${walletId}/`); @@ -212,10 +214,12 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { } _emit() { try { this.onChange(); } catch {} } - // Wei is 10^18 native units; the panel formats via decimals=18. + // Wei is 10^18 native units; the panel formats via decimals=18. The + // ticker follows the chain's nativeCurrency (ETH on mainnet/Sepolia, + // MATIC on Polygon, etc.) so the send-approval overlay reads correctly. snapshot() { return { - chain: "eth", network: this._net.id, ticker: "ETH", decimals: 18, + chain: "eth", network: this._net.id, ticker: this._ticker, decimals: 18, address: this.address, addressIndex: 0, addressPath: "m/44'/60'/0'/0/0", balance: this._state.balance, diff --git a/bundled-addons/bchwallet/wallet-inject.js b/bundled-addons/bchwallet/wallet-inject.js index aab3efa3..ae4d1f2b 100644 --- a/bundled-addons/bchwallet/wallet-inject.js +++ b/bundled-addons/bchwallet/wallet-inject.js @@ -209,6 +209,23 @@ const mainWorldSource = `(function () { function ethEmit(event, data) { for (const fn of ethListeners[event] || []) { try { fn(data); } catch {} } } + // After a chain switch or add, refresh the local chainId/address state + // and fire the two events dapps expect (accountsChanged + + // chainChanged). MetaMask does the same round-trip after switchChain. + async function pullEthStateAndEmit() { + try { + const s = await invoke("eth.state"); + const oldChain = ethState.chainIdHex, oldAddr = ethState.address; + ethState.address = s.address || null; + ethState.chainIdHex = s.chainIdHex; + ethState.networkVersion = s.networkVersion; + window.ethereum.selectedAddress = ethState.address; + window.ethereum.chainId = ethState.chainIdHex; + window.ethereum.networkVersion = ethState.networkVersion; + if (s.chainIdHex !== oldChain) ethEmit("chainChanged", s.chainIdHex); + if ((s.address || null) !== oldAddr) ethEmit("accountsChanged", s.address ? [s.address] : []); + } catch {} + } async function ethHandle(method, params) { params = Array.isArray(params) ? params : (params ? [params] : []); switch (method) { @@ -258,10 +275,24 @@ const mainWorldSource = `(function () { } case "wallet_switchEthereumChain": { const target = String((params[0] && params[0].chainId) || "").toLowerCase(); - return invoke("eth.switchChain", { chainId: target }); + try { + const r = await invoke("eth.switchChain", { chainId: target }); + await pullEthStateAndEmit(); + return r; + } + catch (e) { + // EIP-3326: preserve the 4902 signal the isolated-world handler + // stamps on the Error so dapps fall through to addChain. + if (/is not added/i.test(e.message || "")) { const err = new Error(e.message); err.code = 4902; throw err; } + throw e; + } + } + case "wallet_addEthereumChain": { + // EIP-3085. Approval overlay + persistence live in the addon. + const r = await invoke("eth.addChain", { params: params[0] }); + await pullEthStateAndEmit(); + return r; } - case "wallet_addEthereumChain": - throw new Error("Aegis: chains are managed from the wallet's own settings; add the chain there."); case "wallet_getPermissions": case "wallet_requestPermissions": // Minimum shape most dapps accept.