※ 2021年7月19日にnoteで投稿した記事の移植です。
この記事ではPancakeSwapのコントラクトがswap時にどのような挙動をするかを記載しています。具体的には基本的なswapの種類の説明と、swapExactTokensForTokensに対して内部実装の解説です。誤っている点があったらコメントを頂けると嬉しいです。
またPancakeSwapはUniswap v2のフォークなので事実上Uniswap v2の説明となります。PancakeSwapのドキュメントにもUniswap v2のドキュメントを読めと記載されているので記事中ではUniswap v2のドキュメントを参照します。
swapはざっくりと以下のようなシーケンスで行われます。
swap前にトークンにapproveが必要な理由はswap時にPancakeRouterが対象トークンのコントラクトに対してtransferFromというメソッドを利用してトークンを移動させますが、この際にapproveで指定された量までしか移動できないためです。
approveは小切手を発行するメソッドです。指定したアドレスに対し、approveで指定した量までtransferFrom経由で移動させることを許可します。例えばapproveで数量: 100を指定した場合、対象者が100の数量をtransferFromで移動させると対象者はそれ以上transferFromで移動させることができません。利便性の観点からrouterにapproveするときはかなり大きな数値を指定するようになっているため、小切手が尽きないようになっています。
Uniswapのswap系メソッドは以下にまとまっています。
メソッドはswap{A}For{B}の命名規則になっています。Aを入力トークン、Bを出力トークンを呼びます。またETHは基軸通貨と呼びます。
まずExactの説明です。ExactがAの前にある場合は出力トークンの最小量を指定します。Bの前にある場合は入力トークンの最大量を指定します。UIから指定する際に入力トークンの量を指定して購入する場合はswapExact{A}For{B}となり、出力トークンの量を指定して購入する場合はswap{A}ForExact{B}となります。UIでは以下のように変化します。
Toに入力した場合はswap{A}ForExact{B}となり最大入力量が表示される
slippageを変化させるとMinimum received, Maximum soldの値が変化します。また、transfer taxがかかるトークンとswap{A}ForExact{B}は相性が悪いです。To側の数量を指定して売買をするためには自分でtransfer taxを加味した数値にする必要があるためです。
次にETHの説明です。swap{A}For{B}のAにETHが来るパターンとBにETHが来るパターンがあります。これは入力トークンに基軸通貨を使うか、出力に基軸通貨を使う場合に利用されます。基軸通貨は他のトークンを扱いが違うためメソッドも分かれています。端的に他のトークンとの違いを言うと基軸通貨にはアドレスが存在しません。
基軸通貨をWrapし、WETH、WBNBなどにすることで他のトークンと同様の扱いが行えるようになります。swapETHFor{B}の場合は最初のRouteがWrapped Tokenとなり、swap{A}ForETHの場合は最後のRouteがWrapped Tokenとなります。
最後にswap{A}For{B}SupportingFeeOnTransferTokensについてです。これは転送料がかかるトークンに対して利用します。前提知識で述べた通り、swapするためには一度PancakePairのLPアドレスにトークンをtransferする必要があります。transfer taxがかかるトークンの場合、PancakePairのLPアドレスにトークンが到着した時点でFromに指定した値より小さくなっているケースが考えられます。それを考慮したswapが行えるメソッドはSupportingFeeOnTransferTokensのsuffixがついています。
ここからが本題です。swapExactTokensForTokensのコードは以下のようになっています。
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external virtual override ensure(deadline) returns (uint[] memory amounts) {
amounts = PancakeLibrary.getAmountsOut(factory, amountIn, path);
require(amounts[amounts.length - 1] >= amountOutMin, 'PancakeRouter: INSUFFICIENT_OUTPUT_AMOUNT');
TransferHelper.safeTransferFrom(
path[0], msg.sender, PancakeLibrary.pairFor(factory, path[0], path[1]), amounts[0]
);
_swap(amounts, path, to);
}
メソッドの引数はamountIn, amountOutMin, path, to, deadlineの5つです。それぞれ以下のような意味です。
pathについて説明すると、トークンをswapするためにどのLP(流動性)を使ってswapするかを指定します。例えばBUSDをCAKEにswapしたい場合、
といった形で存在するLPを元にrouteを組み立てる必要があります。PancakeSwapはUI側でrouteを特定のトークンに制限して総当りで検証し、最も良いレートのrouteが選ばれるようになっています。反面、試行ルートが多いのでレートの反映が遅いケースもあります。
最初に PancakeLibrary.getAmountsOut(factory, amountIn, path) を行っています。これは一言で言うと指定されたroute, amountInで取得できるトークン量を計算しています。実際の処理を見てみます。
function getAmountsOut(address factory, uint amountIn, address[] memory path) internal view returns (uint[] memory amounts) {
require(path.length >= 2, 'PancakeLibrary: 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);
}
}
for文を利用して繰り返しgetReservesとgetAmountOutを呼び出しています。例えばrouteにBUSDアドレス,USDTアドレス,CAKEアドレスを指定した場合、
という挙動になります。getReservesとgetAmountOutの処理を見てみます。
// Router側
function getReserves(address factory, address tokenA, address tokenB) internal view returns (uint reserveA, uint reserveB) {
(address token0,) = sortTokens(tokenA, tokenB);
pairFor(factory, tokenA, tokenB);
(uint reserve0, uint reserve1,) = IPancakePair(pairFor(factory, tokenA, tokenB)).getReserves();
(reserveA, reserveB) = tokenA == token0 ? (reserve0, reserve1) : (reserve1, reserve0);
}
function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
require(amountIn > 0, 'PancakeLibrary: INSUFFICIENT_INPUT_AMOUNT');
require(reserveIn > 0 && reserveOut > 0, 'PancakeLibrary: INSUFFICIENT_LIQUIDITY');
uint amountInWithFee = amountIn.mul(9975);
uint numerator = amountInWithFee.mul(reserveOut);
uint denominator = reserveIn.mul(10000).add(amountInWithFee);
amountOut = numerator / denominator;
}
// Factory側(PancakePair)
function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) {
_reserve0 = reserve0;
_reserve1 = reserve1;
_blockTimestampLast = blockTimestampLast;
}
まずRouter側ではgetReserves内でトークンA,トークンBのLPアドレスを計算し、LPアドレス内のトークン量を取得します。その後getAmountOutでは流動性の状態とamountInの値を使ってamountOutを算出します。uint amountInWithFee = amountIn.mul(9975) と記述されている部分はPancakeSwapの取引手数料です。ドキュメントに記載されている通り、0.25%がtrading feeとして徴収されます。
上記の計算をした結果、得られるトークン量が指定されたamountOutMinを下回る場合、その時点でswapが失敗します。上回った場合はTransferHelper.safeTransferFromを利用し、Fromに指定されたトークン量をLPアドレスに移動し、_swapを行います。
// Router側
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,) = PancakeLibrary.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 ? PancakeLibrary.pairFor(factory, output, path[i + 2]) : _to;
IPancakePair(PancakeLibrary.pairFor(factory, input, output)).swap(
amount0Out, amount1Out, to, new bytes(0)
);
}
}
// Factory側(PancakePair)
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
require(amount0Out > 0 || amount1Out > 0, 'Pancake: INSUFFICIENT_OUTPUT_AMOUNT');
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
require(amount0Out < _reserve0 && amount1Out < _reserve1, 'Pancake: 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, 'Pancake: 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) IPancakeCallee(to).pancakeCall(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, 'Pancake: INSUFFICIENT_INPUT_AMOUNT');
{ // scope for reserve{0,1}Adjusted, avoids stack too deep errors
uint balance0Adjusted = (balance0.mul(10000).sub(amount0In.mul(25)));
uint balance1Adjusted = (balance1.mul(10000).sub(amount1In.mul(25)));
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(10000**2), 'Pancake: K');
}
_update(balance0, balance1, _reserve0, _reserve1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}
swapではLPのトークン保有量に不整合が発生しないようにしたり、FlashSwapの補助を行ったりしています。Router側で_swap(..., new bytes(0))という記述がありますが、何らかの値を入力するとFlashSwap扱いになります。これらの処理をpathごとに行うことでswapが完了します。
シンプルなUIの裏でいろいろなことをやっていますね。
ユーザーに複雑さを感じさせない凄い仕組みだなぁと感じました。