Rock Paper Scissors

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

Fee Calculation Truncates Remainder, Causing ETH Dust to Accumulate in Contract

Summary

The RockPaperScissors::_finishGame and _handleTie functions calculate the protocol fee using Solidity's integer division. If the totalPot is not perfectly divisible by 100, the remainder will be silently discarded. This leads to unclaimable "dust" ETH that stays permanently locked in the contract and causes accounting discrepancies. Over time, this could accumulate to a noticeable amount, especially as the number of games increases.


Vulnerability Details

// Inside _finishGame and _handleTie
uint256 totalPot = game.bet * 2;
// @audit-issue Integer division truncates remainder, leading to dust ETH locked in contract
@> uint256 fee = (totalPot * PROTOCOL_FEE_PERCENT) / 100;
uint256 prize = totalPot - fee;

Issues Identified

  1. Integer Truncation

    • Solidity truncates results when performing division with integers.

    • For example, 3 * 10 / 100 = 0 when ideally you want 0.3 → 1 wei or round up.

  2. Locked ETH (Dust)

    • Any leftover remainder from the division will not be included in either fee or prize.

    • This remainder becomes permanently trapped in the contract.

  3. Accounting Discrepancy

    • accumulatedFees only tracks the truncated fee, but address(this).balance will include the leftover.

    • This mismatch can create confusion during audits or when calculating withdrawable fees.


Impact

  • Lost Funds: Small ETH amounts will be unrecoverable, especially impactful over many games.

  • Balance Mismatch: The tracked accumulatedFees will not match the actual contract balance.

  • User Trust Impact: Players may be concerned about unexplained leftover balances.


Proof of Concept (PoC)

PoC Explanation

This test creates a game where the total pot is not divisible by 100, ensuring a truncated remainder. The PoC confirms that fee < ceil(fee), proving dust is created.

function test_PoC_DustFee_Truncation() public {
// Choose a bet amount that causes fee truncation
uint256 dustBet = 1510000000000001; // 0.01510000000000001 ether
uint256 totalPot = dustBet * 2; // 0.03020000000000002 ether
uint256 actualFee = (totalPot * game.PROTOCOL_FEE_PERCENT()) / 100;
uint256 ceilFee = (totalPot * game.PROTOCOL_FEE_PERCENT() + 99) / 100;
console.log("Dust fee: %s", actualFee);
console.log("Ceil fee: %s", ceilFee);
assertTrue(actualFee < ceilFee, "Expected fee to be truncated and produce dust");
}

Expected Output

Dust fee: 3020000000000000
Ceil fee: 3020000000000001

This confirms a 1 wei discrepancy, which will remain locked in the contract and unaccounted for in accumulatedFees.


Tools Used

  • Manual Review

  • Foundry Unit Test


Recommendations

  1. Use Safe Rounding Up
    Prevent truncation using:

    uint256 fee = (totalPot * PROTOCOL_FEE_PERCENT + 99) / 100;
  2. Use Subtractive Fee Calculation

    uint256 prize = totalPot * 90 / 100;
    uint256 fee = totalPot - prize;
  3. Add Dust Recovery (Optional)
    Let admin recover any excess ETH not tracked in accumulatedFees.

    function recoverDust() external onlyAdmin {
    uint256 dust = address(this).balance - accumulatedFees;
    require(dust > 0, "No dust");
    (bool success,) = adminAddress.call{value: dust}("");
    require(success, "Recover failed");
    }

Updates

Appeal created

m3dython Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Rounding Error

The tie-handling logic loses one wei due to integer division

m3dython Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Rounding Error

The tie-handling logic loses one wei due to integer division

Support

FAQs

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