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 #状态机
今日目标
- 深入理解 Solidity 四大核心设计模式
- 掌握 Factory Pattern 的两种实现方式(
newvsCREATE2) - 理解 Proxy Pattern 的概念和适用场景
- 实现状态机模式和 Pull Payment 模式
- 编写完整的 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 的应用场景:
- Uniswap V2 Pair: 用
token0 + token1作为 salt,任何人都能算出交易对地址 - Counterfactual Deployment: 先在地址上接收资金,之后再部署合约
- 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
代理模式的核心原理:
- Proxy 合约持有所有状态(storage)和用户交互入口
- Implementation 合约持有业务逻辑
- Proxy 用
delegatecall调用 Implementation,代码在 Proxy 的存储上下文中执行 - 升级时只需更换 Implementation 地址
2.2 代理模式的变体
| 模式 | 说明 | 代表 |
|---|---|---|
| Transparent Proxy | 管理员调用走 admin 逻辑,用户调用走 implementation | OpenZeppelin |
| 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: 固定前缀,区分于 CREATEdeployer: 工厂合约地址salt: 自定义值 (bytes32)bytecode: creation code + constructor args 的 hash[12:]: 取最后 20 字节作为地址
Pull over Push 核心理念
永远不要在循环中给外部地址转账。原因:
- 某个地址可能是恶意合约(无 receive 函数,导致 revert)
- Gas 成本随接收者数量线性增长
- 单个失败导致整批交易回滚
常见误区
- CREATE2 的 salt 不加 msg.sender: 攻击者可以 front-run 你的交易,用相同的 salt 提前部署,抢占你预期的地址
- 工厂合约存储过多数据: 链上存储昂贵,考虑只存事件,链下索引
- 状态机缺少最终状态: 确保每条路径都能到达终态,避免资金永久锁定
- Pull Payment 不清零再转账: 违反 CEI 模式,存在重入风险
- 代理模式存储冲突: 代理合约和实现合约的状态变量顺序必须一致
面试关联
| 面试题 | 本课关联 |
|---|---|
| "Uniswap 如何计算 Pair 合约地址?" | CREATE2 + salt = keccak256(token0, token1) |
| "智能合约如何实现可升级?" | 代理模式 + delegatecall |
| "为什么 DeFi 协议用 Pull Payment?" | 安全性 + gas 效率 |
| "设计一个空投分发合约" | Pull Payment + Merkle Tree |
| "如何节省大量合约部署的 Gas?" | EIP-1167 Clone Pattern |
| "CREATE 和 CREATE2 的区别?" | 地址可预测性 + 公式 |