3MIKAN
仮想通貨直コン

Uniswap v4入門|PoolManager・hooks・flash accountingを実装検証

Uniswap v4のsingleton PoolManager、PoolKey、hook permission、unlock callback、delta settlement、native ETH、dynamic feeをFoundryで検証します。

3MIKANのブランドキャラクターが中央のPoolManager装置と複数pool、交換可能なhook、差額精算トレイを確認するUniswap v4の記事画像

Uniswap v4のhookを実装したいが、v3までのRouterとPoolを前提にすると、どこでtokenを支払い、いつ残高が確定するのか分からない。hook addressの末尾bitは合っているのにswapがrevertする。callback途中のdeltaを最終残高と読み違える。このような開発者・integrator・reviewer向けに、PoolManagerPoolKey、hook permission、flash accountingを一つのlocal検証で確認します。

結論は、PoolKeyを毎回同じ5項目で特定し、unlock → callback → pool操作 → delta確認 → sync / settle / take → deltaゼロを一つのtransactionとして追うことです。hook addressのpermission bitは呼ばれる関数を示すだけで、そのコードが安全、正確、または有利であることを保証しません。

この記事はnpm公開版の@uniswap/v4-core 1.0.2@uniswap/v4-periphery 1.0.3、Solidity 0.8.26、Cancun EVM、Foundry 1.8.0を固定したlocal contractだけで検証します。外部RPC、mainnet fork、wallet接続、署名、公開transaction、実資産のswapやLP depositは行いません。

v2・v3のPoolごとのcontractからsingletonへ

Uniswap v2ではpairごと、v3ではpoolごとにcontractがあり、Routerなどのperipheryがそれらを呼びます。v4では、複数poolのstateとtoken残高を一つのPoolManagerが管理します。

世代 pool stateの主な置き場所 複数hopの中間transfer 拡張点
v2 pairごとのcontract pair間でtokenを移す pair / Routerの固定ロジック
v3 poolごとのcontract pool間でtokenを移す concentrated liquidity、periphery
v4 singleton PoolManager 中間はdeltaへ集約し、最後に精算できる poolごとのhook

singletonは「poolが一つになった」という意味ではありません。一つのcontract内に、異なるPoolIdを持つ多数のpool stateがあります。integratorはPoolManagerへ直接曖昧なtoken pairを渡すのではなく、完全なPoolKeyを渡します。

3MIKANのキャラクターが一つのPoolManagerに接続された複数poolと、各pool固有の交換可能なhook gateを確認する補助図
一つのPoolManagerが複数poolを持ち、poolごとに0または1つのhookを指定する概念図です。正確な識別項目とpermissionは本文の表を参照してください。

PoolKeyの5項目がpoolを特定する

固定したcore sourceのPoolKeyは次の5項目です。

struct PoolKey {
    Currency currency0;
    Currency currency1;
    uint24 fee;
    int24 tickSpacing;
    IHooks hooks;
}
項目 確認すること 間違えた場合
currency0 addressの数値順で小さいcurrency 順序が逆ならinitializeがrevert
currency1 addressの数値順で大きいcurrency 別tokenなら別pool
fee static feeまたはdynamic fee flag 値が違えば別pool
tickSpacing tickの許容間隔 値が違えば別pool
hooks 0 addressまたはpool固有hook addressが違えば別pool

PoolIdは、この構造体をencodeしたhashです。PoolManagerはPoolKey自体をstorageへ保存しないため、呼び出し側が毎回正しいkeyを組み立てます。今回の検証ではtick spacingだけを変えたkeyでswapし、初期化済みpoolとは別IDになるためPoolNotInitializedでrevertすることを確認します。

native ETHはaddress(0)Currencyです。address順では必ず先頭になるため、ETH/token pairではETHがcurrency0になります。

hookはpool lifecycleへ差し込む外部contract

各poolは、初期化時に0または1つのhook addressを固定します。一つのhook contractを複数poolが共有することはできます。

PoolManagerはhook addressの下位14 bitを見て、どのcallbackを呼ぶか判断します。

bit hex callbackまたは能力
13 0x2000 beforeInitialize
12 0x1000 afterInitialize
11 0x0800 beforeAddLiquidity
10 0x0400 afterAddLiquidity
9 0x0200 beforeRemoveLiquidity
8 0x0100 afterRemoveLiquidity
7 0x0080 beforeSwap
6 0x0040 afterSwap
5 0x0020 beforeDonate
4 0x0010 afterDonate
3 0x0008 beforeSwapがdeltaを返す
2 0x0004 afterSwapがdeltaを返す
1 0x0002 afterAddLiquidityがdeltaを返す
0 0x0001 afterRemoveLiquidityがdeltaを返す

return-delta bitは、対応するcallback bitと組み合わせる必要があります。たとえばbeforeSwapafterSwapを使う単純なcounter hookなら、下位maskは0x00c0です。

permission bitは安全性の証明ではない

permission bitが合っていることから分かるのは、PoolManagerがそのcallbackを呼ぶという一点です。

  • callback内のaccess controlが正しいか
  • external callやoracle readが操作されないか
  • return deltaとfeeが意図どおりか
  • callbackが正しいselectorと長さを返すか
  • state changeが別poolや別利用者へ影響しないか
  • upgrade、admin、pause、withdraw権限があるか

これらはcode、constructor、storage、権限、動作検証、auditを別々に確認します。意図的にrevertするhookは正しいbeforeSwap bitを持つためpoolを初期化できますが、callbackがrevertするためswap全体は失敗します。bitが正しいことと、動作が安全であることは別です。

hook内で外部価格を読む場合は、return値だけでなくfeed identity、updatedAt、L2 sequencer、TWAP windowを分けます。DeFi Oracleのdecimals・staleness・TWAP検証にconsumer側の停止条件をまとめています。

unlock callbackとflash accounting

swapmodifyLiquiditydonateなどの残高を変える操作は、原則としてPoolManagerをunlockした範囲で行います。poolのinitializeはbalanceを変えないため、unlock外で実行できます。

integration / router
  → PoolManager.unlock(data)
  → PoolManagerが呼出元のunlockCallback(data)を呼ぶ
  → swap / modifyLiquidityなどを実行する
  → caller・currencyごとのdeltaを累積する
  → 負deltaを支払い、正deltaを受け取る
  → 全deltaが0ならlockしてreturnする

callbackは外部公開関数です。次の検査がなければ、第三者が任意dataで直接呼び出せます。

if (msg.sender != address(poolManager)) revert UnauthorizedCallback();

local検証ではcallbackを直接呼び、PoolManager以外ならrevertすることを確認します。また、unlock中に再度unlockするとAlreadyUnlockedです。callback入口の認証と、hook自身のonlyPoolManagerは別の境界として確認します。

3MIKANのキャラクターがunlock中に増減する二つのdelta皿をsync、settle、takeでゼロへ戻し、最後に門を閉じる流れを確認する補助図
途中のdeltaは債権・債務の記録であり、最終wallet残高ではありません。正確な符号と精算操作は次の表を正本にします。

BalanceDeltaの符号をcaller視点で読む

PoolManagerはunlock中、(caller, currency)ごとに差額を累積します。

delta caller視点 終了までに行うこと
PoolManagerへ支払う債務 sync、token支払い、settle
PoolManagerから受け取る債権 take、ERC-6909化、または限定的なclear
0 精算済み 追加操作なし

swapを2回行えば、1回目の出力を2回目の入力へdelta上で相殺できます。途中tokenをpool間でtransferせず、最後のnet balanceだけを実際に動かすのがflash accountingです。

callbackがreturnした時点でnon-zero deltaが一つでも残れば、PoolManagerはCurrencyNotSettledでtransaction全体をrevertします。tokenを一時的にtakeすることはできますが、unlock終了までに反対側の操作や支払いで相殺しなければなりません。

ERC-20とnative ETHのsettlement

固定したperipheryのDeltaResolverに合わせ、今回のrouterはcurrencyごとに次の順序で支払います。

currency 負deltaの精算 正deltaの受取
ERC-20 sync(currency)transferFrom(payer, PoolManager, amount)settle() take(currency, recipient, amount)
native ETH periphery 1.0.3ではsync(address(0))settle{value: amount}() take(address(0), recipient, amount)

ERC-20のsyncは、transfer前のPoolManager残高をtransient storageへcheckpointします。settle()は、その後に実際に増えた残高をdeltaへ反映します。transferしてからsyncすると増加前の値を失うため、順番を入れ替えません。

native ETHはmsg.valueから支払額を決めます。coreのIPoolManager上はERC-20残高のcheckpointが不要なのでsyncを省略できますが、固定したperiphery 1.0.3DeltaResolverはnativeでも先にsyncします。今回の検証実装はperiphery準拠pathとcore上許される省略pathを別関数で再現し、どちらもvalue付きsettletakeの後にdeltaが0になることを確認します。余ったmsg.valueはcallback終了後にpayerへ返します。

clearは受取ではなく放棄

clear(currency, amount)は正deltaをtoken transferなしで0へします。amountは現在の正deltaと完全一致しなければrevertします。

clearした分は利用者へ届かず、PoolManager内から回収できなくなります。今回の検証では正delta 9,969をexact amountでclearし、recipient残高が増えないことを確認します。これは危険境界を示す専用の確認であり、通常pathは正deltaをtakeします。通常の受取をclearへ置き換えず、放棄してよいdustと完全一致するamountに限定します。

static fee・dynamic fee・hook独自収支を分ける

v4では「fee」という語が複数の層に現れます。

種類 どこで決まるか 変更の境界
static LP fee PoolKey.fee feeを変えると別PoolKey・別pool
dynamic LP fee PoolKey.fee = 0x800000 hookだけがstored feeを更新できる
per-swap override dynamic poolのbeforeSwap return `0x400000
protocol fee PoolManagerのprotocol設定 hook feeやLP feeとは別
hook独自収支 hookのreturn deltaやcustom accounting hook codeとpermissionを個別review

LP feeの単位は1 basis pointの100分の1です。30000.30%5000.05%です。最大は1_000_000、つまり100%です。

local検証のdynamic fee hookは、初期stored fee 0のpoolを次のswapのbeforeSwap4,000(0.40%)へ更新します。これはstored fee更新の検証であり、0x400000 | feeを返すper-swap overrideの検証ではありません。

dynamicであること自体はfeeの妥当性を保証しません。fee入力のsource、更新者、更新頻度、操作可能性、上限、failure時の挙動を確認します。

local環境で再現する正常系と失敗系

同一の実物PoolManagerへERC-20/ERC-20 poolとnative ETH/ERC-20 poolを初期化し、三つのhookを分けて確認します。

case 確認する証拠 期待結果
simple hook permission mask、before/after count、swap delta beforeとafterが各1回、最終delta 0
dynamic fee hook dynamic flag、beforeSwap、stored fee 0から0.40%へ更新、最終delta 0
revert hook 正しいbeforeSwap bit、revert selector pool初期化は成功、swap全体はrevert
ERC-20 settlement callback中の負delta、sync、transfer、settle PoolManager残高が増え、delta 0
native settlement periphery syncあり / core sync省略、value付きsettle 両pathでETHとtokenを精算し、delta 0
clear swap出力の正delta、recipient残高 exact deltaだけ0、recipientは受け取らない
未清算 callback return直前のnon-zero delta CurrencyNotSettledで全体revert
不正callback callbackの直接呼出元 UnauthorizedCallbackでrevert
wrong PoolKey tick spacingだけを変更 PoolNotInitializedでrevert

実行traceは次の順序を確認します。

PoolManager.unlock
  → SettlementRouter.unlockCallback
    → PoolManager.swap
    → ERC-20ならPoolManager.sync → token transferFrom
    → ERC-20はsettle、native ETHはperiphery pathでsync → value付きsettle
    → 正deltaをPoolManager.take
  → non-zero delta count = 0

固定したローカル検証のERC-20 exact-inputでは、swap直後のdeltaが(-10,000, +9,969)、精算後が(0, 0)でした。native ETHでは入力上限0.01 ETHに対し6,035,841,794,200,769 weiをsettleし、token側の正delta5,981,737,760,509,662をtakeして、未使用valueを返しました。精算を省いたcaseはcallback終了直前に(-10,000, +9,969)が残り、CurrencyNotSettledで全体がrevertします。数値、検証項目、source pin、制約は公開検証JSONへ固定しています。

revertしたtransactionのeventとstate changeは残りません。失敗caseは、残ったeventを証拠にせず、期待したerror selectorとrevert後の残高・counter不変を証拠にします。

固定した検証環境

再現に使ったversionと12個の検証項目に加え、ERC-20、native ETHの2経路、clear、未清算deltaの詳細trace要約を公開検証JSONへ記録しています。

component 固定値
@uniswap/v4-core npm 1.0.2 / gitHead 59d3ecf53afa9264a16bba0e38f4c5d2231f80bc
@uniswap/v4-periphery npm 1.0.3 / gitHead 60cd93803ac2b7fa65fd6cd351fd5fd4cc8c9db5
Foundry / Forge 1.8.0 / commit 61ae26af36320d4fa1020f7db53785885e29eeb5
Solidity / EVM 0.8.26 / Cancun
optimizer via IR、runs 200

v4-coreの公式releaseは確認時点でv4.0.0、tag commitはe50237c43811bd9b526eff40f26772152a42dabaです。検証ではrelease tagを曖昧に追従せず、npm packageのversion・integrity・gitHeadを固定します。v4-peripheryは確認時点でGitHub releaseがないため、同様にnpm packageとgitHeadを正本にします。

transient storageはtransactionが終わるまで共有される

v4 coreはunlock状態、currency delta、non-zero delta count、syncしたreserveなどにEIP-1153のTSTORE / TLOADを使います。

transient storageはtransaction終了時に破棄され、revertにも追従します。しかし「callbackがreturnした瞬間に自動で消えるstorage」ではありません。同じtransaction内のnested callやreentrancyからも、そのtransactionに属する状態が見えます。

したがって、flash accountingで最終deltaが0になることだけではhookの安全性を証明できません。外部callの前後、callback sender、pool key、hook state、return delta、reentrancy guard、資産flowを合わせてreviewします。transient storage単体の挙動はTSTORE・TLOADのlocal検証で確認できます。

実装・review checklist

Poolとhook

  • currency0 < currency1
  • fee、tick spacing、hookを含む完全なPoolKeyを固定したか
  • hook addressの下位14 bitと実装callbackが一致するか
  • permission bitを安全性評価へ流用していないか
  • hookがPoolManager以外からのcallbackを拒否するか
  • dynamic feeのsource、上限、更新条件を確認したか

callbackと精算

  • unlockCallbackがPoolManagerだけを許可するか
  • operation直後にcurrency別deltaを記録したか
  • ERC-20はsyncより後にtransferしているか
  • 負deltaはsettle、正deltaはtakeしているか
  • clearするamountと放棄理由を明示したか
  • callback終了前に全currency deltaが0か

利用者向け表示

  • quote時のPoolKey、fee、hook、blockを表示または記録できるか
  • callback途中のdeltaをwallet最終残高として見せていないか
  • minimum output、deadline、route、price impactを実行条件へ結びつけたか
  • receiptと最終token balanceをtransaction後に読み直すか

LPの価格変動と集中流動性の前提はインパーマネントロスの計算、quoteと実行差はPrice impact・slippage・MEVの違いで確認できます。vaultのasset/share会計とpoolのcurrency deltaは別モデルなので、ERC-4626のshare計算と混同しないでください。

まとめ

Uniswap v4は、hookを追加できるだけのv3ではありません。複数poolを持つsingleton PoolManager、5項目のPoolKey、transaction中のdelta、unlock callback、最後のsettlementが一つの設計です。

調査時は、PoolKey → hook permissionとcode → callback sender → operation delta → sync / settle / take → deltaゼロ → receiptと最終残高の順に対応させます。permission bit、dynamic fee、動作検証の通過のどれか一つを、hookやpool全体の安全性評価へ置き換えないことが重要です。

この記事には特定poolへのswap、LP deposit、token購入を促す導線はありません。将来developer toolやaudit serviceを紹介する場合も、広告表記とlocal検証結果を分離します。

確認した一次情報