DeFi(分散型金融)の中核をなすAMM(自動マーケットメイカー)や流動性プールは、Solidityのスマートコントラクトによって実現されています。UniswapやCurve、Aaveといったプロトコルのコードは公開されており、学習教材として非常に優れています。
これらのプロトコルがどのように動いているかを理解することは、スマートコントラクト開発者としてのスキルを大きく引き上げます。単に「仕組みを知っている」だけでなく、「コードで実装できる」レベルまで理解することで、セキュリティ監査やプロトコル設計に携わるキャリアへの道が開けます。
本記事では、AMMの数式とSolidityへの実装、流動性プールのLP(流動性提供者)トークン設計、フラッシュローンの原理と実装について、実際のコードを交えて解説します。
AMMの仕組み:定数積公式
x * y = k の数学的意味
Uniswap V2が採用したAMMモデルの核心は、定数積公式(Constant Product Formula)です。流動性プール内の2つのトークン量 x と y の積が常に一定値 k を保つという原則です。
- プールに ETH(x) と USDC(y) が存在するとする
- x = 100 ETH, y = 200,000 USDC の場合、k = 20,000,000
- 1 ETH を売りたい場合: (100+1) * y’ = 20,000,000 → y’ = 198,019.8 USDC
- 取得できるUSDC = 200,000 – 198,019.8 ≒ 1,980.2 USDC
価格は自動的に需給によって決まります。大量に買うほど価格が上がる(スリッページ)のは、この式から自然に導かれます。
手数料とプール残高の更新
Uniswap V2では取引額の0.3%が手数料として流動性プールに蓄積されます。手数料をkに反映させることで、LPの実質利益となります。
// 手数料0.3%を考慮したamountOut計算
function getAmountOut(
uint256 amountIn,
uint256 reserveIn,
uint256 reserveOut
) public pure returns (uint256 amountOut) {
require(amountIn > 0, "Insufficient input amount");
require(reserveIn > 0 && reserveOut > 0, "Insufficient liquidity");
uint256 amountInWithFee = amountIn * 997; // 0.3%手数料
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = (reserveIn * 1000) + amountInWithFee;
amountOut = numerator / denominator;
}
Uniswap V3ではこの定数積モデルを集中流動性(Concentrated Liquidity)に発展させ、特定の価格レンジのみに流動性を集中させることで資本効率を大幅に向上させています。
流動性プールの実装
LPトークンの設計
流動性提供者(LP)はプールに流動性を追加した見返りとして、プールの持分を表すLPトークンを受け取ります。引き出し時にLPトークンを返却することで、元本と手数料収益を取り戻します。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
contract SimpleLiquidityPool is ERC20 {
IERC20 public tokenA;
IERC20 public tokenB;
uint256 public reserveA;
uint256 public reserveB;
uint256 private constant MINIMUM_LIQUIDITY = 1000;
event LiquidityAdded(address indexed provider, uint256 amountA, uint256 amountB, uint256 lpTokens);
event LiquidityRemoved(address indexed provider, uint256 amountA, uint256 amountB, uint256 lpTokens);
event Swap(address indexed trader, address tokenIn, uint256 amountIn, uint256 amountOut);
constructor(address _tokenA, address _tokenB) ERC20("LP Token", "LPT") {
tokenA = IERC20(_tokenA);
tokenB = IERC20(_tokenB);
}
function addLiquidity(uint256 _amountA, uint256 _amountB) external returns (uint256 lpTokens) {
tokenA.transferFrom(msg.sender, address(this), _amountA);
tokenB.transferFrom(msg.sender, address(this), _amountB);
uint256 totalSupply_ = totalSupply();
if (totalSupply_ == 0) {
lpTokens = Math.sqrt(_amountA * _amountB) - MINIMUM_LIQUIDITY;
_mint(address(0xdead), MINIMUM_LIQUIDITY); // ロック
} else {
lpTokens = Math.min(
(_amountA * totalSupply_) / reserveA,
(_amountB * totalSupply_) / reserveB
);
}
require(lpTokens > 0, "Insufficient liquidity minted");
_mint(msg.sender, lpTokens);
reserveA += _amountA;
reserveB += _amountB;
emit LiquidityAdded(msg.sender, _amountA, _amountB, lpTokens);
}
function removeLiquidity(uint256 _lpTokens) external returns (uint256 amountA, uint256 amountB) {
require(balanceOf(msg.sender) >= _lpTokens, "Insufficient LP tokens");
uint256 totalSupply_ = totalSupply();
amountA = (_lpTokens * reserveA) / totalSupply_;
amountB = (_lpTokens * reserveB) / totalSupply_;
_burn(msg.sender, _lpTokens);
reserveA -= amountA;
reserveB -= amountB;
tokenA.transfer(msg.sender, amountA);
tokenB.transfer(msg.sender, amountB);
emit LiquidityRemoved(msg.sender, amountA, amountB, _lpTokens);
}
}
最小流動性(MINIMUM_LIQUIDITY)を焼却アドレスに送ることで、ゼロ除算やプールの空になるリスクを防いでいます。これはUniswap V2の設計をそのまま踏襲しています。
スワップ関数の実装
function swap(address _tokenIn, uint256 _amountIn) external returns (uint256 amountOut) {
require(_tokenIn == address(tokenA) || _tokenIn == address(tokenB), "Invalid token");
require(_amountIn > 0, "Amount must be positive");
bool isTokenA = _tokenIn == address(tokenA);
(IERC20 tokenIn, IERC20 tokenOut, uint256 reserveIn, uint256 reserveOut) = isTokenA
? (tokenA, tokenB, reserveA, reserveB)
: (tokenB, tokenA, reserveB, reserveA);
tokenIn.transferFrom(msg.sender, address(this), _amountIn);
amountOut = getAmountOut(_amountIn, reserveIn, reserveOut);
require(amountOut > 0, "Insufficient output amount");
tokenOut.transfer(msg.sender, amountOut);
if (isTokenA) {
reserveA += _amountIn;
reserveB -= amountOut;
} else {
reserveB += _amountIn;
reserveA -= amountOut;
}
emit Swap(msg.sender, _tokenIn, _amountIn, amountOut);
}
フラッシュローンの原理と実装
フラッシュローンとは何か
フラッシュローンは、1トランザクション内で無担保で大量の資金を借り、同じトランザクション内で返済する仕組みです。Aaveが2020年に実用化し、DeFiの革新的なプリミティブとして注目を集めました。
フラッシュローンが可能な理由は、EVMのトランザクション原子性にあります。トランザクションが失敗すればすべての状態変更がロールバックされるため、「返済されなかった場合はトランザクション全体が失敗する」というロジックが成立します。
主な活用例としては、裁定取引(アービトラージ)、担保入れ替え(CollateralSwap)、清算処理(Liquidation)、自己清算(Self-Liquidation)が挙げられます。
フラッシュローンの実装例
interface IFlashLoanReceiver {
function executeFlashLoan(
address token,
uint256 amount,
uint256 fee,
bytes calldata data
) external;
}
contract FlashLoanProvider {
IERC20 public token;
uint256 public constant FEE_BPS = 9; // 0.09%
event FlashLoan(address indexed receiver, uint256 amount, uint256 fee);
constructor(address _token) {
token = IERC20(_token);
}
function flashLoan(
address _receiver,
uint256 _amount,
bytes calldata _data
) external {
uint256 balanceBefore = token.balanceOf(address(this));
require(balanceBefore >= _amount, "Insufficient liquidity");
uint256 fee = (_amount * FEE_BPS) / 10000;
// 借り手にトークンを送付
token.transfer(_receiver, _amount);
// 借り手のコードを実行
IFlashLoanReceiver(_receiver).executeFlashLoan(
address(token),
_amount,
fee,
_data
);
// 返済確認
uint256 balanceAfter = token.balanceOf(address(this));
require(balanceAfter >= balanceBefore + fee, "Flash loan not repaid");
emit FlashLoan(_receiver, _amount, fee);
}
}
重要なのは返済確認のロジックです。balanceAfter >= balanceBefore + fee が満たされない場合、トランザクション全体がリバートされ、資金は安全です。
フラッシュローンを使ったアービトラージ実装例
contract ArbitrageBot is IFlashLoanReceiver {
FlashLoanProvider public flashLoanProvider;
address public owner;
constructor(address _provider) {
flashLoanProvider = FlashLoanProvider(_provider);
owner = msg.sender;
}
function executeArbitrage(
address _token,
uint256 _amount,
address _dexA,
address _dexB
) external {
require(msg.sender == owner, "Not owner");
bytes memory data = abi.encode(_dexA, _dexB);
flashLoanProvider.flashLoan(_token, _amount, data);
}
function executeFlashLoan(
address token,
uint256 amount,
uint256 fee,
bytes calldata data
) external override {
(address dexA, address dexB) = abi.decode(data, (address, address));
// DEX Aでスワップ(概略)
IERC20(token).approve(dexA, amount);
// ... DEX Aでスワップ実行 ...
// DEX Bでスワップ(概略)
// ... DEX Bでスワップ実行 ...
// 元本 + 手数料を返済
IERC20(token).transfer(address(flashLoanProvider), amount + fee);
}
}
価格オラクルとスリッページ保護
AMM価格操作のリスク
AMMの価格はプール残高によって決まるため、大量のトークンを瞬時に移動させることで一時的に価格を操作できます。フラッシュローンを使ったスポット価格操作は、オンチェーン価格オラクルとして単純なAMM残高を使っているプロトコルの攻撃ベクターとなります。
対策として、Time-Weighted Average Price(TWAP)があります。Uniswap V2から実装された累積価格の平均を使うことで、瞬間的な価格操作に対して耐性を持たせます。
スリッページ保護の実装
function swapWithSlippage(
address _tokenIn,
uint256 _amountIn,
uint256 _minAmountOut // スリッページ許容後の最低受取量
) external returns (uint256 amountOut) {
amountOut = swap(_tokenIn, _amountIn);
require(amountOut >= _minAmountOut, "Slippage exceeded");
}
_minAmountOut を指定することで、取引実行時の価格変動によるスリッページを許容範囲内に制限できます。Uniswapのフロントエンドでは通常0.5%のスリッページ許容を設定しています。
ステーキングとリワード配布
Synthetixリワード配布パターン
LPトークンをステーキングして報酬を配布するパターンは、Synthetixが実装し多くのプロジェクトで採用されています。報酬レートと累積報酬を使った効率的な計算方式が特徴です。
contract StakingRewards {
IERC20 public stakingToken;
IERC20 public rewardsToken;
uint256 public rewardRate;
uint256 public lastUpdateTime;
uint256 public rewardPerTokenStored;
mapping(address => uint256) public userRewardPerTokenPaid;
mapping(address => uint256) public rewards;
mapping(address => uint256) public balances;
uint256 public totalSupply;
function rewardPerToken() public view returns (uint256) {
if (totalSupply == 0) return rewardPerTokenStored;
return rewardPerTokenStored + (
rewardRate * (block.timestamp - lastUpdateTime) * 1e18 / totalSupply
);
}
function earned(address account) public view returns (uint256) {
return (
balances[account] * (rewardPerToken() - userRewardPerTokenPaid[account]) / 1e18
) + rewards[account];
}
}
このパターンの巧妙さは、全ユーザー分の報酬を毎回計算せず、rewardPerToken の累積値を使って各ユーザーの未請求報酬を O(1) で計算できる点にあります。
まとめ
AMMと流動性プールの仕組みをSolidityのコードで理解することで、DeFiプロトコルの動作原理が具体的に把握できます。定数積公式はシンプルな数学ですが、LPトークンの設計やフラッシュローンの活用まで幅広い応用が生まれます。
フラッシュローンはDeFiが実現した革新的なプリミティブですが、同時に価格操作攻撃の手段にもなり得ます。プロトコル設計においては、価格オラクルの選択とスリッページ保護の実装が不可欠です。実際のコードを読み書きしながら理解を深めていきましょう。
よくある質問
Q. AMMはオーダーブック型取引所とどう違いますか?
オーダーブック型は買い手と売り手のマッチングが必要ですが、AMMは流動性プールとアルゴリズムによって常に取引が可能です。流動性提供者がプールに資金を置くことで市場が形成されます。オーダーブック型は価格効率が高く、AMMは常時取引が可能な点が特徴です。
Q. フラッシュローンは個人でも使えますか?
技術的には可能です。ただしフラッシュローンを活用するためには、1トランザクション内でアービトラージや清算処理を実行できるスマートコントラクトを自分で書く必要があります。ガス代やスリッページを考慮した上で利益が出るかどうかの計算も必要です。
Q. 流動性を提供すると必ずリターンが得られますか?
流動性提供には手数料収益がありますが、インパーマネントロス(Impermanent Loss)のリスクがあります。プール内のトークン価格比が大きく変動した場合、単純にトークンを保有し続けるよりもリターンが低くなる可能性があります。投資判断はご自身の責任で行ってください。
※本記事は情報提供を目的としており、投資を推奨するものではありません。暗号資産への投資は元本割れのリスクがあります。投資判断はご自身の責任で行ってください。