# Audit report

> 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.
>
> System. 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.
>
> Trust 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.
>
> Please 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.
>
> For 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.
>
> Deployed on Robinhood Chain (4663) from this commit: TombolaDraw 0xC24C89e8Eb078902e5f527A5402631b4c12D237A, TombolaSplitter 0x3A065C0ac5477B556573f454f83EE2aE4184281f.

| | |
|---|---|
| Repository | https://github.com/JJ-ME55/tombola-contracts.git |
| Commit | `705e9d24ec33259d452f0fe4dbe5d8cf3392253f` |
| Job | `42b4b3b5-4c53-4368-aa49-ffdd71222d33` |
| Judged | 2026-10-20 19:31 UTC |
| Findings | 1 high · 3 medium · 3 low · 3 info |

Four agents audited the code as it is at `705e9d2`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. High: 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

`src/TombolaDraw.sol:178`

```
        uint256 target = word % totalWeight;
```

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.

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

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {DrandBeacon} from "src/DrandBeacon.sol";
import {TombolaDraw} from "src/TombolaDraw.sol";

/// Finding: settle() takes `totalWeight` as a free parameter. The Merkle root pins (winner, cumulativeBefore, weight)
/// per leaf but nothing pins the list's total, so after drand publishes the round the keeper may pick ANY leaf of
/// the honestly published list and choose a totalWeight that makes randomWord % totalWeight land in that leaf's
/// range. Every on-chain check (signature, proof, weights, range) passes.
///
/// This test fails on the current code (the keeper pays a leaf that is NOT the randomWord % realTotal winner)
/// and passes once totalWeight is bound to the pinned list (e.g. folded into every leaf, or pinned with the root
/// before the round time), because the forged settle then reverts.
contract TotalWeightUnboundTest is Test {
    DrandBeacon beacon;
    TombolaDraw draw;
    address keeper = makeAddr("keeper");
    address guardian = makeAddr("guardian");
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");
    address carol = makeAddr("carol");

    uint64 ROUND;
    bytes SIG;

    function setUp() public {
        string memory json = vm.readFile("test/fixtures/evmnet.json");
        ROUND = uint64(vm.parseJsonUint(json, ".rounds[0].round"));
        SIG = vm.parseJsonBytes(json, ".rounds[0].signature");
        beacon = new DrandBeacon();
        draw = new TombolaDraw(beacon, keeper, guardian, makeAddr("splitter"));
        vm.warp(beacon.roundTime(ROUND) - 20 minutes);
        vm.deal(address(this), 1 ether);
        draw.fundRegular{value: 1 ether}();
    }

    function _leaf(address a, uint256 cum, uint256 w) internal pure returns (bytes32) {
        return keccak256(bytes.concat(keccak256(abi.encode(a, cum, w))));
    }

    function _pair(bytes32 a, bytes32 b) internal pure returns (bytes32) {
        return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a));
    }

    function test_keeperPicksAnyListedWinnerByChoosingTotalWeight() public {
        // the honest list: alice [0,100e18), bob [100e18,350e18), carol [350e18,400e18); real total 400e18
        uint256 realTotal = 400e18;
        address[3] memory a = [alice, bob, carol];
        uint256[3] memory cum = [uint256(0), 100e18, 350e18];
        uint256[3] memory w = [uint256(100e18), 250e18, 50e18];
        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])];
        bytes32 root = _pair(_pair(leaves[0], leaves[1]), _pair(leaves[2], leaves[3]));

        vm.prank(keeper);
        uint256 id = draw.commit(TombolaDraw.Track.Regular, uint64(block.timestamp), ROUND, 1 ether);
        vm.warp(beacon.roundTime(ROUND) + 1);

        // the honest winner is randomWord % realTotal
        uint256 word = uint256(keccak256(abi.encode(keccak256(SIG), id)));
        uint256 honestTarget = word % realTotal;
        uint256 honest = 0;
        for (uint256 i = 0; i < 3; i++) if (honestTarget >= cum[i] && honestTarget < cum[i] + w[i]) honest = i;

        // the keeper picks a DIFFERENT listed wallet and chooses totalWeight so the word lands in its range:
        // for T in (word/2, word], word % T == word - T, so T = word - cum[pick] gives target == cum[pick]
        uint256 pick = (honest + 1) % 3;
        uint256 forgedTotal = word - cum[pick];
        assertGe(forgedTotal, cum[pick] + w[pick]);
        assertEq(word % forgedTotal, cum[pick]);

        bytes32[] memory proof = new bytes32[](2);
        proof[0] = leaves[pick ^ 1];
        proof[1] = pick < 2 ? _pair(leaves[2], leaves[3]) : _pair(leaves[0], leaves[1]);

        uint256 before = a[pick].balance;
        vm.prank(keeper);
        // EXPECTED (fixed code): the contract rejects a totalWeight the pinned list does not carry
        vm.expectRevert();
        draw.settle(id, SIG, root, forgedTotal, a[pick], cum[pick], w[pick], proof);
        // ACTUAL (current code): the call succeeds and the non-winner is paid the whole prize
        assertEq(a[pick].balance, before, "a wallet that did not win must not be paid");
        assertEq(draw.getDraw(id).winner, address(0));
    }
}
```

### 2. Medium: 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))

`src/TombolaDraw.sol:214`

```
        bool late = block.timestamp >= beacon.roundTime(d.drandRound) + ABANDON_AFTER;
```

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

**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'.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {DrandBeacon} from "src/DrandBeacon.sol";
import {TombolaDraw} from "src/TombolaDraw.sol";

/// commit() accepts a drand round arbitrarily far in the future; with no guardian (the deploy script's default)
/// the reserved prize and the track are then locked for good, because cancel's "late" path never opens.
contract FarRoundTest is Test {
    DrandBeacon beacon;
    TombolaDraw draw;
    address keeper = makeAddr("keeper");

    function setUp() public {
        beacon = new DrandBeacon();
        draw = new TombolaDraw(beacon, keeper, address(0), makeAddr("splitter"));
        vm.warp(1_760_000_000);
        vm.deal(address(this), 10 ether);
        draw.fundMega{value: 5 ether}();
    }

    function test_commitRefusesARoundFarInTheFuture() public {
        // a unix timestamp passed as the round number: round time lands around the year 2193
        uint64 mistaken = uint64(block.timestamp);
        assertGt(beacon.roundTime(mistaken), block.timestamp + 100 * 365 days);
        vm.prank(keeper);
        vm.expectRevert();
        draw.commit(TombolaDraw.Track.Mega, uint64(block.timestamp), mistaken, 5 ether);
    }
}
```

### 3. Medium: 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

`src/TombolaDraw.sol:125`

```
        if (snapshotTime > block.timestamp) revert WrongStage();
```

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.

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

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {DrandBeacon} from "src/DrandBeacon.sol";
import {TombolaDraw} from "src/TombolaDraw.sol";

/// commit() accepts any snapshotTime <= block.timestamp, however old. At commit time every past snapshot's list is
/// public, so the keeper can pick the historical moment at which a wallet of its choice held the largest (up to 100%)
/// share of the weight, and settle with a list that is honest for that moment and passes the README's verification.
contract StaleSnapshotTest is Test {
    DrandBeacon beacon;
    TombolaDraw draw;
    address keeper = makeAddr("keeper");
    address guardian = makeAddr("guardian");
    address insider = makeAddr("insider"); // the only eligible holder 31 minutes after launch
    uint64 ROUND;
    bytes SIG;

    function setUp() public {
        string memory json = vm.readFile("test/fixtures/evmnet.json");
        ROUND = uint64(vm.parseJsonUint(json, ".rounds[0].round"));
        SIG = vm.parseJsonBytes(json, ".rounds[0].signature");
        beacon = new DrandBeacon();
        draw = new TombolaDraw(beacon, keeper, guardian, makeAddr("splitter"));
        vm.warp(beacon.roundTime(ROUND) - 20 minutes);
        vm.deal(address(this), 1 ether);
        draw.fundRegular{value: 1 ether}();
    }

    function test_commitRejectsASnapshotFromLongAgo() public {
        // launch was 30 days ago; 31 minutes after launch only the insider had held >= 10,000 tokens for 30 minutes
        uint64 earlySnapshot = uint64(block.timestamp - 30 days + 31 minutes);
        vm.prank(keeper);
        try draw.commit(TombolaDraw.Track.Regular, earlySnapshot, ROUND, 1 ether) returns (uint256 id) {
            // the honest list for that snapshot has one row: the insider, so it wins with certainty
            vm.warp(beacon.roundTime(ROUND) + 1);
            uint256 w = draw.weightFor(10_000e18, earlySnapshot - 31 minutes, earlySnapshot);
            bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(insider, uint256(0), w))));
            vm.prank(keeper);
            draw.settle(id, SIG, root, w, insider, 0, w, new bytes32[](0));
            assertEq(insider.balance, 0, "a 30-day-old snapshot chosen at commit decided the whole pot");
        } catch {}
    }
}
```

### 4. Medium: 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("")

`src/TombolaDraw.sol:201`

```
        d.root = root;
```

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.

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

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {DrandBeacon} from "src/DrandBeacon.sol";
import {TombolaDraw} from "src/TombolaDraw.sol";

contract RolloverRerollTest is Test {
    DrandBeacon beacon;
    TombolaDraw draw;
    address keeper = makeAddr("keeper");
    uint64 ROUND;
    bytes SIG;

    function setUp() public {
        string memory json = vm.readFile("test/fixtures/evmnet.json");
        ROUND = uint64(vm.parseJsonUint(json, ".rounds[0].round"));
        SIG = vm.parseJsonBytes(json, ".rounds[0].signature");
        beacon = new DrandBeacon();
        draw = new TombolaDraw(beacon, keeper, makeAddr("guardian"), makeAddr("splitter"));
        vm.warp(beacon.roundTime(ROUND) - 20 minutes);
        vm.deal(address(this), 1 ether);
        draw.fundRegular{value: 1 ether}();
    }

    /// After the randomness is public the keeper can discard a draw that has eligible holders
    /// by calling rollover with a non-empty root, then re-commit for a fresh round.
    function test_rolloverWithNonEmptyRootMustRevert() public {
        vm.prank(keeper);
        uint256 id = draw.commit(TombolaDraw.Track.Regular, uint64(block.timestamp), ROUND, 1 ether);
        vm.warp(beacon.roundTime(ROUND) + 1);
        bytes32 nonEmptyRoot = keccak256(bytes.concat(keccak256(abi.encode(makeAddr("alice"), uint256(0), uint256(100e18)))));
        vm.prank(keeper);
        vm.expectRevert();
        draw.rollover(id, SIG, nonEmptyRoot);
    }
}
```

### 5. Low: 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

`src/TombolaDraw.sol:128`

```
        if (roundTime <= block.timestamp) revert RoundTooEarly(); // the round must still be in the future
```

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.

**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).

### 6. Low: 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

`src/TombolaDraw.sol:190`

```
        _pay(winner, amount);
```

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.

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

### 7. Low: 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

`src/TombolaDraw.sol:228`

```
        (bool ok,) = payable(to).call{value: amt}("");
```

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.

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

### 8. Info: 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

`src/TombolaDraw.sol:181`

```
        d.root = root;
```

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

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

### 9. Info: 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

`src/TombolaDraw.sol:123`

```
        if (msg.sender != keeper) revert NotKeeper();
```

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.

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

### 10. Info: DESIGN.md and README describe interfaces and guarantees the code does not have

`DESIGN.md:31`

```
  - `settle(id, winner, amount, snapshotRoot, drandSignature)` by the keeper: the beacon verifies the signature
```

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.

**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).

---

Judge's submission `495751a13932e981795516258b06bda366c6265783245f2a45d46cb8d89585dd`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
