可升级的智能合约

在不可篡改的区块链上更新应用程序,就像在飞行中切换飞机引擎一样

这里是高风险编程的定义:流行的NFT项目AkuDreams意外地将3400万美元的全部筹款永远锁定在一个智能合约中。我在这里写了一个系列推特,详细介绍了这个漏洞:

这篇特别的报道从加密货币的泡沫中出圈了,进入了主流媒体,创造了一个搞笑的误解的回应大杂烩。

有些人想知道,为什么不直接修复代码?漏洞是软件开发不可避免的一部分;构建流程和版本升级是每个技术栈的关键部分。

答案很简单--智能合约字节码一旦部署就不可改变。这隐藏着一个复杂的系统,开发人员创建了这个系统,在不可变的基础层上编写可升级的应用程序。让我们更深入地了解一下。

智能合约如何运行

这里我们讨论的是在以太坊虚拟机(EVM)上运行的Solidity智能合约,是以太坊、Binance智能链、Avalanche、Polygon、Fantom等链使用的编程环境。

EVM的一个有趣的特性是,用户账户和智能合约占据相同的40个十六进制字符的地址空间。不同的是,外部拥有的账户(EOAs)是由私钥操作的,而智能合约不能自己发送交易。合约部署的字节码提供了EOAs可以调用的功能,但一个合约不能自己触发。

区块链上的 "cronjob "等价物被称为keeper job,你向Chainlink/Keep3r/其他人运行的机器人网络付费,以定期调用upkeep功能,如清算、重新平衡或自动收获你的智能合约。

智能合约是存储在区块链上的可执行字节码。一旦代码被部署到智能合约的地址,代码就永远不能被改变(除了自毁,自毁是全面的删除)。那么,如果代码不能被改变,我们如何进行升级?

我将探讨这里使用的三种关键方法,从 "最不可变 "到 "最灵活":

1. 存储参数

每个合约都有自己的存储范围,只有它能触及。最常见的原始类型是整数、地址、映射和数组。这些存储变量是可以改变的,函数逻辑可以随时改变它们。

因此,最简单的升级方式是使用一个治理锁定的方法来更新某些经济参数。如果你正在运行一个StakingPools合约,你可以更新代币被滴出给stakers的奖励率。如果你正在运行一个LendingPools合约,你可以更新存款或借款利率。这不会改变字节码,因为它只是更新存储槽中持有的值。

一个fee-collection合约的样本。该逻辑是不可改变的,但费用百分比可以通过治理来更新。
一个fee-collection合约的样本。该逻辑是不可改变的,但费用百分比可以通过治理来更新。

2. 合约指针

有时候,你可能想检修一个合约的逻辑,而不仅仅是一个参数。因此,你可能有一个主调度合约,持有实际合约的地址并在那里进行调用。

一个很好的例子是Aave V3的代码库。主入口调用一个已知的地址提供者合约,该合约为系统的移动部件提供地址指针。治理者可以用新的字节码在新的地址上部署新的合约,然后将地址提供者的存储变量从$OLD_ADDRESS更新到$NEW_ADDRESS。

一个经过轻度编辑的抵押池自动复合包装器。治理可以从一个抵押池迁移到另一个抵押池,当且仅当它们符合相同的函数接口。
一个经过轻度编辑的抵押池自动复合包装器。治理可以从一个抵押池迁移到另一个抵押池,当且仅当它们符合相同的函数接口。

3. 代理

智能合约有不可变的字节码和可变的存储环境。通常情况下,无论字节码是进行外部调用还是修改自己的存储上下文,这些都是无缝吻合的。

但是EVM有一个特殊的操作码,叫做delegatecall,它从一个不同的、外部的合约中获取字节码,并在原始合约的存储上下文中执行该字节码。这在某种程度上相当于从互联网上下载一个脚本并在你的家庭电脑上运行,所以这应该只在已知的受信任的外部合约上使用,要非常谨慎。

用户调用代理合约,代理合约从实现合约中检索功能逻辑。图片来自[这里](https://mirror.xyz/0xB38709B8198d147cc9Ff9C133838a044d78B064B/M7oTptQkBGXxox-tk9VJjL66E1V8BUF0GF79MMK4YG0)
用户调用代理合约,代理合约从实现合约中检索功能逻辑。图片来自[这里](https://mirror.xyz/0xB38709B8198d147cc9Ff9C133838a044d78B064B/M7oTptQkBGXxox-tk9VJjL66E1V8BUF0GF79MMK4YG0)

让我们以ERC20代币为例。字节码是几个函数,指定谁有资格造币,是否有转让税,是否可暂停,等等。存储上下文包含数据,如持有人地址与持有人余额的映射。使用delegatecall意味着ERC20负责保持其存储环境/持有人余额,但它将功能逻辑/造币规则外包给外部智能合约。而且你可以切换出你委托给哪个智能合约的功能逻辑。

这很有用,因为它可以让你修补逻辑错误,而不会丢失任何重要的存储环境。这也是相当有表现力的。治理者可以部署他们想要的任何逻辑,包括恶意行为,如无限铸币或烧毁特定持有人的代币,或意外地部署一个破碎的升级。权力越大责任越大。

请查看OpenZeppelin文档,了解如何部署你自己的代理和逻辑合约的详细步骤。

高级: 开发细节

这是一个额外的技术部分。如果你不是一个活跃的 Solidity 开发者,请随意跳过。

包含存储上下文的合约被称为 "代理合约proxy contract",而包含字节码实现的合约被称为 "逻辑合约logic contract"。第三方用户将对代理合约进行调用,然后它将在引擎盖下获取逻辑合约的字节码,并在代理的存储上下文中执行。

如果编写可升级的逻辑合约,这里有一些Solidity的细微差别需要正确掌握:

  1. 所有的逻辑合约必须有一个空的构造函数,因为构造函数在部署时在逻辑合约的存储上下文中被原子化地执行。通常在构造函数中进行的设置应该在部署后被转移到代理的存储上下文中单独调用的初始化函数。
  2. EVM以一种非常特殊的方式布置存储,所以必须注意不要重新组织变量声明的顺序。最好的做法是总是以相同的顺序进行存储声明,如果需要新的存储,那么应该放在所有以前的声明之后。

还有一些需要担心的函数冲突--代理合约有几个用于升级其逻辑合约指针的最小函数,如 owner()upgradeTo()。显然,只有代理所有者才能升级逻辑合约。但是如果逻辑合约也有一个owner()方法,它应该优先考虑,会发生什么?

目前的最佳做法是根据哪个地址在调用产生不同的行为。如果调用地址是代理所有者,那么它将永远不会委托调用delegatecall逻辑合约。如果调用的合约不是代理所有者,它将总是委托调用delegatecall。

这就产生了另一个边缘故障,代理所有者可能希望与逻辑互动,所以为了解决这个问题,部署了一个中间的ProxyAdmin合约,所有对代理的调用都要经过这个合约。这使得代理所有者可以通过ProxyAdmin来升级逻辑合约,但也可以直接通过代理合约来与逻辑互动。

代理合约的行为根据谁发起的交易而有所不同
代理合约的行为根据谁发起的交易而有所不同

极为高级:代理类型

虽然在所有的代理类型中,存储委托调用外部字节码的一般想法是相同的,但已经提出了几种不同的实现方式。我将在这里简单地介绍一下它们。

EIP-1967标准指定了一个特定的存储槽,0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc,保证不会被编译器分配。这有助于像Etherscan这样的区块链浏览器在人们查看代理合约时解码并指向逻辑合约方法。

关于如何通过OpenZeppelin部署UUPS或透明代理的完整演练可以在这里找到

通用可升级代理(UUPS

一个版本,其升级逻辑被存储在实施合约中。这意味着它可以在以后被删除,或者在忘记包含升级功能时被意外冻结。然而,凭借更集中的代码,与透明代理相比,它减少了部署和互动成本。

透明代理 (Writeup)

一个版本,升级逻辑被放置在代理本身。这使得部署更加昂贵,但在逻辑合约升级方面,减少了自相残杀的机会。

信标代理 (细节)

一个版本让你在一次调用中把多个代理升级到一个新的执行地址。当你有一个给定的存储上下文的克隆或副本时很有用。

Wrapping it up

你可以在不可变的基底层之上建立可变性,但你不能做相反的事情。USDC可以建立在Ethereum上,但Ethereum不能建立在SWIFT上。这是与托管自己的服务器端代码完全不同的范式,可以随心所欲地改变,但却是建立金融原语的更强大的基础。

希望这些信息能帮助您评估与您互动的各种协议的安全和保障!

Subscribe to quentangle
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.