All checks were successful
check / check (push) Successful in 6s
## Summary All address rendering now uses a single `renderAddressHtml()` function in helpers.js that produces consistent output everywhere: - Color dot (deterministic from address) - Full address with dashed-underline click-to-copy affordance - Etherscan external link icon ## Changes Refactored all 9 view files that display addresses to use the shared utility: - **approval.js** (approve-tx, approve-sign, approve-site): addresses now have click-to-copy with dashed underline affordance - **confirmTx.js**: from/to addresses and token contract address use shared renderer - **txStatus.js**: wait/success/error transaction addresses - **transactionDetail.js**: from/to and decoded calldata addresses - **home.js**: active address display - **send.js**: from-address display - **receive.js**: receive address display - **addressDetail.js**: address line and export-privkey address - **addressToken.js**: address line and contract info ## Consolidation - `EXT_ICON` SVG constant: removed 6 duplicates, now in helpers.js - `copyableHtml()`: removed duplicate, now in helpers.js - `etherscanLinkHtml()`: removed duplicates, now in helpers.js - `attachCopyHandlers()`: removed duplicate, now in helpers.js - Net: **-193 lines** (174 added, 367 removed) closes #97 Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: #129 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
375 lines
13 KiB
JavaScript
375 lines
13 KiB
JavaScript
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
flashCopyFeedback,
|
|
balanceLinesForAddress,
|
|
addressDotHtml,
|
|
addressTitle,
|
|
escapeHtml,
|
|
truncateMiddle,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
} = 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,
|
|
resetSendValidation,
|
|
} = require("./send");
|
|
const { log } = require("../../shared/log");
|
|
const makeBlockie = require("ethereum-blockies-base64");
|
|
const { decryptWithPassword } = require("../../shared/vault");
|
|
const { getSignerForAddress } = require("../../shared/wallet");
|
|
|
|
let ctx;
|
|
|
|
function show() {
|
|
state.selectedToken = null;
|
|
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 " + (ai + 1);
|
|
const blockieEl = $("address-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);
|
|
const addrTitle = addressTitle(addr.address, state.wallets);
|
|
$("address-line").innerHTML = renderAddressHtml(addr.address, {
|
|
title: addrTitle,
|
|
ensName: addr.ensName,
|
|
});
|
|
$("address-line").dataset.full = addr.address;
|
|
attachCopyHandlers($("address-line"));
|
|
const usdTotal = formatUsd(getAddressValueUsd(addr));
|
|
$("address-usd-total").innerHTML = usdTotal || " ";
|
|
const ensEl = $("address-ens");
|
|
// ENS is now shown inside renderAddressHtml, hide the separate element
|
|
ensEl.classList.add("hidden");
|
|
$("address-balances").innerHTML = balanceLinesForAddress(
|
|
addr,
|
|
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>';
|
|
showView("address");
|
|
loadTransactions(addr.address);
|
|
}
|
|
|
|
function isoDate(timestamp) {
|
|
const d = new Date(timestamp * 1000);
|
|
const pad = (n) => String(n).padStart(2, "0");
|
|
if (state.utcTimestamps) {
|
|
return (
|
|
d.getUTCFullYear() +
|
|
"-" +
|
|
pad(d.getUTCMonth() + 1) +
|
|
"-" +
|
|
pad(d.getUTCDate()) +
|
|
"T" +
|
|
pad(d.getUTCHours()) +
|
|
":" +
|
|
pad(d.getUTCMinutes()) +
|
|
":" +
|
|
pad(d.getUTCSeconds()) +
|
|
"Z"
|
|
);
|
|
}
|
|
const offsetMin = -d.getTimezoneOffset();
|
|
const sign = offsetMin >= 0 ? "+" : "-";
|
|
const absOff = Math.abs(offsetMin);
|
|
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
|
|
return (
|
|
d.getFullYear() +
|
|
"-" +
|
|
pad(d.getMonth() + 1) +
|
|
"-" +
|
|
pad(d.getDate()) +
|
|
"T" +
|
|
pad(d.getHours()) +
|
|
":" +
|
|
pad(d.getMinutes()) +
|
|
":" +
|
|
pad(d.getSeconds()) +
|
|
tzStr
|
|
);
|
|
}
|
|
|
|
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 ALL unique addresses (from + to) for ENS resolution so
|
|
// that reverse lookups work for every displayed address, not just
|
|
// the ones that were originally entered as ENS names.
|
|
const counterparties = [
|
|
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
|
];
|
|
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) {
|
|
// For swap transactions, show the user's own labelled wallet
|
|
// address instead of the contract address (see issue #55).
|
|
const counterparty =
|
|
tx.direction === "contract" && tx.directionLabel === "Swap"
|
|
? tx.from
|
|
: tx.direction === "sent" || tx.direction === "contract"
|
|
? tx.to
|
|
: tx.from;
|
|
const ensName = ensNameMap.get(counterparty) || null;
|
|
const title = addressTitle(counterparty, state.wallets);
|
|
const dirLabel = tx.directionLabel;
|
|
const amountStr = tx.value
|
|
? escapeHtml(tx.value + " " + tx.symbol)
|
|
: escapeHtml(tx.symbol);
|
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
|
const displayAddr =
|
|
title || 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);
|
|
const tx = loadedTxs[idx];
|
|
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
|
|
tx.fromEns = ensNameMap.get(tx.from) || null;
|
|
tx.toEns = ensNameMap.get(tx.to) || null;
|
|
ctx.showTransactionDetail(tx);
|
|
});
|
|
});
|
|
}
|
|
|
|
function init(_ctx) {
|
|
ctx = _ctx;
|
|
|
|
$("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 = "";
|
|
$("send-token").classList.remove("hidden");
|
|
$("send-token-static").classList.add("hidden");
|
|
updateSendBalance();
|
|
resetSendValidation();
|
|
showView("send");
|
|
});
|
|
|
|
$("btn-receive").addEventListener("click", () => {
|
|
ctx.showReceive();
|
|
});
|
|
|
|
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
|
|
|
|
// More menu dropdown
|
|
const moreBtn = $("btn-more-menu");
|
|
const moreDropdown = $("more-menu-dropdown");
|
|
moreBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const isOpen = !moreDropdown.classList.toggle("hidden");
|
|
moreBtn.classList.toggle("bg-fg", isOpen);
|
|
moreBtn.classList.toggle("text-bg", isOpen);
|
|
});
|
|
document.addEventListener("click", () => {
|
|
moreDropdown.classList.add("hidden");
|
|
moreBtn.classList.remove("bg-fg", "text-bg");
|
|
});
|
|
moreDropdown.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
});
|
|
|
|
$("btn-export-privkey").addEventListener("click", () => {
|
|
moreDropdown.classList.add("hidden");
|
|
moreBtn.classList.remove("bg-fg", "text-bg");
|
|
const wallet = state.wallets[state.selectedWallet];
|
|
const addr = wallet.addresses[state.selectedAddress];
|
|
const blockieEl = $("export-privkey-jazzicon");
|
|
blockieEl.innerHTML = "";
|
|
const bImg = document.createElement("img");
|
|
bImg.src = makeBlockie(addr.address);
|
|
bImg.width = 48;
|
|
bImg.height = 48;
|
|
bImg.style.imageRendering = "pixelated";
|
|
bImg.style.borderRadius = "50%";
|
|
blockieEl.appendChild(bImg);
|
|
$("export-privkey-title").textContent =
|
|
wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
|
|
const exportAddrContainer = $("export-privkey-dot").parentElement;
|
|
exportAddrContainer.innerHTML = renderAddressHtml(addr.address);
|
|
attachCopyHandlers(exportAddrContainer);
|
|
$("export-privkey-password").value = "";
|
|
$("export-privkey-flash").textContent = "";
|
|
$("export-privkey-flash").style.visibility = "hidden";
|
|
$("export-privkey-password-section").classList.remove("hidden");
|
|
$("export-privkey-result").classList.add("hidden");
|
|
$("export-privkey-value").textContent = "";
|
|
showView("export-privkey");
|
|
});
|
|
|
|
$("btn-export-privkey-confirm").addEventListener("click", async () => {
|
|
const password = $("export-privkey-password").value;
|
|
if (!password) {
|
|
$("export-privkey-flash").textContent = "Password is required.";
|
|
$("export-privkey-flash").style.visibility = "visible";
|
|
return;
|
|
}
|
|
const btn = $("btn-export-privkey-confirm");
|
|
btn.disabled = true;
|
|
btn.classList.add("text-muted");
|
|
const wallet = state.wallets[state.selectedWallet];
|
|
try {
|
|
const secret = await decryptWithPassword(
|
|
wallet.encryptedSecret,
|
|
password,
|
|
);
|
|
const signer = getSignerForAddress(
|
|
wallet,
|
|
state.selectedAddress,
|
|
secret,
|
|
);
|
|
const privateKey = signer.privateKey;
|
|
$("export-privkey-password-section").classList.add("hidden");
|
|
$("export-privkey-value").textContent = privateKey;
|
|
$("export-privkey-result").classList.remove("hidden");
|
|
$("export-privkey-flash").style.visibility = "hidden";
|
|
} catch {
|
|
$("export-privkey-flash").textContent = "Wrong password.";
|
|
$("export-privkey-flash").style.visibility = "visible";
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.classList.remove("text-muted");
|
|
}
|
|
});
|
|
|
|
$("export-privkey-value").addEventListener("click", () => {
|
|
const key = $("export-privkey-value").textContent;
|
|
if (key) {
|
|
navigator.clipboard.writeText(key);
|
|
showFlash("Copied!");
|
|
flashCopyFeedback($("export-privkey-value"));
|
|
}
|
|
});
|
|
|
|
$("btn-export-privkey-back").addEventListener("click", () => {
|
|
$("export-privkey-value").textContent = "";
|
|
$("export-privkey-password").value = "";
|
|
show();
|
|
});
|
|
}
|
|
|
|
module.exports = { init, show };
|