Uniswap v4:HooksとDynamic Fee の実装
Uniswap v4 の概要
この記事では、Uniswap v4 の概要と実装方法についてご紹介します。
※注意:Uniswap v4 は現在開発中のプロトコルであり、正式リリース時に仕様が変更される可能性があります。
主要な新機能
Uniswap v4 における主な新機能は、以下の 4 つです。
-
フック(Hooks)
プールの動作を自由にカスタマイズできる拡張機能 -
シングルトンアーキテクチャ
すべてのプールを単一のコントラクトで管理 -
フラッシュアカウンティング
残高変更を最適化することで効率化を実現 -
ネイティブ ETH サポート
ラップ不要で ETH を直接取引できる
これらの機能により、開発者はより柔軟な DApp を開発しやすくなり、ユーザーはより効率的な取引を行うことが可能になります。以下では、それぞれの機能を詳しく解説します。
フック(Hooks)
フックは Uniswap v4 の中核をなす新機能で、流動性プールの動作を拡張・カスタマイズするための強力な仕組みです。これまでのバージョンでは実装が難しかった柔軟なプール設計が可能になります。
フックの基本概念
- フックは、プールで発生する特定のアクション(例:初期化、スワップ、流動性の追加/削除など)の「前後」に実行される外部スマートコントラクトです。
- 1 つのプールには 1 つのフックを関連付けられますが、1 つのフックが複数のプールを担当することは可能です。
- フックはプラグインのように機能し、Uniswap のコアロジックを変更することなく新たな機能を追加できます。
フックの主要機能
フックを使って実装可能な機能の例は、以下のとおりです。
- オンチェーンの指値注文
- 動的手数料の設定
- NFTを所有していないユーザーのスワップを禁止する
シングルトンアーキテクチャ
Uniswap v4 では、すべてのプールが単一のスマートコントラクトに集約されるシングルトンアーキテクチャを採用しています。これにより、以下の利点が得られます。
- プール作成時のガスコストが大幅に削減(推定 99% 削減)
- プール間のトークン転送が不要になり、マルチホップスワップの効率が向上
フラッシュアカウンティング
フラッシュアカウンティングは、EIP-1153 の Transient Storage を活用した最適化機能です。
主なメリットは次のとおりです。
- スワップや流動性変更時の残高変更を効率的に管理
- 最終的な残高変更のみを精算し、中間的な残高処理を省略
ネイティブ ETH サポート
Uniswap v4 では、ETH をラップ/アンラップすることなく、ネイティブ ETH を直接取引できます。
これにより、以下の利点があります。
- ERC-20 トークン転送と比べて、ガスコストが約半分になる
- ユーザー体験が向上し、ETH の取引がよりシンプルに
これらの新機能によって、Uniswap v4 はカスタマイズ性と効率性を高め、DeFi エコシステムにおいて新たなプラットフォームとなることが期待されています。
Uniswap v4 フックの実装ガイド
フックの種類
Uniswap v4 では、以下の 8 種類のフック関数が用意されています。
-
beforeInitialize / afterInitialize
プールの初期化前後で実行され、初期パラメータの設定や検証に使用される。 -
beforeModifyPosition / afterModifyPosition
流動性の追加・削除時に実行され、ポジション変更の制御や記録に使用される。 -
beforeSwap / afterSwap
スワップ取引の前後で実行され、カスタム手数料やスワップロジックの実装に使用できる。 -
beforeDonate / afterDonate
トークン寄付の前後で実行され、追加インセンティブや報酬分配などに使用できる。
基本的な実装例
以下は、各アクションの呼び出し回数をカウントする簡単なフックの実装例です。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {BaseHook} from "v4-periphery/src/base/hooks/BaseHook.sol";
import {Hooks} from "v4-core/src/libraries/Hooks.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol";
import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "v4-core/src/types/BeforeSwapDelta.sol";
contract Counter is BaseHook {
using PoolIdLibrary for PoolKey;
// プールごとのカウンター
mapping(PoolId => uint256) public beforeSwapCount;
mapping(PoolId => uint256) public afterSwapCount;
mapping(PoolId => uint256) public beforeAddLiquidityCount;
mapping(PoolId => uint256) public beforeRemoveLiquidityCount;
constructor(IPoolManager _poolManager) BaseHook(_poolManager) {}
// フックのパーミッション設定
function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
return Hooks.Permissions({
beforeInitialize: false,
afterInitialize: false,
beforeAddLiquidity: true,
afterAddLiquidity: false,
beforeRemoveLiquidity: true,
afterRemoveLiquidity: false,
beforeSwap: true,
afterSwap: true,
beforeDonate: false,
afterDonate: false,
beforeSwapReturnDelta: false,
afterSwapReturnDelta: false,
afterAddLiquidityReturnDelta: false,
afterRemoveLiquidityReturnDelta: false
});
}
// スワップ前のフック
function beforeSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata, bytes calldata)
external
override
returns (bytes4, BeforeSwapDelta, uint24)
{
beforeSwapCount[key.toId()]++;
return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
}
// スワップ後のフック
function afterSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata, BalanceDelta, bytes calldata)
external
override
returns (bytes4, int128)
{
afterSwapCount[key.toId()]++;
return (BaseHook.afterSwap.selector, 0);
}
// 流動性追加前のフック
function beforeAddLiquidity(
address,
PoolKey calldata key,
IPoolManager.ModifyLiquidityParams calldata,
bytes calldata
) external override returns (bytes4) {
beforeAddLiquidityCount[key.toId()]++;
return BaseHook.beforeAddLiquidity.selector;
}
// 流動性削除前のフック
function beforeRemoveLiquidity(
address,
PoolKey calldata key,
IPoolManager.ModifyLiquidityParams calldata,
bytes calldata
) external override returns (bytes4) {
beforeRemoveLiquidityCount[key.toId()]++;
return BaseHook.beforeRemoveLiquidity.selector;
}
}
サンプルコードのポイント
-
BaseHook の継承
-
BaseHook
コントラクトを継承し、基本的なフック機能を実装する。 - コンストラクタで
IPoolManager
を受け取る。
-
-
パーミッション設定
-
getHookPermissions()
で必要なフックのみを有効化。 - 使用しないフックは
false
に設定。
-
-
フック関数の実装
- 各フック関数で該当するカウンターをインクリメントし、適切なセレクタを返す。
開発ツールとリソース
コアリポジトリ
-
- Uniswap v4 プロトコルの中核となるスマートコントラクト。
- すべてのプール状態を管理する
PoolManager.sol
が含まれる。 - フラッシュアカウンティングやネイティブ ETH サポートを実装。
-
- v4-core 上に構築された抽象化レイヤー。
- フックシステムの実装や
BaseHook
コントラクトを提供。 - ポジションマネージャーやルーターなどを実装。
開発支援ツール
-
v4-template
- Uniswap Foundation 提供の公式テンプレート。
- 基本的なフック実装の例や開発環境のセットアップ手順を網羅。
- テストフレームワークの統合サンプルも含む。
リポジトリの使い分け
- v4-core: プロトコルの基礎部分の理解や低レベルの操作が必要な場合。
- v4-periphery: フックの開発や高度な機能を実装する場合。
- v4-template: 新規フックプロジェクトを開始する際のテンプレートとして利用。
フック関数の引数
beforeSwap
beforeSwap
関数は、スワップ操作が行われる直前に呼び出されます。関数シグネチャと主要引数は以下のとおりです。
function beforeSwap(
address sender,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
bytes calldata hookData
) external returns (bytes4);
-
address sender
- PoolSwapTest を使用してスワップをテストする場合、sender は PoolSwapTest のコントラクトアドレスとなる
- 実際のユーザーアドレス(msg.sender)は必要に応じて hookData に含める
-
PoolKey calldata key
- スワップが行われるプールを識別する構造体
- 主要なフィールド:
-
currency0
: プールの最初のトークン -
currency1
: プールの 2 番目のトークン -
fee
: プールの手数料率 -
tickSpacing
: プールのティックスペーシング -
hooks
: プールに関連付けられたフックのアドレス
-
-
IPoolManager.SwapParams calldata params
- スワップ操作の詳細を含む構造体
- 主要なフィールド:
-
zeroForOne
: スワップの方向(true = token0 → token1) -
amountSpecified
: スワップするトークン量- 正の値: 入力量を指定
- 負の値: 出力量を指定
-
sqrtPriceLimitX96
: スリッページ制限(Q64.96 形式)
-
-
bytes calldata hookData
- フックに渡される追加データ
- カスタムパラメータの受け渡しに使用
- 必要に応じてデコードして使用
戻り値
-
bytes4
: 関数セレクタ(IHooks.beforeSwap.selector
) - エラー時は
revert
する必要がある
フックのデプロイ方法
Uniswap v4 のフックを正しいアドレスにデプロイするには、CREATE2 オペコードを使用してアドレスを事前計算する必要があります。
1. フラグの設定
uint160 flags = uint160(
Hooks.BEFORE_SWAP_FLAG |
Hooks.AFTER_SWAP_FLAG |
Hooks.BEFORE_ADD_LIQUIDITY_FLAG |
Hooks.BEFORE_REMOVE_LIQUIDITY_FLAG
);
2. CREATE2 を使ったデプロイ
// コンストラクタ引数の準備
bytes memory constructorArgs = abi.encode(POOLMANAGER);
// 適切なフラグを持つアドレスを生成する salt を計算
(address hookAddress, bytes32 salt) = HookMiner.find(
CREATE2_DEPLOYER,
flags,
type(Counter).creationCode,
constructorArgs
);
// CREATE2 を使用してフックをデプロイ
Counter counter = new Counter{salt: salt}(IPoolManager(POOLMANAGER));
// デプロイされたアドレスの検証
require(address(counter) == hookAddress, "Hook address mismatch");
3. プールの作成
// デプロイしたフックを IHooks としてキャストし、プールキーの作成に使用
PoolKey key = PoolKey(
currency0,
currency1,
3000, // fee
60, // tickSpacing
IHooks(hookAddress) // デプロイしたフックアドレス
);
// プールの初期化
poolId = key.toId();
manager.initialize(key, SQRT_PRICE_1_1);
デプロイ方法の特徴
- CREATE2 による決定論的なアドレス生成
- 使用するフラグはフックの種類に合わせて設定(例:
Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG
など) - アドレス上位 16 ビットをネームスペースとして活用し、アドレスの衝突を回避
Uniswap v4 Dynamic Fee
Dynamic Fee の基本概念
Dynamic Fee とは、市場の状況に応じて手数料率を柔軟に変動させる仕組みです。これにより、流動性提供者(LP)の収益性や取引の効率性を向上させることが期待できます。
手数料更新メカニズム
1. updateDynamicLPFee(プール全体の手数料を更新)
-
PoolManager
コントラクトの関数。 - プール全体の基本手数料を更新し、定期的な変更に向いている(例:1 時間ごと)。
2. overrideFee(個別スワップ時の手数料を上書き)
- フックの
beforeSwap
関数などで実装可能。 - 個々のスワップごとに手数料を設定する。
-
OVERRIDE_FEE_FLAG
が必須。
手数料の仕組み
- 最大手数料は 100%(
MAX_LP_FEE = 1000000
)。 - 手数料フラグ:
-
DYNAMIC_FEE_FLAG = 0x800000
: 動的手数料プールを示す。 -
OVERRIDE_FEE_FLAG = 0x400000
: 個々のスワップごとに手数料を上書きする場合に使用する。
-
実装例
1. プールの初期化
PoolKey memory poolKey = PoolKey({
currency0: currency0,
currency1: currency1,
// 動的手数料フラグを設定(初期手数料は 0 になる)
fee: uint24(3000) | LPFeeLibrary.DYNAMIC_FEE_FLAG,
tickSpacing: 60,
hooks: IHooks(hookAddress)
});
poolManager.initialize(poolKey, SQRT_RATIO_1_1, "");
動的手数料プールでは、初期手数料が 0 になります。初期値が必要な場合は、
afterInitialize
フックでupdateDynamicLPFee
を呼ぶ必要があります。
2. プール全体の手数料を更新
function afterInitialize(
address,
PoolKey calldata key,
uint160,
int24,
bytes calldata
) external override returns (bytes4) {
// 初期手数料を設定
uint24 initialFee = calculateInitialFee();
require(initialFee <= LPFeeLibrary.MAX_LP_FEE, "Fee too large");
poolManager.updateDynamicLPFee(key, initialFee);
return IHooks.afterInitialize.selector;
}
3. 個別スワップの手数料を設定
function beforeSwap(
address,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
bytes calldata
) external override returns (bytes4, BeforeSwapDelta, uint24) {
// スワップ金額に応じた動的手数料を計算
uint24 _currentFee = calculateFeeBasedOnAmount(params.amountSpecified);
require(_currentFee <= LPFeeLibrary.MAX_LP_FEE, "Fee too large");
// OVERRIDE_FEE_FLAG を設定して返す
uint256 overrideFee = _currentFee | LPFeeLibrary.OVERRIDE_FEE_FLAG;
return (
IHooks.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
uint24(overrideFee)
);
}
実装時の注意点
-
手数料の制限
- すべての手数料は
MAX_LP_FEE
(1000000 = 100%)以下である必要がある。 - 超過すると
LPFeeTooLarge
エラーが発生。
- すべての手数料は
-
動的手数料プール
-
DYNAMIC_FEE_FLAG
を設定したプールは初期手数料が 0。 -
afterInitialize
でupdateDynamicLPFee
を呼び出すなど、初期手数料の設定を忘れないようにする。
-
-
手数料の上書き
-
beforeSwap
で手数料を上書きする場合、OVERRIDE_FEE_FLAG
の設定が必須。 - 上書きされた手数料も
MAX_LP_FEE
の制約を受ける。
-
使用ケース
-
updateDynamicLPFee の活用
- ボラティリティに応じた定期的な手数料調整。
- プール全体の手数料構造を一元管理しやすい。
-
overrideFee の活用
- 取引サイズや条件に応じた細やかな手数料設定。
- 一時的・特定スワップのみの手数料変更など。
まとめ
Uniswap v4 では、以下のような新しい機能を通じて DeFi の可能性をさらに広げます。
- フック(Hooks): プール動作の拡張・カスタマイズが容易
- シングルトンアーキテクチャ: 大幅なガスコスト削減
- フラッシュアカウンティング: トランザクション処理の効率化
- ネイティブ ETH サポート: 直接 ETH を扱うことで体験と効率を向上
これらを組み合わせることで、開発者はより柔軟かつ効率的な DApp を構築でき、ユーザーはより快適に DeFi を利用できるようになります。
最後に
本記事は、ETHGlobal Bangkok ハッカソンでの実装経験をもとに作成しました。
Discord の情報に応じて取引手数料を動的に変更するフックを実装した Harmonia Protocol は、以下の成果を収めました🏆
- Chiliz の SportFi Projects で 2 位
- Sign Protocol の Best Overall Application で 3 位
- Best Use of Private Attestations で 1 位
ソースコード
ETHGlobal
Discussion