Compare commits

..

1 Commits

Author SHA1 Message Date
8fadc4107f fix: sign the ERC-20 amount the send screen displayed (closes #305)
All checks were successful
check / check (push) Successful in 30s
e2e / e2e-chrome (push) Successful in 1m11s
e2e / e2e-firefox (push) Successful in 23s
The wallet's own Send screen renders the amount, the balance and the symbol
from the block explorer's cached decimals, but confirmTx encoded the transfer
from decimals() read off the contract at signing time and nothing compared the
two. A token whose on-chain scale disagrees with the cached one -- an
upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
entry, a compromised Blockscout -- therefore signed an amount that was never
displayed, off by a power of ten for every decimal place of disagreement. The
reproduction on the issue approves 0.25 and signs 250,000,000,000.

The scale is now carried forward on the pending transaction, taken from the
same tokenBalances entry the screen's own numbers come from, and the contract's
decimals() is read at signing time only to be compared with it. A disagreement
is a refusal that names both numbers, never a preference for either: both
candidate transfers move an amount nobody approved. New
src/shared/transferAmount.js holds that check, as the confirmTx counterpart to
approvalVerify.js, and takes the same stance on an absent or unusable value --
a quantity that cannot be compared with what was displayed has not been
checked. The gas estimate encodes from the same carried value and no longer
reads decimals() at all, so the estimate is for the transfer that would be
signed.

Nothing in the e2e suite had ever clicked #btn-confirm-send, so the popup's own
Send -> ConfirmTx -> Sign & Send -> WaitTx path had no coverage at all, which is
how this shipped. It is now driven end to end to a broadcast, with the
transfer() amount hand-decoded out of the raw signed bytes and asserted against
the amount read off the confirmation screen, plus a case where the fixture's
decimals() starts answering 18 after the screen was built and nothing reaches
eth_sendRawTransaction. The fixture gains that override and a receipt, so the
wait screen resolves to the success view instead of polling for the rest of the
run.
2026-08-20 10:14:55 +00:00
11 changed files with 19 additions and 1109 deletions

36
TODO.md
View File

@@ -44,42 +44,6 @@ but the review is broader than any of them.
# Completed Steps # 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)).
`wallet_switchEthereumChain` was answered for any origin at all, with no
connection check and no prompt: any page could clear the `[TESTNET]` banner
under a user 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 itself 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`, snapshotted from the network being left and restored
for the network being entered; `state.rpcUrl` stays the live value for the
active network, so no reader changed. A profile written before the map existed
has its stored pair adopted for the network it was stored under, and loses
nothing. The handler now loads state before it switches
([#316](https://git.eeqj.de/sneak/AutistMask/issues/316)): the service worker
populates nothing at module scope, so a worker revived by the page's own
message held `DEFAULT_STATE`, and the switch persisted every field of it —
wiping every wallet, every site approval and every tracked token from storage
along with the endpoint.
- 2026-08-20: The wallet's own ERC-20 send signs the amount it displayed - 2026-08-20: The wallet's own ERC-20 send signs the amount it displayed
([#305](https://git.eeqj.de/sneak/AutistMask/issues/305)). The confirmation ([#305](https://git.eeqj.de/sneak/AutistMask/issues/305)). The confirmation
screen renders from the block explorer's cached decimals; the transfer was screen renders from the block explorer's cached decimals; the transfer was

View File

@@ -672,34 +672,6 @@ async function handleRpc(method, params, origin) {
} }
if (method === "wallet_switchEthereumChain") { if (method === "wallet_switchEthereumChain") {
// Gated exactly like the signing methods, and gated before the
// same-chain early return. Switching the chain is wallet-wide: it
// moves the network the popup shows and the endpoints every other
// tab is served from, so a page the user never connected to must
// not be able to do it. Ungated, any page could clear the
// [TESTNET] banner under a user who believed they were on Sepolia.
const s = await getState();
const activeAddress = await getActiveAddress();
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
// onChainSwitch() mutates the module-level state singleton and then
// saves every field of it, and currentNetwork() reads the same
// singleton. This worker may have been started by this very message:
// nothing loads state at module scope, so without this the singleton
// is DEFAULT_STATE, the same-chain check compares against the wrong
// network, and the save writes empty wallets, empty allowedSites and
// the default endpoints over the user's stored profile
// (https://git.eeqj.de/sneak/AutistMask/issues/316). Same precedent
// as the transaction path below.
await loadState();
const chainId = params?.[0]?.chainId; const chainId = params?.[0]?.chainId;
if (chainId === currentNetwork().chainId) { if (chainId === currentNetwork().chainId) {
return { result: null }; return { result: null };

View File

@@ -21,10 +21,6 @@ const {
const { getPrice, formatUsd } = require("../../shared/prices"); const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants"); const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const {
resolveTokenDecimals,
unknownDecimalsAmount,
} = require("../../shared/approvalAmount");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects"); const { walletDefect } = require("../../shared/walletDefects");
@@ -47,23 +43,6 @@ function formatTxValue(val) {
return parts[0] + "." + dec; 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) { function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase()); const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null; return t ? t.symbol : null;
@@ -80,15 +59,7 @@ function decodeCalldata(data, toAddress) {
if (parsed) { if (parsed) {
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase()); const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null; const tokenSymbol = token ? token.symbol : null;
// null when no source knows this token's scale. It is not const tokenDecimals = token ? token.decimals : 18;
// 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 contractLabel = tokenSymbol const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")" ? tokenSymbol + " (" + toAddress + ")"
: toAddress; : toAddress;
@@ -100,11 +71,12 @@ function decodeCalldata(data, toAddress) {
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
); );
const isUnlimited = rawAmount === maxUint; const isUnlimited = rawAmount === maxUint;
// An unbounded allowance needs no scale to describe, so it is const amountRaw = isUnlimited
// still named rather than refused. ? "Unlimited"
const amount = isUnlimited : formatTxValue(formatUnits(rawAmount, tokenDecimals));
? { raw: "Unlimited", display: "Unlimited" } const amountStr = isUnlimited
: tokenAmountText(rawAmount, tokenDecimals, tokenSymbol); ? "Unlimited"
: amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return { return {
name: "Token Approval", name: "Token Approval",
@@ -125,8 +97,8 @@ function decodeCalldata(data, toAddress) {
}, },
{ {
label: "Amount", label: "Amount",
value: amount.display, value: amountStr,
rawValue: amount.raw, rawValue: amountRaw,
}, },
], ],
}; };
@@ -135,11 +107,11 @@ function decodeCalldata(data, toAddress) {
if (parsed.name === "transfer") { if (parsed.name === "transfer") {
const to = parsed.args[0]; const to = parsed.args[0];
const rawAmount = parsed.args[1]; const rawAmount = parsed.args[1];
const amount = tokenAmountText( const amountRaw = formatTxValue(
rawAmount, formatUnits(rawAmount, tokenDecimals),
tokenDecimals,
tokenSymbol,
); );
const amountStr =
amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return { return {
name: "Token Transfer", name: "Token Transfer",
@@ -156,8 +128,8 @@ function decodeCalldata(data, toAddress) {
{ label: "Recipient", value: to, address: to }, { label: "Recipient", value: to, address: to },
{ {
label: "Amount", label: "Amount",
value: amount.display, value: amountStr,
rawValue: amount.raw, 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

@@ -19,26 +19,9 @@ async function onChainSwitch(newNetworkId) {
const net = networkById(newNetworkId); const net = networkById(newNetworkId);
// --- core identity --- // --- core identity ---
// Endpoints are remembered per network rather than reset to the
// defaults, because a user who points the wallet at their own node has
// no way to get that URL back once it is gone: overwriting it moved
// every address and every transaction onto a third-party endpoint
// silently and permanently.
//
// state.rpcUrl / state.blockscoutUrl stay the live endpoints of the
// active network, so nothing that reads them changes. The invariant is
// that for the ACTIVE network those two fields are authoritative and
// the map entry may be stale (Settings writes the fields directly);
// for every other network the map is authoritative. Snapshotting the
// outgoing network here, before the switch, is what reconciles them.
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
const remembered = state.networkEndpoints[net.id] || {};
state.networkId = net.id; state.networkId = net.id;
state.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl; state.rpcUrl = net.defaultRpcUrl;
state.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl; state.blockscoutUrl = net.defaultBlockscoutUrl;
// --- price cache --- // --- price cache ---
// Prices are chain-specific (testnet tokens are worthless, // Prices are chain-specific (testnet tokens are worthless,

View File

@@ -14,11 +14,6 @@ const DEFAULT_STATE = {
networkId: "mainnet", networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL, rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL, blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
// Endpoints remembered per network: { [networkId]: { rpcUrl,
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
// of the active network; this is what the others are restored from
// when the active network changes. See onChainSwitch().
networkEndpoints: {},
lastBalanceRefresh: 0, lastBalanceRefresh: 0,
activeAddress: null, activeAddress: null,
allowedSites: {}, allowedSites: {},
@@ -39,9 +34,6 @@ const DEFAULT_STATE = {
const state = { const state = {
...DEFAULT_STATE, ...DEFAULT_STATE,
// Its own object, not the one DEFAULT_STATE holds: onChainSwitch()
// mutates this map in place, and a spread copies the reference.
networkEndpoints: {},
currentView: null, currentView: null,
selectedWallet: null, selectedWallet: null,
selectedAddress: null, selectedAddress: null,
@@ -96,7 +88,6 @@ async function saveState() {
networkId: state.networkId, networkId: state.networkId,
rpcUrl: state.rpcUrl, rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl, blockscoutUrl: state.blockscoutUrl,
networkEndpoints: state.networkEndpoints,
lastBalanceRefresh: state.lastBalanceRefresh, lastBalanceRefresh: state.lastBalanceRefresh,
activeAddress: state.activeAddress, activeAddress: state.activeAddress,
allowedSites: state.allowedSites, allowedSites: state.allowedSites,
@@ -137,30 +128,6 @@ async function loadState() {
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
state.blockscoutUrl = state.blockscoutUrl =
saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl; saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// An actual object is required, not merely a truthy non-array: the
// code below and onChainSwitch() index and ASSIGN INTO this value,
// and assigning a property to a string or a number is a silent no-op
// in sloppy mode. A stored primitive would therefore be re-persisted
// unchanged forever, and every switch would fall back to the network
// default — the endpoint loss this map exists to prevent, with no
// self-healing. The allowedSites/deniedSites guards below are only
// read from, which is why they can be looser.
state.networkEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
// A profile written before this map existed carries exactly one pair
// of endpoints, belonging to whatever network it was last on. Adopt
// it as that network's remembered pair, so a custom endpoint set on
// the old build is not lost by the first switch away and back.
if (!state.networkEndpoints[state.networkId]) {
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
}
state.lastBalanceRefresh = saved.lastBalanceRefresh || 0; state.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
state.activeAddress = saved.activeAddress || null; state.activeAddress = saved.activeAddress || null;
state.allowedSites = state.allowedSites =

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)",
);
});
});

View File

@@ -1,232 +0,0 @@
// Who may move the active chain.
//
// wallet_switchEthereumChain used to be answered for any origin at all, with
// no connection check and no prompt, so a page the user had never connected
// to could clear the [TESTNET] banner under someone who believed they were
// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). The refusal
// is asserted as a refusal to ACT — the state unmoved and no chainChanged
// broadcast — because an error code alone would not distinguish a gate from
// a switch that happened and then reported a failure.
//
// The endpoint half of that issue lives in tests/networkEndpoints.test.js;
// this file mocks the state module, which that one exercises for real.
const { networkById } = require("../src/shared/networks");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The site the persisted state has connected, and one it has never heard of.
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const STRANGER_ORIGIN = "https://stranger.example";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
// The user's own node, so a switch that happens is visible as the loss of it.
const CUSTOM_RPC = "http://127.0.0.1:8545";
function walletFixture() {
return [
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
}
// Let the handler's promise chain run to the next suspension point. The gate
// reads storage before it answers, so the response is several awaits deep.
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
// ---------------------------------------------------------------------------
// The gate: which origins the background will switch the chain for.
// ---------------------------------------------------------------------------
// Load the background worker against stubbed browser APIs, with the real
// chain-switch module behind it, and return the handles to drive it. The
// wallet state is a plain object so that a switch that DID happen is visible
// as a mutation of it, and one that did not is visible as its absence.
function loadBackground() {
jest.resetModules();
const walletState = {
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
networkEndpoints: {},
wallets: walletFixture(),
lastBalanceRefresh: 1,
tokenHolderCache: {},
fraudContracts: [],
};
jest.doMock("../src/shared/state", () => ({
state: walletState,
loadState: jest.fn(async () => {}),
saveState: jest.fn(async () => {}),
currentNetwork: () => networkById(walletState.networkId),
}));
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
const persisted = {
wallets: walletFixture(),
activeAddress: ADDRESS,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
};
let messageListener = null;
// Every message the background pushed at a content script. chainChanged
// is what tells a page the wallet moved, so an ungated switch is visible
// here as well as in the state.
const toTabs = [];
global.chrome = {
storage: {
local: {
get: jest.fn(async () => ({ autistmask: persisted })),
set: jest.fn(async () => {}),
},
},
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }),
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
toTabs.push(message);
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
async function switchChain(chainId, origin) {
let result = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "wallet_switchEthereumChain",
params: [{ chainId }],
},
{ origin },
(r) => {
result = r;
},
);
await settle();
return result;
}
return {
switchChain,
walletState,
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};
}
describe("wallet_switchEthereumChain is gated on the connection", () => {
test("an origin the wallet was never connected to is refused with 4100", async () => {
const bg = loadBackground();
const result = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN);
expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
expect(result.result).toBeUndefined();
// The refusal has to be a refusal to ACT, not just an error string:
// the wallet is still on mainnet, still on the user's own node, and
// no page was told the chain moved.
expect(bg.walletState.networkId).toBe("mainnet");
expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("an unconnected origin is refused even for the chain already active", async () => {
const bg = loadBackground();
const result = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN);
expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
});
test("an unconnected origin is refused before the unsupported-chain answer", async () => {
const bg = loadBackground();
const result = await bg.switchChain("0x89", STRANGER_ORIGIN);
expect(result.error.code).toBe(4100);
});
test("a connected origin switches the chain", async () => {
const bg = loadBackground();
const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(result).toEqual({ result: null });
expect(bg.walletState.networkId).toBe("sepolia");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: SEPOLIA.chainId,
},
]);
});
test("a connected origin asking for an unsupported chain still gets 4902", async () => {
const bg = loadBackground();
const result = await bg.switchChain("0x89", CONNECTED_ORIGIN);
expect(result.error.code).toBe(4902);
expect(bg.walletState.networkId).toBe("mainnet");
});
test("a switch by a connected origin keeps the user's endpoint", async () => {
const bg = loadBackground();
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(bg.walletState.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC);
});
});

View File

@@ -1,212 +0,0 @@
// What a chain switch does to a worker that has not loaded state yet.
//
// The MV3 service worker is terminated when idle and revived by the next
// message, and nothing loads state at module scope. The chain-switch handler
// reaches onChainSwitch(), which mutates the module-level `state` singleton
// and then persists EVERY field of it, so a handler that runs before a load
// writes DEFAULT_STATE over the user's stored profile — every wallet, every
// site approval, every tracked token and the custom endpoint
// (https://git.eeqj.de/sneak/AutistMask/issues/316). The same singleton is
// what currentNetwork() answers from, so the same-chain early return also
// compares against the wrong network.
//
// This file therefore uses the REAL state module and never calls loadState()
// itself: the handler has to do it. tests/chainSwitchGate.test.js mocks the
// state module wholesale and tests/networkEndpoints.test.js always loads
// first, so neither can see this.
const { networkById } = require("../src/shared/networks");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
// The user's own node, and a wallet whose loss is the whole point.
const CUSTOM_RPC = "http://127.0.0.1:8545";
const CUSTOM_BLOCKSCOUT = "http://127.0.0.1:4000/api/v2";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
function walletFixture() {
return [
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
}
// A profile as an installed extension holds it, on `networkId`.
function storedProfile(networkId) {
return {
hasWallet: true,
wallets: walletFixture(),
activeAddress: ADDRESS,
networkId,
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
theme: "dark",
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
// Load the background worker with the real state and chain-switch modules
// behind it, over a storage stub that actually keeps what is written — a
// wipe is only observable against storage that remembers.
function loadColdWorker(networkId) {
jest.resetModules();
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
const store = { autistmask: storedProfile(networkId) };
let messageListener = null;
const toTabs = [];
global.chrome = {
storage: {
local: {
get: jest.fn(async () => ({ autistmask: store.autistmask })),
set: jest.fn(async (items) => {
store.autistmask = items.autistmask;
}),
},
},
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }),
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
toTabs.push(message);
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
async function switchChain(chainId) {
let result = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "wallet_switchEthereumChain",
params: [{ chainId }],
},
{ origin: CONNECTED_ORIGIN },
(r) => {
result = r;
},
);
await settle();
return result;
}
return {
switchChain,
persisted: () => store.autistmask,
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};
}
describe("a chain switch on a worker that never loaded state", () => {
test("keeps the wallets, approvals, tokens and custom endpoint", async () => {
const bg = loadColdWorker("mainnet");
const result = await bg.switchChain(SEPOLIA.chainId);
expect(result).toEqual({ result: null });
const after = bg.persisted();
// The switch itself happened.
expect(after.networkId).toBe("sepolia");
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
// And it took nothing else with it. Without the load these come back
// as [], {}, [] and "system" from DEFAULT_STATE — every wallet in the
// extension gone, encrypted secrets included.
expect(after.wallets).toEqual(walletFixture());
expect(after.hasWallet).toBe(true);
expect(after.activeAddress).toBe(ADDRESS);
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_HOSTNAME] });
expect(after.trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
]);
expect(after.theme).toBe("dark");
// The user's mainnet endpoint is remembered rather than replaced by
// the public default, so switching back returns it.
expect(after.networkEndpoints.mainnet).toEqual({
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
});
await bg.switchChain(MAINNET.chainId);
expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
expect(bg.persisted().wallets).toEqual(walletFixture());
});
test("compares the requested chain against the stored one, not the default", async () => {
// Stored on Sepolia, asked for mainnet. Reading the unloaded
// singleton makes this look like the chain already active, so the
// page is told the switch succeeded while the wallet stays on the
// testnet it was on.
const bg = loadColdWorker("sepolia");
const result = await bg.switchChain(MAINNET.chainId);
expect(result).toEqual({ result: null });
expect(bg.persisted().networkId).toBe("mainnet");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: MAINNET.chainId,
},
]);
});
});

View File

@@ -1193,9 +1193,8 @@ test("the theme and network selectors carry a non-default persisted value (#229)
// and a selector stuck on `dark`/`sepolia` would otherwise be // and a selector stuck on `dark`/`sepolia` would otherwise be
// indistinguishable here from one that persists correctly. Switching // indistinguishable here from one that persists correctly. Switching
// the network back also returns state.rpcUrl and state.blockscoutUrl // the network back also returns state.rpcUrl and state.blockscoutUrl
// to the mainnet endpoints onChainSwitch() remembered, which this // to the mainnet defaults that onChainSwitch() overwrote, which are
// fixture never customised and so are the mainnet defaults // the values src/shared/state.js starts with.
// src/shared/state.js starts with.
await env.page.selectOption("#settings-theme", "system"); await env.page.selectOption("#settings-theme", "system");
await env.page.selectOption("#settings-network", "mainnet"); await env.page.selectOption("#settings-network", "mainnet");

View File

@@ -1,192 +0,0 @@
// What a chain switch is allowed to do to the endpoints the user configured.
//
// A switch used to overwrite state.rpcUrl and state.blockscoutUrl with the
// network defaults, so a user pointing the wallet at their own node lost that
// url the first time anything switched chains — with no notification and no
// way to recover it, having been moved onto a public endpoint that then sees
// every address they hold (https://git.eeqj.de/sneak/AutistMask/issues/308).
// Endpoints are now remembered per network, which is why the round trips
// below assert the ORIGINAL url comes back rather than only that the switch
// happened.
const { networkById } = require("../src/shared/networks");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
// The user's own node: the pair the switch used to throw away.
const CUSTOM_RPC = "http://127.0.0.1:8545";
const CUSTOM_BLOCKSCOUT = "http://127.0.0.1:4000/api/v2";
function walletFixture() {
return [
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
}
// The real state module against stubbed storage, plus whatever the last
// saveState() wrote — so a case can reload a fresh module from the bytes an
// earlier one persisted, which is what an extension restart does. `state` is
// a module-level singleton, so the registry has to be reset per load.
function loadModuleWith(persisted) {
jest.resetModules();
let written = null;
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set: jest.fn(async (items) => {
written = items.autistmask;
}),
},
},
};
return {
mod: require("../src/shared/state"),
chainSwitch: require("../src/shared/chainSwitch"),
written: () => written,
};
}
afterEach(() => {
delete global.chrome;
});
describe("a custom endpoint survives a chain switch", () => {
test("switching away and back restores the user's rpc and blockscout urls", async () => {
const { mod, chainSwitch } = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
networkEndpoints: {
mainnet: {
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
},
},
});
await mod.loadState();
await chainSwitch.onChainSwitch("sepolia");
// The new chain gets its own endpoints, not the ones belonging to the
// chain just left: a mainnet node cannot answer for Sepolia.
expect(mod.state.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(mod.state.blockscoutUrl).toBe(SEPOLIA.defaultBlockscoutUrl);
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
test("an endpoint set on the network being left is remembered, not lost", async () => {
const { mod, chainSwitch } = loadModuleWith({
wallets: walletFixture(),
networkId: "sepolia",
rpcUrl: SEPOLIA.defaultRpcUrl,
blockscoutUrl: SEPOLIA.defaultBlockscoutUrl,
networkEndpoints: {},
});
await mod.loadState();
// What the Settings screen does: write the live field, then save. The
// map entry for the active network is stale until the switch, which
// is what snapshotting the outgoing network exists to reconcile.
mod.state.rpcUrl = CUSTOM_RPC;
await mod.saveState();
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(MAINNET.defaultRpcUrl);
await chainSwitch.onChainSwitch("sepolia");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
});
test("the remembered endpoints survive an extension restart", async () => {
const first = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
});
await first.mod.loadState();
await first.chainSwitch.onChainSwitch("sepolia");
// Reload from exactly the bytes the switch persisted.
const second = loadModuleWith(first.written());
await second.mod.loadState();
expect(second.mod.state.networkId).toBe("sepolia");
expect(second.mod.state.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await second.chainSwitch.onChainSwitch("mainnet");
expect(second.mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(second.mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
test("a profile written before networkEndpoints existed keeps its endpoint", async () => {
// Exactly the stored shape the current release writes: one pair of
// urls and no map. It is adopted as the remembered pair of the
// network it was stored under.
const { mod, chainSwitch } = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
});
await mod.loadState();
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(mod.state.networkEndpoints).toEqual({
mainnet: { rpcUrl: CUSTOM_RPC, blockscoutUrl: CUSTOM_BLOCKSCOUT },
});
await chainSwitch.onChainSwitch("sepolia");
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
// A primitive is the dangerous case, not the array: assigning a property
// to a string throws nothing and stores nothing, so a stored string would
// be carried through loadState() and re-persisted by every save, and each
// switch would fall back to the public default in place of the user's
// endpoint, permanently.
test.each([
["an array", ["not", "a", "map"]],
["a string", "junk"],
["a number", 7],
])("a stored networkEndpoints that is %s is discarded", async (_, bad) => {
const { mod, chainSwitch } = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
networkEndpoints: bad,
});
await mod.loadState();
// Discarded, then seeded from the live endpoints the same way an old
// profile is — never left as something onChainSwitch() would index.
expect(mod.state.networkEndpoints).toEqual({
mainnet: {
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
},
});
// And the endpoint really survives the round trip, which is the point
// of discarding it rather than only of the shape being right.
await chainSwitch.onChainSwitch("sepolia");
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
});