milestone 1.0.0: approval-path security, build-integrity guard, e2e harness and filter coverage #190
21
README.md
21
README.md
@@ -902,6 +902,27 @@ the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). An
|
||||
unbounded allowance or permit needs no scale to describe and is still shown as
|
||||
`Unlimited`.
|
||||
|
||||
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
|
||||
sources are read as authoritative, so a value written into one of them cannot be
|
||||
recognized as a guess afterwards: a fabricated `18` reads exactly like a real
|
||||
`18`, and the refusal above then never fires. So `fetchTokenBalances()` in
|
||||
`src/shared/balances.js` stores what the explorer reported or `null`, never a
|
||||
default, and the same holds for the history list's token transfers in
|
||||
`src/shared/transactions.js`. A token whose `decimals()` reverts has no scale
|
||||
anywhere, and a holding of it carries no quantity either: its balance is `null`
|
||||
— read as unknown, never as zero — and the balance list says so rather than
|
||||
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
|
||||
|
||||
40
TODO.md
40
TODO.md
@@ -122,6 +122,46 @@ but the review is broader than any of them.
|
||||
`src/shared/restorableViews.js`, since `persistedState.js` requires it and
|
||||
that module is in the background bundle.
|
||||
|
||||
- 2026-08-23: An explorer that reports no `decimals` for a token no longer has a
|
||||
scale invented for it before storage
|
||||
([#349](https://git.eeqj.de/sneak/AutistMask/issues/349)).
|
||||
`fetchTokenBalances()` did `parseInt(item.token.decimals || "18", 10)` on the
|
||||
way in, so a token whose `decimals()` reverts was written to
|
||||
`tokenBalances[].decimals` as a fabricated `18` that no reader could tell from
|
||||
a real one. That is upstream of the resolve-or-refuse rule
|
||||
([#306](https://git.eeqj.de/sneak/AutistMask/issues/306),
|
||||
[#340](https://git.eeqj.de/sneak/AutistMask/issues/340)): both approval paths
|
||||
read this stored value as an authoritative source, so the guess walked past
|
||||
refusals that were intact and simply never fired. The stored value is now the
|
||||
explorer's own answer or `null`, and both the ERC-20 amount line and the swap
|
||||
lines reach `unknownDecimalsAmount()` on it. The history list's token
|
||||
transfers carried the same `|| "18"` and now state base units with the scale
|
||||
unknown rather than a quantity. A holding whose scale nothing knows carries
|
||||
`balance: null` — unknown, not zero — and the balance list, the USD total, the
|
||||
Send screen and the confirmation screen each say so instead of printing
|
||||
`0.0000` for money that is really there. The uint8 check is one shared
|
||||
`toDecimals()` rather than three copies, and it answers `0` for a real scale
|
||||
of zero: `|| "18"` collapsed that to eighteen, the trap of
|
||||
[#246](https://git.eeqj.de/sneak/AutistMask/issues/246). Existing installs
|
||||
hold `18`s that cannot be told apart retroactively; they display exactly as
|
||||
they do today until the next balance refresh, which rewrites `tokenBalances`
|
||||
wholesale and needs no user action. No `|| 18` or `?? 18` fallback remains
|
||||
anywhere in `src/`; the literal `18`s that do remain are real data, not
|
||||
defaults — 432 per-token `decimals: 18` entries in the bundled
|
||||
`src/shared/tokenList.js`, and, outside that file, only native ETH's
|
||||
protocol-defined scale in `src/shared/uniswap.js` and the fixed-point
|
||||
comparison 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. Send resolves
|
||||
with `wallets`, which adds the cross-address disagreement check the balance
|
||||
list does not make, so the two can differ; where they do, the stored quantity
|
||||
was computed at a scale Send has refused, and it is withdrawn with it. An
|
||||
unknown scale is an unknown balance, and the user is told that rather than
|
||||
that the fee could not be estimated.
|
||||
|
||||
- 2026-08-23: The background no longer reads or writes the shared `state`
|
||||
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
||||
also closes the cold-worker wrong-chain send
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
displaySymbol,
|
||||
truncateMiddle,
|
||||
balanceLine,
|
||||
unknownableAmount,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
@@ -118,7 +119,9 @@ function show() {
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
);
|
||||
amount = tb ? parseFloat(tb.balance || "0") : 0;
|
||||
// null when the scale is unknown: no quantity to show, and none to
|
||||
// price. balanceLine() states that rather than printing 0.0000.
|
||||
amount = tb ? unknownableAmount(tb.balance) : 0;
|
||||
price = getPrice(symbol);
|
||||
}
|
||||
|
||||
@@ -152,7 +155,7 @@ function show() {
|
||||
attachCopyHandlers($("address-token-line"));
|
||||
|
||||
// USD total for this token only
|
||||
const usdVal = price ? amount * price : null;
|
||||
const usdVal = price && amount !== null ? amount * price : null;
|
||||
const usdStr = formatUsd(usdVal);
|
||||
$("address-token-usd-total").innerHTML = usdStr || " ";
|
||||
|
||||
|
||||
@@ -139,12 +139,17 @@ function show(txInfo) {
|
||||
|
||||
// Balance (with inline USD)
|
||||
if (isErc20) {
|
||||
const bal = txInfo.tokenBalance || "0";
|
||||
const balUsd = tokenPrice ? parseFloat(bal) * tokenPrice : null;
|
||||
$("confirm-balance").textContent = valueWithUsd(
|
||||
bal + " " + symbol,
|
||||
balUsd,
|
||||
);
|
||||
// null is a balance whose scale nothing knows, not a balance of zero
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
||||
// refused at encode time for the same missing scale; what this line
|
||||
// must not do is state a quantity nobody established.
|
||||
const bal = txInfo.tokenBalance;
|
||||
const balUsd =
|
||||
tokenPrice && bal != null ? parseFloat(bal) * tokenPrice : null;
|
||||
$("confirm-balance").textContent =
|
||||
bal == null
|
||||
? "unknown (" + symbol + ")"
|
||||
: valueWithUsd(bal + " " + symbol, balUsd);
|
||||
} else {
|
||||
const bal = txInfo.balance || "0";
|
||||
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
||||
@@ -235,17 +240,22 @@ function renderValidation(txInfo) {
|
||||
}
|
||||
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
|
||||
messages.push(
|
||||
"Insufficient " +
|
||||
symbol +
|
||||
" balance. You have " +
|
||||
txInfo.tokenBalance +
|
||||
" " +
|
||||
symbol +
|
||||
" but are trying to send " +
|
||||
txInfo.amount +
|
||||
" " +
|
||||
symbol +
|
||||
".",
|
||||
txInfo.tokenBalance == null
|
||||
? "This token's balance is unknown, because nothing this" +
|
||||
" wallet can consult reports how many decimal places it" +
|
||||
" uses, so the amount you are trying to send cannot be" +
|
||||
" checked against it."
|
||||
: "Insufficient " +
|
||||
symbol +
|
||||
" balance. You have " +
|
||||
txInfo.tokenBalance +
|
||||
" " +
|
||||
symbol +
|
||||
" but are trying to send " +
|
||||
txInfo.amount +
|
||||
" " +
|
||||
symbol +
|
||||
".",
|
||||
);
|
||||
}
|
||||
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
||||
|
||||
@@ -197,6 +197,15 @@ function showFlash(msg, duration = 2000) {
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// A stored token balance as a number, or null when there is no number in it.
|
||||
// balances.js writes null for a holding whose scale nothing knows, and this
|
||||
// keeps that null from becoming a zero one dereference later.
|
||||
function unknownableAmount(balance) {
|
||||
if (balance == null) return null;
|
||||
const n = parseFloat(balance);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// One row of the balance list: symbol, quantity, fiat value.
|
||||
//
|
||||
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
||||
@@ -204,9 +213,18 @@ function showFlash(msg, duration = 2000) {
|
||||
// attacker-chosen length until it has been through displaySymbol. This is
|
||||
// the row that issue #307 was reported against: every screen that lists a
|
||||
// holding renders through here.
|
||||
//
|
||||
// `amount` is null for a holding whose scale nothing knows
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). There is no quantity to
|
||||
// print for it and no fiat value to derive from one, and printing 0.0000 for
|
||||
// a real holding is the failure this whole rule exists to prevent, so the row
|
||||
// says so instead.
|
||||
function balanceLine(symbol, amount, price, tokenId) {
|
||||
const qty = amount.toFixed(4);
|
||||
const usd = price ? formatUsd(amount * price) || " " : " ";
|
||||
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||
const usd =
|
||||
price && amount !== null
|
||||
? formatUsd(amount * price) || " "
|
||||
: " ";
|
||||
// tokenId is a contract address out of the same explorer JSON, and it
|
||||
// lands inside a quoted attribute.
|
||||
const tokenAttr = tokenId ? ` data-token="${escapeHtml(tokenId)}"` : "";
|
||||
@@ -233,7 +251,12 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
);
|
||||
const seen = new Set();
|
||||
for (const t of addr.tokenBalances || []) {
|
||||
const bal = parseFloat(t.balance || "0");
|
||||
// A null balance is a holding of an unstatable amount, not a holding
|
||||
// of zero, so the show-zero setting has no say over it: hiding it
|
||||
// would be asserting the zero nobody established. Anything that does
|
||||
// not parse to a finite number is unknown for the same reason — the
|
||||
// `|| "0"` this replaced turned both into a confident zero.
|
||||
const bal = unknownableAmount(t.balance);
|
||||
if (bal === 0 && !showZero) continue;
|
||||
html += balanceLine(
|
||||
t.symbol,
|
||||
@@ -266,7 +289,12 @@ function addressHoldsFunds(addr) {
|
||||
if (!addr) return false;
|
||||
if (parseFloat(addr.balance || "0") > 0) return true;
|
||||
for (const t of addr.tokenBalances || []) {
|
||||
if (parseFloat(t.balance || "0") > 0) return true;
|
||||
// A null balance is a holding whose amount could not be stated —
|
||||
// balances.js drops a row of zero base units before the scale is
|
||||
// consulted, so a row that survived with no quantity is holding
|
||||
// something. Warning about funds must err towards warning.
|
||||
const bal = unknownableAmount(t.balance);
|
||||
if (bal === null || bal > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -525,6 +553,7 @@ module.exports = {
|
||||
balanceLine,
|
||||
balanceLinesForAddress,
|
||||
addressHoldsFunds,
|
||||
unknownableAmount,
|
||||
addressColor,
|
||||
addressDotHtml,
|
||||
escapeHtml,
|
||||
|
||||
@@ -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");
|
||||
@@ -159,9 +160,14 @@ function updateSendBalance() {
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
);
|
||||
const bal = tb ? tb.balance || "0" : "0";
|
||||
// A null balance is a holding whose scale nothing knows. Saying "0"
|
||||
// for it would be a claim about the amount; the send itself is
|
||||
// refused later by transferAmountUnits() for the same missing scale.
|
||||
const bal = tb ? tb.balance : "0";
|
||||
$("send-balance").textContent =
|
||||
"Current balance: " + bal + " " + symbol;
|
||||
bal == null
|
||||
? "Current balance: unknown (" + symbol + ")"
|
||||
: "Current balance: " + bal + " " + symbol;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,8 +241,45 @@ function init(_ctx) {
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
);
|
||||
tokenBalance = tb ? tb.balance || "0" : "0";
|
||||
tokenDecimals = tb ? tb.decimals : null;
|
||||
// null carried through rather than flattened to "0": the confirm
|
||||
// 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) : "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.
|
||||
//
|
||||
// Resolved WITH `wallets`, which balances.js does not pass: that
|
||||
// adds explorerDecimals()'s cross-address check, so a contract two
|
||||
// addresses report different scales for answers null rather than
|
||||
// picking one. That check has to apply here, because this value
|
||||
// encodes a transfer; balances.js is formatting one explorer row
|
||||
// at fetch time and cannot consult a state it is in the middle of
|
||||
// replacing.
|
||||
tokenDecimals = resolveTokenDecimals(token, {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
});
|
||||
// The two resolutions can therefore differ, and where they do, the
|
||||
// stored `balance` is a quantity computed at a scale this screen
|
||||
// has just declined to stand behind. Stating it would leave
|
||||
// validateTransfer() checking the amount against a number the
|
||||
// wallet does not vouch for, and — since the unknown-balance path
|
||||
// is gated on the balance, not on the scale — would leave the
|
||||
// fee-estimate failure as the only thing on the confirmation
|
||||
// screen, which says nothing about decimals. Unknown scale means
|
||||
// unknown balance. Only a stored quantity is withdrawn: the "0"
|
||||
// for a token that has no row at all is an absence of holdings,
|
||||
// which is true at every scale.
|
||||
if (tb && tokenDecimals === null) tokenBalance = null;
|
||||
}
|
||||
|
||||
ctx.showConfirmTx({
|
||||
|
||||
@@ -23,33 +23,14 @@
|
||||
// disputed is refused rather than guessed at.
|
||||
|
||||
// Solidity's decimals() is a uint8, and every source here is ultimately
|
||||
// reporting that call's result.
|
||||
const { MAX_DECIMALS } = require("./transferAmount");
|
||||
// reporting that call's result. toDecimals() is that check, shared with the
|
||||
// send path rather than copied: the bundled list stores numbers, the
|
||||
// explorer's copy arrives as a string, and a token the user added by hand
|
||||
// carries whatever lookupTokenInfo() got back, so the accepted types are
|
||||
// enumerated rather than coerced.
|
||||
const { toDecimals } = require("./transferAmount");
|
||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
|
||||
// A decimals value as a number, or null if it is not one. The bundled list
|
||||
// stores numbers, the explorer's copy arrives as a string, and a token the
|
||||
// user added by hand can carry whatever lookupTokenInfo() got back, so the
|
||||
// accepted types are enumerated rather than coerced: Number([]) is 0 and
|
||||
// Number(true) is 1, so a coercing check would read an empty array as a scale
|
||||
// of zero and format the amount as whole tokens.
|
||||
function toDecimals(value) {
|
||||
let n;
|
||||
if (typeof value === "number") {
|
||||
n = value;
|
||||
} else if (typeof value === "bigint") {
|
||||
if (value < 0n || value > BigInt(MAX_DECIMALS)) return null;
|
||||
n = Number(value);
|
||||
} else if (typeof value === "string") {
|
||||
if (!/^[0-9]+$/.test(value)) return null;
|
||||
n = Number(value);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isInteger(n) || n < 0 || n > MAX_DECIMALS) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Every decimals the explorer reported for this contract, across all the
|
||||
// addresses whose balances have been fetched. They describe one contract, so
|
||||
// they should agree; a set that does not agree is a scale in dispute, and this
|
||||
|
||||
@@ -15,6 +15,8 @@ const { deriveAddressFromXpub } = require("./wallet");
|
||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||
const { toDecimals } = require("./transferAmount");
|
||||
const { resolveTokenDecimals } = require("./approvalAmount");
|
||||
|
||||
// Use a static network to skip auto-detection (which can fail and cause
|
||||
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
||||
@@ -66,10 +68,28 @@ function formatTokenBalance(raw, decimals) {
|
||||
return parts[0] + "." + dec;
|
||||
}
|
||||
|
||||
// The explorer's reported holding as an exact base-unit integer, or null when
|
||||
// it reported nothing usable. Base units carry no scale, so this value is
|
||||
// meaningful before the scale is known — which is what lets a holding of zero
|
||||
// be recognised as zero without guessing a scale to divide it by.
|
||||
function rawUnits(value) {
|
||||
if (typeof value === "bigint") return value >= 0n ? value : null;
|
||||
if (typeof value === "number") {
|
||||
return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
|
||||
}
|
||||
if (typeof value !== "string" || !/^[0-9]+$/.test(value)) return null;
|
||||
return BigInt(value);
|
||||
}
|
||||
|
||||
// Fetch token balances for a single address from Blockscout.
|
||||
// Returns [{ address, symbol, decimals, balance }].
|
||||
// Returns [{ address, name, symbol, decimals, balance, holders }].
|
||||
// Filters out spam: only shows tokens that are in the known token list,
|
||||
// explicitly tracked by the user, or have >= 1000 holders.
|
||||
//
|
||||
// `decimals` and `balance` are each null when the answer is unknown, the same
|
||||
// way `holders` already is. Absence is never filled in here: this is the
|
||||
// upstream of every screen that displays a token amount, so a value invented
|
||||
// at this point is indistinguishable from a real one everywhere below it.
|
||||
async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
try {
|
||||
const resp = await debugFetch(
|
||||
@@ -94,11 +114,46 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
// is unchanged.
|
||||
const type = String(item.token?.type || "").toUpperCase();
|
||||
if (type !== "ERC-20") continue;
|
||||
const decimals = parseInt(item.token.decimals || "18", 10);
|
||||
const bal = formatTokenBalance(item.value || "0", decimals);
|
||||
if (bal === "0.0") continue;
|
||||
|
||||
const tokenAddr = (item.token.address_hash || "").toLowerCase();
|
||||
|
||||
// What the explorer reported, or null. NEVER a default: this
|
||||
// value is written to state and every later reader — the approval
|
||||
// screen's amount line, the swap lines, the Send screen — takes it
|
||||
// as the token's resolved scale. A fabricated 18 reads exactly
|
||||
// like a real 18 at that point, so it does not merely display the
|
||||
// wrong quantity, it walks straight past the refusal those screens
|
||||
// already have for a scale nobody knows
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||||
const decimals = toDecimals(item.token.decimals);
|
||||
|
||||
const raw = rawUnits(item.value);
|
||||
// No usable amount at all is nothing to list, exactly as a
|
||||
// formatted "0.0" was before. Checked on the base-unit integer so
|
||||
// it does not depend on knowing the scale: zero base units is zero
|
||||
// tokens at every scale, and a value the explorer did not report
|
||||
// as an integer is not a holding.
|
||||
if (raw === null || raw === 0n) continue;
|
||||
|
||||
// The scale this row's balance is DISPLAYED at, which is not the
|
||||
// same question as what the explorer said. The bundled list and
|
||||
// the tokens the user tracks both outrank the explorer already
|
||||
// (resolveTokenDecimals), so a token they know keeps showing its
|
||||
// real quantity even when the explorer's entry omits decimals.
|
||||
// Only what neither of them nor the explorer knows is unknown.
|
||||
// The stored `decimals` above stays the explorer's own answer
|
||||
// either way: copying another source into it would make
|
||||
// explorerDecimals()'s disagreement check compare something other
|
||||
// than explorer values.
|
||||
const known = resolveTokenDecimals(tokenAddr, { trackedTokens });
|
||||
const scale = known !== null ? known : decimals;
|
||||
// null is a holding of an amount that cannot be stated, which is
|
||||
// not the same as a holding of zero, and must never render as one.
|
||||
// With a scale, the display filter proper applies: a balance that
|
||||
// rounds to zero at six places is dust and is not listed. Without
|
||||
// one there is no such judgement to make, and the row is kept.
|
||||
const bal = scale === null ? null : formatTokenBalance(raw, scale);
|
||||
if (bal === "0.0") continue;
|
||||
// null means the explorer reported no count, which is not the
|
||||
// same as a count of zero. This gate is not the low-holder
|
||||
// display filter: it has no user-facing off switch and governs
|
||||
@@ -127,7 +182,15 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
address: item.token.address_hash,
|
||||
name: item.token.name || "",
|
||||
symbol: item.token.symbol || "???",
|
||||
// null means the explorer reported no usable scale — unknown,
|
||||
// not 18. Distinguishable from a real 18 at read time is the
|
||||
// entire point: resolveTokenDecimals() falls through a null to
|
||||
// its refusal, and takes an 18 as the answer.
|
||||
decimals: decimals,
|
||||
// null means nothing anywhere knows the scale, so there is no
|
||||
// token quantity to state. Not "0.0": a nonzero holding shown
|
||||
// as zero is the same lie in the balance list that the
|
||||
// approval screens refuse to tell.
|
||||
balance: bal,
|
||||
holders: holders,
|
||||
});
|
||||
|
||||
@@ -85,6 +85,14 @@ function isRecord(value) {
|
||||
// alternative — refusing the whole record — sends a user whose wallets are
|
||||
// perfectly readable to an export-or-erase screen over a token list. An entry
|
||||
// that is a record with a text address is kept verbatim, extra fields and all.
|
||||
//
|
||||
// Verbatim is load-bearing for the fields BESIDE the address. A tokenBalances
|
||||
// entry carries `decimals: null` and `balance: null` when nothing knows the
|
||||
// token's scale (src/shared/balances.js,
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/349), and those nulls are the
|
||||
// record that the value is unknown. Only `address` decides whether an entry
|
||||
// survives, so an unknown-scale holding is kept — flooring a null here to some
|
||||
// default would put the guess back one layer down from where it was removed.
|
||||
function tokenRefs(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
|
||||
@@ -78,9 +78,18 @@ function getAddressValue(addr) {
|
||||
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
||||
let partial = false;
|
||||
for (const token of addr.tokenBalances || []) {
|
||||
const tokenBal = parseFloat(token.balance || "0");
|
||||
// A null balance is a holding whose scale nothing knows, so it has no
|
||||
// quantity to price — but it is still a holding, and a total that
|
||||
// silently omits it would read as complete. That is exactly what
|
||||
// `partial` is for (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||||
if (token.balance == null) {
|
||||
partial = true;
|
||||
continue;
|
||||
}
|
||||
const tokenBal = parseFloat(token.balance);
|
||||
// A balance of zero is not a holding: it can neither add to the total
|
||||
// nor make it incomplete.
|
||||
// nor make it incomplete. Anything that is not a number at all is not
|
||||
// a holding this can price either, and is left to the same rule.
|
||||
if (!(tokenBal > 0)) continue;
|
||||
if (prices[token.symbol]) {
|
||||
usd += tokenBal * prices[token.symbol];
|
||||
|
||||
@@ -36,7 +36,13 @@
|
||||
// indexed, assigned into, or .toLowerCase()'d — where a truthy value of
|
||||
// the wrong type throws on the first read. The entries matter as much as
|
||||
// the container: [1, 2] IS a list, and `t.address` is one level below the
|
||||
// Array.isArray().
|
||||
// Array.isArray(). What is checked on an ENTRY is the field the check
|
||||
// exists for and no more — for trackedTokens and tokenBalances that is
|
||||
// `address` alone; the rest of an entry is taken verbatim. So an entry's
|
||||
// `decimals` and `balance` may be null, which is how balances.js records
|
||||
// that nothing knows the token's scale
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349), and every reader
|
||||
// handles that null rather than being defended from it here.
|
||||
// Container shape only: allowedSites, deniedSites. A falsy value or a list
|
||||
// becomes {}; anything else is taken as stored and the entries are not
|
||||
// checked.
|
||||
|
||||
@@ -11,6 +11,10 @@ const { log, debugFetch } = require("./log");
|
||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||
// The uint8 test every scale in this wallet goes through. Shared, not copied:
|
||||
// a scale is either reported or it is unknown, and "unknown" must mean the
|
||||
// same thing here as it does on the screens that refuse to format one.
|
||||
const { toDecimals } = require("./transferAmount");
|
||||
// The plain 4-decimal rule. The history and balance lists deliberately keep
|
||||
// truncation without the approval screens' nonzero floor: the transaction
|
||||
// detail view is the authoritative record and already shows exact precision.
|
||||
@@ -92,21 +96,37 @@ function parseTx(tx, addrLower) {
|
||||
function parseTokenTransfer(tt, addrLower) {
|
||||
const from = tt.from?.hash || "";
|
||||
const to = tt.to?.hash || "";
|
||||
const decimals = parseInt(tt.total?.decimals || "18", 10);
|
||||
// The explorer's own answer, or null. Never a default: a transfer of
|
||||
// 5000000000 units formatted at a guessed 18 reads as 0.000000005, and
|
||||
// nothing downstream can tell that from a real 18-decimal transfer of
|
||||
// that size. `parseInt(x || "18", 10)` also collapsed a genuine scale of
|
||||
// ZERO into 18 (https://git.eeqj.de/sneak/AutistMask/issues/246).
|
||||
const decimals = toDecimals(tt.total?.decimals);
|
||||
const rawVal = tt.total?.value || "0";
|
||||
const direction =
|
||||
normalizeAddress(from) === addrLower ? "sent" : "received";
|
||||
const sym = tt.token?.symbol || "?";
|
||||
// Without a scale there is no token quantity, so none is stated: the list
|
||||
// row falls back to the symbol alone and the detail screen to its
|
||||
// direction label, exactly as the contract-call rows above already do.
|
||||
// The exact figure is not lost — it is the base-unit line below, which is
|
||||
// the one number that needs no scale to be true.
|
||||
const formatted =
|
||||
decimals === null ? "" : formatTxValue(formatUnits(rawVal, decimals));
|
||||
const exact = decimals === null ? "" : formatUnits(rawVal, decimals);
|
||||
return {
|
||||
hash: tt.transaction_hash,
|
||||
blockNumber: tt.block_number,
|
||||
timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
|
||||
from: from,
|
||||
to: to,
|
||||
value: formatTxValue(formatUnits(rawVal, decimals)),
|
||||
exactValue: formatUnits(rawVal, decimals),
|
||||
value: formatted,
|
||||
exactValue: exact,
|
||||
rawAmount: rawVal,
|
||||
rawUnit: sym + " base units (10^-" + decimals + ")",
|
||||
rawUnit:
|
||||
decimals === null
|
||||
? sym + " base units (decimals unknown)"
|
||||
: sym + " base units (10^-" + decimals + ")",
|
||||
valueGwei: null,
|
||||
symbol: sym,
|
||||
direction: direction,
|
||||
|
||||
@@ -52,7 +52,7 @@ function mismatchMessage(displayed, onChain) {
|
||||
);
|
||||
}
|
||||
|
||||
// A decimals value from either source as a number, or null if it is not one.
|
||||
// A decimals value from any source as a number, or null if it is not one.
|
||||
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
|
||||
// as a string, so both of those are accepted alongside a plain number; anything
|
||||
// fractional, negative, out of uint8 range, or of any other type at all is not.
|
||||
@@ -60,7 +60,16 @@ function mismatchMessage(displayed, onChain) {
|
||||
// The types are enumerated rather than coerced because Number() is far too
|
||||
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
|
||||
// admit an empty array as a scale of zero and encode a whole-token transfer
|
||||
// against it.
|
||||
// against it. Absence answers null and never a default, and a real scale of
|
||||
// ZERO answers 0 — the two are different answers, which is the whole point:
|
||||
// a falsy-collapsing `value || 18` cannot tell them apart, and neither can a
|
||||
// reader of what it wrote (https://git.eeqj.de/sneak/AutistMask/issues/246).
|
||||
//
|
||||
// Exported because every module that has to decide whether it knows a token's
|
||||
// scale needs exactly this test, and three separate copies of it is three
|
||||
// places for the answer to drift: approvalAmount.js resolves the scale the
|
||||
// approval screens display at, and balances.js decides what the explorer
|
||||
// actually reported before it is stored.
|
||||
function toDecimals(value) {
|
||||
let n;
|
||||
if (typeof value === "number") {
|
||||
@@ -110,6 +119,7 @@ module.exports = {
|
||||
displayedDecimals,
|
||||
transferAmountUnits,
|
||||
mismatchMessage,
|
||||
toDecimals,
|
||||
MAX_DECIMALS,
|
||||
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
|
||||
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
|
||||
|
||||
282
tests/fabricatedDecimals.test.js
Normal file
282
tests/fabricatedDecimals.test.js
Normal file
@@ -0,0 +1,282 @@
|
||||
// What the balance fetcher stores when the block explorer reports no decimals
|
||||
// for a token, and what the approval screens then display.
|
||||
//
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/349: `fetchTokenBalances()` did
|
||||
// `parseInt(item.token.decimals || "18", 10)` BEFORE writing the row, so a
|
||||
// token whose `decimals()` reverts — and which the explorer therefore reports
|
||||
// no scale for — was stored with a fabricated 18. Nothing downstream could
|
||||
// tell that from a real 18.
|
||||
//
|
||||
// That matters because it is upstream of two refusals that were already built
|
||||
// and already merged. https://git.eeqj.de/sneak/AutistMask/issues/306 made the
|
||||
// ERC-20 amount line resolve the real scale or refuse to format, and
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/340 did the same for the swap
|
||||
// lines. Both read this stored value as an authoritative source, so the guess
|
||||
// walked straight past them: the refusal was intact and simply never fired.
|
||||
//
|
||||
// So these tests run a real explorer response through the real fetcher and
|
||||
// assert on the real approval screens. A test that hand-writes `decimals: null`
|
||||
// onto state would pass on the broken build, because the fabrication is in the
|
||||
// writer, not the readers.
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
debugf: () => {},
|
||||
infof: () => {},
|
||||
warnf: () => {},
|
||||
errorf: () => {},
|
||||
},
|
||||
debugFetch: jest.fn(),
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn(() => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
global.chrome = { storage: makeStorageStub() };
|
||||
|
||||
const { AbiCoder, Interface } = require("ethers");
|
||||
const { ERC20_ABI } = require("../src/shared/constants");
|
||||
const { fetchTokenBalances } = require("../src/shared/balances");
|
||||
const { debugFetch } = require("../src/shared/log");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
|
||||
const { decodeCalldata } = require("../src/popup/views/approval");
|
||||
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
|
||||
|
||||
const HOLDER = "0x" + "a".repeat(40);
|
||||
const BLOCKSCOUT = "https://blockscout.example/api/v2";
|
||||
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
const SPENDER = "0x1111111111111111111111111111111111111111";
|
||||
// Outside the bundled list and untracked, so the explorer is the only source
|
||||
// of a scale for it — which is the case the fabrication was hiding.
|
||||
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
|
||||
// In the bundled list, at 18 decimals, for the other side of a swap.
|
||||
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
||||
|
||||
// The holding the explorer reports, in base units. Large enough that it does
|
||||
// not round to zero even when divided by 10^18, which is what makes it the
|
||||
// case the laundering actually REACHED: a smaller holding formatted at the
|
||||
// fabricated 18 comes out "0.0", the balance list drops the row as dust, and
|
||||
// the approval screens then find no source for the scale and refuse anyway —
|
||||
// for the wrong reason, and only by luck.
|
||||
const HOLDING = 5000000000000000000n;
|
||||
|
||||
// The amount in the dApp's calldata, which is a separate number from the
|
||||
// holding. 1,000.00 of a 6-decimal token; formatted at the fabricated 18 it
|
||||
// reads 0.000000001, and at a real scale of 0 it reads 1000000000.
|
||||
const THOUSAND_AT_SIX = 1000000000n;
|
||||
const HALF_WETH = 500000000000000000n;
|
||||
|
||||
const erc20Iface = new Interface(ERC20_ABI);
|
||||
const coder = AbiCoder.defaultAbiCoder();
|
||||
const routerIface = new Interface([
|
||||
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
||||
]);
|
||||
|
||||
// One Blockscout token-balances row. `token` is spread last so a test can
|
||||
// override or blank a field; the base row carries no `decimals` at all, which
|
||||
// is exactly what a token whose decimals() reverts produces.
|
||||
function row(token = {}, value = HOLDING) {
|
||||
return {
|
||||
value: String(value),
|
||||
token: {
|
||||
type: "ERC-20",
|
||||
address_hash: NOVEL,
|
||||
symbol: "NOVEL",
|
||||
name: "Novel Token",
|
||||
// Well clear of the balance list's own spam floor, so the row is
|
||||
// admitted on its holder count alone: neither the bundled list nor
|
||||
// a tracked entry can supply a scale for it.
|
||||
holders_count: "50000",
|
||||
...token,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function respondWith(items) {
|
||||
debugFetch.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => items,
|
||||
}));
|
||||
}
|
||||
|
||||
// Fetch and place the result exactly where refreshBalances() places it, so the
|
||||
// approval screens read what a real refresh would have left on state.
|
||||
async function fetchOnto(items, trackedTokens = []) {
|
||||
respondWith(items);
|
||||
const balances = await fetchTokenBalances(
|
||||
HOLDER,
|
||||
BLOCKSCOUT,
|
||||
trackedTokens,
|
||||
);
|
||||
state.trackedTokens = trackedTokens;
|
||||
state.wallets = [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
addresses: [
|
||||
{ address: HOLDER, balance: "1.0", tokenBalances: balances },
|
||||
],
|
||||
},
|
||||
];
|
||||
return balances;
|
||||
}
|
||||
|
||||
// The ERC-20 approval screen's Amount line, and the swap decoder's.
|
||||
function erc20AmountLine(data, tokenAddress) {
|
||||
return decodeCalldata(data, tokenAddress).details.find(
|
||||
(d) => d.label === "Amount",
|
||||
).value;
|
||||
}
|
||||
|
||||
function swapAmountLine(data) {
|
||||
return decodeCalldata(data, ROUTER).details.find(
|
||||
(d) => d.label === "Amount",
|
||||
).value;
|
||||
}
|
||||
|
||||
function transferData(amount) {
|
||||
return erc20Iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
|
||||
}
|
||||
|
||||
function approveData(amount) {
|
||||
return erc20Iface.encodeFunctionData("approve", [SPENDER, amount]);
|
||||
}
|
||||
|
||||
function swapData(tokenIn, amountIn, tokenOut, amountOutMin) {
|
||||
const input = coder.encode(
|
||||
["address", "uint256", "uint256", "address[]", "bool"],
|
||||
[RECIPIENT, amountIn, amountOutMin, [tokenIn, tokenOut], true],
|
||||
);
|
||||
return routerIface.encodeFunctionData("execute", [
|
||||
"0x08",
|
||||
[input],
|
||||
9999999999n,
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
debugFetch.mockReset();
|
||||
state.trackedTokens = [];
|
||||
state.wallets = [];
|
||||
});
|
||||
|
||||
describe("what fetchTokenBalances stores for an absent scale", () => {
|
||||
test("the token is not in the bundled list, so the explorer is the only source", () => {
|
||||
expect(TOKEN_BY_ADDRESS.has(NOVEL.toLowerCase())).toBe(false);
|
||||
});
|
||||
|
||||
test("an absent decimals is stored as null, not as 18", async () => {
|
||||
const balances = await fetchOnto([row()]);
|
||||
expect(balances).toHaveLength(1);
|
||||
expect(balances[0].decimals).toBeNull();
|
||||
});
|
||||
|
||||
test("an explicit null decimals is stored as null too", async () => {
|
||||
const balances = await fetchOnto([row({ decimals: null })]);
|
||||
expect(balances[0].decimals).toBeNull();
|
||||
});
|
||||
|
||||
// The same explorer row twice, differing only in whether it reports a
|
||||
// scale of 18. Before the fix both stored 18 and no reader could tell
|
||||
// which one had actually been reported.
|
||||
test("a real 18 is stored as 18, and so is distinguishable from absent", async () => {
|
||||
const real = await fetchOnto([row({ decimals: "18" })]);
|
||||
expect(real[0].decimals).toBe(18);
|
||||
expect(real[0].balance).toBe("5.0");
|
||||
const absent = await fetchOnto([row()]);
|
||||
expect(absent[0].decimals).toBeNull();
|
||||
expect(real[0].decimals).not.toBe(absent[0].decimals);
|
||||
});
|
||||
|
||||
// The falsy-collapse trap of
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/246. `decimals || "18"` reads
|
||||
// a real scale of zero as absent and then as eighteen, which is eighteen
|
||||
// orders of magnitude of error in the direction that displays as nothing.
|
||||
test("a real scale of zero is stored as zero, not collapsed", async () => {
|
||||
for (const reported of ["0", 0]) {
|
||||
const balances = await fetchOnto([row({ decimals: reported })]);
|
||||
expect(balances[0].decimals).toBe(0);
|
||||
expect(balances[0].balance).toBe("5000000000000000000.0");
|
||||
}
|
||||
});
|
||||
|
||||
test("no quantity is stated for a holding whose scale is unknown", async () => {
|
||||
const balances = await fetchOnto([row()]);
|
||||
// Not "0.0": the holding is real and nonzero, and a zero here is the
|
||||
// same lie the approval screens refuse to tell.
|
||||
expect(balances[0].balance).toBeNull();
|
||||
});
|
||||
|
||||
// Zero base units is zero tokens at every scale, so this filter never
|
||||
// needed a scale in the first place and does not acquire one now.
|
||||
test("a holding of zero base units is still dropped without a scale", async () => {
|
||||
expect(await fetchOnto([row({}, 0n)])).toEqual([]);
|
||||
});
|
||||
|
||||
test("the bundled list still supplies a quantity the explorer omitted", async () => {
|
||||
const balances = await fetchOnto([
|
||||
row({ address_hash: WETH, symbol: "WETH" }),
|
||||
]);
|
||||
// The stored decimals stay the explorer's own answer — absent. Copying
|
||||
// another source in here would make explorerDecimals()'s disagreement
|
||||
// check compare something other than explorer values.
|
||||
expect(balances[0].decimals).toBeNull();
|
||||
// The displayed quantity still comes out right, because the bundled
|
||||
// list knows this token's scale and outranks the explorer anyway.
|
||||
expect(balances[0].balance).toBe("5.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ERC-20 approval line reaches its refusal", () => {
|
||||
test("a transfer of a token the explorer gave no scale for is not formatted", async () => {
|
||||
await fetchOnto([row()]);
|
||||
const line = erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL);
|
||||
expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
|
||||
// The defect: a fabricated 18 renders this as 0.000000001, a quantity,
|
||||
// and a wrong one.
|
||||
expect(line).not.toMatch(/^0\./);
|
||||
});
|
||||
|
||||
test("an approve of the same token is not formatted either", async () => {
|
||||
await fetchOnto([row()]);
|
||||
const line = erc20AmountLine(approveData(THOUSAND_AT_SIX), NOVEL);
|
||||
expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
|
||||
expect(line).not.toMatch(/^0\./);
|
||||
});
|
||||
|
||||
test("a scale the explorer did report still formats", async () => {
|
||||
await fetchOnto([row({ decimals: "6" })]);
|
||||
expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe(
|
||||
"1000.0000",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the swap approval line reaches its refusal", () => {
|
||||
test("a swap of a token the explorer gave no scale for is not formatted", async () => {
|
||||
await fetchOnto([row()]);
|
||||
const line = swapAmountLine(
|
||||
swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH),
|
||||
);
|
||||
expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
|
||||
expect(line).not.toMatch(/^0\./);
|
||||
});
|
||||
|
||||
test("a scale the explorer did report still formats", async () => {
|
||||
await fetchOnto([row({ decimals: "6" })]);
|
||||
expect(
|
||||
swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)),
|
||||
).toBe("1000.0000");
|
||||
});
|
||||
});
|
||||
|
||||
test("no test in this file performed a network request", () => {
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
455
tests/unknownScaleSend.test.js
Normal file
455
tests/unknownScaleSend.test.js
Normal file
@@ -0,0 +1,455 @@
|
||||
// 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'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'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.0 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.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