Token auto-discovery, tx history, balance polling, EIP-6963, UI overhaul
All checks were successful
check / check (push) Successful in 14s

Major changes:
- Fetch token balances and tx history from Blockscout API (configurable)
- Remove manual token discovery (discoverTokens) in favor of Blockscout
- HD address gap scanning on mnemonic import
- Duplicate mnemonic detection on wallet add
- EIP-6963 multi-wallet discovery + selectedAddress updates in inpage
- Two-tier balance refresh: 10s while popup open, 60s background
- Fix $0.00 flash before prices load (return null when no prices)
- No-layout-shift: min-height on total value element
- Aligned balance columns (42ch address width, consistent USD column)
- All errors use flash messages instead of off-screen error divs
- Settings gear in global title bar, add-wallet moved to settings pane
- Settings wells with light grey background, configurable Blockscout URL
- Consistent "< Back" buttons top-left on all views
- Address titles (Address 1.1, 1.2, etc.) on main and detail views
- Send view shows current balance of selected asset
- Clickable affordance policy added to README
- Shortened mnemonic backup warning
- Fix broken background script constant imports
This commit is contained in:
2026-02-26 02:13:39 +07:00
parent 2b2137716c
commit 3bd2b58543
27 changed files with 978 additions and 420 deletions

View File

@@ -1,16 +1,13 @@
const { $, showError, hideError, showView } = require("./helpers");
const { $, showView, showFlash } = require("./helpers");
const { TOKENS } = require("../../shared/tokens");
const { state, saveState } = require("../../shared/state");
const {
lookupTokenInfo,
invalidateBalanceCache,
refreshBalances,
} = require("../../shared/balances");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log");
function show() {
$("add-token-address").value = "";
$("add-token-info").classList.add("hidden");
hideError("add-token-error");
const list = $("common-token-list");
list.innerHTML = TOKENS.slice(0, 25)
.map(
@@ -30,8 +27,7 @@ function init(ctx) {
$("btn-add-token-confirm").addEventListener("click", async () => {
const contractAddr = $("add-token-address").value.trim();
if (!contractAddr || !contractAddr.startsWith("0x")) {
showError(
"add-token-error",
showFlash(
"Please enter a valid contract address starting with 0x.",
);
return;
@@ -40,18 +36,20 @@ function init(ctx) {
(t) => t.address.toLowerCase() === contractAddr.toLowerCase(),
);
if (already) {
showError(
"add-token-error",
already.symbol + " is already being tracked.",
);
showFlash(already.symbol + " is already being tracked.");
return;
}
if (isScamAddress(contractAddr)) {
showFlash("This address is on a known scam/fraud list.");
return;
}
hideError("add-token-error");
const infoEl = $("add-token-info");
infoEl.textContent = "Looking up token...";
infoEl.classList.remove("hidden");
log.debugf("Looking up token contract", contractAddr);
try {
const info = await lookupTokenInfo(contractAddr, state.rpcUrl);
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
address: contractAddr,
symbol: info.symbol,
@@ -59,19 +57,12 @@ function init(ctx) {
name: info.name,
});
await saveState();
invalidateBalanceCache();
await refreshBalances(
state.wallets,
state.trackedTokens,
state.rpcUrl,
);
await saveState();
ctx.doRefreshAndRender();
ctx.showAddressDetail();
} catch (e) {
showError(
"add-token-error",
"Could not read token contract. Check the address.",
);
const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", contractAddr, detail);
showFlash(detail);
infoEl.classList.add("hidden");
}
});

View File

@@ -1,4 +1,4 @@
const { $, showError, hideError, showView } = require("./helpers");
const { $, showView, showFlash } = require("./helpers");
const {
generateMnemonic,
hdWalletFromMnemonic,
@@ -6,13 +6,13 @@ const {
} = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances");
function show() {
$("wallet-mnemonic").value = "";
$("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").classList.add("hidden");
hideError("add-wallet-error");
showView("add-wallet");
}
@@ -25,16 +25,14 @@ function init(ctx) {
$("btn-add-wallet-confirm").addEventListener("click", async () => {
const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) {
showError(
"add-wallet-error",
"Please enter a recovery phrase or press the die to generate one.",
showFlash(
"Enter a recovery phrase or press the die to generate one.",
);
return;
}
const words = mnemonic.split(/\s+/);
if (words.length !== 12 && words.length !== 24) {
showError(
"add-wallet-error",
showFlash(
"Recovery phrase must be 12 or 24 words. You entered " +
words.length +
".",
@@ -42,34 +40,42 @@ function init(ctx) {
return;
}
if (!isValidMnemonic(mnemonic)) {
showError(
"add-wallet-error",
"Invalid recovery phrase. Please check for typos.",
);
showFlash("Invalid recovery phrase. Check for typos.");
return;
}
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showError("add-wallet-error", "Please choose a password.");
showFlash("Please choose a password.");
return;
}
if (pw.length < 8) {
showError(
"add-wallet-error",
"Password must be at least 8 characters.",
);
showFlash("Password must be at least 8 characters.");
return;
}
if (pw !== pw2) {
showError("add-wallet-error", "Passwords do not match.");
showFlash("Passwords do not match.");
return;
}
hideError("add-wallet-error");
const encrypted = await encryptWithPassword(mnemonic, pw);
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const duplicate = state.wallets.find(
(w) =>
w.type === "hd" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() ===
firstAddress.toLowerCase(),
);
if (duplicate) {
showFlash(
"This recovery phrase is already added (" +
duplicate.name +
").",
);
return;
}
const encrypted = await encryptWithPassword(mnemonic, pw);
const walletNum = state.wallets.length + 1;
state.wallets.push({
const wallet = {
type: "hd",
name: "Wallet " + walletNum,
xpub: xpub,
@@ -78,11 +84,28 @@ function init(ctx) {
addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
});
};
state.wallets.push(wallet);
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
balance: "0.0000",
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
await saveState();
ctx.renderWalletList();
}
ctx.doRefreshAndRender();
});
$("btn-add-wallet-back").addEventListener("click", () => {

View File

@@ -1,16 +1,20 @@
const { $, showView } = require("./helpers");
const { $, showView, showFlash, balanceLinesForAddress } = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
const { fetchRecentTransactions } = require("../../shared/transactions");
const { updateSendBalance } = 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];
$("address-title").textContent = wallet.name;
const wi = state.selectedWallet;
const ai = state.selectedAddress;
$("address-title").textContent =
wallet.name + " \u2014 Address " + (wi + 1) + "." + (ai + 1);
$("address-full").textContent = addr.address;
$("address-copied-msg").textContent = "";
$("address-eth-balance").textContent = addr.balance;
$("address-usd-value").textContent = formatUsd(getAddressValueUsd(addr));
$("address-usd-total").textContent = formatUsd(getAddressValueUsd(addr));
const ensEl = $("address-ens");
if (addr.ensName) {
ensEl.textContent = addr.ensName;
@@ -18,28 +22,71 @@ function show() {
} else {
ensEl.classList.add("hidden");
}
renderTokenList(addr);
$("address-balances").innerHTML = balanceLinesForAddress(addr);
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
function renderTokenList(addr) {
const list = $("token-list");
const balances = addr.tokenBalances || [];
if (balances.length === 0 && state.trackedTokens.length === 0) {
function formatDate(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())
);
}
function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
async function loadTransactions(address) {
try {
const txs = await fetchRecentTransactions(address, state.blockscoutUrl);
renderTransactions(txs, address);
} 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, address) {
const list = $("tx-list");
if (txs.length === 0) {
list.innerHTML =
'<div class="text-muted text-xs py-1">No tokens added yet. Use "+ Add" to track a token.</div>';
'<div class="text-muted text-xs py-1">No transactions found.</div>';
return;
}
list.innerHTML = balances
.map(
(t) =>
`<div class="py-1 border-b border-border-light flex justify-between">` +
`<span>${t.symbol}</span>` +
`<span>${t.balance || "0"}</span>` +
`</div>`,
)
.join("");
const addrLower = address.toLowerCase();
let html = "";
for (const tx of txs) {
const arrow = tx.direction === "sent" ? "\u2192" : "\u2190";
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const label = tx.direction === "sent" ? "to" : "from";
const errorClass = tx.isError ? ' style="opacity:0.5"' : "";
const errorTag = tx.isError
? ' <span class="text-muted">[failed]</span>'
: "";
html += `<div class="py-1 border-b border-border-light text-xs"${errorClass}>`;
html += `<div>${formatDate(tx.timestamp)} ${arrow} ${escapeHtml(tx.value)} ${escapeHtml(tx.symbol)}${errorTag}</div>`;
html += `<div class="text-muted break-all">${label}: ${escapeHtml(counterparty)}</div>`;
html += `<div class="break-all"><a href="https://etherscan.io/tx/${escapeHtml(tx.hash)}" target="_blank" class="underline decoration-dashed">${escapeHtml(tx.hash)}</a></div>`;
html += `</div>`;
}
list.innerHTML = html;
}
function renderSendTokenSelect(addr) {
@@ -58,10 +105,7 @@ function init(ctx) {
const addr = $("address-full").textContent;
if (addr) {
navigator.clipboard.writeText(addr);
$("address-copied-msg").textContent = "Copied!";
setTimeout(() => {
$("address-copied-msg").textContent = "";
}, 2000);
showFlash("Copied!");
}
});
@@ -71,11 +115,17 @@ function init(ctx) {
});
$("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-password").value = "";
$("send-fee-estimate").classList.add("hidden");
$("send-status").classList.add("hidden");
updateSendBalance();
showView("send");
});

View File

@@ -8,10 +8,7 @@ const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
const {
getProvider,
invalidateBalanceCache,
} = require("../../shared/balances");
const { getProvider } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
let pendingTx = null;
@@ -163,12 +160,19 @@ function init(ctx) {
});
statusEl.textContent = "Sent. Waiting for confirmation...";
const receipt = await tx.wait();
statusEl.textContent =
"Confirmed in block " +
receipt.blockNumber +
". Tx: " +
receipt.hash;
invalidateBalanceCache();
statusEl.innerHTML = "";
statusEl.appendChild(
document.createTextNode(
"Confirmed in block " + receipt.blockNumber + ". Tx: ",
),
);
const link = document.createElement("a");
link.href = "https://etherscan.io/tx/" + receipt.hash;
link.target = "_blank";
link.rel = "noopener";
link.className = "underline decoration-dashed break-all";
link.textContent = receipt.hash;
statusEl.appendChild(link);
ctx.doRefreshAndRender();
} catch (e) {
statusEl.textContent = "Failed: " + (e.shortMessage || e.message);

View File

@@ -1,6 +1,11 @@
// Shared DOM helpers used by all views.
const { DEBUG } = require("../../shared/wallet");
const { DEBUG } = require("../../shared/constants");
const {
formatUsd,
getPrice,
getAddressValueUsd,
} = require("../../shared/prices");
const VIEWS = [
"welcome",
@@ -37,6 +42,7 @@ function showView(name) {
el.classList.toggle("hidden", v !== name);
}
}
clearFlash();
if (DEBUG) {
const banner = document.getElementById("debug-banner");
if (banner) {
@@ -45,4 +51,58 @@ function showView(name) {
}
}
module.exports = { $, showError, hideError, showView };
let flashTimer = null;
function clearFlash() {
if (flashTimer) {
clearTimeout(flashTimer);
flashTimer = null;
}
$("flash-msg").textContent = "";
}
function showFlash(msg, duration = 2000) {
clearFlash();
$("flash-msg").textContent = msg;
flashTimer = setTimeout(() => {
$("flash-msg").textContent = "";
flashTimer = null;
}, duration);
}
function balanceLine(symbol, amount, price) {
const qty = amount.toFixed(4);
const usd = price ? formatUsd(amount * price) : "";
return (
`<div class="flex text-xs">` +
`<span class="flex justify-between shrink-0" style="width:42ch">` +
`<span>${symbol}</span>` +
`<span>${qty}</span>` +
`</span>` +
`<span class="text-right text-muted flex-1">${usd}</span>` +
`</div>`
);
}
function balanceLinesForAddress(addr) {
let html = balanceLine(
"ETH",
parseFloat(addr.balance || "0"),
getPrice("ETH"),
);
for (const t of addr.tokenBalances || []) {
const bal = parseFloat(t.balance || "0");
if (bal === 0) continue;
html += balanceLine(t.symbol, bal, getPrice(t.symbol));
}
return html;
}
module.exports = {
$,
showError,
hideError,
showView,
showFlash,
balanceLinesForAddress,
};

View File

@@ -1,4 +1,4 @@
const { $, showView } = require("./helpers");
const { $, showView, showFlash, balanceLinesForAddress } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { deriveAddressFromXpub } = require("../../shared/wallet");
const {
@@ -33,15 +33,17 @@ function render(ctx) {
html += `</div>`;
wallet.addresses.forEach((addr, ai) => {
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover px-1" 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>`;
if (addr.ensName) {
html += `<div class="text-xs font-bold">${addr.ensName}</div>`;
}
html += `<div class="text-xs break-all">${addr.address}</div>`;
html += `<div class="flex justify-between items-center">`;
html += `<span class="text-xs">${addr.balance} ETH</span>`;
html += `<span class="text-xs text-muted">${formatUsd(getAddressValueUsd(addr))}</span>`;
const addrUsd = formatUsd(getAddressValueUsd(addr));
html += `<div class="flex text-xs">`;
html += `<span class="shrink-0" style="width:42ch">${addr.address}</span>`;
html += `<span class="text-right text-muted flex-1">${addrUsd}</span>`;
html += `</div>`;
html += balanceLinesForAddress(addr);
html += `</div>`;
});
@@ -62,27 +64,25 @@ function render(ctx) {
e.stopPropagation();
const wi = parseInt(btn.dataset.wallet, 10);
const wallet = state.wallets[wi];
const newAddr = deriveAddressFromXpub(
wallet.xpub,
wallet.nextIndex,
);
wallet.addresses.push({
address: deriveAddressFromXpub(wallet.xpub, wallet.nextIndex),
address: newAddr,
balance: "0.0000",
tokenBalances: [],
});
wallet.nextIndex++;
await saveState();
render(ctx);
ctx.doRefreshAndRender();
});
});
renderTotalValue();
}
function init(ctx) {
$("btn-settings").addEventListener("click", () => {
$("settings-rpc").value = state.rpcUrl;
showView("settings");
});
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
}
function init(ctx) {}
module.exports = { init, render };

View File

@@ -1,4 +1,4 @@
const { $, showError, hideError, showView } = require("./helpers");
const { $, showView, showFlash } = require("./helpers");
const { addressFromPrivateKey } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
@@ -7,7 +7,6 @@ function show() {
$("import-private-key").value = "";
$("import-key-password").value = "";
$("import-key-password-confirm").value = "";
hideError("import-key-error");
showView("import-key");
}
@@ -15,34 +14,30 @@ function init(ctx) {
$("btn-import-key-confirm").addEventListener("click", async () => {
const key = $("import-private-key").value.trim();
if (!key) {
showError("import-key-error", "Please enter your private key.");
showFlash("Please enter your private key.");
return;
}
let addr;
try {
addr = addressFromPrivateKey(key);
} catch (e) {
showError("import-key-error", "Invalid private key.");
showFlash("Invalid private key.");
return;
}
const pw = $("import-key-password").value;
const pw2 = $("import-key-password-confirm").value;
if (!pw) {
showError("import-key-error", "Please choose a password.");
showFlash("Please choose a password.");
return;
}
if (pw.length < 8) {
showError(
"import-key-error",
"Password must be at least 8 characters.",
);
showFlash("Password must be at least 8 characters.");
return;
}
if (pw !== pw2) {
showError("import-key-error", "Passwords do not match.");
showFlash("Passwords do not match.");
return;
}
hideError("import-key-error");
const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1;
state.wallets.push({
@@ -57,6 +52,8 @@ function init(ctx) {
await saveState();
ctx.renderWalletList();
showView("main");
ctx.doRefreshAndRender();
});
$("btn-import-key-back").addEventListener("click", () => {

View File

@@ -1,9 +1,12 @@
const { $ } = require("./helpers");
const { $, showFlash } = require("./helpers");
function init(ctx) {
$("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address").textContent;
if (addr) navigator.clipboard.writeText(addr);
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
}
});
$("btn-receive-back").addEventListener("click", ctx.showAddressDetail);

View File

@@ -1,22 +1,41 @@
// Send view: collect To, Amount, Token. Then go to confirmation.
const { $, showError, hideError } = require("./helpers");
const { $, showFlash } = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
function updateSendBalance() {
const addr = currentAddress();
if (!addr) return;
const token = $("send-token").value;
if (token === "ETH") {
$("send-balance").textContent =
"Current balance: " + (addr.balance || "0") + " ETH";
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const symbol = tb ? tb.symbol : "?";
const bal = tb ? tb.balance || "0" : "0";
$("send-balance").textContent =
"Current balance: " + bal + " " + symbol;
}
}
function init(ctx) {
$("send-token").addEventListener("change", updateSendBalance);
$("btn-send-review").addEventListener("click", async () => {
const to = $("send-to").value.trim();
const amount = $("send-amount").value.trim();
if (!to) {
showError("send-error", "Please enter a recipient address.");
showFlash("Please enter a recipient address.");
return;
}
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
showError("send-error", "Please enter a valid amount.");
showFlash("Please enter a valid amount.");
return;
}
hideError("send-error");
// Resolve ENS if needed
let resolvedTo = to;
@@ -26,13 +45,13 @@ function init(ctx) {
const provider = getProvider(state.rpcUrl);
const resolved = await provider.resolveName(to);
if (!resolved) {
showError("send-error", "Could not resolve " + to);
showFlash("Could not resolve " + to);
return;
}
resolvedTo = resolved;
ensName = to;
} catch (e) {
showError("send-error", "Failed to resolve ENS name.");
showFlash("Failed to resolve ENS name.");
return;
}
}
@@ -53,4 +72,4 @@ function init(ctx) {
$("btn-send-back").addEventListener("click", ctx.showAddressDetail);
}
module.exports = { init };
module.exports = { init, updateSendBalance };

View File

@@ -1,12 +1,76 @@
const { $, showView } = require("./helpers");
const { $, showView, showFlash } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
const { log } = require("../../shared/log");
function init(ctx) {
$("btn-save-rpc").addEventListener("click", async () => {
state.rpcUrl = $("settings-rpc").value.trim();
const url = $("settings-rpc").value.trim();
if (!url) {
showFlash("Please enter an RPC URL.");
return;
}
showFlash("Testing endpoint...");
try {
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_chainId",
params: [],
}),
});
const json = await resp.json();
if (json.error) {
log.errorf("RPC validation error:", json.error);
showFlash("Endpoint returned error: " + json.error.message);
return;
}
if (json.result !== ETHEREUM_MAINNET_CHAIN_ID) {
showFlash(
"Wrong network (expected mainnet, got chain " +
json.result +
").",
);
return;
}
} catch (e) {
log.errorf("RPC validation fetch failed:", e.message);
showFlash("Could not reach endpoint.");
return;
}
state.rpcUrl = url;
await saveState();
showFlash("Saved.");
});
$("btn-save-blockscout").addEventListener("click", async () => {
const url = $("settings-blockscout").value.trim();
if (!url) {
showFlash("Please enter a Blockscout API URL.");
return;
}
showFlash("Testing endpoint...");
try {
const resp = await fetch(url + "/stats");
if (!resp.ok) {
showFlash("Endpoint returned HTTP " + resp.status + ".");
return;
}
} catch (e) {
log.errorf("Blockscout validation failed:", e.message);
showFlash("Could not reach endpoint.");
return;
}
state.blockscoutUrl = url;
await saveState();
showFlash("Saved.");
});
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
$("btn-settings-back").addEventListener("click", () => {
ctx.renderWalletList();
showView("main");