Compare commits

..

1 Commits

Author SHA1 Message Date
211ac4779b fix: gate the chain switch and remember endpoints per network (closes #308)
All checks were successful
check / check (push) Successful in 28s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 23s
wallet_switchEthereumChain was answered for any origin at all, with no
connection check and no prompt, so a page the user had never connected to
could move the active chain — clearing the [TESTNET] banner under someone
who believed they were on Sepolia. It now takes the same
allowedSites/connectedSites gate the signing methods take, ahead of the
same-chain and unsupported-chain answers, and refuses an unconnected origin
with 4100.

The switch also overwrote state.rpcUrl and state.blockscoutUrl with the
network defaults, so a user running their own node lost that url
permanently and silently to a public endpoint that then sees every address
they hold. Endpoints are now remembered per network in
state.networkEndpoints: the switch snapshots the network being left and
restores the network being entered, falling back to that network's
defaults. state.rpcUrl and state.blockscoutUrl remain the live endpoints of
the active network, so no reader changed; for the active network they are
authoritative and the map entry may be stale, and the snapshot is what
reconciles them. A profile written before the map existed has its stored
pair adopted for the network it was stored under, so nothing is lost on
first load.

Neither half held without loading state first. onChainSwitch() mutates the
module-level state singleton and persists every field of it, and
currentNetwork() reads the same singleton, but the service worker populates
nothing at module scope — a worker revived by the page's own message held
DEFAULT_STATE, so the same-chain check compared against the wrong network
and the save wrote empty wallets, empty allowedSites, no tracked tokens and
the default endpoints over the user's stored profile. The handler now
awaits loadState() after the gate, as the transaction path already does.

A stored networkEndpoints must now be an actual object. The previous guard
discarded only falsy values and arrays, so a stored primitive survived the
load, the seeding assignment silently no-opped on it, saveState()
re-persisted it, and every switch fell back to the public default in place
of the user's endpoint — permanently, with no self-healing.
2026-08-20 10:38:52 +00:00
4 changed files with 15 additions and 368 deletions

14
TODO.md
View File

@@ -44,20 +44,6 @@ but the review is broader than any of them.
# Completed Steps
- 2026-08-20: The dApp approval screen no longer shows a token transfer it
cannot scale as `0.0000`
([#306](https://git.eeqj.de/sneak/AutistMask/issues/306)). `decodeCalldata`
read decimals from the 512-entry bundled token list alone and fell back to 18,
so every token outside it — most of them, including anything the user added by
contract address — was displayed at the wrong scale: a `transfer` of 5,000
units of a 6-decimal token read as `0.0000`, and a user who reads zero
confirms the drain. The new `src/shared/approvalAmount.js` resolves the scale
from the bundled list, then `state.trackedTokens`, then the decimals the block
explorer already reported in `addr.tokenBalances`, and refuses one the
explorer's own entries disagree about. Where no source knows it, the amount
line is not formatted at all: it shows the base-unit integer and states that
the scale is unknown, for `approve` as well as `transfer`. An unbounded
allowance still reads `Unlimited`, which needs no scale.
- 2026-08-20: A web page can no longer switch the wallet's chain, and switching
no longer destroys the user's endpoints
([#308](https://git.eeqj.de/sneak/AutistMask/issues/308)).

View File

@@ -21,10 +21,6 @@ const {
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const {
resolveTokenDecimals,
unknownDecimalsAmount,
} = require("../../shared/approvalAmount");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
@@ -47,23 +43,6 @@ function formatTxValue(val) {
return parts[0] + "." + dec;
}
// The amount line for a decoded ERC-20 call. With a known scale it is the
// token quantity; with `decimals` null it is the base-unit integer with the
// unknown scale stated, because formatting it with an assumed scale is what
// showed a 5,000-token transfer as `0.0000`. `raw` is what the status screens
// carry, `display` is what the approval screen shows.
function tokenAmountText(rawAmount, decimals, symbol) {
if (decimals === null) {
const unknown = unknownDecimalsAmount(rawAmount);
return { raw: unknown, display: unknown };
}
const formatted = formatTxValue(formatUnits(rawAmount, decimals));
return {
raw: formatted,
display: formatted + (symbol ? " " + symbol : ""),
};
}
function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null;
@@ -80,15 +59,7 @@ function decodeCalldata(data, toAddress) {
if (parsed) {
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null;
// null when no source knows this token's scale. It is not
// defaulted to 18: an amount formatted with a guessed scale is
// the wrong number, and for a token with fewer decimals than the
// guess it is the wrong number in the direction that reads as
// zero. See tokenAmountText().
const tokenDecimals = resolveTokenDecimals(toAddress, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
const tokenDecimals = token ? token.decimals : 18;
const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
@@ -100,11 +71,12 @@ function decodeCalldata(data, toAddress) {
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = rawAmount === maxUint;
// An unbounded allowance needs no scale to describe, so it is
// still named rather than refused.
const amount = isUnlimited
? { raw: "Unlimited", display: "Unlimited" }
: tokenAmountText(rawAmount, tokenDecimals, tokenSymbol);
const amountRaw = isUnlimited
? "Unlimited"
: formatTxValue(formatUnits(rawAmount, tokenDecimals));
const amountStr = isUnlimited
? "Unlimited"
: amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Approval",
@@ -125,8 +97,8 @@ function decodeCalldata(data, toAddress) {
},
{
label: "Amount",
value: amount.display,
rawValue: amount.raw,
value: amountStr,
rawValue: amountRaw,
},
],
};
@@ -135,11 +107,11 @@ function decodeCalldata(data, toAddress) {
if (parsed.name === "transfer") {
const to = parsed.args[0];
const rawAmount = parsed.args[1];
const amount = tokenAmountText(
rawAmount,
tokenDecimals,
tokenSymbol,
const amountRaw = formatTxValue(
formatUnits(rawAmount, tokenDecimals),
);
const amountStr =
amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Transfer",
@@ -156,8 +128,8 @@ function decodeCalldata(data, toAddress) {
{ label: "Recipient", value: to, address: to },
{
label: "Amount",
value: amount.display,
rawValue: amount.raw,
value: amountStr,
rawValue: amountRaw,
},
],
};

View File

@@ -1,103 +0,0 @@
// The scale an ERC-20 amount in a dApp's calldata is displayed with, and what
// to display when there is no such scale.
//
// The approval screen decodes `transfer` and `approve` calldata into a
// quantity the user confirms against. That quantity is a base-unit integer,
// and turning it into a number a person can read needs the token's decimals.
// Assuming a scale is how a drain gets confirmed: a `transfer` of 5000000000
// units of a 6-decimal token is 5,000 tokens, but formatted with the ERC-20
// default of 18 it reads `0.0000`, and a user who reads zero signs.
//
// So a scale is either found or the amount is not formatted. Decimals are
// looked for in the bundled token list, then in the tokens the user tracks,
// then in what the block explorer reported for the contract; where none of
// them answers, unknownDecimalsAmount() renders the base-unit integer with the
// unknown scale stated, and no formatUnits() call is reached at all.
//
// This is the display counterpart to transferAmount.js, which takes the same
// stance on the wallet's own send path: an amount whose scale is unknown or
// 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");
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
// screen has no way to tell which member is the true one.
function explorerDecimals(lower, wallets) {
let found = null;
for (const wallet of wallets || []) {
for (const addr of wallet.addresses || []) {
for (const tb of addr.tokenBalances || []) {
if ((tb.address || "").toLowerCase() !== lower) continue;
const d = toDecimals(tb.decimals);
if (d === null) continue;
if (found !== null && found !== d) return null;
found = d;
}
}
}
return found;
}
// The decimals to render a token amount with, or null when nothing knows.
// `sources` is { trackedTokens, wallets }, both shaped as they are on `state`.
function resolveTokenDecimals(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null;
const bundled = TOKEN_BY_ADDRESS.get(lower);
if (bundled) {
const d = toDecimals(bundled.decimals);
if (d !== null) return d;
}
const tracked = ((sources && sources.trackedTokens) || []).find(
(t) => (t.address || "").toLowerCase() === lower,
);
if (tracked) {
const d = toDecimals(tracked.decimals);
if (d !== null) return d;
}
return explorerDecimals(lower, sources && sources.wallets);
}
// What the amount line reads when the scale is unknown. The base units are
// exact and the caveat is part of the same string, so the number on the screen
// cannot be mistaken for a token quantity, and it can never read as zero for a
// transfer that is not zero.
function unknownDecimalsAmount(rawAmount) {
return String(rawAmount) + " base units (decimals unknown)";
}
module.exports = {
resolveTokenDecimals,
unknownDecimalsAmount,
};

View File

@@ -1,208 +0,0 @@
// The quantity the dApp approval screen shows for a decoded ERC-20 call.
//
// The screen's amount line is the only place a user sees how much a page is
// asking for, and it is decoded from calldata, which carries base units and
// no scale. Issue #306: decodeCalldata read decimals from the bundled token
// list alone and fell back to 18, so a `transfer` of 5000000000 units of a
// 6-decimal token — 5,000 tokens — was displayed as `0.0000` and confirmed.
//
// What is asserted here is that the scale is found wherever the wallet
// already has it, and that where it is nowhere at all no formatted number is
// produced: the amount line has to say base units and say the scale is
// unknown, because a wrong quantity that reads as zero is worse than an
// unwieldy correct one.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state");
const {
resolveTokenDecimals,
unknownDecimalsAmount,
} = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const iface = new Interface(ERC20_ABI);
// Outside the bundled list, as the great majority of ERC-20s are.
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list, at 6 decimals.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SPENDER = "0x1111111111111111111111111111111111111111";
// 5,000 units of a 6-decimal token, the amount from the issue.
const FIVE_THOUSAND_AT_SIX = 5000000000n;
const MAX_UINT256 = (1n << 256n) - 1n;
function transferData(amount) {
return iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
}
function approveData(amount) {
return iface.encodeFunctionData("approve", [SPENDER, amount]);
}
// The Amount line as the approval screen renders it.
function amountLine(data, tokenAddress) {
const decoded = decodeCalldata(data, tokenAddress);
const detail = decoded.details.find((d) => d.label === "Amount");
return detail.value;
}
// A wallet holding `token` with the decimals the block explorer reported,
// shaped as balances.js writes it onto state.
function walletsHolding(token, decimals) {
return [
{
name: "Wallet 1",
addresses: [
{
address: "0x" + "a".repeat(40),
balance: "1.0",
tokenBalances: [
{
address: token,
symbol: "NOVEL",
decimals,
balance: "5000.0",
},
],
},
],
},
];
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("resolveTokenDecimals", () => {
test("prefers the bundled list", () => {
state.trackedTokens = [{ address: USDC, symbol: "USDC", decimals: 2 }];
expect(resolveTokenDecimals(USDC, state)).toBe(6);
});
test("reads a token the user tracks", () => {
state.trackedTokens = [
{
address: NOVEL_TOKEN.toLowerCase(),
symbol: "NOVEL",
decimals: 6,
},
];
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
});
test("reads the decimals the explorer reported", () => {
// Blockscout's copy arrives as a string.
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
});
test("falls past a tracked entry whose decimals are unusable", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: NaN },
];
state.wallets = walletsHolding(NOVEL_TOKEN, 6);
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
});
test("refuses a scale the explorer's own entries disagree about", () => {
const wallets = walletsHolding(NOVEL_TOKEN, 6);
wallets[0].addresses.push({
address: "0x" + "b".repeat(40),
balance: "0.0",
tokenBalances: [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 18 },
],
});
state.wallets = wallets;
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBeNull();
});
test("rejects values that are not a uint8", () => {
for (const decimals of [-1, 256, 1.5, true, [], {}, null, "6.0", ""]) {
state.trackedTokens = [{ address: NOVEL_TOKEN, decimals }];
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBeNull();
}
});
test("is null when nothing knows the token", () => {
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBeNull();
});
});
describe("decodeCalldata amount", () => {
test("transfer of a tracked 6-decimal token shows the true quantity", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
];
expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000.0000");
});
test("transfer priced off the explorer's decimals shows the true quantity", () => {
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000.0000");
});
test("transfer of an unknown-decimals token shows base units, not a number", () => {
const line = amountLine(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
expect(line).toBe("5000000000 base units (decimals unknown)");
expect(line).toBe(unknownDecimalsAmount(FIVE_THOUSAND_AT_SIX));
// The defect: any rendering that reads as a token quantity, and above
// all one that reads as zero.
expect(line).not.toMatch(/0\.0000/);
});
test("approve of a tracked 6-decimal token shows the true quantity", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
];
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
"5000.0000",
);
});
test("approve of an unknown-decimals token shows base units, not a number", () => {
const line = amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN);
expect(line).toBe("5000000000 base units (decimals unknown)");
expect(line).not.toMatch(/0\.0000/);
});
test("an unbounded allowance is still named, with or without a scale", () => {
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
"Unlimited",
);
expect(amountLine(approveData(MAX_UINT256), USDC)).toBe("Unlimited");
});
test("a bundled token keeps its symbol and its scale", () => {
expect(amountLine(transferData(FIVE_THOUSAND_AT_SIX), USDC)).toBe(
"5000.0000 USDC",
);
});
test("the amount carried to the status screens is the same string", () => {
const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
const detail = decoded.details.find((d) => d.label === "Amount");
expect(detail.rawValue).toBe(
"5000000000 base units (decimals unknown)",
);
});
});