market.gno

package memba_nft_market_v2

// NFT Marketplace v2 — General-purpose NFT trading for the Memba ecosystem on test13.
//
// Supports the memba_nft_v2 collection realm via cross-realm MarketTransfer.
// Platform fee: 2.5% to AdminAddress multisig.
// Royalty: clamped to MaxRoyaltyBPS (10%), returned by nft.RoyaltyInfo().
//
// Security:
//   - CEI (Checks-Effects-Interactions) in all payment flows
//   - IsUserCall guard on BuyNFT and MakeOffer (payment-bearing fns)
//   - Offer escrow with OfferTimeoutBlk safety valve (ClaimExpiredOffer)
//   - MinOfferLifetimeBlk prevents instant-cancel front-run
//   - Only original lister can delist (pause-exempt exit)
//   - Self-buy prevention
//   - Defense-in-depth royalty clamp even when collection enforces its own cap

import (
	"chain"
	"chain/banker"
	"chain/runtime"
	"chain/runtime/unsafe"
	"strconv"
	"strings"

	"gno.land/p/samcrew/grc721"
	"gno.land/p/samcrew/avl"
	"gno.land/p/nt/ufmt/v0"

	nft "gno.land/r/samcrew/memba_nft_v2"
)

// ── Types ─────────────────────────────────────────────────────────────────────

// Listing represents a fixed-price sale order.
type Listing struct {
	CollectionID string
	TokenID      string
	Seller       address
	Price        int64
	CreatedBlk   int64
}

// Sale records a completed transaction for the salesLog.
type Sale struct {
	CollectionID string
	TokenID      string
	Seller       address
	Buyer        address
	Price        int64
	Fee          int64
	Royalty      int64
	Blk          int64
}

// ── State ─────────────────────────────────────────────────────────────────────

var (
	listings     *avl.Tree // listingKey -> *Listing
	offers       *avl.Tree // offerKey   -> *Offer
	salesLog     *avl.Tree // itoa(saleId) -> *Sale
	listingOrder []string  // insertion-ordered listing keys (pagination)
	nextSaleId   int64
	totalVolume  int64
	paused       bool
	feeRecipient = address(AdminAddress)
)

func init() {
	listings = avl.NewTree()
	offers = avl.NewTree()
	salesLog = avl.NewTree()
}

// ── Key helpers ───────────────────────────────────────────────────────────────

func listingKey(collectionID, tokenID string) string {
	return collectionID + ":" + tokenID
}

func offerKey(collectionID, tokenID string, buyer address) string {
	return collectionID + ":" + tokenID + ":" + string(buyer)
}

func itoa(n int64) string { return strconv.FormatInt(n, 10) }

// sumUgnot returns the total ugnot in a coin set.
func sumUgnot(coins chain.Coins) int64 {
	for _, c := range coins {
		if c.Denom == "ugnot" {
			return c.Amount
		}
	}
	return 0
}

// removeFromOrder removes a key from the ordered listing slice (O(n)).
func removeFromOrder(key string) {
	for i, k := range listingOrder {
		if k == key {
			listingOrder = append(listingOrder[:i], listingOrder[i+1:]...)
			return
		}
	}
}

// countListingsBySeller counts a seller's open listings. Bounded by MaxListings.
func countListingsBySeller(seller address) int {
	n := 0
	listings.Iterate("", "", func(_ string, v interface{}) bool {
		if v.(*Listing).Seller == seller {
			n++
		}
		return false
	})
	return n
}

// countOffersByBuyer counts a buyer's open offers. Bounded by MaxOffers.
func countOffersByBuyer(buyer address) int {
	n := 0
	offers.Iterate("", "", func(_ string, v interface{}) bool {
		if v.(*Offer).Buyer == buyer {
			n++
		}
		return false
	})
	return n
}

// ── List / Delist ─────────────────────────────────────────────────────────────

// ListNFT lists an NFT for fixed-price sale.
// PREREQUISITE: Owner must call nft.SetApprovalForAll(marketplace, true) first so
// MarketTransfer can execute on behalf of the seller.
func ListNFT(cur realm, collectionID string, tid grc721.TokenID, price int64) {
	if paused {
		panic("market paused")
	}
	seller := unsafe.PreviousRealm().Address()

	if price < MinPrice {
		panic(ufmt.Sprintf("price must be >= %d ugnot", MinPrice))
	}
	if price > MaxPrice {
		panic(ufmt.Sprintf("price must be <= %d ugnot", MaxPrice))
	}
	if listings.Size() >= MaxListings {
		panic("marketplace listing limit reached")
	}
	if countListingsBySeller(seller) >= MaxListingsPerAddr {
		panic("seller listing limit reached")
	}

	key := listingKey(collectionID, string(tid))
	if _, exists := listings.Get(key); exists {
		panic("already listed: " + key)
	}

	// Effects
	listings.Set(key, &Listing{
		CollectionID: collectionID,
		TokenID:      string(tid),
		Seller:       seller,
		Price:        price,
		CreatedBlk:   runtime.ChainHeight(),
	})
	listingOrder = append(listingOrder, key)

	chain.Emit("NFTListed",
		"collection", collectionID,
		"tokenId", string(tid),
		"seller", seller.String(),
		"price", itoa(price),
	)
}

// DelistNFT removes a listing. Only the original lister can delist. Pause-exempt
// (value-exit: unwinds the seller's position).
func DelistNFT(cur realm, collectionID string, tid grc721.TokenID) {
	caller := unsafe.PreviousRealm().Address()
	key := listingKey(collectionID, string(tid))

	val, exists := listings.Get(key)
	if !exists {
		panic("not listed: " + key)
	}
	l := val.(*Listing)
	if l.Seller != caller {
		panic("only seller can delist")
	}

	// Effects
	listings.Remove(key)
	removeFromOrder(key)

	chain.Emit("NFTDelisted",
		"collection", collectionID,
		"tokenId", string(tid),
		"seller", caller.String(),
	)
}

// ── Buy ───────────────────────────────────────────────────────────────────────

// BuyNFT purchases a listed NFT atomically (CEI order).
//
// CEI:
//   Checks  — paused / IsUserCall / listed / self-buy / payment amount
//   Effects — remove listing from state, record sale, update volume
//   Interactions — cross-call MarketTransfer, then banker payouts (seller last)
func BuyNFT(cur realm, collectionID string, tid grc721.TokenID) {
	if paused {
		panic("market paused")
	}
	if !unsafe.PreviousRealm().IsUserCall() {
		panic("must be a direct user call")
	}
	key := listingKey(collectionID, string(tid))
	v, ok := listings.Get(key)
	if !ok {
		panic("listing not found")
	}
	l := v.(*Listing)
	buyer := unsafe.PreviousRealm().Address()
	if buyer == l.Seller {
		panic("cannot buy own listing")
	}
	if sumUgnot(unsafe.OriginSend()) != l.Price {
		panic("incorrect payment")
	}

	royRecip, royAmt := nft.RoyaltyInfo(collectionID, tid, l.Price)
	fee, royalty, sellerAmt := splitProceeds(l.Price, royAmt)

	// Effects (before cross-call + sends)
	seller := l.Seller
	price := l.Price
	listings.Remove(key)
	removeFromOrder(key)
	recordSale(collectionID, string(tid), seller, buyer, price, fee, royalty)
	totalVolume += price

	// Interactions
	nft.MarketTransfer(cross(cur), collectionID, seller, buyer, tid)
	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
	self := unsafe.CurrentRealm().Address()
	if royalty > 0 {
		bnk.SendCoins(self, royRecip, chain.Coins{chain.NewCoin("ugnot", royalty)})
	}
	if fee > 0 {
		bnk.SendCoins(self, feeRecipient, chain.Coins{chain.NewCoin("ugnot", fee)})
	}
	bnk.SendCoins(self, seller, chain.Coins{chain.NewCoin("ugnot", sellerAmt)}) // seller last

	chain.Emit("PurchaseConfirmed",
		"collection", collectionID,
		"tokenId", string(tid),
		"buyer", buyer.String(),
		"seller", seller.String(),
		"price", itoa(price),
		"fee", itoa(fee),
		"royalty", itoa(royalty),
		"royaltyRecipient", royRecip.String(),
		"sellerAmount", itoa(sellerAmt),
	)
}

// ── splitProceeds ─────────────────────────────────────────────────────────────

// splitProceeds computes the three-way split of a sale price.
// royaltyAmount comes pre-computed from nft.RoyaltyInfo (already the actual
// amount, not bps). We clamp it defensively against MaxRoyaltyBPS.
func splitProceeds(price, royaltyAmount int64) (fee, royalty, seller int64) {
	if price < MinPrice {
		panic("price below minimum")
	}
	if price > MaxPrice {
		panic("price above maximum")
	}
	fee = price * FeeBPS / 10000
	royalty = royaltyAmount
	if royalty < 0 {
		royalty = 0
	}
	maxRoy := price * MaxRoyaltyBPS / 10000
	if royalty > maxRoy {
		royalty = maxRoy // defense-in-depth clamp
	}
	if fee+royalty >= price {
		panic("fee plus royalty exceeds price")
	}
	seller = price - fee - royalty
	if seller <= 0 {
		panic("seller amount not positive")
	}
	return
}

// ── recordSale ────────────────────────────────────────────────────────────────

func recordSale(collectionID, tokenID string, seller, buyer address, price, fee, royalty int64) {
	nextSaleId++
	salesLog.Set(itoa(nextSaleId), &Sale{
		CollectionID: collectionID,
		TokenID:      tokenID,
		Seller:       seller,
		Buyer:        buyer,
		Price:        price,
		Fee:          fee,
		Royalty:      royalty,
		Blk:          runtime.ChainHeight(),
	})
}

// ── IsPaused ─────────────────────────────────────────────────────────────────

func IsPaused() bool { return paused }

// ── truncAddr / truncPath (render helpers) ────────────────────────────────────

func truncAddr(addr address) string {
	s := string(addr)
	if len(s) > 13 {
		return s[:10] + "..."
	}
	return s
}

func truncPath(path string) string {
	parts := strings.Split(path, "/")
	if len(parts) > 2 {
		return parts[len(parts)-1]
	}
	return path
}