3MIKAN
仮想通貨直コン

ERC-4626 Vaultの仕組み|share計算・rounding・inflation attack

ERC-4626のasset/share換算、4操作の丸め、preview・max、初回depositへのinflation attack、virtual offset、fee・lossをFoundryで検証します。

3MIKANのブランドキャラクターがasset球を透明なvaultへ預け、別単位のshare札とdonation用の横口を確認するERC-4626の記事画像

ERC-4626 vaultへassetを預けたのに、想定よりshareが少ない。previewDepositは十分に見えたのに、実行時の最小受取量を守れない。初回depositの直前にdonationが入り、丸めで0 shareになった。このような問題を調べる人向けに、換算式、4操作、rounding、fee、lossを一つのローカル検証用実装で確認します。

結論は、assetとshareを別の単位として扱い、操作直前のtotalAssetstotalSupply、decimals、該当するpreview*max*、fee、最小受取量を同じblockで確認することです。ERC-4626準拠、preview、virtual offset、test通過のどれか一つだけでは、資産保全や収益を保証しません。

この記事の検証はOpenZeppelin Contracts 5.6.1、Solidity 0.8.36、Foundry 1.8.0を固定したlocal contractだけで行います。外部RPC、mainnet fork、実在wallet、実資産、yield strategyは使いません。

assetとshareは別の単位

ERC-4626は、一種類のERC-20 assetを受け取り、そのvaultへの持分をERC-20 shareとして表すinterfaceです。

asset: vaultが保有・運用する基礎token
share: vault全体に対する持分を表すERC-20

totalAssets=100totalSupply=100なら、単純化した平均では1 shareが1 asset相当です。donationや運用益でtotalAssets=150になり、shareが100のままなら、1 shareは約1.5 asset相当になります。損失でassetが60へ減れば、shareを100持っていても100 assetは引き出せません。

したがって、「1 share = 1 asset」は初期状態の一例にすぎません。walletのshare残高だけでなく、同じvaultのtotalAssetstotalSupplyへ対応させます。

deposit・mint・withdraw・redeemの違い

4操作は、呼び出し側が固定する単位と、vaultが計算する単位が違います。

操作 呼び出し側が固定 vaultが返す・消費 対応preview 利用者に不利にならない丸め
deposit(assets, receiver) 入れるasset 受け取るshare previewDeposit(assets) shareを切り上げず、下へ丸める
mint(shares, receiver) 欲しいshare 支払うasset previewMint(shares) 必要assetを下げず、上へ丸める
withdraw(assets, receiver, owner) 受け取るasset 焼却するshare previewWithdraw(assets) 必要shareを下げず、上へ丸める
redeem(shares, receiver, owner) 返すshare 受け取るasset previewRedeem(shares) assetを切り上げず、下へ丸める

「asset量を決めて入る」のがdeposit、「share量を決めて入る」のがmintです。退出側も、「asset量を決める」withdrawと「share量を決める」redeemに分かれます。

3MIKANのキャラクターが上下二つのvault機械でassetからshare、shareからassetへの換算と切り下げ・切り上げの違いを示す補助図
図は単位と方向の補助表現です。正確な入力、出力、roundingは上のHTML表と再現結果を正本にします。

3 asset・2 shareで丸めを確認する

意図的にvirtual unitを外した検証用実装をtotalAssets=3totalSupply=2へ置き、1 unitを入力すると次の結果になります。

関数 丸め前 再現結果 理由
previewDeposit(1) 1 × 2 ÷ 3 = 0.66… share 0 share 発行shareはfloor
previewMint(1) 1 × 3 ÷ 2 = 1.5 asset 2 asset 必要assetはceil
previewWithdraw(1) 1 × 2 ÷ 3 = 0.66… share 1 share 必要shareはceil
previewRedeem(1) 1 × 3 ÷ 2 = 1.5 asset 1 asset 受取assetはfloor

1 base unit未満はtoken上で表せません。depositで0 shareになるほど小さい入力は、assetだけvaultへ入り、既存share保有者へ価値を移すdonationになり得ます。frontendとrouterはquote表示だけでなく、minSharesmaxAssetsなどのslippage条件を実行callへ結びつける必要があります。

convert・preview・maxを交換しない

ERC-4626には似たview関数がありますが、用途は別です。

関数群 答える質問 含めるもの 含めないもの
convertToShares / convertToAssets 平均的な換算はいくつか 実装が定める換算rate operation固有feeや個別limitを前提にしない
previewDeposit / previewMint / previewWithdraw / previewRedeem 現在条件で該当操作を行うと何unitsか operation固有feeを含む実行近似 max*が表す利用者・全体limit
maxDeposit / maxMint / maxWithdraw / maxRedeem 現在そのreceiver・ownerへ許される上限はいくつか global・利用者別limit 実行数量の反対側quote

OpenZeppelin 5.6.1の既定実装では、maxDepositmaxMintは制限なしをuint256最大値で表し、maxRedeem(owner)はownerのshare残高、maxWithdraw(owner)はそのshareのpreviewRedeemです。pause、cap、queueなどを持つvaultはoverrideできます。

previewは同じtransaction内の現在条件へ近づける関数ですが、price oracleではありません。別transactionになる間にdonation、fee、loss、limit、strategy評価が変われば、先に見た値と実行値はずれ得ます。

asset decimalsとshare decimalsを表示値で混ぜない

今回のassetは6 decimalsです。

vault asset decimals share decimals empty vaultへ1.000000 assetをdeposit
OpenZeppelin既定 offset 0 6 6 1,000,000 share base units
OpenZeppelin offset 3 6 9 1,000,000,000 share base units

どちらも表示上は初期rateを理解できますが、base unitの桁数は違います。10 ** assetDecimalsをshareへそのまま使わず、assetとvaultのdecimals()を別々に読みます。

精度を上げると小さなdepositが多くのshare base unitsへ分かれ、rounding lossを小さくできます。ただしdecimals差だけを見ても防御全体は分かりません。conversion式に入るvirtual assetとvirtual shareも確認します。

donation型inflation attackを同じ100 unitsで比較する

空のvaultに対して、次の順番をlocal testで再現しました。

  1. attackerが1 assetをdepositする
  2. attackerがvaultへ100 assetsを直接transferし、shareを受け取らない
  3. victimが100 assetsをdepositする
  4. attackerが最初のshareをredeemする
実装 attacker初回share victimのshare attacker回収asset attacker支出101に対する結果
virtual unitなしの意図的な脆弱実装 1 0 201 victimの100を取り込む
OpenZeppelin 5.6.1既定 offset 0 1 1 67 34の損失
OpenZeppelin 5.6.1 offset 3 1,000 1,960 51 50の損失

脆弱実装では、victimの換算がfloor(100 × 1 ÷ 101)=0です。victimのassetはvaultへ入りますがshareは増えず、唯一のshareを持つattackerが201 assetsをredeemできます。

3MIKANのキャラクターがdonationでshare出力が細る無防備vaultと、virtual unitと高精度shareを持つvaultを見比べる補助図
左は無防備な換算、右はvirtual unitと細かなshare単位の概念図です。攻撃値と回収額は上の実行表を参照してください。

virtual asset・virtual share・offsetの役割

OpenZeppelin 5.6.1の既定conversionは、概念上次の値を使います。

shares = assets × (totalSupply + 10^offset) ÷ (totalAssets + 1)
assets = shares × (totalAssets + 1) ÷ (totalSupply + 10^offset)

+1のvirtual assetと+10^offsetのvirtual sharesが、空のvaultにも初期rateを置きます。attackerがdonationしても、attackerはreal sharesだけを持ち、virtual sharesの取り分まで回収できません。offsetを増やすとshare精度が上がり、同じdepositが0 shareへ丸められるために必要なdonationも大きくなります。

ただし、これは万能な防御ではありません。

  • slippage conditionなしで別transactionのpreviewを信頼する問題は残る
  • implementationがconversionやtotalAssetsをoverrideすれば前提が変わる
  • virtual sharesは収益のごく一部を捉え、loss時の退出順にも影響し得る
  • cap、pause、queue、strategy liquidity、oracle、async withdrawalは別の確認対象

既定offset 0でも今回のattackは赤字になりましたが、「どのdepositも損失ゼロ」「どの実装も攻撃不能」という意味ではありません。

feeは対応するpreviewへ入れる

1%のentry / exit feeをassetで取る検証用実装では、次のquoteと実行結果を確認しました。

操作 利用者が指定 preview・実行結果 fee
deposit 101 assetsを支払う 100 sharesを受け取る 1 asset
mint 100 sharesを受け取る 101 assetsを支払う 1 asset
withdraw 99 assetsを受け取る 100 sharesを焼却 1 asset
redeem 100 sharesを焼却 99 assetsを受け取る 1 asset

depositのassetsはfee込み、withdrawのassetsはreceiverが実際に受け取る量として扱っています。したがってentry feeはpreviewDepositのshareを減らし、previewMintの必要assetを増やします。exit feeはpreviewWithdrawの必要shareを増やし、previewRedeemの受取assetを減らします。

OpenZeppelin guideのfee extension例自身もproduction-readyやaudit済みを保証していません。feeをassetで取るかshareで取るか、recipient、端数、event、最大値との整合まで実装ごとに確認します。

lossはshareを自動で減らさず、1 share当たりassetを下げる

100 assetsをdepositして100 sharesがあるvaultから、40 assetsのlossを直接反映すると次のstateになりました。

totalAssets: 100 → 60
totalSupply: 100 → 100
previewRedeem(100): 60 assets

share残高を自動で40%burnするのではなく、同じshareが請求できるasset量が下がります。totalAssetsがstrategyの時価、未回収債権、手数料、locked liquidityをどう数えるかはERC-4626 interfaceだけでは決まりません。

利用者側は、totalAssetsの実装、更新頻度、loss認識、withdraw可能なliquidity、maxWithdrawを分けて見ます。share price表示だけで即時withdraw可能額を断定しません。

fee-on-transfer・rebasing assetはbalance差分まで見る

OpenZeppelin 5.6.1の既定totalAssets()は、assetのbalanceOf(vault)を返します。直接balanceが25増える再現ケースでは、totalAssetsも100から125へ増え、100 sharesのpreviewRedeemはvirtual unitを含め124になりました。balanceを戻すとquoteも戻ります。

一方、1% fee-on-transfer assetを既定vaultへ100 transferした例では、vaultが受け取ったのは99でも、事前quoteに基づく100 sharesがmintされました。

deposit引数: 100
vault受取: 99
発行share: 100

これは「すべてのfee-on-transfer tokenが同じ」という主張ではなく、requested amountとreceived amountが異なるassetを既定transfer pathへ入れると会計前提が崩れる境界例です。rebasing、callback付き、blacklist付き、戻り値が標準外のassetも、名称で一括判断せず、実際のbalance差、revert、share発行順をintegration testで固定します。

Foundry invariantで守る4つの性質

offset 3のvaultに対し、4 actorがdeposit、donation、redeem、lossをランダムな順序で行うhandlerを作りました。64 runs × 64 calls、合計4,096 callsで次を確認しています。

totalAssets == asset.balanceOf(vault)
totalSupply == known actorsのshare残高合計
previewRedeem(totalSupply) <= totalAssets
previewRedeem(previewDeposit(x)) <= x

実行結果は次の通りです。

unit tests: 10 passed, 0 failed
invariants: 4 passed
runs: 64, calls: 4096, reverts: 0

固定seed、操作別call count、数値結果は公開検証JSONへ保存しました。handler、actor、ghost ledger、target selectorの作り方はFoundry invariant test入門で先に確認できます。

このcampaignは、指定した4操作、4 actor、入力範囲、runs、depthの中で反例を探します。passは網羅的証明でもaudit完了でもありません。

実装・integrationの確認順

実装または接続前は、次を同じvault addressとblockへ対応させます。

  1. asset()、asset decimals、vault share decimalsを読む
  2. totalAssetsのsourceと、totalSupply、exchange rateを確認する
  3. 利用する操作に対応するpreview*を選ぶ
  4. receiver / ownerに対応するmax*を読む
  5. entry / exit fee、cap、pause、queue、strategy liquidityを確認する
  6. quoteをminSharesmaxAssetsmaxSharesminAssetsへ結びつける
  7. 初回deposit、small deposit、direct donation、loss、feeをtestする
  8. fee-on-transfer、rebasing、callbackなど対象asset固有のbalance差をtestする
  9. unit、fuzz、invariantを通し、見つけた最小反例を回帰testへ戻す

assetをvaultへ使わせるallowanceの保存先と失効はapprove・Permit・Permit2・ERC-7674の比較、AMMのrateと損益を別モデルで追う場合はインパーマネントロスとAMM式を参照してください。

検証コードを再実行する

検証コードを取得したディレクトリで、次のコマンドを実行します。

forge fmt --check
forge test -vv
node verify-results.mjs
tool 固定version
Foundry / Forge 1.8.0 / commit 61ae26af36320d4fa1020f7db53785885e29eeb5
Solidity 0.8.36
OpenZeppelin Contracts 5.6.1 / commit 5fd1781b1454fd1ef8e722282f86f9293cacf256
EVM Prague
optimizer enabled / 200 runs

検証コードのVulnerableVaultは攻撃を再現するため、意図的にvirtual unitsを外しています。deployしてはいけません。この記事に特定vaultへのdeposit、利回り、token購入を促すaffiliate CTAはなく、スポンサーの有無と技術評価を分離します。

確認した一次情報