Compare commits

..
2 Commits
Author SHA1 Message Date
sneak 0671f34ecc harden: show a personal message's hex and its text in byte order, hidden characters marked (closes #403)
check / check (push) Failing after 2s
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:44:03 +00:00
clawbot 3b713809c8 harden: a token scale above 80 decimal places is refused as unknown (closes #350)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s
toDecimals() accepted any uint8 scale, but formatUnits() and parseUnits()
refuse more than 80 decimal places. A token reporting 81 to 255 made the
formatter throw, and the catch in the swap decoder and in the ERC-20 decoder
turned that into an undecoded approval screen with nothing saying why.

MAX_DECIMALS is now 80, the formatter's own limit, so such a scale is
treated exactly like an unknown one: both approval paths show the base-unit
amount with the scale stated as unknown. The balance list, the history list
and the Send screen use the same check.

Model: opus-5-5
2026-10-04 21:43:11 +02:00
7 changed files with 65 additions and 13 deletions
+4 -1
View File
@@ -926,7 +926,10 @@ 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 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 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 amounts. An unbounded allowance or permit needs no scale to describe and is
still shown as `Unlimited`. 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.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three 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 sources are read as authoritative, so a value written into one of them cannot be
+9 -1
View File
@@ -55,7 +55,15 @@ but the review is broader than any of them.
characters that paint nothing are shown as `U+XXXX` marks, and a message that 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 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. "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 - 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 ([#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 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. // disputed is refused rather than guessed at.
// Solidity's decimals() is a uint8, and every source here is ultimately // Solidity's decimals() is a uint8, and every source here is ultimately
// reporting that call's result. toDecimals() is that check, shared with the // reporting that call's result. toDecimals() is that check, stopping at the 80
// send path rather than copied: the bundled list stores numbers, the // places formatUnits() accepts, and shared with the send path rather than
// explorer's copy arrives as a string, and a token the user added by hand // copied: the bundled list stores numbers, the explorer's copy arrives as a
// carries whatever lookupTokenInfo() got back, so the accepted types are // string, and a token the user added by hand carries whatever lookupTokenInfo()
// enumerated rather than coerced. // got back, so the accepted types are enumerated rather than coerced.
const { toDecimals } = require("./transferAmount"); const { toDecimals } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
+6 -4
View File
@@ -27,9 +27,11 @@
const { parseUnits } = require("ethers"); const { parseUnits } = require("ethers");
// Solidity's decimals() returns a uint8, so anything outside that range is not // Solidity's decimals() returns a uint8, but ethers' formatUnits() and
// an answer this wallet can use. // parseUnits() refuse more than 80 decimal places ("invalid FixedNumber
const MAX_DECIMALS = 255; // 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;
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE = const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
"The transfer was not sent, because the number of decimal places this" + "The transfer was not sent, because the number of decimal places this" +
@@ -55,7 +57,7 @@ function mismatchMessage(displayed, onChain) {
// A decimals value from any source as a number, or null if it is not one. // 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 // 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 // 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. // fractional, negative, above MAX_DECIMALS, or of any other type at all is not.
// //
// The types are enumerated rather than coerced because Number() is far too // 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 // willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
+16
View File
@@ -184,6 +184,22 @@ describe("decodeCalldata amount", () => {
expect(line).not.toMatch(/0\.0000/); 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", () => { test("an unbounded allowance is still named, with or without a scale", () => {
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe( expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
"Unlimited", "Unlimited",
+12 -2
View File
@@ -4,7 +4,7 @@
// contract at signing time, with nothing comparing the two, so a token whose // contract at signing time, with nothing comparing the two, so a token whose
// on-chain scale differed signed an amount that was never displayed. // on-chain scale differed signed an amount that was never displayed.
const { parseUnits } = require("ethers"); const { formatUnits, parseUnits } = require("ethers");
const { const {
displayedDecimals, displayedDecimals,
transferAmountUnits, transferAmountUnits,
@@ -25,7 +25,17 @@ describe("displayedDecimals", () => {
expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS); expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS);
}); });
test("refuses anything that is not a uint8", () => { // 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", () => {
for (const bad of [ for (const bad of [
null, null,
undefined, undefined,
+13
View File
@@ -126,6 +126,19 @@ describe("a swap of a token outside the bundled list", () => {
test("a bundled token on the other side still formats", () => { test("a bundled token on the other side still formats", () => {
expect(swapDetail(data(), "Min. received").value).toBe("0.5000 WETH"); 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", () => { describe("the Min. received line takes the same rule", () => {