fix: show swap transactions with decoded calldata in history and detail views (closes #3) #12
@@ -943,6 +943,23 @@
|
|||||||
<div class="text-xs text-muted mb-1">Transaction hash</div>
|
<div class="text-xs text-muted mb-1">Transaction hash</div>
|
||||||
<div id="tx-detail-hash" class="text-xs break-all"></div>
|
<div id="tx-detail-hash" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
id="tx-detail-decoded"
|
||||||
|
class="mb-3 border border-border border-dashed p-2 hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="tx-detail-action"
|
||||||
|
class="font-bold mb-1 text-xs"
|
||||||
|
></div>
|
||||||
|
<div id="tx-detail-decoded-details" class="text-xs"></div>
|
||||||
|
</div>
|
||||||
|
<div id="tx-detail-rawdata-section" class="mb-4 hidden">
|
||||||
|
<div class="text-xs text-muted mb-1">Raw calldata</div>
|
||||||
|
<div
|
||||||
|
id="tx-detail-rawdata"
|
||||||
|
class="text-xs break-all font-mono"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============ TRANSACTION APPROVAL ============ -->
|
<!-- ============ TRANSACTION APPROVAL ============ -->
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { state, saveState } = require("../../shared/state");
|
|||||||
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
|
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
|
||||||
const { ERC20_ABI } = require("../../shared/constants");
|
const { ERC20_ABI } = require("../../shared/constants");
|
||||||
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||||
|
const { decodeCalldata } = require("../../shared/decodeCalldata");
|
||||||
const txStatus = require("./txStatus");
|
const txStatus = require("./txStatus");
|
||||||
const uniswap = require("../../shared/uniswap");
|
const uniswap = require("../../shared/uniswap");
|
||||||
|
|
||||||
@@ -41,91 +42,7 @@ function etherscanTokenLink(address) {
|
|||||||
return `https://etherscan.io/token/${address}`;
|
return `https://etherscan.io/token/${address}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to decode calldata using known ABIs.
|
// decodeCalldata is now in ../../shared/decodeCalldata.js
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showTxApproval(details) {
|
function showTxApproval(details) {
|
||||||
const toAddr = details.txParams.to;
|
const toAddr = details.txParams.to;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const {
|
|||||||
timeAgo,
|
timeAgo,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state } = require("../../shared/state");
|
const { state } = require("../../shared/state");
|
||||||
|
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||||
|
const { decodeCalldata } = require("../../shared/decodeCalldata");
|
||||||
const makeBlockie = require("ethereum-blockies-base64");
|
const makeBlockie = require("ethereum-blockies-base64");
|
||||||
|
|
||||||
const EXT_ICON =
|
const EXT_ICON =
|
||||||
@@ -85,6 +87,8 @@ function show(tx) {
|
|||||||
fromEns: tx.fromEns || null,
|
fromEns: tx.fromEns || null,
|
||||||
toEns: tx.toEns || null,
|
toEns: tx.toEns || null,
|
||||||
directionLabel: tx.directionLabel || null,
|
directionLabel: tx.directionLabel || null,
|
||||||
|
rawInput: tx.rawInput || null,
|
||||||
|
decoded: tx.decoded || null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
render();
|
render();
|
||||||
@@ -124,6 +128,58 @@ function render() {
|
|||||||
$("tx-detail-time").textContent =
|
$("tx-detail-time").textContent =
|
||||||
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
|
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
|
||||||
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
|
$("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 += `<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 t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
|
||||||
|
const label = t ? t.symbol : "Unknown token";
|
||||||
|
detailsHtml += `<div class="font-bold">${escapeHtml(label)}</div>`;
|
||||||
|
}
|
||||||
|
const dot = addressDotHtml(d.address);
|
||||||
|
const link = `https://etherscan.io/address/${d.address}`;
|
||||||
|
detailsHtml +=
|
||||||
|
`<div class="flex items-center">${dot}` +
|
||||||
|
`<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(d.address)}">${escapeHtml(d.address)}</span>` +
|
||||||
|
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a></div>`;
|
||||||
|
} else {
|
||||||
|
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||||
|
}
|
||||||
|
detailsHtml += `</div>`;
|
||||||
|
}
|
||||||
|
$("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");
|
showView("transaction");
|
||||||
|
|
||||||
document
|
document
|
||||||
|
|||||||
103
src/shared/decodeCalldata.js
Normal file
103
src/shared/decodeCalldata.js
Normal file
@@ -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 };
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
const { formatEther, formatUnits } = require("ethers");
|
const { formatEther, formatUnits } = require("ethers");
|
||||||
const { log, debugFetch } = require("./log");
|
const { log, debugFetch } = require("./log");
|
||||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
|
const { decodeCalldata } = require("./decodeCalldata");
|
||||||
|
|
||||||
function formatTxValue(val) {
|
function formatTxValue(val) {
|
||||||
const parts = val.split(".");
|
const parts = val.split(".");
|
||||||
@@ -23,6 +24,7 @@ function parseTx(tx, addrLower) {
|
|||||||
const rawWei = tx.value || "0";
|
const rawWei = tx.value || "0";
|
||||||
const toIsContract = tx.to?.is_contract || false;
|
const toIsContract = tx.to?.is_contract || false;
|
||||||
const method = tx.method || null;
|
const method = tx.method || null;
|
||||||
|
const rawInput = tx.raw_input || null;
|
||||||
|
|
||||||
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
|
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
|
||||||
let symbol = "ETH";
|
let symbol = "ETH";
|
||||||
@@ -32,14 +34,41 @@ function parseTx(tx, addrLower) {
|
|||||||
let rawUnit = "wei";
|
let rawUnit = "wei";
|
||||||
let direction = from.toLowerCase() === addrLower ? "sent" : "received";
|
let direction = from.toLowerCase() === addrLower ? "sent" : "received";
|
||||||
let directionLabel = direction === "sent" ? "Sent" : "Received";
|
let directionLabel = direction === "sent" ? "Sent" : "Received";
|
||||||
if (toIsContract && method && method !== "transfer") {
|
let decoded = null;
|
||||||
const token = TOKEN_BY_ADDRESS.get(to.toLowerCase());
|
|
||||||
if (token) {
|
if (rawInput && rawInput !== "0x" && rawInput.length >= 10) {
|
||||||
symbol = token.symbol;
|
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";
|
direction = "contract";
|
||||||
directionLabel = label;
|
directionLabel = "Contract Call";
|
||||||
value = "";
|
value = "";
|
||||||
exactValue = "";
|
exactValue = "";
|
||||||
rawAmount = "";
|
rawAmount = "";
|
||||||
@@ -65,6 +94,8 @@ function parseTx(tx, addrLower) {
|
|||||||
holders: null,
|
holders: null,
|
||||||
isContractCall: toIsContract,
|
isContractCall: toIsContract,
|
||||||
method: method,
|
method: method,
|
||||||
|
rawInput: rawInput,
|
||||||
|
decoded: decoded,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user