Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}