diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..166f9af Binary files /dev/null and b/.DS_Store differ diff --git a/.gitmodules b/.gitmodules index 7992421..49ba740 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "lib/chainlink-brownie-contracts"] - path = lib/chainlink-brownie-contracts - url = https://github.com/smartcontractkit/chainlink-brownie-contracts [submodule "lib/foundry-devops"] path = lib/foundry-devops url = https://github.com/Cyfrin/foundry-devops diff --git a/Makefile b/Makefile index e69de29..2c624b0 100644 --- a/Makefile +++ b/Makefile @@ -0,0 +1,73 @@ +-include .env + +.PHONY: all test clean deploy fund help install snapshot format anvil + +DEFAULT_ANVIL_KEY := 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +help: + @echo "Usage:" + @echo " make deploy [ARGS=...]\n example: make deploy ARGS=\"--network sepolia\"" + @echo "" + @echo " make fund [ARGS=...]\n example: make deploy ARGS=\"--network sepolia\"" + +all: clean remove install update build + +# Clean the repo +clean :; forge clean + +# Remove modules +remove :; rm -rf .gitmodules && rm -rf .git/modules/* && rm -rf lib && touch .gitmodules && git add . && git commit -m "modules" + +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 + +# Update Dependencies +update:; forge update + +build:; forge build + +test :; forge test + +snapshot :; forge snapshot + +format :; forge fmt + +anvil :; anvil -m 'test test test test test test test test test test test junk' --steps-tracing --block-time 1 + +make test-fork: + forge test --fork-url $(SEPOLIA_RPC_URL) --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv + +make deploy-mainnet: + forge script script/DeployRaffle.s.sol:DeployRaffle --rpc-url $(MAINNET_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv + +make deploy-sepolia: + forge script script/DeployRaffle.s.sol:DeployRaffle --rpc-url $(SEPOLIA_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv + +make deploy-anvil: + forge script script/DeployRaffle.s.sol:DeployRaffle --private-key $(PRIVATE_KEY) --broadcast --verify -vvvv + +make create-sepolia-sub: + forge script script/Interactions.s.sol:CreateSubscription --rpc-url $(SEPOLIA_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast + +make add-sepolia-consumer: + forge script script/Interactions.s.sol:AddConsumer --rpc-url $(SEPOLIA_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast + +make fund-sepolia-subscription: + forge script script/Interactions.s.sol:FundSubscription --rpc-url $(SEPOLIA_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast + +make create-mainnet-sub: + forge script script/Interactions.s.sol:CreateSubscription --rpc-url $(MAINNET_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast + +make add-mainnet-consumer: + forge script script/Interactions.s.sol:AddConsumer --rpc-url $(MAINNET_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast + +make fund-mainnet-subscription: + forge script script/Interactions.s.sol:FundSubscription --rpc-url $(MAINNET_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast + +make enter-sepolia-raffle: + cast send 0x3282332b209D5E109475C4B4FC5ac7760d45EF0F "enterRaffle()" --value 0.01ether --private-key $(PRIVATE_KEY) --rpc-url $(SEPOLIA_RPC_URL) + +make enter-mainnet-raffle: + cast send "enterRaffle()" --value 0.01ether --private-key $(PRIVATE_KEY) --rpc-url $(MAINNET_RPC_URL) + +make enter-anvil-raffle: + cast send "enterRaffle()" --value 0.01ether --private-key $(PRIVATE_KEY) diff --git a/README.md b/README.md index 4b2af8f..504fb5a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,66 @@ # Foundry Smart Contract Lottery +# Probably Random Raffle Contracts + +## About + +1. This code is to create a provably random lottery + + +## What we want it to do? + +1. Users can pay for a ticket + 1. The ticket fees are going to go to the winner +2. After some amount of time, the lottery will auto pick a winner + 1. Programatically + 2. Use Chainlink VRF (Randomness) & Chainlink Automation (Time-Based Trigger) + +## Learning to create NatSpec Section in contract (Goes above contract, below pragma) + +## Error handling +1. doesn't make sense to use require any more bc of gas +2. use revert + +1. name error messages right under contract declaration + 1. error Raffle__NotEnoughEthSent as an example (Two underscores after contract name then error) + +## Create Chainlink VRF Subscription + +https://vrf.chain.link/sepolia/new + +1. Connect Wallet and approve transaction + +## State Variables +1. cheaper to make all upper case (goes right under error message or contract name) + +/** + * @title Sample Raffle Contract + * @author UEVGUY + * @notice creating a sample raffle + * @dev Using Chainlink VRFv2 + */ + + +## Modulo function is goofy + +1. It works more like take the first set of numbers and see what is left over 2334502 % 10 is just the 2 at the end bc it's what is left over + +## CEI: Checks Effects and Interactions +1. do checks (require if--> error) early in function (more gas efficient) +2. do effect after checks +3. interactions with other contracts come later + 1. events come before interactions + +## You can make reverts with numerous variables in them +1. error My__Error(uint256 someVariable, uint256 anotherVariable); +2. revert My_Error(address(this.balance), anotherVariable.length); + +# Tests + +## Deploy Scripts +1. + + This is a section from the Cyfrin Foundry Solidity Course. Huge shout out to Patrick Collins for making all this! @@ -7,6 +68,18 @@ Huge shout out to Patrick Collins for making all this! *[⭐️ (3:04:09) | Lesson 9: Foundry Smart Contract Lottery](https://www.youtube.com/watch?v=sas02qSFZ74&t=11049s)* - [Foundry Smart Contract Lottery](#foundry-smart-contract-lottery) +- [Probably Random Raffle Contracts](#probably-random-raffle-contracts) + - [About](#about) + - [What we want it to do?](#what-we-want-it-to-do) + - [Learning to create NatSpec Section in contract (Goes above contract, below pragma)](#learning-to-create-natspec-section-in-contract-goes-above-contract-below-pragma) + - [Error handling](#error-handling) + - [Create Chainlink VRF Subscription](#create-chainlink-vrf-subscription) + - [State Variables](#state-variables) + - [Modulo function is goofy](#modulo-function-is-goofy) + - [CEI: Checks Effects and Interactions](#cei-checks-effects-and-interactions) + - [You can make reverts with numerous variables in them](#you-can-make-reverts-with-numerous-variables-in-them) +- [Tests](#tests) + - [Deploy Scripts](#deploy-scripts) - [Getting Started](#getting-started) - [Requirements](#requirements) - [Quickstart](#quickstart) diff --git a/foundry.toml b/foundry.toml index 25b918f..a5bd8a7 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,13 @@ [profile.default] -src = "src" -out = "out" libs = ["lib"] +out = "out" +src = "src" + +[etherscan] +mainnet = {key = "${ETHERSCAN_API_KEY}"} +sepolia = {key = "${ETHERSCAN_API_KEY}"} + +[rpc_endpoints] +sepolia = "${SEPOLIA_RPC_ENDPOINT}" # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/.DS_Store b/lib/.DS_Store new file mode 100644 index 0000000..5ab2b4c Binary files /dev/null and b/lib/.DS_Store differ diff --git a/lib/chainlink b/lib/chainlink new file mode 160000 index 0000000..505e43d --- /dev/null +++ b/lib/chainlink @@ -0,0 +1 @@ +Subproject commit 505e43d0d8e9721031c7e735e1531cc10e7ccc59 diff --git a/lib/chainlink-brownie-contracts b/lib/chainlink-brownie-contracts index c6d0ca5..b0591b8 160000 --- a/lib/chainlink-brownie-contracts +++ b/lib/chainlink-brownie-contracts @@ -1 +1 @@ -Subproject commit c6d0ca512f1b09d93bc81415d246dbee7e5c6894 +Subproject commit b0591b8790171392db81549f809177f0679b04a4 diff --git a/script/DeployRaffle.s.sol b/script/DeployRaffle.s.sol index e69de29..38531ca 100644 --- a/script/DeployRaffle.s.sol +++ b/script/DeployRaffle.s.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {Script} from "forge-std/Script.sol"; +import {Raffle} from "../src/Raffle.sol"; +import {HelperConfig} from "../script/HelperConfig.s.sol"; +import {AddConsumer, CreateSubscription, FundSubscription} from "./Interactions.s.sol"; + +contract DeployRaffle is Script { + function run() external returns (Raffle, HelperConfig) { + HelperConfig helperConfig = new HelperConfig(); // This comes with our mocks! + AddConsumer addConsumer = new AddConsumer(); + HelperConfig.NetworkConfig memory config = helperConfig.getConfig(); + + if (config.subscriptionId == 0) { + CreateSubscription createSubscription = new CreateSubscription(); + ( + config.subscriptionId, + config.vrfCoordinatorV2_5 + ) = createSubscription.createSubscription( + config.vrfCoordinatorV2_5, + config.account + ); + + FundSubscription fundSubscription = new FundSubscription(); + fundSubscription.fundSubscription( + config.vrfCoordinatorV2_5, + config.subscriptionId, + config.link, + config.account + ); + + helperConfig.setConfig(block.chainid, config); + } + + vm.startBroadcast(config.account); + Raffle raffle = new Raffle( + config.subscriptionId, + config.gasLane, + config.automationUpdateInterval, + config.raffleEntranceFee, + config.callbackGasLimit, + config.vrfCoordinatorV2_5 + ); + vm.stopBroadcast(); + + addConsumer.addConsumer( + address(raffle), + config.vrfCoordinatorV2_5, + config.subscriptionId, + config.account + ); + return (raffle, helperConfig); + } +} diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index e69de29..4698b9b 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -0,0 +1,131 @@ +//SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {Script, console2} from "forge-std/Script.sol"; +import {VRFCoordinatorV2_5Mock} from "../lib/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; + int256 public MOCK_WEI_PER_UINT_LINK = 4e15; + + address public FOUNDRY_DEFAULT_SENDER = + 0x7132F9c2a50e40BeC4a54B3B373537239A044a16; + + uint256 public constant ETH_SEPOLIA_CHAIN_ID = 11155111; + uint256 public constant ETH_MAINNET_CHAIN_ID = 1; + uint256 public constant LOCAL_CHAIN_ID = 31337; +} + +contract HelperConfig is CodeConstants, Script { + error HelperConfig__InvalidChainId(); + + struct NetworkConfig { + uint256 subscriptionId; + bytes32 gasLane; + uint256 automationUpdateInterval; + uint256 raffleEntranceFee; + uint32 callbackGasLimit; + address vrfCoordinatorV2_5; + address link; + address account; + } + + NetworkConfig public localNetworkConfig; + mapping(uint256 chainId => NetworkConfig) public networkConfigs; + + constructor() { + networkConfigs[ETH_SEPOLIA_CHAIN_ID] = getSepoliaEthConfig(); + networkConfigs[ETH_MAINNET_CHAIN_ID] = getMainnetEthConfig(); + } + + function getConfig() public returns (NetworkConfig memory) { + return getConfigByChainId(block.chainid); + } + + function setConfig( + uint256 chainId, + NetworkConfig memory networkConfig + ) public { + networkConfigs[chainId] = networkConfig; + } + + function getConfigByChainId( + uint256 chainId + ) public returns (NetworkConfig memory) { + if (networkConfigs[chainId].vrfCoordinatorV2_5 != address(0)) { + return networkConfigs[chainId]; + } else if (chainId == LOCAL_CHAIN_ID) { + return getOrCreateAnvilEthConfig(); + } else { + revert HelperConfig__InvalidChainId(); + } + } + + function getMainnetEthConfig() + public + view + returns (NetworkConfig memory mainnetNetworkConfig) + { + mainnetNetworkConfig = NetworkConfig({ + subscriptionId: 0, // Replace 0 with your subId from the Chainlink VRF UI here + gasLane: 0x9fe0eebf5e446e3c998ec9bb19951541aee00bb90ea201ae456421a2ded86805, + automationUpdateInterval: 604800, // 1 week + raffleEntranceFee: 0.01 ether, + callbackGasLimit: 500000, // 500,000 gas + vrfCoordinatorV2_5: 0x271682DEB8C4E0901D1a1550aD2e64D568E69909, + link: 0x514910771AF9Ca656af840dff83E8264EcF986CA, + account: FOUNDRY_DEFAULT_SENDER + }); + } + + function getSepoliaEthConfig() + public + view + returns (NetworkConfig memory sepoliaNetworkConfig) + { + sepoliaNetworkConfig = NetworkConfig({ + subscriptionId: 4244416522498249141814610753008696399081896929023878836802545309793195337341, // Replace with your subId from the Chainlink VRF UI here + gasLane: 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae, + automationUpdateInterval: 604800, // 1 week + raffleEntranceFee: 0.01 ether, + callbackGasLimit: 500000, // 500,000 gas + vrfCoordinatorV2_5: 0x9DdfaCa8183c41ad55329BdeeD9F6A8d53168B1B, + link: 0x779877A7B0D9E8603169DdbD7836e478b4624789, + account: FOUNDRY_DEFAULT_SENDER + }); + } + + function getOrCreateAnvilEthConfig() public returns (NetworkConfig memory) { + if (localNetworkConfig.vrfCoordinatorV2_5 != address(0)) { + return localNetworkConfig; + } + + console2.log(unicode"⚠️ You have deployed a mock conract!"); + console2.log("Make sure this was intentional"); + vm.startBroadcast(); + VRFCoordinatorV2_5Mock vrfCoordinatorV2_5Mock = new VRFCoordinatorV2_5Mock( + MOCK_BASE_FEE, + MOCK_GAS_PRICE_LINK, + MOCK_WEI_PER_UINT_LINK + ); + LinkToken link = new LinkToken(); + uint256 subscriptionId = vrfCoordinatorV2_5Mock.createSubscription(); + vm.stopBroadcast(); + + localNetworkConfig = NetworkConfig({ + subscriptionId: subscriptionId, + gasLane: 0x474e34a077df58807dbe9c96d3c009b23b3c6d0cce433e59bbf5b34f823bc56c, // doesn't really matter + automationUpdateInterval: 30, // 30 seconds + raffleEntranceFee: 0.01 ether, + callbackGasLimit: 500000, // 500,000 gas + vrfCoordinatorV2_5: address(vrfCoordinatorV2_5Mock), + link: address(link), + account: FOUNDRY_DEFAULT_SENDER + }); + vm.deal(localNetworkConfig.account, 100 ether); + return localNetworkConfig; + } +} diff --git a/script/Interactions.s.sol b/script/Interactions.s.sol index e69de29..47f49a5 100644 --- a/script/Interactions.s.sol +++ b/script/Interactions.s.sol @@ -0,0 +1,148 @@ +//SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {Script, console} from "forge-std/Script.sol"; +import {HelperConfig} from "./HelperConfig.s.sol"; +import {DevOpsTools} from "foundry-devops/src/DevOpsTools.sol"; +import {Raffle} from "../src/Raffle.sol"; +import {VRFCoordinatorV2_5Mock} from "../lib/chainlink/contracts/src/v0.8/vrf/mocks/VRFCoordinatorV2_5Mock.sol"; +import {VRFCoordinatorV2Interface} from "../lib/chainlink/contracts/src/v0.8/vrf/interfaces/VRFCoordinatorV2Interface.sol"; +import {LinkToken} from "../test/mocks/LinkToken.sol"; +import {CodeConstants} from "./HelperConfig.s.sol"; + +contract CreateSubscription is Script { + address public FOUNDRY_DEFAULT_SENDER = + 0x7132F9c2a50e40BeC4a54B3B373537239A044a16; + + function createSubscriptionUsingConfig() public returns (uint256, address) { + HelperConfig helperConfig = new HelperConfig(); + address vrfCoordinatorV2_5 = helperConfig + .getConfigByChainId(block.chainid) + .vrfCoordinatorV2_5; + address account = helperConfig + .getConfigByChainId(block.chainid) + .account; + return createSubscription(vrfCoordinatorV2_5, account); + } + + function createSubscription( + address vrfCoordinatorV2_5, + address account + ) public returns (uint256, address) { + console.log("Creating subscription on chainId: ", block.chainid); + vm.startBroadcast(account); + uint256 subId = VRFCoordinatorV2_5Mock(vrfCoordinatorV2_5) + .createSubscription(); + vm.stopBroadcast(); + console.log("Your subscription Id is: ", subId); + console.log("Please update the subscriptionId in HelperConfig.s.sol"); + return (subId, vrfCoordinatorV2_5); + } + + function run() external returns (uint256, address) { + return createSubscriptionUsingConfig(); + } +} + +contract AddConsumer is Script { + 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 addConsumerUsingConfig(address mostRecentlyDeployed) public { + HelperConfig helperConfig = new HelperConfig(); + uint256 subId = helperConfig.getConfig().subscriptionId; + address vrfCoordinatorV2_5 = helperConfig + .getConfig() + .vrfCoordinatorV2_5; + address account = helperConfig.getConfig().account; + + addConsumer(mostRecentlyDeployed, vrfCoordinatorV2_5, subId, account); + } + + function run() external { + address mostRecentlyDeployed = DevOpsTools.get_most_recent_deployment( + "Raffle", + block.chainid + ); + addConsumerUsingConfig(mostRecentlyDeployed); + } +} + +contract FundSubscription is CodeConstants, Script { + uint96 public constant FUND_AMOUNT = 3 ether; + + function fundSubscriptionUsingConfig() public { + HelperConfig helperConfig = new HelperConfig(); + uint256 subId = helperConfig.getConfig().subscriptionId; + address vrfCoordinatorV2_5 = helperConfig + .getConfig() + .vrfCoordinatorV2_5; + address link = helperConfig.getConfig().link; + address account = helperConfig.getConfig().account; + + if (subId == 0) { + CreateSubscription createSub = new CreateSubscription(); + (uint256 updatedSubId, address updatedVRFv2) = createSub.run(); + subId = updatedSubId; + vrfCoordinatorV2_5 = updatedVRFv2; + console.log( + "New SubId Created! ", + subId, + "VRF Address: ", + vrfCoordinatorV2_5 + ); + } + + fundSubscription(vrfCoordinatorV2_5, subId, link, account); + } + + function fundSubscription( + address vrfCoordinatorV2_5, + uint256 subId, + address link, + address account + ) public { + console.log("Funding subscription: ", subId); + console.log("Using vrfCoordinator: ", vrfCoordinatorV2_5); + console.log("On ChainID: ", block.chainid); + if (block.chainid == LOCAL_CHAIN_ID) { + vm.startBroadcast(account); + VRFCoordinatorV2_5Mock(vrfCoordinatorV2_5).fundSubscription( + subId, + FUND_AMOUNT + ); + vm.stopBroadcast(); + } else { + console.log(LinkToken(link).balanceOf(msg.sender)); + console.log(msg.sender); + console.log(LinkToken(link).balanceOf(address(this))); + console.log(address(this)); + vm.startBroadcast(account); + LinkToken(link).transferAndCall( + vrfCoordinatorV2_5, + FUND_AMOUNT, + abi.encode(subId) + ); + vm.stopBroadcast(); + } + } + + function run() external { + fundSubscriptionUsingConfig(); + } +} diff --git a/src/Raffle.sol b/src/Raffle.sol index e69de29..de5d6c0 100644 --- a/src/Raffle.sol +++ b/src/Raffle.sol @@ -0,0 +1,192 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {VRFConsumerBaseV2Plus} from "../lib/chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol"; +import {VRFV2PlusClient} from "../lib/chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol"; +import {AutomationCompatibleInterface} from "../lib/chainlink/contracts/src/v0.8/automation/interfaces/AutomationCompatibleInterface.sol"; + +/** + * @title Sample Raffle Contract + * @author UEVGUY + * @notice Creating a simple lottery + * @dev Using Chainlink VRFv2 + */ +contract Raffle is VRFConsumerBaseV2Plus, AutomationCompatibleInterface { + error Raffle__UpkeepNotNeeded( + uint256 currentBalance, + uint256 numPlayers, + uint256 raffleState + ); + error Raffle__TransferFailed(); + error Raffle__SendMoreToEnterRaffle(); + error Raffle__RaffleNotOpen(); + + enum RaffleState { + OPEN, + CALCULATING + } + + uint256 private immutable i_subscriptionId; + bytes32 private immutable i_gasLane; + uint32 private immutable i_callbackGasLimit; + uint16 private constant REQUEST_CONFIRMATIONS = 3; + uint32 private constant NUM_WORDS = 1; + + uint256 private immutable i_interval; + uint256 private immutable i_entranceFee; + uint256 private s_lastTimeStamp; + address private s_recentWinner; + address payable[] private s_players; + RaffleState private s_raffleState; + + event RequestedRaffleWinner(uint256 indexed requestId); + event RaffleEnter(address indexed player); + event WinnerPicked(address indexed player); + + constructor( + uint256 subscriptionId, + bytes32 gasLane, // keyHash + uint256 interval, + uint256 entranceFee, + uint32 callbackGasLimit, + address vrfCoordinatorV2 + ) VRFConsumerBaseV2Plus(vrfCoordinatorV2) { + i_gasLane = gasLane; + i_interval = interval; + i_subscriptionId = subscriptionId; + i_entranceFee = entranceFee; + s_raffleState = RaffleState.OPEN; + s_lastTimeStamp = block.timestamp; + i_callbackGasLimit = callbackGasLimit; + } + + function enterRaffle() public payable { + if (msg.value < i_entranceFee) { + revert Raffle__SendMoreToEnterRaffle(); + } + if (s_raffleState != RaffleState.OPEN) { + revert Raffle__RaffleNotOpen(); + } + s_players.push(payable(msg.sender)); + + emit RaffleEnter(msg.sender); + } + + /** + * @dev called by chainlink keeper nodes + * they look for `upkeepNeeded` to return True. + * the following needs to return true to be true: + * 1. time interval has passed between raffle runs. + * 2. lottery is open. + * 3. contract has ETH. + * 4. subscription is funded with LINK (done through Chainlink UI) + */ + function checkUpkeep( + bytes memory /* checkData */ + ) + public + view + override + returns (bool upkeepNeeded, bytes memory /* performData */) + { + bool isOpen = RaffleState.OPEN == s_raffleState; + bool timePassed = ((block.timestamp - s_lastTimeStamp) > i_interval); + bool hasPlayers = s_players.length > 0; + bool hasBalance = address(this).balance > 0; + upkeepNeeded = (timePassed && isOpen && hasBalance && hasPlayers); + return (upkeepNeeded, "0x0"); // can we comment this out? + } + + /** + * @dev after `checkUpkeep` returns `true`, this function is called + * and kicks off a Chainlink VRF call to get random winner. + */ + function performUpkeep(bytes calldata /* performData */) external override { + (bool upkeepNeeded, ) = checkUpkeep(""); + if (!upkeepNeeded) { + revert Raffle__UpkeepNotNeeded( + address(this).balance, + s_players.length, + uint256(s_raffleState) + ); + } + + s_raffleState = RaffleState.CALCULATING; + + // Will revert if subscription is not set and funded. + uint256 requestId = s_vrfCoordinator.requestRandomWords( + VRFV2PlusClient.RandomWordsRequest({ + keyHash: i_gasLane, + subId: i_subscriptionId, + requestConfirmations: REQUEST_CONFIRMATIONS, + callbackGasLimit: i_callbackGasLimit, + numWords: NUM_WORDS, + extraArgs: VRFV2PlusClient._argsToBytes( + // Set nativePayment to true to pay for VRF requests with Sepolia ETH instead of LINK + VRFV2PlusClient.ExtraArgsV1({nativePayment: false}) + ) + }) + ); + emit RequestedRaffleWinner(requestId); + } + + /** + * @dev function that Chainlink VRF node + * calls to send money to the random winner. + */ + function fulfillRandomWords( + uint256, + /* requestId */ uint256[] calldata randomWords + ) internal override { + uint256 indexOfWinner = randomWords[0] % s_players.length; + address payable recentWinner = s_players[indexOfWinner]; + s_recentWinner = recentWinner; + s_players = new address payable[](0); + s_raffleState = RaffleState.OPEN; + s_lastTimeStamp = block.timestamp; + emit WinnerPicked(recentWinner); + (bool success, ) = recentWinner.call{value: address(this).balance}(""); + if (!success) { + revert Raffle__TransferFailed(); + } + } + + /** + * Getter Functions + */ + function getRaffleState() public view returns (RaffleState) { + return s_raffleState; + } + + function getNumWords() public pure returns (uint256) { + return NUM_WORDS; + } + + function getRequestConfirmations() public pure returns (uint256) { + return REQUEST_CONFIRMATIONS; + } + + function getRecentWinner() public view returns (address) { + return s_recentWinner; + } + + function getPlayer(uint256 index) public view returns (address) { + return s_players[index]; + } + + function getLastTimeStamp() public view returns (uint256) { + return s_lastTimeStamp; + } + + function getInterval() public view returns (uint256) { + return i_interval; + } + + function getEntranceFee() public view returns (uint256) { + return i_entranceFee; + } + + function getNumberOfPlayers() public view returns (uint256) { + return s_players.length; + } +} diff --git a/src/subUnit/ExampleEvents.sol b/src/subUnit/ExampleEvents.sol index e69de29..495f35c 100644 --- a/src/subUnit/ExampleEvents.sol +++ b/src/subUnit/ExampleEvents.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +contract ExampleEvents { + uint256 favoriteNumber; + + event storedNumber( + uint256 indexed oldNumber, + uint256 indexed newNumber, + uint256 addedNumber, + address sender + ); + + function store(uint256 _favoriteNumber) public { + emit storedNumber( + favoriteNumber, + _favoriteNumber, + _favoriteNumber + favoriteNumber, + msg.sender + ); + favoriteNumber = _favoriteNumber; + } + + function retrieve() public view returns (uint256) { + return favoriteNumber; + } +} diff --git a/src/subUnit/ExampleModulo.sol b/src/subUnit/ExampleModulo.sol index e69de29..b036bd7 100644 --- a/src/subUnit/ExampleModulo.sol +++ b/src/subUnit/ExampleModulo.sol @@ -0,0 +1,13 @@ +// SPDX-License_Identifier: MIT + +pragma solidity ^0.8.26; + +contract ExampleModulo { + function getModTen(uint256 number) external pure returns (uint256) { + return number % 10; + } + + function getModTwo(uint256 number) external pure returns (uint256) { + return number % 2; + } +} diff --git a/src/subUnit/ExampleRevert.sol b/src/subUnit/ExampleRevert.sol index e69de29..97b607a 100644 --- a/src/subUnit/ExampleRevert.sol +++ b/src/subUnit/ExampleRevert.sol @@ -0,0 +1,17 @@ +//SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +contract ExampleRevert { + error ExampleRevert__Error(); + + function revertWithError() public pure { + if (false) { + revert ExampleRevert__Error(); + } + } + + function revertWithRequire() public pure { + require(true, "ExampleRevert__Error"); + } +} diff --git a/test/.DS_Store b/test/.DS_Store new file mode 100644 index 0000000..1104d4c Binary files /dev/null and b/test/.DS_Store differ diff --git a/test/mocks/LinkToken.sol b/test/mocks/LinkToken.sol index e69de29..1fecc18 100644 --- a/test/mocks/LinkToken.sol +++ b/test/mocks/LinkToken.sol @@ -0,0 +1,72 @@ +//SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {ERC20} from "../../lib/solmate/src/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/staging/RaffleStagingTest.t.sol b/test/staging/RaffleStagingTest.t.sol index e69de29..12da2b1 100644 --- a/test/staging/RaffleStagingTest.t.sol +++ b/test/staging/RaffleStagingTest.t.sol @@ -0,0 +1,5 @@ +//SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +contract RaffleStagingTest {} diff --git a/test/unit/.DS_Store b/test/unit/.DS_Store new file mode 100644 index 0000000..6577ece Binary files /dev/null and b/test/unit/.DS_Store differ diff --git a/test/unit/RaffleTest.t.sol b/test/unit/RaffleTest.t.sol index e69de29..acecdb3 100644 --- a/test/unit/RaffleTest.t.sol +++ b/test/unit/RaffleTest.t.sol @@ -0,0 +1,262 @@ +//SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {DeployRaffle} from "../../script/DeployRaffle.s.sol"; +import {Raffle} from "../../src/Raffle.sol"; +import {Test, console2} from "forge-std/Test.sol"; +import {HelperConfig} from "../../script/HelperConfig.s.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {LinkToken} from "../../test/mocks/LinkToken.sol"; +import {CodeConstants} from "../../script/HelperConfig.s.sol"; +import {VRFCoordinatorV2_5Mock} from "../../lib/chainlink/contracts/src/v0.8/vrf/mocks/VRFCoordinatorV2_5Mock.sol"; + +contract RaffleTest is Test, CodeConstants { + event RequestedRaffleWinner(uint256 indexed requestId); + event RaffleEnter(address indexed player); + event WinnerPicked(address indexed player); + + Raffle public raffle; + HelperConfig public helperConfig; + + uint256 subscriptionId; + bytes32 gasLane; + uint256 automationUpdateInterval; + uint256 raffleEntranceFee; + uint32 callbackGasLimit; + address vrfCoordinatorV2_5; + LinkToken link; + + address public PLAYER = makeAddr("player"); + uint256 public constant STARTING_USER_BALANCE = .1 ether; + uint256 public constant LINK_BALANCE = 10 ether; + + function setUp() external { + DeployRaffle deployer = new DeployRaffle(); + (raffle, helperConfig) = deployer.run(); + vm.deal(PLAYER, STARTING_USER_BALANCE); + + HelperConfig.NetworkConfig memory config = helperConfig.getConfig(); + subscriptionId = config.subscriptionId; + gasLane = config.gasLane; + automationUpdateInterval = config.automationUpdateInterval; + raffleEntranceFee = config.raffleEntranceFee; + callbackGasLimit = config.callbackGasLimit; + vrfCoordinatorV2_5 = config.vrfCoordinatorV2_5; + link = LinkToken(config.link); + + vm.startPrank(msg.sender); + if (block.chainid == LOCAL_CHAIN_ID) { + link.mint(msg.sender, LINK_BALANCE); + VRFCoordinatorV2_5Mock(vrfCoordinatorV2_5).fundSubscription( + subscriptionId, + LINK_BALANCE + ); + } + link.approve(vrfCoordinatorV2_5, LINK_BALANCE); + vm.stopPrank(); + } + + function testRaffleInitializesInOpenState() public view { + assert(raffle.getRaffleState() == Raffle.RaffleState.OPEN); + } + + function testRaffleRevertsWHenYouDontPayEnought() public { + vm.prank(PLAYER); + + vm.expectRevert(Raffle.Raffle__SendMoreToEnterRaffle.selector); + raffle.enterRaffle(); + } + + function testRaffleRecordsPlayerWhenTheyEnter() public { + vm.prank(PLAYER); + + raffle.enterRaffle{value: raffleEntranceFee}(); + + address playerRecorded = raffle.getPlayer(0); + assert(playerRecorded == PLAYER); + } + + function testEmitsEventOnEntrance() public { + vm.prank(PLAYER); + + vm.expectEmit(true, false, false, false, address(raffle)); + emit RaffleEnter(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + } + + function testDontAllowPlayersToEnterWhileRaffleIsCalculating() public { + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + vm.warp(block.timestamp + automationUpdateInterval + 1); + vm.roll(block.number + 1); + raffle.performUpkeep(""); + + vm.expectRevert(Raffle.Raffle__RaffleNotOpen.selector); + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + } + + function testCheckUpkeepReturnsFalseIfItHasNoBalance() public { + vm.warp(block.timestamp + automationUpdateInterval + 1); + vm.roll(block.number + 1); + + (bool upkeepNeeded, ) = raffle.checkUpkeep(""); + + assert(!upkeepNeeded); + } + + function testCheckUpkeepReturnsFalseIfRaffleIsntOpen() public { + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + vm.warp(block.timestamp + automationUpdateInterval + 1); + vm.roll(block.number + 1); + raffle.performUpkeep(""); + Raffle.RaffleState raffleState = raffle.getRaffleState(); + + (bool upkeepNeeded, ) = raffle.checkUpkeep(""); + + assert(raffleState == Raffle.RaffleState.CALCULATING); + assert(upkeepNeeded == false); + } + + function testCheckUpkeepReturnsFalseIfEnoughTimeHasntPassed() public { + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + + (bool upkeepNeeded, ) = raffle.checkUpkeep(""); + + assert(!upkeepNeeded); + } + + function testCheckUpkeepReturnsTrueWhenParametersGood() public { + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + vm.warp(block.timestamp + automationUpdateInterval + 1); + vm.roll(block.number + 1); + + (bool upkeepNeeded, ) = raffle.checkUpkeep(""); + + assert(upkeepNeeded); + } + + function testPerformUpkeepCanOnlyRunIfCheckUpkeepIsTrue() public { + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + vm.warp(block.timestamp + automationUpdateInterval + 1); + vm.roll(block.number + 1); + + raffle.performUpkeep(""); + } + + function testPerformUpkeepRevertsIfCheckUpkeepIsFalse() public { + uint256 currentBalance = 0; + uint256 numPlayers = 0; + Raffle.RaffleState rState = raffle.getRaffleState(); + + vm.expectRevert( + abi.encodeWithSelector( + Raffle.Raffle__UpkeepNotNeeded.selector, + currentBalance, + numPlayers, + rState + ) + ); + raffle.performUpkeep(""); + } + + function testPerformUpkeepUpdatesRaffleStateAndEmitsRequestId() public { + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + vm.warp(block.timestamp + automationUpdateInterval + 1); + vm.roll(block.number + 1); + + vm.recordLogs(); + raffle.performUpkeep(""); // emits requestId + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 requestId = entries[1].topics[1]; + + Raffle.RaffleState raffleState = raffle.getRaffleState(); + assert(uint256(requestId) > 0); + assert(uint256(raffleState) == 1); // 0 = open, 1 = calculating + } + + modifier raffleEntered() { + vm.prank(PLAYER); + raffle.enterRaffle{value: raffleEntranceFee}(); + vm.warp(block.timestamp + automationUpdateInterval + 1); + vm.roll(block.number + 1); + _; + } + + modifier skipFork() { + if (block.chainid != 31337) { + return; + } + _; + } + + function testFulfillRandomWordsCanOnlyBeCalledAfterPerformUpkeep() + public + raffleEntered + skipFork + { + vm.expectRevert(VRFCoordinatorV2_5Mock.InvalidRequest.selector); + VRFCoordinatorV2_5Mock(vrfCoordinatorV2_5).fulfillRandomWords( + 0, + address(raffle) + ); + + vm.expectRevert(VRFCoordinatorV2_5Mock.InvalidRequest.selector); + VRFCoordinatorV2_5Mock(vrfCoordinatorV2_5).fulfillRandomWords( + 1, + address(raffle) + ); + } + + function testFulfillRandomWordsPicksAWinnerResetsAndSendsMoney() + public + raffleEntered + skipFork + { + address expectedWinner = address(1); + + uint256 additionalEntrances = 3; + uint256 startingIndex = 1; // start with address(1) and not address(0) + + for ( + uint256 i = startingIndex; + i < startingIndex + additionalEntrances; + i++ + ) { + address player = address(uint160(i)); + hoax(player, 1 ether); // deal 1 eth to the player + raffle.enterRaffle{value: raffleEntranceFee}(); + } + + uint256 startingTimeStamp = raffle.getLastTimeStamp(); + uint256 startingBalance = expectedWinner.balance; + + vm.recordLogs(); + raffle.performUpkeep(""); // emits requestId + Vm.Log[] memory entries = vm.getRecordedLogs(); + console2.logBytes32(entries[1].topics[1]); + bytes32 requestId = entries[1].topics[1]; // get the requestId from the logs + + VRFCoordinatorV2_5Mock(vrfCoordinatorV2_5).fulfillRandomWords( + uint256(requestId), + address(raffle) + ); + + address recentWinner = raffle.getRecentWinner(); + Raffle.RaffleState raffleState = raffle.getRaffleState(); + uint256 winnerBalance = recentWinner.balance; + uint256 endingTimeStamp = raffle.getLastTimeStamp(); + uint256 prize = raffleEntranceFee * (additionalEntrances + 1); + + assert(recentWinner == expectedWinner); + assert(uint256(raffleState) == 0); + assert(winnerBalance == startingBalance + prize); + assert(endingTimeStamp > startingTimeStamp); + } +}