Skip to content
isfinnePublic

About

Verifiable raffles to identity.md holders — commit the entry hash, draw from a future Ethereum block, anyone can recompute the winners

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

34 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

FAIRDRAW

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.

Who is eligible is not a setting

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.

Creating a raffle is permissionless

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

Look yourself up

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.

Why the result is worth anything

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.

The four shapes a project can pick

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.

Addresses with code are still people

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.

A snapshot may point at the past

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.

Hold time is a rank, not a clock

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.

include_builder

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.json under granted, with its reason, and shown in its own table

Layout

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)

Reading the chain

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 offset you ask for, and refuses any query whose page × offset exceeds 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.

Run it locally

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 minute

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

Tests

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

About

Verifiable raffles to identity.md holders — commit the entry hash, draw from a future Ethereum block, anyone can recompute the winners

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages