トランザクションの流れ:プールアーキテクチャと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つの主要なフローを調整します。
-
トランザクション伝播:新しいトランザクションイベントが発生すると、handlerはトランザクションの全データをブロードキャストするか、ハッシュだけをアナウンスするかを判断します。閾値は
txMaxBroadcastSize = 4096バイトで、これを超えるトランザクションはアナウンスのみとなり、ピアが明示的にリクエストする必要があります。blobトランザクションにとって特に重要な仕組みです。 -
チェーン同期:
downloaderは初回同期やオフライン後のキャッチアップを担います。フル同期とスナップ同期の両モードを調整します。 -
ピア管理:
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回のテーマです。