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}