Towards Unlocking the Potential of UniswapV2: the Swap Function
Released back in 2020, UniswapV2 has gained a reputation as a powerful and versatile decentralized exchange, offering a robust, permissionless, and (almost) immutable set of smart contracts that enable passive liquidity provision. Despite its widespread adoption and subsequent V3 release, we believe that models similar to UniswapV2 have much more untapped potential.
In this technical blog post, aimed at readers who have always wanted to better understand the inner workings of UniswapV2 swaps but never found the time, we will delve into the core of the UniswapV2 protocol, examining the swap function of the UniswapV2 Pair contract in detail. By deeply understanding the UniswapV2 mechanics, one can identify ways to improve and optimize the protocol for further innovation in new directions.
UniswapV2 Resources and the Swap Function
Many excellent write-ups already explain how UniswapV2 operates as a whole, such as those from Ori Pomerantz, ro_herrerai, and the official docs. Instead of repeating that information, we will focus narrowly on the swap function itself, looking closely under its hood.
At the heart of UniswapV2's functionality, the swap function is responsible for enabling users to exchange one token for another within a token Pair contract. The intricacies of this function pave the way for various applications and interactions within the decentralized exchange ecosystem.
You can explore the swap function in its original context within the UniswapV2 core repository. For convenience, we also reproduce the complete function code below. Despite consisting of a mere 22 lines of code, excluding comments, the swap function is a remarkably elegant and sophisticated piece of engineering.
The Code
Delving Into the Swap Function
First off, we note that this is not a function that externally owned accounts (EOAs) or even other smart contracts typically interact with directly. Uniswap has a set of periphery that helps users to interact with the token Pair, such as the router.
Function Header and Initial Checks
The swap function has three arguments: amount0Out
, amount1Out
and data
. These amounts pertain to token0
and token1
of the Pair. These tokens are treated on the same footing. In contrast to tokens, there is no symmetry between incoming and outgoing token amounts. The former are computed as the function executes, while the latter are provided by the user as arguments to the function itself. The data
(NB: calldata reference type) argument is passed through to UniswapV2Callee
contracts in case they need extra information, for example, when executing a flashloan. The lock
modifier prevents reentrancy attacks.
The very first check of the function on line 160 makes sure that at least one of the tokens is leaving the Pair contract. If no tokens are moved out, an INSUFFICIENT_OUTPUT_AMOUNT
error is raised. If a user wishes to donate tokens to the Pair and get nothing in return, they may just transfer these tokens, no need to call swap
then. Similarly, to make sure that newly transferred tokens are reflected in the reserves, the user should call the sync
function instead of swap
.
This might also be the right spot to mention that both amountOut arguments may be nonzero. It is perhaps not useful to buy and sell the same token in the same transaction for a typical user, however, the Pair contract does support this and there are around 2000 of such transactions on the mainnet, see this dashboard.
Intermezzo: Reserves
Before proceeding, let us remind ourselves what reserves are. Reserves are a core component of the Pair contract, representing the amounts of token0
and token1
held in the liquidity pool. They play a crucial role in determining the exchange rate for token swaps and serve as a measure of the liquidity available in the pool. Unlike token balances, reserves are not automatically updated when tokens are transferred to or from the pair contract. In particular, it is possible that token balances are higher than reserves at any point in time due to somebody simply sending tokens to the Pair. They are updated when the sync
function is called, which ensures that the reserves are synchronized with the actual token balances. By maintaining an accurate representation of the liquidity pool's state, reserves facilitate a fair and efficient token swapping process in the protocol.
Getting the Reserves
After the output amount check, reserves are obtained by calling the getReserves
function. The dangling comma discards the last update time of the reserves, which is not relevant for the swap. Loading the reserves up into variables saves gas later on due to MLOAD vs SLOAD operations, as you can see in the gas pricelist. Note that reserves are generally not equal to token balances. In fact, this difference between reserves and balances is what makes the swap work. The user is expected to transfer tokens onto the Pair contract before calling the swap function. The Pair detects this difference during the swap execution. This way, the Pair does not rely on token approvals or transferFrom
functions (while the aforementioned router does).
Next comes the INSUFFICIENT_LIQUIDITY
check. Indeed, a swap can only be carried out if the amounts requested by the user are below the Pair reserves. Note that here comparisons are strict, such that the user cannot fully drain the contract; in other words at least 1 unit of each token must be left in the Pair after the swap.
Subsequently, balance variables are defined. Incidentally, note that uint
is shorthand for uint256
(see types in Solidity docs).
Transfer and Balance Scope
Next comes a limited scope covering lines 166 to 175. As the comment says, the scope helps avoiding the dreaded stack too deep errors (voted "the most significant pain" in 2022 Solidity developer survey). New address variables _token
are created in order to save gas once again. Transfers to token addresses are disallowed and raise the INVALID_TO
error, thus potentially saving people from silly mistakes and/or clever exploits.
Optimistic token transfers that come on lines 170&171 are particularly interesting. The Pair simply transfers as many tokens as the user desires to the to
address with no prior checks. As opposed to the real life, where giving a wad of cash to a stranger asking for it is not prudent, here transaction atomicity and reversals save the day. The transaction either fully succeeds or completely reverts.
Another design decision that has greatly enriched Uniswap interaction comes on line 172, where the Pair contract calls a function on a user smart contract. This single line enables flashloans, arbitrage transactions, liquidations, and similar applications, where complex logic is run after receiving tokens from the Pair but before finalizing the swap call. Having a lock is particularly important here, as otherwise reentrancy attacks would be possible.
On lines 173&174 token balances of the Pair are recorded. Due to the optimistic architecture, up to these lines the user may transfer tokens to the Pair contract and still successfully complete the swap execution. This means that the user might have transferred the tokens either before calling the swap function, or perhaps during its execution as part of the user smart contract function call. We will see how the Pair detects those incoming tokens next.
Figuring out What Came in
In the lines 176-178, the contract is trying to figure out if there were any net token inflows. A net inflow is detected by looking at balances at this point in time, stored reserves and token outflows. It could be said that current token balance is expected to be equal to at least the stored reserve minus the outgoing amount — anything above this can be considered to be an inflow. If nothing came in for either of the tokens, an INSUFFICIENT_INPUT_AMOUNT
error is raised.
Arithmetically, amountIn
is computed by taking the difference between balance
and _reserve
and then adding amountOut
to the difference. One can see that adding amountOut
makes sense here as otherwise balance
and _reserve
could be made equal by sending the same amount in and out, while the Pair contract would not detect any amountIn
and would thus fail to take a fee on the transfer.
Note again that both amountIn arguments may be nonzero. In fact, this is the case for more than 6% of swaps as again can be seen in a dashboard.
Fee Scope
In the swap process, fees are charged on the incoming token amounts amountIn
and are subtracted immediately before any computations take place. This approach is especially evident in functions such as getAmountOut in the UniswapV2 periphery, where fees are explicitly considered. As a result, the amountOut
received by users are lower than what they would be if no fees were charged. After all calculations are completed and the swap is finalized, the full amountIn
, including the fees, is added to the reserves.
After figuring out token inflows, the Pair smart contract is ready to make the final decision on allowing the swap to finalize in the lines 179-183. The criterion is very simple, namely, the product (called K
) of token reserves must increase. An added complication is that a fee 0.3% is taken off amountIn
as we have just discussed.
To avoid dividing numbers, both reserve and balance are multiplied by a factor of 1000 and amountIn
is multiplied by 3 since 3/1000=0.3%. The product K
before the swap is obtained by multiplying the two token _reserve
variables. The final state K
minus fees is obtained by subtracting fees from each token balance first to get balanceAdjusted
and then taking a product of these two adjusted balances.
The product is compared to the initial K
, and if K
stayed the same or increased even after subtracting the fees, the swap successfully finalizes. Otherwise, the K
error is thrown — the stranger known to us from Transfer and Balance Scope returns the wad of cash that the Pool had handed to them. Since this product K
is key to functioning of the protocol, it has prominently featured in the UniswapV2 audit, too.
Reserve Update and Event
In the last two lines of the function, reserves are updated by calling the _update
function and a Swap
event is emitted. By calling the _update
function, the contract synchronizes the reserve amounts with the current token balances after the swap has taken place. This ensures that the reserves accurately reflect the state of the liquidity Pair following the transaction. Emitting this Swap
event provides valuable information to external parties monitoring the UniswapV2 Pair contract, enabling them to track swaps and react accordingly, such as for arbitrage opportunities or tracking trading volume.
Recap
To recap, the swap function in UniswapV2 opens up a multitude of possibilities beyond the basic swapping of token0
for token1
. For example, a user may borrow both token0
and token1
simultaneously, which allows them to interact with other smart contracts, execute more complex strategies, or perhaps even burn a portion of the borrowed tokens. Moreover, the swap function provides flexibility in payment, enabling users to pay for the swap using any combination of tokens. In the most general scenario, users can obtain both token0
and token1
at the beginning, perform various actions, and then send a different proportion of token0
and token1
back to the Pool. This flexibility highlights the fact that users are not required to pay upfront for the swap, further emphasizing the versatility and potential of the UniswapV2 swap function.
Summary
In summary, the swap function serves as a prime example of how a concise piece of code can provide the foundation for a sophisticated and adaptable protocol. By thoroughly analyzing this battle-tested function, we have not only unveiled the intricacies of its operation but also demonstrated how its design decisions have contributed to the success of the Uniswap ecosystem. We hope that this writeup will provide inspiration for future improvements of the automated market maker architecture, especially in directions not yet taken.