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