Compare commits

...
1 Commits
Author SHA1 Message Date
sneak 1bb1d67293 fix: the swap decoder reads a V2 already-paid zero and a zero balance check as the router does (closes #415)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 2s
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 no longer replaces a minimum an
earlier step stated; 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
2026-10-04 20:15:01 +00:00
4 changed files with 164 additions and 17 deletions
+16 -3
View File
@@ -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,21 @@ 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 no
earlier step stated a minimum, and otherwise leaves that step's 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
+9
View File
@@ -45,6 +45,15 @@ 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 leaves that minimum in place. 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
+41 -14
View File
@@ -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) {
+98
View File
@@ -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