Add address-token detail view for per-token transaction filtering
All checks were successful
check / check (push) Successful in 17s

Clicking a token balance on the address detail view navigates to a
focused view showing only that token's transactions. Send pre-selects
and locks the token dropdown, Receive shows an ERC-20 warning for
non-ETH tokens, and all back buttons return to the correct parent view.
This commit is contained in:
2026-02-27 11:26:59 +07:00
parent a5b2470dba
commit 21fe854fa4
9 changed files with 503 additions and 9 deletions

View File

@@ -314,6 +314,75 @@
</div>
</div>
<!-- ============ ADDRESS-TOKEN DETAIL VIEW ============ -->
<div id="view-address-token" class="view hidden">
<button
id="btn-address-token-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<div
id="address-token-jazzicon"
class="flex justify-center mt-1 mb-3"
></div>
<div
class="flex justify-between items-center border-b border-border pb-1 mb-2"
>
<h2 class="font-bold" id="address-token-title">Token</h2>
</div>
<div
class="text-xs mb-1 cursor-pointer break-all"
title="Click to copy"
id="address-token-line"
>
<span id="address-token-dot"></span>
<span id="address-token-full"></span>
<span id="address-token-etherscan-link"></span>
</div>
<div
id="address-token-usd-total"
class="text-xs text-muted mb-3 text-right"
></div>
<!-- single token balance -->
<div class="border-b border-border-light pb-2 mb-2">
<div id="address-token-balance"></div>
</div>
<!-- actions -->
<div class="flex gap-2 mb-3">
<button
id="btn-address-token-send"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer flex-1"
>
Send
</button>
<button
id="btn-address-token-receive"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer flex-1"
>
Receive
</button>
</div>
<!-- ERC-20 warning -->
<div
id="address-token-erc20-warning"
class="text-xs border border-border border-dashed p-2 mb-3 hidden"
></div>
<!-- token-filtered transactions -->
<div class="mt-3">
<div class="border-b border-border pb-1 mb-1">
<h2 class="font-bold">Transactions</h2>
</div>
<div id="address-token-tx-list" class="overflow-hidden">
<div class="text-muted text-xs py-1">Loading...</div>
</div>
</div>
</div>
<!-- ============ SEND ============ -->
<div id="view-send" class="view hidden">
<button

View File

@@ -12,6 +12,7 @@ const welcome = require("./views/welcome");
const addWallet = require("./views/addWallet");
const importKey = require("./views/importKey");
const addressDetail = require("./views/addressDetail");
const addressToken = require("./views/addressToken");
const send = require("./views/send");
const confirmTx = require("./views/confirmTx");
const receive = require("./views/receive");
@@ -47,6 +48,7 @@ const ctx = {
showAddWalletView: () => addWallet.show(),
showImportKeyView: () => importKey.show(),
showAddressDetail: () => addressDetail.show(),
showAddressToken: () => addressToken.show(),
showAddTokenView: () => addToken.show(),
showConfirmTx: (txInfo) => confirmTx.show(txInfo),
};
@@ -106,6 +108,7 @@ async function init() {
importKey.init(ctx);
home.init(ctx);
addressDetail.init(ctx);
addressToken.init(ctx);
send.init(ctx);
confirmTx.init(ctx);
receive.init(ctx);

View File

@@ -20,6 +20,8 @@ const { log } = require("../../shared/log");
const QRCode = require("qrcode");
const makeBlockie = require("ethereum-blockies-base64");
let ctx;
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
@@ -36,6 +38,7 @@ function etherscanTxLink(hash) {
}
function show() {
state.selectedToken = null;
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];
const wi = state.selectedWallet;
@@ -71,6 +74,14 @@ function show() {
state.trackedTokens,
state.showZeroBalanceTokens,
);
$("address-balances")
.querySelectorAll(".balance-row")
.forEach((row) => {
row.addEventListener("click", () => {
state.selectedToken = row.dataset.token;
ctx.showAddressToken();
});
});
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
@@ -272,7 +283,8 @@ function showTxDetail(tx) {
});
}
function init(ctx) {
function init(_ctx) {
ctx = _ctx;
$("address-full").addEventListener("click", () => {
const addr = $("address-full").dataset.full;
if (addr) {
@@ -297,6 +309,7 @@ function init(ctx) {
}
$("send-to").value = "";
$("send-amount").value = "";
$("send-token").disabled = false;
updateSendBalance();
showView("send");
});
@@ -318,7 +331,11 @@ function init(ctx) {
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
$("btn-tx-back").addEventListener("click", () => {
if (state.selectedToken) {
ctx.showAddressToken();
} else {
show();
}
});
}

View File

@@ -0,0 +1,370 @@
// Address-token detail view: shows a single token's balance and
// filtered transactions for the selected address.
const {
$,
showView,
showFlash,
addressDotHtml,
escapeHtml,
truncateMiddle,
balanceLine,
} = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state");
const {
formatUsd,
getPrice,
getAddressValueUsd,
} = require("../../shared/prices");
const {
fetchRecentTransactions,
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { updateSendBalance, renderSendTokenSelect } = require("./send");
const { log } = require("../../shared/log");
const QRCode = require("qrcode");
const makeBlockie = require("ethereum-blockies-base64");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function etherscanAddressLink(address) {
return `https://etherscan.io/address/${address}`;
}
function etherscanTxLink(hash) {
return `https://etherscan.io/tx/${hash}`;
}
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
let loadedTxs = [];
let ensNameMap = new Map();
function show() {
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];
const ai = state.selectedAddress;
const tokenId = state.selectedToken;
// Determine token symbol and balance
let symbol, amount, price;
if (tokenId === "ETH") {
symbol = "ETH";
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
symbol = tb ? tb.symbol : "?";
amount = tb ? parseFloat(tb.balance || "0") : 0;
price = getPrice(symbol);
}
$("address-token-title").textContent =
wallet.name + " \u2014 Address " + (ai + 1) + " \u2014 " + symbol;
// Blockie
const blockieEl = $("address-token-jazzicon");
blockieEl.innerHTML = "";
const img = document.createElement("img");
img.src = makeBlockie(addr.address);
img.width = 48;
img.height = 48;
img.style.imageRendering = "pixelated";
img.style.borderRadius = "50%";
blockieEl.appendChild(img);
// Address line
$("address-token-dot").innerHTML = addressDotHtml(addr.address);
$("address-token-full").dataset.full = addr.address;
$("address-token-full").textContent = addr.address;
const addrLink = etherscanAddressLink(addr.address);
$("address-token-etherscan-link").innerHTML =
`<a href="${addrLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
// USD total for this token only
const usdVal = price ? amount * price : 0;
$("address-token-usd-total").textContent = formatUsd(usdVal);
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
// ERC-20 warning
const warningEl = $("address-token-erc20-warning");
if (tokenId !== "ETH") {
warningEl.textContent =
"This is an ERC-20 token. Only send " +
symbol +
" on the Ethereum network to this address. Sending tokens on other networks will result in permanent loss.";
warningEl.classList.remove("hidden");
} else {
warningEl.classList.add("hidden");
}
// Transactions
$("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address-token");
loadTransactions(addr.address, tokenId);
}
async function loadTransactions(address, tokenId) {
try {
const rawTxs = await fetchRecentTransactions(
address,
state.blockscoutUrl,
);
const result = filterTransactions(rawTxs, {
hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions,
dustThresholdGwei: state.dustThresholdGwei,
fraudContracts: state.fraudContracts,
});
let txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
// Filter to this token only
if (tokenId === "ETH") {
txs = txs.filter((tx) => tx.contractAddress === null);
} else {
txs = txs.filter(
(tx) =>
tx.contractAddress &&
tx.contractAddress.toLowerCase() === tokenId.toLowerCase(),
);
}
loadedTxs = txs;
// Collect unique counterparty addresses for ENS resolution
const counterparties = [
...new Set(
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
),
];
if (counterparties.length > 0) {
try {
ensNameMap = await resolveEnsNames(
counterparties,
state.rpcUrl,
);
} catch {
ensNameMap = new Map();
}
}
renderTransactions(txs);
} catch (e) {
log.errorf("loadTransactions failed:", e.message);
$("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
}
}
function renderTransactions(txs) {
const list = $("address-token-tx-list");
if (txs.length === 0) {
list.innerHTML =
'<div class="text-muted text-xs py-1">No transactions found.</div>';
return;
}
let html = "";
let i = 0;
for (const tx of txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const ensName = ensNameMap.get(counterparty) || null;
const dirLabel = tx.direction === "sent" ? "Sent" : "Received";
const amountStr = escapeHtml(tx.value + " " + tx.symbol);
const maxAddr = Math.max(10, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
html += `</div>`;
i++;
}
list.innerHTML = html;
list.querySelectorAll(".tx-row").forEach((row) => {
row.addEventListener("click", () => {
const idx = parseInt(row.dataset.tx, 10);
showTxDetail(loadedTxs[idx]);
});
});
}
function copyableHtml(text, extraClass) {
const cls =
"underline decoration-dashed cursor-pointer" +
(extraClass ? " " + extraClass : "");
return `<span class="${cls}" data-copy="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
}
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function txDetailAddressHtml(address) {
const ensName = ensNameMap.get(address) || null;
const blockie = blockieHtml(address);
const dot = addressDotHtml(address);
const link = etherscanAddressLink(address);
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
let html = `<div class="mb-1">${blockie}</div>`;
if (ensName) {
html +=
`<div class="flex items-center">${dot}` +
copyableHtml(ensName, "") +
extLink +
`</div>` +
`<div class="break-all">` +
copyableHtml(address, "break-all") +
`</div>`;
} else {
html +=
`<div class="flex items-center">${dot}` +
copyableHtml(address, "break-all") +
extLink +
`</div>`;
}
return html;
}
function txDetailHashHtml(hash) {
const link = etherscanTxLink(hash);
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
return copyableHtml(hash, "break-all") + extLink;
}
function showTxDetail(tx) {
$("tx-detail-hash").innerHTML = txDetailHashHtml(tx.hash);
$("tx-detail-from").innerHTML = txDetailAddressHtml(tx.from);
$("tx-detail-to").innerHTML = txDetailAddressHtml(tx.to);
$("tx-detail-value").textContent = tx.value + " " + tx.symbol;
$("tx-detail-time").textContent =
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction");
// Attach copy handlers
document
.getElementById("view-transaction")
.querySelectorAll("[data-copy]")
.forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
};
});
}
function init(ctx) {
$("address-token-full").addEventListener("click", () => {
const addr = $("address-token-full").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
}
});
$("btn-address-token-back").addEventListener("click", () => {
ctx.showAddressDetail();
});
$("btn-address-token-send").addEventListener("click", () => {
const addr =
state.wallets[state.selectedWallet].addresses[
state.selectedAddress
];
if (!addr.balance || parseFloat(addr.balance) === 0) {
if (state.selectedToken === "ETH") {
showFlash("Cannot send \u2014 zero balance.");
return;
}
}
renderSendTokenSelect(addr);
$("send-to").value = "";
$("send-amount").value = "";
const tokenId = state.selectedToken;
if (tokenId === "ETH") {
$("send-token").value = "ETH";
} else {
$("send-token").value = tokenId;
}
$("send-token").disabled = true;
updateSendBalance();
showView("send");
});
$("btn-address-token-receive").addEventListener("click", () => {
const addr = currentAddress();
const address = addr ? addr.address : "";
$("receive-address").textContent = address;
if (address) {
QRCode.toCanvas($("receive-qr"), address, {
width: 200,
margin: 2,
color: { dark: "#000000", light: "#ffffff" },
});
}
showView("receive");
});
}
module.exports = { init, show };

View File

@@ -13,6 +13,7 @@ const VIEWS = [
"import-key",
"main",
"address",
"address-token",
"send",
"confirm-tx",
"receive",
@@ -72,11 +73,15 @@ function showFlash(msg, duration = 2000) {
}, duration);
}
function balanceLine(symbol, amount, price) {
function balanceLine(symbol, amount, price, tokenId) {
const qty = amount.toFixed(4);
const usd = price ? formatUsd(amount * price) : "";
const tokenAttr = tokenId ? ` data-token="${tokenId}"` : "";
const clickClass = tokenId
? " cursor-pointer hover:bg-hover balance-row"
: "";
return (
`<div class="flex text-xs">` +
`<div class="flex text-xs${clickClass}"${tokenAttr}>` +
`<span class="flex justify-between" style="width:42ch;max-width:100%">` +
`<span>${symbol}</span>` +
`<span>${qty}</span>` +
@@ -91,18 +96,29 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
"ETH",
parseFloat(addr.balance || "0"),
getPrice("ETH"),
"ETH",
);
const seen = new Set();
for (const t of addr.tokenBalances || []) {
const bal = parseFloat(t.balance || "0");
if (bal === 0 && !showZero) continue;
html += balanceLine(t.symbol, bal, getPrice(t.symbol));
html += balanceLine(
t.symbol,
bal,
getPrice(t.symbol),
t.address.toLowerCase(),
);
seen.add(t.address.toLowerCase());
}
if (showZero && trackedTokens) {
for (const t of trackedTokens) {
if (seen.has(t.address.toLowerCase())) continue;
html += balanceLine(t.symbol, 0, getPrice(t.symbol));
html += balanceLine(
t.symbol,
0,
getPrice(t.symbol),
t.address.toLowerCase(),
);
}
}
return html;
@@ -193,6 +209,7 @@ module.exports = {
hideError,
showView,
showFlash,
balanceLine,
balanceLinesForAddress,
addressColor,
addressDotHtml,

View File

@@ -403,6 +403,7 @@ function init(ctx) {
}
$("send-to").value = "";
$("send-amount").value = "";
$("send-token").disabled = false;
renderSendTokenSelect(addr);
updateSendBalance();
showView("send");

View File

@@ -1,4 +1,5 @@
const { $, showFlash } = require("./helpers");
const { state } = require("../../shared/state");
function init(ctx) {
$("btn-receive-copy").addEventListener("click", () => {
@@ -9,7 +10,13 @@ function init(ctx) {
}
});
$("btn-receive-back").addEventListener("click", ctx.showAddressDetail);
$("btn-receive-back").addEventListener("click", () => {
if (state.selectedToken) {
ctx.showAddressToken();
} else {
ctx.showAddressDetail();
}
});
}
module.exports = { init };

View File

@@ -2,6 +2,7 @@
const { $, showFlash, formatAddressHtml } = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
let ctx;
const { getProvider } = require("../../shared/balances");
const { KNOWN_SYMBOLS } = require("../../shared/tokens");
@@ -53,7 +54,8 @@ function updateSendBalance() {
}
}
function init(ctx) {
function init(_ctx) {
ctx = _ctx;
$("send-token").addEventListener("change", updateSendBalance);
$("btn-send-review").addEventListener("click", async () => {
@@ -100,7 +102,14 @@ function init(ctx) {
});
});
$("btn-send-back").addEventListener("click", ctx.showAddressDetail);
$("btn-send-back").addEventListener("click", () => {
$("send-token").disabled = false;
if (state.selectedToken) {
ctx.showAddressToken();
} else {
ctx.showAddressDetail();
}
});
}
module.exports = { init, updateSendBalance, renderSendTokenSelect };

View File

@@ -31,6 +31,7 @@ const state = {
...DEFAULT_STATE,
selectedWallet: null,
selectedAddress: null,
selectedToken: null,
};
async function saveState() {