Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 160 additions & 23 deletions plume/src/spin/Raffle.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import "../interfaces/ISupraRouterContract.sol";

interface ISpin {

function spendRaffleTickets(address _user, uint256 _amount) external;
function spendRaffleTickets(
address _user,
uint256 _amount
) external;
function getUserData(
address _user
) external view returns (uint256, uint256, uint256, uint256, uint256, uint256, uint256);
Expand Down Expand Up @@ -74,11 +77,14 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
mapping(uint256 => uint256) public winnersDrawn;
mapping(uint256 => mapping(address => uint256)) public userWinCount;

// Structure: prizeId => winnerIndex => isInvalid
mapping(uint256 => mapping(uint256 => bool)) public invalidWinners;

// Migration tracking
bool private _migrationComplete;

// Reserved storage gap for future upgrades
uint256[50] private __gap;
uint256[49] private __gap; // Reduced by 1 due to new mapping

// Events
event PrizeAdded(uint256 indexed prizeId, string name);
Expand All @@ -94,6 +100,11 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
);
event WinnerSet(uint256 indexed prizeId, address indexed winner); // @deprecated

event WinnerInvalidated(uint256 indexed prizeId, uint256 indexed winnerIndex, address indexed winner);
error AlreadyInvalid();
error InvalidWinnerIndex();
error WinnerInvalid(); // used in claimPrize for invalidated winners

// Errors
error EmptyTicketPool();
error WinnerDrawn(address winner); // @deprecated
Expand All @@ -110,7 +121,10 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
uint256 private nextPrizeId;

// Initialize function
function initialize(address _spinContract, address _supraRouter) public initializer {
function initialize(
address _spinContract,
address _supraRouter
) public initializer {
__AccessControl_init();
__UUPSUpgradeable_init();

Expand Down Expand Up @@ -201,7 +215,10 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
}

// User is spending raffle tickets to enter a prize
function spendRaffle(uint256 prizeId, uint256 ticketAmount) external prizeIsActive(prizeId) {
function spendRaffle(
uint256 prizeId,
uint256 ticketAmount
) external prizeIsActive(prizeId) {
require(ticketAmount > 0, "Must spend at least 1 ticket");

// Verify and deduct tickets from user balance
Expand Down Expand Up @@ -229,9 +246,10 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
function requestWinner(
uint256 prizeId
) external onlyRole(ADMIN_ROLE) {
if (winnersDrawn[prizeId] >= prizes[prizeId].quantity) {
if (getValidWinnersCount(prizeId) >= prizes[prizeId].quantity) {
revert AllWinnersDrawn();
}

if (prizeRanges[prizeId].length == 0) {
revert EmptyTicketPool();
}
Expand All @@ -251,8 +269,49 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
emit WinnerRequested(prizeId, requestId);
}

function invalidateWinner(
uint256 prizeId,
uint256 winnerIndex
) external onlyRole(ADMIN_ROLE) {
if (winnerIndex >= prizeWinners[prizeId].length) {
revert InvalidWinnerIndex();
}

Winner storage w = prizeWinners[prizeId][winnerIndex];

if (w.winnerAddress == address(0)) {
revert InvalidWinnerIndex();
}
if (w.claimed) {
revert WinnerClaimed();
}
if (invalidWinners[prizeId][winnerIndex]) {
revert AlreadyInvalid();
}

// Mark as invalid in separate mapping
invalidWinners[prizeId][winnerIndex] = true;

// Keep counts consistent
if (winnersDrawn[prizeId] > 0) {
winnersDrawn[prizeId] -= 1;
}
uint256 c = userWinCount[prizeId][w.winnerAddress];
if (c > 0) {
userWinCount[prizeId][w.winnerAddress] = c - 1;
}
Comment on lines +296 to +302
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The manual synchronization of winnersDrawn and userWinCount creates potential for inconsistency. Since getValidWinnersCount() now computes the actual count by iterating invalidWinners, consider whether winnersDrawn still serves a purpose or if it should be deprecated to reduce redundancy and maintenance burden.

Copilot uses AI. Check for mistakes.

// Reactivate prize so it's editable & redrawable
prizes[prizeId].isActive = true;

emit WinnerInvalidated(prizeId, winnerIndex, w.winnerAddress);
}

// Callback from VRF to set the winning ticket number and determine the winner
function handleWinnerSelection(uint256 requestId, uint256[] memory rng) external onlyRole(SUPRA_ROLE) {
function handleWinnerSelection(
uint256 requestId,
uint256[] memory rng
) external onlyRole(SUPRA_ROLE) {
uint256 prizeId = pendingVRFRequests[requestId];

isWinnerRequestPending[prizeId] = false;
Expand All @@ -261,7 +320,9 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
if (!prizes[prizeId].isActive) {
revert PrizeInactive();
}
if (winnersDrawn[prizeId] >= prizes[prizeId].quantity) {

// Check actual valid winners count
if (getValidWinnersCount(prizeId) >= prizes[prizeId].quantity) {
revert NoMoreWinners();
}

Expand All @@ -285,8 +346,9 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
winnerAddress = ranges[lo].user;
}

// Store winner details
prizeWinners[prizeId].push(
// Store winner details (no 'valid' flag needed)
prizeWinners[prizeId]
.push(
Winner({
winnerAddress: winnerAddress,
winningTicketIndex: winningTicketIndex,
Expand All @@ -298,14 +360,52 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
winnersDrawn[prizeId]++;
userWinCount[prizeId][winnerAddress]++;

// Deactivate prize if all winners have been drawn
if (winnersDrawn[prizeId] == prizes[prizeId].quantity) {
// Deactivate prize if all valid winners have been drawn
if (getValidWinnersCount(prizeId) >= prizes[prizeId].quantity) {
prizes[prizeId].isActive = false;
}

emit WinnerSelected(prizeId, winnerAddress, winningTicketIndex);
}

// Helper function to count valid (non-invalidated) winners
function getValidWinnersCount(
uint256 prizeId
) public view returns (uint256) {
Winner[] storage arr = prizeWinners[prizeId];
uint256 count = 0;
for (uint256 i = 0; i < arr.length; i++) {
if (!invalidWinners[prizeId][i]) {
count++;
}
}
Comment on lines +377 to +381
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getValidWinnersCount() iterates through all winners on every call. This function is called in requestWinner(), handleWinnerSelection(), setPrizeActive(), and view functions, creating O(n) overhead for each operation. For prizes with many winners, this could become expensive. Consider caching the valid count and updating it when winners are invalidated.

Copilot uses AI. Check for mistakes.
return count;
}

function getValidPrizeWinners(
uint256 prizeId
) external view returns (Winner[] memory) {
Winner[] storage allWins = prizeWinners[prizeId];
uint256 n = 0;

// Count valid winners
for (uint256 i = 0; i < allWins.length; i++) {
if (!invalidWinners[prizeId][i]) {
n++;
}
}

// Build array of valid winners
Winner[] memory out = new Winner[](n);
uint256 j = 0;
for (uint256 i = 0; i < allWins.length; i++) {
if (!invalidWinners[prizeId][i]) {
out[j++] = allWins[i];
}
}
return out;
}

// Admin function called immediately after VRF callback to set the winner in contract storage
// Executes a binary search to find the winner but only called once
function setWinner(
Expand All @@ -315,25 +415,41 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
}

// read function to get the winner of a prize by direct read
function getWinner(uint256 prizeId, uint256 index) public view returns (address) {
function getWinner(
uint256 prizeId,
uint256 index
) public view returns (address) {
return prizeWinners[prizeId][index].winnerAddress;
}

// User claims their prize, we mark it as claimed and deactivate the prize
function claimPrize(uint256 prizeId, uint256 winnerIndex) external {
if (prizes[prizeId].isActive && winnersDrawn[prizeId] < prizes[prizeId].quantity) {
function claimPrize(
uint256 prizeId,
uint256 winnerIndex
) external {
// Check if this specific winner exists
if (winnerIndex >= prizeWinners[prizeId].length) {
revert WinnerNotDrawn();
}

Winner storage individualWin = prizeWinners[prizeId][winnerIndex];

// Check if winner has been drawn (address should not be zero)
if (individualWin.winnerAddress == address(0)) {
revert WinnerNotDrawn();
}

if (individualWin.claimed) {
revert WinnerClaimed();
}
if (msg.sender != individualWin.winnerAddress) {
revert NotAWinner();
}

// FIXED: Check if winner has been invalidated
if (invalidWinners[prizeId][winnerIndex]) {
revert WinnerInvalid();
}

individualWin.claimed = true;
winnings[msg.sender].push(prizeId);

Expand Down Expand Up @@ -416,7 +532,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
totalUniqueUsers[prizeId],
p.claimed,
p.quantity,
winnersDrawn[prizeId],
getValidWinnersCount(prizeId),
p.formId
);
}
Expand All @@ -436,7 +552,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
endTimestamp: currentPrize.endTimestamp,
isActive: currentPrize.isActive,
quantity: currentPrize.quantity,
winnersDrawn: winnersDrawn[currentPrizeId],
winnersDrawn: getValidWinnersCount(currentPrizeId),
totalTickets: totalTickets[currentPrizeId],
totalUsers: totalUniqueUsers[currentPrizeId],
formId: currentPrize.formId
Expand All @@ -452,6 +568,13 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
return prizeWinners[prizeId];
}

function isWinnerValid(
uint256 prizeId,
uint256 winnerIndex
) external view returns (bool) {
return !invalidWinners[prizeId][winnerIndex];
}

/**
* @notice Returns the indices in prizeWinners[prizeId] for a given user's wins.
* @dev These indices are the values to pass as winnerIndex to claimPrize.
Expand All @@ -468,7 +591,8 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
uint256[] memory indices = new uint256[](wins.length);
uint256 j = 0;
for (uint256 i = 0; i < wins.length; i++) {
if (wins[i].winnerAddress == user && (!onlyUnclaimed || !wins[i].claimed)) {
// Also check that winner is not invalidated
if (wins[i].winnerAddress == user && !invalidWinners[prizeId][i] && (!onlyUnclaimed || !wins[i].claimed)) {
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] This complex conditional with multiple && operators is difficult to parse. Consider extracting the invalidation check or other conditions into named boolean variables to improve readability, e.g., bool isValidWinner = !invalidWinners[prizeId][i];

Suggested change
if (wins[i].winnerAddress == user && !invalidWinners[prizeId][i] && (!onlyUnclaimed || !wins[i].claimed)) {
bool isWinner = wins[i].winnerAddress == user;
bool isValidWinner = !invalidWinners[prizeId][i];
bool isUnclaimedOrAll = !onlyUnclaimed || !wins[i].claimed;
if (isWinner && isValidWinner && isUnclaimedOrAll) {

Copilot uses AI. Check for mistakes.
indices[j++] = i;
}
}
Expand All @@ -484,12 +608,19 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
* @param prizeId The prize identifier.
* @param onlyUnclaimed If true, returns only indices of unclaimed wins.
*/
function getMyWinnerIndices(uint256 prizeId, bool onlyUnclaimed) external view returns (uint256[] memory) {
function getMyWinnerIndices(
uint256 prizeId,
bool onlyUnclaimed
) external view returns (uint256[] memory) {
Winner[] storage wins = prizeWinners[prizeId];
uint256[] memory indices = new uint256[](wins.length);
uint256 j = 0;
for (uint256 i = 0; i < wins.length; i++) {
if (wins[i].winnerAddress == msg.sender && (!onlyUnclaimed || !wins[i].claimed)) {
// Also check that winner is not invalidated
if (
wins[i].winnerAddress == msg.sender && !invalidWinners[prizeId][i]
&& (!onlyUnclaimed || !wins[i].claimed)
) {
indices[j++] = i;
}
}
Expand All @@ -506,7 +637,10 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
}

// Timestamp update for prizes
function updatePrizeEndTimestamp(uint256 prizeId, uint256 endTimestamp) external onlyRole(ADMIN_ROLE) {
function updatePrizeEndTimestamp(
uint256 prizeId,
uint256 endTimestamp
) external onlyRole(ADMIN_ROLE) {
prizes[prizeId].endTimestamp = endTimestamp;
}

Expand All @@ -516,11 +650,14 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable {
* @param prizeId The ID of the prize to modify
* @param active The new active status to set
*/
function setPrizeActive(uint256 prizeId, bool active) external onlyRole(ADMIN_ROLE) {
function setPrizeActive(
uint256 prizeId,
bool active
) external onlyRole(ADMIN_ROLE) {
Prize storage prize = prizes[prizeId];
require(bytes(prize.name).length != 0, "Prize does not exist");
if (active) {
require(winnersDrawn[prizeId] < prize.quantity, "All winners already selected");
require(getValidWinnersCount(prizeId) < prize.quantity, "All winners already selected");
}
prizes[prizeId].isActive = active;
}
Expand Down
Loading