比特币的 DSL(领域特定语言)如何帮助开发者进入比特币生态系统
最近,我们在比特币脚本中发布了一个 Plonk 验证器的实现,使用了 STARK 和 OP_CAT。该 Plonk 验证器已经在比特币 Signet 和 Fractal 主网上进行了测试。这个 Plonk 验证器与我们在七月和八月发布的 Fibonacci 验证器的一个重要区别在于,Plonk 验证器的代码主要是用嵌入式领域特定语言(eDSL)编写的,而不是手动逐个编写比特币脚本操作码,这个 eDSL 是基于 Rust 构建的。
在本文中,我们想谈谈比特币的 DSL,以及它如何帮助开发者进入比特币生态系统,类似于 Solidity 对以太坊的作用。然而,我们必须承认,我们的 eDSL 远未成为比特币的“Solidity”,因为这只是一个非常早期的尝试,并且将会有更多的 DSL 出现(我们认为另一个 DSL,sCrypt,更接近于成为比特币的 Solidity,如下所述),但我们也相信,随着包括我们在内的 DSL 的出现,开发者不再需要逐个编写操作码,比特币的开发者生态系统已经发生了根本性的变化,我们不会回头。
领域特定语言是为特定应用设计的编程语言,其目标是使编程更简单和更安全。
Solidity可以被认为是一个非常成功的例子,特别是 OpenZeppelin 的智能合约库标准化了许多常见组件。
仍然流行的旧 DSL 例子包括用于硬件设计的 Verilog 和VHDL,当然,这些 DSL 使硬件工程师不必手动绘制数十亿个晶体管。
Epic Games 有一个 DSL,称为 Verse,用于在虚幻引擎上编写游戏,并已用于构建其世界级游戏 Fortnite。
领域特定语言可以是全新的创造,但常见的做法是基于现有语言开发这种新语言,以便更容易开发和维护,并且新开发者更容易学习。一种特殊情况是所谓的“嵌入式领域特定语言”(eDSL),它是完全在现有语言上实现的领域特定语言,作为一个库,因此我们不需要实现解析器和整个新的开发工具链。
我们的 eDSL 是基于 Rust 实现的。熟悉 Rust 的开发者应该几乎不需要时间来适应这个 eDSL。我们 eDSL 的主要目标是降低学习曲线以及改善开发“人 机工程学”——总体上是开发者体验。
比特币对 DSL 来说有点新。以前,我们只有针对简单支出策略的比特币 DSL(这也是当时比特币脚本最常见的用例)。其中一个称为 `bitcoin-dsl`,专注于提供一个人性化的界面来描述交易的多重签名和时间锁定要求。这已经足够强大,例如,可以用几行代码实现闪电协议和Ark 协议,并获得了很多赞赏 。
例如,使用 bitcoin-dsl
,如果我想指定一个经典的时间锁定策略(在 Ark 中使用),即 Alice 可以在 ARK 服务提供商(ASP)同意的情况下立即赎回资金,或者,如果达到超时时间,Alice 可以在没有 ASP 支持的情况下单方面赎回资金。传统上,人们需要几乎手动地在比特币脚本中编写这段代码,这可能非常容易出错。现在,人们可以用人类语言定义这个策略,如下所示。
另一个例子是 Minsc,它基于 Miniscript,具有类似的目的和功能。例如,如果我们想实现一个策略,要求交易需要 3/3 多重签名,但在 90 天后,它将放宽为 2/3,Miniscript 允许人们编写支出策略如下:
thresh(3,pk(key_1),pk(key_2),pk(key_3),older(12960))
这里的“thresh”意味着在四件事情中,至少需要发生三件。这四件事情是(1)由 key_1 签名,(2)由 key_2 签名,(3)由 key_3 签名,或(4)UTXO 已存在超过 12960 个区块(大约但不完全是 90 天)。Miniscript 编译器能够将其转换为几行脚本,如下所示。
这些 DSL 的一个限制是它们的范围仅限于支出策略及其组合,而不是更复杂的比特币脚本,例如“计算”用于比特币 STARK 验证器。
一个更强大的候选者是 sCrypt,这是一种基于 TypeScript 的智能合约 DSL,最初为比特币 SV(比特币的一个分叉)设计,但现在提供更多对原生比特币脚本的支持(以及 OP_CAT)。
sCrypt 非常强大,因为人们可以首次编写复杂的比特币脚本,而无需了解操作码。这已被用于 CAT20 协议,并已在生产中使用,这为 Runes 和 Ordinals 提供了改进,显著消除了中心化和可扩展性问题。Unisat 钱包也提供对 CAT20 协议的支持。
一般来说,开发者在 TypeScript 中编写“比特币智能合约”的函数(带有一些方便的内置类型和比特币函数)。然后,只需在函数上添加一个修饰符“@method()”并进行一些其他更改。编译器将此函数编译为智能合约的方法,非常类似于 Solidity 今天提供的体验。
一个展示 sCrypt 潜力的好例子是 CAT20 的转移逻辑。它基本上是用 TypeScript 编写的,有兴趣的读者可以打开这个文件查看脚本 。
想想代币转移。代币转移需要 N 个输入并产生 M 个输出,每个输入都有一个所有者和一个数量,输出也是如此。要验证代币转移,相当于验证:
总输入 = 总输出
CAT20 转移脚本执行以下操作。它首先计算输入中的代币总量,TypeScript 如下所示。
检查 script == preState.tokenScript
是为了确保输入是有效的 CAT20 合约,如果是,其输入代币数量将被添加到 sumInputToken
,它作为一个累加器。最终检查 assert(sumInputToken > preSumInputToken)
是一个合理性检查。
输出遵循类似的工作流程,但还要求输出 scriptSig 匹配 CAT20 协议的一个。它同样计算输出的总和并检查输出的其他事项。最后,脚本检查输入和输出是否匹配。
合约的其余部分是更通用的契约包装器。然而,应该发现 CAT20 已经在这个 DSL——sCrypt 中实现,并且不需要开发人员学习比特币脚本。
与 sCrypt 的 DSL 更侧重于契约和智能合约相比,我们的 DSL 更侧重于计算,尤其是需要拆分的复杂计算,例如比特币 STARK 验证器。我们计划将我们的 DSL 与 sCrypt 集成——这样 sCrypt 中的用户可以在 TypeScript 中用几行代码调用比特币 STARK 验证器。
我们的 DSL 基于 Rust,是一种嵌入式领域特定语言(eDSL),这意味着它更像是 Rust 中的一个库,不需要单独的工具链或 IDE 支持。它不仅仅是一个库,因为我们的 DSL 引入了一些新的抽象:
比特币变量
比特币函数
比特币脚本生成器
以及一些内置功能,如比特币脚本和契约的内存。
为了帮助理解我们为什么要创建这些抽象,了解开发人员在编写比特币脚本时遇到的挑战或“糟糕的人机工程学”是有用的,以我们构建的斐波那契验证器为例。
第一个挑战:堆栈管理 。当 Starkware 的 Victor Kolobov 和我在六月和七月编写斐波那契验证器时,它消耗了大量的脑力,因为我们需要跟踪堆栈中变量的相对位置。
在整个验证器实现中,有几十甚至上百段代码看起来像这样,我们在注释中画出了当前堆栈与不同变量(如“random_coeff2”)及其长度(如 4)。为了计算准备好的对消失参数,我们需要从堆栈中检索“masked_points”和“oods_point”。
在比特币中,要从堆栈中检索元素,需要提供你正在检索的元素与堆栈顶部之间的相对距离(这将在我们绘制的堆栈底部)。这非常不友好,因为当一个元素被复制(到堆栈顶部)时,其他元素到堆栈顶部的相对距离可能会改变(有些保持不变,有些距离更大)。然后,当我们为每个 4 个点运行准备好的对消失过程(由“prepare_pair_vanishing_with_hint”指示)时,它从堆栈顶部消耗 8 个元素并向堆栈顶部生成 4 个元素,这再次改变了相对距离。
因此,代码中的距离“16 + 12 + 4 i + (8 + 24) - 8 i - 1”是这样计算的,从堆栈顶部开始:
跳过“(a, b), (a, b), (a, b), (a, b) for composition”有 16 个元素
跳过“(a, b), (a, b), (a, b) for trace”有 12 个元素
考虑到准备好的对消失后一个点产生的 4 个元素
(8 + 24) - 8 * i - 1 将我们带到相应点的 8 个元素的第一个元素。
这种编写脚本的方式存在许多问题。
进行这样的计算需要大量的脑力,并且通常需要跟踪当前的堆栈布局(在我们的例子中,我们将它们写在代码中作为注释)。
容易出错,错误可能相当隐蔽。如果位置不正确,脚本将表现得“怪异”,很难立即意识到这是由于错误的堆栈位置。
前一步的更改可能会影响堆栈布局,并要求之后的所有程序更新公式,使得管理大型计算变得困难。
这种手动维护堆栈的方式也不是最优的——对于在后续执行中不再需要的值,它们将保留在堆栈中,除非我们手动删除它们。此外,由于在整个执行过程中堆栈从一个程序复制到另一个程序,如果未及时删除未使用的变量,将浪费堆栈空间。
这导致我们对 DSL 的第一个要求:
第二个挑战:状态传递。 我们通常将整个比特币 STARK 验证器拆分为几个较小的交易,这样更容易通过比特币网络,并对内存池中其他待处理的比特币交易影响较小。然而,这要求我们能够将中间状态从一个交易传递到另一个交易。
在旧的斐波那契验证器中,我们使用了一种称为“StackHash”的设计,最初来自 Carter Feldman 的 QED,它使得可以将长计算拆分为较小的交易。“StackHash”的想法是,它可以通过对当前执行的状态进行哈希并将其放入比特币链上的 UTXO 中来保存当前执行的状态,稍后,在下一个执行中,它可以从比特币链中检索该 UTXO 并提取堆栈元素。
通过这样做,可以恢复上一次执行结束时的相同堆栈,并继续其余的执行,就像它是同一个程序一样,如下图所示。
“StackHash”的主要问题是它有很多冗余,存在可扩展性问题,并且不灵活——它只能对整个堆栈进行哈希。
当堆栈很大时,会使下一个执行更昂贵,因为下一个需要加载整个堆栈。
下一个执行可能只需要访问堆栈中的几个元素,但提供了整个堆栈。
在每两次连续执行之间,需要对最终堆栈的布局做出决策,并确保两次执行彼此一致。
需要组织堆栈,以便准备好共享给下一次执行的值保持在堆栈中直到结束。
要使用“StackHash”,我们需要做两件事:(1)在执行开始时,将堆栈哈希解包为完整的堆栈;(2)在执行结束时,将堆栈打包为堆栈哈希。斐波那契验证器的一个例子如下。
在执行之前,它根据前一个堆栈的长度解包堆栈(即上面显示的长公式,对应于从底部到顶部的注释中的堆栈布局)。可以看到,堆栈中有相当多的元素。
执行后,堆栈发生了变化。为了良好的实践,我们再次在注释中列出新的堆栈布局,然后使用“StackHash”打包堆栈,以便用于下一步。
虽然“StackHash”完成了工作,但它有点繁琐,并且确实需要类似的堆栈管理开销。有没有一种更人性化的方式来携带状态?
要回答这个问题,我们首先应该跳出比特币,问问自己,一个在 C++/Java/Rust 中工作的普通程序员会用什么方法来加载和保存状态?可以是文件系统。可以是配置文件。可以是数据库。这些现代抽象极其用户友好的原因之一是它们提供了一个键值接口。
这引出了我们对 DSL 的第二个要求:
第三个挑战:高级编程。 如果你考虑到斐波那契验证器是通过连接不同的比特币脚本构建的,这些脚本是使用操作码编写的,你可能会开始担心开发者生态系统,因为大多数开发者可能从未使用过任何比特币操作码,他们对比特币脚本一无所知。
这更令人担忧,因为比特币脚本的学习材料也很缺乏——例如,虽然 Udemy 上有大约 1100 门在线课程谈论 Solidity,但只有一门实际上谈论比特币脚本,收费 $119.99。
比特币可编程性的实际问题可能不是教人们比特币脚本,而是开发一种高级语言。用比特币脚本编写非常类似于在计算机编程中编写汇编,或在 EVM 中编写 EVM 字节码。它们不会成为大规模采用的方式。
纵观历史,我们可以看到编程语言演变的一个一致模式——更人性化且更接近其他编程语言的高级语言将逐渐占据主导地位,而低级语言如汇编或比特币操作码将被用作高级语言的底层构建块,开发者不需要了解,或者偶尔用于优化或访问低级功能。
因此,我们希望比特币 DSL 能够让语言感觉不那么像比特币,而是对熟悉其他常用编程语言的人来说显得熟悉。
这就要求我们对比特币 DSL 的第三个要求:
我们在 Rust 中构建了一个嵌入式 DSL,满足上述要求。首先,我们消除了开发者管理堆栈的需要。记住,以前,为了在一个点上执行准备好的对消失(STARK 证明验证中的一步),我们必须计算该点与堆栈顶部之间的距离,当堆栈中已经有很多元素时,计算变得复杂。
解决第一个挑战:堆栈管理。 现在 DSL 负责堆栈管理。在下面用我们的 DSL 编写的脚本中,我们有一个点,它是一个 Rust 结构“SecureCirclePointVar”,我们只需将其传递给“prepare_pair_vanishing”函数,以计算该点的一些参数。
开发者不需要关心堆栈管理。开发者只需简单地将变量“oods_point”作为输入传递,就像在 Rust 中一样。
之所以可能,是因为 DSL 为堆栈中的每个元素分配了一个编号。你可以认为“SecureCirclePointVar”除了存储 x 和 y 值外,还为其在堆栈上的 8 个元素存储了一些唯一编号。这些编号是固定的,它们不会在堆栈增长或某些其他变量从堆栈中移除时改变。DSL 将负责计算这些变量相对于堆栈顶部的相对位置。
我们的 DSL 如何进行距离计算?它利用了一种称为 Fenwick 树的数据结构。
DSL 从 0、1、2、… 开始为堆栈上的元素分配编号。如果一个元素的编号比另一个小,那么这个元素必须在堆栈中更深(或者换句话说,离堆栈顶部更远)。如果 DSL 注意到某些元素不会再次使用,它们可能会从堆栈中移除(或释放)——这带来了一个挑战,因为计算距离涉及到对该元素与堆栈顶部之间的 非 释放元素的数量进行求和,同时跳过所有已移除或释放的元素。这意味着时间复杂度为 O(N),因为在最坏的情况下,我们需要检查所有已分配的变量,其中 N 是曾经分配给堆栈的元素数量。
Fenwick 树是一种将时间复杂度降低到 O(logN) 的数据结构,使得这种计算非常经济。这是因为 Fenwick 存储了一些范围和。到堆栈顶部的距离是一种“部分和”,可以通过仅查找 O(logN) 范围和来计算。当我们将新元素分配到堆栈中时,它们被插入到 Fenwick 树中,树为每个新元素更新 O(logN) 范围和。当 DSL 想要释放某个变量时,它会相应地更新 O(logN) 范围和。这使得 DSL 即使在执行过程中可以分配和释放变量的情况下,也能有效地计算变量到堆栈顶部的距离。
通过这种方式,开发者只需在 Rust 中直接操作变量,DSL 将跟踪它们在栈中的位置,并在不再使用时释放它们。
解决第二个挑战:传递状态。 之前,我们使用“StackHash”来保存上一次执行的栈,并在后续执行开始时加载它,我们提到一个理想的接口是“键值对”,因为它直观且易于理解。我们的 DSL 提供了这个接口。
在上面的例子中,我们想计算列线系数(这是比特币 STARK 验证的中间值)。它需要使用域外采样(OODS)点的 y 坐标(“oods_y”)以及从先前执行中“mult”、“a_var”、“b_val”、“c_val”多项式的评估。通过调用 LDM 的“read”方法,使用一个易于理解的变量名来完成。
计算结果将被保存,以便在未来几次使用列线系数的执行中使用。这是通过调用 LDM 的“write”方法来完成的。这些中间变量在比特币 STARK 验证的后期使用,如下所示,开发者只需在此提供系数的名称。
顾名思义,轻量级确定性内存(LDM)是为比特币精心设计的内存实现,当然是轻量级的。我们将在另一篇技术文章中描述我们对 LDM 的构建,并讨论比特币脚本中的内存。
但总的来说,LDM 提供了一个键值对接口,基本上解决了在构成更大计算的小脚本之间传递状态的问题。
解决第三个挑战:高级编程。 从前面的例子可以看出,编码尚未涉及任何比特币操作码。这是因为许多这些函数可以直接调用我们 DSL 的标准库函数(它们只是 Rust 函数),开发者不必编写任何比特币脚本。
我们的域外采样(OODS)实现就是一个这样的例子,如下所示,这是比特币 STARK 验证中的一个重要步骤。它需要执行的计算是:
从内置的 Fiat-Shamir 变换中获取一个随机元素“t”
计算 x = (1 - t^2) / (1 + t^2)
计算 y = 2t / (1 + t^2)
我们的 DSL 中的代码如下。
这不涉及任何比特币脚本。相反,它只是直接在变量(这里是“t”)上执行计算。这大大减少了出错的可能性,并且易于阅读——因为所有变量名都很直观。
仍然有一些情况我们希望编写一些比特币脚本。一种情况是标准库没有所需的功能,但对我们来说更常见的是我们已经有一些用比特币脚本编写的代码,并且已经经过测试,我们希望简单地重用这些代码。
在我们的比特币 STARK 验证器中,有一个这样的例子是“decompose_positions”,它根据默克尔树上的一个位置计算 STARK 验证的几个相关位置。由于这是非常特定于应用程序的,可以理解标准库没有它。
在我们的 DSL 中,可以使用“insert_script”(或对于带参数的脚本使用“insert_script_complex”)来插入原始比特币脚本。脚本的输入(将放置在脚本栈的顶部)作为第二个参数(“variables”)提供,DSL 将负责栈管理以准备脚本的输入。
我们团队的重点仍然是构建比特币 STARK 验证器,看来这个目标已经不远了。我们认为开发 DSL 的最佳方式是使用它——当我们发现用 DSL 编写 STARK 验证器时存在冗余或不便时,我们可以回来改进 DSL。
我们将会有更多的文章谈论比特币 STARK 验证器和契约,以及它的应用,例如 CAT20 协议(我们可以解释为什么它与 ERC20 极其相似)。
我们要感谢来自 sCrypt 的 Xiaohui Liu 提供关于实现 DSL 的建议。sCrypt 提供了一种类似 TypeScript 的 DSL 用于编写比特币脚本,这在编写契约时特别有用,并且正在用于构建 StarkNet 的代币桥。
比特币 Signet 的贡献者(我们仍然不确定是谁给我们发送了测试代币,也许是中本聪?)和 Fractal 赞助了我们原型的交易费用。
在 l2iterative.com 和 Twitter 上找到 L2IV @l2iterative
感谢阅读 L2IV Research!免费订阅以接收新文章并支持我的工作。
作者:Weikeng Chen,研究合伙人,L2IV
免责声明:本内容仅供参考,不应作为法律、商业、投资或税务建议。你应咨询自己的顾问以了解这些事项。对任何证券或数字资产的引用仅用于说明目的,并不构成投资建议或提供投资咨询服务的要约。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!