Aegis: EVM bridge signs what the dapp asked for, chain per site

- eth_sendTransaction dropped the calldata, gas and fee fields, so an
  ERC-20 transfer went out as a 0-value send to the token contract and
  any contract call was broadcast as something else. plan() now carries
  data/gas/fee caps/nonce, estimates gas for calls, and the overlay
  decodes transfer/approve/permit/setApprovalForAll, flags unlimited
  approvals and calldata to a non-contract, and shows estimated vs max
  fee.
- personal_sign signed the hex string ethers/viem send as literal text;
  it now signs the decoded bytes.
- wallet_switchEthereumChain flipped the global selected wallet, so any
  site could move every connected dapp to another chain. Chain is now
  per origin; the sidebar selection no longer redirects a dapp.
- wallet_addEthereumChain silently persisted a connection for chains
  Aegis already had, handing the address to any site. Adding a chain
  no longer grants anything, and RPC URLs must be https.
- eth_sign (blind hash signing) is disabled, as in MetaMask.
This commit is contained in:
Local Dev 2026-10-03 22:51:05 +02:00
parent 4c3d27f1b3
commit 314bce0905
3 changed files with 228 additions and 92 deletions

View file

@ -2670,6 +2670,23 @@ function grantsForPanel(api) {
return out; return out;
} }
// Dapp message bytes: personal_sign carries hex-encoded bytes (ethers, viem,
// wagmi); a plain string is UTF-8 (older dapps, our own panel).
function bytesFromDappMessage(raw) {
const s = raw == null ? "" : String(raw);
if (/^0x([0-9a-f]{2})*$/i.test(s)) return new Uint8Array(Buffer.from(s.slice(2), "hex"));
return new TextEncoder().encode(s);
}
// Overlay preview: the text if it is clean UTF-8, else a length + hex prefix.
function previewBytes(bytes, max = 400) {
let text = null;
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch {}
if (text != null && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text)) {
return text.length > max ? text.slice(0, max) + "…" : text;
}
return `<${bytes.length} bytes: 0x${Buffer.from(bytes.subarray(0, 30)).toString("hex")}${bytes.length > 30 ? "…" : ""}>`;
}
async function withOriginLock(origin, fn) { async function withOriginLock(origin, fn) {
if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval"); if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval");
pendingByOrigin.add(origin); pendingByOrigin.add(origin);
@ -2967,11 +2984,22 @@ function registerPageMessages(api) {
}); });
// ---- Ethereum (EIP-1193) --------------------------------------------- // ---- Ethereum (EIP-1193) ---------------------------------------------
// Routes the same way as Tron: pick the currently-selected ETH wallet if function ethRuntimeForChain(chainId) {
// any; else the first ready ETH wallet. Chain switches happen at the for (const rt of ctx.runtimes.values()) {
// wallet-picker level, not here — dapps that call wallet_switchEthereumChain if (rt.entry.chain !== "eth" || rt.phase !== "ready") continue;
// get a friendly "switch wallet in the Aegis sidebar" error. if (Number(rt.adapter.snapshot().chainId) === Number(chainId)) return rt;
function activeEthRuntime() { }
return null;
}
// The wallet a given origin talks to. Chain is per origin — fixed at
// connect, changed only by that origin's own wallet_switchEthereumChain /
// wallet_addEthereumChain. The sidebar selection is a UI preference and
// never redirects a connected dapp. Unconnected origins get the chain
// they asked for before connecting, else the selected ETH wallet.
function activeEthRuntime(origin) {
const g = origin ? grantFor(api, origin, "eth") : null;
const wanted = g?.chainId ?? (origin ? sessionGrants.get(origin)?.eth?.chainId : null);
if (wanted != null) { const rt = ethRuntimeForChain(wanted); if (rt) return rt; }
const selId = selectedWalletId(); const selId = selectedWalletId();
const selRt = selId && ctx.runtimes.get(selId); const selRt = selId && ctx.runtimes.get(selId);
if (selRt && selRt.entry.chain === "eth" && selRt.phase === "ready") return selRt; if (selRt && selRt.entry.chain === "eth" && selRt.phase === "ready") return selRt;
@ -2979,9 +3007,10 @@ function registerPageMessages(api) {
throw new Error("no Ethereum wallet available — add one in the Aegis sidebar"); throw new Error("no Ethereum wallet available — add one in the Aegis sidebar");
} }
function ethConnectedFor(origin) { return !!grantFor(api, origin, "eth"); } function ethConnectedFor(origin) { return !!grantFor(api, origin, "eth"); }
function ethError(message, code) { const e = new Error(message); e.code = code; return e; }
api.onMessage("eth.requestAccounts", async (_p, m) => { api.onMessage("eth.requestAccounts", async (_p, m) => {
const origin = fromPage(m); const origin = fromPage(m);
const rt = activeEthRuntime(); const rt = activeEthRuntime(origin);
const snap = rt.adapter.snapshot(); const snap = rt.adapter.snapshot();
const chainIdHex = "0x" + Number(snap.chainId).toString(16); const chainIdHex = "0x" + Number(snap.chainId).toString(16);
const networkVersion = String(snap.chainId); const networkVersion = String(snap.chainId);
@ -2993,7 +3022,7 @@ function registerPageMessages(api) {
body: "The site will see this address and can build transactions for you to sign.", body: "The site will see this address and can build transactions for you to sign.",
rows: [ rows: [
{ label: "Address", value: snap.address, mono: true }, { label: "Address", value: snap.address, mono: true },
{ label: "Network", value: snap.network === "mainnet" ? "Ethereum mainnet" : "Sepolia testnet" }, { label: "Network", value: chainMeta("eth", rt.entry.network)?.label || snap.network },
{ label: "Wallet", value: `${rt.entry.label} — Ethereum · ${snap.network}` }, { label: "Wallet", value: `${rt.entry.label} — Ethereum · ${snap.network}` },
], ],
actions: [{ id: "allow", label: "Connect", primary: true }], actions: [{ id: "allow", label: "Connect", primary: true }],
@ -3007,48 +3036,87 @@ function registerPageMessages(api) {
api.onMessage("eth.personalSign", async (p, m) => { api.onMessage("eth.personalSign", async (p, m) => {
const origin = fromPage(m); const origin = fromPage(m);
if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first"); if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first");
const rt = activeEthRuntime(); const rt = activeEthRuntime(origin);
const message = String(p && p.message != null ? p.message : ""); // ethers / viem / wagmi send hex-encoded bytes; signing that hex as
if (message.length > 4096) throw new Error("message too long"); // literal text produced signatures no dapp could verify. The overlay
// shows the decoded text.
const bytes = bytesFromDappMessage(p && p.message);
if (bytes.length > 16384) throw new Error("message too long");
return withOriginLock(origin, async () => { return withOriginLock(origin, async () => {
const pick = await api.approvalModal({ const pick = await api.approvalModal({
title: "Sign an Ethereum message?", title: "Sign an Ethereum message?",
origin, origin,
body: "Signing proves you control this address. It moves no ETH.", body: "Signing proves you control this address. It moves no ETH.",
rows: [ rows: [
{ label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, { label: "Message", value: previewBytes(bytes), mono: true },
{ label: "Address", value: rt.adapter.snapshot().address, mono: true }, { label: "Address", value: rt.adapter.snapshot().address, mono: true },
], ],
actions: [{ id: "sign", label: "Sign", primary: true }], actions: [{ id: "sign", label: "Sign", primary: true }],
}); });
if (pick !== "sign") throw new Error("user rejected"); if (pick !== "sign") throw new Error("user rejected");
return rt.adapter.signMessage(message); return rt.adapter.signMessage(bytes);
}); });
}); });
api.onMessage("eth.sendTransaction", async (p, m) => { api.onMessage("eth.sendTransaction", async (p, m) => {
const origin = fromPage(m); const origin = fromPage(m);
if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first"); if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first");
const rt = activeEthRuntime(); const rt = activeEthRuntime(origin);
const tx = (p && p.tx) || {}; const tx = (p && p.tx) || {};
if (!tx.to) throw new Error("tx.to required"); if (!tx.to) throw new Error("tx.to required");
// MetaMask semantics: `value` and `gas`/`gasLimit` are hex-encoded wei; if (tx.from && String(tx.from).toLowerCase() !== rt.adapter.address.toLowerCase()) throw new Error("tx.from is not the connected account");
// convert to numbers/bigints Aegis's own plan() understands. // MetaMask semantics: every field the dapp set is honoured verbatim —
const valueWei = tx.value ? BigInt(tx.value).toString() : "0"; // value, data, gas, fee caps, nonce. plan() fills in what is missing.
// The calldata used to be dropped, so a token transfer went out as a
// plain 0-value send to the token contract.
const ethLib = ctx.d.ethAdapter;
const data = ethLib.normalizeData(tx.data ?? tx.input);
const valueWei = tx.value ? ethLib.toBig(tx.value).toString() : "0";
return withOriginLock(origin, async () => { return withOriginLock(origin, async () => {
const snap = rt.adapter.snapshot(); const snap = rt.adapter.snapshot();
const plan = await rt.adapter.plan({ to: tx.to, amount: valueWei, sendMax: false }); const plan = await rt.adapter.plan({
to: tx.to, amount: valueWei, sendMax: false, data,
gasLimit: tx.gas ?? tx.gasLimit, maxFeePerGas: tx.maxFeePerGas,
maxPriorityFeePerGas: tx.maxPriorityFeePerGas, gasPrice: tx.gasPrice, nonce: tx.nonce,
});
const meta = chainMeta("eth", rt.entry.network); const meta = chainMeta("eth", rt.entry.network);
const ticker = snap.ticker || meta.ticker;
const isCall = data !== "0x";
const rows = [{ label: "To", value: ethLib.eip55(plan.recipients[0].to), mono: true }];
let body = `This site is asking your wallet to send ${ticker}.`;
let risky = false;
if (isCall) {
let code = "0x";
try { code = await rt.adapter._client.call("eth_getCode", [plan.recipients[0].to, "latest"]); } catch {}
rows.push({ label: "Destination", value: code && code !== "0x" ? "smart contract" : "NOT a contract — calldata sent to a plain address does nothing" });
const call = ethLib.decodeCalldata(data);
if (call && call.name === "transfer") {
rows.push({ label: "Call", value: `transfer ${call.unlimited ? "UNLIMITED" : call.amount.toString()} token units to ${call.to}`, strong: true });
} else if (call && (call.name === "approve" || call.name === "increaseAllowance")) {
risky = !!call.unlimited;
rows.push({ label: "Call", value: `${call.name}: let ${call.spender} spend ${call.unlimited ? "UNLIMITED (∞)" : call.amount.toString()} token units`, strong: true });
} else if (call && call.name === "transferFrom") {
rows.push({ label: "Call", value: `transferFrom ${call.from} → ${call.to}: ${call.amount.toString()} units`, strong: true });
} else if (call && call.name === "setApprovalForAll") {
risky = !!call.approved;
rows.push({ label: "Call", value: `setApprovalForAll: ${call.approved ? "GRANT" : "revoke"} ${call.operator} control over every NFT in this collection`, strong: true });
} else if (call && call.name === "permit") {
risky = !!call.unlimited;
rows.push({ label: "Call", value: `permit: ${call.spender} may spend ${call.unlimited ? "UNLIMITED (∞)" : call.value.toString()} units`, strong: true });
} else {
rows.push({ label: "Call", value: call ? `unknown method 0x${call.selector} (${call.bytes} bytes, not decoded)` : `${(data.length - 2) / 2} bytes of calldata`, mono: true });
}
body = risky
? "WARNING: this call grants another address open-ended control over your tokens. Only sign if you fully trust this site."
: "This transaction calls a contract. Aegis decoded what it could — check the destination, the call and the value.";
}
rows.push({ label: "Amount", value: `${fmtValue(plan.recipients[0].value, meta.decimals)} ${ticker}`, strong: true });
rows.push({ label: "Fee (est.)", value: `${fmtValue(plan.feeEstimate, meta.decimals)} ${ticker} · max ${fmtValue(plan.fee, meta.decimals)} ${ticker}` });
rows.push({ label: "Gas", value: `${plan.gasLimit} · nonce ${plan._draft.nonce}` });
rows.push({ label: "Wallet", value: `${rt.entry.label} — ${meta.label}` });
const pick = await api.approvalModal({ const pick = await api.approvalModal({
title: "Send Ethereum transaction?", title: isCall ? "Send Ethereum contract call?" : "Send Ethereum transaction?",
origin, origin, body, rows,
body: tx.data && tx.data !== "0x" ? "This transaction carries call data (a contract call). Check the destination + value carefully." : "This site is asking your wallet to send ETH.", actions: [{ id: "send", label: risky ? "Send anyway" : "Send", primary: !risky, danger: risky }],
rows: [
{ label: "To", value: plan.recipients[0].to, mono: true },
{ label: "Amount", value: `${fmtValue(plan.recipients[0].value, meta.decimals)} ETH`, strong: true },
{ label: "Fee (est.)", value: `${fmtValue(plan.fee, meta.decimals)} ETH` },
{ label: "Wallet", value: `${rt.entry.label} — Ethereum · ${snap.network}` },
],
actions: [{ id: "send", label: "Send", primary: true }],
}); });
if (pick !== "send") throw new Error("user rejected"); if (pick !== "send") throw new Error("user rejected");
const r = await rt.adapter.signAndBroadcast(plan); const r = await rt.adapter.signAndBroadcast(plan);
@ -3058,7 +3126,7 @@ function registerPageMessages(api) {
api.onMessage("eth.signTypedData", async (p, m) => { api.onMessage("eth.signTypedData", async (p, m) => {
const origin = fromPage(m); const origin = fromPage(m);
if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first"); if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first");
const rt = activeEthRuntime(); const rt = activeEthRuntime(origin);
// Dapps send typedData as either a JSON string (older MetaMask spec) or // Dapps send typedData as either a JSON string (older MetaMask spec) or
// an object (v4). Accept both; the encoder wants an object. // an object (v4). Accept both; the encoder wants an object.
let td = p && p.typedData; let td = p && p.typedData;
@ -3094,25 +3162,19 @@ function registerPageMessages(api) {
}); });
}); });
api.onMessage("eth.switchChain", async (p, m) => { api.onMessage("eth.switchChain", async (p, m) => {
fromPage(m); const origin = fromPage(m);
const wantHex = String(p && p.chainId || "").toLowerCase(); const wantHex = String(p && p.chainId || "").toLowerCase();
const wantId = Number(wantHex); const wantId = Number(wantHex);
if (!Number.isFinite(wantId) || wantId <= 0) throw new Error("bad chainId"); if (!Number.isFinite(wantId) || wantId <= 0) throw new Error("bad chainId");
// Find any wallet already on that chain and select it. // EIP-3326: the well-known "chain not added" code makes dapps fall back
for (const rt of ctx.runtimes.values()) { // to wallet_addEthereumChain.
if (rt.entry.chain !== "eth" || rt.phase !== "ready") continue; if (!ethRuntimeForChain(wantId)) throw ethError(`Aegis: chainId ${wantHex} is not added. Ask via wallet_addEthereumChain.`, 4902);
const snap = rt.adapter.snapshot(); // Per origin only: THIS site moves to the wallet on that chain. It used
if (Number(snap.chainId) === wantId) { // to flip the global selected wallet, so any site — connected or not —
api.storage.set("selectedWalletId", rt.entry.id); // could move every other connected dapp onto another chain. Unconnected
emitState(); // sites just have the preference remembered for their connect prompt.
if (!patchGrant(api, origin, "eth", { chainId: wantId })) rememberPendingChain(origin, wantId);
return null; 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 // 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 // persist the chain config and create a wallet on it under the same
@ -3128,7 +3190,7 @@ function registerPageMessages(api) {
throw new Error("wallet_addEthereumChain: chainId must be a positive hex integer (e.g. '0x89')"); throw new Error("wallet_addEthereumChain: chainId must be a positive hex integer (e.g. '0x89')");
} }
const chainName = String(spec.chainName || "").trim() || `EVM #${chainId}`; const chainName = String(spec.chainName || "").trim() || `EVM #${chainId}`;
const rpcUrls = Array.isArray(spec.rpcUrls) ? spec.rpcUrls.filter((u) => /^https?:\/\//i.test(u)) : []; const rpcUrls = Array.isArray(spec.rpcUrls) ? spec.rpcUrls.filter((u) => /^https:\/\//i.test(u)) : [];
const rpcUrl = rpcUrls[0]; const rpcUrl = rpcUrls[0];
if (!rpcUrl) throw new Error("wallet_addEthereumChain: at least one https rpcUrls entry is required"); if (!rpcUrl) throw new Error("wallet_addEthereumChain: at least one https rpcUrls entry is required");
const explorerBase = Array.isArray(spec.blockExplorerUrls) && spec.blockExplorerUrls[0] const explorerBase = Array.isArray(spec.blockExplorerUrls) && spec.blockExplorerUrls[0]
@ -3142,13 +3204,12 @@ function registerPageMessages(api) {
&& (w.network === CUSTOM_ETH_PREFIX + chainId && (w.network === CUSTOM_ETH_PREFIX + chainId
|| (chainMeta("eth", w.network)?.chainId === chainId))); || (chainMeta("eth", w.network)?.chainId === chainId)));
if (existing) { if (existing) {
// Auto-connect the origin to this wallet — dapps expect the returned // Already known: behaves like a switch for THIS origin only. Never a
// provider to be pointed at the added chain immediately. // grant — this used to persist a connection without any prompt, so
const perms = permissions(api); // any site could read the address by "adding" a chain Aegis already
perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId } }; // had. An unconnected site gets the chain remembered for its eventual
api.storage.set("permissions", perms); // eth_requestAccounts and learns nothing else.
api.storage.set("selectedWalletId", existing.id); if (!patchGrant(api, origin, "eth", { chainId })) rememberPendingChain(origin, chainId);
emitState();
return null; return null;
} }
return withOriginLock(origin, async () => { return withOriginLock(origin, async () => {
@ -3186,14 +3247,12 @@ function registerPageMessages(api) {
list.push(entry); list.push(entry);
writeWallets(api, list); writeWallets(api, list);
api.storage.set("selectedWalletId", id); 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 }); ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null });
emitState(); emitState();
await mountWallet(entry); await mountWallet(entry);
// Adding a chain is not connecting. A connected site moves to the new
// chain; an unconnected one has it remembered for its connect prompt.
if (!patchGrant(api, origin, "eth", { chainId })) rememberPendingChain(origin, chainId);
return null; return null;
}); });
}); });
@ -3203,7 +3262,7 @@ function registerPageMessages(api) {
api.onMessage("eth.state", (_p, m) => { api.onMessage("eth.state", (_p, m) => {
const origin = fromPage(m); const origin = fromPage(m);
if (!ethConnectedFor(origin)) return { address: null, chainIdHex: "0x0", networkVersion: "0" }; if (!ethConnectedFor(origin)) return { address: null, chainIdHex: "0x0", networkVersion: "0" };
const rt = activeEthRuntime(); const rt = activeEthRuntime(origin);
const snap = rt.adapter.snapshot(); const snap = rt.adapter.snapshot();
return { return {
address: snap.address, address: snap.address,
@ -3214,8 +3273,8 @@ function registerPageMessages(api) {
// Read passthrough: forward eth_getBalance / eth_call / etc. to the // Read passthrough: forward eth_getBalance / eth_call / etc. to the
// wallet's own configured RPC. Nothing here reveals the private key. // wallet's own configured RPC. Nothing here reveals the private key.
api.onMessage("eth.rpc", async (p, m) => { api.onMessage("eth.rpc", async (p, m) => {
fromPage(m); const origin = fromPage(m);
const rt = activeEthRuntime(); const rt = activeEthRuntime(origin);
const method = String(p && p.method || ""); const method = String(p && p.method || "");
const params = (p && p.params) || []; const params = (p && p.params) || [];
if (!/^eth_|^net_|^web3_/.test(method)) throw new Error("Aegis: only eth_/net_/web3_ read methods are passed through"); if (!/^eth_|^net_|^web3_/.test(method)) throw new Error("Aegis: only eth_/net_/web3_ read methods are passed through");

View file

@ -164,6 +164,56 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
return { url: rpcUrl, call }; return { url: rpcUrl, call };
} }
// Accept wei as bigint, integer string, hex string or number.
function toBig(v) {
if (typeof v === "bigint") return v;
const s = String(v ?? "0").trim();
if (/^0x[0-9a-f]+$/i.test(s)) return BigInt(s);
if (/^-?\d+$/.test(s)) return BigInt(s);
return BigInt(Math.round(Number(s) || 0));
}
// Calldata as "0x" + even-length lowercase hex; "" / null / "0x" → "0x".
function normalizeData(data) {
const s = String(data ?? "").trim().toLowerCase();
if (!s || s === "0x") return "0x";
const h = s.startsWith("0x") ? s.slice(2) : s;
if (!/^[0-9a-f]*$/.test(h) || h.length % 2) throw new Error("tx.data must be even-length hex");
return "0x" + h;
}
// ---- calldata decode (overlay only) -----------------------------------
// The handful of selectors behind almost every phishing loss. Anything
// else is shown as "<selector> + N bytes" so the user at least sees that
// it is a contract call they cannot read.
const SELECTORS = {
a9059cbb: { name: "transfer", args: ["to", "amount"] },
"095ea7b3": { name: "approve", args: ["spender", "amount"] },
"23b872dd": { name: "transferFrom", args: ["from", "to", "amount"] },
a22cb465: { name: "setApprovalForAll", args: ["operator", "approved"] },
"39509351": { name: "increaseAllowance", args: ["spender", "amount"] },
d505accf: { name: "permit", args: ["owner", "spender", "value", "deadline"] },
};
const UINT256_MAX = (1n << 256n) - 1n;
function decodeCalldata(dataHex) {
const h = normalizeData(dataHex).slice(2);
if (h.length < 8) return null;
const selector = h.slice(0, 8);
const spec = SELECTORS[selector];
const words = [];
for (let i = 8; i + 64 <= h.length; i += 64) words.push(h.slice(i, i + 64));
const out = { selector, name: spec ? spec.name : null, bytes: h.length / 2 };
if (!spec || words.length < spec.args.length) return out;
spec.args.forEach((a, i) => {
const w = words[i];
if (a === "amount" || a === "value" || a === "deadline") out[a] = BigInt("0x" + w);
else if (a === "approved") out[a] = BigInt("0x" + w) !== 0n;
else out[a] = eip55(w.slice(24));
});
const amt = out.amount ?? out.value;
if (amt != null) out.unlimited = amt >= (1n << 255n) || amt === UINT256_MAX;
return out;
}
function scopedStorage(storage, keyPrefix) { function scopedStorage(storage, keyPrefix) {
const k = (key) => keyPrefix + key; const k = (key) => keyPrefix + key;
return { return {
@ -259,45 +309,69 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms); this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms);
} }
async plan({ to, amount, sendMax }) { // Draft a transaction. The panel passes {to, amount, sendMax}; the dapp
// bridge (eth_sendTransaction) additionally passes data, gasLimit, fee
// caps and nonce exactly as the dapp supplied them. Every field the dapp
// set is honoured — the overlay then shows what will really be signed.
// Zero value is fine when there is calldata (ERC-20 transfer/approve).
async plan({ to, amount, sendMax, data, gasLimit, maxFeePerGas, maxPriorityFeePerGas, gasPrice, nonce }) {
const dest = decodeAddress(to); const dest = decodeAddress(to);
const dataHex = normalizeData(data);
const dataBytes = fromHex(dataHex.slice(2));
const from = this.address.toLowerCase(); const from = this.address.toLowerCase();
const [nonceHex, priorityHex, gasPriceHex, gasLimitHex] = await Promise.all([ const [nonceHex, priorityHex, gasPriceHex] = await Promise.all([
this._client.call("eth_getTransactionCount", [from, "pending"]), nonce != null ? Promise.resolve(bigToHex(toBig(nonce))) : this._client.call("eth_getTransactionCount", [from, "pending"]),
this._client.call("eth_maxPriorityFeePerGas", []).catch(() => "0x59682f00"), // fallback: 1.5 gwei this._client.call("eth_maxPriorityFeePerGas", []).catch(() => "0x59682f00"), // fallback: 1.5 gwei
this._client.call("eth_gasPrice", []), this._client.call("eth_gasPrice", []),
Promise.resolve("0x5208"), // 21000 for a plain ETH transfer
]); ]);
const nonce = Number(hexToBig(nonceHex)); const nonceN = Number(hexToBig(nonceHex));
const maxPriorityFeePerGas = hexToBig(priorityHex); const tip = maxPriorityFeePerGas != null ? toBig(maxPriorityFeePerGas) : hexToBig(priorityHex);
// maxFeePerGas heuristic: 2 * base fee + priority tip. base fee ~= // eth_gasPrice on a 1559 chain already includes a tip, so it is the
// gasPrice - priority tip on EIP-1559 chains; we approximate with the // best single-number estimate of what a block will actually charge.
// reported gasPrice as an upper bound plus the priority. const gasPriceNow = hexToBig(gasPriceHex);
const baseGuess = hexToBig(gasPriceHex); // Cap: dapp-supplied maxFeePerGas (or legacy gasPrice) wins; otherwise
const maxFeePerGas = baseGuess * 2n + maxPriorityFeePerGas; // 2 × current price + tip so the tx survives a base-fee spike.
const gasLimit = hexToBig(gasLimitHex); const maxFee = maxFeePerGas != null ? toBig(maxFeePerGas)
const fee = gasLimit * maxFeePerGas; : (gasPrice != null ? toBig(gasPrice) : gasPriceNow * 2n + tip);
const bal = BigInt(this._state.balance.confirmed || "0"); const bal = BigInt(this._state.balance.confirmed || "0");
let value; let value = sendMax ? 0n : toBig(amount);
if (value < 0n) throw new Error("amount must be >= 0 wei");
if (!sendMax && value === 0n && dataBytes.length === 0) throw new Error("amount must be > 0 wei");
// Gas: dapp value if given; 21000 for a plain transfer; otherwise ask
// the node. A revert here surfaces as a clear error BEFORE the overlay,
// which doubles as a cheap "would this even succeed" simulation.
let gas;
if (gasLimit != null) gas = toBig(gasLimit);
else if (dataBytes.length === 0) gas = 21000n;
else {
let est;
try { est = await this._client.call("eth_estimateGas", [{ from, to: dest, value: bigToHex(value), data: dataHex }]); }
catch (e) { throw new Error("transaction would fail (eth_estimateGas): " + (e?.message || e)); }
gas = (hexToBig(est) * 12n) / 10n; // 20% headroom, as MetaMask does
}
if (gas < 21000n) throw new Error("gas limit below 21000");
const feeMax = gas * maxFee;
const feeEstimate = gas * (gasPriceNow < maxFee ? gasPriceNow : maxFee);
if (sendMax) { if (sendMax) {
if (bal <= fee) throw new Error("balance does not cover the gas fee"); if (bal <= feeMax) throw new Error("balance does not cover the gas fee");
value = bal - fee; value = bal - feeMax;
} else { } else if (value + feeMax > bal) {
value = BigInt(Math.round(Number(amount) || 0)); // wei throw new Error("insufficient funds");
if (value <= 0n) throw new Error("amount must be > 0 wei");
if (value + fee > bal) throw new Error("insufficient funds");
} }
return { return {
_draft: { _draft: {
chainId: this._net.chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, chainId: this._net.chainId, nonce: nonceN, maxPriorityFeePerGas: tip, maxFeePerGas: maxFee,
gasLimit, to: dest, value, data: "0x", accessList: [], gasLimit: gas, to: dest, value, data: dataHex, accessList: [],
}, },
recipients: [{ to: dest, value: value.toString() }], recipients: [{ to: dest, value: value.toString() }],
fee: fee.toString(), fee: feeMax.toString(), // worst case — what the balance check uses
feeRate: maxFeePerGas.toString(), feeEstimate: feeEstimate.toString(), // what a block will most likely charge
feeRate: maxFee.toString(),
gasLimit: gas.toString(),
data: dataHex,
inputs: [], inputs: [],
change: "0", change: "0",
total: (value + fee).toString(), total: (value + feeMax).toString(),
}; };
} }
@ -306,7 +380,7 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
if (!d) throw new Error("bad plan"); if (!d) throw new Error("bad plan");
const unsignedFields = [ const unsignedFields = [
d.chainId, d.nonce, d.maxPriorityFeePerGas, d.maxFeePerGas, d.gasLimit, d.chainId, d.nonce, d.maxPriorityFeePerGas, d.maxFeePerGas, d.gasLimit,
fromHex(d.to.slice(2)), d.value, fromHex(""), [], fromHex(d.to.slice(2)), d.value, fromHex(normalizeData(d.data).slice(2)), [],
]; ];
const rawTxHex = signTxEip1559(unsignedFields, this._priv); const rawTxHex = signTxEip1559(unsignedFields, this._priv);
const txid = await this._client.call("eth_sendRawTransaction", [rawTxHex]); const txid = await this._client.call("eth_sendRawTransaction", [rawTxHex]);
@ -325,10 +399,11 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
return { address: this.address, signature: "0x" + toHex(out) }; return { address: this.address, signature: "0x" + toHex(out) };
} }
// Ethereum personal_sign: keccak256("\x19Ethereum Signed Message:\n" + len + msg). // Ethereum personal_sign: keccak256("\x19Ethereum Signed Message:\n" + len + msg).
// `message` is the exact bytes to sign (Uint8Array) or a plain string;
// the caller (index.js) is responsible for hex-decoding what dapps send.
signMessage(message) { signMessage(message) {
const msg = String(message);
const enc = new TextEncoder(); const enc = new TextEncoder();
const body = enc.encode(msg); const body = message instanceof Uint8Array ? message : enc.encode(String(message));
const prefix = enc.encode("\x19Ethereum Signed Message:\n" + body.length); const prefix = enc.encode("\x19Ethereum Signed Message:\n" + body.length);
const buf = concat(prefix, body); const buf = concat(prefix, body);
const hash = keccak_256(buf); const hash = keccak_256(buf);
@ -357,5 +432,5 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
} }
} }
return { EthWallet, NETWORKS, addressFromPubkey, decodeAddress, eip55 }; return { EthWallet, NETWORKS, addressFromPubkey, decodeAddress, eip55, decodeCalldata, normalizeData, toBig };
}; };

View file

@ -292,9 +292,11 @@ const mainWorldSource = `(function () {
return (await invoke("eth.personalSign", { message: String(message) })).signature; return (await invoke("eth.personalSign", { message: String(message) })).signature;
} }
case "eth_sign": { case "eth_sign": {
// Legacy method. Same shape as personal_sign for our purposes. // Blind signing of an arbitrary 32-byte hash — the same digest can be
const [_from, msg] = params; // a transaction. MetaMask ships it disabled; Aegis does not offer it.
return (await invoke("eth.personalSign", { message: String(msg) })).signature; const err = new Error("eth_sign is disabled in Aegis (blind signing). Use personal_sign or eth_signTypedData_v4.");
err.code = 4200;
throw err;
} }
case "eth_sendTransaction": { case "eth_sendTransaction": {
const tx = params[0] || {}; const tx = params[0] || {};