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}