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}