イーサリアム - ETH

スマートコントラクトのイベントとエラーハンドリング:require・revert・customErrorの使い方

スマートコントラクト開発において、イベントとエラーハンドリングは実用的なコードを書く上で欠かせない要素です。イベントはブロックチェーン上のログとして残り、フロントエンドからのリッスンやオフチェーンのデータ分析に活用されます。一方、エラーハンドリングは不正な操作を防ぎ、ユーザーに適切なフィードバックを返すための仕組みです。

Solidity 0.8.0以降、カスタムエラー(custom error)が導入され、エラーメッセージのガスコストを大幅に削減できるようになりました。DeFiプロトコルやNFTコントラクトではほぼ必須の実装パターンとなっています。

本記事では、イベントの定義と発行の方法、requirerevertassert の使い分け、そしてcustom errorの実装方法について、実践的なコード例を交えて詳しく解説します。

イベント(event)の基礎

イベントの定義と発行

Solidityのイベントは、トランザクション実行時にブロックチェーンのログとして記録される仕組みです。ガスを消費しますが、ステート変数への書き込みより大幅に安く、オフチェーンからのデータ取得に適しています。

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

contract TokenTransfer {
    event Transfer(
        address indexed from,
        address indexed to,
        uint256 amount
    );

    event Approval(
        address indexed owner,
        address indexed spender,
        uint256 amount
    );

    mapping(address => uint256) public balances;

    function transfer(address _to, uint256 _amount) public {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        balances[msg.sender] -= _amount;
        balances[_to] += _amount;
        emit Transfer(msg.sender, _to, _amount);
    }
}

indexed キーワードを付けたパラメータはトピックとして記録され、フィルタリング検索が可能になります。1つのイベントに最大3つのindexedパラメータを設定できます。

イベントのログ構造とガスコスト

EVMのログは以下の構造を持ちます。

  • topics[0]: イベントシグネチャのkeccak256ハッシュ(例: Transfer(address,address,uint256)
  • topics[1]以降: indexedパラメータ(最大3個)
  • data: non-indexedパラメータ(ABI エンコード)

ガスコストは、ログベース(375 gas)+ トピックごと(375 gas)+ データバイトごと(8 gas)で計算されます。ストレージ書き込み(SSTORE: 20,000〜100,000 gas)と比較すると非常に安価です。ただしログはコントラクト内から参照できないため、オフチェーン処理専用です。

require によるバリデーション

require の基本的な使い方

require は条件が偽の場合にトランザクションを中断(revert)し、残りのガスを返却するアサーション関数です。入力値の検証やアクセス制御に使います。

function withdraw(uint256 _amount) public {
    require(_amount > 0, "Amount must be positive");
    require(balances[msg.sender] >= _amount, "Insufficient balance");
    require(_amount <= address(this).balance, "Contract balance too low");

    balances[msg.sender] -= _amount;
    (bool success, ) = msg.sender.call{value: _amount}("");
    require(success, "Transfer failed");
}

第2引数のエラーメッセージは文字列としてABIエンコードされるため、長い文字列ほどデプロイコストとリバート時のガスコストが増加します。Solidity 0.8.4以降のcustom errorに移行することが推奨されています。

require と if-revert の比較

require(condition, "message") は実質的に以下と等価です。

if (!condition) {
    revert("message");
}

可読性の観点から require の方が簡潔ですが、複雑な条件分岐では if-revert パターンの方が意図が伝わりやすい場合もあります。どちらを使うかはチームのコーディング規約に従うとよいでしょう。

revert と assert の使い分け

revert の用途

revert は明示的にトランザクションを中断させる命令です。残りのガスを返却し、状態変更をすべてロールバックします。

function buyTicket(uint256 _ticketId) public payable {
    if (ticketsSold[_ticketId]) {
        revert("Ticket already sold");
    }
    if (msg.value < ticketPrice) {
        revert("Insufficient ETH sent");
    }
    ticketsSold[_ticketId] = true;
    ticketOwners[_ticketId] = msg.sender;
    emit TicketPurchased(msg.sender, _ticketId, msg.value);
}

実行パスが明確に失敗する箇所での使用に適しています。エラーメッセージなしで revert() とだけ書くことも可能で、ガスコストを抑えたい場合に有効です。

assert の適切な使い方

assert は不変条件(invariant)のチェックに使います。require と異なり、assert がfalseになる場合はコントラクトのバグを意味します。

function internalCalculation(uint256 a, uint256 b) internal pure returns (uint256) {
    uint256 result = a + b;
    assert(result >= a);  // オーバーフローがないことの確認(0.8以降は不要だが例として)
    return result;
}

重要な違いとして、assert が失敗すると残りのガスをすべて消費します(Solidity 0.8.0以降は変更されましたが、以前は全ガス消費でした)。現在は assert もガスを返却するようになりましたが、使い分けの意味論的な明確さのために残っています。本来ありえない状態のチェックに限定して使うのが原則です。

Custom Error(カスタムエラー)の実装

custom error の定義と使い方

Solidity 0.8.4で導入されたcustom errorは、エラーを構造体のように定義できる機能です。文字列メッセージより大幅にガスが安く、型安全なエラー情報を提供できます。

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

error InsufficientBalance(address account, uint256 balance, uint256 required);
error Unauthorized(address caller, address expected);
error InvalidAmount(uint256 amount);

contract Vault {
    address public owner;
    mapping(address => uint256) public deposits;

    constructor() {
        owner = msg.sender;
    }

    function deposit() public payable {
        if (msg.value == 0) revert InvalidAmount(msg.value);
        deposits[msg.sender] += msg.value;
    }

    function withdraw(uint256 _amount) public {
        if (deposits[msg.sender] < _amount) {
            revert InsufficientBalance(msg.sender, deposits[msg.sender], _amount);
        }
        deposits[msg.sender] -= _amount;
        (bool success, ) = msg.sender.call{value: _amount}("");
        if (!success) revert("Transfer failed");
    }

    function emergencyWithdraw() public {
        if (msg.sender != owner) revert Unauthorized(msg.sender, owner);
        payable(owner).transfer(address(this).balance);
    }
}

custom errorはコントラクト外にも定義でき、複数のコントラクトで共有できます。ABI定義にも含まれるため、ethers.jsなどのライブラリでエラーをデコードして詳細情報を取得できます。

custom error のガス効率比較

エラーメッセージのサイズによって差がありますが、一般的にcustom errorはrevert stringと比較してデプロイ時・実行時ともにガスが安くなります。

  • require(condition, "Short error message"): 文字列をABIエンコード(selector + offset + length + data)
  • revert CustomError(param1, param2): 4バイトのselector + ABI エンコードしたパラメータのみ

大規模なプロトコルではすべてのエラーをcustom errorに統一することで、デプロイコストを数十%削減できるケースもあります。

イベントとエラーの設計パターン

State Machineパターンでのエラー設計

コントラクトの状態管理にenumを使うState Machineパターンでは、状態ごとのエラーを明確に定義することでデバッグが容易になります。

enum AuctionState { Created, Active, Ended, Cancelled }

error AuctionNotActive(AuctionState currentState);
error BidTooLow(uint256 bid, uint256 minimum);
error AuctionAlreadyEnded();

contract Auction {
    AuctionState public state;
    uint256 public highestBid;
    address public highestBidder;

    event BidPlaced(address indexed bidder, uint256 amount);
    event AuctionEnded(address winner, uint256 finalBid);

    function placeBid() public payable {
        if (state != AuctionState.Active) revert AuctionNotActive(state);
        if (msg.value <= highestBid) revert BidTooLow(msg.value, highestBid + 1);

        address previousBidder = highestBidder;
        uint256 previousBid = highestBid;

        highestBid = msg.value;
        highestBidder = msg.sender;

        if (previousBidder != address(0)) {
            payable(previousBidder).transfer(previousBid);
        }
        emit BidPlaced(msg.sender, msg.value);
    }
}

フロントエンドからのイベントリッスン

ethers.jsを使ったイベントのリッスン例を示します。

// ethers.js v6
const contract = new ethers.Contract(address, abi, provider);

// 過去のイベントを取得
const filter = contract.filters.Transfer(null, myAddress);
const events = await contract.queryFilter(filter, fromBlock, toBlock);

// リアルタイムリッスン
contract.on("Transfer", (from, to, amount, event) => {
    console.log(`Transfer: ${from} -> ${to}: ${ethers.formatEther(amount)} ETH`);
});

indexedパラメータを使うとフィルタリングが効率的になります。アドレスでフィルタリングすることで、特定のウォレットに関するイベントのみを取得できます。

よくあるエラーパターンとデバッグ

リバート理由の特定方法

Hardhatでテストを実行する際、revertedWithrevertedWithCustomError マッチャーを使うとリバート理由を正確に検証できます。

// Hardhat + Chai Matchers
await expect(vault.connect(user).withdraw(1000))
    .to.be.revertedWithCustomError(vault, "InsufficientBalance")
    .withArgs(user.address, 500, 1000);

本番環境でのリバートはEtherscanのトランザクション詳細から確認できます。Tenderly等のデバッガを使うと、リバートが発生した正確な実行ステップを追跡できます。

イベントのテスト方法

await expect(vault.deposit({ value: ethers.parseEther("1") }))
    .to.emit(vault, "Deposited")
    .withArgs(user.address, ethers.parseEther("1"));

イベントのテストはコントラクトの動作確認において重要です。特にERC-20やERC-721などの標準インターフェースでは、必須イベントが正しく発行されているかをテストで確認することが品質保証の観点から必須です。

まとめ

Solidityのイベントとエラーハンドリングを適切に設計することで、コントラクトの信頼性とデバッグ容易性が大幅に向上します。custom errorはガス効率と型安全性の両面で優れており、新規開発ではrequireの文字列メッセージよりcustom errorを推奨します。

イベントはオフチェーンとの連携において欠かせない仕組みです。indexed パラメータを適切に設定し、フロントエンドや分析ツールが効率的にデータを取得できる設計を心がけましょう。

よくある質問

Q. require と custom error はどちらを使うべきですか?

新規開発ではcustom errorを推奨します。ガス効率が良く、型安全なエラー情報を提供できます。ただし、古いコントラクトとの互換性が必要な場合や、チームがrequireに慣れている場合はそのまま使い続けることも問題ありません。

Q. イベントはいくつまで定義できますか?

Solidity上に数の制限はありませんが、indexedパラメータは1イベントにつき最大3個です。コントラクトのABIに含まれるため、増えすぎるとABIサイズが大きくなります。

Q. コントラクト内からイベントログを読めますか?

いいえ、読めません。イベントログはEVM外(ノードのデータベース)に保存されるため、コントラクト内のコードからアクセスする手段はありません。コントラクト間で共有する必要があるデータはステート変数に保存する必要があります。

※本記事は情報提供を目的としており、投資を推奨するものではありません。暗号資産への投資は元本割れのリスクがあります。投資判断はご自身の責任で行ってください。

Bitcoin Analyze 編集部

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください