Skip to content
On this page

L2-7: CREATE2(确定性部署)

1. 问题

在以太坊上部署合约时,合约地址由部署者地址和其 nonce 共同决定(CREATE 操作码)。这意味着同一份合约代码在不同链上会得到不同的地址,也无法在部署前预知地址。对于跨链桥、L2 扩容方案、ERC-4337 智能钱包等需要"多链同一地址"的场景,这是致命的。

CREATE2 操作码解决了这个问题——它让合约地址仅取决于:部署者地址、一个 32 字节的 salt、以及合约初始化代码的哈希。只要这三者不变,无论在哪条链上、部署多少次,得到的地址都相同。更强大的是,你可以在部署前计算出地址,实现"反事实实例化"——在合约实际存在之前就向其发送资金并与之交互。

本挑战要求实现一个 Create2Deployer 合约(参考 src/level2/Create2Deployer.sol),支持使用任意 salt 和 initCode 进行 CREATE2 部署,并提供链上地址预测功能。

2. 原因

CREATE2 是以太坊最关键的操作码之一。L2 桥接合约(如 Optimism、Arbitrum 的 L1 桥接)依赖 CREATE2 在 L1 和 L2 上以相同地址部署镜像合约。ERC-4337 账户抽象中的 EntryPoint 合约在所有 EVM 链上使用相同的 CREATE2 地址,确保钱包兼容性。Uniswap V4 的 Hook 合约也利用 CREATE2 进行确定性部署。

理解 CREATE2 的地址公式是深入以太坊底层 EIP 机制的重要一步。地址公式不仅仅是 keccak256 运算——它包含了 0xff 前缀、部署者地址、salt 和 initCodeHash 四个元素的拼接,其中 0xff 前缀专门用来与 CREATE 地址区分,防止碰撞攻击。掌握这个公式意味着你可以独立于任何库在链上和链下算出相同的地址,这对于安全审计和协议设计至关重要。

此外,CREATE2 还有一个重要特性:即使合约在某个地址上被 SELFDESTRUCT 销毁了,同一个 salt + initCode 组合可以再次在同一地址部署全新合约。这个"地址复用"特性既是强大的工具,也是潜在的安全陷阱——需要在使用时明确检查 address.code.length > 0 来防止覆盖已部署的合约。

3. 方案

核心架构

Create2Deployer 合约提供三个层次的接口:

  1. 部署层 (deploy 函数):使用 inline assembly 执行 CREATE2 操作码,部署前检查地址是否已被占用
  2. 地址计算层 (computeAddress 函数):实现标准的 CREATE2 地址公式,两个重载分别接受 (salt, initCodeHash)(salt, initCode)
  3. 辅助层 (computeInitCodeHash, isDeployed):提供 initCode 哈希计算和部署状态查询

地址公式

address = keccak256(0xff + sender + salt + keccak256(initCode)) 取最后 20 字节

其中:

  • 0xff:一个字节的常量前缀,防止与 CREATE 地址碰撞
  • sender:部署者合约地址(20 字节)
  • salt:用户指定的 32 字节随机数
  • keccak256(initCode):初始化代码的 keccak256 哈希(32 字节)

关键实现细节(参考 src/level2/Create2Deployer.sol

solidity
// 部署函数——先计算再部署
function deploy(bytes32 _salt, bytes calldata initCode) external returns (address deployedAddress) {
    if (initCode.length == 0) revert EmptyInitCode();

    // 预计算地址并检查是否已部署
    deployedAddress = computeAddress(_salt, computeInitCodeHash(initCode));
    if (deployedAddress.code.length > 0) revert AlreadyDeployed();

    // 从 calldata 复制 initCode 到 memory
    bytes memory _initCode = initCode;
    assembly {
        // create2(value, offset, length, salt)
        deployedAddress := create2(0, add(_initCode, 0x20), mload(_initCode), _salt)
    }

    if (deployedAddress == address(0)) revert DeploymentFailed();
    emit Deployed(deployedAddress, _salt, msg.sender);
}

关键点:

  • add(_initCode, 0x20):跳过 Solidity 动态 bytes 数组的前 32 字节长度前缀,直接指向实际字节数据
  • mload(_initCode):读取长度前缀,获取 initCode 的字节数
  • create2 返回 address(0) 表示部署失败(如 gas 不足或 initCode 执行 revert)
solidity
// 地址计算——纯函数,可在链上和链下调用
function computeAddress(bytes32 _salt, bytes32 initCodeHash) public view returns (address addr) {
    bytes32 hash = keccak256(
        abi.encodePacked(bytes1(0xff), address(this), _salt, initCodeHash)
    );
    addr = address(uint160(uint256(hash)));
}

跨链相同地址部署

要在不同链上获得相同地址,部署者合约(Create2Deployer)本身必须在每条链上有相同的地址。这可以通过两种方式实现:

  1. 在所有目标链上使用同一个 deployer 地址(需要一个已确定的部署者账户,如 Nick's Method 使用的 0x4e59b44847b379578588920cA78FbF26c0B4956C 单例工厂)
  2. 通过原始交易的 nonce 控制第一个工厂地址的确定性

4. 遭遇的陷阱

  • 已部署地址检查不充分:仅检查 deployedAddress.code.length > 0 不够,SELFDESTRUCT 后 code.length 会变为 0,导致合约被意外覆盖
  • initCode 内存布局错误:在 assembly 中直接传递 initCode 指针时,忘记 Solidity bytes 的前 32 字节是长度前缀,导致 CREATE2 读取错误数据
  • initCodeHash 混淆:将 keccak256(initCode) 与包含构造函数参数的最终字节码混淆,导致地址计算结果与实际部署地址不匹配
  • 跨链 gas 限制差异:不同链的 gas 限制不同,较大的 initCode 可能在一条链成功、另一条链失败,导致"相同地址"目标无法实现
  • Metamorphic Contract 风险:使用 CREATE2 + SELFDESTRUCT 可以在同一地址部署不同代码,可能被用于恶意合约"升级"攻击

5. 陷阱的原因

已部署地址检查依赖 code.length,这是一个运行时动态属性。合约被 SELFDESTRUCT 后,代码从状态中删除但地址仍然存在——下次 CREATE2 时相同的 salt + initCode 会在同一地址生成新合约。这种"地址复活"特性在某些场景有用(如 Metamorphic Contract Factory),但如果不加以防范,会意外覆盖用户的资金或逻辑。

initCode 内存布局问题源于 Solidity 的 ABI 编码:在 memory 中,bytes 类型的前 32 字节存储长度,实际字节数据从偏移 32 字节开始。而在 calldata 中,ABI 编码本身就有 32 字节的偏移量和长度字段。当使用 assemblycreate2 时,需要自己控制指针位置和长度——add(_initCode, 0x20) 跳过长度前缀,mload(_initCode) 读取长度值。

initCodeHash 的计算对象必须是合约的"创建字节码"(creation bytecode),而不是"运行时字节码"(runtime bytecode)。创建字节码包含构造函数逻辑和参数,在执行后才会生成运行时字节码。很多人从 Remix 或 etherscan 复制的是运行时字节码,用它来计算地址会导致完全错误的结果。

6. 如何解决陷阱

针对已部署地址检查不充分:除了检查 code.length > 0,可以增加额外的防覆盖机制。例如,记录已部署的 salt 到映射表中,或者检查合约是否"活跃"(如通过特定存储槽值判断):

solidity
mapping(bytes32 => bool) public saltUsed;

function deploy(bytes32 _salt, bytes calldata initCode) external returns (address) {
    // 双重检查:代码长度 + salt 使用记录
    address predicted = computeAddress(_salt, computeInitCodeHash(initCode));
    if (deployed.code.length > 0 || saltUsed[_salt]) revert AlreadyDeployed();
    saltUsed[_salt] = true;
    // ... 部署逻辑
}

针对 initCode 内存布局错误:始终将 calldata bytes 复制到 memory 变量后再传入 assembly 块;使用 add(ptr, 0x20) 跳过长度前缀;使用 mload(ptr) 获取长度。避免在 assembly 中直接操作 calldata 的 bytes 变量(Solidity 0.8 的 calldata 变量在 assembly 中有不同的表示方式):

solidity
bytes memory _initCode = initCode;  // 复制到 memory
assembly {
    addr := create2(0, add(_initCode, 0x20), mload(_initCode), _salt)
}

针对 initCodeHash 混淆:确保使用合约创建字节码的 keccak256 哈希。在 Foundry 中可以通过 vm.getCode("ContractName") 获取创建字节码;在 ethers.js 中使用 ethers.keccak256(contractFactory.bytecode)。始终使用 computeInitCodeHash() 辅助函数统一哈希计算入口。

针对跨链 gas 限制:部署前在目标链的测试网验证 gas 消耗;设计 initCode 时避免过大的构造函数逻辑;可以在构造函数中使用 immutable 变量而非 storage 写入来减少部署 gas。

7. 技术要点

要点说明
CREATE2 vs CREATECREATE2 地址不依赖 nonce,仅依赖 (0xff, deployer, salt, initCodeHash)
地址公式keccak256(0xff + deployer + salt + keccak256(initCode))[:20]
0xff 前缀防止与 CREATE 地址碰撞,在合约地址空间中明确区分两种部署方式
反事实实例化在合约部署前就能计算并与之交互(发送 ETH、授权等)
Metamorphic 合约CREATE2 + SELFDESTRUCT = 同一地址可部署不同代码,需谨慎使用
Assembly 操作码create2(value, offset, length, salt) — 注意 offset 需跳过 bytes 长度前缀
跨链部署deployer 地址本身需在每条链上相同(通过 deterministic deployer 或 nonce 控制)
地址复用SELFDESTRUCT 后的地址可被 CREATE2 重新利用,需 code.length 检查

Built with AiAda