All checks were successful
check / check (push) Successful in 9s
## Summary Adds Sepolia testnet support to AutistMask. ### Changes - **New `src/shared/networks.js`** — centralized network definitions (mainnet + Sepolia) with chain IDs, default RPC/Blockscout endpoints, and block explorer URLs - **State management** — `networkId` added to persisted state; defaults to mainnet for backward compatibility - **Settings UI** — network selector dropdown lets users switch between Ethereum Mainnet and Sepolia Testnet - **Dynamic explorer links** — all hardcoded `etherscan.io` URLs replaced with dynamic links from the current network config (`sepolia.etherscan.io` for Sepolia) - **Background service** — `wallet_switchEthereumChain` now accepts both mainnet (0x1) and Sepolia (0xaa36a7); broadcasts `chainChanged` to connected dApps - **Inpage provider** — fetches chain ID on init and updates dynamically via `chainChanged` events (no more hardcoded `0x1`) - **Blockscout API** — uses `eth-sepolia.blockscout.com/api/v2` for Sepolia - **Etherscan labels** — phishing/scam checks use the correct explorer per network - **Price fetching** — skipped on testnets (testnet tokens have no real market value) - **RPC validation** — checks against the selected network's chain ID, not hardcoded mainnet - **ethers provider** — `getProvider()` uses the correct ethers `Network` for Sepolia ### API Endpoints Verified | Service | Mainnet | Sepolia | |---------|---------|--------| | Etherscan | etherscan.io | sepolia.etherscan.io | | Blockscout | eth.blockscout.com/api/v2 | eth-sepolia.blockscout.com/api/v2 | | RPC | ethereum-rpc.publicnode.com | ethereum-sepolia-rpc.publicnode.com | | CoinDesk (prices) | ✅ | N/A (skipped on testnet) | closes #110 Reviewed-on: #137 THIS WAS ONESHOTTED USING OPUS 4. WTAF Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
286 lines
10 KiB
JavaScript
286 lines
10 KiB
JavaScript
// 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, debugFetch } = require("./log");
|
|
const { deriveAddressFromXpub } = require("./wallet");
|
|
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
|
|
|
// Use a static network to skip auto-detection (which can fail and cause
|
|
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
|
// Accepts an optional networkName ("mainnet" or "sepolia") for the static
|
|
// network hint so ethers picks the right chain parameters. When omitted,
|
|
// reads the currently selected network from extension state.
|
|
function getProvider(rpcUrl, networkName) {
|
|
// Lazy require to avoid circular dependency issues at module scope.
|
|
const { currentNetwork } = require("./state");
|
|
const name = networkName || currentNetwork().id;
|
|
const net = Network.from(name);
|
|
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
|
|
}
|
|
|
|
function formatBalance(wei) {
|
|
const eth = formatEther(wei);
|
|
const parts = eth.split(".");
|
|
if (parts.length === 1) return eth + ".0";
|
|
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
|
|
return parts[0] + "." + dec;
|
|
}
|
|
|
|
function formatTokenBalance(raw, decimals) {
|
|
const val = formatUnits(raw, decimals);
|
|
const parts = val.split(".");
|
|
if (parts.length === 1) return val + ".0";
|
|
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
|
|
return parts[0] + "." + dec;
|
|
}
|
|
|
|
// Fetch token balances for a single address from Blockscout.
|
|
// Returns [{ address, symbol, decimals, balance }].
|
|
// Filters out spam: only shows tokens that are in the known token list,
|
|
// explicitly tracked by the user, or have >= 1000 holders.
|
|
async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
|
try {
|
|
const resp = await debugFetch(
|
|
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 trackedSet = new Set(
|
|
(trackedTokens || []).map((t) => t.address.toLowerCase()),
|
|
);
|
|
|
|
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;
|
|
|
|
const tokenAddr = (item.token.address_hash || "").toLowerCase();
|
|
const holders = parseInt(item.token.holders_count || "0", 10);
|
|
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
|
|
const isTracked = trackedSet.has(tokenAddr);
|
|
const hasEnoughHolders = holders >= 1000;
|
|
|
|
// Skip spam tokens the user never asked to see
|
|
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
|
|
|
// Skip tokens spoofing a known symbol from a different address
|
|
const sym = (item.token.symbol || "").toUpperCase();
|
|
const legitAddr = KNOWN_SYMBOLS.get(sym);
|
|
if (
|
|
legitAddr !== undefined &&
|
|
legitAddr !== null &&
|
|
tokenAddr !== legitAddr
|
|
)
|
|
continue;
|
|
|
|
balances.push({
|
|
address: item.token.address_hash,
|
|
name: item.token.name || "",
|
|
symbol: item.token.symbol || "???",
|
|
decimals: decimals,
|
|
balance: bal,
|
|
holders: holders,
|
|
});
|
|
}
|
|
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.
|
|
async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
|
|
log.debugf("refreshBalances start, rpc:", rpcUrl);
|
|
const provider = getProvider(rpcUrl);
|
|
const updates = [];
|
|
|
|
for (const wallet of wallets) {
|
|
for (const addr of wallet.addresses) {
|
|
// ETH balance
|
|
updates.push(
|
|
provider
|
|
.getBalance(addr.address)
|
|
.then((bal) => {
|
|
addr.balance = formatBalance(bal);
|
|
log.debugf("ETH balance", addr.address, addr.balance);
|
|
})
|
|
.catch((e) => {
|
|
log.errorf(
|
|
"ETH balance failed",
|
|
addr.address,
|
|
e.shortMessage || e.message,
|
|
);
|
|
}),
|
|
);
|
|
|
|
// ENS reverse lookup — only overwrite on success so that
|
|
// transient RPC errors don't wipe a previously resolved name.
|
|
updates.push(
|
|
provider
|
|
.lookupAddress(addr.address)
|
|
.then((name) => {
|
|
addr.ensName = name || null;
|
|
log.debugf(
|
|
"ENS reverse",
|
|
addr.address,
|
|
"->",
|
|
addr.ensName,
|
|
);
|
|
})
|
|
.catch((e) => {
|
|
log.errorf(
|
|
"ENS reverse failed",
|
|
addr.address,
|
|
e.message,
|
|
);
|
|
// Keep existing addr.ensName if we had one
|
|
}),
|
|
);
|
|
|
|
// ERC-20 token balances via Blockscout
|
|
updates.push(
|
|
fetchTokenBalances(
|
|
addr.address,
|
|
blockscoutUrl,
|
|
trackedTokens,
|
|
).then((balances) => {
|
|
if (balances !== null) {
|
|
addr.tokenBalances = balances;
|
|
log.debugf(
|
|
"Token balances",
|
|
addr.address,
|
|
balances.length,
|
|
"tokens",
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
await Promise.all(updates);
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
// Truncate to prevent storage of excessively long values from RPC
|
|
name = String(name).slice(0, 64);
|
|
symbol = String(symbol).slice(0, 12);
|
|
|
|
log.infof("Token resolved:", symbol, "decimals", Number(decimals));
|
|
return { name, symbol, decimals: Number(decimals) };
|
|
}
|
|
|
|
// Derive HD addresses starting from index 0 and check for on-chain activity.
|
|
// Checks gapLimit addresses in parallel per batch. Stops when an entire
|
|
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
|
|
// 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 checked = 0;
|
|
let checkUpTo = gapLimit;
|
|
|
|
while (checked < checkUpTo) {
|
|
const batch = [];
|
|
for (let i = checked; i < checkUpTo; i++) {
|
|
const addr = deriveAddressFromXpub(xpub, i);
|
|
batch.push({ addr, index: i });
|
|
}
|
|
|
|
const results = await Promise.all(
|
|
batch.map(async ({ addr, index }) => {
|
|
try {
|
|
const [balance, txCount] = await Promise.all([
|
|
provider.getBalance(addr),
|
|
provider.getTransactionCount(addr),
|
|
]);
|
|
return { addr, index, isUsed: balance > 0n || txCount > 0 };
|
|
} catch (e) {
|
|
log.errorf(
|
|
"scanForAddresses check failed",
|
|
addr,
|
|
e.shortMessage || e.message,
|
|
);
|
|
return { addr, index, isUsed: false };
|
|
}
|
|
}),
|
|
);
|
|
|
|
checked = checkUpTo;
|
|
|
|
for (const r of results) {
|
|
if (r.isUsed) {
|
|
used.push({ address: r.addr, index: r.index });
|
|
log.debugf("scanForAddresses used", r.addr, "index:", r.index);
|
|
checkUpTo = Math.max(checkUpTo, r.index + 1 + gapLimit);
|
|
}
|
|
}
|
|
}
|
|
|
|
used.sort((a, b) => a.index - b.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,
|
|
getProvider,
|
|
scanForAddresses,
|
|
};
|