A provably fair raffle built with Foundry, using Chainlink VRF v2.5 for randomness. Based on the lottery project in Cyfrin Updraft's Foundry Fundamentals course.
- Players call
enterRaffleand pay the entrance fee (0.01 ETH). - Once the interval (30 seconds) has passed and the raffle has players,
checkUpkeepreturns true. performUpkeepcan then be called. It closes entries (the raffle state becomesCALCULATING) and requests a random number from the VRF coordinator.- The coordinator calls back
fulfillRandomWords, which picks the winner, credits them with the round's prize and resets the raffle for the next round. - The winner calls
withdrawWinningsto collect their prize. Winnings they leave in the raffle add up across rounds and never count towards a later round's prize.
The prize is credited rather than sent so that a winner who can't receive ETH can't stop the raffle (see Why winners withdraw their prize).
The project includes a Chainlink CRE workflow that calls performUpkeep when it's due,
in place of Chainlink Automation (see Automating upkeep with Chainlink CRE).
Deploying it is a paid Chainlink service, so for now draws are triggered by hand: anyone can call performUpkeep with
the PerformUpkeep script (mise run upkeep:sepolia) or cast.
| Path | Contents |
|---|---|
src/Raffle.sol |
The raffle contract |
src/RaffleUpkeepReceiver.sol |
Receives reports from the CRE workflow and calls performUpkeep |
script/HelperConfig.s.sol |
Per-chain settings: VRF coordinator, gas lane, subscription, LINK token, broadcasting account |
script/DeployRaffle.s.sol |
Deploys the raffle and adds it as a consumer of the VRF subscription |
script/Interactions.s.sol |
Create, fund and add consumers to a VRF subscription |
script/PerformUpkeep.s.sol |
Triggers performUpkeep if checkUpkeep says it's needed |
script/UpkeepReceiver.s.sol |
Deploys the receiver and switches its CRE forwarder |
cre/raffle-upkeep |
The CRE workflow (TypeScript) |
test/unit, test/integration |
Tests; test/integration/RaffleFlowTest.t.sol runs whole rounds end to end |
- Foundry
- mise, which runs the project tasks in
mise.toml(mise taskslists them) - For the CRE workflow: Bun and the CRE CLI,
logged in with
cre login - For Sepolia only:
- the 1Password CLI (
op), which supplies the RPC URL and Etherscan API key - an Etherscan API key (free), to verify the deployed contract
jq, used bymise run sub:create:sepolia- a Foundry keystore account (
cast wallet import) with Sepolia ETH and LINK (faucet)
- the 1Password CLI (
git clone --recurse-submodules https://github.com/SonicWizard/foundry-smart-contract-lottery.git
cd foundry-smart-contract-lottery
mise run build
mise run cre:install # the CRE workflow's dependenciesLocal builds and tests need nothing else. For Sepolia, copy .env.example to .env and fill it in:
| Variable | Value |
|---|---|
SEPOLIA_RPC_URL |
A 1Password secret reference to your RPC URL, such as op://Dev/Alchemy/sepolia-url |
ETHERSCAN_API_KEY |
A 1Password secret reference to your Etherscan API key |
SEPOLIA_ACCOUNT |
The address of your keystore account. It owns the VRF subscription and pays its LINK, and the scripts broadcast from it on Sepolia. |
SEPOLIA_SUBSCRIPTION_ID |
Your VRF subscription ID. Leave it empty until you create one (see below). |
.env is ignored by git. The Sepolia tasks run through op run --env-file=.env, which reads the secrets only for
that command. Forge does write the resolved RPC URL, including any API key in it, to cache/<script>/<chain-id>/ when
it broadcasts ("Sensitive values saved to"). cache/ is ignored by git, but the key is on disk in plain text. Check
the RPC URL with mise run rpc:check, which should print 11155111.
mise run test # all tests on a local chain, using Chainlink's VRF coordinator mock
mise run test:fork # the same tests against a fork of Sepolia
mise run coverage # coverage summary
mise run check # what CI runs: forge fmt --check, forge build --sizes, forge test
mise run cre:typecheck # type-check the CRE workflowOn a fork, the 8 tests that need the local VRF mock are reported as skipped. The rest deploy against the real Sepolia
coordinator and your subscription, so SEPOLIA_SUBSCRIPTION_ID and SEPOLIA_ACCOUNT must be set.
Each task takes <account>, the name of your Foundry keystore account. It must hold the key for SEPOLIA_ACCOUNT, or
Forge refuses to broadcast.
-
Create a subscription, then set
SEPOLIA_SUBSCRIPTION_IDin.envto the ID it prints:mise run sub:create:sepolia <account>
Forge's simulated run logs a different ID from the real one; the task reads the real ID from the transaction.
-
Fund it with LINK. Before answering a request, the VRF node checks that the subscription could pay for it at the gas lane's maximum price (500 gwei on Sepolia), not the actual one. With this raffle's 500,000 callback gas limit, that reserve is about 80 LINK, depending on the LINK/ETH price. A fulfilment actually costs about 0.01 LINK at Sepolia's gas prices, so the reserve isn't spent, but below it requests wait unanswered. Fund about 90 LINK to allow for price changes (the faucet gives 25 at a time):
mise run sub:fund:sepolia 90 <account>
-
Deploy the raffle. This also adds it as a consumer of the subscription and verifies the source on Etherscan:
mise run deploy:sepolia <account>
If
ETHERSCAN_API_KEYis missing, the task stops before sending anything. Forge also submits the source to Sourcify. Etherscan can take several minutes to index a new contract; until it does, verification fails with "Could not detect deployment: Unable to locate ContractCode" or "Contract was not found", even though the deployment succeeded. Wait until Etherscan shows the contract's code, then verify it without redeploying:op run --env-file=.env -- forge verify-contract <raffle-address> src/Raffle.sol:Raffle --chain sepolia \ --rpc-url sepolia --guess-constructor-args --verifier etherscan --watch
-
Enter from any account:
op run --env-file=.env -- cast send <raffle-address> "enterRaffle()" --value 0.01ether \ --rpc-url sepolia --account <account>
-
Pick a winner once the interval has passed. The coordinator calls back a few blocks later:
mise run upkeep:sepolia <raffle-address> <account>
-
Withdraw the prize from the winning account. Check the amount first with
cast call <raffle-address> "getWinnings(address)(uint256)" <winner-address> --rpc-url sepolia:op run --env-file=.env -- cast send <raffle-address> "withdrawWinnings()" --rpc-url sepolia --account <account>
To add an existing raffle to the subscription again, run mise run sub:add-consumer:sepolia <raffle-address> <account>.
After replacing a raffle, remove the old one from the subscription:
op run --env-file=.env -- sh -c 'cast send 0x9DdfaCa8183c41ad55329BdeeD9F6A8d53168B1B \
"removeConsumer(uint256,address)" "$SEPOLIA_SUBSCRIPTION_ID" <old-raffle-address> \
--rpc-url "$SEPOLIA_RPC_URL" --account <account>'- A task seems stuck after sending. Forge may be waiting on transactions that have already failed. Look up each
hash from
broadcast/<script>/11155111/run-latest.jsonwithcast receipt <hash> --rpc-url sepolia(throughop run). Status 0 withgasUsedequal to the transaction's gas limit means it ran out of gas; see Gas on Sepolia. - "in-flight transaction limit reached for delegated accounts". Your account has an EIP-7702 delegation and Forge
sent several transactions at once. The tasks pass
--slowto prevent this; add it to anyforge scriptyou run by hand. - The raffle stays in
CALCULATINGand the winner is never picked. Check the subscription's balance against the reserve in step 2. A request waits until the subscription can cover it, then is answered within a block or two of funding it; normally the answer arrives one to two minutes afterperformUpkeep. - "Failed to decrypt keystore: incorrect password". Forge asks for the password after simulating, so nothing was sent. Run the task again.
Chainlink Automation is being retired in favour of the Chainlink Runtime Environment (CRE). Here a CRE workflow does its job:
- Every minute,
cre/raffle-upkeepreadscheckUpkeep. CRE reads at the last finalized block so that every node sees the same state; on Sepolia that is about 13 minutes behind the latest block. - If upkeep is needed, the workflow sends a signed report through Chainlink's forwarder to
RaffleUpkeepReceiver. - The receiver accepts reports only from that forwarder (and, once set, only from your workflow ID). It checks
checkUpkeepagain and callsperformUpkeep. A report that is out of date by the time it arrives is skipped instead of reverting.
So once the workflow is deployed and upkeep is due, a draw starts within about 15 minutes.
Deploying the workflow to Chainlink's network needs CRE deploy access: cre whoami shows whether you have it, and
cre account access requests it. It is also a paid Chainlink service, so check with Chainlink before deploying. This
project isn't deployed to CRE; draws are triggered with mise run upkeep:sepolia instead.
The CRE simulator is free and needs no deploy access, so you can still run the workflow yourself:
-
Deploy a receiver for your raffle that accepts reports from the simulator's forwarder:
mise run deploy:receiver:sepolia <raffle-address> simulation <account>
-
Put the raffle and receiver addresses in
cre/raffle-upkeep/config.sepolia.json. -
Dry run:
mise run cre:simulate:sepoliareads Sepolia and prepares the report without sending it. -
Send a report: add
CRE_ETH_PRIVATE_KEYto.env(a 1Password reference to a key with Sepolia ETH), then runmise run cre:broadcast:sepolia. Each run checks once; nothing is scheduled.
Once you have deploy access:
-
Deploy the workflow from
cre/, following Chainlink's deploying workflows guide:op run --env-file=../.env -- cre workflow deploy raffle-upkeep --target sepolia-settings
-
Switch the receiver to the production forwarder:
mise run receiver:forwarder:sepolia <receiver-address> production <account>
-
Restrict the receiver to your workflow, using the workflow ID the deploy printed:
op run --env-file=.env -- cast send <receiver-address> "setExpectedWorkflowId(bytes32)" <workflow-id> \ --rpc-url sepolia --account <account>
To test the whole path without sending anything to Sepolia, run it against a local Anvil fork of Sepolia. The script deploys a raffle and receiver on the fork, enters a player, runs the real CRE simulator, and checks that the report started a draw and that a second, stale report was skipped:
mise run cre:fork # in one terminal
mise run cre:e2e:fork # in anotherDeploying to a local Anvil node isn't supported. The local config has no subscription ID, and DeployRaffle only
creates one when nothing is broadcast, so locally the raffle runs only inside tests.
- Tasks:
misetasks instead of a Makefile, with secrets supplied by 1Password throughop runinstead of being stored in.env. - Upkeep: a Chainlink CRE workflow and
RaffleUpkeepReceiverinstead of registering the raffle with Chainlink Automation, which is being retired. ThePerformUpkeepscript remains as a manual fallback. - Local funding: no solmate or
LinkTokenmock. Locally, the mock coordinator'sfundSubscriptioncredits the subscription directly; only Sepolia uses LINK'stransferAndCall. - VRF coordinator calls: scripts call through Chainlink's
IVRFCoordinatorV2Plusinterface instead of the mock's type, and the mock is deployed withdeployCode. Chainlink pins the mock to Solidity 0.8.19, so this project's^0.8.27code can't import it. - Broadcasting account: read from
SEPOLIA_ACCOUNTin.envinstead of being hard-coded inHelperConfig, and checked before any script runs on Sepolia. - Adding a consumer:
AddConsumertakes the raffle address as an argument instead of finding the latest deployment withfoundry-devops, so nofs_permissionsare needed. - Payout: winners withdraw their prize with
withdrawWinningsinstead of being sent it byfulfillRandomWords(see Why winners withdraw their prize). - Resetting a round: the raffle counts the current round's players and overwrites earlier rounds' entries, instead of clearing the players array (see Gas on Sepolia).
- Tests: every script is tested, a two-round end-to-end test runs whole rounds with several players, and coverage is 100% for lines and branches. Tests that need the local VRF mock are reported as skipped on a fork rather than passing without running.
Sepolia activated the Glamsterdam fork on 6 October 2026. Its EIP-8037 makes creating contracts, accounts and storage several times more expensive, which affects this project in three ways:
- Forge underestimates gas. Forge 1.8 still simulates with Osaka gas costs, so its usual gas limit (130% of its
estimate) runs out of gas on Sepolia. The Sepolia tasks in
mise.tomlpass--gas-estimate-multiplier 1000; the limit is only a ceiling, so you pay only for the gas actually used. The tests also run with Osaka costs, so they can't show what a transaction costs on Sepolia; ask the node withcast estimateinstead. - The VRF callback must not grow with the number of players. Clearing the players array at the end of a round
cost about 12k gas per player on Sepolia, so with around 27 players
fulfillRandomWordswould exceed its 500k callback gas limit. VRF doesn't retry a failed callback, so the raffle would be stuck inCALCULATING. The raffle now keeps a player count instead: a round resets with a single write, and later rounds overwrite the old entries, which is also cheaper for players than creating new storage.test_CallbackGasDoesNotGrowWithPlayerschecks this. - CRE needs more gas.
performUpkeepalone needs about 300k gas on Sepolia, so the workflow'swriteGasLimitis 1,000,000.
The Sepolia tasks also pass --slow, so Forge waits for each transaction before sending the next. Nodes accept only
one pending transaction from an account with an EIP-7702 delegation (such as a MetaMask smart account), and without
--slow a script that sends several transactions fails with "in-flight transaction limit reached for delegated
accounts".
In the course version, fulfillRandomWords sends the prize straight to the winner and reverts if that send fails. A
contract with no receive or fallback function can enter the raffle, by mistake or on purpose. If it won, the
callback reverted, so the players, timestamp and state were never reset. The raffle then stayed in CALCULATING for
good: no one could enter, checkUpkeep never returned true again, and the prize was locked in the contract. VRF doesn't
retry a failed callback, so there was no way out.
This version uses the withdrawal pattern instead:
fulfillRandomWordsmakes no external call. It adds the round's prize to the winner's balance, whichgetWinnings(address)returns, and resets the round, so it can't be made to revert by the winner.- The winner calls
withdrawWinningsto collect everything they have won. If the payment fails, the call reverts and the balance is kept, so a winner that can't receive ETH loses only access to its own prize. - The current round's prize is tracked in its own variable (
getPrizePool()) instead of being read from the contract's balance, so unclaimed winnings from earlier rounds aren't paid out again or counted bycheckUpkeep.
test_WinnerThatRejectsEthDoesNotStopRaffle in test/unit/RaffleTest.t.sol shows that a winner that rejects ETH no
longer blocks the next round.