A V4 step's input side is now the currency its SETTLE or SETTLE_ALL names, and its output side the one its TAKE or TAKE_ALL names. A side none of them names comes from the swap actions, lined up as the exact-out ones in reverse order and then the exact-in ones in order, since the router runs exact-out actions from the output end: the input from the first, the output from the last. Amount is the first action's amountIn and Min. received the last one's minimum, each shown only when that action spends the input currency or buys the output currency. SETTLE_ALL and TAKE_ALL were not read before, the first swap action set both sides, and a SWAP_EXACT_OUT was read as spending its path's last currency instead of its first. Model: opus-5-5
1570 lines
63 KiB
JavaScript
1570 lines
63 KiB
JavaScript
const { AbiCoder, Interface, solidityPacked } = require("ethers");
|
|
const uniswap = require("../src/shared/uniswap");
|
|
|
|
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
|
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
|
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
|
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
|
|
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
|
|
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
|
|
// AutistMask's first-ever swap, 2026-02-27.
|
|
// Swapped USDT for ETH via Uniswap V4 Universal Router.
|
|
// https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77
|
|
const FIRST_SWAP_CALLDATA =
|
|
"0x3593564c" +
|
|
"000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0" +
|
|
"0000000000000000000000000000000000000000000000000000000069a1550f00000000000000000000000000000000000000000000000000000000000000020a10000000000000000000000000000000000000000000000000000000000000" +
|
|
"0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" +
|
|
"0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff" +
|
|
"0000000000000000000000000000000000000000000000000000000069c8daf6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" +
|
|
"0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000041" +
|
|
"230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
|
|
"00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" +
|
|
"070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" +
|
|
"00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" +
|
|
"0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e7" +
|
|
"0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064" +
|
|
"0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0" +
|
|
"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
|
|
"dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
|
|
"66133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
|
|
|
|
const coder = AbiCoder.defaultAbiCoder();
|
|
const routerIface = new Interface([
|
|
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
|
]);
|
|
|
|
// Helper: build a minimal execute() calldata from commands + inputs
|
|
function buildExecute(commands, inputs, deadline) {
|
|
return routerIface.encodeFunctionData("execute", [
|
|
commands,
|
|
inputs,
|
|
deadline,
|
|
]);
|
|
}
|
|
|
|
// Helper: encode a PERMIT2_PERMIT input (command 0x0a)
|
|
function encodePermit2(token, amount, spender) {
|
|
return coder.encode(
|
|
[
|
|
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
|
|
"bytes",
|
|
],
|
|
[[[token, amount, 0, 0], spender, 9999999999], "0x1234"],
|
|
);
|
|
}
|
|
|
|
// Helper: encode a BALANCE_CHECK_ERC20 input (command 0x0e)
|
|
function encodeBalanceCheck(owner, token, minBalance) {
|
|
return coder.encode(
|
|
["address", "address", "uint256"],
|
|
[owner, token, minBalance],
|
|
);
|
|
}
|
|
|
|
// Helper: encode a WRAP_ETH input (command 0x0b)
|
|
function encodeWrapEth(recipient, amount) {
|
|
return coder.encode(["address", "uint256"], [recipient, amount]);
|
|
}
|
|
|
|
// Helper: encode a V2_SWAP_EXACT_IN input (command 0x08)
|
|
function encodeV2SwapExactIn(recipient, amountIn, amountOutMin, pathAddrs) {
|
|
return coder.encode(
|
|
["address", "uint256", "uint256", "address[]", "bool"],
|
|
[recipient, amountIn, amountOutMin, pathAddrs, true],
|
|
);
|
|
}
|
|
|
|
// Helper: encode a V2_SWAP_EXACT_OUT input (command 0x09)
|
|
function encodeV2SwapExactOut(recipient, amountOut, amountInMax, pathAddrs) {
|
|
return coder.encode(
|
|
["address", "uint256", "uint256", "address[]", "bool"],
|
|
[recipient, amountOut, amountInMax, pathAddrs, true],
|
|
);
|
|
}
|
|
|
|
// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
|
|
function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
|
|
// V3 path: token(20) + fee(3) + token(20) ...
|
|
let pathHex = pathTokens[0].slice(2).toLowerCase();
|
|
for (let i = 1; i < pathTokens.length; i++) {
|
|
pathHex += "000bb8"; // fee 3000 = 0x000bb8
|
|
pathHex += pathTokens[i].slice(2).toLowerCase();
|
|
}
|
|
return coder.encode(
|
|
["address", "uint256", "uint256", "bytes", "bool"],
|
|
[recipient, amountIn, amountOutMin, "0x" + pathHex, true],
|
|
);
|
|
}
|
|
|
|
// Helper: encode a V4_SWAP input (command 0x10) — just a passthrough blob
|
|
function encodeV4Swap(actions, params) {
|
|
return coder.encode(["bytes", "bytes[]"], [actions, params]);
|
|
}
|
|
|
|
// V4 inner action IDs, as src/shared/uniswap.js names them.
|
|
const V4_SWAP_EXACT_IN = 0x07;
|
|
const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06;
|
|
const V4_SWAP_EXACT_OUT_SINGLE_ID = 0x08;
|
|
const V4_SETTLE_ID = 0x0b;
|
|
const V4_TAKE_ID = 0x0e;
|
|
const ZERO_ADDR = "0x0000000000000000000000000000000000000000";
|
|
|
|
// Helper: V4 SETTLE params — (address currency, uint256 maxAmount, bool payerIsUser)
|
|
function encodeV4Settle(currency) {
|
|
return coder.encode(["address", "uint256", "bool"], [currency, 0n, true]);
|
|
}
|
|
|
|
// Helper: V4 TAKE params — (address currency, address recipient, uint256 amount)
|
|
function encodeV4Take(currency) {
|
|
return coder.encode(
|
|
["address", "address", "uint256"],
|
|
[currency, USER_ADDR, 0n],
|
|
);
|
|
}
|
|
|
|
// Helper: V4 ExactInputParams — (address currencyIn,
|
|
// tuple(address,uint24,int24,address,bytes)[] path,
|
|
// uint128 amountIn, uint128 amountOutMin)
|
|
function encodeV4ExactIn(currencyIn, pathTokens, amountIn, amountOutMin) {
|
|
return coder.encode(
|
|
[
|
|
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
|
],
|
|
[
|
|
[
|
|
currencyIn,
|
|
pathTokens.map((t) => [t, 3000, 60, ZERO_ADDR, "0x"]),
|
|
amountIn,
|
|
amountOutMin,
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
// Helper: V4 ExactInputSingleParams —
|
|
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne,
|
|
// uint128 amountIn, uint128 amountOutMin, bytes hookData)
|
|
function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
|
|
return coder.encode(
|
|
[
|
|
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
|
],
|
|
[
|
|
[
|
|
[currency0, currency1, 100, 1, ZERO_ADDR],
|
|
true, // zeroForOne: in = currency0, out = currency1
|
|
amountIn,
|
|
amountOutMin,
|
|
"0x",
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
// Helper: V4 ExactOutputSingleParams —
|
|
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne,
|
|
// uint128 amountOut, uint128 amountInMaximum, bytes hookData)
|
|
function encodeV4ExactOutSingle(
|
|
currency0,
|
|
currency1,
|
|
amountOut,
|
|
amountInMaximum,
|
|
) {
|
|
return coder.encode(
|
|
[
|
|
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
|
],
|
|
[
|
|
[
|
|
[currency0, currency1, 100, 1, ZERO_ADDR],
|
|
true, // zeroForOne: in = currency0, out = currency1
|
|
amountOut,
|
|
amountInMaximum,
|
|
"0x",
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
// Each V4 swap action's parameters in two shapes: the struct V4Router has
|
|
// deployed on mainnet and Sepolia, and the one v4-periphery `main` has since
|
|
// moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC.
|
|
const POOL_KEY = "tuple(address,address,uint24,int24,address)";
|
|
const PATH_KEY = "tuple(address,uint24,int24,address,bytes)";
|
|
const WETH_USDC_POOL = [WETH_ADDR, USDC_ADDR, 100, 1, ZERO_ADDR];
|
|
const ONE_WETH = 1000000000000000000n;
|
|
const V4_SWAP_ACTION_SHAPES = [
|
|
{
|
|
action: "SWAP_EXACT_IN_SINGLE",
|
|
id: 0x06,
|
|
deployed: coder.encode(
|
|
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
|
|
[[WETH_USDC_POOL, true, ONE_WETH, 990000n, "0x"]],
|
|
),
|
|
newer: coder.encode(
|
|
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
|
|
[[WETH_USDC_POOL, true, ONE_WETH, 990000n, 0n, "0x"]],
|
|
),
|
|
},
|
|
{
|
|
action: "SWAP_EXACT_IN",
|
|
id: 0x07,
|
|
deployed: coder.encode(
|
|
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
|
|
[
|
|
[
|
|
WETH_ADDR,
|
|
[[USDC_ADDR, 100, 1, ZERO_ADDR, "0x"]],
|
|
ONE_WETH,
|
|
990000n,
|
|
],
|
|
],
|
|
),
|
|
newer: coder.encode(
|
|
[`tuple(address,${PATH_KEY}[],uint256[],uint128,uint128)`],
|
|
[
|
|
[
|
|
WETH_ADDR,
|
|
[[USDC_ADDR, 100, 1, ZERO_ADDR, "0x"]],
|
|
[],
|
|
ONE_WETH,
|
|
990000n,
|
|
],
|
|
],
|
|
),
|
|
},
|
|
{
|
|
action: "SWAP_EXACT_OUT_SINGLE",
|
|
id: 0x08,
|
|
deployed: coder.encode(
|
|
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
|
|
[[WETH_USDC_POOL, true, 990000n, ONE_WETH, "0x"]],
|
|
),
|
|
newer: coder.encode(
|
|
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
|
|
[[WETH_USDC_POOL, true, 990000n, ONE_WETH, 0n, "0x"]],
|
|
),
|
|
},
|
|
{
|
|
action: "SWAP_EXACT_OUT",
|
|
id: 0x09,
|
|
deployed: coder.encode(
|
|
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
|
|
[
|
|
[
|
|
USDC_ADDR,
|
|
[[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"]],
|
|
990000n,
|
|
ONE_WETH,
|
|
],
|
|
],
|
|
),
|
|
newer: coder.encode(
|
|
[`tuple(address,${PATH_KEY}[],uint256[],uint128,uint128)`],
|
|
[
|
|
[
|
|
USDC_ADDR,
|
|
[[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"]],
|
|
[],
|
|
990000n,
|
|
ONE_WETH,
|
|
],
|
|
],
|
|
),
|
|
},
|
|
];
|
|
|
|
// Helper: execute() with one V4_SWAP step holding the swap action, then
|
|
// SETTLE of WETH and TAKE of USDC.
|
|
function v4SwapCalldata(actionId, swapParams) {
|
|
return buildExecute(
|
|
"0x10",
|
|
[
|
|
encodeV4Swap(new Uint8Array([actionId, V4_SETTLE_ID, V4_TAKE_ID]), [
|
|
swapParams,
|
|
encodeV4Settle(WETH_ADDR),
|
|
encodeV4Take(USDC_ADDR),
|
|
]),
|
|
],
|
|
9999999999n,
|
|
);
|
|
}
|
|
|
|
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount).
|
|
const V4_SETTLE_ALL_ID = 0x0c;
|
|
const V4_TAKE_ALL_ID = 0x0f;
|
|
function encodeV4SettleAll(currency, maxAmount) {
|
|
return coder.encode(["address", "uint256"], [currency, maxAmount]);
|
|
}
|
|
function encodeV4TakeAll(currency, minAmount) {
|
|
return coder.encode(["address", "uint256"], [currency, minAmount]);
|
|
}
|
|
|
|
function detail(result, label) {
|
|
return result.details.find((d) => d.label === label);
|
|
}
|
|
|
|
describe("uniswap decoder", () => {
|
|
test("returns null for non-execute calldata", () => {
|
|
expect(uniswap.decode("0x", ROUTER_ADDR)).toBeNull();
|
|
expect(uniswap.decode("0xdeadbeef", ROUTER_ADDR)).toBeNull();
|
|
expect(uniswap.decode(null, ROUTER_ADDR)).toBeNull();
|
|
});
|
|
|
|
test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => {
|
|
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result.name).toBe("Swap USDT \u2192 ETH");
|
|
expect(result.description).toContain("Uniswap");
|
|
|
|
const labels = result.details.map((d) => d.label);
|
|
expect(labels).toContain("Protocol");
|
|
expect(labels).toContain("Token In");
|
|
expect(labels).toContain("Steps");
|
|
expect(labels).toContain("Deadline");
|
|
|
|
const tokenIn = result.details.find((d) => d.label === "Token In");
|
|
expect(tokenIn.value).toContain("USDT");
|
|
expect(tokenIn.address.toLowerCase()).toBe(USDT_ADDR.toLowerCase());
|
|
|
|
// Genuine native ETH on the output side, on a real mainnet fixture:
|
|
// V4's TAKE names it as Currency.wrap(address(0)), which reaches the
|
|
// decoder as the explicit zero address and must still read as ETH.
|
|
const tokenOut = result.details.find((d) => d.label === "Token Out");
|
|
expect(tokenOut.value).toBe("ETH");
|
|
expect(result.details.find((d) => d.label === "Amount").value).toBe(
|
|
"Unlimited",
|
|
);
|
|
expect(
|
|
result.details.find((d) => d.label === "Min. received").value,
|
|
).toBe("0.0002 ETH");
|
|
|
|
// Real mainnet calldata is in the deployed shape, so its V4 step is
|
|
// not said to be undecoded.
|
|
const steps = result.details.find((d) => d.label === "Steps");
|
|
expect(steps.value).toBe("Permit2 Permit → V4 Swap");
|
|
});
|
|
|
|
test.each(V4_SWAP_ACTION_SHAPES)(
|
|
"a V4 $action in the deployed shape is read",
|
|
({ id, deployed }) => {
|
|
const result = uniswap.decode(
|
|
v4SwapCalldata(id, deployed),
|
|
ROUTER_ADDR,
|
|
);
|
|
expect(detail(result, "Steps").value).toBe("V4 Swap");
|
|
},
|
|
);
|
|
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/358 — before it, all four
|
|
// newer structs decoded as the deployed ones without an error, and the
|
|
// newer SWAP_EXACT_IN showed the offset of its hop-price array as Amount
|
|
// ("0.0000000000000004 WETH") and its amountIn as Min. received.
|
|
test.each(V4_SWAP_ACTION_SHAPES)(
|
|
"a V4 $action in the newer periphery shape is shown as not decoded",
|
|
({ id, newer }) => {
|
|
const result = uniswap.decode(
|
|
v4SwapCalldata(id, newer),
|
|
ROUTER_ADDR,
|
|
);
|
|
expect(detail(result, "Amount")).toBeUndefined();
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
expect(detail(result, "Steps").value).toBe(
|
|
"V4 Swap (could not be decoded)",
|
|
);
|
|
// SETTLE and TAKE keep their own shape and still name both sides.
|
|
expect(detail(result, "Token In").address).toBe(WETH_ADDR);
|
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
|
},
|
|
);
|
|
|
|
// SETTLE_ALL and TAKE_ALL name both sides as SETTLE and TAKE do.
|
|
test.each(V4_SWAP_ACTION_SHAPES)(
|
|
"a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, takes both tokens from them",
|
|
({ id, newer }) => {
|
|
const data = buildExecute(
|
|
"0x10",
|
|
[
|
|
encodeV4Swap(
|
|
new Uint8Array([id, V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID]),
|
|
[
|
|
newer,
|
|
encodeV4SettleAll(WETH_ADDR, ONE_WETH),
|
|
encodeV4TakeAll(USDC_ADDR, 990000n),
|
|
],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(detail(result, "Steps").value).toBe(
|
|
"V4 Swap (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Token In").address).toBe(WETH_ADDR);
|
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
|
expect(detail(result, "Amount")).toBeUndefined();
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
// Only the unread swap action names the currencies here. The token lines
|
|
// must not say the calldata did not name them.
|
|
test.each(V4_SWAP_ACTION_SHAPES)(
|
|
"a V4 step of only a $action in the newer shape says both tokens could not be decoded",
|
|
({ id, newer }) => {
|
|
const data = buildExecute(
|
|
"0x10",
|
|
[encodeV4Swap(new Uint8Array([id]), [newer])],
|
|
9999999999n,
|
|
);
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(detail(result, "Steps").value).toBe(
|
|
"V4 Swap (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Token In").value).toBe(
|
|
"Unknown (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Token Out").value).toBe(
|
|
"Unknown (could not be decoded)",
|
|
);
|
|
},
|
|
);
|
|
|
|
// The user receives the V4 step's USDC, which only its unread swap
|
|
// action names. The V3 step's WETH and its Min. received figure were
|
|
// shown instead.
|
|
test.each(V4_SWAP_ACTION_SHAPES)(
|
|
"a V3 step, then a V4 step of only a $action in the newer shape, does not show the V3 step's output",
|
|
({ id, newer }) => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
|
|
[
|
|
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
]),
|
|
encodeV4Swap(new Uint8Array([id]), [newer]),
|
|
],
|
|
9999999999n,
|
|
);
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result.name).toBe("Uniswap Swap");
|
|
expect(detail(result, "Amount").value).toBe("2.0000 USDT");
|
|
expect(detail(result, "Token Out").value).toBe(
|
|
"Unknown (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
// The user pays the V4 step's WETH, which only its unread swap action
|
|
// names. The V3 step's USDC and its amount were shown instead.
|
|
test.each(V4_SWAP_ACTION_SHAPES)(
|
|
"a V4 step of only a $action in the newer shape, then a V3 step, does not show the V3 step's input",
|
|
({ id, newer }) => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
|
|
[
|
|
encodeV4Swap(new Uint8Array([id]), [newer]),
|
|
encodeV3SwapExactIn(
|
|
USER_ADDR,
|
|
990000n,
|
|
980000000000000000n,
|
|
[USDC_ADDR, DAI_ADDR],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result.name).toBe("Uniswap Swap");
|
|
expect(detail(result, "Token In").value).toBe(
|
|
"Unknown (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Amount")).toBeUndefined();
|
|
expect(detail(result, "Min. received").value).toBe("0.9800 DAI");
|
|
},
|
|
);
|
|
|
|
// UNWRAP_WETH makes Token Out ETH when no step set the output side. An
|
|
// unread swap action's step did set it, to undetermined.
|
|
test("an UNWRAP_WETH after an unread V4 swap action does not make Token Out ETH", () => {
|
|
const { id, newer } = V4_SWAP_ACTION_SHAPES[0];
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
|
|
[
|
|
encodeV4Swap(new Uint8Array([id]), [newer]),
|
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
|
|
],
|
|
9999999999n,
|
|
);
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(detail(result, "Token Out").value).toBe(
|
|
"Unknown (could not be decoded)",
|
|
);
|
|
});
|
|
|
|
test("decodes V2_SWAP_EXACT_IN with known tokens", () => {
|
|
const data = buildExecute(
|
|
"0x08", // V2_SWAP_EXACT_IN
|
|
[
|
|
encodeV2SwapExactIn(
|
|
USER_ADDR,
|
|
1000000n, // 1 USDT (6 decimals)
|
|
500000000000000n, // 0.0005 ETH
|
|
[USDT_ADDR, WETH_ADDR],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
expect(result.name).toBe("Swap USDT \u2192 WETH");
|
|
|
|
const amount = result.details.find((d) => d.label === "Amount");
|
|
expect(amount.value).toBe("1.0000 USDT");
|
|
|
|
const minOut = result.details.find((d) => d.label === "Min. received");
|
|
expect(minOut.value).toContain("WETH");
|
|
});
|
|
|
|
// Buy exactly 0.5 WETH for at most 1,500 USDC, paid by the user, sent to
|
|
// the caller (the router's MSG_SENDER recipient, address(1)).
|
|
test("decodes V2_SWAP_EXACT_OUT, stating the input amount as a maximum", () => {
|
|
const data = buildExecute(
|
|
"0x09", // V2_SWAP_EXACT_OUT
|
|
[
|
|
encodeV2SwapExactOut(
|
|
"0x0000000000000000000000000000000000000001",
|
|
500000000000000000n, // amountOut: 0.5 WETH
|
|
1500000000n, // amountInMax: 1,500 USDC (6 decimals)
|
|
[USDC_ADDR, WETH_ADDR],
|
|
),
|
|
],
|
|
1767225600n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result.name).toBe("Swap USDC → WETH");
|
|
expect(detail(result, "Token In").address).toBe(USDC_ADDR);
|
|
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
|
|
|
|
// The wait, success and error screens show rawValue as the amount, so
|
|
// it says "Up to" as well.
|
|
const amount = detail(result, "Amount");
|
|
expect(amount.value).toBe("Up to 1500.0000 USDC");
|
|
expect(amount.rawValue).toBe("Up to 1500.0000");
|
|
|
|
expect(detail(result, "Min. received").value).toBe("0.5000 WETH");
|
|
});
|
|
|
|
// Buy exactly 1,500 USDC for at most 0.5 ETH: WRAP_ETH of the maximum, the
|
|
// swap, then UNWRAP_WETH of 0, which returns the ETH the swap did not spend.
|
|
test("a V2 exact-out swap paid in ETH shows the token it buys and a maximum", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8", "uint8"], [0x0b, 0x09, 0x0c]),
|
|
[
|
|
encodeWrapEth(ROUTER_ADDR, 500000000000000000n),
|
|
encodeV2SwapExactOut(
|
|
USER_ADDR,
|
|
1500000000n, // amountOut: 1,500 USDC
|
|
500000000000000000n, // amountInMax: 0.5 WETH
|
|
[WETH_ADDR, USDC_ADDR],
|
|
),
|
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH same encoding
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result.name).toBe("Swap ETH → USDC");
|
|
|
|
const amount = detail(result, "Amount");
|
|
expect(amount.value).toBe("Up to 0.5000 ETH");
|
|
expect(amount.rawValue).toBe("Up to 0.5000");
|
|
|
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
|
expect(detail(result, "Min. received").value).toBe("1500.0000 USDC");
|
|
});
|
|
|
|
// Buy exactly 0.5 ETH for at most 1,500 USDC under a permit: the swap buys
|
|
// WETH and UNWRAP_WETH turns it into ETH.
|
|
test("a V2 exact-out swap that buys ETH shows ETH and the permit as a maximum", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x09, 0x0c]),
|
|
[
|
|
encodePermit2(USDC_ADDR, 1500000000n, ROUTER_ADDR),
|
|
encodeV2SwapExactOut(
|
|
ROUTER_ADDR,
|
|
500000000000000000n, // amountOut: 0.5 WETH
|
|
1500000000n, // amountInMax: 1,500 USDC
|
|
[USDC_ADDR, WETH_ADDR],
|
|
),
|
|
encodeWrapEth(USER_ADDR, 500000000000000000n), // UNWRAP_WETH
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result.name).toBe("Swap USDC → ETH");
|
|
expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC");
|
|
expect(detail(result, "Token Out").value).toBe("ETH");
|
|
expect(detail(result, "Min. received").value).toBe("0.5000 ETH");
|
|
});
|
|
|
|
// Paid in a token, an UNWRAP_WETH of 0 after a swap that buys something
|
|
// other than WETH leaves the output side as it is: Token Out and Min.
|
|
// received are the token bought and its figure, not ETH.
|
|
test.each([
|
|
{
|
|
paidIn: "WETH",
|
|
tokenIn: WETH_ADDR,
|
|
amountInMax: 500000000000000000n, // 0.5 WETH
|
|
tokenOut: USDC_ADDR,
|
|
amountOut: 1300000000n, // 1,300 USDC
|
|
minReceived: "1300.0000 USDC",
|
|
},
|
|
{
|
|
paidIn: "USDC",
|
|
tokenIn: USDC_ADDR,
|
|
amountInMax: 8000000n, // 8 USDC
|
|
tokenOut: DAI_ADDR,
|
|
amountOut: 7000000000000000000n, // 7 DAI
|
|
minReceived: "7.0000 DAI",
|
|
},
|
|
])(
|
|
"a V2 exact-out swap paid in $paidIn, then UNWRAP_WETH, shows the token it buys",
|
|
({ tokenIn, amountInMax, tokenOut, amountOut, minReceived }) => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x09, 0x0c]),
|
|
[
|
|
encodePermit2(tokenIn, amountInMax, ROUTER_ADDR),
|
|
encodeV2SwapExactOut(USER_ADDR, amountOut, amountInMax, [
|
|
tokenIn,
|
|
tokenOut,
|
|
]),
|
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(detail(result, "Token Out").address).toBe(tokenOut);
|
|
expect(detail(result, "Min. received").value).toBe(minReceived);
|
|
},
|
|
);
|
|
|
|
// An exact-in swap is held to the same rule: buying USDC, then UNWRAP_WETH,
|
|
// receives USDC.
|
|
test("an exact-in swap to a token other than WETH, then UNWRAP_WETH, shows that token", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
|
|
[
|
|
encodeV2SwapExactIn(USER_ADDR, 2000000n, 1900000n, [
|
|
USDT_ADDR,
|
|
USDC_ADDR,
|
|
]),
|
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result.name).toBe("Swap USDT → USDC");
|
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
|
expect(detail(result, "Min. received").value).toBe("1.9000 USDC");
|
|
});
|
|
|
|
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so
|
|
// UNWRAP_WETH makes the output ETH.
|
|
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(
|
|
["uint8", "uint8", "uint8", "uint8"],
|
|
[0x0b, 0x09, 0x08, 0x0c],
|
|
),
|
|
[
|
|
encodeWrapEth(ROUTER_ADDR, 500000000000000000n),
|
|
encodeV2SwapExactOut(
|
|
ROUTER_ADDR,
|
|
1500000000n, // amountOut: 1,500 USDC
|
|
500000000000000000n, // amountInMax: 0.5 WETH
|
|
[WETH_ADDR, USDC_ADDR],
|
|
),
|
|
encodeV2SwapExactIn(
|
|
ROUTER_ADDR,
|
|
1500000000n, // amountIn: 1,500 USDC
|
|
400000000000000000n, // amountOutMin: 0.4 WETH
|
|
[USDC_ADDR, WETH_ADDR],
|
|
),
|
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(detail(result, "Token Out").value).toBe("ETH");
|
|
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
|
|
});
|
|
|
|
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
|
|
test("a swap to Sepolia WETH, then UNWRAP_WETH, shows ETH", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
|
|
[
|
|
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
|
|
USDC_ADDR,
|
|
SEPOLIA_WETH_ADDR,
|
|
]),
|
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(detail(result, "Token Out").value).toBe("ETH");
|
|
expect(detail(result, "Min. received").value).toBe("0.0005 ETH");
|
|
});
|
|
|
|
// A step that states a Min. received figure but names no output token has
|
|
// set the output side, so UNWRAP_WETH does not make it ETH: nothing says
|
|
// the figure is counted in WETH.
|
|
test("a step with a minimum but no output token, then UNWRAP_WETH, names no token", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
|
|
[
|
|
encodeV4Swap(new Uint8Array([V4_SWAP_EXACT_IN]), [
|
|
encodeV4ExactIn(
|
|
USDC_ADDR,
|
|
[], // no path: this step names no output currency
|
|
1000000000n, // 1,000 USDC
|
|
400000000000000000n, // amountOutMin
|
|
),
|
|
]),
|
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(detail(result, "Token Out").value).toBe(
|
|
"Unknown (not named in the calldata)",
|
|
);
|
|
expect(detail(result, "Min. received").value).toBe(
|
|
"400000000000000000 base units (decimals unknown)",
|
|
);
|
|
});
|
|
|
|
test("decodes V3_SWAP_EXACT_IN with known tokens", () => {
|
|
const data = buildExecute(
|
|
"0x00", // V3_SWAP_EXACT_IN
|
|
[
|
|
encodeV3SwapExactIn(
|
|
USER_ADDR,
|
|
2000000n, // 2 USDT
|
|
1000000000000000n, // 0.001 ETH
|
|
[USDT_ADDR, WETH_ADDR],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
expect(result.name).toBe("Swap USDT \u2192 WETH");
|
|
});
|
|
|
|
test("decodes WRAP_ETH as ETH input", () => {
|
|
const data = buildExecute(
|
|
"0x0b", // WRAP_ETH
|
|
[encodeWrapEth(ROUTER_ADDR, 1000000000000000000n)],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
const tokenIn = result.details.find((d) => d.label === "Token In");
|
|
expect(tokenIn.value).toBe("ETH (native)");
|
|
|
|
const amount = result.details.find((d) => d.label === "Amount");
|
|
expect(amount.value).toBe("1.0000 ETH");
|
|
});
|
|
|
|
test("decodes UNWRAP_WETH as ETH output", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
|
|
[
|
|
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
]),
|
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH same encoding
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
// UNWRAP_WETH means output is native ETH
|
|
expect(result.name).toBe("Swap USDT \u2192 ETH");
|
|
});
|
|
|
|
test("decodes BALANCE_CHECK_ERC20 for min output", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
|
|
[
|
|
encodeWrapEth(ROUTER_ADDR, 1000000000000000000n),
|
|
encodeBalanceCheck(USER_ADDR, USDT_ADDR, 2000000n),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
const minOut = result.details.find((d) => d.label === "Min. received");
|
|
expect(minOut).toBeDefined();
|
|
expect(minOut.value).toContain("2.0000");
|
|
expect(minOut.value).toContain("USDT");
|
|
});
|
|
|
|
test("shows command names in steps", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x10, 0x0c]),
|
|
[
|
|
encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR),
|
|
encodeV4Swap("0x07", ["0x"]),
|
|
encodeWrapEth(USER_ADDR, 0n), // reusing for UNWRAP_WETH
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
// The V4 swap action's parameters are empty, so that step is said to
|
|
// be undecoded.
|
|
const steps = result.details.find((d) => d.label === "Steps");
|
|
expect(steps.value).toBe(
|
|
"Permit2 Permit \u2192 V4 Swap (could not be decoded) \u2192 Unwrap WETH",
|
|
);
|
|
});
|
|
|
|
test("shows the deadline as a UTC date and time", () => {
|
|
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR);
|
|
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");
|
|
});
|
|
|
|
// A JavaScript date cannot hold this deadline. It used to make the whole
|
|
// swap undecoded.
|
|
test("a deadline of the uint256 maximum is stated in words", () => {
|
|
const data = buildExecute(
|
|
"0x08", // V2_SWAP_EXACT_IN
|
|
[
|
|
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
]),
|
|
],
|
|
2n ** 256n - 1n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
expect(result.name).toBe("Swap USDT \u2192 WETH");
|
|
expect(detail(result, "Deadline").value).toBe(
|
|
"After 275760-09-13 00:00:00 (no deadline in practice)",
|
|
);
|
|
});
|
|
|
|
test("formats permit amount when not unlimited", () => {
|
|
const data = buildExecute(
|
|
"0x0a",
|
|
[encodePermit2(USDT_ADDR, 5000000n, ROUTER_ADDR)],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
const amount = result.details.find((d) => d.label === "Amount");
|
|
expect(amount.value).toBe("5.0000 USDT");
|
|
});
|
|
|
|
// This test validates the decodeV4Swap() fix: a V4 ERC20→ERC20 swap
|
|
// (USDT→USDC) where the token addresses are ONLY discoverable inside
|
|
// the V4_SWAP sub-actions (SETTLE/TAKE). Before decodeV4Swap() was added,
|
|
// command 0x10 was opaque and this would decode as "Uniswap Swap" with
|
|
// no token info (or "ETH → ETH"). Now it correctly shows "USDT → USDC".
|
|
test("decodes V4_SWAP ERC20→ERC20 tokens via SETTLE/TAKE (regression: #59)", () => {
|
|
// Build a V4_SWAP input with SETTLE(USDT) + SWAP_EXACT_IN_SINGLE + TAKE(USDC)
|
|
const V4_SETTLE = 0x0b;
|
|
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
|
|
const V4_TAKE = 0x0e;
|
|
|
|
// actions: SETTLE, SWAP_EXACT_IN_SINGLE, TAKE
|
|
const actions = new Uint8Array([
|
|
V4_SETTLE,
|
|
V4_SWAP_EXACT_IN_SINGLE,
|
|
V4_TAKE,
|
|
]);
|
|
|
|
// SETTLE params: (address currency, uint256 maxAmount, bool payerIsUser)
|
|
const settleParam = coder.encode(
|
|
["address", "uint256", "bool"],
|
|
[USDT_ADDR, 5000000n, true],
|
|
);
|
|
|
|
// SWAP_EXACT_IN_SINGLE params:
|
|
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
|
|
const swapParam = coder.encode(
|
|
[
|
|
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
|
],
|
|
[
|
|
[
|
|
[
|
|
USDT_ADDR,
|
|
USDC_ADDR,
|
|
100, // fee
|
|
1, // tickSpacing
|
|
"0x0000000000000000000000000000000000000000", // hooks
|
|
],
|
|
true, // zeroForOne
|
|
5000000n, // amountIn (5 USDT)
|
|
4900000n, // amountOutMin (4.9 USDC)
|
|
"0x", // hookData
|
|
],
|
|
],
|
|
);
|
|
|
|
// TAKE params: (address currency, address recipient, uint256 amount)
|
|
const takeParam = coder.encode(
|
|
["address", "address", "uint256"],
|
|
[USDC_ADDR, USER_ADDR, 0n],
|
|
);
|
|
|
|
// Encode the V4_SWAP input: (bytes actions, bytes[] params)
|
|
const v4Input = coder.encode(
|
|
["bytes", "bytes[]"],
|
|
[actions, [settleParam, swapParam, takeParam]],
|
|
);
|
|
|
|
// Build execute() with PERMIT2_PERMIT (0x0a) + V4_SWAP (0x10)
|
|
// The permit provides the input token, but V4_SWAP must provide
|
|
// the OUTPUT token — without decodeV4Swap, output would be unknown.
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x0a, 0x10]),
|
|
[encodePermit2(USDT_ADDR, 5000000n, ROUTER_ADDR), v4Input],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
// Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap"
|
|
// After fix: correctly identifies both tokens from V4 sub-actions
|
|
expect(result.name).toBe("Swap USDT \u2192 USDC");
|
|
|
|
const tokenIn = result.details.find((d) => d.label === "Token In");
|
|
expect(tokenIn.value).toContain("USDT");
|
|
|
|
const steps = result.details.find((d) => d.label === "Steps");
|
|
expect(steps.value).toContain("V4 Swap");
|
|
});
|
|
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/364 — the input half.
|
|
//
|
|
// Fails against c9ebac8: `if (!inputAmount) inputAmount = s.amountIn`
|
|
// cannot tell the V3 hop's genuine 0n from "not yet set", so the V2 hop's
|
|
// 0.5 WETH overwrote it while Token In stayed pinned to the V3 hop's USDT.
|
|
// Observed there: Amount = "500000000000.0000 USDT".
|
|
test("a hop's zero amountIn is a real amount, not an opening for the next hop's figure", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
|
|
[
|
|
encodeV3SwapExactIn(USER_ADDR, 0n, 0n, [USDT_ADDR, WETH_ADDR]),
|
|
encodeV2SwapExactIn(
|
|
USER_ADDR,
|
|
500000000000000000n, // 0.5 WETH
|
|
1000000n,
|
|
[WETH_ADDR, USDC_ADDR],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
// The amount and the token it is counted in come from the same hop.
|
|
expect(detail(result, "Token In").address.toLowerCase()).toBe(
|
|
USDT_ADDR.toLowerCase(),
|
|
);
|
|
expect(detail(result, "Amount").value).toBe("0.0000 USDT");
|
|
expect(detail(result, "Amount").value).not.toContain("500000000000");
|
|
});
|
|
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
|
|
// the shape the issue measured: a V4 step that states a minimum of zero
|
|
// and names no output currency.
|
|
//
|
|
// Fails against c9ebac8: `if (v4.amountOutMin) minOutput = ...` and the
|
|
// `else if` beside it both read 0n as absent, so neither the figure nor
|
|
// the token moved. Observed there: Token Out = "WETH (0xC02aaA39...)" and
|
|
// Min. received = "0.5000 WETH" — the V3 hop's guarantee shown for a
|
|
// transaction whose final leg guarantees nothing.
|
|
test("a V4 step with a zero amountOutMin states no minimum instead of keeping an earlier hop's", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
|
|
[
|
|
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
]),
|
|
encodeV4Swap(new Uint8Array([V4_SWAP_EXACT_IN]), [
|
|
encodeV4ExactIn(
|
|
WETH_ADDR,
|
|
[], // no path: this step names no output currency
|
|
1000000000000000000n,
|
|
0n, // no slippage floor at all
|
|
),
|
|
]),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
expect(detail(result, "Token Out").value).toBe(
|
|
"Unknown (not named in the calldata)",
|
|
);
|
|
expect(detail(result, "Min. received").value).toBe(
|
|
"None (no minimum guaranteed)",
|
|
);
|
|
expect(detail(result, "Min. received").value).not.toContain("0.5000");
|
|
});
|
|
|
|
// The same zero floor, but with the final leg's output currency named:
|
|
// the figure must belong to the token beside it. Against c9ebac8 this
|
|
// rendered Token Out = USDC with Min. received = "500000000000.0000 USDC",
|
|
// the V3 hop's 0.5e18 WETH figure re-scaled to USDC's six decimals.
|
|
test("a zero minimum is stated against the token that supplied it", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
|
|
[
|
|
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
]),
|
|
encodeV4Swap(
|
|
new Uint8Array([
|
|
V4_SETTLE_ID,
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
V4_TAKE_ID,
|
|
]),
|
|
[
|
|
encodeV4Settle(WETH_ADDR),
|
|
encodeV4ExactInSingle(
|
|
WETH_ADDR,
|
|
USDC_ADDR,
|
|
1000000000000000000n,
|
|
0n,
|
|
),
|
|
encodeV4Take(USDC_ADDR),
|
|
],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
expect(detail(result, "Token Out").value).toContain("USDC");
|
|
expect(detail(result, "Min. received").value).toBe(
|
|
"None (no minimum guaranteed)",
|
|
);
|
|
});
|
|
|
|
// The other half of the same invariant: a final leg that names an output
|
|
// currency but no minimum leaves Min. received unstated. Against c9ebac8
|
|
// the V3 hop's figure stayed on screen beside the new token, rendering
|
|
// "500000000000.0000 USDC".
|
|
test("a final leg with no minimum drops the line rather than keeping an earlier hop's figure", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
|
|
[
|
|
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
]),
|
|
encodeV4Swap(new Uint8Array([V4_SETTLE_ID, V4_TAKE_ID]), [
|
|
encodeV4Settle(WETH_ADDR),
|
|
encodeV4Take(USDC_ADDR),
|
|
]),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
expect(detail(result, "Token Out").value).toContain("USDC");
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
});
|
|
|
|
// V4 spells "swap the whole open delta" as an amountIn of zero
|
|
// (v4-periphery ActionConstants.OPEN_DELTA = 0, applied by V4Router's
|
|
// _swapExactInputSingle / _swapExactInput). It is not a quantity, and
|
|
// printing "0.0000 WETH" for it would state the exact inverse of what the
|
|
// step does. Against c9ebac8 the Amount line was omitted entirely.
|
|
test("a V4 open-delta amountIn is named, not printed as zero", () => {
|
|
const data = buildExecute(
|
|
"0x10",
|
|
[
|
|
encodeV4Swap(
|
|
new Uint8Array([
|
|
V4_SETTLE_ID,
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
V4_TAKE_ID,
|
|
]),
|
|
[
|
|
encodeV4Settle(WETH_ADDR),
|
|
encodeV4ExactInSingle(
|
|
WETH_ADDR,
|
|
USDC_ADDR,
|
|
0n, // ActionConstants.OPEN_DELTA
|
|
990000n,
|
|
),
|
|
encodeV4Take(USDC_ADDR),
|
|
],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
expect(detail(result, "Token In").value).toContain("WETH");
|
|
expect(detail(result, "Amount").value).toBe(
|
|
"All available (V4 open delta)",
|
|
);
|
|
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
|
|
});
|
|
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/563 — one V4 step of two
|
|
// chained single-pool exact-in swap actions, USDT to WETH and then WETH to
|
|
// USDC. The user pays USDT and receives USDC, but the screen showed the
|
|
// first action's WETH as Token Out and its minimum as Min. received.
|
|
test.each([
|
|
{
|
|
ending: "SETTLE_ALL and TAKE_ALL",
|
|
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
|
|
params: [
|
|
encodeV4SettleAll(USDT_ADDR, 1000000000n),
|
|
encodeV4TakeAll(USDC_ADDR, 990000000n),
|
|
],
|
|
},
|
|
{
|
|
ending: "SETTLE and TAKE",
|
|
ids: [V4_SETTLE_ID, V4_TAKE_ID],
|
|
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
|
|
},
|
|
{
|
|
// Only the swap actions name the currencies.
|
|
ending: "nothing",
|
|
ids: [],
|
|
params: [],
|
|
},
|
|
])(
|
|
"two chained V4 swap actions followed by $ending show the first one's input and the last one's output",
|
|
({ ids, params }) => {
|
|
const data = buildExecute(
|
|
"0x10",
|
|
[
|
|
encodeV4Swap(
|
|
new Uint8Array([
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
...ids,
|
|
]),
|
|
[
|
|
// 1,000 USDT for at least 0.4 WETH
|
|
encodeV4ExactInSingle(
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
1000000000n,
|
|
400000000000000000n,
|
|
),
|
|
// all of that WETH (the open delta) for at least
|
|
// 990 USDC
|
|
encodeV4ExactInSingle(
|
|
WETH_ADDR,
|
|
USDC_ADDR,
|
|
0n,
|
|
990000000n,
|
|
),
|
|
...params,
|
|
],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result.name).toBe("Swap USDT → USDC");
|
|
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
|
|
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
|
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
|
expect(detail(result, "Min. received").value).toBe("990.0000 USDC");
|
|
},
|
|
);
|
|
|
|
// The router works through an exact-out path from its last entry back to
|
|
// its first: the WETH entry buys the USDC, then the USDT entry buys that
|
|
// WETH. The user pays the first entry's USDT, not the last entry's WETH.
|
|
test("a two-hop V4 exact-out action shows its first path entry as Token In", () => {
|
|
const data = buildExecute(
|
|
"0x10",
|
|
[
|
|
encodeV4Swap(new Uint8Array([0x09]), [
|
|
// SWAP_EXACT_OUT: exactly 990 USDC for at most 1,000 USDT
|
|
coder.encode(
|
|
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
|
|
[
|
|
[
|
|
USDC_ADDR,
|
|
[
|
|
[USDT_ADDR, 100, 1, ZERO_ADDR, "0x"],
|
|
[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"],
|
|
],
|
|
990000000n,
|
|
1000000000n,
|
|
],
|
|
],
|
|
),
|
|
]),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result.name).toBe("Swap USDT → USDC");
|
|
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
|
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
|
});
|
|
|
|
// The router runs a chain of exact-out actions from the output end: the
|
|
// first buys the USDC the user receives, owing WETH, and the second buys
|
|
// that WETH (the open delta) with the USDT the user pays.
|
|
test("two chained V4 exact-out actions show the last one's input and the first one's output", () => {
|
|
const data = buildExecute(
|
|
"0x10",
|
|
[
|
|
encodeV4Swap(
|
|
new Uint8Array([
|
|
V4_SWAP_EXACT_OUT_SINGLE_ID,
|
|
V4_SWAP_EXACT_OUT_SINGLE_ID,
|
|
]),
|
|
[
|
|
// exactly 990 USDC for at most 0.5 WETH
|
|
encodeV4ExactOutSingle(
|
|
WETH_ADDR,
|
|
USDC_ADDR,
|
|
990000000n,
|
|
500000000000000000n,
|
|
),
|
|
// exactly that WETH (the open delta) for at most
|
|
// 1,000 USDT
|
|
encodeV4ExactOutSingle(
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
0n,
|
|
1000000000n,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result.name).toBe("Swap USDT → USDC");
|
|
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
|
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
|
expect(detail(result, "Amount")).toBeUndefined();
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
});
|
|
|
|
// An exact-in action buys WETH with USDT and an exact-out action then buys
|
|
// USDC with that WETH. The exact-out action is lined up first, so the last
|
|
// action in the line is the exact-in one, and its minimum is counted in
|
|
// WETH, not in the USDC the user receives. Shown as Min. received, it would
|
|
// read "400000000000.0000 USDC".
|
|
test("a V4 exact-in action, then an exact-out one that spends its output, shows no WETH figure as Min. received", () => {
|
|
const data = buildExecute(
|
|
"0x10",
|
|
[
|
|
encodeV4Swap(
|
|
new Uint8Array([
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
V4_SWAP_EXACT_OUT_SINGLE_ID,
|
|
V4_SETTLE_ALL_ID,
|
|
V4_TAKE_ALL_ID,
|
|
0x13, // CLEAR_OR_TAKE, which the decoder does not read
|
|
]),
|
|
[
|
|
// 1,000 USDT for at least 0.4 WETH
|
|
encodeV4ExactInSingle(
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
1000000000n,
|
|
400000000000000000n,
|
|
),
|
|
// exactly 990 USDC for at most 0.5 WETH
|
|
encodeV4ExactOutSingle(
|
|
WETH_ADDR,
|
|
USDC_ADDR,
|
|
990000000n,
|
|
500000000000000000n,
|
|
),
|
|
encodeV4SettleAll(USDT_ADDR, 1000000000n),
|
|
encodeV4TakeAll(USDC_ADDR, 990000000n),
|
|
// the WETH left over: (address currency, uint256 amountMax)
|
|
coder.encode(
|
|
["address", "uint256"],
|
|
[WETH_ADDR, 100000000000000000n],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result.name).toBe("Swap USDT → USDC");
|
|
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
|
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
|
expect(detail(result, "Amount")).toBeUndefined();
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
});
|
|
|
|
// A swap action that could not be read still stands first or last in its
|
|
// step, so the side and figure it would have given are unknown, while
|
|
// those from the readable action are shown. The unread action is
|
|
// V4_SWAP_ACTION_SHAPES[0].newer, a SWAP_EXACT_IN_SINGLE of WETH to USDC.
|
|
test("a readable V4 swap action, then an unread one, shows the first one's input and no output", () => {
|
|
const data = buildExecute(
|
|
"0x10",
|
|
[
|
|
encodeV4Swap(
|
|
new Uint8Array([
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
]),
|
|
[
|
|
encodeV4ExactInSingle(
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
1000000000n,
|
|
400000000000000000n,
|
|
),
|
|
V4_SWAP_ACTION_SHAPES[0].newer,
|
|
],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(detail(result, "Steps").value).toBe(
|
|
"V4 Swap (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
|
|
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
|
|
expect(detail(result, "Token Out").value).toBe(
|
|
"Unknown (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
});
|
|
|
|
test("an unread V4 swap action, then a readable one, shows the last one's output and no input", () => {
|
|
const data = buildExecute(
|
|
"0x10",
|
|
[
|
|
encodeV4Swap(
|
|
new Uint8Array([
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
]),
|
|
[
|
|
V4_SWAP_ACTION_SHAPES[0].newer,
|
|
encodeV4ExactInSingle(
|
|
USDC_ADDR,
|
|
DAI_ADDR,
|
|
0n,
|
|
990000000000000000000n,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(detail(result, "Steps").value).toBe(
|
|
"V4 Swap (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Token In").value).toBe(
|
|
"Unknown (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Amount")).toBeUndefined();
|
|
expect(detail(result, "Token Out").address).toBe(DAI_ADDR);
|
|
expect(detail(result, "Min. received").value).toBe("990.0000 DAI");
|
|
});
|
|
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2
|
|
// exact-in reads an amountIn of zero as universal-router
|
|
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and
|
|
// the swap uses all of them. Against 375998b this read "0.0000 USDT".
|
|
test("a V2 exact-in already-paid amountIn is named, not printed as zero", () => {
|
|
const data = buildExecute(
|
|
"0x08",
|
|
[
|
|
encodeV2SwapExactIn(
|
|
USER_ADDR,
|
|
0n, // Constants.ALREADY_PAID
|
|
500000000000000n,
|
|
[USDT_ADDR, WETH_ADDR],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
expect(detail(result, "Token In").value).toContain("USDT");
|
|
expect(detail(result, "Amount").value).toBe(
|
|
"Whatever an earlier step sent to the pair (V2 already paid)",
|
|
);
|
|
expect(detail(result, "Amount").rawValue).toBe(
|
|
"Whatever an earlier step sent to the pair (V2 already paid)",
|
|
);
|
|
expect(detail(result, "Min. received").value).toBe("0.0005 WETH");
|
|
});
|
|
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router passes a
|
|
// BALANCE_CHECK_ERC20 whenever the balance is at least minBalance, so a
|
|
// zero one guarantees nothing. Against 375998b it replaced the swap's
|
|
// output side: Token Out = USDC, Min. received = "None (no minimum
|
|
// guaranteed)".
|
|
test("a zero balance check keeps the minimum a swap step stated", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x08, 0x0e]),
|
|
[
|
|
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
]),
|
|
encodeBalanceCheck(USER_ADDR, USDC_ADDR, 0n),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
expect(detail(result, "Token Out").value).toContain("WETH");
|
|
expect(detail(result, "Min. received").value).toBe("0.0005 WETH");
|
|
});
|
|
|
|
// A nonzero balance check still replaces the output side, as before.
|
|
test("a nonzero balance check replaces the minimum a swap step stated", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x08, 0x0e]),
|
|
[
|
|
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
]),
|
|
encodeBalanceCheck(USER_ADDR, USDC_ADDR, 2000000n),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
expect(detail(result, "Token Out").value).toContain("USDC");
|
|
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
|
|
});
|
|
|
|
// With no minimum stated before it, a zero balance check is what sets the
|
|
// output side, and it guarantees nothing.
|
|
test("a zero balance check with no earlier minimum states no minimum", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
|
|
[
|
|
encodeWrapEth(ROUTER_ADDR, 1000000000000000000n),
|
|
encodeBalanceCheck(USER_ADDR, USDT_ADDR, 0n),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
|
|
expect(detail(result, "Token Out").value).toContain("USDT");
|
|
expect(detail(result, "Min. received").value).toBe(
|
|
"None (no minimum guaranteed)",
|
|
);
|
|
});
|
|
|
|
// Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without
|
|
// testing: a non-swap execute() carrying only PERMIT2_PERMIT names no
|
|
// output currency, so it says so and titles itself "Uniswap Swap" rather
|
|
// than inventing "Token Out: ETH".
|
|
test("a PERMIT2_PERMIT-only execute() invents no output token", () => {
|
|
const data = buildExecute(
|
|
"0x0a",
|
|
[encodePermit2(USDT_ADDR, 5000000n, ROUTER_ADDR)],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
expect(result.name).toBe("Uniswap Swap");
|
|
|
|
expect(detail(result, "Token In").value).toContain("USDT");
|
|
expect(detail(result, "Token Out").value).toBe(
|
|
"Unknown (not named in the calldata)",
|
|
);
|
|
expect(detail(result, "Token Out").address).toBeUndefined();
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
});
|
|
|
|
test("handles unknown tokens gracefully", () => {
|
|
const fakeToken = "0x1111111111111111111111111111111111111111";
|
|
const data = buildExecute(
|
|
"0x0a",
|
|
[encodePermit2(fakeToken, 1000000000000000000n, ROUTER_ADDR)],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
expect(result).not.toBeNull();
|
|
expect(result.name).toBe("Uniswap Swap");
|
|
|
|
const tokenIn = result.details.find((d) => d.label === "Token In");
|
|
expect(tokenIn.value).toContain(fakeToken);
|
|
});
|
|
});
|