harden: say a swap's input token is unknown rather than calling it ETH (closes #357) #365
Reference in New Issue
Block a user
Delete Branch "harden/357-undetermined-input-token"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:
Currencyis a user-defined value type overaddress(v4-coretype Currency is address), so it carries the plainaddressABI encoding and ethers returns the truthy string0x0000000000000000000000000000000000000000for 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
inputTokenyields a truthy address or nothing at all:PERMIT2_PERMIT(0x0a) —tokenis anaddressword.V3_SWAP_EXACT_IN(0x00) —tokenInis sliced out of the packed path,"0x" + 40 hex.V2_SWAP_EXACT_IN(0x08) —path[0], anaddressword.WRAP_ETH(0x0b) — the decoder itself supplies the explicit zero address.V4_SWAP(0x10) —settleToken, fromSETTLE'sCurrency,ExactInputParams.currencyIn, or aPoolKeycurrency. Alladdresswords.So a null
inputTokenmeans the calldata named no input currency at all, exactly as on the output side. Asserted rather than argued: the new test decodes a zero-addressCurrencyand checks it is truthy.The change
src/shared/uniswap.jsonly.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 lefttokenInfo()'s!address || address === ZEROintact 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, sincetokenInfo()now answers it.Consequences on the screen:
Token Ingains a refusal branch,Unknown (not named in the calldata)— the same string theToken Outbranch uses, now a sharedUNNAMED_CURRENCYconstant so the two sides cannot drift. The line is kept rather than dropped, which is the failure #346 fixed. It carries noaddressand noisToken: a refusal is not a token.Uniswap Swapinstead ofSwap ETH -> X.The
Amountline's base-unit refusal from #340 is now reachable for the input side throughdecimals: null, but is not reachable in practice today: every arm above setsinputTokenandinputAmounttogether, 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.jsadded, thensrc/shared/uniswap.jsstashed back to head (ad6aa7b) andmake testrun. Three of the seven tests failed: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:
SETTLEofCurrency.wrap(address(0))—Token In: ETH (native),Amount: 1.0000 ETH, titleSwap ETH → USDT.WRAP_ETH—Token In: ETH (native),Amount: 1.0000 ETH.And the repo's real mainnet fixtures in
tests/uniswap.test.jswere run, not read: that suite passes in full on this branch, includingdecodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)(the 2026-02-27 mainnet USDT->ETH swap:Swap USDT → ETH,Token In= USDT) anddecodes WRAP_ETH as ETH input(assertingETH (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 sametry, currency first, sov4.amountInis non-null only whenv4.tokenInis; every other arm setsinputTokenandinputAmounttogether too. There is nothing there to fix.A stale-value hazard does exist, through a different door, and was measured:
The mechanism is the falsy-
0ncollapse, not a missing token: an address is never falsy once set, butif (!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 checkgreen on this branch, rebased ontonextatad6aa7b(a no-op rebase;nexthad 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 buildexit 0,dist/chrome/anddist/firefox/verified against the build's own receipt withautistmask-build-debug=offon 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 -aclean of anything of mine; the lint build uses--output=type=cacheonlyand exports no image). No prune of any kind was run.`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.PASS — independent review at
6385f4fin a fresh clone: no defects found.make checkandmake buildgreen here (56 suites / 1013 tests, lint executed uncached in the pinned container —#11 [lint 1/1] RUN make lint...DONE 7.6swith real eslint/prettier output, notCACHED); 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 indecode(); the two refusal wordings are now oneUNNAMED_CURRENCYconstant, so they cannot differ.Disclosures. Independently reproduced #364 by execution (V3
USDT -> WETHamountIn = 0, then V2WETH -> USDCamountIn = 0.5e18rendersToken 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 thev4.amountIn-without-v4.tokenInmirror cannot fire is verified in the source:amountInis assigned in only two places (src/shared/uniswap.js:280,:305), each in the sametryimmediately 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 V4SWAP_EXACT_IN_SINGLEwhosePoolKey.currency0is the zero address withzeroForOne— both still renderToken In: ETH (native),Amount: 1.0000 ETH, identical to pre-change output.