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