From 9e45c75d292f3519a46827419cbda0de172d5383 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 27 Feb 2026 14:55:11 +0700 Subject: [PATCH] Implement personal_sign and eth_signTypedData_v4 message signing Replace stub error handlers with full approval flow for personal_sign, eth_sign, eth_signTypedData_v4, and eth_signTypedData. Uses toolbar popup only (no fallback window) and keeps sign approvals pending across popup close/reopen cycles so the user can respond via the toolbar icon. --- README.md | 66 ++++++- src/background/index.js | 204 +++++++++++++++++++--- src/popup/index.html | 58 ++++++- src/popup/views/approval.js | 264 +++++++++++++++++++++------- src/popup/views/helpers.js | 1 + src/shared/tokenList.js | 5 + src/shared/uniswap.js | 333 ++++++++++++++++++++++++++++++++++++ tests/uniswap.test.js | 274 +++++++++++++++++++++++++++++ 8 files changed, 1102 insertions(+), 103 deletions(-) create mode 100644 src/shared/uniswap.js create mode 100644 tests/uniswap.test.js diff --git a/README.md b/README.md index 0a173f5..39c083d 100644 --- a/README.md +++ b/README.md @@ -437,20 +437,41 @@ transitions. #### TxApproval - **When**: A connected website requests a transaction via - `eth_sendTransaction`. Opened in a separate popup by the background script. + `eth_sendTransaction`. Opened via the toolbar popup by the background script. - **Elements**: - "Transaction Request" heading - Site hostname (bold) + "wants to send a transaction" + - Decoded action (if calldata is recognized): action name, token details, + amounts, steps, deadline (see Transaction Decoding) - From: color dot + full address + etherscan link - - To: color dot + full address + etherscan link (or "contract creation") + - To/Contract: color dot + full address + etherscan link (or "contract + creation"), token symbol label if known - Value: amount in ETH (4 decimal places) - - Data: raw transaction data (shown if present) + - Raw data: full calldata displayed inline (shown if present) - Password input - "Confirm" / "Reject" buttons - **Transitions**: - "Confirm" (with password) → closes popup (returns result to background) - "Reject" → closes popup (returns rejection to background) +#### SignApproval + +- **When**: A connected website requests a message signature via + `personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened via the toolbar + popup by the background script. +- **Elements**: + - "Signature Request" heading + - Site hostname (bold) + "wants you to sign a message" + - Type: "Personal message" or "Typed data (EIP-712)" + - From: color dot + full address + etherscan link + - Message: decoded UTF-8 text (personal_sign) or formatted domain/type/ + message fields (EIP-712 typed data) + - Password input + - "Sign" / "Reject" buttons +- **Transitions**: + - "Sign" (with password) → signs locally → closes popup (returns signature) + - "Reject" → closes popup (returns rejection to background) + ### External Services AutistMask is not a fully self-contained offline tool. It necessarily @@ -578,13 +599,16 @@ project owner. - View ERC-20 token balances (user adds token by contract address) - Send ETH to an address - Send ERC-20 tokens to an address -- Receive ETH/tokens (display address, copy to clipboard) +- Receive ETH/tokens (display address, copy to clipboard, QR code) - Connect to web3 sites (EIP-1193 `eth_requestAccounts`) -- Sign transactions requested by connected sites +- Sign transactions requested by connected sites (`eth_sendTransaction`) - Sign messages (`personal_sign`, `eth_sign`) -- Lock/unlock with password -- Configurable RPC endpoint -- Future: USD value display (and other fiat currencies) +- Sign typed data (`eth_signTypedData_v4`, `eth_signTypedData`) +- Human-readable transaction decoding (ERC-20, Uniswap Universal Router) +- ETH/USD and token/USD price display +- Configurable RPC endpoint and Blockscout API +- Address poisoning protection (spam token filtering, dust filtering, fraud + contract blocklist) ### Address Poisoning and Fake Token Transfer Attacks @@ -672,6 +696,32 @@ indexes it as a real token transfer. designed as a sharp tool — users who understand the risks can configure the wallet to show everything unfiltered, unix-style. +#### Transaction Decoding + +When a dApp asks the user to approve a transaction, AutistMask attempts to +decode the calldata into a human-readable summary. This is purely a display +convenience to help the user understand what they are signing — it is not +endorsement, special treatment, or partnership with any protocol. + +AutistMask is a generic web3 wallet. It treats all dApps, protocols, and +contracts equally. No contract gets special handling, priority, or integration +beyond what is needed to show the user a legible confirmation screen. Our +commitment is to the user, not to any service, site, or contract. + +Decoded transaction summaries are best-effort. If decoding fails, the raw +calldata is displayed in full. The decoders live in self-contained modules under +`src/shared/` (e.g. `uniswap.js`) so they can be added for common contracts +without polluting wallet-specific code. Contributions of decoders for other +widely-used contracts are welcome. + +Currently supported: + +- **ERC-20**: `approve()` and `transfer()` calls — shows token symbol, spender + or recipient, and amount. +- **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g. + "Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline. + Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks. + ### Non-Goals - Token swaps (use a DEX in the browser) diff --git a/src/background/index.js b/src/background/index.js index 16f97e6..4d81256 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -6,6 +6,7 @@ const { ETHEREUM_MAINNET_CHAIN_ID, DEFAULT_RPC_URL, } = require("../shared/constants"); +const { getBytes } = require("ethers"); const { state, loadState, saveState } = require("../shared/state"); const { refreshBalances, getProvider } = require("../shared/balances"); const { debugFetch } = require("../shared/log"); @@ -148,6 +149,7 @@ function requestApproval(origin, hostname) { } // Open a tx-approval popup and return a promise that resolves with txHash or error. +// Uses the toolbar popup only — no fallback window. function requestTxApproval(origin, hostname, txParams) { return new Promise((resolve) => { const id = nextApprovalId++; @@ -159,41 +161,70 @@ function requestTxApproval(origin, hostname, txParams) { type: "tx", }; - if (actionApi && typeof actionApi.openPopup === "function") { + if (actionApi && typeof actionApi.setPopup === "function") { actionApi.setPopup({ popup: "src/popup/index.html?approval=" + id, }); + } + if (actionApi && typeof actionApi.openPopup === "function") { try { const result = actionApi.openPopup(); if (result && typeof result.catch === "function") { - result.catch(() => openApprovalWindow(id)); + result.catch(() => {}); } } catch { - openApprovalWindow(id); + // openPopup unsupported — user clicks toolbar icon } - } else { - openApprovalWindow(id); } }); } -// Detect when an approval popup (browser-action) closes without a response +// Open a sign-approval popup and return a promise that resolves with { signature } or { error }. +// Uses the toolbar popup only — no fallback window. If openPopup() fails the +// popup URL is still set, so the user can click the toolbar icon to respond. +function requestSignApproval(origin, hostname, signParams) { + return new Promise((resolve) => { + const id = nextApprovalId++; + pendingApprovals[id] = { + origin, + hostname, + signParams, + resolve, + type: "sign", + }; + + if (actionApi && typeof actionApi.setPopup === "function") { + actionApi.setPopup({ + popup: "src/popup/index.html?approval=" + id, + }); + } + if (actionApi && typeof actionApi.openPopup === "function") { + try { + const result = actionApi.openPopup(); + if (result && typeof result.catch === "function") { + result.catch(() => {}); + } + } catch { + // openPopup unsupported — user clicks toolbar icon + } + } + }); +} + +// Detect when an approval popup (browser-action) closes without a response. +// TX and sign approvals are NOT auto-rejected on disconnect because toolbar +// popups naturally close on focus loss and the user can reopen them. runtime.onConnect.addListener((port) => { if (port.name.startsWith("approval:")) { const id = parseInt(port.name.split(":")[1], 10); port.onDisconnect.addListener(() => { const approval = pendingApprovals[id]; if (approval) { - if (approval.type === "tx") { - approval.resolve({ - error: { - code: 4001, - message: "User rejected the request.", - }, - }); - } else { - approval.resolve({ approved: false, remember: false }); + if (approval.type === "tx" || approval.type === "sign") { + // Keep pending — user can reopen the toolbar popup + return; } + approval.resolve({ approved: false, remember: false }); delete pendingApprovals[id]; } resetPopupUrl(); @@ -390,18 +421,59 @@ async function handleRpc(method, params, origin) { } if (method === "personal_sign" || method === "eth_sign") { - return { - error: { message: "Signing not yet implemented in AutistMask." }, - }; + const s = await getState(); + const activeAddress = await getActiveAddress(); + if (!activeAddress) + return { error: { message: "No accounts available" } }; + + const hostname = extractHostname(origin); + const allowed = s.allowedSites[activeAddress] || []; + if ( + !allowed.includes(hostname) && + !connectedSites[origin + ":" + activeAddress] + ) { + return { error: { code: 4100, message: "Unauthorized" } }; + } + + // personal_sign: params[0]=message, params[1]=address + // eth_sign: params[0]=address, params[1]=message + const signParams = + method === "personal_sign" + ? { method, message: params[0], from: params[1] } + : { method, message: params[1], from: params[0] }; + + const decision = await requestSignApproval( + origin, + hostname, + signParams, + ); + if (decision.error) return { error: decision.error }; + return { result: decision.signature }; } if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") { - return { - error: { - message: - "Typed data signing not yet implemented in AutistMask.", - }, - }; + const s = await getState(); + const activeAddress = await getActiveAddress(); + if (!activeAddress) + return { error: { message: "No accounts available" } }; + + const hostname = extractHostname(origin); + const allowed = s.allowedSites[activeAddress] || []; + if ( + !allowed.includes(hostname) && + !connectedSites[origin + ":" + activeAddress] + ) { + return { error: { code: 4100, message: "Unauthorized" } }; + } + + const signParams = { method, typedData: params[1], from: params[0] }; + const decision = await requestSignApproval( + origin, + hostname, + signParams, + ); + if (decision.error) return { error: decision.error }; + return { result: decision.signature }; } if (method === "eth_sendTransaction") { @@ -446,7 +518,13 @@ async function broadcastAccountsChanged() { } // Reject and close any pending approval popups so they don't hang for (const [id, approval] of Object.entries(pendingApprovals)) { - approval.resolve({ approved: false, remember: false }); + if (approval.type === "tx" || approval.type === "sign") { + approval.resolve({ + error: { code: 4001, message: "User rejected the request." }, + }); + } else { + approval.resolve({ approved: false, remember: false }); + } if (approval.windowId) { windowsApi.remove(approval.windowId, () => { if (runtime.lastError) { @@ -514,7 +592,7 @@ if (windowsApi && windowsApi.onRemoved) { windowsApi.onRemoved.addListener((windowId) => { for (const [id, approval] of Object.entries(pendingApprovals)) { if (approval.windowId === windowId) { - if (approval.type === "tx") { + if (approval.type === "tx" || approval.type === "sign") { approval.resolve({ error: { code: 4001, @@ -550,6 +628,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { resp.type = "tx"; resp.txParams = approval.txParams; } + if (approval.type === "sign") { + resp.type = "sign"; + resp.signParams = approval.signParams; + } sendResponse(resp); } else { sendResponse(null); @@ -624,6 +706,76 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { return true; } + if (msg.type === "AUTISTMASK_SIGN_RESPONSE") { + const approval = pendingApprovals[msg.id]; + if (!approval) return false; + delete pendingApprovals[msg.id]; + resetPopupUrl(); + + if (!msg.approved) { + approval.resolve({ + error: { code: 4001, message: "User rejected the request." }, + }); + return true; + } + + (async () => { + try { + await loadState(); + const activeAddress = await getActiveAddress(); + let wallet, addrIndex; + for (const w of state.wallets) { + for (let i = 0; i < w.addresses.length; i++) { + if (w.addresses[i].address === activeAddress) { + wallet = w; + addrIndex = i; + break; + } + } + if (wallet) break; + } + if (!wallet) throw new Error("Wallet not found"); + const decrypted = await decryptWithPassword( + wallet.encryptedSecret, + msg.password, + ); + const signer = getSignerForAddress( + wallet, + addrIndex, + decrypted, + ); + + const sp = approval.signParams; + let signature; + + if (sp.method === "personal_sign" || sp.method === "eth_sign") { + signature = await signer.signMessage(getBytes(sp.message)); + } else { + // eth_signTypedData_v4 / eth_signTypedData + const typedData = JSON.parse(sp.typedData); + const { domain, types, message } = typedData; + // ethers handles EIP712Domain internally + delete types.EIP712Domain; + signature = await signer.signTypedData( + domain, + types, + message, + ); + } + + approval.resolve({ signature }); + sendResponse({ signature }); + } catch (e) { + const errMsg = e.shortMessage || e.message; + approval.resolve({ + error: { message: errMsg }, + }); + sendResponse({ error: errMsg }); + } + })(); + return true; + } + if (msg.type === "AUTISTMASK_ACTIVE_CHANGED") { broadcastAccountsChanged(); return false; diff --git a/src/popup/index.html b/src/popup/index.html index 534881f..848ce08 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -886,11 +886,7 @@
@@ -917,6 +913,58 @@
+ + +