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