「我們上次看過了一筆最簡單的交易」特務 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 想辦法拼湊資訊,將解碼後的資料對應到原始輸入。
a9059cbb
,>>> 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 樹鄰居,適當的帶入雜湊函式,可以從帳戶一路導出樹根。但小雨決定改天再來玩這個。今天的重點是看到帳戶餘額。小雨先展示了一下帳戶中的位元組碼,白花花的亂碼傾瀉而出。
> 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 億多個帳戶的那個數字。他覺得,在哪些帳戶裡面,在某些儲存區的鍵值裡面,記錄他內心永恆的無助。