深度研究新兴智能合约编程语言Move,与现有Solana上以Rust为基础的模式进行比较。
最近,关于 Aptos 和 Sui 的讨论如火如荼,两者是新兴的高性能 L1 公链,Move 智能合约编程语言是这些新链必不可缺的组成部分。一些开发人员正在积极转向Move,宣称它是智能合约发展的未来。其他人更加谨慎,认为 Move 与现有编程语言相比,不能提供更多太新的东西。
加密投资者也在好奇这些 L1 公链的独特之处,如何能与 Solana 抗衡,后者是目前高性能 L1 的主要玩家,以使用 Rust 作为智能合约编程而著称。
但目前我们看到的讨论并没有达到一定深度,能够真正参透这些新科技对我们的影响。这在讨论的两极都适用——Move 的质疑者将 Move 贬低得一无是处,无法欣赏到它更细微(但十分重要)的一面,但 Move 的支持者,过度鼓吹 Move,也没有能看透究竟是什么使其伟大。这就带来巨大的中间地带与模糊不清,致使外界看客、加密开发人员、投资人员,关注此话题,但又无法对自己的观点确信。
在这篇文章中,我将对 Move、其新颖的编程模型、Sui 区块链和它如何利用 Move 的功能,以及它与 Solana 及其编程模型的比较进行深入的技术挖掘。为了突出 Move 的特点,我将把 Solana/Rust 与 Sui/Move 进行比较。因为当你把一个东西与另一个你已熟悉的东西进行比较时,理解就会更容易。
Move 还有其他变种,如 Aptos Move,它们在某些方面略有不同。本文的重点不是讨论 Move 不同变体之间的细微差别,而是展示 Move 的普遍优势,以及它与 Solana 编程模型的比较。因此为了简单起见,我在本文中只使用一个变体(Sui Move)。因此,我在本文中介绍的某些Move概念(即对象和相关功能)只适用于 Move 的 Sui 变体,而不适用于其他变体。虽然 Move 的其他变体不一定有这些概念,但它们使用不同的机制(例如全局存储)能够实现同样功能。但即便如此,本文所讨论的所有Move的主要优点都适用于所有Move集成(原生设定上支持Move字节码 Move bytecode),包括Aptos。我选择Sui,只是因为我对它更熟悉,且我觉得它更直观一些,更容易以文章的形式呈现。
在Solana上,程序(智能合约)是无状态的,它们自己不能访问(读或写)任何在整个交易中持续存在的状态。为了访问或保持状态,程序需要使用账户。每个账户都有一个唯一的地址(Ed25519密钥对的公钥),可以存储任意的数据。
我们可以把Solana的账户空间看作是一个全球键值存储,其中键是账户地址(pubkey),值是账户数据。程序通过读取和修改其值在该键值存储上进行操作。
账户有一个所有权的概念。每个账户由一个(且只有一个)程序拥有。当一个账户被一个程序拥有时,该程序被允许修改其数据。程序不能修改所不拥有的账户(但允许读取这些账户)。运行期间,比较程序执行前后的账户状态,就能够进行这种动态检查,若有非法改动,则交易失败。
每个账户也有一个与之相关的私钥(相应的公钥是它的地址),能够访问这个私钥的用户可以用它来签署交易。利用这种机制,我们在Solana智能合约中实现了权限和所有权的功能--例如,为了获取某些资金,智能合约可以要求用户提供必要的签名。
在其他做程序调用时,客户需要指定这个程序在调用时将访问哪些账户。这样一来,交易处理运行时间就可以安排不重迭的交易并行执行,同时保证数据一致性。这是Solana的设计特点之一,使其具有高吞吐量。
程序可以通过CPI调用来调用其他程序。这些调用的工作原理与来自客户端的调用基本相同——调用者程序需要指定被调用者程序将访问的账户,被调用者程序将进行输入检查,就和从客户端调用是一样的(因为它不信任调用者程序)。
PDA账户是一种特殊账户,使程序能在不拥有或储存私钥的情况下提供账户签名。PDA保证只有为其生成PDA的程序可以为其创建一个签名(而其他用户和程序不行)。当一个程序需要通过CPI调用与另一个程序进行交互并提供授权时,这是很有用的(例如,实施一个金库)。PDA保证除了程序之外没有人可以直接访问程序资源。PDA也可用于在确定地址创建账户。
这些是Solana上安全智能合约编程的基本构件。在某种程度上,你可以把Solana程序看作是操作系统中的程序,而账户则是文件,任何人都可以自由执行任何程序,甚至部署自己的程序。当程序(智能合约)运行时,它们将读取和写入文件(账户)。所有文件都可被所有程序读取,但只有对文件有所有权权限的程序才可以对其进行改写。程序也可以执行其他程序,但它们彼此之间没有任何信任——无论谁执行程序,它都需要假设输入是潜在恶意的。由于该操作系统是任何人在全球范围内都访问的,所以在程序中加入了原生签名验证支持,以便为用户实现权限和所有权功能......这不是一个完美的比喻,但还是挺有趣的。
在Move中,智能合约是以模块形式发布的。模块由函数和自定义类型(结构/struct)组成。结构由字段组成,可以是原始类型(u8,u64,bool...)或其他结构。函数可以调用其他函数——可以是同一模块,也可以是其他公开的模块。
在Solana中,这就相当于所有智能合约都作为模块发布在一个程序中。这意味着所有的智能合约(模块)都包含在同一类系统中,可以直接相互调用,而不需要通过中间的API或接口。这一点非常重要,其影响将在本文中彻底讨论。
3.1. 对象
要注意的是,下面的对象概念针对于Move的Sui变体。而在Move的其他集成中(例如Aptos或Diem/core Move),情况可能略有不同。不过,在其他Move变体中也有类似的解决方案,可以实现同样的事情(状态的持久性),这些解决方案并没有太大区别。
这里介绍Sui变体的主要原因是,文章后面的代码样本都基于Move的Sui变体,同时其对象比如core Move中的全局存储机制更直观易懂一点。重要的是,本文所讨论的Move的所有主要优点都适用于所有Move集成(原生支持Move字节码),包括Aptos。
对象是由运行时存储的结构实例(struct instance),并在事务中持续保持状态。
有三种不同类型的对象(在Sui中):
自有对象是属于用户的对象。只有拥有该对象的用户才能在交易中使用它。所有权元数据是完全透明的,由运行处理。它使用公钥加密技术实现——每个自有对象都与一个公钥关联(运行时存储在对象元数据中),任何时候你想在交易中使用对象,你都需要提供相应签名(现在支持Ed25519,即将支持ECDSA和K-of-N多签名)。
共享对象类似于自有对象,但它们没有一个与之相关的所有者。因此,你不需要拥有任何私钥就可以在交易中使用它们(任何人都可以使用它们)。任何自有对象都可以被共享(由其所有者),一旦一个对象被共享,它将永远保持共享——永远不能被转移或再次成为自有对象。
不可变对象是不能被改动的对象。一旦一个对象被标记为不可变,它的字段就不能再被修改。与共享对象类似,这些对象没有所有者,可以被任何人使用。
Move编程模型非常直观和简单。每个智能合约是一个模块,由函数和结构定义组成。结构在函数中被实例化,并可以通过函数调用传递到其他模块。为了使一个结构能够在跨交易中保持持久,我们把它变成一个可以被拥有、共享或不可改变的对象(仅限于Sui,在其他Move变体中略有不同)。
我们已经看到,在Move:
问题是,这种做法为什么是安全的?是什么阻止了人们发布恶意模块,获取共享对象(如AMM池),并将其发送到恶意模块中,然后继续耗尽其资金?
在Solana中,有一个账户所有权的概念,也就是说只有拥有账户的程序才被允许对其进行改动。但是在Move中,没有模块拥有对象的概念,你可以将对象发送到任意的模块中——不仅可以引用对象、整个对象,也可以引用其本身价值。而且,运行时也没有具体检查,以确保这个对象在通过不受信模块时没有被非法修改。那么,是什么在保护这个对象的安全?如何保证这个对象不被不可信的代码滥用?
这就是Move的新颖之处......让我们来谈谈资源。
4.1. 结构
定义一个结构(struct)类型和你所期望的差不多:
到目前为止还不错——这也是你在Rust中定义一个结构的方式。但在Move中,结构有其独特之处与传统编程语言相比,Move模块在如何使用类型上拥有更多空间。在上面的代码片断中定义的结构将受以下限制:
这意味着,如果在其他模块的函数中处理这个结构的实例,我们将无法改动其字段、克隆它、将其存储在其他结构的字段中,或将其丢弃(必须通过函数调用将其传递到其他地方)。情况是这样的:该结构的模块实施了能从我们模块中调用的函数,来完成这些事情。但除此之外,我们无法直接为外部类型做这些事情。这使模块可以完全控制如何使用和不使用其类型。
由于这些限制,我们似乎失去很多灵活性。这也是事实——在传统编程中,处理这样的结构会非常麻烦,但事实上,这正是我们在智能合约中想要的。智能合约开发毕竟是关于数字资产(资源)的编程。如果你看一下上面描述的结构,这正是它的本质——它是一种资源。它不能随意被凭空创造,不能被复制,也不能被意外地销毁。因此,我们确实在这里失去了一些灵活性,但我们失去的灵活性正是我们所希望的,因为这使对资源的操作变得直观而安全。
此外,Move允许我们通过向结构添加能力(capability)来放宽其中一些限制。有四种能力:键、存储、复制和删除。你可以将这些能力的任何组合添加到一个结构中。
下面是它们的作用:
从本质上讲,Move中的每个结构都是默认的资源。能力给了我们权力,可以精细地放宽这些限制,使其表现得更像传统结构。
4.2. 币(Coin)
币在Sui中实现了类似ERC20/SPL代币的功能,是Sui Move Library的一部分。它的定义是这样的:
你可以在Sui代码库中找到完整的模块实现(链接)。
币类型具有键和存储的功能。键意味着它可以作为一个对象使用。这允许用户直接拥有币(作为一个顶层对象)。当你拥有一个币时,除你之外,其他人甚至不能在交易中引用它(更不用说使用它)。存储意味着,币可以作为一个字段嵌入到另一个结构中,这对于可组合性很有用。
由于没有丢弃功能,币不能在函数中被意外丢弃(销毁)。这是一个非常好的特性——它意味着你不会意外地丢失一个币。如果你正在实现以接收硬币为参数的函数,在函数结束时,你需要明确地对它做一些事情——把它转移给用户,把它嵌入另一个对象,或者通过调用把它送入另一个函数(同样需要对它做一些事情)。当然,通过调用币模块中的coin::burn函数来销毁一个币是可能的,但你需要有目的地这样做(你不会这样意外操作的)。
没有克隆能力意味着没有人可以复制币,从而凭空创造新的供应。创造新的供应可以通过coin::mint函数来完成,而且只能由该币的国库能力对象(treasury capability)的所有者调用。
另外,由于泛型(generics)的存在,每个不同的硬币都是独特类型。由于两个币只能通过coin::join函数加在一起(而不是直接访问它们的字段),这意味着不可能把不同类型的币值加在一起(币A+币B)——因为没有这种签名的函数。类型系统能够保护我们免受坏账影响。
在Move中,资源的安全性由其类型定义。考虑到Move有全局类型系统,这使编程模型更自然和更安全,资源可以直接传入和传出不受信任的代码。
4.3. 字节码验证
如前所述,移动智能合约是作为模块发布的。任何人都被允许创建并上传任何任意模块到区块链上,由任何人执行。我们也已经看到,Move对结构体的使用方式有一定规则。
那么,是什么保证了这些规则被任意模块所遵守?是什么阻止了人们上传具有特殊制作字节码的模块,例如接收一个币对象,然后直接改变其内部字段来绕过这些规则?通过这样做,可以非法地增加所有币的数量。光是字节码的语法就肯定允许这样做。
字节码验证可有防止这种类型的滥用。Move验证器是一个静态分析工具,它分析Move字节码并确定它是否遵守所需的类型、内存和资源安全规则。所有上传到链上的代码都需要通过验证器。当你试图上传一个Move模块到链上时,节点和验证器将首先通过验证器运行,然后才允许提交。如果任何模块试图绕过Move的安全规则,它将被验证器拒绝,并且不会被发布。
Move字节码和验证器是Move的核心创新之处。它实现了一个以资源为中心的直观编程模型,在其他处是无法实现的。最关键的是,它允许结构化类型跨越信任边界而不失去其完整性。
在Solana上,智能合约是程序,而在Move中,它们是模块。这似乎只是一个语义上的差异,但事实并非如此,具有重大意义。区别在于,在Solana上,跨程序边界是没有类型安全的——每个程序通过手动从原始账户数据解码来加载实例,这需要手动进行关键的安全检查,也没有本地资源安全。相反,资源安全必须由每个智能合约单独实现。这确实能够实现足够的可编程性,但与Move的模式相比,它在很大程度上阻碍了可组合性和人机工程学,因为Move的模式对资源有原生支持,它们可以安全地流入和流出不信的代码。
在Move中,类型确实存在于各个模块中——类型系统是全局的。这意味着不需要CPI调用,账户编码/解码,账户所有权检查等——你只需直接调用另一个模块中的函数与参数。整个智能合约的类型和资源安全由编译/发布时的字节码验证来保证,不需要像Solana那样在智能合约层面上实现,然后在运行时检查。
现在我们已经看到了Move编程如何工作,根本安全的原因。那么让我们从可组合性、人机工程学和安全性的角度深入了解一下这对智能合约编程有什么样的影响。在这里,我将把Move/Sui的开发与EVM和Rust/Solana/Anchor进行比较,以帮助理解Move的编程模型所带来的好处。
5.1. 闪电贷
闪电贷是DeFi中的一种贷款类型,贷款金额必须在借入的同一交易中偿还。这样做的主要好处是,由于交易是原子性的,贷款可以完全没有抵押。这可以用来在资产之间进行套利,而不需要有本金。
实现这一目标的主要困难是——你如何从闪电贷智能合约中,保证贷款金额将在同一交易中得到偿还?为了使贷款无需抵押,交易需要是原子性的——也就是说,如果贷款金额没有在同一交易中被偿还,整个交易需要失败。
EVM有动态调度,所以可以使用重入性(reentrancy)来实现这一点,如下所示:
这很好地实现了所需的功能,但问题是,它依赖于可重入性,我们非常希望它不要出现在在智能合约编程中。因为可重入性本质上非常危险,是许多漏洞的根本原因,包括臭名昭著的DAO黑客袭击。
Solana在这方面做得更好,因为它不允许重入。但是,如果没有可重入性,如果闪电贷款智能合约无法回调到自定义智能合约,如该何在Solana上实现闪电贷款?多亏了指令自省( instruction introspection)。在Solana上,每个交易由多个指令(智能合约调用)组成,从任何指令中你都可以检查同一交易中存在的其他指令(它们的程序ID、指令数据和账户)。这使得实现闪存贷款成为可能,具体如下:
这个解决方案足够好,但仍不理想。指令自省在某种程度上是一个特例,在Solana中并不常用,它的使用要求开发者掌握大量概念,其实现本身也有很大技术要求,因为有一些细微差别需要适当考虑。还有一个技术上的限——-偿还指令需要静态地存在于交易中,因此不可能在交易执行期间通过CPI调用动态地调用偿还。这并不是什么大问题,但在与其他智能合约整合时,它在一定程度上限制了代码的灵活性,也将更多复杂性推向客户端。
Move也禁止动态调度和重入,但与Solana不同的是,它有一个非常简单和自然的闪电贷解决方案。Move的线性类型系统允许创建结构,保证在交易执行过程中正好被消耗一次。这就是所谓的 “烫手山芋”(Hot Potato)模式——一个没有键、存储、删除或克隆功能的结构。实现这种模式的模块通常会有一个实例化结构的函数和一个销毁结构的函数。由于”烫手山芋”结构没有丢弃、键或存储功能,因此可以保证它的销毁(destroy)函数能被调用,以此来消耗它。尽管我们可以将其传递给任何模块中的任何其他函数,但最终它还是需要在销毁函数结束。因为没有其他方法来处理它,而且验证器要求在交易结束时对它进行处理(它不能被任意丢弃,因为没有丢弃功能)。
让我们看看如何利用这一点来实现闪电贷。
Move的资源安全特性使Move中的闪电贷成为可能,而无需使用重入或自省。它们保证了收据不能被不受信任的代码所修改,并且它需要在交易结束时被返回给还款函数。这样,我们可以保证在同一个交易中返回正确的资金数额。
该功能完全使用基本的语言原语实现,Move的实现不会像Solana的实现那样受到集成问题的影响,因为后者需要交易是精心设置的。没有任何复杂性被推到客户端。
闪电贷很好展示Move的线性类型系统和资源安全保障如何使我们以其他编程语言无法实现的方式去表达功能。
5.2. 铸币权限锁(Mint Authority Lock)
“铸币权限锁”智能合约扩展了代币铸造的功能,允许多个白名单方(authority)铸造代币。该智能合约的所需功能如下(同时适用于Solana和Sui的实现):
这个智能合约可用于,例如将代币的铸币能力交给其他用户或智能合约,而原来的铸币权限方(管理员)仍然保留对铸币的控制权。不然,我们将不得不把铸币的全部控制权交给另一方,这并不理想,因为我们只得相信它不会滥用该权力。而且给多方提供许可也是不可能的。
这些智能合约的完整实现可以在这里(Solana)和这里(Sui)找到。
注意:请不要在生产中使用这段代码! 这是示例代码,仅用于教育目的。虽然我已经测试了它的功能,但我还没有做彻底的审计或安全测试。
现在让我们来看看这些代码,看看实现方式有什么不同。下面是这个智能合约的完整Solana和Sui实现的并排代码截图。
可以注意到的是,对于相同的功能,Solana的实现的规模是Sui的两倍多(230 LOC vs 104)。这是一个大问题,因为更少代码通常意味着更少错误和更短开发时间。
那么,Solana的这些额外行数是怎么来的呢?如果我们仔细看Solana的代码,我们可以把它分为两个部分——指令实现(智能合约逻辑)和账户检查。指令实现与我们在Sui上的情况比较接近-——Solana136行,Sui上104行。额外的行数源于两个CPI调用的引用(每个大约10个LOC)。最重要的差异是在账户检查(在上面的截图中标为红色的部分),这在Solana上是必须的(事实上是关键的),但在Move中不是。帐户检查占这个智能合约的大约40%(91 LOC)。
Move不需要账户检查。LOC的减少能够带来利处,但同时去除做账户检查也十分必要。因为事实证明,正确实施这些检查是非常棘手的,如果你在犯了哪怕一个错误,往往会导致重大漏洞和用户资金的损失。事实上,一些最大的(就用户资金损失而言)Solana智能合约漏洞就是由不当的账户检查引起的账户替换攻击。
-Wormhole(3.36亿美元) - https://rekt.news/wormhole-rekt/
-Cashio (4800万美元) - https://rekt.news/cashio-rekt/
-Crema Finance (880万美元) - https://rekt.news/crema-finance-rekt/
那么,Move是如何做到没有这些检查又同样安全的呢?让我们仔细看看这些检查的实际作用。这里是mint_to指令所需的账户检查(权限持有人通过调用这个指令来铸造代币):
有6个检查(用红色标出):
1. 检查所提供的锁账户是否为该智能合约所拥有,并且是MintLock类型的。需要传入锁,因为要用于CPI调用,到代币程序进行铸币(它存储了权限)。
2.检查所提供的铸币权限账户是否属于所提供的锁。铸币权限账户持有权限状态(它的公钥,它是否被禁止,等等)。
3.检查指令调用者是否拥有该权限的所需密钥(所需权威签署了该交易)。
4.需要传入代币目标账户,因为代币程序将在CPI调用中更改它(增加余额)。铸币检查在此处并不是严格必要的,因为如果传入了错误账户,CPI调用就会失败,但这个检查还是很好的做法。
5.与4类似。
6.检查代币程序账户是否被正确传入。
但在Move中,没有账户检查或类似的东西,只有功能签名:
mint_balance函数只需要四个参数。在这四个参数中,只有lock和cap代表对象(有点类似于账户)。
在Solana中,我们需要声明6个账户,并手动实现对它们的各种检查,而在Move中,我们只需要传入2个对象,而且不需要明确的检查,这是如何实现的?
在Move中,这些检查有些是由运行透明地完成的,有些是由验证器在编译时静态地完成的,而有些则是在构造上根本不需要的。
账户所有权检查——Move有类型系统,因此这种设计不必要。一个Move结构只能通过其模块中定义的函数进行改动,而不能直接改动。字节码验证保证了结构实例可以自由地流入不受信任的代码(其他模块)而不被非法改动。
账户类型检查——没有必要,因为Move类型存在于整个智能合约中。类型定义被嵌入到模块二进制文件中(在区块链上发布并由虚拟机执行)。验证器将检查,编译/发布期间,我们的函数被调用时,正确的类型是否被传递。
账户实例检查——在Move中(有时在Solana上也是如此),你会在函数主体中做这件事。在这个特例中,这是没有必要的,因为锁和cap参数类型的通用类型参数T强制要求对cap(铸币能力/权限)对象的传入要正确匹配其锁(每个币类型T只能有一个锁)。
帐户签名检查——我们在Sui中不直接处理签名。对象可以由用户拥有。造币权限由造币权限能力对象的所有权授予(由管理员创建)。在mint_balance函数中传递对该对象的引用将允许我们进行铸币。自有对象只能由其所有者在交易中使用。换句话说,对象的签名检查是由运行透明地完成的。
从本质上讲,Move利用字节码验证,以使数字资产的编程模型更加自然。Solana的模型围绕账户所有权、签名、CPI调用、PDA等。但我们退一步想一想,就会发现,我们并不想处理这些问题。它们与数字资产本身没有任何关系——相反,我们不得不使用它们,因为这使我们能够在Solana的编程模型中实现所需功能。
在Solana上,没有字节码验证来保证更细化的类型或资源安全,你不能允许任何程序改动任何账户,所以引入账户所有权的概念是必要的。由于类似原因(没有跨程序调用的类型/资源安全),也没有可以进出程序的用户所有对象的概念,相反,我们用账户签名来证明权限。由于有时程序也需要能够提供账户签名,所以我们有PDA......
虽然你可以在Solana上拥有与Move相同的跨程序类型和资源安全,但你必须使用低级别的构建模块(账户签名、PDA...)手动实现它。归根结底,我们正在用低级别的基元来构建可编程的资源(线性类型)。而这就是账户检查的作用——它们是实现类型安全和手动建模资源需进行的开支。
Move对资源进行原生的抽象,允许我们直接处理资源,而不需要引入任何低级的构建块,如PDA。跨越智能合约边界的类型和资源安全保障是由验证者确保的,不需要手动实现。
5.3 Solana可组合性的局限性
我想再举一个例子,强调Solana上智能合约可合成性的一些痛点。
我们在铸币权限锁的例子中看到,与Sui相比,我们需要在Solana上声明更多的输入(Solana上的6个账户 vs. Sui上的2个对象的mint_to调用)。显然,处理6个账户比处理2个对象更麻烦,特别是如果考虑到还需要为账户实现账户检查。理论上来说这部分是可控的,但当我们开始在单一的调用中把多个不同智能合约组合在一起时会发生什么?
假设我们想创建一个智能合约,能够做以下事情:
它从铸币权限锁程序中拥有某个代币的铸币权,可以进行铸币
当它被调用时,它将使用其权限来铸造用户指定数量的代币,使用AMM将其交换为不同的代币,并在同一指令中将其发送给用户
这个例子的重点是为说明铸币权限锁智能合约和AMM智能合约将如何被组合在一起。指令调用的账户检查可能看起来像这样:
17个账户。每个CPI调用(铸币和交换)5-6程序,加上程序账户。
在Sui上,一个相当的函数的签名是这样的:
只有3个对象。
为什么我们在Sui上传递的对象与Solana上的账户相比要少得多(3比17)?从根本上说,是因为在Move中我们能够嵌入(包裹)它们。类型系统的安全保障使我们能够做到这一点。
下面是一个Solana账户和Sui对象之间的比较,它们持有一个AMM池的状态。
我们可以看到,在Solana上我们存储了其他账户的地址(Pubkeys),它们就像指针一样,并不存储实际的数据。为了访问这些账户,它们需要被单独传入,我们还需手动检查正确的账户是否被传入。在Move中,我们能够将结构相互嵌入并直接访问其值。我们可以混合和匹配来自任何模块的类型,同时它们保留其资源和类型的安全保证,这都得益于Move的全局类型系统和资源安全,它们都由字节码验证所驱动。
但是,在组成多个智能合约时,不得不传递(并因此检查)许多账户,这造成了相当大的实施复杂性,并具有安全影响。这些账户之间的关系可能相当错综复杂,在某种程度上,难以跟踪所有必要的账户检查及其是否正确实施。
其实,这就是我认为在Cashio漏洞中发生的情况(4800万美元)。下面是该(不充分)账户检查的分解,也由此导致了该漏洞。如你所见,这些账户检查变得有些复杂。开发者充满好的意图进行正确检查,但在某某程度上,精神压力变得太大,就会非常容易出错。账户越多,越容易出现错误。
Move的全局类型系统和更自然的编程模型,意味着我们可以在达到心理承受压力的极限之前,以更大的安全性推动智能合约的构成。
附带说明一下,Move的TCB(可信计算基础)要比Rust/Anchor小得多。较小的TCB意味着需要进入智能合约编译执行、被信任的的组件较少。这就减少了可能影响智能合约的漏洞表面积——TCB之外的漏洞不会影响智能合约的安全。
Move的设计考虑到了减少TCB——为尽可能减少TCB,Move做了许多决定。字节码验证器将许多由Move编译器执行的检查从TCB中移除,而在Rust/Anchor中,有更多的组件需要被信任,因此致命安全错误的表面积要更大。
我们能否在Solana上拥有Move,以及如何拥有?
6.1. 有全局类型安全的Anchor?
在我们开始研究之前,让我们简单看看Anchor,并做个小的思想实验。也许我们可以以某种方式升级Anchor,来提供我们从Move中得到的一些好处?也许我们可以获得对跨程序调用的类型安全的本地支持?毕竟,Anchor指令已经类似于Move的入口函数:
也许我们可以延伸Anchor,使账户能直接被传入指令参数。
我们可以避免账户检查?
在这种情况下,我们希望类型检查由运行而不是程序来完成——运行将读取Anchor账户判别器(或其等价物),并能够检查账户传入是否符合所需的判别器(Anchor账户的前8个字节)。
Solana不对同一程序的不同指令调用进行区分,这是由程序手动实现的(在这种情况下,繁重的工作由Anchor完成)。因此,为了做到这一点,运行必须以某种方式了解不同指令、它们的签名、类型信息。
Solana程序编译为SBF(Solana Bytecode Format,eBPF的一种变体),并以这种方式上传到链上(和执行)。SBF本身并没有嵌入任何可以帮助我们的类型或函数信息。但也许我们可以修改SBF,以允许指令和类型信息被嵌入二进制文件中?这样所需的指令和签名信息就可以由运行从二进制文件中读取。
我们确实可以这样做。这将要求相当大的工程量,特别是考虑到我们需要保持与旧程序的向后兼容,但这是我们能获得的好处:
-账户所有权和类型检查由运行而不是程序完成
-对于在编译时已知地址的账户(例如程序账户),我们可以避免从客户端传入它们,现在可以由运行传入。
-如果我们设法将账户约束嵌入到二进制文件中,我们可以进一步减少必须由客户端传入的账户数量,用运行对其进行动态递归在加载(基于嵌入的约束信息)。
我们仍然没有得到:
-嵌入的账户。我们仍然必须使用Pubkeys来引用其他账户,而不能够直接嵌入它们。这意味着我们没有摆脱第5.3节中描述的账户臃肿的问题。
-当进行跨程序调用时,账户类型检查仍然需要在运行时动态进行,而不是像Move中那样在编译时静态进行。
注意:这只是一个思想实验。并不说明其可以安全完成,也不是代表其实现困难,更不标榜这些好处值得付出工程量般的努力。
这些好处确实不错,但从智能合约开发的角度来看,它们并没有从根本上改变什么。在运行时而不是程序中做类型检查可能能带来一些性能上的好处,而且不必在编译时从客户端手动传递地址账户,在一定程度上提升工效(这也可以通过工具化来缓解)。但我们最终仍然在处理Solana的编程模型,它本身在处理数字资产上提供更多帮助——我们仍然没有原生的资源安全,我们不能嵌入账户,所以仍然有账户膨胀问题,我们仍然在处理账户签名和PDA......
理想情况下,我们希望所有的智能合约都生活在一个单一的类型系统中,并且能够像Move那样自由地将对象传入传出。但由于其他智能合约不能被信任,我们不能直接这样做。为了绕过这一点,Solana设有程序分离和账户所有权——每个程序管理自己的账户,它们通过CPI调用进行互动。这很安全,并允许足够的可编程性,但由此产生的编程模型并不理想——没有全局类型系统,也就没有有实质意义的资源安全。
我们希望有一个自然的编程模型,但与此同时,我们也在处理不受信任的代码。虽然在Solana上我们可以安全地处理不受信代码,但在编程模型上上做出妥协。字节码验证使我们有可能同时拥有两者。没有它,我们似乎真的无法改善编程模型。
6.2 Solana字节码格式
如前所述,SBF(Solana字节码格式),即Solana智能合约的编译和链上存储格式,是基于eBPF的。在Solana上使用eBPF而不是任何其他字节码格式(如WASM),主要是因为Solana对安全和高性能智能合约执行的要求,与eBPF设计的内核沙盒程序执行要求一致(它也需要安全和高性能)
从表面上看,eBPF确实是一个可靠的选择。高性能、围绕安全设计,程序的大小和指令的数量是有限的,有一个字节码验证器......看起来很有不错。
但让我们看看这在实践中意味着什么。也许我们可以以某种方式利用eBPF验证器来提高我们智能合约的安全性?以下是eBPF验证器所做的一些事情:
-不允许无限循环
-检查程序是否是一个DAG(有向无环图)
-不允许越界跳转(out-of-bounds jump)
-在进行各种辅助(helper)函数调用时检查参数类型(辅助函数在内核中进行定义,例如用于修改网络数据包)。
好吧,禁止越界跳转似乎很有用,但其他作用有限。事实上,强制要求程序必须是一个DAG并且没有无限循环是有问题的,因为它大大限制了程序的可操作性(我们没有图灵完备性)。在eBPF程序中需要这样做的原因是,验证器需要确定程序在一定数量的指令内终止(这样程序就不会使内核终止;这就是著名的停机问题),而气体计量(gas metering)不是一个选项,因为它将太过影响性能。
虽然这种取舍对实现高性能的防火墙来说是很好的,但对于智能合约的开发来说就不那么好了。eBPF验证器的绝大部分都不能被重用在Solana程序上。事实上,Solana根本就没有使用原始的eBPF验证器,它使用的是一个(更基本的)自定义验证器,主要是检查指令是否正确和是否有越界跳转。
同时,eBPF在设计上最多允许5个参数被传递给一个函数进行调用。这意味着Rust标准库不能直接编译到eBPF。或栈的大小被限制在512字节,这减少了我们可以传递给一个函数的参数的大小而不需要堆分配(heap allocation)。
因此,即使Rust编译到LLVM,有LLVM的eBPF后端,甚至支持Rust编译器针对eBPF使用,你仍然无法使Solana智能合约以其本来的样子编译到eBPF上。这就是为什么Solana团队不得不对Rust代码库和eBPF LLVM后端(例如,通过栈传递参数)进行多次修改。
由于其中一些修改本身是支持上游(无论是Rust还是LLVM),所以Solana团队目前在维护Rust和LLVM的分叉时都做了这些修改。当你执行cargo build-bpf(构建Solana智能合约的典型命令)时,Cargo会拉出这个Solana特定版本的rustc(Rust编程语言的编译器)来进行智能合约的编译(原来的rustc不起作用)。
这就是SBF的诞生过程——Solana需要的一些要求与eBPF不兼容。Solana团队目前正在努力将SBF作为一个独立的LLVM后端上流,并将其作为一个Rust目标加入,以避免维护单独分叉。
因此,虽然eBPF可以作为智能合约的一种格式,但它并不像表上看起来那么理想。它需要进行一些修改,而且原来的验证器也没有很大的用处。
在关于Move和Solana/SBF的讨论中,一个误解就是,一些人认为Move的主要思想应该适用于SBF,因为它是基于eBPF的,也许可以利用其验证器做静态的账户改动检查,而不是在运行时做动态检查。
在我看来,这是一个令人怀疑的说法。即使有可能证明,程序不会在eBPF中改动他们不拥有的账户,这也确实是Move在做的事情,但这肯定不是Move的主要想法。
Move的主要思想是创造一个以资源为中心的编程模型,能够自然地与不可信代码互动。
在实践中,这意味着:
将主要的Move思想引入eBPF/SBF非常难。如果不对eBPF进行重大修改,强制执行一些特性比如“这个不受信任的代码不能丢弃一个T”是不可能的。这需要大量修改,以至于你最终会得到一个新的字节码,它看起来更像Move而不是eBPF。
事实上,类似的思路是导致Move诞生的首要原因。Move团队(当时在Diem)最初考虑从其他格式出发,如WASM、JVM或CLR,但事后添加这个实在是太难了——线性/能力是非常规的。所以Move是从头开始设计的,其想法是通过轻量级的验证器通道来有效执行这些检查。
如果你仔细想想,这其实并不令人惊讶。毕竟最终,智能合约编程不是系统编程,后端编程,或任何一种其他传统编程,它是一种完全不同的编程类型。所以现有字节码和指令格式的功能不能被利用也就不足为奇了,因为它们在设计时考虑的是完全不同的使用情况。
我不是在批评Solana使用eBPF。事实上,我认为这是一个非常可靠的选择,也是团队考虑到背景的良好判断。事后来看,团队可能会选择WASM而不是eBPF,这样就可以避免前面提到的将智能合约编译成eBPF的问题,因为WASM在Rust中有一流的支持(不过WASM可能会有其他问题),但可以看到,考虑到对性能的强调,团队可能觉得eBPF是一个更安全的选择。另外,在做出这些设计选择的时候,Move甚至还没有进行宣布,对于一个初创公司来说,从头开始创建一种新语言肯定不是一个合理的选择。最终,Solana设法提供了一个成功的高性能L1,这才是最重要的。
有三种方法可以在Solana上获得Move:
让我们先来讨论(3)。这里的想法是为Move建立一个LLVM前端,以便将其编译为SBF。编译成SBF的Move智能合约被透明地执行,就像用Rust(或其他任何可以编译成SBF的语言)构建的智能合约一样,而且运行时不需要对Move有任何区分或了解。从运行角度来看,这将是一个非常优雅的解决方案,因为它不需要改变它或它的安全假设。
但我认为以这种方式开发智能合约会比直接使用Anchor更糟。你通过(3)得到的是Solana编程模型中的Move语法。这意味着第五章中讨论的Move的所有重要优势(全局类型安全、全局资源安全、可嵌入对象......)将不复存在。相反,我们仍将不得不处理账户检查、CPI调用、PDA等问题,就像在Rust中一样。而且,由于Move不支持宏(macro),因此使用eDSL实现一个像Anchor这样的框架,来简化其中的一些工作是不可能的,所以代码将与原始Rust相似(但可能更糟糕)。Rust标准库和生态系统也是不可用的,所以像账户序列化和反序列化这样的事情必须在Move中重新实现。
Move不是很适合与其他编程模型一起使用。这是因为它被特别设计为能够编译成Move字节码,并通过验证器。考虑到围绕能力和借贷检查器的自定义规则,这是必要的。其字节码验证十分特殊具体,以至于其他语言几乎没有机会编译成Move字节码并通过验证器。因为Move围绕这种非常特殊的字节码验证而设,所以它不像Rust等语言那样灵活。
剥离字节码就放弃了Move的所有主要优势。虽然Move的类型、资源和内存安全特性会在程序级别上被保留,但它们不会被全局保留。而程序级的安全并没有带来多少新的结果——通过Rust我们已经实现了这些结果。
Move的智能合约生态系统也不能在Solana上使用——编程模型不同,以至于智能合约的重要部分必须被重写。考虑到所有这些,我预计用(3)实现Move的做法不会被接受。
至于(1),这里的想法是(与SBF加载器一起)在运行时添加对Move加载器的支持。Move智能合约将被存储为链上的Move字节码,并由Move VM执行(就像在Sui中一样)。这意味着我们将有一个SBF智能合约的生态系统和一个Move智能合约的生态系统,前者将在当前的Solana编程模型上运行,而后者则在一个(可以说是更高级的)Move模型上运行。
有了这种方法,就有可能保持Move智能合约之间相互作用的所有好处,但这里的一个挑战是让Move智能合约能够与SBF智能合约进行互动,反之亦然——你需要一个对Move和Solana有深刻理解的人,验证器也必须进行调整。
还有一个缺点是需要在运行时维护两个不同的加载器。这会对安全有影响,因为它意味着攻击面会翻倍——任何一个加载器的错误都可能意味着整个链被利用。实际上早在2019年,Solana就加入了对MoveVM的早期支持(#5150),但后来由于安全问题而被移除(#11184)。
至于(2),想法是将整个Move VM作为一个Solana程序(智能合约)运行。Move VM是用Rust实现的,所以可能会把它编译成SBF(除非它使用线程或其他不支持的API)。虽然这听起来很疯狂,但Neon已经实现了类似的方法,将EVM作为一个Solana程序来运行。这种方法的好处是,不需要对运行进行修改,而且可以保持相同的安全假设。
我不熟悉Move VM的技术细节,所以我不能对这种做法的可行性以及它的局限性做太多评论。我的第一个反应是,验证器也必须作为一个程序运行,这意味着在计算预算内。这种方法也会像(1)一样,受到SBF和Move智能合约之间互操作性问题的影响。
没有直接的方法可以将Move的主要功能带到Solana。虽然有可能建立一个LLVM前端,并将Move编译为SBF,但这不会起太多作用,因为编程模型将保持不变。正如第6.1节中的思想实验所说明的那样,如果没有某种字节码验证,就无法改善编程模型。改变eBPF/SBF以支持字节码验证将是非常困难的。似乎唯一合理的选择就是以某种方式让MoveVM运行。但这意味着将有两个生态系统在不同的编程模型上运行,而让它们正确地互操作是极具挑战性。
6.4. Move的性能
Move的字节码不是一种通用的字节码语言。它有一个非常有“主见”的类型系统,为允许所有必要验证,它是相当高级的。这意味着与其他字节码格式(如eBPF/SBF)相比,其性能较低,因为后者更接近于本地代码,人们可能会认为这对于其在高性能L1中的使用是一个问题。
但是,到目前为止,智能合约的执行在Solana(在写这篇文章的时候,平均有3k TPS)和Sui(基于团队所做的最初e2e基准)上都还未成为瓶颈。提高交易处理性能的主要方式就是并行执行。Solana和Sui都实现了这一点,它们要求事先声明依赖关系,并对依赖不同对象/账户集的事务执行进行并行调度。
此外,一旦TX执行出现在关键路径上,没有任何东西可以阻止Move被AOT编译或JIT化以提高性能。这就是为Move构建一个LLVM前端的好处所在。另外,由于Move本身对静态分析的适应性,Move也可能取得特有的进一步优化。
考虑到所有这些,我希望Move的性能在可预见的未来不会成为一个重要的障碍。
7.1. 验证器
Move有一个用于智能合约的形式化验证工具,叫做Move Prover。通过这个工具,你能够判断不同的不变量对你的智能合约是否成立。在幕后,验证条件被翻译成SMT公式,然后使用SMT求解器进行检查。这与模糊测试有很大不同,例如,模糊测试是通过走查输入空间来试错。例如,如果模糊测试和单元/集成测试未能测试出特定的输入或输入组合,显示程序有误,那么它们仍然可以提供一个假阳性。另一方面,验证器本质上提供了形式上的证明,即指定的不变量对所提供的程序成立。这就像针对所有可能的输入检查程序一样,但不需要这样做。
移动验证器的速度非常快,使它可以像类型检查器或linter那样被整合到常规开发工作流程中。
下面是一个验证器的例子(摘自《用Move Prover对智能合约进行快速可靠的形式验证白皮书》)。
7.2. 钱包安全
由于Sui要求所有交易将访问的对象都在函数参数中传递(不从全局状态中动态加载),并且移动函数签名连同类型信息都存储在字节码本身中,我们可以让钱包在用户签名之前向用户提供更有意义的信息,说明交易的内容。
例如,如果我们有一个具有以下签名的函数:
我们可以从函数的签名中看出,这个交易将访问用户的3个资产(资产类型)。不仅如此,根据&和&mut关键字(或没有关键字),我们还可以知道资产1可以被读取,资产2可以被改动(但不能转移或销毁),而资产3有可能被改动、转移或销毁。
钱包可以向用户显示这些信息,然后用户可以更加了解交易可能对资产做出什么动作。如果有什么异样,例如,来自Web3应用程序的交易调用正在接触一些不应接触的资产或币,用户可以观察到这一点,决定不继续进行交易。
钱包也可以另外模拟交易,这将给用户提供更多关于其结果的信息。Sui编程模型以对象为中心,类型信息对运行原生,这意味无需对智能合约有任何具体的应用级知识,就能解释对象的变化。
这在Solana上是不可能的,因为从运行的角度来看,账户包含任意数据。你需要账户的外部描述(特定于应用程序)才能对其进行解释,而智能合约发布者未必提供这些信息。另外,Solana运行时中不存在资产所有权的概念,每个智能合约都需要手动实现这一语义(通常使用账户签名和PDA),这意味着没有通用方法来对此进行追踪。
7.3 简单交易和复杂交易
具体到Sui,在共识层面上有一个有趣的优化,允许某些类型的交易放弃完全的共识,转而使用基于拜占庭一致广播(Byzantine Consistent Broadcast)的更简单算法。这样的好处是,这些交易可以在共识层面上并行,消除队头阻塞(head-of-line blocking),达成几近即时的最终性——基本上实现了web2的可扩展性。
这是由于Sui对自有和共享对象的区分(见3.1节)。只涉及自有对象的交易(被称为简单交易)不需要在Sui上达成完全共识。由于自有对象除了发送者外不能在交易中使用,且发送者一次只能发送一个交易,这本身就意味着这些交易不需参照其他交易进行排序(总排序与因果排序)——我们知道交易中引用的对象不能被其他交易影响,且该交易也不能影响其他对象。因此,我们并不关心该事务相对于链上平行发生的其他事务的排序——这实际上是不相关的。Sui能够利用这一事实,大大优化简单事务的处理,在几百毫秒内实现最终性。但缺点是,发送者一次只能发送一个交易。另一方面,涉及任何数量共享对象的交易(被称为复杂交易),总是需要完全共识。
考虑到自有对象的创建、转移和修改可以完全通过简单事务完成,某些类型的应用可以很好地利用简单事务。很好的例子是NFT(包括大规模造币)和web3游戏。这些用例从低延迟的最终性和消除对头阻塞中获益良多,实现了更好的用户体验和可扩展性。
但其他类型的应用程序必须依赖复杂交易。这包括大多数DeFi应用程序。例如,AMM流动性池需要成为一个共享对象,因为任何种类的交易所订单执行都需完全共识和总排序。因为从根本上说,如果多个订单同时来自不同用户,我们需要就先执行谁的订单达成一致,这就决定了每个用户会得到什么样的执行价格。
还有一些应用程序可以混合使用简单和复杂交易。这些应用需要复杂交易才能实现它们所需功能,但在某些操作上可以利用简单交易来获得更好效率。例如,一个价格预言机就可以如此设计。我们可以让多个发布者使用简单交易,为市场提交价格数据,然后由一个权威机构使用复杂交易对价格进行汇总(例如,股权加权中值)。在某些时候不依靠复杂交易是不可能实现价格预言机的(根本原因是在其他交易中使用发布价格需要就排序达成一致,从而达成完全共识),但至少我们可以用简单交易优化发布者的写入。
Sui文档有关于简单和复杂交易的更多细节。
https://docs.sui.io/devnet/learn/sui-compared
https://docs.sui.io/devnet/learn/how-sui-works#system-overview
本文深入探讨并比较Solana和Sui的编程模型,也对Move编程语言进行探讨,
第二章是对Solana编程模型的总结,而第三章则介绍了Sui Move及其编程模型。第4章接着解释了Move中的类型和资源安全如何运作。Move功能对智能合约开发的意义无法立竿见影,所以在第5章中,我利用现实生活中的例子对Solana和SuiMove进行了更彻底的比较。第6章讨论了eBPF/SBF,表明让Move功能或Move本身在Solana上工作并不容易。第7章讨论了Sui的一些Move相关功能。
智能合约编程是关于数字资产的编程。可以说这是一种新的编程类型,与我们目前看到的其他类型编程(如系统、后台......)截然不同。正因如此,现有编程语言和编程模型自然不能很好适应这种用例。
问题的关键在于,我们希望有一个编程模型,能够自然地与资源打交道,但同时又与不受信的代码互动。Solana在这里做了妥协,它使智能合约在一个不信任环境中具备了必要的可编程性,但其编程模型对于用资源编程来说并不自然。字节码验证使其有可能同时拥有这两种特性。在某种程度上,它把不受信代码变成了受信代码。
Move是一种用于智能合约开发的新型编程语言。它的核心创新之处在于它的字节码,被特意设计为可被验证。虽然字节码验证本身并不是一个新概念,但Move所做的验证确实是一种创新。通过其字节码和验证,Move实现了一个智能合约编程模型,对资源能够有一流支持,并能保证在一个不受信任的环境中安全编程。
我认为Move对智能合约开发的作用就像React对前端开发的作用一样。说“用Move做的事能用Rust做”就像说“用React做的事能用jQuery做”一样。当然有可能实现基于jQuery的应用,能够与React应用相当,但这并不实际。React引入了虚拟DOM的概念,这对开发者来说是完全易懂的的,但使前台的开发速度更快、可扩展、更简单。同样,Move的字节码验证是一种底层技术,对开发者来说也易于理解,但它提供了一个更符合人体工效学、可组合、更安全的智能合约开发。由于其安全性和更直观的编程模型,Move也大大降低了智能合约开发者的准入门槛。
如果Move能设法获得影响(有早期迹象表明它会的),它可能对Solana构成极大威胁。有两点原因。
首先Move智能合约的开发时间要快得多。在Move中从头开始开发一个智能合约可能比在Rust中快2-5倍。因此,Move生态系统的发展可以超过Solana。由于区块链的开放性和无许可性,不存在严重的锁定效应,流动性可以轻松移动。Solana的开发者可能纯粹因为经济考量而被迫采用Move——要么转到Move,要么被Move的开发者超越,因为他们能更快开发出更安全的智能合约。如果你要雇佣一个智能合约开发者,你可以雇佣一个Rust开发者,能建立一个智能合约,或者雇佣一个Move开发者,能在同样时间内建立两个更安全的智能合约。这类似于React对前端开发的影响。
第二,Move的入门门槛比Rust或Solidity低得多。因为Move语法更简单,编程模型更直观。一些开发人员无法用Rust或Solidity进行智能合约开发,但在Move中可能能够进行。由于需要学习的概念较少,非智能合约开发者进入Move,要比进入Rust(Rust本身就是一种复杂的语言,再加上Solana的概念,如PDA,会给初学者带来很多困惑)或Solidity(你需要熟悉语言中非常精细的细节,如重入,以便能够开发安全的智能合约)容易得多。即使现有Solana和Solidity开发者不转向Move,尚未进入该领域的开发者市场也比该领域现有的开发者数量多出好几个量级。由于Move的准入门槛较低,且开发速度更快,它比Rust或Solidity有更好的产品市场适应性,可以从这块蛋糕中分得更大一杯羹。如果新的开发者开始大量涌入,我希望他们从Move开始,而不是Rust或Solidity。这也类似于React在网络行业的情况。
正因如此,我完全可以预料,在中长期内,Solana会加入对Move进行一流支持。但这并容易。为了获得Move的主要好处,Move字节码需要得到本地支持,这意味着简单地将Move编译成eBPF/SBF是不可能的(见第6.3节)。为了保持现有生态系统,两种运行都需要得到支持。主要的技术挑战是如何在运行之间实现适当的互操。这需要对Move和Solana的深入了解,所以我希望Solana团队能在Move团队的支持下对此进行直接推动。
Move起源于Meta(née Facebook)的Diem项目。Move团队由Sam Blackshear领导,其任务是弄清楚如何处理智能合约。在仔细研究了这个问题后,他们发现智能合约的编程都是关于数字资产(资源),但现有语言都不支持该用例,于是决定从头开始建立一种新的编程语言。
我想强调的是,创建一门新语言并不是突然做出的决定,它需要多年的努力才能落地,因为在大多数情况下,使用现有解决方案会更好。Move团队正确预见到,一种安全、对资源有一流支持、同时又足够灵活的智能合约语言是可以建立的,仅此一点就显示出他们高度的专业性。这是团队和支持该项目的Novi/Meta领导层的一个大胆举动(会有一些董事和副总裁参与)。Meta后来停止了他们在Diem上的努力,并最终没能够收获其在Move上的投资成果。但它为更广泛加密货币社区做出了伟大贡献。
总而言之,Move是一项了不起的技术,我相信它会对我们如何开发智能合约产生巨大影响。
责任编辑:Felix