広告

第13話:お金のやり取りを実装!payable修飾子とEtherの送受信

2025年7月31日

第13話:お金のやり取りを実装!payable修飾子とEtherの送受信

概要

コントラクトからETHを安全に送金するための完全ガイド。transfer, send, callの3つの方法を比較し、なぜ.call{value:…}が現在のベストプラクティスなのかを解説します。

同時に、callが内包するリエントランシー攻撃の深刻なリスクと、それを完全に防ぐための絶対的な設計規則「Checks-Effects-Interactions」を学習。ユーザーの資産を守るための必須知識が身につきます。

目次
Solidity学習講座:最終版 目次(全20話)
Solidity学習講座:最終版 目次(全20話)

基礎編 第1話:未来のインターネットへようこそ!Solidityとスマートコントラクトの全体像 第2話:準備は1分!ブラウザだけで開発できる「Remix IDE」の基本操作 第3話:記念すべき初コント ...

続きを見る

はじめに

第12話で、あなたは「継承」という強力な武器を手に入れ、コードの再利用性と構造を劇的に改善しました。OpenZeppelinのようなライブラリを継承すれば、安全なOwnableコントラクトなどを簡単に実装できますね。

そして第11話では、payable修飾子を使い、あなたのコントラクトがユーザーからETHを受け取る方法を学びました。これにより、あなたのコントラクトはaddress(this).balanceに、ユーザーから預かった資産を保持できるようになりました。

ここで当然、次のステップが待っています。それは、**「コントラクトに保管されたETHを、外部のアドレスへ安全に送金する」**方法です。

ユーザーからの出金リクエストに応える、クラウドファンディングで集めた資金をプロジェクトオーナーに渡す、NFTの売上をクリエイターに支払う…。これらすべての行為は、コントラクトからのETH送金機能がなければ成り立ちません。

今回は、SolidityでETHを送金するための3つの方法 (transfer, send, call) を学びます。しかし、ただ使い方を学ぶだけではありません。それぞれの方法が持つセキュリティ上の意味合いと、特に現代のスマートコントラクト開発で絶対に守るべき設計パターンについて、深く理解していただきます。ユーザーの資産を守れるかどうかは、このレッスンにかかっていると言っても過言ではありません。

ETHを送金する3つの方法

ユーザーが預けた資金を引き出す、シンプルな銀行コントラクトを例に、3つの送金方法を見ていきましょう。

Solidity
contract SimpleBank {
    mapping(address => uint) public balances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    // これから、ここに出金ロジックを実装していく
    function withdraw(uint _amount) public {
        // ...
    }
}

1. <address>.transfer(amount) : かつて推奨された方法

transferは、指定したアドレスに指定した量のETH(wei単位)を送金する、最もシンプルな方法です。

  • 構文: 送金先アドレス.transfer(金額);
  • エラー処理: 送金が何らかの理由で失敗した場合(例:送金先がETHを受け取れないコントラクトだった場合など)、トランザクション全体が自動的に**リバート(revert)**されます。つまり、すべての変更が取り消されるため、安全です。
  • 重要な特徴: transferは、送金時に2,300ガスという非常に小さな固定のガスしか相手に渡しません。これは、送金先のコントラクトが複雑な処理(リエントランシー攻撃など)を実行するのを防ぐための安全装置として設計されました。

しかし、【重要】 この2,300ガスという固定値は、イーサリアムネットワークのガス料金体系の変更により、現在ではリスクと見なされています。例えば、Gnosis Safeのようなマルチシグウォレットは、ETHを受け取るだけで2,300以上のガスを必要とする場合があります。つまり、transferを使うと、正当なマルチシグウォレットなどに対して送金が失敗する可能性があるのです。

このため、transferは、かつてはベストプラクティスでしたが、現在では使用が推奨されていません

2. <address>.send(amount) : 非推奨の方法

sendtransferと非常によく似ています。

  • 構文: bool success = 送金先アドレス.send(金額);
  • 特徴: transferと同様に、2,300ガスの固定値を渡します。
  • エラー処理(危険な点): transferと違い、送金が失敗しても自動でリバートしません。その代わり、成否をbool値(成功ならtrue、失敗ならfalse)で返します。したがって、開発者は必ず戻り値を確認する必要があります。 require(success, "Failed to send Ether"); このチェックを怠ると、送金が失敗したにもかかわらず、残高だけが減るなどの致命的なバグに繋がります。

transferと同じガスリミットの問題を抱え、さらにエラー処理を開発者に委ねるリスクがあるため、sendは現在、利用すべきではありません

3. <address>.call{value: amount}("") : 現在のベストプラクティス(最重要)

transfersendが非推奨となった今、ETHの送金には**call**メソッドを使うのが標準的な方法です。

  • 構文: (bool success, ) = 送金先アドレス.call{value: 金額}("");
  • 特徴: callは、デフォルトでその時点で利用可能な全てのガスを送金先に渡します。これにより、マルチシグウォレットのようなガス消費が多めのコントラクトでも、問題なくETHを受け取ることができます。
  • エラー処理: sendと同様に、自動でリバートせず、成否をbool値で返します。したがって、戻り値のチェックは必須です。

しかし、利用可能なガスを全て渡すということは、送金先のコントラクトが悪意のあるコードを実行する時間を与えてしまう、ということを意味します。これが、悪名高いリエントランシー(Re-entrancy)攻撃の脆弱性に繋がります。

ではどうすればよいのか? 答えは、callを**「Checks-Effects-Interactions」**という絶対的なパターンと組み合わせて使うことです。

最重要セキュリティパターン:Checks-Effects-Interactions

これは、外部のコントラクトとやり取り(Interaction)する関数を書く際の鉄則です。処理を必ず以下の順番で実行してください。

  1. Checks(チェック): まず、すべての条件を確認します。require文を使い、関数の実行に必要な前提条件(例:出金額が残高以下であること)を検証します。
  2. Effects(エフェクト/状態変更): 次に、外部とやり取りするに、自身のコントラクトの状態を変更します。例えば、ユーザーの残高を先んじて減らしてしまいます。
  3. Interactions(インタラクション/外部対話): 最後に、外部コントラクトとのやり取り(この場合はETHの送金)を実行します。

このパターンを、先のwithdraw関数に適用してみましょう。

Solidity
function withdraw(uint _amount) public {
    // 1. Checks (チェック)
    uint userBalance = balances[msg.sender];
    require(userBalance >= _amount, "Insufficient balance.");

    // 2. Effects (状態変更)
    // ETHを送金する"前"に、自分の残高を減らす!これが最も重要。
    balances[msg.sender] = userBalance - _amount;

    // 3. Interactions (外部対話)
    (bool sent, ) = msg.sender.call{value: _amount}("");
    require(sent, "Failed to send Ether.");
}

なぜこの順番が重要なのか?(リエントランシー攻撃の仕組み)

もし、「Effects」と「Interactions」の順番を間違えたらどうなるでしょうか。

  1. 悪意のある攻撃者が、自分の攻撃用コントラクトからwithdraw関数を呼び出します。
  2. Checks: 残高チェックはパスします。
  3. Interactions: あなたのコントラクトは、攻撃者のコントラクトにETHを送金します。(状態を変更する前に!
  4. receive(): 攻撃者のコントラクトはETHを受け取った瞬間に、その内部ロジック(receive関数)で、再度あなたのwithdraw関数を呼び出します。(これがリエントラント=再入)
  5. Checks: あなたのコントラクトの残高は、まだ更新されていません。したがって、2回目の残高チェックもパスしてしまいます
  6. Interactions: あなたのコントラクトは、再びETHを送金してしまいます。
  7. このプロセスがガス切れになるまで繰り返され、あなたのコントラクトの資金は全て抜き取られてしまいます。

しかし、「Checks-Effects-Interactions」パターンを守れば、2回目のwithdraw呼び出しが来た時点で、すでにbalances[msg.sender]は減算(またはゼロに)されている(Effects)ため、Checksrequire文で処理が失敗し、攻撃は成立しません。

まとめ:安全な価値の移転を司る

お疲れ様でした。今回は、ユーザーの資産を直接扱う、極めて重要なETHの送金方法について学びました。

  • transfersendは、固定ガスリミットの問題から、現在では非推奨である。
  • ETHの送金には、<address>.call{value: amount}("") を使うのが現在のベストプラクティスである。
  • callを使う際は、リエントランシー攻撃を防ぐため、「Checks-Effects-Interactions」パターンを絶対に遵守しなければならない。

この知識は、あなたの書くコードの安全性を飛躍的に高めます。DeFiプロトコルやマーケットプレイスなど、価値の移転が頻繁に発生するアプリケーションを開発する上で、このパターンはあなたの命綱となるでしょう。

さて、あなたはrequireを使って、不正な操作を防ぐ方法を学びました。しかし、エラー処理の方法はrequireだけではありません。

次回、**第14話「不正な操作は許さない!requirerevertで堅牢なコントラクトを作る」**では、requireassert、そしてrevertという3つのエラー処理方法の違いと、それぞれの適切な使い分けについて深掘りします。クリーンで、ガス効率が良く、そして何より堅牢なコントラクトを構築するための必須知識です。

目次
Solidity学習講座:最終版 目次(全20話)
Solidity学習講座:最終版 目次(全20話)

基礎編 第1話:未来のインターネットへようこそ!Solidityとスマートコントラクトの全体像 第2話:準備は1分!ブラウザだけで開発できる「Remix IDE」の基本操作 第3話:記念すべき初コント ...

続きを見る

-Solidity