Compare commits
1 Commits
e3f3b331f9
...
12190ba428
| Author | SHA1 | Date | |
|---|---|---|---|
| 12190ba428 |
@@ -914,15 +914,6 @@ 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
|
||||||
|
|||||||
7
TODO.md
7
TODO.md
@@ -132,12 +132,7 @@ 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`. `tokenBalances[].decimals` is the
|
scale in `src/shared/txValidation.js`.
|
||||||
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
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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");
|
||||||
@@ -245,22 +244,8 @@ 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) : "0";
|
tokenBalance = tb ? (tb.balance != null ? tb.balance : null) : "0";
|
||||||
// Resolved the same way balances.js resolved the scale it
|
tokenDecimals = tb ? tb.decimals : null;
|
||||||
// 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({
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
// 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