state.gno
9.36 Kb · 444 lines
1// Package arena is a SocialFi realm: profiles, posts, follows, GNOT tips,
2// and creator tickets priced on a quadratic bonding curve.
3//
4// Ticket collateral sits in this realm as totalReserve. Trade fees are
5// forwarded out of that collateral, and Sweep only sends coins above it.
6package arena
7
8import (
9 "chain"
10 "chain/banker"
11 "chain/runtime/unsafe"
12 "strconv"
13 "strings"
14 "unicode/utf8"
15
16 "gno.land/p/nt/avl/v0"
17)
18
19const (
20 // CreatorFeeBps is the creator's cut of each ticket trade.
21 CreatorFeeBps int64 = 500
22 // ProtocolFeeBps is forwarded to the treasury on each ticket trade.
23 ProtocolFeeBps int64 = 500
24 // ReferralFeeBps is paid to the creator's referrer when they have one.
25 ReferralFeeBps int64 = 100
26 // TicketUnit is the ugnot price of ticket number 1. Ticket i costs i² units.
27 TicketUnit int64 = 10_000
28 // MaxSupply is the most tickets one creator can have outstanding.
29 MaxSupply = 2000
30 // MaxTrade is the most tickets one buy or sell can move.
31 MaxTrade = 20
32 // MaxBodyRunes is the longest post, counted in runes.
33 MaxBodyRunes = 280
34 // ProtocolVersion is the immutable revision of this package.
35 // A later package is announced with SetSuccessor. It does not hold this reserve.
36 ProtocolVersion = "1"
37
38 maxHandle = 16
39 minHandle = 3
40 maxNameRunes = 32
41 maxNameBytes = 128
42 maxBioRunes = 160
43 maxBioBytes = 640
44 maxBodyBytes = 1120
45)
46
47// Profile is a copy of a member's public record.
48type Profile struct {
49 Addr address
50 Handle string
51 Name string
52 Bio string
53 Referrer address
54 Joined int64
55 Posts int
56 Followers int
57 Following int
58 TipsIn int64
59 TipsOut int64
60}
61
62// Article is a copy of one post. Gated bodies are still stored on chain;
63// Render withholds them, and GatedBody checks the caller.
64type Article struct {
65 ID int
66 Author address
67 Body string
68 Height int64
69 ReplyTo int
70 Likes int
71 Replies int
72 Tips int64
73 Gated bool
74 Deleted bool
75}
76
77type book struct {
78 Supply int
79 Reserve int64
80 Holders int
81}
82
83// Trees stay unexported. avl.Iterate takes a callback; handing out the
84// tree would let another realm run that callback under this realm.
85type store struct {
86 admin address
87 treasury address
88 paused bool
89 nextID int
90 totalReserve int64
91 byAddr *avl.Tree
92 byHandle *avl.Tree
93 posts *avl.Tree
94 feed *avl.Tree
95 replies *avl.Tree
96 byAuthor *avl.Tree
97 follows *avl.Tree
98 fans *avl.Tree
99 likes *avl.Tree
100 books *avl.Tree
101 shares *avl.Tree
102 successor string
103}
104
105var state *store
106
107func init() {
108 if CreatorFeeBps < 0 || ProtocolFeeBps < 0 || ReferralFeeBps < 0 {
109 panic("fees")
110 }
111 if CreatorFeeBps+ProtocolFeeBps+ReferralFeeBps >= 10_000 {
112 panic("fees")
113 }
114 state = newStore()
115 deployer := unsafe.OriginCaller()
116 if deployer.IsValid() {
117 state.admin = deployer
118 state.treasury = deployer
119 }
120}
121
122func newStore() *store {
123 return &store{
124 nextID: 1,
125 byAddr: avl.NewTree(),
126 byHandle: avl.NewTree(),
127 posts: avl.NewTree(),
128 feed: avl.NewTree(),
129 replies: avl.NewTree(),
130 byAuthor: avl.NewTree(),
131 follows: avl.NewTree(),
132 fans: avl.NewTree(),
133 likes: avl.NewTree(),
134 books: avl.NewTree(),
135 shares: avl.NewTree(),
136 }
137}
138
139// Admin is the address allowed to pause, sweep surplus, and rotate treasury.
140func Admin() address { return state.admin }
141
142// Treasury receives protocol fees and swept surplus.
143func Treasury() address { return state.treasury }
144
145// Version is this package revision. It has no setter.
146func Version() string { return ProtocolVersion }
147
148// Successor is the package path of a later realm, or empty.
149// The later realm does not receive this realm's ticket reserve.
150func Successor() string { return state.successor }
151
152// Paused reports whether new posts, replies, tips, and ticket buys are frozen.
153// Sells stay open so ticket collateral can still exit.
154func Paused() bool { return state.paused }
155
156// TotalReserve is the ugnot locked to back outstanding tickets.
157func TotalReserve() int64 { return state.totalReserve }
158
159func caller(cur realm) address {
160 if !cur.Previous().IsUserCall() {
161 panic("direct user call only")
162 }
163 return cur.Previous().Address()
164}
165
166func mustNoPay() {
167 if len(unsafe.OriginSend()) != 0 {
168 panic("this call takes no payment")
169 }
170}
171
172func takeUgnot(cur realm) int64 {
173 if !cur.Previous().IsUserCall() {
174 panic("direct user call only")
175 }
176 coins := unsafe.OriginSend()
177 if len(coins) != 1 || coins[0].Denom != "ugnot" || coins[0].Amount <= 0 {
178 panic("send ugnot only")
179 }
180 return coins[0].Amount
181}
182
183func payout(cur realm, to address, amt int64, why string) {
184 if amt == 0 {
185 return
186 }
187 if amt < 0 || !to.IsValid() {
188 panic("bad recipient: " + why)
189 }
190 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
191 bnk.SendCoins(cur.Address(), to, chain.Coins{chain.NewCoin("ugnot", amt)})
192}
193
194func mustAdmin(cur realm) {
195 who := caller(cur)
196 mustNoPay()
197 if who != state.admin {
198 panic("admin only")
199 }
200}
201
202func pad(n int) string {
203 if n < 0 {
204 panic("id")
205 }
206 s := strconv.Itoa(n)
207 for len(s) < 10 {
208 s = "0" + s
209 }
210 return s
211}
212
213func validHandle(s string) bool {
214 if len(s) < minHandle || len(s) > maxHandle {
215 return false
216 }
217 alnum := false
218 for i := 0; i < len(s); i++ {
219 c := s[i]
220 switch {
221 case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
222 alnum = true
223 case c == '_':
224 default:
225 return false
226 }
227 }
228 if !alnum || reserved(s) {
229 return false
230 }
231 return true
232}
233
234func reserved(h string) bool {
235 switch h {
236 case "about", "people", "hot", "feed", "following", "admin", "arena", "u", "p", "t":
237 return true
238 default:
239 return false
240 }
241}
242
243func cleanLine(s string, maxRunes, maxBytes int, label string, allowEmpty bool) string {
244 s = strings.TrimSpace(s)
245 if s == "" {
246 if allowEmpty {
247 return ""
248 }
249 panic(label + " length")
250 }
251 if utf8.RuneCountInString(s) > maxRunes || len(s) > maxBytes {
252 panic(label + " length")
253 }
254 for i := 0; i < len(s); i++ {
255 c := s[i]
256 if c < 0x20 || c == 0x7f || c == '<' {
257 panic(label + " has markup")
258 }
259 }
260 return s
261}
262
263func cleanBody(s string) string {
264 s = strings.ReplaceAll(s, "\r\n", "\n")
265 s = strings.ReplaceAll(s, "\r", "\n")
266 s = strings.TrimSpace(s)
267 if s == "" || utf8.RuneCountInString(s) > MaxBodyRunes || len(s) > maxBodyBytes {
268 panic("body length")
269 }
270 for i := 0; i < len(s); i++ {
271 c := s[i]
272 if c == '\n' || c == '\t' {
273 continue
274 }
275 if c < 0x20 || c == 0x7f || c == '<' {
276 panic("body has markup")
277 }
278 }
279 return s
280}
281
282func isYes(s string) bool {
283 switch strings.ToLower(strings.TrimSpace(s)) {
284 case "", "0", "no", "false", "n":
285 return false
286 case "1", "yes", "true", "y", "khoa":
287 return true
288 default:
289 panic("gated must be yes or no")
290 }
291}
292
293func profileByAddr(a address) *Profile {
294 v := state.byAddr.Get(a.String())
295 if v == nil {
296 return nil
297 }
298 p, ok := v.(*Profile)
299 if !ok {
300 panic("profile type")
301 }
302 return p
303}
304
305func lookup(handle string) *Profile {
306 h := strings.ToLower(strings.TrimSpace(handle))
307 if !validHandle(h) {
308 return nil
309 }
310 v := state.byHandle.Get(h)
311 if v == nil {
312 return nil
313 }
314 p, ok := v.(*Profile)
315 if !ok {
316 panic("profile type")
317 }
318 return p
319}
320
321func mustMember(a address) *Profile {
322 p := profileByAddr(a)
323 if p == nil {
324 panic("register first")
325 }
326 return p
327}
328
329func bookOf(creator address) *book {
330 v := state.books.Get(creator.String())
331 if v == nil {
332 panic("no book")
333 }
334 b, ok := v.(*book)
335 if !ok {
336 panic("book type")
337 }
338 return b
339}
340
341func postByID(id int) *Article {
342 if id <= 0 {
343 panic("id")
344 }
345 v := state.posts.Get(pad(id))
346 if v == nil {
347 return nil
348 }
349 p, ok := v.(*Article)
350 if !ok {
351 panic("post type")
352 }
353 return p
354}
355
356func shareKey(holder, creator address) string {
357 return holder.String() + "/" + creator.String()
358}
359
360func getShares(holder, creator address) int {
361 v := state.shares.Get(shareKey(holder, creator))
362 if v == nil {
363 return 0
364 }
365 n, ok := v.(int)
366 if !ok {
367 panic("share type")
368 }
369 return n
370}
371
372func setShares(holder, creator address, n int) {
373 k := shareKey(holder, creator)
374 if n == 0 {
375 state.shares.Remove(k)
376 return
377 }
378 state.shares.Set(k, n)
379}
380
381func authorKey(a address, id int) string {
382 return a.String() + "/" + pad(id)
383}
384
385func followKey(from, to address) string {
386 return from.String() + ">" + to.String()
387}
388
389func fanKey(creator, fan address) string {
390 return creator.String() + "<" + fan.String()
391}
392
393func likeKey(id int, who address) string {
394 return pad(id) + "/" + who.String()
395}
396
397// ProfileByHandle returns a copy of the profile, or false when the handle is unknown.
398func ProfileByHandle(handle string) (Profile, bool) {
399 p := lookup(handle)
400 if p == nil {
401 return Profile{}, false
402 }
403 return *p, true
404}
405
406// ProfileOf returns a copy of the profile bound to addr.
407func ProfileOf(addr address) (Profile, bool) {
408 p := profileByAddr(addr)
409 if p == nil {
410 return Profile{}, false
411 }
412 return *p, true
413}
414
415// GetPost returns a copy of the post. The copy does not alias storage.
416func GetPost(id int) (Article, bool) {
417 if id <= 0 {
418 return Article{}, false
419 }
420 p := postByID(id)
421 if p == nil {
422 return Article{}, false
423 }
424 return *p, true
425}
426
427// SharesOf returns how many tickets holder has of the creator's handle.
428func SharesOf(holder address, handle string) int {
429 p := lookup(handle)
430 if p == nil {
431 panic("unknown handle")
432 }
433 return getShares(holder, p.Addr)
434}
435
436// SupplyOf returns tickets outstanding, ugnot locked in that curve, and holders.
437func SupplyOf(handle string) (supply int, reserve int64, holders int) {
438 p := lookup(handle)
439 if p == nil {
440 panic("unknown handle")
441 }
442 b := bookOf(p.Addr)
443 return b.Supply, b.Reserve, b.Holders
444}