btcd:Golang 下的交易构建
|字数总计:2.7k|阅读时长:10分钟|阅读量:
锲子
参与 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())
|
地址生成
同样,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())
|
这里嵌套了四层函数,从内到外依次是
- 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
| pubkey := wif.PrivKey.PubKey() pkh := btcutil.Hash160(pubkey.SerializeCompressed())
pubkeyAddr, _ := btcutil.NewAddressPubKeyHash(pkh, cfg)
script, _ := txscript.NewScriptBuilder(). AddOp(txscript.OP_0). AddData(pkh). Script() scriptAddr, _ := btcutil.NewAddressScriptHash(script, cfg)
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))
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
| 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
var signedTx bytes.Buffer tx.Serialize(&signedTx) finalRawTx := hex.EncodeToString(signedTx.Bytes())
fmt.Printf("Signed Transaction:\n %s", finalRawTx)
|
本节不设计代码层面的交易发送,简单来说交易的广播就是将它发布到任意一个区块链节点
所以可以将生成的交易提交到 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, idx, txOut.PkScript, 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 }
witness, err := txscript.WitnessSignature( tx, txscript.NewTxSigHashes(tx, fetcher), idx, txOut.Value, script, 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 }
|
参考文章
- Create Raw Bitcoin Transaction and Sign It With Golang
- Learn me a bitcoin