Missing VRF Failure Handling Leads to Permanent Round Lock and Fund Freezing
Once vrfRequested is set to true, there is no way to recover if Chainlink VRF never delivers, leaving the round permanently stuck and funds locked.
Description
When a lottery round reaches maxTickets, the contract requests randomness from Chainlink VRF to select a winner. However, the implementation lacks any timeout mechanism or manual resolution path if the VRF callback never arrives.
function _requestRandomWinner(uint256 productId, uint256 roundId) private {Round storage round = rounds[productId][roundId];require(!round.vrfRequested, "VRF already requested");uint256 requestId = vrfCoordinator.requestRandomWords(...);vrfRequestToProduct[requestId] = productId;vrfRequestToRound[requestId] = roundId;round.vrfRequested = true;round.vrfRequestId = requestId;emit VRFRequested(requestId, productId, roundId);}
Once round.vrfRequested is set to true, there is no way to:
- Re-request randomness if VRF fails
- Manually complete the round
- Refund users if the round cannot complete
- Start a new round for that product
Vulnerable Scenario:
- A PREMIUM product round reaches 1000 tickets sold (maxTickets), triggering VRF request.
- The Chainlink VRF coordinator experiences downtime, or the subscription runs out of LINK tokens.
- The VRF callback never executes, leaving the round permanently stuck with
round.vrfRequested = true and round.completed = false. - Line 327 prevents any re-request:
require(!round.vrfRequested, "VRF already requested"). - All funds from that round (1000 tickets × 20 USDC = 20,000 USDC) are locked in the contract.
- Users cannot participate in new rounds for that product since
currentRound[productId]never increments. - No admin function exists to resolve this stuck state.
This is not a theoretical concern. Chainlink VRF has experienced outages, and subscription management issues are common operational risks.
Impact
VRF callback failures result in permanent freezing of round funds and complete inability to continue lottery operations for the affected product. Users lose their ticket purchases with no recovery mechanism, and the product becomes permanently inoperable.
Recommendation
Implement a timeout-based permissionless fallback mechanism to handle VRF failures without relying on owner privileges:
- Track when VRF requests are made:
mapping(uint256 => mapping(uint256 => uint256)) public roundVRFRequestTime;function _requestRandomWinner(uint256 productId, uint256 roundId) private {// ... existing code ...roundVRFRequestTime[productId][roundId] = block.timestamp;}
- Add a permissionless emergency resolution function:
function resolveStuckRound(uint256 productId, uint256 roundId) external {Round storage round = rounds[productId][roundId];require(round.vrfRequested && !round.completed, "Round not stuck");require(block.timestamp > roundVRFRequestTime[productId][roundId] + 7 days,"VRF timeout not reached");// Use block-based fallback randomnessuint256 fallbackRandom = uint256(keccak256(abi.encodePacked(block.timestamp,block.prevrandao,block.number,productId,roundId)));uint256 winningTicket = fallbackRandom % round.ticketsSold;address winner = ticketOwner[productId][roundId][winningTicket];round.vrfRandomWord = fallbackRandom;round.winner = winner;round.completed = true;_distributeRound(productId, roundId, winner, fallbackRandom);emit RoundCompleted(productId, roundId, winner, products[productId].jackpotUSD, winningTicket);_startNewRound(productId);}
Resolution
Nexalo: Fixed.
Zealynx: Verified. Fixed.

