51776 sc low streak system breaks despite timely user action due to delayed supra oracle callback

  • Submitted on Aug 5th 2025 at 18:36:20 UTC by @light279 for Attackathon | Plume Network

  • Report ID: #51776

  • Report Type: Smart Contract

  • Report severity: Low

  • Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/spin/Spin.sol

  • Impacts:

    • Temporary freezing of funds for at least 24 hours

    • Protocol insolvency

Description

Brief/Intro

The contract allows users to spin once per day and maintains a streak system to reward consecutive daily spins. However, the streak computation relies on block.timestamp inside the Spin::handleRandomness callback, which is triggered asynchronously by the Supra Oracle. This introduces a time gap between the user-initiated Spin::startSpin and the actual randomness handling, potentially breaking user streaks even if the user called the function before the end of the day.

Vulnerability Details

The function _computeStreak calculates whether a user’s spin continues their streak based on the current block timestamp during the handleRandomness execution. But since startSpin only emits a randomness request and the actual response is handled asynchronously (likely in a different block and time), a delay in the Supra oracle’s callback may push the effective time of handleRandomness into the next day.

This results in nowTs / SECONDS_PER_DAY != lastSpinTs / SECONDS_PER_DAY + 1 even when the user called startSpin on time. The streak is then reset despite correct user behavior.

Code excerpt of _computeStreak:

Spin.sol — _computeStreak
function _computeStreak(address user, uint256 nowTs, bool justSpun) internal view returns (uint256) {
        // if a user just spun, we need to increment the streak its a new day or a broken streak
        uint256 streakAdjustment = justSpun ? 1 : 0;

        uint256 lastSpinTs = userData[user].lastSpinTimestamp;

        if (lastSpinTs == 0) {
            return 0 + streakAdjustment;
        }
        uint256 lastDaySpun = lastSpinTs / SECONDS_PER_DAY;
        uint256 today = nowTs / SECONDS_PER_DAY;
        if (today == lastDaySpun) {
            return userData[user].streakCount;
        } // same day
        if (today == lastDaySpun + 1) {
            return userData[user].streakCount + streakAdjustment;
        } // streak not broken yet
        return 0 + streakAdjustment; // broken streak
    }

This issue manifests in handleRandomness where block.timestamp (the oracle callback time) is used instead of the user's original spin time:

Spin.sol — handleRandomness (excerpt)
 function handleRandomness(uint256 nonce, uint256[] memory rngList) external onlyRole(SUPRA_ROLE) nonReentrant {
        address payable user = userNonce[nonce];
        if (user == address(0)) {
            revert InvalidNonce();
        }

        isSpinPending[user] = false;
        delete userNonce[nonce];
        delete pendingNonce[user];

        uint256 currentSpinStreak = _computeStreak(user, block.timestamp, true);
        uint256 randomness = rngList[0]; // Use full VRF range
        
............................

Using block.timestamp here does not reflect the user's original spin time, but the time at which the oracle responds.

Impact Details

Proof of Concept

1

Scenario setup

Assume current day is Day X. A user spins at 23:59 (end of Day X) by calling startSpin().

2

Oracle latency occurs

startSpin() correctly generates a Supra oracle request and passes callback signature handleRandomness(uint256,uint256[]). Due to Oracle latency, the callback handleRandomness() is only called at 00:00:10 on Day X+1.

3

Streak calculation at callback

In handleRandomness(), the _computeStreak() function is called with block.timestamp = 00:00:10, which translates to Day X+1. _computeStreak() checks:

if (today == lastDaySpun + 1) {
    return userData[user].streakCount + 1;
}

But today = X+1, lastDaySpun = X-1, so the condition fails.

4

Result

The fallback clause is executed:

return 0 + 1;

So the user's streak is reset even though they spun on Day X. This causes unexpected behavior and harms streak-based rewards.

Was this helpful?