Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}