1 Commits
Author SHA1 Message Date
sneak 165c748641 fix: a balance check of another address sets no swap minimum (closes #600)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
BALANCE_CHECK_ERC20 takes (owner, token, minBalance), and every Universal
Router release that has it checks the owner's balance as written, without
map(), so address(1) and address(2) there are plain addresses. The decoder
read only the token and the minimum, so a check of another address's balance
read as Min. received. A check whose owner is not the transaction's sender now
sets neither Token Out nor the minimum, and its step reads "Balance Check (of
another address)"; one whose input does not decode reads "Balance Check (could
not be decoded)". A check of the sender keeps its reading, including the zero
reading for one the router lets fail.

Model: opus-5-5
2026-10-19 14:10:00 +00:00
3 changed files with 31 additions and 3 deletions
+4 -1
View File
@@ -3026,7 +3026,10 @@ Currently supported:
`address(1)` and `address(2)` there are addresses too. A check of any address
but the transaction's sender's says nothing about what the user receives: it
sets neither `Token Out` nor the minimum, and it is listed as
`Balance Check (of another address)`. When a swap step, `TAKE` or
`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
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
`TAKE_PORTION` pays the router the swap's output token, the output stays in
the router unless a later step pays all the router holds of it to anyone but
the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is WETH, a
+9 -2
View File
@@ -1093,7 +1093,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]);
if (b && b.owner.toLowerCase() !== sender) {
if (!b) {
// The router still runs a check whose input does not
// decode, such as an owner word with a nonzero top
// byte, of which it reads the last 20 bytes, so the
// owner may be any address. It sets no side, and the
// step says so.
commandNames[i] += " (could not be decoded)";
} else if (b.owner.toLowerCase() !== sender) {
// The router reads the owner as written (see
// decodeBalanceCheck()), so address(1) and address(2)
// are not the sender and the router here. A check of
@@ -1101,7 +1108,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// what the user receives: it sets no side, and the
// step says so.
commandNames[i] += " (of another address)";
} else if (b) {
} else {
// The router passes this check whenever the owner
// holds at least minBalance, so a zero one guarantees
// nothing and does not replace a minimum an earlier
+18
View File
@@ -1796,6 +1796,24 @@ describe.each([
}
},
);
test("an owner word with a nonzero top byte: the check is said not to decode and sets no minimum", () => {
// The router checks the balance of the address in the word's last 20
// bytes, here OTHER's.
const check = withDirtyWord(balanceCheck(WETH, ONE_WETH, OTHER), 0);
const plain = decode(execute(swap, check), USER, address);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check (could not be decoded)",
);
expect(detail(plain, "Min. received").value).toBe(NO_MINIMUM);
const withFlag = decode(execute(swap, flagged(check)), USER, address);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check (could not be decoded) (allowed to fail)",
);
expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM);
});
});
// The DOM stub both screens are drawn into.