diff --git a/.gitignore b/.gitignore index 85198aa..19ea627 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,11 @@ out/ !/broadcast /broadcast/*/31337/ /broadcast/**/dry-run/ +/broadcast/ # Docs docs/ # Dotenv file .env +misc/ diff --git a/.gitmodules b/.gitmodules index 888d42d..58acb81 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,12 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/chainlink-brownie-contracts"] + path = lib/chainlink-brownie-contracts + url = https://github.com/smartcontractkit/chainlink-brownie-contracts +[submodule "lib/solmate"] + path = lib/solmate + url = https://github.com/transmissions11/solmate +[submodule "lib/foundry-devops"] + path = lib/foundry-devops + url = https://github.com/cyfrin/foundry-devops diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..081d0cf --- /dev/null +++ b/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 ./script/DeployRaffle.s.sol:DeployRaffle --rpc-url $(SEPOLIA_RPC_URL) --account sepolia --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv \ No newline at end of file diff --git a/README.md b/README.md index 9265b45..550ba2b 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,37 @@ -## Foundry +# Smart Contract Lottery -**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** +This project is a decentralized lottery/raffle application implemented using Solidity and Foundry. This project was developed following the Cyfrin Updraft online course. +Cyfrin repo: https://github.com/Cyfrin/foundry-smart-contract-lottery-cu -Foundry consists of: +## How It Works -- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). -- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. -- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. -- **Chisel**: Fast, utilitarian, and verbose solidity REPL. +1. Users enter the lottery by sending ETH to the smart contract, paying a predefined minimum entry fee. +2. After a specified amount of time, a winner is selected randomly from the list of participants. +3. The winner receives the entire accumulated prize pool. -## Documentation +## Setup and Installation -https://book.getfoundry.sh/ +1. **Clone the Repository:** -## Usage + ```bash + git clone https://github.com/sergipastor/smart-contract-lottery-cyfrin.git + cd smart-contract-lottery-cyfrin + ``` -### Build +2. **Install Dependencies:** -```shell -$ forge build -``` + ```bash + make install + ``` -### Test +3. **Compile Contracts:** -```shell -$ forge test -``` + ```bash + make build + ``` -### Format +4. **Run Tests:** -```shell -$ forge fmt -``` - -### Gas Snapshots - -```shell -$ forge snapshot -``` - -### Anvil - -```shell -$ anvil -``` - -### Deploy - -```shell -$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key -``` - -### Cast - -```shell -$ cast -``` - -### Help - -```shell -$ forge --help -$ anvil --help -$ cast --help -``` + ```bash + make test + ``` diff --git a/foundry.toml b/foundry.toml index 25b918f..a89eac4 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,5 +2,10 @@ src = "src" out = "out" libs = ["lib"] +remappings = [ + "@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/", + "@solmate=lib/solmate/src/", +] -# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options +[fuzz] +runs = 256 # sets the number of runs in fuzz tests diff --git a/lib/chainlink-brownie-contracts b/lib/chainlink-brownie-contracts new file mode 160000 index 0000000..12393bd --- /dev/null +++ b/lib/chainlink-brownie-contracts @@ -0,0 +1 @@ +Subproject commit 12393bd475bd60c222ff12e75c0f68effe1bbaaf diff --git a/lib/forge-std b/lib/forge-std index 1714bee..978ac6f 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 1714bee72e286e73f76e320d110e0eaf5c4e649d +Subproject commit 978ac6fadb62f5f0b723c996f64be52eddba6801 diff --git a/lib/foundry-devops b/lib/foundry-devops new file mode 160000 index 0000000..df9f90b --- /dev/null +++ b/lib/foundry-devops @@ -0,0 +1 @@ +Subproject commit df9f90b490423578142b5dd50752db9427efb2ac diff --git a/lib/solmate b/lib/solmate new file mode 160000 index 0000000..a9e3ea2 --- /dev/null +++ b/lib/solmate @@ -0,0 +1 @@ +Subproject commit a9e3ea26a2dc73bfa87f0cb189687d029028e0c5 diff --git a/script/Counter.s.sol b/script/Counter.s.sol deleted file mode 100644 index cdc1fe9..0000000 --- a/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script, console} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/script/DeployRaffle.s.sol b/script/DeployRaffle.s.sol new file mode 100644 index 0000000..60f58b4 --- /dev/null +++ b/script/DeployRaffle.s.sol @@ -0,0 +1,45 @@ +// 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 { + deployContract(); + } + + function deployContract() public returns (Raffle, HelperConfig) { + HelperConfig helperConfig = new HelperConfig(); + + HelperConfig.NetworkConfig memory config = helperConfig.getConfig(); + + if (config.subscriptionId == 0) { + CreateSubscription createSubscription = new CreateSubscription(); + // updates subscriptionId & vrfCoordinator in config + (config.subscriptionId, config.vrfCoordinator) = + createSubscription.createSubscription(config.vrfCoordinator, config.account); + + 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(); + // it's broadcasted inside addConsumer + addConsumer.addConsumer(address(raffle), config.vrfCoordinator, config.subscriptionId, config.account); + return (raffle, helperConfig); + } +} diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol new file mode 100644 index 0000000..710ff78 --- /dev/null +++ b/script/HelperConfig.s.sol @@ -0,0 +1,101 @@ +// 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 { + 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; + + address public FOUNDRY_DEFAULT_SENDER = + 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38; + + 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) { + return getOrCreateAnvilEthConfig(); + } else { + revert HelperConfig__InvalidChainId(); + } + } + + function getConfig() public returns (NetworkConfig memory) { + return getConfigByChainId(block.chainid); + } + + function getSepoliaEthConfig() + public + pure + returns (NetworkConfig memory sepoliaNetworkConfig) + { + sepoliaNetworkConfig = NetworkConfig({ + entranceFee: 0.01 ether, + interval: 30, + vrfCoordinator: 0x9DdfaCa8183c41ad55329BdeeD9F6A8d53168B1B, + gasLane: 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae, + callbackGasLimit: 500000, + subscriptionId: 51047831189129265369038141528057349675397396091678916226248283722260792977383, // If left as 0, our scripts will create one! + link: 0x779877A7B0D9E8603169DdbD7836e478b4624789, + account: 0x0B5eE21c72F06E74AE02b34F6478eC27E1937FC1 + }); + } + + function getOrCreateAnvilEthConfig() public returns (NetworkConfig memory) { + if (localNetworkConfig.vrfCoordinator != address(0)) { + return localNetworkConfig; + } + + vm.startBroadcast(); + VRFCoordinatorV2_5Mock vrfCoordinatorV2_5Mock = 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, + interval: 30, + vrfCoordinator: address(vrfCoordinatorV2_5Mock), + gasLane: 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae, + callbackGasLimit: 500000, + subscriptionId: 0, // If left as 0, our scripts will create one! + link: address(linkToken), + account: FOUNDRY_DEFAULT_SENDER + }); + return localNetworkConfig; + } +} diff --git a/script/Interactions.s.sol b/script/Interactions.s.sol new file mode 100644 index 0000000..62f5341 --- /dev/null +++ b/script/Interactions.s.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {Script, console} from "forge-std/Script.sol"; +import {Raffle} from "src/Raffle.sol"; +import {LinkToken} from "test/mocks/LinkToken.sol"; +import {DevOpsTools} from "lib/foundry-devops/src/DevOpsTools.sol"; +import {HelperConfig, CodeConstants} from "script/HelperConfig.s.sol"; +import {VRFCoordinatorV2_5Mock} from "@chainlink/contracts/src/v0.8/vrf/mocks/VRFCoordinatorV2_5Mock.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; + (uint256 subId, ) = createSubscription(vrfCoordinator, account); + return (subId, vrfCoordinator); + } + + function createSubscription( + address vrfCoordinator, + address account + ) public returns (uint256, address) { + console.log("Creating subscription on chainId: ", 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 subscriptionId in HelperConfig.s.sol"); + return (subId, vrfCoordinator); + } + + function run() external returns (uint256, address) { + return createSubscriptionUsingConfig(); + } +} + +contract FundSubscription is Script, CodeConstants { + uint256 public constant FUND_AMOUNT = 3 ether; // 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 ChainId: ", block.chainid); + + if (block.chainid == LOCAL_CHAIN_ID) { + vm.startBroadcast(account); + 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("Using 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/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index aded799..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/Raffle.sol b/src/Raffle.sol new file mode 100644 index 0000000..22ed431 --- /dev/null +++ b/src/Raffle.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol"; +import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol"; + +/** + * @title Sample Raffle Contract + * @author Sergi Pastor + * @notice This contract creates a sample raffle. + * @dev Implements Chainlink VRFv2.5 + */ +contract Raffle is VRFConsumerBaseV2Plus { + error Raffle__SendMoreEthToEnterRaffle(); + error Raffle__TransferFailed(); + error Raffle__RaffleNotOpen(); + error Raffle__UpkeepNotNeeded( + uint256 balance, + uint256 playersLength, + uint256 raffleState + ); + + enum RaffleState { + OPEN, + CALCULATING + } + + uint16 private constant REQUEST_CONFIRMATIONS = 3; + uint32 private constant NUM_WORDS = 1; + uint256 private immutable i_entranceFee; + uint256 private immutable i_timeInterval; // @dev The duration of the lottery in seconds. + bytes32 private immutable i_keyHash; + uint256 private immutable i_subscriptionId; + uint32 private immutable i_callbackGasLimit; + address payable[] private s_players; + uint256 private s_lastTimeStamp; + address private s_recentWinner; + RaffleState private s_raffleState; + + // Events + event RaffleEntered(address indexed player); + event WinnerPicked(address indexed winner); + event RequestedRaffleWinner(uint256 indexed requestId); + + // @dev: override VRFConsumerBaseV2Plus constructor + constructor( + uint256 entranceFee, + uint256 timeInterval, + address vrfCoordinator, + bytes32 gasLane, + uint256 subscriptionId, + uint32 callbackGasLimit + ) VRFConsumerBaseV2Plus(vrfCoordinator) { + i_entranceFee = entranceFee; + i_timeInterval = timeInterval; + i_keyHash = gasLane; + i_subscriptionId = subscriptionId; + i_callbackGasLimit = callbackGasLimit; + + s_lastTimeStamp = block.timestamp; + s_raffleState = RaffleState.OPEN; + } + + function enterRaffle() external payable { + // require(msg.value >= i_entranceFee, "Not enough ETH sent to enter raffle.") + // @dev: using revert with a custom error is more gas efficient than require() + if (msg.value < i_entranceFee) { + revert Raffle__SendMoreEthToEnterRaffle(); + } + if (s_raffleState != RaffleState.OPEN) { + revert Raffle__RaffleNotOpen(); + } + + s_players.push(payable(msg.sender)); + + emit RaffleEntered(msg.sender); + } + + /** + * Checks if it's time for the lottery to finish and be restarted. + * The function will be called periodically by the Chainlink automation nodes. + * @param - ignored + * @return upkeepNeeded true if it's time to restart the lottery + * @return - ignored + */ + function checkUpkeep( + bytes memory /*calldata*/ + ) public view returns (bool upkeepNeeded, bytes memory /*performData*/) { + bool hasTimePassed = ((block.timestamp - s_lastTimeStamp) >= + i_timeInterval); + bool isRaffleOpen = s_raffleState == RaffleState.OPEN; + bool hasBalance = address(this).balance > 0; + bool hasPlayers = s_players.length > 0; + upkeepNeeded = + hasTimePassed && + isRaffleOpen && + hasBalance && + hasPlayers; + return (upkeepNeeded, ""); + } + + function performUpkeep(bytes calldata /* performData */) external { + // Check section + (bool upkeepNeeded, ) = checkUpkeep(""); + if (!upkeepNeeded) { + revert Raffle__UpkeepNotNeeded( + address(this).balance, + s_players.length, + uint256(s_raffleState) + ); + } + if ((block.timestamp - s_lastTimeStamp) < i_timeInterval) { + revert Raffle__RaffleNotOpen(); + } + + s_raffleState = RaffleState.CALCULATING; + + // Get random number using Chainlink VRF 2.5 + // with two different transactions: + // 1. Request the Random Number Generator (RNG) + // 2. The Chainlink oracle sends the RNG + VRFV2PlusClient.RandomWordsRequest memory requestRNG = VRFV2PlusClient + .RandomWordsRequest({ + keyHash: i_keyHash, + subId: i_subscriptionId, + requestConfirmations: REQUEST_CONFIRMATIONS, + callbackGasLimit: i_callbackGasLimit, + numWords: NUM_WORDS, + extraArgs: VRFV2PlusClient._argsToBytes( + // @dev: set nativePayment to true to pay using SepoliaETH instead of LINK. + VRFV2PlusClient.ExtraArgsV1({nativePayment: false}) + ) + }); + uint256 requestId = s_vrfCoordinator.requestRandomWords(requestRNG); + + emit RequestedRaffleWinner(requestId); + } + + function fulfillRandomWords( + uint256 /* requestId */, + uint256[] calldata randomWords + ) internal override { + // The virtual function `fulfillRandomWords` declared in `VRFConsumerBaseV2Plus` + // needs to be overridden here in order to finally obtain the random numbers / words. + uint256 indexOfWinner = randomWords[0] % s_players.length; + address payable recentWinner = s_players[indexOfWinner]; + + // Effect section (handling internal contract state) + s_recentWinner = recentWinner; + s_raffleState = RaffleState.OPEN; + s_players = new address payable[](0); + s_lastTimeStamp = block.timestamp; + emit WinnerPicked(s_recentWinner); + + // Interactions section (handling external communications) + (bool success, ) = recentWinner.call{value: address(this).balance}(""); + if (!success) { + revert Raffle__TransferFailed(); + } + } + + // Getter functions + function getEntranceFee() external view returns (uint256) { + return i_entranceFee; + } + + function getRaffleState() public view returns (RaffleState) { + return s_raffleState; + } + + function getPlayer(uint256 index) public view returns (address) { + return s_players[index]; + } + + function getLastTimeStamp() public view returns (uint256) { + return s_lastTimeStamp; + } + + function getRecentWinner() public view returns (address) { + return s_recentWinner; + } +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 54b724f..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/mocks/LinkToken.sol b/test/mocks/LinkToken.sol new file mode 100644 index 0000000..a9ef30d --- /dev/null +++ b/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/test/unit/RaffleTest.t.sol b/test/unit/RaffleTest.t.sol new file mode 100644 index 0000000..03ff734 --- /dev/null +++ b/test/unit/RaffleTest.t.sol @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {Test} from "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; +import {Raffle} from "src/Raffle.sol"; +import {DeployRaffle} from "script/DeployRaffle.s.sol"; +import {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"; +import {CodeConstants} from "script/HelperConfig.s.sol"; + +contract RaffleTest is CodeConstants, Test { + Raffle public raffle; + HelperConfig public helperConfig; + + uint256 subscriptionId; + bytes32 gasLane; + uint256 automationUpdateInterval; + uint256 raffleEntranceFee; + uint32 callbackGasLimit; + address vrfCoordinator; + + address public PLAYER = makeAddr("player"); + uint256 public constant STARTING_PLAYER_BALANCE = 10 ether; + + event RaffleEntered(address indexed player); + event WinnerPicked(address indexed winner); + + function setUp() external { + DeployRaffle deployer = new DeployRaffle(); + (raffle, helperConfig) = deployer.deployContract(); + + HelperConfig.NetworkConfig memory config = helperConfig.getConfig(); + raffleEntranceFee = config.entranceFee; + automationUpdateInterval = config.interval; + vrfCoordinator = config.vrfCoordinator; + gasLane = config.gasLane; + callbackGasLimit = config.callbackGasLimit; + subscriptionId = config.subscriptionId; + + vm.deal(PLAYER, STARTING_PLAYER_BALANCE); + } + + modifier raffleEntered() { + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + vm.warp(block.timestamp + automationUpdateInterval + 1); + vm.roll(block.number + 1); + _; + } + + function testRaffleInitializesInOpenState() public view { + assert(raffle.getRaffleState() == Raffle.RaffleState.OPEN); + } + + function testRaffleRevertsWhenYouDontPayEnough() public { + vm.prank(PLAYER); // Act + // Act & Assert + vm.expectRevert(Raffle.Raffle__SendMoreEthToEnterRaffle.selector); + raffle.enterRaffle(); + } + + function testRaffleRecordsPlayersWhenTheyEnter() public { + // Arrange + vm.prank(PLAYER); + // Act + raffle.enterRaffle{value: raffleEntranceFee}(); + // Assert + assert(raffle.getPlayer(0) == PLAYER); + } + + function testEnteringRaffleEmitsEvent() public { + // Arrange + vm.prank(PLAYER); + // Act + // @dev: First bool is true because the event has only the first index parameter. + vm.expectEmit(true, false, false, false, address(raffle)); + emit RaffleEntered(PLAYER); // The exact event that we are expecting + // Assert + raffle.enterRaffle{value: raffleEntranceFee}(); + } + + function testDontAllowPlayersToEnterWhileRaffleIsCalculating() public { + // Arrange + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + // warp() changes the current block.timestamp to whatever value given. + vm.warp(block.timestamp + automationUpdateInterval + 1); + // roll() sets a new block number (applied for better simulation purposes). + vm.roll(block.number + 1); + raffle.performUpkeep(""); + + // Act & Assert + vm.expectRevert(Raffle.Raffle__RaffleNotOpen.selector); + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + } + + function testCheckUpkeepReturnsFalseIfHasNoBalance() public { + // Arrange + vm.warp(block.timestamp + automationUpdateInterval + 1); + vm.roll(block.number + 1); + + // Act + (bool upkeepNeeded, ) = raffle.checkUpkeep(""); + + // Assert + assert(!upkeepNeeded); + } + + function testCheckUpkeepReturnsFalseIfRaffleIsNotOpen() public { + // Arrange + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + vm.warp(block.timestamp + automationUpdateInterval + 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: raffleEntranceFee}(); + + // Act + (bool upkeepNeeded, ) = raffle.checkUpkeep(""); + + // Assert + assert(!upkeepNeeded); + } + + function testCheckUpkeepReturnsTrueWhenParametersGood() public { + // Arrange + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + vm.warp(block.timestamp + automationUpdateInterval + 1); + vm.roll(block.number + 1); + + // Act + (bool upkeepNeeded, ) = raffle.checkUpkeep(""); + + // Assert + assert(upkeepNeeded); + } + + /*////////////////////////////////////////////////////////////// + performUpkeep() tests + //////////////////////////////////////////////////////////////*/ + function testPerformUpkeepCanOnlyRunIfCheckUpkeepIsTrue() public { + // Arrange + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + vm.warp(block.timestamp + automationUpdateInterval + 1); + vm.roll(block.number + 1); + + // Act / Assert + // It doesnt revert + raffle.performUpkeep(""); + } + + function testPerformUpkeepRevertsIfCheckUpkeepIsFalse() public { + // Arrange + uint256 currentBalance = 0; + uint256 numPlayers = 0; + Raffle.RaffleState rState = raffle.getRaffleState(); + + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + currentBalance = currentBalance + raffleEntranceFee; + numPlayers = 1; + + // Act / Assert + vm.expectRevert( + abi.encodeWithSelector( + Raffle.Raffle__UpkeepNotNeeded.selector, + currentBalance, + numPlayers, + rState + ) + ); + raffle.performUpkeep(""); + } + + function testPerformUpkeepUpdatesRaffleStateAndEmitsRequestId() + public + raffleEntered + { + // Act + vm.recordLogs(); + // recordLogs() keeps track of every event / log emmitted by the following statements + raffle.performUpkeep(""); // emits requestId + 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); + } + + /*////////////////////////////////////////////////////////////// + fulfillRandomWords() tests + //////////////////////////////////////////////////////////////*/ + modifier skipFork() { + if (block.chainid != LOCAL_CHAIN_ID) { + return; + } + _; + } + + function testFulfillRandomWordsCanOnlyBeCalledAfterPerformUpkeep( + uint256 randomRequestId + ) public raffleEntered skipFork { + // Fuzz test! + vm.expectRevert(VRFCoordinatorV2_5Mock.InvalidRequest.selector); + // We can execute `fulfillRandomWords()` here because it's a mock VRFCoordinator. + // In production, `fulfillRandomWords()` will only be called by the Chainlink nodes. + VRFCoordinatorV2_5Mock(vrfCoordinator).fulfillRandomWords( + randomRequestId, + address(raffle) + ); + } + + function testFulfillRandomWordsPicksWinnerResetsAndSendsMoney() + public + raffleEntered + skipFork + { + // Arrange + uint256 additionalEntrants = 3; // 4 players in total. + uint256 startingIndex = 1; + address expectedWinner = address(1); + + for ( + uint256 i = startingIndex; + i < startingIndex + additionalEntrants; + i++ + ) { + address newPlayer = address(uint160(i)); + hoax(newPlayer, 1 ether); // sets a prank and gives newPlayer 1 ether. + raffle.enterRaffle{value: raffleEntranceFee}(); + } + 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 = raffleEntranceFee * (additionalEntrants + 1); + + assert(recentWinner == expectedWinner); + assert(uint256(raffleState) == 0); + assert(winnerBalance == winnerStartingBalance + prize); + assert(endingTimeStamp > startingTimestamp); + } +}