Solidity開発者を悩ませる!よくあるエラーとその解決策集

スマートコントラクト開発言語として広く使われているSolidity。しかし、その強力さゆえに、開発者は様々なエラーに直面します。特にブロックチェーンの不変性という性質上、一度デプロイしたコントラクトの修正は困難であり、エラーは致命的な結果を招く可能性があります。😱

このブログ記事では、Solidity開発で遭遇しやすい一般的なエラーを取り上げ、その原因と具体的な解決策をコード例と共に詳しく解説していきます。エラーを未然に防ぎ、より安全で堅牢なスマートコントラクトを開発するための一助となれば幸いです。🚀

1. コンパイル時エラー

ソースコードをバイトコードに変換するコンパイル段階で発生するエラーです。コードの文法的な誤りや型に関する問題などが原因となります。比較的発見しやすく、修正も容易な場合が多いです。

⚠️ Solidityバージョンの指定

Solidityはバージョン間で破壊的な変更が含まれることがあります。そのため、pragma solidity ^0.8.0; のように、意図したコンパイラバージョンを明確に指定することが非常に重要です。これにより、予期せぬコンパイルエラーや挙動の違いを防ぐことができます。

最も基本的なエラーの一つで、Solidityの文法ルールに従っていない場合に発生します。

原因の例:

  • セミコロン (;) の欠落
  • 括弧 ((), {}, []) の不一致や閉じ忘れ
  • キーワードのスペルミス (例: functon แทนที่จะเป็น function)
  • 予約語の不正使用

コード例 (エラー):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SyntaxErrorExample {
    uint public myNumber

    function setNumber(uint _newNumber) public {
        myNumber = _newNumber // セミコロンがない!
    }

    function getNumber() public view returns (uint) {
        return myNumber;
    }
} // 閉じ括弧が足りない可能性
      

解決策:

エラーメッセージに表示される行番号と内容を確認し、該当箇所の文法的な誤りを修正します。上記の例では、myNumber の宣言の後と myNumber = _newNumber の後にセミコロンを追加します。


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SyntaxErrorExample {
    uint public myNumber; // セミコロンを追加

    function setNumber(uint _newNumber) public {
        myNumber = _newNumber; // セミコロンを追加
    }

    function getNumber() public view returns (uint) {
        return myNumber;
    }
}
      

変数や関数の引数、戻り値などの「型」に関するエラーです。Solidityは静的型付け言語であり、型の整合性が厳密にチェックされます。

原因の例:

  • 異なる型同士の代入 (例: uint 型変数に string を代入)
  • 互換性のない型での演算 (例: address 型と uint 型の加算)
  • 関数呼び出し時の引数の型が定義と異なる
  • 暗黙的な型変換が許可されていないケース (特にSolidity 0.8.0以降で厳格化)
  • データロケーション (storage, memory, calldata) の指定誤りや欠落 (特に配列や構造体、文字列)

コード例 (エラー):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract TypeErrorExample {
    uint public value = 100;
    string public message = "Hello";

    function addValue(string _add) public {
        // uint型変数にstring型を足そうとしている
        value = value + _add; // TypeError!
    }

    function getMessage() public view returns (string) { // データロケーション指定がない (Solidity 0.5.0以降でエラー)
        return message;
    }
}
      

エラーメッセージの例 (Data Location):

TypeError: Data location must be "memory" or "calldata" for return parameter in function, but none was given.

解決策:

エラーメッセージを確認し、型の不一致がある箇所を修正します。明示的な型変換が必要な場合は、uint()string() などを使用します。データロケーションが必要な場合は、memorycalldata を適切に指定します。


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract TypeErrorExample {
    uint public value = 100;
    string public message = "Hello";

    // 引数の型をuintに変更するか、別のロジックにする
    function addValue(uint _add) public {
        value = value + _add; // OK
    }

    // 戻り値にデータロケーション 'memory' を指定
    function getMessage() public view returns (string memory) {
        return message;
    }
}
      

変数、関数、イベント、修飾子などの宣言に関するエラーです。

原因の例:

  • 未宣言の変数や関数の使用
  • 同じスコープ内での識別子(変数名、関数名など)の重複
  • スコープ外からのアクセス(例:private 変数への外部からのアクセス試行)
  • 修飾子の定義や使用方法の誤り
  • コンストラクタ名のタイポ (Solidity 0.4.22以前で発生しやすかった。現在は constructor キーワードを使用)

事例: Wrong Constructor Name (Solidity 0.4.22以前)

Solidity 0.4.22より前のバージョンでは、コントラクト名と同じ名前の関数がコンストラクタとして扱われていました。もし開発者がコンストラクタの関数名をタイプミスした場合、それは通常のpublic関数として扱われ、誰でも呼び出せてしまう可能性がありました。これにより、初期化処理(例えばオーナーの設定など)を誰でも実行でき、コントラクトの所有権を奪われるなどの脆弱性が存在しました。現在は constructor キーワードが導入されたため、この問題は起こりにくくなっています。

コード例 (エラー):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract DeclarationErrorExample {
    uint public value;
    uint public value; // 同じ変数名を再宣言

    function setValue(uint _val) public {
        data = _val; // 未宣言の変数 'data' を使用
    }

    function getValue() public view returns (uint) {
        return value;
    }
}
      

解決策:

変数や関数を使用する前に正しく宣言します。識別子の重複を避けます。アクセス制御(可視性)を確認し、適切なスコープからアクセスするようにします。


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract DeclarationErrorExample {
    uint public value;
    // 重複する宣言を削除
    uint public data; // 'data' を宣言

    function setValue(uint _val) public {
        data = _val; // 正しく宣言された変数を使用
    }

    function getValue() public view returns (uint) {
        return value;
    }
}
      

関数や状態変数の可視性修飾子 (public, private, internal, external) の指定が不適切、または欠落している場合に発生します。

原因の例:

  • 関数に可視性修飾子が指定されていない(必須)
  • internal 関数を外部から呼び出そうとする
  • private 関数を継承したコントラクトから呼び出そうとする
  • ライブラリ関数での可視性指定の誤り

コード例 (エラー):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VisibilityErrorExample {
    uint private secretValue = 123;

    // 可視性修飾子がない
    function getValue() view returns (uint) { // VisibilityError!
        return secretValue;
    }
}

contract Caller {
    VisibilityErrorExample example = new VisibilityErrorExample();

    function tryGetSecret() public view returns (uint) {
        // private変数は外部から直接アクセスできない
        // return example.secretValue; // エラーになる (コンパイルは通るがアクセスできない)

        // getValue()がpublicでないと呼び出せない
        return example.getValue(); // VisibilityError! (getValueがpublicでないため)
    }
}
      

解決策:

全ての関数に適切な可視性修飾子 (public, private, internal, external) を指定します。アクセスしたいスコープに応じて、正しい可視性を選択します。


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VisibilityErrorExample {
    uint private secretValue = 123; // 内部状態

    // 外部や継承コントラクトからアクセスさせたいなら public or external
    // このコントラクトと継承コントラクト内からのみなら internal
    // このコントラクト内からのみなら private
    function getValue() public view returns (uint) { // 可視性を public に指定
        return secretValue; // private変数には内部からアクセス可能
    }
}

contract Caller {
    VisibilityErrorExample example = new VisibilityErrorExample();

    function tryGetSecret() public view returns (uint) {
        // public関数になったので呼び出せる
        return example.getValue(); // OK
    }
}
      

関数の状態変更可能性 (view, pure, payable) に関するルール違反がある場合に発生します。

原因の例:

  • view 関数内で状態変数を変更しようとする
  • pure 関数内で状態変数の読み取りや変更をしようとする
  • payable 修飾子がない関数でEtherを受け取ろうとする
  • payableなアドレスに transfersend をしようとする

コード例 (エラー):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract StateMutabilityErrorExample {
    uint public counter = 0;

    // view関数は状態を読み取るだけ。変更はできない。
    function incrementAndView() public view {
        counter++; // StateMutabilityError! view関数内で状態変更
    }

    // payableでない関数はEtherを受け取れない
    function receiveEther() public {
        // 何らかの処理
    }

    function sendEther(address _to) public payable {
        // payable(address) にキャストする必要がある
        // _to.transfer(msg.value); // Solidity 0.8.0以降では address payable へのキャストが必要
    }
}
      

解決策:

関数の内容に合わせて、状態変更可能性修飾子を正しく設定します。view 関数では状態を変更せず、pure 関数では状態の読み書きを行わないようにします。Etherを受け取る関数には payable を付与します。Etherを送金する際は、送金先アドレスを payable にキャストします。


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract StateMutabilityErrorExample {
    uint public counter = 0;

    // 状態を変更するので view を外す
    function incrementCounter() public {
        counter++; // OK
    }

    // Etherを受け取る可能性のある関数には payable を付ける
    function receiveEther() public payable {
        // 何らかの処理
    }

    function sendEther(address payable _to) public payable { // 引数も payable に
        // payable なアドレスにはそのまま送金できる
        _to.transfer(msg.value); // OK
    }
}
      

2. 実行時エラー (Revert)

コントラクトの関数実行中に発生するエラーです。トランザクションは中断され、それまでに行われた状態変更は全てロールバック(巻き戻し)されます。これにより、コントラクトの状態が意図しない形で変更されるのを防ぎます。

これらはSolidityで意図的にエラーを発生させ、トランザクションをリバートさせるための主要な方法です。

  • require(条件, “エラーメッセージ”): 主に関数の入力値や外部コントラクトの状態、関数の実行条件などを検証するために使用されます。条件が false の場合にリバートします。未使用のガスは返却されます。
  • assert(条件): 主に内部エラーや不変条件(本来起こり得ないはずの条件)をチェックするために使用されます。条件が false の場合にリバートします。Solidity 0.8.0未満では全てのガスを消費しましたが、0.8.0以降は未使用ガスを返却し、Panic(uint256) エラー (エラーコード 0x01) を発生させます。
  • revert(“エラーメッセージ”) / revert CustomError(): requireassert の条件分岐なしに、直接リバートを発生させます。カスタムエラー (Solidity 0.8.4以降) を使うと、よりガス効率が良く、パラメータを渡すことも可能です。未使用のガスは返却されます。

原因:

  • require() の条件式が false と評価された。
  • assert() の条件式が false と評価された(これはコードのバグを示唆することが多い)。
  • revert() 文が実行された。

コード例 (エラー):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

error NotOwnerError(address caller);
error InsufficientBalance(uint requested, uint available);

contract RevertExamples {
    address public owner;
    mapping(address => uint) public balances;

    constructor() {
        owner = msg.sender;
    }

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

    function withdraw(uint _amount) public {
        // require の例: 残高不足チェック
        require(balances[msg.sender] >= _amount, "Insufficient balance for withdrawal.");

        // assert の例: 本来起こらないはずのチェック (Solidity 0.8.0以降はオーバーフローしないが例として)
        // uint newBalance = balances[msg.sender] - _amount;
        // assert(newBalance <= balances[msg.sender]); // 引き算でアンダーフローしないかのチェック

        balances[msg.sender] -= _amount;
        payable(msg.sender).transfer(_amount);
    }

    function changeOwner(address _newOwner) public {
        // revert の例: カスタムエラー
        if (msg.sender != owner) {
            revert NotOwnerError(msg.sender);
        }
        owner = _newOwner;
    }

    function checkBalanceAndRevert(uint _checkAmount) public view {
        // revert の例: 文字列メッセージ
        if (balances[msg.sender] < _checkAmount) {
            revert("Your balance is lower than the check amount.");
        }
    }
}
      

解決策:

エラーメッセージやカスタムエラーの情報(パラメータなど)を確認し、リバートが発生した原因を特定します。require であれば入力値や前提条件を見直し、assert であればコードのロジックバグを修正します。トランザクション実行前に、フロントエンドなどで条件を満たしているか確認するのも有効です。

💡 カスタムエラー (Custom Errors)

Solidity 0.8.4 から導入されたカスタムエラーは、error NotOwnerError(address caller); のように定義し、revert NotOwnerError(msg.sender); のように使用します。文字列メッセージ (Error(string)) よりもガス効率が良く、エラーの種類を明確に区別でき、パラメータを渡せるためデバッグにも役立ちます。積極的に利用しましょう。

トランザクションを実行するために必要なガス量が、トランザクションに設定されたガス上限 (Gas Limit) を超えた場合に発生します。全ての状態変更はリバートされますが、消費されたガスは返ってきません。

原因の例:

  • 非常に大きな配列やマッピングに対するループ処理
  • 複雑な計算や多数のストレージ書き込み
  • 無限ループ (コードのバグ)
  • 外部コントラクト呼び出しが想定外に多くのガスを消費する
  • トランザクション発行時に設定したガスリミットが低すぎる

コード例 (エラーを引き起こしやすいパターン):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract GasExample {
    uint[] public numbers;
    address[] public users;
    mapping(address => uint) public rewards;

    // 大量の要素を追加する可能性がある
    function addNumber(uint _num) public {
        numbers.push(_num);
    }

    // 配列の全要素をループ処理 (要素数が多いとガス超過の可能性)
    function sumAllNumbers() public view returns (uint sum) {
        for (uint i = 0; i < numbers.length; i++) {
            sum += numbers[i];
        }
        return sum;
    }

     // 多数のユーザーに報酬を配布 (ユーザー数が多いとガス超過の可能性)
    function distributeRewards(uint totalReward) public {
        require(users.length > 0, "No users to distribute rewards to.");
        uint rewardPerUser = totalReward / users.length;
        for (uint i = 0; i < users.length; i++) {
            rewards[users[i]] += rewardPerUser; // ストレージ書き込みは高コスト
        }
    }
}
      

解決策:

  • コードの最適化:
    • ループ処理の回数を制限する(一度に処理する要素数を制限するなど)。
    • ストレージへのアクセス(特に書き込み)を最小限にする。可能な限り memory 変数を使用する。
    • よりガス効率の良いアルゴリズムやデータ構造を使用する。
    • 不要な計算やコードを削除する。
    • ガス効率の良いライブラリを使用する (例: OpenZeppelinのライブラリ)。
    • イベント (event) を使用して、オフチェーンで処理できる情報をログとして記録する。
  • ガスリミットの引き上げ: トランザクションを発行する際に、十分なガスリミットを設定する。ただし、これは根本的な解決策ではなく、コードに問題がある場合は修正が必要です。開発ツール (Hardhat, Truffleなど) は通常、ガス量を自動で見積もりますが、複雑なケースでは手動での調整が必要になることがあります。
  • 処理の分割: 一度のトランザクションで大量の処理を行わず、複数回に分割する設計を検討する (例: バッチ処理)。

⚠️ ガスリミットの見積もりエラー

開発ツールやウォレットが "cannot estimate gas; transaction may fail or may require manual gas limit" のようなエラーを出すことがあります。これは、トランザクションが実行中にリバートする可能性が高い(例: require が失敗する、計算量が多すぎるなど)とツールが判断した場合に発生します。コード内のリバート条件やガス消費量を確認する必要があります。コントラクトサイズが上限に近づいている場合にも発生することがあります。

重要: この問題は Solidity 0.8.0 以降ではデフォルトでチェックされ、発生すると Panic(uint256) エラー (エラーコード 0x11) としてリバートされるため、基本的には発生しません。ただし、unchecked ブロック内や、古いバージョンのSolidity (0.8.0未満) で書かれたコントラクトでは依然として注意が必要です。

算術演算(加算、減算、乗算など)の結果が、その変数の型で表現できる数値の範囲を超えてしまう場合に発生します。

  • Overflow (オーバーフロー): 最大値を超えてしまい、最小値側(通常は0)に戻ってしまう現象。
  • Underflow (アンダーフロー): 最小値(通常は0)未満になってしまい、最大値側に戻ってしまう現象。

原因 (Solidity 0.8.0未満、または unchecked ブロック内):

  • uint 型の最大値に1を加算するなど、型の最大値を超える演算。
  • uint 型の0から1を減算するなど、型の最小値を下回る演算。

事例:

過去には、トークンコントラクトなどでこの脆弱性を悪用し、残高を不正に操作する攻撃が発生しました。例えば、非常に大きな数値を送金しようとしてオーバーフローさせ、結果的に少額またはゼロの送金に見せかけたり、あるいは残高を意図せず増加させたりするケースがありました。

コード例 (Solidity 0.7.x でエラーが発生する可能性のあるコード):


// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6; // 0.8.0未満のバージョン

contract OverflowExample {
    uint8 public balance = 255; // uint8の最大値

    function addOne() public {
        // 255 + 1 は 256 になるが、uint8 では表現できず 0 になってしまう (オーバーフロー)
        balance = balance + 1; // Solidity 0.7.x では balance が 0 になる
    }

    uint8 public counter = 0;

    function subtractOne() public {
        // 0 - 1 は -1 になるが、uint では表現できず 255 になってしまう (アンダーフロー)
        counter = counter - 1; // Solidity 0.7.x では counter が 255 になる
    }
}
      

解決策 (Solidity 0.8.0未満の場合):

  • SafeMathライブラリの使用: OpenZeppelinなどが提供する SafeMath ライブラリを使用します。このライブラリは、演算前にオーバーフロー/アンダーフローが発生しないかチェックし、発生する場合はリバートさせます。
  • Solidity 0.8.0以降へのアップグレード: 最も推奨される方法です。0.8.0以降では、コンパイラが自動的にチェックコードを挿入します。
  • 手動でのチェック: require などを使って、演算結果が範囲内に収まるかを自分でチェックします(推奨されません)。

SafeMathを使った修正例 (Solidity 0.7.x):


// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;

// OpenZeppelin の SafeMath をインポート (npm install @openzeppelin/contracts などで導入)
import "@openzeppelin/contracts/utils/math/SafeMath.sol";

contract SafeMathExample {
    using SafeMath for uint8; // uint8型に対してSafeMathを適用

    uint8 public balance = 255;
    uint8 public counter = 0;

    function addOne() public {
        // balance.add(1) はオーバーフローする場合リバートする
        balance = balance.add(1);
    }

    function subtractOne() public {
        // counter.sub(1) はアンダーフローする場合リバートする
        counter = counter.sub(1);
    }
}
      

Solidity 0.8.0以降の場合 (unchecked ブロック):

Solidity 0.8.0以降で、意図的にチェックを無効化したい場合(ガス代削減などの理由で、安全性が確認できている場合のみ)は unchecked ブロックを使用します。


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract UncheckedExample {
    uint8 public balance = 255;

    function addOneUnchecked() public {
        // uncheckedブロック内では、オーバーフローチェックが行われず、ラップアラウンドする
        unchecked {
            balance = balance + 1; // balance は 0 になる
        }
    }
}
       

⚠️ unchecked ブロックの利用は慎重に

unchecked ブロックはガス代を節約できますが、オーバーフロー/アンダーフローのリスクを自ら負うことになります。使用する際は、その演算が絶対に安全であることを十分に検証してください。

配列や bytes 型のデータにアクセスする際に、存在しないインデックス(添え字)を指定した場合に発生します。通常、Panic(uint256) エラー (エラーコード 0x32) としてリバートされます。

原因:

  • 配列の長さ以上のインデックスを指定して要素にアクセスしようとした。
  • 負のインデックスを指定しようとした(uint 型なので通常は発生しにくいが、型変換などで意図せず発生する可能性)。
  • 空の配列の要素にアクセスしようとした。

コード例 (エラー):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract IndexOutOfBoundsExample {
    uint[] public myArray = [10, 20, 30];

    function accessElement(uint index) public view returns (uint) {
        // indexが3以上の場合、範囲外アクセスとなりリバートする
        return myArray[index]; // Panic(0x32) エラー
    }

    function accessEmptyArray() public view returns(uint) {
        uint[] memory emptyArray;
        // 空の配列の要素にアクセスしようとするとリバートする
        return emptyArray[0]; // Panic(0x32) エラー
    }
}
      

解決策:

配列にアクセスする前に、指定するインデックスが有効な範囲内 (0 <= index < array.length) にあることを require などでチェックします。


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract IndexOutOfBoundsExampleFixed {
    uint[] public myArray = [10, 20, 30];

    function accessElement(uint index) public view returns (uint) {
        // アクセス前にインデックスの範囲をチェック
        require(index < myArray.length, "Index out of bounds");
        return myArray[index];
    }

    function accessEmptyArray() public view returns(uint) {
        uint[] memory emptyArray;
        // アクセス前に配列が空でないかチェック (任意)
        require(emptyArray.length > 0, "Array is empty");
        // require(0 < emptyArray.length, "Index out of bounds"); // より明示的なチェック
        return emptyArray[0];
    }
}
      

EVM (Ethereum Virtual Machine) が解釈できない、または不正な命令を実行しようとした場合に発生する低レベルなエラーです。Solidity 0.8.0以降では、assert の失敗、ゼロ除算、範囲外の配列アクセスなど、特定の内部エラーが Panic(uint256) というエラー型とエラーコードでリバートされるようになりました。これらは以前 invalid opcode を引き起こしていたケースの一部を含みます。

Panic(uint256) エラーコードの例:

エラーコード (16進数) エラーコード (10進数) 状況
0x01 1 assert(false) が呼び出された
0x11 17 算術演算でオーバーフローまたはアンダーフローが発生した (Solidity 0.8.0+ デフォルト)
0x12 18 ゼロ除算またはゼロ剰余が発生した (例: 5 / 023 % 0)
0x21 33 大きすぎる値や負の値を enum 型に変換しようとした
0x22 34 不正にエンコードされたストレージバイト配列にアクセスしようとした
0x31 49 空の配列に対して .pop() を呼び出した
0x32 50 配列や bytesN 型に範囲外のインデックスでアクセスした、またはオフセットが範囲外だった
0x41 65 確保しすぎたメモリを割り当てようとした、または大きすぎる配列を作成しようとした
0x51 81 ゼロで初期化された内部関数型の変数を呼び出した

原因 (Invalid Opcode / Panic):

  • 上記のエラーコードに該当する状況 (assert 失敗、ゼロ除算、範囲外アクセスなど)。
  • 初期化されていない内部関数ポインタの呼び出し (Panic 0x51)。
  • 非常に稀なケースとして、Solidityコンパイラのバグ。
  • インラインアセンブリ (assembly { ... }) 内での不正な操作。

解決策:

Panic エラーコードから原因を特定します。assert が失敗している場合は、コードのロジックにバグがないか徹底的に見直します。ゼロ除算や範囲外アクセスを防ぐために、事前に入力値や状態を require でチェックします。インラインアセンブリを使用している場合は、その部分のコードを慎重にレビューします。もしコンパイラのバグが疑われる場合は、SolidityのGitHubリポジトリで इश्यू (issue) を検索または報告することを検討してください。

🚨 Assertの失敗は深刻なバグの兆候

assert は「起こるはずがない」条件をチェックするために使います。もし assert が失敗 (Panic 0x01) する場合、それはコントラクトのロジックに深刻なバグが存在する可能性が高いことを示しています。直ちに原因を特定し、修正する必要があります。

3. セキュリティ関連のエラーと脆弱性

これらは厳密にはコンパイルエラーや実行時エラーとは異なりますが、コードの書き方によって引き起こされ、悪意のある攻撃者によって利用される可能性のある重大な問題です。

スマートコントラクトにおける最も有名で危険な脆弱性の一つです。関数が外部コントラクトを呼び出した後、状態変数を更新する前に、その外部コントラクトが元の関数を再度呼び出す(リエントラントコール)ことで、意図しない動作を引き起こします。

事例: The DAO 事件 (2016年)

スマートコントラクトの歴史において最も有名なハッキング事件の一つです。The DAOと呼ばれる自律分散型組織のコントラクトにあったリエントランシー脆弱性が悪用され、当時の価値で約50億円相当のEtherが不正に引き出されました。この事件はEthereumコミュニティに大きな衝撃を与え、結果的にEthereumとEthereum Classicへのハードフォーク(分裂)につながりました。

原因:

  • 外部コントラクトへのEther送金 (call{value: ...}()) や外部関数呼び出しを行う。
  • 外部呼び出しの「後」に、残高などの内部状態を更新する処理がある。
  • 呼び出された外部コントラクトが、フォールバック関数 (receive() または fallback()) や通常の関数内で、元のコントラクトの同じ関数(または状態を変更する別の関数)を再度呼び出す。

脆弱なコード例:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

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

    function withdraw() public {
        uint balance = balances[msg.sender];
        require(balance > 0, "Insufficient balance");

        // 問題点: Etherを送金してから残高をゼロにしている
        (bool success, ) = msg.sender.call{value: balance}(""); // 外部コール
        require(success, "Failed to send Ether");

        // リエントランシー攻撃: 上記の call が実行され、攻撃者コントラクトが
        // 再度 withdraw() を呼び出すと、ここに来る前に残高がまだゼロになっていないため、
        // 何度もEtherを引き出せてしまう。
        balances[msg.sender] = 0; // 状態更新
    }

    // receive() external payable {} // Etherを受け取るためのフォールバック関数 (攻撃用コントラクト側で実装される)
}
      

解決策:

  • Checks-Effects-Interactions パターン:
    1. Checks (チェック): 関数の実行条件(残高、権限など)を require で最初に検証する。
    2. Effects (効果): コントラクトの内部状態(残高など)を先に更新する。
    3. Interactions (相互作用): 最後に外部コントラクトの呼び出しやEther送金を行う。
  • リエントランシーガード (Reentrancy Guard): OpenZeppelinの ReentrancyGuard のような修飾子を利用する。これは、関数が実行中の場合に再度同じ関数(またはガードが付いた他の関数)が呼び出されるのを防ぐロックメカニズムを提供します。
  • transfer() / send() の使用(非推奨): 以前は call の代わりに transfer()send() を使うことが推奨されていました。これらはガスリミット (2300 gas) が低いため、受け取り側コントラクトが複雑な処理(リエントラントコールなど)を行うのを防ぎます。しかし、将来のガス代変更に対応できない可能性があるため、現在はChecks-Effects-InteractionsパターンやReentrancyGuardの使用が推奨されています。

修正コード例 (Checks-Effects-Interactions):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

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

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

        // Effects (効果): 状態を先に更新!
        balances[msg.sender] = 0;

        // Interactions (相互作用): 最後に外部コール
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Failed to send Ether");
    }
}
      

修正コード例 (Reentrancy Guard):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

// ReentrancyGuard を継承し、nonReentrant 修飾子を使う
contract GuardedEtherStore is ReentrancyGuard {
    mapping(address => uint) public balances;

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

    // nonReentrant 修飾子を追加
    function withdraw() public nonReentrant {
        uint balance = balances[msg.sender];
        require(balance > 0, "Insufficient balance");

        // 先に状態を更新する方がより安全 (推奨)
        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Failed to send Ether");

        // Effects を Interactions の後に置いても nonReentrant で保護されるが、
        // Checks-Effects-Interactions パターンを併用するのがベストプラクティス
        // balances[msg.sender] = 0; // もしここに置いても nonReentrant があれば防げる
    }
}
       

スマートコントラクトのロジックが block.timestamp (またはエイリアスの now) に依存している場合に発生する可能性のある問題です。ブロックのタイムスタンプは、そのブロックを生成するマイナー(またはバリデーター)によってある程度操作可能です。

原因:

  • 乱数生成のシードとして block.timestamp を使用する(予測可能)。
  • ゲームの勝敗や重要な状態遷移の条件として、block.timestamp の特定の値や範囲に厳密に依存する。

問題点:

マイナーは、自身の利益になるように、ある程度の範囲内でブロックのタイムスタンプを操作できます。例えば、タイムスタンプに依存するゲームで、自分に有利な結果になるようなタイムスタンプを持つブロックを生成しようとするかもしれません。これにより、コントラクトの公平性や予測不可能性が損なわれる可能性があります。

コード例 (問題のある可能性):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract TimestampGame {
    address public winner;
    uint public winningTimestamp;

    function play(uint guess) public {
        // タイムスタンプの下1桁が予想と一致したら勝ち (予測可能!)
        if ((block.timestamp % 10) == guess) {
            winner = msg.sender;
            winningTimestamp = block.timestamp;
            // 賞金を送るなどの処理...
        }
    }
}
      

解決策:

  • block.timestamp を、資金のロック期間終了など、厳密な精度が要求されない長期間の条件判定に使用するのは比較的安全です。
  • 乱数生成やゲームロジックの重要な要素として block.timestamp に依存しないようにします。
  • 真に予測不可能な乱数が必要な場合は、Chainlink VRF (Verifiable Random Function) などのオラクルサービスを利用することを検討します。
  • block.number (ブロック番号) もマイナーによる操作の影響を受けますが、タイムスタンプよりは操作の自由度が低いと考えられます。ただし、これも重要なロジックの基盤としては推奨されません。
  • Commit-Reveal スキームなど、他の暗号学的な手法を利用します。

攻撃者が意図的にガス消費量の多い操作を実行させることで、特定の関数やコントラクト全体をガス切れ (Out of Gas) 状態にし、他のユーザーが利用できないようにする攻撃です。

原因:

  • 外部からの入力によってループ回数が決まる処理(例:複数の受取人に一度に送金する関数で、受取人リストを外部から制御できる場合)。
  • 配列の要素を削除する際に、要素を後方に詰める操作(ガス消費が大きい)。
  • コントラクトが大きくなりすぎ、関数呼び出しに必要な基本ガス量が高くなる。

コード例 (DoS脆弱性の可能性):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract DistributeFunds {
    address public owner;
    address[] public recipients;
    mapping(address => uint) public payouts;

    constructor() {
        owner = msg.sender;
    }

    function addRecipient(address _recipient) public {
        require(msg.sender == owner, "Only owner can add recipients");
        recipients.push(_recipient);
    }

    // 問題点: recipients 配列が非常に大きくなると、ループでガスを大量消費し、
    // ガスリミットに達して実行できなくなる可能性がある。
    // 攻撃者が大量の (あるいはガス消費の大きいコントラクト) アドレスを登録させる可能性がある。
    function distribute(uint totalAmount) public payable {
        require(msg.sender == owner, "Only owner can distribute");
        require(recipients.length > 0, "No recipients");
        require(msg.value >= totalAmount, "Insufficient Ether sent");

        uint amountPerRecipient = totalAmount / recipients.length;
        for (uint i = 0; i < recipients.length; i++) {
            // recipients[i] がガスを大量消費するコントラクトの場合、DoS になる可能性も
            (bool success, ) = recipients[i].call{value: amountPerRecipient}("");
            if (success) {
                 payouts[recipients[i]] += amountPerRecipient;
            }
            // エラー処理をしない場合、一部に送金できないままループが進む可能性もある
        }
    }
}
      

解決策:

  • ループ回数の制限: 一度のトランザクションで処理するループの回数や配列の要素数に上限を設けます。処理しきれなかった分は、別のトランザクションで実行するように促します (Pull Payment パターンなど)。
  • Pull over Push パターン (引き出し型支払い): 受取人に資金を送りつける (Push) のではなく、受取人が自ら資金を引き出しに来る (Pull) 設計にします。これにより、送金処理のガス代は受取人が負担し、一度のトランザクションでのループも不要になります。
  • 配列要素の削除方法の工夫: 配列から要素を削除する際、最後の要素を削除対象の位置に移動させてから配列の長さを縮める方法(Swap and Pop)は、要素を詰めるよりもガス効率が良い場合があります。ただし、配列の順序が保持されないことに注意が必要です。
  • ガスリミットの意識: 関数のガス消費量を常に意識し、過度に消費するような設計を避けます。

Solidityの低レベル関数 call, delegatecall, staticcall や、send は、呼び出しが失敗した場合でもトランザクション全体をリバートさせず、単に false を返します。この戻り値をチェックしない場合、外部呼び出しが失敗したにも関わらず、後続の処理が続行してしまい、コントラクトの状態が予期せず不整合になる可能性があります。

原因:

  • someAddress.call(...) の戻り値 (成功したかどうかの bool 値) をチェックしていない。
  • someAddress.send(...) の戻り値をチェックしていない(send は現在非推奨)。

脆弱なコード例:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
}

contract UncheckedCallVulnerability {
    IERC20 public token;
    mapping(address => uint) public withdrawableAmount;

    constructor(address _tokenAddress) {
        token = IERC20(_tokenAddress);
    }

    function recordWithdrawal(address user, uint amount) external {
        // 仮に何らかの条件を満たしたら引き出し可能額を記録
        withdrawableAmount[user] += amount;
    }

    function withdrawTokens(uint amount) public {
        uint toWithdraw = withdrawableAmount[msg.sender];
        require(toWithdraw >= amount, "Insufficient recorded amount");

        withdrawableAmount[msg.sender] -= amount; // 先に状態を更新 (Effects)

        // 問題点: token.transfer の戻り値をチェックしていない!
        // もし transfer が失敗 (falseを返す) しても、処理は続行され、
        // withdrawableAmount だけが減ってしまう。トークンは送金されない。
        token.transfer(msg.sender, amount); // Interactions
    }
}
       

解決策:

低レベルの呼び出し (call, delegatecall, staticcall) や、false を返す可能性のある外部関数呼び出し (一部のERC20の transfer など) の戻り値 (通常は bool 型の成功フラグ) を必ず require でチェックします。


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
}

contract CheckedCallFixed {
    IERC20 public token;
    mapping(address => uint) public withdrawableAmount;

     constructor(address _tokenAddress) {
        token = IERC20(_tokenAddress);
    }

    function recordWithdrawal(address user, uint amount) external {
        withdrawableAmount[user] += amount;
    }

    function withdrawTokens(uint amount) public {
        uint toWithdraw = withdrawableAmount[msg.sender];
        require(toWithdraw >= amount, "Insufficient recorded amount");

        withdrawableAmount[msg.sender] -= amount;

        // 修正点: 戻り値を require でチェックする
        bool success = token.transfer(msg.sender, amount);
        require(success, "Token transfer failed");
    }
}
       

💡 transfer()call() の違い

Etherの送金において、address payable のメンバ関数 transfer() は、送金が失敗した場合に自動的にリバートします。一方、call{value: ...}("") は失敗しても false を返すだけなので、戻り値のチェックが必須です。現在はより柔軟な call の使用が推奨されていますが、その分注意が必要です。

関数や状態変更操作に対して、適切なアクセス制御(誰がその操作を実行できるか)が実装されていない場合に発生する脆弱性です。

原因:

  • 重要な関数 (例: オーナー変更、資金引き出し、コントラクトのアップグレード) に public が指定されており、誰でも呼び出せる状態になっている。
  • アクセス制御のための修飾子 (例: onlyOwner) が適用されていない、または実装に誤りがある。
  • tx.origin を使った認証(非推奨)。

脆弱なコード例:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

    constructor() {
        owner = msg.sender;
    }

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

    // 問題点: public になっており、誰でも他人の残高をゼロにできてしまう!
    function resetBalance(address _user) public {
        balances[_user] = 0;
    }

    // 問題点: public になっており、誰でもオーナーを変更できてしまう!
    function changeOwner(address _newOwner) public {
        owner = _newOwner;
    }
}
        

脆弱なコード例 (tx.origin):

tx.origin はトランザクションを最初に開始したEOA (Externally Owned Account) のアドレスを返します。これを使って認証を行うと、中間コントラクトを介したフィッシング攻撃に対して脆弱になります。攻撃者はユーザーを騙して、tx.origin をチェックする脆弱なコントラクトを呼び出す中間コントラクトを実行させることができます。この場合、tx.origin はユーザーのアドレスになりますが、msg.sender は中間コントラクトのアドレスになります。


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// 脆弱なコントラクト (tx.origin を使用)
contract VulnerableWallet {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    // 問題点: tx.origin で認証しているため、フィッシング攻撃に弱い
    function transferOwnership(address _newOwner) public {
        require(tx.origin == owner, "Not owner"); // tx.origin を使っている!
        owner = _newOwner;
    }
}

// 攻撃用の中間コントラクト (例)
contract AttackContract {
    VulnerableWallet vulnerableWallet;
    address attacker; // 攻撃者のアドレス

    constructor(address _vulnerableWalletAddress, address _attacker) {
        vulnerableWallet = VulnerableWallet(_vulnerableWalletAddress);
        attacker = _attacker;
    }

    // ユーザーがこの関数を呼び出すと...
    function attack() public {
        // vulnerableWallet の transferOwnership が呼び出される
        // この時、msg.sender はこの AttackContract のアドレスだが、
        // tx.origin はこの attack() 関数を呼び出したユーザーのアドレスになる
        // そのため、vulnerableWallet の require(tx.origin == owner) を通過してしまう可能性がある
        vulnerableWallet.transferOwnership(attacker);
    }
}
        

解決策:

  • 適切な可視性の設定: 関数や変数の可視性 (public, private, internal, external) を最小権限の原則に従って設定します。外部に公開する必要のないものは privateinternal にします。
  • アクセス制御修飾子の実装: OpenZeppelin の Ownable コントラクトなどを継承し、onlyOwner 修飾子を利用するなどして、特定の権限を持つアドレスのみが実行できるようにします。ロールベースのアクセス制御 (Role-Based Access Control, RBAC) が必要な場合は、AccessControl コントラクトを利用します。
  • msg.sender による認証: 認証には tx.origin ではなく、常に直接の呼び出し元である msg.sender を使用します。

修正コード例 (Ownable):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/access/Ownable.sol"; // Ownableをインポート

contract SecureBank is Ownable { // Ownableを継承
    mapping(address => uint) public balances;

    // constructor でオーナーを設定 (Ownableがやってくれる)
    constructor(address initialOwner) Ownable(initialOwner) {}

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

    // onlyOwner 修飾子を追加し、オーナーのみ実行可能にする
    function resetBalance(address _user) public onlyOwner {
        balances[_user] = 0;
    }

    // changeOwner は Ownable コントラクトの transferOwnership を使う
    // function changeOwner(address _newOwner) public onlyOwner { ... } // 自前実装も可能だが推奨しない
}
        

4. デバッグとエラー解決のヒント 🛠️

エラーに遭遇した場合、効率的に原因を特定し解決するためのアプローチを知っておくことが重要です。

コンパイラや実行環境が出力するエラーメッセージには、問題解決の手がかりが含まれています。

  • エラーの種類: TypeError, ParserError, Revert, Panic(0x...) など、エラーの種類を確認します。
  • 場所: エラーが発生したコントラクト名、関数名、行番号が示されていることが多いです。
  • メッセージ/理由: requirerevert のメッセージ、カスタムエラー名とパラメータ、Panic のエラーコードなどが原因特定に役立ちます。
  • データロケーション: TypeError では、memory, storage, calldata の指定に関する問題が示唆されることがあります。

エラーメッセージを正確に理解し、関連するコード箇所を特定することがデバッグの第一歩です。

最新の開発フレームワークは、デバッグを強力にサポートする機能を提供しています。

  • Remix IDE: ブラウザベースのIDEで、手軽にコードを書いてコンパイル、デプロイ、テスト、デバッグができます。ステップ実行や状態変数の確認が可能です。
  • Hardhat: 高機能な開発環境。console.log をSolidityコード内に記述して、ローカルテスト実行時に値を出力できます。詳細なエラーレポートやスタックトレースも表示されます。
  • Truffle Suite: Hardhatと並ぶ人気の開発フレームワーク。truffle debug コマンドでトランザクションをステップ実行し、デバッグできます。Ganacheなどのローカルブロックチェーンと連携して使用します。
  • Foundry: Rust製の比較的新しいフレームワーク。Solidityでテストコードを書けるのが特徴で、高速なテストとFuzzing(ファジング)テスト機能が強力です。デバッグ機能も充実しています。

これらのツールのデバッガやログ機能を活用することで、エラーの原因箇所やその時点での変数の状態を効率的に特定できます。

Hardhat での console.log の例:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// Hardhatのconsole.logをインポート
import "hardhat/console.sol";

contract DebugExample {
    uint public counter;

    function increment(uint _value) public {
        console.log("Increment function called with value:", _value);
        uint oldValue = counter;
        console.log("Old counter value:", oldValue);
        counter += _value;
        console.log("New counter value:", counter);
        // require(counter > 10, "Counter must be greater than 10"); // デバッグ用にコメントアウト/イン
    }
}
       

上記コードをHardhat環境でテスト実行すると、console.log の内容がターミナルに出力されます。

堅牢なテストスイートは、エラーを早期に発見し、リグレッション(修正による新たなバグの発生)を防ぐために不可欠です。

  • 単体テスト (Unit Tests): 個々の関数が期待通りに動作するかを検証します。正常系だけでなく、異常系(require が失敗するケース、不正な入力値など)のテストも重要です。
  • 統合テスト (Integration Tests): 複数のコントラクトが連携して正しく動作するかを検証します。
  • カバレッジ測定: テストがコードのどの程度をカバーしているかを測定し、テストが不十分な箇所を特定します (例: solidity-coverage)。
  • ファジング (Fuzzing): ランダムな入力データを大量に生成してテストを行い、予期しないエッジケースや脆弱性を発見します (Foundryなどでサポート)。

テスト駆動開発 (TDD) のアプローチを取り入れることも有効です。

コードを実行せずに、既知の脆弱性パターンやコーディング規約違反を検出するツールです。

  • Slither: Python製の高機能な静的解析ツール。多くの脆弱性パターンやコードの問題点を検出できます。
  • Solhint: スタイルガイドやセキュリティに関するベストプラクティスをチェックするリンター。
  • その他、Mythril, Securify など。

CI/CDパイプラインにこれらのツールを組み込むことで、開発の早い段階で問題を検出できます。

自分だけでは解決できない問題に直面した場合、活発な開発者コミュニティの力を借りることができます。

  • Stack Overflow / Stack Exchange (Ethereum Stack Exchange): 具体的なエラーメッセージやコードスニペットと共に質問を投稿します。
  • Discord / Telegram: Solidityや各開発フレームワーク、特定のプロジェクトのコミュニティチャンネルで質問します。
  • GitHub: Solidityコンパイラやライブラリ自体のバグが疑われる場合は、 संबंधितリポジトリの इश्यू (Issues) を確認・報告します。

質問する際は、問題点を明確にし、再現可能な最小限のコード例を提供することがマナーです。

まとめ

Solidity開発におけるエラーは避けられないものですが、その種類と原因、そして対処法を理解しておくことで、より迅速かつ効果的に問題を解決できます。特に、コンパイル時エラーは早期に修正し、実行時エラーやセキュリティ脆弱性は、テストや静的解析、そしてセキュアなコーディングパターン(Checks-Effects-Interactionsなど)を適用することで未然に防ぐことが重要です。

エラーは単なる障害ではなく、コードの改善点や学習の機会を示唆してくれるものでもあります。エラーメッセージを注意深く読み解き、開発ツールを最大限に活用し、コミュニティと協力しながら、安全で信頼性の高いスマートコントラクトを構築していきましょう!💪

Happy Coding! 🎉