Add address color dots and cached ENS reverse lookups
Some checks failed
check / check (push) Has been cancelled

Deterministic colored dots derived from address bytes (16-color palette)
displayed before every address. ENS reverse resolution for transaction
counterparties with 12-hour localStorage cache.
This commit is contained in:
2026-02-26 03:26:52 +07:00
parent fbff44ade6
commit d28d5a5a51
5 changed files with 155 additions and 11 deletions

View File

@@ -196,11 +196,15 @@
<h2 class="font-bold" id="address-title">Address</h2> <h2 class="font-bold" id="address-title">Address</h2>
</div> </div>
<div id="address-ens" class="font-bold mb-1 hidden"></div>
<div <div
class="flex text-xs mb-3 cursor-pointer" id="address-ens"
class="font-bold mb-1 hidden flex items-center"
></div>
<div
class="flex text-xs mb-3 cursor-pointer items-center"
title="Click to copy" title="Click to copy"
> >
<span id="address-dot"></span>
<span <span
id="address-full" id="address-full"
style="width: 42ch; max-width: 100%" style="width: 42ch; max-width: 100%"

View File

@@ -1,7 +1,14 @@
const { $, showView, showFlash, balanceLinesForAddress } = require("./helpers"); const {
$,
showView,
showFlash,
balanceLinesForAddress,
addressDotHtml,
} = require("./helpers");
const { state, currentAddress } = require("../../shared/state"); const { state, currentAddress } = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
const { fetchRecentTransactions } = require("../../shared/transactions"); const { fetchRecentTransactions } = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { updateSendBalance } = require("./send"); const { updateSendBalance } = require("./send");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const QRCode = require("qrcode"); const QRCode = require("qrcode");
@@ -13,11 +20,13 @@ function show() {
const ai = state.selectedAddress; const ai = state.selectedAddress;
$("address-title").textContent = $("address-title").textContent =
wallet.name + " \u2014 Address " + (wi + 1) + "." + (ai + 1); wallet.name + " \u2014 Address " + (wi + 1) + "." + (ai + 1);
$("address-dot").innerHTML = addressDotHtml(addr.address);
$("address-full").textContent = addr.address; $("address-full").textContent = addr.address;
$("address-usd-total").textContent = formatUsd(getAddressValueUsd(addr)); $("address-usd-total").textContent = formatUsd(getAddressValueUsd(addr));
const ensEl = $("address-ens"); const ensEl = $("address-ens");
if (addr.ensName) { if (addr.ensName) {
ensEl.textContent = addr.ensName; ensEl.innerHTML =
addressDotHtml(addr.address) + escapeHtml(addr.ensName);
ensEl.classList.remove("hidden"); ensEl.classList.remove("hidden");
} else { } else {
ensEl.classList.add("hidden"); ensEl.classList.add("hidden");
@@ -80,10 +89,30 @@ function escapeHtml(s) {
let loadedTxs = []; let loadedTxs = [];
let ensNameMap = new Map();
async function loadTransactions(address) { async function loadTransactions(address) {
try { try {
const txs = await fetchRecentTransactions(address, state.blockscoutUrl); const txs = await fetchRecentTransactions(address, state.blockscoutUrl);
loadedTxs = txs; 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); renderTransactions(txs);
} catch (e) { } catch (e) {
log.errorf("loadTransactions failed:", e.message); log.errorf("loadTransactions failed:", e.message);
@@ -103,17 +132,20 @@ function renderTransactions(txs) {
let i = 0; let i = 0;
for (const tx of txs) { for (const tx of txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from; const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const ensName = ensNameMap.get(counterparty) || null;
const dirLabel = tx.direction === "sent" ? "Sent" : "Received"; const dirLabel = tx.direction === "sent" ? "Sent" : "Received";
const amountStr = escapeHtml(tx.value + " " + tx.symbol); const amountStr = escapeHtml(tx.value + " " + tx.symbol);
const maxAddr = Math.max(10, 38 - Math.max(0, amountStr.length - 10)); const maxAddr = Math.max(10, 38 - Math.max(0, amountStr.length - 10));
const addrStr = escapeHtml(truncateMiddle(counterparty, maxAddr)); const displayAddr = ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : ""; const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp)); const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(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="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="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span>${addrStr}</span><span>${amountStr}</span></div>`; html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
html += `</div>`; html += `</div>`;
i++; i++;
} }
@@ -126,10 +158,23 @@ function renderTransactions(txs) {
}); });
} }
function txDetailAddressHtml(address) {
const ensName = ensNameMap.get(address) || null;
const dot = addressDotHtml(address);
if (ensName) {
return (
dot +
escapeHtml(ensName) +
`<div class="break-all">${escapeHtml(address)}</div>`
);
}
return dot + escapeHtml(address);
}
function showTxDetail(tx) { function showTxDetail(tx) {
$("tx-detail-hash").textContent = tx.hash; $("tx-detail-hash").textContent = tx.hash;
$("tx-detail-from").textContent = tx.from; $("tx-detail-from").innerHTML = txDetailAddressHtml(tx.from);
$("tx-detail-to").textContent = tx.to; $("tx-detail-to").innerHTML = txDetailAddressHtml(tx.to);
$("tx-detail-value").textContent = tx.value + " " + tx.symbol; $("tx-detail-value").textContent = tx.value + " " + tx.symbol;
$("tx-detail-time").textContent = $("tx-detail-time").textContent =
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")"; isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";

View File

@@ -99,6 +99,35 @@ function balanceLinesForAddress(addr) {
return html; return html;
} }
const ADDRESS_COLORS = [
"#e6194b",
"#3cb44b",
"#4363d8",
"#f58231",
"#911eb4",
"#42d4f4",
"#f032e6",
"#bfef45",
"#fabed4",
"#469990",
"#dcbeff",
"#9a6324",
"#800000",
"#aaffc3",
"#808000",
"#000075",
];
function addressColor(address) {
const idx = parseInt(address.slice(2, 6), 16) % 16;
return ADDRESS_COLORS[idx];
}
function addressDotHtml(address) {
const color = addressColor(address);
return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`;
}
module.exports = { module.exports = {
$, $,
showError, showError,
@@ -106,4 +135,6 @@ module.exports = {
showView, showView,
showFlash, showFlash,
balanceLinesForAddress, balanceLinesForAddress,
addressColor,
addressDotHtml,
}; };

View File

@@ -1,4 +1,10 @@
const { $, showView, showFlash, balanceLinesForAddress } = require("./helpers"); const {
$,
showView,
showFlash,
balanceLinesForAddress,
addressDotHtml,
} = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { deriveAddressFromXpub } = require("../../shared/wallet"); const { deriveAddressFromXpub } = require("../../shared/wallet");
const { const {
@@ -35,12 +41,13 @@ function render(ctx) {
wallet.addresses.forEach((addr, ai) => { wallet.addresses.forEach((addr, ai) => {
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`; html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
html += `<div class="text-xs font-bold">Address ${wi + 1}.${ai + 1}</div>`; html += `<div class="text-xs font-bold">Address ${wi + 1}.${ai + 1}</div>`;
const dot = addressDotHtml(addr.address);
if (addr.ensName) { if (addr.ensName) {
html += `<div class="text-xs font-bold">${addr.ensName}</div>`; html += `<div class="text-xs font-bold flex items-center">${dot}${addr.ensName}</div>`;
} }
const addrUsd = formatUsd(getAddressValueUsd(addr)); const addrUsd = formatUsd(getAddressValueUsd(addr));
html += `<div class="flex text-xs">`; html += `<div class="flex text-xs">`;
html += `<span style="width:42ch;max-width:100%">${addr.address}</span>`; html += `<span class="flex items-center" style="width:42ch;max-width:100%">${addr.ensName ? "" : dot}${addr.address}</span>`;
html += `<span class="text-right text-muted flex-1">${addrUsd}</span>`; html += `<span class="text-right text-muted flex-1">${addrUsd}</span>`;
html += `</div>`; html += `</div>`;
html += balanceLinesForAddress(addr); html += balanceLinesForAddress(addr);

57
src/shared/ens.js Normal file
View File

@@ -0,0 +1,57 @@
// Cached ENS reverse resolution.
// Resolves addresses to ENS names via ethers provider.lookupAddress(),
// caching results in localStorage with a 12-hour TTL.
const { getProvider } = require("./balances");
const { log } = require("./log");
const CACHE_TTL_MS = 43200000; // 12 hours
const CACHE_PREFIX = "ens:";
function getCached(address) {
const key = CACHE_PREFIX + address.toLowerCase();
try {
const raw = localStorage.getItem(key);
if (!raw) return undefined;
const entry = JSON.parse(raw);
if (Date.now() - entry.ts < CACHE_TTL_MS) {
return entry.name;
}
} catch {
// Corrupt cache entry — treat as miss.
}
return undefined;
}
function setCache(address, name) {
const key = CACHE_PREFIX + address.toLowerCase();
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
}
async function resolveEnsName(address, rpcUrl) {
const cached = getCached(address);
if (cached !== undefined) return cached;
const provider = getProvider(rpcUrl);
try {
const name = (await provider.lookupAddress(address)) || null;
setCache(address, name);
return name;
} catch (e) {
log.errorf("ENS reverse lookup failed", address, e.message);
setCache(address, null);
return null;
}
}
async function resolveEnsNames(addresses, rpcUrl) {
const results = new Map();
await Promise.all(
addresses.map(async (addr) => {
results.set(addr, await resolveEnsName(addr, rpcUrl));
}),
);
return results;
}
module.exports = { resolveEnsName, resolveEnsNames };