diff --git a/src/popup/index.html b/src/popup/index.html index 6922bcd..b8f6f4e 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -943,6 +943,23 @@
Transaction hash
+ + diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index 359b506..7bce818 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -3,6 +3,7 @@ const { state, saveState } = require("../../shared/state"); const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers"); const { ERC20_ABI } = require("../../shared/constants"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); +const { decodeCalldata } = require("../../shared/decodeCalldata"); const txStatus = require("./txStatus"); const uniswap = require("../../shared/uniswap"); @@ -41,91 +42,7 @@ function etherscanTokenLink(address) { return `https://etherscan.io/token/${address}`; } -// Try to decode calldata using known ABIs. -// Returns { name, description, details } or null. -function decodeCalldata(data, toAddress) { - if (!data || data === "0x" || data.length < 10) return null; - - // Try ERC-20 (approve / transfer) - try { - const parsed = erc20Iface.parseTransaction({ data }); - if (parsed) { - 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 }, - ], - }; - } - } - } catch { - // Not ERC-20 — fall through - } - - // Try Uniswap Universal Router - const routerResult = uniswap.decode(data, toAddress); - if (routerResult) return routerResult; - - return null; -} +// decodeCalldata is now in ../../shared/decodeCalldata.js function showTxApproval(details) { const toAddr = details.txParams.to; diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 8ecbfe3..9961e28 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -12,6 +12,8 @@ const { timeAgo, } = require("./helpers"); const { state } = require("../../shared/state"); +const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); +const { decodeCalldata } = require("../../shared/decodeCalldata"); const makeBlockie = require("ethereum-blockies-base64"); const EXT_ICON = @@ -85,6 +87,8 @@ function show(tx) { fromEns: tx.fromEns || null, toEns: tx.toEns || null, directionLabel: tx.directionLabel || null, + rawInput: tx.rawInput || null, + decoded: tx.decoded || null, }, }; render(); @@ -124,6 +128,58 @@ function render() { $("tx-detail-time").textContent = isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")"; $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; + + // Decoded calldata section — matches approval screen format + const decodedEl = $("tx-detail-decoded"); + const rawDataEl = $("tx-detail-rawdata-section"); + const decoded = tx.decoded; + + if (decoded) { + $("tx-detail-action").textContent = decoded.name; + let detailsHtml = ""; + if (decoded.description) { + detailsHtml += `
${escapeHtml(decoded.description)}
`; + } + for (const d of decoded.details) { + detailsHtml += `
`; + detailsHtml += `
${escapeHtml(d.label)}
`; + if (d.address) { + if (d.isToken) { + const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase()); + const label = t ? t.symbol : "Unknown token"; + detailsHtml += `
${escapeHtml(label)}
`; + } + const dot = addressDotHtml(d.address); + const link = `https://etherscan.io/address/${d.address}`; + detailsHtml += + `
${dot}` + + `${escapeHtml(d.address)}` + + `${EXT_ICON}
`; + } else { + detailsHtml += `
${escapeHtml(d.value)}
`; + } + detailsHtml += `
`; + } + $("tx-detail-decoded-details").innerHTML = detailsHtml; + decodedEl.classList.remove("hidden"); + } else { + decodedEl.classList.add("hidden"); + } + + // Raw calldata section — shown for unknown contract calls (full, not truncated) + if (tx.rawInput && tx.rawInput !== "0x" && !decoded) { + $("tx-detail-rawdata").textContent = tx.rawInput; + rawDataEl.classList.remove("hidden"); + // Label as unknown contract call + $("tx-detail-action").textContent = "Unknown Contract Call"; + $("tx-detail-decoded-details").innerHTML = ""; + decodedEl.classList.remove("hidden"); + } else if (!decoded) { + rawDataEl.classList.add("hidden"); + } else { + rawDataEl.classList.add("hidden"); + } + showView("transaction"); document diff --git a/src/shared/decodeCalldata.js b/src/shared/decodeCalldata.js new file mode 100644 index 0000000..e93c058 --- /dev/null +++ b/src/shared/decodeCalldata.js @@ -0,0 +1,103 @@ +// Decode transaction calldata into human-readable details. +// Shared between the approval screen and transaction history views. +// Returns { name, description, details } or null. + +const { Interface, formatUnits } = require("ethers"); +const { ERC20_ABI } = require("./constants"); +const { TOKEN_BY_ADDRESS } = require("./tokenList"); +const uniswap = require("./uniswap"); + +const erc20Iface = new Interface(ERC20_ABI); + +function formatTxValue(val) { + const parts = val.split("."); + if (parts.length === 1) return val + ".0000"; + const dec = (parts[1] + "0000").slice(0, 4); + return parts[0] + "." + dec; +} + +function decodeCalldata(data, toAddress) { + if (!data || data === "0x" || data.length < 10) return null; + + // Try ERC-20 (approve / transfer) + try { + const parsed = erc20Iface.parseTransaction({ data }); + if (parsed) { + 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 }, + ], + }; + } + } + } catch { + // Not ERC-20 — fall through + } + + // Try Uniswap Universal Router + const routerResult = uniswap.decode(data, toAddress); + if (routerResult) return routerResult; + + return null; +} + +module.exports = { decodeCalldata }; diff --git a/src/shared/transactions.js b/src/shared/transactions.js index f926784..dee0d06 100644 --- a/src/shared/transactions.js +++ b/src/shared/transactions.js @@ -9,6 +9,7 @@ const { formatEther, formatUnits } = require("ethers"); const { log, debugFetch } = require("./log"); const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList"); +const { decodeCalldata } = require("./decodeCalldata"); function formatTxValue(val) { const parts = val.split("."); @@ -23,6 +24,7 @@ function parseTx(tx, addrLower) { const rawWei = tx.value || "0"; const toIsContract = tx.to?.is_contract || false; const method = tx.method || null; + const rawInput = tx.raw_input || null; // For contract calls, produce a meaningful label instead of "0.0000 ETH" let symbol = "ETH"; @@ -32,14 +34,41 @@ function parseTx(tx, addrLower) { let rawUnit = "wei"; let direction = from.toLowerCase() === addrLower ? "sent" : "received"; let directionLabel = direction === "sent" ? "Sent" : "Received"; - if (toIsContract && method && method !== "transfer") { - const token = TOKEN_BY_ADDRESS.get(to.toLowerCase()); - if (token) { - symbol = token.symbol; + let decoded = null; + + if (rawInput && rawInput !== "0x" && rawInput.length >= 10) { + decoded = decodeCalldata(rawInput, to); + if (decoded) { + // Uniswap swaps: show "Swap" with token pair + if (decoded.name && decoded.name.startsWith("Swap")) { + direction = "contract"; + directionLabel = decoded.name; + value = ""; + exactValue = ""; + rawAmount = ""; + rawUnit = ""; + } else if (decoded.name === "Token Approval") { + direction = "contract"; + directionLabel = "Approve"; + value = ""; + exactValue = ""; + rawAmount = ""; + rawUnit = ""; + } + // Token Transfer: keep as Sent/Received (handled by token transfer overlay) + } else if (toIsContract && method && method !== "transfer") { + // Unknown contract call + direction = "contract"; + directionLabel = "Contract Call"; + value = ""; + exactValue = ""; + rawAmount = ""; + rawUnit = ""; } - const label = method.charAt(0).toUpperCase() + method.slice(1); + } else if (toIsContract && method && method !== "transfer") { + // Contract call without raw input data direction = "contract"; - directionLabel = label; + directionLabel = "Contract Call"; value = ""; exactValue = ""; rawAmount = ""; @@ -65,6 +94,8 @@ function parseTx(tx, addrLower) { holders: null, isContractCall: toIsContract, method: method, + rawInput: rawInput, + decoded: decoded, }; }