{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"42b4b3b5-4c53-4368-aa49-ffdd71222d33","kind":"audit","nodes":[{"acceptedSubmissionHash":"429f5691d69b240ce64fe8451b7d5a2733ba2b6fb54ce003f545f7fa6dcbe257","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"1b12f670e4e028e4381b1cd31a4039b734b51d11a8be10e0f0607633d1e6e93f","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"495751a13932e981795516258b06bda366c6265783245f2a45d46cb8d89585dd","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"d8d330c43b8e242974130dfa1bf29ca5732f384ae30dc9561a5f3f8c9d117041","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"04a2addf8c7f2575064f23f208ccb0c9788db13034da49f3c79756f3bffc204f","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit the Tombola draw contracts in this repository: src/TombolaDraw.sol and src/TombolaSplitter.sol are the new code and the focus; src/DrandBeacon.sol (drand evmnet BLS verification on BN254, via the vendored randa-mu library under vendor/bls-solidity) is reused from an earlier, already reviewed design and is in scope only as the dependency the draw relies on. Solidity 0.8.26, via_ir, cancun. README.md and DESIGN.md describe the system; test/TombolaDraw.t.sol has 19 passing tests including a real evmnet round from test/fixtures/evmnet.json.\n\nSystem. Tombola is a lottery token launched on Pons V2 (Robinhood Chain). Pons charges a creator tax on every trade and accrues it in ETH in its fee escrow for the creatorFeeRecipient, which is TombolaSplitter. Anyone calls TombolaSplitter.harvest(): it claims the escrow and splits the ETH 7333 / 2000 / 667 bps between TombolaDraw.fundRegular(), TombolaDraw.fundMega() and a plain send to the team fee wallet (rounding dust to the team). TombolaDraw holds the two pots and the draw ledger. A keeper (hot wallet, immutable address) calls commit(track, snapshotTime, drandRound, amount): it reserves amount from the track's pot and commits to a FUTURE drand round (beacon.roundTime(round) >= snapshotTime + 10 minutes, round not yet proven on chain, snapshotTime <= block.timestamp, one open draw per track). Off chain the keeper rebuilds the eligibility list from the token's Transfer logs at the snapshot block (weightFor: balance >= 10,000e18, held 30 minutes, 1x to 3x over 72 h), publishes it, and calls settle(id, drandSignature, root, totalWeight, winner, cumulativeBefore, weight, proof): the beacon verifies the BLS signature and stores keccak256(signature); randomWord = keccak256(abi.encode(randomness, id)); the leaf keccak256(bytes.concat(keccak256(abi.encode(winner, cumulativeBefore, weight)))) must verify against root (sorted-pair Merkle); weight > 0 and cumulativeBefore + weight <= totalWeight; randomWord % totalWeight must fall in [cumulativeBefore, cumulativeBefore + weight). The prize is sent with a 50,000 gas stipend; a failed send is credited to unclaimed[winner] and claim(to) pays it later. rollover(id, signature, root) returns the prize to the pot when nobody was eligible. A guardian (cold wallet) may cancel(id) a committed, unsettled draw; after 7 days past the round's time anyone may. solvent() states the invariant balance >= potRegular + potMega + reserved + unclaimedTotal.\n\nTrust model, so findings are ranked against it. The contracts are meant to enforce: (1) the drand round was committed before anyone could know it; (2) the randomness is drand's and the winner index is randomWord mod totalWeight over the pinned list; (3) the winner is in the pinned list with the claimed weight and the index falls in its range; (4) ETH only leaves as a settled prize to the proven winner, the team's share from the splitter, or back into a pot on rollover/cancel. The keeper IS trusted to build an honest, complete list with honest weights: that is verified off chain from public data, not by the contract, and is NOT a finding. The keeper choosing when to commit (and so which future round) is accepted. No owner, no upgrade, no withdraw exists by design.\n\nPlease focus on: (a) pot solvency and accounting: potRegular / potMega / reserved / unclaimedTotal across commit, settle, rollover, cancel, claim, fundRegular/fundMega/receive, including uint128 amount truncation in commit and any path that breaks solvent(); (b) the commit-before-randomness guarantee: can the keeper or anyone commit to a round whose signature is already public or predictable (COMMIT_DELAY vs drand's 3 s period and clock skew, roundTime arithmetic, the randomness(round) != 0 check, snapshotTime far in the past), and whether settle/rollover could use a different round than committed; (c) the Merkle leaf and winner-range checks: leaf encoding collisions, a proof for a leaf that is not a leaf (intermediate node, duplicated last leaf padding), root == 0, totalWeight inconsistent with the tree, overlapping or gapped cumulative ranges letting the keeper pick between two winners, weight == 0 leaves; (d) re-entrancy and ordering: nonReentrant on settle/rollover/cancel/claim, _pay before/after state, the 50,000 gas stipend and return bomb, the splitter's harvest re-entering through the escrow or the team wallet, the draw's receive() being called by the splitter during harvest; (e) the guardian / cancel / abandon paths: can the guardian or the 7-day anyone-cancel be used to grief a settled or settlable draw, re-cancel, double-return, or lock a track (activeDraw) permanently; what happens with guardian == address(0); (f) griefing: dust amounts, failed sends to the team wallet reverting harvest, a winner contract that reverts, claim(to) by third parties, pots stuck forever, a keeper that never settles; (g) anything that lets the keeper, the guardian, the splitter, the team wallet or any third party take ETH that is not a settled prize or the team's bps share, or move ETH between pots. Also note any mismatch between what the README/DESIGN claim the contract enforces and what the code enforces.\n\nFor each finding give the location, the concrete sequence of calls, and the impact in ETH terms; a Foundry reproduction against test/TombolaDraw.t.sol's setUp is welcome. Please classify what is informational (accepted trust in the keeper) separately from what is exploitable.\n\nDeployed on Robinhood Chain (4663) from this commit: TombolaDraw 0xC24C89e8Eb078902e5f527A5402631b4c12D237A, TombolaSplitter 0x3A065C0ac5477B556573f454f83EE2aE4184281f.","parentJobId":null,"planHash":"985ea353a5ed88de2b32d54d66c82fce157e732a4da262f806b505422c87d48b","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"42b4b3b5-4c53-4368-aa49-ffdd71222d33","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51368","feedbackHash":"992b16196b1195a1d5edc071f8f11a4dbaec7b265060404c2d5ec17e9d6ddfc5","nodeKey":"audit_economics","submissionHash":"429f5691d69b240ce64fe8451b7d5a2733ba2b6fb54ce003f545f7fa6dcbe257","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52673","feedbackHash":"4b9ceee2df4deb6bec362a535db0fc7f4565ae2ed7bbb51a072b8dc6a4534973","nodeKey":"audit_flow","submissionHash":"1b12f670e4e028e4381b1cd31a4039b734b51d11a8be10e0f0607633d1e6e93f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"54236","feedbackHash":"9e01a47c3e86868e58b135ef4101d5326b138e6b45d41745da46b130e37dab55","nodeKey":"audit_judge","submissionHash":"495751a13932e981795516258b06bda366c6265783245f2a45d46cb8d89585dd","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52616","feedbackHash":"a1c18f60db0aab768c06d60e9919231cf69b5854c461933bd9e56e9256aff4aa","nodeKey":"audit_math","submissionHash":"d8d330c43b8e242974130dfa1bf29ca5732f384ae30dc9561a5f3f8c9d117041","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52708","feedbackHash":"41cf52ef4d21910c1f9e0e6e21e15f16d4e2c0fabc99e714cf3ae37b540dfca6","nodeKey":"audit_permissions","submissionHash":"04a2addf8c7f2575064f23f208ccb0c9788db13034da49f3c79756f3bffc204f","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"e33f724d0a64af838d8144f93b200a076b5f70af076417977f7b35d50382da4b","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d021901979a78997","findings":[{"citation":"resolved","description":"The trust model says the contract enforces that 'the winner index is randomWord mod totalWeight over the pinned list'. It does not: `root` pins each leaf (winner, cumulativeBefore, weight) but nothing pins the list's total. `totalWeight` is a free calldata argument checked only against the single leaf (`cumulativeBefore + weight <= totalWeight`). For any listed leaf with range [c, c+w) the keeper, once drand has published the round and `word` is known, chooses T = word - c (always > word/2 for a 256-bit word, so word % T == word - T == c, and T >= c + w holds). Signature, Merkle proof, weight bounds and the range check all pass and the whole prize is sent to the keeper's chosen wallet while the published list is honest and the pinned root is correct. The same freedom covers gapped or overlapping cumulative ranges, which the contract also cannot see. The only thing that reveals the fraud is the on-chain `totalWeight` disagreeing with the sum of the published list, which the README's verifier step 7 recomputes; a reader checking just the winner's leaf proof, range and signature against the on-chain draw sees a valid draw. This is a contract-level gap in guarantee (2), distinct from the accepted 'keeper publishes a dishonest list' trust (here the list is honest). Fix: bind the total to the tree, e.g. leaf = keccak256(bytes.concat(keccak256(abi.encode(winner, cumulativeBefore, weight, totalWeight)))) or root' = keccak256(abi.encode(treeRoot, totalWeight)); stronger, add a keeper `pin(id, root, totalWeight)` step required before `beacon.roundTime(drandRound)` (the snapshot block is already <= the commit block, so the list exists at commit time), which makes guarantees (2) and (3) hold against a compromised keeper rather than against off-chain re-verification only.","line":178,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\n\n/// Finding: settle() takes `totalWeight` as a free parameter. The Merkle root pins (winner, cumulativeBefore, weight)\n/// per leaf but nothing pins the list's total, so after drand publishes the round the keeper may pick ANY leaf of\n/// the honestly published list and choose a totalWeight that makes randomWord % totalWeight land in that leaf's\n/// range. Every on-chain check (signature, proof, weights, range) passes.\n///\n/// This test fails on the current code (the keeper pays a leaf that is NOT the randomWord % realTotal winner)\n/// and passes once totalWeight is bound to the pinned list (e.g. folded into every leaf, or pinned with the root\n/// before the round time), because the forged settle then reverts.\ncontract TotalWeightUnboundTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    address keeper = makeAddr(\"keeper\");\n    address guardian = makeAddr(\"guardian\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n\n    uint64 ROUND;\n    bytes SIG;\n\n    function setUp() public {\n        string memory json = vm.readFile(\"test/fixtures/evmnet.json\");\n        ROUND = uint64(vm.parseJsonUint(json, \".rounds[0].round\"));\n        SIG = vm.parseJsonBytes(json, \".rounds[0].signature\");\n        beacon = new DrandBeacon();\n        draw = new TombolaDraw(beacon, keeper, guardian, makeAddr(\"splitter\"));\n        vm.warp(beacon.roundTime(ROUND) - 20 minutes);\n        vm.deal(address(this), 1 ether);\n        draw.fundRegular{value: 1 ether}();\n    }\n\n    function _leaf(address a, uint256 cum, uint256 w) internal pure returns (bytes32) {\n        return keccak256(bytes.concat(keccak256(abi.encode(a, cum, w))));\n    }\n\n    function _pair(bytes32 a, bytes32 b) internal pure returns (bytes32) {\n        return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a));\n    }\n\n    function test_keeperPicksAnyListedWinnerByChoosingTotalWeight() public {\n        // the honest list: alice [0,100e18), bob [100e18,350e18), carol [350e18,400e18); real total 400e18\n        uint256 realTotal = 400e18;\n        address[3] memory a = [alice, bob, carol];\n        uint256[3] memory cum = [uint256(0), 100e18, 350e18];\n        uint256[3] memory w = [uint256(100e18), 250e18, 50e18];\n        bytes32[4] memory leaves = [_leaf(a[0], cum[0], w[0]), _leaf(a[1], cum[1], w[1]), _leaf(a[2], cum[2], w[2]), _leaf(a[2], cum[2], w[2])];\n        bytes32 root = _pair(_pair(leaves[0], leaves[1]), _pair(leaves[2], leaves[3]));\n\n        vm.prank(keeper);\n        uint256 id = draw.commit(TombolaDraw.Track.Regular, uint64(block.timestamp), ROUND, 1 ether);\n        vm.warp(beacon.roundTime(ROUND) + 1);\n\n        // the honest winner is randomWord % realTotal\n        uint256 word = uint256(keccak256(abi.encode(keccak256(SIG), id)));\n        uint256 honestTarget = word % realTotal;\n        uint256 honest = 0;\n        for (uint256 i = 0; i < 3; i++) if (honestTarget >= cum[i] && honestTarget < cum[i] + w[i]) honest = i;\n\n        // the keeper picks a DIFFERENT listed wallet and chooses totalWeight so the word lands in its range:\n        // for T in (word/2, word], word % T == word - T, so T = word - cum[pick] gives target == cum[pick]\n        uint256 pick = (honest + 1) % 3;\n        uint256 forgedTotal = word - cum[pick];\n        assertGe(forgedTotal, cum[pick] + w[pick]);\n        assertEq(word % forgedTotal, cum[pick]);\n\n        bytes32[] memory proof = new bytes32[](2);\n        proof[0] = leaves[pick ^ 1];\n        proof[1] = pick < 2 ? _pair(leaves[2], leaves[3]) : _pair(leaves[0], leaves[1]);\n\n        uint256 before = a[pick].balance;\n        vm.prank(keeper);\n        // EXPECTED (fixed code): the contract rejects a totalWeight the pinned list does not carry\n        vm.expectRevert();\n        draw.settle(id, SIG, root, forgedTotal, a[pick], cum[pick], w[pick], proof);\n        // ACTUAL (current code): the call succeeds and the non-winner is paid the whole prize\n        assertEq(a[pick].balance, before, \"a wallet that did not win must not be paid\");\n        assertEq(draw.getDraw(id).winner, address(0));\n    }\n}","reproduction":"setUp as in test/TombolaDraw.t.sol. Honest list alice [0,100e18), bob [100e18,350e18), carol [350e18,400e18), real total 400e18, root built as the README describes. keeper.commit(Regular, now, ROUND, 1 ether); warp past roundTime(ROUND); word = uint256(keccak256(abi.encode(keccak256(SIG), id))); honest winner is the leaf containing word % 400e18. Keeper picks a different leaf `pick`, sets forgedTotal = word - cum[pick] and calls settle(id, SIG, root, forgedTotal, a[pick], cum[pick], w[pick], proof). Expected: revert (total not the pinned list's). Actual: succeeds, Settled(id, a[pick], 1 ether, root, forgedTotal, word) and 1 ether paid to a[pick]. test/scratch/TotalWeightUnbound.t.sol fails on current code with 'next call did not revert as expected'.","severity":"high","snippet":"        uint256 target = word % totalWeight;","title":"settle(): totalWeight is not bound to the pinned root, so the keeper can choose the winner after the randomness is known"},{"citation":"resolved","description":"README promises 'Seven days after the round's time anyone may [cancel], so a dead keeper can't lock a pot'. The abandon clock runs from roundTime(drandRound), and commit() only checks roundTime > block.timestamp with no ceiling. A keeper (the hot wallet the guardian exists to backstop) that commits `amount = pot` to drandRound = type(uint64).max (roundTime ~ 5.5e19 s) puts the whole pot in `reserved`, makes the track permanently TrackBusy, and `late` can never become true. Nobody can settle or rollover (drand will never sign that round). With guardian == address(0), which script/DeployPons.s.sol documents as a supported configuration ('GUARDIAN (0 for none)'), the ETH is locked forever; with a guardian the pot is frozen until the cold wallet intervenes. Both tracks can be frozen this way in two transactions. The deployed instance (chain 4663, 0xC24C...237A) has a non-zero guardian, so there it is a guardian-recoverable freeze. Fix: bound the horizon in commit(), e.g. `if (roundTime > block.timestamp + MAX_LEAD) revert RoundTooLate();` with MAX_LEAD around 1 day, and/or make the abandon clock `max(roundTime, commitTime) + ABANDON_AFTER` using a stored commit timestamp.","line":214,"path":"src/TombolaDraw.sol","reproduction":"new TombolaDraw(beacon, keeper, address(0), splitter); fundRegular{value: 5 ether}(); keeper.commit(Regular, uint64(block.timestamp), type(uint64).max, 5 ether) succeeds (reserved = 5 ether, potRegular = 0). warp +3650 days; cancel(id) from anyone reverts NotGuardian; keeper.commit(Regular, ...) reverts TrackBusy. Expected per README: anyone can cancel 7 days after the round time. Actual: the 5 ether and the Regular track are locked forever. See test/scratch/CommitBounds.t.sol::test_farFutureRoundLocksPotForeverWithoutGuardian (passes on current code, i.e. the lock is reproduced).","severity":"medium","snippet":"        bool late = block.timestamp >= beacon.roundTime(d.drandRound) + ABANDON_AFTER;","title":"commit(): no upper bound on drandRound, so a far-future round disables the 7-day anyone-cancel path and can lock a pot and its track"},{"citation":"resolved","description":"COMMIT_DELAY (10 minutes) is applied only between snapshotTime and the round; between the commit block and the round the only requirement is roundTime > block.timestamp. At block.timestamp == roundTime - 1 the commit is accepted although drand publishes the round's signature 1 second later. Robinhood Chain is an Arbitrum-style L2 whose block.timestamp is set by the sequencer and may trail wall-clock time by seconds; drand evmnet signs every 3 seconds. Any lag of a few seconds therefore lets the keeper commit to a round whose signature is already on api.drand.sh, which defeats guarantee (1) (the check `beacon.randomness(round) != 0` only covers rounds someone already proved on chain). With the High finding fixed this becomes the remaining way for a keeper to know the word before committing. Fix: require `roundTime >= block.timestamp + COMMIT_DELAY` (or at least a margin of a few minutes) in addition to the snapshot-based bound.","line":128,"path":"src/TombolaDraw.sol","reproduction":"setUp as in test/TombolaDraw.t.sol; vm.warp(beacon.roundTime(ROUND) - 1); keeper.commit(Regular, uint64(roundTime - 11 minutes), ROUND, 1 ether). Expected: RoundTooEarly (round publishes within the clock-skew window). Actual: accepted, draw committed to ROUND. See test/scratch/CommitBounds.t.sol::test_commitAcceptedOneSecondBeforeRoundTime.","severity":"low","snippet":"        if (roundTime <= block.timestamp) revert RoundTooEarly(); // the round must still be in the future","title":"commit(): the 'round must still be in the future' check has zero margin against L2 timestamp skew and drand's 3 s period"},{"citation":"resolved","description":"Only `snapshotTime <= block.timestamp` is enforced. The eligibility list is a deterministic function of snapshotTime, so a keeper may name any past instant, including one minute after launch when a single wallet it controls was the only holder past the 30-minute gate, and the honest list for that snapshot contains only that wallet. The randomness still cannot be predicted, but it no longer matters whom it selects. This goes beyond the accepted 'keeper chooses when to commit' (which fixes the round, not the list) and is visible on chain only after the fact; the guardian has the COMMIT_DELAY window to react. Fix: require snapshotTime within a window of the commit block (e.g. `block.timestamp - snapshotTime <= 1 hours`) and/or strictly greater than the previous draw's snapshotTime on the track.","line":125,"path":"src/TombolaDraw.sol","reproduction":"setUp as in test/TombolaDraw.t.sol; keeper.commit(Regular, 1, ROUND, 1 ether) (snapshotTime = 1, i.e. 1970). Expected: rejected as outside any plausible draw slot. Actual: accepted, getDraw(id).snapshotTime == 1. See test/scratch/CommitBounds.t.sol::test_snapshotTimeMayBeArbitrarilyOld.","severity":"low","snippet":"        if (snapshotTime > block.timestamp) revert WrongStage();","title":"commit(): snapshotTime has no lower bound, so the keeper can pin a historical holder set of its choice"},{"citation":"resolved","description":"README step 7 says 'An empty list pins keccak256(\"\")' and rollover is described as the path for 'nobody eligible'. The contract stores whatever `root` the keeper passes and never checks it, so a keeper can roll over a draw whose published list is non-empty (denying the drand-selected winner and returning the prize to the pot). No ETH leaves the contract, so this is keeper-trust territory and detectable off chain, but it is a README/code mismatch. Fix: `if (root != keccak256(\"\")) revert BadWeights();` in rollover, so the on-chain record itself says the list was empty.","line":201,"path":"src/TombolaDraw.sol","reproduction":"keeper.commit(Regular, now, ROUND, 1 ether); warp past roundTime(ROUND); keeper.rollover(id, SIG, keccak256(\"not empty\")). Expected per README: only the empty-list root keccak256(\"\") is accepted. Actual: accepted, draw settled with winner == address(0), 1 ether back in potRegular (this is exactly test_rolloverReturnsThePot with root keccak256(\"empty list\")).","severity":"info","snippet":"        d.root = root;","title":"rollover(): any root is accepted; the contract does not require the empty-list root the README says is pinned"},{"citation":"resolved","description":"Accepted design, recorded as a trust assumption: once drand has published the committed round and the keeper has published the list, the winner is public knowledge, yet the draw remains cancellable by the guardian at any time before settle, and by anyone from roundTime + 7 days. A guardian can therefore deny a specific winner (prize back to the pot, redrawn later under a new round), and a keeper that waits 7 days lets any third party front-run its settle with cancel. No ETH leaves the system. If this is not intended, restrict guardian cancel to before roundTime (when no winner exists yet) and let the late path stand.","line":215,"path":"src/TombolaDraw.sol","reproduction":"keeper.commit(Regular, now, ROUND, 1 ether); warp roundTime(ROUND) + 1 (signature public, winner computable); guardian.cancel(id) succeeds, potRegular back to 1 ether, the drand-selected wallet is never paid.","severity":"info","snippet":"        if (msg.sender != guardian && !late) revert NotGuardian();","title":"cancel(): the guardian (and anyone after 7 days) can cancel a draw whose winner is already determined"},{"citation":"resolved","description":"DESIGN.md line 31 documents `settle(id, winner, amount, snapshotRoot, drandSignature)` and line 38 `rollover(id)`; the code is `settle(id, drandSignature, root, totalWeight, winner, cumulativeBefore, weight, proof)` and `rollover(id, drandSignature, root)`, and `amount` is fixed at commit, not passed at settle. README line 56 claims a dead keeper cannot lock a pot (see the far-future-round finding) and README line 35 says 'nothing stays in the splitter' while `teamOwed` can hold the team share indefinitely (test_harvestStillFundsThePotsWhenTheTeamWalletRejects shows it). Update the documents to the shipped interface and qualify the two claims.","line":31,"path":"DESIGN.md","reproduction":"Compare DESIGN.md:31 and :38 with src/TombolaDraw.sol:158-167 and :195; compare README.md:35 with src/TombolaSplitter.sol:84 (teamOwed += team).","severity":"info","snippet":"  - `settle(id, winner, amount, snapshotRoot, drandSignature)` by the keeper: the beacon verifies the signature","title":"DESIGN.md describes settle/rollover signatures and promises that the code does not have"}],"hash":"04a2addf8c7f2575064f23f208ccb0c9788db13034da49f3c79756f3bffc204f","nodeId":"ff1d9144-b0a7-43ad-8e14-bf21bd15d967","outcome":"completed","summary":"The review is complete. The findings file at `.imd-findings.json` holds 7 entries, every snippet and line verified against the tree, and the one proof test fails on the current code as required.\n\n**What I found**\n\n- **High, `settle()` at `src/TombolaDraw.sol:178`.** The Merkle root pins each leaf but nothing pins the list's total, and `totalWeight` is free calldata checked only against the one leaf. After drand publishes the round the keeper can pick any listed wallet and set `totalWeight = word - cumulativeBefore`, which makes the modulo land exactly on that wallet. Signature, proof, bounds and range checks all pass and the full prize goes to the chosen wallet while the published list is honest. This breaks guarantee (2) at the contract level and is only catchable by a verifier that recomputes the sum. Proof test under `test/scratch/TotalWeightUnbound.t.sol` fails on the current code. Fix: fold `totalWeight` into the leaf or root hash, or add a keeper `pin(id, root, totalWeight)` step required before the round time.\n- **Medium, `commit()` has no upper bound on the round.** Committing the whole pot to round 2^64-1 makes the 7-day anyone-cancel clock unreachable and the track permanently busy. With no guardian the ETH is locked forever. The deployed instance on Robinhood Chain has a guardian set, so there it is a guardian-recoverable freeze. Fix: cap how far ahead the round may be.\n- **Low, zero-margin future check.** A commit at one second before the round time is accepted, so any L2 timestamp lag lets the keeper commit to an already-published 3-second round.\n- **Low, `snapshotTime` has no lower bound.** The keeper can name a snapshot from right after launch, when its own wallet may be the only eligible holder.\n- **Info.** `rollover()` accepts any root rather than the empty-list root the README promises. The guardian and the 7-day path can cancel a draw whose winner is already public. DESIGN.md documents settle and rollover signatures that do not match the code, and README's \"dead keeper can't lock a pot\" and \"nothing stays in the splitter\" claims are not fully true.\n\n**What checked out clean**\n\nPot accounting conserves every wei across commit, settle, rollover, cancel, claim and funding, with no path breaking `solvent()`. The uint128 amount is guarded, not truncated. Leaves hash 32 bytes and nodes 64 bytes, so no intermediate-node forgery. Reentrancy guards and state-before-pay ordering hold. The beacon rejects non-canonical signature encodings, so the randomness is unique per round. The deployed draw and splitter reference each other correctly, use the documented beacon, and the Pons escrow answers `balanceOf` and exposes `claim`. The existing 21 tests pass.\n\n**Not covered**\n\nI did not re-audit the BLS library or the beacon's pairing arithmetic beyond its encoding checks, per the scope. The L2 timestamp-skew finding names a concrete accepted input but I could not measure actual Robinhood Chain sequencer lag.","treeHash":null,"usage":{"cachedInputTokens":555564,"inputTokens":226,"model":"claude-fable-5-1","outputTokens":29105,"runtime":"claude","turns":18,"wallClockMs":399578}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"476778ae63dff113","findings":[{"citation":"resolved","description":"rollover(id, sig, root) is meant only for 'nobody eligible' (README: an empty list pins keccak256(\"\")), but it stores any root without checking it. The keeper calls it after drand's randomness for the committed round is public, so they can see who would win and, if they don't like the result, roll the pot back and commit again for a new round. That breaks guarantees (1) and (2): the keeper effectively gets to re-draw. Off-chain checks only spot it afterwards. Fix: require(root == keccak256(\"\")) in rollover, which is what the README documents.","line":201,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\n\ncontract RolloverRerollTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    address keeper = makeAddr(\"keeper\");\n    uint64 ROUND;\n    bytes SIG;\n\n    function setUp() public {\n        string memory json = vm.readFile(\"test/fixtures/evmnet.json\");\n        ROUND = uint64(vm.parseJsonUint(json, \".rounds[0].round\"));\n        SIG = vm.parseJsonBytes(json, \".rounds[0].signature\");\n        beacon = new DrandBeacon();\n        draw = new TombolaDraw(beacon, keeper, makeAddr(\"guardian\"), makeAddr(\"splitter\"));\n        vm.warp(beacon.roundTime(ROUND) - 20 minutes);\n        vm.deal(address(this), 1 ether);\n        draw.fundRegular{value: 1 ether}();\n    }\n\n    /// After the randomness is public the keeper can discard a draw that has eligible holders\n    /// by calling rollover with a non-empty root, then re-commit for a fresh round.\n    function test_rolloverWithNonEmptyRootMustRevert() public {\n        vm.prank(keeper);\n        uint256 id = draw.commit(TombolaDraw.Track.Regular, uint64(block.timestamp), ROUND, 1 ether);\n        vm.warp(beacon.roundTime(ROUND) + 1);\n        bytes32 nonEmptyRoot = keccak256(bytes.concat(keccak256(abi.encode(makeAddr(\"alice\"), uint256(0), uint256(100e18)))));\n        vm.prank(keeper);\n        vm.expectRevert();\n        draw.rollover(id, SIG, nonEmptyRoot);\n    }\n}","reproduction":"setUp: fund 1 ether to the regular pot. Keeper calls commit(Regular, now, ROUND, 1 ether), then warps past roundTime(ROUND). Keeper calls rollover(id, SIG, root_of_list_with_alice). Expected: revert, because the list is not empty. Actual: the call succeeds, potRegular goes back to 1 ether and activeDraw is cleared, so the keeper can commit again for a different round.","severity":"medium","snippet":"        d.root = root;","title":"rollover accepts any root after the randomness is public, so the keeper can void an unwanted outcome and re-draw"},{"citation":"resolved","description":"commit stores root = 0 and totalWeight = 0. Both are supplied only in settle, once drand's signature is public. So the list is pinned after the random word is known, not before it. Knowing the word, the keeper can build a tree that passes every on-chain check with any address as winner, for example the one-leaf list (X, 0, W) for any W > 0. As a result checks (3) and the 'pinned list' claims in the NatSpec, README and DESIGN are not enforced against keeper manipulation at all. snapshotTime <= block.timestamp at commit, so the snapshot is already final then. The keeper could commit the root (and totalWeight) in commit, and settle could require the same ones. If that is not wanted, the docs should say the root is only checked off chain.","line":142,"path":"src/TombolaDraw.sol","reproduction":"After commit(Regular, now, ROUND, 1 ether) and the round being published, the keeper computes word = keccak256(abi.encode(keccak256(SIG), id)) and calls settle(id, SIG, root=keccak256(bytes.concat(keccak256(abi.encode(X,0,1)))), totalWeight=1, winner=X, cumulativeBefore=0, weight=1, proof=[]). Expected: rejected because it is not the list fixed before the randomness. Actual: X is paid 1 ether (word % 1 == 0 falls in [0,1)).","severity":"medium","snippet":"                root: bytes32(0),","title":"Snapshot root and totalWeight are chosen at settle, after the randomness is known, not pinned at commit"}],"hash":"1b12f670e4e028e4381b1cd31a4039b734b51d11a8be10e0f0607633d1e6e93f","nodeId":"9ae76f9e-fc4c-4c63-9034-e8cb70db10bc","outcome":"completed","summary":"I found two medium findings and wrote them to `.imd-findings.json`. Both come from the same root cause: the keeper chooses how a draw ends after drand's randomness is already public. Neither one lets anyone take ETH outside the paths you listed.\n\n**1. `rollover` accepts any root, so the keeper can re-draw** (`src/TombolaDraw.sol:201`)\n- The README says a rollover is only for an empty list and pins `keccak256(\"\")`, but the contract doesn't check the root at all.\n- Once the round's randomness is out, the keeper can see who would win and, if they don't like it, call `rollover` on a draw that has eligible holders. The prize goes back to the pot and the keeper commits again on a new round.\n- **Repro:** fund 1 ETH, `commit(Regular, now, ROUND, 1 ether)`, move time past the round, then `rollover(id, SIG, <root of a list containing alice>)`. It should revert; instead it succeeds.\n- **Proof:** a Foundry test in `test/scratch/Rollover.t.sol`, included in the findings file. I ran it and it fails on the current code for that reason. Adding `require(root == keccak256(\"\"))` to `rollover` should make it pass.\n\n**2. The list is fixed at settle, not at commit** (`src/TombolaDraw.sol:142`)\n- `commit` stores `root = 0` and `totalWeight = 0`; both are only supplied in `settle`, after the random word is known.\n- Knowing the word, the keeper can make any address win with a one-leaf list, e.g. `settle(..., root = leaf(X, 0, 1), totalWeight = 1, X, 0, 1, [])` pays X the whole prize.\n- So the on-chain \"winner is in the pinned list\" check (guarantee 3 in your trust model) and the matching claims in the docs give no protection against the keeper. Cheating can only be caught afterwards, off chain.\n- **Fix:** the snapshot is already final when `commit` runs (`snapshotTime <= block.timestamp`), so `commit` could take the root and total weight and `settle` could require the same ones. If you'd rather not change that, the README and DESIGN should say the root is only checked off chain.\n\n**Checked and not reported:**\n- **Solvency:** every pot, reserve, return and unclaimed-credit path keeps `solvent()` true, and the `uint128` cast in `commit` can't truncate because `amount > type(uint128).max` is checked first.\n- **Merkle leaves:** leaves are double-hashed and 96 bytes long, so an intermediate node can't pass as a leaf.\n- **Re-entrancy:** the lock plus the 50,000 gas stipend hold, and the splitter's own lock stops `harvest` being re-entered.\n- **Cancel:** a draw can't be cancelled after it's settled, or twice, and `guardian == address(0)` can't be used to cancel early.\n- **Team wallet:** a team wallet that rejects ETH doesn't block `harvest`; its share is held for `payTeam()`.\n- **Round timing:** `roundTime` and `COMMIT_DELAY` correctly force a future round.\n\nThis was a manual review and I only wrote a test for finding 1. I did not run the 12 specialty passes from the reference guides separately; I covered your focus areas (a) to (g) directly.","treeHash":null,"usage":{"cachedInputTokens":327025,"inputTokens":16,"model":"claude-opus-5-5","outputTokens":5100,"runtime":"claude","turns":8,"wallClockMs":66035}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bf4c5b8707945096","findings":[{"citation":"resolved","description":"commit() bounds snapshotTime only from above (<= block.timestamp, and roundTime >= snapshotTime + COMMIT_DELAY). Nothing bounds it from below or ties it to the schedule (hourly / 4-hourly / weekly in DESIGN.md). Every past snapshot's list can be computed from public Transfer logs when commit is called, so the keeper does not need to know the randomness to bias the draw: it chooses, among all past moments, the one where a wallet of its choice holds the largest share of totalWeight. In the extreme (31 minutes after launch, when only an insider wallet had held >= 10,000 tokens for 30 minutes) that share is 100%, and the whole pot goes to that wallet with certainty. The list is honest for the chosen snapshotTime, so the keeper-trust assumption (honest, complete list with honest weights) is not broken, and README 'Verifying a draw yourself' steps 1-8 all pass: step 2 checks only roundTime >= snapshotTime + COMMIT_DELAY and commit-block time < roundTime, not the snapshot's age. This defeats trust-model property (2) in practice: the drand word is unbiased, but the keeper chooses the distribution it is applied to with full knowledge. It is a different freedom from the accepted one (choosing when to commit, and so which future round), and test_commitRefusesARoundThatIsNotInTheFuture explicitly allows a 2-hour-old snapshot. Impact: up to 100% of any committed amount (the keeper may reserve the entire potRegular or potMega) is directed to a chosen holder, with no on-chain or README-verifier signal. Fix: require snapshotTime >= block.timestamp - MAX_SNAPSHOT_AGE (a few minutes; the keeper commits at the slot), and/or require snapshotTime to sit on the published cadence grid (e.g. snapshotTime % 1 hours == 0 for Regular, % 1 weeks for Mega) and to be later than the previous draw's snapshot on that track. Add the age check to the README verifier.","line":125,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\n\n/// commit() accepts any snapshotTime <= block.timestamp, however old. At commit time every past snapshot's list is\n/// public, so the keeper can pick the historical moment at which a wallet of its choice held the largest (up to 100%)\n/// share of the weight, and settle with a list that is honest for that moment and passes the README's verification.\ncontract StaleSnapshotTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    address keeper = makeAddr(\"keeper\");\n    address guardian = makeAddr(\"guardian\");\n    address insider = makeAddr(\"insider\"); // the only eligible holder 31 minutes after launch\n    uint64 ROUND;\n    bytes SIG;\n\n    function setUp() public {\n        string memory json = vm.readFile(\"test/fixtures/evmnet.json\");\n        ROUND = uint64(vm.parseJsonUint(json, \".rounds[0].round\"));\n        SIG = vm.parseJsonBytes(json, \".rounds[0].signature\");\n        beacon = new DrandBeacon();\n        draw = new TombolaDraw(beacon, keeper, guardian, makeAddr(\"splitter\"));\n        vm.warp(beacon.roundTime(ROUND) - 20 minutes);\n        vm.deal(address(this), 1 ether);\n        draw.fundRegular{value: 1 ether}();\n    }\n\n    function test_commitRejectsASnapshotFromLongAgo() public {\n        // launch was 30 days ago; 31 minutes after launch only the insider had held >= 10,000 tokens for 30 minutes\n        uint64 earlySnapshot = uint64(block.timestamp - 30 days + 31 minutes);\n        vm.prank(keeper);\n        try draw.commit(TombolaDraw.Track.Regular, earlySnapshot, ROUND, 1 ether) returns (uint256 id) {\n            // the honest list for that snapshot has one row: the insider, so it wins with certainty\n            vm.warp(beacon.roundTime(ROUND) + 1);\n            uint256 w = draw.weightFor(10_000e18, earlySnapshot - 31 minutes, earlySnapshot);\n            bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(insider, uint256(0), w))));\n            vm.prank(keeper);\n            draw.settle(id, SIG, root, w, insider, 0, w, new bytes32[](0));\n            assertEq(insider.balance, 0, \"a 30-day-old snapshot chosen at commit decided the whole pot\");\n        } catch {}\n    }\n}","reproduction":"setUp: fund potRegular with 1 ether; block.timestamp = roundTime(ROUND) - 20 min, with launch 30 days earlier. keeper: commit(Regular, snapshotTime = now - 30 days + 31 minutes, ROUND, 1 ether) succeeds. After the round, settle(id, SIG, root = leaf(insider, 0, w), totalWeight = w, insider, 0, w, []) with w = weightFor(10_000e18, snapshot - 31 min, snapshot). Expected: commit reverts, because the snapshot is 30 days stale. Actual: commit succeeds and settle pays the insider the full 1 ether. test/scratch/StaleSnapshot.t.sol fails with 'a 30-day-old snapshot chosen at commit decided the whole pot: 1000000000000000000 != 0'.","severity":"medium","snippet":"        if (snapshotTime > block.timestamp) revert WrongStage();","title":"commit() accepts a snapshotTime arbitrarily far in the past, so an honest-list keeper picks which historical eligibility list decides the draw (up to a certain win)"},{"citation":"resolved","description":"commit() checks that drandRound is far enough in the future but sets no ceiling. cancel() lets anyone cancel only once block.timestamp >= roundTime(drandRound) + 7 days. A round committed far ahead, through a ms/s mix-up (roundAt(block.timestamp * 1000) gives roundTime ~1.79e12, year ~58,000), a wrong genesis, or a compromised hot keeper calling commit(track, now, type(uint64).max, pot), leaves the amount in `reserved` and activeDraw[track] != 0, so the track can never commit again (TrackBusy). settle/rollover cannot happen before drand reaches that round. Only the guardian can unlock it. DeployPons.s.sol documents 'GUARDIAN (0 for none)' and the constructor accepts guardian == address(0). In that configuration the reserved prize and every wei later funded into that track's pot (73.33% or 20% of all creator fees, for ever) are frozen, contradicting the NatSpec 'so a dead keeper can't lock a pot'. The live deployment 0xC24C89e8Eb078902e5f527A5402631b4c12D237A has a nonzero guardian (0x4c6B36b84F14E3111c732136bb38803E82bBCC46), so there a cold-wallet transaction is needed per bad commit. Because the keeper is immutable, a compromised keeper can re-lock right after each guardian cancel. Fix: cap the look-ahead in commit (e.g. roundTime <= block.timestamp + MAX_LEAD, such as 1 day, or roundTime <= snapshotTime + COMMIT_DELAY + small slack), and/or measure abandonment from the commit time (store committedAt). Reject guardian == address(0) or document what it costs.","line":214,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\n\n/// The \"anyone may cancel after ABANDON_AFTER\" escape is measured from beacon.roundTime(drandRound), and drandRound\n/// has no upper bound. A round committed far in the future (here a seconds/milliseconds mix-up) keeps the prize in\n/// `reserved` and the track busy for millennia; only the guardian can undo it, and with guardian == address(0)\n/// (which the deploy script allows: \"GUARDIAN (0 for none)\") nobody can.\ncontract FarFutureRoundTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    address keeper = makeAddr(\"keeper\");\n    uint64 ROUND;\n\n    function setUp() public {\n        string memory json = vm.readFile(\"test/fixtures/evmnet.json\");\n        ROUND = uint64(vm.parseJsonUint(json, \".rounds[0].round\"));\n        beacon = new DrandBeacon();\n        draw = new TombolaDraw(beacon, keeper, address(0), makeAddr(\"splitter\"));\n        vm.warp(beacon.roundTime(ROUND) - 20 minutes);\n        vm.deal(address(this), 1 ether);\n        draw.fundRegular{value: 1 ether}();\n    }\n\n    function test_abandonedDrawCanBeCancelledAfterSevenDays() public {\n        uint64 farRound = beacon.roundAt(block.timestamp * 1000); // roundTime ~ 1.79e12 s, year ~58,000\n        vm.prank(keeper);\n        try draw.commit(TombolaDraw.Track.Regular, uint64(block.timestamp), farRound, 1 ether) returns (uint256 id) {\n            vm.warp(block.timestamp + 8 days); // the keeper is gone\n            draw.cancel(id); // anyone: \"so a dead keeper can't lock a pot\"\n            assertEq(draw.potRegular(), 1 ether);\n            assertEq(draw.activeDraw(TombolaDraw.Track.Regular), 0);\n        } catch {}\n    }\n}","reproduction":"Deploy TombolaDraw(beacon, keeper, guardian = address(0), splitter); fundRegular{1 ether}. keeper: commit(Regular, uint64(now), beacon.roundAt(now * 1000), 1 ether) succeeds. warp(now + 8 days) (or 10 years). Anyone: cancel(id). Expected: succeeds (abandoned draw, pot returned). Actual: reverts NotGuardian(). Every later commit on Regular reverts TrackBusy, and potRegular funded afterwards can never be committed. test/scratch/FarFutureRound.t.sol fails with NotGuardian().","severity":"low","snippet":"        bool late = block.timestamp >= beacon.roundTime(d.drandRound) + ABANDON_AFTER;","title":"The 7-day anyone-may-cancel escape counts from a keeper-chosen drandRound with no upper bound, so one commit can lock a track and its prize indefinitely (for ever with guardian == address(0))"},{"citation":"resolved","description":"settle() does not exclude address(this) or the splitter as winner, and the README list algorithm excludes only the Pons curve and pool. Anyone can transfer >= 10,000 tokens to TombolaDraw or TombolaSplitter. Neither contract can ever move tokens again, so the gift sits there accruing the full 3x multiplier, and an honest keeper following README step 5-7 must include the address. (1) TombolaDraw wins a Mega draw: _pay() sends the prize to TombolaDraw.receive(), which runs within the 50,000 gas stipend and does potRegular += msg.value. reserved falls by the prize and potRegular rises by it, so mega ETH moves to the regular pot ((g): ETH moved between pots). (2) TombolaSplitter wins: its empty receive() accepts the prize, and the next harvest() splits it 7333/2000/667. On a 1 ether regular prize, 0.0667 ether goes to the team wallet (ETH that is not the team's bps share of creator fees) and 0.2 ether to mega. solvent() stays true, but the payout is neither a prize to a holder nor a rollover to its own track. Cost to the griefer: 10,000 tokens burned. Probability of triggering: that address's weight share in each draw. Fix: in settle, if winner == address(this) || winner == splitter, revert (forcing a rollover), or route the amount back to the draw's own track as _return() does. Also exclude these addresses in the README algorithm.","line":190,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\n\n/// Anyone can send >= 10,000 tokens to the TombolaDraw address; the README's list algorithm only excludes the Pons\n/// curve and pool, so after 30 minutes the draw contract is an eligible row of an honest list. When it wins a Mega\n/// draw, settle() pays the prize to itself, receive() books it as potRegular, and the mega prize has moved pots.\ncontract SelfPayoutTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    address keeper = makeAddr(\"keeper\");\n    address guardian = makeAddr(\"guardian\");\n    uint64 ROUND;\n    bytes SIG;\n\n    function setUp() public {\n        string memory json = vm.readFile(\"test/fixtures/evmnet.json\");\n        ROUND = uint64(vm.parseJsonUint(json, \".rounds[0].round\"));\n        SIG = vm.parseJsonBytes(json, \".rounds[0].signature\");\n        beacon = new DrandBeacon();\n        draw = new TombolaDraw(beacon, keeper, guardian, makeAddr(\"splitter\"));\n        vm.warp(beacon.roundTime(ROUND) - 20 minutes);\n        vm.deal(address(this), 1 ether);\n        draw.fundMega{value: 1 ether}();\n    }\n\n    function test_megaPrizeWonByTheDrawContractStaysMega() public {\n        vm.prank(keeper);\n        uint256 id = draw.commit(TombolaDraw.Track.Mega, uint64(block.timestamp), ROUND, 1 ether);\n        vm.warp(beacon.roundTime(ROUND) + 1);\n        uint256 w = 30_000e18; // 10,000 tokens held 72 h\n        bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(address(draw), uint256(0), w))));\n        vm.prank(keeper);\n        try draw.settle(id, SIG, root, w, address(draw), 0, w, new bytes32[](0)) {\n            assertEq(draw.potRegular(), 0, \"the mega prize was booked into the regular pot\");\n        } catch {}\n    }\n}","reproduction":"fundMega{1 ether}; keeper commit(Mega, now, ROUND, 1 ether); after the round settle(id, SIG, root = leaf(address(draw), 0, 30_000e18), 30_000e18, address(draw), 0, 30_000e18, []). Expected: the prize stays on Mega (or settle refuses a self-winner). Actual: potMega = 0, potRegular = 1 ether. Same with winner = splitter (scratch run): splitter holds 1 ether, and harvest() then sends team 0.0667 ether and potMega 0.2 ether. test/scratch/SelfPayout.t.sol fails with 'the mega prize was booked into the regular pot: 1000000000000000000 != 0'.","severity":"low","snippet":"        _pay(winner, amount);","title":"settle() pays protocol contracts like any winner: a Mega prize won by TombolaDraw itself is booked into potRegular, and a prize won by the splitter is re-split, sending 6.67% to the team and 20% to me"},{"citation":"resolved","description":"COMMIT_DELAY (10 minutes) is measured from snapshotTime, which the keeper may set in the past. The only check between the commit itself and the round is roundTime > block.timestamp. With snapshotTime = now - 10 min, the keeper may commit at block.timestamp = roundTime - 1, one third of evmnet's 3 s period. drand publishes round R about 0.3-1 s after roundTime(R). On Robinhood Chain (Arbitrum Orbit) block.timestamp is the sequencer's wall clock in whole seconds and can lag real time: blocks share a second, sequencer clock skew, and delayed-inbox messages carry their L1 posting time. If it lags by more than drand's publication latency, a keeper can read round R's signature from api.drand.sh, compute the winner, and only then commit to R (or skip it), with block.timestamp still < roundTime(R). That breaks guarantee (1) with no on-chain trace: README verifier step 2 checks only 'commit block timestamp < roundTime'. The NatSpec ('round time > snapshot + COMMIT_DELAY, and later than the commit itself') and DESIGN.md ('drandRound is in the future (round time > snapshot + COMMIT_DELAY)') suggest a 10-minute margin that the commit does not get. Fix: require roundTime >= block.timestamp + MIN_LEAD (e.g. 1-2 minutes, or COMMIT_DELAY itself) in addition to the snapshot check, and use the same bound in README step 2.","line":128,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\n\n/// COMMIT_DELAY separates the round from the snapshot, not from the commit. With snapshotTime set 10 minutes back,\n/// commit() accepts a round due one second of chain time later, so \"committed before anyone could know it\" rests on\n/// the L2 sequencer's clock agreeing with drand's to within a second.\ncontract CommitMarginTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    address keeper = makeAddr(\"keeper\");\n    uint64 ROUND;\n\n    function setUp() public {\n        string memory json = vm.readFile(\"test/fixtures/evmnet.json\");\n        ROUND = uint64(vm.parseJsonUint(json, \".rounds[0].round\"));\n        beacon = new DrandBeacon();\n        draw = new TombolaDraw(beacon, keeper, makeAddr(\"guardian\"), makeAddr(\"splitter\"));\n        vm.deal(address(this), 1 ether);\n        draw.fundRegular{value: 1 ether}();\n    }\n\n    function test_commitNeedsAMarginBeforeTheRound() public {\n        vm.warp(beacon.roundTime(ROUND) - 1); // one second (a third of a drand period) before the round\n        vm.prank(keeper);\n        vm.expectRevert(); // RoundTooEarly or any new lead check\n        draw.commit(TombolaDraw.Track.Regular, uint64(block.timestamp - 10 minutes), ROUND, 1 ether);\n    }\n}","reproduction":"warp(beacon.roundTime(ROUND) - 1); keeper: commit(Regular, uint64(block.timestamp - 10 minutes), ROUND, 1 ether). Expected: RoundTooEarly, because the round is only 1 s away. Actual: succeeds. test/scratch/CommitMargin.t.sol fails with 'next call did not revert as expected'.","severity":"low","snippet":"        if (roundTime <= block.timestamp) revert RoundTooEarly(); // the round must still be in the future","title":"The commit-to-round margin is one second of L2 chain time, not COMMIT_DELAY: 'committed before anyone could know it' depends on the sequencer clock matching drand to under a second"},{"citation":"resolved","description":"The root commits to the leaves (account, cumulativeBefore, weight) but not to their sum. settle() takes totalWeight as a free argument and checks only totalWeight > 0 and cumulativeBefore + weight <= totalWeight. After the drand word is known, the keeper can keep the honest root and leaf but search totalWeight' = S + k for the first k where word % totalWeight' lands in the range of the wallet it wants. The probability per k is weight/total, so a hit comes within about total/weight tries, computed off chain instantly. The on-chain record then shows an honest root and a correctly proven leaf, and only a verifier that re-sums the list notices totalWeight != sum. That is the README step-7 check, but the contract's stated property (2), 'the winner index is randomWord mod totalWeight over the pinned list', is not enforced. The tree also cannot rule out overlapping or gapped ranges or leaves summing past totalWeight. Those are list-honesty items and informational, but totalWeight is the one free parameter even with an honest tree. Impact: the full committed prize can be redirected to any listed wallet without altering the published tree. Fix: bind the sum into the commitment, e.g. root = keccak256(abi.encode(merkleRoot, totalWeight)), or prove a terminal leaf whose cumulativeBefore + weight == totalWeight. Better, pin root and totalWeight in commit(): the snapshot is already in the past at commit, so the list exists before the randomness. That removes post-randomness grinding of both the list and totalWeight, and lets rollover require totalWeight == 0.","line":178,"path":"src/TombolaDraw.sol","reproduction":"setUp list alice 100e18 / bob 250e18 / carol 50e18 (sum 400e18), padded tree [L0, L1, L2, L2], draw id 0 on ROUND with the fixture SIG. The honest target word % 400e18 selects bob (idx 1). The keeper searches k and finds totalWeight = 400e18 + 8 where word % totalWeight falls in carol's range [350e18, 400e18). settle(0, SIG, honestRoot, 400e18 + 8, carol, 350e18, 50e18, proofCarol) succeeds and pays carol 1 ether. Expected: revert, because totalWeight is not the pinned list's sum. Actual: the payout succeeds. Verified in a scratch run (test_totalWeightGrind, 'delta from honest 8').","severity":"low","snippet":"        uint256 target = word % totalWeight;","title":"totalWeight is not bound to the pinned root: with an honest published tree the keeper can grind totalWeight (here +8 wei) to make any listed wallet the winner"},{"citation":"resolved","description":"When the 50,000-gas push in _pay fails, the prize goes to unclaimed[winner], and claim(to) can only send unclaimed[to] to `to` itself. A winner that is a contract without a payable receive/fallback (token lockers, vesting contracts and some vaults are typical long-term, high-weight holders, and the README excludes only the Pons curve and pool) can never collect. The ETH stays in unclaimedTotal for ever with no expiry, no recipient redirect and no return to the pot. Each such win permanently removes the whole prize from circulation. Fix (economic-rule choice for the team): exclude contracts that cannot receive ETH from the list, let the winner redirect (claimTo(recipient) with msg.sender == winner, for contracts that can make calls), and/or allow anyone to return an unclaimed credit to its track's pot after a long expiry.","line":228,"path":"src/TombolaDraw.sol","reproduction":"The repo's own test_failedPayoutIsCreditedAndClaimable: winner RejectsEth wins a 1 ether draw; unclaimed[r] = 1 ether; claim(r) reverts SendFailed. No function in TombolaDraw can move that 1 ether anywhere else, so it is locked permanently (potRegular, potMega and reserved never regain it).","severity":"low","snippet":"        (bool ok,) = payable(to).call{value: amt}(\"\");","title":"A prize credited to a winner that cannot receive ETH is locked for ever: claim(to) can pay only the credited address and nothing returns stale credits"},{"citation":"resolved","description":"Informational, classed under the accepted keeper trust, but README/DESIGN claim a mitigation the code cannot provide: 'Anyone can prove a dishonest list from public data within minutes, and the guardian can cancel a committed draw before it settles.' root and totalWeight are supplied only to settle(), which verifies, records and pays atomically. A dishonest list therefore cannot be seen on chain before the money leaves. There is also no minimum delay between commit and settle: commit at roundTime - 1 (see the commit-margin finding) and settle 3 s later. A keeper hot wallet that is compromised (immutable, so it cannot be rotated) commits amount = potRegular and amount = potMega and settles each with a one-leaf root (thief, 0, 1), totalWeight 1 (target = word % 1 = 0). That takes 100% of both pots in seconds, faster than any cold-wallet guardian can react. Fix: pin root/totalWeight at commit (the snapshot is already in the past) so the list is public before the randomness and the guardian has the commit-to-round window to cancel it. Optionally add a minimum lead (roundTime >= block.timestamp + N minutes), and correct the README/DESIGN wording.","line":174,"path":"src/TombolaDraw.sol","reproduction":"fundRegular{5 ether}; warp(roundTime(ROUND) - 1); keeper commit(Regular, now - 10 min, ROUND, 5 ether); warp(+3 s); keeper settle(id, SIG, leaf(thief, 0, 1), 1, thief, 0, 1, []) succeeds and thief.balance == 5 ether. The guardian's only window was about 3 seconds, and the root it would need to judge was not on chain until the paying transaction (scratch test_guardianWindow passes).","severity":"info","snippet":"        if (root == bytes32(0) || totalWeight == 0) revert BadWeights();","title":"Accepted keeper/guardian trust, but the documented safeguard does not work: the list's root first appears in the same settle() tx that pays, so the guardian cannot cancel a dishonest draw, and a leake"},{"citation":"resolved","description":"Informational (keeper/guardian trust), with a doc mismatch. README step 7 says 'An empty list pins keccak256(\"\")', but rollover() stores whatever root it is given (e.g. 0xdead) and does not require totalWeight == 0 or any relation to a list. The keeper sees round R's signature, computes the winner of the honest list, and if it is not a wallet it favours calls rollover(id, sig, anything). The prize returns to the pot, and the keeper commits a new draw, repeating until a favoured wallet wins. The final winning draw is fully honest and passes the README verifier. Only checking every rollover's snapshot for emptiness reveals the vetoes. cancel() by the guardian is likewise allowed after the round is published (any time before settle), which gives the guardian the same veto. Contradicts DESIGN.md 'the drand commitment added so the keeper can't pick the round either'. Fix: pin root/totalWeight at commit and allow rollover only when the committed totalWeight == 0, and allow guardian cancel only before roundTime (anyone-cancel after abandonment stays).","line":201,"path":"src/TombolaDraw.sol","reproduction":"fundRegular{1 ether}; commit(Regular, now, ROUND, 1 ether); after the round, rollover(id, SIG, bytes32(uint256(0xdead))) succeeds and potRegular is back to 1 ether, even though the honest list (alice/bob/carol, total 400e18) has a winner. Verified in scratch test_rolloverAnyRoot.","severity":"info","snippet":"        d.root = root;","title":"rollover() accepts any root after the randomness is public, and the guardian may cancel after it too: the keeper alone, or the guardian alone, can re-roll a draw whose winner it dislikes"},{"citation":"resolved","description":"Informational (design: no owner, no withdraw), noted because the docs claim otherwise. cancel()'s NatSpec and README say anyone may cancel after 7 days 'so a dead keeper can't lock a pot'. But potRegular/potMega decrease only in commit(), which is keeper-only, and keeper is immutable. After the keeper key is lost, the abandon-cancel returns `reserved` to the pot, where it stays for ever, and TombolaSplitter keeps routing 93.33% of every creator-fee harvest into those pots. Impact: the entire pot balance plus all future fee inflows to the pots become permanently unspendable. Fix (design decision): a keeper-rotation path controlled by the guardian, or a guardian/timelocked fallback that can commit or settle, or correct the documentation to state that pots are only ever spendable by the original keeper.","line":123,"path":"src/TombolaDraw.sol","reproduction":"Any state where the keeper key is gone: fundRegular{X}; for every function except commit(), potRegular is only ever increased (fundRegular/receive/_return). commit() reverts NotKeeper for any other caller, so X is permanently locked. Harvests keep adding 73.33% + 20% of each claim.","severity":"info","snippet":"        if (msg.sender != keeper) revert NotKeeper();","title":"A dead or lost keeper freezes both pots and all future pot funding for ever; the 7-day cancel only moves the prize back into a pot that only the keeper can spend"}],"hash":"429f5691d69b240ce64fe8451b7d5a2733ba2b6fb54ce003f545f7fa6dcbe257","nodeId":"8fffa326-18bb-4c6f-92e9-033d283bcabc","outcome":"completed","summary":"I found no way for a third party to take ETH and no break in `solvent()`. The issues are ways the keeper can steer who wins, plus places where the code doesn't enforce what the README and DESIGN say. All 9 findings are in `.imd-findings.json`, each with the exact line, the inputs that trigger it, and what happens versus what should. I changed no source files; the reproductions are in `test/scratch/`. The top four have proof tests that fail on the current code for the stated reason.\n\n**Exploitable, ranked by severity**\n1. **Medium – stale snapshot** (`TombolaDraw.sol:125`). `commit()` accepts any past `snapshotTime`. The keeper can pick a past moment when one wallet was the only eligible holder (e.g. 31 minutes after launch). That wallet then wins the whole pot with certainty, the list is honest for that moment, and every step of the README's verification still passes. Fix: limit how old a snapshot may be, and/or tie it to the draw schedule.\n2. **Low – a far-future round locks a track** (`:214`). There is no upper limit on `drandRound`, and the 7-day \"anyone may cancel\" clock counts from the round's time. One wrong round number (e.g. a seconds/milliseconds mix-up) keeps the prize reserved and blocks every new draw on that track. With `guardian == 0`, which the deploy script allows, that is permanent. The live deployment has a nonzero guardian, so there each bad commit needs a guardian cancel.\n3. **Low – the draw contract can pay itself** (`:190`). Anyone can send 10,000 tokens to TombolaDraw or the splitter, and the README's list rule includes them. If TombolaDraw wins a Mega draw, the prize lands in the regular pot. If the splitter wins, the next harvest re-splits the prize and the team gets 6.67% of it.\n4. **Low – one-second commit margin** (`:128`). The 10-minute `COMMIT_DELAY` is measured from the snapshot, not from the commit. The keeper can commit one second of chain time before the round, so \"committed before anyone could know it\" depends on the chain's clock matching drand's to within a second.\n5. **Low – `totalWeight` isn't tied to the root** (`:178`). With an honest tree, the keeper can pass a slightly different total to pick another listed wallet. In my test, adding 8 wei moved the win from bob to carol.\n6. **Low – credited prizes can be stuck forever** (`:228`). If the winner is a contract that can't receive ETH, `claim()` can only pay that same address, and nothing ever returns the credit to a pot.\n\n**Informational (accepted keeper/guardian trust, but the docs say otherwise)**\n- The guardian can't cancel a dishonest draw in practice. The list's root first appears in the same `settle()` transaction that pays out. A leaked keeper key could commit and settle within about 3 seconds and empty both pots.\n- `rollover()` accepts any root after the randomness is public, and the guardian may cancel after it too. Either one can veto a winner and force a re-draw.\n- If the keeper key is lost, both pots and all future pot funding are frozen for good. The 7-day cancel only puts the prize back into a pot that only the keeper can spend.\n\n**Checked and found sound:**\n- **Accounting:** pots, `reserved` and unclaimed credits stay balanced through commit, settle, rollover, cancel, claim and funding, and the `uint128` amount check runs before the cast.\n- **Re-entrancy:** the locks hold, state is written before the payout, and the 50,000 gas stipend limits a return-data bomb to minor extra gas.\n- **Cancel paths:** no double cancel or double return.\n- **Merkle leaves:** the double-hashed leaf can't be confused with an internal node, and padding by repeating the last leaf is safe.\n- **Signatures:** the BLS library rejects alternate encodings, so whoever proves a round first can't change its randomness.\n- **Splitter:** harvest re-entry, a team wallet that rejects ETH, and dust amounts are all handled.\n\n**Limits:**\n- I read the live config from the public Robinhood Chain RPC. The deployed escrow has the `claim()` and `balanceOf(address)` fun","treeHash":null,"usage":{"cachedInputTokens":1612837,"inputTokens":38,"model":"claude-opus-5-5","outputTokens":53107,"runtime":"claude","turns":29,"wallClockMs":585742}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1b657df861f7b08b","findings":[{"citation":"resolved","description":"The trust model (README 'Trust model', NatSpec lines 13-16) says the contract enforces that the winner index is randomWord mod totalWeight over the list the keeper pinned by root. It does not. (1) The Merkle root commits to each leaf (account, cumulativeBefore, weight) but nothing commits to the list's sum: `totalWeight` is checked only as totalWeight != 0 and cumulativeBefore + weight <= totalWeight (lines 174-175). (2) commit() stores root = 0 and totalWeight = 0 (lines 142-143); both are first supplied to settle(), in the same transaction that proves the round and pays, i.e. after drand's signature is public. Together: once the round is published the keeper computes word = keccak256(abi.encode(keccak256(sig), id)), keeps the honest root and the honest leaf of any wallet it wants, and chooses totalWeight' such that word % totalWeight' lands in that wallet's range (closed form: T = word - cumulativeBefore gives word % T == cumulativeBefore whenever T > word/2, which is always true for a 256-bit word and a small cumulativeBefore; or grind T = realSum + k, which hits within about realSum/weight tries). Signature, proof, weight bounds and range check all pass and the full prize is paid to the chosen wallet. The on-chain record shows the honest root and a correctly proven leaf; only a verifier that re-sums the entire published list (README step 7) notices totalWeight != sum, and only after the ETH has left. The same freedom means overlapping or gapped cumulative ranges and a list whose leaves sum past totalWeight cannot be detected on chain either. This is distinct from the accepted 'keeper publishes a dishonest list' trust: here the list and root are honest, and the one thing the README says the contract enforces is the thing that is not enforced. It also voids the README's mitigation 'the guardian can cancel a committed draw before it settles': the root first exists in the paying transaction, so there is nothing for the guardian to judge beforehand. Impact: up to the whole committed amount (the keeper may reserve the entire pot) redirected to a wallet of the keeper's choice per draw, by the keeper or by whoever holds the hot keeper key. Fix: bind the total into the commitment, e.g. leaf = keccak256(bytes.concat(keccak256(abi.encode(account, cumulativeBefore, weight, totalWeight)))) or root' = keccak256(abi.encode(merkleRoot, totalWeight)); and, since snapshotTime <= block.timestamp is already enforced at commit so the list exists then, pin root and totalWeight in commit() (or a separate keeper pin(id, root, totalWeight) that must land before beacon.roundTime(drandRound)) and have settle require equality. That makes guarantees (2) and (3) hold against a compromised keeper and gives the guardian the commit-to-round window to cancel a bad list.","line":178,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\n\n/// Finding: settle() takes `totalWeight` as a free parameter. The Merkle root pins (winner, cumulativeBefore, weight)\n/// per leaf but nothing pins the list's total, so after drand publishes the round the keeper may pick ANY leaf of\n/// the honestly published list and choose a totalWeight that makes randomWord % totalWeight land in that leaf's\n/// range. Every on-chain check (signature, proof, weights, range) passes.\n///\n/// This test fails on the current code (the keeper pays a leaf that is NOT the randomWord % realTotal winner)\n/// and passes once totalWeight is bound to the pinned list (e.g. folded into every leaf, or pinned with the root\n/// before the round time), because the forged settle then reverts.\ncontract TotalWeightUnboundTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    address keeper = makeAddr(\"keeper\");\n    address guardian = makeAddr(\"guardian\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n\n    uint64 ROUND;\n    bytes SIG;\n\n    function setUp() public {\n        string memory json = vm.readFile(\"test/fixtures/evmnet.json\");\n        ROUND = uint64(vm.parseJsonUint(json, \".rounds[0].round\"));\n        SIG = vm.parseJsonBytes(json, \".rounds[0].signature\");\n        beacon = new DrandBeacon();\n        draw = new TombolaDraw(beacon, keeper, guardian, makeAddr(\"splitter\"));\n        vm.warp(beacon.roundTime(ROUND) - 20 minutes);\n        vm.deal(address(this), 1 ether);\n        draw.fundRegular{value: 1 ether}();\n    }\n\n    function _leaf(address a, uint256 cum, uint256 w) internal pure returns (bytes32) {\n        return keccak256(bytes.concat(keccak256(abi.encode(a, cum, w))));\n    }\n\n    function _pair(bytes32 a, bytes32 b) internal pure returns (bytes32) {\n        return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a));\n    }\n\n    function test_keeperPicksAnyListedWinnerByChoosingTotalWeight() public {\n        // the honest list: alice [0,100e18), bob [100e18,350e18), carol [350e18,400e18); real total 400e18\n        uint256 realTotal = 400e18;\n        address[3] memory a = [alice, bob, carol];\n        uint256[3] memory cum = [uint256(0), 100e18, 350e18];\n        uint256[3] memory w = [uint256(100e18), 250e18, 50e18];\n        bytes32[4] memory leaves = [_leaf(a[0], cum[0], w[0]), _leaf(a[1], cum[1], w[1]), _leaf(a[2], cum[2], w[2]), _leaf(a[2], cum[2], w[2])];\n        bytes32 root = _pair(_pair(leaves[0], leaves[1]), _pair(leaves[2], leaves[3]));\n\n        vm.prank(keeper);\n        uint256 id = draw.commit(TombolaDraw.Track.Regular, uint64(block.timestamp), ROUND, 1 ether);\n        vm.warp(beacon.roundTime(ROUND) + 1);\n\n        // the honest winner is randomWord % realTotal\n        uint256 word = uint256(keccak256(abi.encode(keccak256(SIG), id)));\n        uint256 honestTarget = word % realTotal;\n        uint256 honest = 0;\n        for (uint256 i = 0; i < 3; i++) if (honestTarget >= cum[i] && honestTarget < cum[i] + w[i]) honest = i;\n\n        // the keeper picks a DIFFERENT listed wallet and chooses totalWeight so the word lands in its range:\n        // for T in (word/2, word], word % T == word - T, so T = word - cum[pick] gives target == cum[pick]\n        uint256 pick = (honest + 1) % 3;\n        uint256 forgedTotal = word - cum[pick];\n        assertGe(forgedTotal, cum[pick] + w[pick]);\n        assertEq(word % forgedTotal, cum[pick]);\n\n        bytes32[] memory proof = new bytes32[](2);\n        proof[0] = leaves[pick ^ 1];\n        proof[1] = pick < 2 ? _pair(leaves[2], leaves[3]) : _pair(leaves[0], leaves[1]);\n\n        uint256 before = a[pick].balance;\n        vm.prank(keeper);\n        // EXPECTED (fixed code): the contract rejects a totalWeight the pinned list does not carry\n        vm.expectRevert();\n        draw.settle(id, SIG, root, forgedTotal, a[pick], cum[pick], w[pick], proof);\n        // ACTUAL (current code): the call succeeds and the non-winner is paid the whole prize\n        assertEq(a[pick].balance, before, \"a wallet that did not win must not be paid\");\n        assertEq(draw.getDraw(id).winner, address(0));\n    }\n}","reproduction":"setUp as in test/TombolaDraw.t.sol (fixture round, potRegular = 1 ether). Honest list alice [0,100e18), bob [100e18,350e18), carol [350e18,400e18), real total 400e18, root built as README step 7 (padded [L0,L1,L2,L2]). keeper.commit(Regular, now, ROUND, 1 ether) -> id 0; vm.warp(roundTime(ROUND)+1). word = uint256(keccak256(abi.encode(keccak256(SIG), 0))); honest winner = leaf containing word % 400e18. Keeper picks pick = (honest+1)%3, forgedTotal = word - cum[pick], calls settle(0, SIG, root, forgedTotal, a[pick], cum[pick], w[pick], proof[pick]). Expected: revert (totalWeight is not the pinned list's). Actual: succeeds, Settled(0, a[pick], 1 ether, root, forgedTotal, word) and a[pick] receives 1 ether. test/scratch/TotalWeightUnbound.t.sol fails on this code with 'next call did not revert as expected'. A second scratch run confirmed the grind variant: with the same honest root, totalWeight = 400e18 + 8 moves the winner from bob to carol.","severity":"high","snippet":"        uint256 target = word % totalWeight;","title":"settle(): totalWeight is a free argument never bound to the pinned root, and root/totalWeight are first supplied after the randomness is public, so the keeper can pick any listed wallet as winner with"},{"citation":"resolved","description":"commit() requires roundTime >= snapshotTime + COMMIT_DELAY and roundTime > block.timestamp but sets no ceiling (lines 126-128). cancel()'s permissionless path opens only at roundTime(drandRound) + ABANDON_AFTER (line 214), and settle/rollover need drand's signature for that round. A round far in the future (a unix timestamp passed as the round number gives roundTime around the year 2193; type(uint64).max gives ~5.5e19 s; a ms/s mix-up gives year ~58,000) therefore leaves the draw open with `reserved` holding up to the whole pot and activeDraw[track] != 0, so every later commit on that track reverts TrackBusy. Only the guardian can unlock it. script/DeployPons.s.sol line 16 defaults GUARDIAN to address(0) ('0 for none') and the constructor accepts it; in that configuration (msg.sender can never equal address(0)) the reserved ETH and the track are locked forever, and 73.33% or 20% of every future harvest keeps flowing into a pot that can never be committed again. With a guardian set (the live deployment has one) it is a guardian-recoverable freeze, but the keeper is immutable, so a compromised hot key can re-lock after every cancel. The README line 56 and NatSpec line 211 claim 'a dead keeper can't lock a pot', which is false for this path. Fix: bound the horizon in commit(), e.g. require roundTime <= block.timestamp + MAX_LEAD (a day or less) and/or roundTime <= snapshotTime + COMMIT_DELAY + slack; alternatively measure abandonment from a stored commit timestamp (max(roundTime, committedAt) + ABANDON_AFTER). Consider rejecting guardian == address(0).","line":214,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\n\n/// commit() accepts a drand round arbitrarily far in the future; with no guardian (the deploy script's default)\n/// the reserved prize and the track are then locked for good, because cancel's \"late\" path never opens.\ncontract FarRoundTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    address keeper = makeAddr(\"keeper\");\n\n    function setUp() public {\n        beacon = new DrandBeacon();\n        draw = new TombolaDraw(beacon, keeper, address(0), makeAddr(\"splitter\"));\n        vm.warp(1_760_000_000);\n        vm.deal(address(this), 10 ether);\n        draw.fundMega{value: 5 ether}();\n    }\n\n    function test_commitRefusesARoundFarInTheFuture() public {\n        // a unix timestamp passed as the round number: round time lands around the year 2193\n        uint64 mistaken = uint64(block.timestamp);\n        assertGt(beacon.roundTime(mistaken), block.timestamp + 100 * 365 days);\n        vm.prank(keeper);\n        vm.expectRevert();\n        draw.commit(TombolaDraw.Track.Mega, uint64(block.timestamp), mistaken, 5 ether);\n    }\n}","reproduction":"new TombolaDraw(beacon, keeper, guardian = address(0), splitter); vm.warp(1_760_000_000); fundMega{value: 5 ether}(). keeper.commit(Mega, uint64(block.timestamp), drandRound = 1_760_000_000, 5 ether). Expected: revert (round time is ~167 years away). Actual: succeeds; reserved = 5 ether, potMega = 0; cancel(0) from anyone reverts NotGuardian for ever (late is false until ~2193), commit(Mega, ...) reverts TrackBusy. test/scratch/FarRound.t.sol fails on this code with 'next call did not revert as expected'.","severity":"medium","snippet":"        bool late = block.timestamp >= beacon.roundTime(d.drandRound) + ABANDON_AFTER;","title":"commit(): no upper bound on drandRound, so one commit to a far-future round disables the 7-day anyone-cancel path and freezes the pot and its track (permanently when guardian == address(0))"},{"citation":"resolved","description":"commit() bounds snapshotTime only from above (snapshotTime <= block.timestamp at line 125, roundTime >= snapshotTime + COMMIT_DELAY at line 127). The eligibility list is a deterministic function of snapshotTime, computable from public Transfer logs at commit time for every past instant. The randomness is still unknown at commit, but the keeper chooses the distribution it is applied to: it can name the past moment at which a wallet of its choice held the largest share of totalWeight, including 31 minutes after launch when a single insider wallet was the only address past the 30-minute gate (share 100%, so the prize is certain). Such a list is honest for the chosen snapshot, so the keeper-trust assumption is not broken and every README verifier step (1-8) passes: step 2 checks only roundTime >= snapshotTime + COMMIT_DELAY and commit-block time < roundTime, not the snapshot's age. This is a different freedom from the accepted 'keeper chooses when to commit (and so which future round)', and test_commitRefusesARoundThatIsNotInTheFuture explicitly allows a 2-hour-old snapshot. snapshotTime = 0 (or 1) is also accepted, selecting a pre-launch state. Impact: up to 100% of any committed amount directed to a chosen holder, with no on-chain or verifier signal. Fix: require block.timestamp - snapshotTime <= MAX_SNAPSHOT_AGE (a few minutes), optionally require snapshotTime > the previous draw's snapshotTime on the track and/or on the documented cadence grid, and add the age check to README step 2.","line":125,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\n\n/// commit() accepts any snapshotTime <= block.timestamp, however old. At commit time every past snapshot's list is\n/// public, so the keeper can pick the historical moment at which a wallet of its choice held the largest (up to 100%)\n/// share of the weight, and settle with a list that is honest for that moment and passes the README's verification.\ncontract StaleSnapshotTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    address keeper = makeAddr(\"keeper\");\n    address guardian = makeAddr(\"guardian\");\n    address insider = makeAddr(\"insider\"); // the only eligible holder 31 minutes after launch\n    uint64 ROUND;\n    bytes SIG;\n\n    function setUp() public {\n        string memory json = vm.readFile(\"test/fixtures/evmnet.json\");\n        ROUND = uint64(vm.parseJsonUint(json, \".rounds[0].round\"));\n        SIG = vm.parseJsonBytes(json, \".rounds[0].signature\");\n        beacon = new DrandBeacon();\n        draw = new TombolaDraw(beacon, keeper, guardian, makeAddr(\"splitter\"));\n        vm.warp(beacon.roundTime(ROUND) - 20 minutes);\n        vm.deal(address(this), 1 ether);\n        draw.fundRegular{value: 1 ether}();\n    }\n\n    function test_commitRejectsASnapshotFromLongAgo() public {\n        // launch was 30 days ago; 31 minutes after launch only the insider had held >= 10,000 tokens for 30 minutes\n        uint64 earlySnapshot = uint64(block.timestamp - 30 days + 31 minutes);\n        vm.prank(keeper);\n        try draw.commit(TombolaDraw.Track.Regular, earlySnapshot, ROUND, 1 ether) returns (uint256 id) {\n            // the honest list for that snapshot has one row: the insider, so it wins with certainty\n            vm.warp(beacon.roundTime(ROUND) + 1);\n            uint256 w = draw.weightFor(10_000e18, earlySnapshot - 31 minutes, earlySnapshot);\n            bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(insider, uint256(0), w))));\n            vm.prank(keeper);\n            draw.settle(id, SIG, root, w, insider, 0, w, new bytes32[](0));\n            assertEq(insider.balance, 0, \"a 30-day-old snapshot chosen at commit decided the whole pot\");\n        } catch {}\n    }\n}","reproduction":"setUp as in test/TombolaDraw.t.sol, potRegular = 1 ether, block.timestamp = roundTime(ROUND) - 20 min. keeper.commit(Regular, snapshotTime = now - 30 days + 31 minutes, ROUND, 1 ether). Expected: revert (snapshot is 30 days stale). Actual: succeeds (id 0). After vm.warp(roundTime(ROUND)+1), w = weightFor(10_000e18, snapshot - 31 min, snapshot); settle(0, SIG, root = leaf(insider, 0, w), w, insider, 0, w, []) pays insider the full 1 ether (word % w always lies in [0, w)). test/scratch/StaleSnapshot.t.sol fails on this code with 'a 30-day-old snapshot chosen at commit decided the whole pot: 1000000000000000000 != 0'. Scratch runs also confirmed commit(Regular, 1, ROUND, 1 ether) and snapshotTime = 0 are accepted.","severity":"medium","snippet":"        if (snapshotTime > block.timestamp) revert WrongStage();","title":"commit(): snapshotTime has no lower bound, so the keeper can choose any historical holder set (down to a one-wallet list) with full knowledge, and the list is honest for that snapshot"},{"citation":"resolved","description":"README step 5 and step 7 describe rollover as the path for 'nobody eligible' that 'pins the (empty-list) root', with 'An empty list pins keccak256(\"\")'. rollover() (lines 195-207) stores whatever `root` the keeper passes (including the root of a non-empty list, or 0xdead) and requires nothing of it. It runs after beacon.prove(), so the keeper has already seen drand's signature and can compute the honest winner. If that winner is not to its liking, the keeper rolls the prize back into the pot and commits again for a new round, repeating until a favoured wallet wins; the final winning draw is fully honest and passes the README verifier. cancel() (lines 212-220) gives the guardian the same veto: it is allowed at any time before settlement, including after roundTime when the winner is public knowledge (and after the round is even proven on chain). The 7-day anyone-cancel also lets any third party front-run a keeper's late settle with cancel. No ETH leaves the system, so the impact is biased winner selection (a full prize per draw redirected among holders), not theft; it contradicts DESIGN.md 'the drand commitment added so the keeper can't pick the round either'. Fix: in rollover require root == keccak256(\"\") (so a false rollover is a direct on-chain contradiction of the public eligible set), and ideally pin root/totalWeight at commit so rollover is only allowed when the committed totalWeight == 0; restrict guardian cancel to block.timestamp < beacon.roundTime(d.drandRound) and keep the abandon path.","line":201,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\n\ncontract RolloverRerollTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    address keeper = makeAddr(\"keeper\");\n    uint64 ROUND;\n    bytes SIG;\n\n    function setUp() public {\n        string memory json = vm.readFile(\"test/fixtures/evmnet.json\");\n        ROUND = uint64(vm.parseJsonUint(json, \".rounds[0].round\"));\n        SIG = vm.parseJsonBytes(json, \".rounds[0].signature\");\n        beacon = new DrandBeacon();\n        draw = new TombolaDraw(beacon, keeper, makeAddr(\"guardian\"), makeAddr(\"splitter\"));\n        vm.warp(beacon.roundTime(ROUND) - 20 minutes);\n        vm.deal(address(this), 1 ether);\n        draw.fundRegular{value: 1 ether}();\n    }\n\n    /// After the randomness is public the keeper can discard a draw that has eligible holders\n    /// by calling rollover with a non-empty root, then re-commit for a fresh round.\n    function test_rolloverWithNonEmptyRootMustRevert() public {\n        vm.prank(keeper);\n        uint256 id = draw.commit(TombolaDraw.Track.Regular, uint64(block.timestamp), ROUND, 1 ether);\n        vm.warp(beacon.roundTime(ROUND) + 1);\n        bytes32 nonEmptyRoot = keccak256(bytes.concat(keccak256(abi.encode(makeAddr(\"alice\"), uint256(0), uint256(100e18)))));\n        vm.prank(keeper);\n        vm.expectRevert();\n        draw.rollover(id, SIG, nonEmptyRoot);\n    }\n}","reproduction":"setUp as in test/TombolaDraw.t.sol, potRegular = 1 ether. keeper.commit(Regular, now, ROUND, 1 ether) -> id 0; vm.warp(roundTime(ROUND)+1). keeper.rollover(0, SIG, root = leaf(alice, 0, 100e18)) (a list with one eligible holder). Expected: revert, since the root is not keccak256(\"\"). Actual: succeeds, potRegular back to 1 ether, getDraw(0).root == the non-empty root, activeDraw cleared, and a new commit(Regular, now, roundAt(now + 11 min), 1 ether) is accepted. test/scratch/RolloverReroll.t.sol fails on this code with 'next call did not revert as expected'. Scratch runs also confirmed rollover(0, SIG, bytes32(0xdead)) succeeds, and that guardian.cancel(0) succeeds at roundTime(ROUND)+1 when the signature is public.","severity":"medium","snippet":"        d.root = root;","title":"rollover() accepts any root and cancel() works after the round is public, so the keeper (and the guardian) can veto a known winner and re-draw; README says rollover pins keccak256(\"\")"},{"citation":"resolved","description":"COMMIT_DELAY (10 minutes) is measured from snapshotTime, which the keeper may set in the past (see the snapshot finding). Between the commit block and the round the only requirement is roundTime > block.timestamp (line 128): a commit at block.timestamp == roundTime - 1 is accepted although drand publishes that round's signature one second later. Robinhood Chain is an Arbitrum-style L2 whose block.timestamp is set by the sequencer in whole seconds and can trail wall-clock time (shared-second blocks, sequencer clock skew, delayed-inbox inclusion). Any lag beyond drand's sub-second publication latency lets the keeper read round R's signature from api.drand.sh, compute the winner for the honest list, and only then commit to R (or skip it) while block.timestamp is still < roundTime(R). That defeats guarantee (1) with no on-chain trace: README verifier step 2 checks only 'commit block timestamp below roundTime'. The beacon.randomness(round) == 0 check (line 129) does not help, because nobody needs to prove the round on chain for it to be public. Also, NatSpec line 11 says 'round time > snapshot + COMMIT_DELAY' while line 127 rejects only roundTime < snapshotTime + COMMIT_DELAY, so equality is accepted (the README says >=); harmless but inconsistent. Fix: require roundTime >= block.timestamp + MIN_LEAD (a minute or more, or COMMIT_DELAY itself) in addition to the snapshot-based bound, mirror it in README step 2, and align the NatSpec.","line":128,"path":"src/TombolaDraw.sol","reproduction":"setUp as in test/TombolaDraw.t.sol, potRegular = 1 ether. vm.warp(beacon.roundTime(ROUND) - 1); keeper.commit(Regular, uint64(block.timestamp - 10 minutes), ROUND, 1 ether). Expected: RoundTooEarly (the round publishes within any plausible clock-skew window). Actual: accepted, getDraw(id).drandRound == ROUND. Reproduced in a scratch test (test_commitOneSecondBeforeRound passes on this code, i.e. the commit is accepted).","severity":"low","snippet":"        if (roundTime <= block.timestamp) revert RoundTooEarly(); // the round must still be in the future","title":"commit(): the round only has to be 1 second after block.timestamp, so 'committed before anyone could know it' rests on the L2 sequencer clock matching drand to under drand's 3 s period"},{"citation":"resolved","description":"settle() does not exclude address(this), the splitter or address(0) as winner, and the README eligibility algorithm (step 5) excludes only the Pons curve and pool. Anyone can transfer >= 10,000 tokens to TombolaDraw or TombolaSplitter (neither can ever move them, so they sit at the full 3x multiplier) and an honest keeper following the published rule must then list that address. (1) The splitter wins: _pay sends the prize to its empty receive(); the next harvest() splits it 7333/2000/667, so the team wallet receives 6.67% of a prize, which is ETH that is not the team's bps share of creator fees (guarantee 4), and 20% of a regular prize moves to the mega pot. (2) TombolaDraw wins a mega draw: _pay reaches receive() (line 113-116) inside the 50,000-gas stipend and books the whole prize into potRegular, so the mega pot's ETH silently moves to the regular pot (ETH moved between pots, focus area g). (3) address(0) or another sink wins and the prize is burned. solvent() stays true in every case. Cost to the griefer is the gifted tokens; probability per draw is that address's weight share. Fix: in settle revert if winner == address(this) || winner == splitter || winner == address(0) (forcing the keeper to exclude them, or route the amount back to the draw's own track as _return does), and add both contracts to the documented exclusion set in README step 5.","line":190,"path":"src/TombolaDraw.sol","reproduction":"setUp as in test/TombolaDraw.t.sol with escrow = address(0). Regular: fundRegular{1 ether}; keeper.commit(Regular, now, ROUND, 1 ether); warp roundTime+1; settle(id, SIG, leaf(splitter,0,1), 1, splitter, 0, 1, []) succeeds, splitter.balance = 1 ether; splitter.harvest() -> team.balance = 0.0667 ether, potRegular = 0.7333 ether, potMega = 0.2 ether. Expected: no part of a prize reaches the team wallet. Mega: fundMega{1 ether}; commit(Mega, ...); settle(id, SIG, leaf(address(draw),0,1), 1, address(draw), 0, 1, []) succeeds -> potMega = 0, potRegular = 1 ether. Scratch tests test_splitterWinnerResplitsPrize ('team got part of a prize: 66700000000000000 != 0') and test_selfWinnerMovesMegaToRegular ('mega prize booked into regular pot: 1000000000000000000 != 0') fail on this code.","severity":"low","snippet":"        _pay(winner, amount);","title":"settle() pays TombolaSplitter, TombolaDraw itself or address(0) like any winner: the team wallet then receives 6.67% of a prize, or a mega prize is booked into the regular pot"},{"citation":"resolved","description":"When the 50,000-gas push in _pay fails, the prize is credited to unclaimed[winner] (lines 273-277) and claim(to) can only send unclaimed[to] to `to` itself (line 228). A winner that is a contract without a payable receive/fallback (token lockers, vesting contracts, some multisig or vault implementations, all typical long-term high-weight holders that the README does not exclude) can never collect, and no function moves the credit anywhere else: no expiry, no redirect, no return to the pot. Each such win permanently removes the whole prize from circulation while unclaimedTotal keeps counting it. The gas stipend also means a winner whose receive() needs more than 50,000 gas (e.g. a Safe with a fallback handler) is credited rather than paid, which is fine, but then is subject to the same claim path and must be able to accept a plain 2300+ gas call. Fix (an economic-rule choice for the team): exclude contracts that cannot receive ETH from the list, add claimTo(recipient) callable by the winner (for contracts that can make calls), and/or let anyone return an unclaimed credit to its track's pot after a long expiry.","line":228,"path":"src/TombolaDraw.sol","reproduction":"The repo's own test_failedPayoutIsCreditedAndClaimable: RejectsEth r wins a 1 ether draw; unclaimed[r] == 1 ether; claim(r) reverts SendFailed. No function in TombolaDraw can move that 1 ether anywhere else (potRegular, potMega and reserved never regain it). Reproduced again in scratch test_unclaimedLocked.","severity":"low","snippet":"        (bool ok,) = payable(to).call{value: amt}(\"\");","title":"A prize credited to a winner that cannot receive ETH is locked for ever: claim(to) can only pay the credited address and nothing returns or redirects a stale credit"},{"citation":"resolved","description":"Recorded separately as the task asked, under the accepted trust that the keeper builds an honest list. The keeper is an immutable hot wallet; if its key leaks, the holder calls commit(track, now, roundAt(now + 601), pot) for each track and, once the round is published (or at roundTime - 1, see the margin finding), settle(id, sig, leaf(thief, 0, 1), 1, thief, 0, 1, []): word % 1 == 0 always lies in [0,1), so the entire pot is paid to an address of its choice. README line 72-73 and DESIGN.md line 55-56 say 'the guardian can cancel a committed draw before it settles' as the mitigation, but root and totalWeight are written only at line 181-182 in settle(), atomically with the payout, so a dishonest list is never visible on chain before the ETH leaves and the guardian has nothing to judge. There is also no minimum time between commit and settle. Pinning root/totalWeight at commit (the snapshot is already in the past then) plus a short challenge window before payout would make the guardian meaningful; otherwise the docs should say the guardian can only cancel on off-chain suspicion before the round. Accounting is otherwise sound in every path I checked (commit, settle, rollover, cancel, claim, fundRegular/fundMega/receive, harvest, payTeam keep balance >= potRegular + potMega + reserved + unclaimedTotal; amount > uint128.max reverts before the cast; double settle/cancel is blocked by _open; nonReentrant plus effects-before-interactions hold; a 50,000-gas return bomb cannot hurt the caller since returndata is not copied; the Merkle leaf is double-hashed so 64-byte internal nodes cannot be leaves; the padded duplicate leaf carries the same winner).","line":181,"path":"src/TombolaDraw.sol","reproduction":"setUp as in test/TombolaDraw.t.sol, potRegular = 5 ether. keeper.commit(Regular, now, ROUND, 5 ether); warp roundTime+1; keeper.settle(id, SIG, leaf(thief,0,1), 1, thief, 0, 1, []) succeeds and thief.balance == 5 ether (same shape as scratch test_settleUsesProvenRound, which pays a one-leaf list the full prize). The guardian's cancel window ends when settle lands, and the root it would need to judge was not on chain before that.","severity":"info","snippet":"        d.root = root;","title":"Accepted keeper trust: a compromised hot keeper key takes both pots in two transactions, and the README's guardian mitigation cannot act because the list first appears in the paying transaction"},{"citation":"resolved","description":"Design decision (no owner, no withdraw), noted because the docs claim otherwise. potRegular and potMega only ever decrease in commit() (keeper-only, line 123) and keeper is immutable. After the keeper key is lost, the 7-day abandon-cancel only returns `reserved` to a pot that nobody can ever spend, and TombolaSplitter keeps routing 93.33% of every creator-fee harvest into those pots. Impact: the entire pot balance plus all future pot inflows become permanently unspendable. Fix (scope decision): a guardian-controlled keeper rotation, a timelocked guardian fallback for commit/settle, or correct the README/NatSpec to state that pots are spendable only by the original keeper.","line":123,"path":"src/TombolaDraw.sol","reproduction":"Any state with potRegular = X and the keeper key gone: every function except commit() only increases potRegular (fundRegular, receive, _return); commit() reverts NotKeeper for any other caller (test_onlyTheKeeperCommitsAndSettles), so X is locked and each harvest adds 73.33% + 20% more.","severity":"info","snippet":"        if (msg.sender != keeper) revert NotKeeper();","title":"Immutable keeper with no fallback: a dead or lost keeper freezes both pots and all future pot funding for ever, which the README's 'a dead keeper can't lock a pot' understates"},{"citation":"resolved","description":"DESIGN.md line 31 documents settle(id, winner, amount, snapshotRoot, drandSignature) and line 38 rollover(id); the code is settle(id, drandSignature, root, totalWeight, winner, cumulativeBefore, weight, proof) and rollover(id, drandSignature, root), and amount is fixed at commit, not passed at settle. README line 35 says 'nothing stays in the splitter' while teamOwed can hold the team share indefinitely (test_harvestStillFundsThePotsWhenTheTeamWalletRejects). README line 56 and NatSpec line 211 say a dead keeper cannot lock a pot (see the far-future-round and dead-keeper findings). README line 105 says rollover pins keccak256(\"\") (see the rollover finding). README lines 72-73 say the guardian can cancel before settlement as a mitigation for a dishonest list (see the totalWeight finding). NatSpec line 11 says round time > snapshot + COMMIT_DELAY where the code accepts equality. Update the documents to the shipped interface and qualify the claims.","line":31,"path":"DESIGN.md","reproduction":"Compare DESIGN.md:31 and :38 with src/TombolaDraw.sol:158-167 and :195; README.md:35 with src/TombolaSplitter.sol:84 (teamOwed += team); README.md:56 with the far-future round reproduction; README.md:105 with test_rolloverReturnsThePot (root keccak256(\"empty list\") accepted).","severity":"info","snippet":"  - `settle(id, winner, amount, snapshotRoot, drandSignature)` by the keeper: the beacon verifies the signature","title":"DESIGN.md and README describe interfaces and guarantees the code does not have"}],"hash":"495751a13932e981795516258b06bda366c6265783245f2a45d46cb8d89585dd","nodeId":"b2ffdbf4-5bc7-4474-98b6-704b30d2103f","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds 10 merged findings, 4 of them with Foundry proofs that fail on the current code.\n\n**What I did.** I ran the project's suite (21 tests pass), then reproduced every specialist claim against `src/TombolaDraw.sol` and `src/TombolaSplitter.sol` using the four supplied proofs plus two scratch files of my own under `test/scratch/`. The 25 specialist items collapsed into 10 findings. Nothing was dropped as false, but several were re-ranked and merged.\n\n**Findings kept, most severe first:**\n\n- **High, with proof.** The Merkle root pins leaves but not the list's sum, and root/totalWeight are first supplied in settle, after the randomness is public. With an honest list and honest root the keeper picks any listed wallet by choosing totalWeight. I confirmed both the closed-form variant from the specialist proof and the grind variant (adding 8 wei to the real total moves the winner from bob to carol). This merges five specialist items, including the \"guardian has no window\" observation.\n- **Medium, with proof.** No upper bound on drandRound. A far-future round disables the 7-day anyone-cancel, and with guardian zero (the deploy script's default) the reserved ETH and the track are locked forever.\n- **Medium, with proof.** No lower bound on snapshotTime. The keeper selects any historical holder set with full knowledge, and the list is honest for that snapshot, so the README verifier passes.\n- **Medium, with proof.** Rollover accepts any root and guardian cancel works after the round is public, so either role can veto a known winner and re-draw. README says rollover pins the empty-list root.\n- **Low.** Commit needs only a 1-second margin before the round, so guarantee (1) rests on L2 sequencer clock accuracy below drand's 3-second period.\n- **Low.** The splitter, the draw itself, or address zero can be a proven winner. Reproduced: the team wallet receives 6.67% of a prize, and a mega prize is booked into the regular pot.\n- **Low.** A credit to a winner that cannot receive ETH is locked forever, with no redirect or return.\n- **Info (accepted keeper trust).** A compromised keeper key drains both pots in two transactions; the documented guardian mitigation cannot act because the list only appears in the paying transaction. Accounting, re-entrancy, uint128 truncation and the Merkle leaf encoding checked out clean.\n- **Info.** A dead keeper freezes the pots and all future pot inflows permanently.\n- **Info.** DESIGN.md and README describe a settle signature and guarantees the code does not have.\n\n**Limits.** I could not confirm that the proofs pass once fixed, since the review forbids editing `src/`. All four use a generic revert expectation or try/catch, so any fix that rejects the bad call will pass them. No Slither or fuzzing beyond the project's existing 256-run fuzz test was run.","treeHash":null,"usage":{"cachedInputTokens":911958,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":23858,"runtime":"claude","turns":23,"wallClockMs":569475}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a7979f925df7a720","findings":[{"citation":"resolved","description":"Trust-model claims (2) and (3) (README 'Trust model'; natspec lines 13-16) say the contract enforces 'the winner's index is randomWord mod totalWeight over the list the keeper pinned by root'. But nothing is pinned before the randomness exists. root and totalWeight are first supplied in settle(), in the same transaction that proves the round and pays. settle only checks root != 0, totalWeight != 0 and cumulativeBefore + weight <= totalWeight; totalWeight is never tied to the sum of the leaves under root. Once the drand round is public, the keeper can publish the honest, canonical list (correct root, every weight correct) and still choose any listed wallet as winner by passing a totalWeight above the true sum: word % totalWeight moves for each value, and a few hundred candidates always land in the chosen wallet's range. Ranges outside the true sum have no leaf, so they just revert and the keeper tries the next value. The guardian's cancel cannot act on this, because the root first appears in the transaction that pays. The README's 'the guardian can cancel a committed draw before it settles' therefore gives no protection against a bad list. The off-chain verifier (README step 7: 'the sum [must equal] its totalWeight') detects it, but only after the ETH has left. This is exploitable only by the keeper, but it does not need a dishonest list, which is the one thing the trust model accepts. It needs only freedom the contract claims to remove. Fix: the snapshot is already final at commit, because snapshotTime <= block.timestamp is enforced. So pin root and totalWeight in commit(), or in a separate publish step that must happen before roundTime, and have settle require them to match. If it must stay a single call, at minimum commit to keccak256(root, totalWeight) before the round.","line":174,"path":"src/TombolaDraw.sol","reproduction":"setUp of test/TombolaDraw.t.sol (fixture round 21567121), potRegular >= 1 ether. Honest canonical list: A=(0xA11CE, 0, 100), B=(0xB0B, 100, 100), totalWeight 200, root = hashPair(leaf A, leaf B). keeper: commit(Regular, now, 21567121, 1 ether) -> id 0; warp to roundTime+3. word = keccak256(abi.encode(keccak256(SIG), 0)); word % 200 = 119, so the honest winner is B. The keeper instead calls settle(0, SIG, root, 201, A, 0, 100, [leafB]): word % 201 < 100, so it succeeds and A is paid 1 ether. Expected: revert, because totalWeight 201 != the list's 200, or the root/total were not pinned before the round. Actual: A wins with the honest root. Verified in a scratch test where both settle(...,200,B,...) and settle(...,201,A,...) succeed against the same root.","severity":"medium","snippet":"        if (root == bytes32(0) || totalWeight == 0) revert BadWeights();\n        if (weight == 0 || cumulativeBefore + weight > totalWeight) revert BadWeights();","title":"settle() takes root and totalWeight after the randomness is public, and totalWeight is not bound to the tree, so the keeper can choose the winner from an honest list"},{"citation":"resolved","description":"commit() checks that the round is after snapshotTime + COMMIT_DELAY and after block.timestamp, but sets no maximum. cancel() lets anyone cancel only after roundTime(drandRound) + ABANDON_AFTER, and settle/rollover need drand's signature for that round. A round decades away therefore leaves the draw open with nobody able to close it except the guardian. script/DeployPons.s.sol uses `vm.envOr(\"GUARDIAN\", address(0))` ('0 for none'), and the constructor accepts guardian == 0. In that deployment, the reserved amount (up to the whole pot) stays counted in `reserved`, activeDraw[track] stays non-zero, and the track can never commit again. No owner or withdraw exists, so this is permanent. The README says 'Seven days after the round's time anyone may, so a dead keeper can't lock a pot', but that does not hold for a far-future round. A plausible keeper bug triggers it, such as passing a unix timestamp as the round number (roundTime = GENESIS + (ts-1)*3, roughly the year 2193), as does a compromised hot keeper key. Even with a guardian set, a compromised keeper can re-commit after every cancel. Fix: bound the round in commit, e.g. require roundTime <= block.timestamp + MAX_COMMIT_AHEAD (a day or less) and roundTime <= snapshotTime + COMMIT_DELAY + some slack. Also consider requiring guardian != address(0).","line":214,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\n\n/// commit() accepts a drand round arbitrarily far in the future; with no guardian (the deploy script's default)\n/// the reserved prize and the track are then locked for good, because cancel's \"late\" path never opens.\ncontract FarRoundTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    address keeper = makeAddr(\"keeper\");\n\n    function setUp() public {\n        beacon = new DrandBeacon();\n        draw = new TombolaDraw(beacon, keeper, address(0), makeAddr(\"splitter\"));\n        vm.warp(1_760_000_000);\n        vm.deal(address(this), 10 ether);\n        draw.fundMega{value: 5 ether}();\n    }\n\n    function test_commitRefusesARoundFarInTheFuture() public {\n        // a unix timestamp passed as the round number: round time lands around the year 2193\n        uint64 mistaken = uint64(block.timestamp);\n        assertGt(beacon.roundTime(mistaken), block.timestamp + 100 * 365 days);\n        vm.prank(keeper);\n        vm.expectRevert();\n        draw.commit(TombolaDraw.Track.Mega, uint64(block.timestamp), mistaken, 5 ether);\n    }\n}","reproduction":"TombolaDraw(beacon, keeper, guardian=address(0), splitter), potMega = 5 ether, block.timestamp = 1_760_000_000. keeper: commit(Mega, now, drandRound = 1_760_000_000, 5 ether) succeeds. roundTime is about 6.99e9 (year ~2193). 100 years later, cancel(0) by anyone still reverts NotGuardian, reserved stays 5 ether, and commit(Mega, ...) reverts TrackBusy forever. Expected: commit reverts for a round this far out. Actual: it succeeds, and the 5 ether plus the mega track are locked. Proof: test/scratch/FarRound.t.sol (fails now with 'next call did not revert').","severity":"medium","snippet":"        bool late = block.timestamp >= beacon.roundTime(d.drandRound) + ABANDON_AFTER;\n        if (msg.sender != guardian && !late) revert NotGuardian();","title":"No upper bound on drandRound: one commit can lock a pot and its track forever (permanently when guardian == address(0), the deploy script's default)"},{"citation":"resolved","description":"The README specifies rollover as 'Nobody eligible ... pins the (empty-list) root', and step 7 says 'An empty list pins keccak256(\"\")'. rollover() never checks root == keccak256(\"\"): it accepts any value, including the root of a non-empty list or 0. It runs after beacon.prove(), so the keeper sees the randomness and the winner first. If the winner is not to its liking, it can roll the prize back into the pot and re-commit, a selective abort that re-rolls the draw. Likewise, cancel() lets the guardian cancel a committed draw at any time before settlement, including after the round's signature is public, which is the same selective-abort power. The ETH stays in the pots, so the impact is biased winner selection (up to 100% of a prize per draw redirected among holders), not theft. Fix: in rollover require root == keccak256(\"\") (EMPTY_ROOT), which makes a false rollover a direct contradiction of the public eligible set. Restrict the guardian's cancel to block.timestamp < roundTime(d.drandRound), so it can only cancel before the randomness exists. After that, only the 7-day abandon path or settlement remains.","line":200,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\n\n/// rollover() is documented to pin the empty list's root, keccak256(\"\"), but accepts any root: the keeper can\n/// see the randomness and then roll a draw whose list (root) has eligible holders back into the pot.\ncontract RolloverRootTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    address keeper = makeAddr(\"keeper\");\n    uint64 ROUND;\n    bytes SIG;\n\n    function setUp() public {\n        string memory json = vm.readFile(\"test/fixtures/evmnet.json\");\n        ROUND = uint64(vm.parseJsonUint(json, \".rounds[0].round\"));\n        SIG = vm.parseJsonBytes(json, \".rounds[0].signature\");\n        beacon = new DrandBeacon();\n        draw = new TombolaDraw(beacon, keeper, makeAddr(\"guardian\"), makeAddr(\"splitter\"));\n        vm.warp(beacon.roundTime(ROUND) - 20 minutes);\n        vm.deal(address(this), 10 ether);\n        draw.fundRegular{value: 1 ether}();\n    }\n\n    function test_rolloverRefusesANonEmptyRoot() public {\n        vm.prank(keeper);\n        uint256 id = draw.commit(TombolaDraw.Track.Regular, uint64(block.timestamp), ROUND, 1 ether);\n        vm.warp(beacon.roundTime(ROUND) + 3);\n        // root of a one-holder list (alice, 0, 100): somebody was eligible\n        bytes32 nonEmpty = keccak256(bytes.concat(keccak256(abi.encode(makeAddr(\"alice\"), uint256(0), uint256(100)))));\n        vm.prank(keeper);\n        vm.expectRevert();\n        draw.rollover(id, SIG, nonEmpty);\n    }\n}","reproduction":"setUp of test/TombolaDraw.t.sol: commit(Regular, now, 21567121, 1 ether) -> id 0; warp to roundTime+3. keeper calls rollover(0, SIG, root) with root = leaf(alice, 0, 100) (a list with one eligible holder). Expected: revert, since the root is not keccak256(\"\"). Actual: it succeeds, potRegular gets the 1 ether back, and getDraw(0).root is the non-empty root. A scratch test also confirmed rollover(0, SIG, bytes32(0xdead)) succeeds. Proof: test/scratch/RolloverRoot.t.sol (fails now).","severity":"low","snippet":"        bytes32 rnd = beacon.prove(d.drandRound, drandSignature);\n        d.root = root;","title":"Keeper and guardian can abort a draw after its randomness is public: rollover() accepts any root, and cancel() works after the round is published"},{"citation":"resolved","description":"settle() accepts any proven leaf address. Under the README's eligibility algorithm, any address holding at least 10,000 tokens for 30 minutes is listed, and only 'the Pons curve and pool' are excluded. Anyone can send 10,000 tokens to TombolaSplitter or TombolaDraw and make it eligible, and an honest keeper must then list it. If the splitter wins, _pay sends the prize to the splitter's receive(). The next harvest() splits it 73.33/20/6.67: the team wallet receives 6.67% of a prize, which breaks guarantee (4) (team only gets its bps share of fees), and the rest moves between the pots. If TombolaDraw wins, receive() credits the whole prize to potRegular, so a mega prize silently moves from potMega to potRegular. address(0), or another sink, wins and burns it. Accounting stays solvent, but ETH leaves the prize path in a way the trust model does not allow. Fix: in settle require winner != address(this) && winner != splitter && winner != address(0), and add both contracts to the documented exclusion set.","line":190,"path":"src/TombolaDraw.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DrandBeacon} from \"src/DrandBeacon.sol\";\nimport {TombolaDraw} from \"src/TombolaDraw.sol\";\nimport {TombolaSplitter, IPonsFeeEscrow, ITombolaDrawPots} from \"src/TombolaSplitter.sol\";\n\n/// settle() pays any proven leaf, including the splitter itself. A prize won by the splitter (eligible under the\n/// README's algorithm once anyone sends it 10,000 tokens) is re-split on the next harvest: 6.67% of a prize goes to\n/// the team wallet and the rest is moved between pots.\ncontract InfraWinnerTest is Test {\n    DrandBeacon beacon;\n    TombolaDraw draw;\n    TombolaSplitter splitter;\n    address keeper = makeAddr(\"keeper\");\n    address team = makeAddr(\"team\");\n    uint64 ROUND;\n    bytes SIG;\n\n    function setUp() public {\n        string memory json = vm.readFile(\"test/fixtures/evmnet.json\");\n        ROUND = uint64(vm.parseJsonUint(json, \".rounds[0].round\"));\n        SIG = vm.parseJsonBytes(json, \".rounds[0].signature\");\n        beacon = new DrandBeacon();\n        address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);\n        draw = new TombolaDraw(beacon, keeper, makeAddr(\"guardian\"), predicted);\n        splitter = new TombolaSplitter(ITombolaDrawPots(address(draw)), team, IPonsFeeEscrow(address(0)));\n        vm.warp(beacon.roundTime(ROUND) - 20 minutes);\n        vm.deal(address(this), 10 ether);\n        draw.fundMega{value: 5 ether}();\n    }\n\n    function test_settleRefusesTheSplitterAsWinner() public {\n        vm.prank(keeper);\n        uint256 id = draw.commit(TombolaDraw.Track.Mega, uint64(block.timestamp), ROUND, 5 ether);\n        vm.warp(beacon.roundTime(ROUND) + 3);\n        bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(address(splitter), uint256(0), uint256(1)))));\n        vm.prank(keeper);\n        try draw.settle(id, SIG, root, 1, address(splitter), 0, 1, new bytes32[](0)) {\n            splitter.harvest();\n            // the mega prize was re-split: the team took 6.67% of it and 73.33% moved to the regular pot\n            assertEq(team.balance, 0, \"team wallet received a share of a prize\");\n        } catch {}\n    }\n}","reproduction":"setUp variant: potMega = 5 ether, escrow = 0. commit(Mega, now, 21567121, 5 ether) -> id 0; warp to roundTime+3. settle(0, SIG, root = leaf(splitter, 0, 1), 1, splitter, 0, 1, []) succeeds, then splitter.harvest(). Result: team.balance = 0.3335 ether (6.67% of the prize), potRegular += 3.6665 ether, potMega = 1 ether. Expected: no part of a prize reaches the team wallet. With winner = address(draw), the result is potMega 5 -> 0 and potRegular +5 ether. Proof: test/scratch/InfraWinner.t.sol (fails now: team 333500000000000000 != 0).","severity":"low","snippet":"        _pay(winner, amount);","title":"settle() pays the splitter, the draw contract or address(0) as winners: the team takes 6.67% of a prize, or the prize moves between pots"},{"citation":"resolved","description":"commit() requires snapshotTime <= block.timestamp and roundTime >= snapshotTime + COMMIT_DELAY, but snapshotTime can be arbitrarily old, down to 0. The randomness is still unknown at commit, but the keeper can choose, with full knowledge, the past moment that maximises a chosen wallet's share of weight, e.g. just before a whale sold. Such a list is fully honest at that snapshot, so the verifier passes it. The documented cadence (hourly, then every 4 h; mega weekly) is not enforced either. snapshotTime = 0 selects a pre-launch state with an empty list, which then 'legitimately' rolls over. The accepted trust is 'when to commit (and so which future round)', not which past state counts. Fix: require block.timestamp - snapshotTime <= a small window (e.g. 5 minutes), and ideally roundTime - snapshotTime <= COMMIT_DELAY + slack.","line":125,"path":"src/TombolaDraw.sol","reproduction":"setUp of test/TombolaDraw.t.sol, potRegular = 10 ether: keeper commit(Regular, snapshotTime = 0, 21567121, 1 ether) succeeds (verified in scratch), as does snapshotTime = now - 365 days. Expected: revert for a snapshot more than a few minutes before the commit. Actual: accepted.","severity":"low","snippet":"        if (snapshotTime > block.timestamp) revert WrongStage();\n        uint256 roundTime = beacon.roundTime(drandRound);\n        if (roundTime < uint256(snapshotTime) + COMMIT_DELAY) revert RoundTooEarly();","title":"commit() puts no lower bound on snapshotTime: the keeper can pick any historical state (or 0) after seeing who held what"},{"citation":"resolved","description":"commit() only requires roundTime > block.timestamp, and the evmnet period is 3 s. On Robinhood Chain (an Arbitrum-style L2), block.timestamp comes from the sequencer and can trail wall-clock time: second granularity with ~250 ms blocks, sequencer lag, and delayed-inbox inclusion with timestamps up to hours behind. So a commit can land in a block with timestamp roundTime-1 after drand has already published round R. The keeper could then commit only when the already-public word favours someone. The beacon.randomness(round) == 0 check does not help, because nobody needs to prove the round on chain for it to be public. Also, natspec line 11 says 'round time > snapshot + COMMIT_DELAY' while line 127 rejects only roundTime < snapshotTime + COMMIT_DELAY, so equality is accepted (README says >=). That is harmless but inconsistent. Fix: require roundTime >= block.timestamp + a margin (e.g. 60 s, many periods), and align the natspec.","line":128,"path":"src/TombolaDraw.sol","reproduction":"block.timestamp = T, drand wall clock = T+2 (sequencer lag of 2 s), round R with roundTime = T+1 already published by drand. commit(track, T-600, R, amt) passes every check at lines 125-129. Expected: rejected, because the round is public. Actual: accepted. Boundary: commit with roundTime == snapshotTime + 600 is accepted, contrary to the natspec's '>'.","severity":"info","snippet":"        if (roundTime <= block.timestamp) revert RoundTooEarly(); // the round must still be in the future","title":"Future-round check has a one-second margin, and the natspec states a strict inequality the code does not enforce"},{"citation":"resolved","description":"Within the stated trust model, and listed separately as asked. The keeper can commit(track, now, roundAt(now+601), pot) and, ~10 minutes later, settle with a one-leaf list (keeper, 0, 1), totalWeight 1, empty proof. word % 1 == 0 always lands in [0,1), so the entire pot is paid to an address the keeper chooses. That is a dishonest list, which the trust model accepts as off-chain-detectable. However, the README's mitigation ('the guardian can cancel a committed draw before it settles') cannot act, because the list is first revealed in the paying transaction (see the first finding). Pinning root/totalWeight at or before the round, plus a short challenge window before payout, would make the guardian meaningful. Accounting is otherwise sound: every path checked (commit, settle, rollover, cancel, claim, fund*, receive, harvest, payTeam) keeps balance >= potRegular + potMega + reserved + unclaimedTotal; amount > uint128.max reverts before the cast; double settle/cancel is blocked by _open; nonReentrant plus effects-before-interactions hold; the Merkle leaf is double-hashed (64-byte internal nodes cannot be leaves); BLS signatures are canonical (coordinates < p, cofactor 1), so keccak256(signature) randomness cannot be ground by a third-party prover.","line":177,"path":"src/TombolaDraw.sol","reproduction":"setUp of test/TombolaDraw.t.sol with potRegular = P: keeper commit(Regular, now, ROUND, P); warp to roundTime+3; settle(0, SIG, leaf(attacker,0,1), 1, attacker, 0, 1, []) pays the attacker P. The guardian could only have cancelled before the root existed. Expected per the README: guardian can stop a bad list. Actual: no window.","severity":"info","snippet":"        if (!_verify(proof, root, leaf)) revert BadProof();","title":"Accepted keeper trust (informational): a compromised hot keeper key drains both pots in two transactions, and the guardian has no window to stop it"}],"hash":"d8d330c43b8e242974130dfa1bf29ca5732f384ae30dc9561a5f3f8c9d117041","nodeId":"0ad8d382-6fcc-465a-8e53-f9bbfc179f16","outcome":"completed","summary":"I found no way for a third party to take ETH, and no path that breaks `solvent()`. The problems are in what the contract lets the keeper and guardian do once the randomness is public, plus one permanent-lock risk. The 7 findings (2 medium, 3 low, 2 info) are in `.imd-findings.json`, and every snippet and line number in it has been checked against the source. The three proof tests in `test/scratch/` (FarRound, RolloverRoot and InfraWinner) fail on the current code for the reason each finding states. No project files were changed.\n\n**Medium**\n1. **The keeper can pick the winner from an honest list** (`src/TombolaDraw.sol:174`). The keeper passes `root` and `totalWeight` to `settle()` after the drand round is public, and `totalWeight` is never checked against the list. With an honest two-holder list totalling 200, the fixture round's target is 119, so B should win. `settle(..., totalWeight = 201, A, ...)` also succeeds and pays A. The guardian can't step in, because the list first appears in the transaction that pays. The snapshot is already final at commit time, so the fix is to fix `root` and `totalWeight` in place at `commit` (or in a step before the round time) and have `settle` require them to match.\n2. **One commit can lock a pot forever** (`:214`). There is no upper limit on `drandRound`. A plausible keeper mistake, such as passing a unix timestamp as the round number, puts the round around the year 2193. The deploy script's default is `guardian = address(0)`, and in that case the reserved prize and the whole track are locked permanently: anyone-cancel only opens 7 days after a round that never arrives. The fix is to cap how far ahead the round may be.\n\n**Low**\n3. **Abort after seeing the result** (`:200`). `rollover()` accepts any root, not just the empty-list `keccak256(\"\")` the README specifies. The guardian can also cancel after the round is public. Either way the keeper or guardian can see the winner first and cancel or re-draw. The ETH stays in the pots, so the impact is a biased draw, not theft.\n4. **The splitter or the draw contract can win a prize** (`:190`). Anyone can send either contract 10,000 tokens, which makes it eligible under the README's rules. If the splitter wins a 5 ETH mega prize, the next `harvest()` gives the team 0.3335 ETH of it and moves 3.67 ETH into the regular pot. If the draw contract wins, the prize moves from the mega pot to the regular pot.\n5. **`snapshotTime` can be any past time, including 0** (`:125`). The keeper can choose the historical state that suits a chosen wallet, and the resulting list still passes off-chain verification.\n\n**Info**\n6. **One-second margin on the \"round is in the future\" check** (`:128`). If the chain's timestamps trail real time by a couple of seconds, the keeper could commit to a round drand has already published. The code comment also says `>` where the code allows equality.\n7. **Accepted keeper trust, listed separately as you asked** (`:177`). A compromised hot keeper key can empty both pots in two transactions, and the guardian has no window to stop it.\n\nThe rest of what you asked about checks out:\n- **Accounting:** every function that moves ETH keeps `solvent()` true.\n- **Commit:** the `uint128` bound is checked before the cast.\n- **Double settle or cancel:** blocked.\n- **Re-entrancy:** the `nonReentrant` guards and the ordering of state updates before the external call hold.\n- **Merkle:** a proof can't pass an internal node off as a leaf.\n- **Randomness:** the BLS signature can't be re-encoded to change it.\n- **Splitter:** re-entering `harvest()` and a team wallet that rejects ETH are handled.\n\nI reviewed the code and ran tests only; I didn't use static-analysis or fuzzing tools.","treeHash":null,"usage":{"cachedInputTokens":743178,"inputTokens":26,"model":"claude-opus-5-5","outputTokens":32331,"runtime":"claude","turns":15,"wallClockMs":351492}}],"verification":[]}