harden: say a swap's output token is unknown rather than calling it ETH (closes #353) #356

Merged
clawbot merged 1 commits from harden/353-v4-undetermined-token-out into next 2026-08-23 18:13:20 +02:00
Collaborator

Closes #353.

The determination: null means undetermined, not native ETH

The investigation the issue asked for, from the encoding rather than from this
repo's code.

  • Currency is an address, and native ETH is address(0).
    Uniswap/v4-core, src/types/Currency.sol: type Currency is address;, with
    Currency public constant ADDRESS_ZERO = Currency.wrap(address(0)); and
    isAddressZero() comparing against it. So native ETH has a value in the
    encoding; it is not an absence.
  • A Currency is ABI-encoded as a plain address word. A user-defined value
    type over address has the address ABI encoding — one 32-byte word. Every
    V4 field this decoder reads is such a word: SETTLE (Currency, uint256, bool),
    TAKE (Currency, address, uint256), ExactInputParams.currencyIn,
    PathKey.intermediateCurrency, PoolKey.currency0/currency1.
    Uniswap/v4-periphery, src/libraries/Actions.sol confirms the action IDs
    this file decodes: SWAP_EXACT_IN_SINGLE = 0x06, SWAP_EXACT_IN = 0x07,
    SETTLE = 0x0b, TAKE = 0x0e.
  • Therefore a decoded currency is never null. ethers' ABI decoder returns a
    42-character hex string for an address word; for address(0) it returns the
    truthy "0x0000000000000000000000000000000000000000", which tokenInfo()
    already maps to ETH. A test asserts exactly this decode, so the claim is
    checked rather than argued.

Empirical corroboration already in the suite: tests/uniswap.test.js decodes a
real mainnet USDT->ETH V4 swap (the repo's first-ever swap) and gets
Swap USDT -> ETH. Its native-ETH output side reaches the decoder as the zero
address, not as null.

Conclusion: undetermined. decodeV4Swap() returns null for tokenOut only
when no sub-action yielded a currency at all — a params blob that did not
decode, no TAKE, or an ExactInputParams with an empty path. The two ways a
swap genuinely outputs ETH are both already handled without null: the zero
address (named ETH by tokenInfo()), and UNWRAP_WETH, caught by
hasUnwrapWeth before tokenInfo() is consulted. So the conservative branch
and the correct branch coincide here.

The change

src/shared/uniswap.js only:

  • A null outputToken is no longer passed to tokenInfo(). It resolves to
    {symbol: null, decimals: null, address: null}, so Min. received takes the
    base-unit refusal from
    #340 rather than being scaled at 18,
    and the swap title falls back to Uniswap Swap rather than naming ETH.
  • Token Out gains a third branch reading
    Unknown (not named in the calldata). Without it the line would go missing
    entirely for this population, which is the failure
    #346 fixed — a Min. received
    figure with nothing saying what is being received. It carries no address and
    no isToken: a refusal is not a token.
  • The V4 arm's if (v4.tokenOut) gate now also clears a stale token. A V4 step
    that supersedes an earlier step's Min. received while naming no currency
    previously left the earlier hop's token on screen, so the new figure was read
    against the wrong token. Same two lines, same defect; disclosed here because
    it is one case wider than the issue's literal text.

resolveTokenDecimals(), the display helpers, the bundled token list and the
decode path itself are untouched.

Fail-first evidence

tests/uniswapUndeterminedTokenOut.test.js added, then
src/shared/uniswap.js stashed back to head (bd0a626) and make test run.
Five of the eight tests failed:

● says the output token is unknown instead of naming ETH
    Expected: "Unknown (not named in the calldata)"
    Received: "ETH"
● refuses to scale Min. received rather than assuming 18
    Expected: "1234567 base units (decimals unknown)"
    Received: "0.000000000001 ETH"
● does not name ETH in the swap title either
    Expected: "Uniswap Swap"
    Received: "Swap USDT → ETH"
● does not keep naming the earlier step's output token
    Expected: "Unknown (not named in the calldata)"
    Received: "WETH (0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2)"
● refuses to scale the superseding figure
    Expected: "1234567 base units (decimals unknown)"
    Received: "0.000000000001 WETH"

Test Suites: 1 failed, 51 passed / Tests: 5 failed, 933 passed.

The other three tests pass on both sides by design: they pin the native-ETH
interpretation (zero address stays ETH at 18 decimals, and the raw
coder.decode of a zero-address Currency is asserted truthy) so it cannot
silently change.

Verification

make check green on this branch, rebased onto next at bd0a626:
Test Suites: 52 passed, 52 total, Tests: 938 passed, 938 total,
check-censored: 176 tracked file(s) inspected. 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.7s on the run following the source edit. No containers or images
left behind.

Not established

Nothing about this determination. One adjacent observation, deliberately not
acted on: the same null-means-ETH collapse exists on the input side —
tokenInfo(null) for a null inputToken yields Token In: ETH (native), and
WRAP_ETH already sets the explicit zero address, so null there is likewise
undetermined. Out of scope here; reported separately rather than widened into
this PR.

Closes https://git.eeqj.de/sneak/AutistMask/issues/353. ## The determination: null means undetermined, not native ETH The investigation the issue asked for, from the encoding rather than from this repo's code. - **`Currency` is an address, and native ETH is `address(0)`.** `Uniswap/v4-core`, `src/types/Currency.sol`: `type Currency is address;`, with `Currency public constant ADDRESS_ZERO = Currency.wrap(address(0));` and `isAddressZero()` comparing against it. So native ETH has a value in the encoding; it is not an absence. - **A `Currency` is ABI-encoded as a plain address word.** A user-defined value type over `address` has the `address` ABI encoding — one 32-byte word. Every V4 field this decoder reads is such a word: `SETTLE (Currency, uint256, bool)`, `TAKE (Currency, address, uint256)`, `ExactInputParams.currencyIn`, `PathKey.intermediateCurrency`, `PoolKey.currency0/currency1`. `Uniswap/v4-periphery`, `src/libraries/Actions.sol` confirms the action IDs this file decodes: `SWAP_EXACT_IN_SINGLE = 0x06`, `SWAP_EXACT_IN = 0x07`, `SETTLE = 0x0b`, `TAKE = 0x0e`. - **Therefore a decoded currency is never null.** ethers' ABI decoder returns a 42-character hex string for an `address` word; for `address(0)` it returns the truthy `"0x0000000000000000000000000000000000000000"`, which `tokenInfo()` already maps to ETH. A test asserts exactly this decode, so the claim is checked rather than argued. Empirical corroboration already in the suite: `tests/uniswap.test.js` decodes a real mainnet USDT->ETH V4 swap (the repo's first-ever swap) and gets `Swap USDT -> ETH`. Its native-ETH output side reaches the decoder as the zero address, not as null. **Conclusion: undetermined.** `decodeV4Swap()` returns null for `tokenOut` only when no sub-action yielded a currency at all — a `params` blob that did not decode, no `TAKE`, or an `ExactInputParams` with an empty `path`. The two ways a swap genuinely outputs ETH are both already handled without null: the zero address (named ETH by `tokenInfo()`), and `UNWRAP_WETH`, caught by `hasUnwrapWeth` before `tokenInfo()` is consulted. So the conservative branch and the correct branch coincide here. ## The change `src/shared/uniswap.js` only: - A null `outputToken` is no longer passed to `tokenInfo()`. It resolves to `{symbol: null, decimals: null, address: null}`, so `Min. received` takes the base-unit refusal from https://git.eeqj.de/sneak/AutistMask/issues/340 rather than being scaled at 18, and the swap title falls back to `Uniswap Swap` rather than naming ETH. - `Token Out` gains a third branch reading `Unknown (not named in the calldata)`. Without it the line would go missing entirely for this population, which is the failure https://git.eeqj.de/sneak/AutistMask/issues/346 fixed — a `Min. received` figure with nothing saying what is being received. It carries no `address` and no `isToken`: a refusal is not a token. - The V4 arm's `if (v4.tokenOut)` gate now also clears a stale token. A V4 step that supersedes an earlier step's `Min. received` while naming no currency previously left the earlier hop's token on screen, so the new figure was read against the wrong token. Same two lines, same defect; disclosed here because it is one case wider than the issue's literal text. `resolveTokenDecimals()`, the display helpers, the bundled token list and the decode path itself are untouched. ## Fail-first evidence `tests/uniswapUndeterminedTokenOut.test.js` added, then `src/shared/uniswap.js` stashed back to head (`bd0a626`) and `make test` run. Five of the eight tests failed: ``` ● says the output token is unknown instead of naming ETH Expected: "Unknown (not named in the calldata)" Received: "ETH" ● refuses to scale Min. received rather than assuming 18 Expected: "1234567 base units (decimals unknown)" Received: "0.000000000001 ETH" ● does not name ETH in the swap title either Expected: "Uniswap Swap" Received: "Swap USDT → ETH" ● does not keep naming the earlier step's output token Expected: "Unknown (not named in the calldata)" Received: "WETH (0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2)" ● refuses to scale the superseding figure Expected: "1234567 base units (decimals unknown)" Received: "0.000000000001 WETH" ``` `Test Suites: 1 failed, 51 passed` / `Tests: 5 failed, 933 passed`. The other three tests pass on both sides by design: they pin the native-ETH interpretation (zero address stays `ETH` at 18 decimals, and the raw `coder.decode` of a zero-address `Currency` is asserted truthy) so it cannot silently change. ## Verification `make check` green on this branch, rebased onto `next` at `bd0a626`: `Test Suites: 52 passed, 52 total`, `Tests: 938 passed, 938 total`, `check-censored: 176 tracked file(s) inspected`. 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.7s` on the run following the source edit. No containers or images left behind. ## Not established Nothing about this determination. One adjacent observation, deliberately not acted on: the same null-means-ETH collapse exists on the **input** side — `tokenInfo(null)` for a null `inputToken` yields `Token In: ETH (native)`, and `WRAP_ETH` already sets the explicit zero address, so null there is likewise undetermined. Out of scope here; reported separately rather than widened into this PR.
clawbot added the needs-review label 2026-08-23 18:06:41 +02:00
clawbot added 1 commit 2026-08-23 18:06:41 +02:00
harden: say a swap's output token is unknown rather than calling it ETH (closes #353)
All checks were successful
check / check (push) Successful in 32s
e2e / e2e-chrome (push) Successful in 1m44s
e2e / e2e-firefox (push) Successful in 30s
4415551323
`tokenInfo(null)` answers `{symbol: "ETH", decimals: 18}`, and the V4 arm of
`decode()` could take a step's `amountOutMin` while leaving `outputToken`
null, because the token assignment was gated on `if (v4.tokenOut)`. The
approval screen then named ETH as the output and formatted `Min. received` at
18 decimals for a swap whose output currency the calldata never stated.

Null is not how V4 spells native ETH. v4-core declares `type Currency is
address` and wraps `address(0)` for native ETH, and a `Currency` is
ABI-encoded as a plain address word, so every decode site here gets back the
truthy string `0x0000000000000000000000000000000000000000` for it — which
`tokenInfo()` already names ETH — and an UNWRAP_WETH output is caught
separately. Nothing that genuinely outputs ETH arrives as null; only a step
whose output currency did not decode does.

So a null output token is now treated as undetermined: `Token Out` reads
`Unknown (not named in the calldata)` and `Min. received` falls to the
base-unit refusal from
#340 instead of being scaled at 18.
A V4 step that supersedes an earlier step's `Min. received` without naming a
currency also clears the earlier step's token, so that figure is never read
against the token a previous hop named.
clawbot self-assigned this 2026-08-23 18:06:44 +02:00
Author
Collaborator

PASS — #353 satisfied; determination independently confirmed, fail-first reproduced verbatim (5 failed / 938 passed), real mainnet USDT->ETH V4 fixture still renders Token Out: ETH / Min. received: 0.0002 ETH / Swap USDT → ETH by execution, make check green in an independent clone with the lint stage executing (#11 [lint 1/1] RUN make lint ... DONE 5.3s, not CACHED), one commit, base next, no Claude/Anthropic references.

Anomalies (not defects, disclosed rather than omitted):

  • The change touches a third population beyond the two disclosed in the PR body: a non-swap execute() (e.g. PERMIT2_PERMIT alone) previously rendered Token Out: ETH with title Swap USDT → ETH; it now renders Unknown (not named in the calldata) with title Uniswap Swap. Verified by execution on both sides. Strictly more truthful, so accepted, but it is a behaviour change on transactions that have no output token at all and is untested.
  • PR body says "the other three tests pass on both sides ... they pin the native-ETH interpretation". Only two do; the third ("keeps a Token Out line, so the figure is never unattributed") pins the line's presence and its lack of address/isToken. None are counted as fail-first coverage, so the claim is not inflated — the label is just imprecise.
  • Reviewer method disclosure: an untracked probe test file was added to a private clone and removed again to obtain the execution evidence above. No tracked file was modified and nothing was committed.
PASS — https://git.eeqj.de/sneak/AutistMask/issues/353 satisfied; determination independently confirmed, fail-first reproduced verbatim (5 failed / 938 passed), real mainnet USDT->ETH V4 fixture still renders `Token Out: ETH` / `Min. received: 0.0002 ETH` / `Swap USDT → ETH` by execution, `make check` green in an independent clone with the lint stage executing (`#11 [lint 1/1] RUN make lint` ... `DONE 5.3s`, not `CACHED`), one commit, base `next`, no Claude/Anthropic references. Anomalies (not defects, disclosed rather than omitted): - The change touches a third population beyond the two disclosed in the PR body: a non-swap `execute()` (e.g. `PERMIT2_PERMIT` alone) previously rendered `Token Out: ETH` with title `Swap USDT → ETH`; it now renders `Unknown (not named in the calldata)` with title `Uniswap Swap`. Verified by execution on both sides. Strictly more truthful, so accepted, but it is a behaviour change on transactions that have no output token at all and is untested. - PR body says "the other three tests pass on both sides ... they pin the native-ETH interpretation". Only two do; the third ("keeps a Token Out line, so the figure is never unattributed") pins the line's presence and its lack of `address`/`isToken`. None are counted as fail-first coverage, so the claim is not inflated — the label is just imprecise. - Reviewer method disclosure: an untracked probe test file was added to a private clone and removed again to obtain the execution evidence above. No tracked file was modified and nothing was committed.
clawbot merged commit 28a527295a into next 2026-08-23 18:13:20 +02:00
clawbot deleted branch harden/353-v4-undetermined-token-out 2026-08-23 18:13:21 +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#356