Search Apps Documentation Source Content File Folder Download Copy Actions Download

sales_index.gno

5.66 Kb · 185 lines
  1package memba_nft_market_v3_2
  2
  3// v3.2 — purchase-query index + getters (the marketplace-v2 Phase 6 unlock).
  4//
  5// v3.1 recorded every settlement in salesLog but exposed NO purchase-query read,
  6// so purchase-gated reviews had nothing to gate on (ungated reputation is
  7// sybil-farmable — worse than none). v3.2 maintains two secondary indexes,
  8// written by the same recordSale that appends the log:
  9//
 10//	buyerSellerIdx  "buyer:seller"        -> int64 sale count
 11//	salesByBuyer    "buyer:pad(saleId)"   -> int64 saleId (per-buyer, sale order)
 12//
 13// and exposes HasPurchased / PurchaseCount / GetSalesByBuyer. Reads use the
 14// same prefix-range technique as GetOffersForToken (":" .. ";") — never a
 15// full-tree scan.
 16//
 17// Historical v3.1 sales enter through the sealed SeedSale migration: owner-only,
 18// NON-payable (never reads OriginSend / moves funds), one-way FinalizeSaleSeed
 19// latch — the exact SeedListing/FinalizeSeed precedent from memba_appstore_v3.
 20
 21import (
 22	"strings"
 23
 24	"chain"
 25
 26	"gno.land/p/nt/avl/v0"
 27)
 28
 29var (
 30	buyerSellerIdx *avl.Tree // "buyer:seller" -> int64 count
 31	salesByBuyer   *avl.Tree // "buyer:pad(saleId)" -> int64 saleId
 32	saleSeedSealed bool
 33)
 34
 35func init() {
 36	buyerSellerIdx = avl.NewTree()
 37	salesByBuyer = avl.NewTree()
 38}
 39
 40// pad renders a saleId fixed-width so string ordering == numeric ordering
 41// inside a buyer's prefix range. 12 digits outlives any plausible sale count.
 42func pad(n int64) string {
 43	s := itoa(n)
 44	for len(s) < 12 {
 45		s = "0" + s
 46	}
 47	return s
 48}
 49
 50// indexSale is called by recordSale (and SeedSale) with an already-persisted
 51// sale. It only maintains the secondary indexes — salesLog stays the source
 52// of truth.
 53func indexSale(seller, buyer address, saleId int64) {
 54	bk := buyer.String() + ":" + seller.String()
 55	cnt := int64(0)
 56	if v, ok := buyerSellerIdx.Get(bk); ok {
 57		cnt = v.(int64)
 58	}
 59	buyerSellerIdx.Set(bk, cnt+1)
 60	salesByBuyer.Set(buyer.String()+":"+pad(saleId), saleId)
 61}
 62
 63// ── Read getters (pure, non-failing) ─────────────────────────────────────────
 64
 65// HasPurchased reports whether buyer has at least one settled purchase from
 66// seller on this engine. THE purchase-gate primitive for reviews: a consumer
 67// realm calls this before accepting a buyer's review of a seller.
 68func HasPurchased(buyerStr, sellerStr string) bool {
 69	return buyerSellerIdx.Has(buyerStr + ":" + sellerStr)
 70}
 71
 72// PurchaseCount returns how many settled purchases buyer has made from seller.
 73func PurchaseCount(buyerStr, sellerStr string) int64 {
 74	if v, ok := buyerSellerIdx.Get(buyerStr + ":" + sellerStr); ok {
 75		return v.(int64)
 76	}
 77	return 0
 78}
 79
 80// GetSalesByBuyer returns a page of the buyer's settled purchases in sale
 81// order, one per line:
 82//
 83//	saleId|collectionID|tokenID|seller|price|blk
 84//
 85// Addresses are FULL. limit is clamped to [1, MaxPageSize]; offset skips
 86// forward inside the buyer's prefix range (bounded by the page clamp — the
 87// iteration stops as soon as the page is full).
 88func GetSalesByBuyer(buyerStr string, offset, limit int) string {
 89	if offset < 0 {
 90		offset = 0
 91	}
 92	if limit <= 0 || limit > MaxPageSize {
 93		limit = MaxPageSize
 94	}
 95	start := buyerStr + ":"
 96	end := buyerStr + ";" // ';' (0x3b) is the byte after ':' (0x3a)
 97
 98	var sb strings.Builder
 99	skipped, taken := 0, 0
100	salesByBuyer.Iterate(start, end, func(_ string, v interface{}) bool {
101		if skipped < offset {
102			skipped++
103			return false
104		}
105		if taken >= limit {
106			return true // page full — stop iterating
107		}
108		saleId := v.(int64)
109		sv, ok := salesLog.Get(itoa(saleId))
110		if !ok {
111			return false // index without log row — skip defensively
112		}
113		s := sv.(*Sale)
114		sb.WriteString(itoa(saleId))
115		sb.WriteString("|")
116		sb.WriteString(s.CollectionID)
117		sb.WriteString("|")
118		sb.WriteString(s.TokenID)
119		sb.WriteString("|")
120		sb.WriteString(s.Seller.String())
121		sb.WriteString("|")
122		sb.WriteString(itoa(s.Price))
123		sb.WriteString("|")
124		sb.WriteString(itoa(s.Blk))
125		sb.WriteString("\n")
126		taken++
127		return false
128	})
129	return sb.String()
130}
131
132// ── Migration (owner-only, sealable) ─────────────────────────────────────────
133
134// SeedSale imports one historical v3.1 sale verbatim (its original block height,
135// price and fee/royalty split). Owner-only, NON-payable, and permanently sealed
136// by FinalizeSaleSeed — without the latch this would be a standing backdoor to
137// forge purchase history and defeat the review gate.
138func SeedSale(
139	cur realm,
140	collectionID, tokenID string,
141	sellerStr, buyerStr string,
142	price, fee, royalty, blk int64,
143) {
144	assertAdmin()
145	if saleSeedSealed {
146		panic("sale seeding sealed — migration is finalized")
147	}
148	if collectionID == "" || tokenID == "" {
149		panic("empty collection/token id")
150	}
151	if sellerStr == "" || buyerStr == "" {
152		panic("empty seller/buyer address")
153	}
154	if price < 0 || fee < 0 || royalty < 0 || fee+royalty > price {
155		panic("invalid price/fee/royalty split")
156	}
157	if blk < 0 {
158		panic("invalid block height")
159	}
160	seller := address(sellerStr)
161	buyer := address(buyerStr)
162
163	nextSaleId++
164	salesLog.Set(itoa(nextSaleId), &Sale{
165		CollectionID: collectionID,
166		TokenID:      tokenID,
167		Seller:       seller,
168		Buyer:        buyer,
169		Price:        price,
170		Fee:          fee,
171		Royalty:      royalty,
172		Blk:          blk,
173	})
174	indexSale(seller, buyer, nextSaleId)
175	// Keep the Render volume stat truthful across the migration.
176	totalVolume += price
177	chain.Emit("SaleSeeded", "saleId", itoa(nextSaleId), "collectionID", collectionID)
178}
179
180// FinalizeSaleSeed permanently seals SeedSale (one-way latch). Owner-only.
181func FinalizeSaleSeed(cur realm) {
182	assertAdmin()
183	saleSeedSealed = true
184	chain.Emit("SaleSeedingFinalized")
185}