diff --git a/script/DeployLottery.s.sol b/script/DeployLottery.s.sol index 2194cdc..0206040 100644 --- a/script/DeployLottery.s.sol +++ b/script/DeployLottery.s.sol @@ -7,18 +7,19 @@ import {HelperConfig} from "./HelperConfig.s.sol"; import {SmartLottery} from "../src/Lottery.sol"; contract DeployLottery is Script { - function run() public returns (SmartLottery, HelperConfig) { - return deployContract(); - } - - function deployContract() public returns (SmartLottery, HelperConfig) { + function run() external returns (SmartLottery, HelperConfig) { HelperConfig helperConfig = new HelperConfig(); HelperConfig.NetworkConfig memory config = helperConfig.getConfig(); vm.startBroadcast(); - SmartLottery smartLottery = - new SmartLottery(config.vrfCoordinatorV2, config.subscriptionId, config.keyHash, config.callbackGasLimit); + SmartLottery lottery = new SmartLottery( + config.vrfCoordinatorV2, + config.subscriptionId, + config.keyHash, + config.callbackGasLimit, + config.minimumTicketPrice + ); vm.stopBroadcast(); - return (smartLottery, helperConfig); + return (lottery, helperConfig); } } diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index 2a1936d..cff827d 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -10,10 +10,11 @@ contract HelperConfig is Script { error HelperConfig__InvalidChainId(); struct NetworkConfig { - uint64 subscriptionId; address vrfCoordinatorV2; + uint64 subscriptionId; bytes32 keyHash; uint32 callbackGasLimit; + uint256 minimumTicketPrice; } NetworkConfig public localNetworkConfig; @@ -42,10 +43,11 @@ contract HelperConfig is Script { function getSepoliaEthConfig() public pure returns (NetworkConfig memory) { return NetworkConfig({ + vrfCoordinatorV2: 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625, subscriptionId: 0, - vrfCoordinatorV2: 0x9DdfaCa8183c41ad55329BdeeD9F6A8d53168B1B, - keyHash: 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae, - callbackGasLimit: 500000 + keyHash: 0x474e34a077df58807dbe9c96d3c009b23b3c6d0cce433e59bbf5b34f823bc56c, + callbackGasLimit: 500000, + minimumTicketPrice: 1 ether }); } @@ -61,7 +63,8 @@ contract HelperConfig is Script { subscriptionId: 0, vrfCoordinatorV2: address(vrfCoordinatorMock), keyHash: 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae, - callbackGasLimit: 500000 + callbackGasLimit: 500000, + minimumTicketPrice: 1 ether }); return localNetworkConfig; } diff --git a/src/Lottery.sol b/src/Lottery.sol index da4cbf7..dab7fe8 100644 --- a/src/Lottery.sol +++ b/src/Lottery.sol @@ -4,38 +4,24 @@ pragma solidity ^0.8.20; import {VRFConsumerBaseV2Plus} from "lib/foundry-chainlink-toolkit/lib/chainlink-brownie-contracts/contracts/src/v0.8/dev/vrf/VRFConsumerBaseV2Plus.sol"; -import {VRFV2PlusClient} from - "lib/foundry-chainlink-toolkit/lib/chainlink-brownie-contracts/contracts/src/v0.8/dev/vrf/libraries/VRFV2PlusClient.sol"; import {VRFCoordinatorV2Interface} from "lib/chainlink-brownie-contracts/contracts/src/v0.8/vrf/interfaces/VRFCoordinatorV2Interface.sol"; /** * @title SmartLottery - * @author Adam Cryptab - * @notice A decentralized lottery system using Chainlink VRF for verifiable randomness - * @dev Inherits from VRFConsumerBaseV2Plus to integrate Chainlink's VRF functionality - * @notice This contract allows users to: - * - Enter lotteries by purchasing tickets - * - Automatically and fairly select winners using Chainlink VRF - * - Create and manage multiple concurrent lottery instances + * @notice A decentralized lottery using Chainlink VRF */ contract SmartLottery is VRFConsumerBaseV2Plus { - /*////////////////////////////////////////////////////////////// - ERRORS - //////////////////////////////////////////////////////////////*/ + /* Errors */ error SmartLottery__NotEnoughFunds(); - error SmartLottery__LotteryClosed(); - error SmartLottery__TransferFailed(); - error SmartLottery__NotOperator(); error SmartLottery__InvalidTicketPrice(); error SmartLottery__LotteryNotExists(); error SmartLottery__LotteryExpired(); - error SmartLottery__LotteryAlreadyExists(); + error SmartLottery__WrongState(); error SmartLottery__NoEntrants(); + error SmartLottery__TransferFailed(); - /*////////////////////////////////////////////////////////////// - TYPES - //////////////////////////////////////////////////////////////*/ + /* Types */ enum LotteryState { OPEN, CALCULATING_WINNER, @@ -43,232 +29,120 @@ contract SmartLottery is VRFConsumerBaseV2Plus { } struct Lottery { - uint256 lotteryId; uint256 ticketPrice; uint256 expiration; - address operator; - address payable winner; + address[] entrants; LotteryState state; - address payable[] entrants; uint256 prizePool; } - /*////////////////////////////////////////////////////////////// - VARIABLES - //////////////////////////////////////////////////////////////*/ - // VRF Variables - address private immutable i_vrfCoordinator; + /* State Variables */ + mapping(uint256 => Lottery) private s_lotteries; + mapping(uint256 => uint256) private s_requestIdToLotteryId; + + // VRF Config + VRFCoordinatorV2Interface private immutable i_vrfCoordinator; uint64 private immutable i_subscriptionId; bytes32 private immutable i_keyHash; uint32 private immutable i_callbackGasLimit; - uint16 private constant REQUEST_CONFIRMATIONS = 3; - uint32 private constant NUM_WORDS = 1; - // Lottery Variables - mapping(uint256 => Lottery) private s_lotteries; - mapping(uint256 => uint256) private s_requestIdToLotteryId; - uint256 private s_currentLotteryId; + uint256 public immutable i_minimumTicketPrice; - /*////////////////////////////////////////////////////////////// - EVENTS - //////////////////////////////////////////////////////////////*/ - event LotteryCreated(uint256 indexed lotteryId, uint256 ticketPrice, uint256 expiration, address operator); + /* Events */ + event LotteryCreated(uint256 indexed lotteryId, uint256 ticketPrice, uint256 expiration); event PlayerEntered(uint256 indexed lotteryId, address indexed player); event WinnerPicked(uint256 indexed lotteryId, address indexed winner, uint256 prize); - event RequestedRandomness(uint256 indexed lotteryId, uint256 indexed requestId); - - /*////////////////////////////////////////////////////////////// - MODIFIERS - //////////////////////////////////////////////////////////////*/ - // Only the lottery operator can perform certain actions - modifier onlyOperator(uint256 _lotteryId) { - if (msg.sender != s_lotteries[_lotteryId].operator) { - revert SmartLottery__NotOperator(); - } - _; - } - // Check if the lottery exists - modifier lotteryExists(uint256 _lotteryId) { - if (s_lotteries[_lotteryId].operator == address(0)) { - revert SmartLottery__LotteryNotExists(); - } - _; - } - - // Check if the lottery is open - modifier lotteryOpen(uint256 _lotteryId) { - if (s_lotteries[_lotteryId].state != LotteryState.OPEN) { - revert SmartLottery__LotteryClosed(); - } - if (block.timestamp >= s_lotteries[_lotteryId].expiration) { - revert SmartLottery__LotteryExpired(); - } - _; - } - - /*////////////////////////////////////////////////////////////// - CONSTRUCTOR - //////////////////////////////////////////////////////////////*/ - constructor(address vrfCoordinatorV2, uint64 subscriptionId, bytes32 keyHash, uint32 callbackGasLimit) - VRFConsumerBaseV2Plus(vrfCoordinatorV2) - { - i_vrfCoordinator = vrfCoordinatorV2; + constructor( + address vrfCoordinatorV2, + uint64 subscriptionId, + bytes32 keyHash, + uint32 callbackGasLimit, + uint256 minimumTicketPrice + ) VRFConsumerBaseV2Plus(vrfCoordinatorV2) { + i_vrfCoordinator = VRFCoordinatorV2Interface(vrfCoordinatorV2); i_subscriptionId = subscriptionId; i_keyHash = keyHash; i_callbackGasLimit = callbackGasLimit; + i_minimumTicketPrice = minimumTicketPrice; } - /*////////////////////////////////////////////////////////////// - MAIN FUNCTIONS - //////////////////////////////////////////////////////////////*/ - /** - * @notice Creates a new lottery with the specified parameters - * @param _lotteryId The ID of the new lottery - * @param _ticketPrice The price of a single ticket in wei - * @param _expiration The expiration timestamp of the lottery - */ - function createLottery(uint256 _lotteryId, uint256 _ticketPrice, uint256 _expiration) external { - if (_ticketPrice == 0) revert SmartLottery__InvalidTicketPrice(); - if (_expiration <= block.timestamp) revert SmartLottery__LotteryExpired(); - if (s_lotteries[_lotteryId].operator != address(0)) { - revert SmartLottery__LotteryAlreadyExists(); + function createLottery(uint256 lotteryId, uint256 ticketPrice, uint256 duration) external { + if (ticketPrice < i_minimumTicketPrice) { + revert SmartLottery__InvalidTicketPrice(); + } + if (s_lotteries[lotteryId].state != LotteryState.CLOSED && s_lotteries[lotteryId].expiration != 0) { + revert SmartLottery__WrongState(); } - s_lotteries[_lotteryId] = Lottery({ - lotteryId: _lotteryId, - ticketPrice: _ticketPrice, - expiration: _expiration, - operator: msg.sender, - winner: payable(address(0)), + s_lotteries[lotteryId] = Lottery({ + ticketPrice: ticketPrice, + expiration: block.timestamp + duration, + entrants: new address[](0), state: LotteryState.OPEN, - entrants: new address payable[](0), prizePool: 0 }); - emit LotteryCreated(_lotteryId, _ticketPrice, _expiration, msg.sender); + emit LotteryCreated(lotteryId, ticketPrice, block.timestamp + duration); } - /** - * @notice Allows a player to enter a lottery by sending the required funds - * @param _lotteryId The ID of the lottery to enter - */ - function enterLottery(uint256 _lotteryId) external payable lotteryExists(_lotteryId) lotteryOpen(_lotteryId) { - Lottery storage lottery = s_lotteries[_lotteryId]; - if (msg.value < lottery.ticketPrice) { - revert SmartLottery__NotEnoughFunds(); - } + function enterLottery(uint256 lotteryId) external payable { + Lottery storage lottery = s_lotteries[lotteryId]; + if (lottery.state != LotteryState.OPEN) revert SmartLottery__WrongState(); + if (block.timestamp >= lottery.expiration) revert SmartLottery__LotteryExpired(); + if (msg.value < lottery.ticketPrice) revert SmartLottery__NotEnoughFunds(); - lottery.entrants.push(payable(msg.sender)); + lottery.entrants.push(msg.sender); lottery.prizePool += msg.value; - emit PlayerEntered(_lotteryId, msg.sender); + emit PlayerEntered(lotteryId, msg.sender); } - /** - * @notice Picks a winner for the specified lottery - * @param _lotteryId The ID of the lottery to pick a winner for - */ - function pickWinner(uint256 _lotteryId) - external - lotteryExists(_lotteryId) - lotteryOpen(_lotteryId) - onlyOperator(_lotteryId) - { - Lottery storage lottery = s_lotteries[_lotteryId]; + function pickWinner(uint256 lotteryId) external { + Lottery storage lottery = s_lotteries[lotteryId]; + if (lottery.state != LotteryState.OPEN) revert SmartLottery__WrongState(); if (lottery.entrants.length == 0) revert SmartLottery__NoEntrants(); lottery.state = LotteryState.CALCULATING_WINNER; - uint256 requestId = VRFCoordinatorV2Interface(i_vrfCoordinator).requestRandomWords( - i_keyHash, i_subscriptionId, REQUEST_CONFIRMATIONS, i_callbackGasLimit, NUM_WORDS + uint256 requestId = i_vrfCoordinator.requestRandomWords( + i_keyHash, + i_subscriptionId, + 3, // numConfirmations + i_callbackGasLimit, + 1 // numWords ); - s_requestIdToLotteryId[requestId] = _lotteryId; - - emit RequestedRandomness(_lotteryId, requestId); + s_requestIdToLotteryId[requestId] = lotteryId; } - /** - * @notice Callback function to handle the VRF response - * @param requestId The ID of the request - * @param randomWords The array of random words returned by the VRF - */ function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override { uint256 lotteryId = s_requestIdToLotteryId[requestId]; Lottery storage lottery = s_lotteries[lotteryId]; - uint256 indexOfWinner = randomWords[0] % lottery.entrants.length; - address payable winner = lottery.entrants[indexOfWinner]; + uint256 winnerIndex = randomWords[0] % lottery.entrants.length; + address winner = lottery.entrants[winnerIndex]; + uint256 prize = lottery.prizePool; - lottery.winner = winner; lottery.state = LotteryState.CLOSED; - - uint256 prize = lottery.prizePool; lottery.prizePool = 0; (bool success,) = winner.call{value: prize}(""); - if (!success) { - revert SmartLottery__TransferFailed(); - } + if (!success) revert SmartLottery__TransferFailed(); emit WinnerPicked(lotteryId, winner, prize); } - function withdraw() external onlyOwner { - (bool success,) = msg.sender.call{value: address(this).balance}(""); - if (!success) { - revert SmartLottery__TransferFailed(); - } - } - - receive() external payable {} - fallback() external payable {} - - /*////////////////////////////////////////////////////////////// - VIEW FUNCTIONS - //////////////////////////////////////////////////////////////*/ - /** - * @notice Retrieves the details of a specific lottery - * @param _lotteryId The ID of the lottery to retrieve details for - * @return ticketPrice The price of a single ticket in wei - * @return expiration The expiration timestamp of the lottery - * @return operator The address of the lottery operator - * @return winner The address of the lottery winner - * @return state The current state of the lottery - * @return entrantsCount The number of entrants in the lottery - * @return prizePool The prize pool of the lottery - */ - function getLottery(uint256 _lotteryId) + /* View Functions */ + function getLottery(uint256 lotteryId) external view - returns ( - uint256 ticketPrice, - uint256 expiration, - address operator, - address winner, - LotteryState state, - uint256 entrantsCount, - uint256 prizePool - ) + returns (uint256 ticketPrice, uint256 expiration, uint256 numEntrants, LotteryState state, uint256 prizePool) { - Lottery storage lottery = s_lotteries[_lotteryId]; - return ( - lottery.ticketPrice, - lottery.expiration, - lottery.operator, - lottery.winner, - lottery.state, - lottery.entrants.length, - lottery.prizePool - ); + Lottery storage lottery = s_lotteries[lotteryId]; + return (lottery.ticketPrice, lottery.expiration, lottery.entrants.length, lottery.state, lottery.prizePool); } - /** - * @notice Retrieves the list of entrants for a specific lottery - * @param _lotteryId The ID of the lottery to retrieve entrants for - * @return An array of addresses representing the entrants - */ - function getEntrants(uint256 _lotteryId) external view returns (address payable[] memory) { - return s_lotteries[_lotteryId].entrants; + function getEntrants(uint256 lotteryId) external view returns (address[] memory) { + return s_lotteries[lotteryId].entrants; } } diff --git a/test/LotteryTest.t.sol b/test/LotteryTest.t.sol index 9e891bf..1058085 100644 --- a/test/LotteryTest.t.sol +++ b/test/LotteryTest.t.sol @@ -12,8 +12,10 @@ contract LotteryTest is Test { HelperConfig public helperConfig; address public user = makeAddr("user"); + address public user2 = makeAddr("user2"); uint256 public constant STARTING_BALANCE = 10 ether; uint256 public constant TICKET_PRICE = 1 ether; + uint256 public constant INVALID_PRICE = 0.1 ether; uint256 public constant LOTTERY_ID = 1; // VRF Configuration @@ -36,53 +38,76 @@ contract LotteryTest is Test { callbackGasLimit = config.callbackGasLimit; } - // function testCreatesALottery() public { - // uint256 expiration = block.timestamp + 1 days; - - // // Act: Create a lottery - // lottery.createLottery(LOTTERY_ID, TICKET_PRICE, expiration); - - // // Assert: Check lottery details - // ( - // uint256 ticketPrice, - // uint256 expiration, - // address operator, - // address winner, - // SmartLottery.LotteryState state, - // uint256 entrantsCount, - // uint256 prizePool - // ) = lottery.getLottery(LOTTERY_ID); - - // assertEq(ticketPrice, TICKET_PRICE, "Ticket price mismatch"); - // assertEq(expiration, block.timestamp + 1 days, "Expiration mismatch"); - // assertEq(operator, user, "Operator mismatch"); - // assertEq(uint8(state), uint8(SmartLottery.LotteryState.OPEN), "State mismatch"); - // assertEq(entrantsCount, 0, "Entrants should be empty"); - // assertEq(prizePool, 0, "Prize pool should be 0"); - // } - - function testLotteryIsOpen() public {} - - function testCanEnterLottery() public { + modifier lotteryCreated() { + vm.prank(user); + lottery.createLottery(LOTTERY_ID, TICKET_PRICE, 1 days); + _; + } + + function testRevertsIfInvalidTicketPrice() public { + vm.startPrank(user); + vm.expectRevert(SmartLottery.SmartLottery__InvalidTicketPrice.selector); + lottery.createLottery(LOTTERY_ID, INVALID_PRICE, block.timestamp + 1 days); + } + + function testRevertsIfLotteryExpired() public lotteryCreated { vm.startPrank(user); - lottery.createLottery(LOTTERY_ID, TICKET_PRICE, block.timestamp + 1 days); + vm.warp(block.timestamp + 1 days + 1 seconds); + vm.expectRevert(SmartLottery.SmartLottery__LotteryExpired.selector); + lottery.enterLottery{value: TICKET_PRICE}(LOTTERY_ID); + vm.stopPrank(); + } + + function testCanCreateLottery() public { + vm.startPrank(user); + uint256 duration = 1 days; + uint256 expectedExpiration = block.timestamp + duration; + lottery.createLottery(LOTTERY_ID, TICKET_PRICE, duration); + vm.stopPrank(); + (uint256 ticketPrice, uint256 actualExpiration, uint256 numEntrants,, uint256 prizePool) = + lottery.getLottery(LOTTERY_ID); + + assertEq(ticketPrice, TICKET_PRICE); + assertEq(actualExpiration, expectedExpiration, "Expiration timestamp mismatch"); + assertEq(numEntrants, 0); + assertEq(prizePool, 0); + } + + function testRevertsIfNotEnoughFunds() public lotteryCreated { + vm.startPrank(user2); + vm.deal(user2, STARTING_BALANCE); + vm.expectRevert(SmartLottery.SmartLottery__NotEnoughFunds.selector); + lottery.enterLottery{value: INVALID_PRICE}(LOTTERY_ID); + vm.stopPrank(); + } + + function testCanEnterLottery() public lotteryCreated { + vm.startPrank(user2); + vm.deal(user2, STARTING_BALANCE); lottery.enterLottery{value: TICKET_PRICE}(LOTTERY_ID); vm.stopPrank(); ( , // ticketPrice , // expiration - , // operator - , // winner + uint256 numEntrants, , // state - uint256 entrantsCount, uint256 prizePool ) = lottery.getLottery(LOTTERY_ID); - address payable[] memory entrants = lottery.getEntrants(LOTTERY_ID); + address[] memory entrants = lottery.getEntrants(LOTTERY_ID); + + assertEq(prizePool, TICKET_PRICE); + assertEq(numEntrants, 1); + assertEq(entrants[0], user2); + } - assertEq(prizePool, TICKET_PRICE, "Prize pool should be equal to the ticket price"); - assertEq(entrantsCount, 1, "Entrants count should be 1"); - assertEq(entrants[0], user, "Entrant should be the user"); + function testRaffleAddsPlayerWhenTheyEnter() public lotteryCreated { + vm.startPrank(user2); + vm.deal(user2, STARTING_BALANCE); + lottery.enterLottery{value: TICKET_PRICE}(LOTTERY_ID); + address playerEntered = lottery.getEntrants(LOTTERY_ID)[0]; + vm.stopPrank(); + assertEq(playerEntered, user2, "Player should be user2"); } }