概述

小白入门:https://github.com/dukedaily/solidity-expert ,欢迎star转发,文末加V入群。

职场进阶: https://dukeweb3.com

1. 当前版本问题

  • 交易信息是由普通字符串充当,定义完整的交易结构

  • 挖矿没有奖励,引入挖矿奖励

  • 没有转账机制,实现账户间转账功能

2. V4版本授课思路

  • 定义交易结构、交易ID : 哈希、交易输入:TXInputs、交易输出:TXOutputs

  • 根据结构调整代码,Block结构等一些列的位置(根据提示查找修改位置)

  • 创建Coinbase,无输入(我们用一个特殊的输入表示),一个输出,有奖励

  • 如何查找指定Address的余额

    • 找到这个Address所拥有的UTXO所在的交易的集合(找交易集合)
  • 根据交易集合找到这个Address所能支配的UTXO
  • 根据UTXO找到所有的余额
  • 如何转账

    • 找到所有这笔转账所需要UTXO的集合(根据转账金额,可能是一个或多个)

    • 完成普通交易的创建

  • 更新命令、获取余额:getBalance、转账:send

一、定义交易结构

创建transaction.go文件,依次添加如下代码:

image-20221108135357172

1. 交易输入(TXInput)

指明交易发起人可支付资金的来源,包含:

  • 引用utxo所在交易的ID
  • 所消费utxo在output中的索引
  • 解锁脚本
type TXInput struct {
    //引用output所在交易ID
    TXID []byte

    //应用output的索引值
    VoutIndex int64

    //解锁脚本
    ScriptSig string
}

2. 交易输出(TXOutput)

包含资金接收方的相关信息,包含:

  • 接收金额

  • 锁定脚本

    ==易错点:经常把Value写成小写字母开头的==,这样会无法写入数据库,切记!

type TXOutput struct {
    //接收的金额
    Value float64

    //锁定脚本
    ScriptPubKey string
}

3. 交易结构

type Transaction struct {
    //交易ID
    TXID []byte

    //交易输入,可能是多个
    TXInputs []TXInput

    //交易输出,可能是多个
    TXOutputs []TXOutput
}

4. 设置交易ID方法

//设置交易哈希
func (tx *Transaction) setHash() {
    var buffer bytes.Buffer

    encoder := gob.NewEncoder(&buffer)
    encoder.Encode(tx)

    data := buffer.Bytes()

    hash := sha256.Sum256(data)
    tx.TXID = hash[:]
}

二、创建Coinbase交易

coinbase总是新区块的第一条交易,这条交易中只有一个输出,即对矿工的奖励,没有输入。

//address 是矿工地址,data是矿工自定义的附加信息
func NewCoinbaseTX(address string, data string) *Transaction {

    if data == "" {
        data = fmt.Sprintf("reward %s %f\n", address, reward)
    }

    //比特币系统,对于这个input的id填0,对索引填0xffff,data由矿工填写,一般填所在矿池的名字
    input := TXInput{nil, -1, data}
    output := TXOutput{reward, address}

    txTmp := Transaction{nil, []TXInput{input}, []TXOutput{output}}
    txTmp.setHash()

    return &txTmp
}

中本聪在创世块中添加信息:

"The Times 03/Jan/2009 Chancellor on brink of second bailout for banks"

泰晤士报 2009年1月3日 财政大臣将再次对银行施以援手

在最上面添加下列代码,表示挖矿奖励数目。

const reward  = 12.5

三、根据Transaction调整代码

1. block改写

使用交易数组代替Data,代码如下:

type Block struct {
//区块头:
    //比特币网络的版本号
    Version uint64
    //当前区块的哈希,注意,这是为了方便写代码而加入的。比特币不在区块中存储当前区块的哈希值
    Hash []byte
    //前区块的哈希值,用于连接链条
    PrevBlockHash []byte
    //梅克尔根,用于快速校验区块,校验区块的完整性。
    MerkleRoot []byte
    //时间戳,表示区块创建的时间
    TimeStamp uint64
    //难度值,调整挖矿难度
    Difficuty uint64
    //随机值,挖矿需要求的数字
    Nonce uint64

//区块体:
    //区块数据
    //Data []byte
    Transactions []*Transaction  //这里<------------
}

2. NewBlock改写

func NewBlock(txs []*Transaction, prevHash []byte) Block {  //这里<------------
    var block Block
    block = Block{
        Version:       0,
        PrevBlockHash: prevHash,
        //Hash:          []byte{},
        MerkleRoot:    []byte{},
        TimeStamp:     uint64(time.Now().Unix()),
        Difficuty:     10,
        Nonce:         10,
        Transactions:txs}  //这里修改 <---------------

    pow := NewProofOfWork(block)
    hash, nonce := pow.Run()
    block.Hash = hash
    block.Nonce = nonce

    return block
}

3. NewBlockChain改写

//中本聪在创世块中保存的信息
const genesisInfo = "The Times 03/Jan/2009 Chancellor on brink of second bailout for banks"

//- 提供一个创建BlockChain的方法
func NewBlockChain(address string) *BlockChain { //<------这里修改
    var lastHash []byte

    ...

    db.Update(func(tx *bolt.Tx) error {
        bucket := tx.Bucket([]byte(blockBucket))
        ...
        //创建coinbase交易  <---这里修改
        coinbaseTx := NewCoinbaseTX(address, genesisInfo)
        //创建创世块 <---这里修改
        genesisBlock := NewBlock([]*Transaction{coinbaseTx}, []byte{})
        ...
    }
}

4. AddBlock改写

func (bc *BlockChain)AddBlock(txs []*Transaction)  {
    ...

    //创建新区块
    newBlock := NewBlock(txs, lastBlockHash) //<----修改这里
    ...
}

5.修改prepareData函数

在ProofOfWork.go文件中,

func (pow *ProofOfWork) prepareData(num uint64) []byte {
    block := pow.block
    block.MerkleRoot = block.HashTransactions() //<----这里修改了
    tmp := [][]byte{
        uintToByte(block.Version),
        block.PrevBlockHash,
        //block.Hash,
        block.MerkleRoot,
        uintToByte(block.TimeStamp),
        uintToByte(block.Difficuty),
        uintToByte(num)}

        //block.Data}  //<----这里修改了

    data := bytes.Join(tmp, []byte{})
    return data
}
  • ==在比特币中,其实是对区块头进行哈希运算,而不是对区块整体进行哈希运算。==

  • ==比特币系统根据交易信息生成Merkel Root哈希值,所以交易可以影响到区块的哈希值。==

6. HashTransaction函数实现

这个函数是为了生成Merkel Tree Root哈希值,正常的生成过程是使用所有交易的哈希值生成一个平衡二叉树,此处,为了简化代码,我们目前直接将区块中交易的哈希值进行拼接后进行哈希操作即可。

func (block *Block)HashTransactions() []byte {

    var tmp [][]byte
    for _, tx := range block.Transactions{
        //交易的ID就是交易的哈希值,还记得吗?我们在Transaction里面提供了方法。
        tmp = append(tmp, tx.TXID)
    }

    data := bytes.Join(tmp, []byte{})
    hash := sha256.Sum256(data)

    return hash[:]
}

7.修改commandLine.go文件

在Run()函数中,注释如下语句

image-20221108135427357

在打印函数中,注释如下语句

image-20221108135438054

四、第一次测试

1. 编译

go build *.go

2.创建区块链

请先删掉本地数据库,执行如下命令

image-20221108135449300

3.使用strings查看

目前我们的打印命令不可使用,已经被注释,所以可以使用strings命令简单查看一下,确认信息已经写入数据库。

image-20221108135500206

好的,至此我们已经成功的将创世块写入数据库,恭喜!

五、我到底有多少比特币?

我们都知道比特币通过转账在系统中流通,付款人的钱包会使用付款人的解锁脚本解开能够支配的UTXO,完成花费。同时使用收款人的地址付款金额进行锁定,使之完成接收,从而实现金额的转移。

这里又提到了锁定脚本和解锁脚本,我们使用函数来模拟脚本交易

  1. ==先写GetBalance==
  2. 在将逻辑提出到新的函数FindMyUtox
  3. 改写成UTXOInfo结构
  4. 实现FindNeedUTXOInfo, 直接返回UTXOInfo结构
  5. 实现NewTransaction

1.解锁脚本

解锁脚本是检验input是否可以使用由某个地址锁定的utxo,所以对于解锁脚本来说,是外部提供锁定信息,我去检查一下能否解开它。

我们没有涉及到真实的非对称加密,所以使用字符串来代替加密和签名数据。即使用地址进行加密,同时使用地址当做签名,通过对比字符串来确定utxo能否解开。

在transaction.go中,添加解锁脚本如下:

func (input *TXInput) CanUnlockUTXOWith(unlockData string /*收款人的地址*/) bool {
    //ScriptSig是签名,v4版本中使用付款人的地址填充
    return input.ScriptSig == unlockData
}

2.锁定脚本

同样的,锁定脚本是用于指定比特币的新主人,在创建output时创建。对于这个output来说,它应该是一直在等待一个签名的到来,检查这个签名能否解开自己锁定的比特币。

func (output *TXOutput) CanBeUnlockedWith(unlockData string/*付款人的地址(签名)*/) bool {
    //ScriptPubKey是锁定信息,v4版本中使用收款人的地址填充
    return output.ScriptPubKey == unlockData
}

3.获取指定地址的UTXO的交易集合

我们的比特币都在UTXO中,UTXO又包含在交易中,所以如果想知道我们可以支配的UTXO,必须找到这些UTXO所在的交易,每个人有N个UTXO,所以我们需要找到所有的交易,也就是说,应该找到包含UTXO的交易的集合。

==这是一个核心函数==,代码如下:


//返回指定地址能够支配的utxo所在的交易的集合
func (bc *BlockChain) FindUTXOTransactions(address string) [] Transaction {

    var transactions []Transaction

    spentUTXOs := make(map[string][]int64)
    it := bc.NewIterator()

    for {
        //遍历区块
        block := it.Next()

        //遍历交易
        for _, tx := range block.Transactions {

        OUTPUTS:
        //遍历outputs
            for currentIndex, output := range tx.TXOutputs {
                //过滤已经消耗过得utxo
                if spentUTXOs[string(tx.TXID)] != nil {
                    //交易id非空, 意味着在交易里面有消耗的utxo
                    indexs := spentUTXOs[string(tx.TXID)]

                    for _, index := range indexs {
                        if int64(currentIndex) == index {
                            continue OUTPUTS
                        }
                    }
                }

                if output.CanBeUnlockedWith(address) {
                    transactions = append(transactions, *tx)
                }
            }

            //遍历inputs
            if !tx.IsCoinbase() {
                for _, input := range tx.TXInputs {
                    //检查一下当前的inputs是否和地址有关,能否解开
                    //看看当前花费的这个input是不是和指定的地址有关,如果有关,要记录下来,
                    //这样遍历前面的交易时,就不会再对这个引用的output重复统计了。
                    if input.CanUnlockUTXOWith(address) {
                        spentUTXOs[string(input.TXID)] = append(spentUTXOs[string(input.TXID)], input.VoutIndex)
                        //map[0x000000] = []int64{0}
                        //map[0x111111] = []int64{2,5}
                        //map[0xnnnnnn] = []int64{3}
                    }
                }
            }
        if len(block.PrevBlockHash) == 0 {
            break
        }
        }
    }
    return transactions
}

分析一个示例(跟着代码流程走):

  1. Lily挖矿得到12.5btc,分析当前Lily的output情况。
  2. Lily转账给Tom10btc,再次分析Liliy的output情况。

4.IsCoinbase函数实现

根据coinbase的特点进行判断,请参考签名NewCoinbase函数

func NewCoinbaseTX(address string, data string) *Transaction {
    ...
    input := TXInput{nil, -1, data}
    ...
}

具体实现:

func (tx *Transaction) IsCoinbase() bool {
    if len(tx.TXInputs) == 1 {
        if tx.TXInputs[0].TXID == nil && tx.TXInputs[0].VoutIndex == -1 {
            return true
        }
    }
    return false
}

5.获取指定地址的UTXO的集合

我们已经获取到了所有的交易集合,那么utxo集合便非常容易获取,只需要遍历交易即可。

//返回指定地址能够支配的utxo的集合
func (bc *BlockChain) FindUTXOs(address string) []TXOutput {
    txs := bc.FindUTXOTransactions(address)

    var utxos []TXOutput
    for _, tx := range txs {
        for _, output := range tx.TXOutputs {
            //找到自己能解锁的output
            if output.CanBeUnlockedWith(address) {
                utxos = append(utxos, output)
            }
        }
    }

    return utxos
}

6.获取余额

我们说过,比特币是没有余额概念的,所以这个属于钱包的功能,我们把它放到CLI中去实现。

此时只需要遍历UTXO,然后累计里面的value字段的值即可。

在commandLine.go中添加如下代码:

func (cli *CLI)GetBalance(address string)  {
    bc := GetBlockChainObj()
    defer bc.db.Close()

    utxos := bc.FindUTXOs(address)

    var total float64
    for _, utxo := range utxos{
        total += utxo.Value
    }

    fmt.Printf("The balance of %s is : %f\n", address, total)
}

添加getBalance命令如下:

    case "getBalance":
        if len(os.Args) > 3 && os.Args[2] == "--address" {
            address := os.Args[3]
            if address == "" {
                fmt.Println("address should not be empty!")
                os.Exit(1)
            }
            cli.GetBalance(address)
        } else {
            fmt.Println(Usage)
        }

更新Usage:

image-20221108135539659

六、第二次测试

编译执行,获取挖矿,删除db,执行如下命令,可以看到挖矿奖励。

image-20221108135549960

发财了!

七、创建交易(转账)

到目前为止,我们已经完成了70%的工作,接下来就是实现核心功能--转账

我们的AddBlock命令一直是被注释的状态,接下来就要把它重新用起来。

转账通过交易,所以我们要完成交易的创建,前面已经实现过一个交易,即挖矿奖励交易Coinbase,我们回顾一下,

//address 是矿工地址,data是矿工自定义的附加信息
func NewCoinbaseTX(address string, data string) *Transaction {

    if data == "" {
        data = fmt.Sprintf("reward %s %f\n", address, reward)
    }

    //比特币系统,对于这个input的id填0,对索引填0xffff,data由矿工填写,一般填所在矿池的名字
    input := TXInput{nil, -1, data}
    output := TXOutput{reward, address}

    txTmp := Transaction{nil, []TXInput{input}, []TXOutput{output}}
    txTmp.setHash()

    return &txTmp
}

这个交易十分特殊,没有输入,只有输出,而我们真实的交易一定有输入输出,以及输出金额,所以我们的原型定义如下:

func NewTransaction(from, to string, amount float64, bc *BlockChain) *Transaction {
    //TODO
}

from:付款人

to:收款人

amount:转账金额

bc:区块链本身

1. 普通交易创建

在transaction.go中添加如下代码:

func NewTransaction(from, to string, amount float64, bc *BlockChain) *Transaction {

    //map[txid][]int64
    validUTXOs := make(map[string][]int64)
    var total float64

    //第一部分,找到所需要的UTXO的集合
    validUTXOs /*本次支付所需要的utxo的集合*/ , total /*返回utxos所包含的金额*/ = bc.FindSuitableUTXOs(from, amount)

    if total < amount {
        fmt.Println("Money is not enough!!!")
        os.Exit(1)
    }

    //将返回的utxo转换成input
    var inputs []TXInput
    var outputs []TXOutput

    //第二部分,input的创建
    for txid, indexs := range validUTXOs{
        for _, index := range indexs{
            input := TXInput{[]byte(txid), index, from}
            inputs = append(inputs, input)
        }
    }

    //第三部分,output的创建
    output := TXOutput{amount, to}

    outputs = append(outputs, output)

    if total > amount {
        outputs = append(outputs, TXOutput{total-amount, from})
    }

    txTmp := Transaction{nil, inputs, outputs}
    txTmp.setHash()

    return &txTmp
}

这个函数分为三个部分,

  • 第一部分:找到所有需要的UTXO(有可能余额不足,余额正好,有剩余)
  • 第二部分:根据找到UTXO生成Input
  • 第三部分:生成Output,包括给收款人以及找零(如果有剩余)

2.辅助函数FindSuitableUTXOs

在blockChain.go文件中,添加如下代码:

func (bc *BlockChain) FindSuitableUTXOs(address string, amount float64) (map[string][]int64, float64) {
    txs := bc.FindUTXOTransactions(address)
    validUTXOs := make(map[string][]int64)
    var total float64 = 0

CALCULATE:
    for _, currentTx := range txs {

        for currentIndex, output := range currentTx.TXOutputs {
            if output.CanBeUnlockedWith(address) {
                if total < amount {
                    total += output.Value

                    //indexs := validUTXOs[string(currentTx.TXID)]
                    //indexs = append(indexs, int64(currentIndex))
                    validUTXOs[string(currentTx.TXID)] = append(validUTXOs[string(currentTx.TXID)], int64(currentIndex))

                } else {
                    break CALCULATE
                }
            }
        }
    }

    return validUTXOs, total
}

这个函数的功能为:找到指定地址所需金额的UTXO的集合,同时将这些UTXO总额一并返回。

八、添加send命令

1. 参数

我们将使用send命令实现转账功能,包含参数有:

from 付款人
to 收款人
amount 金额
miner 矿工

由于我们没有涉及到网络,所以我们手动指定矿工,完成资源分配

2. 调整代码

func (cli *CLI)Send(from , to string, amount float64, miner string)  {
    bc := GetBlockChainObj()
    tx := NewTransaction(from, to, amount, bc)
    defer bc.db.Close()

    //指定挖矿交易
    coinbase := NewCoinbaseTX(miner, "")
    bc.AddBlock([]*Transaction{coinbase, tx})

    fmt.Println("Send Successfully!")
}

修改Usage如下(注意,删除了addBlock --data DATA命令)

image-20221108135611837

3.参数解析

    case "send":
        //send --from FROM --to TO --amount AMOUNT --miner MINER  "send money from FROM to TO"
        if len(os.Args) < 10 {
            fmt.Println(Usage)
            os.Exit(1)
        }

        //简单粗暴,缺少校验,先看效果,马上整改
        from := os.Args[3]
        to := os.Args[5]
        amount, _ := strconv.ParseFloat(os.Args[7], 64)
        miner := os.Args[9]

        cli.Send(from, to, amount, miner)
    case "printChain":
        ...

九、第三次测试

由于send我们没有做有效性校验,所以命令要严格按照Usage中的顺序填写,后续会优化代码,请稍安勿躁。

请以此执行下列命令,感受btc的转移。

 duke ~/Desktop/code/v4$  ./block getBalance --address duke
The balance of duke is : 12.500000
 duke ~/Desktop/code/v4$  ./block send --from "duke" --to "lily" --amount 10 --miner "Tom"
target : 100000000000000000000000000000000000000000000000000000000000
found hash : 00000aeaaaf14392e923bc97acd41fa21316c1202587d25faf0b2c5617d115fd, 641020
Send Successfully!
 duke ~/Desktop/code/v4$  ./block getBalance --address duke
The balance of duke is : 2.500000
 duke ~/Desktop/code/v4$  ./block getBalance --address lily
The balance of lily is : 10.000000
 duke ~/Desktop/code/v4$  ./block getBalance --address tom  //<-----账本中没有他,所以为零
The balance of tom is : 0.000000
 duke ~/Desktop/code/v4$  ./block getBalance --address Tom
The balance of Tom is : 12.500000

测试金额不足情况

image-20221108135624498

十、小结

在v4版本中,我们引入了交易结构,奖励机制,实现了转账功能,相信各位已经对交易关系有了更深的理解。

但是我们的程序依然存在如下几个问题:

  1. 没有引入真正的地址,目前是使用字符串代替,如“lily”(v5解决)
  2. utxo集,目前每次查询余额时,程序都会遍历整个区块链账本,这样很慢,真实的比特币全节点客户端会维护一个utxo池,放在内存中维护,便于快速查询。(v6解决)
  3. 内存交易池,用于存放即将被打包的交易,我们目前的程序中,每个区块只有一条普通交易。(待开发)

十一、下节预告

地址钱包 + 数字签名