ads2.gno
18.02 Kb · 595 lines
1// GnoMoji — Ads Realm
2// gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/ads2
3//
4// On-chain ad marketplace for GnoMoji on Gno.land (v1.5.0 / Onyx testnet).
5//
6// Design: pure AVL state, no banker imports. Follows the same pattern as
7// game3 and minter3 which are confirmed working on Onyx testnet.
8//
9// GNOT flow:
10// Deposit — frontend calls DepositBid; the realm tracks the credited amount
11// in advertiserBal. The GNOT attached to the MsgCall is held by
12// the realm's address automatically by the VM.
13// Payouts — ClaimRefEarnings records a pending payout in refPending.
14// The treasury server (same wallet that runs mintPending) reads
15// refPending via RefPendingClaim() and sends GNOT server-side,
16// then calls ClearRefClaim() to zero the entry.
17//
18// This avoids the chain/banker import that is unavailable on Onyx testnet.
19
20package ads2
21
22import (
23 "strconv"
24 "strings"
25 "time"
26
27 "gno.land/p/nt/avl/v0"
28 "gno.land/p/nt/ufmt/v0"
29)
30
31// ── State ─────────────────────────────────────────────────────────────────────
32
33var (
34 campaigns avl.Tree // campaignId → compact JSON string
35 advertiserBal avl.Tree // g1addr → ugnot balance as string
36 adViews avl.Tree // campaignId+":"+g1addr+":"+dayStr → "1" (per-day dedup)
37 referrals avl.Tree // advertiserAddr → referrerAddr
38 refEarnings avl.Tree // referrerAddr → ugnot earned as string
39 refPending avl.Tree // referrerAddr → ugnot pending payout as string
40 tasks avl.Tree // taskId → compact JSON string
41 taskClaims avl.Tree // taskId+":"+playerAddr → "pending:proofUrl"|"approved"|"rejected"
42 pendingReview avl.Tree // campaignId → "1"
43 admin address
44 paused bool
45)
46
47func init(cur realm) {
48 admin = cur.Previous().Address()
49}
50
51// ── Admin helpers ─────────────────────────────────────────────────────────────
52
53func isAdmin(cur realm) bool {
54 return cur.Previous().Address() == admin
55}
56
57func requireAdmin(cur realm) {
58 if !isAdmin(cur) {
59 panic("unauthorized: admin only")
60 }
61}
62
63func requireNotPaused() {
64 if paused {
65 panic("realm is paused")
66 }
67}
68
69func SetPaused(cur realm, p bool) {
70 requireAdmin(cur)
71 paused = p
72}
73
74// ── Write: Deposit ────────────────────────────────────────────────────────────
75
76// DepositBid credits the caller's advertiser balance with the declared ugnot
77// amount. The caller must attach matching coins to the MsgCall (the VM holds
78// them in the realm's address). The amount is passed explicitly so the realm
79// can credit it without importing chain/banker.
80//
81// Security: the treasury server MUST verify the on-chain balance before
82// approving any campaign spend — it should call AdvertiserBalance() to
83// cross-check the declared amount matches what was actually deposited.
84func DepositBid(cur realm, ugnotAmount string) {
85 requireNotPaused()
86 caller := cur.Previous().Address().String()
87
88 amt, err := strconv.ParseUint(ugnotAmount, 10, 64)
89 if err != nil || amt == 0 {
90 panic("invalid ugnot amount")
91 }
92
93 cur64 := uint64(0)
94 if advertiserBal.Has(caller) {
95 cur64, _ = strconv.ParseUint(advertiserBal.Get(caller).(string), 10, 64)
96 }
97 advertiserBal.Set(caller, strconv.FormatUint(cur64+amt, 10))
98}
99
100// ── Write: Campaigns ─────────────────────────────────────────────────────────
101
102// SubmitCampaign stores a campaign JSON and queues it for admin review.
103// Returns the generated campaignId.
104// Status is forced to "pending_review" regardless of what the client sends.
105func SubmitCampaign(cur realm, campaignJson string) string {
106 requireNotPaused()
107 caller := cur.Previous().Address().String()
108
109 if strings.TrimSpace(campaignJson) == "" {
110 panic("campaignJson must not be empty")
111 }
112
113 prefix := caller
114 if len(prefix) > 8 {
115 prefix = prefix[:8]
116 }
117 campaignId := "ad-" + prefix + "-" + strconv.FormatInt(time.Now().Unix(), 36)
118
119 campaignJson = injectField(campaignJson, "id", campaignId)
120 campaignJson = setStatus(campaignJson, "pending_review")
121
122 campaigns.Set(campaignId, campaignJson)
123 pendingReview.Set(campaignId, "1")
124 return campaignId
125}
126
127// ReviewCampaign approves or rejects a campaign.
128// Approved campaigns transition to "active" and can receive views.
129func ReviewCampaign(cur realm, campaignId string, decision string) {
130 requireAdmin(cur)
131
132 if decision != "approved" && decision != "rejected" {
133 panic("decision must be 'approved' or 'rejected'")
134 }
135 if !campaigns.Has(campaignId) {
136 panic("campaign not found: " + campaignId)
137 }
138
139 json := campaigns.Get(campaignId).(string)
140 pendingReview.Remove(campaignId)
141
142 newStatus := decision
143 if decision == "approved" {
144 newStatus = "active"
145 }
146 campaigns.Set(campaignId, setStatus(json, newStatus))
147}
148
149// ── Write: Views ──────────────────────────────────────────────────────────────
150
151// RecordView bills the advertiser one bid_per_view for a player watching an ad.
152// Deduplicated per (campaignId, callerAddr, UTC day).
153// Splits 50% of the bid to the referrer if one is registered for the advertiser.
154func RecordView(cur realm, campaignId string) {
155 requireNotPaused()
156 caller := cur.Previous().Address().String()
157
158 if !campaigns.Has(campaignId) {
159 panic("campaign not found: " + campaignId)
160 }
161
162 // Per-day dedup: campaignId:addr:dayNumber
163 dayStr := strconv.FormatInt(time.Now().Unix()/86400, 10)
164 dedupKey := campaignId + ":" + caller + ":" + dayStr
165 if adViews.Has(dedupKey) {
166 return // already recorded today — silent skip
167 }
168
169 json := campaigns.Get(campaignId).(string)
170
171 if extractField(json, "status") != "active" {
172 return
173 }
174
175 bidStr := extractField(json, "bid_per_view")
176 bid, err := strconv.ParseUint(bidStr, 10, 64)
177 if err != nil || bid == 0 {
178 return
179 }
180
181 advertiserAddr := extractField(json, "advertiser")
182 if advertiserAddr == "" {
183 return
184 }
185
186 balStr := "0"
187 if advertiserBal.Has(advertiserAddr) {
188 balStr = advertiserBal.Get(advertiserAddr).(string)
189 }
190 bal, _ := strconv.ParseUint(balStr, 10, 64)
191 if bal < bid {
192 campaigns.Set(campaignId, setStatus(json, "paused"))
193 return
194 }
195
196 // Deduct bid from advertiser balance
197 bal -= bid
198 advertiserBal.Set(advertiserAddr, strconv.FormatUint(bal, 10))
199
200 // Mark view for dedup
201 adViews.Set(dedupKey, "1")
202
203 // Increment total view counter
204 totalKey := campaignId + ":total"
205 totalStr := "0"
206 if adViews.Has(totalKey) {
207 totalStr = adViews.Get(totalKey).(string)
208 }
209 total, _ := strconv.ParseUint(totalStr, 10, 64)
210 adViews.Set(totalKey, strconv.FormatUint(total+1, 10))
211
212 // Referral split: 50% of bid goes to referrer earnings
213 if referrals.Has(advertiserAddr) {
214 referrer := referrals.Get(advertiserAddr).(string)
215 half := bid / 2
216 if half > 0 {
217 refBal := uint64(0)
218 if refEarnings.Has(referrer) {
219 refBal, _ = strconv.ParseUint(refEarnings.Get(referrer).(string), 10, 64)
220 }
221 refEarnings.Set(referrer, strconv.FormatUint(refBal+half, 10))
222 }
223 }
224
225 // Auto-pause if balance is now below one bid
226 if bal < bid {
227 campaigns.Set(campaignId, setStatus(json, "paused"))
228 }
229}
230
231// ── Write: Referrals ──────────────────────────────────────────────────────────
232
233// RegisterReferral permanently links the calling advertiser to a referrer.
234// Each advertiser may register exactly one referrer; cannot be changed.
235func RegisterReferral(cur realm, referrerAddr string) {
236 requireNotPaused()
237 caller := cur.Previous().Address().String()
238
239 if referrals.Has(caller) {
240 panic("referral already registered for this advertiser")
241 }
242 if referrerAddr == caller {
243 panic("cannot refer yourself")
244 }
245 if !strings.HasPrefix(referrerAddr, "g1") {
246 panic("invalid referrer address: must start with g1")
247 }
248 referrals.Set(caller, referrerAddr)
249}
250
251// ClaimRefEarnings moves the caller's accumulated referral earnings into a
252// pending payout queue. The treasury server reads RefPendingClaim(), sends
253// GNOT from the treasury wallet, then calls ClearRefClaim() to zero it.
254func ClaimRefEarnings(cur realm) {
255 requireNotPaused()
256 caller := cur.Previous().Address().String()
257
258 earnedStr := "0"
259 if refEarnings.Has(caller) {
260 earnedStr = refEarnings.Get(caller).(string)
261 }
262 earned, _ := strconv.ParseUint(earnedStr, 10, 64)
263 if earned == 0 {
264 panic("nothing to claim")
265 }
266
267 // Add to pending payout (may accumulate across multiple claims)
268 pending := uint64(0)
269 if refPending.Has(caller) {
270 pending, _ = strconv.ParseUint(refPending.Get(caller).(string), 10, 64)
271 }
272 refPending.Set(caller, strconv.FormatUint(pending+earned, 10))
273
274 // Zero out earned balance
275 refEarnings.Set(caller, "0")
276}
277
278// ClearRefClaim is called by the admin/treasury after disbursing GNOT to a
279// referrer. Zeroes the pending payout entry.
280func ClearRefClaim(cur realm, referrerAddr string) {
281 requireAdmin(cur)
282 refPending.Set(referrerAddr, "0")
283}
284
285// ── Write: Tasks ─────────────────────────────────────────────────────────────
286
287// SubmitTaskDef creates a new paid social task. Returns the taskId.
288func SubmitTaskDef(cur realm, taskJson string) string {
289 requireNotPaused()
290 caller := cur.Previous().Address().String()
291
292 if strings.TrimSpace(taskJson) == "" {
293 panic("taskJson must not be empty")
294 }
295
296 prefix := caller
297 if len(prefix) > 8 {
298 prefix = prefix[:8]
299 }
300 taskId := "task-" + prefix + "-" + strconv.FormatInt(time.Now().Unix(), 36)
301 taskJson = injectField(taskJson, "id", taskId)
302 tasks.Set(taskId, taskJson)
303 return taskId
304}
305
306// SubmitTask records a player's proof-of-completion claim on a paid task.
307func SubmitTask(cur realm, taskId string, proofUrl string) {
308 requireNotPaused()
309 caller := cur.Previous().Address().String()
310
311 if !tasks.Has(taskId) {
312 panic("task not found: " + taskId)
313 }
314 claimKey := taskId + ":" + caller
315 if taskClaims.Has(claimKey) {
316 panic("already submitted a claim for this task")
317 }
318 taskClaims.Set(claimKey, "pending:"+proofUrl)
319}
320
321// ApproveTask marks a task claim as approved. Only the task's advertiser or
322// the admin may approve. Coin reward is handled server-side via the minter.
323func ApproveTask(cur realm, taskId string, claimerAddr string) {
324 requireNotPaused()
325 caller := cur.Previous().Address().String()
326
327 if !tasks.Has(taskId) {
328 panic("task not found: " + taskId)
329 }
330
331 json := tasks.Get(taskId).(string)
332 advertiserAddr := extractField(json, "advertiser")
333
334 if caller != advertiserAddr && !isAdmin(cur) {
335 panic("unauthorized: only task advertiser or admin can approve")
336 }
337
338 claimKey := taskId + ":" + claimerAddr
339 if !taskClaims.Has(claimKey) {
340 panic("claim not found")
341 }
342 taskClaims.Set(claimKey, "approved")
343}
344
345// ── Read functions (pure queries) ─────────────────────────────────────────────
346
347// GetCampaign returns the JSON string for a single campaign, or "null".
348func GetCampaign(campaignId string) string {
349 if !campaigns.Has(campaignId) {
350 return "null"
351 }
352 return campaigns.Get(campaignId).(string)
353}
354
355// ListCampaigns returns a JSON array of campaigns submitted by advertiserAddr.
356func ListCampaigns(advertiserAddr string) string {
357 var parts []string
358 campaigns.Iterate("", "\xFF", func(key string, val any) bool {
359 json := val.(string)
360 if strings.Contains(json, `"advertiser":"`+advertiserAddr+`"`) {
361 parts = append(parts, json)
362 }
363 return false
364 })
365 return "[" + strings.Join(parts, ",") + "]"
366}
367
368// SelectAd returns the JSON of the winning campaign for the given viewer level,
369// chosen by weighted auction (higher bid_per_view = proportionally more weight).
370// Returns "null" if no eligible campaign exists.
371//
372// NOTE: uses time.Now().UnixNano() % totalWeight as deterministic pseudo-random.
373// Not cryptographic — sufficient for ad placement.
374func SelectAd(viewerLevel int) string {
375 type candidate struct {
376 json string
377 bid uint64
378 }
379
380 var pool []candidate
381 totalWeight := uint64(0)
382
383 campaigns.Iterate("", "\xFF", func(key string, val any) bool {
384 json := val.(string)
385
386 if extractField(json, "status") != "active" {
387 return false
388 }
389
390 bidStr := extractField(json, "bid_per_view")
391 bid, err := strconv.ParseUint(bidStr, 10, 64)
392 if err != nil || bid == 0 {
393 return false
394 }
395
396 advertiserAddr := extractField(json, "advertiser")
397 balStr := "0"
398 if advertiserBal.Has(advertiserAddr) {
399 balStr = advertiserBal.Get(advertiserAddr).(string)
400 }
401 bal, _ := strconv.ParseUint(balStr, 10, 64)
402 if bal < bid {
403 return false
404 }
405
406 minLvlStr := extractField(json, "target_min_level")
407 maxLvlStr := extractField(json, "target_max_level")
408 if minLvlStr != "" {
409 minLvl, _ := strconv.Atoi(minLvlStr)
410 if viewerLevel < minLvl {
411 return false
412 }
413 }
414 if maxLvlStr != "" {
415 maxLvl, _ := strconv.Atoi(maxLvlStr)
416 if maxLvl > 0 && viewerLevel > maxLvl {
417 return false
418 }
419 }
420
421 pool = append(pool, candidate{json, bid})
422 totalWeight += bid
423 return false
424 })
425
426 if len(pool) == 0 || totalWeight == 0 {
427 return "null"
428 }
429
430 pick := uint64(time.Now().UnixNano()) % totalWeight
431 running := uint64(0)
432 for _, c := range pool {
433 running += c.bid
434 if pick < running {
435 return c.json
436 }
437 }
438 return pool[len(pool)-1].json
439}
440
441// AdvertiserBalance returns the ugnot balance for an advertiser address.
442func AdvertiserBalance(addr string) string {
443 if !advertiserBal.Has(addr) {
444 return "0"
445 }
446 return advertiserBal.Get(addr).(string)
447}
448
449// PendingReviews returns a JSON array of campaigns awaiting admin review.
450func PendingReviews() string {
451 var parts []string
452 pendingReview.Iterate("", "\xFF", func(key string, val any) bool {
453 if campaigns.Has(key) {
454 parts = append(parts, campaigns.Get(key).(string))
455 }
456 return false
457 })
458 return "[" + strings.Join(parts, ",") + "]"
459}
460
461// GetTask returns the JSON for a single task, or "null".
462func GetTask(taskId string) string {
463 if !tasks.Has(taskId) {
464 return "null"
465 }
466 return tasks.Get(taskId).(string)
467}
468
469// ListTasks returns a JSON array of tasks submitted by advertiserAddr.
470func ListTasks(advertiserAddr string) string {
471 var parts []string
472 tasks.Iterate("", "\xFF", func(key string, val any) bool {
473 json := val.(string)
474 if strings.Contains(json, `"advertiser":"`+advertiserAddr+`"`) {
475 parts = append(parts, json)
476 }
477 return false
478 })
479 return "[" + strings.Join(parts, ",") + "]"
480}
481
482// RefEarnings returns the claimable referral earnings for an address.
483func RefEarnings(addr string) string {
484 if !refEarnings.Has(addr) {
485 return "0"
486 }
487 return refEarnings.Get(addr).(string)
488}
489
490// RefPendingClaim returns the pending payout amount for a referrer address.
491// The treasury server calls this before sending GNOT.
492func RefPendingClaim(addr string) string {
493 if !refPending.Has(addr) {
494 return "0"
495 }
496 return refPending.Get(addr).(string)
497}
498
499// TotalViews returns the total recorded view count for a campaign.
500func TotalViews(campaignId string) string {
501 totalKey := campaignId + ":total"
502 if !adViews.Has(totalKey) {
503 return "0"
504 }
505 return adViews.Get(totalKey).(string)
506}
507
508// GetTaskClaim returns the claim status string for a taskId+claimerAddr pair.
509func GetTaskClaim(taskId string, claimerAddr string) string {
510 key := taskId + ":" + claimerAddr
511 if !taskClaims.Has(key) {
512 return "null"
513 }
514 return taskClaims.Get(key).(string)
515}
516
517// ── Render ────────────────────────────────────────────────────────────────────
518
519func Render(_ string) string {
520 return ufmt.Sprintf(
521 "# GnoMoji Ads Realm\n\n"+
522 "Admin: `%s`\n\n"+
523 "Path: `gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/ads2`\n\n"+
524 "On-chain ad marketplace for GnoMoji. Advertisers deposit GNOT, "+
525 "campaigns are reviewed before going live, players earn referral rewards.\n",
526 admin.String(),
527 )
528}
529
530// ── JSON helpers ──────────────────────────────────────────────────────────────
531
532// extractField pulls the value of a simple string or numeric JSON field.
533// Returns "" if the field is absent.
534func extractField(json string, field string) string {
535 needle := `"` + field + `":`
536 idx := strings.Index(json, needle)
537 if idx < 0 {
538 return ""
539 }
540 rest := strings.TrimSpace(json[idx+len(needle):])
541 if len(rest) == 0 {
542 return ""
543 }
544 if rest[0] == '"' {
545 rest = rest[1:]
546 end := strings.Index(rest, `"`)
547 if end < 0 {
548 return rest
549 }
550 return rest[:end]
551 }
552 // Numeric / boolean: read until delimiter
553 end := strings.IndexAny(rest, ",}")
554 if end < 0 {
555 return strings.TrimSpace(rest)
556 }
557 return strings.TrimSpace(rest[:end])
558}
559
560// setStatus replaces or appends the "status" field in a JSON object.
561func setStatus(json string, status string) string {
562 needle := `"status":"`
563 idx := strings.Index(json, needle)
564 if idx >= 0 {
565 start := idx + len(needle)
566 end := strings.Index(json[start:], `"`)
567 if end >= 0 {
568 return json[:start] + status + json[start+end:]
569 }
570 }
571 closeIdx := strings.LastIndex(json, "}")
572 if closeIdx < 0 {
573 return json
574 }
575 return json[:closeIdx] + `,"status":"` + status + `"}` + json[closeIdx+1:]
576}
577
578// injectField sets a string field in a JSON object, replacing an existing
579// value or appending before the closing brace.
580func injectField(json string, field string, value string) string {
581 needle := `"` + field + `":"`
582 idx := strings.Index(json, needle)
583 if idx >= 0 {
584 start := idx + len(needle)
585 end := strings.Index(json[start:], `"`)
586 if end >= 0 {
587 return json[:start] + value + json[start+end:]
588 }
589 }
590 closeIdx := strings.LastIndex(json, "}")
591 if closeIdx < 0 {
592 return json
593 }
594 return json[:closeIdx] + `,"` + field + `":"` + value + `"}` + json[closeIdx+1:]
595}