返回 SC 笔记
SC Day 22

Solidity 设计模式 - 工厂模式/代理模式/状态机/Pull over Push

### 一、工厂模式 (Factory Pattern)

2026-04-22
第一阶段:基础构建 (Day 21-24)
Solidity设计模式工厂模式代理模式CREATE2状态机

日期: 2026-04-22 方向: Solidity 阶段: 第一阶段:基础构建 (Day 21-24) 标签: #Solidity #设计模式 #工厂模式 #代理模式 #CREATE2 #状态机


今日目标

  1. 深入理解 Solidity 四大核心设计模式
  2. 掌握 Factory Pattern 的两种实现方式(new vs CREATE2
  3. 理解 Proxy Pattern 的概念和适用场景
  4. 实现状态机模式和 Pull Payment 模式
  5. 编写完整的 TokenFactory 合约(使用 CREATE2)

核心概念

一、工厂模式 (Factory Pattern)

工厂模式允许一个合约动态部署其他合约。这是 DeFi 协议中最常用的模式 — Uniswap 的 Factory 合约就是用来创建交易对的。

1.1 为什么需要工厂模式?

场景不用工厂用工厂
部署新代币每次手动部署调用工厂的 create() 方法
地址管理手动记录每个合约地址工厂自动注册和索引
标准化无法保证合约一致性统一模板,保证行为一致
Gas 成本每次完整部署可用 Clone 节省 Gas
可预测地址地址随机CREATE2 可预测地址

1.2 CREATE vs CREATE2

EVM 有两种创建合约的操作码:

CREATE — 传统方式:

address = keccak256(sender, nonce)
  • 地址取决于部署者地址 + nonce
  • nonce 每次部署递增,地址不可预测
  • 同一份代码在不同 nonce 下部署到不同地址

CREATE2 — 确定性部署(EIP-1014):

address = keccak256(0xFF, sender, salt, keccak256(bytecode))
  • 地址取决于:部署者地址 + salt + bytecode 的 hash
  • 只要 salt 和 bytecode 不变,地址在部署前就可以计算出来
  • 这使得"先承诺地址、后部署合约"成为可能

CREATE2 的应用场景

  1. Uniswap V2 Pair: 用 token0 + token1 作为 salt,任何人都能算出交易对地址
  2. Counterfactual Deployment: 先在地址上接收资金,之后再部署合约
  3. Account Abstraction: 预先计算钱包地址

1.3 Clone Pattern (EIP-1167 Minimal Proxy)

当部署大量相同逻辑的合约时,Clone 模式可以大幅节省 Gas:

方式Gas 成本说明
普通 CREATE~200K-500K完整部署整个字节码
CREATE2同上 + salt完整部署,地址可预测
EIP-1167 Clone~41K只部署一个 45 字节的 proxy

Clone 的原理是部署一个极小的代理合约,所有调用都 delegatecall 到实现合约。

二、代理模式 (Proxy Pattern)

代理模式是智能合约可升级性的基础。核心思想:分离逻辑和状态

用户 --> Proxy (保存状态) --delegatecall--> Implementation (提供逻辑)

2.1 为什么需要代理模式?

智能合约一旦部署就不可更改。但:

  • 可能需要修复 bug
  • 可能需要添加新功能
  • 可能需要优化 Gas

代理模式的核心原理:

  1. Proxy 合约持有所有状态(storage)和用户交互入口
  2. Implementation 合约持有业务逻辑
  3. Proxy 用 delegatecall 调用 Implementation,代码在 Proxy 的存储上下文中执行
  4. 升级时只需更换 Implementation 地址

2.2 代理模式的变体

模式说明代表
Transparent Proxy管理员调用走 admin 逻辑,用户调用走 implementationOpenZeppelin
UUPS升级逻辑在 implementation 中OpenZeppelin, 更省 Gas
Beacon Proxy多个 proxy 共享一个 beacon 来获取 implementation 地址批量升级
Diamond (EIP-2535)多个 implementation (facets)复杂系统

三、状态机模式 (State Machine Pattern)

很多 DeFi 操作本质上是状态机:众筹的 Active -> Successful -> Closed,拍卖的 Created -> Bidding -> Ended -> Settled。

状态机核心原则:
1. 每个状态有明确的允许操作
2. 状态转换有严格的条件
3. 使用 enum 定义状态
4. 使用 modifier 限制状态下的操作

四、Pull over Push Payment

Push Payment(主动推送)—— 有安全风险:

// 危险! 如果某个地址是无法接收ETH的合约,整个操作就卡住了
function distribute(address[] memory recipients, uint256[] memory amounts) external {
    for (uint i = 0; i < recipients.length; i++) {
        payable(recipients[i]).transfer(amounts[i]); // 单个失败就全部回滚
    }
}

Pull Payment(被动提取)—— 推荐做法:

// 安全! 每个人自己来取钱,一个人失败不影响其他人
mapping(address => uint256) public pendingWithdrawals;

function withdraw() external {
    uint256 amount = pendingWithdrawals[msg.sender];
    require(amount > 0, "Nothing to withdraw");
    pendingWithdrawals[msg.sender] = 0;
    payable(msg.sender).transfer(amount);
}

代码实战

完整 TokenFactory 合约 (CREATE2)

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

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

/**
 * @title SimpleToken
 * @notice 由 TokenFactory 部署的标准 ERC20 代币
 */
contract SimpleToken is ERC20, Ownable {
    uint8 private _decimals;

    constructor(
        string memory name_,
        string memory symbol_,
        uint8 decimals_,
        uint256 initialSupply,
        address owner_
    ) ERC20(name_, symbol_) Ownable(owner_) {
        _decimals = decimals_;
        _mint(owner_, initialSupply);
    }

    function decimals() public view override returns (uint8) {
        return _decimals;
    }

    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }

    function burn(uint256 amount) external {
        _burn(msg.sender, amount);
    }
}

/**
 * @title TokenFactory
 * @notice 使用 CREATE2 部署 ERC20 代币,地址可预测
 * @dev 演示工厂模式 + CREATE2 + 事件索引 + Pull Payment
 */
contract TokenFactory {
    // ============ 状态变量 ============

    struct TokenInfo {
        address tokenAddress;
        string name;
        string symbol;
        address creator;
        uint256 createdAt;
    }

    // 所有已创建的代币
    TokenInfo[] public tokens;

    // 创建者 => 其创建的代币地址列表
    mapping(address => address[]) public creatorTokens;

    // salt => 是否已使用 (防止同一 salt 重复使用)
    mapping(bytes32 => bool) public saltUsed;

    // 创建费用 (Pull Payment 模式)
    uint256 public creationFee = 0.01 ether;
    address public owner;
    uint256 public collectedFees;

    // ============ 事件 ============

    event TokenCreated(
        address indexed tokenAddress,
        address indexed creator,
        string name,
        string symbol,
        uint256 initialSupply
    );

    event FeesWithdrawn(address indexed owner, uint256 amount);

    // ============ 修饰符 ============

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    // ============ 构造函数 ============

    constructor() {
        owner = msg.sender;
    }

    // ============ 核心函数 ============

    /**
     * @notice 使用 CREATE2 部署新的 ERC20 代币
     * @param name_ 代币名称
     * @param symbol_ 代币符号
     * @param decimals_ 小数位数
     * @param initialSupply 初始供应量 (已含 decimals)
     * @param salt 用于 CREATE2 的盐值
     * @return tokenAddress 新部署的代币地址
     */
    function createToken(
        string calldata name_,
        string calldata symbol_,
        uint8 decimals_,
        uint256 initialSupply,
        bytes32 salt
    ) external payable returns (address tokenAddress) {
        // 1. 检查
        require(msg.value >= creationFee, "Insufficient fee");
        require(!saltUsed[salt], "Salt already used");
        require(bytes(name_).length > 0, "Empty name");
        require(bytes(symbol_).length > 0, "Empty symbol");
        require(initialSupply > 0, "Zero supply");

        // 2. 标记 salt 已使用
        saltUsed[salt] = true;

        // 3. 累计费用 (Pull Payment)
        collectedFees += msg.value;

        // 4. 使用 CREATE2 部署
        // 将 salt 与 msg.sender 结合,防止 front-running
        bytes32 finalSalt = keccak256(abi.encodePacked(msg.sender, salt));

        // CREATE2: new Contract{salt: ...}(args)
        SimpleToken token = new SimpleToken{salt: finalSalt}(
            name_,
            symbol_,
            decimals_,
            initialSupply,
            msg.sender  // 代币的 owner 是调用者
        );

        tokenAddress = address(token);

        // 5. 记录
        tokens.push(TokenInfo({
            tokenAddress: tokenAddress,
            name: name_,
            symbol: symbol_,
            creator: msg.sender,
            createdAt: block.timestamp
        }));

        creatorTokens[msg.sender].push(tokenAddress);

        // 6. 发出事件
        emit TokenCreated(tokenAddress, msg.sender, name_, symbol_, initialSupply);
    }

    /**
     * @notice 预测 CREATE2 部署地址 (部署前就知道地址!)
     * @dev 使用与 createToken 相同的 salt 计算逻辑
     */
    function predictAddress(
        string calldata name_,
        string calldata symbol_,
        uint8 decimals_,
        uint256 initialSupply,
        address creator,
        bytes32 salt
    ) external view returns (address predicted) {
        bytes32 finalSalt = keccak256(abi.encodePacked(creator, salt));

        // CREATE2 地址 = keccak256(0xFF, deployer, salt, keccak256(bytecode))
        bytes memory bytecode = abi.encodePacked(
            type(SimpleToken).creationCode,
            abi.encode(name_, symbol_, decimals_, initialSupply, creator)
        );

        bytes32 hash = keccak256(
            abi.encodePacked(
                bytes1(0xff),
                address(this),
                finalSalt,
                keccak256(bytecode)
            )
        );

        predicted = address(uint160(uint256(hash)));
    }

    // ============ Pull Payment: 费用提取 ============

    /**
     * @notice Owner 提取累积的创建费用
     */
    function withdrawFees() external onlyOwner {
        uint256 amount = collectedFees;
        require(amount > 0, "No fees to withdraw");

        collectedFees = 0; // CEI: 先更新状态
        (bool success, ) = owner.call{value: amount}("");
        require(success, "Transfer failed");

        emit FeesWithdrawn(owner, amount);
    }

    // ============ 查询函数 ============

    function getTokenCount() external view returns (uint256) {
        return tokens.length;
    }

    function getCreatorTokens(address creator) external view returns (address[] memory) {
        return creatorTokens[creator];
    }

    function getTokenInfo(uint256 index) external view returns (TokenInfo memory) {
        require(index < tokens.length, "Index out of bounds");
        return tokens[index];
    }

    // ============ 管理函数 ============

    function setCreationFee(uint256 newFee) external onlyOwner {
        creationFee = newFee;
    }
}

状态机模式实战 — 荷兰拍卖

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

/**
 * @title DutchAuction
 * @notice 荷兰拍卖: 价格从高到低递减,第一个出价者获胜
 * @dev 演示状态机模式
 */
contract DutchAuction {
    // ============ 状态定义 ============

    enum AuctionState {
        Created,    // 已创建,尚未开始
        Active,     // 进行中,价格递减
        Sold,       // 已售出
        Expired,    // 已过期,无人购买
        Cancelled   // 已取消
    }

    // ============ 状态变量 ============

    address public seller;
    address public buyer;

    uint256 public startPrice;
    uint256 public endPrice;
    uint256 public startTime;
    uint256 public duration;

    AuctionState public state;

    // Pull Payment
    mapping(address => uint256) public pendingReturns;

    // ============ 事件 ============

    event AuctionStarted(uint256 startPrice, uint256 endPrice, uint256 duration);
    event AuctionSold(address buyer, uint256 price);
    event AuctionExpired();
    event AuctionCancelled();

    // ============ 状态机修饰符 ============

    modifier inState(AuctionState expected) {
        require(state == expected, "Invalid auction state");
        _;
    }

    modifier onlySeller() {
        require(msg.sender == seller, "Only seller");
        _;
    }

    // ============ 构造函数 ============

    constructor(
        uint256 _startPrice,
        uint256 _endPrice,
        uint256 _duration
    ) {
        require(_startPrice > _endPrice, "Start > End");
        require(_duration > 0, "Duration > 0");

        seller = msg.sender;
        startPrice = _startPrice;
        endPrice = _endPrice;
        duration = _duration;
        state = AuctionState.Created;
    }

    // ============ 状态转换函数 ============

    /// @notice 启动拍卖 (Created -> Active)
    function start() external onlySeller inState(AuctionState.Created) {
        state = AuctionState.Active;
        startTime = block.timestamp;
        emit AuctionStarted(startPrice, endPrice, duration);
    }

    /// @notice 购买 (Active -> Sold)
    function buy() external payable inState(AuctionState.Active) {
        uint256 price = currentPrice();
        require(price > 0, "Auction expired");
        require(msg.value >= price, "Insufficient payment");

        state = AuctionState.Sold;
        buyer = msg.sender;

        // 多余的 ETH 记入 Pull Payment
        if (msg.value > price) {
            pendingReturns[msg.sender] += msg.value - price;
        }

        // 卖家收入也走 Pull Payment
        pendingReturns[seller] += price;

        emit AuctionSold(msg.sender, price);
    }

    /// @notice 取消拍卖 (Created/Active -> Cancelled)
    function cancel() external onlySeller {
        require(
            state == AuctionState.Created || state == AuctionState.Active,
            "Cannot cancel"
        );
        state = AuctionState.Cancelled;
        emit AuctionCancelled();
    }

    /// @notice 标记过期 (Active -> Expired)
    function markExpired() external inState(AuctionState.Active) {
        require(block.timestamp >= startTime + duration, "Not expired yet");
        state = AuctionState.Expired;
        emit AuctionExpired();
    }

    // ============ 查询函数 ============

    /// @notice 获取当前价格 (线性递减)
    function currentPrice() public view returns (uint256) {
        if (state != AuctionState.Active) return 0;
        if (block.timestamp >= startTime + duration) return 0;

        uint256 elapsed = block.timestamp - startTime;
        uint256 priceDrop = ((startPrice - endPrice) * elapsed) / duration;
        return startPrice - priceDrop;
    }

    // ============ Pull Payment ============

    function withdraw() external {
        uint256 amount = pendingReturns[msg.sender];
        require(amount > 0, "Nothing to withdraw");
        pendingReturns[msg.sender] = 0;
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
    }
}

状态转换图

  Created ----start()----> Active ----buy()----> Sold
     |                       |
     |---cancel()---+        |---markExpired()---> Expired
                    |        |
                    v        |---cancel()---+
                Cancelled <-----------------+

关键要点总结

四大模式适用场景

模式适用场景DeFi 实例
工厂模式需要批量创建标准化合约Uniswap Factory, Gnosis Safe Factory
代理模式合约需要可升级Aave V3, Compound V3
状态机有明确生命周期的操作众筹、拍卖、借贷、治理提案
Pull Payment需要给多人分钱空投领取、收益分配、退款

CREATE2 地址计算公式

address = keccak256(0xFF ++ deployer ++ salt ++ keccak256(bytecode))[12:]
  • 0xFF: 固定前缀,区分于 CREATE
  • deployer: 工厂合约地址
  • salt: 自定义值 (bytes32)
  • bytecode: creation code + constructor args 的 hash
  • [12:]: 取最后 20 字节作为地址

Pull over Push 核心理念

永远不要在循环中给外部地址转账。原因:

  1. 某个地址可能是恶意合约(无 receive 函数,导致 revert)
  2. Gas 成本随接收者数量线性增长
  3. 单个失败导致整批交易回滚

常见误区

  1. CREATE2 的 salt 不加 msg.sender: 攻击者可以 front-run 你的交易,用相同的 salt 提前部署,抢占你预期的地址
  2. 工厂合约存储过多数据: 链上存储昂贵,考虑只存事件,链下索引
  3. 状态机缺少最终状态: 确保每条路径都能到达终态,避免资金永久锁定
  4. Pull Payment 不清零再转账: 违反 CEI 模式,存在重入风险
  5. 代理模式存储冲突: 代理合约和实现合约的状态变量顺序必须一致

面试关联

面试题本课关联
"Uniswap 如何计算 Pair 合约地址?"CREATE2 + salt = keccak256(token0, token1)
"智能合约如何实现可升级?"代理模式 + delegatecall
"为什么 DeFi 协议用 Pull Payment?"安全性 + gas 效率
"设计一个空投分发合约"Pull Payment + Merkle Tree
"如何节省大量合约部署的 Gas?"EIP-1167 Clone Pattern
"CREATE 和 CREATE2 的区别?"地址可预测性 + 公式

参考资源