深入研究比特币:(4)MAST、Taproot、Graftroot
比特币中最常见的脚本类型是 P2PKH 及其隔离见证等价物 PK2WPKH。这些脚本将输出锁定到特定的公钥哈希(PKH),这是公钥的哈希值。
P2PKH vs P2WHPK
P2PKH
Pay to PubKey Hash 构造如下:
OP_DUP OP_HASH160 <pkh> OP_EQUALVERIFY OP_CHECKSIG
要花费此输出,输入必须提供:
-
与
<pkh>
对应的公钥 -
使用该公钥对交易的有效签名
该脚本的工作原理如下:
-
OP_DUP
复制提供的公钥 -
OP_HASH160
使用 RIPEMD-160 算法对公钥进行哈希 -
<pkh>
是预期的公钥哈希 -
OP_EQUALVERIFY
检查哈希后的公钥是否与预期的<pkh>
匹配,如果不匹配则失败 -
OP_CHECKSIG
检查提供的签名是否对应于公钥和支出交易的有效签名
P2WPKH
OP_0 <pkh>
在隔离见证中,P2PKH 脚本被转换为更紧凑的形式:
-
OP_0
代表空堆栈元素 -
<pkh>
是预期的公钥哈希
隔离见证节点会识别这个模板并将其执行为完整的 P2PKH 脚本(也就是上面的 OP_DUP 版本),而旧节点将其视为可花费的输出。
与原始 P2PKH 脚本相比,隔离见证格式节省了 3 个字节(P2PKH 4+pkh,而 P2WPKH 是 1+pkh)。
下面介绍 P2SH 和 P2WSH
-
P2SH 和 P2WSH 主要用于多重签名(multisig)脚本。
-
脚本哈希提交实际的赎回脚本,在花费时会揭示和执行赎回脚本。
P2SH
构造:
OP_HASH160 <sh> OP_EQUAL
验证通过后,将 pre-image(实际上是脚本)执行:
-
花费交易提供赎回脚本作为输入。
-
从赎回脚本计算出脚本哈希。
-
如果提供的脚本哈希与 P2SH/P2WSH 输出中的脚本哈希匹配,则执行赎回脚本。
P2WSH
构造
OP_0 <sh>
说明
-
对于 P2SH,脚本哈希是 20 字节 (RIPEMD160(SHA256(赎回脚本)))。
-
对于 P2WSH,脚本哈希是 32 字节 (SHA256(赎回脚本)),以消除 P2SH 中存在的碰撞漏洞。
-
所以虽然看起来和 P2WPKH 一样,但节点能够通过哈希长度来区分
多重签名(Multisig)
多重签名脚本
多重签名脚本的格式如下:
OP_2 <pkA> <pkB> <pkC> OP_3 OP_CHECKMULTISIG
其中:
-
OP_2
:表示需要 2 个签名 -
<pkA>
、<pkB>
、<pkC>
:三个公钥 -
OP_3
:表示总共有 3 个公钥 -
OP_CHECKMULTISIG
:验证签名
使用多重签名脚本
为了使用多重签名脚本,需要提供满足要求数量的签名,例如:
OP_0 <sigA> <sigC>
其中:
-
OP_0
:一个 0 字节,由于历史原因必须添加,否则验证会失败 -
<sigA>
、<sigC>
:A 和 C 对应的签名,顺序必须与公钥顺序一致
应用
多重签名在实际中有广泛应用,尤其是在交易所等场景。它的优点包括:
-
提高安全性:即使一个私钥泄露,也不会损失所有资金
-
方便管理:可以由多人共同管理资金,避免单点故障
常见的多重签名设置有 2-of-3、3-of-4、2-of-2、3-of-3 等。
改进
目前多重签名还存在一些问题,比如:
-
验证时必须添加一个无意义的 0 字节,浪费了空间
-
签名必须严格按照公钥顺序提供,限制了灵活性
Segwit 本可以修复这些问题,但为了兼容旧版本最终没有改动。未来有望通过新的签名算法如 Schnorr 聚合签名等来进一步优化多重签名。
P2PK vs P2PKH
Pay-to-Pubkey (P2PK)
输出脚本:
<pubkey> OP_CHECKSIG
- 输出脚本中直接包含公钥,占 34 字节(公钥 33 字节+OP_CHECKSIG 1 字节)
花费时输入脚本:
<signature>
- 花费时只需提供签名即可,节省了 33 字节的公钥
P2PK 总体上可以节省 23 字节的空间。
Pay-to-Pubkey-Hash (P2PKH)
输出脚本:
OP_DUP OP_HASH160 <pubkeyhash> OP_EQUALVERIFY OP_CHECKSIG
- 输出中包含 20 字节公钥哈希,加上其他操作码共 25 字节,比 P2PK 输出小 9 字节
花费时输入脚本:
<signature> <pubkey>
- 花费时需提供签名和公钥,多出 33 字节公钥
所以 P2PKH 输出虽然更小,但花费时脚本更大,总的来说多占用了一些空间。
为什么更多采用 P2PKH?
-
可以先不公开完整公钥,增加一定隐私性
-
输出更小,UTXOs 占用空间小,利于快速随机访问
-
签名可以离线存储,输出需要放入数据库
-
可能 Satoshi 最初不知道压缩公钥格式,完整存储公钥太占空间
P2SH vs 直接在输出中包含脚本
类似 P2PK vs P2PKH,把完整脚本放在输出中可以节省脚本哈希的 20 字节空间。但是同样输出变大,不利于快速验证。而且目前节点多数不支持非标准的脚本输出格式。
为了克服这些限制,提出了更高级的脚本类型,如 MAST(Merkelized Abstract Syntax Trees)、Taproot 和 Graftroot,接下来我们将讨论这些内容。
MAST
使用 Merkle Tree 解决大脚本问题
当需要处理非常大的脚本时,比如 2-of-50 多签脚本,直接在链上存储整个脚本是不可行的,因为脚本可能会非常庞大,占用大量的区块空间。
这时可以使用承诺(Commitment)加上 Merkle 树的方式来解决这个问题。具体步骤如下:
-
承诺阶段:
-
将整个大脚本拆分成多个小块。
-
对每个小块计算哈希值,形成 Merkle 树的叶子节点。
-
计算 Merkle 树的根哈希值,作为对整个脚本的承诺。
-
将 Merkle 树的根哈希值发布到链上,作为脚本的占位符。
-
-
揭示阶段:
-
当需要执行脚本时,只需要提供 Merkle 树证明,即从叶子节点到根节点的路径。
-
验证方可以通过 Merkle 树证明验证这些小块确实是原始脚本的一部分,而不需要下载整个庞大的脚本。
-
这样做的好处是:
-
只需在链上存储 Merkle 树的根哈希值,而不是整个大脚本,大大节省了区块空间。
-
在执行脚本时,只需提供必要的 Merkle 证明,验证方可以快速验证脚本的有效性,而不需要下载整个脚本。
-
保持了脚本的隐私性,因为只有必要的部分被揭示,而不是全部内容。
基于 AST 节点的 Merkle Tree
但脚本是一种编程语言,因此我们并不直接用原生 Merkle Tree,而是用 MAST。
MAST 是一种基于 Merkle 树的技术,用于压缩和隐藏复杂的脚本逻辑。它的工作原理如下:
-
脚本编译:
-
首先,将复杂的脚本逻辑编译成一棵抽象语法树(Abstract Syntax Tree, AST)。
-
AST 是一种树状数据结构,能够表示程序的语法结构。
-
-
Merkle 化:
-
对 AST 中的每个节点计算哈希值,形成 Merkle 树。
-
Merkle 树的根哈希值就成为对整个脚本的承诺。
-
-
部分揭示:
-
在执行交易时,只需要提供 Merkle 树证明,即从叶子节点到根节点的路径。
-
验证方可以通过 Merkle 证明验证这些节点确实属于原始脚本,而不需要下载整个庞大的脚本。
-
-
隐藏逻辑:
-
未被验证的节点,其具体的逻辑内容不需要被公开。
-
这样可以隐藏脚本的复杂逻辑细节,提高隐私性。
-
基于多脚本的 Merkle Tree
考虑到直接哈希 AST 太占空间,进一步想法是,将每个脚本作为 Merkle 树的叶子节点来构建 Merkle 树。这种方法的工作流程如下:
-
将每个脚本作为 Merkle 树的叶子节点。
-
计算每个叶子节点的哈希值。
-
构建 Merkle 树,计算根哈希值作为对整个脚本集合的承诺。
-
在执行交易时,只需提供使用的脚本对应的 Merkle 证明。
-
验证方可以通过 Merkle 证明验证脚本的有效性,而无需下载整个脚本集合。
这种方法确实比 MAST 更加简单直接,同时也能够实现类似的优化效果,减小区块空间占用,提高执行效率,并保持脚本内容的隐私性。
但是还是存在问题,例如要实现 2 of 50 的多签,需要 $C_{50}^2=1225$
个脚本,树高度为 $11$
,证明大小为 $11\times 32=352 \mathrm{Bytes}$
P2SMR
原理
P2SMR (Pay-to-Spend-Merkle-Root) 的写法是:
-
在交易中,P2SMR 输出包含了一个 Merkle 根(Merkle Root)。
-
Merkle 根是一个哈希值,它代表了一个 Merkle 树的根节点。这个 Merkle 树包含了某些相关的数据,比如之前的交易输入。
-
当一个新的交易想要花费这个 P2SMR 输出时,它需要提供:
-
证明该输入是有效的,即提供一个 Merkle 证明,证明该输入确实包含在之前的 Merkle 树中。
-
提供该输入的参数,比如签名等。
-
-
验证节点可以通过验证 Merkle 证明和参数,来确认该输入是有效的,从而允许这笔交易被
-
打包进区块。
尾调用优化
如果栈上只剩下 2 个元素,可以将顶部元素视为主要 Merkle Root,底部元素视为证明和参数。将这两个元素作为输入,进行 tail call 递归验证。在递归过程中,只需要保留两个元素在栈上,不断更新 Merkle 根和证明/参数,直到验证完成。
OP_RETURN 及其局限性
原理
OP_RETURN 的特点是执行时立即返回 False,所以人们通常使用 OP_RETURN 来在 Bitcoin 链上存储一些数据的哈希值,以证明在某个区块高度之前就已经知道了这些数据。
局限
但 OP_RETURN 有以下局限:
-
数据是公开可见的,有时候并不需要这样
-
OP_RETURN 会占用区块链的空间,有额外开销
0-byte OP_RETURN
能否实现不占用任何空间的 OP_RETURN?
P2CH
P2CH(Pay to Contract Hash)由 Andrew Poelstra 提出,可以在签名中承诺一些数据,相比 OP_RETURN 有以下优点:
-
对其他人来说,签名看起来与普通签名无异,承诺的数据是隐藏的
-
不占用额外的区块链空间,零字节开销
-
可以在他人的签名中承诺数据,只要告诉他们数据即可
原理
Schnorr 签名的形式是:
$$
s = k - h(m, R)a
$$
它的含义我们解释过了:数字签名原理:从 Lamport 到椭圆曲线
-
$s$
是签名。 -
$k$
是一个随机数。 -
$h(m, R)$
是一个哈希函数,它以消息$m$
和一些随机数$R$
作为输入,输出一个哈希值。 -
$a$
是私钥。
两边同乘生成元可以验证签名:
$$
sG = R + h(m,R)A
$$
-
这个等式是用来验证签名的过程。
-
$G$
是一个公开的基点。 -
$A$
是公钥,由私钥$a$
计算得到,即$A = aG$
。 -
左边
$sG$
是使用签名值$s$
和基点$G$
计算得到的结果。 -
右边
$R + h(m,R)A$
是使用消息$m$
、随机数$R$
和公钥$A$
计算得到的结果。
通过验证这两个等式是否成立,可以确认签名是否有效。
P2CH 的思想是,我们能否重新定义 $k$
让它包含信息?因为 $k$
通常只是随机数,我们重新定义一个随机数 $j$
,让 $k$
按照如下规则生成:
$$
k = j + h(data, jG)
$$
这样的话,$s = j + h(data,jG) - h(m, kG)a$
.
验证
若不知道内情,验证方程与普通签名完全一致。因为 $j + h(data, jG)$
看起来完全随机。
$$
sG = R - h(m, R)A
$$
签名者可以在事后证明 R 并非随机生成,即不是 $kG$
,而是包含了承诺的数据。证明过程为给出 data 和 J,使得:
J + h(data, J)G = R
由于 j 的定义是递归的(公式移项后可知),事后伪造是很难的。因此 P2CH 可以证明某些数据在签名之前就已经存在了。
Taproot 的构想
P2CH 说明我们可以把数据承诺藏在签名里。基于此,Greg Maxwell 提出了 Taproot 的想法,用于实现 MAST 和多签名。虽然 P2CH 最初只是用来承诺数据,但结合 MAST 和多签名可以发挥出更大的作用,这个想法却花了一年多时间才被人意识到。
Taproot
动机与概念
Taproot 的动机是统一 P2PKH(Pay-to-PubKeyHash)和 P2SH(Pay-to-ScriptHash)的外观,使它们在区块链上看起来相同,从而提高交易的隐私性。在现有的比特币系统中,P2PKH 和 P2SH 在输出脚本上有明显的区别,这可能导致用户被跟踪或交易被区分对待。
可以使用 P2SH 来处理所有情况(1 of 1 多签),但这很无聊,不能利用酷炫的数学
Taproot 通过使用 Pay-to-Contract-Hash(P2CH)的概念,结合 Schnoor 签名和 Merkle 树技术,允许在同一个输出中同时支持 P2PKH 和 P2SH。具体来说,它允许创建一个输出,该输出可以被一个私钥直接花费(类似于 P2PKH),也可以通过揭示一个脚本和相应的签名来花费(类似于 P2SH)。
执行细节
在 Taproot 中,我们创建一个私钥 $J$
,脚本 $z$
,然后我们构造一个公钥
$$
C = J + hash(z, J)G
$$
考虑到 $c = j + hash(z, J)$
,我们可以构造出私钥 $c$
.
所以当我们需要用 P2PKH 创建一个输出时,就用 $C = J + hash(z, J)G$
签名。想要用 P2SH 时,就用 $c = j + hash(z, J)$
签名。
在花费这个输出时,有两种方式:
-
直接使用私钥
$C$
进行签名,无需揭示脚本$z$
。 -
揭示脚本
$z$
和相关的子钥$J$
,然后执行脚本。
总结:
-
将 P2PKH 和 P2SH 合并为单一输出类型
-
将密钥
J
和脚本z
合并 -
发送到密钥
C
其中C = J + hash(z, J)G
-
-
作为 P2PKH 处理: 使用
c = j + hash(z, J)
签名 -
作为 P2SH 处理: 公开
(z, J)
、提供参数并运行脚本z
-
如果所有人都同意并签名,甚至不需要显示合同脚本(例如 n 选 n 多重签名)
Trick:签名聚合
考虑令 $J$
是由所有人密钥之和组成的公钥,$z$
是 2 of 50 签名的 Merkle Root。那么如果我想用 2 of 50 的方式取钱,则必须揭露。或者我让 50 个人都签名,则可以得到 $c$
生成的签名,就可以避免公开脚本。
允许在 Schnoor 签名中进行签名聚合,意味着多个参与方可以共同创建一个签名,而无需公开各自的私钥。这为多签交易提供了更多的灵活性和隐私保护。
Trick:证明没有已知的私钥
$$
C= J+h(z,J)G
$$
-
可以制作一个公钥
C
并证明它没有已知的私钥-
交互式: 使用其他人的
J
-
非交互式: 揭露
J
的 x 坐标的预映像
-
-
用于证明一个公钥是"仅脚本"的,没有签名密钥
-
因为没有使用正确的方式计算 J
-
这样可以证明你不会搞到一个
$c$
来签名。 -
这样你可以证明这里只有脚本。
补充说明
-
任何人都可以向任何一组公钥制作一个 taproot 输出,而无需他们参与
- 只需要公钥,不需要任何私钥
-
这与 Greg Maxwell 提出的另一个提案 graftroot 有所不同
Graftroot
概念与实现
Graftroot 是在 Taproot 的基础上进一步发展的概念,它允许将多个脚本与单一的根密钥 C 关联起来。与 Taproot 不同,Graftroot 不需要在创建输出时就确定所有可能的脚本,而是可以在之后随时添加新的脚本。
Graftroot 的工作原理如下:
-
创建一个根密钥 C,所有可能的脚本都与这个根密钥相关联。
-
每个脚本都由根密钥 C 签名,作为对该脚本的背书。
-
在花费输出时,提供根密钥 C 的签名以及要执行的脚本。
这种方法的优势在于,可以轻松地添加新的脚本,而无需对已有的输出进行任何修改。此外,由于所有脚本都与同一个根密钥相关联,因此在花费时只需要提供一个签名,而不是多个 Merkle 证明。
互动设置与可扩展性
Graftroot 的一个缺点是需要互动设置。在创建输出时,所有参与方必须共同签名以背书所有可能的脚本。这意味着,如果要创建一个 2-of-50 的多签输出,需要所有 50 个参与方的合作。然而,一旦输出被创建并确认,参与方可以事后为任何新的脚本提供签名,这为系统提供了灵活性。