fix: the bundled token list names tokens on Ethereum Mainnet only (closes #632)
bundledToken(address, chainId) in tokenList.js answers only for Ethereum Mainnet's chain id. Every reader of the bundled list 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. Seven existing tests read the bundled list without naming a network. Each now names Ethereum Mainnet. Model: opus-5-5
This commit is contained in:
@@ -90,9 +90,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("resolveTokenDecimals", () => {
|
||||
// The bundled list is read only for a token on Ethereum Mainnet.
|
||||
test("prefers the bundled list", () => {
|
||||
state.trackedTokens = [{ address: USDC, symbol: "USDC", decimals: 2 }];
|
||||
expect(resolveTokenDecimals(USDC, state)).toBe(6);
|
||||
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
|
||||
expect(resolveTokenDecimals(USDC, sources)).toBe(6);
|
||||
});
|
||||
|
||||
test("reads a token the user tracks", () => {
|
||||
@@ -124,7 +126,8 @@ describe("resolveTokenDecimals", () => {
|
||||
// answers 0 is used rather than fallen past like the unusable entry above.
|
||||
test("uses a bundled scale of zero", () => {
|
||||
state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }];
|
||||
expect(resolveTokenDecimals(SLP, state)).toBe(0);
|
||||
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
|
||||
expect(resolveTokenDecimals(SLP, sources)).toBe(0);
|
||||
});
|
||||
|
||||
test("uses a tracked scale of zero", () => {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// The approval screen for a site's Sepolia transfer of the contract at USDC's
|
||||
// mainnet address, while Sepolia is active.
|
||||
//
|
||||
// The bundled token list holds Ethereum Mainnet contracts. The contract at the
|
||||
// same address on Sepolia is another token, but the screen named it USDC and
|
||||
// scaled its amount at USDC's decimals
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/632). The bundled list now names
|
||||
// a token only on Ethereum Mainnet.
|
||||
//
|
||||
// Driven through the real approval screen, against the minimal DOM stub
|
||||
// tests/approvalTokenNetwork.test.js uses.
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { Interface } = require("ethers");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { NETWORKS } = require("../src/shared/networks");
|
||||
const approval = require("../src/popup/views/approval");
|
||||
|
||||
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
||||
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
|
||||
// USDC's address on Ethereum Mainnet, where the bundled list names it.
|
||||
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
|
||||
// FROM sends 1234500000 base units to RECIPIENT.
|
||||
const TRANSFER_DATA = new Interface([
|
||||
"function transfer(address to, uint256 amount)",
|
||||
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
|
||||
|
||||
function makeElement(id) {
|
||||
const classes = new Set();
|
||||
const el = {
|
||||
id,
|
||||
textContent: "",
|
||||
value: "",
|
||||
innerHTML: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
listeners: {},
|
||||
classList: {
|
||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
||||
contains: (n) => classes.has(n),
|
||||
toggle: (n, force) => {
|
||||
const on = force === undefined ? !classes.has(n) : force;
|
||||
if (on) classes.add(n);
|
||||
else classes.delete(n);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
addEventListener: (name, fn) => {
|
||||
el.listeners[name] = el.listeners[name] || [];
|
||||
el.listeners[name].push(fn);
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
appendChild: () => {},
|
||||
};
|
||||
// Views reach for .parentElement to hide whole sections.
|
||||
Object.defineProperty(el, "parentElement", {
|
||||
get: () => node(id + "-parent"),
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeDocument() {
|
||||
const els = new Map();
|
||||
return {
|
||||
getElementById(id) {
|
||||
// The debug banner is created on demand by helpers.js; absent
|
||||
// is the state a non-debug, non-testnet popup is in.
|
||||
if (id === "debug-banner") return null;
|
||||
if (!els.has(id)) els.set(id, makeElement(id));
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => makeElement("created"),
|
||||
body: { prepend: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
function node(id) {
|
||||
return globalThis.document.getElementById(id);
|
||||
}
|
||||
|
||||
// One line of the decoded details: its label and what is drawn under it.
|
||||
function decodedLine(label) {
|
||||
return node("approve-tx-decoded-details")
|
||||
.innerHTML.split('<div class="mb-2">')
|
||||
.find((line) =>
|
||||
line.startsWith(`<div class="text-muted">${label}</div>`),
|
||||
);
|
||||
}
|
||||
|
||||
// Open the approval screen, with Sepolia active, for the site's Sepolia
|
||||
// transaction sending TRANSFER_DATA to the contract at USDC's address.
|
||||
async function showSepoliaTransfer() {
|
||||
globalThis.document = makeDocument();
|
||||
globalThis.window = { location: { search: "" }, close: () => {} };
|
||||
globalThis.chrome.runtime = {
|
||||
connect: () => ({ postMessage: () => {} }),
|
||||
sendMessage: (msg, reply) => {
|
||||
if (!reply) return;
|
||||
reply({
|
||||
type: "tx",
|
||||
origin: "https://dapp.example",
|
||||
isPhishingDomain: false,
|
||||
approvedFrom: FROM,
|
||||
approvedTx: {
|
||||
type: 2,
|
||||
from: FROM,
|
||||
chainId: NETWORKS.sepolia.chainId,
|
||||
nonce: "0x7",
|
||||
gasLimit: "0x30d40",
|
||||
maxPriorityFeePerGas: "0x3b9aca00",
|
||||
maxFeePerGas: "0x77359400",
|
||||
to: USDC,
|
||||
value: "0x0",
|
||||
data: TRANSFER_DATA,
|
||||
accessList: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
state.networkId = "sepolia";
|
||||
state.trackedTokens = [];
|
||||
state.networkTrackedTokens = {};
|
||||
state.activeAddress = FROM;
|
||||
state.wallets = [
|
||||
{
|
||||
type: "hd",
|
||||
name: "Wallet 1",
|
||||
xpub: "xpub-wallet-1",
|
||||
encryptedSecret: "encrypted-secret-1",
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
state.currentView = null;
|
||||
approval.init({});
|
||||
await approval.show(1);
|
||||
}
|
||||
|
||||
test("names no token, and states the amount in base units", async () => {
|
||||
await showSepoliaTransfer();
|
||||
expect(state.currentView).toBe("approve-tx");
|
||||
expect(node("approve-tx-to").innerHTML).not.toContain("USDC");
|
||||
expect(decodedLine("Token")).toContain(
|
||||
'<div class="font-bold">Unknown token</div>',
|
||||
);
|
||||
expect(decodedLine("Amount")).toContain(
|
||||
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
|
||||
);
|
||||
});
|
||||
@@ -111,13 +111,15 @@ function respondWith(items) {
|
||||
}
|
||||
|
||||
// Fetch and place the result exactly where refreshBalances() places it, so the
|
||||
// approval screens read what a real refresh would have left on state.
|
||||
// approval screens read what a real refresh on Ethereum Mainnet would have
|
||||
// left on state.
|
||||
async function fetchOnto(items, trackedTokens = []) {
|
||||
respondWith(items);
|
||||
const balances = await fetchTokenBalances(
|
||||
HOLDER,
|
||||
BLOCKSCOUT,
|
||||
trackedTokens,
|
||||
"0x1",
|
||||
);
|
||||
state.trackedTokens = trackedTokens;
|
||||
state.wallets = [
|
||||
|
||||
@@ -192,7 +192,13 @@ describe("the balance-list spam gate", () => {
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||
// On Ethereum Mainnet, the only network the known-token list covers.
|
||||
const balances = await fetchTokenBalances(
|
||||
HOLDER,
|
||||
BLOCKSCOUT,
|
||||
[],
|
||||
"0x1",
|
||||
);
|
||||
expect(balances).toHaveLength(1);
|
||||
expect(balances[0].holders).toBeNull();
|
||||
});
|
||||
|
||||
@@ -67,13 +67,15 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("resolveTokenSymbol", () => {
|
||||
// The bundled list is read only for a token on Ethereum Mainnet.
|
||||
test("reads the bundled list", () => {
|
||||
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
|
||||
expect(resolveTokenSymbol(USDC, { chainId: "0x1" })).toBe("USDC");
|
||||
});
|
||||
|
||||
test("prefers the bundled list over a tracked entry", () => {
|
||||
state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }];
|
||||
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
|
||||
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
|
||||
expect(resolveTokenSymbol(USDC, sources)).toBe("USDC");
|
||||
});
|
||||
|
||||
test("reads a token the user tracks", () => {
|
||||
|
||||
@@ -1495,6 +1495,41 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
expect(mainnet[0].chainId).toBe("0x1");
|
||||
});
|
||||
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled token list
|
||||
// names a contract only on Ethereum Mainnet. On Sepolia, a call to the
|
||||
// contract at USDC's mainnet address is labelled with the network's native
|
||||
// currency. A call to any contract the wallet does not name gets that label.
|
||||
test("a contract call on Sepolia is not named by the bundled list", async () => {
|
||||
respondWith(
|
||||
[
|
||||
{
|
||||
hash: "0x" + "b".repeat(64),
|
||||
block_number: 21000095,
|
||||
timestamp: TS,
|
||||
from: { hash: VICTIM },
|
||||
to: { hash: USDC_CONTRACT, is_contract: true },
|
||||
value: "0",
|
||||
method: "approve",
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
const sepolia = await fetchRecentTransactions(
|
||||
VICTIM,
|
||||
BLOCKSCOUT,
|
||||
"0xaa36a7",
|
||||
);
|
||||
expect(sepolia[0].directionLabel).toBe("Approve");
|
||||
expect(sepolia[0].symbol).toBe("SepoliaETH");
|
||||
const mainnet = await fetchRecentTransactions(
|
||||
VICTIM,
|
||||
BLOCKSCOUT,
|
||||
"0x1",
|
||||
);
|
||||
expect(mainnet[0].symbol).toBe("USDC");
|
||||
});
|
||||
|
||||
test("the fake token transfer survives fetching and is then filtered", async () => {
|
||||
respondWith(
|
||||
[],
|
||||
|
||||
@@ -167,7 +167,8 @@ function row(token = {}, value = FIVE_WETH) {
|
||||
}
|
||||
|
||||
// Fetch the explorer's rows through the real fetcher and put them exactly where
|
||||
// refreshBalances() puts them. The node reports the holding the explorer does.
|
||||
// refreshBalances() puts them on Ethereum Mainnet. The node reports the holding
|
||||
// the explorer does.
|
||||
async function fetchOnto(items) {
|
||||
mockProvider.tokenUnits = BigInt(items[0].value);
|
||||
debugFetch.mockImplementation(async () => ({
|
||||
@@ -176,7 +177,7 @@ async function fetchOnto(items) {
|
||||
statusText: "OK",
|
||||
json: async () => items,
|
||||
}));
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], "0x1");
|
||||
state.wallets = [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
|
||||
Reference in New Issue
Block a user