fix: store an absent explorer decimals as unknown instead of fabricating 18 (closes #349)
fetchTokenBalances() did parseInt(item.token.decimals || "18", 10) before writing to state.wallets[].addresses[].tokenBalances[].decimals, so a token whose decimals() reverts -- one the block explorer reports no scale for -- was stored with a fabricated 18 that no reader could tell from a real one. That is upstream of a rule already merged. #306 made the ERC-20 approval amount line resolve the real scale or refuse to format, and #340 extended it to the swap lines; both read this stored value as an authoritative source, so the guess walked straight past refusals that were intact and simply never fired. A 1,000-unit approval of such a token rendered 0.000000001 on the one screen whose job is to state what is being authorized. The stored value is now the explorer's own answer or null, never a default. Both approval paths reach unknownDecimalsAmount() on a null, using the refusal that was already there. The history list's token transfers carried the same || "18" and now state exact base units with the scale unknown rather than a quantity at a guessed one. A holding whose scale nothing knows has no quantity either, so its balance is stored as null -- unknown, never zero -- and the balance list, the address USD total, the Send screen and the confirmation screen each say so rather than printing 0.0000 for money that is really there. The zero-balance filter moved onto the base-unit integer, where it needs no scale at all. The bundled token list and the user's tracked tokens already outrank the explorer, so a token either of them knows still displays its real quantity when the explorer's entry omits decimals; only what none of the three knows is unknown. Which makes the stored field the explorer's answer alone, and NOT the scale a screen renders at. Those are two questions, and every screen that needs the second one asks resolveTokenDecimals(). The Send screen did not: it read tokenBalances[].decimals raw and carried it onto the pending transaction, so a bundled or tracked token whose explorer row omits decimals reached displayedDecimals(null) inside estimateGas(). That throws, is caught as an unavailable fee, and disables Send behind "The network fee could not be estimated ... Please go back and try again" -- untrue, unactionable, and for a token such as WETH whose scale was never in doubt. The balance and the amount on the same screen were correct throughout, and validateTransfer() had nothing to object to, so nothing named the real reason. Before this change the fabricated 18 happened to be that token's real scale and the send completed, so this is a capability regression and not an inherited one. Send now resolves the scale through resolveTokenDecimals(), with no fallback. The two resolutions are deliberately not identical, and where they differ the balance follows the scale. balances.js resolves without wallets, because it is formatting one explorer row during a fetch that is about to replace the very state it would be consulting; its explorer leg is therefore that row's own value. send.js resolves with wallets, which adds explorerDecimals()'s cross-address check, so a contract two addresses report different scales for answers null rather than picking one -- a check that must apply to a value which goes on to encode a transfer. For a token neither bundled nor tracked whose explorer rows disagree, that leaves a stored quantity computed at a scale Send has just refused. 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 rather than on the scale, would again leave the fee-estimate failure as the only thing on the confirmation screen. So Send withdraws the stored quantity along with the scale: an unknown scale is an unknown balance. Only a stored quantity is withdrawn -- the "0" for a token with no row at all is an absence of holdings, which is true at every scale. The uint8 check is one shared toDecimals() rather than three copies of it, and it answers 0 for a real scale of zero: || "18" collapsed that to eighteen, the falsy-collapse trap of #246. The reader half is asserted, not just the writer half. Each of the six sites that now distinguishes an unknown quantity from a zero one -- balanceLine(), balanceLinesForAddress(), addressHoldsFunds(), getAddressValue()'s partial flag, the Send balance line and the confirmation screen's balance and insufficient-balance wording -- is tested on the PAIR, because an assertion about null alone still passes on a build that renders both as zero. The Send and confirmation cases run the real explorer response through the real fetcher, the real review handler and the real confirmation screen, so they show which of the two scale questions each screen is asking, including a two-address fixture whose explorer rows report 6 and 18 for one contract. Existing installs hold 18s that cannot be told apart retroactively -- that is the defect, and no migration can undo it. They display exactly as they do today until the next balance refresh, which rewrites tokenBalances wholesale and needs no user action. The schema version is not bumped: version 1 records stay valid and are read exactly as before. No || 18 or ?? 18 fallback remains anywhere in src/. The literal 18s that do remain are real data rather than 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.
This commit is contained in:
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
|
unbounded allowance or permit needs no scale to describe and is still shown as
|
||||||
`Unlimited`.
|
`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
|
#### Partial USD totals
|
||||||
|
|
||||||
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
||||||
|
|||||||
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
|
`src/shared/restorableViews.js`, since `persistedState.js` requires it and
|
||||||
that module is in the background bundle.
|
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`
|
- 2026-08-23: The background no longer reads or writes the shared `state`
|
||||||
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
||||||
also closes the cold-worker wrong-chain send
|
also closes the cold-worker wrong-chain send
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const {
|
|||||||
displaySymbol,
|
displaySymbol,
|
||||||
truncateMiddle,
|
truncateMiddle,
|
||||||
balanceLine,
|
balanceLine,
|
||||||
|
unknownableAmount,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
@@ -118,7 +119,9 @@ function show() {
|
|||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
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);
|
price = getPrice(symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +155,7 @@ function show() {
|
|||||||
attachCopyHandlers($("address-token-line"));
|
attachCopyHandlers($("address-token-line"));
|
||||||
|
|
||||||
// USD total for this token only
|
// USD total for this token only
|
||||||
const usdVal = price ? amount * price : null;
|
const usdVal = price && amount !== null ? amount * price : null;
|
||||||
const usdStr = formatUsd(usdVal);
|
const usdStr = formatUsd(usdVal);
|
||||||
$("address-token-usd-total").innerHTML = usdStr || " ";
|
$("address-token-usd-total").innerHTML = usdStr || " ";
|
||||||
|
|
||||||
|
|||||||
@@ -139,12 +139,17 @@ function show(txInfo) {
|
|||||||
|
|
||||||
// Balance (with inline USD)
|
// Balance (with inline USD)
|
||||||
if (isErc20) {
|
if (isErc20) {
|
||||||
const bal = txInfo.tokenBalance || "0";
|
// null is a balance whose scale nothing knows, not a balance of zero
|
||||||
const balUsd = tokenPrice ? parseFloat(bal) * tokenPrice : null;
|
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
||||||
$("confirm-balance").textContent = valueWithUsd(
|
// refused at encode time for the same missing scale; what this line
|
||||||
bal + " " + symbol,
|
// must not do is state a quantity nobody established.
|
||||||
balUsd,
|
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 {
|
} else {
|
||||||
const bal = txInfo.balance || "0";
|
const bal = txInfo.balance || "0";
|
||||||
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
||||||
@@ -235,7 +240,12 @@ function renderValidation(txInfo) {
|
|||||||
}
|
}
|
||||||
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
|
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
|
||||||
messages.push(
|
messages.push(
|
||||||
"Insufficient " +
|
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 +
|
symbol +
|
||||||
" balance. You have " +
|
" balance. You have " +
|
||||||
txInfo.tokenBalance +
|
txInfo.tokenBalance +
|
||||||
|
|||||||
@@ -197,6 +197,15 @@ function showFlash(msg, duration = 2000) {
|
|||||||
}, duration);
|
}, 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.
|
// One row of the balance list: symbol, quantity, fiat value.
|
||||||
//
|
//
|
||||||
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
// `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
|
// attacker-chosen length until it has been through displaySymbol. This is
|
||||||
// the row that issue #307 was reported against: every screen that lists a
|
// the row that issue #307 was reported against: every screen that lists a
|
||||||
// holding renders through here.
|
// 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) {
|
function balanceLine(symbol, amount, price, tokenId) {
|
||||||
const qty = amount.toFixed(4);
|
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||||
const usd = price ? formatUsd(amount * price) || " " : " ";
|
const usd =
|
||||||
|
price && amount !== null
|
||||||
|
? formatUsd(amount * price) || " "
|
||||||
|
: " ";
|
||||||
// tokenId is a contract address out of the same explorer JSON, and it
|
// tokenId is a contract address out of the same explorer JSON, and it
|
||||||
// lands inside a quoted attribute.
|
// lands inside a quoted attribute.
|
||||||
const tokenAttr = tokenId ? ` data-token="${escapeHtml(tokenId)}"` : "";
|
const tokenAttr = tokenId ? ` data-token="${escapeHtml(tokenId)}"` : "";
|
||||||
@@ -233,7 +251,12 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
|||||||
);
|
);
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const t of addr.tokenBalances || []) {
|
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;
|
if (bal === 0 && !showZero) continue;
|
||||||
html += balanceLine(
|
html += balanceLine(
|
||||||
t.symbol,
|
t.symbol,
|
||||||
@@ -266,7 +289,12 @@ function addressHoldsFunds(addr) {
|
|||||||
if (!addr) return false;
|
if (!addr) return false;
|
||||||
if (parseFloat(addr.balance || "0") > 0) return true;
|
if (parseFloat(addr.balance || "0") > 0) return true;
|
||||||
for (const t of addr.tokenBalances || []) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -525,6 +553,7 @@ module.exports = {
|
|||||||
balanceLine,
|
balanceLine,
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
addressHoldsFunds,
|
addressHoldsFunds,
|
||||||
|
unknownableAmount,
|
||||||
addressColor,
|
addressColor,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const {
|
|||||||
const { state, currentAddress } = require("../../shared/state");
|
const { state, currentAddress } = require("../../shared/state");
|
||||||
let ctx;
|
let ctx;
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
|
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
|
||||||
const { resolveSymbol } = require("../../shared/tokenList");
|
const { resolveSymbol } = require("../../shared/tokenList");
|
||||||
const { isLowHolderCount } = require("../../shared/holders");
|
const { isLowHolderCount } = require("../../shared/holders");
|
||||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||||
@@ -159,9 +160,14 @@ function updateSendBalance() {
|
|||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
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 =
|
$("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,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
);
|
);
|
||||||
tokenBalance = tb ? tb.balance || "0" : "0";
|
// null carried through rather than flattened to "0": the confirm
|
||||||
tokenDecimals = tb ? tb.decimals : null;
|
// 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({
|
ctx.showConfirmTx({
|
||||||
|
|||||||
@@ -23,33 +23,14 @@
|
|||||||
// disputed is refused rather than guessed at.
|
// disputed is refused rather than guessed at.
|
||||||
|
|
||||||
// Solidity's decimals() is a uint8, and every source here is ultimately
|
// Solidity's decimals() is a uint8, and every source here is ultimately
|
||||||
// reporting that call's result.
|
// reporting that call's result. toDecimals() is that check, shared with the
|
||||||
const { MAX_DECIMALS } = require("./transferAmount");
|
// 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");
|
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
|
// Every decimals the explorer reported for this contract, across all the
|
||||||
// addresses whose balances have been fetched. They describe one contract, so
|
// 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
|
// 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 { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
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
|
// Use a static network to skip auto-detection (which can fail and cause
|
||||||
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
||||||
@@ -66,10 +68,28 @@ function formatTokenBalance(raw, decimals) {
|
|||||||
return parts[0] + "." + dec;
|
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.
|
// 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,
|
// Filters out spam: only shows tokens that are in the known token list,
|
||||||
// explicitly tracked by the user, or have >= 1000 holders.
|
// 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) {
|
async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||||
try {
|
try {
|
||||||
const resp = await debugFetch(
|
const resp = await debugFetch(
|
||||||
@@ -94,11 +114,46 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
|||||||
// is unchanged.
|
// is unchanged.
|
||||||
const type = String(item.token?.type || "").toUpperCase();
|
const type = String(item.token?.type || "").toUpperCase();
|
||||||
if (type !== "ERC-20") continue;
|
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();
|
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
|
// null means the explorer reported no count, which is not the
|
||||||
// same as a count of zero. This gate is not the low-holder
|
// same as a count of zero. This gate is not the low-holder
|
||||||
// display filter: it has no user-facing off switch and governs
|
// 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,
|
address: item.token.address_hash,
|
||||||
name: item.token.name || "",
|
name: item.token.name || "",
|
||||||
symbol: item.token.symbol || "???",
|
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,
|
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,
|
balance: bal,
|
||||||
holders: holders,
|
holders: holders,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ function isRecord(value) {
|
|||||||
// alternative — refusing the whole record — sends a user whose wallets are
|
// 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
|
// 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.
|
// 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) {
|
function tokenRefs(value) {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.filter(
|
return value.filter(
|
||||||
|
|||||||
@@ -78,9 +78,18 @@ function getAddressValue(addr) {
|
|||||||
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
||||||
let partial = false;
|
let partial = false;
|
||||||
for (const token of addr.tokenBalances || []) {
|
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
|
// 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 (!(tokenBal > 0)) continue;
|
||||||
if (prices[token.symbol]) {
|
if (prices[token.symbol]) {
|
||||||
usd += tokenBal * prices[token.symbol];
|
usd += tokenBal * prices[token.symbol];
|
||||||
|
|||||||
@@ -36,7 +36,13 @@
|
|||||||
// indexed, assigned into, or .toLowerCase()'d — where a truthy value of
|
// 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 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
|
// 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
|
// Container shape only: allowedSites, deniedSites. A falsy value or a list
|
||||||
// becomes {}; anything else is taken as stored and the entries are not
|
// becomes {}; anything else is taken as stored and the entries are not
|
||||||
// checked.
|
// checked.
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const { log, debugFetch } = require("./log");
|
|||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
||||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
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
|
// The plain 4-decimal rule. The history and balance lists deliberately keep
|
||||||
// truncation without the approval screens' nonzero floor: the transaction
|
// truncation without the approval screens' nonzero floor: the transaction
|
||||||
// detail view is the authoritative record and already shows exact precision.
|
// detail view is the authoritative record and already shows exact precision.
|
||||||
@@ -92,21 +96,37 @@ function parseTx(tx, addrLower) {
|
|||||||
function parseTokenTransfer(tt, addrLower) {
|
function parseTokenTransfer(tt, addrLower) {
|
||||||
const from = tt.from?.hash || "";
|
const from = tt.from?.hash || "";
|
||||||
const to = tt.to?.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 rawVal = tt.total?.value || "0";
|
||||||
const direction =
|
const direction =
|
||||||
normalizeAddress(from) === addrLower ? "sent" : "received";
|
normalizeAddress(from) === addrLower ? "sent" : "received";
|
||||||
const sym = tt.token?.symbol || "?";
|
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 {
|
return {
|
||||||
hash: tt.transaction_hash,
|
hash: tt.transaction_hash,
|
||||||
blockNumber: tt.block_number,
|
blockNumber: tt.block_number,
|
||||||
timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
|
timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
|
||||||
from: from,
|
from: from,
|
||||||
to: to,
|
to: to,
|
||||||
value: formatTxValue(formatUnits(rawVal, decimals)),
|
value: formatted,
|
||||||
exactValue: formatUnits(rawVal, decimals),
|
exactValue: exact,
|
||||||
rawAmount: rawVal,
|
rawAmount: rawVal,
|
||||||
rawUnit: sym + " base units (10^-" + decimals + ")",
|
rawUnit:
|
||||||
|
decimals === null
|
||||||
|
? sym + " base units (decimals unknown)"
|
||||||
|
: sym + " base units (10^-" + decimals + ")",
|
||||||
valueGwei: null,
|
valueGwei: null,
|
||||||
symbol: sym,
|
symbol: sym,
|
||||||
direction: direction,
|
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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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) {
|
function toDecimals(value) {
|
||||||
let n;
|
let n;
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
@@ -110,6 +119,7 @@ module.exports = {
|
|||||||
displayedDecimals,
|
displayedDecimals,
|
||||||
transferAmountUnits,
|
transferAmountUnits,
|
||||||
mismatchMessage,
|
mismatchMessage,
|
||||||
|
toDecimals,
|
||||||
MAX_DECIMALS,
|
MAX_DECIMALS,
|
||||||
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
|
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
|
||||||
UNREADABLE_CONTRACT_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);
|
||||||
|
});
|
||||||
|
});
|
||||||
446
tests/unknownScaleSend.test.js
Normal file
446
tests/unknownScaleSend.test.js
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
// 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 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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