fix: a V4 step that spends some of its output currency shows no Min. received (closes #574) #667

Merged
clawbot merged 1 commits from issue-574-v4-spent-output-min into next 2026-10-21 07:34:27 +02:00
4 changed files with 106 additions and 10 deletions
+5 -1
View File
@@ -1243,7 +1243,11 @@ only when no earlier step set it. It finds each side by three rules:
is their sum; in any other case the step gives no `Amount`. The decoder reads
no amount from an exact-out action, and an open delta is not a stated amount.
Its `Min. received` is found the same way, from the `amountOutMin` of the
actions that buy the output currency.
actions that buy the output currency, except that the step gives none when a
swap action in it also spends the output currency, because the step then does
not pay out all that those actions buy. A step that buys at least 0.5 WETH
with USDT and swaps 0.3 WETH of it for USDC, with WETH as its output side,
gives no `Min. received`.
3. If any swap action in the step could not be read, only `SETTLE`,
`SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no
`Amount` or `Min. received`. A side they did not name is unknown, and the
+5 -1
View File
@@ -953,7 +953,9 @@ function totalAmount(amounts) {
// 2. Amount is the amountIn of the swap action that spends Token In. If
// several exact-in actions spend it, each with a stated amount, it is
// their sum; in any other case there is none. Min. received is the same
// for amountOutMin and the actions that buy Token Out.
// for amountOutMin and the actions that buy Token Out, except that there
// is none when a swap action in the step also spends Token Out: the step
// then does not pay out all that those actions buy.
// 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL,
// TAKE and TAKE_ALL name the sides, and there is no Amount or Min.
// received.
@@ -1187,12 +1189,14 @@ function decodeV4Swap(input) {
const boughtOnly = currenciesNotIn(bought, spent);
if (present(takeToken) || boughtOnly.length === 1) {
tokenOut = present(takeToken) ? takeToken : boughtOnly[0];
if (!spent.includes(tokenOut)) {
amountOutMin = totalAmount(
swaps
.filter((s) => s.tokenOut === tokenOut)
.map((s) => s.amountOutMin),
);
}
}
return {
tokenIn,
+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