Time:2022-09-10 Click:311
原文标题:《Smart Contract Development—Move vs.Rust》
原文作者:Krešimir Klas,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的Sui变体。而在Move的其他集成中(例如Aptos或Diem/core Move),情况可能略有不同。不过,在其他Move变体中也有类似的解决方案,可以实现同样的事情(状态的持久性),这些解决方案并没有太大区别。
这里介绍Sui变体的主要原因是,文章后面的代码样本都基于Move的Sui变体,同时其对象比如core Move中的全局存储机制更直观易懂一点。重要的是,本文所讨论的Move的所有主要优点都适用于所有Move集成(原生支持Move字节码),包括Aptos。
对象是由运行时存储的结构实例(struct instance),并在事务中持续保持状态。
有三种不同类型的对象(在Sui中):
自有对象(owned objects)
共享对象(shared objects)
不可变对象(immutable objects)
自有对象是属于用户的对象。只有拥有该对象的用户才能在交易中使用它。所有权元数据是完全透明的,由运行处理。它使用公钥加密技术实现——每个自有对象都与一个公钥关联(运行时存储在对象元数据中),任何时候你想在交易中使用对象,你都需要提供相应签名(现在支持Ed25519,即将支持ECDSA和K-of-N多签名)。
共享对象类似于自有对象,但它们没有一个与之相关的所有者。因此,你不需要拥有任何私钥就可以在交易中使用它们(任何人都可以使用它们)。任何自有对象都可以被共享(由其所有者),一旦一个对象被共享,它将永远保持共享——永远不能被转移或再次成为自有对象。
不可变对象是不能被改动的对象。一旦一个对象被标记为不可变,它的字段就不能再被修改。与共享对象类似,这些对象没有所有者,可以被任何人使用。
Move编程模型非常直观和简单。每个智能合约是一个模块,由函数和结构定义组成。结构在函数中被实例化,并可以通过函数调用传递到其他模块。为了使一个结构能够在跨交易中保持持久,我们把它变成一个可以被拥有、共享或不可改变的对象(仅限于Sui,在其他Move变体中略有不同)。
你可以将你拥有(或共享)的任何对象传递给任何模块中的任何函数
任何人都可以发布一个(潜在的敌对)模块
不存在模块拥有结构的概念,这将使所有者模块拥有改变结构的唯一权力,就像Solana账户的情况一样——结构可以流入其他模块,也可以嵌入其他结构中。
问题是,这种做法为什么是安全的?是什么阻止了人们发布恶意模块,获取共享对象(如AMM池),并将其发送到恶意模块中,然后继续耗尽其资金?
在Solana中,有一个账户所有权的概念,也就是说只有拥有账户的程序才被允许对其进行改动。但是在Move中,没有模块拥有对象的概念,你可以将对象发送到任意的模块中——不仅可以引用对象、整个对象,也可以引用其本身价值。而且,运行时也没有具体检查,以确保这个对象在通过不受信模块时没有被非法修改。那么,是什么在保护这个对象的安全?如何保证这个对象不被不可信的代码滥用?
这就是Move的新颖之处......让我们来谈谈资源。
到目前为止还不错——这也是你在Rust中定义一个结构的方式。但在Move中,结构有其独特之处与传统编程语言相比,Move模块在如何使用类型上拥有更多空间。在上面的代码片断中定义的结构将受以下限制:
它只能在定义该结构的模块中被实例化(“打包”)和销毁(“解包”)——也就是说,你不能从任何其他模块的任何函数中实例化或销毁一个结构实例。
结构实例的字段只能在其模块中被访问(因此也可以被改动)。
不能在其模块之外克隆或复制结构实例
不能将一个结构实例存储在其他结构实例的字段中
这意味着,如果在其他模块的函数中处理这个结构的实例,我们将无法改动其字段、克隆它、将其存储在其他结构的字段中,或将其丢弃(必须通过函数调用将其传递到其他地方)。情况是这样的:该结构的模块实施了能从我们模块中调用的函数,来完成这些事情。但除此之外,我们无法直接为外部类型做这些事情。这使模块可以完全控制如何使用和不使用其类型。
由于这些限制,我们似乎失去很多灵活性。这也是事实——在传统编程中,处理这样的结构会非常麻烦,但事实上,这正是我们在智能合约中想要的。智能合约开发毕竟是关于数字资产(资源)的编程。如果你看一下上面描述的结构,这正是它的本质——它是一种资源。它不能随意被凭空创造,不能被复制,也不能被意外地销毁。因此,我们确实在这里失去了一些灵活性,但我们失去的灵活性正是我们所希望的,因为这使对资源的操作变得直观而安全。
此外,Move允许我们通过向结构添加能力(capability)来放宽其中一些限制。有四种能力:键、存储、复制和删除。你可以将这些能力的任何组合添加到一个结构中。
下面是它们的作用:
键 - 允许一个结构成为一个对象(专属Sui,core Move情况略有不同)。如前所述,对象是持久化的,如果是自有对象,需要用户签名才能在智能合约调用中使用。当使用键能力时,结构的第一个字段必须是具有UID类型的对象ID。这将给它一个全球唯一的ID,能够用它进行引用。
存储 - 允许将该结构作为一个字段嵌入另一个结构中
复制 - 允许从任何地方任意复制/克隆该结构
删除 - 允许从任何地方任意销毁该结构
从本质上讲,Move中的每个结构都是默认的资源。能力给了我们权力,可以精细地放宽这些限制,使其表现得更像传统结构。
你可以在Sui代码库中找到完整的模块实现(链接)。
币类型具有键和存储的功能。键意味着它可以作为一个对象使用。这允许用户直接拥有币(作为一个顶层对象)。当你拥有一个币时,除你之外,其他人甚至不能在交易中引用它(更不用说使用它)。存储意味着,币可以作为一个字段嵌入到另一个结构中,这对于可组合性很有用。
由于没有丢弃功能,币不能在函数中被意外丢弃(销毁)。这是一个非常好的特性——它意味着你不会意外地丢失一个币。如果你正在实现以接收硬币为参数的函数,在函数结束时,你需要明确地对它做一些事情——把它转移给用户,把它嵌入另一个对象,或者通过调用把它送入另一个函数(同样需要对它做一些事情)。当然,通过调用币模块中的coin::burn函数来销毁一个币是可能的,但你需要有目的地这样做(你不会这样意外操作的)。
没有克隆能力意味着没有人可以复制币,从而凭空创造新的供应。创造新的供应可以通过coin::mint函数来完成,而且只能由该币的国库能力对象(treasury capability)的所有者调用。
另外,由于泛型(generics)的存在,每个不同的硬币都是独特类型。由于两个币只能通过coin::join函数加在一起(而不是直接访问它们的字段),这意味着不可能把不同类型的币值加在一起(币A 币B)——因为没有这种签名的函数。类型系统能够保护我们免受坏账影响。
在Move中,资源的安全性由其类型定义。考虑到Move有全局类型系统,这使编程模型更自然和更安全,资源可以直接传入和传出不受信任的代码。
如前所述,移动智能合约是作为模块发布的。任何人都被允许创建并上传任何任意模块到区块链上,由任何人执行。我们也已经看到,Move对结构体的使用方式有一定规则。
那么,是什么保证了这些规则被任意模块所遵守?是什么阻止了人们上传具有特殊制作字节码的模块,例如接收一个币对象,然后直接改变其内部字段来绕过这些规则?通过这样做,可以非法地增加所有币的数量。光是字节码的语法就肯定允许这样做。
字节码验证可有防止这种类型的滥用。Move验证器是一个静态分析工具,它分析Move字节码并确定它是否遵守所需的类型、内存和资源安全规则。所有上传到链上的代码都需要通过验证器。当你试图上传一个Move模块到链上时,节点和验证器将首先通过验证器运行,然后才允许提交。如果任何模块试图绕过Move的安全规则,它将被验证器拒绝,并且不会被发布。
Move字节码和验证器是Move的核心创新之处。它实现了一个以资源为中心的直观编程模型,在其他处是无法实现的。最关键的是,它允许结构化类型跨越信任边界而不失去其完整性。
在Solana上,智能合约是程序,而在Move中,它们是模块。这似乎只是一个语义上的差异,但事实并非如此,具有重大意义。区别在于,在Solana上,跨程序边界是没有类型安全的——每个程序通过手动从原始账户数据解码来加载实例,这需要手动进行关键的安全检查,也没有本地资源安全。相反,资源安全必须由每个智能合约单独实现。这确实能够实现足够的可编程性,但与Move的模式相比,它在很大程度上阻碍了可组合性和人机工程学,因为Move的模式对资源有原生支持,它们可以安全地流入和流出不信的代码。
在Move中,类型确实存在于各个模块中——类型系统是全局的。这意味着不需要CPI调用,账户编码/解码,账户所有权检查等——你只需直接调用另一个模块中的函数与参数。整个智能合约的类型和资源安全由编译/发布时的字节码验证来保证,不需要像Solana那样在智能合约层面上实现,然后在运行时检查。
闪电贷是DeFi中的一种贷款类型,贷款金额必须在借入的同一交易中偿还。这样做的主要好处是,由于交易是原子性的,贷款可以完全没有抵押。这可以用来在资产之间进行套利,而不需要有本金。
实现这一目标的主要困难是——你如何从闪电贷智能合约中,保证贷款金额将在同一交易中得到偿还?为了使贷款无需抵押,交易需要是原子性的——也就是说,如果贷款金额没有在同一交易中被偿还,整个交易需要失败。
EVM有动态调度,所以可以使用重入性(reentrancy)来实现这一点,如下所示:
闪电贷用户创建并上传自定义智能合约,当调用该合约时,将通过调用将控制权传递给闪电贷智能合约
然后,闪电贷智能合约将向自定义智能合约发送请求的贷款金额,并调用自定义智能合约中的executeOperation()回调函数。
然后,自定义智能合约将使用收到的贷款金额来执行它所需要的操作(如套利)。
在自定义智能合约完成其操作后,它需要将借出的金额返回给闪电贷智能合约。
这样,自定义智能合约的executionOperation()就完成了,控制权将返回给闪电贷智能合约,它将检查贷款金额是否已经正确返回。
如果自定义智能合约没有正确返回贷款金额,整个交易将失败。
这很好地实现了所需的功能,但问题是,它依赖于可重入性,我们非常希望它不要出现在在智能合约编程中。因为可重入性本质上非常危险,是许多漏洞的根本原因,包括臭名昭著的DAO黑客袭击。
Solana在这方面做得更好,因为它不允许重入。但是,如果没有可重入性,如果闪电贷款智能合约无法回调到自定义智能合约,如该何在Solana上实现闪电贷款?多亏了指令自省( instruction introspection)。在Solana上,每个交易由多个指令(智能合约调用)组成,从任何指令中你都可以检查同一交易中存在的其他指令(它们的程序ID、指令数据和账户)。这使得实现闪存贷款成为可能,具体如下:
闪电贷款智能合约实现借款(borrow)和还款(repay)指令
用户通过在同一交易中把借款和还款指令的调用堆迭在一起,创建一个闪电贷交易。借款指令在执行时,将使用指令自省检查偿还指令是否安排在同一交易的后期。如果偿还指令的调用不存在或无效,交易将在这个阶段失败。
在借款和还款的调用之间,借来的资金可以被任何其他处于两者之间的指令任意使用。
在交易结束时,还款指令调用将把资金返还给闪电放款人智能合约 (该指令的存在将在借款指令的反思中进行检查)
这个解决方案足够好,但仍不理想。指令自省在某种程度上是一个特例,在Solana中并不常用,它的使用要求开发者掌握大量概念,其实现本身也有很大技术要求,因为有一些细微差别需要适当考虑。还有一个技术上的限——-偿还指令需要静态地存在于交易中,因此不可能在交易执行期间通过CPI调用动态地调用偿还。这并不是什么大问题,但在与其他智能合约整合时,它在一定程度上限制了代码的灵活性,也将更多复杂性推向客户端。
Move也禁止动态调度和重入,但与Solana不同的是,它有一个非常简单和自然的闪电贷解决方案。Move的线性类型系统允许创建结构,保证在交易执行过程中正好被消耗一次。这就是所谓的 “烫手山芋”(Hot Potato)模式——一个没有键、存储、删除或克隆功能的结构。实现这种模式的模块通常会有一个实例化结构的函数和一个销毁结构的函数。由于”烫手山芋”结构没有丢弃、键或存储功能,因此可以保证它的销毁(destroy)函数能被调用,以此来消耗它。尽管我们可以将其传递给任何模块中的任何其他函数,但最终它还是需要在销毁函数结束。因为没有其他方法来处理它,而且验证器要求在交易结束时对它进行处理(它不能被任意丢弃,因为没有丢弃功能)。
让我们看看如何利用这一点来实现闪电贷。
闪电贷智能合约实现了一个“烫手山芋”的收据(Receipt)结构
当通过调用贷款函数进行贷款时,它将向调用者发送两个对象——请求的资金(一个币)和一个收据,是需要偿还贷款金额的记录。
然后,借款人可以将收到的资金用于其需要的操作(如套利)。
在借款人完成其预期的操作后,它需要调用还款函数,该函数将收到借款资金和收据作为参数。这个函数被保证在同一个交易中被调用,因为调用者没有其他办法摆脱收据实例(它不允许被丢弃或嵌入到另一个对象中,这是验证器所要求的)。
还款函数通过读取嵌入在收据中的贷款信息来检查是否已返回正确的金额。
Move的资源安全特性使Move中的闪电贷成为可能,而无需使用重入或自省。它们保证了收据不能被不受信任的代码所修改,并且它需要在交易结束时被返回给还款函数。这样,我们可以保证在同一个交易中返回正确的资金数额。
该功能完全使用基本的语言原语实现,Move的实现不会像Solana的实现那样受到集成问题的影响,因为后者需要交易是精心设置的。没有任何复杂性被推到客户端。
闪电贷很好展示Move的线性类型系统和资源安全保障如何使我们以其他编程语言无法实现的方式去表达功能。
原始的代币铸币权限方创建一个“铸币锁”,这将使我们的智能合约能够监管铸币。调用者成为该铸币锁的管理员。
管理员可以为该锁创建额外的铸币授权,可以授权给其他各方,并允许他随时使用该锁来铸造代币。
每个铸币授权都有 每日可以铸造的代币数量限制。
管理员可以在任何时候禁止(和解除)任何铸币权限方。
管理员的能力可以转让给另一方。
这个智能合约可用于,例如将代币的铸币能力交给其他用户或智能合约,而原来的铸币权限方(管理员)仍然保留对铸币的控制权。不然,我们将不得不把铸币的全部控制权交给另一方,这并不理想,因为我们只得相信它不会滥用该权力。而且给多方提供许可也是不可能的。
这些智能合约的完整实现可以在这里(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.检查代币程序账户是否被正确传入。
我们可以看到,账户检查(在这个例子中)分为这五类:
帐户所有权检查(1,2,4,5)
帐户类型检查(1、2、4、5)
帐户实例检查 (某一账户类型的正确实例是否被传入)(2,5)
账户签名检查 (3)
程序账户地址检查 (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。跨越智能合约边界的类型和资源安全保障是由验证者确保的,不需要手动实现。
我想再举一个例子,强调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中,有更多的组件需要被信任,因此致命安全错误的表面