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

connect4.gno

16.40 Kb · 592 lines
  1// Package connect4 is a staked Connect 4 game. A creator posts an offer with
  2// a ugnot stake; anyone (or a named opponent) accepts with the same stake;
  3// the winner takes the pot minus a flat house fee.
  4//
  5// The first mover is picked by a two-sided commit-reveal. The creator offers
  6// with sha256(passphrase), the acceptor accepts with sha256(seed). Then the
  7// creator reveals the passphrase (Reveal) and the acceptor the seed
  8// (RevealSeed), each within 90s, and the first mover is drawn from both. When
  9// a player commits, the other side's secret is still hidden, so neither can
 10// grind for an outcome, and once its secret is revealed nobody can change it;
 11// the only way out is to not reveal, which forfeits. Watching a pending Accept
 12// tells the creator nothing either, so it cannot cancel unfavourable games.
 13// Each address may use a commitment once: a reused secret is already public.
 14//
 15// Calls signed by an account session key (Quick play) may only Play, Reveal,
 16// RevealSeed, ClaimTimeout and Cancel: a stolen session key must not resign
 17// games, move stakes or act as the owner.
 18//
 19// Every move has a 90s clock measured in block time. Nothing on-chain fires
 20// by itself, so an expired clock is settled by anyone calling ClaimTimeout:
 21// whoever did not reveal in time forfeits, a game whose first mover never
 22// moved is void and both stakes are refunded, otherwise the player who ran
 23// out of time forfeits.
 24package connect4
 25
 26import (
 27	"chain"
 28	"chain/banker"
 29	"chain/runtime"
 30	"chain/runtime/unsafe"
 31	"crypto/sha256"
 32	"encoding/hex"
 33	"strconv"
 34	"strings"
 35	"time"
 36
 37	"gno.land/p/nt/avl/v0"
 38	"gno.land/p/nt/ownable/v0"
 39)
 40
 41const (
 42	moveTimeout = 90 * time.Second
 43	maxOfferTTL = 60 // minutes
 44	feeCap      = minStake / 2
 45	minStake    = 1_000_000 // ugnot
 46	denom       = "ugnot"
 47)
 48
 49type Status int
 50
 51const (
 52	Open Status = iota
 53	Playing
 54	Won
 55	Draw
 56	Void
 57	Cancelled
 58)
 59
 60func (s Status) String() string {
 61	return [...]string{"open", "playing", "won", "draw", "void", "cancelled"}[s]
 62}
 63
 64type Game struct {
 65	ID        int
 66	Creator   address
 67	Opponent  address // optional; if set, only this address may accept
 68	Acceptor  address
 69	Stake     int64 // ugnot, per player
 70	Fee       int64 // ugnot house fee, fixed at offer time
 71	ExpiresAt time.Time
 72	// Commitment is the lowercase hex sha256 of the creator's passphrase;
 73	// SeedCommitment that of the acceptor's seed, given with Accept.
 74	Commitment     string
 75	SeedCommitment string
 76	// Passphrase is set by Reveal; the draw waits for RevealSeed.
 77	Passphrase string
 78	Revealed   bool
 79
 80	Board    board
 81	Turn     byte // 0 = awaiting the reveals, 1 = creator, 2 = acceptor
 82	Moves    int
 83	LastCol  int // column and row of the last piece dropped, valid if Moves > 0
 84	LastRow  int
 85	LastMove time.Time
 86	Status   Status
 87	Winner   address
 88}
 89
 90type Stats struct {
 91	Games, Wins, Draws int
 92	GnotWon            int64 // total net ugnot won: the opponent's stake minus the fee
 93}
 94
 95var (
 96	games         avl.Tree // key(id) -> *Game
 97	active        avl.Tree // key(id) -> *Game, only Open and Playing
 98	stats         avl.Tree // address -> *Stats
 99	nextID        int
100	fee           int64 = 100_000 // ugnot per decisive game
101	feesCollected int64
102	owner         *ownable.Ownable // the deployer; nil (no owner) if deployed some other way
103	pendingOwner  address          // set by TransferOwnership, made owner by AcceptOwnership
104	used          avl.Tree         // address + ":" + commitment -> true, both kinds
105	// testSession makes tests see every call as session-signed (no test hook sets one).
106	testSession bool
107)
108
109// The deployer owns the realm: deploy from the multisig that should own it.
110func init(cur realm) {
111	if cur.Previous().IsUserCall() {
112		owner = ownable.NewWithAddress(cur.Previous().Address())
113	}
114}
115
116// Offer posts a game open for validFor minutes. The stake is the ugnot sent
117// with the call. A non-empty opponent makes the offer private. commitment is
118// the lowercase hex sha256 of a secret passphrase, revealed after Accept.
119func Offer(cur realm, opponent address, validFor int, commitment string, maxFee int64) int {
120	noSession("Offer")
121	stake := received(cur)
122	caller := cur.Previous().Address()
123	if opponent == caller {
124		panic("cannot challenge yourself")
125	}
126	if errs := validateOffer(stake, validFor, opponent, commitment); len(errs) > 0 {
127		panic(errs[0])
128	}
129	// The fee is snapshotted into the game: maxFee is the most the creator
130	// agreed to, so a fee raised while the offer was in flight is refused.
131	if fee > maxFee {
132		panic("the house fee is " + gnot(fee) + ", above your maximum of " + gnot(maxFee))
133	}
134	useCommitment(caller, commitment)
135	nextID++
136	g := &Game{
137		ID:        nextID,
138		Creator:   caller,
139		Opponent:  opponent,
140		Stake:     stake,
141		Fee:       fee,
142		ExpiresAt: time.Now().Add(time.Duration(validFor) * time.Minute),
143
144		Commitment: commitment,
145	}
146	games.Set(key(g.ID), g)
147	active.Set(key(g.ID), g)
148	return g.ID
149}
150
151// validateOffer returns every rule an offer breaks. Offer panics with the
152// first; the "new" page lists them all before anyone signs.
153func validateOffer(stake int64, validFor int, opponent address, commitment string) []string {
154	var errs []string
155	if stake < minStake {
156		errs = append(errs, "stake below minimum of "+gnot(minStake))
157	}
158	if stake <= fee {
159		errs = append(errs, "stake must exceed the house fee of "+gnot(fee))
160	}
161	if validFor < 1 || validFor > maxOfferTTL {
162		errs = append(errs, "validFor must be between 1 and 60 minutes")
163	}
164	// IsValid also accepts other spellings of an address, which would never
165	// match the canonical one Accept compares against.
166	if opponent != "" && !canonical(opponent) {
167		errs = append(errs, "invalid opponent address (use the lower-case g1… form)")
168	}
169	if !isHexSHA256(commitment) {
170		errs = append(errs, "commitment must be 64 lowercase hex chars (sha256 of your passphrase)")
171	}
172	return errs
173}
174
175// Accept joins an open offer by sending exactly its stake. seedCommitment is
176// the lowercase hex sha256 of a secret seed, revealed after the creator's
177// passphrase. The creator then has 90s to Reveal.
178func Accept(cur realm, id int, seedCommitment string) {
179	noSession("Accept")
180	stake := received(cur)
181	caller := cur.Previous().Address()
182	g := mustGet(id)
183	if g.Status != Open {
184		panic("game is not open")
185	}
186	if !time.Now().Before(g.ExpiresAt) {
187		panic("offer expired")
188	}
189	if caller == g.Creator {
190		panic("cannot accept your own offer")
191	}
192	if g.Opponent != "" && caller != g.Opponent {
193		panic("offer is reserved for another player")
194	}
195	if stake != g.Stake {
196		panic("must send exactly " + strconv.FormatInt(g.Stake, 10) + denom)
197	}
198	if !isHexSHA256(seedCommitment) {
199		panic("seedCommitment must be 64 lowercase hex chars (sha256 of your seed)")
200	}
201	useCommitment(caller, seedCommitment)
202	g.Acceptor = caller
203	g.SeedCommitment = seedCommitment
204	g.Status = Playing
205	g.LastMove = time.Now() // Turn stays 0 until Reveal
206}
207
208// Reveal discloses the creator's passphrase. It must come within 90s of
209// Accept; the acceptor then has 90s to RevealSeed.
210func Reveal(cur realm, id int, passphrase string) {
211	noCoins()
212	g := mustGet(id)
213	if g.Status != Playing || g.Turn != 0 || g.Revealed {
214		panic("game is not awaiting the creator's reveal")
215	}
216	if cur.Previous().Address() != g.Creator {
217		panic("only the creator can reveal")
218	}
219	if time.Now().After(g.deadline()) {
220		panic("reveal clock ran out")
221	}
222	if len(passphrase) > 128 {
223		panic("passphrase longer than 128 bytes")
224	}
225	if sha256Hex(passphrase) != g.Commitment {
226		panic("passphrase does not match the commitment")
227	}
228	g.Passphrase = passphrase
229	g.Revealed = true
230	g.LastMove = time.Now()
231}
232
233// RevealSeed discloses the acceptor's seed, which with the creator's
234// passphrase picks the first mover. It must come within 90s of Reveal.
235func RevealSeed(cur realm, id int, seed string) {
236	noCoins()
237	g := mustGet(id)
238	if g.Status != Playing || g.Turn != 0 || !g.Revealed {
239		panic("game is not awaiting the acceptor's seed")
240	}
241	if cur.Previous().Address() != g.Acceptor {
242		panic("only the acceptor can reveal the seed")
243	}
244	if time.Now().After(g.deadline()) {
245		panic("reveal clock ran out")
246	}
247	if sha256Hex(seed) != g.SeedCommitment {
248		panic("seed does not match the commitment")
249	}
250	g.Turn = firstMover(g.Passphrase, seed, g)
251	g.LastMove = time.Now()
252}
253
254// firstMover draws 1 (creator) or 2 (acceptor) from both secrets.
255func firstMover(passphrase, seed string, g *Game) byte {
256	h := sha256.Sum256([]byte(strings.Join([]string{
257		passphrase, seed, strconv.Itoa(g.ID), g.Creator.String(), g.Acceptor.String(),
258	}, "|")))
259	return 1 + h[0]&1
260}
261
262// Play drops a piece in column (1-7). move is the game's move count the
263// player saw (Moves before this piece): a resent or delayed move that the game
264// has moved past is refused instead of being played on a later turn.
265func Play(cur realm, id, column, move int) {
266	noCoins()
267	g := mustGet(id)
268	if g.Status != Playing {
269		panic("game is not in progress")
270	}
271	if g.Turn == 0 {
272		panic("waiting for the creator to reveal")
273	}
274	if cur.Previous().Address() != g.player(g.Turn) {
275		panic("not your turn")
276	}
277	if time.Now().After(g.deadline()) {
278		panic("move clock ran out")
279	}
280	if move != g.Moves {
281		panic("stale move: the game is at move " + strconv.Itoa(g.Moves))
282	}
283	if column < 1 || column > cols {
284		panic("column must be between 1 and 7")
285	}
286	r := g.Board.drop(column-1, g.Turn)
287	if r < 0 {
288		panic("column is full")
289	}
290	g.Moves++
291	g.LastCol, g.LastRow = column-1, r
292	switch {
293	case g.Board.wins(column-1, r):
294		settleWin(cur, g, g.player(g.Turn))
295	case g.Moves == cols*rows:
296		settleDraw(cur, g)
297	default:
298		g.Turn = 3 - g.Turn
299		g.LastMove = time.Now()
300	}
301}
302
303// ClaimTimeout settles a game whose move clock ran out. Anyone may call it.
304func ClaimTimeout(cur realm, id int) {
305	noCoins()
306	g := mustGet(id)
307	if g.Status != Playing {
308		panic("game is not in progress")
309	}
310	if !time.Now().After(g.deadline()) {
311		panic("move clock has not run out")
312	}
313	if g.Turn == 0 { // whoever did not reveal in time forfeits
314		if g.Revealed {
315			settleWin(cur, g, g.Creator)
316		} else {
317			settleWin(cur, g, g.Acceptor)
318		}
319		return
320	}
321	if g.Moves == 0 {
322		settleVoid(cur, g)
323		return
324	}
325	settleWin(cur, g, g.player(3-g.Turn))
326}
327
328// Resign concedes a game in progress.
329func Resign(cur realm, id int) {
330	noCoins()
331	noSession("Resign")
332	g := mustGet(id)
333	if g.Status != Playing {
334		panic("game is not in progress")
335	}
336	switch cur.Previous().Address() {
337	case g.Creator:
338		settleWin(cur, g, g.Acceptor)
339	case g.Acceptor:
340		settleWin(cur, g, g.Creator)
341	default:
342		panic("not a player in this game")
343	}
344}
345
346// Cancel withdraws an open offer and refunds the creator. The creator may
347// cancel at any time; anyone may once the offer has expired.
348func Cancel(cur realm, id int) {
349	noCoins()
350	g := mustGet(id)
351	if g.Status != Open {
352		panic("game is not open")
353	}
354	if cur.Previous().Address() != g.Creator && time.Now().Before(g.ExpiresAt) {
355		panic("only the creator can cancel an unexpired offer")
356	}
357	finish(g, Cancelled)
358	send(cur, g.Creator, g.Stake)
359}
360
361// SetFee changes the flat house fee, in ugnot, for new offers. Games already
362// offered keep the fee they were created with.
363func SetFee(cur realm, amount int64) {
364	noCoins()
365	noSession("SetFee")
366	owner.AssertOwnedBy(cur.Previous().Address())
367	// Capped so the owner can't raise the fee to take most of a stake by
368	// changing it just before an offer lands.
369	if amount < 0 || amount > feeCap {
370		panic("fee must be between 0 and " + gnot(feeCap))
371	}
372	fee = amount
373}
374
375// WithdrawFees sends all collected fees to to.
376func WithdrawFees(cur realm, to address) {
377	noCoins()
378	noSession("WithdrawFees")
379	owner.AssertOwnedBy(cur.Previous().Address())
380	amount := feesCollected
381	feesCollected = 0
382	send(cur, to, amount)
383}
384
385// TransferOwnership names the next owner, who becomes owner by calling
386// AcceptOwnership. Two steps, so a mistyped or non-canonical address can
387// never strand ownership (and the fees): only an address that signs can
388// accept, and it is recorded as it signs.
389func TransferOwnership(cur realm, newOwner address) {
390	noCoins()
391	noSession("TransferOwnership")
392	owner.AssertOwnedBy(cur.Previous().Address())
393	if !canonical(newOwner) {
394		panic("invalid address (use the lower-case g1… form)")
395	}
396	pendingOwner = newOwner
397}
398
399// AcceptOwnership completes a TransferOwnership; only the named address may call it.
400func AcceptOwnership(cur realm) {
401	noCoins()
402	noSession("AcceptOwnership")
403	caller := cur.Previous().Address()
404	if pendingOwner == "" || caller != pendingOwner {
405		panic("no ownership transfer pending for this address")
406	}
407	owner = ownable.NewWithAddress(caller)
408	pendingOwner = ""
409}
410
411// useCommitment records a commitment for addr, refusing one it already used:
412// a revealed secret is public, so reusing it hands the other side the draw.
413// Keyed by address, so nobody can block another player's commitment.
414func useCommitment(addr address, commitment string) {
415	k := addr.String() + ":" + commitment
416	if used.Has(k) {
417		panic("commitment already used: generate a fresh secret")
418	}
419	used.Set(k, true)
420}
421
422// received returns the ugnot sent with a direct user call. OriginSend only
423// describes a real payment to this realm when the caller is an EOA using
424// maketx call; see docs/resources/effective-gno.md.
425func received(cur realm) int64 {
426	if !cur.Previous().IsUserCall() {
427		panic("only user-call (maketx call) accepted")
428	}
429	sent := unsafe.OriginSend()
430	if len(sent) != 1 || sent[0].Denom != denom {
431		panic("send exactly one " + denom + " coin")
432	}
433	return sent[0].Amount
434}
435
436// noCoins guards calls that take no payment: coins sent with them would be
437// stuck in the realm.
438func noCoins() {
439	if len(unsafe.OriginSend()) != 0 {
440		panic("this function does not accept coins")
441	}
442}
443
444// noSession refuses calls signed by an account session key.
445func noSession(action string) {
446	if _, _, _, ok := runtime.GetSessionInfo(); ok || testSession {
447		panic(action + " needs the account's own key, not a session key")
448	}
449}
450
451func sha256Hex(s string) string {
452	h := sha256.Sum256([]byte(s))
453	return hex.EncodeToString(h[:])
454}
455
456func isHexSHA256(s string) bool {
457	if len(s) != 64 {
458		return false
459	}
460	for _, c := range s {
461		if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
462			return false
463		}
464	}
465	return true
466}
467
468func settleWin(cur realm, g *Game, winner address) {
469	loser := g.Creator
470	if winner == g.Creator {
471		loser = g.Acceptor
472	}
473	pot := 2 * g.Stake
474	finish(g, Won)
475	g.Winner = winner
476	feesCollected += g.Fee
477	w := statsOf(winner)
478	w.Games++
479	w.Wins++
480	w.GnotWon += g.Stake - g.Fee
481	topWins = bump(topWins, winner, int64(w.Wins))
482	topGnot = bump(topGnot, winner, w.GnotWon)
483	statsOf(loser).Games++
484	send(cur, winner, pot-g.Fee)
485}
486
487func settleDraw(cur realm, g *Game) {
488	finish(g, Draw)
489	for _, p := range []address{g.Creator, g.Acceptor} {
490		s := statsOf(p)
491		s.Games++
492		s.Draws++
493		send(cur, p, g.Stake)
494	}
495}
496
497func settleVoid(cur realm, g *Game) {
498	finish(g, Void)
499	send(cur, g.Creator, g.Stake)
500	send(cur, g.Acceptor, g.Stake)
501}
502
503// leader is one leaderboard row. The boards are kept at settlement so Render
504// never walks every player's stats.
505type leader struct {
506	Addr  address
507	Score int64
508}
509
510var topWins, topGnot []leader // highest first, at most topN
511
512const topN = 10
513
514// bump moves addr to its new score in board. Scores only grow, so a player
515// already listed can only move up; ties keep the earlier holder first.
516func bump(board []leader, addr address, score int64) []leader {
517	for i, l := range board {
518		if l.Addr == addr {
519			board = append(board[:i], board[i+1:]...)
520			break
521		}
522	}
523	i := len(board)
524	for i > 0 && board[i-1].Score < score {
525		i--
526	}
527	if i >= topN {
528		return board
529	}
530	board = append(board, leader{})
531	copy(board[i+1:], board[i:])
532	board[i] = leader{addr, score}
533	if len(board) > topN {
534		board = board[:topN]
535	}
536	return board
537}
538
539func finish(g *Game, s Status) {
540	g.Status = s
541	active.Remove(key(g.ID))
542}
543
544func send(cur realm, to address, amount int64) {
545	if amount == 0 {
546		return
547	}
548	banker.NewBanker(banker.BankerTypeRealmSend, cur).
549		SendCoins(cur.Address(), to, chain.Coins{{denom, amount}})
550}
551
552func (g *Game) player(turn byte) address {
553	if turn == 1 {
554		return g.Creator
555	}
556	return g.Acceptor
557}
558
559func (g *Game) deadline() time.Time { return g.LastMove.Add(moveTimeout) }
560
561func statsOf(addr address) *Stats {
562	if v := stats.Get(addr.String()); v != nil {
563		return v.(*Stats)
564	}
565	s := &Stats{}
566	stats.Set(addr.String(), s)
567	return s
568}
569
570// key zero-pads ids so avl iteration order matches creation order.
571func key(id int) string {
572	s := strconv.Itoa(id)
573	if len(s) < 10 {
574		s = strings.Repeat("0", 10-len(s)) + s
575	}
576	return s
577}
578
579func get(id int) *Game {
580	if v := games.Get(key(id)); v != nil {
581		return v.(*Game)
582	}
583	return nil
584}
585
586func mustGet(id int) *Game {
587	g := get(id)
588	if g == nil {
589		panic("game not found")
590	}
591	return g
592}