Raffles to identity.md holders that anyone can recompute.
Anyone can run one — there is nobody to ask. You bring a prize and a clock; the site freezes the holder set straight off the contract, publishes its hash before the randomness exists, and draws on schedule from an Ethereum block that had not been mined at commit time.
Built for the case in this thread:
30 WL spots, 10 straight to the largest holders, 20 drawn weighted by how long each wallet has
held — the top_hold mode below.
One collection, one chain, fixed in server/collection.py:
identity.md (IDMD) · ERC-721 · Ethereum
0x0000ec93127baa929e58e97dd0095a2bfb38ec1d
A project chooses the prize, the split and the times. It does not choose who can win, so the
field most likely to be set wrong does not exist, and two raffles snapshotting at the same block
have literally the same entry list. min_balance (how many NFTs a wallet must hold) and an
exclude list are the only filters.
There is no application and no allowlist, and signing in is optional.
Press Sign in with X and the raffle carries a handle with a check: a name nobody could have
typed for themselves. Skip it, give a name, and the raffle runs identically — the draw's integrity
never depended on who created it — but the name is shown marked unverified, and the front page
has a filter for raffles from signed accounts only. An unsigned organiser cannot take a name a
verified account is already using, which is the one thing the signature was protecting.
server/xauth.py — OAuth 2.0 authorization code with PKCE, scopes from X_SCOPES (default
users.read tweet.read; /2/users/me needs only the first, but X's authorize step is widely
reported to reject it alone), callback /api/x/callback. The access token is used once to read the
username and then discarded: this site never acts as anybody on X, so it never keeps the means to.
The session key reaches the browser through a one-time nonce in the URL fragment rather than the
key itself, so nothing usable lands in history.
Environment: X_CLIENT_ID, X_CLIENT_SECRET, X_REDIRECT_URI, X_SCOPES (see .env.example).
Without a client id nothing breaks — the sign-in button simply is not offered and every raffle is
created under a self-given name.
Abuse controls, since anybody can create: 20 sign-in or unsigned-create attempts per IP per hour,
5 pending raffles per verified handle, 20 pending unsigned ones site-wide, and
admin.py block <handle>.
GET /api/standings is a live view of the whole holder set — address, balance, hours held —
refreshed by the clock every 15 minutes from one replay, so the lookup box on the site costs
nothing per visitor. It answers "am I eligible, and how many tickets do I have". It is not a
commitment: a raffle's frozen entries.json is, and the lookup box on a raffle page reads that
instead.
snapshot_at every holder is replayed out of the Transfer log and weighted
-> entries.json, frozen
commit entries_hash = sha256("<i>,<address>,<weight>\n" for every entry, in order)
beacon_block = a future Ethereum block, named now
draws_at seed = sha256(entries_hash || beacon_block_hash)
pick i = sha256(seed || uint32be(i)) mod (sum of remaining weights)
-> result.json
The organiser cannot pick the seed (the block does not exist yet) and cannot change the entries (the hash is already published). Everything needed to redo the draw is in the two JSON files:
python3 server/verify.py entries.json result.json # offline, imports nothing from the site
or paste the same two files into /verify.html, which replays it in the browser with WebCrypto.
Once drawn, GET /api/raffles/<id>/winners.txt is the same wallets with the explanation stripped
off — top holders, then granted, then drawn, one address per line — because whoever loads the
allowlist wants addresses, not three tables. The page carries it too, with a copy button.
Stated plainly: the proposer of that one Ethereum slot could withhold their block to reroll once. For whitelist spots that is not a real threat; for anything valuable, widen the commit-to-draw gap or move the seed to a randomness beacon such as drand.
| mode | what happens | direct_top | raffle_spots | weight |
|---|---|---|---|---|
top |
top X holders, nothing drawn | X | 0 | — |
random |
X drawn, one ticket each | 0 | X | equal |
top_random |
top X, then Y drawn | X | Y | equal |
top_hold |
top X, then Y drawn by hold-time rank | X | Y | hold_hours |
Top holders are ranked by balance, awarded outright and removed from the pool before the draw, so nobody wins twice.
Skipping every address with bytecode is the obvious rule and the wrong one. Where the 2000 NFTs actually sit:
| NFTs | what it is | |
|---|---|---|
| plain EOA | 1499 | a private key |
0xef0100… |
500 | an EOA that signed an EIP-7702 delegation — 137 of them, running MetaMask's EIP7702StatelessDeleGator or Uniswap's CaliburEntry |
| deployed contract | 1 | one CoinbaseSmartWallet (ERC-4337) |
| marketplaces, vaults | 0 | listing an NFT never moves it |
So bytecode is not evidence of anything: a quarter of the collection is held by ordinary wallets
that upgraded themselves. The rule is snapshot.drop_contracts(): the 0xef0100 prefix is an EOA
and always counts; a deployed contract counts if it answers entryPoint() (ERC-4337) or
getThreshold() (Safe); anything else is a vault or a bridge and is dropped. Today that drops
nothing and all 2000 NFTs are in the running.
snapshot_at can be earlier than the raffle — freezing the list as of an announcement, so nobody
buys in after reading it. It is also the one knob an organiser could shop with, picking a moment
whose holder list they have already seen, and no amount of hashing detects that after the fact.
So it is allowed and then labelled: the raffle carries retroactive, the page leads with a note
saying exactly when the snapshot was taken versus when the raffle was created, and the reader
decides. The draw is unaffected either way — the seed is always a block that did not exist yet.
draws_at gets no such freedom: it must be at least five minutes after both now and the
snapshot, so the beacon block is genuinely unmined when the commitment goes out.
Tickets for hold_hours come from where a wallet sits in the holder set, not from a date:
eleven bands in steps of 0.1, 2x for the longest-held down to a plain 1x for the newest, so the
best weight is exactly twice the worst. Weights are the integers 20…10 — the draw commits to exact
integers, so nothing floating point ever reaches the commitment, and the x-figure is just those
tenths.
An absolute ladder was the obvious design and it decays. Whatever rung you pick, the collection ages past it and the weight stops separating anyone: one ticket per week capped at five had 58% of this holder set sitting at the cap, so the weighting was doing nothing for the majority of it. Bands defined as shares of the set stay filled forever.
Measured against the live 728 wallets, the switch improved both ends at once — a day-old wallet's odds at one of 20 spots went from 0.73% to 0.90%, and the mint cohort's expected share went from 9.0 spots to 10.2 — because weight stopped being wasted on a huge tie at the cap.
Ranking is on whole days, and the percentile is the share of the set that held strictly longer, so a tie is always a tie: two wallets that crossed the line on the same day cannot land in different bands. Ranking on hours would split the mint cohort at an arbitrary point — same day, three hours apart, different weight, no way to explain it. The cost is that a big tie group fills several bands at once: today 223 mint-day wallets all hold 2x, so the 1.9x and 1.8x bands are empty until newer holders push them apart.
HOLD_BANDS and HOLD_TOP_TENTHS in the environment.
A box at the top of the form puts the site author's wallet (0xfinne.eth, override with
BUILDER_ADDRESS) straight on the list. It is a grant, not an entry:
- one spot, carved out of the drawn spots, so a 30-spot prize still hands out exactly 30
- the wallet is pulled out of the draw pool, so it cannot also be drawn
- declined if the wallet already ranked into the top holders on its own — that would be two spots
- refused at creation unless at least two spots are being drawn, so there is still a draw
- published in
result.jsonundergranted, with its reason, and shown in its own table
| path | what |
|---|---|
server/collection.py |
the collection, and the only shape eligibility takes |
server/draw.py |
the rw-1 algorithm — pure, and the only place randomness is decided |
server/snapshot.py |
Transfer replay → balances, hold hours, weights, supply reconciliation |
server/chains.py |
Ethereum RPC + the Etherscan v2 transfer index |
server/tick.py |
the clock: scheduled → locked → drawn, run every minute by a timer |
server/api.py |
read API for everyone, sign-in + POST /api/raffles for a signed-in handle |
server/xauth.py |
Sign in with X — optional, and the only thing a check mark means |
server/admin.py |
operator CLI: list, show, cancel, delete, block a handle |
server/verify.py |
standalone verifier |
public/ |
the three pages: list + detail, create, verify |
deploy/ |
systemd units, Caddy block, deploy.sh (dfyre-la, behind a CF Tunnel) |
Etherscan v2 account/tokennfttx, free key, no getLogs sweep. Two things it does not tell you,
both found the hard way and both now encoded in chains.py:
- it returns at most 1000 rows however large an
offsetyou ask for, and refuses any query whosepage × offsetexceeds 10000. A short page is the only honest end-of-history signal - moving the window re-sends the whole final block, and one transaction can legitimately move the same token twice (aggregators hop seller → router → buyer). De-overlapping with a set drops those hops; it takes a multiset
Both mistakes produce a plausible-looking but wrong entry list, so snapshot.build() reconciles
the replay against totalSupply() at the snapshot block and refuses to continue if they disagree.
cp .env.example .env # put your Etherscan key in it; the X app is optional
set -a; . ./.env; set +a
export RAFFLE_HOME=/tmp/fairdraw
mkdir -p $RAFFLE_HOME/state
python3 server/api.py --port 8099 --static public # dev: one process serves pages and API
python3 server/tick.py # in production a timer runs this every minuteOpen http://127.0.0.1:8099/new.html and create one. Without X_CLIENT_ID there is no sign-in
button and the form asks for a name instead — the raffle runs the same, marked unverified.
python3 tests/test_draw.py # 24 assertions on the draw itself
python3 tests/test_modes.py # the four modes, the times and the builder rule
python3 tests/test_indexer.py # live: the replay must reconstruct totalSupply exactly
bash tests/smoke.sh # live: create over the API, snapshot, draw, re-verify
python3 tests/seed-dev.py # a dev instance with real holders in all three states