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

ERC-4626 vaultへassetを預けたのに、想定よりshareが少ない。previewDepositは十分に見えたのに、実行時の最小受取量を守れない。初回depositの直前にdonationが入り、丸めで0 shareになった。このような問題を調べる人向けに、換算式、4操作、rounding、fee、lossを一つのローカル検証用実装で確認します。
結論は、assetとshareを別の単位として扱い、操作直前のtotalAssets、totalSupply、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=100、totalSupply=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のtotalAssetsとtotalSupplyへ対応させます。
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に分かれます。
3 asset・2 shareで丸めを確認する
意図的にvirtual unitを外した検証用実装をtotalAssets=3、totalSupply=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表示だけでなく、minSharesやmaxAssetsなどの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の既定実装では、maxDepositとmaxMintは制限なしを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で再現しました。
- attackerが1 assetをdepositする
- attackerがvaultへ100 assetsを直接transferし、shareを受け取らない
- victimが100 assetsをdepositする
- 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できます。
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へ対応させます。
asset()、asset decimals、vault share decimalsを読むtotalAssetsのsourceと、totalSupply、exchange rateを確認する- 利用する操作に対応する
preview*を選ぶ - receiver / ownerに対応する
max*を読む - entry / exit fee、cap、pause、queue、strategy liquidityを確認する
- quoteを
minShares、maxAssets、maxShares、minAssetsへ結びつける - 初回deposit、small deposit、direct donation、loss、feeをtestする
- fee-on-transfer、rebasing、callbackなど対象asset固有のbalance差をtestする
- 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はなく、スポンサーの有無と技術評価を分離します。
確認した一次情報
- ERC-4626 Tokenized Vaults確認日: 2026/08/31
- OpenZeppelin ERC-4626 guide確認日: 2026/08/31
- OpenZeppelin Contracts v5.6.1確認日: 2026/08/31
- OpenZeppelin ERC4626 v5.6.1 source確認日: 2026/08/31
- OpenZeppelin IERC4626 v5.6.1 source確認日: 2026/08/31
- OpenZeppelin Math v5.6.1 source確認日: 2026/08/31
- Solidity 0.8.36 documentation確認日: 2026/08/31
- Foundry v1.8.0確認日: 2026/08/31



