代理与可升级性——最小代理(EIP-1167)

本文介绍了EIP-1167最小代理(Minimal Proxy)合约,它通过部署极小的bytecode stub,将所有调用委托给单个实现合约,从而降低了大量合约实例的部署成本。与普通代理不同,最小代理不可升级,但非常小巧高效,适用于需要大量相同逻辑但独立状态的场景,例如DEX中的流动性交易对。

代理和可升级性 — 最小代理 (EIP-1167)

工厂模式使得部署多个合约变得容易,但是每次部署仍然需要为完整的字节码支付 gas。

如果你能以一小部分的成本部署数千个实例呢?

这就是 EIP-1167,最小代理(或克隆)模式背后的思想。

最小代理不是每次都重新部署整个逻辑,而是只保存几个字节的代码,这些代码将每个调用委托给一个单独的实现合约。

每个实例的行为都像一个完整的合约,但重用相同的逻辑:节省 gas,存储和字节码大小。

在这篇文章中,我们将介绍:

1. EIP-1167 如何使用一个微小的字节码存根来降低部署成本

2. 普通代理和克隆之间的区别

3. 使用 Foundry 在链上部署和验证最小代理的演练

最后,你将理解不同的协议如何在不一次又一次地重新部署逻辑的情况下扩展大规模部署。

最小代理 (EIP-1167)

并非所有代理都与升级有关。 有时,目标不是永远保持一个地址稳定 - 而是廉价地冲压出许多相同的合约,每个合约都有自己的状态但共享相同的逻辑。 这正是 EIP-1167 “最小代理”合约(又名 克隆)的设计目的。

什么是最小代理?

克隆只是大约 45 字节的运行时代码,它使用 DELEGATECALL 将每个调用转发到固定的实现。

  • 逻辑合约(“主副本”)保存所有函数。
  • 每个克隆不存储任何逻辑,只存储数据。 对克隆的调用在其存储中执行,但使用主副本的代码。
  • 克隆之间唯一的区别是它们的状态,而不是它们的代码。

因为实现地址是嵌入到克隆的字节码中的,所以克隆以后无法升级。 这使得它们非常小,gas 效率高且可预测,但也与可升级代理从根本上不同。

克隆的字节码如何工作

EIP-1167 背后的“魔力”在于所有克隆共享相同的小运行时代码,只有一个可变部分:嵌入的实现地址。

规范的运行时如下所示(十六进制):

363d3d373d3d3d363d73<20-byte-impl>5af43d82803e903d91602b57fd5bf3

// <20-byte-impl> 可以是: 0xbebebebebe.... (共享逻辑地址)

OPcode 解释

按回车键或单击以全尺寸查看图像

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

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

最小示例

minimalProxy.sol

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

/* ---------------------------------- */
/* 1) 实现 (共享逻辑)   */
/* ---------------------------------- */
contract Counter {
    address public owner;
    uint256 public value;
    bool private _initialized;
    // 在部署后通过克隆调用
    function initialize(address _owner, uint256 start) external {
        require(!_initialized, "already initialized");
        _initialized = true;
        owner = _owner;
        value = start;
    }
    function inc() external {
        require(msg.sender == owner, "not owner");
        unchecked { value += 1; }
    }
    // 可选项: 阻止初始化逻辑合约本身
    constructor() {
        _initialized = true;
    }
}
/* ---------------------------------- */
/* 2) 最小克隆工厂           */
/* ---------------------------------- */
contract CounterCloneFactory {
    event CloneCreated(address indexed clone, address indexed owner, uint256 start, bytes32 salt);
    /* ---- 内部助手: 构建克隆的创建代码 ---- */
    // 克隆创建代码 = 返回 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"
        );
    }
    /* ---- 使用 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));
    }
    /* ---- 使用 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

// 预期输出:
// [⠊] Compiling...
// No files changed, compilation skipped
// Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// Deployed to: 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707
//Transaction hash: 0x43f37888e5c370cc1a398f1891cea860a80815fdf59716d4e2d8adb198b5edb8
// 最小代理工厂的部署
forge create src/minimalProxy.sol:CounterCloneFactory \
  --rpc-url localhost:8545 \
  --private-key <YOUR-ANVIL-PK> --broadcast
// 预期输出:
// [⠊] Compiling...
// No files changed, compilation skipped
// Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// Deployed to: 0x0165878A594ca255338adfa4d48449f69242Eb8F
// Transaction hash: 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 状态中。

这是昂贵且浪费的:代码永远不会改变,它只是重复。

总结

最小代理将工厂概念发挥到了极致:重用除状态之外的所有内容。

每个克隆不是重新部署完整的逻辑字节码,而是仅存储一个 45 字节的 delegatecall 存根,该存根将执行转发到共享实现。

这种设计大大降低了部署成本,同时为每个实例维护了隔离状态,非常适合需要启动大量相同合约的协议。

它是许多著名协议背后的模式,并且是任何旨在在 EVM 上有效扩展的协议的基础工具。

工厂创造了一致性,最小代理使其负担得起。

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

0 条评论

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