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

reaper.gno

9.36 Kb · 257 lines
  1// Package reaper is a noticeboard whose garbage is a standing bounty.
  2//
  3// Anyone can post a note with an expiry. Posting locks a storage deposit, paid
  4// by the poster. Once a note expires, anyone at all can delete it, and the
  5// chain refunds that deposit to whoever signed the deleting transaction. So
  6// the poster pays to occupy space and a stranger is paid to reclaim it.
  7//
  8// Nothing here is a token, a reward pool or an emission schedule. The incentive
  9// is the chain's own storage accounting, which already works this way for every
 10// realm on gno.land; this realm only makes it legible. Reap and Compact are
 11// permissionless because the expiry predicate is checked on chain, so the
 12// worst a malicious reaper can do is waste their own gas.
 13//
 14// Two collaborators own the parts this realm does not:
 15//
 16//   - gno.land/p/moul/ulist/v1 stores the notes and owns compaction. Its
 17//     Delete is a soft delete that leaves a dead tree node behind, and its
 18//     Compact frees those nodes without moving any live index.
 19//   - gno.land/p/moul/x/storagecost/v0 owns the arithmetic: what a byte
 20//     refunds, and how many bytes a transaction must free to pay for itself.
 21//
 22// The realm deliberately cannot see its own byte count. No stdlib call exposes
 23// a realm's locked storage, so every figure Render shows is an estimate from
 24// payload length. The authoritative numbers are the chain's, in the
 25// StorageDepositEvent and StorageUnlockEvent each transaction emits.
 26package reaper
 27
 28import (
 29	"strings"
 30
 31	"chain/runtime"
 32
 33	"gno.land/p/moul/md/v0"
 34	"gno.land/p/moul/txlink/v0"
 35	"gno.land/p/moul/ulist/v1"
 36	"gno.land/p/moul/x/storagecost/v0"
 37	"gno.land/p/nt/ufmt/v0"
 38)
 39
 40// gasWantedReap is the gas ceiling a reaping transaction is assumed to ask
 41// for, used only to price the advertised bounty. It is the measured cost of a
 42// ten-note reap rounded up; a caller reaping more notes should re-price with
 43// storagecost.EvaluateAtFloor directly rather than trust this.
 44const gasWantedReap int64 = 5_000_000
 45
 46// maxBody caps a note so one poster cannot lock an unbounded deposit in a
 47// single call, which would also make the gas cost of reaping it unpredictable.
 48const maxBody = 4096
 49
 50// Note is one posting. Body is what costs storage; the rest is bookkeeping
 51// that makes the incentive visible in Render.
 52type Note struct {
 53	Body    string
 54	Author  address
 55	Posted  int64 // block height
 56	Expires int64 // block height; reapable once ChainHeight passes it
 57}
 58
 59// notes is append-addressed: an index is stable for the life of the realm,
 60// which is what lets Compact reclaim dead nodes without renumbering.
 61var notes = ulist.New()
 62
 63// Post adds a note that becomes reapable ttl blocks from now, and locks the
 64// storage deposit for it against the caller.
 65//
 66// A ttl of zero is allowed and makes the note reapable immediately, which is
 67// the cheapest way to demonstrate the mechanism.
 68func Post(cur realm, body string, ttl int64) int {
 69	if body == "" {
 70		panic("reaper: empty note")
 71	}
 72	if len(body) > maxBody {
 73		panic(ufmt.Sprintf("reaper: note too long, %d bytes against a %d cap", len(body), maxBody))
 74	}
 75	if ttl < 0 {
 76		panic("reaper: negative ttl")
 77	}
 78	height := runtime.ChainHeight()
 79	notes.Append(&Note{
 80		Body:    body,
 81		Author:  cur.Previous().Address(),
 82		Posted:  height,
 83		Expires: height + ttl,
 84	})
 85	return notes.TotalSize() - 1
 86}
 87
 88// Reap deletes up to limit expired notes and returns how many it deleted.
 89//
 90// Permissionless by design. The storage deposit freed goes to whoever signed
 91// this transaction, so a stranger keeping the board tidy is paid for it out of
 92// the deposits the posters locked. A note that has not expired is skipped, not
 93// refused, so a reaper never has to guess which indices are ripe.
 94//
 95// It walks from the highest index down, which is not cosmetic. In the backing
 96// list the oldest indices are the ancestors of the newest, so a node can only
 97// be freed once everything below it is dead. Reaping oldest-first with a
 98// binding limit therefore never creates a dead tail and leaves Compactable at
 99// zero, stranding the tree structure, which measures at roughly two thirds of
100// what an entry costs. Reaping newest-first makes each batch immediately
101// compactable. Every candidate is expired either way, so the order changes
102// only who gets paid how much, and it is measured: see the ulist package doc.
103func Reap(cur realm, limit int) int {
104	if limit <= 0 {
105		panic("reaper: limit must be positive")
106	}
107	height := runtime.ChainHeight()
108	reaped := 0
109	for i := notes.TotalSize() - 1; i >= 0 && reaped < limit; i-- {
110		n, ok := noteAt(i)
111		if !ok || n.Expires > height {
112			continue
113		}
114		notes.MustDelete(i)
115		reaped++
116	}
117	return reaped
118}
119
120// Compact frees the tree nodes that reaping left behind and returns how many.
121//
122// Also permissionless, and also paid the same way. It is a separate call
123// because it is a separate economic decision, and a surprisingly large one: on
124// chain, compacting a drained board returned about three times what deleting
125// the notes themselves did, because a list node costs more than the note it
126// carries. But it returns nothing at all while any live note sits below the
127// dead ones, so the two calls are worth batching in that order: reap, then
128// compact. Compactable says how much is actually there, for free.
129func Compact(cur realm) int {
130	return notes.Compact()
131}
132
133// Reapable counts the notes that have expired and not yet been reaped.
134func Reapable() int {
135	height := runtime.ChainHeight()
136	count := 0
137	total := notes.TotalSize()
138	for i := 0; i < total; i++ {
139		n, ok := noteAt(i)
140		if ok && n.Expires <= height {
141			count++
142		}
143	}
144	return count
145}
146
147// Compactable reports how many dead tree nodes a Compact would free.
148func Compactable() int { return notes.Compactable() }
149
150// Live counts the notes still holding storage.
151func Live() int { return notes.Size() }
152
153// Bounty prices what is currently on the table for a reaper, at the default
154// storage price and the floor gas price.
155//
156// The byte figure is an estimate from payload length, never the chain's own
157// accounting. Treat it as an advertisement, not a settlement.
158func Bounty() storagecost.Quote {
159	height := runtime.ChainHeight()
160	var payload int64
161	total := notes.TotalSize()
162	for i := 0; i < total; i++ {
163		n, ok := noteAt(i)
164		if ok && n.Expires <= height {
165			payload += int64(len(n.Body))
166		}
167	}
168	return storagecost.EvaluateAtFloor(storagecost.EstimateBytes(payload), gasWantedReap)
169}
170
171// noteAt reads index i, reporting whether a live note is there. A reaped or
172// compacted index reads as absent.
173func noteAt(i int) (*Note, bool) {
174	v := notes.Get(i)
175	if v == nil {
176		return nil, false
177	}
178	n, ok := v.(*Note)
179	return n, ok
180}
181
182func Render(path string) string {
183	var b strings.Builder
184
185	b.WriteString(md.H1("Reaper"))
186	b.WriteString("\nA noticeboard whose garbage is a standing bounty. Posting a note locks a storage deposit. Once the note expires, anyone can delete it and the chain refunds that deposit **to whoever signs the deleting transaction**.\n\n")
187
188	quote := Bounty()
189	b.WriteString(md.H2("On the table right now"))
190	b.WriteString("\n")
191
192	reapable := Reapable()
193	if reapable == 0 {
194		b.WriteString("Nothing has expired. Every note here is still paid for.\n\n")
195	} else {
196		b.WriteString(md.BulletList([]string{
197			ufmt.Sprintf("**%d expired notes**, about %d bytes of state", reapable, quote.Bytes),
198			ufmt.Sprintf("refunds roughly **%s** to the reaper", storagecost.FormatGNOT(quote.Refund)),
199			ufmt.Sprintf("against **%s** of gas at the floor price, break-even at %d bytes", storagecost.FormatGNOT(quote.Fee), quote.BreakEven),
200			ufmt.Sprintf("verdict: **%s**", verdictWord(quote)),
201		}))
202		b.WriteString("\n")
203		b.WriteString(ufmt.Sprintf("[Reap them](%s)\n\n", txlink.Call("Reap", "limit", "100")))
204	}
205
206	if dead := Compactable(); dead > 0 {
207		b.WriteString(md.H2("Compaction available"))
208		b.WriteString(ufmt.Sprintf("\n%d dead tree nodes are reclaimable. Compacting frees fewer bytes per unit of gas than reaping does, so it is worth doing in batches.\n\n[Compact](%s)\n\n",
209			dead, txlink.Call("Compact")))
210	}
211
212	b.WriteString(md.H2("Board"))
213	b.WriteString("\n")
214	if notes.Size() == 0 {
215		b.WriteString("Empty. [Post the first note](" + txlink.Call("Post", "body", "hello", "ttl", "0") + ")\n\n")
216	} else {
217		height := runtime.ChainHeight()
218		rows := []string{}
219		total := notes.TotalSize()
220		for i := 0; i < total; i++ {
221			n, ok := noteAt(i)
222			if !ok {
223				continue
224			}
225			state := ufmt.Sprintf("expires at %d", n.Expires)
226			if n.Expires <= height {
227				state = "**reapable**"
228			}
229			rows = append(rows, ufmt.Sprintf("`#%d` %s | %d bytes by %s, %s",
230				i, summarize(n.Body), len(n.Body), n.Author.String(), state))
231		}
232		b.WriteString(md.BulletList(rows))
233		b.WriteString("\n")
234	}
235
236	b.WriteString(md.HorizontalRule())
237	b.WriteString("\nThe arithmetic is [p/moul/x/storagecost](/p/moul/x/storagecost/v0); the storage is [p/moul/ulist](/p/moul/ulist/v1), whose `Compact` reclaims dead nodes without moving a live index. Byte figures on this page are estimates from payload length: no stdlib call exposes a realm's real locked storage.\n")
238
239	return b.String()
240}
241
242func verdictWord(q storagecost.Quote) string {
243	if q.Worth() {
244		return "worth " + storagecost.FormatGNOT(q.Net)
245	}
246	return "not worth the gas yet"
247}
248
249// summarize keeps the board readable when a note is long.
250func summarize(body string) string {
251	const width = 48
252	oneLine := strings.ReplaceAll(strings.ReplaceAll(body, "\n", " "), "|", " ")
253	if len(oneLine) <= width {
254		return oneLine
255	}
256	return oneLine[:width] + "..."
257}