Provably fair random draw service. Commit-reveal protocol with public entropy.
wallop.run · How verification works
Wallop runs verifiably fair random draws where nobody controls the outcome — not the organiser, not the platform, not the service itself.
Entries are locked before the draw. The seed is computed from two public, unpredictable entropy sources: a drand beacon value and a live atmospheric pressure reading from Middle Wallop, Hampshire — three villages in the Test Valley, and the reason the service is called Wallop. Neither source is predictable or controllable. The algorithm is open source and deterministic. Anyone can re-run it and verify the result.
You shouldn't trust us. Verify it yourself.
- Lock entries — caller submits entry list, Wallop computes and publishes the entry hash
- Commit — Wallop declares which future entropy sources will be used (drand beacon round + weather observation time)
- Fetch entropy — after the declared time, Wallop fetches randomness from drand and a Met Office weather reading
- Compute seed & run — entropy sources are combined via JCS + SHA256 to produce a seed, which is fed into the deterministic fair_pick algorithm
- Permanent proof — the full proof record (entries, entropy, seed, results) is stored permanently with a public verification page
Single-draw verifiability — the commit-reveal protocol above — proves any one draw was fair. It does not prove that the operator running the draw didn't quietly run nine others and only publish the result they liked. That gap is closed by the operator registry:
- Every API key may belong to an
Operator. Operators are public identities with a stable slug. - Every draw an operator locks gets a gap-free per-operator sequence number. Discarded, expired, and failed draws still occupy their slot — gaps are detectable.
- At lock time, wallop_core signs an Ed25519 commitment receipt (lock receipt, schema v4) over a canonical JSON payload that binds the operator, per-operator
sequence,draw_id,commitment_hash,entry_hash,locked_at,signing_key_id, declared entropy sources (drand chain + round, weather station + time),winner_count,wallop_core_version,fair_pick_version, and the pinned algorithm identity tags (JCS version, signature algorithm, entropy composition). The receipt is inserted in the same transaction as the lock, so a draw cannot be locked without its receipt being committed atomically. The execution receipt (schema v3) is signed at draw-completion time and commits the realised entropy values, the computed seed, the ordered results, thelock_receipt_hash(binding execution to the specific lock receipt), and thesigning_key_idof the wallop infrastructure key that produced the signature. - The operator's public registry lives at
/operator/:slugand lists every draw they have ever locked, in sequence order, with status badges. Signed receipts are served as JSON at/operator/:slug/receiptsand individually at/operator/:slug/receipts/:n. The current Ed25519 public key is at/operator/:slug/key. - A transparency log at
/transparencypublishes a daily Merkle root over all receipts, pinned to a drand round number. Mirroring the receipt log over time and recomputing the root lets a third party detect any retroactive tampering with operator receipts.
This defends against post-hoc draw shopping: lock a draw, see the result, dislike it, discard it, lock another with the same entries on a fresh round, repeat. After this change every locked draw is permanently visible in the operator's registry whether it eventually completed or not, and the signed receipt commits the operator to that entry set resolving to some outcome at that sequence slot. Anyone can verify the receipts independently using the operator's public key.
It does not defend against an operator locking parallel draws with different entry sets. Operators must follow "one contest = one locked draw."
Signing keys can be rotated by inserting a new OperatorSigningKey row with a later valid_from timestamp; old keys are never deleted, so previously published receipts remain verifiable forever.
| Layer | Package | Purpose |
|---|---|---|
| Algorithm | fair_pick (separate repo) |
Deterministic (entries, seed) → winners. Pure functions, zero side effects. |
| Protocol | wallop_core (this repo) |
Commit-reveal protocol, entropy fetching, seed computation |
| Web | wallop_web (this repo) |
Proof pages, API endpoints, live draws |
If your app includes wallop_core as a dependency and shares the same database, you must configure Oban with a separate prefix. Each service processes its own draws independently — the code is identical (wallop_core), the algorithm is deterministic, and the proof is independently verifiable regardless of which service executed the draw.
# In your app's config.exs — use a different Oban prefix
config :wallop_core, Oban,
repo: WallopCore.Repo,
prefix: "oban_app",
queues: [entropy: 10, webhooks: 5, default: 5],
plugins: []The wallop service uses the default public prefix. Your app uses oban_app (or any other name). Both share the database but process their own jobs independently.
You will need to run Oban.Migrations for your prefix:
defmodule MyApp.Repo.Migrations.AddObanAppJobs do
use Ecto.Migration
def up, do: Oban.Migration.up(prefix: "oban_app")
def down, do: Oban.Migration.down(prefix: "oban_app")
endYour app also needs MET_OFFICE_API_KEY and HONEYCOMB_API_KEY environment variables set, since the EntropyWorker and OTel exporter run in your process. Set a distinct OTel service name so traces are separated in Honeycomb:
# In your app's runtime.exs
config :opentelemetry,
resource: [service: [name: "your-app-name"]]PubSub works across services automatically via Redis — draw updates broadcast from either service are received by both.
Wallop! never stores personally identifiable information. Entry identifiers are Wallop!-assigned server-generated UUIDv4 values — the operator does not supply any identifier at submission time. The add_entries API accepts only %{weight: pos_integer()} per entry plus a per-batch client_ref idempotency token; any other key on the payload is silently dropped.
The recommended integration pattern:
- Your app submits a batch of entries via
PATCH /api/v1/draws/:id/entrieswithweightper entry and a freshclient_ref(an opaque high-entropy idempotency token — a UUID is fine). - Wallop generates a UUID for each entry server-side using
:crypto.strong_rand_bytes/1, inserts them atomically, and returns the UUIDs in submission order asmeta.inserted_entries: [{uuid}, ...]. The i-th element corresponds to the i-th entry in your request. - Your app captures those UUIDs and stores its own
(your_person_id → wallop_uuid)mapping in your database. Wallop never learns who the person behind a UUID is. - Wallop hashes the entry list (
{draw_id, entries: [{uuid, weight}]}) into a permanent, immutable proof record. - On a GDPR deletion request, your app deletes the person's record and the UUID mapping in its own database — the wallop proof record remains intact because it contains only the opaque UUIDs wallop itself generated.
If your add_entries HTTP response is dropped before you can capture the UUIDs, you have two recovery paths:
- Retry the same call with the same
client_ref. Identical retries replay the original response (same UUIDs in same order, no double-insert). A retry with the sameclient_refagainst a different entry payload returns HTTP 409. - Read back via the authenticated
GET /api/v1/draws/:id/entriesendpoint (api-key-scoped, keyset-paginated). Works at any draw status.
The client_ref is hashed at the request boundary; the plaintext is never persisted by Wallop!. Use a UUID or other high-entropy random value — do not use semantically meaningful or guessable identifiers.
Operator-supplied entry identifiers are NOT stored anywhere in wallop. Entry data in signed receipts and the public proof bundle contains only wallop UUIDs and weights. Any binding between a UUID and operator-side data (user account, payment, ticket number, etc.) lives in the operator's own system.
- Language: Elixir
- Framework: Phoenix + Ash Framework
- Database: PostgreSQL
- API format: JSON:API
This repo depends on the fair_pick package as a sibling directory. Clone both repos side by side:
git clone git@github.com:electric-lump-software/wallop.git
git clone git@github.com:electric-lump-software/fair_pick.gitThen:
cd wallop
mix deps.get
mix ash.setup # creates database and runs migrations
mix test
mix format
mix credo --strictWallop reads .env via Dotenvy in dev and test. The only required entry for a fresh clone is VAULT_KEY — a base64-encoded 32-byte AES key used by WallopCore.Vault to encrypt sensitive fields (operator signing keys, webhook secrets, etc).
Generate one once and append to .env:
openssl rand -base64 32 | awk '{print "VAULT_KEY=" $0}' >> .envKeep this key stable across restarts — rotating it will make any previously-encrypted row in your local database undecryptable. If you ever want to start fresh: delete the key from .env, generate a new one, and run mix reset.
If you run another consumer of wallop_core against the same local Postgres database, you must use the same VAULT_KEY value in both projects' .env files. Both BEAMs encrypt and decrypt rows in the shared wallop_dev DB, and a mismatch will produce Cloak.MissingCipher errors at runtime for any row written by the other project.
Production sets the same variable via the hosting platform's env var mechanism.
Active development. The algorithm, protocol layer, API, entropy layer (drand + Met Office weather), and public proof pages are all implemented.
MIT — see LICENSE.