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

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}