From 43784cab3fe7a41f94d2df6bdd8938ed50fe47cd Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 23 Aug 2026 16:20:22 +0200 Subject: [PATCH] harden: resolve or refuse the swap token scale instead of guessing 18 (closes #340) tokenInfo() returned decimals 18 for any token absent from the bundled list, so the swap approval line rendered a real 1000.00 of a 6-decimal token as 0.000000000001. The scale is now resolved from what the wallet already holds (bundled list, tracked tokens, explorer-reported decimals) or refused outright, matching the rule set for the ERC-20 path in #306. A refusal reuses unknownDecimalsAmount(), so it reads as "base units (decimals unknown)" with no decimal point and no symbol, and the same string propagates to rawValue so no downstream screen can render a figure the approval screen refused. No new network call on the approval path. Verified green on all three CI contexts: check, e2e-chrome, e2e-firefox. --- README.md | 19 +++ TODO.md | 14 +++ src/popup/views/approval.js | 18 ++- src/shared/approvalAmount.js | 4 + src/shared/uniswap.js | 63 +++++++--- tests/uniswapUnknownDecimals.test.js | 176 +++++++++++++++++++++++++++ 6 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 tests/uniswapUnknownDecimals.test.js diff --git a/README.md b/README.md index 6b09000..cce64ea 100644 --- a/README.md +++ b/README.md @@ -883,6 +883,25 @@ transaction detail view is the authoritative record and already shows exact precision. The 4-decimal rule is unchanged everywhere else, including for amounts at or above the floor on the approval screens. +The floor applies only where the token's scale is known. Where it is not, the +approval screen states base units instead of a quantity — see Unknown token +scale below — and no truncation happens at all. + +**Specific Exception — unknown token scale:** Calldata carries base units and no +scale, so every amount on the dApp approval screen needs the token's `decimals`. +It is resolved from the bundled token list, then from the tokens the user +tracks, then from what the block explorer reported for the contract +(`resolveTokenDecimals()` in `src/shared/approvalAmount.js`). Where none of them +answers, the amount is not formatted: the line reads +`5000000000 base units (decimals unknown)`. A guessed scale is not an +approximation but a different number — 1,000 units of a 6-decimal token +formatted at 18 decimals reads `0.000000001` — on the screen whose only job is +to state what is being authorized. Both amount paths of that screen take this +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`). An +unbounded allowance or permit needs no scale to describe and is still shown as +`Unlimited`. + #### Partial USD totals Prices are fetched for the top 25 tokens only, so an address can hold assets the diff --git a/TODO.md b/TODO.md index 9f3d1b7..a9ff42b 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,20 @@ but the review is broader than any of them. # Completed Steps +- 2026-08-23: The swap approval screen no longer guesses 18 decimals for a token + outside the bundled list + ([#340](https://git.eeqj.de/sneak/AutistMask/issues/340)). `tokenInfo()` in + `src/shared/uniswap.js` returned `decimals: 18` for any such token — the same + assumption [#306](https://git.eeqj.de/sneak/AutistMask/issues/306) removed + from the ERC-20 amount line — so a 1,000-unit swap of a 6-decimal token was + stated as `0.000000001`, and every newly listed token reached it. The swap's + `Amount` and `Min. received` lines now resolve the scale through + `resolveTokenDecimals()`, the same bundled-list-then-tracked-then-explorer + order the ERC-20 line uses, and where nothing knows it they render + `unknownDecimalsAmount()` — base units with the scale stated — instead of a + number. No new data source and no network call: the scale comes only from what + the wallet already holds. An unbounded permit is still shown as `Unlimited`. + `README.md` records the rule as a Display Consistency exception. - 2026-08-23: A failed release build no longer leaves a loadable debug bundle in `dist/` ([#333](https://git.eeqj.de/sneak/AutistMask/issues/333)). With `AUTISTMASK_DEBUG=1` exported, `make build` compiled a debug bundle and failed diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index 1c10a1d..2e468c9 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -73,6 +73,14 @@ function tokenLabel(address) { function decodeCalldata(data, toAddress) { if (!data || data === "0x" || data.length < 10) return null; + // Where a token's scale is looked for, for every decoder below: the ERC-20 + // amount line and the swap's Amount and Min. received lines resolve it the + // same way, and refuse to format the same way when it is nowhere. + const decimalsSources = { + trackedTokens: state.trackedTokens, + wallets: state.wallets, + }; + // Try ERC-20 (approve / transfer) try { const parsed = erc20Iface.parseTransaction({ data }); @@ -84,10 +92,10 @@ function decodeCalldata(data, toAddress) { // the wrong number, and for a token with fewer decimals than the // guess it is the wrong number in the direction that reads as // zero. See tokenAmountText(). - const tokenDecimals = resolveTokenDecimals(toAddress, { - trackedTokens: state.trackedTokens, - wallets: state.wallets, - }); + const tokenDecimals = resolveTokenDecimals( + toAddress, + decimalsSources, + ); const contractLabel = tokenSymbol ? tokenSymbol + " (" + toAddress + ")" : toAddress; @@ -167,7 +175,7 @@ function decodeCalldata(data, toAddress) { } // Try Uniswap Universal Router - const routerResult = uniswap.decode(data, toAddress); + const routerResult = uniswap.decode(data, toAddress, decimalsSources); if (routerResult) return routerResult; return null; diff --git a/src/shared/approvalAmount.js b/src/shared/approvalAmount.js index 066b558..e9c3540 100644 --- a/src/shared/approvalAmount.js +++ b/src/shared/approvalAmount.js @@ -14,6 +14,10 @@ // them answers, unknownDecimalsAmount() renders the base-unit integer with the // unknown scale stated, and no formatUnits() call is reached at all. // +// The Uniswap decoder's Amount and Min. received lines land on this same +// screen and use these same two functions, so there is one way of resolving a +// scale and one way of saying there is none. +// // 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. diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 6c0b7ac..b637162 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -4,6 +4,10 @@ const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers"); const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { truncateAmountNeverZero } = require("./amountDisplay"); +const { + resolveTokenDecimals, + unknownDecimalsAmount, +} = require("./approvalAmount"); const coder = AbiCoder.defaultAbiCoder(); @@ -44,13 +48,37 @@ function formatAmount(raw, decimals) { return truncateAmountNeverZero(formatUnits(raw, decimals)); } -function tokenInfo(address) { +// `decimals` is null when nothing knows this token's scale. It is not +// defaulted to 18: the swap lines land on the same approval screen as the +// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as +// 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are +// on `state`; resolveTokenDecimals() reads the bundled list, then those. +function tokenInfo(address, sources) { if (!address || address === "0x0000000000000000000000000000000000000000") { return { symbol: "ETH", decimals: 18, address: null }; } const t = TOKEN_BY_ADDRESS.get(address.toLowerCase()); - if (t) return { symbol: t.symbol, decimals: t.decimals, address }; - return { symbol: null, decimals: 18, address }; + return { + symbol: t ? t.symbol : null, + decimals: resolveTokenDecimals(address, sources), + address, + }; +} + +// A swap amount line. With a scale it is the token quantity; with none it is +// the base-unit integer with the unknown scale stated, the same refusal the +// ERC-20 amount line makes, so the screen has one way of saying it. `display` +// is the line on the screen, `raw` is what the status screens carry. +function amountText(raw, info) { + if (info.decimals === null) { + const unknown = unknownDecimalsAmount(raw); + return { raw: unknown, display: unknown }; + } + const formatted = formatAmount(raw, info.decimals); + return { + raw: formatted, + display: formatted + (info.symbol ? " " + info.symbol : ""), + }; } // Decode PERMIT2_PERMIT (command 0x0a) input bytes. @@ -323,7 +351,7 @@ function decodeV4Swap(input) { // Try to decode a Universal Router execute() call. // Returns { name, description, details } matching the format used by // the approval UI, or null if the calldata is not a recognised execute(). -function decode(data, toAddress) { +function decode(data, toAddress, sources) { try { const parsed = ROUTER_IFACE.parseTransaction({ data }); if (!parsed) return null; @@ -416,10 +444,10 @@ function decode(data, toAddress) { } // Resolve token info - const inInfo = tokenInfo(inputToken); + const inInfo = tokenInfo(inputToken, sources); const outInfo = hasUnwrapWeth ? { symbol: "ETH", decimals: 18, address: null } - : tokenInfo(outputToken); + : tokenInfo(outputToken, sources); const inSymbol = inInfo.symbol; const outSymbol = outInfo.symbol; @@ -456,16 +484,15 @@ function decode(data, toAddress) { "0xffffffffffffffffffffffffffffffffffffffff", ); const isUnlimited = inputAmount >= maxUint160; - const amountRaw = isUnlimited - ? "Unlimited" - : formatAmount(inputAmount, inInfo.decimals); - const amountStr = isUnlimited - ? "Unlimited" - : amountRaw + (inSymbol ? " " + inSymbol : ""); + // An unbounded permit needs no scale to describe, so it is still + // named rather than refused. + const amount = isUnlimited + ? { raw: "Unlimited", display: "Unlimited" } + : amountText(inputAmount, inInfo); details.push({ label: "Amount", - value: amountStr, - rawValue: amountRaw, + value: amount.display, + rawValue: amount.raw, }); } @@ -486,10 +513,10 @@ function decode(data, toAddress) { } if (minOutput !== null && minOutput !== undefined) { - const minStr = - formatAmount(minOutput, outInfo.decimals) + - (outSymbol ? " " + outSymbol : ""); - details.push({ label: "Min. received", value: minStr }); + details.push({ + label: "Min. received", + value: amountText(minOutput, outInfo).display, + }); } details.push({ label: "Steps", value: commandNames.join(" \u2192 ") }); diff --git a/tests/uniswapUnknownDecimals.test.js b/tests/uniswapUnknownDecimals.test.js new file mode 100644 index 0000000..a5be93c --- /dev/null +++ b/tests/uniswapUnknownDecimals.test.js @@ -0,0 +1,176 @@ +// The scale the swap lines of the dApp approval screen are displayed with. +// +// Issue #340: `tokenInfo()` in `src/shared/uniswap.js` returned `decimals: 18` +// for any token absent from the bundled list — the same guess +// https://git.eeqj.de/sneak/AutistMask/issues/306 removed from the ERC-20 +// amount line, still live on the swap path. A 1,000-token swap of a 6-decimal +// token then rendered as `0.000000001` on the one screen whose job is to state +// what is being authorized, and every newly listed token reaches it. +// +// What is asserted here is the rule #306 established: resolve the real scale +// wherever the wallet already has it, and where nothing has it refuse to +// format — base units with the scale stated, never a quantity. + +globalThis.chrome = { + storage: { local: { get: async () => ({}), set: async () => {} } }, +}; + +const { AbiCoder, Interface } = require("ethers"); +const { state } = require("../src/shared/state"); +const { unknownDecimalsAmount } = require("../src/shared/approvalAmount"); +const { decodeCalldata } = require("../src/popup/views/approval"); + +const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; +const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; +// Outside the bundled list, as every newly listed token is. +const NOVEL = "0xE2E0000000000000000000000000000000000E2e"; +// Also outside it, standing in for the swap's output side. +const NOVEL_OUT = "0xd0d0000000000000000000000000000000000d0d"; +// In the bundled list, at 18 decimals. +const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; + +// 1,000.00 of a 6-decimal token — the amount from the issue, which the 18 +// guess rendered as 0.000000001. +const THOUSAND_AT_SIX = 1000000000n; +// 0.5 WETH out, so the Min. received line has a real number of its own. +const HALF_WETH = 500000000000000000n; + +const coder = AbiCoder.defaultAbiCoder(); +const routerIface = new Interface([ + "function execute(bytes commands, bytes[] inputs, uint256 deadline)", +]); + +// A V2_SWAP_EXACT_IN (command 0x08) execute() call: `amountIn` of `tokenIn` +// for at least `amountOutMin` of `tokenOut`. +function swapData(tokenIn, amountIn, tokenOut, amountOutMin) { + const input = coder.encode( + ["address", "uint256", "uint256", "address[]", "bool"], + [RECIPIENT, amountIn, amountOutMin, [tokenIn, tokenOut], true], + ); + return routerIface.encodeFunctionData("execute", [ + "0x08", + [input], + 9999999999n, + ]); +} + +// 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: "1000.0", + }, + ], + }, + ], + }, + ]; +} + +// The swap detail line as the approval screen renders it. It goes through +// decodeCalldata() rather than uniswap.decode() directly, because the scale +// sources the screen supplies are part of what is under test. +function swapDetail(data, label) { + const decoded = decodeCalldata(data, ROUTER); + return decoded.details.find((d) => d.label === label); +} + +beforeEach(() => { + state.trackedTokens = []; + state.wallets = []; +}); + +describe("a swap of a token outside the bundled list", () => { + const data = () => swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH); + + test("shows the true quantity when the user tracks the token", () => { + state.trackedTokens = [ + { address: NOVEL, symbol: "NOVEL", decimals: 6 }, + ]; + expect(swapDetail(data(), "Amount").value).toBe("1000.0000"); + }); + + test("shows the true quantity from the explorer's decimals", () => { + state.wallets = walletsHolding(NOVEL, "6"); + expect(swapDetail(data(), "Amount").value).toBe("1000.0000"); + }); + + test("refuses to format when nothing knows the scale", () => { + const detail = swapDetail(data(), "Amount"); + expect(detail.value).toBe("1000000000 base units (decimals unknown)"); + expect(detail.value).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX)); + // The defect: an 18-decimal guess renders this swap as 0.000000001, a + // quantity, and a wrong one. + expect(detail.value).not.toMatch(/^0\./); + expect(detail.value).not.toMatch(/[0-9]\.[0-9]/); + }); + + test("the amount carried to the status screens is the same refusal", () => { + expect(swapDetail(data(), "Amount").rawValue).toBe( + "1000000000 base units (decimals unknown)", + ); + }); + + test("a bundled token on the other side still formats", () => { + expect(swapDetail(data(), "Min. received").value).toBe("0.5000 WETH"); + }); +}); + +describe("the Min. received line takes the same rule", () => { + test("refuses to format an output token of unknown scale", () => { + const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX); + const detail = swapDetail(data, "Min. received"); + expect(detail.value).toBe("1000000000 base units (decimals unknown)"); + expect(detail.value).not.toMatch(/[0-9]\.[0-9]/); + }); + + test("shows the true quantity when the user tracks the output token", () => { + state.trackedTokens = [ + { address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 }, + ]; + const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX); + expect(swapDetail(data, "Min. received").value).toBe("1000.0000"); + }); +}); + +describe("the permit amount takes the same rule", () => { + // PERMIT2_PERMIT (command 0x0a): the input token and amount come from the + // permit rather than from a swap step. + function permitData(token, amount) { + const input = coder.encode( + [ + "tuple(tuple(address,uint160,uint48,uint48),address,uint256)", + "bytes", + ], + [[[token, amount, 0, 0], ROUTER, 9999999999], "0x1234"], + ); + return routerIface.encodeFunctionData("execute", [ + "0x0a", + [input], + 9999999999n, + ]); + } + + test("refuses to format a permit on a token of unknown scale", () => { + const detail = swapDetail(permitData(NOVEL, THOUSAND_AT_SIX), "Amount"); + expect(detail.value).toBe("1000000000 base units (decimals unknown)"); + }); + + test("an unbounded permit is still named, with or without a scale", () => { + const maxUint160 = (1n << 160n) - 1n; + expect(swapDetail(permitData(NOVEL, maxUint160), "Amount").value).toBe( + "Unlimited", + ); + }); +});