Provably Fair — algorithm

Canonical SlicePot spec (algorithm id PVPHUB_WHEEL_V2). Immutable: any change ships as a new version, never as a silent edit.

PVPHUB_WHEEL_V2 · quicknet · 52db9ba70e0cc0f6eaf7…

Idea

The house does not pick the winner after bets are placed:

  1. Before betting: publish commit serverSeedHash = SHA256(serverSeed) and targetDrandRound.
  2. Players bet; bet manifest → manifestHash.
  3. Fetch exactly the drand Quicknet beacon for targetDrandRound (BLS signature checked).
  4. combinedRandomness = SHA256(serverSeed + ":" + drandRandomness).
  5. Unbiased ticket in [0, totalBankMinor) → winner by ticket ranges.
  6. After settlement: reveal serverSeed. The client recalculates everything.

V1 (PVPHUB_WHEEL_V1) is legacy: ticket from drand only. New rounds use V2 only.

Constants

algorithmVersionPVPHUB_WHEEL_V2
drandNetworkquicknet
drandChainHash52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971
selectionRuleFIRST_ROUND_AT_OR_AFTER_CLOSE_PLUS_LEAD
genesis / period1692803367 / 3s

Relays: api.drand.sh, api2.drand.sh, drand.cloudflare.com (path includes chain hash).

Commit (before / during betting)

Public in round.state.fairness and verify payload (no secret):

  • serverSeedHash
  • targetDrandRound, targetDrandTimestamp
  • bettingEndsAt, drandLeadMs, drandChainHash, algorithmVersion

Secret serverSeed (32-byte hex) is not published until reveal.

time(round)      = genesis + (round − 1) × period
targetDrandRound = first round with time(round) ≥ bettingEndsAt + drandLeadMs
// legacy rounds (FIRST_ROUND_STRICTLY_AFTER_*): first round with genesis + round × period > bettingEndsAt

Manifest

Canonical JSON (object keys sorted recursively) → manifestHash = SHA256(utf8). Fields:

  • roundId, mode, targetDrandRound
  • bets[]: acceptedSequence, betId, userPublicId, amountMinor

Ticket order: sort by acceptedSequence, then userId. Each player gets a contiguous [from, to] of length amountMinor cents. totalBankMinor = sum(amountMinor).

Entropy & ticket (V2)

combinedRandomness = SHA256(`${serverSeed}:${drandRandomness}`)
winningTicket      = unbiased(combined, totalBankMinor)  ∈ [0, total)

Unbiased: 256-bit window + rejection sampling; otherwise material = SHA256(material) up to 64 times.

Winner: userId whose range satisfies from ≤ ticket ≤ to.

resultHash (V2)

resultHash = SHA256(
  `PVPHUB_WHEEL_V2:${roundId}:${manifestHash}:${targetDrandRound}:${serverSeedHash}:${combinedRandomness}`
)

Fee / payout

fee          = Σ ceil(bet.amountMinor × feeBps / 10000)
winnerPayout = totalBankMinor − fee
// default feeBps = 300 (3%); welcome = 500 (5%)
// 50¢+50¢ × 5% → ceil(2.5)+ceil(2.5)=6¢, prize 94¢

Cancel

Fewer than 2 unique players, bets not frozen before the beacon, or drand fail/timeout → CANCELLED and full refund. serverSeed is revealed so you can check it against serverSeedHash. Verify status is cancelled (not fair).

How to verify yourself

  1. In the app: Verify button / round modal — green status only after client-side recalculation.
  2. Page /verify — paste JSON or fetch by wheelId + roundId.
  3. Public REST: https://letmetest.bid/wheel/fairness/:wheelId/:roundId
  4. External beacon: api.drand.sh/v2/beacons/quicknet/rounds/{targetDrandRound}.

Client checklist: algorithm → target round → manifestHash → serverSeedHash → drand BLS → combined → resultHash → ticket → fee/payout.

Bots (disclosure)

Dev/load bots (Games.WheelBot) may join. They bet under the same PF rules; seed/drand do not depend on bots. Production bot policy should stay disclosed in the UI.