Files
AutistMask/tests/uniswap.test.js
T
clawbot 4109c4d44a
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: WETH paid to the user before an UNWRAP_WETH reads as WETH (closes #607)
An UNWRAP_WETH made Token Out ETH whenever the output side was WETH,
though the router unwraps only the WETH it holds. decode() now records
where the step that set the output side paid its output, and an
UNWRAP_WETH makes the output ETH only when that step paid WETH to the
router and the router still holds WETH. When that step paid only part
of its WETH to the router, or made a payout the decoder cannot read,
the output reads ETH with no minimum: the decoder does not count how
much of that step's minimum reaches the router, nor read the
UNWRAP_WETH's own minimum. A BALANCE_CHECK_ERC20 pays nobody, so it is
never read as unwrapped. README Display Consistency says the same.

Model: opus-5-5
2026-10-20 16:09:45 +02:00

3327 lines
131 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, sent from USER_ADDR.
// Swapped USDT for ETH via Uniswap V4 Universal Router.
// https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77
// This is that transaction's input, as a public Ethereum node returns it.
const FIRST_SWAP_CALLDATA =
"0x3593564c" +
"000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000069a1550f0000000000000000000000000000000000000000000000000000000000000002" +
"0a100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" +
"0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000069c8daf6" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e0" +
"0000000000000000000000000000000000000000000000000000000000000041230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
"00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" +
"070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" +
"00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" +
"0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e70000000000000000000000000000000000000000000000000000000000000001" +
"0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000001" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060" +
"000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000060" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
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: a V3 path: token(20) + fee(3) + token(20) ...
function encodeV3Path(pathTokens) {
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 "0x" + pathHex;
}
// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
return coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[recipient, amountIn, amountOutMin, encodeV3Path(pathTokens), true],
);
}
// Helper: encode a V3_SWAP_EXACT_OUT input (command 0x01). Its path lists
// the token bought first and the token spent last.
function encodeV3SwapExactOut(
recipient,
amountOut,
amountInMax,
pathTokens,
payerIsUser = true,
) {
return coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
recipient,
amountOut,
amountInMax,
encodeV3Path(pathTokens),
payerIsUser,
],
);
}
// 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",
{},
USER_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",
);
// Its TAKE pays the From address, so the minimum is received.
expect(
result.details.find((d) => d.label === "Min. received").value,
).toBe("0.0002 ETH");
expect(labels).not.toContain("Paid to");
// 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",
{},
USER_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,
"0x1",
{},
USER_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,
"0x1",
{},
USER_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,
"0x1",
{},
USER_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,
"0x1",
{},
USER_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,
"0x1",
{},
USER_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, "0x1", {}, USER_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, "0x1", {}, USER_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, "0x1", {}, USER_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, "0x1", {}, USER_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");
});
// https://git.eeqj.de/sneak/AutistMask/issues/601 — a V3_SWAP_EXACT_OUT
// was named but not read, so its tokens read "Unknown (not named in the
// calldata)" and it showed no amounts. It is read as a V2_SWAP_EXACT_OUT
// is, with its path read as the router reads it: from the token it buys
// to the token it spends. Read the other way, each path below would be a
// swap of WETH for USDT.
test.each([
{ hops: "single-hop", path: [WETH_ADDR, USDT_ADDR] },
{ hops: "multi-hop", path: [WETH_ADDR, USDC_ADDR, USDT_ADDR] },
])(
"decodes a $hops V3_SWAP_EXACT_OUT, stating the input amount as a maximum",
({ path }) => {
// Buy exactly 1 WETH for at most 2,000 USDT, paid by the user.
const data = buildExecute(
"0x01", // V3_SWAP_EXACT_OUT
[encodeV3SwapExactOut(USER_ADDR, ONE_WETH, 2000000000n, path)],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Token Out").address).toBe(
WETH_ADDR.toLowerCase(),
);
const amount = detail(result, "Amount");
expect(amount.value).toBe("Up to 2000.0000 USDT");
expect(amount.rawValue).toBe("Up to 2000.0000");
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
},
);
// The V2 exact-out swap paid in ETH above, as a V3_SWAP_EXACT_OUT paid
// from the WETH the router holds: it takes nothing more from the user's
// wallet.
test("a V3 exact-out swap paid in ETH from the router shows the token it buys and a maximum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0b, 0x01, 0x0c]),
[
encodeWrapEth(ROUTER_ADDR, 500000000000000000n),
encodeV3SwapExactOut(
USER_ADDR,
1500000000n, // amountOut: 1,500 USDC
500000000000000000n, // amountInMax: 0.5 WETH
[USDC_ADDR, WETH_ADDR],
false, // paid from the router
),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH same encoding
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Swap ETH → USDC");
expect(detail(result, "Amount").value).toBe("Up to 0.5000 ETH");
expect(detail(result, "Moved from the sender")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(
USDC_ADDR.toLowerCase(),
);
expect(detail(result, "Paid to")).toBeUndefined();
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", {}, USER_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,
"0x1",
{},
USER_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, "0x1", {}, USER_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. That step is paid by the user, so the
// USDC the exact-out step bought stays in the router, and the minimum is
// not received (https://git.eeqj.de/sneak/AutistMask/issues/606).
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", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. paid out").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(ROUTER_ADDR, 1000000n, 500000000000000n, [
USDC_ADDR,
SEPOLIA_WETH_ADDR,
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_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, "0x1", {}, USER_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, "0x1", {}, USER_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, "0x1", {}, USER_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(ROUTER_ADDR, 1000000n, 500000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH same encoding
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
// UNWRAP_WETH means output is native ETH
expect(result.name).toBe("Swap USDT \u2192 ETH");
});
// No step pays anyone, so the minimum is not said to be received.
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", {}, USER_ADDR);
expect(result).not.toBeNull();
const minOut = result.details.find((d) => d.label === "Min. paid out");
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", {}, USER_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",
);
});
// 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: "Across Bridge Deposit",
},
// Only the routers with the 0x7f mask have 0x07, as
// PAY_PORTION_FULL_PRECISION; the others revert on it
// (https://git.eeqj.de/sneak/AutistMask/issues/599).
{
router: "UniversalRouterV1",
address: ROUTER_V1_ADDR,
command: "0x07",
steps: "Command 0x07",
},
{
router: "UniversalRouterV2",
address: ROUTER_ADDR,
command: "0x07",
steps: "Command 0x07",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR,
command: "0x07",
steps: "Pay Portion (Full Precision)",
},
])(
"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,
// and the router lets the step fail
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
test.each([
{
flag: "without",
command: 0x21,
steps: "Execute Sub-Plan (its steps are not shown)",
},
{
flag: "with",
command: 0xa1,
steps: "Execute Sub-Plan (its steps are not shown) (allowed to fail)",
},
])(
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
({ command, steps }) => {
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",
{},
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe(steps);
// 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",
{},
USER_ADDR,
);
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",
{},
USER_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, "0x1", {}, USER_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, "0x1", {}, USER_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, "0x1", {}, USER_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, "0x1", {}, USER_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/625 — a route split across
// two swap steps the user pays for, with no PERMIT2_PERMIT, takes both
// steps' amounts from the wallet. On next at 671b4d3 the Amount line read
// only the first step's 600.0000 USDT.
describe("a swap split across steps the user pays for", () => {
const USDT_600 = 600000000n;
const USDT_400 = 400000000n;
const DAI_400 = 400000000000000000000n;
const OTHER_ADDR = "0x1111111111111111111111111111111111111111";
const UNDECODED = "Unknown (could not be decoded)";
// The lines between Token In and Token Out, as [label, value].
function linesBetweenTokens(result) {
const lines = result.details.map((d) => [d.label, d.value]);
const from = lines.findIndex(([label]) => label === "Token In");
const to = lines.findIndex(([label]) => label === "Token Out");
return lines.slice(from + 1, to);
}
// execute() of these command bytes and inputs, decoded as sent by
// the user.
function decodeSteps(commands, inputs) {
const data = buildExecute(
solidityPacked(
commands.map(() => "uint8"),
commands,
),
inputs,
9999999999n,
);
return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
}
// The first step: a V3 exact-in of 600 USDT for USDC, paid by the
// user, which sets the input side.
const V3_600 = encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]);
// V4 SETTLE of `amount` of `currency`, and TAKE of `amount` of
// `currency` to `recipient`.
const settle = (currency, amount, payerIsUser) =>
coder.encode(
["address", "uint256", "bool"],
[currency, amount, payerIsUser],
);
const take = (currency, recipient, amount) =>
coder.encode(
["address", "address", "uint256"],
[currency, recipient, amount],
);
// `input` with the top byte of its word at `index` set. This decoder
// cannot read such a word as an address or a length; the router reads
// its low bytes and runs the step.
const dirtyWord = (input, index) => {
const at = 2 + index * 64;
return input.slice(0, at) + "ff" + input.slice(at + 2);
};
test("a split across a V3 and a V2 step the user pays for shows the total", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Token In").address.toLowerCase()).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Amount").rawValue).toBe("1000.0000");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
// In the order of Uniswap's own V4 routes: the swap, then a SETTLE of
// OPEN_DELTA by the user, which pays what the swap spends.
test("a split across a V3 step and a V4 step the user pays for shows the total", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_400,
390000000n,
),
encodeV4Settle(USDT_ADDR), // paid by the user
encodeV4Take(USDC_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
test("a second step the user pays for that spends another token shows that token's amount", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(
USER_ADDR,
400000000000000000000n, // 400 DAI
390000000n,
[DAI_ADDR, USDC_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
]);
});
// Every later V4 step takes from the sender what its SETTLEs paid by
// the user and its SETTLE_ALLs pay, each currency on its own, with a
// swap action or without. Each of these read 600.0000 USDT alone
// before.
const USDT_FOR_USDC_400 = encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_400,
390000000n,
);
const DAI_FOR_USDC_400 = encodeV4ExactInSingle(
DAI_ADDR,
USDC_ADDR,
DAI_400,
390000000n,
);
const DAI_GROUP = [
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
test.each([
{
kind: "a V4 step paying USDT and DAI by a SETTLE_ALL of each",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
DAI_FOR_USDC_400,
encodeV4SettleAll(USDT_ADDR, USDT_400),
encodeV4SettleAll(DAI_ADDR, DAI_400),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [["Amount", "1000.0000 USDT"], ...DAI_GROUP],
},
{
kind: "a V4 step with a SETTLE of USDT by the user, then a SETTLE of WETH from the router",
actions: [
V4_SETTLE_ID,
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ALL_ID,
],
params: [
settle(USDT_ADDR, USDT_400, true),
settle(WETH_ADDR, 2n ** 255n, false), // CONTRACT_BALANCE
USDT_FOR_USDC_400,
encodeV4ExactInSingle(WETH_ADDR, USDC_ADDR, 0n, 1n),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [["Amount", "1000.0000 USDT"]],
},
{
kind: "a V4 step with no swap action: a SETTLE of 400 USDT by the user and a TAKE of it to another address",
actions: [V4_SETTLE_ID, V4_TAKE_ID],
params: [
settle(USDT_ADDR, USDT_400, true),
take(USDT_ADDR, OTHER_ADDR, USDT_400),
],
lines: [["Amount", "1000.0000 USDT"]],
},
// The SETTLE's amount, not the swap's, is what the user pays.
{
kind: "a V4 step swapping 400 USDT with a SETTLE of 5,000 USDT by the user, the rest taken to another address",
actions: [
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
V4_TAKE_ALL_ID,
],
params: [
settle(USDT_ADDR, 5000000000n, true),
USDT_FOR_USDC_400,
take(USDT_ADDR, OTHER_ADDR, 0n),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [["Amount", "5600.0000 USDT"]],
},
// A SETTLE_ALL pays all the step owes, here what the TAKE takes as
// well as what the swap spends, so its amount is not known.
{
kind: "a V4 step with no swap action: a TAKE of 400 USDT to another address, then a SETTLE_ALL of USDT",
actions: [V4_TAKE_ID, V4_SETTLE_ALL_ID],
params: [
take(USDT_ADDR, OTHER_ADDR, USDT_400),
encodeV4SettleAll(USDT_ADDR, USDT_400),
],
lines: [
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
],
},
{
kind: "a V4 step swapping 400 USDT, with a TAKE of 400 USDT to another address before its SETTLE_ALL of USDT",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
take(USDT_ADDR, OTHER_ADDR, USDT_400),
encodeV4SettleAll(USDT_ADDR, 800000000n),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
],
},
])(
"a V3 step of 600 USDT, then $kind",
({ actions, params, lines }) => {
const result = decodeSteps(
[0x00, 0x10],
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
);
expect(linesBetweenTokens(result)).toEqual(lines);
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/628 — the V4 step that
// sets the input side takes what its SETTLE paid by the user states,
// not what its swap spends. Each of these read 400.0000 USDT before;
// on next the first two read 400.0000 USDT and 1.0000 USDT.
const V4_SETTLE_5000 = encodeV4Swap(
new Uint8Array([
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
V4_TAKE_ALL_ID,
]),
[
settle(USDT_ADDR, 5000000000n, true),
USDT_FOR_USDC_400,
take(USDT_ADDR, OTHER_ADDR, 0n), // the rest
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
test.each([
{
kind: "a V4 step swapping 400 USDT with a SETTLE of 5,000 USDT by the user, the rest taken to another address,",
commands: [0x10],
inputs: [V4_SETTLE_5000],
},
{
kind: "a PERMIT2_PERMIT of 1 USDT that the router may let fail, then that V4 step,",
commands: [0x8a, 0x10],
inputs: [
encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR),
V4_SETTLE_5000,
],
},
// The unread SWEEP keeps the permit's amount from standing.
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, that V4 step, then a SWEEP that cannot be read,",
commands: [0x0a, 0x10, 0x04],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
V4_SETTLE_5000,
dirtyWord(
coder.encode(
["address", "address", "uint256"],
[USDC_ADDR, USER_ADDR, 0n],
),
1,
),
],
},
])("$kind shows the 5,000 USDT", ({ commands, inputs }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "5000.0000 USDT"],
]);
});
// A step whose swap action cannot be read states no amount of its
// own, but what the user pays it of its input currency is still its
// amount. On next it had no Amount line.
test("a V4 step whose swap action cannot be read, with a SETTLE of 400 USDT by the user, shows the 400 USDT", () => {
// USDT for USDC exact-in, in the newer shape the decoder cannot
// read (see V4_SWAP_ACTION_SHAPES).
const unreadSwap = coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
[
[
[USDC_ADDR, USDT_ADDR, 100, 1, ZERO_ADDR],
false, // currency1 (USDT) for currency0 (USDC)
USDT_400,
390000000n,
0n,
"0x",
],
],
);
const result = decodeSteps(
[0x10],
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
unreadSwap,
settle(USDT_ADDR, USDT_400, true),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
],
);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "400.0000 USDT"],
]);
});
// A step that may take from the sender what the decoder did not read
// leaves no Amount figure, which may be too low, and one line says so.
const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer;
test.each([
{
kind: "a V4 step whose SETTLE by the user cannot be read",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
dirtyWord(settle(USDT_ADDR, USDT_400, true), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
},
{
kind: "a V4 step whose SETTLE_ALL cannot be read",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
dirtyWord(encodeV4SettleAll(USDT_ADDR, USDT_400), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
},
{
kind: "a V4 step whose swap action and SETTLE_ALL cannot be read",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
UNREAD_SWAP,
dirtyWord(encodeV4SettleAll(WETH_ADDR, ONE_WETH), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
},
])("a V3 step of 600 USDT, then $kind", ({ actions, params }) => {
const result = decodeSteps(
[0x00, 0x10],
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
});
test.each([
{
kind: "a V4 step swapping 400 USDT whose SETTLE by the user cannot be read",
commands: [0x10],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
USDT_FOR_USDC_400,
dirtyWord(settle(USDT_ADDR, USDT_400, true), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
],
},
{
kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read",
commands: [0x00, 0x10],
inputs: [
V3_600,
dirtyWord(
encodeV4Swap(new Uint8Array([V4_SETTLE_ID]), [
settle(USDT_ADDR, USDT_400, true),
]),
2,
),
],
},
{
kind: "a V4 step with no swap action whose SETTLE_ALL cannot be read, then a V3 step of 600 USDT",
commands: [0x10, 0x00],
inputs: [
encodeV4Swap(
new Uint8Array([V4_TAKE_ID, V4_SETTLE_ALL_ID]),
[
take(USDT_ADDR, OTHER_ADDR, USDT_400),
dirtyWord(
encodeV4SettleAll(USDT_ADDR, USDT_400),
0,
),
],
),
V3_600,
],
},
{
kind: "a V3 step of 600 USDT, then a V2 step the user pays for whose input cannot be read",
commands: [0x00, 0x08],
inputs: [
V3_600,
dirtyWord(
encodeV2SwapExactIn(OTHER_ADDR, USDT_400, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
0,
),
],
},
{
kind: "a V3 step of 600 USDT, then a PERMIT2_TRANSFER_FROM whose input cannot be read",
commands: [0x00, 0x02],
inputs: [
V3_600,
dirtyWord(
coder.encode(
["address", "address", "uint160"],
[USDT_ADDR, OTHER_ADDR, USDT_400],
),
1,
),
],
},
{
kind: "a V3 step of 600 USDT, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x00, 0x0d],
inputs: [
V3_600,
coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
),
],
},
])("$kind", ({ commands, inputs }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
});
// The steps of an EXECUTE_SUB_PLAN may take from the sender too, so
// wherever it stands one line says so, and the figure of another
// take cannot read as all that leaves.
const SUB_PLAN = encodeSubPlan("0x00", [V3_600]);
const V2_DAI_400 = encodeV2SwapExactIn(USER_ADDR, DAI_400, 390000000n, [
DAI_ADDR,
USDC_ADDR,
]);
test.each([
{
kind: "a V3 step of 600 USDT and a V2 step of 400 DAI, then an EXECUTE_SUB_PLAN",
commands: [0x00, 0x08, 0x21],
inputs: [V3_600, V2_DAI_400, SUB_PLAN],
lines: [
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", UNDECODED],
],
},
{
kind: "an EXECUTE_SUB_PLAN, then a V3 step of 600 USDT and a V2 step of 400 DAI",
commands: [0x21, 0x00, 0x08],
inputs: [SUB_PLAN, V3_600, V2_DAI_400],
lines: [
["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
],
},
{
kind: "a V3 step of 600 USDT and a PERMIT2_TRANSFER_FROM of 400 DAI, then an EXECUTE_SUB_PLAN",
commands: [0x00, 0x02, 0x21],
inputs: [
V3_600,
coder.encode(
["address", "address", "uint160"],
[DAI_ADDR, OTHER_ADDR, DAI_400],
),
SUB_PLAN,
],
lines: [
["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", OTHER_ADDR],
],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A PERMIT2_PERMIT in the plain shape of Uniswap's own routes keeps
// its own amount: Permit2 lets the router take no more of the token,
// so the steps after it do not add to it.
test("a split after a PERMIT2_PERMIT shows the permit's amount", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x00, 0x08]),
[
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR), // 5000 USDT
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("5000.0000 USDT");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
// Out of that shape the permit caps nothing the Amount line can rely
// on, so the line gives the total the steps take, as with no permit.
const ONE_USDT_PERMIT = (spender) =>
encodePermit2(USDT_ADDR, 1000000n, spender);
test.each([
{
kind: "to another spender",
commands: [0x0a],
permits: [ONE_USDT_PERMIT(OTHER_ADDR)],
},
{
kind: "that the router lets fail",
commands: [0x8a],
permits: [ONE_USDT_PERMIT(ROUTER_ADDR)],
},
{
kind: "followed by a second one",
commands: [0x0a, 0x0a],
permits: [
ONE_USDT_PERMIT(ROUTER_ADDR),
ONE_USDT_PERMIT(ROUTER_ADDR),
],
},
])(
"a split after a PERMIT2_PERMIT $kind shows the total the steps take",
({ commands, permits }) => {
const result = decodeSteps(
[...commands, 0x00, 0x08],
[
...permits,
V3_600,
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "1000.0000 USDT"],
]);
},
);
// A PERMIT2_PERMIT_BATCH, which this decoder does not read, may raise
// what the router may take of the permit's token again.
test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => {
const permitBatch = coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)",
"bytes",
],
[
[
[[USDT_ADDR, 10000000000n, 0, 0]], // 10,000 USDT
ROUTER_ADDR,
9999999999,
],
"0x1234",
],
);
const result = decodeSteps(
[0x0a, 0x03, 0x00, 0x08],
[
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
permitBatch,
V3_600,
encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
]);
});
// Any other step this decoder does not read may do the same, for
// example through the hook of the pool a V4_INITIALIZE_POOL sets up.
test.each([
{
kind: "a V4_INITIALIZE_POOL",
command: 0x13,
input: coder.encode(
[POOL_KEY, "uint160"],
[
[USDC_ADDR, USDT_ADDR, 3000, 60, OTHER_ADDR],
79228162514264337593543950336n,
],
),
},
{
kind: "a SWEEP whose input cannot be read",
command: 0x04,
input: dirtyWord(
coder.encode(
["address", "address", "uint256"],
[USDC_ADDR, USER_ADDR, 0n],
),
1,
),
},
])(
"a split after a PERMIT2_PERMIT, with $kind between its steps, shows the total the steps take",
({ command, input }) => {
const result = decodeSteps(
[0x0a, 0x00, command, 0x08],
[
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
input,
encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
]);
},
);
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC.
const v3ExactOut = (amountInMax) =>
encodeV3SwapExactOut(USER_ADDR, 390000000n, amountInMax, [
USDC_ADDR,
USDT_ADDR,
]);
// A split exact-out route: a V3_SWAP_EXACT_OUT of at most 6,000 USDT
// and a V2_SWAP_EXACT_OUT of at most 4,000 USDT, both paid by the
// user, then a SWEEP of USDC to the user.
const EXACT_OUT_SPLIT = [
v3ExactOut(6000000000n),
encodeV2SwapExactOut(USER_ADDR, 390000000n, 4000000000n, [
USDT_ADDR,
USDC_ADDR,
]),
coder.encode(
["address", "address", "uint256"],
[USDC_ADDR, USER_ADDR, 0n],
),
];
// What a V3_SWAP_EXACT_OUT takes, at most its amountInMax, counts
// as a V2_SWAP_EXACT_OUT's does, and the Amount line is a maximum.
// The permit of 10,000 USDT stands; the one of 600 USDT does not,
// because the step before it took its token.
test.each([
{
kind: "a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT of at most 400 USDT",
commands: [0x00, 0x01],
inputs: [V3_600, v3ExactOut(USDT_400)],
amount: "Up to 1000.0000 USDT",
},
{
kind: "a PERMIT2_PERMIT of 10,000 USDT, then a V3_SWAP_EXACT_OUT, a V2_SWAP_EXACT_OUT and a SWEEP",
commands: [0x0a, 0x01, 0x09, 0x04],
inputs: [
encodePermit2(USDT_ADDR, 10000000000n, ROUTER_ADDR),
...EXACT_OUT_SPLIT,
],
amount: "Up to 10000.0000 USDT",
},
{
kind: "a V3_SWAP_EXACT_OUT, a V2_SWAP_EXACT_OUT and a SWEEP",
commands: [0x01, 0x09, 0x04],
inputs: EXACT_OUT_SPLIT,
amount: "Up to 10000.0000 USDT",
},
{
kind: "a V3_SWAP_EXACT_OUT of at most 9,400 USDT, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 600 USDT",
commands: [0x01, 0x0a, 0x00],
inputs: [
v3ExactOut(9400000000n),
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
],
amount: "Up to 10000.0000 USDT",
},
])(
"$kind, all paid by the user, shows the total as a maximum",
({ commands, inputs, amount }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Amount", amount],
]);
},
);
// A V3_SWAP_EXACT_OUT whose path is shorter than one token cannot be
// read. It may pay any address and take from the sender, so it
// leaves no Amount figure.
test("a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT whose path cannot be read, shows no Amount figure", () => {
const unreadable = coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[USER_ADDR, 390000000n, USDT_400, "0x", true],
);
const result = decodeSteps([0x00, 0x01], [V3_600, unreadable]);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
expect(detail(result, "Paid to").value).toBe(UNDECODED);
});
// https://git.eeqj.de/sneak/AutistMask/issues/637 — the router reads
// a V3 path as one token and whole hops of a 3-byte fee and a token,
// and ignores any bytes after the last hop. Each path below is
// `tokens` and then the last 19 bytes of a bundled token's address
// whose first byte is the last token's last byte, so the path's last
// 20 bytes spell a token the router does not read. Such a path is not
// read, as one shorter than one token is not.
const pathEndingIn = (tokens, address) =>
encodeV3Path(tokens) + address.slice(4).toLowerCase();
const SPK_ADDR = "0xc20059e0317DE91738d13af027DfC4a50781b066";
const IUSD_ADDR = "0x48f9e38f3070AD8945DFEae3FA70987722E3D89c";
const v3Swap = (amount, otherAmount, path) =>
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[USER_ADDR, amount, otherAmount, path, true],
);
// A V3 exact-in of 1 WETH for USDC pays out USDC, not IUSD.
test("a V3_SWAP_EXACT_IN whose path has bytes after its last hop names no Token Out", () => {
const path = pathEndingIn([WETH_ADDR, USDC_ADDR], IUSD_ADDR);
const result = decodeSteps([0x00], [v3Swap(ONE_WETH, 1n, path)]);
expect(detail(result, "Token Out").address).toBeUndefined();
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
});
// A V3 exact-out of 1,500 USDC for at most 0.5 WETH spends WETH, not
// SPK. After it, a PERMIT2_PERMIT of 1 WETH and a V3 exact-in of 0.5
// WETH: the permit's amount does not stand, because the step before
// it also takes WETH.
test("a V3_SWAP_EXACT_OUT whose path has bytes after its last hop, then a PERMIT2_PERMIT and a V3 exact-in, shows no Amount figure", () => {
const path = pathEndingIn([USDC_ADDR, WETH_ADDR], SPK_ADDR);
const result = decodeSteps(
[0x01, 0x0a, 0x00],
[
v3Swap(1500000000n, ONE_WETH / 2n, path),
encodePermit2(WETH_ADDR, ONE_WETH, ROUTER_ADDR),
encodeV3SwapExactIn(USER_ADDR, ONE_WETH / 2n, 1n, [
WETH_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
expect(detail(result, "Paid to").value).toBe(UNDECODED);
});
// A later step's amount that cannot be added to the Amount line,
// because the amount of the step that set the input side is not a
// quantity, gets lines of its own. When that step stated no amount,
// it gets lines of its own too, saying so, so the 400 USDT cannot
// read as all that leaves.
const V2_EXACT_OUT_400 = encodeV2SwapExactOut(
USER_ADDR,
390000000n,
USDT_400,
[USDT_ADDR, USDC_ADDR],
);
const UP_TO_400_USDT = [
["Moved from the sender", "Up to 400.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
const UNKNOWN_USDT = [
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
// A V4 exact-out step of at most 600 USDT for 1 WETH, in the shape
// of Uniswap's own routes: a SETTLE of OPEN_DELTA by the user pays
// what the swap spends.
const V4_EXACT_OUT_600 = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
ONE_WETH,
USDT_600,
),
encodeV4Settle(USDT_ADDR),
encodeV4TakeAll(WETH_ADDR, 0n),
],
);
// The same step with its SETTLE of OPEN_DELTA paid from the router.
const V4_EXACT_OUT_600_FROM_ROUTER = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
ONE_WETH,
USDT_600,
),
settle(USDT_ADDR, 0n, false),
encodeV4TakeAll(WETH_ADDR, 0n),
],
);
test.each([
{
kind: "a V4 exact-out step with a SETTLE of OPEN_DELTA by the user",
commands: [0x10, 0x09],
first: V4_EXACT_OUT_600,
lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT],
},
// Paid from the router, the step takes nothing from the wallet.
{
kind: "a V4 exact-out step with a SETTLE of OPEN_DELTA from the router",
commands: [0x10, 0x09],
first: V4_EXACT_OUT_600_FROM_ROUTER,
lines: UP_TO_400_USDT,
},
// Settled by the user, the step's amount would be what that
// SETTLE pays, which the decoder cannot state here.
{
kind: "a V4 exact-in step of the open delta, settled from the router",
commands: [0x10, 0x09],
first: encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(USDT_ADDR, USDC_ADDR, 0n, 1n),
settle(USDT_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
lines: [
["Amount", "All available (V4 open delta)"],
...UP_TO_400_USDT,
],
},
{
kind: "a V2 exact-in step of ALREADY_PAID",
commands: [0x08, 0x09],
first: encodeV2SwapExactIn(USER_ADDR, 0n, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
lines: [
[
"Amount",
"Whatever an earlier step sent to the pair (V2 already paid)",
],
...UP_TO_400_USDT,
],
},
])(
"$kind, then a V2 exact-out of at most 400 USDT the user pays for, shows the 400 USDT",
({ commands, first, lines }) => {
const result = decodeSteps(commands, [first, V2_EXACT_OUT_400]);
expect(linesBetweenTokens(result)).toEqual(lines);
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/634 — after a
// PERMIT2_PERMIT of another token, the V4 exact-out step paid from
// the router still takes nothing from the wallet, so it gets no lines
// of its own. On next it got lines saying its amount is not known.
const ONE_DAI_PERMIT = encodePermit2(DAI_ADDR, 10n ** 18n, ROUTER_ADDR);
test.each([
{
kind: "a PERMIT2_PERMIT of 1 DAI, then a V4 exact-out step of at most 600 USDT paid from the router",
commands: [0x0a, 0x10],
inputs: [ONE_DAI_PERMIT, V4_EXACT_OUT_600_FROM_ROUTER],
lines: [["Amount", "1.0000 DAI"]],
},
{
kind: "a PERMIT2_PERMIT of 1 DAI, a V4 exact-out step of at most 600 USDT paid from the router, then a V2 exact-out of at most 400 USDT the user pays for",
commands: [0x0a, 0x10, 0x09],
inputs: [
ONE_DAI_PERMIT,
V4_EXACT_OUT_600_FROM_ROUTER,
V2_EXACT_OUT_400,
],
lines: [["Amount", "Up to 1.0000 DAI"], ...UP_TO_400_USDT],
},
])(
"$kind, gives the V4 step no lines of its own",
({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
},
);
// The V4 exact-out route above the other way round, where the later
// take's unknown amount leaves no figure at all, and other ways the
// step that sets the input side states no amount.
test.each([
{
kind: "a V2 exact-out of at most 400 USDT, then a V4 exact-out step of at most 600 USDT",
commands: [0x09, 0x10],
inputs: [V2_EXACT_OUT_400, V4_EXACT_OUT_600],
lines: UNKNOWN_USDT,
},
{
kind: "a PERMIT2_PERMIT of 1 USDT to another spender, a V4 exact-out step of at most 600 USDT, then a V2 exact-out of at most 400 USDT",
commands: [0x0a, 0x10, 0x09],
inputs: [
encodePermit2(USDT_ADDR, 1000000n, OTHER_ADDR),
V4_EXACT_OUT_600,
V2_EXACT_OUT_400,
],
lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT],
},
{
kind: "a V4 step whose swap action cannot be read, with a SETTLE of WETH by the user, then a V2 exact-out of at most 400 USDT",
commands: [0x10, 0x09],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
UNREAD_SWAP,
encodeV4Settle(WETH_ADDR),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
V2_EXACT_OUT_400,
],
lines: [
["Moved from the sender", UNDECODED],
["Token moved", "WETH (" + WETH_ADDR + ")"],
["Moved to", "The swap step that spends it"],
...UP_TO_400_USDT,
],
},
])(
"$kind, all paid by the user, shows that an amount is not known",
({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/634 — a
// PERMIT2_TRANSFER_FROM of the token the V4 exact-out step spends,
// before or after it, shows its own figure, so the step's amount is
// shown as not known beside it, and the 400 USDT cannot read as all
// that leaves. On next only the 400 USDT was shown. Into the router,
// the 400 USDT is on its Paid to line.
const transfer = (token, recipient, amount) =>
coder.encode(
["address", "address", "uint160"],
[token, recipient, amount],
);
const MOVED_400_USDT = [
["Moved from the sender", "400.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", OTHER_ADDR],
];
test.each([
{
kind: "a V4 exact-out step of at most 600 USDT, then a PERMIT2_TRANSFER_FROM of 400 USDT to another address",
commands: [0x10, 0x02],
inputs: [
V4_EXACT_OUT_600,
transfer(USDT_ADDR, OTHER_ADDR, USDT_400),
],
lines: [...UNKNOWN_USDT, ...MOVED_400_USDT],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 400 USDT to another address, then a V4 exact-out step of at most 600 USDT",
commands: [0x02, 0x10],
inputs: [
transfer(USDT_ADDR, OTHER_ADDR, USDT_400),
V4_EXACT_OUT_600,
],
lines: [...UNKNOWN_USDT, ...MOVED_400_USDT],
},
{
kind: "a V4 exact-out step of at most 600 USDT, then a PERMIT2_TRANSFER_FROM of 400 USDT into the router",
commands: [0x10, 0x02],
inputs: [
V4_EXACT_OUT_600,
transfer(USDT_ADDR, ROUTER_ADDR, USDT_400),
],
lines: UNKNOWN_USDT,
},
{
kind: "a PERMIT2_TRANSFER_FROM of 400 USDT into the router, then a V4 exact-out step of at most 600 USDT",
commands: [0x02, 0x10],
inputs: [
transfer(USDT_ADDR, ROUTER_ADDR, USDT_400),
V4_EXACT_OUT_600,
],
lines: UNKNOWN_USDT,
},
])(
"$kind, paid by the user, shows that the step's amount is not known",
({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
},
);
// Paid from the router, the V4 exact-out step takes nothing from the
// wallet, so the moves give all that leaves.
test("a PERMIT2_TRANSFER_FROM of 400 USDT into the router, one of 10 USDT to another address, then a V4 exact-out step paid from the router, shows only the moves", () => {
const result = decodeSteps(
[0x02, 0x02, 0x10],
[
transfer(USDT_ADDR, ROUTER_ADDR, USDT_400),
transfer(USDT_ADDR, OTHER_ADDR, 10000000n),
V4_EXACT_OUT_600_FROM_ROUTER,
],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", "10.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", OTHER_ADDR],
]);
});
// A move of another token says nothing of how much USDT leaves.
test("a V4 exact-out step of at most 600 USDT, then a PERMIT2_TRANSFER_FROM of 400 DAI, shows only the DAI moved", () => {
const result = decodeSteps(
[0x10, 0x02],
[V4_EXACT_OUT_600, transfer(DAI_ADDR, OTHER_ADDR, DAI_400)],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", OTHER_ADDR],
]);
});
// A V2 exact-out step makes every amount the swap steps spend a maximum,
// the total included.
test("a split with a V2 exact-out step the user pays for shows the total as a maximum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x09]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactOut(USER_ADDR, 390000000n, USDT_400, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT");
});
});
// 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", {}, USER_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, "0x1", {}, USER_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, "0x1", {}, USER_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. The
// SETTLE is paid from the router: one paid by the user would make the
// step's amount what that SETTLE pays.
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,
]),
[
coder.encode(
["address", "uint256", "bool"],
[WETH_ADDR, 0n, false],
),
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
0n, // ActionConstants.OPEN_DELTA
990000n,
),
encodeV4Take(USDC_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_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 — 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.
// `minLabel` is the minimum's line: with no TAKE or TAKE_ALL, no step
// pays the user, so it is not said to be received.
const V4_STEP_ENDINGS = [
{
ending: "SETTLE_ALL and TAKE_ALL",
minLabel: "Min. received",
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
params: [
encodeV4SettleAll(USDT_ADDR, 1000000000n),
encodeV4TakeAll(USDC_ADDR, 990000000n),
],
},
{
ending: "SETTLE and TAKE",
minLabel: "Min. received",
ids: [V4_SETTLE_ID, V4_TAKE_ID],
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
},
{
ending: "CLOSE_CURRENCY",
minLabel: "Min. paid out",
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, minLabel }) => {
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",
{},
USER_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")?.value).toBe(amount);
expect(detail(result, minLabel)?.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",
{},
USER_ADDR,
);
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", {}, USER_ADDR);
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", {}, USER_ADDR);
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", {}, USER_ADDR);
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", {}, USER_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);
});
// 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", {}, USER_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, "0x1", {}, USER_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, "0x1", {}, USER_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. No step pays anyone, so the
// minimum is not said to be received.
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", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDT");
expect(detail(result, "Min. paid out").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", {}, USER_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, "0x1", {}, USER_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);
});
});