DEX의 교과서인 유니스왑 V2 컨트랙트를 정리해보자. Figma를 사용해 간단한 다이어그램을 만들어봤다. 유니스왑V2 컨트랙트는 크게 두 가지로 나뉜다. 코어(Core)는 Pair 부분을 담당하고, 주변부(Periphery)는 Swap 부분을 담당한다. 각각 핵심 코드를 살펴보자.
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
//새로운 pair 생성
bytes memory bytecode = type(UniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
//생성한 pair 주소 및 토큰 주소를 할당
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);
}
핵심 함수는 createPair()
다. 토큰 주소를 받아서 새로운 pair의 주소를 리턴한다.
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
//첫 유동성 공급이면 LP 토큰을 MINIMUM_LIQUIDITY만큼 소각
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');
//LP토큰 민팅
_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);
}
mint()
는 LP토큰을 만드는 함수다.
처음에 이해가 가지 않았던 점은 getReserves()
함수를 처음 호출할 때 _reserve0
와 _reserve1
값이 0으로 되어 있을텐데 어떻게 리저브 값을 가져올 수 있는지 의문이었다. 이 의문은 단순히 코드 자체만 봤기 때문에 가질 수 있는 의문이다. 전체적인 맥락에서 생각해보면, LP 토큰을 민팅하기 위해선 그 이전에 LP 풀에 토큰을 전송해 유동성을 공급해야 한다.
따라서 코드를 작성했을 때 리저브의 값은 0이겠지만 실제로 코드가 실행될 때는 이미 토큰이 전송된 상태이기 때문에 0보다 클 것이다. 또한 해당 변수들은 mint
, burn
, swap
함수가 실행될 때 _update()
를 통해 최신값으로 변경된다. 따라서 초기에 0인 것도 맞고 나중에 리저브 값을 가져올 수 있는 것도 맞다.
또 한가지 혼란이 왔던 점은 _reserve0
과 balance0
의 차이점이다. 분명 '컨트랙트가 갖고 있는 토큰의 양'으로 알고 있는 개념인데 다르게 쓰이고 있다.
정확히 말하면 balance0
는 '기존 pool에 있는 토큰A의 개수(_reserve0
) + 방금 컨트랙트로 전송된 토큰A의 개수(amount0
)'를 말한다. 혼란이 오는 이유는 컨트랙트에 방금 전송한 토큰을 왜 다른 이름으로 구분을 하는지 한 번에 와닿지 않기 때문이다. 여기서 구분되는 이유는 간단하다. "아직" 유동성에 추가되지 않았기 때문이다.
나중에 Router 파트에서 보겠지만 addLiquidity()
함수에서 유저가 pool에 토큰을 보내기 전에 현재 pool에 있는 reserve 값을 먼저 확인한다. 이후에 유저가 토큰을 보내고 나서 LP 토큰을 민팅할 때 _update()
함수가 호출되면서 전체 reserve 값을 업데이트한다. 순서를 정리하면 다음과 같다.
현재 reserve 값 확인 - 유저가 토큰 전송 - LP 토큰 민트 - reserve 값 업데이트
유저가 유동성이 없는 pool에 처음으로 유동성을 공급할 때 MINIMUM_LIQUIDITY
만큼 LP 토큰을 민팅해서 소각한다. 이렇게 하는 이유는 생성되는 전체 LP 토큰의 양이 MINIMUM_LIQUIDITY
보다 많도록 하기 위함이다. 만약 MINIMUM_LIQUIDITY
보다 적다면 liquidity 값이 음수가 되면서 revert 될 것이다. 발행되는 LP 토큰의 개수가 MINIMUM_LIQUIDITY
보다 많아야 나중에 토큰 가격을 계산할 때 또는 pool에 있는 극히 작은 개수의 토큰을 다뤄야 할 때 발생할 수 있는 라운딩 에러를 방지할 수 있기 때문이다.
function burn(address to) external lock returns (uint amount0, uint amount1) {
//토큰 개수와 LP 토큰 개수 확인
(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)];
//유저가 보낸 LP 토큰 개수에 맞게 돌려줘야 할 토큰 개수 계산
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');
//LP토큰 소각 및 토큰 전송
_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);
}
burn()
은 LP 토큰을 소각하고 토큰을 돌려주는 함수다.
uint liquidity = balanceOf[address(this)];
이 때 pair 컨트랙트에는 없는 balanceOf
mapping이 등장하는데, 이건 UniswapV2ERC20 컨트랙트에서 상속해 온 mapping이다. 현재 pair 컨트랙트가 갖고 있는 LP 토큰의 양을 나타낸다.
한 가지 의문이었던 것은 burn()
함수가 호출될 때 유저가 보낸 LP 토큰이 얼마인지 어떻게 아냐는 것이다. 이것 또한 맥락을 고려하지 않고 코드 자체만 봤기 때문에 생기는 의문이다. 어차피 burn()
함수를 호출하기 전에 유저는 LP 토큰을 페어 컨트랙트에 보내야 한다. 이후 유저가 보낸 LP 토큰의 개수를 포함해 balanceOf[address(this)]
가 업데이트 되고 burn()
함수가 호출될 것이다.
풀에 유동성을 공급할 때 민팅된 LP 토큰은 유저에게 전송된다. 즉, 원래 페어 컨트랙트는 LP 토큰을 갖고 있지 않기 때문에 만약 컨트랙트에 LP 토큰이 있다면 전부 유저가 LP 토큰을 소각하기 위해 보낸 것들이다. 따라서 liquidity
는 온전히 유저의 LP 토큰을 나타내고, 이를 LP 토큰의 총 발행량인 _totalSupply
로 나누면 유저의 지분을 나타낼 수 있다. 지분율을 풀에 있는 토큰의 개수와 곱하면 기존에 유저가 공급했던 토큰의 개수(amount0
)를 알아낼 수 있다.
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);
}
swap()
은 말 그대로 토큰을 교환해주는 함수다.
파라미터에 스왑을 통해 얻고자 하는 토큰 개수를 입력하면, 토큰을 전송한 이후 계산식을 통해 유저가 풀에 얼마나 토큰을 넣었는지 파악하고 이를 통해 풀의 밸런스를 조정한다. 자세한 계산식은 조금 밑에서 다뤄보도록 하자.
require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
중간에 require
문으로 to
주소가 토큰 주소가 아닌지 확인하는데, 만약 이런 확인이 없으면 악의적인 사용자가 토큰 주소로 토큰을 보내서 가격을 조작할 수도 있고, 차익거래를 노릴 수 있기 때문에 이를 방지하기 위함이라고 한다. 그럼 소각주소(address(0)
)는 왜 확인 안 하냐는 궁금증이 생길 수 있다. 하지만 이는 발생할 확률이 낮다는 게 chatGPT의 의견이다.. 프로젝트 측에서 스왑함수를 가져와서 사용할 때 함수 바깥에서 방지 코드를 넣을 수 있기 때문.
IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
위 코드는 flash loan 때 사용되는 callback 함수다. to
주소의 컨트랙트에 uniswapV2Call()
이 구현돼있다면 받은 토큰으로 flash swaps, arbitrage, liquidation 등의 여러 커스텀 로직을 시도를 할 수 있다.
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
이제 계산식을 보자. 문자로만 보면 헷갈리기 때문에 실제 숫자를 통해 이해해보자. 우리는 2000개의 이더를 USDC로 스왑하고 싶다. 현재 풀에는 10,000개의 이더가 있고, 5,000개의 USDC가 있다. 각 변수들은 다음과 같다.
_reserve0
: 이더 10,000 개 (현재 pool)
_reserve1
: USDC 5,000 개 (현재 pool)
amount0In
: 이더 2,000개 (스왑할 개수)
amount0Out
: 0
amount1In
: 0
amount1Out
: USDC k 개 (나중에 받을 개수)
이더 -> USDC로 스왑할 때 풀에 이더만 집어넣기 때문에 나중에 받을 USDC 개수를 제외한 나머지 변수들은 모두 0이다. 이제 각 변수값을 계산식에 대입해보자.
uint amount0In = balance0 > 10,000 - 0 ? balance0 - (10,000 - 0) : 0;
uint amount1In = balance1 > 5,000 - k ? balance1 - (5,000 - k) : 0;
balance0
는 기존에 풀에 있던 이더 1000개에 유저가 넣은 이더 2000개를 더해 총 12,000개가 된다. balance1
는 기존에 풀에 있던 USDC 5,000개에서 나중에 유저에게 전송할 k개를 제외한 값 (5000 - k)
가 된다. 따라서 amount0In
은 2,000이 되고, amount1In
은 0이 된다. 이를 통해 유저가 스왑하기 위해 풀에 넣은 이더의 개수를 알 수 있다.
이후 유저가 넣은 양의 0.3% 만큼 수수료를 떼고, 나머지 개수로 balance 값을 새롭게 조정한다. 그런데 amount0In
이 0인 경우가 있을까? 일반적인 상황에선 토큰을 전송한 다음에 스왑을 진행하기 때문에 0보다 크다. amount0In
이 0인 경우는 주로 flash loan을 통해 풀에서 토큰을 빌려갈 때다. 풀에 토큰을 넣지 않고 빌려갈 수 있기 때문이다.
// force balances to match reserves
function skim(address to) external lock {
address _token0 = token0; // gas savings
address _token1 = token1; // gas savings
_safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
_safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
}
// force reserves to match balances
function sync() external lock {
_update(IERC20(token0).balanceOf(address(this)), IERC20(token1).balanceOf(address(this)), reserve0, reserve1);
}
이 외에 skim
과 sync
가 있는데, 두 함수의 역할이 조금 다르다.
skim
은 현재 업데이트 되지 않은 reserve의 값과 실제 풀에 있는 balance가 일치하지 않을 때, 강제로 차익분을 to
주소로 보내서 reserve와 balance 값이 일치하도록 한다. 이렇게 하는 이유는 가끔 의도치 않게 페어 컨트랙트로 토큰을 전송했을 때 복구하는 등 여러 돌발 상황에서의 안정 장치라고 생각하면 될 것 같다.
sync
는 아직 업데이트되지 않은 reserve 값을 실제 balance와 일치하도록 업데이트 시켜준다.
Migrator는 기존 V1의 페어들을 V2로 migration 하기 위한 컨트랙트다. Periphery에서의 핵심은 Router 컨트랙트다. Router01
은 현재 버그가 발견되어 더 이상 사용하지 않는다. 따라서 Router02
를 살펴보자. 함수는 크게 addLiquidity
, removeLiquidity
, swap
3가지로 나뉠 수 있다.
// **** ADD LIQUIDITY ****
function _addLiquidity(
address tokenA,
address tokenB,
uint amountADesired,
uint amountBDesired,
uint amountAMin,
uint amountBMin
) internal virtual returns (uint amountA, uint amountB) {
//pair가 없을 경우, pair 생성
if (IUniswapV2Factory(factory).getPair(tokenA, tokenB) == address(0)) {
IUniswapV2Factory(factory).createPair(tokenA, tokenB);
}
(uint reserveA, uint reserveB) = UniswapV2Library.getReserves(factory, tokenA, tokenB);
//아직 initialize 되지 않았을 경우, 파라미터 값을 변수값에 할당
if (reserveA == 0 && 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);
}
}
}
_addLiquidity()
함수는 실제로 유동성을 공급하지 않고, 유동성을 공급할 양을 결정하는 함수다. amountADesired
와 amountBDesired
는 유저가 풀에 유동성을 공급하고자 하는 토큰의 개수를 의미한다. amountAMin
과 amountBMin
은 유저가 풀에 유동성을 공급하고자 하는 토큰의 최소 개수다.
이 때 UniswapV2Library.quote
를 통해서 바꾸고자 하는 A토큰의 양을 현재 reserve에 대입했을 때 B토큰이 얼마나 필요한지 알아낸다. 이상적인 상황에서 나온 B토큰의 양이 기존에 스왑을 통해 얻고자 했던 양보다 적다면 최솟값보다 큰지 확인하고 각 변수에 값을 할당한다. 만약 B토큰의 양이 스왑을 통해 얻고자 했던 양보다 크다면 B토큰의 양을 reserve에 대입했을 때 A토큰이 얼마나 필요한지 알아내서 같은 절차를 거친다.
다소 복잡해보이는 이 절차를 거치는 이유는 뭘까? 유동성을 풀에 공급할 때 중요한 것은 reserveA * reserveB = k
가 유지돼야 한다는 점이다. 만약 유저가 A토큰 대비 B토큰을 매우 크게 설정해서 유동성을 넣으려고 하면 풀이 엉망이 될 것이다.
// 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;
}
따라서 풀에 A토큰을 넣을 때 1차적으로 quote()
함수를 통해 reserveA * reserveB = k
공식에 기반해서 적절한 토큰B의 양을 계산해준다. 그 다음 계산을 통해 나온 토큰B의 양을 조건식에 맞는지 2차 검증을 거치는 것이다. 이렇게 해야 나중에 토큰을 풀에 넣었을 때 k값이 변하지 않고 유지될 것이다.
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);
}
addLiquidity()
는 실제로 유동성을 공급하는 함수다. _addLiquidity()
를 통해 계산된 토큰 양을 페어로 집어넣고 LP 토큰을 유저에게 민팅해준다.
function addLiquidityETH(
address token,
uint amountTokenDesired,
uint amountTokenMin,
uint amountETHMin,
address to,
uint deadline
) external virtual override payable ensure(deadline) returns (uint amountToken, uint amountETH, uint liquidity) {
(amountToken, amountETH) = _addLiquidity(
token,
WETH,
amountTokenDesired,
msg.value,
amountTokenMin,
amountETHMin
);
address pair = UniswapV2Library.pairFor(factory, token, WETH);
TransferHelper.safeTransferFrom(token, msg.sender, pair, amountToken);
IWETH(WETH).deposit{value: amountETH}();
assert(IWETH(WETH).transfer(pair, amountETH));
liquidity = IUniswapV2Pair(pair).mint(to);
// refund dust eth, if any
if (msg.value > amountETH) TransferHelper.safeTransferETH(msg.sender, msg.value - amountETH);
}
추가적으로 addLiquidityETH()
함수는 페어에 토큰 종류 하나와 이더를 공급할 때 실행된다. 공급된 이더는 weth
로 래핑된다.
유동성을 제거하는 함수는 크게 3종류로 나눌 수 있다.
removeLiquidity
removeLiquidityWithPermit
removeLiquidityETHSupportingFeeOnTransferTokens
각 종류에는 ETH 버전이 추가된다.
// **** 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');
}
LP토큰을 받아서 소각시키고 유저에게 돌려줄 토큰의 양을 리턴한다. 아직 유저에게 토큰을 보내지 않은 것이 특징이다.
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);
}
보통 유동성을 제거할 때 유저는 router 컨트랙트가 LP 토큰을 다룰 수 있도록 approve
해줘야 한다. 트랜잭션이 추가적으로 필요한 과정이기 때문에 가스비가 소모된다. removeLiquidityWithPermit()
는 이 과정을 유동성 제거 함수에 포함시켜서 가스비를 절감하게 해준다.
function removeLiquidityETHSupportingFeeOnTransferTokens(
address token,
uint liquidity,
uint amountTokenMin,
uint amountETHMin,
address to,
uint deadline
) public virtual override ensure(deadline) returns (uint amountETH) {
(, amountETH) = removeLiquidity(
token,
WETH,
liquidity,
amountTokenMin,
amountETHMin,
address(this),
deadline
);
TransferHelper.safeTransfer(token, to, IERC20(token).balanceOf(address(this)));
IWETH(WETH).withdraw(amountETH);
TransferHelper.safeTransferETH(to, amountETH);
}
위 함수는 조금 특이하다. 가끔 어떤 토큰들 보면 트랜잭션이 발생할 때마다 소각시킨다든지, 일정 퍼센트를 홀더들에게 재분배 한다든지 하는 애들이 있다. 이러한 기능을 도와주는 것이 removeLiquidityETHSupportingFeeOnTransferTokens()
함수다.(이름이 너무 길다 ㄷㄷ)
이제 대망의 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()
함수다. path에 대한 개념 없이 코드로만 이해하기는 조금 복잡하다. 간단한 예를 통해 이해해보자.
첫 번째 케이스는 단일 페어로 스왑하는 경우다. USDC를 이더로 스왑한다고 하면 path는 다음과 같다.
path = [USDC_address, ETH_address]
두 번째 케이스는 페어가 없는 토큰쌍을 거래하는 경우다. USDC를 DOGE로 스왑하고 싶은데 DOGE/USDC 페어가 없다고 해보자. 이 때는 이더가 중간 다리 역할을 해준다. 이 경우에 path는 다음과 같다.
path = [USDC_address, ETH_address, DOGE_address]
반복문을 돌려서 총 두 번 스왑을 한다. 처음엔 USDC를 이더로 스왑하고, 이후엔 이더를 DOGE로 스왑한다.
여러 경우에 따라 스왑에 사용되는 함수가 달라진다. 각 케이스 별로 나눠봤다. 조금 복잡하지만 함수 이름 대로 이해하면 된다.
USDC를 아비트럼으로 스왑한다고 가정해보면
1.swapExactTokensForTokens
: 5USDC를 ARB로 바꾸고 싶을 때
2.swapTokensForExactTokens
: USDC를 10ARB로 바꾸고 싶을 때
USDC를 이더로 스왑한다고 가정해보면
3.swapTokensForExactETH
: USDC를 1이더로 바꾸고 싶을 때
4.swapExactTokensForETH
: 5USDC를 이더로 바꾸고 싶을 때
이더를 USDC로 스왑한다고 가정해보면
5.swapETHForExactTokens
: 이더를 5USDC로 바꾸고 싶을 때
6.swapExactETHForTokens
: 1이더를 USDC로 바꾸고 싶을 때
Exact가 앞에 붙었을 때는 나중에 스왑해서 받는 토큰은 최솟값으로 받게 되고, Exact가 뒤에 붙었을 때는 스왑할 토큰을 최대값으로 가정한다.