-
[DeFi] Uniswap V2 Contract 코드 분석 - Periphery, Migrator블록체인 2021. 3. 8. 02:13
이번에는 Uniswap V2의 Periphery 쪽 코드 분석을 해봐야겠다.
코드는 여기서 확인할 수 있다.github.com/Uniswap/uniswap-v2-periphery/tree/master/contracts
우선 contracts 폴더에 3개의 solidity contract가 작성돼있다.
- UniswapV2Migrator.sol
- UniswapV2Router01.sol
- UniswapV2Router02.sol
그리고 4개의 폴더가 있는데, interfaces와 library 폴더가 중요할 것 같으니 여기에도 어떤 컨트랙트가 있는지 보자
interfaces
- IERC20.sol
- IUniswapV2Migrator.sol
- IUniswapV2Router01.sol
- IUniswapV2Router02.sol
- IWETH.sol
- 그리고 V1 인터페이스(Factory, Exchange)도 존재
libraries
- SafeMath.sol
- UniswapV2Library.sol
- UniswapV2LiquidityMathLibrary.sol
- UniswapV2OracleLibrary.sol
요 정도인 것 같다. 먼저 인터페이스를 보며 개발진이 periphery를 개발할 떄 어떤 생각을 했는지 추측해봐야지.
먼저 인터페이스들을 간단히 살펴보자. ERC20은 다 아니까 넘기고, Migrator부터 살펴봐야지IUniswapV2Migrator
github.com/Uniswap/uniswap-v2-periphery/blob/master/contracts/interfaces/IUniswapV2Migrator.sol
들어가보면 migrate라는 함수가 딱 하나 있다. 파라미터로는 토큰 컨트랙트 주소, 토큰 최소량, 이더리움 최소량, 보내는 사람(to), deadline 이렇게 5개가 존재한다. 후 근데 주석 한 줄 조차 없어서 이게 어떤 역할을 하는 애인지 알려면 구현부를 봐야할 것 같다. 이름만 보면 대~충 V1에서 V2로 migrate를 도와주는 녀석인 것 같긴 한데...
구현부 : github.com/Uniswap/uniswap-v2-periphery/blob/master/contracts/UniswapV2Migrator.sol
구현 부를 보니까 대충 맞는것 같다. 로직에 V1과 관련된 변수들이 있는것을 봐서는.. 하나씩 뜯어보자
function migrate(address token, uint amountTokenMin, uint amountETHMin, address to, uint deadline) external override { IUniswapV1Exchange exchangeV1 = IUniswapV1Exchange(factoryV1.getExchange(token)); uint liquidityV1 = exchangeV1.balanceOf(msg.sender); require(exchangeV1.transferFrom(msg.sender, address(this), liquidityV1), 'TRANSFER_FROM_FAILED'); (uint amountETHV1, uint amountTokenV1) = exchangeV1.removeLiquidity(liquidityV1, 1, 1, uint(-1)); TransferHelper.safeApprove(token, address(router), amountTokenV1); (uint amountTokenV2, uint amountETHV2,) = router.addLiquidityETH{value: amountETHV1}( token, amountTokenV1, amountTokenMin, amountETHMin, to, deadline ); if (amountTokenV1 > amountTokenV2) { TransferHelper.safeApprove(token, address(router), 0); // be a good blockchain citizen, reset allowance to 0 TransferHelper.safeTransfer(token, msg.sender, amountTokenV1 - amountTokenV2); } else if (amountETHV1 > amountETHV2) { // addLiquidityETH guarantees that all of amountETHV1 or amountTokenV1 will be used, hence this else is safe TransferHelper.safeTransferETH(msg.sender, amountETHV1 - amountETHV2); } }
먼저 파라미터로 들어온 토큰 주소를 통해 V1 Exchange 컨트랙트를 가져온다.
그리고 이 주소를 이용해 트랜잭션 sender의 밸런스(= liquidity1)를 가져오고, migrate 컨트랙트 주소로 liquidity1만큼을 보낸다. 이 때 이 결과가 true여야만 다음 로직이 실행된다.성공적으로 토큰을 V1 컨트랙트에서 migrate 컨트랙트로 전송했다면, V1에서는 유동성을 제거한다. 그런데 V1 Exchange 컨트랙트를 보니 2, 3, 4번째 파라미터가 각각 min_eth, min_tokens, deadline이던데 저렇게 막 1, 1, -1를 넣어도 되는건가 싶기는하다. 왜 저렇게 짰을까......라고 생각하는 찰나에 min_eth의 단위가 wei라는걸 알게됐다^^ 아 1이더라고 생각해 버렸었는데, 이런거 막히면 몇 시간 버리는게 함정 ㅠ_ㅠ
deadline에 uint(-1)을 넣은건 unsinged int에 -1넣으면 보통 maximum int가 나오니까 deadline에 무한대값을 넣어준거나 마찬가지일 것 같고. 뭐 토큰의 양도 최소 1개는 해야하나보다..싶다.그 다음은 Router 컨트랙트에 토큰 전송 권한을 safeApprove함수를 통해 넘겨준다. 자체 라이브러리를 사용해서 safety를 확보한다는데 library 코드를 살짝 뜯어보니, safeApprove 호출 전에 호출하고자 하는 컨트랙트가 erc20의 approve를 구현하고 있는지를 체크하는 것 같다. 예전에 크립토키디 코드 볼 때도 일반 erc721과 달리 encoded with selector를 통해 상대 컨트랙트가 erc721 컨트랙트 인지 여부를 체크하던데, 자세한 것은 나중에 더 깊게 살펴봐야겠다. 어쨋든 이걸 한 이유는 유동성 공급을 해줄때 라우터를 통해 pool 컨트랙트로 공급을 해줘야 하는데, router가 토큰을 전송할 권한이 있어야 하므로 그런 것이라고 생각할 수 있겠다.
그리고 router.addLiquidityETH 부분에서 라우터에 유동성을 공급해준다. router 컨트랙트 코드를 보면 알겠지만, 라우터에게 공급해주는 것이라기 보다는, 라우터를 통해서 해당하는 토큰 pool에다가 공급한다. 라우터에 보면 다음과 같은 코드가 있으니 참고!
function addLiquidityETH( address token, uint amountTokenDesired, uint amountTokenMin, uint amountETHMin, address to, uint deadline ) external 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); if (msg.value > amountETH) TransferHelper.safeTransferETH(msg.sender, msg.value - amountETH); // refund dust eth, if any }
(함수 body 철 줄에 UnswapV2Library.pairFor를 이용해서 erc20-weth 페어를 찾아오는 것을 볼 수 있다.)
그 외에 addLiquidityETH를 호출하면서 많은 파라미터가 전달되는데, minETH는 함수 호출 시에 들어오는 파라미터로 아마 다른 곳과 비슷하게 1 wei로 들어오지 않을까 싶다. amountTokenMin도 마찬가지고, deadline은 뭐 상황마다 다르겠지만 사실 크게 중요하지는 않은 것 같다.(이게 들어보니까 플래시론과 관련된 파라미터인것 같던데 아직 플래시론에 대한 이해도 명확하지 않아 다음에 다뤄봐야겠다.)이렇게 removeLiquidity to V1 -> addLiquidity to V2를 통해 유니스왑 V1에 있던 유동성을 V2로 옮겨오는 로직을 확인했다. 문제는 그 다음 조건문인데, 이게 무엇인지 한 눈에 잘 안들어와서 하나씩 자세히 들여다 보기로 했다.
if (amountTokenV1 > amountTokenV2) { TransferHelper.safeApprove(token, address(router), 0); // be a good blockchain citizen, reset allowance to 0 TransferHelper.safeTransfer(token, msg.sender, amountTokenV1 - amountTokenV2); } else if (amountETHV1 > amountETHV2) { // addLiquidityETH guarantees that all of amountETHV1 or amountTokenV1 will be used, hence this else is safe TransferHelper.safeTransferETH(msg.sender, amountETHV1 - amountETHV2); }
첫 번째로 amoutTokenV1 > amountTokenV2 일 경우, 도대체 이게 뭔 뜻일까 찾아봐도 잘 안나길래 혼자 추측해봤는데 V1의 pool과 V2의 pool의 이더 : 토큰의 비율이 다른 경우가 아닐까 싶다. 먼저 router 컨트랙트에 approve 함수를 다시 불러서 대신 전송할 수 있는 토큰의 양을 0으로 셋팅해버리는데, 주석을 보니 라우터 컨트랙트에 잔고를 0으로 만들려고 그런 것 같다. 이 부분이 좀 명확하지 않아서 좀 더 생각해봤는데, 우선 위에 addLiquidityETH 함수와 함께 살펴보면 좋을것 같다.
addLiquidityETH를 보면 private function인 _addLiquidity 함수를 호출하는데, 이 내부 로직이 좀 복잡하지만 한번 살펴봐야 정확하게 이해할 수 있을 것 같다.
function _addLiquidity( address tokenA, address tokenB, uint amountADesired, uint amountBDesired, uint amountAMin, uint amountBMin ) private 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 { 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); } } }
일단 보기만해도 눈이 핑핑도는 코드인데.. 첫 조건문은 거의 초기화문이니 건너뛰고, reserveA == 0 && reserveB == 0의 else문을 주목해서 봐야할 것 같다. 보면 quote라는 함수를 호출하는게 눈에 띄는데, Library에 가서 해당 코드를 보니 amountADesired의 값(그니까 pool에 넣고 싶은 tokenA의 양을 뜻함)을 넣으면, 그와 동일한 가치의 tokenB를 반환해주는 함수라고 한다. 실제로 LP를 할 떄 두 토큰을 동일한 양을 집어넣게 돼있는데 그것과 같다고 보면 될 것 같다. 근데 여기서 한가지 분기가 있는데, tokenB의 양을 가지고 내가 넣으려고 한 토큰의 양보다 계산한 토큰의 양이 큰지 작은지를 기준으로 로직이 살짝 나뉜다. 중요하게 생각해야 할 점은 여기서 tokenB는 ETH라는 점일 것 같다.
- 만약 내가 넣으려고 하는 ETH(amountBDesired)가, tokenA와 동일한 가치의 ETH(amountBOptimal)보다 큰 경우 : AMM 정해진 법칙(프로토콜)대로 굴러가야 하므로, 유동성 풀에 들어가야 하는 이더의 양은 amountBOptimal 일 것이다. 따라서 amountA = amountADesired, amountB = amountBOptimal로 설정하고 반환한다.
- 반대의 경우 (amountBDesired < amountBOptimal) : 이 경우는 조금 생각해봐야 할 게 있는데, 바로 tokenB의 양이 부족한 경우라는 것이다. 따라서 그에 맞춰서 token A의 양도 조금 줄여줘야 amountBDesired를 유동성 풀에 공급할 수 있을텐데, 그에 따라 새롭게amountAOptimal값을 amountBDesired에 맞게 다시 계산해준다. 그리고 간단하게 파라미터로 들어온 최소 A의 양과 비교하고, amountA = amountAOptimal, amountB = amountBDesired 로 설정하고 반환한다.
뭔가 되게 복잡해보였는데, 조금 집중해서 보니 그렇게 어려운 로직은 아닌것 같다. 요약하면 AMM에 맞게 파라미터로 들어온 tokenA, tokenB의 다양한 경우의 수를 모두 계산해서 유동성 풀에 공급하는 내용이다. 어쨋든 이런 로직을 거쳐서 router는 migrator로 부터(transforFrom 함수를 쓰는것에 주목!) 계산한 양 만큼의 토큰들을 pool 컨트랙트로 전송한다.
여기까지 살펴보니 왜 safeApprove(0)을 통해 router의 토큰 전송 권한을 0으로 설정하는지 알 것 같다. 일단 유니스왑 V1과 유니스왑 V2 풀에 존재하는 토큰의 비율이 일정하다는 보장이 없다. 따라서 마이그레이션 하는 도중에 두 풀의 토큰 비율의 차 때문에 실제로 들어가는 토큰의 양에 변화가 생길 수 있고, 이런 경우 migrator에 잔액이 남을 수 있다. 그리고 윗 줄에서 safeApprove로 설정해준 토큰의 양은 amountTokenV1 인 것에 주목해야한다. 실제로 전송된 토큰의 양은 amountTokenV2 일 것이므로, 그 차이만큼의 토큰 전송 권한이 router에 남아있을 것이므로 그 찌꺼기를 제거해주는 작업인 것이다. (아우 쓰다가 느꼈는데 아마 읽는 사람은 진짜 두서없다고 느낄듯 싶지만 다시 깔끔하게 쓸 자신이 없다 ㅠ_ㅠ) 그 다음 줄 safeTransfer를 보니 잔액을 사용자에게 환불해주는 로직이 있다.
마찬가지로 amountETHV1 > amountETHV2인 경우는 잔액을 사용자에게 돌려주는 것으로 마무리된다. 얘는 erc20이 아니므로 따로 권한 조정해주는 로직은 없는 것으로 보인다.
원래 Periphery쪽 코드를 모두 정리해보려고 했는데 migrator만 해도 이 정도네 ㅡ.ㅡ;; 도무지 한 포스트에 정리할 자신이 없어서 먼저 migrator 정도만 정리하고 마무리하려고 한다. 요약하자면 V1에서 유동성 제거, 그 양만큼 V2에 유동성을 공급해주고, 두 풀의 토큰 비율 차이에서 발생하는 양을 환불해주는 로직이라고 보면 될 것 같다. 내가 분석한게 정확한지 맞는지 모르겠지만, 그래도 컨트랙트 좀 들여다보니 어느정도 정리되는 것 같다. 앞으로도 열심히 분석해봐야지... 더불어 NFT 관련 컨트랙트를 분석해보려고 한다!
'블록체인' 카테고리의 다른 글
Compound를 분석해보자 (2) - 컨트랙트 1편, Borrowing (0) 2021.05.02 Compound를 분석해보자 (1) - 컨셉 (1) 2021.04.11 [Defi] Uniswap V2 Architecture 분석 (0) 2021.02.24 [Defi] Uniswap V2 Contract 코드 분석 2 - Pair (0) 2021.02.23 [Defi] Uniswap V2 Contract 코드 분석 1 - Factory (1) 2021.02.08