decode() had no arm for Universal Router command 0x09, so the approval screen showed no token or amount for it. It now takes the path's first token and amountInMax as the input side, the last token and amountOut as the output side. With such a step, the Amount figure reads "Up to <amount>" whichever step set it, on the approval, wait, success and error screens, unless it reads "Unlimited", as an unbounded PERMIT2_PERMIT does, or "All available (V4 open delta)". In every swap, UNWRAP_WETH makes Token Out ETH only when the output side is WETH, on mainnet or Sepolia, or no step set it; otherwise the output keeps its own token and figure. decodeV2SwapExactOut() loses its eslint-disable comment. Model: opus-5-5
873 lines
36 KiB
JavaScript
873 lines
36 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("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");
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
});
|