1 · Round lifecycle
A round has two halves separated in time, and that separation is the entire fairness argument. We commit to a block before it exists; later, that block's hash decides everything. Nothing in between is ours to choose.
The round file is written to disk at open, before the close block exists. That published file is the commitment. Everything after it is derived, and every derivation is reproducible from public chain data.
2 · Where randomness comes from
There is no operator seed. The entropy is the hash of the close block, and the round bound itself to that block number before the block was mined:
// seed for round N seed = keccak256( closeBlockHash ‖ roundId )
We can't influence a BNB Chain block hash, and we fixed the block number in advance, so we can't shop for a favourable one either. The winners are fully determined the instant that block mines — before we have done anything at all.
Why not commit-reveal
The obvious scheme is to publish keccak256(serverSeed) up front and reveal
the seed later. We built that first and then threw it away, because it has a hole:
it proves the seed wasn't swapped within a round, but not that the round was
ever published. Seeing winners we disliked, we could kill the process, drop the round,
and start again with a fresh seed.
Why not Chainlink VRF
VRF is sound, but it bills per request. At a 30-minute cadence that's roughly 48 requests a day, forever, for randomness a block hash already provides at zero cost once the close block is committed ahead of time.
Why the hash is read off-chain
Solidity's blockhash() only reaches back 256 blocks. A design that
reads the close-block hash on-chain therefore has a hard deadline to settle within —
miss it and the draw becomes unrecoverable. We read the hash through
eth_getBlockByNumber instead, which has no such limit. Same ungrindable
entropy, no deadline.
3 · Which block gets counted
The close block is public the moment a round opens. If it were also the snapshot block, it would be trivially gameable: buy at close−1, sell at close+1, collect a ticket you never really held. So the close block doesn't get snapshotted. It selects one:
snapshotBlock = closeBlock − ( closeBlockHash mod lookbackBlocks )
lookbackBlocks is announced when the round opens, so the range is known.
Which block inside it counts is not knowable until the close block mines — and by
then that block is already in the past, so nobody can react to it. Holding across the
whole window is the only way to be certain of a ticket.
4 · How balances are read
Every balance in a snapshot is read with a single Multicall3 call pinned to the snapshot block. Pinning is what makes the snapshot atomic — a transfer landing mid-read cannot cause a wallet to be counted twice or zeroed.
The candidate set comes from indexing Transfer logs from the token's deploy
block, but balances are never derived from those events. That distinction matters:
- Event replay is cheaper, and it is what most implementations do.
- It silently drifts on reflection-style tokens, where
balanceOfis computed from an index rather than stored — common in this corner of the market. - Reading
balanceOfat a pinned block is exact by construction, whatever the token does internally.
5 · Eligibility & weighting
Prizes are for people who traded the token. Two filters enforce that.
eligible = balance ≥ MIN_ENTRY
and address ∉ EXCLUDED
and address has no contract code
weight = min( balance, cap )
Contracts are excluded automatically
Liquidity pools, bonding curves, routers, lockers, bridges and exchange deposit contracts all hold large balances, and none of them is a trader. Rather than maintain a list of them — which is always out of date, and always missing the one that matters — any address with contract code is barred from the draw.
Smart-contract wallets — multisigs, account-abstraction wallets — are caught by the same
rule. If you want those eligible, ALLOW_CONTRACT_WINNERS=true turns the
filter off. Most projects should leave it on.
Plain wallets you exclude by hand
Deployer, treasury, dev, marketing, airdrop and payout wallets are usually ordinary
wallets, so no automatic rule catches them. They go in excluded.txt, one per
line with a comment saying why. npm run preflight scans the live token before
launch and tells you what's still eligible that shouldn't be.
The whale cap, solved rather than applied
Weighting purely by balance lets one wallet dominate the winner feed, so a single wallet's weight is capped at a fixed share of the draw. The subtlety is that capping removes weight, which shrinks the denominator, which lifts the capped wallet's share back up.
Applied in one pass against the pre-cap total, a 5% cap measured 6.12% of the weight actually used. The cap is therefore solved to a fixed point instead, so the configured number means what it says. Measured after the fix: 4.99%.
Why weight by balance at all
Equal odds per wallet sounds fairer and is the opposite. It pays anyone who splits one balance across many wallets, which costs a few cents in gas. Weighting by balance makes splitting pointless by construction. Simulated over 600,000 winner slots:
| Actor | Share of slots | Result |
|---|---|---|
| Whale holding 23% of eligible float | 4.03% | cap holding (configured 5%) |
| Honest holder, 100,000 | 0.71% | baseline |
| Sybil: same 100,000 split 100 ways | 0.74% | 1.012× payout — splitting gains nothing |
| 500 dust wallets below the minimum | 0.00% | excluded |
6 · The draw
Entries are sorted by address so the draw reproduces regardless of map iteration order. Winners are drawn without replacement — one wallet cannot take two prizes in a round.
// per winner slot i r_i = keccak256( seed ‖ i ) ticket = r_i mod remainingWeight // modulo-bias rejected winner = entry whose cumulative interval covers ticket
Draws use rejection sampling rather than a bare modulo, so the low end of the range isn't very slightly favoured. Prizes are split by a fixed schedule, and integer-division dust goes to first place rather than being stranded — the payouts always sum to exactly the pool.
7 · Payout
Winners are paid by sequential MARSCOIN transfers from a hot payout wallet. Sequential, not parallel: parallel sends sharing a nonce is the classic way to get replacement-underpriced errors and silently dropped payouts.
The round file is written before each transaction is awaited, and any winner already carrying a transaction hash is skipped on re-run. If the process dies mid-payout, restarting resumes — it does not double-pay.
8 · Verify a round yourself
Every round is published in full. You need nothing from us but the file and any BNB Chain node.
- Fetch the round:
/api/round/<id>. It contains the close block, its hash, the snapshot block, the seed, every eligible balance and every winner. - Check the hash is real. Read that block from any node and compare hashes. This is the step that can't be faked from the file alone.
- Check the snapshot block was derived, not chosen. Recompute
closeBlock − (closeBlockHash mod lookbackBlocks). - Check the seed.
keccak256(closeBlockHash ‖ roundId). - Re-run the draw over the published snapshot and compare the winners.
- Optionally confirm the balances themselves by reading
balanceOfat the snapshot block — that catches a snapshot that was tampered with rather than mis-drawn.
The engine ships this as a command, and it does steps 2 through 5 against the live chain:
npm run verify // last round npm run verify -- 42 // a specific round
9 · Parameters
These are the knobs. Live values for a running deployment are shown on the home page.
The prize is set in dollars per round and converted to MARSCOIN at the market rate when the round closes, so it tracks whatever the operator decides that round rather than a number frozen at boot. If the payout wallet can't cover the target, the prize is reduced before the draw — the figure published in the round file is always the figure paid, never an advertised amount that couldn't be honoured.
Round length is converted to a block count using measured block time, not a hardcoded constant. BNB Chain block time has changed materially over the chain's life — at the time of writing it measures around 0.45s, where a hardcoded 3s would make a "30-minute" round run roughly six times too long.
10 · Limits & failure modes
Things this design does not solve, stated plainly.
| Limit | What it means for you |
|---|---|
| Payouts are off-chain | The draw is verifiable, but sending the prize is a transaction we make. Nothing forces it on-chain. If the payout wallet is empty or the process is down, prizes stall. |
| The prize pool is pro-cyclical | Tax is the only funding source. Quiet trading means small pools. There is no treasury underwriting it, and a raffle funded purely by volume has no floor. |
| MARSCOIN is not ours | Its vault, its rules. It can change how or whether it pays SPCXB, and we would find out the same way you would. |
| Reorgs | Handled by staying a set number of confirmations behind the head before treating a block as final — not by assuming they don't happen. |
| RPC dependence | Snapshots need an endpoint that serves eth_getLogs reliably. A degraded provider delays a round; it does not change who wins, because the close block is already fixed. |
| It's a memecoin | The mechanism being fair says nothing about the token being a good idea. Only hold what you can afford to lose. |