Appearance
L1-2: Multisend(バルク転送)
1. 課題
単一のトランザクションで複数のアドレスにETHまたはERC20トークンを一括送信するコントラクトを作成します。すべての転送が完全に成功するか、すべてロールバックする(原子性)必要があります。
2. 背景
イーサリアムでは、EOAは1トランザクションにつき1回の転送しか送信できません。一括転送にはコントラクトによる中継が必要です:
- DeFiプロトコルの報酬分配
- DAOから複数の貢献者への支払い
- 複数アドレスへのトークンエアドロップ
原子性により「一部の転送だけ成功し、他は失敗する」という不整合な状態を防ぎます。
3. 解決策
ETH一括転送
solidity
function sendETH(address payable[] calldata receivers, uint256[] calldata amounts)
msg.value == sum(amounts)を検証- 各受信者に対して
call{value: amount}("")を実行 - 1つでも失敗すればトランザクション全体がロールバック
ERC20一括転送
solidity
function sendTokens(address[] calldata receivers, uint256[] calldata amounts, address token)
SafeERC20.safeTransferFromを使用して1件ずつ転送- 呼び出し元は事前にコントラクトをapproveする必要があります
4. 遭遇した落とし穴
4.1 transfer()をcall()の代わりに使用している
transfer()は2300 gasに固定されています。受信側がコントラクトで、fallbackにより多くのgasが必要な場合、転送は失敗します。
4.2 msg.valueの検証漏れ
msg.value == sum(amounts)を検証しないと、コントラクト自身のETHが誤って引き出される可能性があります。
4.3 SafeERC20のインポート
OpenZeppelin v5では、safeTransferFromはSafeERC20ライブラリに含まれており、using SafeERC20 for IERC20が必要です。
5. 落とし穴の原因
5.1
イーサリアムのEIP-1884以降、特定のオペコードのgasコストが増加しました。2300 gasでは意味のあるコントラクトロジックを実行するには不十分です。call{value}("")は利用可能なすべてのgasを転送します。
5.2
配列の長さだけを検証して合計金額を検証しない場合、攻撃者は合計よりも多くのETHを送信して差額をコントラクトに負担させたり、合計よりも少なく送信してコントラクト自身の資金が引き出されるように仕向けたりする可能性があります。
6. 落とし穴の解決方法
6.1
低レベルなcall{value: amount}("")を.transfer()や.send()の代わりに使用します。
6.2
ループの前に合計金額を計算し、msg.value == totalSentを検証します。
7. 技術的ポイント
| ポイント | 説明 |
|---|---|
| ETH転送パターン | call{value}("") > transfer() |
| ERC20転送パターン | safeTransferFrom > transferFrom |
| 原子性 | Solidityでは、revertが発生するとすべての状態変更が自動的にロールバック |
| Gas最適化 | 配列走査はO(n)、多数の受信者ではGas上限に注意 |
| SafeERC20 | OZ v5ではライブラリとして提供され、using文が必要 |