fix: a zero-minimum balance check never sets the output side (closes #649)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A BALANCE_CHECK_ERC20 of the sender with a zero minimum guarantees
nothing, and neither does one the router lets fail. Such a check set
Token Out and the minimum whenever the output side held no minimum.
After an UNWRAP_WETH that drops the minimum, a zero check of USDC
named USDC as Token Out. Such a check now sets neither line. A check
with a positive minimum is unchanged.

Model: opus-5-5
This commit is contained in:
2026-10-27 07:32:53 +00:00
parent fc6cffcaba
commit 7c456df556
4 changed files with 92 additions and 34 deletions
+13 -7
View File
@@ -4380,10 +4380,9 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
});
// With no minimum stated before it, a zero balance check is what sets the
// output side, and it guarantees nothing. No step pays anyone, so the
// minimum is not said to be received.
test("a zero balance check with no earlier minimum states no minimum", () => {
// A zero balance check sets neither Token Out nor the minimum. The route
// pays only the WRAP_ETH's WETH, to the router, where it stays.
test("a zero balance check after a WRAP_ETH into the router names no output token and no minimum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
[
@@ -4396,9 +4395,16 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDT");
expect(detail(result, "Min. paid out").value).toBe(
"None (no minimum guaranteed)",
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. paid out")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Paid to").value).toBe(
"The router, where it stays (no later step pays it out)",
);
expect(detail(result, "Token moved into the router").address).toBe(
WETH_ADDR,
);
});
+60 -5
View File
@@ -688,6 +688,62 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
]);
});
// The same route, then a balance check of the sender's USDC with a zero
// minimum. The check guarantees nothing, so Token Out stays ETH
// (https://git.eeqj.de/sneak/AutistMask/issues/649).
test("the same route, then a zero balance check of USDC, still reads as ETH with no minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, MSG_SENDER, (ONE_WETH * 6n) / 10n),
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
balanceCheck(USDC, 0n),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Token Out").value).toBe("ETH");
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Steps",
"Deadline",
]);
});
// A PERMIT2_TRANSFER_FROM moves the sender's WETH into the router. An
// UNWRAP_WETH pays that WETH to the user as ETH. A zero balance check of
// USDC sets neither Token Out nor the minimum, before the UNWRAP_WETH or
// after it (https://git.eeqj.de/sneak/AutistMask/issues/649).
test.each([
{
kind: "before the UNWRAP_WETH",
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
},
{
kind: "after the UNWRAP_WETH",
steps: [unwrapWeth(MSG_SENDER), balanceCheck(USDC, 0n)],
},
])(
"WETH moved into the router and unwrapped, with a zero balance check of USDC $kind, reads as ETH with no minimum",
({ steps }) => {
const result = decode(
execute(
permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH),
...steps,
),
);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// A TAKE the decoder cannot read may pay part of the WETH to the user.
test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => {
const result = decode(
@@ -2820,17 +2876,16 @@ describe("the allow-revert flag", () => {
},
);
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, names no output token and no minimum", () => {
const check = balanceCheck(WETH, ONE_WETH);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH",
);
const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").address).toBe(WETH);
expect(detail(withFlag, "Min. paid out").value).toBe(
"None (no minimum guaranteed)",
);
expect(detail(withFlag, "Token Out").value).toBe(UNNAMED);
expect(detail(withFlag, "Min. paid out")).toBeUndefined();
expect(detail(withFlag, "Min. received")).toBeUndefined();
});
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.