harden: say a swap's input token is unknown rather than calling it ETH (closes #357) #365

Merged
clawbot merged 1 commits from harden/357-undetermined-input-token into next 2026-08-23 20:23:05 +02:00
Collaborator

Closes #357. The twin on the input side of #356, whose approach, wording and test structure this mirrors.

The determination, sanity-checked on the input side

The determination is #353's and is not re-derived: Currency is a user-defined value type over address (v4-core type Currency is address), so it carries the plain address ABI encoding and ethers returns the truthy string 0x0000000000000000000000000000000000000000 for native ETH — never null.

Checked that it holds on the input side, where the sub-actions differ from the output side's. Every path that can set inputToken yields a truthy address or nothing at all:

  • PERMIT2_PERMIT (0x0a) — token is an address word.
  • V3_SWAP_EXACT_IN (0x00) — tokenIn is sliced out of the packed path, "0x" + 40 hex.
  • V2_SWAP_EXACT_IN (0x08) — path[0], an address word.
  • WRAP_ETH (0x0b) — the decoder itself supplies the explicit zero address.
  • V4_SWAP (0x10) — settleToken, from SETTLE's Currency, ExactInputParams.currencyIn, or a PoolKey currency. All address words.

So a null inputToken means the calldata named no input currency at all, exactly as on the output side. Asserted rather than argued: the new test decodes a zero-address Currency and checks it is truthy.

The change

src/shared/uniswap.js only.

The collapse is removed from tokenInfo() itself rather than guarded at its call sites. tokenInfo(null) now returns {symbol: null, decimals: null, address: null}; the zero address keeps returning ETH at 18 decimals. This is a deliberate departure from #356's shape and is disclosed as such: that PR guarded the output call site with a ternary and left tokenInfo()'s !address || address === ZERO intact for the input side's benefit. With both sides refusing, a per-call-site guard would have meant two statements of one rule in one file, and would have left the trap itself alive for the next caller. The output side's ternary is therefore removed, since tokenInfo() now answers it.

Consequences on the screen:

  • Token In gains a refusal branch, Unknown (not named in the calldata) — the same string the Token Out branch uses, now a shared UNNAMED_CURRENCY constant so the two sides cannot drift. The line is kept rather than dropped, which is the failure #346 fixed. It carries no address and no isToken: a refusal is not a token.
  • The swap title falls back to Uniswap Swap instead of Swap ETH -> X.

The Amount line's base-unit refusal from #340 is now reachable for the input side through decimals: null, but is not reachable in practice today: every arm above sets inputToken and inputAmount together, so an amount without a named input currency does not occur. Stated rather than tested, because no calldata produces it.

decodeV4Swap(), resolveTokenDecimals(), the bundled token list and the decode path are untouched.

Fail-first evidence

tests/uniswapUndeterminedTokenIn.test.js added, then src/shared/uniswap.js stashed back to head (ad6aa7b) and make test run. Three of the seven tests failed:

● an execute() whose input currency never decoded ›
  says the input token is unknown instead of naming ETH
    Expected: "Unknown (not named in the calldata)"
    Received: "ETH (native)"
● an execute() whose input currency never decoded ›
  does not name ETH in the swap title either
    Expected: "Uniswap Swap"
    Received: "Swap ETH → USDC"
● an execute() that names only an output token ›
  refuses the input rather than defaulting it to ETH
    Expected: "Unknown (not named in the calldata)"
    Received: "ETH (native)"

Test Suites: 1 failed, 55 passed / Tests: 3 failed, 1010 passed.

The other four pass on both sides by design: they pin the native-ETH interpretation so it cannot silently change.

Native ETH in still renders as ETH — executed, not read

Both spellings are pinned in the new file and pass:

  • A V4 SETTLE of Currency.wrap(address(0))Token In: ETH (native), Amount: 1.0000 ETH, title Swap ETH → USDT.
  • WRAP_ETHToken In: ETH (native), Amount: 1.0000 ETH.

And the repo's real mainnet fixtures in tests/uniswap.test.js were run, not read: that suite passes in full on this branch, including decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP) (the 2026-02-27 mainnet USDT->ETH swap: Swap USDT → ETH, Token In = USDT) and decodes WRAP_ETH as ETH input (asserting ETH (native)). Disclosure on the limit of that evidence: the repo has no mainnet fixture whose input is native ETH — the one real swap pays USDT — so the input-side native-ETH pins are the synthetic pair above.

The stale-value hazard: present, but not this defect

Checked by execution, as asked. The literal two-line mirror of #356 — a step supplying an amount while naming no token — cannot fire on the input side. In decodeV4Swap() the currency and the amount are assigned in the same try, currency first, so v4.amountIn is non-null only when v4.tokenIn is; every other arm sets inputToken and inputAmount together too. There is nothing there to fix.

A stale-value hazard does exist, through a different door, and was measured:

V3 USDT -> WETH with amountIn = 0, then V2 WETH -> USDC with amountIn = 0.5e18
  Token In = USDT (0xdac17f...)
  Amount   = 500000000000.0000 USDT

The mechanism is the falsy-0n collapse, not a missing token: an address is never falsy once set, but if (!inputAmount) treats a zero amount as unset, so a later hop's figure lands against an earlier hop's token. That is the input-side counterpart of #359, and its remedy is #359's remedy — gate on explicit presence rather than truthiness — applied to the input side. This unit was scoped away from that, so it is not fixed here; filed as #364 so it is not lost when this squashes. Fixing it would also not be a step toward it: this change concerns an undetermined token, that one a determined-but-wrong token.

#359 itself is untouched, as instructed, and this change does not make it any nearer.

Verification

make check green on this branch, rebased onto next at ad6aa7b (a no-op rebase; next had not moved): Test Suites: 56 passed, 56 total, Tests: 1013 passed, 1013 total, test-verify-build: 46 case(s) passed, check-censored: 182 tracked file(s) inspected. make build exit 0, dist/chrome/ and dist/firefox/ verified against the build's own receipt with autistmask-build-debug=off on all four bundles.

Lint ran in the pinned container and executed rather than replaying cache — #11 [lint 1/1] RUN make lint / $ eslint . && prettier --check . / #11 DONE 5.5s. Disclosure: the post-rebase re-run reported #11 CACHED, correctly, because the rebase was a no-op and the tree was byte-identical to the run that executed. No containers or images left behind (docker ps -a clean of anything of mine; the lint build uses --output=type=cacheonly and exports no image). No prune of any kind was run.

Closes https://git.eeqj.de/sneak/AutistMask/issues/357. The twin on the input side of https://git.eeqj.de/sneak/AutistMask/pulls/356, whose approach, wording and test structure this mirrors. ## The determination, sanity-checked on the input side The determination is https://git.eeqj.de/sneak/AutistMask/issues/353's and is not re-derived: `Currency` is a user-defined value type over `address` (v4-core `type Currency is address`), so it carries the plain `address` ABI encoding and ethers returns the truthy string `0x0000000000000000000000000000000000000000` for native ETH — never null. Checked that it holds on the input side, where the sub-actions differ from the output side's. Every path that can set `inputToken` yields a truthy address or nothing at all: - `PERMIT2_PERMIT` (0x0a) — `token` is an `address` word. - `V3_SWAP_EXACT_IN` (0x00) — `tokenIn` is sliced out of the packed path, `"0x" + 40 hex`. - `V2_SWAP_EXACT_IN` (0x08) — `path[0]`, an `address` word. - `WRAP_ETH` (0x0b) — the decoder itself supplies the explicit zero address. - `V4_SWAP` (0x10) — `settleToken`, from `SETTLE`'s `Currency`, `ExactInputParams.currencyIn`, or a `PoolKey` currency. All `address` words. So a null `inputToken` means the calldata named no input currency at all, exactly as on the output side. Asserted rather than argued: the new test decodes a zero-address `Currency` and checks it is truthy. ## The change `src/shared/uniswap.js` only. The collapse is removed from `tokenInfo()` itself rather than guarded at its call sites. `tokenInfo(null)` now returns `{symbol: null, decimals: null, address: null}`; the zero address keeps returning ETH at 18 decimals. This is a deliberate departure from https://git.eeqj.de/sneak/AutistMask/pulls/356's shape and is disclosed as such: that PR guarded the output call site with a ternary and left `tokenInfo()`'s `!address || address === ZERO` intact for the input side's benefit. With both sides refusing, a per-call-site guard would have meant two statements of one rule in one file, and would have left the trap itself alive for the next caller. The output side's ternary is therefore removed, since `tokenInfo()` now answers it. Consequences on the screen: - `Token In` gains a refusal branch, `Unknown (not named in the calldata)` — the same string the `Token Out` branch uses, now a shared `UNNAMED_CURRENCY` constant so the two sides cannot drift. The line is kept rather than dropped, which is the failure https://git.eeqj.de/sneak/AutistMask/issues/346 fixed. It carries no `address` and no `isToken`: a refusal is not a token. - The swap title falls back to `Uniswap Swap` instead of `Swap ETH -> X`. The `Amount` line's base-unit refusal from https://git.eeqj.de/sneak/AutistMask/issues/340 is now reachable for the input side through `decimals: null`, but is not reachable in practice today: every arm above sets `inputToken` and `inputAmount` together, so an amount without a named input currency does not occur. Stated rather than tested, because no calldata produces it. `decodeV4Swap()`, `resolveTokenDecimals()`, the bundled token list and the decode path are untouched. ## Fail-first evidence `tests/uniswapUndeterminedTokenIn.test.js` added, then `src/shared/uniswap.js` stashed back to head (`ad6aa7b`) and `make test` run. Three of the seven tests failed: ``` ● an execute() whose input currency never decoded › says the input token is unknown instead of naming ETH Expected: "Unknown (not named in the calldata)" Received: "ETH (native)" ● an execute() whose input currency never decoded › does not name ETH in the swap title either Expected: "Uniswap Swap" Received: "Swap ETH → USDC" ● an execute() that names only an output token › refuses the input rather than defaulting it to ETH Expected: "Unknown (not named in the calldata)" Received: "ETH (native)" ``` `Test Suites: 1 failed, 55 passed` / `Tests: 3 failed, 1010 passed`. The other four pass on both sides by design: they pin the native-ETH interpretation so it cannot silently change. ## Native ETH in still renders as ETH — executed, not read Both spellings are pinned in the new file and pass: - A V4 `SETTLE` of `Currency.wrap(address(0))` — `Token In: ETH (native)`, `Amount: 1.0000 ETH`, title `Swap ETH → USDT`. - `WRAP_ETH` — `Token In: ETH (native)`, `Amount: 1.0000 ETH`. And the repo's real mainnet fixtures in `tests/uniswap.test.js` were run, not read: that suite passes in full on this branch, including `decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)` (the 2026-02-27 mainnet USDT->ETH swap: `Swap USDT → ETH`, `Token In` = USDT) and `decodes WRAP_ETH as ETH input` (asserting `ETH (native)`). Disclosure on the limit of that evidence: the repo has no mainnet fixture whose *input* is native ETH — the one real swap pays USDT — so the input-side native-ETH pins are the synthetic pair above. ## The stale-value hazard: present, but not this defect Checked by execution, as asked. The literal two-line mirror of https://git.eeqj.de/sneak/AutistMask/pulls/356 — a step supplying an amount while naming no token — **cannot fire on the input side**. In `decodeV4Swap()` the currency and the amount are assigned in the same `try`, currency first, so `v4.amountIn` is non-null only when `v4.tokenIn` is; every other arm sets `inputToken` and `inputAmount` together too. There is nothing there to fix. A stale-value hazard does exist, through a different door, and was measured: ``` V3 USDT -> WETH with amountIn = 0, then V2 WETH -> USDC with amountIn = 0.5e18 Token In = USDT (0xdac17f...) Amount = 500000000000.0000 USDT ``` The mechanism is the falsy-`0n` collapse, not a missing token: an address is never falsy once set, but `if (!inputAmount)` treats a zero amount as unset, so a later hop's figure lands against an earlier hop's token. That is the input-side counterpart of https://git.eeqj.de/sneak/AutistMask/issues/359, and its remedy is #359's remedy — gate on explicit presence rather than truthiness — applied to the input side. This unit was scoped away from that, so it is not fixed here; filed as https://git.eeqj.de/sneak/AutistMask/issues/364 so it is not lost when this squashes. Fixing it would also not be a step toward it: this change concerns an *undetermined* token, that one a determined-but-wrong token. https://git.eeqj.de/sneak/AutistMask/issues/359 itself is untouched, as instructed, and this change does not make it any nearer. ## Verification `make check` green on this branch, rebased onto `next` at `ad6aa7b` (a no-op rebase; `next` had not moved): `Test Suites: 56 passed, 56 total`, `Tests: 1013 passed, 1013 total`, `test-verify-build: 46 case(s) passed`, `check-censored: 182 tracked file(s) inspected`. `make build` exit 0, `dist/chrome/` and `dist/firefox/` verified against the build's own receipt with `autistmask-build-debug=off` on all four bundles. Lint ran in the pinned container and executed rather than replaying cache — `#11 [lint 1/1] RUN make lint` / `$ eslint . && prettier --check .` / `#11 DONE 5.5s`. Disclosure: the post-rebase re-run reported `#11 CACHED`, correctly, because the rebase was a no-op and the tree was byte-identical to the run that executed. No containers or images left behind (`docker ps -a` clean of anything of mine; the lint build uses `--output=type=cacheonly` and exports no image). No prune of any kind was run.
clawbot added the needs-review label 2026-08-23 20:15:45 +02:00
clawbot added 1 commit 2026-08-23 20:15:46 +02:00
harden: say a swap's input token is unknown rather than calling it ETH (closes #357)
All checks were successful
check / check (push) Successful in 31s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 33s
6385f4fa42
`tokenInfo(null)` answered `{symbol: "ETH", decimals: 18}`, so a swap whose
calldata never named an input currency rendered `Token In: ETH (native)` and
titled itself `Swap ETH -> X`. The approval screen asserted the user was paying
native ETH when nothing had established it.

Null is not how native ETH arrives. v4-core declares `type Currency is address`
and wraps `address(0)` for it; a user-defined value type over `address` carries
the plain `address` ABI encoding, so a native-ETH currency reaches every decode
site here as the truthy string
`0x0000000000000000000000000000000000000000`, and WRAP_ETH sets that same
explicit zero address. A null token means undetermined, on the input side as on
the output side.

The collapse is removed from `tokenInfo()` itself rather than guarded at each
call site, so both sides of the screen answer the same condition the same way:
null refuses, the zero address is still ETH at 18 decimals. `Token In` gains
the refusal branch `Unknown (not named in the calldata)`, now a shared constant
with the `Token Out` branch it must match.
Author
Collaborator

PASS — independent review at 6385f4f in a fresh clone: no defects found. make check and make build green here (56 suites / 1013 tests, lint executed uncached in the pinned container — #11 [lint 1/1] RUN make lint ... DONE 7.6s with real eslint/prettier output, not CACHED); fail-first reproduced exactly (3 failed / 1010 passed, same three messages); no existing test file touched, so #356 is unedited and its output-side suite plus the real mainnet USDT->ETH V4 fixture pass unchanged; tokenInfo() is module-private with exactly two callers, both in decode(); the two refusal wordings are now one UNNAMED_CURRENCY constant, so they cannot differ.

Disclosures. Independently reproduced #364 by execution (V3 USDT -> WETH amountIn = 0, then V2 WETH -> USDC amountIn = 0.5e18 renders Token In = USDT, Amount = 500000000000.0000 USDT) — real, severe, and byte-identical before and after this change, so neither fixed nor worsened here; #359 is untouched. The author's claim that the v4.amountIn-without-v4.tokenIn mirror cannot fire is verified in the source: amountIn is assigned in only two places (src/shared/uniswap.js:280, :305), each in the same try immediately after the currency. Beyond the PR's synthetic pins I ran two more realistic native-ETH-input encodings — WRAP_ETH + V3_SWAP_EXACT_IN, and a V4 SWAP_EXACT_IN_SINGLE whose PoolKey.currency0 is the zero address with zeroForOne — both still render Token In: ETH (native), Amount: 1.0000 ETH, identical to pre-change output.

PASS — independent review at `6385f4f` in a fresh clone: no defects found. `make check` and `make build` green here (`56 suites / 1013 tests`, lint executed uncached in the pinned container — `#11 [lint 1/1] RUN make lint` ... `DONE 7.6s` with real eslint/prettier output, not `CACHED`); fail-first reproduced exactly (3 failed / 1010 passed, same three messages); no existing test file touched, so https://git.eeqj.de/sneak/AutistMask/pulls/356 is unedited and its output-side suite plus the real mainnet USDT->ETH V4 fixture pass unchanged; `tokenInfo()` is module-private with exactly two callers, both in `decode()`; the two refusal wordings are now one `UNNAMED_CURRENCY` constant, so they cannot differ. Disclosures. Independently reproduced https://git.eeqj.de/sneak/AutistMask/issues/364 by execution (V3 `USDT -> WETH` `amountIn = 0`, then V2 `WETH -> USDC` `amountIn = 0.5e18` renders `Token In = USDT`, `Amount = 500000000000.0000 USDT`) — real, severe, and byte-identical before and after this change, so neither fixed nor worsened here; https://git.eeqj.de/sneak/AutistMask/issues/359 is untouched. The author's claim that the `v4.amountIn`-without-`v4.tokenIn` mirror cannot fire is verified in the source: `amountIn` is assigned in only two places (`src/shared/uniswap.js:280`, `:305`), each in the same `try` immediately after the currency. Beyond the PR's synthetic pins I ran two more realistic native-ETH-input encodings — `WRAP_ETH` + `V3_SWAP_EXACT_IN`, and a V4 `SWAP_EXACT_IN_SINGLE` whose `PoolKey.currency0` is the zero address with `zeroForOne` — both still render `Token In: ETH (native)`, `Amount: 1.0000 ETH`, identical to pre-change output.
clawbot merged commit c9ebac822a into next 2026-08-23 20:23:05 +02:00
clawbot deleted branch harden/357-undetermined-input-token 2026-08-23 20:23:06 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#365