pointsv2.gno
11.07 Kb · 467 lines
1// Package pointsv2 is referral + check-in + trade/create points for gnomemepad.
2//
3// Trade/create awards are pad-only: allowed pad packages call OnTrade / OnCreate
4// after a successful user action. EOAs cannot self-award trade points.
5//
6// SetReferrer / CheckIn / AwardPoints / Leaderboard — same as v1
7// AllowPad / RevokePad — admin allowlist of pad package paths
8// OnTrade / OnCreate — called by pad via cross(cur)
9// ParamsInfo — extended for UI
10package pointsv2
11
12import (
13 "chain"
14 "chain/runtime"
15 "strconv"
16 "strings"
17
18 "gno.land/p/nt/avl/v0"
19)
20
21const (
22 PointsReferrerBonus int64 = 50
23 PointsRefereeBonus int64 = 25
24 PointsCheckIn int64 = 5
25 CheckInIntervalH int64 = 100
26 // Trade / create (v2)
27 PointsCreateBonus int64 = 30 // creator on successful Create
28 PointsBuyBase int64 = 2 // base points per buy
29 PointsSellBase int64 = 1 // base points per sell
30 PointsPerGnotBuy int64 = 10 // extra pts per full GNOT bought (volume)
31 PointsPerGnotSell int64 = 3 // extra pts per full GNOT sold
32 MaxTradePtsPerHeight int64 = 200 // per-user per-height cap (anti-spam)
33 UgnotPerGnot int64 = 1_000_000
34 LeaderboardMax int = 50
35 MaxLeaderboardReturn int = 20
36 MaxPadPathLen int = 200
37)
38
39var (
40 admin address
41 inited bool
42 // pointsByAddr: address string -> int64
43 pointsByAddr avl.Tree
44 // referrerOf: address string -> referrer address string
45 referrerOf avl.Tree
46 // lastCheckIn: address string -> height int64
47 lastCheckIn avl.Tree
48 // allowedPads: package path -> true
49 allowedPads avl.Tree
50 // tradePtsAtHeight: "addr|height" -> int64 points already awarded that height
51 tradePtsAtHeight avl.Tree
52 // totalTradePts / totalCreatePts counters for ops
53 totalTradePts int64
54 totalCreatePts int64
55)
56
57func Init(cur realm) {
58 if inited {
59 panic("pointsv2: already initialized")
60 }
61 if !cur.Previous().IsUserCall() {
62 panic("pointsv2: EOA only")
63 }
64 admin = cur.Previous().Address()
65 inited = true
66 pointsByAddr = avl.Tree{}
67 referrerOf = avl.Tree{}
68 lastCheckIn = avl.Tree{}
69 allowedPads = avl.Tree{}
70 tradePtsAtHeight = avl.Tree{}
71 chain.Emit("Init", "admin", admin.String())
72}
73
74func requireInit() {
75 if !inited {
76 panic("pointsv2: call Init first")
77 }
78}
79
80func requireAdmin(cur realm) {
81 requireInit()
82 if !cur.Previous().IsUserCall() {
83 panic("pointsv2: EOA only")
84 }
85 if cur.Previous().Address() != admin {
86 panic("pointsv2: not admin")
87 }
88}
89
90func getPts(addr string) int64 {
91 v := pointsByAddr.Get(addr)
92 if v == nil {
93 return 0
94 }
95 n, ok := v.(int64)
96 if !ok {
97 return 0
98 }
99 return n
100}
101
102func addPts(addr string, delta int64) {
103 if delta == 0 || addr == "" {
104 return
105 }
106 cur := getPts(addr)
107 next := cur + delta
108 if next < 0 {
109 next = 0
110 }
111 pointsByAddr.Set(addr, next)
112}
113
114// AllowPad registers a pad package path that may call OnTrade / OnCreate.
115func AllowPad(cur realm, padPkg string) {
116 requireAdmin(cur)
117 padPkg = strings.TrimSpace(padPkg)
118 if padPkg == "" || len(padPkg) > MaxPadPathLen {
119 panic("pointsv2: invalid pad path")
120 }
121 if !strings.HasPrefix(padPkg, "gno.land/r/") {
122 panic("pointsv2: pad must be gno.land/r/…")
123 }
124 allowedPads.Set(padPkg, true)
125 chain.Emit("AllowPad", "pad", padPkg)
126}
127
128// RevokePad removes a pad from the allowlist.
129func RevokePad(cur realm, padPkg string) {
130 requireAdmin(cur)
131 padPkg = strings.TrimSpace(padPkg)
132 allowedPads.Remove(padPkg)
133 chain.Emit("RevokePad", "pad", padPkg)
134}
135
136// IsPadAllowed reports whether path may award trade points.
137func IsPadAllowed(padPkg string) bool {
138 requireInit()
139 return allowedPads.Has(strings.TrimSpace(padPkg))
140}
141
142// ListPads returns allowed pad paths, one per line.
143func ListPads() string {
144 requireInit()
145 out := ""
146 allowedPads.Iterate("", "", func(key string, _ any) bool {
147 if out != "" {
148 out += "\n"
149 }
150 out += key
151 return false
152 })
153 return out
154}
155
156func requireAllowedPad(cur realm) string {
157 requireInit()
158 prev := cur.Previous()
159 // Must be a realm call (pad), not EOA
160 if prev.IsUserCall() {
161 panic("pointsv2: pad realm only")
162 }
163 path := prev.PkgPath()
164 if path == "" || !allowedPads.Has(path) {
165 panic("pointsv2: pad not allowed")
166 }
167 return path
168}
169
170func heightCapKey(addr string, h int64) string {
171 return addr + "|" + strconv.FormatInt(h, 10)
172}
173
174func remainingHeightCap(addr string, h int64) int64 {
175 k := heightCapKey(addr, h)
176 used := int64(0)
177 if v := tradePtsAtHeight.Get(k); v != nil {
178 used, _ = v.(int64)
179 }
180 left := MaxTradePtsPerHeight - used
181 if left < 0 {
182 return 0
183 }
184 return left
185}
186
187func consumeHeightCap(addr string, h, award int64) int64 {
188 if award <= 0 {
189 return 0
190 }
191 left := remainingHeightCap(addr, h)
192 if left <= 0 {
193 return 0
194 }
195 if award > left {
196 award = left
197 }
198 k := heightCapKey(addr, h)
199 used := int64(0)
200 if v := tradePtsAtHeight.Get(k); v != nil {
201 used, _ = v.(int64)
202 }
203 tradePtsAtHeight.Set(k, used+award)
204 return award
205}
206
207// OnTrade awards points for a pad buy (side=0) or sell (side=1).
208// volumeUgnot is the GNOT notional of the trade (sent in / received out).
209// Returns points awarded (0 if capped).
210func OnTrade(cur realm, trader address, launchID string, side int64, volumeUgnot int64) int64 {
211 _ = requireAllowedPad(cur)
212 if !trader.IsValid() {
213 return 0
214 }
215 if volumeUgnot < 0 {
216 volumeUgnot = 0
217 }
218 launchID = strings.TrimSpace(launchID)
219 if launchID == "" {
220 return 0
221 }
222
223 base := PointsBuyBase
224 perG := PointsPerGnotBuy
225 if side == 1 {
226 base = PointsSellBase
227 perG = PointsPerGnotSell
228 } else if side != 0 {
229 // ignore open / unknown
230 return 0
231 }
232
233 gnotFull := volumeUgnot / UgnotPerGnot
234 award := base + gnotFull*perG
235 if award <= 0 {
236 return 0
237 }
238
239 me := trader.String()
240 h := runtime.ChainHeight()
241 award = consumeHeightCap(me, h, award)
242 if award <= 0 {
243 return 0
244 }
245 addPts(me, award)
246 totalTradePts += award
247 chain.Emit("OnTrade",
248 "trader", me,
249 "id", launchID,
250 "side", strconv.FormatInt(side, 10),
251 "vol", strconv.FormatInt(volumeUgnot, 10),
252 "pts", strconv.FormatInt(award, 10),
253 )
254 return award
255}
256
257// OnCreate awards create bonus to the launch creator (pad-only).
258func OnCreate(cur realm, creator address, launchID string) int64 {
259 _ = requireAllowedPad(cur)
260 if !creator.IsValid() {
261 return 0
262 }
263 launchID = strings.TrimSpace(launchID)
264 if launchID == "" {
265 return 0
266 }
267 me := creator.String()
268 h := runtime.ChainHeight()
269 award := consumeHeightCap(me, h, PointsCreateBonus)
270 if award <= 0 {
271 return 0
272 }
273 addPts(me, award)
274 totalCreatePts += award
275 chain.Emit("OnCreate", "creator", me, "id", launchID, "pts", strconv.FormatInt(award, 10))
276 return award
277}
278
279// SetReferrer binds caller to a referrer once. Self-referral rejected.
280func SetReferrer(cur realm, referrer address) {
281 requireInit()
282 if !cur.Previous().IsUserCall() {
283 panic("pointsv2: EOA only")
284 }
285 if !referrer.IsValid() {
286 panic("pointsv2: invalid referrer")
287 }
288 me := cur.Previous().Address()
289 if me == referrer {
290 panic("pointsv2: cannot refer self")
291 }
292 meS := me.String()
293 if referrerOf.Has(meS) {
294 panic("pointsv2: referrer already set")
295 }
296 refS := referrer.String()
297 referrerOf.Set(meS, refS)
298 addPts(refS, PointsReferrerBonus)
299 addPts(meS, PointsRefereeBonus)
300 chain.Emit("SetReferrer", "user", meS, "referrer", refS)
301}
302
303// CheckIn grants PointsCheckIn if enough heights passed since last check-in.
304func CheckIn(cur realm) int64 {
305 requireInit()
306 if !cur.Previous().IsUserCall() {
307 panic("pointsv2: EOA only")
308 }
309 me := cur.Previous().Address().String()
310 h := runtime.ChainHeight()
311 last := int64(0)
312 if v := lastCheckIn.Get(me); v != nil {
313 last, _ = v.(int64)
314 }
315 if last > 0 && h-last < CheckInIntervalH {
316 panic("pointsv2: check-in too soon")
317 }
318 lastCheckIn.Set(me, h)
319 addPts(me, PointsCheckIn)
320 chain.Emit("CheckIn", "user", me, "points", strconv.FormatInt(PointsCheckIn, 10))
321 return getPts(me)
322}
323
324// AwardPoints is admin-only (campaigns / corrections).
325func AwardPoints(cur realm, to address, amount int64) {
326 requireAdmin(cur)
327 if !to.IsValid() {
328 panic("pointsv2: invalid to")
329 }
330 if amount == 0 {
331 panic("pointsv2: amount zero")
332 }
333 addPts(to.String(), amount)
334 chain.Emit("Award", "to", to.String(), "amount", strconv.FormatInt(amount, 10))
335}
336
337func GetPoints(addr string) int64 {
338 requireInit()
339 return getPts(strings.TrimSpace(addr))
340}
341
342func GetReferrer(addr string) string {
343 requireInit()
344 v := referrerOf.Get(strings.TrimSpace(addr))
345 if v == nil {
346 return ""
347 }
348 s, _ := v.(string)
349 return s
350}
351
352func Leaderboard(n int) string {
353 requireInit()
354 if n <= 0 {
355 n = 10
356 }
357 if n > MaxLeaderboardReturn {
358 n = MaxLeaderboardReturn
359 }
360 type row struct {
361 addr string
362 pts int64
363 }
364 rows := make([]row, 0, LeaderboardMax)
365 pointsByAddr.Iterate("", "", func(key string, value any) bool {
366 pts, _ := value.(int64)
367 if pts <= 0 {
368 return false
369 }
370 rows = append(rows, row{addr: key, pts: pts})
371 if len(rows) >= LeaderboardMax {
372 return true
373 }
374 return false
375 })
376 for i := 0; i < len(rows); i++ {
377 best := i
378 for j := i + 1; j < len(rows); j++ {
379 if rows[j].pts > rows[best].pts {
380 best = j
381 }
382 }
383 rows[i], rows[best] = rows[best], rows[i]
384 }
385 if n > len(rows) {
386 n = len(rows)
387 }
388 out := ""
389 for i := 0; i < n; i++ {
390 if out != "" {
391 out += "\n"
392 }
393 out += rows[i].addr + "|" + strconv.FormatInt(rows[i].pts, 10)
394 }
395 return out
396}
397
398func UserCount() int {
399 requireInit()
400 return pointsByAddr.Size()
401}
402
403func PadCount() int {
404 requireInit()
405 return allowedPads.Size()
406}
407
408func TransferAdmin(cur realm, newAdmin address) {
409 requireAdmin(cur)
410 if !newAdmin.IsValid() {
411 panic("pointsv2: invalid address")
412 }
413 old := admin
414 admin = newAdmin
415 chain.Emit("TransferAdmin", "from", old.String(), "to", newAdmin.String())
416}
417
418func Admin() string {
419 requireInit()
420 return admin.String()
421}
422
423// ParamsInfo for UI:
424//
425// referrer|referee|checkIn|interval|createBonus|buyBase|sellBase|ptsPerGnotBuy|ptsPerGnotSell|maxPerHeight|tradePtsTotal|createPtsTotal|v2
426func ParamsInfo() string {
427 return strconv.FormatInt(PointsReferrerBonus, 10) + "|" +
428 strconv.FormatInt(PointsRefereeBonus, 10) + "|" +
429 strconv.FormatInt(PointsCheckIn, 10) + "|" +
430 strconv.FormatInt(CheckInIntervalH, 10) + "|" +
431 strconv.FormatInt(PointsCreateBonus, 10) + "|" +
432 strconv.FormatInt(PointsBuyBase, 10) + "|" +
433 strconv.FormatInt(PointsSellBase, 10) + "|" +
434 strconv.FormatInt(PointsPerGnotBuy, 10) + "|" +
435 strconv.FormatInt(PointsPerGnotSell, 10) + "|" +
436 strconv.FormatInt(MaxTradePtsPerHeight, 10) + "|" +
437 strconv.FormatInt(totalTradePts, 10) + "|" +
438 strconv.FormatInt(totalCreatePts, 10) + "|v2"
439}
440
441func Render(path string) string {
442 path = strings.Trim(path, "/")
443 if !inited {
444 return "# gnomemepad pointsv2\n\n> Not initialized.\n"
445 }
446 out := "# gnomemepad pointsv2\n\n"
447 out += "- Users: **" + strconv.Itoa(pointsByAddr.Size()) + "**\n"
448 out += "- Allowed pads: **" + strconv.Itoa(allowedPads.Size()) + "**\n"
449 out += "- Trade pts issued: **" + strconv.FormatInt(totalTradePts, 10) + "**\n"
450 out += "- Create pts issued: **" + strconv.FormatInt(totalCreatePts, 10) + "**\n\n"
451 out += "## API\n\n- SetReferrer / CheckIn\n- OnTrade / OnCreate (pad allowlist)\n- AllowPad / Leaderboard\n"
452 _ = path
453 return out
454}
455
456func resetForTest() {
457 var zero address
458 admin = zero
459 inited = false
460 pointsByAddr = avl.Tree{}
461 referrerOf = avl.Tree{}
462 lastCheckIn = avl.Tree{}
463 allowedPads = avl.Tree{}
464 tradePtsAtHeight = avl.Tree{}
465 totalTradePts = 0
466 totalCreatePts = 0
467}