diff --git a/README.md b/README.md index 10b7fdf..9ce197d 100644 --- a/README.md +++ b/README.md @@ -734,6 +734,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 d7f171d..76b8c74 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", + ); + }); +});