Files
AutistMask/tests/unknownScaleSend.test.js
T
clawbot 9e5bef1f37
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: show balances and fees below 0.000001 as nonzero on the send screens (closes #343)
The stored ETH and token balances and the send-confirm screen's fee were each
cut to six decimal places, and a token holding cut to zero was dropped, so a
value below 0.000001 read as zero. Balances are now stored exactly, whatever
decimals a token declares, and every nonzero token holding is kept; the balance
check reads a token balance to its first 18 places. The balance lists, the
send-screen token selector, the address total and the remove-address warning
leave out a holding below 0.000001 themselves, through isBelowOneMillionth().
The send and send-confirm screens' balances, reserve and insufficient-balance
messages go through truncateAmountNeverZero(). The send-confirm and approval
screens both render the fee through formatFee(), which prices the exact fee in
USD.

Model: opus-5-5
2026-10-04 08:23:53 +00:00

456 lines
17 KiB
JavaScript

// The Send and confirmation screens for a token whose explorer row carries no
// decimals.
//
// https://git.eeqj.de/sneak/AutistMask/issues/349 made `fetchTokenBalances()`
// store the explorer's own answer — `null` when it reported none — while the
// scale a balance is DISPLAYED at is resolved separately: bundled list, then
// the user's tracked tokens, then the explorer. The two are different
// questions, and `tokenBalances[].decimals` only answers the second one.
//
// A reader that takes the stored field for the display scale therefore gets
// `null` for a token the wallet does know the scale of. On the Send path that
// null reaches `displayedDecimals()` inside `estimateGas()`, which throws, is
// caught as an unavailable fee, and disables Send behind "The network fee could
// not be estimated" — untrue, unactionable, and for a bundled token like WETH
// or DAI whose scale was never in doubt. So the Send screen resolves the scale
// the same way the balance list did, and only carries a null forward when that
// resolution genuinely answers null.
//
// Driven through the real `fetchTokenBalances()`, the real Send review handler
// and the real confirmation screen: a test that hand-wrote `decimals: null`
// onto state would not show which of the two questions each screen is asking.
//
// The reader sites that are pure display are in tests/unknownScaleDisplay.test.js,
// and what the fetcher stores is in tests/fabricatedDecimals.test.js.
"use strict";
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// Everything the confirmation screen would reach the network for. The gas
// estimate is the point: with a usable scale it must succeed, so that a failure
// in these tests is a failure of the scale and not of the stub.
const mockProvider = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
gasPrice: 1000000000n,
}),
estimateGas: async () => 21000n,
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 0n,
};
jest.mock("../src/shared/balances", () => {
const actual = jest.requireActual("../src/shared/balances");
return { ...actual, getProvider: () => mockProvider };
});
// The confirmation screen's best-effort Etherscan label lookup is the one
// thing here that reaches for fetch(). It is stubbed to fail, which is the
// path it already takes offline; the assertion at the bottom of this file
// pins that it is the ONLY fetch these screens make.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM. Every id in index.html that these two views touch resolves to a
// fresh recording element; nothing here depends on layout, only on what the
// views write into the elements and which handlers they register.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
onclick: null,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const { parseUnits } = require("ethers");
const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state");
const {
displayedDecimals,
transferAmountUnits,
} = require("../src/shared/transferAmount");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
const HOLDER = "0x" + "a".repeat(40);
const SECOND_HOLDER = "0x" + "b".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const BLOCKSCOUT = "https://blockscout.example/api/v2";
// Bundled, 18 decimals. The wallet knows this token's scale without asking
// anyone, which is what makes an unsendable WETH a regression rather than a
// refusal.
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
// Neither bundled nor tracked, so the explorer is the only possible source and
// an omission there really is an unknown scale.
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
const FIVE_WETH = 5000000000000000000n;
function row(token = {}, value = FIVE_WETH) {
return {
value: String(value),
token: {
type: "ERC-20",
address_hash: WETH,
symbol: "WETH",
name: "Wrapped Ether",
holders_count: "50000",
...token,
},
};
}
// Fetch the explorer's rows through the real fetcher and put them exactly where
// refreshBalances() puts them.
async function fetchOnto(items) {
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => items,
}));
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
state.wallets = [
{
name: "Wallet 1",
addresses: [
{ address: HOLDER, balance: "1.0", tokenBalances: balances },
],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
return balances;
}
// The same, for two addresses of one wallet holding the same contract. Sending
// is from the first. Two addresses is what it takes to reach
// explorerDecimals()'s disagreement check, which is only reachable across rows.
async function fetchOntoBoth(itemsA, itemsB) {
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => itemsA,
}));
const a = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => itemsB,
}));
const b = await fetchTokenBalances(SECOND_HOLDER, BLOCKSCOUT, []);
state.wallets = [
{
name: "Wallet 1",
addresses: [
{ address: HOLDER, balance: "1.0", tokenBalances: a },
{ address: SECOND_HOLDER, balance: "1.0", tokenBalances: b },
],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
return { a, b };
}
function el(id) {
return global.document.getElementById(id);
}
// Press Review on the Send screen and return the txInfo it hands the
// confirmation screen.
async function reviewSend(tokenAddress, amount) {
let handed = null;
send.init({ showConfirmTx: (info) => (handed = info) });
state.selectedToken = tokenAddress;
el("send-token").value = tokenAddress;
el("send-to").value = RECIPIENT;
el("send-amount").value = amount;
await el("btn-send-review").handlers.get("click")();
return handed;
}
// show() kicks off the gas estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
function text(id) {
return el(id).textContent;
}
function errors() {
return el("confirm-errors").innerHTML;
}
function sendDisabled() {
return el("btn-confirm-send").disabled;
}
beforeEach(() => {
elements.clear();
debugFetch.mockReset();
state.wallets = [];
state.trackedTokens = [];
state.selectedToken = null;
state.fraudContracts = [];
state.hideLowHolderTokens = false;
state.currentView = null;
});
describe("the Send screen resolves the scale rather than reading the stored one", () => {
test("the bundled list knows WETH, and the explorer row does not report a scale", async () => {
expect(TOKEN_BY_ADDRESS.get(WETH.toLowerCase()).decimals).toBe(18);
const balances = await fetchOnto([row()]);
// Stored: the explorer's own answer, which is nothing. Reading THIS is
// what carried a null into the fee estimate.
expect(balances[0].decimals).toBeNull();
// Displayed: the bundled scale, so the quantity on screen is real.
expect(balances[0].balance).toBe("5.0");
});
test("the review hands the confirmation screen the resolved scale, not the stored null", async () => {
const balances = await fetchOnto([row()]);
const txInfo = await reviewSend(WETH, "1.5");
expect(txInfo.tokenDecimals).toBe(18);
expect(txInfo.tokenDecimals).not.toBe(balances[0].decimals);
expect(txInfo.tokenBalance).toBe("5.0");
});
test("that scale estimates a fee and leaves Send enabled", async () => {
await fetchOnto([row()]);
const txInfo = await reviewSend(WETH, "1.5");
confirmTx.show(txInfo);
await settle();
// The regression: displayedDecimals(null) threw in estimateGas(), the
// catch reported the fee as unknown, and Send stayed disabled behind a
// message about the network fee that no retry could clear.
expect(text("confirm-fee-amount")).not.toBe("Unable to estimate");
expect(text("confirm-fee-amount")).toContain("ETH");
expect(errors()).toBe("");
expect(sendDisabled()).toBe(false);
});
test("and the transfer encodes at the scale that was displayed", async () => {
await fetchOnto([row()]);
const txInfo = await reviewSend(WETH, "1.5");
// The two calls confirmTx makes with this field: the gas estimate's
// scale, and the encode, which compares it against the contract's own
// decimals() before parsing.
expect(displayedDecimals(txInfo.tokenDecimals)).toBe(18);
expect(
transferAmountUnits(txInfo.amount, txInfo.tokenDecimals, 18n),
).toBe(parseUnits("1.5", 18));
});
test("a token nothing knows the scale of is still refused, and says why", async () => {
await fetchOnto([
row({ address_hash: NOVEL, symbol: "NOVEL", name: "Novel Token" }),
]);
const txInfo = await reviewSend(NOVEL, "1.5");
// No fallback was introduced: resolution answers null here, and the
// null is what goes forward.
expect(txInfo.tokenDecimals).toBeNull();
expect(txInfo.tokenBalance).toBeNull();
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(sendDisabled()).toBe(true);
});
});
// balances.js resolves the display scale WITHOUT `wallets`, so its explorer leg
// is the row it is formatting. send.js resolves WITH `wallets`, so its explorer
// leg is explorerDecimals(), which answers null when two addresses report
// different scales for one contract — the check that must apply before a scale
// encodes a transfer. The two therefore disagree exactly here, and a stored
// balance formatted at a scale the Send screen just refused is not a balance it
// may state: it would leave validateTransfer() satisfied, the unknown-balance
// sentence unfired, and the fee-estimate failure as the only thing on screen.
describe("a scale the explorer's own rows disagree about", () => {
// 5000000 units at the "6" address A reports, 5e18 at the "18" address B
// reports: both format to "5.0", so the disagreement is in the scale alone
// and not in the quantity.
function novel(decimals, value) {
return row(
{
address_hash: NOVEL,
symbol: "NOVEL",
name: "Novel Token",
decimals,
},
value,
);
}
test("is stored per row, because storage holds the explorer's own answer", async () => {
const { a, b } = await fetchOntoBoth(
[novel("6", 5000000n)],
[novel("18", FIVE_WETH)],
);
expect(a[0].decimals).toBe(6);
expect(a[0].balance).toBe("5.0");
expect(b[0].decimals).toBe(18);
});
test("resolves to null on the Send screen, and takes the balance with it", async () => {
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBeNull();
// The regression this closes: null scale alongside a non-null balance.
expect(txInfo.tokenBalance).toBeNull();
});
test("so the user is told the balance is unknown, not only that the fee failed", async () => {
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
const txInfo = await reviewSend(NOVEL, "1.5");
confirmTx.show(txInfo);
await settle();
// Before the fix: "5.0 NOVEL", an empty confirm-errors, and
// confirm-fee-unknown-error — "the network fee could not be
// estimated... please go back and try again" — as the only explanation
// for a screen that can never proceed.
expect(errors()).not.toBe("");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(sendDisabled()).toBe(true);
// The fee line still reports the estimate as unavailable, because it
// genuinely is — displayedDecimals() refuses the same missing scale.
// What changed is that it is no longer the ONLY thing on the screen,
// and no longer the only offered explanation. This is exactly how the
// token nothing knows the scale of already behaved.
expect(text("confirm-fee-amount")).toBe("Unable to estimate");
expect(el("confirm-fee-unknown-error").style.visibility).toBe(
"visible",
);
});
test("while agreeing rows leave the scale usable", async () => {
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBe(6);
expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("5.0000 NOVEL");
expect(errors()).toBe("");
expect(sendDisabled()).toBe(false);
});
});
describe("the confirmation screen tells an unknown balance from a zero one", () => {
function txInfo(tokenBalance) {
return {
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1.5",
token: NOVEL,
balance: "1.0",
tokenSymbol: "NOVEL",
tokenBalance,
tokenDecimals: tokenBalance === null ? null : 18,
};
}
async function render(tokenBalance) {
state.wallets = [
{
name: "Wallet 1",
addresses: [
{ address: HOLDER, balance: "1.0", tokenBalances: [] },
],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.show(txInfo(tokenBalance));
await settle();
return { balance: text("confirm-balance"), errors: errors() };
}
test("the balance line states unknown rather than a quantity of zero", async () => {
const unknown = await render(null);
const zero = await render("0.0");
expect(unknown.balance).not.toBe(zero.balance);
expect(unknown.balance).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0000 NOVEL");
});
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
// spend from, which is the fail-closed side — but "you have 0.0" is a claim
// about the holding, and this one has no established quantity to claim.
test("the insufficient-balance message names the reason, not a figure", async () => {
const unknown = await render(null);
const zero = await render("0.0");
expect(unknown.errors).not.toBe(zero.errors);
expect(unknown.errors).toContain("This token&#39;s balance is unknown");
expect(unknown.errors).not.toContain("You have");
expect(zero.errors).toContain("You have 0.0000 NOVEL");
expect(zero.errors).not.toContain("balance is unknown");
});
});
test("the only network these screens reached for is the Etherscan label lookup", () => {
for (const [url] of global.fetch.mock.calls) {
expect(String(url)).toMatch(/^https:\/\/etherscan\.io\/address\//);
}
});