package gnobuilders_badges_v2
// gnobuilders_badges — GRC721 badge collection for GnoBuilders quests.
//
// Features:
// - Admin-only minting (backend triggers mint after quest verification)
// - Two badge types: Quest badges (transferable) and Rank badges (soulbound)
// - Per-quest deduplication (one badge per quest per user)
// - Standard GRC721: BalanceOf, OwnerOf, TokenURI, TransferFrom, Approve
// - Render() contract for badge gallery display
//
// Security:
// - Only admin can mint (prevents self-minting)
// - Soulbound badges: TransferFrom panics for rank badges
// - Owner can add/remove admins
// - Token ID format: "{address}:{questId}" (globally unique)
import (
"chain"
"chain/runtime"
"chain/runtime/unsafe"
"strconv"
"strings"
"gno.land/p/samcrew/avl"
"gno.land/p/nt/ufmt/v0"
)
// ── Constants ────────────────────────────────────────────────
const (
CollectionName = "GnoBuilders Badges"
CollectionSymbol = "GNOBADGE"
MaxAdmins = 10
)
// ── Types ────────────────────────────────────────────────────
type Badge struct {
TokenID string // "{address}:{questId}"
Owner address
QuestID string
TokenURI string // IPFS CID or URL to badge metadata JSON
Soulbound bool // true for rank badges (non-transferable)
MintedAt int64 // block height
}
// ── State ────────────────────────────────────────────────────
var (
badges *avl.Tree // tokenID -> *Badge
ownerIndex *avl.Tree // address -> comma-separated tokenIDs (secondary index)
balances *avl.Tree // address -> int (badge count)
approvals *avl.Tree // tokenID -> approved address
opApprove *avl.Tree // "owner:operator" -> bool (approval for all)
admins *avl.Tree // address -> bool
// Owner is set at package load time via OriginCaller() — this captures the
// deployer address (samcrew-core-test1 multisig on testnet).
owner = unsafe.OriginCaller()
totalMinted uint64
paused bool
)
func init() {
badges = avl.NewTree()
ownerIndex = avl.NewTree()
balances = avl.NewTree()
approvals = avl.NewTree()
opApprove = avl.NewTree()
admins = avl.NewTree()
admins.Set(owner.String(), true)
}
// ── Emergency Pause ────────────────────────────────────────
func assertNotPaused() {
if paused {
panic("realm is paused — emergency maintenance")
}
}
func Pause(cur realm) {
assertCallerIsOwner()
paused = true
}
func Unpause(cur realm) {
assertCallerIsOwner()
paused = false
}
func IsPaused() bool { return paused }
// ── GRC721 Standard ─────────────────────────────────────────
func Name() string { return CollectionName }
func Symbol() string { return CollectionSymbol }
func TotalSupply() uint64 { return totalMinted }
func BalanceOf(addr address) uint64 {
val, exists := balances.Get(addr.String())
if !exists {
return 0
}
return uint64(val.(int))
}
func OwnerOf(tokenID string) address {
val, exists := badges.Get(tokenID)
if !exists {
panic("token not found: " + tokenID)
}
return val.(*Badge).Owner
}
func TokenURI(tokenID string) string {
val, exists := badges.Get(tokenID)
if !exists {
panic("token not found: " + tokenID)
}
return val.(*Badge).TokenURI
}
func Approve(cur realm, approved address, tokenID string) {
assertNotPaused()
caller := unsafe.PreviousRealm().Address()
val, exists := badges.Get(tokenID)
if !exists {
panic("token not found")
}
badge := val.(*Badge)
if badge.Owner != caller {
panic("only owner can approve")
}
if badge.Soulbound {
panic("soulbound badges cannot be approved for transfer")
}
approvals.Set(tokenID, approved.String())
// GRC721 Approval event
chain.Emit("Approval",
"owner", caller.String(),
"approved", approved.String(),
"tokenId", tokenID,
)
}
func SetApprovalForAll(cur realm, operator address, approved bool) {
assertNotPaused()
if operator == "" {
panic("operator address cannot be empty")
}
caller := unsafe.PreviousRealm().Address()
if operator == caller {
panic("cannot approve self")
}
key := caller.String() + ":" + operator.String()
if approved {
opApprove.Set(key, true)
} else {
opApprove.Remove(key)
}
}
func IsApprovedForAll(ownerAddr address, operator address) bool {
key := ownerAddr.String() + ":" + operator.String()
_, exists := opApprove.Get(key)
return exists
}
func GetApproved(tokenID string) address {
val, exists := approvals.Get(tokenID)
if !exists {
return ""
}
return address(val.(string))
}
func TransferFrom(cur realm, from address, to address, tokenID string) {
assertNotPaused()
caller := unsafe.PreviousRealm().Address()
val, exists := badges.Get(tokenID)
if !exists {
panic("token not found")
}
badge := val.(*Badge)
// Soulbound check
if badge.Soulbound {
panic("soulbound badges cannot be transferred")
}
// Auth check
if badge.Owner != caller {
approved := GetApproved(tokenID)
if approved != caller && !IsApprovedForAll(from, caller) {
panic("not authorized to transfer")
}
}
if badge.Owner != from {
panic("from address is not the owner")
}
// Transfer
badge.Owner = to
badges.Set(tokenID, badge)
approvals.Remove(tokenID) // Clear approval
// Update balances and owner index
removeFromOwnerIndex(from, tokenID)
addToOwnerIndex(to, tokenID)
decBalance(from)
incBalance(to)
// GRC721 Transfer event
chain.Emit("Transfer",
"from", from.String(),
"to", to.String(),
"tokenId", tokenID,
)
}
// ── Minting (Admin Only) ────────────────────────────────────
// MintQuestBadge mints a quest completion badge to a user.
// Token ID is "{address}:{questId}" to enforce one badge per quest per user.
// Quest badges are transferable by default.
func MintQuestBadge(cur realm, to address, questID string, tokenURI string) string {
assertNotPaused()
assertCallerIsAdmin()
if len(questID) == 0 {
panic("questID cannot be empty")
}
if containsChar(questID, ',') || containsChar(questID, ':') {
panic("questID must not contain ',' or ':'")
}
tokenID := to.String() + ":" + questID
// Check dedup
if _, exists := badges.Get(tokenID); exists {
panic("badge already minted for this quest: " + tokenID)
}
badge := &Badge{
TokenID: tokenID,
Owner: to,
QuestID: questID,
TokenURI: tokenURI,
Soulbound: false,
MintedAt: runtime.ChainHeight(),
}
badges.Set(tokenID, badge)
addToOwnerIndex(to, tokenID)
incBalance(to)
totalMinted++
// GRC721 Transfer event from zero address (mint convention)
chain.Emit("Transfer",
"from", "",
"to", to.String(),
"tokenId", tokenID,
)
chain.Emit("QuestBadgeMinted",
"to", to.String(),
"questId", questID,
"tokenId", tokenID,
)
return tokenID
}
// MintRankBadge mints a soulbound rank badge to a user.
// Token ID is "{address}:rank:{tier}" to enforce one rank badge per tier per user.
// Rank badges are NON-transferable (soulbound).
func MintRankBadge(cur realm, to address, tier int, tokenURI string) string {
assertNotPaused()
assertCallerIsAdmin()
tokenID := to.String() + ":rank:" + strconv.Itoa(tier)
// Check dedup
if _, exists := badges.Get(tokenID); exists {
panic("rank badge already minted for tier " + strconv.Itoa(tier))
}
badge := &Badge{
TokenID: tokenID,
Owner: to,
QuestID: "rank:" + strconv.Itoa(tier),
TokenURI: tokenURI,
Soulbound: true,
MintedAt: runtime.ChainHeight(),
}
badges.Set(tokenID, badge)
addToOwnerIndex(to, tokenID)
incBalance(to)
totalMinted++
chain.Emit("Transfer",
"from", "",
"to", to.String(),
"tokenId", tokenID,
)
chain.Emit("RankBadgeMinted",
"to", to.String(),
"tier", strconv.Itoa(tier),
"tokenId", tokenID,
)
return tokenID
}
// UpdateTokenURI allows an admin to update the metadata URI of a badge.
// This is critical because realm code is immutable once deployed — if badge
// metadata needs correction (e.g., broken IPFS CID), this is the only mechanism.
func UpdateTokenURI(cur realm, tokenID string, newURI string) {
assertNotPaused()
assertCallerIsAdmin()
val, exists := badges.Get(tokenID)
if !exists {
panic("token not found: " + tokenID)
}
badge := val.(*Badge)
badge.TokenURI = newURI
badges.Set(tokenID, badge)
}
// ── Burn / Revoke ───────────────────────────────────────────
// BurnBadge permanently removes a badge. Admin-only for soulbound badges,
// owner or admin for transferable badges.
func BurnBadge(cur realm, tokenID string) {
assertNotPaused()
caller := unsafe.PreviousRealm().Address()
val, exists := badges.Get(tokenID)
if !exists {
panic("token not found: " + tokenID)
}
badge := val.(*Badge)
if badge.Soulbound {
// Soulbound: admin-only burn (revocation)
assertCallerIsAdmin()
} else {
// Transferable: owner or admin can burn
isAdmin := false
if _, aok := admins.Get(caller.String()); aok {
isAdmin = true
}
if badge.Owner != caller && !isAdmin {
panic("only badge owner or admin can burn")
}
}
badgeOwner := badge.Owner
badges.Remove(tokenID)
approvals.Remove(tokenID)
removeFromOwnerIndex(badgeOwner, tokenID)
decBalance(badgeOwner)
if totalMinted > 0 {
totalMinted--
}
// GRC721 Transfer event to zero address (burn convention)
chain.Emit("Transfer",
"from", badgeOwner.String(),
"to", "",
"tokenId", tokenID,
)
}
// ── Admin Management ────────────────────────────────────────
func AddAdmin(cur realm, addr address) {
assertCallerIsOwner()
if addr == "" {
panic("address cannot be empty")
}
if admins.Size() >= MaxAdmins {
panic("admin limit reached")
}
admins.Set(addr.String(), true)
}
func RemoveAdmin(cur realm, addr address) {
assertCallerIsOwner()
if addr == owner {
panic("cannot remove owner")
}
if _, exists := admins.Get(addr.String()); !exists {
panic("address is not an admin: " + addr.String())
}
admins.Remove(addr.String())
}
// TransferOwnership transfers realm ownership. The old owner remains as admin
// (the new owner can call RemoveAdmin to revoke). This is intentional to prevent
// accidental lockout during multi-step ownership transfers.
func TransferOwnership(cur realm, newOwner address) {
assertCallerIsOwner()
if newOwner == "" {
panic("address cannot be empty")
}
if newOwner == owner {
panic("already owner")
}
admins.Set(newOwner.String(), true)
owner = newOwner
}
func IsAdmin(addr address) bool {
_, exists := admins.Get(addr.String())
return exists
}
func GetOwner() address { return owner }
// ── Queries ─────────────────────────────────────────────────
// GetBadge returns badge info as a pipe-delimited string.
func GetBadge(tokenID string) string {
val, exists := badges.Get(tokenID)
if !exists {
return ""
}
b := val.(*Badge)
sb := strconv.FormatBool(b.Soulbound)
return ufmt.Sprintf("%s|%s|%s|%s|%s|%d",
b.TokenID, b.Owner, b.QuestID, b.TokenURI, sb, b.MintedAt)
}
// GetUserBadges returns all badge token IDs for a user as comma-separated string.
// Uses the ownerIndex for O(1) lookup instead of full table scan.
func GetUserBadges(addr address) string {
val, exists := ownerIndex.Get(addr.String())
if !exists {
return ""
}
return val.(string)
}
// GetUserBadgeDetails returns full badge details for a user as a multi-line string.
// Each line: tokenID|owner|questID|tokenURI|soulbound|mintedAt
// This avoids N+1 queries from the frontend — one call returns all badge data.
func GetUserBadgeDetails(addr address) string {
tokenIDsStr := GetUserBadges(addr)
if tokenIDsStr == "" {
return ""
}
var lines []string
for _, tokenID := range strings.Split(tokenIDsStr, ",") {
info := GetBadge(tokenID)
if info != "" {
lines = append(lines, info)
}
}
return strings.Join(lines, "\n")
}
// ── Render ──────────────────────────────────────────────────
func Render(path string) string {
if path == "" {
return renderHome()
}
if strings.HasPrefix(path, "badge/") {
tokenID := strings.TrimPrefix(path, "badge/")
return renderBadge(tokenID)
}
if strings.HasPrefix(path, "user/") {
addr := strings.TrimPrefix(path, "user/")
return renderUserBadges(addr)
}
return "# 404\nPage not found: " + path
}
func renderHome() string {
var sb strings.Builder
sb.WriteString("# GnoBuilders Badges\n\n")
sb.WriteString(ufmt.Sprintf("**Collection:** %s (%s)\n", CollectionName, CollectionSymbol))
sb.WriteString(ufmt.Sprintf("**Total Minted:** %d\n", totalMinted))
sb.WriteString(ufmt.Sprintf("**Owner:** %s\n", owner))
sb.WriteString(ufmt.Sprintf("**Admins:** %d\n\n", admins.Size()))
// Recent badges (last 20)
count := 0
sb.WriteString("## Recent Badges\n\n")
sb.WriteString("| Token | Quest | Owner | Soulbound |\n")
sb.WriteString("|-------|-------|-------|-----------|\n")
badges.ReverseIterate("", "", func(key string, value any) bool {
if count >= 20 {
return true
}
b := value.(*Badge)
soul := ""
if b.Soulbound {
soul = "yes"
}
sb.WriteString(ufmt.Sprintf("| %s | %s | %s | %s |\n",
truncStr(b.TokenID, 30), b.QuestID, truncAddr(b.Owner), soul))
count++
return false
})
return sb.String()
}
func renderBadge(tokenID string) string {
val, exists := badges.Get(tokenID)
if !exists {
return "# Badge Not Found\nNo badge with ID: " + tokenID
}
b := val.(*Badge)
var sb strings.Builder
sb.WriteString(ufmt.Sprintf("# Badge: %s\n\n", b.QuestID))
sb.WriteString(ufmt.Sprintf("**Token ID:** %s\n", b.TokenID))
sb.WriteString(ufmt.Sprintf("**Owner:** %s\n", b.Owner))
sb.WriteString(ufmt.Sprintf("**Quest:** %s\n", b.QuestID))
sb.WriteString(ufmt.Sprintf("**Soulbound:** %s\n", strconv.FormatBool(b.Soulbound)))
sb.WriteString(ufmt.Sprintf("**Minted at block:** %d\n", b.MintedAt))
if b.TokenURI != "" {
sb.WriteString(ufmt.Sprintf("**Metadata:** %s\n", b.TokenURI))
}
return sb.String()
}
func renderUserBadges(addr string) string {
var sb strings.Builder
sb.WriteString(ufmt.Sprintf("# Badges for %s\n\n", addr))
// Use ownerIndex for O(1) lookup instead of full table scan
tokenIDsStr := GetUserBadges(address(addr))
if tokenIDsStr == "" {
sb.WriteString("*No badges yet.*\n")
return sb.String()
}
tokenIDs := strings.Split(tokenIDsStr, ",")
for _, tokenID := range tokenIDs {
val, exists := badges.Get(tokenID)
if !exists {
continue
}
b := val.(*Badge)
soul := ""
if b.Soulbound {
soul = " (soulbound)"
}
sb.WriteString(ufmt.Sprintf("- **%s** — %s%s (block %d)\n",
b.QuestID, truncStr(b.TokenURI, 40), soul, b.MintedAt))
}
return sb.String()
}
// ── Helpers ─────────────────────────────────────────────────
func assertCallerIsAdmin() {
caller := unsafe.PreviousRealm().Address()
if _, exists := admins.Get(caller.String()); !exists {
panic("unauthorized: caller " + caller.String() + " is not an admin")
}
}
func assertCallerIsOwner() {
caller := unsafe.PreviousRealm().Address()
if caller != owner {
panic("unauthorized: caller " + caller.String() + " is not the owner")
}
}
func incBalance(addr address) {
val, exists := balances.Get(addr.String())
if !exists {
balances.Set(addr.String(), 1)
} else {
balances.Set(addr.String(), val.(int)+1)
}
}
func decBalance(addr address) {
val, exists := balances.Get(addr.String())
if !exists {
return
}
n := val.(int) - 1
if n <= 0 {
balances.Remove(addr.String())
} else {
balances.Set(addr.String(), n)
}
}
func truncAddr(addr address) string {
s := addr.String()
if len(s) > 13 {
return s[:10] + "..."
}
return s
}
func truncStr(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen-3] + "..."
}
return s
}
// ── Owner Index Management ──────────────────────────────────
func addToOwnerIndex(addr address, tokenID string) {
key := addr.String()
val, exists := ownerIndex.Get(key)
if !exists {
ownerIndex.Set(key, tokenID)
} else {
ownerIndex.Set(key, val.(string)+","+tokenID)
}
}
func containsChar(s string, ch rune) bool {
for _, c := range s {
if c == ch {
return true
}
}
return false
}
func removeFromOwnerIndex(addr address, tokenID string) {
key := addr.String()
val, exists := ownerIndex.Get(key)
if !exists {
return
}
ids := strings.Split(val.(string), ",")
var remaining []string
for _, id := range ids {
if id != tokenID {
remaining = append(remaining, id)
}
}
if len(remaining) == 0 {
ownerIndex.Remove(key)
} else {
ownerIndex.Set(key, strings.Join(remaining, ","))
}
}