GTE Spot CLOB and Router
Findings & Analysis Report
2026-02-05
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [M-01] Flawed Zero-Cost Trade Prevention
- [M-02] FOK orders wrongly revert on dust residual amounts below lot size
- [M-03] Removing only the tail order from a limit does not reduce tree size, allowing order book to grow indefinitely
- [M-04] Malicious user can spam orders that expire immediately or cancel them immediately to wipe out legit orders from a book with orders only on one side
-
Low Risk and Informational Issues
- L-01
getOrdersPaginatedmay return empty list even if there are matching orders - L-02 Unable to update lot size as CLOBManager does not expose such function
- L-03 Too harsh competitive requirement when tree is full
- L-04 Unable to cancel expired orders
- L-05 CLOB config invariant can be broken by updating
- L-06 Strict FOK orders may prevent valid trades in GTERouter
- L-07 Incorrect
FeeTierIndexOutOfBoundserror on 15th fee rate retrieval - L-08
spotDepositPermit2can be temporarily DoS’ed by signature frontrunning - L-09 Unused errors and events
- L-01
- Disclosures
Overview
About C4
Code4rena (C4) is a competitive audit platform where security researchers, referred to as Wardens, review, audit, and analyze codebases for security vulnerabilities in exchange for bounties provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the GTE Spot CLOB and Router smart contract system. The audit took place from July 23 to August 06, 2025.
Final report assembled by Code4rena.
Summary
The C4 analysis yielded an aggregated total of 7 unique vulnerabilities. Of these vulnerabilities, 3 received a risk rating in the category of HIGH severity and 4 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 25 reports detailing issues with a risk rating of LOW severity or non-critical.
All of the issues presented here are linked back to their original finding, which may include relevant context from the judge and GTE team.
Scope
The code under review can be found within the C4 GTE Spot CLOB and Router repository, and is composed of 16 smart contracts written in the Solidity programming language and includes 1,963 lines of Solidity code.
The code in C4’s GTE Spot CLOB and Router repository was pulled from:
- Repository: https://github.com/liquid-labs-inc/gte-contracts
- Commit hash:
d937e760e43f2a6633d557f07edbb757d8b28b78
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (3)
[H-01] Order double-linked list is broken because order.prevOrderId is not persisted
Submitted by montecristo, also found by 0x1998, 0xAsen, 0xdice91, 0xlookman, 0xPhantom, axelot, ayden, BenRai, boredpukar, DDEENNY, dhank, Drynooo, Egbe, gh0xt, JuggerNaut63, lodelux, Olugbenga, Ragnarok, Riceee, sedare64, surenyanoks, taticuvostru, touristS, volodya, and VulnViper
clob/types/Book.sol#L275-L289clob/types/Book.sol#L150-L154
order.prevOrderId is updated only in memory and not saved to storage, breaking the linked list. This causes order adding and removal issues, potentially leading to denial of service when the order book is full and the limit’s tailOrder becomes invalid.
Description
Orders are stored as a double-linked list in the book.
However, this linking is broken as order.prevOrderId is not actually stored in EVM storage:
File: contracts/clob/types/Book.sol
150: function addOrderToBook(Book storage self, Order memory order) internal {
151: Limit storage limit = _updateBookPostOrder(self, order);
152:
153:@> _updateLimitPostOrder(self, limit, order);
154: }
As we can see in the above code, linked list is updated in the end. And order is passed as memory type:
File: contracts/clob/types/Book.sol
275: function _updateLimitPostOrder(Book storage self, Limit storage limit, Order memory order) private {
276: limit.numOrders++;
277:
278: if (limit.headOrder.isNull()) {
279: limit.headOrder = order.id;
280: limit.tailOrder = order.id;
281: } else {
282: Order storage tailOrder = self.orders[limit.tailOrder];
283: tailOrder.nextOrderId = order.id;
284:@> order.prevOrderId = tailOrder.id;
285: limit.tailOrder = order.id;
286: }
287:
288: emit LimitOrderCreated(BookEventNonce.inc(), order.id, order.price, order.amount, order.side);
289: }
In L284, order.prevOrderId is updated. However, it is not stored in EVM storage because order is passed as memory in L275.
This completely breaks linked list in future operations. For example, when removing orders:
File: contracts/clob/types/Book.sol
320: OrderId prev = order.prevOrderId;
321: OrderId next = order.nextOrderId;
322:
323: if (!prev.isNull()) self.orders[prev].nextOrderId = next;
324: else limit.headOrder = next;
325:
326: if (!next.isNull()) self.orders[next].prevOrderId = prev;
327: else limit.tailOrder = prev;
prevwill be null in L320 becauseprevOrderIdwas not persisted- Thus, L323 will not be reached and L324 will always be reached. This means
prevandnextlinkage will be broken - In L327,
limit.tailOrderwill always be set to null if removed entry was the tail
Impact
Order adding/removal will be affected.
Especially, when order book is full, CLOB can face DoS because limit tailOrder is set to empty order due to broken linked list.
This DoS is demonstrated by a POC.
View detailed Proof of Concept
[H-02] Dust orders can block order posting
Submitted by montecristo, also found by 0xAsen, gesha17, gizzy, holtzzx, newspacexyz, and solhhj
clob/CLOB.sol #L807-L849
When matching incoming orders, maker orders can be reduced below the minimum limit without checks, resulting in dust positions remaining in the order book. These dust orders can block new incoming orders, as matching them may revert with a ZeroCostTrade error if the quote amount rounds down to zero.
Description
When matching an incoming order, maker order’s amount can be set below minLimitOrderAmountInBase, as there is no min amount check:
833:@> bool orderRemoved = matchData.baseDelta == matchedBase;
834:
835: // Handle token accounting for maker.
836: if (takerOrder.side == Side.BUY) {
837: TransientMakerData.addQuoteToken(makerOrder.owner, matchData.quoteDelta);
838:
839: if (!orderRemoved) ds.metadata().baseTokenOpenInterest -= matchData.baseDelta;
840: } else {
841: TransientMakerData.addBaseToken(makerOrder.owner, matchData.baseDelta);
842:
843: if (!orderRemoved) ds.metadata().quoteTokenOpenInterest -= matchData.quoteDelta;
844: }
845:
846: if (orderRemoved) ds.removeOrderFromBook(makerOrder);
847:@> else makerOrder.amount -= matchData.baseDelta;
- In L833, order is removed only when matched amount is equal to maker order’s amount
- In L847, maker order’s amount is decreased by matched amount. The result amount can be dust, as there is no min amount check done afterwards.
As such, maker order’s amount can be a dust amount (up to lotSizeInBase config).
This means there can be a dust position in the order book.
What happens if this dust order is matched to another incoming order?
819: matchData.baseDelta = (matchedBase.min(takerOrder.amount) / lotSize) * lotSize;
820: matchData.quoteDelta = ds.getQuoteTokenAmount(matchedPrice, matchData.baseDelta);
- In L819,
matchData.baseDeltacan be as small aslotSize, sincemakeOrder.amountcan be down tolotSize - In L820,
matchData.quoteDeltacan be zero due to rounddown:
File: contracts/clob/types/Book.sol
471: function getQuoteTokenAmount(Book storage self, uint256 price, uint256 baseAmount)
472: internal
473: view
474: returns (uint256 quoteAmount)
475: {
476: return baseAmount * price / self.config().baseSize;
477: }
If matchData.quoteDelta is 0, the trade(or order posting) will revert with ZeroCostTrade error:
439: if (totalQuoteSent == 0 || totalBaseReceived == 0) revert ZeroCostTrade();
Impact
Dust positions can block incoming orders. An example is shown in the POC.
Recommended Mitigation Steps
Consider removing orders if the amount after matching is lower than minLimitOrderAmountInBase.
View detailed Proof of Concept
[H-03] DOS Attack via Order Amendment Bypassing maxLimitsPerTx Protection
Submitted by eightzerofour, also found by lonelybones
clob/CLOB.sol #L390
The CLOB (Central Limit Order Book) system implements DOS protection through the maxLimitsPerTx parameter, which limits the number of new limit orders a user can place within a single transaction. However, the amend() function allows users to bypass this critical protection mechanism by amending existing orders to different price levels without incrementing the transaction limit counter. This enables attackers to flood the order book with unlimited price level changes in a single transaction, effectively circumventing the protocol’s DOS protection.
Vulnerability Details
The vulnerability stems from the fact that the amend() function in CLOB.sol does not call incrementLimitsPlaced() when an order is amended to a new price or side, even though such amendments effectively create new order book entries at different price levels.
In CLOB.sol, the postLimitOrder function properly enforces DOS protection:
function postLimitOrder(address account, PostLimitOrderArgs calldata args)
external
onlySenderOrOperator(account, OperatorRoles.CLOB_LIMIT)
returns (PostLimitOrderResult memory)
{
Book storage ds = _getStorage();
ds.assertLimitPriceInBounds(args.price);
ds.assertLimitOrderAmountInBounds(args.amountInBase);
ds.assertLotSizeCompliant(args.amountInBase);
// Max limits per tx is enforced on the caller to allow for whitelisted operators
// to implement their own max limit logic.
ds.incrementLimitsPlaced(address(factory), msg.sender);
uint256 orderId;
if (args.clientOrderId == 0) {
orderId = ds.incrementOrderId();
} else {
orderId = account.getOrderId(args.clientOrderId);
ds.assertUnusedOrderId(orderId);
}
Order memory newOrder = args.toOrder(orderId, account);
if (newOrder.isExpired()) revert OrderExpired();
emit LimitOrderSubmitted(CLOBEventNonce.inc(), account, orderId, args);
if (args.side == Side.BUY) return _processLimitBidOrder(ds, account, newOrder, args);
else return _processLimitAskOrder(ds, account, newOrder, args);
}
However, the amend() function bypasses this protection entirely:
function amend(address account, AmendArgs calldata args)
external
override
onlyOperatorCallback
returns (int256 quoteTokenDelta, int256 baseTokenDelta)
{
Book storage ds = CLOBStorageLib.getStorage();
// No call to incrementLimitsPlaced() here!
Order storage order = ds.orders[args.orderId.toOrderId()];
order.assertExists();
if (order.owner != account) revert Unauthorized();
return _processAmend(ds, order, args);
}
When amending to a new price/side, the _executeAmendNewOrder function effectively creates a new order:
function _executeAmendNewOrder(Book storage ds, Order storage order, AmendArgs calldata args)
internal
returns (int256 quoteTokenDelta, int256 baseTokenDelta)
{
// Removes order from current position
ds.removeOrderFromBook(order);
// Creates new order with new parameters
Order memory newOrder = Order({
id: order.id,
prevOrderId: OrderId.wrap(0),
nextOrderId: OrderId.wrap(0),
owner: order.owner,
amount: args.amountInBase,
price: args.price,
side: args.side,
cancelTimestamp: args.cancelTimestamp
});
// Places order at new position - effectively a new limit order
if (args.side == Side.BUY) {
return _executeBidLimitOrder(ds, newOrder, args.limitOrderType);
} else {
return _executeAskLimitOrder(ds, newOrder, args.limitOrderType);
}
}
Impact (Attack Vectors)
Order Book Flooding: Attackers can create unlimited order book activity in a single transaction by repeatedly amending orders to different price levels, completely bypassing the maxLimitsPerTx protection designed to prevent this exact scenario.
- It can be executed by any user with minimal capital (just enough for 2 initial orders)
- It can be automated and repeated across multiple transactions to maintain the attack
Recommended Mitigation Steps
A possible mitigation would be implemented by modifying the _processAmend function in CLOB.sol to enforce DOS protection when an order is amended to a different price or side.
Fix Applied:
function _processAmend(Book storage ds, Order storage order, AmendArgs calldata args)
internal
returns (int256 quoteTokenDelta, int256 baseTokenDelta)
{
Order memory preAmend = order;
address maker = preAmend.owner;
if (args.cancelTimestamp.isExpired() || args.amountInBase < ds.settings().minLimitOrderAmountInBase) {
revert AmendInvalid();
}
// Check lot size compliance after other validations
ds.assertLotSizeCompliant(args.amountInBase);
if (order.side != args.side || order.price != args.price) {
// change place in book - this effectively creates a new order position,
// so we need to enforce DOS protection by checking limits
ds.incrementLimitsPlaced(address(factory), msg.sender);
(quoteTokenDelta, baseTokenDelta) = _executeAmendNewOrder(ds, order, args);
} else {
// change amount - no new position created, no limit check needed
(quoteTokenDelta, baseTokenDelta) = _executeAmendAmount(ds, order, args.amountInBase);
if (quoteTokenDelta + baseTokenDelta == 0) revert ZeroOrder();
}
emit OrderAmended(CLOBEventNonce.inc(), preAmend, args, quoteTokenDelta, baseTokenDelta);
_settleAmend(ds, maker, quoteTokenDelta, baseTokenDelta);
}
When an order amendment changes the price or side (order.side != args.side || order.price != args.price), the function now calls ds.incrementLimitsPlaced(address(factory), msg.sender) before executing the amendment. Amount-only amendments (same price and side) do not trigger the limit check since they don’t create new order book positions. The fix ensures that both postLimitOrder and amend operations that create new order book positions are subject to the same DOS protection mechanism.
Verification:
The fix was validated by running the existing test, which now demonstrates that:
- Normal DOS protection works: Cannot post more than
maxLimitsPerTxorders - Cannot amend orders to new positions when limit is reached
- The attack is prevented: The first amendment attempt fails with
LimitsPlacedExceedsMax()error
View detailed Proof of Concept
Medium Risk Findings (4)
[M-01] Flawed Zero-Cost Trade Prevention
Submitted by VulnViper, also found by 0xAura, 0xDeoGratias, 0xDetermination, 0xhanu58, 0xPhantom, 0xterrah, Almanax, anonymousjoe, axelot, BenRai, boredpukar, ChainSentry, DDEENNY, dhank, EVDoc, Gosho, guri, jerry0422, lirezArAzAvi, lodelux, Neeloy, Pexy, random1106, Riceee, sharonphiliplima, solhhj, Tofu, v2110, and Zibounne
clob/CLOB.sol#L503-L505clob/CLOB.sol#L544-L546
In CLOB.sol, the validation logic incorrectly uses bitwise operations to detect zero-value trades, creating two critical issues:
If baseTokenAmountReceived = 1 and quoteTokenAmountSent = 2, then baseTokenAmountReceived & quoteTokenAmountSent = 0.
https://github.com/code-423n4/2025-07-gte-clob/blob/main/contracts/clob/CLOB.sol#L503-L505
@> if (baseTokenAmountReceived != quoteTokenAmountSent && baseTokenAmountReceived & quoteTokenAmountSent == 0) {
revert ZeroCostTrade();
}
https://github.com/code-423n4/2025-07-gte-clob/blob/main/contracts/clob/CLOB.sol#L544-L546
@> if (baseTokenAmountSent != quoteTokenAmountReceived && baseTokenAmountSent & quoteTokenAmountReceived == 0) {
revert ZeroCostTrade();
}
Impact
Occurrence Probability: Low (requires specific value alignment)
Operational Impact: High (instant revert blocks valid trade)
Recommended Mitigation Steps
- if (baseTokenAmountReceived != quoteTokenAmountSent && baseTokenAmountReceived & quoteTokenAmountSent == 0) {
+ if (baseTokenAmountReceived != quoteTokenAmountSent && (baseTokenAmountReceived == 0 || quoteTokenAmountSent == 0)) {
revert ZeroCostTrade();
}
- if (baseTokenAmountSent != quoteTokenAmountReceived && baseTokenAmountSent & quoteTokenAmountReceived == 0) {
+ if (baseTokenAmountSent != quoteTokenAmountReceived && (baseTokenAmountSent == 0 || quoteTokenAmountReceived == 0)) {
revert ZeroCostTrade();
}
View detailed Proof of Concept
[M-02] FOK orders wrongly revert on dust residual amounts below lot size
Submitted by montecristo, also found by 0x15, 0xterrah, Adotsam, ahahaHard1k, Angry_Mustache_Man, anonymousjoe, befree3x, BenRai, boredpukar, dhank, dmdg321, edoscoba, KineticsOfWeb3, lodelux, lonelybones, Ollam, Olugbenga, princekay, random1106, Shahil_Hussain, Soosh, touristS, udogodwin, volifoet, Web3Hunters, willycode20, and Yanx
clob/CLOB.sol #L440
The CLOB contract unnecessarily reverts FOK orders when the remaining unmatched amount is gte (pun intended) 0, but smaller than lotSizeInBase, contrary to the documented behavior. This leads to reverts of multi-hop swaps where exact output amounts from UniswapV2 cannot be adjusted to match CLOB’s lot size.
According to the README:
On CLOB fill, filled amounts are rounded down to the nearest lot. FOK fill orders should not revert if only the amount rounded off is left unfilled, and the user is not charged for the dust.
It can be understood in either one of the following ways:
- If the FOK fill order amount is slightly less than the matching limit order, the transaction should not revert
- If the FOK fill order amount is slightly greater than the matching limit order, the transaction should not revert
However, the current implementation does not behave as described.
For example, let’s consider there is a limit ask order and a fill bid order, with lotSizeInBase = 1e6:
Case 1: If fill bid order amount is slightly less than limit ask order:
-
Limit ask order
amountInBase: 1e18 (1 WETH)price: 3000e6 (3000 USD)
-
Fill bid order
amountInBase: 1e18 - 1price: 3000e6fillOrderType: FOK
-
After matching:
matchData.baseDelta: 1e18 - 1e6matchData.quoteDelta: (1e18 - 1e6) * 3000e6 / 1e18 = 2999999999newOrder.amount: 1e18 - 1 - (1e18 - 1e6) = 1e6 - 1
Thus, the transaction reverts due to the following check:
440: if (args.fillOrderType == FillOrderType.FILL_OR_KILL && newOrder.amount > 0) revert FOKOrderNotFilled();
Case 2: If fill bid order amount is slightly greater than limit ask order:
-
Limit ask order 1
amountInBase: 1e18 (1 WETH)price: 3000e6 (3000 USD)
-
Limit ask order 2 (ensure there are sufficient matching orders)
amountInBase: 1e18 (1 WETH)price: 3000e6 (3000 USD)
-
Fill bid order
amountInBase: 1e18 + 1price: 3000e6fillOrderType: FOK
-
After matching ask order 1:
matchData.baseDelta: 1e18matchData.quoteDelta: 3000e6newOrder.amount: 1
-
After matching ask order 2:
matchData.baseDelta: 0matchData.quoteDelta: 0newOrder.amount: 1
Thus, order fill will again revert with FOKOrderNotFilled error.
Indeed, order fill should not revert if newOrder.amount < ds.settings().lotSizeInBase
Impact
One might argue that this is a user mistake, as the user should not input quirky amounts like 1e18 - 1 or 1e18 + 1 in the first place.
However, this can happen with no user mistake in a practical scenario:
- Assume user wants to do a multihop swap in GTERouter
- The first hop will be done on Uniswap
- The second hop will be done on GTE
In this case, Uniswap’s output amount will be set as GTE FOK fill order’s amount:
File: contracts/router/GTERouter.sol
310: fillArgs.amount = route.prevAmountOut; // @audit this is UniswapV2's output amount
311: fillArgs.fillOrderType = ICLOB.FillOrderType.FILL_OR_KILL; // @audit FOK type is enforced
The user cannot adjust Uniswap’s output to match GTE’s lot size. So if Uniswap’s output amount is slightly greater or less than matching order’s amount, the whole swap will revert with FOKOrderNotFilled error.
Recommended Mitigation Steps
diff --git a/contracts/clob/CLOB.sol b/contracts/clob/CLOB.sol
index 2131fcc..0f9f2a3 100644
--- a/contracts/clob/CLOB.sol
+++ b/contracts/clob/CLOB.sol
@@ -437,7 +437,7 @@ contract CLOB is ICLOB, Ownable2StepUpgradeable {
(uint256 totalQuoteSent, uint256 totalBaseReceived) = _matchIncomingBid(ds, newOrder, args.amountIsBase);
if (totalQuoteSent == 0 || totalBaseReceived == 0) revert ZeroCostTrade();
- if (args.fillOrderType == FillOrderType.FILL_OR_KILL && newOrder.amount > 0) revert FOKOrderNotFilled();
+ if (args.fillOrderType == FillOrderType.FILL_OR_KILL && newOrder.amount >= ds.settings().lotSizeInBase) revert FOKOrderNotFilled();
// slither-disable-next-line reentrancy-events This external call is to the factory
uint256 takerFee = _settleIncomingOrder(ds, account, Side.BUY, totalQuoteSent, totalBaseReceived);
View detailed Proof of Concept
[M-03] Removing only the tail order from a limit does not reduce tree size, allowing order book to grow indefinitely
Submitted by montecristo, also found by 0x1998, 0xAura, 0xDetermination, 0xdice91, 0xlookman, ahahaHard1k, anonymousjoe, ayden, BenRai, dhank, dimulski, gesha17, Gosho, Merulez99, Neeloy, Ollam, Ragnarok, Soosh, and touristS
clob/CLOB.sol#L600-L605clob/CLOB.sol#L633-L638
When the tree size limit is reached, the CLOB only removes the least competitive order (tail order) from a limit, but does not remove the limit itself if it contains multiple orders. As a result, the tree size does not decrease, allowing the order book to bypass the intended size restriction and grow without bound.
Description
When the tree size reaches the limit, CLOB will remove the most non-competitive order from the book:
632: // The book is full, pop the least competitive order (or revert if incoming is the least competitive)
633: if (ds.askTree.size() == maxNumLimitsPerSide) {
634: uint256 maxAskPrice = ds.getWorstAskPrice();
635: if (newOrder.price >= maxAskPrice) revert MaxOrdersInBookPostNotCompetitive();
636:
637:@> _removeNonCompetitiveOrder(ds, ds.orders[ds.askLimits[maxAskPrice].tailOrder]);
638: }
The problem is that it only removes tailOrder from the limit. If a limit contains multiple orders, the limit will not be removed from the tree:
File: contracts/clob/types/Book.sol
307:@> if (limit.numOrders == 1) {
308: if (order.side == Side.BUY) {
309:@> delete self.bidLimits[price];
310: self.bidTree.remove(price);
311: } else {
312:@> delete self.askLimits[price];
313: self.askTree.remove(price);
314: }
315: return;
316: }
Since RedBlackTree.size() returns total number of nodes (i.e. limits), tree size will not be reduced even after removing the tail order.
Indeed, in order to keep the tree size limit, the code should remove all orders from the given limit, not just the tail.
Impact
If the worst limit has multiple orders:
- Tree will not shrink after removing non-competitive order
So if the new order has a new limit (i.e. there are no existing orders with the given price):
- Tree size will actually increase by 1 after adding a new limit order
Thus, for subsequent order postings:
- Since tree size check is using
==(CLOB.sol:633), subsequent order posting will bypass tree limit checking
As a result, the tree and the order book will grow indefinitely.
Recommended Mitigation Steps
Consider implementing _removeNonCompetitiveLimit instead of removing just the tail order.
View detailed Proof of Concept
[M-04] Malicious user can spam orders that expire immediately or cancel them immediately to wipe out legit orders from a book with orders only on one side
Submitted by gesha17
clob/CLOB.sol #L592
When creating an order, there are no restrictions on a minimum time the order can be valid, meaning a user can create an order with a cancelTimestamp that is the very next second.
Also, there is an upper bound on how many limits each side of a book can have - maxNumLimitsPerSide. This is enforced during limit creation - if the book is full, the worst limit will be removed:
https://github.com/code-423n4/2025-07-gte-clob/blob/main/contracts/clob/CLOB.sol#L592
/// @dev Performs the core matching and placement of a bid limit order into the book
function _executeBidLimitOrder(Book storage ds, Order memory newOrder, LimitOrderType limitOrderType)
internal
returns (uint256 postAmount, uint256 quoteTokenAmountSent, uint256 baseTokenAmountReceived)
{
if (limitOrderType == LimitOrderType.POST_ONLY && ds.getBestAskPrice() <= newOrder.price) {
revert PostOnlyOrderWouldFill();
}
// Attempt to fill any of the incoming limit order that's overlapping into asks
(uint256 totalQuoteSent, uint256 totalBaseReceived) = _matchIncomingBid(ds, newOrder, true);
// NOOP, there is no more size left after filling to create a limit order
if (newOrder.amount < ds.settings().minLimitOrderAmountInBase) {
newOrder.amount = 0;
return (postAmount, totalQuoteSent, totalBaseReceived);
}
// The book is full, pop the least competitive order (or revert if incoming is the least competitive)
> if (ds.bidTree.size() == maxNumLimitsPerSide) {
uint256 minBidPrice = ds.getWorstBidPrice();
if (newOrder.price <= minBidPrice) revert MaxOrdersInBookPostNotCompetitive();
_removeNonCompetitiveOrder(ds, ds.orders[ds.bidLimits[minBidPrice].tailOrder]);
}
// After filling, the order still has sufficient size and can be placed as a limit
ds.addOrderToBook(newOrder);
postAmount = ds.getQuoteTokenAmount(newOrder.price, newOrder.amount);
return (postAmount, totalQuoteSent, totalBaseReceived);
}
As a result, this opens an attack vector where a malicious user can wipe out all legit orders from a book by submitting many orders of higher prices that expire immediately. The attacker can get around the maximum number of limits per transaction by using separate accounts. There is little risk to the attacker, because unless the orders are fulfilled immediately - within the very next second, they will expire and can just be refunded.
An alternative attack to this one would be one where the attacker immediately cancels the orders via cancel() in the very next transaction.
Note that this attack is only feasible on order books that have active orders only on one side of the book. As orders of higher prices on the other side would naturally be filled by the attacker’s orders.
Recommended Mitigation Steps
Enforce a minimum order liveness time - e.g. 10 minutes. This will significantly increase the risk of the attacker having their orders fulfilled and essentially selling a lot of tokens at a higher than market price. Also, make sure the orders cannot be canceled via cancel() during this minimum liveness period.
View detailed Proof of Concept
Low Risk and Informational Issues
For this audit, 25 QA reports were submitted by wardens compiling low risk and informational issues. The QA report highlighted below by montecristo received the top score from the judge. 14 Low-severity findings were also submitted individually, and can be viewed here.
The following wardens also submitted QA reports: 0xAkira, 0xAura, 0xl33, Angry_Mustache_Man, dhank, K42, Ky0toFu, MakeIChop, marutint10, muktariq, newspacexyz, peanuts, princekay, rayss, redpanda, Riceee, rishab, solhhj, Sparrow, Teycir, touristS, unnamed, v2110, and Yanx.
[L-01] getOrdersPaginated may return empty list even if there are matching orders
The getOrdersPaginated function fails to return available orders when the requested startPrice doesn’t exactly match any existing orders, despite better prices being available. This occurs because the function returns early when no exact price match is found, rather than checking for the next best available price in the order book.
Finding description and impact
CLOB::getOrdersPaginated will return orders from TOB down from given price, according to the comment:
279: /// @notice Gets `pageSize` of orders from TOB down from a `startPrice` and on a given `side` of the book
280: function getOrdersPaginated(uint256 startPrice, Side side, uint256 pageSize)
281: external
282: view
283: returns (Order[] memory result, Order memory nextOrder)
284: {
285: Book storage ds = _getStorage();
286:
287: nextOrder = side == Side.BUY
288: ? ds.orders[ds.bidLimits[startPrice].headOrder]
289: : ds.orders[ds.askLimits[startPrice].headOrder];
290:
291: return ds.getOrdersPaginated(nextOrder, pageSize);
292: }
However, if there is no limit with the same price to startPrice, nextOrder will be null in L287.
Thus, BookLib::getOrdersPaginated will early-break:
File: contracts/clob/types/Book.sol
211: function getOrdersPaginated(Book storage ds, Order memory startOrder, uint256 pageSize)
212: internal
213: view
214: returns (Order[] memory result, Order memory nextOrder)
215: {
216: Order[] memory orders = new Order[](pageSize);
217: nextOrder = startOrder;
218: uint256 counter;
219:
220: while (counter < pageSize) {
221:@> if (nextOrder.id.unwrap() == 0) break;
222: orders[counter] = nextOrder;
223:
As a result, CLOB::getOrdersPaginated will return empty list even though there are better orders registered in a book.
For example:
- Assume there is a sell order that sells 1 eth for 2999 USDC
- A user wants to get sell orders that have price lower than 3000 USDC
- However,
CLOB::getOrdersPaginatedwill return empty list as order limit is 2999 not exactly 3000
Recommended Mitigation Steps
Consider setting nextOrder to the next best order if it’s null.
diff --git a/contracts/clob/CLOB.sol b/contracts/clob/CLOB.sol
index 2131fcc..adcb211 100644
--- a/contracts/clob/CLOB.sol
+++ b/contracts/clob/CLOB.sol
@@ -288,6 +288,12 @@ contract CLOB is ICLOB, Ownable2StepUpgradeable {
? ds.orders[ds.bidLimits[startPrice].headOrder]
: ds.orders[ds.askLimits[startPrice].headOrder];
+ if (nextOrder.isNull()) {
+ nextOrder = nextOrder.side == Side.BUY
+ ? ds.orders[ds.bidLimits[ds.getNextBiggestPrice(nextOrder.price, Side.BUY)].headOrder]
+ : ds.orders[ds.askLimits[ds.getNextSmallestPrice(nextOrder.price, Side.SELL)].headOrder];
+ }
+
return ds.getOrdersPaginated(nextOrder, pageSize);
}
[L-02] Unable to update lot size as CLOBManager does not expose such function
CLOB contract exposes setLotSizeInBase function:
329: function setLotSizeInBase(uint256 newLotSizeInBase) external onlyManager {
330: _getStorage().setLotSizeInBase(newLotSizeInBase);
331: }
This function is only callable by CLOBManager contract.
However, CLOBManager does not have any public function to consume this method.
Thus, it is impossible to update CLOB lot size.
Additional note: this function is missing from ICLOB.sol either.
[L-03] Too harsh competitive requirement when tree is full
The CLOB contract rejects new orders at the worst existing price (when newOrder.price == maxAskPrice) even though they wouldn’t increase tree size.
Finding description and impact
CLOB will not accept the incoming order if it’s least competitive when the tree is full:
624: // The book is full, pop the least competitive order (or revert if incoming is the least competitive)
625: if (ds.askTree.size() == maxNumLimitsPerSide) {
626: uint256 maxAskPrice = ds.getWorstAskPrice();
627:@> if (newOrder.price >= maxAskPrice) revert MaxOrdersInBookPostNotCompetitive();
628:
629: _removeNonCompetitiveOrder(ds, ds.orders[ds.askLimits[maxAskPrice].tailOrder]);
630: }
In L627, >= is too strict because if newOrder.price == maxAskPrice, the tree size will not be increased by adding the incoming order, since newOrder.price is already registered as a node (i.e. limit) in the tree.
Let’s make an example.
Current implementation will NOT allow the following:
- There are 1000 ask limits with the price range of 1001 ~ 2000
- A new sell order with price 2000 will be rejected because it’s the least competitive one
However, the following is allowed
- A seller creates a sell order with price of 2000
- Another seller creates a sell order with the same price 2000
- 999 other orders are registered with the price range of 1001 ~ 1999
As such, CLOB unnecessarily rejects the incoming order even though the tree can support it.
Recommended Mitigation Steps
diff --git a/contracts/clob/CLOB.sol b/contracts/clob/CLOB.sol
index 2131fcc..7e978bd 100644
--- a/contracts/clob/CLOB.sol
+++ b/contracts/clob/CLOB.sol
@@ -591,7 +591,7 @@ contract CLOB is ICLOB, Ownable2StepUpgradeable {
// The book is full, pop the least competitive order (or revert if incoming is the least competitive)
if (ds.bidTree.size() == maxNumLimitsPerSide) {
uint256 minBidPrice = ds.getWorstBidPrice();
- if (newOrder.price <= minBidPrice) revert MaxOrdersInBookPostNotCompetitive();
+ if (newOrder.price < minBidPrice) revert MaxOrdersInBookPostNotCompetitive();
_removeNonCompetitiveOrder(ds, ds.orders[ds.bidLimits[minBidPrice].tailOrder]);
}
@@ -624,7 +624,7 @@ contract CLOB is ICLOB, Ownable2StepUpgradeable {
// The book is full, pop the least competitive order (or revert if incoming is the least competitive)
if (ds.askTree.size() == maxNumLimitsPerSide) {
uint256 maxAskPrice = ds.getWorstAskPrice();
- if (newOrder.price >= maxAskPrice) revert MaxOrdersInBookPostNotCompetitive();
+ if (newOrder.price > maxAskPrice) revert MaxOrdersInBookPostNotCompetitive();
_removeNonCompetitiveOrder(ds, ds.orders[ds.askLimits[maxAskPrice].tailOrder]);
}
[L-04] Unable to cancel expired orders
The protocol only allows order cancellations by owners, causing expired orders to permanently occupy slots until manually removed. This might block new orders when the book is full and force the system to retain worthless dust positions indefinitely.
Finding description and impact
The protocol only allows order cancellation by the owner or a privileged operator, even for expired orders:
910: Order storage order = ds.orders[orderId.toOrderId()];
911:
912: if (order.isNull()) {
913: emit CancelFailed(CLOBEventNonce.inc(), orderId, account);
914: continue; // Order may have been matched
915:@> } else if (order.owner != account) {
916: revert CancelUnauthorized();
917: }
This is unfavorable in the following cases:
- A least competitive order expires. A seller wants to post an order at the same or a lower price but is blocked by the tree size limit.
- If a new order is posted, the worst but unexpired order may be popped from the book, even if the second-worst order has expired.
Additionally, if an order is left with a dust amount, the owner may no longer be interested in closing it, causing it to remain in the tree indefinitely. There are parties (e.g., a new seller wanting to post a cheaper order or an existing seller concerned about their order being kicked out) who would benefit from closing expired orders. Therefore, the protocol should expose a method to allow this.
Recommended Mitigation Steps
Consider skipping authorization if the order is expired:
diff --git a/contracts/clob/CLOB.sol b/contracts/clob/CLOB.sol
index 2131fcc..a278ab9 100644
--- a/contracts/clob/CLOB.sol
+++ b/contracts/clob/CLOB.sol
@@ -912,7 +912,7 @@ contract CLOB is ICLOB, Ownable2StepUpgradeable {
if (order.isNull()) {
emit CancelFailed(CLOBEventNonce.inc(), orderId, account);
continue; // Order may have been matched
- } else if (order.owner != account) {
+ } else if (!order.isExpired() && order.owner != account) {
revert CancelUnauthorized();
}
[L-05] CLOB config invariant can be broken by updating
CLOBManager validates the following invariant before deploying a CLOB:
File: contracts/clob/CLOBManager.sol
289: function _assertValidSettings(SettingsParams calldata settings, uint256 baseSize) internal pure {
290:@> if (settings.tickSize.fullMulDiv(settings.minLimitOrderAmountInBase, baseSize) == 0) revert InvalidSettings();
This is to prevent listing zero-cost trades in the order book.
However, when tickSize and minLimitOrderAmountInBase is updated, no such check is done.
File: contracts/clob/types/Book.sol
487: function setTickSize(Book storage self, uint256 newTickSize) internal {
488: if (newTickSize < MIN_LIMIT_PRICE_IN_TICKS) revert NewTickSizeInvalid();
489: self.settings().tickSize = newTickSize;
490:
491: emit TickSizeUpdated(BookEventNonce.inc(), newTickSize);
492: }
493:
494: function setMinLimitOrderAmountInBase(Book storage self, uint256 newMinLimitOrderAmountInBase) internal {
495: if (newMinLimitOrderAmountInBase < MIN_MIN_LIMIT_ORDER_AMOUNT_BASE) revert NewMinLimitOrderAmountInvalid();
496:
497: self.settings().minLimitOrderAmountInBase = newMinLimitOrderAmountInBase;
498:
499: emit MinLimitOrderAmountInBaseUpdated(BookEventNonce.inc(), newMinLimitOrderAmountInBase);
500: }
Thus, such invariant may be broken by a config update.
[L-06] Strict FOK orders may prevent valid trades in GTERouter
The router’s use of strict FILL_OR_KILL orders unnecessarily fails valid partial executions, causing missed trading opportunities when residual funds remain.
Finding description and impact
When hop type is CLOB_FILL, GTERouter executes a post fill order in CLOB contract with overly restrictive parameters:
File: contracts/router/GTERouter.sol
311: fillArgs.fillOrderType = ICLOB.FillOrderType.FILL_OR_KILL;
This creates suboptimal behavior because:
- FOK orders require exact amount matching (no residual funds)
- Valid trades fail when the router can’t spend the entire input amount
For example:
- Existing sell order: 1 WETH for 2500 USDC
- User attempts to buy 1 WETH for 2600 USDC via GTERouter
GTERouter::executeRoutereverts withFOKOrderNotFilledbecause the fill order will be left with 100 extra USDC
Recommended Mitigation Steps
Change to IMMEDIATE_OR_CANCEL to permit partial fills:
fillArgs.fillOrderType = ICLOB.FillOrderType.IMMEDIATE_OR_CANCEL;
[L-07] Incorrect FeeTierIndexOutOfBounds error on 15th fee rate retrieval
File: contracts/clob/types/FeeData.sol
25: function packFeeRates(uint16[] memory fees) internal pure returns (PackedFeeRates) {
26: if (fees.length > U16_PER_WORD) revert FeeTiersExceedsMax();
27:
28: uint256 packedValue = 0;
29: for (uint256 i; i < fees.length; i++) {
30: packedValue = packedValue | (uint256(fees[i]) << (i * U16_PER_WORD));
31: }
32:
33: return PackedFeeRates.wrap(packedValue);
34: }
35:
36: function getFeeAt(PackedFeeRates fees, uint256 index) internal pure returns (uint16) {
37:@> if (index >= 15) revert FeeTierIndexOutOfBounds();
38:
39: uint256 shiftBits = index * U16_PER_WORD;
40:
41: return uint16((PackedFeeRates.unwrap(fees) >> shiftBits) & 0xFFFF);
42: }
PackedFeeRates can support up to 16 fee rates, because sixteen uint16 variables can be packed into a single uint256 slot.
However, getFeeAt will revert on fee retrieving at 15th index due to L37.
The correct index bound checking should be something like the following:
if (index > 15) revert FeeTierIndexOutOfBounds();
[L-08] spotDepositPermit2 can be temporarily DoS’ed by signature frontrunning
File: contracts/router/GTERouter.sol
134: function spotDepositPermit2(
135: address token,
136: uint160 amount,
137:@> IAllowanceTransfer.PermitSingle calldata permitSingle,
138:@> bytes calldata signature
139: ) external {
140:@> permit2.permit(msg.sender, permitSingle, signature);
An attacker can front-run spotDepositPermit2 transaction by calling permit2.permit with publicly visible permitSingle and signature.
The legitimate transaction will revert because it tries to use a nonce that has already been used.
Consider wrapping permit2.permit with try-catch blocks to ensure deposit can continue even if permit2 call reverts.
This issue is explained in more detail in the following reports:
[L-09] Unused errors and events
The following errors are unused in GTERouter.sol and should be removed:
error UnwrapWethOnly();
error InvalidCLOBAmountSide();
The following event is unused (the same event is defined in Book.sol) in CLOB.sol and should be removed:
event TickSizeUpdated(uint256 newTickSize, uint256 nonce);
The following error is unused in CLOB.sol:
event TickSizeUpdated(uint256 newTickSize, uint256 nonce);
The following error is unused in CLOBManager.sol:
error CLOBBeaconMustHaveRouter();
Detailed Proofs of Concept for the above-listed Low-severity issues may be viewed here.
Disclosures
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.