Solidityスマートコントラクトにおける算術ミスは、トークン、シェア、価格の計算で誤った数式、桁数、丸め方向、または数値の仮定を用いることで発生し、ユーザーが過剰な請求権を鋳造したり、預けた額より多く引き出したり、会計不変条件が崩壊したりする原因となります。対策としては、単位を明示的に定義し、完全精度の有界算術を使い、数式が維持すべき経済的不変条件をテストすることが必要です。
Solidityにおける算術ミスとは何か、攻撃はどのように機能するのか?
Solidityの算術ミスとは、預け入れ、引き出し、残高、価格、手数料、流動性を経済的請求権に変換する計算式に欠陥があることを指します。攻撃者は、意図された会計モデルと実装された数式の不一致をついてトランザクションの順序、資産量、プール状態を操作し、不正な換算を有利に利用します。
現代のSolidityでは算術オーバーフローやアンダーフローはデフォルトでリバートされるため、この保護は有用ですが、数式が経済的に正しいことを保証するものではありません。計算がuint256の範囲内であっても、過剰なシェアの鋳造、過小な担保評価、ユーザーに有利な丸めの可能性は残ります。
典型的な過剰シェア攻撃は次のようになります:
- 既存の資産とシェアを持つバルトでは、シェアの価格が1単位ではない。
- バルトが利回り、手数料、寄付、リベースによる残高増加を受ける。
- 総資産が増加したが総シェアが変わらないため、シェア価格が上昇する。
- 攻撃者が「1シェア=1資産」と仮定する数式で資産を預け入れる。
- バルトが現在価格よりも多くのシェアを鋳造する。
- 攻撃者はそのシェアを利用し、プール全体から不釣り合いに多くの資産を引き出す。
核心となる不変条件は単純です:預け入れ後、新規ユーザーの所有比率は、そのユーザーが提供した価値に対応していなければなりません。プール資産の1%相当の預け入れが1%以上のシェアを生むなら、既存保有者の持分が希薄化します。
どのSolidityパターンがトークンおよびシェア会計の誤りを引き起こすか?
トークンやシェアの会計エラーは通常、単位の混同、中間値の切り捨て、変動するシェア価格を固定値として扱うことから発生します。高リスクなのはdeposit、mint、withdraw、redeem、借入限度、手数料清算、流動性計算、報酬分配などの関数です。
危険な典型パターンは、預け入れた資産単位から直接シェアを鋳造することです。
shares = assets * 1e18 / assetScale;
この換算は、初回預け入れ時に1シェアを1正規化資産単位と定義した場合にのみ妥当です。バルトが利回りを得たり損失を被った場合は、常に現在の交換レートを使う必要があります。
shares = assets * totalShares / totalAssets;
しかし、この数式も注意が必要です。先に掛け算をするとunchecked内でオーバーフローの危険があり、先に割り算をすると精度が失われます。小数の正規化も誤りの元です。USDCのような資産は18小数のシェアトークンより桁数が少なく、プロトコルは生トークン単位と正規化会計単位を一貫して区別しなければなりません。
一般的な原因:
- 異なる小数精度の資産に対し
1e18をハードコーディングする。 - シェア価格変動後も固定1対1レートでシェアを鋳造する。
- 掛け算の前に割り算をして意味のある精度を失う。
- 預け入れまたは引き出しで過剰に切り上げし、累積的な価値漏出を生む。
- ユーザー管理の値やスケーリング係数周囲の
unchecked利用。 - トークン残高が通常の入出金フロー外で変化しないと仮定。
- フィーオン転送、リベース、利回りトークンがキャッシュ会計を無効化することを忘れる。
Solidityのシェア計算における算術ミスをどう修正するか?
算術ミス修正には、資産単位を一度正規化し、現在のプール比率でシェア計算し、完全精度の掛け算・割り算を用い、新規預金者へ既存保有者の価値が移らない丸めを選択します。コントラクトは資産残高とシェア供給を連動する会計状態として扱わなければなりません。
次の脆弱なバルト例は、小数を設定可能なトークンを受け入れ、reportYieldで資産が増加しても固定レートで正規化シェアを鋳造しています。これにより、利回り蓄積後に預金者が過剰なシェアを受け取り、そのシェアをプール価値の増加分で償還できます。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function transfer(address to, uint256 amount) external returns (bool);
}
contract VulnerableShareVault {
IERC20 public immutable asset;
uint8 public immutable assetDecimals;
uint256 public totalAssets;
uint256 public totalShares;
mapping(address => uint256) public shares;
constructor(address asset_, uint8 decimals_) {
asset = IERC20(asset_);
assetDecimals = decimals_;
}
function deposit(uint256 assets) external {
require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");
uint256 scale = 10 ** assetDecimals;
// VULNERABLE: 既存シェアの現在価格を無視。
uint256 minted = (assets * 1e18) / scale;
totalAssets += assets;
totalShares += minted;
shares[msg.sender] += minted;
}
function reportYield(uint256 assets) external {
require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");
totalAssets += assets;
}
function withdraw(uint256 shareAmount) external {
uint256 assets = (shareAmount * totalAssets) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
totalAssets -= assets;
require(asset.transfer(msg.sender, assets), "transfer failed");
}
}
修正後の例では、生資産単位を18小数の会計単位へ変換し、現在の交換レートでシェアを鋳造しています。OpenZeppelinの完全精度Math.mulDivを使い、預け入れと引き出しは切り捨て、対応する小数を制限しています。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
interface IERC20 {
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function transfer(address to, uint256 amount) external returns (bool);
}
contract FixedShareVault {
IERC20 public immutable asset;
uint256 public immutable assetScale;
uint256 public totalAssets;
uint256 public totalShares;
mapping(address => uint256) public shares;
constructor(address asset_, uint8 decimals_) {
require(decimals_ <= 18, "unsupported decimals");
asset = IERC20(asset_);
assetScale = 10 ** decimals_;
}
function deposit(uint256 assets) external {
require(assets > 0, "zero assets");
require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");
uint256 normalizedAssets = Math.mulDiv(assets, 1e18, assetScale);
uint256 normalizedPool = Math.mulDiv(totalAssets, 1e18, assetScale);
// FIX: 預金前のプール比率に基づきシェア価格を算出。
uint256 minted = totalShares == 0
? normalizedAssets
: Math.mulDiv(normalizedAssets, totalShares, normalizedPool);
require(minted > 0, "deposit too small");
totalAssets += assets;
totalShares += minted;
shares[msg.sender] += minted;
}
function withdraw(uint256 shareAmount) external {
uint256 assets = Math.mulDiv(shareAmount, totalAssets, totalShares);
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
totalAssets -= assets;
require(asset.transfer(msg.sender, assets), "transfer failed");
}
}
プロトコルが預金、引き出し、担保または流動性の価格設定をカスタム算術で行うなら、スマートコントラクト監査とセキュリティ評価で単位変換と経済的不変条件をトランザクションライフサイクル全体で追跡可能です。
開発者はどうすれば算術ミスを防げるか?
開発者はまず会計単位を定義し、数式を書く前に明確にし、保守的な丸めを適用し、すべての変換をセキュリティ境界とみなすことで算術ミスを防止します。数式は、各入力が何を意味するか、小数精度、許容範囲、そして丸めの恩恵を受ける者を明確にしなければなりません。
| 技術 | 防止内容 | ガス or 複雑度コスト | 制限事項 |
|---|---|---|---|
| 明示的単位正規化 | 生のトークン単位と18小数のシェア単位混同 | 掛け算か割り算が増加 | ドキュメント化された小数方針が必要 |
Math.mulDiv |
a * b / cのオーバーフローと精度損失 |
単純演算子より多いガス | 経済数式の正当性は検証しない |
| 預金・引き出しの切り捨て | 過剰にシェア鋳造・過剰償還を防止 | ダスト残高が発生する可能性 | ダストの回収方針が必要 |
| 最小シェア鋳造閾値 | 0シェアになる預金防止 | 1回の比較 | 正当な小口ユーザーを不当に遮断しないこと |
| 入力値、小数の範囲制限 | 危険なべき乗や極端なユーザー入力防止 | バリデーションの手間 | 対応資産に合わせた境界値設定必須 |
| 不変条件テスト | プール会計違反の状態遷移検出 | テスト工数 | 不変条件は攻撃的連続操作にも対応すべき |
プールがサポートできない価値の発行を防ぐために切り捨て(floor)丸めを使いましょう。例えば、バルトは通常、預け入れシェアは切り捨て、引き出し資産も切り捨てます。ユーザー有利の丸めが必要な場合は、結果生じる補助金を明示的にファンドし、他の預金者に隠れて負担させないようにすべきです。
セキュリティの洞察: Sokenの手法では、算術レビューは演算子ではなく単位と保存則から始まります。チェック済み掛け算でも、結果が誤った資産、シェア、価格、所有権請求であれば脆弱になり得ます。
実際の事故は算術ミスの影響をどう示しているか?
算術エラー事故は、トークン・シェア会計がコード品質の局所問題ではなく、プロトコル全体のセキュリティ性質であることを示しています。影響を受けるロジックはAMMリザーブ、貸付報酬、バルトシェア、流動性計算、資産換算ルーチンなど多岐にわたります。
| プロトコル | 日付 | 損失額 | チェーン |
|---|---|---|---|
| Cetus CLMM | 2025-05-22 | $223.0M | Sui |
| Compound V2 | 2021-09-29 | $147.0M | Ethereum |
| Uranium Finance | 2021-04-28 | $57.2M | BSC |
| ValueDefi | 2021-05-07 | $11.0M | BSC |
| KiiChain | 2026-08-22 | $9.7M | KiiChain |
| Truebit | 2026-01-08 | $26.4M | Ethereum |
| Notional V2 | 2026-09-04 | $1.7M | Ethereum |
| USM | 2026-08-09 | $136K | Ethereum |
これら事故には共通点があります:計算がオンチェーン価値の分配または評価を支配していたことです。失敗は必ずしも未チェックのuint256加算ではなく、リザーブ、供給、価格、ユーザー請求の誤った関係である場合が多いです。
DefiLlamaは2020年から2026年までに35件のスマートコントラクト算術エラー事故を記録し、合計損失は$561.0Mに達します。プロトコルチームにとって、この歴史はアクセス制御や外部呼び出し処理と同じ優先度で会計経路のレビューを行うことを支持します。
デプロイ前に算術ミスをどう検出するか?
算術ミスは、孤立した数式を手動でチェックするより、状態遷移をレビューし多数の操作シーケンスで経済的特性をテストするほうが効果的です。静的解析で危険な演算子・uncheckedブロックを特定し、ファジングと不変条件テストで預け入れ、引き出し、利回り、損失、異常な小数値への会計モデルの耐性を検証します。
集中したレビューのチェックリスト:
- すべての資産、シェア、価格、レート、手数料変数の単位と小数をリストアップする。
- すべての預け入れが預入前の交換レートを使っていることを検証する。
- すべての引き出しが消費されるシェアに一致するレートで計算されていることを検証する。
- 会計経路での生の
*、/、unchecked、ハードコーディングされたスケール定数を探す。 - 小数精度が異なる資産や最小預け入れ近辺でテストする。
- 丸めにより非対応のシェア鋳造や資産引き出しが起きないことを確認する。
- ユーザー操作前にリベース、寄付、手数料、利回りによる残高変動をモデル化する。
- ユーザーの所有請求合計が償還可能な資産を超えないことをチェックする。
Slitherは危険な算術パターンやuncheckedブロック、怪しい精度演算を検出できますが、シェア価格数式の経済的妥当性は判断できません。FoundryのファジングはtotalAssetsが全償還請求をカバーし、正当な利回りがない限り直近の入出金サイクルで預金者が利益を得られないことを主張と組み合わせると有効です。
不変条件テストはマルチユーザーシーケンスを生成すべきです:預け入れ、利回り報告、引き出し、部分引き出し、寄付、繰り返し小口預け入れ。Sokenのレビューでは、意図した数理モデルと各状態更新を照合し、状態変化の前に正しい数式を適用しても誤ったシェア価格は防げないことにも注意します。
算術の安全性とは、すべての変換で経済的真実を守ることです。Solidity 0.8のチェック済み算術を使っても、誤った価格で請求権を鋳造し、誤った小数を用い、外向きに丸めるなら脆弱性が残ります。
まず会計変数ごとに単位と不変条件を書き、不変条件をFoundryのテストに組み込んでから新規資産タイプや価格経路を追加しましょう。Solidityセキュリティのパターン研究を深めたい方は、Soken Hubをご覧ください。