用 Go 構建一個區塊鏈 -- Part 4: 交易(1)

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

翻譯的系列文章我已經放到了 GitHub 上:blockchain-tutorial,後續如有更新都會在 GitHub 上,可能就不在這裡同步了。如果想直接運行代碼,也可以 clone GitHub 上的教程倉庫,進入 src 目錄執行 make 即可。

引言

交易(transaction)是比特幣的核心所在,而區塊鏈的唯一目的,也正是為了能夠安全可靠地儲存體交易。在區塊鏈中,交易一旦被建立,就沒有任何人能夠再去修改或是刪除它。在今天的文章中,我們將會開始實現交易這個部分。不過,由於交易是很大的話題,我會把它分為兩部分來講:在今天這個部分,我們會實現交易的通用機制。在第二部分,我們會繼續討論它的一些細節。

此外,由於代碼實現變化很大,就不在文章中羅列所有代碼了,這裡 可以看到所有的改動。

沒有勺子

如果以前開發過 web 應用,在支付的實現環節,你可能會在資料庫中建立這樣兩張表:

  • accounts
  • transactions

account(賬戶)會儲存使用者資訊,裡麵包括了個人資訊和餘額。transaction(交易)會儲存資金轉移資訊,也就是資金從一個賬戶轉移到另一個賬戶這樣的內容。在比特幣中,支付是另外一種完全不同的方式:

  1. 沒有賬戶(account)
  2. 沒有餘額(balance)
  3. 沒有住址(address)
  4. 沒有貨幣(coin)
  5. 沒有發送人和接收人(sender,receiver)(這裡所說的發送人和接收人是基於目前現實生活情境,交易雙方與人是一一對應的。而在比特幣中,“交易雙方”是地址,地址背後才是人,人與地址並不是一一對應的關係,一個人可能有很多個地址。)

鑒於區塊鏈是一個公開開放的資料庫,所以我們並不想要儲存錢包所有者的敏感資訊(所以具有一定的匿名性)。資金不是通過賬戶來收集,交易也不是從一個地址將錢轉移到另一個地址,也沒有一個欄位或者屬性來儲存賬戶餘額。交易就是區塊鏈要表達的所有內容。那麼,交易裡面到底有什麼內容呢?

比特幣交易

一筆交易由一些輸入(input)和輸出(output)組合而來:

type Transaction struct {    ID   []byte    Vin  []TXInput    Vout []TXOutput}

對於每一筆新的交易,它的輸入會引用(reference)之前一筆交易的輸出(這裡有個例外,也就是我們待會兒要談到的 coinbase 交易)。所謂引用之前的一個輸出,也就是將之前的一個輸出包含在另一筆交易的輸入當中。交易的輸出,也就是幣實際儲存的地方。下面的圖示闡釋了交易之間的互相關聯:

注意:

  1. 有一些輸出並沒有被關聯到某個輸入上
  2. 一筆交易的輸入可以引用之前多筆交易的輸出
  3. 一個輸入必須引用一個輸出

貫穿本文,我們將會使用像“錢(money)”,“幣(coin)”,“花費(spend)”,“發送(send)”,“賬戶(account)” 等等這樣的詞。但是在比特幣中,實際並不存在這樣的概念。交易僅僅是通過一個指令碼(script)來鎖定(lock)一些價值(value),而這些價值只可以被鎖定它們的人解鎖(unlock)。

交易輸出

讓我們先從輸出(output)開始:

type TXOutput struct {    Value        int    ScriptPubKey string}

實際上,正是輸出裡面儲存了“幣”(注意,也就是上面的 Value 欄位)。而這裡的儲存,指的是用一個數學難題對輸出進行鎖定,這個難題被儲存在 ScriptPubKey 裡面。在內部,比特幣使用了一個叫做 Script 的指令碼語言,用它來定義鎖定和解鎖輸出的邏輯。雖然這個語言相當的原始(這是為了避免潛在的駭客攻擊和濫用而有意為之),並不複雜,但是我們並不會在這裡討論它的細節。你可以在這裡 找到詳細解釋。

在比特幣中,value 欄位儲存的是 satoshi 的數量,而不是>有 BTC 的數量。一個 satoshi 等於一百萬分之一的 >BTC(0.00000001 BTC),這也是比特幣裡面最小的貨幣單位>(就像是 1 分的硬幣)。

由於還沒有實現地址(address),所以目前我們會避免涉及邏輯相關的完整指令碼。ScriptPubKey 將會儲存一個任意的字串(使用者定義的錢包地址)。

順便說一下,有了一個這樣的指令碼語言,也意味著比特幣其實也可以作為一個智能合約平台。

關於輸出,非常重要的一點是:它們是不可再分的(invisible),這也就是說,你無法僅引用它的其中某一部分。要麼不用,如果要用,必須一次性用完。當一個新的交易中引用了某個輸出,那麼這個輸出必須被全部花費。如果它的值比需要的值大,那麼就會產生一個找零,找零會返還給發送方。這跟現實世界的情境十分類似,當你想要支付的時候,如果一個東西值 1 美元,而你給了一個 5 美元的紙幣,那麼你會得到一個 4 美元的找零。

交易輸入

這裡是輸入:

type TXInput struct {    Txid      []byte    Vout      int    ScriptSig string}

正如之前所提到的,一個輸入引用了之前一筆交易的一個輸出:Txid 儲存的是這筆交易的 ID,Vout 儲存的是該輸出在這筆交易中所有輸出的索引(因為一筆交易可能有多個輸出,需要有資訊指明是具體的哪一個)。ScriptSig 是一個指令碼,提供了可作用於一個輸出的 ScriptPubKey 的資料。如果 ScriptSig 提供的資料是正確的,那麼輸出就會被解鎖,然後被解鎖的值就可以被用於產生新的輸出;如果資料不正確,輸出就無法被引用在輸入中,或者說,也就是無法使用這個輸出。這種機制,保證了使用者無法花費屬於其他人的幣。

再次強調,由於我們還沒有實現地址,所以 ScriptSig 將僅僅儲存一個任意使用者定義的錢包地址。我們會在下一篇文章中實現公開金鑰(public key)和簽名(signature)。

來簡要總結一下。輸出,就是 “幣” 儲存的地方。每個輸出都會帶有一個解鎖指令碼,這個指令碼定義瞭解鎖該輸出的邏輯。每筆新的交易,必須至少有一個輸入和輸出。一個輸入引用了之前一筆交易的輸出,並提供了資料(也就是 ScriptSig 欄位),該資料會被用在輸出的解鎖指令碼中解鎖輸出,解鎖完成後即可使用它的值去產生新的輸出。

也就是說,每一筆輸入都是之前一筆交易的輸出,那麼從一筆交易開始不斷往前追溯,它涉及的輸入和輸出到底是誰先存在呢?換個說法,這是個雞和蛋誰先誰後的問題,是先有蛋還是先有雞呢?

先有蛋

在比特幣中,是先有蛋,然後才有雞。輸入引用輸出的邏輯,是經典的“蛋還是雞”問題:輸入先產生輸出,然後輸出使得輸入成為可能。在比特幣中,最先有輸出,然後才有輸入。換而言之,第一筆交易只有輸出,沒有輸入。

當礦工挖出一個新的塊時,它會向新的塊中添加一個 coinbase 交易。coinbase 交易是一種特殊的交易,它不需要引用之前一筆交易的輸出。它“憑空”產生了幣(也就是產生了新幣),這也是礦工獲得挖出新塊的獎勵,可以理解為“發行新幣”。

在區塊鏈的最初,也就是第一個塊,叫做創世塊。正是這個創世塊,產生了區塊鏈最開始的輸出。對於創世塊,不需要引用之前交易的輸出。因為在創世塊之前根本不存在交易,也就沒有不存在有交易輸出。

來建立一個 coinbase 交易:

func NewCoinbaseTX(to, data string) *Transaction {    if data == "" {        data = fmt.Sprintf("Reward to '%s'", to)    }    txin := TXInput{[]byte{}, -1, data}    txout := TXOutput{subsidy, to}    tx := Transaction{nil, []TXInput{txin}, []TXOutput{txout}}    tx.SetID()    return &tx}

coinbase 交易只有一個輸出,沒有輸入。在我們的實現中,它的 Txid 為空白,Vout 等於 -1。並且,在目前的視線中,coinbase 交易也沒有在 ScriptSig 中儲存一個指令碼,而只是儲存了一個任意的字串。

在比特幣中,第一筆 coinbase 交易包含了如下資訊:“The Times 03/Jan/2009 Chancellor on brink of second bailout for banks”。可點擊這裡查看.

subsidy 是獎勵的數額。在比特幣中,實際並沒有儲存這個數字,而是基於區塊總數進行計算而得:區塊總數除以 210000 就是 subsidy。挖出創世塊的獎勵是 50 BTC,每挖出 210000 個塊後,獎勵減半。在我們的實現中,這個獎勵值將會是一個常量(至少目前是)。

將交易儲存到區塊鏈

從現在開始,每個塊必須儲存至少一筆交易。如果沒有交易,也就不可能挖出新的塊。這意味著我們應該移除 BlockData 欄位,取而代之的是儲存體交易:

type Block struct {    Timestamp     int64    Transactions  []*Transaction    PrevBlockHash []byte    Hash          []byte    Nonce         int}

NewBlockNewGenesisBlock 也必須做出相應改變:

func NewBlock(transactions []*Transaction, prevBlockHash []byte) *Block {    block := &Block{time.Now().Unix(), transactions, prevBlockHash, []byte{}, 0}    ...}func NewGenesisBlock(coinbase *Transaction) *Block {    return NewBlock([]*Transaction{coinbase}, []byte{})}

接下來修改建立新鏈的函數:

func CreateBlockchain(address string) *Blockchain {    ...    err = db.Update(func(tx *bolt.Tx) error {        cbtx := NewCoinbaseTX(address, genesisCoinbaseData)        genesis := NewGenesisBlock(cbtx)        b, err := tx.CreateBucket([]byte(blocksBucket))        err = b.Put(genesis.Hash, genesis.Serialize())        ...    })    ...}

現在,這個函數會接受一個地址作為參數,這個地址會用來接收挖出創世塊的獎勵。

工作量證明

工作量證明演算法必須要將儲存在區塊裡面的交易考慮進去,以此保證區塊鏈交易儲存的一致性和可靠性。所以,我們必須修改 ProofOfWork.prepareData 方法:

func (pow *ProofOfWork) prepareData(nonce int) []byte {    data := bytes.Join(        [][]byte{            pow.block.PrevBlockHash,            pow.block.HashTransactions(), // This line was changed            IntToHex(pow.block.Timestamp),            IntToHex(int64(targetBits)),            IntToHex(int64(nonce)),        },        []byte{},    )    return data}

不像之前使用 pow.block.Data,現在我們使用 pow.block.HashTransactions()

func (b *Block) HashTransactions() []byte {    var txHashes [][]byte    var txHash [32]byte    for _, tx := range b.Transactions {        txHashes = append(txHashes, tx.ID)    }    txHash = sha256.Sum256(bytes.Join(txHashes, []byte{}))    return txHash[:]}

我們使用雜湊提供資料的唯一表示,這個之前也遇到過。我們想要通過僅僅一個雜湊,就可以識別一個塊裡面的所有交易。為此,我們獲得每筆交易的雜湊,將它們關聯起來,然後獲得一個串連後的組合雜湊。

比特幣使用了一個更加複雜的技術:它將一個塊裡麵包含的所有交易表示為一個  Merkle tree ,然後在工作量證明系統中使用樹的根雜湊(root hash)。這個方法能夠讓我們快速檢索一個塊裡面是否包含了某筆交易,即只需 root hash 而無需下載所有交易即可完成判斷。

來檢查一下到目前為止是否正確:

$ blockchain_go createblockchain -address Ivan00000093450837f8b52b78c25f8163bb6137caf43ff4d9a01d1b731fa8ddcc8aDone!

很好!我們已經獲得了第一筆挖礦獎勵,但是,我們要如何查看餘額呢?

未花費的交易輸出

我們需要找到所有的未花費交易輸出(unspent transactions outputs, UTXO)。未花費(unspent) 指的是這個輸出還沒有被包含在任何交易的輸入中,或者說沒有被任何輸入引用。在上面的圖示中,未花費的輸出是:

  1. tx0, output 1;
  2. tx1, output 0;
  3. tx3, output 0;
  4. tx4, output 0.

當然了,當我們檢查餘額時,我們並不需要知道整個區塊鏈上所有的 UTXO,只需要關注那些我們能夠解鎖的那些 UTXO(目前我們還沒有實現密鑰,所以我們將會使用使用者定義的地址來代替)。首先,讓我們定義在輸入和輸出上的鎖定和解鎖方法:

func (in *TXInput) CanUnlockOutputWith(unlockingData string) bool {    return in.ScriptSig == unlockingData}func (out *TXOutput) CanBeUnlockedWith(unlockingData string) bool {    return out.ScriptPubKey == unlockingData}

在這裡,我們只是將 script 欄位與 unlockingData 進行了比較。在後續文章我們基於私密金鑰實現了地址以後,會對這部分進行改進。

下一步,找到包含未花費輸出的交易,這一步相當困難:

func (bc *Blockchain) FindUnspentTransactions(address string) []Transaction {  var unspentTXs []Transaction  spentTXOs := make(map[string][]int)  bci := bc.Iterator()  for {    block := bci.Next()    for _, tx := range block.Transactions {      txID := hex.EncodeToString(tx.ID)    Outputs:      for outIdx, out := range tx.Vout {        // Was the output spent?        if spentTXOs[txID] != nil {          for _, spentOut := range spentTXOs[txID] {            if spentOut == outIdx {              continue Outputs            }          }        }        if out.CanBeUnlockedWith(address) {          unspentTXs = append(unspentTXs, *tx)        }      }      if tx.IsCoinbase() == false {        for _, in := range tx.Vin {          if in.CanUnlockOutputWith(address) {            inTxID := hex.EncodeToString(in.Txid)            spentTXOs[inTxID] = append(spentTXOs[inTxID], in.Vout)          }        }      }    }    if len(block.PrevBlockHash) == 0 {      break    }  }  return unspentTXs}

由於交易被儲存在區塊裡,所以我們不得不檢查區塊鏈裡的每一筆交易。從輸出開始:

if out.CanBeUnlockedWith(address) {    unspentTXs = append(unspentTXs, tx)}

如果一個輸出被一個地址鎖定,並且這個地址恰好是我們要找的未花費交易輸出的地址,那麼這個輸出就是我們想要的。不過在擷取它之前,我們需要檢查該輸出是否已經被包含在一個輸入中,也就是檢查它是否已經被花費了:

if spentTXOs[txID] != nil {    for _, spentOut := range spentTXOs[txID] {        if spentOut == outIdx {            continue Outputs        }    }}

我們跳過那些已經被包含在其他輸入中的輸出(被包含在輸入中,也就是說明這個輸出已經被花費,無法再用了)。檢查完輸出以後,我們將所有能夠解鎖給定地址鎖定的輸出的輸入聚集起來(這並不適用於 coinbase 交易,因為它們不解鎖輸出):

if tx.IsCoinbase() == false {    for _, in := range tx.Vin {        if in.CanUnlockOutputWith(address) {            inTxID := hex.EncodeToString(in.Txid)            spentTXOs[inTxID] = append(spentTXOs[inTxID], in.Vout)        }    }}

這個函數返回了一個交易列表,裡麵包含了未花費輸出。為了計算餘額,我們還需要一個函數將這些交易作為輸入,然後僅返回一個輸出:

func (bc *Blockchain) FindUTXO(address string) []TXOutput {       var UTXOs []TXOutput       unspentTransactions := bc.FindUnspentTransactions(address)       for _, tx := range unspentTransactions {               for _, out := range tx.Vout {                       if out.CanBeUnlockedWith(address) {                               UTXOs = append(UTXOs, out)                       }               }       }       return UTXOs}

就是這麼多了!現在我們來實現 getbalance 命令:

func (cli *CLI) getBalance(address string) {    bc := NewBlockchain(address)    defer bc.db.Close()    balance := 0    UTXOs := bc.FindUTXO(address)    for _, out := range UTXOs {        balance += out.Value    }    fmt.Printf("Balance of '%s': %d\n", address, balance)}

賬戶餘額就是由賬戶地址鎖定的所有未花費交易輸出的總和。

在挖出創世塊以後,來檢查一下我們的餘額:

$ blockchain_go getbalance -address IvanBalance of 'Ivan': 10

這就是我們的第一筆錢!

發送幣

現在,我們想要給其他人發送一些幣。為此,我們需要建立一筆新的交易,將它放到一個塊裡,然後挖出這個塊。之前我們只實現了 coinbase 交易(這是一種特殊的交易),現在我們需要一種通用的交易:

func NewUTXOTransaction(from, to string, amount int, bc *Blockchain) *Transaction {    var inputs []TXInput    var outputs []TXOutput    acc, validOutputs := bc.FindSpendableOutputs(from, amount)    if acc < amount {        log.Panic("ERROR: Not enough funds")    }    // Build a list of inputs    for txid, outs := range validOutputs {        txID, err := hex.DecodeString(txid)        for _, out := range outs {            input := TXInput{txID, out, from}            inputs = append(inputs, input)        }    }    // Build a list of outputs    outputs = append(outputs, TXOutput{amount, to})    if acc > amount {        outputs = append(outputs, TXOutput{acc - amount, from}) // a change    }    tx := Transaction{nil, inputs, outputs}    tx.SetID()    return &tx}

在建立新的輸出前,我們首先必須找到所有的未花費輸出,並且確保它們儲存了足夠的值(value),這就是 FindSpendableOutputs 方法做的事情。隨後,對於每個找到的輸出,會建立一個引用該輸出的輸入。接下來,我們建立兩個輸出:

  1. 一個由接收者地址鎖定。這是給實際給其他地址轉移的幣。
  2. 一個由寄件者地址鎖定。這是一個找零。只有當未花費輸出超過新證券交易所需時產生。記住:輸出是不可再分的

FindSpendableOutputs 方法基於之前定義的 FindUnspentTransactions 方法:

func (bc *Blockchain) FindSpendableOutputs(address string, amount int) (int, map[string][]int) {    unspentOutputs := make(map[string][]int)    unspentTXs := bc.FindUnspentTransactions(address)    accumulated := 0Work:    for _, tx := range unspentTXs {        txID := hex.EncodeToString(tx.ID)        for outIdx, out := range tx.Vout {            if out.CanBeUnlockedWith(address) && accumulated < amount {                accumulated += out.Value                unspentOutputs[txID] = append(unspentOutputs[txID], outIdx)                if accumulated >= amount {                    break Work                }            }        }    }    return accumulated, unspentOutputs}

這個方法對所有的未花費交易進行迭代,並對它的值進行累加。當累加值大於或等於我們想要傳送的值時,它就會停止並返回累加值,同時返回的還有通過交易 ID 進行分組的輸出索引。我們並不想要取出超出需要花費的錢。

現在,我們可以修改 Blockchain.MineBlock 方法:

func (bc *Blockchain) MineBlock(transactions []*Transaction) {    ...    newBlock := NewBlock(transactions, lastHash)    ...}

最後,讓我們來實現 send 方法:

func (cli *CLI) send(from, to string, amount int) {    bc := NewBlockchain(from)    defer bc.db.Close()    tx := NewUTXOTransaction(from, to, amount, bc)    bc.MineBlock([]*Transaction{tx})    fmt.Println("Success!")}

發送幣意味著建立新的交易,並通過挖出新塊的方式將交易打包到區塊鏈中。不過,比特幣並不是一連串立刻完成這些事情(不過我們的實現是這麼做的)。相反,它會將所有新的交易放到一個記憶體池中(mempool),然後當一個礦工準備挖出一個新塊時,它就從記憶體池中取出所有的交易,建立一個候選塊。只有當包含這些交易的塊被挖出來,並添加到區塊鏈以後,裡面的交易才開始確認。

讓我們來檢查一下發送幣是否能工作:

$ blockchain_go send -from Ivan -to Pedro -amount 600000001b56d60f86f72ab2a59fadb197d767b97d4873732be505e0a65cc1e37Success!$ blockchain_go getbalance -address IvanBalance of 'Ivan': 4$ blockchain_go getbalance -address PedroBalance of 'Pedro': 6

很好!現在,讓我們建立更多的交易,確保從多個輸出中發送幣也正常工作:

$ blockchain_go send -from Pedro -to Helen -amount 200000099938725eb2c7730844b3cd40209d46bce2c2af9d87c2b7611fe9d5bdfSuccess!$ blockchain_go send -from Ivan -to Helen -amount 2000000a2edf94334b1d94f98d22d7e4c973261660397dc7340464f7959a7a9aaSuccess!

現在,Helen 的幣被鎖定在了兩個輸出中:一個來自 Pedro,一個來自 Ivan。讓我們把它們發送給其他人:

$ blockchain_go send -from Helen -to Rachel -amount 3000000c58136cffa669e767b8f881d16e2ede3974d71df43058baaf8c069f1a0Success!$ blockchain_go getbalance -address IvanBalance of 'Ivan': 2$ blockchain_go getbalance -address PedroBalance of 'Pedro': 4$ blockchain_go getbalance -address HelenBalance of 'Helen': 1$ blockchain_go getbalance -address RachelBalance of 'Rachel': 3

看起來沒問題!現在,來測試一些失敗的情況:

$ blockchain_go send -from Pedro -to Ivan -amount 5panic: ERROR: Not enough funds$ blockchain_go getbalance -address PedroBalance of 'Pedro': 4$ blockchain_go getbalance -address IvanBalance of 'Ivan': 2

總結

雖然不容易,但是現在終於實現交易了!不過,我們依然缺少了一些像比特幣那樣的一些關鍵特性:

  1. 地址(address)。我們還沒有基於私密金鑰(private key)的真真實位址。
  2. 獎勵(reward)。現在挖礦是肯定無法盈利的!
  3. UTXO 集。擷取餘額需要掃描整個區塊鏈,而當區塊非常多的時候,這麼做就會花費很長時間。並且,如果我們想要驗證後續交易,也需要花費很長時間。而 UTXO 集就是為瞭解決這些問題,加快交易相關的操作。
  4. 記憶體池(mempool)。在交易被打包到塊之前,這些交易被儲存在記憶體池裡面。在我們目前的實現中,一個塊僅僅包含一筆交易,這是相當低效的。

連結:

  1. Full source codes
  2. Transaction
  3. Merkle tree
  4. Coinbase

本文原始碼:part_4

原文連結:Building Blockchain in Go. Part 4: Transactions 1

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.