Summary
The FeeCollector
contract has a precision loss issue in the _calculatePendingRewards
function. This issue arises due to Solidity’s integer division, which truncates decimal values, leading to inaccurate reward distributions.
_calculatePendingRewards
Vulnerability Details
The _calculatePendingRewards
function calculates a user's share of the total distributed rewards using integer division:
uint256 share = (totalDistributed * userVotingPower) / totalVotingPower;
Since Solidity does not support floating-point arithmetic, this operation discards fractional values, resulting in incorrect rewards being transferred.
Foundry Test:
├─ [38016] FeeCollector::claimRewards(LendingPoolTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496])
│ ├─ [1644] veRAACToken::getVotingPower(LendingPoolTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall]
│ │ └─ ← [Return] 29761315 [2.976e7]
│ ├─ [374] veRAACToken::getTotalVotingPower() [staticcall]
│ │ └─ ← [Return] 250684931506849315 [2.506e17]
│ ├─ [0] console::log("totalDistributed is :", 437500000000000 [4.375e14]) [staticcall]
│ │ └─ ← [Stop]
│ ├─ [3281] ERC20Mock::transfer(LendingPoolTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 51939 [5.193e4])
│ │ ├─ emit Transfer(from: FeeCollector: [0x51a240271AB8AB9f9a21C82d9a85396b704E164d], to: LendingPoolTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], value: 51939 [5.193e4])
│ │ └─ ← [Return] true
│ ├─ emit RewardClaimed(user: LendingPoolTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], amount: 51939 [5.193e4])
│ └─ ← [Return] 51939 [5.193e4]
└─ ← [Stop]
totalDistributed = 437500000000000
userVotingPower = 29761315
totalVotingPower = 250684931506849315
437500000000000 * 29761315 = 13018325312500000000000
13018325312500000000000 / 250684931506849315 = 51,931.999880464480888508196688651
The User was expected to make as much as = 51,931.999880464480888508196688651
but the Due to the Issue they are getting = 51,931
Impact
Users receive lower rewards than they should.
The issue accumulates over time, leading to significant unclaimed rewards.
Can cause dissatisfaction among users and undermine trust in the reward mechanism.
Observed Behavior
Expected reward: 51,939.999880464480888508196688651
Actual reward transferred: 51,939
Tools Used
Manual code review
Solidity debugging tools (e.g., Hardhat, Foundry)
Console logging (console.log
in Foundry) to observe precision loss
POC : FOUNDRY
pragma solidity ^0.8.20;
import "forge-std/src/Test.sol";
import "../../contracts/core/pools/LendingPool/LendingPool.sol";
import "../../contracts/core/collectors/FeeCollector.sol";
import "../../contracts/core/collectors/Treasury.sol";
import "../../contracts/mocks/core/tokens/ERC20Mock.sol";
import "../../contracts/core/tokens/veRAACToken.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract LendingPoolTest is Test {
FeeCollector feeCollector;
veRAACToken veraactoken;
ERC20Mock token;
LendingPool lendingPool;
Treasury treasury;
address admin;
address user;
uint256 public swapTaxRate = 100;
uint256 public burnTaxRate = 50;
address public TokenAddress;
uint256 constant WAD = 1e18;
uint256 constant RAY = 1e27;
uint256 STARTING_BALANCE = 100 ether;
uint256 amount = 10 ether;
function setUp() public {
admin = vm.addr(1);
user = vm.addr(2);
vm.startPrank(admin);
token = new ERC20Mock("ERC20Mock", "ERC");
TokenAddress = address(token);
veraactoken = new veRAACToken(TokenAddress);
treasury = new Treasury(admin);
feeCollector = new FeeCollector(TokenAddress, address(veraactoken), address(treasury), admin, admin);
token.mint(TokenAddress, STARTING_BALANCE);
token.mint(admin, STARTING_BALANCE);
token.mint(address(treasury), STARTING_BALANCE);
IERC20(TokenAddress).balanceOf(admin);
IERC20(TokenAddress).balanceOf(TokenAddress);
IERC20(TokenAddress).balanceOf(address(this));
vm.stopPrank();
}
function testClaimReward() public {
token.mint(address(this), amount);
IERC20(address(token)).transfer(address(this), amount);
IERC20(address(token)).approve(address(feeCollector), amount);
IERC20(address(token)).approve(address(veraactoken), amount);
IERC20(TokenAddress).balanceOf(address(this));
uint256 _amount = 1 ether;
uint256 duration = 365 days + 1 days;
feeCollector.collectFee(625000000000000, 1);
veraactoken.lock(_amount, duration);
skip(365 days + 1 days);
vm.startPrank(admin);
feeCollector.distributeCollectedFees();
vm.stopPrank();
feeCollector.claimRewards(address(this));
}
}
POC Setup:
Install forge-std and add it in the node\_modules
folder and create a folder called foundry and create a file called feeCollector.t.sol
and paste my poc in the file and you are good to go
.
The Hole idea was that when the test run the token reward that was supposed to be sent to the user is "51,931.999880464480888508196688651"
but Due to Precision issue in solidity it will send "51931"
to the user living some part of their reward in the contract : "999880464480888508196688651"
Recommendations
Solution 1: Scale Up Before Division
Multiplying values by a higher factor before performing the division helps maintain precision:
uint256 share = (totalDistributed * userVotingPower * 1e18) / totalVotingPower;
share = share / 1e18;
This prevents truncation errors by retaining more precise values during calculations.
Solution 2: Use a Fixed-Point Math Library
Utilizing libraries such as PRBMath
or FixedPointMath
ensures accurate calculations without significant precision loss.
Implementing one of these solutions will improve the fairness and accuracy of reward distributions in the contract.