高級
Taproot
BIP-340/341/342:Schnorr 簽名、MAST 和 Tapscript 的綜合升級
20 分鐘
概述
Taproot 是 Bitcoin 在 2021 年 11 月啟用的重大升級,包含三個相關的 BIP: BIP-340(Schnorr 簽名)、BIP-341(Taproot)和 BIP-342(Tapscript)。 這次升級提升了隱私性、效率和智能合約能力。
啟用區塊: Taproot 在區塊高度 709,632(2021 年 11 月 14 日)啟用, 使用 Speedy Trial 機制達成共識。
三大組件
BIP-340: Schnorr 簽名
Schnorr 簽名特性:
- 線性數學特性(可聚合)
- 64 bytes(vs ECDSA 70-72 bytes)
- 批量驗證更快
- 更簡潔的安全證明
簽名結構:
┌─────────────────────────────────────┐
│ R (32 bytes) │ s (32 bytes) │
└─────────────────────────────────────┘
簽名算法:
1. 選擇隨機數 k
2. R = k * G
3. e = hash(R || P || m)
4. s = k + e * d
5. 簽名 = (R, s) BIP-341: Taproot
Taproot 輸出結構:
┌──────────────────┐
│ Taproot 輸出 │
│ P = Q + hash * G │
└────────┬─────────┘
│
┌──────────────┴──────────────┐
│ │
┌──────┴──────┐ ┌───────┴───────┐
│ Key Path │ │ Script Path │
│ 用內部密鑰 │ │ MAST 腳本樹 │
│ 直接花費 │ │ │
└─────────────┘ └───────────────┘
P = Q + hash(Q || merkle_root) * G
其中:
- Q: 內部公鑰(可以是多簽聚合密鑰)
- merkle_root: 腳本樹的 Merkle 根
- P: Taproot 輸出公鑰 BIP-342: Tapscript
Tapscript 改進:
- 移除 ECDSA 簽名檢查操作碼
- 新增 OP_CHECKSIGADD
- 簽名驗證使用 Schnorr
- 移除 201 操作碼限制
- 改進的 OP_SUCCESS 機制
新操作碼:
- OP_CHECKSIGADD: 多簽計數器
- OP_SUCCESS: 未來升級預留 Key Path 花費
最常見的花費方式,看起來與普通單簽交易相同:
Key Path 優勢:
1. 最小的鏈上足跡(只有簽名)
2. 所有 Taproot 輸出看起來相同
3. 多簽可以聚合為單一簽名
4. 最低手續費
Witness 結構:
┌─────────────────────────────────────┐
│ 64-byte Schnorr 簽名 │
└─────────────────────────────────────┘
如果使用 SIGHASH 附加:
┌─────────────────────────────────────┐
│ 65-byte Schnorr 簽名 + SIGHASH flag │
└─────────────────────────────────────┘ TypeScript 實作
import * as secp256k1 from '@noble/secp256k1';
import { sha256 } from '@noble/hashes/sha256';
interface TaprootKeyPair {
privateKey: Uint8Array;
publicKey: Uint8Array;
tweakedPrivateKey: Uint8Array;
tweakedPublicKey: Uint8Array;
}
// 計算 Taproot tweaked key
function taprootTweak(
internalPubkey: Uint8Array,
merkleRoot?: Uint8Array
): Uint8Array {
// tagged hash: "TapTweak"
const tag = sha256('TapTweak');
const tagHash = new Uint8Array([...tag, ...tag]);
const data = merkleRoot
? new Uint8Array([...internalPubkey, ...merkleRoot])
: internalPubkey;
return sha256(new Uint8Array([...tagHash, ...data]));
}
function createTaprootKeyPair(
privateKey: Uint8Array,
merkleRoot?: Uint8Array
): TaprootKeyPair {
// 獲取 x-only 公鑰(32 bytes)
const fullPubkey = secp256k1.getPublicKey(privateKey, true);
const publicKey = fullPubkey.slice(1); // 移除前綴
// 計算 tweak
const tweak = taprootTweak(publicKey, merkleRoot);
// tweaked private key
const tweakedPrivateKey = secp256k1.utils.mod(
BigInt('0x' + Buffer.from(privateKey).toString('hex')) +
BigInt('0x' + Buffer.from(tweak).toString('hex')),
secp256k1.CURVE.n
);
const tweakedPrivKeyBytes = new Uint8Array(
Buffer.from(tweakedPrivateKey.toString(16).padStart(64, '0'), 'hex')
);
// tweaked public key
const tweakedPubkey = secp256k1.getPublicKey(tweakedPrivKeyBytes, true);
return {
privateKey,
publicKey,
tweakedPrivateKey: tweakedPrivKeyBytes,
tweakedPublicKey: tweakedPubkey.slice(1),
};
}
// 創建 Key Path 簽名
async function signKeyPath(
tweakedPrivateKey: Uint8Array,
message: Uint8Array
): Promise<Uint8Array> {
return secp256k1.schnorr.sign(message, tweakedPrivateKey);
}
// 驗證 Key Path 簽名
async function verifyKeyPath(
signature: Uint8Array,
message: Uint8Array,
tweakedPublicKey: Uint8Array
): Promise<boolean> {
return secp256k1.schnorr.verify(signature, message, tweakedPublicKey);
} Script Path 花費
當需要使用預設腳本條件時:
Script Path 結構:
MAST (Merkelized Alternative Script Tree):
┌───────┐
│ Root │
└───┬───┘
│
┌─────────┴─────────┐
│ │
┌───┴───┐ ┌───┴───┐
│ AB │ │ CD │
└───┬───┘ └───┬───┘
│ │
┌─────┴─────┐ ┌─────┴─────┐
│ │ │ │
Script A Script B Script C Script D
花費時只需揭露:
- 實際使用的腳本
- Merkle 路徑(兄弟節點)
- 內部公鑰
- Control block Control Block 結構
Control Block:
┌──────────────┬─────────────────┬─────────────────┐
│ Leaf Version │ Internal Pubkey │ Merkle Path │
│ (1 byte) │ (32 bytes) │ (32 * n bytes) │
└──────────────┴─────────────────┴─────────────────┘
Leaf Version:
- 0xc0: 當前 Tapscript 版本
- 最低位表示公鑰奇偶性
Witness Stack (Script Path):
┌─────────────────────────────────────┐
│ Script inputs (variable) │
├─────────────────────────────────────┤
│ Script (variable) │
├─────────────────────────────────────┤
│ Control block (33 + 32n bytes) │
└─────────────────────────────────────┘ TypeScript 實作
interface TapLeaf {
version: number;
script: Uint8Array;
}
interface TapTree {
left: TapTree | TapLeaf;
right: TapTree | TapLeaf;
}
// Tagged hash for Taproot
function taggedHash(tag: string, data: Uint8Array): Uint8Array {
const tagHash = sha256(new TextEncoder().encode(tag));
return sha256(new Uint8Array([...tagHash, ...tagHash, ...data]));
}
// 計算 TapLeaf hash
function tapLeafHash(leaf: TapLeaf): Uint8Array {
const leafData = new Uint8Array([
leaf.version,
leaf.script.length,
...leaf.script,
]);
return taggedHash('TapLeaf', leafData);
}
// 計算 TapBranch hash
function tapBranchHash(left: Uint8Array, right: Uint8Array): Uint8Array {
// 字典序排序
const [first, second] = Buffer.compare(
Buffer.from(left),
Buffer.from(right)
) < 0
? [left, right]
: [right, left];
return taggedHash('TapBranch', new Uint8Array([...first, ...second]));
}
// 構建 MAST
function buildTapTree(tree: TapTree | TapLeaf): Uint8Array {
if ('script' in tree) {
return tapLeafHash(tree);
}
const leftHash = buildTapTree(tree.left);
const rightHash = buildTapTree(tree.right);
return tapBranchHash(leftHash, rightHash);
}
// 生成 Merkle 證明路徑
function getMerkleProof(
tree: TapTree | TapLeaf,
targetLeaf: TapLeaf
): Uint8Array[] {
const proof: Uint8Array[] = [];
function search(
node: TapTree | TapLeaf,
target: Uint8Array
): boolean {
if ('script' in node) {
const nodeHash = tapLeafHash(node);
return Buffer.compare(
Buffer.from(nodeHash),
Buffer.from(target)
) === 0;
}
const leftHash = buildTapTree(node.left);
const rightHash = buildTapTree(node.right);
if (search(node.left, target)) {
proof.push(rightHash);
return true;
}
if (search(node.right, target)) {
proof.push(leftHash);
return true;
}
return false;
}
search(tree, tapLeafHash(targetLeaf));
return proof;
}
// 構建 Control Block
function buildControlBlock(
leafVersion: number,
internalPubkey: Uint8Array,
merkleProof: Uint8Array[],
outputPubkeyParity: boolean
): Uint8Array {
const versionByte = leafVersion | (outputPubkeyParity ? 0x01 : 0x00);
const proofBytes = merkleProof.reduce(
(acc, hash) => new Uint8Array([...acc, ...hash]),
new Uint8Array()
);
return new Uint8Array([
versionByte,
...internalPubkey,
...proofBytes,
]);
} MuSig2 多簽聚合
Schnorr 簽名支持密鑰聚合,使多簽看起來像單簽:
MuSig2 協議流程:
1. 密鑰聚合
P_agg = a1*P1 + a2*P2 + ... + an*Pn
其中 ai = hash(L || Pi),L = hash(P1 || P2 || ... || Pn)
2. 第一輪:Nonce 交換
每個簽名者:
- 生成 (r1, R1) 和 (r2, R2)
- 廣播 (R1, R2)
3. 聚合 Nonce
R_agg = R1_agg + b*R2_agg
其中 b = hash(all R values || P_agg || message)
4. 第二輪:部分簽名
每個簽名者:
s_i = r1_i + b*r2_i + e*a_i*d_i
5. 聚合簽名
s = s1 + s2 + ... + sn
最終簽名 = (R_agg, s)
優勢:
- 鏈上只有 64 字節簽名
- 驗證者無法區分單簽和多簽
- 減少交易大小和手續費 MuSig2 實作示例
import * as secp256k1 from '@noble/secp256k1';
interface MuSig2Context {
publicKeys: Uint8Array[];
aggregatedPubkey: Uint8Array;
keyAggCoefficients: Uint8Array[];
}
interface MuSig2Round1 {
secretNonces: [Uint8Array, Uint8Array];
publicNonces: [Uint8Array, Uint8Array];
}
interface MuSig2Session {
context: MuSig2Context;
round1: MuSig2Round1;
aggregatedNonce?: Uint8Array;
challenge?: Uint8Array;
}
// 計算密鑰聚合係數
function computeKeyAggCoeff(
publicKeys: Uint8Array[],
targetPubkey: Uint8Array
): Uint8Array {
const L = sha256(
publicKeys.reduce(
(acc, pk) => new Uint8Array([...acc, ...pk]),
new Uint8Array()
)
);
return sha256(new Uint8Array([...L, ...targetPubkey]));
}
// 聚合公鑰
function aggregatePublicKeys(publicKeys: Uint8Array[]): MuSig2Context {
const coefficients = publicKeys.map(pk =>
computeKeyAggCoeff(publicKeys, pk)
);
let aggregatedPoint = secp256k1.Point.ZERO;
for (let i = 0; i < publicKeys.length; i++) {
const point = secp256k1.Point.fromHex(
Buffer.from(publicKeys[i]).toString('hex')
);
const coeff = BigInt('0x' + Buffer.from(coefficients[i]).toString('hex'));
aggregatedPoint = aggregatedPoint.add(point.multiply(coeff));
}
return {
publicKeys,
aggregatedPubkey: aggregatedPoint.toRawBytes(true).slice(1),
keyAggCoefficients: coefficients,
};
}
// Round 1: 生成 nonces
function musig2Round1(): MuSig2Round1 {
const r1 = secp256k1.utils.randomPrivateKey();
const r2 = secp256k1.utils.randomPrivateKey();
const R1 = secp256k1.getPublicKey(r1, true).slice(1);
const R2 = secp256k1.getPublicKey(r2, true).slice(1);
return {
secretNonces: [r1, r2],
publicNonces: [R1, R2],
};
}
// 聚合 nonces
function aggregateNonces(
allNonces: [Uint8Array, Uint8Array][],
aggregatedPubkey: Uint8Array,
message: Uint8Array
): Uint8Array {
// 計算 R1_agg 和 R2_agg
let R1_agg = secp256k1.Point.ZERO;
let R2_agg = secp256k1.Point.ZERO;
for (const [R1, R2] of allNonces) {
R1_agg = R1_agg.add(
secp256k1.Point.fromHex(Buffer.from(R1).toString('hex'))
);
R2_agg = R2_agg.add(
secp256k1.Point.fromHex(Buffer.from(R2).toString('hex'))
);
}
// 計算 b = hash(all nonces || P_agg || message)
const nonceData = allNonces.reduce(
(acc, [R1, R2]) => new Uint8Array([...acc, ...R1, ...R2]),
new Uint8Array()
);
const b = sha256(
new Uint8Array([...nonceData, ...aggregatedPubkey, ...message])
);
const bScalar = BigInt('0x' + Buffer.from(b).toString('hex'));
// R_agg = R1_agg + b * R2_agg
const R_agg = R1_agg.add(R2_agg.multiply(bScalar));
return R_agg.toRawBytes(true).slice(1);
}
// Round 2: 生成部分簽名
function musig2PartialSign(
session: MuSig2Session,
privateKey: Uint8Array,
message: Uint8Array,
allNonces: [Uint8Array, Uint8Array][]
): Uint8Array {
const { context, round1 } = session;
// 計算聚合 nonce
const R_agg = aggregateNonces(
allNonces,
context.aggregatedPubkey,
message
);
// 計算挑戰 e = hash(R_agg || P_agg || message)
const e = taggedHash('BIP0340/challenge',
new Uint8Array([...R_agg, ...context.aggregatedPubkey, ...message])
);
// 計算 b
const nonceData = allNonces.reduce(
(acc, [R1, R2]) => new Uint8Array([...acc, ...R1, ...R2]),
new Uint8Array()
);
const b = sha256(
new Uint8Array([...nonceData, ...context.aggregatedPubkey, ...message])
);
// 找到自己的係數
const pubkey = secp256k1.getPublicKey(privateKey, true).slice(1);
const myIndex = context.publicKeys.findIndex(
pk => Buffer.compare(Buffer.from(pk), Buffer.from(pubkey)) === 0
);
const a = context.keyAggCoefficients[myIndex];
// s_i = r1 + b*r2 + e*a*d
const r1 = BigInt('0x' + Buffer.from(round1.secretNonces[0]).toString('hex'));
const r2 = BigInt('0x' + Buffer.from(round1.secretNonces[1]).toString('hex'));
const bVal = BigInt('0x' + Buffer.from(b).toString('hex'));
const eVal = BigInt('0x' + Buffer.from(e).toString('hex'));
const aVal = BigInt('0x' + Buffer.from(a).toString('hex'));
const d = BigInt('0x' + Buffer.from(privateKey).toString('hex'));
const s_i = secp256k1.utils.mod(
r1 + bVal * r2 + eVal * aVal * d,
secp256k1.CURVE.n
);
return new Uint8Array(
Buffer.from(s_i.toString(16).padStart(64, '0'), 'hex')
);
} Tapscript 腳本
新操作碼
OP_CHECKSIGADD (0xba):
用於多簽,替代 OP_CHECKMULTISIG
傳統多簽 (2-of-3):
OP_2 <pubkey1> <pubkey2> <pubkey3> OP_3 OP_CHECKMULTISIG
Tapscript 多簽 (2-of-3):
<pubkey1> OP_CHECKSIG
<pubkey2> OP_CHECKSIGADD
<pubkey3> OP_CHECKSIGADD
OP_2 OP_NUMEQUAL
執行流程:
1. OP_CHECKSIG: 驗證第一個簽名,推入 0 或 1
2. OP_CHECKSIGADD: 驗證並累加計數器
3. OP_NUMEQUAL: 檢查是否達到閾值
優勢:
- 批量驗證更高效
- 無需 dummy 元素
- 簽名順序不重要(配合 SIGHASH) 常見 Tapscript 模式
// 時間鎖腳本
function createTimelockScript(
pubkey: Uint8Array,
locktime: number
): Uint8Array {
return new Uint8Array([
// <locktime> OP_CHECKLOCKTIMEVERIFY OP_DROP <pubkey> OP_CHECKSIG
...encodeNumber(locktime),
0xb1, // OP_CHECKLOCKTIMEVERIFY
0x75, // OP_DROP
0x20, // push 32 bytes
...pubkey,
0xac, // OP_CHECKSIG
]);
}
// 雜湊鎖腳本 (HTLC)
function createHashlockScript(
pubkey: Uint8Array,
hash: Uint8Array
): Uint8Array {
return new Uint8Array([
// OP_SHA256 <hash> OP_EQUALVERIFY <pubkey> OP_CHECKSIG
0xa8, // OP_SHA256
0x20, // push 32 bytes
...hash,
0x88, // OP_EQUALVERIFY
0x20, // push 32 bytes
...pubkey,
0xac, // OP_CHECKSIG
]);
}
// 多簽腳本 (使用 OP_CHECKSIGADD)
function createMultisigScript(
pubkeys: Uint8Array[],
threshold: number
): Uint8Array {
const script: number[] = [];
// 第一個公鑰使用 OP_CHECKSIG
script.push(0x20);
script.push(...pubkeys[0]);
script.push(0xac); // OP_CHECKSIG
// 後續公鑰使用 OP_CHECKSIGADD
for (let i = 1; i < pubkeys.length; i++) {
script.push(0x20);
script.push(...pubkeys[i]);
script.push(0xba); // OP_CHECKSIGADD
}
// 檢查閾值
script.push(...encodeNumber(threshold));
script.push(0x9c); // OP_NUMEQUAL
return new Uint8Array(script);
}
// 輔助函數:編碼數字
function encodeNumber(n: number): number[] {
if (n === 0) return [0x00];
if (n >= 1 && n <= 16) return [0x50 + n]; // OP_1 到 OP_16
// 更大的數字需要 push
const bytes: number[] = [];
let value = n;
while (value > 0) {
bytes.push(value & 0xff);
value >>= 8;
}
if (bytes[bytes.length - 1] & 0x80) {
bytes.push(0x00);
}
return [bytes.length, ...bytes];
} Taproot 地址
地址格式: Bech32m (BIP-350)
前綴: bc1p (mainnet) / tb1p (testnet)
結構:
┌──────┬───────────────────────────────────────┐
│ bc1p │ 32-byte x-only pubkey (Bech32m 編碼) │
└──────┴───────────────────────────────────────┘
範例:
bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3297
特點:
- 使用 Bech32m 而非 Bech32
- 只包含 x 座標(y 座標隱含為偶數)
- witness version = 1 地址生成
import { bech32m } from 'bech32';
function createTaprootAddress(
outputPubkey: Uint8Array,
network: 'mainnet' | 'testnet' = 'mainnet'
): string {
const prefix = network === 'mainnet' ? 'bc' : 'tb';
const version = 1; // witness version for Taproot
// 將公鑰轉換為 5-bit groups
const words = bech32m.toWords(outputPubkey);
// 加入 version
words.unshift(version);
return bech32m.encode(prefix, words);
}
function decodeTaprootAddress(address: string): {
version: number;
pubkey: Uint8Array;
} {
const { prefix, words } = bech32m.decode(address);
if (prefix !== 'bc' && prefix !== 'tb') {
throw new Error('Invalid prefix');
}
const version = words[0];
if (version !== 1) {
throw new Error('Invalid witness version for Taproot');
}
const pubkey = new Uint8Array(bech32m.fromWords(words.slice(1)));
return { version, pubkey };
} 隱私優勢
Taproot 隱私改進:
1. 輸出同質性
- 所有 Taproot 輸出看起來相同
- 無法區分單簽、多簽、複雜合約
2. Key Path 隱私
- 只揭露最終簽名
- 隱藏備用腳本路徑的存在
3. Script Path 最小揭露
- 只揭露使用的腳本
- 其他腳本保持隱藏
對比:
傳統 P2SH 多簽:
├── 輸出時: 看起來像 P2SH
├── 花費時: 揭露完整 redeemScript
└── 暴露: 所有參與者公鑰、閾值
Taproot Key Path:
├── 輸出時: 看起來像任何 Taproot
├── 花費時: 只有單一簽名
└── 暴露: 無(聚合密鑰看起來像單簽)
Taproot Script Path:
├── 輸出時: 看起來像任何 Taproot
├── 花費時: 揭露使用的腳本 + Merkle 路徑
└── 暴露: 部分樹結構(不是全部) 應用場景
Lightning Network 通道
Taproot 優化 LN 通道:
Key Path: 協作關閉
├── 雙方 MuSig2 聚合簽名
└── 看起來像普通單簽交易
Script Path: 非協作關閉
├── 時間鎖承諾交易
├── HTLC 腳本
└── 懲罰機制
優勢:
- 協作關閉完全隱私
- 減少鏈上足跡
- 降低手續費 複雜智能合約
// 遺產規劃合約示例
interface InheritanceContract {
owner: Uint8Array; // 主要所有者
heir: Uint8Array; // 繼承人
lawyer: Uint8Array; // 律師(見證人)
timelock: number; // 時間鎖(例如 2 年)
}
function createInheritanceTree(
contract: InheritanceContract
): TapTree {
// Key Path: 所有者隨時可以花費(正常使用)
// 使用 owner 作為內部密鑰
// Script Path 1: 繼承人 + 律師 (任何時候)
const multisigScript: TapLeaf = {
version: 0xc0,
script: createMultisigScript(
[contract.heir, contract.lawyer],
2
),
};
// Script Path 2: 繼承人獨自(時間鎖後)
const timelockScript: TapLeaf = {
version: 0xc0,
script: createTimelockScript(contract.heir, contract.timelock),
};
// Script Path 3: 所有者 + 律師(緊急恢復)
const recoveryScript: TapLeaf = {
version: 0xc0,
script: createMultisigScript(
[contract.owner, contract.lawyer],
2
),
};
// 構建樹(最常用的腳本靠近根部)
return {
left: multisigScript,
right: {
left: timelockScript,
right: recoveryScript,
},
};
} 最佳實踐
- 優先使用 Key Path:設計合約時確保最常用路徑可通過 Key Path 花費
- MAST 樹平衡:將最可能使用的腳本放在樹的較淺位置
- MuSig2 多簽:多方合作時使用密鑰聚合而非腳本多簽
- Nonce 安全:MuSig2 的 nonce 必須正確生成,重複使用會導致私鑰洩露
- 使用已審計的庫:Schnorr 和 MuSig2 實作需要謹慎
相關資源
已複製連結