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}