PancakeSwapの中身を読む

※ 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するときはかなり大きな数値を指定するようになっているため、小切手が尽きないようになっています。

Swapの種類

Uniswapのswap系メソッドは以下にまとまっています。

メソッドはswap{A}For{B}の命名規則になっています。Aを入力トークン、Bを出力トークンを呼びます。またETHは基軸通貨と呼びます。

まずExactの説明です。ExactがAの前にある場合は出力トークンの最小量を指定します。Bの前にある場合は入力トークンの最大量を指定します。UIから指定する際に入力トークンの量を指定して購入する場合はswapExact{A}For{B}となり、出力トークンの量を指定して購入する場合はswap{A}ForExact{B}となります。UIでは以下のように変化します。

Fromに入力した場合はswapExact{A}For{B}となり最小出力量が表示される
Fromに入力した場合はswapExact{A}For{B}となり最小出力量が表示される
Toに入力した場合はswap{A}ForExact{B}となり最大入力量が表示される
Toに入力した場合はswap{A}ForExact{B}となり最大入力量が表示される

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つです。それぞれ以下のような意味です。

  • amountIn: 入力トークンの数量
  • amountOutMin: 出力トークンの最低量(slippage考慮後の値)
  • path: トークンをswapするためのroute
  • to: 交換した後にtransferする対象、基本的に自分のアドレスが入る
  • deadline: 指定された時刻を過ぎた場合はswapを中止する

pathについて説明すると、トークンをswapするためにどのLP(流動性)を使ってswapするかを指定します。例えばBUSDをCAKEにswapしたい場合、

  • BUSD-CAKEのLPだけ存在する
    • BUSDアドレス,CAKEアドレスがrouteになる
  • USDT-BUSD、USDT-CAKEのLPだけ存在する
    • BUSDアドレス,USDTアドレス,CAKEアドレスがrouteになる
    • BUSDアドレス,CAKEアドレスを指定してもLPのrouteがないのでNG

といった形で存在する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アドレスを指定した場合、

  1. BUSDアドレス,USDTアドレスでgetReservesを呼ぶ
  2. 得られたreserveIn, reserveOutを使ってBUSD->USDTへのswapで得られる数量をgetAmountOutで取得
  3. USDTアドレス,CAKEアドレスでgetReservesを呼ぶ
  4. 得られたreserveIn, reserveOutを使ってUSDT->CAKEへのswapで得られる数量をgetAmountOutで取得
  5. それぞれのpathでswapした結果を返す

という挙動になります。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が完了します。

まとめ

  • swapには入力トークン、出力トークンに応じていろいろなメソッドが使い分けられている
  • swap時にはrouteを辿り、複数回のswapが行われている(route毎にtrading feeがかかる)

シンプルなUIの裏でいろいろなことをやっていますね。
ユーザーに複雑さを感じさせない凄い仕組みだなぁと感じました。

コントラクトリンク

  • PancakeRouter
  • PancakeFactory
Subscribe to narvik
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.