bundledToken(address, chainId) in tokenList.js answers only for Ethereum Mainnet's chain id. Every reader of TOKEN_BY_ADDRESS now goes through it, with the chain id of the transaction, balance or history entry it draws. The token sources the screens build carry that chain id. The Uniswap decoder sets the transaction's chain id on them. fetchTokenBalances() takes the chain id of the network it asks about. isSpoofedSymbol() takes the chain id of the token's network. It checks the bundled list's symbols only on Ethereum Mainnet. The native asset's labels stay reserved on every network. Twenty existing tests and four shared test helpers read the bundled list without naming a network. Each now names Ethereum Mainnet. Model: opus-5-5
212 lines
7.7 KiB
JavaScript
212 lines
7.7 KiB
JavaScript
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
escapeHtml,
|
|
goBack,
|
|
trackToken,
|
|
} = require("./helpers");
|
|
const { getTopTokens } = require("../../shared/tokenList");
|
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
|
const { lookupTokenInfo } = require("../../shared/balances");
|
|
const { isScamAddress } = require("../../shared/scamlist");
|
|
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
|
const { log } = require("../../shared/log");
|
|
|
|
let ctx;
|
|
|
|
function isTracked(address) {
|
|
const lower = address.toLowerCase();
|
|
return state.trackedTokens.some((t) => t.address.toLowerCase() === lower);
|
|
}
|
|
|
|
function nameAndSymbol(t) {
|
|
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
|
|
}
|
|
|
|
function renderTop10() {
|
|
const el = $("settings-addtoken-top10");
|
|
el.innerHTML = getTopTokens(10)
|
|
.map((t) => {
|
|
const tracked = isTracked(t.address);
|
|
const cls = tracked
|
|
? "border border-border px-1 text-xs opacity-40 cursor-default"
|
|
: "border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs";
|
|
return (
|
|
`<button class="settings-addtoken-quick ${cls}"` +
|
|
` data-address="${escapeHtml(t.address)}"` +
|
|
` data-symbol="${escapeHtml(t.symbol)}"` +
|
|
` data-decimals="${escapeHtml(t.decimals)}"` +
|
|
` data-name="${escapeHtml(t.name || "")}"` +
|
|
`${tracked ? " disabled" : ""}>${escapeHtml(t.symbol)}</button>`
|
|
);
|
|
})
|
|
.join("");
|
|
el.querySelectorAll(".settings-addtoken-quick:not([disabled])").forEach(
|
|
(btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const token = {
|
|
address: btn.dataset.address,
|
|
symbol: btn.dataset.symbol,
|
|
decimals: parseInt(btn.dataset.decimals, 10),
|
|
name: btn.dataset.name || btn.dataset.symbol,
|
|
};
|
|
trackToken(token);
|
|
await saveState();
|
|
showFlash("Added " + token.symbol);
|
|
renderTop10();
|
|
renderDropdown();
|
|
ctx.doRefreshAndRender();
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function renderDropdown() {
|
|
const sel = $("settings-addtoken-select");
|
|
const tokens = getTopTokens(100);
|
|
let html = '<option value="">-- select --</option>';
|
|
for (const t of tokens) {
|
|
const tracked = isTracked(t.address);
|
|
const label = nameAndSymbol(t) + (tracked ? " (tracked)" : "");
|
|
html +=
|
|
`<option value="${escapeHtml(t.address)}"` +
|
|
` data-symbol="${escapeHtml(t.symbol)}"` +
|
|
` data-decimals="${escapeHtml(t.decimals)}"` +
|
|
` data-name="${escapeHtml(t.name || "")}"` +
|
|
`${tracked ? " disabled" : ""}>${escapeHtml(label)}</option>`;
|
|
}
|
|
sel.innerHTML = html;
|
|
}
|
|
|
|
function show() {
|
|
$("settings-addtoken-address").value = "";
|
|
$("settings-addtoken-info").textContent = "";
|
|
$("settings-addtoken-info").style.visibility = "hidden";
|
|
// The bundled list holds Ethereum Mainnet contracts, which name no token
|
|
// on any other network.
|
|
$("settings-addtoken-common").classList.toggle(
|
|
"hidden",
|
|
state.networkId !== "mainnet",
|
|
);
|
|
renderTop10();
|
|
renderDropdown();
|
|
showView("settings-addtoken");
|
|
}
|
|
|
|
function init(_ctx) {
|
|
ctx = _ctx;
|
|
|
|
$("btn-settings-addtoken-back").addEventListener("click", () => {
|
|
goBack();
|
|
});
|
|
|
|
$("btn-settings-addtoken-select").addEventListener("click", async () => {
|
|
const sel = $("settings-addtoken-select");
|
|
const opt = sel.options[sel.selectedIndex];
|
|
if (!opt || !opt.value) {
|
|
showFlash("Please select a token.");
|
|
return;
|
|
}
|
|
if (isTracked(opt.value)) {
|
|
showFlash("Already tracked.");
|
|
return;
|
|
}
|
|
const token = {
|
|
address: opt.value,
|
|
symbol: opt.dataset.symbol,
|
|
decimals: parseInt(opt.dataset.decimals, 10),
|
|
name: opt.dataset.name || opt.dataset.symbol,
|
|
};
|
|
trackToken(token);
|
|
await saveState();
|
|
showFlash("Added " + token.symbol);
|
|
renderTop10();
|
|
renderDropdown();
|
|
ctx.doRefreshAndRender();
|
|
});
|
|
|
|
$("btn-settings-addtoken-manual").addEventListener("click", async () => {
|
|
const addr = $("settings-addtoken-address").value.trim();
|
|
if (!addr || !addr.startsWith("0x")) {
|
|
showFlash("Enter a valid contract address starting with 0x.");
|
|
return;
|
|
}
|
|
if (isTracked(addr)) {
|
|
showFlash("Already tracked.");
|
|
return;
|
|
}
|
|
if (isScamAddress(addr)) {
|
|
showFlash("This address is on a known scam/fraud list.");
|
|
return;
|
|
}
|
|
const infoEl = $("settings-addtoken-info");
|
|
infoEl.textContent = "Looking up token...";
|
|
infoEl.style.visibility = "visible";
|
|
log.debugf("Looking up token contract", addr);
|
|
// The network the lookup asks, which Settings may switch away from
|
|
// before it answers, as for a transaction in confirmTx.js.
|
|
const chainId = currentNetwork().chainId;
|
|
try {
|
|
const info = await lookupTokenInfo(
|
|
addr,
|
|
state.rpcUrl,
|
|
state.networkId,
|
|
ctx.pageClosed,
|
|
);
|
|
// A contract address names a token on one network only, so an
|
|
// answer after a switch is not added to the new network's list.
|
|
if (currentNetwork().chainId !== chainId) {
|
|
showFlash(
|
|
"The active network changed before the lookup finished," +
|
|
" so the token was not added.",
|
|
);
|
|
return;
|
|
}
|
|
// The balance list never shows a token that the spoof rule
|
|
// refuses, so such a token is not tracked. The rule never refuses
|
|
// a bundled token, so the "Top tokens:" buttons and the top 100
|
|
// dropdown need no such check.
|
|
if (isSpoofedSymbol(info.symbol, addr, chainId)) {
|
|
showFlash("Not added: this token fakes a known symbol.");
|
|
infoEl.textContent = "";
|
|
infoEl.style.visibility = "hidden";
|
|
return;
|
|
}
|
|
log.infof("Adding token", info.symbol, addr);
|
|
trackToken({
|
|
address: addr,
|
|
symbol: info.symbol,
|
|
decimals: info.decimals,
|
|
name: info.name,
|
|
});
|
|
await saveState();
|
|
showFlash("Added " + info.symbol);
|
|
$("settings-addtoken-address").value = "";
|
|
infoEl.textContent = "";
|
|
infoEl.style.visibility = "hidden";
|
|
renderTop10();
|
|
renderDropdown();
|
|
ctx.doRefreshAndRender();
|
|
} catch (e) {
|
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
|
// src/popup/index.js.
|
|
if (ctx.pageClosed.aborted) return;
|
|
const detail = e.shortMessage || e.message || String(e);
|
|
log.errorf("Adding token failed for", addr, detail);
|
|
// lookupTokenInfo() rejects a contract with a one-line message
|
|
// starting "Not a valid ERC-20 token". Any other error, such as a
|
|
// failed save, can be far longer, so it is only logged.
|
|
showFlash(
|
|
detail.startsWith("Not a valid ERC-20 token")
|
|
? detail
|
|
: "Could not add the token.",
|
|
);
|
|
infoEl.textContent = "";
|
|
infoEl.style.visibility = "hidden";
|
|
}
|
|
});
|
|
}
|
|
|
|
module.exports = { init, show };
|