翻譯的系列文章我已經放到了 GitHub 上:blockchain-tutorial,後續如有更新都會在 GitHub 上,可能就不在這裡同步了。如果想直接執行程式碼,也可以 clone GitHub 上的教程倉庫,進入 src 目錄執行 make
即可。
引言
到目前為止,我們所構建的原型已經具備了區塊鏈所有的關鍵特性:匿名,安全,隨機生成的地址;區塊鏈資料儲存;工作量證明系統;可靠地儲存交易。儘管這些特性都不可或缺,但是仍有不足。能夠使得這些特性真正發光發熱,使得加密貨幣成為可能的,是網路(network)。如果實現的這樣一個區塊鏈僅僅執行在單一節點上,有什麼用呢?如果只有一個使用者,那麼這些基於密碼學的特性,又有什麼用呢?正是由於網路,才使得整個機制能夠運轉和發光發熱。
你可以將這些區塊鏈特性認為是規則(rule),類似於人類在一起生活,繁衍生息建立的規則,一種社會安排。區塊鏈網路就是一個程式社群,裡面的每個程式都遵循同樣的規則,正是由於遵循著同一個規則,才使得網路能夠長存。類似的,當人們都有著同樣的想法,就能夠將拳頭攥在一起構建一個更好的生活。如果有人遵循著不同的規則,那麼他們就將生活在一個分裂的社群(州,公社,等等)中。同樣的,如果有區塊鏈節點遵循不同的規則,那麼也會形成一個分裂的網路。
重點在於:如果沒有網路,或者大部分節點都不遵守同樣的規則,那麼規則就會形同虛設,毫無用處!
宣告:不幸的是,我並沒有足夠的時間來實現一個真實的 P2P 網路原型。本文我會展示一個最常見的場景,這個場景涉及不同型別的節點。繼續改進這個場景,將它實現為一個 P2P 網路,對你來說是一個很好的挑戰和實踐!除了本文的場景,我也無法保證在其他場景將會正常工作。抱歉!
本文的程式碼實現變化很大,請點選 這裡 檢視所有的程式碼更改。
區塊鏈網路
區塊鏈網路是去中心化的,這意味著沒有伺服器,客戶端也不需要依賴伺服器來獲取或處理資料。在區塊鏈網路中,有的是節點,每個節點是網路的一個完全(full-fledged)成員。節點就是一切:它既是一個客戶端,也是一個伺服器。這一點需要牢記於心,因為這與傳統的網頁應用非常不同。
區塊鏈網路是一個 P2P(Peer-to-Peer,端到端)的網路,即節點直接連線到其他節點。它的拓撲是扁平的,因為在節點的世界中沒有層級之分。下面是它的示意圖:
Business vector created by Dooder - Freepik.com
要實現這樣一個網路節點更加困難,因為它們必須執行很多操作。每個節點必須與很多其他節點進行互動,它必須請求其他節點的狀態,與自己的狀態進行比較,當狀態過時時進行更新。
節點角色
儘管節點具有完備成熟的屬性,但是它們也可以在網路中扮演不同角色。比如:
礦工
這樣的節點執行於強大或專用的硬體(比如 ASIC)之上,它們唯一的目標是,儘可能快地挖出新塊。礦工是區塊鏈中唯一可能會用到工作量證明的角色,因為挖礦實際上意味著解決 PoW 難題。在權益證明 PoS 的區塊鏈中,沒有挖礦。全節點
這些節點驗證礦工挖出來的塊的有效性,並對交易進行確認。為此,他們必須擁有區塊鏈的完整拷貝。同時,全節點執行路由操作,幫助其他節點發現彼此。對於網路來說,非常重要的一段就是要有足夠多的全節點。因為正是這些節點執行了決策功能:他們決定了一個塊或一筆交易的有效性。SPV
SPV 表示 Simplified Payment Verification,簡單支付驗證。這些節點並不儲存整個區塊鏈副本,但是仍然能夠對交易進行驗證(不過不是驗證全部交易,而是一個交易子集,比如,傳送到某個指定地址的交易)。一個 SPV 節點依賴一個全節點來獲取資料,可能有多個 SPV 節點連線到一個全節點。SPV 使得錢包應用成為可能:一個人不需要下載整個區塊鏈,但是仍能夠驗證他的交易。
網路簡化
為了在目前的區塊鏈原型中實現網路,我們不得不簡化一些事情。因為我們沒有那麼多的計算機來模擬一個多節點的網路。當然,我們可以使用虛擬機器或是 Docker 來解決這個問題,但是這會使一切都變得更復雜:你將不得不先解決可能出現的虛擬機器或 Docker 問題,而我的目標是將全部精力都放在區塊鏈實現上。所以,我們想要在一臺機器上執行多個區塊鏈節點,同時希望它們有不同的地址。為了實現這一點,我們將使用埠號作為節點識別符號,而不是使用 IP 地址,比如將會有這樣地址的節點:127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002 等等。我們叫它埠節點(port node) ID,並使用環境變數 NODE_ID
對它們進行設定。故而,你可以開啟多個終端視窗,設定不同的 NODE_ID
執行不同的節點。
這個方法也需要有不同的區塊鏈和錢包檔案。它們現在必須依賴於節點 ID 進行命名,比如 blockchain_3000.db, blockchain_30001.db and wallet_3000.db, wallet_30001.db 等等。
實現
所以,當你下載 Bitcoin Core 並首次執行時,到底發生了什麼呢?它必須連線到某個節點下載最新狀態的區塊鏈。考慮到你的電腦並沒有意識到所有或是部分的比特幣節點,那麼連線到的“某個節點”到底是什麼?
在 Bitcoin Core 中硬編碼一個地址,已經被證實是一個錯誤:因為節點可能會被攻擊或關機,這會導致新的節點無法加入到網路中。在 Bitcoin Core 中,硬編碼了 DNS seeds。雖然這些並不是節點,但是 DNS 伺服器知道一些節點的地址。當你啟動一個全新的 Bitcoin Core 時,它會連線到一個種子節點,獲取全節點列表,隨後從這些節點中下載區塊鏈。
不過在我們目前的實現中,無法做到完全的去中心化,因為會出現中心化的特點。我們會有三個節點:
一箇中心節點。所有其他節點都會連線到這個節點,這個節點會在其他節點之間傳送資料。
一個礦工節點。這個節點會在記憶體池中儲存新的交易,當有足夠的交易時,它就會打包挖出一個新塊。
一個錢包節點。這個節點會被用作在錢包之間傳送幣。但是與 SPV 節點不同,它儲存了區塊鏈的一個完整副本。
場景
本文的目標是實現如下場景:
- 中心節點建立一個區塊鏈。
- 一個其他(錢包)節點連線到中心節點並下載區塊鏈。
- 另一個(礦工)節點連線到中心節點並下載區塊鏈。
- 錢包節點建立一筆交易。
- 礦工節點接收交易,並將交易儲存到記憶體池中。
- 當記憶體池中有足夠的交易時,礦工開始挖一個新塊。
- 當挖出一個新塊後,將其傳送到中心節點。
- 錢包節點與中心節點進行同步。
- 錢包節點的使用者檢查他們的支付是否成功。
這就是比特幣中的一般流程。儘管我們不會實現一個真實的 P2P 網路,但是我們會實現一個真是,也是比特幣最常見最重要的使用者場景。
版本
節點通過訊息(message)進行交流。當一個新的節點開始執行時,它會從一個 DNS 種子獲取幾個節點,給它們傳送 version
訊息,在我們的實現看起來就像是這樣:
type version struct {
Version int
BestHeight int
AddrFrom string
}複製程式碼
由於我們僅有一個區塊鏈版本,所以 Version
欄位實際並不會儲存什麼重要資訊。BestHeight
儲存區塊鏈中節點的高度。AddFrom
儲存傳送者的地址。
接收到 version
訊息的節點應該做什麼呢?它會響應自己的 version
訊息。這是一種握手?:如果沒有事先互相問候,就不可能有其他交流。不過,這並不是處於禮貌:version
用於找到一個更長的區塊鏈。當一個節點接收到 version
訊息,它會檢查本節點的區塊鏈是否比 BestHeight
的值更大。如果不是,節點就會請求並下載缺失的塊。
為了接收訊息,我們需要一個伺服器:
var nodeAddress string
var knownNodes = []string{"localhost:3000"}
func StartServer(nodeID, minerAddress string) {
nodeAddress = fmt.Sprintf("localhost:%s", nodeID)
miningAddress = minerAddress
ln, err := net.Listen(protocol, nodeAddress)
defer ln.Close()
bc := NewBlockchain(nodeID)
if nodeAddress != knownNodes[0] {
sendVersion(knownNodes[0], bc)
}
for {
conn, err := ln.Accept()
go handleConnection(conn, bc)
}
}複製程式碼
首先,我們對中心節點的地址進行硬編碼:因為每個節點必須知道從何處開始初始化。minerAddress
引數指定了接收挖礦獎勵的地址。程式碼片段:
if nodeAddress != knownNodes[0] {
sendVersion(knownNodes[0], bc)
}複製程式碼
這意味著如果當前節點不是中心節點,它必須向中心節點傳送 version
訊息來查詢是否自己的區塊鏈已過時。
func sendVersion(addr string, bc *Blockchain) {
bestHeight := bc.GetBestHeight()
payload := gobEncode(version{nodeVersion, bestHeight, nodeAddress})
request := append(commandToBytes("version"), payload...)
sendData(addr, request)
}複製程式碼
我們的訊息,在底層就是位元組序列。前 12 個位元組指定了命令名(比如這裡的 version
),後面的位元組會包含 gob 編碼的訊息結構,commandToBytes
看起來是這樣:
func commandToBytes(command string) []byte {
var bytes [commandLength]byte
for i, c := range command {
bytes[i] = byte(c)
}
return bytes[:]
}複製程式碼
它建立一個 12 位元組的緩衝區,並用命令名進行填充,將剩下的位元組置為空。下面一個相反的函式:
func bytesToCommand(bytes []byte) string {
var command []byte
for _, b := range bytes {
if b != 0x0 {
command = append(command, b)
}
}
return fmt.Sprintf("%s", command)
}複製程式碼
當一個節點接收到一個命令,它會執行 bytesToCommand
來提取命令名,並選擇正確的處理器處理命令主體:
func handleConnection(conn net.Conn, bc *Blockchain) {
request, err := ioutil.ReadAll(conn)
command := bytesToCommand(request[:commandLength])
fmt.Printf("Received %s command\n", command)
switch command {
...
case "version":
handleVersion(request, bc)
default:
fmt.Println("Unknown command!")
}
conn.Close()
}複製程式碼
下面是 version
命令處理器:
func handleVersion(request []byte, bc *Blockchain) {
var buff bytes.Buffer
var payload verzion
buff.Write(request[commandLength:])
dec := gob.NewDecoder(&buff)
err := dec.Decode(&payload)
myBestHeight := bc.GetBestHeight()
foreignerBestHeight := payload.BestHeight
if myBestHeight < foreignerBestHeight {
sendGetBlocks(payload.AddrFrom)
} else if myBestHeight > foreignerBestHeight {
sendVersion(payload.AddrFrom, bc)
}
if !nodeIsKnown(payload.AddrFrom) {
knownNodes = append(knownNodes, payload.AddrFrom)
}
}複製程式碼
首先,我們需要對請求進行解碼,提取有效資訊。所有的處理器在這部分都類似,所以我們會下面的程式碼片段中略去這部分。
然後節點將從訊息中提取的 BestHeight
與自身進行比較。如果自身節點的區塊鏈更長,它會回覆 version
訊息;否則,它會傳送 getblocks
訊息。
getblocks
type getblocks struct {
AddrFrom string
}複製程式碼
getblocks
意為 “給我看一下你有什麼區塊”(在比特幣中,這會更加複雜)。注意,它並沒有說“把你全部的區塊給我”,而是請求了一個塊雜湊的列表。這是為了減輕網路負載,因為區塊可以從不同的節點下載,並且我們不想從一個單一節點下載數十 GB 的資料。
處理命令十分簡單:
func handleGetBlocks(request []byte, bc *Blockchain) {
...
blocks := bc.GetBlockHashes()
sendInv(payload.AddrFrom, "block", blocks)
}複製程式碼
在我們簡化版的實現中,它會返回 所有塊雜湊。
inv
type inv struct {
AddrFrom string
Type string
Items [][]byte
}複製程式碼
比特幣使用 inv
來向其他節點展示當前節點有什麼塊和交易。再次提醒,它沒有包含完整的區塊鏈和交易,僅僅是雜湊而已。Type
欄位表明了這是塊還是交易。
處理 inv
稍顯複雜:
func handleInv(request []byte, bc *Blockchain) {
...
fmt.Printf("Recevied inventory with %d %s\n", len(payload.Items), payload.Type)
if payload.Type == "block" {
blocksInTransit = payload.Items
blockHash := payload.Items[0]
sendGetData(payload.AddrFrom, "block", blockHash)
newInTransit := [][]byte{}
for _, b := range blocksInTransit {
if bytes.Compare(b, blockHash) != 0 {
newInTransit = append(newInTransit, b)
}
}
blocksInTransit = newInTransit
}
if payload.Type == "tx" {
txID := payload.Items[0]
if mempool[hex.EncodeToString(txID)].ID == nil {
sendGetData(payload.AddrFrom, "tx", txID)
}
}
}複製程式碼
如果收到塊雜湊,我們想要將它們儲存在 blocksInTransit
變數來跟蹤已下載的塊。這能夠讓我們從不同的節點下載塊。在將塊置於傳送狀態時,我們給 inv
訊息的傳送者傳送 getdata
命令並更新 blocksInTransit
。在一個真實的 P2P 網路中,我們會想要從不同節點來傳送塊。
在我們的實現中,我們永遠也不會傳送有多重雜湊的 inv
。這就是為什麼當 payload.Type == "tx"
時,只會拿到第一個雜湊。然後我們檢查是否在記憶體池中已經有了這個雜湊,如果沒有,傳送 getdata
訊息。
getdata
type getdata struct {
AddrFrom string
Type string
ID []byte
}複製程式碼
getdata
用於某個塊或交易的請求,它可以僅包含一個塊或交易的 ID。
func handleGetData(request []byte, bc *Blockchain) {
...
if payload.Type == "block" {
block, err := bc.GetBlock([]byte(payload.ID))
sendBlock(payload.AddrFrom, &block)
}
if payload.Type == "tx" {
txID := hex.EncodeToString(payload.ID)
tx := mempool[txID]
sendTx(payload.AddrFrom, &tx)
}
}複製程式碼
這個處理器比較地直觀:如果它們請求一個塊,則返回塊;如果它們請求一筆交易,則返回交易。注意,我們並不檢查實際上是否已經有了這個塊或交易。這是一個缺陷 :)
block 和 tx
type block struct {
AddrFrom string
Block []byte
}
type tx struct {
AddFrom string
Transaction []byte
}複製程式碼
實際完成資料轉移的正是這些訊息。
處理 block
訊息十分簡單:
func handleBlock(request []byte, bc *Blockchain) {
...
blockData := payload.Block
block := DeserializeBlock(blockData)
fmt.Println("Recevied a new block!")
bc.AddBlock(block)
fmt.Printf("Added block %x\n", block.Hash)
if len(blocksInTransit) > 0 {
blockHash := blocksInTransit[0]
sendGetData(payload.AddrFrom, "block", blockHash)
blocksInTransit = blocksInTransit[1:]
} else {
UTXOSet := UTXOSet{bc}
UTXOSet.Reindex()
}
}複製程式碼
當接收到一個新塊時,我們把它放到區塊鏈裡面。如果還有更多的區塊需要下載,我們繼續從上一個下載的塊的那個節點繼續請求。當最後把所有塊都下載完後,對 UTXO 集進行重新索引。
TODO:並非無條件信任,我們應該在將每個塊加入到區塊鏈之前對它們進行驗證。
TODO: 並非執行 UTXOSet.Reindex(), 而是應該使用 UTXOSet.Update(block),因為如果區塊鏈很大,它將需要很多時間來對整個 UTXO 集重新索引。
處理 tx
訊息是最困難的部分:
func handleTx(request []byte, bc *Blockchain) {
...
txData := payload.Transaction
tx := DeserializeTransaction(txData)
mempool[hex.EncodeToString(tx.ID)] = tx
if nodeAddress == knownNodes[0] {
for _, node := range knownNodes {
if node != nodeAddress && node != payload.AddFrom {
sendInv(node, "tx", [][]byte{tx.ID})
}
}
} else {
if len(mempool) >= 2 && len(miningAddress) > 0 {
MineTransactions:
var txs []*Transaction
for id := range mempool {
tx := mempool[id]
if bc.VerifyTransaction(&tx) {
txs = append(txs, &tx)
}
}
if len(txs) == 0 {
fmt.Println("All transactions are invalid! Waiting for new ones...")
return
}
cbTx := NewCoinbaseTX(miningAddress, "")
txs = append(txs, cbTx)
newBlock := bc.MineBlock(txs)
UTXOSet := UTXOSet{bc}
UTXOSet.Reindex()
fmt.Println("New block is mined!")
for _, tx := range txs {
txID := hex.EncodeToString(tx.ID)
delete(mempool, txID)
}
for _, node := range knownNodes {
if node != nodeAddress {
sendInv(node, "block", [][]byte{newBlock.Hash})
}
}
if len(mempool) > 0 {
goto MineTransactions
}
}
}
}複製程式碼
首先要做的事情是將新交易放到記憶體池中(再次提醒,在將交易放到記憶體池之前,必要對其進行驗證)。下個片段:
if nodeAddress == knownNodes[0] {
for _, node := range knownNodes {
if node != nodeAddress && node != payload.AddFrom {
sendInv(node, "tx", [][]byte{tx.ID})
}
}
}複製程式碼
檢查當前節點是否是中心節點。在我們的實現中,中心節點並不會挖礦。它只會將新的交易推送給網路中的其他節點。
下一個很大的程式碼片段是礦工節點“專屬”。讓我們對它進行一下分解:
if len(mempool) >= 2 && len(miningAddress) > 0 {複製程式碼
miningAddress
只會在礦工節點上設定。如果當前節點(礦工)的記憶體池中有兩筆或更多的交易,開始挖礦:
for id := range mempool {
tx := mempool[id]
if bc.VerifyTransaction(&tx) {
txs = append(txs, &tx)
}
}
if len(txs) == 0 {
fmt.Println("All transactions are invalid! Waiting for new ones...")
return
}複製程式碼
首先,記憶體池中所有交易都是通過驗證的。無效的交易會被忽略,如果沒有有效交易,則挖礦中斷。
cbTx := NewCoinbaseTX(miningAddress, "")
txs = append(txs, cbTx)
newBlock := bc.MineBlock(txs)
UTXOSet := UTXOSet{bc}
UTXOSet.Reindex()
fmt.Println("New block is mined!")複製程式碼
驗證後的交易被放到一個塊裡,同時還有附帶獎勵的 coinbase 交易。當塊被挖出來以後,UTXO 集會被重新索引。
TODO: 提醒,應該使用 UTXOSet.Update 而不是 UTXOSet.Reindex.
for _, tx := range txs {
txID := hex.EncodeToString(tx.ID)
delete(mempool, txID)
}
for _, node := range knownNodes {
if node != nodeAddress {
sendInv(node, "block", [][]byte{newBlock.Hash})
}
}
if len(mempool) > 0 {
goto MineTransactions
}複製程式碼
當一筆交易被挖出來以後,就會被從記憶體池中移除。當前節點所連線到的所有其他節點,接收帶有新塊雜湊的 inv
訊息。在處理完訊息後,它們可以對塊進行請求。
結果
讓我們來回顧一下上面定義的場景。
首先,在第一個終端視窗中將 NODE_ID
設定為 3000(export NODE_ID=3000
)。為了讓你知道什麼節點執行什麼操作,我會使用像 NODE 3000 或 NODE 3001 進行標識。
NODE 3000
建立一個錢包和一個新的區塊鏈:
$ blockchain_go createblockchain -address CENTREAL_NODE複製程式碼
(為了簡潔起見,我會使用假地址。)
然後,會生成一個僅包含創世塊的區塊鏈。我們需要儲存塊,並在其他節點使用。創世塊承擔了一條鏈識別符號的角色(在 Bitcoin Core 中,創世塊是硬編碼的)
$ cp blockchain_3000.db blockchain_genesis.db複製程式碼
NODE 3001
接下來,開啟一個新的終端視窗,將 node ID 設定為 3001。這會作為一個錢包節點。通過 blockchain_go createwallet
生成一些地址,我們把這些地址叫做 WALLET_1, WALLET_2, WALLET_3.
NODE 3000
向錢包地址傳送一些幣:
$ blockchain_go send -from CENTREAL_NODE -to WALLET_1 -amount 10 -mine
$ blockchain_go send -from CENTREAL_NODE -to WALLET_2 -amount 10 -mine複製程式碼
-mine
標誌指的是塊會立刻被同一節點挖出來。我們必須要有這個標誌,因為初始狀態時,網路中沒有礦工節點。
啟動節點:
$ blockchain_go startnode複製程式碼
這個節點會持續執行,直到本文定義的場景結束。
NODE 3001
啟動上面儲存創世塊節點的區塊鏈:
$ cp blockchain_genesis.db blockchain_3001.db複製程式碼
執行節點:
$ blockchain_go startnode複製程式碼
它會從中心節點下載所有區塊。為了檢查一切正常,暫停節點執行並檢查餘額:
$ blockchain_go getbalance -address WALLET_1
Balance of 'WALLET_1': 10
$ blockchain_go getbalance -address WALLET_2
Balance of 'WALLET_2': 10複製程式碼
你還可以檢查 CENTRAL_NODE
地址的餘額,因為 node 3001 現在有它自己的區塊鏈:
$ blockchain_go getbalance -address CENTRAL_NODE
Balance of 'CENTRAL_NODE': 10複製程式碼
NODE 3002
開啟一個新的終端視窗,將它的 ID 設定為 3002,然後生成一個錢包。這會是一個礦工節點。初始化區塊鏈:
$ cp blockchain_genesis.db blockchain_3002.db複製程式碼
啟動節點:
$ blockchain_go startnode -miner MINER_WALLET複製程式碼
NODE 3001
傳送一些幣:
$ blockchain_go send -from WALLET_1 -to WALLET_3 -amount 1
$ blockchain_go send -from WALLET_2 -to WALLET_4 -amount 1複製程式碼
NODE 3002
迅速切換到礦工節點,你會看到挖出了一個新塊!同時,檢查中心節點的輸出。
NODE 3001
切換到錢包節點並啟動:
$ blockchain_go startnode複製程式碼
它會下載最近挖出來的塊!
暫停節點並檢查餘額:
$ blockchain_go getbalance -address WALLET_1
Balance of 'WALLET_1': 9
$ blockchain_go getbalance -address WALLET_2
Balance of 'WALLET_2': 9
$ blockchain_go getbalance -address WALLET_3
Balance of 'WALLET_3': 1
$ blockchain_go getbalance -address WALLET_4
Balance of 'WALLET_4': 1
$ blockchain_go getbalance -address MINER_WALLET
Balance of 'MINER_WALLET': 10複製程式碼
就是這麼多了!
總結
這是本系列的最後一篇文章了。我本可以就實現一個真實的 P2P 網路原型繼續展開,但是我真的沒有這麼多時間。我希望本文已經回答了關於比特幣技術的一些問題,也給讀者提出了一些問題,這些問題你可以自行尋找答案。在比特幣技術中還有隱藏著很多有趣的事情!好運!
後記:你可以從實現 addr
訊息來開始改進網路,正如比特幣網路協議中所描述的(連結可以下方找到)那樣。這是一個非常重要的訊息,因為它允許節點來互相發現彼此。我已經開始實現了,不過還沒有完成!
連結: