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

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}