用Go打造区块链(4)—交易记录(一)

论坛 期权论坛 期权     
数海拾荒   2020-1-1 00:16   1166   0
由于众所周知的原因,最近区块链又一次被推到了风口,下面转发几篇两年前翻译的首发于知乎关于如何用Go语言打造区块链的文章。Go语言是由google开发并于2009年发布的一种静态、强类型、编译型、并发型,并具有垃圾回收(GC)功能的编程语言,特别适用于分布式网络系统开发,而区块链(blockchain)本质上是一本在网络上分布存储的账本,这两者具有天然的匹配性,目前火热的Ethereum Project就是用go原生实现的。这一系列的文章是由Ivan Kuznetsov(https://jeiwan.net/)所写,本人觉得是一个结合Go语言学习区块链技术的好资料,后面将用自己的语言翻译一遍,从第一篇开始,顺便对Go语言以及区块链有一个初步的认识。
介绍(Introduction)交易记录是比特币的核心,用区块链的目的是想以一种安全和可靠的方式来存储交易记录,使得无人可以在它们被创建以后再修改它们。今天我们将开始实现交易记录。不过这是一个非常大的话题,我将把这部分内容分成两部分:在这部分当中,我们会实现交易记录的通用机制,在第二部分会实现具体细节。
然后,因为代码改动非常大,对所有代码进行描述变得不太有意义。要查阅所有的代码变得可以点击这里:https://github.com/Jeiwan/blockchain_go/compare/part_3...part_4#files_bucket。
这里没有调羹(There is no spoon)假如你曾经开发过web应用,为了实现支付你应该会在数据库中创建这样的一些表:账户(accounts)和交易记录(transactions)。一个账户会存储每个用户的信息,包括他们的个人信息和资产负债表,而一个交易记录会存储货币如何从一个账户转到另外一个账户。在比特币当中,支付是采用完全不同的方式实现的。主要特点如下:
  • 没有账户
  • 没有资产负债表
  • 没有地址
  • 没有货币
  • 没有支付方和接收方
因为区块链是一个公共、开发的数据库,我们不打算存储钱包拥有者的敏感信息。币也不集中在某一个账户。交易记录也把钱从一个账户转移到另一个账户。也没有账户资产负债情况的信息和特性。仅仅只有交易记录(transactions)。然后在交易记录当中究竟有什么呢?
比特币交易记录(Bitcoin Transaction)一条交易记录是所有输入(inputs)和输出(outputs)的合并:
  1. type Transaction struct {
  2. ID   []byte
  3. Vin  []TXInput
  4. Vout []TXOutput
  5. }
复制代码
一条新的交易记录的所有输入与之前交易记录的所有输出相对应(有一个例外,一会儿我们会讨论)。输出是实际存储比特币的地方。下面的示意图展示了交易记录之间的内部关系:


有一些输出并不与输入相对应注意以下几点:
  • 在一个交易记录当中,输入可以与来自不同交易记录的输出相对应
  • 一个输入只能对于一个输出,但是一个输出可以对应多个输入,比如有多个人向同一个人转账
在这边文章当中,我们将会用到这样的名词:“金钱(money)”,“货币(coins)”,“消费(spend)”,“发送(send)”,“账户(account)”等。但是在比特币当中没有这样对于的概念。交易记录通过一段脚本将价值锁定,并且只能由锁定它们的人来解锁。
交易记录输出(Transaction Outputs)让我们从输出开始:
  1. type TXOutput struct {
  2. Value        int
  3. ScriptPubKey string
  4. }
复制代码
事实上,这里的输出存储着“币值(coins)”(可以参阅上面的 Value 变量)。保存便意味着用一段口令将它锁定, 而这段口令保存在
  1. ScriptPubKey
复制代码
中。在比特币内部使用一个叫做
  1. Script
复制代码
的脚本语言,用来定义输出的锁定和解锁逻辑。这个脚本语言非常接近底层(刻意如此设计,是为了避免可能的滥用和攻击),在此我们不对它进行详细讨论。你可以点击这里:https://en.bitcoin.it/wiki/Script查看详细的解释。
在比特币中,value 字段用来保存 satoshis (聪)的数量,而不是比特币(BTC1)的数量。一比特币等于1000万个聪,聪目前是比特币系统中最小的货币单位(就像美分在美元系统中的地位一样)。
由于我们没有实现任何地址相关的内容,目前我们就避免讨论。ScriptPubKey 字段会存储一段随机字符串(用户自定义的钱包地址)
顺便提一句,因为有这样的脚本语言的存在意味着比特币也能够用作智能合约平台
输出的一个重要特性是不可分割性,意味着你能够提及其值的一部分。当一个输出在一个新的交易记录当中被提到,它会花掉它的全部。当它的价值比所需要的大,会产生找零然后返回给发出的人。这与现实生活中的场景类似,当你为某价值1元的东西支付5元会得到4元的找零。
交易记录输入(Transaction Inputs)然后接下来是输入(inputs)
  1. type TXInput struct {
  2. Txid      []byte
  3. Vout      int
  4. ScriptSig string
  5. }
复制代码
之前已经提到,一个输入对应前一个输出:
  1. Txid
复制代码
字段存储这样一条交易记录的
  1. ID
复制代码
  1. Vout
复制代码
字段存储交易记录当中输出的索引。
  1. ScriptSig
复制代码
字段是一段向输出的
  1. ScriptPubKey
复制代码
字段中提供数据的脚本。如果数据正确的话,输出将会被解锁,然后它所含的价值(value)可以用来产生新的输出;如果不正确的话,输出将无法被输入引用,无法建立连接。这个机制是为了保证用户不能花属于别人的比特币。
再一次的,因为我们没有实现地址,在我们的实现当中
  1. ScriptSig
复制代码
字段只是一段随机字符串保存用户定义的钱包地址。我们将在下一篇文章当中实现公钥(public keys)和签名(signatures)检查。
让我最后总结一下。输出是“币”存储的地方。每一个输出会带一段解锁的脚本(字符串),决定了解锁输出的逻辑。每一个新的交易记录只是有一个输入和一个输出。一个输入对应一个从前一个交易记录(transaction)来的输出并提供用于解锁(unlocking)输出的数据以便使用输出中的币值(value)来创建新的输出(outputs)
但是哪个先出现呢:输入还是输出?
“先有鸡还是先有蛋”(The egg)在比特币当中,蛋是在鸡之前出现的。“输入对应输出”的逻辑与“鸡或者蛋”的场景类似:输入产生输出,输出让输入变得可能。在比特币当中,输出在输入之前出现。
当一个矿工开始挖坑(mining a block),将在区块当中添加币基交易记录(coinbase transaction)。币基交易记录是一种特殊的交易记录,并不需要已经存在的输出就能够产生。它可以从无直接创造出输出。相当于不需要鸡的蛋。这是矿工在挖新区块时获得的奖励。
正如你所知,在区块链当中有一个创世区块。正是这个创世区块在区块链中产生最新的输出。并不需要已有的输出,因为也没有已存在的交易记录和这样的输出可供使用。
  1. func NewCoinbaseTX(to, data string) *Transaction {
  2. if data == "" {
  3.   data = fmt.Sprintf("Reward to '%s'", to)
  4. }
  5. txin := TXInput{[]byte{}, -1, data}
  6. txout := TXOutput{subsidy, to}
  7. tx := Transaction{nil, []TXInput{txin}, []TXOutput{txout}}
  8. tx.SetID()
  9. return &tx
  10. }
复制代码
一个币基交易记录只有一个输入。在我们的实现当中它的
  1. Txid
复制代码
字段是空的,
  1. Vout
复制代码
字段为-1。而且,币基交易记录的
  1. ScriptSig
复制代码
字段并不保存脚本。相应的,存储随机数据。
在比特币当中,最早的币基交易记录包含以下信息:“The Times 03/Jan/2009 Chancellor on brink of second bailout for banks”。你可以点击这里:https://www.blockchain.com/btc/tx/4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b?show_adv=true 自我查阅。
  1. subsidy
复制代码
字段是奖励的数量。在比特币当中,这个数据不在任何地方保存,只是根据总的区块数进行计算:区块数除以210000。挖到创世区块产生50个比特币,然后每 210000 个区块奖励减半。在我们的实现当中,我们将奖励设定为常数(只是目前是这个样子的)。
在区块链中存储交易记录(Storing Transactions in Blockchain)从此以后,每一个区块必须存储至少一条交易记录,并且没有交易记录的挖坑也变得不再可能。这意味着我们将从
  1. Block
复制代码
结构体中移除
  1. Data
复制代码
字段,添加
  1. transactions
复制代码
代替:
  1. type Block struct {
  2. Timestamp     int64
  3. Transactions  []*Transaction
  4. PrevBlockHash []byte
  5. Hash          []byte
  6. Nonce         int
  7. }
复制代码
  1. NewBlock
复制代码
  1. NewGenesisBlock
复制代码
也必须相应地进行修改:
  1. func NewBlock(transactions []*Transaction, prevBlockHash []byte) *Block {
  2. block := &Block{time.Now().Unix(), transactions, prevBlockHash, []byte{}, 0}
  3. ...
  4. }
  5. func NewGenesisBlock(coinbase *Transaction) *Block {
  6. return NewBlock([]*Transaction{coinbase}, []byte{})
  7. }
复制代码
下一步需要改变的是修改区块链的创建方式:
  1. func CreateBlockchain(address string) *Blockchain {
  2. ...
  3. err = db.Update(func(tx *bolt.Tx) error {
  4.   cbtx := NewCoinbaseTX(address, genesisCoinbaseData)
  5.   genesis := NewGenesisBlock(cbtx)
  6.   b, err := tx.CreateBucket([]byte(blocksBucket))
  7.   err = b.Put(genesis.Hash, genesis.Serialize())
  8.   ...
  9. })
  10. ...
  11. }
复制代码
现在,这个函数将接收一个会收到创世区块奖励的地址。
工作证明(Proof-of-Work)工作证明算法(PoW)必须考虑区块链中存储的交易记录,为了确保保存交易记录的区块链的一致性和可靠性。因此,现在我们必须修改
  1. ProofOfWork.prepareData
复制代码
方法:
  1. func (pow *ProofOfWork) prepareData(nonce int) []byte {
  2. data := bytes.Join(
  3.   [][]byte{
  4.    pow.block.PrevBlockHash,
  5.    pow.block.HashTransactions(), // This line was changed
  6.    IntToHex(pow.block.Timestamp),
  7.    IntToHex(int64(targetBits)),
  8.    IntToHex(int64(nonce)),
  9.   },
  10.   []byte{},
  11. )
  12. return data
  13. }
复制代码
我们现在用
  1. pow.block.HashTransactions()
复制代码
代替
  1. pow.block.Data
复制代码
  1. func (b *Block) HashTransactions() []byte {
  2. var txHashes [][]byte
  3. var txHash [32]byte
  4. for _, tx := range b.Transactions {
  5.   txHashes = append(txHashes, tx.ID)
  6. }
  7. txHash = sha256.Sum256(bytes.Join(txHashes, []byte{}))
  8. return txHash[:]
  9. }
复制代码
再一次,我们用哈希来作为一种提供数据唯一标示的工具。我们要一个区块中的所有交易记录都让一个唯一的哈希值来标示。为了达到这个目的,我们获取了每一条交易记录的哈希,然后将它们串联起来,最后获得串联后数据的哈希值。
比特币用一个更加复杂的技术:它采用Merkle tree来组织一个区块中的所有交易记录,然后用树的根哈希值来确保PoW系统的运行。这种方法能够让我们快速的检验一个区块是否包含确定的交易记录,只要有根哈希值而不需要下载所有的交易记录。让我们检验一下到目前为止一切照常进行:
  1. $ blockchain_go createblockchain -address Ivan
  2. 00000093450837f8b52b78c25f8163bb6137caf43ff4d9a01d1b731fa8ddcc8a
  3. Done!
复制代码
非常好!我们获得了第一个挖矿奖励。但是我们如何检查余额呢?
未花费交易记录输出(Unspent Transaction Outputs)我们需要找出所有的未花费交易记录输出(UTXO)。未花费相当于这些输出并有与任何输入有关联。在上面的示意图中,以下为未花费交易记录:
tx0, output 1;
tx1, output 0;
tx3, output 0;
tx4, output 0.
当然,当我们检查余额时,我们并不需要全部,只需要那些我们有私钥可以解锁的部分。(目前我们还没有实现私钥,将用用户定义的地址来代替)。首先,让我们在输入和输出上定义锁定-解锁方法:
  1. func (in *TXInput) CanUnlockOutputWith(unlockingData string) bool {
  2. return in.ScriptSig == unlockingData
  3. }
  4. func (out *TXOutput) CanBeUnlockedWith(unlockingData string) bool {
  5. return out.ScriptPubKey == unlockingData
  6. }
复制代码
这里,我们只是将
  1. unlockingData
复制代码
  1. script
复制代码
字段进行了比较。这部分代码会在后续的文章中所有提升,在我们实现基于私钥的地址以后。
下一步-寻找含有未花费输出的交易记录-实现起来非常的难:
  1. func (bc *Blockchain) FindUnspentTransactions(address string) []Transaction {
  2.   var unspentTXs []Transaction
  3.   spentTXOs := make(map[string][]int)
  4.   bci := bc.Iterator()
  5.   for {
  6.     block := bci.Next()
  7.     for _, tx := range block.Transactions {
  8.       txID := hex.EncodeToString(tx.ID)
  9.     Outputs:
  10.       for outIdx, out := range tx.Vout {
  11.         // Was the output spent?
  12.         if spentTXOs[txID] != nil {
  13.           for _, spentOut := range spentTXOs[txID] {
  14.             if spentOut == outIdx {
  15.               continue Outputs
  16.             }
  17.           }
  18.         }
  19.         if out.CanBeUnlockedWith(address) {
  20.           unspentTXs = append(unspentTXs, *tx)
  21.         }
  22.       }
  23.       if tx.IsCoinbase() == false {
  24.         for _, in := range tx.Vin {
  25.           if in.CanUnlockOutputWith(address) {
  26.             inTxID := hex.EncodeToString(in.Txid)
  27.             spentTXOs[inTxID] = append(spentTXOs[inTxID], in.Vout)
  28.           }
  29.         }
  30.       }
  31.     }
  32.     if len(block.PrevBlockHash) == 0 {
  33.       break
  34.     }
  35.   }
  36.   return unspentTXs
  37. }
复制代码
由于交易记录存在在区块当中,我们必须检查区块链中的每一个区块。我们从输出开始:
  1. if out.CanBeUnlockedWith(address) {
  2. unspentTXs = append(unspentTXs, tx)
  3. }
复制代码
当一个输出由我们用来选择未花费交易记录的地址上的锁,那么这个输出就是我们想要的。
但是在我们取得它之前我们需要确认它是否已经与一个输入相关联:
  1. if spentTXOs[txID] != nil {
  2. for _, spentOut := range spentTXOs[txID] {
  3.   if spentOut == outIdx {
  4.    continue Outputs
  5.   }
  6. }
  7. }
复制代码
我们将忽略那些已经与输入相关的的输出(它们的价值已经转移到其它的输出,所以不能再统计它们)。检查输出以后,我们手机所有那些可以结果被提供的地址锁上的输出的输入(这对币基交易记录不适用,因为它们不解锁任何输出):
  1. if tx.IsCoinbase() == false {
  2.     for _, in := range tx.Vin {
  3.         if in.CanUnlockOutputWith(address) {
  4.             inTxID := hex.EncodeToString(in.Txid)
  5.             spentTXOs[inTxID] = append(spentTXOs[inTxID], in.Vout)
  6.         }
  7.     }
  8. }
复制代码
下面的函数返回一系列包含未花费输出的交易记录。为了计算余额,我们需要额外的一个以交易记录为参数并只返回输出的函数:
  1. func (bc *Blockchain) FindUTXO(address string) []TXOutput {
  2.        var UTXOs []TXOutput
  3.        unspentTransactions := bc.FindUnspentTransactions(address)
  4.        for _, tx := range unspentTransactions {
  5.                for _, out := range tx.Vout {
  6.                        if out.CanBeUnlockedWith(address) {
  7.                                UTXOs = append(UTXOs, out)
  8.                        }
  9.                }
  10.        }
  11.        return UTXOs
  12. }
复制代码
就这样,现在我可以实现
  1. getbalance
复制代码
命令:
  1. func (cli *CLI) getBalance(address string) {
  2. bc := NewBlockchain(address)
  3. defer bc.db.Close()
  4. balance := 0
  5. UTXOs := bc.FindUTXO(address)
  6. for _, out := range UTXOs {
  7.   balance += out.Value
  8. }
  9. fmt.Printf("Balance of '%s': %d\n", address, balance)
  10. }
复制代码
账户余额就是账户地址所锁定的所有交易记录输出价值的总和。
让我们检查在挖了创世区块的矿以后的余额:
  1. $ blockchain_go getbalance -address Ivan
  2. Balance of 'Ivan': 10
复制代码
这是我们的第一份钱!
发送币(Sending Coins)现在,我们想要给其他人发一些币过去。为此,我们需要创建一个新的交易记录,把它放到区块当中,然后挖坑。到目前为止,我们只实现了币基交易记录(特殊的一种交易记录),现在我们需要一个普通的交易记录:
  1. func NewUTXOTransaction(from, to string, amount int, bc *Blockchain) *Transaction {
  2. var inputs []TXInput
  3. var outputs []TXOutput
  4. acc, validOutputs := bc.FindSpendableOutputs(from, amount)
  5. if acc < amount {
  6.   log.Panic("ERROR: Not enough funds")
  7. }
  8. // Build a list of inputs
  9. for txid, outs := range validOutputs {
  10.   txID, err := hex.DecodeString(txid)
  11.   for _, out := range outs {
  12.    input := TXInput{txID, out, from}
  13.    inputs = append(inputs, input)
  14.   }
  15. }
  16. // Build a list of outputs
  17. outputs = append(outputs, TXOutput{amount, to})
  18. if acc > amount {
  19.   outputs = append(outputs, TXOutput{acc - amount, from}) // a change
  20. }
  21. tx := Transaction{nil, inputs, outputs}
  22. tx.SetID()
  23. return &tx
  24. }
复制代码
在创建新的输出之前,我们首先找出所有的未消费输出并且确保它们存有足够的币值。这是
  1. FindSpendableOutputs
复制代码
方法的功能。在这之后,每一个找到的输出创建一个与之对应的输入。然后,我们创建两个输出:
  • 一个用接受者的地址进行锁定。这是实际需要转移到其它地址的币值。
  • 一个用发送者的地址进行锁定。这是找零。当且仅当剩余未花费输出持有的总币值比新的交易记录所要求的多。记住:输出是不可分割的。
  1. FindSpendableOutput
复制代码
方法是基于我们早先定义的
  1. FindUnspentTransactions
复制代码
方法:
  1. func (bc *Blockchain) FindSpendableOutputs(address string, amount int) (int, map[string][]int) {
  2. unspentOutputs := make(map[string][]int)
  3. unspentTXs := bc.FindUnspentTransactions(address)
  4. accumulated := 0
  5. Work:
  6. for _, tx := range unspentTXs {
  7.   txID := hex.EncodeToString(tx.ID)
  8.   for outIdx, out := range tx.Vout {
  9.    if out.CanBeUnlockedWith(address) && accumulated < amount {
  10.     accumulated += out.Value
  11.     unspentOutputs[txID] = append(unspentOutputs[txID], outIdx)
  12.     if accumulated >= amount {
  13.      break Work
  14.     }
  15.    }
  16.   }
  17. }
  18. return accumulated, unspentOutputs
  19. }
复制代码
这个方法遍历所有的未花费交易记录并累计它们的币值。当累计的币值大于或者等于我们所有转移的量时,它停止工作然后返回累计币值(accumulated value)以及按交易记录ID进行分组的输出索引。我们不打算取比我们打算要花费的多。
现在我们可以修改
  1. Blockchain.MineBlock
复制代码
方法:
  1. func (bc *Blockchain) MineBlock(transactions []*Transaction) {
  2. ...
  3. newBlock := NewBlock(transactions, lastHash)
  4. ...
  5. }
复制代码
最终,让我们实现
  1. send
复制代码
命令:
  1. func (cli *CLI) send(from, to string, amount int) {
  2. bc := NewBlockchain(from)
  3. defer bc.db.Close()
  4. tx := NewUTXOTransaction(from, to, amount, bc)
  5. bc.MineBlock([]*Transaction{tx})
  6. fmt.Println("Success!")
  7. }
复制代码
发送币意味着创建一个交易记录然后以对一个区块以挖矿的形式将它加入到区块链当中。但是比特币并不立即执行,正如我们所实现的。反而将所有新交易记录放到内存池,当一个矿工准备去挖矿时,它将从内存池中拿走所有的交易记录并创建一个候选区块。当且仅当包含它们的区块被挖出被加入到区块链时所有的交易记录才会被确认。
让我们确认下发送币的功能是否工作正常:
  1. $ blockchain_go send -from Ivan -to Pedro -amount 6
  2. 00000001b56d60f86f72ab2a59fadb197d767b97d4873732be505e0a65cc1e37
  3. Success!
  4. $ blockchain_go getbalance -address Ivan
  5. Balance of 'Ivan': 4
  6. $ blockchain_go getbalance -address Pedro
  7. Balance of 'Pedro': 6
复制代码
非常好!现在,让我们创建更多的交易记录然后确保从不同的输出发出币也通用工作正常:
  1. $ blockchain_go send -from Pedro -to Helen -amount 2
  2. 00000099938725eb2c7730844b3cd40209d46bce2c2af9d87c2b7611fe9d5bdf
  3. Success!
  4. $ blockchain_go send -from Ivan -to Helen -amount 2
  5. 000000a2edf94334b1d94f98d22d7e4c973261660397dc7340464f7959a7a9aa
  6. Success!
复制代码
现在 Helen的币被锁定在两个输出当中:一个来自Pedro,另外一个来自Ivan。让我们将它们转移到其它人:
  1. $ blockchain_go send -from Helen -to Rachel -amount 3
  2. 000000c58136cffa669e767b8f881d16e2ede3974d71df43058baaf8c069f1a0
  3. Success!
  4. $ blockchain_go getbalance -address Ivan
  5. Balance of 'Ivan': 2
  6. $ blockchain_go getbalance -address Pedro
  7. Balance of 'Pedro': 4
  8. $ blockchain_go getbalance -address Helen
  9. Balance of 'Helen': 1
  10. $ blockchain_go getbalance -address Rachel
  11. Balance of 'Rachel': 3
复制代码
看起来非常棒!让我们测试一个失败案例:
  1. $ blockchain_go send -from Pedro -to Ivan -amount 5
  2. panic: ERROR: Not enough funds
  3. $ blockchain_go getbalance -address Pedro
  4. Balance of 'Pedro': 4
  5. $ blockchain_go getbalance -address Ivan
  6. Balance of 'Ivan': 2
复制代码
结论(Conclusion)哎呀!这并不容易,但现在我们还是有了交易记录!虽然,一些类比特币的加密币的特性还暂时缺失:
  • 地址。我们并没有实际、私有的地址
  • 奖励。挖矿时绝对无利可图的
  • UTXO集合。获取余额需要扫描整个区块链,当有很多区块时,这会非常耗时。并且,验证后面的交易记录也非常耗时。UTXO集试图解决这些问题并让通过交易记录的运营更加快
  • 内存池。这是交易记录在被推入区块之前存储交易记录的地方。在我们当前的实现当中,一个区块仅包含一个交易记录,然则这非常的低效。
链接
  • https://github.com/Jeiwan/blockchain_go/tree/part_4
  • https://en.bitcoin.it/wiki/Transaction
  • https://en.wikipedia.org/wiki/Merkle_tree
  • https://en.bitcoin.it/wiki/Coinbase
往期文章用Go打造区块链(1)—基础原型
用Go打造区块链(2)—工作证明机制(PoW)
用Go打造区块链(3)—数据存储及命令行(CLI)




分享到 :
0 人收藏
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

积分:10
帖子:2
精华:0
期权论坛 期权论坛
发布
内容

下载期权论坛手机APP