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

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}