社区微信群开通啦,扫一扫抢先加入社区官方微信群
社区微信群
区块链是 21 世纪最具革命性的技术之一,它仍然处于不断成长的阶段,而且还有很多潜力尚未显现出来。 本质上,区块链只是一个分布式数据库而已。 不过,使它独一无二的是,区块链是一个公开的数据库,而不是一个私人数据库,也就是说,每个使用它的人都有一个完整或部分的副本。 只有经过其他数据库管理员的同意,才能向数据库中添加新的记录。 此外,也正是由于区块链,才使得加密货币和智能合约成为现实。
在本系列文章中,我们将实现一个简化版的区块链,基于它来构建简化版的加密货币。
让我们从 “区块链” 中的 “区块” 谈起。在区块链中,存储有效信息的是区块。比如,比特币区块存储的有效信息,就是比特币交易,交易信息也是所有加密货币的本质。除此以外,区块还包含了一些技术信息,比如版本,当前时间戳和前一个区块的哈希。
在本文中,我们并不会实现一个像比特币技术规范所描述的区块链,而是实现一个简化版的区块链,它仅包含了一些关键信息。看起来就像是这样:
type Block struct {
Timestamp int64
Data []byte
PrevBlockHash []byte
Hash []byte
}
在比特币技术规范中,Timestamp, PrevBlockHash, Hash 是区块头(block header),区块头是一个单独的数据结构。而交易,也就是这里的 Data, 是另一个单独的数据结构。为了简便起见,我把这两个混合在了一起。
那么,我们要如何计算哈希呢?如何计算哈希,是区块链一个非常重要的部分。正是由于这个特性,才使得区块链是安全的。计算一个哈希,是在计算上非常困难的一个操作。即使在高速电脑上,也要花费不少时间 (这就是为什么人们会购买 GPU 来挖比特币) 。这是一个有意为之的架构设计,它故意使得加入新的区块十分困难,因此可以保证区块一旦被加入以后,就很难再进行修改。在本系列未来几篇文章中,我们将会讨论和实现这个机制。
目前,我们仅取了 Block 结构的一些字段(Timestamp, Data 和 PrevBlockHash),并将它们相互连接起来,然后在连接后的结果上计算一个 SHA-256 的哈希. 让我们在SetHash
方法中完成这个任务:
func (b *Block) SetHash() {
timestamp := []byte(strconv.FormatInt(b.Timestamp, 10))
headers := bytes.Join([][]byte{b.PrevBlockHash, b.Data, timestamp}, []byte{})
hash := sha256.Sum256(headers)
b.Hash = hash[:]
}
接下来,按照 Golang 的惯例,我们会实现一个用于简化创建一个区块的函数:
func NewBlock(data string, prevBlockHash []byte) *Block {
block := &Block{time.Now().Unix(), []byte(data), prevBlockHash, []byte{}}
block.SetHash()
return block
}
这就是区块部分的全部内容了!
下面让我们来实现一个区块链。本质上,区块链仅仅是一个有着特定结构的数据库,是一个有序,后向连接的列表。这也就是说,区块按照插入的顺序进行存储,每个块都被连接到前一个块。这样的结构,能够让我们快速地获取链上的最新块,并且高效地通过哈希来检索一个块。
在 Golang 中,可以通过一个 array 和 map 来实现这个结构:array 存储有序的哈希(Golang 中 array 是有序的),map 存储 hask -> block 对(Golang 中, map 是无序的)。 但是在基本的原型阶段,我们只用到了 array,因为现在还不需要通过哈希来获取块。
type Blockchain struct {
blocks []*Block
}
这就是我们的第一个区块链!我从来没有想过它会是这么容易。
现在,让我们能够给它添加一个块:
func (bc *Blockchain) AddBlock(data string) {
prevBlock := bc.blocks[len(bc.blocks)-1]
newBlock := NewBlock(data, prevBlock.Hash)
bc.blocks = append(bc.blocks, newBlock)
}
完成!不过,真的就这样了吗?
为了加入一个新的块,我们必须要有一个已有的块,但是,现在我们的链是空的,一个块都没有!所以,在任何一个区块链中,都必须至少有一个块。这样的块,也就是链中的第一个块,通常叫做创世块(genesis block). 让我们实现一个方法来创建一个创世块:
func NewGenesisBlock() *Block {
return NewBlock("Genesis Block", []byte{})
}
现在,我们可以实现一个函数来创建有创世块的区块链:
func NewBlockchain() *Blockchain {
return &Blockchain{[]*Block{NewGenesisBlock()}}
}
来检查一个我们的区块链是否如期工作:
func main() {
bc := NewBlockchain()
bc.AddBlock("Send 1 BTC to Ivan")
bc.AddBlock("Send 2 more BTC to Ivan")
for _, block := range bc.blocks {
fmt.Printf("Prev. hash: %xn", block.PrevBlockHash)
fmt.Printf("Data: %sn", block.Data)
fmt.Printf("Hash: %xn", block.Hash)
fmt.Println()
}
}
输出:
Prev. hash:
Data: Genesis Block
Hash: aff955a50dc6cd2abfe81b8849eab15f99ed1dc333d38487024223b5fe0f1168
Prev. hash: aff955a50dc6cd2abfe81b8849eab15f99ed1dc333d38487024223b5fe0f1168
Data: Send 1 BTC to Ivan
Hash: d75ce22a840abb9b4e8fc3b60767c4ba3f46a0432d3ea15b71aef9fde6a314e1
Prev. hash: d75ce22a840abb9b4e8fc3b60767c4ba3f46a0432d3ea15b71aef9fde6a314e1
Data: Send 2 more BTC to Ivan
Hash: 561237522bb7fcfbccbc6fe0e98bbbde7427ffe01c6fb223f7562288ca2295d1
我们创建了一个非常简单的区块链原型:它仅仅是一个数组构成的一系列区块,每个块都与前一个块相关联。真实的区块链要比这复杂得多。在我们的区块链中,加入新的块非常简单,而且很快,但是在真实的区块链中,加入新的块需要很多工作:你必须要经过十分繁重的计算(这个机制叫做工作量证明),来获得添加一个新块的权力。并且,区块链是一个没有单一决策者的分布式数据库。因此,一个新的块必须要被网络的其他参与者确认和同意(这个机制叫做共识(consensus))。还有一点,我们的区块链还没有任何的交易!
在接下来的文章的我们将会一一覆盖这些特性。
在 前面一文 中,我们构造了一个非常简单的数据结构,这个数据结构也是整个区块链数据库的核心。目前所完成的区块链原型,已经可以通过链式关系把区块相互关联起来:每个块都被连接到前一个块。
但是,我们实现的区块链有一个巨大的缺点:向链中加入区块太容易和廉价了。而区块链和比特币的其中一个核心就是,要想加入新的区块,必须先完成一些非常困难的工作。在本文,我们将会解决这个缺点。
区块链的一个关键点就是,一个人必须经过一系列困难的工作,才能将数据放入到区块链中。正是这种困难的工作,才使得区块链是安全和一致的。此外,完成这个工作的人也会获得奖励(这也就是通过挖矿获得币)。
这个机制与生活的一个现象非常类似:一个人必须通过努力工作,才能够获得回报或者奖励,用以支撑他们的生活。在区块链中,是通过网络中的参与者(矿工)不断的工作来支撑整个网络,也就是矿工不断地向区块链中加入新块,然后获得相应的奖励。作为他们努力工作的结果,新生成的区块就能够被安全地被加入到区块链中,这种机制维护了整个区块链数据库的稳定性。值得注意的是,完成了这个工作的人必须要证明这一点,他必须要证明确实是他完成了这些工作。
整个 “努力工作并进行证明” 的机制,就叫做工作量证明(proof-of-work)。要想完成工作非常地不容易,因为这需要大量的计算能力:即便是高性能计算机,也无法在短时间内快速完成。此外,这个工作的困难度会随着时间不断增长,以保持每个小时大概出 6 个新块的速度。在比特币中,这个工作的目的是为了找到一个块的哈希,同时这个哈希满足了一些必要条件。这个哈希,也就充当了证明的角色。因此,寻求证明(寻找有效哈希),就是实际要做的事情。
在本节中,我们会讨论哈希计算。如果你已经熟悉了这个概念,可以跳过这一节。
获得指定数据的一个哈希值的过程,就叫做哈希计算。一个哈希,就是对所计算数据的一个唯一的表示。一个哈希函数输入任意大小的数据,输出一个固定大小的哈希值。下面是哈希的几个关键特性:
-哈希过程-
哈希函数被广泛用于检测数据的一致性。一些软件提供者除了提供软件包以外,还会发布校验和。当下载完一个文件以后,你可以用哈希函数对下载好的文件计算一个哈希,并与作者提供的哈希进行比较,以此来保证文件下载的完整性。
在区块链中,哈希被用于保证一个块的一致性。哈希算法的输入数据包含了前一个块的哈希,因此使得不太可能(或者,至少很困难)去修改链中的一个块:因为如果一个人想要修改前面一个块的哈希,那么他必须要重新计算这个块以及后面所有块的哈希。
Hashcash
比特币使用 Hashcash ,一个最初用来防止垃圾邮件的工作量证明算法。它可以被分解为以下步骤:
因此,这是一个暴力算法:改变计数器,计算一个新的哈希,检查,增加计数器,计算一个哈希,检查,如此反复。这也是为什么说它是在计算上是非常昂贵的,因为这一步需要如此反复不断地计算和检查。
现在,让我们来仔细看一下一个哈希要满足的必要条件。在原始的 Hashcash 实现中,它的要求是 “一个哈希的前 20 位必须是 0”。在比特币中,这个要求会随着时间而不断变化。因为按照设计,必须保证每 10 分钟生成一个块,而不论计算能力会随着时间增长,或者是会有越来越多的矿工进入网络,所以需要动态调整这个必要条件。
为了阐释这一算法,我从前一个例子(“I like donuts”)中取得数据,并且找到了一个前 3 个字节是全是 0 的哈希。
-一个以三个0开头的哈希值-
ca07ca 是计数器的 16 进制值,十进制的话是 13240266.
好了,完成了理论层面,来开始写代码吧!首先,定义挖矿的难度值:
const targetBits = 24
在比特币中,当一个块被挖出来以后,“target bits” 代表了区块头里存储的难度。这里的 24 指的是算出来的哈希前 24 位必须是 0,用 16 进制表示化的话,就是前 6 位必须是 0,这一点可以在最后的输出可以看出来。目前不会实现一个动态调整目标的算法,所以将难度定义为一个全局的常量即可。
24 其实是一个可以任意取的数字,目的是要有一个目标(target)而已,这个目标占据不到 256 位的内存空间。同时,我们想要有足够的差异性,但是又不至于大的过分,因为差异性越大,就越难找到一个合适的哈希。
type ProofOfWork struct {
block *Block
target *big.Int
}
func NewProofOfWork(b *Block) *ProofOfWork {
target := big.NewInt(1)
target.Lsh(target, uint(256-targetBits))
pow := &ProofOfWork{b, target}
return pow
}
这里,我们构造了 ProofOfWork 结构,里面存储了指向一个块和一个目标的指针。“目标” ,也就是前一节中所描述的必要条件。这里使用了一个 大 整数,我们将哈希与目标进行比较:先把一个哈希转换成一个大整数,然后检测它是否小于目标。
在** NewProofOfWork** 函数中,我们将** big.Int** 初始化为 1,然后左移 256 - targetBits
位。256 是一个 SHA-256 哈希的位数,我们将要使用的是 SHA-256 哈希算法。target(目标) 的 16 进制形式为:
0x10000000000000000000000000000000000000000000000000000000000
它在内存上占据了 29 个字节。下面是与前面例子哈希的形式化比较:
0fac49161af82ed938add1d8725835cc123a1a87b1b196488360e58d4bfb51e3
0000010000000000000000000000000000000000000000000000000000000000
0000008b0f41ec78bab747864db66bcb9fb89920ee75f43fdaaeb5544f7f76ca
第一个哈希(基于 “I like donuts” 计算)比目标要大,因此它并不是一个有效的工作量证明。第二个哈希(基于 “I like donutsca07ca” 计算)比目标要小,所以是一个有效的证明。
译者注:评论有人提出上面的形式化比较有些“言不符实”,其实它应该并非由 “I like donuts” 而来,但是原文作者表达的意思是没问题的,可能是疏忽而已。下面是我做的一个小实验:
package main
import (
"crypto/sha256"
"fmt"
"math/big"
)
func main() {
data1 := []byte("I like donuts")
data2 := []byte("I like donutsca07ca")
targetBits := 24
target := big.NewInt(1)
target.Lsh(target, uint(256-targetBits))
fmt.Printf("%xn", sha256.Sum256(data1))
fmt.Printf("%64xn", target)
fmt.Printf("%xn", sha256.Sum256(data2))
}
输出:
-实验-
你可以把目标想象为一个范围的上界:如果一个数(由哈希转换而来)比上界要小,那么这是有效的,反之无效。因为要求比上界要小,所以会导致更少的有效数字。因此,也就需要通过困难的工作(一系列反复的计算),才能找到一个有效的数字。
现在,我们需要有数据来进行哈希,准备数据:
func (pow *ProofOfWork) prepareData(nonce int) []byte {
data := bytes.Join(
[][]byte{
pow.block.PrevBlockHash,
pow.block.Data,
IntToHex(pow.block.Timestamp),
IntToHex(int64(targetBits)),
IntToHex(int64(nonce)),
},
[]byte{},
)
return data
}
这个部分比较直观:只需要将 target ,nonce 与 Block 进行合并。这里的 nonce ,就是上面 Hashcash 所提到的计数器,它是一个密码学术语。
很好,到这里,所有的准备工作就完成了,下面来实现 PoW 算法的核心:
func (pow *ProofOfWork) Run() (int, []byte) {
var hashInt big.Int
var hash [32]byte
nonce := 0
fmt.Printf("Mining the block containing "%s"n", pow.block.Data)
for nonce < maxNonce {
data := pow.prepareData(nonce)
hash = sha256.Sum256(data)
hashInt.SetBytes(hash[:])
if hashInt.Cmp(pow.target) == -1 {
fmt.Printf("r%x", hash)
break
} else {
nonce++
}
}
fmt.Print("nn")
return nonce, hash[:]
}
首先我们对变量进行初始化:
然后开始一个 “无限” 循环:maxNonce 对这个循环进行了限制, 它等于 math.MaxInt64。这是为了避免 nonce 可能出现的溢出。尽管我们的 PoW 实现的难度太小了,以至于计数器其实不太可能会溢出,但最好还是以防万一检查一下。
在这个循环中,我们做的事情有:
跟之前所讲的一样简单。现在我们可以移除 Block 的 SetHash 方法,然后修改 NewBlock 函数:
func NewBlock(data string, prevBlockHash []byte) *Block {
block := &Block{time.Now().Unix(), []byte(data), prevBlockHash, []byte{}, 0}
pow := NewProofOfWork(block)
nonce, hash := pow.Run()
block.Hash = hash[:]
block.Nonce = nonce
return block
}
在这里,你可以看到 nonce 被保存为 Block 的一个属性。这是十分有必要的,因为待会儿我们需要用 nonce 来对这个工作量进行证明。Block 结构现在看起来像是这样:
type Block struct {
Timestamp int64
Data []byte
PrevBlockHash []byte
Hash []byte
Nonce int
}
好了!现在让我们来运行一下是否正常工作:
Mining the block containing "Genesis Block"
00000041662c5fc2883535dc19ba8a33ac993b535da9899e593ff98e1eda56a1
Mining the block containing "Send 1 BTC to Ivan"
00000077a856e697c69833d9effb6bdad54c730a98d674f73c0b30020cc82804
Mining the block containing "Send 2 more BTC to Ivan"
000000b33185e927c9a989cc7d5aaaed739c56dad9fd9361dea558b9bfaf5fbe
Prev. hash:
Data: Genesis Block
Hash: 00000041662c5fc2883535dc19ba8a33ac993b535da9899e593ff98e1eda56a1
Prev. hash: 00000041662c5fc2883535dc19ba8a33ac993b535da9899e593ff98e1eda56a1
Data: Send 1 BTC to Ivan
Hash: 00000077a856e697c69833d9effb6bdad54c730a98d674f73c0b30020cc82804
Prev. hash: 00000077a856e697c69833d9effb6bdad54c730a98d674f73c0b30020cc82804
Data: Send 2 more BTC to Ivan
Hash: 000000b33185e927c9a989cc7d5aaaed739c56dad9fd9361dea558b9bfaf5fbe
成功了!你可以看到每个哈希都是 3 个字节的 0 开始,并且获得这些哈希需要花费一些时间。
还剩下一件事情需要做,对工作量证明进行验证:
func (pow *ProofOfWork) Validate() bool {
var hashInt big.Int
data := pow.prepareData(pow.block.Nonce)
hash := sha256.Sum256(data)
hashInt.SetBytes(hash[:])
isValid := hashInt.Cmp(pow.target) == -1
return isValid
}
这里,就是我们就用到了上面保存的 nonce。
再来检测一次是否正常工作:
func main() {
...
for _, block := range bc.blocks {
...
pow := NewProofOfWork(block)
fmt.Printf("PoW: %sn", strconv.FormatBool(pow.Validate()))
fmt.Println()
}
}
输出:
...
Prev. hash:
Data: Genesis Block
Hash: 00000093253acb814afb942e652a84a8f245069a67b5eaa709df8ac612075038
PoW: true
Prev. hash: 00000093253acb814afb942e652a84a8f245069a67b5eaa709df8ac612075038
Data: Send 1 BTC to Ivan
Hash: 0000003eeb3743ee42020e4a15262fd110a72823d804ce8e49643b5fd9d1062b
PoW: true
Prev. hash: 0000003eeb3743ee42020e4a15262fd110a72823d804ce8e49643b5fd9d1062b
Data: Send 2 more BTC to Ivan
Hash: 000000e42afddf57a3daa11b43b2e0923f23e894f96d1f24bfd9b8d2d494c57a
PoW: true
从下图可以看出,这次我们产生三个块花费了一分多钟,比没有工作量证明之前慢了很多(也就是成本高了很多):
-输出-
我们的区块链离真正的区块链又进了一步:现在需要经过一些困难的工作才能加入新的块,因此挖矿就有可能了。但是,它还缺少一些至关重要的特性:区块链数据库并不是持久化的,没有钱包,地址,交易,也没有共识机制。不过,所有的这些,我们都会在接下来的文章中实现,现在,愉快地挖矿吧!
链接:
- Full source codes
- Blockchain hashing algorithm
- Proof of work
- Hashcash
本文源代码:part_2
到目前为止,我们已经构建了一个有工作量证明机制的区块链。有了工作量证明,挖矿也就有了着落。虽然目前的实现离一个有着完整功能的区块链越来越近了,但是它仍然缺少了一些重要的特性。在今天的内容中,我们会将区块链持久化到一个数据库中,然后会提供一个简单的命令行接口,用来完成一些与区块链的交互操作。本质上,区块链是一个分布式数据库,不过,我们暂时先忽略 “分布式” 这个部分,仅专注于 “存储” 这一点。
目前,我们的区块链实现里面并没有用到数据库,而是在每次运行程序时,简单地将区块链存储在内存中。那么一旦程序退出,所有的内容就都消失了。我们没有办法再次使用这条链,也没有办法与其他人共享,所以我们需要把它存储到磁盘上。
那么,我们要用哪个数据库呢?实际上,任何一个数据库都可以。在 比特币原始论文 中,并没有提到要使用哪一个具体的数据库,它完全取决于开发者如何选择。 Bitcoin Core ,最初由中本聪发布,现在是比特币的一个参考实现,它使用的是 LevelDB。而我们将要使用的是...
因为它:
BoltDB GitHub 上的 README 是这么说的:
Bolt 是一个纯键值存储的 Go 数据库,启发自 Howard Chu 的 LMDB. 它旨在为那些无须一个像 Postgres 和 MySQL 这样有着完整数据库服务器的项目,提供一个简单,快速和可靠的数据库。
由于 Bolt 意在用于提供一些底层功能,简洁便成为其关键所在。它的
API 并不多,并且仅关注值的获取和设置。仅此而已。
听起来跟我们的需求完美契合!来快速过一下:
Bolt 使用键值存储,这意味着它没有像 SQL RDBMS (MySQL,PostgreSQL 等等)的表,没有行和列。相反,数据被存储为键值对(key-value pair,就像 Golang 的 map)。键值对被存储在 bucket 中,这是为了将相似的键值对进行分组(类似 RDBMS 中的表格)。因此,为了获取一个值,你需要知道一个 bucket 和一个键(key)。
需要注意的一个事情是,Bolt 数据库没有数据类型:键和值都是字节数组(byte array)。鉴于需要在里面存储 Go 的结构(准确来说,也就是存储(块)Block),我们需要对它们进行序列化,也就说,实现一个从 Go struct 转换到一个 byte array 的机制,同时还可以从一个 byte array 再转换回 Go struct。虽然我们将会使用 encoding/gob 来完成这一目标,但实际上也可以选择使用 JSON, XML, Protocol Buffers 等等。之所以选择使用 encoding/gob, 是因为它很简单,而且是 Go 标准库的一部分。
在开始实现持久化的逻辑之前,我们首先需要决定到底要如何在数据库中进行存储。为此,我们可以参考 Bitcoin Core 的做法:
简单来说,Bitcoin Core 使用两个 “bucket” 来存储数据:
此外,出于性能的考虑,Bitcoin Core 将每个区块(block)存储为磁盘上的不同文件。如此一来,就不需要仅仅为了读取一个单一的块而将所有(或者部分)的块都加载到内存中。但是,为了简单起见,我们并不会实现这一点。
在 blocks 中,key -> value 为:
key | value |
---|---|
b + 32 字节的 block hash |
block index record |
f + 4 字节的 file number |
file information record |
l + 4 字节的 file number |
the last block file number used |
R + 1 字节的 boolean |
是否正在 reindex |
F + 1 字节的 flag name length + flag name string |
1 byte boolean: various flags that can be on or off |
t + 32 字节的 transaction hash |
transaction index record |
在 chainstate,key -> value 为:
key | value |
---|---|
c + 32 字节的 transaction hash |
unspent transaction output record for that transaction |
B |
32 字节的 block hash: the block hash up to which the database represents the unspent transaction outputs |
详情可见 这里。
因为目前还没有交易,所以我们只需要 blocks bucket。另外,正如上面提到的,我们会将整个数据库存储为单个文件,而不是将区块存储在不同的文件中。所以,我们也不会需要文件编号(file number)相关的东西。最终,我们会用到的键值对有:
l
-> 链中最后一个块的 hash这就是实现持久化机制所有需要了解的内容了。
上面提到,在 BoltDB 中,值只能是 []byte
类型,但是我们想要存储 Block
结构。所以,我们需要使用 encoding/gob 来对这些结构进行序列化。
让我们来实现 Block
的 Serialize
方法(为了简洁起见,此处略去了错误处理):
func (b *Block) Serialize() []byte {
var result bytes.Buffer
encoder := gob.NewEncoder(&result)
err := encoder.Encode(b)
return result.Bytes()
}
这个部分比较直观:首先,我们定义一个 buffer 存储序列化之后的数据。然后,我们初始化一个 gob
encoder 并对 block 进行编码,结果作为一个字节数组返回。
接下来,我们需要一个解序列化的函数,它会接受一个字节数组作为输入,并返回一个 Block
. 它不是一个方法(method),而是一个单独的函数(function):
func DeserializeBlock(d []byte) *Block {
var block Block
decoder := gob.NewDecoder(bytes.NewReader(d))
err := decoder.Decode(&block)
return &block
}
这就是序列化部分的内容了。
让我们从 NewBlockchain
函数开始。在之前的实现中,它会创建一个新的Blockchain
实例,并向其中加入创世块。而现在,我们希望它做的事情有:
Blockchain
实例Blockchain
实例的 tip 为数据库中存储的最后一个块的哈希Blockchain
实例,其 tip 指向创世块(tip 有尾部,尖端的意思,在这里 tip 存储的是最后一个块的哈希)代码大概是这样:
func NewBlockchain() *Blockchain {
var tip []byte
db, err := bolt.Open(dbFile, 0600, nil)
err = db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
if b == nil {
genesis := NewGenesisBlock()
b, err := tx.CreateBucket([]byte(blocksBucket))
err = b.Put(genesis.Hash, genesis.Serialize())
err = b.Put([]byte("l"), genesis.Hash)
tip = genesis.Hash
} else {
tip = b.Get([]byte("l"))
}
return nil
})
bc := Blockchain{tip, db}
return &bc
}
来一段一段地看下代码:
db, err := bolt.Open(dbFile, 0600, nil)
这是打开一个 BoltDB 文件的标准做法。注意,即使不存在这样的文件,它也不会返回错误。
err = db.Update(func(tx *bolt.Tx) error {
...
})
在 BoltDB 中,数据库操作通过一个事务(transaction)进行操作。有两种类型的事务:只读(read-only)和读写(read-write)。这里,打开的是一个读写事务(db.Update(...)
),因为我们可能会向数据库中添加创世块。
b := tx.Bucket([]byte(blocksBucket))
if b == nil {
genesis := NewGenesisBlock()
b, err := tx.CreateBucket([]byte(blocksBucket))
err = b.Put(genesis.Hash, genesis.Serialize())
err = b.Put([]byte("l"), genesis.Hash)
tip = genesis.Hash
} else {
tip = b.Get([]byte("l"))
}
这里是函数的核心。在这里,我们先获取了存储区块的 bucket:如果存在,就从中读取 l
键;如果不存在,就生成创世块,创建 bucket,并将区块保存到里面,然后更新 l
键以存储链中最后一个块的哈希。
另外,注意创建 Blockchain
一个新的方式:
bc := Blockchain{tip, db}
这次,我们不在里面存储所有的区块了,而是仅存储区块链的 tip。另外,我们存储了一个数据库连接。因为我们想要一旦打开它的话,就让它一直运行,直到程序运行结束。因此,Blockchain
的结构现在看起来是这样:
type Blockchain struct {
tip []byte
db *bolt.DB
}
接下来我们想要更新的是 AddBlock
方法:现在向链中加入区块,就不是像之前向一个数组中加入一个元素那么简单了。从现在开始,我们会将区块存储在数据库里面:
func (bc *Blockchain) AddBlock(data string) {
var lastHash []byte
err := bc.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
lastHash = b.Get([]byte("l"))
return nil
})
newBlock := NewBlock(data, lastHash)
err = bc.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
err := b.Put(newBlock.Hash, newBlock.Serialize())
err = b.Put([]byte("l"), newBlock.Hash)
bc.tip = newBlock.Hash
return nil
})
}
继续来一段一段分解开来:
err := bc.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
lastHash = b.Get([]byte("l"))
return nil
})
这是 BoltDB 事务的另一个类型(只读)。在这里,我们会从数据库中获取最后一个块的哈希,然后用它来挖出一个新的块的哈希:
newBlock := NewBlock(data, lastHash)
b := tx.Bucket([]byte(blocksBucket))
err := b.Put(newBlock.Hash, newBlock.Serialize())
err = b.Put([]byte("l"), newBlock.Hash)
bc.tip = newBlock.Hash
现在,产生的所有块都会被保存到一个数据库里面,所以我们可以重新打开一个链,然后向里面加入新块。但是在实现这一点后,我们失去了之前一个非常好的特性:我们再也无法打印区块链的区块了,因为现在不是将区块存储在一个数组,而是放到了数据库里面。让我们来解决这个问题!
BoltDB 允许对一个 bucket 里面的所有 key 进行迭代,但是所有的 key 都以字节序进行存储,而且我们想要以区块能够进入区块链中的顺序进行打印。此外,因为我们不想将所有的块都加载到内存中(因为我们的区块链数据库可能很大!或者现在可以假装它可能很大),我们将会一个一个地读取它们。故而,我们需要一个区块链迭代器(BlockchainIterator):
type BlockchainIterator struct {
currentHash []byte
db *bolt.DB
}
每当要对链中的块进行迭代时,我们就会创建一个迭代器,里面存储了当前迭代的块哈希(currentHash)和数据库的连接(db)。通过 db
,迭代器逻辑上被附属到一个区块链上(这里的区块链指的是存储了一个数据库连接的 Blockchain
实例),并且通过 Blockchain
方法进行创建:
func (bc *Blockchain) Iterator() *BlockchainIterator {
bci := &BlockchainIterator{bc.tip, bc.db}
return bci
}
注意,迭代器的初始状态为链中的 tip,因此区块将从头到尾,也就是从最新的到最旧的进行获取。实际上,选择一个 tip 就是意味着给一条链“投票”。一条链可能有多个分支,最长的那条链会被认为是主分支。在获得一个 tip (可以是链中的任意一个块)之后,我们就可以重新构造整条链,找到它的长度和需要构建它的工作。这同样也意味着,一个 tip 也就是区块链的一种标识符。
BlockchainIterator
只会做一件事情:返回链中的下一个块。
func (i *BlockchainIterator) Next() *Block {
var block *Block
err := i.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
encodedBlock := b.Get(i.currentHash)
block = DeserializeBlock(encodedBlock)
return nil
})
i.currentHash = block.PrevBlockHash
return block
}
这就是数据库部分的内容了!
到目前为止,我们的实现还没有提供一个与程序交互的接口:目前只是在 main
函数中简单执行了 NewBlockchain
和 bc.AddBlock
。是时候改变了!现在我们想要拥有这些命令:
blockchain_go addblock "Pay 0.031337 for a coffee"
blockchain_go printchain
所有命令行相关的操作都会通过 CLI
结构进行处理:
type CLI struct {
bc *Blockchain
}
它的 “入口” 是 Run
函数:
func (cli *CLI) Run() {
cli.validateArgs()
addBlockCmd := flag.NewFlagSet("addblock", flag.ExitOnError)
printChainCmd := flag.NewFlagSet("printchain", flag.ExitOnError)
addBlockData := addBlockCmd.String("data", "", "Block data")
switch os.Args[1] {
case "addblock":
err := addBlockCmd.Parse(os.Args[2:])
case "printchain":
err := printChainCmd.Parse(os.Args[2:])
default:
cli.printUsage()
os.Exit(1)
}
if addBlockCmd.Parsed() {
if *addBlockData == "" {
addBlockCmd.Usage()
os.Exit(1)
}
cli.addBlock(*addBlockData)
}
if printChainCmd.Parsed() {
cli.printChain()
}
}
我们会使用标准库里面的 flag 包来解析命令行参数:
addBlockCmd := flag.NewFlagSet("addblock", flag.ExitOnError)
printChainCmd := flag.NewFlagSet("printchain", flag.ExitOnError)
addBlockData := addBlockCmd.String("data", "", "Block data")
首先,我们创建两个子命令: addblock
和 printchain
, 然后给 addblock
添加 -data
标志。printchain
没有任何标志。
switch os.Args[1] {
case "addblock":
err := addBlockCmd.Parse(os.Args[2:])
case "printchain":
err := printChainCmd.Parse(os.Args[2:])
default:
cli.printUsage()
os.Exit(1)
}
然后,我们检查用户提供的命令,解析相关的 flag
子命令:
if addBlockCmd.Parsed() {
if *addBlockData == "" {
addBlockCmd.Usage()
os.Exit(1)
}
cli.addBlock(*addBlockData)
}
if printChainCmd.Parsed() {
cli.printChain()
}
接着检查解析是哪一个子命令,并调用相关函数:
func (cli *CLI) addBlock(data string) {
cli.bc.AddBlock(data)
fmt.Println("Success!")
}
func (cli *CLI) printChain() {
bci := cli.bc.Iterator()
for {
block := bci.Next()
fmt.Printf("Prev. hash: %xn", block.PrevBlockHash)
fmt.Printf("Data: %sn", block.Data)
fmt.Printf("Hash: %xn", block.Hash)
pow := NewProofOfWork(block)
fmt.Printf("PoW: %sn", strconv.FormatBool(pow.Validate()))
fmt.Println()
if len(block.PrevBlockHash) == 0 {
break
}
}
}
这部分内容跟之前的很像,唯一的区别是我们现在使用的是 BlockchainIterator
对区块链中的区块进行迭代:
记得不要忘了对 main
函数作出相应的修改:
func main() {
bc := NewBlockchain()
defer bc.db.Close()
cli := CLI{bc}
cli.Run()
}
注意,无论提供什么命令行参数,都会创建一个新的链。
这就是今天的所有内容了! 来看一下是不是如期工作:
$ blockchain_go printchain
No existing blockchain found. Creating a new one...
Mining the block containing "Genesis Block"
000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109b
Prev. hash:
Data: Genesis Block
Hash: 000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109b
PoW: true
$ blockchain_go addblock -data "Send 1 BTC to Ivan"
Mining the block containing "Send 1 BTC to Ivan"
000000d7b0c76e1001cdc1fc866b95a481d23f3027d86901eaeb77ae6d002b13
Success!
$ blockchain_go addblock -data "Pay 0.31337 BTC for a coffee"
Mining the block containing "Pay 0.31337 BTC for a coffee"
000000aa0748da7367dec6b9de5027f4fae0963df89ff39d8f20fd7299307148
Success!
$ blockchain_go printchain
Prev. hash: 000000d7b0c76e1001cdc1fc866b95a481d23f3027d86901eaeb77ae6d002b13
Data: Pay 0.31337 BTC for a coffee
Hash: 000000aa0748da7367dec6b9de5027f4fae0963df89ff39d8f20fd7299307148
PoW: true
Prev. hash: 000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109b
Data: Send 1 BTC to Ivan
Hash: 000000d7b0c76e1001cdc1fc866b95a481d23f3027d86901eaeb77ae6d002b13
PoW: true
Prev. hash:
Data: Genesis Block
Hash: 000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109b
PoW: true
在下篇文章中,我们将会实现地址,钱包,(可能实现)交易。尽请收听!
链接
本文源码:part_3
交易(transaction)是比特币的核心所在,而区块链的唯一目的,也正是为了能够安全可靠地存储交易。在区块链中,交易一旦被创建,就没有任何人能够再去修改或是删除它。在今天的文章中,我们将会开始实现交易这个部分。不过,由于交易是很大的话题,我会把它分为两部分来讲:在今天这个部分,我们会实现交易的通用机制。在第二部分,我们会继续讨论它的一些细节。
此外,由于代码实现变化很大,就不在文章中罗列所有代码了,这里 可以看到所有的改动。
如果以前开发过 web 应用,在支付的实现环节,你可能会在数据库中创建这样两张表:
account(账户)会存储用户信息,里面包括了个人信息和余额。transaction(交易)会存储资金转移信息,也就是资金从一个账户转移到另一个账户这样的内容。在比特币中,支付是另外一种完全不同的方式:
鉴于区块链是一个公开开放的数据库,所以我们并不想要存储钱包所有者的敏感信息(所以具有一定的匿名性)。资金不是通过账户来收集,交易也不是从一个地址将钱转移到另一个地址,也没有一个字段或者属性来保存账户余额。交易就是区块链要表达的所有内容。那么,交易里面到底有什么内容呢?
一笔交易由一些输入(input)和输出(output)组合而来:
type Transaction struct {
ID []byte
Vin []TXInput
Vout []TXOutput
}
对于每一笔新的交易,它的输入会引用(reference)之前一笔交易的输出(这里有个例外,也就是我们待会儿要谈到的 coinbase 交易)。所谓引用之前的一个输出,也就是将之前的一个输出包含在另一笔交易的输入当中。交易的输出,也就是币实际存储的地方。下面的图示阐释了交易之间的互相关联:
the interconnection of transactions
注意:
贯穿本文,我们将会使用像“钱(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
个块后,奖励减半。在我们的实现中,这个奖励值将会是一个常量(至少目前是)。
从现在开始,每个块必须存储至少一笔交易。如果没有交易,也就不可能挖出新的块。这意味着我们应该移除 Block
的 Data
字段,取而代之的是存储交易:
type Block struct {
Timestamp int64
Transactions []*Transaction
PrevBlockHash []byte
Hash []byte
Nonce int
}
NewBlock
和 NewGenesisBlock
也必须做出相应改变:
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 Ivan
00000093450837f8b52b78c25f8163bb6137caf43ff4d9a01d1b731fa8ddcc8a
Done!
很好!我们已经获得了第一笔挖矿奖励,但是,我们要如何查看余额呢?
我们需要找到所有的未花费交易输出(unspent transactions outputs, UTXO)。未花费(unspent) 指的是这个输出还没有被包含在任何交易的输入中,或者说没有被任何输入引用。在上面的图示中,未花费的输出是:
当然了,当我们检查余额时,我们并不需要知道整个区块链上所有的 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': %dn", address, balance)
}
账户余额就是由账户地址锁定的所有未花费交易输出的总和。
在挖出创世块以后,来检查一下我们的余额:
$ blockchain_go getbalance -address Ivan
Balance 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()
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!