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.

gnobuilders_badges.gno

18.43 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/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/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}