fix: store an absent explorer decimals as unknown instead of fabricating 18 (closes #349)
parseInt(decimals || "18") ran before writing stored tokenBalances[].decimals, so an explorer reporting no decimals produced a fabricated 18 indistinguishable from a real one at read time. That defeated the resolve-or-refuse guarantees of #306 and #340: their refusal paths were intact but never fired, because the guess was laundered upstream of them. An absent scale is now stored as unknown, and a holding whose scale nothing knows carries a null balance -- unknown, never zero -- with six reader sites saying so rather than printing 0.0000. The Send screen resolves the display scale rather than reading the stored one, so a bundled token whose explorer row omits decimals still sends; when the scale cannot be resolved the stored quantity is withdrawn too, so the user is told the balance is unknown rather than only that the fee failed. Existing fabricated 18s cannot be told apart retroactively and are replaced wholesale on the next balance refresh. An explorer-sourced scale stays trusted -- only fabrication is removed; the reasoning is recorded on the issue.
This commit was merged in pull request #367.
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user