Read OSS

トランザクションの流れ:プールアーキテクチャとP2P伝播

上級

前提知識

  • 記事1〜3
  • Ethereumのトランザクション型(Legacy、EIP-1559、EIP-4844 blob)
  • P2Pネットワークの基礎知識

トランザクションの流れ:プールアーキテクチャとP2P伝播

これまで、ブロックの実行から状態の更新、ディスクへの書き込みまでを追ってきました。今回はデータの流れを逆方向から見ていきます。トランザクションがノードに届き、検証されてプールに格納され、ネットワーク全体に伝播し、最終的にブロックに取り込まれるまでの流れです。このライフサイクルには2つの主要なサブシステムが関わっています。SubPoolアグリゲーターパターンを持つトランザクションプールと、devp2pネットワークスタックです。両者をつなぐ中心的なコーディネーターが handler 構造体です。

トランザクション型と複数プールの必要性

Ethereumには現在5種類のトランザクション型があり、それぞれ core/types/transaction.go で定義されています。

const (
    LegacyTxType     = 0x00
    AccessListTxType = 0x01
    DynamicFeeTxType = 0x02
    BlobTxType       = 0x03
    SetCodeTxType    = 0x04
)

0x00〜0x02 および 0x04 は「通常の」トランザクションです。calldata を持ち、サイズの特性も似ており、同じライフサイクルをたどります。一方、0x03(EIP-4844で導入されたblobトランザクション)は根本的に異なります。blobトランザクションは1件あたり数百キロバイトものblobデータを含む場合があり、このサイズの違いがプール管理、ネットワーク伝播、エビクション戦略に大きな影響を与えます。

200バイト程度の通常のトランザクションと、200キロバイトものblobトランザクションを1つのモノリシックなプールで効率よく扱うことはできません。エビクションポリシー、メモリ予算、永続化戦略がまったく異なるからです。そこでGethが導入したのがSubPoolアグリゲーターパターンです。

SubPoolアグリゲーターパターン

TxPool 構造体はそれ自体がプールではありません。複数の専門化されたプール実装を統一的に管理するアグリゲーターです。

type TxPool struct {
    subpools []SubPool
    chain    BlockChain
    stateLock sync.RWMutex
    state     *state.StateDB
    subs      event.SubscriptionScope
    quit      chan chan error
    term      chan struct{}
    sync      chan chan error
}

アグリゲーターは統一されたAPIを外部に提供しつつ、各 SubPool はそれぞれのトランザクション型に最適化された戦略で処理を行います。

flowchart TD
    INCOMING["Incoming Transaction"] --> FILTER["TxPool.Add()"]
    FILTER --> DISPATCH["Dispatch to matching SubPool<br/>(based on Filter())"]
    DISPATCH --> LP["legacypool<br/>Types: 0x00, 0x01, 0x02, 0x04<br/>In-memory with journal<br/>Price-based eviction"]
    DISPATCH --> BP["blobpool<br/>Type: 0x03<br/>Disk-backed (billy)<br/>Size-aware eviction"]
    LP --> PENDING["Pending() — merged view"]
    BP --> PENDING
    PENDING --> MINER["Miner / Block Builder"]

SubPool インターフェースは包括的で、フィルタリング、初期化、リセット(チェーンヘッドの変更時)、gasチップの更新、トランザクションの追加、pending状態の取得、ステータスクエリといった機能の実装が求められます。Filter(tx) メソッドにより、各SubPoolは自分が担当するトランザクション型を宣言します。

eth.New() では、2つのプールが作成・合成されます。

legacyPool := legacypool.New(config.TxPool, eth.blockchain)
eth.blobTxPool = blobpool.New(config.BlobPool, eth.blockchain, legacyPool.HasPendingAuth)
eth.txPool, err = txpool.New(config.TxPool.PriceLimit, eth.blockchain, []txpool.SubPool{legacyPool, eth.blobTxPool})

blobpool.New に渡される legacyPool.HasPendingAuth コールバックは、プール間の連携ポイントです。blobプールはこれを利用して、特定のアカウントに対するSetCode承認がlegacyプールのpending状態に存在するかどうかを確認できます。

LazyTransaction:遅延ロードによる最適化

マイナーがブロック構築のためにpendingトランザクションを要求する際、すべての候補について完全なトランザクションデータを読み込むのは無駄が多くなります。特に、ブロックに取り込まれないかもしれない数メガバイトものblobトランザクションに対してはなおさらです。LazyTransaction パターンはこの問題を解決します。

type LazyTransaction struct {
    Pool      LazyResolver
    Hash      common.Hash
    Tx        *types.Transaction  // nil until resolved
    Time      time.Time
    GasFeeCap *uint256.Int
    GasTipCap *uint256.Int
    Gas       uint64
    BlobGas   uint64
}

LazyTransaction はマイナーが順序付けやフィルタリングの判断に必要な最小限のメタデータ(gasキャップ、gas上限、ハッシュ)だけを保持します。完全なトランザクションデータは、実際に必要になったときに初めて Resolve() 経由で読み込まれます。

func (ltx *LazyTransaction) Resolve() *types.Transaction {
    if ltx.Tx != nil {
        return ltx.Tx
    }
    return ltx.Pool.Get(ltx.Hash)
}

LazyResolver インターフェースはシンプルで、Get(hash) メソッドひとつだけです。各SubPoolがこれを実装し、resolverがlazyトランザクションに注入されることで、管理元のプールから完全なデータを取得できるようになります。

Tip: Resolve() のコメントには重要な設計上の判断が記されています。元のプールがキャッシュしていない場合、このメソッドは意図的に解決済みのトランザクションをキャッシュしません。blobトランザクションを無条件にキャッシュすると、メモリが際限なく膨らんでしまうからです。

devp2pネットワークスタック

トランザクションはdevp2pネットワークスタックを通じてノードに届き、また送り出されます。p2p.Server はピア接続、プロトコルの多重化、そしてノード探索を管理します。

flowchart TD
    subgraph "Discovery"
        DNS["DNS Discovery"]
        V4["discv4 (UDP)"]
        V5["discv5 (UDP)"]
        FAIR["FairMix<br/>Balanced source selection"]
    end
    subgraph "Transport"
        RLPX["RLPx Encrypted TCP"]
    end
    subgraph "Protocols"
        ETH_P["eth/68 Protocol"]
        SNAP_P["snap/1 Protocol"]
    end
    DNS --> FAIR
    V4 --> FAIR
    V5 --> FAIR
    FAIR --> RLPX
    RLPX --> ETH_P
    RLPX --> SNAP_P

Protocol 構造体はdevp2pのサブプロトコルを定義します。

type Protocol struct {
    Name    string
    Version uint
    Length  uint64
    Run     func(peer *Peer, rw MsgReadWriter) error
    DialCandidates enode.Iterator
    Attributes     []enr.Entry
}

Run 関数は接続された各ピアに対して新しいgoroutineで呼び出され、MsgReadWriter を通じてプロトコルメッセージの読み書きを行います。DialCandidates フィールドは FairMix ディスカバリーミキサーから供給される、接続候補ピアのイテレーターです。

FairMix の特徴は、DNS、discv4、discv5といった複数のピア探索ソースを公平性を保証しながら組み合わせる点です。特定のソースが接続試行を独占しないよう、各ソースに100ミリ秒のタイムアウトが設定されており、毎ラウンド均等に候補を生成できます。

handler:P2Pとブロックチェーンをつなぐ接着剤

handler 構造体は、ネットワークとブロックチェーンのサブシステムをつなぐ中心的なコーディネーターです。

type handler struct {
    nodeID    enode.ID
    networkID uint64
    synced    atomic.Bool
    database  ethdb.Database
    txpool    txPool
    chain     *core.BlockChain
    maxPeers  int
    downloader     *downloader.Downloader
    txFetcher      *fetcher.TxFetcher
    peers          *peerSet
    txBroadcastKey [16]byte
    // ...
}
sequenceDiagram
    participant Peer as Remote Peer
    participant Handler as handler
    participant TxFetcher as TxFetcher
    participant TxPool as TxPool
    participant Miner as Miner

    Peer->>Handler: NewPooledTransactionHashes (announcement)
    Handler->>TxFetcher: Notify(peer, hashes)
    TxFetcher->>Peer: GetPooledTransactions (fetch)
    Peer-->>TxFetcher: PooledTransactions (response)
    TxFetcher->>TxPool: Add(txs)
    TxPool-->>Handler: NewTxsEvent
    Handler->>Peer: Broadcast or Announce to other peers
    Miner->>TxPool: Pending() — pull txs for block

handlerは3つの主要なフローを調整します。

  1. トランザクション伝播:新しいトランザクションイベントが発生すると、handlerはトランザクションの全データをブロードキャストするか、ハッシュだけをアナウンスするかを判断します。閾値は txMaxBroadcastSize = 4096 バイトで、これを超えるトランザクションはアナウンスのみとなり、ピアが明示的にリクエストする必要があります。blobトランザクションにとって特に重要な仕組みです。

  2. チェーン同期downloader は初回同期やオフライン後のキャッチアップを担います。フル同期とスナップ同期の両モードを調整します。

  3. ピア管理peerSet は接続中のピアを追跡します。txBroadcastKey はトランザクションブロードキャストのルーティングを決定論的に行うための仕組みで、トランザクションハッシュのSipHashを基にブロードキャスト先のピアのサブセットを選択します。ネットワークを洪水させることなく、適切なカバレッジを確保できます。

newHandler() コンストラクターはdownloaderとトランザクションフェッチャーをセットアップし、プールに接続するコールバックを配線します。

fetchTx := func(peer string, hashes []common.Hash) error {
    p := h.peers.peer(peer)
    if p == nil { return errors.New("unknown peer") }
    return p.RequestTxs(hashes)
}
addTxs := func(txs []*types.Transaction) []error {
    return h.txpool.Add(txs, false)
}
h.txFetcher = fetcher.NewTxFetcher(h.chain, validateMeta, addTxs, fetchTx, h.removePeer)

Tip: synced アトミックboolは重要な調整フラグです。ノードがネットワークと同期済みと判断するまで、トランザクション処理は無効化されています。enableSyncedFeatures() メソッドがこのフラグを切り替えると、トランザクションのブロードキャストとプールへの受け入れが解放されます。ノードがトランザクションを受け付けないように見える場合は、まず同期が完了しているかどうかを確認してみましょう。

トランザクションがネットワークを流れてプールに格納され、ブロックがステートに対して実行される様子を追ってきました。残る問いは「外の世界はこれらとどのようにやり取りするのか」です。それがRPCレイヤーの役割であり、第6回のテーマです。