理解合约部署、代理和CREATE2——第一部分

本文是关于智能合约部署生命周期的实用指南。首先介绍部署交易的本质(to = null,init code -> runtime code)以及合约地址的推导方式,然后深入探讨实际生产中常见的模式:CREATE2实现确定性地址,工厂模式和克隆(EIP-1167)实现低成本大规模部署,以及由delegatecall驱动的可升级代理(Transparent/UUPS)。

理解合约部署、代理和 CREATE2 - 第 1 部分

当你阅读链上系统时,遇到的绝大多数“神秘”之处不在于 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 有两个任务:

  1. 在部署时运行一次:它执行任何构造函数逻辑,设置初始存储,并处理构造函数参数。
  2. 返回 runtime code:将永久存在于合约地址上的实际字节码。

换句话说:

  • Init code = “引导加载程序”。
  • Runtime 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

我们看到地址是相同的。

使用 CREATE2 进行确定性部署

正常的合约部署(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 池和可验证的系统合约等功能。

代理和可升级性

智能合约在设计上是不可变的。 一旦部署,它们的字节码就无法更改。 这种不可变性对于信任来说是件好事,但对于实际协议来说却很痛苦:错误会发生,标准会发展,治理规则会改变。 如何“升级”逻辑而无需要求每个用户迁移到新地址?

答案是 代理。 合约永远不会更改其地址,但会将调用转发到可以交换的实现。

核心思想

代理是一个精简的合约,它:

  1. 接收调用。
  2. 将其(通过 delegatecall)转发到另一个合约,称为 实现
  3. 将结果返回给用户。

由于 delegatecall 在调用者的上下文中执行,因此所有存储都保留在代理中,而逻辑存在于实现中。 更换实现 → 你已经“升级”了合约,但用户可以继续与同一个地址交互。

透明代理 (EIP-1967)

问题: 合约是不可变的,但实际系统需要修复和新功能。

解决方案: 透明代理 保持一个公共地址,并将用户调用转发到可交换的实现。 它 使用 来自 EIP-1967 的固定存储槽,因此工具和审计可以确切地知道 admin/implementation 所在的位置

同一代理的两张面孔

用户界面(普通调用者)

  • 任何不是管理员的调用者都会命中代理的 fallback
  • 代理 delegatecall s 到当前 实现 并返回结果。
  • 所有 状态都存在于代理中; 实现只是逻辑。

管理员界面(升级操作员)

  • 如果调用者 是管理员,则代理 不委托
  • 管理员可以调用显式管理功能,例如 upgradeTochangeAdmin 等。
  • 这可以防止管理员通过 fallback 意外命中应用程序代码(函数选择器冲突)。

这种拆分就是它被称为 透明 的原因:对于用户来说,代理的行为类似于应用程序,对于管理员来说,它公开了一个微小的控制面板。

三个规范的 EIP-1967 插槽(可在任何浏览器/RPC 上读取)

  • 实现插槽(保存已执行合约的地址)

0x360894A13BA1A3210667C828492DB98DCA3E2076CC3735A920A3CA505D382BBC

  • 管理员插槽(保存代理合约管理员的地址)

0xB53127684A568B3173AE13B9F8A6016E243E63B6E8EE1178D6A717850B5D6103

  • 信标插槽(用于信标代理。我们不会在此处介绍它

0xA3F0AD74E5423AEBFD80D3EF4346578335A9A72AEAEE59FF6CB3582B35133D50

注意:可以使用 eth_getStorageAt / cast storage 检查它们,并将最后 20 个字节读取为地址。

重要的操作隐患 (EOA 与 ProxyAdmin):

如果你还需要从该地址使用 dapp,请不要将你的日常 EOA 设置为代理管理员

  • 管理员无法命中 fallback → 他们 无法通过代理使用应用程序
  • 最佳实践:使代理管理员成为一个 单独的 权限(通常是由多重签名/时间锁控制的 OpenZeppelin 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(正如 之前的博客文章 中所学到的在调用者的存储(代理)中执行逻辑代码。 实现的构造函数之前在 自己的 上下文中运行。
  • 构造函数无法重新运行: 一旦部署了实现(逻辑合约),其构造函数就消失了。 你不能稍后“通过 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 合约)。
  • 插槽由 EIP-1967 标准化,这使得检查和审计可预测。
  • 在逻辑合约中使用 初始化程序; 构造函数不会通过代理运行。

总结

在第 1 部分中,我们揭开了合约生命周期的神秘面纱:to = null 交易如何运行 init code 一次以生成 runtime code; CREATE 地址如何来自 keccak256(rlp([sender, nonce]))[12:])。 为什么 CREATE2 允许你从部署者、盐值和 init code 预先计算地址,以及 透明代理 (EIP-1967) 如何保持一个稳定的地址,同时通过 delegatecall 转发调用,并具有干净的用户/管理员拆分。 重要的习惯:对于可升级的逻辑,首选 初始化程序 而不是构造函数,使 管理员 与用户 EOA 分开,并跨版本保留 存储布局

下次我们将扩展工具箱,涵盖 UUPSClones (EIP-1167),快速了解 Diamond (EIP-2535)工厂,以及实用的浏览器提示和插槽检查。

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

0 条评论

请先 登录 后评论
andrey_obruchkov
andrey_obruchkov
江湖只有他的大名,没有他的介绍。