在Go中构建区块链-4-交易-1

使用golang构建简单的区块链

本文译自 Building Blockchain in Go. Part 4: Transactions 1

简介

交易是比特币的核心,区块链的唯一目的是以安全可靠的方式存储交易,因此在创建交易后没有人可以修改它们。 今天我们开始实施交易。 但因为这是一个相当大的主题,所以我将其分为两部分:在这一部分中,我们将实现交易的一般机制,在第二部分中我们将讨论细节。

另外,由于代码更改很大,因此在这里描述所有更改是没有意义的。 您可以在这里看到所有更改。

相较于传统支付

如果您曾经开发过 Web 应用程序,为了实现付款,您可能会在数据库中创建这些表:帐户和交易。 帐户将存储有关用户的信息,包括他们的个人信息和余额,而交易将存储有关从一个帐户转移到另一个帐户的资金的信息。 在比特币中,支付是以完全不同的方式实现的。 在这里:

  1. 没有账户。
  2. 没有余额。
  3. 没有地址。
  4. 没有币。
  5. 没有发送者和接收者。

由于区块链是一个公共且开放的数据库,我们不想存储有关钱包所有者的敏感信息。 币不收集在帐户中。 交易不会将资金从一个地址转移到另一个地址。 没有保存帐户余额的字段或属性。 只有交易。 那么交易里面有什么?

比特币交易

交易是输入和输出的组合:

1
2
3
4
5
type Transaction struct {
	ID   []byte
	Vin  []TXInput
	Vout []TXOutput
}

新交易的输入引用先前交易的输出(不过有一个例外,我们将在稍后讨论)。 输出是实际存储币的地方。 下图展示了交易的互连:

img.png

请注意:

  1. 有些输出未链接到输入。
  2. 在一笔交易中,输入可以引用多个交易的输出。
  3. 输入必须引用输出。

在整篇文章中,我们将使用“钱”、“币”、“花费”、“发送”、“账户”等词语。但比特币中没有这样的概念。 交易只是用脚本锁定值,该脚本只能由锁定它们的人解锁。

交易输出

让我们首先从输出开始:

1
2
3
4
type TXOutput struct {
	Value        int
	ScriptPubKey string
}

实际上,它是存储“币”的输出(注意上面的值字段)。 存储意味着用一个数学迷题锁定它们,该谜题存储在 ScriptPubKey 中。 在内部,比特币使用一种称为 Script 的脚本语言,用于定义输出锁定和解锁逻辑。 该语言非常原始(这是有意为之,以避免可能的黑客攻击和误用),但我们不会详细讨论它。 您可以在这里找到它的详细解释。

在比特币中,value字段存储的是satoshis的数量,而不是BTC的数量。 聪是比特币的一亿分之一(0.00000001 BTC),因此这是比特币中最小的货币单位(如美分)。

由于我们没有实现地址,因此我们现在将避免整个脚本相关逻辑。 ScriptPubKey 将存储任意字符串(用户定义的钱包地址)。

顺便说一句,拥有这样的脚本语言意味着比特币也可以用作智能合约平台。

关于输出的一个重要的事情是它们是不可分割的,这意味着您不能引用其值的一部分。 当新交易中引用输出时,它会作为一个整体被使用。 如果其值大于所需值,则会生成更改并将其发送回发送者。 这类似于现实世界的情况,例如,您用 5 美元的钞票购买 1 美元的商品,然后找回 4 美元。

交易输入

这是输入:

1
2
3
4
5
type TXInput struct {
	Txid      []byte
	Vout      int
	ScriptSig string
}

如前所述,输入引用先前的输出:Txid 存储该交易的 ID,Vout 存储交易中输出的索引。 ScriptSig 是一个脚本,提供在输出的 ScriptPubKey 中使用的数据。 如果数据正确,则可以解锁输出,并且其值可以用于生成新的输出; 如果不正确,则无法在输入中引用输出。 这是保证用户不能花费属于其他人的币的机制。

同样,由于我们还没有实现地址,ScriptSig 将仅存储任意用户定义的钱包地址。 我们将在下一篇文章中实现公钥和签名检查。

让我们总结一下。 输出是存储“币”的地方。 每个输出都带有一个解锁脚本,它决定了解锁输出的逻辑。 每笔新交易必须至少有一个输入和输出。 输入引用先前交易的输出,并提供在输出的解锁脚本中使用的数据(ScriptSig 字段)来解锁它并使用其值创建新的输出。

但先有什么:输入还是输出?

先有鸡还是先有蛋

在比特币中,先有蛋,后有鸡。 输入-引用-输出逻辑是经典的“先有鸡还是先有蛋”的情况:输入产生输出,输出使输入成为可能。 在比特币中,输出先于输入。

当矿工开始挖掘一个区块时,它会向其中添加一个 coinbase 交易。 coinbase 交易是一种特殊类型的交易,不需要先前存在的输出。 它凭空创造输出(即“币”)。 有蛋无鸡。 这是矿工开采新区块所获得的奖励。

如您所知,区块链的开头有创世块。 正是这个区块生成了区块链中的第一个输出。 并且不需要先前的输出,因为没有先前的交易,也没有这样的输出。

让我们创建一个 coinbase 交易:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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 交易包含以下消息:“泰晤士报 2009 年 1 月 3 日财政大臣即将对银行进行第二次救助”。你可以自己看看

补贴是奖励的金额。 在比特币中,这个数字不存储在任何地方,仅根据区块总数计算:区块数量除以210000。挖掘创世区块产生50 BTC,每210000个区块奖励减半。 在我们的实现中,我们将把奖励存储为常量(至少现在是这样😉)。

在区块链中存储交易

从现在开始,每个区块必须存储至少一笔交易,并且不再可能在没有交易的情况下挖掘区块。这意味着我们应该删除块的数据字段并存储交易:

1
2
3
4
5
6
7
type Block struct {
	Timestamp     int64
	Transactions  []*Transaction
	PrevBlockHash []byte
	Hash          []byte
	Nonce         int
}

NewBlock 和 NewGenesisBlock 也必须相应更改:

1
2
3
4
5
6
7
8
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{})
}

接下来要改变的是创建一个新的区块链:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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.HashTransactions() 代替 pow.block.Data,它是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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 树,并在工作量证明系统中使用树的根哈希。 这种方法允许快速检查一个块是否包含特定交易,仅具有根哈希而无需下载所有交易。

让我们检查一下到目前为止一切是否正确:

1
2
3
4
$ blockchain_go createblockchain -address Ivan
00000093450837f8b52b78c25f8163bb6137caf43ff4d9a01d1b731fa8ddcc8a

Done!

好的!我们收到了第一笔挖矿奖励。但我们如何查看余额呢?

未花费的交易输出

我们需要找到所有未花费的交易输出(UTXO)。 未花费意味着这些输出没有在任何输入中引用。 在上图中,这些是:

  1. tx0,输出1;
  2. tx1,输出0;
  3. tx3,输出0;
  4. tx4,输出0。

当然,当我们检查余额时,我们不需要全部,而只需要那些可以用我们拥有的密钥解锁的(目前我们没有实现密钥,将使用用户定义的地址代替)。 首先,我们定义输入和输出的锁定/解锁方法:

1
2
3
4
5
6
7
func (in *TXInput) CanUnlockOutputWith(unlockingData string) bool {
	return in.ScriptSig == unlockingData
}

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

这里我们只是将脚本字段与unlockingData进行比较。在我们实现基于私钥的地址之后,这些部分将在以后的文章中得到改进。

下一步 - 查找包含未使用输出的交易 - 非常困难:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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
}

由于交易存储在区块中,因此我们必须检查区块链中的每个区块。我们从输出开始:

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

如果输出被我们正在搜索未使用交易输出的同一地址锁定,那么这就是我们想要的输出。但在获取之前,我们需要检查输入中是否已经引用了输出:

1
2
3
4
5
6
7
if spentTXOs[txID] != nil {
	for _, spentOut := range spentTXOs[txID] {
		if spentOut == outIdx {
			continue Outputs
		}
	}
}

我们跳过输入中引用的那些(它们的值已移至其他输出,因此我们无法对它们进行计数)。 检查输出后,我们收集所有可以解锁使用所提供的地址锁定的输出的输入(这不适用于 coinbase 交易,因为它们不解锁输出):

1
2
3
4
5
6
7
8
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)
        }
    }
}

该函数返回包含未花费输出的交易列表。为了计算余额,我们还需要一个函数来接受交易并仅返回输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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 命令:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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)
}

账户余额是该账户地址锁定的所有未花费交易输出值的总和。

让我们在挖掘创世块后检查一下我们的余额:

1
2
$ blockchain_go getbalance -address Ivan
Balance of 'Ivan': 10

这是我们的第一笔钱!

发送币

现在,我们想发送一些币给其他人。 为此,我们需要创建一个新交易,将其放入一个区块中,然后挖掘该区块。 到目前为止,我们只实现了coinbase交易(这是一种特殊类型的交易),现在我们需要一个通用交易:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
}

在创建新的输出之前,我们首先必须找到所有未使用的输出并确保它们存储足够的价值。 这就是 FindSpendableOutputs 方法的作用。 之后,为每个找到的输出创建引用它的输入。 接下来,我们创建两个输出:

  1. 一个与接收者地址锁定的。 这是实际将币转移到其他地址。
  2. 与发件人地址锁定的一种。 这是一个改变。 仅当未花费的输出所具有的价值高于新交易所需的价值时,才会创建它。 请记住:输出是不可分割的。

FindSpendableOutputs 方法基于我们之前定义的

FindUnspentTransactions 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (bc *Blockchain) FindSpendableOutputs(address string, amount int) (int, map[string][]int) {
	unspentOutputs := make(map[string][]int)
	unspentTXs := bc.FindUnspentTransactions(address)
	accumulated := 0

Work:
	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方法:

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

最后,我们来实现发送命令:

1
2
3
4
5
6
7
8
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)中,当矿工准备好挖掘一个块时,它会从mempool中取出所有交易并创建一个候选块。 仅当包含交易的区块被开采并添加到区块链时,交易才会被确认。

让我们检查一下发送币是否有效:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ blockchain_go send -from Ivan -to Pedro -amount 6
00000001b56d60f86f72ab2a59fadb197d767b97d4873732be505e0a65cc1e37

Success!

$ blockchain_go getbalance -address Ivan
Balance of 'Ivan': 4

$ blockchain_go getbalance -address Pedro
Balance of 'Pedro': 6

好的!现在,让我们创建更多交易并确保从多个输出发送工作正常:

1
2
3
4
5
6
7
8
9
$ blockchain_go send -from Pedro -to Helen -amount 2
00000099938725eb2c7730844b3cd40209d46bce2c2af9d87c2b7611fe9d5bdf

Success!

$ blockchain_go send -from Ivan -to Helen -amount 2
000000a2edf94334b1d94f98d22d7e4c973261660397dc7340464f7959a7a9aa

Success!

现在,海伦的币被锁定在两个输出中:一个来自佩德罗,一个来自伊万。让我们将它们发送给其他人:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ blockchain_go send -from Helen -to Rachel -amount 3
000000c58136cffa669e767b8f881d16e2ede3974d71df43058baaf8c069f1a0

Success!

$ blockchain_go getbalance -address Ivan
Balance of 'Ivan': 2

$ blockchain_go getbalance -address Pedro
Balance of 'Pedro': 4

$ blockchain_go getbalance -address Helen
Balance of 'Helen': 1

$ blockchain_go getbalance -address Rachel
Balance of 'Rachel': 3

看起来不错!现在我们来测试一下失败情况:

1
2
3
4
5
6
7
8
$ blockchain_go send -from Pedro -to Ivan -amount 5
panic: ERROR: Not enough funds

$ blockchain_go getbalance -address Pedro
Balance of 'Pedro': 4

$ blockchain_go getbalance -address Ivan
Balance of 'Ivan': 2

结论

唷! 这并不容易,但我们现在有交易了! 尽管如此,类似比特币的加密货币缺少一些关键特征:

  1. 地址。 我们还没有真正的、基于私钥的地址。
  2. 奖励。 开采区块绝对不赚钱!
  3. UTXO 设置。 获得平衡需要扫描整个区块链,当区块数量很多时,这可能需要很长时间。 此外,如果我们想验证以后的交易,可能会花费很多时间。 UTXO集合旨在解决这些问题并使交易操作变得快速。
  4. 内存池。 这是交易在打包到区块之前存储的地方。 在我们当前的实现中,一个区块只包含一个交易,这是相当低效的。
本博客已稳定运行 小时 分钟
共发表 31 篇文章 · 总计 82.93 k 字
本站总访问量
Built with Hugo
主题 StackJimmy 设计