iT邦幫忙

2025 iThome 鐵人賽

DAY 9
1

「我們上次看過了一筆最簡單的交易」特務 K 說「我們有辦法看一筆比較複雜的嗎?」

小雨從上次的區塊裡面,找到了一筆 代幣的交易 0xc7c4...69cc

儀表板上相關的欄位如下。一筆從 Kraken 交易所發出的交易,與 USDT 代幣合約互動,轉送 22.8 USDT 代幣到 0x35...377D 這個帳戶。

From                        Kraken 7        (地址為 0x89e51fA8CA5D66cd220bAed62ED01e8951aa7c40)
Interacted with contract    USDT Stablecoin (地址為 0xdAC17F958D2ee523a2206206994597C13D831ec7)
Tokens transferred          Kraken 7    -> 0x35...377D  for 22.8 USDT ($22.8)

「我們前面談過了原生代幣,那應該是系統本有的,那自訂代幣是怎麼制定的呢?」特務 K 問。

「原生代幣是每個帳戶都會有個以太幣餘額欄位。自訂代幣則是代幣發行人要自行部署一個合約」小雨回答「最常見的就是 ERC20 標準的代幣。」

「什麼是合約呢?」

「一個部署到以太坊帳戶的程式」小雨說「我們現在來看看這筆交易是怎麼和代幣合約互動的」

儀表板底下有個 "View Details" 的虛線文字,點下去後,出現更多訊息。原始輸入(Raw input)欄位寫著

0xa9059cbb00000000000000000000000035728b27c11c38df27bccf1a00c41b570965377d00000000000000000000000000000000000000000000000000000000015be680

解碼後的輸入資料(Decoded input data)欄位寫著:

Method id   a9059cbb
Call        transfer(address _to, uint256 _value)
Name Type Data
_to address 0x35728B27c11c38Df27bcCF1A00c41B570965377D
_value uint256 22800000

特務 K 想辦法拼湊資訊,將解碼後的資料對應到原始輸入。

  • 開頭是 Method id 的 a9059cbb
  • 再接著一串 0 ,然後是收受方的地址 0x35...377D 這個帳戶。
  • 再接著一串 0 ,然後是 15be680 ,看起來像十六進位。換算之後(他直接在 Python 裡面輸入 >>> 0x15be680)得到 22800000 ,這是轉帳金額 22.8 USDT 的 1,000,000 倍,差六個零。

他需要開始問小雨問題了。

「解編碼之後的資訊告訴我們什麼事情呢?」

「這筆交易背後的運作,是呼叫代幣合約的 transfer 函式,把代幣合約裡面記載的發送方餘額減去轉帳金額」小雨說「然後在收受方的餘額加上轉帳金額」

「原始輸入資料裡面沒看到 transfer 耶?」

「這是合約程式語言 Solidity 的慣例,他會在合約程式建立函式的 選擇器(Selector) 」小雨說「這樣交易能夠告訴合約要呼叫哪個函式」

「怎麼把函式變成選擇器呢?」

「主要是把函式介面(Function Signature)做雜湊之後,選 4 個位元組 」小雨說「所以部署的合約只需要 4 個位元組來指稱編譯過的函式,交易也只需要 4 個位元組來指稱函式,會比使用全名省資料」

小雨很快地用 https://playground.ethers.org/ 示範怎麼把 transfer 變成 a9059cbb

> keccak256(toUtf8Bytes("transfer(address,uint256)")).substring(null, 10)
"0xa9059cbb"

特務 K 看了函式介面,第一個參數要放地址,也就是收受方的地址。第二個放轉帳金額。

「為什麼轉帳金額差 6 個零呢?」

「ERC20 標準是用整數記錄餘額,然後再記錄要把小數點搬幾個零。大部分的代幣合約都是遵從以太幣的慣例,搬 18 個零。 USDT 是少數的例外,搬 6 個零。」小雨說「所以實際記載的轉帳數字是 22800000 ,但要往左搬六個零詮釋為 22.8 USDT 。」

小雨又從節點用 geth attach 的控制台叫出了同樣一筆交易。

> eth.getTransaction("0xc7c42e6a5e299287f09f9338ea8e233543984beda9f57d8016b44d9d24bf69cc")
{
  accessList: [],
  blockHash: "0xb0f2f8b10e60f69e1d7f96c1646ac4fe4dd7af6cca5c2d5e0f2e559c37426fbc",
  blockNumber: 23231122,
  chainId: "0x1",
  from: "0x89e51fa8ca5d66cd220baed62ed01e8951aa7c40",
  gas: 500000,
  gasPrice: 2403306824,
  hash: "0xc7c42e6a5e299287f09f9338ea8e233543984beda9f57d8016b44d9d24bf69cc",
  input: "0xa9059cbb00000000000000000000000035728b27c11c38df27bccf1a00c41b570965377d00000000000000000000000000000000000000000000000000000000015be680",
  maxFeePerGas: 3723719548,
  maxPriorityFeePerGas: 2000000000,
  nonce: 3268530,
  r: "0xafed8f97e92ff37f540450c6ab757f90bebf6740bb324b24c04e60ac09ac55f0",
  s: "0x516b15a06f0aa97f990e91141ea846e5c917d39ef29c7ef18935da032eaecc8f",
  to: "0xdac17f958d2ee523a2206206994597c13d831ec7",
  transactionIndex: 43,
  type: "0x2",
  v: "0x0",
  value: 0,
  yParity: "0x0"
}

特務 K 觀察到:雖然這筆交易的意圖,是 0x89e5...a7c40 這位發送者,想轉移 22.8 USDT 代幣給 0x35...377D 。但端從這筆交易來看, from 欄位確實是 0x89e5 , to 欄位卻是 USDT 的合約地址 0xdac1...1ec7 。交易是往合約送的!

「對,早期儀表板確實忠實把 to 欄位標記為合約,讓使用者感到混淆,但現在都把 to 欄位改名為 Interacted with contract 了」小雨說「並且會把代幣轉帳的內容標注清楚。」

以太坊帳戶

地址、餘額、合約程式碼、儲存區,全部都在帳戶裡。小雨心想是時候來看一個了。

Geth 上面使用 getProof 函式可以取得一個帳戶的雜湊樹證明。

「這是 USDT 的合約帳戶」小雨說

> eth.getProof("0xdac17f958d2ee523a2206206994597c13d831ec7", null, "latest")
{
  accountProof: [...], // 省略以免佔文章空間
  address: "0xdac17f958d2ee523a2206206994597c13d831ec7",
  balance: "0x1",
  codeHash: "0xb44fb4e949d0f78f87f79ee46428f23a2a5713ce6fc6e0beb3dda78c2ac1ea55",
  nonce: "0x1",
  storageHash: "0x82643ae7869c47c9d0649cdeb23509ee5fdef4bc8cf228635ae53100235fbad1",
  storageProof: []
}

兩人湊近端詳

  • accountProof 的地方是一層層的 MPT 樹鄰居,適當的帶入雜湊函式,可以從帳戶一路導出樹根。但小雨決定改天再來玩這個。今天的重點是看到帳戶餘額。
  • address 欄位是 USDT 的地址
  • balance 欄位記載以太幣的餘額。
  • codeHash 欄位是程式碼的雜湊值。用 Solidity 語言撰寫的原始碼,會先編譯為 EVM 位元組碼(Bytecode),然後部署到某個合約帳戶中。帳戶本身會記載位元組碼的雜湊值。
  • storageHash 是儲存區。這是用 MPT 樹儲存的鍵值資料,這邊存的是雜湊樹根。如果 getProof 函式有註明想證明哪個儲存值的存在, storageProof 會提供相關的資料以供計算雜湊樹證明。

小雨先展示了一下帳戶中的位元組碼,白花花的亂碼傾瀉而出。

> eth.getCode("0xdac17f958d2ee523a2206206994597c13d831ec7")
"0x60606040...bb280029" // 省略以免佔文章空間

特務 K 比對了一下 瀏覽器的 Deployed Bytecode 欄位中的頭尾,和輸出值是相同的 。

「我們來找交易雙方 USDT 代幣的餘額」小雨說。

瀏覽器中顯示合約裡的 balanceOf 函式,是 ERC20 合約中會實作的函式,可以看到合約中每個帳戶相對應的餘額。

帳號                                         餘額
0x89e51fa8ca5d66cd220baed62ed01e8951aa7c40  (uint256) : 133699359628499
0x35728B27c11c38Df27bcCF1A00c41B570965377D  (uint256) : 22800000

因為可能收發交易的雙方都會再度轉帳。讀者看到這份報告的時候,瀏覽器上看到的餘額已經不一樣了。

「ERC20 代幣會用鍵值方式記錄代幣帳戶餘額:鍵是地址,值是 256 位元的無符號整數的餘額」小雨補充。

探索儲存區

「我們來找找 balanceOf 函式看到的代幣餘額,實際儲存在帳戶儲存區的哪裡」小雨說

「我們提到了這個儲存區幾次,但他的用途是什麼呢?」特務 K 問。

「放在儲存區的值,是需要永久保存的資料,就像我們現在看到的代幣餘額一樣」小雨說。

「永久保存在哪呢?」

「全節點中」

「這樣節點會不會某天塞爆?」

「可能會,所以要把東西放到儲存區的燃氣定價非常昂貴」小雨說「此外,有些已經沒人用的合約,儲存區還佔著一堆空間,而全節點需要把它們保存到永遠。」

「人們是用什麼心態看待那些沒用但又需要保存的資料呢?」

「畢竟區塊鏈想給的理想是永不掉資料、永不停下來的機器」小雨說「總不能一筆在鏈上的存款,過了幾年突然不見對吧」

「聽起來,這個理想總有一天是要和現實衝突的」

「儲存區、或全域狀態的成長與永續經營的確是以前曾經激烈辯論的議題」

特務 K 拿出他的筆記本,那些日後待討論的議題又多了一項。

「在 Solidity 程式碼裡面,開發者可能會定義一些需要儲存的變數,但最後都需要分享同一個儲存區」小雨回到正題「Solidity 有定義一個有系統的 佈局規則,讓程式碼可以找到對的儲存區讀寫。」

特務 K 掃了掃佈局規則的文件,不是很好懂。但知道要在儲存區找東西,要先變出正確的「鍵」,就能找到對應的「值」。

小雨也是第一次做這件事,看了看文件。ERC20 代幣的餘額用 Solidity 的 mapping 資料型態,可以記錄鍵值格式。但 mapping 的鍵要變成儲存區的鍵,需要知道這個 mapping 是第幾個宣告的變數,我們把這個「第幾個」命名為 p。最後儲存區的鍵和 p 雜湊在一起,就是儲存區的鍵了。

因為 ERC20 代幣的餘額通常是最早定義的幾個變數之一,從 0 逐個試一下就能找到 p 。

小雨先把收受方地址與要測試的 p 雜湊在一起。然後到 geth 主控台看能不能在帳戶儲存區找到非零的值。

// ether.js
> solidityPackedKeccak256(["uint256", "uint256"], [ "0x35728B27c11c38Df27bcCF1A00c41B570965377D", 2])
"0x0c2bb01b114686909154998187b98a0389d96052e91fd245a05b326af4a91b66"
// Geth console
> slot_hash = "0x0c2bb01b114686909154998187b98a0389d96052e91fd245a05b326af4a91b66"
> eth.getStorageAt("0xdac17f958d2ee523a2206206994597c13d831ec7", slot_hash)
"0x00000000000000000000000000000000000000000000000000000000015be680" // 22800000

「找到 p 了,是 2」

同樣的也能找到發送方的儲存區鍵與其記載的 USDT 代幣餘額

// // ether.js
> solidityPackedKeccak256(["uint256", "uint256"], [ "0x89e51fa8ca5d66cd220baed62ed01e8951aa7c40", 2])
"0xf94b05ba7d0a6aa6692eecf3cf30c6ca5d527c3e5fd03d7081ff14e4910ec033"
// geth console
> slot_hash = "0xf94b05ba7d0a6aa6692eecf3cf30c6ca5d527c3e5fd03d7081ff14e4910ec033"
> eth.getStorageAt("0xdac17f958d2ee523a2206206994597c13d831ec7", slot_hash)
"0x0000000000000000000000000000000000000000000000000000448154910e83" // 7,532,226,262,531

總結

特務 K 回想,這次看了一筆比較複雜的交易,是代幣交易。

代幣的發行人事先在合約帳戶部署了 Solidity 編譯後的位元組碼,稱為合約。

合約用合約帳戶的儲存區,用鍵值方式記載了代幣持有人的地址及其持有的餘額。

當持有人想轉帳,就對合約帳戶發一筆交易,呼叫合約中的 transfer 函式,並註明收受人與想轉移的代幣金額。

交易執行時,合約程式會增減儲存區發送方、收受方的持有餘額。

就如小雨一開始所說,運算是可以把資料存下來、讀出來、算一算、再存回去。

特務 K 深吸一口氣。他望著窗外的夜空與儀表板首頁上 4.4 億多個帳戶的那個數字。他覺得,在哪些帳戶裡面,在某些儲存區的鍵值裡面,記錄他內心永恆的無助。


上一篇
人可以撞衫 資料不能撞號
下一篇
一板一眼的電腦
系列文
那個有好多好多節點的電腦調查報告10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言