代理合约(Proxy contracts)使智能合约能够在保留其状态的同时升级其逻辑。
默认情况下,智能合约无法升级,因为已部署的字节码无法修改。
EVM 中更改字节码的唯一机制是部署一个新合约。然而,这个新合约的存储对前一个合约“一无所知”。这意味着保存在存储中的先前值对新合约是不可用的。
代理引入的解决方案是将存储保留在一个合约中,并从另一个合约中获取业务逻辑和功能(由字节码提供)。如果需要新功能,则部署一个新的“逻辑合约”,但存储合约保持不变。
当对存储合约发起调用时,存储合约只需对逻辑合约中的函数执行 delegatecalls 来进行状态更新。
要理解代理,理解 delegatecall 至关重要,因此请先阅读链接的文章。
我们这里所说的“存储合约”就是代理合约。
在本文中,我们将教授:
- 代理的工作原理以及如何创建它们
- 如何升级代理智能合约
免责声明: 此处演示的代理实现仅供学习之用,不应用于生产环境。对于生产级别的代理,请参阅我们关于 Proxy Patterns 一书的后续章节。但在阅读后续章节之前,您应该先阅读本章以打下更好的基础。
什么是代理合约?
代理合约是一种存储状态变量,同时将其所有逻辑委托给一个或多个实现合约的智能合约。也就是说,代理合约仅仅保存存储变量,而由单独的合约逻辑来更新这些存储变量。
你可以把代理合约想象成手机上的应用程序和数据。手机保留了你的个人数据(联系人、照片和浏览记录),就像代理保留了它的状态一样。实现合约就像手机的操作系统(OS)和应用程序,负责其功能和行为。当操作系统或应用程序更新时,手机的功能会得到改善,但你的数据保持完好无损。
这个类比说明了代理合约如何在保留其状态的同时将功能委托给实现合约。

代理合约及其逻辑合约的设置如下:
- 部署代理合约
- 然后部署实现合约
- 将实现合约地址存储在代理的存储中
- 现在,代理通过
DELEGATECALL将所有调用转发到实现地址
调用是如何转发的?
由于代理没有自己的逻辑,任何对代理合约的调用都将被 fallback 函数捕获。fallback 函数处理函数调用与合约中定义的任何函数都不匹配的情况。
然后,代理将使用它接收到的相同 calldata 对实现合约执行 delegatecall,如下图所示:

代理合约总是使用它接收到的相同 calldata 对实现合约执行 delegatecalls。
以下是我们 关于 delegatecall 的文章 中的动画,作为复习:
为什么需要代理合约?
代理合约有两个值得注意的用例:
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 部署实现合约。部署后,我们复制其地址:

2. 部署代理合约并设置实现地址
现在我们可以部署 Proxy 合约,并将 Implementation 合约地址存储在 Proxy 合约中。
// Replace this with the implementation address
// you get when you deployed the implementation
// contract
address immutable implementation = 0x44fE319Fc0C2d3e09F9a86AF94eEabB6173A1d58;
下面我们展示一个代理合约的示例:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
contract Proxy {
// Change the implementation address to the one you get after deploying the
// implementation contract
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 函数将两个数字 5 和 4 相加,我们将通过执行该函数及其参数的 ABI 编码 来构造 calldata,如下所示:
function seeEncoding() external pure returns (bytes memory) {
return abi.encodeWithSignature("addNumbers(uint256,uint256)", 5,4);
}
结果将是下面的代码:
0xef9fc50b00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000004
现在我们已经构造了 calldata,让我们使用它来调用代理合约中的函数。
将 calldata 粘贴到输入框中。我们预期结果是 9,因为 5+4 等于 9,如下面的屏幕截图所示:

尽管 Proxy 合约没有发出事件的逻辑,但当我们将 calldata 发送给代理时,我们仍然看到触发了事件。这是因为发出事件的逻辑被代理执行了 delegatecall。
显然,这个测试合约的过程看起来有点复杂,尤其是如果我们必须手动编码自己的 calldata。一个更简单的替代方案是使用 Implementation 合约的 ABI 与 Proxy 合约进行交互。
2. 在 Remix 中使用 Implementation 合约的 ABI 与 Proxy 合约交互
部署后请按照以下步骤设置代理合约,以便你可以通过实现合约的 ABI 与之交互:
- 在
CONTRACT下拉菜单中,选择Implementation合约。

- 复制
Proxy合约地址并将其粘贴到At Address输入框中。

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

现在,你应该能够与 addNumbers 函数进行交互,如下图所示:

注意:尽管 Remix 将我们正在交互的合约标记为 Implementation,但它实际上是使用了 Implementation 合约 ABI 的 Proxy 合约。
正如我们所见,当用户与 Proxy 合约交互并尝试调用 addNumbers 函数时,它会触发 fallback 函数,因为 Proxy 中不存在 addNumbers 函数。一旦被触发,fallback 函数会使用 delegatecall 将执行转发到定义该函数的 Implementation 合约。
下面的屏幕录像总结了上述步骤:
到目前为止,我们已经看到了一个基本的代理合约,它将调用委托给硬编码的实现地址。然而,这种方法不可升级,因为由于 immutable 关键字,实现地址被固定在了合约字节码中。
address immutable implementation = 0x44fE319Fc0C2d3e09F9a86AF94eEabB6173A1d58;
更新代理的实现
为了使代理合约可升级,我们需要以一种部署后仍可更新的方式存储实现地址。我们可以通过向代理合约添加一个 setImplementation 函数来实现这一点:
function setImplementation(address _implementation) public onlyOwner {
implementation = _implementation;
}
现在,我们不再对实现地址进行硬编码,而是将其存储在状态变量 implementation 中。这样一来,我们就可以在需要时更新它。
contract Proxy {
// Store the implementation contract address
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");
_;
}
存储冲突问题
现在我们的代理合约包含了 implementation、admin 和 number(我们引入了 number 来演示存储冲突问题)存储变量,且存储布局现在如下所示:
contract Proxy {
address public implementation;
address public admin;
uint256 public number;
...
而在我们的实现合约中,我们有 number 状态变量:
contract Implementation {
uint256 public number;
...
这将导致存储冲突。当我们尝试更新代理合约中的 number 状态变量时,我们将会覆盖 implementation 地址状态,这并不是我们想要的!
在 Solidity 中,存储变量根据它们在合约中的声明顺序被分配到固定的 存储槽。这意味着如果代理和实现中的存储变量布局不匹配,就会发生冲突。
在我们的例子中:
- 代理合约将
implementation地址存储在 slot 0,admin在 slot 1,number在 slot 2。 - 在实现合约中,
number变量存储在 slot 0。
这会导致冲突,因为当实现合约尝试更新其 number 变量时,它最终会修改代理合约中存储在同一槽(slot 0)的 implementation 地址。

这导致了意外的行为——你想要更新代理中的 number,但实际上你覆盖了存储中的 implementation 地址。
我们的文章 Storage Slots in Solidity: Storage Allocation and Low-level assembly storage operations 和 Storage Slot III (Complex Types) 通过有用的图表和动画详细解释了 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++;
}
}
你会注意到 increment 操作将无法正常工作,因为它更新的是实现合约的地址,而不是存储中的 number:

我们如何解决代理中的存储冲突问题?
一种方法是选择一个随机槽,使得发生冲突的可能性极低。关于我们如何选择这样一个槽的更多细节在 ERC-1967 中有说明。
因此,遵循 ERC-1967 约定,我们将使用下面的槽来存储 implementation 地址:
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
该槽位通过以下方式伪随机推导得出:
bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
我们将使用下面的槽来存储 admin 地址:
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
推导自:
bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)
要读取或写入这些特定的存储槽,你需要在内联汇编中分别使用 sload 和 sstore。
下面的代码是我们最初的代理合约的修改版,现在使用由 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++;
}
}
现在,如果我们部署此合约并运行它,我们将得到所需的结果,如下所示:

我们在 Storage Slots for Proxies 文章中广泛讨论了 EIP-1967,这也是本书的下一章。
结论
在本文中,我们探讨了代理合约的概念、其重要性,以及它们如何实现可升级性并降低 Solidity 智能合约的部署成本。
以下是本文的一些关键要点:
DELEGATECALL操作码使利用代理进行升级成为可能。- 代理合约(包括可升级和不可升级)保留存储,包括实现地址。实现合约则保留逻辑。
- 可升级代理保留存储和实现地址,并提供一个 setter 函数来更新实现地址。实现仅保留逻辑
进一步阅读推荐: