From cf69066517bfca359248f45bfea973e0ab3b14b9 Mon Sep 17 00:00:00 2001 From: ungaro Date: Wed, 22 Oct 2025 10:15:25 -0400 Subject: [PATCH 1/5] raffle update invalidate winners --- plume/src/spin/Raffle.sol | 79 ++++++++++++++++++++++++++ plume/test/Raffle.t.sol | 113 +++++++++++++++++++------------------- 2 files changed, 135 insertions(+), 57 deletions(-) diff --git a/plume/src/spin/Raffle.sol b/plume/src/spin/Raffle.sol index e4dc2877..4fd6285b 100644 --- a/plume/src/spin/Raffle.sol +++ b/plume/src/spin/Raffle.sol @@ -94,6 +94,12 @@ 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 @@ -251,6 +257,36 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { emit WinnerRequested(prizeId, requestId); } + + function invalidateWinner(uint256 prizeId, uint256 winnerIndex) external onlyRole(ADMIN_ROLE) { + Winner storage w = prizeWinners[prizeId][winnerIndex]; + + if (winnerIndex >= prizeWinners[prizeId].length) revert InvalidWinnerIndex(); + if (w.winnerAddress == address(0)) revert InvalidWinnerIndex(); + if (w.claimed) revert WinnerClaimed(); + if (!w.valid) revert AlreadyInvalid(); + + // Mark invalid + w.valid = false; + + // 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; + } + + // Reactivate so prize is 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) { uint256 prizeId = pendingVRFRequests[requestId]; @@ -306,6 +342,45 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { emit WinnerSelected(prizeId, winnerAddress, winningTicketIndex); } + + function winnersCountForPrize(uint256 prizeId) public view returns (uint256) { + Winner[] storage arr = prizeWinners[prizeId]; + uint256 count; + for (uint256 i = 0; i < arr.length; i++) { + if (arr[i].valid) count++; + } + return count; + } + + function getValidPrizeWinners(uint256 prizeId) external view returns (Winner[] memory) { + Winner[] storage allWins = prizeWinners[prizeId]; + uint256 n; + for (uint256 i = 0; i < allWins.length; i++) if (allWins[i].valid) n++; + + Winner[] memory out = new Winner[](n); + uint256 j; + for (uint256 i = 0; i < allWins.length; i++) { + if (allWins[i].valid) { + out[j++] = allWins[i]; + } + } + return out; + } + + + + function backfillValidFlags(uint256 prizeId, uint256 from, uint256 to) external onlyRole(ADMIN_ROLE) { + Winner[] storage arr = prizeWinners[prizeId]; + uint256 lim = arr.length; + if (to > lim) to = lim; + for (uint256 i = from; i < to; i++) { + if (!arr[i].valid && arr[i].winnerAddress != address(0)) { + arr[i].valid = true; + } + } + } + + // 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( @@ -334,6 +409,10 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { revert NotAWinner(); } + if (!individualWin.claimed) { + revert WinnerInvalid(); + } + individualWin.claimed = true; winnings[msg.sender].push(prizeId); diff --git a/plume/test/Raffle.t.sol b/plume/test/Raffle.t.sol index eb65afb7..c4c7eb95 100644 --- a/plume/test/Raffle.t.sol +++ b/plume/test/Raffle.t.sol @@ -32,8 +32,8 @@ contract RaffleFlowTest is PlumeTestBase { // Happy path test function testAddAndGetPrizeDetails() public { vm.prank(ADMIN); - raffle.addPrize("Gold", "Shiny", 0, 1); - (string memory n, string memory d, uint256 t,,,,,, uint256 quantity, uint256 drawn) = raffle + raffle.addPrize("Gold", "Shiny", 0, 1, "0"); + (string memory n, string memory d, uint256 t,,,,,, uint256 quantity, uint256 drawn, string memory formId) = raffle .getPrizeDetails(1); assertEq(n, "Gold"); assertEq(d, "Shiny"); @@ -44,18 +44,18 @@ contract RaffleFlowTest is PlumeTestBase { function testSpendRaffleSuccess() public { vm.prank(ADMIN); - raffle.addPrize("A", "A", 0, 1); + raffle.addPrize("A", "A", 0, 1, "0"); spinStub.setBalance(USER, 10); vm.prank(USER); raffle.spendRaffle(1, 5); assertEq(spinStub.balances(USER), 5); - (, , uint256 pool,,,,,,, ) = raffle.getPrizeDetails(1); + (, , uint256 pool,,,,,,,, ) = raffle.getPrizeDetails(1); assertEq(pool, 5); } function testSpendRaffleInsufficient() public { vm.prank(ADMIN); - raffle.addPrize("A", "A", 0, 1); + raffle.addPrize("A", "A", 0, 1,"0"); spinStub.setBalance(USER, 1); vm.prank(USER); vm.expectRevert( @@ -66,7 +66,7 @@ contract RaffleFlowTest is PlumeTestBase { function testRequestWinnerEmitsEvents() public { vm.prank(ADMIN); - raffle.addPrize("A", "A", 0, 1); + raffle.addPrize("A", "A", 0, 1,"0"); spinStub.setBalance(USER, 3); vm.prank(USER); raffle.spendRaffle(1, 3); @@ -97,7 +97,7 @@ contract RaffleFlowTest is PlumeTestBase { function testClaimPrizeSuccess() public { vm.prank(ADMIN); - raffle.addPrize("A", "A", 0, 1); + raffle.addPrize("A", "A", 0, 1,"0"); // Add tickets spinStub.setBalance(USER, 1); @@ -105,7 +105,7 @@ contract RaffleFlowTest is PlumeTestBase { raffle.spendRaffle(1, 1); // Verify tickets were spent - (, , uint256 pool,,,,,,, ) = raffle.getPrizeDetails(1); + (, , uint256 pool,,,,,,,, ) = raffle.getPrizeDetails(1); assertEq(pool, 1, "Tickets should be added to the pool"); // Request winner @@ -145,7 +145,7 @@ contract RaffleFlowTest is PlumeTestBase { function testClaimPrizeNotWinner() public { vm.prank(ADMIN); - raffle.addPrize("A", "A", 0, 1); + raffle.addPrize("A", "A", 0, 1,"0"); // Ensure we spend tickets so total is > 0 spinStub.setBalance(USER, 2); // Increase to 2 tickets @@ -153,7 +153,7 @@ contract RaffleFlowTest is PlumeTestBase { raffle.spendRaffle(1, 2); // Spend 2 tickets // Verify tickets were spent - (, , uint256 pool,,,,,,, ) = raffle.getPrizeDetails(1); + (, , uint256 pool,,,,,,,, ) = raffle.getPrizeDetails(1); assertEq(pool, 2, "Tickets should be in the pool"); vm.recordLogs(); @@ -187,7 +187,7 @@ contract RaffleFlowTest is PlumeTestBase { function testRemovePrizeFlow() public { vm.prank(ADMIN); - raffle.addPrize("Test","Desc",1, 1); + raffle.addPrize("Test","Desc",1, 1,"0"); // Remove prize vm.prank(ADMIN); raffle.removePrize(1); @@ -204,7 +204,7 @@ contract RaffleFlowTest is PlumeTestBase { function testSpendRaffleZeroTicketsReverts() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 1); + raffle.addPrize("A","A",1, 1,"0"); vm.prank(USER); vm.expectRevert("Must spend at least 1 ticket"); raffle.spendRaffle(1,0); @@ -212,26 +212,26 @@ contract RaffleFlowTest is PlumeTestBase { function testSpendRaffleMultipleEntriesAndTotalUsers() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 1); + raffle.addPrize("A","A",1, 1,"0"); // First entry spinStub.setBalance(USER,5); vm.prank(USER); raffle.spendRaffle(1,2); - (,,,,,, uint256 users1,,,) = raffle.getPrizeDetails(1); + (,,,,,, uint256 users1,,,,) = raffle.getPrizeDetails(1); assertEq(users1, 1); // Second entry same user spinStub.setBalance(USER,5); vm.prank(USER); raffle.spendRaffle(1,3); - (,,,,,, uint256 users2,,,) = raffle.getPrizeDetails(1); + (,,,,,, uint256 users2,,,,) = raffle.getPrizeDetails(1); assertEq(users2, 1, "totalUsers should not increment for repeat entry"); } function testGetPrizeDetailsAllPrizes() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 2); + raffle.addPrize("A","A",1, 2,"0"); vm.prank(ADMIN); - raffle.addPrize("B","B",1, 2); + raffle.addPrize("B","B",1, 2,"0"); spinStub.setBalance(USER,15); @@ -262,7 +262,7 @@ contract RaffleFlowTest is PlumeTestBase { function testRequestWinnerEmptyPoolReverts() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 1); + raffle.addPrize("A","A",1, 1,"0"); vm.prank(ADMIN); vm.expectRevert(abi.encodeWithSelector(Raffle.EmptyTicketPool.selector)); raffle.requestWinner(1); @@ -270,7 +270,7 @@ contract RaffleFlowTest is PlumeTestBase { function testRequestWinnerAllWinnersDrawnReverts() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 1); + raffle.addPrize("A","A",1, 1,"0"); // add tickets spinStub.setBalance(USER,1); vm.prank(USER); @@ -305,7 +305,7 @@ contract RaffleFlowTest is PlumeTestBase { function testHandleWinnerSelectionSetsWinner() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 1); + raffle.addPrize("A","A",1, 1,"0"); spinStub.setBalance(USER,3); vm.prank(USER); raffle.spendRaffle(1,3); @@ -337,7 +337,7 @@ contract RaffleFlowTest is PlumeTestBase { function testGetUserWins() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 1); + raffle.addPrize("A","A",1, 1,"0"); spinStub.setBalance(USER,4); vm.prank(USER); raffle.spendRaffle(1,2); vm.prank(USER); raffle.spendRaffle(1,1); @@ -377,7 +377,7 @@ contract RaffleFlowTest is PlumeTestBase { function testGetWinner() public { // Draw winner with ticket #2 (USER) vm.prank(ADMIN); - raffle.addPrize("Test Prize", "A test prize", 100, 1); + raffle.addPrize("Test Prize", "A test prize", 100, 1,"0"); // Add tickets for USER spinStub.setBalance(USER, 5); @@ -404,7 +404,7 @@ contract RaffleFlowTest is PlumeTestBase { // Test with ticket #4 (USER2) vm.prank(ADMIN); - raffle.addPrize("Second Prize", "Another prize", 100, 1); + raffle.addPrize("Second Prize", "Another prize", 100, 1,"0"); spinStub.setBalance(USER, 2); vm.prank(USER); @@ -429,7 +429,7 @@ contract RaffleFlowTest is PlumeTestBase { function testEditPrize() public { // Add a prize first vm.prank(ADMIN); - raffle.addPrize("Original", "Original description", 100, 1); + raffle.addPrize("Original", "Original description", 100, 1,"0"); // Add some tickets to verify they remain after edit spinStub.setBalance(USER, 5); @@ -437,13 +437,13 @@ contract RaffleFlowTest is PlumeTestBase { raffle.spendRaffle(1, 5); // Record the number of tickets before editing - (, , uint256 poolBefore,,,,,,,) = raffle.getPrizeDetails(1); + (, , uint256 poolBefore,,,,,,,,) = raffle.getPrizeDetails(1); assertEq(poolBefore, 5, "Tickets should be in the pool"); // Edit the prize vm.recordLogs(); vm.prank(ADMIN); - raffle.editPrize(1, "Updated", "Updated description", 200, 2); + raffle.editPrize(1, "Updated", "Updated description", 200, 2, "0"); // Verify the edit event was emitted Vm.Log[] memory logs = vm.getRecordedLogs(); @@ -457,7 +457,7 @@ contract RaffleFlowTest is PlumeTestBase { assertTrue(foundEvent, "PrizeEdited event not found"); // Verify the prize details were updated but tickets remain - (string memory n, string memory d, uint256 poolAfter, bool active, , , , , uint256 quantity, ) = + (string memory n, string memory d, uint256 poolAfter, bool active, , , , , uint256 quantity, ,) = raffle.getPrizeDetails(1); assertEq(n, "Updated", "Name should be updated"); @@ -474,25 +474,25 @@ contract RaffleFlowTest is PlumeTestBase { function testEditPrizeGuards() public { // Add and remove a prize vm.prank(ADMIN); - raffle.addPrize("Original", "Original description", 100, 1); + raffle.addPrize("Original", "Original description", 100, 1, "0"); vm.prank(ADMIN); raffle.removePrize(1); // Try to edit inactive prize vm.prank(ADMIN); vm.expectRevert("Prize not available"); - raffle.editPrize(1, "Updated", "Updated description", 200, 1); + raffle.editPrize(1, "Updated", "Updated description", 200, 1, "0"); // Try to edit as non-admin vm.prank(USER); vm.expectRevert(); // Just expect any revert - raffle.editPrize(1, "Updated", "Updated description", 200, 1); + raffle.editPrize(1, "Updated", "Updated description", 200, 1, "0"); } function testClaimPrizeAlreadyClaimedReverts() public { // Setup - add prize and spend raffle vm.prank(ADMIN); - raffle.addPrize("Prize", "Test prize", 100, 1); + raffle.addPrize("Prize", "Test prize", 100, 1, "0"); spinStub.setBalance(USER2, 1); vm.prank(USER2); @@ -519,7 +519,7 @@ contract RaffleFlowTest is PlumeTestBase { function testSetPrizeActiveGuards() public { // Setup test prize vm.prank(ADMIN); - raffle.addPrize("Prize", "Test prize", 100, 1); + raffle.addPrize("Prize", "Test prize", 100, 1, "0"); // Add tickets spinStub.setBalance(USER, 3); @@ -535,7 +535,7 @@ contract RaffleFlowTest is PlumeTestBase { raffle.handleWinnerSelection(req, rng); // Prize is now inactive because all winners are drawn - (,,, bool active,,,,,,) = raffle.getPrizeDetails(1); + (,,, bool active,,,,,,,) = raffle.getPrizeDetails(1); assertFalse(active, "Prize should be inactive after all winners drawn"); // Try to set it back to active should revert because all winners are selected @@ -547,7 +547,7 @@ contract RaffleFlowTest is PlumeTestBase { function testWinnerFlowNoSetWinner() public { // Setup prize and tickets vm.prank(ADMIN); - raffle.addPrize("Test Prize", "A test prize", 100, 1); + raffle.addPrize("Test Prize", "A test prize", 100, 1, "0"); // Add tickets for USER spinStub.setBalance(USER, 5); @@ -581,28 +581,28 @@ contract RaffleFlowTest is PlumeTestBase { function testPrizeDeleteAddDoesNotOverwriteActivePrize() public { // create ids 1-3 - vm.prank(ADMIN); raffle.addPrize("A", "one", 0, 1); // id-1 - vm.prank(ADMIN); raffle.addPrize("B", "two", 0, 1); // id-2 - vm.prank(ADMIN); raffle.addPrize("C", "three", 0, 1); // id-3 + vm.prank(ADMIN); raffle.addPrize("A", "one", 0, 1, "0"); // id-1 + vm.prank(ADMIN); raffle.addPrize("B", "two", 0, 1, "0"); // id-2 + vm.prank(ADMIN); raffle.addPrize("C", "three", 0, 1, "0"); // id-3 // remove id-2 ─────────────────────────────────────────────────────── vm.prank(ADMIN); raffle.removePrize(2); // sanity: id-3 is still "C" - (string memory before,,,,,,,,, ) = raffle.getPrizeDetails(3); + (string memory before,,,,,,,,,, ) = raffle.getPrizeDetails(3); assertEq(before, "C"); // add another prize (should become id-4) - vm.prank(ADMIN); raffle.addPrize("D", "four", 0, 1); // BUG: re-uses id-3 + vm.prank(ADMIN); raffle.addPrize("D", "four", 0, 1, "0"); // BUG: re-uses id-3 // EXPECTATION: id-3 still "C" - (string memory aft,,,,,,,,, ) = raffle.getPrizeDetails(3); + (string memory aft,,,,,,,,,, ) = raffle.getPrizeDetails(3); assertEq(aft,"C","addPrize re-issued id-3 and overwrote live prize"); } function testMultipleWinners() public { vm.prank(ADMIN); - raffle.addPrize("Multi-Winner Prize", "Desc", 100, 2); + raffle.addPrize("Multi-Winner Prize", "Desc", 100, 2, "0"); spinStub.setBalance(USER, 5); vm.prank(USER); @@ -621,7 +621,7 @@ contract RaffleFlowTest is PlumeTestBase { // Check first winner assertEq(raffle.getWinner(1, 0), USER2); - (,,, bool isActive1,,,,,, uint256 drawn1) = raffle.getPrizeDetails(1); + (,,, bool isActive1,,,,,, uint256 drawn1,) = raffle.getPrizeDetails(1); assertTrue(isActive1, "Prize should still be active"); assertEq(drawn1, 1, "Should have 1 winner drawn"); @@ -634,7 +634,7 @@ contract RaffleFlowTest is PlumeTestBase { // Check second winner assertEq(raffle.getWinner(1, 1), USER); - (,,, bool isActive2,,,,,, uint256 drawn2) = raffle.getPrizeDetails(1); + (,,, bool isActive2,,,,,, uint256 drawn2,) = raffle.getPrizeDetails(1); assertFalse(isActive2, "Prize should be inactive after all winners drawn"); assertEq(drawn2, 2, "Should have 2 winners drawn"); @@ -646,7 +646,7 @@ contract RaffleFlowTest is PlumeTestBase { function test_MultiWinner_Claiming_Is_Isolated() public { vm.prank(ADMIN); - raffle.addPrize("Multi-Winner Prize", "Desc", 100, 2); + raffle.addPrize("Multi-Winner Prize", "Desc", 100, 2, "0"); spinStub.setBalance(USER, 5); vm.prank(USER); @@ -691,7 +691,7 @@ contract RaffleFlowTest is PlumeTestBase { function test_MultiWinner_Same_User_Wins_Twice() public { vm.prank(ADMIN); - raffle.addPrize("Prize", "Desc", 100, 2); + raffle.addPrize("Prize", "Desc", 100, 2, "0"); spinStub.setBalance(USER, 5); vm.prank(USER); @@ -737,7 +737,7 @@ contract RaffleFlowTest is PlumeTestBase { function test_MultiWinner_Edit_Prize_Increase_Quantity() public { vm.prank(ADMIN); - raffle.addPrize("Prize", "Desc", 100, 1); + raffle.addPrize("Prize", "Desc", 100, 1, "0"); spinStub.setBalance(USER, 2); vm.prank(USER); @@ -751,18 +751,18 @@ contract RaffleFlowTest is PlumeTestBase { raffle.handleWinnerSelection(req1, rng1); // Prize should now be inactive - (,,,bool isActive,,,,,,) = raffle.getPrizeDetails(1); + (,,,bool isActive,,,,,,,) = raffle.getPrizeDetails(1); assertFalse(isActive, "Prize should be inactive after 1/1 winners drawn"); // Attempt to edit prize after it's inactive, should revert vm.prank(ADMIN); vm.expectRevert("Prize not available"); - raffle.editPrize(1, "Prize", "Desc", 100, 2); + raffle.editPrize(1, "Prize", "Desc", 100, 2, "0"); } function test_MultiWinner_Edit_Prize_Decrease_Quantity() public { vm.prank(ADMIN); - raffle.addPrize("Prize", "Desc", 100, 3); + raffle.addPrize("Prize", "Desc", 100, 3, "0"); spinStub.setBalance(USER, 2); vm.prank(USER); @@ -776,12 +776,12 @@ contract RaffleFlowTest is PlumeTestBase { raffle.handleWinnerSelection(req1, rng1); // Prize should still be active - (,,,bool isActive,,,,,,) = raffle.getPrizeDetails(1); + (,,,bool isActive,,,,,,,) = raffle.getPrizeDetails(1); assertTrue(isActive, "Prize should still be active after 1/3 winners drawn"); // Edit prize to decrease quantity to 1 vm.prank(ADMIN); - raffle.editPrize(1, "Prize", "Desc", 100, 1); + raffle.editPrize(1, "Prize", "Desc", 100, 1, "0"); // Now requesting a winner should fail as we've already drawn 1. vm.prank(ADMIN); @@ -791,7 +791,7 @@ contract RaffleFlowTest is PlumeTestBase { function test_MultiWinner_Binary_Search_With_Large_Pool() public { vm.prank(ADMIN); - raffle.addPrize("Large Pool Prize", "Desc", 100, 1); + raffle.addPrize("Large Pool Prize", "Desc", 100, 1, "0"); uint256 totalTickets; address[] memory users = new address[](50); @@ -808,7 +808,7 @@ contract RaffleFlowTest is PlumeTestBase { raffle.spendRaffle(1, ticketsToSpend); } - (,,uint256 ticketsInPool,,,,,,,) = raffle.getPrizeDetails(1); + (,,uint256 ticketsInPool,,,,,,,,) = raffle.getPrizeDetails(1); assertEq(ticketsInPool, totalTickets, "Total tickets in pool is incorrect"); // Request a winner, RNG selects a ticket in the middle of the ranges @@ -858,7 +858,7 @@ contract RaffleFlowTest is PlumeTestBase { function test_Cancel_Request_Success() public { // Setup prize and tickets vm.prank(ADMIN); - raffle.addPrize("Test Prize", "Desc", 100, 1); + raffle.addPrize("Test Prize", "Desc", 100, 1,"0"); spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 5); @@ -886,7 +886,7 @@ vm.prank(ADMIN); function test_Cancel_Request_Fails_For_Non_Admin() public { // Setup prize and tickets vm.prank(ADMIN); - raffle.addPrize("Test Prize", "Desc", 100, 1); + raffle.addPrize("Test Prize", "Desc", 100, 1,"0"); spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 5); @@ -904,7 +904,7 @@ vm.prank(ADMIN); function test_Cancel_Request_Fails_When_Not_Pending() public { // Setup prize vm.prank(ADMIN); - raffle.addPrize("Test Prize", "Desc", 100, 1); + raffle.addPrize("Test Prize", "Desc", 100, 1,"0"); // Attempt to cancel should revert with our specific error message vm.prank(ADMIN); @@ -913,4 +913,3 @@ vm.prank(ADMIN); } } - From c5071849762cccb23eb81a3620dbb4910c8dd34a Mon Sep 17 00:00:00 2001 From: ungaro Date: Thu, 23 Oct 2025 11:52:47 -0400 Subject: [PATCH 2/5] update raffle and tests --- plume/src/spin/Raffle.sol | 182 ++++++---- plume/test/Raffle.t.sol | 707 ++++++++++++++++++++++++++++++-------- 2 files changed, 689 insertions(+), 200 deletions(-) diff --git a/plume/src/spin/Raffle.sol b/plume/src/spin/Raffle.sol index 4fd6285b..ea380bad 100644 --- a/plume/src/spin/Raffle.sol +++ b/plume/src/spin/Raffle.sol @@ -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); @@ -36,6 +39,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { uint256 cumulativeEnd; } + // REFACTORED: Removed 'valid' flag from Winner struct struct Winner { address winnerAddress; uint256 winningTicketIndex; @@ -74,11 +78,15 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { mapping(uint256 => uint256) public winnersDrawn; mapping(uint256 => mapping(address => uint256)) public userWinCount; + // REFACTORED: New mapping to track invalid winners + // 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[45] private __gap; // Reduced by 1 due to new mapping // Events event PrizeAdded(uint256 indexed prizeId, string name); @@ -99,7 +107,6 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { error InvalidWinnerIndex(); error WinnerInvalid(); // used in claimPrize for invalidated winners - // Errors error EmptyTicketPool(); error WinnerDrawn(address winner); // @deprecated @@ -116,7 +123,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(); @@ -207,7 +217,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 @@ -235,9 +248,11 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { function requestWinner( uint256 prizeId ) external onlyRole(ADMIN_ROLE) { - if (winnersDrawn[prizeId] >= prizes[prizeId].quantity) { + // REFACTORED: Check actual valid winners count + if (getValidWinnersCount(prizeId) >= prizes[prizeId].quantity) { revert AllWinnersDrawn(); } + if (prizeRanges[prizeId].length == 0) { revert EmptyTicketPool(); } @@ -257,17 +272,29 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { emit WinnerRequested(prizeId, requestId); } - - function invalidateWinner(uint256 prizeId, uint256 winnerIndex) external onlyRole(ADMIN_ROLE) { + // REFACTORED: Updated invalidateWinner to use mapping instead of struct flag + 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(); + } - if (winnerIndex >= prizeWinners[prizeId].length) revert InvalidWinnerIndex(); - if (w.winnerAddress == address(0)) revert InvalidWinnerIndex(); - if (w.claimed) revert WinnerClaimed(); - if (!w.valid) revert AlreadyInvalid(); - - // Mark invalid - w.valid = false; + // Mark as invalid in separate mapping + invalidWinners[prizeId][winnerIndex] = true; // Keep counts consistent if (winnersDrawn[prizeId] > 0) { @@ -278,17 +305,17 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { userWinCount[prizeId][w.winnerAddress] = c - 1; } - // Reactivate so prize is editable & redrawable + // 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; @@ -297,7 +324,9 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { if (!prizes[prizeId].isActive) { revert PrizeInactive(); } - if (winnersDrawn[prizeId] >= prizes[prizeId].quantity) { + + // REFACTORED: Check actual valid winners count + if (getValidWinnersCount(prizeId) >= prizes[prizeId].quantity) { revert NoMoreWinners(); } @@ -321,7 +350,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { winnerAddress = ranges[lo].user; } - // Store winner details + // Store winner details (no 'valid' flag needed) prizeWinners[prizeId].push( Winner({ winnerAddress: winnerAddress, @@ -334,53 +363,51 @@ 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); } - - function winnersCountForPrize(uint256 prizeId) public view returns (uint256) { + // REFACTORED: Helper function to count valid (non-invalidated) winners + function getValidWinnersCount(uint256 prizeId) public view returns (uint256) { Winner[] storage arr = prizeWinners[prizeId]; - uint256 count; + uint256 count = 0; for (uint256 i = 0; i < arr.length; i++) { - if (arr[i].valid) count++; + if (!invalidWinners[prizeId][i]) { + count++; + } } return count; } - function getValidPrizeWinners(uint256 prizeId) external view returns (Winner[] memory) { + // REFACTORED: Updated to check invalidWinners mapping + function getValidPrizeWinners( + uint256 prizeId + ) external view returns (Winner[] memory) { Winner[] storage allWins = prizeWinners[prizeId]; - uint256 n; - for (uint256 i = 0; i < allWins.length; i++) if (allWins[i].valid) n++; + 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; + uint256 j = 0; for (uint256 i = 0; i < allWins.length; i++) { - if (allWins[i].valid) { + if (!invalidWinners[prizeId][i]) { out[j++] = allWins[i]; } } return out; } - - - function backfillValidFlags(uint256 prizeId, uint256 from, uint256 to) external onlyRole(ADMIN_ROLE) { - Winner[] storage arr = prizeWinners[prizeId]; - uint256 lim = arr.length; - if (to > lim) to = lim; - for (uint256 i = from; i < to; i++) { - if (!arr[i].valid && arr[i].winnerAddress != address(0)) { - arr[i].valid = true; - } - } - } - - // 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( @@ -390,17 +417,29 @@ 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) { + // REFACTORED: Fixed the critical bug and updated to check invalidWinners mapping + 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(); @@ -408,8 +447,9 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { if (msg.sender != individualWin.winnerAddress) { revert NotAWinner(); } - - if (!individualWin.claimed) { + + // FIXED: Check if winner has been invalidated + if (invalidWinners[prizeId][winnerIndex]) { revert WinnerInvalid(); } @@ -495,7 +535,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { totalUniqueUsers[prizeId], p.claimed, p.quantity, - winnersDrawn[prizeId], + getValidWinnersCount(prizeId), // REFACTORED: Return valid winners count p.formId ); } @@ -515,7 +555,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { endTimestamp: currentPrize.endTimestamp, isActive: currentPrize.isActive, quantity: currentPrize.quantity, - winnersDrawn: winnersDrawn[currentPrizeId], + winnersDrawn: getValidWinnersCount(currentPrizeId), // REFACTORED: Return valid winners count totalTickets: totalTickets[currentPrizeId], totalUsers: totalUniqueUsers[currentPrizeId], formId: currentPrize.formId @@ -531,6 +571,11 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { return prizeWinners[prizeId]; } + // REFACTORED: Check if a specific winner is valid + 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. @@ -547,7 +592,10 @@ 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)) { + // REFACTORED: Also check that winner is not invalidated + if (wins[i].winnerAddress == user && + !invalidWinners[prizeId][i] && + (!onlyUnclaimed || !wins[i].claimed)) { indices[j++] = i; } } @@ -563,12 +611,18 @@ 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)) { + // REFACTORED: Also check that winner is not invalidated + if (wins[i].winnerAddress == msg.sender && + !invalidWinners[prizeId][i] && + (!onlyUnclaimed || !wins[i].claimed)) { indices[j++] = i; } } @@ -585,7 +639,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; } @@ -595,11 +652,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; } diff --git a/plume/test/Raffle.t.sol b/plume/test/Raffle.t.sol index c4c7eb95..78981172 100644 --- a/plume/test/Raffle.t.sol +++ b/plume/test/Raffle.t.sol @@ -2,13 +2,25 @@ pragma solidity ^0.8.25; import "../src/spin/Raffle.sol"; -import "forge-std/Test.sol"; -import {ADMIN, USER, USER2, SUPRA_ORACLE, DEPOSIT_CONTRACT, PlumeTestBase, SUPRA_OWNER, ARB_SYS_ADDRESS, ArbSysMock, SpinStub} from "./TestUtils.sol"; +import { + ADMIN, + ARB_SYS_ADDRESS, + ArbSysMock, + DEPOSIT_CONTRACT, + PlumeTestBase, + SUPRA_ORACLE, + SUPRA_OWNER, + SpinStub, + USER, + USER2 +} from "./TestUtils.sol"; +import "forge-std/Test.sol"; // -------------------------------------- // Raffle flow tests // -------------------------------------- contract RaffleFlowTest is PlumeTestBase { + Raffle public raffle; SpinStub public spinStub; @@ -20,7 +32,7 @@ contract RaffleFlowTest is PlumeTestBase { // Deploy spin stub and raffle contracts spinStub = new SpinStub(); raffle = new Raffle(); - + // Whitelist raffle contract with Supra Oracle whitelistContract(ADMIN, address(raffle)); @@ -33,8 +45,8 @@ contract RaffleFlowTest is PlumeTestBase { function testAddAndGetPrizeDetails() public { vm.prank(ADMIN); raffle.addPrize("Gold", "Shiny", 0, 1, "0"); - (string memory n, string memory d, uint256 t,,,,,, uint256 quantity, uint256 drawn, string memory formId) = raffle - .getPrizeDetails(1); + (string memory n, string memory d, uint256 t,,,,,, uint256 quantity, uint256 drawn, string memory formId) = + raffle.getPrizeDetails(1); assertEq(n, "Gold"); assertEq(d, "Shiny"); assertEq(t, 0); @@ -49,24 +61,22 @@ contract RaffleFlowTest is PlumeTestBase { vm.prank(USER); raffle.spendRaffle(1, 5); assertEq(spinStub.balances(USER), 5); - (, , uint256 pool,,,,,,,, ) = raffle.getPrizeDetails(1); + (,, uint256 pool,,,,,,,,) = raffle.getPrizeDetails(1); assertEq(pool, 5); } function testSpendRaffleInsufficient() public { vm.prank(ADMIN); - raffle.addPrize("A", "A", 0, 1,"0"); + raffle.addPrize("A", "A", 0, 1, "0"); spinStub.setBalance(USER, 1); vm.prank(USER); - vm.expectRevert( - abi.encodeWithSelector(Raffle.InsufficientTickets.selector) - ); + vm.expectRevert(abi.encodeWithSelector(Raffle.InsufficientTickets.selector)); raffle.spendRaffle(1, 2); } function testRequestWinnerEmitsEvents() public { vm.prank(ADMIN); - raffle.addPrize("A", "A", 0, 1,"0"); + raffle.addPrize("A", "A", 0, 1, "0"); spinStub.setBalance(USER, 3); vm.prank(USER); raffle.spendRaffle(1, 3); @@ -75,10 +85,10 @@ contract RaffleFlowTest is PlumeTestBase { vm.prank(ADMIN); raffle.requestWinner(1); Vm.Log[] memory L = vm.getRecordedLogs(); - + // Find the WinnerRequested event uint256 req = 0; - for (uint i = 0; i < L.length; i++) { + for (uint256 i = 0; i < L.length; i++) { if (L[i].topics[0] == keccak256("WinnerRequested(uint256,uint256)")) { req = uint256(L[i].topics[2]); break; @@ -97,7 +107,7 @@ contract RaffleFlowTest is PlumeTestBase { function testClaimPrizeSuccess() public { vm.prank(ADMIN); - raffle.addPrize("A", "A", 0, 1,"0"); + raffle.addPrize("A", "A", 0, 1, "0"); // Add tickets spinStub.setBalance(USER, 1); @@ -105,7 +115,7 @@ contract RaffleFlowTest is PlumeTestBase { raffle.spendRaffle(1, 1); // Verify tickets were spent - (, , uint256 pool,,,,,,,, ) = raffle.getPrizeDetails(1); + (,, uint256 pool,,,,,,,,) = raffle.getPrizeDetails(1); assertEq(pool, 1, "Tickets should be added to the pool"); // Request winner @@ -116,7 +126,7 @@ contract RaffleFlowTest is PlumeTestBase { // Find request ID uint256 req = 0; - for (uint i = 0; i < logs.length; i++) { + for (uint256 i = 0; i < logs.length; i++) { if (logs[i].topics[0] == keccak256("WinnerRequested(uint256,uint256)")) { req = uint256(logs[i].topics[2]); break; @@ -140,12 +150,11 @@ contract RaffleFlowTest is PlumeTestBase { winners = raffle.getPrizeWinners(1); assertTrue(winners[0].claimed, "Winner claimed after is false"); assertEq(winners[0].winnerAddress, USER, "Winner should be user after"); - } function testClaimPrizeNotWinner() public { vm.prank(ADMIN); - raffle.addPrize("A", "A", 0, 1,"0"); + raffle.addPrize("A", "A", 0, 1, "0"); // Ensure we spend tickets so total is > 0 spinStub.setBalance(USER, 2); // Increase to 2 tickets @@ -153,7 +162,7 @@ contract RaffleFlowTest is PlumeTestBase { raffle.spendRaffle(1, 2); // Spend 2 tickets // Verify tickets were spent - (, , uint256 pool,,,,,,,, ) = raffle.getPrizeDetails(1); + (,, uint256 pool,,,,,,,,) = raffle.getPrizeDetails(1); assertEq(pool, 2, "Tickets should be in the pool"); vm.recordLogs(); @@ -163,7 +172,7 @@ contract RaffleFlowTest is PlumeTestBase { // Find the WinnerRequested event to get the request ID uint256 req = 0; - for (uint i = 0; i < L1.length; i++) { + for (uint256 i = 0; i < L1.length; i++) { if (L1[i].topics[0] == keccak256("WinnerRequested(uint256,uint256)")) { req = uint256(L1[i].topics[2]); break; @@ -184,18 +193,18 @@ contract RaffleFlowTest is PlumeTestBase { vm.expectRevert(Raffle.NotAWinner.selector); raffle.claimPrize(1, 0); } - + function testRemovePrizeFlow() public { vm.prank(ADMIN); - raffle.addPrize("Test","Desc",1, 1,"0"); + raffle.addPrize("Test", "Desc", 1, 1, "0"); // Remove prize vm.prank(ADMIN); raffle.removePrize(1); // Now spendRaffle should revert PrizeInactive - spinStub.setBalance(USER,1); + spinStub.setBalance(USER, 1); vm.prank(USER); vm.expectRevert("Prize not available"); - raffle.spendRaffle(1,1); + raffle.spendRaffle(1, 1); // requestWinner should revert PrizeInactive vm.prank(ADMIN); vm.expectRevert(); @@ -204,54 +213,54 @@ contract RaffleFlowTest is PlumeTestBase { function testSpendRaffleZeroTicketsReverts() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 1,"0"); + raffle.addPrize("A", "A", 1, 1, "0"); vm.prank(USER); vm.expectRevert("Must spend at least 1 ticket"); - raffle.spendRaffle(1,0); + raffle.spendRaffle(1, 0); } function testSpendRaffleMultipleEntriesAndTotalUsers() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 1,"0"); + raffle.addPrize("A", "A", 1, 1, "0"); // First entry - spinStub.setBalance(USER,5); + spinStub.setBalance(USER, 5); vm.prank(USER); - raffle.spendRaffle(1,2); + raffle.spendRaffle(1, 2); (,,,,,, uint256 users1,,,,) = raffle.getPrizeDetails(1); assertEq(users1, 1); // Second entry same user - spinStub.setBalance(USER,5); + spinStub.setBalance(USER, 5); vm.prank(USER); - raffle.spendRaffle(1,3); + raffle.spendRaffle(1, 3); (,,,,,, uint256 users2,,,,) = raffle.getPrizeDetails(1); assertEq(users2, 1, "totalUsers should not increment for repeat entry"); } function testGetPrizeDetailsAllPrizes() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 2,"0"); + raffle.addPrize("A", "A", 1, 2, "0"); vm.prank(ADMIN); - raffle.addPrize("B","B",1, 2,"0"); + raffle.addPrize("B", "B", 1, 2, "0"); - spinStub.setBalance(USER,15); + spinStub.setBalance(USER, 15); vm.prank(USER); - raffle.spendRaffle(1,2); // 2 tickets into prize 1 + raffle.spendRaffle(1, 2); // 2 tickets into prize 1 vm.prank(USER); - raffle.spendRaffle(1,3); // 3 tickets into prize 1 (again) + raffle.spendRaffle(1, 3); // 3 tickets into prize 1 (again) vm.prank(USER); - raffle.spendRaffle(2,1); // 1 ticket into prize 2 + raffle.spendRaffle(2, 1); // 1 ticket into prize 2 // First entry - spinStub.setBalance(USER2,15); + spinStub.setBalance(USER2, 15); vm.prank(USER2); - raffle.spendRaffle(1,4); // 4 tickets into prize 1 + raffle.spendRaffle(1, 4); // 4 tickets into prize 1 vm.prank(USER2); - raffle.spendRaffle(2,6); // 6 tickets into prize 2 + raffle.spendRaffle(2, 6); // 6 tickets into prize 2 (Raffle.PrizeWithTickets[] memory prizes) = raffle.getPrizeDetails(); assertEq(prizes[0].totalUsers, 2, "totalUsers should not increment for repeat entry"); @@ -262,7 +271,7 @@ contract RaffleFlowTest is PlumeTestBase { function testRequestWinnerEmptyPoolReverts() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 1,"0"); + raffle.addPrize("A", "A", 1, 1, "0"); vm.prank(ADMIN); vm.expectRevert(abi.encodeWithSelector(Raffle.EmptyTicketPool.selector)); raffle.requestWinner(1); @@ -270,28 +279,28 @@ contract RaffleFlowTest is PlumeTestBase { function testRequestWinnerAllWinnersDrawnReverts() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 1,"0"); + raffle.addPrize("A", "A", 1, 1, "0"); // add tickets - spinStub.setBalance(USER,1); + spinStub.setBalance(USER, 1); vm.prank(USER); - raffle.spendRaffle(1,1); - + raffle.spendRaffle(1, 1); + // first draw vm.recordLogs(); vm.prank(ADMIN); raffle.requestWinner(1); Vm.Log[] memory logs = vm.getRecordedLogs(); - + // Find request ID in logs uint256 req = 0; - for (uint i = 0; i < logs.length; i++) { + for (uint256 i = 0; i < logs.length; i++) { if (logs[i].topics[0] == keccak256("WinnerRequested(uint256,uint256)")) { req = uint256(logs[i].topics[2]); break; } } require(req != 0, "Request ID not found in logs"); - + uint256[] memory rng = new uint256[](1); rng[0] = 0; vm.prank(SUPRA_ORACLE); @@ -305,18 +314,18 @@ contract RaffleFlowTest is PlumeTestBase { function testHandleWinnerSelectionSetsWinner() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 1,"0"); - spinStub.setBalance(USER,3); + raffle.addPrize("A", "A", 1, 1, "0"); + spinStub.setBalance(USER, 3); vm.prank(USER); - raffle.spendRaffle(1,3); - + raffle.spendRaffle(1, 3); + vm.recordLogs(); vm.prank(ADMIN); raffle.requestWinner(1); Vm.Log[] memory logs = vm.getRecordedLogs(); - + uint256 req = 0; - for (uint i = 0; i < logs.length; i++) { + for (uint256 i = 0; i < logs.length; i++) { if (logs[i].topics[0] == keccak256("WinnerRequested(uint256,uint256)")) { req = uint256(logs[i].topics[2]); break; @@ -337,36 +346,38 @@ contract RaffleFlowTest is PlumeTestBase { function testGetUserWins() public { vm.prank(ADMIN); - raffle.addPrize("A","A",1, 1,"0"); - spinStub.setBalance(USER,4); - vm.prank(USER); raffle.spendRaffle(1,2); - vm.prank(USER); raffle.spendRaffle(1,1); - + raffle.addPrize("A", "A", 1, 1, "0"); + spinStub.setBalance(USER, 4); + vm.prank(USER); + raffle.spendRaffle(1, 2); + vm.prank(USER); + raffle.spendRaffle(1, 1); + // draw and claim so wins get populated vm.recordLogs(); - vm.prank(ADMIN); + vm.prank(ADMIN); raffle.requestWinner(1); Vm.Log[] memory logs = vm.getRecordedLogs(); - + // Find request ID in logs uint256 req = 0; - for (uint i = 0; i < logs.length; i++) { + for (uint256 i = 0; i < logs.length; i++) { if (logs[i].topics[0] == keccak256("WinnerRequested(uint256,uint256)")) { req = uint256(logs[i].topics[2]); break; } } require(req != 0, "Request ID not found in logs"); - - uint256[] memory rng = new uint256[](1); + + uint256[] memory rng = new uint256[](1); rng[0] = 1; - vm.prank(SUPRA_ORACLE); + vm.prank(SUPRA_ORACLE); raffle.handleWinnerSelection(req, rng); // Claim the prize - vm.prank(USER); + vm.prank(USER); raffle.claimPrize(1, 0); - + uint256[] memory userWins = raffle.getUserWinnings(USER); assertEq(userWins.length, 1); assertEq(userWins[0], 1); @@ -377,47 +388,47 @@ contract RaffleFlowTest is PlumeTestBase { function testGetWinner() public { // Draw winner with ticket #2 (USER) vm.prank(ADMIN); - raffle.addPrize("Test Prize", "A test prize", 100, 1,"0"); - + raffle.addPrize("Test Prize", "A test prize", 100, 1, "0"); + // Add tickets for USER spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 3); // USER: tickets 1-3 - + // Add tickets for USER2 spinStub.setBalance(USER2, 5); vm.prank(USER2); raffle.spendRaffle(1, 2); // USER2: tickets 4-5 - + // request prize ID1 winner uint256 requestId = requestWinnerForPrize(1); - + uint256[] memory rng = new uint256[](1); rng[0] = 1; // Will result in ticket #2 - USER - + vm.prank(SUPRA_ORACLE); raffle.handleWinnerSelection(requestId, rng); // Verify winner is USER address winner = raffle.getWinner(1, 0); assertEq(winner, USER); - + // Test with ticket #4 (USER2) vm.prank(ADMIN); - raffle.addPrize("Second Prize", "Another prize", 100, 1,"0"); - + raffle.addPrize("Second Prize", "Another prize", 100, 1, "0"); + spinStub.setBalance(USER, 2); vm.prank(USER); raffle.spendRaffle(2, 2); // USER: tickets 1-2 - + spinStub.setBalance(USER2, 2); vm.prank(USER2); raffle.spendRaffle(2, 2); // USER2: tickets 3-4 - + requestId = requestWinnerForPrize(2); - + rng[0] = 3; // Will result in ticket #4 - USER2 - + vm.prank(SUPRA_ORACLE); raffle.handleWinnerSelection(requestId, rng); @@ -429,43 +440,43 @@ contract RaffleFlowTest is PlumeTestBase { function testEditPrize() public { // Add a prize first vm.prank(ADMIN); - raffle.addPrize("Original", "Original description", 100, 1,"0"); - + raffle.addPrize("Original", "Original description", 100, 1, "0"); + // Add some tickets to verify they remain after edit spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 5); - + // Record the number of tickets before editing - (, , uint256 poolBefore,,,,,,,,) = raffle.getPrizeDetails(1); + (,, uint256 poolBefore,,,,,,,,) = raffle.getPrizeDetails(1); assertEq(poolBefore, 5, "Tickets should be in the pool"); - + // Edit the prize vm.recordLogs(); vm.prank(ADMIN); raffle.editPrize(1, "Updated", "Updated description", 200, 2, "0"); - + // Verify the edit event was emitted Vm.Log[] memory logs = vm.getRecordedLogs(); bool foundEvent = false; - for (uint i = 0; i < logs.length; i++) { - if (logs[i].topics[0] == keccak256("PrizeEdited(uint256,string,string,uint256,uint256)")) { + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == keccak256("PrizeEdited(uint256,string,string,uint256,uint256,string)")) { foundEvent = true; break; } } assertTrue(foundEvent, "PrizeEdited event not found"); - + // Verify the prize details were updated but tickets remain - (string memory n, string memory d, uint256 poolAfter, bool active, , , , , uint256 quantity, ,) = + (string memory n, string memory d, uint256 poolAfter, bool active,,,,, uint256 quantity,,) = raffle.getPrizeDetails(1); - + assertEq(n, "Updated", "Name should be updated"); assertEq(d, "Updated description", "Description should be updated"); assertEq(poolAfter, 5, "Ticket pool should remain unchanged"); assertTrue(active, "Prize should remain active"); assertEq(quantity, 2, "Quantity should be updated"); - + // Verify we can still request a winner with the updated prize vm.prank(ADMIN); raffle.requestWinner(1); @@ -477,7 +488,7 @@ contract RaffleFlowTest is PlumeTestBase { raffle.addPrize("Original", "Original description", 100, 1, "0"); vm.prank(ADMIN); raffle.removePrize(1); - + // Try to edit inactive prize vm.prank(ADMIN); vm.expectRevert("Prize not available"); @@ -493,23 +504,23 @@ contract RaffleFlowTest is PlumeTestBase { // Setup - add prize and spend raffle vm.prank(ADMIN); raffle.addPrize("Prize", "Test prize", 100, 1, "0"); - + spinStub.setBalance(USER2, 1); vm.prank(USER2); raffle.spendRaffle(1, 1); - + uint256 req = requestWinnerForPrize(1); - + // Select winner uint256[] memory rng = new uint256[](1); rng[0] = 0; // Will select USER2 vm.prank(SUPRA_ORACLE); raffle.handleWinnerSelection(req, rng); - + // First claim should succeed vm.prank(USER2); raffle.claimPrize(1, 0); - + // Second claim should revert vm.prank(USER2); vm.expectRevert(abi.encodeWithSelector(Raffle.WinnerClaimed.selector)); @@ -520,24 +531,24 @@ contract RaffleFlowTest is PlumeTestBase { // Setup test prize vm.prank(ADMIN); raffle.addPrize("Prize", "Test prize", 100, 1, "0"); - + // Add tickets spinStub.setBalance(USER, 3); vm.prank(USER); raffle.spendRaffle(1, 3); - + uint256 req = requestWinnerForPrize(1); - + // Select winner uint256[] memory rng = new uint256[](1); rng[0] = 1; // Will select USER's ticket vm.prank(SUPRA_ORACLE); raffle.handleWinnerSelection(req, rng); - + // Prize is now inactive because all winners are drawn (,,, bool active,,,,,,,) = raffle.getPrizeDetails(1); assertFalse(active, "Prize should be inactive after all winners drawn"); - + // Try to set it back to active should revert because all winners are selected vm.prank(ADMIN); vm.expectRevert("All winners already selected"); @@ -548,26 +559,26 @@ contract RaffleFlowTest is PlumeTestBase { // Setup prize and tickets vm.prank(ADMIN); raffle.addPrize("Test Prize", "A test prize", 100, 1, "0"); - + // Add tickets for USER spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 3); // USER: tickets 1-3 - + // Add tickets for USER2 spinStub.setBalance(USER2, 5); vm.prank(USER2); raffle.spendRaffle(1, 2); // USER2: tickets 4-5 - + uint256 requestId = requestWinnerForPrize(1); - + // Set winning ticket to #4 (USER2) uint256[] memory rng = new uint256[](1); rng[0] = 3; // Will result in ticket #4 -> (3 % 5) + 1 = 4 - + vm.prank(SUPRA_ORACLE); raffle.handleWinnerSelection(requestId, rng); - + // Verify winner is USER2 address winner = raffle.getWinner(1, 0); assertEq(winner, USER2, "Winner should be USER2 after handleWinnerSelection"); @@ -580,24 +591,30 @@ contract RaffleFlowTest is PlumeTestBase { } function testPrizeDeleteAddDoesNotOverwriteActivePrize() public { - // create ids 1-3 - vm.prank(ADMIN); raffle.addPrize("A", "one", 0, 1, "0"); // id-1 - vm.prank(ADMIN); raffle.addPrize("B", "two", 0, 1, "0"); // id-2 - vm.prank(ADMIN); raffle.addPrize("C", "three", 0, 1, "0"); // id-3 - - // remove id-2 ─────────────────────────────────────────────────────── - vm.prank(ADMIN); raffle.removePrize(2); + // create ids 1-3 + vm.prank(ADMIN); // id-1 + raffle.addPrize("A", "one", 0, 1, "0"); + vm.prank(ADMIN); // id-2 + raffle.addPrize("B", "two", 0, 1, "0"); + vm.prank(ADMIN); // id-3 + raffle.addPrize("C", "three", 0, 1, "0"); + + // remove id-2 + // ─────────────────────────────────────────────────────── + vm.prank(ADMIN); + raffle.removePrize(2); // sanity: id-3 is still "C" - (string memory before,,,,,,,,,, ) = raffle.getPrizeDetails(3); + (string memory before,,,,,,,,,,) = raffle.getPrizeDetails(3); assertEq(before, "C"); // add another prize (should become id-4) - vm.prank(ADMIN); raffle.addPrize("D", "four", 0, 1, "0"); // BUG: re-uses id-3 + vm.prank(ADMIN); // BUG: re-uses id-3 + raffle.addPrize("D", "four", 0, 1, "0"); // EXPECTATION: id-3 still "C" - (string memory aft,,,,,,,,,, ) = raffle.getPrizeDetails(3); - assertEq(aft,"C","addPrize re-issued id-3 and overwrote live prize"); + (string memory aft,,,,,,,,,,) = raffle.getPrizeDetails(3); + assertEq(aft, "C", "addPrize re-issued id-3 and overwrote live prize"); } function testMultipleWinners() public { @@ -607,7 +624,7 @@ contract RaffleFlowTest is PlumeTestBase { spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 3); // User tickets 1-3 - + spinStub.setBalance(USER2, 5); vm.prank(USER2); raffle.spendRaffle(1, 2); // User2 tickets 4-5 @@ -651,7 +668,7 @@ contract RaffleFlowTest is PlumeTestBase { spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 3); // User tickets 1-3 - + spinStub.setBalance(USER2, 5); vm.prank(USER2); raffle.spendRaffle(1, 2); // User2 tickets 4-5 @@ -682,7 +699,7 @@ contract RaffleFlowTest is PlumeTestBase { // USER (winner index 1) claims their prize vm.prank(USER); raffle.claimPrize(1, 1); - + // Verify state again winners = raffle.getPrizeWinners(1); assertTrue(winners[0].claimed, "Winner 0 (USER2) should remain claimed"); @@ -751,7 +768,7 @@ contract RaffleFlowTest is PlumeTestBase { raffle.handleWinnerSelection(req1, rng1); // Prize should now be inactive - (,,,bool isActive,,,,,,,) = raffle.getPrizeDetails(1); + (,,, bool isActive,,,,,,,) = raffle.getPrizeDetails(1); assertFalse(isActive, "Prize should be inactive after 1/1 winners drawn"); // Attempt to edit prize after it's inactive, should revert @@ -776,13 +793,13 @@ contract RaffleFlowTest is PlumeTestBase { raffle.handleWinnerSelection(req1, rng1); // Prize should still be active - (,,,bool isActive,,,,,,,) = raffle.getPrizeDetails(1); + (,,, bool isActive,,,,,,,) = raffle.getPrizeDetails(1); assertTrue(isActive, "Prize should still be active after 1/3 winners drawn"); // Edit prize to decrease quantity to 1 vm.prank(ADMIN); raffle.editPrize(1, "Prize", "Desc", 100, 1, "0"); - + // Now requesting a winner should fail as we've already drawn 1. vm.prank(ADMIN); vm.expectRevert(abi.encodeWithSelector(Raffle.AllWinnersDrawn.selector)); @@ -792,13 +809,13 @@ contract RaffleFlowTest is PlumeTestBase { function test_MultiWinner_Binary_Search_With_Large_Pool() public { vm.prank(ADMIN); raffle.addPrize("Large Pool Prize", "Desc", 100, 1, "0"); - + uint256 totalTickets; address[] memory users = new address[](50); // 50 users each spend a different number of tickets - for (uint i = 0; i < 50; i++) { - address user = address(uint160(uint(keccak256(abi.encodePacked("user", i))))); + for (uint256 i = 0; i < 50; i++) { + address user = address(uint160(uint256(keccak256(abi.encodePacked("user", i))))); users[i] = user; uint256 ticketsToSpend = i + 1; totalTickets += ticketsToSpend; @@ -808,7 +825,7 @@ contract RaffleFlowTest is PlumeTestBase { raffle.spendRaffle(1, ticketsToSpend); } - (,,uint256 ticketsInPool,,,,,,,,) = raffle.getPrizeDetails(1); + (,, uint256 ticketsInPool,,,,,,,,) = raffle.getPrizeDetails(1); assertEq(ticketsInPool, totalTickets, "Total tickets in pool is incorrect"); // Request a winner, RNG selects a ticket in the middle of the ranges @@ -817,7 +834,7 @@ contract RaffleFlowTest is PlumeTestBase { // Manually calculate who the winner should be based on our ranges uint256 cumulative = 0; - for (uint i = 0; i < 50; i++) { + for (uint256 i = 0; i < 50; i++) { cumulative += (i + 1); if (winningTicket <= cumulative) { expectedWinner = users[i]; @@ -838,14 +855,16 @@ contract RaffleFlowTest is PlumeTestBase { } /// Helper to request a winner and return the request ID - function requestWinnerForPrize(uint256 prizeId) internal returns (uint256) { + function requestWinnerForPrize( + uint256 prizeId + ) internal returns (uint256) { vm.recordLogs(); vm.prank(ADMIN); raffle.requestWinner(prizeId); Vm.Log[] memory logs = vm.getRecordedLogs(); - + uint256 req = 0; - for (uint i = 0; i < logs.length; i++) { + for (uint256 i = 0; i < logs.length; i++) { if (logs[i].topics[0] == keccak256("WinnerRequested(uint256,uint256)")) { req = uint256(logs[i].topics[2]); break; @@ -858,7 +877,7 @@ contract RaffleFlowTest is PlumeTestBase { function test_Cancel_Request_Success() public { // Setup prize and tickets vm.prank(ADMIN); - raffle.addPrize("Test Prize", "Desc", 100, 1,"0"); + raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 5); @@ -872,8 +891,8 @@ contract RaffleFlowTest is PlumeTestBase { vm.expectRevert(abi.encodeWithSelector(Raffle.WinnerRequestPending.selector, 1)); vm.prank(ADMIN); raffle.requestWinner(1); - -vm.prank(ADMIN); + + vm.prank(ADMIN); // 2. Admin cancels the pending request raffle.cancelWinnerRequest(1); @@ -886,7 +905,7 @@ vm.prank(ADMIN); function test_Cancel_Request_Fails_For_Non_Admin() public { // Setup prize and tickets vm.prank(ADMIN); - raffle.addPrize("Test Prize", "Desc", 100, 1,"0"); + raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 5); @@ -904,12 +923,422 @@ vm.prank(ADMIN); function test_Cancel_Request_Fails_When_Not_Pending() public { // Setup prize vm.prank(ADMIN); - raffle.addPrize("Test Prize", "Desc", 100, 1,"0"); + raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); // Attempt to cancel should revert with our specific error message vm.prank(ADMIN); vm.expectRevert("No request pending for this prize"); raffle.cancelWinnerRequest(1); } + + // ======================================== + // InvalidateWinner Tests + // ======================================== + + function test_InvalidateWinner_Success() public { + // Setup prize with 1 winner slot + vm.prank(ADMIN); + raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); + + spinStub.setBalance(USER, 5); + vm.prank(USER); + raffle.spendRaffle(1, 5); + + // Draw winner + uint256 req = requestWinnerForPrize(1); + uint256[] memory rng = new uint256[](1); + rng[0] = 2; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req, rng); + + // Prize should be inactive after winner drawn + (,,, bool isActiveBefore,,,,,, uint256 drawnBefore,) = raffle.getPrizeDetails(1); + assertFalse(isActiveBefore, "Prize should be inactive after all winners drawn"); + assertEq(drawnBefore, 1, "Should have 1 winner drawn"); + + // Invalidate the winner + vm.recordLogs(); + vm.prank(ADMIN); + raffle.invalidateWinner(1, 0); + + // Check event was emitted + Vm.Log[] memory logs = vm.getRecordedLogs(); + bool foundEvent = false; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == keccak256("WinnerInvalidated(uint256,uint256,address)")) { + foundEvent = true; + break; + } + } + assertTrue(foundEvent, "WinnerInvalidated event should be emitted"); + + // Prize should be reactivated + (,,, bool isActiveAfter,,,,,, uint256 drawnAfter,) = raffle.getPrizeDetails(1); + assertTrue(isActiveAfter, "Prize should be reactivated after invalidation"); + assertEq(drawnAfter, 0, "Valid winners count should be 0 after invalidation"); + + // Check winner is marked as invalid + assertFalse(raffle.isWinnerValid(1, 0), "Winner should be marked as invalid"); + } + + function test_InvalidateWinner_InvalidIndex() public { + // Setup prize + vm.prank(ADMIN); + raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); + + spinStub.setBalance(USER, 5); + vm.prank(USER); + raffle.spendRaffle(1, 5); + + // Draw winner (creates winner at index 0) + uint256 req = requestWinnerForPrize(1); + uint256[] memory rng = new uint256[](1); + rng[0] = 2; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req, rng); + + // Try to invalidate non-existent winner at index 1 + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(Raffle.InvalidWinnerIndex.selector)); + raffle.invalidateWinner(1, 1); + + // Try to invalidate with very large index + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(Raffle.InvalidWinnerIndex.selector)); + raffle.invalidateWinner(1, 999); + } + + function test_InvalidateWinner_AlreadyClaimed() public { + // Setup prize + vm.prank(ADMIN); + raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); + + spinStub.setBalance(USER, 5); + vm.prank(USER); + raffle.spendRaffle(1, 5); + + // Draw winner + uint256 req = requestWinnerForPrize(1); + uint256[] memory rng = new uint256[](1); + rng[0] = 2; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req, rng); + + // Claim the prize + vm.prank(USER); + raffle.claimPrize(1, 0); + + // Try to invalidate claimed winner + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(Raffle.WinnerClaimed.selector)); + raffle.invalidateWinner(1, 0); + } + + function test_InvalidateWinner_AlreadyInvalid() public { + // Setup prize + vm.prank(ADMIN); + raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); + + spinStub.setBalance(USER, 5); + vm.prank(USER); + raffle.spendRaffle(1, 5); + + // Draw winner + uint256 req = requestWinnerForPrize(1); + uint256[] memory rng = new uint256[](1); + rng[0] = 2; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req, rng); + + // Invalidate the winner once + vm.prank(ADMIN); + raffle.invalidateWinner(1, 0); + + // Try to invalidate again + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(Raffle.AlreadyInvalid.selector)); + raffle.invalidateWinner(1, 0); + } + + function test_InvalidateWinner_OnlyAdmin() public { + // Setup prize + vm.prank(ADMIN); + raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); + + spinStub.setBalance(USER, 5); + vm.prank(USER); + raffle.spendRaffle(1, 5); + + // Draw winner + uint256 req = requestWinnerForPrize(1); + uint256[] memory rng = new uint256[](1); + rng[0] = 2; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req, rng); + + // Try to invalidate as non-admin + vm.prank(USER); + vm.expectRevert(); // AccessControl revert + raffle.invalidateWinner(1, 0); + } + + function test_InvalidateWinner_CannotClaimAfter() public { + // Setup prize + vm.prank(ADMIN); + raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); + + spinStub.setBalance(USER, 5); + vm.prank(USER); + raffle.spendRaffle(1, 5); + + // Draw winner + uint256 req = requestWinnerForPrize(1); + uint256[] memory rng = new uint256[](1); + rng[0] = 2; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req, rng); + + // Invalidate the winner + vm.prank(ADMIN); + raffle.invalidateWinner(1, 0); + + // Try to claim the invalidated prize + vm.prank(USER); + vm.expectRevert(abi.encodeWithSelector(Raffle.WinnerInvalid.selector)); + raffle.claimPrize(1, 0); + } + + function test_InvalidateWinner_CanDrawNewWinner() public { + // Setup prize with 1 winner slot + vm.prank(ADMIN); + raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); + + spinStub.setBalance(USER, 5); + vm.prank(USER); + raffle.spendRaffle(1, 3); + + spinStub.setBalance(USER2, 5); + vm.prank(USER2); + raffle.spendRaffle(1, 2); + + // Draw first winner (USER) + uint256 req1 = requestWinnerForPrize(1); + uint256[] memory rng1 = new uint256[](1); + rng1[0] = 1; // USER's ticket + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req1, rng1); + + // Verify USER won + assertEq(raffle.getWinner(1, 0), USER); + + // Prize should be inactive (all winners drawn) + (,,, bool isActive1,,,,,,,) = raffle.getPrizeDetails(1); + assertFalse(isActive1, "Prize should be inactive"); + + // Invalidate USER's win + vm.prank(ADMIN); + raffle.invalidateWinner(1, 0); + + // Prize should be reactivated + (,,, bool isActive2,,,,,,,) = raffle.getPrizeDetails(1); + assertTrue(isActive2, "Prize should be reactivated"); + + // Should be able to draw a new winner + uint256 req2 = requestWinnerForPrize(1); + uint256[] memory rng2 = new uint256[](1); + rng2[0] = 4; // USER2's ticket + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req2, rng2); + + // Verify USER2 won at index 1 + assertEq(raffle.getWinner(1, 1), USER2); + + // Check valid winners count + assertEq(raffle.getValidWinnersCount(1), 1, "Should have 1 valid winner"); + } + + function test_InvalidateWinner_MultipleWinners_InvalidateOne() public { + // Setup prize with 3 winner slots + vm.prank(ADMIN); + raffle.addPrize("Multi-Winner Prize", "Desc", 100, 3, "0"); + + spinStub.setBalance(USER, 10); + vm.prank(USER); + raffle.spendRaffle(1, 4); + + spinStub.setBalance(USER2, 10); + vm.prank(USER2); + raffle.spendRaffle(1, 4); + + // Draw three winners + uint256[] memory rng = new uint256[](1); + + // Winner 1: USER + uint256 req1 = requestWinnerForPrize(1); + rng[0] = 1; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req1, rng); + + // Winner 2: USER2 + uint256 req2 = requestWinnerForPrize(1); + rng[0] = 5; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req2, rng); + + // Winner 3: USER + uint256 req3 = requestWinnerForPrize(1); + rng[0] = 2; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req3, rng); + + // Verify all three winners + assertEq(raffle.getWinner(1, 0), USER); + assertEq(raffle.getWinner(1, 1), USER2); + assertEq(raffle.getWinner(1, 2), USER); + assertEq(raffle.getValidWinnersCount(1), 3, "Should have 3 valid winners"); + + // Invalidate the second winner (USER2 at index 1) + vm.prank(ADMIN); + raffle.invalidateWinner(1, 1); + + // Check valid winners count decreased + assertEq(raffle.getValidWinnersCount(1), 2, "Should have 2 valid winners after invalidation"); + + // Winner indices 0 and 2 should still be valid + assertTrue(raffle.isWinnerValid(1, 0), "Winner 0 should still be valid"); + assertFalse(raffle.isWinnerValid(1, 1), "Winner 1 should be invalid"); + assertTrue(raffle.isWinnerValid(1, 2), "Winner 2 should still be valid"); + + // USER should still be able to claim their valid wins (indices 0 and 2) + vm.prank(USER); + raffle.claimPrize(1, 0); + + vm.prank(USER); + raffle.claimPrize(1, 2); + + // USER2 should not be able to claim invalidated win + vm.prank(USER2); + vm.expectRevert(abi.encodeWithSelector(Raffle.WinnerInvalid.selector)); + raffle.claimPrize(1, 1); + } + + function test_InvalidateWinner_GetValidPrizeWinners() public { + // Setup prize with 2 winner slots + vm.prank(ADMIN); + raffle.addPrize("Prize", "Desc", 100, 2, "0"); + + spinStub.setBalance(USER, 10); + vm.prank(USER); + raffle.spendRaffle(1, 5); + + spinStub.setBalance(USER2, 10); + vm.prank(USER2); + raffle.spendRaffle(1, 5); + + // Draw two winners + uint256[] memory rng = new uint256[](1); + + uint256 req1 = requestWinnerForPrize(1); + rng[0] = 1; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req1, rng); + + uint256 req2 = requestWinnerForPrize(1); + rng[0] = 7; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req2, rng); + + // Should have 2 winners total + Raffle.Winner[] memory allWinners = raffle.getPrizeWinners(1); + assertEq(allWinners.length, 2, "Should have 2 total winners"); + + // Invalidate first winner + vm.prank(ADMIN); + raffle.invalidateWinner(1, 0); + + // getValidPrizeWinners should only return the valid winner + Raffle.Winner[] memory validWinners = raffle.getValidPrizeWinners(1); + assertEq(validWinners.length, 1, "Should have 1 valid winner"); + assertEq(validWinners[0].winnerAddress, USER2, "Valid winner should be USER2"); + } + + function test_InvalidateWinner_GetWinnerIndices() public { + // Setup prize with 3 winner slots + vm.prank(ADMIN); + raffle.addPrize("Prize", "Desc", 100, 3, "0"); + + spinStub.setBalance(USER, 10); + vm.prank(USER); + raffle.spendRaffle(1, 10); + + // Draw three winners (all USER) + uint256[] memory rng = new uint256[](1); + + uint256 req1 = requestWinnerForPrize(1); + rng[0] = 1; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req1, rng); + + uint256 req2 = requestWinnerForPrize(1); + rng[0] = 2; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req2, rng); + + uint256 req3 = requestWinnerForPrize(1); + rng[0] = 3; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req3, rng); + + // USER should have 3 winner indices + uint256[] memory indicesBefore = raffle.getWinnerIndices(1, false, USER); + assertEq(indicesBefore.length, 3, "USER should have 3 wins"); + + // Invalidate middle winner + vm.prank(ADMIN); + raffle.invalidateWinner(1, 1); + + // USER should now have 2 valid winner indices + uint256[] memory indicesAfter = raffle.getWinnerIndices(1, false, USER); + assertEq(indicesAfter.length, 2, "USER should have 2 valid wins"); + assertEq(indicesAfter[0], 0, "First valid index should be 0"); + assertEq(indicesAfter[1], 2, "Second valid index should be 2"); + } + + function test_InvalidateWinner_ReactivatesPrize() public { + // Setup prize with quantity 1 + vm.prank(ADMIN); + raffle.addPrize("Prize", "Desc", 100, 1, "0"); + + spinStub.setBalance(USER, 5); + vm.prank(USER); + raffle.spendRaffle(1, 5); + + // Draw winner - this should deactivate the prize + uint256 req = requestWinnerForPrize(1); + uint256[] memory rng = new uint256[](1); + rng[0] = 2; + vm.prank(SUPRA_ORACLE); + raffle.handleWinnerSelection(req, rng); + + // Verify prize is inactive + (,,, bool isActiveBefore,,,,,,,) = raffle.getPrizeDetails(1); + assertFalse(isActiveBefore, "Prize should be inactive"); + + // Invalidate winner + vm.prank(ADMIN); + raffle.invalidateWinner(1, 0); + + // Verify prize is reactivated + (,,, bool isActiveAfter,,,,,,,) = raffle.getPrizeDetails(1); + assertTrue(isActiveAfter, "Prize should be reactivated after invalidation"); + + // Should be able to edit the prize now + vm.prank(ADMIN); + raffle.editPrize(1, "Updated Prize", "Updated", 200, 1, "0"); + + (string memory name,,,,,,,,,,) = raffle.getPrizeDetails(1); + assertEq(name, "Updated Prize", "Prize should be editable after reactivation"); + } + } From 2e400a061244cede823cf0d05c8853590804819b Mon Sep 17 00:00:00 2001 From: ungaro Date: Thu, 23 Oct 2025 11:54:57 -0400 Subject: [PATCH 3/5] formatting --- plume/src/spin/Raffle.sol | 37 +++++++++++++++++++++---------------- plume/test/Raffle.t.sol | 28 ++++++++++++++-------------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/plume/src/spin/Raffle.sol b/plume/src/spin/Raffle.sol index ea380bad..12337cbe 100644 --- a/plume/src/spin/Raffle.sol +++ b/plume/src/spin/Raffle.sol @@ -81,7 +81,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { // REFACTORED: New mapping to track invalid winners // Structure: prizeId => winnerIndex => isInvalid mapping(uint256 => mapping(uint256 => bool)) public invalidWinners; - + // Migration tracking bool private _migrationComplete; @@ -280,9 +280,9 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { if (winnerIndex >= prizeWinners[prizeId].length) { revert InvalidWinnerIndex(); } - + Winner storage w = prizeWinners[prizeId][winnerIndex]; - + if (w.winnerAddress == address(0)) { revert InvalidWinnerIndex(); } @@ -324,7 +324,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { if (!prizes[prizeId].isActive) { revert PrizeInactive(); } - + // REFACTORED: Check actual valid winners count if (getValidWinnersCount(prizeId) >= prizes[prizeId].quantity) { revert NoMoreWinners(); @@ -351,7 +351,8 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { } // Store winner details (no 'valid' flag needed) - prizeWinners[prizeId].push( + prizeWinners[prizeId] + .push( Winner({ winnerAddress: winnerAddress, winningTicketIndex: winningTicketIndex, @@ -372,7 +373,9 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { } // REFACTORED: Helper function to count valid (non-invalidated) winners - function getValidWinnersCount(uint256 prizeId) public view returns (uint256) { + function getValidWinnersCount( + uint256 prizeId + ) public view returns (uint256) { Winner[] storage arr = prizeWinners[prizeId]; uint256 count = 0; for (uint256 i = 0; i < arr.length; i++) { @@ -389,7 +392,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { ) 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]) { @@ -435,7 +438,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { } Winner storage individualWin = prizeWinners[prizeId][winnerIndex]; - + // Check if winner has been drawn (address should not be zero) if (individualWin.winnerAddress == address(0)) { revert WinnerNotDrawn(); @@ -447,7 +450,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { if (msg.sender != individualWin.winnerAddress) { revert NotAWinner(); } - + // FIXED: Check if winner has been invalidated if (invalidWinners[prizeId][winnerIndex]) { revert WinnerInvalid(); @@ -572,7 +575,10 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { } // REFACTORED: Check if a specific winner is valid - function isWinnerValid(uint256 prizeId, uint256 winnerIndex) external view returns (bool) { + function isWinnerValid( + uint256 prizeId, + uint256 winnerIndex + ) external view returns (bool) { return !invalidWinners[prizeId][winnerIndex]; } @@ -593,9 +599,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { uint256 j = 0; for (uint256 i = 0; i < wins.length; i++) { // REFACTORED: Also check that winner is not invalidated - if (wins[i].winnerAddress == user && - !invalidWinners[prizeId][i] && - (!onlyUnclaimed || !wins[i].claimed)) { + if (wins[i].winnerAddress == user && !invalidWinners[prizeId][i] && (!onlyUnclaimed || !wins[i].claimed)) { indices[j++] = i; } } @@ -620,9 +624,10 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { uint256 j = 0; for (uint256 i = 0; i < wins.length; i++) { // REFACTORED: Also check that winner is not invalidated - if (wins[i].winnerAddress == msg.sender && - !invalidWinners[prizeId][i] && - (!onlyUnclaimed || !wins[i].claimed)) { + if ( + wins[i].winnerAddress == msg.sender && !invalidWinners[prizeId][i] + && (!onlyUnclaimed || !wins[i].claimed) + ) { indices[j++] = i; } } diff --git a/plume/test/Raffle.t.sol b/plume/test/Raffle.t.sol index 78981172..c52e90f7 100644 --- a/plume/test/Raffle.t.sol +++ b/plume/test/Raffle.t.sol @@ -939,7 +939,7 @@ contract RaffleFlowTest is PlumeTestBase { // Setup prize with 1 winner slot vm.prank(ADMIN); raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); - + spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 5); @@ -985,7 +985,7 @@ contract RaffleFlowTest is PlumeTestBase { // Setup prize vm.prank(ADMIN); raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); - + spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 5); @@ -1012,7 +1012,7 @@ contract RaffleFlowTest is PlumeTestBase { // Setup prize vm.prank(ADMIN); raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); - + spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 5); @@ -1038,7 +1038,7 @@ contract RaffleFlowTest is PlumeTestBase { // Setup prize vm.prank(ADMIN); raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); - + spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 5); @@ -1064,7 +1064,7 @@ contract RaffleFlowTest is PlumeTestBase { // Setup prize vm.prank(ADMIN); raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); - + spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 5); @@ -1086,7 +1086,7 @@ contract RaffleFlowTest is PlumeTestBase { // Setup prize vm.prank(ADMIN); raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); - + spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 5); @@ -1112,7 +1112,7 @@ contract RaffleFlowTest is PlumeTestBase { // Setup prize with 1 winner slot vm.prank(ADMIN); raffle.addPrize("Test Prize", "Desc", 100, 1, "0"); - + spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 3); @@ -1161,7 +1161,7 @@ contract RaffleFlowTest is PlumeTestBase { // Setup prize with 3 winner slots vm.prank(ADMIN); raffle.addPrize("Multi-Winner Prize", "Desc", 100, 3, "0"); - + spinStub.setBalance(USER, 10); vm.prank(USER); raffle.spendRaffle(1, 4); @@ -1172,7 +1172,7 @@ contract RaffleFlowTest is PlumeTestBase { // Draw three winners uint256[] memory rng = new uint256[](1); - + // Winner 1: USER uint256 req1 = requestWinnerForPrize(1); rng[0] = 1; @@ -1226,7 +1226,7 @@ contract RaffleFlowTest is PlumeTestBase { // Setup prize with 2 winner slots vm.prank(ADMIN); raffle.addPrize("Prize", "Desc", 100, 2, "0"); - + spinStub.setBalance(USER, 10); vm.prank(USER); raffle.spendRaffle(1, 5); @@ -1237,7 +1237,7 @@ contract RaffleFlowTest is PlumeTestBase { // Draw two winners uint256[] memory rng = new uint256[](1); - + uint256 req1 = requestWinnerForPrize(1); rng[0] = 1; vm.prank(SUPRA_ORACLE); @@ -1266,14 +1266,14 @@ contract RaffleFlowTest is PlumeTestBase { // Setup prize with 3 winner slots vm.prank(ADMIN); raffle.addPrize("Prize", "Desc", 100, 3, "0"); - + spinStub.setBalance(USER, 10); vm.prank(USER); raffle.spendRaffle(1, 10); // Draw three winners (all USER) uint256[] memory rng = new uint256[](1); - + uint256 req1 = requestWinnerForPrize(1); rng[0] = 1; vm.prank(SUPRA_ORACLE); @@ -1308,7 +1308,7 @@ contract RaffleFlowTest is PlumeTestBase { // Setup prize with quantity 1 vm.prank(ADMIN); raffle.addPrize("Prize", "Desc", 100, 1, "0"); - + spinStub.setBalance(USER, 5); vm.prank(USER); raffle.spendRaffle(1, 5); From 6181b5d42c936f5de1103b4ceaea1506956cbce7 Mon Sep 17 00:00:00 2001 From: ungaro Date: Thu, 23 Oct 2025 11:59:57 -0400 Subject: [PATCH 4/5] update comments --- plume/src/spin/Raffle.sol | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/plume/src/spin/Raffle.sol b/plume/src/spin/Raffle.sol index 12337cbe..44508662 100644 --- a/plume/src/spin/Raffle.sol +++ b/plume/src/spin/Raffle.sol @@ -39,7 +39,6 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { uint256 cumulativeEnd; } - // REFACTORED: Removed 'valid' flag from Winner struct struct Winner { address winnerAddress; uint256 winningTicketIndex; @@ -78,7 +77,6 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { mapping(uint256 => uint256) public winnersDrawn; mapping(uint256 => mapping(address => uint256)) public userWinCount; - // REFACTORED: New mapping to track invalid winners // Structure: prizeId => winnerIndex => isInvalid mapping(uint256 => mapping(uint256 => bool)) public invalidWinners; @@ -248,7 +246,6 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { function requestWinner( uint256 prizeId ) external onlyRole(ADMIN_ROLE) { - // REFACTORED: Check actual valid winners count if (getValidWinnersCount(prizeId) >= prizes[prizeId].quantity) { revert AllWinnersDrawn(); } @@ -272,7 +269,6 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { emit WinnerRequested(prizeId, requestId); } - // REFACTORED: Updated invalidateWinner to use mapping instead of struct flag function invalidateWinner( uint256 prizeId, uint256 winnerIndex @@ -325,7 +321,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { revert PrizeInactive(); } - // REFACTORED: Check actual valid winners count + // Check actual valid winners count if (getValidWinnersCount(prizeId) >= prizes[prizeId].quantity) { revert NoMoreWinners(); } @@ -372,7 +368,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { emit WinnerSelected(prizeId, winnerAddress, winningTicketIndex); } - // REFACTORED: Helper function to count valid (non-invalidated) winners + // Helper function to count valid (non-invalidated) winners function getValidWinnersCount( uint256 prizeId ) public view returns (uint256) { @@ -386,7 +382,6 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { return count; } - // REFACTORED: Updated to check invalidWinners mapping function getValidPrizeWinners( uint256 prizeId ) external view returns (Winner[] memory) { @@ -427,7 +422,6 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { return prizeWinners[prizeId][index].winnerAddress; } - // REFACTORED: Fixed the critical bug and updated to check invalidWinners mapping function claimPrize( uint256 prizeId, uint256 winnerIndex @@ -538,7 +532,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { totalUniqueUsers[prizeId], p.claimed, p.quantity, - getValidWinnersCount(prizeId), // REFACTORED: Return valid winners count + getValidWinnersCount(prizeId), p.formId ); } @@ -558,7 +552,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { endTimestamp: currentPrize.endTimestamp, isActive: currentPrize.isActive, quantity: currentPrize.quantity, - winnersDrawn: getValidWinnersCount(currentPrizeId), // REFACTORED: Return valid winners count + winnersDrawn: getValidWinnersCount(currentPrizeId), totalTickets: totalTickets[currentPrizeId], totalUsers: totalUniqueUsers[currentPrizeId], formId: currentPrize.formId @@ -574,7 +568,6 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { return prizeWinners[prizeId]; } - // REFACTORED: Check if a specific winner is valid function isWinnerValid( uint256 prizeId, uint256 winnerIndex @@ -598,7 +591,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { uint256[] memory indices = new uint256[](wins.length); uint256 j = 0; for (uint256 i = 0; i < wins.length; i++) { - // REFACTORED: Also check that winner is not invalidated + // Also check that winner is not invalidated if (wins[i].winnerAddress == user && !invalidWinners[prizeId][i] && (!onlyUnclaimed || !wins[i].claimed)) { indices[j++] = i; } @@ -623,7 +616,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { uint256[] memory indices = new uint256[](wins.length); uint256 j = 0; for (uint256 i = 0; i < wins.length; i++) { - // REFACTORED: Also check that winner is not invalidated + // Also check that winner is not invalidated if ( wins[i].winnerAddress == msg.sender && !invalidWinners[prizeId][i] && (!onlyUnclaimed || !wins[i].claimed) From 07759193d7ed2b89a527e33eda31f8a7fc999907 Mon Sep 17 00:00:00 2001 From: ungaro Date: Fri, 24 Oct 2025 10:43:12 -0400 Subject: [PATCH 5/5] update gap --- plume/src/spin/Raffle.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plume/src/spin/Raffle.sol b/plume/src/spin/Raffle.sol index 44508662..2a8647b0 100644 --- a/plume/src/spin/Raffle.sol +++ b/plume/src/spin/Raffle.sol @@ -84,7 +84,7 @@ contract Raffle is Initializable, AccessControlUpgradeable, UUPSUpgradeable { bool private _migrationComplete; // Reserved storage gap for future upgrades - uint256[45] private __gap; // Reduced by 1 due to new mapping + uint256[49] private __gap; // Reduced by 1 due to new mapping // Events event PrizeAdded(uint256 indexed prizeId, string name);