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:
parent
8fcc0e2433
commit
1b29706ba4
3 changed files with 205 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue