fix: a V4 step that spends some of its output currency shows no Min. received (closes #574)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

A V4 step's Min. received was the sum of the minimums of the swap
actions that buy its output currency, even when another swap action in
the same step spends some of that currency. The user then receives
less than that figure. decodeV4Swap() now gives no minimum when a swap
action in the step also spends the output currency, as it already does
for other minimums it cannot state. A multi-hop action counts only the
currency it spends and the one it ends with, so a path through an
intermediate currency is not read as spending it.

Model: opus-5-5
This commit is contained in:
2026-10-21 04:33:18 +00:00
parent 4be625dc06
commit c684386a28
4 changed files with 106 additions and 10 deletions
+82
View File
@@ -3730,6 +3730,88 @@ describe("uniswap decoder", () => {
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/574 — a V4 step that buys
// at least 0.5 WETH with 1,000 USDT, spends some of it on USDC, and takes
// the USDC and then the WETH, so its output side is WETH. Only what is
// left of the 0.5 WETH is sure to reach the user, and the screen read
// Min. received 0.5000 WETH.
test.each([
{
spend: "0.3 WETH for at least 594 USDC",
swap: {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
300000000000000000n,
594000000n,
),
},
},
{
spend: "0.3 WETH through DAI for at least 594 USDC",
swap: {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
WETH_ADDR,
[DAI_ADDR, USDC_ADDR],
300000000000000000n,
594000000n,
),
},
},
{
spend: "at most 0.3 WETH for exactly 594 USDC",
swap: {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
594000000n,
300000000000000000n,
),
},
},
])(
"a V4 step that buys WETH and spends $spend, with WETH as its output side, shows no Min. received",
({ swap }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
USDT_FOR_WETH_IN.id,
swap.id,
V4_SETTLE_ALL_ID,
V4_TAKE_ID,
V4_TAKE_ID,
]),
[
USDT_FOR_WETH_IN.param,
swap.param,
encodeV4SettleAll(USDT_ADDR, 1000000000n),
encodeV4Take(USDC_ADDR),
encodeV4Take(WETH_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
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.
+9 -3
View File
@@ -1332,6 +1332,10 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
// The second swap action spends 1 USDC of what the first buys,
// so the step states no minimum
// (https://git.eeqj.de/sneak/AutistMask/issues/574).
noMin: true,
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 SETTLE of OPEN_DELTA from the router before the swap, which the user settles",
@@ -1432,7 +1436,7 @@ describe("any token left in the router is not received", () => {
),
left: { label: "Token moved into the router", value: "ETH" },
},
])("$kind", ({ data, left, spent, unread }) => {
])("$kind", ({ data, left, spent, unread, noMin }) => {
const result = decode(data);
expect(labels(result)).toEqual([
"Protocol",
@@ -1458,7 +1462,7 @@ describe("any token left in the router is not received", () => {
"Token Out",
"Paid to",
"Token moved into the router",
"Min. paid out",
...(noMin ? [] : ["Min. paid out"]),
"Steps",
"Deadline",
]);
@@ -1474,7 +1478,9 @@ describe("any token left in the router is not received", () => {
}
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Token moved into the router")).toEqual(left);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
expect(detail(result, "Min. paid out")?.value).toBe(
noMin ? undefined : "1990.0000 USDC",
);
});
// Uniswap's own routes: a swap paid in ETH wraps into the router what its