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"
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.
`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
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 next2026-08-23 18:13:20 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.
Currencyis an address, and native ETH isaddress(0).Uniswap/v4-core,src/types/Currency.sol:type Currency is address;, withCurrency public constant ADDRESS_ZERO = Currency.wrap(address(0));andisAddressZero()comparing against it. So native ETH has a value in theencoding; it is not an absence.
Currencyis ABI-encoded as a plain address word. A user-defined valuetype over
addresshas theaddressABI encoding — one 32-byte word. EveryV4 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.solconfirms the action IDsthis file decodes:
SWAP_EXACT_IN_SINGLE = 0x06,SWAP_EXACT_IN = 0x07,SETTLE = 0x0b,TAKE = 0x0e.42-character hex string for an
addressword; foraddress(0)it returns thetruthy
"0x0000000000000000000000000000000000000000", whichtokenInfo()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.jsdecodes areal 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 zeroaddress, not as null.
Conclusion: undetermined.
decodeV4Swap()returns null fortokenOutonlywhen no sub-action yielded a currency at all — a
paramsblob that did notdecode, no
TAKE, or anExactInputParamswith an emptypath. The two ways aswap genuinely outputs ETH are both already handled without null: the zero
address (named ETH by
tokenInfo()), andUNWRAP_WETH, caught byhasUnwrapWethbeforetokenInfo()is consulted. So the conservative branchand the correct branch coincide here.
The change
src/shared/uniswap.jsonly:outputTokenis no longer passed totokenInfo(). It resolves to{symbol: null, decimals: null, address: null}, soMin. receivedtakes thebase-unit refusal from
#340 rather than being scaled at 18,
and the swap title falls back to
Uniswap Swaprather than naming ETH.Token Outgains a third branch readingUnknown (not named in the calldata). Without it the line would go missingentirely for this population, which is the failure
#346 fixed — a
Min. receivedfigure with nothing saying what is being received. It carries no
addressandno
isToken: a refusal is not a token.if (v4.tokenOut)gate now also clears a stale token. A V4 stepthat supersedes an earlier step's
Min. receivedwhile naming no currencypreviously 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 thedecode path itself are untouched.
Fail-first evidence
tests/uniswapUndeterminedTokenOut.test.jsadded, thensrc/shared/uniswap.jsstashed back to head (bd0a626) andmake testrun.Five of the eight tests failed:
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
ETHat 18 decimals, and the rawcoder.decodeof a zero-addressCurrencyis asserted truthy) so it cannotsilently change.
Verification
make checkgreen on this branch, rebased ontonextatbd0a626:Test Suites: 52 passed, 52 total,Tests: 938 passed, 938 total,check-censored: 176 tracked file(s) inspected. Lint ran in the pinnedcontainer and executed rather than replaying cache —
#11 [lint 1/1] RUN make lint/$ eslint . && prettier --check ./#11 DONE 5.7son the run following the source edit. No containers or imagesleft 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 nullinputTokenyieldsToken In: ETH (native), andWRAP_ETHalready sets the explicit zero address, so null there is likewiseundetermined. Out of scope here; reported separately rather than widened into
this PR.
`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.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 → ETHby execution,make checkgreen in an independent clone with the lint stage executing (#11 [lint 1/1] RUN make lint...DONE 5.3s, notCACHED), one commit, basenext, no Claude/Anthropic references.Anomalies (not defects, disclosed rather than omitted):
execute()(e.g.PERMIT2_PERMITalone) previously renderedToken Out: ETHwith titleSwap USDT → ETH; it now rendersUnknown (not named in the calldata)with titleUniswap 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.address/isToken. None are counted as fail-first coverage, so the claim is not inflated — the label is just imprecise.