掃碼下載
BTC $65,505.84 -1.71%
ETH $1,927.24 -2.79%
BNB $610.97 -0.77%
XRP $1.42 -4.56%
SOL $81.67 -4.53%
TRX $0.2795 -0.47%
DOGE $0.0974 -3.83%
ADA $0.2735 -4.22%
BCH $465.83 -3.10%
LINK $8.64 -2.97%
HYPE $28.98 -1.81%
AAVE $122.61 -3.42%
SUI $0.9138 -6.63%
XLM $0.1605 -4.62%
ZEC $260.31 -8.86%
BTC $65,505.84 -1.71%
ETH $1,927.24 -2.79%
BNB $610.97 -0.77%
XRP $1.42 -4.56%
SOL $81.67 -4.53%
TRX $0.2795 -0.47%
DOGE $0.0974 -3.83%
ADA $0.2735 -4.22%
BCH $465.83 -3.10%
LINK $8.64 -2.97%
HYPE $28.98 -1.81%
AAVE $122.61 -3.42%
SUI $0.9138 -6.63%
XLM $0.1605 -4.62%
ZEC $260.31 -8.86%

Geth 源碼系列:交易設計及實現

Summary: 這篇文章是 Geth 源碼系列的第四篇,我們將深入研究以太坊交易,包括交易機制的設計和交易的生命周期。最後會詳細說明交易的執行流程,包括在執行層和共識層的互動流程。
LXDAO
2025-11-08 10:08:33
收藏
這篇文章是 Geth 源碼系列的第四篇,我們將深入研究以太坊交易,包括交易機制的設計和交易的生命周期。最後會詳細說明交易的執行流程,包括在執行層和共識層的互動流程。

1. 交易簡介

以太坊執行層可以看作是一個交易驅動的狀態機,交易是唯一修改狀態的方式。交易只能由 EOA 發起,並且會在交易中附加私鑰的簽名,交易執行之後就會更新以太坊網絡的狀態。以太坊網絡中最簡單的交易就是 ETH 的轉帳,從一個帳戶轉帳到另一個帳戶。

這裡需要說明一下,隨著 EOA 通過 EIP-7702 的升級可以支持合約的能力,以後合約帳戶和 EOA 的概念會逐漸模糊,但在當前的版本中,還是認為只有私鑰控制的 EOA 才能發起交易。

以太坊支持不同類型的交易,在以太坊主網剛上線時,只支持一種交易,後續在以太坊不斷升級的過程中,才陸續支持了不同類型的交易。當前主流的交易是可以支持動態費率 EIP-1559 的交易,大多數用戶提交的都是這類交易。EIP-4844 中則引入了可以為 Layer2 或者其他鏈下擴容方案提供更加便宜的數據存儲,而在最新的 Pectra 升級中,則通過 EIP-7702 引入了可以將 EOA 擴展為合約的交易形式。

隨著以太坊的發展,後續可能還會支持其他的交易類型,但是交易總體的處理流程變化不會太大,都需要經過交易提交 → 交易校驗 → 進入交易池 → 交易傳播 → 打包進入區塊的流程。

2. 交易結構的演進

從以太坊主網上線開始,以太坊交易結構可以總結為四次大的變化,分別從安全性和擴展性打好了基礎,後續以太坊可以用低成本的方式增加交易類型。

防止跨鏈重放攻擊

最開始的交易結構如下所示,RLP 標識交易數據會被編碼成 RLP 結構後再傳播和處理:

RLP([nonce, gasPrice, gasLimit, to, value, data, v, r, s])

這個結構最大的问题在於沒有和鏈關聯,在主網生成的交易可以被隨意放到其他鏈上去執行,所以 EIP-155 中通過在簽名v值中嵌入chainId(如主網ID=1),隔離不同鏈的交易,從而保證每條鏈的交易都無法在其他鏈重放。

涉及 EIP:

  • EIP-155

交易擴展的標準化

隨著以太坊的發展,最開始的交易格式已經無法滿足一些場景的需求,所以需要增加新的交易類型,但是如果隨意增加交易的類型,後續可能會面臨管理複雜,無法標準化的問題。在 EIP-2718 中,定義了後續交易的格式,主要定義了 TransactionType || TransactionPayload 結構,其中:

  • TransactionType 定義類交易類型,至多可以擴展到 128 種交易類型,足夠新增交易類型使用

  • TransactionPayload 定義了交易的數據格式,當前使用 RLP 來編碼數據,後續也有可能會升級到 SSZ 或者其他編碼

這次升級是在 Berlin 升級中完成,除了 EIP-2718 之外,這次升級中還通過 EIP-2930 引入了 Access List 交易類型,這個交易類型允許用戶在交易中預先聲明需要訪問的合約和存儲,可以降低交易執行過程中的 gas 消耗。

涉及 EIP:

  • EIP-2718

  • EIP-2930

以太坊經濟模型變革

在 London 升級中,EIP-1559 引入了 Base Fee 機制,讓 ETH 發行的速度減緩甚至通縮,對於參與質押的節點來說,還有可能通過小費(maxPriorityFeePerGas)獲得額外的收入。EIP-1559 交易繼承了 Access List 機制,這已經是目前最主要的交易。並且在 Paris 的 The Merge 升級之後,以太坊從 PoW 轉向 PoS,原先的挖礦經濟模型已經不再適用,以太坊進入 Staking 時代。

另外在 EIP-1559 中,還通過引入 target 機制,可以動態調整 Base Fee,相當於為以太坊引入了負載均衡的能力,target 值是區塊 Gas Limit 的一半,如果超出這個值,Base Fee 就會持續上漲,這樣很多交易就會避開擁堵時間,這樣可以讓鏈的整體擁堵情況能減緩,用戶體驗更好。

涉及 EIP:

  • EIP-1559

增加各類擴展交易

在 EIP-2718 和 EIP-1559 分別定義好擴展交易的標準和經濟模型之後,就陸續有新的交易類型增加。在最近兩次的升級中,分別增加了 EIP-4844 和 EIP-7702,前者增加了 Blob 交易類型,為鏈下擴容方案提供了理想化的存儲方案,空間大,價格還低,而且也有著類似 EIP-1559 的經濟模型和負載機制,EIP-7702 則可以將 EOA 改造成掌握私鑰的智能合約帳戶,為後續帳號抽象的大規模採用做好準備。

涉及 EIP:

  • EIP-4844

  • EIP-7702

3. 交易模塊架構

交易作為以太坊這個狀態機的輸入,幾乎所有的主流程都圍繞交易來進行,交易進入交易池之前需要校驗交易的格式和簽名等信息,進入交易池之後,需要在不同的節點之間傳播,再被出塊節點從交易池中選擇,然後交易會在 EVM 上執行,並修改狀態數據庫,最後被打包進區塊在執行層和共識層之間傳遞。

對於出塊節點和非出塊節點,處理交易的流程會有一些差別。對於出塊節點,會負責從交易池中挑選交易,然後打包進區塊,並更新本地的狀態數據庫,對於非出塊節點,只需要將同步到的最新區塊中的交易重新執行一遍,讓本地的狀態更新到最新。

交易種類

目前以太坊總共支持五類交易。這些交易的主要結構都類似,不同交易會通過交易類型字段來區分,不同類型的交易中通過一些擴展字段來實現特定的用途。

  • LegacyTxType:創世區塊沿用至今的基礎格式,採用第一價格拍賣模型 (用戶手動設置gasPrice),EIP-155 升級後默認嵌入 chainId 防跨鏈重放攻擊,當前在以太坊主網上的使用量已經比較少,以太坊當前兼容這個交易類型,後續會逐步淘汰

  • AccessListTxType:預熱存儲訪問大幅降低 Gas成本,這個特性已經被後續的交易類型繼承,直接使用這個交易類型的交易也較少

  • DynamicFeeTxType:更新了以太坊的經濟模型的交易類型,引入 Base Fee 和 target 機制,繼承了 AccessList 特性,這也是當前最主流的交易類型

  • BlobTxType:專為鏈下擴容涉及的交易類型,允許交易通過 blob 結構攜帶低成本的大量數據,降低鏈下擴容方案的成本,繼承了 AccessList 和 DynamicFee 特性,並且交易中的 blob 有與 EIP-1559 類似的單獨計費機制

  • SetCodeTxType:允許 EOA 轉換為合約帳戶(也可以通過交易撤銷合約能力),並可以執行 EOA 中對應的合約代碼,繼承了 AccessList 和 DynamicFee 特性

交易生命周期

當交易被打包進行區塊之後,已經完成了對狀態數據的修改,可以理解為交易的生命周期已經結束,在這個過程中,交易會經歷四個周期:

  • 交易驗證:EOA 提交的交易會經過一系列基礎驗證之後才會被加入到交易池中

  • 交易廣播:新提交到交易池的交易會被廣播到其他的節點的交易池中

  • 交易執行:出塊節點會從交易池中挑選交易進行執行

  • 交易打包:交易會按照一定的順序(先區分是否是本地交易,然後再按照 gas fee 大小)打包進區塊,會忽略那些會驗證不通過的交易

交易池

交易池是臨時存放交易的地方,交易在被打包之前,都会被存儲在交易池中,交易池中的交易會被同步到其他節點,同時也會從其他節點的交易池中同步交易。用戶提交的交易會首先進入到交易池中,然後經過共識層觸發共識流程,驅動交易執行以及被打包進區塊。

當前交易池的實現有兩種類型:

  • Blob 交易池(Blob TxPool)

  • 其他交易的交易池(Legacy TxPool)

由於 Blob 交易攜帶的數據與其他交易攜帶的數據處理流程不一樣,所以會使用單獨的交易池來處理,是其他類型的交易雖然類型不一致,但在不同節點之間同步以及被打包的流程基本一致,所以會在同一個交易池中被處理,交易池中交易都由外部 EOA 的所有者提交,向交易池中提交交易有兩種方式:

  • SendTransaction

  • SendRawTransaction

SendTransaction 是客戶端發送的是一個未簽名的交易對象,節點會用交易中發起地址 from 對應的私鑰來對交易進行簽名。而 SendRawTransaction 則需要提前對交易簽名,然後將簽名完成的交易提交到節點。這種方式更常用,使用 Metamask、Rabby 等錢包使用的就是這種方式。

在這裡以 SendRawTransaction 為例,在節點啟動之後,節點會啟動以一個 API 模塊,用來處理外部的各類 API 請求,SendRawTransaction 就是其中的一個 API,源代碼在 internal/ethapi/api.go :

func (api *TransactionAPI) SendRawTransaction(ctx context.Context, input hexutil.Bytes) (common.Hash, error) {
tx := new(types.Transaction)
if err := tx.UnmarshalBinary(input); err != nil {
return common.Hash{}, err
}
return SubmitTransaction(ctx, api.b, tx)
}

4. 核心數據結構

對於交易模塊來說,核心的數據結構就兩部分,一部分是用來表示交易本身的數據結構,另一部分就是表示臨時存儲交易的交易池結構。由於交易在交易池中需要在不同的節點之間傳播,所以在交易池中的實現中會依賴底層的 p2p 協議。

交易結構

使用了 core/types/transaction.go 的 Transaction 來統一表示了所有的交易類型:

type Transaction struct {
inner TxData // 交易實際的數據會存儲在這裡
time time.Time
//….
}

TxData 是一個 interface 類型,定義了所有交易類型都需要實現的屬性獲取方法,但是對於 LegacyTxType 這類交易,其中很多字段都沒有,那就會使用之前已經存在的字段替代或者直接返回空:

type TxData interface {
txType() byte // 交易類型
copy() TxData // 創建交易數據的深度拷貝
chainID() *big.Int // 鏈ID,用於區分不同的以太坊網絡
accessList() AccessList // 預編譯的訪問列表,用於優化gas消耗(EIP-2930引入)
data() []byte // 交易的輸入數據,用於合約調用或創建
gas() uint64 // Gas 限制,表示交易最多可以消耗的 gas 數量
gasPrice() *big.Int // 每單位 Gas 的價格(用於 Legacy 交易)
gasTipCap() *big.Int // 小費上限(用於 EIP-1559 交易)
gasFeeCap() *big.Int // 總費用上限(用於 EIP-1559 交易)
value() *big.Int // 交易中發送的 ETH 數量
nonce() uint64 // 交易序號,用於防止重放攻擊
to() *common.Address // 接收方地址,如果是合約創建則為nil
rawSignatureValues() (v, r, s *big.Int) // 原始簽名值(v, r, s)
setSignatureValues(chainID, v, r, s *big.Int) // 設置簽名值
effectiveGasPrice(dst *big.Int, baseFee *big.Int) *big.Int // 計算實際的gas價格(考慮baseFee)
encode(*bytes.Buffer) error // 將交易編碼為字節流
decode([]byte) error // 從字節流解碼交易
sigHash(*big.Int) common.Hash // 需要簽名的交易哈希
}

除了上面每個交易都需要實有的細節之外,每個新增加的交易都有自身的擴展部分。

在 Blob 交易中:

  • BlobFeeCap:每個 blob 數據的最大費用上限,類似 maxFeePerGas,但是專門用於計算 blob 數據的費用

  • BlobHashes:存儲所有 blob 數據的哈希值數組,這些數據會在執行層存儲,用於證明 Blob 數據的完整性和真實性

  • Sidecar:包含實際的 blob 數據及其證明,這些數據不會在執行層存儲,只會在共識層存儲一段時間,也不會被編碼到交易中

在 SetCode 交易中:

  • AuthList:是一個授權列表,用於實現合約代碼的多重授權機制,用於幫助 EOA 獲取智能合約的能力

所有的交易類型都需要實現 TxData,然後每種交易的差異化處理會在交易類型的內部去實現。這樣面向接口的一個好處就是後續可以很輕鬆地增加一種新的交易類型,而不需要去修改當前的交易流程流程。

交易池結構

與交易結構類似,交易池也採用了同樣的設計模式,使用 core/txpool/txpool.go 中的 TxPool 來統一管理交易池,其中 SubPool 是一個 interface,每個交易池的具體實現都需要實現這個 interface:

type TxPool struct {
subpools []SubPool // 交易池的具體實現
chain BlockChain
// …
}
type LegacyPool struct {
config Config // 交易池參數配置
chainconfig *params.ChainConfig // 區塊鏈參數配置
chain BlockChain // 區塊鏈接口
gasTip atomic.Pointer[uint256.Int] // 當前接受的最低 gas 小費
txFeed event.Feed // 交易事件的發布訂閱系統
signer types.Signer // 交易簽名驗證器
pending map[common.Address]*list // 當前可處理的交易
queue map[common.Address]*list // 暫時不可處理的交易
//…
}
type BlobPool struct {
config Config // 交易池的參數配置
reserve txpool.AddressReserver //
store billy.Database // 持久化數據存儲,用於存儲交易元數據和 blob 數據
stored uint64 //
limbo *limbo //
signer types.Signer //
chain BlockChain //
index map[common.Address][]*blobTxMeta //
spent map[common.Address]*uint256.Int //
//…
}

當前實現了 SubPool 的兩個交易池為:

  • BlobTxPool:用於管理 Blob 交易

  • LegacyTxPool:用於管理 Blob 交易之外的其他交易

之所以 Blob 交易需要和其他交易分開管理,是因為 Blob 交易中有可能會攜帶大量的 blob 數據,其他的交易都可以直接在內存中管理和同步,而 Blob 交易中的 blob 數據則需要持久化存儲,所以不能使用和其他交易一樣的管理方式。

5. 費用機制

由於以太坊本身無法處理停機問題,所以使用 Gas 機制來防止一些惡意的攻擊,另外 Gas 本身也作為用戶的手續費在使用,這是 Gas 最初的兩個用途。

經過了這些年的發展之後,Gas 除了上面的兩個用途,還是以太坊經濟模型的重要組成部分,可以控制 ETH 的發行數量,還可以幫助以太坊完成通縮。甚至還可以動態調節以太坊網絡的流量,提升用戶使用體驗。

以太坊的費用機制使用 Gas 來實現,有維護網絡安全、實現經濟模型平衡等多種作用。

Gas

以太坊在處理交易時,在 EVM 上執行的每個操作都需要消耗 Gas,比如使用內存、讀取數據、寫入數據等等,有的操作消耗 Gas 多,有的消耗少,比如 ETH 的轉帳操作需要消耗 21,000 Gas。在每一筆交易中,都需要設定該交易最高需要消耗多少 Gas,如果 Gas 消耗完,那麼交易就執行結束,而且這個過程中消耗的 Gas 不會退還,也正是這個機制可以用來處理以太坊的停機問題。

以太坊中區塊的大小也是使用 Gas 來限制,而不是使用具體的大小單位,區塊內所有交易實際消耗的 Gas 不能大於區塊本身 Gas 限制。Gas 只是 EVM 執行過程中的計量單位,用於需要為每筆交易消耗的 Gas 支付 ETH,Gas 的價格通常使用 Gwei 來表示, 1 Ether = 10\^9 Gwei。

在當前的以太坊網絡中,當前的一個區塊的大小限制是 36M Gas,但是在當前的社區中對於提升區塊的 Gas 限制的聲量很大,認為提升區塊 Gas 限制到 60M 是比較合理的選擇,這個數字會提升網絡的容量,而且同時不會威脅到網絡的安全,目前已經在測試網測試中。同時社區中也有人認為單純使用 Gas 限制來控制的區塊的大小不合理,需要引入字節大小的限制,目前這些正在社區討論中。

EIP-1559

在引入 EIP-1559 的機制之後,將之前的 GasPrice 直接拆分成了 Base Fee 和 Priority Fee(maxPriorityFeePerGas) ,其中 Base Fee 會全部被銷毀,以控制以太坊中 ETH 的增長速度,Priority Fee 則會給出出塊節點對應的驗證者。用戶可以在交易中設置 maxFeePerGas 來保證最終支付的費用是受限的。

如果要保證交易成功,那麼就需要保證 maxFeePerGas ≥ Base Fee + Priority Fee,否則交易會執行失敗,並且費用也不會退換。用戶需要實際支出的費用為 (Base Fee+Priority Fee)×Gas Used,多餘的費用會退還給交易發起的地址。

Base Fee 處在動態變化中,以區塊中 Gas 的實際用量為基準,區塊最大 Gas 限制的一半稱之為 target,如果上一个區塊的實際用量超過了 target,那麼當前的區塊的 Base Fee 就會增加,如果前一個區塊的 Gas 用量低於 target,那麼 Base Fee 就會減少,否則就不變。

Blob 交易費用機制

Blob 交易的費用結算分為兩部分,一部分是使用 EIP-1559 來和其他交易共同調整 Base Fee,另一部分是 Blob 交易中的 Blob 數據有一個獨立的 Blob Fee 機制,其中 target 值是最大 Blob 數量的一半,也是根據 Blob 數據塊的使用量來調整 Blob Fee,但是不單獨設置 Priority Fee,因為 Blob 交易也可以直接設置交易中的 Priority Fee 來促使 Blob 交易被更快打包。

6. 交易處理流程源碼分析

在上面詳細介紹了以太坊中交易機制的設計及實現,接下來,我們將通過分析代碼,詳細介紹交易在 Geth 中具體實現,包括交易在整個生命周期中的處理流程。

交易提交

無論是通過 SendTransaction 還是 SendURawTransaction 的方式提交交易,都会調用 internal/ethapi/api.go 中的 SubmitTransaction 函數向交易池提交交易。

在這個函數中,會對交易做兩個基本的檢查,一個是檢查 gas fee 是否合理,另一個是檢查是交易是否滿足 EIP-155 的規範,EIP-155 通過在交易簽名中引入 chainID 參數,解決了跨鏈交易重放問題。該檢查確保當節點配置開啟 EIP155Required 時,所有向交易池提交的交易都必須符合這個標準。

在完成檢查之後,就會把交易提交到交易池,由 eth/api_backend.go 中的 SendTx 來實現添加邏輯:

在交易池中,會通過 Filter 方法來為交易匹配對應的交易池,當前有兩個交易池實現,如果是 blob 交易,那麼就會放入BlobPool,否則放入LegacyPool:

到這裡,EOA 提交的交易就已經被放入交易池,這個交易就會開始在交易池中傳播,進入後續的交易打包和執行流程。

如果在交易打包之前,重新發送了一筆交易,新的交易設置了新的 gasPrice 和 gasLimit,就會把原來交易池中的交易刪除,替換成了新的 gasPrice 和 gasLimit 之後重新返回到交易池中。這種方式也可以用來取消不想執行的交易。

func (api *TransactionAPI) Resend(ctx context.Context, sendArgs TransactionArgs, gasPrice *hexutil.Big, gasLimit *hexutil.Uint64) (common.Hash, error) {
if sendArgs.Nonce == nil {
return common.Hash{}, errors.New("missing transaction nonce in transaction spec")
}
if err := sendArgs.setDefaults(ctx, api.b, false); err != nil {
return common.Hash{}, err
}
matchTx := sendArgs.ToTransaction(types.LegacyTxType)
// Before replacing the old transaction, ensure the new transaction fee is reasonable.
price := matchTx.GasPrice()
if gasPrice != nil {
price = gasPrice.ToInt()
}
gas := matchTx.Gas()
if gasLimit != nil {
gas = uint64(*gasLimit)
}
if err := checkTxFee(price, gas, api.b.RPCTxFeeCap()); err != nil {
return common.Hash{}, err
}
// Iterate the pending list for replacement
pending, err := api.b.GetPoolTransactions()
if err != nil {
return common.Hash{}, err
}
for _, p := range pending {
wantSigHash := api.signer.Hash(matchTx)
pFrom, err := types.Sender(api.signer, p)
if err == nil \&\& pFrom == sendArgs.from() \&\& api.signer.Hash(p) == wantSigHash {
// Match. Re-sign and send the transaction.
if gasPrice != nil \&\& (*big.Int)(gasPrice).Sign() != 0 {
sendArgs.GasPrice = gasPrice
}
if gasLimit != nil \&\& *gasLimit != 0 {
sendArgs.Gas = gasLimit
}
signedTx, err := api.sign(sendArgs.from(), sendArgs.ToTransaction(types.LegacyTxType))
if err != nil {
return common.Hash{}, err
}
if err = api.b.SendTx(ctx, signedTx); err != nil {
return common.Hash{}, err
}
return signedTx.Hash(), nil
}
}
return common.Hash{}, fmt.Errorf("transaction %#x not found", matchTx.Hash())
}

交易廣播

節點在接收到 EOA 提交的交易之後,需要在網絡中進行傳播,txpool(core/txpool/txpool.go )提供了 SubscribeTransactions 方法,可以訂閱交易池中的新事件,Blob 交易池和 Legacy 交易池實現訂閱的方式不一致:

func (p *TxPool) SubscribeTransactions(ch chan\<- core.NewTxsEvent, reorgs bool) event.Subscription {
subs := make([]event.Subscription, len(p.subpools))
for i, subpool := range p.subpools {
subs[i] = subpool.SubscribeTransactions(ch, reorgs)
}
return p.subs.Track(event.JoinSubscriptions(subs…))
}

BlobPool 區分了兩種事件源:

  • discoverFeed :僅包含新發現的交易

  • insertFeed :包含所有交易,包括因重組而重新進入池的交易

func (p *BlobPool) SubscribeTransactions(ch chan\<- core.NewTxsEvent, reorgs bool) event.Subscription {
if reorgs {
return p.insertFeed.Subscribe(ch)
} else {
return p.discoverFeed.Subscribe(ch)
}
}

LagacyPool 不區分新交易和重組交易,它使用單一的 txFeed 來發送所有交易事件。

func (pool *LegacyPool) SubscribeTransactions(ch chan\<- core.NewTxsEvent, reorgs bool) event.Subscription {
return pool.txFeed.Subscribe(ch)
}

總的來說, SubscribeTransactions 通過事件機制將交易池與其他組件解耦,這個訂閱可以被多個模塊使用,比如交易廣播、交易打包以及對外 RPC 都需要監聽這個流程,然後做出對應的處理。

同時 p2p 模塊 (eth/handler.go) 會持續監聽新交易事件,如果接收到了新交易,那麼就會發送廣播,將交易廣播出去:

// eth/handler.go 在產生新交易之後,會通過 p2p 網絡廣播出去
func (h *handler) txBroadcastLoop() {
defer h.wg.Done()
for {
select {
case event := \<-h.txsCh: // 這裡監聽新交易信息
h.BroadcastTransactions(event.Txs)
case \<-h.txsSub.Err():
return
}
}
}

在廣播交易時,需要對交易進行分類,如果是 blob 交易或者是超過了一定大小的交易,無法直接傳播,對於普通的交易,則標記為可以直接傳播。然後從當前節點的對等節點中去找那些沒有這筆交易的節點。如果節點可以直接廣播,則標記為 true,這個過程也是在 BroadcastTransactions 方法中實現的:

依照上面的原則對交易分類完成,可以直接傳播的交易就直接發送,blob 交易或者大交易則只廣播 hash,等到需要用到這筆交易的時候再來獲取。

廣播中只發送 hash 的交易會被放到 peer 節點的這個字段中:

新交易會通過 p2p 模塊廣播出去,同時也會從 p2p 網絡中接收新的交易。在 eth/backend.go 中初始化 Ethereum 實例時,會初始化 p2p 模塊,添加交易池的接口。p2p 模塊運行起來之後,會從 p2p 消息中解析出交易請求添加到交易池中。

具體來說,在實例化 handler 的時候,會指定好從其他節點獲取交易的方式,會通過 eth/fetcher 中的 TxFetcher 去獲取遠端的交易,TxFetcher 會通過這裡的 fetchTx 方法去獲取遠端的交易,實際是調用 eth/protocols/eth 協議中實現的 RequestTxs 方法去獲取交易:

// eth/backend.go New 函數
if eth.handler, err = newHandler(\&handlerConfig{
NodeID: eth.p2pServer.Self().ID(),
Database: chainDb,
Chain: eth.blockchain,
TxPool: eth.txPool,
Network: networkID,
Sync: config.SyncMode,
BloomCache: uint64(cacheLimit),
EventMux: eth.eventMux,
RequiredBlocks: config.RequiredBlocks,
}); err != nil {
return nil, err
}
// eth/handler.go newHandler 函數,註冊獲取新交易的過程
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.txpool.Has, addTxs, fetchTx, h.removePeer)
// eth/handler_eth.go Handle 方法,在接收到新的交易之後,會添加到交易池中
for _, tx := range *packet {
if tx.Type() == types.BlobTxType {
return errors.New("disallowed broadcast blob transaction")
}
}
return h.txFetcher.Enqueue(peer.ID(), *packet, false)
// eth/fetcher/tx_fetcher.go 的 Handle 方法會調用上面註冊的 addTxs 來將講義添加到交易池
for j, err := range f.addTxs(batch) {
//….
}

RequestTxs 方法通過發送 GetPooledTransactionsMsg 消息,然後收到其他節點發送的 PooledTransactionsMsg 的響應,由 backend 中的 Handle 方法來處理,在這個方法中調用 txFetcher 的 Enqueue 方法,最終 Enqueue 方法調用的 adTxs 方法把從其他節點獲取的交易添加到交易池:

在交易池中還有一個延遲加載的設計,通過core/txpool/subpool.go中的 LazyTransaction 來實現,通過延遲加載機制減少內存使用並提高交易處理效率。它存儲交易的關鍵元數據,只在真正需要時才加載完整交易數據,在以太坊處理大量交易時發揮著重要作用。這種設計特別適合交易池和區塊打包這樣的場景,其中大多數交易可能最終不會被包含在區塊中,因此不需要完整加載所有交易數據。

type LazyTransaction struct {
Pool LazyResolver // Transaction resolver to pull the real transaction up
Hash common.Hash // Transaction hash to pull up if needed
Tx *types.Transaction // Transaction if already resolved
Time time.Time // Time when the transaction was first seen
GasFeeCap *uint256.Int // Maximum fee per gas the transaction may consume
GasTipCap *uint256.Int // Maximum miner tip per gas the transaction can pay
Gas uint64 // Amount of gas required by the transaction
BlobGas uint64 // Amount of blob gas required by the transaction
}
func (ltx *LazyTransaction) Resolve() *types.Transaction {
if ltx.Tx != nil {
return ltx.Tx
}
return ltx.Pool.Get(ltx.Hash)
}

另外由於以太坊是一個 perminssionless 的網絡,節點有可能會從網絡中接收到一些惡意的請求,極端情況下,節點可能會面臨 DDos 攻擊,所以節點會使用一系列的方式來防止來自網絡的惡意攻擊:

  • 交易基礎驗證

  • 節點資源限制

  • 交易驅逐機制

  • p2p 網絡層防護

這裡以 Legacypool 為例(Blobpool 也有類似的機制),在交易被添加進交易池之前,首先會經過一個基礎的驗證,在 core/txpool/validation.go 中的 ValidateTransaction 方法中,會對交易做一個基礎的驗證,包括交易類型、交易大小、Gas 等是否符合要求,如果不符合,就會拒絕接收交易。

這裡的交易大小使用 Slot 來規定,在 core/txpool/legacypool/legacypool.go 中定義了 Slot:

const (
txSlotSize = 32 * 1024
txMaxSize = 4 * txSlotSize // 128KB
)

每個交易不能超過 4 個 Slot,而且對於每個帳戶、整個節點都有最大 Slot 的限制,對於帳戶,達到限制之後就不能提交新的交易。對於節點,達到限制之後就需要剔除舊的交易,在core/txpool/legacypool/legacypool.go中的 truncatePending 方法中會公平驅逐交易,防止單個帳戶佔用過多交易池資源:

type Config struct {
AccountSlots uint64
GlobalSlots uint64
}

在網絡層上,對於 blob 交易或者是超過了一定大小的交易,不會直接在網絡上傳播交易內容,只會傳播交易 Hash,從而避免網絡上傳播的數據量過大,造成 DDos 攻擊。

交易打包

在交易提交到交易池之後,會在以太坊網絡中的節點之間傳播,在某個節點的驗證者被選中為出塊節點之後,驗證者就會委託共識層和執行層構造區塊。

驗證者會首先從共識層觸發區塊構造流程,共識層在接收到區塊構造請求之後,就會調用執行層的 engineAPI 來構造區塊,engineAPI 的實現在 eth/catalyst/api.go 。共識層會先調用 ForkchoiceUpdated API 來發送構造區塊的請求,ForkchoiceUpdated 有多個版本,具體調用哪個版本依據當前網絡版本來決定,調用完成之後會返回 PayloadID,然後根據這個參數調用 GetPayload 對應版本 API 來獲取區塊的構造結果。

無論調用的是 ForkchoiceUpdated 的哪個版本,最終都是調用 forkchoiceUpdated 方法來構造區塊:

在 ForkchoiceUpdated 方法中會對執行層當前的狀態做校驗,如果當前執行層正在同步區塊、或者獲得最終性的區塊不符合預期,那麼該方法都會向共識層直接返回錯誤信息,構造區塊失敗:

在對執行層的信息校驗完成之後,就會調用 miner/miner.go 中的 BuildPayload 方法來構造區塊。構造區塊的具體操作都在 miner/payload_building.go 中的 generateWork 方法中完成,但這裡需要注意,在調用這個方法之後,就會先產生了一個空的 payload,並把這個 payloadID 返回給共識層。同時會啟動一個 goroutine 真正去完成區塊的打包流程,這個 goroutine 會持續去交易池中找價值更高的交易,每次重新打包交易之後,會更新 payload。

打包交易的是通過 miner/worker.go 中的 fillTransactions 方法來完成,實際上就是調用 txpool 的 Pending 方法來獲取待打包的交易:

共識層在 slot 結束之前會調用 getPayload API 來獲取最終打包好的區塊。如果提交的交易被打包在這個區塊當中,那麼交易就會被 EVM 執行,並改變狀態數據庫。如果這次沒有被打包,那麼就會等待下一次被打包。

交易執行

在打包交易的過程中,同時也會將交易在 EVM 中執行,得到區塊交易完成之後狀態的變化,同樣還是在 generateWork 函數中,準備好當前區塊執行的環境變量,主要是獲取最新區塊和最新狀態數據庫:

這裡的 state 就是代表狀態數據庫:

在這裡形成了一個 StateDB → stateObjects→ stateAcount 的結構,分別代表完整的狀態數據庫、帳號對象集合以及單個帳戶對象。其中 StateObject 中結構中,dirtyStorage 表示當前交易執行後已改變的狀態,pendingStorage 表示當前區塊執行之後已改變的狀態,originStorage 表示原始的狀態,所以這三個狀態從新到舊是 dirtyStorage → pendingStorage → originStorage,這裡關於存儲的詳細解析可以查看之前關於存儲的詳細解析:

在eth/backend.go的 New 方法中啟動時會加載交易池的配置,其中有一個 Locals 的配置,這個配置中的地址會被視為本地地址,這些本地地址提交的交易會被優先處理。

在獲取到當前的環境變量之後,就可以執行交易了,首先會獲取全部待打包的交易,並把其中的本地交易挑選出來,區分成本地交易和正常交易,然後會對本地交易和正常交易分別按照手續費從高到低打包交易。交易具體的執行都在miner/worker.go中 commitTransactions 方法中進行:

最終都是調用 ApplyTransaction 函數,在這個函數中,會調用 EVM 執行交易,並修改狀態數據庫:

func ApplyTransaction(evm *vm.EVM, gp *GasPool, statedb *state.StateDB, header *types.Header, tx *types.Transaction, usedGas *uint64) (*types.Receipt, error) {
msg, err := TransactionToMessage(tx, types.MakeSigner(evm.ChainConfig(), header.Number, header.Time), header.BaseFee)
if err != nil {
return nil, err
}
// Create a new context to be used in the EVM environment
return ApplyTransactionWithEVM(msg, gp, statedb, header.Number, header.Hash(), tx, usedGas, evm)
}

交易驗證

上面討論的情況都是交易被打包進區塊的流程,大多數情況下,節點只會驗證已經被打包好的區塊,而不是自己打包區塊。

共識層在同步到區塊之後,使用 engine API 將同步到的最新區塊傳輸到執行層。使用的是 engine_NewPayload 系列方法。這系列的方法最後都會調用 newPayload 方法,在這個方法中將共識層的 payload 組裝成一個 block:

然後檢查這個區塊是否已經存在了,如果存在了,那麼就直接返回取消有效的狀態:

如果當前執行層還在同步狀態,那麼暫時就無法接收新的區塊:

如果上面的條件都滿足,那麼就開始將區塊插入到區塊鏈中,這裡需要注意,在插入區塊的時候不會直接指定鏈頭,因為鏈頭的決策會涉及到鏈分叉的選擇,這個需要依靠共識層來決定:

共識層會調用 forkChoiceupdated API 來調用core/blockchain.go中的SetCanonical方法來決定區塊頭:

還有一種情況會觸發區塊頭的設置,那就是區塊發生重組,區塊重組會執行 core/blockchain.goreorg 方法,在這個方法中同樣會設置當前最新確定的區塊頭。

回到區塊的執行過程,在 core/blockchain.go 中的 InsertBlockWithoutSetHead 方法會調用 insertChain 方法,在這個方法中,會做一系列條件的檢查,檢查完成之後,就會開始處理區塊:

在具體的 Process 裡面,處理邏輯就很清晰了,和之前打包交易的流程類似,不斷在 evm 中執行交易,然後修改狀態數據庫,與打包不同的地方在於,這裡只是將新区塊中的交易重放一遍,而不需要去交易池中獲取。

7. 總結

交易是驅動以太坊狀態變化的唯一方式,交易在以太坊中的處理需要經過多個階段。交易需要先經過驗證,再被提交到交易池,在不同的節點之間通過 p2p 網絡傳播,然後被出塊節點打包進區塊,最後其他節點同步區塊,在本地執行區塊中的交易,並同步狀態變更。

隨著以太坊協議的不斷發展,從最開始只能支持一種交易,到目前可以支持 5 種交易。這些不同類型的交易可以讓以太坊適應不同的角色,及可以作為 DApp 的運行平台,也可以作為 Layer2 或者其他鏈下擴容的結算層。最近新增加的 EIP-7702 為以太坊被大規模採用做好了技術上的準備。

Ref

[1]https://ethereum.org/zh/developers/docs/transactions/

[2]https://hackmd.io/@danielrachi/engine_api

[3]https://github.com/ethereum/go-ethereum/commit/c8a9a9c0917dd57d077a79044e65dbbdd421458b

[4]https://pumpthegas.org/

[5]https://github.com/ethereum/EIPs/pull/9698

關聯標籤
warnning 風險提示
app_icon
ChainCatcher 與創新者共建Web3世界