OrderBook

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

Fee Calculation Rounding Error Causes Zero Fees for Small Transactions

Root + Impact

Description

  • Normal behavior: The protocol should collect a 3% fee on all transactions regardless of transaction size. Every trade should contribute to protocol revenue through consistent fee collection.

  • Specific issue: The fee calculation uses integer division which rounds down to zero for small transaction amounts, causing the protocol to lose fee revenue on trades below 34 USDC. This creates an economic incentive for users to split large orders into multiple small orders to avoid fees.

  • Technical Details: The current fee calculation: `protocolFee = (order.priceInUSDC * FEE) / PRECISION` where FEE = 3 and PRECISION = 100, results in zero fees for any price less than 34 USDC due to Solidity's integer division truncation.

  • Mathematical Analysis:
    - For 33 USDC: fee = (33 * 3) / 100 = 99 / 100 = 0 (truncated)
    - For 34 USDC: fee = (34 * 3) / 100 = 102 / 100 = 1 wei
    - This creates a fee cliff where users pay 0 or 1+ wei with no gradual scaling

  • Economic Impact: Protocol loses revenue on small trades - Users can exploit this by splitting large orders - Unfair fee structure favoring small transactions - Potential for fee avoidance strategies

// Root cause in the codebase with @> marks to highlight the relevant section
function buyOrder(uint256 _orderId) public {
Order storage order = orders[_orderId];
// ... validation checks
@> uint256 protocolFee = (order.priceInUSDC * FEE) / PRECISION; // FEE = 3, PRECISION = 100
@> uint256 sellerReceives = order.priceInUSDC - protocolFee;
iUSDC.safeTransferFrom(msg.sender, address(this), protocolFee);
iUSDC.safeTransferFrom(msg.sender, order.seller, sellerReceives);
// ... rest of function
@> totalFees += protocolFee; // Accumulates zero fees for small transactions
}

Risk

Likelihood:

  • Reason 1: Automatically occurs for any transaction with price < 34 USDC (very common for small trades or test transactions)

  • Reason 2: Users can intentionally exploit this by splitting large orders into multiple sub-34 USDC transactions

Impact:

  • Impact 1: Direct revenue loss for the protocol on all small transactions below the fee threshold

  • Impact 2: Economic distortion where users are incentivized to avoid fees through order splitting, reducing overall protocol efficiency

Proof of Concept

The current fee calculation (price * 3) / 100 results in zero fees for any price below 34 USDC due to Solidity's integer division truncation. This creates a significant fee avoidance opportunity.

// Simplified PoC demonstrating fee avoidance
contract FeeRoundingPoC {
function testFeeRounding() public {
// Test small transaction - should pay 3% fee but pays 0
uint256 smallPrice = 33e6; // 33 USDC
uint256 expectedFee = (smallPrice * 3) / 100; // Should be ~0.99 USDC
uint256 actualFee = (smallPrice * 3) / 100; // Actually 0 due to rounding
console.log("Small order price:", smallPrice);
console.log("Expected fee (3%):", (smallPrice * 3) / 100);
console.log("Actual fee:", actualFee);
assert(actualFee == 0); // Fee is zero!
// Demonstrate fee avoidance strategy
uint256 totalValue = 1000e6; // 1000 USDC total
uint256 singleOrderFee = (totalValue * 3) / 100; // 30 USDC fee
// Split into 31 orders of 33 USDC each
uint256 splitOrders = 31;
uint256 orderSize = 33e6;
uint256 totalSplitFees = 0;
for(uint256 i = 0; i < splitOrders; i++) {
totalSplitFees += (orderSize * 3) / 100; // Each order pays 0 fee
}
console.log("Single order fee:", singleOrderFee);
console.log("Split orders total fee:", totalSplitFees);
console.log("Fee avoided:", singleOrderFee - totalSplitFees);
assert(totalSplitFees == 0); // No fees paid at all!
// Output: Fee avoided: 30 USDC
}
}

Recommended Mitigation

Implement a minimum fee to ensure all transactions contribute to protocol revenue while maintaining fair fee structure.

- function buyOrder(uint256 _orderId) public {
+ function buyOrder(uint256 _orderId) public {
Order storage order = orders[_orderId];
// ... validation checks
- uint256 protocolFee = (order.priceInUSDC * FEE) / PRECISION;
+ uint256 protocolFee = calculateFee(order.priceInUSDC);
uint256 sellerReceives = order.priceInUSDC - protocolFee;
iUSDC.safeTransferFrom(msg.sender, address(this), protocolFee);
iUSDC.safeTransferFrom(msg.sender, order.seller, sellerReceives);
// ... rest of function
}
+ // Add helper function with minimum fee
+ function calculateFee(uint256 price) internal pure returns (uint256) {
+ uint256 calculatedFee = (price * FEE) / PRECISION;
+ return calculatedFee > 0 ? calculatedFee : 1; // Minimum 1 wei fee
+ }
Updates

Lead Judging Commences

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

Fee can be bypassed

Protocol Suffers Potential Revenue Leakage due to Precision Loss in Fee Calculation

Support

FAQs

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