[BUG] Partially Filled Orders Cannot Be Matched Again - OrderFilledOrCancelled Error
Issue #99 | State: OPEN | Created: 2026-01-29T15:08:14Z
Assignees: linked0, Abdulkarim4u
Updated: 2026-01-29T15:08:14Z | Closed: N/A
๐ Bug Description
When a limit order is partially filled, subsequent attempts to match the remaining shares fail with contract error 0x7b38b76e (OrderFilledOrCancelled()). This prevents the remaining portion of partially filled orders from being executed.
๐ Steps to Reproduce
- **Alice/ Trader 1 ** places a Buy Limit order: 20 shares at 51ยข ($10.20 total)
- **Bob/Trader2 who has shares ** Market Sells 10 shares โ โ
SUCCESS (partial fill)
- Database state:
originalSize: 20, remainingSize: 10, status: PARTIALLY_FILLED
- Database state:
- Bob tries to Market Sell another 10 shares โ โ FAILS
- Error:
execution reverted: 0x7b38b76e(OrderFilledOrCancelled)
- Error:
๐ Expected Behavior
- Bob's second sell should match against Alice's remaining 10 shares
- Trade should execute successfully
- Alice should receive all 20 shares across two trades
โ Actual Behavior
- Second trade fails with
OrderFilledOrCancelled()error - Alice's order remains stuck as
PARTIALLY_FILLEDin database - Remaining 10 shares cannot be filled
๐ Root Cause Analysis
The Problem: Duplicate Order Hash
The CTFExchange contract uses order hash-based tracking:
mapping(bytes32 => uint256) public filled; // orderHash => filledAmount
First fill (10 shares):
- Contract calculates
orderHash = keccak256(abi.encode(order)) - Records
filled[orderHash] = 10 shares - โ Transaction succeeds
Second fill attempt (remaining 10 shares):
- System retrieves order from database:
status: PARTIALLY_FILLED, remainingSize: 10 - Constructs order struct with SAME nonce, salt, and signature
- Calculates SAME orderHash
- Contract checks:
filled[orderHash] > 0โ Already processed! โ โ REVERT
Why Our Current Approach Fails
Database correctly updates:
{
orderId: "abc-123",
remainingSize: 10, // โ
Correct
status: "PARTIALLY_FILLED",
nonce: 0, // โ Same nonce = same hash
salt: "...", // โ Same salt = same hash
signature: "..." // โ Same signature = same hash
}
But on-chain:
filled[orderHash] = 10; // Contract: "This order was already processed!"
The contract doesn't differentiate between "fully filled" and "partially filled" - it only knows if an order hash has been used.
โ Proposed Solution
Recommended: One Order Per Fill (Polymarket Approach)
How it works:
- After ANY fill (partial or full), mark the order as
FILLEDand close it - User must place a NEW order for remaining amount if desired
Why this approach:
- โ Matches industry standard (Polymarket, 0x Protocol)
- โ No complex state management
- โ Prevents OrderFilledOrCancelled error entirely
- โ No smart contract changes needed
- โ Simple implementation
Code Changes Required:
1. Exclude PARTIALLY_FILLED from order matching
File: /api/src/routes/orders.ts (line ~385)
const matchingDbOrders = await prisma.order.findMany({
where: {
outcomeId,
side: oppositeSide,
status: 'OPEN', // โ
CHANGE: Remove 'PARTIALLY_FILLED' from array
isActive: true,
price: signedOrder.side === 0
? { lte: price }
: { gte: price },
},
orderBy: {
price: signedOrder.side === 0 ? 'asc' : 'desc',
},
});
Before:
status: { in: ['OPEN', 'PARTIALLY_FILLED'] }, // โ Includes partially filled
After:
status: 'OPEN', // โ
Only open orders
2. Always mark orders as FILLED after any fill
File: /api/src/routes/orders.ts (line ~697)
await prisma.order.update({
where: { id: matchOrder.id },
data: {
remainingSize: { decrement: fillShares },
status: 'FILLED', // โ
CHANGE: Always FILLED (not PARTIALLY_FILLED)
isActive: false, // โ
CHANGE: Deactivate order
},
});
Before:
status: fillShares >= matchShares ? 'FILLED' : 'PARTIALLY_FILLED', // โ Allows partial
isActive: fillShares >= matchShares ? false : true,
After:
status: 'FILLED', // โ
Always mark as FILLED
isActive: false, // โ
Always deactivate
๐ฌ Alternative Solutions Considered
Option 2: Increment Nonce After Partial Fill
- Pros: Allows true partial fills
- Cons: Requires user to re-sign after each partial fill (not practical for auto-matching)
- Verdict: โ Not recommended (user may be offline)
Option 3: Smart Contract Upgrade
- Pros: Native partial fill support
- Cons: Expensive, risky, goes against 0x/Seaport patterns
- Verdict: โ Not recommended
๐ Testing Plan
After implementing the fix:
- โ Alice places buy limit: 20 shares at 51ยข
- โ
Bob market sells 10 shares
- Expected: Order marked as
FILLED,isActive: false
- Expected: Order marked as
- โ
Bob market sells 10 more shares
- Expected: Matches against OTHER orders (not Alice's closed order)
- โ Alice's portfolio shows 10 shares (from first fill)
- โ Alice's open orders tab is empty (order closed after first fill)
- โ Alice can place NEW order for remaining shares if desired
๐ References
- Polymarket: Uses one-time orders (no partial fill reuse)
- 0x Protocol: Tracks filled amounts on-chain, but our contract doesn't support this
- Seaport (OpenSea): Similar partial fill handling to 0x
- Our CTFExchange: Based on simplified 0x design, treats each orderHash as single-use
๐ก Additional Notes
UX Consideration
After this fix, users will need to place a new order for remaining shares. Consider adding UI messaging:
โน๏ธ Your limit order was partially filled (10/20 shares).
The order has been closed. Place a new order for the remaining 10 shares.
Impact Assessment
- Breaking Change: No (existing orders continue working)
- Data Migration: No (existing PARTIALLY_FILLED orders will be excluded from matching)
- User Experience: Slight degradation (must place new order), but matches industry standard
๐ฏ Success Criteria
- No more
OrderFilledOrCancellederrors - Partially filled orders are properly closed
- Users can place new orders for remaining amounts
- Order matching works correctly for all scenarios
- Tests pass for partial fill scenarios
๐ Related Files
/api/src/routes/orders.ts(main order matching logic)/api/src/routes/trade.ts(trade execution)PARTIAL_FILL_BUG_ANALYSIS.md(detailed technical analysis)
๐ธ Error Logs
โ Error executing batch orders: Error: execution reverted (unknown custom error)
(action="estimateGas", data="0x7b38b76e", reason=null, ...)
Error code decoded: OrderFilledOrCancelled()
Priority: High ๐ด Estimated Effort: Small (2 lines of code changes) Risk: Low (minimal changes, no contract upgrade needed)