Transaction 1
交易(Transaction) 是 BTC 的核心,也是区块链唯一的目的,为了可以安全可靠的交易,交易一旦被创建,就没有人可以再去修改或者删除。
BTC 使用的 UTXO(Unspent Transaction Output,未花费交易输出) 模型, UTXO 的核心思想:
- 交易即现金流转
将货币视为 物理现金交易 的数字化- 每一笔交易都像用纸币支付,需要消耗旧的 “纸币”(UTXO),生成新的“纸币”(UTXO)
- 不存在全局账户余额 ,只有分散的未花费输出集合,因此余额需要通过遍历整个交易历史来获得
- 链式所有权校验
- 每个 UTXO 都包含所有者的锁定脚本(如公钥哈希)
- 要花费 UTXO ,必须提供匹配的解锁脚本(如签名)
BTC 的数据
如果你做过互联网开发,实现一个支付相关的功能,至少需要在数据库中创建两个表,一个用来存储账号的信息和余额,一个用来存储订单信息。
但是在 BTC 中不能这样, BTC 没有账号,没有余额,没有地址,没有付款方和收款方。
BTC 交易
在 blockchain.info 中,可以看到交易信息。
交易由输入 (input) 和输出 (output) 组合而来:
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 并注册到那个人的地址,可以为他所用。
交易输出
首先实现输出
type TXOutput struct {
Value int
ScriptPubKey string
}
输出主要包括两个部分:
- 一定量的币, BTC 中是以“聪” (Satoshi) 为单位,1 BTC = 100,000,000 聪。
- 定义花费这笔比特币的解锁条件,称为“锁定脚本”,通常包含接收者的公钥哈希。
事实上,就是这里的输出存储了“币”,也就是这里的 Value
字段。而这里的存储,指的是用一个数学难题对输出进行锁定,这个难题被存储在 ScriptPubKey 里面。在内部,比特币使用了一个叫做 Script 的脚本语言,用它来定义锁定和解锁输出的逻辑。虽然这个语言相当的原始(这是为了避免潜在的黑客攻击和滥用而有意为之),并不复杂,但是我们也并不会在这里讨论它的细节。你可以在这里找到详细解释。
由于还没实现地址 (adress) ,所以目前会避免涉及相关逻辑。 ScriptPubKey
将会存储一个任意的字符串(用户定义的钱包地址)。
有了一个这样的脚本语言,也意味着比特币其实也可以作为一个智能合约平台。
关于输出,它们是不可再分的,不能使用其中的一部分,要么用完,要么不用。如果一个交易中引用了某个输出,那么这个输出就被使用了,不能再用了,如果它的值大于需求的值,则会产生一个找零,将找零返回给发送方,而这个找零会产生新的输出。这和现实十分相似。
交易输入
输入:
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 交易:
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。
将交易保存到区块链
每个块必须存储至少一笔交易。如果没有交易,也就不可能生产出新的块,这意味着移除 Block
的 Data
字段,改为存储交易。
type Block struct {
Timestamp int64
Transactions []*Transaction
// Data []byte
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{})
}
接着是创建区块链的修改,复制一份 NewBlockchain
,在它的上面修改就好:
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
方法:
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 而无需下载所有交易即可完成判断。
修改命令行:
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
函数修改为:
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 (但是目前还没有实现密钥,先使用用户定义的地址来代替)。首先定义在输入和输出上的锁定和解决方法:
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 (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
}
这里比较复杂,因为交易被存储在块中,因此需要将每一个块都取出来检查是否被花费,如果输出已经被包含在其他的输入了,那就说明这个输出已经被花费了。:
// 判断是否已花费
if spentTXOs[txID] != nil {
for _, spentOut := range spentTXOs[txID] {
if spentOut == outIdx {
// 跳过已花费的输出
continue Outputs
}
}
}
为了计算余额,还需要一个方法将这些交易作为输入,最后只返回一个输出,这里就是便利所有的输出,然后进行对比,把符合的输出放到一个切片中:
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
}
有了这些,就可以计算余额了:
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 交易是一种特殊交易,现在需要一种普通的交易。
// 计算所有未花费输出,然后判断是否足够,并且将未花费输出计算总和返回出去
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 是类似的
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
方法就可以了
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!
可以看到,转账成功实现了。