Compare commits

..

4 Commits

Author SHA1 Message Date
5c4a671d4a fix: answer eth_chainId and net_version from loaded state (closes #317)
All checks were successful
check / check (push) Successful in 28s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 22s
Both methods answered from currentNetwork(), which reads the module-level state
singleton, and nothing populates that at module scope. A service worker revived
by the page's own message therefore held DEFAULT_STATE and reported mainnet
0x1 / 1 to a page whose user was on Sepolia, so a dApp asking which chain the
wallet is on built its interaction for the wrong one. Neither method is gated on
a connection, so any page got the stale answer.

Both now answer from getState() — the per-call storage read that returns a
detached object, which every other read handler in this file already uses —
rather than by loading the singleton. Loading it would fix the stale answer but
introduce a worse defect on the same path: loadState() replaces state.wallets
wholesale, and backgroundRefresh() hands the singleton's wallets to
refreshBalances(), which mutates those address objects in place across a network
round trip before stamping lastBalanceRefresh and saving. A load landing inside
that round trip detaches the objects being mutated, so the save persists the
pre-refresh balances while still marking the refresh done, and the freshness
guard then suppresses the redo for half the alarm period. These two methods are
reachable by any page, and the injected provider sends eth_chainId on every page
load, so an ordinary page load would be enough to drop a refresh and a polling
page could keep any refresh from ever persisting. getState() reads storage once
per call and mutates nothing shared. networkById(undefined) already falls back
to mainnet, which is the answer a profile with no stored networkId had before.

Read-side audit of the background, which the fix was the occasion for: the other
singleton reads are wallet_switchEthereumChain, the transaction verify/broadcast
path and backgroundRefresh, and all three already load first. Every other
handler answers from storage per call through getState(). One stale read remains
and is deliberately not fixed here, being a different handler rather than the
same one-line shape: handleSendTransaction calls getProvider() with no network
name, so balances.js falls back to the same unloaded singleton for ethers'
static network hint, and a cold-worker send on Sepolia is prepared with a
mainnet hint. It is caught later — the artifact is verified against the loaded
chain before broadcast — so it fails the send rather than sending on the wrong
chain.

The test's storage stub structured-clones in both directions, as the real
chrome.storage.local does. A stub that hands back the live stored object aliases
it into whatever reads it, which makes an in-place mutation of a detached copy
look as though it reached storage and hides this entire class of defect: with an
aliasing get, the whole suite passes against the loadState() version above.

Verified failing first, two mutations, each with the rest of the tree untouched.
Reverting the handler to the singleton read gives 3 failed / 791 passed: exactly
the three cases that read the chain on a cold worker, each answering 0x1 / 1
instead of 0xaa36a7 / 11155111. Replacing getState() with await loadState() plus
currentNetwork() gives 1 failed / 793 passed: the new mid-refresh case, with the
persisted balance "0" where the refresh wrote "1.5". With the fix, 794 passed /
37 suites, and lint ran uncached in the pinned container.
2026-08-20 11:03:10 +00:00
50078b3566 fix: resolve approval-screen token decimals, and refuse to format an unknown scale (closes #306)
All checks were successful
check / check (push) Successful in 34s
e2e / e2e-chrome (push) Successful in 1m13s
e2e / e2e-firefox (push) Successful in 28s
decodeCalldata consulted only the 512-entry bundled list and defaulted to 18
decimals, so a transfer of 5,000 units of a 6-decimal token rendered
"Amount 0.0000" and the user confirmed a drain reading zero. The same
understatement applied to approve, where an unbounded allowance also rendered
0.0000.

Decimals now resolve from the bundled list, then trackedTokens, then the
address's explorer-reported entry, with uint8 validation and a refusal when
sources for one contract disagree. When no source knows the scale, no
formatUnits call is reached at all: the line renders raw base units with an
explicit "decimals unknown" warning, and the same string reaches
pendingTxDetails.amount so the status screens carry no formatted figure either.

Verified failing first two independent ways: restoring the old
`token ? token.decimals : 18` fails 6 of 15 new tests with the unknown case
reporting "0.0000"; making the resolver return 18 rather than null on the
unknown path fails a different 6, spanning resolver and render levels.
2026-08-20 12:57:38 +02:00
6350aad591 fix: gate the chain switch and remember endpoints per network (closes #308)
All checks were successful
check / check (push) Successful in 29s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 22s
wallet_switchEthereumChain was answered for any origin at all, with no
connection check and no prompt, so any page could move the active chain and
clear the [TESTNET] banner under a user who believed they were on Sepolia. It
now takes the same allowedSites check the signing methods take and returns 4100
for an unconnected origin.

The handler also awaits loadState() before it reads or moves the network. The
MV3 worker populates nothing at module scope, so a worker revived by the page's
own message held DEFAULT_STATE: the same-chain check compared against the wrong
network, and the save wrote empty wallets, empty allowedSites and default
endpoints over the user's stored profile, destroying every wallet in the
extension. Also fixes #316.

Endpoints are now remembered per network in a persisted networkEndpoints map,
so a user running a local or private node no longer loses that url permanently
to a public endpoint on every switch. A stored map must be an actual object; a
primitive previously survived the load and made every switch fall back to the
public default with no self-healing.

Verified failing first: dropping only the added loadState() fails exactly the
two cold-worker cases; reverting only the type guard fails exactly the string
and number cases. Reverting both source files to next gives 12 failed / 751
passed.
2026-08-20 12:42:01 +02:00
2f80a9bdb4 fix: sign the ERC-20 amount the confirmation screen displayed (closes #305)
All checks were successful
check / check (push) Successful in 27s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 22s
The send screen was built from the indexer's decimals while the transfer was
encoded from the contract's decimals() read at signing time, with nothing
comparing them. A token whose scales disagree moved 10^12 times the approved
amount.

The displayed scale is now carried on pendingTx from the same tokenBalances
entry the amount, balance and symbol were rendered from, and both encode sites
use it. transferAmount.js refuses rather than falling back when the two scales
disagree or either is unusable.

Adds the first end-to-end coverage of the popup's own Send -> ConfirmTx ->
Sign & Send path; #btn-confirm-send had never been clicked by any test.
2026-08-20 12:31:28 +02:00
16 changed files with 1504 additions and 45 deletions

View File

@@ -992,6 +992,13 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- **Transitions**:
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
- "Sign & Send" on an ERC-20 whose contract answers `decimals()` with a
different number than the amount above was displayed with → nothing is
signed → **ErrorTx** naming both numbers. The transfer is encoded from the
decimals the screen rendered, carried forward on the pending transaction;
the contract's own answer is read at signing time only to be compared with
it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`)
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Back" → **Send**

60
TODO.md
View File

@@ -44,6 +44,38 @@ but the review is broader than any of them.
# Completed Steps
- 2026-08-20: A page asking which chain the wallet is on is told the chain the
user is actually on ([#317](https://git.eeqj.de/sneak/AutistMask/issues/317)).
`eth_chainId` and `net_version` answered from `currentNetwork()`, which reads
the module-level `state` singleton that nothing populates at module scope, so
a service worker revived by the page's own message answered out of
`DEFAULT_STATE` and reported mainnet `0x1`/`1` to a user on Sepolia — a dApp
building its interaction for the wrong chain. Both now answer from
`getState()`, the per-call detached storage read the other read handlers use,
rather than from the singleton: these two are reachable by any page on every
provider init, and mutating the shared singleton on that path would detach the
wallet objects an in-flight `backgroundRefresh()` is mutating. The read side
of the background was audited with it: the remaining singleton reads are the
chain switch, the transaction verification path and `backgroundRefresh`, which
each already load, and everything else answers from storage per call through
`getState()`. One stale read is left named but unfixed, outside this issue's
scope: `handleSendTransaction` builds its provider with no network name, so
`getProvider()` falls back to the same unloaded singleton for ethers' static
network hint.
- 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)).
@@ -60,7 +92,33 @@ but the review is broader than any of them.
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.
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
([#305](https://git.eeqj.de/sneak/AutistMask/issues/305)). The confirmation
screen renders from the block explorer's cached decimals; the transfer was
encoded from `decimals()` read off the contract at signing time, and nothing
compared the two, so a token whose on-chain scale disagreed — an upgradeable
or proxy token, a stale explorer entry, a compromised Blockscout — signed an
amount that was never on screen, off by a power of ten per decimal place of
disagreement. The scale is now carried forward on the pending transaction from
the same balance entry the screen's amount, balance and symbol come from, and
the contract's answer is read at signing time only to be compared with it: a
disagreement is a refusal naming both numbers, never a preference for either
(`src/shared/transferAmount.js`, the `confirmTx` counterpart to
`approvalVerify.js`). The gas estimate encodes from the same carried value and
no longer reads `decimals()` at all. Nothing in the e2e suite had ever clicked
`#btn-confirm-send`, which is how this shipped: the popup's own Send →
ConfirmTx → Sign & Send → WaitTx path now runs end to end to a broadcast, with
the `transfer()` amount decoded out of the raw signed bytes and asserted
against what the screen displayed, and a companion case where the contract
starts answering a different scale after the screen was built and nothing
reaches the RPC. Reverting only the signing-side comparison turns that second
case red and leaves the other 53 green.
- 2026-08-17: The Settings screen is driven in a browser, and every element id
the popup looks up is checked statically. Nothing exercised Settings in the
e2e suite, and jest runs with no DOM, so the densest run of `$("...")` lookups

View File

@@ -3,7 +3,11 @@
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint.
const { DEFAULT_RPC_URL } = require("../shared/constants");
const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks");
const {
SUPPORTED_CHAIN_IDS,
networkById,
networkByChainId,
} = require("../shared/networks");
const { onChainSwitch } = require("../shared/chainSwitch");
const {
state,
@@ -663,12 +667,28 @@ async function handleRpc(method, params, origin) {
return { result: [] };
}
if (method === "eth_chainId") {
return { result: currentNetwork().chainId };
}
if (method === "net_version") {
return { result: currentNetwork().networkVersion };
// Both answered from currentNetwork(), which reads the module-level state
// singleton, and nothing populates that at module scope. A worker revived
// by the page's own message therefore held DEFAULT_STATE and told a page
// it was on mainnet while the user was on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/317).
//
// Answered from getState() rather than by loading the singleton. Any page
// reaches these two — neither is gated on a connection, and the injected
// provider sends eth_chainId on every page load — and loadState() replaces
// state.wallets wholesale, which would detach the address objects an
// in-flight backgroundRefresh() is mutating across its network round trip,
// so its saveState() would persist the pre-refresh balances while still
// stamping lastBalanceRefresh. getState() is the detached per-call storage
// read the other read handlers here already use.
// networkById(undefined) falls back to mainnet, matching the default for a
// profile with no stored networkId.
if (method === "eth_chainId" || method === "net_version") {
const s = await getState();
const net = networkById(s.networkId);
return {
result: method === "eth_chainId" ? net.chainId : net.networkVersion,
};
}
if (method === "wallet_switchEthereumChain") {
@@ -689,6 +709,17 @@ async function handleRpc(method, params, origin) {
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;
if (chainId === currentNetwork().chainId) {
return { result: null };

View File

@@ -21,6 +21,10 @@ 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");
@@ -43,6 +47,23 @@ 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;
@@ -59,7 +80,15 @@ function decodeCalldata(data, toAddress) {
if (parsed) {
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null;
const tokenDecimals = token ? token.decimals : 18;
// 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 contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
@@ -71,12 +100,11 @@ function decodeCalldata(data, toAddress) {
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = rawAmount === maxUint;
const amountRaw = isUnlimited
? "Unlimited"
: formatTxValue(formatUnits(rawAmount, tokenDecimals));
const amountStr = isUnlimited
? "Unlimited"
: amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
// 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);
return {
name: "Token Approval",
@@ -97,8 +125,8 @@ function decodeCalldata(data, toAddress) {
},
{
label: "Amount",
value: amountStr,
rawValue: amountRaw,
value: amount.display,
rawValue: amount.raw,
},
],
};
@@ -107,11 +135,11 @@ function decodeCalldata(data, toAddress) {
if (parsed.name === "transfer") {
const to = parsed.args[0];
const rawAmount = parsed.args[1];
const amountRaw = formatTxValue(
formatUnits(rawAmount, tokenDecimals),
const amount = tokenAmountText(
rawAmount,
tokenDecimals,
tokenSymbol,
);
const amountStr =
amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Transfer",
@@ -128,8 +156,8 @@ function decodeCalldata(data, toAddress) {
{ label: "Recipient", value: to, address: to },
{
label: "Amount",
value: amountStr,
rawValue: amountRaw,
value: amount.display,
rawValue: amount.raw,
},
],
};

View File

@@ -25,6 +25,10 @@ const {
getFullWarnings,
} = require("../../shared/addressWarnings");
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const {
displayedDecimals,
transferAmountUnits,
} = require("../../shared/transferAmount");
const {
CODES,
FEE_PENDING,
@@ -302,8 +306,17 @@ async function estimateGas(txInfo) {
});
} else {
const contract = new Contract(txInfo.token, ERC20_ABI, provider);
const decimals = await contract.decimals();
const amount = parseUnits(txInfo.amount, decimals);
// The scale the screen is rendering with, not the contract's own
// answer: the estimate has to be for the transfer that would be
// signed, and that one is encoded from what was displayed. See
// transferAmount.js. A pending transaction that carries no usable
// scale throws here, which reports the fee as unknown and leaves
// Send blocked — an amount that cannot be checked against the
// screen is never estimated for, let alone sent.
const amount = parseUnits(
txInfo.amount,
displayedDecimals(txInfo.tokenDecimals),
);
gasLimit = await contract.transfer.estimateGas(txInfo.to, amount, {
from: txInfo.from,
});
@@ -445,8 +458,16 @@ function init(_ctx) {
ERC20_ABI,
connectedSigner,
);
const decimals = await contract.decimals();
const amount = parseUnits(pendingTx.amount, decimals);
// The contract's decimals() is read to be COMPARED with the
// scale the screen rendered this amount at, not to encode with:
// encoding from it signs whatever the contract answers now,
// which is not what the user read. A disagreement throws and is
// reported on the error screen. See transferAmount.js.
const amount = transferAmountUnits(
pendingTx.amount,
pendingTx.tokenDecimals,
await contract.decimals(),
);
tx = await contract.transfer(pendingTx.to, amount);
}

View File

@@ -220,6 +220,11 @@ function init(_ctx) {
let tokenSymbol = null;
let tokenBalance = null;
// The scale the amount and the balance below are rendered at, carried
// forward so the transfer is encoded with the number the user read
// rather than with whatever the contract answers at signing time. See
// src/shared/transferAmount.js.
let tokenDecimals = null;
if (token !== "ETH") {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -230,6 +235,7 @@ function init(_ctx) {
state.trackedTokens,
);
tokenBalance = tb ? tb.balance || "0" : "0";
tokenDecimals = tb ? tb.decimals : null;
}
ctx.showConfirmTx({
@@ -241,6 +247,7 @@ function init(_ctx) {
balance: addr.balance,
tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals,
});
});

View File

@@ -0,0 +1,103 @@
// 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

@@ -137,8 +137,18 @@ async function loadState() {
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
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 =
saved.networkEndpoints && !Array.isArray(saved.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

View File

@@ -0,0 +1,116 @@
// The base-unit amount an ERC-20 transfer from the wallet's own Send screen is
// encoded with.
//
// A token amount is a decimal string plus a scale, and the two come from
// different places. The confirmation screen renders the amount, the balance and
// the symbol from the block explorer's cached metadata (see
// fetchTokenBalances() in balances.js); the transfer used to be encoded from
// decimals() read off the contract at signing time, and nothing compared the
// two. A token whose on-chain scale differs from the cached one — an
// upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
// entry — therefore signed an amount that was never displayed, off by a power
// of ten for every decimal place of disagreement.
//
// So the scale used to encode is the scale the screen rendered with, carried
// forward on the pending transaction, and the contract's own answer is read
// only to be compared with it. A disagreement is a refusal, never a preference
// for either number: the wallet cannot tell which of the two the user meant,
// and both candidate transfers move an amount nobody approved.
//
// This is the confirmTx counterpart to approvalVerify.js, which does the same
// job for the dApp approval path, and it takes the same stance: a quantity that
// cannot be compared with what was displayed has not been checked, so an absent
// or unusable value is refused rather than filled in.
//
// Every message here is shown to the user on the transaction error screen, so
// each is a full sentence and names the numbers it is refusing over.
const { parseUnits } = require("ethers");
// Solidity's decimals() returns a uint8, so anything outside that range is not
// an answer this wallet can use.
const MAX_DECIMALS = 255;
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
"The transfer was not sent, because the number of decimal places this" +
" amount was shown with is unknown, so the amount that would be signed" +
" cannot be shown to be the amount that was displayed.";
const UNREADABLE_CONTRACT_DECIMALS_MESSAGE =
"The transfer was not sent, because the token contract did not report a" +
" usable number of decimal places, so the amount that would be signed" +
" cannot be checked against the amount that was displayed.";
function mismatchMessage(displayed, onChain) {
return (
"The transfer was not sent. The token contract reports " +
onChain +
" decimal places, but the amount was displayed using " +
displayed +
", so signing it would move a different amount than the one shown." +
" Reopen the wallet to reload this token's details and try again."
);
}
// A decimals value from either 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.
//
// 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.
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;
}
// The decimals the confirmation screen rendered an amount with, as a number.
// Throws when the pending transaction does not carry a usable one — which is
// also what keeps the gas estimate from quietly estimating a different transfer
// than the one that would be signed.
function displayedDecimals(value) {
const displayed = toDecimals(value);
if (displayed === null) {
throw new Error(UNKNOWN_DISPLAYED_DECIMALS_MESSAGE);
}
return displayed;
}
// The transfer amount in the token's base units, or a throw. `amount` is the
// decimal string the user typed and the screen displayed, `displayed` is the
// scale it was displayed at, and `onChain` is what the contract's decimals()
// answered at signing time. The two scales must agree.
function transferAmountUnits(amount, displayed, onChain) {
const shown = displayedDecimals(displayed);
const reported = toDecimals(onChain);
if (reported === null) {
throw new Error(UNREADABLE_CONTRACT_DECIMALS_MESSAGE);
}
if (reported !== shown) {
throw new Error(mismatchMessage(shown, reported));
}
return parseUnits(String(amount), shown);
}
module.exports = {
displayedDecimals,
transferAmountUnits,
mismatchMessage,
MAX_DECIMALS,
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
};

View File

@@ -0,0 +1,208 @@
// 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

@@ -0,0 +1,258 @@
// What eth_chainId and net_version answer on 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. Both methods answered from
// currentNetwork(), which reads the module-level `state` singleton, so a
// worker revived by the page's own message answered out of DEFAULT_STATE and
// told a page it was on mainnet while the user was on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/317).
//
// This file therefore uses the REAL state module and never calls loadState()
// itself: the handler has to answer from storage on its own. Same shape as
// tests/coldWorkerChainSwitch.test.js, which covers the write side.
const { networkById } = require("../src/shared/networks");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const UNKNOWN_ORIGIN = "https://stranger.example";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
const REFRESHED_BALANCE = "1.5";
function storedProfile(networkId) {
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId,
rpcUrl: networkById(networkId).defaultRpcUrl,
blockscoutUrl: networkById(networkId).defaultBlockscoutUrl,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [],
};
}
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 module behind it, over a
// storage stub that keeps what is written.
//
// The stub structured-clones in both directions, as the real
// chrome.storage.local does. A stub that handed back the live stored object
// would alias it into whatever read it, so an in-place mutation of a detached
// copy would appear to have reached storage and this whole class of defect
// would be invisible here.
//
// opts.refreshBalances replaces the balances stub, so a test can hold a
// refresh open across a message.
function loadColdWorker(networkId, opts) {
jest.resetModules();
const options = opts || {};
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: options.refreshBalances || jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
let alarmHandlers = {};
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn((handlers) => {
alarmHandlers = handlers;
}),
}));
const store = { autistmask: storedProfile(networkId) };
let messageListener = null;
const set = jest.fn(async (items) => {
store.autistmask = structuredClone(items.autistmask);
});
global.chrome = {
storage: {
local: {
get: jest.fn(async () => structuredClone(store)),
set,
},
},
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) => {
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
async function rpc(method, origin) {
let result = null;
messageListener(
{ type: "AUTISTMASK_RPC", method, params: [] },
{ origin: origin || CONNECTED_ORIGIN },
(r) => {
result = r;
},
);
await settle();
return result;
}
return {
rpc,
persisted: () => store.autistmask,
storageSet: set,
fireBalanceAlarm: () => alarmHandlers.balance(),
};
}
describe("chain identity read by a worker that never loaded state", () => {
test("eth_chainId answers the stored chain, not the default", async () => {
// The first message this worker ever sees. Reading the unloaded
// singleton answers mainnet's 0x1 to a user who is on Sepolia.
const bg = loadColdWorker("sepolia");
expect(await bg.rpc("eth_chainId")).toEqual({
result: SEPOLIA.chainId,
});
});
test("net_version answers the stored chain, not the default", async () => {
const bg = loadColdWorker("sepolia");
expect(await bg.rpc("net_version")).toEqual({
result: SEPOLIA.networkVersion,
});
});
test("answers the stored chain to an origin that never connected", async () => {
// Neither method is gated on a connection, so the stale answer reached
// any page at all; the fixed answer has to as well.
const bg = loadColdWorker("sepolia");
expect(await bg.rpc("eth_chainId", UNKNOWN_ORIGIN)).toEqual({
result: SEPOLIA.chainId,
});
expect(await bg.rpc("net_version", UNKNOWN_ORIGIN)).toEqual({
result: SEPOLIA.networkVersion,
});
});
test("answers mainnet for a profile stored on mainnet", async () => {
// The default and the stored value agree here, so this case cannot
// catch the defect; it is what keeps the fix from being a swap.
const bg = loadColdWorker("mainnet");
expect(await bg.rpc("eth_chainId")).toEqual({
result: MAINNET.chainId,
});
expect(await bg.rpc("net_version")).toEqual({
result: MAINNET.networkVersion,
});
});
test("persists nothing: these are reads", async () => {
// The load must not turn a read into a write. saveState() persists
// every field of the singleton, and a read path that reached it would
// be the wipe https://git.eeqj.de/sneak/AutistMask/issues/316 fixed.
const bg = loadColdWorker("sepolia");
await bg.rpc("eth_chainId");
await bg.rpc("net_version");
expect(bg.storageSet).not.toHaveBeenCalled();
expect(bg.persisted()).toEqual(storedProfile("sepolia"));
});
test("a chain read arriving mid-refresh does not discard the refresh", async () => {
// Any page reaches these two methods, and the injected provider sends
// eth_chainId on every page load, so this overlap is ordinary traffic
// rather than a contrived race.
//
// backgroundRefresh() hands the singleton's wallets to
// refreshBalances(), which mutates those address objects in place once
// the network round trip resolves, and only then saves. Answering the
// page by calling loadState() would replace state.wallets mid-flight,
// so the refreshed balances would land on detached objects and the
// save that follows would persist the pre-refresh values — while still
// stamping lastBalanceRefresh, suppressing the redo.
let releaseRoundTrip;
const roundTrip = new Promise((resolve) => {
releaseRoundTrip = resolve;
});
let refreshReachedNetwork;
const inFlight = new Promise((resolve) => {
refreshReachedNetwork = resolve;
});
const bg = loadColdWorker("sepolia", {
refreshBalances: async (wallets) => {
refreshReachedNetwork();
await roundTrip;
// In place, on the objects handed in — as balances.js does.
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await inFlight;
expect(await bg.rpc("eth_chainId", UNKNOWN_ORIGIN)).toEqual({
result: SEPOLIA.chainId,
});
releaseRoundTrip();
await refresh;
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
});
});

View File

@@ -0,0 +1,212 @@
// 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

@@ -221,11 +221,6 @@ const RPC_RESULTS = {
eth_estimateGas: hex(GAS_LIMIT),
eth_getTransactionCount: "0x0",
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
// "not mined yet", which is what a node answers for a transaction it has
// only just accepted. The wait screen the dApp transaction approval hands
// off to polls this every 10 seconds; leaving it unstubbed would report
// the poll as escaping traffic the moment a test outlived one tick.
eth_getTransactionReceipt: null,
};
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
@@ -253,17 +248,22 @@ function latestBlock() {
const SELECTOR_DECIMALS = "0x313ce567";
// Every eth_call still answers with a zero word except decimals() on the
// stub token. ethers reads that before it can encode an ERC-20 transfer,
// and a zero there makes parseUnits() reject any fractional amount — so the
// ERC-20 confirmation path would fail its gas estimate for a reason that
// has nothing to do with what is being tested.
function ethCallResult(req) {
// stub token, which the wallet reads back at signing time to compare with
// the scale the confirmation screen rendered (issue #305).
//
// opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through
// Blockscout, which is exactly the disagreement the wallet must refuse to
// sign over. It is read at request time, so a test flips it on the options
// object the route was registered with — after the confirmation screen has
// been built — without re-registering anything.
function ethCallResult(req, opts) {
const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
return word(STUB_TOKEN.decimals);
return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals);
}
return ZERO_WORD;
}
@@ -346,6 +346,38 @@ function transactionDetails(hash) {
};
}
// The receipt for a transaction this run broadcast.
//
// eth_getTransactionReceipt otherwise answers null — "not mined yet", which is
// what a node says about a transaction it has only just accepted, and what the
// wait screen has to keep polling through. opts.seedReceipt confirms it
// instead, which is how a test that drives the popup's own send to a broadcast
// gets off the wait screen: the wait resolves to the success view, which has a
// Done button, rather than polling for a receipt for the rest of the suite.
//
// Every field ethers' receipt formatter requires is present. A receipt it
// cannot parse throws inside the poll, which the wallet reports through
// log.errorf — i.e. console.error — and the harness fails the run on, so a
// half-populated fixture here would surface as an unrelated-looking failure.
function transactionReceipt(hash) {
return {
transactionHash: hash,
transactionIndex: "0x0",
blockHash: "0x" + "33".repeat(32),
blockNumber: hex(STUB_BLOCK_NUMBER),
from: STUB_COUNTERPARTY,
to: STUB_TOKEN.address,
cumulativeGasUsed: hex(GAS_LIMIT),
gasUsed: hex(GAS_LIMIT),
effectiveGasPrice: hex(GAS_PRICE_WEI),
contractAddress: null,
logs: [],
logsBloom: "0x" + "00".repeat(256),
status: "0x1",
type: "0x2",
};
}
function jsonResponse(route, body) {
return route.fulfill({
status: 200,
@@ -408,7 +440,13 @@ function rpcReply(req, opts, report) {
});
}
if (req.method === "eth_call") {
return Object.assign(envelope, { result: ethCallResult(req) });
return Object.assign(envelope, { result: ethCallResult(req, opts) });
}
if (req.method === "eth_getTransactionReceipt") {
const hash = Array.isArray(req.params) ? req.params[0] : null;
return Object.assign(envelope, {
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
});
}
if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock() });
@@ -555,6 +593,11 @@ function traceEnabled(raw) {
* eth_estimateGas until this is cleared again.
* @param {string[]} [opts.broadcastTransactions] every raw signed
* transaction handed to eth_sendRawTransaction, appended in order.
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
* the stub token, in place of the value Blockscout reports for it. This is
* the token that lies about its scale; read at request time.
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
* confirmed receipt instead of null, so a wait screen resolves.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>}
*/

View File

@@ -12,10 +12,12 @@
const {
Transaction,
formatEther,
formatUnits,
getAddress,
getBytes,
hexlify,
parseEther,
parseUnits,
toQuantity,
toUtf8Bytes,
verifyMessage,
@@ -1965,6 +1967,208 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble
);
});
// ------------------------- the popup's own send, end to end (#305)
//
// Everything above this point stops at the confirmation screen. Nothing in
// the suite had ever clicked #btn-confirm-send, so the wallet's own Send ->
// ConfirmTx -> Sign & Send -> WaitTx path had no coverage at all, and issue
// #305 shipped through the gap: the screen was rendered from the explorer's
// decimals while the transfer was encoded from decimals() read off the
// contract at signing time, with nothing comparing the two.
//
// These two tests drive that path to a broadcast and read the amount out of
// the bytes the node was handed. The first asserts those bytes against what
// the screen displayed; the second makes the contract answer a different
// scale after the screen was built, and requires that nothing is broadcast.
// keccak("transfer(address,uint256)")[0:4].
const SELECTOR_TRANSFER = "0xa9059cbb";
// What decimals() starts answering once the confirmation screen has been
// built. The explorer reports 6 for the same token, so a wallet that encodes
// from the contract signs 10^12 times the amount it displayed.
const LYING_DECIMALS = "18";
const TOKEN_DECIMALS = Number(STUB_TOKEN.decimals);
// The transfer() call inside a raw signed transaction, hand-decoded.
//
// Deliberately not run through an ethers Interface built from the
// extension's own ABI: what is under assertion is the bytes that reached the
// node, and the fewer assumptions the wallet and the assertion share, the
// less room there is for both to be wrong in the same direction.
function decodeTransfer(rawSignedTx) {
const signed = Transaction.from(rawSignedTx);
const data = signed.data.toLowerCase();
assert(
data.startsWith(SELECTOR_TRANSFER) && data.length === 10 + 128,
"the broadcast transaction is not an ERC-20 transfer() call: " + data,
);
return {
signed,
recipient: getAddress("0x" + data.slice(34, 74)),
rawAmount: BigInt("0x" + data.slice(74)),
};
}
// The amount the confirmation screen is showing, verbatim.
async function shownAmount(page) {
return (await page.locator("#confirm-amount").innerText()).trim();
}
async function fillPasswordAndSend(page) {
await page.fill("#confirm-tx-password", PASSWORD);
await page.click("#btn-confirm-send");
}
async function goToTokenConfirm(env) {
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
const shown = await shownAmount(env.page);
assert(
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
"the confirmation screen is not showing the amount that was entered: " +
JSON.stringify(shown),
);
return shown;
}
test("the popup's own ERC-20 send broadcasts the amount it displayed (#305)", async (env) => {
// The previous test left the ETH balance at the fee-only fixture, which
// blocks sending outright; this one has to be able to press Send.
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true });
const shown = await goToTokenConfirm(env);
const before = env.routeOpts.broadcastTransactions.length;
// Confirm the transaction once it is broadcast, so the wait screen
// resolves to the success view instead of polling for the rest of the run.
env.routeOpts.seedReceipt = true;
await fillPasswordAndSend(env.page);
await visible(env.page, "#view-wait-tx", 60000);
const broadcast = env.routeOpts.broadcastTransactions;
assert(
broadcast.length === before + 1,
"expected exactly one raw transaction to reach the RPC, got " +
(broadcast.length - before),
);
const { signed, recipient, rawAmount } = decodeTransfer(
broadcast[broadcast.length - 1],
);
// The measurement, printed on every run: the amount the user read, and
// what the signed bytes mean at each of the two candidate scales. Under
// the defect these three lines disagree.
console.log(
"# erc-20 send artifact: displayed=" +
JSON.stringify(shown) +
" rawAmount=" +
rawAmount +
" asIf" +
TOKEN_DECIMALS +
"Decimals=" +
formatUnits(rawAmount, TOKEN_DECIMALS) +
" asIf" +
LYING_DECIMALS +
"Decimals=" +
formatUnits(rawAmount, Number(LYING_DECIMALS)),
);
assert(
getAddress(signed.to) === getAddress(STUB_TOKEN.address),
"the broadcast transaction does not call the token contract: " +
signed.to,
);
assert(
recipient === getAddress(STUB_COUNTERPARTY),
"the broadcast transfer goes to " + recipient,
);
// What the whole issue turns on: the signed amount, read back at the
// scale the SCREEN rendered with, is the number the screen rendered.
const wanted = parseUnits(shown.split(" ")[0], TOKEN_DECIMALS);
assert(
rawAmount === wanted,
"the broadcast transfer moves " +
rawAmount +
" base units, which is " +
formatUnits(rawAmount, TOKEN_DECIMALS) +
" " +
STUB_TOKEN.symbol +
" at the scale the confirmation screen displayed — but the screen" +
" displayed " +
JSON.stringify(shown) +
", i.e. " +
wanted +
" base units (#305)",
);
const summary = (
await env.page.locator("#wait-tx-summary").innerText()
).trim();
assert(
summary === shown,
"the wait screen summarises the send as " +
JSON.stringify(summary) +
", not as the approved " +
JSON.stringify(shown),
);
await visible(env.page, "#view-success-tx", 60000);
await env.page.click("#btn-success-tx-done");
await visible(env.page, "#view-address");
env.routeOpts.seedReceipt = false;
});
test("a token that lies about decimals() at signing time broadcasts nothing (#305)", async (env) => {
const shown = await goToTokenConfirm(env);
// Only now, with the screen already built and its estimate already taken
// at the explorer's scale, does the contract start answering differently.
// This is the whole shape of the defect: a value read at signing time that
// nothing on screen was ever derived from.
env.routeOpts.tokenDecimalsOverride = LYING_DECIMALS;
const before = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await visible(env.page, "#view-error-tx", 60000);
env.routeOpts.tokenDecimalsOverride = null;
assert(
env.routeOpts.broadcastTransactions.length === before,
"a transfer encoded against a contract that contradicts the " +
"confirmation screen still reached the RPC (#305)",
);
const message = (
await env.page.locator("#error-tx-message").innerText()
).trim();
console.log(
"# erc-20 decimals refusal: displayed=" +
JSON.stringify(shown) +
" contract=" +
LYING_DECIMALS +
" message=" +
JSON.stringify(message),
);
assert(
message.includes("reports " + LYING_DECIMALS + " decimal places") &&
message.includes("displayed using " + STUB_TOKEN.decimals),
"the refusal does not name both scales it is refusing over: " +
JSON.stringify(message),
);
assert(
/^[A-Z].*\.$/s.test(message),
"the refusal is not a full sentence: " + JSON.stringify(message),
);
await env.page.click("#btn-error-tx-done");
await visible(env.page, "#view-address");
});
// ------------------------------------------- dApp round trips (#183)
//
// The seam. Everything above drives the popup on its own; this section is
@@ -3095,6 +3299,13 @@ async function main() {
ethBalanceWei: null,
failGasEstimate: false,
holdGasEstimate: false,
// What decimals() answers for the stub token, when it is to answer
// something other than the value the same fixture reports through
// Blockscout. The token that lies about its scale (#305).
tokenDecimalsOverride: null,
// Whether eth_getTransactionReceipt confirms a transaction rather than
// answering "not mined yet".
seedReceipt: false,
// Every raw signed transaction handed to eth_sendRawTransaction, in
// order. The dApp transaction round trip asserts against these bytes
// rather than against anything the extension reported about them.

View File

@@ -154,11 +154,22 @@ describe("a custom endpoint survives a chain switch", () => {
expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
test("a stored networkEndpoints of the wrong type is discarded", async () => {
const { mod } = loadModuleWith({
// 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",
networkEndpoints: ["not", "a", "map"],
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
networkEndpoints: bad,
});
await mod.loadState();
@@ -166,9 +177,16 @@ describe("a custom endpoint survives a chain switch", () => {
// profile is — never left as something onChainSwitch() would index.
expect(mod.state.networkEndpoints).toEqual({
mainnet: {
rpcUrl: MAINNET.defaultRpcUrl,
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
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);
});
});

View File

@@ -0,0 +1,128 @@
// The scale an ERC-20 transfer from the wallet's own Send screen is encoded
// with (issue #305). The screen renders from the block explorer's cached
// decimals; the transfer used to be encoded from decimals() read off the
// contract at signing time, with nothing comparing the two, so a token whose
// on-chain scale differed signed an amount that was never displayed.
const { parseUnits } = require("ethers");
const {
displayedDecimals,
transferAmountUnits,
MAX_DECIMALS,
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
} = require("../src/shared/transferAmount");
describe("displayedDecimals", () => {
test("accepts what the explorer and the contract each answer with", () => {
// A string is what fetchTokenBalances() parses out of Blockscout, a
// number is what it stores, and a bigint is what ethers hands back
// from a uint8 return.
expect(displayedDecimals("6")).toBe(6);
expect(displayedDecimals(6)).toBe(6);
expect(displayedDecimals(6n)).toBe(6);
expect(displayedDecimals(0)).toBe(0);
expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS);
});
test("refuses anything that is not a uint8", () => {
for (const bad of [
null,
undefined,
"",
"eighteen",
NaN,
6.5,
-1,
MAX_DECIMALS + 1,
true,
{},
[],
]) {
expect(() => displayedDecimals(bad)).toThrow(
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
);
}
});
});
describe("transferAmountUnits", () => {
test("encodes with the displayed scale when the contract agrees", () => {
expect(transferAmountUnits("0.25", 6, 6n)).toBe(parseUnits("0.25", 6));
expect(transferAmountUnits("0.25", "6", 6n)).toBe(
parseUnits("0.25", 6),
);
expect(transferAmountUnits("1.5", 18, 18n)).toBe(parseUnits("1.5", 18));
});
// The reproduction on the issue: 0.25 of a token displayed at 6 decimals,
// signed against a contract answering 18, moves 10^12 times the amount
// that was approved.
test("refuses the reproduction rather than signing either amount", () => {
expect(() => transferAmountUnits("0.25", 6, 18n)).toThrow(
/contract reports 18 decimal places, but the amount was displayed using 6/,
);
});
test("refuses a disagreement in the other direction too", () => {
expect(() => transferAmountUnits("0.25", 18, 6n)).toThrow(
/contract reports 6 decimal places, but the amount was displayed using 18/,
);
});
test("never returns the amount at either scale on a disagreement", () => {
// The point of the refusal: both candidate encodings exist, and the
// wallet must produce neither.
let thrown = null;
try {
transferAmountUnits("0.25", 6, 18n);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(Error);
expect(thrown.message).toMatch(/was not sent/);
});
test("refuses when the screen's scale is unknown", () => {
expect(() => transferAmountUnits("0.25", null, 6n)).toThrow(
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
);
expect(() => transferAmountUnits("0.25", undefined, 6n)).toThrow(
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
);
});
test("refuses when the contract's answer is not a uint8", () => {
for (const bad of [null, undefined, "", "eighteen", 6.5, -1, 256]) {
expect(() => transferAmountUnits("0.25", 6, bad)).toThrow(
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
);
}
});
test("rejects an amount finer than the token's scale", () => {
// parseUnits' own refusal, reached only once the scales agree: a
// fractional base unit cannot be sent and must not be truncated.
expect(() => transferAmountUnits("0.0000001", 6, 6n)).toThrow();
});
test("every refusal is a full sentence", () => {
const messages = [];
for (const args of [
["0.25", 6, 18n],
["0.25", null, 6n],
["0.25", 6, "eighteen"],
]) {
try {
transferAmountUnits(...args);
} catch (e) {
messages.push(e.message);
}
}
expect(messages).toHaveLength(3);
for (const m of messages) {
expect(m).toMatch(/^[A-Z]/);
expect(m).toMatch(/\.$/);
}
});
});