跳到内容

了解如何编写可升级的智能合约

探索可升级智能合约在去中心化应用中的重要性。了解它们如何通过代理模式实现更新、错误修复和增强安全性。

8 分钟阅读
了解如何编写可升级的智能合约

智能合约通过引入无需中介的自执行程序,彻底改变了我们对去中心化应用的认知。它们在去中心化区块链网络上透明运行,提供无需信任、安全性更高等优势。然而,智能合约的不可篡改性曾被视为一大优势,如今却带来了严峻挑战:智能合约一旦部署,便无法修改。这种刚性使得实施必要更新或修复漏洞变得十分困难。

在本文中,我们将探讨可升级智能合约的概念,阐明其重要性,并详细介绍其工作原理,重点关注在 Linea zkEVM 上的实现方式。

什么是可升级智能合约?

可升级智能合约通过允许在部署后进行修改和改进,解决了传统智能合约的局限性。传统智能合约以不可篡改性著称,而可升级合约则将不可篡改性的安全优势与灵活适应的能力相结合。这使开发者能够在不影响用户体验、无需迁移至新合约的情况下实施更新。

在传统合约中,漏洞或安全缺陷一旦部署便成为永久性问题。可升级合约通过支持部署后更新来解决这一问题,从而应对以下关键挑战:

1. 漏洞修复与安全漏洞:开发者可在部署后修补漏洞,保护用户资产。

2. 适应性:可升级合约支持与新协议的无缝集成,使应用程序紧跟技术发展步伐。

3. 安全增强:面对不断演变的网络威胁,这类合约无需重新部署整个合约,即可轻松实施新的安全措施。

4. 治理灵活性:去中心化自治组织(DAO)可通过社区投票管理升级流程,确保透明度和利益相关方的参与。

5. 简化更新:与需要复杂重新部署和数据迁移的传统合约不同,可升级合约简化了更新流程,带来更高效、更友好的用户体验。

可升级智能合约如何运作?

创建可升级智能合约最常用的方法是代理模式。该方法将合约的逻辑与数据分离,使开发者能够在保持相同数据存储的同时更新逻辑。

其工作原理如下:

  1. 代理合约:该合约持有存储(状态变量),并作为用户的交互接口。

  2. 实现合约:该合约包含可执行代码(业务逻辑)。

  3. 委托:代理合约使用以太坊的 delegatecall 将函数调用委托给实现合约。这使代理能够在保留相同存储的同时执行实现合约的代码。

当需要升级时,代理合约被指向新的实现合约,从而在不更改合约存储或影响用户的情况下实现逻辑更新。以下是一个使用 DAO 代理合约来说明此过程的示例:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract DAOProxy {
    // Storage slot for the address of the implementation contract
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    // Storage slot for the admin address
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");

    // Modifier to restrict access to admin
    modifier onlyAdmin() {
        require(msg.sender == _admin(), "Caller is not admin");
        _;
    }

    // Constructor to set the initial implementation and admin
    constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }

    // Fallback function to delegate calls to the implementation
    fallback() external payable {
        _delegate(implementation());
    }

    // Receive function to handle plain Ether transfers
    receive() external payable {
        _delegate(implementation());
    }

    // Internal function to delegate the call to the implementation
    function _delegate(address _implementation) internal {
        assembly {
            // Copy msg.data
            calldatacopy(0, 0, calldatasize())
            // Call the implementation
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            // Copy the returned data
            returndatacopy(0, 0, returndatasize())
            // Handle result
            switch result
            case 0 {
                // Revert if the call failed
                revert(0, returndatasize())
            }
            default {
                // Return data if the call succeeded
                return(0, returndatasize())
            }
        }
    }

    // Getter for the implementation address
    function implementation() internal view returns (address impl) {
        bytes32 slot = implementationSlot;
        assembly {
            impl := sload(slot)
        }
    }

    // Getter for the admin address
    function _admin() internal view returns (address adm) {
        bytes32 slot = adminSlot;
        assembly {
            adm := sload(slot)
        }
    }

    // Function to upgrade the implementation contract
    function upgradeTo(address newImplementation) external onlyAdmin {
        _setImplementation(newImplementation);
    }

    // Internal function to set the implementation address
    function _setImplementation(address newImplementation) internal {
        bytes32 slot = implementationSlot;
        assembly {
            sstore(slot, newImplementation)
        }
    }

    // Function to change admin
    function changeAdmin(address newAdmin) external onlyAdmin {
        _setAdmin(newAdmin);
    }

    // Internal function to set the admin address
    function _setAdmin(address newAdmin) internal {
        bytes32 slot = adminSlot;
        assembly {
            sstore(slot, newAdmin)
        }
    }
}
复制
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");
复制

需要特别注意两点:实现合约地址的存储槽和管理员地址的存储槽。这些是经过精心设计的唯一标识符,用于计算代理合约中实现合约地址的存储槽。

在使用带有 delegatecall 的代理模式时(我们稍后将深入了解此函数),实现合约在代理存储的上下文中运行。这意味着:

  • 共享存储空间:两个合约共享相同的存储布局。

  • 覆盖风险:若管理不当,代理合约中的变量可能被实现合约覆盖。

通过使用哈希唯一标识符:

  • 隔离性:确保代理使用的存储槽不太可能与实现合约中的任何变量发生冲突。

  • 一致性:在合约中所有需要实现合约地址的地方,始终使用相同的存储槽。

constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }
复制

另一个重要方面是代理合约的构造函数。它包含一个 _implementation 参数,这意味着必须先部署实现合约,并在部署代理合约时将其地址作为参数传入。

 fallback() external payable {
        _delegate(implementation());
    }

    receive() external payable {
        _delegate(implementation());
    }
复制

接下来是 fallback 函数和 receive 函数,这两个 Solidity 函数在将调用和以太币转账转发至实现合约方面发挥着关键作用。它们处理合约接收到与任何已明确定义函数不匹配的调用或资金的情况。以下是对这些函数功能的深入说明:

1. fallback 函数

  • 回退机制:Solidity 中的 fallback 函数是一个特殊函数,在以下两种主要情况下执行:- 当调用合约中不存在的函数时。

  • 当向合约发送数据但与任何函数签名不匹配时。

  • 委托:在 DAOProxy 合约的上下文中,fallback 函数将所有此类调用委托给当前实现合约。

在 DAOProxy 中的工作方式:

  • 函数调用处理:- 当用户调用代理合约中未定义的函数时,fallback 函数被触发。

  • 这种情况很常见,因为代理合约主要包含 upgradeTo 和 changeAdmin 等管理函数,而非实现合约的业务逻辑函数。

  • 委托至实现合约:- fallback 函数调用 _delegate(implementation()),将调用转发至实现合约。

  • _delegate 是一个内部函数,使用 delegatecall 在代理存储上下文中执行实现合约的代码。

  • 执行上下文:- 所有状态变更发生在代理的存储中。

  • msg.sender 和 msg.value 保持不变,保留原始调用者的上下文。

2. receive 函数

  • 以太币接收: receive 函数在 Solidity 0.6.0 中引入,当合约接收不带任何附加数据的以太币时执行(即 msg.data 为空时)。

  • 以太币转账的委托: 在 DAOProxy 合约中,普通以太币转账被委托至实现合约。

在 DAOProxy 中的工作方式:

  • 以太币转账处理:- 当有人向代理合约地址发送不带数据的以太币时,receive 函数被触发。

  • 这通过仅指定 to 地址和 value(以太币数量)而不调用任何特定函数的交易来实现。

  • 委托至实现合约:- 与 fallback 函数类似,receive 调用 _delegate(implementation()),将以太币转账转发至实现合约。

  • 实现合约随后定义如何处理收到的以太币(例如,更新余额、触发事件)。

 function _delegate(address _implementation) internal {
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }
复制

接下来是 _delegate 函数,它使代理能够在保留原始调用上下文的同时,将函数调用转发至实现合约。这使合约逻辑能够随时间升级,而无需更改合约地址或影响其状态。

其他函数是帮助我们管理代理合约状态变量的 getter 和 setter。为此,我们使用 assembly 关键字,它允许开发者在合约中直接嵌入低级汇编代码。该特性提供了对以太坊虚拟机(EVM)指令的直接访问,能够更精确地控制合约行为、优化 gas 消耗,并执行在 Solidity 高级代码中不直接可用或效率较低的操作。

现在,关键问题来了:我们如何使用这个代理合约与实现智能合约进行交互?

为此,我们需要实现合约的 ABI。由于代理将调用委托给实现合约,你需要其 ABI 来了解可以调用哪些函数。此外,在与区块链交互时,我们将使用代理合约的地址,而非实现合约的地址。代理持有状态并将调用委托给实现合约,因此所有交互都应通过代理进行。

假设我们有一个 JavaScript 项目,并使用 Ethers.js 与区块链交互。代码如下所示:

const proxyAddress = "0xProxyContractAddress";
const implementationABI = [ /* ABI array of the implementation contract */ ];

const proxyContract = new ethers.Contract(proxyAddress, implementationABI, provider);

// For a function 'setValue(uint256 newValue)'
await proxyContract.connect(signer).setValue(42);

// For a function 'getValue()'
const value = await proxyContract.getValue();
console.log("Value:", value);
复制

这样,我们就通过代理合约调用了实现合约中的假想函数 setValue。

既然我们在讨论可升级合约,另一个重要问题随之而来:如何升级我的合约?

首先,只有管理员(部署代理合约的人)才能升级实现合约。为此,管理员必须先将新版本的实现合约部署到区块链上。之后,他们需要调用代理合约中的 upgradeTo 函数,并将新合约地址作为参数传入。代码如下所示:

await proxyContract.connect(adminSigner).upgradeTo(newImplementationAddress);

拥抱智能合约的未来

可升级智能合约在区块链开发中至关重要,因为它们允许在部署后进行更改和改进。通过利用代理模式,开发者可以无缝引入新功能、修复漏洞,并在不影响用户体验或迁移资产的情况下响应不断变化的需求。

然而,能力越大,责任越大。以深入理解其机制、安全影响和最佳实践的态度来对待可升级合约至关重要。确保存储布局一致性、实施健全的访问控制以及对升级进行全面测试,是防范潜在风险的必要步骤。

在着手构建可升级合约时,不妨考虑使用 OpenZeppelin 的 Upgrades Plugin 等成熟工具来简化流程并增强安全性。祝编码愉快!

AI 翻译。可能包含错误。请务必核实信息。

评价此翻译

本文作者: