Back to Blog
Uniswap V2 — Complete Guide to Understand the DeFi Protocol
UniswapDeFiSolidity

Uniswap V2 — Complete Guide to Understand the DeFi Protocol

11 min
3 views
In order to understand the different components that we are going to go through while analysing the code first it is important to know which are the main concepts and which is their role. So, bare with me because it is going to be worth it.
I have summarised in 5 paragraphs the main important concepts that you need to know exist and that you will understand by the end of this article.
Uniswap is a decentralised exchange protocol. This protocol is a suite of persistent, non-upgradable smart contracts that together create an automated market maker.
The Uniswap ecosystem consists of liquidity providers who contribute to liquidity, traders the tokens and developers who interact with smart contracts to develop new interactions for the tokens.
Each Uniswap smart contract, or pair, manages a liquidity pool made up of reserves of two ERC-20 tokens.
Every liquidity pool rebalances to maintain a 50/50 proportion of cryptocurrency assets, which in turn determines the price of the assets.
Liquidity providers can be anyone who is able to supply equal values of ETH and an ERC-20 token to a Uniswap exchange contract. In return they are given Liquidity Provider Tokens (LP tokens represent the share of the pool owned by a liquidity provider) from the exchange contract which can be used to withdraw their proportion of the liquidity pool at any time.

The Main Smart Contracts

The main smart contracts in their repository are these:
  • UniswapV2ERC20 — an extended ERC20 implementation that's used for LP-tokens. It additionally implements EIP-2612 to support off-chain approval of transfers.
  • UniswapV2Factory — similarly to V1, this is a factory contract that creates pair contracts and serves as a registry for them. The registry uses create2 to generate pair addresses — we'll see how it works in detail.
  • UniswapV2Pair — the main contract that's responsible for the core logic. It's worth noting that the factory allows to create only unique pairs to not dilute liquidity.
  • UniswapV2Router — the main entry point for the Uniswap UI and other web and decentralized applications working on top of Uniswap.
  • UniswapV2Library — a collection of helper functions that implement important calculations.
In this article we will be mentioning all of these but we will focus mainly on going through UniswapV2Router and UniswapV2Factory code, although UniswapV2Pair and UniswapV2Library are going to be very involved.

UniswapV2Router02.sol

This contract makes it easier to create pairs, add and remove liquidity, calculate prices for all possible swap variations and perform actual swaps. Router works with all pairs deployed via the Factory contract.
You will need to create an instance in your contract in order to call addLiquidity, removeLiquidity and swapExactTokensForTokens functions:
1address private constant ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;
2
3IUniswapV2Router02 public uniswapV2Router;
4uniswapV2Router = IUniswapV2Router02(ROUTER);
Let's now look into liquidity management:

FUNCTION addLiquidity()

1function addLiquidity(
2 address tokenA,
3 address tokenB,
4 uint amountADesired,
5 uint amountBDesired,
6 uint amountAMin,
7 uint amountBMin,
8 address to,
9 uint deadline
10) external returns (uint amountA, uint amountB, uint liquidity);
  • tokenA and tokenB: are the tokens we need to get or create the pair we want to add liquidity to.
  • amountADesired and amountBDesired are the amounts we want to deposit into the liquidity pool.
  • amountAMin and amountBMin are the minimal amounts we want to deposit.
  • to address is the address that receives LP-tokens.
  • deadline, would most commonly be the block.timestamp
Inside the internal _addLiquidity() it will check if a pair of those two tokens already exists and in case it doesn't it will create a new one:
1if (IUniswapV2Factory(factory).getPair(tokenA, tokenB) == address(0)) {
2 IUniswapV2Factory(factory).createPair(tokenA, tokenB);
3}
Then it needs to get the existing amount of the tokens or also known as reserveA and reserveB, which we can access through UniswapV2Pair contract:
1IUniswapV2Pair(pairFor(factory, tokenA, tokenB)).getReserves()
Now, the external function addLiquidity returns (uint amountA, uint amountB, uint liquidity), so how does it calculate it?
After getting the reserves mentioned above through UniswapV2Library, there are a series of checks.
If the pair didn't exist and a new one was newly created, amountA and amountB returned will be amountADesired and amountBDesired which were passed as parameters.
Otherwise, it will do this operation:
1amountBOptimal = amountADesired.mul(reserveB) / reserveA;
If the amountB is smaller or equal to the amountBDesired, then it will return:
1(uint amountA, uint amountB) = (amountADesired, amountBOptimal)
Otherwise, it will return:
1(uint amountA, uint amountB) = (amountAOptimal, amountBDesired)
where amountAOptimal is calculated the same way as amountBOptimal.
Then, to calculate the liquidity to return will go through the following:
First of all, it is going to deploy the UniswapV2Pair contract with the address of the existing/newly created pair.
How does it do that? It calculates the CREATE2 address for a pair without making any external call with:
1pair = address(uint(keccak256(abi.encodePacked(
2 hex'ff',
3 factory,
4 keccak256(abi.encodePacked(token0, token1)),
5 hex'96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f' // init code hash
6))));
Then, it gets the address of the newly deployed contract, which we will need in order to mint the tokens from this pair of tokens.
When you add liquidity to a pair, the contract mints LP-tokens; when you remove liquidity, LP-tokens get burned.
So, first we get the address using pairFor from UniswapV2Library:
1address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);
So, that later can mint the ERC20 tokens and calculate the liquidity to return:
1liquidity = IUniswapV2Pair(pair).mint(to);
In case you're wondering why it ends up being an ERC20, inside the mint function it's storing it like this:
1uint balance0 = IERC20(token0).balanceOf(address(this));
2uint balance1 = IERC20(token1).balanceOf(address(this));

FUNCTION removeLiquidity()

1function removeLiquidity(
2 address tokenA,
3 address tokenB,
4 uint liquidity,
5 uint amountAMin,
6 uint amountBMin,
7 address to,
8 uint deadline
9) external returns (uint amountA, uint amountB);
Removing liquidity from pool means burning of LP-tokens in exchange for proportional amount of underlying tokens.
1IUniswapV2Pair(pair).transferFrom(msg.sender, pair, liquidity);
Then again, the external function is returning two values (uint amountA, uint amountB) and those are calculated with the parameters passed to the function.
The amount of tokens returned with the liquidity provided are calculated like that:
1amount0 = liquidity.mul(balance0) / _totalSupply;
2amount1 = liquidity.mul(balance1) / _totalSupply;
Then it is going to transfer those amount of tokens to the specified address:
1_safeTransfer(_token0, to, amount0);
2_safeTransfer(_token1, to, amount1);
The bigger your share of LP-tokens, the bigger share of reserve you get after burning.
And these calculations above are happening inside the burn function:
1IUniswapV2Pair(pair).burn(to)

FUNCTION swapExactTokensForTokens()

Swap tokens
1function swapExactTokensForTokens(
2 uint amountIn,
3 uint amountOutMin,
4 address[] calldata path,
5 address to,
6 uint deadline
7) external returns (uint[] memory amounts);
The core functionality of Uniswap is swapping tokens, so let's figure out what happens in the code, in order to understand it better.
Most likely, you have heard of the magic formula used in liquidity pools:
X * Y = K
So, that is going to happen first thing inside the swap function with the function getAmountOut().
The key functions used inside are:
1TransferHelper.safeTransferFrom()
Where the token amount is sent to the pair token.
And at a lower level swap function from UniswapV2Pair contract it will be:
1_safeTransfer(_token, to, amountOut);
That will be doing the actual transfer back to the expected address.

UniswapV2Factory.sol

Uniswap V2 Factory
The factory contract is a registry of all deployed pair contracts. This contract is necessary because we don't want to have pairs of identical tokens so liquidity is not split into multiple identical pairs.
The contract also simplifies pair contracts deployment: instead of deploying the pair contract manually with any external call, one can simply call a method in the factory contract.
Alright, let's rewind because very important things have been said in these lines above. Let's split them and analyse them separately:

This contract is a registry of all deployed pair contracts

There's only one factory contract deployed and the contract serves as the official registry of Uniswap pairs.
Now, where do we see that in the code and what is happening:
1address[] public allPairs;
It has the array of allPairs, which as mentioned above, are stored in this contract. The pairs get added inside a method called createPair() by pushing the newly initialised pair to the array.
1allPairs.push(pair);

This contract is necessary because we don't want to have pairs of identical tokens

1mapping(address => mapping(address => address)) public getPair;
It has a mapping of the address of the pair with both tokens that form the pair. That is used to check if a pair already exists.
1require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS');

The contract also simplifies pair contracts deployment

This is a much deeper topic but I am going to try to summarise what is important of what happens here.
In Ethereum, contracts can deploy contracts. One can call a function of a deployed contract, and this function will deploy another contract.
You don't need to compile and deploy a contract from your computer, you can do this via an existing contract.
Now, how does Uniswap deploy smart contracts?
By using the opcode CREATE2:
1bytes memory bytecode = type(UniswapV2Pair).creationCode;
2bytes32 salt = keccak256(abi.encodePacked(token0, token1));
3assembly {
4 pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
5}
In the first line, we get the creation bytecode of UniswapV2Pair.
Next line creates salt, a sequence of bytes that's used to generate new contract's address deterministically.
And the final line is where we're calling create2 to create a new address deterministically using bytecode + salt. Deploy UniswapV2Pair.
And get the pair address, which we can see is the return value from createPair() function:
1function createPair(
2 address tokenA,
3 address tokenB
4) external returns (address pair)
Which is used in _addLiquidity() internal function when the provided tokens are not an existing pair.

Practical Examples

Now, in order to see all what we went through being tested, here you can see the way we can add liquidity:
1function addLiquidity(
2 address _tokenA,
3 address _tokenB,
4 uint _amountA,
5 uint _amountB
6) external {
7 IERC20(_tokenA).transferFrom(msg.sender, address(this), _amountA);
8 IERC20(_tokenB).transferFrom(msg.sender, address(this), _amountB);
9
10 IERC20(_tokenA).approve(ROUTER, _amountA);
11 IERC20(_tokenB).approve(ROUTER, _amountB);
12
13 (uint amountA, uint amountB, uint liquidity) =
14 IUniswapV2Router(ROUTER).addLiquidity(
15 _tokenA,
16 _tokenB,
17 _amountA,
18 _amountB,
19 1,
20 1,
21 address(this),
22 block.timestamp
23 );
24
25 emit Log("amountA", amountA);
26 emit Log("amountB", amountB);
27 emit Log("liquidity", liquidity);
28}
And also how we have to consider to remove liquidity:
1function removeLiquidity(address _tokenA, address _tokenB) external {
2 address pair = IUniswapV2Factory(FACTORY).getPair(_tokenA, _tokenB);
3
4 uint liquidity = IERC20(pair).balanceOf(address(this));
5 IERC20(pair).approve(ROUTER, liquidity);
6
7 (uint amountA, uint amountB) =
8 IUniswapV2Router(ROUTER).removeLiquidity(
9 _tokenA,
10 _tokenB,
11 liquidity,
12 1,
13 1,
14 address(this),
15 block.timestamp
16 );
17
18 emit Log("amountA", amountA);
19 emit Log("amountB", amountB);
20}

Get in touch

Building or forking an AMM? The Uniswap V2 codebase is battle-tested, but custom implementations — modified pricing curves, new fee structures, additional hooks — introduce fresh attack surfaces that the original audits never covered.
At Zealynx, we've audited DEX protocols, liquidity pools, and DeFi primitives across Solidity, Rust, and Cairo. We combine manual line-by-line review with fuzz testing and invariant suites to catch the edge cases that automated tools miss.
What we offer:
  • AMM & DEX audits — Full review of pair contracts, factory logic, and router security
  • Fuzz & invariant testing — Foundry-based test suites targeting price manipulation and reserve imbalances
  • Fork security reviews — Identify what changed from the original and what risks those changes introduced
  • Smart contract development — Secure-by-design implementations optimized for gas efficiency

Glossary

TermDefinition
Automated Market Maker (AMM)A protocol that uses mathematical formulas instead of order books to determine asset prices and facilitate trades.
Liquidity PoolA smart contract holding reserves of two tokens that enables decentralized trading.
LP TokensTokens minted to liquidity providers representing their share of a pool's reserves.
CREATE2An EVM opcode that deploys contracts to deterministic addresses based on the deployer address, salt, and bytecode.
EIP-2612A standard for gasless ERC-20 approvals using off-chain signatures (permit function).

oog
zealynx

Subscribe to Our Newsletter

Stay updated with our latest security insights and blog posts

© 2024 Zealynx