# MegaDraw v3 — design and threat model

An hourly holder draw for a plain ERC-20 launched on Pons V2 (Robinhood Chain, chain id 4663). The token has
no hooks and cannot tell the draw anything, so everything the draw knows it reads itself, in public, on chain.

Files: `pons/MegaDraw.sol` (the draw), `SeedSource.sol` (the randomness), `pons/IPonsV2FeeEscrow.sol` (the two
escrow calls). The website's verifier is `js/drawmath.js`; the browser test suite is `contracts/tests/`.

## The rules

| | |
| --- | --- |
| Ticket | 50,000 tokens, rounded down, per wallet |
| Who plays | wallets on the contract's list (`register()`, open to anyone), that are not contracts and not excluded |
| List cap | `maxHolders`, raisable by anyone within the gas budget (never lowered); when full, a listed wallet under one ticket — or holding fewer whole tickets than a newcomer — can be `replace()`d |
| Readings | four checkpoints, due 10:00 / 7:40 / 5:20 / 3:00 before the hour, each with a 70-second slot |
| Counted balance | the lowest reading from the start checkpoint (chosen by the seed) to the fourth |
| Sitting out | any reading worth fewer whole tickets than the wallet's previous reading |
| Prize | the pot at the fourth checkpoint; fees arriving later belong to the next hour |
| Seed | keccak256 of three block hashes, committed at the fourth checkpoint (`seedBlock = commit + seedDelay`, then `+seedStep`, `+2·seedStep`) |
| Finalists | three different wallets, each drawn weighted by the tickets not yet drawn |
| Winner | one of the finalists, equal odds; must still hold `tickets × 50,000` at the draw, else the next finalist in draw order |
| Payout | in the same transaction: the winner gets the prize minus a fixed 4.75% team fee (TEAM_BPS, to the deploy-time team wallet) and a 0.25% caller tip (capped at `tipCap`) |
| Owner | `setExcluded()` before `lock()`, nothing after; no round can run before the lock |

Derived numbers, all public and reproducible:

```
seed            = keccak256(abi.encode(h(seedBlock), h(seedBlock+step), h(seedBlock+2·step)))
startCheckpoint = keccak256(abi.encode(seed, contract, roundId, 1, 0)) % 4
finalist j      = keccak256(abi.encode(seed, contract, roundId, 2, j)) % (tickets not yet drawn)
final pick      = keccak256(abi.encode(seed, contract, roundId, 3, 0)) % finalists
```

## Why each rule is there

**Four readings, one transaction each.** A balance can only be read, never reported, so the contract reads every
listed wallet inside a single transaction: nothing can move between two reads of one transaction, so no token is
ever counted twice. Each chunk of 100 balances is committed as a keccak hash and emitted; `write()` only accepts
lists that match, so the tickets do not depend on who pushes the data.

**A randomized start.** If the count were a single known moment, the cheapest attack would be to buy into it and
sell straight after. Instead the count starts at one of the four readings, chosen after all four exist by blocks
that did not exist when they were taken. A wallet that holds from before the first reading always counts in full;
a wallet that arrives later counts only if the start lands on or after its arrival — one chance in four for the
last reading.

**Slots, not deadlines only.** Each checkpoint may be taken in a 70-second slot. A checkpoint taken late is
refused and the round moves to the next hour, so nobody — keeper included — can hold a reading back to a moment
that suits them. Rounds are skipped, never bent.

**Only plain wallets may read or draw.** `checkpoint()` and `draw()` require `msg.sender == tx.origin` with no
code, so nobody can wrap a reading inside their own transaction: borrow tokens, be counted, hand them back. A
buyer must therefore hold across separate transactions and pay the tax both ways.

**Commit, then reveal.** The prize and the balances are fixed before the seed blocks exist. Nobody, including
whoever sends the last checkpoint, can know the seed at that point.

**A seed is never re-rolled.** If a hash is unreadable the round reverts; only if the blocks have really left the
chain's hash window (about 11 hours of nobody calling) is the round *voided* and the pot rolled over. Waiting can
cost a round, but it can never produce a second attempt at the same tickets.

**Three finalists, equal final odds.** Weighted sampling picks who reaches the final; the final itself is equal.
This trims the largest wallets' share and lifts everyone else's a little. It is not a cure for concentration: a
large holder who splits across wallets gets most of it back (see `js/content.js` → Provably page for the exact
numbers).

**Hold until the draw.** The drawn finalist must still hold the tokens its tickets came from when the draw runs,
a few minutes after the hour; otherwise the prize passes down the finalist list. This keeps a wallet from being
counted and emptied in the same hour.

## Threat model

| Who | What they could try | What stops it |
| --- | --- | --- |
| A holder | flash-borrow tokens around a checkpoint | reads only happen in a transaction sent by a plain wallet |
| A holder | buy just before a reading, sell right after | the start checkpoint is unknown until afterwards; the round trip costs the tax twice |
| A holder | split into many wallets | tickets round down per wallet; splitting only helps by reaching several finalist slots, and that is published |
| A holder | sell after being counted | the winner must still hold its tokens at the draw |
| Anyone | forge the balances in `write()` | every list is checked against the hash committed at the checkpoint |
| Anyone | grief the holder list | the list is capped and wallets under a ticket can be replaced; a spammer must hold 50,000 tokens per slot at the moment of listing |
| The keeper (the team) | choose when a reading happens | only inside a 70-second slot, and anyone else may send it |
| The keeper | withhold the draw to re-roll | the seed is fixed for the round; waiting past the hash window voids the round instead of re-drawing |
| The team | change the rules | after `lock()` the contract has no owner and no function to change anything |
| The team | take the pot | the prize asset can only leave as a prize, a tip or a held payout |
| Pons | redirect future fees | only the current recipient (this contract, which cannot) — except Pons' protocol owner, whose override is announced on chain and takes 3 days |
| The sequencer | bias a seed | it produces every block, so it is the one party that could. There is no VRF on this chain; this is stated on the site |

## Gas, and what a round really costs

**Checkpoint cost, measured on Robinhood Chain.** `eth_estimateGas` on `checkpoint()` against the real deployed
bytecode (immutables filled by the real constructor), with the registry written in through state overrides —
`locked`, `holderCount`, `roundEndsAt` and every `holderAt` entry, the layout checked by reading each value back
first. Balances are read from the real token. Fresh storage, so these are the first-round (worst) figures:

| listed wallets | `checkpoint()` | of the 32M per-transaction cap |
| --- | --- | --- |
| 1,000 | 6,267,651 | 20% |
| 2,000 | 12,442,231 | 39% |
| 4,000 | 24,839,112 (the last checkpoint: 24,851,702) | 78% |

That is **~6,190 gas per listed wallet** plus ~77k fixed, and it is linear. The browser suite's gas column was
far lower because its harness keeps one EIP-2929 access set for a whole scenario, so storage the registration had
already touched was priced warm (100) instead of cold (2,100). Its correctness results are unaffected.

**Everything else per round.** `write` is ~17,000 per wallet (derived from the EVM price list: a slot read, two
slot writes, `holderAt`, `EXTCODESIZE`, the `Snapshotted` log, four balances of calldata — not yet measured on
chain), `resolve` ~80k, `draw` ~0.41M (send with ~900k), a fee `sweep` ~150k. `register` is ~50k per wallet.

| listed wallets | one `checkpoint` | of the cap | whole round | at 0.0586 gwei | per day |
| --- | --- | --- | --- | --- | --- |
| 500 | 3.2M | 10% | 22M | 0.0013 ETH | 0.031 ETH |
| 1,000 | 6.3M | 20% | 43M | 0.0025 ETH | 0.060 ETH |
| 2,000 | 12.5M | 39% | 85M | 0.0050 ETH | 0.119 ETH |
| 3,000 | 18.7M | 58% | 126M | 0.0074 ETH | 0.178 ETH |
| 4,000 | 24.8M | 78% | 168M | 0.0099 ETH | 0.236 ETH |
| 5,000 | 31.0M | 97% | 210M | 0.0123 ETH | 0.295 ETH |

**What sizes `maxHolders`.** `maxTxGasLimit` on this chain is 32,000,000 (`ArbGasInfo.getGasAccountingParams()`
at `0x…006C`; the largest transaction seen on chain is 17.8M) and `checkpoint()` must read every listed wallet
in **one** transaction. It cannot be split: reading part of the list in one transaction and the rest in another
would let a holder move tokens between two of their own wallets between the parts and be counted twice. So:

- the hard ceiling is **~5,150** listed wallets;
- the keeper must never ask for more than 32M: at 4,000 wallets its default 1.3× gas buffer asks for 32.3M and
  the transaction is refused, so the checkpoint limit has to be clamped below the cap;
- the cap is permanent and there is no owner after `lock()`. If a future network upgrade reprices storage reads
  (Ethereum's 2021 Berlin upgrade roughly tripled exactly these costs) or the chain lowers its per-transaction
  limit, a full list that no longer fits in one transaction stops every checkpoint, and `prune()` cannot shrink
  a list of wallets that still hold tickets. Headroom against that: 2,000 → costs can rise 2.6×; 3,000 → 1.7×;
  4,000 → 1.29×.

The keeper earns the 0.25% caller tip, capped by `tipCap`; the team fee (4.75% of each prize) goes to the team wallet whoever runs the round — together 5% when the team's own keeper draws. On tips alone the keeper breaks even on a pot of about 1.0 ETH an hour at 1,000 wallets, 3.0 at 3,000 and 3.9 at 4,000 — treat the tips as a rebate, not the budget.

**The launch starts at `maxHolders = 4000`, and the cap can only ever RISE.** A full list reads in ~24.8M gas, 78% of
the per-transaction limit. Anyone may call `raiseCap(newCap)` between rounds: the new cap must fit `newCap * g + 500k
<= 30.5M`, where g is the WORSE of the hardcoded cold cost (6,190/wallet, measured on chain) and what the last full
read measured on itself — the self-measurement can veto a raise (say the chain reprices gas upward) but never promise
one (an access list could pre-warm storage and fake a cheap read), so the honest ceiling is ~4,846 and ABS_MAX_HOLDERS
= 5,000 bounds it forever. When the list is full, `replace()` also seats any wallet holding strictly more whole
tickets in place of a smaller one (rate-limited to cap/50+1 larger-evictions per round), so a full list always belongs
to the largest holders and a big buyer is never turned away; the keeper re-lists evicted wallets automatically from the
HolderReplaced/HolderPruned events. If reads ever stop fitting a transaction, rounds stop completing; after 72 straight
missed rounds plus 3 days, `pruneFrozen()` lets anyone shed wallets under a floor that starts at 2 tickets and doubles
per frozen day (capped at 16 tickets — supply math then guarantees a fitting list), and `abortFrozen()` voids a round
stuck mid-flight for 7 days. Every escape is permissionless, bounded, and pays nobody. With one ticket at 50,000
tokens, only holders of 0.005% of supply or more take a place, so the token can have far more holders than the cap;
the cap bounds the wallets drawn from, not who may hold.

## Deploying

```
constructor(token, prizeToken (0 = ETH), escrow, arbitrum, seedDelay, seedStep, tipCap, closeOffset, windowLength, maxHolders)
Robinhood Chain: (token, 0x0, 0xd3AFEB2a57f70eF218Aa82451c51B2fb0416Ac9e, true, 1740, 20, 0.05e18, 180, 420, 4000, teamWallet)
```

1. Launch the token on Pons V2 with the creator tax you want.
2. Deploy MegaDraw (the operator console does this from a browser wallet).
3. `setExcluded()` for the launch wallet and any treasury or locker, then `lock()`.
4. From the launch wallet: `PonsV2LaunchFactory.transferCreatorFeeRecipient(token, MegaDraw)`.
5. Verify the source on the explorer with `contracts/build/MegaDraw.input.json` (the exact standard-JSON input).
6. Put the addresses in `js/config.js`, and run `keeper/keeper.js` (or the console's keeper) so rounds happen.
