Token auto-discovery, tx history, balance polling, EIP-6963, UI overhaul
All checks were successful
check / check (push) Successful in 14s
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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user