Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}