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

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}