可升级智能合约与智能合约代理模式指南

  • cyfrin
  • 发布于 2025-02-27 14:36
  • 阅读 18

这篇文章详细介绍了可升级智能合约代理模式的原理和用途。通过将功能与存储分开,开发者可以在保持合约地址和存储不变的情况下,升级合约的功能。文章还比较了几种不同的代理模式,包括透明代理、UUPS代理和信标代理,分析它们的优缺点,并提出了相应的安全考虑。

代理智能合约模式是一种简单有效的方式来创建可升级的智能合约,以使你的协议保持最新。

可升级代理智能合约模式是一种简单有效的方法,可以创建可升级的合约,随时间变化而改变。 它允许开发者在保留合约地址和存储的同时,修改智能合约的功能。

但智能合约不是应该是不可变的吗?

可升级代理模式将功能(或实现逻辑)和存储分开到不同的合约中,使逻辑可以升级,同时保持不可变性。

听起来令人困惑?让我们深入了解一下。

我们需要可升级的智能合约吗?

智能合约是不可变的,这意味着商业逻辑一旦部署就无法更新,从而确保了信任、去中心化和安全性。

尽管不可变性对智能合约的完整性至关重要,但在开发者需要解决漏洞和修改逻辑的情况下,它也会带来挑战。幸运的是,已经开发出可升级智能合约模式来解决这个问题。

一种在保留合约状态的同时修改智能合约功能的方法是使用代理合约。代理模式允许开发者在保持不可变性的同时,通过将业务逻辑分成不同的智能合约来修改智能合约的功能。通过使用代理合约,当用户与智能合约交互时,执行的逻辑可以修改。

什么是代理智能合约?

代理模式是一种智能合约模式,它将业务逻辑分为至少两个独立的合约:

  1. 一个合约包含数据存储。此合约称为 代理合约
  2. 一个合约包含业务逻辑。此合约称为 实现合约

该图显示了可升级智能合约代理模式的工作原理

代理模式 - 函数调用的执行路径

用户直接与代理合约交互,代理合约将请求转发(使用 delegatecall 函数)到实现合约,指定函数的执行方式以及如何修改代理的存储。

在调用被转发到实现合约后,数据由代理合约检索并返回给用户。

该图显示了可升级智能合约代理模式的工作原理

代理模式升级 - 调用新的实现地址

当合约被升级时,新的实现合约会被重新部署,并包含修改后的实现逻辑。然后,使用“更新”函数调用来更新代理合约,以重新路由到新的实现。

这样,合约的存储和地址保持不可变,同时允许底层逻辑被更新或修复错误。

简单(委托)可升级代理智能合约如何工作?

简单代理模式 依赖于低级别的 delegatecalldelegatecall 是以太坊的一条操作码,允许一个合约(合约 A)在合约 A 的上下文中调用另一个合约(合约 B)。这意味着,当调用合约 B 上的某个函数时,合约 A 的存储会被修改,同时 msg.sendermsg.value 也会被保留。这表示对合约 B 的函数调用看起来就像是对合约 A 上函数的调用。

每当用户通过自定义的 fallback 函数调用函数时,代理合约就会调用 delegatecall。回退函数是指在函数签名不匹配合约中指定的任何函数时执行的函数。这使得合约能够响应任意的以太坊事务。自定义回退函数发起 delegatecall,将用户的调用重新路由到保存函数逻辑的实现合约。

当逻辑需要更新时,部署一个新的实现合约,代理合约通过调用如 upgradeTo(address newImplementation) 的升级函数来指向新的实现地址。

— 要了解有关代理模式工作的更多信息,请参考 OpenZeppelin 代理模式文章

什么是初始化器?

与构造函数一样,初始化器用于在代理智能模式中初始化状态。 如果在实现合约上调用构造函数,它只会影响实现合约的存储,而不是代理的存储。因此,在实现合约上实施的初始化器函数通过来自代理合约的 delegatecall 执行,用于初始化代理存储中的状态,并与实现合约的逻辑对齐。

  • 初始化器不是在部署时执行一次,如同构造函数,而是在部署后手动调用。
  • 与构造函数不同,继承不会自动处理,因为初始化器只是一个普通函数。如果实现合约的父合约有构造函数,这些必须在初始化器函数中调用。
  • 初始化器只能调用一次。通常使用布尔值来防止安全漏洞。确保初始化器只能被调用一次的一种方法是使用 OpenZeppelin Initializable 合约
contract Implementation is Initializable {
    function initialize() public Initializable {
        // 初始化逻辑在这里
    }
}
  • 在实现代理智能合约模式时,初始化器必须仔细构建,以确保仅在预期情况下可调用,以保持智能合约的安全性。

— 有关初始化器的更多信息,请参阅 OpenZeppelin 关于编写可升级合约的文档

简单委托代理模式的问题

简单代理模式存在两个主要问题:

让我们深入探讨这些问题。

存储冲突

Solidity 代码中变量声明的顺序决定了智能合约的 存储布局。在使用可升级智能合约时,由于声明顺序在以下两者之间存在差异,这可能成为一个问题:

  • 代理和实现合约
  • 不同的实现合约版本

这可能在读取和修改数据时导致问题,进而导致安全漏洞和代码中的错误。

代理合约与实现合约之间的存储冲突

代理模式要求代理和实现合约分享相同的存储布局,以防止变量被错误地覆盖或读取。

由于代理合约需要存储实现合约的地址,但实现合约不需要,因此存储冲突的一个常见原因是实现合约在代理合约上覆盖了实现地址插槽。为了解决这个问题,创建了 ERC-1967 代理存储槽标准,该标准定义了一个特定的存储槽用于存储实现合约地址。

该图显示了代理合约与实现合约之间如何处理存储冲突

代理合约与实现合约之间的存储冲突

实现版本之间的存储冲突

如果存储变量声明顺序在版本之间更改,值将被覆盖或错误读取。

如果在旧的实现合约中存储插槽 0 指向“变量 a”,插槽 1 指向“变量 b”,而在升级的新实现中,存储插槽 0 指向“变量 b”,插槽 1 也指向“变量 b”,当读取或修改存储插槽 0 时,将错误访问“变量 a”。

contract A {
    uint256 a;
    uint256 b;
}

contract B {
    uint256 b;
    uint256 a;
}

在这个例子中,如果我们进行升级,使得合约 B 成为新的实现,然后使用其原始存储插槽 0 访问“变量 a”,它将错误地与“变量 b”交互。

因此,在创建新实现合约以进行升级时,确保存储变量的顺序保持一致至关重要。

函数冲突

智能合约中的函数是通过函数选择器选择的。函数选择器是函数签名的 keccak256 哈希的前 4 个字节,函数签名由函数名称、输入类型、状态可见性、修饰符和返回类型定义。当函数选择器发生冲突时,例如,在代理和实现合约中存在相同选择器的函数,当用户调用某个函数时,代理并不知道是调用自己的函数还是执行 delegatecall。如果实现和代理合约中的函数具有相同的签名,例如实现中也有一个update(address)函数,就会导致是否执行 delegatecall 的模糊性。

OpenZeppelin 文章 深入解释的另一个例子中,攻击者创建了一个代理,指向一个代币实现合约:

contract AttackerProxy {
    // 代理代码在这里...

    function collate_propagate_storage(bytes16) external {
        implementation.delegatecall(abi.encodeWithSignature(
            "transfer(address,uint256)", proxyOwner, 1000
        ));
    }

    // ...
}

contract Implementation {
    // 实现代码在这里...

    function burn(uint256 value) public virtual {
        _burn(_msgSender(), value);
    }

    function transfer(address to, uint256 value) public virtual returns (bool) {
        address owner = _msgSender();
        _transfer(owner, to, value);
        return true;
    }
    // ...
}

在这种情况下,collate_propagate_storage 的函数签名是 0x42966c68。偶然的是,burn 函数的函数签名也为 0x42966c68。因此,如果用户通过代理调用 burn,例如意图燃烧 1 个代币,他们实际上可能会将 1000 个代币转账给攻击者!

为了解决这个问题,OpenZeppelin 开发了一些代理模式:

  • 透明代理模式
  • UUPS 模式
  • 启用 Beacon 的代理模式

什么是透明代理模式?

透明代理模式的工作原理是基于消息调用的来源进行解释,即使用 msg.sender 的值,根据地址是管理员还是非管理员用户提供不同的执行路径。代理合约获取发送者,根据值确定是否将调用重新路由到实现合约:

  • 如果 msg.sender 是用户,即不是管理员,调用将委托给实现合约。这是常用的代理模式行为。
  • 如果 msg.sender 是代理的管理员,代理将调用其在代理上的管理功能,调用不会委托。管理员操作是通过具有 owner 角色的 ProxyAdmin 合约发起的,将调用转发到代理合约。这是为了避免当用户也是管理员时与合约交互时出现意外的回滚。

这意味着管理员只能与代理合约的函数进行交互,而非管理员只能与实现合约的函数进行交互,从而消除了模糊性。

该图显示了在透明智能合约代理模式中的可升级智能合约的工作原理

透明代理模式

透明代理的缺点

  • 非管理员用户无法再访问 读取函数,例如获取实现地址的变量。 ERC-1967 解决了这个问题,通过为实现合约和管理员地址提供特定的存储槽,使用户能够查看这些值。
  • 与代理合约的交互需要更多的 gas,因为代理需要确定是否重新路由调用,因此在每个调用中需要加载管理员地址。
  • 部署成本更高,由于这一额外逻辑而增加了部署成本。

这导致了 UUPS 代理模式的发展。让我们了解一下这个。

有关更多信息,请参阅 OpenZeppelin 透明代理模式文章

什么是通用升级代理标准 (UUPS)?

与简单和透明代理模式一样,UUPS 代理,提议在 EIP-1822 中,使用 delegatecall 函数。不同之处在于 UUPS 代理使用实现合约,而不是代理合约来管理升级。

代理合约成为一个简单的转发合约,通过 delegatecall 将所有调用转发到实现合约,从而消除了加载管理员地址和在每个调用中检查调用地址时所需的 gas 开销,这在透明代理模式中是需要的。

UUPS 代理如何工作?

实现合约继承一个 “可代理合约”,如 OpenZeppelin 的抽象 UUPSUpgradable 合约 (提供一个 _authorizeUpgrade(address newImplementation) 函数,未定义让开发者实现自定义授权逻辑),该合约提供升级功能。这意味着仅在升级调用时需要加载管理员地址,从而降低 gas 成本。

每个新的实现合约将继承一个“可代理合约”,确保持续的可升级性。这消除了由于函数选择器冲突所带来的模糊性,因为 管理员功能和实现逻辑存储在同一个合约中。由于 Solidity 编译器无法处理不同智能合约间的函数选择器冲突,但可以拒绝在同一智能合约内发生冲突的调用,因此减轻了函数选择器冲突的风险。

UUPS 的优缺点

UUPS 代理更小,因为管理员逻辑存储在实现合约中,因此部署成本较低。这也伴随着一个缺点,即部署实现合约和因此升级合约的成本更高。

另一个主要优点但也是一个缺点是,通过将代理合约升级到不继承“可代理合约”的实现,可以冻结升级。但是,如果错误地这样做,这将是不可逆转的,代理将无法在未来升级。

什么是 Beacon 代理模式?

Beacon 代理模式引入了一种高效的升级机制,用于当 多个代理合约需要同步更新 时,所有这些代理合约都指向一个实现合约。通过使用一个中间合约,称为“信标”合约,保存用于所有关联代理的实现地址,可以同时升级每个代理,而不必在每个代理中单独更新实现合约地址。

信标代理的所有者通过修改实现合约的地址来执行升级。当子代理引用信标以获取实现地址时,它们将指向新的实现合约以进行存储和状态修改。

该图显示了在信标智能合约代理模式中的可升级智能合约的工作原理

图:信标代理模式

示例用例包括在需要重大更新时强制对所有用户的代理合约进行推送升级。通过这种方式,所有用户的安全性得以维护,而不依赖于每个用户单独升级他们的合约。

Beacon 代理的缺点

Beacon 代理合约的缺点在于,加生权力成为单点故障。信标合约的所有者可以无需权限,推送升级到所有关联的代理。为了解决这个问题,可以实施多签名钱包等机制。

在结束之前,还有两种代理模式值得一提:最小代理模式和钻石代理模式。

什么是最小代理模式?

最小代理与之前提到的代理标准不同,因为它们 不提供升级或授权功能。相反,它们提供了一种简化且经济高效的解决方案,用于部署 共享公共逻辑但每个实例需要独立存储的同一合约的多个实例

最小代理模式允许部署实现合约的一个实例,然后为每个新合约实例部署轻量或“最小”代理。这些代理在部署后是静态和不可变的,简化了它们的结构并降低了部署和运行时的 gas 成本。

什么是钻石代理模式?

钻石模式允许代理合约将函数调用委托给多个称为“切面”的实现合约。要实施钻石模式,代理合约中的映射将函数选择器链接到切面地址。当用户发起函数调用时,代理合约检查映射,识别相应的切面,并使用 delegatecall 将调用重定向到适当的实现合约。

与其他代理升级模式相比,这种升级模式提供了几个优势:

  1. 允许单独修改函数,而无需重新部署整个实现合约。
  2. 钻石模式允许功能跨多个实现合约拆分,这意味着 24KB 的智能合约大小限制不再适用。
  3. 与具有广泛访问控制的代理模式不同,其中具有特定权限的帐户可以升级 整个合约,钻石模式支持模块化权限。这允许限制仅能升级智能合约中的特定函数。

该图显示了在钻石智能合约代理模式中的可升级智能合约的工作原理

图:钻石代理模式

可升级 Solidity 智能合约代理模式比较

现在我们已经了解了用于升级智能合约的不同代理模式,让我们进行比较,以更好地理解它们的优缺点:

代理模式优缺点 比较简单/委托 - 升级和授权功能。

该图显示了不同可升级智能合约代理模式之间的比较

安全考虑

除了上述不同代理模式的优缺点外,在决定是否编写可升级智能合约时,还需注意以下安全考虑:

  • 存储和函数冲突:如上所述,存储和函数冲突在使用代理合约时可能带来安全风险。由于这常常是智能合约安全性的问题,因此值得再提一次。
  • 增加的复杂性:代理模式为智能合约设计提供了一层额外的复杂性。随着每次升级,复杂性随之增加,因此理解智能合约的难度也增加。这使得智能合约或协议更难审计,因此可能使其更容易受到攻击。为此,确保所有升级都得到妥善记录,并尽量减少升级。
  • 中心化:执行升级的行为是中心化的,因为它需要信任被授权的地址来执行升级。由于执行的逻辑可以被任意修改,用户可能会受到恶意管理员攻击的威胁。为此,建议使用多签账户来执行管理员操作。

总结

代理智能合约模式是创建可升级智能合约的有效方式,这意味着实现逻辑可以在保持存储和地址不可变性的同时进行修改。

这意味着错误可以被修复,功能可以在部署后进行修改。根据具体的用例,有不同类型的代理,每种代理都有其优缺点。

在决定是否使用代理合约时,考虑这些模式的安全性非常重要,并妥善记录这些决定和对用户的影响。

  • 原文链接: cyfrin.io/blog/upgradeab...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.