lesson-3_流動性の提供を実装しよう
🔥 流動性の提供を実装しましょう
このレッスンではトークンを保有したユーザが、流動性を提供する際に動かす関数をコントラクトに実装します。
以下に実装の要点を整理します。
-
ユーザは2つのトークンをコントラクトに預けることができます。
-
預けるトークンはお互い同価値の量を預けてもらうというルールを設けます。 例) プールにトークンXとYが1:2の割合で存在する場合、トークンXを10預ける場合はもうトークンYは20必要なことになります。
-
コントラクト内では、ユーザが預けたトークンの量が全体のどれくらいの割合であるか(シェア)を数値として保持します。
それでは実装に入りますが、まずはAMM.sol
内が以下になるようにコンストラクタの下にコードを追加してください。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract AMM {
IERC20 private _tokenX; // ERC20を実装したコントラクト
IERC20 private _tokenY; // ERC20を実装したコントラクト
uint256 public totalShare; // シェアの総量
mapping(address => uint256) public share; // 各ユーザのシェア
mapping(IERC20 => uint256) public totalAmount; // プールにロックされた各トークンの量
uint256 public constant PRECISION = 1_000_000; // シェアの精度に使用する定数(= 6桁)
// プールに使えるトークンを指定します。
constructor(IERC20 tokenX, IERC20 tokenY) {
_tokenX = tokenX;
_tokenY = tokenY;
}
// プールに流動性があり、使用可能であることを確認します。
modifier activePool() {
require(totalShare > 0, "Zero Liquidity");
_;
}
// スマートコントラクトが扱えるトークンであることを確認します。
modifier validToken(IERC20 token) {
require(
token == _tokenX || token == _tokenY,
"Token is not in the pool"
);
_;
}
// スマートコントラクトが扱えるトークンであることを確認します。
modifier validTokens(IERC20 tokenX, IERC20 tokenY) {
require(
tokenX == _tokenX || tokenY == _tokenY,
"Token is not in the pool"
);
require(
tokenY == _tokenX || tokenY == _tokenY,
"Token is not in the pool"
);
require(tokenX != tokenY, "Tokens should be different!");
_;
}
// 引数のトークンとペアのトークンのコントラクトを返します。
function _pairToken(IERC20 token)
private
view
validToken(token)
returns (IERC20)
{
if (token == tokenX) {
return tokenY;
}
return tokenX;
}
}
ここで追加したものはこれから実装する関数で必要になってくるものです。
次に、以下の関数をコントラクトの最後の行に追加してください。
// 引数のトークンの量に値するペアのトークンの量を返します。
function getEquivalentToken(IERC20 inToken, uint256 amountIn)
public
view
activePool
validToken(inToken)
returns (uint256)
{
IERC20 outToken = _pairToken(inToken);
return (totalAmount[outToken] * amountIn) / totalAmount[inToken];
}
ここではユーザが流動性を提供する前に、片方の預けるトークンの量から、もう片方の同価値の量を返却する関数を実装しています。
トークンXとYに関してプールにある総量をそれぞれx、y、流動性提供によりプールに増える量をそれぞれx'、y'で表すとすると次の式が成り立ちます。
上記のような比の関係において、以下のように式を解いてy'を求めることができます。
この計算を先ほど実装した関数内では行っています。 引数で渡されたトークンとその量から、ペアのトークンとその同価値の量(返り値)を求めています。
次に実際に流動性を提供する関数を実装します。 コントラクトの最後の行に以下の関数を追加してください。
// プールに流動性を提供します。
function provide(
IERC20 tokenX,
uint256 amountX,
IERC20 tokenY,
uint256 amountY
) external validTokens(tokenX, tokenY) returns (uint256) {
require(amountX > 0, "Amount cannot be zero!");
require(amountY > 0, "Amount cannot be zero!");
uint256 newshare;
if (totalShare == 0) {
// 初期は100
newshare = 100 * PRECISION;
} else {
uint256 shareX = (totalShare * amountX) / totalAmount[tokenX];
uint256 shareY = (totalShare * amountY) / totalAmount[tokenY];
require(
shareX == shareY,
"Equivalent value of tokens not provided..."
);
newshare = shareX;
}
require(
newshare > 0,
"Asset value less than threshold for contribution!"
);
tokenX.transferFrom(msg.sender, address(this), amountX);
tokenY.transferFrom(msg.sender, address(this), amountY);
totalAmount[tokenX] += amountX;
totalAmount[tokenY] += amountY;
totalShare += newshare;
share[msg.sender] += newshare;
return newshare;
}
引数には預けるトークンコントラクトとその量を受け取ります。 modifierやrequireを使って各値が正常か確認しています。
預けられたトークンのシェアを求め、newShare
に格納します。
ここでは条件分岐があります。
uint256 newshare;
if (totalShare == 0) {
// 初期は100
newshare = 100 * PRECISION;
} else {
uint256 shareX = (totalShare * amountX) / totalAmount[tokenX];
uint256 shareY = (totalShare * amountY) / totalAmount[tokenY];
require(
shareX == shareY,
"Equivalent value of tokens not provided..."
);
newshare = shareX;
}
totalShare == 0
の時、つまりプールにまだトークンが存在しない場合は、シェアの初期値を100とします。
totalShare != 0
の場合は、それぞれのトークンに関して、預けられたトークンの全体のトークンに対するシェアを求めます。
計算式は以下を基にしています。
各トークンは同価値の量だけ渡されているはずなので、それぞれのシェアも同じになるはずです。
シェアの計算後の処理について見ていきましょう。
tokenX.transferFrom(msg.sender, address(this), amountX);
tokenY.transferFrom(msg.sender, address(this), amountY);
ここでは流動性を提供するユーザから実際にトークンをコントラクトへ引き出します。
IERC20
の中にある関数transferFrom
は以下のような引数に従って、トークンの移動(送金)を行うことができます。
- 引数1: トークンの送金元のアドレス。今回は流動性の提供者であるためmsg.sender。
- 引数2: トークンの送金先のアドレス。今回はコントラクトであるためaddress(this)。
- 引数3: 送金するトークンの量。今回は預けるトークンの量。
transferFrom
に関してはこの後さらに詳しく理解していきます。
totalAmount[tokenX] += amountX;
totalAmount[tokenY] += amountY;
ここではプールにあるトークンの総量を増やしています。
totalShare += newshare;
share[msg.sender] += newshare;
ここではプール内に増えたトークンのシェアを、シェア総量と流動性提供者のシェアに加えます。
🏦 transferFrom
とapprove
先ほどtransferFrom
という関数を使用しましたが、この関数はapprove
という関数とセットで使います。
approve
という関数は、あるアカウントまたはスマートコントラクトが、自分の所有するトークンを移動することを許可する関数です。
このように使います。
ERC20TokenContract.approve(移動を実行するアカウントまたはコントラクトのアドレス、移動するトークンの量);
例えば、アカウントAがTokenXを所有していて、アカウントBがAの持つTokenXを30だけ移動する許可を与えたいとします。
そのためには、AはこのようにしてTokenXのapprove
を呼び出します。
TokenX.approve(Bのアドレス, 30);
すると、BはTokenXのtransferFrom
を呼び出すことでAの持つTokenXを自身に移動することができます。
TokenX.transferFrom(Aのアドレス, Bのアドレス, 30);
このapprove
、transferFrom
の一連の流れを経てBはAの持つトークンを自身へ移動することができました。
approve
なしにtransferFrom
が使えてしまうと、Bは好き勝手にAの持つトークンを移動できてしまうのでトークンの機能として成り立ちません。
AMMコントラクト
のprovide
関数内ではtransferFrom
を使用していますが、これは流動性提供者がprovide
を呼び出す前に
各トークンのapprove
の実行を済ませていることが前提です。
approve
が行われていない場合はtransferFrom
の呼び出しは失敗します。
📓 approve
/transferFrom
を使う理由
上記の話は、トークンを直接AMMコントラクト
へ送信(transfer
を使用)してから、provide
を呼び出すという流れでも成立しそうですが
なぜapprove
/transferFrom
を使用するのかについても考えてみます。
トークンを直接AMMコントラクト
へ送信してからprovide
を呼び出す場合は以下のような流れになります。
- Aはトークンコントラクトの
transfer
を呼び出して、AMMへトークンを送信する - AはAMMコントラクトの
provide
を呼び出して流動性を提供する- AMMはプールの状態を確認しシェア算出などの処理をする
- シェアなどの状態変数を変更する
この1と2を独立した処理として順番に実行したとすると、以下の問題が起きます。
- 1と2の処理の合間にプールの状態が変更する可能性がある
- 2のトランザクションが何らかの理由で失敗すると、1の返金処理をする必要がある
- AMMから見て、1がAから流動性提供で 実行されたものだということがわからない
つまり、1と2は同時に行う必要があります。
ここでtransferFromとapproveの出番です。
実際に行う処理の流れを整理します。
- Aはトークンコントラクトの
approve
を呼び出すことで、AMMコントラクトがトークンを移動することを許可する - AはAMMコントラクトの
provide
を呼び出して流動性を提供する- AMMはプールの状態を確認しシェア算出などの処理をする
- AMMは流動性提供者からトークンを自身へ移動
- シェアなどの状態変数を変更する
後に実装するswapの際にも、同じような理由で
トークンの送受信とAMMコントラクト内での処理(レートの計算など)を同じトランザクションで行いたいのでapprove
/transferFrom
を使用します。
🧪 テストを追加しましょう
追加した機能に対するテストを追加しましょう。
test/AMM.ts
ファイル内のinit
テストを以下のようにprovide
テストに書き換えてください。
- 変更すると環境によって赤の波線が表示される箇所があるかもしれませんが、テストを実行すると消えますので、一旦気にせず進めてください。
describe("provide", function () {
it("Token should be moved", async function () {
const { amm, token0, token1, owner } = await loadFixture(deployContract);
const ownerBalance0Before = await token0.balanceOf(owner.address);
const ownerBalance1Before = await token1.balanceOf(owner.address);
const ammBalance0Before = await token0.balanceOf(amm.address);
const ammBalance1Before = await token1.balanceOf(amm.address);
// 今回使用する2つのトークンはETHと同じ単位を使用するとしているので、
// 100 ether (= 100 * 10^18) 分をprovideするという意味です。
const amountProvide0 = ethers.utils.parseEther("100");
const amountProvide1 = ethers.utils.parseEther("200");
await token0.approve(amm.address, amountProvide0);
await token1.approve(amm.address, amountProvide1);
await amm.provide(
token0.address,
amountProvide0,
token1.address,
amountProvide1
);
expect(await token0.balanceOf(owner.address)).to.eql(
ownerBalance0Before.sub(amountProvide0)
);
expect(await token1.balanceOf(owner.address)).to.eql(
ownerBalance1Before.sub(amountProvide1)
);
expect(await token0.balanceOf(amm.address)).to.eql(
ammBalance0Before.add(amountProvide0)
);
expect(await token1.balanceOf(amm.address)).to.eql(
ammBalance1Before.add(amountProvide1)
);
});
});