F-2025-0003·incorrect-state-reset

Incremental totalUnclaimedProceeds funding mechanism leads to systematic accounting corruption and withdrawal failures

Fixedlotterypixel-lotterychainlink-vrf
TL;DR

withdraw() resets userRewardPerShare as if the user fully withdrew, even on partial withdrawals, freezing the retained proceeds out of future reward distributions and breaking the reward tracking invariant.

Severity
HIGH
Impact
HIGH
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
LOW
Exploitability
MEDIUM
02Section · Description

Description

The withdraw(uint256 amount) function contains a flaw in its reward tracking logic that permanently corrupts future reward calculations for users who perform partial withdrawals. When a user withdraws only a portion of their proceeds, the function incorrectly resets their reward tracking index (userRewardPerShare) as if they had withdrawn all funds, while simultaneously retaining the remaining proceeds in their balance.

solidity
function withdraw(uint256 amount) external nonReentrant {
// ... calculate totalProceeds including pending rewards
uint256 totalProceeds = proceeds[msg.sender] + pending;
// Store remaining proceeds after partial withdrawal
proceeds[msg.sender] = totalProceeds - amount;
// BUG: Reset tracking index as if user withdrew everything
userRewardPerShare[msg.sender] = rewardPerShare;
}

The contract uses a Compound-style reward distribution system where userRewardPerShare[user] tracks the last synchronization point for calculating pending rewards. This index should only be fully reset when proceeds[user] == 0. By resetting it during partial withdrawals while retaining proceeds, the system breaks the fundamental invariant of the reward tracking mechanism.

Vulnerable Scenario:

The following steps help understand the issue:

  1. Alice has 100 APE total proceeds available for withdrawal
  2. Alice calls withdraw(50) to withdraw 50 APE, leaving 50 APE in her proceeds balance
  3. The contract sets proceeds[Alice] = 50 APE (remaining amount)
  4. Bug: The contract sets userRewardPerShare[Alice] = rewardPerShare (full reset)
  5. Contract receives new deposits, increasing the global rewardPerShare
  6. Alice's future reward calculations only consider rewards earned after step 4
  7. Result: Alice's retained 50 APE never participates in future reward distributions
03Section · Impact

Impact

This vulnerability causes users who perform partial withdrawals to permanently lose the ability to earn future rewards on their retained proceeds. The retained funds become "frozen" in the reward system, unable to participate in subsequent reward distributions. The impact compounds over time as new deposits flow into the contract, with affected users missing out on proportional rewards that should apply to their retained balances. This represents a fundamental breakdown of the reward distribution system for any user who doesn't withdraw their full balance in a single transaction.

04Section · Recommendation

Recommendation

Fix the reward tracking logic to properly handle partial withdrawals by preventing index reset during partial withdrawals.

solidity
function withdraw(uint256 amount) external nonReentrant {
// ... existing logic ...
proceeds[msg.sender] = totalProceeds - amount;
// Only reset index if user has no remaining proceeds
if (proceeds[msg.sender] == 0) {
userRewardPerShare[msg.sender] = rewardPerShare;
}
// If proceeds remain, keep the existing index to maintain proper tracking
}

In this way, it maintains the intended functionality while fixing the core accounting flaw.

05Section · Resolution

Resolution

Golden Grid: Confirmed. A test was implemented and it showed that a fix included on a previous issue, fixed this vulnerability as well.

Zealynx: Fixed. Made sure that the issue was present on commit under scope and then fixed as per dev team's test results.

Status
Fixed
F-2025-0003

oog
zealynx

Smart Contract Security Digest

Monthly exploit breakdowns, audit checklists, and DeFi security research — straight to your inbox

© 2026 Zealynx