fix: always name a swap's output token, by address when no symbol is known (closes #346)
The Token Out detail line was pushed only when the bundled token list supplied a symbol, so a swap whose output token is absent from that list — which is every newly listed token — rendered no Token Out line at all, leaving a Min. received figure with no statement of which token is being received. The line is now keyed on the token's address and falls back to showing it when there is no symbol, the same fallback the Token In line in this file already made; the symbol-only case (native ETH, including an unwrap) is unchanged. This composes with the unknown-scale refusal on the same screen: for a token nothing knows the scale of, the address names the token and the base-unit figure states the amount with the scale declared unknown, so no address stands next to a number that silently means something else.
This commit is contained in:
11
TODO.md
11
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
107
tests/uniswapTokenOut.test.js
Normal file
107
tests/uniswapTokenOut.test.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user