Quintile Lottery — Internal Audit
Pre-deployment internal review of QuintileLottery V2. No high or critical findings. Five low-severity items, five informational. Source for this page is AUDIT.md in the contracts repo.
Scope
Reviewed: AbstractQuintileLottery.sol (628 lines), QuintileLotteryETH.sol (78 lines), SoulboundBadge.sol (154 lines), and the participant/donor badge subclasses. The first-generation v1 contracts are already frozen on Sepolia and were not re-audited.
Compiler: solc 0.8.24, evm cancun, optimizer 200 runs, bytecode_hash = "none". Test suite: 39/39 passing including a 1024-run fuzz invariant on the donation cap.
Method
Manual review by subsystem — architectural pass, then state-machine pass, then authorization, external calls, accounting, and liveness. Each of the eleven invariants from the test plan was either asserted with a forge test or proven by code inspection. Static analysis via Slither contributed zero new material findings beyond the manual report.
Findings
One Medium finding was downgraded to Low after re-examination of the engineering already in place. No High or Critical issues.
L-01 — Dead defensive check in setLottery
The badge's setLottery function had a LotteryAlreadySet() revert that was unreachable because the function atomically renounces admin on success, so any second call trips NotAdmin() first. Cosmetic. The check has been removed; the one-shot guarantee is now solely via the admin renounce.
L-02 — Unbounded epochDelay constructor parameter
The constructor validated epochDelay != 0 but not an upper bound. A pathologically large deploy value would overflow targetBlock = block.number + epochDelay * blockEpoch on every requestDraw, bricking the contract. Owner-trusted parameter at deploy, but a foot-gun. Fixed by adding _epochDelay > 100 rejection (100 epochs ≈ 10.7 hours — well past any reasonable production value).
L-03 — Self-ownership not rejected at deploy
The constructor rejected _owner == address(0) but not _owner == address(this). A buggy deploy script computing the lottery's own future address could leave the contract owning itself, which would strand withdrawFees via the contract's own receive() revert. Recoverable via transferOwnership but a sharp edge. Fixed by adding the self-ownership check.
L-04 — Whale-claim gas at canonical max scale unmeasured
For the canonical variant at full capacity (8,400 tickets, single whale claim), the refactored _processClaim is the optimal O(wTotal + indicesLen) shape, estimated at roughly 67k gas. The number is design-pass-estimated, not snapshot-measured. Recommendation: add deterministic forge gas-snapshot tests at n ∈ {100, 1000, 4200, 8400} and gate CI on forge snapshot --check. Carry-forward task; no blocker.
L-05 — RANDAO proposer-bias residual
This is the load-bearing security question for any on-chain lottery that wants to be permissionless and oracle-free. executeDraw reads block.prevrandao at the block the transaction lands in. A non-proposer attacker cannot meaningfully bias the seed because mempool inclusion timing is out of their control — they cannot lock in a specific block's seed. A validator-attacker who is also a ticket-holder and gets a proposer slot during the post-target window can spend their attestation reward (about $15–50 on mainnet) to nudge the seed by one proposer's worth of entropy. With an epochDelay × blockEpoch lockout of 256 slots (the canonical setting), the seed depends on a chain of eight proposers, making multi-validator collusion the practical attack surface.
This is the standard RANDAO acceptance for permissionless draws. The engineering already in place — the epochDelay lockout, the public-permissionless executeDraw that lets aligned non-attackers fire as soon as the window opens, and the requestDraw re-call gate that lets third parties reset the seed window if execution stalls — is the correct shape for a no-oracle design. The residual is accepted, documented, and quantifiable.
I-01 through I-05 — Informational
Caret pragmas instead of exact pin, the prevrandao-over-blockhash tradeoff, the hard-coded donor-badge threshold of one ticket cost, no event on rollover injection at round reset (added — RolloverInjected now emits), and slot-0 packing of owner / armed / drawCommitted as a deliberate gas optimization. None are correctness issues. Most are documented design choices.
Owner-cannot inventory
One of the cleanest things to verify in an audit is what the owner role cannot do. For the Quintile Lottery, owner powers are exhaustively: forceArm (only under strict stale-conditions — minimum tickets met, pot still below minPot, and at least one block-month since round start), transferOwnership, and withdrawFees. The owner cannot pause the contract, drain participant funds, alter the ticket price, alter the cap, alter the winner selection, alter the randomness source, alter historical claims, sweep unclaimed prizes, or upgrade the implementation. These are guaranteed by the absence of the corresponding functions — there is no proxy, no role manager, no admin escape hatch.
Checks-Effects-Interactions discipline
Every external call site was reviewed for reentrancy. Six call sites total: badge mints in buyTickets and donate (last), payment hooks in buyTickets and donate (before state mutation by design, so an ERC-20 transferFrom failure aborts cleanly), payout in claimPrize (last, after the claimed bitmap is updated — a reentrant claim returns zero and reverts NoPrize), and payout in withdrawFees (after protocolFees = 0 — a reentrant withdraw also reverts on zero). No reentrancy vulnerabilities.
Disposition
Four fixes shipped this pass: L-01 dead-code removal, L-02 epochDelay upper bound, L-03 self-ownership rejection, I-04 RolloverInjected event. Two carry-forward: L-04 gas snapshots at scale, I-01 exact-pin pragmas (deferred until after current Sepolia v2 cycle). Five informationals are either by-design or accepted.
An external audit covering the L-05 acceptance under target-TVL economics, the gas profile at canonical max scale, and a property-fuzz campaign translating the invariant set into Echidna predicates is the recommended next step before mainnet.
V3 Hardening Pass 2026-05-22
Second internal review pass covering the full V3 contract family: AbstractQuintileLottery, QuintileLotteryETH, QuintileLotteryERC20, EVLBadge, SoulboundBadge, QTST_v3, and BQTST. Three low-severity code fixes, three documented design residuals, 26 new checklist-driven tests. 120/120 passing.
Scope
This pass reviews all changes since the V2 audit. New surface: EVLBadge.sol — a soulbound ERC-721 + EIP-5192 credential for positive-EV round participation, using a registry pattern (mapping(address => bool) authorizedLottery) with one-shot renounceAdmin. QuintileLotteryERC20.sol — a generic ERC-20 denominated lottery subclass using SafeERC20, backing four deployed variants. QTST_v3 and BQTST — badge-gated ERC-20 utility tokens. Existing V2 findings are treated as resolved or carry-forward per the V2 disposition.
Test suite: 120/120 passing across seven files, including a new AuditChecklist.t.sol (26 tests) that directly encodes the low-severity and safety checklist items as executable assertions.
Fixes Shipped This Pass
H-01 — _safeMint removed from soulbound badges
SoulboundBadge.mintIfFirst and EVLBadge.mintIfFirst previously called _safeMint, which fires the onERC721Received callback on contract recipients. For a non-transferable token, this callback is never needed — no recipient contract needs to know it holds an SBT. The callback is pure attack surface: a malicious or vulnerable contract could re-enter the lottery at badge-mint time (badge mints are the last operations in buyTickets and donate, after all state transitions, but reentrancy into claimPrize from within a donation callback is a concern). Fixed by switching to _mint, which skips the callback entirely. Confirmed by test: a contract with no onERC721Received implementation receives the badge without reverting.
H-02 — Soulbound approve paths not blocked
SoulboundBadge and EVLBadge overrode safeTransferFrom to revert, but did not override approve or setApprovalForAll. A holder could call approve(spender, tokenId) to grant an approval that was unclearable but harmless only as long as the transfer overrides held. The approval state created a misleading on-chain record and could interact unexpectedly with future contract versions or off-chain tools expecting approvals to imply transferability. Fixed by adding overrides that revert with SoulboundNonTransferable on any approval attempt. All four ERC-721 transfer and approval entry points now revert consistently on both badge types.
L-01 (v3) — epochDelay minimum raised to 4
The constructor rejected epochDelay == 0 but allowed values 1, 2, and 3, which give commit lookaheads of 32, 64, and 96 blocks (~6, ~13, ~19 minutes). EIP-4399 recommends a minimum of four epochs (~25.6 minutes) to ensure the RANDAO commitment is contributed by at least four independent proposers before the seed is readable. A deployer who set epochDelay = 1 would produce a contract with a weaker RANDAO bound than intended. Fixed by changing the lower-bound check from _epochDelay == 0 to _epochDelay < 4. The canonical deployment uses epochDelay = 8 (51 minutes). Confirmed by test: values 0–3 revert, 4 and above are accepted up to the existing 100-epoch ceiling.
Documented Design Residuals
Three behaviors were flagged during this pass and confirmed as intentional design decisions. They are recorded here rather than fixed.
R-01 — RANDAO pushback grind window
executeDraw reads block.prevrandao from whichever block the caller's transaction lands in, not from a fixed targetBlock + 1. A validator who holds lottery tickets and gets a proposer slot during the post-target window can observe the seed their block would produce before deciding whether to include the executeDraw call. The bias quantified by Alpturer & Weinberg (2024) for a single proposer over a one-epoch window is small but non-zero. At the 42 ETH cap this attack has a negative expected return — attestation rewards forgone exceed expected winnings — and the public-permissionless executeDraw means any other participant can fire first. The residual is accepted at the current cap. Chainlink VRF migration is the recommended path if the pot cap is ever raised beyond roughly 100 ETH.
R-02 — currentPot() can exceed CAP in the tickets-first-then-donate path
donationRevenue <= DONATION_CAP is the enforced invariant, not currentPot() <= CAP. If the pot reaches its ticket-fill ceiling first, a subsequent maximum donation can push currentPot() slightly above CAP. The PotFull check on ticket purchases (currentPot() >= CAP) correctly blocks further tickets once this threshold is crossed. The donation overflow is bounded by DONATION_CAP and goes entirely to the prize pool, which is favourable to players. Confirmed as intended by fuzz test: donationRevenue never exceeds DONATION_CAP across all input combinations, but currentPot() can legally overshoot CAP.
R-03 — Fee-on-transfer tokens break ERC-20 lottery conservation
QuintileLotteryERC20._collectAmount transfers amount from the caller and credits amount to the pot without a balance-delta check. A fee-on-transfer token would cause the contract to credit more to the pot than it actually received, eventually making the pot insolvent when prizes are paid out. QTST_v3 and BQTST are both standard non-FoT ERC-20s, so this does not affect any deployed variant. Documented explicitly by a test that uses a synthetic 5%-fee token to demonstrate the conservation break. Anyone deploying a new ERC-20 lottery variant must verify the token is non-FoT or add a balance-delta intake check.
EVLBadge Registry Security
The EVL credential uses a registry pattern rather than a single fixed lottery address because multiple lottery variants must be able to mint it. The admin role (held by the deployer) can call addLottery to authorize new addresses and renounceAdmin to permanently freeze the registry. The deploy ceremony authorizes all six V3 lottery contracts and then calls renounceAdmin in the same broadcast, so the admin window is limited to the duration of the deploy transaction. After renounce, no new lotteries can ever be added: the authorized set is immutable. Confirmed by test: addLottery reverts after renounceAdmin.
The mintIfFirst function is idempotent — if the recipient already holds a badge, the call returns without minting. This means a user who participates in multiple positive-EV rounds and claims the badge for each round receives exactly one badge total, and any duplicate claim attempt is a no-op rather than a revert, which simplifies UI integration.
Checklist Coverage (AuditChecklist.t.sol)
The 26-test checklist file directly encodes the low-severity and safety items as forge assertions, providing a regression gate for future changes. Key invariants confirmed by test:
- epochDelay 0–3 reverts at construction; 4–100 accepted; 101 reverts
withdrawFeesreentrancy: a re-entrant attacker receives exactly one payout (state zeroed before the call)donationRevenue <= DONATION_CAPholds across all fuzz inputscurrentPot() > CAPis achievable in the tickets-first-then-donate path (documented, not a bug)- FoT token breaks conservation (documented limitation — test uses a synthetic 5%-fee token)
- Non-
ERC721Receivercontracts can receive soulbound badges (confirms_mintnot_safeMint) - Both
safeTransferFromoverloads revert on all SBT types approveandsetApprovalForAllrevert on all SBT types- Double prize-claim reverts
NoPrize - Re-entrant
claimPrizereceives nothing extra
Disposition
Three code fixes shipped: _safeMint → _mint in both badge types (H-01), approve/setApprovalForAll revert overrides added (H-02), epochDelay < 4 constructor rejection (L-01 v3). Three design residuals documented and accepted: RANDAO pushback grind (R-01), pot-can-exceed-CAP (R-02), FoT conservation break (R-03). No high or critical findings in the V3 additions.
Independent external audit is recommended before mainnet deployment. Priority items for an external auditor: the RANDAO acceptance argument under actual validator economics at the target cap, the EVLBadge registry trust model during the deploy ceremony, and a property-fuzz campaign on the ERC-20 lottery's intake/payout accounting.