作者讨论了智能合约开发中编程语言特性的重要性,以期提升安全性和效率。通过回顾OpenZeppelin Contracts的开发经验, 强调了安全抽象和防错设计的重要性,同时指出手写汇编来优化性能,有可能导致安全隐患。最后,提出了一个新EVM语言的构想,旨在结合功能语言的优势,推动编程语言设计的创新
- 原文链接:frang.io/blog...
- 译者:AI翻译官,校对:翻译小组
- 本文链接:learnblockchain.cn/article…
这是我在 Devcon 7 上发表的演讲 的重新录制,我分享了一些关于编程语言特性的想法,作为智能合约开发者,我们应该真正关注这些特性。
https://www.bilibili.com/video/BV1nvk7YwE7H/
很多内容是基于我在 OpenZeppelin Contracts 上的工作,这是一种广泛使用的 Solidity 库。在我作为该项目的开发者和维护者的 6 年时间里,我不得不阅读和编写大量的 Solidity,因此这也是对那段时间的一些回顾。
OpenZeppelin Contracts 的最初目标是策划一个常见合约类型的库,开发者可以重用这些合约,而不是自己编写代码并冒着引入错误的风险。很快就显而易见,我们在库中提供的代码只是开发者的起点,他们通常需要在此基础上添加自定义行为。因此,代码不仅要在自身上是安全的,还要在开发者自己的代码上下文中保持安全,这也有助于防止该代码中的错误。换句话说,OpenZeppelin Contracts 必须被构思为一个抽象库,特别是安全的抽象。
我将展示一个我非常喜欢的例子。ERC20 代币是以太坊的一个基础。ERC20 的实现必须确保一些基本属性,比如一个账户有足够的余额来发起转账。它还必须保持某些状态不变,比如余额的总和与总供应量相匹配。在此基础上,它还必须发出跟踪余额变化的事件。所有这些在完成的实现中相对容易确保,但在库中,我们只提供一个起点,开发者将扩展它,那么我们能否确保这些属性在开发者最终组合的代币合约中保持有效?
事实上,在库的早期版本中,打破这些保证是很容易的。
import {ERC20} from "@openzeppelin/contracts";
contract Token is ERC20 {
constructor(address premint) {
uint256 amount = 10000e18;
balances[premint] += amount;
}
}
即使库中的 ERC20 实现是正确的,这个简单的合约也是不正确的,因为它打破了总供应量的不变性。我们在增加一个余额的同时并没有增加总供应量。一旦我们修复了这一点,它仍然不正确,因为缺少 Transfer 事件。
import {ERC20} from "@openzeppelin/contracts";
contract Token is ERC20 {
constructor(address premint) {
uint256 amount = 10000e18;
balances[premint] += amount;
totalSupply += amount;
emit Transfer(0, premint, amount);
}
}
在某个时刻,我们将修复所有问题。但这正是我们希望简化的事情。我们可以通过提供一个抽象来开始:一个始终执行所有三项操作并确保不变性得以保持的 _mint
函数。
import {ERC20} from "@openzeppelin/contracts";
contract Token is ERC20 {
constructor(address premint) {
uint256 amount = 10000e18;
_mint(premint, amount);
}
}
这真的很好,但这足够吗?假设审计员看到一个使用 OpenZeppelin 的 ERC20 合约,但进行了扩展,他们能否假设这些不变性是成立的?在库的早期版本中,答案是否定的。他们必须在逐行检查代码时牢记并确保供应量是通过 仅使用 _mint
创建的。
审计员,以及诚实地说,参与开发和审查的每个人,都应该能够专注于更重要的事情。因此,我们希望将这种关注点转移到机器上,比如一个 linter 或静态分析器。事实上,如果我们将余额和供应变量设为私有,编译器可以为我们处理这个问题。因此,我们最终得到了一个抽象,具有封装的状态,提供了一个安全的接口来操作 ERC20 余额。
这些听起来像是简单的错误,私有变量听起来也不算什么。但即使是最近,我们也看到这个问题出现。Maker,一个在这个领域备受尊敬的项目,在 2023 年部署了 Savings DAI 代币,但没有为铸造事件提供 Transfer 事件。因此,你会看到各种工具(如浏览器、钱包或税务软件)在处理这个代币时遇到困难。不幸的是,他们没有使用库,没有使用良好的 ERC20 抽象,并且通过在较低级别实现逻辑,最终得到了一个非标准代币。
我认为这个例子展示了库的重要性,但更具体地说,展示了抽象的重要性,以及在安全抽象上构建的必要性,这些抽象编码并保持我们感兴趣的属性。
我之前提到,开发者需要在库提供的基础上添加自定义行为,此外,库希望将其中一些作为可选模块提供。我们称之为可扩展性和模块化,基本上在 Solidity 中创建可扩展和模块化抽象的唯一机制是继承,通常是多重继承。
让我们看一个例子,在 ERC20 的上下文中,另一个富有成效的抽象,转账钩子(Hook)。
contract ERC20Votes is ERC20 {
function transfer(from, to, amount) override {
_moveVotingPower(from, to, amount);
super.transfer(from, to, amount);
}
function transferFrom(from, to, amount) override {
_moveVotingPower(from, to, amount);
super.transferFrom(from, to, amount);
}
}
人们对 ERC20 代币的许多自定义通常是向转账中添加行为。最初,这是通过继承 ERC20 合约并分别重写 transfer
和 transferFrom
函数来添加行为的。在这里存在一个风险,即扩展的作者可能会重写一个转账函数而不重写另一个,我们甚至还没有涉及其他类似转账的函数,如 _mint
。这将导致不一致的行为,并很可能出现错误。
解决这个问题的抽象是 _beforeTokenTransfer
“钩子”:一个合约在每个类似转账的函数开始时调用的函数。在这个钩子上添加的自定义行为将自动应用于所有应该应用的地方,合约的各种特性,如 transfer
或 _mint
,在扩展方面表现一致,这是一个很大的改进。
contract ERC20Votes is ERC20 {
function _beforeTokenTransfer(from, to, amount) override {
_moveVotingPower(from, to, amount);
super._beforeTokenTransfer(from, to, amount);
}
}
要使用这个钩子抽象,必须从合约继承并重写钩子函数。但我想强调这个函数中的第二行,我们使用 super,这一点非常重要。因为这是使用继承和重写,实际上存在一系列的重写,我们必须执行所有这些。如果没有这一行,我们将无法将这个扩展与注册相同钩子的其他扩展结合起来……而且实际上情况更糟,因为我们可以结合它,并且它会编译,但它不会按我们预期的方式运行。为了正确使用这个抽象,钩子,我们必须记得添加这一第二行,并正确地传递正确的参数等等。
所以这并不是真正很好的抽象。因为抽象应该隐藏不相关的细节,让我们专注于我们试图实现的高层目标,并帮助我们避免错误。在这种情况下,我们想要关注投票权逻辑,但却被继承的偶然特性分散了注意力,面临犯下严重错误的风险。
与前面的私有变量示例不同,在这种情况下,编译器根本无法帮助我们。因此,如果我们想要减轻这种关注,就需要一些自定义工具来实现。虽然工具是可以的,但它是选择性的,作为库的作者,我们必须假设大多数开发者不会使用它。
理想的钩子抽象实现不应该要求这一行能够安全使用。那么,为什么 OpenZeppelin Contracts 会这样做?原因再次是,继承在语言中或多或少是唯一的机制,如果我们想表达像这样的可扩展模式。即便我们可以构造一些替代方案,但它们并不被视为可行,因为它们妨碍了另一个重要目标:效率。
在 2020 年,发生了一些有趣的事情。伊斯坦布尔硬分叉使读取存储的成本增加了四倍。除此之外,链开始严重拥堵,链上交易的成本突然变得非常高。因此,gas 成为了每个人非常重要的关注点。用户支付了非常高的费用,应用开发者则受到用户的指责,因为他们编写了效率低下的代码。
对于像我们这样的库作者来说,这意味着 gas 效率变得更重要。用户不仅希望安全,还希望,甚至可能主要希望,效率。因此,我们必须牢记这两点,并在某些情况下找到它们之间的正确平衡。
在这一时期,我们开始看到一个非常有趣的现象。那就是汇编语言的崛起,手写的内联汇编在 Solidity 代码中应用。在我看来,开发者社区对这一点的反应过度,试图削减这里和那里的少量 gas,尽管 EVM 中的其他原语可能会花费成千上万甚至数万的 gas。
所以我对这一运动持批评态度,但我必须承认,汇编的支持者确实触及了一些东西。他们观察到,他们手写的汇编可以比编译器生成的代码更高效。我认为,这在一般情况下是正确的。Solidity 的高层抽象并不是零成本抽象。这种体验实际上反映了 C++ 设计原则中定义的概念:
当你使用零成本抽象时,你的性能至少与手写的代码一样好
这个想法存在已久。C++是一种在 80 年代创建的语言。但最近,由于 Rust 的巨大流行,这个想法获得了一些新的知名度。Rust 的成功让人印象深刻。它在区块链领域的影响力尤其强大,当谈到智能合约编程语言时,我们看到压倒性的影响:Cairo、Noir、Move、Sway、Stylus、Solana,这些都是受 Rust 启发或直接使用 Rust 的。我认为这并非偶然。智能合约需要安全,并且需要有效利用资源,而这些正是 Rust 的强项。
那么,让我们看一个例子,说明 Solidity 不是一个零成本抽象语言。
function processProof(bytes32[] memory proof, bytes32 leaf) pure returns (bytes32) {
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) {
computedHash = keccak256(bytes.concat(computedHash, proof[i]));
}
return computedHash;
}
这是来自一个默克尔证明库的函数,你可以从一个叶子和一个默克尔证明计算出一个默克尔根。在这个函数中,我们使用了一些高层抽象,换句话说,它们并不直接映射到 EVM 指令,比如这个 for 循环或我们将重点放在的这个 bytes.concat
函数。事实证明,这个 processProof
函数有一个非常不理想的性能特性,即它分配的内存与证明的大小成正比,这段内存只使用一次,而且再也不需要了,它就静静地增加了内存大小并影响了合约的其他部分的成本。我们得到了这是使用抽象的结果,而编译器没有足够聪明来消除或结合这些分配。
所以这是 OpenZeppelin 选择使用汇编的地方。
function processProof(bytes32[] memory proof, bytes32 leaf) pure returns (bytes32) {
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) {
computedHash = efficientKeccak256(computedHash, proof[i]);
}
return computedHash;
}
function efficientKeccak256(bytes32 a, bytes32 b) pure returns (bytes32 value) {
assembly ("memory-safe") {
mstore(0x00, a)
mstore(0x20, b)
value := keccak256(0x00, 0x40)
}
}
这个新的实现现在在分配方面是最优的,事实上,它没有进行任何分配,因为它使用了 Solidity 维护的 64 字节临时空间。我们还将这小段汇编包装在一个辅助函数中,使原始的保持在更高的抽象级别,以免干扰其所实现的逻辑。
但顺便提一下,从技术上讲,这样也不是零成本,因为据我在测试中发现,这个函数调用并没有内联,所以它确实会带来一些小的开销。
因此,在这种情况下,我们通过手动编写汇编来改善了我们需要的逻辑的性能。我们也有纪律地将其限制在一个不错的辅助函数中。
但我真的认为,在我们的智能合约中将汇编正常化是一个错误。因为性能,或 gas 效率,并不是唯一的要求。智能合约必须工作,它们必须保护资金,必须对各种攻击稳健。它们必须实现这些高层目标。当我们编写汇编时,我们必须考虑低层细节,比如内存布局和脏位,而这些会分散我们对高层目标的注意。我坚信我们应该更接近高层光谱的那个端。
这就是为何高层语言被开发的原因。为何我们从汇编转向 C,最终转向 JavaScript。它允许我们解决越来越复杂的问题。而这自然也是智能合约将要前进的方向。
现在公正地说,Solidity 编译器缺乏优化反映了 Solidity 团队的一些合理决策。智能合约需要高度的保证,而编译器是其中一个重要部分。去年,Vyper 的一个编译器错误导致数百万美元被盗。上个月,Sway 编译器的一个错误导致资金被冻结。尽管这些可能不是优化器错误,但实施优化所需的额外复杂性无疑增加了出现错误的可能性。尤其是在 Solidity 的“遗留管道”中,这根本不是为优化而设计的。
尽管如此,我们确实需要高效的智能合约。为了基本的效率要求而不得不诉诸汇编是不可持续的。因此,我们需要纠正方向,我们需要能够在更高的抽象层次上工作,以解决未来复杂的问题。值得指出的是,高层语言不仅对人类(如开发者和审计人员)有利,也对计算机(如静态分析和形式验证)有利,而这些都是我们绝对需要的。
目前正在进行改进现有语言状态的工作。Solidity 团队一直在开发 IR 流水线,这种流水线更适合优化,并且还在研发一个新版本的语言,该版本将具有更好的抽象机制,例如泛型。我们现在甚至有其他编译器正在开发,这可能会在多方面改善语言。Vyper 团队一直在努力改进其安全流程,真正提高了优化标准,正在开发他们自己的 IR,并且最近引入了抽象模块,这在以往是明显缺乏的。
这些努力非常有价值。我们应该投资于逐步改进我们当前的语言和编译器。但现在仍处于早期阶段,没有理由相信我们已经找到编写智能合约的最佳方式,因此我认为我们也应该继续探索和尝试语言设计和编译器构建的新想法。
就我个人而言,我当前正在探索一种我认为很有前景的 EVM 语言方向,称为 EVML,主要是从函数式语言及其传统中汲取灵感。那么,我为什么对这一方向特别感兴趣呢?我今天谈论了很多关于抽象的内容,我认为将一类一等函数、代数数据类型与表达能力强的类型系统结合在一起,是一种能让我们走得更远的多功能抽象工具。在效率方面,我们可以从 Rust 学到很多,它具备所有这些特性并能高效地编译,同时也可以从 Haskell 和 OCaml 学到很多,它们在如何良好编译方面做了出色的工作并发表了大量研究。相信在 EVM 中这可能甚至更容易。但更重要的是,我认为这样特征的语言可以是一个小型的,具有形式规范和进行形式推理能力的语言,因此可能会对编译器有很高的信心,或许最终甚至是一个完全经过验证的编译器。这个语言正在开发中,但我会在不久后分享更多信息。
最后,我想解释一下这个演讲的标题。它的灵感来源于 1965 年的一篇影响力论文,名为《下一个 700 种编程语言》,该论文提出了一些我刚刚讨论的作为编程语言设计基础的想法。事实上,我无法告诉你下一个 700 种 EVM 语言是什么,但我想今天传达的是,我们应该探索这个领域,因为这可能会让我们克服当前面临的一些重大限制。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!