比特币脚本开发教程

2025-07-10

比特币脚本有点像房间里的大象,大家都知道这个东西,但是大家都看不见,或者不在乎。这个教程将从最基本的操作开始,理解比特币脚本的原理,学会自己写比特币脚本。因为比特币脚本不是图灵完备的,所以包含很多命令行操作,以及需要观察输出结果。

1. 启动本地节点

运行这个命令安装 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 

2. 创建钱包

运行这个命令来创建一个比特币钱包:

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 个区块的成熟期,奖励是不到账的。

3. 发送交易

那么现在我们已经有了本地在运行的节点,以及有余额的钱包,接下来可以发起一笔普通的转账交易。先生成一个用于接收转账的新地址,我生成的地址是 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

现在再去查询交易状态,无论是确认数还是钱包余额,就都符合预期了。

4. 查看交易脚本

我们刚才发送的是一笔 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

5. 用 btcdeb 调试

刚才提到了 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_2OP_3OP_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 的命令行输出并不是特别直观,所以这里尽管占用篇幅,也有必要把整个过程的输出都复制过来,还拆分了步骤,方便理解每一步在干什么。可以看到每一个操作码都会对应一些行为,这个行为是比特币程序里定义的,包括加法、减法等各种运算,也有一些行为更复杂的操作,或者对简单的操作码进行排列组合,达到实现更复杂功能的目的。我们还看到比特币脚本的执行是基于栈的,全部行为都发生在栈结构里,栈结构也就意味着完全没有动态内存分配之类的东西。

6. 自己编写比特币脚本 (1)

刚才尝试了在 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

现在,这个脚本就上链并且有余额了。

7. 自己编写比特币脚本 (2)

目前这个脚本地址里的钱,任何人都可以消费,消费的同时会运算一下 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

8. Troubshooting

我本地的操作环境以及软件脚本是:

OS: MacOS
bitcoind: v29.0.0
btcdeb:5.0.24