Most online giveaways ask you to trust someone. The host posts three wallet addresses and says they were picked at random. You can't see how. You can't tell whether the entry list was edited, or whether the draw was re-run until the "right" wallets came out.
We wanted a giveaway where that trust isn't needed. The goal was simple to state: nobody, the host and our own server included, can know the winners before entries close, and nobody can change them afterwards. And anyone should be able to check this on their own computer.
This post walks through how we did it for SealDraw, a raffle service on Solana. None of the four steps is new on its own. What matters is how they fit together, and the small details that decide whether a proof is actually checkable.
Step 1: commit to a secret seed before entries open
When a raffle is created, the server makes a 32-byte secret seed and publishes only its hash:
seedCommit = SHA-256(seed)
The seed stays secret until the draw. Because the commitment is public from the first minute, we can't swap the seed later after seeing who entered. If we did, the published seed would no longer hash to the commitment.
We don't store the seed in the database. It is derived from a server secret and a random per-raffle nonce:
export function raffleSeed(seedSecret: string, seedNonce: string): Uint8Array {
return hmac(sha256, utf8.encode(seedSecret), utf8.encode(`proofdraw:seed:${seedNonce}`));
}
So a copy of the database alone doesn't reveal any seed. (The proofdraw: label is a leftover from the project's old name. More on that below.)
At the same moment the prize, the number of winners, the closing time and the entry conditions are locked. The host can't change them once the raffle is open.
Step 2: entries are signatures, not database rows
Entering costs nothing and needs no transaction. The entrant's wallet signs a fixed text:
I enter SealDraw raffle #<id>
The server checks the Ed25519 signature against the wallet's public key, and both message and signature are published. A list of addresses can be padded with made-up entries; a list of signatures can't. The checker verifies every one.
One Seeker, one entry
A new Solana wallet is free, so "one entry per wallet" really means "one entry per bot". The strongest option we offer is a Seeker-only raffle. Every Solana Seeker phone has one Seeker Genesis Token, and the raffle accepts one entry per token.
On entry, the server looks at the wallet's Token-2022 accounts and checks whether any of them belongs to the Genesis Token group (the mint's tokenGroupMember extension). The mint it finds is stored with the entry. The database then does the rest:
UNIQUE (raffle_id, genesis_mint)
If the same Seeker tries again from a second wallet, the insert fails and the entrant gets "This Seeker already entered with another wallet".
For raffles open to any wallet, the host must set at least one holding condition (up to five): hold some SOL, hold a token, or hold an NFT from a collection. Those conditions are what make each extra wallet cost something.
Step 3: re-check, freeze and hash the list at closing
Checking holdings only at entry time has an obvious hole: enter with a wallet, move the tokens to the next wallet, enter again. So when a raffle closes, every entry is checked again before anything is frozen:
async function recheckEntry(ctx, e, gates, checkGenesis) {
if (checkGenesis) {
const mint = await ctx.solana.genesisMint(e.wallet);
if (!mint || mint !== e.genesis_mint) return "no longer holds the Seeker Genesis Token it entered with";
}
if (gates.length) {
const v = await checkGates(ctx.solana, e.wallet, gates);
if (!v.ok) return v.reason;
}
return null;
}
An entry that no longer qualifies is removed, and the reason is published with the proof. Nothing disappears silently.
This runs in a scheduled job on Cloudflare Workers, which limits how many outside requests one run can make. So the job does at most 300 re-checks per run and carries on the next minute from the first entry not yet checked. A 5,000-entry raffle takes about 17 runs to close. That's fine: a list frozen a few minutes later changes nothing.
When every entry has been re-checked, the list is frozen:
- wallets sorted in plain text order,
- joined with
\n, no trailing newline, entriesHash = SHA-256(list).
These rules look fussy, but two people with the same entries must produce the same bytes, or the hash means nothing.
At the same moment the server announces a block that doesn't exist yet:
const drawSlot = (await ctx.solana.finalizedSlot()) + DRAW_SLOT_OFFSET; // 150
Solana slots are about 400 ms, so 150 slots is roughly a minute in the future.
Step 4: let a future block decide, then reveal the seed
The draw uses the first block produced at or after drawSlot (slots with no block are skipped), and only once it is finalized. Then:
drawSeed = SHA-256(seed || entriesHash || blockhash)
That's 32 + 32 + 32 bytes: the seed, the entries hash decoded from hex, and the blockhash decoded from base58.
Why all three?
- The seed was fixed before any entry existed, so nobody can pick it to suit the entrants.
- The entries hash ties the result to the exact frozen list.
- The blockhash didn't exist when the list was frozen. So even we, who know the seed, can't add a last-minute entry and predict its effect.
From drawSeed we build a stream of random numbers. Block i of the stream is SHA-256(drawSeed || i), with i as a 4-byte big-endian integer, read as eight unsigned 32-bit integers. To get a number below n without bias, values in the uneven tail are thrown away:
nextInt(n: number): number {
const limit = 2 ** 32 - ((2 ** 32) % n);
let v = this.next32();
while (v >= limit) v = this.next32();
return v % n;
}
Winners come from a partial Fisher-Yates shuffle of the sorted list. The first k positions are the winners, in rank order:
for (let i = 0; i < k; i++) {
const j = i + rng.nextInt(list.length - i);
[list[i], list[j]] = [list[j], list[i]];
}
return list.slice(0, k);
Before publishing, the server checks that the list it is drawing from still hashes to the published entriesHash, and that the seed still matches the commitment. If either doesn't, it refuses to publish. Otherwise it publishes the seed, the block and the winners in one database transaction.
There's also a deadline. The result must be published within 31 minutes of closing. If it isn't, the raffle is publicly marked "late". It's still drawn with the same block as soon as possible, and the mark stays. If our job ever stalls, that's visible, not hidden.
Checking it without trusting us
Every raffle publishes a proof as JSON. The raffle page has a button that re-runs the draw in your browser. But a check that runs on our page is still our code. So the same logic also ships as a standalone script:
curl -O https://sealdraw.2cstudio.app/verify.mjs
node verify.mjs https://sealdraw.2cstudio.app/raffles/12/proof --rpc https://api.mainnet-beta.solana.com
It needs Node.js 20 or newer and has no packages. It uses Web Crypto for SHA-256 and Ed25519, and a short base58 decoder. The code between the core start and core end markers is the same code the browser runs. It checks, in order:
- the seed hashes to the commitment,
- every entry carries a valid signature,
- the list is sorted, has no duplicates, and matches
entriesHash, - with
--rpc, that the deciding block really is the first block at or afterdrawSlot, and that Solana reports the same blockhash, - re-running the shuffle gives the same winners.
"Skipped" is not "passed"
One detail we care about: every step reports one of three results, not two. ok, FAIL or skip.
Some checks can't always run. Older browsers can't verify Ed25519 signatures through Web Crypto. If you leave out --rpc, the script can't ask Solana about the block. In those cases the step is reported as skipped, with the reason:
skip this browser cannot check Ed25519 signatures; use verify.mjs or a newer browser
skip blockhash of block … not checked against Solana
It would be easy to treat "couldn't check" as "fine" and show a green tick. That would make the proof look stronger than it is.
Proofs have to stay valid forever
Two small stories about keeping old proofs checkable.
The rename. The project was called ProofDraw until 17 October 2026. Raffle #1 was entered by signing I enter ProofDraw raffle #1. If we had changed the entry text everywhere, that raffle's published signatures would stop verifying. So the message is chosen by raffle id, and a test locks it in:
export const LEGACY_NAME_LAST_ID = 1;
export const entryMessage = (raffleId: number) =>
`I enter ${raffleId <= LEGACY_NAME_LAST_ID ? "ProofDraw" : "SealDraw"} raffle #${raffleId}`;
The internal proofdraw:seed: label stayed for the same reason. Changing it would change raffle #1's seed.
Versioning. Holder-snapshot raffles, added later, enter every holder automatically with tickets by holdings. They need a different list format (wallet:tickets) and a weighted pick, so their proofs carry version: 2. The checker reads the version from the proof, and version 1 proofs are checked as version 1 forever.
What this does and doesn't cover
SealDraw never holds prizes. The host sends them and posts the transactions on the result page, which shows whether they did. The math can prove who won, but not that someone paid.
Try it
If you want to run a giveaway this way, or just poke at a proof with the script, SealDraw is at https://sealdraw.2cstudio.app. Entering is always free. The full rules are on the site's fairness page, and verify.mjs is downloadable from the site.
Written by 2C Studio, a small studio.