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

tipjar.gno

6.06 Kb · 237 lines
  1// Package tipjar is a public tip jar. Anyone can send real ugnot with an
  2// optional message via Tip; the realm keeps a running leaderboard of the
  3// most generous tippers and a feed of the most recent tips, both shown in
  4// Render. Only the deployer (captured as owner at init time) can Withdraw
  5// the accumulated balance. State-mutating functions are crossing functions
  6// (`cur realm`) per the gno 0.9 interrealm convention.
  7package tipjar
  8
  9import (
 10	"sort"
 11	"strconv"
 12
 13	"chain"
 14	"chain/banker"
 15	"chain/runtime"
 16	"chain/runtime/unsafe"
 17
 18	"gno.land/p/nt/avl/v0"
 19)
 20
 21const (
 22	denom         = "ugnot"
 23	maxRecent     = 10
 24	maxMessageLen = 140
 25)
 26
 27// tipperStats is the persisted, accumulated record for one tipper.
 28type tipperStats struct {
 29	addr        address
 30	total       int64
 31	count       int
 32	lastMessage string
 33	lastHeight  int64
 34}
 35
 36// tipEntry is one line in the recent-tips feed.
 37type tipEntry struct {
 38	from    address
 39	amount  int64
 40	message string
 41	height  int64
 42}
 43
 44var (
 45	owner address // captured deployer, set once in init
 46
 47	tippers avl.Tree // address string -> *tipperStats
 48	recent  []tipEntry
 49
 50	totalReceived int64
 51	withdrawn     int64
 52	tipCount      int
 53)
 54
 55func init() {
 56	owner = unsafe.OriginCaller()
 57}
 58
 59// get returns the stored *tipperStats for addr, creating one if absent.
 60func get(addr address) *tipperStats {
 61	key := addr.String()
 62	if v := tippers.Get(key); v != nil {
 63		return v.(*tipperStats)
 64	}
 65	ts := &tipperStats{addr: addr}
 66	tippers.Set(key, ts)
 67	return ts
 68}
 69
 70// Tip credits the caller's OriginSend ugnot to the jar and records it
 71// against their running total. Only an EOA calling directly via MsgCall may
 72// tip — see the payment-guard note on IsUserCall vs IsUser.
 73func Tip(cur realm, message string) string {
 74	if !cur.IsCurrent() {
 75		panic("spoofed realm")
 76	}
 77	prev := cur.Previous()
 78	if !prev.IsUserCall() {
 79		panic("only an EOA via MsgCall can tip")
 80	}
 81	if len(message) > maxMessageLen {
 82		panic("message too long (max " + strconv.Itoa(maxMessageLen) + " chars)")
 83	}
 84
 85	amount := unsafe.OriginSend().AmountOf(denom)
 86	if amount <= 0 {
 87		panic("send some ugnot to tip")
 88	}
 89
 90	from := prev.Address()
 91	height := runtime.ChainHeight()
 92
 93	ts := get(from)
 94	ts.total += amount
 95	ts.count++
 96	ts.lastMessage = message
 97	ts.lastHeight = height
 98
 99	totalReceived += amount
100	tipCount++
101
102	recent = append(recent, tipEntry{from: from, amount: amount, message: message, height: height})
103	if len(recent) > maxRecent {
104		recent = recent[len(recent)-maxRecent:]
105	}
106
107	chain.Emit("Tip", "from", from.String(), "amount", strconv.FormatInt(amount, 10), "message", message)
108	return "thanks for the " + strconv.FormatInt(amount, 10) + "ugnot tip!"
109}
110
111// Balance reports the ugnot still held by the jar (received minus withdrawn).
112func Balance() int64 {
113	return totalReceived - withdrawn
114}
115
116// Withdraw sends amount ugnot from the jar to the owner. Owner-only.
117func Withdraw(cur realm, amount int64) {
118	if !cur.IsCurrent() {
119		panic("spoofed realm")
120	}
121	if cur.Previous().Address() != owner {
122		panic("only the owner can withdraw")
123	}
124	if amount <= 0 {
125		panic("amount must be positive")
126	}
127	if amount > Balance() {
128		panic("amount exceeds available balance")
129	}
130
131	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
132	bnk.SendCoins(cur.Address(), owner, chain.NewCoins(chain.NewCoin(denom, amount)))
133	withdrawn += amount
134
135	chain.Emit("Withdraw", "amount", strconv.FormatInt(amount, 10))
136}
137
138// byTotal implements sort.Interface, ranking tippers by total tipped
139// descending, ties broken by tip count then address for a fully
140// deterministic order.
141type byTotal struct {
142	stats []*tipperStats
143}
144
145func (b byTotal) Len() int      { return len(b.stats) }
146func (b byTotal) Swap(i, j int) { b.stats[i], b.stats[j] = b.stats[j], b.stats[i] }
147func (b byTotal) Less(i, j int) bool {
148	if b.stats[i].total != b.stats[j].total {
149		return b.stats[i].total > b.stats[j].total
150	}
151	if b.stats[i].count != b.stats[j].count {
152		return b.stats[i].count > b.stats[j].count
153	}
154	return b.stats[i].addr.String() < b.stats[j].addr.String()
155}
156
157// leaderboard collects all tippers ranked by total tipped descending.
158func leaderboard() []*tipperStats {
159	stats := make([]*tipperStats, 0, tippers.Size())
160	tippers.Iterate("", "", func(_ string, v any) bool {
161		stats = append(stats, v.(*tipperStats))
162		return false
163	})
164	sort.Stable(byTotal{stats: stats})
165	return stats
166}
167
168// medal returns the emoji for a given zero-based rank, or "" past the podium.
169func medal(rank int) string {
170	switch rank {
171	case 0:
172		return "🥇"
173	case 1:
174		return "🥈"
175	case 2:
176		return "🥉"
177	default:
178		return ""
179	}
180}
181
182// display returns a shortened address for table display.
183func display(addr address) string {
184	s := addr.String()
185	if len(s) > 12 {
186		return s[:8] + "…" + s[len(s)-4:]
187	}
188	return s
189}
190
191// Render shows the jar's balance, the tipper leaderboard, and a feed of the
192// most recent tips.
193func Render(path string) string {
194	out := "# 🫙 Tip Jar\n\n"
195	out += "Send ugnot with `Tip(message)` to leave a tip and a note. " +
196		"The owner can withdraw the accumulated balance with `Withdraw(amount)`.\n\n"
197
198	out += "**Balance:** " + strconv.FormatInt(Balance(), 10) + "ugnot" +
199		"  ·  **Total tips:** " + strconv.Itoa(tipCount) +
200		"  ·  **All-time received:** " + strconv.FormatInt(totalReceived, 10) + "ugnot\n\n"
201
202	stats := leaderboard()
203	out += "## Leaderboard\n\n"
204	if len(stats) == 0 {
205		out += "_No tips yet. Be the first!_\n\n"
206	} else {
207		out += "| Rank | Tipper | Total tipped | Tips |\n"
208		out += "| ---: | :--- | ---: | ---: |\n"
209		for i, ts := range stats {
210			rankCell := medal(i)
211			if rankCell == "" {
212				rankCell = strconv.Itoa(i + 1)
213			}
214			out += "| " + rankCell +
215				" | " + display(ts.addr) +
216				" | " + strconv.FormatInt(ts.total, 10) + "ugnot" +
217				" | " + strconv.Itoa(ts.count) + " |\n"
218		}
219		out += "\n"
220	}
221
222	out += "## Recent tips\n\n"
223	if len(recent) == 0 {
224		out += "_Nothing yet._\n"
225		return out
226	}
227	for i := len(recent) - 1; i >= 0; i-- {
228		e := recent[i]
229		out += "- **" + display(e.from) + "** tipped " + strconv.FormatInt(e.amount, 10) +
230			"ugnot at block " + strconv.FormatInt(e.height, 10)
231		if e.message != "" {
232			out += ": _" + e.message + "_"
233		}
234		out += "\n"
235	}
236	return out
237}