기존의 Uniswap V1은 ETH<->ERC20 거래만 가능했지만, V2에서는 ERC20<->ERC20 직접 거래가 가능해졌다. v1, v2 차이는 다음과 같다.
| 항목 | Uniswap V1 | Uniswap V2 |
|---|---|---|
| 거래 쌍 구조 | ETH <-> ERC20만 지원 | ERC20 <-> ERC20 직접 지원 |
| 스왑 경로 | ETH 중개 필요 (2번 스왑) | Multi-Hop Swap Path 지원 |
| 오라클 기능 | 없음 | 시간 가중 평균 가격 (TWAP) 내장 |
| 수수료 구조 | 0.3% (전액 LP에게 분배) | 0.3% (0.05%는 프로토콜 수익으로 설정 가능) |
| Flash Swaps | x | o |
| 보안 | Reentrancy Guard x | Reentrancy Guard o, SafeMath o |
| 유동성 풀 구성 | ETH 중심의 단일 구조 | ERC20 쌍 자유 구성 |
| 스마트 계약 구조 | 단순 구조 | Factory / Pair / Router로 역할 분리 |
| 가격 조작 저항성 | 낮음 | TWAP으로 개선 |

Uniswap v2에서는 크게 3가지 계약으로 분류할 수 있다.
swapExactTokensForTokens, swapTokensForExactTokens 등을 통해 Token간 Swap 실행addLiquidity, removeLiquidity를 통해 유동성 공급 및 제거 처리swap(), mint(), burn()
Uniswap v2는 다음과 같은 invariant를 따른다.

스왑 전 상태를 , 라고 하자.
이제 사용자가 토큰 를 만큼 Pool에 넣고, 토큰 를 만큼 가져가면 스왑 후 상태는 다음 수식이 될 것이다.

초기와 스왑 후 모두 같은 곡선 위에 있어야 하므로 두 식은 같아야한다.
해당 수식을 유도해보자.(dy에 대해 정리)
양변을 로 나누기
를 넘기고 도 넘기기
우항에서 를 곱해줌
분모가 같으니 두 방정식 합쳐줌. 를 빼주자
즉 AMM에 있는 Token 와 를 알고 있고 에 넣는 토큰 양도 알고있으면 (에 대한 토큰) 개수를 계산할 수 있다.
하지만 여기서 fee가 붙는다면? Uniswap의 수수료는 0.3%이다. 예를 들어, 를 Pool에 넣고 를 받는다고 할 때, 입력 토큰 에 대해 수수료가 차감된다. 그래서 실제로 Pool에 들어가는 양은 가 된다.
수수료를 반영한 공식
Pool 구성이 다음과 같다고 할 때 수수료를 계산해보자.
즉, 1000 DAI Token을 Swap하게 되면 0.498 ETH를 받게 된다.(수수료 0.3% 포함)

Single-hop swap 방식과 Multi-hop swap 방식이 존재한다.
Single-hop Swap은 한개의 Pair만을 이용해서 두 Token을 교환하는 방식이다.(e.g. WETH -> DAI)
1. 사용자는 Router Contract에 있는 swapExactTokensForTokens 함수 호출(스왑할 WETH 양과 DAI 토큰의 주소를 전달)
2. Router Contract는 사용자가 보낸 WETH를 WETH/DAI 페어 계약으로 전송
3. WETH가 Pair 꼐약으로 전송된 후, Router는 Pair 계약의 swap 함수를 호출(WETH->DAI 스왑)
4. 스왑된 DAI는 Router를 통해 사용자에게 전송
Multi-hop swap은 두개 이상의 Pair 계약을 경유해서 토큰을 교환하는 방식이다. 예를 들어 WETH -> MKR로 교환하려고 하는데 직접적인 Pair Contract가 없다면 여러 path를 거쳐서 swap해야 한다. (e.g. WETH <-> DAI <-> MKR)
1. 사용자는 Router 계약의 swapExactTokensForTokens 함수를 호출하여 WETH -> MKR 스왑을 요청
2. Router는 사용자의 WETH를 WETH/DAI Pair로 전송한 후, swap 함수를 호출하여 WETH를 DAI로 교환
3. 스왑된 DAI는 다시 DAI/MKR Pair로 전송되고, Router는 이 Pair의 swap 함수를 호출하여 DAI를 MKR로 교환
4. 최종적으로 MKR은 Router를 통해 사용자에게 전송
Uniswap V2는 크게 V2 Core와 V2 Periphery로 나뉘어져있다.
V2 Core
V2 Periphery
이 함수는 특정 수량의 Token을 다른 Token으로 swap할 때 사용한다.
v2-periphery/contracts/UniswapV2Router02.sol
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);
}
함수 내부에서는 UniswapV2Library의 getAmountsOut 함수를 호출해서 Swap 과정에서 교환될 토큰 수량을 계산한다. 반환되는 amounts 배열은 각 단계에서 교환되는 토큰의 수량을 담고 있다.
이후 함수는 입력 토큰을 해당 Pair 계약으로 전송한다. 이 페어 계약 주소는 path 배열에 주어진 입출력 토큰 주소를 기준으로 create2 함수를 통해 계산된다.
이후 _swap 함수를 호출해서 path 배열을 순회한다. 각 반복에서 Pair Contract 주소를 계산하고, 해당 Pair 계약의 swap 함수를 호출한다.
// **** SWAP ****
// 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)
);
}
}
이를 통해, 토큰 간 swap이 순차적으로 진행되며, 마지막에 최종 출력 토큰이 사용자에게 전달된다.
v2-periphery/contracts/libraries/UniswapV2Library.sol
// 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;
}
getAmountOut()은 입력된 Token 수량과 Pool의 Reserve 상태를 바탕으로 출력 Token 수량을 계산해준다.
WETH 1e18를 줬을 때 -> DAI -> MKR swap을 통해서 얻는 token amounts 계산
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "./interfaces/IERC20.sol";
import "./interfaces/IUniswapV2Router02.sol";
import "./UniswapV2Router02.sol";
contract UniswapV2SwapAmountsTest is Test {
IERC20 private constant weth = IERC20(WETH);
IERC20 private constant dai = IERC20(DAI);
IERC20 private constant mkr = IERC20(MKR);
IUniswapV2Router02 private constant router =
IUniswapV2Router02(UNISWAP_V2_ROUTER_02);
function test_getAmountsOut() public {
address[] memory path = new address[](3);
path[0] = WETH;
path[1] = DAI;
path[2] = MKR;
uint256 amountIn = 1e18;
uint256[] memory amounts = router.getAmountsOut(amountIn, path);
console2.log("WETH", amounts[0]);
console2.log("DAI", amounts[1]);
console2.log("MKR", amounts[2]);
}
}
getAmountsOut() 함수는 특정 스왑 경로에 따라 스왑을 진행했을 때 받게 될 토큰 수량을 계산하는 데 사용한다.
v2-periphery/contracts/libraries/UniswapV2Library.sol
// 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);
}
}
우선 스왑 경로 길이에 맞춰 amounts 배열을 생성하고, 첫 번째 값에 입력 값을 넣는다.
우리는 1 WETH를 넣어줬으니, amounts[0] = 1e18이 될 것이다.
이후 for 루프를 이용해서 경로 상의 각 token pair에 대해 swap 계산을 반복한다.
getReserves 함수)getAmountOut 함수)swapTokensForExactTokens() 함수는 정해진 출력양을 얻기 위해 최소한의 입력 토큰을 사용해 Swap을 수행하는 함수다. 예를 들어, 우리가 최대 3,000 DAI까지 지불할 의향이 있고, 정확히 1 WETH를 받고 싶다면 이 함수를 사용한다. 만약 1 WETH를 얻기 위해 3,000 DAI보다 더 많은 양이 필요하다면 revert가 된다.
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);
}
swapTokensForExactTokens() 함수 내부에서 사용되는 UniswapV2Library.getAmountsIn() 함수는 getAmountsOut과 반대로 얼마의 Token을 얻기위해 얼마의 입력이 필요한지 역으로 계산해준다. amounts[0]은 필요한 입력 토큰 수량이고, amounts[n]은 마지막 출력 토큰 수량이다.(입력한 amountOut)
이렇게 계산된 amountIn은 amountInMax보다 실제 필요한 입력값이 많으면 거래를 revert하게 된다.
require(amountIn <= amountInMax, 'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT');만약 통과하게 되면, 입력 토큰을 전송해주는데 첫 번째 pair 계약 주소(path[0], path[1] 기반)를 계산하고 사용자의 입력 토큰을 해당 pair로 전송한다.
이후 _swap을 호출해, 내부 루프를 통해 path 배열에 따라 각 pair에서 순차적으로 swap을 수행한다. 마지막 pair는 출력 토큰을 to 주소로 보내고, 중간 pair는 출력 토큰을 다음 pair 주소로 전달한다.
getAmountIn은 정해진 출력 토큰 수량에 대해 최소 입력 토큰 수량을 계산한다.
// 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);
}
}
// 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);
}
getAmountsIn 은 out함수와 반대로 부터 까지 감소하면서 구한다. 첫 반복에서 amountOut이 amounts[N-1]에 저장되어 있고, 이 값을 바탕으로 getAmountIn() 함수를 호출하여 필요한 입력량을 계산한다. 그 결과는 amounts[N-2]에 저장한다. 따라서 최종적으로 amounts[0]에는 필요한 입력량이 계산되게 된다.
아래 Test코드는 1 MKR을 얻기 위해 필요한 입력 토큰 수량을 계산한다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "./interfaces/IERC20.sol";
import "./interfaces/IUniswapV2Router02.sol";
import "./UniswapV2Router02.sol";
contract UniswapV2SwapAmountsTest is Test {
IERC20 private constant weth = IERC20(WETH);
IERC20 private constant dai = IERC20(DAI);
IERC20 private constant mkr = IERC20(MKR);
IUniswapV2Router02 private constant router =
IUniswapV2Router02(UNISWAP_V2_ROUTER_02);
function test_getAmountIn() public {
address[] memory path = new address[](3);
path[0] = WETH;
path[1] = DAI;
path[2] = MKR;
uint256 amountOut = 1e18;
uint256[] memory amounts = router.getAmountIn(amountOut, path);
console2.log("WETH", amounts[0]);
console2.log("DAI", amounts[1]);
console2.log("MKR", amounts[2]);
}
}
이렇게 실행했을 때 내부 동작을 살펴보자.
path = [WETH, DAI, MKR] 일 때, amounts = [0, 0, 1e18]로 시작한다.
for 루프 내부 동작을 보면 i = path.length - 1이므로 i = 2부터 시작한다.
path[2] = MKR, path[1] = DAI, 이를 기반으로 getReserves, getAmountIn를 실행하여 amounts[1]을 계산하고, amounts[0, 계산된 값, 1e18] 이렇게 채워진다. 한번 더 루프를 돌면서 amounts 배열을 다 채우게 된다. 이제 amounts[0]이 1 MKR을 얻기 위해 필요한 WETH가 구해진다.
swap 함수는 v2-core의 UniswapV2Pair.sol에 정의되어 있다.
이 계약은 Token간의 실제 Swap을 처리하는 역할을 한다.
swap함수 내의 token0, token1은 pair 계약의 두 쌍의 토큰이다.
// 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);
}
internal을 좀 더 살펴보자. 일단 swap함수의 인자로 4개가 전달 된다.
getReserves()를 통해 Pool내 유동성보다 많은 요청이 오면 revert가 발생한다.
require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');
optimistic한 transfer를 수행한다. to가 토큰이면 revert 발생한다.
address _token0 = token0;
address _token1 = token1;
require(to != _token0 && to != _token1, 'INVALID_TO');
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);
data가 존재하면 Flash Swap으로 간주하고, to 주소에 uniswapV2Call() 를 호출하여 외부 컨트랙트의 로직을 실행한다.
if (data.length > 0)
IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
전송 후 Pool 잔고를 다시 조회하여 얼마나 들어왔는지 계산한다. 실제 받은 입력 값 amountInt이 하나 이상이여야 한다.
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');
수수료 0.3%를 반영한 adjusted 잔고를 계산한다.
조정된 Pool 잔고로 AMM의 핵심 invariant인 조건이 유지되는지 확인한다. Swap 후의 토큰 곱은 스왑 전보다 작아지면 안된다.
balance0은 스왑 후 실제 token0의 잔액이고, amountIn은 사용자로부터 들어온 token0의 양이다.
1000 ** 2는 수수료를 0.3% 반영한 조정값(997/1000) 기반이다.
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');
Pool의 balance 및 reserve 값을 업데이트하고, Swap 이벤트를 발생시킨다.
_update(balance0, balance1, _reserve0, _reserve1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
Uniswap V2의 유동성 Pool 내에서 실시간으로 토큰 가격이 어떻게 결정되는지 알아보자.
지금 이 순간, X 토큰의 가격은 얼마인가? 라고 물어보면 Uniswap V2에서는 주문서 기반 거래소처럼 고정된 가격이 아니라 spot price 개념을 사용한다. spot price는 극도로 작은 거래량을 기준으로 한 이론적인 교환 비율이다.
이는 현재 풀 상태 에서의 곡선의 접선 기울기로 나타낼 수 있다.
Uniswap에서 invariant한 공식은 , 이다.
이 공식에 미분을 적용해보자.
미분을 적용하면 이렇게 된다.
이므로 이를 대입해주자
즉 특정 점 에서의 접선의 기울기는 이며, 순간 가격은 부호를 제외한 가 된다. 부호는 단순히 가 증가할수록 는 감소해야 한다는 관계를 나타낸다.
실질 거래 가격은 할선의 기울기를 통해 구할 수 있다.
사용자가 실제로 일정량의 의 토큰 X를 넣고, 의 토큰 Y를 받는 경우, Pool은 에서 로 이동하게 된다. 여기서 ()이다.
이제 실제 체결 가격은 다음과 같이 계산 된다. 이는 곡선 위의 두 점을 잇는 할선(secant line)의 기울기를 의미하며, 사용자가 실제로 체감하는 평균 교환 비율이다.
하지만, 순간 가격하고 실질 가격하고는 다르다.
대부분의 경우 이다. 왜냐하면 거래가 진행되는 동안 Pool의 상태는 계속 변하기 때문이다. 이 차이를 Slippage라고 한다.
빨간선의 기울기 : : 우리가 넣은 각 dx에 대해 얻은 토큰 Y 양을 알려준다. 즉, 이 선의 기울기는 토큰의 환율을 알려준다.
노란선 : 두 토큰의 현재 가격
를 점점 작게 만들면 빨간선이 줄어들면서 점차 노란선에 수렴하게 된다. 즉, 두 토큰의 현재 가격은 이 노란선의 기울기를 통해 나타낼 수 있다. 만약 거래 규모가 0이면 노란선과 빨간선은 정확히 겹칠 것이다.
이래서 거래의 규모를 점점 줄인다면 더 유리하다는 점을 알 수 있다.

하지만 Swap의 크기가 매우 작아지면() 할선은 접선에 가까워지고, 실질 가격은 점차 순간 가격에 수렴하게 된다.
이를 이해하면 AMM 기반 거래소가 가격을 어떻게 discover하고 제공하는지 알 수 있다. 그리고 큰 거래일수록 가격이 불리해진다는 점을 이해할 수 있다.
createPair함수는 두 개의 토큰 주소 tokenA와 tokenB를 입력으로 받아, 해당 토큰 쌍에 대한 새로운 Pair 계약 주소를 반환한다.
v2-core/contracts/UniswapV2Factory.sol
function createPair(address tokenA, address tokenB) external returns (address pair) {
// 동일한 토큰이면 에러 발생
require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
// 토큰을 정렬해서 작은 주소가 token0, 큰 주소가 token1이 되도록함
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
// 이미 동일한 두 토큰 쌍에 대해 Pair가 존재하는지 확인
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));
// create2를 이용해 계약 주소를 배포 전에 예측->별도 배포 트랜잭션 없이도 Pair 주소를 미리 알 수 있음
// 이 방식은 토큰 주소들과 salt값을 기반으로 계약 주소를 결정론적으로 계산할 수 있음
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
// 배포된 Pair 계약에 대해 initialize() 함수를 호출, 토큰 정보를 전달하여 초기화
IUniswapV2Pair(pair).initialize(token0, token1);
// getPair[token0][token1] = pairAddress 형태로 매핑을 업데이트하여, 해당 토큰 쌍으로 Pair 계약을 쉽게 찾을 수 있도록 함
getPair[token0][token1] = pair;
getPair[token1][token0] = pair; // populate mapping in the reverse direction
allPairs.push(pair);
emit PairCreated(token0, token1, pair, allPairs.length);
}
create2를 사용해서 pair 계약을 배포함으로써, 예측 가능한 주소를 확보하고, 효율성 높이고, 중복 배포를 방지할 수 있다.
Liquidity Pool에서 Liquidity Provier에게 몇 개의 share를 발행해야하는지 알아보자. 사용자가 Uniswap v2 Pool에 Liquidity를 추가하면 그에 상응하는 share를 받는데 이 지분은 전체 유동성 풀에 대한 소유권 비율을 나타낸다.
를 통해서 Pool의 가치가 얼마나 증가했는지 알 수 있다. 이 비율 만큼 Pool 지분도 증가해야하므로 관계식이 성립한다.
위 식을 통해서 를 계산해보자.
Liquidity Pool에 자금이 입금되었을 때, 새로운 LP에게 몇 개의 share를 발행해야 하는지를 계산해보자.
기존에 Pool에 1,100 USDC가 있고 LP가 110USDC를 공급했을 때 1,210 USDC가 된다. 전체 발행된 총 share는 1,000이라고 해보자. 이제 를 구해보자.
Pool의 가치가 10% 증가했으므로 기존 1,000 share의 10%인 100 shares를 새로 발행해야함을 알 수 있다. 이후 총 발행된 Pool 지분은 1,100 shares가 된다.
이제 share를 burn(소각)할 때의 계산 방법에 대해 알아보자. share 소각을 진행하면 아래와 같은 공식에 의해 돌려받게 된다.
share 소각을 진행하면 당연히 Pool에 있는 Token 수가 줄어들 것이다. 그리고 Pool의 가치 에서 으로 감소할 것이고 동일하게 share수도 비례해서 줄어들 것이다. 만약 개의 share를 소각하여 총 share수가 10%가 줄어들었으면 Pool의 가치도 10% 줄어든다.
share를 소각하면 다음과 같은 share 비율로 감소가 된다.
따라서, 소각 전 후의 share 비율을 다음과 같이 나타낼 수 있다. 이 비율을 기반으로 Pool의 가치 변화를 구할 수 있다.
이를 정리해보면 다음과 같다.
최종적으로 사용자가 share를 소각할 때 아래와 같은 공식을 통해서 계산이 가능하다.
1100개의 share가 있고, 100개를 소각하면 몇개의 Token을 받을까? 그리고 Pool에는 1210개의 USDC가 있다고 가정해보자. 100개의 share를 소각하면 총 share에서 9%가 감소한다.
100개의 share를 소각하면 110개의 Token을 받게 된다.
스마트 컨트랙트에서 유동성을 공급하려면 Router 컨트랙트의 addLiquidity()를 호출하면 된다. 이 때 Router에서 pair pool이 존재하는지 확인하고, 존재하지 않는다면 Facotry 컨트랙트가 createPair함수를 통해 새로운 pair contract를 생성한다. Router는 사용자의 지갑에서 토큰을 pair contract로 전송하게 되고, Router 컨트랙트는 Pair Contract의 mint 함수를 호출하게 된다. 마지막으로, Pair Contract는 예치된 Token들의 양을 기준으로 LP토큰을 발행할지 계산하고, 사용자에게 제공한다.

addLiquidity() 함수는 8개의 인자를 받는다.
addLiquidityETH()는 ETH가 포함된 pair를 사용할 때 호출하면 된다.
v2-periphery/contracts/UniswapV2Router02.sol
// **** 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 && reserveB == 0) {
(amountA, amountB) = (amountADesired, amountBDesired);
} else {
// quote 함수를 이용하여, 주어진 tokenA에 대해 얼마나 많은 tokenB를 제공해야 할지 계산
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);
}
}
}
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);
// 토큰 A와 B를 사용자의 지갑 → 페어 컨트랙트로 전송
TransferHelper.safeTransferFrom(tokenA, msg.sender, pair, amountA);
TransferHelper.safeTransferFrom(tokenB, msg.sender, pair, amountB);
// pair 컨트랙트의 mint 함수를 호출하여 유동성 제공자 토큰(LP 토큰)을 발행
liquidity = IUniswapV2Pair(pair).mint(to);
}
// 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;
}
quote 함수는 아래와 같이 동작하는데, invarient 에 기반하여 유동성을 추가할 때 현재 Pool의 가격 비율을 유지하도록 도와준다.
mint 함수는 유동성 제공자(LP)에게 토큰을 발행하는 함수다. 인자로 LP Token을 받을 사용자 주소가 들어가고, 발행될 LP 토큰 수량을 return하게 된다.
동작을 보면, Pool의 내부 상태를 불러온다. 이 때 amount0, amount1을 계산하는데 이는 새로 추가된 토큰 수량 = 실제 잔액 - 기존 reserve를 계산하기 위함이다.
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); 을 통해 프로토콜 수수료를 처리한다.
Liqudity를 계산하는 부분인데, Pool에 처음 유동성을 추가하는 경우 sqrt(amount0 * amount1) 를 통해 Pool 가치를 기준으로 계산한다. 그리고 MINIMUM_LIQUIDITY 만큼 영구 lock-up을 한다. 이는 Pool Inflation Attack을 방지하기 위함이다. 이미 유동성이 있다면, 기존 리저브 대비 비율을 기준으로 최소값을 선택하여 liquidity 계산한다.
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);
}
v2-core/contracts/UniswapV2Pair.sol
// 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;
}
}
// 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);
}