fix: a balance check of another address sets no swap minimum (closes #600)
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, not the sender and the router. 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)". A check of the sender keeps the reading it had, including the zero reading for one the router lets fail. Model: opus-5-5
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
// The address a swap's output is paid to
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/594 and
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/599), and the allow-revert flag
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/599), the allow-revert flag
|
||||
// on the steps that pay it
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/602), and the address whose
|
||||
// balance a BALANCE_CHECK_ERC20 checks
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/600).
|
||||
//
|
||||
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
|
||||
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
|
||||
@@ -47,6 +49,8 @@ const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
||||
const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
|
||||
// UniversalRouterV1 on Ethereum Mainnet.
|
||||
const ROUTER_V1 = "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B";
|
||||
// UniversalRouterV1_2_V2Support on Ethereum Mainnet.
|
||||
const ROUTER_V1_2 = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
|
||||
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
const OTHER = "0x7777777777777777777777777777777777777777";
|
||||
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
|
||||
@@ -219,16 +223,20 @@ function unwrapWeth(recipient) {
|
||||
return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])];
|
||||
}
|
||||
|
||||
function balanceCheck(token, minBalance) {
|
||||
// BALANCE_CHECK_ERC20 (0x0e) of `owner`'s balance of `token`.
|
||||
function balanceCheck(token, minBalance, owner = USER) {
|
||||
return [
|
||||
0x0e,
|
||||
coder.encode(
|
||||
["address", "address", "uint256"],
|
||||
[USER, token, minBalance],
|
||||
[owner, token, minBalance],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// `step` with the allow-revert flag set on its command byte.
|
||||
const flagged = ([command, input]) => [command | 0x80, input];
|
||||
|
||||
// `step`, a [command or action, input] pair, with the top byte of the input's
|
||||
// word `index` set. The decoder cannot read that word as an address.
|
||||
function withDirtyWord([id, input], index) {
|
||||
@@ -1383,15 +1391,11 @@ describe("paths whose recipients were untested", () => {
|
||||
// and goes on to the next step only for some commands, none of them a swap or
|
||||
// a payment; on every other command the flag has no effect.
|
||||
describe("the allow-revert flag", () => {
|
||||
// UniversalRouterV1_2_V2Support on Ethereum Mainnet.
|
||||
const ROUTER_V1_2 = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
|
||||
// UniversalRouterV2_1_1 on Ethereum Mainnet.
|
||||
const ROUTER_V2_1_1 = "0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA";
|
||||
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
|
||||
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
|
||||
|
||||
// `step` with the allow-revert flag set on its command byte.
|
||||
const flagged = ([command, input]) => [command | 0x80, input];
|
||||
// `steps` with the flag set on the one at `index`.
|
||||
const flagStep = (steps, index) =>
|
||||
steps.map((s, i) => (i === index ? flagged(s) : s));
|
||||
@@ -1714,6 +1718,86 @@ describe("the allow-revert flag", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/600: on every release that has
|
||||
// BALANCE_CHECK_ERC20, the router checks the balance of the owner as written,
|
||||
// without map(), so address(1) and address(2) there are those addresses, not
|
||||
// the sender and the router. Only a check of the sender's own balance says
|
||||
// anything about what the user receives.
|
||||
describe.each([
|
||||
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
|
||||
{ router: "UniversalRouterV2", address: ROUTER },
|
||||
{ router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2 },
|
||||
])("on $router, the owner of a BALANCE_CHECK_ERC20", ({ address }) => {
|
||||
// A swap of 2,000 USDT for WETH with no minimum, paid to the user.
|
||||
const swap = v3SwapExactIn(MSG_SENDER, USDT_2000, 0n, USDT, WETH);
|
||||
const NO_MINIMUM = "None (no minimum guaranteed)";
|
||||
|
||||
test("the sender: the check sets the minimum, unless the router lets it fail", () => {
|
||||
const check = balanceCheck(WETH, ONE_WETH, USER);
|
||||
|
||||
const plain = decode(execute(swap, check), USER, address);
|
||||
expect(detail(plain, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → Balance Check",
|
||||
);
|
||||
expect(detail(plain, "Min. received").value).toBe("1.0000 WETH");
|
||||
// The sender as the From address gives it, in lower case.
|
||||
expect(
|
||||
decode(execute(swap, check), USER.toLowerCase(), address),
|
||||
).toEqual(plain);
|
||||
|
||||
const withFlag = decode(execute(swap, flagged(check)), USER, address);
|
||||
expect(detail(withFlag, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → Balance Check (allowed to fail)",
|
||||
);
|
||||
expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM);
|
||||
});
|
||||
|
||||
const notTheSender = [
|
||||
{ kind: "address(1)", owner: MSG_SENDER },
|
||||
{ kind: "address(2)", owner: ADDRESS_THIS },
|
||||
{ kind: "another address", owner: OTHER },
|
||||
];
|
||||
|
||||
test.each(notTheSender)(
|
||||
"$kind: after a swap paid to the user, the check sets no minimum and is said to be of another address",
|
||||
({ owner }) => {
|
||||
const check = balanceCheck(WETH, ONE_WETH, owner);
|
||||
|
||||
const plain = decode(execute(swap, check), USER, address);
|
||||
expect(detail(plain, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → Balance Check (of another address)",
|
||||
);
|
||||
expect(paidTo(plain)).toEqual([]);
|
||||
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 (of another address) (allowed to fail)",
|
||||
);
|
||||
expect(paidTo(withFlag)).toEqual([]);
|
||||
expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(notTheSender)(
|
||||
"$kind: a check alone names no output token and states no minimum",
|
||||
({ owner }) => {
|
||||
const check = balanceCheck(WETH, ONE_WETH, owner);
|
||||
|
||||
for (const step of [check, flagged(check)]) {
|
||||
const result = decode(execute(step), USER, address);
|
||||
expect(detail(result, "Token Out").value).toBe(UNNAMED);
|
||||
expect(detail(result, "Min. received")).toBeUndefined();
|
||||
expect(detail(result, "Min. paid out")).toBeUndefined();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The DOM stub both screens are drawn into.
|
||||
function makeElement(id) {
|
||||
const classes = new Set();
|
||||
|
||||
Reference in New Issue
Block a user