比特币中的签名数据是通过私钥对交易数据进行 ECDSA
或 Schnorr
签名生成的, 签名数据被放置在交易的 scriptSig
或 witness
字段中。目的是为了在脚本执行时, 通过 OP_CHECKSIG
等操作码的检查, 以确认是否可以花费该 UTXO
。
交易的签名针对的是特定的交易输入, 多个交易输入需要多个签名。
未签名交易
签名前需要确认交易的输入和输出:
-
交易输入
txid
- 要花费的UTXO
来自于哪个交易IDvout
- 要花费的UTXO
来自于交易输出的哪个索引号
-
交易输出
amount
- 输出的聪的数量scriptPubKey
- 锁定脚本。根据接收地址生成的锁定脚本。
未签名交易构造器
签名
比特币中签名指的是计算出交易中 ScriptSig
或 Witness
字段的签名数据, 用于验证交易的合法性。
不同的交易输入的锁定脚本类型具有不同的签名过程, 但总体上都需要经历下面的步骤:
- 确认签名哈希类型
- 构造未签名数据
- 数据签名
- 签名数据编码
- 解锁脚本
签名交易
1. 确认签名哈希类型
签名哈希类型 SigHash
, 用于指定签名的数据。
SIGHASH_DEFAULT
- 值为0x00
, 用于P2TR
默认值, 等同于SIGHASH_ALL
, 签名所有输入和输出。SIGHASH_ALL
- 值为0x01
, 默认值, 签名所有输入和输出, 意味着交易的输入和输出有任何变动, 签名将失效。SIGHASH_NONE
- 值为0x02
, 签名所有输入, 但不签名任何输出, 除当前要签名的输入外, 其他交易输入的sequence
设置为0, 意味着生成的签名可以用于相同输入但有不同输出的交易。SIGHASH_SINGLE
- 值为0x03
, 签名所有输入, 但只签名对应索引的输出。交易输出的数量最多等于要签名的输入的索引号加 1, 例如签名的输入索引是 3, 则输出数量最多为 4。同时在输出中, 除了对应索引的输出外, 其他输出的金额设置为最大值, 锁定脚本为空。最后除当前要签名的输入外, 其他交易输入的sequence
设置为0SIGHASH_ANYONECANPAY
- 值为0x80
, 与其他类型组合使用:SIGHASH_ALL | SIGHASH_ANYONECANPAY
- 值为0x81
, 签名当前输入和所有输出SIGHASH_NONE | SIGHASH_ANYONECANPAY
- 值为0x82
, 签名当前输入, 但不签名输出SIGHASH_SINGLE | SIGHASH_ANYONECANPAY
- 值为0x83
, 签名当前输入, 但只签名对应索引的输出
2. 构造未签名数据
对不同的类型的 UTXO
锁, 有不同的构造过程。
非隔离见证锁
非隔离见证锁有 P2PKH
、P2SH
、P2MS
。
当要签名的交易输入的 UTXO
锁是非隔离见证锁时, 则需要将 scriptSig
字段临时替换为 UTXO
的 scriptPubKey
字段, 其他交易输入的 scriptSig
字段保持为空。
例如有如下交易
要签名的第 0 个交易输入的 UTXO
锁是 P2PKH
类型,需将第 0 个交易输入scriptSig
字段替换为 scriptPubKey
字段, 当签名哈希类型是 SIGHASH_ALL
时, 得到签名数据是:
当 sigHash
不同时, 需要对交易数据进行不同的处理:
/**
*
* @param tx 未签名交易
* @param inIndex 签名的输入索引
* @param utxo inIndex 对应的 UTXO 信息, 包括 script、value、type
* @param sigHashValue 哈希类型
* @returns tx
*/
export const sigMsgForSignature = (
tx: Transaction,
inIndex: number,
utxo: UTXO,
sigHashValue: number
) => {
// 复制 tx
const txTemp = tx.clone()
// sigHash === SIGHASH_NONE
if ((sigHashValue & 0x1f) === Transaction.SIGHASH_NONE) {
// 清空输出
txTemp.outs = []
// 清空非签名输入的 sequence
txTemp.ins.forEach((input, i) => {
if (i === inIndex) return
input.sequence = 0
})
} else if ((sigHashValue & 0x1f) === Transaction.SIGHASH_SINGLE) {
// sigHash === SIGHASH_SINGLE
// 如果输入索引大于输出数量, 抛出异常
if (inIndex >= txTemp.outs.length) {
throw new Error('Input index is out of range')
}
// 移除多余的输出
txTemp.outs.length = inIndex + 1
// 设置非签名输入的索引对应的输出的 amount 为最大值, `scriptPubKey` 为空
for (let i = 0; i < inIndex; i++) {
;(txTemp.outs as any)[i] = BLANK_OUTPUT
;(txTemp.outs as any)[i] = {
script: new Uint8Array(0),
valueBuffer: fromHex('ffffffffffffffff')
}
}
// 清空非签名输入的 sequence
txTemp.ins.forEach((input, i) => {
if (i === inIndex) return
input.sequence = 0
})
}
if (sigHashValue & Transaction.SIGHASH_ANYONECANPAY) {
console.log(`SIGHASH_ANYONECANPAY: ${sigHashValue} \n`)
// 只保留当前要签名的输入
txTemp.ins = [txTemp.ins[inIndex]]
// 设置 `scriptSig` 为 `scriptPubKey`
txTemp.ins[0].script = Buffer.from(utxo.script, 'hex')
} else {
// SIGHASH_ALL
// 所有输入的 `scriptSig` 字段设置为空
txTemp.ins.forEach((input) => {
input.script = Buffer.from('')
})
// 设置当前要签名的输入的 `scriptSig` 为 `scriptPubKey`
txTemp.ins[inIndex].script = Buffer.from(utxo.script, 'hex')
}
return txTemp
}
隔离见证V0
隔离见证V0锁有 P2WPKH
、P2WSH
、P2SH-P2WSH
。
BIP143 定义了隔离见证交易的签名规则, 如果要签名的交易输入的 UTXO
锁是隔离见证V0类型 则首先需要分别计算下列数据:
-
hashPrevouts
-sigHash
不包含SIGHASH_ANYONECANPAY
时存在,hashPrevouts
等于所有交易输入的txid
和vout
拼接后进行Hash256
计算 -
hashSequence
-sigHash
等于SIGHASH_ALL
时存在,hashSequence
等于所有交易输入的sequence
拼接后进行Hash256
计算 -
hashOutputs
sigHash
不包含SIGHASH_SINGLE
和SIGHASH_NONE
时,hashOutputs
等于所有交易输出的amount
、scriptPubKeySize
和scriptPubKey
拼接后进行Hash256
计算sigHash
等于SIGHASH_SINGLE
且要签名的交易输入索引小于输出数量时,hashOutputs
等于要签名的交易输入索引对应的交易输出的amount
、scriptPubKeySize
和scriptPubKey
拼接后进行Hash256
计算
其次依次拼接:
- 交易的版本号
version
hashPrevouts
hashSequence
- 要签名的交易输入的
txid
- 要签名的交易输入的
vout
scriptCode
的字节大小scriptCode
: 如果是P2WPKH
锁, 则提取出公钥哈希转换为P2PKH
锁定脚本作为scriptCode
; 如果是P2WSH
锁, 则提取出redeemScript
作为scriptCode
- 花费的
UTXO
的金额 - 要签名的交易输入的
sequence
hashOutputs
- 交易的
lockTime
具体见代码 signature.ts
隔离见证V1
隔离见证V1锁有 P2TR
。
BIP341 定义了 Taproot
签名规则, 如果要签名的交易输入的 UTXO
锁是 P2TR
, 则首先需要分别计算下列数据:
hashPrevouts
-sigHash
不包含SIGHASH_ANYONECANPAY
时存在,hashPrevouts
等于所有交易输入的txid
和vout
拼接后进行sha256
计算hashAmounts
-sigHash
不包含SIGHASH_ANYONECANPAY
时存在,hashAmounts
等于所有交易输入的Amount
拼接后进行sha256
计算hashScriptPubKeys
-sigHash
不包含SIGHASH_ANYONECANPAY
时存在,hashScriptPubKeys
等于所有交易输入的scriptPubKeySize
和scriptPubKey
拼接后进行sha256
计算hashSequences
-sigHash
不包含SIGHASH_ANYONECANPAY
时存在,hashSequences
等于所有交易输入的sequence
拼接后进行sha256
计算hashOutputs
sigHash
不包含SIGHASH_SINGLE
和SIGHASH_NONE
时,hashOutputs
等于所有交易输出的amount
、scriptPubKeySize
和scriptPubKey
拼接后进行sha256
计算sigHash
等于SIGHASH_SINGLE
且要签名的交易输入索引小于输出数量时,hashOutputs
等于要签名的交易输入索引对应的交易输出的amount
、scriptPubKeySize
和scriptPubKey
拼接后进行Hash256
计算
spendType
- 花费方式- 0 - 秘钥路径花费, 且没有附加数据
annex
- 1 - 秘钥路径花费, 且有附加数据
annex
- 2 - 脚本路径花费, 且没有附加数据
annex
- 3 - 脚本路径花费, 且有附加数据
annex
- 0 - 秘钥路径花费, 且没有附加数据
其次依次拼接:
- 交易的版本号
version
- 交易的
lockTime
hashPrevouts
hashAmounts
hashScriptPubKeys
hashSequences
hashOutputs
spendType
- 当
sigHash
包含SIGHASH_ANYONECANPAY
时, 继续拼接- 签名的交易输入的
txid
- 签名的交易输入的
vout
- 签名的交易输入的
Amount
- 签名的交易输入的
scriptPubKeySize
- 签名的交易输入的
scriptPubKey
- 签名的交易输入的
sequence
- 签名的交易输入的
- 当
sigHash
包含SIGHASH_ANYONECANPAY
时只拼接签名的交易输入的索引
具体见代码 signature.ts
3. 数据签名
P2TR
使用 Schnorr
签名, 其他类型的锁定脚本使用 ECDSA
签名。
ECDSA 签名
对于非 P2TR
类型的锁定脚本使用 ECDSA
签名。签名前需要在上一步构造的未签名数据末尾添加4字节小端序签名哈希类型, 并进行 Hash256
运算。将运算结果作为 ECDSA
签名的消息。
import ecc from '@bitcoinerlab/secp256k1'
import ECPairFactory from 'ecpair'
import { hash256 } from 'bitcoinjs-lib/src/crypto'
const ECPair = ECPairFactory(ecc)
const keypair = ECPair.fromPrivateKey(Buffer.from(privateKey, 'hex'))
const hashedTransaction = hash256(Buffer.from(waitingForSignTx, 'hex'))
// ecdsa 签名结果
const ecdsaSignature = keypair.sign(hashedTransaction)
ECDSA
签名结果是一个 64
字节的数据, 前 32
字节是 r
, 后 32
字节是 s
。
验证时通过公钥和签名数据计算得到的 r
是否与签名数据中的 r
相同, 若相同则签名有效。
因此实际的交易中, 凡是用到 ECDSA
签名的脚本, 都需要提供公钥。
例如:
P2PK
将公钥存储在锁定脚本中, 解锁脚本只需要包含签名数据P2PKH
和P2WPKH
将公钥和签名数据都放在解锁脚本中P2MS
将多签用到的公钥都放在了锁定脚本中
Schnorr 签名
P2TR
使用 Schnorr
签名。签名前需要在上一步构造的未签名数据前添加 1 字节签名哈希类型。并计算 tapKeyHash
作为 Schnorr
签名的消息。
Schnorr
签名的私钥不是原始私钥, 而是 Taproot
私钥。
Taproot 私钥
Taproot
私钥, 也称 Tweaked Private Key
。等于原始私钥加上 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)
}
const tweakedPrivateKey = ecc.privateAdd(
privateKey,
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])
)
}
最后使用 tweakSigner
对 tapKeyHash
签名得到签名数据
import { taggedHash } from 'bitcoinjs-lib/src/crypto'
const keypair = ECPair.fromPrivateKey(Buffer.from(privateKey, 'hex'))
const tapKeyHash = taggedHash(
'TapSighash',
Buffer.concat([Buffer.from([0x00]), Buffer.from(witnessV1Msg.sigMsg, 'hex')])
)
const tweakedSigner = tweakSigner(keypair, NETWORKS[network].network)
const signature =
tweakedSigner.signSchnorr && tweakedSigner.signSchnorr(tapKeyHash)
4. 签名数据编码
ECDSA 签名
ECDSA
签名数据需要进行 DER
编码, 生成最终的签名数据。
假设 ECDSA
签名数据是:
039d8fcf8bd43c2eba1ca16ad2512e33892994745220464164dd7550886a4f961d056caebb71ab54aedf29143b6416a5e482d3d840c0dab48abbd34efef53032
r=039d8fcf8bd43c2eba1ca16ad2512e33892994745220464164dd7550886a4f96
s=1d056caebb71ab54aedf29143b6416a5e482d3d840c0dab48abbd34efef53032
- 在
r
和s
前添加类型字节0x02
(表示整数) 和长度字节0x20
r:
02 20 039d8fcf8bd43c2eba1ca16ad2512e33892994745220464164dd7550886a4f96
s:
02 20 1d056caebb71ab54aedf29143b6416a5e482d3d840c0dab48abbd34efef53032
- 组合
r
和s
后并在前添加整体长度字节0x44
和 标识符字节0x30
30440220039d8fcf8bd43c2eba1ca16ad2512e33892994745220464164dd7550886a4f9602201d056caebb71ab54aedf29143b6416a5e482d3d840c0dab48abbd34efef53032
- 末尾添加签名使用的哈希类型
30440220039d8fcf8bd43c2eba1ca16ad2512e33892994745220464164dd7550886a4f9602201d056caebb71ab54aedf29143b6416a5e482d3d840c0dab48abbd34efef5303201
最终的签名数据大小为 71 字节
Schnorr 签名
Schnorr
签名数据不需要进行 DER
编码, 当 SigHash
不是 SIGHASH_DEFAULT
时, 需要在签名数据后添加 1 字节的SigHash
。
所以 Schnorr
签名数据是大小通常是 64 或 65 字节
5. 解锁脚本
根据要花费的 UTXO
的锁定脚本类型, 将签名数据以及其他数据联合生成解锁脚本。
例如:
P2PKH
要花费的 UTXO
是 P2PKH
类型, 则解锁脚本格式为:
OP_PUSHBYTES_71 <签名数据> OP_PUSHBYTES_33 <公钥>
并放入对应交易输入的 scriptSig
字段。
P2WPKH
要花费的 UTXO
是 P2WPKH
类型, 则将签名数据和公钥放入对应交易输入索引的 witness
字段。
P2TR
要花费的 UTXO
是 P2TR
类型且是秘钥路径, 则将签名数据直接放入对应交易输入索引 witness
字段。
如果是脚本路径花费, 则除了签名数据外, 还需要使用的脚本和控制块一并放入 witness
字段。