fix: a V4 step of chained swap actions shows what the user receives (closes #563)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A V4 step's input side is what SETTLE or SETTLE_ALL names, else the one
currency its swap actions spend and never buy; its output side is what
TAKE or TAKE_ALL names, else the one they buy and never spend. Amount
and Min. received come from those actions, summed where several state
one. An unread swap action leaves the sides to SETTLE, SETTLE_ALL, TAKE
and TAKE_ALL, with no figure. A step with a swap action always sets the
output side, sets the input side only when no earlier step set it, and
sets a side its rules leave unknown as unknown with no figure.
SWAP_EXACT_OUT is now read as spending its first path entry, not its
last.

Model: opus-5-5
This commit is contained in:
2026-10-19 00:06:36 +00:00
parent fc042c3191
commit 6792a1f9b6
3 changed files with 735 additions and 166 deletions
+474 -20
View File
@@ -107,6 +107,7 @@ function encodeV4Swap(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_SWAP_EXACT_OUT_SINGLE_ID = 0x08;
const V4_SETTLE_ID = 0x0b;
const V4_TAKE_ID = 0x0e;
const ZERO_ADDR = "0x0000000000000000000000000000000000000000";
@@ -163,6 +164,31 @@ function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
);
}
// Helper: V4 ExactOutputSingleParams —
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne,
// uint128 amountOut, uint128 amountInMaximum, bytes hookData)
function encodeV4ExactOutSingle(
currency0,
currency1,
amountOut,
amountInMaximum,
) {
return coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[currency0, currency1, 100, 1, ZERO_ADDR],
true, // zeroForOne: in = currency0, out = currency1
amountOut,
amountInMaximum,
"0x",
],
],
);
}
// Each V4 swap action's parameters in two shapes: the struct V4Router has
// deployed on mainnet and Sepolia, and the one v4-periphery `main` has since
// moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC.
@@ -267,9 +293,7 @@ function v4SwapCalldata(actionId, swapParams) {
);
}
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount). The
// decoder reads neither, so in a V4_SWAP of a swap action and these, only the
// swap action names the currencies.
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount).
const V4_SETTLE_ALL_ID = 0x0c;
const V4_TAKE_ALL_ID = 0x0f;
function encodeV4SettleAll(currency, maxAmount) {
@@ -279,6 +303,12 @@ function encodeV4TakeAll(currency, minAmount) {
return coder.encode(["address", "uint256"], [currency, minAmount]);
}
// V4 CLOSE_CURRENCY (currency), which the decoder does not read.
const V4_CLOSE_CURRENCY_ID = 0x12;
function encodeV4CloseCurrency(currency) {
return coder.encode(["address"], [currency]);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
@@ -358,10 +388,9 @@ describe("uniswap decoder", () => {
},
);
// Only the unread swap action names the currencies here. The token lines
// must not say the calldata did not name them.
// SETTLE_ALL and TAKE_ALL name both sides as SETTLE and TAKE do.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, says both tokens could not be decoded",
"a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, takes both tokens from them",
({ id, newer }) => {
const data = buildExecute(
"0x10",
@@ -381,6 +410,27 @@ describe("uniswap decoder", () => {
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").address).toBe(WETH_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// Only the unread swap action names the currencies here. The token lines
// must not say the calldata did not name them.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 step of only a $action in the newer shape says both tokens could not be decoded",
({ id, newer }) => {
const data = buildExecute(
"0x10",
[encodeV4Swap(new Uint8Array([id]), [newer])],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
);
@@ -394,7 +444,7 @@ describe("uniswap decoder", () => {
// action names. The V3 step's WETH and its Min. received figure were
// shown instead.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V3 step, then a V4 $action in the newer shape and TAKE_ALL, does not show the V3 step's output",
"a V3 step, then a V4 step of only a $action in the newer shape, does not show the V3 step's output",
({ id, newer }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
@@ -403,10 +453,7 @@ describe("uniswap decoder", () => {
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
],
9999999999n,
);
@@ -423,15 +470,12 @@ describe("uniswap decoder", () => {
// The user pays the V4 step's WETH, which only its unread swap action
// names. The V3 step's USDC and its amount were shown instead.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape and SETTLE_ALL, then a V3 step, does not show the V3 step's input",
"a V4 step of only a $action in the newer shape, then a V3 step, does not show the V3 step's input",
({ id, newer }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[
encodeV4Swap(new Uint8Array([id, V4_SETTLE_ALL_ID]), [
newer,
encodeV4SettleAll(WETH_ADDR, ONE_WETH),
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
encodeV3SwapExactIn(
USER_ADDR,
990000n,
@@ -458,10 +502,7 @@ describe("uniswap decoder", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
9999999999n,
@@ -1124,6 +1165,419 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
});
// https://git.eeqj.de/sneak/AutistMask/issues/563 — a V4 step of several
// swap actions. The screen showed the first action's output as Token Out.
// Every step below pays USDT and receives USDC, so it reads Swap USDT →
// USDC whichever kind of swap actions it holds and in whatever order they
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
// sides to the swap actions alone.
const V4_STEP_ENDINGS = [
{
ending: "SETTLE_ALL and TAKE_ALL",
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
params: [
encodeV4SettleAll(USDT_ADDR, 1000000000n),
encodeV4TakeAll(USDC_ADDR, 990000000n),
],
},
{
ending: "SETTLE and TAKE",
ids: [V4_SETTLE_ID, V4_TAKE_ID],
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
},
{
ending: "CLOSE_CURRENCY",
ids: [
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
],
params: [
encodeV4CloseCurrency(USDT_ADDR),
encodeV4CloseCurrency(WETH_ADDR),
encodeV4CloseCurrency(USDC_ADDR),
],
},
];
// 1,000 USDT for at least 0.5 WETH
const USDT_FOR_WETH_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
1000000000n,
500000000000000000n,
),
};
// 0.5 WETH for at least 990 USDC
const WETH_FOR_USDC_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
500000000000000000n,
990000000n,
),
};
// exactly 0.5 WETH for at most 1,000 USDT
const USDT_FOR_WETH_OUT = {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
500000000000000000n,
1000000000n,
),
};
// exactly 990 USDC for at most 0.5 WETH
const WETH_FOR_USDC_OUT = {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
990000000n,
500000000000000000n,
),
};
// 400 USDT for at least 396 USDC
const USDT_FOR_USDC_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
400000000n,
396000000n,
),
};
// 600 USDT through WETH for at least 594 USDC
const USDT_FOR_USDC_THROUGH_WETH_IN = {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
USDT_ADDR,
[WETH_ADDR, USDC_ADDR],
600000000n,
594000000n,
),
};
// the open delta of USDT through WETH for at least 594 USDC
const USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN = {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
USDT_ADDR,
[WETH_ADDR, USDC_ADDR],
0n,
594000000n,
),
};
// Amount is the amountIn of the action that spends USDT, or the sum when
// several exact-in actions each state one. Min. received is the same for
// the actions that buy USDC. The decoder reads no amount from an exact-out
// action, and an open delta is not a stated amount.
const V4_STEPS = [
{
step: "USDT for WETH exact-in, then WETH for USDC exact-in",
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "WETH for USDC exact-in, then USDT for WETH exact-in",
swaps: [WETH_FOR_USDC_IN, USDT_FOR_WETH_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "WETH for USDC exact-out, then USDT for WETH exact-out",
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_OUT],
amount: undefined,
min: undefined,
},
{
step: "USDT for WETH exact-out, then WETH for USDC exact-out",
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_OUT],
amount: undefined,
min: undefined,
},
{
step: "USDT for WETH exact-in, then WETH for USDC exact-out",
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_OUT],
amount: "1000.0000 USDT",
min: undefined,
},
{
step: "WETH for USDC exact-out, then USDT for WETH exact-in",
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_IN],
amount: "1000.0000 USDT",
min: undefined,
},
{
step: "USDT for WETH exact-out, then WETH for USDC exact-in",
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_IN],
amount: undefined,
min: "990.0000 USDC",
},
{
step: "USDT for USDC, then USDT for USDC through WETH, both exact-in",
swaps: [USDT_FOR_USDC_IN, USDT_FOR_USDC_THROUGH_WETH_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "USDT for USDC, then the open delta of USDT for USDC through WETH, both exact-in",
swaps: [USDT_FOR_USDC_IN, USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN],
amount: undefined,
min: "990.0000 USDC",
},
];
test.each(
V4_STEPS.flatMap((step) =>
V4_STEP_ENDINGS.map((ending) => ({ ...step, ...ending })),
),
)(
"a V4 step of $step, then $ending, reads Swap USDT → USDC",
({ swaps, ids, params, amount, min }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([...swaps.map((s) => s.id), ...ids]),
[...swaps.map((s) => s.param), ...params],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")?.value).toBe(amount);
expect(detail(result, "Min. received")?.value).toBe(min);
},
);
// A step with a swap action that could not be read is named only by its
// SETTLE_ALL, TAKE_ALL, SETTLE and TAKE, and states no figure: its readable
// USDT for WETH action names neither side. The unread action is
// V4_SWAP_ACTION_SHAPES[0].newer, WETH for USDC exact-in in the newer
// shape.
test.each([
{
...V4_STEP_ENDINGS[0],
tokenIn: `USDT (${USDT_ADDR})`,
tokenOut: `USDC (${USDC_ADDR})`,
},
{
...V4_STEP_ENDINGS[1],
tokenIn: `USDT (${USDT_ADDR})`,
tokenOut: `USDC (${USDC_ADDR})`,
},
{
...V4_STEP_ENDINGS[2],
tokenIn: "Unknown (could not be decoded)",
tokenOut: "Unknown (could not be decoded)",
},
])(
"a V4 step of a readable swap action and an unread one, then $ending, shows no figure",
({ ids, params, tokenIn, tokenOut }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
USDT_FOR_WETH_IN.id,
V4_SWAP_ACTION_SHAPES[0].id,
...ids,
]),
[
USDT_FOR_WETH_IN.param,
V4_SWAP_ACTION_SHAPES[0].newer,
...params,
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe(tokenIn);
expect(detail(result, "Token Out").value).toBe(tokenOut);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// A V4 step that spends WETH on USDC and on DAI. Its swap actions buy two
// currencies that none of them spends, and CLOSE_CURRENCY, which the
// decoder does not read, names no side, so its output side is unknown.
const WETH_FOR_USDC_AND_DAI = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
]),
[
// 0.25 WETH for at least 495 USDC
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
250000000000000000n,
495000000n,
),
// 0.25 WETH for at least 495 DAI
encodeV4ExactInSingle(
WETH_ADDR,
DAI_ADDR,
250000000000000000n,
495000000000000000000n,
),
encodeV4CloseCurrency(WETH_ADDR),
encodeV4CloseCurrency(USDC_ADDR),
encodeV4CloseCurrency(DAI_ADDR),
],
);
// The user ends with USDC and DAI, not the V3 step's WETH. That WETH and
// the V3 step's Min. received figure were shown instead.
test("a V3 step, then a V4 step whose swap actions buy two currencies, does not show the V3 step's output", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(
USER_ADDR,
1000000000n,
500000000000000000n,
[USDT_ADDR, WETH_ADDR],
),
WETH_FOR_USDC_AND_DAI,
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. received")).toBeUndefined();
});
// The input side of the same gap: the V4 step's swap actions spend USDC
// and DAI, two currencies that none of them buys, and nothing names its
// input side. The V3 step's WETH and its amount were shown instead.
test("a V4 step whose swap actions spend two currencies, then a V3 step, does not show the V3 step's input", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
]),
[
// 500 USDC for at least 0.25 WETH
encodeV4ExactInSingle(
USDC_ADDR,
WETH_ADDR,
500000000n,
250000000000000000n,
),
// 500 DAI for at least 0.25 WETH
encodeV4ExactInSingle(
DAI_ADDR,
WETH_ADDR,
500000000000000000000n,
250000000000000000n,
),
encodeV4CloseCurrency(USDC_ADDR),
encodeV4CloseCurrency(DAI_ADDR),
encodeV4CloseCurrency(WETH_ADDR),
],
),
encodeV3SwapExactIn(
USER_ADDR,
500000000000000000n,
990000000n,
[WETH_ADDR, USDT_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Min. received").value).toBe("990.0000 USDT");
});
// UNWRAP_WETH makes Token Out ETH when no step set the output side. A V4
// step with a swap action did set it, to unknown here.
test("an UNWRAP_WETH after a V4 step whose swap actions buy two currencies does not make Token Out ETH", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
WETH_FOR_USDC_AND_DAI,
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
});
// The router works through an exact-out path from its last entry back to
// its first: the WETH entry buys the USDC, then the USDT entry buys that
// WETH. The user pays the first entry's USDT, not the last entry's WETH.
test("a two-hop V4 exact-out action shows its first path entry as Token In", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(new Uint8Array([0x09]), [
// SWAP_EXACT_OUT: exactly 990 USDC for at most 1,000 USDT
coder.encode(
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
[
[
USDC_ADDR,
[
[USDT_ADDR, 100, 1, ZERO_ADDR, "0x"],
[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"],
],
990000000n,
1000000000n,
],
],
),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
});
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2
// exact-in reads an amountIn of zero as universal-router
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and