# 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 0xa6fb19FD8ea1d9463f942e7B04b8ED2f1A715637, TombolaSplitter 0xF7dec43e3c3626A4F3A83b568896b6AdAb5343e2.
>
> This is a re-audit: job 42b4b3b5-4c53-4368-aa49-ffdd71222d33 audited commit 705e9d2 and reported 1 High (totalWeight unbound at settle), 3 Medium (far-future round, stale snapshot, rollover/cancel veto), 3 Low (1-second lead, contracts as winner, locked credits) and 3 Info (keeper trust, dead keeper, docs). Every one was addressed in this commit (see the commit message and README). Please confirm each is closed and look for anything the changes introduced, in particular around the list now being pinned at commit (root, totalWeight, EMPTY_ROOT), the cancel windows, setKeeper, redirectCredit and expireCredit.

| | |
|---|---|
| Repository | https://github.com/JJ-ME55/tombola-contracts.git |
| Commit | `bb37d2c6f2db7d46cfc393ab1c622c9be30d75d0` |
| Job | `ad6fc8d6-e85b-4015-998f-e3dc092398ad` |
| Judged | 2026-10-20 20:21 UTC |
| Findings | 2 medium · 2 low · 5 info |

Four agents audited the code as it is at `bb37d2c`, 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. Medium: Keeper can veto a publicly known outcome: settle()/rollover() are keeper-only and the 7-day abandon cancel re-rolls a settleable prize

`src/TombolaDraw.sol:185`

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

README.md (Trust model, lines 88-90) and the contract header (line 25: 'a known outcome can't be vetoed') state that once the round is public the keeper can neither choose the outcome nor decide whether to accept it. The code enforces only the first half. After the committed round is published every input settle() needs is public and verified on chain: the root and totalWeight are pinned at commit, drand's signature is verifiable by anyone (beacon.prove is permissionless), and settle() itself checks the leaf, the proof and the modulus range. Yet settle() (line 185) and rollover() (line 213) revert NotKeeper for anyone else, and the guardian cannot cancel between roundTime and roundTime + ABANDON_AFTER (lines 232-236). A keeper that dislikes the proven winner simply never calls settle(). Seven days after the round anyone, the keeper included, calls cancel(id): _return() puts the full prize back into the pot and the keeper commits it again to a fresh round with a fresh list. Repeating this until a favoured wallet wins biases the lottery at no ETH cost, only a 7-day lock per re-roll. This is the same veto the previous audit's Medium 4 closed for the guardian (cancel refused after roundTime) and for rollover (refused on a non-empty list); it survives through keeper non-settlement. The brief accepts the keeper choosing WHEN to commit; it does not accept the keeper choosing whether to honour a known outcome, and the README claims the contract prevents it. Impact: the proven winner of any draw loses the whole prize (`amount`, up to the full pot of the track), which goes back under the keeper's choice. Fix: drop the keeper check from settle() and rollover() (every argument is validated against pinned data and the BLS signature, so the caller adds nothing), or at minimum open them to anyone once block.timestamp >= beacon.roundTime(d.drandRound). The abandon cancel then only reaches draws that are genuinely unsettleable. Duplicates merged: audit_economics d182f69a, audit_math c3e1f89d, audit_permissions f7d1e37d, audit_flow 285f1797.

**Reproduction**

setUp of test/TombolaDraw.t.sol (chain 20 minutes before the fixture round). 1) draw.fundRegular{value: 1 ether}(); keeper commits (Regular, now, ROUND, 1 ether, leaf(alice, 0, 1e18), 1e18): a one-wallet honest list, alice must win whatever drand says. 2) vm.warp(beacon.roundTime(ROUND) + 1); anyone may call beacon.prove(ROUND, SIG), the outcome is fixed. 3) vm.prank(alice); draw.settle(id, SIG, alice, 0, 1e18, []) -> expected: alice receives 1 ether; actual: revert NotKeeper(). The same call from the guardian reverts NotKeeper(). 4) vm.prank(guardian); draw.cancel(id) -> revert TooEarly(). 5) vm.warp(roundTime + 7 days); vm.prank(keeper); draw.cancel(id) -> succeeds: potRegular == 1 ether, alice.balance == 0, getDraw(id).cancelled == true. 6) keeper commit(Regular, now, roundAt(now + 11 min), 1 ether, leaf(bob, 0, 1e18), 1e18) -> succeeds: the same prize is re-drawn. test/scratch/Judge.t.sol::test_keeperReRollsAKnownOutcome runs steps 1-6 and passes on this code; the attached proof fails with NotKeeper() on this code and passes once settle() is permissionless after the round.

**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";
import {TombolaSplitter, IPonsFeeEscrow, ITombolaDrawPots} from "src/TombolaSplitter.sol";

/// The keeper can veto a KNOWN outcome: settle() and rollover() are keeper-only and nothing else can close a
/// settleable draw, so a keeper that dislikes the winner simply never settles. ABANDON_AFTER later anyone (the
/// keeper included) cancels, the prize returns to the pot and the keeper re-draws it against a new round.
/// README: "The keeper can choose when to commit ... but neither which outcome a round will give nor, once it
/// is known, whether to accept it." The code does not enforce the second half.
/// Fails now (NotKeeper on a third party's settle, and the abandon cancel re-rolls a settleable draw); passes once
/// settle() is open to anyone holding the public inputs (drand signature + published leaf).
contract KeeperVetoTest is Test {
    DrandBeacon beacon;
    TombolaDraw draw;
    address keeper = makeAddr("keeper");
    address guardian = makeAddr("guardian");
    address alice = makeAddr("alice");
    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();
        address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);
        draw = new TombolaDraw(beacon, keeper, guardian, predicted);
        new TombolaSplitter(ITombolaDrawPots(address(draw)), makeAddr("team"), IPonsFeeEscrow(address(0)));
        vm.warp(beacon.roundTime(ROUND) - 20 minutes);
        vm.deal(address(this), 1 ether);
        draw.fundRegular{value: 1 ether}();
    }

    function test_thirdPartyCanSettleAKnownOutcomeSoTheKeeperCannotVetoIt() public {
        // a one-wallet honest list: alice holds all the weight, so she is the winner whatever the round says
        bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(alice, uint256(0), uint256(1e18)))));
        vm.prank(keeper);
        uint256 id = draw.commit(TombolaDraw.Track.Regular, uint64(vm.getBlockTimestamp()), ROUND, 1 ether, leaf, 1e18);

        // the round is published: the outcome is public and fixed
        vm.warp(beacon.roundTime(ROUND) + 1);
        bytes32[] memory proof = new bytes32[](0);

        // the keeper does not like it and never calls settle(). Nobody else may close the draw with the public
        // inputs: this is the defect. Expected: the settle succeeds and alice is paid.
        vm.prank(alice);
        draw.settle(id, SIG, alice, 0, 1e18, proof);
        assertEq(alice.balance, 1 ether, "the proven winner is paid regardless of the keeper's wishes");

        // and consequently the abandon-cancel can no longer re-roll her prize
        vm.warp(beacon.roundTime(ROUND) + 7 days);
        vm.expectRevert(TombolaDraw.WrongStage.selector);
        draw.cancel(id);
    }
}
```

### 2. Medium: setKeeper() is blocked by any open draw, so a leaked keeper key denies its own rotation by re-committing after every guardian cancel

`src/TombolaDraw.sol:247`

```
        if (activeDraw[Track.Regular] != 0 || activeDraw[Track.Mega] != 0) revert TrackBusy();
```

README.md line 90-91 names the guardian's cancel window and a keeper rotation as 'the answer' to a leaked hot key. setKeeper() (line 247) reverts TrackBusy while either track has an open draw, and only the keeper opens draws: commit() needs just 1 wei in a pot (anyone can supply it through receive(), fundRegular() or fundMega()) and any round 10 minutes to 1 day ahead. The deployed guardian 0x4c6B36b84F14E3111c732136bb38803E82bBCC46 is an EOA (cast code is empty on chain 4663), so cancel(id) and setKeeper(next) are two transactions. A bot holding the leaked key re-commits in the block after each cancel lands; setKeeper() keeps reverting TrackBusy. Worse, once a draw's round time has passed the guardian cannot cancel at all for 7 days (line 234-235), so a leaked key that commits and then stays silent freezes rotation for 7 days per draw with no race needed, and re-commits the block after the abandon cancel. Each attacker draw can carry amount = the whole pot with a one-leaf list naming the attacker; the guardian must cancel every one inside the 10-minute COMMIT_DELAY window, forever, and the first miss pays the attacker the entire reserved amount at settle (potRegular or potMega in full). Impact: the only key-compromise recovery path in the design is defeatable at the cost of gas, and the pots are then protected only by the guardian never missing a 10-minute window. Fix: let setKeeper() succeed while draws are open (open draws are fully pinned; the new keeper can settle or roll them over and the guardian can still cancel), or make setKeeper() cancel open draws whose round is still in the future in the same transaction, or add a guardian-only cancelAndRotate(id, newKeeper). Duplicates merged: audit_economics c2d6fb1d, audit_math abc13fbf, audit_permissions d68c9884, audit_flow df9f0308.

**Reproduction**

setUp of test/TombolaDraw.t.sol with 10 ether in potRegular. 1) prank(keeper): commit(Regular, now, ROUND, 10 ether, leaf(attacker, 0, 1), 1) -> id. 2) prank(guardian): cancel(id) -> ok, activeDraw cleared. 3) same or next block, prank(keeper): commit(Regular, now, ROUND, 10 ether, leaf(attacker, 0, 1), 1) -> ok. 4) prank(guardian): setKeeper(next) -> expected: keeper == next; actual: revert TrackBusy(). Variant: attacker calls draw.fundMega{value: 1}() then the keeper commits (Mega, now, ROUND, 1 wei, ...) -> setKeeper reverts TrackBusy as well. The attached proof (two tests) fails with TrackBusy() on this code and passes once setKeeper() no longer depends on the keeper's cooperation.

**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";
import {TombolaSplitter, IPonsFeeEscrow, ITombolaDrawPots} from "src/TombolaSplitter.sol";

/// Finding: setKeeper() reverts TrackBusy while any draw is open, and the keeper may open one at any time with
/// 1 wei of pot (which anyone can supply through receive()). A leaked keeper key therefore re-commits right after
/// every guardian cancel and the guardian can never rotate it: the README's answer to a leaked key does not work
/// against a bot. Fails now: the guardian's setKeeper() after cancel + re-commit reverts TrackBusy.
/// Passes once setKeeper() no longer depends on the keeper's cooperation (e.g. allowed with open draws, which the
/// new keeper can settle from the published list, or cancelling them itself).
contract KeeperRotationTest is Test {
    DrandBeacon beacon;
    TombolaDraw draw;
    TombolaSplitter splitter;
    address keeper = makeAddr("leaked keeper key");
    address guardian = makeAddr("guardian");
    address team = makeAddr("team");
    address attacker = makeAddr("attacker"); // whoever holds the leaked key

    uint64 ROUND;

    function setUp() public {
        string memory json = vm.readFile("test/fixtures/evmnet.json");
        ROUND = uint64(vm.parseJsonUint(json, ".rounds[0].round"));
        beacon = new DrandBeacon();
        address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);
        draw = new TombolaDraw(beacon, keeper, guardian, predicted);
        splitter = new TombolaSplitter(ITombolaDrawPots(address(draw)), team, IPonsFeeEscrow(address(0)));
        assertEq(address(splitter), predicted);
        vm.warp(beacon.roundTime(ROUND) - 20 minutes);
        vm.deal(address(this), 10 ether);
        draw.fundRegular{value: 10 ether}();
    }

    function _dishonestCommit(TombolaDraw.Track track, uint256 amount) internal returns (uint256 id) {
        // the attacker as the only leaf: it wins whatever drand says, unless the guardian cancels within the window
        bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(attacker, uint256(0), uint256(1)))));
        vm.prank(keeper);
        id = draw.commit(track, uint64(block.timestamp), ROUND, amount, root, 1);
    }

    function test_leakedKeeperBlocksItsOwnRotationByRecommitting() public {
        address next = makeAddr("next keeper");
        uint256 id = _dishonestCommit(TombolaDraw.Track.Regular, 10 ether);

        // the guardian notices in time and cancels; the bot re-commits in the same block, before setKeeper lands
        vm.prank(guardian);
        draw.cancel(id);
        _dishonestCommit(TombolaDraw.Track.Regular, 10 ether);

        vm.prank(guardian);
        draw.setKeeper(next); // TrackBusy on the current code: the rotation never happens
        assertEq(draw.keeper(), next, "the guardian must be able to revoke a leaked key");
    }

    function test_oneWeiOnTheOtherTrackIsEnoughToBlockRotation() public {
        address next = makeAddr("next keeper");
        // the attacker supplies the 1 wei itself: fundMega() is open to anyone
        vm.deal(attacker, 1);
        vm.prank(attacker);
        draw.fundMega{value: 1}();
        _dishonestCommit(TombolaDraw.Track.Mega, 1);
        vm.prank(guardian);
        draw.setKeeper(next); // TrackBusy on the current code
        assertEq(draw.keeper(), next);
    }
}
```

### 3. Low: expireCredit() returns an uncollected Mega-track prize to the regular pot: anyone can move ETH between pots

`src/TombolaDraw.sol:274`

```
        potRegular += amt;
```

A failed payout is credited per address (unclaimed, creditedAt) without remembering which track the draw was on, and expireCredit() unconditionally adds the expired credit to potRegular (line 274). A Mega prize (the 20% share) whose winner is a contract that cannot receive ETH and never calls claim/redirectCredit therefore becomes regular-pot money after CREDIT_EXPIRY, and anyone may trigger that at the first second. README line 61 and line 84 do document 'returned to the regular pot', so the code matches the docs, but the brief's trust item (4) says ETH goes back into 'a pot' on rollover/cancel and asks that any inter-pot movement be flagged: this is the one path where mega-draw ETH is redistributed to regular-draw participants, by a third party. No ETH leaves the contract and solvent() holds. Impact: up to the full mega prize amount shifts from the mega pot to the regular pot. Fix: record the track with the credit (e.g. mapping(address => uint256) per track, or expire per draw id and use d.track) and return it to that pot, or keep the behaviour and say so in the trust model. Duplicates merged: audit_economics 3ba2eaca, audit_permissions 2a25909d, audit_flow f51e8fb8.

**Reproduction**

setUp of test/TombolaDraw.t.sol. RejectsEth r; draw.fundMega{value: 1 ether}(); prank(keeper): commit(Mega, now, ROUND, 1 ether, leaf(r, 0, 1e18), 1e18); vm.warp(roundTime(ROUND) + 1); prank(keeper): settle(id, SIG, r, 0, 1e18, []) -> the send fails, unclaimed[r] == 1 ether, potMega == 0; vm.warp(+365 days); anyone: expireCredit(r) -> expected: potMega == 1 ether; actual: potRegular == 1 ether, potMega == 0. test/scratch/Judge.t.sol::test_expireCreditMovesMegaPrizeToRegularPot passes on this code, showing the state.

### 4. Low: Guardian cancel has no clock-skew margin: a cancel in a block stamped roundTime - 1 is accepted although drand's signature may already be public

`src/TombolaDraw.sol:232`

```
        if (block.timestamp < roundTime) {
```

The guardian's veto is meant to end when the outcome becomes public (README line 65-67). The test is block.timestamp < beacon.roundTime(round) (line 232). drand evmnet publishes round r at wall-clock roundTime(r) and its API serves the signature within the same second; the L2 block timestamp is set by the sequencer and trails wall-clock (measured during this review: the latest Robinhood Chain block was stamped 1 s behind wall-clock). In a block stamped roundTime - 1 the guardian (or anyone with the guardian's key, or a guardian colluding with the keeper) can already read the signature, compute the winner, and still cancel: a known outcome is vetoed and the prize returns to the pot. The previous audit's 'one-second lead' Low was closed on the commit side with COMMIT_DELAY (line 149); the symmetric margin is missing on the cancel side. The guardian is trusted, and the window is a second or two, so this is Low. Impact: the prize of one draw (`amount`) is re-rolled despite a public outcome. Fix: require block.timestamp + CANCEL_MARGIN < roundTime for the guardian path (one minute is plenty), so no block the sequencer can plausibly stamp overlaps drand's publication. Duplicate: audit_flow 74df73c1.

**Reproduction**

setUp of test/TombolaDraw.t.sol. _fund(1 ether); id = _commit(1 ether); vm.warp(beacon.roundTime(ROUND) - 1); beacon.isValid(ROUND, SIG) == true (the real signature exists and verifies); vm.prank(guardian); draw.cancel(id) -> succeeds, getDraw(id).cancelled == true, potRegular == 1 ether. Expected: a cancel inside the sequencer's clock tolerance of the round's publication is refused. test/scratch/Judge.t.sol::test_guardianCancelsOneSecondBeforeTheRound passes on this code, showing the state.

### 5. Info: Accepted keeper trust: overlapping, gapped or falsely empty lists are accepted on chain; the keeper may pick between two winners or force a free re-roll

`src/TombolaDraw.sol:198`

```
        if (target < cumulativeBefore || target >= cumulativeBefore + weight) revert BadWinner();
```

Informational, inside the brief's stated trust model: the contract checks only that the winner's leaf is in the pinned tree, weight > 0, cumulativeBefore + weight <= totalWeight (line 194) and that the target falls in the leaf's range (line 198). It cannot check that the ranges partition [0, totalWeight). Keeper-controlled shapes: (1) overlapping ranges, e.g. leaves (alice, 0, 100e18) and (bob, 0, 100e18) with totalWeight 100e18: after the round both leaves prove and the keeper picks the winner; (2) a gapped total, e.g. an honest 400e18 list pinned with totalWeight 800e18: with probability 50% no leaf covers the target, nobody can settle, rollover() reverts WrongStage (totalWeight != 0), and after ABANDON_AFTER the prize returns to the pot, a free re-roll; (3) EMPTY_ROOT with totalWeight 0 while holders exist: rollover only. All are visible to anyone recomputing the list in the commit-to-round window, and the guardian can cancel them there. Also by design: the keeper's own wallet, the guardian or the team wallet may be leaves (only address(0), the draw and the splitter are refused at line 188), and the 10-minute COMMIT_DELAY is the whole reaction window for a cold-wallet guardian. Nothing to change in the contract; the off-chain verifier should refuse an unpublished list, check that cumulativeBefore values are contiguous and sum to the pinned total, and the guardian should cancel any draw whose list is not public well before roundTime. Hardening options if wanted: let settle() also take the neighbouring leaf to prove contiguity, and prove the last leaf's cumulativeBefore + weight == totalWeight at commit. Duplicates merged: audit_economics f4599194, audit_permissions aa4cfc58, audit_flow dc459a19.

**Reproduction**

setUp of test/TombolaDraw.t.sol. prank(keeper): commit(Regular, now, ROUND, 1 ether, root over {leaf(alice,0,100e18), leaf(bob,0,100e18)}, 100e18) -> accepted. vm.warp(roundTime(ROUND) + 1). settle(id, SIG, alice, 0, 100e18, [leafBob]) pays alice 1 ether; from the same state settle(id, SIG, bob, 0, 100e18, [leafAlice]) pays bob 1 ether. test/scratch/Judge.t.sol::test_overlappingRangesLetTheKeeperChoose shows both succeed (vm.snapshotState between them). Expected per README: the list identifies one winner; actual: two leaves qualify and the keeper chooses.

### 6. Info: Commit-before-randomness rests on the sequencer's block.timestamp being within COMMIT_DELAY (10 min) of wall-clock time

`src/TombolaDraw.sol:149`

```
        if (roundTime < block.timestamp + COMMIT_DELAY) revert RoundTooEarly();
```

The only guards against committing to a round whose signature already exists are roundTime >= block.timestamp + COMMIT_DELAY (line 149) and the on-chain randomness(round) != 0 check (line 151), which only catches rounds somebody has already proven on this chain. drand evmnet publishes every 3 s, so a sequencer clock lagging wall-clock by >= 600 s would let the keeper commit to a published round and steer the result through snapshotTime (30-minute range) and the list. Measured during this review the latest Robinhood Chain block was stamped 1 s behind wall-clock, and Arbitrum-family sequencers stay within seconds, so this is a chain-level trust assumption rather than a defect. The roundTime arithmetic is otherwise sound: round 0 reverts on underflow, MAX_LEAD (line 150) bounds large rounds, settle/rollover use only d.drandRound so no other round can be substituted. Nothing to change unless the chain's timestamp policy changes; a larger COMMIT_DELAY would widen the margin at the cost of a longer guardian window. Duplicates merged: audit_economics cfe1f167, audit_permissions 02ac83fc.

**Reproduction**

State: sequencer block.timestamp == wallClock - 600 (or more). keeper calls commit(track, snapshotTime = block.timestamp, round = roundAt(block.timestamp + 600), ...) -> accepted, although drand already published that round in real time. Not reproducible in Foundry beyond vm.warp; measured live lag is 1 s (block 1792527489 vs clock 1792527490).

### 7. Info: README table and DESIGN.md still describe the pre-pin interface (root supplied at settle, list built after the round)

`README.md:12`

```
| [`src/TombolaDraw.sol`](src/TombolaDraw.sol) | Holds the two pots and the draw ledger. The keeper `commit`s a draw to a future drand round, then `settle`s it with drand's signature, the Merkle root of the published eligibility list and the winner's leaf proof. Pays the winner. |
```

The commit message for bb37d2c closes the previous audit's Info 10 with 'README and DESIGN match the shipped interface'. Four passages still describe the previous design: README.md line 12 (the keeper settles 'with drand's signature, the Merkle root of the published eligibility list and the winner's leaf proof'; settle() now takes only the winner's leaf, the root is pinned by commit); DESIGN.md line 17 ('the round committed on chain before the snapshot'; the snapshot now precedes the commit); DESIGN.md lines 33-36 (settle 'records the winner, the Merkle root of the published snapshot'); DESIGN.md lines 48-53 (the keeper builds and publishes the snapshot 'after the drand round is published' and then settles, exactly the flow the re-audit fix removed). DESIGN.md calls the verifier verify-drop.mjs while README calls it verify-draw.mjs. A verifier following DESIGN.md would accept a list published after the round. README steps 2-4 and the trust model are correct except the keeper-veto sentence covered by the Medium finding. Fix: reword the four passages to the commit-pins-list flow. Duplicates merged: audit_permissions c5046d15, audit_flow d048a35e.

**Reproduction**

Compare README.md line 12 and DESIGN.md lines 17, 33-36, 48-53 with src/TombolaDraw.sol: commit(Track,uint64,uint64,uint256,bytes32,uint256) at line 140 takes root and totalWeight and requires snapshotTime <= block.timestamp (line 146); settle(uint256,bytes,address,uint256,uint256,bytes32[]) at line 181 has no root parameter. Expected: the docs describe the shipped signatures; actual: they describe a settle that supplies the root and a list built after the round.

### 8. Info: harvest() reverts as a whole if the Pons escrow's claim() ever reverts while balanceOf() is non-zero, stranding ETH already in the splitter

`src/TombolaSplitter.sol:58`

```
            emit Harvested(escrow.claim());
```

Dependency-failure note. harvest() calls escrow.claim() whenever escrow.balanceOf(splitter) > 0 with no fallback (line 58). If the escrow ever reverts on claim() (a pause, an upgrade, a changed rule) harvest() reverts entirely, so ETH already sitting in the splitter cannot reach the pots or the team until the escrow is fixed, and the splitter is immutable and bound to 0xd3AFEB2a57f70eF218Aa82451c51B2fb0416Ac9e forever (confirmed on chain). The specialists report the live escrow reverts only on a zero balance, which the balanceOf guard already covers, so no failing state exists today. A try/catch around the claim (distribute what is here even when the claim fails) would remove the coupling. Duplicate: audit_economics d5b9e83d.

**Reproduction**

test/scratch/Judge.t.sol::test_harvestBlockedByRevertingEscrow: a TombolaSplitter bound to an escrow whose balanceOf() returns 1 and whose claim() reverts, with 1 ether already in the splitter; harvest() -> expected: at least the splitter's own 1 ether is distributed; actual: the whole call reverts with the escrow's reason and nothing is distributed.

### 9. Info: Re-audit closure check: all ten findings of job 42b4b3b5 are closed in this commit, with the two residuals reported above

`src/TombolaDraw.sol:152`

```
        if (totalWeight == 0 ? root != EMPTY_ROOT : (root == bytes32(0) || root == EMPTY_ROOT)) revert BadWeights();
```

High 1 (totalWeight unbound at settle): closed; root and totalWeight are commit parameters (line 140), validated at line 152 and used as the modulus at line 197; test_settleRefusesTheWrongWinner covers it. Medium 2 (far-future round): closed by MAX_LEAD at line 150. Medium 3 (stale snapshot): closed by MAX_SNAPSHOT_AGE at line 146. Medium 4 (rollover/cancel veto): closed for rollover (line 215 refuses a non-empty list) and for the guardian (lines 232-236); the keeper keeps an equivalent veto through non-settlement, reported above as Medium. Low 5 (1-second lead): closed on the commit side at line 149; the cancel side lacks the margin, reported above as Low. Low 6 (contracts as winner): closed by line 188 and redirectCredit (line 262). Low 7 (locked credits): closed by expireCredit (line 271). Info 8/9 (keeper trust, dead keeper): setKeeper added (line 244); the rotation can be blocked by an open draw, reported above as Medium. Info 10 (docs): mostly closed, four stale passages remain (Info above). Also verified in this review: solvent() holds on every path exercised (commit, settle paid or credited, rollover, both cancel windows, claim, redirectCredit, expireCredit, fundRegular/fundMega/receive, splitter harvest); uint128 truncation in commit is prevented by the type(uint128).max bound at line 154; the double-hashed 96-byte leaf cannot collide with a 64-byte inner node, so neither an intermediate node nor the duplicated padding leaf can be proven as a different winner; root == 0 and EMPTY_ROOT misuse are refused at line 152; nonReentrant covers settle/rollover/cancel/claim/redirectCredit/expireCredit and the unguarded fund functions only add; the 50,000 gas stipend bounds a return bomb to what 50k gas can allocate; the splitter conserves every wei, holds a team share whose send fails or re-enters in teamOwed without blocking the pots (test_teamWalletReentryIsHeld), and never calls the draw's receive(); activeDraw can never lock permanently because roundTime <= commit + MAX_LEAD and the abandon cancel is permissionless; guardian == address(0) is refused by the constructor. On chain 4663 the deployed TombolaDraw's keeper, guardian, splitter and beacon and the splitter's draw and escrow match the documented addresses; both keeper and guardian are EOAs; pots, reserved and drawCount are 0.

**Reproduction**

Verification only: forge test (24 passing), the six specialist proof tests under test/scratch/ (all fail for their stated reason), test/scratch/Judge.t.sol (6 passing), and cast calls against 0xa6fb19FD8ea1d9463f942e7B04b8ED2f1A715637 and 0xF7dec43e3c3626A4F3A83b568896b6AdAb5343e2 on https://rpc.mainnet.chain.robinhood.com. No failing input; this entry records what was confirmed closed and sound.

---

Judge's submission `2d51176d3d52f8855b4e5732adc3d87d28aa1c3df6b449c98bd67958e0721da4`, 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.
