52339 sc low loss of daily streak and jackpot eligibility due to supra generator callback delay and on callback time usage in spin sol
Submitted on Aug 10th 2025 at 00:55:23 UTC by @perseverance for Attackathon | Plume Network
Report ID: #52339
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/spin/Spin.sol
Impacts: Theft of unclaimed yield
Description
Short summary
When a user initiates a spin, the contract does not pin the request time for the user’s streak state. Instead, both the streak computation and jackpot/day-of-week logic are evaluated at the time the oracle callback arrives. Supra Generator can delay the callback or the callback might fail due to lack of fees for the Spin/Raffle system. For Supra dVRF 3.0, callbacks can legitimately be delayed (automatic retries up to 48 hours when underfunded), which makes the final evaluation depend on a later block.timestamp. This can break a user’s accumulated streak and/or change jackpot thresholds, resulting in missed rewards. Additionally, while pending, users are blocked from spinning again.
The vulnerability
The Spin contract computes reward category and the user’s streak at callback time using block.timestamp, not the time when the user clicked spin. If the oracle callback is delayed or retried due to insufficient gas funds, the evaluation can occur on a different day or even a different week than the request, breaking the user’s streak or moving jackpot thresholds.
Key code locations
Streak and reward are determined on callback, using current
block.timestamp:
// plume_network/attackathon-plume-network/plume/src/spin/Spin.sol:304-321
function handleRandomness(uint256 nonce, uint256[] memory rngList) external onlyRole(SUPRA_ROLE) nonReentrant {
// ...
// Compute daily streak and determine reward
uint256 currentSpinStreak = _computeStreak(user, block.timestamp, true); // @audit-issue compute streak based on the callback time
uint256 randomness = rngList[0]; // Use full VRF range
(string memory rewardCategory, uint256 rewardAmount) = determineReward(randomness, currentSpinStreak);
// ...
}determineRewardderives jackpot/day-of-week fromblock.timestampat callback time, not request time:
// plume_network/attackathon-plume-network/plume/src/spin/Spin.sol:383-413
function determineReward(uint256 randomness, uint256 streakForReward) internal view returns (string memory, uint256) {
uint256 probability = randomness % 1_000_000; // Normalize VRF range to 1M
uint256 daysSinceStart = (block.timestamp - campaignStartDate) / 1 days;
uint8 weekNumber = uint8(getCurrentWeek());
uint8 dayOfWeek = uint8(daysSinceStart % 7);
uint256 jackpotThreshold = jackpotProbabilities[dayOfWeek];
if (probability < jackpotThreshold) {
return ("Jackpot", jackpotPrizes[weekNumber]);
} else if (probability <= rewardProbabilities.plumeTokenThreshold) {
// ...
} else if (probability <= rewardProbabilities.raffleTicketThreshold) {
// ...
}
return ("Nothing", 0);
}The streak computation depends on the difference in days between
lastSpinTimestampand the callback time; if the callback lands after a gap > 1 day, the streak resets:
// plume_network/attackathon-plume-network/plume/src/spin/Spin.sol:417-445
function _computeStreak(address user, uint256 nowTs, bool justSpun) internal view returns (uint256) {
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; }
if (today == lastDaySpun + 1) { return userData[user].streakCount + streakAdjustment; }
return 0 + streakAdjustment; // broken streak
}While a request is pending, users cannot spin again, and canceling requires admin intervention and does not refund nor preserve the streak state tied to the request time:
// plume_network/attackathon-plume-network/plume/src/spin/Spin.sol:722-737
function cancelPendingSpin(address user) external onlyRole(ADMIN_ROLE) {
require(isSpinPending[user], "No spin pending for this user");
uint256 nonce = pendingNonce[user];
if (nonce != 0) { delete userNonce[nonce]; }
delete pendingNonce[user];
isSpinPending[user] = false;
// Note: The spin fee is NOT refunded.
}On the oracle side, insufficient funds trigger a retry mechanism rather than immediate callback. This is expected in dVRF 3.0 and can introduce hours/days of delay:
https://vscode.blockscan.com/ethereum/0x343027e1b7fa744679cd2596b2f7b4c823d939b1
// plume_network/eth_0x343027e1b7fa744679cd2596b2f7b4c823d939b1_code/src/SupraGeneratorContract.sol:227-248
if(IDepositContract(depositContract).checkClientFund(_clientWalletAddress) < tx.gasprice * (callbackGasLimit + IDepositContract(depositContract).verificationGasValue())) {
emit RequestRetry(
_nonce,
instanceId,
_callerContract,
_func,
_rngCount,
_clientSeed,
_clientWalletAddress,
block.chainid,
_requestBlockNumber
);
(bool fundsCollected, ) = depositContract.call(abi.encodeCall(IDepositContract.collectFund, (_clientWalletAddress, tx.gasprice * supraMinimumGasPerTx)));
if(!fundsCollected) {
revert FailedToCollectFunds();
}
return(false, fundsCollected, "");
}Documentation explicitly states dVRF 3.0 retries failed callbacks due to insufficient gas every 6 hours for up to 48 hours, which increases the chance of date/week drifting between request and fulfillment: see “Request Retry Mechanism” in Migration to dVRF 3.0 (docs.supra.com/dvrf/migration-to-dvrf-3.0).
Severity assessment
Bug Severity: High
Impact category: Loss of Yield — Loss/withholding of user rewards, unfair outcome manipulation, DoS of user participation (pending state), missed jackpot eligibility.
Reasoning:
Impact: Users can lose accumulated streaks and jackpots because evaluation is anchored to callback time. This directly affects user rewards and expected game fairness. If the streak required for a given jackpot is higher (e.g., week 3 requires 5 streaks), losing the streak is more severe and forces the user to start over.
Likelihood: Underfunded deposits or volatile gas are common; dVRF 3.0 intentionally retries for up to 48h, making delayed callbacks realistic.
Suggested Fix / Remediation
Pin request-time state and decouple reward evaluation from callback time. Concretely:
Store request timestamp (or precomputed day/week indices and streak snapshot) when the user starts a spin.
Use the pinned request-time values (request timestamp / request day / week / streak snapshot) in
handleRandomness,_computeStreak, anddetermineRewardinstead ofblock.timestamp.Allow users to re-spin or refund when requests are pending for too long (optional), or ensure pending state preserves request-time streak if a cancel/refund path is used.
Proof of Concept (Conceptual)
T = D+1 (callback finally arrives)
handleRandomnesscomputescurrentSpinStreak = _computeStreak(user, block.timestamp, true). Sincetoday > lastDaySpun + 1, the function returns1(broken streak), notS+1.determineRewardcomputesdayOfWeekandweekNumberfrom the newblock.timestamp, which may have differentjackpotThreshold/prize.The user loses their streak and likely loses jackpot eligibility.
Sequence diagram
sequenceDiagram
participant U as User
participant Spin as Spin.sol
participant Router as SupraRouterContract
participant Gen as SupraGeneratorContract
participant Dep as Deposit
U->>Spin: startSpin() with fee
Spin->>Router: generateRequest("handleRandomness(uint256,uint256[])",1, ...)
Router->>Gen: rngRequest(...)
Gen->>Dep: checkClientFund(...)
alt Insufficient funds / high gas
Gen-->>Gen: emit RequestRetry(...)
note right of Gen: dVRF 3.0 retries every 6h up to 48h
Gen-->>Router: (no callback yet)
Router-->>Spin: (no callback yet)
note over U,Spin: Pending state persists. Day rolls over.
end
U-->>U: Time passes (>= 1 day)
Gen->>Router: rngCallback(nonce, rngList, Spin, "handleRandomness(uint256,uint256[])")
Router->>Spin: handleRandomness(nonce, rngList)
Spin->>Spin: _computeStreak(user, block.timestamp, true)
note right of Spin: Streak broken due to callback day > lastSpinDay + 1
Spin->>Spin: determineReward(randomness, currentSpinStreak)
note right of Spin: dayOfWeek/weekNumber taken from callback time
Spin-->>U: Emits SpinCompleted with reduced/no rewardNotes / References
Target file: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/spin/Spin.sol
Supra Generator retry logic reference (on-chain): https://vscode.blockscan.com/ethereum/0x343027e1b7fa744679cd2596b2f7b4c823d939b1
dVRF 3.0 docs “Request Retry Mechanism”: https://docs.supra.com/dvrf/migration-to-dvrf-3.0
(End of report)
Was this helpful?