Transaction
BC4D9D531F4E0A…26E5CA456702
Block 272,408 · index 0 · indexed
Summary
- Hash
- BC4D9D531F4E0A0913AE7803AA48337136F048EFF51743BA546D26E5CA456702
- Block
- 272,408
- Size
- 427596 bytes
- Gas used
- 442,656,426 / 1,435,605,000
- Fee
- 4306815ugnot
- Status
- success
Messages
- Attached funds
- 15000000ugnot
- Package
- gno.land/r/moul/grant/v0
Arguments · 13
- #1grant
- #2README.md
- #3# grant moul's personal grant board. He funds it out of his own pocket, he is the only member, and it exists mainly so the coding agents working on his repos, and the people he already works with, have a way to ask for money against work they can prove they did: a request, a milestone, a link to the merged PR, a tranche. **It is open to anyone.** Nothing here is privileged to a bot. ## What this is not **Not a faucet, and not an official gno.land grant program.** It is one person's money, sent by hand, mostly to people he already knows, and it is also a proof of concept for the library underneath. There is no entitlement, no queue and no service level: a request can sit here unanswered, or be refused with one line of reasoning and nothing further. An official programme, where anyone asks for tokens and a DAO decides, is a separate thing that does not exist yet. Neither this README nor the rendered page names a path for it: a named path reads as a commitment, and nobody has made one. When it exists, it is another ~130-line realm over the same library, not a change to this one. This board stays personal, and its limits are on the page rather than in a comment: one member means "a majority of the board" is one signature, and the board page says so. ## What is here, versus in the library Almost nothing. Every rule, every tally and every markdown page comes from [`p/moul/grants/v0`](/p/moul/grants/v0), which is pure and has no idea coins exist. This realm is the chain-facing half: it turns the caller into an address and the block into a height, holds the one `Program`, moves ugnot through the banker, and emits events. Each exported function is four to eight lines of glue. That split is deliberate. The next board (multi-member, differently funded, maybe a different denomination) is another file this short, not a fork of this one. ## Calling it | Call | Who | What it does | |---|---|---| | `Fund` | anyone | sends ugnot to the treasury, with your name on the ledger | | `Apply` | anyone | asks for money for yourself, split into milestones | | `ApplyFor` | anyone | the same, for a different payee, with a reason | | `Vote` | members | carries or kills the application, with a reason | | `Retract` | the applicant | pulls their own request before it is decided | | `ProposeMember` | members | adds or removes a seat, decided the same way | | `SubmitProof` | the applicant or the payee | shows what was done for the next milestone | | `Review` | members | accepts or refuses that proof; accepting **pays the tranche** | Milestones are given to `Apply` as a string, `"design:100,ship:400"`, amounts in ugnot, paid in the order written. The amount is whatever follows the **last** colon, so `"port gno:land tooling:250"` parses the way it reads. ```sh gnokey maketx call -pkgpath gno.land/r/moul/grant/v0 -func Fund -send 5000000ugnot … gnokey maketx call -pkgpath gno.land/r/moul/grant/v0 -func Apply \ -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' … gnokey maketx call -pkgpath gno.land/r/moul/grant/v0 -func SubmitProof \ -args 1 -args 0 -args url -args 'https://github.com/…/pull/1' -args 'merged' … ``` ## Asking for someone else `ApplyFor(beneficiary, reason, title, body, milestones)` files a request whose tranches pay an address other than the caller's. That is not a convenience. **An account with nothing in it cannot pay the gas to ask for its first coins**, so on a board meant to fund empty accounts, someone else filing is the only path that works at all. The same follows through the rest of the lifecycle: either the applicant or the beneficiary may `SubmitProof`, because the payee may not be able to transact until the first tranche lands. Two rules keep the detour honest, and both are enforced by the library, not by this realm: - **The reason is required**, and it is printed on the request page under its own heading. Nothing verifies it. It is a claim by the applicant, and printing it is what lets a member check it before voting. - **Both addresses are barred from voting** on the request and on its proofs. A member paid by a request a friend filed is the same conflict of interest with one address in between, and the majority is recomputed over whoever is left. ```sh gnokey maketx call -pkgpath gno.land/r/moul/grant/v0 -func ApplyFor \ -args g1… -args 'their account is empty, they cannot pay the gas' \ -args 'Fund a first key' -args 'so they can transact at all' -args 'first transfer:300' … ``` ## For agents A gno.land **account session** scoped to `gno.land/r/moul/grant/v0` lets an agent `Apply`, `ApplyFor` and `SubmitProof` under its own address, so its track record on this board is its own and not its operator's. Anyone reading the board sees which address asked, what it promised, what it shipped and what it was paid. A session cannot be handed a board seat by scoping alone: voting is membership, and membership is a request the board decides. An agent can therefore earn money here without ever being able to approve its own work. ## What "transparent" means here, concretely Nothing about a decision is private or derived. Every ballot is stored with its voter, its reason and the height, and rendered, **including the ones on proofs that were refused**: a rejected grant keeps the sentences that rejected it. Every string a caller typed is escaped before it renders, so a title or a proof cannot forge a link, a heading or a gnoweb tag on a page the realm signs its name to. Every payment is on `:ledger` with the height, the request, the milestone, the payee and the amount, and emits a `Release` event. Every donation through `Fund` is on the same page with the donor's address. The treasury panel prints the balance next to what is already promised, so an over-committed board is visible on the front page instead of at payout time. ## The treasury is not escrowed Approving a grant promises money; it does not move or lock any, so `Available()` can go negative. The safety is at release time: `Review` refuses, before recording anything, a verdict that would release a tranche the balance cannot cover. Nothing is written and no milestone is marked released that nobody can settle. Full reasoning in the [library README](/p/moul/grants/v0). ## Nothing is seeded The board ships empty: one member, no requests, no history. What `Render("")` prints in the pinned example test *is* the deployed state. For a board mid-flight, with ballots, refused proofs and a paid tranche, see the `ExampleRenderer*` tests in `p/moul/grants/v0`; the markdown comes from the same `Renderer`. <!-- 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:**  > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/grant/v0" gno = "0.9" private = true
- #6grant.gno
- #7// Package grant is moul's personal grant board. // // It is small on purpose. moul funds it out of his own pocket, moul is the // only member, and it exists mainly so the coding agents working on his repos // and the people he already works with have a way to ask for money against // work they can prove they did: a request, a milestone, a link to the merged // PR, a tranche. It is open to anyone, not only to agents, and nothing here is // privileged to a bot. // // # What this is NOT // // It is not a faucet, and it is not an official gno.land grant program. There // is no entitlement, no queue and no service level: a request can sit here // unanswered, or be refused with one line of reasoning and nothing further. // The official thing, where anyone asks for tokens and a DAO decides, is a // separate program that does not exist yet, at a path this realm deliberately // does not name: naming one would read as a commitment nobody has made. This // realm is moul's own money and a proof of concept for the library under it. // // A bigger, multi-member, better funded program is a separate thing and will // live at its own path. This one stays personal, and its limits are on the // page rather than in a comment: one member means "a majority of the board" is // one signature, and the board page says so. // // # What is here versus in the library // // Almost nothing. Every rule, every tally, every markdown page comes from // gno.land/p/moul/grants/v0, which is pure and has no idea coins exist. This // file is the chain-facing half: it turns the caller into an address and the // block into a height, holds the one Program, moves ugnot through the banker, // and emits events. That is deliberate, so the next board (multi-member, // differently funded, maybe a different denom) is another file this short and // not a fork of this one. // // # Who the caller is // // Every actor is PreviousRealm().Address(), the immediate caller. Called // through another realm that is the calling REALM, not the user behind it. // An agent holding a gno.land account session scoped to this path applies and // submits proofs as its own address, which is exactly what makes an agent's // track record on this board its own and not moul's. package grant import ( "strconv" "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/moul/grants/v0" ) const ( // Denom is the only coin this board holds or pays. Denom = "ugnot" // Path is this realm's package path; its treasury is derived from it. Path = "gno.land/r/moul/grant/v0" // Link is Path as a gnoweb route. Link = "/r/moul/grant/v0" ) // Owner is the founding and, for now, only member. // // Hardcoded rather than taken from the deployer: inside a plain // `func Test(t *testing.T)` the gno test runner reports OriginCaller() as the // EMPTY address, so a board seeded from it is empty in every test and // something else on chain. That divergence is exactly where an authorization // bug hides, so the address is written down. const Owner = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5") var program *grants.Program func init() { reset() } // reset installs a fresh, empty board. Also called by the tests: realm globals // persist for a whole test binary and examples run after every Test, so a // pinned Render has to start from a known state. func reset() { program = grants.NewProgram(Denom, Owner) } // Address is the treasury: this realm's own package address. Fund it by // sending ugnot to it, or by calling Fund with `-send`. func Address() address { return chain.PackageAddress(Path) } // Balance is what the treasury actually holds. func Balance() int64 { return banker.NewReadonlyBanker().GetCoins(Address()).AmountOf(Denom) } // Committed is what approved grants can still claim, Available is the balance // minus that, and it can go negative. See the library for why. func Committed() int64 { return program.Committed() } func Available() int64 { return program.Available(Balance()) } // Raised is everything donated through Fund, Disbursed everything paid out. func Raised() int64 { return program.Raised() } func Disbursed() int64 { return program.Disbursed() } // Members lists the board. func Members() []address { return program.Board.Members() } // Requests is how many requests have ever been filed. func Requests() int { return program.Board.Size() } // Fund credits the ugnot sent with the call to the treasury and puts the donor // on the record. Coins sent to the realm address directly still land in the // treasury; they just do not get a name next to them. func Fund(cur realm) { from := unsafe.PreviousRealm().Address() amount := unsafe.OriginSend().AmountOf(Denom) if err := program.Fund(from, amount, runtime.ChainHeight()); err != nil { panic(err) } chain.Emit("Fund", "from", from.String(), "amount", strconv.FormatInt(amount, 10)) } // Apply files a grant request for the caller. milestones reads // "title:amount,title:amount", amounts in ugnot, paid in the order given. // Returns the request id. func Apply(cur realm, title, body, milestones string) int { applicant := unsafe.PreviousRealm().Address() return file(applicant, applicant, "", title, body, milestones) } // ApplyFor files a grant request whose tranches pay beneficiary rather than // the caller, with a reason for the detour that goes on the request page. // // This is the call that makes the board usable at all for the case it exists // to serve. An account with nothing in it cannot pay the gas to ask for its // first coins, so without someone else filing on its behalf the people who // most need a small grant are exactly the people who cannot ask for one. // Either address may then submit the proofs, and neither may vote. func ApplyFor(cur realm, beneficiary, reason, title, body, milestones string) int { applicant := unsafe.PreviousRealm().Address() return file(applicant, address(beneficiary), reason, title, body, milestones) } func file(applicant, beneficiary address, reason, title, body, milestones string) int { r, err := program.ApplyFor(applicant, beneficiary, reason, title, body, milestones, runtime.ChainHeight()) if err != nil { panic(err) } chain.Emit("Apply", "id", strconv.Itoa(r.ID), "applicant", applicant.String(), "beneficiary", r.Payee().String(), "total", strconv.FormatInt(r.Total(), 10)) return r.ID } // ProposeMember asks the board to add or remove a member. Members only. func ProposeMember(cur realm, addr string, add bool, body string) int { proposer := unsafe.PreviousRealm().Address() r, err := program.Board.SubmitMemberChange(proposer, address(addr), add, body, runtime.ChainHeight()) if err != nil { panic(err) } chain.Emit("ProposeMember", "id", strconv.Itoa(r.ID), "subject", addr, "add", strconv.FormatBool(add)) return r.ID } // Retract pulls the caller's own request before it is decided. func Retract(cur realm, id int) { caller := unsafe.PreviousRealm().Address() if err := program.Board.Withdraw(caller, id, runtime.ChainHeight()); err != nil { panic(err) } chain.Emit("Retract", "id", strconv.Itoa(id)) } // Vote records a member's ballot. The reason is stored and rendered next to // the vote: it is the only part of a decision that tells an applicant what to // do about it. func Vote(cur realm, id int, approve bool, reason string) { voter := unsafe.PreviousRealm().Address() st, err := program.Board.Vote(voter, id, approve, reason, runtime.ChainHeight()) if err != nil { panic(err) } chain.Emit("Vote", "id", strconv.Itoa(id), "voter", voter.String(), "approve", strconv.FormatBool(approve), "status", st.String()) } // SubmitProof offers evidence for the next milestone of an approved grant. // kind is "url", "hash" or "text"; ref is the link, digest or statement. func SubmitProof(cur realm, id, milestone int, kind, ref, note string) { caller := unsafe.PreviousRealm().Address() p := grants.Proof{Kind: kind, Ref: ref, Note: note, Height: runtime.ChainHeight()} if err := program.Board.SubmitProof(caller, id, milestone, p); err != nil { panic(err) } chain.Emit("SubmitProof", "id", strconv.Itoa(id), "milestone", strconv.Itoa(milestone), "kind", kind) } // Review records a member's verdict on the proof under review. The verdict // that carries also pays the tranche, in this same transaction. A verdict the // treasury cannot cover is refused outright and changes nothing. func Review(cur realm, id, milestone int, accept bool, reason string) { voter := unsafe.PreviousRealm().Address() out, pay, err := program.Review(voter, id, milestone, accept, reason, runtime.ChainHeight(), Balance()) if err != nil { panic(err) } chain.Emit("Review", "id", strconv.Itoa(id), "milestone", strconv.Itoa(milestone), "voter", voter.String(), "accept", strconv.FormatBool(accept), "outcome", out.String()) if pay == nil { return } banker.NewBanker(banker.BankerTypeRealmSend, cur). SendCoins(Address(), pay.To, chain.NewCoins(chain.NewCoin(Denom, pay.Amount))) chain.Emit("Release", "id", strconv.Itoa(id), "milestone", strconv.Itoa(milestone), "to", pay.To.String(), "amount", strconv.FormatInt(pay.Amount, 10)) }
- #8grant_test.gno
- #9package grant import ( "chain" "chain/banker" "testing" "gno.land/p/moul/grants/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) const ( zoe = address("g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz") // an applicant kim = address("g1jvh5ukk07dvd57fxefcp5aa29xaydxmxs7myyp") // a second one ) func balanceOf(a address) int64 { return banker.NewReadonlyBanker().GetCoins(a).AmountOf(Denom) } func issue(n int64) { testing.IssueCoins(Address(), chain.NewCoins(chain.NewCoin(Denom, n))) } func TestFreshBoardIsOwnedAndEmpty(t *testing.T) { reset() uassert.True(t, Owner.IsValid(), "the owner is a real address") uassert.True(t, zoe.IsValid()) uassert.True(t, kim.IsValid()) uassert.True(t, Address().IsValid(), "the treasury address") urequire.Equal(t, 1, len(Members())) uassert.Equal(t, Owner.String(), Members()[0].String()) uassert.Equal(t, 0, Requests(), "nothing seeded, this board is real") uassert.Equal(t, int64(0), Committed()) uassert.Equal(t, int64(0), Raised()) uassert.Equal(t, int64(0), Disbursed()) } // The whole path with real coins, on a one-member board: the owner is the // majority, so one ballot carries and one review pays. func TestApplyApproveProveGetPaid(cur realm, t *testing.T) { reset() urequire.Equal(t, int64(0), Balance(), "start from an empty treasury") // Someone funds the board, by name. issue(500) testing.SetOriginSend(chain.NewCoins(chain.NewCoin(Denom, 500))) testing.SetRealm(testing.NewUserRealm(kim)) Fund(cross(cur)) uassert.Equal(t, int64(500), Balance()) uassert.Equal(t, int64(500), Raised()) uassert.Equal(t, kim.String(), program.Donations[0].From.String()) // An outsider applies. Anyone may. testing.SetRealm(testing.NewUserRealm(zoe)) id := Apply(cross(cur), "Ship the indexer", "two tranches", "alpha:100,beta:400") uassert.Equal(t, 1, id) uassert.Equal(t, int64(0), Committed(), "a pending request promises nothing") // A non-member cannot vote, not even the applicant. testing.SetRealm(testing.NewUserRealm(zoe)) uassert.AbortsContains(t, cur, grants.ErrNotMember.Error(), func() { Vote(cross(cur), id, true, "me again") }) testing.SetRealm(testing.NewUserRealm(Owner)) Vote(cross(cur), id, true, "worth it") uassert.Equal(t, int64(500), Committed(), "approval promises the full ask") uassert.Equal(t, int64(0), Available()) // Milestone 1: proof, then the one review that carries also pays. testing.SetRealm(testing.NewUserRealm(kim)) uassert.AbortsContains(t, cur, grants.ErrNotApplicant.Error(), func() { SubmitProof(cross(cur), id, 0, "url", "https://example.com/pr/1", "not mine to prove") }) testing.SetRealm(testing.NewUserRealm(zoe)) SubmitProof(cross(cur), id, 0, "url", "https://example.com/pr/1", "alpha is live") testing.SetRealm(testing.NewUserRealm(Owner)) Review(cross(cur), id, 0, true, "confirmed") uassert.Equal(t, int64(100), balanceOf(zoe), "the tranche landed") uassert.Equal(t, int64(400), Balance()) uassert.Equal(t, int64(100), Disbursed()) urequire.Equal(t, 1, len(program.Payments)) uassert.Equal(t, int64(100), program.Payments[0].Amount) // Out of order is refused. testing.SetRealm(testing.NewUserRealm(zoe)) uassert.AbortsContains(t, cur, grants.ErrOutOfOrder.Error(), func() { SubmitProof(cross(cur), id, 0, "url", "https://example.com/pr/1", "again") }) // Milestone 2: a proof the owner refuses, then one they accept. testing.SetRealm(testing.NewUserRealm(zoe)) SubmitProof(cross(cur), id, 1, "text", "trust me", "") testing.SetRealm(testing.NewUserRealm(Owner)) Review(cross(cur), id, 1, false, "no evidence") uassert.Equal(t, int64(100), balanceOf(zoe), "a refused proof pays nothing") testing.SetRealm(testing.NewUserRealm(zoe)) SubmitProof(cross(cur), id, 1, "hash", "sha256:deadbeef", "beta, signed release") testing.SetRealm(testing.NewUserRealm(Owner)) Review(cross(cur), id, 1, true, "verified") uassert.Equal(t, int64(500), balanceOf(zoe), "paid in full") uassert.Equal(t, int64(0), Balance(), "the treasury is back to empty") uassert.Equal(t, int64(0), Committed()) uassert.Equal(t, int64(500), Disbursed()) uassert.Equal(t, 2, len(program.Payments)) // Both attempts on milestone 2 are on the record, refusal included. m2 := program.Board.Get(id).Milestones[1] urequire.Equal(t, 2, len(m2.Attempts)) uassert.Equal(t, "refused", m2.Attempts[0].Outcome.String()) uassert.Equal(t, "no evidence", m2.Attempts[0].Reviews[0].Reason) uassert.Equal(t, "accepted", m2.Attempts[1].Outcome.String()) } // The verdict that would release a tranche the treasury cannot pay is refused // outright and leaves no trace. func TestReviewRefusesToReleaseWhatItCannotPay(cur realm, t *testing.T) { reset() urequire.Equal(t, int64(0), Balance(), "no coins anywhere") testing.SetRealm(testing.NewUserRealm(zoe)) id := Apply(cross(cur), "Ship the indexer", "", "alpha:100") testing.SetRealm(testing.NewUserRealm(Owner)) Vote(cross(cur), id, true, "worth it") testing.SetRealm(testing.NewUserRealm(zoe)) SubmitProof(cross(cur), id, 0, "url", "https://example.com/pr/1", "") testing.SetRealm(testing.NewUserRealm(Owner)) uassert.AbortsContains(t, cur, grants.ErrUnderfunded.Error(), func() { Review(cross(cur), id, 0, true, "confirmed") }) m := program.Board.Get(id).Milestones[0] uassert.Equal(t, 0, len(m.Current().Reviews), "the ballot was not recorded") uassert.False(t, m.Released) uassert.Equal(t, 0, len(program.Payments)) uassert.Equal(t, int64(-100), Available(), "and the shortfall is visible") } func TestRetractAndBadInput(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(zoe)) uassert.AbortsContains(t, cur, grants.ErrBadSpec.Error(), func() { Apply(cross(cur), "Bad", "", "no amount here") }) uassert.Equal(t, 0, Requests(), "nothing was filed") testing.SetRealm(testing.NewUserRealm(zoe)) id := Apply(cross(cur), "Never mind", "", "thing:1") testing.SetRealm(testing.NewUserRealm(kim)) uassert.AbortsContains(t, cur, grants.ErrNotApplicant.Error(), func() { Retract(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(zoe)) Retract(cross(cur), id) uassert.Equal(t, "withdrawn", program.Board.Get(id).Status.String()) testing.SetOriginSend(chain.NewCoins()) testing.SetRealm(testing.NewUserRealm(kim)) uassert.AbortsContains(t, cur, grants.ErrNothingSent.Error(), func() { Fund(cross(cur)) }) uassert.Equal(t, 0, len(program.Donations)) } // The owner can hand out a seat, and once there are two members the bar moves // to two. That is the path from this personal board to a real one. func TestProposeMemberGrowsTheBoardAndRaisesTheBar(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(zoe)) uassert.AbortsContains(t, cur, grants.ErrNotMember.Error(), func() { ProposeMember(cross(cur), zoe.String(), true, "let me in") }) testing.SetRealm(testing.NewUserRealm(Owner)) id := ProposeMember(cross(cur), kim.String(), true, "reviews everything anyway") testing.SetRealm(testing.NewUserRealm(Owner)) Vote(cross(cur), id, true, "proposed it") uassert.Equal(t, "completed", program.Board.Get(id).Status.String()) urequire.Equal(t, 2, len(Members())) testing.SetRealm(testing.NewUserRealm(zoe)) next := Apply(cross(cur), "Ship the indexer", "", "alpha:100") r := program.Board.Get(next) uassert.Equal(t, 2, program.Board.Eligible(r)) uassert.Equal(t, 2, program.Board.Majority(r), "two members, two signatures") testing.SetRealm(testing.NewUserRealm(Owner)) Vote(cross(cur), next, true, "worth it") uassert.Equal(t, "pending", program.Board.Get(next).Status.String(), "the owner is no longer a majority") testing.SetRealm(testing.NewUserRealm(kim)) Vote(cross(cur), next, true, "agreed") uassert.Equal(t, "approved", program.Board.Get(next).Status.String()) } func TestRenderUnknownPaths(t *testing.T) { reset() uassert.True(t, len(Render("")) > 0) uassert.Equal(t, "# moul's grant board\n\nThere is no request 99.\n", Render("request/99")) uassert.True(t, len(Render("ledger")) > 0) uassert.True(t, len(Render("nope")) > 0) } // The case this board exists for: an account with nothing in it cannot pay the // gas to ask for its first coins, so someone else asks for it. The treasury // ends where it started, at zero, which is what the pinned pages render. func TestApplyForPaysTheBeneficiaryNotTheFiler(cur realm, t *testing.T) { reset() urequire.Equal(t, int64(0), Balance()) before := balanceOf(kim) issue(300) testing.SetOriginSend(chain.NewCoins(chain.NewCoin(Denom, 300))) testing.SetRealm(testing.NewUserRealm(address(Owner))) Fund(cross(cur)) // zoe files for kim, whose account is empty. testing.SetRealm(testing.NewUserRealm(zoe)) id := ApplyFor(cross(cur), kim.String(), "kim's account is empty, she cannot pay the gas", "Fund a first key", "so she can transact at all", "first transfer:300") r := program.Board.Get(id) urequire.True(t, r.OnBehalf()) uassert.Equal(t, kim.String(), r.Payee().String()) // The owner carries it alone, and either party may prove it. Here the // filer does, because the payee still cannot send a transaction. testing.SetRealm(testing.NewUserRealm(address(Owner))) Vote(cross(cur), id, true, "I know kim.") testing.SetRealm(testing.NewUserRealm(zoe)) SubmitProof(cross(cur), id, 0, "text", "she has a key and no coins", "") testing.SetRealm(testing.NewUserRealm(address(Owner))) Review(cross(cur), id, 0, true, "Sent.") uassert.Equal(t, before+300, balanceOf(kim), "the payee got the coins") uassert.Equal(t, int64(0), Balance(), "and the treasury is back to empty") uassert.Equal(t, kim.String(), program.Payments[0].To.String()) // A reason is not optional when the payee is someone else. testing.SetRealm(testing.NewUserRealm(zoe)) uassert.AbortsContains(t, cur, "needs a reason", func() { ApplyFor(cross(cur), kim.String(), "", "no reason given", "", "x:1") }) }
- #10render.gno
- #11package grant import "gno.land/p/moul/grants/v0" // The board's whole front page is these three strings plus the library's // Renderer. Nothing about the layout lives here, which is the test of whether // the split worked: a second board is another file this size. const ( intro = "A small, personal grant board. moul funds it out of his own pocket, and it\n" + "exists mainly so the coding agents working on his repos, and the people he\n" + "already works with, can ask for money against work they can prove they did: a\n" + "request, a milestone, a link to the merged PR, a tranche. It is open to anyone.\n" + "Nothing here is privileged to a bot." notofficial = "**This is not a faucet and it is not an official grant program.** It is one\n" + "person's money, sent by hand, mostly to people he already knows, and it is also\n" + "a proof of concept for the library underneath. There is no entitlement, no queue\n" + "and no service level: a request can sit here unanswered, or be refused with one\n" + "line of reasoning and nothing further. An official programme, where anyone asks\n" + "for tokens and a DAO decides, is a separate thing that does not exist yet, and\n" + "this page deliberately does not name a path for it: naming one would read as a\n" + "commitment nobody has made." scope = "**One member, and the page says so.** A majority of a one-member board is one\n" + "signature, so today this is moul deciding in public rather than a DAO deciding.\n" + "What makes it worth reading is the record: every ballot carries its reason,\n" + "every proof stays up including the refused ones, and every coin out is on the\n" + "ledger. A bigger multi-member program is a separate thing and will live at its\n" + "own path." forothers = "**You can ask for someone else.** `ApplyFor` takes the address the money goes\n" + "to and a reason for the detour, and prints both on the request. That is not a\n" + "convenience: an account with nothing in it cannot pay the gas to ask for its\n" + "first coins, so on a board meant to fund empty accounts, someone else filing is\n" + "the only path that works. Either address may then submit the proofs, and neither\n" + "may vote on the request." agents = "**For agents.** A gno.land account session scoped to this path lets an agent\n" + "`Apply` and `SubmitProof` under its own address, so its track record here is\n" + "its own and not its operator's. A session cannot be given a board seat by\n" + "scoping alone: voting is membership, and membership is a request the board\n" + "decides." footer = "Built on [p/moul/grants/v0](/p/moul/grants/v0), which holds every rule, every\n" + "tally and this page's markdown, and has no idea coins exist." ) // Render serves the board at "", one request at "request/<id>", and the money // trail at "ledger". func Render(path string) string { return grants.Renderer{ Program: program, Title: "moul's grant board", Intro: intro, Notes: []string{notofficial, scope, forothers, agents}, Path: Path, Link: Link, Treasury: Address(), Balance: Balance(), Footer: footer, }.Render(path) }
- #12render_example_test.gno
- #13package grant // ExampleRender pins the board as it is at deploy: one member, no requests, // an empty treasury. That IS the shipped state, since nothing is seeded here. // For a board mid-flight, with ballots, refused proofs and a paid tranche, // see the pinned pages in p/moul/grants/v0's render_test.gno; the markdown is // generated by the same Renderer. // // It calls reset() first: realm globals persist for the whole test binary and // examples run after every Test. The treasury BALANCE is not a realm global // and reset() cannot clear it, so the tests are written to be balance-neutral // (TestApplyApproveProveGetPaid pays out exactly what it funds). A test that // issues coins and does not spend them shows up as a diff here, which is the // point. func ExampleRender() { reset() print(Render("")) // Output: // # moul's grant board // // A small, personal grant board. moul funds it out of his own pocket, and it // exists mainly so the coding agents working on his repos, and the people he // already works with, can ask for money against work they can prove they did: a // request, a milestone, a link to the merged PR, a tranche. It is open to anyone. // Nothing here is privileged to a bot. // // **This is not a faucet and it is not an official grant program.** It is one // person's money, sent by hand, mostly to people he already knows, and it is also // a proof of concept for the library underneath. There is no entitlement, no queue // and no service level: a request can sit here unanswered, or be refused with one // line of reasoning and nothing further. An official programme, where anyone asks // for tokens and a DAO decides, is a separate thing that does not exist yet, and // this page deliberately does not name a path for it: naming one would read as a // commitment nobody has made. // // **One member, and the page says so.** A majority of a one-member board is one // signature, so today this is moul deciding in public rather than a DAO deciding. // What makes it worth reading is the record: every ballot carries its reason, // every proof stays up including the refused ones, and every coin out is on the // ledger. A bigger multi-member program is a separate thing and will live at its // own path. // // **You can ask for someone else.** `ApplyFor` takes the address the money goes // to and a reason for the detour, and prints both on the request. That is not a // convenience: an account with nothing in it cannot pay the gas to ask for its // first coins, so on a board meant to fund empty accounts, someone else filing is // the only path that works. Either address may then submit the proofs, and neither // may vote on the request. // // **For agents.** A gno.land account session scoped to this path lets an agent // `Apply` and `SubmitProof` under its own address, so its track record here is // its own and not its operator's. A session cannot be given a board seat by // scoping alone: voting is membership, and membership is a request the board // decides. // // ## Treasury // // | Field | Value | // |---|---| // | Address | `g1uah596w03ufkslrjfg8erxszvjq3zra6avs37n` | // | Balance | 0 ugnot | // | Promised | 0 ugnot | // | Unpromised | 0 ugnot | // | Donated | 0 ugnot | // | Paid out | 0 ugnot | // // ## Board // // A decision needs a majority of the members eligible to cast a ballot on it, // recomputed every time: the party a decision is about never votes on it, so an // applicant who sits on the board shrinks the room rather than packing it. // // | Member | Joined at height | // |---|---| // | `g1manfred47kzduec920z88wfr64ylksmdcedlf5` | 0 | // // ## Requests // // Nothing has been asked of this board yet. `Apply` is open to anyone. // // ## Where the money went // // [The ledger](/r/moul/grant/v0:ledger) lists every donation in and every tranche // out, with the height, the payee and the milestone it paid for. // // ## Calling it // // ```sh // # put money in // gnokey maketx call -pkgpath gno.land/r/moul/grant/v0 -func Fund -send 5000000ugnot ... // # ask for some // gnokey maketx call -pkgpath gno.land/r/moul/grant/v0 -func Apply \ // -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' ... // # ask on behalf of someone who cannot pay the gas to ask // gnokey maketx call -pkgpath gno.land/r/moul/grant/v0 -func ApplyFor \ // -args g1... -args 'their account is empty' \ // -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' ... // # decide (members only) // gnokey maketx call -pkgpath gno.land/r/moul/grant/v0 -func Vote -args 1 -args true -args 'reason' ... // # show your work, then get paid for it // gnokey maketx call -pkgpath gno.land/r/moul/grant/v0 -func SubmitProof \ // -args 1 -args 0 -args url -args 'https://...' -args 'what it is' ... // gnokey maketx call -pkgpath gno.land/r/moul/grant/v0 -func Review -args 1 -args 0 -args true -args 'looks done' ... // ``` // // Built on [p/moul/grants/v0](/p/moul/grants/v0), which holds every rule, every // tally and this page's markdown, and has no idea coins exist. } // ExampleRenderLedger pins the money trail of a board nobody has funded yet. func ExampleRenderLedger() { reset() print(Render("ledger")) // Output: // # moul's grant board: the ledger // // Every coin in and every coin out, in the order it happened. Coins sent // straight to `g1uah596w03ufkslrjfg8erxszvjq3zra6avs37n` land in the treasury // without appearing here, which is why `Fund` exists: it is the same transfer // with a name attached. // // ## In // // Nothing donated through `Fund` yet. // // ## Out // // No tranche has been released yet. // // Balance now: 0 ugnot. [Back to the board](/r/moul/grant/v0). }
- Attached funds
- 18000000ugnot
- Package
- gno.land/r/moul/x/amm/v0
Arguments · 15
- #1amm
- #2README.md
- #3# amm: a constant-product AMM whose LP positions are real GRC20 tokens A constant-product market maker for GRC20 pairs, many pools in one realm, `x*y=k` with a 30 bps fee that stays with the liquidity providers. A liquidity position is a **GRC20 token**, minted per pool and registered with [`r/nt/grc20reg`](https://gno.land/r/nt/grc20reg/v0), so a position can be transferred, approved, and priced by anything else on the chain. Design study: [moul/gno-contracts#135](https://github.com/moul/gno-contracts/issues/135). ## The ledger-row design this replaced, and what it cost to leave it The first cut of this realm kept a position as a row in a private `avl.Tree`. That is the smaller thing, and it is what the realm shipped with until the measurements below said the trade was worth taking. Both were built and run, so the comparison is measured rather than argued, and the earlier shape is recoverable from [#137](https://github.com/moul/gno-contracts/pull/137). | | a private ledger row | a registered GRC20 | |---|---|---| | transferable | no | yes | | approvable / usable as collateral | no | yes | | readable by another realm | no | yes, via `grc20reg.Get(key)` | | the realm must expose | nothing extra | 4 wrappers + `LPToken` | | pool creation also does | nothing | mint a token, write a registry entry | | allowance race surface | none | the standard GRC20 one | Nothing about pricing, reserves, rounding or the guards moved: the ledger-row version's `amm_test.gno` runs unmodified against this one, only the pkgpath string differs. What moved is where a share lives. Four identical operations, one `--- GAS:` figure each, from the same `gas_test.gno` run against both. Fixture funding is measured separately so it does not pollute the comparison: | operation | ledger row | GRC20 | delta | |---|---|---|---| | seed (create pool + first deposit) | 1 716 207 | 2 213 407 | **+497 200 (+29.0%)** | | swap | 1 459 531 | 1 459 531 | **0 (+0.0%)** | | join (second provider) | 1 731 048 | 1 789 306 | +58 258 (+3.4%) | | exit (full burn) | 1 433 236 | 1 518 298 | +85 062 (+5.9%) | | | ledger row | GRC20 | |---|---|---| | `amm.gno`, total lines | 506 | 589 | | `amm.gno`, code lines | 333 | 359 | | exported functions | 10 | 15 | The shape of that is the interesting part. **Swapping is unaffected to the gas unit**, because the hot path never touches share accounting: it reads two reserves, prices, moves two token balances, writes two reserves. The whole premium is paid where positions are created and destroyed. Pool creation carries it almost entirely, once, as a fixed setup cost: minting the LP token and registering it. Per-provider operations pay 3 to 6 percent. Read the other way: **transferable LP positions cost a one-off ~0.5M gas per pool and ~5% on liquidity operations, and nothing at all on trading.** That is why they are the default here, and why Uniswap V2 pairs are ERC20s. ## The LP API ```go LPToken(keyA, keyB string) string // the pool's LP token registry key AllowanceLP(keyA, keyB string, owner, spender address) int64 TransferLP(cur realm, keyA, keyB string, to address, amount int64) ApproveLP(cur realm, keyA, keyB string, spender address, amount int64) TransferFromLP(cur realm, keyA, keyB string, from, to address, amount int64) ``` The four wrappers exist because the LP token lives *in this realm*: a signing user has no token realm of its own to call, the way they would for any other GRC20. A **realm** holding LP does not need them and can move its own balance through the registry: ```go grc20reg.Transfer(0, cur, amm.LPToken(keyA, keyB), to, n) ``` `ApproveLP` carries the usual GRC20 approve race: an allowance lowered from a non-zero value can be spent at both the old and the new figure under unlucky ordering. Set it to 0 first. `SharesOf` and `TotalShares` are `lp.BalanceOf` and `lp.TotalSupply`; `AddLiquidity`, `RemoveLiquidity`, `Swap`, `AmountOut`, `Quote`, `Reserves`, `PoolCount` and `Render` are unchanged by the LP decision. ## LP token naming One token per pool, symbol `LP<n>` from a never-reset counter, name `AMM LP <symA>/<symB>`, decimals mirroring token A (the LP unit is token A at seed time). The symbol is a counter and not the pair because `grc20` caps a symbol at 11 characters, which `LP-` plus two 11-character symbols would blow straight past; the readable pair goes in the name, which allows 64. Never resetting the counter is what keeps `grc20reg`'s one-token-per-realm-and-symbol rule satisfiable forever: a drained pool keeps its LP token and identity, and reseeding reuses it rather than minting a second token under a symbol already taken. ## Warnings - **Not an oracle.** The reserve ratio is a spot price any trader can move inside one transaction. Nothing should price off this realm. - **`minOut` is your only slippage protection**, **a pool is only as honest as its two tokens**, and **none of this is audited**. - **The LP `PrivateLedger` never leaves this realm.** It is the minting authority; exporting it, even indirectly, would let anyone mint positions against real reserves. <!-- 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 -->
- #4amm.gno
- #5// Package amm is a minimal constant-product automated market maker for GRC20 // token pairs, in a single file. // // It holds many pools in one realm. A pool is created implicitly by its first // liquidity deposit and is addressed by the two tokens' grc20reg keys, in // either order. Swaps follow x*y=k with a 30 bps fee that stays in the pool // and therefore accrues to the liquidity providers. // // # What it is not // // Not an oracle: the reserve ratio is a spot price that any trader can move // within a single transaction. Nothing should price off this realm. // Not audited. Not a router: one hop, one pool, no path finding. Not a // place to park value. // // # Design notes worth knowing before calling it // // Reserves are stored fields and are NEVER derived from BalanceOf. That is // the single decision that removes Uniswap V2's MINIMUM_LIQUIDITY burn, its // skim/sync pair, and the first-depositor share-inflation attack in one go: // sending tokens straight to this realm's address changes no reserve, no // price and no share value. The price of that is blunt and worth stating: // tokens sent directly to the realm address are PERMANENTLY STUCK. // // The first deposit mints shares equal to the token-A amount rather than // sqrt(A*B). The geometric mean is cosmetic, every later operation uses only // ratios, and dropping it removes an integer square root over a 128-bit // product. Later deposits mint min(A-side, B-side) with floor division on // both, so an off-ratio deposit always rounds against the depositor. // // # v1 vs v0: LP shares are a real GRC20 token // // v0 tracks LP positions in a private avl ledger: smallest possible, but a // position can only be held by the address that opened it and is invisible to // every other contract. v1 mints one GRC20 token per pool instead, holds its // PrivateLedger, and registers it with grc20reg. A position is then an // ordinary fungible token: transferable, approvable, usable as collateral, // and readable by any realm that knows the registry key. // // The cost is honest and measurable. Pool creation now also mints a token and // writes a registry entry. Positions gain an allowance surface, and with it // the classic GRC20 approve race. And because MsgCall cannot pass a realm // argument, this realm has to re-export Transfer/Approve/TransferFrom/ // Allowance wrappers over the LP token for signing users, four functions that // v0 does not need at all. Another REALM can skip them and move its own LP // balance through grc20reg directly. // // All amounts are int64, as GRC20 mandates, and there is no 256-bit type in // reach. Pricing therefore runs through a 128-bit mulDiv (math/bits), and // every reserve is capped at maxReserve so that the plain int64 parts of the // formula cannot overflow. See the comment on maxReserve for the arithmetic, // and note the consequence: this AMM is unusable with 18-decimal tokens. // Six to nine decimals is the practical band. // // Design study and full analysis: https://github.com/moul/gno-contracts/issues/135 package amm import ( "chain" "math" "math/bits" "strconv" "gno.land/p/nt/avl/v0" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/seqid/v0" "gno.land/p/nt/ufmt/v0" "gno.land/r/nt/grc20reg/v0" ) const ( // feeNum/feeDen is the swap fee kept by the pool: 997/1000, i.e. 30 bps. feeNum = 997 feeDen = 1000 // maxReserve is the largest reserve a pool will hold, in base units. // // Pricing computes den = reserveIn*feeDen + amountIn*feeNum in plain // int64 before handing off to the 128-bit mulDiv. Capping every reserve, // and every post-swap reserve, at MaxInt64/feeDen makes that sum provably // safe: // // den < (reserveIn + amountIn) * feeDen <= maxReserve * feeDen // = 9223372036854775000 <= MaxInt64 = 9223372036854775807 // // AddLiquidity enforces the cap on the reserves themselves and Swap // enforces it on reserveIn+amountIn, so a pool can never even enter the // region where the formula would be unsafe. maxReserve = math.MaxInt64 / feeDen // 9223372036854775 ) // pool is one token pair. keyA < keyB always holds: the pair is canonicalised // on the way in, so (X,Y) and (Y,X) address the same pool. type pool struct { keyA, keyB string tokA, tokB *grc20.Token resA, resB int64 // lp is this pool's LP token: total supply is the share supply and a // holder's balance is their position. lpLedger is the minting authority // and MUST NOT leak out of this file. lpKey is lp's grc20reg key. lp *grc20.Token lpLedger *grc20.PrivateLedger lpKey string } // pools maps the pool id "keyA~keyB" -> *pool. "~" cannot occur in a // grc20reg key, and unlike "|" it does not break a markdown table cell. var pools avl.Tree // lpSeq numbers the LP tokens. It is never reset, so a symbol is never reused // and grc20reg's one-token-per-realm-and-symbol rule is never tripped. var lpSeq seqid.ID // // Writes. // // AddLiquidity deposits up to maxA of keyA and maxB of keyB and mints LP // shares to the caller. It creates the pool if this is its first deposit, in // which case the caller's amounts set the starting price and the whole of // maxA and maxB is taken. // // On an existing pool the deposit is trimmed to the current reserve ratio: // only one side is consumed in full and the remainder of the richer side is // left untouched, so pass the amounts you are willing to spend, not the // amounts you insist on spending. // // The caller must first Approve this realm's address on BOTH tokens for at // least the amounts that will be taken. Returns the shares minted. func AddLiquidity(cur realm, keyA, keyB string, maxA, maxB int64) int64 { a, b, flipped := canon(keyA, keyB) amtA, amtB := maxA, maxB if flipped { amtA, amtB = maxB, maxA } if amtA <= 0 || amtB <= 0 { panic("amm: both deposit amounts must be > 0") } p := getPool(a, b) if p == nil { p = newPool(0, cur, a, b) pools.Set(poolID(a, b), p) } var minted int64 if p.lp.TotalSupply() == 0 { // First position: the depositor sets the price and the share unit is // token A at seed time. No sqrt, no MINIMUM_LIQUIDITY burn. minted = amtA } else { // Trim to the current ratio, then mint from whichever side is scarcer. // Both divisions floor, and taking the minimum means an off-ratio // deposit is rounded against the depositor, never against the pool. if want := mulDiv(amtA, p.resB, p.resA); want <= amtB { amtB = want } else { amtA = mulDiv(amtB, p.resA, p.resB) } if amtA <= 0 || amtB <= 0 { panic("amm: deposit too small for the current ratio") } supply := p.lp.TotalSupply() minted = min64( mulDiv(amtA, supply, p.resA), mulDiv(amtB, supply, p.resB), ) } if minted <= 0 { panic("amm: deposit mints zero shares") } if p.resA+amtA > maxReserve || p.resB+amtB > maxReserve { panic("amm: reserve cap exceeded") } provider := cur.Previous().Address() self := cur.Address() pull(0, cur, p.tokA, provider, self, amtA) pull(0, cur, p.tokB, provider, self, amtB) p.resA += amtA p.resB += amtB if err := p.lpLedger.Mint(provider, minted); err != nil { panic("amm: cannot mint LP shares: " + err.Error()) } chain.Emit("AddLiquidity", "pool", poolID(p.keyA, p.keyB), "provider", provider.String(), "amountA", itoa(amtA), "amountB", itoa(amtB), "shares", itoa(minted), ) return minted } // RemoveLiquidity burns shares held by the caller and returns the proportional // amounts of both tokens, in the caller's (keyA, keyB) argument order. // // Burning the pool's entire share supply pays out the whole reserve, so the // last provider out leaves nothing unclaimable behind and the pool can be // reseeded at a fresh price. func RemoveLiquidity(cur realm, keyA, keyB string, shares int64) (int64, int64) { if shares <= 0 { panic("amm: shares must be > 0") } a, b, flipped := canon(keyA, keyB) p := mustPool(a, b) owner := cur.Previous().Address() if p.lp.BalanceOf(owner) < shares { panic("amm: insufficient shares") } supply := p.lp.TotalSupply() var amtA, amtB int64 if shares == supply { amtA, amtB = p.resA, p.resB } else { amtA = mulDiv(shares, p.resA, supply) amtB = mulDiv(shares, p.resB, supply) } if amtA <= 0 || amtB <= 0 { panic("amm: burn would return nothing on one side") } p.resA -= amtA p.resB -= amtB if err := p.lpLedger.Burn(owner, shares); err != nil { panic("amm: cannot burn LP shares: " + err.Error()) } push(0, cur, p.tokA, owner, amtA) push(0, cur, p.tokB, owner, amtB) chain.Emit("RemoveLiquidity", "pool", poolID(p.keyA, p.keyB), "provider", owner.String(), "amountA", itoa(amtA), "amountB", itoa(amtB), "shares", itoa(shares), ) if flipped { return amtB, amtA } return amtA, amtB } // Swap sells amountIn of keyIn for keyOut and aborts unless at least minOut // comes back. The caller must first Approve this realm's address on keyIn. // // minOut is the only protection against being sandwiched or against the pool // moving between quoting and execution. Pass a real bound; passing 0 means // accepting any price at all. func Swap(cur realm, keyIn, keyOut string, amountIn, minOut int64) int64 { if amountIn <= 0 { panic("amm: amountIn must be > 0") } if minOut < 0 { panic("amm: minOut must be >= 0") } a, b, flipped := canon(keyIn, keyOut) p := mustPool(a, b) resIn, resOut := p.resA, p.resB tokIn, tokOut := p.tokA, p.tokB if flipped { resIn, resOut = p.resB, p.resA tokIn, tokOut = p.tokB, p.tokA } out := AmountOut(amountIn, resIn, resOut) if out <= 0 { panic("amm: output rounds to zero") } if out < minOut { panic("amm: slippage, output below minOut") } trader := cur.Previous().Address() pull(0, cur, tokIn, trader, cur.Address(), amountIn) newIn, newOut := resIn+amountIn, resOut-out // The invariant holds by construction (out is floored), so this can only // fire if the pricing above is ever edited into being wrong. It is exact: // both products are compared in 128 bits. if cmpProd(newIn, newOut, resIn, resOut) < 0 { panic("amm: constant product regression") } if flipped { p.resB, p.resA = newIn, newOut } else { p.resA, p.resB = newIn, newOut } push(0, cur, tokOut, trader, out) chain.Emit("Swap", "pool", poolID(p.keyA, p.keyB), "trader", trader.String(), "tokenIn", tokIn.GetSymbol(), "amountIn", itoa(amountIn), "amountOut", itoa(out), ) return out } // TransferLP moves amount of the caller's LP position to `to`. // // The LP token lives in this realm, so a signing user cannot reach it through // the token's own entry points the way they would for any other GRC20: there // are none. These four wrappers are that entry point. A REALM holding LP does // not need them and can go through grc20reg instead: // // grc20reg.Transfer(0, cur, amm.LPToken(keyA, keyB), to, n) func TransferLP(cur realm, keyA, keyB string, to address, amount int64) { a, b, _ := canon(keyA, keyB) lpCheck(mustPool(a, b).lpLedger.CallerTeller().Transfer(0, cur, to, amount)) } // ApproveLP lets spender move up to amount of the caller's LP position. // // Same approve race as any GRC20: an allowance changed from a non-zero value // can be spent at both the old and the new one if the holder is unlucky with // ordering. Set it to 0 first when lowering it. func ApproveLP(cur realm, keyA, keyB string, spender address, amount int64) { a, b, _ := canon(keyA, keyB) lpCheck(mustPool(a, b).lpLedger.CallerTeller().Approve(0, cur, spender, amount)) } // TransferFromLP spends an allowance the owner granted to the caller. func TransferFromLP(cur realm, keyA, keyB string, from, to address, amount int64) { a, b, _ := canon(keyA, keyB) lpCheck(mustPool(a, b).lpLedger.CallerTeller().TransferFrom(0, cur, from, to, amount)) } func lpCheck(err error) { if err != nil { panic("amm: LP token: " + err.Error()) } } // // Reads. // // AmountOut is the pricing function, fee included, as a pure function of the // two reserves. Exported so a caller can quote off-chain against reserves it // already holds, and so the arithmetic is testable on its own. func AmountOut(amountIn, reserveIn, reserveOut int64) int64 { if amountIn <= 0 { panic("amm: amountIn must be > 0") } if reserveIn <= 0 || reserveOut <= 0 { panic("amm: pool has an empty reserve") } if reserveIn > maxReserve || reserveOut > maxReserve { panic("amm: reserve above cap") } if amountIn > maxReserve-reserveIn { panic("amm: reserve cap exceeded") } inFee := amountIn * feeNum den := reserveIn*feeDen + inFee return mulDiv(inFee, reserveOut, den) } // Quote prices amountIn of keyIn against the pool's live reserves. It is the // number Swap would return right now, which is not a promise about the next // block. func Quote(keyIn, keyOut string, amountIn int64) int64 { a, b, flipped := canon(keyIn, keyOut) p := mustPool(a, b) if flipped { return AmountOut(amountIn, p.resB, p.resA) } return AmountOut(amountIn, p.resA, p.resB) } // Reserves returns the two reserves in the caller's argument order. func Reserves(keyA, keyB string) (int64, int64) { a, b, flipped := canon(keyA, keyB) p := mustPool(a, b) if flipped { return p.resB, p.resA } return p.resA, p.resB } // SharesOf returns owner's LP shares, i.e. their LP token balance. func SharesOf(keyA, keyB string, owner address) int64 { a, b, _ := canon(keyA, keyB) return mustPool(a, b).lp.BalanceOf(owner) } // TotalShares returns the pool's LP token total supply. func TotalShares(keyA, keyB string) int64 { a, b, _ := canon(keyA, keyB) return mustPool(a, b).lp.TotalSupply() } // LPToken returns the grc20reg key of the pool's LP token. Hand it to any // realm that should read or move these positions without importing this one. func LPToken(keyA, keyB string) string { a, b, _ := canon(keyA, keyB) return mustPool(a, b).lpKey } // AllowanceLP reports how much of owner's LP position spender may move. func AllowanceLP(keyA, keyB string, owner, spender address) int64 { a, b, _ := canon(keyA, keyB) return mustPool(a, b).lp.Allowance(owner, spender) } // PoolCount returns how many pools exist. func PoolCount() int { return pools.Size() } // Render lists every pool, or one pool's detail when path is a "keyA~keyB" // pool id. func Render(path string) string { if path != "" { v := pools.Get(path) if v == nil { return "# 404\n\nNo pool `" + path + "`.\n" } p := v.(*pool) out := "# " + p.tokA.GetSymbol() + " / " + p.tokB.GetSymbol() + "\n\n" out += "- **" + p.tokA.GetSymbol() + "** reserve: " + itoa(p.resA) + " (`" + p.keyA + "`)\n" out += "- **" + p.tokB.GetSymbol() + "** reserve: " + itoa(p.resB) + " (`" + p.keyB + "`)\n" out += "- **LP token**: `" + p.lpKey + "` (" + p.lp.GetSymbol() + ")\n" out += "- **LP shares**: " + itoa(p.lp.TotalSupply()) + " across " + strconv.Itoa(p.lp.KnownAccounts()) + " holder(s)\n" return out } out := "# Minimal AMM\n\n" out += "Constant product (`x*y=k`) with a 30 bps fee kept by the pool. Every position is a transferable GRC20 LP token. Reserves are stored, never read from balances, so sending tokens here directly does nothing and they cannot be recovered.\n\n" if pools.Size() == 0 { out += "_No pools yet. Call `AddLiquidity` with two `grc20reg` keys to open one._\n" return out } out += "| pool | reserves | LP token | supply | holders |\n" out += "|---|---|---|---|---|\n" pools.Iterate("", "", func(key string, value any) bool { p := value.(*pool) out += ufmt.Sprintf("| [%s/%s](/r/moul/x/amm/v0:%s) | %d %s / %d %s | %s | %d | %d |\n", p.tokA.GetSymbol(), p.tokB.GetSymbol(), key, p.resA, p.tokA.GetSymbol(), p.resB, p.tokB.GetSymbol(), p.lp.GetSymbol(), p.lp.TotalSupply(), p.lp.KnownAccounts()) return false }) return out } // // Internals. // // canon sorts a caller's token pair into the pool's canonical order and // reports whether the caller's order was reversed. func canon(first, second string) (a, b string, flipped bool) { if first == "" || second == "" { panic("amm: empty token key") } if first == second { panic("amm: a pool needs two different tokens") } if first > second { return second, first, true } return first, second, false } // newPool resolves both tokens, mints this pool's LP token and registers it. // // Non-crossing (`_ int, rlm realm`): grc20.NewToken binds the token's // origRealm to rlm.PkgPath() under an IsCurrent assertion, and grc20reg // keys off the registering caller, so rlm has to stay AddLiquidity's own // live frame rather than a fresh one. // // The LP unit is token A at seed time, so the LP token mirrors token A's // decimals. The symbol is LP<n> because grc20 caps a symbol at 11 chars, // which "LP-" plus two 11-char symbols would blow past; the human-readable // pair lives in the name instead. func newPool(_ int, rlm realm, a, b string) *pool { tokA, tokB := grc20reg.MustGet(a), grc20reg.MustGet(b) id := lpSeq.Next() lp, ledger := grc20.NewToken( "AMM LP "+tokA.GetSymbol()+"/"+tokB.GetSymbol(), "LP"+strconv.FormatUint(uint64(id), 10), tokA.GetDecimals(), id, rlm, ) p := &pool{keyA: a, keyB: b, tokA: tokA, tokB: tokB, lp: lp, lpLedger: ledger} p.lpKey = grc20reg.Register(cross(rlm), lp, "") return p } // poolID is the storage and render key for a canonicalised pair. func poolID(a, b string) string { return a + "~" + b } func getPool(a, b string) *pool { v := pools.Get(poolID(a, b)) if v == nil { return nil } return v.(*pool) } func mustPool(a, b string) *pool { p := getPool(a, b) if p == nil { panic("amm: no such pool: " + poolID(a, b)) } return p } // pull moves amount of tok from `from` into this realm, spending the // allowance `from` granted to this realm's address. // // Non-crossing on purpose: `_ int, rlm realm` is the only shape that keeps // rlm the caller's own live frame. RealmTeller binds the spender eagerly to // rlm.Address(), which is this realm. func pull(_ int, rlm realm, tok *grc20.Token, from, to address, amount int64) { err := tok.RealmTeller(0, rlm).TransferFrom(0, rlm, from, to, amount) if err != nil { panic("amm: cannot take " + tok.GetSymbol() + ": " + err.Error()) } } // push sends amount of tok from this realm to `to`. func push(_ int, rlm realm, tok *grc20.Token, to address, amount int64) { err := tok.RealmTeller(0, rlm).Transfer(0, rlm, to, amount) if err != nil { panic("amm: cannot send " + tok.GetSymbol() + ": " + err.Error()) } } // mulDiv returns floor(a*b/c) through a 128-bit intermediate, which plain // int64 arithmetic cannot do: a*b is routinely wider than 63 bits here even // when the quotient is small. func mulDiv(a, b, c int64) int64 { if a < 0 || b < 0 { panic("amm: negative operand") } if c <= 0 { panic("amm: division by a non-positive value") } hi, lo := bits.Mul64(uint64(a), uint64(b)) // bits.Div64 panics for y <= hi; refusing here turns that into a named // abort and also covers every quotient that would not fit in 64 bits. if hi >= uint64(c) { panic("amm: quotient overflows int64") } q, _ := bits.Div64(hi, lo, uint64(c)) if q > uint64(math.MaxInt64) { panic("amm: quotient overflows int64") } return int64(q) } // cmpProd compares a*b with c*d exactly, in 128 bits, and returns -1, 0 or 1. func cmpProd(a, b, c, d int64) int { h1, l1 := bits.Mul64(uint64(a), uint64(b)) h2, l2 := bits.Mul64(uint64(c), uint64(d)) if h1 != h2 { if h1 < h2 { return -1 } return 1 } if l1 != l2 { if l1 < l2 { return -1 } return 1 } return 0 } func min64(a, b int64) int64 { if a < b { return a } return b } func itoa(v int64) string { return strconv.FormatInt(v, 10) }
- #6amm_test.gno
- #7package amm import ( "chain" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/r/nt/grc20reg/v0" ) // Two throwaway tokens, minted and registered once for the whole test binary. // They are created from this realm, so grc20reg keys them under this path. var ( tokAAA, tokBBB *grc20.Token ledAAA, ledBBB *grc20.PrivateLedger keyAAA, keyBBB string ) func init(cur realm) { tokAAA, ledAAA = grc20.NewToken("Test Alpha", "AAA", 6, 0, cur) tokBBB, ledBBB = grc20.NewToken("Test Beta", "BBB", 6, 1, cur) keyAAA = grc20reg.Register(cross(cur), tokAAA, "") keyBBB = grc20reg.Register(cross(cur), tokBBB, "") } func self() address { return chain.PackageAddress("gno.land/r/moul/x/amm/v0") } // fund mints both tokens to addr and approves this realm to spend them. func fund(addr address, amount int64) { ledAAA.Mint(addr, amount) ledBBB.Mint(addr, amount) ledAAA.Approve(addr, self(), amount) ledBBB.Approve(addr, self(), amount) } // reset clears every pool so each test starts from an empty realm. func reset() { pools = avl.Tree{} } // // Pure arithmetic. // func TestAmountOut(t *testing.T) { cases := []struct { name string in, resIn, resOut, want int64 }{ {"balanced pool, small trade", 1_000, 1_000_000, 1_000_000, 996}, {"balanced pool, 1%", 10_000, 1_000_000, 1_000_000, 9_871}, {"balanced pool, 10%", 100_000, 1_000_000, 1_000_000, 90_661}, {"balanced pool, 50%", 500_000, 1_000_000, 1_000_000, 332_665}, {"skewed pool", 100_000, 1_000_000, 4_000_000, 362_644}, {"near drain", 1_000, 1, 1_000_000, 998_997}, {"dust rounds to zero", 1, 1_000_000, 1_000_000, 0}, {"reserves at the cap", maxReserve / 2, maxReserve / 2, maxReserve, 4_604_758_097_518_382}, } for _, tc := range cases { got := AmountOut(tc.in, tc.resIn, tc.resOut) uassert.Equal(t, tc.want, got, tc.name) } } func TestAmountOutNeverBreaksK(t *testing.T) { resIn, resOut := int64(3_000_000), int64(7_500_000) for _, in := range []int64{1, 7, 1_000, 999_999, 3_000_000} { out := AmountOut(in, resIn, resOut) uassert.True(t, out < resOut, "output must stay inside the reserve") uassert.True(t, cmpProd(resIn+in, resOut-out, resIn, resOut) >= 0, "k must not decrease") } } func TestAmountOutGuards(cur realm, t *testing.T) { uassert.PanicsWithMessage(t, cur, "amm: amountIn must be > 0", func() { AmountOut(0, 1_000, 1_000) }) uassert.PanicsWithMessage(t, cur, "amm: pool has an empty reserve", func() { AmountOut(1, 0, 1_000) }) uassert.PanicsWithMessage(t, cur, "amm: reserve above cap", func() { AmountOut(1, maxReserve+1, 1_000) }) uassert.PanicsWithMessage(t, cur, "amm: reserve cap exceeded", func() { AmountOut(maxReserve, maxReserve, 1_000) }) } func TestMulDiv(t *testing.T) { uassert.Equal(t, int64(0), mulDiv(0, 12345, 7)) uassert.Equal(t, int64(3), mulDiv(7, 5, 10)) // floors // 2^62 * 4 is 2^64: the intermediate does not fit in int64, the result does. const big = int64(1) << 62 uassert.Equal(t, big/2, mulDiv(big, 4, 8)) // maxReserve * feeDen is the widest product this realm ever forms. uassert.Equal(t, int64(maxReserve), mulDiv(maxReserve, feeDen, feeDen)) } func TestCmpProd(t *testing.T) { const big = int64(1) << 40 uassert.Equal(t, 0, cmpProd(big, big, big, big)) uassert.Equal(t, -1, cmpProd(big, big, big, big+1)) uassert.Equal(t, 1, cmpProd(big, big+1, big, big)) } // // Lifecycle. // func TestSeedSwapRemove(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("seed-alice") fund(alice, 10_000_000) testing.SetRealm(testing.NewUserRealm(alice)) heldA, heldB := tokAAA.BalanceOf(self()), tokBBB.BalanceOf(self()) // Seed: the first provider sets the price and gets shares == amountA. minted := AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 4_000_000) uassert.Equal(t, int64(1_000_000), minted) uassert.Equal(t, int64(1), int64(PoolCount())) rA, rB := Reserves(keyAAA, keyBBB) uassert.Equal(t, int64(1_000_000), rA) uassert.Equal(t, int64(4_000_000), rB) uassert.Equal(t, heldA+1_000_000, tokAAA.BalanceOf(self()), "reserve is really held") // Swap AAA -> BBB. quoted := Quote(keyAAA, keyBBB, 100_000) uassert.Equal(t, int64(362_644), quoted) out := Swap(cross(cur), keyAAA, keyBBB, 100_000, 362_000) uassert.Equal(t, quoted, out) rA, rB = Reserves(keyAAA, keyBBB) uassert.Equal(t, int64(1_100_000), rA) uassert.Equal(t, int64(3_637_356), rB) uassert.True(t, cmpProd(rA, rB, 1_000_000, 4_000_000) > 0, "fee grew k") // Burn half the position. gotA, gotB := RemoveLiquidity(cross(cur), keyAAA, keyBBB, 500_000) uassert.Equal(t, int64(550_000), gotA) uassert.Equal(t, int64(1_818_678), gotB) uassert.Equal(t, int64(500_000), TotalShares(keyAAA, keyBBB)) // Burn the rest: the last provider out takes the whole reserve. gotA, gotB = RemoveLiquidity(cross(cur), keyAAA, keyBBB, 500_000) uassert.Equal(t, int64(550_000), gotA) uassert.Equal(t, int64(1_818_678), gotB) uassert.Equal(t, int64(0), TotalShares(keyAAA, keyBBB)) rA, rB = Reserves(keyAAA, keyBBB) uassert.Equal(t, int64(0), rA) uassert.Equal(t, int64(0), rB) uassert.Equal(t, heldA, tokAAA.BalanceOf(self()), "nothing unclaimable left behind") uassert.Equal(t, heldB, tokBBB.BalanceOf(self()), "nothing unclaimable left behind") // A drained pool is reseedable, at whatever price the next provider picks. uassert.Equal(t, int64(200_000), AddLiquidity(cross(cur), keyAAA, keyBBB, 200_000, 100_000)) rA, rB = Reserves(keyAAA, keyBBB) uassert.Equal(t, int64(200_000), rA) uassert.Equal(t, int64(100_000), rB) } func TestArgumentOrderIsSymmetric(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("sym-alice") fund(alice, 10_000_000) testing.SetRealm(testing.NewUserRealm(alice)) AddLiquidity(cross(cur), keyBBB, keyAAA, 4_000_000, 1_000_000) uassert.Equal(t, int64(1), int64(PoolCount()), "one pool whichever order is used") rA, rB := Reserves(keyAAA, keyBBB) uassert.Equal(t, int64(1_000_000), rA) uassert.Equal(t, int64(4_000_000), rB) rB2, rA2 := Reserves(keyBBB, keyAAA) uassert.Equal(t, rA, rA2, "reserves follow the caller's order") uassert.Equal(t, rB, rB2, "reserves follow the caller's order") } func TestSecondProviderIsTrimmedToRatio(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("trim-alice") bob := testutils.TestAddress("trim-bob") fund(alice, 10_000_000) fund(bob, 10_000_000) testing.SetRealm(testing.NewUserRealm(alice)) AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 4_000_000) // Bob offers a badly skewed pair: 100k AAA is only worth 400k BBB, so the // extra BBB is left in his wallet and his shares price off the scarce side. testing.SetRealm(testing.NewUserRealm(bob)) beforeB := tokBBB.BalanceOf(bob) minted := AddLiquidity(cross(cur), keyAAA, keyBBB, 100_000, 9_000_000) uassert.Equal(t, int64(100_000), minted) uassert.Equal(t, int64(400_000), beforeB-tokBBB.BalanceOf(bob), "only the ratio amount was taken") rA, rB := Reserves(keyAAA, keyBBB) uassert.Equal(t, int64(1_100_000), rA) uassert.Equal(t, int64(4_400_000), rB) uassert.Equal(t, int64(1_100_000), TotalShares(keyAAA, keyBBB)) // Round-trip: Bob can never take out more than he put in. gotA, gotB := RemoveLiquidity(cross(cur), keyAAA, keyBBB, minted) uassert.True(t, gotA <= 100_000, "no value created on the A side") uassert.True(t, gotB <= 400_000, "no value created on the B side") } func TestDonationIsInert(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("don-alice") mallory := testutils.TestAddress("don-mallory") fund(alice, 10_000_000) fund(mallory, 10_000_000) testing.SetRealm(testing.NewUserRealm(alice)) AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 1_000_000) before := Quote(keyAAA, keyBBB, 10_000) // Mallory sends 5,000,000 AAA straight to the realm address, the classic // setup for a share-inflation or price-manipulation attack. heldA := tokAAA.BalanceOf(self()) ledAAA.Transfer(mallory, self(), 5_000_000) uassert.Equal(t, heldA+5_000_000, tokAAA.BalanceOf(self()), "the balance really moved") rA, rB := Reserves(keyAAA, keyBBB) uassert.Equal(t, int64(1_000_000), rA, "reserves ignore balances") uassert.Equal(t, int64(1_000_000), rB, "reserves ignore balances") uassert.Equal(t, before, Quote(keyAAA, keyBBB, 10_000), "price is unchanged") // And a later provider is priced off reserves, not off the donation. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.Equal(t, int64(1_000_000), AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 1_000_000)) } // // Guards. // func TestGuards(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("guard-alice") fund(alice, 10_000_000) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "amm: a pool needs two different tokens", func() { AddLiquidity(cross(cur), keyAAA, keyAAA, 1, 1) }) uassert.AbortsWithMessage(t, cur, "amm: both deposit amounts must be > 0", func() { AddLiquidity(cross(cur), keyAAA, keyBBB, 0, 1) }) uassert.AbortsWithMessage(t, cur, "amm: no such pool: "+keyAAA+"~"+keyBBB, func() { Swap(cross(cur), keyAAA, keyBBB, 1, 0) }) AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 1_000_000) uassert.AbortsWithMessage(t, cur, "amm: slippage, output below minOut", func() { Swap(cross(cur), keyAAA, keyBBB, 10_000, 10_000) }) uassert.AbortsWithMessage(t, cur, "amm: output rounds to zero", func() { Swap(cross(cur), keyAAA, keyBBB, 1, 0) }) uassert.AbortsWithMessage(t, cur, "amm: amountIn must be > 0", func() { Swap(cross(cur), keyAAA, keyBBB, 0, 0) }) uassert.AbortsWithMessage(t, cur, "amm: insufficient shares", func() { RemoveLiquidity(cross(cur), keyAAA, keyBBB, 2_000_000) }) uassert.AbortsWithMessage(t, cur, "amm: reserve cap exceeded", func() { AddLiquidity(cross(cur), keyAAA, keyBBB, maxReserve, maxReserve) }) // An allowance that does not cover the deposit aborts, and nothing moved. bob := testutils.TestAddress("guard-bob") ledAAA.Mint(bob, 1_000_000) ledBBB.Mint(bob, 1_000_000) ledAAA.Approve(bob, self(), 10) ledBBB.Approve(bob, self(), 1_000_000) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "insufficient allowance", func() { AddLiquidity(cross(cur), keyAAA, keyBBB, 100_000, 100_000) }) uassert.Equal(t, int64(1_000_000), tokAAA.BalanceOf(bob), "nothing moved: the first pull failed") uassert.Equal(t, int64(0), SharesOf(keyAAA, keyBBB, bob)) }
- #8gas_test.gno
- #9package amm import ( "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // A fixed four-step profile, declared in order so `gno test -v` reports one // `--- GAS:` figure per operation. The same four steps exist verbatim in v1, // so the difference between the two runs is the cost of the share accounting // and nothing else. See moul/gno-contracts#135. var ( gasAlice = testutils.TestAddress("gas-alice") gasBob = testutils.TestAddress("gas-bob") ) // TestGas0Fund keeps the fixture cost (minting and approving both tokens for // both actors) out of the four measured numbers. func TestGas0Fund(t *testing.T) { reset() fund(gasAlice, 10_000_000) fund(gasBob, 10_000_000) } func TestGas1Seed(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(gasAlice)) uassert.Equal(t, int64(1_000_000), AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 4_000_000)) } func TestGas2Swap(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(gasAlice)) uassert.Equal(t, int64(362_644), Swap(cross(cur), keyAAA, keyBBB, 100_000, 1)) } func TestGas3Join(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(gasBob)) uassert.Equal(t, int64(90_908), AddLiquidity(cross(cur), keyAAA, keyBBB, 100_000, 1_000_000)) } func TestGas4Exit(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(gasBob)) held := SharesOf(keyAAA, keyBBB, gasBob) uassert.Equal(t, int64(90_908), held) RemoveLiquidity(cross(cur), keyAAA, keyBBB, held) uassert.Equal(t, int64(0), SharesOf(keyAAA, keyBBB, gasBob)) }
- #10gnomod.toml
- #11module = "gno.land/r/moul/x/amm/v0" gno = "0.9" # public: a pool mints its LP token and registers it with r/nt/grc20reg, and # holds the *grc20.Token values that registry hands out for the pair's two # sides. Both make this realm's own objects reachable from those token realms. # As private = true the first CreatePool panics with "cannot persist object # from the private realm gno.land/r/moul/x/amm/v0" out of assertObjectIsPublic. # Measured, not inferred: flip the flag and `make test PKG=amm` fails.
- #12lp_test.gno
- #13package amm import ( "testing" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/r/nt/grc20reg/v0" ) // A third token, so a second pool can prove each one mints its own LP token. var ( tokCCC *grc20.Token ledCCC *grc20.PrivateLedger keyCCC string ) func init(cur realm) { tokCCC, ledCCC = grc20.NewToken("Test Gamma", "CCC", 6, 2, cur) keyCCC = grc20reg.Register(cross(cur), tokCCC, "") } // TestLPPositionIsARealToken is the whole point of v1: the position exists // outside this realm, as an ordinary registered GRC20. func TestLPPositionIsARealToken(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("lp-alice") fund(alice, 10_000_000) testing.SetRealm(testing.NewUserRealm(alice)) minted := AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 4_000_000) // Readable by any realm that knows the key, without importing this one. lp := grc20reg.MustGet(LPToken(keyAAA, keyBBB)) uassert.Equal(t, minted, lp.BalanceOf(alice)) uassert.Equal(t, minted, lp.TotalSupply()) uassert.Equal(t, minted, TotalShares(keyAAA, keyBBB)) // The LP unit is token A at seed time, so it mirrors token A's decimals. uassert.Equal(t, tokAAA.GetDecimals(), lp.GetDecimals()) uassert.Equal(t, "AMM LP AAA/BBB", lp.GetName()) } // TestLPIsTransferable: a position can change hands without touching the pool. func TestLPIsTransferable(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("xfer-alice") bob := testutils.TestAddress("xfer-bob") fund(alice, 10_000_000) testing.SetRealm(testing.NewUserRealm(alice)) AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 4_000_000) TransferLP(cross(cur), keyAAA, keyBBB, bob, 500_000) uassert.Equal(t, int64(500_000), SharesOf(keyAAA, keyBBB, alice)) uassert.Equal(t, int64(500_000), SharesOf(keyAAA, keyBBB, bob)) // Bob exits on a position he was given, having never deposited anything. // In v0 this is not expressible at all. testing.SetRealm(testing.NewUserRealm(bob)) gotA, gotB := RemoveLiquidity(cross(cur), keyAAA, keyBBB, 500_000) uassert.Equal(t, int64(500_000), gotA) uassert.Equal(t, int64(2_000_000), gotB) uassert.Equal(t, int64(500_000), tokAAA.BalanceOf(bob), "bob was never funded") } // TestLPAllowance: the approve/transferFrom surface v1 gains, and the abort // when it is spent past its limit. func TestLPAllowance(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("allow-alice") carol := testutils.TestAddress("allow-carol") fund(alice, 10_000_000) testing.SetRealm(testing.NewUserRealm(alice)) AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 1_000_000) ApproveLP(cross(cur), keyAAA, keyBBB, carol, 300_000) uassert.Equal(t, int64(300_000), AllowanceLP(keyAAA, keyBBB, alice, carol)) testing.SetRealm(testing.NewUserRealm(carol)) TransferFromLP(cross(cur), keyAAA, keyBBB, alice, carol, 300_000) uassert.Equal(t, int64(300_000), SharesOf(keyAAA, keyBBB, carol)) uassert.Equal(t, int64(700_000), SharesOf(keyAAA, keyBBB, alice)) uassert.Equal(t, int64(0), AllowanceLP(keyAAA, keyBBB, alice, carol)) uassert.AbortsContains(t, cur, "insufficient allowance", func() { TransferFromLP(cross(cur), keyAAA, keyBBB, alice, carol, 1) }) } // TestEachPoolMintsItsOwnLPToken: two pools, two distinct registered tokens. func TestEachPoolMintsItsOwnLPToken(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("two-alice") fund(alice, 10_000_000) ledCCC.Mint(alice, 10_000_000) ledCCC.Approve(alice, self(), 10_000_000) testing.SetRealm(testing.NewUserRealm(alice)) AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 4_000_000) AddLiquidity(cross(cur), keyAAA, keyCCC, 2_000_000, 2_000_000) ab := LPToken(keyAAA, keyBBB) ac := LPToken(keyAAA, keyCCC) uassert.NotEqual(t, ab, ac, "one LP token per pool") uassert.Equal(t, int64(1_000_000), grc20reg.MustGet(ab).TotalSupply()) uassert.Equal(t, int64(2_000_000), grc20reg.MustGet(ac).TotalSupply()) uassert.Equal(t, "AMM LP AAA/CCC", grc20reg.MustGet(ac).GetName()) // Moving one pool's LP leaves the other alone. TransferLP(cross(cur), keyAAA, keyBBB, testutils.TestAddress("two-bob"), 400_000) uassert.Equal(t, int64(600_000), SharesOf(keyAAA, keyBBB, alice)) uassert.Equal(t, int64(2_000_000), SharesOf(keyAAA, keyCCC, alice)) }
- #14render_example_test.gno
- #15package amm import ( "gno.land/p/nt/avl/v0" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/testutils/v0" ) // A fixed LP token for the examples, so the rendered symbol does not depend on // how many pools the other tests happened to open first. var ( exLP *grc20.Token exLedge *grc20.PrivateLedger ) func init(cur realm) { exLP, exLedge = grc20.NewToken("AMM LP AAA/BBB", "LPDEMO", 6, 9999, cur) exLedge.Mint(testutils.TestAddress("lp"), 1_000_000) } // seedExamplePool installs one deterministic pool so the examples below do not // depend on which tests ran before them. The numbers are the worked example // from the package docs: a 1,000,000 / 4,000,000 pool after a 100,000 swap. func seedExamplePool() { pools = avl.Tree{} pools.Set(poolID(keyAAA, keyBBB), &pool{ keyA: keyAAA, keyB: keyBBB, tokA: tokAAA, tokB: tokBBB, resA: 1_100_000, resB: 3_637_356, lp: exLP, lpLedger: exLedge, lpKey: "gno.land/r/moul/x/amm/v0.LPDEMO", }) } // ExampleRender pins the realm's home page. func ExampleRender() { seedExamplePool() print(Render("")) // Output: // # Minimal AMM // // Constant product (`x*y=k`) with a 30 bps fee kept by the pool. Every position is a transferable GRC20 LP token. Reserves are stored, never read from balances, so sending tokens here directly does nothing and they cannot be recovered. // // | pool | reserves | LP token | supply | holders | // |---|---|---|---|---| // | [AAA/BBB](/r/moul/x/amm/v0:gno.land/r/moul/x/amm/v0.AAA~gno.land/r/moul/x/amm/v0.BBB) | 1100000 AAA / 3637356 BBB | LPDEMO | 1000000 | 1 | } // ExampleRender_pool pins a single pool's detail page. func ExampleRender_pool() { seedExamplePool() print(Render(poolID(keyAAA, keyBBB))) // Output: // # AAA / BBB // // - **AAA** reserve: 1100000 (`gno.land/r/moul/x/amm/v0.AAA`) // - **BBB** reserve: 3637356 (`gno.land/r/moul/x/amm/v0.BBB`) // - **LP token**: `gno.land/r/moul/x/amm/v0.LPDEMO` (LPDEMO) // - **LP shares**: 1000000 across 1 holder(s) } // ExampleRender_unknown pins the miss. func ExampleRender_unknown() { seedExamplePool() print(Render("nope")) // Output: // # 404 // // No pool `nope`. }
- Attached funds
- 7000000ugnot
Arguments · 11
- #1bullscows
- #2README.md
- #3# Bulls & Cows > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- A shared on-chain [Bulls & Cows](https://en.wikipedia.org/wiki/Bulls_and_Cows) puzzle (the classic ancestor of Mastermind and Wordle-for-digits). One secret 4-digit code with no repeated digits is live at a time; anyone can call `Guess` with a candidate code and gets back **bulls** (right digit, right position) and **cows** (right digit, wrong position). First caller to land 4 bulls cracks the round, lands on the leaderboard, and a fresh secret is drawn immediately so the next round starts at once. Realm: `gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/bullscows` ## Example calls ``` Guess("1972") -> "1972 -> 1 bulls, 2 cows" Guess("7192") -> "*** SOLVED *** 7192 was it — round 3 cracked in 5 guesses. New round 4 has begun, good luck!" GiveUp() -> "round 4's code was 4082 — round 5 is live now." ``` `Render("")` shows the current round, attempts so far, the last 10 guesses with their bulls/cows, and the top-5 fastest-solve leaderboard. The secret is generated deterministically from the on-chain block height and the round counter via a small LCG (`newSecret`/`nextRand` in `bullscows.gno`) — no external randomness needed, and nobody can precompute the next code before its round actually starts. ## Toolchain status Verified locally with `gno test -v .` and `gno lint .` against a `gno.land/gnolang/gno` master checkout used as `GNOROOT` (the default installed `gno` binary had a stale/ missing `GNOROOT`, so `GNOROOT=<path-to-gnolang/gno> gno test .` was used instead). All 7 tests pass, lint is clean. This exercises current sapphire (gno 0.9) stdlib paths (`chain/runtime`, no `std` package) and the `cur realm` / `cross(cur)` crossing convention. ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/bullscows) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `shortAddr` helper. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. Visible change: addresses now render in monospace. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4bullscows.gno
- #5// Package bullscows is a shared on-chain Bulls & Cows puzzle: one secret // 4-digit code (distinct digits) per round, guessed cooperatively/competitively // by anyone who calls Guess. Bulls = right digit, right spot. Cows = right // digit, wrong spot. Whoever hits 4 bulls wins the round and a leaderboard // entry; a fresh secret is drawn immediately after. package bullscows import ( "chain/runtime" "strconv" "strings" "gno.land/p/moul/kit/ui/v0" ) const ( maxHistory = 10 maxRecords = 5 ) type guessLog struct { player string guess string bulls int cows int } type record struct { player string attempts int round int } var ( secret [4]int round int attempts int history []guessLog records []record ) func init() { round = 1 secret = newSecret(seedFor(round)) } // seedFor derives a per-round PRNG seed from the current chain height so // nobody can precompute the next secret before the round actually starts. func seedFor(r int) int64 { return runtime.ChainHeight()*1000003 + int64(r)*7919 + 17 } // nextRand is a minimal LCG (glibc constants) — no crypto strength needed // for a casual puzzle, just enough spread across digits. func nextRand(seed int64) int64 { return (seed*1103515245 + 12345) & 0x7fffffff } func newSecret(seed int64) [4]int { var used [10]bool var digits [4]int s := seed for i := 0; i < 4; i++ { for { s = nextRand(s) d := int(s % 10) if !used[d] { used[d] = true digits[i] = d break } } } return digits } // parseGuess validates a 4-digit, distinct-digit guess string. func parseGuess(guess string) ([4]int, bool) { var out [4]int if len(guess) != 4 { return out, false } var used [10]bool for i := 0; i < 4; i++ { c := guess[i] if c < '0' || c > '9' { return out, false } d := int(c - '0') if used[d] { return out, false } used[d] = true out[i] = d } return out, true } // score returns (bulls, cows) for guess against target, assuming both have // distinct digits (guaranteed by parseGuess / newSecret). func score(target, guess [4]int) (int, int) { bulls, cows := 0, 0 for i := 0; i < 4; i++ { if guess[i] == target[i] { bulls++ continue } for j := 0; j < 4; j++ { if i != j && guess[i] == target[j] { cows++ break } } } return bulls, cows } func addHistory(l guessLog) { history = append(history, l) if len(history) > maxHistory { history = history[len(history)-maxHistory:] } } // addRecord keeps the top maxRecords fastest solves, ascending by attempts. func addRecord(r record) { records = append(records, r) for i := len(records) - 1; i > 0 && records[i].attempts < records[i-1].attempts; i-- { records[i], records[i-1] = records[i-1], records[i] } if len(records) > maxRecords { records = records[:maxRecords] } } func startNewRound() { round++ attempts = 0 history = nil secret = newSecret(seedFor(round)) } // Guess submits a 4-distinct-digit code against the current round's secret. // Returns the bulls/cows feedback, or "solved!" text when it's a win. func Guess(cur realm, guess string) string { digits, ok := parseGuess(guess) if !ok { panic("guess must be 4 digits, 0-9, no repeats (e.g. \"1972\")") } player := cur.Previous().Address().String() attempts++ bulls, cows := score(secret, digits) addHistory(guessLog{player: player, guess: guess, bulls: bulls, cows: cows}) if bulls == 4 { wonRound := round wonAttempts := attempts addRecord(record{player: player, attempts: wonAttempts, round: wonRound}) startNewRound() return "*** SOLVED *** " + guess + " was it — round " + strconv.Itoa(wonRound) + " cracked in " + strconv.Itoa(wonAttempts) + " guesses. New round " + strconv.Itoa(round) + " has begun, good luck!" } return guess + " -> " + strconv.Itoa(bulls) + " bulls, " + strconv.Itoa(cows) + " cows" } // GiveUp reveals the current secret and starts a fresh round without // awarding a leaderboard record. func GiveUp(cur realm) string { revealed := digitsToString(secret) oldRound := round startNewRound() return "round " + strconv.Itoa(oldRound) + "'s code was " + revealed + " — round " + strconv.Itoa(round) + " is live now." } func digitsToString(d [4]int) string { var sb strings.Builder for _, v := range d { sb.WriteString(strconv.Itoa(v)) } return sb.String() } func Render(path string) string { var sb strings.Builder sb.WriteString("# Bulls & Cows\n\n") sb.WriteString("One shared secret 4-digit code (no repeated digits). Call `Guess(\"1972\")` ") sb.WriteString("to get **bulls** (right digit, right spot) and **cows** (right digit, wrong spot). ") sb.WriteString("First to 4 bulls wins the round; `GiveUp()` reveals the code and starts over.\n\n") sb.WriteString("## Round " + strconv.Itoa(round) + "\n\n") sb.WriteString("Attempts so far: **" + strconv.Itoa(attempts) + "**\n\n") if len(history) == 0 { sb.WriteString("_No guesses yet this round — be the first._\n\n") } else { sb.WriteString("### Recent guesses\n\n") sb.WriteString("| player | guess | bulls | cows |\n|---|---|---|---|\n") for i := len(history) - 1; i >= 0; i-- { h := history[i] sb.WriteString("| " + ui.AddrOf(h.player) + " | " + h.guess + " | " + strconv.Itoa(h.bulls) + " | " + strconv.Itoa(h.cows) + " |\n") } sb.WriteString("\n") } sb.WriteString("## Leaderboard (fastest solves)\n\n") if len(records) == 0 { sb.WriteString("_Nobody has cracked a code yet._\n") } else { sb.WriteString("| rank | player | attempts | round |\n|---|---|---|---|\n") for i, r := range records { sb.WriteString("| " + strconv.Itoa(i+1) + " | " + ui.AddrOf(r.player) + " | " + strconv.Itoa(r.attempts) + " | " + strconv.Itoa(r.round) + " |\n") } } return sb.String() }
- #6bullscows_test.gno
- #7package bullscows import ( "strconv" "strings" "testing" "gno.land/p/nt/testutils/v0" ) func TestParseGuess(t *testing.T) { cases := []struct { in string ok bool }{ {"1972", true}, {"0123", true}, {"11 2", false}, {"123", false}, {"12345", false}, {"1123", false}, // repeated digit {"12a3", false}, } for _, c := range cases { if _, ok := parseGuess(c.in); ok != c.ok { t.Fatalf("parseGuess(%q) ok = %v, want %v", c.in, ok, c.ok) } } } func TestScore(t *testing.T) { target := [4]int{1, 9, 7, 2} if b, c := score(target, [4]int{1, 9, 7, 2}); b != 4 || c != 0 { t.Fatalf("exact match: got bulls=%d cows=%d, want 4/0", b, c) } if b, c := score(target, [4]int{2, 7, 9, 1}); b != 0 || c != 4 { t.Fatalf("full rotation: got bulls=%d cows=%d, want 0/4", b, c) } if b, c := score(target, [4]int{1, 3, 4, 5}); b != 1 || c != 0 { t.Fatalf("one bull: got bulls=%d cows=%d, want 1/0", b, c) } if b, c := score(target, [4]int{5, 6, 7, 8}); b != 1 || c != 0 { t.Fatalf("no overlap but position 7: got bulls=%d cows=%d, want 1/0", b, c) } } func TestNewSecretDistinctDigits(t *testing.T) { for seed := int64(0); seed < 50; seed++ { d := newSecret(seed) var seen [10]bool for _, v := range d { if v < 0 || v > 9 { t.Fatalf("digit out of range: %d", v) } if seen[v] { t.Fatalf("newSecret(%d) = %v has repeated digit", seed, d) } seen[v] = true } } } func TestAddRecordKeepsFastestSorted(t *testing.T) { records = nil addRecord(record{player: "a", attempts: 10, round: 1}) addRecord(record{player: "b", attempts: 3, round: 2}) addRecord(record{player: "c", attempts: 7, round: 3}) addRecord(record{player: "d", attempts: 1, round: 4}) addRecord(record{player: "e", attempts: 9, round: 5}) addRecord(record{player: "f", attempts: 2, round: 6}) // pushes out the worst if len(records) != maxRecords { t.Fatalf("len(records) = %d, want %d", len(records), maxRecords) } want := []int{1, 2, 3, 7, 9} for i, w := range want { if records[i].attempts != w { t.Fatalf("records[%d].attempts = %d, want %d", i, records[i].attempts, w) } } } func TestAddHistoryCaps(t *testing.T) { history = nil for i := 0; i < maxHistory+5; i++ { addHistory(guessLog{player: "p", guess: strconv.Itoa(i), bulls: 0, cows: 0}) } if len(history) != maxHistory { t.Fatalf("len(history) = %d, want %d", len(history), maxHistory) } if history[len(history)-1].guess != strconv.Itoa(maxHistory+4) { t.Fatalf("history did not keep the most recent entries") } } // Crossing: Guess mutates realm state, needs cur realm + cross(cur). func TestGuessRoundtrip(cur realm, t *testing.T) { alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) round = 1 attempts = 0 history = nil records = nil secret = [4]int{1, 9, 7, 2} out := Guess(cross(cur), "1234") if !strings.Contains(out, "bulls") { t.Fatalf("Guess() = %q, want bulls/cows feedback", out) } if attempts != 1 { t.Fatalf("attempts = %d, want 1", attempts) } testing.SetRealm(testing.NewUserRealm(alice)) out = Guess(cross(cur), "1972") if !strings.Contains(out, "SOLVED") { t.Fatalf("Guess() = %q, want a solved message", out) } if round != 2 { t.Fatalf("round = %d, want 2 (new round should start on solve)", round) } if len(records) != 1 || records[0].attempts != 2 { t.Fatalf("records = %v, want one entry with attempts=2", records) } } func TestGiveUpStartsNewRound(cur realm, t *testing.T) { round = 1 attempts = 3 secret = [4]int{4, 5, 6, 7} out := GiveUp(cross(cur)) if !strings.Contains(out, "4567") { t.Fatalf("GiveUp() = %q, want it to reveal 4567", out) } if round != 2 || attempts != 0 { t.Fatalf("round=%d attempts=%d, want round=2 attempts=0", round, attempts) } }
- #8gnomod.toml
- #9module = "gno.land/r/moul/x/daily/bullscows/v1" gno = "0.9" private = true
- #10render_test.gno
- #11package bullscows import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) // clear restores the globals to their init() state so assertions do not depend // on which tests ran first. Realm globals live for the whole test binary. func clear() { round = 1 attempts = 0 history = nil records = nil secret = newSecret(seedFor(round)) } // ExampleRender pins the fresh-round view: no guesses, empty leaderboard. func ExampleRender() { clear() print(Render("")) // Output: // # Bulls & Cows // // One shared secret 4-digit code (no repeated digits). Call `Guess("1972")` to get **bulls** (right digit, right spot) and **cows** (right digit, wrong spot). First to 4 bulls wins the round; `GiveUp()` reveals the code and starts over. // // ## Round 1 // // Attempts so far: **0** // // _No guesses yet this round — be the first._ // // ## Leaderboard (fastest solves) // // _Nobody has cracked a code yet._ } // TestRenderHistoryNewestFirst checks the guess table is reverse-chronological. func TestRenderHistoryNewestFirst(t *testing.T) { clear() addHistory(guessLog{player: "alice", guess: "1234", bulls: 1, cows: 2}) addHistory(guessLog{player: "bob", guess: "5678", bulls: 0, cows: 1}) out := Render("") uassert.True(t, strings.Contains(out, "### Recent guesses"), "history section should appear") uassert.True(t, strings.Contains(out, "| `bob` | 5678 | 0 | 1 |\n| `alice` | 1234 | 1 | 2 |\n"), "newest guess should be listed first") uassert.False(t, strings.Contains(out, "_No guesses yet"), "empty-history notice should be gone") } // TestRenderLeaderboard checks solved records render in rank order. func TestRenderLeaderboard(t *testing.T) { clear() addRecord(record{player: "carol", attempts: 3, round: 1}) addRecord(record{player: "dave", attempts: 7, round: 2}) out := Render("") uassert.True(t, strings.Contains(out, "| 1 | `carol` | 3 | 1 |\n| 2 | `dave` | 7 | 2 |\n"), "leaderboard should rank fastest first") uassert.False(t, strings.Contains(out, "_Nobody has cracked a code yet._"), "empty-leaderboard notice should be gone") } // TestRenderShortensLongAddresses checks the bech32 addresses players actually // have get elided rather than blowing out the table. func TestRenderShortensLongAddresses(t *testing.T) { clear() long := "g1manfred47kzduec920z88wfr64ylksmdcedlf5" addHistory(guessLog{player: long, guess: "1234", bulls: 0, cows: 0}) out := Render("") uassert.False(t, strings.Contains(out, long), "full address should not be rendered") uassert.True(t, strings.Contains(out, "| `"+long[:8]+"…"+long[len(long)-4:]+"` | 1234 |"), "address should be shortened to prefix…suffix") } // TestRenderTracksRoundAndAttempts checks the counters reach the page. func TestRenderTracksRoundAndAttempts(t *testing.T) { clear() round = 4 attempts = 11 out := Render("") uassert.True(t, strings.Contains(out, "## Round 4\n"), "round should be shown") uassert.True(t, strings.Contains(out, "Attempts so far: **11**"), "attempts should be shown") } // TestRenderIgnoresPath documents that Render has no sub-routes. func TestRenderIgnoresPath(t *testing.T) { clear() uassert.Equal(t, Render(""), Render("/anything")) }
- Attached funds
- 6000000ugnot
Arguments · 9
- #1closestguess
- #2README.md
- #3# Closest Guess > ⚠️ **Experimental — generated with no human supervision** by the daily MCP pipeline to exercise gno tooling. Not audited. See [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md). --- A blind, multiplayer number-guessing puzzle: submit one silent guess per round in 1..1000, then anyone calls Reveal() to disclose the hidden target and crown whoever landed closest. No hints, no iterative narrowing — just one shot and a leaderboard ranked by smallest distance across all-time rounds. It's a deliberately different twist on the genre from higher/lower or bulls-and-cows: the suspense is in not knowing until reveal. Built for sapphire (gno 0.9). Live demo (agent address): https://sapphire.testnets.gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/closestguess ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/closestguess) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `shortAddr` helper. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. Visible change: addresses now render in monospace. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4closestguess.gno
- #5// Package closestguess is a blind, multiplayer number-guessing puzzle. // // Unlike a classic higher/lower guessing game, closestguess gives NO feedback // per guess. Each round hides a target in 1..1000 (derived deterministically // from the block height the round started at); every address may submit // exactly one blind Guess for the round. Whenever someone calls Reveal, the // round closes: the target is disclosed, the guess with the smallest absolute // distance wins (ties go to whoever guessed first), the winner is recorded on // the leaderboard, and a fresh round starts immediately. package closestguess import ( "strconv" "strings" "chain" "chain/runtime" "gno.land/p/moul/kit/ui/v0" ) const ( minTarget = 1 maxTarget = 1000 ) // roundT holds the mutable state of the open round. The target stays hidden // (never rendered, never returned) until Reveal closes the round. type roundT struct { number int startHeight int64 target int } // winRecord is one closed-round result, kept for the leaderboard. type winRecord struct { round int winner string target int guess int distance int players int } // guessEntry is one address's blind guess for the current round. type guessEntry struct { addr string n int } var ( current *roundT // entries holds one guessEntry per address for the CURRENT round only, in // submission order (so Reveal can break distance ties in favor of // whoever guessed first). Reset on every Reveal. A plain slice is fine // here: rounds are player-scoped and small, unlike long-lived global // state where avl.Tree would matter. entries []guessEntry // leaderboard is append-only across all rounds; sorted on render. leaderboard []winRecord ) func init() { current = newRound(1, runtime.ChainHeight()) } // newRound builds a fresh round whose hidden target derives deterministically // from the start height and round number. Gno has no runtime RNG, so mixing // height with the round number is the only available entropy, and it also // keeps consecutive rounds at the same height from repeating a target. func newRound(number int, height int64) *roundT { return &roundT{ number: number, startHeight: height, target: targetFromHeight(height, number), } } func targetFromHeight(h int64, round int) int { if h < 0 { h = -h } span := int64(maxTarget - minTarget + 1) mixed := h*2654435761 + int64(round)*40503 + 12345 if mixed < 0 { mixed = -mixed } return int(mixed%span) + minTarget } // Guess records the caller's single blind guess for the current round. It // returns no hint about correctness — that is the whole point of the puzzle. // Crossing function: caller invokes as Guess(cross(cur), n). func Guess(cur realm, n int) string { if n < minTarget || n > maxTarget { panic("guess must be in " + strconv.Itoa(minTarget) + ".." + strconv.Itoa(maxTarget)) } caller := cur.Previous().Address().String() if hasGuessed(caller) { panic("you already guessed this round; wait for Reveal()") } entries = append(entries, guessEntry{addr: caller, n: n}) chain.Emit("GuessSubmitted", "round", strconv.Itoa(current.number), "player", caller, ) return "guess recorded for round " + strconv.Itoa(current.number) + " (" + strconv.Itoa(len(entries)) + " player(s) so far) — call Reveal() to see who's closest" } func hasGuessed(addr string) bool { for _, e := range entries { if e.addr == addr { return true } } return false } // Reveal closes the current round: discloses the target, crowns whoever // landed closest (ties favor the earliest guesser), records a leaderboard // entry, and opens a fresh round. Panics if nobody has guessed yet. // Crossing function. func Reveal(cur realm) string { if len(entries) == 0 { panic("no guesses submitted yet this round") } target := current.target winner := entries[0] best := distance(winner.n, target) for _, e := range entries[1:] { if d := distance(e.n, target); d < best { best = d winner = e } } rec := winRecord{ round: current.number, winner: winner.addr, target: target, guess: winner.n, distance: best, players: len(entries), } leaderboard = append(leaderboard, rec) chain.Emit("RoundRevealed", "round", strconv.Itoa(rec.round), "winner", winner.addr, "target", strconv.Itoa(target), "distance", strconv.Itoa(best), ) closedRound := current.number current = newRound(closedRound+1, runtime.ChainHeight()) entries = nil return "round " + strconv.Itoa(closedRound) + " revealed: target was " + strconv.Itoa(target) + " — " + ui.AddrOf(winner.addr) + " wins, guessed " + strconv.Itoa(winner.n) + " (distance " + strconv.Itoa(best) + ") among " + strconv.Itoa(rec.players) + " player(s). Round " + strconv.Itoa(current.number) + " is now open." } func distance(guess, target int) int { d := guess - target if d < 0 { d = -d } return d } // Render produces the gnoweb Markdown view. The current round's target and // individual guess values are never shown — only the player count — so // rendering the page can't leak the puzzle. func Render(path string) string { var b strings.Builder b.WriteString("# Closest Guess\n\n") b.WriteString("A blind, multiplayer number-guessing puzzle. Each round hides a target in ") b.WriteString("**" + strconv.Itoa(minTarget) + ".." + strconv.Itoa(maxTarget) + "**. ") b.WriteString("Call `Guess(n)` once per round — you get no feedback. ") b.WriteString("Anyone can call `Reveal()` to close the round: the target is disclosed and ") b.WriteString("whoever landed closest wins.\n\n") b.WriteString("## Current round\n\n") b.WriteString("- Round: **" + strconv.Itoa(current.number) + "**\n") b.WriteString("- Started at block height: " + strconv.FormatInt(current.startHeight, 10) + "\n") b.WriteString("- Players guessed so far: " + strconv.Itoa(len(entries)) + "\n") b.WriteString("- Status: **open** — target hidden until `Reveal()` is called.\n\n") b.WriteString("## Leaderboard (closest-ever wins)\n\n") b.WriteString(renderLeaderboard()) b.WriteString("\n## How to play\n\n") b.WriteString("```\n") b.WriteString("Guess(n) // n in 1..1000, one shot per address per round, no feedback\n") b.WriteString("Reveal() // closes the round: reveals target, crowns the closest guess\n") b.WriteString("```\n") return b.String() } // renderLeaderboard formats closed rounds ranked by smallest distance (ties: // earlier round first). Sorted on a copy so state is untouched. func renderLeaderboard() string { if len(leaderboard) == 0 { return "_No round has been revealed yet — be the first to call `Reveal()`!_\n" } ranked := make([]winRecord, len(leaderboard)) copy(ranked, leaderboard) sortByClosest(ranked) var b strings.Builder b.WriteString("| Rank | Player | Round | Target | Guess | Distance | Players |\n") b.WriteString("|---|---|---|---|---|---|---|\n") for i, w := range ranked { b.WriteString("| " + strconv.Itoa(i+1) + " | " + ui.AddrOf(w.winner) + " | " + strconv.Itoa(w.round) + " | " + strconv.Itoa(w.target) + " | " + strconv.Itoa(w.guess) + " | " + strconv.Itoa(w.distance) + " | " + strconv.Itoa(w.players) + " |\n") } return b.String() } // sortByClosest does an in-place insertion sort: smallest distance first, // then earlier round first on ties. Small n; insertion sort keeps it // deterministic and dependency-free. func sortByClosest(rs []winRecord) { for i := 1; i < len(rs); i++ { j := i for j > 0 && closer(rs[j], rs[j-1]) { rs[j], rs[j-1] = rs[j-1], rs[j] j-- } } } func closer(a, b winRecord) bool { if a.distance != b.distance { return a.distance < b.distance } return a.round < b.round }
- #6closestguess_test.gno
- #7package closestguess import ( "strings" "testing" "gno.land/p/nt/testutils/v0" ) // resetGame re-initializes package globals so each test starts clean; init() // runs only once for the whole test binary. func resetGame(height int64) { current = newRound(1, height) entries = nil leaderboard = nil } func TestTargetFromHeight(t *testing.T) { tests := []struct { name string height int64 }{ {"zero", 0}, {"one", 1}, {"large", 123456}, {"negative", -42}, } for _, tt := range tests { got := targetFromHeight(tt.height, 1) if got < minTarget || got > maxTarget { t.Errorf("%s: targetFromHeight(%d,1)=%d, want in %d..%d", tt.name, tt.height, got, minTarget, maxTarget) } if again := targetFromHeight(tt.height, 1); again != got { t.Errorf("%s: non-deterministic: %d != %d", tt.name, again, got) } } // Different round numbers at the same height should (almost always) mix // to different targets, so consecutive rounds don't repeat. a := targetFromHeight(100, 1) b := targetFromHeight(100, 2) if a == b { t.Errorf("targetFromHeight(100,1) == targetFromHeight(100,2) == %d, want them to differ", a) } } func TestDistance(t *testing.T) { cases := []struct{ a, b, want int }{ {5, 5, 0}, {5, 8, 3}, {8, 5, 3}, {0, 1000, 1000}, } for _, c := range cases { if got := distance(c.a, c.b); got != c.want { t.Errorf("distance(%d,%d) = %d, want %d", c.a, c.b, got, c.want) } } } // TestGuessRejectsOutOfRangeAndDuplicate exercises the crossing Guess // function, so it takes cur realm and calls through cross(cur). A panic // raised across the crossing call boundary isn't catchable by a plain // defer/recover; revive() is the boundary-aware way to assert on it. func TestGuessRejectsOutOfRangeAndDuplicate(cur realm, t *testing.T) { resetGame(10) alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) if r := revive(func() { Guess(cross(cur), 0) }); r == nil { t.Error("Guess(0) should panic: out of range") } testing.SetRealm(testing.NewUserRealm(alice)) msg := Guess(cross(cur), 500) if !strings.Contains(msg, "recorded") { t.Errorf("Guess(500) = %q, want it to mention 'recorded'", msg) } testing.SetRealm(testing.NewUserRealm(alice)) if r := revive(func() { Guess(cross(cur), 501) }); r == nil { t.Error("second Guess from the same address should panic") } } // TestRevealPicksClosestAndStartsNewRound exercises Guess + Reveal together. func TestRevealPicksClosestAndStartsNewRound(cur realm, t *testing.T) { resetGame(50) target := current.target prevRound := current.number alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") far := target + 100 if far > maxTarget { far = target - 100 } if far < minTarget { far = minTarget } near := target + 1 if near > maxTarget { near = target - 1 } if near < minTarget { near = minTarget } testing.SetRealm(testing.NewUserRealm(alice)) Guess(cross(cur), far) testing.SetRealm(testing.NewUserRealm(bob)) Guess(cross(cur), near) msg := Reveal(cross(cur)) if !strings.Contains(msg, "revealed") { t.Errorf("Reveal() = %q, want it to mention 'revealed'", msg) } if len(leaderboard) != 1 { t.Fatalf("leaderboard len = %d, want 1", len(leaderboard)) } if leaderboard[0].winner != bob.String() { t.Errorf("winner = %q, want %q (closer guess)", leaderboard[0].winner, bob.String()) } if current.number != prevRound+1 { t.Errorf("round number = %d, want %d", current.number, prevRound+1) } if len(entries) != 0 { t.Errorf("entries should reset after Reveal, got %d", len(entries)) } } // TestRevealPanicsWithNoGuesses checks the panic path via revive() (see the // note on TestGuessRejectsOutOfRangeAndDuplicate). func TestRevealPanicsWithNoGuesses(cur realm, t *testing.T) { resetGame(1) if r := revive(func() { Reveal(cross(cur)) }); r == nil { t.Error("Reveal() with no guesses should panic") } } // TestRenderContainsState is a plain read — no cur. func TestRenderContainsState(t *testing.T) { resetGame(3) out := Render("") for _, want := range []string{"Closest Guess", "Current round", "Leaderboard"} { if !strings.Contains(out, want) { t.Errorf("Render() missing %q", want) } } }
- #8gnomod.toml
- #9module = "gno.land/r/moul/x/daily/closestguess/v1" gno = "0.9" private = true
- Attached funds
- 7000000ugnot
Arguments · 11
- #1coinflipduel
- #2README.md
- #3# Coin-Flip Duel > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- A one-on-one coin-flip duel between two addresses. The challenger picks heads or tails and names an opponent; the opponent settles it with a single `Accept` call, which flips the coin on the spot and records the result. No stakes, just an on-chain win/loss record and a running champion. Realm: `gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/coinflipduel` ## Example calls ``` Challenge("g1bob...", "heads") # as alice -> opens duel #1, alice called heads Accept("1") # as bob -> flips the coin, settles the duel Cancel("2") # as the challenger, before it's accepted ``` ## Render paths - `` (home) — arena summary, champion, pending duels - `<id>` (e.g. `3`) — a single duel's detail - `<address>` — a player's win/loss record ## Toolchain Built and tested against the local `gno` binary (`master.3130+bf5b31eda`). `gno test .` passes; see the injected build brief for the sapphire (gno 0.9) API notes this realm follows (`chain`, `chain/runtime`, `gno.land/p/nt/avl/v0`). ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/coinflipduel) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `escapeInline` helper. **Real escaping.** The local helper replaced a handful of markdown metacharacters. `ui.Inline` and `ui.Cell` delegate to [`p/nt/markdown/sanitize`](/p/nt/markdown/sanitize/v0), which also strips bidi and zero-width characters and folds newlines, so user text cannot reorder or escape the line it sits on. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4coinflipduel.gno
- #5// Package coinflipduel is a one-on-one coin-flip duel: challenge another // address, they call heads or tails, and the block settles it. No stakes, // just bragging rights tracked in an on-chain win/loss record. package coinflipduel import ( "strconv" "strings" "chain" "chain/runtime" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // duelStatus tracks the lifecycle of a duel. type duelStatus int const ( statusPending duelStatus = iota statusResolved statusCancelled ) func (s duelStatus) String() string { switch s { case statusPending: return "pending" case statusResolved: return "resolved" case statusCancelled: return "cancelled" default: return "?" } } // duel is one challenge from Challenger to Opponent over a single coin flip. // The Challenger calls a side up front; the Opponent's only move is to // accept, which triggers the flip. type duel struct { ID string Challenger address Opponent address Call string // "heads" or "tails" — the challenger's pick Status duelStatus Result string // "heads" or "tails" once resolved Winner address CreatedAt int64 ResolvedAt int64 } // playerState is the persisted win/loss record for one address. type playerState struct { Wins int Losses int Duels int } var ( duels avl.Tree // duel ID -> *duel players avl.Tree // address string -> *playerState nextID int flipNonce int totalDone int champion address championWins int ) func parseCall(s string) (string, bool) { switch strings.ToLower(strings.TrimSpace(s)) { case "heads", "h": return "heads", true case "tails", "t": return "tails", true default: return "", false } } // flipCoin derives a deterministic pseudo-random side from the current chain // height, a monotonic per-realm nonce, and both duelists' addresses, so two // duels resolved in the same block still diverge. func flipCoin(d *duel) string { flipNonce++ seed := runtime.ChainHeight() + int64(flipNonce) s := d.Challenger.String() + d.Opponent.String() + d.ID for i := 0; i < len(s); i++ { seed += int64(s[i]) } if seed < 0 { seed = -seed } if seed%2 == 0 { return "heads" } return "tails" } func getOrCreate(addr address) *playerState { key := addr.String() if v := players.Get(key); v != nil { return v.(*playerState) } ps := &playerState{} players.Set(key, ps) return ps } func recordResult(winner, loser address) { w := getOrCreate(winner) w.Wins++ w.Duels++ l := getOrCreate(loser) l.Losses++ l.Duels++ if w.Wins > championWins { championWins = w.Wins champion = winner } } // Challenge opens a new duel against opponentAddr, locking in the // challenger's call of "heads"/"tails" ("h"/"t" also accepted). The // opponent settles it by calling Accept with the returned duel ID. func Challenge(cur realm, opponentAddr string, call string) string { challenger := cur.Previous().Address() opponent := address(strings.TrimSpace(opponentAddr)) if !opponent.IsValid() { panic("invalid opponent address") } if opponent == challenger { panic("cannot duel yourself") } pick, ok := parseCall(call) if !ok { panic("call must be heads or tails (h/t)") } nextID++ id := strconv.Itoa(nextID) d := &duel{ ID: id, Challenger: challenger, Opponent: opponent, Call: pick, Status: statusPending, CreatedAt: runtime.ChainHeight(), } duels.Set(id, d) chain.Emit("DuelChallenged", "id", id, "challenger", challenger.String(), "opponent", opponent.String(), "call", pick, ) return "duel #" + id + " created: you called " + pick + ", waiting for " + opponent.String() + " to accept" } // Accept settles a pending duel. Only the challenged opponent may call it; // the flip happens immediately and the result is final. func Accept(cur realm, id string) string { caller := cur.Previous().Address() v := duels.Get(id) if v == nil { panic("no such duel") } d := v.(*duel) if d.Status != statusPending { panic("duel already " + d.Status.String()) } if caller != d.Opponent { panic("only the challenged opponent can accept this duel") } result := flipCoin(d) d.Result = result d.Status = statusResolved d.ResolvedAt = runtime.ChainHeight() if result == d.Call { d.Winner = d.Challenger recordResult(d.Challenger, d.Opponent) } else { d.Winner = d.Opponent recordResult(d.Opponent, d.Challenger) } totalDone++ chain.Emit("DuelResolved", "id", id, "result", result, "winner", d.Winner.String(), ) return "the coin landed on " + result + " -- " + d.Winner.String() + " wins duel #" + id } // Cancel withdraws a still-pending duel. Only the challenger may cancel, // and only before the opponent accepts. func Cancel(cur realm, id string) string { caller := cur.Previous().Address() v := duels.Get(id) if v == nil { panic("no such duel") } d := v.(*duel) if d.Status != statusPending { panic("duel already " + d.Status.String()) } if caller != d.Challenger { panic("only the challenger can cancel this duel") } d.Status = statusCancelled return "duel #" + id + " cancelled" } func renderHome() string { var b strings.Builder b.WriteString("# Coin-Flip Duel\n\n") b.WriteString("Challenge another address to a coin flip. You call heads or tails " + "up front; they accept and the block decides. No stakes -- just a record.\n\n") b.WriteString("- Total duels resolved: " + strconv.Itoa(totalDone) + "\n") if champion.IsValid() { b.WriteString("- Reigning champion: `" + champion.String() + "` (" + strconv.Itoa(championWins) + " wins)\n") } else { b.WriteString("- No champion yet -- be the first to win a duel.\n") } b.WriteString("\n## How to play\n\n") b.WriteString("1. `Challenge(opponentAddr, \"heads\"|\"tails\")` -- opens a duel, returns its ID.\n") b.WriteString("2. The opponent calls `Accept(id)` -- flips the coin and settles it on the spot.\n") b.WriteString("3. The challenger may `Cancel(id)` while it's still pending.\n\n") b.WriteString("View a duel at this realm's path plus its ID (e.g. `.../coinflipduel:3`), " + "or a player's record plus their address (e.g. `.../coinflipduel:g1youraddress...`).\n\n") b.WriteString("## Pending duels\n\n") pending := 0 duels.Iterate("", "", func(key string, value interface{}) bool { d := value.(*duel) if d.Status == statusPending { pending++ b.WriteString("- #" + d.ID + ": `" + d.Challenger.String() + "` called " + d.Call + ", waiting on `" + d.Opponent.String() + "`\n") } return false }) if pending == 0 { b.WriteString("_none right now_\n") } return b.String() } func renderDuel(id string) string { v := duels.Get(id) if v == nil { return "# Duel #" + ui.Inline(id) + "\n\nNo such duel.\n" } d := v.(*duel) var b strings.Builder b.WriteString("# Duel #" + d.ID + "\n\n") b.WriteString("- Challenger: `" + d.Challenger.String() + "` called **" + d.Call + "**\n") b.WriteString("- Opponent: `" + d.Opponent.String() + "`\n") b.WriteString("- Status: " + d.Status.String() + "\n") if d.Status == statusResolved { b.WriteString("- Coin landed on: **" + d.Result + "**\n") b.WriteString("- Winner: `" + d.Winner.String() + "`\n") } return b.String() } func renderPlayer(rawAddr string) string { addr := strings.TrimSpace(rawAddr) safe := ui.Inline(addr) v := players.Get(addr) if v == nil { return "# Player " + safe + "\n\nNo recorded duels yet.\n" } ps := v.(*playerState) var b strings.Builder b.WriteString("# Player " + safe + "\n\n") b.WriteString("- Wins: " + strconv.Itoa(ps.Wins) + "\n") b.WriteString("- Losses: " + strconv.Itoa(ps.Losses) + "\n") b.WriteString("- Duels played: " + strconv.Itoa(ps.Duels) + "\n") return b.String() } // Render shows the duel arena at "", a specific duel when path is a numeric // ID, or one player's record when path is their bech32 address. func Render(path string) string { path = strings.TrimPrefix(strings.TrimSpace(path), "/") if path == "" { return renderHome() } if _, err := strconv.Atoi(path); err == nil { return renderDuel(path) } return renderPlayer(path) }
- #6coinflipduel_test.gno
- #7package coinflipduel import ( "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) func resetState() { duels = avl.Tree{} players = avl.Tree{} nextID = 0 flipNonce = 0 totalDone = 0 var zero address champion = zero championWins = 0 } func TestParseCall(t *testing.T) { for _, s := range []string{"heads", "Heads", " h ", "tails", "T"} { if _, ok := parseCall(s); !ok { t.Fatalf("parseCall(%q) should be valid", s) } } if _, ok := parseCall("sideways"); ok { t.Fatal(`parseCall("sideways") should be invalid`) } } // Crossing: Challenge and Accept mutate realm state, so the test needs `cur realm`. func TestChallengeAndAccept(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) Challenge(cross(cur), bob.String(), "heads") if !duels.Has("1") { t.Fatal("expected duel #1 to exist") } testing.SetRealm(testing.NewUserRealm(bob)) Accept(cross(cur), "1") v := duels.Get("1") d := v.(*duel) if d.Status != statusResolved { t.Fatalf("Status = %v, want resolved", d.Status) } if d.Result != "heads" && d.Result != "tails" { t.Fatalf("Result = %q, want heads or tails", d.Result) } if totalDone != 1 { t.Fatalf("totalDone = %d, want 1", totalDone) } winnerStats := getOrCreate(d.Winner) if winnerStats.Wins != 1 { t.Fatalf("winner Wins = %d, want 1", winnerStats.Wins) } if !champion.IsValid() || champion != d.Winner { t.Fatal("champion should be the duel winner") } } func TestChallengeSelfRejected(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) // Challenge panics inside a cross-realm call; a plain defer/recover in // this test does not catch a cross-boundary panic, so use revive(). rec := revive(func() { Challenge(cross(cur), alice.String(), "heads") }) if rec == nil { t.Fatal("expected panic when challenging yourself") } } func TestOnlyOpponentCanAccept(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") mallory := testutils.TestAddress("mallory") testing.SetRealm(testing.NewUserRealm(alice)) Challenge(cross(cur), bob.String(), "tails") testing.SetRealm(testing.NewUserRealm(mallory)) rec := revive(func() { Accept(cross(cur), "1") }) if rec == nil { t.Fatal("expected panic when a non-opponent accepts") } } func TestCancel(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) Challenge(cross(cur), bob.String(), "heads") Cancel(cross(cur), "1") v := duels.Get("1") d := v.(*duel) if d.Status != statusCancelled { t.Fatalf("Status = %v, want cancelled", d.Status) } } // Non-crossing: Render is a plain read. func TestRenderHome(t *testing.T) { resetState() out := Render("") if out == "" { t.Fatal(`Render("") should not be empty`) } }
- #8gnomod.toml
- #9module = "gno.land/r/moul/x/daily/coinflipduel/v1" gno = "0.9" private = true
- #10render_example_test.gno
- #11package coinflipduel // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # Coin-Flip Duel // // Challenge another address to a coin flip. You call heads or tails up front; they accept and the block decides. No stakes -- just a record. // // - Total duels resolved: 0 // - No champion yet -- be the first to win a duel. // // ## How to play // // 1. `Challenge(opponentAddr, "heads"|"tails")` -- opens a duel, returns its ID. // 2. The opponent calls `Accept(id)` -- flips the coin and settles it on the spot. // 3. The challenger may `Cancel(id)` while it's still pending. // // View a duel at this realm's path plus its ID (e.g. `.../coinflipduel:3`), or a player's record plus their address (e.g. `.../coinflipduel:g1youraddress...`). // // ## Pending duels // // _none right now_ }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1commitrevealdemo
- #2README.md
- #3# `gno.land/r/moul/x/daily/commitrevealdemo/v0` **Live demo of [`p/moul/x/daily/commitreveal`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/daily/commitreveal/v0).** A thin, stateless realm: it holds no state of its own and contains none of the library's logic — `Render` just exercises the package and shows the result. Because it is stateless, the rendered page is identical on every call and on every node. Render it at [`/r/moul/x/daily/commitrevealdemo/v0`](https://gno.land/r/moul/x/daily/commitrevealdemo/v0). The library README explains the design and the trade-offs; this realm is the worked example. ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/commitrevealdemo) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `short` helper. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. Visible change: the commitment truncation is now ui.ShortN, which cannot panic on a short input. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4commitrevealdemo.gno
- #5// Package commitrevealdemo is a small gnoweb demo of the commit-reveal scheme // provided by the [p/moul/x/daily/commitreveal](/p/moul/x/daily/commitreveal/v0) // library: the two phases, and what the salt is for. // // It contains no crypto of its own. Stateless, so Render is deterministic — // which is precisely what the library is for. package commitrevealdemo import ( "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/x/daily/commitreveal/v0" ) // Render renders the demo for gnoweb. func Render(path string) string { var b strings.Builder b.WriteString("# Commit–Reveal\n\n") b.WriteString("Bind to a choice without disclosing it, demoing the ") b.WriteString("[`p/moul/x/daily/commitreveal`](/p/moul/x/daily/commitreveal/v0) library.\n\n") b.WriteString("## The problem\n\n") b.WriteString("A transaction is public before it executes. In a sealed-bid auction or ") b.WriteString("a simultaneous-move game, whoever moves last reads everyone else's ") b.WriteString("move and wins for free.\n\n") aliceSalt := "alice-secret-salt-1" bobSalt := "bob-secret-salt-0001" aliceCommit := commitreveal.MustCommit("rock", aliceSalt) bobCommit := commitreveal.MustCommit("paper", bobSalt) b.WriteString("## Phase 1 — commit\n\n") b.WriteString("Each player publishes only `H(value ‖ salt)`. Nothing about the move ") b.WriteString("leaks, but neither can change it later.\n\n") b.WriteString("| player | commitment |\n|---|---|\n") b.WriteString("| alice | `" + ui.ShortN(aliceCommit, 16, 0) + "` |\n") b.WriteString("| bob | `" + ui.ShortN(bobCommit, 16, 0) + "` |\n") b.WriteString("\n## Phase 2 — reveal\n\n") b.WriteString("Now the values and salts are published and checked against the ") b.WriteString("commitments recorded earlier:\n\n") b.WriteString("| check | result |\n|---|---|\n") row(&b, "alice opens with `rock`", commitreveal.Open(aliceCommit, "rock", aliceSalt)) row(&b, "bob opens with `paper`", commitreveal.Open(bobCommit, "paper", bobSalt)) row(&b, "bob tries `scissors` instead", commitreveal.Open(bobCommit, "scissors", bobSalt)) row(&b, "bob claims alice's commitment", commitreveal.Open(aliceCommit, "rock", bobSalt)) b.WriteString("\nBob cannot switch his move after seeing Alice's, and cannot pass off ") b.WriteString("her commitment as his own.\n\n") b.WriteString("## Why the salt is mandatory\n\n") b.WriteString("Rock-paper-scissors has three possible moves. Without a salt there are ") b.WriteString("exactly three possible hashes, and hashing all three breaks the scheme ") b.WriteString("outright. The library refuses a salt shorter than `") b.WriteString(itoa(commitreveal.MinSaltLen) + "` bytes rather than leaving that as advice:\n\n") _, err := commitreveal.Commit("rock", "tooshort") b.WriteString("- `Commit(\"rock\", \"tooshort\")` → `") if err != nil { b.WriteString(err.Error()) } b.WriteString("`\n\n") b.WriteString("The salt also keeps two players who pick the *same* move from ") b.WriteString("publishing the same commitment:\n\n") one := commitreveal.MustCommit("rock", "salt-one-0123456789") two := commitreveal.MustCommit("rock", "salt-two-0123456789") b.WriteString("| same move, different salt | commitment |\n|---|---|\n") b.WriteString("| player 1 | `" + ui.ShortN(one, 16, 0) + "` |\n") b.WriteString("| player 2 | `" + ui.ShortN(two, 16, 0) + "` |\n") b.WriteString("\n## Two details that are easy to get wrong\n\n") b.WriteString("- **Length-prefixed hashing.** With plain concatenation `(\"ab\",\"cd…\")` ") b.WriteString("and `(\"abc\",\"d…\")` hash identically, so one commitment could be opened ") b.WriteString("two different ways.\n") b.WriteString("- **Constant-time comparison.** A short-circuiting check leaks, through ") b.WriteString("timing, how many leading bytes of a guess were right — enough to rebuild ") b.WriteString("a commitment byte by byte.\n") return b.String() } func row(b *strings.Builder, label string, err error) { b.WriteString("| " + label + " | ") if err == nil { b.WriteString("✅ accepted") } else { b.WriteString("❌ `" + err.Error() + "`") } b.WriteString(" |\n") } func itoa(i int) string { if i == 0 { return "0" } s := "" for i > 0 { s = string(rune('0'+i%10)) + s i /= 10 } return s }
- #6gnomod.toml
- #7module = "gno.land/r/moul/x/daily/commitrevealdemo/v1" gno = "0.9" private = true
- #8render_example_test.gno
- #9package commitrevealdemo // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # Commit–Reveal // // Bind to a choice without disclosing it, demoing the [`p/moul/x/daily/commitreveal`](/p/moul/x/daily/commitreveal/v0) library. // // ## The problem // // A transaction is public before it executes. In a sealed-bid auction or a simultaneous-move game, whoever moves last reads everyone else's move and wins for free. // // ## Phase 1 — commit // // Each player publishes only `H(value ‖ salt)`. Nothing about the move leaks, but neither can change it later. // // | player | commitment | // |---|---| // | alice | `11811a2d4eede29f…` | // | bob | `db066e8c917e07d5…` | // // ## Phase 2 — reveal // // Now the values and salts are published and checked against the commitments recorded earlier: // // | check | result | // |---|---| // | alice opens with `rock` | ✅ accepted | // | bob opens with `paper` | ✅ accepted | // | bob tries `scissors` instead | ❌ `commitreveal: reveal does not match the commitment` | // | bob claims alice's commitment | ❌ `commitreveal: reveal does not match the commitment` | // // Bob cannot switch his move after seeing Alice's, and cannot pass off her commitment as his own. // // ## Why the salt is mandatory // // Rock-paper-scissors has three possible moves. Without a salt there are exactly three possible hashes, and hashing all three breaks the scheme outright. The library refuses a salt shorter than `16` bytes rather than leaving that as advice: // // - `Commit("rock", "tooshort")` → `commitreveal: salt is shorter than MinSaltLen` // // The salt also keeps two players who pick the *same* move from publishing the same commitment: // // | same move, different salt | commitment | // |---|---| // | player 1 | `a5d84fa94157acca…` | // | player 2 | `ccbc5d0b7ab1e84d…` | // // ## Two details that are easy to get wrong // // - **Length-prefixed hashing.** With plain concatenation `("ab","cd…")` and `("abc","d…")` hash identically, so one commitment could be opened two different ways. // - **Constant-time comparison.** A short-circuiting check leaks, through timing, how many leading bytes of a guess were right — enough to rebuild a commitment byte by byte. }
- Attached funds
- 8000000ugnot
Arguments · 9
- #1eggling
- #2README.md
- #3# Eggling > ⚠️ **Experimental — generated with no human supervision** by the daily MCP pipeline to exercise gno tooling. Not audited. See [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md). --- Plant an on-chain egg, let it incubate for at least 20 blocks, then hatch it — how long you waited plus a bit of sealed-in luck decides whether you get a Common Slime or a Legendary Glimmer. Train hatched creatures for XP and levels, and rename them once they've hatched. It's a lighter, luck-and-patience spin on yesterday's tamagotchi: no hunger or death, just the thrill of the hatch and a slow climb of levels. Built for gno 0.9. ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/eggling) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `escapeInline`, `shortAddr` helpers. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. **Real escaping.** The local helper replaced a handful of markdown metacharacters. `ui.Inline` and `ui.Cell` delegate to [`p/nt/markdown/sanitize`](/p/nt/markdown/sanitize/v0), which also strips bidi and zero-width characters and folds newlines, so user text cannot reorder or escape the line it sits on. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4eggling.gno
- #5// Package eggling is an on-chain egg incubator and creature collector. // Plant an egg, let it incubate for a minimum number of blocks, then Hatch // it: the longer you waited (and a little luck, seeded from the egg's own // history) decides the rarity of the creature you get. Train hatched // creatures to level them up. Unlike a tamagotchi there's nothing to feed // or lose to neglect — eggling is about patience and the luck of the // hatch, not survival. package eggling import ( "crypto/sha256" "encoding/hex" "sort" "strconv" "strings" "chain/runtime" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // minIncubation is the minimum number of blocks an egg must sit before it // can hatch. const minIncubation int64 = 20 // waitCap is the incubation-time bonus cap (in blocks) used by rarityTier, // so waiting forever doesn't guarantee a legendary — it just improves odds. const waitCap int64 = 200 var speciesNames = [...]string{ "Slime", "Sprout", "Ember", "Pebble", "Breeze", "Glimmer", "Shade", "Coral", } var rarityNames = [...]string{"Common", "Uncommon", "Rare", "Legendary"} var rarityEmoji = [...]string{"⚪", "🟢", "🔵", "🟡"} // egg is the persisted record for one planted egg, hatched or not. type egg struct { ID string Owner address PlantedAt int64 Hatched bool Species string RarityTier int Name string XP int Level int } var ( eggs avl.Tree // id string -> *egg nextID int totalPlanted int totalHatched int rarityCounts [4]int ) func get(id string) (*egg, bool) { v := eggs.Get(id) if v == nil { return nil, false } return v.(*egg), true } // hashSeed hashes the given parts (joined with ":") to a deterministic hex // digest. Used to derive an egg's species and rarity roll from facts // already fixed at plant time, so nobody — not even the owner — can game // the outcome after the fact. func hashSeed(parts ...string) string { sum := sha256.Sum256([]byte(strings.Join(parts, ":"))) return hex.EncodeToString(sum[:]) } // seedBytes pulls the first two bytes out of a hex digest for use as two // independent 0-255 rolls (species pick, rarity roll). func seedBytes(seedHex string) (byte, byte) { raw, err := hex.DecodeString(seedHex) if err != nil || len(raw) < 2 { return 0, 0 } return raw[0], raw[1] } // rarityTier turns a 0-255 roll plus the blocks waited into a tier index // 0..3 (common..legendary). Waiting longer raises the effective score, so // patience improves your odds, but a bad roll can still land common even // after a long wait, and a lucky roll can hatch rare almost immediately. func rarityTier(roll uint8, waitBlocks int64) int { bonus := waitBlocks if bonus > waitCap { bonus = waitCap } score := int(roll) + int(bonus)/2 switch { case score >= 300: return 3 case score >= 220: return 2 case score >= 140: return 1 default: return 0 } } // plant is the non-crossing core of PlantEgg. func plant(owner address, height int64) *egg { nextID++ id := strconv.Itoa(nextID) e := &egg{ID: id, Owner: owner, PlantedAt: height} eggs.Set(id, e) totalPlanted++ return e } // PlantEgg starts incubating a new egg for the caller. Returns its ID. func PlantEgg(cur realm) string { if !cur.IsCurrent() { panic("spoofed realm") } e := plant(cur.Previous().Address(), runtime.ChainHeight()) return "🥚 planted egg #" + e.ID + " — incubate at least " + strconv.Itoa(int(minIncubation)) + " blocks, then Hatch(\"" + e.ID + "\")" } // hatch is the non-crossing core of Hatch. func hatch(e *egg, height int64) string { if e.Hatched { panic("egg #" + e.ID + " already hatched into a " + e.Species) } wait := height - e.PlantedAt if wait < minIncubation { panic("egg #" + e.ID + " needs " + strconv.Itoa(int(minIncubation-wait)) + " more blocks to incubate") } seed := hashSeed(e.ID, e.Owner.String(), strconv.FormatInt(e.PlantedAt, 10)) rarityRoll, speciesRoll := seedBytes(seed) tier := rarityTier(rarityRoll, wait) species := speciesNames[int(speciesRoll)%len(speciesNames)] e.Hatched = true e.Species = species e.RarityTier = tier e.Name = species e.Level = 1 rarityCounts[tier]++ totalHatched++ return rarityEmoji[tier] + " egg #" + e.ID + " hatched into a " + rarityNames[tier] + " " + species + "!" } // Hatch hatches an incubated egg the caller owns. func Hatch(cur realm, id string) string { if !cur.IsCurrent() { panic("spoofed realm") } e, ok := get(id) if !ok { panic("no such egg #" + id) } if e.Owner != cur.Previous().Address() { panic("only the owner can hatch egg #" + id) } return hatch(e, runtime.ChainHeight()) } // train is the non-crossing core of Train. func train(e *egg) string { if !e.Hatched { panic("egg #" + e.ID + " hasn't hatched yet") } e.XP += 10 newLevel := e.XP/50 + 1 leveled := newLevel > e.Level e.Level = newLevel msg := e.Name + " gained 10 XP (" + strconv.Itoa(e.XP) + " total)" if leveled { msg += " and reached level " + strconv.Itoa(e.Level) + "!" } return msg } // Train gives a hatched creature the caller owns some experience, leveling // it up every 50 XP. func Train(cur realm, id string) string { if !cur.IsCurrent() { panic("spoofed realm") } e, ok := get(id) if !ok { panic("no such egg #" + id) } if e.Owner != cur.Previous().Address() { panic("only the owner can train egg #" + id) } return train(e) } // rename is the non-crossing core of Rename. func rename(e *egg, name string) string { if !e.Hatched { panic("egg #" + e.ID + " hasn't hatched yet — nothing to name") } name = strings.TrimSpace(name) if name == "" { panic("name cannot be empty") } old := e.Name e.Name = name return old + " renamed to " + name } // Rename gives a hatched creature the caller owns a custom name. func Rename(cur realm, id string, name string) string { if !cur.IsCurrent() { panic("spoofed realm") } e, ok := get(id) if !ok { panic("no such egg #" + id) } if e.Owner != cur.Previous().Address() { panic("only the owner can rename egg #" + id) } return rename(e, name) } // byIDNumeric orders eggs by their numeric ID, ascending. type byIDNumeric []*egg func (r byIDNumeric) Len() int { return len(r) } func (r byIDNumeric) Swap(i, j int) { r[i], r[j] = r[j], r[i] } func (r byIDNumeric) Less(i, j int) bool { a, _ := strconv.Atoi(r[i].ID) b, _ := strconv.Atoi(r[j].ID) return a < b } func allEggs() []*egg { var rows []*egg eggs.Iterate("", "", func(_ string, v any) bool { rows = append(rows, v.(*egg)) return false }) sort.Stable(byIDNumeric(rows)) return rows } func renderHome() string { var b strings.Builder b.WriteString("# 🥚 Eggling\n\n") b.WriteString("An on-chain egg incubator. `PlantEgg()` to start one, wait at least " + strconv.Itoa(int(minIncubation)) + " blocks, then `Hatch(id)` — the longer you " + "waited (plus a little sealed-in luck) decides the rarity of the creature you get. " + "`Train(id)` levels a hatched creature up; `Rename(id, name)` gives it a name.\n\n") b.WriteString("- Eggs planted: " + strconv.Itoa(totalPlanted) + "\n") b.WriteString("- Creatures hatched: " + strconv.Itoa(totalHatched) + "\n") if totalHatched > 0 { for tier := 3; tier >= 0; tier-- { if rarityCounts[tier] > 0 { b.WriteString(" - " + rarityEmoji[tier] + " " + rarityNames[tier] + ": " + strconv.Itoa(rarityCounts[tier]) + "\n") } } } rows := allEggs() b.WriteString("\n## Eggs\n\n") if len(rows) == 0 { b.WriteString("_None planted yet. Be the first!_\n") return b.String() } b.WriteString("| ID | Owner | Status | Level |\n") b.WriteString("| :--- | :--- | :--- | ---: |\n") for _, e := range rows { status := "🥚 incubating" level := "-" if e.Hatched { status = rarityEmoji[e.RarityTier] + " " + ui.Inline(e.Name) + " (" + rarityNames[e.RarityTier] + " " + e.Species + ")" level = strconv.Itoa(e.Level) } b.WriteString("| #" + e.ID + " | " + ui.Addr(e.Owner) + " | " + status + " | " + level + " |\n") } b.WriteString("\n_View a single egg at this realm's path plus its ID (e.g. `.../eggling:3`), " + "or one owner's eggs plus their address._\n") return b.String() } func renderEgg(id string, height int64) string { e, ok := get(id) if !ok { return "# Egg #" + ui.Inline(id) + "\n\nNo such egg.\n" } var b strings.Builder if !e.Hatched { wait := height - e.PlantedAt b.WriteString("# 🥚 Egg #" + e.ID + "\n\n") b.WriteString("- Owner: `" + e.Owner.String() + "`\n") b.WriteString("- Planted at block: " + strconv.FormatInt(e.PlantedAt, 10) + "\n") if wait < minIncubation { b.WriteString("- Ready to hatch in: " + strconv.FormatInt(minIncubation-wait, 10) + " more blocks\n") } else { b.WriteString("- Ready to hatch now — call `Hatch(\"" + e.ID + "\")`\n") } return b.String() } b.WriteString("# " + rarityEmoji[e.RarityTier] + " " + e.Name + "\n\n") b.WriteString("- Owner: `" + e.Owner.String() + "`\n") b.WriteString("- Species: " + e.Species + "\n") b.WriteString("- Rarity: " + rarityNames[e.RarityTier] + "\n") b.WriteString("- Level: " + strconv.Itoa(e.Level) + " (" + strconv.Itoa(e.XP) + " XP)\n") return b.String() } func renderOwner(rawAddr string) string { owner := address(strings.TrimSpace(rawAddr)) safe := ui.Inline(owner.String()) var b strings.Builder b.WriteString("# Eggs owned by " + safe + "\n\n") found := false for _, e := range allEggs() { if e.Owner != owner { continue } found = true if e.Hatched { b.WriteString("- #" + e.ID + ": " + rarityEmoji[e.RarityTier] + " " + ui.Inline(e.Name) + " — " + rarityNames[e.RarityTier] + " " + e.Species + ", level " + strconv.Itoa(e.Level) + "\n") } else { b.WriteString("- #" + e.ID + ": 🥚 incubating\n") } } if !found { b.WriteString("_No eggs found for this address._\n") } return b.String() } // Render shows the full egg list at "", a single egg's detail when path is // a numeric ID, or one owner's eggs when path is a bech32 address. func Render(path string) string { path = strings.TrimPrefix(strings.TrimSpace(path), "/") if path == "" { return renderHome() } if _, err := strconv.Atoi(path); err == nil { return renderEgg(path, runtime.ChainHeight()) } return renderOwner(path) }
- #6eggling_test.gno
- #7package eggling import ( "strings" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) func resetState() { eggs = avl.Tree{} nextID = 0 totalPlanted = 0 totalHatched = 0 rarityCounts = [4]int{} } func TestHashSeedDeterministicAndSensitive(t *testing.T) { a := hashSeed("1", "g1alice", "100") b := hashSeed("1", "g1alice", "100") if a != b { t.Fatal("hashSeed should be deterministic for the same inputs") } if a == hashSeed("2", "g1alice", "100") { t.Fatal("different egg IDs should hash differently") } if len(a) != 64 { t.Fatalf("hashSeed length = %d, want 64 (hex sha256)", len(a)) } } func TestSeedBytesFromKnownDigest(t *testing.T) { // sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 r1, r2 := seedBytes(hashSeed("")) if r1 != 0xe3 || r2 != 0xb0 { t.Fatalf("seedBytes = (%x, %x), want (e3, b0)", r1, r2) } } func TestRarityTierBoundaries(t *testing.T) { cases := []struct { roll uint8 wait int64 want int }{ {0, 0, 0}, // score 0 -> common {139, 0, 0}, // score 139 -> common {140, 0, 1}, // score 140 -> uncommon {219, 0, 1}, // score 219 -> uncommon {220, 0, 2}, // score 220 -> rare {255, 0, 2}, // score 255 -> rare (no wait bonus) {255, 200, 3}, // score 355 -> legendary (max wait bonus) {0, 200, 0}, // score 100 -> still common, roll matters too } for _, c := range cases { if got := rarityTier(c.roll, c.wait); got != c.want { t.Fatalf("rarityTier(%d, %d) = %d, want %d", c.roll, c.wait, got, c.want) } } } func TestRarityTierCapsWaitBonus(t *testing.T) { // waitCap is 200; waiting 1000 blocks should score identically to // waiting exactly 200. if rarityTier(50, 1000) != rarityTier(50, 200) { t.Fatal("rarityTier should cap the wait bonus at waitCap") } } func TestPlantAssignsSequentialIDs(t *testing.T) { resetState() alice := testutils.TestAddress("alice") e1 := plant(alice, 100) e2 := plant(alice, 101) if e1.ID != "1" || e2.ID != "2" { t.Fatalf("expected sequential IDs 1, 2; got %s, %s", e1.ID, e2.ID) } if totalPlanted != 2 { t.Fatalf("totalPlanted = %d, want 2", totalPlanted) } } func TestHatchTooEarlyPanics(t *testing.T) { resetState() alice := testutils.TestAddress("alice") e := plant(alice, 100) defer func() { if r := recover(); r == nil { t.Fatal("expected panic hatching before minIncubation blocks pass") } }() hatch(e, 100+minIncubation-1) } func TestHatchTwicePanics(t *testing.T) { resetState() alice := testutils.TestAddress("alice") e := plant(alice, 100) hatch(e, 100+minIncubation) defer func() { if r := recover(); r == nil { t.Fatal("expected panic hatching an already-hatched egg") } }() hatch(e, 100+minIncubation) } func TestHatchSetsSpeciesAndCountsStats(t *testing.T) { resetState() alice := testutils.TestAddress("alice") e := plant(alice, 100) hatch(e, 100+minIncubation) if !e.Hatched { t.Fatal("expected egg to be hatched") } if e.Species == "" { t.Fatal("expected a species to be assigned") } if e.Level != 1 { t.Fatalf("expected level 1 on hatch, got %d", e.Level) } if totalHatched != 1 { t.Fatalf("totalHatched = %d, want 1", totalHatched) } if rarityCounts[e.RarityTier] != 1 { t.Fatalf("expected rarityCounts[%d] = 1, got %d", e.RarityTier, rarityCounts[e.RarityTier]) } } func TestTrainBeforeHatchPanics(t *testing.T) { resetState() alice := testutils.TestAddress("alice") e := plant(alice, 100) defer func() { if r := recover(); r == nil { t.Fatal("expected panic training an unhatched egg") } }() train(e) } func TestTrainAccumulatesXPAndLevelsUp(t *testing.T) { resetState() alice := testutils.TestAddress("alice") e := plant(alice, 100) hatch(e, 100+minIncubation) for i := 0; i < 5; i++ { train(e) } if e.XP != 50 { t.Fatalf("XP = %d, want 50", e.XP) } if e.Level != 2 { t.Fatalf("Level = %d, want 2", e.Level) } } func TestRenameRequiresHatchedAndNonEmpty(t *testing.T) { resetState() alice := testutils.TestAddress("alice") e := plant(alice, 100) func() { defer func() { if r := recover(); r == nil { t.Fatal("expected panic renaming an unhatched egg") } }() rename(e, "Sparky") }() hatch(e, 100+minIncubation) rename(e, "Sparky") if e.Name != "Sparky" { t.Fatalf("Name = %q, want Sparky", e.Name) } defer func() { if r := recover(); r == nil { t.Fatal("expected panic renaming with an empty name") } }() rename(e, " ") } func TestRenderShowsEmptyThenEggThenOwner(t *testing.T) { resetState() if !strings.Contains(Render(""), "None planted yet") { t.Fatal("expected empty-state placeholder") } alice := testutils.TestAddress("alice") e := plant(alice, 100) hatch(e, 100+minIncubation) home := Render("") if !strings.Contains(home, "#"+e.ID) { t.Fatal("expected egg row in home listing") } detail := Render(e.ID) if !strings.Contains(detail, "Rarity") { t.Fatal("expected detail card to show rarity") } owned := Render(alice.String()) if !strings.Contains(owned, e.Name) { t.Fatal("expected owner view to list the creature by name") } } // TestFullLifecycleViaCrossingCalls smoke-tests the exported, realm-crossing // entry points end to end (as an on-chain caller would invoke them). func TestFullLifecycleViaCrossingCalls(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) msg := PlantEgg(cross(cur)) if !strings.Contains(msg, "planted egg #1") { t.Fatalf("expected plant confirmation, got %q", msg) } testing.SkipHeights(minIncubation) hatchMsg := Hatch(cross(cur), "1") if !strings.Contains(hatchMsg, "hatched into a") { t.Fatalf("expected hatch confirmation, got %q", hatchMsg) } trainMsg := Train(cross(cur), "1") if !strings.Contains(trainMsg, "gained 10 XP") { t.Fatalf("expected train confirmation, got %q", trainMsg) } renameMsg := Rename(cross(cur), "1", "Buddy") if !strings.Contains(renameMsg, "renamed to Buddy") { t.Fatalf("expected rename confirmation, got %q", renameMsg) } e, ok := get("1") if !ok || e.Name != "Buddy" || e.XP != 10 { t.Fatalf("unexpected final state: %+v", e) } } // TestOnlyOwnerCanHatch checks that another address can't hatch someone // else's egg. func TestOnlyOwnerCanHatch(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) PlantEgg(cross(cur)) testing.SkipHeights(minIncubation) testing.SetRealm(testing.NewUserRealm(bob)) rec := revive(func() { Hatch(cross(cur), "1") }) if rec == nil { t.Fatal("expected panic hatching someone else's egg") } }
- #8gnomod.toml
- #9module = "gno.land/r/moul/x/daily/eggling/v1" gno = "0.9" private = true
- Attached funds
- 5000000ugnot
Arguments · 11
- #1eightball
- #2README.md
- #3# 🎱 Magic 8-Ball > ⚠️ **Experimental — generated with no human supervision.** This realm was produced > by an MCP-driven agent to exercise the gno MCP server + tooling and to generate > test content for gno compilers, linters and formatters. **Not audited. Not for > production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- A classic Magic 8-Ball as a gno.land realm. Ask a yes/no question and the current **block height** picks one of the 20 canonical answers — fully deterministic, since there is no true randomness on-chain. - `Ask(cur realm, question string) string` — records `{caller, question, answer, height}` and returns the answer (crossing function; emits a `Shaken` event). - `Asked() int` — how many questions have been asked (read-only). - `Render(path string) string` — shows the last answer and a table of recent shakes. Built for the sapphire testnet (gno `0.9`). Example: ``` gnokey maketx call -pkgpath gno.land/r/moul/x/daily/eightball/v0 \ -func Ask -args "will this PR pass CI?" ... ``` ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/eightball) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `shortAddr` helper. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. Visible change: addresses now render in monospace. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4eightball.gno
- #5// Package eightball is a Magic 8-Ball realm: ask a yes/no question and the // block height decides your fate. Deterministic (no real randomness on-chain). package eightball import ( "chain" "chain/runtime" "chain/runtime/unsafe" "strconv" "strings" "gno.land/p/moul/kit/ui/v0" ) // The 20 classic Magic 8-Ball answers. var answers = []string{ "It is certain.", "It is decidedly so.", "Without a doubt.", "Yes definitely.", "You may rely on it.", "As I see it, yes.", "Most likely.", "Outlook good.", "Yes.", "Signs point to yes.", "Reply hazy, try again.", "Ask again later.", "Better not tell you now.", "Cannot predict now.", "Concentrate and ask again.", "Don't count on it.", "My reply is no.", "My sources say no.", "Outlook not so good.", "Very doubtful.", } type shake struct { asker string question string answer string height int64 } var history []shake // answerIndex maps (block height, prior shakes) to an answer slot. Pure and // deterministic so it can be tested without touching realm state. func answerIndex(height int64, n int) int { i := (height + int64(n)) % int64(len(answers)) if i < 0 { i += int64(len(answers)) } return int(i) } // Ask poses a question to the Magic 8-Ball and returns its answer. Crossing // function: it mutates persistent state, so it takes `cur realm` (gno 0.9). func Ask(cur realm, question string) string { question = strings.TrimSpace(question) if question == "" { panic("question must not be empty") } if len(question) > 200 { panic("question too long (max 200 chars)") } h := runtime.ChainHeight() ans := answers[answerIndex(h, len(history))] history = append(history, shake{ asker: unsafe.PreviousRealm().Address().String(), question: question, answer: ans, height: h, }) chain.Emit("Shaken", "answer", ans, "height", strconv.FormatInt(h, 10)) return ans } // Asked returns how many questions have been asked. Read-only. func Asked() int { return len(history) } // Render is the gnoweb Markdown view. func Render(path string) string { var b strings.Builder b.WriteString("# 🎱 Magic 8-Ball\n\n") b.WriteString("> ⚠️ Experimental, auto-generated with no human supervision (MCP test corpus). ") b.WriteString("Not audited. See [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md).\n\n") b.WriteString("Ask a yes/no question — the block height decides your fate.\n\n") b.WriteString("**Questions asked:** ") b.WriteString(strconv.Itoa(len(history))) b.WriteString("\n\n") if len(history) == 0 { b.WriteString("_No questions yet. Call `Ask(\"will it rain tomorrow?\")`._\n") return b.String() } last := history[len(history)-1] b.WriteString("Last shake: 🎱 _") b.WriteString(last.answer) b.WriteString("_\n\n| # | asker | question | answer | height |\n") b.WriteString("|---|---|---|---|---|\n") start := len(history) - 1 end := start - 14 if end < 0 { end = 0 } for i := start; i >= end; i-- { e := history[i] b.WriteString("| ") b.WriteString(strconv.Itoa(i + 1)) b.WriteString(" | ") b.WriteString(ui.AddrOf(e.asker)) b.WriteString(" | ") b.WriteString(e.question) b.WriteString(" | ") b.WriteString(e.answer) b.WriteString(" | ") b.WriteString(strconv.FormatInt(e.height, 10)) b.WriteString(" |\n") } return b.String() }
- #6eightball_test.gno
- #7package eightball import ( "testing" "gno.land/p/nt/uassert/v0" ) func TestAnswerIndexInRange(t *testing.T) { cases := []struct { height int64 n int }{ {0, 0}, {1, 0}, {19, 0}, {20, 0}, {21, 5}, {999999, 3}, {-1, 0}, {-1000, 7}, } for _, c := range cases { idx := answerIndex(c.height, c.n) uassert.True(t, idx >= 0 && idx < len(answers), "index out of range for height="+itoa(c.height)) } } func TestAnswerIndexDeterministic(t *testing.T) { uassert.Equal(t, answerIndex(42, 3), answerIndex(42, 3), "same inputs, same slot") } func TestRenderEmpty(t *testing.T) { out := Render("") uassert.NotEmpty(t, out, "render must not be empty") uassert.True(t, len(answers) == 20, "classic 8-ball has 20 answers") } func itoa(n int64) string { neg := n < 0 if neg { n = -n } if n == 0 { return "0" } var b [24]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } if neg { i-- b[i] = '-' } return string(b[i:]) }
- #8gnomod.toml
- #9module = "gno.land/r/moul/x/daily/eightball/v1" gno = "0.9" private = true
- #10render_example_test.gno
- #11package eightball // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # 🎱 Magic 8-Ball // // > ⚠️ Experimental, auto-generated with no human supervision (MCP test corpus). Not audited. See [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md). // // Ask a yes/no question — the block height decides your fate. // // **Questions asked:** 0 // // _No questions yet. Call `Ask("will it rain tomorrow?")`._ }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1escrow
- #2README.md
- #3# Escrow > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- A minimal 2-party escrow realm for gno.land (a port of the classic Solidity escrow idea, accounting only — no funds move). One party opens a deal against a counterparty with free-form terms; both parties confirm to settle it, and the creator can cancel any deal that is still open. **Realm path:** `gno.land/r/REPLACE_ADDR/escrow` ## Example calls ``` # Alice opens a deal with Bob; returns the new deal id (e.g. 0) Create(g1bob..., "swap NFT for 100 GNOT") # Each party confirms; when both have confirmed, the deal becomes "Settled" Confirm(0) # called by Alice Confirm(0) # called by Bob -> Settled # The creator may cancel while the deal is still Open Cancel(0) # called by Alice ``` ## Read (Render) - `/` — table of all deals: id, parties, state, confirmations, terms. - `/<id>` — details for a single deal (e.g. `/0`). ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/escrow) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `short`, `escapePipe` helpers. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. **Real escaping.** The local helper replaced a handful of markdown metacharacters. `ui.Inline` and `ui.Cell` delegate to [`p/nt/markdown/sanitize`](/p/nt/markdown/sanitize/v0), which also strips bidi and zero-width characters and folds newlines, so user text cannot reorder or escape the line it sits on. Visible change: addresses now render in monospace. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4escrow.gno
- #5package escrow import ( "chain" "chain/runtime/unsafe" "errors" "strconv" "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // State constants for a deal's lifecycle. const ( StateOpen = "Open" StateSettled = "Settled" StateCanceled = "Canceled" ) // Deal is a 2-party escrow record. Accounting only: no funds move, // this tracks agreement and mutual confirmation between two parties. type Deal struct { ID int Creator address Counterparty address Terms string State string CreatorOK bool CounterOK bool } var ( deals = avl.NewTree() // id (string) -> *Deal nextID int ) var ( errNotFound = errors.New("escrow: deal not found") errNotParty = errors.New("escrow: caller is not a party to this deal") errNotOpen = errors.New("escrow: deal is not open") errNotCreator = errors.New("escrow: only the creator can cancel") errBadAddr = errors.New("escrow: invalid counterparty address") errSelfDeal = errors.New("escrow: counterparty must differ from creator") ) // Create opens a new escrow deal between the caller (creator) and the // given counterparty, governed by a free-form terms string. Returns the // new deal id. Panics on invalid input (cross-realm abort). func Create(cur realm, counterparty address, terms string) int { if !counterparty.IsValid() { panic(errBadAddr) } creator := unsafe.PreviousRealm().Address() if counterparty == creator { panic(errSelfDeal) } id := nextID nextID++ d := &Deal{ ID: id, Creator: creator, Counterparty: counterparty, Terms: terms, State: StateOpen, } deals.Set(strconv.Itoa(id), d) chain.Emit("Created", "id", strconv.Itoa(id), "creator", creator.String(), "counterparty", counterparty.String()) return id } // Confirm records confirmation by whichever party is calling. Once both // parties have confirmed, the deal transitions to Settled. func Confirm(cur realm, id int) { d := mustGet(id) if d.State != StateOpen { panic(errNotOpen) } caller := unsafe.PreviousRealm().Address() switch caller { case d.Creator: d.CreatorOK = true case d.Counterparty: d.CounterOK = true default: panic(errNotParty) } chain.Emit("Confirmed", "id", strconv.Itoa(id), "party", caller.String()) if d.CreatorOK && d.CounterOK { d.State = StateSettled chain.Emit("Settled", "id", strconv.Itoa(id)) } } // Cancel voids an open deal. Only the creator may cancel, and only // before the deal has settled. func Cancel(cur realm, id int) { d := mustGet(id) if d.State != StateOpen { panic(errNotOpen) } caller := unsafe.PreviousRealm().Address() if caller != d.Creator { panic(errNotCreator) } d.State = StateCanceled chain.Emit("Canceled", "id", strconv.Itoa(id)) } func mustGet(id int) *Deal { v := deals.Get(strconv.Itoa(id)) if v == nil { panic(errNotFound) } return v.(*Deal) } // Render lists all escrow deals with their parties, terms, and state. // When path is a numeric id, renders just that deal. func Render(path string) string { path = strings.TrimSpace(strings.Trim(path, "/")) if path != "" { if id, err := strconv.Atoi(path); err == nil { v := deals.Get(strconv.Itoa(id)) if v == nil { return "# Escrow\n\nDeal `" + path + "` not found.\n" } return renderOne(v.(*Deal)) } } var b strings.Builder b.WriteString("# Escrow\n\n") b.WriteString("2-party escrow deals (accounting only — no funds move).\n\n") if deals.Size() == 0 { b.WriteString("_No deals yet._\n") return b.String() } b.WriteString("| ID | Creator | Counterparty | State | Confirmations | Terms |\n") b.WriteString("|----|---------|--------------|-------|---------------|-------|\n") deals.Iterate("", "", func(_ string, v interface{}) bool { d := v.(*Deal) b.WriteString("| " + strconv.Itoa(d.ID) + " | " + ui.Addr(d.Creator) + " | " + ui.Addr(d.Counterparty) + " | " + d.State + " | " + confs(d) + " | " + ui.Cell(d.Terms) + " |\n") return false }) return b.String() } func renderOne(d *Deal) string { var b strings.Builder b.WriteString("# Escrow Deal #" + strconv.Itoa(d.ID) + "\n\n") b.WriteString("- **State:** " + d.State + "\n") b.WriteString("- **Creator:** " + d.Creator.String() + " (" + yn(d.CreatorOK) + ")\n") b.WriteString("- **Counterparty:** " + d.Counterparty.String() + " (" + yn(d.CounterOK) + ")\n") b.WriteString("- **Terms:** " + d.Terms + "\n") return b.String() } func confs(d *Deal) string { n := 0 if d.CreatorOK { n++ } if d.CounterOK { n++ } return strconv.Itoa(n) + "/2" } func yn(b bool) string { if b { return "confirmed" } return "pending" }
- #6escrow_test.gno
- #7package escrow import ( "strings" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") ) func resetState() { deals = avl.NewTree() nextID = 0 } func TestCreateAndConfirmSettles(cur realm, t *testing.T) { resetState() // Alice creates a deal with Bob. testing.SetRealm(testing.NewUserRealm(alice)) id := Create(cross(cur), bob, "swap NFT for 100 GNOT") uassert.Equal(t, 0, id) // Alice confirms (1/2, still open). Confirm(cross(cur), id) out := Render("0") uassert.True(t, strings.Contains(out, "State:** Open"), "expected still open after one confirm") // Bob confirms -> settles. testing.SetRealm(testing.NewUserRealm(bob)) Confirm(cross(cur), id) out = Render("0") uassert.True(t, strings.Contains(out, "Settled"), "expected Settled state") uassert.True(t, strings.Contains(out, "swap NFT for 100 GNOT"), "expected terms") } func TestCancelByCreator(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(alice)) id := Create(cross(cur), bob, "rent payment") Cancel(cross(cur), id) out := Render("") uassert.True(t, strings.Contains(out, "Canceled"), "expected Canceled state") } func TestNonPartyCannotConfirm(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(alice)) id := Create(cross(cur), bob, "consulting") // Carol is not a party. testing.SetRealm(testing.NewUserRealm(carol)) uassert.AbortsWithMessage(t, cur, "escrow: caller is not a party to this deal", func() { Confirm(cross(cur), id) }) } func TestOnlyCreatorCancels(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(alice)) id := Create(cross(cur), bob, "loan") testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsWithMessage(t, cur, "escrow: only the creator can cancel", func() { Cancel(cross(cur), id) }) }
- #8gnomod.toml
- #9module = "gno.land/r/moul/x/daily/escrow/v1" gno = "0.9" private = true
- Attached funds
- 6000000ugnot
Arguments · 9
- #1handles
- #2README.md
- #3# handles > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- A tiny on-chain nickname registry. Any address can claim one unique handle (3-20 lowercase letters/digits, starting with a letter), attach a short bio, transfer the handle to another address, or release it back into the pool. Two `avl.Tree` indexes (`handle -> record`, `owner -> record`) keep lookups in both directions cheap and deterministic. Realm path: `gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/handles` ## Example calls ``` Register(cur, "moul") # claim the handle "moul" for the caller SetBio(cur, "building gno stuff") Transfer(cur, g1...recipient) # hand the handle to another address Release(cur) # free the handle back up OwnerOf("moul") # (address, bool) HandleOf(g1...) # (string, bool) Count() # number of registered handles ``` `Render("")` lists every registered handle in a table; `Render("/moul")` shows a single handle's detail page (owner, bio, block registered). ## Toolchain status Built and tested against a local `gno` build at `master.3130+bf5b31eda` (GNOROOT pointed at a full `gnolang/gno` checkout). `gno test .` and `gno lint .` both pass (6 tests, 0 lint issues). Note for test authors: plain `recover()` did not catch panics thrown across a `cross(cur)` boundary in this toolchain build — the non-crossing call form (`Register(cur, ...)` instead of `Register(cross(cur), ...)`) was used in the two panic-expectation tests instead, since no realm boundary is crossed and there was nothing to revive. ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/handles) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `short` helper. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/handles/v1" gno = "0.9" private = true
- #6handles.gno
- #7// Package handles is a tiny on-chain nickname registry. // // Any address may claim one unique handle (3-20 lowercase letters/digits, // starting with a letter), attach a short bio, transfer the handle to // another address, or release it back to the pool. It is deliberately // small: two AVL indexes (handle -> record, address -> record) and a // handful of guarded mutators. package handles import ( "strconv" "strings" "chain" "chain/runtime" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) const maxBioLen = 140 // record is the persisted state for one claimed handle. type record struct { handle string owner address bio string sinceBlk int64 } var ( byHandle avl.Tree // handle (string) -> *record byOwner avl.Tree // owner address.String() -> *record ) // validHandle reports whether h is 3-20 chars, starts with a lowercase // letter, and contains only lowercase letters and digits thereafter. func validHandle(h string) bool { if len(h) < 3 || len(h) > 20 { return false } for i := 0; i < len(h); i++ { c := h[i] switch { case c >= 'a' && c <= 'z': case c >= '0' && c <= '9' && i > 0: default: return false } } return true } // Register claims handle for the caller. Aborts if the handle is invalid, // already taken, or the caller already owns a handle (release it first). func Register(cur realm, handle string) { if !cur.IsCurrent() { panic("invalid realm") } if !cur.Previous().IsUserCall() { panic("only a direct user call can register a handle") } if !validHandle(handle) { panic("handle must be 3-20 lowercase letters/digits, starting with a letter") } caller := cur.Previous().Address() if byOwner.Has(caller.String()) { panic("this address already owns a handle; release it first") } if byHandle.Has(handle) { panic("handle already taken") } r := &record{ handle: handle, owner: caller, sinceBlk: runtime.ChainHeight(), } byHandle.Set(handle, r) byOwner.Set(caller.String(), r) chain.Emit("HandleRegistered", "handle", handle, "owner", caller.String()) } // SetBio updates the bio of the handle owned by the caller. func SetBio(cur realm, bio string) { if !cur.IsCurrent() { panic("invalid realm") } if len(bio) > maxBioLen { panic("bio too long (max " + strconv.Itoa(maxBioLen) + " chars)") } r := ownRecord(cur) r.bio = bio chain.Emit("BioUpdated", "handle", r.handle) } // Release frees the handle owned by the caller, making it claimable again. func Release(cur realm) { if !cur.IsCurrent() { panic("invalid realm") } r := ownRecord(cur) byHandle.Remove(r.handle) byOwner.Remove(r.owner.String()) chain.Emit("HandleReleased", "handle", r.handle) } // Transfer moves the caller's handle to another address that does not // already own one. func Transfer(cur realm, to address) { if !cur.IsCurrent() { panic("invalid realm") } if !to.IsValid() { panic("invalid recipient address") } r := ownRecord(cur) if byOwner.Has(to.String()) { panic("recipient already owns a handle") } byOwner.Remove(r.owner.String()) r.owner = to byOwner.Set(to.String(), r) chain.Emit("HandleTransferred", "handle", r.handle, "to", to.String()) } // ownRecord resolves the record for the current caller, panicking if the // caller does not own a handle. func ownRecord(cur realm) *record { caller := cur.Previous().Address() v := byOwner.Get(caller.String()) if v == nil { panic("this address does not own a handle") } return v.(*record) } // OwnerOf returns the owner of handle and whether it exists. func OwnerOf(handle string) (address, bool) { v := byHandle.Get(handle) if v == nil { return address(""), false } return v.(*record).owner, true } // HandleOf returns the handle owned by addr, if any. func HandleOf(addr address) (string, bool) { v := byOwner.Get(addr.String()) if v == nil { return "", false } return v.(*record).handle, true } // Count returns the number of currently registered handles. func Count() int { return byHandle.Size() } // Render renders Markdown. "/" lists every handle; "/<handle>" shows a // single record's detail. func Render(path string) string { path = strings.TrimPrefix(path, "/") if path == "" { return renderIndex() } return renderHandle(path) } func renderIndex() string { var b strings.Builder b.WriteString("# 🪪 Handles\n\n") b.WriteString("A tiny on-chain nickname registry. Claim a short handle, ") b.WriteString("attach a bio, transfer it, or release it.\n\n") if byHandle.Size() == 0 { b.WriteString("_No handles registered yet._\n\n") b.WriteString("Call `Register(handle)` to claim the first one.\n") return b.String() } b.WriteString("| Handle | Owner | Since block |\n") b.WriteString("|--------|-------|-------------|\n") byHandle.Iterate("", "", func(key string, value interface{}) bool { r := value.(*record) b.WriteString("| [" + key + "](/" + key + ") | " + ui.Addr(r.owner) + " | " + strconv.Itoa(int(r.sinceBlk)) + " |\n") return false }) b.WriteString("\nTotal handles: " + strconv.Itoa(byHandle.Size()) + "\n") return b.String() } func renderHandle(handle string) string { v := byHandle.Get(handle) if v == nil { return "# Handles\n\nNo handle named `" + handle + "`.\n\n[← all handles](/)\n" } r := v.(*record) var b strings.Builder b.WriteString("# 🪪 @" + r.handle + "\n\n") b.WriteString("**Owner:** " + r.owner.String() + "\n\n") if r.bio != "" { b.WriteString("**Bio:** " + r.bio + "\n\n") } b.WriteString("**Registered at block:** " + strconv.Itoa(int(r.sinceBlk)) + "\n\n") b.WriteString("[← all handles](/)\n") return b.String() }
- #8handles_test.gno
- #9package handles import ( "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") ) // reset clears package state between tests so cases stay independent. func reset() { byHandle = avl.Tree{} byOwner = avl.Tree{} } func TestValidHandle(t *testing.T) { cases := map[string]bool{ "moul": true, "m": false, // too short "ab": false, // too short "Moul": false, // uppercase "1moul": false, // starts with digit "moul1": true, "moul-1": false, // hyphen "averylonghandlethatistoolong12": false, // too long } for h, want := range cases { if got := validHandle(h); got != want { t.Errorf("validHandle(%q) = %v, want %v", h, got, want) } } } func TestRegisterAndLookup(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), "moul") owner, ok := OwnerOf("moul") if !ok || owner != alice { t.Fatalf("OwnerOf(moul) = %v, %v; want %v, true", owner, ok, alice) } handle, ok := HandleOf(alice) if !ok || handle != "moul" { t.Fatalf("HandleOf(alice) = %q, %v; want moul, true", handle, ok) } if got := Count(); got != 1 { t.Fatalf("Count() = %d, want 1", got) } } func TestRegisterRejectsTakenHandle(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), "moul") testing.SetRealm(testing.NewUserRealm(bob)) defer func() { if r := recover(); r == nil { t.Errorf("expected panic registering a taken handle") } }() Register(cur, "moul") } func TestRegisterRejectsSecondHandleForSameOwner(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), "moul") testing.SetRealm(testing.NewUserRealm(alice)) defer func() { if r := recover(); r == nil { t.Errorf("expected panic when the same owner registers twice") } }() Register(cur, "second") } func TestTransferAndRelease(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), "moul") testing.SetRealm(testing.NewUserRealm(alice)) Transfer(cross(cur), bob) if _, ok := HandleOf(alice); ok { t.Fatalf("alice should no longer own a handle after transfer") } handle, ok := HandleOf(bob) if !ok || handle != "moul" { t.Fatalf("HandleOf(bob) = %q, %v; want moul, true", handle, ok) } testing.SetRealm(testing.NewUserRealm(bob)) Release(cross(cur)) if Count() != 0 { t.Fatalf("Count() = %d, want 0 after release", Count()) } if _, ok := OwnerOf("moul"); ok { t.Fatalf("moul should be unclaimed after release") } } func TestSetBioAndRender(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), "moul") SetBio(cross(cur), "building gno stuff") out := Render("/moul") if !contains(out, "moul") || !contains(out, "building gno stuff") { t.Fatalf("Render(/moul) missing expected content: %s", out) } index := Render("") if !contains(index, "moul") { t.Fatalf("Render('') missing handle in index: %s", index) } missing := Render("/nobody") if !contains(missing, "No handle named") { t.Fatalf("Render(/nobody) should report missing handle: %s", missing) } } func contains(s, substr string) bool { for i := 0; i+len(substr) <= len(s); i++ { if s[i:i+len(substr)] == substr { return true } } return false }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1kingofdice
- #2README.md
- #3# 👑 King of the Dice > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- A "king of the hill" dice game for gno.land (sapphire / gno 0.9). Call `Roll()` to throw a deterministic 1-100 die; beat the reigning king's roll and you dethrone them, becoming king yourself. Unlike a typical leaderboard that just sums points, this one ranks players by **accumulated reign length in blocks** — a single lucky high roll only pays off for as long as nobody beats it, so outlasting the field matters more than the roll itself. **Realm path:** `gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/kingofdice` ## Public API | Function | Kind | Description | | --- | --- | --- | | `Roll(cur realm)` | crossing (tx) | Roll 1-100; dethrones the king on a higher roll. | | `Render(path string) string` | view | Current king + reign-length leaderboard. | ## Example calls ```sh gnokey maketx call \ -pkgpath "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/kingofdice" \ -func Roll \ -gas-fee 1000000ugnot -gas-wanted 2000000 \ -broadcast -chainid sapphire-1 -remote <rpc> <keyname> ``` View the dashboard in gnoweb at `/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/kingofdice`, or query it: ```sh gnokey query "vm/qrender" -data "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/kingofdice:" -remote <rpc> ``` ## Toolchain status Verified locally: `gno test .` passes (built against a `gnolang/gno` checkout as `GNOROOT`, since the machine's configured `GNOROOT` pointed at a stale, deleted path). Tests avoid `gno.land/p/nt/uassert/v0` — the cached copy of that package uses the bare `cross` builtin as a function value, which the locally installed `gno` (master) rejects; plain `testing.T` calls sidestep that unrelated cache/toolchain drift. ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/kingofdice) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `medal` helper. **One podium.** `ui.Podium` replaces the medal switch three realms each had a copy of. Same output. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/kingofdice/v1" gno = "0.9" private = true
- #6kingofdice.gno
- #7// Package kingofdice is a "king of the hill" dice game. Anyone can Roll a // pseudo-random 1-100 die; beat the reigning king's roll and you dethrone // them, becoming king yourself. The leaderboard doesn't rank raw points — it // ranks accumulated *reign length*, measured in blocks held as king, so // camping on a lucky high roll pays off more than a single spike. package kingofdice import ( "sort" "strconv" "chain/runtime" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // playerStats is the persisted record for one address. type playerStats struct { addr address rolls int bestRoll int reigns int totalReignBlocks int64 // blocks held as king across all past reigns } var ( players avl.Tree // address string -> *playerStats king address kingRoll int reignSince int64 // chain height at which the current king took the throne nonce int totalRolls int ) // get returns the stored *playerStats for addr, creating one if absent. func get(addr address) *playerStats { key := addr.String() if v := players.Get(key); v != nil { return v.(*playerStats) } ps := &playerStats{addr: addr} players.Set(key, ps) return ps } // pseudoRoll derives a deterministic 1-100 value from the caller's address, // a per-call nonce, and the current chain height, so no two calls in the // same block from the same address collide, yet the result can't be // pre-computed off-chain before the triggering transaction lands. func pseudoRoll(addr address, nonce int, height int64) int { s := addr.String() var h int64 for i := 0; i < len(s); i++ { h = h*31 + int64(s[i]) } h += int64(nonce)*104729 + height*7919 if h < 0 { h = -h } return int(h%100) + 1 } // Roll throws the die for the caller. Rolling higher than the current king's // roll dethrones them (crediting their finished reign in blocks) and crowns // the caller as the new king. func Roll(cur realm) string { if !cur.IsCurrent() { panic("spoofed realm") } caller := cur.Previous().Address() nonce++ totalRolls++ height := runtime.ChainHeight() roll := pseudoRoll(caller, nonce, height) ps := get(caller) ps.rolls++ if roll > ps.bestRoll { ps.bestRoll = roll } if !king.IsValid() || roll > kingRoll { if king.IsValid() { prev := get(king) prev.totalReignBlocks += height - reignSince } king = caller kingRoll = roll reignSince = height ps.reigns++ return "you rolled " + strconv.Itoa(roll) + " and seized the throne!" } if king == caller { return "you rolled " + strconv.Itoa(roll) + " — you're still king (need to beat " + strconv.Itoa(kingRoll) + ")" } return "you rolled " + strconv.Itoa(roll) + " — not enough to dethrone the king's " + strconv.Itoa(kingRoll) } // effectiveReignBlocks returns a player's accumulated reign length, including // the currently-in-progress reign if they are the sitting king. It does not // mutate state — it's a display-time projection. func effectiveReignBlocks(ps *playerStats, height int64) int64 { if king.IsValid() && ps.addr == king { return ps.totalReignBlocks + (height - reignSince) } return ps.totalReignBlocks } // byReign implements sort.Interface, ranking players by effective reign // length descending, ties broken by best roll then address so the order is // fully deterministic. type byReign struct { stats []*playerStats blocks []int64 } func (r byReign) Len() int { return len(r.stats) } func (r byReign) Swap(i, j int) { r.stats[i], r.stats[j] = r.stats[j], r.stats[i] r.blocks[i], r.blocks[j] = r.blocks[j], r.blocks[i] } func (r byReign) Less(i, j int) bool { if r.blocks[i] != r.blocks[j] { return r.blocks[i] > r.blocks[j] } if r.stats[i].bestRoll != r.stats[j].bestRoll { return r.stats[i].bestRoll > r.stats[j].bestRoll } return r.stats[i].addr.String() < r.stats[j].addr.String() } // snapshot collects all players ranked by effective reign length descending. func snapshot(height int64) ([]*playerStats, []int64) { stats := make([]*playerStats, 0, players.Size()) players.Iterate("", "", func(_ string, v any) bool { stats = append(stats, v.(*playerStats)) return false }) blocks := make([]int64, len(stats)) for i, ps := range stats { blocks[i] = effectiveReignBlocks(ps, height) } sort.Stable(byReign{stats: stats, blocks: blocks}) return stats, blocks } // display returns a shortened address for table display. func display(addr address) string { s := addr.String() if len(s) > 12 { return s[:8] + "…" + s[len(s)-4:] } return s } // Render shows the current king and a reign-length leaderboard. func Render(path string) string { height := runtime.ChainHeight() out := "# 👑 King of the Dice\n\n" out += "Roll higher than the reigning king to take the throne. The leaderboard " + "ranks total blocks held as king, not raw roll count — outlasting beats " + "getting lucky once.\n\n" if !king.IsValid() { out += "_No king yet. Call `Roll()` to make the first claim._\n\n" } else { out += "**Current king:** `" + display(king) + "` with a roll of **" + strconv.Itoa(kingRoll) + "**, reigning for **" + strconv.Itoa(int(height-reignSince)) + "** blocks so far.\n\n" } out += "Total rolls: **" + strconv.Itoa(totalRolls) + "**\n\n" stats, blocks := snapshot(height) if len(stats) == 0 { return out } out += "| Rank | Player | Blocks reigned | Reigns | Best roll | Rolls |\n" out += "| ---: | :--- | ---: | ---: | ---: | ---: |\n" for i, ps := range stats { rankCell := ui.Podium(i) if rankCell == "" { rankCell = strconv.Itoa(i + 1) } out += "| " + rankCell + " | " + display(ps.addr) + " | " + strconv.Itoa(int(blocks[i])) + " | " + strconv.Itoa(ps.reigns) + " | " + strconv.Itoa(ps.bestRoll) + " | " + strconv.Itoa(ps.rolls) + " |\n" } return out }
- #8kingofdice_test.gno
- #9package kingofdice import ( "strings" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) // resetState clears package globals between tests. func resetState() { players = avl.Tree{} var zero address king = zero kingRoll = 0 reignSince = 0 nonce = 0 totalRolls = 0 } func TestPseudoRollDeterministicAndRanged(t *testing.T) { alice := testutils.TestAddress("alice") r1 := pseudoRoll(alice, 1, 100) r2 := pseudoRoll(alice, 1, 100) if r1 != r2 { t.Fatalf("same inputs must give same roll: %d != %d", r1, r2) } if r1 < 1 || r1 > 100 { t.Fatalf("roll must be in [1, 100], got %d", r1) } r3 := pseudoRoll(alice, 2, 100) if r1 == r3 { t.Fatal("different nonce should (almost always) change the roll") } } // Crossing test: takes `cur realm` so it can call crossing functions via // cross(cur). SetRealm is called directly in this frame. func TestRollCrownsFirstCallerThenSwapsOnHigherRoll(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) Roll(cross(cur)) if !king.IsValid() { t.Fatal("someone should be king after first roll") } firstKing := king // Keep rolling as bob until bob out-rolls alice, or give up after a // generous number of attempts (rolls are deterministic per nonce/height, // so this always terminates locally). testing.SetRealm(testing.NewUserRealm(bob)) for i := 0; i < 200 && king == firstKing; i++ { Roll(cross(cur)) } if get(alice).reigns != 1 { t.Fatalf("alice should have exactly one recorded reign, got %d", get(alice).reigns) } } func TestRenderShowsNoKingThenKing(cur realm, t *testing.T) { resetState() if !strings.Contains(Render(""), "No king yet") { t.Fatal("expected empty-board placeholder") } alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) Roll(cross(cur)) out := Render("") if !strings.Contains(out, "Current king") { t.Fatal("expected current king section once someone rolled") } if !strings.Contains(out, "Blocks reigned") { t.Fatal("expected leaderboard header") } }
- Attached funds
- 5000000ugnot
Arguments · 11
- #1leaderboard
- #2README.md
- #3# 🏆 Leaderboard > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- An on-chain score leaderboard realm for gno.land (sapphire / gno 0.9). Any account can accumulate points with `AddPoints(n)` and optionally attach a display name with `SetName(name)`. `Render` returns a Markdown table ranked by points descending — gold/silver/bronze medals for the top three — plus the total player count. State is kept in an `avl.Tree` so rendering is deterministic across nodes. **Realm path:** `gno.land/r/REPLACE_ADDR/leaderboard` ## Public API | Function | Kind | Description | | --- | --- | --- | | `AddPoints(cur realm, n int)` | crossing (tx) | Add `n` (> 0) points to the caller's total. | | `SetName(cur realm, name string)` | crossing (tx) | Set the caller's display name (≤ 32 bytes; empty clears it). | | `Render(path string) string` | view | Ranked Markdown leaderboard. | ## Example calls ```sh # Add 10 points to your total gnokey maketx call \ -pkgpath "gno.land/r/REPLACE_ADDR/leaderboard" \ -func AddPoints -args 10 \ -gas-fee 1000000ugnot -gas-wanted 2000000 \ -broadcast -chainid sapphire-1 -remote <rpc> <keyname> # Set your display name gnokey maketx call \ -pkgpath "gno.land/r/REPLACE_ADDR/leaderboard" \ -func SetName -args "Alice" \ -gas-fee 1000000ugnot -gas-wanted 2000000 \ -broadcast -chainid sapphire-1 -remote <rpc> <keyname> ``` View the rendered leaderboard in gnoweb at `/r/REPLACE_ADDR/leaderboard`, or query it: ```sh gnokey query "vm/qrender" -data "gno.land/r/REPLACE_ADDR/leaderboard:" -remote <rpc> ``` ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/leaderboard) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `medal` helper. **One podium.** `ui.Podium` replaces the medal switch three realms each had a copy of. Same output. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/leaderboard/v1" gno = "0.9" private = true
- #6leaderboard.gno
- #7// Package leaderboard is an on-chain score leaderboard realm. // // Any account can accumulate points via AddPoints and (optionally) attach a // display name with SetName. Render produces a ranked Markdown table sorted by // points descending, with medals for the top three players. package leaderboard import ( "sort" "strconv" "chain/runtime/unsafe" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // player holds the accumulated state for a single address. type player struct { addr address name string points int } // players maps address string -> *player. An avl.Tree gives deterministic // iteration (unlike a Go map) so Render output is stable across nodes. var players avl.Tree // get returns the stored *player for addr, creating one if absent. func get(addr address) *player { key := addr.String() if v := players.Get(key); v != nil { return v.(*player) } p := &player{addr: addr} players.Set(key, p) return p } // caller returns the address of the account that invoked the current tx. // unsafe.PreviousRealm() is the origin user for a MsgCall entry point. func caller() address { return unsafe.PreviousRealm().Address() } // AddPoints adds n points to the caller's total. n must be positive. // // Crossing function: MsgCall dispatches only to crossing functions, and the // cur.IsCurrent() check authenticates the live call frame before we trust the // caller identity derived from it. func AddPoints(cur realm, n int) { if !cur.IsCurrent() { panic("spoofed realm") } if n <= 0 { panic("points must be positive") } p := get(caller()) p.points += n } // SetName attaches a display name (max 32 bytes) to the caller. Passing an // empty string clears the name and falls back to the address in Render. func SetName(cur realm, name string) { if !cur.IsCurrent() { panic("spoofed realm") } if len(name) > 32 { panic("name too long (max 32)") } get(caller()).name = name } // display returns the player's name, or a shortened address if unnamed. func (p *player) display() string { if p.name != "" { return p.name } s := p.addr.String() if len(s) > 12 { return s[:8] + "…" + s[len(s)-4:] } return s } // byRank implements sort.Interface, ranking players by points descending with // ties broken by address so the order is deterministic across nodes. The gno // sort package exposes Sort/Stable over an Interface — there is no sort.Slice. type byRank []*player func (r byRank) Len() int { return len(r) } func (r byRank) Swap(i, j int) { r[i], r[j] = r[j], r[i] } func (r byRank) Less(i, j int) bool { if r[i].points != r[j].points { return r[i].points > r[j].points } return r[i].addr.String() < r[j].addr.String() } // snapshot collects all players into a slice ranked by points descending. func snapshot() []*player { out := make([]*player, 0, players.Size()) players.Iterate("", "", func(_ string, v any) bool { out = append(out, v.(*player)) return false }) sort.Stable(byRank(out)) return out } // Render produces the Markdown leaderboard. It is NOT a crossing function // (read-only view, no cur realm parameter). func Render(path string) string { ranked := snapshot() out := "# 🏆 Leaderboard\n\n" if len(ranked) == 0 { out += "_No players yet. Call `AddPoints(n)` to get on the board._\n" return out } out += "Total players: **" + strconv.Itoa(len(ranked)) + "**\n\n" out += "| Rank | Player | Points |\n" out += "| ---: | :--- | ---: |\n" for i, p := range ranked { rankCell := ui.Podium(i) if rankCell == "" { rankCell = strconv.Itoa(i + 1) } out += "| " + rankCell + " | " + p.display() + " | " + strconv.Itoa(p.points) + " |\n" } return out }
- #8leaderboard_test.gno
- #9package leaderboard import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) // resetState clears package globals between tests. Global-state reset belongs // in a helper; realm setup (SetRealm) must NOT — it only affects its own frame. func resetState() { keys := []string{} players.Iterate("", "", func(key string, _ any) bool { keys = append(keys, key) return false }) for _, k := range keys { players.Remove(k) } } const ( alice = "g1alice00000000000000000000000000000000" bob = "g1bob0000000000000000000000000000000000" carol = "g1carol00000000000000000000000000000000" ) // Crossing test: takes `cur realm` so it can call crossing functions via // cross(cur). SetRealm is called directly in this frame (never via a helper). func TestAddPointsAccumulatesAndRanks(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(address(alice))) AddPoints(cross(cur), 10) AddPoints(cross(cur), 5) // accumulates -> 15 testing.SetRealm(testing.NewUserRealm(address(bob))) AddPoints(cross(cur), 20) uassert.Equal(t, 15, get(address(alice)).points, "alice points") uassert.Equal(t, 20, get(address(bob)).points, "bob points") // Ranking: bob (20) outranks alice (15). ranked := snapshot() uassert.Equal(t, 2, len(ranked), "player count") uassert.Equal(t, bob, ranked[0].addr.String(), "rank 1 is bob") uassert.Equal(t, alice, ranked[1].addr.String(), "rank 2 is alice") } func TestRenderTableWithMedalsAndName(cur realm, t *testing.T) { resetState() // Empty board. uassert.True(t, strings.Contains(Render(""), "No players yet"), "empty placeholder") testing.SetRealm(testing.NewUserRealm(address(alice))) AddPoints(cross(cur), 30) SetName(cross(cur), "Alice") testing.SetRealm(testing.NewUserRealm(address(bob))) AddPoints(cross(cur), 20) testing.SetRealm(testing.NewUserRealm(address(carol))) AddPoints(cross(cur), 10) out := Render("") uassert.True(t, strings.Contains(out, "| 🥇 | Alice | 30 |"), "Alice gold row") uassert.True(t, strings.Contains(out, "🥈"), "silver medal present") uassert.True(t, strings.Contains(out, "🥉"), "bronze medal present") uassert.True(t, strings.Contains(out, "Total players: **3**"), "total players count") } func TestSetNameTooLongPanics(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(address(alice))) uassert.AbortsWithMessage(t, cur, "name too long (max 32)", func() { SetName(cross(cur), strings.Repeat("x", 33)) }) } func TestAddPointsRejectsNonPositive(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(address(alice))) uassert.AbortsWithMessage(t, cur, "points must be positive", func() { AddPoints(cross(cur), 0) }) }
- #10render_example_test.gno
- #11package leaderboard // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # 🏆 Leaderboard // // _No players yet. Call `AddPoints(n)` to get on the board._ }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1microblog
- #2README.md
- #3# Microblog Wall > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- A public, append-only microblog for gno.land. Anyone can post a short message (up to 280 bytes); each post records the caller's address and the block height at which it was made. The `Render` view shows the wall newest-first, 20 posts per page, with total count and prev/next pagination driven by the render path. - **Realm path:** `gno.land/r/REPLACE_ADDR/microblog` - **Gno version:** 0.9 (sapphire) ## API - `Post(cur realm, msg string)` — crossing tx. Panics if `msg` is empty or longer than 280 bytes. Stores `{author, msg, height}`. - `Count() int` — total number of posts. - `Render(path string) string` — Markdown wall, newest-first, paginated. ## Example calls Post a message (crossing call via `gnokey`): ```sh gnokey maketx call \ -pkgpath "gno.land/r/REPLACE_ADDR/microblog" \ -func Post \ -args "gm gno.land 👋" \ -gas-fee 1000000ugnot -gas-wanted 2000000 \ -broadcast -chainid sapphire-1 -remote <rpc> mykey ``` View the wall in gnoweb: ``` https://gno.land/r/REPLACE_ADDR/microblog # page 1 (newest 20) https://gno.land/r/REPLACE_ADDR/microblog:?page=2 # older posts https://gno.land/r/REPLACE_ADDR/microblog:/2 # same page, path form ``` From another realm: ```go import "gno.land/r/REPLACE_ADDR/microblog" func Shout(cur realm) { microblog.Post(cross(cur), "posted from another realm") } ``` ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/microblog) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `escapeInline`, `shortAddr` helpers. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. **Real escaping.** The local helper replaced a handful of markdown metacharacters. `ui.Inline` and `ui.Cell` delegate to [`p/nt/markdown/sanitize`](/p/nt/markdown/sanitize/v0), which also strips bidi and zero-width characters and folds newlines, so user text cannot reorder or escape the line it sits on. Visible change: addresses now render in monospace. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/microblog/v1" gno = "0.9" private = true
- #6microblog.gno
- #7// Package microblog is a public microblog wall for gno.land. // // Anyone can Post a short message (<= maxMsgLen chars). Every post records // the caller address, the message, and the block height at which it was made. // Render displays the wall newest-first, 20 posts per page, with pagination // driven by the Render path (e.g. "?page=2" or "/2"). package microblog import ( "chain" "chain/runtime" "chain/runtime/unsafe" "strconv" "strings" "gno.land/p/moul/kit/ui/v0" ) // maxMsgLen is the maximum length (in bytes) of a single post. const maxMsgLen = 280 // pageSize is how many posts a single Render page shows. const pageSize = 20 // post is a single microblog entry. type post struct { Author string // bech32 address of the poster Msg string // the message body Height int64 // block height at which it was posted } // posts is the append-only wall, in chronological (oldest-first) order. // Render reverses this view to show newest-first. var posts []post // Post publishes msg to the wall. It is a crossing function: callers invoke it // with Post(cross(cur), "hello"). It panics if msg is empty or longer than // maxMsgLen bytes. func Post(cur realm, msg string) { if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } if len(msg) == 0 { panic("microblog: empty message") } if len(msg) > maxMsgLen { panic("microblog: message too long (max " + strconv.Itoa(maxMsgLen) + " bytes)") } author := unsafe.PreviousRealm().Address() posts = append(posts, post{ Author: author.String(), Msg: msg, Height: runtime.ChainHeight(), }) chain.Emit("Post", "author", author.String(), "len", strconv.Itoa(len(msg))) } // Count returns the total number of posts on the wall. func Count() int { return len(posts) } // Render renders the wall as Markdown, newest-first, pageSize posts per page. // The page is parsed from path: "?page=N", "?p=N", "/N", or a bare "N" all // select page N (1-indexed). Anything else defaults to page 1. func Render(path string) string { total := len(posts) page := parsePage(path) var b strings.Builder b.WriteString("# Microblog Wall\n\n") if total == 0 { b.WriteString("_No posts yet. Be the first to post._\n") return b.String() } pages := (total + pageSize - 1) / pageSize if page < 1 { page = 1 } if page > pages { page = pages } b.WriteString(strconv.Itoa(total)) b.WriteString(" posts total · page ") b.WriteString(strconv.Itoa(page)) b.WriteString(" of ") b.WriteString(strconv.Itoa(pages)) b.WriteString("\n\n") // Newest-first: post index total-1 is newest. For page p (1-indexed), // skip (p-1)*pageSize newest posts, then take up to pageSize. start := total - 1 - (page-1)*pageSize end := start - pageSize + 1 if end < 0 { end = 0 } for i := start; i >= end; i-- { p := posts[i] b.WriteString("- **") b.WriteString(ui.AddrOf(p.Author)) b.WriteString("** · height ") b.WriteString(strconv.FormatInt(p.Height, 10)) b.WriteString("\n\n ") b.WriteString(ui.Inline(p.Msg)) b.WriteString("\n") } b.WriteString("\n") b.WriteString(renderNav(page, pages)) return b.String() } // renderNav builds a simple prev/next navigation footer as Markdown links. func renderNav(page, pages int) string { var b strings.Builder if page > 1 { b.WriteString("[← newer](?page=") b.WriteString(strconv.Itoa(page - 1)) b.WriteString(")") } if page > 1 && page < pages { b.WriteString(" · ") } if page < pages { b.WriteString("[older →](?page=") b.WriteString(strconv.Itoa(page + 1)) b.WriteString(")") } if b.Len() == 0 { return "" } return b.String() + "\n" } // parsePage extracts the 1-indexed page number from a Render path. It accepts // "?page=N", "?p=N", "/N", and a bare "N". Unrecognized input yields page 1. func parsePage(path string) int { path = strings.TrimSpace(path) if path == "" { return 1 } // Query form: "?page=2&foo=bar" or "?p=2". if idx := strings.IndexByte(path, '?'); idx >= 0 { query := path[idx+1:] for _, part := range strings.Split(query, "&") { kv := strings.SplitN(part, "=", 2) if len(kv) != 2 { continue } if kv[0] == "page" || kv[0] == "p" { if n, err := strconv.Atoi(strings.TrimSpace(kv[1])); err == nil { return n } } } return 1 } // Path form: "/2" or "2". seg := strings.TrimPrefix(path, "/") if n, err := strconv.Atoi(seg); err == nil { return n } return 1 }
- #8microblog_test.gno
- #9package microblog import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // resetWall clears package state between tests (globals persist in-process, // and subtests do not reset them — do it explicitly). func resetWall() { posts = nil } // asUser must be inlined into each test: SetRealm affects the calling frame // only. We therefore call it directly in the test bodies below. func TestPostAndCount(cur realm, t *testing.T) { resetWall() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) Post(cross(cur), "hello wall") if got := Count(); got != 1 { t.Fatalf("Count() = %d, want 1", got) } if posts[0].Msg != "hello wall" { t.Errorf("Msg = %q, want %q", posts[0].Msg, "hello wall") } if posts[0].Author != alice.String() { t.Errorf("Author = %q, want %q", posts[0].Author, alice.String()) } } func TestPostTooLongPanics(cur realm, t *testing.T) { resetWall() testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("bob"))) // Aborts from a cross(cur) call cannot be caught by a plain recover(); // assert on the abort message instead. uassert.AbortsWithMessage(t, cur, "microblog: message too long (max 280 bytes)", func() { Post(cross(cur), strings.Repeat("x", maxMsgLen+1)) }) } func TestPostEmptyPanics(cur realm, t *testing.T) { resetWall() testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("bob"))) // Aborts from a cross(cur) call cannot be caught by a plain recover(); // assert on the abort message instead. uassert.AbortsWithMessage(t, cur, "microblog: empty message", func() { Post(cross(cur), "") }) } func TestPostMaxLenOK(cur realm, t *testing.T) { resetWall() testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("carol"))) // Exactly maxMsgLen bytes must be accepted. Post(cross(cur), strings.Repeat("y", maxMsgLen)) if got := Count(); got != 1 { t.Fatalf("Count() = %d, want 1 for exactly-max message", got) } } func TestRenderEmpty(t *testing.T) { resetWall() out := Render("") if !strings.Contains(out, "No posts yet") { t.Errorf("empty Render should mention no posts, got:\n%s", out) } } func TestRenderNewestFirstAndPagination(cur realm, t *testing.T) { resetWall() testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("dave"))) // 25 posts -> 2 pages (20 + 5). for i := 0; i < 25; i++ { Post(cross(cur), "msg"+itoa(i)) } if got := Count(); got != 25 { t.Fatalf("Count() = %d, want 25", got) } page1 := Render("") // Newest (msg24) must appear before older (msg5) on page 1. i24 := strings.Index(page1, "msg24") i5 := strings.Index(page1, "msg5") if i24 < 0 || i5 < 0 { t.Fatalf("page 1 missing expected posts: msg24=%d msg5=%d", i24, i5) } if i24 > i5 { t.Errorf("page 1 not newest-first: msg24 at %d should precede msg5 at %d", i24, i5) } if !strings.Contains(page1, "page 1 of 2") { t.Errorf("page 1 header wrong, got:\n%s", page1) } // Page 2 via query form must contain the oldest post. page2 := Render("?page=2") if !strings.Contains(page2, "msg0") { t.Errorf("page 2 should contain oldest msg0, got:\n%s", page2) } if !strings.Contains(page2, "page 2 of 2") { t.Errorf("page 2 header wrong, got:\n%s", page2) } // Path form "/2" should equal query form "?page=2". if Render("/2") != page2 { t.Errorf("path form /2 differs from query form ?page=2") } } func TestParsePage(t *testing.T) { cases := []struct { in string want int }{ {"", 1}, {"?page=3", 3}, {"?p=4", 4}, {"/2", 2}, {"5", 5}, {"?page=abc", 1}, {"garbage", 1}, } for _, c := range cases { if got := parsePage(c.in); got != c.want { t.Errorf("parsePage(%q) = %d, want %d", c.in, got, c.want) } } } // itoa is a tiny local int->string helper for building test messages. func itoa(n int) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } var buf [20]byte i := len(buf) for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } if neg { i-- buf[i] = '-' } return string(buf[i:]) }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1moodstone
- #2README.md
- #3# 🪨 Moodstone > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- An on-chain mood tracker for the Gno ecosystem. Users log how they're feeling with a short note, and the realm shows live community sentiment stats — all stored immutably on gno.land. **Realm path:** `gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/moodstone` ## Moods `happy` · `sad` · `excited` · `calm` · `anxious` · `angry` · `neutral` ## Example Calls ```bash # Log a mood gnokey maketx call \ -pkgpath gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/moodstone \ -func LogMood \ -args 'happy' -args 'Just deployed my first realm!' \ -gas-fee 1000000ugnot -gas-wanted 2000000 \ -broadcast -chainid test5 \ test5.gno.land:443 # Query total entries (read-only) gnokey query vm/qeval \ -remote test5.gno.land:443 \ -data 'gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/moodstone.TotalEntries()' # Query dominant mood gnokey query vm/qeval \ -remote test5.gno.land:443 \ -data 'gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/moodstone.DominantMood()' ``` ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/moodstone) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `shortAddr` helper. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/moodstone/v1" gno = "0.9" private = true
- #6moodstone.gno
- #7package moodstone import ( "chain/runtime" "chain/runtime/unsafe" "strconv" "strings" "gno.land/p/moul/kit/ui/v0" ) // Entry holds a single mood log. type Entry struct { Author address Mood string Note string Block int64 } var entries []Entry var validMoods = []string{"happy", "sad", "excited", "calm", "anxious", "angry", "neutral"} func isValid(mood string) bool { for _, m := range validMoods { if m == mood { return true } } return false } func emoji(mood string) string { switch mood { case "happy": return "😊" case "sad": return "😢" case "excited": return "🎉" case "calm": return "😌" case "anxious": return "😰" case "angry": return "😠" case "neutral": return "😐" } return "❓" } func bar(count, total int) string { if total == 0 { return strings.Repeat("░", 20) } filled := (count * 20) / total return strings.Repeat("▓", filled) + strings.Repeat("░", 20-filled) } // LogMood records the caller's current mood with an optional note. // mood must be one of: happy sad excited calm anxious angry neutral func LogMood(_ realm, mood, note string) { mood = strings.ToLower(strings.TrimSpace(mood)) if !isValid(mood) { panic("invalid mood; choose from: " + strings.Join(validMoods, ", ")) } entries = append(entries, Entry{ Author: unsafe.OriginCaller(), Mood: mood, Note: strings.TrimSpace(note), Block: runtime.ChainHeight(), }) } // TotalEntries returns the total number of mood logs on record. func TotalEntries() int { return len(entries) } // MoodCount returns how many times the given mood has been logged. func MoodCount(mood string) int { mood = strings.ToLower(strings.TrimSpace(mood)) n := 0 for _, e := range entries { if e.Mood == mood { n++ } } return n } // DominantMood returns the most-logged mood, or "none" if no entries. func DominantMood() string { if len(entries) == 0 { return "none" } counts := make(map[string]int) for _, e := range entries { counts[e.Mood]++ } best := "" bestN := 0 for _, m := range validMoods { if counts[m] > bestN { bestN = counts[m] best = m } } return best } func Render(path string) string { out := "# 🪨 Moodstone — On-chain Mood Tracker\n\n" out += "> Log how you feel. Watch how the community feels. Immutably, on Gno.\n\n" total := len(entries) // Community stats table out += "## 📊 Community Vibes\n\n" if total == 0 { out += "*No moods logged yet — be the first!*\n\n" } else { counts := make(map[string]int) for _, e := range entries { counts[e.Mood]++ } out += "| Mood | Count | Distribution |\n" out += "|------|-------|--------------|\n" for _, m := range validMoods { c := counts[m] pct := 0 if total > 0 { pct = (c * 100) / total } out += "| " + emoji(m) + " " + m + " | " + strconv.Itoa(c) + " | `" + bar(c, total) + "` " + strconv.Itoa(pct) + "% |\n" } dom := DominantMood() out += "\n**Total entries:** " + strconv.Itoa(total) + " · **Dominant mood:** " + emoji(dom) + " " + dom + "\n\n" } // Recent entries (newest first, max 10) out += "## 📝 Recent Entries\n\n" if total == 0 { out += "*Nothing here yet.*\n\n" } else { start := total - 10 if start < 0 { start = 0 } for i := total - 1; i >= start; i-- { e := entries[i] line := "- " + emoji(e.Mood) + " **" + e.Mood + "**" if e.Note != "" { line += ": *" + e.Note + "*" } line += " — " + ui.Addr(e.Author) + " at block " + strconv.FormatInt(e.Block, 10) out += line + "\n" } out += "\n" } // How to use out += "## 🚀 How to Use\n\n" out += "```bash\n" out += "gnokey maketx call \\\n" out += " -pkgpath gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/moodstone \\\n" out += " -func LogMood \\\n" out += " -args 'happy' -args 'Just shipped something awesome!'\n" out += "```\n\n" out += "**Valid moods:** " + strings.Join(validMoods, " · ") + "\n" return out }
- #8moodstone_test.gno
- #9package moodstone import ( "testing" "gno.land/p/nt/testutils/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") ) func TestLogMoodBasic(cur realm, t *testing.T) { entries = nil testing.SetOriginCaller(alice) LogMood(cross(cur),"happy", "great day!") LogMood(cross(cur),"calm", "") if len(entries) != 2 { t.Fatalf("expected 2 entries, got %d", len(entries)) } if entries[0].Mood != "happy" { t.Errorf("expected happy, got %s", entries[0].Mood) } if entries[0].Note != "great day!" { t.Errorf("expected 'great day!', got %s", entries[0].Note) } if entries[1].Mood != "calm" { t.Errorf("expected calm, got %s", entries[1].Mood) } } func TestLogMoodCaseInsensitive(cur realm, t *testing.T) { entries = nil testing.SetOriginCaller(alice) LogMood(cross(cur),"HAPPY", "uppercase input") if entries[0].Mood != "happy" { t.Errorf("expected normalized to happy, got %s", entries[0].Mood) } } func TestIsValid(t *testing.T) { for _, m := range validMoods { if !isValid(m) { t.Errorf("%s should be valid", m) } } if isValid("euphoric") { t.Error("euphoric should not be valid") } if isValid("") { t.Error("empty string should not be valid") } } func TestMoodCount(cur realm, t *testing.T) { entries = nil testing.SetOriginCaller(alice) LogMood(cross(cur),"happy", "") LogMood(cross(cur),"happy", "") LogMood(cross(cur),"sad", "") if MoodCount("happy") != 2 { t.Errorf("expected 2 happy, got %d", MoodCount("happy")) } if MoodCount("sad") != 1 { t.Errorf("expected 1 sad, got %d", MoodCount("sad")) } if MoodCount("angry") != 0 { t.Errorf("expected 0 angry, got %d", MoodCount("angry")) } } func TestTotalEntries(cur realm, t *testing.T) { entries = nil if TotalEntries() != 0 { t.Errorf("expected 0, got %d", TotalEntries()) } testing.SetOriginCaller(alice) LogMood(cross(cur),"excited", "testing!") if TotalEntries() != 1 { t.Errorf("expected 1, got %d", TotalEntries()) } } func TestDominantMood(cur realm, t *testing.T) { entries = nil if DominantMood() != "none" { t.Errorf("expected none on empty, got %s", DominantMood()) } testing.SetOriginCaller(alice) LogMood(cross(cur),"happy", "") LogMood(cross(cur),"happy", "") testing.SetOriginCaller(bob) LogMood(cross(cur),"sad", "") if DominantMood() != "happy" { t.Errorf("expected happy to dominate, got %s", DominantMood()) } } func TestRender(cur realm, t *testing.T) { entries = nil out := Render("") if len(out) == 0 { t.Error("Render returned empty string") } testing.SetOriginCaller(alice) LogMood(cross(cur),"neutral", "just checking the render") out = Render("") if len(out) == 0 { t.Error("Render returned empty string after entries") } }
- Attached funds
- 7000000ugnot
Arguments · 9
- #1qvote
- #2README.md
- #3# Quadratic Voting > ⚠️ **Experimental — generated with no human supervision** by the daily MCP pipeline to exercise gno tooling. Not audited. See [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md). --- A Gno port of the classic Solidity 'Ballot' voting contract, but swapping one-address-one-vote for quadratic voting: every address gets a fixed 100-credit budget per poll, and stacking N votes on one option costs N^2 credits, so spreading conviction is cheap but dominating one option gets steep fast. Create a poll with CreatePoll, spend (or refund) credits with Vote(pollID, optionIdx, delta), and the creator ends it with ClosePoll. Render shows the poll board, per-poll results, and any voter's standing. Built for sapphire (gno 0.9). Live demo (agent address): https://sapphire.testnets.gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/qvote ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/qvote) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `escapeInline` helper. **Real escaping.** The local helper replaced a handful of markdown metacharacters. `ui.Inline` and `ui.Cell` delegate to [`p/nt/markdown/sanitize`](/p/nt/markdown/sanitize/v0), which also strips bidi and zero-width characters and folds newlines, so user text cannot reorder or escape the line it sits on. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/qvote/v1" gno = "0.9" private = true
- #6qvote.gno
- #7// Package qvote is a quadratic-voting poll board, a Gno take on the classic // Solidity "Ballot" voting contract with one twist borrowed from mechanism // design instead of one-address-one-vote: every voter gets a fixed voice- // credit budget per poll, and piling votes onto a single option costs the // square of how many votes they stack there. Buying your 1st vote on an // option costs 1 credit, the 2nd costs 3 more (4 total), the 3rd costs 5 // more (9 total) -- so spreading conviction across options is cheap, but // dominating one option gets expensive fast. Votes can also be pulled back // for a matching credit refund. package qvote import ( "strconv" "strings" "chain" "chain/runtime" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // InitialCredits is the fixed voice-credit budget every address gets to // spend on each poll (shared across all of that poll's options). const InitialCredits int64 = 100 const ( minOptions = 2 maxOptions = 8 ) type poll struct { ID string Creator address Question string Options []string Tally []int64 CreatedHeight int64 Closed bool } // voterState is one address's standing inside one poll: how many credits // they've spent so far and how many votes that bought them on each option. type voterState struct { CreditsUsed int64 Votes []int64 } var ( polls avl.Tree // poll ID -> *poll voters avl.Tree // "<pollID>:<addr>" -> *voterState nextID int ) func voterKey(pollID string, addr address) string { return pollID + ":" + addr.String() } func getPoll(pollID string) *poll { p, ok := polls.Get(pollID).(*poll) if !ok { panic("no such poll: " + pollID) } return p } func getOrCreateVoter(pollID string, addr address, numOptions int) *voterState { key := voterKey(pollID, addr) if v, ok := voters.Get(key).(*voterState); ok { return v } vs := &voterState{Votes: make([]int64, numOptions)} voters.Set(key, vs) return vs } // square is the quadratic-voting cost curve: N votes on one option cost // N*N credits in total. func square(n int64) int64 { return n * n } // CreatePoll opens a new poll with the given question and comma-separated // options (at least 2, at most 8), returning its ID. func CreatePoll(cur realm, question string, optionsCSV string) string { creator := cur.Previous().Address() question = strings.TrimSpace(question) if question == "" { panic("question must not be empty") } var options []string for _, raw := range strings.Split(optionsCSV, ",") { opt := strings.TrimSpace(raw) if opt == "" { continue } options = append(options, opt) } if len(options) < minOptions { panic("need at least " + strconv.Itoa(minOptions) + " non-empty options") } if len(options) > maxOptions { panic("at most " + strconv.Itoa(maxOptions) + " options are allowed") } nextID++ id := strconv.Itoa(nextID) p := &poll{ ID: id, Creator: creator, Question: question, Options: options, Tally: make([]int64, len(options)), CreatedHeight: runtime.ChainHeight(), } polls.Set(id, p) chain.Emit("PollCreated", "id", id, "creator", creator.String(), "question", question) return "poll #" + id + " created with " + strconv.Itoa(len(options)) + " options" } // Vote adjusts the caller's votes on one option of a poll by delta (positive // to buy more, negative to sell some back for a credit refund). The credit // cost of holding N votes on an option is N*N, taken from the caller's fixed // per-poll budget of InitialCredits. func Vote(cur realm, pollID string, optionIdx int, delta int64) string { caller := cur.Previous().Address() p := getPoll(pollID) if p.Closed { panic("poll #" + pollID + " is closed") } if optionIdx < 0 || optionIdx >= len(p.Options) { panic("invalid option index") } if delta == 0 { panic("delta must be non-zero") } vs := getOrCreateVoter(pollID, caller, len(p.Options)) current := vs.Votes[optionIdx] updated := current + delta if updated < 0 { panic("cannot remove more votes than you hold on this option") } cost := square(updated) - square(current) newCreditsUsed := vs.CreditsUsed + cost if newCreditsUsed > InitialCredits { panic("exceeds your voice-credit budget of " + strconv.FormatInt(InitialCredits, 10) + " for this poll (would need " + strconv.FormatInt(newCreditsUsed, 10) + ")") } vs.Votes[optionIdx] = updated vs.CreditsUsed = newCreditsUsed p.Tally[optionIdx] += delta chain.Emit("VoteCast", "pollID", pollID, "voter", caller.String(), "option", p.Options[optionIdx], "votes", strconv.FormatInt(updated, 10), ) verb := "bought" n := delta if delta < 0 { verb = "sold back" n = -delta } return "you " + verb + " " + strconv.FormatInt(n, 10) + " vote(s) on \"" + p.Options[optionIdx] + "\" -- now holding " + strconv.FormatInt(updated, 10) + " (credits used: " + strconv.FormatInt(vs.CreditsUsed, 10) + "/" + strconv.FormatInt(InitialCredits, 10) + ")" } // ClosePoll ends voting on a poll. Only its creator may close it. func ClosePoll(cur realm, pollID string) string { caller := cur.Previous().Address() p := getPoll(pollID) if caller != p.Creator { panic("only the poll creator can close it") } if p.Closed { panic("poll #" + pollID + " is already closed") } p.Closed = true chain.Emit("PollClosed", "id", pollID) return "poll #" + pollID + " closed" } func leadingOption(p *poll) (string, int64) { best := -1 var bestVotes int64 = -1 for i, v := range p.Tally { if v > bestVotes { bestVotes = v best = i } } if best < 0 { return "", 0 } return p.Options[best], bestVotes } func renderHome() string { var b strings.Builder b.WriteString("# Quadratic Voting\n\n") b.WriteString("Create a poll, then spend voice credits on the options you care about -- " + "the Nth vote you stack on one option costs N^2 credits out of a fixed budget of " + strconv.FormatInt(InitialCredits, 10) + " per poll, so spreading support across " + "options is cheap but dominating one gets steep fast.\n\n") b.WriteString("## Polls\n\n") count := 0 polls.Iterate("", "", func(key string, value interface{}) bool { count++ p := value.(*poll) status := "open" if p.Closed { status = "closed" } lead, votes := leadingOption(p) line := "- #" + p.ID + " (" + status + "): " + ui.Inline(p.Question) if lead != "" { line += " -- leading: \"" + ui.Inline(lead) + "\" (" + strconv.FormatInt(votes, 10) + ")" } b.WriteString(line + "\n") return false }) if count == 0 { b.WriteString("_no polls yet -- call `CreatePoll(question, \"opt1,opt2,...\")` to start one._\n") } b.WriteString("\n## How to use\n\n") b.WriteString("1. `CreatePoll(\"Best pizza topping?\", \"pineapple,mushroom,pepperoni\")` -- returns a poll ID.\n") b.WriteString("2. `Vote(pollID, optionIdx, delta)` -- e.g. `Vote(\"1\", 0, 3)` buys 3 votes on option 0 " + "(costs 9 credits); a negative delta sells votes back for a refund.\n") b.WriteString("3. `ClosePoll(pollID)` -- the creator ends voting.\n\n") b.WriteString("View a poll at this realm's path plus its ID (e.g. `.../qvote:1`), " + "or one voter's standing in it with `.../qvote:1/g1youraddress...`.\n") return b.String() } func renderPoll(id string) string { p, ok := polls.Get(id).(*poll) if !ok { return "# Poll #" + ui.Inline(id) + "\n\nNo such poll.\n" } var b strings.Builder b.WriteString("# Poll #" + p.ID + ": " + ui.Inline(p.Question) + "\n\n") status := "open" if p.Closed { status = "closed" } b.WriteString("- Status: " + status + "\n") b.WriteString("- Creator: `" + p.Creator.String() + "`\n\n") b.WriteString("## Results\n\n") var total int64 for _, v := range p.Tally { total += v } for i, opt := range p.Options { b.WriteString(strconv.Itoa(i) + ". " + ui.Inline(opt) + " -- " + strconv.FormatInt(p.Tally[i], 10) + " vote(s)\n") } b.WriteString("\nTotal votes cast: " + strconv.FormatInt(total, 10) + "\n") return b.String() } func renderVoter(pollID, rawAddr string) string { safeAddr := ui.Inline(strings.TrimSpace(rawAddr)) p, ok := polls.Get(pollID).(*poll) if !ok { return "# Poll #" + ui.Inline(pollID) + "\n\nNo such poll.\n" } var b strings.Builder b.WriteString("# " + safeAddr + " in poll #" + p.ID + "\n\n") state, ok := voters.Get(voterKey(pollID, address(strings.TrimSpace(rawAddr)))).(*voterState) if !ok { b.WriteString("No votes cast yet.\n") return b.String() } b.WriteString("- Credits used: " + strconv.FormatInt(state.CreditsUsed, 10) + "/" + strconv.FormatInt(InitialCredits, 10) + "\n\n") for i, opt := range p.Options { if state.Votes[i] == 0 { continue } b.WriteString("- " + ui.Inline(opt) + ": " + strconv.FormatInt(state.Votes[i], 10) + " vote(s)\n") } return b.String() } // Render shows the poll board at "", one poll's results at "<pollID>", or // one voter's standing in a poll at "<pollID>/<addr>". func Render(path string) string { path = strings.TrimPrefix(strings.TrimSpace(path), "/") if path == "" { return renderHome() } if idx := strings.IndexByte(path, '/'); idx >= 0 { return renderVoter(path[:idx], path[idx+1:]) } return renderPoll(path) }
- #8qvote_test.gno
- #9package qvote import ( "strings" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) func resetState() { polls = avl.Tree{} voters = avl.Tree{} nextID = 0 } func TestSquare(t *testing.T) { cases := []struct { n, want int64 }{ {0, 0}, {1, 1}, {2, 4}, {3, 9}, {10, 100}, } for _, c := range cases { if got := square(c.n); got != c.want { t.Fatalf("square(%d) = %d, want %d", c.n, got, c.want) } } } // Crossing: CreatePoll mutates realm state, so the test needs `cur realm`. func TestCreatePollValidatesOptions(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) id := CreatePoll(cross(cur), "best color?", "red, green , blue") if id != "poll #1 created with 3 options" { t.Fatalf("unexpected message: %q", id) } p := getPoll("1") if len(p.Options) != 3 || p.Options[0] != "red" || p.Options[2] != "blue" { t.Fatalf("options not parsed/trimmed correctly: %#v", p.Options) } if rec := revive(func() { CreatePoll(cross(cur), "one option?", "only-one") }); rec == nil { t.Fatal("expected panic for too few options") } if rec := revive(func() { CreatePoll(cross(cur), " ", "a,b") }); rec == nil { t.Fatal("expected panic for empty question") } } func TestVoteQuadraticCostAndBudget(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) CreatePoll(cross(cur), "pizza topping?", "pineapple,mushroom,pepperoni") // 1st vote costs 1, 2nd costs 3 more (4 total), 3rd costs 5 more (9 total). Vote(cross(cur), "1", 0, 1) vs := getOrCreateVoter("1", alice, 3) if vs.CreditsUsed != 1 { t.Fatalf("credits used = %d, want 1", vs.CreditsUsed) } Vote(cross(cur), "1", 0, 2) if vs.CreditsUsed != 9 || vs.Votes[0] != 3 { t.Fatalf("after buying to 3 votes: credits=%d votes=%d, want 9/3", vs.CreditsUsed, vs.Votes[0]) } // Spending the rest of the 100-credit budget on another option should work // right up to the edge, then fail past it. Vote(cross(cur), "1", 1, 9) // 81 credits, total 90 if vs.CreditsUsed != 90 { t.Fatalf("credits used = %d, want 90", vs.CreditsUsed) } if rec := revive(func() { Vote(cross(cur), "1", 2, 4) }); rec == nil { // would cost 16 more, total 106 > 100 t.Fatal("expected panic for exceeding voice-credit budget") } p := getPoll("1") if p.Tally[0] != 3 || p.Tally[1] != 9 { t.Fatalf("unexpected tally: %#v", p.Tally) } } func TestVoteSellBackRefundsCredits(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) CreatePoll(cross(cur), "best sea?", "north,baltic") Vote(cross(cur), "1", 0, 4) // 16 credits, 4 votes Vote(cross(cur), "1", 0, -1) // back to 3 votes: cost delta = 9-16 = -7 vs := getOrCreateVoter("1", alice, 2) if vs.Votes[0] != 3 { t.Fatalf("votes = %d, want 3", vs.Votes[0]) } if vs.CreditsUsed != 9 { t.Fatalf("credits used = %d, want 9", vs.CreditsUsed) } if getPoll("1").Tally[0] != 3 { t.Fatalf("tally = %d, want 3", getPoll("1").Tally[0]) } if rec := revive(func() { Vote(cross(cur), "1", 0, -10) }); rec == nil { t.Fatal("expected panic for selling more votes than held") } } func TestClosePollOnlyCreator(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) CreatePoll(cross(cur), "close me?", "yes,no") testing.SetRealm(testing.NewUserRealm(bob)) if rec := revive(func() { ClosePoll(cross(cur), "1") }); rec == nil { t.Fatal("expected panic when a non-creator closes the poll") } testing.SetRealm(testing.NewUserRealm(alice)) ClosePoll(cross(cur), "1") if !getPoll("1").Closed { t.Fatal("poll should be closed") } if rec := revive(func() { Vote(cross(cur), "1", 0, 1) }); rec == nil { t.Fatal("expected panic when voting on a closed poll") } } func TestRenderNoPolls(t *testing.T) { resetState() out := Render("") if !strings.Contains(out, "no polls yet") { t.Fatalf("expected empty-state message, got: %s", out) } } func TestRenderPollNotFound(t *testing.T) { resetState() out := Render("42") if !strings.Contains(out, "No such poll") { t.Fatalf("expected not-found message, got: %s", out) } }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1rps
- #2README.md
- #3# Rock-Paper-Scissors > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- Play rock-paper-scissors against the chain. The house move is computed deterministically from the current block height plus your personal play count, then the round is scored and your win/loss/draw tally is recorded on-chain. `Render` shows the caller-agnostic global tally, a per-player results table, and the last few rounds played. **Realm path:** `gno.land/r/REPLACE_ADDR/rps` ## Example calls ``` # Play a round (choice: rock|paper|scissors; single letters r|p|s also work) gnokey maketx call -pkgpath "gno.land/r/REPLACE_ADDR/rps" \ -func Play -args "rock" -gas-fee 1000000ugnot -gas-wanted 2000000 ... # View the dashboard gnokey query vm/qrender --data "gno.land/r/REPLACE_ADDR/rps:" ``` ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/rps) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `shortAddr` helper. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. Visible change: addresses now render in monospace. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/rps/v1" gno = "0.9" private = true
- #6rps.gno
- #7package rps import ( "chain" "chain/runtime" "chain/runtime/unsafe" "strconv" "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // Moves. const ( rock = 0 paper = 1 scissors = 2 ) var moveNames = [3]string{"rock", "paper", "scissors"} // Outcomes. const ( outLose = 0 // player loses outDraw = 1 outWin = 2 // player wins ) // tally holds a single player's cumulative record. type tally struct { wins int losses int draws int plays int // total rounds this player has played (drives entropy) } // round is one recorded game (for the recent-rounds table). type round struct { player address height int64 player_m int // player move house_m int // house move outcome int } // State. var ( players = avl.NewTree() // address string -> *tally rounds []round // append-only history; Render shows the tail // Global tally (caller-agnostic). totalWins int // player wins totalLosses int // player losses totalDraws int totalRounds int ) const maxRecent = 8 // parseChoice maps a choice string to a move index; ok=false if invalid. func parseChoice(choice string) (int, bool) { switch strings.ToLower(strings.TrimSpace(choice)) { case "rock", "r": return rock, true case "paper", "p": return paper, true case "scissors", "s": return scissors, true } return 0, false } // houseMove derives the house move deterministically from the chain height // and the caller's play count (so repeated calls in the same block differ). func houseMove(height int64, playCount int) int { x := height + int64(playCount)*7 m := x % 3 if m < 0 { m += 3 } return int(m) } // decide returns the outcome from the player's perspective. func decide(playerMove, houseM int) int { if playerMove == houseM { return outDraw } // player beats (player+1)%3 ... rock(0) beats scissors(2), etc. if (playerMove+2)%3 == houseM { return outWin } return outLose } func getTally(key string) *tally { if v := players.Get(key); v != nil { return v.(*tally) } return nil } // Play plays one round against the chain. choice is "rock"|"paper"|"scissors" // (single-letter shortcuts accepted). It panics on an invalid choice. func Play(cur realm, choice string) { mv, ok := parseChoice(choice) if !ok { panic("invalid choice: use rock, paper, or scissors") } caller := unsafe.PreviousRealm().Address() key := caller.String() t := getTally(key) if t == nil { t = &tally{} players.Set(key, t) } height := runtime.ChainHeight() hm := houseMove(height, t.plays) outcome := decide(mv, hm) t.plays++ switch outcome { case outWin: t.wins++ totalWins++ case outLose: t.losses++ totalLosses++ default: t.draws++ totalDraws++ } totalRounds++ rounds = append(rounds, round{ player: caller, height: height, player_m: mv, house_m: hm, outcome: outcome, }) chain.Emit( "RoundPlayed", "player", key, "choice", moveNames[mv], "house", moveNames[hm], "outcome", outcomeName(outcome), ) } func outcomeName(o int) string { switch o { case outWin: return "win" case outLose: return "lose" default: return "draw" } } func winRate(wins, plays int) string { if plays == 0 { return "0%" } return strconv.Itoa(wins*100/plays) + "%" } // Render returns a Markdown dashboard: global tally, per-player table, and the // last few rounds. It is caller-agnostic (no cur). func Render(path string) string { var b strings.Builder b.WriteString("# Rock-Paper-Scissors — play vs the chain\n\n") b.WriteString("Call `Play(cur, \"rock\"|\"paper\"|\"scissors\")`. ") b.WriteString("The house move is derived deterministically from the current block height ") b.WriteString("and your personal play count.\n\n") // Global tally. b.WriteString("## Global tally\n\n") b.WriteString("| Rounds | Player wins | Player losses | Draws | Player win rate |\n") b.WriteString("|---|---|---|---|---|\n") b.WriteString("| " + strconv.Itoa(totalRounds)) b.WriteString(" | " + strconv.Itoa(totalWins)) b.WriteString(" | " + strconv.Itoa(totalLosses)) b.WriteString(" | " + strconv.Itoa(totalDraws)) b.WriteString(" | " + winRate(totalWins, totalRounds) + " |\n\n") // Per-player table (deterministic order via avl iteration). b.WriteString("## Players\n\n") if players.Size() == 0 { b.WriteString("_No games played yet. Be the first!_\n\n") } else { b.WriteString("| Player | W | L | D | Rounds | Win rate |\n") b.WriteString("|---|---|---|---|---|---|\n") players.Iterate("", "", func(key string, v interface{}) bool { t := v.(*tally) b.WriteString("| " + ui.AddrOf(key)) b.WriteString(" | " + strconv.Itoa(t.wins)) b.WriteString(" | " + strconv.Itoa(t.losses)) b.WriteString(" | " + strconv.Itoa(t.draws)) b.WriteString(" | " + strconv.Itoa(t.plays)) b.WriteString(" | " + winRate(t.wins, t.plays) + " |\n") return false }) b.WriteString("\n") } // Recent rounds (tail). b.WriteString("## Recent rounds\n\n") if len(rounds) == 0 { b.WriteString("_None yet._\n") } else { b.WriteString("| Height | Player | Choice | House | Result |\n") b.WriteString("|---|---|---|---|---|\n") start := len(rounds) - maxRecent if start < 0 { start = 0 } // Show newest first. for i := len(rounds) - 1; i >= start; i-- { r := rounds[i] b.WriteString("| " + strconv.FormatInt(r.height, 10)) b.WriteString(" | " + ui.Addr(r.player)) b.WriteString(" | " + moveNames[r.player_m]) b.WriteString(" | " + moveNames[r.house_m]) b.WriteString(" | " + outcomeName(r.outcome) + " |\n") } } return b.String() }
- #8rps_test.gno
- #9package rps import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) func TestDecideAndParse(t *testing.T) { // parseChoice. cases := []struct { in string want int ok bool }{ {"rock", rock, true}, {"PAPER", paper, true}, {" scissors ", scissors, true}, {"r", rock, true}, {"p", paper, true}, {"s", scissors, true}, {"lizard", 0, false}, } for _, c := range cases { got, ok := parseChoice(c.in) uassert.Equal(t, c.ok, ok, "ok for "+c.in) if ok { uassert.Equal(t, c.want, got, "move for "+c.in) } } // decide: player perspective. uassert.Equal(t, outDraw, decide(rock, rock), "rock vs rock") uassert.Equal(t, outWin, decide(rock, scissors), "rock beats scissors") uassert.Equal(t, outLose, decide(rock, paper), "rock loses to paper") uassert.Equal(t, outWin, decide(paper, rock), "paper beats rock") uassert.Equal(t, outWin, decide(scissors, paper), "scissors beats paper") uassert.Equal(t, outLose, decide(scissors, rock), "scissors loses to rock") // houseMove determinism. uassert.Equal(t, houseMove(10, 0), houseMove(10, 0), "same inputs -> same move") if houseMove(10, 0) == houseMove(10, 1) && houseMove(10, 1) == houseMove(10, 2) { t.Error("play count should vary house move across values") } } func TestPlayRecordsTally(cur realm, t *testing.T) { user := testutils.TestAddress("rps-player") testing.SetRealm(testing.NewUserRealm(user)) before := totalRounds Play(cross(cur),"rock") Play(cross(cur),"paper") Play(cross(cur),"scissors") uassert.Equal(t, before+3, totalRounds, "global rounds incremented") tl := getTally(user.String()) uassert.True(t, tl != nil, "player tally exists") uassert.Equal(t, 3, tl.plays, "player played 3 rounds") uassert.Equal(t, tl.wins+tl.losses+tl.draws, tl.plays, "outcomes sum to plays") // Render must show the global tally header and the player. out := Render("") uassert.True(t, strings.Contains(out, "Global tally"), "render has global tally") uassert.True(t, strings.Contains(out, "Recent rounds"), "render has recent rounds") // Invalid choice aborts (crossing fn -> cross-realm abort). uassert.AbortsWithMessage(t, cur, "invalid choice: use rock, paper, or scissors", func() { Play(cross(cur),"lizard") }) }
- Attached funds
- 10000000ugnot
Arguments · 11
- #1rpsduel
- #2README.md
- #3# Rock-Paper-Scissors Duel > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- Asynchronous, two-player rock-paper-scissors with a real commit-reveal handshake: the challenger locks in a move as a sha256 commitment, the opponent replies in the open (seeing only a hash gives them nothing to go on), and the challenger reveals last to settle the round. A challenger who tries to dodge a losing reveal can be forfeited by the opponent once a 50-block reveal window expires — no house move, no single-tx shortcut, just two players and a fairness primitive. **Realm path:** `gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsduel` ## Example calls ```sh # 1. Compute your commitment off-chain (or via a read-only query) gnokey query vm/qeval \ --data 'gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsduel.ComputeCommit("rock", "xyz123")' # 2. Open a duel against an opponent with that commitment gnokey maketx call \ -pkgpath "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsduel" \ -func Open -args "g1opponentaddr..." -args "<64-char hex commit>" \ -gas-fee 1000000ugnot -gas-wanted 3000000 -broadcast -chainid sapphire-1 <key> # 3. Opponent replies in the clear gnokey maketx call \ -pkgpath "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsduel" \ -func Accept -args "1" -args "paper" \ -gas-fee 1000000ugnot -gas-wanted 3000000 -broadcast -chainid sapphire-1 <key> # 4. Challenger reveals to settle it gnokey maketx call \ -pkgpath "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsduel" \ -func Reveal -args "1" -args "rock" -args "xyz123" \ -gas-fee 1000000ugnot -gas-wanted 3000000 -broadcast -chainid sapphire-1 <key> # View the lobby, a specific duel, or a player's record gnokey query vm/qrender --data "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsduel:" gnokey query vm/qrender --data "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsduel:1" ``` ## Toolchain status Compiled and tested against the `chain/sapphire` (gno 0.9) toolchain tag from `gnolang/gno` — `gno test .` and `gno lint .` both pass clean. ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/rpsduel) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `escapeInline` helper. **Real escaping.** The local helper replaced a handful of markdown metacharacters. `ui.Inline` and `ui.Cell` delegate to [`p/nt/markdown/sanitize`](/p/nt/markdown/sanitize/v0), which also strips bidi and zero-width characters and folds newlines, so user text cannot reorder or escape the line it sits on. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/rpsduel/v1" gno = "0.9" private = true
- #6render_example_test.gno
- #7package rpsduel // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # Rock-Paper-Scissors Duel // // Challenge another address to rock-paper-scissors, async and fair: you commit your move as a hash, they reply in the open, then you reveal to settle it. Stall on revealing a losing round and your opponent can claim a forfeit win once the window expires. // // - Total duels resolved: 0 // - No champion yet -- be the first to win a duel. // // ## How to play // // 1. Pick a move and a random salt, e.g. `rock` + `xyz123`. // 2. Compute your commitment: `ComputeCommit("rock", "xyz123")` (read-only call). // 3. `Open(opponentAddr, commit)` -- opens the duel, returns its ID. // 4. The opponent calls `Accept(id, "paper"|"scissors"|"rock")`. // 5. You call `Reveal(id, "rock", "xyz123")` -- must match your commitment exactly. // 6. If you never reveal, the opponent can call `ClaimForfeit(id)` after 50 blocks. // // View a duel at this realm's path plus its ID (e.g. `.../rpsduel:3`), or a player's record plus their address (e.g. `.../rpsduel:g1youraddress...`). // // ## Open duels // // _none right now_ }
- #8rpsduel.gno
- #9// Package rpsduel is an asynchronous, two-player rock-paper-scissors duel // with a commit-reveal handshake. The challenger locks in a move as a // sha256 commitment up front so the opponent can't peek at it; the // opponent then replies in the clear (seeing only a hash gives them no // edge); the challenger reveals last to settle the round. A challenger who // tries to dodge a losing reveal can be forfeited by the opponent once the // reveal window expires. package rpsduel import ( "crypto/sha256" "encoding/hex" "strconv" "strings" "chain" "chain/runtime" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // Move is one of rock, paper, or scissors, ordered so that // (winner - loser + 3) % 3 == 1 for every winning pair. type Move int const ( Rock Move = iota Paper Scissors ) func moveName(m Move) string { switch m { case Rock: return "rock" case Paper: return "paper" case Scissors: return "scissors" default: return "?" } } func parseMove(s string) (Move, bool) { switch strings.ToLower(strings.TrimSpace(s)) { case "rock", "r": return Rock, true case "paper", "p": return Paper, true case "scissors", "s": return Scissors, true default: return 0, false } } // judge returns 0 for a tie, 1 if p beats h, 2 if h beats p. func judge(p, h Move) int { return (int(p) - int(h) + 3) % 3 } // revealWindow is how many blocks the challenger gets to reveal after the // opponent accepts before the opponent can claim a forfeit win. const revealWindow int64 = 50 type duelStatus int const ( statusPending duelStatus = iota statusAwaitingReveal statusResolved statusForfeited statusCancelled ) func (s duelStatus) String() string { switch s { case statusPending: return "pending" case statusAwaitingReveal: return "awaiting reveal" case statusResolved: return "resolved" case statusForfeited: return "forfeited" case statusCancelled: return "cancelled" default: return "?" } } // duel is one asynchronous round between Challenger and Opponent. The // challenger's move is hidden behind Commit until Reveal; the opponent's // move is stored in the clear once they Accept. type duel struct { ID string Challenger address Opponent address Commit string // hex sha256 of "<move>:<salt>" OpponentMove Move Status duelStatus Result string // "challenger" | "opponent" | "draw" once settled Winner address OpenedAt int64 AcceptedAt int64 RevealDeadline int64 } // playerState is the persisted record for one address. type playerState struct { Wins int Losses int Draws int Forfeits int // times this player, as challenger, failed to reveal in time Duels int } var ( duels avl.Tree // duel ID -> *duel players avl.Tree // address string -> *playerState nextID int totalResolved int champion address championWins int ) // ComputeCommit hashes a move and salt exactly the way Reveal checks it, so // a caller can compute their commitment (e.g. via a read-only query) before // calling Open, then remember the salt to pass to Reveal later. func ComputeCommit(moveStr, salt string) string { m, ok := parseMove(moveStr) if !ok { panic("invalid move: use rock, paper, or scissors (r/p/s)") } sum := sha256.Sum256([]byte(moveName(m) + ":" + salt)) return hex.EncodeToString(sum[:]) } func getOrCreate(addr address) *playerState { key := addr.String() if v := players.Get(key); v != nil { return v.(*playerState) } ps := &playerState{} players.Set(key, ps) return ps } func bumpChampion(addr address, wins int) { if wins > championWins { championWins = wins champion = addr } } // settle scores a revealed round and updates both players' records. func settle(d *duel, challengerMove Move, result int) { cs := getOrCreate(d.Challenger) os := getOrCreate(d.Opponent) cs.Duels++ os.Duels++ switch result { case 1: cs.Wins++ os.Losses++ d.Winner = d.Challenger d.Result = "challenger" bumpChampion(d.Challenger, cs.Wins) case 2: os.Wins++ cs.Losses++ d.Winner = d.Opponent d.Result = "opponent" bumpChampion(d.Opponent, os.Wins) default: cs.Draws++ os.Draws++ d.Result = "draw" } d.Status = statusResolved totalResolved++ } // Open challenges opponentAddr to a duel, locking in the challenger's move // as a commitment (see ComputeCommit) so the opponent can't see it before // replying. func Open(cur realm, opponentAddr string, commitHex string) string { challenger := cur.Previous().Address() opponent := address(strings.TrimSpace(opponentAddr)) if !opponent.IsValid() { panic("invalid opponent address") } if opponent == challenger { panic("cannot duel yourself") } commit := strings.ToLower(strings.TrimSpace(commitHex)) if len(commit) != sha256.Size*2 { panic("commit must be a 64-character hex sha256 digest; build it with ComputeCommit") } if _, err := hex.DecodeString(commit); err != nil { panic("commit must be valid hex") } nextID++ id := strconv.Itoa(nextID) d := &duel{ ID: id, Challenger: challenger, Opponent: opponent, Commit: commit, Status: statusPending, OpenedAt: runtime.ChainHeight(), } duels.Set(id, d) chain.Emit("DuelOpened", "id", id, "challenger", challenger.String(), "opponent", opponent.String(), ) return "duel #" + id + " opened against " + opponent.String() + " -- waiting for them to Accept" } // Accept replies to a pending duel with a plain move. Only the challenged // opponent may call it; going second behind the challenger's hidden // commitment is what keeps this fair. func Accept(cur realm, id string, moveStr string) string { caller := cur.Previous().Address() v := duels.Get(id) if v == nil { panic("no such duel") } d := v.(*duel) if d.Status != statusPending { panic("duel is " + d.Status.String() + ", not pending") } if caller != d.Opponent { panic("only the challenged opponent can accept this duel") } m, ok := parseMove(moveStr) if !ok { panic("invalid move: use rock, paper, or scissors (r/p/s)") } d.OpponentMove = m d.Status = statusAwaitingReveal d.AcceptedAt = runtime.ChainHeight() d.RevealDeadline = d.AcceptedAt + revealWindow chain.Emit("DuelAccepted", "id", id, "opponentMove", moveName(m)) return "you played " + moveName(m) + " in duel #" + id + " -- waiting for " + d.Challenger.String() + " to reveal by block " + strconv.Itoa(int(d.RevealDeadline)) } // Reveal settles an accepted duel. Only the original challenger may call // it, and only with the exact move+salt that produced their commitment. func Reveal(cur realm, id string, moveStr string, salt string) string { caller := cur.Previous().Address() v := duels.Get(id) if v == nil { panic("no such duel") } d := v.(*duel) if d.Status != statusAwaitingReveal { panic("duel is " + d.Status.String() + ", not awaiting reveal") } if caller != d.Challenger { panic("only the challenger can reveal") } m, ok := parseMove(moveStr) if !ok { panic("invalid move: use rock, paper, or scissors (r/p/s)") } sum := sha256.Sum256([]byte(moveName(m) + ":" + salt)) if hex.EncodeToString(sum[:]) != d.Commit { panic("revealed move+salt doesn't match your original commitment") } result := judge(m, d.OpponentMove) settle(d, m, result) chain.Emit("DuelResolved", "id", id, "result", d.Result, "challengerMove", moveName(m), "opponentMove", moveName(d.OpponentMove), ) return "you revealed " + moveName(m) + " vs " + moveName(d.OpponentMove) + " -- " + outcomeMsg(d) } func outcomeMsg(d *duel) string { switch d.Result { case "challenger": return "you win duel #" + d.ID + "!" case "opponent": return "you lose duel #" + d.ID + " -- " + d.Opponent.String() + " wins." default: return "draw." } } // ClaimForfeit lets the opponent collect a default win when the challenger // dodges revealing (e.g. because they saw the opponent's move and knew // they'd lose) past the reveal window. func ClaimForfeit(cur realm, id string) string { caller := cur.Previous().Address() v := duels.Get(id) if v == nil { panic("no such duel") } d := v.(*duel) if d.Status != statusAwaitingReveal { panic("duel is " + d.Status.String() + ", not awaiting reveal") } if caller != d.Opponent { panic("only the waiting opponent can claim a forfeit") } if runtime.ChainHeight() <= d.RevealDeadline { panic("reveal window hasn't expired yet") } d.Status = statusForfeited d.Winner = d.Opponent d.Result = "opponent" cs := getOrCreate(d.Challenger) cs.Duels++ cs.Losses++ cs.Forfeits++ os := getOrCreate(d.Opponent) os.Duels++ os.Wins++ totalResolved++ bumpChampion(d.Opponent, os.Wins) chain.Emit("DuelForfeited", "id", id, "winner", d.Opponent.String()) return "duel #" + id + " forfeited -- " + d.Challenger.String() + " never revealed" } // Cancel withdraws a still-pending duel. Only the challenger may cancel, // and only before the opponent accepts. func Cancel(cur realm, id string) string { caller := cur.Previous().Address() v := duels.Get(id) if v == nil { panic("no such duel") } d := v.(*duel) if d.Status != statusPending { panic("duel is " + d.Status.String() + ", not pending") } if caller != d.Challenger { panic("only the challenger can cancel this duel") } d.Status = statusCancelled return "duel #" + id + " cancelled" } func renderHome() string { var b strings.Builder b.WriteString("# Rock-Paper-Scissors Duel\n\n") b.WriteString("Challenge another address to rock-paper-scissors, async and " + "fair: you commit your move as a hash, they reply in the open, then you " + "reveal to settle it. Stall on revealing a losing round and your opponent " + "can claim a forfeit win once the window expires.\n\n") b.WriteString("- Total duels resolved: " + strconv.Itoa(totalResolved) + "\n") if champion.IsValid() { b.WriteString("- Reigning champion: `" + champion.String() + "` (" + strconv.Itoa(championWins) + " wins)\n") } else { b.WriteString("- No champion yet -- be the first to win a duel.\n") } b.WriteString("\n## How to play\n\n") b.WriteString("1. Pick a move and a random salt, e.g. `rock` + `xyz123`.\n") b.WriteString("2. Compute your commitment: `ComputeCommit(\"rock\", \"xyz123\")` (read-only call).\n") b.WriteString("3. `Open(opponentAddr, commit)` -- opens the duel, returns its ID.\n") b.WriteString("4. The opponent calls `Accept(id, \"paper\"|\"scissors\"|\"rock\")`.\n") b.WriteString("5. You call `Reveal(id, \"rock\", \"xyz123\")` -- must match your commitment exactly.\n") b.WriteString("6. If you never reveal, the opponent can call `ClaimForfeit(id)` after " + strconv.Itoa(int(revealWindow)) + " blocks.\n\n") b.WriteString("View a duel at this realm's path plus its ID (e.g. `.../rpsduel:3`), " + "or a player's record plus their address (e.g. `.../rpsduel:g1youraddress...`).\n\n") b.WriteString("## Open duels\n\n") open := 0 duels.Iterate("", "", func(key string, value interface{}) bool { d := value.(*duel) if d.Status == statusPending { open++ b.WriteString("- #" + d.ID + ": `" + d.Challenger.String() + "` waiting on `" + d.Opponent.String() + "` to Accept\n") } else if d.Status == statusAwaitingReveal { open++ b.WriteString("- #" + d.ID + ": `" + d.Challenger.String() + "` must Reveal by block " + strconv.Itoa(int(d.RevealDeadline)) + "\n") } return false }) if open == 0 { b.WriteString("_none right now_\n") } return b.String() } func renderDuel(id string) string { v := duels.Get(id) if v == nil { return "# Duel #" + ui.Inline(id) + "\n\nNo such duel.\n" } d := v.(*duel) var b strings.Builder b.WriteString("# Duel #" + d.ID + "\n\n") b.WriteString("- Challenger: `" + d.Challenger.String() + "`\n") b.WriteString("- Opponent: `" + d.Opponent.String() + "`\n") b.WriteString("- Status: " + d.Status.String() + "\n") if d.Status == statusAwaitingReveal { b.WriteString("- Opponent played: **" + moveName(d.OpponentMove) + "**\n") b.WriteString("- Reveal deadline: block " + strconv.Itoa(int(d.RevealDeadline)) + "\n") } if d.Status == statusResolved || d.Status == statusForfeited { b.WriteString("- Result: " + d.Result + "\n") b.WriteString("- Winner: `" + d.Winner.String() + "`\n") } return b.String() } func renderPlayer(rawAddr string) string { addr := strings.TrimSpace(rawAddr) safe := ui.Inline(addr) v := players.Get(addr) if v == nil { return "# Player " + safe + "\n\nNo recorded duels yet.\n" } ps := v.(*playerState) var b strings.Builder b.WriteString("# Player " + safe + "\n\n") b.WriteString("- Wins: " + strconv.Itoa(ps.Wins) + "\n") b.WriteString("- Losses: " + strconv.Itoa(ps.Losses) + "\n") b.WriteString("- Draws: " + strconv.Itoa(ps.Draws) + "\n") b.WriteString("- Duels played: " + strconv.Itoa(ps.Duels) + "\n") if ps.Forfeits > 0 { b.WriteString("- Forfeited (didn't reveal in time): " + strconv.Itoa(ps.Forfeits) + "\n") } return b.String() } // Render shows the duel lobby at "", a specific duel when path is a // numeric ID, or one player's record when path is their bech32 address. func Render(path string) string { path = strings.TrimPrefix(strings.TrimSpace(path), "/") if path == "" { return renderHome() } if _, err := strconv.Atoi(path); err == nil { return renderDuel(path) } return renderPlayer(path) }
- #10rpsduel_test.gno
- #11package rpsduel import ( "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) func resetState() { duels = avl.Tree{} players = avl.Tree{} nextID = 0 totalResolved = 0 var zero address champion = zero championWins = 0 } func TestParseMove(t *testing.T) { for _, s := range []string{"rock", "Rock", " r ", "paper", "P", "scissors", "S"} { if _, ok := parseMove(s); !ok { t.Fatalf("parseMove(%q) should be valid", s) } } if _, ok := parseMove("lizard"); ok { t.Fatal(`parseMove("lizard") should be invalid`) } } func TestJudge(t *testing.T) { cases := []struct { p, h Move want int }{ {Rock, Rock, 0}, {Paper, Paper, 0}, {Scissors, Scissors, 0}, {Paper, Rock, 1}, {Scissors, Paper, 1}, {Rock, Scissors, 1}, {Rock, Paper, 2}, {Paper, Scissors, 2}, {Scissors, Rock, 2}, } for _, c := range cases { if got := judge(c.p, c.h); got != c.want { t.Fatalf("judge(%v,%v) = %d, want %d", c.p, c.h, got, c.want) } } } func TestComputeCommitDeterministicAndSalted(t *testing.T) { a := ComputeCommit("rock", "salt1") b := ComputeCommit("rock", "salt1") if a != b { t.Fatal("ComputeCommit should be deterministic for the same move+salt") } if a == ComputeCommit("rock", "salt2") { t.Fatal("different salts should produce different commits") } if a == ComputeCommit("paper", "salt1") { t.Fatal("different moves should produce different commits") } if len(a) != 64 { t.Fatalf("commit length = %d, want 64 (hex sha256)", len(a)) } } // Crossing: Open/Accept/Reveal mutate realm state, so the test needs `cur realm`. func TestFullDuelChallengerWins(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") commit := ComputeCommit("paper", "s3cr3t") testing.SetRealm(testing.NewUserRealm(alice)) Open(cross(cur), bob.String(), commit) v := duels.Get("1") if v == nil { t.Fatal("expected duel #1 to exist") } d := v.(*duel) if d.Status != statusPending { t.Fatalf("Status = %v, want pending", d.Status) } testing.SetRealm(testing.NewUserRealm(bob)) Accept(cross(cur), "1", "rock") if d.Status != statusAwaitingReveal { t.Fatalf("Status = %v, want awaiting reveal", d.Status) } testing.SetRealm(testing.NewUserRealm(alice)) Reveal(cross(cur), "1", "paper", "s3cr3t") if d.Status != statusResolved { t.Fatalf("Status = %v, want resolved", d.Status) } if d.Result != "challenger" { t.Fatalf("Result = %q, want challenger (paper beats rock)", d.Result) } if d.Winner != alice { t.Fatal("expected alice to win") } if totalResolved != 1 { t.Fatalf("totalResolved = %d, want 1", totalResolved) } aliceStats := getOrCreate(alice) if aliceStats.Wins != 1 { t.Fatalf("alice.Wins = %d, want 1", aliceStats.Wins) } bobStats := getOrCreate(bob) if bobStats.Losses != 1 { t.Fatalf("bob.Losses = %d, want 1", bobStats.Losses) } if !champion.IsValid() || champion != alice { t.Fatal("champion should be alice") } } func TestRevealMismatchPanics(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) Open(cross(cur), bob.String(), ComputeCommit("rock", "s3cr3t")) testing.SetRealm(testing.NewUserRealm(bob)) Accept(cross(cur), "1", "scissors") testing.SetRealm(testing.NewUserRealm(alice)) rec := revive(func() { Reveal(cross(cur), "1", "paper", "s3cr3t") }) if rec == nil { t.Fatal("expected panic when revealed move doesn't match the commitment") } } func TestOnlyOpponentCanAccept(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") mallory := testutils.TestAddress("mallory") testing.SetRealm(testing.NewUserRealm(alice)) Open(cross(cur), bob.String(), ComputeCommit("rock", "s3cr3t")) testing.SetRealm(testing.NewUserRealm(mallory)) rec := revive(func() { Accept(cross(cur), "1", "paper") }) if rec == nil { t.Fatal("expected panic when a non-opponent accepts") } } func TestClaimForfeitBeforeDeadlinePanics(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) Open(cross(cur), bob.String(), ComputeCommit("rock", "s3cr3t")) testing.SetRealm(testing.NewUserRealm(bob)) Accept(cross(cur), "1", "paper") rec := revive(func() { ClaimForfeit(cross(cur), "1") }) if rec == nil { t.Fatal("expected panic when claiming a forfeit before the reveal window expires") } } func TestClaimForfeitAfterDeadline(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) Open(cross(cur), bob.String(), ComputeCommit("rock", "s3cr3t")) testing.SetRealm(testing.NewUserRealm(bob)) Accept(cross(cur), "1", "paper") testing.SkipHeights(revealWindow + 1) ClaimForfeit(cross(cur), "1") v := duels.Get("1") d := v.(*duel) if d.Status != statusForfeited { t.Fatalf("Status = %v, want forfeited", d.Status) } if d.Winner != bob { t.Fatal("expected bob to win by forfeit") } aliceStats := getOrCreate(alice) if aliceStats.Forfeits != 1 { t.Fatalf("alice.Forfeits = %d, want 1", aliceStats.Forfeits) } } func TestCancel(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) Open(cross(cur), bob.String(), ComputeCommit("rock", "s3cr3t")) Cancel(cross(cur), "1") v := duels.Get("1") d := v.(*duel) if d.Status != statusCancelled { t.Fatalf("Status = %v, want cancelled", d.Status) } } func TestOpenSelfRejected(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) rec := revive(func() { Open(cross(cur), alice.String(), ComputeCommit("rock", "s3cr3t")) }) if rec == nil { t.Fatal("expected panic when challenging yourself") } } // Non-crossing: Render is a plain read. func TestRenderHome(t *testing.T) { resetState() out := Render("") if out == "" { t.Fatal(`Render("") should not be empty`) } }
- Attached funds
- 6000000ugnot
Arguments · 11
- #1rpsmatch
- #2README.md
- #3# Rock-Paper-Scissors Arena > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- Best-of-three rock-paper-scissors against the house, played across multiple calls instead of a single stateless throw. Each `Throw` plays one round of your current match (starting a new one if you don't have one in progress); the house's move is derived deterministically from the current block height, a per-realm nonce, and your address. First to two round wins takes the match, and every player's match record (wins, losses, rounds played) is kept on-chain and viewable per-address. **Realm path:** `gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsmatch` ## Example calls ```sh # Play one round of your current (or a new) match gnokey maketx call \ -pkgpath "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsmatch" \ -func Throw -args "rock" \ -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid sapphire-1 <key> # View the arena dashboard gnokey query vm/qrender \ --data "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsmatch:" # View one player's record gnokey query vm/qrender \ --data "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsmatch:g1youraddress..." ``` ## Toolchain Written against the sapphire (gno 0.9) stdlib split (`chain`, `chain/runtime`, `gno.land/p/nt/avl/v0`). The `gno` binary on `PATH` here is a newer/older mismatch of its own `GNOROOT` (native-function drift), so tests were run against a `gno` built from the exact pseudo-version pinned in that binary's own module info (`github.com/gnolang/gno@v0.0.0-20260605043206-bf5b31eda8b9`). All 5 tests pass (`gno test -v .`) and `gno lint .` is clean. ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/rpsmatch) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `escapeInline` helper. **Real escaping.** The local helper replaced a handful of markdown metacharacters. `ui.Inline` and `ui.Cell` delegate to [`p/nt/markdown/sanitize`](/p/nt/markdown/sanitize/v0), which also strips bidi and zero-width characters and folds newlines, so user text cannot reorder or escape the line it sits on. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/rpsmatch/v1" gno = "0.9" private = true
- #6render_example_test.gno
- #7package rpsmatch // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # Rock-Paper-Scissors Arena // // Best-of-three matches against the house. First to 2 round wins takes the match. // // - Total rounds played: 0 // - No champion yet — be the first to win a match. // // ## How to play // // Call `Throw("rock"|"paper"|"scissors")` (or `r`/`p`/`s`). Your throw starts a new match if you don't have one in progress, and each call plays one round of it. // // View your own record at this realm's path plus your address, e.g. `.../rpsmatch:g1youraddress...` }
- #8rpsmatch.gno
- #9// Package rpsmatch is a best-of-three rock-paper-scissors arena against the // house. Unlike a single stateless throw, a match persists across calls: // the first player to win two rounds takes the match, and every player's // match record accumulates on-chain. package rpsmatch import ( "strconv" "strings" "chain" "chain/runtime" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // Move is one of rock, paper, or scissors, ordered so that // (winner - loser + 3) % 3 == 1 for every winning pair. type Move int const ( Rock Move = iota Paper Scissors ) // winsNeeded is the number of round wins required to take a match. const winsNeeded = 2 // match tracks an in-progress best-of-three series for one player. type match struct { PlayerWins int HouseWins int Rounds int } // playerState is the persisted record for one address. type playerState struct { Active *match MatchWins int MatchLosses int MatchesPlayed int RoundsPlayed int } var ( players avl.Tree // address string -> *playerState nonce int totalRounds int champion address championWins int ) func moveName(m Move) string { switch m { case Rock: return "rock" case Paper: return "paper" case Scissors: return "scissors" default: return "?" } } func parseMove(s string) (Move, bool) { switch strings.ToLower(strings.TrimSpace(s)) { case "rock", "r": return Rock, true case "paper", "p": return Paper, true case "scissors", "s": return Scissors, true default: return 0, false } } // judge returns 0 for a tie, 1 if p beats h, 2 if h beats p. func judge(p, h Move) int { return (int(p) - int(h) + 3) % 3 } // pickHouseMove derives a deterministic pseudo-random move from the current // chain height, a monotonic per-realm nonce, and the caller's address, so // repeated throws in the same block still diverge. func pickHouseMove(caller address, n int) Move { seed := runtime.ChainHeight() + int64(n) s := caller.String() for i := 0; i < len(s); i++ { seed += int64(s[i]) } if seed < 0 { seed = -seed } return Move(seed % 3) } func getOrCreate(addr address) *playerState { key := addr.String() if v := players.Get(key); v != nil { return v.(*playerState) } ps := &playerState{} players.Set(key, ps) return ps } // Throw plays one round of the caller's current match, starting a fresh // match if none is in progress. Accepts "rock"/"paper"/"scissors" or the // single-letter shorthand "r"/"p"/"s". func Throw(cur realm, moveStr string) string { if !cur.IsCurrent() { panic("invalid realm") } caller := cur.Previous().Address() playerMove, ok := parseMove(moveStr) if !ok { panic("invalid move: use rock, paper, or scissors (r/p/s)") } ps := getOrCreate(caller) if ps.Active == nil { ps.Active = &match{} } m := ps.Active nonce++ houseMove := pickHouseMove(caller, nonce) round := judge(playerMove, houseMove) m.Rounds++ totalRounds++ ps.RoundsPlayed++ var roundMsg string switch round { case 1: m.PlayerWins++ roundMsg = "you win the round" case 2: m.HouseWins++ roundMsg = "house wins the round" default: roundMsg = "round tied" } out := "round " + strconv.Itoa(m.Rounds) + ": you played " + moveName(playerMove) + ", house played " + moveName(houseMove) + " -> " + roundMsg + " (score " + strconv.Itoa(m.PlayerWins) + "-" + strconv.Itoa(m.HouseWins) + ")" if m.PlayerWins >= winsNeeded || m.HouseWins >= winsNeeded { ps.Active = nil ps.MatchesPlayed++ playerTookMatch := m.PlayerWins > m.HouseWins if playerTookMatch { ps.MatchWins++ if ps.MatchWins > championWins { championWins = ps.MatchWins champion = caller } out += ". MATCH WON!" } else { ps.MatchLosses++ out += ". match lost." } chain.Emit("MatchFinished", "player", caller.String(), "playerWins", strconv.Itoa(m.PlayerWins), "houseWins", strconv.Itoa(m.HouseWins), ) } chain.Emit("RoundPlayed", "player", caller.String(), "playerMove", moveName(playerMove), "houseMove", moveName(houseMove), "result", strconv.Itoa(round), ) return out } func renderHome() string { var b strings.Builder b.WriteString("# Rock-Paper-Scissors Arena\n\n") b.WriteString("Best-of-three matches against the house. First to " + strconv.Itoa(winsNeeded) + " round wins takes the match.\n\n") b.WriteString("- Total rounds played: " + strconv.Itoa(totalRounds) + "\n") if champion.IsValid() { b.WriteString("- Reigning champion: `" + champion.String() + "` (" + strconv.Itoa(championWins) + " match wins)\n") } else { b.WriteString("- No champion yet — be the first to win a match.\n") } b.WriteString("\n## How to play\n\n") b.WriteString("Call `Throw(\"rock\"|\"paper\"|\"scissors\")` (or `r`/`p`/`s`). " + "Your throw starts a new match if you don't have one in progress, " + "and each call plays one round of it.\n\n") b.WriteString("View your own record at this realm's path plus your address, " + "e.g. `.../rpsmatch:g1youraddress...`\n") return b.String() } func renderPlayer(rawAddr string) string { addr := strings.TrimSpace(rawAddr) safe := ui.Inline(addr) v := players.Get(addr) if v == nil { return "# Player " + safe + "\n\nNo recorded throws yet.\n" } ps := v.(*playerState) var b strings.Builder b.WriteString("# Player " + safe + "\n\n") b.WriteString("- Matches won: " + strconv.Itoa(ps.MatchWins) + "\n") b.WriteString("- Matches lost: " + strconv.Itoa(ps.MatchLosses) + "\n") b.WriteString("- Matches played: " + strconv.Itoa(ps.MatchesPlayed) + "\n") b.WriteString("- Rounds played: " + strconv.Itoa(ps.RoundsPlayed) + "\n") if ps.Active != nil { b.WriteString("\n**Match in progress:** " + strconv.Itoa(ps.Active.PlayerWins) + "-" + strconv.Itoa(ps.Active.HouseWins) + " through " + strconv.Itoa(ps.Active.Rounds) + " round(s).\n") } return b.String() } // Render shows the arena dashboard at "", or one player's record when path // is their bech32 address. func Render(path string) string { path = strings.TrimPrefix(strings.TrimSpace(path), "/") if path == "" { return renderHome() } return renderPlayer(path) }
- #10rpsmatch_test.gno
- #11package rpsmatch import ( "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) func resetState() { players = avl.Tree{} nonce = 0 totalRounds = 0 var zero address champion = zero championWins = 0 } func TestJudge(t *testing.T) { cases := []struct { p, h Move want int }{ {Rock, Rock, 0}, {Paper, Paper, 0}, {Scissors, Scissors, 0}, {Paper, Rock, 1}, {Scissors, Paper, 1}, {Rock, Scissors, 1}, {Rock, Paper, 2}, {Paper, Scissors, 2}, {Scissors, Rock, 2}, } for _, c := range cases { if got := judge(c.p, c.h); got != c.want { t.Fatalf("judge(%v,%v) = %d, want %d", c.p, c.h, got, c.want) } } } func TestParseMove(t *testing.T) { for _, s := range []string{"rock", "Rock", " r ", "paper", "P", "scissors", "S"} { if _, ok := parseMove(s); !ok { t.Fatalf("parseMove(%q) should be valid", s) } } if _, ok := parseMove("lizard"); ok { t.Fatal("parseMove(\"lizard\") should be invalid") } } // Crossing: Throw mutates realm state, so the test needs `cur realm`. func TestThrowUpdatesStats(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) Throw(cross(cur), "rock") ps := getOrCreate(alice) if ps.RoundsPlayed != 1 { t.Fatalf("RoundsPlayed = %d, want 1", ps.RoundsPlayed) } if totalRounds != 1 { t.Fatalf("totalRounds = %d, want 1", totalRounds) } } func TestMatchCompletes(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) for i := 0; i < 10; i++ { ps := getOrCreate(alice) if ps.Active == nil && ps.MatchesPlayed > 0 { break } Throw(cross(cur), "rock") } ps := getOrCreate(alice) if ps.MatchesPlayed == 0 { t.Fatal("expected a match to complete within 10 rounds") } if ps.MatchWins+ps.MatchLosses != ps.MatchesPlayed { t.Fatalf("wins+losses (%d) != matchesPlayed (%d)", ps.MatchWins+ps.MatchLosses, ps.MatchesPlayed) } } // Non-crossing: Render is a plain read. func TestRenderHome(t *testing.T) { resetState() out := Render("") if out == "" { t.Fatal("Render(\"\") should not be empty") } }
- Attached funds
- 6000000ugnot
Arguments · 11
- #1rpsoracle
- #2README.md
- #3# Rock-Paper-Scissors Oracle > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- A rock-paper-scissors opponent that doesn't roll dice — it studies you. The oracle tallies every move you've ever thrown and always counters whichever one you favor most, so a fixed habit gets punished while a genuinely mixed (roughly 1/3-1/3-1/3) strategy is unbeatable in the long run. It's a live, playable demo of the rock-paper-scissors mixed-strategy Nash equilibrium: `Render` shows your win/loss/draw record, your move-history breakdown, and a "predictability score" (your most-played move's share of your rounds — lower means the oracle has less to work with). **Realm path:** `gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsoracle` ## Example calls ```sh # Play one round — the oracle predicts from your move history, then counters gnokey maketx call \ -pkgpath "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsoracle" \ -func Play -args "rock" \ -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid sapphire-1 <key> # View the oracle's dashboard (total rounds, oracle win rate, top outwitter) gnokey query vm/qrender \ --data "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsoracle:" # View one player's record and move-history breakdown gnokey query vm/qrender \ --data "gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/rpsoracle:g1youraddress..." ``` ## Toolchain Written against the sapphire (gno 0.9) stdlib split (`chain`, `chain/runtime`, `gno.land/p/nt/avl/v0`, `gno.land/p/nt/testutils/v0`), following the same crossing-function (`cur realm`) and `avl.Tree`-backed state patterns as the `rpsmatch` realm in this repo. v0's README noted that `gno test` could not be run when it was written, because the `gno` on `PATH` embedded a `GNOROOT` pointing at a deleted directory. That no longer applies: **v1 is tested**, with `gno` built from `origin/master`, and it passes `make test` and `make lint` alongside the rest of the repo. ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/rpsoracle) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `escapeInline` helper. **Real escaping.** The local helper replaced a handful of markdown metacharacters. `ui.Inline` and `ui.Cell` delegate to [`p/nt/markdown/sanitize`](/p/nt/markdown/sanitize/v0), which also strips bidi and zero-width characters and folds newlines, so user text cannot reorder or escape the line it sits on. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/rpsoracle/v1" gno = "0.9" private = true
- #6render_example_test.gno
- #7package rpsoracle // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # Rock-Paper-Scissors Oracle // // An adaptive opponent: it doesn't roll dice, it studies you. Every throw is logged, and the oracle always counters whichever move you've played most often. Play a uniform mixed strategy and it can't out-guess you; fall into a habit and it will. // // - Total rounds played: 0 // - No one has beaten the oracle yet. // // ## How to play // // Call `Play("rock"|"paper"|"scissors")` (or `r`/`p`/`s`). View your own record at this realm's path plus your address, e.g. `.../rpsoracle:g1youraddress...` }
- #8rpsoracle.gno
- #9// Package rpsoracle is a rock-paper-scissors opponent that doesn't roll // dice: it studies each player's move history and always throws the // counter to whichever move that player has favored most. Play a fixed // pattern and the oracle punishes it; play close to a uniform 1/3-1/3-1/3 // mix and it can't out-guess you better than chance. package rpsoracle import ( "strconv" "strings" "chain" "chain/runtime" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // Move is one of rock, paper, or scissors, ordered so that // (winner - loser + 3) % 3 == 1 for every winning pair. type Move int const ( Rock Move = iota Paper Scissors ) func moveName(m Move) string { switch m { case Rock: return "rock" case Paper: return "paper" case Scissors: return "scissors" default: return "?" } } func parseMove(s string) (Move, bool) { switch strings.ToLower(strings.TrimSpace(s)) { case "rock", "r": return Rock, true case "paper", "p": return Paper, true case "scissors", "s": return Scissors, true default: return 0, false } } // judge returns 0 for a tie, 1 if p beats h, 2 if h beats p. func judge(p, h Move) int { return (int(p) - int(h) + 3) % 3 } // beats returns the move that defeats m. func beats(m Move) Move { return Move((int(m) + 1) % 3) } // playerState is the persisted record for one address. type playerState struct { Counts [3]int // history tally per move, what the oracle predicts from Rounds int Wins int // player beat the oracle Losses int // oracle beat the player Draws int BestStreak int streak int // current player win streak against the oracle } var ( players avl.Tree // address string -> *playerState nonce int totalRounds int oracleCorrect int // rounds the oracle won by successfully countering topOutwitter address topOutwitterWins int ) func getOrCreate(addr address) *playerState { key := addr.String() if v := players.Get(key); v != nil { return v.(*playerState) } ps := &playerState{} players.Set(key, ps) return ps } // predict guesses the player's next move as the most-played move in their // history so far. Ties (including a fresh player's all-zero history) fall // back to a chain-height-derived seed so the oracle doesn't always break // ties the same way. func predict(ps *playerState, seed int64) Move { best := Rock bestCount := ps.Counts[Rock] tied := []Move{Rock} for _, m := range []Move{Paper, Scissors} { switch { case ps.Counts[m] > bestCount: bestCount = ps.Counts[m] best = m tied = []Move{m} case ps.Counts[m] == bestCount: tied = append(tied, m) } } if len(tied) > 1 { if seed < 0 { seed = -seed } best = tied[int(seed)%len(tied)] } return best } // Play pits the caller against the oracle: it predicts your next move from // your own move history and throws the counter. Accepts // "rock"/"paper"/"scissors" or the single-letter shorthand "r"/"p"/"s". func Play(cur realm, moveStr string) string { if !cur.IsCurrent() { panic("invalid realm") } caller := cur.Previous().Address() playerMove, ok := parseMove(moveStr) if !ok { panic("invalid move: use rock, paper, or scissors (r/p/s)") } ps := getOrCreate(caller) nonce++ seed := runtime.ChainHeight() + int64(nonce) predicted := predict(ps, seed) oracleMove := beats(predicted) result := judge(playerMove, oracleMove) ps.Counts[playerMove]++ ps.Rounds++ totalRounds++ var msg string switch result { case 1: ps.Wins++ ps.streak++ if ps.streak > ps.BestStreak { ps.BestStreak = ps.streak } if ps.Wins > topOutwitterWins { topOutwitterWins = ps.Wins topOutwitter = caller } msg = "you outwitted the oracle!" case 2: ps.Losses++ ps.streak = 0 oracleCorrect++ msg = "the oracle read you like a book." default: ps.Draws++ ps.streak = 0 msg = "a draw — you and the oracle picked the same move." } chain.Emit("RoundPlayed", "player", caller.String(), "playerMove", moveName(playerMove), "oraclePredicted", moveName(predicted), "oracleMove", moveName(oracleMove), "result", strconv.Itoa(result), ) return "you played " + moveName(playerMove) + ", the oracle predicted " + moveName(predicted) + " and threw " + moveName(oracleMove) + " -> " + msg } func renderHome() string { var b strings.Builder b.WriteString("# Rock-Paper-Scissors Oracle\n\n") b.WriteString("An adaptive opponent: it doesn't roll dice, it studies you. ") b.WriteString("Every throw is logged, and the oracle always counters whichever ") b.WriteString("move you've played most often. Play a uniform mixed strategy and ") b.WriteString("it can't out-guess you; fall into a habit and it will.\n\n") b.WriteString("- Total rounds played: " + strconv.Itoa(totalRounds) + "\n") if totalRounds > 0 { pct := oracleCorrect * 100 / totalRounds b.WriteString("- Oracle win rate: " + strconv.Itoa(pct) + "%\n") } if topOutwitter.IsValid() { b.WriteString("- Top outwitter: `" + topOutwitter.String() + "` (" + strconv.Itoa(topOutwitterWins) + " wins against the oracle)\n") } else { b.WriteString("- No one has beaten the oracle yet.\n") } b.WriteString("\n## How to play\n\n") b.WriteString("Call `Play(\"rock\"|\"paper\"|\"scissors\")` (or `r`/`p`/`s`). ") b.WriteString("View your own record at this realm's path plus your address, ") b.WriteString("e.g. `.../rpsoracle:g1youraddress...`\n") return b.String() } func renderPlayer(rawAddr string) string { addr := strings.TrimSpace(rawAddr) safe := ui.Inline(addr) v := players.Get(addr) if v == nil { return "# Player " + safe + "\n\nNo recorded rounds yet.\n" } ps := v.(*playerState) var b strings.Builder b.WriteString("# Player " + safe + "\n\n") b.WriteString("- Rounds played: " + strconv.Itoa(ps.Rounds) + "\n") b.WriteString("- Beat the oracle: " + strconv.Itoa(ps.Wins) + "\n") b.WriteString("- Lost to the oracle: " + strconv.Itoa(ps.Losses) + "\n") b.WriteString("- Draws: " + strconv.Itoa(ps.Draws) + "\n") b.WriteString("- Best win streak vs oracle: " + strconv.Itoa(ps.BestStreak) + "\n") b.WriteString("- Move history — rock: " + strconv.Itoa(ps.Counts[Rock]) + ", paper: " + strconv.Itoa(ps.Counts[Paper]) + ", scissors: " + strconv.Itoa(ps.Counts[Scissors]) + "\n") if ps.Rounds > 0 { maxCount := ps.Counts[Rock] for _, c := range ps.Counts[1:] { if c > maxCount { maxCount = c } } predictability := maxCount * 100 / ps.Rounds b.WriteString("- Predictability score: " + strconv.Itoa(predictability) + "% (lower is harder for the oracle to read)\n") } return b.String() } // Render shows the oracle's dashboard at "", or one player's record when // path is their bech32 address. func Render(path string) string { path = strings.TrimPrefix(strings.TrimSpace(path), "/") if path == "" { return renderHome() } return renderPlayer(path) }
- #10rpsoracle_test.gno
- #11package rpsoracle import ( "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) func resetState() { players = avl.Tree{} nonce = 0 totalRounds = 0 oracleCorrect = 0 var zero address topOutwitter = zero topOutwitterWins = 0 } func TestJudge(t *testing.T) { cases := []struct { p, h Move want int }{ {Rock, Rock, 0}, {Paper, Paper, 0}, {Scissors, Scissors, 0}, {Paper, Rock, 1}, {Scissors, Paper, 1}, {Rock, Scissors, 1}, {Rock, Paper, 2}, {Paper, Scissors, 2}, {Scissors, Rock, 2}, } for _, c := range cases { if got := judge(c.p, c.h); got != c.want { t.Fatalf("judge(%v,%v) = %d, want %d", c.p, c.h, got, c.want) } } } func TestBeats(t *testing.T) { if beats(Rock) != Paper { t.Fatal("beats(Rock) should be Paper") } if beats(Paper) != Scissors { t.Fatal("beats(Paper) should be Scissors") } if beats(Scissors) != Rock { t.Fatal("beats(Scissors) should be Rock") } } func TestParseMove(t *testing.T) { for _, s := range []string{"rock", "Rock", " r ", "paper", "P", "scissors", "S"} { if _, ok := parseMove(s); !ok { t.Fatalf("parseMove(%q) should be valid", s) } } if _, ok := parseMove("lizard"); ok { t.Fatal("parseMove(\"lizard\") should be invalid") } } func TestPredictPicksMostFrequent(t *testing.T) { ps := &playerState{} ps.Counts[Paper] = 5 ps.Counts[Rock] = 1 if got := predict(ps, 0); got != Paper { t.Fatalf("predict = %v, want Paper", got) } } // Crossing: Play mutates realm state, so the test needs `cur realm`. func TestOracleCountersPredictableRock(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) for i := 0; i < 5; i++ { Play(cross(cur), "rock") } ps := getOrCreate(alice) if ps.Losses == 0 { t.Fatal("expected the oracle to eventually counter an all-rock player") } if ps.Rounds != 5 { t.Fatalf("Rounds = %d, want 5", ps.Rounds) } } // Non-crossing: Render is a plain read. func TestRenderHome(t *testing.T) { resetState() out := Render("") if out == "" { t.Fatal("Render(\"\") should not be empty") } }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1sparklinedemo
- #2README.md
- #3# `gno.land/r/moul/x/daily/sparklinedemo/v0` A gnoweb demo of the [`p/moul/x/daily/sparkline`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/daily/sparkline) library: it sparks a handful of fixed series and shows the three behaviours worth knowing before putting one in a realm — a flat series rendering mid-ramp, a fixed window clamping its outliers, and floor rounding keeping the peak distinct. It holds no state and contains no rendering logic of its own; the series are hard-coded rather than read from chain state, so `Render` is deterministic and its output is pinned by an example test. Render it at [`/r/moul/x/daily/sparklinedemo/v0`](https://gno.land/r/moul/x/daily/sparklinedemo/v0). <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/sparklinedemo/v0" gno = "0.9" private = true
- #6render_example_test.gno
- #7package sparklinedemo // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # Sparklines // // A numeric series as one line of block characters, demoing the [`p/moul/x/daily/sparkline`](/p/moul/x/daily/sparkline/v0) library. // // ## A series // // `▁▁▂▄▃▅█▆▅▃▂▁` // // `3, 5, 9, 14, 12, 18, 25, 21, 16, 11, 7, 4` // // Scaled between its own bounds, `3` and `25`. The shape is the point; the axis is not. // // ## The ramp // // `▁▂▃▄▅▆▇█` — eight levels, low to high. // // | value | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | // |---|---|---|---|---|---|---|---|---| // | level | `▁` | `▂` | `▃` | `▄` | `▅` | `▆` | `▇` | `█` | // // ## A flat series // // `▅▅▅▅` — `1000, 1000, 1000, 1000` // // Mid-ramp, not along the floor. A series that never moves has no shape, but drawing it at the bottom would claim it was *zero*, which is a different thing and a wrong one. // // ## Negatives // // `▁▃▄▅█▅▂` — `-8, -3, 0, 4, 9, 2, -5` // // Nothing special: the window is the minimum and maximum, wherever zero happens to fall. // // ## A fixed window clamps // // `-20, 0, 25, 50, 75, 100, 140` against `0..100`: // // `▁▁▂▄▆██` // // `-20` pins to the floor and `140` to the ceiling. Choosing the window keeps a percentage readable, and one outlier cannot flatten the rest of the picture — or break the realm that drew it. // // ## Rounding is floor // // | value in `0..100` | level | // |---|---| // | 0 | `▁` | // | 14 | `▁` | // | 50 | `▄` | // | 85 | `▆` | // | 99 | `▇` | // | 100 | `█` | // // Only the maximum itself reaches the top of the ramp, so the peak of a series is always visually distinct. // // ## Why integers // // Scaling is integer division throughout. Render output has to be byte-identical on every validating node, and floating point is the usual way that quietly stops being true. }
- #8sparklinedemo.gno
- #9// Package sparklinedemo is a small gnoweb demo of the block-character series // renderer provided by the [p/moul/x/daily/sparkline](/p/moul/x/daily/sparkline/v0) // library: it sparks a few fixed series and shows the three behaviours worth // knowing before using it in a realm — flat series, clamping, and floor // rounding. // // It contains no rendering logic of its own. Stateless, so Render is // deterministic. package sparklinedemo import ( "strconv" "strings" "gno.land/p/moul/x/daily/sparkline/v0" ) // series are fixed rather than derived from chain state: a demo whose output // moved with the block height could not be pinned by an example test. var ( traffic = []int{3, 5, 9, 14, 12, 18, 25, 21, 16, 11, 7, 4} flat = []int{1000, 1000, 1000, 1000} swing = []int{-8, -3, 0, 4, 9, 2, -5} pct = []int{-20, 0, 25, 50, 75, 100, 140} ) // Render renders the demo for gnoweb. // // Render("") / Render("/") -> the full demo func Render(path string) string { var b strings.Builder b.WriteString("# Sparklines\n\n") b.WriteString("A numeric series as one line of block characters, demoing the ") b.WriteString("[`p/moul/x/daily/sparkline`](/p/moul/x/daily/sparkline/v0) library.\n\n") b.WriteString("## A series\n\n") lo, hi, _ := sparkline.Bounds(traffic) b.WriteString("`" + sparkline.Ints(traffic) + "`\n\n") b.WriteString(ints(traffic) + "\n\n") b.WriteString("Scaled between its own bounds, `" + strconv.Itoa(lo) + "` and `" + strconv.Itoa(hi) + "`. The shape is the point; the axis is not.\n\n") b.WriteString("## The ramp\n\n") b.WriteString("`" + sparkline.Levels + "` — eight levels, low to high.\n\n") b.WriteString("| value | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |\n") b.WriteString("|---|---|---|---|---|---|---|---|---|\n") b.WriteString("| level |") for v := 0; v <= 7; v++ { b.WriteString(" `" + sparkline.Scaled([]int{v}, 0, 7) + "` |") } b.WriteString("\n\n") b.WriteString("## A flat series\n\n") b.WriteString("`" + sparkline.Ints(flat) + "` — " + ints(flat) + "\n\n") b.WriteString("Mid-ramp, not along the floor. A series that never moves has no ") b.WriteString("shape, but drawing it at the bottom would claim it was *zero*, ") b.WriteString("which is a different thing and a wrong one.\n\n") b.WriteString("## Negatives\n\n") b.WriteString("`" + sparkline.Ints(swing) + "` — " + ints(swing) + "\n\n") b.WriteString("Nothing special: the window is the minimum and maximum, wherever ") b.WriteString("zero happens to fall.\n\n") b.WriteString("## A fixed window clamps\n\n") b.WriteString(ints(pct) + " against `0..100`:\n\n") b.WriteString("`" + sparkline.Scaled(pct, 0, 100) + "`\n\n") b.WriteString("`-20` pins to the floor and `140` to the ceiling. Choosing the ") b.WriteString("window keeps a percentage readable, and one outlier cannot flatten ") b.WriteString("the rest of the picture — or break the realm that drew it.\n\n") b.WriteString("## Rounding is floor\n\n") b.WriteString("| value in `0..100` | level |\n") b.WriteString("|---|---|\n") for _, v := range []int{0, 14, 50, 85, 99, 100} { b.WriteString("| " + strconv.Itoa(v) + " | `" + sparkline.Scaled([]int{v}, 0, 100) + "` |\n") } b.WriteString("\nOnly the maximum itself reaches the top of the ramp, so the peak ") b.WriteString("of a series is always visually distinct.\n\n") b.WriteString("## Why integers\n\n") b.WriteString("Scaling is integer division throughout. Render output has to be ") b.WriteString("byte-identical on every validating node, and floating point is the ") b.WriteString("usual way that quietly stops being true.\n") return b.String() } // ints formats a series as `a, b, c` for the prose around each sparkline. func ints(values []int) string { parts := make([]string, len(values)) for i, v := range values { parts[i] = strconv.Itoa(v) } return "`" + strings.Join(parts, ", ") + "`" }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1streaks
- #2README.md
- #3# Streaks — on-chain habit-streak tracker > ⚠️ **Experimental — generated with no human supervision** by the daily MCP pipeline to exercise gno tooling. Not audited. See [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md). --- A tiny on-chain habit tracker: call CheckIn() and it builds a streak as long as you come back within 100 blocks of your last visit; wait longer and the streak resets to zero on your next check-in. It tracks current streak, longest streak ever, and total check-ins per address, with a sorted leaderboard and a per-address detail view in Render. Neat because it turns block height — the one clock Gno actually has — into a believable stand-in for 'don't break the chain' daily habit tracking, with no wall-clock hacks. Built for sapphire (gno 0.9). Live demo (agent address): https://sapphire.testnets.gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/streaks ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/streaks) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `shortAddr` helper. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/streaks/v1" gno = "0.9" private = true
- #6streaks.gno
- #7// Package streaks is an on-chain habit-streak tracker. Call CheckIn() again // within graceBlocks of your last check-in and the streak keeps growing; let // the gap run past that and it resets to zero on your next check-in. Gno has // no wall clock, so "again soon enough" is measured in blocks rather than // calendar days — graceBlocks is the tunable stand-in for "before tomorrow". package streaks import ( "sort" "strconv" "chain/runtime" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // graceBlocks is the max block gap between two check-ins that still counts // as consecutive. A gap larger than this lapses the streak. const graceBlocks int64 = 100 // record is the persisted streak state for one address. type record struct { owner address current int64 longest int64 total int64 lastHeight int64 } var streaks avl.Tree // owner address string -> *record func get(owner address) (*record, bool) { v, ok := streaks.Get(owner.String()).(*record) return v, ok } // project reports the record's current streak as of height without // mutating it: once the gap since lastHeight exceeds graceBlocks the streak // reads as lapsed, even though the stored value only resets on the next // actual check-in. func project(r *record, height int64) (current int64, alive bool) { if height-r.lastHeight > graceBlocks { return 0, false } return r.current, true } // checkin is the non-crossing core of CheckIn, kept separate so unit tests // can drive it directly by address and height. func checkin(owner address, height int64) string { r, ok := get(owner) if !ok { r = &record{owner: owner} streaks.Set(owner.String(), r) } else { if height <= r.lastHeight { panic("already checked in at this block height") } if height-r.lastHeight > graceBlocks { r.current = 0 } } r.current++ if r.current > r.longest { r.longest = r.current } r.lastHeight = height r.total++ return "checked in — current streak: " + strconv.FormatInt(r.current, 10) + " 🔥" } // CheckIn records a check-in for the caller at the current block height. func CheckIn(cur realm) string { if !cur.IsCurrent() { panic("spoofed realm") } return checkin(cur.Previous().Address(), runtime.ChainHeight()) } // CurrentStreak returns addr's live current streak, 0 if it has lapsed or // addr has never checked in. func CurrentStreak(addr string) int64 { r, ok := get(address(addr)) if !ok { return 0 } current, _ := project(r, runtime.ChainHeight()) return current } // LongestStreak returns addr's best streak ever, 0 if it has never checked in. func LongestStreak(addr string) int64 { r, ok := get(address(addr)) if !ok { return 0 } return r.longest } // byRank orders the leaderboard by longest streak, then current streak, then // owner address — fully deterministic regardless of avl iteration order. type byRank []*record func (b byRank) Len() int { return len(b) } func (b byRank) Swap(i, j int) { b[i], b[j] = b[j], b[i] } func (b byRank) Less(i, j int) bool { if b[i].longest != b[j].longest { return b[i].longest > b[j].longest } if b[i].current != b[j].current { return b[i].current > b[j].current } return b[i].owner.String() < b[j].owner.String() } func renderDetail(r *record, height int64) string { current, alive := project(r, height) status := "🔥 active" if !alive { status = "💤 lapsed" } out := "## " + ui.Addr(r.owner) + "\n\n" out += "- Status: " + status + "\n" out += "- Current streak: " + strconv.FormatInt(current, 10) + "\n" out += "- Longest streak: " + strconv.FormatInt(r.longest, 10) + "\n" out += "- Total check-ins: " + strconv.FormatInt(r.total, 10) + "\n" out += "- Last check-in at block " + strconv.FormatInt(r.lastHeight, 10) + "\n" return out } // Render shows either the full leaderboard (path == "") or a single // address's detail card when path is a bech32 address. func Render(path string) string { height := runtime.ChainHeight() out := "# 🔥 Streaks\n\n" out += "An on-chain habit-streak tracker. Call `CheckIn()` again within " + strconv.FormatInt(graceBlocks, 10) + " blocks of your last check-in to keep it alive; " + "wait longer than that and it resets to zero on your next check-in.\n\n" if path != "" { r, ok := get(address(path)) if !ok { return out + "_No check-ins yet for `" + path + "`._\n" } return out + renderDetail(r, height) } var rows []*record streaks.Iterate("", "", func(_ string, v any) bool { rows = append(rows, v.(*record)) return false }) if len(rows) == 0 { out += "_Nobody has checked in yet. Be the first!_\n" return out } sort.Stable(byRank(rows)) out += "| Rank | Address | Status | Current | Longest | Total |\n" out += "| ---: | :--- | :--- | ---: | ---: | ---: |\n" for i, r := range rows { current, alive := project(r, height) status := "🔥" if !alive { status = "💤" } out += "| " + strconv.Itoa(i+1) + " | " + ui.Addr(r.owner) + " | " + status + " | " + strconv.FormatInt(current, 10) + " | " + strconv.FormatInt(r.longest, 10) + " | " + strconv.FormatInt(r.total, 10) + " |\n" } out += "\n_View a single address at `?<address>`._\n" return out }
- #8streaks_test.gno
- #9package streaks import ( "strings" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) func resetState() { streaks = avl.Tree{} } func TestCheckinFirstTimeStartsStreak(t *testing.T) { resetState() alice := testutils.TestAddress("alice") checkin(alice, 100) r, ok := get(alice) if !ok { t.Fatal("expected a record after first check-in") } if r.current != 1 || r.longest != 1 || r.total != 1 { t.Fatalf("expected current=1 longest=1 total=1, got current=%d longest=%d total=%d", r.current, r.longest, r.total) } } func TestCheckinWithinGraceExtendsStreak(t *testing.T) { resetState() alice := testutils.TestAddress("alice") checkin(alice, 100) checkin(alice, 150) // gap of 50 <= graceBlocks(100) checkin(alice, 200) // gap of 50 again r, _ := get(alice) if r.current != 3 { t.Fatalf("expected streak of 3, got %d", r.current) } if r.longest != 3 { t.Fatalf("expected longest of 3, got %d", r.longest) } } func TestCheckinAfterGraceResetsStreak(t *testing.T) { resetState() alice := testutils.TestAddress("alice") checkin(alice, 100) checkin(alice, 150) checkin(alice, 400) // gap of 250 > graceBlocks(100): lapsed r, _ := get(alice) if r.current != 1 { t.Fatalf("expected streak reset to 1, got %d", r.current) } if r.longest != 2 { t.Fatalf("expected longest to stay at 2, got %d", r.longest) } if r.total != 3 { t.Fatalf("expected total check-ins of 3, got %d", r.total) } } func TestCheckinSameOrEarlierHeightPanics(t *testing.T) { resetState() alice := testutils.TestAddress("alice") checkin(alice, 100) defer func() { if r := recover(); r == nil { t.Fatal("expected panic checking in at a non-later height") } }() checkin(alice, 100) } func TestProjectReportsLapsedWithoutMutating(t *testing.T) { resetState() alice := testutils.TestAddress("alice") checkin(alice, 100) r, _ := get(alice) current, alive := project(r, 100+graceBlocks+1) if alive || current != 0 { t.Fatalf("expected lapsed projection (0, false), got (%d, %v)", current, alive) } // Stored state must be untouched by a read-only projection. if r.current != 1 { t.Fatalf("project must not mutate stored state, got current=%d", r.current) } } func TestCurrentStreakAndLongestStreakQueries(t *testing.T) { resetState() alice := testutils.TestAddress("alice") checkin(alice, 100) checkin(alice, 150) if got := LongestStreak(alice.String()); got != 2 { t.Fatalf("LongestStreak() = %d, want 2", got) } if got := LongestStreak(testutils.TestAddress("bob").String()); got != 0 { t.Fatalf("LongestStreak() for unknown address = %d, want 0", got) } } func TestRenderShowsEmptyThenLeaderboardThenDetail(t *testing.T) { resetState() if !strings.Contains(Render(""), "Nobody has checked in yet") { t.Fatal("expected empty-state placeholder") } alice := testutils.TestAddress("alice") checkin(alice, 100) out := Render("") if !strings.Contains(out, "Rank") { t.Fatal("expected leaderboard table in root render") } detail := Render(alice.String()) if !strings.Contains(detail, "Current streak") { t.Fatal("expected detail card to show current streak") } if !strings.Contains(Render(testutils.TestAddress("bob").String()), "No check-ins yet") { t.Fatal("expected placeholder for an address with no check-ins") } } // TestCheckInViaCrossingCall smoke-tests the exported, realm-crossing entry // point end to end (as an on-chain caller would invoke it). func TestCheckInViaCrossingCall(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) msg := CheckIn(cross(cur)) if !strings.Contains(msg, "checked in") { t.Fatalf("expected check-in confirmation, got %q", msg) } if _, ok := get(alice); !ok { t.Fatal("expected a record to be persisted for caller") } }
- Attached funds
- 7000000ugnot
Arguments · 9
- #1tamagotchi
- #2README.md
- #3# Tamagotchi > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- A tiny on-chain virtual pet. Hatch one, then keep it alive by feeding, playing, and letting it sleep — hunger, happiness, and energy all decay with block height, so neglect (or just forgetting to check in) eventually kills it. Hatching a new pet after a death keeps your lifetime feed/play/death counts, so the leaderboard rewards long-term care rather than a single lucky run. Realm: `gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/tamagotchi` ## Example calls ``` maketx call ... -func Hatch -args "Fluffy" # start a pet maketx call ... -func Feed # -30 hunger, +5 energy maketx call ... -func Play # +20 happiness, -10 energy, +5 hunger maketx call ... -func Sleep # +40 energy, +5 hunger ``` `Render("")` lists every pet ever hatched with alive/dead status and lifetime stats. `Render("<owner-address>")` shows one pet's detail card. ## Toolchain status `gno test .` passes (10/10 tests) against `~/p/gh/gnolang/gno` as `GNOROOT`. Uses only `chain/runtime`, `sort`, `strconv`, and the kept `gno.land/p/nt/avl/v0` / `gno.land/p/nt/testutils/v0` packages per the sapphire stdlib reference. Panic paths are tested through non-crossing helper functions (`hatch`, `feed`, `play`, `sleep`) rather than through the exported `cur realm` entry points, since panics raised inside a realm-crossing call aren't `recover()`-able from the caller on this toolchain version. ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/tamagotchi) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `shortAddr` helper. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/tamagotchi/v1" gno = "0.9" private = true
- #6tamagotchi.gno
- #7// Package tamagotchi is an on-chain virtual pet. Hatch one, then keep it // alive by Feeding, Playing and letting it Sleep — hunger, happiness and // energy all decay with block height, and neglect too long kills the pet. // Hatching again after a death starts a fresh pet but keeps your lifetime // stats, so the leaderboard rewards long-term care, not just luck. package tamagotchi import ( "sort" "strconv" "chain/runtime" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // pet is the persisted record for one owner's tamagotchi. type pet struct { owner address name string born int64 lastUpdate int64 hunger int // 0 (full) .. 100 (starving) happiness int // 0 (miserable) .. 100 (joyful) energy int // 0 (exhausted) .. 100 (energetic) alive bool deaths int feeds int plays int } var pets avl.Tree // owner address string -> *pet func clamp(v, lo, hi int) int { if v < lo { return lo } if v > hi { return hi } return v } func get(owner address) (*pet, bool) { v := pets.Get(owner.String()) if v == nil { return nil, false } return v.(*pet), true } // tick applies stat decay for blocks elapsed since the pet's last update and // kills it if any stat has bottomed/topped out. Mutates and persists. func (p *pet) tick(height int64) { if !p.alive { return } elapsed := int(height - p.lastUpdate) if elapsed <= 0 { return } p.hunger = clamp(p.hunger+elapsed, 0, 100) p.happiness = clamp(p.happiness-elapsed/2, 0, 100) p.energy = clamp(p.energy-elapsed/3, 0, 100) p.lastUpdate = height if p.hunger >= 100 || p.happiness <= 0 || p.energy <= 0 { p.alive = false p.deaths++ } } // project computes display stats as of height without mutating the pet, so // Render can show a live-looking view without writing state on a query. func project(p *pet, height int64) (hunger, happiness, energy int, alive bool) { if !p.alive { return p.hunger, p.happiness, p.energy, false } elapsed := int(height - p.lastUpdate) if elapsed < 0 { elapsed = 0 } hunger = clamp(p.hunger+elapsed, 0, 100) happiness = clamp(p.happiness-elapsed/2, 0, 100) energy = clamp(p.energy-elapsed/3, 0, 100) alive = hunger < 100 && happiness > 0 && energy > 0 return } func ageStageName(born, height int64) string { switch age := height - born; { case age < 10: return "🥚 Egg" case age < 50: return "🐣 Baby" case age < 200: return "🐤 Teen" default: return "🐓 Adult" } } // requireLivingPet ticks and fetches the caller's pet, panicking with a // friendly message if there is none or it has died. func requireLivingPet(owner address, height int64) *pet { p, ok := get(owner) if !ok { panic("you don't have a pet yet — call Hatch(name) first") } p.tick(height) if !p.alive { panic(p.name + " has died of neglect. Call Hatch(name) to start over.") } return p } // hatch is the non-crossing core of Hatch, kept separate so unit tests can // exercise its panics directly without going through a realm-crossing call. func hatch(owner address, name string, height int64) string { if name == "" { panic("name cannot be empty") } deaths, feeds, plays := 0, 0, 0 if existing, ok := get(owner); ok { if existing.alive { existing.tick(height) } if existing.alive { panic("you already have a living pet: " + existing.name) } deaths, feeds, plays = existing.deaths, existing.feeds, existing.plays } pets.Set(owner.String(), &pet{ owner: owner, name: name, born: height, lastUpdate: height, hunger: 20, happiness: 80, energy: 80, alive: true, deaths: deaths, feeds: feeds, plays: plays, }) return "🥚 " + name + " has hatched!" } // Hatch creates a new pet for the caller. Lifetime feeds/plays/deaths carry // over from any previous pet so the leaderboard tracks long-term care. func Hatch(cur realm, name string) string { if !cur.IsCurrent() { panic("spoofed realm") } return hatch(cur.Previous().Address(), name, runtime.ChainHeight()) } // feed is the non-crossing core of Feed. func feed(owner address, height int64) string { p := requireLivingPet(owner, height) p.hunger = clamp(p.hunger-30, 0, 100) p.energy = clamp(p.energy+5, 0, 100) p.feeds++ return p.name + " munches happily. Hunger: " + strconv.Itoa(p.hunger) + "/100" } // Feed reduces hunger and gives a small energy boost. func Feed(cur realm) string { if !cur.IsCurrent() { panic("spoofed realm") } return feed(cur.Previous().Address(), runtime.ChainHeight()) } // play is the non-crossing core of Play. func play(owner address, height int64) string { p := requireLivingPet(owner, height) if p.energy < 10 { return p.name + " is too tired to play — try Sleep() first." } p.happiness = clamp(p.happiness+20, 0, 100) p.energy = clamp(p.energy-10, 0, 100) p.hunger = clamp(p.hunger+5, 0, 100) p.plays++ return p.name + " had a blast! Happiness: " + strconv.Itoa(p.happiness) + "/100" } // Play boosts happiness at the cost of energy and a bit of hunger. Refuses // to run a tired pet into the ground — rest first. func Play(cur realm) string { if !cur.IsCurrent() { panic("spoofed realm") } return play(cur.Previous().Address(), runtime.ChainHeight()) } // sleep is the non-crossing core of Sleep. func sleep(owner address, height int64) string { p := requireLivingPet(owner, height) p.energy = clamp(p.energy+40, 0, 100) p.hunger = clamp(p.hunger+5, 0, 100) return p.name + " takes a nap. Energy: " + strconv.Itoa(p.energy) + "/100" } // Sleep restores energy at the cost of a little hunger. func Sleep(cur realm) string { if !cur.IsCurrent() { panic("spoofed realm") } return sleep(cur.Previous().Address(), runtime.ChainHeight()) } // byAliveThenOwner ranks living pets before dead ones, then orders each // group by owner address so the leaderboard is fully deterministic. type byAliveThenOwner []*pet func (r byAliveThenOwner) Len() int { return len(r) } func (r byAliveThenOwner) Swap(i, j int) { r[i], r[j] = r[j], r[i] } func (r byAliveThenOwner) Less(i, j int) bool { if r[i].alive != r[j].alive { return r[i].alive } return r[i].owner.String() < r[j].owner.String() } func bar(v int) string { filled := v / 10 out := "" for i := 0; i < 10; i++ { if i < filled { out += "█" } else { out += "░" } } return out } func renderCard(p *pet, height int64) string { hunger, happiness, energy, alive := project(p, height) out := "## " + p.name + " — owned by " + ui.Addr(p.owner) + "\n\n" if !alive { out += "💀 **Deceased.** Owner can `Hatch(name)` a new pet.\n\n" return out } out += ageStageName(p.born, height) + " · age **" + strconv.Itoa(int(height-p.born)) + "** blocks\n\n" out += "- Hunger: " + bar(100-hunger) + " (" + strconv.Itoa(hunger) + "/100 — lower is better)\n" out += "- Happiness: " + bar(happiness) + " (" + strconv.Itoa(happiness) + "/100)\n" out += "- Energy: " + bar(energy) + " (" + strconv.Itoa(energy) + "/100)\n\n" return out } // Render shows either every pet ever hatched (path == "") or a single pet's // detail card when path is a bech32 owner address. func Render(path string) string { height := runtime.ChainHeight() out := "# 🐣 Tamagotchi\n\n" out += "A tiny on-chain pet. `Hatch(\"name\")` to start, then keep it alive with " + "`Feed()`, `Play()` and `Sleep()` — stats decay every block, so neglect kills it.\n\n" if path != "" { owner := address(path) p, ok := get(owner) if !ok { return out + "_No pet found for `" + path + "`._\n" } return out + renderCard(p, height) } var rows []*pet pets.Iterate("", "", func(_ string, v any) bool { rows = append(rows, v.(*pet)) return false }) if len(rows) == 0 { out += "_No pets hatched yet. Be the first!_\n" return out } sort.Stable(byAliveThenOwner(rows)) out += "| Pet | Owner | Status | Feeds | Plays | Deaths |\n" out += "| :--- | :--- | :--- | ---: | ---: | ---: |\n" for _, p := range rows { _, _, _, alive := project(p, height) status := "🐤 alive" if !alive { status = "💀 dead" } out += "| " + p.name + " | " + ui.Addr(p.owner) + " | " + status + " | " + strconv.Itoa(p.feeds) + " | " + strconv.Itoa(p.plays) + " | " + strconv.Itoa(p.deaths) + " |\n" } out += "\n_View a single pet at `?<owner-address>`._\n" return out }
- #8tamagotchi_test.gno
- #9package tamagotchi import ( "strings" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) func resetState() { pets = avl.Tree{} } func TestClamp(t *testing.T) { if clamp(150, 0, 100) != 100 { t.Fatal("clamp should cap at hi") } if clamp(-5, 0, 100) != 0 { t.Fatal("clamp should floor at lo") } if clamp(50, 0, 100) != 50 { t.Fatal("clamp should pass through in-range values") } } func TestHatchCreatesLivingPet(t *testing.T) { resetState() alice := testutils.TestAddress("alice") hatch(alice, "Fluffy", 100) p, ok := get(alice) if !ok || !p.alive { t.Fatal("expected a living pet after hatch") } if p.name != "Fluffy" { t.Fatalf("expected name Fluffy, got %s", p.name) } } func TestHatchTwiceWhileAlivePanics(t *testing.T) { resetState() alice := testutils.TestAddress("alice") hatch(alice, "Fluffy", 100) defer func() { if r := recover(); r == nil { t.Fatal("expected panic hatching a second living pet") } }() hatch(alice, "Again", 101) } func TestHatchAfterDeathKeepsLifetimeStats(t *testing.T) { resetState() alice := testutils.TestAddress("alice") hatch(alice, "Fluffy", 0) feed(alice, 0) p, _ := get(alice) p.alive = false p.deaths = 1 hatch(alice, "Fluffy2", 10) p, _ = get(alice) if p.feeds != 1 || p.deaths != 1 { t.Fatalf("expected lifetime stats to carry over, got feeds=%d deaths=%d", p.feeds, p.deaths) } if !p.alive { t.Fatal("expected the new pet to be alive") } } func TestFeedReducesHunger(t *testing.T) { resetState() alice := testutils.TestAddress("alice") hatch(alice, "Fluffy", 100) p, _ := get(alice) p.hunger = 60 feed(alice, 100) if p.hunger != 30 { t.Fatalf("expected hunger 30 after feed, got %d", p.hunger) } if p.feeds != 1 { t.Fatalf("expected feeds counter to increment, got %d", p.feeds) } } func TestPlayRefusesWhenTooTired(t *testing.T) { resetState() alice := testutils.TestAddress("alice") hatch(alice, "Fluffy", 100) p, _ := get(alice) p.energy = 5 out := play(alice, 100) if !strings.Contains(out, "too tired") { t.Fatalf("expected too-tired message, got %q", out) } if p.plays != 0 { t.Fatal("play should not have counted while too tired") } } func TestNeglectKillsPet(t *testing.T) { resetState() alice := testutils.TestAddress("alice") hatch(alice, "Fluffy", 0) defer func() { if r := recover(); r == nil { t.Fatal("expected panic acting on a neglected/dead pet") } }() // Feed re-ticks against a much later height, starving the pet to death. feed(alice, 1000) } func TestFeedWithoutPetPanics(t *testing.T) { resetState() bob := testutils.TestAddress("bob") defer func() { if r := recover(); r == nil { t.Fatal("expected panic feeding without a pet") } }() feed(bob, 100) } func TestRenderShowsEmptyThenPet(t *testing.T) { resetState() if !strings.Contains(Render(""), "No pets hatched yet") { t.Fatal("expected empty-state placeholder") } alice := testutils.TestAddress("alice") hatch(alice, "Fluffy", 100) out := Render("") if !strings.Contains(out, "Fluffy") { t.Fatal("expected pet name in leaderboard render") } detail := Render(alice.String()) if !strings.Contains(detail, "Hunger") { t.Fatal("expected detail card to show hunger stat") } } // TestHatchViaCrossingCall smoke-tests the exported, realm-crossing entry // point end to end (as an on-chain caller would invoke it). func TestHatchViaCrossingCall(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) msg := Hatch(cross(cur), "Fluffy") if !strings.Contains(msg, "hatched") { t.Fatalf("expected hatch confirmation, got %q", msg) } if _, ok := get(alice); !ok { t.Fatal("expected pet to be persisted for caller") } }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1tipjar
- #2README.md
- #3# Tip Jar > ⚠️ **Experimental — generated with no human supervision** by the daily MCP pipeline to exercise gno tooling. Not audited. See [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md). --- A public tip jar realm: anyone can send real ugnot with an optional message via Tip, and the realm tracks a leaderboard of the most generous tippers plus a feed of recent tips, both rendered live in Markdown. Only the deployer (captured as owner at deploy time) can withdraw the accumulated balance. It's a small, complete example of the payment-guard pattern (OriginSend + IsUserCall) paired with a sorted avl.Tree leaderboard. Built for gno 0.9. ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/tipjar) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `medal` helper. **One podium.** `ui.Podium` replaces the medal switch three realms each had a copy of. Same output. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/tipjar/v1" gno = "0.9" private = true
- #6tipjar.gno
- #7// Package tipjar is a public tip jar. Anyone can send real ugnot with an // optional message via Tip; the realm keeps a running leaderboard of the // most generous tippers and a feed of the most recent tips, both shown in // Render. Only the deployer (captured as owner at init time) can Withdraw // the accumulated balance. State-mutating functions are crossing functions // (`cur realm`) per the gno 0.9 interrealm convention. package tipjar import ( "sort" "strconv" "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) const ( denom = "ugnot" maxRecent = 10 maxMessageLen = 140 ) // tipperStats is the persisted, accumulated record for one tipper. type tipperStats struct { addr address total int64 count int lastMessage string lastHeight int64 } // tipEntry is one line in the recent-tips feed. type tipEntry struct { from address amount int64 message string height int64 } var ( owner address // captured deployer, set once in init tippers avl.Tree // address string -> *tipperStats recent []tipEntry totalReceived int64 withdrawn int64 tipCount int ) func init() { owner = unsafe.OriginCaller() } // get returns the stored *tipperStats for addr, creating one if absent. func get(addr address) *tipperStats { key := addr.String() if v := tippers.Get(key); v != nil { return v.(*tipperStats) } ts := &tipperStats{addr: addr} tippers.Set(key, ts) return ts } // Tip credits the caller's OriginSend ugnot to the jar and records it // against their running total. Only an EOA calling directly via MsgCall may // tip — see the payment-guard note on IsUserCall vs IsUser. func Tip(cur realm, message string) string { if !cur.IsCurrent() { panic("spoofed realm") } prev := cur.Previous() if !prev.IsUserCall() { panic("only an EOA via MsgCall can tip") } if len(message) > maxMessageLen { panic("message too long (max " + strconv.Itoa(maxMessageLen) + " chars)") } amount := unsafe.OriginSend().AmountOf(denom) if amount <= 0 { panic("send some ugnot to tip") } from := prev.Address() height := runtime.ChainHeight() ts := get(from) ts.total += amount ts.count++ ts.lastMessage = message ts.lastHeight = height totalReceived += amount tipCount++ recent = append(recent, tipEntry{from: from, amount: amount, message: message, height: height}) if len(recent) > maxRecent { recent = recent[len(recent)-maxRecent:] } chain.Emit("Tip", "from", from.String(), "amount", strconv.FormatInt(amount, 10), "message", message) return "thanks for the " + strconv.FormatInt(amount, 10) + "ugnot tip!" } // Balance reports the ugnot still held by the jar (received minus withdrawn). func Balance() int64 { return totalReceived - withdrawn } // Withdraw sends amount ugnot from the jar to the owner. Owner-only. func Withdraw(cur realm, amount int64) { if !cur.IsCurrent() { panic("spoofed realm") } if cur.Previous().Address() != owner { panic("only the owner can withdraw") } if amount <= 0 { panic("amount must be positive") } if amount > Balance() { panic("amount exceeds available balance") } bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins(cur.Address(), owner, chain.NewCoins(chain.NewCoin(denom, amount))) withdrawn += amount chain.Emit("Withdraw", "amount", strconv.FormatInt(amount, 10)) } // byTotal implements sort.Interface, ranking tippers by total tipped // descending, ties broken by tip count then address for a fully // deterministic order. type byTotal struct { stats []*tipperStats } func (b byTotal) Len() int { return len(b.stats) } func (b byTotal) Swap(i, j int) { b.stats[i], b.stats[j] = b.stats[j], b.stats[i] } func (b byTotal) Less(i, j int) bool { if b.stats[i].total != b.stats[j].total { return b.stats[i].total > b.stats[j].total } if b.stats[i].count != b.stats[j].count { return b.stats[i].count > b.stats[j].count } return b.stats[i].addr.String() < b.stats[j].addr.String() } // leaderboard collects all tippers ranked by total tipped descending. func leaderboard() []*tipperStats { stats := make([]*tipperStats, 0, tippers.Size()) tippers.Iterate("", "", func(_ string, v any) bool { stats = append(stats, v.(*tipperStats)) return false }) sort.Stable(byTotal{stats: stats}) return stats } // display returns a shortened address for table display. func display(addr address) string { s := addr.String() if len(s) > 12 { return s[:8] + "…" + s[len(s)-4:] } return s } // Render shows the jar's balance, the tipper leaderboard, and a feed of the // most recent tips. func Render(path string) string { out := "# 🫙 Tip Jar\n\n" out += "Send ugnot with `Tip(message)` to leave a tip and a note. " + "The owner can withdraw the accumulated balance with `Withdraw(amount)`.\n\n" out += "**Balance:** " + strconv.FormatInt(Balance(), 10) + "ugnot" + " · **Total tips:** " + strconv.Itoa(tipCount) + " · **All-time received:** " + strconv.FormatInt(totalReceived, 10) + "ugnot\n\n" stats := leaderboard() out += "## Leaderboard\n\n" if len(stats) == 0 { out += "_No tips yet. Be the first!_\n\n" } else { out += "| Rank | Tipper | Total tipped | Tips |\n" out += "| ---: | :--- | ---: | ---: |\n" for i, ts := range stats { rankCell := ui.Podium(i) if rankCell == "" { rankCell = strconv.Itoa(i + 1) } out += "| " + rankCell + " | " + display(ts.addr) + " | " + strconv.FormatInt(ts.total, 10) + "ugnot" + " | " + strconv.Itoa(ts.count) + " |\n" } out += "\n" } out += "## Recent tips\n\n" if len(recent) == 0 { out += "_Nothing yet._\n" return out } for i := len(recent) - 1; i >= 0; i-- { e := recent[i] out += "- **" + display(e.from) + "** tipped " + strconv.FormatInt(e.amount, 10) + "ugnot at block " + strconv.FormatInt(e.height, 10) if e.message != "" { out += ": _" + e.message + "_" } out += "\n" } return out }
- #8tipjar_test.gno
- #9package tipjar import ( "strings" "testing" "chain" "chain/banker" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) var realmAddr = chain.PackageAddress("gno.land/r/moul/x/daily/tipjar/v1") // reset clears package globals between tests, pinning owner to a known test // address rather than whatever init() captured from the test harness. func reset() { owner = testutils.TestAddress("owner") tippers = avl.Tree{} recent = nil totalReceived = 0 withdrawn = 0 tipCount = 0 } // fund simulates a tipper sending `amount` ugnot alongside the tx: the // runtime deposits it at the realm's address first, then unsafe.OriginSend // reports it to the crossing function. SetRealm is call-frame scoped (see // build.md), so the caller must still SetRealm directly before crossing. func fund(amount int64) { testing.IssueCoins(realmAddr, chain.Coins{{denom, amount}}) testing.SetOriginSend(chain.Coins{{denom, amount}}) } func TestTipCreditsAndTracks(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("alice") fund(500) testing.SetRealm(testing.NewUserRealm(alice)) Tip(cross(cur), "gm") if got := Balance(); got != 500 { t.Fatalf("Balance() = %d, want 500", got) } if tipCount != 1 { t.Fatalf("tipCount = %d, want 1", tipCount) } ts := get(alice) if ts.total != 500 || ts.count != 1 || ts.lastMessage != "gm" { t.Fatalf("unexpected tipper stats: %+v", ts) } fund(250) testing.SetRealm(testing.NewUserRealm(alice)) Tip(cross(cur), "again") if got := get(alice).total; got != 750 { t.Fatalf("cumulative total = %d, want 750", got) } if got := Balance(); got != 750 { t.Fatalf("Balance() after second tip = %d, want 750", got) } } func TestTipRejectsZeroAmount(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) rec := revive(func() { Tip(cross(cur), "freeloading") }) if rec == nil { t.Fatal("expected panic when tipping with no ugnot sent") } } func TestTipRejectsNonUserCall(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewCodeRealm("gno.land/r/g1attacker/relay")) rec := revive(func() { Tip(cross(cur), "sneaky") }) if rec == nil { t.Fatal("expected panic when the caller isn't an EOA via MsgCall") } } func TestWithdrawOwnerOnly(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("alice") mallory := testutils.TestAddress("mallory") fund(1000) testing.SetRealm(testing.NewUserRealm(alice)) Tip(cross(cur), "for the jar") testing.SetRealm(testing.NewUserRealm(mallory)) rec := revive(func() { Withdraw(cross(cur), 100) }) if rec == nil { t.Fatal("expected panic when a non-owner withdraws") } testing.SetRealm(testing.NewUserRealm(owner)) Withdraw(cross(cur), 400) if got := Balance(); got != 600 { t.Fatalf("Balance() after withdraw = %d, want 600", got) } ro := banker.NewReadonlyBanker() if got := ro.GetCoins(owner).AmountOf(denom); got != 400 { t.Fatalf("owner received = %d, want 400", got) } rec = revive(func() { Withdraw(cross(cur), 10000) }) if rec == nil { t.Fatal("expected panic when withdrawing more than the balance") } } func TestRenderShowsLeaderboardAndFeed(cur realm, t *testing.T) { reset() if !strings.Contains(Render(""), "No tips yet") { t.Fatal("expected empty-jar placeholder") } alice := testutils.TestAddress("alice") fund(300) testing.SetRealm(testing.NewUserRealm(alice)) Tip(cross(cur), "nice work") out := Render("") if !strings.Contains(out, "Leaderboard") { t.Fatal("expected leaderboard section") } if !strings.Contains(out, "nice work") { t.Fatal("expected the tip message in the recent-tips feed") } }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1urlshort
- #2README.md
- #3# URL Shortener > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- An on-chain alias registry for the gno.land sapphire testnet. Each caller can register `alias -> url` mappings they own: an alias is claimed on a first-come basis, only its owner may update or remove it, and every resolution through `Render("/<alias>")` bumps a per-alias click counter. Aliases must be non-empty and alphanumeric. **Realm path:** `gno.land/r/REPLACE_ADDR/urlshort` ## Example calls ``` # Register (or update your own) alias -> url Shorten("gno", "https://gno.land", "the chain") # Update the target (must be the same owner) Shorten("gno", "https://docs.gno.land", "now the docs") # Remove an alias you own Remove("gno") ``` ## Render paths - `/` — lists every alias with owner and click count. - `/<alias>` — shows the target url, owner, note, and click count (and counts the click). ## What changed in v1 Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/urlshort) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `short` helper. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. The rendering change means the output differs from v0, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. `v0` stays live and untouched. <!-- 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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/x/daily/urlshort/v1" gno = "0.9" private = true
- #6urlshort.gno
- #7// Package urlshort is a simple on-chain URL alias registry. // // Callers register an `alias -> url` mapping they own. An alias can only be // claimed once; the owner may later update the target URL, but nobody else can // overwrite an alias they do not own. A per-alias click counter is bumped every // time the alias is resolved through Render("/<alias>"). package urlshort import ( "strings" "chain" "chain/runtime/unsafe" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // entry is the persisted record for a single alias. type entry struct { url string owner address note string clicks int } // aliases maps alias (string) -> *entry, kept in an avl.Tree for deterministic // iteration order in Render. var aliases avl.Tree // isAlphanumeric reports whether s is non-empty and made only of [0-9A-Za-z]. func isAlphanumeric(s string) bool { if s == "" { return false } for _, c := range s { switch { case c >= '0' && c <= '9': case c >= 'a' && c <= 'z': case c >= 'A' && c <= 'Z': default: return false } } return true } // Shorten registers `alias -> url` owned by the caller. // // Rules: // - alias must be non-empty and alphanumeric; // - url must be non-empty; // - if the alias is unclaimed, it is created owned by the caller; // - if it is already claimed by the caller, the url/note are updated; // - if it is claimed by someone else, the call aborts. func Shorten(cur realm, alias string, url string, note string) { if !isAlphanumeric(alias) { panic("alias must be non-empty and alphanumeric") } if url == "" { panic("url must be non-empty") } caller := unsafe.PreviousRealm().Address() if v := aliases.Get(alias); v != nil { e := v.(*entry) if e.owner != caller { panic("alias already taken by another owner") } e.url = url e.note = note chain.Emit("AliasUpdated", "alias", alias, "owner", caller.String()) return } e := &entry{url: url, owner: caller, note: note, clicks: 0} aliases.Set(alias, e) chain.Emit("AliasCreated", "alias", alias, "owner", caller.String()) } // Remove deletes an alias owned by the caller. Aborts if the alias does not // exist or the caller is not the owner. func Remove(cur realm, alias string) { v := aliases.Get(alias) if v == nil { panic("alias not found") } e := v.(*entry) caller := unsafe.PreviousRealm().Address() if e.owner != caller { panic("only the owner can remove an alias") } aliases.Remove(alias) chain.Emit("AliasRemoved", "alias", alias, "owner", caller.String()) } // Lookup returns the target url for an alias and whether it exists. It is a // read-only helper and does NOT increment the click counter. func Lookup(alias string) (string, bool) { v := aliases.Get(alias) if v == nil { return "", false } return v.(*entry).url, true } // Render renders Markdown. The root path lists every alias; "/<alias>" shows a // single alias detail and bumps its click counter. func Render(path string) string { if path == "" || path == "/" { return renderIndex() } alias := strings.TrimPrefix(path, "/") v := aliases.Get(alias) if v == nil { return "# URL Shortener\n\nNo alias named `" + alias + "`.\n\n[← all aliases](/)\n" } e := v.(*entry) e.clicks++ var b strings.Builder b.WriteString("# 🔗 " + alias + "\n\n") b.WriteString("**Target:** <" + e.url + ">\n\n") b.WriteString("**Owner:** " + e.owner.String() + "\n\n") if e.note != "" { b.WriteString("**Note:** " + e.note + "\n\n") } b.WriteString("**Clicks:** " + itoa(e.clicks) + "\n\n") b.WriteString("[← all aliases](/)\n") return b.String() } func renderIndex() string { var b strings.Builder b.WriteString("# 🔗 URL Shortener\n\n") if aliases.Size() == 0 { b.WriteString("_No aliases registered yet._\n\n") b.WriteString("Call `Shorten(alias, url, note)` to register one.\n") return b.String() } b.WriteString("| Alias | Target | Owner | Clicks |\n") b.WriteString("|-------|--------|-------|--------|\n") aliases.Iterate("", "", func(key string, value interface{}) bool { e := value.(*entry) b.WriteString("| [" + key + "](/" + key + ") | <" + e.url + "> | " + ui.Addr(e.owner) + " | " + itoa(e.clicks) + " |\n") return false }) b.WriteString("\nTotal aliases: " + itoa(aliases.Size()) + "\n") return b.String() } // itoa converts a non-negative int to its decimal string without importing // strconv (kept minimal & deterministic). func itoa(n int) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } var buf [20]byte i := len(buf) for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } if neg { i-- buf[i] = '-' } return string(buf[i:]) }
- #8urlshort_test.gno
- #9package urlshort import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) func TestIsAlphanumeric(t *testing.T) { cases := []struct { in string want bool }{ {"", false}, {"abc", true}, {"ABC123", true}, {"a b", false}, {"a-b", false}, {"under_score", false}, {"9", true}, } for _, c := range cases { if got := isAlphanumeric(c.in); got != c.want { t.Errorf("isAlphanumeric(%q) = %v, want %v", c.in, got, c.want) } } } func TestItoa(t *testing.T) { cases := []struct { in int want string }{ {0, "0"}, {7, "7"}, {42, "42"}, {1000, "1000"}, } for _, c := range cases { if got := itoa(c.in); got != c.want { t.Errorf("itoa(%d) = %q, want %q", c.in, got, c.want) } } } func TestShortenAndRender(cur realm, t *testing.T) { alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) Shorten(cross(cur), "gno", "https://gno.land", "the chain") url, ok := Lookup("gno") uassert.True(t, ok, "alias should exist") uassert.Equal(t, "https://gno.land", url) // Detail render bumps the click counter. out := Render("/gno") uassert.True(t, strings.Contains(out, "https://gno.land"), "detail shows url") uassert.True(t, strings.Contains(out, "Clicks:** 1"), "click counted") // Index lists the alias. idx := Render("/") uassert.True(t, strings.Contains(idx, "[gno](/gno)"), "index lists alias") } func TestShortenOwnershipRules(cur realm, t *testing.T) { alice := testutils.TestAddress("alice2") bob := testutils.TestAddress("bob2") testing.SetRealm(testing.NewUserRealm(alice)) Shorten(cross(cur), "mine", "https://a.example", "") // Bob cannot overwrite alice's alias. testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsWithMessage(t, cur, "alias already taken by another owner", func() { Shorten(cross(cur), "mine", "https://b.example", "") }) // Alice can update her own. testing.SetRealm(testing.NewUserRealm(alice)) Shorten(cross(cur), "mine", "https://a2.example", "updated") url, _ := Lookup("mine") uassert.Equal(t, "https://a2.example", url) // Invalid alias aborts. uassert.AbortsWithMessage(t, cur, "alias must be non-empty and alphanumeric", func() { Shorten(cross(cur), "bad alias", "https://x.example", "") }) // Empty url aborts. uassert.AbortsWithMessage(t, cur, "url must be non-empty", func() { Shorten(cross(cur), "okalias", "", "") }) } func TestRemoveRules(cur realm, t *testing.T) { alice := testutils.TestAddress("alice3") bob := testutils.TestAddress("bob3") testing.SetRealm(testing.NewUserRealm(alice)) Shorten(cross(cur), "rm", "https://rm.example", "") // Bob cannot remove alice's alias. testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsWithMessage(t, cur, "only the owner can remove an alias", func() { Remove(cross(cur), "rm") }) // Alice removes her own. testing.SetRealm(testing.NewUserRealm(alice)) Remove(cross(cur), "rm") _, ok := Lookup("rm") uassert.False(t, ok, "alias should be gone") }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1grc20faucet
- #2README.md
- #3# `gno.land/r/moul/x/grc20faucet/v0` **Two free GRC20 tokens, RED and BLUE, so experiments have something to run on.** Worthless by construction, unlimited, and shared. ``` maketx call -pkgpath gno.land/r/moul/x/grc20faucet/v0 -func Claim ``` 100.0000 of each per claim (4 decimals), once every 100 blocks per account. | token | symbol | registry key | |---|---|---| | Red Token | `RED` | `gno.land/r/moul/x/grc20faucet/v0.RED` | | Blue Token | `BLUE` | `gno.land/r/moul/x/grc20faucet/v0.BLUE` | Both are registered in [`r/nt/grc20reg`](https://gno.land/r/nt/grc20reg/v0), which is the point: another realm can find and move them by key without importing this one. Everything else here is the ordinary GRC20 surface, with the symbol as the first argument: ``` -func Transfer -args RED -args <to> -args 250000 -func Approve -args BLUE -args <spender> -args 250000 -func TransferFrom -args RED -args <from> -args <to> -args 250000 ``` `Approve` is the interesting one. It is what lets a realm such as [`r/moul/x/grc20wrapdemo`](https://gno.land/r/moul/x/grc20wrapdemo/v0) pull your tokens into escrow, and until you call it that realm has no authority over your balance at all. Two tokens rather than one because the interesting patterns start at two: a meta-token over a pair needs a pair. <!-- 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 -->
- #4faucet.gno
- #5// Package grc20faucet issues two free GRC20 tokens, RED and BLUE, and gives // them away on request. // // It exists so that anything needing a token to experiment on has one that is // worthless, unlimited and shared. Both tokens are registered in // gno.land/r/nt/grc20reg/v0, which is what lets another realm find and move // them by key without importing this package: that indirection is the whole // point, and it is what gno.land/r/moul/x/grc20wrapdemo/v0 wraps. // // Two tokens rather than one, because the interesting patterns start at two: a // meta-token over a pair needs a pair. package grc20faucet import ( "chain/runtime" "gno.land/p/nt/avl/v0" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/ufmt/v0" "gno.land/r/nt/grc20reg/v0" ) // Tokens are exported so an importing realm can read them directly; the // ledgers stay private, since they carry unrestricted mint and burn. var ( Red *grc20.Token Blue *grc20.Token redLedger *grc20.PrivateLedger blueLedger *grc20.PrivateLedger // RedKey and BlueKey are the grc20reg lookup keys, the way a realm that // does NOT import this package refers to these tokens. RedKey string BlueKey string lastClaim = avl.NewTree() // address -> block height of its last claim ) const ( decimals = 4 // ClaimAmount is minted of EACH token per claim: 100.0000 units. ClaimAmount = int64(1_000_000) // ClaimEvery is the cooldown, in blocks, between two claims by the same // account. Free money with no cooldown is a spam vector, not a faucet. ClaimEvery = int64(100) ) func init(cur realm) { Red, redLedger = grc20.NewToken("Red Token", "RED", decimals, 0, cur) Blue, blueLedger = grc20.NewToken("Blue Token", "BLUE", decimals, 1, cur) RedKey = grc20reg.Register(cross(cur), Red, "red") BlueKey = grc20reg.Register(cross(cur), Blue, "blue") } // Claim mints ClaimAmount of both RED and BLUE to the caller. func Claim(cur realm) { who := caller(cur) now := runtime.ChainHeight() if next, ok := NextClaim(who); ok && now < next { panic(ufmt.Sprintf("too soon: next claim at block %d, current %d", next, now)) } lastClaim.Set(who.String(), now) checkErr(redLedger.Mint(who, ClaimAmount)) checkErr(blueLedger.Mint(who, ClaimAmount)) } // NextClaim returns the block height at which `who` may claim again, and // whether they have ever claimed at all. func NextClaim(who address) (int64, bool) { v := lastClaim.Get(who.String()) if v == nil { return 0, false } return v.(int64) + ClaimEvery, true } // Transfer moves the caller's own units of `symbol`. func Transfer(cur realm, symbol string, to address, amount int64) { checkErr(ledgerOf(symbol).CallerTeller().Transfer(0, cur, to, amount)) } // Approve lets `spender` draw `amount` of `symbol` from the caller's balance. // // This is the call that makes every wrapper in grc20wrapdemo work: the wrapper // realm has no authority over anybody's tokens until its address is named here. func Approve(cur realm, symbol string, spender address, amount int64) { checkErr(ledgerOf(symbol).CallerTeller().Approve(0, cur, spender, amount)) } // TransferFrom spends an allowance the caller was granted. func TransferFrom(cur realm, symbol string, from, to address, amount int64) { checkErr(ledgerOf(symbol).CallerTeller().TransferFrom(0, cur, from, to, amount)) } // BalanceOf returns `owner`'s balance of `symbol`. func BalanceOf(symbol string, owner address) int64 { return tokenOf(symbol).BalanceOf(owner) } // Allowance returns what `owner` let `spender` draw of `symbol`. func Allowance(symbol string, owner, spender address) int64 { return tokenOf(symbol).Allowance(owner, spender) } // TotalSupply returns how much of `symbol` has been claimed so far. func TotalSupply(symbol string) int64 { return tokenOf(symbol).TotalSupply() } func tokenOf(symbol string) *grc20.Token { switch symbol { case "RED": return Red case "BLUE": return Blue } panic("unknown symbol " + symbol + " (RED or BLUE)") } func ledgerOf(symbol string) *grc20.PrivateLedger { switch symbol { case "RED": return redLedger case "BLUE": return blueLedger } panic("unknown symbol " + symbol + " (RED or BLUE)") } // caller is the account or realm that crossed into this one. func caller(cur realm) address { if !cur.IsCurrent() { panic("grc20faucet: stale realm token") } return cur.Previous().Address() } func checkErr(err error) { if err != nil { panic(err) } } func Render(path string) string { s := "# GRC20 faucet: RED and BLUE\n\n" s += "Two worthless tokens to experiment on. `Claim` gives you " s += ufmt.Sprintf("%d of each, once every %d blocks.\n\n", ClaimAmount, ClaimEvery) s += "| token | symbol | decimals | supply | registry key |\n" s += "|---|---|---|---|---|\n" s += ufmt.Sprintf("| %s | RED | %d | %d | `%s` |\n", Red.GetName(), Red.GetDecimals(), Red.TotalSupply(), RedKey) s += ufmt.Sprintf("| %s | BLUE | %d | %d | `%s` |\n", Blue.GetName(), Blue.GetDecimals(), Blue.TotalSupply(), BlueKey) s += "\n## Use it\n\n" s += "```\n" s += "gnokey maketx call -pkgpath gno.land/r/moul/x/grc20faucet/v0 -func Claim ...\n" s += "gnokey maketx call -pkgpath gno.land/r/moul/x/grc20faucet/v0 -func Approve \\\n" s += " -args RED -args <spender> -args 1000000 ...\n" s += "```\n\n" s += "Both tokens are registered in [grc20reg](/r/nt/grc20reg/v0), so a realm can\n" s += "move them by key without importing this one. That is what\n" s += "[grc20wrapdemo](/r/moul/x/grc20wrapdemo/v0) does with them.\n" if path != "" { s += "\n---\n\n" s += ufmt.Sprintf("Balance of `%s`: RED %d, BLUE %d\n", path, Red.BalanceOf(address(path)), Blue.BalanceOf(address(path))) } return s }
- #6faucet_test.gno
- #7package grc20faucet import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/r/nt/grc20reg/v0" ) // testing.SetRealm governs the crossing calls made from the frame that called // it, so switching accounts has to be written inline in each test, never // through a helper that does not itself cross. func TestClaimAndCooldown(cur realm, t *testing.T) { alice := testutils.TestAddress("f-alice") testing.SetRealm(testing.NewUserRealm(alice)) _, ok := NextClaim(alice) uassert.False(t, ok, "no cooldown before the first claim") Claim(cross(cur)) uassert.Equal(t, ClaimAmount, BalanceOf("RED", alice)) uassert.Equal(t, ClaimAmount, BalanceOf("BLUE", alice)) next, ok := NextClaim(alice) uassert.True(t, ok, "a cooldown is recorded") uassert.AbortsContains(t, cur, "too soon", func() { Claim(cross(cur)) }) testing.SkipHeights(ClaimEvery + 1) Claim(cross(cur)) uassert.Equal(t, 2*ClaimAmount, BalanceOf("RED", alice)) after, _ := NextClaim(alice) uassert.True(t, after > next, "the cooldown moved forward") } func TestTransferAndAllowance(cur realm, t *testing.T) { alice := testutils.TestAddress("f-tx-alice") bob := testutils.TestAddress("f-tx-bob") carl := testutils.TestAddress("f-tx-carl") testing.SetRealm(testing.NewUserRealm(alice)) Claim(cross(cur)) Transfer(cross(cur), "RED", bob, 400_000) uassert.Equal(t, int64(400_000), BalanceOf("RED", bob)) uassert.Equal(t, ClaimAmount-400_000, BalanceOf("RED", alice)) Approve(cross(cur), "BLUE", carl, 250_000) uassert.Equal(t, int64(250_000), Allowance("BLUE", alice, carl)) testing.SetRealm(testing.NewUserRealm(carl)) TransferFrom(cross(cur), "BLUE", alice, bob, 250_000) uassert.Equal(t, int64(250_000), BalanceOf("BLUE", bob)) uassert.Equal(t, int64(0), Allowance("BLUE", alice, carl)) uassert.AbortsContains(t, cur, "insufficient allowance", func() { TransferFrom(cross(cur), "BLUE", alice, bob, 1) }) } func TestUnknownSymbolIsRefused(cur realm, t *testing.T) { who := testutils.TestAddress("f-unknown") testing.SetRealm(testing.NewUserRealm(who)) uassert.PanicsContains(t, cur, "unknown symbol GREEN", func() { BalanceOf("GREEN", who) }) uassert.AbortsContains(t, cur, "unknown symbol GREEN", func() { Transfer(cross(cur), "GREEN", who, 1) }) } // Both tokens are in the registry under their own realm and symbol, which is // what lets another realm move them without importing this one. func TestBothTokensAreRegistered(t *testing.T) { uassert.Equal(t, "gno.land/r/moul/x/grc20faucet/v0.RED", RedKey) uassert.Equal(t, "gno.land/r/moul/x/grc20faucet/v0.BLUE", BlueKey) uassert.Equal(t, "Red Token", grc20reg.MustGet(RedKey).GetName()) uassert.Equal(t, "Blue Token", grc20reg.MustGet(BlueKey).GetName()) uassert.Equal(t, 4, grc20reg.MustGet(BlueKey).GetDecimals()) } func TestRender(cur realm, t *testing.T) { who := testutils.TestAddress("f-render") testing.SetRealm(testing.NewUserRealm(who)) Claim(cross(cur)) home := Render("") uassert.True(t, strings.Contains(home, "# GRC20 faucet: RED and BLUE"), home) uassert.True(t, strings.Contains(home, "| Red Token | RED | 4 |"), home) uassert.True(t, strings.Contains(home, RedKey), "the registry key is discoverable") page := Render(who.String()) uassert.True(t, strings.Contains(page, "Balance of"), page) uassert.True(t, strings.Contains(page, "RED 1000000"), page) }
- #8gnomod.toml
- #9module = "gno.land/r/moul/x/grc20faucet/v0" gno = "0.9" # public: its init registers the two tokens it owns with r/nt/grc20reg, so # this realm's own objects have to be reachable from that one. Measured: # with private = true, `make test PKG=grc20faucet` panics with # "cannot persist object from the private realm".
- Attached funds
- 5000000ugnot
Arguments · 9
- #1aaa
- #2README.md
- #3# `gno.land/r/moul/x/pairs/aaa/v0` A throwaway GRC20 (AAA, 6 decimals) with an **open faucet**, used to exercise the pair instances in this repo. No value, no owner controls, anyone can mint. `Faucet` mints 1000 AAA to the caller. Registered in `r/nt/grc20reg/v0` at deploy, so its key is `gno.land/r/moul/x/pairs/aaa/v0.AAA`, which is what an instance realm is configured with. <!-- 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 -->
- #4aaa.gno
- #5// Package aaa is a throwaway GRC20 with an open faucet, used to exercise the // pair instances in this repo. It has no value and no owner controls. package aaa import ( "gno.land/p/nt/grc20/v0" "gno.land/r/nt/grc20reg/v0" ) var ( Token *grc20.Token privateLedger *grc20.PrivateLedger userTeller grc20.Teller ) // Key is this token's grc20reg key, the string an instance realm is // configured with. const Key = "gno.land/r/moul/x/pairs/aaa/v0.AAA" func init(cur realm) { Token, privateLedger = grc20.NewToken("Test Alpha", "AAA", 6, 0, cur) userTeller = privateLedger.CallerTeller() grc20reg.Register(cross(cur), Token, "") } // Faucet mints a fixed amount to the caller. Unrestricted on purpose. func Faucet(cur realm) { if err := privateLedger.Mint(cur.Previous().Address(), 1_000_000_000); err != nil { panic(err) } } func TotalSupply() int64 { return userTeller.TotalSupply() } func BalanceOf(owner address) int64 { return userTeller.BalanceOf(owner) } func Allowance(owner, spender address) int64 { return userTeller.Allowance(owner, spender) } func Transfer(cur realm, to address, amount int64) { if err := userTeller.Transfer(0, cur, to, amount); err != nil { panic(err) } } func Approve(cur realm, spender address, amount int64) { if err := userTeller.Approve(0, cur, spender, amount); err != nil { panic(err) } } func Render(string) string { return "# AAA\n\nThrowaway test token for the pair instances. `Faucet` mints 1000 AAA to you.\n" }
- #6aaa_test.gno
- #7package aaa import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) func TestFaucetAndRender(cur realm, t *testing.T) { user := testutils.TestAddress("aaa-user") testing.SetRealm(testing.NewUserRealm(user)) Faucet(cross(cur)) uassert.Equal(t, int64(1_000_000_000), BalanceOf(user)) uassert.True(t, strings.Contains(Render(""), "# AAA")) uassert.Equal(t, "gno.land/r/moul/x/pairs/aaa/v0.AAA", Key) }
- #8gnomod.toml
- #9module = "gno.land/r/moul/x/pairs/aaa/v0" gno = "0.9" # public: a demo GRC20 whose whole purpose is to be imported by a pair # instance, and whose token object is registered with r/nt/grc20reg.
- Attached funds
- 5000000ugnot
Arguments · 9
- #1bbb
- #2README.md
- #3# `gno.land/r/moul/x/pairs/bbb/v0` A throwaway GRC20 (BBB, 6 decimals) with an **open faucet**, used to exercise the pair instances in this repo. No value, no owner controls, anyone can mint. `Faucet` mints 1000 BBB to the caller. Registered in `r/nt/grc20reg/v0` at deploy, so its key is `gno.land/r/moul/x/pairs/bbb/v0.BBB`, which is what an instance realm is configured with. <!-- 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 -->
- #4bbb.gno
- #5// Package bbb is a throwaway GRC20 with an open faucet, used to exercise the // pair instances in this repo. It has no value and no owner controls. package bbb import ( "gno.land/p/nt/grc20/v0" "gno.land/r/nt/grc20reg/v0" ) var ( Token *grc20.Token privateLedger *grc20.PrivateLedger userTeller grc20.Teller ) // Key is this token's grc20reg key, the string an instance realm is // configured with. const Key = "gno.land/r/moul/x/pairs/bbb/v0.BBB" func init(cur realm) { Token, privateLedger = grc20.NewToken("Test Beta", "BBB", 6, 1, cur) userTeller = privateLedger.CallerTeller() grc20reg.Register(cross(cur), Token, "") } // Faucet mints a fixed amount to the caller. Unrestricted on purpose. func Faucet(cur realm) { if err := privateLedger.Mint(cur.Previous().Address(), 1_000_000_000); err != nil { panic(err) } } func TotalSupply() int64 { return userTeller.TotalSupply() } func BalanceOf(owner address) int64 { return userTeller.BalanceOf(owner) } func Allowance(owner, spender address) int64 { return userTeller.Allowance(owner, spender) } func Transfer(cur realm, to address, amount int64) { if err := userTeller.Transfer(0, cur, to, amount); err != nil { panic(err) } } func Approve(cur realm, spender address, amount int64) { if err := userTeller.Approve(0, cur, spender, amount); err != nil { panic(err) } } func Render(string) string { return "# BBB\n\nThrowaway test token for the pair instances. `Faucet` mints 1000 BBB to you.\n" }
- #6bbb_test.gno
- #7package bbb import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) func TestFaucetAndRender(cur realm, t *testing.T) { user := testutils.TestAddress("bbb-user") testing.SetRealm(testing.NewUserRealm(user)) Faucet(cross(cur)) uassert.Equal(t, int64(1_000_000_000), BalanceOf(user)) uassert.True(t, strings.Contains(Render(""), "# BBB")) uassert.Equal(t, "gno.land/r/moul/x/pairs/bbb/v0.BBB", Key) }
- #8gnomod.toml
- #9module = "gno.land/r/moul/x/pairs/bbb/v0" gno = "0.9" # public: a demo GRC20 whose whole purpose is to be imported by a pair # instance, and whose token object is registered with r/nt/grc20reg.
- Attached funds
- 17000000ugnot
- Package
- gno.land/r/moul/x/reaper/v1
Arguments · 11
- #1reaper
- #2README.md
- #3# `gno.land/r/moul/x/reaper/v1` 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 | ## Reading it from outside: every number is a query `v0` is live at [`gno.land/r/moul/x/reaper/v0`](https://gno.land/r/moul/x/reaper/v0); `v1` is this source, and is not published yet. A package path is immutable once deployed, so the escaping fix below could not reach `v0` and is the reason `v1` exists. None of what follows needs a key, a transaction or a gas budget. `Reapable`, `Compactable` and `Bounty` are in the ABI precisely so the incentive is legible to a reader *before* anyone signs anything: a bot decides whether to spend gas entirely from free reads. ```bash RPC=https://rpc.gno.land hx() { printf '%s' "$1" | xxd -p | tr -d '\n'; } q() { curl -s "$RPC/abci_query?path=%22$1%22&data=0x$(hx "$2")" | python3 -c ' import base64, json, sys r = json.load(sys.stdin)["result"]["response"] r = r.get("ResponseBase") or r print(base64.b64decode(r["Data"]).decode() if r.get("Data") else r.get("Log"))'; } R=gno.land/r/moul/x/reaper/v0 ``` | Call | What it answers | |---|---| | `q vm%2Fqeval "$R.Reapable()"` | how many notes have expired, so a reaper knows whether to bother | | `q vm%2Fqeval "$R.Compactable()"` | how many dead tree nodes a `Compact` would free: a separate call, and usually the larger payout | | `q vm%2Fqeval "$R.Bounty().String()"` | the whole decision on one line: bytes, refund, gas, break-even, verdict | | `q vm%2Fqstorage "$R"` | the chain's own figure for what this realm has locked, which is not the realm's estimate | | `q vm%2Fqrender "$R:"` | the page | `gnokey query vm/qeval -remote https://rpc.gno.land -data '<expr>'` is the same call with a binary in front of it. On 2026-09-22, with three expired notes sitting on the board: ``` $ q vm%2Fqeval "$R.Bounty().String()" ("37 bytes, refunds 0.0037 GNOT against 0.005 GNOT of gas, break-even 50 bytes: not worth it yet" string) $ q vm%2Fqstorage "$R" storage: 24435, deposit: 2443500 ``` Read those two together, because the gap between them is the one thing this realm cannot close from the inside. `Bounty` prices 37 bytes, estimated from the length of the note bodies that have expired. `vm/qstorage` reports what the chain has actually locked against the realm, 24,435 bytes and 2.4435 GNOT, for everything it owns including its own source. **A realm cannot see that number about itself; a querier can.** So price a reap off `Bounty` and settle against the chain, never the other way round. Three expired notes are also, deliberately, not worth reaping: 0.0037 GNOT of refund against 0.005 GNOT of gas. The 50-byte break-even is the mechanism working rather than failing. A bounty that paid out below its own cost would be paying bots to churn. ### What it has actually paid, on mainnet One `Reap` and one `Compact` have run on `gnoland-1`, at heights 228101 and 228113 on 2026-09-21. The chain's own numbers, not the realm's estimate: | height | call | bytes freed | refunded | gas used | |---|---|---|---|---| | 228101 | `Reap(100)` | 3,945 | 0.3945 GNOT | 5,732,152 | | 228113 | `Compact()` | 2,568 | 0.2568 GNOT | 4,684,883 | At the floor gas price those two transactions cost 0.005732 and 0.004685 GNOT, so the refund was **69x** and **55x** the cost of collecting it. The compaction row is the one to notice: a second transaction, over notes that had already been deleted, returned 65% as much again as the deletion itself. Both were signed by the account that posted the notes, so what mainnet has demonstrated so far is the accounting and not yet the stranger. The refund goes to whoever signs, either way, which is exactly why the stranger case pays. **The gas fee is flat, and sizing it is the reaper's whole job.** tm2 deducts the declared `-gas-fee` in full in the ante handler and never refunds the unused part, so a reap costs what you ask for and not what you spend. Both transactions above asked for 1,000,000,000 gas and paid 1 GNOT to do 5.7 and 4.7 million gas of work, which turned a 69x win into a loss. Size `-gas-wanted` to the batch and set `-gas-fee` at the floor price, which is what `Bounty` already assumes. The same queries run in CI. [`example_test.gno`](./example_test.gno) pins each one, and it pins *only* queries for a reason worth knowing: an example function takes no arguments, so it never receives a `cur realm` and can never `Post`, `Reap` or `Compact`. What an example can reach is exactly what a reader of the live realm can reach without a key. ## 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. ## A note body is attacker-controlled, and the board renders it Anyone who pays the deposit chooses the bytes, and `Render` puts them on a page every reader of the realm loads. That makes a note body untrusted input landing in an **inline** markdown slot, the same category as a username or a post title, and the board never emits one unescaped. One call does it: ```go func summarize(body string) string { return ui.Excerpt(strings.ReplaceAll(body, "|", " "), 48) } ``` [`ui.Excerpt`](https://github.com/moul/gno-contracts/blob/main/p/moul/kit/ui/ui.gno) owns both halves, the length cap and the escaping, and does them in the order that is safe. It delegates the escaping itself to [`p/nt/markdown/sanitize`](https://github.com/gnolang/gno/tree/master/examples/gno.land/p/nt/markdown/sanitize/v0). This realm shipped without that, and it is worth naming what the gap actually allowed, because the hand-rolled version looked like it was doing the job: ```go // what the board used to do, and what it stopped: only "\n" and "|" oneLine := strings.ReplaceAll(strings.ReplaceAll(body, "\n", " "), "|", " ") ``` | a note containing | rendered as | so a poster could | |---|---|---| | `[Claim 100 GNOT](https://evil.example)` | a live link | phish every reader, with the realm's page as the attribution | | `` | an image request | log the IP of everyone who opened the board | | `<gno-columns>`, `<h5>` | gnoweb chrome | lay out the page, or forge a section heading | | `\r`, U+2028, U+2029 | a line break | leave the bullet item and emit top-level markdown | | U+202E, U+200B | nothing visible | reorder what a reader sees away from what the bytes say | The escaper closes all five, and none of them were a bug in the incentive design: the storage accounting was right, the rendering was not. Two details are easy to get backwards, which is exactly why they live in `ui.Excerpt` and not at this call site: - **Cut first, escape second.** Escaping works by inserting backslashes, so cutting an already-escaped string can strand a trailing lone backslash that escapes the chrome appended after it. - **Cut on a rune boundary.** Slicing a body at byte 48 splits a multi-byte character in half and puts invalid UTF-8 on the page. One emoji in a note was enough. The general rule, for any realm that renders something a caller stored: reach for `ui.Inline`, `ui.Cell` or `ui.Excerpt`, and drop to `sanitize.*` only for a slot they do not cover (a multi-paragraph body, a URL, a code fence). Its package doc carries the slot table. ## 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" "gno.land/p/moul/x/storagecost/v0" "gno.land/p/nt/ufmt/v0" ) // Every Example in this file is a *query*, and that is a constraint rather // than a choice: an example function takes no arguments, so it never receives // a `cur realm` and can never call Post, Reap or Compact. What is left is // exactly the surface a reader of the live realm has without a key, so each // one carries the `vm/qeval` line that produces the same answer on chain: // // gnokey query vm/qeval -remote https://rpc.gno.land \ // -data 'gno.land/r/moul/x/reaper/v0.<expr>' // // The board is empty by the time these run, because every Test in this // package drains what it posted and examples run after all of them. // 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. // // vm/qrender gno.land/r/moul/x/reaper/v0: // // 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. } // ExampleReapable is the poll a reaping bot runs before it spends anything: // three counters, three queries, no key and no gas. // // vm/qeval gno.land/r/moul/x/reaper/v0.Live() // vm/qeval gno.land/r/moul/x/reaper/v0.Reapable() // vm/qeval gno.land/r/moul/x/reaper/v0.Compactable() // // Reapable and Compactable are separate numbers because reaping and // compacting are separate transactions, and compaction returns nothing at all // while a live note still sits below the dead ones. A bot that sees // `reapable 0, compactable 0` should not sign anything, which is the answer // here. func ExampleReapable() { print(ufmt.Sprintf("live %d, reapable %d, compactable %d\n", Live(), Reapable(), Compactable())) // Output: // live 0, reapable 0, compactable 0 } // ExampleBounty is the single query that decides whether reaping is worth // doing, and on an empty board it has to refuse to advertise a profit. // // vm/qeval gno.land/r/moul/x/reaper/v0.Bounty().String() // // Fee is what the reaping transaction costs at the floor gas price, and the // chain charges it whether or not anything is freed. BreakEven turns that fee // back into bytes: free fewer than 50 and the reaper is paying to tidy up. // That threshold, not the size of the board, is what makes the mechanism a // market rather than a chore. func ExampleBounty() { print(Bounty().String() + "\n") // Output: // 0 bytes, refunds 0 GNOT against 0.005 GNOT of gas, break-even 50 bytes: not worth it yet } // ExampleBounty_worth is the same query answered by a board worth draining: // ten expired notes of a kilobyte each. // // It recomputes the quote instead of posting, because an example has no // `cur realm` to post with. The arithmetic is the one Bounty applies, so the // numbers are the ones the live query would return with that board on it. // // This is the whole concept in two lines. The poster locked 1.8944 GNOT to // occupy that space; a stranger who never posted anything spends 0.005 GNOT of // gas to delete it and the chain hands them the deposit. Nobody minted a // token, and no pool was funded. func ExampleBounty_worth() { q := storagecost.EvaluateAtFloor(storagecost.EstimateBytes(10*1024), gasWantedReap) print(q.String() + "\n") print(ufmt.Sprintf("the reaper is paid %dx what the transaction costs\n", q.Refund/q.Fee)) // Output: // 18944 bytes, refunds 1.8944 GNOT against 0.005 GNOT of gas, break-even 50 bytes: worth 1.8894 GNOT // the reaper is paid 378x what the transaction costs }
- #6gnomod.toml
- #7module = "gno.land/r/moul/x/reaper/v1" gno = "0.9" private = true
- #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. // - gno.land/p/moul/kit/ui/v0 owns display: the escaping, the length cap // and the address format. A note body is arbitrary bytes chosen by // whoever paid to post it and is rendered back to every reader, so it is // attacker-controlled input in a markdown slot. Render never emits a body // unescaped; ui.Excerpt is what makes that true, and it delegates to // p/nt/markdown/sanitize. // // 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/kit/ui/v0" "gno.land/p/moul/md/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(ui.Action("Reap them", "Reap", "limit", "100") + "\n\n") } 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%s\n\n", dead, ui.Action("Compact", "Compact"))) } b.WriteString(md.H2("Board")) b.WriteString("\n") if notes.Size() == 0 { b.WriteString("Empty. " + ui.Action("Post the first note", "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), ui.Addr(n.Author), 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 is the board's one-line view of a note. // // A body is whatever bytes a poster paid to store, so on the board it is // attacker-controlled input in an inline markdown slot. ui.Excerpt owns both // halves of that: the rune-boundary cut and the escaping, in the order that is // safe. This realm shipped a hand-rolled version that did neither, and the // replacement for it is one call. func summarize(body string) string { const width = 48 // Cosmetic, not a defense: "|" is the column separator of the row this // lands in. The escaper deliberately leaves pipes literal, which is right, // since a lone pipe cannot open a GFM table with no delimiter row. return ui.Excerpt(strings.ReplaceAll(body, "|", " "), width) }
- #10reaper_test.gno
- #11package reaper import ( "strings" "testing" "unicode/utf8" "gno.land/p/moul/kit/ui/v0" "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, in the house address format, which is what makes // "the poster paid" visible without 40 opaque characters per row. uassert.True(t, strings.Contains(out, ui.Addr(poster)), 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, ui.Ellipsis), 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) } // TestSummarizeNeutralizesAnAttackerAuthoredBody is the regression test for a // hole this realm shipped with: Render emitted a note body into the board // after nothing but a ReplaceAll of "\n" and "|", so anyone who paid to post // could put arbitrary markdown on a page every reader of the realm loads. // // The body is the one string here an attacker controls and the board is an // INLINE slot, so sanitize.InlineText owns it. Each case asserts the dangerous // SEQUENCE is dead and the readable text survived, rather than pinning the // exact escaped bytes, which are the sanitizer's business and not this realm's. func TestSummarizeNeutralizesAnAttackerAuthoredBody(t *testing.T) { cases := []struct { name string body string gone []string // sequences that must not survive into the page kept string // the readable text, which must }{ { // The payload that matters: a phishing link rendered as prose, // attributed by the page to the realm rather than to the poster. name: "inline link", body: "[Claim your 100 GNOT](https://evil.example/drain)", gone: []string{"](", "](https"}, kept: "Claim your 100 GNOT", }, { // An image is a beacon: it fires on load and hands the attacker // the IP of everyone who opened the board. name: "image beacon", body: "", gone: []string{" stopped exactly this and nothing // else. Keep it dead: one newline ends the bullet item, and every // byte after it is top-level markdown. name: "break out of the list item", body: "ok\n# Reaper is deprecated, use /r/evil/reaper", gone: []string{"\n"}, kept: "Reaper is deprecated", }, { // Folding only "\n" left the other three line terminators open. name: "exotic line terminators", body: "ok\r\n- fake row\u2028- another\u2029- third", gone: []string{"\r", "\n", "\u2028", "\u2029"}, kept: "fake row", }, { // Invisible reordering: the bytes say one thing, the page shows // another, and a reader diffing the two sees nothing. name: "bidi and zero-width", body: "safe\u202enote\u200b\u202c", gone: []string{"\u202e", "\u200b", "\u202c"}, kept: "safe", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { out := summarize(tc.body) for _, bad := range tc.gone { uassert.False(t, strings.Contains(out, bad), tc.name+": a dangerous sequence survived into "+out) } uassert.True(t, strings.Contains(out, tc.kept), tc.name+": escaping ate the readable text: "+out) uassert.True(t, utf8.ValidString(out), tc.name+": invalid UTF-8: "+out) }) } } // TestSummarizeEscapesAnUnbalancedBracket covers the cross-row case: a "[" in // one note and a "]" in another used to let two cheap notes bracket every row // between them into one link. func TestSummarizeEscapesAnUnbalancedBracket(t *testing.T) { uassert.Equal(t, "\\[", summarize("[")) uassert.Equal(t, "\\]", summarize("]")) uassert.Equal(t, "\\[", ui.Excerpt("[", 48)) } // TestSummarizeCutsOnARuneBoundary pins the other half of the old bug: the // truncation sliced at a byte offset, so a body of multi-byte characters put // half a character on the page. One emoji in a note was enough. func TestSummarizeCutsOnARuneBoundary(t *testing.T) { // The leading "x" puts byte 48, the old cut point, in the middle of a // 4-byte rune rather than on a boundary. out := summarize("x" + strings.Repeat("\U0001F33E", 60)) uassert.True(t, utf8.ValidString(out), "cut mid-rune: "+out) uassert.True(t, strings.HasSuffix(out, ui.Ellipsis), "long body should be elided: "+out) // A short body comes back whole, with no elision and nothing to escape. short := summarize("plain note") uassert.Equal(t, "plain note", short) } // TestRenderEscapesTheBoard is the end-to-end half: the sanitizer is only // worth anything if Render is what calls it, and the board is the one place a // stranger's bytes reach the page. func TestRenderEscapesTheBoard(cur realm, t *testing.T) { clear(cur, t) testing.SetRealm(testing.NewUserRealm(poster)) Post(cross(cur), "[Claim 100 GNOT](https://evil.example)\n# Official", 0) out := Render("") uassert.False(t, strings.Contains(out, "](https://evil.example)"), out) uassert.False(t, strings.Contains(out, "\n# Official"), out) // The note stays legible, just inert: the text survives, the chrome does not. uassert.True(t, strings.Contains(out, "Claim 100 GNOT"), out) uassert.True(t, utf8.ValidString(out), "Render emitted invalid UTF-8") testing.SetRealm(testing.NewUserRealm(stranger)) Reap(cross(cur), 100) Compact(cross(cur)) }
Result log
msg:0,success:true,log:,events:[] msg:1,success:true,log:,events:[] msg:2,success:true,log:,events:[] msg:3,success:true,log:,events:[] msg:4,success:true,log:,events:[] msg:5,success:true,log:,events:[] msg:6,success:true,log:,events:[] msg:7,success:true,log:,events:[] msg:8,success:true,log:,events:[] msg:9,success:true,log:,events:[] msg:10,success:true,log:,events:[] msg:11,success:true,log:,events:[] msg:12,success:true,log:,events:[] msg:13,success:true,log:,events:[] msg:14,success:true,log:,events:[] msg:15,success:true,log:,events:[] msg:16,success:true,log:,events:[] msg:17,success:true,log:,events:[] msg:18,success:true,log:,events:[] msg:19,success:true,log:,events:[] msg:20,success:true,log:,events:[] msg:21,success:true,log:,events:[] msg:22,success:true,log:,events:[] msg:23,success:true,log:,events:[] msg:24,success:true,log:,events:[] msg:25,success:true,log:,events:[] msg:26,success:true,log:,events:[] msg:27,success:true,log:,events:[]