diff --git a/.gitmodules b/.gitmodules index a808097..6fb7034 100644 --- a/.gitmodules +++ b/.gitmodules @@ -34,3 +34,9 @@ [submodule "lottery/lib/chainlink-brownie-contracts"] path = lottery/lib/chainlink-brownie-contracts url = https://github.com/smartcontractkit/chainlink-brownie-contracts +[submodule "lottery/lib/solmate"] + path = lottery/lib/solmate + url = https://github.com/transmissions11/solmate +[submodule "lottery/lib/foundry-devops"] + path = lottery/lib/foundry-devops + url = https://github.com/cyfrin/foundry-devops diff --git a/lottery/Makefile b/lottery/Makefile new file mode 100644 index 0000000..638d8f0 --- /dev/null +++ b/lottery/Makefile @@ -0,0 +1,12 @@ +-include .env + +.PHONY: all test deploy + +build :; forge build + +test :; forge test + +install :; forge install cyfrin/foundry-devops@0.2.2 --no-commit && forge install smartcontractkit/chainlink-brownie-contracts@1.1.1 --no-commit && forge install foundry-rs/forge-std@v1.8.2 --no-commit && forge install transmissions11/solmate@v6 --no-commit + +deploy-sepolia: + @forge script scripts/DeployRaffle.s.sol:DeployRaffle --rpc-url $(SEPOLIA_RPC_URL) --account default --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv diff --git a/lottery/foundry.toml b/lottery/foundry.toml index 4f698c6..0e07134 100644 --- a/lottery/foundry.toml +++ b/lottery/foundry.toml @@ -2,6 +2,16 @@ src = "src" out = "out" libs = ["lib"] -remappings = ['@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/'] +remappings = [ + '@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/', + '@solmate=lib/solmate/src/' +] +fs_permissions = [ + { access = "read", path = "./broadcast" }, + { access = "read", path = "./reports" }, +] + +[fuzz] +runs = 256 # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lottery/lib/forge-std b/lottery/lib/forge-std index b93cf4b..978ac6f 160000 --- a/lottery/lib/forge-std +++ b/lottery/lib/forge-std @@ -1 +1 @@ -Subproject commit b93cf4bc34ff214c099dc970b153f85ade8c9f66 +Subproject commit 978ac6fadb62f5f0b723c996f64be52eddba6801 diff --git a/lottery/lib/foundry-devops b/lottery/lib/foundry-devops new file mode 160000 index 0000000..df9f90b --- /dev/null +++ b/lottery/lib/foundry-devops @@ -0,0 +1 @@ +Subproject commit df9f90b490423578142b5dd50752db9427efb2ac diff --git a/lottery/lib/solmate b/lottery/lib/solmate new file mode 160000 index 0000000..a9e3ea2 --- /dev/null +++ b/lottery/lib/solmate @@ -0,0 +1 @@ +Subproject commit a9e3ea26a2dc73bfa87f0cb189687d029028e0c5 diff --git a/lottery/script/DeployRaffle.s.sol b/lottery/script/DeployRaffle.s.sol new file mode 100644 index 0000000..a812414 --- /dev/null +++ b/lottery/script/DeployRaffle.s.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {Script} from "forge-std/Script.sol"; +import {Raffle} from "src/Raffle.sol"; +import {HelperConfig} from "script/HelperConfig.s.sol"; +import {CreateSubscription, FundSubscription, AddConsumer} from "script/Interactions.s.sol"; + +contract DeployRaffle is Script { + function run() public {} + + function deployContract() public returns(Raffle, HelperConfig) { + HelperConfig helperConfig = new HelperConfig(); + // local => deploy mocks, get local config + // sepolia => get sepolia config + HelperConfig.NetworkConfig memory config = helperConfig.getConfig(); + + if (config.subscriptionId == 0) { + // create subscription + CreateSubscription createSubscription = new CreateSubscription(); + (config.subscriptionId, config.vrfCoordinator) = createSubscription.createSubscription(config.vrfCoordinator, config.account); + + // fund it! + FundSubscription fundSubscription = new FundSubscription(); + fundSubscription.fundSubscription(config.vrfCoordinator, config.subscriptionId, config.link, config.account); + } + + vm.startBroadcast(config.account); + Raffle raffle = new Raffle( + config.entranceFee, + config.interval, + config.vrfCoordinator, + config.gasLane, + config.subscriptionId, + config.callbackGasLimit + ); + vm.stopBroadcast(); + + AddConsumer addConsumer = new AddConsumer(); + addConsumer.addConsumer(address(raffle), config.vrfCoordinator, config.subscriptionId, config.account); + + return (raffle, helperConfig); + } +} diff --git a/lottery/script/HelperConfig.s.sol b/lottery/script/HelperConfig.s.sol new file mode 100644 index 0000000..9a8e6ff --- /dev/null +++ b/lottery/script/HelperConfig.s.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {Script} from "forge-std/Script.sol"; +import {VRFCoordinatorV2_5Mock} from "@chainlink/contracts/src/v0.8/vrf/mocks/VRFCoordinatorV2_5Mock.sol"; +import {LinkToken} from "test/mocks/LinkToken.sol"; + +abstract contract CodeConstants { + /* VRF Mock Values */ + uint96 public MOCK_BASE_FEE = 0.25 ether; + uint96 public MOCK_GAS_PRICE_LINK = 1e9; + // LINK / ETH price + int256 public MOCK_WEI_PER_UINT_LINK = 4e15; + + uint256 public constant ETH_SEPOLIA_CHAIN_ID = 11155111; + uint256 public constant LOCAL_CHAIN_ID = 31337; +} + +contract HelperConfig is CodeConstants, Script { + error HelperConfig__InvalidChainId(); + + struct NetworkConfig { + uint256 entranceFee; + uint256 interval; + address vrfCoordinator; + bytes32 gasLane; + uint32 callbackGasLimit; + uint256 subscriptionId; + address link; + address account; + } + + NetworkConfig public localNetworkConfig; + mapping(uint256 chainid => NetworkConfig) public networkConfigs; + + constructor() { + networkConfigs[ETH_SEPOLIA_CHAIN_ID] = getSepoliaEthConfig(); + } + + function getConfigByChainId(uint256 chainId) public returns (NetworkConfig memory) { + if (networkConfigs[chainId].vrfCoordinator != address(0)) { + return networkConfigs[chainId]; + } else if (chainId == LOCAL_CHAIN_ID) { + // get or create anvil eth config + return getOrCreateAnvilEthConfig(); + } else { + revert HelperConfig__InvalidChainId(); + } + } + + function getConfig() public returns (NetworkConfig memory) { + return getConfigByChainId(block.chainid); + } + + function getSepoliaEthConfig() public pure returns (NetworkConfig memory) { + return NetworkConfig({ + entranceFee: 0.01 ether, // 1e16 + interval: 30, // 30 seconds + vrfCoordinator: 0x9DdfaCa8183c41ad55329BdeeD9F6A8d53168B1B, + gasLane: 0x130dba50ad435d4ecc214aad0d5820474137bd68e7e77724144f27c3c377d3d4, + callbackGasLimit: 500000, // 500,000 gas + subscriptionId: 0, + link: 0x779877A7B0D9E8603169DdbD7836e478b4624789, + account: address(1) + }); + } + + function getOrCreateAnvilEthConfig() public returns (NetworkConfig memory) { + // check if already set network config + if (localNetworkConfig.vrfCoordinator != address(0)) { + return localNetworkConfig; + } + + // deploy mocks and such + vm.startBroadcast(); + VRFCoordinatorV2_5Mock vrfCoordinatorMock = new VRFCoordinatorV2_5Mock(MOCK_BASE_FEE, MOCK_GAS_PRICE_LINK, MOCK_WEI_PER_UINT_LINK); + LinkToken linkToken = new LinkToken(); + vm.stopBroadcast(); + + localNetworkConfig = NetworkConfig({ + entranceFee: 0.01 ether, // 1e16 + interval: 30, // 30 seconds + vrfCoordinator: address(vrfCoordinatorMock), + // doesn't matter + gasLane: 0x130dba50ad435d4ecc214aad0d5820474137bd68e7e77724144f27c3c377d3d4, + callbackGasLimit: 500000, // 500,000 gas + subscriptionId: 0, // might have to fix + link: address(linkToken), + account: 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38 + }); + return localNetworkConfig; + } +} diff --git a/lottery/script/Interactions.s.sol b/lottery/script/Interactions.s.sol new file mode 100644 index 0000000..1762264 --- /dev/null +++ b/lottery/script/Interactions.s.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {Script, console} from "forge-std/Script.sol"; +import {HelperConfig, CodeConstants} from "script/HelperConfig.s.sol"; +import {VRFCoordinatorV2_5Mock} from "@chainlink/contracts/src/v0.8/vrf/mocks/VRFCoordinatorV2_5Mock.sol"; +import {LinkToken} from "test/mocks/LinkToken.sol"; +import {DevOpsTools} from "lib/foundry-devops/src/DevOpsTools.sol"; + +contract CreateSubscription is Script { + function createSubscriptionUsingConfig() public returns (uint256, address) { + HelperConfig helperConfig = new HelperConfig(); + address vrfCoordinator = helperConfig.getConfig().vrfCoordinator; + address account = helperConfig.getConfig().account; + + // create subscription + (uint256 subId, ) = createSubscription(vrfCoordinator, account); + return (subId, vrfCoordinator); + } + + function createSubscription(address vrfCoordinator, address account) public returns (uint256, address) { + console.log("Creating subscription on chain Id: ", block.chainid); + vm.startBroadcast(account); + uint256 subId = VRFCoordinatorV2_5Mock(vrfCoordinator).createSubscription(); + vm.stopBroadcast(); + + console.log("Your subscription Id is: ", subId); + console.log("Please update the subscription Id in your HelperConfig.s.sol"); + return (subId, vrfCoordinator); + } + + function run() public { + createSubscriptionUsingConfig(); + } +} + +contract FundSubscription is Script, CodeConstants { + uint256 public constant FUND_AMOUNT = 3 ether; // equals to 3 LINK + + function fundSubscriptionUsingConfig() public { + HelperConfig helperConfig = new HelperConfig(); + address vrfCoordinator = helperConfig.getConfig().vrfCoordinator; + uint256 subscriptionId = helperConfig.getConfig().subscriptionId; + address linkToken = helperConfig.getConfig().link; + address account = helperConfig.getConfig().account; + fundSubscription(vrfCoordinator, subscriptionId, linkToken, account); + } + + function fundSubscription(address vrfCoordinator, uint256 subscriptionId, address linkToken, address account) public { + console.log("Funding subscription: ", subscriptionId); + console.log("Using vrfCoordinator: ", vrfCoordinator); + console.log("On ChainiId: ", block.chainid); + + if (block.chainid == LOCAL_CHAIN_ID) { + vm.startBroadcast(); + VRFCoordinatorV2_5Mock(vrfCoordinator).fundSubscription(subscriptionId, FUND_AMOUNT * 100); + vm.stopBroadcast(); + } else { + vm.startBroadcast(account); + LinkToken(linkToken).transferAndCall(vrfCoordinator, FUND_AMOUNT, abi.encode(subscriptionId)); + vm.stopBroadcast(); + } + } + + function run() public { + fundSubscriptionUsingConfig(); + } +} + +contract AddConsumer is Script { + function addConsumerUsingConfig(address mostRecentlyDeployed) public { + HelperConfig helperConfig = new HelperConfig(); + uint256 subId = helperConfig.getConfig().subscriptionId; + address vrfCoordinator = helperConfig.getConfig().vrfCoordinator; + address account = helperConfig.getConfig().account; + addConsumer(mostRecentlyDeployed, vrfCoordinator, subId, account); + } + + function addConsumer(address contractToAddToVrf, address vrfCoordinator, uint256 subId, address account) public { + console.log("Adding consumer contract: ", contractToAddToVrf); + console.log("To vrfCoordinator: ", vrfCoordinator); + console.log("On ChainId: ", block.chainid); + vm.startBroadcast(account); + VRFCoordinatorV2_5Mock(vrfCoordinator).addConsumer(subId, contractToAddToVrf); + vm.stopBroadcast(); + } + + function run() external { + address mostRecentlyDeployed = DevOpsTools.get_most_recent_deployment("Raffle", block.chainid); + addConsumerUsingConfig(mostRecentlyDeployed); + } +} diff --git a/lottery/src/Raffle.sol b/lottery/src/Raffle.sol index 4b8d7ac..7a7f1a7 100644 --- a/lottery/src/Raffle.sol +++ b/lottery/src/Raffle.sol @@ -36,7 +36,7 @@ contract Raffle is VRFConsumerBaseV2Plus { error Raffle__SendMoreToEnterRaffle(); error Raffle__TransferFailed(); error Raffle__RaffleNotOpen(); - error Raffle__UpkeepNotNeeded(uint256 balance, uint256 playersLength, uint256 raffleState); + error Raffle__UpkeepNotNeeded(uint256 balance, uint256 playersLength, uint256 raffleState); /* Type Declarations */ enum RaffleState { @@ -62,6 +62,7 @@ contract Raffle is VRFConsumerBaseV2Plus { /* Events */ event RaffleEntered(address indexed player); event WinnerPicked(address indexed winner); + event RequestedRaffleWinner(uint256 indexed requestId); constructor( uint256 entranceFee, @@ -112,20 +113,20 @@ contract Raffle is VRFConsumerBaseV2Plus { view returns (bool upkeepNeeded, bytes memory /* performData */ ) { - bool timeHasPassed = ((block.timestamp - s_lastTimeStamp) >= i_interval); - bool isOpen = s_raffleState == RaffleState.OPEN; - bool hasBalance = address(this).balance > 0; - bool hasPlayers = s_players.length > 0; - upkeepNeeded = timeHasPassed && isOpen && hasBalance && hasPlayers; - return (upkeepNeeded, ""); + bool timeHasPassed = ((block.timestamp - s_lastTimeStamp) >= i_interval); + bool isOpen = s_raffleState == RaffleState.OPEN; + bool hasBalance = address(this).balance > 0; + bool hasPlayers = s_players.length > 0; + upkeepNeeded = timeHasPassed && isOpen && hasBalance && hasPlayers; + return (upkeepNeeded, ""); } - function performUpkeep(bytes calldata /* performData */) external { - (bool upkeepNeeded, ) = checkUpkeep(""); - if (!upkeepNeeded) { - revert Raffle__UpkeepNotNeeded(address(this).balance, s_players.length, uint256(s_raffleState)); - } - + function performUpkeep(bytes calldata /* performData */ ) external { + (bool upkeepNeeded,) = checkUpkeep(""); + if (!upkeepNeeded) { + revert Raffle__UpkeepNotNeeded(address(this).balance, s_players.length, uint256(s_raffleState)); + } + s_raffleState = RaffleState.CALCULATING; VRFV2PlusClient.RandomWordsRequest memory request = VRFV2PlusClient.RandomWordsRequest({ keyHash: i_keyHash, @@ -135,11 +136,15 @@ contract Raffle is VRFConsumerBaseV2Plus { numWords: NUM_WORDS, extraArgs: VRFV2PlusClient._argsToBytes(VRFV2PlusClient.ExtraArgsV1({nativePayment: false})) }); - s_vrfCoordinator.requestRandomWords(request); + uint256 requestId = s_vrfCoordinator.requestRandomWords(request); + + // actually redundant because V2_5 has its own emit event with request id + // but its being done for the sake of ease development + emit RequestedRaffleWinner(requestId); } // CEI: Check, Effects, Interactions Pattern - function fulfillRandomWords(uint256 /* requestId */, uint256[] calldata randomWords) internal virtual override { + function fulfillRandomWords(uint256, /* requestId */ uint256[] calldata randomWords) internal virtual override { // Checks: check values before executing function // Effect (Internal Contract State) @@ -164,4 +169,20 @@ contract Raffle is VRFConsumerBaseV2Plus { function getEntranceFee() external view returns (uint256) { return i_entranceFee; } + + function getRaffleState() external view returns (RaffleState) { + return s_raffleState; + } + + function getPlayer(uint256 indexOfPlayer) external view returns (address) { + return s_players[indexOfPlayer]; + } + + function getLastTimeStamp() external view returns (uint256) { + return s_lastTimeStamp; + } + + function getRecentWinner() external view returns (address) { + return s_recentWinner; + } } diff --git a/lottery/test/mocks/LinkToken.sol b/lottery/test/mocks/LinkToken.sol new file mode 100644 index 0000000..a9ef30d --- /dev/null +++ b/lottery/test/mocks/LinkToken.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// @dev This contract has been adapted to fit with foundry +pragma solidity ^0.8.0; + +import {ERC20} from "@solmate/tokens/ERC20.sol"; + +interface ERC677Receiver { + function onTokenTransfer(address _sender, uint256 _value, bytes memory _data) external; +} + +contract LinkToken is ERC20 { + uint256 constant INITIAL_SUPPLY = 1000000000000000000000000; + uint8 constant DECIMALS = 18; + + constructor() ERC20("LinkToken", "LINK", DECIMALS) { + _mint(msg.sender, INITIAL_SUPPLY); + } + + function mint(address to, uint256 value) public { + _mint(to, value); + } + + event Transfer(address indexed from, address indexed to, uint256 value, bytes data); + + /** + * @dev transfer token to a contract address with additional data if the recipient is a contact. + * @param _to The address to transfer to. + * @param _value The amount to be transferred. + * @param _data The extra data to be passed to the receiving contract. + */ + function transferAndCall(address _to, uint256 _value, bytes memory _data) public virtual returns (bool success) { + super.transfer(_to, _value); + // emit Transfer(msg.sender, _to, _value, _data); + emit Transfer(msg.sender, _to, _value, _data); + if (isContract(_to)) { + contractFallback(_to, _value, _data); + } + return true; + } + + // PRIVATE + + function contractFallback(address _to, uint256 _value, bytes memory _data) private { + ERC677Receiver receiver = ERC677Receiver(_to); + receiver.onTokenTransfer(msg.sender, _value, _data); + } + + function isContract(address _addr) private view returns (bool hasCode) { + uint256 length; + assembly { + length := extcodesize(_addr) + } + return length > 0; + } +} diff --git a/lottery/test/unit/RaffleTest.t.sol b/lottery/test/unit/RaffleTest.t.sol new file mode 100644 index 0000000..9c35be6 --- /dev/null +++ b/lottery/test/unit/RaffleTest.t.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {Test} from "forge-std/Test.sol"; +import {DeployRaffle} from "script/DeployRaffle.s.sol"; +import {Raffle} from "src/Raffle.sol"; +import {CodeConstants, HelperConfig} from "script/HelperConfig.s.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {VRFCoordinatorV2_5Mock} from "@chainlink/contracts/src/v0.8/vrf/mocks/VRFCoordinatorV2_5Mock.sol"; + +contract RaffleTest is CodeConstants, Test { + Raffle public raffle; + HelperConfig public helperConfig; + + uint256 entranceFee; + uint256 interval; + address vrfCoordinator; + bytes32 gasLane; + uint32 callbackGasLimit; + uint256 subscriptionId; + + address public PLAYER = makeAddr("player"); + uint256 public constant STARTING_PLAYER_BALANCE = 10 ether; + + event RaffleEntered(address indexed player); + + function setUp() external { + DeployRaffle deployer = new DeployRaffle(); + (raffle, helperConfig) = deployer.deployContract(); + HelperConfig.NetworkConfig memory config = helperConfig.getConfig(); + entranceFee = config.entranceFee; + interval = config.interval; + vrfCoordinator = config.vrfCoordinator; + gasLane = config.gasLane; + callbackGasLimit = config.callbackGasLimit; + subscriptionId = config.subscriptionId; + + vm.deal(PLAYER, STARTING_PLAYER_BALANCE); + } + + function testRaffleInitializedInOpenState() public view { + assert(raffle.getRaffleState() == Raffle.RaffleState.OPEN); + } + + function testRaffleRevertsWhenYouDontPayEnough() public { + // Arrange + vm.prank(PLAYER); + // Act / Asset + vm.expectRevert(Raffle.Raffle__SendMoreToEnterRaffle.selector); + // Assert + raffle.enterRaffle(); + } + + function testRaffleRecordsPlayersWhenTheyEnter() public { + // Arrange + vm.prank(PLAYER); + // Act + raffle.enterRaffle{value: entranceFee}(); + // Assert + address playerRecorded = raffle.getPlayer(0); + assert(playerRecorded == PLAYER); + } + + function testEnteringRaffleEmitsEvent() public { + // Arrange + vm.prank(PLAYER); + // Act + vm.expectEmit(true, false, false, false, address(raffle)); + emit RaffleEntered(PLAYER); + // Assert + raffle.enterRaffle{value: entranceFee}(); + } + + function testDontAllowPlayersToEnterWhileRaffleIsCalculating() public { + // Arrange + vm.prank(PLAYER); + raffle.enterRaffle{value: entranceFee}(); + vm.warp(block.timestamp + interval + 1); + vm.roll(block.number + 1); + raffle.performUpkeep(""); + + // Act + vm.expectRevert(Raffle.Raffle__RaffleNotOpen.selector); + vm.prank(PLAYER); + raffle.enterRaffle{value: entranceFee}(); + + // Assert + } + + // Check Upkeep + function testCheckUpkeepReturnsFalseIfItHasNoBalance() public { + // Arrange + vm.warp(block.timestamp + interval + 1); + vm.roll(block.number + 1); + + // Act + (bool upkeepNeeded, ) = raffle.checkUpkeep(""); + + // Assert + assert(!upkeepNeeded); + } + + function testCheckUpkeepReturnsFalseIfRaffleIsntOpen() public { + // Arrange + vm.prank(PLAYER); + raffle.enterRaffle{value: entranceFee}(); + vm.warp(block.timestamp + interval + 1); + vm.roll(block.number + 1); + raffle.performUpkeep(""); + + // Act + (bool upkeepNeeded, ) = raffle.checkUpkeep(""); + + // Assert + assert(!upkeepNeeded); + } + + function testCheckUpkeepReturnsFalseIfEnoughTimeHasPassed() public { + // Arrange + vm.prank(PLAYER); + raffle.enterRaffle{value: entranceFee}(); + vm.warp(block.timestamp + interval + 1); + vm.roll(block.number + 1); + raffle.performUpkeep(""); + + vm.warp(block.timestamp + interval + 1); + vm.roll(block.number + 1); + + // Act + (bool upkeepNeeded, ) = raffle.checkUpkeep(""); + + // Assert + assert(!upkeepNeeded); + } + + function testCheckUpkeepReturnsTrueWhenParametersAreGood() public { + // Arrange + vm.prank(PLAYER); + raffle.enterRaffle{value: entranceFee}(); + vm.warp(block.timestamp + interval + 1); + vm.roll(block.number + 1); + + // Act + (bool upkeepNeeded, ) = raffle.checkUpkeep(""); + + // Assert + assert(upkeepNeeded); + } + + // Perform Upkeep + function testPerformUpkeepCanOnlyRunIfCheckUpkeepIsTrue() public { + // Arrange + vm.prank(PLAYER); + raffle.enterRaffle{value: entranceFee}(); + vm.warp(block.timestamp + interval + 1); + vm.roll(block.number + 1); + + // Act / Assert + raffle.performUpkeep(""); + } + + function testPerformUpkeepRevertsIfCheckUpkeepIsFalse() public { + // Arrange + uint256 currentBalance = 0; + uint256 numPlayers = 0; + Raffle.RaffleState rState = raffle.getRaffleState(); + + vm.prank(PLAYER); + raffle.enterRaffle{value: entranceFee}(); + currentBalance = currentBalance + entranceFee; + numPlayers = 1; + + // Act / Assert + vm.expectRevert( + abi.encodeWithSelector(Raffle.Raffle__UpkeepNotNeeded.selector, currentBalance, numPlayers, rState) + ); + raffle.performUpkeep(""); + } + + modifier raffleEntered() { + // Arrange + vm.prank(PLAYER); + raffle.enterRaffle{value: entranceFee}(); + vm.warp(block.timestamp + interval + 1); + vm.roll(block.number + 1); + _; + } + + function testPerformUpkeepUpdatesRaffleStateAndEmitsRequested() public raffleEntered { + // Act + vm.recordLogs(); + raffle.performUpkeep(""); + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 requestId = entries[1].topics[1]; + + // Assert + Raffle.RaffleState raffleState = raffle.getRaffleState(); + assert(uint256(requestId) > 0); + assert(uint256(raffleState) == 1); + } + + // Fulfill Random Words + modifier skipFork() { + if (block.chainid != LOCAL_CHAIN_ID) { + return; + } + _; + } + + function testFulfillRandomWordsCanOnlyBeCalledAfterPerformUpkeep(uint256 randomRequestId) public raffleEntered skipFork { + // Arrange / Assert + vm.expectRevert(VRFCoordinatorV2_5Mock.InvalidRequest.selector); + VRFCoordinatorV2_5Mock(vrfCoordinator).fulfillRandomWords(randomRequestId, address(raffle)); + } + + function testFulfillRandomWordsPicksAWinnerResetsAndSendsMoney() public raffleEntered skipFork { + // Arrange + uint256 additionalEntrants = 3; + uint256 startingIndex = 1; + address expectedWinner = address(1); + + for(uint256 i = startingIndex; i < startingIndex + additionalEntrants; i++) { + address newPlayer = address(uint160(i)); // address(1) + hoax(newPlayer, 1 ether); + raffle.enterRaffle{value: entranceFee}(); + } + uint256 startingTimeStamp = raffle.getLastTimeStamp(); + uint256 winnerStartingBalance = expectedWinner.balance; + + // Act + vm.recordLogs(); + raffle.performUpkeep(""); + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 requestId = entries[1].topics[1]; + VRFCoordinatorV2_5Mock(vrfCoordinator).fulfillRandomWords(uint256(requestId), address(raffle)); + + // Assert + address recentWinner = raffle.getRecentWinner(); + Raffle.RaffleState raffleState = raffle.getRaffleState(); + uint256 winnerBalance = recentWinner.balance; + uint256 endingTimeStamp = raffle.getLastTimeStamp(); + uint256 prize = entranceFee * (additionalEntrants + 1); + + assert(recentWinner == expectedWinner); + assert(uint256(raffleState) == 0); + assert(winnerBalance == winnerStartingBalance + prize); + assert(endingTimeStamp > startingTimeStamp); + } +}