1 Commits
Author SHA1 Message Date
sneak d921bb871b fix: a zero-minimum balance check never changes the output side (closes #649)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A BALANCE_CHECK_ERC20 of the sender with a zero minimum guarantees
nothing. It replaced the output side whenever that side held no
minimum, so after an UNWRAP_WETH that drops the minimum, a zero check
of USDC named USDC as Token Out. Such a check now leaves alone an
output side that an earlier step set. It still sets the output side
when no earlier step did. A check with a positive minimum is unchanged.

Model: opus-5-5
2026-10-27 06:57:57 +00:00
4 changed files with 40 additions and 69 deletions

No files matched your search

+21 -18
View File
@@ -1277,18 +1277,21 @@ see Transaction Decoding), do not always read as a number. They can read:
step pays nothing itself and swaps whatever an earlier step sent to the pair, step pays nothing itself and swaps whatever an earlier step sent to the pair,
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
such meaning and is shown as a zero. such meaning and is shown as a zero.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when it shows - `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
a V2, V3 or V4 swap's minimum of zero. The router passes a minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step whenever the balance is at least `minBalance`, so a `BALANCE_CHECK_ERC20` step's `minBalance`. Before
zero `minBalance` guarantees nothing. A check with a zero `minBalance` sets [#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
neither `Token Out` nor `Min. received`. A nonzero `minBalance` sets both read `0.0000` when the token's scale was known. The router passes a balance
lines, as a swap step does. Only a check of the transaction's sender's own check whenever the balance is at least `minBalance`, so a zero `minBalance`
balance sets either line; a check of any other address sets neither (see guarantees nothing. A check with a zero `minBalance` sets `Token Out` and
Transaction Decoding). A `BALANCE_CHECK_ERC20` step that the router lets fail `Min. received` only when no earlier step set the output side. A nonzero
without reverting the transaction (see Transaction Decoding) guarantees `minBalance` sets both lines, as a swap step does. Only a check of the
nothing either, whatever its `minBalance`, and is read as one whose transaction's sender's own balance sets either line; a check of any other
`minBalance` is zero. A minimum stated by a V4 swap step the router lets fail address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
is likewise read as zero. that the router lets fail without reverting the transaction (see Transaction
Decoding) guarantees nothing either, whatever its `minBalance`, and is read as
one whose `minBalance` is zero. A minimum stated by a V4 swap step the router
lets fail is likewise read as zero.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -3394,12 +3397,12 @@ Currently supported:
such as one whose owner word has a nonzero top byte (the router reads the such as one whose owner word has a nonzero top byte (the router reads the
owner from the word's last 20 bytes), likewise sets neither, and it is listed owner from the word's last 20 bytes), likewise sets neither, and it is listed
as `Balance Check (could not be decoded)`. A check with a zero `minBalance` as `Balance Check (could not be decoded)`. A check with a zero `minBalance`
sets neither `Token Out` nor the minimum. When a swap step, `TAKE` or never changes an output side that an earlier step set. When a swap step,
`TAKE_PORTION` pays the router any token, the swap's output or another, a `TAKE` or `TAKE_PORTION` pays the router any token, the swap's output or
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step another, a `WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns
paid it into ETH, that token stays in the router unless a later step pays out WETH a step paid it into ETH, that token stays in the router unless a later
or spends all the router holds of it: a `SWEEP` of that token to anyone but step pays out or spends all the router holds of it: a `SWEEP` of that token to
the router, an `UNWRAP_WETH` when it is WETH, a `WRAP_ETH` of anyone but the router, an `UNWRAP_WETH` when it is WETH, a `WRAP_ETH` of
`CONTRACT_BALANCE` when it is ETH, a `TRANSFER` of `CONTRACT_BALANCE`, a `CONTRACT_BALANCE` when it is ETH, a `TRANSFER` of `CONTRACT_BALANCE`, a
`PAY_PORTION` of 10,000 bips or a `PAY_PORTION_FULL_PRECISION` of 1e18 of that `PAY_PORTION` of 10,000 bips or a `PAY_PORTION_FULL_PRECISION` of 1e18 of that
token to anyone but the router, an `ACROSS_V4_DEPOSIT_V3` of token to anyone but the router, an `ACROSS_V4_DEPOSIT_V3` of
+7 -6
View File
@@ -1629,13 +1629,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else { } else {
// The router passes this check whenever the owner // The router passes this check whenever the owner
// holds at least minBalance, so a zero one guarantees // holds at least minBalance, so a zero one guarantees
// nothing. A check the router lets fail guarantees // nothing. A check with a zero minBalance leaves
// nothing either, so it is read as a zero one. A zero // alone an output side that an earlier step set,
// check sets neither Token Out nor the minimum. Any // token included. A check the router lets fail
// other minBalance sets the output side as a swap // guarantees nothing either, so it is read as a zero
// does. The check pays nobody. // one. Any other minBalance sets the output side as a
// swap does. The check pays nobody.
const minBalance = allowedToFail ? 0n : b.minBalance; const minBalance = allowedToFail ? 0n : b.minBalance;
if (minBalance !== 0n) { if (!(minBalance === 0n && outputEstablished)) {
setOutput(i, b.token, minBalance, []); setOutput(i, b.token, minBalance, []);
} }
keepsRouterWeth = true; keepsRouterWeth = true;
+7 -13
View File
@@ -4380,9 +4380,10 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("2.0000 USDC"); expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
}); });
// A zero balance check sets neither Token Out nor the minimum. The route // With no minimum stated before it, a zero balance check is what sets the
// pays only the WRAP_ETH's WETH, to the router, where it stays. // output side, and it guarantees nothing. No step pays anyone, so the
test("a zero balance check after a WRAP_ETH into the router names no output token and no minimum", () => { // minimum is not said to be received.
test("a zero balance check with no earlier minimum states no minimum", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
[ [
@@ -4395,16 +4396,9 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toContain("USDT");
"Unknown (not named in the calldata)", expect(detail(result, "Min. paid out").value).toBe(
); "None (no minimum guaranteed)",
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,
); );
}); });
+5 -32
View File
@@ -716,34 +716,6 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
]); ]);
}); });
// 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. // 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", () => { 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( const result = decode(
@@ -2876,16 +2848,17 @@ describe("the allow-revert flag", () => {
}, },
); );
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, names no output token and no minimum", () => { test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
const check = balanceCheck(WETH, ONE_WETH); const check = balanceCheck(WETH, ONE_WETH);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe( expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH", "1.0000 WETH",
); );
const withFlag = decode(execute(flagged(check))); const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").value).toBe(UNNAMED); expect(detail(withFlag, "Token Out").address).toBe(WETH);
expect(detail(withFlag, "Min. paid out")).toBeUndefined(); expect(detail(withFlag, "Min. paid out").value).toBe(
expect(detail(withFlag, "Min. received")).toBeUndefined(); "None (no minimum guaranteed)",
);
}); });
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail. // Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.