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.

leaderboard.gno

12.21 Kb · 322 lines
  1// Package memba_arcade_leaderboard_v1 is the funds-free, verified-score ledger for the Memba
  2// arcade (BARRICADE, Space Invaders, and future games — one realm, boards keyed per game slug).
  3// Nothing here trusts a client number: an authorized ATTESTER (the backend verifier's key)
  4// re-simulates a submitted input log and attests only the reproduced result. The chain stores the
  5// attested result plus a commitment to the log (its sha256) — the log itself stays off-chain;
  6// "verified, never trustless" is the public contract.
  7//
  8// SAFETY: there is NO banker, NO transfer, and NO OriginSend anywhere — the fund-drain risk class
  9// does not apply. The only trust boundary is who may attest (owner ∪ the attester allowlist); every
 10// write emits an event. Two buckets never mix: the competitive DAILY BOARD (one best entry per
 11// address per game per day, shared seed) and personal TRAINING RECEIPTS (practice runs — a
 12// verified flex, never ranked).
 13//
 14// The game slug is shape-validated only — there is deliberately NO on-chain game registry: the
 15// realm is funds-free, so the worst a typo'd slug can do is fragment a board, and the attester
 16// (not the player) authors the slug.
 17package memba_arcade_leaderboard_v1
 18
 19import (
 20	"chain"
 21	"chain/runtime"
 22
 23	"gno.land/p/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/avl"
 24	"gno.land/p/nt/ufmt/v0"
 25)
 26
 27// No owner is compiled in: the publishing transaction's signer (on gnoland-1 the samcrew
 28// namespace multisig, the stamped creator at enable time) is seeded as owner at package load;
 29// hand it to the memba_dao executor post-deploy via the 2-step ownership transfer.
 30
 31const (
 32	// MaxScore bounds a single attested score — a fat-finger / overflow guard far above any
 33	// reachable sim score (arcade sim scores live in the tens of thousands).
 34	MaxScore = int64(1_000_000_000)
 35	// MaxGameLen bounds the game slug. The slug charset is [a-z0-9-], which EXCLUDES '|' —
 36	// load-bearing: '|' is the composite-key separator in every scoped tree below.
 37	MaxGameLen = 32
 38	// MaxStatsLen bounds the attester-authored per-game stats JSON blob. Ranking never reads
 39	// it; reads only ever emit it JSON-escaped.
 40	MaxStatsLen = 256
 41	// ReceiptsCap bounds one address's training-receipt ring (oldest evicted first; the ring is
 42	// mixed-game, so this is the per-address bound).
 43	ReceiptsCap = 100
 44	// ReceiptDailyCap bounds how many receipts one address may mint per (game, day) (spam cap).
 45	ReceiptDailyCap = 5
 46	// RequestCap bounds one address's PENDING receipt requests (fulfilled ones free their slot):
 47	// the only unprivileged write path must not grow realm state unboundedly.
 48	RequestCap = 8
 49)
 50
 51// Entry is one attested, verifier-reproduced run result.
 52type Entry struct {
 53	Addr           address
 54	Game           string // slug, 1..MaxGameLen chars of [a-z0-9-] (never '|', the key separator)
 55	Day            string // YYYY-MM-DD (the shared daily seed's date; receipt = submission day)
 56	Mode           string // "daily" | "practice"
 57	Seed           string
 58	Score          int64  // the ONLY ranked field
 59	Stats          string // compact attester-authored JSON (game-specific; ≤ MaxStatsLen; opaque here)
 60	SimVersion     int64
 61	StateHash      string // the sim's canonical terminal-state digest
 62	InputLogSha256 string // commitment to the off-chain input log (the proof)
 63	AttestedAt     int64  // block height
 64}
 65
 66var (
 67	owner        address
 68	pendingOwner address
 69	paused       bool
 70	attesters    = avl.NewTree() // attester address string -> bool
 71	boards       = avl.NewTree() // "game|day|addr" -> *Entry (competitive, one best per addr/game/day)
 72	receipts     = avl.NewTree() // addr string -> []*Entry (training ring, mixed-game, capped)
 73	logHashes    = avl.NewTree() // "game|day|sha256" -> addr string (board slot: first-submitter-per-day)
 74	hashOwners   = avl.NewTree() // sha256 -> addr string (GLOBAL: a log binds to ONE address, ever)
 75	requests     = avl.NewTree() // "addr|sha256" -> int64 height (player-paid receipt requests)
 76	requestCount = avl.NewTree() // addr string -> int (pending requests, capped at RequestCap)
 77	boardCount   = avl.NewTree() // "game|day" -> int (entries per game-day, O(1) reads)
 78)
 79
 80func init() { seedAuthority(publisherAtLoad()) }
 81
 82// seedAuthority installs the publisher as owner. It runs once at package load.
 83func seedAuthority(publisher address) {
 84	owner = publisher
 85	pendingOwner = ""
 86}
 87
 88func assertOwner(cur realm) {
 89	if !cur.IsCurrent() {
 90		panic("spoofed realm")
 91	}
 92	if cur.Previous().Address() != owner {
 93		panic("unauthorized: owner only")
 94	}
 95}
 96
 97func assertNotPaused() {
 98	if paused {
 99		panic("attestation is paused")
100	}
101}
102
103func isAttester(addr string) bool {
104	_, ok := attesters.Get(addr)
105	return ok
106}
107
108// assertAttester is the single trust boundary: only the owner or an allowlisted attester key (the
109// backend verifier) may write results. The verifier's own re-simulation gate is what makes an
110// attested score meaningful — this realm only enforces that the source is trusted.
111func assertAttester(cur realm) {
112	if !cur.IsCurrent() {
113		panic("spoofed realm")
114	}
115	pr := cur.Previous().Address()
116	if pr != owner && !isAttester(pr.String()) {
117		panic("unauthorized: owner or allowlisted attester only")
118	}
119}
120
121func assertEntryShape(game string, addr address, day, mode, seed string, score, simVersion int64, stateHash, logHash, stats string) {
122	if addr == "" {
123		panic("addr must be non-empty")
124	}
125	assertGameSlug(game)
126	if len(day) != 10 || day[4] != '-' || day[7] != '-' {
127		panic("day must be YYYY-MM-DD")
128	}
129	for _, i := range []int{0, 1, 2, 3, 5, 6, 8, 9} {
130		if day[i] < '0' || day[i] > '9' {
131			panic("day must be YYYY-MM-DD")
132		}
133	}
134	if mode != "daily" && mode != "practice" {
135		panic("mode must be daily or practice")
136	}
137	if seed == "" {
138		panic("seed must be non-empty")
139	}
140	if score < 0 || score > MaxScore {
141		panic("score out of range")
142	}
143	if simVersion <= 0 {
144		panic("simVersion must be positive")
145	}
146	if stateHash == "" || logHash == "" {
147		panic("stateHash and inputLogSha256 must be non-empty")
148	}
149	if len(stats) > MaxStatsLen {
150		panic("stats too long")
151	}
152}
153
154// assertGameSlug validates the game slug shape: 1..MaxGameLen bytes of [a-z0-9-]. The charset
155// excludes '|' (and everything else), so a slug can never forge or split a composite key.
156func assertGameSlug(game string) {
157	if len(game) < 1 || len(game) > MaxGameLen {
158		panic("game must be 1-32 chars of [a-z0-9-]")
159	}
160	for i := 0; i < len(game); i++ {
161		c := game[i]
162		if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
163			panic("game must be 1-32 chars of [a-z0-9-]")
164		}
165	}
166}
167
168// AttestScore writes (or improves) an address's entry on a game's competitive board for a day.
169// Attester-only. The verifier batches a day's results into one multi-msg tx at day close, plus
170// spam-capped singles for the "certify now" flow. Re-attesting the same game/address/day only ever
171// RAISES the score (Second Wind). A log hash already attested for that game-day for a DIFFERENT
172// address is rejected — on a shared daily seed a valid input log is portable, so first-submitter
173// wins (replay-theft defense). `stats` is the attester-authored per-game JSON blob (waves, wins,
174// levels, …) — never ranked, only echoed by reads.
175func AttestScore(cur realm, game string, addr address, day, seed string, score, simVersion int64, stateHash, logHash, stats string) {
176	assertNotPaused()
177	assertAttester(cur)
178	assertEntryShape(game, addr, day, "daily", seed, score, simVersion, stateHash, logHash, stats)
179
180	hashKey := game + "|" + day + "|" + logHash
181	if v, ok := logHashes.Get(hashKey); ok && v.(string) != addr.String() {
182		panic("duplicate input log: already attested for another address today")
183	}
184	// The GLOBAL net: a log binds to one address forever, across BOTH buckets —
185	// a stolen log can't take a board slot today, a receipt tomorrow, or
186	// anything under a different day OR GAME string (the review rationale
187	// generalizes: no free-form string may launder a stolen log, so this tree
188	// deliberately stays UNSCOPED by game).
189	assertHashOwner(logHash, addr)
190
191	key := game + "|" + day + "|" + addr.String()
192	if v, ok := boards.Get(key); ok {
193		if v.(*Entry).Score >= score {
194			panic("existing entry is not improved")
195		}
196	} else {
197		countKey := game + "|" + day
198		n := 0
199		if c, ok := boardCount.Get(countKey); ok {
200			n = c.(int)
201		}
202		boardCount.Set(countKey, n+1)
203	}
204
205	e := &Entry{
206		Addr: addr, Game: game, Day: day, Mode: "daily", Seed: seed, Score: score, Stats: stats,
207		SimVersion: simVersion, StateHash: stateHash,
208		InputLogSha256: logHash, AttestedAt: runtime.ChainHeight(),
209	}
210	boards.Set(key, e)
211	logHashes.Set(hashKey, addr.String())
212	hashOwners.Set(logHash, addr.String())
213	chain.Emit("ScoreAttested", "game", game, "addr", addr.String(), "day", day,
214		"score", itoa64(score), "simVersion", itoa64(simVersion))
215}
216
217// AttestReceipt mints a personal training receipt — a verified "I did this run", never ranked.
218// Attester-only; capped per address per (game, day) and ring-capped per address. Fulfills (and
219// clears) a matching player-paid RequestReceipt if one is staged.
220func AttestReceipt(cur realm, game string, addr address, day, seed string, score, simVersion int64, stateHash, logHash, stats string) {
221	assertNotPaused()
222	assertAttester(cur)
223	assertEntryShape(game, addr, day, "practice", seed, score, simVersion, stateHash, logHash, stats)
224
225	var ring []*Entry
226	if v, ok := receipts.Get(addr.String()); ok {
227		ring = v.([]*Entry)
228	}
229	sameDay := 0
230	for _, r := range ring {
231		if r.InputLogSha256 == logHash {
232			panic("duplicate receipt for this input log")
233		}
234		if r.Game == game && r.Day == day {
235			sameDay++
236		}
237	}
238	if sameDay >= ReceiptDailyCap {
239		panic("receipt cap reached for this day")
240	}
241	// The same GLOBAL net as the board: a receipt for someone else's attested
242	// log is a forged "verified flex" — reject it, and bind first use here too.
243	assertHashOwner(logHash, addr)
244
245	e := &Entry{
246		Addr: addr, Game: game, Day: day, Mode: "practice", Seed: seed, Score: score, Stats: stats,
247		SimVersion: simVersion, StateHash: stateHash,
248		InputLogSha256: logHash, AttestedAt: runtime.ChainHeight(),
249	}
250	ring = append(ring, e)
251	if len(ring) > ReceiptsCap {
252		ring = ring[len(ring)-ReceiptsCap:] // evict oldest
253	}
254	receipts.Set(addr.String(), ring)
255	hashOwners.Set(logHash, addr.String())
256	clearRequest(addr, logHash)
257	chain.Emit("ReceiptAttested", "game", game, "addr", addr.String(), "day", day,
258		"score", itoa64(score), "simVersion", itoa64(simVersion))
259}
260
261// RequestReceipt is the player-paid leg: ANY signer stages an on-chain consent + payment anchor for
262// one of their own runs (they pay this call's gas). The backend sees the event, verifies the stored
263// log, and fulfills via AttestReceipt. Self-limiting: each request costs the caller gas, and one
264// key slot per (addr, hash). Hash-scoped and game-agnostic on purpose — the verifier, not the
265// player, decides which game the log replays under.
266func RequestReceipt(cur realm, logHash string) {
267	assertNotPaused()
268	if !cur.IsCurrent() {
269		panic("spoofed realm")
270	}
271	if logHash == "" {
272		panic("logHash must be non-empty")
273	}
274	caller := cur.Previous().Address()
275	key := caller.String() + "|" + logHash
276	if _, ok := requests.Get(key); !ok {
277		n := 0
278		if c, ok := requestCount.Get(caller.String()); ok {
279			n = c.(int)
280		}
281		if n >= RequestCap {
282			panic("pending request cap reached — wait for fulfillment")
283		}
284		requestCount.Set(caller.String(), n+1)
285	}
286	requests.Set(key, runtime.ChainHeight())
287	chain.Emit("ReceiptRequested", "addr", caller.String(), "logHash", logHash)
288}
289
290// assertHashOwner enforces the GLOBAL one-log-one-address rule shared by both
291// buckets (and by every game), and is idempotent for the rightful owner
292// (Second Wind re-attests).
293func assertHashOwner(logHash string, addr address) {
294	if v, ok := hashOwners.Get(logHash); ok && v.(string) != addr.String() {
295		panic("duplicate input log: already bound to another address")
296	}
297}
298
299// clearRequest removes a fulfilled request and frees the caller's cap slot.
300func clearRequest(addr address, logHash string) {
301	key := addr.String() + "|" + logHash
302	if _, ok := requests.Get(key); !ok {
303		return
304	}
305	requests.Remove(key)
306	if c, ok := requestCount.Get(addr.String()); ok {
307		if n := c.(int); n > 1 {
308			requestCount.Set(addr.String(), n-1)
309		} else {
310			requestCount.Remove(addr.String())
311		}
312	}
313}
314
315func itoa64(n int64) string { return ufmt.Sprintf("%d", n) }
316
317func boolStr(b bool) string {
318	if b {
319		return "true"
320	}
321	return "false"
322}