game_casino.gno
17.67 Kb · 512 lines
1package game32
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "math/bits"
7 "strconv"
8 "strings"
9 "time"
10
11 "chain"
12
13 "gno.land/p/nt/avl/v0"
14)
15
16// ── Casino: two-sided commit–reveal, settled and paid on chain ──────────────
17//
18// One round = one bet by one player:
19// 1. CommitCasinoSeed(roundId, player, sha256(s)) operator
20// s is 32 fresh random bytes for this round, kept by the server.
21// 2. PlaceCasinoBet(roundId, game, coin, amt, choice, sha256(c)) the player
22// c is the player's secret (made and kept by the browser). The stake is
23// debited and the bet's largest possible net win is reserved in the pool.
24// 3. RevealCasinoSeed(roundId, s) operator
25// s must hash to the commit. The operator still doesn't know c, so it
26// doesn't know the result when it reveals.
27// 4. RevealCasinoSecret(roundId, c) anyone (a preimage)
28// c must hash to the bet's hash; the realm rolls and settles the bet.
29// Timeouts (nobody can profit from withholding):
30// - s not revealed 30 min after the bet → RefundCasinoBet (full stake back).
31// - s revealed but c not revealed within 24 h → ForfeitCasinoBet (anyone):
32// the bet settles as a loss; only the player could have known the result.
33// No refund once s is revealed.
34//
35// Results (the only rules; the client re-derives and shows them):
36// roll = uint32(sha256(s:c:roundId:game)[0:4]) % 100
37// coin_flip: 0..48 heads, 51..99 tails, 49/50 edge (house) win pays 2×
38// higher_lower: 51..99 higher, 0..48 lower, 49/50 house win pays 2×
39// rps: 0..32 player wins (2×), 33..64 push (stake back), 65..99 house
40// wins; the house move shown is derived from that. RTP 98%.
41// slots: reels/lines/scatters/Hold & Spin from the stream in game_slots.gno;
42// free spins and MAJOR/GRAND jackpots in game_casino_bonus.gno.
43//
44// Pool safety (fixed in code):
45// coin_flip / wheel / rps bet ≤ 0.5% of the free pool; slots bet ≤ 0.004%
46// (its 500× max win then reserves ≤ 2% of the pool); every open bet reserves
47// its largest net win; open reservations ≤ 20% of the pool; net paid today +
48// reservations ≤ 10% of the pool at 00:00 UTC; opens at ≥ 20,000 grin-eq and
49// ≥ 200 units. Coins only move between players and the pool: nothing is
50// minted, and the admin can't withdraw the pool.
51
52var (
53 casinoBets avl.Tree // roundId+":"+addr → *casinoBet
54 casinoReserved avl.Tree // coin → largest net payouts of unsettled bets (string)
55 casinoDay avl.Tree // coin → "day:snapshot:paid:received" (UTC day)
56)
57
58const (
59 casinoWinRange = uint64(49) // winning rolls per pick (of 100) for flip/wheel
60 rpsWinBelow = uint64(33) // rps: roll < 33 player wins
61 rpsPushBelow = uint64(65) // rps: 33..64 push
62 casinoBetWindowSec = int64(3600) // a committed round takes its bet within 1 h
63 casinoSeedTimeoutSec = int64(1800) // refunds open 30 min after the bet if s is never revealed
64 casinoSecretTimeoutSec = int64(86400) // the bet forfeits 24 h after s if c is never revealed
65 casinoMaxBetBps = uint64(50) // flip / wheel / rps bet ≤ 0.5% of the free pool
66 slotsMaxBetPPM = uint64(40) // slots bet ≤ 0.004% of the free pool
67 casinoOpenBps = uint64(2000) // open reservations on a coin ≤ 20% of the pool
68 casinoDayBps = uint64(1000) // daily net payout ≤ 10% of the day's snapshot
69 casinoMinPoolMilli = uint64(20000000) // ≥ 20,000 grin-equivalent …
70 casinoMinPoolUnits = uint64(200) // … and ≥ 200 units of the coin
71 casinoMaxAmount = uint64(1000000000)
72)
73
74// Reference value per unit by rarity, in milli-grin (same table as the
75// exchange plan): only used for the pool's opening threshold.
76var casinoRefMilli = [7]uint64{1000, 3200, 11000, 40000, 160000, 720000, 3600000}
77
78type casinoRound struct {
79 player string
80 seedHash string
81 serverSeed string
82 status string // committed | bet | seeded | settled | refunded | closed
83 committedAt int64
84 betAt int64
85 seededAt int64
86 bets int
87}
88
89type casinoBet struct {
90 addr, game, coin, choice string
91 clientHash, clientSecret string
92 amount, exposure uint64
93 settled, won, refunded bool
94 forfeited bool
95 roll, payout uint64
96 outcome string
97 // slots only
98 grid string
99 lineMult uint64
100 lines int
101 scatters int
102 freeSpin bool // a free spin from a grant (no stake)
103 capped bool // the round's win hit the 500× cap
104 fsGranted uint64 // free spins this spin granted
105 hsTiles string // Hold & Spin tiles ("." separated, 0 = empty)
106 hsRespins int
107 hsMult uint64 // Hold & Spin win in bets (MINI/MINOR included)
108 jackpots string // "major", "grand", "major+grand" or ""
109 jackpotWin uint64 // paid from the jackpot pools (outside the cap)
110}
111
112// ── roles ────────────────────────────────────────────────────────────────────
113
114// isOperator: the casino operator key commits and reveals server seeds. Once an
115// operator is set, only that key can; the admin (treasury) key only stands in
116// while no operator has been named yet (bootstrap and tests).
117func isOperator(cur realm) bool {
118 c := cur.Previous().Address()
119 if operator != address("") {
120 return c == operator
121 }
122 return admin != address("") && c == admin
123}
124
125// SetOperator lets the admin name the server account that commits and
126// reveals casino seeds. It must be its own key, never the admin/treasury key.
127// The operator can't move anyone's coins.
128func SetOperator(cur realm, addr string) {
129 if cur.Previous().Address() != admin {
130 panic("unauthorized: admin only")
131 }
132 a := address(addr)
133 if !a.IsValid() {
134 panic("invalid address")
135 }
136 if a == admin {
137 panic("the casino operator must be a separate key, not the admin/treasury key")
138 }
139 operator = a
140 chain.Emit("CasinoOperatorSet", "operator", addr)
141}
142
143func Operator() string { return operator.String() }
144
145// ── casino accounting (monitoring) ──────────────────────────────────────────
146//
147// Per coin: pool + MAJOR + GRAND == seeded + staked − paid, always. Per coin and
148// game: rounds, staked, paid (free spins and jackpots count as slots pay), so
149// realized RTP = paid / staked. Updated on every settlement; read by CasinoStats.
150
151var casinoStats avl.Tree // coin:field or coin:game:field → uint (string)
152
153func casStat(coin, game, field string, add uint64) {
154 k := coin + ":" + field
155 if game != "" {
156 k = coin + ":" + game + ":" + field
157 }
158 casSet(&casinoStats, k, casAdd(casGet(&casinoStats, k), add))
159}
160
161func casRecord(coin, game string, staked, paid uint64) {
162 casStat(coin, game, "rounds", 1)
163 casStat(coin, game, "staked", staked)
164 casStat(coin, game, "paid", paid)
165 casStat(coin, "", "staked", staked)
166 casStat(coin, "", "paid", paid)
167}
168
169// CasinoStats reports a coin's accounting: pool, reserved, jackpots, seeded,
170// staked, paid, the conservation check and per-game rounds/staked/paid.
171func CasinoStats(coinId string) string {
172 pool := casGet(&casinoPool, coinId)
173 major := casGet(&casinoJackpot, jpKey(coinId, "major"))
174 grand := casGet(&casinoJackpot, jpKey(coinId, "grand"))
175 seeded := casGet(&casinoStats, coinId+":seeded")
176 staked := casGet(&casinoStats, coinId+":staked")
177 paid := casGet(&casinoStats, coinId+":paid")
178 fees := casGet(&casinoStats, coinId+":fees") // exchange protocol-fee inflow
179 ok := pool+major+grand+paid == seeded+staked+fees
180 out := `{"coin":"` + coinId + `","pool":"` + casU(pool) + `","reserved":"` + casU(casGet(&casinoReserved, coinId)) +
181 `","major":"` + casU(major) + `","grand":"` + casU(grand) + `","seeded":"` + casU(seeded) +
182 `","staked":"` + casU(staked) + `","paid":"` + casU(paid) + `","fees":"` + casU(fees) + `","conserved":` + strconv.FormatBool(ok) +
183 `,"freeSpinsOpen":` + strconv.Itoa(casinoGrants.Size()) + `,"games":{`
184 for i, g := range []string{"coin_flip", "higher_lower", "rps", "slots"} {
185 if i > 0 {
186 out += ","
187 }
188 out += `"` + g + `":{"rounds":` + casU(casGet(&casinoStats, coinId+":"+g+":rounds")) +
189 `,"staked":"` + casU(casGet(&casinoStats, coinId+":"+g+":staked")) +
190 `","paid":"` + casU(casGet(&casinoStats, coinId+":"+g+":paid")) + `"}`
191 }
192 return out + `}}`
193}
194
195// ── uint helpers (balances and pool values are decimal strings) ─────────────
196
197func casGet(t *avl.Tree, key string) uint64 {
198 if !t.Has(key) {
199 return 0
200 }
201 v, _ := strconv.ParseUint(t.Get(key).(string), 10, 64)
202 return v
203}
204
205func casSet(t *avl.Tree, key string, v uint64) { t.Set(key, strconv.FormatUint(v, 10)) }
206
207func casAdd(a, b uint64) uint64 {
208 s, carry := bits.Add64(a, b, 0)
209 if carry != 0 {
210 panic("math overflow")
211 }
212 return s
213}
214
215func casSub(a, b uint64) uint64 {
216 if b > a {
217 panic("math underflow")
218 }
219 return a - b
220}
221
222// casMulDiv = floor(a*b/c) with a 128-bit product.
223func casMulDiv(a, b, c uint64) uint64 {
224 hi, lo := bits.Mul64(a, b)
225 if hi >= c {
226 panic("math overflow")
227 }
228 q, _ := bits.Div64(hi, lo, c)
229 return q
230}
231
232func casMul(a, b uint64) uint64 {
233 hi, lo := bits.Mul64(a, b)
234 if hi != 0 {
235 panic("math overflow")
236 }
237 return lo
238}
239
240func casU(n uint64) string { return strconv.FormatUint(n, 10) }
241
242func casBalOf(addr, coin string) uint64 { return casGet(&balances, addr+":"+coin) }
243
244func casCredit(addr, coin string, amt uint64) {
245 if amt > 0 {
246 casSet(&balances, addr+":"+coin, casAdd(casBalOf(addr, coin), amt))
247 }
248}
249
250func casDebit(addr, coin string, amt uint64) {
251 have := casBalOf(addr, coin)
252 if have < amt {
253 panic("insufficient balance: need " + casU(amt) + " " + coin + ", have " + casU(have))
254 }
255 casSet(&balances, addr+":"+coin, have-amt)
256}
257
258func casGrinValueMilli(coin string, amt uint64) uint64 {
259 r, ok := coinRarity[coin]
260 if !ok || r < 0 || r > 6 {
261 return 0
262 }
263 hi, lo := bits.Mul64(amt, casinoRefMilli[r])
264 if hi != 0 {
265 return ^uint64(0)
266 }
267 return lo
268}
269
270func casIsHex64(s string) bool {
271 if len(s) != 64 {
272 return false
273 }
274 for i := 0; i < len(s); i++ {
275 c := s[i]
276 if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
277 return false
278 }
279 }
280 return true
281}
282
283// casSafe: a short [A-Za-z0-9_.-] token (no quotes, so it is JSON-safe).
284func casSafe(s string, maxLen int) bool {
285 if s == "" || len(s) > maxLen {
286 return false
287 }
288 for i := 0; i < len(s); i++ {
289 c := s[i]
290 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.') {
291 return false
292 }
293 }
294 return true
295}
296
297func sha256Hex(s string) string {
298 h := sha256.Sum256([]byte(s))
299 return hex.EncodeToString(h[:])
300}
301
302// ── outcome rules (the only ones; the client just re-derives them) ──────────
303
304// CasinoRoll is the on-chain roll: uint32(sha256(s:c:roundId:game)[0:4]) % 100.
305func CasinoRoll(serverSeed, clientSecret, roundId, game string) uint64 {
306 h := sha256.Sum256([]byte(serverSeed + ":" + clientSecret + ":" + roundId + ":" + game))
307 v := uint64(h[0])<<24 | uint64(h[1])<<16 | uint64(h[2])<<8 | uint64(h[3])
308 return v % 100
309}
310
311// CasinoOutcome maps a roll to what landed. For rps the outcome is
312// "win" | "push" | "lose" (see rpsHouseMove for the move shown).
313func CasinoOutcome(game string, roll uint64) string {
314 switch game {
315 case "coin_flip":
316 if roll < casinoWinRange {
317 return "heads"
318 }
319 if roll >= 100-casinoWinRange {
320 return "tails"
321 }
322 return "edge"
323 case "higher_lower":
324 if roll >= 100-casinoWinRange {
325 return "higher"
326 }
327 if roll < casinoWinRange {
328 return "lower"
329 }
330 return "house"
331 case "rps":
332 if roll < rpsWinBelow {
333 return "win"
334 }
335 if roll < rpsPushBelow {
336 return "push"
337 }
338 return "lose"
339 }
340 return ""
341}
342
343// rpsHouseMove: the house move that produces the outcome against the player's move.
344func rpsHouseMove(choice, outcome string) string {
345 beats := map[string]string{"rock": "scissors", "paper": "rock", "scissors": "paper"} // key beats value
346 switch outcome {
347 case "win":
348 return beats[choice]
349 case "push":
350 return choice
351 }
352 for k, v := range beats {
353 if v == choice {
354 return k
355 }
356 }
357 return ""
358}
359
360func validCasinoChoice(game, choice string) bool {
361 switch game {
362 case "coin_flip":
363 return choice == "heads" || choice == "tails"
364 case "higher_lower":
365 return choice == "higher" || choice == "lower"
366 case "rps":
367 return choice == "rock" || choice == "paper" || choice == "scissors"
368 case "slots":
369 return casSafe(choice, 32)
370 }
371 return false
372}
373
374// casinoExposure: the largest net amount a bet can take from the pool.
375func casinoExposure(game string, amt uint64) uint64 {
376 if game == "slots" {
377 return casMul(amt, slotsMaxWinMult-1)
378 }
379 return amt // flip / wheel / rps win pays 2× = net +amt
380}
381
382// ── pool ────────────────────────────────────────────────────────────────────
383
384func casDayNow() int64 { return time.Now().Unix() / 86400 }
385
386func casinoDayState(coin string) (int64, uint64, uint64, uint64) {
387 day := casDayNow()
388 if casinoDay.Has(coin) {
389 f := strings.Split(casinoDay.Get(coin).(string), ":")
390 d, _ := strconv.ParseInt(f[0], 10, 64)
391 if d == day && len(f) == 4 {
392 snap, _ := strconv.ParseUint(f[1], 10, 64)
393 paid, _ := strconv.ParseUint(f[2], 10, 64)
394 recv, _ := strconv.ParseUint(f[3], 10, 64)
395 return day, snap, paid, recv
396 }
397 }
398 return day, casGet(&casinoPool, coin), 0, 0 // first touch of the day: snapshot the pool
399}
400
401func setCasinoDay(coin string, day int64, snap, paid, recv uint64) {
402 casinoDay.Set(coin, strconv.FormatInt(day, 10)+":"+casU(snap)+":"+casU(paid)+":"+casU(recv))
403}
404
405func casinoIsOpen(coin string, pool uint64) bool {
406 return pool >= casinoMinPoolUnits && casGrinValueMilli(coin, pool) >= casinoMinPoolMilli
407}
408
409func casinoMaxBet(game string, free uint64) uint64 {
410 if game == "slots" {
411 m := casMulDiv(free, slotsMaxBetPPM, 1000000)
412 return m - m%slotsBetStep
413 }
414 return casMulDiv(free, casinoMaxBetBps, 10000)
415}
416
417// casinoCheckBet panics unless the pool can pay the bet's largest win in full (read-only).
418func casinoCheckBet(coin, game string, amt uint64) {
419 pool := casGet(&casinoPool, coin)
420 reserved := casGet(&casinoReserved, coin)
421 if !casinoIsOpen(coin, pool) {
422 panic("casino closed for " + coin + ": the pool needs at least 20,000 grin-equivalent and 200 " + coin)
423 }
424 if game == "slots" && (amt < slotsBetStep || amt%slotsBetStep != 0) {
425 panic("slots bet must be a multiple of 25")
426 }
427 free := casSub(pool, reserved)
428 maxBet := casinoMaxBet(game, free)
429 if amt > maxBet {
430 if game == "slots" {
431 panic("bet too large: slots max is 0.004 percent of the free casino pool (" + casU(maxBet) + " " + coin + ")")
432 }
433 panic("bet too large: max is 0.5 percent of the free casino pool (" + casU(maxBet) + " " + coin + ")")
434 }
435 exp := casinoExposure(game, amt)
436 if casAdd(reserved, exp) > casMulDiv(pool, casinoOpenBps, 10000) {
437 panic("casino busy: open bets on " + coin + " would exceed 20 percent of the pool")
438 }
439 _, snap, paid, recv := casinoDayState(coin)
440 net := uint64(0)
441 if paid > recv {
442 net = paid - recv
443 }
444 if casAdd(casAdd(net, reserved), exp) > casMulDiv(snap, casinoDayBps, 10000) {
445 panic("casino daily payout limit reached for " + coin)
446 }
447}
448
449func casinoReserve(coin string, exp uint64) {
450 casSet(&casinoReserved, coin, casAdd(casGet(&casinoReserved, coin), exp))
451 day, snap, paid, recv := casinoDayState(coin)
452 setCasinoDay(coin, day, snap, paid, recv)
453}
454
455// casinoSettle releases the reservation and moves coins: the player receives
456// `payout` (0 ≤ payout ≤ stake + exposure) and the pool keeps the rest of the
457// stake or pays the difference. Returns payout.
458func casinoSettle(addr, coin string, amt, exposure, payout uint64) uint64 {
459 if payout > casAdd(amt, exposure) {
460 panic("payout above the reservation")
461 }
462 casSet(&casinoReserved, coin, casSub(casGet(&casinoReserved, coin), exposure))
463 day, snap, paid, recv := casinoDayState(coin)
464 pool := casGet(&casinoPool, coin)
465 if payout >= amt {
466 win := payout - amt
467 casSet(&casinoPool, coin, casSub(pool, win))
468 setCasinoDay(coin, day, snap, casAdd(paid, win), recv)
469 } else {
470 keep := amt - payout
471 casSet(&casinoPool, coin, casAdd(pool, keep))
472 setCasinoDay(coin, day, snap, paid, casAdd(recv, keep))
473 }
474 casCredit(addr, coin, payout)
475 return payout
476}
477
478// SeedCasinoPool moves the ADMIN'S OWN on-chain coins into a casino pool.
479// Only ever the admin's own balance; nobody can withdraw a pool back.
480func SeedCasinoPool(cur realm, coinId string, amount string) {
481 if cur.Previous().Address() != admin {
482 panic("unauthorized: admin only")
483 }
484 if _, ok := coinRarity[coinId]; !ok {
485 panic("unknown coin id: " + coinId)
486 }
487 amt, err := strconv.ParseUint(amount, 10, 64)
488 if err != nil || amt == 0 || amt > casinoMaxAmount {
489 panic("invalid amount")
490 }
491 from := admin.String()
492 casDebit(from, coinId, amt)
493 casSet(&casinoPool, coinId, casAdd(casGet(&casinoPool, coinId), amt))
494 casStat(coinId, "", "seeded", amt)
495 chain.Emit("CasinoPoolSeeded", "coin", coinId, "from", from, "amount", amount)
496}
497
498// CasinoPool reports a coin's pool, reservation, max bets and daily counters.
499func CasinoPool(coinId string) string {
500 pool := casGet(&casinoPool, coinId)
501 reserved := casGet(&casinoReserved, coinId)
502 _, snap, paid, recv := casinoDayState(coinId)
503 free := uint64(0)
504 if pool > reserved {
505 free = pool - reserved
506 }
507 return `{"coin":"` + coinId + `","pool":"` + casU(pool) + `","reserved":"` + casU(reserved) +
508 `","maxBet":"` + casU(casinoMaxBet("coin_flip", free)) + `","slotsMaxBet":"` + casU(casinoMaxBet("slots", free)) +
509 `","open":` + strconv.FormatBool(casinoIsOpen(coinId, pool)) +
510 `,"daySnapshot":"` + casU(snap) + `","paidToday":"` + casU(paid) + `","receivedToday":"` + casU(recv) + `"}`
511}
512