理解 Solidity 代理合约

  • RareSkills
  • 发布于 2025-02-25 10:31
  • 阅读 4601

本文详细介绍了代理合约的概念及其在智能合约中的重要性,特别是如何通过代理合约实现智能合约的升级性和降低部署成本。文章通过示例代码和图解,深入解析了代理合约的工作原理和实现方法,并探讨了存储冲突问题及其解决方案。

代理合约使智能合约能够保留其状态,同时允许其逻辑升级。

默认情况下,智能合约无法升级,因为已部署的字节码无法被修改。

在 EVM 中更改字节码的唯一机制是部署新合约。然而,这个新合约的存储并不知道之前的合约。这意味着保存在存储中的先前值对新合约不可用。

代理带来的解决方案是将存储保留在一个合约中,并从另一个合约获取业务逻辑和功能(由字节码提供)。如果需要新的功能,那么会部署一个新的“逻辑合约”,但存储合约保持不变。

当对存储合约进行调用时,存储合约只需 delegatecall 逻辑合约中的函数以进行状态更新。

免责声明 此处演示的代理实现仅用于学习目的,不应用于生产环境。有关生产级代理的信息,请查看我们书中关于 Proxy Patterns 的后续章节。但你应该首先阅读本章,以便在阅读后面的章节时有更好的基础。

什么是代理合约?

代理合约是一个智能合约,它存储状态变量,同时将其所有逻辑委托给一个或多个实现合约。也就是说,代理合约仅保留存储变量,而一个单独合约的逻辑会更新这些存储变量。

你可以将代理合约视为类似于你移动电话上的应用程序和数据。手机保留你的个人数据——联系人、照片和浏览历史——就像代理保留其状态一样。实现合约就像手机的操作系统(OS)和应用程序,负责其功能和行为。当操作系统或应用程序更新时,手机的功能改进,但你的数据保持完整。

https://img.learnblockchain.cn/2025/02/26/UpgradePhoneCompressed.mp4

这个类比说明了代理合约如何在将功能委托给实现合约的同时保持其状态。

展示代理合约如何在将功能委托给实现合约时保持其状态的图示

代理合约及其逻辑合约的设置过程如下:

  1. 你部署一个代理合约
  2. 然后你部署实现合约
  3. 你将实现合约地址存储在代理的存储中
  4. 现在,代理通过 DELEGATECALL 将所有调用转发到实现地址

如何转发调用?

由于代理没有自己的逻辑,对代理合约的任何调用将被捕获到 fallback 函数中。fallback 函数处理函数调用不匹配合约中任何已定义函数的情况。

代理将使用代理收到的相同 calldata delegatecall 实现,如下图所示:

展示如何通过 delegatecall 转发 calldata 的图示

代理合约始终使用它收到的相同 calldata 对实现进行 delegatecall

以下是来自 我们关于 delegatecall 文章 的动画供你复习:

https://img.learnblockchain.cn/2025/02/26/delegatecallAnimation.mp4

为什么使用代理合约?

代理合约有两个显著的用例:

1. 可升级性

升级合约的能力是代理合约最常见的用例。代理模式允许你创建可以升级(加入新逻辑或新功能)的合约,而不干扰现有合约的状态或地址。

另一方面,非可升级的合约需要你说服所有用户、钱包提供商和交易所每次修复错误或添加新功能时迁移到智能合约地址。

2. 节省部署Gas成本

如果需要部署多个合约副本,代理可以 节省Gas,因为所有代理可以使用相同的逻辑合约,同时保持自己的独立状态。你只需部署一个实现合约,所有代理使用 delegatecall 与其进行交互,而不是为每个副本部署完整的合约逻辑。由于逻辑非常简单,已部署的字节码小得多,因此部署成本更低。这种模式称为 Minimal Proxy Pattern(后面会详细介绍)。

如何部署可升级的智能合约(不用于生产)

让我们首先创建一个简单的代理合约,它 delegatecalls 一个硬编码的实现地址。这将帮助我们理解基本模式,然后再使其可升级。

1. 部署你的实现合约

下面的合约我们可以作为实现合约。它接收两个数字作为参数,并将它们的和作为一个 事件 发出。

contract Implementation {
    event Result(uint256 newValue);

    function addNumbers(uint256 number1, uint256 number2) public returns (uint256 result ) {
        result = number1 + number2;
        emit Result(result);
    }
}

我们使用 Remix 部署实现合约,如下所示。部署完成后,我们复制地址:

展示如何使用 Remix 部署实现合约的图示

2. 部署代理合约并设置实现地址

我们现在可以部署 Proxy 合约,并将 Implementation 合约地址存储在 Proxy 合约中。

  // 用实现地址替换此地址 , 你在部署实现合约时获得的地址
address immutable implementation = 0x44fE319Fc0C2d3e09F9a86AF94eEabB6173A1d58;

下面是一个代理合约的示例:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;

contract Proxy {
    // 将实现合约地址更改为你在部署实现合约后获得的地址
    address immutable implementation = 0x44fE319Fc0C2d3e09F9a86AF94eEabB6173A1d58;

    fallback(bytes calldata data) external returns (bytes memory) {
        (bool success, bytes memory result) = implementation.delegatecall(data);
        require(success, "Delegatecall failed");
        return result;
     }
}

在 Remix 中按照下图的说明部署代理合约。

展示如何部署代理合约并设置实现地址的图示

测试/与你的合约交互

合约部署完成后,下一步是与代理合约进行交互。我们将探讨与合约交互的两种方法。

1. 使用 Remix 中的 Low Level Interaction 接口与代理合约交互

在 Remix 中与代理合约进行交互的第一种方法是使用 Low Level Interaction 接口。这涉及为目标函数构建 calldata,并将其传入 calldata 输入框。

因此,为了触发 addNumbers 函数以将两个数字 54 相加,我们将通过 ABI 编码 构造 calldata,如下所示:

function seeEncoding() external pure returns (bytes memory) {
    return abi.encodeWithSignature("addNumbers(uint256,uint256)", 5,4);
}

结果将是以下代码:

0xef9fc50b00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000004

现在我们已经构造好了 calldata,让我们使用它来调用代理合约中的功能。

calldata 粘贴到输入框中。我们预期结果为 9,因为 5+4 等于 9,如下图所示:

展示如何使用 calldata 调用 Remix 中的代理合约的图示

尽管 Proxy 合约没有逻辑来发出事件,但在我们将 calldata 发送到代理时,我们仍然看到了事件被触发。这是因为发出事件的逻辑是由代理通过 delegatecall 实现的。

显然,这个过程在测试合约时看起来有点复杂,尤其是如果我们不得不手动编码自己的 calldata。更简单的替代方法是通过实现合约的 ABI 与代理合约交互。

2. 使用 Remix 中的 Implementation 合约的 ABI 与 Proxy 合约交互

在部署后,按照以下步骤设置代理合约,以便你可以通过实现合约的 ABI 与其交互:

  1. CONTRACT 下拉菜单中,选择 Implementation 合约。

展示如何在 Remix 中选择实现合约的图示

  1. 复制 Proxy 合约地址并粘贴到 At Address 输入框中。

展示如何复制 Proxy 合约地址并粘贴到 At Address 输入框中的图示

  1. 点击 At Address 按钮与合约进行交互,如下图所示。

展示如何点击 At Address 并与 Remix 中的代理合约交互的图示

现在,你应该能够交互调用 addNumbers 函数,如下图所示:

展示与代理合约交互的图示

注意:尽管 Remix 将我们与之交互的合约标记为 Implementation,但它实际上是 Proxy 合约在使用 Implementation 合约的 ABI。

如我们所见,当用户与代理合约交互并试图调用 addNumbers 函数时,由于 addNumbers 函数在 Proxy 中不存在,因此触发了 fallback 函数。触发后,fallback 函数将执行转发到实现合约,并使用 delegatecall,其中定义了该函数。

以下屏幕录制总结了上述步骤:

https://img.learnblockchain.cn/2025/02/26/proxyDemo.mp4

到目前为止,我们已经看到一个基本的代理合约,该合约代理调用到一个硬编码的实现地址。然而,这种方法是不可升级的,因为实现地址是固定在合约字节码中的,因为使用了 immutable 关键字。

address immutable implementation = 0x44fE319Fc0C2d3e09F9a86AF94eEabB6173A1d58;

更新代理的实现

为了使代理合约可升级,我们需要以可以在部署后更新的方式存储实现地址。我们可以通过在代理合约中添加 setImplementation 函数来实现:

function setImplementation(address _implementation) public onlyOwner {
    implementation = _implementation;
}

现在,我们不再硬编码实现地址,而是将其存储在一个状态变量 implementation 中。这样,我们可以在必要时更新它。

contract Proxy {
    // 存储实现合约地址
    address implementation;

    function setImplementation(address _implementation) public {
        implementation = _implementation;
    }

    fallback(bytes calldata data) external returns (bytes memory) {
        (bool success, bytes memory result) = implementation.delegatecall(data);
        require(success, "Delegatecall failed");
        return result;
     }
}

出于安全考虑,我们需要确保只有管理员可以更新实现地址。我们通过引入一个 admin 状态变量来实现这一点:

contract Proxy {
    address public implementation;
    address public admin;
    ...

并通过修饰符限制对 setImplementation 函数的访问:

modifier onlyOwner() {
   require(msg.sender == admin, "Not the contract owner");
   _;
}

存储冲突问题

现在我们的代理合约包括 implementationadminnumber(我们引入 number 来说明存储冲突问题)存储变量,存储布局现在如下所示:

contract Proxy {
    address public implementation;
    address public admin; 
    uint256 public number;
    ...

在我们的实现合约中,我们有 number 状态变量:

contract Implementation {
    uint256 public number;
    ...

这将导致存储冲突。当我们尝试更新代理合约中的 number 状态变量时,我们将覆盖 implementation 地址状态,这不是我们想要的!

在 Solidity 中,存储变量根据它们在合约中的声明顺序被分配到固定的 存储槽。这意味着,如果代理和实现的存储变量布局不匹配,将会发生冲突。

在我们的案例中:

  • 代理合约在槽 0 存储 implementation 地址,在槽 1 存储 admin,在槽 2 存储 number
  • 在实现合约中,number 变量存储在槽 0。

这导致了冲突,因为当实现合约试图更新其 number 变量时,它最终在代理合约中修改 implementation 地址,这在同一槽(槽 0)中存储。

展示存储重叠的图示

这导致意外行为——你想在代理中更新 number,但你实际上是在存储中覆盖 implementation 地址。

我们的文章 Solidity 中的存储槽:存储分配和低级汇编存储操作Storage Slot III (复杂类型) 详细解释了 Solidity 中存储槽的工作原理,并附有有用的图示和动画。

让我们使用以下合约作为示例:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;

contract Proxy {
    address public implementation;
    address public admin;
    uint256 public number;

    constructor() {
        admin = msg.sender;
    }

    modifier onlyOwner() {
        require(msg.sender == admin, "Not the contract owner");
        _;
    }

    function setImplementation(address _implementation) public onlyOwner {
        implementation = _implementation;
    }

    fallback(bytes calldata data) external returns (bytes memory) {
        require(implementation != address(0), "Implementation not set");

        (bool success, bytes memory result) = implementation.delegatecall(data);
        require(success, "Delegatecall failed");
        return result;
     }
}

contract Implementation {
    uint256 public number;

    function increment() public {
        number++; 
    }
}

你会注意到自增将无法正常工作,因为它更新的是实现合约的地址,而不是存储中的 number

展示合约交互返回值为 0 的图示

我们如何解决代理中的存储冲突问题?

一种方法是选择一个随机槽,以便发生冲突的可能性极小。有关如何选择此类槽的更多细节,可以查看 ERC-1967

因此,根据 ERC-1967 规范,我们将使用以下槽来存储 implementation 地址:

0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

该槽是通过以下手动操作伪随机生成的:

bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)

我们将使用以下槽来存储 admin 地址:

0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103

其通过以下方法推导出:

bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)

要读取或写入这些特定的存储槽,你需要在内联汇编中使用 sloadsstore

以下是我们初始代理合约的修订版,现在使用 ERC-1967 标准定义的存储槽,以确保代理和实现合约之间没有存储碰撞。

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

contract Proxy {

    /**
     * @dev Storage slot for the implementation address.
     * This is derived from  `bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)`.
     */
    bytes32 private constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    /**
     * @dev Storage slot for the admin address.
     * This is derived from `bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)`.
     */
    bytes32 private constant _ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016eaf15eb9e8e9f03347e2db6a3ec1e1cb0;

        // Initialize proxy with the owner
    constructor() {
        address admin = msg.sender;
        assembly {
            // Store admin in the ERC-1967 admin slot
            sstore(_ADMIN_SLOT, admin)
        }
    }

    modifier onlyOwner() {
        address admin;
        assembly {
            // Load admin from the ERC-1967 admin slot
            admin := sload(_ADMIN_SLOT)
        }
        require(msg.sender == admin, "Not the contract owner");
        _;
    }

    function setImplementation(address _implementation) public onlyOwner {
        assembly {
            // Store implementation in the ERC-1967 implementation slot
            sstore(_IMPLEMENTATION_SLOT, _implementation)
        }
    }

    fallback(bytes calldata data) external payable returns (bytes memory) {
        address implementation;
        assembly {
            // Load implementation from the ERC-1967 implementation slot
            implementation := sload(_IMPLEMENTATION_SLOT)
        }
        require(implementation != address(0), "Implementation not set");

        (bool success, bytes memory result) = implementation.delegatecall(data);
        require(success, "Delegatecall failed");
        return result;
    }
}

contract Implementation {
    uint256 public number;

    function increment() public {
        number++; 
    }
}

现在,如果我们部署此合约并运行它,我们将得到如下所示的所需结果:

展示合约交互返回值为 2 的图示

我们在 Storage Slots for Proxies 文章中详细讨论 EIP-1967,它是本书的下一个章节。

结论

在本文中,我们探讨了代理合约的概念、重要性,以及它们如何实现可升级性并减少 Solidity 智能合约的部署成本。

以下是本篇文章的一些关键要点:

  • DELEGATECALL 操作码使得通过代理实现可升级成为可能。
  • 代理合约(可升级和不可升级)持有存储,包括实现地址。实现合约持有逻辑。
  • 可升级代理持有存储、实现地址,并提供一个设置函数以更新实现地址。实现仅持有逻辑。

进一步阅读推荐:

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

1 条评论

请先 登录 后评论