gnobuilders_badges.gno
16.98 Kb · 656 lines
1package gnobuilders_badges_v2
2
3// gnobuilders_badges — GRC721 badge collection for GnoBuilders quests.
4//
5// Features:
6// - Admin-only minting (backend triggers mint after quest verification)
7// - Two badge types: Quest badges (transferable) and Rank badges (soulbound)
8// - Per-quest deduplication (one badge per quest per user)
9// - Standard GRC721: BalanceOf, OwnerOf, TokenURI, TransferFrom, Approve
10// - Render() contract for badge gallery display
11//
12// Security:
13// - Only admin can mint (prevents self-minting)
14// - Soulbound badges: TransferFrom panics for rank badges
15// - Owner can add/remove admins
16// - Token ID format: "{address}:{questId}" (globally unique)
17
18import (
19 "chain"
20 "chain/runtime"
21 "chain/runtime/unsafe"
22 "strconv"
23 "strings"
24
25 "gno.land/p/nt/avl/v0"
26 "gno.land/p/nt/ufmt/v0"
27)
28
29// ── Constants ────────────────────────────────────────────────
30
31const (
32 CollectionName = "GnoBuilders Badges"
33 CollectionSymbol = "GNOBADGE"
34 MaxAdmins = 10
35)
36
37// ── Types ────────────────────────────────────────────────────
38
39type Badge struct {
40 TokenID string // "{address}:{questId}"
41 Owner address
42 QuestID string
43 TokenURI string // IPFS CID or URL to badge metadata JSON
44 Soulbound bool // true for rank badges (non-transferable)
45 MintedAt int64 // block height
46}
47
48// ── State ────────────────────────────────────────────────────
49
50var (
51 badges *avl.Tree // tokenID -> *Badge
52 ownerIndex *avl.Tree // address -> comma-separated tokenIDs (secondary index)
53 balances *avl.Tree // address -> int (badge count)
54 approvals *avl.Tree // tokenID -> approved address
55 opApprove *avl.Tree // "owner:operator" -> bool (approval for all)
56 admins *avl.Tree // address -> bool
57 // Owner is set at package load time via OriginCaller() — this captures the
58 // deployer address (samcrew-core-test1 multisig on testnet).
59 owner = unsafe.OriginCaller()
60 totalMinted uint64
61 paused bool
62)
63
64func init() {
65 badges = avl.NewTree()
66 ownerIndex = avl.NewTree()
67 balances = avl.NewTree()
68 approvals = avl.NewTree()
69 opApprove = avl.NewTree()
70 admins = avl.NewTree()
71
72 admins.Set(owner.String(), true)
73}
74
75// ── Emergency Pause ────────────────────────────────────────
76
77func assertNotPaused() {
78 if paused {
79 panic("realm is paused — emergency maintenance")
80 }
81}
82
83func Pause(cur realm) {
84 assertCallerIsOwner()
85 paused = true
86}
87
88func Unpause(cur realm) {
89 assertCallerIsOwner()
90 paused = false
91}
92
93func IsPaused() bool { return paused }
94
95// ── GRC721 Standard ─────────────────────────────────────────
96
97func Name() string { return CollectionName }
98func Symbol() string { return CollectionSymbol }
99
100func TotalSupply() uint64 { return totalMinted }
101
102func BalanceOf(addr address) uint64 {
103 val, exists := balances.Get(addr.String())
104 if !exists {
105 return 0
106 }
107 return uint64(val.(int))
108}
109
110func OwnerOf(tokenID string) address {
111 val, exists := badges.Get(tokenID)
112 if !exists {
113 panic("token not found: " + tokenID)
114 }
115 return val.(*Badge).Owner
116}
117
118func TokenURI(tokenID string) string {
119 val, exists := badges.Get(tokenID)
120 if !exists {
121 panic("token not found: " + tokenID)
122 }
123 return val.(*Badge).TokenURI
124}
125
126func Approve(cur realm, approved address, tokenID string) {
127 assertNotPaused()
128 caller := unsafe.PreviousRealm().Address()
129 val, exists := badges.Get(tokenID)
130 if !exists {
131 panic("token not found")
132 }
133 badge := val.(*Badge)
134 if badge.Owner != caller {
135 panic("only owner can approve")
136 }
137 if badge.Soulbound {
138 panic("soulbound badges cannot be approved for transfer")
139 }
140 approvals.Set(tokenID, approved.String())
141
142 // GRC721 Approval event
143 chain.Emit("Approval",
144 "owner", caller.String(),
145 "approved", approved.String(),
146 "tokenId", tokenID,
147 )
148}
149
150func SetApprovalForAll(cur realm, operator address, approved bool) {
151 assertNotPaused()
152 if operator == "" {
153 panic("operator address cannot be empty")
154 }
155 caller := unsafe.PreviousRealm().Address()
156 if operator == caller {
157 panic("cannot approve self")
158 }
159 key := caller.String() + ":" + operator.String()
160 if approved {
161 opApprove.Set(key, true)
162 } else {
163 opApprove.Remove(key)
164 }
165}
166
167func IsApprovedForAll(ownerAddr address, operator address) bool {
168 key := ownerAddr.String() + ":" + operator.String()
169 _, exists := opApprove.Get(key)
170 return exists
171}
172
173func GetApproved(tokenID string) address {
174 val, exists := approvals.Get(tokenID)
175 if !exists {
176 return ""
177 }
178 return address(val.(string))
179}
180
181func TransferFrom(cur realm, from address, to address, tokenID string) {
182 assertNotPaused()
183 caller := unsafe.PreviousRealm().Address()
184
185 val, exists := badges.Get(tokenID)
186 if !exists {
187 panic("token not found")
188 }
189 badge := val.(*Badge)
190
191 // Soulbound check
192 if badge.Soulbound {
193 panic("soulbound badges cannot be transferred")
194 }
195
196 // Auth check
197 if badge.Owner != caller {
198 approved := GetApproved(tokenID)
199 if approved != caller && !IsApprovedForAll(from, caller) {
200 panic("not authorized to transfer")
201 }
202 }
203
204 if badge.Owner != from {
205 panic("from address is not the owner")
206 }
207
208 // Transfer
209 badge.Owner = to
210 badges.Set(tokenID, badge)
211 approvals.Remove(tokenID) // Clear approval
212
213 // Update balances and owner index
214 removeFromOwnerIndex(from, tokenID)
215 addToOwnerIndex(to, tokenID)
216 decBalance(from)
217 incBalance(to)
218
219 // GRC721 Transfer event
220 chain.Emit("Transfer",
221 "from", from.String(),
222 "to", to.String(),
223 "tokenId", tokenID,
224 )
225}
226
227// ── Minting (Admin Only) ────────────────────────────────────
228
229// MintQuestBadge mints a quest completion badge to a user.
230// Token ID is "{address}:{questId}" to enforce one badge per quest per user.
231// Quest badges are transferable by default.
232func MintQuestBadge(cur realm, to address, questID string, tokenURI string) string {
233 assertNotPaused()
234 assertCallerIsAdmin()
235 if len(questID) == 0 {
236 panic("questID cannot be empty")
237 }
238 if containsChar(questID, ',') || containsChar(questID, ':') {
239 panic("questID must not contain ',' or ':'")
240 }
241
242 tokenID := to.String() + ":" + questID
243
244 // Check dedup
245 if _, exists := badges.Get(tokenID); exists {
246 panic("badge already minted for this quest: " + tokenID)
247 }
248
249 badge := &Badge{
250 TokenID: tokenID,
251 Owner: to,
252 QuestID: questID,
253 TokenURI: tokenURI,
254 Soulbound: false,
255 MintedAt: runtime.ChainHeight(),
256 }
257
258 badges.Set(tokenID, badge)
259 addToOwnerIndex(to, tokenID)
260 incBalance(to)
261 totalMinted++
262
263 // GRC721 Transfer event from zero address (mint convention)
264 chain.Emit("Transfer",
265 "from", "",
266 "to", to.String(),
267 "tokenId", tokenID,
268 )
269 chain.Emit("QuestBadgeMinted",
270 "to", to.String(),
271 "questId", questID,
272 "tokenId", tokenID,
273 )
274
275 return tokenID
276}
277
278// MintRankBadge mints a soulbound rank badge to a user.
279// Token ID is "{address}:rank:{tier}" to enforce one rank badge per tier per user.
280// Rank badges are NON-transferable (soulbound).
281func MintRankBadge(cur realm, to address, tier int, tokenURI string) string {
282 assertNotPaused()
283 assertCallerIsAdmin()
284
285 tokenID := to.String() + ":rank:" + strconv.Itoa(tier)
286
287 // Check dedup
288 if _, exists := badges.Get(tokenID); exists {
289 panic("rank badge already minted for tier " + strconv.Itoa(tier))
290 }
291
292 badge := &Badge{
293 TokenID: tokenID,
294 Owner: to,
295 QuestID: "rank:" + strconv.Itoa(tier),
296 TokenURI: tokenURI,
297 Soulbound: true,
298 MintedAt: runtime.ChainHeight(),
299 }
300
301 badges.Set(tokenID, badge)
302 addToOwnerIndex(to, tokenID)
303 incBalance(to)
304 totalMinted++
305
306 chain.Emit("Transfer",
307 "from", "",
308 "to", to.String(),
309 "tokenId", tokenID,
310 )
311 chain.Emit("RankBadgeMinted",
312 "to", to.String(),
313 "tier", strconv.Itoa(tier),
314 "tokenId", tokenID,
315 )
316
317 return tokenID
318}
319
320// UpdateTokenURI allows an admin to update the metadata URI of a badge.
321// This is critical because realm code is immutable once deployed — if badge
322// metadata needs correction (e.g., broken IPFS CID), this is the only mechanism.
323func UpdateTokenURI(cur realm, tokenID string, newURI string) {
324 assertNotPaused()
325 assertCallerIsAdmin()
326 val, exists := badges.Get(tokenID)
327 if !exists {
328 panic("token not found: " + tokenID)
329 }
330 badge := val.(*Badge)
331 badge.TokenURI = newURI
332 badges.Set(tokenID, badge)
333}
334
335// ── Burn / Revoke ───────────────────────────────────────────
336
337// BurnBadge permanently removes a badge. Admin-only for soulbound badges,
338// owner or admin for transferable badges.
339func BurnBadge(cur realm, tokenID string) {
340 assertNotPaused()
341 caller := unsafe.PreviousRealm().Address()
342 val, exists := badges.Get(tokenID)
343 if !exists {
344 panic("token not found: " + tokenID)
345 }
346 badge := val.(*Badge)
347
348 if badge.Soulbound {
349 // Soulbound: admin-only burn (revocation)
350 assertCallerIsAdmin()
351 } else {
352 // Transferable: owner or admin can burn
353 isAdmin := false
354 if _, aok := admins.Get(caller.String()); aok {
355 isAdmin = true
356 }
357 if badge.Owner != caller && !isAdmin {
358 panic("only badge owner or admin can burn")
359 }
360 }
361
362 badgeOwner := badge.Owner
363 badges.Remove(tokenID)
364 approvals.Remove(tokenID)
365 removeFromOwnerIndex(badgeOwner, tokenID)
366 decBalance(badgeOwner)
367 if totalMinted > 0 {
368 totalMinted--
369 }
370
371 // GRC721 Transfer event to zero address (burn convention)
372 chain.Emit("Transfer",
373 "from", badgeOwner.String(),
374 "to", "",
375 "tokenId", tokenID,
376 )
377}
378
379// ── Admin Management ────────────────────────────────────────
380
381func AddAdmin(cur realm, addr address) {
382 assertCallerIsOwner()
383 if addr == "" {
384 panic("address cannot be empty")
385 }
386 if admins.Size() >= MaxAdmins {
387 panic("admin limit reached")
388 }
389 admins.Set(addr.String(), true)
390}
391
392func RemoveAdmin(cur realm, addr address) {
393 assertCallerIsOwner()
394 if addr == owner {
395 panic("cannot remove owner")
396 }
397 if _, exists := admins.Get(addr.String()); !exists {
398 panic("address is not an admin: " + addr.String())
399 }
400 admins.Remove(addr.String())
401}
402
403// TransferOwnership transfers realm ownership. The old owner remains as admin
404// (the new owner can call RemoveAdmin to revoke). This is intentional to prevent
405// accidental lockout during multi-step ownership transfers.
406func TransferOwnership(cur realm, newOwner address) {
407 assertCallerIsOwner()
408 if newOwner == "" {
409 panic("address cannot be empty")
410 }
411 if newOwner == owner {
412 panic("already owner")
413 }
414 admins.Set(newOwner.String(), true)
415 owner = newOwner
416}
417
418func IsAdmin(addr address) bool {
419 _, exists := admins.Get(addr.String())
420 return exists
421}
422
423func GetOwner() address { return owner }
424
425// ── Queries ─────────────────────────────────────────────────
426
427// GetBadge returns badge info as a pipe-delimited string.
428func GetBadge(tokenID string) string {
429 val, exists := badges.Get(tokenID)
430 if !exists {
431 return ""
432 }
433 b := val.(*Badge)
434 sb := strconv.FormatBool(b.Soulbound)
435 return ufmt.Sprintf("%s|%s|%s|%s|%s|%d",
436 b.TokenID, b.Owner, b.QuestID, b.TokenURI, sb, b.MintedAt)
437}
438
439// GetUserBadges returns all badge token IDs for a user as comma-separated string.
440// Uses the ownerIndex for O(1) lookup instead of full table scan.
441func GetUserBadges(addr address) string {
442 val, exists := ownerIndex.Get(addr.String())
443 if !exists {
444 return ""
445 }
446 return val.(string)
447}
448
449// GetUserBadgeDetails returns full badge details for a user as a multi-line string.
450// Each line: tokenID|owner|questID|tokenURI|soulbound|mintedAt
451// This avoids N+1 queries from the frontend — one call returns all badge data.
452func GetUserBadgeDetails(addr address) string {
453 tokenIDsStr := GetUserBadges(addr)
454 if tokenIDsStr == "" {
455 return ""
456 }
457
458 var lines []string
459 for _, tokenID := range strings.Split(tokenIDsStr, ",") {
460 info := GetBadge(tokenID)
461 if info != "" {
462 lines = append(lines, info)
463 }
464 }
465 return strings.Join(lines, "\n")
466}
467
468// ── Render ──────────────────────────────────────────────────
469
470func Render(path string) string {
471 if path == "" {
472 return renderHome()
473 }
474 if strings.HasPrefix(path, "badge/") {
475 tokenID := strings.TrimPrefix(path, "badge/")
476 return renderBadge(tokenID)
477 }
478 if strings.HasPrefix(path, "user/") {
479 addr := strings.TrimPrefix(path, "user/")
480 return renderUserBadges(addr)
481 }
482 return "# 404\nPage not found: " + path
483}
484
485func renderHome() string {
486 var sb strings.Builder
487 sb.WriteString("# GnoBuilders Badges\n\n")
488 sb.WriteString(ufmt.Sprintf("**Collection:** %s (%s)\n", CollectionName, CollectionSymbol))
489 sb.WriteString(ufmt.Sprintf("**Total Minted:** %d\n", totalMinted))
490 sb.WriteString(ufmt.Sprintf("**Owner:** %s\n", owner))
491 sb.WriteString(ufmt.Sprintf("**Admins:** %d\n\n", admins.Size()))
492
493 // Recent badges (last 20)
494 count := 0
495 sb.WriteString("## Recent Badges\n\n")
496 sb.WriteString("| Token | Quest | Owner | Soulbound |\n")
497 sb.WriteString("|-------|-------|-------|-----------|\n")
498 badges.ReverseIterate("", "", func(key string, value any) bool {
499 if count >= 20 {
500 return true
501 }
502 b := value.(*Badge)
503 soul := ""
504 if b.Soulbound {
505 soul = "yes"
506 }
507 sb.WriteString(ufmt.Sprintf("| %s | %s | %s | %s |\n",
508 truncStr(b.TokenID, 30), b.QuestID, truncAddr(b.Owner), soul))
509 count++
510 return false
511 })
512
513 return sb.String()
514}
515
516func renderBadge(tokenID string) string {
517 val, exists := badges.Get(tokenID)
518 if !exists {
519 return "# Badge Not Found\nNo badge with ID: " + tokenID
520 }
521 b := val.(*Badge)
522 var sb strings.Builder
523 sb.WriteString(ufmt.Sprintf("# Badge: %s\n\n", b.QuestID))
524 sb.WriteString(ufmt.Sprintf("**Token ID:** %s\n", b.TokenID))
525 sb.WriteString(ufmt.Sprintf("**Owner:** %s\n", b.Owner))
526 sb.WriteString(ufmt.Sprintf("**Quest:** %s\n", b.QuestID))
527 sb.WriteString(ufmt.Sprintf("**Soulbound:** %s\n", strconv.FormatBool(b.Soulbound)))
528 sb.WriteString(ufmt.Sprintf("**Minted at block:** %d\n", b.MintedAt))
529 if b.TokenURI != "" {
530 sb.WriteString(ufmt.Sprintf("**Metadata:** %s\n", b.TokenURI))
531 }
532 return sb.String()
533}
534
535func renderUserBadges(addr string) string {
536 var sb strings.Builder
537 sb.WriteString(ufmt.Sprintf("# Badges for %s\n\n", addr))
538
539 // Use ownerIndex for O(1) lookup instead of full table scan
540 tokenIDsStr := GetUserBadges(address(addr))
541 if tokenIDsStr == "" {
542 sb.WriteString("*No badges yet.*\n")
543 return sb.String()
544 }
545
546 tokenIDs := strings.Split(tokenIDsStr, ",")
547 for _, tokenID := range tokenIDs {
548 val, exists := badges.Get(tokenID)
549 if !exists {
550 continue
551 }
552 b := val.(*Badge)
553 soul := ""
554 if b.Soulbound {
555 soul = " (soulbound)"
556 }
557 sb.WriteString(ufmt.Sprintf("- **%s** — %s%s (block %d)\n",
558 b.QuestID, truncStr(b.TokenURI, 40), soul, b.MintedAt))
559 }
560
561 return sb.String()
562}
563
564// ── Helpers ─────────────────────────────────────────────────
565
566func assertCallerIsAdmin() {
567 caller := unsafe.PreviousRealm().Address()
568 if _, exists := admins.Get(caller.String()); !exists {
569 panic("unauthorized: caller " + caller.String() + " is not an admin")
570 }
571}
572
573func assertCallerIsOwner() {
574 caller := unsafe.PreviousRealm().Address()
575 if caller != owner {
576 panic("unauthorized: caller " + caller.String() + " is not the owner")
577 }
578}
579
580func incBalance(addr address) {
581 val, exists := balances.Get(addr.String())
582 if !exists {
583 balances.Set(addr.String(), 1)
584 } else {
585 balances.Set(addr.String(), val.(int)+1)
586 }
587}
588
589func decBalance(addr address) {
590 val, exists := balances.Get(addr.String())
591 if !exists {
592 return
593 }
594 n := val.(int) - 1
595 if n <= 0 {
596 balances.Remove(addr.String())
597 } else {
598 balances.Set(addr.String(), n)
599 }
600}
601
602func truncAddr(addr address) string {
603 s := addr.String()
604 if len(s) > 13 {
605 return s[:10] + "..."
606 }
607 return s
608}
609
610func truncStr(s string, maxLen int) string {
611 if len(s) > maxLen {
612 return s[:maxLen-3] + "..."
613 }
614 return s
615}
616
617// ── Owner Index Management ──────────────────────────────────
618
619func addToOwnerIndex(addr address, tokenID string) {
620 key := addr.String()
621 val, exists := ownerIndex.Get(key)
622 if !exists {
623 ownerIndex.Set(key, tokenID)
624 } else {
625 ownerIndex.Set(key, val.(string)+","+tokenID)
626 }
627}
628
629func containsChar(s string, ch rune) bool {
630 for _, c := range s {
631 if c == ch {
632 return true
633 }
634 }
635 return false
636}
637
638func removeFromOwnerIndex(addr address, tokenID string) {
639 key := addr.String()
640 val, exists := ownerIndex.Get(key)
641 if !exists {
642 return
643 }
644 ids := strings.Split(val.(string), ",")
645 var remaining []string
646 for _, id := range ids {
647 if id != tokenID {
648 remaining = append(remaining, id)
649 }
650 }
651 if len(remaining) == 0 {
652 ownerIndex.Remove(key)
653 } else {
654 ownerIndex.Set(key, strings.Join(remaining, ","))
655 }
656}