Pieces Protocol

First Flight #32
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: low
Valid

Protocol and users might lose funds due to Precision Loss in TokenDivider contract

Summary

The TokenDivider contract contains a precision loss vulnerability in its fee calculation logic within the buyOrder function. The issue arises from the use of integer division, which truncates any remainder when calculating the fee (order.price / 100) and the seller fee (fee / 2). This leads to an actual fee collected that is slightly less than the theoretical full fee, causing a financial discrepancy. While the issue may appear minor at first glance, its cumulative impact could be significant.

Vulnerability Details

The vulnerability lies in the calculation of the fee in the buyOrder function, specifically in the following lines:

uint256 fee = order.price / 100;
uint256 sellerFee = fee / 2;
if(msg.value < order.price + sellerFee) {
revert TokenDivider__InsuficientEtherForFees();
}
(bool success, ) = payable(order.seller).call{value: (order.price - sellerFee)}("");
if(!success) {
revert TokenDivider__TransferFailed();
}
(bool taxSuccess, ) = payable(owner()).call{value: fee}("");
if(!taxSuccess) {
revert TokenDivider__TransferFailed();
}

The root cause lies in Solidity's behavior when performing division with integers: it truncates the decimal portion of the result, leading to a loss of precision. Specifically:

The calculation of fee involves dividing order.price by 100 to determine 1% of the order value. The subsequent calculation of sellerFee divides the previously truncated fee by 2, compounding the precision loss.
For example, if the order price is $9999: The expected fee should be 9999 ÷ 100 = 99.9. However, due to truncation, the calculated fee is 99, resulting in a loss of $1.\

This precision loss affects downstream calculations, including the seller’s payout and the platform’s earnings.

Proof of concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import {Test, console2} from "forge-std/Test.sol";
import {TokenDivider} from "../src/TokenDivider.sol";
import {MockERC721} from "./MockERC721.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract TokenDividerTest is Test {
TokenDivider public tokenDivider;
MockERC721 public mockNFT;
address public owner;
address public buyer;
address public seller;
function setUp() public {
owner = makeAddr("owner");
buyer = makeAddr("buyer");
seller = makeAddr("seller");
vm.startPrank(owner);
tokenDivider = new TokenDivider();
mockNFT = new MockERC721();
vm.stopPrank();
}
function testPrecisionLossInFees() public {
// Setup: Mint NFT to seller
vm.startPrank(owner);
mockNFT.mint(seller, 1);
vm.stopPrank();
// Seller approves and divides NFT
vm.startPrank(seller);
mockNFT.approve(address(tokenDivider), 1);
tokenDivider.divideNft(address(mockNFT), 1, 100); // Create 100 fractions
// Get the ERC20 address from the TokenDivider
address erc20Address = tokenDivider.getErc20InfoFromNft(address(mockNFT)).erc20Address;
// Approve TokenDivider to spend ERC20 tokens
IERC20(erc20Address).approve(address(tokenDivider), type(uint256).max);
// Create sell order with a price that will demonstrate precision loss
// Using 9999 to demonstrate loss - theoretical 1% fee should be 99.99
uint256 orderPrice = 9999;
tokenDivider.sellErc20(address(mockNFT), orderPrice, 50); // Sell 50 fractions
vm.stopPrank();
// Track balances before purchase
uint256 initialSellerBalance = seller.balance;
uint256 initialOwnerBalance = owner.balance;
// Calculate theoretical values (what we should get with perfect precision)
uint256 theoreticalFee = (orderPrice * 1) / 100; // Should be 99.99 but will be 99
uint256 theoreticalFullFee = 100; // What we should get if we rounded up
// Buyer purchases the order
vm.startPrank(buyer);
vm.deal(buyer, orderPrice + 100); // Give buyer enough ETH for purchase + max possible fee
tokenDivider.buyOrder{value: orderPrice + 100}(0, seller);
vm.stopPrank();
uint256 actualSellerReceived = seller.balance - initialSellerBalance;
uint256 actualOwnerReceived = owner.balance - initialOwnerBalance;
console2.log("Order Price:", orderPrice);
console2.log("Theoretical Full Fee (100):", theoreticalFullFee);
console2.log("Actual Fee Collected:", actualOwnerReceived);
console2.log("Fee Difference (precision loss):", theoreticalFullFee - actualOwnerReceived);
console2.log("Seller Received:", actualSellerReceived);
// The actual fee should be less than the theoretical full fee due to precision loss
assertTrue(actualOwnerReceived < theoreticalFullFee, "Fee should be lost due to precision");
assertTrue(actualOwnerReceived == 99, "Fee should be truncated to 99");
}
function testEdgeCasePrecisionLoss() public {
// Similar setup as above but with price = 199
// This should demonstrate how fees get truncated:
// 199/100 = 1.99 truncated to 1
vm.startPrank(owner);
mockNFT.mint(seller, 1);
vm.stopPrank();
vm.startPrank(seller);
mockNFT.approve(address(tokenDivider), 1);
tokenDivider.divideNft(address(mockNFT), 1, 100);
address erc20Address = tokenDivider.getErc20InfoFromNft(address(mockNFT)).erc20Address;
IERC20(erc20Address).approve(address(tokenDivider), type(uint256).max);
uint256 orderPrice = 199;
tokenDivider.sellErc20(address(mockNFT), orderPrice, 50);
vm.stopPrank();
uint256 initialOwnerBalance = owner.balance;
vm.startPrank(buyer);
vm.deal(buyer, orderPrice + 2);
tokenDivider.buyOrder{value: orderPrice + 2}(0, seller);
vm.stopPrank();
uint256 actualOwnerReceived = owner.balance - initialOwnerBalance;
console2.log("\nEdge Case Test:");
console2.log("Order Price:", orderPrice);
console2.log("Expected Fee (1.99):", "1.99");
console2.log("Actual Fee Collected:", actualOwnerReceived);
assertTrue(actualOwnerReceived == 1, "Fee should be truncated to 1 instead of 1.99");
}
}

The division of order.price by 100 and subsequently dividing fee by 2 introduces precision loss due to integer arithmetic. For instance, if the order price is 9999, the theoretical fee should be 100 (9999 ÷ 100). However, the actual fee collected is 99, as demonstrated in the Proof of Concept (PoC) test logs:

Order Price: 9999
Theoretical Full Fee (100): 100
Actual Fee Collected: 99
Fee Difference (precision loss): 1
Seller Received: 9950

The loss of 1 unit may appear trivial for a single transaction, but across thousands of transactions, these discrepancies can aggregate into a significant financial impact on both the protocol and its users.

Impact

  1. Sellers and the platform may lose out on small amounts in each transaction. The collected fees are consistently lower than expected, resulting in revenue loss for the platform. This issue becomes more pronounced in high-frequency transaction scenarios, where even a small precision loss per transaction can add up to significant financial discrepancies over time.

  2. Affected users might lose trust in the platform if they perceive fee calculations to be inaccurate.

Recommendation

To address the precision loss, it is advisable to use fixed-point arithmetic instead of integer division. This can be achieved by scaling the input values to include decimals.

uint256 scaledPrice = order.price * 100; // Scale by 100 to handle decimals
uint256 fee = scaledPrice / 10000; // Adjust division to retain precision
uint256 sellerFee = fee / 2;

Here now, if you multiply 9999*100 you get, 999900, when you divide by 10000, you get 99.99 instead of 99
Previously, the fee would have been truncated to 99, causing a cumulative loss over multiple transactions.

Alternatively, consider using libraries such as PRBMath or OpenZeppelin’s utilities for handling fixed-point numbers accurately.

Updates

Lead Judging Commences

fishy Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Precision loss

Support

FAQs

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