P2TR
: Pay To Taproot, 意为支付到 Taproot
。由于是隔离见证的第 1 个版本, 所以也叫 V1_P2TR
。
锁定脚本
P2TR
锁定脚本的操作码模板:
OP_1 <Taproot 输出公钥>
以下是一个 P2TR
锁定脚本的示例:
OP_1
是隔离见证的版本字节, 表示该锁定脚本是 P2TR
。紧接着是 32 字节的Taproot
输出公钥的 坐标。
Taproot 输出公钥 是一个 32
字节的公钥, 由内部公钥和脚本树的默克尔根计算得到。

-
内部公钥是用户私钥经过
secp256k1
曲线生成, 实际上就是用户公钥。但在计算Taproot
输出公钥时, 只会使用公钥的 坐标。 -
Taproot
允许多个锁定脚本, 任意一个锁定脚本的解锁即可花费UTXO
。 在有多种锁定脚本的情况下, 将多个锁定脚本通过MAST
构造成一个脚本树, 脚本树的默克尔根参与计算Taproot
输出公钥。在花费时, 可以选择其中一个锁定脚本并提供解锁脚本, 同时还需要提供默克尔证明。假设使用上图中Script_A
来花费UTXO
, 除了解锁脚本外, 还需要其默克尔证明B
和C
。
多种锁定脚本可以是完全自定义的锁定脚本, 也可以是标准锁定脚本, 例如:
// Alice 可花费 (P2PK)
script_A = '<Alice_pubkey> OP_CHECKSIG'
// Bob 可以在 30 天后花费
script_B = '<30 days> OP_CHECKLOCKTIMEVERIFY OP_DROP <Bob_pubkey> OP_CHECKSIG'
// Alice, Bob, Carol 任意 2 人可以花费 (P2MS)
script_C =
'OP_2 <Alice_pubkey> <Bob_pubkey> <Carol_pubkey> OP_3 OP_CHECKMULTISIG'
锁定脚本经过 taggedHash
运算后, 成为脚本树的子节点。将所有子节点构造成一个默克尔树, 得到默克尔根。内部公钥和默克尔根拼接后经过 taggedHash
运算计算出 tweak
值, 简称 t
。
内部公钥加上 t
乘以椭圆曲线 secp256k1
的基点 G
即可得到 Taproot
输出公钥 Q
。
Q
是椭圆曲线的一个点, 但在实际中, 只会用到其 x
坐标, 因此 Q
实际指的是点 Q
的 x
坐标。
将 Q
进行 bech32m
编码即可得到 Taproot
地址。 当向 Taproot
地址转账时, 对其执行 bech32m
解码可得到地址的 Taproot
输出公钥, Taproot
输出公钥最终会被存入 UTXO
锁定脚本中。
TaggedHash
TaggedHash
是一个对数据进行 sha256
哈希运算的函数。
const taggedHash = (prefix: string, data: Buffer) => {
return sha256(
Buffer.concat([Buffer.concat([sha256(prefix), sha256(prefix)]), data])
)
}
脚本树可以为空。 在比特币钱包中, 默认生成的 Taproot
地址都是在没有脚本树的情况下:
Taproot 地址生成器
解锁脚本
要解锁 P2TR
, 有两种方式:
- 秘钥路径 -
Key Path
- 脚本路径 -
Script Path
秘钥路径
只有持有内部公钥对应的私钥才能使用秘钥路径解锁 P2TR
, 解锁脚本只有一个签名数据。
以下是一个秘钥路径解锁 P2TR
的交易示例:
该交易只有一个输入, 对应的 Witness
字段为
[
{
"stackItems": "01",
"0": {
"size": "40",
"item": "ad06bd890367e518991fa7337b834032d824586b61d95cf84c0a53ee2c58cb37b53e324c8f85609da8f90da48f35af10121420f112dfc91ea6ed5fccfa0c5f8a" // 签名
}
}
]
见证数据中包含的元素只有一个, 即 Schnorr
签名数据。
需要注意的是, 秘钥路径花费时的签名私钥并非是内部私钥, 而是 Taproot
私钥。Taproot
公钥可以由 Taproot
私钥计算得到。
已知 Taproot
公钥 , 内部私钥为 , 替换 为 : , 可以得到 , 令 Taproot
私钥为 K_t
, 则
得出 Taproot
私钥为 , 即内部私钥加上 tweak
值。
import { networks, type Signer } from 'bitcoinjs-lib'
const network = networks.testnet
const keyPair = ECPair.fromWIF(process.env['SECRET_KEY']!, network)
function tweakSigner(signer: Signer, opts: any = {}): Signer {
let privateKey: Uint8Array | undefined = signer.privateKey!
if (!privateKey) {
throw new Error('Private key is required for tweaking signer!')
}
if (signer.publicKey[0] === 3) {
privateKey = ecc.privateNegate(privateKey)
}
// 内部私钥加上 tweak 值
const tweakedPrivateKey = ecc.privateAdd(
privateKey,
// tweak = TaggedHash('TapTweak', P + Root), Root 为脚本树的根哈希
tapTweakHash(toXOnly(signer.publicKey), opts.tweakHash)
)
if (!tweakedPrivateKey) {
throw new Error('Invalid tweaked private key!')
}
return ECPair.fromPrivateKey(tweakedPrivateKey, {
network
})
}
function tapTweakHash(pubKey: Buffer, h: Buffer | undefined): Buffer {
return crypto.taggedHash(
'TapTweak',
Buffer.concat(h ? [pubKey, h] : [pubKey])
)
}
秘钥路径花费时, 不需要执行脚本。只需要验证签名和锁定脚本中的 Taproot
公钥是否匹配即可证明交易的有效性。
import { Transaction } from 'bitcoinjs-lib'
import { verifySchnorr } from 'tiny-secp256k1'
const verifyTaprootSignature = () => {
// txid: ffda7de7dd1466343e1da5566fdb7db32476d0b0f2e1be7d5dc783bf555d2e00
const tx = Transaction.fromHex(
'02000000000101c1d468b687c165337bcf3ca07194b142f09d4f74e244044a6a8fee9d5539f0ab0200000000ffffffff025798000000000000160014f90371a81f05e7f13091c523ac4ab2f438f9e30272003000000000002251203cb650a3809269365cfea9874982c00b2f61951203a6f3b3686848f1e1b08d460140ad06bd890367e518991fa7337b834032d824586b61d95cf84c0a53ee2c58cb37b53e324c8f85609da8f90da48f35af10121420f112dfc91ea6ed5fccfa0c5f8a00000000'
)
// 要花费的 utxo
const utxo = {
// 锁定脚本
script: Buffer.from(
'51203cb650a3809269365cfea9874982c00b2f61951203a6f3b3686848f1e1b08d46',
'hex'
),
// 金额
amount: 3185699
}
// 从锁定脚本中提取 Taproot 公钥
// 3cb650a3809269365cfea9874982c00b2f61951203a6f3b3686848f1e1b08d46
const taprootPubkey = utxo.script.subarray(2)
// 计算签名哈希
// 422a8fcae1eacd3a5bdaeaac83c15b8ec4ec5236f8adb32d6756b816730dd85e
const sigHash = tx.hashForWitnessV1(
0, // 输入索引
[utxo.script], // 锁定脚本
[utxo.amount], // 金额
Transaction.SIGHASH_DEFAULT
)
// 提取签名
// ad06bd890367e518991fa7337b834032d824586b61d95cf84c0a53ee2c58cb37b53e324c8f85609da8f90da48f35af10121420f112dfc91ea6ed5fccfa0c5f8a
const signature = tx.ins[0].witness[0]
// 验证签名
const isValid = verifySchnorr(sigHash, taprootPubkey, signature)
console.log(`isValid: ${isValid}`) // isValid: true
}
verifyTaprootSignature()
脚本路径
另一种花费方式是通过脚本路径。假设脚本树中有三个锁定脚本
import { script } from 'bitcoinjs-lib'
import { OPS } from 'bitcoinjs-lib/src/ops'
// Alice 可以花费
const ScriptA = script.compile([
Buffer.from(AlicePublicKey, 'hex'),
OPS.OP_CHECKSIG
])
// 30 天后 Bob 可以花费
const locktime = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60
const ScriptB = script.compile([
script.number.encode(locktime),
OPS.OP_CHECKLOCKTIMEVERIFY,
OPS.OP_DROP,
Buffer.from(BobPublicKey, 'hex'),
OPS.OP_CHECKSIG
])
// 2-of-3 多签
const ScriptC = script.compile([
OPS.OP_2,
Buffer.from(AlicePublicKey, 'hex'),
Buffer.from(BobPublicKey, 'hex'),
Buffer.from(CarolPublicKey, 'hex'),
OPS.OP_3,
OPS.OP_CHECKMULTISIG
])

构建 P2TR
地址:
import { Taptree } from 'bitcoinjs-lib/src/types'
import { initEccLib, networks, type Signer } from 'bitcoinjs-lib'
import ECPairFactory from 'ecpair'
import * as ecc from 'tiny-secp256k1'
initEccLib(ecc)
const ECPair = ECPairFactory(ecc)
const network = networks.testnet
// 随机生成内部秘钥对
const keypair = ECPair.makeRandom({ network })
const toXOnly = (pubkey: Buffer) => pubkey.subarray(1, 33)
// 脚本树需要以两个一组的形式构建
const scriptTree: Taptree = [
[
{
output: ScriptA
},
{
output: ScriptB
}
],
{
output: ScriptC
}
]
const { output, address } = payments.p2tr({
internalPubkey: toXOnly(keypair.publicKey),
scriptTree,
network
})
console.log(address) // tb1p...
当向该地址转账生成 P2TR
类型的 UTXO
后, 可选择的花费方式有:
- 拥有内部私钥可直接选择秘钥路径花费
- 没有内部私钥时, 只能选择脚本路径花费方式, 从脚本树中选择任意一个可满足条件的锁定脚本
- 选择
ScriptA
, 需提供默克尔证明B
和C
- 选择
ScriptB
, 需提供默克尔证明A
和C
- 选择
ScriptC
, 需提供默克尔证明AB
- 选择
解锁 P2TR
交易的 Witness
字段格式为:
<解锁脚本> <赎回脚本> <控制块>
- 解锁脚本: 脚本树中锁定脚本对应的解锁脚本, 假如选择
ScriptA
, 只需要签名数据即可; 但对于ScriptC
, 则需要类似P2MS
多签解锁脚本 - 赎回脚本: 选择花费的锁定脚本, 即脚本树中的
ScriptA
、ScriptB
或ScriptC
- 控制块: 内部公钥和默克尔证明的组合
假设现在选择 ScriptA
花费 P2TR
类型的 UTXO
:
// 赎回脚本
const ScriptA_Redeem = {
output: ScriptA,
redeemVersion: 0xc0
}
// 计算控制块: 方式一
// const controlBlock = Buffer.concat(
// [
// Buffer.from([redeemVersion | outputKey.parity]), // 赎回脚本的版本 | 公钥奇偶性
// internalPubkey // 内部公钥
// ].concat(path) // 默克尔证明路径, 对于 ScriptA 来说, path = [B, C]
// )
// 计算控制块: 方式二
const ScriptA_P2TR = payments.p2tr({
internalPubkey: toXOnly(keypair.publicKey), // 内部公钥
scriptTree, // 脚本树
network,
redeem: ScriptA_Redeem // 赎回脚本
})
const controlBlock = ScriptA_P2TR.witness![ScriptA_P2TR.witness!.length - 1]
最后是签名数据, 下列代码完整展示使用 PSBT
签名一笔交易:
const AliceKeyPair = ECPair.fromPrivateKey(Buffer.from(AlicePrivateKey, 'hex'))
const psbt = new Psbt({ network })
psbt.addInput({
hash: '79cc74ac08dd7c27efbc81cf7ac1cce471ecb220617c8784ef00385e7b240e5d', // txid
index: 0,
witnessUtxo: {
value: 3500,
script: Buffer.from(
'512048ee3388cfff76c93aa391ccc47ee78f1b6337392155d5212706f857e6a697bd',
'hex'
)
},
tapLeafScript: [
// 使用的花费脚本
{
leafVersion: 0xc0,
script: ScriptA,
controlBlock: ScriptA_P2TR.witness![ScriptA_P2TR.witness!.length - 1]
}
]
})
psbt.addOutput({
address: '...',
value: 600
})
psbt.signInput(0, AliceKeyPair)
const txHex = psbt.finalizeAllInputs().extractTransaction().toHex()
签名后得到的交易数据如下:
通过 RPC 接口 sendrawtransaction
广播交易。