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

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}