Add address-token detail view for per-token transaction filtering
All checks were successful
check / check (push) Successful in 17s
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:
370
src/popup/views/addressToken.js
Normal file
370
src/popup/views/addressToken.js
Normal 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 };
|
||||
Reference in New Issue
Block a user