比特币脚本有点像房间里的大象,大家都知道这个东西,但是大家都看不见,或者不在乎。这个教程将从最基本的操作开始,理解比特币脚本的原理,学会自己写比特币脚本。因为比特币脚本不是图灵完备的,所以包含很多命令行操作,以及需要观察输出结果。
运行这个命令安装 bitcoind
的二进制,然后用 bitcoind --help
来测试是否安装成功:
brew install bitcoin
创建一个用于测试使用的目录,比如我的目录名称是 bitcoin-regtest
:
mkdir ./bitcoin-regtest
cd ./bitcoin-regtest
在这个目录下新建一个叫 bitcoin.conf
文件,复制这些配置内容进去:
regtest=1
txindex=1
fallbackfee=0.0001
这是本地节点的配置文件,后续我们的比特币脚本将基于本地启动的开发节点来测试。这个配置文件中的 regtest=1
比较关键,指明了节点的类型是本地开发网络,不会真的到公网上同步区块数据,本地节点的块高度将从 0 开始。另外两个配置 txindex=1
是指启动本地节点对所有交易的索引,方便我们后续查看交易,fallbackfee=0.0001
则是指明交易手续费的大小。
停留在包含配置文件的当前目录下,执行这个命令来启动节点。这里的命令行,以及后续的命令行,都会带上 -datadir
参数,因为我们希望节点数据是隔离的,每一个工作目录都是一份新的环境,不至于污染电脑的全局环境,而且默认环境的路径比较长,不同操作系统不一致,虽然我们在后续的命令里都需要带上这么一个参数,看起来有点麻烦,但同时也避免了很多其他问题,比如找不到系统默认目录在哪儿之类:
bitcoind -datadir=./ -daemon
命令成功执行会看到 Bitcoin Core starting
的字样。为了测试节点是否真的在运行,可以用这个命令查看节点的状,会得到一个 json 数据:
bitcoin-cli -datadir=./ getblockchaininfo
如果还是对节点的运行状态不放心,可以直接查看节点的日志文件。这就是我们指定了数据目录的好处,日志文件在这个位置:
cat ./regtest/debug.log
如果想要停掉节点,避免后台进程一直在电脑上运行,用这个命令来停止节点:
bitcoin-cli -datadir=./ stop
注意启动节点用的是 bitcoind
,停止节点用的是 bitcoin-cli
。前者属于 server 端的命令,后者属于 client 端的命令。
另外,如果在停止节点后重启节点,发现钱包(下一小节内容)不能用了,可以用这个命令来导入钱包:
bitcoin-cli -datadir=./ loadwallet learn-script
运行这个命令来创建一个比特币钱包:
bitcoin-cli -datadir=./ createwallet "learn-script"
我们刚提到命令行中使用 -datadir
参数来指定数据目录,那么钱包的文件其实也会在数据目录下保存,可以看一下 ./regtest/wallets
目录,有一个 learn-script
的文件夹,我们刚才创建的钱包就在这个文件夹内。
查看钱包地址的命令,比如我的地址是 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw
:
bitcoin-cli -datadir=./ getnewaddress
接着在本地节点上,给钱包地址挖一些钱出来,这里的参数 101
是指挖 101 个区块。为什么是 101 个区块呢?一般我们挖的区块数量会大于 100,因为比特币网络有 100 个区块的成熟期,也就是区块奖励需要在 100 个区块之后,才可以消费。假如我们只挖了 99 个区块,虽然理论上应该得到很多区块奖励,但实际上是不能花费的。
bitcoin-cli -datadir=./ generatetoaddress 101 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw
这个命令运行输出的是每个块的区块哈希。运行结束后,我们就可以查看钱包地址的余额了,余额应该是 50:
bitcoin-cli -datadir=./ getbalance
为什么是 50?因为比特币的区块奖励每 4 年减半,第一次减半之前的块奖励,每个区块都是 50 BTC。为什么挖了 101 个块,但只能查到 50 BTC 的余额?因为后 100 个区块的成熟期,奖励是不到账的。
那么现在我们已经有了本地在运行的节点,以及有余额的钱包,接下来可以发起一笔普通的转账交易。先生成一个用于接收转账的新地址,我生成的地址是 bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc
:
bitcoin-cli -datadir=./ getnewaddress
可以查看验证一下,新生成的地址余额为 0。这个命令中的参数 0
意味着查询结果包含未确认的交易。
bitcoin-cli -datadir=./ getreceivedbyaddress bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc 0
接着使用发起交易的命令,来向新生成的地址转账 0.01 BTC:
bitcoin-cli -datadir=./ sendtoaddress bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc 0.01
这个命令会返回交易哈希,比如我的哈希值是 81be2e97507d7a274029ec4d5ce9728a54fe6d885aa0f12a13ec6f54eee66c26
。我们需要用这个交易哈希来查询交易结果和交易详情,像这样:
bitcoin-cli -datadir=./ gettransaction 81be2e97507d7a274029ec4d5ce9728a54fe6d885aa0f12a13ec6f54eee66c26
这笔交易此时就已经提交到链上了,但是也许你会注意到,查询交易详情返回的交易状态中,有一个 "confirmations": 0
,意味着交易还没有被确认,而且区块高度还停留在 lastprocessedblock: 101
上。因为比特币不会自动出块,这个时候查询接收地址的余额,能看出差异:
# 查询到余额是 0.01
bitcoin-cli -datadir=./ getreceivedbyaddress bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc 0
# 查询到余额是 0
bitcoin-cli -datadir=./ getreceivedbyaddress bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc 1
因为我们之前有说明,最后一个参数是 0 代表包含未确认的交易,否则只查询确认的交易。我们刚刚发送的交易就还没有确认。如果想确认下来,就得用之前的 generatetoaddress
命令再挖一个区块出来:
bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw
现在再去查询交易状态,无论是确认数还是钱包余额,就都符合预期了。
我们刚才发送的是一笔 P2WPKH 交易,因为现在比特币客户端默认使用原生 SegWit 的地址格式。
先了解一下 P2PKH 是什么,全称是 Pay to Public-Key Hash,我们使用的比特币地址本身就是一个公钥的子集,而 P2PKH 交易以账户地址为接收参数,所以命名为 P2PKH。我们常说的比特币原生地址,就是指 P2PKH 格式,一般以 1
开头,
相比 P2PKH,原生 SegWit 的地址格式叫 P2WPKH,中间多了个字母 W,全称是 Pay to Witness Public-Key Hash,特点是会把签名数据放在 witness 字段里,而不是每一笔 UTXO 的输出里,我们可以具体看一下,首先根据交易哈希,查询得到交易的全部数据:
bitcoin-cli -datadir=./ getrawtransaction 81be2e97507d7a274029ec4d5ce9728a54fe6d885aa0f12a13ec6f54eee66c26
会得到一大段编码后的数据,用这个命令来解码交易数据:
bitcoin-cli -datadir=./ decoderawtransaction 020000000001018f4e8514038b93d6cc1d4f77b011f4726ba765d338bfdf1e6724d1844bc5d36e0000000000fdffffff0240420f0000000000160014400a517208b473618b98817840328c09a77d6b123eaaf629010000001600147ef4555b42b71e6ebecd687170c92ab64cce35500247304402202417ff3f6959a7d449849ae78fd5272826339cd7096ab02cdd7eccfc7779fb14022077e43ce155259a602b6172261b1d830d30e0de8b06cd6479cac02ea7c6928ff10121020b396a9dfa1655feef066fe03b403d3e4bdee41ef9b26551497c0921acbf6bc196000000
查询得到的数据结构是这样:
{
// ...
"vin": [
{
// ...
"txinwitness": [
"304402202417ff3f6959a7d449849ae78fd5272826339cd7096ab02cdd7eccfc7779fb14022077e43ce155259a602b6172261b1d830d30e0de8b06cd6479cac02ea7c6928ff101",
"020b396a9dfa1655feef066fe03b403d3e4bdee41ef9b26551497c0921acbf6bc1"
],
}
],
"vout": [
{
"value": 0.01000000,
"scriptPubKey": {
"asm": "0 400a517208b473618b98817840328c09a77d6b12",
"desc": "addr(bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc)#nry368tt",
"hex": "0014400a517208b473618b98817840328c09a77d6b12",
"address": "bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc",
"type": "witness_v0_keyhash"
}
},
{
"value": 49.98998590,
"scriptPubKey": {
// ...
}
}
]
}
首先关注 txinwitness
这个字段,它是一个数字,有两个部分,第一个部分是签名数据,第二个部分是公钥,这就是我们之前提到的 SegWit,对金额的签名不放在 vout
里,而是放在了 vin
里。
然后再关注 scriptPubKey
里的 asm
,ASM 是 RedeemScript 的意思,表示满足什么样的条件就可以消费脚本中锁定的金额。是的我们即使是发起普通转账,实际上也是一种比特币脚本,金额锁定在了脚本中。我们查询到的脚本内容分为两段,第一段是 0
,表示比特币脚本中的一个操作码 OP_0
,第二段是 400a517208b473618b98817840328c09a77d6b12
,其实就是钱包地址,经过 bech32 编码后会变成熟悉的样子 bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc
。
刚才提到了 OP_0
这个操作码,它具体是什么呢?操作码是比特币脚本的关键,我们可以用 btcdeb
工具调试和观察一下。btcdeb 没有提供一键式的安装命令,可以按照 官方的教程 先下载源码,然后编译安装。验证安装结果:
btcdeb --version
OP_0
这个操作码本身干的事情很简单,就是把空数据压进栈结构里,尝试运行命令:
btcdeb OP_0
会看到这样的输出:
script | stack
--------+--------
0 |
#0000 0
前面的 script
表示有一个操作 0
, 也就是 OP_0
,这里显示的时候自动隐去了 OP_
前缀。后面 #0000 0
则表示目前栈里内容为 0(空)。接下来的输入 step
命令,让 btcdeb 真正运行 OP_0
这个步骤,运行结果是这样,可以看到推了一个空数据到栈里,这就是 OP_0
干的事情:
step
<> PUSH stack
为了增加理解,我们举一个别的操作码例子来观察栈内数据的变化,尝试这个命令:
btcdeb '[OP_2 OP_3 OP_ADD]'
然后输出 step
命令,一直按回车直到脚本结束,输出内容的过程像是这样。默认内容是这样,此时脚本里有 3 个操作码等待执行,分别是 OP_2
、OP_3
和 OP_ADD
:
script | stack
--------+--------
2 |
3 |
OP_ADD |
#0000 2
第一次回车执行了脚本的第一个步骤 OP_2
,对应操作把数字 2 压入栈,执行结束后脚本里剩 2 个操作码了,同时 stack
中有了数字 2:
step
<> PUSH stack 02
btcdeb> script | stack
--------+--------
3 | 02
OP_ADD |
#0001 3
第二次回车继续执行了 OP_3
操作码,把数字 3 压入栈,此时脚本里只剩 1 个操作码,栈中有数字 2 和数字 3:
<> PUSH stack 03
btcdeb> script | stack
--------+--------
OP_ADD | 03
| 02
#0002 OP_ADD
第三次回车执行 OP_ADD
操作码,这个操作码会从栈里弹出两个数字,计算加法后把结果推回栈内,得到结果 5:
<> POP stack
<> POP stack
<> PUSH stack 05
btcdeb> script | stack
--------+--------
| 05
因为 btcdeb
的命令行输出并不是特别直观,所以这里尽管占用篇幅,也有必要把整个过程的输出都复制过来,还拆分了步骤,方便理解每一步在干什么。可以看到每一个操作码都会对应一些行为,这个行为是比特币程序里定义的,包括加法、减法等各种运算,也有一些行为更复杂的操作,或者对简单的操作码进行排列组合,达到实现更复杂功能的目的。我们还看到比特币脚本的执行是基于栈的,全部行为都发生在栈结构里,栈结构也就意味着完全没有动态内存分配之类的东西。
刚才尝试了在 btcdeb 调试工具里运算加法,现在试着在实际的比特币交易中,写入脚本代码,并且在链上运算。这段是原始的操作码形式的脚本,要注意这个脚本是不安全的,属于自验证的脚本,任何人都可以花费这个脚本中的金额,只是在花费过程中,脚本表示的数字运算会在链上执行:
[OP_2 OP_3 OP_ADD OP_5 OP_EQUAL]
首先需要把操作码转变为十六进制形式,这个编码过程需要手动,或者写代码来操作。我们使用手动的方式,这个 比特币文档 中列出了全部支持的操作码,以及对应的十六进制字符,到我们这个小脚本这里,对应关系就是:
操作码 | 十六进制 |
---|---|
OP_2 | 52 |
OP_3 | 53 |
OP_ADD | 93 |
OP_5 | 55 |
OP_EQUAL | 87 |
因此我们按照依次拼接的顺序,得到了的十六进制脚本:
5253935587
接着生成 P2SH 地址。P2SH 的全称是 Pay to Script Hash,意思是支付到脚本哈希,或者说锁定金额到脚本中,相当于链上脚本的地址:
bitcoin-cli -datadir=./ decodescript 5253935587
命令输出中有一个 p2sh-segwit
字段,值是 2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX
,把这个 P2SH 地址用作参数生成脚本的校验和,校验和是构造比特币交易必须要的一个参数:
bitcoin-cli -datadir=./ getdescriptorinfo "addr(2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX)"
得到 descriptor
的值为 addr(2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX)#s260u65e
,后续用这个值作为脚本参数构造交易。
不过到这里还有个坑,比特币的 P2SH 脚本,只能用观察模式的钱包导入,所以需要新创建一个没有私钥的钱包:
bitcoin-cli -datadir=./ createwallet "arith-watch" true true "" true
用刚刚创建的新钱包,导入 P2SH 脚本。看到这个命令返回 "success": true
,才表示导入成功:
bitcoin-cli -datadir=./ -rpcwallet=arith-watch importdescriptors '[{"desc":"addr(2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX)#s260u65e","timestamp":"now","label":"arith-2+3=5"}]'
现在有了 P2SH 的脚本地址,并且已经把脚本导入到钱包,接下来可以给脚本打钱了。这个命令从 learn-script 钱包转账 0.01 BTC 给脚本:
bitcoin-cli -datadir=./ -rpcwallet=learn-script sendtoaddress 2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX 0.01
挖一个区块让交易确认:
bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw
现在,这个脚本就上链并且有余额了。
目前这个脚本地址里的钱,任何人都可以消费,消费的同时会运算一下 2+3
这个表达式,并且判断结果是否为 5
。接下来构建一笔花费脚本金额的交易,真正花掉刚才存进脚本的钱。准备一个收款地址:
bitcoin-cli -datadir=./ -rpcwallet=learn-script getnewaddress
我新建的地址是 bcrt1q0n2x7030x59j5ql9pp6mw0tps74ag0znrdp45r
。用这个地址构建一笔交易,注意这里 inputs
中的 txid
,是刚才给 P2SH 转账的那一笔交易哈希:
bitcoin-cli -datadir=./ -named createrawtransaction \
inputs='[{"txid":"b952acd06a4f7edd7b2d5da0d509d01dfbb8e49fa15123d9cd5d3d23f944cdc2","vout":0}]' \
outputs='{"bcrt1q0n2x7030x59j5ql9pp6mw0tps74ag0znrdp45r":0.009}'
在构建的交易中添加自动找零参数:
bitcoin-cli -datadir=./ -rpcwallet=learn-script \
fundrawtransaction 0200000001c2cd44f9233d5dcdd92351a19fe4b8fb1dd009d5a05d2d7bdd7e4f6ad0ac52b90000000000fdffffff01a0bb0d00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c5300000000
关键的一步,用钱包给这笔交易签名,注意这里是给找零之后的交易数据进行签名,如果不找零,节点会把找零金额当作手续费,而节点默认还有手续费的上限值,如果这一步没找零,下一步会触发手续费上限报错:
bitcoin-cli -datadir=./ -rpcwallet=learn-script \
signrawtransactionwithwallet 0200000001c2cd44f9233d5dcdd92351a19fe4b8fb1dd009d5a05d2d7bdd7e4f6ad0ac52b90000000000fdffffff02a0bb0d00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c5360a0d92901000000160014a3e136e24d5a8db14f15016b99fb21ea4b0b69da00000000
最后,把签名好的交易数据广播出去就行了:
bitcoin-cli -datadir=./ sendrawtransaction 02000000000101c2cd44f9233d5dcdd92351a19fe4b8fb1dd009d5a05d2d7bdd7e4f6ad0ac52b90000000017160014c2d5ade24c1d0b9f27f651a71c3fe49d23d0ae13fdffffff02a0bb0d00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c5360a0d92901000000160014a3e136e24d5a8db14f15016b99fb21ea4b0b69da024730440220406a51d43ade05b240fcf2d14b58c90f31ebc705ab262189949355cac54d0431022051b592c570ef960a35e8509766e903ba836e3bcd1fb3c5cc211f0ff3442283550121021ff283ca8c9ecb45c8e19eacb7e8ae6fcb27d8addd38011d633e396487db44e300000000
记得再挖一个区块让交易确认:
bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw
查看交易状态,验证交易已被花费,如果返回空值,说明已被花费。这里查的交易哈希是当时用钱包给脚本转账 0.01 BTC 那一笔交易的哈希:
bitcoin-cli -datadir=./ gettxout b952acd06a4f7edd7b2d5da0d509d01dfbb8e49fa15123d9cd5d3d23f944cdc2 0
我本地的操作环境以及软件脚本是:
OS: MacOS
bitcoind: v29.0.0
btcdeb:5.0.24