티스토리 뷰
⛓️ 시리즈
2024.01.30 - [Solidity/DeFi] - [Uniswap] V2 Core - UniswapV2ERC20
2024.01.31 - [Solidity/DeFi] - [Uniswap] V2 Core - UniswapV2Factory
2024.01.31 - [Solidity/DeFi] - [Uniswap] V2 Core - UniswapV2Pair
2024.02.05 - [Solidity/DeFi] - [Uniswap] V2 Core 보충 자료 - 백서 읽기
🦄 Router
라우터는 코어 컨트랙트의 기능을 하나의 트랜잭션으로 손쉽게 호출할 수 있도록 편의성을 제공하는 함수들을 정의합니다. Uniswap V2에는 2 종류의 라우터가 정의되어 있습니다. 각각 01, 02를 붙여서 구분하는데, 02 라우터가 01 라우터를 상속하는 것을 보아하니 일부 기능이 확장된 라우터임을 짐작할 수 있습니다. 이에 대해서는 01 라우터부터 차근차근 살펴보도록 하겠습니다.
🛰️Router01
1. IUniswapV2Router01 인터페이스
// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.6.2;
interface IUniswapV2Router01 {
function factory() external view returns (address);
function WETH() external view returns (address);
function addLiquidity(
address tokenA,
address tokenB,
uint amountADesired,
uint amountBDesired,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) external returns (uint amountA, uint amountB, uint liquidity);
function addLiquidityETH(
address token,
uint amountTokenDesired,
uint amountTokenMin,
uint amountETHMin,
address to,
uint deadline
)
external
payable
returns (uint amountToken, uint amountETH, uint liquidity);
function removeLiquidity(
address tokenA,
address tokenB,
uint liquidity,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) external returns (uint amountA, uint amountB);
function removeLiquidityETH(
address token,
uint liquidity,
uint amountTokenMin,
uint amountETHMin,
address to,
uint deadline
) external returns (uint amountToken, uint amountETH);
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 returns (uint amountA, uint amountB);
function removeLiquidityETHWithPermit(
address token,
uint liquidity,
uint amountTokenMin,
uint amountETHMin,
address to,
uint deadline,
bool approveMax,
uint8 v,
bytes32 r,
bytes32 s
) external returns (uint amountToken, uint amountETH);
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external returns (uint[] memory amounts);
function swapTokensForExactTokens(
uint amountOut,
uint amountInMax,
address[] calldata path,
address to,
uint deadline
) external returns (uint[] memory amounts);
function swapExactETHForTokens(
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external payable returns (uint[] memory amounts);
function swapTokensForExactETH(
uint amountOut,
uint amountInMax,
address[] calldata path,
address to,
uint deadline
) external returns (uint[] memory amounts);
function swapExactTokensForETH(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external returns (uint[] memory amounts);
function swapETHForExactTokens(
uint amountOut,
address[] calldata path,
address to,
uint deadline
) external payable returns (uint[] memory amounts);
function quote(
uint amountA,
uint reserveA,
uint reserveB
) external pure returns (uint amountB);
function getAmountOut(
uint amountIn,
uint reserveIn,
uint reserveOut
) external pure returns (uint amountOut);
function getAmountIn(
uint amountOut,
uint reserveIn,
uint reserveOut
) external pure returns (uint amountIn);
function getAmountsOut(
uint amountIn,
address[] calldata path
) external view returns (uint[] memory amounts);
function getAmountsIn(
uint amountOut,
address[] calldata path
) external view returns (uint[] memory amounts);
}
인터페이스부터 살펴보면, 다음과 같은 기능들이 정의되어 있음을 짐작할 수 있습니다.
- 유동성 풀에 유동성 제공
- 유동성 풀에서 유동성 제거
- 정해진 양의 토큰 A를 사용해 토큰 B로 스왑
- 원하는 양의 토큰 B를 얻기 위해 토큰 A를 스왑
- 정해진 양의 토큰을 사용해 ETH로 스왑 (또는 그 반대)
- 원하는 양의 토큰을 얻기 위해 ETH를 스왑 (또는 그 반대)
- 스왑 가격 계산
한 가지 눈에 띄는 것은 'WETH'라는 getter 함수입니다. 이 함수는 'Wrapped Ether'라는 ERC-20 토큰 컨트랙트의 주소를 반환합니다.
function WETH() external view returns (address);
그런데 WETH는 무엇이고 왜 사용되는 것일까요?
2. Wrapped Ether: WETH
Wrapped Ether는 말 그대로 ETH를 래핑한 ERC-20 토큰입니다. 1 WETH는 1 ETH와 동등한 가치를 지닙니다. 예를 들어, deposit 함수를 호출하여 1 ETH를 컨트랙트에 전송하면 1 WETH 만큼의 잔액이 추가됩니다. 그리고 ETH가 필요해지면 withdraw 함수를 호출하여 WETH를 반납하고 반납한 만큼의 ETH를 찾아갈 수 있습니다.
그렇다면 WETH는 왜 필요할까요? 일반적으로 이더리움의 통화인 ETH와 ERC-20 토큰은 상호호환이 되지 않습니다. 이 때문에 스마트 컨트랙트 상에서 ETH와 토큰을 교환하려면 상당히 복잡한 과정이 필요했습니다. 그러나 WETH를 도입함으로써 ERC-20 토큰끼리의 손쉬운 교환이 가능해졌습니다. 특히 Uniswap V2부터는 토큰 쌍의 교환을 지원하므로 WETH의 사용은 필수불가결이라고 볼 수 있습니다.
3. addLiquidity 함수
function addLiquidity(
address tokenA,
address tokenB,
uint amountADesired,
uint amountBDesired,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) external returns (uint amountA, uint amountB, uint liquidity);
addLiquidity 함수는 유동성 풀에 유동성(토큰 쌍)을 추가하는 함수입니다. 함수의 파라미터는 다음과 같습니다.
- tokenA: 토큰 A의 주소
- tokenB: 토큰 B의 주소
- amountADesired: 유동성 풀에 추가할 수 있는 토큰 A의 개수의 상한
- amountBDesired: 유동성 풀에 추가할 수 있는 토큰 B의 개수의 상한
- amountAMin: 유동성 풀에 추가할 수 있는 토큰 A의 개수의 하한
- amountBMin: 유동성 풀에 추가할 수 있는 토큰 B의 개수의 하한
- to: 유동성 토큰을 지급받을 주소
- deadline: 함수 호출이 유효한 기간
addLiquidity 함수의 동작 과정은 다음과 같습니다.
- ensure modifier를 통해 함수 호출의 유효성을 검사합니다.
- 토큰 A와 토큰 B의 페어가 존재하지 않으면 팩토리를 통해 새로 생성합니다.
- 입력된 파라미터를 기반으로 유동성 풀에 추가할 토큰의 개수 amountA와 amoutB를 구합니다.
- transferFrom 함수를 통해 토큰 A와 토큰 B를 페어 컨트랙트로 전송합니다.
- liquidity 만큼의 유동성 토큰을 to에게 전송합니다.
주의할 점은 transferFrom을 통해 토큰이 전송되어야 하므로 별도로 approve를 사용해 토큰 사용을 허용해줘야 합니다.
4. addLiquidityETH 함수
function addLiquidityETH(
address token,
uint amountTokenDesired,
uint amountTokenMin,
uint amountETHMin,
address to,
uint deadline
)
external
payable
returns (uint amountToken, uint amountETH, uint liquidity);
addLiquidityETH 함수는 유동성 풀에 유동성(토큰, ETH)을 추가하는 함수입니다. 함수의 파라미터는 다음과 같습니다.
- token: 토큰의 주소
- amountTokenDesired: 유동성 풀에 추가할 수 있는 토큰의 개수의 상한
- amountTokenMin: 유동성 풀에 추가할 수 있는 토큰의 개수의 하한
- to: 유동성 토큰을 지급받을 주소
- deadline: 함수 호출이 유효한 기간
addLiquidityETH 함수의 동작 과정은 다음과 같습니다.
- ensure modifier를 통해 함수 호출의 유효성을 검사합니다.
- 토큰과 WETH의 페어가 존재하지 않으면 팩토리를 통해 새로 생성합니다.
- 입력된 파라미터를 기반으로 유동성 풀에 추가할 토큰의 개수 amountToken과 WETH의 개수 amountETH를 구합니다.
- transferFrom 함수를 통해 토큰을 페어 컨트랙트로 전송합니다.
- WETH 컨트랙트에 amountETH를 입금하고 동일한 양의 WETH를 받습니다.
- transfer 함수를 통해 WETH를 페어 컨트랙트로 전송합니다.
- liquidity 만큼의 유동성 토큰을 to에게 전송합니다.
- 사용하고 남은 ETH를 호출자에게 반환합니다.
addLiquidity와 달리 하나의 토큰을 입력받고 다른 하나는 이더를 직접 받아 WETH로 변환하여 유동성 풀에 추가합니다. addLiquidity 함수와 마찬가지로 미리 approve 함수를 호출하여 토큰 사용을 허용해줘야 합니다.
5. removeLiquidity, removeLiquidityETH 함수
function removeLiquidity(
address tokenA,
address tokenB,
uint liquidity,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) external returns (uint amountA, uint amountB);
function removeLiquidityETH(
address token,
uint liquidity,
uint amountTokenMin,
uint amountETHMin,
address to,
uint deadline
) external returns (uint amountToken, uint amountETH);
removeLiquidity, removeLiquidityETH 함수는 유동성 토큰을 반납하고 그에 상응하는 유동성을 유동성 풀에서 인출하는 함수입니다.
두 함수의 공통적인 동작 과정은 다음과 같습니다.
- ensure modifier를 통해 함수 호출의 유효성을 검사합니다.
- transferFrom 함수를 사용해 liquidity 만큼의 유동성 토큰을 페어 컨트랙트로 전송합니다.
- 페어 컨트랙트의 burn 함수를 호출해 유동성을 인출하여 to에게 전송합니다.
- 만약 인출된 토큰의 양이 최소한도보다 작은 경우 함수는 revert 됩니다.
removeLiquidityETH 함수의 경우 다음과 같은 로직이 추가됩니다.
- 페어 컨트랙트에서 to에게 제거된 유동성을 직접 전송하는 우선 라우터 컨트랙트에게 전송합니다.
- transfer 함수를 사용해 라우터 컨트랙트에서 to에게 인출된 토큰을 전송합니다.
- WETH 컨트랙트의 withdraw 함수를 호출하여 인출된 WETH에 상응하는 ETH를 인출합니다.
- 인출된 ETH를 to에게 전송합니다.
인출될 유동성 토큰은 사전에 approve 함수를 사용해 라우터 컨트랙트에게 사용 허가를 내려줘야 합니다.
6. removeLiquidityWithPermit, removeLiquidityETHWithPermit 함수
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 returns (uint amountA, uint amountB);
function removeLiquidityETHWithPermit(
address token,
uint liquidity,
uint amountTokenMin,
uint amountETHMin,
address to,
uint deadline,
bool approveMax,
uint8 v,
bytes32 r,
bytes32 s
) external returns (uint amountToken, uint amountETH);
라우터에서 transferFrom을 사용해 유동성 토큰을 전송하기 위해서는 사전에 approve 함수를 호출하여 토큰의 사용을 허용해야 하는 번거로움이 있습니다.
removeLiquidityWithPermit, removeLiquidityETHWithPermit 이 두 함수는 ERC-2612를 구현한 페어 컨트랙트의 permit 함수를 호출하여 approve를 직접 호출하지 않고도 함수 호출에 사용된 v, r, s 서명값을 사용해 페어 컨트랙트에서 유동성 토큰을 인출해 가도록 합니다.
여기서 드는 의문점은 왜 유동성을 추가하는 데에는 permit을 사용하지 않았는가입니다. 이에 대한 대답은 '모든 ERC-20 토큰 컨트랙트가 ERC-2612를 구현하지 않았기 때문'이라고 답할 수 있습니다. 반면 페어 컨트랙트는 누구라도 분명하게 ERC-2612를 구현했음을 확인할 수 있으므로 이와 같이 WithPermit을 붙인 함수에서 사용될 수 있습니다.
7. _swap 함수
/**
* @dev 여러 경로를 통해 토큰을 교환하고 최종적으로 _to에 전송
* @param amounts 교환할 토큰의 양 배열
* @param path 토큰을 교환할 토큰 컨트랙트의 주소 배열
* @param _to 최종 수신자
*/
function _swap(
uint[] memory amounts,
address[] memory path,
address _to
) private {
// path[0]을 amounts[0]만큼 path[0]-path[1] 페어로 전송해 놓은 상태에서 시작
for (uint i; i < path.length - 1; i++) {
(address input, address output) = (path[i], path[i + 1]); // i번째 토큰을 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)); // 정렬된 컨트랙트 주소에 따라 amount0Out, amount1Out 설정
address to = i < path.length - 2
? UniswapV2Library.pairFor(factory, output, path[i + 2])
: _to; // output을 전송할 주소 설정
IUniswapV2Pair(UniswapV2Library.pairFor(factory, input, output)).swap(
amount0Out,
amount1Out,
to,
new bytes(0)
);
}
}
_swap 함수는 다른 스왑 함수들의 기반이 되는 내부함수입니다. 이 함수에 따르면 토큰 A를 토큰 B로 한 번에 스왑 할 수도 있지만, 여러 경로를 거쳐서 스왑 하는 것도 가능합니다. _swap 함수의 파라미터는 다음과 같습니다.
- amounts: amount[i]는 이전의 스왑 결과로 페어 컨트랙트로 전송된 i-1(i > 1)번째 토큰과 교환할 i번째 토큰의 양을 가리킵니다. i가 0인 경우는 처음 스왑이 이루어질 페어 컨트랙트로 전송하는 토큰의 양을 가리킵니다.
- path: 순서대로 스왑 할 토큰 컨트랙트의 주소를 나타냅니다.
- _to: 마지막 스왑이 마무리되면 최종적으로 스왑 된 토큰을 받을 주소를 가리킵니다.
_swap 함수의 실행과정은 다음과 같습니다.
- _swap 함수가 호출되기 전에 path[0]와 path[1] 토큰의 페어 컨트랙트로 amount[0] 만큼의 path[0] 토큰이 전송되어 있어야 합니다.
- for문을 통해 path[i] 토큰을 path[i+1] 토큰을 스왑하는 과정을 반복합니다. 이 때 amount[i+1] 만큼의 path[i+1] 토큰을 받게 됩니다.
- 스왑 결과는 path[i+1] 토큰과 path[i+2] 토큰의 페어 컨트랙트로 전송됩니다.
- 만약 i가 path.length-2라면 모든 스왑 과정을 마친 것으로, 최종 결과를 _to에게 전송합니다.
8. swapExact**For** 함수
정해진 입력값에 대해 예상되는 최소 출력 이상을 얻을 수 있는 경우에만 실행되는 스왑 함수들입니다.
- swapExactTokensForTokens
- swapExactETHForTokens
- swapExactTokensForETH
9. swap**ForExact** 함수
정해진 출력값에 대해 예상되는 최대 입력 이하를 사용하는 경우에만 실행되는 스왑 함수들입니다.
- swapTokensForExactTokens
- swapETHForExactTokens
- swapTokensForExactETH
9. swapExact**For**과 swap**ForExact** 의 차이
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);
}
}
swapExact**For** 함수는 getAmountsOut 함수를 사용해 초기 입력으로부터 예상되는 출력들을 순서대로 계산합니다.
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);
}
}
swap**ForExact** 함수는 getAmountsIn 함수를 사용해 최종 출력으로부터 예상되는 입력을 역순으로 계산합니다.
🛰️Router02
1. IUniswapV2Router02 인터페이스
// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.6.2;
import "./IUniswapV2Router01.sol";
interface IUniswapV2Router02 is IUniswapV2Router01 {
function removeLiquidityETHSupportingFeeOnTransferTokens(
address token,
uint liquidity,
uint amountTokenMin,
uint amountETHMin,
address to,
uint deadline
) external returns (uint amountETH);
function removeLiquidityETHWithPermitSupportingFeeOnTransferTokens(
address token,
uint liquidity,
uint amountTokenMin,
uint amountETHMin,
address to,
uint deadline,
bool approveMax,
uint8 v,
bytes32 r,
bytes32 s
) external returns (uint amountETH);
function swapExactTokensForTokensSupportingFeeOnTransferTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external;
function swapExactETHForTokensSupportingFeeOnTransferTokens(
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external payable;
function swapExactTokensForETHSupportingFeeOnTransferTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external;
}
IUniswapV2Router02는 IUniswapV2Router01을 상속하고 있어서 구현체의 기본적인 기능은 유사합니다. 다만 비슷한 기능을 하는 함수들에 'SupportingFeeOnTransferTokens'라는 접미사가 붙어 있는 것을 볼 수 있습니다. 이것이 의미하는 바가 무엇일까요?
2. SupportingFeeOnTransferTokens
유동성을 제거할 때
(, amountETH) = removeLiquidity(
token,
WETH,
liquidity,
amountTokenMin,
amountETHMin,
address(this),
deadline
);
TransferHelper.safeTransfer(
token,
to,
IERC20(token).balanceOf(address(this))
);
유동성 풀에서 제거된 토큰을 전송하는 것이 아닌, 라우터의 잔액을 모두 전송하도록 되어 있습니다. 그리고 ETH와 토큰 페어에 대해서만 해당 로직이 적용됩니다. ETH를 유동성 풀에 추가하거나 제거할 때 WETH로 변환하는 과정이 추가되어 있기 때문에 가스비가 더 많이 지출되는 것을 보상해 주기 위한 방안이라고 보면 될까요? 아직은 의도를 잘 모르겠습니다.
실제 의도
transfer 과정에서 수수료가 발생하는 토큰에 대한 지원을 위해 사용된다고 합니다. 따라서 removeLiquidity 함수에서 반환되는 amountToken을 사용하지 않고 라우터에서 토큰을 받아서 다시 받은 만큼을 to에게 전송하는 식으로 진행이 되는 것입니다. 그런데 토큰 쌍이 모두 수수료가 발생하는 경우는? 흠... WETH-ERC20 페어를 거쳐서 스왑을 해야되겠군요.
스왑 할 때
function _swapSupportingFeeOnTransferTokens(
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);
IUniswapV2Pair pair = IUniswapV2Pair(
UniswapV2Library.pairFor(factory, input, output)
);
uint amountInput;
uint amountOutput;
{
// scope to avoid stack too deep errors
(uint reserve0, uint reserve1, ) = pair.getReserves();
(uint reserveInput, uint reserveOutput) = input == token0
? (reserve0, reserve1)
: (reserve1, reserve0);
amountInput = IERC20(input).balanceOf(address(pair)).sub(
reserveInput
);
amountOutput = UniswapV2Library.getAmountOut(
amountInput,
reserveInput,
reserveOutput
);
}
(uint amount0Out, uint amount1Out) = input == token0
? (uint(0), amountOutput)
: (amountOutput, uint(0));
address to = i < path.length - 2
? UniswapV2Library.pairFor(factory, output, path[i + 2])
: _to;
pair.swap(amount0Out, amount1Out, to, new bytes(0));
}
}
토큰을 전송할 때 수수료가 발생하므로 사전에 getAmountsOut 함수를 사용해 예측된 결과가 모두 빗나갈 수 있습니다. 따라서 스왑을 실행하기 전에 일일이 getReserves 함수를 호출하여 출력을 새로 계산해줘야 합니다. 이 경우는 정확한 입력을 알고 있어야 출력을 구할 수 있기 때문에 정해진 입력값이 주어지는 swapExact**For** 함수에 대해서만 적용할 수 있습니다.
🥷마무리
오늘은 Uniswap V2 라우터에 대해 알아보았습니다. Uniswap V2는 컨트랙트가 모듈화되어 있기 때문에 라우터를 통해 하나의 트랜잭션으로 로직을 처리하고 있습니다. 물론 approve 함수를 별도로 실행해야 하는 번거로움은 여전한 것 같습니다. 이와 관련해서는 앞서 말씀드렸다시피 ERC-2612가 모든 ERC-20 토큰 컨트랙트에 대해 호환이 되지 않기 때문에 무턱대고 사용할 수 없는 데에 이유가 있습니다.
이어지는 게시글에서는 Uniswap V2를 활용한 오라클이나 플래시 스왑 예제를 다뤄보고자 합니다. 긴 글 읽어주셔서 감사합니다.
'Solidity > DeFi' 카테고리의 다른 글
[Uniswap] V2 FlashSwap 예제 (1) | 2024.02.22 |
---|---|
[Uniswap] V2 Oracle 예제 (0) | 2024.02.21 |
[Uniswap] V2 Core 보충 자료 - 백서 읽기 (0) | 2024.02.05 |
[Uniswap] V2 Core - UniswapV2Pair (0) | 2024.01.31 |
[Uniswap] V2 Core - UniswapV2Factory (1) | 2024.01.31 |