diff --git a/TODO.md b/TODO.md index a9ff42b..68c9dcd 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,17 @@ but the review is broader than any of them. # Completed Steps +- 2026-08-23: A swap always names its output token + ([#346](https://git.eeqj.de/sneak/AutistMask/issues/346)). The `Token Out` + detail line in `src/shared/uniswap.js` was pushed only when a symbol was + known, so a swap whose output token is absent from the bundled list — every + newly listed token — showed a `Min. received` figure with nothing saying what + was being received. The line is now keyed on the token's address and falls + back to it when there is no symbol, exactly as the `Token In` line already + did. It composes with the unknown-scale refusal from + [#340](https://git.eeqj.de/sneak/AutistMask/issues/340): the address says + which token, the base-unit figure says how much and states that the scale is + unknown. - 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 diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index b637162..59be572 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -496,20 +496,23 @@ function decode(data, toAddress, sources) { }); } - if (outSymbol) { - if (outInfo.address) { - const label = outSymbol - ? outSymbol + " (" + outputToken + ")" - : outputToken; - details.push({ - label: "Token Out", - value: label, - address: outputToken, - isToken: true, - }); - } else { - details.push({ label: "Token Out", value: outSymbol }); - } + // Keyed on the address, not the symbol: a token absent from the + // bundled list has no symbol, and gating the line on one dropped it + // entirely, leaving a Min. received figure with nothing saying what is + // being received. The Token In line above already falls back to the + // address; this does the same. + if (outInfo.address) { + const label = outSymbol + ? outSymbol + " (" + outInfo.address + ")" + : outInfo.address; + details.push({ + label: "Token Out", + value: label, + address: outInfo.address, + isToken: true, + }); + } else if (outSymbol) { + details.push({ label: "Token Out", value: outSymbol }); } if (minOutput !== null && minOutput !== undefined) { diff --git a/tests/uniswapTokenOut.test.js b/tests/uniswapTokenOut.test.js new file mode 100644 index 0000000..9979d11 --- /dev/null +++ b/tests/uniswapTokenOut.test.js @@ -0,0 +1,107 @@ +// The output token of a swap, as the dApp approval screen names it. +// +// Issue #346: the `Token Out` detail line in `src/shared/uniswap.js` was +// pushed only `if (outSymbol)`, and a token absent from the bundled list has +// no symbol. The line was therefore dropped entirely for exactly that +// population — which is every newly listed token — leaving a `Min. received` +// figure with nothing on the screen saying what is being received. The +// `Token In` line already falls back to the address; the rule asserted here is +// that the output side does too, always. + +const { AbiCoder, Interface, getAddress } = require("ethers"); +const uniswap = require("../src/shared/uniswap"); +const { unknownDecimalsAmount } = require("../src/shared/approvalAmount"); + +const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; +const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; +// In the bundled list, at 18 decimals. +const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; +// Outside it, as every newly listed token is. Checksummed, because that is +// the form the decoder gets back from the ABI decode and puts on the line. +const NOVEL_OUT = getAddress("0xd0d0000000000000000000000000000000000d0d"); + +const HALF_WETH = 500000000000000000n; +// 1,000.00 of a 6-decimal token. +const THOUSAND_AT_SIX = 1000000000n; + +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. +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, + ]); +} + +// An UNWRAP_WETH (command 0x0c) execute() call: the output side is native ETH, +// which has no contract address to name. +function unwrapData() { + return routerIface.encodeFunctionData("execute", [ + "0x0c", + [coder.encode(["address", "uint256"], [RECIPIENT, HALF_WETH])], + 9999999999n, + ]); +} + +function detail(data, label, sources) { + const decoded = uniswap.decode(data, ROUTER, sources || {}); + return decoded.details.find((d) => d.label === label); +} + +describe("a swap to a token absent from the bundled list", () => { + const data = () => swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX); + + test("still renders a Token Out line, naming the address", () => { + const out = detail(data(), "Token Out"); + expect(out).toBeDefined(); + expect(out.value).toBe(NOVEL_OUT); + expect(out.address).toBe(NOVEL_OUT); + expect(out.isToken).toBe(true); + }); + + test("names the address alongside the unknown-scale refusal", () => { + // The same population hits both: no symbol, and no scale either. The + // address says which token, the base units say how much and admit the + // scale is unknown — neither line silently means something else. + expect(detail(data(), "Token Out").value).toBe(NOVEL_OUT); + expect(detail(data(), "Min. received").value).toBe( + unknownDecimalsAmount(THOUSAND_AT_SIX), + ); + }); + + test("names the address when the scale is known but the symbol is not", () => { + const sources = { + trackedTokens: [ + { address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 }, + ], + }; + expect(detail(data(), "Token Out", sources).value).toBe(NOVEL_OUT); + expect(detail(data(), "Min. received", sources).value).toBe( + "1000.0000", + ); + }); +}); + +describe("the output tokens that already had a line keep it unchanged", () => { + test("a bundled token is named by symbol and address", () => { + const data = swapData(NOVEL_OUT, THOUSAND_AT_SIX, WETH, HALF_WETH); + const out = detail(data, "Token Out"); + expect(out.value).toBe("WETH (" + WETH + ")"); + expect(out.address).toBe(WETH); + }); + + test("an unwrap to native ETH is named ETH, with no address", () => { + const out = detail(unwrapData(), "Token Out"); + expect(out.value).toBe("ETH"); + expect(out.address).toBeUndefined(); + }); +});