Skip to content

Transaction 1

交易Transaction) 是 BTC 的核心,也是区块链唯一的目的,为了可以安全可靠的交易,交易一旦被创建,就没有人可以再去修改或者删除。

BTC 使用的 UTXO(Unspent Transaction Output,未花费交易输出) 模型, UTXO 的核心思想:

  1. 交易即现金流转
    将货币视为 物理现金交易 的数字化
    1. 每一笔交易都像用纸币支付,需要消耗旧的 “纸币”(UTXO),生成新的“纸币”(UTXO)
    2. 不存在全局账户余额 ,只有分散的未花费输出集合,因此余额需要通过遍历整个交易历史来获得
  2. 链式所有权校验
    1. 每个 UTXO 都包含所有者的锁定脚本(如公钥哈希)
    2. 要花费 UTXO ,必须提供匹配的解锁脚本(如签名)

BTC 的数据

如果你做过互联网开发,实现一个支付相关的功能,至少需要在数据库中创建两个表,一个用来存储账号的信息和余额,一个用来存储订单信息。
但是在 BTC 中不能这样, BTC 没有账号,没有余额,没有地址,没有付款方和收款方。

BTC 交易

blockchain.info 中,可以看到交易信息。
交易由输入 (input) 和输出 (output) 组合而来:

go
type Transaction struct {
	ID   []byte
	Vin  []TXInput
	Vout []TXOutput
}

对于每一笔新的交易,它会引用之前一笔交易的输出(这里有个例外,coinbase 交易,也就是 创世交易(Genesis Transaction)),引用就是花费的意思。所谓引用之前的一个输出,也就是将之前的一个输出包含在另一笔交易的输入当中,就是花费之前的交易输出。交易的输出,就是币实际存储的地方。用 Deepseek 花了一个图,通俗易懂:

# Alice 挖矿获得 10 个币,Alice 将 10 个币转给 Bob , Bob 将 10 个币转给 Charlie
交易1(创世交易):  
Inputs: 无  
Outputs: [输出1: 10币 → Alice]

交易2(Alice→Bob):  
Inputs: [引用输出1]  
Outputs: [输出2: 10币 → Bob]

交易3(Bob→Charlie):  
Inputs: [引用输出2]  
Outputs: [输出3: 10币 → Charlie]

---

# Alice 挖矿获得十个币,Alice 将 5 个币转给 Bob , Alice 将 5 个币转给 Charlie 
交易0(历史)  
└─ 输出0: 10币 → Alice  

   ├─ 交易1(Alice→Bob)  
   │  ├─ 输入: 引用输出0  
   │  ├─ 输出1: 5币 → Bob  
   │  └─ 输出2: 5币 → Alice(找零)  
   │      │  
   │      └─ 交易2(Alice→Charlie)  
   │         ├─ 输入: 引用输出2  
   │         └─ 输出3: 5币 → Charlie

有一些输出并没有被关联到某个输入上
一笔交易的输出可以引用之前多比交易的输出
一个输入必须引用之前的一个输出(创世交易除外)

贯穿本文,我们将会使用像“钱(money)”,“币(coin)”,“花费(spend)”,“发送(send)”,“账户(account)” 等等这样的词。但是在比特币中,其实并不存在这样的概念。交易仅仅是通过一个脚本(script)来锁定(lock)一些值(value),而这些值只可以被锁定它们的人解锁(unlock)。

每一笔比特币交易都会创造输出,输出都会被区块链记录下来。给某个人发送比特币,实际上意味着创造新的 UTXO 并注册到那个人的地址,可以为他所用。

交易输出

首先实现输出

go
type TXOutput struct {
	Value        int
	ScriptPubKey string
}

输出主要包括两个部分:

  1. 一定量的币, BTC 中是以“聪” (Satoshi) 为单位,1 BTC = 100,000,000 聪。
  2. 定义花费这笔比特币的解锁条件,称为“锁定脚本”,通常包含接收者的公钥哈希。

事实上,就是这里的输出存储了“币”,也就是这里的 Value 字段。而这里的存储,指的是用一个数学难题对输出进行锁定,这个难题被存储在 ScriptPubKey 里面。在内部,比特币使用了一个叫做 Script 的脚本语言,用它来定义锁定和解锁输出的逻辑。虽然这个语言相当的原始(这是为了避免潜在的黑客攻击和滥用而有意为之),并不复杂,但是我们也并不会在这里讨论它的细节。你可以在这里找到详细解释。

由于还没实现地址 (adress) ,所以目前会避免涉及相关逻辑。 ScriptPubKey 将会存储一个任意的字符串(用户定义的钱包地址)。

有了一个这样的脚本语言,也意味着比特币其实也可以作为一个智能合约平台。

关于输出,它们是不可再分的,不能使用其中的一部分,要么用完,要么不用。如果一个交易中引用了某个输出,那么这个输出就被使用了,不能再用了,如果它的值大于需求的值,则会产生一个找零,将找零返回给发送方,而这个找零会产生新的输出。这和现实十分相似。

交易输入

输入:

go
type TXInput struct {
	Txid      []byte
	Vout      []int
	ScriptSig string
}

一个输入要引用之前的交易的输出, Txid 就是之前的交易的 ID ,Vout 就是那笔交易的输出的索引(因为一笔交易可能有多个输出,需要有信息指明是哪个),ScriptSig 是一个脚本,提供了可解锁输出结构里面 ScriptPubKey 字段的数据。如果 ScriptSig 提供的数据是正确的,那么输出就会被解锁,然后被解锁的值就可以被用于产生新的输出;如果数据不正确,输出就无法被引用在输入中,或者说,无法使用这个输出。这种机制,保证了用户无法花费属于其他人的币。

和前面一样,由于没有实现地址,因此 ScriptSig 只是存储一个用户自定义的任意钱包的地址。

用通俗易懂的话来说,小明有一个 魔法存钱罐A(输出) ,存钱罐上面有一把 密码锁(ScriptPubKey),只有符合密码锁的钥匙可以把存钱罐打开,存钱罐一旦打开就碎了。小明想给小红三块 钱(比特币) ,然后小明用 密码锁的钥匙(ScriptSig 打开这把锁之后,存钱罐碎了,小明拿出 5 个硬币,将 2 个硬币放入小红的魔法存钱罐B 中,剩下 3 个硬币放到自己的魔法存钱罐C 中 。

先有蛋

当矿工挖到一个新的区块时,系统会凭空给矿工比特币,不需要任何输入。
创建一个 coinbase 交易:

go
func (tx *Transaction) SetID() {
	var encoded bytes.Buffer
	var hash [32]byte

	enc := gob.NewEncoder(&encoded)
	err := enc.Encode(tx)
	if err != nil {
		log.Panic(err)
	}

	hash = sha256.Sum256(encoded.Bytes())
	tx.ID = hash[:]
}

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 , BTC 中为 0xFFFFFFFF , -1 看起来比较简洁。在这里,coinbase 交易也没有在 ScriptSig 存储脚本,只是存储了一个字符串 data

BTC 的第一笔 coinbase 交易包含了如下信息:"The Times 03/Jan/2009 Chancellor on brink of second bailout for banks." 详情

subsidy 是挖出新区快的奖励金,在 BTC 中,没有实际的这个数字,这个数字是根据区块的总数计算获取的:50 / ((1 / 2) ^ 衰减次数) ,向下取整,没被挖出 210000 个,就衰减一次,也就是区块高度 +1 ,最初为 1。

将交易保存到区块链

每个块必须存储至少一笔交易。如果没有交易,也就不可能生产出新的块,这意味着移除 BlockData 字段,改为存储交易。

go
type Block struct {
	Timestamp   int64
	Transactions []*Transaction
	// Data          []byte
	PrevBlockHash []byte
	Hash          []byte
	Nonce         int
}

NewBlockNewGenesisBlock 需要修改

go
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{})
}

接着是创建区块链的修改,复制一份 NewBlockchain ,在它的上面修改就好:

go
func NewBlockchain(address string) *Blockchain {
	var tip []byte
	ctx := context.Background()

	rdb := redis.NewClient(&redis.Options{
		Addr:     "localhost:6379",
		Password: "", // 没有密码,默认值
		DB:       0,  // 默认是 0
	})

	result, err := rdb.Get(ctx, blocksBucket+"l").Result()
	if err != nil {
		log.Panic(err)
	}

	tip = []byte(result)

	bc := Blockchain{tip, rdb, ctx}
	return &bc
}

func CreateBlockchain(address string) *Blockchain {
	var tip []byte
	ctx := context.Background()

	rdb := redis.NewClient(&redis.Options{
		Addr:     "localhost:6379",
		Password: "", // 没有密码,默认值
		DB:       0,  // 默认是 0
	})

	cbtx := NewCoinbaseTX(address, genesisCoinbaseData)
	genesis := NewGenesisBlock(cbtx)

	rdb.Set(ctx, blocksBucket+string(genesis.Hash), genesis.Serialize(), 0)
	rdb.Set(ctx, blocksBucket+"l", genesis.Hash, 0)
	tip = genesis.Hash

	bc := Blockchain{tip, rdb, ctx}
	return &bc
}

CreateBlockchain 会接受一个地址作为参数,这个地址将会被用来接受挖出创世块的奖励。

工作量证明

工作量证明需要将存储在区块里面的交易考虑进去,以此保证区块链交易存储的一致性和可靠性,因此需要修改 prepareData 方法:

go
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[:]
}

func (pow *ProofOfWork) prepareData(nonce int) []byte {
	data := bytes.Join(
		[][]byte{
			pow.Block.PrevBlockHash,
			pow.Block.HashTransactions(),
			IntToHex(pow.Block.Timestamp),
			IntToHex(int64(TargetBits)),
			IntToHex(int64(nonce)),
		},
		[]byte{},
	)

	return data
}

将原本的 data 改成 HashTransactions

BTC 使用的是更复杂的技术:它将一个块里包含的所有交易表示为一个 Merkle tree ,然后在工作量证明系统中使用树的根哈希(root hash)。这个方法能够让我们快速检索一个块里面是否包含了某笔交易,即只需 root hash 而无需下载所有交易即可完成判断。

修改命令行:

go
func (cli *CLI) createBlockchain(address string) {
	_ = CreateBlockchain(address)
	fmt.Println("Done!")
}

func (cli *CLI) Run() {
	cli.verifyArgs()

	// addBlockCmd := flag.NewFlagSet("ab", flag.ExitOnError)
	printChainCmd := flag.NewFlagSet("pc", flag.ExitOnError)
	createBlockchainCmd := flag.NewFlagSet("cbc", flag.ExitOnError)

	// addBlockData := addBlockCmd.String("data", "", "Block data")
	createBlockchainAddress := createBlockchainCmd.String("address", "", "The address to send genesis block reward to")

	switch os.Args[1] {
	// case "ab":
	// 	err := addBlockCmd.Parse(os.Args[2:])
	// 	if err != nil {
	// 		log.Panic(err)
	// 	}
	case "pc":
		err := printChainCmd.Parse(os.Args[2:])
		if err != nil {
			log.Panic(err)
		}
	case "cbc":
		err := createBlockchainCmd.Parse(os.Args[2:])
		if err != nil {
			log.Panic(err)
		}
	default:
		cli.printUsage()
		os.Exit(1)
	}

	// if addBlockCmd.Parsed() {
	// 	if *addBlockData == "" {
	// 		addBlockCmd.Usage()
	// 		os.Exit(1)
	// 	}
	// 	cli.addBlock(*addBlockData)
	// }
	if printChainCmd.Parsed() {
		cli.printChain()
	}
	if createBlockchainCmd.Parsed() {
		if *createBlockchainAddress == "" {
			createBlockchainCmd.Usage()
			os.Exit(1)
		}
		cli.createBlockchain(*createBlockchainAddress)
	}
}

main 函数修改为:

go
func main() {
	cli := blockchain.CLI{}
	cli.Run()
}

把跟 AddBlock 函数有关的全都注释掉

我把挖矿的难度调成了 12 , 1 的难度感觉太低了,运行一下

go run blockchain.go cbc --address ABC~
Mining the block containing 
000c6ac64b58372435b85a93c0e15b17ba8a386029fb261fbe72b48e7436c6bc

Done!

这就是挖矿成功被奖励了!

输出未花费交易(UTXO)(余额)

挖矿已经能获得奖励了,现在需要查看余额,也就是 未花费交易输出(unspent transactions output, UTXO)

要查询余额,不需要知道整个区块链上所有的 UTXO ,只需要关心那些能解锁的 UTXO (但是目前还没有实现密钥,先使用用户定义的地址来代替)。首先定义在输入和输出上的锁定和解决方法:

go
func (in *TXInput) CanUnlockOutputWith(unlockingData string) bool {
	return in.ScriptSig == unlockingData
}

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

这里只是单纯的将 script 字段和 unlockingData 进行对比。

下一步,找到所有包含未花费输出的交易:

go
func (tx Transaction) IsCoinbase() bool {
	return len(tx.Vin) == 1 && len(tx.Vin[0].Txid) == 0 && tx.Vin[0].Vout == -1
}

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 {
				// 判断是否已花费
				if spentTXOs[txID] != nil {
					for _, spentOut := range spentTXOs[txID] {
						if spentOut == outIdx {
							// 跳过已花费的输出
							continue Outputs
						}
					}
				}

				// 看看是否可以解锁,如果可以解锁,说明是自己的余额
				if out.CanBeUnlockedWith(address) {
					unspentTXs = append(unspentTXs, *tx)
				}
			}

			// 本次交易是否是 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)
					}
				}
			}
		}

		// 到达创世块
		if len(block.PrevBlockHash) == 0 {
			break
		}
	}

	return unspentTXs
}

这里比较复杂,因为交易被存储在块中,因此需要将每一个块都取出来检查是否被花费,如果输出已经被包含在其他的输入了,那就说明这个输出已经被花费了。:

go
// 判断是否已花费
if spentTXOs[txID] != nil {
    for _, spentOut := range spentTXOs[txID] {
        if spentOut == outIdx {
            // 跳过已花费的输出
            continue Outputs
        }
    }
}

为了计算余额,还需要一个方法将这些交易作为输入,最后只返回一个输出,这里就是便利所有的输出,然后进行对比,把符合的输出放到一个切片中:

go
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
}

有了这些,就可以计算余额了:

go
func (cli *CLI) Run() {
    ...
	getBalanceCmd := flag.NewFlagSet("gb", flag.ExitOnError)

	getBalanceAddress := getBalanceCmd.String("address", "", "The address to get balance for")

    ...

	case "gb":
		err := getBalanceCmd.Parse(os.Args[2:])
		if err != nil {
			log.Panic(err)
		}

    ...

	if getBalanceCmd.Parsed() {
		if *getBalanceAddress == "" {
			getBalanceCmd.Usage()
			os.Exit(1)
		}
		cli.getBalance(*getBalanceAddress)
	}
}

这里我清空了 redis 的数据,检验一下

go run blockchain.go cbc -address abc
Mining the block containing 
000f0375bb05b7ab5413c5b779c445d8d710d82e87aa9d0a55d0a2f3033e0eb3

Done!

go run blockchain.go gb -address abc  
Balance of 'abc': '50'

发送币(转账)

现在,有了余额,就可以进行交易了,这需要给别人发送币,这需要创建一笔交易,然后把它放到一个块里,然后挖出这个块。之前的 coinbase 交易是一种特殊交易,现在需要一种普通的交易。

go
// 计算所有未花费输出,然后判断是否足够,并且将未花费输出计算总和返回出去
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
}


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!")
	}

	// inputs list
	for txid, outs := range validOutputs {
		txID, err := hex.DecodeString(txid)
		if err != nil {
			log.Panic(err)
		}

		for _, out := range outs {
			input := TXInput{txID, out, from}
			inputs = append(inputs, input)
		}
	}

	// outputs list
	outputs = append(outputs, TXOutput{amount, to})
    // 找零
	if acc > amount {
		outputs = append(outputs, TXOutput{acc - amount, from})
	}

	tx := Transaction{nil, inputs, outputs}
	tx.SetID()

	return &tx
}

现在只需要添加一下 MineBlock 方法就准备好了,这个方法和前面的 coinbase 是类似的

go
func (bc *Blockchain) MineBlock(transaction []*Transaction) {
	var lastHash []byte

	ctx, rdb := NewRedisClient()
	b, err := rdb.Get(ctx, string([]byte("l"))).Result()
	if err != nil {
		log.Panic(err)
	}
	lastHash = []byte(b)

	newBlock := NewBlock(transaction, lastHash)

	rdb.Set(ctx, blocksBucket+string(newBlock.Hash), newBlock.Serialize(), 0)
	rdb.Set(ctx, blocksBucket+"l", newBlock.Hash, 0)

	bc.tip = newBlock.Hash
}

最后只需要在命令行这里实现一个 Send 方法就可以了

go
func (cli *CLI) send(from, to string, amount int) {
	bc := NewBlockchain(from)

	tx := NewUTXOTransaction(from, to, amount, bc)
	bc.MineBlock([]*Transaction{tx})
	fmt.Println("Success!")
}

func (cli *CLI) Run() {
    ...
    sendCmd := flag.NewFlagSet("s", flag.ExitOnError)
    ...
	sendFrom := sendCmd.String("from", "", "Source wallet address")
	sendTo := sendCmd.String("to", "", "Destination wallet address")
	sendAmount := sendCmd.Int("amount", 0, "Amount to send")
    ...
	switch os.Args[1] {
	...
	case "send":
		err := sendCmd.Parse(os.Args[2:])
		if err != nil {
			log.Panic(err)
		}
	...
	if sendCmd.Parsed() {
		if *sendFrom == "" || *sendTo == "" || *sendAmount <= 0 {
			sendCmd.Usage()
			os.Exit(1)
		}
		cli.send(*sendFrom, *sendTo, *sendAmount)
	}
}

最后测试一下

go run blockchain.go send -from abc -to ABC~ -amount 5
Mining the block containing 
0001c3da3d55b610c3fb5dd01495eb45c3ef89f2a8b899ccfa7bcf8b61f4389c

Success!

go run blockchain.go gb -address ABC~
Balance of 'ABC~': '5'

go run blockchain.go gb -address abc 
Balance of 'abc': '45'

go run blockchain.go send -from abc -to ABC~ -amount 100
2025/05/20 16:55:43 ERROR: Not enough funds!
panic: ERROR: Not enough funds!

可以看到,转账成功实现了。