Summary
Use of a uint64 data type for the totalFees
variable leads to withdrawFees() function failures when working with large values, such as 19.2e18 or higher as 18,446,744,073,709,551,615 is the maximum value of uint64 can hold. Changing the variable 'totalFees' data type to uint256
can handle much larger values without potential overflow or wraparound issues.
Once fees go even higher than 19.2e18 functions below will be affected as well:
selectWinner()
Vulnerability Details
Below withdrawFees() function will fail if fees are higher than 18,446,744,073,709,551,615 which is the maximum value of uint64 totalFees
can hold, meaning fees will stay locked in the contract forever.
address public feeAddress;
@> uint64 public totalFees = 0;
function withdrawFees() external {
require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!");
@> uint256 feesToWithdraw = totalFees;
totalFees = 0;
(bool success,) = feeAddress.call{value: feesToWithdraw}("");
require(success, "PuppyRaffle: Failed to withdraw fees");
}
Once fees go even higher than 19.2e18 functions below functions will be affected as well:
selectWinner()
Impact
withdrawFees() function fails when tested in foundry
Foundry testing
using 4 players fees can be withdrawn from the contract when entranceFee
is set to 23e18
as calculated fee will be below maximum value that uint64 can take, fee will be (23,000,000,000,000,000,000 * 4) * 20 / 100 = 18,400,000,000,000,000,000
@> entranceFee = 23e18;
modifier playersEntered() {
address[] memory players = new address[](4);
players[0] = playerOne;
players[1] = playerTwo;
players[2] = playerThree;
players[3] = playerFour;
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
_;
}
function testWithdrawFees() public playersEntered {
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
uint256 expectedPrizeAmount = ((entranceFee * 4) * 20) / 100;
puppyRaffle.selectWinner();
puppyRaffle.withdrawFees();
assertEq(address(feeAddress).balance, expectedPrizeAmount);
}
Running 1 test for test/PuppyRaffleTest.t.sol:PuppyRaffleTest
[PASS] testWithdrawFees() (gas: 317155)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.68ms
fees can't be withdrawn from the contract when entranceFee
is set to 24e18
, meaning withdrawFees() function fails as value of fees is higher than the maximum value of uint64 can hold, fee will be (24,000,000,000,000,000,000 * 4) * 20 / 100 = 19,200,000,000,000,000,000
which is higher than uint64 max take of 18,446,744,073,709,551,615
@> entranceFee = 24e18;
modifier playersEntered() {
address[] memory players = new address[](4);
players[0] = playerOne;
players[1] = playerTwo;
players[2] = playerThree;
players[3] = playerFour;
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
_;
}
function testWithdrawFees() public playersEntered {
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
uint256 expectedPrizeAmount = ((entranceFee * 4) * 20) / 100;
puppyRaffle.selectWinner();
puppyRaffle.withdrawFees();
assertEq(address(feeAddress).balance, expectedPrizeAmount);
}
Running 1 test for test/PuppyRaffleTest.t.sol:PuppyRaffleTest
[FAIL. Reason: Assertion failed.] testWithdrawFees() (gas: 326792)
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 2.98ms
function testWithdrawFees() public playersEntered {
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
uint256 expectedFeesAmount = ((entranceFee * 4) * 20) / 100;
puppyRaffle.selectWinner();
uint64 totalCurrentFeesAfterTheWinnerIsPicked = puppyRaffle.getFees();
console.log(address(puppyRaffle).balance);
console.log(totalCurrentFeesAfterTheWinnerIsPicked);
}
now the improper conversion directly effects how fees are calculated in selectWinner() function once the uint64 is overflown, meaning when a larger integer type is casted to a smaller one (e.g., uint256 to uint64), it truncates the higher bits, effectively taking only the least significant bits, this can lead to unexpected results when the source value is larger than the maximum value a uint64 can hold, which is 18,446,744,073,709,551,615
-- in PuppyRaffle contract exactly that happens, if 4 players enter the raffle with entranceFee
set to 24e18
the fees will be truncated and calculated incorrectly, this means when fee is converted to uint64 the fee becomes less than it should, example below:
@> entranceFee = 24e18;
function testFees() public playersEntered {
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
puppyRaffle.selectWinner();
uint256 totalAmountCollected = 4 * entranceFee;
uint256 currentFees = (totalAmountCollected * 20) / 100;
uint64 totalCurrentFeesConvertedToUint64 = uint64(currentFees);
uint64 feesReturnedFromTheContract = puppyRaffle.getFees();
console.log("Total amount collected: ", totalAmountCollected);
console.log("Current Fee in uint256: ", currentFees);
console.log("Fee converted in uint64 as per contract code: ", totalCurrentFeesConvertedToUint64);
console.log("Fee returned from the contract using getter function: ", feesReturnedFromTheContract);
}
Running 1 test for test/PuppyRaffleTest.t.sol:PuppyRaffleTest
[PASS] testFees() (gas: 289834)
Logs:
Total amount collected: 96000000000000000000
Current Fee in uint256: 19200000000000000000
Fee converted in uint64 as per contract code: 753255926290448384
Fee returned from the contract using getter function: 753255926290448384
(24,000,000,000,000,000,000 * 4) * 20 / 100 = 19,200,000,000,000,000,000 -> this should be correct fee and not `753255926290448384`
Tools Used
Recommendations
- uint64 public totalFees = 0;
+ uint256 public totalFees = 0;
- totalFees = totalFees + uint64(fee);
+ totalFees = totalFees + fee;
- require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!");
+ require(address(this).balance == totalFees, "PuppyRaffle: There are currently players active!");