はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

今回は、トランザクションの送信とレシート取得を1回のJSON-RPC呼び出しで同期的に完結させる新しいメソッドeth_sendRawTransactionSyncを提案しているEIP7966についてまとめていきます!

以下にまとめられているものを解説しながらまとめていきます。

他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。

概要

EIP7966とは、eth_sendRawTransactionSyncという新しいJSON-RPCメソッドを定義する提案です。 従来のeth_sendRawTransactionではトランザクションを送信した後、レシート(実行結果)を取得するために別途ポーリングが必要でした。 この提案のメソッドを使えば、送信と同時にレシートが返ってくるまで待機するため、1回のRPC呼び出しで送信から確認まで完結します。

特にブロック生成が速いL2チェーンや高スループットなEVM互換チェーンでは、送信後すぐにレシートが利用可能なケースが多く、わざわざ別のリクエストでポーリングするのは無駄なレイテンシを生んでいました。 EIP7966はこのギャップを埋め、トランザクション確認のレイテンシを約50%削減しつつ、クライアント実装をシンプルにすることを目指しています。

さらに、nonceギャップ(送信しようとしているトランザクションのnonceが、次に期待されるnonceより大きい場合)でトランザクションが拒否された場合、エラーレスポンスに「期待されるnonce」を含めて返すため、開発者が別途アカウント状態を問い合わせる必要がなくなるというDX改善も含まれています。

動機

非同期トランザクション送信の課題

現在のEthereumクライアントは、署名済みトランザクションをeth_sendRawTransactionで非同期に送信しています。 このメソッドはトランザクションハッシュを即座に返しますが、トランザクションが実際にブロックに取り込まれたかどうかは別の問題です。 クライアントはeth_getTransactionReceiptを繰り返し呼び出して、レシートが利用可能になるまでポーリングしなければなりません。

この非同期アプローチは、Ethereumメインネットのようにブロック生成に12秒程度かかるチェーンではある程度合理的です。 しかし、ブロック生成が速くレイテンシの低いL2ソリューションや高頻度ブロックチェーンでは、レシートはトランザクションがブロックプロデューサーのmempoolに到達した直後に利用可能になることが多いです。 にもかかわらず追加のRPC呼び出しを強制されるのは、不必要なレイテンシとネットワークオーバーヘッドを生み出しています。

以下の図は、同期方式と非同期方式のトランザクション送信フローの違いを示しています。

Sync vs Async Transaction Sending

低レイテンシなブロックチェーンでは、トランザクションレシートはトランザクションがブロックプロデューサーのmempoolに到達した直後に利用可能になります。 そこに追加のRPC呼び出しを挟むのは不必要なレイテンシを生み出しているのです。

nonceエラー時の情報不足

もう一つの問題は、トランザクションが即座に実行できない場合のエラーハンドリングです。 例えばnonceギャップ(送ろうとしているトランザクションのnonceが、次に期待されるnonceよりも大きい場合)や残高不足で拒否された時、既存のメソッドは汎用的なエラーメッセージしか返しません。 開発者は原因を特定するためにeth_getTransactionCountなどの追加RPC呼び出しでアカウント状態を確認する必要があり、無駄なラウンドトリップと遅延が発生していました。

この提案による解決策

eth_sendRawTransactionSyncは、トランザクション送信とレシート取得を1回のRPC呼び出しに統合することで上記の課題を解決します。

改善項目 効果
レイテンシ削減 送信+確認の合計レイテンシを約50%削減する。
クライアント簡素化 ポーリングループの実装が不要になる。
UX向上 dAppsやウォレットがより即応的になる。
Web2パターンへの接近 従来のリクエスト-レスポンスパターンに近づく。
後方互換性の維持 既存RPCメソッドはそのまま使い続けられる。

仕様

メソッド定義

メソッド名とパラメータ

メソッド名はeth_sendRawTransactionSyncです。 2つのパラメータを受け取ります。

位置 型 説明 必須
1 DATA 署名済みトランザクションデータ。eth_sendRawTransactionと同じ形式で、hex エンコードされたRLPエンコード済みの署名済みトランザクション。 はい
2 INT 最大待機時間(ミリ秒)。この時間内にレシートが得られなければタイムアウトエラーを返す。省略可能。 いいえ
パラメータのバリデーションルール

トランザクションデータは、eth_sendRawTransactionと同様に有効なhexエンコード・RLPエンコード済みの署名済みトランザクションでなければなりません。 タイムアウトは正の整数で、ノードが設定した最大タイムアウト値を超えてはなりません。

戻り値

メソッドの戻り値はシナリオに応じて変わります。

  • 成功時 eth_getTransactionReceiptで定義されたトランザクションレシートオブジェクトを返します。 つまり、トランザクションがブロックに取り込まれて実行された結果がそのまま返ってきます。
  • タイムアウト時 エラーコード4とタイムアウトメッセージを返します。 トランザクションはmempoolに追加されていますが、指定時間内にブロックに取り込まれませんでした。
  • 実行不可時 ノードの実装によってはエラーコード5とエラーメッセージを返す場合があります。 トランザクションがmempoolに追加されなかった(即時実行の準備ができていない)状態を表し、開発者体験の向上を目的としたエラー分類です。
  • 標準エラー時 既存のRPCエラーフォーマットに準拠したJSON-RPCエラーオブジェクトを返します。

エラーコード

eth_sendRawTransactionSync固有のエラーコードは以下の3種類です。

コード エラー種別 説明 dataフィールドの内容
4 Timeout トランザクションはmempoolに追加されたが、タイムアウト時間内に処理されなかった。 トランザクションハッシュ(hex文字列)
5 Unknown/Queued トランザクションはmempoolに追加されなかった(実行準備ができていない)。 トランザクションハッシュ(hex文字列)
6 Nonce Gap トランザクションはmempoolに追加されなかった(nonceギャップを検出)。 期待されるnonce(hex文字列)

エラーレスポンスの構造は以下の3つのフィールドで構成されます。

  • code エラー種別を示すエラーコード。
  • message 人間が読めるエラーメッセージ。
  • data エラー固有のデータ。 エラーコード4(Timeout)と5(Unknown/Queued)ではトランザクションハッシュのhex文字列が、エラーコード6(Nonce Gap)では期待されるnonceのhex文字列が入ります。

エラーコード6の設計意図

エラーコード6(Nonce Gap)は特に注目すべき設計です。 nonceギャップでトランザクションが拒否された時、エラーレスポンスのdataフィールドに期待されるnonceがhex文字列で直接返されます。

これにより、アプリケーションは正しいnonceをエラーレスポンスから即座に取得でき、eth_getTransactionCountを別途呼び出す必要がなくなります。 追加のRPCラウンドトリップを避けることで、正しいnonceでのトランザクション再送信までの時間が大幅に短縮されます。

タイムアウト設定

ノード側の設定

ノード実装はレシートを待機する際に設定可能なタイムアウトを組み込むことが推奨されています。 推奨デフォルト値は2秒です。 ノードオペレーターは、リアルタイムのネットワーク状況に基づいて動的にタイムアウトを調整する仕組みを実装することもできます。

ユーザー側の設定

メソッドの第2パラメータとして、クライアント側からもタイムアウトを指定できます。 アプリケーションは自身のレイテンシ要件に応じてタイムアウトを調整可能です。 ただし、ノード側で設定された最大タイムアウトを超える値は使用できません。 無効なタイムアウト値が渡された場合、ノードはデフォルトのタイムアウトを使用します。

処理フロー

eth_sendRawTransactionSyncリクエストを受け取った時のハンドラの処理フローを以下に示します。

flowchart TD
    A[リクエスト受信] --> B{タイムアウトパラメータあり?}
    B -- はい --> C{タイムアウト値は有効?}
    B -- いいえ --> D[デフォルトタイムアウトを使用]
    C -- はい --> E[指定値を使用]
    C -- いいえ --> D
    D --> F{トランザクションは即時実行可能?}
    E --> F
    F -- nonceギャップ --> G[エラーコード6を返す\n期待nonceを含む]
    F -- その他の理由でキューイング --> H[エラーコード5を返す]
    F -- 即時実行可能 --> I[mempoolに送信]
    I --> J{レシート取得を待機}
    J -- タイムアウト前に取得 --> K[レシートを返す - 成功]
    J -- タイムアウト --> L[エラーコード4を返す\nトランザクションハッシュを含む]
    I -- 送信失敗 --> M[標準エラーを返す]

処理の流れを説明します。

  1. タイムアウトの検証 タイムアウトパラメータが渡された場合、その妥当性を検証します。 無効な場合はノードのデフォルト設定のタイムアウトが使われます。
  2. 即時実行可能性の判定 トランザクションをmempoolに追加する前に、即座に実行できるかどうかを確認します。 nonceギャップがある場合(トランザクションのnonceが期待値より大きい場合)はmempoolに追加せず、エラーコード6と期待されるnonceを返します。 その他の理由で即時実行できない場合もmempoolに追加せず、エラーコード5を返します。
  3. mempoolへの送信 即時実行可能であれば、既存のeth_sendRawTransactionと同じセマンティクスでトランザクションをmempoolに送信します。
  4. レシートの待機 タイムアウト時間が経過するまでレシートを待ちます。 レシートが見つかれば即座に返し、タイムアウトが経過した場合はエラーコード4とトランザクションハッシュを返します。
  5. 送信失敗の処理 トランザクションデータ自体が不正な場合など、送信が失敗した場合は既存のeth_sendRawTransactionの定義に従ってエラーを即座に返します。

リクエスト・レスポンス例

リクエスト(タイムアウトなし)

タイムアウトを指定しない場合、ノードのデフォルトタイムアウトが適用されます。

json
{
  "jsonrpc": "2.0",
  "method": "eth_sendRawTransactionSync",
  "params": [
    "0xf86c808504a817c80082520894ab... (signed tx hex)"
  ],
  "id": 1
}

リクエスト(タイムアウトあり)

5秒(5000ミリ秒)のタイムアウトを指定する例です。

json
{
  "jsonrpc": "2.0",
  "method": "eth_sendRawTransactionSync",
  "params": [
    "0xf86c808504a817c80082520894ab... (signed tx hex)",
    5000
  ],
  "id": 1
}

レスポンス(成功)

トランザクションがタイムアウト前にブロックに取り込まれた場合、eth_getTransactionReceiptと同じ形式のレシートオブジェクトが返されます。

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "transactionHash": "0x1234abcd...",
    "blockHash": "0xabcd1234...",
    "blockNumber": "0x10d4f",
    "cumulativeGasUsed": "0x5208",
    "gasUsed": "0x5208",
    "contractAddress": null,
    "logs": [],
    "status": "0x1"
  }
}

statusが"0x1"であればトランザクションは成功、"0x0"であればrevertしたことを意味します。

レスポンス(タイムアウト - エラーコード4)

トランザクションはmempoolに追加されましたが、指定時間内にブロックに取り込まれなかった場合です。 dataフィールドにトランザクションハッシュが含まれるため、クライアントはこのハッシュを使って後からeth_getTransactionReceiptでレシートをポーリングできます。

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": 4,
    "message": "The transaction was added to the mempool but wasn't processed within the designated timeout interval.",
    "data": "0x1234abcd..."
  }
}

レスポンス(Nonce Gap - エラーコード6)

nonceギャップが検出された場合です。 トランザクションはmempoolに追加されず、dataフィールドに期待されるnonceがhex文字列で返されます。 アプリケーションはこのnonceを使って即座にトランザクションを再構築して送信できます。

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": 6,
    "message": "The transaction was rejected due to a nonce gap. Please resubmit with the next on-chain nonce.",
    "data": "0x5"
  }
}

レスポンス(Unknown/Queued - エラーコード5)

トランザクションが何らかの理由で即時実行できず拒否された場合です。 トランザクションはmempoolに追加されていませんが、ノードがトランザクションデータからハッシュを計算済みのため、dataフィールドにトランザクションハッシュが返されます。

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": 5,
    "message": "The transaction was rejected for an unknown reason.",
    "data": "0x1234abcd..."
  }
}

レスポンス(標準エラー)

トランザクションデータ自体が不正な場合など、既存のeth_sendRawTransactionと同様のエラーが返されます。

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32000,
    "message": "Invalid transaction"
  }
}

補足

なぜ既存RPCを拡張しないのか

「eth_sendRawTransactionにタイムアウトパラメータを追加すればいいのでは?」と思うかもしれません。 しかし、既存メソッドの振る舞いを変更すると互換性の問題が生じます。 現在のクライアントはeth_sendRawTransactionがトランザクションハッシュを即座に返すことを前提に設計されています。 戻り値の型やタイミングが変わると、既存のすべてのクライアントに影響を与えてしまいます。

別メソッドとして定義することで、セマンティクスが明示的になり、開発者はオプトインで選択できます。 既存の非同期方式も引き続き使えるため、既存の仕様に影響を与えることなく新しい選択肢を提供できるのです。

同期方式が適するチェーンと適さないチェーン

eth_sendRawTransactionSyncは特にブロック生成が速くネットワークレイテンシの低いEVM互換チェーンやL2ソリューションに向いています。 こうした環境ではレシートがすぐに利用可能になるため、同期的なレシート取得によってレスポンス時間を大幅に改善できます。

一方、Ethereumメインネットのようにブロック生成に12秒かかるチェーンでは、同期的な待機はRPC呼び出しの長時間ブロックやタイムアウトを引き起こしやすく、実用的ではない場合があります。 この提案はオプションのメソッドであるため、こうしたチェーンでは従来の非同期方式を使い続ければ問題ありません。

nonceエラーで期待値を返す設計の利点

エラーコード6でnonceギャップが検出された時に期待されるnonceを返す設計には、以下のメリットがあります。

メリット 説明
即時回復 正しいnonceがエラーレスポンスに含まれるため、eth_getTransactionCountを別途呼び出す必要がない。
レイテンシ削減 追加RPCラウンドトリップを避けることで、トランザクション再送信までの時間が短縮される。

以下の図は、nonceギャップが発生した時のリカバリーフローを従来方式とEIP7966で比較したものです。

fig1-nonce-recovery.png

従来であれば「nonceが合わない」というエラーを受け取った後、eth_getTransactionCountで正しいnonceを問い合わせ、トランザクションを再構築して再送信するという3回のRPC呼び出しが必要でした。 EIP7966ではエラーレスポンスに期待nonceが含まれるため、「エラーから読み取り→即座に再送信」の2回で完結します。

互換性

この提案は新しいRPCメソッドの追加のみであり、既存のメソッドを変更・廃止しません。 eth_sendRawTransactionを使用している既存アプリケーションはそのまま動作し続けます。 eth_sendRawTransactionSyncを実装していないノードは、従来通りmethod not foundを返すだけで動作に影響はありません。 また、同一アプリケーション内で従来の非同期メソッドと新しい同期メソッドを混在して使用することも可能です。

そのため、既存のアプリケーションやインフラに影響を与えることなく段階的に導入できます。

参考実装

以下はreth(Rustベースのイーサリアムクライアント)でのeth_sendRawTransactionSyncのハンドラ実装例です。

rust
async fn send_raw_transaction_sync(
    &self,
    tx: Bytes,
    user_timeout_ms: Option<u64>,
) -> RpcResult<OpTransactionReceipt> {
    const MAX_TIMEOUT_MS: u64 = 2_000;

    const ERROR_CODE_TIMEOUT: i32 = 4;
    const ERROR_CODE_UNKNOWN: i32 = 5;
    const ERROR_CODE_NONCE_GAP: i32 = 6;

    const ERROR_MSG_TIMEOUT_RECEIPT: &str = "The transaction was added to the mempool but wasn't processed within the designated timeout interval.";
    const ERROR_MSG_UNKNOWN: &str = "The transaction was rejected for an unknown reason.";
    const ERROR_MSG_NONCE_GAP: &str = "The transaction was rejected due to a nonce gap. Please resubmit with the next on-chain nonce.";

    let start_time = Instant::now();
    let timeout = Duration::from_millis(
        user_timeout_ms.map_or(MAX_TIMEOUT_MS, |ms| ms.min(MAX_TIMEOUT_MS)),
    );

    let pool_transaction = OpPooledTransaction::from_pooled(recover_raw_transaction(&tx)?);
    let sender = pool_transaction.sender();

    let outcome = self
        .pool
        .add_transaction(TransactionOrigin::Local, pool_transaction)
        .await
        .map_err(OpEthApiError::from_eth_err)?;

    if let AddedTransactionState::Queued(reason) = outcome.state {
        self.pool.remove_transaction(outcome.hash);

        match reason {
            QueuedReason::NonceGap => {
                let expected_nonce = self
                    .pending_state
                    .basic_account(&sender)
                    .ok()
                    .flatten()
                    .map(|acc| format!("0x{:x}", acc.nonce));

                return Err(ErrorObject::owned(
                    ERROR_CODE_NONCE_GAP,
                    ERROR_MSG_NONCE_GAP,
                    expected_nonce,
                ));
            }
            _ => {
                return Err(ErrorObject::owned(
                    ERROR_CODE_UNKNOWN,
                    ERROR_MSG_UNKNOWN,
                    Some(hash),
                ));
            }
        }
    }

    let hash = outcome.hash;
    match self
        .pending_state
        .get_receipt(hash, timeout.saturating_sub(start_time.elapsed()))
        .await
    {
        Some(receipt) => Ok(receipt),
        None => Err(ErrorObject::owned(
            ERROR_CODE_TIMEOUT,
            ERROR_MSG_TIMEOUT_RECEIPT,
            Some(hash),
        )),
    }
}

この実装では以下の流れで処理が行われています。

  1. タイムアウトの決定 ユーザー指定のタイムアウトがあればそれを使い、なければデフォルトの2秒(MAX_TIMEOUT_MS)を使います。 ユーザー指定値が最大値を超えている場合は最大値にクランプされます。
  2. トランザクションの復元と送信 生のバイトデータからトランザクションを復元し、トランザクションプールに追加します。
  3. キュー状態の判定 トランザクションがキューに入った場合(即時実行不可)、プールから削除した上で理由に応じて適切なエラーコードを返します。 nonceギャップの場合はアカウントの現在のnonceを取得してエラーレスポンスに含めます。

仕様と参照実装のアプローチの違い

仕様ではmempoolへの追加前にトランザクションの即時実行可能性をチェックすることが求められていますが、この参照実装ではまずプールに追加してからキュー状態を確認し、即時実行できない場合はプールから削除する方式を採用しています。 最終的な振る舞い(即時実行できないトランザクションがmempoolに残らない)は同じですが、実装の内部手順が異なります。

  1. レシートの待機 トランザクションが即時実行可能な場合、残りのタイムアウト時間内でレシートを待機します。 レシートが得られればそのまま返し、タイムアウトすればエラーコード4を返します。

go-ethereumのような他の実装では、ポーリングの代わりにチャネルを使ったイベント駆動方式でレシートの到着を検知する方法も可能です。

セキュリティ

DoS対策としてのタイムアウト

eth_sendRawTransactionSyncは同期的にレシートを待機するため、タイムアウトなしに実装するとRPC呼び出しが無限にブロックされるリスクがあります。 ノード設定可能なタイムアウト(推奨デフォルト2秒)がこの問題を防ぎます。 ノード実装は、大量の同期リクエストがノードのパフォーマンスを劣化させたりDoSを引き起こしたりしないよう注意する必要があります。

既存のセキュリティモデルとの関係

この提案はトランザクション送信自体に新しいセキュリティリスクを導入するものではありません。 トランザクションの検証・実行ロジックは既存のeth_sendRawTransactionと同一であり、追加されるのはレシートの同期待機のみです。 ノード実装はタイムアウトレスポンスを適切に処理し、必要に応じてトランザクションの状態監視を続ける必要があります。

引用

最後に

今回は「EIP7966によるトランザクション送信とレシート取得の同期化」についてまとめてきました。 いかがだったでしょうか?

L2や高速チェーンの普及に伴い、「送信したら即座にレシートが返ってくる」という体験はますます求められるようになっています。 従来の「送信→ポーリング→レシート取得」という3ステップが「送信→レシート取得」の1ステップに短縮されることで、dApps開発者にとってはクライアントロジックの簡素化、ユーザーにとってはよりレスポンシブな体験が実現します。

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!