Root + Impact
amendSellOrder ignores previous deadline, enabling orders to exceed the maximum allowed duration
Description
The amendSellOrder
function enforces that the newDeadlineDuration is less than or equal to the maximum allowed duration (MAX_DEADLINE_DURATION
), but it does not account for the time already elapsed since the order was created.
This allows the seller to repeatedly extend the order's deadline by calling amendSellOrder
, effectively bypassing the maximum order lifetime. As a result, an order can persist indefinitely, breaking the protocol’s documented feature of “Deadline enforcement to prevent stale listings”, which is designed to protect market participants from interacting with outdated or misleading orders.
// Root cause in the codebase with @> marks to highlight the relevant section
function amendSellOrder(
uint256 _orderId,
uint256 _newAmountToSell,
uint256 _newPriceInUSDC,
uint256 _newDeadlineDuration
) public {
Order storage order = orders[_orderId];
if (order.seller == address(0)) revert OrderNotFound();
if (order.seller != msg.sender) revert NotOrderSeller();
if (!order.isActive) revert OrderAlreadyInactive();
if (block.timestamp >= order.deadlineTimestamp) revert OrderExpired();
if (_newAmountToSell == 0) revert InvalidAmount();
if (_newPriceInUSDC == 0) revert InvalidPrice();
if (
_newDeadlineDuration == 0 ||
_newDeadlineDuration > MAX_DEADLINE_DURATION
) revert InvalidDeadline();
@> uint256 newDeadlineTimestamp = block.timestamp + _newDeadlineDuration;
IERC20 token = IERC20(order.tokenToSell);
if (_newAmountToSell > order.amountToSell) {
uint256 diff = _newAmountToSell - order.amountToSell;
token.safeTransferFrom(msg.sender, address(this), diff);
} else if (_newAmountToSell < order.amountToSell) {
uint256 diff = order.amountToSell - _newAmountToSell;
token.safeTransfer(order.seller, diff);
}
order.amountToSell = _newAmountToSell;
order.priceInUSDC = _newPriceInUSDC;
order.deadlineTimestamp = newDeadlineTimestamp;
emit OrderAmended(
_orderId,
_newAmountToSell,
_newPriceInUSDC,
newDeadlineTimestamp
);
}
Risk
Likelihood: HIGH
The vulnerability can be exploited by any order creator without any special privileges or technical expertise. The seller simply calls the amendSellOrder
function before the deadline expires, which is a normal function exposed by the protocol.
There are no protocol-level safeguards preventing repeated use of the amend function, meaning a malicious actor could easily automate deadline extensions to maintain stale orders indefinitely. This can happen continuously as long as the order is active.
Impact: HIGH
The exploit breaks a critical market protection mechanism—deadline enforcement—intended to remove stale or misleading orders from the order book. This undermines market trust and the integrity of the order book, as takers and bots rely on deadlines to assess order freshness and risk.
Malicious actors can leverage this flaw to execute liquidity baiting and market manipulation attacks, potentially causing financial losses for unsuspecting traders who take stale orders, and damaging the protocol’s reputation as a reliable exchange venue.
Proof of Concept
function test_deadlineExtensionExploit() public {
uint256 maxDeadline = book.MAX_DEADLINE_DURATION();
console2.log(
"Protocol max deadline: %s seconds (%s days)",
maxDeadline,
maxDeadline / 1 days
);
vm.startPrank(alice);
wbtc.approve(address(book), 2e8);
uint256 orderId = book.createSellOrder(
address(wbtc),
2e8,
180_000e6,
maxDeadline
);
vm.stopPrank();
OrderBook.Order memory order = book.getOrder(orderId);
uint256 originalDeadline = order.deadlineTimestamp;
uint256 creationTime = block.timestamp;
console2.log("Order created at timestamp: %s", creationTime);
console2.log("Original deadline: %s", originalDeadline);
uint256 timeSkip = maxDeadline - 0.5 days;
vm.warp(block.timestamp + timeSkip);
console2.log(
"Time skipped: %s seconds (%s days)",
timeSkip,
timeSkip / 1 days
);
vm.prank(alice);
book.amendSellOrder(orderId, 2e8, 180_000e6, maxDeadline);
order = book.getOrder(orderId);
uint256 newDeadline = order.deadlineTimestamp;
uint256 totalDuration = newDeadline - creationTime;
console2.log("New deadline after extension: %s", newDeadline);
console2.log(
"Total duration from creation: %s seconds (%s days)",
totalDuration,
totalDuration / 1 days
);
console2.log(
"Exceeded protocol limit by: %s seconds (%s days)",
totalDuration - maxDeadline,
(totalDuration - maxDeadline) / 1 days
);
assertTrue(
totalDuration > maxDeadline,
"Order should exceed max deadline"
);
assertEq(
totalDuration,
maxDeadline + timeSkip,
"Total duration calculation"
);
vm.warp(block.timestamp + timeSkip);
vm.prank(alice);
book.amendSellOrder(orderId, 2e8, 180_000e6, maxDeadline);
order = book.getOrder(orderId);
uint256 finalDeadline = order.deadlineTimestamp;
uint256 finalTotalDuration = finalDeadline - creationTime;
console2.log(
"After second extension - Total duration: %s days",
finalTotalDuration / 1 days
);
assertTrue(
finalTotalDuration > maxDeadline * 2,
"Should exceed 2x max deadline"
);
}
Recommended Mitigation
- remove this code
uint256 newDeadlineTimestamp = block.timestamp + _newDeadlineDuration;
+ add this code
Add creationTimestamp to Order struct - Now we remember the full history, path-dependent not Markovian
In createSellOrder - Store block.timestamp as creationTimestamp
In amendSellOrder - The critical fix:
uint256 totalDurationFromCreation = newDeadlineTimestamp - order.creationTimestamp;
if (totalDurationFromCreation > MAX_DEADLINE_DURATION) {
revert InvalidDeadline();
}