Files
AutistMask/tests/uniswap.test.js
sneak 7270480e0b
Some checks failed
check / check (push) Failing after 2m16s
build: add ESLint to script/lint and containerize linting (closes #152)
script/lint ran `prettier --check .`, byte for byte what script/fmt-check
runs, so make check checked formatting twice and did no static analysis on
a cryptocurrency wallet. Two used-but-not-imported crashes shipped past it.

ESLint is pinned in package.json with @eslint/js recommended as the base and
a flat config in eslint.config.js. no-undef and no-unused-vars are restated
error-level so a future recommended-set change cannot downgrade them.
Globals are declared per tree rather than globally, because a too-wide set
hides the next unimported identifier: browser for the popup and content
scripts, service worker for src/background/ and src/shared/, browser for the
one documented POPUP ONLY module in src/shared/, jest for tests/, node for
build.js, and both for the e2e harnesses, which carry the callbacks they
ship into the page inline.

Two rules new to the recommended set are off, and both would have cost
something to satisfy. no-useless-assignment flags the `password = null` and
`decryptedSecret = null` wipes in approval.js and confirmTx.js: those
assignments are dead by construction, which is the point of them, and the
rule's fix is to delete the wipe. preserve-caught-error would change what
the wallet's error paths throw, which is a decision of its own.

Every remaining violation is fixed: 41 unused bindings and 53 undefined
identifiers. Unused catch bindings became `catch {`, which the repo already
used; the shared init(ctx) view signature keeps its parameter as _ctx in the
three views that do not read it. src/shared/uniswap.js keeps its unused
V2_SWAP_EXACT_OUT decoder behind a scoped disable, because deleting it would
widen the gap it represents rather than close it (#283).

Linting is containerized. script/lint builds the Dockerfile's new lint stage
so the ESLint deciding whether this repo is green is the pinned one and not
whatever the host has; AUTISTMASK_LINT_NATIVE, set only in that image, is
what makes make check inside the CI build lint in place instead of recursing
into docker. The check stage takes a COPY --from=lint dependency so a lint
failure fails the whole build early rather than racing it.

No --fix anywhere in the lint path: make check remains non-mutating.

The README claim that a used-but-not-imported identifier is invisible to
make check, and the same claim in script/test-e2e, are no longer true and
are corrected.
2026-08-14 04:16:53 +00:00

357 lines
16 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 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 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]);
}
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());
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");
});
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).toContain("1.0000");
expect(amount.value).toContain("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");
});
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);
});
});