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

@@ -21,8 +21,21 @@
>@sneak</a
>
</h1>
<button
id="btn-settings"
class="bg-transparent border-none text-fg cursor-pointer text-2xl p-0 leading-none"
title="Settings"
>
&#9881;
</button>
</div>
<!-- ============ FLASH MESSAGE AREA ============ -->
<div
id="flash-msg"
class="text-xs text-muted min-h-[1.25rem] mb-1"
></div>
<!-- ============ WELCOME / FIRST USE ============ -->
<div id="view-welcome" class="view hidden">
<p class="mb-3">Welcome! To get started, add a wallet.</p>
@@ -36,6 +49,12 @@
<!-- ============ ADD WALLET (unified create/import) ============ -->
<div id="view-add-wallet" class="view hidden">
<button
id="btn-add-wallet-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Add Wallet</h2>
<p class="mb-2">
Enter your 12 or 24 word recovery phrase below, or click the
@@ -62,10 +81,8 @@
id="add-wallet-phrase-warning"
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
>
These words are your recovery phrase. Write them down on
paper and keep them somewhere safe. Anyone with these words
can access your funds. If you lose them, your wallet cannot
be recovered.
Write these words down and keep them safe. Anyone with them
can take your funds; if you lose them, your wallet is gone.
</div>
<div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label>
@@ -87,24 +104,12 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="flex gap-2">
<button
id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
<button
id="btn-add-wallet-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Back
</button>
</div>
<div
id="add-wallet-error"
class="mt-2 border border-border border-dashed p-1 hidden"
></div>
<button
id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
<div class="mt-3 text-xs text-muted">
Have a private key instead?
<button
@@ -118,6 +123,12 @@
<!-- ============ IMPORT PRIVATE KEY ============ -->
<div id="view-import-key" class="view hidden">
<button
id="btn-import-key-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Import Private Key</h2>
<p class="mb-2">
Paste your private key below. This wallet will have a single
@@ -151,84 +162,58 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="flex gap-2">
<button
id="btn-import-key-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Import
</button>
<button
id="btn-import-key-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Back
</button>
</div>
<div
id="import-key-error"
class="mt-2 border border-border border-dashed p-1 hidden"
></div>
<button
id="btn-import-key-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Import
</button>
</div>
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
<div id="view-main" class="view hidden">
<!-- total portfolio value -->
<div id="total-value" class="text-2xl font-bold mb-2"></div>
<div
id="total-value"
class="text-2xl font-bold mb-2 min-h-[2rem]"
></div>
<!-- wallet list -->
<div id="wallet-list"></div>
<div class="mt-3 border-t border-border pt-2 flex gap-2">
<button
id="btn-main-add-wallet"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
+ Add wallet
</button>
<button
id="btn-settings"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Settings
</button>
</div>
</div>
<!-- ============ ADDRESS DETAIL VIEW ============ -->
<div id="view-address" class="view hidden">
<button
id="btn-address-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<div
class="flex justify-between items-center border-b border-border pb-1 mb-2"
>
<h2 class="font-bold" id="address-title">Address</h2>
<button
id="btn-address-back"
class="border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Back
</button>
</div>
<div id="address-ens" class="font-bold mb-1 hidden"></div>
<div
id="address-full"
class="text-xs break-all mb-1 cursor-pointer"
class="flex text-xs mb-3 cursor-pointer"
title="Click to copy"
></div>
<div
class="text-xs text-muted mb-3"
id="address-copied-msg"
></div>
<!-- balance -->
>
<span
id="address-full"
class="shrink-0"
style="width: 42ch"
></span>
<span
id="address-usd-total"
class="text-right text-muted flex-1"
></span>
</div>
<!-- balances -->
<div class="border-b border-border-light pb-2 mb-2">
<div class="text-base font-bold">
<span id="address-eth-balance">0.0000</span> ETH
</div>
<div
class="text-xs text-muted"
id="address-usd-value"
></div>
<div id="address-balances"></div>
</div>
<!-- actions -->
@@ -245,31 +230,33 @@
>
Receive
</button>
<button
id="btn-add-token"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer flex-1"
>
+ Token
</button>
</div>
<!-- tokens -->
<div>
<div
class="flex justify-between items-center border-b border-border pb-1 mb-1"
>
<h2 class="font-bold">Tokens</h2>
<button
id="btn-add-token"
class="border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
>
+ Add
</button>
<!-- transactions -->
<div class="mt-3">
<div class="border-b border-border pb-1 mb-1">
<h2 class="font-bold">Transactions</h2>
</div>
<div id="token-list">
<div class="text-muted text-xs py-1">
No tokens added yet. Use "+ Add" to track a token.
</div>
<div id="tx-list">
<div class="text-muted text-xs py-1">Loading...</div>
</div>
</div>
</div>
<!-- ============ SEND ============ -->
<div id="view-send" class="view hidden">
<button
id="btn-send-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Send</h2>
<div class="mb-2">
<label class="block mb-1">What to send</label>
@@ -290,7 +277,13 @@
/>
</div>
<div class="mb-2">
<label class="block mb-1">Amount</label>
<div class="flex justify-between mb-1">
<label>Amount</label>
<span
id="send-balance"
class="text-xs text-muted"
></span>
</div>
<input
type="text"
id="send-amount"
@@ -298,28 +291,22 @@
placeholder="0.0"
/>
</div>
<div class="flex gap-2">
<button
id="btn-send-review"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Review
</button>
<button
id="btn-send-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Cancel
</button>
</div>
<div
id="send-error"
class="mt-2 border border-border border-dashed p-1 hidden"
></div>
<button
id="btn-send-review"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Review
</button>
</div>
<!-- ============ CONFIRM TRANSACTION ============ -->
<div id="view-confirm-tx" class="view hidden">
<button
id="btn-confirm-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Confirm Transaction</h2>
<div class="mb-2">
<div class="text-xs text-muted">From</div>
@@ -350,20 +337,12 @@
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 hidden"
></div>
<div class="flex gap-2">
<button
id="btn-confirm-send"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Send
</button>
<button
id="btn-confirm-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Back
</button>
</div>
<button
id="btn-confirm-send"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Send
</button>
<div
id="confirm-status"
class="mt-2 border border-border p-1 hidden"
@@ -408,6 +387,12 @@
<!-- ============ RECEIVE ============ -->
<div id="view-receive" class="view hidden">
<button
id="btn-receive-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Receive</h2>
<p class="mb-2">
Share this address with the sender. Make sure you only use
@@ -420,24 +405,22 @@
id="receive-address"
class="border border-border p-2 break-all select-all mb-3"
></div>
<div class="flex gap-2">
<button
id="btn-receive-copy"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Copy address
</button>
<button
id="btn-receive-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Back
</button>
</div>
<button
id="btn-receive-copy"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Copy address
</button>
</div>
<!-- ============ ADD TOKEN ============ -->
<div id="view-add-token" class="view hidden">
<button
id="btn-add-token-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Add Token</h2>
<p class="mb-2">
Enter the contract address of the token you want to track.
@@ -465,36 +448,44 @@
class="flex flex-wrap gap-1"
></div>
</div>
<div class="flex gap-2">
<button
id="btn-add-token-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
<button
id="btn-add-token-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Cancel
</button>
</div>
<div
id="add-token-error"
class="mt-2 border border-border border-dashed p-1 hidden"
></div>
<button
id="btn-add-token-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
</div>
<!-- ============ SETTINGS ============ -->
<div id="view-settings" class="view hidden">
<h2 class="font-bold mb-2">Settings</h2>
<button
id="btn-settings-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-3">Settings</h2>
<h2 class="font-bold mb-1">Network</h2>
<p class="text-xs text-muted mb-1">
The server used to talk to the Ethereum network. Change this
if you run your own node or prefer a different provider.
</p>
<div class="mb-3">
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Wallets</h3>
<p class="text-xs text-muted mb-2">
Add a new wallet from a recovery phrase or private key.
</p>
<button
id="btn-main-add-wallet"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
+ Add wallet
</button>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Ethereum RPC</h3>
<p class="text-xs text-muted mb-1">
The server used to talk to the Ethereum network. Change
this if you run your own node or prefer a different
provider.
</p>
<input
type="text"
id="settings-rpc"
@@ -508,12 +499,22 @@
</button>
</div>
<div class="border-t border-border pt-2">
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Blockscout API</h3>
<p class="text-xs text-muted mb-1">
Used to fetch token balances and transaction history.
Change this if you run your own Blockscout instance.
</p>
<input
type="text"
id="settings-blockscout"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
<button
id="btn-settings-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
id="btn-save-blockscout"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Back
Save
</button>
</div>
</div>

View File

@@ -1,11 +1,11 @@
// AutistMask popup entry point.
// Loads state, initializes views, triggers first render.
const { DEBUG } = require("../shared/wallet");
const { DEBUG } = require("../shared/constants");
const { state, saveState, loadState } = require("../shared/state");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const { showView } = require("./views/helpers");
const { $, showView } = require("./views/helpers");
const home = require("./views/home");
const welcome = require("./views/welcome");
@@ -23,13 +23,22 @@ function renderWalletList() {
home.render(ctx);
}
let refreshInFlight = false;
async function doRefreshAndRender() {
await Promise.all([
refreshPrices(),
refreshBalances(state.wallets, state.trackedTokens, state.rpcUrl),
]);
await saveState();
renderWalletList();
if (refreshInFlight) return;
refreshInFlight = true;
try {
await Promise.all([
refreshPrices(),
refreshBalances(state.wallets, state.rpcUrl, state.blockscoutUrl),
]);
state.lastBalanceRefresh = Date.now();
await saveState();
renderWalletList();
} finally {
refreshInFlight = false;
}
}
const ctx = {
@@ -54,6 +63,12 @@ async function init() {
await loadState();
$("btn-settings").addEventListener("click", () => {
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
showView("settings");
});
welcome.init(ctx);
addWallet.init(ctx);
importKey.init(ctx);
@@ -72,6 +87,7 @@ async function init() {
renderWalletList();
showView("main");
doRefreshAndRender();
setInterval(doRefreshAndRender, 10000);
}
}

View File

@@ -10,6 +10,7 @@
--color-border: #000000;
--color-border-light: #cccccc;
--color-hover: #eeeeee;
--color-well: #f5f5f5;
}
body {

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");