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

leaderboard.gno

12.18 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/samcrew/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}