# Foundry lottery demo: implementation report Task: build a standalone Foundry lottery game demo with a reviewed deployment script for a future Sepolia run. This report covers the architecture, the test results, the deployment parameters, and the work that remains before any real-money launch. The previous attempt was rejected because it committed `artifacts/report.md` as a tracked file. This attempt keeps the report untracked: `artifacts/` is listed in `.gitignore` and is not part of the commit. ## Deliverables | Path | Purpose | | --- | --- | | `src/Lottery.sol` | Game contract: rounds, entries, draw request, fulfillment, claims, refunds, fees, pause, two-step ownership | | `src/interfaces/IRandomnessCoordinator.sol`, `src/interfaces/IRandomnessConsumer.sol` | Asynchronous randomness interface pair | | `src/mocks/MockRandomnessCoordinator.sol` | Explicitly labelled local/testnet mock; operator submits the random word by hand | | `script/DeploymentConfig.sol` | Per-chain deployment parameters as version-controlled Solidity; no env vars, no files | | `script/Deploy.s.sol` | Deployment script with pre-validation and post-deployment verification | | `test/*.t.sol`, `test/helpers/` | 119 tests: unit, fuzz, invariant, reentrancy, mock, deployment | | `foundry.toml` | `ffi = false`, `fs_permissions = []`, no custom compiler, auto-detected solc (`^0.8.24`), `release` profile pinned to 0.8.24 | | `lib/forge-std/` | forge-std v1.16.1 vendored as plain files (no submodule, no `.git`) | | `README.md` | Rules, permissions, transitions, local play-through with `cast`, deployment procedure, VRF integration design | ## Architecture ### Round state machine ``` Inactive --startRound()--> Open --closeRound()--> Drawing --rawFulfillRandomness()--> Settled | (0 tickets) | |---------------------> Cancelled | | '--expireRound()---------'--expireRound()--> Refunding ``` - **Entry**: `enter(quantity)` with `msg.value == quantity * ticketPrice` exactly, while `block.timestamp <= deadline`, within `maxTicketsPerRound`, and not paused. Each ticket is one storage slot and one chance in the draw. - **Closure**: permissionless once `block.timestamp > deadline`. With tickets, the lottery requests randomness from the coordinator and records `requestId -> roundId`; without tickets the round is Cancelled and no request is made. A reused request id reverts. - **Fulfillment**: only the immutable coordinator address may call `rawFulfillRandomness`. Unknown request ids revert (coordinator bug). Deliveries for rounds no longer in Drawing (duplicates, or deliveries after expiry) emit `FulfillmentIgnored` and change nothing, so the coordinator's transaction does not revert and settled state is never rewritten. The function is reentrancy guarded, which also rejects a coordinator that tries to fulfil synchronously inside the request. - **Winner**: `randomness % ticketCount`, holder of that ticket. `prize = pot - fee`, `fee = pot * feeBps / 10_000`, fee capped at 10% in the constructor. - **Timeout**: permissionless `expireRound` once `drawRequestedAt + fulfillmentTimeout` has passed (Drawing), or once `deadline + fulfillmentTimeout` has passed for a round that could not be closed (Open; e.g. the coordinator reverts every request). Result is Refunding, or Cancelled with no tickets. - **Pull payments**: `claimPrize(roundId, to)` by the winner, `refund(roundId, to)` by each entrant of a Refunding round, `withdrawFees(to)` by the owner. All are checks-effects-interactions plus a reentrancy lock; a failed transfer reverts and leaves the amount claimable. `to` allows contract winners without a payable fallback to collect elsewhere. - **Accounting**: `escrowedFunds` tracks every wei owed. `receive`/`fallback` revert, so `balance == escrowedFunds` unless ETH is force-sent. - **Admin**: `pause` blocks only `startRound` and `enter`. Closing, fulfillment, expiry, claims and refunds always work. Ownership transfer is two-step. No admin function moves user funds or changes parameters; price, durations, cap, fee and coordinator are immutable. ### Permissions summary | Actor | Can | | --- | --- | | Anyone | start a round, enter, close after the deadline, expire after the timeout | | Coordinator | deliver randomness | | Owner | pause / unpause inflows, withdraw fees, hand over ownership | | Mock operator | choose the random word (testnet stand-in for an oracle network) | ### Randomness `MockRandomnessCoordinator` is the only coordinator implementation in the repository. It is labelled in code and docs as local/testnet only: the operator picks the word, so the operator picks the winner. The production path is an adapter implementing `IRandomnessCoordinator` over Chainlink VRF v2.5 (design in the README): request restricted to the lottery, VRF request id passed through unchanged, `randomWords[0]` forwarded to `rawFulfillRandomness`, callback gas budget of at least 250k (settlement measured at roughly 140k inside a 189k mock fulfil transaction), and a `fulfillmentTimeout` of hours so refunds are a real failure path rather than a race with normal oracle latency. ## Verification results Run on macOS with forge 1.7.1 and locally cached solc 0.8.30 (auto-detected), offline (`--offline`). | Check | Result | | --- | --- | | `forge build --offline` | success, no warnings, no lint findings | | `forge test --offline` | 119 passed, 0 failed, 0 skipped across 9 suites | | `forge fmt --check` | clean | | `forge script script/Deploy.s.sol:DeployLottery --offline` | dry run succeeds on chain id 31337, deploys mock + lottery, no broadcast, no `broadcast/` directory created | | `forge coverage` | `src/Lottery.sol` 100% lines / 100% statements / 100% branches / 100% functions; `src/mocks/MockRandomnessCoordinator.sol` 100% on all four | Test suites: | Suite | Tests | Focus | | --- | --- | --- | | `LotteryEntryTest` | 17 | entry success, exact-payment fuzz, deadline boundary (accepted at `deadline`, rejected at `deadline + 1`), capacity, pause, wrong state, direct transfers rejected | | `LotteryRoundsTest` | 22 | start/close/expire and their exact boundaries, zero-ticket cancellation, coordinator reverting, synchronous fulfillment rejected (`ReentrantCall`), swallowed synchronous attempt leaves round Drawing, reused request id, expiry fuzz | | `LotteryFulfillmentTest` | 12 | coordinator-only, modulo winner selection (unit + fuzz), zero fee, duplicate delivery ignored, late delivery after expiry ignored and refunds continue, post-timeout delivery still settles if not expired, unknown request, cross-round isolation | | `LotteryClaimsTest` | 20 | claim/refund/fee success, non-winner, non-entrant, duplicates, wrong states, zero recipient, failed payments keep the amount claimable, fee accumulation, full-lifecycle conservation of funds | | `LotteryReentrancyTest` | 7 | re-entrant claim, refund, entry and fee withdrawal; both swallowed (paid exactly once) and propagated (whole call reverts, amount stays claimable) | | `LotteryAdminTest` | 21 | constructor validation of every parameter, pause blocks only inflows, two-step ownership, view helpers | | `LotteryInvariantsTest` | 6 | 48 runs x 40 calls, handler never reverts: balance == escrow == ghost ledger == per-round liabilities; prize + fee == pot; winner holds winning ticket; winner/prize never rewritten; states never regress; single active round; fee cap | | `MockRandomnessCoordinatorTest` | 7 | ids, operator-only, unknown/duplicate, consumer revert leaves request retryable, operator transfer | | `DeployTest` | 7 | Anvil config deploys and plays a round, Sepolia placeholders rejected, unsupported chain, existing coordinator mode, config validation, constructor error bubbling | A scratch driver (not delivered) ran the invariant handler for 3,000 pseudo-random calls and reached 57 rounds: 21 settled, 6 refunding, 29 cancelled, 16 prizes claimed, with balance equal to escrow at the end. The invariants are therefore exercised on all terminal states, not vacuously. Gas (from `forge test --gas-report`): `enter` 29k for one ticket up to 2.39M for 100 tickets in one call; `closeRound` 156k; settlement callback about 140k; `claimPrize` up to 70k; `refund` up to 91k; `expireRound` 33k; deployment about 1.89M gas for mock + lottery. ## Deployment parameters `script/DeploymentConfig.sol` is the single source of truth. Nothing is read from the environment. | Parameter | Anvil (31337) | Sepolia (11155111) | | --- | --- | --- | | `owner` | `0xf39F...2266` (Anvil account 0) | `address(0)` placeholder, script refuses to run | | `coordinator` | deploy `MockRandomnessCoordinator` | deploy `MockRandomnessCoordinator` | | `mockOperator` | Anvil account 0 | `address(0)` placeholder, script refuses to run | | `ticketPrice` | 0.01 ETH | 0.001 ETH | | `roundDuration` | 10 minutes | 1 day | | `maxTicketsPerRound` | 100 | 500 | | `fulfillmentTimeout` | 30 minutes | 6 hours | | `feeBps` | 500 | 500 | Procedure for the future Sepolia run (documented in the README, not executed here): fill in the two addresses and commit; dry-run with `FOUNDRY_PROFILE=release` (pinned solc 0.8.24) using a keystore account and no `--broadcast`; review; broadcast with `--verify`; commit the broadcast log; verify owner, coordinator, operator and parameters on the explorer; play one small round including a deliberate timeout. Not done in this assignment, by design: no transactions were broadcast, no funded wallet or private key was used, no Uniswap-specific `launch.json` was produced. ## Assumptions and operational responsibilities - Rounds are progressed by whoever acts first. The operator should run a keeper for `closeRound` and `expireRound`, but entrants can always do it themselves. - The mock operator must fulfil within `fulfillmentTimeout`; otherwise entrants will refund. - The owner withdraws fees; fees only accrue on settled rounds. - Timestamp comparisons are used for deadlines measured in minutes to days. The seconds of drift a validator can introduce do not matter, and no randomness is derived from block data. - The compiler is auto-detected from `pragma solidity ^0.8.24` so the offline verifier can use any cached solc at or above that version; `evm_version = "cancun"` is set explicitly so the choice does not depend on the forge version. ## Remaining work before a real-money launch 1. **Replace the mock with a verifiable randomness adapter** (Chainlink VRF v2.5 or equivalent), restricted to the lottery, with a funded subscription, a callback gas limit of at least 250k, and a `fulfillmentTimeout` of hours. Test the adapter against a VRF coordinator mock and on Sepolia. 2. **Independent adversarial security review.** Passing tests are not an audit. The contract would hold other people's funds. 3. **Multisig owner and, ideally, a timelock** for `pause` and `withdrawFees`; decide who holds the keeper role and how it is monitored. 4. **Decide the economics**: fee level, ticket price, round length, per-round cap versus block gas limits (per-ticket storage costs about 24k gas per ticket), and whether refunds should also cover a keeper incentive. 5. **Operational runbook**: monitoring for stuck Drawing rounds, alerting before `fulfillmentTimeout`, subscription balance alerts, and an incident procedure for pausing. 6. **Legal / jurisdictional review** of running a paid lottery at all. 7. **Sepolia rehearsal** using the documented procedure, including a deliberate timeout and refund, before any mainnet consideration.