订单生命周期:从创建到消亡的完整旅程

做链上订单簿,说白了就是管理订单的「生老病死」。

我刚开始接触这个领域时,觉得订单不就是挂上去、成交、取消嘛,能有多复杂?直到我在一个项目中遇到内存池拥堵导致的订单状态混乱,才意识到——订单状态机设计不好,整个系统都会崩。

今天咱们就聊聊订单的完整生命周期。我会结合自己踩过的坑,把每个环节掰开揉碎了讲。

订单状态机:核心骨架

先看一张我画的订单状态流转图。这张图我每次做新项目都会贴在墙上——

创建 活跃 部分成交 完全成交 已取消 提交 部分匹配 完全匹配 取消 取消

这张图我看了不下百遍。每个箭头背后都有一段故事。

创建订单:一切从这里开始

创建订单,就是把用户的意图「刻」到链上。

用户说「我想用 2000 USDC 买 1 个 ETH,单价 2000」,我们就得把这个意图打包成一笔交易发出去。

代码层面,核心逻辑是这样的——

function createOrder(
    address maker,
    uint256 price,
    uint256 amount,
    bool isBuy
) external returns (uint256 orderId) {
    // 检查用户余额
    require(balanceOf[maker] >= amount, "余额不足");
    
    // 锁定资产
    _lockAsset(maker, amount);
    
    // 生成订单ID
    orderId = nextOrderId++;
    
    // 存储订单
    orders[orderId] = Order({
        maker: maker,
        price: price,
        amount: amount,
        filled: 0,
        isBuy: isBuy,
        status: OrderStatus.ACTIVE,
        createdAt: block.timestamp
    });
    
    // 插入价格队列
    _insertIntoOrderBook(orderId, price, isBuy);
    
    emit OrderCreated(orderId, maker, price, amount, isBuy);
}
我的经验:创建订单时一定要先锁定资产。我曾经见过一个项目没做这一步,结果用户创建了卖单后立刻转走资产,导致吃单方买了空气。嗯,那项目后来被骂惨了。

取消订单:不是想撤就能撤

取消订单看似简单,但有几个坑要注意。

首先,只有订单创建者才能取消。这个权限检查不能少。

其次,取消时要归还锁定的资产。我见过有人忘了这步,资产直接锁死在合约里——

function cancelOrder(uint256 orderId) external {
    Order storage order = orders[orderId];
    
    // 权限检查
    require(order.maker == msg.sender, "不是你的订单");
    
    // 状态检查
    require(order.status == OrderStatus.ACTIVE, "订单已结束");
    
    // 更新状态
    order.status = OrderStatus.CANCELLED;
    
    // 归还资产
    _unlockAsset(order.maker, order.amount - order.filled);
    
    // 从订单簿移除
    _removeFromOrderBook(orderId, order.price, order.isBuy);
    
    emit OrderCancelled(orderId);
}
注意:取消订单时,如果订单已经部分成交,只需要归还剩余未成交的部分。我曾经犯过这个错,把全部资产都退回去了,结果成交的那部分也退给了用户——亏大了。

部分成交:最常见的状态

说实话,完全成交的订单其实不多。大部分订单都是部分成交,然后慢慢「啃」完。

为什么会这样?因为链上流动性是碎片化的。你挂一个 100 ETH 的买单,可能对面只有 0.5 ETH 的卖单。那就先吃 0.5,剩下的继续挂着。

部分成交的逻辑——

function _matchOrder(uint256 takerOrderId, uint256 makerOrderId, uint256 matchAmount) internal {
    Order storage makerOrder = orders[makerOrderId];
    
    // 更新已成交数量
    makerOrder.filled += matchAmount;
    
    // 判断是否完全成交
    if (makerOrder.filled >= makerOrder.amount) {
        makerOrder.status = OrderStatus.FULLY_FILLED;
        _removeFromOrderBook(makerOrderId, makerOrder.price, makerOrder.isBuy);
    }
    
    // 转移资产
    _transferAsset(makerOrder.maker, takerOrderId, matchAmount);
}

关键点:部分成交时,订单状态仍然是 ACTIVE,只是 filled 字段增加了。直到 filled == amount,才变成 FULLY_FILLED。

完全成交:订单的终点

完全成交意味着订单的 amount 全部被吃掉了。这时候要做三件事——

  1. 更新状态为 FULLY_FILLED
  2. 从订单簿中移除
  3. 释放所有锁定的资产(其实已经转移走了)

我习惯在完全成交时触发一个事件,方便前端做 UI 更新——

event OrderFullyFilled(
    uint256 indexed orderId,
    address indexed maker,
    uint256 totalAmount,
    uint256 totalValue
);

订单状态机:一张表说清楚

当前状态 触发事件 下一状态 备注
PENDING 交易确认 ACTIVE 创建后立即进入
ACTIVE 部分匹配 ACTIVE filled 增加,状态不变
ACTIVE 完全匹配 FULLY_FILLED 订单完成
ACTIVE 用户取消 CANCELLED 归还剩余资产
ACTIVE 超时 EXPIRED 可选功能
避坑指南:我曾经在状态机里漏掉了「ACTIVE 状态下不能重复取消」的检查。结果用户连续调用两次 cancelOrder,第二次调用时订单已经取消了,但合约没检查状态,直接又归还了一次资产。嗯,那笔账我到现在还记得。

实战中的几个细节

说几个我实际项目中遇到的坑——

  • Gas 优化:创建订单时,尽量把数据打包成 struct 存储,减少 storage 操作次数。我见过有人每个字段单独存,Gas 费直接翻倍。
  • 重入攻击:取消订单时,先更新状态再转账。顺序反了就可能被重入攻击。Solidity 的 Checks-Effects-Interactions 模式不是白说的。
  • 精度问题:部分成交时,如果金额不能整除,剩下的零头怎么处理?我习惯把零头退还给 maker,而不是吃掉。公平第一。

订单生命周期,说白了就是管理好「创建→匹配→成交/取消」这条线。状态机设计好了,后面的事情就顺了。

你想想看,如果状态机有漏洞,用户就能钻空子。轻则损失手续费,重则合约被掏空。所以这块一定要慎之又慎。


公众号:蓝海资料掘金营,微信deep3321