harden: pair every swap amount with the token that supplied it (closes #359)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 37s

`src/shared/uniswap.js` gated the token and the amount on truthiness, and
gated them independently. An address is never falsy once set, but an amount
of `0n` is, so a hop supplying a zero amount fixed the token permanently
while leaving the amount open, and the next hop's figure was then displayed
against the first hop's token, at that token's scale.

Input side: a V3 `USDT -> WETH` hop with `amountIn = 0n` followed by a V2
`WETH -> USDC` hop of `0.5e18` rendered `Token In = USDT` with
`Amount = 500000000000.0000 USDT`. Output side: a V3 hop followed by a V4
step with `amountOutMin = 0n` kept `Min. received = 0.5000 WETH` on screen
for a final leg that guarantees nothing.

Both halves are the same gate in the same file and take the same remedy, so
they are one change rather than two statements of one rule.

- One `present()` helper replaces every truthiness gate on a decoded value.
- The input and output sides are each set as a PAIR, never field by field:
  an amount and the token it is counted in always come from the same hop.
  The input side is fixed by the first hop that states either half, the
  output side by the last. A half the establishing hop did not state stays
  null and the line says so.
- A zero slippage floor reads `None (no minimum guaranteed)` rather than
  `0.0000`, which reads as an artifact of the four-decimal rule.
- A V4 `amountIn` of zero is `ActionConstants.OPEN_DELTA` -- v4-periphery's
  `V4Router` substitutes the full open credit for it -- so it reads
  `All available (V4 open delta)`, not `0.0000`, which would have stated the
  exact inverse of what the step does. `amountOutMinimum` gets no such
  mapping and a zero there is a literal floor of zero.

Tests: the two fail-first cases from the issues, two further pairing cases
(a zero minimum against a named output token, and a final leg naming a token
but no minimum), the open-delta amount, and the PERMIT2_PERMIT-only
`execute()` that must invent no output token. Native ETH is pinned on both
sides against the real mainnet fixture and the WRAP_ETH/UNWRAP_WETH paths.

closes #364
This commit is contained in:
2026-08-23 18:34:03 +00:00
parent c9ebac822a
commit bbfbe885cd
3 changed files with 452 additions and 57 deletions

15
TODO.md
View File

@@ -45,6 +45,21 @@ but the review is broader than any of them.
# Completed Steps
- 2026-08-23: A swap amount and the token it is counted in now always come from
the same hop, on both sides of the approval screen
([#359](https://git.eeqj.de/sneak/AutistMask/issues/359) and
[#364](https://git.eeqj.de/sneak/AutistMask/issues/364), the output and input
halves of one gate, fixed as one unit). `src/shared/uniswap.js` gated the
token and the amount on truthiness and independently; an address is never
falsy once set but an amount of `0n` is, so a hop supplying a zero amount
fixed the token and left the amount open, and the next hop's figure was then
rendered against the first hop's token at that token's scale — 0.5 WETH shown
as `500000000000.0000 USDT`, and an earlier hop's `Min. received` shown for a
final leg that guarantees nothing. Both sides are now set as a pair through
explicit presence, a zero slippage floor reads `None (no minimum guaranteed)`,
and V4's `OPEN_DELTA` (an `amountIn` of zero, which `V4Router` reads as "swap
the whole open credit") reads `All available (V4 open delta)` instead of
`0.0000`.
- 2026-08-23: A swap whose input token the calldata never named is said to be
unknown instead of being called ETH
([#357](https://git.eeqj.de/sneak/AutistMask/issues/357)), the twin on the