A transaction with no `to` showed a blank address, with a colour dot whose colour was `undefined`, on the wait, success and error screens, the transaction detail view and the history rows on Home, AddressDetail and AddressToken. The approval screen showed "(contract creation)". All of them now say "This transaction creates a new contract. It has no recipient." The three history lists draw a row's counterparty lines through one helper in helpers.js. A transaction with a real `to` is unchanged. The new test drives each screen and list both ways. Model: opus-5-5
332 lines
11 KiB
JavaScript
332 lines
11 KiB
JavaScript
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
balanceLinesForAddress,
|
|
txCounterpartyHtml,
|
|
addressTitle,
|
|
escapeHtml,
|
|
displaySymbol,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
goBack,
|
|
pushCurrentView,
|
|
} = require("./helpers");
|
|
const { state, saveState } = require("../../shared/state");
|
|
const { formatAddressTotal, getAddressValue } = 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 exportPrivkey = require("./exportPrivkey");
|
|
const { walletDefect } = require("../../shared/walletDefects");
|
|
|
|
// The defect of the wallet the selected address belongs to, or null. Both the
|
|
// send and the private-key export path check it before asking for a password,
|
|
// so a wallet that cannot derive its keys says so instead of failing after the
|
|
// user has typed one in.
|
|
function selectedWalletDefect() {
|
|
if (state.selectedWallet === null) return null;
|
|
return walletDefect(state.wallets[state.selectedWallet]);
|
|
}
|
|
|
|
let ctx;
|
|
|
|
function show() {
|
|
state.selectedToken = null;
|
|
const wallet = state.wallets[state.selectedWallet];
|
|
const addr = wallet.addresses[state.selectedAddress];
|
|
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 = formatAddressTotal(getAddressValue(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, {
|
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
|
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,
|
|
state.networkId,
|
|
);
|
|
} 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);
|
|
// The explorer's method name for a contract call, title-cased.
|
|
const dirLabel = escapeHtml(tx.directionLabel);
|
|
const sym = displaySymbol(tx.symbol);
|
|
const amountStr = tx.value
|
|
? escapeHtml(tx.value + " " + sym)
|
|
: escapeHtml(sym);
|
|
// The counterparty used to be squeezed in beside the amount and
|
|
// truncated to whatever was left over. It gets its own row now and
|
|
// is shown whole; the title or ENS name, where there is one, names
|
|
// it on the line above rather than replacing it.
|
|
const nameStr = escapeHtml(title || ensName || "");
|
|
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 += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
|
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];
|
|
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", () => {
|
|
goBack();
|
|
});
|
|
|
|
$("btn-send").addEventListener("click", () => {
|
|
const defect = selectedWalletDefect();
|
|
if (defect) {
|
|
showFlash(defect.shortMessage);
|
|
return;
|
|
}
|
|
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();
|
|
pushCurrentView();
|
|
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");
|
|
// There is no private key to export for an address this wallet
|
|
// cannot derive. Without this the export screen would take a
|
|
// password and then report it as wrong.
|
|
const defect = selectedWalletDefect();
|
|
if (defect) {
|
|
showFlash(defect.shortMessage);
|
|
return;
|
|
}
|
|
// No pushCurrentView() here: exportPrivkey.show() can return
|
|
// without navigating, so it does its own push.
|
|
exportPrivkey.show(state.selectedWallet, state.selectedAddress);
|
|
});
|
|
|
|
exportPrivkey.init();
|
|
}
|
|
|
|
module.exports = { init, show };
|