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.

README.md

8.80 Kb · 153 lines

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. Offer escrows the creator's stake with an expiry (1-60 min) and an optional reserved opponent. Accept must send exactly the same stake. Both use cur.Previous().IsUserCall() + unsafe.OriginSend().
  • Presence via the move clock. Each move has 90s of block time. Accept proves the acceptor is present, Reveal the 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 call ClaimTimeout, so bystanders can settle stuck games. A late Play panics rather than settling, keeping one settlement path.
  • First mover by two-sided commit-reveal. Offer takes commitment, the lowercase hex sha256(passphrase); Accept takes seedCommitment, the same for the acceptor's seed. The game then waits (Turn = 0, Play rejected) for the creator's Reveal(id, passphrase) within 90s of Accept, then the acceptor's RevealSeed(id, seed) within 90s of that. The first mover is the low bit of sha256(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: ClaimTimeout pays 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. Offer takes 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. ActiveJSON gives 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) can SetFee and WithdrawFees (collected fees only, never stakes); it is whoever deploys the realm (init with an IsUserCall() previous), so the deploying multisig owns it with no address baked into the code. Ownership moves in two steps, TransferOwnership(newOwner) then AcceptOwnership() 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 Offer and TransferOwnership refuse it (address.gno, the check of p/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) may Play, Reveal, RevealSeed, ClaimTimeout and Cancel only. 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 otherwise Resign every 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 Offer and Accept panics 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 active tree 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 through ActiveJSON.

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 Accept block 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, Reveal and RevealSeed, 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.