锲子

参与 Bitcoin 的相关生态难免需要涉及到链上的操作,相比于 Ethereum 这样简单的交易发送机制,Bitcoin 上构建交易则需要一定的编程能力,这篇博文记录一下 Bitcoin 上构建交易的各种姿势。(博文不间断更新)

这篇博文的前置知识:UTxO、交易结构与脚本语言,可以在 Learn me a bitcoin 学习,另外:

golang 下的 Bitcoin 工具库为:github.com/btcsuite/btcd

简单交易的构建

私钥与地址生成

私钥生成

私钥有多种形式,在 Bitcoin 中最为常见的是 WIF (Wallet Import Format)格式的私钥,也有 16 进制的私钥,这些私钥之间是可以相互转换的

事实上目前的大多数钱包插件都不支持 WIF 格式的私钥导入,只能使用一些软件钱包才能导入。

golang 内可以生成私钥并派生 WIF 格式的私钥,也可以使用在线的 BIP-39 助记词生成(不推荐主网使用)

1
2
3
4
5
6
7
8
9
10
11
12
// 博文的代码都默认为测试网
cfg := &chaincfg.TestNet3Params

privateKey, err := btcec.NewPrivateKey()
if err != nil {
log.Fatalln(err)
return
}

wif, err := btcutil.NewWIF(privateKey, cfg, true)
fmt.Printf("Generated WIF Key: %s", wif.String())
// Generated WIF Key: cViUtGHsa6XUxxk2Qht23NKJvEzQq5mJYQVFRsEbB1PmSHMmBs4T

地址生成

同样,Bitcoin 的地址有多种类型,在之前的博文 BIP、闪电网络与 Trao 中略有提及,如果需要具体了解也可以阅 Learn me a bitcoin 的 Script 小节中的各种地址。

支付类型和地址相关的内容放在了新坑:btcd:Golang 下的交易构建

目前比较常用的是 Taproot 地址,这是为了支持 Taproot 协议而被提出的地址类型,可以支持不同的 Pay-To 方式

在代码的实现层面上,通过 WIF 私钥生成地址的代码如下

1
2
3
4
5
6
7
8
taprootAddr, err := btcutil.NewAddressTaproot(
schnorr.SerializePubKey(
txscript.ComputeTaprootKeyNoScript(
wif.PrivKey.PubKey())),
&chaincfg.TestNet3Params)

log.Printf("Taproot testnet address: %s\n", taprootAddr.String())
// Taproot testnet address: tb1p3d3l9m5d0gu9uykqurm4n8xcdmmw9tkhh8srxa32lvth79kz7vysx9jgcr

这里嵌套了四层函数,从内到外依次是

  • wif.PrivKey.PubKey():获取 WIF 密钥对的公钥
  • txscript.ComputeTaprootKeyNoScript:通过公钥计算得到一个 Taproot 的 Schnorr 签名公钥,类似一个转换的过程
  • schnorr.SerializePubKey:对公钥序列化为字节码
  • btcutil.NewAddressTaproot:序列化后的公钥生成 Taproot 地址

简单交易构建

Bitcoin 最为简单的交易即一笔输入和一笔输出的交易,它将一笔输入中的 BTC 转入到另外一个地址

随机生成转账地址:tb1pvwak065fek4y0mup9p4l7t03ey2nu8as7zgcrlgm9mdfl8gs5rzss490qd

虽然说是“简单”交易构建,实际上 Taproot 类型的交易算是比较麻烦的交易构建方式,较为简单的应该是 P2PKH 交易的构建。

构建交易之前需要获取地址的可用 UTxO,需要一个函数 GetUnspent(address string) 来得到地址的 UTxO,这里先手动填入然后返回所需要的 UTxO 的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func GetUnspent(address string) (*wire.OutPoint, *txscript.MultiPrevOutFetcher){
// 交易的哈希值,并且要指定输出位置
txHash, _ := chainhash.NewHashFromStr(
"7282d54f485561dd21ba22a971b096eb6d0f45ed2fe6bf8c29d87cee162633b4")
point := wire.NewOutPoint(txHash, uint32(0))

// 交易的锁定脚本,对应的是 ScriptPubKey 字段
script, _ := hex.DecodeString("51208b63f2ee8d7a385e12c0e0f7599cd86ef6e2aed7b9e033762afb177f16c2f309")
output := wire.NewTxOut(int64(1000), script)
fetcher := txscript.NewMultiPrevOutFetcher(nil)
fetcher.AddPrevOut(*point, output)

return point, fetcher
}

这里返回的是一个输出点(output point)和前置输出获取器(Fetcher)

  • 输出点记录了 UTxO 所在的交易哈希和输出所在交易的位置(index),后续用于填充交易的输入
  • 获取器中记录了一个映射关系,即一个输出点对应的输出是什么样的(它输出的数量和锁定脚本)

此外,发送交易之前要将地址解码并且生成 Taproot 下的 PayToAddress 脚本,该过程的实现代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
func DecodeTaprootAddress(strAddr string, cfg *chaincfg.Params) ([]byte, 
error) {
taprootAddr, err := btcutil.DecodeAddress(strAddr, cfg)
if err != nil {
return nil, err
}

byteAddr, err := txscript.PayToAddrScript(taprootAddr)
if err != nil {
return nil, err
}
return byteAddr, nil
}

至此,可以开始一笔简单交易的构建,相比于在 Ethereum 下一个 Json 就可以完成的操作,在 Bitcoin 则是需要实例化一个空的交易体并手动填充

新建交易的输入(wire.TxIn)需要三个参数:前一输出点、签名、见证脚本(witness),构建交易时后面两者均先默认为 nil,在签名完成后才进行填充

签名和见证脚本

在通常情况下,见证脚本和签名脚本是独立的,或者说见证脚本就是一种签名,只是它独立在交易体外,并且这一部分数据也可以在很久之后被节点修剪掉。

1
2
3
4
5
6
7
8
9
10
// 默认的 version = 1
tx := wire.NewMsgTx(wire.TxVersion)

// 以前一笔交易的输出点作为输入
in := wire.NewTxIn(point, nil, nil)
tx.AddTxIn(in)

// 新建输出,支付到指定地址并填充转移多少
out := wire.NewTxOut(int64(800), byteAddr)
tx.AddTxOut(out)

然后需要对交易进行签名,签名是对交易的所有输入进行签名,即需要填入正确的解锁脚本到签名脚本或见证脚本字段中。在 Taproot 交易中,通常需要填入解锁的见证脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 获取前一笔交易
prevOutput := fetcher.FetchPrevOutput(in.PreviousOutPoint)

// 使用私钥生成见证脚本
witness, _ := txscript.TaprootWitnessSignature(tx,
txscript.NewTxSigHashes(tx, fetcher), 0, prevOutput.Value,
prevOutput.PkScript, txscript.SigHashDefault, wif.PrivKey)

// 填充输入的见证脚本
tx.TxIn[0].Witness = witness

// 将完成签名的交易转为 hex 形式并输出
var signedTx bytes.Buffer
tx.Serialize(&signedTx)
finalRawTx := hex.EncodeToString(signedTx.Bytes())

fmt.Printf("Signed Transaction:\n %s", finalRawTx)
// Signed Transaction: 01000000000101b4332616ee7cd8298cbfe62fed450f6deb96b071a922ba21dd6155484fd582720000000000ffffffff01200300000000000022512063bb67ea89cdaa47ef81286bff2df1c9153e1fb0f09181fd1b2eda9f9d10a0c5014011a52fdf6ccdda65359ecc9761b199e132d92bb21be059c6c5fb23e86af7152d429dde23314df0db4bcd52428acffab876b8cca1e19d2788a8382c48141b19bd00000000

本节不设计代码层面的交易发送,简单来说交易的广播就是将它发布到任意一个区块链节点

所以可以将生成的交易提交到 Broadcast Transaction 即可,演示交易为 https://mempool.space/testnet/tx/f11f3edccb9988729ba4896e1da82b799a7b4e70cca82aa212058076dd49d76f

本节的完整代码:Simple Bitcoin Transaction - Github Gist

参考文章

  1. Create Raw Bitcoin Transaction and Sign It With Golang
  2. Learn me a bitcoin