Visitar URL original
GitHub - SonicWizard/foundry-smart-contract-lottery: Chainlink VRF raffle in Foundry, deployed on Sepolia. Fixes two ways the course raffle could lock up: a winner that rejects ETH, and a VRF callback that runs out of gas under Glamsterdam gas costs. · GitHub
Skip to content

About

Chainlink VRF raffle in Foundry, deployed on Sepolia. Fixes two ways the course raffle could lock up: a winner that rejects ETH, and a VRF callback that runs out of gas under Glamsterdam gas costs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Smart Contract Lottery

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.

How it works

  1. Players call enterRaffle and pay the entrance fee (0.01 ETH).
  2. Once the interval (30 seconds) has passed and the raffle has players, checkUpkeep returns true.
  3. performUpkeep can then be called. It closes entries (the raffle state becomes CALCULATING) and requests a random number from the VRF coordinator.
  4. The coordinator calls back fulfillRandomWords, which picks the winner, credits them with the round's prize and resets the raffle for the next round.
  5. The winner calls withdrawWinnings to 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

Requirements

  • Foundry
  • mise, which runs the project tasks in mise.toml (mise tasks lists 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 by mise run sub:create:sepolia
    • a Foundry keystore account (cast wallet import) with Sepolia ETH and LINK (faucet)

Setup

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 dependencies

Local 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.

Testing

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 workflow

On 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.

Deploying to Sepolia

Each task takes <account>, the name of your Foundry keystore account. It must hold the key for SEPOLIA_ACCOUNT, or Forge refuses to broadcast.

  1. Create a subscription, then set SEPOLIA_SUBSCRIPTION_ID in .env to 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.

  2. 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>
  3. 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_KEY is 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
  4. Enter from any account:

    op run --env-file=.env -- cast send <raffle-address> "enterRaffle()" --value 0.01ether \
      --rpc-url sepolia --account <account>
  5. Pick a winner once the interval has passed. The coordinator calls back a few blocks later:

    mise run upkeep:sepolia <raffle-address> <account>
  6. 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>'

Troubleshooting

  • 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.json with cast receipt <hash> --rpc-url sepolia (through op run). Status 0 with gasUsed equal 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 --slow to prevent this; add it to any forge script you run by hand.
  • The raffle stays in CALCULATING and 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 after performUpkeep.
  • "Failed to decrypt keystore: incorrect password". Forge asks for the password after simulating, so nothing was sent. Run the task again.

Automating upkeep with Chainlink CRE

Chainlink Automation is being retired in favour of the Chainlink Runtime Environment (CRE). Here a CRE workflow does its job:

  1. Every minute, cre/raffle-upkeep reads checkUpkeep. 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.
  2. If upkeep is needed, the workflow sends a signed report through Chainlink's forwarder to RaffleUpkeepReceiver.
  3. The receiver accepts reports only from that forwarder (and, once set, only from your workflow ID). It checks checkUpkeep again and calls performUpkeep. 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:

  1. Deploy a receiver for your raffle that accepts reports from the simulator's forwarder:

    mise run deploy:receiver:sepolia <raffle-address> simulation <account>
  2. Put the raffle and receiver addresses in cre/raffle-upkeep/config.sepolia.json.

  3. Dry run: mise run cre:simulate:sepolia reads Sepolia and prepares the report without sending it.

  4. Send a report: add CRE_ETH_PRIVATE_KEY to .env (a 1Password reference to a key with Sepolia ETH), then run mise run cre:broadcast:sepolia. Each run checks once; nothing is scheduled.

Once you have deploy access:

  1. Deploy the workflow from cre/, following Chainlink's deploying workflows guide:

    op run --env-file=../.env -- cre workflow deploy raffle-upkeep --target sepolia-settings
  2. Switch the receiver to the production forwarder:

    mise run receiver:forwarder:sepolia <receiver-address> production <account>
  3. 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 another

Deploying locally

Deploying 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.

Differences from the course

  • Tasks: mise tasks instead of a Makefile, with secrets supplied by 1Password through op run instead of being stored in .env.
  • Upkeep: a Chainlink CRE workflow and RaffleUpkeepReceiver instead of registering the raffle with Chainlink Automation, which is being retired. The PerformUpkeep script remains as a manual fallback.
  • Local funding: no solmate or LinkToken mock. Locally, the mock coordinator's fundSubscription credits the subscription directly; only Sepolia uses LINK's transferAndCall.
  • VRF coordinator calls: scripts call through Chainlink's IVRFCoordinatorV2Plus interface instead of the mock's type, and the mock is deployed with deployCode. Chainlink pins the mock to Solidity 0.8.19, so this project's ^0.8.27 code can't import it.
  • Broadcasting account: read from SEPOLIA_ACCOUNT in .env instead of being hard-coded in HelperConfig, and checked before any script runs on Sepolia.
  • Adding a consumer: AddConsumer takes the raffle address as an argument instead of finding the latest deployment with foundry-devops, so no fs_permissions are needed.
  • Payout: winners withdraw their prize with withdrawWinnings instead of being sent it by fulfillRandomWords (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.

Gas on Sepolia

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.toml pass --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 with cast estimate instead.
  • 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 fulfillRandomWords would exceed its 500k callback gas limit. VRF doesn't retry a failed callback, so the raffle would be stuck in CALCULATING. 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_CallbackGasDoesNotGrowWithPlayers checks this.
  • CRE needs more gas. performUpkeep alone needs about 300k gas on Sepolia, so the workflow's writeGasLimit is 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".

Why winners withdraw their prize

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:

  • fulfillRandomWords makes no external call. It adds the round's prize to the winner's balance, which getWinnings(address) returns, and resets the round, so it can't be made to revert by the winner.
  • The winner calls withdrawWinnings to 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 by checkUpkeep.

test_WinnerThatRejectsEthDoesNotStopRaffle in test/unit/RaffleTest.t.sol shows that a winner that rejects ETH no longer blocks the next round.

About

Chainlink VRF raffle in Foundry, deployed on Sepolia. Fixes two ways the course raffle could lock up: a winner that rejects ETH, and a VRF callback that runs out of gas under Glamsterdam gas costs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages