OrderBook

First Flight #43
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: low
Valid

State Inconsistency in Order Expiration

Root + Impact

Description

  • Normal Behavior

    The OrderBook contract is designed to automatically handle order expiration by checking deadlines during the buyOrder function execution, preventing buyers from purchasing expired orders. Orders should reflect their true availability status to external systems and users, with expired orders being effectively inactive and non-purchasable.

  • Specific Issue

    The contract suffers from a critical state inconsistency where orders remain marked as isActive = true in storage even after their deadline has passed, creating a disconnect between the actual order validity and the stored state. This inconsistency causes the getOrderDetailsString function to display confusing status messages like "Expired (Awaiting Cancellation)" while external integrations reading the isActive flag directly will incorrectly perceive expired orders as available for purchase, leading to failed transactions and misleading market data.

function buyOrder(uint256 _orderId) public {
Order storage order = orders[_orderId];
if (order.seller == address(0)) revert OrderNotFound();
if (!order.isActive) revert OrderNotActive();
if (block.timestamp >= order.deadlineTimestamp) revert OrderExpired(); // @> ISSUE: Expiration check only here
order.isActive = false; // @> PROBLEM: Only updated when filled, not when expired
}

Risk

Likelihood:

  • Orders will naturally expire over time as the 3-day maximum deadline period elapses, and many sellers will not manually cancel expired orders, leaving them in an inconsistent state that persists indefinitely.

  • External systems and DEX aggregators will continuously query order states for market data and routing decisions, encountering the stale isActive = true flags and making incorrect assumptions about order availability.

Impact:

  • External integrations and DEX aggregators will display expired orders as available, leading to failed user transactions when buyers attempt to purchase non-fillable orders, resulting in wasted gas costs and poor user experience.

  • Market data becomes unreliable as order book depth calculations include expired orders, creating false liquidity signals and misleading price discovery mechanisms that affect trading decisions across the ecosystem.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Test, console2} from "forge-std/Test.sol";
import {OrderBook} from "../src/OrderBook.sol";
import {MockUSDC} from "./mocks/MockUSDC.sol";
import {MockWBTC} from "./mocks/MockWBTC.sol";
import {MockWETH} from "./mocks/MockWETH.sol";
import {MockWSOL} from "./mocks/MockWSOL.sol";
/**
* @title State Inconsistency in Order Expiration
* @notice Demonstrates critical flaws in order expiration state management
* @dev Shows how expired orders remain "active" creating confusion and potential exploits
*/
contract ExpirationStateInconsistency is Test {
OrderBook book;
MockUSDC usdc;
MockWBTC wbtc;
MockWETH weth;
MockWSOL wsol;
address owner;
address seller;
address buyer;
function setUp() public {
owner = makeAddr("protocol_owner");
seller = makeAddr("seller");
buyer = makeAddr("buyer");
// Deploy mock tokens
usdc = new MockUSDC(6);
wbtc = new MockWBTC(8);
weth = new MockWETH(18);
wsol = new MockWSOL(18);
// Deploy OrderBook
vm.prank(owner);
book = new OrderBook(address(weth), address(wbtc), address(wsol), address(usdc), owner);
// Setup balances
weth.mint(seller, 10e18);
usdc.mint(buyer, 50_000e6);
}
/**
* @notice CRITICAL: Demonstrates state inconsistency in expired orders
* @dev Shows how orders remain "active" after expiration, causing confusion
*/
function test_ExpiredOrderStateInconsistency() public {
console2.log("\n=== EXPIRED ORDER STATE INCONSISTENCY EXPLOIT ===");
console2.log("Demonstrating how expired orders remain 'active' in state");
// 1. Seller creates order with short deadline
vm.startPrank(seller);
weth.approve(address(book), 1e18);
uint256 orderId = book.createSellOrder(
address(weth),
1e18,
3000e6,
1 hours // Short deadline for testing
);
vm.stopPrank();
console2.log("Step 1: Order created with 1 hour deadline");
console2.log("Order ID:", orderId);
// 2. Check initial state
OrderBook.Order memory orderBefore = book.getOrder(orderId);
console2.log("Initial state - isActive:", orderBefore.isActive);
console2.log("Initial state - deadline:", orderBefore.deadlineTimestamp);
console2.log("Current timestamp:", block.timestamp);
// 3. Fast forward past the deadline
vm.warp(block.timestamp + 2 hours); // Move 2 hours forward
console2.log("\nStep 2: Fast forwarded 2 hours past deadline");
console2.log("New timestamp:", block.timestamp);
// 4. CRITICAL ISSUE: Order state doesn't reflect expiration
OrderBook.Order memory orderAfter = book.getOrder(orderId);
console2.log("\n=== STATE INCONSISTENCY DETECTED ===");
console2.log("Order isActive:", orderAfter.isActive); // Still true!
console2.log("Order deadline:", orderAfter.deadlineTimestamp);
console2.log("Current time:", block.timestamp);
console2.log("Is expired?", block.timestamp >= orderAfter.deadlineTimestamp); // True
console2.log("But isActive is still:", orderAfter.isActive); // True!
// 5. String representation shows confusing status
string memory orderDetails = book.getOrderDetailsString(orderId);
console2.log("\nOrder details string:");
console2.log(orderDetails);
// 6. Verify the inconsistency
assertTrue(orderAfter.isActive, "Order should appear active in storage");
assertTrue(
block.timestamp >= orderAfter.deadlineTimestamp,
"Order should be past deadline"
);
// 7. buyOrder correctly reverts, but state remains inconsistent
vm.startPrank(buyer);
usdc.approve(address(book), 5000e6);
vm.expectRevert(OrderBook.OrderExpired.selector);
book.buyOrder(orderId);
vm.stopPrank();
console2.log("\n INCONSISTENCY CONFIRMED:");
console2.log("- Order appears active in storage (isActive = true)");
console2.log("- Order is past deadline (expired)");
console2.log("- buyOrder correctly reverts");
console2.log("- But other functions may behave unexpectedly with stale state");
}
/**
* @notice Demonstrates amendment blocking on expired orders
* @dev Shows how expired orders can't be amended, creating operational issues
*/
function test_ExpiredOrderAmendmentBlocking() public {
console2.log("\n=== EXPIRED ORDER AMENDMENT BLOCKING ===");
// 1. Create order
vm.startPrank(seller);
weth.approve(address(book), 1e18);
uint256 orderId = book.createSellOrder(address(weth), 1e18, 3000e6, 1 hours);
vm.stopPrank();
// 2. Wait for expiration
vm.warp(block.timestamp + 2 hours);
// 3. Try to amend expired order
vm.startPrank(seller);
vm.expectRevert(OrderBook.OrderExpired.selector);
book.amendSellOrder(orderId, 1e18, 3500e6, 1 hours);
vm.stopPrank();
console2.log(" Expired orders cannot be amended");
console2.log("Sellers must cancel and recreate, but state remains confusing");
}
/**
* @notice Demonstrates the impact of stale active state on external integrations
* @dev Shows how external systems might misinterpret order availability
*/
function test_StaleStateImpactOnIntegrations() public {
console2.log("\n=== STALE STATE IMPACT ON EXTERNAL INTEGRATIONS ===");
// 1. Create multiple orders with different deadlines
vm.startPrank(seller);
weth.approve(address(book), 5e18);
uint256 orderId1 = book.createSellOrder(address(weth), 1e18, 3000e6, 30 minutes);
uint256 orderId2 = book.createSellOrder(address(weth), 1e18, 3100e6, 1 hours);
uint256 orderId3 = book.createSellOrder(address(weth), 1e18, 3200e6, 2 hours);
vm.stopPrank();
console2.log("Created 3 orders with 30min, 1hr, 2hr deadlines");
// 2. Fast forward 1.5 hours
vm.warp(block.timestamp + 90 minutes);
console2.log("Fast forwarded 1.5 hours");
// 3. Check states - external systems would see all as "active"
OrderBook.Order memory order1 = book.getOrder(orderId1);
OrderBook.Order memory order2 = book.getOrder(orderId2);
OrderBook.Order memory order3 = book.getOrder(orderId3);
console2.log("\n=== EXTERNAL SYSTEM VIEW ===");
console2.log("Order 1 - isActive:", order1.isActive, "| Actually expired:",
block.timestamp >= order1.deadlineTimestamp);
console2.log("Order 2 - isActive:", order2.isActive, "| Actually expired:",
block.timestamp >= order2.deadlineTimestamp);
console2.log("Order 3 - isActive:", order3.isActive, "| Actually expired:",
block.timestamp >= order3.deadlineTimestamp);
// 4. External system might count these as available orders
uint256 apparentlyActiveOrders = 0;
uint256 actuallyActiveOrders = 0;
if (order1.isActive) apparentlyActiveOrders++;
if (order2.isActive) apparentlyActiveOrders++;
if (order3.isActive) apparentlyActiveOrders++;
if (order1.isActive && block.timestamp < order1.deadlineTimestamp) actuallyActiveOrders++;
if (order2.isActive && block.timestamp < order2.deadlineTimestamp) actuallyActiveOrders++;
if (order3.isActive && block.timestamp < order3.deadlineTimestamp) actuallyActiveOrders++;
console2.log("\nApparently active orders:", apparentlyActiveOrders);
console2.log("Actually active orders:", actuallyActiveOrders);
assertEq(apparentlyActiveOrders, 3, "All orders appear active");
assertEq(actuallyActiveOrders, 1, "Only one order is actually active");
console2.log("\n INTEGRATION ISSUE CONFIRMED:");
console2.log("External systems see", apparentlyActiveOrders, "active orders");
console2.log("But only", actuallyActiveOrders, "can actually be filled");
}
/**
* @notice Demonstrates the cancellation of expired orders
* @dev Shows how expired orders can still be cancelled, returning tokens
*/
function test_ExpiredOrderCancellation() public {
console2.log("\n=== EXPIRED ORDER CANCELLATION BEHAVIOR ===");
// 1. Create order
vm.startPrank(seller);
weth.approve(address(book), 1e18);
uint256 orderId = book.createSellOrder(address(weth), 1e18, 3000e6, 1 hours);
vm.stopPrank();
uint256 sellerBalanceBefore = weth.balanceOf(seller);
uint256 contractBalanceBefore = weth.balanceOf(address(book));
console2.log("Seller balance before:", sellerBalanceBefore / 1e18, "wETH");
console2.log("Contract balance before:", contractBalanceBefore / 1e18, "wETH");
// 2. Wait for expiration
vm.warp(block.timestamp + 2 hours);
// 3. Seller can still cancel expired order
vm.startPrank(seller);
book.cancelSellOrder(orderId);
vm.stopPrank();
uint256 sellerBalanceAfter = weth.balanceOf(seller);
uint256 contractBalanceAfter = weth.balanceOf(address(book));
console2.log("Seller balance after:", sellerBalanceAfter / 1e18, "wETH");
console2.log("Contract balance after:", contractBalanceAfter / 1e18, "wETH");
// 4. Verify order state
OrderBook.Order memory orderAfter = book.getOrder(orderId);
assertFalse(orderAfter.isActive, "Order should be inactive after cancellation");
console2.log("\n CANCELLATION SUCCESSFUL:");
console2.log("Expired orders can be cancelled normally");
console2.log("This is correct behavior, but state inconsistency remains an issue");
}
}

PoC Results:

forge test --match-contract ExpirationStateInconsistency --via-ir -vv
[⠰] Compiling...
[⠑] Compiling 1 files with Solc 0.8.26
[⠃] Solc 0.8.26 finished in 5.20s
Compiler run successful!
Ran 4 tests for test/ExpirationStateInconsistency.t.sol:ExpirationStateInconsistency
[PASS] test_ExpiredOrderAmendmentBlocking() (gas: 225843)
Logs:
=== EXPIRED ORDER AMENDMENT BLOCKING ===
Expired orders cannot be amended
Sellers must cancel and recreate, but state remains confusing
[PASS] test_ExpiredOrderCancellation() (gas: 207370)
Logs:
=== EXPIRED ORDER CANCELLATION BEHAVIOR ===
Seller balance before: 9999999999999999999 wETH
Contract balance before: 1 wETH
Seller balance after: 10000000000000000000 wETH
Contract balance after: 0 wETH
CANCELLATION SUCCESSFUL:
Expired orders can be cancelled normally
This is correct behavior, but state inconsistency remains an issue
[PASS] test_ExpiredOrderStateInconsistency() (gas: 298797)
Logs:
=== EXPIRED ORDER STATE INCONSISTENCY EXPLOIT ===
Demonstrating how expired orders remain 'active' in state
Step 1: Order created with 1 hour deadline
Order ID: 1
Initial state - isActive: true
Initial state - deadline: 3601
Current timestamp: 1
Step 2: Fast forwarded 2 hours past deadline
New timestamp: 7201
=== STATE INCONSISTENCY DETECTED ===
Order isActive: true
Order deadline: 3601
Current time: 7201
Is expired? true
But isActive is still: true
Order details string:
Order ID: 1
Seller: 0xdfa97bfe5d2b2e8169b194eaa78fbb793346b174
Selling: 1000000000000000000 wETH
Asking Price: 3000000000 USDC
Deadline Timestamp: 3601
Status: Expired (Awaiting Cancellation)
INCONSISTENCY CONFIRMED:
- Order appears active in storage (isActive = true)
- Order is past deadline (expired)
- buyOrder correctly reverts
- But other functions may behave unexpectedly with stale state
[PASS] test_StaleStateImpactOnIntegrations() (gas: 590860)
Logs:
=== STALE STATE IMPACT ON EXTERNAL INTEGRATIONS ===
Created 3 orders with 30min, 1hr, 2hr deadlines
Fast forwarded 1.5 hours
=== EXTERNAL SYSTEM VIEW ===
Order 1 - isActive: true | Actually expired: true
Order 2 - isActive: true | Actually expired: true
Order 3 - isActive: true | Actually expired: false
Apparently active orders: 3
Actually active orders: 1
INTEGRATION ISSUE CONFIRMED:
External systems see 3 active orders
But only 1 can actually be filled
Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 3.78ms (7.21ms CPU time)
Ran 1 test suite in 8.24ms (3.78ms CPU time): 4 tests passed, 0 failed, 0 skipped (4 total tests)

Recommended Mitigation

This mitigation addresses the state inconsistency by implementing dynamic state updates in view functions and centralizing order validation logic. The getOrder function now returns accurate isActive status based on current time, while the new _isOrderActiveAndValid internal function provides consistent validation across all contract functions. The markExpiredOrdersInactive function allows for batch cleanup of expired orders to maintain accurate on-chain state. This approach ensures that external integrations receive reliable order data while maintaining gas efficiency by only updating storage when necessary, eliminating the confusion between displayed status and actual order availability.

- function getOrder(uint256 _orderId) public view returns (Order memory orderDetails) {
- if (orders[_orderId].seller == address(0)) revert OrderNotFound();
- orderDetails = orders[_orderId]; // Returns stale state
- }
+ function getOrder(uint256 _orderId) public view returns (Order memory orderDetails) {
+ if (orders[_orderId].seller == address(0)) revert OrderNotFound();
+ orderDetails = orders[_orderId];
+
+ // Update isActive based on current time for accurate representation
+ if (orderDetails.isActive && block.timestamp >= orderDetails.deadlineTimestamp) {
+ orderDetails.isActive = false;
+ }
+ }
+ // Add centralized validation function for consistency
+ function _isOrderActiveAndValid(Order memory order) internal view returns (bool) {
+ return order.isActive && block.timestamp < order.deadlineTimestamp;
+ }
- function buyOrder(uint256 _orderId) public {
- Order storage order = orders[_orderId];
- if (!order.isActive) revert OrderNotActive();
- if (block.timestamp >= order.deadlineTimestamp) revert OrderExpired();
+ function buyOrder(uint256 _orderId) public {
+ Order storage order = orders[_orderId];
+ if (!_isOrderActiveAndValid(order)) revert OrderNotActive(); // Unified check
+ // Add cleanup function for batch state updates
+ function markExpiredOrdersInactive(uint256[] calldata _orderIds) external {
+ for (uint256 i = 0; i < _orderIds.length; i++) {
+ Order storage order = orders[_orderIds[i]];
+ if (order.isActive && block.timestamp >= order.deadlineTimestamp) {
+ order.isActive = false;
+ }
+ }
+ }
Updates

Lead Judging Commences

yeahchibyke Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

Expired orders still show as active

The `buyOrder()` function checks if an order is expired but fails to update the `isActive` flag when reverting, causing expired orders to remain marked as active in storage.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.