connect4_v4 source realm
package connect4\_v4 is a staked Connect 4 game. A creator posts an offer with a ugnot stake; anyone (or a named oppo...
View source
connect4 - staked Connect 4
Context
We want a real-money Connect 4 on gno.land: equal ugnot stakes, winner takes the pot minus a small house fee, fast turns, a lobby of offers open for the next N minutes, and a guarantee both players are present once a game starts.
Two platform facts shape the design:
time.Now()is the block timestamp. It is deterministic and only advances per block; nothing executes on its own, so deadlines are enforced lazily by whichever transaction checks them.- Presence cannot be observed on-chain. The only proof a player is there is a transaction they signed.
Decision
- Lobby of offers.
Offerescrows the creator's stake with an expiry (1-60 min) and an optional reserved opponent.Acceptmust send exactly the same stake. Both usecur.Previous().IsUserCall()+unsafe.OriginSend(). - Presence via the move clock. Each move has 90s of block time.
Acceptproves the acceptor is present,Revealthe creator, and the first move the first mover. If the clock runs out with zero moves the game is void and both stakes are refunded (the absent player may be the creator who posted long ago); after at least one move, the player on turn forfeits. Anyone can callClaimTimeout, so bystanders can settle stuck games. A latePlaypanics rather than settling, keeping one settlement path. - First mover by two-sided commit-reveal.
Offertakescommitment, the lowercase hexsha256(passphrase);AccepttakesseedCommitment, the same for the acceptor's seed. The game then waits (Turn = 0,Playrejected) for the creator'sReveal(id, passphrase)within 90s of Accept, then the acceptor'sRevealSeed(id, seed)within 90s of that. The first mover is the low bit ofsha256(passphrase|seed|id|creator|acceptor). When a player commits, the other side's secret is still hidden, so neither can grind for an outcome; once revealed, a secret can't change. The only way out is not revealing, which forfeits:ClaimTimeoutpays the other player as a win. A pending Accept shows only a hash, so a creator watching the mempool learns nothing it could cancel on. - Commitments are one-use per address. A revealed secret is public, so reusing it would let the other side compute the draw. Reuse is refused per address (creator or acceptor), never globally, so nobody can block another player's offer by posting its commitment first.
- Fee is a flat 0.1 GNOT (100,000 ugnot) per decisive game (one with a
winner), adjustable by the owner up to 0.5 GNOT (half the minimum stake)
and snapshotted per game at
Offer, so a live game's terms never change.Offertakes the highest fee the creator accepts (maxFee) and refuses a higher one, so the owner can't raise it while an offer is in flight; the cap bounds it for everyone.ActiveJSONgives the current fee, and the gnoweb offer link passes it. Draws and void games pay no fee. The lobby shows each offer's own fee. The owner (p/nt/ownable/v0) canSetFeeandWithdrawFees(collected fees only, never stakes); it is whoever deploys the realm (initwith anIsUserCall()previous), so the deploying multisig owns it with no address baked into the code. Ownership moves in two steps,TransferOwnership(newOwner)thenAcceptOwnership()by that address, so a mistyped spelling can never strand ownership and fees. - Address arguments must be canonical. The chain accepts four texts for
one address (bech32 or bech32m, lower or upper case), but a caller's
address is always lower-case bech32. An opponent or a new owner in another
spelling could never match its caller, so
OfferandTransferOwnershiprefuse it (address.gno, the check ofp/samcrew/launchpad/meta/v1, copied so the realm doesn't depend on that package). - Stale moves.
Play(id, column, move)takes the move count the player saw and refuses any other. A client that re-sends a move whose outcome it could not see (a dropped response) cannot have it land on a later turn. - Session keys. Calls signed by a tm2 account session key (gno #5307;
runtime.GetSessionInfo) mayPlay,Reveal,RevealSeed,ClaimTimeoutandCancelonly. Session allow-paths are per realm, not per function, and a session's spend limit counts gas and sent coins but not what a call forfeits: a stolen session key could otherwiseResignevery live game.Offer/Accept(stakes) and the owner functions need the account's own key too. - Cancel. The creator may cancel an open offer at any time; anyone may once it has expired, so stale offers can be cleared. The refund always goes to the creator.
- Non-payable calls reject coins. Every function other than
OfferandAcceptpanics if coins are sent, so nothing gets stuck in the realm. - Payouts are pushed with a RealmSend banker inside the settling tx, after state is updated. Native coin sends run no callee code.
- Leaderboards (wins, net ugnot won: the opponent's stake minus the
fee) update only on Won/Draw, so void
or cancelled games cannot farm stats. The two top-10 boards are kept at
settlement (
bump), so Render never walks every player's stats. - An
activetree holds only Open/Playing games so the lobby render cost does not grow with history, and the lobby lists at most 50 of them; clients page throughActiveJSON.
Alternatives considered
-
First mover from a hash computed in
Accept(block height, time, ids). Rejected: a tx can carry several messages and is atomic, so an acceptor can send[Accept, Play]and let the whole tx revert whenever the pick goes against them, then retry next block. -
Creator-only commit-reveal, with the
Acceptblock height mixed in (the first hardened version). Rejected in review: a short private offer can only be accepted at about 12-18 heights, so a creator can grind one passphrase that moves first at all of them (about 2^18 tries). -
An acceptor seed sent in clear with
Accept. Rejected: the creator sees it in the mempool and could cancel, or accept with another account, before the Accept lands whenever the draw goes against it. -
The creator picking the first mover in the offer, or a two-game match with each side moving first once: no randomness at all, but a different game.
-
Matchmaking queue by stake tier: faster pairing, but no browsable lobby and it pairs people with idle players.
-
Explicit "ready" handshake after accept: an extra tx per game that the first move already provides.
-
Forfeit on first-move timeout: punishes an acceptor's opponent who simply posted an offer and left before it was taken; void is fairer.
-
Chess-style time banks / per-game timeouts: more state and UI for little gain at this stage.
Consequences
- Clocks are only as precise as block production: a move can land a few seconds past 90s if no block was produced in between. Symmetric for both players.
- A weak secret can be brute-forced offline from its public commitment,
letting the other side steer the draw. This cannot be enforced on-chain;
the pages ask for
openssl rand -hex 32, and Memba generates 32 random bytes for both. - Each game needs two extra txs before the first move,
RevealandRevealSeed, each within 90s; both players must stay online until then. - Self-play with an alt account inflates wins but costs the fee.
- A stolen session key can still play bad moves, one per turn, in the owner's live games. That is inherent to signing moves without the wallet; the client keeps sessions short and bounded.
Security review (before mainnet)
An audit against docs/resources/gno-ai-contract-review.md, the payment
guidance in effective-gno.md and misc/audit-pattern-harness, then two
reviews of this repository's PR, led to the two-sided draw, one-use
commitments, the stale-move check, session-key refusals, the fee cap,
two-step ownership, settlement-time leaderboards, the capped lobby and the
deployer-owner above. Confirmed sound:
the IsUserCall() + OriginSend() payment pair, no exported pointers or
callbacks, Render writing only validated addresses and formatted numbers,
fee < stake per game, exact refunds, and Play (until the deadline) and
ClaimTimeout (after it) never overlapping. The harness's current_guard
hits are the cur.IsCurrent() guard that AGENTS.md says not to write in
crossing functions.
Read accessors
GameJSON(id) (with seedCommitment and revealed), ActiveJSON(offset, limit) and LeadersJSON() (the two top-10 boards) return hand-built JSON for
clients (Memba's Arcade) that read over vm/qeval instead of scraping
Render. Each response carries now, the block time, so clients count the
90s clock in chain time. The board is a 42-char column-major string. Strings
go through strconv.Quote although every stored string is validated on
write. The accessors never call statsOf, which writes on a miss.
package connect4_v4 is a staked Connect 4 game. A creator posts an offer with a ugnot stake; anyone (or a named opponent) accepts with the same stake; the winner takes the pot minus a flat house fee.
The first mover is picked by a two-sided commit-reveal. The creator offers with sha256(passphrase), the acceptor accepts with sha256(seed). Then the creator reveals the passphrase (Reveal) and the acceptor the seed (RevealSeed), each within 90s, and the first mover is drawn from both. When a player commits, the other side's secret is still hidden, so neither can grind for an outcome, and once its secret is revealed nobody can change it; the only way out is to not reveal, which forfeits. Watching a pending Accept tells the creator nothing either, so it cannot cancel unfavourable games. Each address may use a commitment once: a reused secret is already public.
Calls signed by an account session key (Quick play) may only Play, Reveal, RevealSeed, ClaimTimeout and Cancel: a stolen session key must not resign games, move stakes or act as the owner.
Every move has a 90s clock measured in block time. Nothing on-chain fires by itself, so an expired clock is settled by anyone calling ClaimTimeout: whoever did not reveal in time forfeits, a game whose first mover never moved is void and both stakes are refunded, otherwise the player who ran out of time forfeits.
1
16
func Accept
crossing ActionAccept joins an open offer by sending exactly its stake. seedCommitment is the lowercase hex sha256 of a secret seed, revealed after the creator's passphrase. The creator then has 90s to Reveal.
func AcceptOwnership
crossing ActionAcceptOwnership completes a TransferOwnership; only the named address may call it.
func ActiveJSON
ActionActiveJSON returns {"now":<unix>,"fee":<ugnot>,"games":[...]}: the house fee for new offers, then Open and Playing games in id order, skipping offset and returning at most limit (clamped to 1-100).
func Cancel
crossing ActionCancel withdraws an open offer and refunds the creator. The creator may cancel at any time; anyone may once the offer has expired.
func ClaimTimeout
crossing ActionClaimTimeout settles a game whose move clock ran out. Anyone may call it.
func GameJSON
ActionGameJSON returns {"now":<unix>,"game":<game>|null}. It is a read-only accessor for clients querying over vm/qeval; now is the block time.
func LeadersJSON
ActionLeadersJSON returns {"wins":[...],"gnot":[...]}: the top-10 boards (most games won, most ugnot won) kept at settlement, highest first. Each entry is {"addr":<address>,"score":<int>}.
func Offer
crossing ActionOffer posts a game open for validFor minutes. The stake is the ugnot sent with the call. A non-empty opponent makes the offer private. commitment is the lowercase hex sha256 of a secret passphrase, revealed after Accept.
func Play
crossing ActionPlay drops a piece in column (1-7). move is the game's move count the player saw (Moves before this piece): a resent or delayed move that the game has moved past is refused instead of being played on a later turn.
func Render
Render serves "" (lobby and leaderboards), "new" (offer form) and "game/<id>" (one game with every action its state allows). Everything written comes from realm state or from query values that have been validated and re-formatted; raw path input is never echoed.
func Resign
crossing ActionResign concedes a game in progress.
func Reveal
crossing ActionReveal discloses the creator's passphrase. It must come within 90s of Accept; the acceptor then has 90s to RevealSeed.
func RevealSeed
crossing ActionRevealSeed discloses the acceptor's seed, which with the creator's passphrase picks the first mover. It must come within 90s of Reveal.
func SetFee
crossing ActionSetFee changes the flat house fee, in ugnot, for new offers. Games already offered keep the fee they were created with.
func TransferOwnership
crossing ActionTransferOwnership names the next owner, who becomes owner by calling AcceptOwnership. Two steps, so a mistyped or non-canonical address can never strand ownership (and the fees): only an address that signs can accept, and it is recorded as it signs.
func WithdrawFees
crossing ActionWithdrawFees sends all collected fees to to.
3
type Game
struct 1type Game struct {
2 ID int
3 Creator address
4 Opponent address // optional; if set, only this address may accept
5 Acceptor address
6 Stake int64 // ugnot, per player
7 Fee int64 // ugnot house fee, fixed at offer time
8 ExpiresAt time.Time
9 // Commitment is the lowercase hex sha256 of the creator's passphrase;
10 // SeedCommitment that of the acceptor's seed, given with Accept.
11 Commitment string
12 SeedCommitment string
13 // Passphrase is set by Reveal; the draw waits for RevealSeed.
14 Passphrase string
15 Revealed bool
16
17 Board board
18 Turn byte // 0 = awaiting the reveals, 1 = creator, 2 = acceptor
19 Moves int
20 LastCol int // column and row of the last piece dropped, valid if Moves > 0
21 LastRow int
22 LastMove time.Time
23 Status Status
24 Winner address
25}