深入研究比特币:(4)MAST、Taproot、Graftroot

比特币中最常见的脚本类型是 P2PKH 及其隔离见证等价物 PK2WPKH。这些脚本将输出锁定到特定的公钥哈希(PKH),这是公钥的哈希值。

P2PKH vs P2WHPK

P2PKH

Pay to PubKey Hash 构造如下:

OP_DUP OP_HASH160 <pkh> OP_EQUALVERIFY OP_CHECKSIG

要花费此输出,输入必须提供:

  1. <pkh> 对应的公钥

  2. 使用该公钥对交易的有效签名

该脚本的工作原理如下:

  1. OP_DUP 复制提供的公钥

  2. OP_HASH160 使用 RIPEMD-160 算法对公钥进行哈希

  3. <pkh> 是预期的公钥哈希

  4. OP_EQUALVERIFY 检查哈希后的公钥是否与预期的 <pkh> 匹配,如果不匹配则失败

  5. OP_CHECKSIG 检查提供的签名是否对应于公钥和支出交易的有效签名

P2WPKH

OP_0 <pkh>

在隔离见证中,P2PKH 脚本被转换为更紧凑的形式:

  1. OP_0 代表空堆栈元素

  2. <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(实际上是脚本)执行:

  1. 花费交易提供赎回脚本作为输入。

  2. 从赎回脚本计算出脚本哈希。

  3. 如果提供的脚本哈希与 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?

  1. 可以先不公开完整公钥,增加一定隐私性

  2. 输出更小,UTXOs 占用空间小,利于快速随机访问

  3. 签名可以离线存储,输出需要放入数据库

  4. 可能 Satoshi 最初不知道压缩公钥格式,完整存储公钥太占空间

P2SH vs 直接在输出中包含脚本

类似 P2PK vs P2PKH,把完整脚本放在输出中可以节省脚本哈希的 20 字节空间。但是同样输出变大,不利于快速验证。而且目前节点多数不支持非标准的脚本输出格式。

为了克服这些限制,提出了更高级的脚本类型,如 MAST(Merkelized Abstract Syntax Trees)、Taproot 和 Graftroot,接下来我们将讨论这些内容。

MAST

使用 Merkle Tree 解决大脚本问题

当需要处理非常大的脚本时,比如 2-of-50 多签脚本,直接在链上存储整个脚本是不可行的,因为脚本可能会非常庞大,占用大量的区块空间。

这时可以使用承诺(Commitment)加上 Merkle 树的方式来解决这个问题。具体步骤如下:

  1. 承诺阶段:

    • 将整个大脚本拆分成多个小块。

    • 对每个小块计算哈希值,形成 Merkle 树的叶子节点。

    • 计算 Merkle 树的根哈希值,作为对整个脚本的承诺。

    • 将 Merkle 树的根哈希值发布到链上,作为脚本的占位符。

  2. 揭示阶段:

    • 当需要执行脚本时,只需要提供 Merkle 树证明,即从叶子节点到根节点的路径。

    • 验证方可以通过 Merkle 树证明验证这些小块确实是原始脚本的一部分,而不需要下载整个庞大的脚本。

这样做的好处是:

  1. 只需在链上存储 Merkle 树的根哈希值,而不是整个大脚本,大大节省了区块空间。

  2. 在执行脚本时,只需提供必要的 Merkle 证明,验证方可以快速验证脚本的有效性,而不需要下载整个脚本。

  3. 保持了脚本的隐私性,因为只有必要的部分被揭示,而不是全部内容。

基于 AST 节点的 Merkle Tree

但脚本是一种编程语言,因此我们并不直接用原生 Merkle Tree,而是用 MAST。

MAST 是一种基于 Merkle 树的技术,用于压缩和隐藏复杂的脚本逻辑。它的工作原理如下:

  1. 脚本编译:

    • 首先,将复杂的脚本逻辑编译成一棵抽象语法树(Abstract Syntax Tree, AST)。

    • AST 是一种树状数据结构,能够表示程序的语法结构。

  2. Merkle 化:

    • 对 AST 中的每个节点计算哈希值,形成 Merkle 树。

    • Merkle 树的根哈希值就成为对整个脚本的承诺。

  3. 部分揭示:

    • 在执行交易时,只需要提供 Merkle 树证明,即从叶子节点到根节点的路径。

    • 验证方可以通过 Merkle 证明验证这些节点确实属于原始脚本,而不需要下载整个庞大的脚本。

  4. 隐藏逻辑:

    • 未被验证的节点,其具体的逻辑内容不需要被公开。

    • 这样可以隐藏脚本的复杂逻辑细节,提高隐私性。

基于多脚本的 Merkle Tree

考虑到直接哈希 AST 太占空间,进一步想法是,将每个脚本作为 Merkle 树的叶子节点来构建 Merkle 树。这种方法的工作流程如下:

  1. 将每个脚本作为 Merkle 树的叶子节点。

  2. 计算每个叶子节点的哈希值。

  3. 构建 Merkle 树,计算根哈希值作为对整个脚本集合的承诺。

  4. 在执行交易时,只需提供使用的脚本对应的 Merkle 证明。

  5. 验证方可以通过 Merkle 证明验证脚本的有效性,而无需下载整个脚本集合。

这种方法确实比 MAST 更加简单直接,同时也能够实现类似的优化效果,减小区块空间占用,提高执行效率,并保持脚本内容的隐私性。

但是还是存在问题,例如要实现 2 of 50 的多签,需要 $C_{50}^2=1225$ 个脚本,树高度为 $11$,证明大小为 $11\times 32=352 \mathrm{Bytes}$

P2SMR

原理

P2SMR (Pay-to-Spend-Merkle-Root) 的写法是:

  1. 在交易中,P2SMR 输出包含了一个 Merkle 根(Merkle Root)。

  2. Merkle 根是一个哈希值,它代表了一个 Merkle 树的根节点。这个 Merkle 树包含了某些相关的数据,比如之前的交易输入。

  3. 当一个新的交易想要花费这个 P2SMR 输出时,它需要提供:

    • 证明该输入是有效的,即提供一个 Merkle 证明,证明该输入确实包含在之前的 Merkle 树中。

    • 提供该输入的参数,比如签名等。

  4. 验证节点可以通过验证 Merkle 证明和参数,来确认该输入是有效的,从而允许这笔交易被

  5. 打包进区块。

尾调用优化

如果栈上只剩下 2 个元素,可以将顶部元素视为主要 Merkle Root,底部元素视为证明和参数。将这两个元素作为输入,进行 tail call 递归验证。在递归过程中,只需要保留两个元素在栈上,不断更新 Merkle 根和证明/参数,直到验证完成。

OP_RETURN 及其局限性

原理

OP_RETURN 的特点是执行时立即返回 False,所以人们通常使用 OP_RETURN 来在 Bitcoin 链上存储一些数据的哈希值,以证明在某个区块高度之前就已经知道了这些数据。

局限

但 OP_RETURN 有以下局限:

  1. 数据是公开可见的,有时候并不需要这样

  2. OP_RETURN 会占用区块链的空间,有额外开销

0-byte OP_RETURN

能否实现不占用任何空间的 OP_RETURN?

P2CH

P2CH(Pay to Contract Hash)由 Andrew Poelstra 提出,可以在签名中承诺一些数据,相比 OP_RETURN 有以下优点:

  1. 对其他人来说,签名看起来与普通签名无异,承诺的数据是隐藏的

  2. 不占用额外的区块链空间,零字节开销

  3. 可以在他人的签名中承诺数据,只要告诉他们数据即可

原理

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)$ 签名。

在花费这个输出时,有两种方式:

  1. 直接使用私钥 $C$ 进行签名,无需揭示脚本 $z$

  2. 揭示脚本 $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 的工作原理如下:

  1. 创建一个根密钥 C,所有可能的脚本都与这个根密钥相关联。

  2. 每个脚本都由根密钥 C 签名,作为对该脚本的背书。

  3. 在花费输出时,提供根密钥 C 的签名以及要执行的脚本。

这种方法的优势在于,可以轻松地添加新的脚本,而无需对已有的输出进行任何修改。此外,由于所有脚本都与同一个根密钥相关联,因此在花费时只需要提供一个签名,而不是多个 Merkle 证明。

互动设置与可扩展性

Graftroot 的一个缺点是需要互动设置。在创建输出时,所有参与方必须共同签名以背书所有可能的脚本。这意味着,如果要创建一个 2-of-50 的多签输出,需要所有 50 个参与方的合作。然而,一旦输出被创建并确认,参与方可以事后为任何新的脚本提供签名,这为系统提供了灵活性。

课程

本文是 https://www.youtube.com/watch?v=gF4Mkkhyz1Q 的笔记。