Uniswap V2에 대한 문제를 풀고 몇 가지 챙겨둘 것들이 생겨 기록하고자 한다.
처음으로 제대로 try catch문을 이해하였다.
solidity에서는 다른 일반적인 언어들에서 사용되는 try catch문과 사뭇 다르다.
slippage를 0.3%씩 늘려서 swap이 성공할때까지 호출하려고 하는 함수이다.
try pair.swap(_amount0Out, _amount1Out, address(this), "") {}
catch {
_swapWithRetries(_pair, _amount0Out, _amount1Out, _maxRetries, _retryNo + 1);
}
pair.swap(_amount0Out, _amount1Out, address(this), "") 부분이 성공적으로 호출되면 try 내부의 구문을 실행하게 된다.
(bool success,) = address(pair).call(
abi.encodeWithSignature("swap(uint256,uint256,address,bytes)", _amount0Out, _amount1Out, address(this), "")
);
if (!success) {
_swapWithRetries(_pair, _amount0Out, _amount1Out, _maxRetries, _retryNo + 1);
}
이렇게 low-level call을 활용해서도 동일하게 코드를 작성할 수 있다.
계속 test가 fail이 나서 답지와 한참을 비교해보았더니 다음과 같았다.
function _sortTokens(address tokenA, address tokenB) internal pure returns (address token0, address token1) {
// TODO: Implement tokens sorting functionality as in Uniswap V2 Factory `createPair` function
require(tokenA != tokenB, "Sniper: IDENTICAL_ADDRESS");
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0), "Sniper: ZERO_ADDRESS");
}
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
이렇게 token0, token1를 동일하게 재선언하여 내부에 지역 변수에 값이 저장되게 되었다.
이렇게 작성하여 결과적으로 리턴되는 값은 (0, 0)이다. (shadowing으로 인해 리턴 변수에 아무 것도 넣어주지 않게됨)
Uniswap V2의 swap 함수에서 한번 더 짚고 넘어갈 것이 있어 정리하였다.
여기서 유의할 점은 일반적으로 스왑을 진행하는 경우에는 다음과 같이 Router의 swap 함수들을 사용한다
out양이 고정될 경우 InMin을, in양이 고정될 경우 OutMin을 파라미터로 받는다.
이후에 require문을 사용하여,
out양이 고정될 경우 minIn이 getAmountsIn()의 리턴 값 이상인지,
in양이 고정될 경우 minOut이 getAmountsOut()의 리턴 값 이하인지를 체크하고 이것에 만족하고 나면
실질적인 Pair contract의 swap 함수를 호출하게 된다.
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external
Pair의 swap 함수에는 amountIn이 파라미터로 들어가지 않는다.
// 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 {
// #1
require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
// #2
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
// #3
require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');
// #4
uint balance0;
uint balance1;
// #5 (Core Logic)
{ // 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));
}
// #6
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');
// #7
{ // 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));
// #8
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);
}
#5를 보면, 우선 amount0Out과 amount1Out을 묻따 to 주소로 보내준다.
그리고 data가 있을 경우, to 주소의 uniswapV2Call 함수를 호출해준다.
함수 호출이 마치고 나서
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
줄이 되기 이전까지 모든 상환을 마쳐야 한다.
이것은 #6, #7, #8에서 확인을 하게 된다.
우선 #6을 보자
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');
여기서 reserve는 해당 Pair 컨트랙트가 tracking하고 있는 실질적인 잔액이다.
물론, ERC20 transfer 혹은 transferFrom 함수를 이용하여 UniswapV2의 인터페이스 함수를 사용하지 않고 토큰을 전송하는 경우, 실질적인 토큰 컨트랙트의 잔액과 맞지 않을 수 있다.
(이 부분은 sync() 함수로 맞추어 줄 수 있다.)
위의 코드의 의미는 다음과 같다.
balance0 > _reserve0 - amount0Out
(토큰 상환 후 최종잔액) (swap 호출 전 원래 잔액) (대출해준 금액)
부등호의 의미가 다소 헷갈릴 수도 있는데, 대략 이런 맥락이다.
(토큰 상환 후 잔액) > (토큰 빌려준 후 잔액)
이렇게 보면 조금 더 당연하게 보인다.
(당연히 상대에게 토큰을 빌려줬을 때보다 상대방이 토큰을 상환했을 때 잔액이 더 많아야됨)
다만 여기서 아직까지 정확한 금액을 상환했는지는 체크하지 않는다.
100을 빌려주고 1만 상환해도 해당 부등호는 통과할 수 있다.
#7과 #8을 보자
// #7
{ // 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));
// #8
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
}
이 부분은 다음과 같이 수식적으로 이해할 수 있다.
(x_1 - 0.003*x_in) * (y_1 - 0.003*y_in) >= x_0 * y_0
x_0, y_0는 거래 전 금액, 그리고 x_1과 y_1은 거래 후 금액이다 (#6까지).
(1000*x_1 - 3*x_in) * (1000*y_1 - 3*y_in) >= 1000**2 * x_0 * y_0
소수점을 정수로 표현하기 위해 양 변에 1000을 2번 곱해주면 이렇게 표현이 가능하다.
즉 #6에서 조금이라도 상환을 하였는지 체크하고, #7,#8에서 fee를 제대로 포함하였는지를 위의 부등식으로 체크하게 되는 것이다.