本文深入探讨了链上订单簿在实际应用中面临的19个常见问题,涵盖了从交易抢跑、订单唯一性到Gas用量、时间逻辑以及预言机利用等多个方面。文章不仅分析了这些问题的根本原因,还提供了相应的缓解措施,旨在帮助智能合约工程师、协议架构师和审计人员构建更安全、可靠的去中心化交易系统。
链上订单簿在纸面上看起来很棒。你可以获得完全的透明性,与 DeFi 的其余部分的可组合性,以及对用户和监管机构来说清晰的故事:“一切都在链上”。
但是,一旦你开始实施它们,事情就会变得很快变得混乱。围绕撮合、部分成交、取消、gas 飙升、清算和预言机更新的极端情况出现在你未计划的地方。一个单一的漏洞可能会冻结交易,将价值泄漏给 MEV 机器人,或者随着时间的推移悄悄地破坏你的账簿。
这篇文章是为那些正在实际构建或审查这些系统的人准备的——智能合约工程师、协议架构师,以及从事现货 DEX、永续合约、RFQ 引擎或混合型应用审计员。我们不会重新解释限价订单如何工作。相反,我们将专注于现实世界中的实现往往会出错的地方。
在无需许可的区块链中,交易在包含在一个区块之前在 mempool 中公开可见。在公共 mempool 中创建、取消和执行订单允许对手监控所有活动并有策略地重新排序他们的交易。缺少承诺-揭示阶段使得攻击者能够在限价订单变得有利可图时立即狙击它们。
类似地,当取消被即时处理且没有摩擦时,交易者可能会观察到一个传入的订单,并在同一区块内取消或重新发布他们自己的订单,从而操纵队列优先级或避免不利的成交。这种成交和取消之间的竞争也可能导致 taker 的交易意外回滚,导致不可预测的用户体验并破坏订单的可靠性。
如果订单哈希或签名不能紧密匹配所有用户预期的参数(包括 nonce/salt、合约和链上下文),则有效的链下签名可能会在多个订单、链或合约升级中被重新提交或重放。如果没有严格执行的唯一性,先前已成交或已取消的订单可以通过重新提交签名来“复活”,从而导致双重成交或未经授权的执行。在多链部署或协议升级中,未能对签名进行域分离会启用跨链和跨合约重放攻击,可能会耗尽多个部署中的流动性。
bytes32 DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("MyDEX")),
keccak256(bytes("1")),
block.chainid,
address(this)
)
);
bytes32 orderHash = keccak256(
abi.encode(
ORDER_TYPEHASH,
maker,
taker,
tokenGive,
tokenGet,
amountGive,
amountGet,
expiration,
nonce
)
);
部分成交需要对“剩余数量”和“可用数量”字段进行细致的管理。未能原子性地更新这些字段,或未能在并发交易期间协调差异,可能导致可用余额与订单意图失去同步。这可能允许 taker 成交超过预期数量,或导致订单卡在“无法成交”的状态。当计算不使用精确的定点运算时,也可能出现错误,导致灰尘级别的成交或舍入误差累积,并最终表现为非同小可的资金损失或无法认领的订单残余。
functionfill(bytes32 hash, uint256 takerFill) external {
Order storage o = orders[hash];
o.executedAmount += takerFill;
IERC20(o.token).transferFrom(o.maker, msg.sender, takerFill);
// Missing: o.remainingAmount = o.totalAmount - o.executedAmount;
}
在上面的示例中,允许部分成交的订单维护两个相关的字段:remainingAmount(订单仍然可以成交多少)和 executedAmount。如果这些字段被单独更新或以错误的顺序更新,竞争条件和不同步可能允许过度成交或留下永远无法完全清除的微小“灰尘”订单,并且在这里,remainingAmount 永远不会更新,导致重复成交超过原始上限。
当匹配引擎逻辑或链上成交循环处理一批订单时,单个订单中的任何失败——例如尝试与被列入黑名单或不符合条件的交易对手进行成交,或由于 allowance/approval 问题而失败的转账——都可能导致整个批次回滚。此外,价格和大小计算中的简单整数除法或未检查的下溢可能允许零值成交或破坏订单状态的溢出。如果没有细粒度的错误处理(例如每个订单的 try/catch 或 revert-on-failure),该协议可以被任何人通过将单个有问题订单提交到批次中来轻松进行 DoS。
functionmatchBatch(bytes32[] memory orderHashes) external {
for (uint i = 0; i < orderHashes.length; i++) {
_match(orderHashes[i]); // reverts on any failure
}
}
在上面的示例中,循环遍历未结订单的批量匹配例程可能会因为任何单个成交失败而完全回滚——由于零 allowance、被列入黑名单的交易对手或转账回滚——导致简单的 DoS。
require(
remainingTakerAmount * order.amountGet >= fillAmount * order.amountGive,
"Price condition not met"
);
订单履行函数通常与 ERC-20 transferFrom 交互,并且还可以支持用于高级结算的Hook(例如,onOrderFill 或 permit 扩展)。如果在这些调用之后执行内部会计处理,或者如果没有重入保护地链接多个外部调用,则恶意合约可以利用递归调用来操纵订单状态、耗尽资金或绕过成交/取消限制。如果协议允许用户提供的合约或任意调用目标,那么重入尤其严重。
functionfill(...) external {
IERC20(token).transferFrom(...) // external call
orders[hash].executedAmount += fillAmt; // state update
}
在上面的示例中,恶意 token 合约可以重新进入交换逻辑并操纵相同的订单或其他订单。也就是说,攻击者的 transferFrom 钩可以再次调用 fill()。
即使没有任何一个用户进行垃圾信息攻击,订单簿也可能因 全局操作 而遭受 DoS 攻击,这些全局操作的成本随订单簿的大小或对抗性输入而变化。典型的罪魁祸首包括:对 所有订单 的无限次迭代,链上 排序/堆维护,在单个调用中扫描多个 价格水平,或其 gas 随 n 个匹配项 增长 的批量函数。
随着订单簿的增长,这些 O(n)/O(n²) 路径要么超过区块 gas 限制,要么使关键维护(清理、结算、重新索引)变得负担不起,从而导致协议停滞。
如果到期逻辑只是检查 block.timestamp 是否超过了到期参数,那么它很容易受到极端情况的影响,例如矿工操纵的时间戳或仅在成交时才检查到期时间的过时状态。此外,如果取消或清理逻辑不能可靠地处理过期的订单,这些订单可能会保留在订单簿中,导致不正确的可用余额、在预期到期后意外成交,或者用户无法从过期/取消的订单中收回资金。
如果在取消或声明订单时(而不是在下订单时固定)计算动态费用等级或折扣标志,则订单创建和结算之间更改的协议参数可能会导致退款计算偏离原始预期。这会导致不一致的会计处理、潜在的过度退款或退款不足,以及用户在取消之前操纵其费用等级以获取经济利益的漏洞。
uint256 fee = isPremium[msg.sender] ? premiumRate : standardRate;
functioncancel(...) external {
// user may have switched tiers here
_refund(order.amount + fee);
}
在上面的示例中,如果在取消时重新评估佣金费率或折扣(而不是在下订单时固定),用户可以在订单中期更改其等级以套取费用退款。
旨在防止在极端市场波动期间执行的熔断机制和波动率边界可能会错误地触发,如果检查没有以同时存在买入和卖出流动性为条件。如果在只有订单簿的一侧存在时计算或强制执行边界,DEX 可能会因不完整的上下文而阻止交易很长时间,从而导致市场参与者拒绝服务。
if (potentialPrice > staticRef * (1 + threshold)) {
haltTrading(); // even if no sell orders exist
}
在上面的示例中,基于波动率 bands 的自动 halts, haltTrading, 如果只有一侧(买入或卖出)有深度,则可能会错误地触发,从而阻止任何交易直到手动干预。
未平仓头寸的资金费率应与每次头寸变动同步更新。延迟或推迟资金费率计算——例如,在头寸合并、回滚或部分平仓期间——为复杂的用户创造了通过在资金更新之前在账户之间转移头寸或合并头寸来博弈系统的机会,从而避免支付并造成系统性缓冲短缺。缺少每个交易者的缓冲限制可能会使用户能够反复地为自己操纵资金逻辑。
functionmergePositions(...) external {
positions[p1].size += positions[p2].size;
delete positions[p2]; // funding owed on p2 never charged
}
在上面的示例中,头寸在没有立即结算费用的情况下合并,允许用户避免支付累积的资金费用,从而耗尽协议缓冲区。
如果基于单个价格快照(即使来自链上预言机)触发清算,攻击者可以暂时操纵价格源,从而以人为的价格强制清算。缺乏时间加权平均价格(TWAP)的使用或缺少多区块清算延迟使得攻击者能够抢先进行清算或从短暂的波动中获利,从而导致普通用户遭受不公平的损失。
price = (cumPrice[current] - cumPrice[past]) / (currentBlock - pastBlock);
在成交或取消操作中对数量和 availableQuantity 的不一致更新会导致协议会计中的不平衡,从而可能允许双重支出、用户资金被困或 UI 和实际链上状态之间的差异。当通过单独的代码路径处理头寸合并、部分平仓或费用退款时,这可能会变得特别严重。
matchOrder(...) {
if (filled == order.available) {
delete orders[id]; // quantity cleared, availableQuantity stale
}
}
在上面的示例中,在成交或取消时对 quantity 与 availableQuantity 的不匹配更新会创建剩余 token 或双重支出场景。
订单簿合约通常会公开特权入口点(费用/配置更新、紧急暂停、通过 relayer 创建和匹配、市场上市)。如果任何这些都可以在没有严格的角色检查的情况下被调用,或者如果 元交易转发器 是受信任的但 _msgSender() 没有被一致地使用,攻击者可以 (a) 直接调用管理函数,(b) 通过恶意转发器欺骗发送者,或者 (c) 通过从受损 EOA 调用 grantRole/set_XYZ 来提升权限。在可升级的部署中,未受保护的初始化程序或不安全的代理管理员允许攻击者重新初始化、获取所有权或交换实现。即使有角色,自身的角色管理员(例如,CONFIG_ROLE 管理员是 CONFIG_ROLE)也允许循环权限授予。
零成本、即时取消让 maker 能够 对 mempool 做出反应 并在成交着陆之前撤回订单。这创造了“JIT 流动性”的假象,增加了 Taker 回滚率,并启用了 队列狙击(以稍微更好的价格取消/重新发布以始终保持在订单簿的顶部)。Maker 还可以通过发布订单然后在同一区块内取消来恶意攻击,从而在从未打算交易的情况下膨胀状态和事件。
functioncancel(bytes32 hash) external {
require(msg.sender == orders[hash].maker, "ONLY_MAKER");
delete orders[hash]; // ❌ zero friction
}
单个帐户(或一小部分帐户)可以通过 放置大量微小订单,扰乱索引器并将其他用户的交互推入更高的 gas 区域来降低用户体验。即使使用高效的全局逻辑,来自一个地址的 突发 放置/取消模式也会膨胀日志,触发频繁的重新组织敏感状态更改,并且可以用于恶意攻击等待成交的接受者。
function place(Order memory o) external {
_store(o); // ❌ no per-user or global cap
}
低于 最小基本规模 或 名义价值 的订单会产生未成交的灰尘并扭曲费用(舍入为零)。缺乏 tick/lot 粒度会产生病态的价格/大小对,从而破坏匹配等效性(例如,7 位小数的价格遇到 6 位小数的资产)。多小数 token 使这变得复杂:错误缩放的价格/金额会导致 看起来有效 但无法结算的订单(费用 > 名义价值或永远无法清除的灰尘残余)。
struct Market {
uint8 baseDec; uint8 quoteDec;
uint256 tick; uint256 lot;
uint256 minBase; uint256 minNotional; // normalized to 18 decimals
}
mapping(bytes32 => Market) public markets; // by marketId
function\_validatePlacement(bytes32 mId, uint256 rawAmount, uint256 rawPrice) internalview {
Market memory m = markets[mId];
// normalize to 18 decimals for checks
uint256 amount18 = rawAmount * (10 ** (18 - m.baseDec));
uint256 price18 = rawPrice * (10 ** (18 - m.quoteDec));
require(amount18 % m.lot == 0, "LOT");
require(price18 % m.tick == 0, "TICK");
require(amount18 >= m.minBase, "MIN_BASE");
uint256 notional18 = FullMath.mulDiv(amount18, price18, 1e18);
require(notional18 >= m.minNotional, "MIN_NOTIONAL");
}
如果没有自成交预防 (STP),用户可以 交叉对抗他们自己的挂单(同一地址)以伪造交易量、赚取回扣、操纵价格打印或步进内部队列。在元交易上下文中,_msgSender() 与 msg.sender 不匹配会导致 假阴性/假阳性(通过中继器填写时,做市商看起来不同)。在双边匹配中,未能检测到两个订单共享一个 受益所有者(相同的地址或已知的代理)允许系统洗盘交易。
functionfill(bytes32 hash) external {
Order storage o = orders[hash];
// ❌ no maker==taker guard
_transfer(o.maker, msg.sender, ...);
}
当订单有效性/结算源自外部标头或预言机(例如,PoW 标头、跨链证明、TWAP 累加器)时,错误的数学或过时的数据 会级联到订单簿中:无效的价格、不正确的放置/更新/取消资格或不安全的结算。常见的错误包括 紧凑难度解码 溢出/下溢、字节序错误、滥用 EVM prevrandao 作为“随机性”以及未能强制执行 单调累积工作量 或 新鲜度限制——所有这些都可能让不正确的状态解锁订单操作。
updateOrder 通常会改变主要字段(价格、数量、溢价、行使价),而不会 重新计算 相关字段(名义价值、支付、费用)或重新检查不变性。攻击者可以通过 增量更新 将订单从有效状态“转移”到无效状态,从而绕过仅在 place() 强制执行的检查。部分成交与更新相结合会产生 不一致的状态(例如,支付 > 剩余名义价值)或允许 费用/溢价 超过经济价值。
functionupdateOrder(bytes32 h, Update u) external {
Order storage o = orders[h];
o.price = u.price; // ❌ derived fields not recomputed
o.amount = u.amount; // ❌ invariants not rechecked
}
链上订单簿默认情况下不是不安全的——它们只是放大了你在设计和实施中采取的每一个捷径。我们涵盖的大多数“陷阱”不会出现在正常情况下的测试中。它们出现在负载下、动荡的市场期间,或者当一个对抗性搜索者认为你的协议值得他们花费时间时。
使用这 19 种失效模式作为一份动态清单,而不是一次性阅读。将它们转化为不变性、模糊测试用例、监控警报和审查问题,用于每一个影响你的匹配、结算或风险逻辑的新功能。
实际的下一步很简单:选择当前设计中感觉最脆弱的三个部分,并写下你将如何通过代码或测试证明它们_不能_像这里描述的那样失败。完成此操作后,请问你的团队一个问题:
如果我们下周对我们的订单簿进行重大升级,这些风险中的哪些仍然会让我们夜不能寐?
这个答案应该指导你的下一轮审查。
- 原文链接: hacken.io/insights/order...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!