gnobuilders_badges.gno

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, ","))
	}
}