深入技术
P2TR

P2TR

P2TR: Pay To Taproot, 意为支付到 Taproot。由于是隔离见证的第 1 个版本, 所以也叫 V1_P2TR

锁定脚本

P2TR 锁定脚本的操作码模板:

OP_1 <Taproot 输出公钥>

以下是一个 P2TR 锁定脚本的示例:

OP_13cb650a3809269365cfea9874982c00b2f61951203a6f3b3686848f1e1b08d46

OP_1 是隔离见证的版本字节, 表示该锁定脚本是 P2TR。紧接着是 32 字节的Taproot 输出公钥的 XX 坐标。

Taproot 输出公钥 是一个 32 字节的公钥, 由内部公钥和脚本树的默克尔根计算得到。

  • 内部公钥是用户私钥经过 secp256k1 曲线生成, 实际上就是用户公钥。但在计算 Taproot 输出公钥时, 只会使用公钥的 XX 坐标。

  • Taproot 允许多个锁定脚本, 任意一个锁定脚本的解锁即可花费 UTXO。 在有多种锁定脚本的情况下, 将多个锁定脚本通过 MAST 构造成一个脚本树, 脚本树的默克尔根参与计算 Taproot 输出公钥。在花费时, 可以选择其中一个锁定脚本并提供解锁脚本, 同时还需要提供默克尔证明。假设使用上图中 Script_A 来花费 UTXO , 除了解锁脚本外, 还需要其默克尔证明 BC

多种锁定脚本可以是完全自定义的锁定脚本, 也可以是标准锁定脚本, 例如:

// 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=TaggedHash("TapTweak",P+Root)\mathtt {t = TaggedHash("TapTweak", P + Root)}

内部公钥加上 t 乘以椭圆曲线 secp256k1 的基点 G 即可得到 Taproot 输出公钥 Q

Q=P+t×G\mathtt {Q = P + t \times G}

Q 是椭圆曲线的一个点, 但在实际中, 只会用到其 x 坐标, 因此 Q 实际指的是点 Qx 坐标。

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 地址
私钥
压缩公钥
Step 1: X坐标
Step 2: 计算 Taproot 公钥
Step 3: 字节分组
Step 4: 添加版本前缀
Step 5: Bech32m 编码

解锁脚本

要解锁 P2TR, 有两种方式:

  • 秘钥路径 - Key Path
  • 脚本路径 - Script Path

秘钥路径

只有持有内部公钥对应的私钥才能使用秘钥路径解锁 P2TR, 解锁脚本只有一个签名数据。

以下是一个秘钥路径解锁 P2TR 的交易示例:

02000000000101c1d468b687c165337bcf3ca07194b142f09d4f74e244044a6a8fee9d5539f0ab0200000000ffffffff025798000000000000160014f90371a81f05e7f13091c523ac4ab2f438f9e30272003000000000002251203cb650a3809269365cfea9874982c00b2f61951203a6f3b3686848f1e1b08d460140ad06bd890367e518991fa7337b834032d824586b61d95cf84c0a53ee2c58cb37b53e324c8f85609da8f90da48f35af10121420f112dfc91ea6ed5fccfa0c5f8a00000000

该交易只有一个输入, 对应的 Witness 字段为

[
  {
    "stackItems": "01",
    "0": {
      "size": "40",
      "item": "ad06bd890367e518991fa7337b834032d824586b61d95cf84c0a53ee2c58cb37b53e324c8f85609da8f90da48f35af10121420f112dfc91ea6ed5fccfa0c5f8a" // 签名
    }
  }
]

见证数据中包含的元素只有一个, 即 Schnorr 签名数据。

需要注意的是, 秘钥路径花费时的签名私钥并非是内部私钥, 而是 Taproot 私钥。Taproot 公钥可以由 Taproot 私钥计算得到。

已知 Taproot 公钥 Q=P+t×G\mathtt {Q = P + t \times G}, 内部私钥为 KK, 替换 PPK×GK \times G: Q=K×G+t×G\mathtt {Q = K \times G + t \times G}, 可以得到 Q=(K+t)×G\mathtt {Q = (K + t) \times G}, 令 Taproot 私钥为 K_t, 则 Kt×G=(K+t)×G\mathtt {K_t \times G = (K + t) \times G}

得出 Taproot 私钥为 Kt=K+t\mathtt {K_t = K + t}, 即内部私钥加上 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, 需提供默克尔证明 BC
    • 选择 ScriptB, 需提供默克尔证明 AC
    • 选择 ScriptC, 需提供默克尔证明 AB

解锁 P2TR 交易的 Witness 字段格式为:

<解锁脚本> <赎回脚本> <控制块>

  • 解锁脚本: 脚本树中锁定脚本对应的解锁脚本, 假如选择 ScriptA, 只需要签名数据即可; 但对于 ScriptC, 则需要类似 P2MS 多签解锁脚本
  • 赎回脚本: 选择花费的锁定脚本, 即脚本树中的 ScriptAScriptBScriptC
  • 控制块: 内部公钥和默克尔证明的组合

假设现在选择 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()

签名后得到的交易数据如下:

020000000001015d0e247b5e3800ef84877c6120b2ec71e4ccc17acf81bcef277cdd08ac74cc790000000000ffffffff015802000000000000225120b494169f485231b6f428d3ce782cdaccc6b6bfd5d1a45802b62848d1b74c04f10340495c5af07e89f22e1d5e1a3b126e8117afdd1106d8429bc3668d713368868086dfa92e1908682302c8beb42c3c450f38db7b9ff2a5b4d733ea2a287b6eaba855232102386d02d92ce3ccae0beabe10ceaa8d6b2f70f33952c914bde17928c0c905a367ac61c1f165189258973308cd1847171e69581d9c9518392e5ec49e3742c61c8d2bc1af61904494d5c5244c05c7a57a7913e75aa640f91bdd970107a7899084f2f249746defbb4fd5b2c4da6aa54e326c65d0a5b5eabc79a4b7a90bd4ef8f5bd54b433800000000

通过 RPC 接口 sendrawtransaction 广播交易。

Copyright © 2025 HeapUp