Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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