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

本文深入探讨了以太坊虚拟机(EVM)上常用的合约部署模式,包括用于逻辑升级的UUPS代理,用于标准化和可追踪部署的工厂模式,以及用于gas高效复制的最小代理(克隆)。文章通过代码示例详细解释了这些模式的原理和应用,并区分了简单合约、代理和克隆。

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

在前一部分中,我们揭开了合约如何从原始初始化代码到运行时字节码,真正在 EVM 区块链上部署的面纱,甚至还介绍了 CREATE 和 CREATE2 地址背后的数学原理。 这为我们奠定了基础:合约最初是如何产生的。

但是,如果你在以太坊上构建超过一个周末,你就会在实际应用中看到一些其他的东西:合约看起来与你从 Remix 或 Hardhat 或 Foundry 编写和部署的合约不太一样。 相反,你会遇到代理、克隆、工厂,甚至具有多个移动部件的“钻石”合约。 这些不是边缘情况,它们为 DeFi 中一些最大的协议提供动力。

这篇文章是关于理解这些模式的。 我们将了解它们存在的原因、它们在底层如何工作,以及当你偶然在浏览器中遇到它们时如何识别它们。 具体来说,我们将介绍:

1. UUPS 代理 (EIP-1822)

2. 工厂

3. 最小代理 (EIP-1167)

到最后,你将拥有一个心理工具箱来识别和推理这些模式,并有信心知道你是在与 vanilla 合约、克隆还是隐藏升级路径的代理进行对话。

UUPS 代理 ( EIP-1822 )

透明代理 很好,但它们有一个额外的负担:你必须维护代理合约和升级管理逻辑。 UUPS (通用可升级代理标准) 颠倒了这种设计。

UUPS 代理不是让代理合约负责升级,而是将该责任推送到实现合约中。 代理本身只是一个“哑转发器”,它委托所有调用。 实现包含一个特殊函数(通常称为 proxiableUUIDupgradeTo),它知道如何升级到新版本。 这使得代理非常小且可重用,而每个实现都可以定义自己的规则来确定谁可以升级。

Logic Contract 的地址存储在定义的存储位置 keccak256("PROXIABLE")=0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7

主要区别:

  • 代理更精简:内部没有升级代码。
  • 每个实现合约必须包含一个升级函数(通常是 OpenZeppelin 的 UUPSUpgradeable mixin)。
  • 存储布局仍然重要:如果在升级之间更改存储顺序,你将破坏状态。

示例:

UUPSLogicContract.sol

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

// 代理
contract Proxiable {
    // 存储中的代码位置是 keccak256("PROXIABLE") = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"

    function updateCodeAddress(address newAddress) internal {
        require(
            bytes32(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7) == Proxiable(newAddress).proxiableUUID(),
            "不兼容"
        );
        assembly { // solium-disable-line
            sstore(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7, newAddress)
        }
    }
    function proxiableUUID() public pure returns (bytes32) {
        return 0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7;
    }
}

// 控制只有所有者才能进行更改
contract Owned {

    address owner;

    function setOwner(address _owner) internal {
        owner = _owner;
    }
    modifier onlyOwner() {
        require(msg.sender == owner, "只有所有者才能执行此操作");
        _;
    }
}

contract LibraryLockDataLayout {
  bool public initialized = false;
}

// 锁定机制。
contract LibraryLock is LibraryLockDataLayout {
    // 确保一旦部署Logic Contract,任何人都无法操纵它。
    // PARITY WALLET HACK PREVENTION

    modifier delegatedOnly() {
        require(initialized == true, "库已锁定。不允许直接'调用'");
        _;
    }
    function initialize() internal {
        initialized = true;
    }
}

contract ERC20DataLayout is LibraryLockDataLayout {
  uint256 public totalSupply;
  mapping(address=>uint256) public tokens;
}

contract MyToken is Owned, ERC20DataLayout, Proxiable, LibraryLock {
    function constructor1(uint256 _initialSupply) public {
        totalSupply = _initialSupply;
        tokens[msg.sender] = _initialSupply;
        initialize();
        setOwner(msg.sender);
    }
    function updateCode(address newCode) public onlyOwner delegatedOnly  {
        updateCodeAddress(newCode);
    }

    function transfer(address to, uint256 amount) public delegatedOnly {
        require(tokens[msg.sender] >= amount, "资金不足,无法转账");
        tokens[to] += amount;
        tokens[msg.sender] -= amount;
    }
}

注意: 这里添加了很多预防措施,主合约是继承 Proxiable 的 MyToken。 其他继承可以保护合约免受各种可能的攻击。

UUPSProxy.sol:

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

/*
 * 非常简化的 UUPS 模式。
 * - 代理存储状态并委托调用。
 * - 实现持有升级逻辑。
 */

// ---------------- 代理 ----------------
contract UUPSProxy {
    // 存储中的代码位置是 keccak256("PROXIABLE") = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"
    constructor(bytes memory constructData, address contractLogic) {
        // 保存代码地址
        assembly {
            sstore(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7, contractLogic)
        }
        // 调用构造函数
        (bool success,  ) = contractLogic.delegatecall(constructData);
        require(success, "构造失败");
    }

    // 此回退实际上将调用逻辑合约
    // 因为带有数据字节的每个函数都会到达这里
    fallback() external payable {
        assembly {
            // 加载逻辑合约地址
            let contractLogic := sload(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7)
            calldatacopy(0x0, 0x0, calldatasize())
            // 使用数据字节调用逻辑合约
            let success := delegatecall(sub(gas(), 10000), contractLogic, 0x0, calldatasize(), 0, 0)
            let retSz := returndatasize()
            returndatacopy(0, 0, retSz)
            switch success
            case 0 {
                revert(0, retSz)
            }
            default {
                return(0, retSz)
            }
        }
    }
}

让我们启动节点:

anvil

并从不同的终端部署两个合约

// 部署合约逻辑
forge create src/UUPSLogicContract.sol:MyToken --rpc-url localhost:8545 --private-key <YOUR-ANVIL-PRIVATE-KEY> --broadcast

// 预期输出,类似于:
// [⠊] 正在编译...
// [⠒] 正在使用 Solc 0.8.30 编译 1 个文件
// [⠆] Solc 0.8.30 在 69.50 毫秒内完成
// 编译器运行成功!
// 部署者:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到:0x5FbDB2315678afecb367f032d93F642f64180aa3
// 交易哈希:0xfd7f81e7a54dba55a1a6399e465ed9ff67dc0d95a3f2fbbf85542fd7b0ffdf81

// 部署 UUPS 代理
INIT_DATA=$(cast calldata "constructor1(uint256)" 1000000)
forge create src/UUPSProxy.sol:UUPSProxy --rpc-url localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --broadcast --constructor-args "$INIT_DATA" 0x5FbDB2315678afecb367f032d93F642f64180aa3

// 预期输出,类似于:
// [⠊] 正在编译...
// 没有文件更改,跳过编译
// 部署者:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到:0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
// 交易哈希:0x60c04bf75c647a9c5bcd73155c8906675247ccf4f25fab20bf76e1b5a4ef129e

现在让我们通过代理调用 MyToken 合约上的自动生成的 getter:

// 请注意 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 是代理地址
cast call 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 "totalSupply()(uint256)" --rpc-url http://localhost:8545

工厂

到目前为止,我们已经了解了合约如何部署以及代理如何帮助升级。 但你将在生产 EVM 代码中看到的另一种模式:工厂。

工厂只是一个知道如何部署其他合约的合约。 你无需手动部署每个新合约,而是与单个工厂合约交互,该合约:

  • 启动新实例(CREATECREATE2
  • 安全地初始化它们
  • 发出事件,以便你可以在链上跟踪它们

可以将其视为合约的装配线。 你向工厂提供参数,它会返回一个新实例。

为什么使用工厂?

几乎每个主要协议都使用工厂,因为它们带来了:

  • 标准化: 每个实例都使用相同的代码路径进行部署。
  • 可发现性: 你可以查询工厂事件或映射以查找所有已部署的实例。
  • 初始化安全性: 工厂通常在部署后立即调用初始化程序,因此你不会最终得到其他人可以劫持的半成品合约。
  • 确定性: 使用 CREATE2,工厂甚至可以在实例存在之前预先计算其实例的地址(非常适合“反事实”钱包或确定性池地址)。

示例:

basicfactory.sol:

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

contract Counter {
    address public owner;
    uint256 public value;

    constructor(address _owner, uint256 start) {
        owner = _owner;
        value = start;
    }

    function inc() external {
        require(msg.sender == owner, "不是所有者");
        value += 1;
    }
}

contract CounterFactory {
    event CounterCreated(address indexed counter, address indexed owner, uint256 start);

    function createCounter(address owner_, uint256 start_) external returns (address addr) {
        for (uint256 i = 0; i < 5; i++) {
            Counter c = new Counter(owner_, start_);
            addr = address(c);
            emit CounterCreated(addr, owner_, start_);
        }
    }
}

设置节点:

anvil

部署合约并调用它

// 部署工厂合约
forge create src/basicfactory.sol:CounterFactory \
  --rpc-url localhost:8545 \
  --private-key <YOUR-ANVIL-PK>

// 预期输出:
// [⠊] 正在编译...
// [⠒] 正在使用 Solc 0.8.30 编译 1 个文件
// [⠢] Solc 0.8.30 在 52.99 毫秒内完成
// 编译器运行成功!
// 部署者:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到:0x610178dA211FEF7D417bC0e6FeD39F05609AD788
// 交易哈希:0x5c25685d15aacde7128a648d48dcd8b975ffc6e20aac9605bce2f582125b6437

// 调用工厂
cast send 0x610178dA211FEF7D417bC0e6FeD39F05609AD788 \
  "createCounter(address,uint256)(address)" \
  0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 42 \
  --rpc-url localhost:8545 --private-key <YOUR-ANVIL-PK>

// 在此调用后预期的日志,其中包含每个发出的事件的主题
// 在那里你会找到合约地址

最小代理 ( EIP-1167 ) ](https://miro.medium.com/v2/resize:fit:700/1*MEBqvCo4KfVYN6TYP45fgg.png)

https://learnblockchain.cn/docs/eips/EIPS/eip-1167

注意: 一个真实的例子可以是一个 dex pair,其中每个流动性对都是一个克隆,拥有自己的储备,但都共享相同的对逻辑。

最小示例:

minimalProxy.sol

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

/* ---------------------------------- */
/* 1) Implementation (shared logic)   */
/* ---------------------------------- */
contract Counter {
    address public owner;
    uint256 public value;
    bool private _initialized;

    // 在部署后通过克隆调用
    function initialize(address _owner, uint256 start) external {
        require(!_initialized, "已初始化");
        _initialized = true;
        owner = _owner;
        value = start;
    }

    function inc() external {
        require(msg.sender == owner, "不是所有者");
        unchecked { value += 1; }
    }

    // 可选:阻止初始化逻辑合约本身
    constructor() {
        _initialized = true;
    }
}

/* ---------------------------------- */
/* 2) Minimal Clone Factory           */
/* ---------------------------------- */
contract CounterCloneFactory {
    event CloneCreated(address indexed clone, address indexed owner, uint256 start, bytes32 salt);

    /* ---- Internal helpers: build creation code for the clone ---- */

    // 克隆创建代码 = 返回 45 字节运行时的短序言
    function _cloneCreationCode(address impl) internal pure returns (bytes memory code) {
        // creation: 3d602d80600a3d3981f3  -> return(next 0x37 bytes)
        // runtime:  363d3d373d3d3d363d73 <impl> 5af43d82803e903d91602b57fd5bf3
        code = abi.encodePacked(
            hex"3d602d80600a3d3981f3",
            hex"363d3d373d3d3d363d73",
            impl,
            hex"5af43d82803e903d91602b57fd5bf3"
        );
    }

    /* ---- Deploy a clone with CREATE ---- */

    function createClone(address implementation, address owner_, uint256 start_)
        external
        returns (address clone)
    {
        bytes memory code = _cloneCreationCode(implementation);
        assembly {
            clone := create(0, add(code, 0x20), mload(code))
            if iszero(clone) { revert(0, 0) }
        }
        // 立即初始化(构造函数不通过克隆运行)
        (bool ok, ) = clone.call(abi.encodeWithSignature("initialize(address,uint256)", owner_, start_));
        require(ok, "init failed");
        emit CloneCreated(clone, owner_, start_, bytes32(0));
    }

    /* ---- Deploy a deterministic clone with CREATE2 ---- */

    function createCloneDeterministic(address implementation, address owner_, uint256 start_, bytes32 salt)
        external
        returns (address clone)
    {
        bytes memory code = _cloneCreationCode(implementation);
        assembly {
            clone := create2(0, add(code, 0x20), mload(code), salt)
            if iszero(clone) { revert(0, 0) }
        }
        (bool ok, ) = clone.call(abi.encodeWithSignature("initialize(address,uint256)", owner_, start_));
        require(ok, "init failed");
        emit CloneCreated(clone, owner_, start_, salt);
    }
}

让我们部署它并运行一些链上函数:

启动节点:

anvil

部署合约:

// 共享逻辑的部署
forge create src/minimalProxy.sol:Counter \
  --rpc-url localhost:8545 \
  --private-key <YOUR-ANVIL-PK> --broadcast

// 预期输出:
// [⠊] 正在编译...
// 没有文件更改,跳过编译
// 部署者:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到:0x5FC8d32690cc91D4c39d9d3abcBD16989F875707
//交易哈希:0x43f37888e5c370cc1a398f1891cea860a80815fdf59716d4e2d8adb198b5edb8

// 最小代理工厂的部署
forge create src/minimalProxy.sol:CounterCloneFactory \
  --rpc-url localhost:8545 \
  --private-key <YOUR-ANVIL-PK> --broadcast

// 预期输出:
// [⠊] 正在编译...
// 没有文件更改,跳过编译
// 部署者:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到:0x0165878A594ca255338adfa4d48449f69242Eb8F
// 交易哈希:0x6ddac050694baf16fc7bcfde90dcf0516bd1777b6a1b8ea23fd8019bc20391b2

创建一些克隆:

// 部署第一个实例
cast send 0x0165878A594ca255338adfa4d48449f69242Eb8F \
  "createClone(address,address,uint256)(address)" \
  0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 <YOUR-ANVIL-PK-ADDR> 100 \
  --rpc-url localhost:8545 \
  --private-key <YOUR-ANVIL-PK>

// 成功后,期望以下日志:
// logs [\
// {"address":"0x0165878a594ca255338adfa4d48449f69242eb8f",\
//    "topics":["0x6823f533242a9c540bd8ab230da11a7199723745abb9d484732bb57b4f34d4d1",\
//              "0x0000000000000000000000003b02ff1e626ed7a8fd6ec5299e2c54e1421b626b",\
//              "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266"],\
//              "data":"0x00000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000000",\
//              "blockHash":"0xe7748e4f28f2492642e9f64e7252fd40e85079e5211523b7b5b35c82adceff69","blockNumber":"0x8","blockTimestamp":"0x68b92822","transactionHash":"0xe48d780a8268235887019e74d62e09a7e09164aaa37bdea60a2c0aa97f81d00c",\
//              "transactionIndex":"0x0",\
//              "logIndex":"0x0",\
//              "removed":false}]
//
// 从日志中我们可以看到地址是:0x3b02ff1e626ed7a8fd6ec5299e2c54e1421b626b

// 部署第二个实例
cast send 0x0165878A594ca255338adfa4d48449f69242Eb8F \
  "createClone(address,address,uint256)(address)" \
  0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 <YOUR-ANVIL-PK-ADDR> 100 \
  --rpc-url localhost:8545 \
  --private-key <YOUR-ANVIL-PK>

// 成功后,期望有此日志:
// [{"address":"0x0165878a594ca255338adfa4d48449f69242eb8f",\
//   "topics":["0x6823f533242a9c540bd8ab230da11a7199723745abb9d484732bb57b4f34d4d1",\
//   "0x000000000000000000000000ba12646cc07adbe43f8bd25d83fb628d29c8a762" ....\
//\
// 从日志中我们可以看到地址是:0xba12646cc07adbe43f8bd25d83fb628d29c8a762\

让我们进行一些读取和写入:

// 读取第一个代理的值
cast call 0x3b02ff1e626ed7a8fd6ec5299e2c54e1421b626b "value()(uint256)" --rpc-url localhost:8545
// 预期输出:100

// 读取第一个代理的值
cast call 0xba12646cc07adbe43f8bd25d83fb628d29c8a762 "value()(uint256)" --rpc-url localhost:8545
// 预期输出:100

// 在第一个合约上进行写操作
cast send 0x3b02ff1e626ed7a8fd6ec5299e2c54e1421b626b "inc()" \\
  --rpc-url http://localhost:8545 \\
  --private-key <YOUR-ANVIL-PK>

// 读取第一个代理的值
cast call 0x3b02ff1e626ed7a8fd6ec5299e2c54e1421b626b "value()(uint256)" --rpc-url localhost:8545
// 预期输出:101

// 读取第一个代理的值
cast call 0xba12646cc07adbe43f8bd25d83fb628d29c8a762 "value()(uint256)" --rpc-url localhost:8545
// 预期输出:100

注意: 正如我们所见,每个代理都持有自己的存储,同时指向一些共享逻辑。 我们在这里使用了工厂模式结合代理。

为什么不在工厂中只部署完整的合约?

简单的 CounterFactory 示例工作正常:每次调用 createCounter 都会部署一个完整Counter 合约。 但这里有一个很大的权衡:gas 成本。 每次你在 EVM 上部署合约时,你都需要支付与你放在链上的字节码大小成正比的 gas 费用。

  • 一个普通的 Counter 可能有几 KB 的字节码。
  • 部署 10,000 个计数器 = 将相同的字节码 10,000 次写入 EVM 状态。

这既昂贵又浪费:代码永远不会改变,它只是重复而已。

总结

在这篇文章中,我们探讨了 EVM 中三个最重要的部署模式:用于可升级逻辑的 UUPS 代理,用于标准化和可跟踪部署的工厂,以及用于 gas 高效复制的 最小代理(克隆)。 这些模式为当今的大部分 DeFi 提供动力,理解它们可以帮助你发现你是在与简单的合约、代理还是克隆进行交互。 在本系列的下一部分中,我们将更进一步,了解 Diamond 代理模式 (EIP-2535),该模式支持具有多个方面的模块化合约。

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

0 条评论

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