深入剖析Solmate库 #06:CREATE3.sol

CREATE3.sol 在 CREATE2 确定性地址的基础上,通过插入一个固定字节码的中间代理合约,将 initcode 从地址计算公式中剔除,实现部署地址仅由 deployer + salt 决定,彻底解耦合约地址与构造函数参数的绑定关系。

一、概述

版本说明:[solmate]:main分支 [commit: 89365b8],[forge-std]:v1.15.0

源码链接https://github.com/RevelationOfTuring/solmate/blob/main/src/utils/CREATE3.sol

CREATE3 是 solmate 的工具库,实现与 initcode 无关的确定性地址部署。整个库只有 3 个函数(deploy + 两个 getDeployed 重载),核心依赖一个 16 字节的内联代理合约(proxy)和 Bytes32AddressLib 工具库。

解决的核心问题

CREATE  地址 = f(deployer, nonce)                           ← nonce 不可控,地址不确定
CREATE2 地址 = f(deployer, salt, keccak256(initcode))       ← initcode 包含构造函数参数,参数变则地址变
CREATE3 地址 = f(deployer, salt)                            ← 与 initcode 完全无关 ✅

CREATE2 看似解决了确定性地址问题,但它的地址公式包含 keccak256(initcode)。如果合约的构造函数参数变了(例如不同链上的 admin 地址不同),initcode 就变了,部署地址也随之改变。CREATE3 通过两步部署彻底消除了 initcode 对地址的影响。

依赖关系

CREATE3.sol
  └── Bytes32AddressLib.sol  ← 提供 .fromLast20Bytes(),用于 keccak256 → address 转换

二、适用场景

适合 不适合
跨链部署同一合约到相同地址(不同链上构造函数参数不同) 只在单链部署且参数固定(CREATE2 足够)
工厂合约需要提前承诺部署地址,但 initcode 尚未确定 部署频率极高、对 gas 极度敏感(CREATE3 多一次 CREATE2 + CALL 开销)
协议升级时新旧版本需要部署到可预测地址 需要在同一 salt 下重复部署(CREATE3 每个 salt 只能用一次)
需要在部署前将地址写入其他合约的配置中 简单的一次性部署脚本

三、合约结构总览

CREATE3 (library)
│
├── Dependencies
│   └── using Bytes32AddressLib for bytes32    ← .fromLast20Bytes() 方法链
│
├── Constants
│   ├── PROXY_BYTECODE : bytes (16 bytes)      ← 代理合约的完整字节码(initcode + runtime)
│   └── PROXY_BYTECODE_HASH : bytes32          ← keccak256(PROXY_BYTECODE),用于地址预计算
│
└── Functions(3 个 internal 函数)
    ├── deploy(salt, creationCode, value) → address       ← 部署合约
    ├── getDeployed(salt) → address                       ← 预测地址(当前合约为 creator)
    └── getDeployed(salt, creator) → address              ← 预测地址(指定任意 creator)

四、核心原理:两步部署

CREATE3 的精妙之处在于将部署拆成两步,利用一个字节码固定的中间代理隔离 initcode 对地址的影响:

步骤 1: CREATE2 部署 proxy
  proxy_address = keccak256(0xFF ++ deployer ++ salt ++ keccak256(PROXY_BYTECODE))[12:32]
  → PROXY_BYTECODE 是常量 → proxy 地址只取决于 deployer + salt ✅

步骤 2: proxy 内部用 CREATE 部署最终合约
  final_address = keccak256(RLP([proxy_address, nonce=1]))[12:32]
  → proxy 地址确定 + nonce 固定为 1 → 最终地址确定 ✅

结论:final_address = f(deployer, salt) —— 与 creationCode 完全无关

完整调用链

deploy(salt, creationCode, value)
  │
  ├── 1. CREATE2(PROXY_BYTECODE, salt)
  │       → 部署 proxy 到确定地址
  │       → proxy 的 runtime = 0x363d3d37363d34f0(8 字节)
  │
  ├── 2. proxy.call{value}(creationCode)
  │       → proxy runtime 执行:
  │         CALLDATACOPY → 将 creationCode 拷贝到内存
  │         CREATE(value, 0, size) → 用 creationCode 部署最终合约
  │
  └── 3. 校验
          → require(proxy != address(0))           // CREATE2 成功
          → require(success && code.length != 0)   // 最终合约部署成功

五、源码逐行解析

5.1 PROXY_BYTECODE

bytes internal constant PROXY_BYTECODE = hex"67_36_3d_3d_37_36_3d_34_f0_3d_52_60_08_60_18_f3";

这 16 字节是 CREATE3 的核心——一个完整的代理合约字节码(initcode + runtime),可能是以太坊上最小的功能性合约之一。

字节码布局

hex"67 363d3d37363d34f0 3d 52 6008 6018 f3"
     ↑  ↑             ↑
     │  └─ runtime(8 字节)—— 被 PUSH8 嵌入为立即数
     └─ initcode 外壳(8 字节)

initcode 阶段(CREATE2 部署 proxy 时执行)

功能:将 runtime 字节码返回,使其成为 proxy 的 deployed code。

 Opcode  | Opcode + Args          | Description      | Stack
---------|------------------------|------------------|------------------
 0x67    | 0x67XXXXXXXXXXXXXXXX   | PUSH8 runtime    | runtime
 0x3d    | 0x3d                   | RETURNDATASIZE   | 0 runtime
 0x52    | 0x52                   | MSTORE           |
         |   → MSTORE(0, runtime)                    |
         |   → 内存: [零 24 字节][runtime 8 字节]      |
 0x60    | 0x6008                 | PUSH1 8          | 8
 0x60    | 0x6018                 | PUSH1 24         | 24 8
 0xf3    | 0xf3                   | RETURN           |
         |   → RETURN(offset=24, size=8)             |
         |   → 返回 runtime 作为 deployed code        |

runtime 阶段(proxy 被调用时执行)

功能:将 calldata(= creationCode)原样传给 CREATE,部署为新合约。

 Opcode  | Opcode + Args | Description      | Stack
---------|---------------|------------------|-----------------------
 0x36    | 0x36          | CALLDATASIZE     | size
 0x3d    | 0x3d          | RETURNDATASIZE   | 0 size
 0x3d    | 0x3d          | RETURNDATASIZE   | 0 0 size
 0x37    | 0x37          | CALLDATACOPY     |
         |   → CALLDATACOPY(destOffset=0, offset=0, size)
         |   → 将完整 calldata 拷贝到0x00起始的内存中
 0x36    | 0x36          | CALLDATASIZE     | size
 0x3d    | 0x3d          | RETURNDATASIZE   | 0 size
 0x34    | 0x34          | CALLVALUE        | value 0 size
 0xf0    | 0xf0          | CREATE           | newContract
         |   → CREATE(value, offset=0, size)
         |   → 用 calldata + msg.value 部署新合约

Gas 优化技巧:RETURNDATASIZE 代替 PUSH1 0

PUSH1 0x00     = 0x60 0x00 → 2 字节,3 gas
RETURNDATASIZE = 0x3d      → 1 字节,2 gas
  • proxy 执行上下文中,在使用 0x3d 的位置之前没有发生过外部调用,所以 RETURNDATASIZE 一定为 0
  • 本字节码共使用 3 次 0x3d,总计节省 3 字节 + 3 gas
  • 对 CREATE2 部署的合约,字节码越短越好(每字节 200 gas 部署成本)
  • 这是 EVM 汇编中常见的优化 pattern(EIP-1167 最小代理合约中也使用了同样技巧)

EIP-3855 PUSH0 补充:Shanghai 升级引入了 PUSH0(0x5f),1 字节、2 gas,无条件压入 0,语义更清晰。但 Solmate 编写时 PUSH0 尚未引入,保留 RETURNDATASIZE 也确保了对所有 EVM 版本的向后兼容性(PUSH0 仅在 Shanghai 及之后的链上可用)。

5.2 PROXY_BYTECODE_HASH

bytes32 internal constant PROXY_BYTECODE_HASH = keccak256(PROXY_BYTECODE);

预计算的 proxy 字节码哈希,在 getDeployed() 中作为 CREATE2 地址公式的 bytecodeHash 参数。因为 PROXY_BYTECODE 是常量,所以 hash 也是常量。

5.3 deploy

function deploy(
    bytes32 salt,
    bytes memory creationCode,
    uint256 value
) internal returns (address deployed) {

    // 将 constant 字节码拷贝到 memory
    // 原因:assembly 中的 create2 只能从 memory 读取数据,不能直接读 constant
    bytes memory proxyChildBytecode = PROXY_BYTECODE;

    address proxy;
    /// @solidity memory-safe-assembly
    assembly {
        // 用 CREATE2 部署 proxy 合约
        // create2(endowment, offset, size, salt):
        //   - 0: 不给 proxy 转 ETH(ETH 是给最终合约的)
        //   - add(proxyChildBytecode, 32): 跳过 bytes 的前 32 字节长度前缀,指向实际字节码
        //   - mload(proxyChildBytecode): 读取字节码长度(= 16 字节)
        //   - salt: 用户提供的盐值
        // 返回值:部署成功返回 proxy 地址,失败返回 address(0)
        proxy := create2(0, add(proxyChildBytecode, 32), mload(proxyChildBytecode), salt)
    }

    // proxy 地址为 0 说明 CREATE2 失败
    // 典型场景:salt 已被使用过(该地址上已有 proxy 代码)
    require(proxy != address(0), "DEPLOYMENT_FAILED");

    // 预计算最终合约地址(纯数学推导,不依赖实际部署结果)
    // 此时最终合约尚未部署,但地址已经确定
    deployed = getDeployed(salt);

    // 调用 proxy 合约,传入 creationCode 作为 calldata + 附带 ETH
    // proxy 的 runtime 逻辑:
    //   1. CALLDATACOPY → 将 creationCode 拷贝到内存
    //   2. CREATE(value, 0, calldatasize) → 用 creationCode 部署最终合约,并转入 ETH
    (bool success, ) = proxy.call{value: value}(creationCode);

    // 双重校验:
    // 校验 1: success = true → proxy.call 没有 revert
    //   大多数情况都会返回 true,唯一返回 false 的场景:call 过程中 gas 不足
    // 校验 2: deployed.code.length != 0 → 最终地址确实有合约代码
    //   为什么需要?因为 proxy 的 runtime 不检查 CREATE 返回值:
    //   constructor revert → CREATE 返回 address(0) → 但 proxy 不关心,直接 STOP
    //   → proxy.call 仍然返回 success=true → 必须额外检查目标地址是否有代码
    require(success && deployed.code.length != 0, "INITIALIZATION_FAILED");
}

作用:通过 CREATE3 模式部署合约,实现与 initcode 无关的确定性地址。内部先用 CREATE2 部署固定字节码的 proxy,再通过 proxy 用 CREATE 部署最终合约。

参数

参数 类型 含义
salt bytes32 用户自定义的盐值,与 deployer 地址共同决定最终合约地址
creationCode bytes memory 目标合约的完整创建字节码(包含构造函数参数)
value uint256 部署时转给目标合约的 ETH 数量(wei)

返回值

类型 含义
address 最终部署的合约地址

两种 revert 场景

revert 消息 触发条件 根因
"DEPLOYMENT_FAILED" proxy == address(0) CREATE2 失败:salt 已被使用(proxy 地址已有代码)
"INITIALIZATION_FAILED" !success || deployed.code.length == 0 最终合约部署失败:constructor revert / 空 creationCode / gas 不足

5.4 getDeployed(单参数)

function getDeployed(bytes32 salt) internal view returns (address) {
    // 将 address(this) 作为 creator 参数
    return getDeployed(salt, address(this));
}

作用:预计算 CREATE3 部署的最终合约地址,以当前合约(address(this))为 deployer。是双参数版本的便捷重载。

参数

参数 类型 含义
salt bytes32 用户自定义的盐值

返回值

类型 含义
address 最终合约的预测地址

5.5 getDeployed (双参数)

function getDeployed(
    bytes32 salt, 
    address creator
) internal pure returns (address) {

    // ═══════════════════════════════════════════════════════
    // 第一步:用 CREATE2 公式计算 proxy 地址
    // proxy = keccak256(0xFF ++ creator ++ salt ++ bytecodeHash)[12:32]
    // ═══════════════════════════════════════════════════════
    address proxy = keccak256(
        abi.encodePacked(
            bytes1(0xFF),        // CREATE2 地址前缀(EIP-1014)
            creator,             // 部署者地址(20 字节)
            salt,                // 用户盐值(32 字节)
            PROXY_BYTECODE_HASH  // proxy 字节码哈希(32 字节,常量)
        )
    ).fromLast20Bytes();         // 取 keccak256 结果的低 20 字节作为 proxy 地址

    // ═══════════════════════════════════════════════════════
    // 第二步:用 CREATE 公式(RLP 编码)计算最终合约地址
    // deployed = keccak256(RLP([proxy, nonce=1]))[12:32]
    // ═══════════════════════════════════════════════════════
    return
        keccak256(
            abi.encodePacked(
                // RLP 编码 [proxy, 1] 的完整字节序列:
                // 0xd6 = 0xc0 + 22 → list 前缀(后续共 22 字节)
                // 0x94 = 0x80 + 20 → string 前缀(后续 20 字节是 address)
                hex"d6_94",
                proxy,           // 代理合约地址(20 字节)
                hex"01"          // nonce = 1(proxy 第一次执行 CREATE 时 nonce 为 1,EIP-161)
            )
        ).fromLast20Bytes();     // 取低 20 字节 → 最终合约地址
}

作用:预计算 CREATE3 部署的最终合约地址,可指定任意 deployer。通过两步纯数学推导(CREATE2 公式 → RLP 编码)计算地址,不依赖任何链上状态。

参数

参数 类型 含义
salt bytes32 用户自定义的盐值
creator address 部署者地址(即调用 deploy() 的合约地址)

返回值

类型 含义
address 最终合约的预测地址

RLP 编码结构图

hex"d6_94" ++ proxy(20 bytes) ++ hex"01"

d6          ← list 前缀:0xc0 + 22 → list 前缀(后续共 22 字节)
├── 94      ← string 前缀:0x80 + 20 → string 前缀(后续 20 字节是 address)
│   └── <proxy address>  ← 20 字节
└── 01      ← nonce = 1(单字节直接编码,RLP 规则:1~127 直接编码)

为什么 nonce 是 1?

  • 合约账户的初始 nonce 为 1(EIP-161)
  • proxy 被 CREATE2 部署后,第一次执行 CREATE 时 nonce 就是 1
  • 所以 0x01 是硬编码的常量,永远不会变

六、完整调用流程图

                    用户调用 deploy(salt, creationCode, value)
                                    │
                    ┌───────────────┼───────────────┐
                    │               │               │
                    ▼               │               │
    ┌──────────────────────────┐    │               │
    │  Step 1: CREATE2         │    │               │
    │  部署 proxy 合约          │    │               │
    │                          │    │               │
    │  proxy_addr = CREATE2(   │    │               │
    │    value = 0,            │    │               │
    │    bytecode = PROXY_..., │    │               │
    │    salt = salt           │    │               │
    │  )                       │    │               │
    └──────────┬───────────────┘    │               │
               │                    │               │
               │ proxy_addr         │               │
               ▼                    │               │
    ┌──────────────────────────┐    │               │
    │  require(proxy != 0)     │    │               │
    │  → "DEPLOYMENT_FAILED"   │    │               │
    └──────────┬───────────────┘    │               │
               │                    │               │
               ▼                    │               │
    ┌──────────────────────────┐    │               │
    │  deployed = getDeployed  │    │               │
    │  (salt)                  │    │               │
    │  → 纯数学预计算            │    │               │
    └──────────┬───────────────┘    │               │
               │                    │               │
               ▼                    ▼               │
    ┌──────────────────────────────────────────┐    │
    │  Step 2: proxy.call{value}(creationCode) │    │
    │                                          │    │
    │  proxy runtime 执行:                     │    │
    │  1. CALLDATACOPY → 拷贝 creationCode      │    │
    │  2. CREATE(value, 0, size)               │    │
    │     → 部署最终合约到 deployed 地址          │    │
    └──────────┬───────────────────────────────┘    │
               │                                    │
               │ success, deployed                  │
               ▼                                    │
    ┌──────────────────────────────────────────┐    │
    │  require(success &&                      │    │
    │    deployed.code.length != 0)            │    │
    │  → "INITIALIZATION_FAILED"               │    │
    └──────────┬───────────────────────────────┘    │
               │                                    │
               ▼                                    │
         return deployed ◄──────────────────────────┘

七、地址推导公式图

                        deployer + salt
                             │
              ┌──────────────┴──────────────┐
              ▼                             │
    CREATE2 地址公式                         │
    keccak256(                              │
      0xFF                                  │
      ++ deployer                           │
      ++ salt                               │
      ++ keccak256(PROXY_BYTECODE)          │
    )[12:32]                                │
              │                             │
              ▼                             │
         proxy_address                      │
              │                             │
              ▼                             │
    CREATE 地址公式(RLP 编码)                │
    keccak256(                              │
      0xd6                     ← list 前缀   │
      ++ 0x94                  ← string 前缀 │
      ++ proxy_address         ← 20 字节     │
      ++ 0x01                  ← nonce = 1  │
    )[12:32]                                │
              │                             │
              ▼                             │
        final_address ═══════ f(deployer, salt) ← 与 creationCode 无关!

八、设计思想

8.1 固定中间层消除变量

CREATE3 的核心洞察:在 deployer 和最终合约之间插入一个字节码固定的中间代理,将"可变的 initcode"从地址公式中隔离出去。 代理的字节码是常量,所以代理地址只取决于 deployer + salt;代理的 nonce 永远为 1,所以最终地址也只取决于代理地址。两层确定性叠加 = 最终地址的确定性。

8.2 极致的字节码优化

16 字节实现了一个完整的合约部署器——包括 initcode 和 runtime 两个阶段。优化手段:

  • RETURNDATASIZE 替代 PUSH1 0(每次节省 1 字节 + 1 gas)
  • PUSH8 将 runtime 嵌入 initcode 作为立即数(无需额外的代码拷贝逻辑)
  • runtime 不检查 CREATE 返回值(由外层 Solidity 代码做双重校验)

九、安全注意事项

风险 说明 建议
salt 一次性 每个 deployer + salt 组合只能部署一次(proxy 地址被占用后无法重复 CREATE2) 设计 salt 生成策略时考虑唯一性(如 keccak256(abi.encode(name, version))
constructor revert 静默 proxy 不检查 CREATE 返回值,constructor revert 不会让 proxy.call revert deploy 函数已通过 deployed.code.length != 0 校验兜底
无权限控制 deploy 函数是 internal 的,任何继承合约都可以调用 在工厂合约中添加 onlyOwner 等权限修饰符
跨链地址一致性前提 同一 deployer 地址 + 同一 salt → 同一最终地址,但前提是 deployer 在各链上地址一致 确保工厂合约本身也是通过 CREATE2 跨链部署到相同地址
ETH 转入不可逆 value 参数直接传给 CREATE,如果 constructor revert,ETH 留在 proxy 中无法取回 proxy 没有取回机制,实际由 deploy 函数的 require 保证失败时整个交易 revert

十、与同类方案对比

方案 地址公式 initcode 无关 gas 开销 实现复杂度
CREATE f(deployer, nonce) ✅(但 nonce 不可控) 最低 最简单
CREATE2 f(deployer, salt, keccak256(initcode)) 简单
CREATE3 (solmate) f(deployer, salt) 中等(多一次 CREATE2 + CALL) 中等

十一、测试实战

全部 Foundry 测试合约https://github.com/RevelationOfTuring/foundry-solmate/blob/main/test/utils/CREATE3.t.sol

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论