Transaction
440D6A2ECBD288…0D88830DDFDB
Block 227,792 · index 0 · indexed
Summary
- Hash
- 440D6A2ECBD288E9AF08DB164B71083FB02D42615AA52D301D9D0D88830DDFDB
- Block
- 227,792
- Size
- 24266 bytes
- Gas used
- 30,599,199 / 42,985,800
- Fee
- 429858ugnot
- Status
- success
Messages
- Attached funds
- 10000000ugnot
- Package
- gno.land/r/moul/x/reaper/v0
Arguments · 13
- #1reaper
- #2README.md
- #3# `gno.land/r/moul/x/reaper/v0` A noticeboard whose garbage is a standing bounty. Anyone can post a note with an expiry. Posting locks a storage deposit, paid by the poster. Once a note expires, **anyone at all** can delete it, and the chain refunds that deposit to whoever signed the deleting transaction. The poster pays to occupy space; a stranger is paid to reclaim it. There is no token here, no reward pool and no emission schedule. The incentive is the chain's own storage accounting, which already works this way for every realm on gno.land. This realm only makes it legible. ## Why it is safe to let strangers delete things `Reap` and `Compact` are permissionless because the expiry predicate is checked on chain. A reaper cannot delete a note that has not expired, so the worst a malicious caller can do is waste their own gas. That is the general pattern worth taking away: where the predicate for "this is garbage" is cheap to verify on chain, deletion needs no authorization at all, and the protocol is the bounty. The inverse is the warning. The chain pays for destruction, so in any realm whose delete path is *not* predicate-guarded, authorization is the only thing standing between it and profitable vandalism. ## The interface | | | |---|---| | `Post(body, ttl)` | adds a note reapable `ttl` blocks from now, and locks its deposit against you. `ttl` 0 is allowed and is the cheapest demonstration | | `Reap(limit)` | deletes up to `limit` expired notes. Permissionless. The refund goes to you. Unexpired notes are skipped, not refused, so a reaper never has to guess which indices are ripe | | `Compact()` | frees the dead tree nodes reaping left behind. Permissionless, paid the same way | | `Bounty()` | prices what is currently on the table, as a `storagecost.Quote` | | `Reapable()` · `Compactable()` · `Live()` | free reads: the three numbers a bot needs | ## The ordering that turned out to matter `Reap` walks from the **highest index down**, and that is economic rather than cosmetic. In the backing list the oldest indices are the *ancestors* of the newest, and a node can only be freed once everything below it is dead. So a reap that took the oldest notes first, which is the obvious way to drain an expiry queue, would never create a dead tail: `Compactable` stays at zero and the tree structure stays locked. That structure is not a rounding error. Measured on chain with 32 entries of 512 bytes, deleting the notes refunded 8,896 bytes and the subsequent compaction refunded **a further 27,679**, because a list node costs more than the note it carries. Every candidate is expired either way, so the direction changes nothing about what is legal to delete. It only changes how much the reaper gets paid, by about 4x. The measurement is in [`p/moul/ulist`](https://github.com/moul/gno-contracts/tree/main/p/moul/ulist). `Reap` and `Compact` stay separate calls because they are separate decisions, and they are worth batching in that order: compaction returns nothing while a live note still sits below the dead ones. ## What it is built from The realm is thin on purpose. Two packages own the parts it does not: - [`p/moul/ulist`](https://github.com/moul/gno-contracts/tree/main/p/moul/ulist) stores the notes and owns compaction. Its `Delete` is a soft delete that leaves a dead node behind, and its `Compact` frees those nodes without moving a live index. - [`p/moul/x/storagecost`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/storagecost) owns the arithmetic: what a byte refunds, and how many bytes a transaction must free to pay for itself. ## The figures on the page are estimates No stdlib call exposes a realm's own locked storage, so every byte count in `Render` is derived from payload length. Treat the bounty as an advertisement, not a settlement. The authoritative numbers are the chain's, in the `StorageDepositEvent` and `StorageUnlockEvent` each transaction emits. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4example_test.gno
- #5package reaper import "strings" // ExampleRender pins the page's skeleton: the title, the two sections that are // always present, and the footer crediting the two packages that own the parts // this realm does not. // // It deliberately prints only the invariant lines. Everything interesting on // the page is priced from the current block height and the notes outstanding, // and an Example runs after every Test in the package and sees whatever state // they left. The variable half is pinned by TestRenderShowsTheBountyAndTheReapLink // instead, which controls the board first. func ExampleRender() { for _, line := range strings.Split(Render(""), "\n") { switch { case strings.HasPrefix(line, "# "), strings.HasPrefix(line, "## "), strings.HasPrefix(line, "A noticeboard"), strings.HasPrefix(line, "The arithmetic is"): print(line + "\n") } } // Output: // # Reaper // A 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**. // ## On the table right now // ## Board // The 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. }
- #6gnomod.toml
- #7module = "gno.land/r/moul/x/reaper/v0" gno = "0.9"
- #8reaper.gno
- #9// Package reaper is a noticeboard whose garbage is a standing bounty. // // Anyone can post a note with an expiry. Posting locks a storage deposit, paid // by the poster. Once a note expires, anyone at all can delete it, and the // chain refunds that deposit to whoever signed the deleting transaction. So // the poster pays to occupy space and a stranger is paid to reclaim it. // // Nothing here is a token, a reward pool or an emission schedule. The incentive // is the chain's own storage accounting, which already works this way for every // realm on gno.land; this realm only makes it legible. Reap and Compact are // permissionless because the expiry predicate is checked on chain, so the // worst a malicious reaper can do is waste their own gas. // // Two collaborators own the parts this realm does not: // // - gno.land/p/moul/ulist/v1 stores the notes and owns compaction. Its // Delete is a soft delete that leaves a dead tree node behind, and its // Compact frees those nodes without moving any live index. // - gno.land/p/moul/x/storagecost/v0 owns the arithmetic: what a byte // refunds, and how many bytes a transaction must free to pay for itself. // // The realm deliberately cannot see its own byte count. No stdlib call exposes // a realm's locked storage, so every figure Render shows is an estimate from // payload length. The authoritative numbers are the chain's, in the // StorageDepositEvent and StorageUnlockEvent each transaction emits. package reaper import ( "strings" "chain/runtime" "gno.land/p/moul/md/v0" "gno.land/p/moul/txlink/v0" "gno.land/p/moul/ulist/v1" "gno.land/p/moul/x/storagecost/v0" "gno.land/p/nt/ufmt/v0" ) // gasWantedReap is the gas ceiling a reaping transaction is assumed to ask // for, used only to price the advertised bounty. It is the measured cost of a // ten-note reap rounded up; a caller reaping more notes should re-price with // storagecost.EvaluateAtFloor directly rather than trust this. const gasWantedReap int64 = 5_000_000 // maxBody caps a note so one poster cannot lock an unbounded deposit in a // single call, which would also make the gas cost of reaping it unpredictable. const maxBody = 4096 // Note is one posting. Body is what costs storage; the rest is bookkeeping // that makes the incentive visible in Render. type Note struct { Body string Author address Posted int64 // block height Expires int64 // block height; reapable once ChainHeight passes it } // notes is append-addressed: an index is stable for the life of the realm, // which is what lets Compact reclaim dead nodes without renumbering. var notes = ulist.New() // Post adds a note that becomes reapable ttl blocks from now, and locks the // storage deposit for it against the caller. // // A ttl of zero is allowed and makes the note reapable immediately, which is // the cheapest way to demonstrate the mechanism. func Post(cur realm, body string, ttl int64) int { if body == "" { panic("reaper: empty note") } if len(body) > maxBody { panic(ufmt.Sprintf("reaper: note too long, %d bytes against a %d cap", len(body), maxBody)) } if ttl < 0 { panic("reaper: negative ttl") } height := runtime.ChainHeight() notes.Append(&Note{ Body: body, Author: cur.Previous().Address(), Posted: height, Expires: height + ttl, }) return notes.TotalSize() - 1 } // Reap deletes up to limit expired notes and returns how many it deleted. // // Permissionless by design. The storage deposit freed goes to whoever signed // this transaction, so a stranger keeping the board tidy is paid for it out of // the deposits the posters locked. A note that has not expired is skipped, not // refused, so a reaper never has to guess which indices are ripe. // // It walks from the highest index down, which is not cosmetic. In the backing // list the oldest indices are the ancestors of the newest, so a node can only // be freed once everything below it is dead. Reaping oldest-first with a // binding limit therefore never creates a dead tail and leaves Compactable at // zero, stranding the tree structure, which measures at roughly two thirds of // what an entry costs. Reaping newest-first makes each batch immediately // compactable. Every candidate is expired either way, so the order changes // only who gets paid how much, and it is measured: see the ulist package doc. func Reap(cur realm, limit int) int { if limit <= 0 { panic("reaper: limit must be positive") } height := runtime.ChainHeight() reaped := 0 for i := notes.TotalSize() - 1; i >= 0 && reaped < limit; i-- { n, ok := noteAt(i) if !ok || n.Expires > height { continue } notes.MustDelete(i) reaped++ } return reaped } // Compact frees the tree nodes that reaping left behind and returns how many. // // Also permissionless, and also paid the same way. It is a separate call // because it is a separate economic decision, and a surprisingly large one: on // chain, compacting a drained board returned about three times what deleting // the notes themselves did, because a list node costs more than the note it // carries. But it returns nothing at all while any live note sits below the // dead ones, so the two calls are worth batching in that order: reap, then // compact. Compactable says how much is actually there, for free. func Compact(cur realm) int { return notes.Compact() } // Reapable counts the notes that have expired and not yet been reaped. func Reapable() int { height := runtime.ChainHeight() count := 0 total := notes.TotalSize() for i := 0; i < total; i++ { n, ok := noteAt(i) if ok && n.Expires <= height { count++ } } return count } // Compactable reports how many dead tree nodes a Compact would free. func Compactable() int { return notes.Compactable() } // Live counts the notes still holding storage. func Live() int { return notes.Size() } // Bounty prices what is currently on the table for a reaper, at the default // storage price and the floor gas price. // // The byte figure is an estimate from payload length, never the chain's own // accounting. Treat it as an advertisement, not a settlement. func Bounty() storagecost.Quote { height := runtime.ChainHeight() var payload int64 total := notes.TotalSize() for i := 0; i < total; i++ { n, ok := noteAt(i) if ok && n.Expires <= height { payload += int64(len(n.Body)) } } return storagecost.EvaluateAtFloor(storagecost.EstimateBytes(payload), gasWantedReap) } // noteAt reads index i, reporting whether a live note is there. A reaped or // compacted index reads as absent. func noteAt(i int) (*Note, bool) { v := notes.Get(i) if v == nil { return nil, false } n, ok := v.(*Note) return n, ok } func Render(path string) string { var b strings.Builder b.WriteString(md.H1("Reaper")) 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") quote := Bounty() b.WriteString(md.H2("On the table right now")) b.WriteString("\n") reapable := Reapable() if reapable == 0 { b.WriteString("Nothing has expired. Every note here is still paid for.\n\n") } else { b.WriteString(md.BulletList([]string{ ufmt.Sprintf("**%d expired notes**, about %d bytes of state", reapable, quote.Bytes), ufmt.Sprintf("refunds roughly **%s** to the reaper", storagecost.FormatGNOT(quote.Refund)), ufmt.Sprintf("against **%s** of gas at the floor price, break-even at %d bytes", storagecost.FormatGNOT(quote.Fee), quote.BreakEven), ufmt.Sprintf("verdict: **%s**", verdictWord(quote)), })) b.WriteString("\n") b.WriteString(ufmt.Sprintf("[Reap them](%s)\n\n", txlink.Call("Reap", "limit", "100"))) } if dead := Compactable(); dead > 0 { b.WriteString(md.H2("Compaction available")) 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", dead, txlink.Call("Compact"))) } b.WriteString(md.H2("Board")) b.WriteString("\n") if notes.Size() == 0 { b.WriteString("Empty. [Post the first note](" + txlink.Call("Post", "body", "hello", "ttl", "0") + ")\n\n") } else { height := runtime.ChainHeight() rows := []string{} total := notes.TotalSize() for i := 0; i < total; i++ { n, ok := noteAt(i) if !ok { continue } state := ufmt.Sprintf("expires at %d", n.Expires) if n.Expires <= height { state = "**reapable**" } rows = append(rows, ufmt.Sprintf("`#%d` %s | %d bytes by %s, %s", i, summarize(n.Body), len(n.Body), n.Author.String(), state)) } b.WriteString(md.BulletList(rows)) b.WriteString("\n") } b.WriteString(md.HorizontalRule()) 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") return b.String() } func verdictWord(q storagecost.Quote) string { if q.Worth() { return "worth " + storagecost.FormatGNOT(q.Net) } return "not worth the gas yet" } // summarize keeps the board readable when a note is long. func summarize(body string) string { const width = 48 oneLine := strings.ReplaceAll(strings.ReplaceAll(body, "\n", " "), "|", " ") if len(oneLine) <= width { return oneLine } return oneLine[:width] + "..." }
- #10reaper_test.gno
- #11package reaper import ( "strings" "testing" "gno.land/p/moul/x/storagecost/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( poster = testutils.TestAddress("poster") // stranger never posts, it only deletes. The whole point of the realm is // that this is the address the chain pays. stranger = testutils.TestAddress("stranger") ) // ttlFuture is the ttl used for a note that must not be reapable yet. It is a // named constant because clear has to outrun it. const ttlFuture = 1000 // clear empties the board so a test starts from a known state, whatever ran // before it. // // gno resets the block height for each test function but keeps realm state, so // a test that left an unexpired note behind cannot be cleaned up by a later // test skipping heights: the later test's clock starts over. Every test that // posts a future-dated note therefore drains it before returning, and clear // asserts an empty board rather than trusting that. // // clear cannot reset TotalSize, which is append-addressed for the life of the // realm, so tests assert on Live and Reapable and never on absolute indices. func clear(cur realm, t *testing.T) { t.Helper() drain(cur) uassert.Equal(t, 0, Live()) uassert.Equal(t, 0, Reapable()) } // drain expires everything outstanding and reaps it. func drain(cur realm) { testing.SkipHeights(ttlFuture + 1) testing.SetRealm(testing.NewUserRealm(stranger)) for Reapable() > 0 { Reap(cross(cur), 1000) } Compact(cross(cur)) } func TestPostLocksAndReapFrees(cur realm, t *testing.T) { clear(cur, t) testing.SetRealm(testing.NewUserRealm(poster)) Post(cross(cur), "first", 0) Post(cross(cur), "second", 0) uassert.Equal(t, 2, Live()) uassert.Equal(t, 2, Reapable()) // A stranger who posted nothing may reap, and that is the point. testing.SetRealm(testing.NewUserRealm(stranger)) uassert.Equal(t, 2, Reap(cross(cur), 100)) uassert.Equal(t, 0, Live()) uassert.Equal(t, 0, Reapable()) } func TestReapSkipsUnexpiredAndHonoursLimit(cur realm, t *testing.T) { clear(cur, t) testing.SetRealm(testing.NewUserRealm(poster)) Post(cross(cur), "ripe one", 0) Post(cross(cur), "ripe two", 0) Post(cross(cur), "not yet", ttlFuture) uassert.Equal(t, 3, Live()) uassert.Equal(t, 2, Reapable()) // The limit caps the work, so a reaper can size a transaction to its gas. testing.SetRealm(testing.NewUserRealm(stranger)) uassert.Equal(t, 1, Reap(cross(cur), 1)) uassert.Equal(t, 1, Reapable()) // The unexpired note survives a reap that asks for everything. uassert.Equal(t, 1, Reap(cross(cur), 100)) uassert.Equal(t, 0, Reapable()) uassert.Equal(t, 1, Live()) // Leave nothing behind: the next test's height starts over and could not // expire this note. drain(cur) } func TestReapOfNothingIsNotAnError(cur realm, t *testing.T) { clear(cur, t) // A bot that races another bot to the same notes must not revert, or it // loses its gas to an abort instead of merely earning nothing. testing.SetRealm(testing.NewUserRealm(stranger)) uassert.Equal(t, 0, Reap(cross(cur), 100)) } func TestCompactFollowsReaping(cur realm, t *testing.T) { clear(cur, t) testing.SetRealm(testing.NewUserRealm(poster)) for i := 0; i < 8; i++ { Post(cross(cur), "note", 0) } // Nothing is reclaimable until something has been reaped. uassert.Equal(t, 0, Compactable()) testing.SetRealm(testing.NewUserRealm(stranger)) uassert.Equal(t, 8, Reap(cross(cur), 100)) uassert.True(t, Compactable() > 0) freed := Compact(cross(cur)) uassert.True(t, freed > 0) uassert.Equal(t, 0, Compactable()) // Compacting twice is not an error, it just frees nothing. uassert.Equal(t, 0, Compact(cross(cur))) } func TestPostRejectsBadInput(cur realm, t *testing.T) { clear(cur, t) testing.SetRealm(testing.NewUserRealm(poster)) uassert.AbortsWithMessage(t, cur, "reaper: empty note", func() { Post(cross(cur), "", 0) }) uassert.AbortsWithMessage(t, cur, "reaper: negative ttl", func() { Post(cross(cur), "fine", -1) }) // The cap keeps one call from locking an unbounded deposit. uassert.AbortsWithMessage(t, cur, "reaper: note too long, 4097 bytes against a 4096 cap", func() { Post(cross(cur), strings.Repeat("x", maxBody+1), 0) }) uassert.AbortsWithMessage(t, cur, "reaper: limit must be positive", func() { Reap(cross(cur), 0) }) } func TestBountyPricesWhatIsOnTheTable(cur realm, t *testing.T) { clear(cur, t) // An empty board advertises nothing, and must not claim a profit. empty := Bounty() uassert.Equal(t, int64(0), empty.Bytes) uassert.Equal(t, int64(0), empty.Refund) uassert.False(t, empty.Worth()) testing.SetRealm(testing.NewUserRealm(poster)) body := strings.Repeat("x", 1024) for i := 0; i < 10; i++ { Post(cross(cur), body, 0) } // Ten 1 KB notes, priced through the same estimate the library documents. q := Bounty() uassert.Equal(t, storagecost.EstimateBytes(10*1024), q.Bytes) uassert.Equal(t, storagecost.Refund(q.Bytes, storagecost.DefaultStoragePrice), q.Refund) uassert.True(t, q.Worth()) // Once reaped, nothing is on the table. testing.SetRealm(testing.NewUserRealm(stranger)) Reap(cross(cur), 100) uassert.Equal(t, int64(0), Bounty().Bytes) } func TestRenderShowsTheBountyAndTheReapLink(cur realm, t *testing.T) { clear(cur, t) // Empty board: an invitation, and no bounty claimed. out := Render("") uassert.True(t, strings.Contains(out, "# Reaper"), out) uassert.True(t, strings.Contains(out, "Nothing has expired"), out) uassert.True(t, strings.Contains(out, "Post the first note"), out) testing.SetRealm(testing.NewUserRealm(poster)) Post(cross(cur), strings.Repeat("y", 1024), 0) out = Render("") uassert.True(t, strings.Contains(out, "1 expired notes"), out) uassert.True(t, strings.Contains(out, "refunds roughly **0.1894 GNOT**"), out) // The reap link must be a help link for the right function with the right // argument, or the button on the page does nothing useful. txlink builds // it relative to the current realm, so there is no path to get wrong. uassert.True(t, strings.Contains(out, "$help&func=Reap&limit=100"), out) // The author is named, which is what makes "the poster paid" visible. uassert.True(t, strings.Contains(out, poster.String()), out) uassert.True(t, strings.Contains(out, "**reapable**"), out) // A long note is summarized rather than dumped into the table. uassert.True(t, strings.Contains(out, "..."), out) uassert.False(t, strings.Contains(out, strings.Repeat("y", 200)), "body should be truncated") } func TestRenderNeverEmitsTwoBlankLines(cur realm, t *testing.T) { // gno collapses two consecutive blank lines, so output containing them can // never be pinned by an Example. This test is what lets ExampleRender stay // meaningful. clear(cur, t) testing.SetRealm(testing.NewUserRealm(poster)) Post(cross(cur), "one", 0) Post(cross(cur), "two", ttlFuture) testing.SetRealm(testing.NewUserRealm(stranger)) Reap(cross(cur), 1) for _, path := range []string{"", "anything"} { out := Render(path) uassert.False(t, strings.Contains(out, "\n\n\n"), "blank-line run in Render("+path+")") } drain(cur) } func TestReapWalksNewestFirstSoCompactionHasWork(cur realm, t *testing.T) { // The ordering is economic, not cosmetic: in the backing list the oldest // indices are the ancestors of the newest, so a partial reap that took the // oldest first would leave nothing compactable and strand the tree // structure, which costs more than the notes do. clear(cur, t) testing.SetRealm(testing.NewUserRealm(poster)) for i := 0; i < 16; i++ { Post(cross(cur), "note", 0) } first := notes.TotalSize() - 16 last := notes.TotalSize() - 1 testing.SetRealm(testing.NewUserRealm(stranger)) uassert.Equal(t, 4, Reap(cross(cur), 4)) // The four highest indices went, and the four lowest survived. uassert.Equal(t, nil, notes.Get(last)) uassert.True(t, notes.Get(first) != nil) // Which is the whole point: there is something to compact after one batch. uassert.True(t, Compactable() > 0) drain(cur) }
- #12/gno.MemPackageType
- #13 MPUserAll
Result log
msg:0,success:true,log:,events:[]