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
This commit was merged in pull request #615.
This commit is contained in:
2026-10-19 16:27:41 +02:00
parent c99772a520
commit 3db066413e
4 changed files with 176 additions and 38 deletions
+34 -10
View File
@@ -388,7 +388,13 @@ function decodePermit2(input) {
}
}
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes.
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes. The router passes the
// check when `owner` holds at least `minBalance` of `token`, and reads `owner`
// as written, without map(), on every release that has the command:
// - UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/base/Dispatcher.sol
// - UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol
// - UniversalRouterV2_1_1 and UniversalRouterV2_1_2, the same file at both
// commits: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/base/Dispatcher.sol
// ABI: (address owner, address token, uint256 minBalance)
function decodeBalanceCheck(input) {
try {
@@ -1087,15 +1093,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]);
// 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 step stated. A
// check the router lets fail guarantees nothing either, so
// it is read as a zero one. Any other minBalance sets the
// output side as a swap does. It pays nobody.
const minBalance = b && !allowedToFail ? b.minBalance : 0n;
if (b && !(minBalance === 0n && present(minOutput))) {
setOutput(b.token, minBalance);
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
// any address but the sender's says nothing about
// what the user receives: it sets no side, and the
// step says so.
commandNames[i] += " (of another address)";
} 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
// step stated. A check the router lets fail guarantees
// nothing either, so it is read as a zero one. Any
// other minBalance sets the output side as a swap
// does. It pays nobody.
const minBalance = allowedToFail ? 0n : b.minBalance;
if (!(minBalance === 0n && present(minOutput))) {
setOutput(b.token, minBalance);
}
}
} else if (cmdId === 0x00) {
const s = decodeV3SwapExactIn(inputs[i]);