fix: a V3 exact-out swap step shows its tokens and amounts (closes #601)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A V3_SWAP_EXACT_OUT step was named in the Steps line but never read, so
its tokens read as not named in the calldata and it gave no amounts. It
is now read as a V2_SWAP_EXACT_OUT is: its path from the token it buys
to the token it spends, as the router reads it, its amountInMax as the
most it spends, its amountOut as the minimum, and its recipient as the
address it pays. One the user pays for after the step that sets the
input side joins the Amount total. A V3 path with bytes after its last
whole hop, which the router ignores, leaves either V3 swap step unread.

Also closes #637.

Model: opus-5-5
This commit was merged in pull request #636.
This commit is contained in:
2026-10-20 10:06:09 +02:00
parent ba27ddced0
commit 657dc14f04
4 changed files with 392 additions and 174 deletions
+181 -15
View File
@@ -88,17 +88,42 @@ function encodeV2SwapExactOut(recipient, amountOut, amountInMax, pathAddrs) {
);
}
// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
// V3 path: token(20) + fee(3) + token(20) ...
// 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, "0x" + pathHex, true],
[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,
],
);
}
@@ -644,6 +669,79 @@ describe("uniswap decoder", () => {
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", () => {
@@ -2048,11 +2146,9 @@ describe("uniswap decoder", () => {
);
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC. Its input is (recipient, amountOut, amountInMax,
// path, payerIsUser), laid out as encodeV3SwapExactIn() writes one;
// an exact-out path lists the token bought first.
// USDT for USDC.
const v3ExactOut = (amountInMax) =>
encodeV3SwapExactIn(USER_ADDR, 390000000n, amountInMax, [
encodeV3SwapExactOut(USER_ADDR, 390000000n, amountInMax, [
USDC_ADDR,
USDT_ADDR,
]);
@@ -2070,14 +2166,16 @@ describe("uniswap decoder", () => {
[USDC_ADDR, USER_ADDR, 0n],
),
];
// This decoder does not read a V3_SWAP_EXACT_OUT, so what one takes
// leaves no Amount figure wherever it stands, and a permit's amount
// does not stand.
// 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",
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",
@@ -2086,11 +2184,13 @@ describe("uniswap decoder", () => {
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",
@@ -2100,17 +2200,83 @@ describe("uniswap decoder", () => {
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
],
amount: "Up to 10000.0000 USDT",
},
])(
"$kind, all paid by the user, shows no Amount figure",
({ commands, inputs }) => {
"$kind, all paid by the user, shows the total as a maximum",
({ commands, inputs, amount }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
["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,