Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f3b331f9 |
@@ -914,6 +914,15 @@ anywhere, and a holding of it carries no quantity either: its balance is `null`
|
|||||||
printing `0.0000` for money that is really there. `0` is a real scale and is
|
printing `0.0000` for money that is really there. `0` is a real scale and is
|
||||||
never treated as absent.
|
never treated as absent.
|
||||||
|
|
||||||
|
`tokenBalances[].decimals` is therefore the explorer's own answer and nothing
|
||||||
|
else, which is not the same question as the scale a screen should render at.
|
||||||
|
Anything that needs the second one calls `resolveTokenDecimals()` — the balance
|
||||||
|
list, the approval and swap lines, and the Send screen, which carries the
|
||||||
|
resolved scale onto the pending transaction for `transferAmount.js` to encode
|
||||||
|
and compare against. Reading the stored field directly instead answers `null`
|
||||||
|
for a bundled or tracked token the explorer merely omitted, which is not a
|
||||||
|
refusal the wallet has any reason to make.
|
||||||
|
|
||||||
#### Partial USD totals
|
#### Partial USD totals
|
||||||
|
|
||||||
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
||||||
|
|||||||
@@ -132,7 +132,12 @@ but the review is broader than any of them.
|
|||||||
they do today until the next balance refresh, which rewrites `tokenBalances`
|
they do today until the next balance refresh, which rewrites `tokenBalances`
|
||||||
wholesale and needs no user action. The only `18`s left in `src/` are native
|
wholesale and needs no user action. The only `18`s left in `src/` are native
|
||||||
ETH's real scale in `src/shared/uniswap.js` and the fixed-point comparison
|
ETH's real scale in `src/shared/uniswap.js` and the fixed-point comparison
|
||||||
scale in `src/shared/txValidation.js`.
|
scale in `src/shared/txValidation.js`. `tokenBalances[].decimals` is the
|
||||||
|
explorer's answer alone and not the scale a screen renders at, so the Send
|
||||||
|
screen resolves through `resolveTokenDecimals()` like every other consumer:
|
||||||
|
reading the stored field raw carried a `null` into `estimateGas()` for a
|
||||||
|
bundled token such as WETH, which reported an unestimable network fee and left
|
||||||
|
Send disabled behind a message no retry could clear.
|
||||||
|
|
||||||
- 2026-08-23: The background no longer reads or writes the shared `state`
|
- 2026-08-23: The background no longer reads or writes the shared `state`
|
||||||
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
||||||
|
|||||||
+17
-2
@@ -12,6 +12,7 @@ const {
|
|||||||
const { state, currentAddress } = require("../../shared/state");
|
const { state, currentAddress } = require("../../shared/state");
|
||||||
let ctx;
|
let ctx;
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
|
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
|
||||||
const { resolveSymbol } = require("../../shared/tokenList");
|
const { resolveSymbol } = require("../../shared/tokenList");
|
||||||
const { isLowHolderCount } = require("../../shared/holders");
|
const { isLowHolderCount } = require("../../shared/holders");
|
||||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||||
@@ -244,8 +245,22 @@ function init(_ctx) {
|
|||||||
// screen states an unknown balance as unknown, and
|
// screen states an unknown balance as unknown, and
|
||||||
// validateTransfer() treats it as no balance to spend from, which
|
// validateTransfer() treats it as no balance to spend from, which
|
||||||
// is the fail-closed side of an amount nobody can check.
|
// is the fail-closed side of an amount nobody can check.
|
||||||
tokenBalance = tb ? (tb.balance != null ? tb.balance : null) : "0";
|
tokenBalance = tb ? (tb.balance ?? null) : "0";
|
||||||
tokenDecimals = tb ? tb.decimals : null;
|
// Resolved the same way balances.js resolved the scale it
|
||||||
|
// DISPLAYED this token's balance at: bundled list, then the user's
|
||||||
|
// tracked tokens, then the explorer. The stored
|
||||||
|
// tokenBalances[].decimals is the explorer's own answer alone, so
|
||||||
|
// reading it raw carries a null forward for a token the wallet
|
||||||
|
// does know the scale of — and displayedDecimals() then throws
|
||||||
|
// inside estimateGas(), which the confirmation screen reports as
|
||||||
|
// an unestimable fee. Unsendable, over a scale that was never in
|
||||||
|
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||||||
|
// Still null when nothing knows: no fallback, and the unknown
|
||||||
|
// path below is then the real one.
|
||||||
|
tokenDecimals = resolveTokenDecimals(token, {
|
||||||
|
trackedTokens: state.trackedTokens,
|
||||||
|
wallets: state.wallets,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.showConfirmTx({
|
ctx.showConfirmTx({
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// What the screens that READ a stored token balance do with a holding whose
|
||||||
|
// scale nothing knows.
|
||||||
|
//
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/349 stopped `fetchTokenBalances()`
|
||||||
|
// fabricating a scale of 18, so a row it cannot state a quantity for is now
|
||||||
|
// stored with `balance: null`. Every reader of that field therefore has two
|
||||||
|
// distinct inputs where it used to have one, and the property that has to hold
|
||||||
|
// at each of them is the same one this codebase keeps losing:
|
||||||
|
//
|
||||||
|
// null (unknown) and 0 (genuinely zero) must produce DIFFERENT output.
|
||||||
|
//
|
||||||
|
// Losing it is what https://git.eeqj.de/sneak/AutistMask/issues/246,
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/306,
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/322,
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/359 and
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/364 each were. So every case
|
||||||
|
// below asserts the pair, not just that the null branch does something
|
||||||
|
// reasonable: an assertion on the null alone still passes on a build that
|
||||||
|
// renders both as zero, which is precisely the build being guarded against.
|
||||||
|
//
|
||||||
|
// The writer half — that the fetcher stores null rather than 18 — is in
|
||||||
|
// tests/fabricatedDecimals.test.js, and the Send and confirmation screens are
|
||||||
|
// in tests/unknownScaleSend.test.js.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// helpers.js reaches for both at module scope through the modules it pulls in.
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: () => Promise.resolve({}),
|
||||||
|
set: () => Promise.resolve(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runtime: { sendMessage: () => {} },
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: () => null,
|
||||||
|
createElement: () => ({ style: {}, classList: { toggle() {} } }),
|
||||||
|
body: { prepend: () => {} },
|
||||||
|
addEventListener: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
balanceLine,
|
||||||
|
balanceLinesForAddress,
|
||||||
|
addressHoldsFunds,
|
||||||
|
} = require("../src/popup/views/helpers");
|
||||||
|
const {
|
||||||
|
prices,
|
||||||
|
clearPrices,
|
||||||
|
getAddressValue,
|
||||||
|
} = require("../src/shared/prices");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
|
||||||
|
const NOVEL = "0x1111111111111111111111111111111111111111";
|
||||||
|
|
||||||
|
// One stored tokenBalances row. `balance: null` is what balances.js writes for
|
||||||
|
// a holding whose scale nothing knows; "0.0" is a quantity that was actually
|
||||||
|
// established and is zero.
|
||||||
|
function holding(balance) {
|
||||||
|
return {
|
||||||
|
address: NOVEL,
|
||||||
|
symbol: "NOVEL",
|
||||||
|
decimals: balance === null ? null : 18,
|
||||||
|
balance,
|
||||||
|
holders: 50000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function address(balance) {
|
||||||
|
return {
|
||||||
|
address: "0x" + "a".repeat(40),
|
||||||
|
balance: "0",
|
||||||
|
tokenBalances: [holding(balance)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The quantity cell of a rendered row, which is the second of the two spans
|
||||||
|
// inside the fixed-width span.
|
||||||
|
function quantities(html) {
|
||||||
|
return [...html.matchAll(/<span>([^<]*)<\/span>/g)].map((m) => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearPrices();
|
||||||
|
state.wallets = [];
|
||||||
|
state.trackedTokens = [];
|
||||||
|
state.activeAddress = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearPrices();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("balanceLine", () => {
|
||||||
|
test("an unknown quantity and a zero one render differently", () => {
|
||||||
|
const unknown = balanceLine("NOVEL", null, null, NOVEL);
|
||||||
|
const zero = balanceLine("NOVEL", 0, null, NOVEL);
|
||||||
|
expect(unknown).not.toBe(zero);
|
||||||
|
expect(quantities(unknown)).toEqual(["NOVEL", "quantity unknown"]);
|
||||||
|
expect(quantities(zero)).toEqual(["NOVEL", "0.0000"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown quantity produces no fiat figure, a zero one does", () => {
|
||||||
|
prices.NOVEL = 3;
|
||||||
|
const unknown = balanceLine("NOVEL", null, 3, NOVEL);
|
||||||
|
const zero = balanceLine("NOVEL", 0, 3, NOVEL);
|
||||||
|
// A price times an unknown quantity is not $0.00: that is the same
|
||||||
|
// claim of "nothing here" the quantity cell just refused to make.
|
||||||
|
expect(unknown).toContain(
|
||||||
|
'<span class="text-right text-muted flex-1"> </span>',
|
||||||
|
);
|
||||||
|
expect(zero).toContain(
|
||||||
|
'<span class="text-right text-muted flex-1">$0.00</span>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("balanceLinesForAddress", () => {
|
||||||
|
// The show-zero setting is a statement about zeroes. An unknown quantity
|
||||||
|
// is not one, so hiding the row would assert the zero nobody established
|
||||||
|
// and the holding would vanish from the list entirely.
|
||||||
|
test("hiding zero balances hides the zero row and keeps the unknown one", () => {
|
||||||
|
const unknown = balanceLinesForAddress(address(null), [], false);
|
||||||
|
const zero = balanceLinesForAddress(address("0.0"), [], false);
|
||||||
|
expect(unknown).not.toBe(zero);
|
||||||
|
expect(unknown).toContain("quantity unknown");
|
||||||
|
expect(unknown).toContain("NOVEL");
|
||||||
|
expect(zero).not.toContain("NOVEL");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("showing zero balances still tells the two apart", () => {
|
||||||
|
const unknown = balanceLinesForAddress(address(null), [], true);
|
||||||
|
const zero = balanceLinesForAddress(address("0.0"), [], true);
|
||||||
|
expect(unknown).not.toBe(zero);
|
||||||
|
expect(quantities(unknown)).toEqual([
|
||||||
|
"ETH",
|
||||||
|
"0.0000",
|
||||||
|
"NOVEL",
|
||||||
|
"quantity unknown",
|
||||||
|
]);
|
||||||
|
expect(quantities(zero)).toEqual(["ETH", "0.0000", "NOVEL", "0.0000"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addressHoldsFunds", () => {
|
||||||
|
// Read by deleteAddress.js to decide whether removing the address is
|
||||||
|
// warned about. balances.js drops a row of zero base units before any
|
||||||
|
// scale is consulted, so a row that survived with no quantity is holding
|
||||||
|
// something, and the warning must err towards warning.
|
||||||
|
test("an unknown balance holds funds, a zero balance does not", () => {
|
||||||
|
expect(addressHoldsFunds(address(null))).toBe(true);
|
||||||
|
expect(addressHoldsFunds(address("0.0"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAddressValue", () => {
|
||||||
|
// `usd` is the value of what could be priced and `partial` says it is a
|
||||||
|
// floor rather than the total. An unpriceable holding is exactly what
|
||||||
|
// `partial` exists for; a holding of zero can neither add to the total nor
|
||||||
|
// make it incomplete.
|
||||||
|
test("an unknown balance makes the total partial, a zero balance does not", () => {
|
||||||
|
prices.ETH = 2000;
|
||||||
|
prices.NOVEL = 3;
|
||||||
|
const unknown = getAddressValue(address(null));
|
||||||
|
const zero = getAddressValue(address("0.0"));
|
||||||
|
expect(unknown).not.toEqual(zero);
|
||||||
|
expect(unknown).toEqual({ usd: 0, partial: true });
|
||||||
|
expect(zero).toEqual({ usd: 0, partial: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown balance is not priced as zero of the token", () => {
|
||||||
|
prices.ETH = 2000;
|
||||||
|
prices.NOVEL = 3;
|
||||||
|
// The same row with a real quantity of 10 is worth $30. Neither that
|
||||||
|
// figure nor a confident $0.00 may be stated for the unknown one.
|
||||||
|
expect(getAddressValue(address("10.0"))).toEqual({
|
||||||
|
usd: 30,
|
||||||
|
partial: false,
|
||||||
|
});
|
||||||
|
expect(getAddressValue(address(null)).usd).toBe(0);
|
||||||
|
expect(getAddressValue(address(null)).partial).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
// 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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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's balance is unknown");
|
||||||
|
expect(sendDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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.0 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's balance is unknown");
|
||||||
|
expect(unknown.errors).not.toContain("You have");
|
||||||
|
expect(zero.errors).toContain("You have 0.0 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\//);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user