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

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}