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:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user