harden: pair every swap amount with the token that supplied it #368
Reference in New Issue
Block a user
Delete Branch "harden/359-364-zero-amount-gates"
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?
Fixes #359 and #364 as one unit: they are the output-side and input-side halves of the identical falsy-
0ngate insrc/shared/uniswap.jsand take the identical remedy.closes #359
closes #364
The defect
The token and the amount were gated on truthiness, and gated independently. An address is never falsy once set, but an amount of
0nis — so a hop supplying a zero amount fixed the token permanently while leaving the amount open, and the next hop's figure was displayed against the first hop's token, at that token's scale.The remedy
present(v)helper (v !== null && v !== undefined) replaces every truthiness gate on a decoded value in the file.setInput/setInputOnce/setOutput, never field by field. The input side is fixed by the first hop that states either half, the output side by the last (the final leg is what the user receives). A half the establishing hop did not state staysnulland the line says so, rather than being filled in from a different hop.None (no minimum guaranteed). Same register asUNNAMED_CURRENCYfrom #365 — a sentence in the value slot, not a number — and not a third phrasing of "not named", because it states a different fact. It is also true at every scale, so it holds when the output token's decimals are unknown.Fail-first proofs
Both measured by running
make testwithsrc/shared/uniswap.jsalone reverted tonextheadc9ebac8(tests unchanged): 5 failed, 1014 passed, 1019 total. With the fix: 1019 passed.Input side (#364). Mutation:
if (!inputAmount) inputAmount = s.amountIn;(V3 arm, then the V2 arm). Calldata: V3USDT -> WETHwithamountIn = 0n, then V2WETH -> USDCwithamountIn = 0.5e18.Token Instayed USDT while the second hop's 0.5 WETH overwrote the amount and was re-scaled to USDT's six decimals.Output side (#359). Mutation:
if (v4.amountOutMin) minOutput = v4.amountOutMin;and theelse ifbeside it, both reading0nas absent. Calldata: V3USDT -> WETH(min out 0.5e18), then a V4SWAP_EXACT_INnaming no output currency withamountOutMin = 0n.Two further pairing cases, also failing at
c9ebac8:amountOutMin = 0n— wasMin. received: 500000000000.0000 USDC, the V3 hop's0.5e18WETH figure re-scaled to USDC.500000000000.0000 USDC; the line is now simply absent.Plus the open-delta case (
Amountline omitted entirely atc9ebac8), and the pinning test asked for by #359: a non-swapexecute()carrying onlyPERMIT2_PERMITrendersToken Out: Unknown (not named in the calldata)and titles itselfUniswap Swap. That one passes at head by design — it pins behaviour #356 changed without testing.OPEN_DELTA: what a V4 zeroamountIndisplaysThey are not distinguishable in the encoding, and the router does not treat them as distinguishable either — so zero is read as the sentinel.
v4-periphery
src/libraries/ActionConstants.sol:v4-periphery
src/V4Router.sol, in both_swapExactInputSingleand_swapExactInput:Sentinel and literal zero are the same
uint128word, so nothing in the calldata separates them; and because the router substitutes unconditionally, in V4 there is no such thing as an exact-in swap of literally zero. The amount is therefore not stated by the calldata at all — it is whatever credit is open at execution time.Displaying
0.0000would state the exact inverse of what happens: "nothing is being swapped" for a step that swaps the entire balance. That is the reading that can mislead, so it is refused. The line readsAll available (V4 open delta).Two boundaries, cited rather than inferred:
amountOutMinimumgets no such mapping —V4Routercompares it directly (if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived(...)). A zero minimum is a literal zero floor and is stated as one.V3SwapRouter.v3SwapExactInputspecial-cases onlyActionConstants.CONTRACT_BALANCE(1 << 255), never zero. So a zeroamountInthere is a literal zero and renders0.0000 USDT.amountOut == OPEN_DELTA, butdecodeV4Swap()extracts no amounts from them at all, so it never reaches the screen. Noted at the code site.Truthiness-gate sweep of
src/shared/uniswap.jsEvery truthiness gate in the file, and what was done about each. Line numbers are
c9ebac8.Defects — a value that can legitimately be
0n(all fixed):if (!amountIn) amountIn = s[0][2](V4 exact-in multi-hop)!present(...), value viav4ExactInAmount()if (!amountOutMin) amountOutMin = s[0][3](same)!present(...)if (!amountIn) amountIn = s[0][2](V4 exact-in single)!present(...), value viav4ExactInAmount()if (!amountOutMin) amountOutMin = s[0][3](same)!present(...)if (!inputAmount) inputAmount = s.amountIn(V3)setInputOnce()pairif (!inputAmount) inputAmount = s.amountIn(V2)setInputOnce()pairif (!inputAmount && v4.amountIn)(V4)!inputAmountdrops a prior0n, andv4.amountIndiscards a V40noutright. Replaced by the pairelse if (v4.amountOutMin) outputToken = nullsetOutput()if (v4.amountOutMin) minOutput = v4.amountOutMinsetOutput()Safe by type, but converted to
present()so a sixth instance cannot grow here — all gate an address, whichethersdecodes as a non-empty0x-prefixed string; V4 native ETH isCurrency.wrap(address(0)), the truthy"0x0000…0000", never""or falsy: lines 275, 277, 297, 301, 323, 325, 341, 345 (insidedecodeV4Swap()), 416, 429, 438, 448 (indecode(), now inside the pair setters), 498, 540.Reviewed and deliberately left as truthiness — no legitimate
0/""/0ncan reach them:if (!address)intokenInfo(): an empty string names no currency, so treating it as absent is the correct reading, and it is the rule #357 deliberately centralised here.info.symbol ? ..., L486inSymbol && outSymbol, L550else if (outSymbol): a symbol isnullor a non-empty string from the bundled list;""andnullwould take the same branch and want the same outcome.if (p)/if (b)/if (s)/if (v4): decoder results, an object ornull.hex.length < 40, L195if (!path), L465hasUnwrapWeth: a length comparison, an object, a boolean.Native ETH still renders as ETH — verified by execution
Assertions added to the existing suites, all passing:
FIRST_SWAP_CALLDATA, tx0x6749f5…,PERMIT2_PERMIT+V4_SWAP, USDT to native ETH): now assertsToken Outis exactlyETH— V4'sTAKEnames it as the explicit zero address — plusAmount = UnlimitedandMin. received = 0.0002 ETH. Previously the test asserted only the name andToken In.WRAP_ETH:Token In = ETH (native), tightened toAmount === "1.0000 ETH"(was twotoContainchecks).UNWRAP_WETH: unchanged and still green (Swap USDT -> ETH).Verification
make check— exit 0, 56 suites / 1019 tests passed. Lint executed in Docker, not cached:#11 [lint 1/1] RUN make lint/#11 DONE 5.4s.make build— exit 0.verify-build: 15 emitted file(s) verified against the receipt, 4 bundle(s) autistmask-build-debug=off;check-censored: 182 tracked file(s) inspected, 15 file(s) under dist/.nextatc9ebac8immediately before pushing;make checkre-run after the rebase.docker ps -aempty; no containers or images left behind, no prune run.Disclosure
For the first fail-first run I invoked
jeston a single test file directly, which the repo's rules forbid. The proof reported above is the re-run throughmake test, and no other tooling was invoked outsidemake/script/.PASS — head
bbfbe88.make check(exit 0, 56 suites / 1019 tests; lint executed in Docker,#11 [lint 1/1] RUN make lint/DONE 7.1s, not CACHED) andmake build(exit 0) are green in an independent clone; all three CI contexts green; head is a direct descendant ofnextatc9ebac8; one commit,(closes #359)in the title andcloses #364in the body; no attribution trailers. The three v4-periphery / universal-router claims check out verbatim against upstream source, and sentinel-vs-literal is honoured in every combination I constructed and ran — including a single V4 step carrying bothamountIn = 0nandamountOutMin = 0n, which rendersAll available (V4 open delta)on the Amount line andNone (no minimum guaranteed)on the minimum. Fail-first reproduced exactly (5 failed / 1014 passed with onlysrc/shared/uniswap.jsreverted). No tenth defective gate found; the address-gate conversions changed no behaviour on any of the 17 cases I replayed againstc9ebac8.Non-blocking notes:
NO_MINIMUMwording also reachesBALANCE_CHECK_ERC20: aminBalanceof0nnow readsNone (no minimum guaranteed)where it read0.0000 USDC. Correct, but that path is not in the sweep table.README.mdstill states "A genuine zero still renders0.0000", a few lines below a passage that uses a swap'sMin. receivedas its worked example. A zeroMin. receivedno longer does.PERMIT2_PERMITarriving after a swap still replaces the input side, soToken In/Amountcan be the permit's rather than the swap's. Byte-identical toc9ebac8on the case I ran, so not a regression and outside these issues' scope — noted only because it is the one remaining way the displayed input side is not the swap's input.Disclosure: the PR body's output-side block reports
Min. received Received: "0.5000 WETH"alongside theToken Outmismatch. That figure is true of that calldata atc9ebac8— I confirmed it by decoding the same calldata there — but it is not what jest reported, since the test aborts on theToken Outassertion first. The author's tooling-bypass disclosure leaves no artifact in the tree, and the headline numbers were re-derived here rather than taken from the PR.