本文是关于智能合约部署生命周期的实用指南。首先介绍部署交易的本质(to = null
,init code -> runtime code)以及合约地址的推导方式,然后深入探讨实际生产中常见的模式:CREATE2实现确定性地址,工厂模式和克隆(EIP-1167)实现低成本大规模部署,以及由delegatecall
驱动的可升级代理(Transparent/UUPS)。
当你阅读链上系统时,遇到的绝大多数“神秘”之处不在于 Solidity,而在于代码如何到达那里并保持在那里:部署、地址背后的数学原理、大规模创建副本的工厂,以及使合约在不破坏用户体验的情况下可升级的代理模式。
这篇文章是关于该生命周期的实用指南。我们将从第一性原理开始——部署事务究竟是什么(
to = null
,init code → runtime code)以及合约地址是如何推导出来的。然后深入研究你在生产环境中实际看到的模式:用于确定性地址的 CREATE2,用于廉价大规模部署的工厂和 克隆 (EIP-1167),以及由delegatecall
驱动的可升级代理(Transparent/UUPS)。在此过程中,我们将学习如何在区块浏览器上发现代理,读取 EIP-1967 存储槽以查找实现,并避免导致升级失败的陷阱(存储布局冲突、初始化程序错误、自毁实现)。如果你曾经想知道“为什么这个合约的代码看起来很小?”,“他们在部署之前是如何知道这个地址的?”或“真正的逻辑隐藏在哪里?”,这篇文章就是地图。
在第一部分中,你将学习:
1. 合约部署的工作原理
2. 使用 CREATE2 进行确定性部署
3. 代理和可升级性:透明代理 (EIP-1967)
当你部署智能合约时,你实际上只是发送一种特殊的交易。与 to
是现有地址的普通转账不同,部署交易会将 to = null
,并使用交易的 calldata 来携带合约的 init code。
该 init code 有两个任务:
换句话说:
当你将参数传递给 Solidity 中的构造函数时,它们会被 ABI 编码并附加到 init code 中。 它们在部署期间被消耗掉,并且不会保留在合约字节码中。 这就是为什么你在查询 eth_getCode
时没有在 runtime code 中看到它们的原因。
EVM 在部署时确定性地计算合约的地址:
使用 CREATE (默认):
address = keccak256(rlp(sender_address, sender_nonce))[12:]
意思是:获取部署帐户的地址 + 它的 nonce,对其进行 RLP 编码,哈希处理,并使用最后 20 个字节。
还有上限:合约不能大于 24 KB 的字节码 ( EIP-170)。
正如我们所见,合约代码与 CREATE 操作码 的地址计算无关。 让我们在部署后使用手动计算来计算合约地址。
让我们运行 Anvil(正如我们 在 这里 所学习的)
anvil
手动计算:
让我们选择一个 Anvil 的默认地址和私钥,并检查 nonce:
我们可以通过 eth_getTransactionCount
的原生 EVM 调用来检查 nonce,也可以使用 forge 的 cast
。
cast nonce <YOUR-ADDRESS> --rpc-url http://localhost:8545
这将显示该地址的 nonce。 现在让我们使用上面的计算公式。 让我们进行 RLP 编码。 你可以从之前的 博客 之一获取 RLP 编码的代码,或者使用一些在线工具. 为了清晰起见,我将使用在线工具。
rlp(sender_address, sender_nonce)
注意:我的 nonce 在响应中是 1,在十六进制中输入你的 nonce。 如果为 0,则输入 0x
现在让我们计算下一步的 keccak256,我将再次使用在线工具:
keccak256(rlp(sender_address, sender_nonce))[12:]
这里的输入是上一张图片的输出,我们取哈希的最后 20 个字节。
让我们借助 forge 验证地址,为此,我们将运行下一行:
// <YOUR-ADDRESS> - 可以是 anvil 默认地址中的地址
cast compute-address <YOUR-ADDRESS> --nonce 1
Computed Address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
我们看到地址是相同的。
正常的合约部署(CREATE
)将新地址与部署者的帐户及其 nonce 相关联。 这意味着地址是顺序的,除非你跟踪 nonce,否则你无法提前知道它们。
EIP-1014 (CREATE2) 引入了一种在地址上部署合约的方法,该地址在 交易被挖掘之前 是可预测的。 这就是为什么人们说 CREATE2 为你提供“确定性部署”。* ](https://miro.medium.com/v2/resize:fit:700/1bquj9salONFp-FStHNSqWg.png)
我在这里选择了一个随机盐值
0x0000000000000000000000000000000000000000000000000000000000000042
让我们使用 forge 验证它:
// <YOUR-ADDRESS> - 可以是 anvil 默认地址中的地址
cast compute-address <YOUR-ADDRESS>\
--salt 0x0000000000000000000000000000000000000000000000000000000000000042 \
--init-code-hash 0x9e7ceb5009cf19fc3a77cbead52c79f881b81800108a8931565aa92f9f1f5b64
Computed Address: 0x93eFaEdEe330e749D9AF79424398204EC04F89F1
正如你所看到的,我们得到了相同的地址。
开发者在 部署之前 保证合约地址,从而实现预先资助的钱包、确定性的 DEX 池和可验证的系统合约等功能。
智能合约在设计上是不可变的。 一旦部署,它们的字节码就无法更改。 这种不可变性对于信任来说是件好事,但对于实际协议来说却很痛苦:错误会发生,标准会发展,治理规则会改变。 如何“升级”逻辑而无需要求每个用户迁移到新地址?
答案是 代理。 合约永远不会更改其地址,但会将调用转发到可以交换的实现。
代理是一个精简的合约,它:
delegatecall
)转发到另一个合约,称为 实现。由于 delegatecall
在调用者的上下文中执行,因此所有存储都保留在代理中,而逻辑存在于实现中。 更换实现 → 你已经“升级”了合约,但用户可以继续与同一个地址交互。
问题: 合约是不可变的,但实际系统需要修复和新功能。
解决方案: 透明代理 保持一个公共地址,并将用户调用转发到可交换的实现。 它 使用 来自 EIP-1967 的固定存储槽,因此工具和审计可以确切地知道 admin/implementation 所在的位置。
同一代理的两张面孔
用户界面(普通调用者)
fallback
。delegatecall
s 到当前 实现 并返回结果。管理员界面(升级操作员)
upgradeTo
、changeAdmin
等。这种拆分就是它被称为 透明 的原因:对于用户来说,代理的行为类似于应用程序,对于管理员来说,它公开了一个微小的控制面板。
三个规范的 EIP-1967 插槽(可在任何浏览器/RPC 上读取)
0x360894A13BA1A3210667C828492DB98DCA3E2076CC3735A920A3CA505D382BBC
0xB53127684A568B3173AE13B9F8A6016E243E63B6E8EE1178D6A717850B5D6103
0xA3F0AD74E5423AEBFD80D3EF4346578335A9A72AEAEE59FF6CB3582B35133D50
注意: 你 可以使用
eth_getStorageAt
/cast storage
检查它们,并将最后 20 个字节读取为地址。
重要的操作隐患 (EOA 与 ProxyAdmin):
如果你还需要从该地址使用 dapp,请不要将你的日常 EOA 设置为代理管理员。
ProxyAdmin
合约)。 你的用户 EOA 仍然是普通用户。让我们看一下最小的透明代理实现:
合约实现 StorageV1.sol
:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.12;
contract StorageV1 {
// IMPORTANT: storage lives in the proxy; layout must be stable across upgrades.
// 重要提示:存储存在于代理中; 布局必须在升级后保持稳定。
uint256 public number;
string public ownerName;
bool private _initialized;
// Will be initialized only once, you can use openzeppelin guard
// 将仅初始化一次,你可以使用openzeppelin guard
function initialize(uint256 n, string calldata who) external {
require(!_initialized, "already initialized");
_initialized = true;
number = n;
ownerName = who;
}
function setNumber(uint256 n) external {
number = n;
}
}
其他合约实现 StorageV2.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.12;
import "./StorageV1.sol";
contract StorageV2 is StorageV1 {
// new vars only at the end
// 新变量仅在末尾
string public note;
function setNote(string calldata s) external { note = s; }
function double() external view returns (uint256) { return number * 2; }
}
透明代理实现 TransparentProxy1967.sol
:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.12;
/// @title Minimal Transparent Proxy (EIP-1967) – for education/demo
/// @title 最小透明代理 (EIP-1967) – 用于教育/演示
/// @notice Admin gets upgrade functions; non-admin callers are delegated to implementation.
/// @notice 管理员获得升级功能; 非管理员调用者被委托给实现。
/// Admin is blocked from fallback to avoid selector clashes.
/// 管理员被阻止从 fallback 避免选择器冲突。
contract TransparentProxy1967 {
// EIP-1967 slots (implementation, admin)
// EIP-1967 插槽(实现、管理员)
bytes32 private constant _IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
bytes32 private constant _ADMIN_SLOT =
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
event Upgraded(address indexed implementation);
event AdminChanged(address previousAdmin, address newAdmin);
// logic - The address of the logic contract
// logic - 逻辑合约的地址
// initialAdmin - The admin of this proxy that can upgrade logic
// initialAdmin - 可以升级逻辑的此代理的管理员
// data - "constructor" for the logic contract
// data - 逻辑合约的“构造函数”
constructor(address logic, address initialAdmin, bytes memory data) payable {
require(_isContract(logic), "Proxy: logic not a contract");
require(initialAdmin != address(0), "Proxy: admin zero");
_setAddress(_ADMIN_SLOT, initialAdmin);
_setAddress(_IMPLEMENTATION_SLOT, logic);
// optional initializer call (acts like a constructor for the implementation)
// 可选的初始化程序调用(充当实现的构造函数)
if (data.length > 0) {
(bool ok, bytes memory err) = logic.delegatecall(data);
require(ok, string(err));
}
}
// -------- Admin control plane --------
// -------- 管理员控制面板 --------
modifier ifAdmin() {
if (msg.sender == _admin()) {
_;
} else {
_fallback();
}
}
function admin() external ifAdmin returns (address) { return _admin(); }
function implementation() external ifAdmin returns (address) { return _implementation(); }
function changeAdmin(address newAdmin) external ifAdmin {
require(newAdmin != address(0), "Proxy: admin zero");
emit AdminChanged(_admin(), newAdmin);
_setAddress(_ADMIN_SLOT, newAdmin);
}
function upgradeTo(address newImplementation) external ifAdmin {
_upgradeTo(newImplementation);
}
function upgradeToAndCall(address newImplementation, bytes calldata data) external payable ifAdmin {
_upgradeTo(newImplementation);
(bool ok, bytes memory err) = newImplementation.delegatecall(data);
require(ok, string(err));
}
// -------- User surface (fallback/delegate) --------
// -------- 用户界面(fallback/delegate)--------
fallback() external payable { _fallback(); }
receive() external payable { _fallback(); }
function _fallback() internal {
require(msg.sender != _admin(), "Transparent: admin cannot fallback");
_delegate(_implementation());
}
function _delegate(address impl) internal {
assembly {
// 0x00–0x3f: scratch space the compiler may use temporarily.
// 0x00–0x3f:编译器可能暂时使用的暂存空间。
// Using memory at 0x00 is safe in this proxy fallback
// 在此代理 fallback 中,使用 0x00 的内存是安全的,
// because the assembly block never returns to Solidity.
// 因为程序集块永远不会返回到 Solidity.
calldatacopy(0, 0, calldatasize())
let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch ok
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
// -------- Slot helpers & guards --------
// -------- 插槽助手和保护 --------
function _admin() internal view returns (address a) { a = _getAddress(_ADMIN_SLOT); }
function _implementation() internal view returns (address a) { a = _getAddress(_IMPLEMENTATION_SLOT); }
function _upgradeTo(address newImpl) internal {
require(_isContract(newImpl), "Proxy: new impl not a contract");
_setAddress(_IMPLEMENTATION_SLOT, newImpl);
emit Upgraded(newImpl);
}
function _getAddress(bytes32 slot) internal view returns (address a) {
assembly { a := sload(slot) }
}
function _setAddress(bytes32 slot, address a) internal {
assembly { sstore(slot, a) }
}
function _isContract(address a) internal view returns (bool) {
uint256 size; assembly { size := extcodesize(a) }
return size > 0;
}
}
注意: 请注意实际的合约执行是如何在
fallback
函数中发生的。
看看我们在逻辑合约中没有构造函数。 但是为什么呢?
delegatecall
(正如 在 之前的博客文章 中所学到的)在调用者的存储(代理)中执行逻辑代码。 实现的构造函数之前在 自己的 上下文中运行。owner
/ number
,但在初始化代理的存储之前,通过代理读取将返回默认值。让我们深入研究有趣的部分:
让我们像之前在不同终端中所做的那样,使用 anvil 启动本地链
anvil
逻辑合约部署:
forge create src/StorageV1.sol:StorageV1 --rpc-url http://localhost:8545 --private-key <来自-ANVIL的-PRIVATE-KEY> --broadcast
// 输出将是:
// [⠢] 正在编译...
// 没有文件更改,跳过编译
// 部署者:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到:0x5FbDB2315678afecb367f032d93F642f64180aa3
// 交易哈希:0x35b7d8d461c60c3759ab08a733c2f66e5fd8e22656ca5e158b37eecec605a80e
透明代理部署:
// 以前部署的合约
IMPL=0x5FbDB2315678afecb367f032d93F642f64180aa3
// ADMIN 是 anvil 提供的第二个地址,你可以使用任何其他管理员
// 为了简单起见,我们将使用此地址
ADMIN=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
## 构建完整的 calldata(选择器 + 参数)
## 逻辑合约的“构造函数”
INIT_DATA=$(cast calldata "initialize(uint256,string)" 42 "some-owner")
// --private-key - 在本例中是我们之前提供的 ADMIN 的私钥
// 但可以使用任何其他部署者
forge create src/TransparentProxy1967.sol:TransparentProxy1967 \
--rpc-url http://127.0.0.1:8545 \
--private-key 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d \
--broadcast \
--constructor-args $IMPL $ADMIN "$INIT_DATA"
// 输出应如下所示:
// [⠢] 正在编译...
// 没有文件更改,跳过编译
// 部署者:0x70997970C51812dc3A010C7d01b50e0d17dc79C8
// 部署到:0x8464135c8F25Da09e49BC8782676a84730C318bC
// 交易哈希:0x389ad3d17e7d8fe850c9325247cd3bd6d9c7bcd59757a73246656d56fb9425c3
让我们使用透明代理合约作为用户:
## 通过代理读取(使用 delegatecall)
cast call 0x8464135c8F25Da09e49BC8782676a84730C318bC "number()(uint256)" --rpc-url http://127.0.0.1:8545
cast call 0x8464135c8F25Da09e49BC8782676a84730C318bC "ownerName()(string)" --rpc-url http://127.0.0.1:8545
// 输出应如下所示:
// 42
// "some-owner"
## 通过代理写入
cast send 0x8464135c8F25Da09e49BC8782676a84730C318bC "setNumber(uint256)" 77 --private-key <YOUR-PRIVATE-KEY> --rpc-url http://127.0.0.1:8545
## 检查是否有效
cast call 0x8464135c8F25Da09e49BC8782676a84730C318bC "number()(uint256)" --rpc-url http://127.0.0.1:8545
// 预期输出:77
让我们证明地址无法 fallback(使用逻辑合约):
## 这会恢复:“Transparent: admin cannot fallback”
cast call 0x8464135c8F25Da09e49BC8782676a84730C318bC "number()(uint256)" --from 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --rpc-url http://127.0.0.1:8545
// 预期输出:
// 服务器返回错误响应:错误代码 3:执行已恢复:Transparent: admin cannot fallback...
让我们检查透明代理插槽:
## 管理员插槽(最后 20 个字节)
cast storage 0x8464135c8F25Da09e49BC8782676a84730C318bC 0xB53127684A568B3173AE13B9F8A6016E243E63B6E8EE1178D6A717850B5D6103 --rpc-url http://127.0.0.1:8545
## 逻辑合约地址插槽(最后 20 个字节)
cast storage 0x8464135c8F25Da09e49BC8782676a84730C318bC 0x360894A13BA1A3210667C828492DB98DCA3E2076CC3735A920A3CA505D382BBC --rpc-url http://127.0.0.1:8545
// 预期输出:
// 0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8
// 0x0000000000000000000000005fbdb2315678afecb367f032d93f642f64180aa3
让我们 升级到具有添加功能的新实现 StorageV2
(相同的存储前缀)
让我们部署第二个合约:
forge create src/StorageV2.sol:StorageV2 --rpc-url http://127.0.0.1:8545 --private-key <YOUR-PRIVATE-KEY> --broadcast
// 输出应如下所示
// [⠢] 正在编译...
// 没有文件更改,跳过编译
// 部署者:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署位置:0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9
// 交易哈希:0x80fd23c7145fff6853e17439b1e4d1332b41526a411dfaa842a4f985d55f936c
将透明代理指向新创建的合约:
cast send 0x8464135c8F25Da09e49BC8782676a84730C318bC "upgradeTo(address)" 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 --from 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --rpc-url http://127.0.0.1:8545 --private-key 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
现在你可以重新运行存储,并查看逻辑合约地址是否已更改。
ProxyAdmin
合约)。在第 1 部分中,我们揭开了合约生命周期的神秘面纱:to = null
交易如何运行 init code 一次以生成 runtime code; CREATE 地址如何来自 keccak256(rlp([sender, nonce]))[12:])
。 为什么 CREATE2 允许你从部署者、盐值和 init code 预先计算地址,以及 透明代理 (EIP-1967) 如何保持一个稳定的地址,同时通过 delegatecall
转发调用,并具有干净的用户/管理员拆分。 重要的习惯:对于可升级的逻辑,首选 初始化程序 而不是构造函数,使 管理员 与用户 EOA 分开,并跨版本保留 存储布局。
下次我们将扩展工具箱,涵盖 UUPS、Clones (EIP-1167),快速了解 Diamond (EIP-2535) 和 工厂,以及实用的浏览器提示和插槽检查。
- 原文链接: medium.com/@andrey_obruc...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!