Introduction
在上一篇文章,我们开始实施交易。您还了解了交易的非个人性质:没有用户帐户,您的个人数据(例如,姓名,护照号码或SSN)不是必需的,也不存储在比特币的任何地方。但仍然必须有一些东西可以确定您是交易输出的所有者(即锁定在这些输出上的硬币的所有者)。这就是比特币需要的地址。到目前为止,我们已经使用任意用户定义的字符串作为地址,现在是实现真实地址的时候了,因为它们是在比特币中实现的。
这部分介绍了重要的代码更改,因此在这里解释所有这些都没有意义。请参阅 this page查看自上一篇文章以来的所有更改。
Bitcoin Address
以下是比特币地址的示例:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa。这是第一个比特币地址,据称属于Satoshi Nakamoto。比特币地址是公开的。如果您想向某人发送硬币,您需要知道他们的地址。但地址(尽管是独一无二的)并不能确定您是“钱包”的拥有者。实际上,这样的地址是公钥的人类可读表示。在比特币中,您的身份是存储在您计算机上的一对(或多对)私钥和公钥(或存储在您有权访问的其他地方)。比特币依靠加密算法的组合来创建这些密钥,并保证世界上没有其他人可以访问您的硬币而无需实际访问您的密钥。我们来讨论一下这些算法是什么。
公钥加密
公钥加密算法使用密钥对:公钥和私钥。公钥不敏感,可以向任何人透露。相反,私钥不应该被披露:除了所有者之外,没有人可以访问它们,因为它是用作所有者标识符的私钥。你是私人密钥(当然是加密货币世界)。
从本质上讲,比特币钱包只是一对这样的钥匙。当您安装钱包应用程序或使用比特币客户端生成新地址时,会为您生成一对密钥。控制私钥的人控制比特币中发送给该密钥的所有硬币。
私钥和公钥只是字节的随机序列,因此它们不能在屏幕上打印并由人读取。这就是比特币使用算法将公钥转换为人类可读字符串的原因。
如果你曾经使用过比特币钱包应用程序,很可能会为你生成一个助记符密码短语。使用这些短语代替私钥,并且可以用于生成它们。这个机制是在 BIP-039.
好的,我们现在知道用比特币识别用户的是什么。但比特币如何检查交易输出(以及存储在其上的硬币)的所有权?
Digital Signatures
在数学和密码学中,有一个数字签名的概念 - 算法可以保证:
- 从发件人转移到收件人时,数据未被修改;
- 该数据是由某个发件人创建的;
- 发件人不能拒绝发送数据。
通过将签名算法应用于数据(即,对数据进行签名),可以获得签名,稍后可以对其进行验证。使用私钥进行数字签名,验证需要公钥。
为了签署数据,我们需要以下内容:
- 需要签名的数据;
- 私钥
签名操作产生签名,该签名存储在事务输入中。为了验证签名,需要以下内容:
- 已签署的数据;
- 签名;
- 公钥.
简单来说,验证过程可以描述为:检查此签名是否使用用于生成公钥的私钥从此数据中获取。
数字签名不是加密,您无法从签名重建数据。这类似于散列:您通过散列算法运行数据并获得数据的唯一表示。签名和散列之间的区别是密钥对:它们使签名验证成为可能。
但密钥对也可用于加密数据:私钥用于加密,公钥用于解密数据。比特币不使用加密算法。
比特币中的每个交易输入都由创建交易的人签名。比特币中的每笔交易必须经过验证才能进入区块。验证手段(除了其他程序):
- 检查输入是否有权使用先前事务的输出。
- 检查事务签名是否正确。
示意性地,签署数据和验证签名的过程看起来像这样:
现在让我们回顾一下交易的整个生命周期:
- 最初,有一个包含coinbase交易的创世块。在coinbase交易中没有实际输入,因此不需要签名。 coinbase事务的输出包含一个散列的公钥(
RIPEMD16(SHA256(PubKey))
使用算法)。 - 当一个人发送硬币时,就会创建一个交易。交易的输入将参考先前交易的输出。每个输入都将存储一个公钥(不是哈希)和整个交易的签名。
- 比特币网络中接收交易的其他节点将对其进行验证。除了其他东西,他们还会检查:输入中公钥的哈希值与引用输出的哈希值匹配(这可以确保发送者只花费属于它们的硬币);签名是正确的(这确保了交易是由硬币的真正所有者创建的)。
- 当矿工节点准备挖掘新块时,它会将事务放入块中并开始挖掘它。
- 当阻塞被挖掘时,网络中的每个其他节点都会收到一条消息,说明该块已被挖掘,并将该块添加到区块链中。
- 将块添加到区块链后,事务完成,其输出可在新事务中引用。
椭圆曲线密码学
如上所述,公钥和私钥是随机字节的序列。由于它是用于识别硬币所有者的私钥,因此存在必需条件:随机性算法必须产生真正的随机字节。我们不希望意外生成其他人拥有的私钥。
比特币使用椭圆曲线生成私钥。椭圆曲线是一个复杂的数学概念,我们在这里不会详细解释(如果你很好奇,请查看this gentle introduction to elliptic curves警告:数学公式!)。我们需要知道的是,这些曲线可用于生成非常大且随机的数字。比特币使用的曲线可以随机选取0到2²之间的数字(当可见宇宙中有10⁷⁸到10⁸之间的原子时,这个数字约为10⁷⁷)。如此巨大的上限意味着几乎不可能两次生成相同的私钥。
此外,比特币使用(我们将)ECDSA(椭圆曲线数字签名算法)算法来签署交易。
Base58
现在让我们回到上面提到的比特币地址:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa。现在我们知道这是一个人类可读的公钥表示。如果我们解码它,这就是公钥的样子(作为十六进制系统中写入的字节序列):
0062E907B15CBF27D5425399EBF6F0FB50EBB88F18C29B7D93
复制代码
比特币使用Base58算法将公钥转换为人类可读格式。该算法与着名的Base64非常相似,但它使用较短的字母表:从字母表中删除了一些字母,以避免一些使用字母相似性的攻击。因此,没有这些符号:0(零),O(大写o),I(大写i),l(小写L),因为它们看起来相似。此外,没有+和/符号。
让我们示意地显示从公钥获取地址的过程:
因此,上述解码公钥由三部分组成:
Version Public key hash Checksum
00 62E907B15CBF27D5425399EBF6F0FB50EBB88F18 C29B7D93
复制代码
由于散列函数是一种方式(即,它们不能被反转),因此无法从散列中提取公钥。但我们可以通过运行它来检查是否使用了公钥来获取哈希,以及保存哈希函数和比较哈希值。
好的,现在我们已经完成了所有部分,让我们编写一些代码。当用代码编写时,一些概念应该更清楚。
实现地址
我们将从开始Wallet
structure:
type Wallet struct {
PrivateKey ecdsa.PrivateKey
PublicKey []byte
}
type Wallets struct {
Wallets map[string]*Wallet
}
func NewWallet() *Wallet {
private, public := newKeyPair()
wallet := Wallet{private, public}
return &wallet
}
func newKeyPair() (ecdsa.PrivateKey, []byte) {
curve := elliptic.P256()
private, err := ecdsa.GenerateKey(curve, rand.Reader)
pubKey := append(private.PublicKey.X.Bytes(), private.PublicKey.Y.Bytes()...)
return *private, pubKey
}
复制代码
钱包只不过是一对钥匙。我们还需要Wallets
键入以保存钱包集合,将它们保存到文件中,然后从中加载它们。在建筑功能Wallet
生成新密钥对。该newKeyPair
功能很简单:ECDSA基于椭圆曲线,所以我们需要一个。接下来,使用该曲线生成私钥,并且从私钥生成公钥。需要注意的一点是:在基于椭圆曲线的算法中,公钥是曲线上的点。因此,公钥是X,Y坐标的组合。在比特币中,这些坐标被连接起来并形成公钥。
现在,让我们生成一个地址:
func (w Wallet) GetAddress() []byte {
pubKeyHash := HashPubKey(w.PublicKey)
versionedPayload := append([]byte{version}, pubKeyHash...)
checksum := checksum(versionedPayload)
fullPayload := append(versionedPayload, checksum...)
address := Base58Encode(fullPayload)
return address
}
func HashPubKey(pubKey []byte) []byte {
publicSHA256 := sha256.Sum256(pubKey)
RIPEMD160Hasher := ripemd160.New()
_, err := RIPEMD160Hasher.Write(publicSHA256[:])
publicRIPEMD160 := RIPEMD160Hasher.Sum(nil)
return publicRIPEMD160
}
func checksum(payload []byte) []byte {
firstSHA := sha256.Sum256(payload)
secondSHA := sha256.Sum256(firstSHA[:])
return secondSHA[:addressChecksumLen]
}
复制代码
以下是将公钥转换为Base58地址的步骤:
- 使用公钥并将其哈希两次
RIPEMD160(SHA256(PubKey))
哈希算法。 - 将地址生成算法的版本添加到哈希。
- 通过散列步骤2的结果来计算校验和
SHA256(SHA256(payload))
。校验和是结果哈希的前四个字节。 - 将校验和附加到
version+PubKeyHash
. - 用Base58为
version+PubKeyHash+checksum
进行编码。
结果,你会得到一个真实的比特币地址,你甚至可以查看其余额blockchain.info。但我可以向您保证,无论您生成新地址多少次并检查其余额,余额为0。这就是选择合适的公钥加密算法如此重要的原因:考虑私钥是随机数,生成相同数字的机会必须尽可能低。理想情况下,它必须与“从不”一样低。
另外,请注意您不需要连接到比特币节点来获取地址。地址生成算法利用在许多编程语言和库中实现的开放算法的组合。
现在我们需要修改它们的输入和输出以使用地址:
type TXInput struct {
Txid []byte
Vout int
Signature []byte
PubKey []byte
}
func (in *TXInput) UsesKey(pubKeyHash []byte) bool {
lockingHash := HashPubKey(in.PubKey)
return bytes.Compare(lockingHash, pubKeyHash) == 0
}
type TXOutput struct {
Value int
PubKeyHash []byte
}
func (out *TXOutput) Lock(address []byte) {
pubKeyHash := Base58Decode(address)
pubKeyHash = pubKeyHash[1 : len(pubKeyHash)-4]
out.PubKeyHash = pubKeyHash
}
func (out *TXOutput) IsLockedWithKey(pubKeyHash []byte) bool {
return bytes.Compare(out.PubKeyHash, pubKeyHash) == 0
}
复制代码
请注意,我们已不再使用ScriptPubKey
和 ScriptSig
字段,因为我们不打算实现脚本语言。代替,ScriptSig
被分为 Signature
和 PubKey
字段,并且 ScriptPubKey被重命名为
PubKeyHash
。我们将实现与比特币相同的输出锁定/解锁和输入签名逻辑,但我们将在方法中执行此操作。
UsesKey
方法检查输入是否使用特定键来解锁输出。请注意,输入存储原始公钥(即,不进行哈希处理),但该函数采用哈希值。IsLockedWithKey
检查是否提供了公钥哈希用于锁定输出。这是一个补充功能UsesKey
,它们都被用于FindUnspentTransactions
在事务之间建立连接。
Lock
只需锁定输出。当我们向某人发送硬币时,我们只知道他们的地址,因此该函数将地址作为唯一的参数。然后解码该地址,并从中提取公钥哈希并保存在PubKeyHash
字段.
现在,让我们检查一切是否正常:
$ blockchain_go createwallet
Your new address: 13Uu7B1vDP4ViXqHFsWtbraM3EfQ3UkWXt
$ blockchain_go createwallet
Your new address: 15pUhCbtrGh3JUx5iHnXjfpyHyTgawvG5h
$ blockchain_go createwallet
Your new address: 1Lhqun1E9zZZhodiTqxfPQBcwr1CVDV2sy
$ blockchain_go createblockchain -address 13Uu7B1vDP4ViXqHFsWtbraM3EfQ3UkWXt
0000005420fbfdafa00c093f56e033903ba43599fa7cd9df40458e373eee724d
Done!
$ blockchain_go getbalance -address 13Uu7B1vDP4ViXqHFsWtbraM3EfQ3UkWXt
Balance of '13Uu7B1vDP4ViXqHFsWtbraM3EfQ3UkWXt': 10
$ blockchain_go send -from 15pUhCbtrGh3JUx5iHnXjfpyHyTgawvG5h -to 13Uu7B1vDP4ViXqHFsWtbraM3EfQ3UkWXt -amount 5
2017/09/12 13:08:56 ERROR: Not enough funds
$ blockchain_go send -from 13Uu7B1vDP4ViXqHFsWtbraM3EfQ3UkWXt -to 15pUhCbtrGh3JUx5iHnXjfpyHyTgawvG5h -amount 6
00000019afa909094193f64ca06e9039849709f5948fbac56cae7b1b8f0ff162
Success!
$ blockchain_go getbalance -address 13Uu7B1vDP4ViXqHFsWtbraM3EfQ3UkWXt
Balance of '13Uu7B1vDP4ViXqHFsWtbraM3EfQ3UkWXt': 4
$ blockchain_go getbalance -address 15pUhCbtrGh3JUx5iHnXjfpyHyTgawvG5h
Balance of '15pUhCbtrGh3JUx5iHnXjfpyHyTgawvG5h': 6
$ blockchain_go getbalance -address 1Lhqun1E9zZZhodiTqxfPQBcwr1CVDV2sy
Balance of '1Lhqun1E9zZZhodiTqxfPQBcwr1CVDV2sy': 0
复制代码
太好了!现在让我们实现事务签名。
实施签名
必须签署交易,因为这是比特币保证不能花钱属于其他人的唯一方式。如果签名无效,则该交易也被视为无效,因此无法添加到区块链中。
我们拥有实现交易签名的所有部分,除了一件事:要签署的数据。交易的哪些部分实际签署了?或者一个交易是整体签署的?选择要签名的数据非常重要。问题是要签名的数据必须包含以独特方式标识数据的信息。例如,仅签署输出值是没有意义的,因为此类签名不会考虑发件人和收件人。
考虑到事务解锁以前的输出,重新分配它们的值并锁定新输出,必须签署以下数据:
- 公钥哈希存储在未锁定的输出中。这标识了交易的“发件人”。
- 公钥哈希存储在新的锁定输出中。这标识了交易的“收件人”。
- 新产出的价值。
如您所见,我们不需要对存储在输入中的公钥进行签名。因此,在比特币中,它不是一个已签名的交易,而是带有输入存储的修剪副本ScriptPubKey
来自参考输出。
描述了获取修剪的事务副本的详细过程 here。它可能已经过时,但我没有找到更可靠的信息来源。
好吧,它看起来很复杂,所以让我们开始编码。我们将从Sign
方法开始:
func (tx *Transaction) Sign(privKey ecdsa.PrivateKey, prevTXs map[string]Transaction) {
if tx.IsCoinbase() {
return
}
txCopy := tx.TrimmedCopy()
for inID, vin := range txCopy.Vin {
prevTx := prevTXs[hex.EncodeToString(vin.Txid)]
txCopy.Vin[inID].Signature = nil
txCopy.Vin[inID].PubKey = prevTx.Vout[vin.Vout].PubKeyHash
txCopy.ID = txCopy.Hash()
txCopy.Vin[inID].PubKey = nil
r, s, err := ecdsa.Sign(rand.Reader, &privKey, txCopy.ID)
signature := append(r.Bytes(), s.Bytes()...)
tx.Vin[inID].Signature = signature
}
}
复制代码
该方法采用私钥和先前事务的映射。如上所述,为了对事务进行签名,我们需要访问事务输入中引用的输出,因此我们需要存储这些输出的事务。
让我们一步一步地回顾这个方法:
if tx.IsCoinbase() {
return
}
复制代码
Coinbase事务未签名,因为它们中没有实际输入。
txCopy := tx.TrimmedCopy()
复制代码
修剪后的副本将被签名,而非完整交易:
func (tx *Transaction) TrimmedCopy() Transaction {
var inputs []TXInput
var outputs []TXOutput
for _, vin := range tx.Vin {
inputs = append(inputs, TXInput{vin.Txid, vin.Vout, nil, nil})
}
for _, vout := range tx.Vout {
outputs = append(outputs, TXOutput{vout.Value, vout.PubKeyHash})
}
txCopy := Transaction{tx.ID, inputs, outputs}
return txCopy
}
复制代码
副本将包括所有输入和输出,但是TXInput.Signature
和 TXInput.PubKey
会被设置为 nil.
接下来,我们遍历副本中的每个输入:
for inID, vin := range txCopy.Vin {
prevTx := prevTXs[hex.EncodeToString(vin.Txid)]
txCopy.Vin[inID].Signature = nil
txCopy.Vin[inID].PubKey = prevTx.Vout[vin.Vout].PubKeyHash
复制代码
对于美国输入, Signature
被设置为 nil
(只是仔细检查)和PubKey
被设置为 PubKeyHash
引用的输出。此时,除当前交易之外的所有交易都是“空的”,即他们的交易Signature
和 PubKey
字段设置为nil。从而,输入单独签名虽然这对我们的应用程序来说不是必需的,但比特币允许事务包含引用不同地址的输入。
txCopy.ID = txCopy.Hash()
txCopy.Vin[inID].PubKey = nil
复制代码
Hash
方法序列化事务并使用SHA-256算法对其进行哈希处理。结果哈希是我们要签署的数据。获得哈希后我们应该重置PubKey
字段,因此它不会影响进一步的迭代。
现在,中心部分:
r, s, err := ecdsa.Sign(rand.Reader, &privKey, txCopy.ID)
signature := append(r.Bytes(), s.Bytes()...)
tx.Vin[inID].Signature = signature
复制代码
我们用 privKey
对 txCopy.ID
进行签名。 ECDSA签名是一对数字,我们将它们连接并存储在输入中Signature
字段.
现在,验证功能:
func (tx *Transaction) Verify(prevTXs map[string]Transaction) bool {
txCopy := tx.TrimmedCopy()
curve := elliptic.P256()
for inID, vin := range tx.Vin {
prevTx := prevTXs[hex.EncodeToString(vin.Txid)]
txCopy.Vin[inID].Signature = nil
txCopy.Vin[inID].PubKey = prevTx.Vout[vin.Vout].PubKeyHash
txCopy.ID = txCopy.Hash()
txCopy.Vin[inID].PubKey = nil
r := big.Int{}
s := big.Int{}
sigLen := len(vin.Signature)
r.SetBytes(vin.Signature[:(sigLen / 2)])
s.SetBytes(vin.Signature[(sigLen / 2):])
x := big.Int{}
y := big.Int{}
keyLen := len(vin.PubKey)
x.SetBytes(vin.PubKey[:(keyLen / 2)])
y.SetBytes(vin.PubKey[(keyLen / 2):])
rawPubKey := ecdsa.PublicKey{curve, &x, &y}
if ecdsa.Verify(&rawPubKey, txCopy.ID, &r, &s) == false {
return false
}
}
return true
}
复制代码
该方法非常简单。首先,我们需要相同的交易副本:
txCopy := tx.TrimmedCopy()
复制代码
接下来,我们需要用于生成密钥对的相同曲线:
curve := elliptic.P256()
复制代码
接下来,我们检查每个输入中的签名:
for inID, vin := range tx.Vin {
prevTx := prevTXs[hex.EncodeToString(vin.Txid)]
txCopy.Vin[inID].Signature = nil
txCopy.Vin[inID].PubKey = prevTx.Vout[vin.Vout].PubKeyHash
txCopy.ID = txCopy.Hash()
txCopy.Vin[inID].PubKey = nil
复制代码
这件作品与之相同Sign
方法,因为在验证过程中我们需要签署的相同数据。
r := big.Int{}
s := big.Int{}
sigLen := len(vin.Signature)
r.SetBytes(vin.Signature[:(sigLen / 2)])
s.SetBytes(vin.Signature[(sigLen / 2):])
x := big.Int{}
y := big.Int{}
keyLen := len(vin.PubKey)
x.SetBytes(vin.PubKey[:(keyLen / 2)])
y.SetBytes(vin.PubKey[(keyLen / 2):])
复制代码
这里我们解压缩存储的值TXInput.Signature
和 TXInput.PubKey
因为签名是一对数字而公钥是一对坐标。我们之前将它们连接起来进行存储,现在我们需要将它们解压缩才能使用crypto/ecdsa
方法.
rawPubKey := ecdsa.PublicKey{curve, &x, &y}
if ecdsa.Verify(&rawPubKey, txCopy.ID, &r, &s) == false {
return false
}
}
return true
复制代码
这是:我们创造了一个ecdsa.PublicKey
使用从输入中提取的公钥并执行ecdsa.Verify
传递从输入中提取的签名。如果验证了所有输入,则返回true;如果至少有一个输入验证失败,则返回false。
现在,我们需要一个函数来获取以前的事务。由于这需要与区块链互动,我们将把它作为一种方法Blockchain
:
func (bc *Blockchain) FindTransaction(ID []byte) (Transaction, error) {
bci := bc.Iterator()
for {
block := bci.Next()
for _, tx := range block.Transactions {
if bytes.Compare(tx.ID, ID) == 0 {
return *tx, nil
}
}
if len(block.PrevBlockHash) == 0 {
break
}
}
return Transaction{}, errors.New("Transaction is not found")
}
func (bc *Blockchain) SignTransaction(tx *Transaction, privKey ecdsa.PrivateKey) {
prevTXs := make(map[string]Transaction)
for _, vin := range tx.Vin {
prevTX, err := bc.FindTransaction(vin.Txid)
prevTXs[hex.EncodeToString(prevTX.ID)] = prevTX
}
tx.Sign(privKey, prevTXs)
}
func (bc *Blockchain) VerifyTransaction(tx *Transaction) bool {
prevTXs := make(map[string]Transaction)
for _, vin := range tx.Vin {
prevTX, err := bc.FindTransaction(vin.Txid)
prevTXs[hex.EncodeToString(prevTX.ID)] = prevTX
}
return tx.Verify(prevTXs)
}
复制代码
这些功能很简单:FindTransaction
按ID查找事务(这需要迭代区块链中的所有块);SignTransaction
接受一个事务,查找它引用的事务并签名;VerifyTransaction
做同样的事情,但改为验证交易。
现在,我们需要实际签署和验证交易。签名发生在NewUTXOTransaction
:
func NewUTXOTransaction(from, to string, amount int, bc *Blockchain) *Transaction {
...
tx := Transaction{nil, inputs, outputs}
tx.ID = tx.Hash()
bc.SignTransaction(&tx, wallet.PrivateKey)
return &tx
}
复制代码
在将事务放入块之前进行验证:
func (bc *Blockchain) MineBlock(transactions []*Transaction) {
var lastHash []byte
for _, tx := range transactions {
if bc.VerifyTransaction(tx) != true {
log.Panic("ERROR: Invalid transaction")
}
}
...
}
复制代码
就是这样!让我们再一次检查一切:
$ blockchain_go createwallet
Your new address: 1AmVdDvvQ977oVCpUqz7zAPUEiXKrX5avR
$ blockchain_go createwallet
Your new address: 1NE86r4Esjf53EL7fR86CsfTZpNN42Sfab
$ blockchain_go createblockchain -address 1AmVdDvvQ977oVCpUqz7zAPUEiXKrX5avR
000000122348da06c19e5c513710340f4c307d884385da948a205655c6a9d008
Done!
$ blockchain_go send -from 1AmVdDvvQ977oVCpUqz7zAPUEiXKrX5avR -to 1NE86r4Esjf53EL7fR86CsfTZpNN42Sfab -amount 6
0000000f3dbb0ab6d56c4e4b9f7479afe8d5a5dad4d2a8823345a1a16cf3347b
Success!
$ blockchain_go getbalance -address 1AmVdDvvQ977oVCpUqz7zAPUEiXKrX5avR
Balance of '1AmVdDvvQ977oVCpUqz7zAPUEiXKrX5avR': 4
$ blockchain_go getbalance -address 1NE86r4Esjf53EL7fR86CsfTZpNN42Sfab
Balance of '1NE86r4Esjf53EL7fR86CsfTZpNN42Sfab': 6
复制代码
什么都没有打破。真棒!
我们也要评论一下bc.SignTransaction(&tx, wallet.PrivateKey)
确保无法挖掘未签名的交易:
func NewUTXOTransaction(from, to string, amount int, bc *Blockchain) *Transaction {
...
tx := Transaction{nil, inputs, outputs}
tx.ID = tx.Hash()
// bc.SignTransaction(&tx, wallet.PrivateKey)
return &tx
}
复制代码
$ go install
$ blockchain_go send -from 1AmVdDvvQ977oVCpUqz7zAPUEiXKrX5avR -to 1NE86r4Esjf53EL7fR86CsfTZpNN42Sfab -amount 1
2017/09/12 16:28:15 ERROR: Invalid transaction
复制代码
Conclusion
到目前为止我们真的很棒,实现了比特币的许多关键功能!我们已经实现了网络外的几乎所有内容,在下一部分中,我们将完成交易。
英文原文:https://jeiwan.cc/posts/building-blockchain-in-go-part-5/
更多文章欢迎访问: http://www.apexyun.com
联系邮箱:public@space-explore.com
(未经同意,请勿转载)