Following the introduction to the Dive into Uniswap v2 Whitepaper, today we'll explore the Uniswap v2 contract code.
This article won't cover the contract code line-by-line but will focus on the contract architecture and key methods. For detailed code explanations, I recommend reading Ethereum's official blog: Uniswap v2 contract walk-through.
Uniswap v2 contracts are primarily divided into two categories: core and periphery contracts. The core contracts contain only the most basic trading functionalities, with core code about 200 lines, ensuring minimization to avoid introducing bugs as users' funds are stored in these contracts. The periphery contracts provide various encapsulated methods tailored to user scenarios, such as supporting native ETH trades (automatically converted to WETH), multi-path swaps (executing A→B→C trades in one method), etc., all calling upon the core contracts. Operations on the app.uniswap.org interface utilize the periphery contracts.
Let's introduce the functionalities of a few main contracts:
-
uniswap-v2-core
- UniswapV2Factory: Factory contract for creating Pair contracts (and setting protocol fee recipient addresses)
- UniswapV2Pair: Pair (trading pair) contract, defining several basic methods related to trading, such as swap/mint/burn, price oracle, etc., itself being an ERC20 contract inheriting UniswapV2ERC20
- UniswapV2ERC20: Implements the ERC20 standard methods
-
uniswap-v2-periphery
- UniswapV2Router02: The latest version of the router contract, compared to UniswapV2Router01, it adds support for FeeOnTransfer tokens; implements Uniswap v2's most commonly used interfaces, such as adding/removing liquidity, swapping tokens A for B, swapping ETH for tokens, etc.
- UniswapV1Router01: The older version of Router implementation, similar to Router02, but does not support FeeOnTransferTokens, and is no longer in use
The most important method in the factory contract is createPair
:
function createPair(address tokenA, address tokenB) external returns (address pair) {
require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS'); // single check is sufficient
bytes memory bytecode = type(UniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
IUniswapV2Pair(pair).initialize(token0, token1);
getPair[token0][token1] = pair;
getPair[token1][token0] = pair; // populate mapping in the reverse direction
allPairs.push(pair);
emit PairCreated(token0, token1, pair, allPairs.length);
}
Firstly, token0
and token1
are sorted to ensure token0
's literal address is less than token1
's. Then, a contract is created using assembly
+ create2
. assembly
allows for direct EVM manipulation in Solidity using the Yul language, representing a lower-level method of operation. As discussed in the whitepaper, create2
is mainly used to create deterministic trading pair contract addresses, meaning the pair address can be computed directly from the two token addresses without on-chain contract queries.
CREATE2
comes from EIP-1014, according to which, the final generated address is influenced by the custom salt
value provided during pair contract generation. For a trading pair of two tokens, the salt
value should be consistent; naturally, using the trading pair's two token addresses comes to mind. To ensure order does not affect the pair, the contract starts by sorting the two tokens to generate the salt
value in ascending order.
In the latest EVM versions, passing the salt
parameter directly to the new
method is supported, shown as:
pair = new UniswapV2Pair{salt: salt}();
Due to the lack of this functionality during the development of Uniswap v2 contracts, assembly create2
was used.
According to the Yul specification, create2
is defined as follows:
create2(v, p, n, s)
create new contract with code mem[p…(p+n)) at address keccak256(0xff . this . s . keccak256(mem[p…(p+n))) and send v wei and return the new address, where 0xff is a 1 byte value, this is the current contract’s address as a 20 byte value and s is a big-endian 256-bit value; returns 0 on error
In the source code, the create2
method call:
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
The parameters are interpreted as follows:
- v=0: The amount of ETH tokens (in wei) sent to the newly created pair contract
- p=add(bytecode, 32): The starting position of the contract bytecode
Why add 32? Because the
bytecode
type isbytes
, and per the ABI specification,bytes
is a variable length type. The first 32 bytes store the length of thebytecode
, followed by the actual content, making the start of the actual contract bytecode atbytecode+32
bytes. - n=mload(bytecode): The total byte length of the contract bytecode
As mentioned, the first 32 bytes of
bytecode
store the actual length of the contract bytecode (in bytes), andmload
fetches the value of the first 32 bytes of the passed parameter, makingmload(bytecode)
equal ton
. - s=salt: The custom
salt
, which is the combination oftoken0
andtoken1
addresses encoded together.
This contract mainly defines UniswapV2's ERC20 standard implementation, with relatively straightforward code. Here, we'll introduce the permit
method:
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
bytes32 digest = keccak256(
abi.encodePacked(
'\x19\x01',
DOMAIN_SEPARATOR,
keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
)
);
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
_approve(owner, spender, value);
}
The permit
method implements the "Meta transactions for pool shares" feature introduced in section 2.5 of the whitepaper. EIP-712 defines the standard for offline signatures, i.e., the format of digest
that a user signs. The signature's content is the authorization (approve
) by the owner to allow a contract (spender
) to spend a certain amount (value
) of tokens (Pair liquidity tokens) before a deadline. Applications (periphery contracts) can use the original information and the generated v, r, s signatures to call the Pair contract's permit
method to obtain authorization. The permit
method uses ecrecover
to restore the signing address to the token owner; if verification passes, the approval is granted.
The Pair contract primarily implements three methods: mint
(adding liquidity), burn
(removing liquidity), and swap
(exchange).
This method implements the functionality of adding liquidity.
// this low-level function should be called from a contract which performs important safety checks
function mint(address to) external lock returns (uint liquidity) {
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
uint balance0 = IERC20(token0).balanceOf(address(this));
uint balance1 = IERC20(token1).balanceOf(address(this));
uint amount0 = balance0.sub(_reserve0);
uint amount1 = balance1.sub(_reserve1);
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
if (_totalSupply == 0) {
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
_mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
} else {
liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
}
require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');
_mint(to, liquidity);
_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
emit Mint(msg.sender, amount0, amount1);
}
Initially, getReserves()
fetches the cached balances of the two tokens. As mentioned in the whitepaper, caching balances is meant to prevent manipulation of the price oracle. It is also used here to calculate protocol fees by subtracting the cached balance from the current balance to determine the amount of transferred tokens.
_mintFee calculates protocol fees:
// if fee is on, mint liquidity equivalent to 1/6th of the growth in sqrt(k)
function _mintFee(uint112 _reserve0, uint112 _reserve1) private returns (bool feeOn) {
address feeTo = IUniswapV2Factory(factory).feeTo();
feeOn = feeTo != address(0);
uint _kLast = kLast; // gas savings
if (feeOn) {
if (_kLast != 0) {
uint rootK = Math.sqrt(uint(_reserve0).mul(_reserve1));
uint rootKLast = Math.sqrt(_kLast);
if (rootK > rootKLast) {
uint numerator = totalSupply.mul(rootK.sub(rootKLast));
uint denominator = rootK.mul(5).add(rootKLast);
uint liquidity = numerator / denominator;
if (liquidity > 0) _mint(feeTo, liquidity);
}
}
} else if (_kLast != 0) {
kLast = 0;
}
}
For calculations on protocol fees, please refer to the whitepaper.
The mint
method determines that if it's the first time providing liquidity for the trading pair, liquidity tokens are generated based on the square root of xy and MINIMUM_LIQUIDITY (i.e., 1000 wei) is burned; otherwise, liquidity tokens are minted based on the ratio of the transferred token value to the current liquidity value.
This method implements the functionality of removing liquidity.
// this low-level function should be called from a contract which performs important safety checks
function burn(address to) external lock returns (uint amount0, uint amount1) {
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
address _token0 = token0; // gas savings
address _token1 = token1; // gas savings
uint balance0 = IERC20(_token0).balanceOf(address(this));
uint balance1 = IERC20(_token1).balanceOf(address(this));
uint liquidity = balanceOf[address(this)];
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution
amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution
require(amount0 > 0 && amount1 > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED');
_burn(address(this), liquidity);
_safeTransfer(_token0, to, amount0);
_safeTransfer(_token1, to, amount1);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
emit Burn(msg.sender, amount0, amount1, to);
}
Similar to mint
, the burn
method also calculates protocol fees.
Refer to the whitepaper, for saving transaction fees, Uniswap v2 only collects accumulated protocol fees when minting/burning liquidity.
After removing liquidity, the proportion of destroyed liquidity tokens to the total determines the corresponding amounts of the two tokens the user will receive.
This method implements the functionality of exchanging (trading) two tokens.
// this low-level function should be called from a contract which performs important safety checks
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');
uint balance0;
uint balance1;
{ // scope for _token{0,1}, avoids stack too deep errors
address _token0 = token0;
address _token1 = token1;
require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
}
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
{ // scope for reserve{0,1}Adjusted, avoids stack too deep errors
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
}
_update(balance0, balance1, _reserve0, _reserve1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}
To accommodate flash loan functionality and not rely on a specific token's transfer method, the entire swap
method does not use parameters like amountIn
, but rather calculates the amount of transferred tokens by comparing the current balance with the cached balance.
Since the swap
method will check the balances (after subtracting fees) to comply with the constant product formula constraint (refer to the whitepaper formula), the contract can first transfer the tokens the user wishes to receive, if the user did not previously transfer tokens to the contract for the trade, it is equivalent to borrowing tokens (i.e., a flash loan); if using a flash loan, it is necessary to return the borrowed tokens in the custom uniswapV2Call
method, otherwise, the transaction will revert.
The swap
method updates the cumulative price required by the price oracle using the cached balance and finally updates the cached balance to the current balance at the end of the method.
// update reserves and, on the first call per block, price accumulators
function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW');
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired
if (timeElapsed > 0 and _reserve0 != 0 and _reserve1 != 0) {
// * never overflows, and + overflow is desired
price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
}
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = blockTimestamp;
emit Sync(reserve0, reserve1);
}
Note, both the block timestamp and cumulative prices are overflow-safe. (For detailed derivation, please refer to the whitepaper)
Since UniswapV2Router01 has a bug in handling FeeOnTransferTokens, it is no longer used. Here, we will only introduce the latest version of the UniswapV2Router02 contract.
Router02 encapsulates the most commonly used trading interfaces; to accommodate the need for native ETH trades, most interfaces are provided in ETH versions. Compared to Router01, some interfaces have added support for FeeOnTransferTokens.
We will mainly introduce the ERC20 version of the code, as the logic for the ETH version is identical, only involving conversion between ETH and WETH.
Before delving into specific ERC20 methods, let's first introduce several common methods from the Library contract and their mathematical derivations.
Input the factory address and two token addresses to calculate the address of their trading pair.
// calculates the CREATE2 address for a pair without making any external calls
function pairFor(address factory, address tokenA, address tokenB) internal pure returns (address pair) {
(address token0, address token1) = sortTokens(tokenA, tokenB);
pair = address(uint(keccak256(abi.encodePacked(
hex'ff',
factory,
keccak256(abi.encodePacked(token0, token1)),
hex'96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f' // init code hash
))));
}
As mentioned previously, since the CREATE2
opcode is used, the pair address can be directly computed without on-chain contract queries.
create2(v, p, n, s)
create new contract with code mem[p…(p+n)) at address keccak256(0xff . this . s . keccak256(mem[p…(p+n))) and send v wei and return the new address, where 0xff is a 1 byte value, this is the current contract’s address as a 20 byte value and s is a big-endian 256-bit value; returns 0 on error
The new pair contract's address is calculated as keccak256(0xff + this + salt + keccak256(mem[p…(p+n)))
:
- this: Factory contract address
- salt:
keccak256(abi.encodePacked(token0, token1))
keccak256(mem[p…(p+n))
:0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f
Since every trading pair uses the UniswapV2Pair contract for creation, the init code hash is the same. We can write a Solidity method in the UniswapV2Factory to calculate the hash:
function initCodeHash() external pure returns (bytes32) {
bytes memory bytecode = type(UniswapV2Pair).creationCode;
bytes32 hash;
assembly {
hash := keccak256(add(bytecode, 32), mload(bytecode))
}
return hash;
}
The quote
method converts a certain amount (amountA
) of token A into an equivalent amount of token B based on the reserves in the contract. Fees are not considered here, as this is merely a unit conversion.
// given some amount of an asset and pair reserves, returns an equivalent amount of the other asset
function quote(uint amountA, uint reserveA, uint reserveB) internal pure returns (uint amountB) {
require(amountA > 0, 'UniswapV2Library: INSUFFICIENT_AMOUNT');
require(reserveA > 0 && reserveB > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
amountB = amountA.mul(reserveB) / reserveA;
}
This method calculates how much of token B (amountOut
) can be obtained for a given amount of token A (amountIn
), based on the reserves in the pool.
// given an input amount of an asset and pair reserves, returns the maximum output amount of the other asset
function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
uint amountInWithFee = amountIn.mul(997);
uint numerator = amountInWithFee.mul(reserveOut);
uint denominator = reserveIn.mul(1000).add(amountInWithFee);
amountOut = numerator / denominator;
}
To derive the mathematical formula for this method, let's revisit the constraint for token balances after a swap as discussed in the whitepaper and the core contracts:
Where
The mathematical derivation is as follows:
$y_{in} = 0$
$x_1 = x_0 + x_{in}$
$y_1 = y_0 - y_{out}$
$(x_1 - 0.003 \cdot x_{in}) \cdot (y_1 - 0.003 \cdot y_{in}) = x_0 \cdot y_0$
$(x_1 - 0.003 \cdot x_{in}) \cdot y_1 = x_0 \cdot y_0$
$(x_0 + x_{in} - 0.003 \cdot x_{in}) \cdot (y_0 - y_{out}) = x_0 \cdot y_0$
$(x_0 + 0.997 \cdot x_{in}) \cdot (y_0 - y_{out}) = x_0 \cdot y_0$
$y_{out} = y_0 - \frac {x_0 \cdot y_0}{x_0 + 0.997 \cdot x_{in}}$
$y_{out} = \frac {0.997 \cdot x_{in} \cdot y_0}{x_0 + 0.997 \cdot x_{in}}$
Since Solidity does not support floating-point numbers, the formula can be converted to:
This calculation result is the amountOut
as shown in the getAmountOut
method, where:
$amountIn = x_{in}$
$reserveIn = x_0$
$reserveOut = y_0$
$amountOut = y_{out}$
This method calculates how much of token A (amountIn
) is required to obtain a specified amount of token B (amountOut
).
// given an output amount of an asset and pair reserves, returns a required input amount of the other asset
function getAmountIn(uint amountOut, uint reserveIn, uint reserveOut) internal pure returns (uint amountIn) {
require(amountOut > 0, 'UniswapV2Library: INSUFFICIENT_OUTPUT_AMOUNT');
require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
uint numerator = reserveIn.mul(amountOut).mul(1000);
uint denominator = reserveOut.sub(amountOut).mul(997);
amountIn = (numerator / denominator).add(1);
}
getAmountOut
calculates getAmountIn
calculates
The calculation result shown in the contract code corresponds to the formula above. Note the final add(1)
is to ensure that the input amount (amountIn
) is not less than the theoretical minimum due to rounding, effectively requiring at least 1 more unit of the input token.
This method calculates, for a given amount (amountIn
) of the first token, how much of the last token in a sequence (amounts
) can be obtained using multiple trading pairs. The first element in the amounts
array represents amountIn
, and the last element represents the quantity of the target token. This method essentially loops through the getAmountIn
method.
// performs chained getAmountOut calculations on any number of pairs
function getAmountsOut(address factory, uint amountIn, address[] memory path) internal view returns (uint[] memory amounts) {
require(path.length >= 2, 'UniswapV2Library: INVALID_PATH');
amounts = new uint[](path.length);
amounts[0] = amountIn;
for (uint i; i < path.length - 1; i++) {
(uint reserveIn, uint reserveOut) = getReserves(factory, path[i], path[i + 1]);
amounts[i + 1] = getAmountOut(amounts[i], reserveIn, reserveOut);
}
}
As opposed to getAmountsOut
, getAmountsIn
calculates the amounts of intermediary tokens required when a specific amount (amountOut
) of the target token is desired. It iteratively calls the getAmountIn
method.
// performs chained getAmountIn calculations on any number of pairs
function getAmountsIn(address factory, uint amountOut, address[] memory path) internal view returns (uint[] memory amounts) {
require(path.length >= 2, 'UniswapV2Library: INVALID_PATH');
amounts = new uint[](path.length);
amounts[amounts.length - 1] = amountOut;
for (uint i = path.length - 1; i > 0; i--) {
(uint reserveIn, uint reserveOut) = getReserves(factory, path[i - 1], path[i]);
amounts[i - 1] = getAmountIn(amounts[i], reserveIn, reserveOut);
}
}
function addLiquidity(
address tokenA,
address tokenB,
uint amountADesired,
uint amountBDesired,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) external virtual override ensure(deadline) returns (uint amountA, uint amountB, uint liquidity) {
(amountA, amountB) = _addLiquidity(tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin);
address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);
TransferHelper.safeTransferFrom(tokenA, msg.sender, pair, amountA);
TransferHelper.safeTransferFrom(tokenB, msg.sender, pair, amountB);
liquidity = IUniswapV2Pair(pair).mint(to);
}
Since Router02 directly interacts with users, the interface is designed with user scenarios in mind. addLiquidity
provides 8 parameters:
address tokenA
: Token Aaddress tokenB
: Token Buint amountADesired
: Desired amount of token A to deposituint amountBDesired
: Desired amount of token B to deposituint amountAMin
: Minimum amount of token A to deposituint amountBMin
: Minimum amount of token B to depositaddress to
: Recipient address for liquidity tokensuint deadline
: Expiration time for the request
Transactions submitted by users can be packed by miners at an uncertain time, hence the token price at the time of submission might differ from the price at the time of transaction packing. amountMin
controls the price fluctuation range to prevent being exploited by miners or bots; similarly, deadline
ensures the transaction expires after the specified time.
If the token price provided by users when adding liquidity differs from the actual price, they will only receive liquidity tokens at a lower exchange rate, with the surplus tokens contributing to the entire pool. _addLiquidity
helps calculate the optimal exchange rate. If it's the first time adding liquidity, a trading pair contract will be created first; otherwise, the best token amounts to inject are calculated based on the current pool balances.
// **** ADD LIQUIDITY ****
function _addLiquidity(
address tokenA,
address tokenB,
uint amountADesired,
uint amountBDesired,
uint amountAMin,
uint amountBMin
) internal virtual returns (uint amountA, uint amountB) {
// create the pair if it doesn't exist yet
if (IUniswapV2Factory(factory).getPair(tokenA, tokenB) == address(0)) {
IUniswapV2Factory(factory).createPair(tokenA, tokenB);
}
(uint reserveA, uint reserveB) = UniswapV2Library.getReserves(factory, tokenA, tokenB);
if (reserveA == 0 and reserveB == 0) {
(amountA, amountB) = (amountADesired, amountBDesired);
} else {
uint amountBOptimal = UniswapV2Library.quote(amountADesired, reserveA, reserveB);
if (amountBOptimal <= amountBDesired) {
require(amountBOptimal >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT');
(amountA, amountB) = (amountADesired, amountBOptimal);
} else {
uint amountAOptimal = UniswapV2Library.quote(amountBDesired, reserveB, reserveA);
assert(amountAOptimal <= amountADesired);
require(amountAOptimal >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT');
(amountA, amountB) = (amountAOptimal, amountBDesired);
}
}
}
Finally, the core contract's mint
method is called to mint liquidity tokens.
Firstly, liquidity tokens are sent to the pair contract. Based on the proportion of received liquidity tokens to the total tokens, the corresponding amounts of the two tokens represented by the liquidity are calculated. After destroying the liquidity tokens, the user receives the corresponding proportion of tokens. If it's lower than the user's set minimum expectations (amountAMin/amountBMin), the transaction reverts.
// **** REMOVE LIQUIDITY ****
function removeLiquidity(
address tokenA,
address tokenB,
uint liquidity,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) public virtual override ensure(deadline) returns (uint amountA, uint amountB) {
address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);
IUniswapV2Pair(pair).transferFrom(msg.sender, pair, liquidity); // send liquidity to pair
(uint amount0, uint amount1) = IUniswapV2Pair(pair).burn(to);
(address token0,) = UniswapV2Library.sortTokens(tokenA, tokenB);
(amountA, amountB) = tokenA == token0 ? (amount0, amount1) : (amount1, amount0);
require(amountA >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT');
require(amountB >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT');
}
Normally, to remove liquidity, users need to perform two operations:
approve
: Authorizing the Router contract to spend their liquidity tokensremoveLiquidity
: Calling the Router contract to remove liquidity
Unless the maximum token amount was authorized during the first authorization, each liquidity removal would require two interactions, meaning users would need to pay gas fees twice. By using the removeLiquidityWithPermit
method, users can authorize the Router contract to spend their tokens through a signature without separately calling approve
, only needing to call the liquidity removal method once to complete the operation, saving gas costs. Additionally, since offline signatures do not incur gas fees, each signature can authorize only a specific amount of tokens, enhancing security.
function removeLiquidityWithPermit(
address tokenA,
address tokenB,
uint liquidity,
uint amountAMin,
uint amountBMin,
address to,
uint deadline,
bool approveMax, uint8 v, bytes32 r, bytes32 s
) external virtual override returns (uint amountA, uint amountB) {
address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);
uint value = approveMax ? uint(-1) : liquidity;
IUniswapV2Pair(pair).permit(msg.sender, address(this), value, deadline, v, r, s);
(amountA, amountB) = removeLiquidity(tokenA, tokenB, liquidity, amountAMin, amountBMin, to, deadline);
}
There are two common scenarios for trading:
- Using a specified amount of token A (input) to exchange for the maximum amount of token B (output)
- Receiving a specified amount of token B (output) using the minimum amount of token A (input)
This method implements the first scenario, exchanging for the maximum output token based on a specified input token.
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external virtual override ensure(deadline) returns (uint[] memory amounts) {
amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
TransferHelper.safeTransferFrom(
path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
);
_swap(amounts, path, to);
}
First, the getAmountsOut
method from the Library contract is used to calculate the output token amounts for each trade based on the exchange path, ensuring the amount received from the last trade (amounts[amounts.length - 1]
) is not less than the expected minimum output (amountOutMin
). Tokens are then sent to the first trading pair address to start the entire exchange process.
Assuming a user wants to swap WETH for DYDX, and the best exchange path calculated off-chain is WETH → USDC → DYDX, then amountIn
is the WETH amount, amountOutMin
is the minimum expected DYDX amount, path
is [WETH address, USDC address, DYDX address], and amounts
are [amountIn, USDC amount, DYDX amount]. During the execution of _swap
, the tokens received from each intermediary trade are sent to the next trading pair address, and so on, until the final trade is completed, and the _to
address receives the output tokens from the last trade.
// requires the initial amount to have already been sent to the first pair
function _swap(uint[] memory amounts, address[] memory path, address _to) internal virtual {
for (uint i; i < path.length - 1; i++) {
(address input, address output) = (path[i], path[i + 1]);
(address token0,) = UniswapV2Library.sortTokens(input, output);
uint amountOut = amounts[i + 1];
(uint amount0Out, uint amount1Out) = input == token0 ? (uint(0), amountOut) : (amountOut, uint(0));
address to = i < path.length - 2 ? UniswapV2Library.pairFor(factory, output, path[i + 2]) : _to;
IUniswapV2Pair(UniswapV2Library.pairFor(factory, input, output)).swap(
amount0Out, amount1Out, to, new bytes(0)
);
}
}
This method implements the second trading scenario, exchanging the minimum input token for a specified output token.
function swapTokensForExactTokens(
uint amountOut,
uint amountInMax,
address[] calldata path,
address to,
uint deadline
) external virtual override ensure(deadline) returns (uint[] memory amounts) {
amounts = UniswapV2Library.getAmountsIn(factory, amountOut, path);
require(amounts[0] <= amountInMax, 'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT');
TransferHelper.safeTransferFrom(
path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
);
_swap(amounts, path, to);
}
Similar to the above, here the getAmountsIn
method from the Library is first used to reverse calculate the minimum input token amounts required for each exchange, ensuring the calculated amount (after deducting fees) for the first token is not greater than the maximum amount the user is willing to provide (amountInMax
). Tokens are then sent to the first trading pair address to initiate the entire exchange process.
Since the core contracts only support ERC20 token trades, the periphery contracts need to convert between ETH and WETH to support ETH trades. Most methods are provided in ETH versions to accommodate this. Exchange mainly involves two operations:
- Address conversion: Since ETH does not have a contract address, the deposit and withdraw methods of the WETH contract are used to convert between ETH and WETH
- Token amount conversion: ETH amounts are obtained through
msg.value
, and the corresponding WETH amounts can be calculated, after which standard ERC20 interfaces can be used
Some tokens deduct fees during the transfer process, resulting in a difference between the transferred amount and the actual received amount. Therefore, it's not straightforward to calculate the required token amounts for intermediary exchanges. In such cases, the balanceOf
method (rather than the transfer
method) should be used to determine the actual received token amounts. Router02 introduced support for Inclusive Fee On Transfer Tokens. For more detailed explanations, refer to the official documentation.