Three trust problems decide whether a ballot is fair: who can enter, what a loss is worth, and whether the draw and the money were handled honestly. World ID answers the first. A Sui Move package answers the other two. Here’s exactly what runs today and what each check protects.
The trust moment. Before a scarce drop accepts an entry, it needs to know one thing: this is a real person it hasn’t already let into this drop. Tenjō asks World ID for exactly that, and nothing more.
Why Proof of Human. Entry needs uniqueness and nothing else: every extra account would be an extra entry and an extra pity counter, which is exactly where a bot farm attacks. Tenjō asks for no name, age, nationality or document data. Real World IDs enter with Orb-verified Proof of Human, World’s strongest one-person-one-ID guarantee. World’s simulator runs the passport credential on staging, the document path for fans who haven’t visited an Orb. Selfie Check alone lacks the uniqueness a ballot needs. The choice is pinned at the first real entry.
Why it enables pity. World derives the nullifier from the person and Tenjō’s fixed action, so the same person always gets the same one. That is what lets losses follow a fan from drop to drop without an account.
Real World IDs bring a passkey. A real World ID can prove each action only once, so every drop gets its own action and a fresh code. Enter with a passkey and the entry uses the passkey’s code instead, so losses follow it. The passkey signs each entry’s World ID request on the device; Tenjō keeps only its public key. The per-drop proof still decides who gets in: one person, one entry, whichever passkey they bring.
Uniqueness is as strong as the credential. Someone holding two identity documents could hold two passport-based World IDs; Orb Proof of Human closes that gap.
POST /api/rp-signatureenter:<drop>:<challenge>POST developer.world.org/api/v4/verify/{rp_id}code = sha256(tenjo:v1:scope:nullifier)[0:32]Anonymous code, entries, chances, results, loss counts, pickups, and proof timings (purpose, outcome, duration).
Tenjō’s database holds no names, emails, phone numbers, raw proofs, raw nullifiers or IP addresses.
Each series (a tour, shop or product line) keeps one ledger row per code. Your chances in the next drop come straight from it.
Only a settled draw writes the ledger: every loser gains one loss, every winner resets to zero, even if they never collect. One drop per series runs at a time, so weights can’t go stale.
pity(series, code) → losseschances = 1 + min(5, losses)Commit, then settle. After close, anyone can call draw. It reads Sui’s randomness object at 0x8, which the validators produce jointly, and stores 32 random bytes on the drop. Its gas doesn’t depend on the result, so nobody can quietly abort an unlucky draw and try again.
Anyone can check it. settle is deterministic. For each pick it hashes the seed with the pick number, takes the result modulo the chances still in the pool and walks the entries in order. The same inputs always give the same winners, so the public record re-runs the maths in TypeScript and compares.
Drops created while Sui is configured settle on-chain; the database mirrors the chain for fast pages. Older and local demo drops keep their server draw and say so.
entry fun draw<T>(drop: &mut Drop<T>, r: &Random,
clock: &Clock, ctx: &mut TxContext) {
assert!(clock.timestamp_ms() >= drop.closes_at_ms, ETooEarly);
assert!(drop.seed.is_none(), EAlreadyDrawn);
let mut generator = random::new_generator(r, ctx);
drop.seed.fill(generator.generate_bytes(32));
}
public fun settle<T>(drop: &mut Drop<T>,
series: &mut Series, ctx: &mut TxContext)
// roll_i = u64(blake2b256(seed ‖ i)) % chances left
// winners → losses 0, soulbound Ticket
// losers → losses + 1, deposit refunded
// one coin to the organiser for the seatstenjo:enter:v1 ‖ drop ‖ code ‖ senderenter with exactly the entry price. Move checks the Ed25519 permit, reads your chances from the series ledger and locks the coin in the drop’s escrow.enter<T>(drop, series, code, sig, deposit, clock)settle<T>(drop, series)Why a vault. Many ticket ballots ask winners to pay within a few days, and unpaid wins are cancelled. Holding the deposit up front makes a win final the moment it’s drawn, and a loss costs nothing.
Any coin. Drop<T> is generic, so the same contract takes testnet SUI today and a stablecoin such as USDsui or USDC on mainnet.
Free drops too. When the price is zero, the server registers verified entries itself with its OrganiserCap. The draw, the ledger and the Ticket logic are identical.
0x0d0f…3a65View on Suiscan Collecting needs a fresh proof bound to collect:<drop>:<challenge>, with a live selfie requested. It must resolve to a winning code, and each win collects once. A public code alone can’t claim anything.
Simulator winners collect with a fresh proof, and the record labels each pickup untested-staging. Real World IDs don’t collect yet: that needs liveness attested on the server and its own World ID action, since entry spends the drop’s.
| What happens | What the fan sees | What’s saved |
|---|---|---|
| Same person enters twice | Already entered | Nothing new |
| Cancel in World App | Entry not completed | Nothing |
| Missing credential or tampered proof | Refused, with the reason | Nothing |
| World unavailable | Try again shortly (not a rejection) | Nothing |
| Draw before close | Draw not open yet | Nothing |
| Permit used by another wallet | Entry refused on Sui | Nothing; the deposit never moves |
| Someone else tries to collect | Pickup refused | Item stays uncollected |
sui::random can pick, and only a settled draw can change a loss count.