From 87071bf6ffd63cdd0f8dcc77cb34795546c24664 Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 4 Oct 2026 20:15:01 +0000 Subject: [PATCH] fix: the swap decoder reads a V2 already-paid zero and a zero balance check as the router does (closes #415) A V2 exact-in amountIn of zero is the router's ALREADY_PAID marker: an earlier step sent the tokens to the pair and the swap spends all of them. Amount showed 0.0000 for it; it now reads "Whatever an earlier step sent to the pair (V2 already paid)", in the style of the V4 open delta line. A BALANCE_CHECK_ERC20 passes whenever the balance is at least minBalance, so a zero one guarantees nothing. It now sets the output side only when that side holds no minimum at the point the check is reached; a nonzero one sets the output side as before. README's Display Consistency text and TODO.md are updated to match. Model: opus-5-5 --- README.md | 20 +++++++-- TODO.md | 10 +++++ src/shared/uniswap.js | 55 +++++++++++++++++------- tests/uniswap.test.js | 98 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c824dbf..0e7a0ed 100644 --- a/README.md +++ b/README.md @@ -887,7 +887,9 @@ Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. The rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero minimum, and `Amount` reads `All available (V4 open delta)` when the amount it -shows is a V4 exact-in `amountIn` of zero. +shows is a V4 exact-in `amountIn` of zero and +`Whatever an earlier step sent to the pair (V2 already paid)` when it is a V2 +exact-in `amountIn` of zero. The rule and its exception live in `src/shared/amountDisplay.js` as `truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and @@ -980,7 +982,8 @@ read: exact-out step, whichever step set the line, including the `WRAP_ETH` of a swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure, not necessarily all of it; the wait, success and error screens show it with - the same words. `Unlimited` and `All available (V4 open delta)` keep their + the same words. `Unlimited`, `All available (V4 open delta)` and + `Whatever an earlier step sent to the pair (V2 already paid)` keep their wording. When a V2 exact-out step sets `Min. received`, that line shows its `amountOut`, the exact amount it buys. - `All available (V4 open delta)`: the swap's `Amount` line, when the amount it @@ -991,11 +994,22 @@ read: V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its `amountInMax`, and a V4 swap step the `amountIn` of its first readable exact-in action. +- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's + `Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero. + The router reads that zero as "the pair already holds the input tokens": the + 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 + such meaning and is shown as a zero. - `None (no minimum guaranteed)`: the swap's `Min. received` line, when the minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a `BALANCE_CHECK_ERC20` step's `minBalance`. Before [#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance` - read `0.0000` when the token's scale was known. + 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` + guarantees nothing: it sets `Token Out` and `Min. received` only when the + output side holds no minimum, not even a zero one, at the point the check is + reached, and otherwise leaves the current token and figure in place. A nonzero + `minBalance` sets both lines, as a swap step does. 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 diff --git a/TODO.md b/TODO.md index 6e3158f..dbc4085 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,16 @@ but the review is broader than any of them. # Completed Steps +- 2026-10-04: The swap decoder reads two router zeros the way the router does + ([#415](https://git.eeqj.de/sneak/AutistMask/issues/415)). A V2 exact-in + `amountIn` of zero means an earlier step already sent the tokens to the pair; + `Amount` showed `0.0000` for it and now reads + `Whatever an earlier step sent to the pair (V2 already paid)`. A + `BALANCE_CHECK_ERC20` with a zero `minBalance` guarantees nothing, yet it + replaced the minimum an earlier swap step stated, so `Min. received` read + `None (no minimum guaranteed)`; it now sets the output side only when that + side holds no minimum at the point the check is reached. A nonzero + `minBalance` still sets the output side. - 2026-10-04: The signature screen shows a personal message as the bytes that are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It showed only the decoded text, with bidirectional and zero-width characters diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 7602a00..0f4496d 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -84,17 +84,31 @@ function present(value) { // // `amountOutMinimum` gets no such mapping: V4Router compares it directly // (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so -// a zero minimum is a literal zero slippage floor and is stated as one. Nor do -// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput` +// a zero minimum is a literal zero slippage floor and is stated as one. Nor +// does the V3 path have it — universal-router's `V3SwapRouter.v3SwapExactInput` // special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero — -// so a zero `amountIn` there is a literal zero and is displayed as one. +// so a zero V3 `amountIn` is a literal zero and is displayed as one. The V2 +// exact-in path gives zero a meaning of its own: see ALREADY_PAID. const OPEN_DELTA = Symbol("v4-open-delta"); -// The two amount lines that state a fact instead of a quantity. Same register -// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread -// as a number — and deliberately not a third phrasing of "not named": these -// say different things. +// The Universal Router's V2 exact-in spells "the pair already holds the input +// tokens" as an amount of zero: universal-router +// `contracts/libraries/Constants.sol` declares +// `uint256 internal constant ALREADY_PAID = 0` ("Used for identifying cases +// when a v2 pair has already received input tokens"), and +// `V2SwapRouter.v2SwapExactInput` makes no payment of its own when `amountIn` +// equals it. The swap then spends whatever an earlier step sent to the pair. +// As with OPEN_DELTA, the calldata states no quantity, and "0.0000" would say +// that nothing is swapped. +const ALREADY_PAID = Symbol("v2-already-paid"); + +// The amount lines that state a fact instead of a quantity. Same register as +// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a +// number — and deliberately not another phrasing of "not named": these say +// different things. const OPEN_DELTA_AMOUNT = "All available (V4 open delta)"; +const ALREADY_PAID_AMOUNT = + "Whatever an earlier step sent to the pair (V2 already paid)"; const NO_MINIMUM = "None (no minimum guaranteed)"; // Permit2 amounts are uint160; the maximum is Permit2's "unbounded". @@ -185,6 +199,7 @@ function decodeBalanceCheck(input) { // Decode V2_SWAP_EXACT_IN (command 0x08) input bytes. // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // address[] path, bool payerIsUser) +// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID. function decodeV2SwapExactIn(input) { try { const d = coder.decode( @@ -192,7 +207,7 @@ function decodeV2SwapExactIn(input) { input, ); return { - amountIn: d[1], + amountIn: d[1] === 0n ? ALREADY_PAID : d[1], amountOutMin: d[2], tokenIn: d[3][0], tokenOut: d[3][d[3].length - 1], @@ -502,7 +517,13 @@ function decode(data, toAddress, sources) { if (cmdId === 0x0e) { const b = decodeBalanceCheck(inputs[i]); - if (b) setOutput(b.token, b.minBalance); + // 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. Any + // other minBalance sets the output side as a swap does. + if (b && !(b.minBalance === 0n && present(minOutput))) { + setOutput(b.token, b.minBalance); + } } if (cmdId === 0x00) { @@ -623,14 +644,20 @@ function decode(data, toAddress, sources) { } if (present(inputAmount)) { - // Two amounts need no scale to describe and are named rather than - // formatted: V4's open delta, which is not a quantity at all (see - // OPEN_DELTA), and an unbounded permit. The open-delta test comes - // first — the sentinel is not a bigint and cannot be compared with - // one. + // Three amounts need no scale to describe and are named rather + // than formatted: V4's open delta and V2's already-paid zero, + // neither of which is a quantity at all (see OPEN_DELTA and + // ALREADY_PAID), and an unbounded permit. Those two tests come + // first — the sentinels are not bigints and cannot be compared + // with one. let amount; if (inputAmount === OPEN_DELTA) { amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; + } else if (inputAmount === ALREADY_PAID) { + amount = { + raw: ALREADY_PAID_AMOUNT, + display: ALREADY_PAID_AMOUNT, + }; } else if (inputAmount >= MAX_UINT160) { amount = { raw: "Unlimited", display: "Unlimited" }; } else if (hasV2ExactOut) { diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 206771d..91931cb 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -831,6 +831,104 @@ describe("uniswap decoder", () => { expect(detail(result, "Min. received").value).toBe("0.9900 USDC"); }); + // https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2 + // exact-in reads an amountIn of zero as universal-router + // Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and + // the swap uses all of them. Against 375998b this read "0.0000 USDT". + test("a V2 exact-in already-paid amountIn is named, not printed as zero", () => { + const data = buildExecute( + "0x08", + [ + encodeV2SwapExactIn( + USER_ADDR, + 0n, // Constants.ALREADY_PAID + 500000000000000n, + [USDT_ADDR, WETH_ADDR], + ), + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result).not.toBeNull(); + + expect(detail(result, "Token In").value).toContain("USDT"); + expect(detail(result, "Amount").value).toBe( + "Whatever an earlier step sent to the pair (V2 already paid)", + ); + expect(detail(result, "Amount").rawValue).toBe( + "Whatever an earlier step sent to the pair (V2 already paid)", + ); + expect(detail(result, "Min. received").value).toBe("0.0005 WETH"); + }); + + // https://git.eeqj.de/sneak/AutistMask/issues/415 — the router passes a + // BALANCE_CHECK_ERC20 whenever the balance is at least minBalance, so a + // zero one guarantees nothing. Against 375998b it replaced the swap's + // output side: Token Out = USDC, Min. received = "None (no minimum + // guaranteed)". + test("a zero balance check keeps the minimum a swap step stated", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x08, 0x0e]), + [ + encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [ + USDT_ADDR, + WETH_ADDR, + ]), + encodeBalanceCheck(USER_ADDR, USDC_ADDR, 0n), + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result).not.toBeNull(); + + expect(detail(result, "Token Out").value).toContain("WETH"); + expect(detail(result, "Min. received").value).toBe("0.0005 WETH"); + }); + + // A nonzero balance check still replaces the output side, as before. + test("a nonzero balance check replaces the minimum a swap step stated", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x08, 0x0e]), + [ + encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [ + USDT_ADDR, + WETH_ADDR, + ]), + encodeBalanceCheck(USER_ADDR, USDC_ADDR, 2000000n), + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result).not.toBeNull(); + + expect(detail(result, "Token Out").value).toContain("USDC"); + expect(detail(result, "Min. received").value).toBe("2.0000 USDC"); + }); + + // With no minimum stated before it, a zero balance check is what sets the + // output side, and it guarantees nothing. + test("a zero balance check with no earlier minimum states no minimum", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), + [ + encodeWrapEth(ROUTER_ADDR, 1000000000000000000n), + encodeBalanceCheck(USER_ADDR, USDT_ADDR, 0n), + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result).not.toBeNull(); + + expect(detail(result, "Token Out").value).toContain("USDT"); + expect(detail(result, "Min. received").value).toBe( + "None (no minimum guaranteed)", + ); + }); + // Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without // testing: a non-swap execute() carrying only PERMIT2_PERMIT names no // output currency, so it says so and titles itself "Uniswap Swap" rather