Compare commits

...

4 Commits

Author SHA1 Message Date
3fd3e30f44 fix: label swap methods as "Swap" in tx lists, remove unused variable
All checks were successful
check / check (push) Successful in 23s
- Map known DEX methods (execute, swap, multicall, etc.) to "Swap"
  label instead of raw method name like "Execute"
- Remove unused displayData variable in transactionDetail.js

Addresses review feedback on PR #10.
2026-02-27 12:31:25 -08:00
clawbot
76059c3674 fix: display swaps and contract calls correctly in tx history (closes #3)
- Preserve contract call metadata (direction, label, method) when token
  transfers merge with normal txs in fetchRecentTransactions
- Handle 'contract' direction in counterparty display for home and
  address detail list views
- Add decoded calldata display to transaction detail view, fetching
  raw input from Blockscout and using decodeCalldata from approval.js
- Show 'Unknown contract call' with raw hex for unrecognized calldata
- Export decodeCalldata from approval.js for reuse
2026-02-27 12:31:13 -08:00
0ed7b8e61d Merge pull request 'fix: show ERC-20 contract details in address-token view (closes #9)' (#11) from fix/address-token-details into main
All checks were successful
check / check (push) Successful in 8s
Reviewed-on: #11
2026-02-27 21:09:37 +01:00
user
560065dd77 fix: show ERC-20 contract details in address-token view (closes #9)
All checks were successful
check / check (push) Successful in 22s
2026-02-27 12:06:22 -08:00
7 changed files with 168 additions and 5 deletions

View File

@@ -358,6 +358,12 @@
</div>
</div>
<!-- token contract details (ERC-20 only) -->
<div
id="address-token-contract-info"
class="border-b border-border-light pb-2 mb-2 text-xs hidden"
></div>
<!-- actions -->
<div class="flex gap-2 mb-3">
<button
@@ -939,6 +945,15 @@
<div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div>
</div>
<div id="tx-detail-calldata-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">
Contract interaction
</div>
<div
id="tx-detail-calldata"
class="text-xs break-all border border-border border-dashed p-2"
></div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="tx-detail-hash" class="text-xs break-all"></div>

View File

@@ -185,7 +185,10 @@ function renderTransactions(txs) {
let html = "";
let i = 0;
for (const tx of txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const counterparty =
tx.direction === "sent" || tx.direction === "contract"
? tx.to
: tx.from;
const ensName = ensNameMap.get(counterparty) || null;
const dirLabel = tx.directionLabel;
const amountStr = tx.value

View File

@@ -130,6 +130,49 @@ function show() {
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
// Token contract details (ERC-20 only)
const contractInfo = $("address-token-contract-info");
if (tokenId !== "ETH") {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
const tokenName = tb && tb.name ? escapeHtml(tb.name) : null;
const tokenSymbol = tb && tb.symbol ? escapeHtml(tb.symbol) : null;
const tokenDecimals = tb && tb.decimals != null ? tb.decimals : null;
const tokenHolders = tb && tb.holders != null ? tb.holders : null;
const dot = addressDotHtml(tokenId);
const tokenLink = `https://etherscan.io/token/${escapeHtml(tokenId)}`;
let infoHtml =
`<div class="font-bold mb-1">Token Contract</div>` +
`<div class="flex items-center mb-1">${dot}` +
`<span class="break-all underline decoration-dashed cursor-pointer" id="address-token-contract-copy" data-copy="${escapeHtml(tokenId)}">${escapeHtml(tokenId)}</span>` +
`<a href="${tokenLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
`</div>`;
const details = [];
if (tokenName)
details.push(`<span class="text-muted">Name:</span> ${tokenName}`);
if (tokenSymbol)
details.push(
`<span class="text-muted">Symbol:</span> ${tokenSymbol}`,
);
if (tokenDecimals != null)
details.push(
`<span class="text-muted">Decimals:</span> ${tokenDecimals}`,
);
if (tokenHolders != null)
details.push(
`<span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}`,
);
if (details.length > 0) {
infoHtml += `<div class="flex flex-wrap gap-x-3 gap-y-1">${details.join("")}</div>`;
}
contractInfo.innerHTML = infoHtml;
contractInfo.classList.remove("hidden");
} else {
contractInfo.innerHTML = "";
contractInfo.classList.add("hidden");
}
// Transactions
$("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
@@ -252,6 +295,14 @@ function init(_ctx) {
}
});
$("address-token-contract-info").addEventListener("click", (e) => {
const copyEl = e.target.closest("[data-copy]");
if (copyEl) {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
}
});
$("btn-address-token-back").addEventListener("click", () => {
ctx.showAddressDetail();
});

View File

@@ -453,4 +453,4 @@ function init(ctx) {
});
}
module.exports = { init, show };
module.exports = { init, show, decodeCalldata };

View File

@@ -102,7 +102,10 @@ function renderHomeTxList(ctx) {
let html = "";
let i = 0;
for (const tx of homeTxs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const counterparty =
tx.direction === "sent" || tx.direction === "contract"
? tx.to
: tx.from;
const dirLabel = tx.directionLabel;
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)

View File

@@ -13,6 +13,8 @@ const {
} = require("./helpers");
const { state } = require("../../shared/state");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
@@ -85,9 +87,15 @@ function show(tx) {
fromEns: tx.fromEns || null,
toEns: tx.toEns || null,
directionLabel: tx.directionLabel || null,
direction: tx.direction || null,
isContractCall: tx.isContractCall || false,
method: tx.method || null,
},
};
render();
if (tx.isContractCall || tx.direction === "contract") {
loadCalldata(tx.hash, tx.to);
}
}
function render() {
@@ -121,6 +129,10 @@ function render() {
nativeEl.parentElement.classList.add("hidden");
}
// Hide calldata section by default; loadCalldata will show it if needed
const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden");
$("tx-detail-time").textContent =
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
@@ -137,6 +149,62 @@ function render() {
});
}
function renderDecodedCalldata(decoded) {
let html = `<div class="font-bold mb-1">${escapeHtml(decoded.name)}</div>`;
if (decoded.description) {
html += `<div class="text-muted mb-1">${escapeHtml(decoded.description)}</div>`;
}
for (const detail of decoded.details || []) {
html += `<div class="mb-1"><span class="text-muted">${escapeHtml(detail.label)}:</span> `;
if (detail.address) {
const dot = addressDotHtml(detail.address);
html += `${dot}${copyableHtml(detail.value, "break-all")}`;
} else {
html += escapeHtml(detail.value);
}
html += `</div>`;
}
return html;
}
async function loadCalldata(txHash, toAddress) {
const section = $("tx-detail-calldata-section");
const container = $("tx-detail-calldata");
if (!section || !container) return;
try {
const resp = await debugFetch(
state.blockscoutUrl + "/transactions/" + txHash,
);
if (!resp.ok) return;
const txData = await resp.json();
const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return;
const decoded = decodeCalldata(inputData, toAddress || "");
if (decoded) {
container.innerHTML = renderDecodedCalldata(decoded);
} else {
const method = txData.method || "Unknown method";
let html = `<div class="font-bold mb-1">Unknown contract call</div>`;
html += `<div class="text-muted mb-1">${escapeHtml(method)}</div>`;
html += `<div class="break-all font-mono text-xs">${copyableHtml(inputData, "break-all")}</div>`;
container.innerHTML = html;
}
section.classList.remove("hidden");
// Bind copy handlers for new elements
section.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
};
});
} catch (e) {
log.errorf("loadCalldata failed:", e.message);
}
}
function init(_ctx) {
ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => {

View File

@@ -37,7 +37,21 @@ function parseTx(tx, addrLower) {
if (token) {
symbol = token.symbol;
}
const label = method.charAt(0).toUpperCase() + method.slice(1);
// Map known DEX methods to "Swap" for cleaner display
const SWAP_METHODS = new Set([
"execute",
"swap",
"swapExactTokensForTokens",
"swapTokensForExactTokens",
"swapExactETHForTokens",
"swapTokensForExactETH",
"swapExactTokensForETH",
"swapETHForExactTokens",
"multicall",
]);
const label = SWAP_METHODS.has(method)
? "Swap"
: method.charAt(0).toUpperCase() + method.slice(1);
direction = "contract";
directionLabel = label;
value = "";
@@ -139,9 +153,18 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
// When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. Replace the normal tx with the token transfer.
// amount and symbol. Replace the normal tx with the token transfer,
// but preserve contract call metadata (direction, label, method) so
// swaps and other contract interactions display correctly.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
parsed.direction = "contract";
parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true;
parsed.method = existing.method;
}
txsByHash.set(parsed.hash, parsed);
}