DEV Community

Cover image for Swap: Inside Uniswap V2’s Core Operation
Adi
Adi

Posted on

Swap: Inside Uniswap V2’s Core Operation

Three posts in. We've established what AMMs are and how the constant product formula works, mapped out the contract architecture, and walked through how liquidity gets added to a pool. If you haven't read those, this post will still make sense in isolation, but some things will click harder with the context.

This is the one most people care about. Everything else in Uniswap exists to support this. The swap is what users actually come for, and it's where the x · y = k formula stops being a concept on a whiteboard and starts being enforced on-chain, in real time, with real money.

The full path, same as always: User → Router → Pair → User


What you're actually choosing when you swap

Before getting into the mechanics, there's a design decision at the Router level worth understanding because it shapes everything downstream.

When you want to swap tokens, you have two fundamentally different ways to express what you want:

"I have exactly 100 USDC and I want as much ETH as possible for it." This is swapExactTokensForTokens(). You're fixing the input and accepting whatever output the pool gives you, subject to a minimum floor you set.

"I want exactly 0.05 ETH and I'll pay however much USDC it takes." This is swapTokensForExactTokens(). You're fixing the output and accepting whatever input is required, subject to a maximum ceiling you set.

Router02 contract actually ships several variants of both entry points to handle the different forms tokens can take. There's swapExactETHForTokens() and swapTokensForExactETH() for when one side of the swap is native ETH rather than an ERC-20, since ETH needs to be wrapped into WETH before the pool can handle it, and the Router takes care of that wrapping transparently. There's also swapExactTokensForTokensSupportingFeeOnTransferTokens() and its variants, which handle tokens that take a cut on every transfer (for eg. Safemoon), meaning the amount that actually arrives at the pool is less than what was sent, and a naive exact-input check would fail because the received amount never matches the sent amount. The core logic across all variants is the same.

The mechanics downstream are nearly identical. The difference is in which direction the Router runs the calculation. For an exact-input swap, it calls getAmountsOut() to figure out what you'll receive. For an exact-output swap, it calls getAmountsIn() to figure out what you need to pay. Both live in UniswapV2Library. We'll walk through the core exact-input path in detail since it's the more common one, but the logic mirrors cleanly.


Computing the output: getAmountsOut() and getAmountOut()

Once swapExactTokensForTokens() is called with amountIn, the path array, and the user's amountOutMin floor, the Router's first job is to figure out how much the user will actually receive. It calls getAmountsOut() on UniswapV2Library, which walks the path array and calls getAmountOut() for each consecutive pair of tokens in the route.

getAmountOut() is where the constant product formula actually runs. Given the current reserves of a pool and an input amount, it returns the maximum output the pool can give without violating the invariant.

Here's the intuition before the formula. The pool has reserveIn of the token you're sending and reserveOut of the token you want. The invariant says reserveIn × reserveOut = k. After your swap, the pool has more of your token and less of the other. The new reserves must still satisfy k. So:

(reserveIn+amountIn)×(reserveOutamountOut)=k=reserveIn×reserveOut (reserveIn + amountIn) \times (reserveOut - amountOut) = k = reserveIn \times reserveOut

Solving for amountOut:

amountOut=amountIn×reserveOutreserveIn+amountIn amountOut = \frac{amountIn \times reserveOut}{reserveIn + amountIn}

That's the fee-free version. But Uniswap takes a 0.3% fee on every swap, and it's taken from the input. Only 99.7% of your input actually counts toward moving the pool. In integer math, that's represented as multiplying by 997 and treating the denominator as 1000.

So the effective input that the invariant sees is amountIn × 997 / 1000. Substituting that in:

amountOut=amountIn×997×reserveOutreserveIn×1000+amountIn×997 amountOut = \frac{amountIn \times 997 \times reserveOut}{reserveIn \times 1000 + amountIn \times 997}

This is the exact formula in the contract. The fee doesn't get sent anywhere separately. It just stays in the pool as the difference between the input the user sent and the input the invariant used. Every swap makes the pool ever so slightly larger than k alone would predict, which is exactly the fee accumulation mechanism from the Mint post.

Now look at the denominator: reserveIn × 1000 + amountIn × 997. As amountIn grows, this denominator grows, which means amountOut grows more slowly relative to input. You get diminishing returns the larger your swap is. This is referred to as the price impact, and it's not a bug or a fee. It's a direct mathematical consequence of the constant product rule. The pool's ratio is shifting under your feet as you trade, and you're effectively buying into increasingly worse territory with each additional unit you swap. Small swaps barely move the ratio. Large swaps move it a lot, and the pool charges you for that displacement implicitly through the math.

After getAmountsOut() builds the full amounts[] array across the path, the Router checks that amounts[amounts.length - 1] (the final output) is at least amountOutMin. If the market moved between when the user submitted and when the transaction executes, and the output has dropped below their floor, the transaction reverts here before a single token moves. That's slippage protection.


Multi-hop routing: why tokens don't come back to the Router

If the path has more than two tokens, the swap chains through multiple pools. Say the path is [tokenA, WETH, tokenB]. That's two separate swaps: tokenA into the A/WETH pool, then WETH into the WETH/B pool.

The obvious way to implement this would be: execute the first swap, receive WETH into the Router, then execute the second swap with that WETH. Simple, intuitive, and wrong for two reasons. First, it would require two transfer operations for the intermediate token, adding unnecessary gas. Second, and more importantly, it would mean the Router holds funds between steps, which is exactly the kind of in-between state that creates attack surface, and the Router isn’t designed to hold funds, ever.

Instead, _swap() does something elegant: for every hop except the last, the recipient of the swap is not the user and not the Router. It's the address of the next pool in the path.

So when the first swap executes on the A/WETH pool, the WETH doesn't come back to the Router. It goes directly into the WETH/B pool. The WETH/B pool now has more WETH than its reserves account for. If you read the Mint post, this pattern is familiar: the difference between a pool's actual balance and its last recorded reserve is how the pair contract figures out what just arrived. The second swap then runs against that pool with WETH already sitting inside it.

This works because swap() on the pair contract is a verify-after design. The next pool doesn't need to be explicitly told that WETH arrived. It just needs to have more WETH than it remembers, and the invariant check will confirm whether the amount is sufficient. The tokens flow from pool to pool in a single transaction, never touching the Router in between. The last hop in the path is the only one that sends tokens to the actual user's to address. Everything before it is just pools talking to pools.

In code, _swap() is a loop over the path array. At each iteration i, it determines which token in the pair is token0 and which is token1 (pairs always store tokens in sorted address order, which may not match the order the user passed in the path), then sets amount0Out and amount1Out accordingly — one will be the output amount from amounts[i+1], the other will be zero. For the to address it passes into swap(), it checks whether there's a next hop in the path. If there is, it calls pairFor() on UniswapV2Library to compute the address of the next pool and uses that as to. If this is the final hop, it uses the user's actual to address. That single conditional is the entire routing mechanism — the loop just keeps calling swap() on each pool in sequence, each time telling the pool to send its output directly to whoever needs it next.


Inside the Pair: swap() and the optimistic transfer

With amounts computed and tokens routed, _swap() calls swap(amount0Out, amount1Out, to, data) on the pair contract for each hop. This is where something happens that will make any developer who's read about checks-effects-interactions do a double take.

The pair sends the output tokens to to before checking whether it received enough input.

If you've spent any time in smart contract security, you know the checks-effects-interactions pattern. The rule is: first validate everything (checks), then update your internal state (effects), then make external calls (interactions). The reason is reentrancy. If you make an external call while your state is still in an intermediate condition, a malicious contract can call back into you before you've finished, exploit the inconsistency, and drain you. Sending tokens before verifying payment sounds like exactly the kind of thing that pattern is designed to prevent.

So what's going on?

The key is that the invariant check happens immediately after the transfer, within the same atomic transaction, and there is no exploitable state window in between. The pair also has a lock modifier on swap() that prevents any reentrant calls from succeeding. But the deeper reason the optimistic design is safe here is that the invariant check is a global accounting check, not a per-user check. It doesn't ask "did this specific caller pay?". It asks "is the pool as a whole at least as healthy as it was before this call?"

Here's what the pair actually does in sequence:

  1. Sends output tokens to to.
  2. If the data field is non-empty, calls uniswapV2Call() on to (flash loan, covered in the next post).
  3. Reads the current actual balances of both tokens.
  4. Calculates how much of each token came in during this call.
  5. Runs the K invariant check.

For a regular swap, the input tokens were already transferred to the pair by the Router before swap() was called. So by the time step 3 runs, both the outgoing tokens have been sent and the incoming tokens are sitting in the contract. The balances reflect the full picture of what happened. If someone tried to take output tokens without the Router having sent any input, step 4 would show zero input and step 5 would fail. The transaction reverts, including the output transfer from step 1.

The reason for sending first isn't recklessness. It's that flash swaps need the borrowed tokens delivered to the borrower before the callback runs, and since flash swaps and regular swaps share the same swap() function, the design applies to both. Regular swaps get the same mechanism for free, and it's safe because the invariant check is unconditional.

The invariant check scales everything by 1000 to avoid fractional math, and subtracts the 0.3% fee from the input:

balance0Adjusted=balance0×1000amount0In×3 balance0Adjusted = balance0 \times 1000 - amount0In \times 3
balance1Adjusted=balance1×1000amount1In×3 balance1Adjusted = balance1 \times 1000 - amount1In \times 3

Then verifies:

balance0Adjusted×balance1Adjustedreserve0×reserve1×10002 balance0Adjusted \times balance1Adjusted \geq reserve0 \times reserve1 \times 1000^2

Multiplying by 3 instead of dividing by 1000 keeps everything in integer arithmetic. The right-hand side scales up by $1000^2$ to match. This is the same 0.3% fee logic from getAmountOut(), expressed from the pool's perspective rather than the user's. If the equation holds, the pool is no worse off than before the swap. If it doesn't, someone tried to take more than they paid for, and the whole transaction reverts.

After the check passes, _update() syncs the reserves and updates the price accumulators, exactly as it does in mint(). The Swap event is emitted and the function returns.


The other entry point: swapTokensForExactTokens()

For completeness, the exact-output variant works the same way structurally, just reversed. Instead of getAmountsOut(), the Router calls getAmountsIn(), which walks the path backwards using getAmountIn(). Given the desired output, it calculates the required input at each hop from the final pool back to the first, producing the same amounts[] array. The user sets an amountInMax ceiling instead of an amountOutMin floor, and the Router checks that the required input doesn't exceed it. Everything downstream, the _swap() loop, the per-pool swap() call, the invariant check, is identical.


What this post didn't cover

The data parameter in swap() was mentioned but not fully explained. When data is non-empty, the pair calls uniswapV2Call() on the recipient before the invariant check runs. That's the flash swap mechanism. The optimistic transfer design will make even more sense in that context, which is exactly where we're going next.

Top comments (0)