Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: high
Valid

# Integer overflow / unsafe `uint64` cast in `PuppyRaffle::selectWinner` corrupts fee accounting and locks fees

[M-1] Integer overflow / unsafe uint64 cast in PuppyRaffle::selectWinner corrupts fee accounting and locks fees

Risk

Likelihood: Medium — Triggers automatically once the fee for a raffle exceeds type(uint64).max (~18.44 ETH of fees, i.e. ~92 entrants at a 1 ETH entrance fee), or cumulatively across rounds. No attacker action required.

Impact: High — The recorded totalFees no longer matches the ETH actually held, so fees are unaccounted for and withdrawFees reverts permanently — the fees are locked in the contract.

Severity: Medium (High impact × Medium likelihood).

Description

totalFees is a uint64, and selectWinner adds the fee to it using an unchecked uint64 cast under Solidity 0.7.6 (which does not have built-in overflow protection):

uint64 public totalFees = 0;
...
uint256 fee = (totalAmountCollected * 20) / 100;
totalFees = totalFees + uint64(fee); // @> unsafe cast + uint64 overflow, no 0.8 checks

If fee > type(uint64).max (≈ 1.8446e19 wei ≈ 18.44 ETH), the uint64(fee) cast truncates the value (keeps only fee % 2**64), and uint64 addition can silently wrap. totalFees then records far less than the fees the contract actually holds.

Impact

  • Fee accounting is corrupted: totalFees undercounts the real fees.

  • withdrawFees requires address(this).balance == uint256(totalFees); since the balance now exceeds the truncated totalFees, this check can never pass, so the legitimately-earned fees are permanently locked.

Proof of Concept

93 players enter (real fee = 18.6 ETH), the winner is selected, and:

  • recorded totalFees0.153 ETH (truncated), vs 18.6 ETH actually held — assertLt(recordedFees, expectedFee) passes.

  • assertEq(address(puppyRaffle).balance, 18.6 ETH) passes.

  • withdrawFees() reverts with "There are currently players active!" — fees locked.

function test_totalFees_overflow_loses_and_locks_fees() public {
uint256 n = 93; // fee = 93 * 0.2 ETH = 18.6 ETH > type(uint64).max (~18.44e18 wei)
address[] memory players = new address[](n);
for (uint256 i = 0; i < n; i++) players[i] = address(uint160(i + 1));
puppyRaffle.enterRaffle{value: entranceFee * n}(players);
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
puppyRaffle.selectWinner();
uint256 expectedFee = (entranceFee * n * 20) / 100; // 18.6 ETH actually held
uint256 recordedFees = uint256(puppyRaffle.totalFees()); // truncated by uint64 cast
assertLt(recordedFees, expectedFee); // accounting undercounts
assertEq(address(puppyRaffle).balance, expectedFee); // contract holds the full fee
vm.expectRevert("PuppyRaffle: There are currently players active!"); // fees locked
puppyRaffle.withdrawFees();
}

Recommended Mitigation

Use Solidity ^0.8.x (built-in overflow checks) and store totalFees as uint256 with no narrowing cast:

-pragma solidity ^0.7.6;
+pragma solidity ^0.8.20;
...
-uint64 public totalFees = 0;
+uint256 public totalFees = 0;
...
-totalFees = totalFees + uint64(fee);
+totalFees = totalFees + fee;

Also prefer tracking fees with an internal accounting variable rather than comparing against address(this).balance.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-05] Typecasting from uint256 to uint64 in PuppyRaffle.selectWinner() May Lead to Overflow and Incorrect Fee Calculation

## Description ## Vulnerability Details The type conversion from uint256 to uint64 in the expression 'totalFees = totalFees + uint64(fee)' may potentially cause overflow problems if the 'fee' exceeds the maximum value that a uint64 can accommodate (2^64 - 1). ```javascript totalFees = totalFees + uint64(fee); ``` ## POC <details> <summary>Code</summary> ```javascript function testOverflow() public { uint256 initialBalance = address(puppyRaffle).balance; // This value is greater than the maximum value a uint64 can hold uint256 fee = 2**64; // Send ether to the contract (bool success, ) = address(puppyRaffle).call{value: fee}(""); assertTrue(success); uint256 finalBalance = address(puppyRaffle).balance; // Check if the contract's balance increased by the expected amount assertEq(finalBalance, initialBalance + fee); } ``` </details> In this test, assertTrue(success) checks if the ether was successfully sent to the contract, and assertEq(finalBalance, initialBalance + fee) checks if the contract's balance increased by the expected amount. If the balance didn't increase as expected, it could indicate an overflow. ## Impact This could consequently lead to inaccuracies in the computation of 'totalFees'. ## Recommendations To resolve this issue, you should change the data type of `totalFees` from `uint64` to `uint256`. This will prevent any potential overflow issues, as `uint256` can accommodate much larger numbers than `uint64`. Here's how you can do it: Change the declaration of `totalFees` from: ```javascript uint64 public totalFees = 0; ``` to: ```jasvascript uint256 public totalFees = 0; ``` And update the line where `totalFees` is updated from: ```diff - totalFees = totalFees + uint64(fee); + totalFees = totalFees + fee; ``` This way, you ensure that the data types are consistent and can handle the range of values that your contract may encounter.

Support

FAQs

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

Give us feedback!