A V4 swap could set minOutput while leaving outputToken null, and tokenInfo(null) returned ETH at 18 decimals, so the approval screen named an asset the calldata never stated. Established from the V4 encoding that this is wrong rather than protocol semantics: Currency is a user-defined value type over address, so native ETH arrives as the truthy zero-address string and is already mapped to ETH, and an UNWRAP_WETH output is caught earlier — both genuine native-ETH routes are covered without null, so null can only mean no sub-action yielded a currency. A genuine native-ETH V4 swap still renders as ETH, pinned by test against the real mainnet fixture. The same gate also let a later step overwrite minOutput while an earlier hop's outputToken stayed on screen, showing a figure against the wrong token. Fixed together as the same two lines and the same defect.
212 lines
7.8 KiB
JavaScript
212 lines
7.8 KiB
JavaScript
// 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");
|
|
});
|
|
});
|