49868 sc insight raffle sol does not enforce prize endtimestamp allowing user and admin interactions with expired prizes
Submitted on Jul 20th 2025 at 03:51:08 UTC by @blackgrease for Attackathon | Plume Network
Report ID: #49868
Report Type: Smart Contract
Report severity: Insight
Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/spin/Raffle.sol
Impacts:
Contract fails to deliver promised returns, but doesn't lose value
Description
The severity and impact mismatch is intentional. While the stated impact is Contract fails to deliver promised returns, but doesn't lose value, I believe the overall severity is High/Medium in relation to failed functionality causing disparity between the protocols expectation and the actual contract execution.
Summary
The Raffle allows an admin to update the ending timestamp of a created prize. However, there are no checks in place enforcing that user raffle tickets cannot be spent on expired prizes. Furthermore, expired prizes can be modified by admins. This results in incorrect interactions involving users and admins with prizes that are supposed to be expired.
Details
The Raffle contract by default sets all created prizes to have an endTimestamp of 0 in addPrize. This means prizes will continue indefinitely unless updated.
function addPrize( string calldata name, string calldata description, uint256 value,uint256 quantity) external onlyRole(ADMIN_ROLE) {
uint256 prizeId = nextPrizeId++;
prizeIds.push(prizeId);
require(bytes(prizes[prizeId].name).length == 0, "Prize ID already in use");
require(quantity > 0, "Quantity must be greater than 0");
prizes[prizeId] = Prize({
name: name,
description: description,
value: value,
endTimestamp: 0, /@audit: by default, prizes have no expiration date.
isActive: true,
winner: address(0), // deprecated
winnerIndex: 0, // deprecated
claimed: false, // deprecated
quantity: quantity
});
emit PrizeAdded(prizeId, name);
}An admin can update a prize's endTimestamp using:
// Timestamp update for prizes
function updatePrizeEndTimestamp(uint256 prizeId, uint256 endTimestamp) external onlyRole(ADMIN_ROLE) prizeIsActive(prizeId) {
prizes[prizeId].endTimestamp = endTimestamp;
}The Raffle contract does not check if a prize has expired; therefore interactions with technically expired prizes are allowed. A non-exhaustive list of problematic interactions:
Users: spending raffle tickets on expired prizes.
Admin:
Can edit an expired prize.
Can request a winner on a technically expired prize.
Can remove an expired prize (not a security loss but wasteful).
The prizeIsActive modifier only checks isActive and does not consider endTimestamp:
modifier prizeIsActive(uint256 prizeId) {
require(prizes[prizeId].isActive, "Prize not available");
_;
}While the contract runs without reverting, it functions incorrectly relative to the intended semantics: when a prize has an expiration set, interactions should be prevented after that time.
Impact
The stated impact in the original report is: Contract fails to deliver promised returns, but doesn't lose value. The author argues the functional mismatch (admin expects a prize to be expired; users can still participate) elevates the effective severity to High/Medium because:
Admins may set an expiration and stop interacting with a prize off-chain, while users can still spend tickets on it unaware that no draws will be requested.
Any prize with a non-zero
endTimestampthat is in the past will still accept interactions unless actively deactivated.
Likelihood: High for any time-limited prize that is left with isActive == true but with endTimestamp in the past.
Resulting incorrect behaviors include:
Users spending raffle tickets on expired prizes.
Admins editing or requesting winners for expired prizes.
Mitigation
Recommended global fix: incorporate expiration checks into the prizeIsActive modifier so all functions using it automatically inherit expiration enforcement.
Suggested behavior:
If
endTimestamp == 0→ prize does not expire, skip expiration check.If
endTimestamp != 0andblock.timestamp > endTimestamp→ mark prize inactive and revert.
Proposed modifier change:
modifier prizeIsActive(uint256 prizeId) {
+ if(prizes[prizeId].endTimestamp != 0){ // If the prize is not supposed to run forever, check expiration.
+ if(block.timestamp > prizes[prizeId].endTimestamp){
+ // Automatically set the prize inactive globally
+ prizes[prizeId].isActive = false;
+ // Revert
+ revert("Prize has expired");
+ }
+ }
- require(prizes[prizeId].isActive, "Prize not available");
+ // No checks if prize is set to run indefinitely
+ require(prizes[prizeId].isActive, "Prize not available");
_;
}Additionally, make requestWinner use this modifier instead of checking isActive inline:
- function requestWinner(uint256 prizeId) external onlyRole(ADMIN_ROLE) {
+ function requestWinner(uint256 prizeId) external onlyRole(ADMIN_ROLE) prizeIsActive(prizeId) {
if (winnersDrawn[prizeId] >= prizes[prizeId].quantity) {
revert AllWinnersDrawn();
}
if (prizeRanges[prizeId].length == 0) {
revert EmptyTicketPool();
}
- require(prizes[prizeId].isActive, "Prize not available");
if (isWinnerRequestPending[prizeId]) {
revert WinnerRequestPending(prizeId);
}
isWinnerRequestPending[prizeId] = true;
string memory callbackSig = "handleWinnerSelection(uint256,uint256[])";
uint256 requestId = supraRouter.generateRequest(
callbackSig, 1, 1, uint256(keccak256(abi.encodePacked(prizeId, block.timestamp))), msg.sender);
pendingVRFRequests[requestId] = prizeId;
emit WinnerRequested(prizeId, requestId);
}This change ensures:
Admins cannot edit/remove/request winners for expired prizes.
Users cannot spend tickets on expired prizes.
Any call using
prizeIsActivewill automatically enforce expiration.
Proof of Concept
Private gist with a runnable Foundry test and walkthrough: https://gist.github.com/blackgrease/40569a7feced0ab1acc8785aa1fcaf8d
Run with:
forge test --mt testPrizeEndtimestampIsIgnored --via-ir -vvvThere is also a screenshot of the Foundry test stack trace included in the original report.
Link to Proof of Concept
https://gist.github.com/blackgrease/40569a7feced0ab1acc8785aa1fcaf8d
Was this helpful?