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,19 +1,23 @@
// Balance fetching: ETH balances, ERC-20 token balances, ENS reverse lookup.
// Cached for 60 seconds.
// Balance fetching: ETH balances via RPC, ERC-20 token balances via
// Blockscout, ENS reverse lookup via RPC.
const {
JsonRpcProvider,
Network,
Contract,
formatEther,
formatUnits,
} = require("ethers");
const { ERC20_ABI } = require("./constants");
const { log } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const BALANCE_CACHE_TTL = 60000; // 60 seconds
let lastFetchedAt = 0;
// Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare).
const mainnet = Network.from("mainnet");
function getProvider(rpcUrl) {
return new JsonRpcProvider(rpcUrl);
return new JsonRpcProvider(rpcUrl, mainnet, { staticNetwork: mainnet });
}
function formatBalance(wei) {
@@ -32,11 +36,42 @@ function formatTokenBalance(raw, decimals) {
return parts[0] + "." + dec;
}
// Fetch token balances for a single address from Blockscout.
// Returns [{ address, symbol, decimals, balance }].
async function fetchTokenBalances(address, blockscoutUrl) {
try {
const resp = await fetch(
blockscoutUrl + "/addresses/" + address + "/token-balances",
);
if (!resp.ok) {
log.errorf("blockscout token-balances:", resp.status);
return null;
}
const items = await resp.json();
if (!Array.isArray(items)) return null;
const balances = [];
for (const item of items) {
if (item.token?.type !== "ERC-20") continue;
const decimals = parseInt(item.token.decimals || "18", 10);
const bal = formatTokenBalance(item.value || "0", decimals);
if (bal === "0.0") continue;
balances.push({
address: item.token.address_hash,
symbol: item.token.symbol || "???",
decimals: decimals,
balance: bal,
});
}
return balances;
} catch (e) {
log.errorf("fetchTokenBalances failed:", e.message);
return null;
}
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// trackedTokens: [{ address, symbol, decimals }]
async function refreshBalances(wallets, trackedTokens, rpcUrl) {
const now = Date.now();
if (now - lastFetchedAt < BALANCE_CACHE_TTL) return;
async function refreshBalances(wallets, rpcUrl, blockscoutUrl) {
log.debugf("refreshBalances start, rpc:", rpcUrl);
const provider = getProvider(rpcUrl);
const updates = [];
@@ -48,8 +83,15 @@ async function refreshBalances(wallets, trackedTokens, rpcUrl) {
.getBalance(addr.address)
.then((bal) => {
addr.balance = formatBalance(bal);
log.debugf("ETH balance", addr.address, addr.balance);
})
.catch(() => {}),
.catch((e) => {
log.errorf(
"ETH balance failed",
addr.address,
e.shortMessage || e.message,
);
}),
);
// ENS reverse lookup
@@ -64,67 +106,117 @@ async function refreshBalances(wallets, trackedTokens, rpcUrl) {
}),
);
// ERC-20 token balances
if (!addr.tokenBalances) addr.tokenBalances = [];
for (const token of trackedTokens) {
updates.push(
(async () => {
try {
const contract = new Contract(
token.address,
ERC20_ABI,
provider,
// ERC-20 token balances via Blockscout
updates.push(
fetchTokenBalances(addr.address, blockscoutUrl).then(
(balances) => {
if (balances !== null) {
addr.tokenBalances = balances;
log.debugf(
"Token balances",
addr.address,
balances.length,
"tokens",
);
const raw = await contract.balanceOf(addr.address);
const existing = addr.tokenBalances.find(
(t) =>
t.address.toLowerCase() ===
token.address.toLowerCase(),
);
const bal = formatTokenBalance(raw, token.decimals);
if (existing) {
existing.balance = bal;
} else {
addr.tokenBalances.push({
address: token.address,
symbol: token.symbol,
decimals: token.decimals,
balance: bal,
});
}
} catch (e) {
// skip on error
}
})(),
);
}
},
),
);
}
}
await Promise.all(updates);
lastFetchedAt = now;
log.debugf("refreshBalances done");
}
// Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
const provider = getProvider(rpcUrl);
const contract = new Contract(contractAddress, ERC20_ABI, provider);
const [name, symbol, decimals] = await Promise.all([
contract.name(),
contract.symbol(),
contract.decimals(),
]);
let name, symbol, decimals;
try {
symbol = await contract.symbol();
log.debugf("symbol() =", symbol);
} catch (e) {
log.errorf("symbol() failed:", e.shortMessage || e.message);
throw new Error("Not a valid ERC-20 token (symbol() failed).");
}
try {
decimals = await contract.decimals();
log.debugf("decimals() =", decimals);
} catch (e) {
log.errorf("decimals() failed:", e.shortMessage || e.message);
throw new Error("Not a valid ERC-20 token (decimals() failed).");
}
try {
name = await contract.name();
log.debugf("name() =", name);
} catch (e) {
log.warnf("name() failed, using symbol as name:", e.message);
name = symbol;
}
log.infof("Token resolved:", symbol, "decimals", Number(decimals));
return { name, symbol, decimals: Number(decimals) };
}
// Force-invalidate the balance cache (e.g. after sending a tx).
function invalidateBalanceCache() {
lastFetchedAt = 0;
// Derive HD addresses starting from index 0 and check for on-chain activity.
// Stops after gapLimit consecutive addresses with zero balance and zero tx count.
// Returns { addresses: [{ address, index }], nextIndex }.
async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
const provider = getProvider(rpcUrl);
const used = [];
let gap = 0;
let index = 0;
while (gap < gapLimit) {
const addr = deriveAddressFromXpub(xpub, index);
let balance, txCount;
try {
[balance, txCount] = await Promise.all([
provider.getBalance(addr),
provider.getTransactionCount(addr),
]);
} catch (e) {
log.errorf(
"scanForAddresses check failed",
addr,
e.shortMessage || e.message,
);
// Treat RPC failure as empty to avoid infinite loop
gap++;
index++;
continue;
}
if (balance > 0n || txCount > 0) {
used.push({ address: addr, index });
gap = 0;
log.debugf("scanForAddresses used", addr, "index:", index);
} else {
gap++;
}
index++;
}
const nextIndex = used.length > 0 ? used[used.length - 1].index + 1 : 1;
log.infof(
"scanForAddresses done, found:",
used.length,
"nextIndex:",
nextIndex,
);
return { addresses: used, nextIndex };
}
module.exports = {
refreshBalances,
lookupTokenInfo,
invalidateBalanceCache,
getProvider,
scanForAddresses,
};