threads.gno
9.34 Kb · 262 lines
1// Package threads is a discussion block any realm can embed, keyed on the page
2// it appears on rather than on the realm that stores it.
3//
4// It is the text half of gno.land/r/moul/reactions: the same embed shape, the
5// same page keys, two lines in the host realm.
6//
7// import "gno.land/r/moul/x/social/threads/v0"
8//
9// func Render(path string) string {
10// return body + threads.RenderBlock()
11// }
12//
13// [RenderBlock] takes no argument because it does not need one: a plain read
14// exported by a realm and called by another opens no realm frame, so
15// unsafe.CurrentRealm() inside it reports the CALLER. The host realm's own
16// path is the page key, and nothing has to be configured or registered.
17// [RenderBlockFor] is the explicit form, for a realm that renders more than
18// one page or already holds its path as a constant.
19//
20// # The token, and the three things it has to answer
21//
22// THREAD exists because placement is the one thing a discussion has that
23// people want and cannot all have at once.
24//
25// mint 1 THREAD to a thread's author, the first time each distinct
26// address replies to it. People, not messages, and never yourself.
27// sink [Boost] burns PinCost THREAD to pin a thread to the top of its
28// page for PinBlocks blocks. That is the only thing that removes
29// supply, and the only thing the token does.
30// buyer anyone who wants placement and has not earned it. They have to
31// acquire THREAD from someone who did, with [Transfer], which is
32// what makes attention a market rather than a scoreboard.
33//
34// It is farmable, and saying so is cheaper than pretending otherwise: two
35// addresses replying to each other mint one THREAD each per thread. The fix is
36// a gate on who counts as a replier, which belongs in a vouching realm and not
37// here; until then the cost of farming is one transaction per point and the
38// benefit is capped by what a pin is worth.
39package threads
40
41import (
42 "chain"
43 "chain/runtime"
44 "chain/runtime/unsafe"
45 "strconv"
46
47 "gno.land/p/moul/kit/store/v0"
48 sc "gno.land/p/moul/x/social/coin/v0"
49 th "gno.land/p/moul/x/social/threads/v0"
50 "gno.land/r/nt/grc20reg/v0"
51)
52
53// realmPath is this realm's own path, the one its gnomod.toml module line
54// declares. It is written out rather than read from unsafe.CurrentRealm(),
55// which reports the CALLER in every plain read this realm exports and would
56// therefore build every link against whichever realm embedded the block.
57const realmPath = "gno.land/r/moul/x/social/threads/v0"
58
59const (
60 // EarnPerReplier is minted to a thread's author the first time each
61 // distinct address replies to it.
62 EarnPerReplier = int64(1)
63
64 // PinCost is burned by Boost, and PinBlocks is how long the pin lasts.
65 // A pin extends from whichever is later, now or the current pin, so
66 // buying one never shortens somebody else's.
67 PinCost = int64(10)
68 PinBlocks = int64(1000)
69
70 // BlockThreads is how many threads an embedded block shows before it
71 // turns into a link to the full page.
72 BlockThreads = 5
73)
74
75var (
76 // board holds every thread. A redeploy would wipe it, which is why this
77 // realm is not private (see gnomod.toml).
78 board = th.NewBoard()
79
80 // points is the THREAD ledger. The Coin is unexported on purpose: it
81 // carries unrestricted mint and burn, and an exported pointer to it
82 // would hand both to any importer.
83 points *sc.Coin
84
85 // tokenKey is the r/nt/grc20reg lookup key, which is how a realm that
86 // does not import this one refers to THREAD.
87 tokenKey string
88)
89
90// tokenPolicy is the three declarations THREAD is issued under. It is a var
91// and not three literals inside init so that the tests, which rebuild the
92// ledger between cases, issue the same token the chain holds.
93var tokenPolicy = sc.Policy{
94 Mint: "1 to a thread's author per distinct address that replies to it",
95 Sink: "burned by Boost to pin a thread to the top of its page",
96 Buyer: "anyone who wants placement and has not earned it",
97}
98
99func init(cur realm) {
100 points = sc.New("Thread Points", "THREAD", 0, 0, tokenPolicy, 0, cur)
101 tokenKey = grc20reg.Register(cross(cur), points.Token(), "thread")
102}
103
104// Post opens a thread under page.
105//
106// page is a package path, the full one: "gno.land/r/moul/home". It does not
107// have to be a realm that embeds the block, and nothing checks that it exists.
108// A page is just a name, which is what lets a realm key discussion on
109// something finer than itself.
110func Post(cur realm, page, body string) int64 {
111 who := caller(cur)
112 id, err := board.Post(page, who, body, runtime.ChainHeight())
113 if err != nil {
114 panic(err.Error())
115 }
116 chain.Emit("Post", "id", id.String(), "page", page, "author", who.String())
117 return int64(id)
118}
119
120// Reply appends to a thread.
121//
122// The first time each distinct address replies to a given thread, its author
123// earns EarnPerReplier. Replying again earns nothing, and replying to your own
124// thread never earns anything.
125func Reply(cur realm, threadID int64, body string) {
126 who := caller(cur)
127 id := store.ID(threadID)
128 isNew, err := board.Reply(id, who, body, runtime.ChainHeight())
129 if err != nil {
130 panic(err.Error())
131 }
132 chain.Emit("Reply", "id", id.String(), "author", who.String())
133
134 if isNew {
135 t, _ := board.Get(id)
136 points.Earn(t.Author, EarnPerReplier)
137 chain.Emit("Earn", "id", id.String(), "author", t.Author.String())
138 }
139}
140
141// Boost burns PinCost THREAD from the caller and pins the thread to the top of
142// its page for PinBlocks blocks.
143//
144// Anyone may boost anyone's thread. The pin is on the thread and not on the
145// buyer, so the thing being sold is placement and not authorship.
146func Boost(cur realm, threadID int64) {
147 who := caller(cur)
148 id := store.ID(threadID)
149 t, ok := board.Get(id)
150 if !ok {
151 panic(th.ErrNoThread.Error())
152 }
153
154 now := runtime.ChainHeight()
155 from := now
156 if t.PinnedUntil > from {
157 from = t.PinnedUntil
158 }
159 // Charge first, pin second. The chain would revert both on an abort, so
160 // the order is invisible on mainnet and load-bearing everywhere else: a
161 // test, a filetest and any future caller that recovers see a refused
162 // boost that pinned nothing.
163 points.Spend(who, PinCost)
164 if err := board.Pin(id, from+PinBlocks); err != nil {
165 panic(err.Error())
166 }
167 chain.Emit("Boost", "id", id.String(), "by", who.String(),
168 "until", strconv.FormatInt(from+PinBlocks, 10))
169}
170
171// Transfer moves the caller's own THREAD. It is what makes the sink a market:
172// somebody who wants a pin and has written nothing has to buy from an author.
173func Transfer(cur realm, to address, amount int64) {
174 if err := points.CallerTeller().Transfer(0, cur, to, amount); err != nil {
175 panic(err.Error())
176 }
177}
178
179// Approve lets spender draw amount of the caller's THREAD.
180func Approve(cur realm, spender address, amount int64) {
181 if err := points.CallerTeller().Approve(0, cur, spender, amount); err != nil {
182 panic(err.Error())
183 }
184}
185
186// TransferFrom spends an allowance the caller was granted.
187func TransferFrom(cur realm, from, to address, amount int64) {
188 if err := points.CallerTeller().TransferFrom(0, cur, from, to, amount); err != nil {
189 panic(err.Error())
190 }
191}
192
193// RenderBlock returns the block for the realm CALLING it, which is the form a
194// host realm embeds.
195//
196// It has no cur realm parameter on purpose. A plain read is borrowed: gno
197// opens no realm frame for it, so unsafe.CurrentRealm() reports the caller's
198// path and the block keys itself with no argument. Adding a cur realm here
199// would silently reverse that and key every embed to this realm instead.
200func RenderBlock() string {
201 return blockFor(unsafe.CurrentRealm().PkgPath())
202}
203
204// RenderBlockFor is [RenderBlock] for a named page. Use it from a crossing
205// function, where there is no caller to read off the stack, or when one realm
206// renders several pages.
207func RenderBlockFor(page string) string { return blockFor(page) }
208
209// blockFor is the shared body, so no exported read of this realm calls another
210// and picks up the wrong path.
211func blockFor(page string) string {
212 now := runtime.ChainHeight()
213 return th.Block(realmPath, page, board.List(page, now, BlockThreads), board.PageLen(page), now)
214}
215
216// Count is how many threads a page holds.
217func Count(page string) int { return board.PageLen(page) }
218
219// Total is how many threads exist, across every page.
220func Total() int { return board.Len() }
221
222// Pages is how many pages have ever been posted on.
223func Pages() int { return board.Pages() }
224
225// Repliers is how many distinct addresses have replied to a thread, which is
226// also how much its author has earned from it.
227func Repliers(threadID int64) int {
228 t, _ := board.Get(store.ID(threadID))
229 return t.Repliers()
230}
231
232// PinnedUntil is the height a thread stops being pinned at, or 0.
233func PinnedUntil(threadID int64) int64 {
234 t, ok := board.Get(store.ID(threadID))
235 if !ok {
236 return 0
237 }
238 return t.PinnedUntil
239}
240
241// BalanceOf is an address's THREAD balance.
242func BalanceOf(owner address) int64 { return points.BalanceOf(owner) }
243
244// Allowance is what owner let spender draw.
245func Allowance(owner, spender address) int64 {
246 return points.Token().Allowance(owner, spender)
247}
248
249// TotalSupply is the THREAD in existence: everything earned, less everything
250// burned by Boost.
251func TotalSupply() int64 { return points.Supply() }
252
253// TokenKey is the r/nt/grc20reg key for THREAD.
254func TokenKey() string { return tokenKey }
255
256// caller is the address that called us, checked the one way that is safe.
257func caller(cur realm) address {
258 if !cur.IsCurrent() {
259 panic("spoofed realm: cur is not the live crossing frame")
260 }
261 return cur.Previous().Address()
262}