锲子

参与 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 小节中的各种地址。

支付类型和地址相关的内容参见 Bitcoin 支付方式及地址类型

目前比较常用的是 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 地址

其他类型的地址生成方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 通过 wif 得到公钥并计算 hash160 值
pubkey := wif.PrivKey.PubKey()
pkh := btcutil.Hash160(pubkey.SerializeCompressed())

// P2PKH 类型地址
pubkeyAddr, _ := btcutil.NewAddressPubKeyHash(pkh, cfg)
// P2SH-P2WPKH 类型地址:先构建支付脚本,然后对脚本生成地址
script, _ := txscript.NewScriptBuilder().
AddOp(txscript.OP_0).
AddData(pkh).
Script()
scriptAddr, _ := btcutil.NewAddressScriptHash(script, cfg)
// P2WPKH 地址
witnessAddr, _ := btcutil.NewAddressWitnessPubKeyHash(pkh, cfg)

交易构建

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

扩展:其他类型的输入、输出以及交易大小

交易输出

golang 下的 btcd 提供了统一的方法构建输出脚本,可以通过类似前面 Taproot 交易构建的方法来实现交易输出的设置。当然,也可以根据交易格式手动构建输出,但是比较繁琐且容易出错,这里展示统一的构建方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (cb *CommonBuilder) AddPayToAddressOutput(value int64, address string) error {
addr, err := btcutil.DecodeAddress(address, cb.cfg)
if err != nil {
return err
}

lockingScript, err := txscript.PayToAddrScript(addr)
if err != nil {
return err
}

out := wire.NewTxOut(value, lockingScript)
cb.tx.AddTxOut(out)
cb.outputValue += value

return nil
}

交易输入签名

针对不同的输入类型,需要通过调用不同的函数来实现交易的签名,从而对 UTxO 进行解锁

解锁的签名涉及到不同类型的签名,它对应了 HashType 这个字段,通常在签名的最后一个字节,各种类型可以参考 部分签名交易

下面的代码里的 idx 代表的是输入所在交易中的位置,这里的交易设置了输入、输出后但是没有签名,依次遍历输入进行签名

P2PKH

P2PKH 类型的输入需要对前一个输出的公钥哈希进行签名,这里可以直接调用 txscript.SignatureScript 函数进行签名

1
2
3
4
5
6
7
8
9
10
11
12
13
sig, err := txscript.SignatureScript(
tx, // wire.MsgTX 类型的交易体
idx, // 交易输入的位置,例如第一个位置是0,依次推导
txOut.PkScript, // 需要进行签名的脚本,也就是前一个输出的 pubkeyScript
txscript.SigHashAll, // 交易的签名类型
wif.PrivKey, // 签名私钥
true, // 输出的公钥是否压缩类型
)
if err != nil {
return err
}

tx.TxIn[idx].SignatureScript = sig

P2WPKH-P2SH

这种类型的输入签名没有已经实现的函数,需要构建输入脚本到交易的 signatureScript 并且进行 Witness 签名后填入字段

构建 Witness 签名可以通过调用 WitnessSignature 方法来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 构建脚本,以解锁输出中锁定的脚本哈希
script, err := txscript.NewScriptBuilder().
AddOp(txscript.OP_0).
AddData(pkh).
Script()
if err != nil {
return err
}

// 对脚本进行签名,这里调用 WitnessSignature 进行签名
witness, err := txscript.WitnessSignature(
tx, // wire.MsgTX 类型的交易体
txscript.NewTxSigHashes(tx, fetcher), // 这里需要实现一个交易的 fetcher 用于构建哈希
idx, // 交易输入的位置,例如第一个位置是0,依次推导
txOut.Value, // 前一个交易输出中携带的 btc 数量,单位为
script, // 对前面的脚本进行签名,注意,这里是 P2SH 交易,不是对前面的锁定脚本签名
txscript.SigHashAll, // 签名类型
wif.PrivKey, // 签名私钥
true, // 是否压缩类型
)
if err != nil {
return err
}

// 这里需要构建新的脚本,将前一个脚本压栈
script, err = txscript.NewScriptBuilder().
AddData(script).
Script()
if err != nil {
return err
}

tx.TxIn[idx].SignatureScript = script
tx.TxIn[idx].Witness = witness

P2WPKH

Witness 下的 P2PKH 交易比较简单,直接调用 WitnessSignature 方法来实现签名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sig, err := txscript.TaprootWitnessSignature(
tx,
txscript.NewTxSigHashes(tx, fetcher),
idx,
txOut.Value,
txOut.PkScript,
txscript.SigHashAll,
wif.PrivKey,
)
if err != nil {
return err
}

tx.TxIn[idx].Witness = sig

Taproot

Taproot 的类似前面所说的签名方式,这里直接给代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sig, err := txscript.TaprootWitnessSignature(
tx,
txscript.NewTxSigHashes(tx, fetcher),
idx,
txOut.Value,
txOut.PkScript,
txscript.SigHashAll,
wifs.PrivKey,
)
if err != nil {
return err
}

tx.TxIn[idx].Witness = sig

交易大小

交易根据不同的交易类型存在不同的计算方式:Transaction size calculator

但是,公式并不能很好地计算混合类型的交易,所以 go-btc 下提供了函数计算交易的虚拟大小(但是通过实际的使用发现似乎存在误差,后续还需要看一下)

这里是编写的工具里一个用于设置交易费用的代码,通过 mempool.GetTxVirtualSize 方法来获取交易大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func (cb *CommonBuilder) SetRefundOutput(refundAddress string) error {
address, err := btcutil.DecodeAddress(refundAddress, cb.cfg)
if err != nil {
return err
}

lockingScript, err := txscript.PayToAddrScript(address)
if err != nil {
return err
}

out := wire.NewTxOut(0, lockingScript)
cb.tx.AddTxOut(out)

vSize := mempool.GetTxVirtualSize(btcutil.NewTx(cb.tx))
fee := float64(vSize) * cb.gasPrice
refund := (cb.inputValue - cb.outputValue) - int64(math.Floor(fee+0.5))

if refund > 0 {
cb.tx.TxOut[len(cb.tx.TxOut)-1].Value = refund
} else {
cb.tx.TxOut = cb.tx.TxOut[:len(cb.tx.TxOut)-1]
if refund < 0 {
return fmt.Errorf("input value %d < output value %d", cb.inputValue, cb.outputValue)
}

}

return nil
}

参考文章

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