Decode ERC-20 calldata in transaction approval popup
All checks were successful
check / check (push) Successful in 15s

The tx approval screen now decodes known ERC-20 function calls
(approve, transfer) and shows them in plain language instead of
raw hex. For the Uniswap approve example, the user now sees:

  Action: Token Approval
  Approve spending of your USDT
  Token: USDT (with full contract address + etherscan link)
  Spender: (full address + etherscan link)
  Amount: Unlimited

Known tokens from the built-in list show their symbol. Unknown
tokens show the contract address. Max uint256 approvals are
labeled "Unlimited". The raw data is still shown below in a
scrollable area for verification.

Also labels the "To" field as "Contract" since dApp transactions
are always contract calls, and shows the token symbol above the
contract address when recognized.
This commit is contained in:
2026-02-27 12:33:09 +07:00
parent d29273114b
commit a9935eca8d
2 changed files with 177 additions and 14 deletions

View File

@@ -858,21 +858,39 @@
<span id="approve-tx-hostname" class="font-bold"></span>
wants to send a transaction.
</p>
<div class="mb-2">
<!-- decoded action (shown when calldata is recognized) -->
<div
id="approve-tx-decoded"
class="mb-3 border border-border border-dashed p-2 hidden"
>
<div class="text-xs text-muted mb-1">Action</div>
<div
id="approve-tx-action"
class="text-xs font-bold mb-2"
></div>
<div id="approve-tx-decoded-details" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">From</div>
<div id="approve-tx-from" class="text-xs break-all"></div>
</div>
<div class="mb-2">
<div class="text-xs text-muted mb-1">To</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Contract</div>
<div id="approve-tx-to" class="text-xs break-all"></div>
</div>
<div class="mb-2">
<div class="mb-3">
<div class="text-xs text-muted mb-1">Value</div>
<div id="approve-tx-value" class="text-xs font-bold"></div>
</div>
<div id="approve-tx-data-section" class="mb-2 hidden">
<div class="text-xs text-muted mb-1">Data</div>
<div id="approve-tx-data" class="text-xs break-all"></div>
<div id="approve-tx-data-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="approve-tx-data"
class="text-xs break-all"
style="max-height: 4rem; overflow-y: auto"
></div>
</div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>

View File

@@ -1,6 +1,8 @@
const { $, addressDotHtml, escapeHtml, showView } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { formatEther } = require("ethers");
const { formatEther, formatUnits, Interface } = require("ethers");
const { ERC20_ABI } = require("../../shared/constants");
const { TOKENS } = require("../../shared/tokens");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
@@ -12,6 +14,14 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
const erc20Iface = new Interface(ERC20_ABI);
// Build address→token lookup from known token list
const TOKEN_BY_ADDRESS = new Map();
for (const t of TOKENS) {
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
}
function approvalAddressHtml(address) {
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
@@ -26,28 +36,161 @@ function formatTxValue(val) {
return parts[0] + "." + dec;
}
let approvalId = null;
function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null;
}
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
// Try to decode calldata using the ERC-20 ABI.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) {
if (!data || data === "0x" || data.length < 10) return null;
try {
const parsed = erc20Iface.parseTransaction({ data });
if (!parsed) return null;
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null;
const tokenDecimals = token ? token.decimals : 18;
const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
if (parsed.name === "approve") {
const spender = parsed.args[0];
const rawAmount = parsed.args[1];
const maxUint = BigInt(
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = rawAmount === maxUint;
const amountStr = isUnlimited
? "Unlimited"
: formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
(tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Approval",
description: tokenSymbol
? "Approve spending of your " + tokenSymbol
: "Approve spending of an ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{ label: "Spender", value: spender, address: spender },
{ label: "Amount", value: amountStr },
],
};
}
if (parsed.name === "transfer") {
const to = parsed.args[0];
const rawAmount = parsed.args[1];
const amountStr =
formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
(tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Transfer",
description: tokenSymbol
? "Transfer " + tokenSymbol
: "Transfer ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{ label: "Recipient", value: to, address: to },
{ label: "Amount", value: amountStr },
],
};
}
return null;
} catch {
return null;
}
}
function showTxApproval(details) {
$("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-from").innerHTML = approvalAddressHtml(state.activeAddress);
const toAddr = details.txParams.to;
$("approve-tx-to").innerHTML = toAddr
? approvalAddressHtml(toAddr)
: escapeHtml("(contract creation)");
// Show token symbol next to contract address if known
const symbol = toAddr ? tokenLabel(toAddr) : null;
if (toAddr) {
let toHtml = "";
if (symbol) {
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
}
toHtml += approvalAddressHtml(toAddr);
if (symbol) {
const link = etherscanTokenLink(toAddr);
toHtml = toHtml.replace("</div>", "") + ""; // approvalAddressHtml already has etherscan link
}
$("approve-tx-to").innerHTML = toHtml;
} else {
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
}
$("approve-tx-value").textContent =
formatTxValue(formatEther(details.txParams.value || "0")) + " ETH";
// Decode calldata
const decoded = decodeCalldata(details.txParams.data, toAddr || "");
const decodedEl = $("approve-tx-decoded");
if (decoded) {
$("approve-tx-action").textContent = decoded.name;
let detailsHtml = "";
if (decoded.description) {
detailsHtml += `<div class="mb-2">${escapeHtml(decoded.description)}</div>`;
}
for (const d of decoded.details) {
detailsHtml += `<div class="mb-2">`;
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) {
if (d.isToken) {
const tLink = etherscanTokenLink(d.address);
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`;
detailsHtml += approvalAddressHtml(d.address);
} else {
detailsHtml += approvalAddressHtml(d.address);
}
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
detailsHtml += `</div>`;
}
$("approve-tx-decoded-details").innerHTML = detailsHtml;
decodedEl.classList.remove("hidden");
} else {
decodedEl.classList.add("hidden");
}
// Always show raw data when present
if (details.txParams.data && details.txParams.data !== "0x") {
$("approve-tx-data").textContent = details.txParams.data;
$("approve-tx-data-section").classList.remove("hidden");
} else {
$("approve-tx-data-section").classList.add("hidden");
}
showView("approve-tx");
}
function show(id) {
approvalId = id;
// Connect a port so the background detects if the popup closes
// without an explicit response (e.g. user clicks away).
runtime.connect({ name: "approval:" + id });
runtime.sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id }, (details) => {
if (!details) {
@@ -66,6 +209,8 @@ function show(id) {
});
}
let approvalId = null;
function init(ctx) {
$("approve-remember").addEventListener("change", async () => {
state.rememberSiteChoice = $("approve-remember").checked;