Each Universal Router address now carries the mask its own source reads a command's type with: 0x1f for UniversalRouterV1, 0x3f for V1_2 and V2, 0x7f for V2_1_1 and V2_1_2. The fixed 0x1f dropped the 0x20 bit, so EXECUTE_SUB_PLAN (0x21) was listed as a V3 exact-out swap. A sub-plan step is now listed as "Execute Sub-Plan (its steps are not shown)". Its commands are not read and may spend from or pay the user wherever the step stands, so with one the summary names no swap direction, Token In and Token Out read "Unknown (could not be decoded)", and there is no Amount or Min. received. Model: opus-5-5
1902 lines
75 KiB
JavaScript
1902 lines
75 KiB
JavaScript
const { AbiCoder, Interface, solidityPacked } = require("ethers");
|
|
const uniswap = require("../src/shared/uniswap");
|
|
|
|
// A Universal Router on Ethereum Mainnet, chain id 0x1: UniversalRouterV2,
|
|
// which reads a command's type with the mask 0x3f.
|
|
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
|
// Two more there, which read it with 0x1f and 0x7f.
|
|
const ROUTER_V1_ADDR = "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B";
|
|
const ROUTER_V2_1_2_ADDR = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
|
|
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]);
|
|
}
|
|
|
|
// Helper: encode an EXECUTE_SUB_PLAN input (command 0x21), which holds the
|
|
// sub-plan's own commands and inputs
|
|
function encodeSubPlan(commands, inputs) {
|
|
return coder.encode(["bytes", "bytes[]"], [commands, inputs]);
|
|
}
|
|
|
|
// 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]);
|
|
}
|
|
|
|
// V4 CLOSE_CURRENCY (currency), which the decoder does not read.
|
|
const V4_CLOSE_CURRENCY_ID = 0x12;
|
|
function encodeV4CloseCurrency(currency) {
|
|
return coder.encode(["address"], [currency]);
|
|
}
|
|
|
|
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, "0x1")).toBeNull();
|
|
expect(uniswap.decode("0xdeadbeef", ROUTER_ADDR, "0x1")).toBeNull();
|
|
expect(uniswap.decode(null, ROUTER_ADDR, "0x1")).toBeNull();
|
|
});
|
|
|
|
test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => {
|
|
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1");
|
|
|
|
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,
|
|
"0x1",
|
|
);
|
|
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,
|
|
"0x1",
|
|
);
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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",
|
|
);
|
|
});
|
|
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/535 — every command's type
|
|
// was read from its low five bits, as only UniversalRouterV1 reads it, so
|
|
// the later routers listed 0x21 as a V3 exact-out swap, and
|
|
// UniversalRouterV2_1_2 listed 0x40 as a V3 exact-in swap.
|
|
test.each([
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1_ADDR,
|
|
command: "0x21",
|
|
steps: "V3 Swap (Exact Out)",
|
|
},
|
|
{
|
|
router: "UniversalRouterV2",
|
|
address: ROUTER_ADDR,
|
|
command: "0x21",
|
|
steps: "Execute Sub-Plan (its steps are not shown)",
|
|
},
|
|
{
|
|
router: "UniversalRouterV2_1_2",
|
|
address: ROUTER_V2_1_2_ADDR,
|
|
command: "0x21",
|
|
steps: "Execute Sub-Plan (its steps are not shown)",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1_ADDR,
|
|
command: "0x40",
|
|
steps: "V3 Swap (Exact In)",
|
|
},
|
|
{
|
|
router: "UniversalRouterV2",
|
|
address: ROUTER_ADDR,
|
|
command: "0x40",
|
|
steps: "V3 Swap (Exact In)",
|
|
},
|
|
{
|
|
router: "UniversalRouterV2_1_2",
|
|
address: ROUTER_V2_1_2_ADDR,
|
|
command: "0x40",
|
|
steps: "Command 0x40",
|
|
},
|
|
])(
|
|
"command $command sent to $router is listed as $steps",
|
|
({ address, command, steps }) => {
|
|
const data = buildExecute(command, ["0x"], 9999999999n);
|
|
const result = uniswap.decode(data, address, "0x1");
|
|
expect(detail(result, "Steps").value).toBe(steps);
|
|
},
|
|
);
|
|
|
|
// A sub-plan holding a V3 swap of USDT for WETH, which the decoder does
|
|
// not read. With the allow-revert flag (0x80) the command byte is 0xa1.
|
|
test.each([
|
|
{ flag: "without", command: 0x21 },
|
|
{ flag: "with", command: 0xa1 },
|
|
])(
|
|
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
|
|
({ command }) => {
|
|
const subPlan = encodeSubPlan("0x00", [
|
|
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
]),
|
|
]);
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8"], [command]),
|
|
[subPlan],
|
|
9999999999n,
|
|
);
|
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
|
expect(result.name).toBe("Uniswap Swap");
|
|
expect(detail(result, "Steps").value).toBe(
|
|
"Execute Sub-Plan (its steps are not shown)",
|
|
);
|
|
// The sub-plan names both currencies, so the lines must not say
|
|
// the calldata did not.
|
|
expect(detail(result, "Token In").value).toBe(
|
|
"Unknown (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Token Out").value).toBe(
|
|
"Unknown (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Amount")).toBeUndefined();
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
// The steps inside a sub-plan may spend the user's tokens or pay the user,
|
|
// so the steps read beside it are not the whole swap, whether they come
|
|
// before it or after it. No side is named and no figure is given.
|
|
test.each([
|
|
{
|
|
// 2,000 USDT for at least 1 WETH, paid to the router; the sub-plan
|
|
// swaps that WETH for USDC.
|
|
beside: "a swap before",
|
|
commands: "0x0021",
|
|
inputs: [
|
|
encodeV3SwapExactIn(ROUTER_ADDR, 2000000000n, ONE_WETH, [
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
]),
|
|
encodeSubPlan("0x00", [
|
|
encodeV3SwapExactIn(USER_ADDR, ONE_WETH, 2000000000n, [
|
|
WETH_ADDR,
|
|
USDC_ADDR,
|
|
]),
|
|
]),
|
|
],
|
|
steps: "V3 Swap (Exact In) \u2192 Execute Sub-Plan (its steps are not shown)",
|
|
},
|
|
{
|
|
// The sub-plan buys WETH with the user's USDC; 1 WETH is then
|
|
// swapped for at least 2,000 DAI.
|
|
beside: "a swap after",
|
|
commands: "0x2100",
|
|
inputs: [
|
|
encodeSubPlan("0x00", [
|
|
encodeV3SwapExactIn(ROUTER_ADDR, 2000000000n, ONE_WETH, [
|
|
USDC_ADDR,
|
|
WETH_ADDR,
|
|
]),
|
|
]),
|
|
encodeV3SwapExactIn(USER_ADDR, ONE_WETH, 2000n * ONE_WETH, [
|
|
WETH_ADDR,
|
|
DAI_ADDR,
|
|
]),
|
|
],
|
|
steps: "Execute Sub-Plan (its steps are not shown) \u2192 V3 Swap (Exact In)",
|
|
},
|
|
{
|
|
// A permit among the sub-plan's steps would replace this one.
|
|
beside: "a permit before",
|
|
commands: "0x0a21",
|
|
inputs: [encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR), "0x"],
|
|
steps: "Permit2 Permit \u2192 Execute Sub-Plan (its steps are not shown)",
|
|
},
|
|
{
|
|
// The sub-plan buys USDC for the user; an UNWRAP_WETH of 0
|
|
// follows. No step outside the sub-plan sets the output side, and
|
|
// UNWRAP_WETH would then make Token Out ETH.
|
|
beside: "an UNWRAP_WETH after",
|
|
commands: "0x210c",
|
|
inputs: [
|
|
encodeSubPlan("0x00", [
|
|
encodeV3SwapExactIn(USER_ADDR, 2000000000n, 1900000000n, [
|
|
USDT_ADDR,
|
|
USDC_ADDR,
|
|
]),
|
|
]),
|
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
|
|
],
|
|
steps: "Execute Sub-Plan (its steps are not shown) \u2192 Unwrap WETH",
|
|
},
|
|
])(
|
|
"$beside an EXECUTE_SUB_PLAN step does not name either side",
|
|
({ commands, inputs, steps }) => {
|
|
const data = buildExecute(commands, inputs, 9999999999n);
|
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
|
expect(detail(result, "Steps").value).toBe(steps);
|
|
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, "Token Out").value).toBe(
|
|
"Unknown (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
test("shows the deadline as a UTC date and time", () => {
|
|
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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 — a V4 step of several
|
|
// swap actions. The screen showed the first action's output as Token Out.
|
|
// Every step below pays USDT and receives USDC, so it reads Swap USDT →
|
|
// USDC whichever kind of swap actions it holds and in whatever order they
|
|
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
|
|
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
|
|
// sides to the swap actions alone.
|
|
const V4_STEP_ENDINGS = [
|
|
{
|
|
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)],
|
|
},
|
|
{
|
|
ending: "CLOSE_CURRENCY",
|
|
ids: [
|
|
V4_CLOSE_CURRENCY_ID,
|
|
V4_CLOSE_CURRENCY_ID,
|
|
V4_CLOSE_CURRENCY_ID,
|
|
],
|
|
params: [
|
|
encodeV4CloseCurrency(USDT_ADDR),
|
|
encodeV4CloseCurrency(WETH_ADDR),
|
|
encodeV4CloseCurrency(USDC_ADDR),
|
|
],
|
|
},
|
|
];
|
|
// 1,000 USDT for at least 0.5 WETH
|
|
const USDT_FOR_WETH_IN = {
|
|
id: V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
param: encodeV4ExactInSingle(
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
1000000000n,
|
|
500000000000000000n,
|
|
),
|
|
};
|
|
// 0.5 WETH for at least 990 USDC
|
|
const WETH_FOR_USDC_IN = {
|
|
id: V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
param: encodeV4ExactInSingle(
|
|
WETH_ADDR,
|
|
USDC_ADDR,
|
|
500000000000000000n,
|
|
990000000n,
|
|
),
|
|
};
|
|
// exactly 0.5 WETH for at most 1,000 USDT
|
|
const USDT_FOR_WETH_OUT = {
|
|
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
|
|
param: encodeV4ExactOutSingle(
|
|
USDT_ADDR,
|
|
WETH_ADDR,
|
|
500000000000000000n,
|
|
1000000000n,
|
|
),
|
|
};
|
|
// exactly 990 USDC for at most 0.5 WETH
|
|
const WETH_FOR_USDC_OUT = {
|
|
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
|
|
param: encodeV4ExactOutSingle(
|
|
WETH_ADDR,
|
|
USDC_ADDR,
|
|
990000000n,
|
|
500000000000000000n,
|
|
),
|
|
};
|
|
// 400 USDT for at least 396 USDC
|
|
const USDT_FOR_USDC_IN = {
|
|
id: V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
param: encodeV4ExactInSingle(
|
|
USDT_ADDR,
|
|
USDC_ADDR,
|
|
400000000n,
|
|
396000000n,
|
|
),
|
|
};
|
|
// 600 USDT through WETH for at least 594 USDC
|
|
const USDT_FOR_USDC_THROUGH_WETH_IN = {
|
|
id: V4_SWAP_EXACT_IN,
|
|
param: encodeV4ExactIn(
|
|
USDT_ADDR,
|
|
[WETH_ADDR, USDC_ADDR],
|
|
600000000n,
|
|
594000000n,
|
|
),
|
|
};
|
|
// the open delta of USDT through WETH for at least 594 USDC
|
|
const USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN = {
|
|
id: V4_SWAP_EXACT_IN,
|
|
param: encodeV4ExactIn(
|
|
USDT_ADDR,
|
|
[WETH_ADDR, USDC_ADDR],
|
|
0n,
|
|
594000000n,
|
|
),
|
|
};
|
|
// Amount is the amountIn of the action that spends USDT, or the sum when
|
|
// several exact-in actions each state one. Min. received is the same for
|
|
// the actions that buy USDC. The decoder reads no amount from an exact-out
|
|
// action, and an open delta is not a stated amount.
|
|
const V4_STEPS = [
|
|
{
|
|
step: "USDT for WETH exact-in, then WETH for USDC exact-in",
|
|
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_IN],
|
|
amount: "1000.0000 USDT",
|
|
min: "990.0000 USDC",
|
|
},
|
|
{
|
|
step: "WETH for USDC exact-in, then USDT for WETH exact-in",
|
|
swaps: [WETH_FOR_USDC_IN, USDT_FOR_WETH_IN],
|
|
amount: "1000.0000 USDT",
|
|
min: "990.0000 USDC",
|
|
},
|
|
{
|
|
step: "WETH for USDC exact-out, then USDT for WETH exact-out",
|
|
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_OUT],
|
|
amount: undefined,
|
|
min: undefined,
|
|
},
|
|
{
|
|
step: "USDT for WETH exact-out, then WETH for USDC exact-out",
|
|
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_OUT],
|
|
amount: undefined,
|
|
min: undefined,
|
|
},
|
|
{
|
|
step: "USDT for WETH exact-in, then WETH for USDC exact-out",
|
|
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_OUT],
|
|
amount: "1000.0000 USDT",
|
|
min: undefined,
|
|
},
|
|
{
|
|
step: "WETH for USDC exact-out, then USDT for WETH exact-in",
|
|
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_IN],
|
|
amount: "1000.0000 USDT",
|
|
min: undefined,
|
|
},
|
|
{
|
|
step: "USDT for WETH exact-out, then WETH for USDC exact-in",
|
|
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_IN],
|
|
amount: undefined,
|
|
min: "990.0000 USDC",
|
|
},
|
|
{
|
|
step: "USDT for USDC, then USDT for USDC through WETH, both exact-in",
|
|
swaps: [USDT_FOR_USDC_IN, USDT_FOR_USDC_THROUGH_WETH_IN],
|
|
amount: "1000.0000 USDT",
|
|
min: "990.0000 USDC",
|
|
},
|
|
{
|
|
step: "USDT for USDC, then the open delta of USDT for USDC through WETH, both exact-in",
|
|
swaps: [USDT_FOR_USDC_IN, USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN],
|
|
amount: undefined,
|
|
min: "990.0000 USDC",
|
|
},
|
|
];
|
|
test.each(
|
|
V4_STEPS.flatMap((step) =>
|
|
V4_STEP_ENDINGS.map((ending) => ({ ...step, ...ending })),
|
|
),
|
|
)(
|
|
"a V4 step of $step, then $ending, reads Swap USDT → USDC",
|
|
({ swaps, ids, params, amount, min }) => {
|
|
const data = buildExecute(
|
|
"0x10",
|
|
[
|
|
encodeV4Swap(
|
|
new Uint8Array([...swaps.map((s) => s.id), ...ids]),
|
|
[...swaps.map((s) => s.param), ...params],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
|
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")?.value).toBe(amount);
|
|
expect(detail(result, "Min. received")?.value).toBe(min);
|
|
},
|
|
);
|
|
|
|
// A step with a swap action that could not be read is named only by its
|
|
// SETTLE_ALL, TAKE_ALL, SETTLE and TAKE, and states no figure: its readable
|
|
// USDT for WETH action names neither side. The unread action is
|
|
// V4_SWAP_ACTION_SHAPES[0].newer, WETH for USDC exact-in in the newer
|
|
// shape.
|
|
test.each([
|
|
{
|
|
...V4_STEP_ENDINGS[0],
|
|
tokenIn: `USDT (${USDT_ADDR})`,
|
|
tokenOut: `USDC (${USDC_ADDR})`,
|
|
},
|
|
{
|
|
...V4_STEP_ENDINGS[1],
|
|
tokenIn: `USDT (${USDT_ADDR})`,
|
|
tokenOut: `USDC (${USDC_ADDR})`,
|
|
},
|
|
{
|
|
...V4_STEP_ENDINGS[2],
|
|
tokenIn: "Unknown (could not be decoded)",
|
|
tokenOut: "Unknown (could not be decoded)",
|
|
},
|
|
])(
|
|
"a V4 step of a readable swap action and an unread one, then $ending, shows no figure",
|
|
({ ids, params, tokenIn, tokenOut }) => {
|
|
const data = buildExecute(
|
|
"0x10",
|
|
[
|
|
encodeV4Swap(
|
|
new Uint8Array([
|
|
USDT_FOR_WETH_IN.id,
|
|
V4_SWAP_ACTION_SHAPES[0].id,
|
|
...ids,
|
|
]),
|
|
[
|
|
USDT_FOR_WETH_IN.param,
|
|
V4_SWAP_ACTION_SHAPES[0].newer,
|
|
...params,
|
|
],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
|
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
|
expect(detail(result, "Steps").value).toBe(
|
|
"V4 Swap (could not be decoded)",
|
|
);
|
|
expect(detail(result, "Token In").value).toBe(tokenIn);
|
|
expect(detail(result, "Token Out").value).toBe(tokenOut);
|
|
expect(detail(result, "Amount")).toBeUndefined();
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
// A V4 step that spends WETH on USDC and on DAI. Its swap actions buy two
|
|
// currencies that none of them spends, and CLOSE_CURRENCY, which the
|
|
// decoder does not read, names no side, so its output side is unknown.
|
|
const WETH_FOR_USDC_AND_DAI = encodeV4Swap(
|
|
new Uint8Array([
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
V4_CLOSE_CURRENCY_ID,
|
|
V4_CLOSE_CURRENCY_ID,
|
|
V4_CLOSE_CURRENCY_ID,
|
|
]),
|
|
[
|
|
// 0.25 WETH for at least 495 USDC
|
|
encodeV4ExactInSingle(
|
|
WETH_ADDR,
|
|
USDC_ADDR,
|
|
250000000000000000n,
|
|
495000000n,
|
|
),
|
|
// 0.25 WETH for at least 495 DAI
|
|
encodeV4ExactInSingle(
|
|
WETH_ADDR,
|
|
DAI_ADDR,
|
|
250000000000000000n,
|
|
495000000000000000000n,
|
|
),
|
|
encodeV4CloseCurrency(WETH_ADDR),
|
|
encodeV4CloseCurrency(USDC_ADDR),
|
|
encodeV4CloseCurrency(DAI_ADDR),
|
|
],
|
|
);
|
|
|
|
// The user ends with USDC and DAI, not the V3 step's WETH. That WETH and
|
|
// the V3 step's Min. received figure were shown instead.
|
|
test("a V3 step, then a V4 step whose swap actions buy two currencies, does not show the V3 step's output", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
|
|
[
|
|
encodeV3SwapExactIn(
|
|
USER_ADDR,
|
|
1000000000n,
|
|
500000000000000000n,
|
|
[USDT_ADDR, WETH_ADDR],
|
|
),
|
|
WETH_FOR_USDC_AND_DAI,
|
|
],
|
|
9999999999n,
|
|
);
|
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
|
expect(result.name).toBe("Uniswap Swap");
|
|
expect(detail(result, "Token In").address).toBe(
|
|
USDT_ADDR.toLowerCase(),
|
|
);
|
|
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
|
|
expect(detail(result, "Token Out").value).toBe(
|
|
"Unknown (not named in the calldata)",
|
|
);
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
});
|
|
|
|
// The input side of the same gap: the V4 step's swap actions spend USDC
|
|
// and DAI, two currencies that none of them buys, and nothing names its
|
|
// input side. The V3 step's WETH and its amount were shown instead.
|
|
test("a V4 step whose swap actions spend two currencies, then a V3 step, does not show the V3 step's input", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
|
|
[
|
|
encodeV4Swap(
|
|
new Uint8Array([
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
V4_CLOSE_CURRENCY_ID,
|
|
V4_CLOSE_CURRENCY_ID,
|
|
V4_CLOSE_CURRENCY_ID,
|
|
]),
|
|
[
|
|
// 500 USDC for at least 0.25 WETH
|
|
encodeV4ExactInSingle(
|
|
USDC_ADDR,
|
|
WETH_ADDR,
|
|
500000000n,
|
|
250000000000000000n,
|
|
),
|
|
// 500 DAI for at least 0.25 WETH
|
|
encodeV4ExactInSingle(
|
|
DAI_ADDR,
|
|
WETH_ADDR,
|
|
500000000000000000000n,
|
|
250000000000000000n,
|
|
),
|
|
encodeV4CloseCurrency(USDC_ADDR),
|
|
encodeV4CloseCurrency(DAI_ADDR),
|
|
encodeV4CloseCurrency(WETH_ADDR),
|
|
],
|
|
),
|
|
encodeV3SwapExactIn(
|
|
USER_ADDR,
|
|
500000000000000000n,
|
|
990000000n,
|
|
[WETH_ADDR, USDT_ADDR],
|
|
),
|
|
],
|
|
9999999999n,
|
|
);
|
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
|
expect(result.name).toBe("Uniswap Swap");
|
|
expect(detail(result, "Token In").value).toBe(
|
|
"Unknown (not named in the calldata)",
|
|
);
|
|
expect(detail(result, "Amount")).toBeUndefined();
|
|
expect(detail(result, "Token Out").address).toBe(
|
|
USDT_ADDR.toLowerCase(),
|
|
);
|
|
expect(detail(result, "Min. received").value).toBe("990.0000 USDT");
|
|
});
|
|
|
|
// UNWRAP_WETH makes Token Out ETH when no step set the output side. A V4
|
|
// step with a swap action did set it, to unknown here.
|
|
test("an UNWRAP_WETH after a V4 step whose swap actions buy two currencies does not make Token Out ETH", () => {
|
|
const data = buildExecute(
|
|
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
|
|
[
|
|
WETH_FOR_USDC_AND_DAI,
|
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
|
|
],
|
|
9999999999n,
|
|
);
|
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
|
expect(detail(result, "Token Out").value).toBe(
|
|
"Unknown (not named in the calldata)",
|
|
);
|
|
});
|
|
|
|
// 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, "0x1");
|
|
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);
|
|
});
|
|
|
|
// 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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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, "0x1");
|
|
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);
|
|
});
|
|
});
|