🔥 距離 Gate.io WCTC S7 正式開賽僅剩 7 天
世界加密貨幣交易大賽即將開啓,總獎池高達 $5,000,000
👉🏻 立即報名:https://gate.io/competition/wctc/s7?pid=APP&c=moments_gatePost&ch=druYjDaF
報名參賽,不僅有機會贏取高達百萬美元的個人獎勵,更有 Gate.io 專屬週邊大禮等你來拿
全球頂尖交易員正在集結,一場交易盛宴即將開啓
🔗 活動詳情: https://www.gate.io/announcements/article/44440
深入探討EVM 數據結構、交易收據與事件日誌
**撰文:**NOXX
編譯:Flush
對於任何希望了解Web3 領域的人來說,瀏覽鏈上數據是一項基本技能。了解構成區塊鏈的數據結構有助於我們思考創造性的方法來解析這些數據。同時,這些鏈上數據構成了可用數據的很大一部分。此篇將深入研究EVM 中的一個關鍵數據結構,交易收據和其相關的事件日誌。
為什麼使用日誌
開始之前,我們先簡單地聊聊作為solidity 的開發者,為什麼需要使用事件日誌:
EVM 節點不需要永久保留日誌,可以通過刪除舊日誌來節省空間。合約無法訪問日誌存儲,因此節點不需要它們來執行合約。另一方面,合約存儲是執行所必需的,因此無法刪除。
以太坊區塊默克爾根
在第4 部分,我們深入探討了以太坊框架,尤其是狀態默克爾根部分。狀態根(State Root)是區塊頭中所包含的三個默克爾根之一。另外兩個是Transaction Root 和Receipt Root。
為了輸入構建這個框架,我們將參考以太坊上的區塊15001871,其中包含5 個交易及其相關收據和發送的事件日誌。
區塊頭
我們將從區塊頭中的3 個部分開始, Transaction Root,Receipt Root 和Logs Bloom(對區塊頭的簡單介紹可以在第4 部分中回顧)。
圖源:
在Transaction Root 和Receipt Root 下的以太坊客戶端中,Merkle Patricia Tries 包含該區塊內所有交易數據和收據數據。而本文將只關注於節點可以訪問的所有交易和收據。
通過以太坊節點查詢到15001871 區塊的區塊頭信息如下:
區塊頭中的logsBloom 是一個關鍵的數據結構,將在本文後面提到。首先讓我們從位於Transaction Root 下的數據開始,即Transaction Trie。
交易樹Transaction Trie
Transaction Trie 是生成transactionsRoot 並記錄交易請求向量的數據集,交易請求向量是執行一個交易所需的信息片段,一個交易包含的數據字段如下:
在了解了以上數據字段之後,讓我們來看一下15001871 區塊的第一筆交易
通过 Geth 的 ethclient 查询,可以看到 ChainId 和 AccessList 都有 "omitempty",这意味着如果该字段为空,则将在响应中被省略,以减少或甚略序列化后数据的大小。
代碼源:
這筆交易表示將USDT 代幣轉移到0xec23e787ea25230f74a3da0f515825c1d820f47a 地址。 To 地址是ERC20 USDT 的合約地址0xdac17f958d2ee523a2206206994597c13d831ec7。通過input data 我們可以看到函數簽名0xa9059cbb 對應為函數transfer(address,uint256),將數量為0x2b279b8(十六進制的45251000)的42.251 枚USDT(精度為6)轉到0xec23e787ea25230f74a3da0f515825c1d820f47a 地址。
你可能注意到這個交易數據結構並沒有告訴我們任何關於交易結果的信息,那麼交易成功了嗎?它消耗了多少gas?又觸發了哪些事件記錄呢?此時我們將引入Receipt Trie。
收據樹Receipt Trie
如同購物收據會記錄交易的結果一樣, Receipt Trie 中的一個對象為以太坊交易做同樣的事情而且還會記錄一些額外細節。回到上面提出有關交易收據的問題,我們將重點關注觸發了以下事件的日誌。
再次查詢0x311b 的鏈上數據並獲取其交易收據,此時將獲取到以下字段:
代碼源:
現在我們知道了交易收據的組成,接下來進一步了解交易收據中的logsBloom 和日誌數組logs。
事件日誌Event Logs
通過以太坊主網上的USDT 合約代碼,可以看到Transfer 事件在合約的第86 行進行了聲明,其中2 個輸入參數均有關鍵字"indexed"。
(代碼源:
當事件輸入被「索引」 時,它可以讓我們通過該輸入快速查找日誌。例如,使用上面的索引"from"時,可以在區塊X 和Y 之間獲取所有"from"地址為0x5041ed759dd4afc3a72b8192c143f72f4724081a 的Transfer 類型的事件日誌。我們還可以看到,在第138 行調用transfer 函數時,會觸發事件日誌。值得注意的是,當前合約使用的solidity 版本較早,因此缺少emit 關鍵字。
再回到獲取到的鏈上數據:
代碼源:
讓我們更深入其中的address、topics 和data 字段。
主題Topics
Topics 是索引值。從上圖能看到鏈上查詢的數據中有3 個topics 的索引參數,而Transfer 事件只有2 個索引參數(from 和to)。這是因為第一個topics 始終是事件的函數簽名哈希值。當前事例中的事件函數簽名是Transfer(address,address,uint256)。通過對其進行keccak256 哈希,得到結果ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef。
(在線工具:
當我們按照之前講到的對from 字段進行查詢,但同時又想限制查詢的事件日誌類型僅為Transfer 類型的事件日誌時,就需要通過索引事件簽名來完成按事件類型進行過濾篩選的操作。
我們最多可以有4 個topics,每個topic 的大小為32 字節(如果索引參數的類型大於32 字節(即字符串和字節),則不會存儲實際數據,而是存儲數據的keccak256 摘要)。我們可以聲明3 個索引參數,因為第一個參數由事件簽名獲取。但存在有一種情況是第一個topic 不是哈希事件簽名。這種情況就是聲明匿名事件的時候。此時開啟了使用4 個而不是之前的3 個索引參數的可能性,但失去了對事件名稱進行索引的能力。匿名事件的另一個優點是它們的部署成本更低,因為它們不會強制使用1 個額外的topic。而其他topics 就是來自Transfer 事件的索引"from"和"to"的值。
數據Data
data 部分包含事件日誌中的剩餘(非索引)參數。在上述事例中存在一個value 值0x00000000000000000000000000000000000000000000000000000000002b279b8,其十進制為45251000,也就是前面提到的金額$45.251。如果還有更多這樣的參數,那麼它們將被附加到data 項中。下面的示例將展示超過1 個非索引參數的情況。
當前示例向Transfer 事件添加了一個額外的"tax"字段。假設設定的tax 為20%,那麼稅收值應該是45251000 * 20% = 9050200,其十六進制為0x8a1858,由於這個數的類型是uint256,而data 的類型為32 字節,則需要將十六進制值填充為32 字節,data 項結果為0x000000000000000000000000000000000000000000000000000000000002b279b8000000000000000000000000000000000000000000000000000000000008a1858。
地址Address
address 字段是發出事件的合約地址,有關這個字段的一個重要說明是,儘管它未包含在topic 部分中,它也將被索引。原因在於Transfer 事件是ERC20 標準的一部分,這意味著當需要過濾篩選ERC20 轉賬事件的日誌時,將從所有的ERC20 合約中獲取轉賬事件。而通過索引合約地址,可以將搜索範圍縮小到特定的合約/ 代幣,如示例中的USDT。
操作碼Opcodes
最後就是LOG 操作碼。它們從不包含topic 時的LOG0 到包含4 個topics 時的LOG4。 LOG3 是我們示例中使用的內容。包含如下:
(圖源:
offset 和length 定義數據在內存中位於data 部分的位置。
了解了log 的結構以及一個topic 是如何被索引後,我們再來了解索引項是如何被查找的。
Bloom 過濾器Bloom Filters
索引項能夠被更快查找的秘訣就是Bloom filter。
Llimllib 文章對這個數據結構有很好的定義與解釋。
「Bloom filter 是一個數據結構,它可以用來判斷某個元素是否在集合內,具有運行快速,內存佔用小的特點。而高效插入和查詢的代價就是,Bloom Filter 是一個基於概率的數據結構:它只能告訴我們一個元素絕對不在集合內或可能在集合內。Bloom filter 的基礎數據結構是一個比特向量。」
下面是一個比特向量的例子。白色單元格代表值為0 的位,綠色單元格代表值為1 的位。
通過採取一些輸入和哈希將這些比特位設置為1,由此產生的哈希值被用作哪個比特位上應被更新的位索引。上面的比特向量是對值"ethereum"使用2 個不同的哈希處理得到2 位索引的結果。哈希表示十六進制數,要獲得索引,可以取這個數字並將其轉換為0 到14 之間的值。有很多方法可以做到這一點,比如說mod 14。
回顧
有了一個用於交易的Bloom filter,也就是一個比特向量,可以在以太坊中通過哈希處理,確定要更新比特向量中的哪些位的輸入是地址字段和事件日誌的topic。讓我們回顧一下交易收據中的logsBloom,這是一個特定交易的Bloom filter。一個交易可以有多個日誌,其包含所有日誌的address / topic。
如果再向上回顧到區塊頭,會發現另一個logsBloom。這是該區塊內所有交易的Bloom filter。其中包含每個交易的每個日誌中的所有addresses / topics。
這些Bloom filters 用十六進製表示而不是二進制。它們的長度為256 字節,表示一個2048 位的向量。如果參考上面的Llimllib 示例,我們的位向量長度為15,位索引2 和13 翻轉記為1。若將其轉換為十六進制,來看看會得到什麼。
雖然十六進製表示得併不像是比特向量,但是在logsBloom 中就是如此。
查詢Queries
之前提到過一個查詢內容「查找區塊X 和Y 之間獲取所有"from"地址為0x5041ed759dd4afc3a72b8192c143f72f4724081a 的Transfer 類型的事件日誌」。我們可以獲取事件簽名topic,它表示類型Transfer 以及from(0x5041…)值的topic,並確定Bloom filter 中的哪些位索引應該設置為1。
如果在區塊頭中使用logsBloom,可以檢查到這些位中的任何一個是否未設置為1。如果不是,可以確定區塊中沒有符合該條件的日誌。而如果發現這些位已被設置,我們就會知道匹配的日誌可能就在塊中。但是不能完全確定,原因是區塊頭logsBloom 由多個地址和主題組成。其他的事件日誌可能已設置匹配位。這就是Bloom filter 是一種概率數據結構的原因。位向量越大,與其他日誌發生位索引衝突的可能性就越小。一旦有了匹配的Bloom filter,就可以使用相同的方法查詢各個收據的logsBloom。當獲取到匹配項時,可以查看實際的日誌條目來檢索對象。
按照上述操作在區塊X 到Y 執行,就可以快速查找和檢索符合標準的所有日誌。這也就是Bloom filter 從概念上講的工作原理。
現在來看看以太坊中使用的實現。
Geth 實現- Bloom Filters
我們了解了Bloom filter 是如何工作的,下面來學習Bloom filter 是如何從address / topic 到logsBloom 的一步步在一個實際的塊中完成篩選的。
首先從以太坊黃皮書的定義來看:
圖源:
「我們定義Bloom filter 函數為M,將日誌條目縮減到單個256 字節哈希中:
其中
是一個專門的Bloom filter,它在給定任意字節序列的情況下設置2048 中的三位。通過在字節序列的Keccak-256 哈希散列中獲取前三對字節中每對字節的低位11 位來實現這一點。 」
下面將提供一個示例和對Geth 客戶端實現的參考來簡化上述定義的理解。
這是我們在Etherscan 上面查看的交易日誌。
第一個topic 是事件簽名0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef 並以將這個值轉換為應更新的位索引。
以下是來自Geth 代碼庫的bloomValues 函數。
這個函數接收事件簽名topic,如:0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef 等數據,並返回需要在Bloom filter 中更新的位索引。
代碼源:
1)參考黃皮書片段,「字節序列的Keccak-256 哈希散列中的前三對字節」。這三對字節也就是6 個字節,即hashbuf 的長度。
2)示例數據:0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef。
1)使用keccak256 的sha 輸出的十六進制結果為(當使用keccak 256 作為函數簽名時,輸入是文本類型,而這裡是十六進制類型):ada389e1fc24a8587c776340efb91b36e675792ab631816100d55df0b5cf3cbc。
2)hasbuf 現有內容[ad, a3, 89, e1, fc, 24](十六進制)。每個十六進製字符代表4 位。
3計算v1。
1)哈希緩衝區 [1] = 0xa3 = 10100011 用於與0x7 按位與。 0x7 = 00000111。
2)一個字節由8 位組成,如果想獲得一個位索引,需要確保得到的值在零索引數組的0 到7 之間。使用按位與將hashbuf [1] 限制為0 到7 之間的值。示例中計算得,10100011 & 00000111 = 00000011 = 3。
3)該位索引值與位移運算符一起使用,也就是向左位移3 位,得到8 位字節索引00001000 ,以創建一個翻轉位。
4)v1 是整個字節而不是實際的位索引,因為這個值之後將在Bloom filter 上進行按位或運算。 OR 或運算將確保Bloom filter 中的所有相應位也被翻轉。
1)將hashbuf 通過big-endian uint16 字節序,這使其限制位數組的前2 個字節,也就是示例中的0xada3 = 1010110110100011。
2)將此值與0x7ff = 0000011111111111 進行按位與運算。其中0x7ff 設置為1 的位數有11 個。在黃皮書中有提到,「它通過獲取前三對中每一對的低11 位來實現這一點」。這將得到值0000010110100011,也就是1010110110100011 & 0000011111111111 的結果。
3)然後將這個值右移3 位。這會將11 位數字轉換為8 位數字。我們想要一個字節索引,而Bloom filter 的字節長度為256,因此需要字節索引值在該範圍內。而一個8 位的數正好可以是0 到255 之間的任何值。在我們的例子中,這個值是0000010110100011 右移3 位的結果10110100 = 180。
4)通過BloomByteLength 計算我們的字節索引,知道它是256 減去計算出的180,再減1。減1 是為了將結果保持在0 到255 之間。這給了我們要更新的字節索引,在這種情況下它結果是字節75,也就是我們計算出i1 的結果。
1)我們只涵蓋了第一個字節對0xada3,這是針對字節對2 和3 再次完成的。每個address / topic 將更新2048 位向量中的3 位。在黃皮書中提到,「Bloom filter 在給定任意字節序列的情況下設置2048 中的三位」。
2)字節對2 狀態更新字節195 中的位索引1(按照過程3,4 執行,結果如圖)。
3)字節對3 狀態更新字節123 中的位索引4。
4)如果要更新的位已經被另一個topic 翻轉,它將保持原樣。如果沒有,將翻轉為1。
通過上面的操作過程,可以確定事件簽名topic 將翻轉Bloom filter 中的以下位:
查看交易收據中的logBlooms,將其轉換為二進製文件,就可以驗證這些位索引是否已設置。
同時,對於那些有興趣深入了解更多關於日誌搜索和Bloom filter 的實現的讀者,可以參考BloomBits Trie 文章。
到此,我們深入探討EVM 系列文章告一段落,之後還會給大家帶來更多優質的技術文章。