Compare commits

..
1 Commits
Author SHA1 Message Date
sneak b4ae645695 harden: show a personal message's hex and its text in byte order, hidden characters marked (closes #403)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s
The signature screen showed only the text a personal message decodes
to, with bidirectional, right-to-left and zero-width characters acting
on it, so a site could make the message read differently from the
bytes that are signed, and a message that was not hex was decoded into
NUL characters. The screen now shows the hex as "Raw data" alongside
the text, lays the text out left to right in byte order, and shows each
control character, line and paragraph separator, and character that
paints nothing (the set src/shared/symbolSpoof.js already strips) as a
U+XXXX mark. A message is hex when getBytes, which signing uses, reads
it; one that is not cannot be signed, so it is shown as plain text with
"Sign" disabled.

Model: opus-5-5
2026-10-04 19:26:52 +00:00
7 changed files with 13 additions and 65 deletions
+1 -4
View File
@@ -926,10 +926,7 @@ rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
token permission warning on the signature screen takes the same rule for its
amounts. An unbounded allowance or permit needs no scale to describe and is
still shown as `Unlimited`. A source's answer counts only if it is a whole
number from 0 to 80: `decimals()` returns a `uint8`, but `formatUnits()` cannot
format more than 80 decimal places, so a token that reports 81 to 255 is shown
as one whose scale nothing knows.
still shown as `Unlimited`.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be
+1 -9
View File
@@ -55,15 +55,7 @@ but the review is broader than any of them.
characters that paint nothing are shown as `U+XXXX` marks, and a message that
is not hex by the rule signing reads it with is shown as plain text with
"Sign" disabled, since such a message has no bytes to sign.
- 2026-10-04: A token that reports more than 80 decimal places has no known
scale ([#350](https://git.eeqj.de/sneak/AutistMask/issues/350)). The shared
scale check `toDecimals()` accepted any `uint8`, but `formatUnits()` throws
above 80, so such a token left a swap or an ERC-20 call on the approval screen
undecoded, with nothing saying why. The check now stops at 80, and both
approval paths show the base-unit amount with the scale stated as unknown. The
balance list and the history list use the same check, so the same token no
longer stops an address's token balances from refreshing or its history from
loading.
- 2026-10-04: Debug mode no longer writes RPC API keys to the console
([#410](https://git.eeqj.de/sneak/AutistMask/issues/410)). `debugFetch` logged
every request's full URL and body, so an RPC endpoint with a key in its path
+5 -5
View File
@@ -23,11 +23,11 @@
// disputed is refused rather than guessed at.
// Solidity's decimals() is a uint8, and every source here is ultimately
// reporting that call's result. toDecimals() is that check, stopping at the 80
// places formatUnits() accepts, and shared with the send path rather than
// copied: the bundled list stores numbers, the explorer's copy arrives as a
// string, and a token the user added by hand carries whatever lookupTokenInfo()
// got back, so the accepted types are enumerated rather than coerced.
// reporting that call's result. toDecimals() is that check, shared with the
// send path rather than copied: the bundled list stores numbers, the
// explorer's copy arrives as a string, and a token the user added by hand
// carries whatever lookupTokenInfo() got back, so the accepted types are
// enumerated rather than coerced.
const { toDecimals } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { isSpoofedSymbol } = require("./symbolSpoof");
+4 -6
View File
@@ -27,11 +27,9 @@
const { parseUnits } = require("ethers");
// Solidity's decimals() returns a uint8, but ethers' formatUnits() and
// parseUnits() refuse more than 80 decimal places ("invalid FixedNumber
// decimals (too large)"). A scale of 81 to 255 can be neither displayed nor
// encoded, so it is not an answer this wallet can use, the same as no answer.
const MAX_DECIMALS = 80;
// 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" +
@@ -57,7 +55,7 @@ function mismatchMessage(displayed, onChain) {
// A decimals value from any source as a number, or null if it is not one.
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
// as a string, so both of those are accepted alongside a plain number; anything
// fractional, negative, above MAX_DECIMALS, or of any other type at all is not.
// 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
-16
View File
@@ -184,22 +184,6 @@ describe("decodeCalldata amount", () => {
expect(line).not.toMatch(/0\.0000/);
});
// A token added by hand carries whatever its decimals() returned, and a
// uint8 reaches 255, but formatUnits() throws above 80. The throw left the
// call undecoded rather than refused
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
test("a token reporting more than 80 decimals shows base units", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 81 },
];
expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000000000 base units (decimals unknown)");
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
"5000000000 base units (decimals unknown)",
);
});
test("an unbounded allowance is still named, with or without a scale", () => {
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
"Unlimited",
+2 -12
View File
@@ -4,7 +4,7 @@
// 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 { formatUnits, parseUnits } = require("ethers");
const { parseUnits } = require("ethers");
const {
displayedDecimals,
transferAmountUnits,
@@ -25,17 +25,7 @@ describe("displayedDecimals", () => {
expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS);
});
// decimals() is a uint8, but formatUnits() and parseUnits() stop at 80
// places, so a larger scale cannot be shown or encoded
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
test("accepts exactly the scales the formatter accepts", () => {
expect(() => formatUnits(1n, MAX_DECIMALS)).not.toThrow();
expect(() => parseUnits("1", MAX_DECIMALS)).not.toThrow();
expect(() => formatUnits(1n, MAX_DECIMALS + 1)).toThrow();
expect(() => parseUnits("1", MAX_DECIMALS + 1)).toThrow();
});
test("refuses anything that is not a uint8 the formatter accepts", () => {
test("refuses anything that is not a uint8", () => {
for (const bad of [
null,
undefined,
-13
View File
@@ -126,19 +126,6 @@ describe("a swap of a token outside the bundled list", () => {
test("a bundled token on the other side still formats", () => {
expect(swapDetail(data(), "Min. received").value).toBe("0.5000 WETH");
});
// A token added by hand carries whatever its decimals() returned, and a
// uint8 reaches 255, but formatUnits() throws above 80. The throw left the
// whole swap undecoded rather than refused
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
test("refuses to format when the token reports more than 80 decimals", () => {
state.trackedTokens = [
{ address: NOVEL, symbol: "NOVEL", decimals: 81 },
];
expect(swapDetail(data(), "Amount").value).toBe(
"1000000000 base units (decimals unknown)",
);
});
});
describe("the Min. received line takes the same rule", () => {