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.