Transaction
A9152DAC60B40A…2A7B2194B91F
Block 796,827 · index 0 · indexed
Summary
- Hash
- A9152DAC60B40AF17DD882B7C25F1BB34B700C2B905A74AA919A2A7B2194B91F
- Block
- 796,827
- Size
- 57358 bytes
- Gas used
- 74,958,021 / 120,000,000
- Fee
- 126000 ugnot
- Status
- success
Messages
- Attached funds
- 30000000 ugnot
Arguments · 23
- #1gnocial
- #2admin.gno
- #3package gnocial import ( "chain" "chain/banker" "chain/runtime/unsafe" "strconv" "strings" ) // SetPaused freezes posts, replies, tips, and ticket buys when on is true/1/yes. // Pass false/0/no to reopen. Ticket sells are never paused. func SetPaused(cur realm, on string) { mustAdmin(cur) switch on { case "true", "1", "yes": state.paused = true case "false", "0", "no": state.paused = false default: panic("paused must be true or false") } chain.Emit("ArenaPause", "paused", on, "admin", state.admin.String()) } // SetTreasury moves future protocol fees and surplus sweeps to next. func SetTreasury(cur realm, next address) { mustAdmin(cur) if !next.IsValid() { panic("bad treasury") } state.treasury = next chain.Emit("ArenaTreasury", "treasury", next.String()) } // TransferAdmin rotates the pause/sweep key. func TransferAdmin(cur realm, next address) { mustAdmin(cur) if !next.IsValid() { panic("bad admin") } state.admin = next chain.Emit("ArenaAdmin", "admin", next.String()) } // SetSuccessor records the package path of a later realm. // An empty path clears it. This does not move coins or shares. func SetSuccessor(cur realm, next string) { mustAdmin(cur) next = strings.TrimSpace(next) if next != "" { if !validSuccessor(next) || next == unsafe.CurrentRealm().PkgPath() { panic("bad successor") } } state.successor = next chain.Emit("ArenaSuccessor", "path", next) } func validSuccessor(path string) bool { const prefix = "gno.land/r/" if !strings.HasPrefix(path, prefix) || len(path) > 128 { return false } rest := path[len(prefix):] if rest == "" || strings.HasSuffix(rest, "/") || strings.Contains(rest, "//") || strings.Contains(rest, ".") { return false } for i := 0; i < len(rest); i++ { c := rest[i] switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '/', c == '_': default: return false } } return true } // Sweep sends this realm's ugnot above TotalReserve to the treasury. // confirm must be the word "sweep". Ticket collateral is not included. func Sweep(cur realm, confirm string) { mustAdmin(cur) if confirm != "sweep" { panic("confirm sweep") } if !state.treasury.IsValid() { panic("treasury not set") } bal := banker.NewReadonlyBanker().GetCoins(cur.Address()).AmountOf("ugnot") if bal < state.totalReserve { panic("insolvent") } surplus := bal - state.totalReserve payout(cur, state.treasury, surplus, "treasury") chain.Emit("ArenaSweep", "ugnot", strconv.FormatInt(surplus, 10)) }
- #4arena_test.gno
- #5package gnocial import ( "chain" "chain/banker" "strconv" "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) func resetArena() { state = newStore() state.admin = testutils.TestAddress("admin") state.treasury = testutils.TestAddress("treasury") } func ugnots(n int64) chain.Coins { if n == 0 { return nil } return chain.Coins{chain.NewCoin("ugnot", n)} } func bal(a address) int64 { return banker.NewReadonlyBanker().GetCoins(a).AmountOf("ugnot") } func mustAbort(t *testing.T, rlm realm, msg string, fn func()) { if !uassert.AbortsWithMessage(t, rlm, msg, fn) { t.FailNow() } } func TestCurveMath(t *testing.T) { urequire.Equal(t, int64(1), sumSq(1)) urequire.Equal(t, int64(5), sumSq(2)) urequire.Equal(t, int64(14), sumSq(3)) var want int64 for i := int64(1); i <= 40; i++ { want += i * i urequire.Equal(t, want, sumSq(i)) } urequire.Equal(t, int64(10_000), curveCost(0, 1)) urequire.Equal(t, int64(50_000), curveCost(0, 2)) urequire.Equal(t, int64(40_000), curveCost(1, 1)) urequire.Equal(t, int64(140_000), curveCost(0, 3)) } func TestSocial(cur realm, t *testing.T) { resetArena() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) mustAbort(t, cur, "bad handle", func() { Register(cross(cur), "ab", "", "", "") }) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) mustAbort(t, cur, "bad handle", func() { Register(cross(cur), "admin", "", "", "") }) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) mustAbort(t, cur, "bad handle", func() { Register(cross(cur), "gnocial", "", "", "") }) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnots(1)) mustAbort(t, cur, "this call takes no payment", func() { Register(cross(cur), "alice", "", "", "") }) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) Register(cross(cur), "alice", "Alice", "xin chao", "") testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) mustAbort(t, cur, "already registered", func() { Register(cross(cur), "alice2", "", "", "") }) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) mustAbort(t, cur, "self referral", func() { Register(cross(cur), "bob", "", "", bob.String()) }) _, ok := ProfileOf(bob) urequire.Equal(t, false, ok) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Register(cross(cur), "bob", "", "", alice.String()) testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("carol"))) testing.SetOriginSend(nil) mustAbort(t, cur, "handle taken", func() { Register(cross(cur), "alice", "", "", "") }) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) id := Post(cross(cur), "chao-arena", "") got, ok := GetPost(id) urequire.Equal(t, true, ok) got.Body = "mutated" again, _ := GetPost(id) urequire.Equal(t, "chao-arena", again.Body) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Like(cross(cur), id) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) mustAbort(t, cur, "already liked", func() { Like(cross(cur), id) }) Follow(cross(cur), "alice") replyID := Reply(cross(cur), id, "tra loi") aliceP, _ := ProfileByHandle("alice") urequire.Equal(t, 1, aliceP.Followers) urequire.Equal(t, 1, aliceP.Posts) bobP, _ := ProfileByHandle("bob") urequire.Equal(t, 1, bobP.Following) post, _ := GetPost(id) urequire.Equal(t, 1, post.Likes) urequire.Equal(t, 1, post.Replies) urequire.Equal(t, true, Following(bob, "alice")) feed := Feed(0, 8) urequire.Equal(t, "chao-arena", feed[0].Body) urequire.Equal(t, false, strings.Contains(Render(""), "chao-arena")) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Unlike(cross(cur), id) Unfollow(cross(cur), "alice") post, _ = GetPost(id) urequire.Equal(t, 0, post.Likes) aliceP, _ = ProfileByHandle("alice") urequire.Equal(t, 0, aliceP.Followers) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) Delete(cross(cur), id) post, _ = GetPost(id) urequire.Equal(t, true, post.Deleted) urequire.Equal(t, "", post.Body) urequire.Equal(t, false, strings.Contains(Render(""), "chao-arena")) reply, ok := GetPost(replyID) urequire.Equal(t, true, ok) urequire.Equal(t, "tra loi", reply.Body) urequire.Equal(t, true, strings.Contains(Render(""), "Gnocial")) } func TestGatedBody(cur realm, t *testing.T) { resetArena() realmAddr := cur.Address() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") const secret = "bi-mat-ve-99" testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) Register(cross(cur), "alice", "", "", "") id := Post(cross(cur), secret, "yes") urequire.Equal(t, false, strings.Contains(Render(""), secret)) urequire.Equal(t, false, strings.Contains(Render("p/"+strconv.Itoa(id)), secret)) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Register(cross(cur), "bob", "", "", "") testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) mustAbort(t, cur, "need a ticket", func() { GatedBody(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) urequire.Equal(t, secret, GatedBody(cross(cur), id)) due, _, _, _, _ := QuoteBuy("alice", 1) testing.IssueCoins(realmAddr, ugnots(due)) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(ugnots(due)) Buy(cross(cur), "alice", 1) testing.SetOriginSend(nil) urequire.Equal(t, secret, GatedBody(cross(cur), id)) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Sell(cross(cur), "alice", 1) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) mustAbort(t, cur, "need a ticket", func() { GatedBody(cross(cur), id) }) } func TestTicketRoundTrip(cur realm, t *testing.T) { resetArena() realmAddr := cur.Address() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") carol := testutils.TestAddress("carol") treasury := testutils.TestAddress("treasury") testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) Register(cross(cur), "alice", "", "", "") testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Register(cross(cur), "bob", "", "", alice.String()) testing.SetRealm(testing.NewUserRealm(carol)) testing.SetOriginSend(nil) Register(cross(cur), "carol", "", "", "") dueA, curveA, _, _, refA := QuoteBuy("alice", 1) urequire.Equal(t, int64(10_000), curveA) urequire.Equal(t, int64(0), refA) urequire.Equal(t, int64(11_000), dueA) due, curve, creatorFee, protocolFee, referralFee := QuoteBuy("bob", 1) urequire.Equal(t, int64(10_000), curve) urequire.Equal(t, int64(500), creatorFee) urequire.Equal(t, int64(450), protocolFee) urequire.Equal(t, int64(50), referralFee) urequire.Equal(t, int64(11_000), due) startRealm := bal(realmAddr) startAlice := bal(alice) startBob := bal(bob) startTreasury := bal(treasury) testing.IssueCoins(realmAddr, ugnots(due)) testing.SetRealm(testing.NewUserRealm(carol)) testing.SetOriginSend(ugnots(due)) Buy(cross(cur), "bob", 1) urequire.Equal(t, 1, SharesOf(carol, "bob")) supply, reserve, holders := SupplyOf("bob") urequire.Equal(t, 1, supply) urequire.Equal(t, int64(10_000), reserve) urequire.Equal(t, 1, holders) urequire.Equal(t, int64(10_000), TotalReserve()) urequire.Equal(t, startAlice+50, bal(alice)) urequire.Equal(t, startBob+500, bal(bob)) urequire.Equal(t, startTreasury+450, bal(treasury)) urequire.Equal(t, startRealm+10_000, bal(realmAddr)) proceeds, _, _, _, _ := QuoteSell("bob", 1) urequire.Equal(t, int64(9_000), proceeds) startCarol := bal(carol) testing.SetRealm(testing.NewUserRealm(carol)) testing.SetOriginSend(nil) Sell(cross(cur), "bob", 1) urequire.Equal(t, 0, SharesOf(carol, "bob")) supply, reserve, holders = SupplyOf("bob") urequire.Equal(t, 0, supply) urequire.Equal(t, int64(0), reserve) urequire.Equal(t, 0, holders) urequire.Equal(t, int64(0), TotalReserve()) urequire.Equal(t, startCarol+proceeds, bal(carol)) urequire.Equal(t, startAlice+100, bal(alice)) urequire.Equal(t, startBob+1_000, bal(bob)) urequire.Equal(t, startTreasury+900, bal(treasury)) urequire.Equal(t, startRealm, bal(realmAddr)) testing.SetRealm(testing.NewUserRealm(carol)) testing.SetOriginSend(nil) mustAbort(t, cur, "not enough tickets", func() { Sell(cross(cur), "bob", 1) }) } func TestTipForwards(cur realm, t *testing.T) { resetArena() realmAddr := cur.Address() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) Register(cross(cur), "alice", "", "", "") id := Post(cross(cur), "tip me", "") testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Register(cross(cur), "bob", "", "", "") startRealm := bal(realmAddr) startAlice := bal(alice) testing.IssueCoins(realmAddr, ugnots(25_000)) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(ugnots(25_000)) Tip(cross(cur), id) urequire.Equal(t, startRealm, bal(realmAddr)) urequire.Equal(t, startAlice+25_000, bal(alice)) post, _ := GetPost(id) urequire.Equal(t, int64(25_000), post.Tips) aliceP, _ := ProfileByHandle("alice") urequire.Equal(t, int64(25_000), aliceP.TipsIn) bobP, _ := ProfileByHandle("bob") urequire.Equal(t, int64(25_000), bobP.TipsOut) } func TestPaymentGuards(cur realm, t *testing.T) { resetArena() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewCodeRealm("gno.land/r/evil/proxy")) testing.SetOriginSend(ugnots(11_000)) mustAbort(t, cur, "direct user call only", func() { Buy(cross(cur), "alice", 1) }) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) Register(cross(cur), "alice", "", "", "") testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Register(cross(cur), "bob", "", "", "") due, _, _, _, _ := QuoteBuy("alice", 1) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(ugnots(1)) mustAbort(t, cur, "send exactly "+strconv.FormatInt(due, 10)+"ugnot", func() { Buy(cross(cur), "alice", 1) }) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(chain.Coins{chain.NewCoin("atom", due)}) mustAbort(t, cur, "send ugnot only", func() { Buy(cross(cur), "alice", 1) }) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(chain.Coins{ chain.NewCoin("atom", 1), chain.NewCoin("ugnot", due), }) mustAbort(t, cur, "send ugnot only", func() { Buy(cross(cur), "alice", 1) }) // A rejected buy must not mint tickets. urequire.Equal(t, 0, SharesOf(bob, "alice")) urequire.Equal(t, int64(0), TotalReserve()) } func TestPauseStillSells(cur realm, t *testing.T) { resetArena() realmAddr := cur.Address() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") admin := testutils.TestAddress("admin") testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) Register(cross(cur), "alice", "", "", "") testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Register(cross(cur), "bob", "", "", "") due, _, _, _, _ := QuoteBuy("alice", 1) testing.IssueCoins(realmAddr, ugnots(due)) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(ugnots(due)) Buy(cross(cur), "alice", 1) testing.SetRealm(testing.NewUserRealm(admin)) testing.SetOriginSend(nil) SetPaused(cross(cur), "yes") urequire.Equal(t, true, Paused()) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) mustAbort(t, cur, "paused", func() { Post(cross(cur), "nope", "") }) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(ugnots(due)) mustAbort(t, cur, "paused", func() { Buy(cross(cur), "alice", 1) }) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Sell(cross(cur), "alice", 1) urequire.Equal(t, 0, SharesOf(bob, "alice")) urequire.Equal(t, int64(0), TotalReserve()) } func TestSweepKeepsReserve(cur realm, t *testing.T) { resetArena() realmAddr := cur.Address() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") admin := testutils.TestAddress("admin") treasury := testutils.TestAddress("treasury") testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) Register(cross(cur), "alice", "", "", "") testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Register(cross(cur), "bob", "", "", "") due, _, _, _, _ := QuoteBuy("alice", 1) testing.IssueCoins(realmAddr, ugnots(due)) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(ugnots(due)) Buy(cross(cur), "alice", 1) urequire.Equal(t, int64(10_000), TotalReserve()) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) mustAbort(t, cur, "admin only", func() { Sweep(cross(cur), "sweep") }) testing.SetRealm(testing.NewUserRealm(admin)) testing.SetOriginSend(nil) mustAbort(t, cur, "confirm sweep", func() { Sweep(cross(cur), "please") }) testing.IssueCoins(realmAddr, ugnots(77)) beforeT := bal(treasury) surplus := bal(realmAddr) - TotalReserve() testing.SetRealm(testing.NewUserRealm(admin)) testing.SetOriginSend(nil) Sweep(cross(cur), "sweep") urequire.Equal(t, TotalReserve(), bal(realmAddr)) urequire.Equal(t, beforeT+surplus, bal(treasury)) urequire.Equal(t, int64(10_000), TotalReserve()) supply, reserve, _ := SupplyOf("alice") urequire.Equal(t, 1, supply) urequire.Equal(t, int64(10_000), reserve) } func TestCreatorFeeSchedule(cur realm, t *testing.T) { resetArena() realmAddr := cur.Address() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") carol := testutils.TestAddress("carol") dave := testutils.TestAddress("dave") eve := testutils.TestAddress("eve") treasury := testutils.TestAddress("treasury") testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) Register(cross(cur), "alice", "", "", "") aliceP, _ := ProfileByHandle("alice") urequire.Equal(t, CreatorFeeDefaultBps, aliceP.CreatorFee) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Register(cross(cur), "bob", "", "", alice.String()) testing.SetRealm(testing.NewUserRealm(carol)) testing.SetOriginSend(nil) Register(cross(cur), "carol", "", "", "") testing.SetRealm(testing.NewUserRealm(dave)) testing.SetOriginSend(nil) Register(cross(cur), "dave", "", "", alice.String()) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) SetCreatorFee(cross(cur), 100) dueAlice, _, cf, pf, rf := QuoteBuy("alice", 1) urequire.Equal(t, int64(11_000), dueAlice) urequire.Equal(t, int64(100), cf) urequire.Equal(t, int64(900), pf) urequire.Equal(t, int64(0), rf) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) SetCreatorFee(cross(cur), 700) _, _, cf, pf, rf = QuoteBuy("alice", 1) urequire.Equal(t, int64(700), cf) urequire.Equal(t, int64(300), pf) urequire.Equal(t, int64(0), rf) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) mustAbort(t, cur, "fee", func() { SetCreatorFee(cross(cur), 99) }) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) mustAbort(t, cur, "fee", func() { SetCreatorFee(cross(cur), 701) }) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnots(1)) mustAbort(t, cur, "this call takes no payment", func() { SetCreatorFee(cross(cur), 500) }) testing.SetRealm(testing.NewUserRealm(eve)) testing.SetOriginSend(nil) mustAbort(t, cur, "register first", func() { SetCreatorFee(cross(cur), 500) }) testing.SetRealm(testing.NewUserRealm(carol)) testing.SetOriginSend(nil) SetCreatorFee(cross(cur), 100) bobP, _ := ProfileByHandle("bob") urequire.Equal(t, int64(500), bobP.CreatorFee) carolP, _ := ProfileByHandle("carol") urequire.Equal(t, int64(100), carolP.CreatorFee) testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) SetCreatorFee(cross(cur), 700) due, curve, cf, pf, rf := QuoteBuyFor("bob", dave.String(), 1) urequire.Equal(t, int64(10_000), curve) urequire.Equal(t, int64(11_000), due) urequire.Equal(t, int64(700), cf) urequire.Equal(t, int64(200), pf) urequire.Equal(t, int64(100), rf) same, _, _, _, _ := QuoteBuy("bob", 1) urequire.Equal(t, due, same) _, _, _, pf, rf = QuoteBuyFor("bob", eve.String(), 1) urequire.Equal(t, int64(250), pf) urequire.Equal(t, int64(50), rf) if !uassert.PanicsWithMessage(t, cur, "bad address", func() { QuoteBuyFor("bob", "not-an-address", 1) }) { t.FailNow() } testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) SetCreatorFee(cross(cur), 500) due, _, cf, pf, rf = QuoteBuyFor("bob", dave.String(), 1) urequire.Equal(t, int64(500), cf) urequire.Equal(t, int64(400), pf) urequire.Equal(t, int64(100), rf) startRealm := bal(realmAddr) startAlice := bal(alice) startBob := bal(bob) startTreasury := bal(treasury) testing.IssueCoins(realmAddr, ugnots(due)) testing.SetRealm(testing.NewUserRealm(dave)) testing.SetOriginSend(ugnots(due)) Buy(cross(cur), "bob", 1) urequire.Equal(t, startAlice+100, bal(alice)) urequire.Equal(t, startBob+500, bal(bob)) urequire.Equal(t, startTreasury+400, bal(treasury)) urequire.Equal(t, startRealm+10_000, bal(realmAddr)) proceeds, _, _, _, _ := QuoteSellFor("bob", dave.String(), 1) urequire.Equal(t, int64(9_000), proceeds) testing.SetRealm(testing.NewUserRealm(dave)) testing.SetOriginSend(nil) Sell(cross(cur), "bob", 1) urequire.Equal(t, startAlice+200, bal(alice)) urequire.Equal(t, startBob+1_000, bal(bob)) urequire.Equal(t, startTreasury+800, bal(treasury)) urequire.Equal(t, startRealm, bal(realmAddr)) urequire.Equal(t, int64(0), TotalReserve()) }
- #6gnomod.toml
- #7module = "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/gnocial" gno = "0.9"
- #8post.gno
- #9package gnocial import ( "chain" "chain/runtime" "math/overflow" "strconv" ) // Post publishes a top-level post and returns its id. // gated is yes/1/true/khoa to withhold the body from Render. // The body is still public chain state. Pass an empty gated value for a public post. func Post(cur realm, body, gated string) int { who := caller(cur) mustNoPay() if state.paused { panic("paused") } author := mustMember(who) return writePost(author, 0, body, isYes(gated)) } // Reply publishes a reply to parent and returns the new id. Replies are public. func Reply(cur realm, parent int, body string) int { who := caller(cur) mustNoPay() if state.paused { panic("paused") } author := mustMember(who) target := postByID(parent) if target == nil { panic("unknown post") } if target.Deleted { panic("deleted") } id := writePost(author, parent, body, false) target.Replies++ state.replies.Set(pad(parent)+"/"+pad(id), id) return id } func writePost(author *Profile, parent int, body string, gated bool) int { body = cleanBody(body) id := state.nextID state.nextID++ p := &Article{ ID: id, Author: author.Addr, Body: body, Height: runtime.ChainHeight(), ReplyTo: parent, Gated: gated, } state.posts.Set(pad(id), p) if parent == 0 { author.Posts++ state.feed.Set(pad(id), id) state.byAuthor.Set(authorKey(author.Addr, id), id) chain.Emit( "ArenaPost", "id", strconv.Itoa(id), "author", author.Addr.String(), "handle", author.Handle, "gated", yesNo(gated), ) } else { chain.Emit( "ArenaReply", "id", strconv.Itoa(id), "parent", strconv.Itoa(parent), "author", author.Addr.String(), "handle", author.Handle, ) } return id } func yesNo(v bool) string { if v { return "yes" } return "no" } // Delete soft-deletes the caller's post and clears its body from current state. // The original transaction still holds the text in chain history. func Delete(cur realm, id int) { who := caller(cur) mustNoPay() mustMember(who) p := postByID(id) if p == nil { panic("unknown post") } if p.Deleted { panic("deleted") } if p.Author != who { panic("author only") } if p.ReplyTo == 0 { state.feed.Remove(pad(id)) state.byAuthor.Remove(authorKey(who, id)) if author := profileByAddr(who); author != nil && author.Posts > 0 { author.Posts-- } } else { state.replies.Remove(pad(p.ReplyTo) + "/" + pad(id)) if parent := postByID(p.ReplyTo); parent != nil && parent.Replies > 0 { parent.Replies-- } } p.Deleted = true p.Body = "" p.Gated = false chain.Emit("ArenaDelete", "id", strconv.Itoa(id), "author", who.String()) } // Like adds the caller's like. One like per account. func Like(cur realm, id int) { who := caller(cur) mustNoPay() mustMember(who) p := livePost(id) k := likeKey(id, who) if state.likes.Has(k) { panic("already liked") } state.likes.Set(k, 1) p.Likes++ chain.Emit("ArenaLike", "id", strconv.Itoa(id), "from", who.String()) } // Unlike removes the caller's like. func Unlike(cur realm, id int) { who := caller(cur) mustNoPay() mustMember(who) p := livePost(id) k := likeKey(id, who) if !state.likes.Has(k) { panic("not liked") } state.likes.Remove(k) if p.Likes > 0 { p.Likes-- } chain.Emit("ArenaUnlike", "id", strconv.Itoa(id), "from", who.String()) } // Tip forwards the attached ugnot to the post's author. The realm does not keep it. func Tip(cur realm, id int) { who := caller(cur) if state.paused { panic("paused") } mustMember(who) sent := takeUgnot(cur) p := livePost(id) author := profileByAddr(p.Author) if author == nil { panic("register first") } p.Tips = overflow.Add64p(p.Tips, sent) author.TipsIn = overflow.Add64p(author.TipsIn, sent) if self := profileByAddr(who); self != nil { self.TipsOut = overflow.Add64p(self.TipsOut, sent) } payout(cur, p.Author, sent, "author") chain.Emit( "ArenaTip", "id", strconv.Itoa(id), "from", who.String(), "to", p.Author.String(), "ugnot", strconv.FormatInt(sent, 10), ) } // GatedBody returns a gated post's text for the author or a current ticket holder. // Chain storage is public; this only gates the convenience call and Render. func GatedBody(cur realm, id int) string { who := caller(cur) mustNoPay() mustMember(who) p := livePost(id) if !p.Gated { return p.Body } if p.Author != who && getShares(who, p.Author) < 1 { panic("need a ticket") } return p.Body } func livePost(id int) *Article { p := postByID(id) if p == nil { panic("unknown post") } if p.Deleted { panic("deleted") } return p } // Liked reports whether who liked the post. func Liked(who address, id int) bool { if id <= 0 { return false } return state.likes.Has(likeKey(id, who)) }
- #10profile.gno
- #11package gnocial import ( "chain" "chain/runtime" "strconv" "strings" ) // Register opens a profile for the calling account. // handle is 3–16 chars of a-z, 0-9 and underscore. referrer may be empty // or the bech32 address of an existing member. A member can register once. func Register(cur realm, handle, name, bio, referrer string) { who := caller(cur) mustNoPay() if profileByAddr(who) != nil { panic("already registered") } handle = strings.ToLower(strings.TrimSpace(handle)) if !validHandle(handle) { panic("bad handle") } if state.byHandle.Has(handle) { panic("handle taken") } if name == "" { name = handle } name = cleanLine(name, maxNameRunes, maxNameBytes, "name", false) bio = cleanLine(bio, maxBioRunes, maxBioBytes, "bio", true) var ref address referrer = strings.TrimSpace(referrer) if referrer != "" { if len(referrer) > 60 { panic("bad referrer") } ref = address(referrer) if !ref.IsValid() { panic("bad referrer") } if ref == who { panic("self referral") } if profileByAddr(ref) == nil { panic("referrer not registered") } } p := &Profile{ Addr: who, Handle: handle, Name: name, Bio: bio, Referrer: ref, Joined: runtime.ChainHeight(), CreatorFee: CreatorFeeDefaultBps, } state.byAddr.Set(who.String(), p) state.byHandle.Set(handle, p) state.books.Set(who.String(), &book{}) chain.Emit( "ArenaRegister", "addr", who.String(), "handle", handle, "referrer", ref.String(), ) } // SetCreatorFee stores this member's share of each ticket trade, in basis points. // Only that member can change it. The trade total stays FeeCapBps. func SetCreatorFee(cur realm, points int64) { who := caller(cur) mustNoPay() p := mustMember(who) if points < CreatorFeeMinBps || points > CreatorFeeMaxBps { panic("fee") } p.CreatorFee = points chain.Emit( "ArenaCreatorFee", "addr", who.String(), "handle", p.Handle, "bps", strconv.FormatInt(points, 10), ) } // UpdateProfile replaces the display name and bio. The handle stays. func UpdateProfile(cur realm, name, bio string) { who := caller(cur) mustNoPay() p := mustMember(who) if strings.TrimSpace(name) == "" { name = p.Handle } p.Name = cleanLine(name, maxNameRunes, maxNameBytes, "name", false) p.Bio = cleanLine(bio, maxBioRunes, maxBioBytes, "bio", true) chain.Emit("ArenaProfile", "addr", who.String(), "handle", p.Handle) } // Follow records that the caller follows handle. func Follow(cur realm, handle string) { who := caller(cur) mustNoPay() self := mustMember(who) target := lookup(handle) if target == nil { panic("unknown handle") } if target.Addr == who { panic("cannot follow self") } k := followKey(who, target.Addr) if state.follows.Has(k) { panic("already following") } state.follows.Set(k, 1) state.fans.Set(fanKey(target.Addr, who), 1) self.Following++ target.Followers++ chain.Emit("ArenaFollow", "from", who.String(), "to", target.Addr.String(), "handle", target.Handle) } // Unfollow removes a follow. func Unfollow(cur realm, handle string) { who := caller(cur) mustNoPay() self := mustMember(who) target := lookup(handle) if target == nil { panic("unknown handle") } k := followKey(who, target.Addr) if !state.follows.Has(k) { panic("not following") } state.follows.Remove(k) state.fans.Remove(fanKey(target.Addr, who)) if self.Following > 0 { self.Following-- } if target.Followers > 0 { target.Followers-- } chain.Emit("ArenaUnfollow", "from", who.String(), "to", target.Addr.String(), "handle", target.Handle) } // Following reports whether from follows the handle. func Following(from address, handle string) bool { target := lookup(handle) if target == nil { return false } return state.follows.Has(followKey(from, target.Addr)) }
- #12query.gno
- #13package gnocial import ( "strings" ) // Holding is one creator's tickets in a wallet. It is a copy. type Holding struct { Creator address Handle string Amount int } func mustLimit(n int) int { if n < 1 || n > 20 { panic("limit") } return n } // Feed lists newest top-level posts with id < before. before 0 starts at the newest. func Feed(before, limit int) []Article { limit = mustLimit(limit) posts := recentPosts(before, limit) out := make([]Article, len(posts)) for i, p := range posts { out[i] = *p } return out } // PostsBy lists one member's newest top-level posts with id < before. func PostsBy(addr address, before, limit int) []Article { limit = mustLimit(limit) prefix := addr.String() + "/" out := make([]Article, 0, limit) state.byAuthor.ReverseIterate(prefix, prefix+"~", func(key string, value any) bool { id, ok := value.(int) if !ok { panic("author type") } if before > 0 && id >= before { return false } p := postByID(id) if p == nil || p.Deleted { return false } out = append(out, *p) return len(out) >= limit }) return out } // Replies lists direct replies of parent with id > after, oldest first. func Replies(parent, after, limit int) []Article { limit = mustLimit(limit) if parent <= 0 { panic("id") } prefix := pad(parent) + "/" start := prefix if after > 0 { start = prefix + pad(after) + "\x00" } out := make([]Article, 0, limit) state.replies.Iterate(start, prefix+"~", func(key string, value any) bool { id, ok := value.(int) if !ok { panic("reply type") } p := postByID(id) if p == nil || p.Deleted { return false } out = append(out, *p) return len(out) >= limit }) return out } // Handles lists profiles in handle order, strictly after the given handle. func Handles(after string, limit int) []Profile { limit = mustLimit(limit) start := "" after = strings.ToLower(strings.TrimSpace(after)) if after != "" { start = after + "\x00" } out := make([]Profile, 0, limit) state.byHandle.Iterate(start, "", func(key string, value any) bool { p, ok := value.(*Profile) if !ok { panic("profile type") } out = append(out, *p) return len(out) >= limit }) return out } // FollowingList lists profiles `who` follows, in address order, after `after`. func FollowingList(who, after address, limit int) []Profile { return edgeList(who, after, limit, ">") } // FollowerList lists profiles that follow `who`, in address order, after `after`. func FollowerList(who, after address, limit int) []Profile { return edgeList(who, after, limit, "<") } func edgeList(who, after address, limit int, sep string) []Profile { limit = mustLimit(limit) if !who.IsValid() { panic("bad address") } prefix := who.String() + sep start := prefix if after.IsValid() { start = prefix + after.String() + "\x00" } out := make([]Profile, 0, limit) tree := state.follows if sep == "<" { tree = state.fans } tree.Iterate(start, prefix+"~", func(key string, value any) bool { i := strings.LastIndex(key, sep) if i < 0 { return false } p := profileByAddr(address(key[i+1:])) if p == nil { return false } out = append(out, *p) return len(out) >= limit }) return out } // Holdings lists ticket balances of holder, after the given creator address. func Holdings(holder, after address, limit int) []Holding { limit = mustLimit(limit) if !holder.IsValid() { panic("bad address") } prefix := holder.String() + "/" start := prefix if after.IsValid() { start = prefix + after.String() + "\x00" } out := make([]Holding, 0, limit) state.shares.Iterate(start, prefix+"~", func(key string, value any) bool { n, ok := value.(int) if !ok { panic("share type") } i := strings.LastIndex(key, "/") if i < 0 || n < 1 { return false } creator := address(key[i+1:]) handle := "" if p := profileByAddr(creator); p != nil { handle = p.Handle } out = append(out, Holding{Creator: creator, Handle: handle, Amount: n}) return len(out) >= limit }) return out } // Hot ranks the newest window the same way Render does. It is not a global index. func Hot(limit int) []Article { limit = mustLimit(limit) posts := recentPosts(0, hotWindow) top := make([]scoredPost, 0, limit) for _, p := range posts { score := int64(p.Likes)*1_000_000 + p.Tips top = insertRank(top, scoredPost{p: p, score: score}, limit) } out := make([]Article, len(top)) for i, row := range top { out[i] = *row.p } return out } // MemberCount is the number of registered handles. func MemberCount() int { return state.byHandle.Size() } // FeedCount is the number of live top-level posts still in the feed index. func FeedCount() int { return state.feed.Size() } // LatestID is the newest post id, or 0 when nothing has been posted. func LatestID() int { if state.nextID <= 1 { return 0 } return state.nextID - 1 } const hotWindow = 30 type scoredPost struct { p *Article score int64 } func insertRank(top []scoredPost, row scoredPost, limit int) []scoredPost { if len(top) == limit && row.score <= top[len(top)-1].score { return top } top = append(top, row) for i := len(top) - 1; i > 0; i-- { if top[i].score <= top[i-1].score { break } top[i], top[i-1] = top[i-1], top[i] } if len(top) > limit { top = top[:limit] } return top } func recentPosts(before, limit int) []*Article { end := "" if before > 0 { end = pad(before) } out := make([]*Article, 0, limit) state.feed.ReverseIterate("", end, func(key string, value any) bool { id, ok := value.(int) if !ok { panic("feed type") } if before > 0 && id >= before { return false } p := postByID(id) if p == nil || p.Deleted || p.ReplyTo != 0 { return false } out = append(out, p) return len(out) >= limit }) return out }
- #14query_test.gno
- #15package gnocial import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) func TestQueriesAndSuccessor(cur realm, t *testing.T) { resetArena() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") admin := testutils.TestAddress("admin") testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) Register(cross(cur), "alice", "Alice", "xin chao", "") mustAbort(t, cur, "body has markup", func() { Post(cross(cur), "<b>x</b>", "") }) id := Post(cross(cur), "bai cong khai", "") gid := Post(cross(cur), "bi mat 42", "yes") testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(nil) Register(cross(cur), "bob", "Bob", "", alice.String()) Reply(cross(cur), id, "tra loi 1") reply2 := Reply(cross(cur), id, "tra loi 2") rows := Feed(0, 8) urequire.Equal(t, true, len(rows) >= 2) urequire.Equal(t, false, strings.Contains(Render(""), "bi mat 42")) urequire.Equal(t, false, strings.Contains(Render(""), "bai cong khai")) secret, ok := GetPost(gid) urequire.Equal(t, true, ok) urequire.Equal(t, "bi mat 42", secret.Body) people := Handles("", 1) urequire.Equal(t, 1, len(people)) urequire.Equal(t, "alice", people[0].Handle) next := Handles(people[0].Handle, 1) urequire.Equal(t, "bob", next[0].Handle) reps := Replies(id, 0, 1) urequire.Equal(t, 1, len(reps)) urequire.Equal(t, "tra loi 1", reps[0].Body) rest := Replies(id, reps[0].ID, 1) urequire.Equal(t, reply2, rest[0].ID) due, _, _, _, _ := QuoteBuy("alice", 1) urequire.Equal(t, int64(11000), due) acc, ok := ProfileOf(alice) urequire.Equal(t, true, ok) urequire.Equal(t, "xin chao", acc.Bio) urequire.Equal(t, 2, MemberCount()) urequire.Equal(t, true, LatestID() >= reply2) if !uassert.PanicsWithMessage(t, cur, "limit", func() { Feed(0, 0) }) { t.FailNow() } testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) mustAbort(t, cur, "admin only", func() { SetSuccessor(cross(cur), "gno.land/r/demo/next") }) testing.SetRealm(testing.NewUserRealm(admin)) testing.SetOriginSend(ugnots(1)) mustAbort(t, cur, "this call takes no payment", func() { SetSuccessor(cross(cur), "gno.land/r/demo/next") }) testing.SetRealm(testing.NewUserRealm(admin)) testing.SetOriginSend(nil) mustAbort(t, cur, "bad successor", func() { SetSuccessor(cross(cur), "gno.land/r/demo/next.v2") }) reserve := TotalReserve() SetSuccessor(cross(cur), "gno.land/r/demo/next") urequire.Equal(t, "gno.land/r/demo/next", Successor()) urequire.Equal(t, reserve, TotalReserve()) urequire.Equal(t, true, strings.Contains(Render("about"), "gno.land/r/demo/next")) SetSuccessor(cross(cur), "") urequire.Equal(t, "", Successor()) }
- #16render.gno
- #17package gnocial import "strconv" // Render is the short gnoweb page for this fund realm. // The long client and Sheet live in the view realm. A realm has to declare // Render, and the storage deposit of this package cannot hold both the curve // and the full client. func Render(path string) string { line := "# Gnocial\n\nPhien ban " + ProtocolVersion if state.successor != "" { line += "\n\nKe tiep: " + state.successor } if state.paused { line += "\n\nTam dung dang bat. Ban ve van mo." } line += "\n\nThanh vien: " + strconv.Itoa(MemberCount()) return line }
- #18state.gno
- #19// Package gnocial is a SocialFi realm: profiles, posts, follows, GNOT tips, // and creator tickets priced on a quadratic bonding curve. // // Each ticket trade charges FeeCapBps of the curve. The creator chooses a // share from CreatorFeeMinBps to CreatorFeeMaxBps. A creator referrer and a // trader referrer each take a fixed slice when that person exists. The // treasury receives the rest of the cap. That remainder is this package's // schedule. Arena has not published its protocol percent. // // Ticket collateral sits in this realm as totalReserve. Trade fees are // forwarded out of that collateral, and Sweep only sends coins above it. // This package does not receive the reserve of an earlier realm. package gnocial import ( "chain" "chain/banker" "chain/runtime/unsafe" "strconv" "strings" "unicode/utf8" "gno.land/p/nt/avl/v0" ) const ( // FeeCapBps is the total taken from the curve on every buy and every sell. FeeCapBps int64 = 1_000 // CreatorFeeMinBps is the smallest share a creator may keep. CreatorFeeMinBps int64 = 100 // CreatorFeeMaxBps is the largest share a creator may keep. CreatorFeeMaxBps int64 = 700 // CreatorFeeDefaultBps is stored on a profile at Register. CreatorFeeDefaultBps int64 = 500 // CreatorReferralBps is paid to the creator's referrer when they have one. CreatorReferralBps int64 = 50 // TraderReferralBps is paid to the buyer or seller's referrer when they have one. TraderReferralBps int64 = 50 // TicketUnit is the ugnot price of ticket number 1. Ticket i costs i² units. TicketUnit int64 = 10_000 // MaxSupply is the most tickets one creator can have outstanding. MaxSupply = 2000 // MaxTrade is the most tickets one buy or sell can move. MaxTrade = 20 // MaxBodyRunes is the longest post, counted in runes. MaxBodyRunes = 280 // ProtocolVersion is the immutable revision of this package. // A later package is announced with SetSuccessor. It does not hold this reserve. ProtocolVersion = "2" maxHandle = 16 minHandle = 3 maxNameRunes = 32 maxNameBytes = 128 maxBioRunes = 160 maxBioBytes = 640 maxBodyBytes = 1120 ) // Profile is a copy of a member's public record. type Profile struct { Addr address Handle string Name string Bio string Referrer address Joined int64 Posts int Followers int Following int TipsIn int64 TipsOut int64 CreatorFee int64 } // Article is a copy of one post. Gated bodies are still stored on chain; // Render withholds them, and GatedBody checks the caller. type Article struct { ID int Author address Body string Height int64 ReplyTo int Likes int Replies int Tips int64 Gated bool Deleted bool } type book struct { Supply int Reserve int64 Holders int } // Trees stay unexported. avl.Iterate takes a callback; handing out the // tree would let another realm run that callback under this realm. type store struct { admin address treasury address paused bool nextID int totalReserve int64 byAddr *avl.Tree byHandle *avl.Tree posts *avl.Tree feed *avl.Tree replies *avl.Tree byAuthor *avl.Tree follows *avl.Tree fans *avl.Tree likes *avl.Tree books *avl.Tree shares *avl.Tree successor string } var state *store func init() { if CreatorFeeMinBps < 1 || CreatorFeeDefaultBps < CreatorFeeMinBps || CreatorFeeDefaultBps > CreatorFeeMaxBps { panic("fees") } if CreatorReferralBps < 0 || TraderReferralBps < 0 || FeeCapBps < 1 || FeeCapBps >= 10_000 { panic("fees") } if CreatorFeeMaxBps+CreatorReferralBps+TraderReferralBps > FeeCapBps { panic("fees") } state = newStore() deployer := unsafe.OriginCaller() if deployer.IsValid() { state.admin = deployer state.treasury = deployer } } func newStore() *store { return &store{ nextID: 1, byAddr: avl.NewTree(), byHandle: avl.NewTree(), posts: avl.NewTree(), feed: avl.NewTree(), replies: avl.NewTree(), byAuthor: avl.NewTree(), follows: avl.NewTree(), fans: avl.NewTree(), likes: avl.NewTree(), books: avl.NewTree(), shares: avl.NewTree(), } } // Admin is the address allowed to pause, sweep surplus, and rotate treasury. func Admin() address { return state.admin } // Treasury receives protocol fees and swept surplus. func Treasury() address { return state.treasury } // Version is this package revision. It has no setter. func Version() string { return ProtocolVersion } // Successor is the package path of a later realm, or empty. // The later realm does not receive this realm's ticket reserve. func Successor() string { return state.successor } // Paused reports whether new posts, replies, tips, and ticket buys are frozen. // Sells stay open so ticket collateral can still exit. func Paused() bool { return state.paused } // TotalReserve is the ugnot locked to back outstanding tickets. func TotalReserve() int64 { return state.totalReserve } func caller(cur realm) address { if !cur.Previous().IsUserCall() { panic("direct user call only") } return cur.Previous().Address() } func mustNoPay() { if len(unsafe.OriginSend()) != 0 { panic("this call takes no payment") } } func takeUgnot(cur realm) int64 { if !cur.Previous().IsUserCall() { panic("direct user call only") } coins := unsafe.OriginSend() if len(coins) != 1 || coins[0].Denom != "ugnot" || coins[0].Amount <= 0 { panic("send ugnot only") } return coins[0].Amount } func payout(cur realm, to address, amt int64, why string) { if amt == 0 { return } if amt < 0 || !to.IsValid() { panic("bad recipient: " + why) } bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins(cur.Address(), to, chain.Coins{chain.NewCoin("ugnot", amt)}) } func mustAdmin(cur realm) { who := caller(cur) mustNoPay() if who != state.admin { panic("admin only") } } func pad(n int) string { if n < 0 { panic("id") } s := strconv.Itoa(n) for len(s) < 10 { s = "0" + s } return s } func validHandle(s string) bool { if len(s) < minHandle || len(s) > maxHandle { return false } alnum := false for i := 0; i < len(s); i++ { c := s[i] switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9': alnum = true case c == '_': default: return false } } if !alnum || reserved(s) { return false } return true } func reserved(h string) bool { switch h { case "about", "people", "hot", "feed", "following", "admin", "arena", "gnocial", "u", "p", "t": return true default: return false } } func cleanLine(s string, maxRunes, maxBytes int, label string, allowEmpty bool) string { s = strings.TrimSpace(s) if s == "" { if allowEmpty { return "" } panic(label + " length") } if utf8.RuneCountInString(s) > maxRunes || len(s) > maxBytes { panic(label + " length") } for i := 0; i < len(s); i++ { c := s[i] if c < 0x20 || c == 0x7f || c == '<' { panic(label + " has markup") } } return s } func cleanBody(s string) string { s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") s = strings.TrimSpace(s) if s == "" || utf8.RuneCountInString(s) > MaxBodyRunes || len(s) > maxBodyBytes { panic("body length") } for i := 0; i < len(s); i++ { c := s[i] if c == '\n' || c == '\t' { continue } if c < 0x20 || c == 0x7f || c == '<' { panic("body has markup") } } return s } func isYes(s string) bool { switch strings.ToLower(strings.TrimSpace(s)) { case "", "0", "no", "false", "n": return false case "1", "yes", "true", "y", "khoa": return true default: panic("gated must be yes or no") } } func profileByAddr(a address) *Profile { v := state.byAddr.Get(a.String()) if v == nil { return nil } p, ok := v.(*Profile) if !ok { panic("profile type") } return p } func lookup(handle string) *Profile { h := strings.ToLower(strings.TrimSpace(handle)) if !validHandle(h) { return nil } v := state.byHandle.Get(h) if v == nil { return nil } p, ok := v.(*Profile) if !ok { panic("profile type") } return p } func mustMember(a address) *Profile { p := profileByAddr(a) if p == nil { panic("register first") } return p } func bookOf(creator address) *book { v := state.books.Get(creator.String()) if v == nil { panic("no book") } b, ok := v.(*book) if !ok { panic("book type") } return b } func postByID(id int) *Article { if id <= 0 { panic("id") } v := state.posts.Get(pad(id)) if v == nil { return nil } p, ok := v.(*Article) if !ok { panic("post type") } return p } func shareKey(holder, creator address) string { return holder.String() + "/" + creator.String() } func getShares(holder, creator address) int { v := state.shares.Get(shareKey(holder, creator)) if v == nil { return 0 } n, ok := v.(int) if !ok { panic("share type") } return n } func setShares(holder, creator address, n int) { k := shareKey(holder, creator) if n == 0 { state.shares.Remove(k) return } state.shares.Set(k, n) } func authorKey(a address, id int) string { return a.String() + "/" + pad(id) } func followKey(from, to address) string { return from.String() + ">" + to.String() } func fanKey(creator, fan address) string { return creator.String() + "<" + fan.String() } func likeKey(id int, who address) string { return pad(id) + "/" + who.String() } // ProfileByHandle returns a copy of the profile, or false when the handle is unknown. func ProfileByHandle(handle string) (Profile, bool) { p := lookup(handle) if p == nil { return Profile{}, false } return *p, true } // ProfileOf returns a copy of the profile bound to addr. func ProfileOf(addr address) (Profile, bool) { p := profileByAddr(addr) if p == nil { return Profile{}, false } return *p, true } // GetPost returns a copy of the post. The copy does not alias storage. func GetPost(id int) (Article, bool) { if id <= 0 { return Article{}, false } p := postByID(id) if p == nil { return Article{}, false } return *p, true } // SharesOf returns how many tickets holder has of the creator's handle. func SharesOf(holder address, handle string) int { p := lookup(handle) if p == nil { panic("unknown handle") } return getShares(holder, p.Addr) } // SupplyOf returns tickets outstanding, ugnot locked in that curve, and holders. func SupplyOf(handle string) (supply int, reserve int64, holders int) { p := lookup(handle) if p == nil { panic("unknown handle") } b := bookOf(p.Addr) return b.Supply, b.Reserve, b.Holders }
- #20ticket.gno
- #21package gnocial import ( "chain" "math/overflow" "strconv" "strings" ) // QuoteBuy is the exact ugnot a buyer must attach to mint amount tickets. // The total fee is always FeeCapBps of the curve. An anonymous quote pays // no trader-referrer slice, so that slice stays in the protocol fee. // The amount due does not change when the buyer is known. func QuoteBuy(handle string, amount int) (due, curve, creatorFee, protocolFee, referralFee int64) { return QuoteBuyFor(handle, "", amount) } // QuoteBuyFor is QuoteBuy with the buyer's address. Empty or a member with // no referrer adds no trader-referrer slice. A bad address panics. func QuoteBuyFor(handle, buyer string, amount int) (due, curve, creatorFee, protocolFee, referralFee int64) { curve, creatorFee, protocolFee, referralFee = quoteTotals(handle, buyer, amount, false) due = overflow.Add64p(curve, overflow.Add64p(creatorFee, overflow.Add64p(protocolFee, referralFee))) return } // QuoteSell is what a seller receives. The reserve drops by the full curve. // Proceeds are the curve minus FeeCapBps, whoever the seller is. func QuoteSell(handle string, amount int) (proceeds, curve, creatorFee, protocolFee, referralFee int64) { return QuoteSellFor(handle, "", amount) } // QuoteSellFor is QuoteSell with the seller's address. func QuoteSellFor(handle, seller string, amount int) (proceeds, curve, creatorFee, protocolFee, referralFee int64) { curve, creatorFee, protocolFee, referralFee = quoteTotals(handle, seller, amount, true) proceeds = curve - creatorFee - protocolFee - referralFee if proceeds <= 0 { panic("proceeds") } return } // Buy mints amount tickets of handle. Attach exactly QuoteBuy's due in ugnot. // The caller's own referrer receives the trader slice. The due matches QuoteBuy. func Buy(cur realm, handle string, amount int) { who := caller(cur) if state.paused { panic("paused") } mustMember(who) curve, creatorFee, protocolFee, creatorRefAmt, traderRefAmt, p, creatorRef, traderRef := tradeQuote(handle, who.String(), amount, false) due := overflow.Add64p(curve, bps(curve, FeeCapBps)) sent := takeUgnot(cur) if sent != due { panic("send exactly " + strconv.FormatInt(due, 10) + "ugnot") } b := bookOf(p.Addr) b.Supply += amount b.Reserve = overflow.Add64p(b.Reserve, curve) state.totalReserve = overflow.Add64p(state.totalReserve, curve) addShares(who, p.Addr, amount) forwardFees(cur, p, creatorFee, protocolFee, creatorRef, creatorRefAmt, traderRef, traderRefAmt) chain.Emit( "ArenaBuy", "buyer", who.String(), "creator", p.Addr.String(), "handle", p.Handle, "amount", strconv.Itoa(amount), "curve", strconv.FormatInt(curve, 10), "due", strconv.FormatInt(due, 10), ) } // Sell burns amount tickets and pays the caller from the reserve, minus fees. // Sells stay available while the realm is paused. func Sell(cur realm, handle string, amount int) { who := caller(cur) mustNoPay() mustMember(who) curve, creatorFee, protocolFee, creatorRefAmt, traderRefAmt, p, creatorRef, traderRef := tradeQuote(handle, who.String(), amount, true) if getShares(who, p.Addr) < amount { panic("not enough tickets") } proceeds := curve - bps(curve, FeeCapBps) if proceeds <= 0 { panic("proceeds") } b := bookOf(p.Addr) if b.Reserve < curve || state.totalReserve < curve { panic("insolvent") } b.Supply -= amount b.Reserve = overflow.Sub64p(b.Reserve, curve) state.totalReserve = overflow.Sub64p(state.totalReserve, curve) subShares(who, p.Addr, amount) payout(cur, who, proceeds, "seller") forwardFees(cur, p, creatorFee, protocolFee, creatorRef, creatorRefAmt, traderRef, traderRefAmt) chain.Emit( "ArenaSell", "seller", who.String(), "creator", p.Addr.String(), "handle", p.Handle, "amount", strconv.Itoa(amount), "curve", strconv.FormatInt(curve, 10), "proceeds", strconv.FormatInt(proceeds, 10), ) } func forwardFees(cur realm, creator *Profile, creatorFee, protocolFee int64, creatorRef address, creatorRefAmt int64, traderRef address, traderRefAmt int64) { if protocolFee > 0 && !state.treasury.IsValid() { panic("treasury not set") } payout(cur, creator.Addr, creatorFee, "creator") payout(cur, state.treasury, protocolFee, "treasury") if creatorRefAmt > 0 { payout(cur, creatorRef, creatorRefAmt, "creator-referrer") } if traderRefAmt > 0 { payout(cur, traderRef, traderRefAmt, "trader-referrer") } } func addShares(holder, creator address, n int) { if n < 1 { panic("amount") } had := getShares(holder, creator) setShares(holder, creator, had+n) if had == 0 { bookOf(creator).Holders++ } } func subShares(holder, creator address, n int) { had := getShares(holder, creator) if n < 1 || had < n { panic("not enough tickets") } setShares(holder, creator, had-n) if had == n { b := bookOf(creator) if b.Holders > 0 { b.Holders-- } } } func quoteTotals(handle, trader string, amount int, sell bool) (curve, creatorFee, protocolFee, referralFee int64) { var creatorRefAmt, traderRefAmt int64 curve, creatorFee, protocolFee, creatorRefAmt, traderRefAmt, _, _, _ = tradeQuote(handle, trader, amount, sell) referralFee = overflow.Add64p(creatorRefAmt, traderRefAmt) return } // tradeQuote splits FeeCapBps of the curve. trader is the buyer or seller. // Empty means an anonymous quote: no trader-referrer slice. // protocolFee is the cap minus the floored slices, so it cannot go negative // while the configured slices fit inside the cap. func tradeQuote(handle, trader string, amount int, sell bool) (curve, creatorFee, protocolFee, creatorRefAmt, traderRefAmt int64, creator *Profile, creatorRef, traderRef address) { creator = lookup(handle) if creator == nil { panic("unknown handle") } b := bookOf(creator.Addr) start := b.Supply if sell { if amount < 1 || amount > MaxTrade { panic("amount") } if amount > b.Supply { panic("not enough tickets") } start = b.Supply - amount } curve = curveCost(start, amount) points := creator.CreatorFee if points < CreatorFeeMinBps || points > CreatorFeeMaxBps { panic("fee") } creatorRef = creator.Referrer traderRef = traderReferrer(trader) total := bps(curve, FeeCapBps) creatorFee = bps(curve, points) if creatorRef.IsValid() { creatorRefAmt = bps(curve, CreatorReferralBps) } if traderRef.IsValid() { traderRefAmt = bps(curve, TraderReferralBps) } used := overflow.Add64p(creatorFee, overflow.Add64p(creatorRefAmt, traderRefAmt)) if used > total { panic("fee") } protocolFee = total - used return } func traderReferrer(raw string) address { raw = strings.TrimSpace(raw) if raw == "" { return address("") } if len(raw) > 60 { panic("bad address") } who := address(raw) if !who.IsValid() { panic("bad address") } p := profileByAddr(who) if p == nil || !p.Referrer.IsValid() { return address("") } return p.Referrer } func bps(amount, points int64) int64 { if amount == 0 || points == 0 { return 0 } if amount < 0 || points < 0 { panic("fee") } return overflow.Mul64p(amount, points) / 10_000 } // curveCost is the ugnot reserve needed to move `amount` tickets starting at supply. // Ticket i (1-based) is priced i² * TicketUnit. The sum of squares is exact. func curveCost(supply, amount int) int64 { if amount < 1 || amount > MaxTrade { panic("amount") } if supply < 0 { panic("supply") } from := int64(supply) to := from + int64(amount) if to > int64(MaxSupply) { panic("supply cap") } return overflow.Mul64p(sumSq(to)-sumSq(from), TicketUnit) } func sumSq(n int64) int64 { if n <= 0 { return 0 } if n > int64(MaxSupply) { panic("supply cap") } // n*(n+1)*(2n+1)/6, dividing 2 and 3 out before multiplying. a := n b := n + 1 if a%2 == 0 { a /= 2 } else { b /= 2 } c := 2*n + 1 if a%3 == 0 { a /= 3 } else if b%3 == 0 { b /= 3 } else if c%3 == 0 { c /= 3 } else { panic("sumSq") } return overflow.Mul64p(overflow.Mul64p(a, b), c) }
- #22/gno.MemPackageType
- #23 MPUserAll
Result log
msg:0,success:true,log:,events:[]