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