feat(theseus/aegis): EIP-3085 wallet_addEthereumChain + EIP-3326 switchChain

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-<chainId>")
  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-<chainId> 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.
This commit is contained in:
Local Dev 2026-09-07 22:26:27 +02:00
parent 8fcc0e2433
commit 1b29706ba4
3 changed files with 205 additions and 10 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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.