Compare commits
1 Commits
12190ba428
...
e3f3b331f9
| 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
|
||||
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
|
||||
|
||||
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
||||
|
||||
7
TODO.md
7
TODO.md
@@ -132,7 +132,12 @@ but the review is broader than any of them.
|
||||
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
|
||||
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`
|
||||
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
const { state, currentAddress } = require("../../shared/state");
|
||||
let ctx;
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
|
||||
const { resolveSymbol } = require("../../shared/tokenList");
|
||||
const { isLowHolderCount } = require("../../shared/holders");
|
||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||
@@ -244,8 +245,22 @@ function init(_ctx) {
|
||||
// screen states an unknown balance as unknown, and
|
||||
// validateTransfer() treats it as no balance to spend from, which
|
||||
// is the fail-closed side of an amount nobody can check.
|
||||
tokenBalance = tb ? (tb.balance != null ? tb.balance : null) : "0";
|
||||
tokenDecimals = tb ? tb.decimals : null;
|
||||
tokenBalance = tb ? (tb.balance ?? null) : "0";
|
||||
// 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({
|
||||
|
||||
185
tests/unknownScaleDisplay.test.js
Normal file
185
tests/unknownScaleDisplay.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
343
tests/unknownScaleSend.test.js
Normal file
343
tests/unknownScaleSend.test.js
Normal file
@@ -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