-
[Defi] Uniswap V2 Contract 코드 분석 2 - Pair블록체인 2021. 2. 23. 03:47
이번에는 Uniswap Core Contract의 두 번쨰, UniswapV2Pair 컨트랙트를 분석해보자.
먼저 이 컨트랙트가 어떤 인터페이스를 구현하고 있는지 살펴보자.
두 번째 인터페이스인 UniswapV2ERC20은 아마도 ERC20 표준인 것 같아서, 앞의 IUniswapV2Pair 인터페이스를 집중적으로 살펴보자. (https://github.com/Uniswap/uniswap-v2-core/blob/master/contracts/interfaces/IUniswapV2Pair.sol)
interface IUniswapV2Pair { event Approval(address indexed owner, address indexed spender, uint value); event Transfer(address indexed from, address indexed to, uint value); function name() external pure returns (string memory); function symbol() external pure returns (string memory); function decimals() external pure returns (uint8); function totalSupply() external view returns (uint); function balanceOf(address owner) external view returns (uint); function allowance(address owner, address spender) external view returns (uint); function approve(address spender, uint value) external returns (bool); function transfer(address to, uint value) external returns (bool); function transferFrom(address from, address to, uint value) external returns (bool); function DOMAIN_SEPARATOR() external view returns (bytes32); function PERMIT_TYPEHASH() external pure returns (bytes32); function nonces(address owner) external view returns (uint); function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external; event Mint(address indexed sender, uint amount0, uint amount1); event Burn(address indexed sender, uint amount0, uint amount1, address indexed to); event Swap( address indexed sender, uint amount0In, uint amount1In, uint amount0Out, uint amount1Out, address indexed to ); event Sync(uint112 reserve0, uint112 reserve1); function MINIMUM_LIQUIDITY() external pure returns (uint); function factory() external view returns (address); function token0() external view returns (address); function token1() external view returns (address); function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); function price0CumulativeLast() external view returns (uint); function price1CumulativeLast() external view returns (uint); function kLast() external view returns (uint); function mint(address to) external returns (uint liquidity); function burn(address to) external returns (uint amount0, uint amount1); function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external; function skim(address to) external; function sync() external; function initialize(address, address) external; }
굉장히 길지만 하나씩 차근히 분석해보자. 블로그 글이 길어질 것 같은 예감이 든다.
먼저 event는 이 컨트랙트에서 사용할 로그라고 생각하면 된다. 블록에 기록이 남아서 어떤 이벤트의 발생 여부를 추적할 수 있다. IUniswapV2Pair에서는 두 개의 이벤트를 사용하는데, Approval, Transfer이다. 뒤에 이어지는 함수들로 유추해보건데, 이 인터페이스에 erc20 표준을 포함하는 것 같다는 생각이 들었다. (그런데 정작 구현 컨트랙트에서는 두 인터페이스를 나눠서 선언하던데, 왜 그런지도 차차 생각해봐야겠다.)
name, symbol, decimals, totalSupply, balanceOf, allowance, approve, transfer, transferFrom은 ERC20 에서 정의하는 표준 함수다. 하나씩 간단하게 알아보자.
- name() - 만들고자 하는 ERC20 토큰의 이름을 반환한다.
- symbol() - 만들고자 하는 ERC20 토큰의 symbol을 정의한다. 예를 들어 이더리움의 경우(erc20은 아니지만) name은 ethereum이고 symbol은 eth이다.
- decimals() - 소수점 아래 수의 개수를 뜻한다. 보통 18인 경우가 많은데, 소수점 아래 18자리 까지 쓴다는 뜻이다.
- totalSupply() - 만들고자 하는 ERC20 토큰의 전체 발행량이다.
- balanceOf(address owner) - onwer 주소가 갖고 있는 ERC20 토큰의 양을 뜻한다.
- allowance(address owner, address spender) - 처음 보면 좀 생소할 수 있는데, owner가 갖고 있는 토큰의 전송 권한을 spender에게 준다는 뜻이다.
- transfer(address to, uint value) - 트랜잭션을 보낸이의 잔고에서 value만큼을 to에게 보낸다.
- transferFrom(address from, address to, uint value) - from의 잔고에서 to에게 value만큼의 ERC20 토큰을 보낸다. 이 때, 이 트랜잭션을 생성한 자가 from의 토큰을 전송할 권한이 있어야한다. 이것을 위의 allowance 함수를 이용하여 설정하는 것이다.
그 다음에는 여러가지 함수들이 있는데, 인터페이스만 봐서는 어떤 내용을 하는지 이해가 안되는 함수들도 몇개 있다.
인터페이스에 선언은 돼있는데 실제로 구현 되지 않은 함수들도 좀 있어서, 구현된 함수 위주로 살펴보자.1. 발행
// 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); }
먼저 첫 줄을 보면 getReserves()라는 함수를 통해 _reserve0, _reserve1 라는 값을 가져온다. 아직 어떤 역할을 하는 함수인지는 잘 모르겠지만 코드를보니 _update와 같은 함수에서 값을 잠시 저장해두는 역할을 하는 것 같다. 아직 와닿지는 않으나 그냥 그러려니 하고 다음 줄을 보자.(getReserves를 사용하는 이유는 이 포스트에서 계속 알아보려고 한다.)
그 다음 줄들을 보면, 이 컨트랙트에 있는 token0, token1의 밸런스를 가져온다. 그리고 위에서 가져온 reserve0, reserve1만큼을 잔고에서 차감한다. reserve값은 코드를 쭉 살펴보니 대충 감이 왔는데 그 전 balance값을 저장해둔 것이다. 여기서 주의할 점은 swap, mint, burn 함수가 실행됐을때만 reserve값이 업데이트 되기 때문에, 유동성 공급을 위해 들어온 돈은 반영이 안됐을 것이다. 또한 실제로 유동성 공급 컨트랙트를 찾아보니, 먼저 토큰 비율 등을 계산하고, 각 컨트랙트로 전송한 뒤에 mint를 실행하고 있다.
따라서 amount0, amount1 값은 유저가 공급한 두 토큰의 양(유동성을 공급한 양)이라고 볼 수 있다. LP를 하기 위해서는 두 토큰을 공급해야 할 것이므로 balance0, balance1값과 amount0, amount1값은 조금 다를 것이고, 그 양은 유저가 공급한 토큰의 양과 같을 것이다.그 다음은 _mintFee라는 함수를 이용해서 feeOn 스위치를 on/off 시켜주는데,, 이 mintFee가 어떤 함수인지 궁금해서 한번 살펴봤다.
// 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; } }
주석을 보니, 발행량이 sqrt(k)의 증가량의 1/6과 같다면 fee가 on이라는데 이것만 봐서는 도통 무슨 소리인지 모르겠다.
코드를 보면 먼저 이 pool factory에서 feeTo를 가져온다. 저번 포스트에서 살펴보기로는 feeTo는 이 pool에서 발생하는 수수료의 일부를 가져가는 컨트랙트 주소였다. 그리고 이 feeTo가 zero address가 아니라면, true를 대입한다. 일단 feeTo는 내가 알기로는 아직 구현돼있지 않아서 if(feeOn) 이후의 로직은 실행되지 않을 것 같다. 그래도 어떤 로직인지 한번 살펴보자.먼저 kLast라는 값이 0인지 체크하는데, 멤버 변수에 달린 주석을 보면 kLast는 reserve0 * reserve1를 계산한 값이라고 한다. 뭐 feeOn이 false인 경우 kLast는 무조건 0으로 설정되게 돼있고, 0이 아닌경우는 파라미터로 들어온 reserve0와 reserve1를 곱하고 루트 값(rootK)을 구한다. 그리고 기존에 0이 아닌 _kLast의 루트(rootKLast)도 구하고, rootK > rootKLast인 경우에 다음 변수들을 구한다.
1. numerator = totalSupply * (rootK - rootKLast)
2. denominator = (rootK * 5) + rootKLast
3. liquidity = numerator / denominator;liquidity가 0보다 큰 경우 feeTo에게 liquidity 양 만큼의 Uni-V2 토큰을 발행한다. (_mint함수는 상속받은 UniswapV2ERC20에 구현돼있다.) 그런데 이게 도대체 무슨 의미를 갖는것일까 고민을 해봤다. 우선 코드에서 kLast가 어떻게 활용되는지 살펴보자.
// Member variable uint public kLast; // reserve0 * reserve1, as of immediately after the most recent liquidity event // ... // In mint() function if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date // ... // In burn() function if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
kLast가 활용되는 코드를 보니, reserve0 * reserve1값이 kLast에 대입되는것 같다. Uniswap은 CPMM(Constant Product Market Maker)방식을 사용한 탈중앙화 거래소이고, 이것의 핵심은 pool을 이루는 두 개의 토큰의 곱이 상수 k로 일정하다는 것이다. 따라서 토큰이 발행, 소각되는 경우에 이 k값을 업데이트 해주는 것이라고 생각할 수 있다.
그런데 코드를 보다가 왜 굳이 kLast를 함수 메모리 변수(지역 변수)로 복사해서 사용하는지 궁금했다. 주석으로 가스비를 절약하기 위해서라고 나와있긴 한데, 그럼 스토리지 변수를 로드하는데 가스비가 드는것인가? 궁금해서 이더리움 황서를 찾아봤다.
이제 위에서 왜 getReserves() 함수를 사용했는지 알 것 같다. getReserves() 함수를 살펴보면 단순히 멤버 변수(즉 storage 변수)를 복사해서 memory 변수에 대입하여 반환하는 역할밖에 하지 않는데, sload를 최소로 사용하도록 하여 가스비를 절감하는 역할을 하는 것이다.
다시 저 feeOn이 참일때의 로직을 살펴보면, 그 전 k값을 저장해 둔 lastK의 루트값과 업데이트된 k = reserve0 * reserve1의 루트값을 비교한다. 만약 업데이트된 k의 루트값이 더 크다면 추가로 pool에 자금이 들어온 상황이므로 이 경우 feeTo에게 수수료를 지불한다. 이 때 위에서 정리한 1, 2, 3번 식을 이용해 수수료를 계산한다. (처음에 왜 루트값을 이용해 비교하나 했는데, 결국 수수료 계산시 루트값을 사용하기 때문에 그런것 같다고 나름 결론내렸다.)
어쨋든 이런 로직을 계산하고 마지막에는 bool타입의 feeOn을 반환한다. 한 가지 특이한 점은, fee를 지불하는 개념이 아니라 새로 발행하는것 같다.
조금 길게 feeOn 함수에 대해 살펴봤는데, 요약하자면 feeOn가 참인 경우 fee를 계산하여 새로 토큰을 발행하고, 거짓인 경우는 아무 일도 일어나지 않는다.
그리고 totalSupply값이 0일때와 아닐때의 동작이 다른데, 아마 초기 발행과 그 이후를 뜻하는 것 같다. 첫 발행시에는 MINIMUM_LIQUIDITY만큼을 zero address에게 발행하는데, 이는 영원히 lock한다는 것을 의미한다. 이 컨트랙트에서 최소 유동량은 10의 3승, 즉 1000이다. 그리고 liquidity = sqrt(amount 0 * amount1) - MINIMUM_LIQUIDITY로 계산한다. 만약 totalSupply가 0이 아니라면 (amount0 * totalSupply) / reserve0과 (amount1 * totalSupply) / reserve1 중에서 작은 값을 liquidity로 정한다. 수식만 보면 애매하긴 한데 잘 생각해보면 liquidity는 전체 발행량 중에 내가 차지하고 있는 비율 정도로 생각할 수 있다. MINIMUM_LIQUIDITY를 사용하는 이유는 유니스왑 문서에 나와있는데, 반올림 오류를 개선하기 위함이라고 한다. (일종의 영점 조절 비슷한 것?) 또한 이 양은 전체 토큰의 양에 비해서 터무니없이 작은 양이기 때문에 별 문제가 없다고 설명하고 있다.(https://uniswap.org/docs/v2/protocol-overview/smart-contracts/#minimum-liquidity)
그리고 liquidity가 양수인지 여부를 체크하고, 발행자(to)에게 liquidity만큼의 token을 발행한다.(여기서는 UNI-V2 토큰) 그리고 _update함수를 실행하는데 이는 아래에서 더 살펴보도록 하겠다.마지막으로 feeOn이 참인 경우, kLast 값을 갱신하고, mint 이벤트를 emit한 후 함수가 종료된다.
2. 소각
// 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); }
burn 함수를 보면 가장 먼저 눈에 띄는게 함수 선언부에 위치한 lock키워드이다. Solidity에는 modifier라고 하는게 있는데, 간단하게 얘기하자면 함수를 꾸며주는 역할을 한다.
uint private unlocked = 1; modifier lock() { require(unlocked == 1, 'UniswapV2: LOCKED'); unlocked = 0; _; unlocked = 1; }
보통 이런식으로 작성하는데, 조건문을 앞에 주렁주렁 달고있다. 그리고 하이픈(_)은 이 modifier가 달린 함수를 실행한다는 뜻이다.
lock modifier를 보면 처음에 unlocked 변수가 1인지 체크하고, 1이라면 unlocked를 0으로 set하고 lock이 붙어있는 함수를 실행하고 다시 unlocked를 1로 셋팅한다. 컴퓨터를 전공했다면 이런 개념을 흔히 접하게 되는데, 동기화를 위한 코드이다. 여러 사람이 컨트랙트 함수를 비슷한 시간에 실행했을때 동기화를 보장해주는 뮤텍스라고 보면 된다.
즉, lock이라는 modifier가 붙은 함수는, 누군가가 함수를 실행중에 있을 때, 다른 사람이 함수를 실행할 수 없다.이렇게 lock 키워드에 대해 간단히 살펴봤고, 파라미터는 amount0, amount1이라는 두 개의 숫자 변수가 들어온다. 아마 두 토큰 소각할 양을 뜻하는 것 같다.
여기서도 마찬가지로 storage변수들을 memory변수로 끌고 오는 것을 볼 수 있다. 가스비를 줄이기 위한 눈물나는 노력 ㅠ_ㅠ (근데 진짜 가스비 양심없음,,,) 풀을 구성하고 있는 두 토큰 주소를 통해 pool에 존재하는 각 토큰의 balance를 가져오고, LP 토큰의 밸런스도 가져온다. 그리고 amount값은 balance * (liquidity / totalSupply)를 통해 역산한다.여기서 알 수 있는 점은 내가 유동성을 해제할 때 받는 토큰의 양이 처음과 달라질 수 있다는 점인 것 같다. 우선 liquidity가 무엇을 뜻 하는지에 대해 생각해 볼 필요가 있는데, 유동성을 해제하는 비즈니스 로직을 구현한 periphery의 코드를 보면(https://github.com/Uniswap/uniswap-v2-periphery/blob/master/contracts/UniswapV2Router02.sol), 먼저 pool 토큰을 이 컨트랙트로 보낸 후(liquidity가 증가함)에 burn 함수를 호출한다. 즉, 정상적인(이라고 표현하는게 맞는지 모르겠는데) liquidity 상태에서 무언가 변화가 일어난 것이다. 따라서 증가한 만큼의 liquidity를 이용해 전체 보유량 대비 내가 보유하고 있는 pool token의 비율을 계산한 뒤, 컨트랙트에 있는 A, B 토큰을 그 비율만큼 돌려주는 것이다. 이걸 보니 AMM이 한 층 더 깊게 이해되는것 같다. (기존에는 곱이 같고, 이런것만 알고 해제할때는 얼마를 돌려주는지를 잘 몰랐다.)
이렇게 보니 Impermanet loss도 조금 더 이해가 되는 것 같은데, 두 토큰의 비율이 계속 변하므로 내가 돌려받는 토큰의 양이 바뀌는 것 때문에 발생하는 것 같다. 자세한 것은 더 공부해보고 포스팅 해야겠다.
그리고 두 토큰의 amount값이 양수인지 여부를 체크하고, _burn을 실행한다. 이 함수는 UniswapV2ERC20 컨트랙트에 정의돼있고, 내부를 살펴보면 정말 간단한데 사용자의 잔고와 totalSupply에서 소각할 양 만큼을 차감한다. 여기서 소각되는 토큰은 UNI-V2 토큰이고, 소각의 의미를 생각해보면 유동성 공급을 해제하는 것이라고 볼 수 있을것 같다. 이는 다음 코드를 통해 확인할 수 있는데, burn후에 다시 safeTransfer함수를 통해 amount0, amount1만큼의 각 토큰을 소각자에게 보낸다. 즉 UNI-V2토큰을 소각하고 공급했던 각 토큰을 회수하는 동작이다.그리고 balance0, balance1값을 갱신하고 _update함수를 실행한다. 그 후에는 발행과 마찬가지로 feeOn이 참인 경우는 kLast를 갱신하고, Burn 이벤트를 emit한다.
3. 교환(swap)
// 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); }
여기까지만 해도 엄청나게 긴데, 하나가 더 남았다. 이번에 살펴볼 마지막 함수인 swap함수. 어떻게 보면 가장 핵심적인 기능이 아닐까 싶다.
먼저 이 함수도 lock modifier가 붙어있는것으로 봐서는 한번에 한명만 실행할 수 있고, 파라미터로는 amount0Out amout1Out, 어떤 주소 to, calldata가 들어온다. docs를 보니 calldata는 플래시론(하나의 트랜잭션에서 대출과 상환이 모두 이루어지는 작업)의 경우에만 존재하고 그 외의는 length가 0인 데이터가 들어온다고 한다. 각각이 어떤 의미인지는 대충은 알겠는데 정확한 것은 함수 내용을 통해 살펴보자.
먼저 amunt0Out, amount1Out이 양수인지 체크하고, reserve0, reserve1값보다 크지는 않은지 체크한다.(풀에 존재하는 양보다 많은 양을 스왑할 수는 없으니까)코드를 보면 먼저 풀에 존재하는 각 토큰의 주소를 가져오고, to로 가져온 주소와 같지는 않은지 확인한다. 간단한 체크 같은데 스왑의 대상이 그 토큰 컨트랙트면 안되는 것은 당연한 것 같다.
이어지는 조건문에서 각 amount가 0보다 크면, 해당 토큰을 token0에서 to로 전송한다. 이는 풀에 존재하는 토큰을 스왑을 실행하는 사람에게 전송한다고 생각하면 될 것 같다. 그런데 내가 스왑이라 하면, 두 토큰을 교환(즉, A를 주고 B를 받던지, B를 주고 A를 받는)하는 작업인데 왜 코드상에는 두 토큰을 모두 to로 보내는 로직이 작성됐는지 잘 모르겠다. 개인적인 추측으로는 파라미터 콜을 외부에서 A, B 비율에 맞게 잘 조절해주는 것 같다.(사실 유니스왑을 써보면 스왑할 토큰을 설정하고 한 토큰의 양을 입력하면, 교환되는 나머지 토큰의 비율이 자동으로 잡힌다.) 이런 플로우를 다 이해하려면 코드 분석 후에 유니스왑 전체적인 아키텍처 분석도 해봐야겠다.그리고 대충 로직은 알겠는데 왜 스코프(새로운 중괄호 쌍)를 설정했을까? 주석을 보면 avoids stack too deep 이라는 에러를 피하기 위해서라는데, 궁금해서 조금 더 찾아봤다. 이 에러는 EVM Stack의 깊은 곳을 살펴볼때 발생할 수 있는 에러라고 한다. 보통 스택의 깊이가 16이 넘어가는 경우에 발생하는 에러라고 한다. 대충 추측해보자면 EVM의 스택 사이즈가 한정돼있고, 새로운 스코프를 지정해주면 기존 스택은 백업해둔다던지 하고 스택을 새로 셋팅하는 것 같기는 한데...더 자세한 내용은 추후 포스트에서 다뤄봐야겠다. (사실 다 조사해서 쓰고싶은데 진짜 블로그 글 너무 길어지는것 같다...)
그 다음으로 balance0, balance1에는 토큰의 잔고를 가져와서 대입한다. transfer를 했으니까 balance값을 초기화해주는 과정인 것 같다. amount0In, amount1In의 경우는 swap했을때 들어오는 잔고라고 보면 될 것 같다. 코드를 보다 보니, amount0Out, amount1Out가 컨트랙트 내에서 계산되는게 아니라 파라미터로 직접 주입되는 구조여서, 컨트랙트로 들어오는 토큰의 양 또한 이를 이용해서 계산하는 것 같다. 그래서 새로 계산한 balance값이 reserve - amountOut 값 보다 큰 경우 해당 값으로 amountIn값을 대입하고, 아닌 경우 0을 대입한다. balance는 가장 최신 잔고 값이라고 생각하면 될 것 같다.
거의 다 끝나가는데, amount0In과 amount1In 중 하나가 0 이상인지를 체크한다. 일단 amountIn 값을 계산하는 로직에서 음수가 나오기는 힘들 것 같고, 둘 다 0이라면 balance = reserve - amountOunt, 즉 두 토큰이 둘 다 들어온 상황을 뜻하는 것 같다. swap은 하나만 들어오고 하나는 나가는 작업이니 이런 경우는 파라미터가 잘못된 경우라고 판단하는 것 같다.
그리고 마지막에는 balance0Adjusted와 balance1Adjusted값을 계산하는데 공식은 (balance * 1000) - (amountIn * 3)이다. 그리고 balance0Adjusted * balance1Adjusted >= reserve0 * reserve1 * 1000^2를 만족하는지 체크한다. 그리고 update함수를 실행해 잔고 등을 업데이트하고, Swap 이벤트를 emit 후 함수를 종료한다.
뭔가 두서도 없고 마지막 로직은 왜 adjust값을 실행하는지도 아직 잘 모르겠다. 레퍼런스가 좀 부족하기도 하고, 대부분 영어라 해석하는데 좀 걸리기도 하는데 그래도 블로그에 글 쓰면서 공부하니까 확실히 뭔가 하는 것 같긴 하다 =_=;; 코어 코드를 완벽하게 이해하려면 periphery쪽 코드도 다 봐야할 것 같고, 전체적인 컨트랙트 아키텍처도 이해해야 할 것 같다.
'블록체인' 카테고리의 다른 글
[DeFi] Uniswap V2 Contract 코드 분석 - Periphery, Migrator (2) 2021.03.08 [Defi] Uniswap V2 Architecture 분석 (0) 2021.02.24 [Defi] Uniswap V2 Contract 코드 분석 1 - Factory (1) 2021.02.08 [DeFi] Mirror에서 Mirror token Staking 하기 (10) 2021.02.04 [DeFi] Sushiswap 에서 SUSHI-WETH LP하기 (0) 2021.02.03