harden: say a swap's output token is unknown rather than calling it ETH (closes #353)
`tokenInfo(null)` answers `{symbol: "ETH", decimals: 18}`, and the V4 arm of
`decode()` could take a step's `amountOutMin` while leaving `outputToken`
null, because the token assignment was gated on `if (v4.tokenOut)`. The
approval screen then named ETH as the output and formatted `Min. received` at
18 decimals for a swap whose output currency the calldata never stated.
Null is not how V4 spells native ETH. v4-core declares `type Currency is
address` and wraps `address(0)` for native ETH, and a `Currency` is
ABI-encoded as a plain address word, so every decode site here gets back the
truthy string `0x0000000000000000000000000000000000000000` for it — which
`tokenInfo()` already names ETH — and an UNWRAP_WETH output is caught
separately. Nothing that genuinely outputs ETH arrives as null; only a step
whose output currency did not decode does.
So a null output token is now treated as undetermined: `Token Out` reads
`Unknown (not named in the calldata)` and `Min. received` falls to the
base-unit refusal from
#340 instead of being scaled at 18.
A V4 step that supersedes an earlier step's `Min. received` without naming a
currency also clears the earlier step's token, so that figure is never read
against the token a previous hop named.
This commit is contained in:
211
tests/uniswapUndeterminedTokenOut.test.js
Normal file
211
tests/uniswapUndeterminedTokenOut.test.js
Normal file
@@ -0,0 +1,211 @@
|
||||
// What the dApp approval screen says the output token of a V4 swap is when the
|
||||
// calldata did not name one.
|
||||
//
|
||||
// Issue #353: `src/shared/uniswap.js` took a V4 step's `amountOutMin` while
|
||||
// leaving `outputToken` null, and `tokenInfo(null)` answers
|
||||
// `{symbol: "ETH", decimals: 18}`. An undetermined output was therefore stated
|
||||
// to the user as ETH, with `Min. received` formatted at 18 decimals — the same
|
||||
// class as https://git.eeqj.de/sneak/AutistMask/issues/340 and
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/306, but naming the wrong asset
|
||||
// rather than the wrong scale.
|
||||
//
|
||||
// The determination this file pins: null is NOT how V4 spells native ETH.
|
||||
// v4-core declares `type Currency is address` and wraps `address(0)` for
|
||||
// native ETH, and a Currency is ABI-encoded as a plain address word, so a
|
||||
// native-ETH currency reaches the decoder as the string
|
||||
// "0x0000000000000000000000000000000000000000" — truthy, and already named
|
||||
// ETH by tokenInfo(). Both cases are asserted below: the zero address stays
|
||||
// ETH, and null refuses.
|
||||
|
||||
const { AbiCoder, Interface, solidityPacked } = require("ethers");
|
||||
const uniswap = require("../src/shared/uniswap");
|
||||
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
|
||||
|
||||
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
||||
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
const ZERO = "0x0000000000000000000000000000000000000000";
|
||||
// Both in the bundled list: USDT at 6 decimals, WETH at 18.
|
||||
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
||||
|
||||
const REFUSAL = "Unknown (not named in the calldata)";
|
||||
|
||||
// The figure whose asset is in question. At 18 decimals it renders as
|
||||
// 0.000000000001; in base units it renders as itself.
|
||||
const MIN_OUT = 1234567n;
|
||||
const HALF_ETH = 500000000000000000n;
|
||||
|
||||
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
|
||||
const V4_SWAP_EXACT_IN = 0x07;
|
||||
const V4_SETTLE = 0x0b;
|
||||
const V4_TAKE = 0x0e;
|
||||
|
||||
const coder = AbiCoder.defaultAbiCoder();
|
||||
const routerIface = new Interface([
|
||||
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
||||
]);
|
||||
|
||||
function execute(commands, inputs) {
|
||||
return routerIface.encodeFunctionData("execute", [
|
||||
commands,
|
||||
inputs,
|
||||
9999999999n,
|
||||
]);
|
||||
}
|
||||
|
||||
function v4Input(actions, params) {
|
||||
return coder.encode(
|
||||
["bytes", "bytes[]"],
|
||||
[new Uint8Array(actions), params],
|
||||
);
|
||||
}
|
||||
|
||||
// IV4Router.ExactInputParams as the decoder reads it:
|
||||
// (Currency currencyIn, PathKey[] path, uint128 amountIn, uint128 minOut).
|
||||
// An empty path names no output currency at all.
|
||||
function exactInParams(currencyIn, path, amountIn, amountOutMin) {
|
||||
return coder.encode(
|
||||
[
|
||||
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
||||
],
|
||||
[[currencyIn, path, amountIn, amountOutMin]],
|
||||
);
|
||||
}
|
||||
|
||||
// IV4Router.ExactInputSingleParams: (PoolKey, bool zeroForOne, uint128
|
||||
// amountIn, uint128 minOut, bytes hookData).
|
||||
function exactInSingleParams(currency0, currency1, zeroForOne, min) {
|
||||
return coder.encode(
|
||||
[
|
||||
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
||||
],
|
||||
[
|
||||
[
|
||||
[currency0, currency1, 500, 10, ZERO],
|
||||
zeroForOne,
|
||||
1000000n,
|
||||
min,
|
||||
"0x",
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// V3_SWAP_EXACT_IN (command 0x00) input: path is token(20) fee(3) token(20).
|
||||
function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) {
|
||||
const path =
|
||||
"0x" +
|
||||
tokenIn.slice(2).toLowerCase() +
|
||||
"000bb8" +
|
||||
tokenOut.slice(2).toLowerCase();
|
||||
return coder.encode(
|
||||
["address", "uint256", "uint256", "bytes", "bool"],
|
||||
[USER, amountIn, amountOutMin, path, true],
|
||||
);
|
||||
}
|
||||
|
||||
function detail(data, label) {
|
||||
const decoded = uniswap.decode(data, ROUTER, {});
|
||||
expect(decoded).not.toBeNull();
|
||||
return decoded.details.find((d) => d.label === label);
|
||||
}
|
||||
|
||||
describe("a V4 step that states a Min. received but no output currency", () => {
|
||||
// SWAP_EXACT_IN with an empty path: amountOutMin decodes, no currency out
|
||||
// does, and there is no TAKE to supply one.
|
||||
const data = () =>
|
||||
execute("0x10", [
|
||||
v4Input(
|
||||
[V4_SWAP_EXACT_IN],
|
||||
[exactInParams(USDT, [], 5000000n, MIN_OUT)],
|
||||
),
|
||||
]);
|
||||
|
||||
test("says the output token is unknown instead of naming ETH", () => {
|
||||
expect(detail(data(), "Token Out").value).toBe(REFUSAL);
|
||||
});
|
||||
|
||||
test("keeps a Token Out line, so the figure is never unattributed", () => {
|
||||
const out = detail(data(), "Token Out");
|
||||
expect(out).toBeDefined();
|
||||
// A refusal is not a token: nothing to link to an explorer.
|
||||
expect(out.address).toBeUndefined();
|
||||
expect(out.isToken).toBeUndefined();
|
||||
});
|
||||
|
||||
test("refuses to scale Min. received rather than assuming 18", () => {
|
||||
expect(detail(data(), "Min. received").value).toBe(
|
||||
unknownDecimalsAmount(MIN_OUT),
|
||||
);
|
||||
});
|
||||
|
||||
test("does not name ETH in the swap title either", () => {
|
||||
const decoded = uniswap.decode(data(), ROUTER, {});
|
||||
expect(decoded.name).toBe("Uniswap Swap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a V4 step whose Min. received supersedes an earlier step's", () => {
|
||||
// V3 USDT -> WETH, then a V4 step that carries the final Min. received but
|
||||
// names no currency. The figure belongs to the V4 step, so it must not be
|
||||
// read against the token the V3 step named.
|
||||
const data = () =>
|
||||
execute(solidityPacked(["uint8", "uint8"], [0x00, 0x10]), [
|
||||
v3ExactIn(USDT, WETH, 5000000n, HALF_ETH),
|
||||
v4Input(
|
||||
[V4_SWAP_EXACT_IN],
|
||||
[exactInParams(WETH, [], HALF_ETH, MIN_OUT)],
|
||||
),
|
||||
]);
|
||||
|
||||
test("does not keep naming the earlier step's output token", () => {
|
||||
expect(detail(data(), "Token Out").value).toBe(REFUSAL);
|
||||
});
|
||||
|
||||
test("refuses to scale the superseding figure", () => {
|
||||
expect(detail(data(), "Min. received").value).toBe(
|
||||
unknownDecimalsAmount(MIN_OUT),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native ETH out, which V4 spells as the zero address", () => {
|
||||
// The pin on the other half of the determination. PoolKey currencies are
|
||||
// ordered, so native ETH is currency0; zeroForOne false swaps USDT in for
|
||||
// ETH out. TAKE names the same zero-address currency.
|
||||
const data = () =>
|
||||
execute("0x10", [
|
||||
v4Input(
|
||||
[V4_SETTLE, V4_SWAP_EXACT_IN_SINGLE, V4_TAKE],
|
||||
[
|
||||
coder.encode(
|
||||
["address", "uint256", "bool"],
|
||||
[USDT, 5000000n, true],
|
||||
),
|
||||
exactInSingleParams(ZERO, USDT, false, HALF_ETH),
|
||||
coder.encode(
|
||||
["address", "address", "uint256"],
|
||||
[ZERO, USER, 0n],
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
|
||||
test("a Currency of address(0) decodes to a truthy address string", () => {
|
||||
// The fact the whole determination rests on: an absent output currency
|
||||
// and a native-ETH one are distinguishable here, because the ABI
|
||||
// decoder never yields null for an address word.
|
||||
const [currency] = coder.decode(
|
||||
["address", "address", "uint256"],
|
||||
coder.encode(["address", "address", "uint256"], [ZERO, USER, 0n]),
|
||||
);
|
||||
expect(currency).toBe(ZERO);
|
||||
expect(Boolean(currency)).toBe(true);
|
||||
});
|
||||
|
||||
test("is still named ETH and still scaled at 18 decimals", () => {
|
||||
expect(detail(data(), "Token Out").value).toBe("ETH");
|
||||
expect(detail(data(), "Min. received").value).toBe("0.5000 ETH");
|
||||
expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Swap USDT → ETH");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user