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
This commit is contained in:
2026-10-27 06:57:57 +00:00
parent fc6cffcaba
commit d921bb871b
3 changed files with 44 additions and 15 deletions
+9 -9
View File
@@ -1283,9 +1283,8 @@ see Transaction Decoding), do not always read as a number. They can read:
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance` [#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
read `0.0000` when the token's scale was known. The router passes a balance read `0.0000` when the token's scale was known. The router passes a balance
check whenever the balance is at least `minBalance`, so a zero `minBalance` check whenever the balance is at least `minBalance`, so a zero `minBalance`
guarantees nothing: it sets `Token Out` and `Min. received` only when the guarantees nothing. A check with a zero `minBalance` sets `Token Out` and
output side holds no minimum, not even a zero one, at the point the check is `Min. received` only when no earlier step set the output side. A nonzero
reached, and otherwise leaves the current token and figure in place. A nonzero
`minBalance` sets both lines, as a swap step does. Only a check of the `minBalance` sets both lines, as a swap step does. Only a check of the
transaction's sender's own balance sets either line; a check of any other transaction's sender's own balance sets either line; a check of any other
address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
@@ -3397,12 +3396,13 @@ Currently supported:
`Balance Check (of another address)`. A check whose input does not decode, `Balance Check (of another address)`. A check whose input does not decode,
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)`. When a swap step, `TAKE` or as `Balance Check (could not be decoded)`. A check with a zero `minBalance`
`TAKE_PORTION` pays the router any token, the swap's output or another, a never changes an output side that an earlier step set. When a swap step,
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step `TAKE` or `TAKE_PORTION` pays the router any token, the swap's output or
paid it into ETH, that token stays in the router unless a later step pays out another, a `WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns
or spends all the router holds of it: a `SWEEP` of that token to anyone but WETH a step paid it into ETH, that token stays in the router unless a later
the router, an `UNWRAP_WETH` when it is WETH, a `WRAP_ETH` of step pays out or spends all the router holds of it: a `SWEEP` of that token to
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 and does not replace a minimum an earlier // nothing. A check with a zero minBalance leaves
// step stated. A check the router lets fail guarantees // alone an output side that an earlier step set,
// nothing either, so it is read as a zero one. 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. It 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 && present(minOutput))) { if (!(minBalance === 0n && outputEstablished)) {
setOutput(i, b.token, minBalance, []); setOutput(i, b.token, minBalance, []);
} }
keepsRouterWeth = true; keepsRouterWeth = true;
+28
View File
@@ -688,6 +688,34 @@ 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 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(