Some checks failed
check / check (push) Has been cancelled
Add showZeroBalanceTokens setting (default: on). When enabled, balanceLinesForAddress merges state.trackedTokens with the address's tokenBalances, showing 0.0000 lines for tracked tokens that have no balance on that address. This gives users visibility into all tokens they're watching across all addresses.
300 lines
10 KiB
JavaScript
300 lines
10 KiB
JavaScript
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
balanceLinesForAddress,
|
|
addressDotHtml,
|
|
escapeHtml,
|
|
formatAddressHtml,
|
|
truncateMiddle,
|
|
} = require("./helpers");
|
|
const { state, currentAddress, saveState } = require("../../shared/state");
|
|
const { formatUsd, 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");
|
|
|
|
function show() {
|
|
const wallet = state.wallets[state.selectedWallet];
|
|
const addr = wallet.addresses[state.selectedAddress];
|
|
const wi = state.selectedWallet;
|
|
const ai = state.selectedAddress;
|
|
$("address-title").textContent =
|
|
wallet.name + " \u2014 Address " + (wi + 1) + "." + (ai + 1);
|
|
$("address-dot").innerHTML = addressDotHtml(addr.address);
|
|
$("address-full").dataset.full = addr.address;
|
|
$("address-full").textContent = addr.address;
|
|
$("address-usd-total").textContent = formatUsd(getAddressValueUsd(addr));
|
|
const ensEl = $("address-ens");
|
|
if (addr.ensName) {
|
|
ensEl.innerHTML =
|
|
addressDotHtml(addr.address) + escapeHtml(addr.ensName);
|
|
ensEl.classList.remove("hidden");
|
|
} else {
|
|
ensEl.classList.add("hidden");
|
|
}
|
|
$("address-balances").innerHTML = balanceLinesForAddress(
|
|
addr,
|
|
state.trackedTokens,
|
|
state.showZeroBalanceTokens,
|
|
);
|
|
renderSendTokenSelect(addr);
|
|
$("tx-list").innerHTML =
|
|
'<div class="text-muted text-xs py-1">Loading...</div>';
|
|
showView("address");
|
|
loadTransactions(addr.address);
|
|
}
|
|
|
|
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();
|
|
|
|
async function loadTransactions(address) {
|
|
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,
|
|
});
|
|
const 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();
|
|
}
|
|
|
|
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);
|
|
$("tx-list").innerHTML =
|
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
|
}
|
|
}
|
|
|
|
function renderTransactions(txs) {
|
|
const list = $("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 etherscanAddressLink(address) {
|
|
return `https://etherscan.io/address/${address}`;
|
|
}
|
|
|
|
function etherscanTxLink(hash) {
|
|
return `https://etherscan.io/tx/${hash}`;
|
|
}
|
|
|
|
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 copyableHtml(text, extraClass) {
|
|
const cls =
|
|
"underline decoration-dashed cursor-pointer" +
|
|
(extraClass ? " " + extraClass : "");
|
|
return `<span class="${cls}" data-copy="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
|
|
}
|
|
|
|
function txDetailAddressHtml(address) {
|
|
const ensName = ensNameMap.get(address) || null;
|
|
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>`;
|
|
if (ensName) {
|
|
return (
|
|
dot +
|
|
copyableHtml(ensName, "") +
|
|
extLink +
|
|
`<div class="break-all">` +
|
|
copyableHtml(address, "break-all") +
|
|
`</div>`
|
|
);
|
|
}
|
|
return dot + copyableHtml(address, "break-all") + extLink;
|
|
}
|
|
|
|
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-full").addEventListener("click", () => {
|
|
const addr = $("address-full").dataset.full;
|
|
if (addr) {
|
|
navigator.clipboard.writeText(addr);
|
|
showFlash("Copied!");
|
|
}
|
|
});
|
|
|
|
$("btn-address-back").addEventListener("click", () => {
|
|
ctx.renderWalletList();
|
|
showView("main");
|
|
});
|
|
|
|
$("btn-send").addEventListener("click", () => {
|
|
const addr =
|
|
state.wallets[state.selectedWallet].addresses[
|
|
state.selectedAddress
|
|
];
|
|
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
|
showFlash("Cannot send \u2014 zero balance.");
|
|
return;
|
|
}
|
|
$("send-to").value = "";
|
|
$("send-amount").value = "";
|
|
updateSendBalance();
|
|
showView("send");
|
|
});
|
|
|
|
$("btn-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");
|
|
});
|
|
|
|
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
|
|
|
|
$("btn-tx-back").addEventListener("click", () => {
|
|
show();
|
|
});
|
|
}
|
|
|
|
module.exports = { init, show };
|