diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 9310a18f..539fb817 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -2670,6 +2670,23 @@ function grantsForPanel(api) { 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) { if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval"); pendingByOrigin.add(origin); @@ -2967,11 +2984,22 @@ function registerPageMessages(api) { }); // ---- Ethereum (EIP-1193) --------------------------------------------- - // Routes the same way as Tron: pick the currently-selected ETH wallet if - // any; else the first ready ETH wallet. Chain switches happen at the - // wallet-picker level, not here — dapps that call wallet_switchEthereumChain - // get a friendly "switch wallet in the Aegis sidebar" error. - function activeEthRuntime() { + function ethRuntimeForChain(chainId) { + for (const rt of ctx.runtimes.values()) { + if (rt.entry.chain !== "eth" || rt.phase !== "ready") continue; + if (Number(rt.adapter.snapshot().chainId) === Number(chainId)) return rt; + } + 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 selRt = selId && ctx.runtimes.get(selId); 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"); } 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) => { const origin = fromPage(m); - const rt = activeEthRuntime(); + const rt = activeEthRuntime(origin); const snap = rt.adapter.snapshot(); const chainIdHex = "0x" + Number(snap.chainId).toString(16); 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.", rows: [ { 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}` }, ], actions: [{ id: "allow", label: "Connect", primary: true }], @@ -3007,48 +3036,87 @@ function registerPageMessages(api) { api.onMessage("eth.personalSign", async (p, m) => { const origin = fromPage(m); if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first"); - const rt = activeEthRuntime(); - const message = String(p && p.message != null ? p.message : ""); - if (message.length > 4096) throw new Error("message too long"); + const rt = activeEthRuntime(origin); + // ethers / viem / wagmi send hex-encoded bytes; signing that hex as + // 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 () => { const pick = await api.approvalModal({ title: "Sign an Ethereum message?", origin, body: "Signing proves you control this address. It moves no ETH.", 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 }, ], actions: [{ id: "sign", label: "Sign", primary: true }], }); 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) => { const origin = fromPage(m); if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first"); - const rt = activeEthRuntime(); + const rt = activeEthRuntime(origin); const tx = (p && p.tx) || {}; if (!tx.to) throw new Error("tx.to required"); - // MetaMask semantics: `value` and `gas`/`gasLimit` are hex-encoded wei; - // convert to numbers/bigints Aegis's own plan() understands. - const valueWei = tx.value ? BigInt(tx.value).toString() : "0"; + if (tx.from && String(tx.from).toLowerCase() !== rt.adapter.address.toLowerCase()) throw new Error("tx.from is not the connected account"); + // MetaMask semantics: every field the dapp set is honoured verbatim — + // 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 () => { 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 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({ - title: "Send Ethereum transaction?", - origin, - 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.", - 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 }], + title: isCall ? "Send Ethereum contract call?" : "Send Ethereum transaction?", + origin, body, rows, + actions: [{ id: "send", label: risky ? "Send anyway" : "Send", primary: !risky, danger: risky }], }); if (pick !== "send") throw new Error("user rejected"); const r = await rt.adapter.signAndBroadcast(plan); @@ -3058,7 +3126,7 @@ function registerPageMessages(api) { api.onMessage("eth.signTypedData", async (p, m) => { const origin = fromPage(m); 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 // an object (v4). Accept both; the encoder wants an object. let td = p && p.typedData; @@ -3094,25 +3162,19 @@ function registerPageMessages(api) { }); }); api.onMessage("eth.switchChain", async (p, m) => { - fromPage(m); + const origin = fromPage(m); 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-3326: the well-known "chain not added" code makes dapps fall back + // to wallet_addEthereumChain. + if (!ethRuntimeForChain(wantId)) throw ethError(`Aegis: chainId ${wantHex} is not added. Ask via wallet_addEthereumChain.`, 4902); + // Per origin only: THIS site moves to the wallet on that chain. It used + // to flip the global selected wallet, so any site — connected or not — + // could move every other connected dapp onto another chain. Unconnected + // sites just have the preference remembered for their connect prompt. + if (!patchGrant(api, origin, "eth", { chainId: wantId })) rememberPendingChain(origin, wantId); + return null; }); // 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 @@ -3128,7 +3190,7 @@ function registerPageMessages(api) { 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 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] @@ -3142,13 +3204,12 @@ function registerPageMessages(api) { && (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(); + // Already known: behaves like a switch for THIS origin only. Never a + // grant — this used to persist a connection without any prompt, so + // any site could read the address by "adding" a chain Aegis already + // had. An unconnected site gets the chain remembered for its eventual + // eth_requestAccounts and learns nothing else. + if (!patchGrant(api, origin, "eth", { chainId })) rememberPendingChain(origin, chainId); return null; } return withOriginLock(origin, async () => { @@ -3186,14 +3247,12 @@ function registerPageMessages(api) { 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); + // 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; }); }); @@ -3203,7 +3262,7 @@ function registerPageMessages(api) { api.onMessage("eth.state", (_p, m) => { const origin = fromPage(m); if (!ethConnectedFor(origin)) return { address: null, chainIdHex: "0x0", networkVersion: "0" }; - const rt = activeEthRuntime(); + const rt = activeEthRuntime(origin); const snap = rt.adapter.snapshot(); return { address: snap.address, @@ -3214,8 +3273,8 @@ function registerPageMessages(api) { // Read passthrough: forward eth_getBalance / eth_call / etc. to the // wallet's own configured RPC. Nothing here reveals the private key. api.onMessage("eth.rpc", async (p, m) => { - fromPage(m); - const rt = activeEthRuntime(); + const origin = fromPage(m); + const rt = activeEthRuntime(origin); const method = String(p && p.method || ""); const params = (p && p.params) || []; if (!/^eth_|^net_|^web3_/.test(method)) throw new Error("Aegis: only eth_/net_/web3_ read methods are passed through"); diff --git a/bundled-addons/aegis/lib/chain-eth.js b/bundled-addons/aegis/lib/chain-eth.js index a5c9bfd0..a1676303 100644 --- a/bundled-addons/aegis/lib/chain-eth.js +++ b/bundled-addons/aegis/lib/chain-eth.js @@ -164,6 +164,56 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { 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 " + 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) { const k = (key) => keyPrefix + key; return { @@ -259,45 +309,69 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { 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 dataHex = normalizeData(data); + const dataBytes = fromHex(dataHex.slice(2)); const from = this.address.toLowerCase(); - const [nonceHex, priorityHex, gasPriceHex, gasLimitHex] = await Promise.all([ - this._client.call("eth_getTransactionCount", [from, "pending"]), + const [nonceHex, priorityHex, gasPriceHex] = await Promise.all([ + 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_gasPrice", []), - Promise.resolve("0x5208"), // 21000 for a plain ETH transfer ]); - const nonce = Number(hexToBig(nonceHex)); - const maxPriorityFeePerGas = hexToBig(priorityHex); - // maxFeePerGas heuristic: 2 * base fee + priority tip. base fee ~= - // gasPrice - priority tip on EIP-1559 chains; we approximate with the - // reported gasPrice as an upper bound plus the priority. - const baseGuess = hexToBig(gasPriceHex); - const maxFeePerGas = baseGuess * 2n + maxPriorityFeePerGas; - const gasLimit = hexToBig(gasLimitHex); - const fee = gasLimit * maxFeePerGas; + const nonceN = Number(hexToBig(nonceHex)); + const tip = maxPriorityFeePerGas != null ? toBig(maxPriorityFeePerGas) : hexToBig(priorityHex); + // eth_gasPrice on a 1559 chain already includes a tip, so it is the + // best single-number estimate of what a block will actually charge. + const gasPriceNow = hexToBig(gasPriceHex); + // Cap: dapp-supplied maxFeePerGas (or legacy gasPrice) wins; otherwise + // 2 × current price + tip so the tx survives a base-fee spike. + const maxFee = maxFeePerGas != null ? toBig(maxFeePerGas) + : (gasPrice != null ? toBig(gasPrice) : gasPriceNow * 2n + tip); 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 (bal <= fee) throw new Error("balance does not cover the gas fee"); - value = bal - fee; - } else { - value = BigInt(Math.round(Number(amount) || 0)); // wei - if (value <= 0n) throw new Error("amount must be > 0 wei"); - if (value + fee > bal) throw new Error("insufficient funds"); + if (bal <= feeMax) throw new Error("balance does not cover the gas fee"); + value = bal - feeMax; + } else if (value + feeMax > bal) { + throw new Error("insufficient funds"); } return { _draft: { - chainId: this._net.chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, - gasLimit, to: dest, value, data: "0x", accessList: [], + chainId: this._net.chainId, nonce: nonceN, maxPriorityFeePerGas: tip, maxFeePerGas: maxFee, + gasLimit: gas, to: dest, value, data: dataHex, accessList: [], }, recipients: [{ to: dest, value: value.toString() }], - fee: fee.toString(), - feeRate: maxFeePerGas.toString(), + fee: feeMax.toString(), // worst case — what the balance check uses + feeEstimate: feeEstimate.toString(), // what a block will most likely charge + feeRate: maxFee.toString(), + gasLimit: gas.toString(), + data: dataHex, inputs: [], 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"); const unsignedFields = [ 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 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) }; } // 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) { - const msg = String(message); 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 buf = concat(prefix, body); 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 }; }; diff --git a/bundled-addons/aegis/wallet-inject.js b/bundled-addons/aegis/wallet-inject.js index 2e370b7c..41068427 100644 --- a/bundled-addons/aegis/wallet-inject.js +++ b/bundled-addons/aegis/wallet-inject.js @@ -292,9 +292,11 @@ const mainWorldSource = `(function () { return (await invoke("eth.personalSign", { message: String(message) })).signature; } case "eth_sign": { - // Legacy method. Same shape as personal_sign for our purposes. - const [_from, msg] = params; - return (await invoke("eth.personalSign", { message: String(msg) })).signature; + // Blind signing of an arbitrary 32-byte hash — the same digest can be + // a transaction. MetaMask ships it disabled; Aegis does not offer it. + 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": { const tx = params[0] || {};